프로필 개선

This commit is contained in:
pandoli365 2026-05-03 19:34:31 +09:00
parent 49dd1e5a6f
commit 39674c7be7
12 changed files with 1047 additions and 20 deletions

View File

@ -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);
}
}

View File

@ -34,6 +34,14 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
"error", "login", "profile", "signup", "" "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}") @GetMapping("/{pageName}")
public ModelAndView mainView(@PathVariable("pageName") String pageName, public ModelAndView mainView(@PathVariable("pageName") String pageName,
@RequestParam(name = "id", required = false) String id, @RequestParam(name = "id", required = false) String id,

View File

@ -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;
}
}

View File

@ -6,6 +6,7 @@ import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper;
import com.pandoli365.bibimbap.mapper.UsersMapper; import com.pandoli365.bibimbap.mapper.UsersMapper;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -14,9 +15,15 @@ import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import javax.crypto.SecretKeyFactory; import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec; 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.MessageDigest;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
@ -25,6 +32,7 @@ import java.util.Base64;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.UUID;
@Controller @Controller
public class UserController { public class UserController {
@ -38,11 +46,15 @@ public class UserController {
private static final int PASSWORD_SALT_BYTES = 16; private static final int PASSWORD_SALT_BYTES = 16;
private static final int DEFAULT_SESSION_SECONDS = 60 * 30; private static final int DEFAULT_SESSION_SECONDS = 60 * 30;
private static final int REMEMBER_SESSION_SECONDS = 60 * 60 * 24 * 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 UsersMapper usersMapper;
private final UserAuthIdentitiesMapper userAuthIdentitiesMapper; private final UserAuthIdentitiesMapper userAuthIdentitiesMapper;
private final SecureRandom secureRandom = new SecureRandom(); 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) { public UserController(UsersMapper usersMapper, UserAuthIdentitiesMapper userAuthIdentitiesMapper) {
this.usersMapper = usersMapper; this.usersMapper = usersMapper;
this.userAuthIdentitiesMapper = userAuthIdentitiesMapper; this.userAuthIdentitiesMapper = userAuthIdentitiesMapper;
@ -151,6 +163,70 @@ public class UserController {
return "redirect:/"; 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) { private UserData createUser(String displayName, String canonicalEmail, String avatarUrl, OffsetDateTime loginAt) {
UserData user = new UserData(); UserData user = new UserData();
user.setDisplayName(displayName); user.setDisplayName(displayName);
@ -194,6 +270,102 @@ public class UserController {
return "true".equalsIgnoreCase(value) || "on".equalsIgnoreCase(value); 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) { private void saveLoginSession(HttpSession session, UserData user, UserAuthIdentityData identity) {
session.setAttribute("id", user.getId()); session.setAttribute("id", user.getId());
session.setAttribute("userId", user.getId()); session.setAttribute("userId", user.getId());
@ -322,4 +494,7 @@ public class UserController {
public record AuthResult(int status, String message) { public record AuthResult(int status, String message) {
} }
public record ProfileAvatarResult(int status, String message, String avatarUrl) {
}
} }

View File

@ -2,3 +2,5 @@ spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://localhost:5432/bibimbap?currentSchema=dev spring.datasource.url=jdbc:postgresql://localhost:5432/bibimbap?currentSchema=dev
spring.datasource.username=your_username spring.datasource.username=your_username
spring.datasource.password=your_password spring.datasource.password=your_password
app.upload.game-storage-path=src/main/resources/static/game

View File

@ -2,3 +2,5 @@ spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://localhost:5432/bibimbap?currentSchema=live spring.datasource.url=jdbc:postgresql://localhost:5432/bibimbap?currentSchema=live
spring.datasource.username=your_username spring.datasource.username=your_username
spring.datasource.password=your_password 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

View File

@ -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>

View File

@ -5,6 +5,10 @@
boolean headerLoggedIn = headerSession != null && headerSession.getAttribute("userId") != null; boolean headerLoggedIn = headerSession != null && headerSession.getAttribute("userId") != null;
String headerProfileHref = request.getContextPath() + (headerLoggedIn ? "/profile" : "/login"); String headerProfileHref = request.getContextPath() + (headerLoggedIn ? "/profile" : "/login");
String headerProfileLabel = headerLoggedIn ? "프로필" : "로그인"; String headerProfileLabel = headerLoggedIn ? "프로필" : "로그인";
String headerAvatarUrl = headerSession != null && headerSession.getAttribute("avatarUrl") != null
? String.valueOf(headerSession.getAttribute("avatarUrl"))
: "";
boolean headerHasAvatar = headerLoggedIn && !headerAvatarUrl.isBlank();
%> %>
<style> <style>
/* 기본(라이트) 헤더 — data-theme 없을 때도 동일 톤 */ /* 기본(라이트) 헤더 — data-theme 없을 때도 동일 톤 */
@ -125,9 +129,29 @@
background: var(--accent); background: var(--accent);
color: #1a1a1a; 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 { .site-header__profile:active {
transform: scale(0.96); transform: scale(0.96);
} }
.site-header__profile-img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.site-header__profile svg { .site-header__profile svg {
width: 1.35rem; width: 1.35rem;
height: 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"/> <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> </svg>
</button> </button>
<a class="site-header__profile" href="<%= headerProfileHref %>" aria-label="<%= headerProfileLabel %>" title="<%= headerProfileLabel %>"> <a class="site-header__profile <%= headerLoggedIn ? "site-header__profile--avatar" : "site-header__profile--login" %>" 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"> <% if (!headerLoggedIn) { %>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/> <span>로그인</span>
<circle cx="12" cy="7" r="4"/> <% } else if (headerHasAvatar) { %>
</svg> <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> </a>
</div> </div>
</div> </div>

View File

@ -1,5 +1,10 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %> <%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
<%@ page import="com.pandoli365.bibimbap.game.GameCatalog" %> <%@ 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> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">
<head> <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 { .search-section {
margin-bottom: 1.25rem; width: min(100%, 48rem);
margin: 0 auto 1.25rem;
} }
.search-form { .search-form {
flex: 1;
min-width: 0;
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: stretch; align-items: stretch;
@ -365,13 +430,26 @@
<jsp:include page="/WEB-INF/views/header.jsp"/> <jsp:include page="/WEB-INF/views/header.jsp"/>
<main class="page-main"> <main class="page-main">
<section class="search-section" aria-label="게임·제작자 검색"> <section class="search-section" aria-label="게임·제작자 검색">
<form class="search-form" role="search" action="#" method="get"> <div class="home-toolbar">
<div class="search-form__field"> <form class="search-form" role="search" action="#" method="get">
<label class="search-form__label" for="q">게임·제작자 검색</label> <div class="search-form__field">
<input class="search-form__input" type="search" id="q" name="q" placeholder="게임·제작자 검색" autocomplete="off" enterkeyhint="search" /> <label class="search-form__label" for="q">게임·제작자 검색</label>
</div> <input class="search-form__input" type="search" id="q" name="q" placeholder="게임·제작자 검색" autocomplete="off" enterkeyhint="search" />
<button class="search-form__submit" type="submit">검색</button> </div>
</form> <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" id="search-history" hidden>
<div class="search-history__head"> <div class="search-history__head">
<span class="search-history__label">최근 검색</span> <span class="search-history__label">최근 검색</span>
@ -384,7 +462,6 @@
<section class="card-grid" aria-label="추천 목록"> <section class="card-grid" aria-label="추천 목록">
<%-- 카드 데이터: GameCatalog (DB 연동 시 교체) --%> <%-- 카드 데이터: GameCatalog (DB 연동 시 교체) --%>
<% <%
String ctx = request.getContextPath();
for (int i = 0; i < GameCatalog.COUNT; i++) { for (int i = 0; i < GameCatalog.COUNT; i++) {
int displayIndex = i + 1; int displayIndex = i + 1;
/* 썸네일 URL — DB 연동 시 null·빈 문자열이면 로고 폴백 */ /* 썸네일 URL — DB 연동 시 null·빈 문자열이면 로고 폴백 */

View File

@ -122,6 +122,10 @@
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
.profile-avatar img[hidden],
.profile-avatar span[hidden] {
display: none;
}
.profile-summary__body { .profile-summary__body {
min-width: 0; min-width: 0;
} }
@ -138,6 +142,22 @@
font-size: 0.9375rem; font-size: 0.9375rem;
word-break: break-word; 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 { .profile-details {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@ -198,6 +218,17 @@
background: var(--accent); background: var(--accent);
color: var(--button-text); 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 { .profile-actions form {
margin: 0; margin: 0;
} }
@ -223,9 +254,13 @@
padding: 1.25rem; padding: 1.25rem;
} }
.profile-button, .profile-button,
.profile-avatar-form,
.profile-actions form { .profile-actions form {
width: 100%; width: 100%;
} }
.profile-avatar-form .profile-button {
flex: 1;
}
} }
</style> </style>
</head> </head>
@ -241,15 +276,16 @@
<div class="profile-panel"> <div class="profile-panel">
<div class="profile-summary"> <div class="profile-summary">
<div class="profile-avatar" aria-hidden="true"> <div class="profile-avatar" aria-hidden="true">
<% if (!avatarUrl.isBlank()) { %> <img id="profile-avatar-img" src="<%= avatarUrl %>" alt="" width="96" height="96" <%= avatarUrl.isBlank() ? "hidden" : "" %> />
<img src="<%= avatarUrl %>" alt="" width="96" height="96" /> <span id="profile-avatar-initial" <%= avatarUrl.isBlank() ? "" : "hidden" %>><%= avatarInitial %></span>
<% } else { %>
<span><%= avatarInitial %></span>
<% } %>
</div> </div>
<div class="profile-summary__body"> <div class="profile-summary__body">
<h2 class="profile-summary__name"><%= displayName %></h2> <h2 class="profile-summary__name"><%= displayName %></h2>
<p class="profile-summary__email"><%= email %></p> <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>
</div> </div>
@ -282,5 +318,148 @@
</section> </section>
</main> </main>
<jsp:include page="/WEB-INF/views/footer.jsp"/> <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> </body>
</html> </html>