웹사이트 1차 제작

This commit is contained in:
pandoli365 2026-04-15 22:41:49 +09:00
parent 524417f10d
commit 838db494d1
17 changed files with 2026 additions and 21 deletions

1
.gitignore vendored
View File

@ -1,4 +1,3 @@
HELP.md
target/
.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/

View File

@ -1,4 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="96b9b4a5-b1bc-45b3-baf2-17942ad056f6" name="변경" comment="3차 초기화">
<change beforePath="$PROJECT_DIR$/.gitignore" beforeDir="false" afterPath="$PROJECT_DIR$/.gitignore" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/HELP.md" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/pom.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/main/resources/application.properties" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/main/resources/dev/application.properties" beforeDir="false" afterPath="$PROJECT_DIR$/src/main/resources/dev/application.properties" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/main/resources/live/application.properties" beforeDir="false" afterPath="$PROJECT_DIR$/src/main/resources/live/application.properties" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="MavenImportPreferences">
<option name="explicitlyEnabledProfiles" value="dev" />
</component>
<component name="ProjectColorInfo"><![CDATA[{
"associatedIndex": 2
}]]></component>
<component name="ProjectId" id="3COUL5RY7op3FL1tvImKjMX11Vl" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
<option name="showMembers" value="true" />
<option name="showVisibilityIcons" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"RunOnceActivity.git.unshallow": "true",
"Spring Boot.이름이 지정되지 않았습니다.executor": "Run",
"git-widget-placeholder": "main",
"kotlin-language-version-configured": "true"
}
}]]></component>
<component name="RunManager">
<configuration name="이름이 지정되지 않았습니다" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
<module name="bibimbap" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.pandoli365.bibimbap.BibimbapApplication" />
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-jdk-30f59d01ecdd-26cb7f24e5b0-intellij.indexing.shared.core-IU-253.29346.240" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task id="LOCAL-00001" summary="3차 초기화">
<option name="closed" value="true" />
<created>1776256387267</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1776256387267</updated>
</task>
<task active="true" id="Default" summary="디폴트 작업">
<changelist id="96b9b4a5-b1bc-45b3-baf2-17942ad056f6" name="변경" comment="3차 초기화" />
<created>1776256595415</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1776256595415</updated>
</task>
<option name="localTasksCounter" value="2" />
<servers />
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="3차 초기화" />
<option name="LAST_COMMIT_MESSAGE" value="3차 초기화" />
</component>
</project>

18
HELP.md
View File

@ -1,18 +0,0 @@
# Getting Started
### Reference Documentation
For further reference, please consider the following sections:
* [Official Apache Maven documentation](https://maven.apache.org/guides/index.html)
* [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/3.5.14-SNAPSHOT/maven-plugin)
* [Create an OCI image](https://docs.spring.io/spring-boot/3.5.14-SNAPSHOT/maven-plugin/build-image.html)
### Maven Parent overrides
Due to Maven's design, elements are inherited from the parent POM to the project POM.
While most of the inheritance is fine, it also inherits unwanted elements like `<license>` and `<developers>` from the
parent.
To prevent this, the project POM contains empty overrides for these elements.
If you manually switch to a different parent and actually want the inheritance, you need to remove those overrides.

39
pom.xml
View File

@ -35,6 +35,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
@ -126,4 +130,39 @@
</pluginRepository>
</pluginRepositories>
<profiles>
<profile>
<id>dev</id>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<profiles>
<profile>dev</profile>
</profiles>
</configuration>
</plugin>
</plugins>
</build>
</profile>
<profile>
<id>live</id>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<profiles>
<profile>live</profile>
</profiles>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

View File

@ -0,0 +1,14 @@
package com.pandoli365.bibimbap.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("index");
}
}

View File

@ -0,0 +1,91 @@
package com.pandoli365.bibimbap.game;
/**
* 데모용 게임 메타데이터. DB 연동 저장소로 대체하면 됩니다.
*/
public final class GameCatalog {
private GameCatalog() {
}
public static final int COUNT = 20;
public static final String[] NAMES = {
"별빛 정원", "던전 카페", "픽셀 레이서", "구름 위를 걷다",
"마지막 타임라인", "요리하는 용", "네온 시티", "작은 숲의 집",
"역전 재판인", "달무리 탐정", "코드 러너", "바다 노래",
"시간 상자", "불꽃 학원", "조용한 우주", "종이 비행기",
"거울 미로", "손끝 RPG", "노을 역", "꿈의 도서관"
};
public static final String[] CREATORS = {
"Studio Luna", "김민재", "PixelCat", "이하늘",
"Team Horizon", "별작업실", "NEON LAB", "숲그림",
"Court Games", "달무리", "dev.han", "wave.sound",
"BoxSoft", "학원제작소", "COSMOS", "PaperFly",
"mirror.inc", "손끝게임즈", "노을팀", "책벌레"
};
public static final int[] LIKE_COUNTS = {
1284, 56, 8921, 234,
1205, 445, 678, 9012,
3400, 12, 567, 89,
4456, 223, 7777, 156,
990, 34, 2100, 888
};
/** 제작자 한마디 (상세 페이지 하단) */
public static final String[] CREATOR_NOTES = {
"밤하늘을 걸으며 힐링하고 싶어서 만들었습니다. 플레이 해 주셔서 감사합니다.",
"카페 던전은 사랑입니다. 버그 제보는 Git 이슈로 부탁드려요.",
"속도감 있는 레이싱! 컨트롤은 WASD, 모바일은 추후 지원 예정이에요.",
"구름 위를 걷는 기분을 WebGL로 담아봤습니다.",
"스토리에 집중했습니다. 엔딩까지 플레이해 주세요.",
"요리 도트에 진심입니다. 레시피 아이디어 환영합니다.",
"네온 사인이 마음에 드셨다면 별 하나 부탁드려요.",
"작은 집에서 시작하는 하루. 소소한 상호작용을 즐겨 주세요.",
"반전 스토리, 스포일러는 삼가 주세요!",
"추리는 끝이 없어요. 힌트는 커뮤니티에 올려 두었습니다.",
"코딩하듯 플레이하는 러너. PR도 환영합니다.",
"바다 소리를 들으며 플레이해 보세요. 이어폰 추천!",
"시간 루프가 헷갈리면 메모를 추천합니다.",
"학원물이지만 가볍게 즐겨 주세요.",
"우주는 넓고 할 일은 많습니다. 업데이트 예정이에요.",
"종이비행기처럼 가볍게 날아가 보세요.",
"거울 방향이 헷갈릴 수 있어요. 인내심을…",
"손끝으로 즐기는 턴제 RPG입니다.",
"노을이 지는 역에서 만나요.",
"책 속 세계로 오신 걸 환영합니다."
};
public static final String[] GIT_URLS = {
"https://github.com/example/starlight-garden",
"https://github.com/example/dungeon-cafe",
"https://github.com/example/pixel-racer",
"https://github.com/example/cloud-walk",
"https://github.com/example/last-timeline",
"https://github.com/example/cooking-dragon",
"https://github.com/example/neon-city",
"https://github.com/example/small-forest",
"https://github.com/example/court-game",
"https://github.com/example/moon-detective",
"https://github.com/example/code-runner",
"https://github.com/example/sea-song",
"https://github.com/example/time-box",
"https://github.com/example/flame-school",
"https://github.com/example/quiet-space",
"https://github.com/example/paper-plane",
"https://github.com/example/mirror-maze",
"https://github.com/example/fingertip-rpg",
"https://github.com/example/sunset-station",
"https://github.com/example/dream-library"
};
public static boolean isValidId(int id) {
return id >= 1 && id <= COUNT;
}
public static int toIndex(int id) {
return id - 1;
}
}

View File

@ -0,0 +1,38 @@
package com.pandoli365.bibimbap.web;
import com.pandoli365.bibimbap.game.GameCatalog;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@Controller
public class GameController {
/**
* Unity WebGL 빌드를 넣을 경로 : /webgl/game-{id}/index.html
* 해당 폴더에 Build + TemplateData 두면 iframe 으로 로드됩니다.
*/
public static String webglUrlForGame(int gameId) {
return "/webgl/game-" + gameId + "/index.html";
}
@GetMapping("/game/{id}")
public String gameDetail(@PathVariable("id") int id, Model model) {
if (!GameCatalog.isValidId(id)) {
return "redirect:/";
}
int idx = GameCatalog.toIndex(id);
model.addAttribute("gameId", id);
model.addAttribute("gameName", GameCatalog.NAMES[idx]);
model.addAttribute("creator", GameCatalog.CREATORS[idx]);
model.addAttribute("likeCount", GameCatalog.LIKE_COUNTS[idx]);
model.addAttribute("likeCountFormatted", String.format("%,d", GameCatalog.LIKE_COUNTS[idx]));
model.addAttribute("creatorNote", GameCatalog.CREATOR_NOTES[idx]);
model.addAttribute("gitUrl", GameCatalog.GIT_URLS[idx]);
/* 데모: 공통 플레이스홀더. 실제 빌드는 static/webgl/game-{id}/ 에 두고 아래 한 줄을 webglUrlForGame(id) 로 바꾸면 됩니다. */
model.addAttribute("webglUrl", "/webgl/placeholder/index.html");
model.addAttribute("webglDeployPath", webglUrlForGame(id));
return "game-detail";
}
}

View File

@ -1 +1,8 @@
spring.application.name=bibimbap
# JSP 뷰 (루트에 두어 항상 로드되도록 함)
spring.mvc.view.prefix=/WEB-INF/views/
spring.mvc.view.suffix=.jsp
spring.profiles.active=dev
spring.config.import=optional:classpath:dev/application.properties

View File

@ -6,7 +6,7 @@ spring.mvc.view.prefix=/WEB-INF/views/
spring.mvc.view.suffix=.jsp
# common
spring.config.import=classpath:db.properties, classpath:common.properties
spring.config.import=classpath:dev/db.properties
# IP
server.address=0.0.0.0

View File

@ -6,7 +6,7 @@ spring.mvc.view.prefix=/WEB-INF/views/
spring.mvc.view.suffix=.jsp
# common
spring.config.import=classpath:db.properties, classpath:common.properties
spring.config.import=classpath:live/db.properties
# IP
server.address=0.0.0.0

View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGL placeholder</title>
<style>
* { box-sizing: border-box; margin: 0; }
html, body { height: 100%; background: #1a1a1a; color: #e8e8e8; font-family: system-ui, sans-serif; }
.wrap {
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1rem;
text-align: center;
gap: 0.75rem;
}
.box {
width: min(90vw, 480px);
aspect-ratio: 16 / 10;
border: 2px dashed rgba(232, 165, 75, 0.45);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(ellipse at center, #2a2a2a 0%, #141414 100%);
}
p { font-size: 0.875rem; line-height: 1.5; opacity: 0.9; max-width: 28rem; }
code { font-size: 0.8rem; color: #e8a54b; }
</style>
</head>
<body>
<div class="wrap">
<div class="box" role="img" aria-label="WebGL 캔버스 영역"></div>
<p>Unity WebGL 빌드 출력물(<code>index.html</code>, <code>Build</code>, <code>TemplateData</code>)을<br>
<code>src/main/resources/static/webgl/game-번호/</code> 에 넣고 상세 페이지에서 iframe 경로를 연결하세요.</p>
</div>
</body>
</html>

View File

@ -0,0 +1,93 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<style>
.site-footer {
margin-top: auto;
border-top: 1px solid var(--footer-border, rgba(0, 0, 0, 0.08));
background: var(--footer-bg, rgba(0, 0, 0, 0.02));
color: var(--footer-text, #1a1a1a);
}
html[data-theme="dark"] .site-footer {
--footer-border: rgba(255, 255, 255, 0.08);
--footer-bg: rgba(0, 0, 0, 0.35);
--footer-text: #ece8e1;
--footer-muted: #a39e96;
}
html:not([data-theme="dark"]) .site-footer {
--footer-muted: #5c5c5c;
}
.site-footer__inner {
max-width: 72rem;
margin: 0 auto;
padding: 1.75rem max(1rem, env(safe-area-inset-left)) calc(1.75rem + env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-right));
}
.site-footer__brand {
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--footer-text);
}
.site-footer__note {
margin: 0 0 1rem;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--footer-muted, #5c5c5c);
}
.site-footer__meta {
margin: 0 0 1.15rem;
font-size: 0.8125rem;
line-height: 1.6;
color: var(--footer-muted, #5c5c5c);
}
.site-footer__meta a {
color: #e8a54b;
text-decoration: none;
font-weight: 500;
}
.site-footer__meta a:hover {
text-decoration: underline;
}
.site-footer__links {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.25rem;
}
.site-footer__links a {
font-size: 0.8125rem;
font-weight: 600;
color: var(--footer-text);
text-decoration: none;
opacity: 0.9;
}
.site-footer__links a:hover {
color: #e8a54b;
opacity: 1;
}
.site-footer__sep {
display: inline-block;
width: 1px;
height: 0.75rem;
background: var(--footer-border, rgba(0, 0, 0, 0.12));
margin: 0 0.15rem;
vertical-align: middle;
}
</style>
<footer class="site-footer" role="contentinfo">
<div class="site-footer__inner">
<p class="site-footer__brand">© 2026 유니티 개발자 모임</p>
<p class="site-footer__note">본 웹사이트는 비영리 단체 퍼슈트도서관의 후원으로 운영됩니다.</p>
<p class="site-footer__meta">
문의: <a href="mailto:admin@pandoli365.com">admin@pandoli365.com</a>
<span class="site-footer__sep" aria-hidden="true"></span>
제작: 김판돌
</p>
<ul class="site-footer__links">
<li><a href="https://x.com/Fursuit_Library" target="_blank" rel="noopener noreferrer">X</a></li>
<li><a href="https://furlib.pandoli365.com" target="_blank" rel="noopener noreferrer">퍼슈트도서관</a></li>
<li><a href="https://gitea.pandoli365.com/pandoli365/bibimbap" target="_blank" rel="noopener noreferrer">소스 코드</a></li>
</ul>
</div>
</footer>

View File

@ -0,0 +1,900 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<!DOCTYPE html>
<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>${gameName} — bibimbap</title>
<style>
html {
color-scheme: light;
--surface: #faf8f5;
--card-bg: #fff;
--text: #1a1a1a;
--text-muted: #5c5c5c;
--accent: #e8a54b;
--accent-soft: rgba(232, 165, 75, 0.14);
--border: rgba(0, 0, 0, 0.08);
--webgl-bg: #0d0d0d;
--panel-shadow: 0 4px 24px rgba(26, 26, 26, 0.06);
--radius: 16px;
}
html[data-theme="dark"] {
color-scheme: dark;
--surface: #121212;
--card-bg: #1e1e1e;
--text: #ece8e1;
--text-muted: #a39e96;
--border: rgba(255, 255, 255, 0.1);
--accent-soft: rgba(232, 165, 75, 0.12);
--webgl-bg: #080808;
--panel-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
}
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
background: var(--surface);
color: var(--text);
}
html[data-theme="dark"] body {
background: linear-gradient(165deg, #1c1a18 0%, var(--surface) 38%, #0e0e0e 100%);
background-attachment: fixed;
}
html:not([data-theme="dark"]) body {
background: linear-gradient(165deg, #fff9f0 0%, var(--surface) 45%, #f3ede6 100%);
background-attachment: fixed;
}
.game-page {
max-width: 56rem;
margin: 0 auto;
padding: 1.25rem max(1rem, env(safe-area-inset-left)) 2.75rem max(1rem, env(safe-area-inset-right));
}
.game-back {
display: inline-flex;
align-items: center;
gap: 0.45rem;
margin-bottom: 1.25rem;
padding: 0.5rem 0.95rem 0.5rem 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted);
text-decoration: none;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 999px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
transition: color 0.2s ease, border-color 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease;
}
.game-back svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
opacity: 0.85;
}
.game-back:hover {
color: var(--accent);
border-color: rgba(232, 165, 75, 0.45);
box-shadow: 0 4px 16px rgba(232, 165, 75, 0.12);
transform: translateX(-2px);
}
.game-info-card {
margin-bottom: 1.75rem;
padding: 1.5rem 1.35rem 1.6rem;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--panel-shadow);
position: relative;
overflow: hidden;
}
.game-info-card::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--accent), rgba(232, 165, 75, 0.25), transparent);
opacity: 0.9;
}
.game-info-card h1 {
margin: 0 0 1.25rem;
padding-bottom: 0.85rem;
font-size: clamp(1.5rem, 4.5vw, 1.85rem);
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1.2;
border-bottom: 1px solid var(--border);
}
.game-meta-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
}
@media (max-width: 640px) {
.game-meta-grid {
grid-template-columns: 1fr;
}
}
.game-meta-item {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.85rem 0.9rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.game-meta-item:hover {
border-color: rgba(232, 165, 75, 0.25);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
}
html[data-theme="dark"] .game-meta-item:hover {
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
}
.game-meta-item__row {
display: flex;
align-items: center;
gap: 0.45rem;
}
.game-meta-item__icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 8px;
background: var(--accent-soft);
color: var(--accent);
flex-shrink: 0;
}
.game-meta-item__icon svg {
width: 0.95rem;
height: 0.95rem;
}
.game-meta-item__label {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
}
.game-meta-item__value {
font-size: 0.9375rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
word-break: break-word;
}
.game-meta-item--likes {
padding: 0.65rem 0.75rem 0.75rem;
}
.game-meta-like-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
width: 100%;
margin: 0;
margin-top: 0.1rem;
padding: 0.55rem 0.7rem;
font: inherit;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--card-bg);
color: var(--text);
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease, transform 0.12s ease;
-webkit-tap-highlight-color: transparent;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.game-meta-like-btn:hover {
border-color: rgba(232, 165, 75, 0.45);
box-shadow: 0 3px 12px rgba(232, 165, 75, 0.12);
}
.game-meta-like-btn:active {
transform: scale(0.98);
}
.game-meta-like-btn[aria-pressed="true"] {
background: var(--accent-soft);
border-color: rgba(232, 165, 75, 0.55);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.game-meta-item--likes:has(.game-meta-like-btn[aria-pressed="true"]) {
border-color: rgba(232, 165, 75, 0.38);
}
.game-meta-like-btn__heart {
width: 1.15rem;
height: 1.15rem;
flex-shrink: 0;
}
.game-meta-like-btn__heart path {
fill: none;
stroke: var(--accent);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.game-meta-like-btn[aria-pressed="true"] .game-meta-like-btn__heart path {
fill: var(--accent);
stroke: none;
}
.game-meta-like-btn__count {
font-size: 1.0625rem;
font-weight: 800;
letter-spacing: -0.03em;
font-variant-numeric: tabular-nums;
color: var(--accent);
}
html[data-theme="dark"] .game-meta-like-btn[aria-pressed="true"] .game-meta-like-btn__count {
color: #f0c47a;
}
.game-webgl {
margin-bottom: 1.75rem;
}
.game-webgl__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin-bottom: 0.85rem;
}
.game-webgl__head h2 {
margin: 0;
font-size: 1.05rem;
font-weight: 800;
letter-spacing: -0.03em;
}
.game-webgl__icon {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 10px;
background: var(--accent-soft);
color: var(--accent);
}
.game-webgl__icon svg {
width: 1.1rem;
height: 1.1rem;
}
.game-webgl__badge {
margin-left: auto;
padding: 0.2rem 0.55rem;
font-size: 0.625rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
background: var(--accent-soft);
border: 1px solid rgba(232, 165, 75, 0.35);
border-radius: 6px;
}
@media (max-width: 480px) {
.game-webgl__badge {
margin-left: 0;
}
}
.game-webgl__shell {
position: relative;
padding: 3px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(232, 165, 75, 0.45) 0%, rgba(232, 165, 75, 0.08) 50%, rgba(255, 255, 255, 0.06) 100%);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
}
html:not([data-theme="dark"]) .game-webgl__shell {
box-shadow: 0 12px 36px rgba(26, 26, 26, 0.12);
}
.game-webgl__frame-wrap {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
max-height: min(70vh, 520px);
background: var(--webgl-bg);
border-radius: 15px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.35);
}
html:not([data-theme="dark"]) .game-webgl__frame-wrap {
border-color: rgba(0, 0, 0, 0.12);
}
.game-webgl__frame-wrap iframe {
position: absolute;
inset: 0;
width: 100%;
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;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.game-panel {
position: relative;
padding: 1.35rem 1.35rem 1.5rem;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--panel-shadow);
overflow: hidden;
}
.game-panel::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: linear-gradient(180deg, var(--accent) 0%, rgba(232, 165, 75, 0.35) 100%);
border-radius: 4px 0 0 4px;
}
.game-panel__head {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1rem;
}
.game-panel__icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 10px;
background: var(--accent-soft);
color: var(--accent);
flex-shrink: 0;
}
.game-panel__icon svg {
width: 1.15rem;
height: 1.15rem;
}
.game-panel__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
}
.game-panel__subtitle {
margin: 0.15rem 0 0;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-muted);
}
.game-panel--creator .game-panel__quote {
margin: 0;
padding: 1rem 1rem 1rem 1.15rem;
font-size: 0.9375rem;
line-height: 1.75;
font-style: normal;
color: var(--text);
background: linear-gradient(135deg, var(--surface) 0%, transparent 55%);
border-radius: 12px;
border-left: 3px solid var(--accent);
}
.game-panel__git-wrap {
margin-top: 1.15rem;
padding-top: 1rem;
border-top: 1px dashed var(--border);
}
.game-panel__git {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 1rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
text-decoration: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
word-break: break-all;
}
.game-panel__git svg {
flex-shrink: 0;
width: 1.1rem;
height: 1.1rem;
color: var(--text-muted);
}
.game-panel__git:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-soft);
}
.game-panel__git:hover svg {
color: var(--accent);
}
.game-comments__composer {
margin-bottom: 1.25rem;
}
.game-comments__label {
display: block;
margin-bottom: 0.45rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted);
}
.game-comments__form {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.game-comments__form textarea {
width: 100%;
box-sizing: border-box;
min-height: 6.5rem;
padding: 0.85rem 1rem;
font-size: 0.875rem;
font-family: inherit;
line-height: 1.55;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
resize: vertical;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.game-comments__form textarea::placeholder {
color: var(--text-muted);
opacity: 0.85;
}
.game-comments__form textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 4px rgba(232, 165, 75, 0.18);
}
.game-comments__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.game-comments__form button[type="submit"] {
padding: 0.55rem 1.5rem;
font-size: 0.875rem;
font-weight: 700;
color: #1a1a1a;
background: linear-gradient(180deg, #f0c978 0%, var(--accent) 100%);
border: none;
border-radius: 10px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(232, 165, 75, 0.35);
transition: transform 0.15s ease, box-shadow 0.2s ease;
-webkit-tap-highlight-color: transparent;
}
.game-comments__form button[type="submit"]:hover {
box-shadow: 0 4px 14px rgba(232, 165, 75, 0.45);
}
.game-comments__form button[type="submit"]:active {
transform: scale(0.98);
}
.game-comments__hint {
font-size: 0.6875rem;
color: var(--text-muted);
}
.game-comments__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.game-comments__empty {
margin: 0;
padding: 1.5rem 1rem;
font-size: 0.875rem;
text-align: center;
color: var(--text-muted);
background: var(--surface);
border-radius: 12px;
border: 1px dashed var(--border);
}
.game-comments__item {
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.85rem 1rem;
font-size: 0.875rem;
line-height: 1.55;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
transition: border-color 0.2s ease;
}
.game-comments__item:hover {
border-color: rgba(232, 165, 75, 0.2);
}
.game-comments__avatar {
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 800;
color: var(--accent);
background: var(--accent-soft);
border: 1px solid rgba(232, 165, 75, 0.25);
}
.game-comments__body {
flex: 1;
min-width: 0;
}
.game-comments__meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 0.65rem;
margin-bottom: 0.4rem;
}
.game-comments__nick {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
}
.game-comments__meta time {
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--text-muted);
}
.game-comments__item p {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: var(--text);
}
.game-comments__footer {
margin-top: 0.5rem;
display: flex;
justify-content: flex-end;
}
.game-comments__delete {
padding: 0.25rem 0.6rem;
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.game-comments__delete:hover {
color: #b33;
border-color: rgba(180, 50, 50, 0.25);
background: rgba(180, 50, 50, 0.06);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
@media (max-width: 480px) {
.game-panel {
padding: 1.15rem 1.1rem 1.35rem;
}
.game-comments__actions {
flex-direction: column-reverse;
align-items: stretch;
}
.game-comments__form button[type="submit"] {
width: 100%;
}
.game-comments__hint {
text-align: center;
}
}
</style>
</head>
<body>
<jsp:include page="/WEB-INF/views/header.jsp"/>
<main class="game-page">
<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>
<section class="game-info-card" aria-labelledby="game-title">
<h1 id="game-title">${gameName}</h1>
<div class="game-meta-grid">
<div class="game-meta-item">
<div class="game-meta-item__row">
<span class="game-meta-item__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 9h16M4 15h16M10 3L8 21M16 3l-2 18"/></svg>
</span>
<span class="game-meta-item__label">번호</span>
</div>
<span class="game-meta-item__value">#${gameId}</span>
</div>
<div class="game-meta-item">
<div class="game-meta-item__row">
<span class="game-meta-item__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</span>
<span class="game-meta-item__label">제작자</span>
</div>
<span class="game-meta-item__value">${creator}</span>
</div>
<div class="game-meta-item game-meta-item--likes">
<div class="game-meta-item__row">
<span class="game-meta-item__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>
</span>
<span class="game-meta-item__label">좋아요</span>
</div>
<button type="button" id="game-like-btn" class="game-meta-like-btn" aria-pressed="false" aria-label="좋아요">
<svg class="game-meta-like-btn__heart" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
<span id="game-like-count" class="game-meta-like-btn__count">${likeCountFormatted}</span>
</button>
</div>
</div>
</section>
<section class="game-webgl" aria-labelledby="webgl-heading">
<div class="game-webgl__head">
<span class="game-webgl__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</span>
<h2 id="webgl-heading">플레이</h2>
<span class="game-webgl__badge">WebGL</span>
</div>
<div class="game-webgl__shell">
<div class="game-webgl__frame-wrap">
<iframe title="${gameName} WebGL"
src="${pageContext.request.contextPath}${webglUrl}"
allow="fullscreen; autoplay; xr-spatial-tracking"
loading="eager"
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">
<section class="game-panel game-panel--creator" aria-labelledby="creator-heading">
<div class="game-panel__head">
<span class="game-panel__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>
</span>
<div>
<h2 id="creator-heading" class="game-panel__title">제작자의 한마디</h2>
<p class="game-panel__subtitle">이 게임을 만들며 전하고 싶었던 이야기예요.</p>
</div>
</div>
<blockquote class="game-panel__quote">${creatorNote}</blockquote>
<div class="game-panel__git-wrap">
<a class="game-panel__git" href="${gitUrl}" target="_blank" rel="noopener noreferrer">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
소스 코드 · GitHub
</a>
</div>
</section>
<section class="game-panel game-panel--comments" aria-labelledby="comments-heading">
<div class="game-panel__head">
<span class="game-panel__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
</span>
<div>
<h2 id="comments-heading" class="game-panel__title">덧글</h2>
<p class="game-panel__subtitle">플레이 소감이나 버그 제보를 남겨 주세요.</p>
</div>
</div>
<div class="game-comments__composer">
<form id="game-comment-form" class="game-comments__form" novalidate>
<label class="game-comments__label" for="game-comment-input">내용</label>
<textarea id="game-comment-input" name="comment" rows="4" maxlength="1000" placeholder="여기에 덧글을 작성해 주세요." required></textarea>
<div class="game-comments__actions">
<span class="game-comments__hint">최대 1,000자</span>
<button type="submit">덧글 등록</button>
</div>
</form>
</div>
<p id="game-comments-empty" class="game-comments__empty" hidden>아직 덧글이 없습니다.<br>첫 번째 덧글을 남겨 보세요.</p>
<ul id="game-comment-list" class="game-comments__list" aria-labelledby="comments-heading"></ul>
</section>
</div>
</main>
<jsp:include page="/WEB-INF/views/footer.jsp"/>
<script>
(function () {
var gameId = ${gameId};
var baseLikes = ${likeCount};
var LIKE_KEY = 'bibimbap-game-liked';
var COMMENT_KEY = 'bibimbap-game-comments';
function getLikedMap() {
try {
var raw = localStorage.getItem(LIKE_KEY);
var o = raw ? JSON.parse(raw) : {};
return o && typeof o === 'object' ? o : {};
} catch (e) {
return {};
}
}
function setLiked(gameIdStr, liked) {
var m = getLikedMap();
if (liked) m[gameIdStr] = true;
else delete m[gameIdStr];
try {
localStorage.setItem(LIKE_KEY, JSON.stringify(m));
} catch (err) {}
}
function isLiked(gameIdStr) {
return !!getLikedMap()[gameIdStr];
}
function formatCount(n) {
return n.toLocaleString('ko-KR');
}
var likeBtn = document.getElementById('game-like-btn');
var likeCountEl = document.getElementById('game-like-count');
var gid = String(gameId);
function syncLike() {
var liked = isLiked(gid);
likeBtn.setAttribute('aria-pressed', liked ? 'true' : 'false');
likeBtn.setAttribute('aria-label', liked ? '좋아요 취소' : '좋아요');
likeCountEl.textContent = formatCount(baseLikes + (liked ? 1 : 0));
}
likeBtn.addEventListener('click', function () {
var next = !isLiked(gid);
setLiked(gid, next);
syncLike();
});
syncLike();
function getComments() {
try {
var raw = localStorage.getItem(COMMENT_KEY);
var o = raw ? JSON.parse(raw) : {};
var list = o[gid];
return Array.isArray(list) ? list : [];
} catch (e) {
return [];
}
}
function saveComments(list) {
try {
var raw = localStorage.getItem(COMMENT_KEY);
var o = raw ? JSON.parse(raw) : {};
if (typeof o !== 'object' || o === null) o = {};
o[gid] = list;
localStorage.setItem(COMMENT_KEY, JSON.stringify(o));
} catch (err) {}
}
var listEl = document.getElementById('game-comment-list');
var emptyEl = document.getElementById('game-comments-empty');
var form = document.getElementById('game-comment-form');
var input = document.getElementById('game-comment-input');
var DEFAULT_NICK = 'test';
function renderComments() {
var items = getComments().slice().sort(function (a, b) {
return (b.at || '').localeCompare(a.at || '');
});
listEl.innerHTML = '';
emptyEl.hidden = items.length > 0;
items.forEach(function (c) {
var li = document.createElement('li');
li.className = 'game-comments__item';
var av = document.createElement('div');
var nick = (c.nickname && String(c.nickname).trim()) ? String(c.nickname).trim() : DEFAULT_NICK;
av.className = 'game-comments__avatar';
av.setAttribute('aria-hidden', 'true');
av.textContent = nick.charAt(0).toUpperCase();
var body = document.createElement('div');
body.className = 'game-comments__body';
var meta = document.createElement('div');
meta.className = 'game-comments__meta';
var nickEl = document.createElement('span');
nickEl.className = 'game-comments__nick';
nickEl.textContent = nick;
var t = document.createElement('time');
t.dateTime = c.at || '';
try {
t.textContent = c.at ? new Date(c.at).toLocaleString('ko-KR') : '';
} catch (e) {
t.textContent = '';
}
meta.appendChild(nickEl);
meta.appendChild(t);
var p = document.createElement('p');
p.textContent = c.text || '';
body.appendChild(meta);
body.appendChild(p);
if (c.id) {
var foot = document.createElement('div');
foot.className = 'game-comments__footer';
var del = document.createElement('button');
del.type = 'button';
del.className = 'game-comments__delete';
del.textContent = '삭제';
del.setAttribute('aria-label', '이 덧글 삭제');
del.addEventListener('click', function () {
var next = getComments().filter(function (x) { return x.id !== c.id; });
saveComments(next);
renderComments();
});
foot.appendChild(del);
body.appendChild(foot);
}
li.appendChild(av);
li.appendChild(body);
listEl.appendChild(li);
});
}
form.addEventListener('submit', function (ev) {
ev.preventDefault();
var text = (input.value || '').trim();
if (!text) return;
var id = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : String(Date.now()) + '-' + Math.random();
var entry = { id: id, text: text, at: new Date().toISOString(), nickname: DEFAULT_NICK };
var list = getComments();
list.push(entry);
saveComments(list);
input.value = '';
renderComments();
});
renderComments();
})();
</script>
</body>
</html>

View File

@ -0,0 +1,173 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
<style>
/* 기본(라이트) 헤더 — data-theme 없을 때도 동일 톤 */
.site-header {
--header-bg: #ffffff;
--header-text: #1a1a1a;
--header-muted: rgba(26, 26, 26, 0.55);
--header-border: rgba(0, 0, 0, 0.08);
--header-btn-hover: rgba(0, 0, 0, 0.06);
box-shadow: 0 1px 0 var(--header-border), 0 4px 16px rgba(0, 0, 0, 0.06);
}
html[data-theme="dark"] .site-header {
--header-bg: #1a1a1a;
--header-text: #f5f0e8;
--header-muted: rgba(245, 240, 232, 0.65);
--header-border: rgba(255, 255, 255, 0.06);
--header-btn-hover: rgba(255, 255, 255, 0.08);
box-shadow: 0 1px 0 var(--header-border), 0 8px 24px rgba(0, 0, 0, 0.35);
}
.site-header {
--accent: #e8a54b;
--header-h: 4rem;
background: var(--header-bg);
color: var(--header-text);
position: sticky;
top: 0;
z-index: 100;
}
.site-header__inner {
max-width: 72rem;
margin: 0 auto;
padding: 0 max(1rem, env(safe-area-inset-left)) 0 max(1rem, env(safe-area-inset-right));
height: var(--header-h);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.site-header__brand {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
color: inherit;
font-weight: 600;
letter-spacing: -0.02em;
font-size: 1.125rem;
min-width: 0;
}
.site-header__brand:hover {
color: var(--accent);
}
.site-header__logo {
height: 2.25rem;
width: auto;
display: block;
border-radius: 6px;
object-fit: contain;
flex-shrink: 0;
}
.site-header__actions {
display: flex;
align-items: center;
gap: 0.375rem;
flex-shrink: 0;
}
.site-header__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
padding: 0;
border: none;
border-radius: 10px;
background: transparent;
color: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s ease;
}
.site-header__icon-btn:hover {
background: var(--header-btn-hover);
}
.site-header__icon-btn:active {
transform: scale(0.96);
}
.site-header__icon-btn svg {
width: 1.375rem;
height: 1.375rem;
}
.site-header__icon-btn .icon-sun {
display: none;
}
.site-header__icon-btn .icon-moon {
display: block;
}
html[data-theme="dark"] .site-header__icon-btn .icon-moon {
display: none;
}
html[data-theme="dark"] .site-header__icon-btn .icon-sun {
display: block;
}
.site-header__profile {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: 50%;
background: var(--header-btn-hover);
color: var(--header-text);
text-decoration: none;
transition: background 0.15s ease, transform 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.site-header__profile:hover {
background: var(--accent);
color: #1a1a1a;
}
.site-header__profile:active {
transform: scale(0.96);
}
.site-header__profile svg {
width: 1.35rem;
height: 1.35rem;
}
</style>
<header class="site-header" role="banner">
<div class="site-header__inner">
<a class="site-header__brand" href="${pageContext.request.contextPath}/">
<img class="site-header__logo" src="${pageContext.request.contextPath}/images/logo.png" alt="" width="120" height="36" />
<span>bibimbap</span>
</a>
<div class="site-header__actions">
<button type="button" class="site-header__icon-btn" id="theme-toggle" aria-label="다크 모드로 전환" title="테마 전환">
<%-- 라이트 모드일 때 달(다크로), 다크 모드일 때 해(라이트로) --%>
<svg class="icon-moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
<svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
</svg>
</button>
<a class="site-header__profile" href="${pageContext.request.contextPath}/#" aria-label="프로필" title="프로필">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</a>
</div>
</div>
</header>
<script>
(function () {
var btn = document.getElementById('theme-toggle');
if (!btn) return;
function label() {
var dark = document.documentElement.getAttribute('data-theme') === 'dark';
btn.setAttribute('aria-label', dark ? '라이트 모드로 전환' : '다크 모드로 전환');
btn.setAttribute('title', dark ? '라이트 모드' : '다크 모드');
}
label();
btn.addEventListener('click', function () {
var next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
try { localStorage.setItem('bibimbap-theme', next); } catch (e) {}
label();
});
})();
</script>

View File

@ -0,0 +1,535 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
<%@ page import="com.pandoli365.bibimbap.game.GameCatalog" %>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
<title>bibimbap</title>
<style>
html {
color-scheme: light;
--surface: #faf8f5;
--card-bg: #fff;
--text: #1a1a1a;
--text-muted: #5c5c5c;
--accent: #e8a54b;
--border: rgba(0, 0, 0, 0.08);
--card-media-1: #f0ebe3;
--card-media-2: #e5ddd2;
--card-media-3: #dccfb8;
--card-shadow: rgba(0, 0, 0, 0.06);
--search-btn-text: #1a1a1a;
}
html[data-theme="dark"] {
color-scheme: dark;
--surface: #121212;
--card-bg: #1e1e1e;
--text: #ece8e1;
--text-muted: #a39e96;
--border: rgba(255, 255, 255, 0.1);
--card-media-1: #2a2620;
--card-media-2: #1f1c18;
--card-media-3: #3d3528;
--card-shadow: rgba(0, 0, 0, 0.35);
--search-btn-text: #1a1a1a;
}
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
background: var(--surface);
color: var(--text);
}
.page-main {
max-width: 72rem;
margin: 0 auto;
padding: 1rem max(1rem, env(safe-area-inset-left)) 2.5rem max(1rem, env(safe-area-inset-right));
}
@media (min-width: 640px) {
.page-main {
padding-top: 1.5rem;
}
}
/* 검색 */
.search-section {
margin-bottom: 1.25rem;
}
.search-form {
display: flex;
gap: 0.5rem;
align-items: stretch;
}
.search-form__field {
flex: 1;
min-width: 0;
}
.search-form__label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.search-form__input {
width: 100%;
box-sizing: border-box;
height: 3rem;
padding: 0 1rem 0 2.75rem;
font-size: 1rem;
border: 1px solid var(--border);
border-radius: 12px;
color: var(--text);
background-color: var(--card-bg);
background-repeat: no-repeat;
background-position: 0.875rem 50%;
background-size: 1.125rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' fill='none' stroke='%23999' stroke-width='2'%3E%3Ccircle cx='8.5' cy='8.5' r='5.5'/%3E%3Cpath d='M12 12l5 5'/%3E%3C/svg%3E");
}
html[data-theme="dark"] .search-form__input {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' fill='none' stroke='%23aaa' stroke-width='2'%3E%3Ccircle cx='8.5' cy='8.5' r='5.5'/%3E%3Cpath d='M12 12l5 5'/%3E%3C/svg%3E");
}
.search-form__input::placeholder {
color: var(--text-muted);
}
.search-form__input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.25);
}
.search-form__submit {
flex-shrink: 0;
min-width: 4.5rem;
height: 3rem;
padding: 0 1rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--search-btn-text);
background: var(--accent);
border: none;
border-radius: 12px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.search-form__submit:active {
transform: scale(0.98);
}
.search-history {
margin-top: 0.75rem;
}
.search-history[hidden] {
display: none !important;
}
.search-history__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.search-history__label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.02em;
}
.search-history__clear {
padding: 0.25rem 0.5rem;
font-size: 0.6875rem;
font-weight: 500;
color: var(--text-muted);
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.search-history__clear:hover {
color: var(--accent);
background: rgba(232, 165, 75, 0.12);
}
.search-history__chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.search-history__chip {
display: inline-flex;
align-items: center;
gap: 0.15rem;
max-width: 100%;
padding: 0.2rem 0.35rem 0.2rem 0.65rem;
font-size: 0.8125rem;
color: var(--text);
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 999px;
-webkit-tap-highlight-color: transparent;
}
.search-history__chip-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 12rem;
margin: 0;
padding: 0.15rem 0;
font: inherit;
color: inherit;
text-align: left;
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
}
.search-history__chip-text:hover {
color: var(--accent);
}
.search-history__chip-remove {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
padding: 0;
font-size: 1rem;
line-height: 1;
color: var(--text-muted);
background: transparent;
border: none;
border-radius: 50%;
cursor: pointer;
}
.search-history__chip-remove:hover {
color: var(--text);
background: rgba(0, 0, 0, 0.06);
}
html[data-theme="dark"] .search-history__chip-remove:hover {
background: rgba(255, 255, 255, 0.08);
}
/* 카드 그리드: 모바일 2열 → 태블릿 3열 → 데스크톱 4~5열 */
.card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.625rem;
}
@media (min-width: 480px) {
.card-grid {
gap: 0.75rem;
}
}
@media (min-width: 640px) {
.card-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
}
@media (min-width: 900px) {
.card-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (min-width: 1200px) {
.card-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
a.card {
display: flex;
flex-direction: column;
border-radius: 12px;
overflow: hidden;
background: var(--card-bg);
border: 1px solid var(--border);
box-shadow: 0 2px 8px var(--card-shadow);
text-decoration: none;
color: inherit;
cursor: pointer;
transition: box-shadow 0.18s ease, transform 0.18s ease, border-color 0.18s ease;
}
a.card:hover {
box-shadow: 0 6px 16px var(--card-shadow);
transform: translateY(-2px);
border-color: rgba(232, 165, 75, 0.35);
}
a.card:focus {
outline: none;
}
a.card:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* 가로:세로 = 4:5 (포스터/썸네일에 흔한 비율, 3:5보다 덜 길쭉해 모바일 그리드에 균형 있음) */
.card__media {
position: relative;
width: 100%;
aspect-ratio: 4 / 5;
background: var(--card-media-2);
}
.card__index {
position: absolute;
top: 0.45rem;
left: 0.45rem;
z-index: 2;
padding: 0.25rem 0.5rem;
font-size: 0.6875rem;
font-weight: 800;
letter-spacing: -0.02em;
color: #1a1a1a;
background: rgba(255, 255, 255, 0.93);
border-radius: 6px;
line-height: 1;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
html[data-theme="dark"] .card__index {
background: rgba(30, 30, 30, 0.93);
color: #ece8e1;
}
.card__img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
/* 이미지 없음: 로고만 중앙 */
.card__media-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 14%;
background: linear-gradient(160deg, var(--card-media-1) 0%, var(--card-media-2) 45%, var(--card-media-3) 100%);
}
.card__logo-fallback {
width: min(52%, 7.5rem);
height: auto;
max-height: 42%;
object-fit: contain;
opacity: 0.88;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.12));
}
html[data-theme="dark"] .card__logo-fallback {
opacity: 0.92;
filter: drop-shadow(0 2px 10px rgba(0, 0, 0, 0.45));
}
.card__body {
padding: 0.5rem 0.625rem 0.625rem;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.card__game-name {
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
color: var(--text);
}
.card__creator {
margin: 0;
font-size: 0.6875rem;
color: var(--text-muted);
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card__likes {
margin: 0.15rem 0 0;
font-size: 0.6875rem;
font-weight: 600;
color: var(--text);
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
}
</style>
</head>
<body>
<jsp:include page="/WEB-INF/views/header.jsp"/>
<main class="page-main">
<section class="search-section" aria-label="게임·제작자 검색">
<form class="search-form" role="search" action="#" 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" />
</div>
<button class="search-form__submit" type="submit">검색</button>
</form>
<div class="search-history" id="search-history" hidden>
<div class="search-history__head">
<span class="search-history__label">최근 검색</span>
<button type="button" class="search-history__clear" id="search-history-clear">전체 삭제</button>
</div>
<div class="search-history__chips" id="search-history-chips" role="list"></div>
</div>
</section>
<section class="card-grid" aria-label="추천 목록">
<%-- 카드 데이터: GameCatalog (DB 연동 시 교체) --%>
<%
String ctx = request.getContextPath();
for (int i = 0; i < GameCatalog.COUNT; i++) {
int displayIndex = i + 1;
/* 썸네일 URL — DB 연동 시 null·빈 문자열이면 로고 폴백 */
String thumbUrl = null; // 예: list.get(i).getThumbnailUrl()
boolean hasImage = thumbUrl != null && !thumbUrl.isBlank();
%>
<a class="card" href="<%= ctx %>/game/<%= displayIndex %>" aria-labelledby="game-title-<%= i %>">
<div class="card__media">
<span class="card__index" aria-hidden="true">#<%= displayIndex %></span>
<% if (hasImage) { %>
<img class="card__img" src="<%= thumbUrl %>" alt="" loading="lazy" decoding="async" />
<% } else { %>
<div class="card__media-empty" role="presentation">
<img class="card__logo-fallback" src="<%= ctx %>/images/logo.png" alt="" width="120" height="120" />
</div>
<% } %>
</div>
<div class="card__body">
<h2 class="card__game-name" id="game-title-<%= i %>"><%= GameCatalog.NAMES[i] %></h2>
<p class="card__creator"><%= GameCatalog.CREATORS[i] %></p>
<p class="card__likes">좋아요 <%= String.format("%,d", GameCatalog.LIKE_COUNTS[i]) %></p>
</div>
</a>
<%
}
%>
</section>
</main>
<jsp:include page="/WEB-INF/views/footer.jsp"/>
<script>
(function () {
var STORAGE_KEY = 'bibimbap-search-history';
var MAX_ITEMS = 10;
function loadHistory() {
try {
var raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
var parsed = JSON.parse(raw);
var list = Array.isArray(parsed) ? parsed.filter(function (s) { return typeof s === 'string' && s.trim(); }) : [];
if (list.length > MAX_ITEMS) {
list = list.slice(0, MAX_ITEMS);
saveHistory(list);
}
return list;
} catch (e) {
return [];
}
}
function saveHistory(items) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
} catch (e) {
/* 용량 초과·비공개 모드 등 */
}
}
/** 검색어 추가: 앞에 넣고, 동일 문구(대소문자 무시)는 제거 후 최대 개수 유지 */
function addSearchQuery(query) {
var q = (query || '').trim();
if (!q) return;
var list = loadHistory();
var lower = q.toLowerCase();
list = list.filter(function (item) { return item.toLowerCase() !== lower; });
list.unshift(q);
if (list.length > MAX_ITEMS) list = list.slice(0, MAX_ITEMS);
saveHistory(list);
}
function removeSearchQuery(query) {
var lower = (query || '').toLowerCase();
var list = loadHistory().filter(function (item) { return item.toLowerCase() !== lower; });
saveHistory(list);
}
function clearHistory() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch (e) {}
}
var form = document.querySelector('.search-form');
var input = document.getElementById('q');
var historyEl = document.getElementById('search-history');
var chipsEl = document.getElementById('search-history-chips');
var clearBtn = document.getElementById('search-history-clear');
function renderChips() {
var list = loadHistory();
chipsEl.innerHTML = '';
if (!list.length) {
historyEl.hidden = true;
return;
}
historyEl.hidden = false;
list.forEach(function (term) {
var chip = document.createElement('div');
chip.className = 'search-history__chip';
chip.setAttribute('role', 'listitem');
var textBtn = document.createElement('button');
textBtn.type = 'button';
textBtn.className = 'search-history__chip-text';
textBtn.textContent = term;
textBtn.title = term;
textBtn.addEventListener('click', function () {
input.value = term;
input.focus();
});
var removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'search-history__chip-remove';
removeBtn.setAttribute('aria-label', '삭제: ' + term);
removeBtn.textContent = '\u00D7';
removeBtn.addEventListener('click', function (ev) {
ev.stopPropagation();
removeSearchQuery(term);
renderChips();
});
chip.appendChild(textBtn);
chip.appendChild(removeBtn);
chipsEl.appendChild(chip);
});
}
if (form && input) {
form.addEventListener('submit', function (ev) {
ev.preventDefault();
var q = input.value;
addSearchQuery(q);
renderChips();
/* 실제 검색 URL 연동 시: location.href = '...?q=' + encodeURIComponent(q.trim()); */
});
clearBtn.addEventListener('click', function () {
clearHistory();
renderChips();
});
renderChips();
}
})();
</script>
</body>
</html>

View File

@ -0,0 +1,13 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
<script>
(function () {
try {
var k = 'bibimbap-theme';
var t = localStorage.getItem(k);
if (t !== 'light' && t !== 'dark') {
t = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', t);
} catch (e) {}
})();
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB