Prepare public repository

This commit is contained in:
pandoli365 2026-05-03 16:13:32 +09:00
commit 23f594c3c5
29 changed files with 3116 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
/mvnw text eol=lf
*.cmd text eol=crlf

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
target/
.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### Local secrets ###
src/main/resources/*/db.properties
!src/main/resources/*/db.properties.example

3
.mvn/wrapper/maven-wrapper.properties vendored Normal file
View File

@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip

View File

@ -0,0 +1,57 @@
# Profile Management Prompt
아래 프롬프트를 AI에게 전달하고, 원하는 작업과 프로파일 이름을 함께 적으면 됩니다.
## Prompt
```text
너는 이 Spring Boot Maven 프로젝트의 프로파일 관리 담당자다.
요청:
- 작업: [추가 또는 삭제]
- 프로파일 이름: [예: dev, live, stage, local]
프로젝트 규칙:
- Maven 프로파일은 `pom.xml``<profiles>` 아래에 정의한다.
- 각 Maven 프로파일은 `spring-boot-maven-plugin``<profiles><profile>{profileName}</profile></profiles>` 설정으로 같은 이름의 Spring profile을 실행하도록 만든다.
- Spring profile별 설정 파일은 `src/main/resources/{profileName}/application.properties`에 둔다.
- DB 설정 파일은 `src/main/resources/{profileName}/db.properties`에 둔다.
- `{profileName}/application.properties``spring.config.import=classpath:{profileName}/db.properties`를 포함해야 한다.
- 루트 `src/main/resources/application.properties`는 기본 실행 프로파일만 관리한다. 사용자가 기본 프로파일 변경을 요청하지 않았다면 수정하지 않는다.
- `spring-boot-starter-parent`를 다시 추가하지 않는다. 이 프로젝트는 `spring-boot-dependencies` BOM import 방식으로 관리한다.
- `native`, `nativeTest` 프로파일을 추가하지 않는다.
프로파일 추가 작업:
1. `pom.xml`에 같은 이름의 Maven profile이 이미 있는지 확인한다.
2. 없으면 기존 `dev` 또는 `live` 프로파일 블록과 같은 형식으로 새 profile을 추가한다.
3. `src/main/resources/{profileName}/application.properties`를 만든다.
4. `src/main/resources/{profileName}/db.properties`를 만든다.
5. 새 설정 파일은 기존 `dev` 또는 `live` 설정을 참고하되, 프로파일 이름이 들어가는 값은 `{profileName}`으로 맞춘다.
6. DB 접속 정보처럼 민감하거나 환경마다 달라지는 값은 기존 값을 무작정 복사하지 말고 placeholder 또는 사용자가 제공한 값으로 둔다.
프로파일 삭제 작업:
1. `pom.xml``<profiles>`에서 해당 profile 블록만 삭제한다.
2. `src/main/resources/{profileName}` 디렉터리의 설정 파일 삭제 여부를 사용자 요청에서 확인한다.
3. 삭제 대상 프로파일이 루트 `application.properties``spring.profiles.active` 또는 `spring.config.import`에 쓰이고 있으면, 삭제 전에 대체 기본 프로파일을 사용자에게 확인한다.
4. 다른 프로파일이나 공통 설정은 건드리지 않는다.
검증:
- Maven Wrapper를 사용할 수 있으면 `./mvnw validate`를 실행한다.
- 가능하면 `./mvnw help:all-profiles`로 원하는 프로파일만 표시되는지 확인한다.
- 검증을 실행할 수 없으면 그 이유를 최종 답변에 명확히 적는다.
최종 답변:
- 변경한 파일 목록을 짧게 요약한다.
- 추가/삭제된 프로파일 이름을 명시한다.
- 실행한 검증 명령과 결과를 적는다.
```
## Example
```text
위 프롬프트를 참고해서 `stage` 프로파일을 추가해줘.
```
```text
위 프롬프트를 참고해서 `dev` 프로파일을 삭제해줘. 단, 설정 파일도 함께 삭제해줘.
```

295
mvnw vendored Normal file
View File

@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"

189
mvnw.cmd vendored Normal file
View File

@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"

237
pom.xml Normal file
View File

@ -0,0 +1,237 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.pandoli365</groupId>
<artifactId>bibimbap</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>war</packaging>
<name>bibimbap</name>
<description>bibimbap</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>21</java.version>
<maven.compiler.release>${java.version}</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>3.5.14-SNAPSHOT</spring-boot.version>
<lombok.version>1.18.44</lombok.version>
<maven-clean-plugin.version>3.4.1</maven-clean-plugin.version>
<maven-compiler-plugin.version>3.14.1</maven-compiler-plugin.version>
<maven-deploy-plugin.version>3.1.4</maven-deploy-plugin.version>
<maven-failsafe-plugin.version>3.5.5</maven-failsafe-plugin.version>
<maven-install-plugin.version>3.1.4</maven-install-plugin.version>
<maven-resources-plugin.version>3.3.1</maven-resources-plugin.version>
<maven-surefire-plugin.version>3.5.5</maven-surefire-plugin.version>
<maven-war-plugin.version>3.4.0</maven-war-plugin.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<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>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-clean-plugin</artifactId>
<version>${maven-clean-plugin.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven-compiler-plugin.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-deploy-plugin</artifactId>
<version>${maven-deploy-plugin.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>${maven-failsafe-plugin.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-install-plugin</artifactId>
<version>${maven-install-plugin.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>${maven-resources-plugin.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven-surefire-plugin.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>${maven-war-plugin.version}</version>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<executions>
<execution>
<id>default-compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</execution>
<execution>
<id>default-testCompile</id>
<phase>test-compile</phase>
<goals>
<goal>testCompile</goal>
</goals>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<releases>
<enabled>false</enabled>
</releases>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<releases>
<enabled>false</enabled>
</releases>
</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,13 @@
package com.pandoli365.bibimbap;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BibimbapApplication {
public static void main(String[] args) {
SpringApplication.run(BibimbapApplication.class, args);
}
}

View File

@ -0,0 +1,13 @@
package com.pandoli365.bibimbap;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
public class ServletInitializer extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(BibimbapApplication.class);
}
}

View File

@ -0,0 +1,7 @@
package com.pandoli365.bibimbap.abstracts;
public class ErrorResult extends Result{
public ErrorResult(int status) {
super(status);
}
}

View File

@ -0,0 +1,7 @@
package com.pandoli365.bibimbap.abstracts;
public abstract class Request {
public boolean IsReceivedAllField() {
return true;
}
}

View File

@ -0,0 +1,27 @@
package com.pandoli365.bibimbap.abstracts;
public abstract class Result {
public int status;
public String message;
public Result() {}
public Result(int status) {
this.status = status;
switch (status)
{
case 200:
this.message = "Success"; return;
case 400:
this.message = "Invalid Request"; return;
case 401:
this.message = "세션 만료"; return;
case 1000:
this.message = "NULL USERS"; return;
default:
System.out.println("잘못된 status 케이스");
this.message = "";
return;
}
}
}

View File

@ -0,0 +1,18 @@
package com.pandoli365.bibimbap.abstracts;
import jakarta.servlet.http.HttpSession;
public abstract class Service<Req extends Request, Res extends Result> {
public boolean is_login = false;
public abstract Res StartService(HttpSession session, Req request);
public Res ChackService(HttpSession session, Req request){
if (is_login && session.getAttribute("id") == null)
return (Res) new ErrorResult(401);
if(request != null && !request.IsReceivedAllField())
return (Res) new ErrorResult(401);
return StartService(session, request);
}
}

View File

@ -0,0 +1,68 @@
package com.pandoli365.bibimbap.controller;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.Arrays;
//이곳에서는 뷰만 제어
@Controller
public class WebMvcController implements WebMvcConfigurer, ErrorController {
@RequestMapping("/error")
public ModelAndView errorView(HttpServletRequest request) {
ModelAndView mv = new ModelAndView("/errer");
Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
if (status != null) {
mv.addObject("statusCode", status);
}
return mv;
}
@RequestMapping("/{pageName}")
public ModelAndView mainView(@PathVariable("pageName") String pageName,
@RequestParam(required = false) String id,
HttpSession session,
HttpServletRequest request) {
ModelAndView mv = new ModelAndView();
// if (!ALLOWED_PAGES.contains(keyword)) {
// return new ModelAndView("redirect:/main");
// }
switch (pageName) {
case "error":
mv.setViewName("/errer");
Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
if (status != null) {
mv.addObject("statusCode", status);
}
break;
default:
mv.setViewName("/index");
break;
}
return mv;
}
/// 접속기기 모바일 확인 함수
private boolean isMobileDevice(HttpServletRequest request) {
String userAgent = request.getHeader("User-Agent");
if (userAgent == null) {
return false; // User-Agent가 없는 경우 기본값은 false
}
// 모바일 User-Agent 리스트 (대표적인 기기들)
String[] mobileKeywords = {"Android", "iPhone", "iPad", "iPod", "Windows Phone", "Mobile", "Opera Mini", "BlackBerry"};
// User-Agent 문자열이 모바일 기기를 포함하는지 검사
return Arrays.stream(mobileKeywords).anyMatch(userAgent::contains);
}
}

View File

@ -0,0 +1,34 @@
package com.pandoli365.bibimbap.controller.api;
import com.pandoli365.bibimbap.game.GameCatalog;
import org.springframework.context.annotation.Configuration;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@Configuration
public class GameController {
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

@ -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,7 @@
spring.application.name=bibimbap
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

@ -0,0 +1,23 @@
spring.profiles.active=dev
spring.application.name=bibimbap
# ViewResolver
spring.mvc.view.prefix=/WEB-INF/views/
spring.mvc.view.suffix=.jsp
# common
spring.config.import=classpath:dev/db.properties
# IP
server.address=0.0.0.0
# encoding
server.servlet.encoding.force-response=true
# file upload Max Size
spring.servlet.multipart.max-file-size=100MB
spring.servlet.multipart.max-request-size=100MB
# log
mybatis.configuration.log-impl=org.apache.ibatis.logging.slf4j.Slf4jImpl
logging.level.org.apache.ibatis=TRACE

View File

@ -0,0 +1,4 @@
spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://localhost:5432/bibimbap?currentSchema=dev
spring.datasource.username=your_username
spring.datasource.password=your_password

View File

@ -0,0 +1,23 @@
spring.profiles.active=live
spring.application.name=bibimbap
# ViewResolver
spring.mvc.view.prefix=/WEB-INF/views/
spring.mvc.view.suffix=.jsp
# common
spring.config.import=classpath:live/db.properties
# IP
server.address=0.0.0.0
# encoding
server.servlet.encoding.force-response=true
# file upload Max Size
spring.servlet.multipart.max-file-size=100MB
spring.servlet.multipart.max-request-size=100MB
# log
mybatis.configuration.log-impl=org.apache.ibatis.logging.slf4j.Slf4jImpl
logging.level.org.apache.ibatis=TRACE

View File

@ -0,0 +1,4 @@
spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://localhost:5432/bibimbap?currentSchema=live
spring.datasource.username=your_username
spring.datasource.password=your_password

View File

@ -0,0 +1,231 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" isErrorPage="true" %>
<%!
private String htmlEscape(Object value) {
if (value == null) {
return "";
}
return String.valueOf(value)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
%>
<!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);
--button-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);
--button-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);
display: flex;
flex-direction: column;
}
.page-main {
width: 100%;
max-width: 72rem;
box-sizing: border-box;
margin: 0 auto;
padding: 2rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.error-panel {
width: min(100%, 38rem);
padding: 2rem;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card-bg);
box-shadow: 0 2px 8px var(--card-shadow);
text-align: center;
}
.error-panel__media {
width: 8rem;
height: 8rem;
margin: 0 auto 1.5rem;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(160deg, var(--card-media-1) 0%, var(--card-media-2) 45%, var(--card-media-3) 100%);
}
.error-panel__logo {
width: 5rem;
height: auto;
object-fit: contain;
opacity: 0.9;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.12));
}
html[data-theme="dark"] .error-panel__logo {
opacity: 0.94;
filter: drop-shadow(0 2px 10px rgba(0, 0, 0, 0.45));
}
.error-panel__eyebrow {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.02em;
}
.error-panel__title {
margin: 0;
font-size: clamp(1.75rem, 4vw, 2.5rem);
line-height: 1.2;
letter-spacing: 0;
color: var(--text);
}
.error-panel__description {
max-width: 28rem;
margin: 1rem auto 0;
font-size: 0.9375rem;
line-height: 1.65;
color: var(--text-muted);
}
.error-panel__meta {
margin: 1rem auto 0;
padding: 0.75rem 1rem;
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-muted);
background: rgba(0, 0, 0, 0.025);
font-size: 0.8125rem;
line-height: 1.5;
text-align: left;
word-break: break-word;
}
html[data-theme="dark"] .error-panel__meta {
background: rgba(255, 255, 255, 0.04);
}
.error-panel__actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.625rem;
margin-top: 1.5rem;
}
.error-panel__button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 3rem;
padding: 0 1rem;
border-radius: 12px;
border: 1px solid var(--border);
font-size: 0.9375rem;
font-weight: 700;
text-decoration: none;
color: var(--text);
background: var(--card-bg);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.error-panel__button--primary {
border-color: transparent;
color: var(--button-text);
background: var(--accent);
}
.error-panel__button:hover {
border-color: rgba(232, 165, 75, 0.45);
box-shadow: 0 4px 12px var(--card-shadow);
}
.error-panel__button:active {
transform: scale(0.98);
}
@media (max-width: 480px) {
.page-main {
align-items: flex-start;
padding-top: 1.5rem;
}
.error-panel {
padding: 1.5rem 1rem;
}
.error-panel__button {
width: 100%;
}
}
</style>
</head>
<body>
<jsp:include page="/WEB-INF/views/header.jsp"/>
<%
String ctx = request.getContextPath();
Object statusCode = request.getAttribute("jakarta.servlet.error.status_code");
Object requestUri = request.getAttribute("jakarta.servlet.error.request_uri");
Object message = request.getAttribute("jakarta.servlet.error.message");
if (message == null || String.valueOf(message).isBlank()) {
message = exception != null ? exception.getMessage() : null;
}
%>
<main class="page-main">
<section class="error-panel" aria-labelledby="error-title">
<div class="error-panel__media" aria-hidden="true">
<img class="error-panel__logo" src="<%= ctx %>/images/logo.png" alt="" width="120" height="120" />
</div>
<p class="error-panel__eyebrow">
<% if (statusCode != null) { %>
ERROR <%= statusCode %>
<% } else { %>
ERROR
<% } %>
</p>
<h1 class="error-panel__title" id="error-title">페이지를 불러오지 못했습니다</h1>
<p class="error-panel__description">
요청을 처리하는 중 문제가 발생했습니다. 잠시 후 다시 시도하거나 홈으로 돌아가 다른 메뉴를 이용해 주세요.
</p>
<% if (requestUri != null || message != null) { %>
<div class="error-panel__meta" aria-label="오류 상세 정보">
<% if (requestUri != null) { %>
<div>요청 경로: <%= htmlEscape(requestUri) %></div>
<% } %>
<% if (message != null) { %>
<div>메시지: <%= htmlEscape(message) %></div>
<% } %>
</div>
<% } %>
<div class="error-panel__actions">
<a class="error-panel__button error-panel__button--primary" href="<%= ctx %>/">홈으로 이동</a>
<a class="error-panel__button" href="javascript:history.back()">이전 페이지</a>
</div>
</section>
</main>
<jsp:include page="/WEB-INF/views/footer.jsp"/>
</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

View File

@ -0,0 +1,13 @@
package com.pandoli365.bibimbap;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class BibimbapApplicationTests {
@Test
void contextLoads() {
}
}