diff --git a/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java b/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java index 6377c9e..f95247b 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java @@ -28,7 +28,7 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController { @RequestMapping("/error") public ModelAndView errorView(HttpServletRequest request) { - ModelAndView mv = new ModelAndView("/errer"); + ModelAndView mv = new ModelAndView("errer"); Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE); if (status != null) { mv.addObject("statusCode", status); @@ -46,7 +46,7 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController { if (!isLoggedIn(session)) { return new ModelAndView("redirect:/login"); } - return new ModelAndView("/game-register"); + return new ModelAndView("game-register"); } @GetMapping("/") @@ -65,7 +65,7 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController { } switch (pageName) { case "error": - mv.setViewName("/errer"); + mv.setViewName("errer"); Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE); if (status != null) { mv.addObject("statusCode", status); @@ -75,22 +75,22 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController { if (isLoggedIn(session)) { return new ModelAndView("redirect:/profile"); } - mv.setViewName("/login"); + mv.setViewName("login"); break; case "profile": if (!isLoggedIn(session)) { return new ModelAndView("redirect:/login"); } - mv.setViewName("/profile"); + mv.setViewName("profile"); break; case "signup": - mv.setViewName("/signup"); + mv.setViewName("signup"); break; case "terms": - mv.setViewName("/terms"); + mv.setViewName("terms"); break; case "operation-policy": - mv.setViewName("/operation-policy"); + mv.setViewName("operation-policy"); break; default: return indexModelAndView(); @@ -100,7 +100,7 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController { } private ModelAndView indexModelAndView() { - ModelAndView mv = new ModelAndView("/index"); + ModelAndView mv = new ModelAndView("index"); mv.addObject("games", gamesMapper.getVisibleGames()); return mv; } diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/ApiExceptionControllerAdvice.java b/src/main/java/com/pandoli365/bibimbap/controller/api/ApiExceptionControllerAdvice.java new file mode 100644 index 0000000..77cb618 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/ApiExceptionControllerAdvice.java @@ -0,0 +1,36 @@ +package com.pandoli365.bibimbap.controller.api; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +import java.util.LinkedHashMap; +import java.util.Map; + +@RestControllerAdvice +public class ApiExceptionControllerAdvice { + + private static final Logger log = LoggerFactory.getLogger(ApiExceptionControllerAdvice.class); + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity> handleMaxUploadSizeExceeded() { + Map body = new LinkedHashMap<>(); + body.put("status", HttpStatus.PAYLOAD_TOO_LARGE.value()); + body.put("message", "upload file is too large. WebGL zip must be 1GB or smaller."); + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(body); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception exception) { + log.error("API request failed", exception); + + Map body = new LinkedHashMap<>(); + body.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); + body.put("message", "server error"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java index 5f7baa8..00fb385 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java @@ -41,7 +41,8 @@ public class GameController { @RequestParam(name = "visible", required = false) String visible, HttpSession session ) { - if (sessionUserId(session) == null) { + Long userId = sessionUserId(session); + if (userId == null) { return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); } @@ -65,8 +66,8 @@ public class GameController { } GameData game = new GameData(); + game.setUserId(userId); game.setName(normalizedName); - game.setCreator(sessionDisplayName(session)); game.setCreatorNote(normalizedCreatorNote); game.setGitUrl(trimToEmpty(gitUrl)); game.setWebglPath(normalizedWebglPath); @@ -145,27 +146,6 @@ public class GameController { return null; } - private String sessionDisplayName(HttpSession session) { - String displayName = sessionValue(session, "displayName"); - if (displayName != null) { - return displayName; - } - String email = sessionValue(session, "email"); - return email == null ? "bibimbap 사용자" : email; - } - - private String sessionValue(HttpSession session, String name) { - if (session == null) { - return null; - } - Object value = session.getAttribute(name); - if (value == null) { - return null; - } - String text = String.valueOf(value).trim(); - return text.isBlank() ? null : text; - } - private boolean isChecked(String value) { return "true".equalsIgnoreCase(value) || "on".equalsIgnoreCase(value); } diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java index 24b8c96..eab79ce 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java @@ -4,6 +4,9 @@ import jakarta.servlet.http.HttpSession; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -31,6 +34,7 @@ import java.util.zip.ZipInputStream; @RequestMapping("/api/game-files") public class GameUploadController { + private static final Logger log = LoggerFactory.getLogger(GameUploadController.class); private static final long WEBGL_EXTRACTED_MAX_BYTES = 512L * 1024 * 1024; private static final int WEBGL_MAX_ENTRIES = 8_000; private static final long THUMBNAIL_MAX_BYTES = 10L * 1024 * 1024; @@ -38,6 +42,11 @@ public class GameUploadController { @Value("${app.upload.game-storage-path:src/main/resources/static}") private String uploadStoragePath; + @GetMapping("/ping") + public ResponseEntity> ping() { + return ResponseEntity.ok(Map.of("status", "ok")); + } + @PostMapping public ResponseEntity> uploadGameFiles( @RequestParam("files") MultipartFile[] files, @@ -87,6 +96,10 @@ public class GameUploadController { @RequestParam(name = "file", required = false) MultipartFile file, HttpSession session ) throws IOException { + log.info("WebGL zip upload request received. fileName={}, size={}, contentType={}", + file == null ? null : file.getOriginalFilename(), + file == null ? null : file.getSize(), + file == null ? null : file.getContentType()); Long userId = sessionUserId(session); if (userId == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) @@ -125,6 +138,8 @@ public class GameUploadController { response.put("deployPath", deployPath); response.put("entryCount", extractResult.entryCount()); response.put("extractedBytes", extractResult.extractedBytes()); + log.info("WebGL zip upload completed. gameUuid={}, webglPath={}, entryCount={}, extractedBytes={}", + gameUuid, webglPath, extractResult.entryCount(), extractResult.extractedBytes()); return ResponseEntity.ok(response); } catch (IllegalArgumentException e) { deleteRecursively(targetDir); diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java index 1cbba08..e12fcbd 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java @@ -164,6 +164,39 @@ public class UserController { return "redirect:/"; } + @PostMapping("/profile/nickname") + @Transactional + public ResponseEntity updateProfileNickname( + @RequestParam(name = "displayName", required = false) String displayName, + 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(), "로그인이 필요합니다.")); + } + + String normalizedDisplayName = normalizeDisplayName(displayName, null); + if (normalizedDisplayName == null) { + return ResponseEntity.badRequest() + .body(new AuthResult(HttpStatus.BAD_REQUEST.value(), "변경할 닉네임을 입력해 주세요.")); + } + + UserData user = usersMapper.getUser(userId); + if (user == null || !STATUS_ACTIVE.equals(user.getStatus())) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(new AuthResult(HttpStatus.FORBIDDEN.value(), "사용할 수 없는 계정입니다.")); + } + + user.setDisplayName(normalizedDisplayName); + usersMapper.updateUser(user); + updateAuthIdentityDisplayName(session, normalizedDisplayName); + updateDisplayNameSession(session, normalizedDisplayName); + + return ResponseEntity.ok(new ProfileNicknameResult(200, "닉네임이 변경되었습니다.", normalizedDisplayName)); + } + @PostMapping("/profile/avatar") @Transactional public ResponseEntity updateProfileAvatar( @@ -390,6 +423,30 @@ public class UserController { userAuthIdentitiesMapper.updateUserAuthIdentity(identity); } + private void updateAuthIdentityDisplayName(HttpSession session, String displayName) { + 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.setDisplayName(displayName); + userAuthIdentitiesMapper.updateUserAuthIdentity(identity); + } + private void updateAvatarSession(HttpSession session, String avatarUrl) { session.setAttribute("avatarUrl", avatarUrl); @@ -406,6 +463,22 @@ public class UserController { } } + private void updateDisplayNameSession(HttpSession session, String displayName) { + session.setAttribute("displayName", displayName); + + Object account = session.getAttribute("account"); + if (account instanceof Map existingAccount) { + Map updatedAccount = new LinkedHashMap<>(); + existingAccount.forEach((key, value) -> { + if (key != null) { + updatedAccount.put(String.valueOf(key), value); + } + }); + updatedAccount.put("displayName", displayName); + session.setAttribute("account", updatedAccount); + } + } + private void saveLoginSession(HttpSession session, UserData user, UserAuthIdentityData identity) { session.setAttribute("id", user.getId()); session.setAttribute("userId", user.getId()); @@ -537,4 +610,7 @@ public class UserController { public record ProfileAvatarResult(int status, String message, String avatarUrl) { } + + public record ProfileNicknameResult(int status, String message, String displayName) { + } } diff --git a/src/main/java/com/pandoli365/bibimbap/data/GameData.java b/src/main/java/com/pandoli365/bibimbap/data/GameData.java index db3e6f5..6a68f07 100644 --- a/src/main/java/com/pandoli365/bibimbap/data/GameData.java +++ b/src/main/java/com/pandoli365/bibimbap/data/GameData.java @@ -5,6 +5,7 @@ import java.time.OffsetDateTime; public class GameData { private Long id; + private Long userId; private String name; private String creator; private String creatorNote; @@ -25,6 +26,14 @@ public class GameData { this.id = id; } + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + public String getName() { return name; } diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java index 9383ea8..2b25a0c 100644 --- a/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java @@ -14,47 +14,51 @@ public interface GamesMapper { @Select(""" SELECT - id, - name, - creator, - creator_note AS creatorNote, - git_url AS gitUrl, - webgl_path AS webglPath, - thumbnail_url AS thumbnailUrl, - like_count AS likeCount, - is_visible AS visible, - sort_order AS sortOrder, - created_at AS createdAt, - updated_at AS updatedAt - FROM games - WHERE id = #{id} + g.id, + g.user_id AS userId, + g.name, + u.display_name AS creator, + g.creator_note AS creatorNote, + g.git_url AS gitUrl, + g.webgl_path AS webglPath, + g.thumbnail_url AS thumbnailUrl, + g.like_count AS likeCount, + g.is_visible AS visible, + g.sort_order AS sortOrder, + g.created_at AS createdAt, + g.updated_at AS updatedAt + FROM games g + JOIN users u ON u.id = g.user_id + WHERE g.id = #{id} """) GameData getGame(long id); @Select(""" SELECT - id, - name, - creator, - creator_note AS creatorNote, - git_url AS gitUrl, - webgl_path AS webglPath, - thumbnail_url AS thumbnailUrl, - like_count AS likeCount, - is_visible AS visible, - sort_order AS sortOrder, - created_at AS createdAt, - updated_at AS updatedAt - FROM games - WHERE is_visible IS NOT FALSE - ORDER BY sort_order ASC, created_at DESC, id DESC + g.id, + g.user_id AS userId, + g.name, + u.display_name AS creator, + g.creator_note AS creatorNote, + g.git_url AS gitUrl, + g.webgl_path AS webglPath, + g.thumbnail_url AS thumbnailUrl, + g.like_count AS likeCount, + g.is_visible AS visible, + g.sort_order AS sortOrder, + g.created_at AS createdAt, + g.updated_at AS updatedAt + FROM games g + JOIN users u ON u.id = g.user_id + WHERE g.is_visible IS NOT FALSE + ORDER BY g.sort_order ASC, g.created_at DESC, g.id DESC """) List getVisibleGames(); @Insert(""" INSERT INTO games ( + user_id, name, - creator, creator_note, git_url, webgl_path, @@ -62,8 +66,8 @@ public interface GamesMapper { is_visible, sort_order ) VALUES ( + #{userId}, #{name}, - #{creator}, #{creatorNote}, #{gitUrl}, #{webglPath}, @@ -84,8 +88,8 @@ public interface GamesMapper { @Update(""" UPDATE games SET + user_id = #{userId}, name = #{name}, - creator = #{creator}, creator_note = #{creatorNote}, git_url = #{gitUrl}, webgl_path = #{webglPath}, diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2d927d7..75a085c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,5 +1,25 @@ spring.profiles.active=@app.profile@ spring.application.name=bibimbap +# ViewResolver +spring.mvc.view.prefix=/WEB-INF/views/ +spring.mvc.view.suffix=.jsp + +# IP +server.address=0.0.0.0 + +# encoding +server.servlet.encoding.force-response=false + +# file upload Max Size +spring.servlet.multipart.max-file-size=1GB +spring.servlet.multipart.max-request-size=1GB +server.tomcat.max-http-form-post-size=1GB +server.tomcat.max-swallow-size=-1 + # common -spring.config.import=classpath:${spring.profiles.active}/db.properties \ No newline at end of file +spring.config.import=classpath:${spring.profiles.active}/db.properties + +# log +mybatis.configuration.log-impl=org.apache.ibatis.logging.slf4j.Slf4jImpl +logging.level.org.apache.ibatis=TRACE \ No newline at end of file diff --git a/src/main/resources/dev/application.properties b/src/main/resources/dev/application.properties deleted file mode 100644 index 984167f..0000000 --- a/src/main/resources/dev/application.properties +++ /dev/null @@ -1,23 +0,0 @@ -spring.profiles.active=dev -spring.application.name=bibimbap - -# ViewResolver -spring.mvc.view.prefix=/WEB-INF/views/ -spring.mvc.view.suffix=.jsp - -# common -spring.config.import=classpath:dev/db.properties - -# IP -server.address=0.0.0.0 - -# encoding -server.servlet.encoding.force-response=false - -# file upload Max Size -spring.servlet.multipart.max-file-size=100MB -spring.servlet.multipart.max-request-size=100MB - -# log -mybatis.configuration.log-impl=org.apache.ibatis.logging.slf4j.Slf4jImpl -logging.level.org.apache.ibatis=TRACE diff --git a/src/main/resources/live/application.properties b/src/main/resources/live/application.properties deleted file mode 100644 index 962ccc7..0000000 --- a/src/main/resources/live/application.properties +++ /dev/null @@ -1,23 +0,0 @@ -spring.profiles.active=live -spring.application.name=bibimbap - -# ViewResolver -spring.mvc.view.prefix=/WEB-INF/views/ -spring.mvc.view.suffix=.jsp - -# common -spring.config.import=classpath:live/db.properties - -# IP -server.address=0.0.0.0 - -# encoding -server.servlet.encoding.force-response=false - -# file upload Max Size -spring.servlet.multipart.max-file-size=100MB -spring.servlet.multipart.max-request-size=100MB - -# log -mybatis.configuration.log-impl=org.apache.ibatis.logging.slf4j.Slf4jImpl -logging.level.org.apache.ibatis=TRACE diff --git a/src/main/webapp/WEB-INF/views/game-register.jsp b/src/main/webapp/WEB-INF/views/game-register.jsp index f4dfa29..e61571d 100644 --- a/src/main/webapp/WEB-INF/views/game-register.jsp +++ b/src/main/webapp/WEB-INF/views/game-register.jsp @@ -508,6 +508,8 @@ 'X-Requested-With': 'XMLHttpRequest' }, body: body + }).catch(function () { + throw new Error('Upload request failed before receiving a server response. Please check the server console and browser Network tab.'); }).then(function (res) { return res.json().catch(function () { return { message: '파일을 업로드하지 못했습니다.' }; diff --git a/src/main/webapp/WEB-INF/views/modal.jsp b/src/main/webapp/WEB-INF/views/modal.jsp index 50b366d..97d3aa9 100644 --- a/src/main/webapp/WEB-INF/views/modal.jsp +++ b/src/main/webapp/WEB-INF/views/modal.jsp @@ -46,6 +46,35 @@ line-height: 1.6; color: var(--modal-muted, #5c5c5c); } + .site-modal__field { + display: grid; + gap: 0.5rem; + margin-top: 1rem; + } + .site-modal__field[hidden] { + display: none !important; + } + .site-modal__field-label { + font-size: 0.8125rem; + font-weight: 800; + color: var(--modal-text, #1a1a1a); + } + .site-modal__input { + width: 100%; + height: 2.875rem; + box-sizing: border-box; + border: 1px solid var(--modal-border, rgba(0, 0, 0, 0.08)); + border-radius: 10px; + padding: 0 0.875rem; + background: var(--modal-bg, #fff); + color: var(--modal-text, #1a1a1a); + font: inherit; + } + .site-modal__input:focus { + outline: none; + border-color: #e8a54b; + box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.22); + } .site-modal__actions { display: flex; justify-content: flex-end; @@ -65,6 +94,14 @@ cursor: pointer; -webkit-tap-highlight-color: transparent; } + .site-modal__button--secondary { + border-color: var(--modal-border, rgba(0, 0, 0, 0.08)); + background: transparent; + color: var(--modal-text, #1a1a1a); + } + .site-modal__button[hidden] { + display: none !important; + } .site-modal__button:active { transform: scale(0.98); } @@ -73,7 +110,12 @@

+
+
@@ -83,33 +125,51 @@ var root = document.getElementById('site-modal'); var titleEl = document.getElementById('site-modal-title'); var messageEl = document.getElementById('site-modal-message'); + var fieldEl = document.getElementById('site-modal-field'); + var fieldLabelEl = document.getElementById('site-modal-field-label'); + var inputEl = document.getElementById('site-modal-input'); + var cancelBtn = document.getElementById('site-modal-cancel'); var confirmBtn = document.getElementById('site-modal-confirm'); var onConfirm = null; + var onCancel = null; var lastFocused = null; - if (!root || !titleEl || !messageEl || !confirmBtn) return; + if (!root || !titleEl || !messageEl || !fieldEl || !fieldLabelEl || !inputEl || !cancelBtn || !confirmBtn) return; - function close() { + function close(confirmed) { root.hidden = true; document.removeEventListener('keydown', onKeyDown); - var callback = onConfirm; + var callback = confirmed ? onConfirm : onCancel; + var inputValue = inputEl.value; onConfirm = null; + onCancel = null; if (lastFocused && typeof lastFocused.focus === 'function') { lastFocused.focus(); } if (typeof callback === 'function') { - callback(); + callback(inputValue); } } function onKeyDown(ev) { + if (ev.isComposing) { + return; + } if (ev.key === 'Enter') { ev.preventDefault(); - close(); + close(true); + } else if (ev.key === 'Escape' && !cancelBtn.hidden) { + ev.preventDefault(); + close(false); } } - confirmBtn.addEventListener('click', close); + confirmBtn.addEventListener('click', function () { + close(true); + }); + cancelBtn.addEventListener('click', function () { + close(false); + }); window.BibimbapModal = { alert: function (options) { @@ -117,11 +177,45 @@ lastFocused = document.activeElement; titleEl.textContent = opts.title || '알림'; messageEl.textContent = opts.message || ''; + fieldEl.hidden = true; + inputEl.value = ''; + inputEl.removeAttribute('maxlength'); + inputEl.removeAttribute('placeholder'); + cancelBtn.hidden = true; confirmBtn.textContent = opts.confirmText || '확인'; onConfirm = opts.onConfirm || null; + onCancel = null; root.hidden = false; document.addEventListener('keydown', onKeyDown); confirmBtn.focus(); + }, + prompt: function (options) { + var opts = options || {}; + lastFocused = document.activeElement; + titleEl.textContent = opts.title || '입력'; + messageEl.textContent = opts.message || ''; + fieldLabelEl.textContent = opts.label || '입력값'; + inputEl.value = opts.value || ''; + if (opts.placeholder) { + inputEl.setAttribute('placeholder', opts.placeholder); + } else { + inputEl.removeAttribute('placeholder'); + } + if (opts.maxLength) { + inputEl.setAttribute('maxlength', opts.maxLength); + } else { + inputEl.removeAttribute('maxlength'); + } + fieldEl.hidden = false; + cancelBtn.hidden = false; + cancelBtn.textContent = opts.cancelText || '취소'; + confirmBtn.textContent = opts.confirmText || '변경'; + onConfirm = opts.onConfirm || null; + onCancel = opts.onCancel || null; + root.hidden = false; + document.addEventListener('keydown', onKeyDown); + inputEl.focus(); + inputEl.select(); } }; })(); diff --git a/src/main/webapp/WEB-INF/views/profile.jsp b/src/main/webapp/WEB-INF/views/profile.jsp index 80d8e24..67620f9 100644 --- a/src/main/webapp/WEB-INF/views/profile.jsp +++ b/src/main/webapp/WEB-INF/views/profile.jsp @@ -7,20 +7,12 @@ String rawDisplayName = session.getAttribute("displayName") == null ? "" : String.valueOf(session.getAttribute("displayName")); String rawEmail = session.getAttribute("email") == null ? "" : String.valueOf(session.getAttribute("email")); String rawAvatarUrl = session.getAttribute("avatarUrl") == null ? "" : String.valueOf(session.getAttribute("avatarUrl")); - String rawRole = session.getAttribute("role") == null ? "" : String.valueOf(session.getAttribute("role")); - String rawStatus = session.getAttribute("status") == null ? "" : String.valueOf(session.getAttribute("status")); - String rawAuthProvider = session.getAttribute("authProvider") == null ? "" : String.valueOf(session.getAttribute("authProvider")); - String rawLastLoginAt = session.getAttribute("lastLoginAt") == null ? "" : String.valueOf(session.getAttribute("lastLoginAt")); String initialSource = rawDisplayName.isBlank() ? rawEmail : rawDisplayName; String initial = initialSource.isBlank() ? "B" : initialSource.substring(0, 1).toUpperCase(Locale.ROOT); String displayName = HtmlUtils.htmlEscape(rawDisplayName.isBlank() ? "bibimbap 사용자" : rawDisplayName); String email = HtmlUtils.htmlEscape(rawEmail.isBlank() ? "이메일 정보 없음" : rawEmail); String avatarUrl = HtmlUtils.htmlEscape(rawAvatarUrl); - String role = HtmlUtils.htmlEscape(rawRole.isBlank() ? "USER" : rawRole); - String status = HtmlUtils.htmlEscape(rawStatus.isBlank() ? "ACTIVE" : rawStatus); - String authProvider = HtmlUtils.htmlEscape(rawAuthProvider.isBlank() ? "email" : rawAuthProvider); - String lastLoginAt = HtmlUtils.htmlEscape(rawLastLoginAt.isBlank() ? "이번 세션" : rawLastLoginAt); String avatarInitial = HtmlUtils.htmlEscape(initial); %> @@ -280,34 +272,16 @@ ><%= avatarInitial %>
-

<%= displayName %>

+

<%= displayName %>

+
-
-
-

권한

-

<%= role %>

-
-
-

상태

-

<%= status %>

-
-
-

로그인 방식

-

<%= authProvider %>

-
-
-

최근 로그인

-

<%= lastLoginAt %>

-
-
-
홈으로 이동
@@ -326,9 +300,12 @@ var avatarLabel = document.getElementById('profile-avatar-label'); var avatarImg = document.getElementById('profile-avatar-img'); var avatarInitial = document.getElementById('profile-avatar-initial'); + var nicknameButton = document.getElementById('profile-nickname-button'); + var displayNameEl = document.getElementById('profile-display-name'); var previewUrl = null; var savedAvatarSrc = avatarImg && !avatarImg.hidden ? avatarImg.getAttribute('src') : ''; var defaultLabelText = avatarLabel ? avatarLabel.textContent : ''; + var defaultNicknameText = nicknameButton ? nicknameButton.textContent : ''; function openModal(title, message, confirmText, onConfirm) { if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') { @@ -388,6 +365,98 @@ avatarLabel.style.pointerEvents = uploading ? 'none' : ''; } + function setNicknameSaving(saving) { + if (!nicknameButton) return; + nicknameButton.disabled = saving; + nicknameButton.textContent = saving ? '변경 중...' : defaultNicknameText; + } + + function currentDisplayName() { + if (!nicknameButton) return ''; + return nicknameButton.getAttribute('data-current-name') || ''; + } + + function updateDisplayName(displayName) { + if (displayNameEl) { + displayNameEl.textContent = displayName; + } + if (nicknameButton) { + nicknameButton.setAttribute('data-current-name', displayName); + } + if (avatarInitial) { + avatarInitial.textContent = displayName ? displayName.charAt(0).toUpperCase() : 'B'; + } + } + + function submitNickname(nextName) { + var value = (nextName || '').trim(); + if (!value) { + openModal('닉네임 변경 실패', '변경할 닉네임을 입력해 주세요.'); + return; + } + if (value.length > 32) { + openModal('닉네임 변경 실패', '닉네임은 32자 이하로 입력해 주세요.'); + return; + } + + var body = new URLSearchParams(); + body.set('displayName', value); + setNicknameSaving(true); + + fetch(ctx + '/profile/nickname', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + '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) { + updateDisplayName(data && data.displayName ? data.displayName : value); + openModal('닉네임 변경 완료', '닉네임이 변경되었습니다.'); + }).catch(function (err) { + openModal('닉네임 변경 실패', err.message || '닉네임을 변경하지 못했습니다.', '확인', function () { + if (err.status === 401) { + window.location.href = ctx + '/login'; + } + }); + }).finally(function () { + setNicknameSaving(false); + }); + } + + function openNicknamePrompt() { + var currentName = currentDisplayName(); + if (window.BibimbapModal && typeof window.BibimbapModal.prompt === 'function') { + window.BibimbapModal.prompt({ + title: '닉네임 변경', + message: '새로운 닉네임을 입력해 주세요.', + label: '닉네임', + value: currentName, + maxLength: 32, + confirmText: '변경', + cancelText: '취소', + onConfirm: submitNickname + }); + return; + } + var nextName = prompt('새로운 닉네임을 입력해 주세요.', currentName); + if (nextName !== null) { + submitNickname(nextName); + } + } + function uploadAvatar(file) { if (!form || !file) return; @@ -454,6 +523,10 @@ }); } + if (nicknameButton) { + nicknameButton.addEventListener('click', openNicknamePrompt); + } + if (form) { form.addEventListener('submit', function (ev) { ev.preventDefault(); diff --git a/src/test/db/dev-to-live-update.sql b/src/test/db/dev-to-live-update.sql index 64ca28e..bb7e623 100644 --- a/src/test/db/dev-to-live-update.sql +++ b/src/test/db/dev-to-live-update.sql @@ -36,7 +36,6 @@ CREATE SEQUENCE IF NOT EXISTS "games_id_seq"; CREATE TABLE "games" ( "id" bigint DEFAULT nextval('games_id_seq'::regclass) NOT NULL, "name" character varying(200) NOT NULL, - "creator" character varying(100) NOT NULL, "creator_note" text, "git_url" character varying(500), "webgl_path" character varying(500), @@ -47,6 +46,7 @@ CREATE TABLE "games" ( "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, "is_delete" boolean DEFAULT false NOT NULL, + "user_id" bigint NOT NULL, PRIMARY KEY ("id") );