From eeb14b40cbd99aeaf604938501ed9c19d4268e62 Mon Sep 17 00:00:00 2001 From: "NJ\\skrwn" Date: Fri, 4 Sep 2026 01:44:59 +0900 Subject: [PATCH] =?UTF-8?q?=EB=82=98=EC=9D=98=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 23 ++ .env.example | 16 ++ .gitignore | 7 + Dockerfile | 51 +++++ GameServer/GameServer.csproj | 7 + GameServer/wwwroot/css/style.css | 236 +++++--------------- GameServer/wwwroot/js/views/home.js | 331 ++++++++++------------------ README.md | 291 +++++++++++++++++++++++- deploy/genesis.env.example | 15 ++ deploy/genesis.service | 42 ++++ deploy/healthcheck.sh | 115 ++++++++++ deploy/publish-linux.cmd | 5 + deploy/publish-linux.ps1 | 144 ++++++++++++ deploy/publish-linux.sh | 124 +++++++++++ docker-compose.yml | 52 +++++ 15 files changed, 1054 insertions(+), 405 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 deploy/genesis.env.example create mode 100644 deploy/genesis.service create mode 100644 deploy/healthcheck.sh create mode 100644 deploy/publish-linux.cmd create mode 100644 deploy/publish-linux.ps1 create mode 100644 deploy/publish-linux.sh create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2d7f700 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +# 빌드 컨텍스트에서 제외할 것들. +# 특히 wwwroot/uploads 는 올려 둔 사진이 수십 MB 라 이미지에 들어가면 안 된다. + +**/bin +**/obj +**/.vs +**/.vscode +**/.git +**/.gitignore +**/node_modules +**/*.user +**/*.suo + +# 업로드된 사진은 볼륨으로 관리한다. 이미지에 굽지 않는다. +GameServer/wwwroot/uploads/ + +# 비밀값 +.env +**/appsettings.Development.json + +# 문서·산출물 +README.md +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..120f1d4 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# 이 파일을 .env 로 복사한 뒤 값을 채운다. (.env 는 git 에 올라가지 않는다) +# +# cp .env.example .env +# +# 여기 넣은 값이 appsettings.json 보다 우선한다. + +# MariaDB 연결 문자열. +# 컨테이너 안에서는 localhost 가 컨테이너 자신을 가리킨다. +# DB 가 같은 호스트에서 돌더라도 localhost 가 아니라 실제 주소를 적어야 한다. +DB_CONNECTION=Server=152.69.235.249;Port=3306;Database=mygame;Uid=root;Pwd=여기에-DB-비밀번호; + +# 포트폴리오 관리자 비밀번호 (글쓰기 로그인) +ADMIN_PASSWORD=여기에-관리자-비밀번호 + +# 업로드 한 장의 최대 크기 (바이트). 기본 100MB +MAX_UPLOAD_BYTES=104857600 diff --git a/.gitignore b/.gitignore index 3dd3587..b84af00 100644 --- a/.gitignore +++ b/.gitignore @@ -276,3 +276,10 @@ __pycache__/ # Portfolio uploaded media GameServer/wwwroot/uploads/* !GameServer/wwwroot/uploads/.gitkeep + +# 배포 산출물 +/artifacts/ + +# 컨테이너 배포용 비밀값 · 호스트 업로드 폴더 +.env +/uploads/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b9c5284 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# Genesis 게임 서버 + 포트폴리오 사이트 — 리눅스 컨테이너 이미지 +# +# 빌드 컨텍스트는 저장소 루트다. (Dockerfile 이 루트에 있으므로 `docker build .`) +# +# docker build -t genesis-gameserver . +# docker run -d --name genesis -p 5281:8080 genesis-gameserver +# +# 주의: 업로드된 이미지는 /app/wwwroot/uploads 에 쌓인다. +# 볼륨으로 빼 두지 않으면 컨테이너를 새로 만들 때 전부 사라진다. + +# ───────────────────────────────────────────────────────────── +# 1단계 — 빌드 +# 프로젝트가 net9.0 을 대상으로 하므로 SDK 9 이미지를 쓴다. +# ───────────────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +# csproj 만 먼저 복사해 restore 한다. +# 소스만 바뀐 경우 이 레이어가 캐시되어 재빌드가 빨라진다. +COPY GameServer/GameServer.csproj GameServer/ +RUN dotnet restore GameServer/GameServer.csproj + +COPY . . +RUN dotnet publish GameServer/GameServer.csproj \ + -c Release \ + -o /app/publish \ + -p:UseAppHost=false + +# ───────────────────────────────────────────────────────────── +# 2단계 — 실행 +# ───────────────────────────────────────────────────────────── +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final +WORKDIR /app + +COPY --from=build /app/publish . + +# 업로드 폴더를 만들고 비루트 사용자(app, UID 1654)에게 쓰기 권한을 준다. +# 호스트 폴더를 마운트할 때도 같은 UID 로 맞춰야 한다. (README 참고) +RUN mkdir -p /app/wwwroot/uploads && chown -R app:app /app/wwwroot/uploads + +# 마운트를 잊어도 최소한 컨테이너 수명 동안은 동작하도록 볼륨을 선언해 둔다. +VOLUME ["/app/wwwroot/uploads"] + +# .NET 8 부터 컨테이너 기본 포트는 8080 이다. (루트 권한이 필요 없는 포트) +ENV ASPNETCORE_HTTP_PORTS=8080 \ + ASPNETCORE_ENVIRONMENT=Production \ + DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false +EXPOSE 8080 + +USER app +ENTRYPOINT ["dotnet", "GameServer.dll"] diff --git a/GameServer/GameServer.csproj b/GameServer/GameServer.csproj index 54d13dd..b73965f 100644 --- a/GameServer/GameServer.csproj +++ b/GameServer/GameServer.csproj @@ -5,6 +5,13 @@ enable enable + + + + + diff --git a/GameServer/wwwroot/css/style.css b/GameServer/wwwroot/css/style.css index 8f29b83..fdc8b7c 100644 --- a/GameServer/wwwroot/css/style.css +++ b/GameServer/wwwroot/css/style.css @@ -448,209 +448,37 @@ em { color: var(--text-mute); } -/* -------------------------------------------------------------------------- - 6. 인라인 카테고리 필터 — 칩이 아니라 글자 - -------------------------------------------------------------------------- */ - -.filters { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: var(--space-4); -} - -.filter { - position: relative; - display: inline-flex; - align-items: baseline; - gap: var(--space-2); - padding: var(--space-1) 0; - color: var(--text-mute); - font-size: var(--step--1); - font-weight: 600; - white-space: nowrap; - transition: color var(--dur) var(--ease); -} - -.filter:hover { - color: var(--text); -} - -.filter.is-active { - color: var(--text); -} - -/* 활성 항목은 카테고리 색 밑줄 */ -.filter.is-active::after { - content: ""; - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 2px; - border-radius: 2px; - background: var(--card-accent); -} - -.filter__count { - font-family: var(--font-mono); - font-size: var(--step--2); - color: var(--text-mute); - opacity: 0.7; -} - -.filter.is-active .filter__count { - color: var(--card-accent); - opacity: 1; -} - /* -------------------------------------------------------------------------- 7. 인덱스 목록 + 커서 썸네일 -------------------------------------------------------------------------- */ -/* 대표 작업 — 목록 맨 앞 글을 크게 보여 준다. - 글이 하나뿐이어도 화면이 비어 보이지 않게 하는 역할. */ -.lead { - display: grid; - grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); - gap: var(--space-6); - align-items: center; - margin-bottom: var(--space-7); - color: var(--text); -} - -.lead__media { - position: relative; - margin: 0; - aspect-ratio: 16 / 10; - overflow: hidden; - border-radius: var(--radius); - border: 1px solid var(--rule); - background: var(--surface-2); -} - -.lead__media img { - width: 100%; - height: 100%; - object-fit: cover; - transition: transform 620ms var(--ease); -} - -.lead:hover .lead__media img { - transform: scale(1.03); -} - -/* 이미지가 없는 글의 자리 — 카테고리 색으로 옅게 물든 판 + 첫 글자 */ -.lead__media--none, +/* 이미지가 없는 글의 자리 — 카테고리 색으로 옅게 물든 판 + 제목 첫 글자 */ .idx__thumb--none { display: grid; place-items: center; background-color: var(--surface-2); background-image: linear-gradient(140deg, - color-mix(in srgb, var(--card-accent) 16%, transparent) 0%, + color-mix(in srgb, var(--card-accent) 18%, transparent) 0%, transparent 62%); color: color-mix(in srgb, var(--card-accent) 45%, var(--text-mute)); font-family: var(--font-mono); font-weight: 700; + font-size: 26px; line-height: 1; } -.lead__media--none { - font-size: clamp(56px, 8vw, 104px); -} - -.idx__thumb--none { - font-size: 20px; -} - -/* color-mix 미지원 브라우저 대비 */ @supports not (background: color-mix(in srgb, red 10%, transparent)) { - .lead__media--none, .idx__thumb--none { background-image: none; color: var(--text-mute); } } -.lead__body { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: var(--space-3); - min-width: 0; -} - -/* 한글은 자간을 크게 벌리면 글자가 흩어져 보인다. 살짝만 준다. */ -.lead__label { - display: inline-flex; - align-items: center; - gap: var(--space-2); - font-size: var(--step--2); - font-weight: 700; - letter-spacing: 0.04em; - color: var(--text-mute); -} - -.lead__label::before { - content: ""; - width: 18px; - height: 2px; - border-radius: 2px; - background: var(--card-accent); -} - -.lead__cat { - font-family: var(--font-mono); - font-size: var(--step--1); - font-weight: 700; - letter-spacing: 0.06em; - color: var(--card-accent); -} - -.lead__title { - font-size: var(--step-3); - font-weight: 800; - letter-spacing: -0.035em; - line-height: 1.2; - word-break: keep-all; - color: var(--text); -} - -.lead__sum { - font-size: var(--step-0); - color: var(--text-dim); - line-height: 1.75; - word-break: keep-all; -} - -.lead__meta { - font-family: var(--font-mono); - font-size: var(--step--1); - color: var(--text-mute); -} - -.lead__more { - margin-top: var(--space-2); - font-size: var(--step--1); - font-weight: 700; - color: var(--card-accent); - box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--card-accent) 32%, transparent); - transition: box-shadow var(--dur) var(--ease); -} - -.lead__more::after { - content: " →"; -} - -.lead:hover .lead__more { - box-shadow: inset 0 -2px 0 var(--card-accent); -} - -/* 인덱스 행의 작은 썸네일 */ +/* 인덱스 행의 썸네일 */ .idx__thumb { margin: 0; - width: 96px; + width: 132px; aspect-ratio: 4 / 3; overflow: hidden; border-radius: var(--radius-sm); @@ -671,6 +499,41 @@ em { border-color: color-mix(in srgb, var(--card-accent) 45%, var(--rule)); } +/* 카테고리 묶음 — 분야 제목 + 그 아래 목록 */ +.group { + margin-bottom: var(--space-7); +} + +.group:last-child { + margin-bottom: 0; +} + +.group__title { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-5); + font-size: var(--step-1); + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text); +} + +.group__dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--card-accent); + flex: none; +} + +.group__count { + font-family: var(--font-mono); + font-size: var(--step--2); + font-weight: 700; + color: var(--text-mute); +} + .index { list-style: none; border-top: 1px solid var(--rule-strong); @@ -683,11 +546,12 @@ em { /* 행 하나. 번호 / 본문 / 메타 세 칸 그리드. */ .idx { display: grid; - /* 번호 · 썸네일 · 본문 · 메타 */ - grid-template-columns: 2.6rem auto 1fr auto; + /* 번호 · 썸네일 · 본문 · 날짜 */ + grid-template-columns: 2.4rem auto 1fr auto; align-items: center; - gap: var(--space-4); - padding: var(--space-4) 0; + gap: var(--space-5); + /* 썸네일이 잘 보이도록 위아래를 넉넉히 둔다 */ + padding: var(--space-5) 0; color: var(--text); transition: padding-left var(--dur) var(--ease), color var(--dur) var(--ease); } @@ -1794,13 +1658,17 @@ a.sr-only:focus-visible { /* 좁은 화면에서는 메타를 제목 아래로 내린다 */ .idx { - grid-template-columns: 2rem 64px 1fr; - gap: var(--space-3); + grid-template-columns: 2rem 88px 1fr; + gap: var(--space-4); padding: var(--space-4) 0; } .idx__thumb { - width: 64px; + width: 88px; + } + + .group { + margin-bottom: var(--space-7); } .idx__meta { diff --git a/GameServer/wwwroot/js/views/home.js b/GameServer/wwwroot/js/views/home.js index da1090d..cdaccfa 100644 --- a/GameServer/wwwroot/js/views/home.js +++ b/GameServer/wwwroot/js/views/home.js @@ -1,21 +1,19 @@ -// views/home.js — 홈(작업 인덱스) 화면 +// views/home.js — 홈(작업 목록) 화면 // 라우트: #/ · #/?cat= -// 구성: 히어로 → 인라인 카테고리 필터 → 대표 작업 한 건 → 번호 매긴 인덱스 +// 구성: 히어로 → 카테고리별 묶음, 각 묶음 안에서 번호 매긴 목록 // -// 카드 그리드 대신 '작품집 목차' 형태를 쓰되, 이미지가 늘 자리를 갖도록 -// 맨 앞 글은 크게(lead) 보여 주고 나머지 행에는 작은 썸네일을 붙인다. -// 이미지가 없는 글은 제목 첫 글자를 넣은 파스텔 판으로 대신한다. +// 날짜순으로 전부 섞어 보여 주지 않는다. 작업의 선후보다 분야가 중요해서 +// 카테고리마다 제목을 달고 그 아래에 작업을 나열한다. +// 모든 항목은 같은 크기다. 대신 행 간격을 넉넉히 두어 썸네일이 잘 보이게 한다. import { api } from '../api.js'; -import { el, escapeHtml, formatDate, setBusy, skeletonRows, toast } from '../ui.js'; +import { el, escapeHtml, formatDate, setBusy, skeletonRows } from '../ui.js'; -/** 한 번에 불러오는 게시물 수 */ -const PAGE_SIZE = 20; +/** 한 번에 긁어오는 게시물 수 (여러 번 나눠 받아 전부 모은다) */ +const FETCH_SIZE = 50; /** 카테고리 캐시 수명 (ms) */ const CATEGORY_TTL_MS = 60000; -// 카테고리 목록은 모듈 스코프에 캐시한다. -// 필터만 바뀌는 경우(#/ ↔ #/?cat=game) 뷰가 다시 마운트돼도 다시 요청하지 않는다. let catCache = null; let catCachedAt = 0; let catInflight = null; @@ -78,7 +76,7 @@ function padNo(n) { return String(n).padStart(2, '0'); } -/** 'YYYY. MM. DD.' → 'YY.MM' (인덱스에서는 짧게 쓴다) */ +/** 'YYYY. MM. DD.' → 'YY.MM' */ function shortDate(iso) { const full = formatDate(iso); const m = /^(\d{4})\.\s*(\d{2})\./.exec(full); @@ -92,98 +90,44 @@ function firstChar(title) { return Array.from(text)[0]; } -/** - * 이미지 자리. 그림이 있으면 img, 없으면 첫 글자를 넣은 판. - * @param {string} cls 바깥 figure 의 클래스 - * @param {string} url 안전하게 걸러진 이미지 주소 ('' 가능) - * @param {string} title alt / 대체 글자용 - */ -function mediaHtml(cls, url, title) { +/** 이미지 자리. 그림이 있으면 img, 없으면 첫 글자를 넣은 판. */ +function thumbHtml(url, title) { if (url) { return ( - '
' + + '
' + '' + escapeHtml(title) + '' + '
' ); } return ( - '