게임 등록 버그 수정

This commit is contained in:
pandoli365 2026-05-03 23:55:26 +09:00
parent b61973f96d
commit 4e49fd21ff
14 changed files with 409 additions and 146 deletions

View File

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

View File

@ -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<Map<String, Object>> handleMaxUploadSizeExceeded() {
Map<String, Object> 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<Map<String, Object>> handleException(Exception exception) {
log.error("API request failed", exception);
Map<String, Object> 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);
}
}

View File

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

View File

@ -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<Map<String, Object>> ping() {
return ResponseEntity.ok(Map.of("status", "ok"));
}
@PostMapping
public ResponseEntity<Map<String, Object>> 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);

View File

@ -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<String, Object> 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) {
}
}

View File

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

View File

@ -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<GameData> 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},

View File

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

View File

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

View File

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

View File

@ -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: '파일을 업로드하지 못했습니다.' };

View File

@ -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 @@
<div class="site-modal__panel" role="document">
<h2 class="site-modal__title" id="site-modal-title"></h2>
<p class="site-modal__message" id="site-modal-message"></p>
<label class="site-modal__field" id="site-modal-field" hidden>
<span class="site-modal__field-label" id="site-modal-field-label"></span>
<input class="site-modal__input" type="text" id="site-modal-input" autocomplete="off" />
</label>
<div class="site-modal__actions">
<button class="site-modal__button site-modal__button--secondary" type="button" id="site-modal-cancel" hidden>취소</button>
<button class="site-modal__button" type="button" id="site-modal-confirm">확인</button>
</div>
</div>
@ -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();
}
};
})();

View File

@ -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);
%>
<html lang="ko">
@ -280,34 +272,16 @@
<span id="profile-avatar-initial" <%= avatarUrl.isBlank() ? "" : "hidden" %>><%= avatarInitial %></span>
</div>
<div class="profile-summary__body">
<h2 class="profile-summary__name"><%= displayName %></h2>
<h2 class="profile-summary__name" id="profile-display-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" />
<button class="profile-button profile-button--small" type="button" id="profile-nickname-button" data-current-name="<%= displayName %>">닉네임 변경</button>
</form>
</div>
</div>
<div class="profile-details" aria-label="계정 정보">
<div class="profile-detail">
<p class="profile-detail__label">권한</p>
<p class="profile-detail__value"><%= role %></p>
</div>
<div class="profile-detail">
<p class="profile-detail__label">상태</p>
<p class="profile-detail__value"><%= status %></p>
</div>
<div class="profile-detail">
<p class="profile-detail__label">로그인 방식</p>
<p class="profile-detail__value"><%= authProvider %></p>
</div>
<div class="profile-detail">
<p class="profile-detail__label">최근 로그인</p>
<p class="profile-detail__value"><%= lastLoginAt %></p>
</div>
</div>
<div class="profile-actions">
<a class="profile-button profile-button--primary" href="<%= ctx %>/">홈으로 이동</a>
<form action="<%= ctx %>/logout" method="post">
@ -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();

View File

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