Prepare public repository
This commit is contained in:
commit
23f594c3c5
|
|
@ -0,0 +1,2 @@
|
|||
/mvnw text eol=lf
|
||||
*.cmd text eol=crlf
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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` 프로파일을 삭제해줘. 단, 설정 파일도 함께 삭제해줘.
|
||||
```
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.pandoli365.bibimbap.abstracts;
|
||||
|
||||
public class ErrorResult extends Result{
|
||||
public ErrorResult(int status) {
|
||||
super(status);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.pandoli365.bibimbap.abstracts;
|
||||
|
||||
public abstract class Request {
|
||||
public boolean IsReceivedAllField() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
%>
|
||||
<!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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 |
|
|
@ -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() {
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in New Issue