프로필 개선
This commit is contained in:
parent
49dd1e5a6f
commit
39674c7be7
|
|
@ -0,0 +1,29 @@
|
|||
package com.pandoli365.bibimbap.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
@Configuration
|
||||
public class UploadResourceConfig implements WebMvcConfigurer {
|
||||
|
||||
@Value("${app.upload.game-storage-path:src/main/resources/static}")
|
||||
private String gameStoragePath;
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
Path uploadPath = Paths.get(gameStoragePath).toAbsolutePath().normalize();
|
||||
String uploadLocation = uploadPath.toUri().toString();
|
||||
String profileLocation = uploadPath.resolve("profile").toUri().toString();
|
||||
String gameLocation = uploadPath.resolve("game").toUri().toString();
|
||||
|
||||
registry.addResourceHandler("/profile/**")
|
||||
.addResourceLocations(profileLocation);
|
||||
registry.addResourceHandler("/game/**")
|
||||
.addResourceLocations(uploadLocation, gameLocation);
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,14 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
|
|||
"error", "login", "profile", "signup", ""
|
||||
);
|
||||
|
||||
@GetMapping("/game/new")
|
||||
public ModelAndView gameRegisterView(HttpSession session) {
|
||||
if (!isLoggedIn(session)) {
|
||||
return new ModelAndView("redirect:/login");
|
||||
}
|
||||
return new ModelAndView("/game-register");
|
||||
}
|
||||
|
||||
@GetMapping("/{pageName}")
|
||||
public ModelAndView mainView(@PathVariable("pageName") String pageName,
|
||||
@RequestParam(name = "id", required = false) String id,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
package com.pandoli365.bibimbap.controller.api;
|
||||
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/game-files")
|
||||
public class GameUploadController {
|
||||
|
||||
@Value("${app.upload.game-storage-path:src/main/resources/static/game}")
|
||||
private String gameStoragePath;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<Map<String, Object>> uploadGameFiles(
|
||||
@RequestParam("files") MultipartFile[] files,
|
||||
@RequestParam(name = "path", required = false) String path,
|
||||
HttpSession session
|
||||
) throws IOException {
|
||||
if (session == null || session.getAttribute("userId") == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("message", "login is required"));
|
||||
}
|
||||
if (files == null || files.length == 0) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "files is required"));
|
||||
}
|
||||
|
||||
Path root = Paths.get(gameStoragePath).toAbsolutePath().normalize();
|
||||
List<Map<String, String>> uploadedFiles = new ArrayList<>();
|
||||
for (MultipartFile file : files) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Path targetFile;
|
||||
try {
|
||||
targetFile = resolveTargetFile(root, path, file, files.length);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("message", "invalid path"));
|
||||
}
|
||||
|
||||
Files.createDirectories(targetFile.getParent());
|
||||
Files.copy(file.getInputStream(), targetFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
String relativePath = root.relativize(targetFile).toString().replace('\\', '/');
|
||||
uploadedFiles.add(Map.of(
|
||||
"fileName", targetFile.getFileName().toString(),
|
||||
"path", relativePath,
|
||||
"url", "/game/" + relativePath
|
||||
));
|
||||
}
|
||||
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("storagePath", root.toString());
|
||||
response.put("files", uploadedFiles);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
private Path resolveTargetFile(Path root, String path, MultipartFile file, int fileCount) {
|
||||
String safePath = path == null ? "" : path.trim().replace('\\', '/');
|
||||
while (safePath.startsWith("/")) {
|
||||
safePath = safePath.substring(1);
|
||||
}
|
||||
|
||||
boolean usePathAsDirectory = safePath.isBlank() || safePath.endsWith("/") || fileCount > 1;
|
||||
Path targetFile = usePathAsDirectory
|
||||
? root.resolve(safePath).resolve(uniqueFileName(file.getOriginalFilename()))
|
||||
: root.resolve(safePath);
|
||||
targetFile = targetFile.normalize();
|
||||
if (!targetFile.startsWith(root) || targetFile.getParent() == null) {
|
||||
throw new IllegalArgumentException("invalid path");
|
||||
}
|
||||
return targetFile;
|
||||
}
|
||||
|
||||
private String uniqueFileName(String originalFileName) {
|
||||
String cleanName = originalFileName == null ? "file" : Paths.get(originalFileName).getFileName().toString();
|
||||
cleanName = cleanName.replaceAll("[^A-Za-z0-9._-]", "_");
|
||||
if (cleanName.isBlank() || ".".equals(cleanName) || "..".equals(cleanName)) {
|
||||
cleanName = "file";
|
||||
}
|
||||
return UUID.randomUUID() + "_" + cleanName;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper;
|
|||
import com.pandoli365.bibimbap.mapper.UsersMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
|
@ -14,9 +15,15 @@ import org.springframework.stereotype.Controller;
|
|||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.crypto.SecretKeyFactory;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.OffsetDateTime;
|
||||
|
|
@ -25,6 +32,7 @@ import java.util.Base64;
|
|||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Controller
|
||||
public class UserController {
|
||||
|
|
@ -38,11 +46,15 @@ public class UserController {
|
|||
private static final int PASSWORD_SALT_BYTES = 16;
|
||||
private static final int DEFAULT_SESSION_SECONDS = 60 * 30;
|
||||
private static final int REMEMBER_SESSION_SECONDS = 60 * 60 * 24 * 30;
|
||||
private static final long PROFILE_AVATAR_MAX_BYTES = 5L * 1024 * 1024;
|
||||
|
||||
private final UsersMapper usersMapper;
|
||||
private final UserAuthIdentitiesMapper userAuthIdentitiesMapper;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
@Value("${app.upload.game-storage-path:src/main/resources/static}")
|
||||
private String uploadStoragePath;
|
||||
|
||||
public UserController(UsersMapper usersMapper, UserAuthIdentitiesMapper userAuthIdentitiesMapper) {
|
||||
this.usersMapper = usersMapper;
|
||||
this.userAuthIdentitiesMapper = userAuthIdentitiesMapper;
|
||||
|
|
@ -151,6 +163,70 @@ public class UserController {
|
|||
return "redirect:/";
|
||||
}
|
||||
|
||||
@PostMapping("/profile/avatar")
|
||||
@Transactional
|
||||
public ResponseEntity<?> updateProfileAvatar(
|
||||
@RequestParam(name = "avatar", required = false) MultipartFile avatar,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
HttpSession session = request.getSession(false);
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(new AuthResult(HttpStatus.UNAUTHORIZED.value(), "로그인이 필요합니다."));
|
||||
}
|
||||
if (avatar == null || avatar.isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new AuthResult(HttpStatus.BAD_REQUEST.value(), "프로필 이미지를 선택해 주세요."));
|
||||
}
|
||||
if (avatar.getSize() > PROFILE_AVATAR_MAX_BYTES) {
|
||||
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
|
||||
.body(new AuthResult(HttpStatus.PAYLOAD_TOO_LARGE.value(), "프로필 이미지는 5MB 이하로 올려 주세요."));
|
||||
}
|
||||
|
||||
String extension = profileImageExtension(avatar);
|
||||
if (extension == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new AuthResult(HttpStatus.BAD_REQUEST.value(), "PNG, JPG, WEBP, GIF 이미지만 사용할 수 있습니다."));
|
||||
}
|
||||
|
||||
UserData user = usersMapper.getUser(userId);
|
||||
if (user == null || !STATUS_ACTIVE.equals(user.getStatus())) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(new AuthResult(HttpStatus.FORBIDDEN.value(), "사용할 수 없는 계정입니다."));
|
||||
}
|
||||
|
||||
try {
|
||||
Path storageRoot = Paths.get(uploadStoragePath).toAbsolutePath().normalize();
|
||||
Path profileRoot = storageRoot.resolve("profile").resolve(String.valueOf(userId)).normalize();
|
||||
if (!profileRoot.startsWith(storageRoot)) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new AuthResult(HttpStatus.BAD_REQUEST.value(), "저장 경로가 올바르지 않습니다."));
|
||||
}
|
||||
|
||||
Files.createDirectories(profileRoot);
|
||||
String fileName = UUID.randomUUID() + extension;
|
||||
Path targetFile = profileRoot.resolve(fileName).normalize();
|
||||
if (!targetFile.startsWith(profileRoot)) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new AuthResult(HttpStatus.BAD_REQUEST.value(), "저장 경로가 올바르지 않습니다."));
|
||||
}
|
||||
|
||||
Files.copy(avatar.getInputStream(), targetFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
String avatarUrl = "/profile/" + userId + "/" + fileName;
|
||||
|
||||
user.setAvatarUrl(avatarUrl);
|
||||
usersMapper.updateUser(user);
|
||||
updateAuthIdentityAvatar(session, avatarUrl);
|
||||
updateAvatarSession(session, avatarUrl);
|
||||
|
||||
return ResponseEntity.ok(new ProfileAvatarResult(200, "프로필 이미지가 저장되었습니다.", avatarUrl));
|
||||
} catch (IOException e) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(new AuthResult(HttpStatus.INTERNAL_SERVER_ERROR.value(), "프로필 이미지를 저장하지 못했습니다."));
|
||||
}
|
||||
}
|
||||
|
||||
private UserData createUser(String displayName, String canonicalEmail, String avatarUrl, OffsetDateTime loginAt) {
|
||||
UserData user = new UserData();
|
||||
user.setDisplayName(displayName);
|
||||
|
|
@ -194,6 +270,102 @@ public class UserController {
|
|||
return "true".equalsIgnoreCase(value) || "on".equalsIgnoreCase(value);
|
||||
}
|
||||
|
||||
private Long sessionUserId(HttpSession session) {
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
Object userId = session.getAttribute("userId");
|
||||
if (userId instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (userId instanceof String text) {
|
||||
try {
|
||||
return Long.parseLong(text);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String profileImageExtension(MultipartFile file) {
|
||||
String contentType = file.getContentType();
|
||||
if (contentType != null) {
|
||||
switch (contentType.toLowerCase(Locale.ROOT)) {
|
||||
case "image/png":
|
||||
return ".png";
|
||||
case "image/jpeg":
|
||||
case "image/jpg":
|
||||
return ".jpg";
|
||||
case "image/webp":
|
||||
return ".webp";
|
||||
case "image/gif":
|
||||
return ".gif";
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
String originalName = file.getOriginalFilename();
|
||||
if (originalName == null) {
|
||||
return null;
|
||||
}
|
||||
String cleanName = Paths.get(originalName).getFileName().toString().toLowerCase(Locale.ROOT);
|
||||
if (cleanName.endsWith(".png")) {
|
||||
return ".png";
|
||||
}
|
||||
if (cleanName.endsWith(".jpg") || cleanName.endsWith(".jpeg")) {
|
||||
return ".jpg";
|
||||
}
|
||||
if (cleanName.endsWith(".webp")) {
|
||||
return ".webp";
|
||||
}
|
||||
if (cleanName.endsWith(".gif")) {
|
||||
return ".gif";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void updateAuthIdentityAvatar(HttpSession session, String avatarUrl) {
|
||||
Object authIdentityId = session.getAttribute("authIdentityId");
|
||||
Long identityId = null;
|
||||
if (authIdentityId instanceof Number number) {
|
||||
identityId = number.longValue();
|
||||
} else if (authIdentityId instanceof String text) {
|
||||
try {
|
||||
identityId = Long.parseLong(text);
|
||||
} catch (NumberFormatException e) {
|
||||
identityId = null;
|
||||
}
|
||||
}
|
||||
if (identityId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
UserAuthIdentityData identity = userAuthIdentitiesMapper.getUserAuthIdentity(identityId);
|
||||
if (identity == null) {
|
||||
return;
|
||||
}
|
||||
identity.setAvatarUrl(avatarUrl);
|
||||
userAuthIdentitiesMapper.updateUserAuthIdentity(identity);
|
||||
}
|
||||
|
||||
private void updateAvatarSession(HttpSession session, String avatarUrl) {
|
||||
session.setAttribute("avatarUrl", avatarUrl);
|
||||
|
||||
Object account = session.getAttribute("account");
|
||||
if (account instanceof Map<?, ?> existingAccount) {
|
||||
Map<String, Object> updatedAccount = new LinkedHashMap<>();
|
||||
existingAccount.forEach((key, value) -> {
|
||||
if (key != null) {
|
||||
updatedAccount.put(String.valueOf(key), value);
|
||||
}
|
||||
});
|
||||
updatedAccount.put("avatarUrl", avatarUrl);
|
||||
session.setAttribute("account", updatedAccount);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveLoginSession(HttpSession session, UserData user, UserAuthIdentityData identity) {
|
||||
session.setAttribute("id", user.getId());
|
||||
session.setAttribute("userId", user.getId());
|
||||
|
|
@ -322,4 +494,7 @@ public class UserController {
|
|||
|
||||
public record AuthResult(int status, String message) {
|
||||
}
|
||||
|
||||
public record ProfileAvatarResult(int status, String message, String avatarUrl) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,3 +2,5 @@ spring.datasource.driver-class-name=org.postgresql.Driver
|
|||
spring.datasource.url=jdbc:postgresql://localhost:5432/bibimbap?currentSchema=dev
|
||||
spring.datasource.username=your_username
|
||||
spring.datasource.password=your_password
|
||||
|
||||
app.upload.game-storage-path=src/main/resources/static/game
|
||||
|
|
|
|||
|
|
@ -2,3 +2,5 @@ spring.datasource.driver-class-name=org.postgresql.Driver
|
|||
spring.datasource.url=jdbc:postgresql://localhost:5432/bibimbap?currentSchema=live
|
||||
spring.datasource.username=your_username
|
||||
spring.datasource.password=your_password
|
||||
|
||||
app.upload.game-storage-path=src/main/resources/static/game
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 433 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 487 KiB |
|
|
@ -0,0 +1,425 @@
|
|||
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
|
||||
<%@ page import="org.springframework.web.util.HtmlUtils" %>
|
||||
<!DOCTYPE html>
|
||||
<%
|
||||
String ctx = request.getContextPath();
|
||||
String rawDisplayName = session.getAttribute("displayName") == null ? "" : String.valueOf(session.getAttribute("displayName"));
|
||||
String creatorValue = HtmlUtils.htmlEscape(rawDisplayName);
|
||||
%>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
|
||||
<title>신규 게임 개시 | bibimbap</title>
|
||||
<style>
|
||||
html {
|
||||
color-scheme: light;
|
||||
--surface: #faf8f5;
|
||||
--card-bg: #fff;
|
||||
--text: #1a1a1a;
|
||||
--text-muted: #5c5c5c;
|
||||
--accent: #e8a54b;
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--card-media-1: #f0ebe3;
|
||||
--card-media-2: #e5ddd2;
|
||||
--card-media-3: #dccfb8;
|
||||
--card-shadow: rgba(0, 0, 0, 0.06);
|
||||
--button-text: #1a1a1a;
|
||||
--field-bg: #fff;
|
||||
}
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--surface: #121212;
|
||||
--card-bg: #1e1e1e;
|
||||
--text: #ece8e1;
|
||||
--text-muted: #a39e96;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--card-media-1: #2a2620;
|
||||
--card-media-2: #1f1c18;
|
||||
--card-media-3: #3d3528;
|
||||
--card-shadow: rgba(0, 0, 0, 0.35);
|
||||
--button-text: #1a1a1a;
|
||||
--field-bg: #181818;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.game-register-main {
|
||||
width: 100%;
|
||||
max-width: 72rem;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
padding: 2rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
|
||||
flex: 1;
|
||||
}
|
||||
.game-register-heading {
|
||||
width: min(100%, 56rem);
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
.game-register-heading__eyebrow {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.game-register-heading__title {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.game-register-layout {
|
||||
width: min(100%, 56rem);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 18rem;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
.game-register-panel,
|
||||
.game-preview {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg);
|
||||
box-shadow: 0 2px 8px var(--card-shadow);
|
||||
}
|
||||
.game-register-panel {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.game-form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.game-form__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.875rem;
|
||||
}
|
||||
.game-field {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.game-field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.game-field__label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.game-field__input,
|
||||
.game-field__textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--field-bg);
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
.game-field__input {
|
||||
height: 3rem;
|
||||
padding: 0 0.875rem;
|
||||
}
|
||||
.game-field__textarea {
|
||||
min-height: 8rem;
|
||||
resize: vertical;
|
||||
padding: 0.875rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.game-field__input::placeholder,
|
||||
.game-field__textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.game-field__input:focus,
|
||||
.game-field__textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.25);
|
||||
}
|
||||
.game-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.game-check input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.game-form__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.game-button {
|
||||
min-height: 2.875rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.game-button:hover {
|
||||
border-color: rgba(232, 165, 75, 0.45);
|
||||
box-shadow: 0 4px 12px var(--card-shadow);
|
||||
}
|
||||
.game-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.game-button--primary {
|
||||
border-color: transparent;
|
||||
background: var(--accent);
|
||||
color: var(--button-text);
|
||||
}
|
||||
.game-button svg {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
.game-preview {
|
||||
overflow: hidden;
|
||||
position: sticky;
|
||||
top: 5rem;
|
||||
}
|
||||
.game-preview__media {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 5;
|
||||
background: linear-gradient(160deg, var(--card-media-1) 0%, var(--card-media-2) 45%, var(--card-media-3) 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.game-preview__media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.game-preview__media img[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.game-preview__fallback {
|
||||
width: min(52%, 7.5rem);
|
||||
height: auto;
|
||||
opacity: 0.88;
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
html[data-theme="dark"] .game-preview__fallback {
|
||||
opacity: 0.92;
|
||||
filter: drop-shadow(0 2px 10px rgba(0, 0, 0, 0.45));
|
||||
}
|
||||
.game-preview__body {
|
||||
padding: 0.875rem 1rem 1rem;
|
||||
}
|
||||
.game-preview__name {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
.game-preview__creator,
|
||||
.game-preview__note {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
.game-preview__note {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.game-register-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.game-preview {
|
||||
position: static;
|
||||
width: min(100%, 24rem);
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.game-register-main {
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
.game-register-panel {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.game-form__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.game-form__actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
.game-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
||||
<main class="game-register-main">
|
||||
<section class="game-register-heading" aria-labelledby="game-register-title">
|
||||
<p class="game-register-heading__eyebrow">BIBIMBAP GAME</p>
|
||||
<h1 class="game-register-heading__title" id="game-register-title">신규 게임 개시</h1>
|
||||
</section>
|
||||
|
||||
<div class="game-register-layout">
|
||||
<section class="game-register-panel" aria-label="게임 등록 양식">
|
||||
<form class="game-form" action="<%= ctx %>/game/new" method="post" id="game-register-form">
|
||||
<div class="game-form__grid">
|
||||
<div class="game-field game-field--full">
|
||||
<label class="game-field__label" for="game-name">게임 이름</label>
|
||||
<input class="game-field__input" type="text" id="game-name" name="name" maxlength="80" autocomplete="off" required />
|
||||
</div>
|
||||
<div class="game-field">
|
||||
<label class="game-field__label" for="game-creator">제작자</label>
|
||||
<input class="game-field__input" type="text" id="game-creator" name="creator" maxlength="40" value="<%= creatorValue %>" autocomplete="name" required />
|
||||
</div>
|
||||
<div class="game-field">
|
||||
<label class="game-field__label" for="game-git-url">소스 코드 URL</label>
|
||||
<input class="game-field__input" type="url" id="game-git-url" name="gitUrl" placeholder="https://example.com/repository" autocomplete="url" />
|
||||
</div>
|
||||
<div class="game-field">
|
||||
<label class="game-field__label" for="game-webgl-path">WebGL 경로</label>
|
||||
<input class="game-field__input" type="text" id="game-webgl-path" name="webglPath" placeholder="/webgl/my-game/index.html" />
|
||||
</div>
|
||||
<div class="game-field">
|
||||
<label class="game-field__label" for="game-thumbnail-url">썸네일 URL</label>
|
||||
<input class="game-field__input" type="url" id="game-thumbnail-url" name="thumbnailUrl" placeholder="https://example.com/thumbnail.png" />
|
||||
</div>
|
||||
<div class="game-field game-field--full">
|
||||
<label class="game-field__label" for="game-creator-note">소개</label>
|
||||
<textarea class="game-field__textarea" id="game-creator-note" name="creatorNote" maxlength="600"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="game-check">
|
||||
<input type="checkbox" name="visible" value="true" checked />
|
||||
<span>목록에 공개</span>
|
||||
</label>
|
||||
|
||||
<div class="game-form__actions">
|
||||
<a class="game-button" href="<%= ctx %>/">취소</a>
|
||||
<button class="game-button game-button--primary" type="submit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 5v14"/>
|
||||
<path d="M5 12h14"/>
|
||||
</svg>
|
||||
<span>등록 요청</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="game-preview" aria-label="미리보기">
|
||||
<div class="game-preview__media">
|
||||
<img id="preview-thumb" alt="" hidden />
|
||||
<img class="game-preview__fallback" src="<%= ctx %>/images/logo.png" alt="" width="120" height="120" />
|
||||
</div>
|
||||
<div class="game-preview__body">
|
||||
<h2 class="game-preview__name" id="preview-name">게임 이름</h2>
|
||||
<p class="game-preview__creator" id="preview-creator"><%= creatorValue.isBlank() ? "제작자" : creatorValue %></p>
|
||||
<p class="game-preview__note" id="preview-note">소개</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.getElementById('game-register-form');
|
||||
var nameInput = document.getElementById('game-name');
|
||||
var creatorInput = document.getElementById('game-creator');
|
||||
var noteInput = document.getElementById('game-creator-note');
|
||||
var thumbnailInput = document.getElementById('game-thumbnail-url');
|
||||
var previewName = document.getElementById('preview-name');
|
||||
var previewCreator = document.getElementById('preview-creator');
|
||||
var previewNote = document.getElementById('preview-note');
|
||||
var previewThumb = document.getElementById('preview-thumb');
|
||||
|
||||
function valueOr(input, fallback) {
|
||||
var value = input && input.value ? input.value.trim() : '';
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
previewName.textContent = valueOr(nameInput, '게임 이름');
|
||||
previewCreator.textContent = valueOr(creatorInput, '제작자');
|
||||
previewNote.textContent = valueOr(noteInput, '소개');
|
||||
|
||||
var thumbnailUrl = valueOr(thumbnailInput, '');
|
||||
if (thumbnailUrl) {
|
||||
previewThumb.src = thumbnailUrl;
|
||||
previewThumb.hidden = false;
|
||||
} else {
|
||||
previewThumb.removeAttribute('src');
|
||||
previewThumb.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (nameInput) nameInput.addEventListener('input', updatePreview);
|
||||
if (creatorInput) creatorInput.addEventListener('input', updatePreview);
|
||||
if (noteInput) noteInput.addEventListener('input', updatePreview);
|
||||
if (thumbnailInput) thumbnailInput.addEventListener('input', updatePreview);
|
||||
|
||||
if (previewThumb) {
|
||||
previewThumb.addEventListener('error', function () {
|
||||
previewThumb.removeAttribute('src');
|
||||
previewThumb.hidden = true;
|
||||
});
|
||||
}
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('submit', function (ev) {
|
||||
ev.preventDefault();
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
|
||||
window.BibimbapModal.alert({
|
||||
title: '저장 준비 중',
|
||||
message: '저장 로직은 다음 단계에서 연결됩니다.',
|
||||
confirmText: '확인'
|
||||
});
|
||||
} else {
|
||||
alert('저장 로직은 다음 단계에서 연결됩니다.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updatePreview();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -5,6 +5,10 @@
|
|||
boolean headerLoggedIn = headerSession != null && headerSession.getAttribute("userId") != null;
|
||||
String headerProfileHref = request.getContextPath() + (headerLoggedIn ? "/profile" : "/login");
|
||||
String headerProfileLabel = headerLoggedIn ? "프로필" : "로그인";
|
||||
String headerAvatarUrl = headerSession != null && headerSession.getAttribute("avatarUrl") != null
|
||||
? String.valueOf(headerSession.getAttribute("avatarUrl"))
|
||||
: "";
|
||||
boolean headerHasAvatar = headerLoggedIn && !headerAvatarUrl.isBlank();
|
||||
%>
|
||||
<style>
|
||||
/* 기본(라이트) 헤더 — data-theme 없을 때도 동일 톤 */
|
||||
|
|
@ -125,9 +129,29 @@
|
|||
background: var(--accent);
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.site-header__profile--login {
|
||||
width: auto;
|
||||
min-width: 4.25rem;
|
||||
border-radius: 10px;
|
||||
padding: 0 0.875rem;
|
||||
background: transparent;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.site-header__profile--avatar {
|
||||
overflow: hidden;
|
||||
background: var(--header-btn-hover);
|
||||
border: 1px solid var(--header-border);
|
||||
}
|
||||
.site-header__profile:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.site-header__profile-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
.site-header__profile svg {
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
|
|
@ -150,11 +174,17 @@
|
|||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
|
||||
</svg>
|
||||
</button>
|
||||
<a class="site-header__profile" href="<%= headerProfileHref %>" aria-label="<%= headerProfileLabel %>" title="<%= headerProfileLabel %>">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
<a class="site-header__profile <%= headerLoggedIn ? "site-header__profile--avatar" : "site-header__profile--login" %>" href="<%= headerProfileHref %>" aria-label="<%= headerProfileLabel %>" title="<%= headerProfileLabel %>">
|
||||
<% if (!headerLoggedIn) { %>
|
||||
<span>로그인</span>
|
||||
<% } else if (headerHasAvatar) { %>
|
||||
<img class="site-header__profile-img" src="<%= headerAvatarUrl %>" alt="" width="44" height="44" />
|
||||
<% } else { %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
<% } %>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
|
||||
<%@ page import="com.pandoli365.bibimbap.game.GameCatalog" %>
|
||||
<%
|
||||
String ctx = request.getContextPath();
|
||||
jakarta.servlet.http.HttpSession homeSession = request.getSession(false);
|
||||
boolean loggedIn = homeSession != null && homeSession.getAttribute("userId") != null;
|
||||
%>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
|
|
@ -53,11 +58,71 @@
|
|||
}
|
||||
}
|
||||
|
||||
.home-toolbar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.625rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.home-action-button {
|
||||
flex-shrink: 0;
|
||||
min-height: 3rem;
|
||||
padding: 0 0.875rem 0 0.625rem;
|
||||
border: 1px solid rgba(232, 165, 75, 0.32);
|
||||
border-radius: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
white-space: nowrap;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.home-action-button:hover {
|
||||
border-color: rgba(232, 165, 75, 0.62);
|
||||
box-shadow: 0 6px 16px var(--card-shadow);
|
||||
}
|
||||
.home-action-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.home-action-button__icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent);
|
||||
color: #1a1a1a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.home-action-button__icon svg {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.home-toolbar {
|
||||
flex-direction: column;
|
||||
}
|
||||
.home-action-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 검색 */
|
||||
.search-section {
|
||||
margin-bottom: 1.25rem;
|
||||
width: min(100%, 48rem);
|
||||
margin: 0 auto 1.25rem;
|
||||
}
|
||||
.search-form {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
|
|
@ -365,13 +430,26 @@
|
|||
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
||||
<main class="page-main">
|
||||
<section class="search-section" aria-label="게임·제작자 검색">
|
||||
<form class="search-form" role="search" action="#" method="get">
|
||||
<div class="search-form__field">
|
||||
<label class="search-form__label" for="q">게임·제작자 검색</label>
|
||||
<input class="search-form__input" type="search" id="q" name="q" placeholder="게임·제작자 검색" autocomplete="off" enterkeyhint="search" />
|
||||
</div>
|
||||
<button class="search-form__submit" type="submit">검색</button>
|
||||
</form>
|
||||
<div class="home-toolbar">
|
||||
<form class="search-form" role="search" action="#" method="get">
|
||||
<div class="search-form__field">
|
||||
<label class="search-form__label" for="q">게임·제작자 검색</label>
|
||||
<input class="search-form__input" type="search" id="q" name="q" placeholder="게임·제작자 검색" autocomplete="off" enterkeyhint="search" />
|
||||
</div>
|
||||
<button class="search-form__submit" type="submit">검색</button>
|
||||
</form>
|
||||
<% if (loggedIn) { %>
|
||||
<a class="home-action-button" href="<%= ctx %>/game/new">
|
||||
<span class="home-action-button__icon" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5v14"/>
|
||||
<path d="M5 12h14"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>신규 게임 개시</span>
|
||||
</a>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="search-history" id="search-history" hidden>
|
||||
<div class="search-history__head">
|
||||
<span class="search-history__label">최근 검색</span>
|
||||
|
|
@ -384,7 +462,6 @@
|
|||
<section class="card-grid" aria-label="추천 목록">
|
||||
<%-- 카드 데이터: GameCatalog (DB 연동 시 교체) --%>
|
||||
<%
|
||||
String ctx = request.getContextPath();
|
||||
for (int i = 0; i < GameCatalog.COUNT; i++) {
|
||||
int displayIndex = i + 1;
|
||||
/* 썸네일 URL — DB 연동 시 null·빈 문자열이면 로고 폴백 */
|
||||
|
|
|
|||
|
|
@ -122,6 +122,10 @@
|
|||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.profile-avatar img[hidden],
|
||||
.profile-avatar span[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.profile-summary__body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
@ -138,6 +142,22 @@
|
|||
font-size: 0.9375rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
.profile-avatar-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
.profile-avatar-form__input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
.profile-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
|
@ -198,6 +218,17 @@
|
|||
background: var(--accent);
|
||||
color: var(--button-text);
|
||||
}
|
||||
.profile-button--small {
|
||||
min-height: 2.375rem;
|
||||
border-radius: 9px;
|
||||
padding: 0 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.profile-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
box-shadow: none;
|
||||
}
|
||||
.profile-actions form {
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -223,9 +254,13 @@
|
|||
padding: 1.25rem;
|
||||
}
|
||||
.profile-button,
|
||||
.profile-avatar-form,
|
||||
.profile-actions form {
|
||||
width: 100%;
|
||||
}
|
||||
.profile-avatar-form .profile-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
|
@ -241,15 +276,16 @@
|
|||
<div class="profile-panel">
|
||||
<div class="profile-summary">
|
||||
<div class="profile-avatar" aria-hidden="true">
|
||||
<% if (!avatarUrl.isBlank()) { %>
|
||||
<img src="<%= avatarUrl %>" alt="" width="96" height="96" />
|
||||
<% } else { %>
|
||||
<span><%= avatarInitial %></span>
|
||||
<% } %>
|
||||
<img id="profile-avatar-img" src="<%= avatarUrl %>" alt="" width="96" height="96" <%= avatarUrl.isBlank() ? "hidden" : "" %> />
|
||||
<span id="profile-avatar-initial" <%= avatarUrl.isBlank() ? "" : "hidden" %>><%= avatarInitial %></span>
|
||||
</div>
|
||||
<div class="profile-summary__body">
|
||||
<h2 class="profile-summary__name"><%= displayName %></h2>
|
||||
<p class="profile-summary__email"><%= email %></p>
|
||||
<form class="profile-avatar-form" action="<%= ctx %>/profile/avatar" method="post" enctype="multipart/form-data" id="profile-avatar-form">
|
||||
<label class="profile-button profile-button--small" for="profile-avatar-input" id="profile-avatar-label">프로필 이미지 변경</label>
|
||||
<input class="profile-avatar-form__input" type="file" id="profile-avatar-input" name="avatar" accept="image/png,image/jpeg,image/webp,image/gif" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -282,5 +318,148 @@
|
|||
</section>
|
||||
</main>
|
||||
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||
<script>
|
||||
(function () {
|
||||
var ctx = '<%= ctx %>';
|
||||
var form = document.getElementById('profile-avatar-form');
|
||||
var input = document.getElementById('profile-avatar-input');
|
||||
var avatarLabel = document.getElementById('profile-avatar-label');
|
||||
var avatarImg = document.getElementById('profile-avatar-img');
|
||||
var avatarInitial = document.getElementById('profile-avatar-initial');
|
||||
var previewUrl = null;
|
||||
var savedAvatarSrc = avatarImg && !avatarImg.hidden ? avatarImg.getAttribute('src') : '';
|
||||
var defaultLabelText = avatarLabel ? avatarLabel.textContent : '';
|
||||
|
||||
function openModal(title, message, confirmText, onConfirm) {
|
||||
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
|
||||
window.BibimbapModal.alert({
|
||||
title: title,
|
||||
message: message,
|
||||
confirmText: confirmText || '확인',
|
||||
onConfirm: onConfirm
|
||||
});
|
||||
return;
|
||||
}
|
||||
alert(message);
|
||||
if (typeof onConfirm === 'function') {
|
||||
onConfirm();
|
||||
}
|
||||
}
|
||||
|
||||
function setAvatar(src) {
|
||||
if (!avatarImg) return;
|
||||
avatarImg.src = src;
|
||||
avatarImg.hidden = false;
|
||||
if (avatarInitial) {
|
||||
avatarInitial.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function showInitialAvatar() {
|
||||
if (avatarImg) {
|
||||
avatarImg.removeAttribute('src');
|
||||
avatarImg.hidden = true;
|
||||
}
|
||||
if (avatarInitial) {
|
||||
avatarInitial.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearPreviewUrl() {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
previewUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreSavedAvatar() {
|
||||
clearPreviewUrl();
|
||||
if (savedAvatarSrc) {
|
||||
setAvatar(savedAvatarSrc);
|
||||
} else {
|
||||
showInitialAvatar();
|
||||
}
|
||||
}
|
||||
|
||||
function setUploading(uploading) {
|
||||
if (!avatarLabel) return;
|
||||
avatarLabel.textContent = uploading ? '변경 중...' : defaultLabelText;
|
||||
avatarLabel.setAttribute('aria-disabled', uploading ? 'true' : 'false');
|
||||
avatarLabel.style.pointerEvents = uploading ? 'none' : '';
|
||||
}
|
||||
|
||||
function uploadAvatar(file) {
|
||||
if (!form || !file) return;
|
||||
|
||||
var body = new FormData();
|
||||
body.append('avatar', file);
|
||||
setUploading(true);
|
||||
|
||||
fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: body
|
||||
}).then(function (res) {
|
||||
return res.json().catch(function () {
|
||||
return { message: '프로필 이미지를 저장하지 못했습니다.' };
|
||||
}).then(function (data) {
|
||||
if (!res.ok) {
|
||||
var error = new Error(data && data.message ? data.message : '프로필 이미지를 저장하지 못했습니다.');
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}).then(function (data) {
|
||||
if (data && data.avatarUrl) {
|
||||
clearPreviewUrl();
|
||||
savedAvatarSrc = data.avatarUrl + '?v=' + Date.now();
|
||||
setAvatar(savedAvatarSrc);
|
||||
}
|
||||
}).catch(function (err) {
|
||||
restoreSavedAvatar();
|
||||
openModal('이미지 변경 실패', err.message || '프로필 이미지를 저장하지 못했습니다.', '확인', function () {
|
||||
if (err.status === 401) {
|
||||
window.location.href = ctx + '/login';
|
||||
}
|
||||
});
|
||||
}).finally(function () {
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
setUploading(false);
|
||||
});
|
||||
}
|
||||
|
||||
if (input) {
|
||||
input.addEventListener('change', function () {
|
||||
clearPreviewUrl();
|
||||
var file = input.files && input.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (!file.type || file.type.indexOf('image/') !== 0) {
|
||||
input.value = '';
|
||||
restoreSavedAvatar();
|
||||
openModal('이미지 변경 실패', '이미지 파일만 선택해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
previewUrl = URL.createObjectURL(file);
|
||||
setAvatar(previewUrl);
|
||||
uploadAvatar(file);
|
||||
});
|
||||
}
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('submit', function (ev) {
|
||||
ev.preventDefault();
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Reference in New Issue