삭제시 delete가 아닌 기록이 남도록 변경

This commit is contained in:
pandoli365 2026-05-05 23:10:44 +09:00
parent 4e49fd21ff
commit 40c6c88e9d
11 changed files with 411 additions and 71 deletions

View File

@ -50,13 +50,14 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
}
@GetMapping("/")
public ModelAndView indexView() {
return indexModelAndView();
public ModelAndView indexView(@RequestParam(name = "q", required = false) String query) {
return indexModelAndView(query);
}
@GetMapping("/{pageName}")
public ModelAndView mainView(@PathVariable("pageName") String pageName,
@RequestParam(name = "id", required = false) String id,
@RequestParam(name = "q", required = false) String query,
HttpSession session,
HttpServletRequest request) {
ModelAndView mv = new ModelAndView();
@ -93,15 +94,19 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
mv.setViewName("operation-policy");
break;
default:
return indexModelAndView();
return indexModelAndView(query);
}
return mv;
}
private ModelAndView indexModelAndView() {
private ModelAndView indexModelAndView(String query) {
String normalizedQuery = query == null ? "" : query.trim();
ModelAndView mv = new ModelAndView("index");
mv.addObject("games", gamesMapper.getVisibleGames());
mv.addObject("games", normalizedQuery.isBlank()
? gamesMapper.getVisibleGames()
: gamesMapper.searchVisibleGames(normalizedQuery));
mv.addObject("searchQuery", normalizedQuery);
return mv;
}

View File

@ -9,6 +9,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@ -89,10 +90,10 @@ public class GameController {
}
@GetMapping("/game/{id}")
public String gameDetail(@PathVariable("id") long id, Model model) {
public String gameDetail(@PathVariable("id") long id, Model model, HttpSession session) {
GameData game = gamesMapper.getGame(id);
if (game != null) {
addGameModel(model, game);
addGameModel(model, game, sessionUserId(session));
return "game-detail";
}
@ -111,10 +112,125 @@ public class GameController {
model.addAttribute("gitUrl", GameCatalog.GIT_URLS[idx]);
model.addAttribute("webglUrl", webglUrlForGame(intId));
model.addAttribute("webglDeployPath", webglUrlForGame(intId));
model.addAttribute("owner", false);
return "game-detail";
}
private void addGameModel(Model model, GameData game) {
@GetMapping("/game/{id}/edit")
public String gameEditView(@PathVariable("id") long id, Model model, HttpSession session) {
Long userId = sessionUserId(session);
if (userId == null) {
return "redirect:/login";
}
GameData game = gamesMapper.getGame(id);
if (game == null) {
return "redirect:/";
}
if (!userId.equals(game.getUserId())) {
return "redirect:/game/" + id;
}
model.addAttribute("editMode", true);
model.addAttribute("editGameId", game.getId());
model.addAttribute("editGameName", trimToEmpty(game.getName()));
model.addAttribute("editCreatorNote", trimToEmpty(game.getCreatorNote()));
model.addAttribute("editGitUrl", trimToEmpty(game.getGitUrl()));
model.addAttribute("editWebglPath", trimToEmpty(game.getWebglPath()));
model.addAttribute("editThumbnailUrl", trimToEmpty(game.getThumbnailUrl()));
model.addAttribute("editVisible", game.getVisible() == null || game.getVisible());
model.addAttribute("editGameUuid", gameUuidFromPath(trimToEmpty(game.getWebglPath())));
return "game-register";
}
@PostMapping("/game/{id}/edit")
@Transactional
public ResponseEntity<Map<String, Object>> updateGame(
@PathVariable("id") long id,
@RequestParam(name = "name", required = false) String name,
@RequestParam(name = "gitUrl", required = false) String gitUrl,
@RequestParam(name = "creatorNote", required = false) String creatorNote,
@RequestParam(name = "webglPath", required = false) String webglPath,
@RequestParam(name = "thumbnailUrl", required = false) String thumbnailUrl,
@RequestParam(name = "visible", required = false) String visible,
HttpSession session
) {
Long userId = sessionUserId(session);
if (userId == null) {
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
}
GameData existing = gamesMapper.getGame(id);
if (existing == null) {
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
}
if (!userId.equals(existing.getUserId())) {
return response(HttpStatus.FORBIDDEN, "작성자만 수정할 수 있습니다.");
}
String normalizedName = trimToNull(name);
String normalizedWebglPath = trimToNull(webglPath);
if (normalizedName == null || normalizedName.length() > 80) {
return response(HttpStatus.BAD_REQUEST, "게임 이름을 확인해 주세요.");
}
if (!isGameAssetPath(normalizedWebglPath) || !normalizedWebglPath.endsWith("/index.html")) {
return response(HttpStatus.BAD_REQUEST, "WebGL zip 업로드를 먼저 완료해 주세요.");
}
String normalizedThumbnailUrl = trimToNull(thumbnailUrl);
if (normalizedThumbnailUrl != null && !isGameAssetPath(normalizedThumbnailUrl)) {
return response(HttpStatus.BAD_REQUEST, "썸네일 경로가 올바르지 않습니다.");
}
String normalizedCreatorNote = trimToEmpty(creatorNote);
if (normalizedCreatorNote.length() > 600) {
return response(HttpStatus.BAD_REQUEST, "소개는 600자 이하로 입력해 주세요.");
}
existing.setName(normalizedName);
existing.setCreatorNote(normalizedCreatorNote);
existing.setGitUrl(trimToEmpty(gitUrl));
existing.setWebglPath(normalizedWebglPath);
existing.setThumbnailUrl(normalizedThumbnailUrl == null ? "" : normalizedThumbnailUrl);
existing.setVisible(isChecked(visible));
gamesMapper.updateGame(existing);
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("message", "게임이 수정되었습니다.");
body.put("gameId", existing.getId());
body.put("location", "/game/" + existing.getId());
return ResponseEntity.ok(body);
}
@DeleteMapping("/game/{id}")
@Transactional
public ResponseEntity<Map<String, Object>> deleteGame(@PathVariable("id") long id, HttpSession session) {
Long userId = sessionUserId(session);
if (userId == null) {
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
}
GameData existing = gamesMapper.getGame(id);
if (existing == null) {
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
}
if (!userId.equals(existing.getUserId())) {
return response(HttpStatus.FORBIDDEN, "작성자만 삭제할 수 있습니다.");
}
gamesMapper.softDeleteGameComments(id);
gamesMapper.deleteGameLikes(id);
gamesMapper.softDeleteGame(id);
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("message", "게임이 삭제되었습니다.");
body.put("location", "/");
return ResponseEntity.ok(body);
}
private void addGameModel(Model model, GameData game, Long currentUserId) {
int likeCount = game.getLikeCount() == null ? 0 : game.getLikeCount();
String webglPath = trimToEmpty(game.getWebglPath());
model.addAttribute("gameId", game.getId());
@ -126,6 +242,7 @@ public class GameController {
model.addAttribute("gitUrl", trimToEmpty(game.getGitUrl()));
model.addAttribute("webglUrl", webglPath);
model.addAttribute("webglDeployPath", webglPath);
model.addAttribute("owner", currentUserId != null && currentUserId.equals(game.getUserId()));
}
private Long sessionUserId(HttpSession session) {
@ -167,6 +284,14 @@ public class GameController {
return text == null ? "" : text;
}
private String gameUuidFromPath(String webglPath) {
if (!isGameAssetPath(webglPath)) {
return "";
}
String[] parts = webglPath.split("/");
return parts.length > 2 ? parts[2] : "";
}
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", status.value());

View File

@ -20,6 +20,7 @@ public interface GameCommentsMapper {
deleted_at AS deletedAt
FROM game_comments
WHERE id = #{id}
AND is_delete IS NOT TRUE
""")
GameCommentData getGameComment(long id);
@ -42,8 +43,10 @@ public interface GameCommentsMapper {
SET
nickname = #{nickname},
content = #{content},
deleted_at = #{deletedAt}
deleted_at = #{deletedAt},
is_delete = CASE WHEN #{deletedAt} IS NULL THEN false ELSE true END
WHERE id = #{id}
AND is_delete IS NOT TRUE
""")
int updateGameComment(GameCommentData gameComment);
}

View File

@ -1,9 +1,11 @@
package com.pandoli365.bibimbap.mapper;
import com.pandoli365.bibimbap.data.GameData;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@ -30,6 +32,8 @@ public interface GamesMapper {
FROM games g
JOIN users u ON u.id = g.user_id
WHERE g.id = #{id}
AND g.is_delete IS NOT TRUE
AND u.is_delete IS NOT TRUE
""")
GameData getGame(long id);
@ -51,10 +55,41 @@ public interface GamesMapper {
FROM games g
JOIN users u ON u.id = g.user_id
WHERE g.is_visible IS NOT FALSE
AND g.is_delete IS NOT TRUE
AND u.is_delete IS NOT TRUE
ORDER BY g.sort_order ASC, g.created_at DESC, g.id DESC
""")
List<GameData> getVisibleGames();
@Select("""
SELECT
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
AND g.is_delete IS NOT TRUE
AND u.is_delete IS NOT TRUE
AND (
g.name ILIKE CONCAT('%', #{query}, '%')
OR u.display_name ILIKE CONCAT('%', #{query}, '%')
OR g.creator_note ILIKE CONCAT('%', #{query}, '%')
)
ORDER BY g.sort_order ASC, g.created_at DESC, g.id DESC
""")
List<GameData> searchVisibleGames(@Param("query") String query);
@Insert("""
INSERT INTO games (
user_id,
@ -82,6 +117,7 @@ public interface GamesMapper {
@Select("""
SELECT COALESCE(MAX(sort_order), 0) + 1
FROM games
WHERE is_delete IS NOT TRUE
""")
int nextSortOrder();
@ -95,8 +131,36 @@ public interface GamesMapper {
webgl_path = #{webglPath},
thumbnail_url = #{thumbnailUrl},
is_visible = #{visible},
sort_order = #{sortOrder}
sort_order = #{sortOrder},
updated_at = now()
WHERE id = #{id}
AND is_delete IS NOT TRUE
""")
int updateGame(GameData game);
@Update("""
UPDATE game_comments
SET
is_delete = true,
deleted_at = COALESCE(deleted_at, now())
WHERE game_id = #{gameId}
AND is_delete IS NOT TRUE
""")
int softDeleteGameComments(@Param("gameId") long gameId);
@Delete("""
DELETE FROM game_likes
WHERE game_id = #{gameId}
""")
int deleteGameLikes(@Param("gameId") long gameId);
@Update("""
UPDATE games
SET
is_delete = true,
updated_at = now()
WHERE id = #{id}
AND is_delete IS NOT TRUE
""")
int softDeleteGame(@Param("id") long id);
}

View File

@ -26,6 +26,7 @@ public interface UserAuthIdentitiesMapper {
updated_at AS updatedAt
FROM user_auth_identities
WHERE id = #{id}
AND is_delete IS NOT TRUE
""")
UserAuthIdentityData getUserAuthIdentity(long id);
@ -45,7 +46,7 @@ public interface UserAuthIdentitiesMapper {
FROM user_auth_identities
WHERE provider = #{provider}
AND provider_user_id = #{providerUserId}
AND is_delete = false
AND is_delete IS NOT TRUE
""")
UserAuthIdentityData getUserAuthIdentityByProvider(
@Param("provider") String provider,
@ -86,6 +87,7 @@ public interface UserAuthIdentitiesMapper {
last_login_at = #{lastLoginAt},
updated_at = now()
WHERE id = #{id}
AND is_delete IS NOT TRUE
""")
int updateUserAuthIdentity(UserAuthIdentityData userAuthIdentity);
}

View File

@ -23,6 +23,7 @@ public interface UsersMapper {
updated_at AS updatedAt
FROM users
WHERE id = #{id}
AND is_delete IS NOT TRUE
""")
UserData getUser(long id);
@ -57,6 +58,7 @@ public interface UsersMapper {
last_login_at = #{lastLoginAt},
updated_at = now()
WHERE id = #{id}
AND is_delete IS NOT TRUE
""")
int updateUser(UserData user);
}

View File

@ -18,7 +18,7 @@ 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=optional:classpath:${spring.profiles.active}/db.properties,optional:file:src/main/resources/${spring.profiles.active}/db.properties
# log
mybatis.configuration.log-impl=org.apache.ibatis.logging.slf4j.Slf4jImpl

View File

@ -1,4 +1,7 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<%
boolean owner = Boolean.TRUE.equals(request.getAttribute("owner"));
%>
<!DOCTYPE html>
<html lang="ko">
<head>
@ -79,6 +82,55 @@
box-shadow: 0 4px 16px rgba(232, 165, 75, 0.12);
transform: translateX(-2px);
}
.game-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.game-topbar .game-back {
margin-bottom: 0;
}
.game-owner-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.game-owner-button {
min-height: 2.25rem;
padding: 0 0.8rem;
border: 1px solid var(--border);
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--card-bg);
color: var(--text);
font-size: 0.8125rem;
font-weight: 800;
text-decoration: none;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
.game-owner-button:hover {
border-color: rgba(232, 165, 75, 0.45);
color: var(--accent);
}
.game-owner-button--danger:hover {
border-color: rgba(180, 50, 50, 0.32);
color: #b33;
background: rgba(180, 50, 50, 0.06);
}
@media (max-width: 520px) {
.game-topbar {
align-items: stretch;
flex-direction: column;
}
.game-owner-actions {
justify-content: flex-end;
}
}
.game-info-card {
margin-bottom: 1.75rem;
@ -312,24 +364,6 @@
height: 100%;
border: none;
}
.game-webgl__hint {
margin: 0.85rem 0 0;
padding: 0.75rem 1rem;
font-size: 0.75rem;
line-height: 1.55;
color: var(--text-muted);
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 12px;
}
.game-webgl__hint code {
font-size: 0.68rem;
word-break: break-all;
color: var(--accent);
padding: 0.1em 0.35em;
background: var(--surface);
border-radius: 4px;
}
/* 제작자 한마디 ~ 덧글: 공통 패널 */
.game-community {
margin-top: 2rem;
@ -634,10 +668,18 @@
<body>
<jsp:include page="/WEB-INF/views/header.jsp"/>
<main class="game-page">
<div class="game-topbar">
<a class="game-back" href="${pageContext.request.contextPath}/">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
목록으로
</a>
<% if (owner) { %>
<div class="game-owner-actions" aria-label="게시물 관리">
<a class="game-owner-button" href="${pageContext.request.contextPath}/game/${gameId}/edit">수정</a>
<button type="button" class="game-owner-button game-owner-button--danger" id="game-delete-button">삭제</button>
</div>
<% } %>
</div>
<section class="game-info-card" aria-labelledby="game-title">
<h1 id="game-title">${gameName}</h1>
@ -694,8 +736,6 @@
referrerpolicy="same-origin"></iframe>
</div>
</div>
<p class="game-webgl__hint">실제 Unity WebGL 빌드는 리소스 폴더에 두세요. 예: <code>static${webglDeployPath}</code><br>
준비되면 <code>GameController</code>에서 <code>webglUrl</code>을 해당 경로(<code>webglUrlForGame(id)</code>)로 바꾸면 됩니다.</p>
</section>
<div class="game-community">
@ -747,6 +787,7 @@
<script>
(function () {
var ctx = '${pageContext.request.contextPath}';
var gameId = ${gameId};
var baseLikes = ${likeCount};
var LIKE_KEY = 'bibimbap-game-liked';
@ -782,6 +823,54 @@
var likeBtn = document.getElementById('game-like-btn');
var likeCountEl = document.getElementById('game-like-count');
var gid = String(gameId);
var deleteBtn = document.getElementById('game-delete-button');
function deleteGame() {
fetch(ctx + '/game/' + encodeURIComponent(gid), {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
}).then(function (res) {
return res.json().catch(function () {
return { message: '게시물을 삭제하지 못했습니다.' };
}).then(function (data) {
if (!res.ok) {
throw new Error(data && data.message ? data.message : '게시물을 삭제하지 못했습니다.');
}
return data;
});
}).then(function () {
window.location.href = ctx + '/';
}).catch(function (err) {
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
window.BibimbapModal.alert({
title: '삭제 실패',
message: err.message || '게시물을 삭제하지 못했습니다.',
confirmText: '확인'
});
} else {
alert(err.message || '게시물을 삭제하지 못했습니다.');
}
});
}
if (deleteBtn) {
deleteBtn.addEventListener('click', function () {
if (window.BibimbapModal && typeof window.BibimbapModal.confirm === 'function') {
window.BibimbapModal.confirm({
title: '게시물 삭제',
message: '삭제하시겠습니까? 삭제 후 복구가 불가능합니다.',
confirmText: '삭제',
cancelText: '취소',
onConfirm: deleteGame
});
} else if (confirm('게시물을 삭제할까요?')) {
deleteGame();
}
});
}
function syncLike() {
var liked = isLiked(gid);

View File

@ -5,13 +5,25 @@
String ctx = request.getContextPath();
String rawDisplayName = session.getAttribute("displayName") == null ? "" : String.valueOf(session.getAttribute("displayName"));
String creatorValue = HtmlUtils.htmlEscape(rawDisplayName);
boolean editMode = Boolean.TRUE.equals(request.getAttribute("editMode"));
String editGameId = request.getAttribute("editGameId") == null ? "" : String.valueOf(request.getAttribute("editGameId"));
String formAction = ctx + (editMode ? "/game/" + editGameId + "/edit" : "/game/new");
String pageTitle = editMode ? "게임 수정" : "신규 게임 게시";
String submitText = editMode ? "수정" : "등록";
String editGameName = HtmlUtils.htmlEscape(request.getAttribute("editGameName") == null ? "" : String.valueOf(request.getAttribute("editGameName")));
String editCreatorNote = HtmlUtils.htmlEscape(request.getAttribute("editCreatorNote") == null ? "" : String.valueOf(request.getAttribute("editCreatorNote")));
String editGitUrl = HtmlUtils.htmlEscape(request.getAttribute("editGitUrl") == null ? "" : String.valueOf(request.getAttribute("editGitUrl")));
String editWebglPath = HtmlUtils.htmlEscape(request.getAttribute("editWebglPath") == null ? "" : String.valueOf(request.getAttribute("editWebglPath")));
String editThumbnailUrl = HtmlUtils.htmlEscape(request.getAttribute("editThumbnailUrl") == null ? "" : String.valueOf(request.getAttribute("editThumbnailUrl")));
String editGameUuid = HtmlUtils.htmlEscape(request.getAttribute("editGameUuid") == null ? "" : String.valueOf(request.getAttribute("editGameUuid")));
boolean editVisible = !editMode || Boolean.TRUE.equals(request.getAttribute("editVisible"));
%>
<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>
<title><%= pageTitle %> | bibimbap</title>
<style>
html {
color-scheme: light;
@ -342,59 +354,59 @@
<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>
<h1 class="game-register-heading__title" id="game-register-title"><%= pageTitle %></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">
<form class="game-form" action="<%= formAction %>" method="post" id="game-register-form" data-edit-mode="<%= editMode %>">
<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 />
<input class="game-field__input" type="text" id="game-name" name="name" maxlength="80" autocomplete="off" value="<%= editGameName %>" 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" />
<input class="game-field__input" type="url" id="game-git-url" name="gitUrl" placeholder="https://example.com/repository" autocomplete="url" value="<%= editGitUrl %>" />
</div>
<div class="game-field">
<label class="game-field__label" for="game-webgl-zip">WebGL zip</label>
<label class="game-file" for="game-webgl-zip">
<span class="game-file__action">zip 선택</span>
<span class="game-file__name" id="game-webgl-file-name">선택된 파일 없음</span>
<span class="game-file__name" id="game-webgl-file-name"><%= editMode && !editWebglPath.isBlank() ? "기존 WebGL 유지" : "선택된 파일 없음" %></span>
</label>
<input class="game-file__input" type="file" id="game-webgl-zip" name="webglZip" accept=".zip,application/zip,application/x-zip-compressed" required />
<input class="game-file__input" type="file" id="game-webgl-zip" name="webglZip" accept=".zip,application/zip,application/x-zip-compressed" <%= editMode ? "" : "required" %> />
</div>
<div class="game-field">
<label class="game-field__label" for="game-thumbnail-image">썸네일 이미지</label>
<label class="game-file" for="game-thumbnail-image">
<span class="game-file__action">이미지 선택</span>
<span class="game-file__name" id="game-thumbnail-file-name">선택된 파일 없음</span>
<span class="game-file__name" id="game-thumbnail-file-name"><%= editMode && !editThumbnailUrl.isBlank() ? "기존 썸네일 유지" : "선택된 파일 없음" %></span>
</label>
<input class="game-file__input" type="file" id="game-thumbnail-image" name="thumbnailImage" accept="image/png,image/jpeg,image/webp,image/gif" />
</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>
<textarea class="game-field__textarea" id="game-creator-note" name="creatorNote" maxlength="600"><%= editCreatorNote %></textarea>
</div>
</div>
<input type="hidden" id="game-uploaded-game-uuid" name="gameUuid" />
<input type="hidden" id="game-uploaded-webgl-path" name="webglPath" />
<input type="hidden" id="game-uploaded-thumbnail-url" name="thumbnailUrl" />
<input type="hidden" id="game-uploaded-game-uuid" name="gameUuid" value="<%= editGameUuid %>" />
<input type="hidden" id="game-uploaded-webgl-path" name="webglPath" value="<%= editWebglPath %>" />
<input type="hidden" id="game-uploaded-thumbnail-url" name="thumbnailUrl" value="<%= editThumbnailUrl %>" />
<label class="game-check">
<input type="checkbox" name="visible" value="true" checked />
<input type="checkbox" name="visible" value="true" <%= editVisible ? "checked" : "" %> />
<span>목록에 공개</span>
</label>
<div class="game-form__actions">
<a class="game-button" href="<%= ctx %>/">취소</a>
<a class="game-button" href="<%= editMode ? ctx + "/game/" + editGameId : ctx + "/" %>">취소</a>
<button class="game-button game-button--primary" type="submit" id="game-register-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 id="game-register-submit-label">등록</span>
<span id="game-register-submit-label"><%= submitText %></span>
</button>
</div>
</form>
@ -402,13 +414,17 @@
<aside class="game-preview" aria-label="미리보기">
<div class="game-preview__media">
<% if (editThumbnailUrl.isBlank()) { %>
<img id="preview-thumb" alt="" hidden />
<img class="game-preview__fallback" id="preview-fallback" src="<%= ctx %>/images/logo.png" alt="" width="120" height="120" />
<% } else { %>
<img id="preview-thumb" src="<%= ctx + editThumbnailUrl %>" alt="" />
<% } %>
<img class="game-preview__fallback" id="preview-fallback" src="<%= ctx %>/images/logo.png" alt="" width="120" height="120" <%= editThumbnailUrl.isBlank() ? "" : "hidden" %> />
</div>
<div class="game-preview__body">
<h2 class="game-preview__name" id="preview-name">게임 이름</h2>
<h2 class="game-preview__name" id="preview-name"><%= editGameName.isBlank() ? "게임 이름" : editGameName %></h2>
<p class="game-preview__creator" id="preview-creator"><%= creatorValue.isBlank() ? "제작자" : creatorValue %></p>
<p class="game-preview__note" id="preview-note">소개</p>
<p class="game-preview__note" id="preview-note"><%= editCreatorNote.isBlank() ? "소개" : editCreatorNote %></p>
</div>
</aside>
</div>
@ -418,6 +434,7 @@
(function () {
var ctx = '<%= ctx %>';
var form = document.getElementById('game-register-form');
var editMode = form && form.getAttribute('data-edit-mode') === 'true';
var nameInput = document.getElementById('game-name');
var gitUrlInput = document.getElementById('game-git-url');
var noteInput = document.getElementById('game-creator-note');
@ -489,7 +506,7 @@
if (!submitBtn) return;
submitBtn.disabled = submitting;
if (submitLabel) {
submitLabel.textContent = submitting ? '등록 중...' : submitText;
submitLabel.textContent = submitting ? (editMode ? '수정 중...' : '등록 중...') : submitText;
}
}
@ -524,7 +541,7 @@
});
}
function registerGame() {
function saveGame() {
var body = new URLSearchParams();
body.set('name', valueOr(nameInput, ''));
body.set('gitUrl', valueOr(gitUrlInput, ''));
@ -612,13 +629,16 @@
var webglFile = webglInput && webglInput.files ? webglInput.files[0] : null;
var thumbnailFile = thumbnailInput && thumbnailInput.files ? thumbnailInput.files[0] : null;
if (!webglFile) {
if (!editMode && !webglFile) {
openModal('업로드 실패', 'WebGL zip 파일을 선택해 주세요.');
return;
}
setSubmitting(true);
uploadFile(ctx + '/api/game-files/webgl-zip', webglFile).then(function (webglResult) {
(webglFile ? uploadFile(ctx + '/api/game-files/webgl-zip', webglFile) : Promise.resolve({
gameUuid: uploadedGameUuid ? uploadedGameUuid.value : '',
webglPath: uploadedWebglPath ? uploadedWebglPath.value : ''
})).then(function (webglResult) {
if (uploadedGameUuid && webglResult && webglResult.gameUuid) {
uploadedGameUuid.value = webglResult.gameUuid;
}
@ -644,9 +664,9 @@
if (uploadedThumbnailUrl && thumbnailResult && thumbnailResult.thumbnailUrl) {
uploadedThumbnailUrl.value = thumbnailResult.thumbnailUrl;
}
return registerGame();
return saveGame();
}).then(function (registerResult) {
openModal('게임 등록 완료', '게임 등록이 완료되었습니다.', '확인', function () {
openModal(editMode ? '게임 수정 완료' : '게임 등록 완료', editMode ? '게임이 수정되었습니다.' : '게임 등록이 완료되었습니다.', '확인', function () {
if (registerResult && registerResult.gameId) {
window.location.href = ctx + '/game/' + registerResult.gameId;
} else if (registerResult && registerResult.location) {

View File

@ -12,6 +12,13 @@
if (gamesAttr instanceof List<?>) {
games = (List<GameData>) gamesAttr;
}
String searchQuery = "";
Object searchQueryAttr = request.getAttribute("searchQuery");
if (searchQueryAttr instanceof String) {
searchQuery = ((String) searchQueryAttr).trim();
}
boolean searching = !searchQuery.isBlank();
String escapedSearchQuery = HtmlUtils.htmlEscape(searchQuery);
%>
<!DOCTYPE html>
<html lang="ko">
@ -453,10 +460,10 @@
<main class="page-main">
<section class="search-section" aria-label="게임·제작자 검색">
<div class="home-toolbar">
<form class="search-form" role="search" action="#" method="get">
<form class="search-form" role="search" action="<%= ctx %>/" method="get">
<div class="search-form__field">
<label class="search-form__label" for="q">게임·제작자 검색</label>
<input class="search-form__input" type="search" id="q" name="q" placeholder="게임·제작자 검색" autocomplete="off" enterkeyhint="search" />
<input class="search-form__input" type="search" id="q" name="q" value="<%= escapedSearchQuery %>" placeholder="게임·제작자 검색" autocomplete="off" enterkeyhint="search" />
</div>
<button class="search-form__submit" type="submit">검색</button>
</form>
@ -483,8 +490,12 @@
<section class="card-grid" aria-label="추천 목록">
<% if (games.isEmpty()) { %>
<% if (searching) { %>
<div class="home-empty">"<%= escapedSearchQuery %>"에 대한 검색 결과가 없습니다.</div>
<% } else { %>
<div class="home-empty">아직 공개된 게임이 없습니다.<br>로그인 후 첫 게임을 등록해 주세요.</div>
<% } %>
<% } %>
<%
for (int i = 0; i < games.size(); i++) {
GameData game = games.get(i);
@ -605,7 +616,7 @@
textBtn.title = term;
textBtn.addEventListener('click', function () {
input.value = term;
input.focus();
form.submit();
});
var removeBtn = document.createElement('button');
@ -627,17 +638,18 @@
if (form && input) {
form.addEventListener('submit', function (ev) {
ev.preventDefault();
var q = input.value;
var q = input.value.trim();
input.value = q;
addSearchQuery(q);
renderChips();
/* 실제 검색 URL 연동 시: location.href = '...?q=' + encodeURIComponent(q.trim()); */
});
if (clearBtn) {
clearBtn.addEventListener('click', function () {
clearHistory();
renderChips();
});
}
renderChips();
}

View File

@ -189,6 +189,24 @@
document.addEventListener('keydown', onKeyDown);
confirmBtn.focus();
},
confirm: function (options) {
var opts = options || {};
lastFocused = document.activeElement;
titleEl.textContent = opts.title || '확인';
messageEl.textContent = opts.message || '';
fieldEl.hidden = true;
inputEl.value = '';
inputEl.removeAttribute('maxlength');
inputEl.removeAttribute('placeholder');
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);
confirmBtn.focus();
},
prompt: function (options) {
var opts = options || {};
lastFocused = document.activeElement;