diff --git a/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java b/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java index f95247b..848fcff 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java @@ -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; } 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 00fb385..05173ed 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java @@ -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> 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 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> 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 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> response(HttpStatus status, String message) { Map body = new LinkedHashMap<>(); body.put("status", status.value()); diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java index b5e8279..0cd1dc4 100644 --- a/src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java @@ -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); } diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java index 2b25a0c..04a1d00 100644 --- a/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java @@ -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 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 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); } diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/UserAuthIdentitiesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/UserAuthIdentitiesMapper.java index 954a216..6ab8cfe 100644 --- a/src/main/java/com/pandoli365/bibimbap/mapper/UserAuthIdentitiesMapper.java +++ b/src/main/java/com/pandoli365/bibimbap/mapper/UserAuthIdentitiesMapper.java @@ -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); } diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/UsersMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/UsersMapper.java index 1231799..c1a869b 100644 --- a/src/main/java/com/pandoli365/bibimbap/mapper/UsersMapper.java +++ b/src/main/java/com/pandoli365/bibimbap/mapper/UsersMapper.java @@ -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); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 75a085c..1956720 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -18,8 +18,8 @@ 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 -logging.level.org.apache.ibatis=TRACE \ No newline at end of file +logging.level.org.apache.ibatis=TRACE diff --git a/src/main/webapp/WEB-INF/views/game-detail.jsp b/src/main/webapp/WEB-INF/views/game-detail.jsp index 5efef8c..605762c 100644 --- a/src/main/webapp/WEB-INF/views/game-detail.jsp +++ b/src/main/webapp/WEB-INF/views/game-detail.jsp @@ -1,4 +1,7 @@ <%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> +<% + boolean owner = Boolean.TRUE.equals(request.getAttribute("owner")); +%> @@ -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 @@
- - - 목록으로 - +
+ + + 목록으로 + + <% if (owner) { %> +
+ 수정 + +
+ <% } %> +

${gameName}

@@ -694,8 +736,6 @@ referrerpolicy="same-origin"> -

실제 Unity WebGL 빌드는 리소스 폴더에 두세요. 예: static${webglDeployPath}
- 준비되면 GameController에서 webglUrl을 해당 경로(webglUrlForGame(id))로 바꾸면 됩니다.

@@ -747,6 +787,7 @@