41 Commits
v3.0.1 ... main

Author SHA1 Message Date
5e1b93f668 Re:제로 EX17BT 및 패러렐 이미지 추가 2026-10-24 22:03:59 +09:00
7109f62a51 슈타인즈 게이트 카드 추가 및 마도카 패러렐 이미지 보강 2026-10-12 16:50:05 +09:00
ed2f3131ab 마도카 EX16BT 카드 추가 2026-09-22 10:46:32 +09:00
9077d6c255 UA56 누락 이미지 보정 2026-09-15 15:16:09 +09:00
4a309d3f6b UA56 카드 시리즈 추가 2026-09-15 15:12:24 +09:00
06442f8344 외부 통계 API 추가 2026-08-12 17:57:35 +09:00
65818cf2ee UA55 카드 시리즈 추가 2026-08-09 21:18:17 +09:00
97215f155d 정합성·보안·인덱스 개선
- health/시작 로그 버전을 package.json 기준으로 동적화
- express-rate-limit으로 로그인·이메일 쓰기 요청 IP 기준 제한, trust proxy 설정
- nginx 보안 헤더(nosniff·X-Frame-Options·Referrer-Policy) 추가
- 공개 덱 목록용 복합 인덱스 추가
- 서버 페이지네이션은 후속 과제로 todo에 기록

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 16:49:49 +09:00
af721d327d 전송 최적화: nginx gzip·이미지 lazy-load·캐시 연장
- nginx 텍스트 자산 gzip 압축 추가로 css/js/json/HTML 전송량 절감
- cardList 카드 이미지 lazy-load로 시리즈 진입 시 즉시 다운로드 방지
- 정적 이미지 캐시 7일→30일 연장
- 미사용 style/deck-history.css 삭제

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 14:52:18 +09:00
a90bdcbe13 덱 목록 무한 스크롤과 WebP 썸네일 최적화 추가
- 내 덱·공개 덱 목록을 무한 스크롤로 전환(공용 페이저 모듈 추가)
- 즐겨찾기 토글 시 카드만 in-place 갱신해 스크롤 위치 유지
- 저장 시 420px WebP 썸네일 생성·전송, 목록은 썸네일 우선 사용
- 삭제·미참조 정리에서 원본 PNG와 썸네일 함께 처리

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 18:17:58 +09:00
63aabf61c4 버전 4.0.18과 변경 문서 동기화
VERSION과 API 버전을 4.0.18로 올리고 spec/update/changelog에 관리자 덱 복구 및 탈퇴 동선 변경 내용을 반영한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 18:10:55 +09:00
ee00b2aa89 관리자 덱 불러오기와 회원 탈퇴 동선 개선
관리자 덱 카드에서 썸네일 확대와 불러오기를 복구하고, 회원 수정은 역할 변경 메뉴 안에서 탈퇴를 선택해 2단계 확인 후 처리하도록 통합한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 18:10:47 +09:00
c193975c86 공유 덱 카드 복원 기준을 imgSrc로 전환
동일 번호 카드가 많은 시리즈에서도 선택한 카드가 정확히 복원되도록 저장·검증·복원 흐름을 imgSrc 중심으로 정리하고, 이미지 누락 시 공통 뒷면 폴백을 적용한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 17:59:46 +09:00
2c4f622408 회원 탈퇴와 덱 일괄 삭제 추가 2026-06-25 17:11:31 +09:00
a5b7d720d6 이메일 인증 재발송과 관리자 승인 개선 2026-06-25 16:51:29 +09:00
5fbd58bd07 인증 요청 안정화와 월간 메일 한도 적용 2026-06-25 14:05:23 +09:00
35a2976182 이메일 인증과 비밀번호 재설정 추가 2026-06-25 12:02:44 +09:00
edf48e53a5 로그인 유지와 마지막 접속 기록 개선 2026-06-24 09:56:25 +09:00
5a56ddcede v4.0.10: 관리자 계정 변경 안전장치와 접속시간 지표 추가
역할 변경은 명시적으로 버튼을 눌렀을 때만 가능하게 제한하고, 마지막 접속시간과 비밀번호 분실 안내를 추가해 운영 관리성을 높인다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 18:24:45 +09:00
bc30356c8b v4.0.9: 관리자 회원 목록 2열·덱 공개/전체 통계 추가
관리자가 회원별 저장 덱 이용 현황을 한눈에 볼 수 있도록 API 집계와 UI를 보강한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 16:51:14 +09:00
eb4b35bccc v4.0.8: nginx no-cache·deck-favorites shim으로 배포/캐시 문제 수정
구 HTML·CDN immutable JS가 init 없이 남던 문제를 nginx 캐시 정책·shim·배포 검증으로 해결한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 16:24:19 +09:00
72d73a54bd docs: deploy.md nginx 재빌드 안내 복구
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 16:19:16 +09:00
f3b7ac6cf0 v4.0.7: 덱 목록 JS 캐시 무효화 및 UaDeckFavorites.init 방어 처리
구 deck-favorites.js가 CDN에 남아 init 호출이 덱 로드를 막던 문제를 스크립트 ?v= 쿼리와 optional init로 수정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 16:19:03 +09:00
ac1ab7d8c0 v4.0.6: output.css CDN 캐시 무효화 및 nginx CSS 캐시 정책 수정
HTML은 갱신됐으나 구 output.css가 CDN에 남아 Tailwind 레이아웃이 깨지던 문제를 ?v= 쿼리와 CSS must-revalidate로 해결한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 15:57:00 +09:00
ba89235207 v4.0.5: 로그인·덱·관리자 Tailwind 레이아웃 통합
공통 UI를 components.css로 빌드에 포함하고 로그인·회원가입·내 덱·공개 덱·관리자 화면 레이아웃을 정리했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 15:48:09 +09:00
93e81ce3ea 관리자 기능과 계정 즐겨찾기 개선 2026-06-23 14:32:43 +09:00
00e05dbe3f 프로젝트 문서 체계 정비 2026-06-23 13:42:38 +09:00
e11b1dc6df v4.0.0: 헤더 로고 크기 brand.css로 통일
페이지마다 흩어져 있던 로고 스타일을 brand.css 단일 정의(32px)로 모아 전 화면에서 동일하게 표시되도록 수정했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 11:43:26 +09:00
a281d731a1 v4.0.0: UNIARE 리브랜딩·레이아웃·덱 UX 개선
제품명·헤더 로고 이미지 통일, 덱 이름 변경·조회 수 제거, 썸네일 홀드 미리보기를 누르는 동안만 표시하도록 수정하고 Tailwind CSS를 재빌드했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 11:19:07 +09:00
2228757ea5 NAS 운영 네이밍을 uniare-*로 통일
docker-compose 서비스/컨테이너/볼륨 이름을 uniare 기준으로 맞추고, nginx 프록시 대상 및 배포 문서 예시를 함께 갱신했습니다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 20:12:37 +09:00
331d6c5fe9 나스 서비스 준비 2026-06-22 19:52:45 +09:00
2ecf6201c0 v4.0.0: 덱 UX·i18n·썸네일 저장 개선 및 413 수정
즐겨찾기·홀드 미리보기·페이지 도움말·3개국어 번역을 추가하고, 덱 저장 시 썸네일을 1200px로 통일하며 Nginx/API 업로드 제한을 상향했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 18:50:08 +09:00
dbf02241e2 v4.0.0: 회원·덱 API, 인증 UI, 프로필 및 레이아웃 통일
Express/SQLite API와 Docker 구성을 추가하고 로그인·덱 저장·공개 덱·관리자·프로필 화면을 구현했다. 결과 화면 저장 UX, 헤더 프로필, 애드센스용 app-shell 레이아웃(1280px) 및 문서를 반영했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 16:59:36 +09:00
f8c954f24b +유니티켓: 히로아카,테일즈,헌터헌터 2026-06-17 16:16:52 +09:00
dc80f50bda 마크로스(2nd) & 무직전생(1st) 2026-06-15 13:09:51 +09:00
fa59f6f6ea v3.1.2: add ua53, ua50 2026-05-26 16:50:54 +09:00
4479391207 v.3.1.1: add ua52 2026-05-16 11:16:34 +09:00
7970962e18 v3.1.0: add ua51 series and handoff docs 2026-04-19 17:13:13 +09:00
db227dd02e revert: restore original card list interactions 2026-04-08 17:33:00 +09:00
ebebe1b12d fix: correct ua27 card number and refresh deck counts 2026-04-08 17:30:46 +09:00
eed4604432 v3.0.1: 이미지 경로 정리 및 누락 이미지 반영 2026-03-30 18:20:38 +09:00
1702 changed files with 35398 additions and 2272 deletions

View File

@@ -1,37 +1,27 @@
# 프로젝트 AI 가이드라인 및 문서화 규칙
# 프로젝트 AI / 문서 규칙
## 🌐 기본 응답 원칙
- 모든 대화와 문서는 **한국어**로 작성한다.
- 코드 내 주석은 반드시 **JSDoc 형식**을 사용한다.
이 문서는 AI 에이전트가 이 저장소에서 작업할 때 참고하는 규칙과 문서 관리 원칙을 간결하게 정리합니다. 실제 작업 시 우선 적용 대상은 루트 `AGENTS.md`입니다.
## 🧾 Git 및 버전 관리 규칙
- Git 작성자 정보는 프로젝트 기준 계정으로 통일한다.
- Git 커밋 메시지는 반드시 **한국어**로 작성한다.
- 버전이 올라가는 작업은 `docs/update.md`에 먼저 반영하고, 같은 버전명을 Git 태그에도 맞춘다.
- 원격 저장소에 푸시하기 전, 민감 정보(실명, 개인 이메일, 비밀키, 로컬 경로)가 포함되지 않았는지 확인한다.
## 기본 응답 원칙
- 모든 대화와 문서는 한국어로 작성한다.
- 코드 주석은 JSDoc 형식으로 작성한다.
- 답변은 짧고 명확하게 작성한다.
- 추측성 답변 금지.
- 불확실한 내용은 불확실하다고 명시한다.
## 📂 문서 자동 관리 규칙
모든 작업 수행 후, AI는 관련 내용을 아래 지정된 파일에 즉시 반영해야 한다.
## Git / 버전 규칙
- 커밋 메시지는 한국어로 작성한다.
- 버전이 올라가는 작업은 `docs/update.md`에 먼저 기록한다.
- 원격 푸시 전 민감 정보(실명, 이메일, 비밀번호, 로컬 경로)가 포함되지 않았는지 확인한다.
1. **작업 이력 (docs/update.md)**
- 수행한 작업 내용을 상세히 기록한다.
- 당일 업데이트라도 수정 시마다 버전을 갱신하여 하나의 파일에 누적 관리한다. (별도 파일 생성 금지)
## 문서 자동 관리
- 작업 후 반드시 `docs/update.md`, `docs/todo.md`, `docs/spec.md`, `docs/history.md`, `docs/map.md`, `docs/convention.md`를 확인하고 필요한 내용을 반영한다.
- `docs/update.md`는 수행 작업을 기록한다.
- `docs/history.md`는 설계 결정 이유를 기록한다.
- `docs/todo.md`는 남은 작업을 정리한다.
- `docs/spec.md`는 구현·API·데이터 구조를 최신화한다.
- `docs/map.md`는 파일-화면/기능 매핑을 최신화한다.
2. **할 일 및 이슈 (docs/todo.md)**
- 현재 직면한 문제점과 다음에 이어서 진행할 작업 목록을 정리한다.
3. **기술 명세 (docs/spec.md)**
- API 명세, 기획 내용, 데이터베이스 스키마 구조를 최신 상태로 유지한다.
4. **코딩 컨벤션 (docs/convention.md)**
- 프로젝트 전용 코딩 스타일 및 네이밍 규칙(Variable, Function, Class 등)을 정의하고 준수한다.
5. **의사결정 이력 (docs/history.md)**
- 주요 설계 결정 사항 및 시스템 아키텍처 변경 이력을 기록한다.
6. **파일-화면 매핑 가이드 (docs/map.md)**
- 연결성 기록: 특정 파일(Ex: Login.jsx)이 브라우저 화면의 어느 경로(/login)와 어느 기능(로그인 버튼 등)을 담당하는지 초보자 관점에서 기록한다.
## ⚠️ 실행 지침
- 새로운 코드를 작성하거나 수정하기 전, 반드시 `docs/` 내 관련 문서들을 먼저 참조한다.
- 작업 완료 후 위 문서들의 업데이트가 누락되지 않도록 확인한다.
## 실행 지침
- 수정 전에 관련 `docs/` 문서를 먼저 확인한다.
- 작업 완료 후 문서 반영 누락이 없도록 확인한다.

View File

@@ -1 +1 @@
모든 작업 시 프로젝트 루트의 .ai-rules.md 지침을 엄격히 준수하고, 작업 종료 시마다 docs/ 폴더 내 문서를 업데이트할 것.
모든 작업 시 프로젝트 루트의 AGENTS.md 지침을 엄격히 준수할 것.

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
# Docker 빌드 시 API 이미지에서 제외
api/node_modules
api/data
.git
.env
*.md
docs/
.cursor/
.cursorrules

29
.env.example Normal file
View File

@@ -0,0 +1,29 @@
# UNIARE API 환경변수 — 이 파일을 .env로 복사한 뒤 값을 채우세요
# --- API ---
API_PORT=3000
JWT_SECRET=여기에-32자-이상-랜덤-시크릿-입력
JWT_EXPIRES=7d
APP_BASE_URL=https://uniare.sori.studio
# 비워두면 /api/stats 공개, 값을 넣으면 Bearer 또는 X-Stats-Token 필요
STATS_API_TOKEN=
# --- 이메일 (Resend) ---
RESEND_API_KEY=re_여기에_API_KEY_입력
RESEND_FROM_EMAIL=UNIARE <account@send.example.com>
# --- 웹 (docker-compose.prod.yml) ---
WEB_PORT=44082
# --- 관리자 (최초 기동 시 자동 생성) ---
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=change-me-8chars
ADMIN_USERNAME=admin
# --- CORS (옆 프로젝트·로컬 개발 포트 포함, 쉼표 구분) ---
CORS_ORIGINS=https://uniare.sori.studio,http://localhost:44082,http://127.0.0.1:44082,http://localhost:8080,http://127.0.0.1:8080,http://localhost:5500,http://127.0.0.1:5500,http://localhost:5173
# --- 로컬 api:dev 전용 (npm run api:dev) ---
DATAS_PATH=./datas
DB_PATH=./api/data/uadeck.db
PORT=3000

5
.gitignore vendored
View File

@@ -27,4 +27,7 @@ logs/
.DS_Store
# 기타
/coverage
/coverage
# 로컬 초안 (git 미추적)
docs/posts/

480
AGENTS.md Normal file
View File

@@ -0,0 +1,480 @@
# AGENTS.md
## 목적
이 파일은 이 프로젝트에서 작업하는 AI 에이전트(Codex, Claude Code 등)가 반드시 따라야 하는 공통 작업 규칙을 정의한다.
모든 작업은 본 문서의 지침을 우선 적용한다.
## 기본 응답 원칙
* 모든 대화, 설명, 보고, 문서 작성은 한국어로 작성한다.
* 사용자가 별도 형식을 요구하지 않는 한 답변은 짧고 명확하게 작성한다.
* 추측성 답변을 하지 않는다.
* 불확실한 내용은 반드시 불확실하다고 명시한다.
* 작업 완료를 선언하기 전에 실제 수정 파일과 문서 반영 여부를 다시 확인한다.
## 프로젝트 기본 원칙
* 운영 환경은 Docker Compose 기반으로 구성한다.
* 환경 변수는 반드시 `.env`로 관리한다.
* 운영 URL, API Key, 비밀번호, DB 정보는 하드코딩하지 않는다.
* 개발 환경과 운영 환경은 분리한다.
* 로컬 개발 DB와 운영 DB는 반드시 분리한다.
* 사람이 구조를 빠르게 이해할 수 있도록 작성한다.
* 유지보수성을 우선 고려한다.
## 문서 구조
```text
docs/
spec.md
todo.md
update.md
history.md
deploy.md
widget.md
map.md
changelog.md
convention.md
```
## 문서 역할
### spec.md
현재 무엇이 구현되어 있는가
기록한다.
포함 내용
* 기능 명세
* API 명세
* 데이터 구조
* 화면 동작
* 권한 정책
작업 로그는 기록하지 않는다.
---
### update.md
이번 작업에서 무엇을 수정했는가
기록한다.
예시
```md
# v0.0.3
- 로그인 API 추가.
- 회원가입 화면 수정.
- 비밀번호 검증 로직 개선.
```
개발자 작업 로그이다.
---
### history.md
왜 그렇게 구현했는가
기록한다.
작업 로그를 기록하지 않는다.
형식
```md
## YYYY-MM-DD vX.X.X — 한 줄 요약
💡 결정 이유
🔧 실제 적용 내용
```
예시
```md
## 2026-06-16 v1.5.105 — 텍스트 색상은 제한된 인라인 토큰으로 저장
💡 다크모드 색상 보정과 라이브 편집 직렬화를 통합 관리하기 위함.
🔧 `::red[텍스트]` 형식 채택, 지원 색상은 gray, blue, green, yellow, red, purple.
```
규칙
* 장문의 서술형 작성 금지
* 3~5줄 이내 작성
* 회의록 작성 금지
* 최신 항목이 항상 상단
---
### todo.md
앞으로 해야 할 작업만 기록한다.
규칙
* 완료된 작업은 즉시 제거
* 더 이상 필요 없는 작업 제거
* 현재 기준 유효한 작업만 유지
---
### deploy.md
운영 서버 배포 방법을 기록한다.
비개발자도 복사 후 실행만 하면 배포할 수 있어야 한다.
반드시 포함
* 프로젝트 위치
* Git 저장소 주소
* 환경 변수 위치
* 개발 실행 방법
* 빌드 방법
* Docker 실행 방법
* Docker 재시작 방법
* 운영 배포 방법
* 롤백 방법
---
### widget.md
외부 서비스 연동 방법을 기록한다.
목적
* Homepage 위젯
* Grafana
* RSS
* OpenAPI
* Discord
* Telegram
* Webhook
등 외부 서비스와 연결하는 방법 제공
규칙
* 실제 사용 가능한 URL 제공
* curl 예시 제공
* YAML 예시 제공
* Docker 환경 주의사항 제공
* 비개발자도 따라할 수 있도록 작성
---
### map.md
어느 파일이 어느 기능을 담당하는가
기록한다.
예시
```md
/deckHistory.html
deckHistory.html
/login.html
login.html
SiteHeader
style/brand.css
```
---
### changelog.md
사용자에게 공개할 변경사항을 기록한다.
공지사항 또는 업데이트 게시글로 바로 사용할 수 있어야 한다.
규칙
* 개발 로그 작성 금지
* 사용자 관점으로 작성
* 기능 추가 시 사용 방법 포함
* UI 변경 시 변경 이유 포함
* 복사 후 공지사항으로 바로 게시 가능한 수준으로 작성
예시
```md
## v1.5.109
### 추천 사이트 영역 위치 변경
추천 사이트 목록을 왼쪽 사이드바로 이동하였습니다.
### 사이드바 구조 개선
광고와 게시물 목차가 보다 안정적으로 표시되도록 개선하였습니다.
```
---
### convention.md
프로젝트 규칙만 기록한다.
반복적으로 적용되는 규칙만 기록한다.
## 문서 관계
```text
spec.md
현재 무엇이 구현되어 있는가
update.md
이번 작업에서 무엇을 수정했는가
history.md
왜 그렇게 구현했는가
todo.md
앞으로 무엇을 해야 하는가
deploy.md
어떻게 배포하는가
widget.md
외부 서비스와 어떻게 연동하는가
map.md
어느 파일이 어느 기능을 담당하는가
changelog.md
사용자 입장에서 무엇이 변경되었는가
convention.md
프로젝트에서 어떤 규칙을 따라야 하는가
```
## 문서 선참조 규칙
코드를 작성하거나 수정하기 전에 아래 문서를 먼저 확인한다.
```text
docs/spec.md
docs/todo.md
docs/history.md
docs/convention.md
docs/map.md
docs/deploy.md
```
문서와 코드가 충돌하면
1. 실제 코드를 확인한다.
2. 코드를 기준으로 판단한다.
3. 문서를 최신 상태로 수정한다.
## 작업 수행 절차
모든 작업은 아래 순서를 따른다.
1. 관련 문서 확인
2. 관련 코드 확인
3. 기존 패턴 파악
4. 최소 범위 수정
5. 검증 수행
6. 문서 갱신
7. 결과 정리
## 버전 관리 규칙
프로젝트 루트에 VERSION 파일을 유지한다.
예시
```text
v0.0.1
```
작업 완료 후 커밋 시 반드시 버전을 증가시킨다.
예시
```text
v0.0.1 → v0.0.2
v0.0.2 → v0.0.3
```
버전 변경 시 반드시 갱신
* VERSION
* docs/update.md
* docs/changelog.md (필요 시)
Git 커밋 메시지는 반드시 한국어로 작성한다.
예시
```text
회원가입 검증 로직 개선
게시물 관리 기능 추가
```
민감 정보는 절대 커밋하지 않는다.
예시
* API Key
* 비밀키
* 토큰
* 비밀번호
* 내부 서버 주소
* 개인정보
## Homepage API 규칙
서비스가 운영 단계에 진입하면 Homepage(getHomepage.dev) 연동용 API 제공을 고려한다.
기본 엔드포인트
```http
GET /api/homepage
```
기본 응답 예시
```json
{
"status": "online",
"version": "1.0.0",
"lastUpdated": "2026-06-23T00:00:00+09:00"
}
```
추가 데이터는 서비스 특성에 맞게 설계한다.
예시
* 방문자 수
* 게시물 수
* 회원 수
* 주문 수
* 활성 사용자 수
* 서버 상태
* 스토리지 사용량
* 백업 상태
AI는 프로젝트 특성에 맞는 운영 지표를 제안해야 한다.
Homepage 위젯에서 사용할 수 있는 YAML 예시를 widget.md에 함께 작성한다.
## Docker 규칙
* Docker Compose 사용을 기본으로 한다.
* compose.yml 또는 docker-compose.yml 사용
* 가능한 경우 서비스 분리
예시
```text
app
db
redis
nginx
```
* 컨테이너 이름
* 포트
* 볼륨
* URL
은 환경 변수로 관리한다.
배포 문서에는 실제 서버에서 사용하는 명령어를 기록한다.
sudo 사용 여부는 서버 환경을 따른다.
## 코드 작성 규칙
### 공통
* 기존 코드 스타일을 우선 따른다.
* 불필요한 리팩토링 금지
* 요청받지 않은 대규모 구조 변경 금지
* 작은 수정으로 해결 가능한 문제를 전면 재구성하지 않는다.
* 의미 없는 이름 사용 금지
* 하드코딩 금지
---
### JavaScript
* JavaScript 사용 시 JSDoc 작성
* 함수 및 클래스는 문서화 주석 작성
예시
```javascript
/**
* 게시물 목록 조회
* @param {Object} options 조회 옵션
* @returns {Promise<Array>}
*/
```
---
### 스타일
* Tailwind CSS를 기본으로 사용한다.
* Tailwind Utility Class를 우선 사용한다.
* 단순 스타일을 위해 별도 CSS 파일을 생성하지 않는다.
* 반복되는 스타일은 컴포넌트화 또는 공통 클래스화한다.
* 의미 없는 클래스명 사용 금지
금지 예시
```text
box1
box2
temp
test
```
* 의미 있는 고유 클래스명을 사용한다.
* 전역 스타일 사용을 최소화한다.
* 다크모드를 고려한다.
* 기본 폰트는 Pretendard를 사용한다.
## 검증 규칙
가능한 범위 내에서 반드시 검증한다.
예시
* 빌드 확인
* API 동작 확인
* 실패 케이스 확인
검증하지 못한 경우 명시한다.
## 작업 결과 보고 형식
반드시 아래 형식으로 보고한다.
1. 수정 내용
2. 변경 파일
3. 실행 방법
4. 빌드 방법
5. 배포 방법
6. 검증 결과
7. 문서 반영 여부
8. 남은 이슈
## 최종 체크리스트
* docs 확인 여부
* 코드 수정 완료 여부
* 검증 수행 여부
* VERSION 증가 여부
* update.md 기록 여부
* history.md 기록 여부
* deploy.md 갱신 여부
* widget.md 갱신 여부
* Homepage API 갱신 여부
* 문서 반영 여부
* 민감 정보 포함 여부
* Git 커밋 여부
* Git Push 여부

1
VERSION Normal file
View File

@@ -0,0 +1 @@
v4.0.28

802
admin.html Normal file
View File

@@ -0,0 +1,802 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./style/output.css?v=4.0.16" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet" />
<title>관리자 - UNIARE</title>
</head>
<body class="app-page">
<nav class="app-nav">
<div class="app-nav-inner">
<a href="/" class="inline-flex items-center gap-2.5 no-underline">
<img src="/images/assets/uniare-h48.png" alt="UNIARE" class="app-brand-logo" height="32" />
<span class="text-base font-extrabold leading-tight text-slate-800">관리자</span>
</a>
<div id="auth-header-slot"></div>
</div>
</nav>
<div class="app-shell">
<main class="app-main w-full">
<header class="mb-8">
<h1 class="mb-2 text-3xl font-black tracking-tight text-slate-900">관리자</h1>
<p class="text-sm text-slate-500">회원과 저장된 덱을 관리합니다.</p>
</header>
<div class="mb-7 flex gap-1 border-b border-slate-200" role="tablist">
<button type="button" data-tab="users" role="tab" aria-selected="true" class="-mb-px cursor-pointer border-0 border-b-2 border-blue-600 bg-transparent px-4 py-3 text-sm font-bold text-blue-600 transition-colors hover:text-slate-600">회원 관리</button>
<button type="button" data-tab="decks" role="tab" aria-selected="false" class="-mb-px cursor-pointer border-0 border-b-2 border-transparent bg-transparent px-4 py-3 text-sm font-bold text-slate-400 transition-colors hover:text-slate-600">덱 관리</button>
</div>
<section id="tab-users">
<div class="mb-5 flex flex-wrap gap-3">
<input type="search" id="user-search" class="box-border min-w-0 flex-1 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/15" placeholder="이메일, 사용자명 검색..." />
<button type="button" id="user-search-btn" class="cursor-pointer rounded-xl border-0 bg-slate-800 px-5 py-2.5 text-sm font-bold text-white transition hover:bg-slate-700">검색</button>
</div>
<div id="user-list" class="grid grid-cols-1 gap-3 sm:grid-cols-2"></div>
</section>
<section id="tab-decks" class="hidden">
<div class="deck-history-toolbar mb-6">
<div class="deck-toolbar-main-row">
<input type="search" id="admin-deck-search" class="deck-search-input" placeholder="덱 이름, 시리즈, 사용자 검색..." />
<select id="admin-deck-series-filter" class="deck-series-select" aria-label="시리즈 필터">
<option value="">모든 시리즈</option>
</select>
<span id="deck-count" class="deck-count-label"></span>
</div>
<div class="mt-3 flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 pt-3">
<label class="flex cursor-pointer items-center gap-2 text-xs font-semibold text-slate-500">
<input type="checkbox" id="admin-deck-select-visible" class="size-4 rounded border-slate-300 accent-blue-600" />
현재 목록 전체 선택
</label>
<button type="button" id="admin-deck-bulk-delete" class="cursor-pointer rounded-xl border-0 bg-red-600 px-4 py-2 text-xs font-bold text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-slate-300" disabled>선택 덱 삭제</button>
</div>
</div>
<div id="admin-deck-list"></div>
<section class="mt-8 flex flex-col items-stretch justify-between gap-4 rounded-2xl border border-slate-200 bg-white px-5 py-4 shadow-sm sm:flex-row sm:items-center">
<div>
<h2 class="mb-1 text-[0.9375rem] font-bold">미참조 썸네일</h2>
<p id="orphan-preview-status" class="m-0 text-xs text-slate-500">확인 전</p>
</div>
<div class="flex shrink-0 gap-2">
<button type="button" id="orphan-preview-refresh" class="flex-1 cursor-pointer rounded-xl border-0 bg-slate-100 px-4 py-2.5 text-[0.8125rem] font-bold text-slate-600 hover:bg-slate-200">새로 확인</button>
<button type="button" id="orphan-preview-delete" class="flex-1 cursor-pointer rounded-xl border-0 bg-red-600 px-4 py-2.5 text-[0.8125rem] font-bold text-white disabled:cursor-not-allowed disabled:bg-slate-300" disabled>모두 정리</button>
</div>
</section>
<div id="orphan-preview-list" class="mt-3 hidden max-h-56 overflow-y-auto rounded-xl border border-slate-200 bg-white px-4 py-2"></div>
</section>
</main>
</div>
<!-- 회원 정보 모달 -->
<div id="edit-modal" class="fixed inset-0 z-[100] hidden items-center justify-center bg-slate-900/50 p-4">
<div class="w-full max-w-[26rem] overflow-hidden rounded-[1.25rem] bg-white shadow-2xl">
<div class="border-b border-slate-200 bg-gradient-to-br from-slate-50 to-blue-50 px-6 pb-4 pt-6">
<div class="flex items-center gap-4">
<div class="relative shrink-0">
<img id="edit-avatar-preview" class="h-[4.5rem] w-[4.5rem] rounded-full border-[3px] border-white bg-white object-cover shadow-md" alt="" />
<button type="button" id="edit-avatar-btn" class="absolute -bottom-1 -right-1 flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border-2 border-white bg-blue-600 text-xs text-white" title="사진 변경">📷</button>
<input type="file" id="edit-avatar-file" accept="image/*" class="hidden" />
</div>
<div>
<h2 id="edit-modal-title" class="m-0 text-lg font-extrabold text-slate-900">회원 정보</h2>
<p id="edit-modal-sub" class="mt-1 text-xs text-slate-500"></p>
</div>
</div>
<button type="button" id="edit-avatar-remove" class="mt-2 hidden cursor-pointer border-0 bg-transparent p-0 text-[0.6875rem] text-slate-400 underline">프로필 사진 제거</button>
</div>
<div class="px-6 pb-6 pt-5">
<input type="hidden" id="edit-user-id" />
<div class="mb-4">
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400" for="edit-email">이메일</label>
<input type="email" id="edit-email" class="w-full box-border rounded-xl border border-slate-200 px-3 py-2.5 text-sm disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400" autocomplete="off" />
</div>
<div class="mb-4">
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400" for="edit-username">사용자명</label>
<input type="text" id="edit-username" class="w-full box-border rounded-xl border border-slate-200 px-3 py-2.5 text-sm disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400" autocomplete="off" />
</div>
<div class="mb-4">
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400">역할</label>
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5">
<div class="flex items-center justify-between gap-2">
<span id="edit-role-summary" class="text-sm font-semibold text-slate-700"></span>
<button type="button" id="toggle-role-editor" class="cursor-pointer rounded-lg border-0 bg-slate-200 px-2.5 py-1 text-[0.6875rem] font-bold text-slate-600 hover:bg-slate-300">역할 변경하기</button>
</div>
<div id="edit-role-editor" class="mt-2 hidden">
<select id="edit-role" class="w-full box-border rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400">
<option value="user">일반 회원</option>
<option value="admin">관리자</option>
<option value="delete_user">회원 탈퇴 처리</option>
</select>
<p id="edit-role-hint" class="mt-1.5 hidden text-[0.6875rem] leading-snug text-amber-600">본인 계정의 관리자 권한은 변경할 수 없습니다.</p>
</div>
</div>
</div>
<div class="mb-4">
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400" for="edit-isActive">상태</label>
<select id="edit-isActive" class="w-full box-border rounded-xl border border-slate-200 px-3 py-2.5 text-sm disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400">
<option value="1">활성</option>
<option value="0">비활성</option>
</select>
<p id="edit-status-hint" class="mt-1.5 hidden text-[0.6875rem] leading-snug text-amber-600">본인 계정은 비활성화할 수 없습니다.</p>
</div>
<p id="modal-error" class="mt-2 hidden text-[0.8125rem] text-red-500"></p>
<div class="my-4 h-px bg-slate-200"></div>
<button type="button" id="approve-email-verification" class="mb-2 hidden w-full cursor-pointer rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-2.5 text-[0.8125rem] font-bold text-emerald-700 hover:bg-emerald-100">이메일 인증 승인</button>
<button type="button" id="open-password-modal" class="w-full cursor-pointer rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-[0.8125rem] font-bold text-slate-600 hover:bg-slate-50">비밀번호 변경하기</button>
<div class="mt-3 flex gap-2">
<button type="button" id="modal-cancel" class="flex-1 cursor-pointer rounded-xl border-0 bg-slate-100 px-4 py-2.5 text-[0.8125rem] font-bold text-slate-600 hover:bg-slate-200">취소</button>
<button type="button" id="modal-save" class="flex-1 cursor-pointer rounded-xl border-0 bg-slate-800 px-4 py-2.5 text-[0.8125rem] font-bold text-white hover:bg-slate-700">저장</button>
</div>
</div>
</div>
</div>
<!-- 비밀번호 변경 모달 -->
<div id="password-modal" class="fixed inset-0 z-[100] hidden items-center justify-center bg-slate-900/50 p-4">
<div class="w-full max-w-[26rem] overflow-hidden rounded-[1.25rem] bg-white shadow-2xl">
<div class="border-b border-slate-200 bg-gradient-to-br from-slate-50 to-blue-50 px-6 pb-4 pt-6">
<h2 class="m-0 text-lg font-extrabold text-slate-900">비밀번호 변경</h2>
<p id="password-modal-sub" class="mt-1 text-xs text-slate-500"></p>
</div>
<div class="px-6 pb-6 pt-5">
<input type="hidden" id="password-user-id" />
<div class="mb-4">
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400" for="password-new">새 비밀번호</label>
<input type="password" id="password-new" class="w-full box-border rounded-xl border border-slate-200 px-3 py-2.5 text-sm" placeholder="8자 이상" autocomplete="new-password" />
</div>
<div class="mb-4">
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400" for="password-confirm">비밀번호 확인</label>
<input type="password" id="password-confirm" class="w-full box-border rounded-xl border border-slate-200 px-3 py-2.5 text-sm" placeholder="다시 입력" autocomplete="new-password" />
</div>
<p id="password-error" class="mt-2 hidden text-[0.8125rem] text-red-500"></p>
<div class="flex gap-2">
<button type="button" id="password-cancel" class="flex-1 cursor-pointer rounded-xl border-0 bg-slate-100 px-4 py-2.5 text-[0.8125rem] font-bold text-slate-600 hover:bg-slate-200">취소</button>
<button type="button" id="password-save" class="flex-1 cursor-pointer rounded-xl border-0 bg-slate-800 px-4 py-2.5 text-[0.8125rem] font-bold text-white hover:bg-slate-700">변경</button>
</div>
</div>
</div>
</div>
<div id="user-delete-modal" class="fixed inset-0 z-[110] hidden items-center justify-center bg-slate-900/50 p-4">
<div class="w-full max-w-[26rem] rounded-[1.25rem] bg-white p-6 shadow-2xl">
<h2 class="m-0 text-lg font-extrabold text-slate-900">회원 탈퇴 처리</h2>
<p id="user-delete-modal-sub" class="mt-2 text-sm leading-relaxed text-slate-500"></p>
<p class="mt-3 text-xs leading-relaxed text-slate-500">계정과 개인 정보는 삭제되며 저장한 덱은 익명으로 유지됩니다. 복구할 수 없습니다.</p>
<label class="mt-4 block text-[0.6875rem] font-bold uppercase text-slate-400" for="user-delete-confirmation">확인을 위해 DELETE 입력</label>
<input type="text" id="user-delete-confirmation" class="mt-1.5 w-full box-border rounded-xl border border-slate-200 px-3 py-2.5 text-sm" autocomplete="off" placeholder="DELETE" />
<p id="user-delete-error" class="mt-2 hidden text-[0.8125rem] text-red-500"></p>
<div class="mt-5 flex gap-2">
<button type="button" id="user-delete-no" class="flex-1 cursor-pointer rounded-xl border-0 bg-slate-100 px-4 py-2.5 text-sm font-bold text-slate-600 hover:bg-slate-200">NO</button>
<button type="button" id="user-delete-yes" class="flex-1 cursor-pointer rounded-xl border-0 bg-red-600 px-4 py-2.5 text-sm font-bold text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-slate-300" disabled>YES</button>
</div>
</div>
</div>
<script src="/script/api.js?v=4.0.16"></script>
<script src="/script/image-utils.js"></script>
<script src="/script/deck-preview-hold.js?v=4.0.17"></script>
<script src="/script/auth-header.js"></script>
<script>
const DEFAULT_AVATAR =
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#94a3b8"><path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/></svg>',
);
const editModal = document.getElementById('edit-modal');
const passwordModal = document.getElementById('password-modal');
/**
* 관리자 모달 표시 상태 변경
* @param {HTMLElement} modal 대상 모달
* @param {boolean} isOpen 표시 여부
*/
function setModalOpen(modal, isOpen) {
modal.classList.toggle('hidden', !isOpen);
modal.classList.toggle('flex', isOpen);
}
let currentAdminId = null;
let editingUser = null;
let pendingAvatarImage = null;
let pendingRemoveAvatar = false;
let adminDecks = [];
const selectedAdminDecks = new Set();
let isRoleEditorOpen = false;
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function resolveAvatarSrc(userOrUrl) {
const avatarUrl = typeof userOrUrl === 'object' ? userOrUrl?.avatarUrl : userOrUrl;
const updatedAt = typeof userOrUrl === 'object' ? userOrUrl?.updatedAt : null;
if (!avatarUrl) return DEFAULT_AVATAR;
let url = UaDeckApi.resolveMediaUrl(avatarUrl) || DEFAULT_AVATAR;
if (updatedAt && url && !url.startsWith('data:')) {
url += (url.includes('?') ? '&' : '?') + 't=' + encodeURIComponent(updatedAt);
}
return url;
}
/**
* 날짜를 상대 시간 텍스트로 변환
* @param {string|null|undefined} value
* @returns {string}
*/
function formatRelativeTime(value) {
if (!value) return '기록 없음';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '기록 없음';
const diffMs = Date.now() - date.getTime();
if (diffMs < 0) return '방금 전';
const minuteMs = 60 * 1000;
const hourMs = 60 * minuteMs;
const dayMs = 24 * hourMs;
const monthMs = 30 * dayMs;
if (diffMs < minuteMs) return '방금 전';
if (diffMs < hourMs) return `${Math.floor(diffMs / minuteMs)}분 전`;
if (diffMs < dayMs) return `${Math.floor(diffMs / hourMs)}시간 전`;
if (diffMs < monthMs) return `${Math.floor(diffMs / dayMs)}일 전`;
return `${Math.floor(diffMs / monthMs)}달 전`;
}
/**
* 역할 변경 입력창 표시 여부
* @param {boolean} isOpen
*/
function setRoleEditorOpen(isOpen) {
isRoleEditorOpen = isOpen;
document.getElementById('edit-role-editor').classList.toggle('hidden', !isOpen);
document.getElementById('toggle-role-editor').textContent = isOpen ? '역할 변경 닫기' : '역할 변경하기';
}
async function guardAdmin() {
if (!UaDeckApi.getToken()) {
location.href = '/login.html?redirect=/admin.html';
return false;
}
try {
const { user } = await UaDeckApi.getMe();
if (user.role !== 'admin') {
alert('관리자 권한이 필요합니다.');
location.href = '/';
return false;
}
currentAdminId = user.id;
return true;
} catch {
location.href = '/login.html?redirect=/admin.html';
return false;
}
}
async function loadUsers(q = '') {
const { users, total } = await UaDeckApi.adminGetUsers(q);
const list = document.getElementById('user-list');
list.innerHTML =
`<p class="col-span-full mb-1 text-xs text-slate-400">총 ${total}명</p>` +
users
.map((u) => {
const isSelf = u.id === currentAdminId;
const deckPublic = Number(u.deckPublic ?? 0);
const deckTotal = Number(u.deckTotal ?? 0);
return `
<article class="rounded-2xl border border-slate-200 bg-white px-4 py-3.5 shadow-sm">
<div class="flex items-start gap-3">
<img src="${escapeHtml(resolveAvatarSrc(u))}" alt="" class="h-11 w-11 shrink-0 rounded-full border-2 border-slate-200 bg-slate-100 object-cover" onerror="this.src='${DEFAULT_AVATAR}'" />
<div class="min-w-0 flex-1">
<h3 class="mb-0.5 text-sm font-bold leading-snug text-slate-900">${escapeHtml(u.username)}${isSelf ? ' <span class="rounded-md bg-blue-50 px-1.5 py-0.5 text-[0.625rem] font-bold text-blue-600">나</span>' : ''}</h3>
<div class="truncate text-xs text-slate-400">${escapeHtml(u.email)}</div>
<div class="mt-1 text-[0.6875rem] text-slate-500">마지막 접속: ${escapeHtml(formatRelativeTime(u.lastLoginAt))}</div>
<div class="mt-1.5 flex flex-wrap justify-between gap-1">
<div class="flex gap-1">
<span class="rounded-md bg-blue-50 px-1.5 py-0.5 text-[0.625rem] font-bold text-blue-600">${escapeHtml(u.role)}</span>
<span class="rounded-md px-1.5 py-0.5 text-[0.625rem] font-bold ${u.isActive ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-500'}">${u.isActive ? '활성' : '비활성'}</span>
<span class="rounded-md px-1.5 py-0.5 text-[0.625rem] font-bold ${u.emailVerifiedAt ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-700'}">${u.emailVerifiedAt ? '이메일 인증' : '이메일 미인증'}</span>
</div>
<div class="mt-1.5 text-xs font-semibold text-slate-600" title="공개 덱 / 전체 저장 덱">덱 ${deckPublic}/${deckTotal} <span class="font-normal text-slate-400">(공개/전체)</span></div>
</div>
</div>
<button type="button" data-edit='${JSON.stringify(u).replace(/'/g, '&#39;')}' class="shrink-0 cursor-pointer rounded-lg border-0 bg-slate-100 px-2.5 py-1.5 text-[0.6875rem] font-bold text-slate-600 hover:bg-slate-200">수정</button>
</div>
</article>`;
})
.join('');
list.querySelectorAll('[data-edit]').forEach((btn) => {
btn.addEventListener('click', () => openEditModal(JSON.parse(btn.dataset.edit)));
});
}
function openEditModal(u) {
editingUser = u;
pendingAvatarImage = null;
pendingRemoveAvatar = false;
const isSelf = u.id === currentAdminId;
document.getElementById('edit-user-id').value = u.id;
document.getElementById('edit-email').value = u.email;
document.getElementById('edit-username').value = u.username;
document.getElementById('edit-role').value = u.role;
document.getElementById('edit-isActive').value = u.isActive ? '1' : '0';
document.getElementById('edit-role-summary').textContent = u.role === 'admin' ? '관리자' : '일반 회원';
document.getElementById('edit-modal-title').textContent = u.username;
document.getElementById('edit-modal-sub').textContent = u.email;
document.getElementById('edit-avatar-preview').src = resolveAvatarSrc(u);
document.getElementById('edit-avatar-remove').classList.toggle('hidden', !u.avatarUrl);
setRoleEditorOpen(false);
document.getElementById('edit-role').disabled = isSelf;
document.getElementById('edit-isActive').disabled = isSelf;
document.getElementById('toggle-role-editor').disabled = isSelf;
document.getElementById('toggle-role-editor').classList.toggle('hidden', isSelf);
document.getElementById('edit-role-hint').classList.toggle('hidden', !isSelf);
document.getElementById('edit-status-hint').classList.toggle('hidden', !isSelf);
document.getElementById('approve-email-verification').classList.toggle(
'hidden',
Boolean(u.emailVerifiedAt),
);
document.getElementById('modal-error').classList.add('hidden');
setModalOpen(editModal, true);
}
function renderDeckPreview(d) {
if (!d.preview_url && !d.thumbnail_url) {
return '<div class="deck-card-preview"><div class="flex h-full items-center justify-center bg-white text-[0.6875rem] font-semibold text-slate-400">미리보기 없음</div></div>';
}
const version = d.updatedAt ? `?v=${encodeURIComponent(d.updatedAt)}` : '';
const thumbSrc = escapeHtml(UaDeckApi.resolveMediaUrl(d.thumbnail_url || d.preview_url) + version);
const fullSrc = escapeHtml(UaDeckApi.resolveMediaUrl(d.preview_url || d.thumbnail_url) + version);
return `<div class="deck-card-preview deck-card-preview--holdable" data-hold-preview data-preview-src="${fullSrc}"><img src="${thumbSrc}" alt="" loading="lazy" class="admin-deck-preview-img h-full w-full bg-white object-contain" draggable="false" onerror="this.parentElement.classList.remove('deck-card-preview--holdable'); this.parentElement.removeAttribute('data-hold-preview'); this.parentElement.removeAttribute('data-preview-src'); this.parentElement.innerHTML='<div class=\\'flex h-full items-center justify-center bg-white text-[0.6875rem] font-semibold text-slate-400\\'>미리보기 없음</div>'" /></div>`;
}
/**
* 바이트 크기 표시
* @param {number} bytes
* @returns {string}
*/
function formatBytes(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* 관리자 덱 시리즈 필터 갱신
*/
function populateAdminSeriesFilter() {
const select = document.getElementById('admin-deck-series-filter');
const current = select.value;
const seriesMap = new Map();
adminDecks.forEach((deck) => {
if (!seriesMap.has(deck.series)) {
seriesMap.set(deck.series, deck.seriesName || deck.series);
}
});
select.innerHTML =
'<option value="">모든 시리즈</option>' +
[...seriesMap.entries()]
.sort((a, b) => String(a[1]).localeCompare(String(b[1]), 'ko'))
.map(([key, label]) => `<option value="${escapeHtml(key)}">${escapeHtml(label)}</option>`)
.join('');
if (current && seriesMap.has(current)) select.value = current;
}
/**
* 열린 관리자 덱 메뉴 닫기
*/
function closeAdminDeckMenus() {
document.querySelectorAll('#admin-deck-list .deck-card-more-menu').forEach((menu) => {
menu.classList.add('hidden');
});
document.querySelectorAll('#admin-deck-list .deck-card-more-btn').forEach((btn) => {
btn.setAttribute('aria-expanded', 'false');
});
}
/**
* 관리자 덱 목록 렌더링
*/
function renderAdminDecks() {
const query = document.getElementById('admin-deck-search').value.trim().toLowerCase();
const series = document.getElementById('admin-deck-series-filter').value;
const filtered = adminDecks.filter((deck) => {
if (series && deck.series !== series) return false;
if (!query) return true;
return [deck.name, deck.seriesName, deck.series, deck.username]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(query);
});
document.getElementById('deck-count').textContent = `${filtered.length} / ${adminDecks.length}개`;
const list = document.getElementById('admin-deck-list');
if (!filtered.length) {
list.innerHTML = '<div class="deck-grid"><div class="deck-grid-empty">조건에 맞는 덱이 없습니다.</div></div>';
updateBulkDeleteControls(filtered);
return;
}
list.innerHTML = `<div class="deck-grid">${filtered
.map((d) => {
const title = escapeHtml(d.name || d.seriesName || d.series || '이름 없음');
const seriesLabel = escapeHtml(d.seriesName || d.series);
const date = new Date(d.updatedAt).toLocaleDateString('ko-KR');
const visibilityBadge = d.isPublic
? '<span class="deck-card-badge-public">공개</span>'
: '<span class="deck-card-badge-private">비공개</span>';
return `
<article class="deck-card" data-code="${escapeHtml(d.shareCode)}">
<div class="relative">
<label class="absolute left-2 top-2 z-10 flex cursor-pointer items-center rounded-lg bg-white/90 p-1.5 shadow-sm" title="덱 선택">
<input type="checkbox" class="admin-deck-select size-4 rounded border-slate-300 accent-blue-600" data-share-code="${escapeHtml(d.shareCode)}" ${selectedAdminDecks.has(d.shareCode) ? 'checked' : ''} />
</label>
${renderDeckPreview(d)}
</div>
<div class="deck-card-body">
<div class="deck-card-visibility">
<span class="deck-card-series">${seriesLabel}</span>
${visibilityBadge}
</div>
<h2 class="deck-card-title">${title}</h2>
<div class="deck-card-meta">${escapeHtml(d.username || '비회원')} · ${date}</div>
<div class="deck-card-actions justify-end">
<button type="button" class="deck-card-btn deck-card-btn-load" data-admin-load="${escapeHtml(d.shareCode)}" data-series="${escapeHtml(d.series)}" data-name="${escapeHtml(d.seriesName || '')}">불러오기</button>
<div class="deck-card-more-wrap">
<button type="button" class="deck-card-more-btn" aria-haspopup="true" aria-expanded="false" aria-label="더 보기">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="8" cy="3" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="8" cy="13" r="1.5"/></svg>
</button>
<div class="deck-card-more-menu hidden" role="menu">
<button type="button" class="deck-card-more-item deck-card-more-item--danger" role="menuitem" data-admin-delete="${escapeHtml(d.shareCode)}">삭제</button>
</div>
</div>
</div>
</div>
</article>`;
})
.join('')}</div>`;
closeAdminDeckMenus();
if (window.UaDeckPreviewHold?.bindHoldPreview) {
window.UaDeckPreviewHold.bindHoldPreview(list);
}
updateBulkDeleteControls(filtered);
}
/**
* 일괄 삭제 선택 상태 갱신
* @param {object[]} [visibleDecks]
*/
function updateBulkDeleteControls(visibleDecks = null) {
const visible = visibleDecks || adminDecks.filter((deck) => {
const query = document.getElementById('admin-deck-search').value.trim().toLowerCase();
const series = document.getElementById('admin-deck-series-filter').value;
if (series && deck.series !== series) return false;
if (!query) return true;
return [deck.name, deck.seriesName, deck.series, deck.username]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(query);
});
const visibleCodes = visible.map((deck) => deck.shareCode);
const selectedVisibleCount = visibleCodes.filter((code) => selectedAdminDecks.has(code)).length;
const selectAll = document.getElementById('admin-deck-select-visible');
selectAll.checked = visibleCodes.length > 0 && selectedVisibleCount === visibleCodes.length;
selectAll.indeterminate = selectedVisibleCount > 0 && selectedVisibleCount < visibleCodes.length;
document.getElementById('admin-deck-bulk-delete').disabled = selectedAdminDecks.size === 0;
document.getElementById('admin-deck-bulk-delete').textContent =
selectedAdminDecks.size > 0
? `선택 덱 삭제 (${selectedAdminDecks.size})`
: '선택 덱 삭제';
}
async function loadAdminDecks() {
const { decks } = await UaDeckApi.adminGetDecks();
adminDecks = decks;
[...selectedAdminDecks].forEach((code) => {
if (!adminDecks.some((deck) => deck.shareCode === code)) {
selectedAdminDecks.delete(code);
}
});
populateAdminSeriesFilter();
renderAdminDecks();
}
/**
* 미참조 썸네일 현황 조회
*/
async function loadOrphanPreviews() {
const status = document.getElementById('orphan-preview-status');
const deleteBtn = document.getElementById('orphan-preview-delete');
try {
const data = await UaDeckApi.adminGetOrphanPreviews();
status.textContent = data.totalCount
? `${data.totalCount}개 · ${formatBytes(data.totalBytes)}`
: '정리할 파일이 없습니다.';
deleteBtn.disabled = data.totalCount === 0;
const list = document.getElementById('orphan-preview-list');
list.classList.toggle('hidden', data.totalCount === 0);
list.innerHTML = data.files
.map(
(file) =>
`<div class="flex flex-col gap-1 border-b border-slate-100 py-2 text-xs last:border-b-0 sm:flex-row sm:justify-between sm:gap-4"><span class="min-w-0 break-all text-slate-700">${escapeHtml(file.fileName)}</span><span class="shrink-0 text-slate-400">${formatBytes(file.size)} · ${new Date(file.modifiedAt).toLocaleDateString('ko-KR')}</span></div>`,
)
.join('');
} catch (err) {
status.textContent = err.message;
deleteBtn.disabled = true;
document.getElementById('orphan-preview-list').classList.add('hidden');
}
}
document.getElementById('edit-avatar-btn').addEventListener('click', () => {
document.getElementById('edit-avatar-file').click();
});
document.getElementById('edit-avatar-file').addEventListener('change', async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
pendingAvatarImage = await UaImageUtils.resizeImageToPng(file);
pendingRemoveAvatar = false;
document.getElementById('edit-avatar-preview').src = pendingAvatarImage;
document.getElementById('edit-avatar-remove').classList.remove('hidden');
} catch (err) {
alert(err.message || '이미지 처리에 실패했습니다.');
}
e.target.value = '';
});
document.getElementById('edit-avatar-remove').addEventListener('click', () => {
pendingAvatarImage = null;
pendingRemoveAvatar = true;
document.getElementById('edit-avatar-preview').src = DEFAULT_AVATAR;
document.getElementById('edit-avatar-remove').classList.add('hidden');
});
document.getElementById('open-password-modal').addEventListener('click', () => {
if (!editingUser) return;
document.getElementById('password-user-id').value = editingUser.id;
document.getElementById('password-modal-sub').textContent = `${editingUser.username} (${editingUser.email})`;
document.getElementById('password-new').value = '';
document.getElementById('password-confirm').value = '';
document.getElementById('password-error').classList.add('hidden');
setModalOpen(passwordModal, true);
});
document.getElementById('approve-email-verification').addEventListener('click', async () => {
if (!editingUser || editingUser.emailVerifiedAt) return;
if (!confirm(`${editingUser.email} 계정의 이메일 인증을 승인하시겠습니까?`)) return;
const errEl = document.getElementById('modal-error');
errEl.classList.add('hidden');
try {
const result = await UaDeckApi.adminVerifyUserEmail(editingUser.id);
editingUser.emailVerifiedAt = result.emailVerifiedAt;
document.getElementById('approve-email-verification').classList.add('hidden');
await loadUsers(document.getElementById('user-search').value);
alert('이메일 인증을 승인했습니다.');
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
});
document.getElementById('toggle-role-editor').addEventListener('click', () => {
if (!editingUser || editingUser.id === currentAdminId) return;
setRoleEditorOpen(!isRoleEditorOpen);
});
document.getElementById('password-cancel').addEventListener('click', () => {
setModalOpen(passwordModal, false);
});
document.getElementById('password-save').addEventListener('click', async () => {
const errEl = document.getElementById('password-error');
errEl.classList.add('hidden');
const pw = document.getElementById('password-new').value;
const confirm = document.getElementById('password-confirm').value;
if (pw.length < 8) {
errEl.textContent = '비밀번호는 8자 이상이어야 합니다.';
errEl.classList.remove('hidden');
return;
}
if (pw !== confirm) {
errEl.textContent = '비밀번호가 일치하지 않습니다.';
errEl.classList.remove('hidden');
return;
}
try {
await UaDeckApi.adminResetPassword(document.getElementById('password-user-id').value, pw);
setModalOpen(passwordModal, false);
alert('비밀번호가 변경되었습니다.');
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
});
document.querySelectorAll('[data-tab]').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-tab]').forEach((tabButton) => {
const isActive = tabButton === btn;
tabButton.classList.toggle('border-blue-600', isActive);
tabButton.classList.toggle('text-blue-600', isActive);
tabButton.classList.toggle('border-transparent', !isActive);
tabButton.classList.toggle('text-slate-400', !isActive);
tabButton.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
document.getElementById('tab-users').classList.toggle('hidden', btn.dataset.tab !== 'users');
document.getElementById('tab-decks').classList.toggle('hidden', btn.dataset.tab !== 'decks');
});
});
document.getElementById('admin-deck-search').addEventListener('input', renderAdminDecks);
document.getElementById('admin-deck-series-filter').addEventListener('change', renderAdminDecks);
document.getElementById('admin-deck-list').addEventListener('click', async (e) => {
const selection = e.target.closest('.admin-deck-select');
if (selection) {
if (selection.checked) {
selectedAdminDecks.add(selection.dataset.shareCode);
} else {
selectedAdminDecks.delete(selection.dataset.shareCode);
}
updateBulkDeleteControls();
return;
}
const moreBtn = e.target.closest('.deck-card-more-btn');
if (moreBtn) {
e.stopPropagation();
const menu = moreBtn.parentElement?.querySelector('.deck-card-more-menu');
const isOpen = menu && !menu.classList.contains('hidden');
closeAdminDeckMenus();
if (!isOpen && menu) {
menu.classList.remove('hidden');
moreBtn.setAttribute('aria-expanded', 'true');
}
return;
}
const deleteBtn = e.target.closest('[data-admin-delete]');
if (deleteBtn) {
if (!confirm('덱과 연결된 썸네일을 함께 삭제하시겠습니까?')) return;
await UaDeckApi.adminDeleteDeck(deleteBtn.dataset.adminDelete);
await Promise.all([loadAdminDecks(), loadOrphanPreviews()]);
return;
}
const loadBtn = e.target.closest('[data-admin-load]');
if (loadBtn) {
const code = loadBtn.dataset.adminLoad;
const series = loadBtn.dataset.series;
const name = loadBtn.dataset.name;
location.href = `/cardList.html?series=${series}&seriesName=${encodeURIComponent(name)}&d=${code}`;
}
});
document.addEventListener('click', (e) => {
if (!e.target.closest('#admin-deck-list .deck-card-more-wrap')) closeAdminDeckMenus();
});
document.getElementById('admin-deck-select-visible').addEventListener('change', (event) => {
document.querySelectorAll('.admin-deck-select').forEach((checkbox) => {
checkbox.checked = event.target.checked;
if (event.target.checked) {
selectedAdminDecks.add(checkbox.dataset.shareCode);
} else {
selectedAdminDecks.delete(checkbox.dataset.shareCode);
}
});
updateBulkDeleteControls();
});
document.getElementById('admin-deck-bulk-delete').addEventListener('click', async () => {
const shareCodes = [...selectedAdminDecks];
if (!shareCodes.length) return;
if (!confirm(`선택한 덱 ${shareCodes.length}개와 연결된 썸네일을 함께 삭제하시겠습니까?`)) return;
const result = await UaDeckApi.adminDeleteDecks(shareCodes);
selectedAdminDecks.clear();
alert(`${result.deletedCount}개 덱을 삭제했습니다.`);
await Promise.all([loadAdminDecks(), loadOrphanPreviews()]);
});
document.getElementById('orphan-preview-refresh').addEventListener('click', loadOrphanPreviews);
document.getElementById('orphan-preview-delete').addEventListener('click', async () => {
if (!confirm('어떤 덱에서도 사용하지 않는 썸네일을 모두 삭제하시겠습니까?')) return;
const result = await UaDeckApi.adminDeleteOrphanPreviews();
alert(`${result.deletedCount}개 파일을 정리했습니다.`);
loadOrphanPreviews();
});
document.getElementById('user-search-btn').addEventListener('click', () => {
loadUsers(document.getElementById('user-search').value);
});
document.getElementById('modal-cancel').addEventListener('click', () => {
setModalOpen(editModal, false);
});
document.getElementById('modal-save').addEventListener('click', async () => {
const errEl = document.getElementById('modal-error');
errEl.classList.add('hidden');
const id = document.getElementById('edit-user-id').value;
const isSelf = Number(id) === currentAdminId;
const body = {
email: document.getElementById('edit-email').value,
username: document.getElementById('edit-username').value,
};
const selectedRole = document.getElementById('edit-role').value;
if (!isSelf) {
if (isRoleEditorOpen) {
if (selectedRole === 'delete_user') {
const targetLabel = `${document.getElementById('edit-username').value || editingUser?.username || ''} (${document.getElementById('edit-email').value || editingUser?.email || ''})`.trim();
const firstConfirm = confirm(`정말 ${targetLabel} 회원을 탈퇴 처리하시겠습니까?`);
if (!firstConfirm) return;
const secondConfirm = confirm('탈퇴 처리 후 계정 복구는 불가능합니다. 계속 진행할까요?');
if (!secondConfirm) return;
try {
await UaDeckApi.adminDeleteUser(id, 'DELETE');
setModalOpen(editModal, false);
editingUser = null;
await Promise.all([
loadUsers(document.getElementById('user-search').value),
loadAdminDecks(),
loadOrphanPreviews(),
]);
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
return;
}
body.role = selectedRole;
}
body.isActive = document.getElementById('edit-isActive').value === '1';
}
if (pendingAvatarImage) body.avatarImage = pendingAvatarImage;
if (pendingRemoveAvatar) body.removeAvatar = true;
try {
await UaDeckApi.adminUpdateUser(id, body);
setModalOpen(editModal, false);
loadUsers(document.getElementById('user-search').value);
if (isSelf && window.UaDeckAuth?.refresh) {
await window.UaDeckAuth.refresh();
}
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
});
[editModal, passwordModal].forEach((modal) => {
modal.addEventListener('click', (e) => {
if (e.target === modal) setModalOpen(modal, false);
});
});
document.addEventListener('DOMContentLoaded', async () => {
if (await guardAdmin()) {
loadUsers();
loadAdminDecks();
loadOrphanPreviews();
}
});
</script>
</body>
</html>

3
api/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
data/
.env

18
api/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:22-alpine
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY src ./src
ENV NODE_ENV=production
ENV DATAS_PATH=/datas
ENV DB_PATH=/data/uadeck.db
EXPOSE 3000
CMD ["node", "src/index.js"]

20
api/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "uadeck-api",
"version": "4.0.28",
"type": "module",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"test": "node --test test/*.test.js"
},
"dependencies": {
"bcryptjs": "^3.0.2",
"better-sqlite3": "^11.9.1",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"express-rate-limit": "^8.5.2",
"jsonwebtoken": "^9.0.2"
}
}

178
api/src/db.js Normal file
View File

@@ -0,0 +1,178 @@
import Database from 'better-sqlite3';
import bcrypt from 'bcryptjs';
import fs from 'fs';
import path from 'path';
/**
* SQLite DB 연결 및 스키마 초기화
* @param {string} dbPath
* @returns {import('better-sqlite3').Database}
*/
export function initDb(dbPath) {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
avatar_url TEXT,
role TEXT NOT NULL DEFAULT 'user',
is_active INTEGER NOT NULL DEFAULT 1,
last_login_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS decks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
share_code TEXT NOT NULL UNIQUE,
share_type TEXT NOT NULL DEFAULT 'user',
content_hash TEXT,
user_id INTEGER,
series TEXT NOT NULL,
series_name TEXT,
name TEXT NOT NULL DEFAULT '',
cards_json TEXT NOT NULL,
is_public INTEGER NOT NULL DEFAULT 1,
view_count INTEGER NOT NULL DEFAULT 0,
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_decks_user_id ON decks(user_id);
CREATE INDEX IF NOT EXISTS idx_decks_content_hash ON decks(content_hash);
CREATE INDEX IF NOT EXISTS idx_decks_public_list
ON decks(share_type, is_public, updated_at);
CREATE TABLE IF NOT EXISTS deck_favorites (
user_id INTEGER NOT NULL,
share_code TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, share_code),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (share_code) REFERENCES decks(share_code) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_deck_favorites_share_code ON deck_favorites(share_code);
CREATE TABLE IF NOT EXISTS auth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_type
ON auth_tokens(user_id, type);
CREATE TABLE IF NOT EXISTS email_send_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL,
month_key TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'reserved',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
sent_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_email_send_logs_user_month
ON email_send_logs(user_id, month_key, status);
`);
const deckCols = db.prepare('PRAGMA table_info(decks)').all();
if (!deckCols.some((c) => c.name === 'preview_image')) {
db.exec('ALTER TABLE decks ADD COLUMN preview_image TEXT');
}
const userCols = db.prepare('PRAGMA table_info(users)').all();
if (!userCols.some((c) => c.name === 'default_deck_public')) {
db.exec('ALTER TABLE users ADD COLUMN default_deck_public INTEGER NOT NULL DEFAULT 1');
}
if (!userCols.some((c) => c.name === 'skip_deck_public_prompt')) {
db.exec('ALTER TABLE users ADD COLUMN skip_deck_public_prompt INTEGER NOT NULL DEFAULT 0');
}
if (!userCols.some((c) => c.name === 'last_login_at')) {
db.exec('ALTER TABLE users ADD COLUMN last_login_at TEXT');
}
if (!userCols.some((c) => c.name === 'email_verified_at')) {
db.exec('ALTER TABLE users ADD COLUMN email_verified_at TEXT');
db.exec(`
UPDATE users
SET email_verified_at = COALESCE(updated_at, created_at, datetime('now'))
WHERE email_verified_at IS NULL
`);
}
const usernameCaseConflicts = db.prepare(`
SELECT lower(username) AS normalized_username, COUNT(*) AS count
FROM users
GROUP BY lower(username)
HAVING COUNT(*) > 1
LIMIT 1
`).get();
if (!usernameCaseConflicts) {
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_nocase
ON users(username COLLATE NOCASE)
`);
} else {
console.warn(
'대소문자만 다른 기존 사용자명이 있어 NOCASE 고유 인덱스를 만들지 못했습니다. 관리자 확인이 필요합니다.',
);
}
return db;
}
/**
* 환경변수 기준 관리자 계정 시드
* ADMIN_EMAIL로 이미 가입한 경우 역할만 admin으로 승격한다.
* @param {import('better-sqlite3').Database} db
*/
export function seedAdmin(db) {
const email = process.env.ADMIN_EMAIL?.trim().toLowerCase();
const password = process.env.ADMIN_PASSWORD;
const username = process.env.ADMIN_USERNAME || 'admin';
if (!email || !password) return;
const existingAdmin = db.prepare('SELECT id FROM users WHERE role = ?').get('admin');
if (existingAdmin) return;
const existingUser = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (existingUser) {
db.prepare(`
UPDATE users
SET role = 'admin',
email_verified_at = COALESCE(email_verified_at, datetime('now')),
updated_at = datetime('now')
WHERE id = ?
`).run(existingUser.id);
return;
}
const hash = bcrypt.hashSync(password, 12);
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, role, email_verified_at
)
VALUES (?, ?, ?, ?, 'admin', datetime('now'))
`).run(email, username, hash, '관리자');
}

75
api/src/index.js Normal file
View File

@@ -0,0 +1,75 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { initDb, seedAdmin } from './db.js';
import { createAuthRouter } from './routes/auth.js';
import { createDecksRouter } from './routes/decks.js';
import { createAdminRouter } from './routes/admin.js';
import { createStatsRouter } from './routes/stats.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT) || 3000;
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'uadeck.db');
/**
* package.json에서 서비스 버전을 읽는다. (실패 시 'unknown')
* @returns {string}
*/
function readAppVersion() {
try {
const pkgRaw = fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8');
return JSON.parse(pkgRaw).version || 'unknown';
} catch {
return 'unknown';
}
}
const APP_VERSION = readAppVersion();
if (!process.env.DATAS_PATH) {
process.env.DATAS_PATH = path.join(__dirname, '..', '..', 'datas');
}
const db = initDb(DB_PATH);
seedAdmin(db);
const app = express();
// nginx 프록시 1홉 뒤에서 실행 → rate limit이 실제 클라이언트 IP(X-Forwarded-For)를 쓰도록
app.set('trust proxy', 1);
const corsOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map((o) => o.trim())
: true;
app.use(cors({ origin: corsOrigins, credentials: true }));
app.use(express.json({ limit: '20mb' }));
app.get('/api/v1/health', (_req, res) => {
res.json({ ok: true, version: APP_VERSION });
});
app.use('/api/stats', createStatsRouter(db, APP_VERSION));
app.use('/api/v1/stats', createStatsRouter(db, APP_VERSION));
app.use('/api/v1/auth', createAuthRouter(db));
app.use('/api/v1/decks', createDecksRouter(db));
app.use('/api/v1/admin', createAdminRouter(db));
app.use((err, _req, res, _next) => {
if (err.type === 'entity.too.large') {
return res.status(413).json({
error: '요청 크기가 너무 큽니다. 고화질 옵션을 끄고 다시 저장해 주세요.',
});
}
console.error(err);
res.status(500).json({ error: '서버 오류가 발생했습니다.' });
});
app.listen(PORT, () => {
console.log(`UNIARE API v${APP_VERSION} listening on :${PORT}`);
console.log(`DB: ${DB_PATH}`);
console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
});

View File

@@ -0,0 +1,91 @@
import jwt from 'jsonwebtoken';
/**
* JWT 시크릿
* @returns {string}
*/
function getSecret() {
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error('JWT_SECRET 환경변수가 설정되지 않았습니다.');
}
return secret;
}
/**
* JWT 토큰 발급
* @param {object} user
* @returns {string}
*/
export function signToken(user) {
return jwt.sign(
{ sub: user.id, role: user.role },
getSecret(),
{ expiresIn: process.env.JWT_EXPIRES || '7d' },
);
}
/**
* 선택적 인증 — 토큰 없으면 req.user = null
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
export function optionalAuth(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
req.user = null;
return next();
}
try {
const payload = jwt.verify(header.slice(7), getSecret());
req.user = { id: payload.sub, role: payload.role };
} catch {
req.user = null;
}
next();
}
/**
* 필수 인증
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
export function requireAuth(req, res, next) {
optionalAuth(req, res, () => {
if (!req.user) {
return res.status(401).json({ error: '로그인이 필요합니다.' });
}
next();
});
}
/**
* 관리자 전용 — JWT가 아닌 DB의 최신 role을 확인한다.
* @param {import('better-sqlite3').Database} db
* @returns {import('express').RequestHandler}
*/
export function createRequireAdmin(db) {
return (req, res, next) => {
requireAuth(req, res, () => {
const row = db.prepare('SELECT role FROM users WHERE id = ? AND is_active = 1').get(req.user.id);
if (!row || row.role !== 'admin') {
return res.status(403).json({ error: '관리자 권한이 필요합니다.' });
}
req.user.role = row.role;
next();
});
};
}
/**
* @deprecated createRequireAdmin(db) 사용 권장
*/
export function requireAdmin(req, res, next) {
if (req.user?.role !== 'admin') {
return res.status(403).json({ error: '관리자 권한이 필요합니다.' });
}
next();
}

440
api/src/routes/admin.js Normal file
View File

@@ -0,0 +1,440 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { createRequireAdmin } from '../middleware/auth.js';
import { contentHash, validateCards, nowIso } from '../utils.js';
import { resolveDeckCards } from '../services/cardResolver.js';
import {
getPreviewUrl,
getThumbnailUrl,
deletePreviewImage,
listOrphanPreviewImages,
deleteOrphanPreviewImages,
} from '../services/previewImage.js';
import { getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
import { deleteUserAccount } from '../services/accountDeletion.js';
/**
* 회원 목록에 저장 덱 수(공개/전체) 부여
* @param {import('better-sqlite3').Database} db
* @param {Array<{ id: number }>} users
* @returns {Array<object>}
*/
function attachUserDeckCounts(db, users) {
if (!users.length) return users;
const placeholders = users.map(() => '?').join(',');
const ids = users.map((u) => u.id);
const counts = db
.prepare(
`
SELECT user_id AS userId,
COUNT(*) AS deckTotal,
SUM(CASE WHEN is_public = 1 THEN 1 ELSE 0 END) AS deckPublic
FROM decks
WHERE share_type = 'user' AND user_id IN (${placeholders})
GROUP BY user_id
`,
)
.all(...ids);
const countByUser = new Map(counts.map((row) => [row.userId, row]));
return users.map((user) => {
const stats = countByUser.get(user.id);
return {
...user,
deckPublic: stats?.deckPublic ?? 0,
deckTotal: stats?.deckTotal ?? 0,
};
});
}
/**
* 관리자 라우트
* @param {import('better-sqlite3').Database} db
* @returns {import('express').Router}
*/
export function createAdminRouter(db) {
const router = Router();
router.use(createRequireAdmin(db));
router.get('/users', (req, res) => {
const { q, page = 1, limit = 20 } = req.query;
const offset = (Number(page) - 1) * Number(limit);
let rows;
let total;
if (q) {
const like = `%${q}%`;
total = db.prepare(`
SELECT COUNT(*) AS c FROM users
WHERE email LIKE ? OR username LIKE ? OR display_name LIKE ?
`).get(like, like, like).c;
rows = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, email_verified_at AS emailVerifiedAt,
last_login_at AS lastLoginAt,
created_at AS createdAt, updated_at AS updatedAt
FROM users
WHERE email LIKE ? OR username LIKE ? OR display_name LIKE ?
ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(like, like, like, Number(limit), offset);
} else {
total = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
rows = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, email_verified_at AS emailVerifiedAt,
last_login_at AS lastLoginAt,
created_at AS createdAt, updated_at AS updatedAt
FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(Number(limit), offset);
}
res.json({
users: attachUserDeckCounts(
db,
rows.map((u) => ({
...u,
avatarUrl: getAvatarUrl(u.id) || u.avatarUrl,
})),
),
total,
page: Number(page),
limit: Number(limit),
});
});
router.get('/users/:id', (req, res) => {
const user = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, email_verified_at AS emailVerifiedAt,
last_login_at AS lastLoginAt,
created_at AS createdAt, updated_at AS updatedAt
FROM users WHERE id = ?
`).get(req.params.id);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
const deckTotal = db.prepare(
"SELECT COUNT(*) AS c FROM decks WHERE user_id = ? AND share_type = 'user'",
).get(user.id).c;
const deckPublic = db.prepare(
"SELECT COUNT(*) AS c FROM decks WHERE user_id = ? AND share_type = 'user' AND is_public = 1",
).get(user.id).c;
user.avatarUrl = getAvatarUrl(user.id) || user.avatarUrl;
res.json({ user, deckCount: deckTotal, deckPublic, deckTotal });
});
router.put('/users/:id', (req, res) => {
const targetId = Number(req.params.id);
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(targetId);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
const isSelf = req.user.id === targetId;
const { email, username, role, isActive, avatarImage, removeAvatar } = req.body || {};
if (isSelf) {
if (role === 'user') {
return res.status(400).json({ error: '자신의 관리자 권한은 해제할 수 없습니다.' });
}
if (isActive === false) {
return res.status(400).json({ error: '자신의 계정은 비활성화할 수 없습니다.' });
}
}
let nextAvatarUrl;
if (avatarImage) {
const saved = saveAvatarImage(targetId, avatarImage);
if (!saved) {
return res.status(400).json({ error: '프로필 이미지 저장에 실패했습니다.' });
}
nextAvatarUrl = getAvatarUrl(targetId);
} else if (removeAvatar === true) {
deleteAvatarImage(targetId);
nextAvatarUrl = null;
}
const trimmedUsername = username?.trim() ?? null;
const trimmedEmail = email?.trim().toLowerCase() ?? null;
try {
const fields = ['updated_at = ?'];
const values = [nowIso()];
if (trimmedEmail) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail) || trimmedEmail.length > 254) {
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
}
fields.push('email = ?');
values.push(trimmedEmail);
}
if (trimmedUsername) {
if (
trimmedUsername.length < 2 ||
trimmedUsername.length > 30 ||
!/^[\p{L}\p{N}_-]+$/u.test(trimmedUsername)
) {
return res.status(400).json({
error: '사용자명은 2~30자의 한글·영문·숫자·밑줄·하이픈만 사용할 수 있습니다.',
});
}
const duplicate = db.prepare(`
SELECT id FROM users
WHERE username = ? COLLATE NOCASE AND id != ?
`).get(trimmedUsername, targetId);
if (duplicate) {
return res.status(409).json({ error: '이미 사용 중인 사용자명입니다.' });
}
fields.push('username = ?', 'display_name = ?');
values.push(trimmedUsername, trimmedUsername);
}
if (!isSelf && role) {
fields.push('role = ?');
values.push(role);
}
if (!isSelf && isActive !== undefined) {
fields.push('is_active = ?');
values.push(isActive ? 1 : 0);
}
if (nextAvatarUrl !== undefined) {
fields.push('avatar_url = ?');
values.push(nextAvatarUrl);
}
values.push(targetId);
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
} catch (err) {
if (String(err.message).includes('UNIQUE')) {
return res.status(409).json({ error: '이메일 또는 사용자명이 이미 사용 중입니다.' });
}
throw err;
}
const updated = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, email_verified_at AS emailVerifiedAt,
last_login_at AS lastLoginAt,
created_at AS createdAt, updated_at AS updatedAt
FROM users WHERE id = ?
`).get(targetId);
updated.avatarUrl = getAvatarUrl(updated.id) || updated.avatarUrl;
res.json({ user: updated });
});
router.post('/users/:id/verify-email', (req, res) => {
const targetId = Number(req.params.id);
const user = db.prepare(`
SELECT id, email_verified_at AS emailVerifiedAt
FROM users WHERE id = ?
`).get(targetId);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
const verifiedAt = user.emailVerifiedAt || nowIso();
const transaction = db.transaction(() => {
db.prepare(`
UPDATE users
SET email_verified_at = ?, updated_at = ?
WHERE id = ?
`).run(verifiedAt, nowIso(), targetId);
db.prepare(`
UPDATE auth_tokens
SET used_at = ?
WHERE user_id = ? AND type = 'verify_email' AND used_at IS NULL
`).run(nowIso(), targetId);
});
transaction();
res.json({
ok: true,
emailVerifiedAt: verifiedAt,
message: '이메일 인증을 승인했습니다.',
});
});
router.post('/users/:id/reset-password', (req, res) => {
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
const { newPassword } = req.body || {};
if (!newPassword || newPassword.length < 8) {
return res.status(400).json({ error: '새 비밀번호는 8자 이상이어야 합니다.' });
}
const hash = bcrypt.hashSync(newPassword, 12);
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?').run(hash, nowIso(), req.params.id);
res.json({ ok: true, message: '비밀번호가 초기화되었습니다.' });
});
router.delete('/users/:id', (req, res) => {
const targetId = Number(req.params.id);
if (req.user.id === targetId) {
return res.status(400).json({
error: '현재 로그인한 관리자 계정은 직접 탈퇴시킬 수 없습니다.',
});
}
if (String(req.body?.confirmation || '') !== 'DELETE') {
return res.status(400).json({
error: '탈퇴 확인 입력란에 DELETE를 정확히 입력해 주세요.',
code: 'ACCOUNT_DELETE_CONFIRMATION_REQUIRED',
});
}
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(targetId);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
const result = deleteUserAccount(db, targetId);
res.json({
ok: true,
...result,
message: '회원 탈퇴 처리가 완료되었습니다.',
});
});
router.post('/decks/preset', async (req, res, next) => {
try {
const { series, seriesName, name, cards, isPublic } = req.body || {};
if (!series || !cards || !name) {
return res.status(400).json({ error: 'series, name, cards는 필수입니다.' });
}
const normalized = validateCards(cards);
const hash = contentHash(normalized);
const existing = db.prepare('SELECT share_code FROM decks WHERE content_hash = ? AND share_type = ?').get(hash, 'preset');
if (existing) {
return res.status(409).json({
error: '동일한 구성의 프리셋이 이미 존재합니다.',
shareCode: existing.share_code,
});
}
const cardsJson = JSON.stringify(normalized);
db.prepare(`
INSERT INTO decks (share_code, share_type, content_hash, user_id, series, series_name, name, cards_json, is_public)
VALUES (?, 'preset', ?, ?, ?, ?, ?, ?, ?)
`).run(
hash,
hash,
req.user.id,
series,
seriesName || series,
name,
cardsJson,
isPublic === false ? 0 : 1,
);
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(hash);
const { cards: resolved, totalCards } = await resolveDeckCards(series, normalized);
res.status(201).json({
v: 1,
shareCode: row.share_code,
shareType: 'preset',
series: row.series,
seriesName: row.series_name,
name: row.name,
totalCards,
cards: resolved,
});
} catch (err) {
if (err.message?.includes('카드') || err.message?.includes('덱')) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
router.get('/decks', (req, res) => {
const rows = db.prepare(`
SELECT d.id, d.share_code AS shareCode, d.share_type AS shareType, d.series, d.series_name AS seriesName,
d.name, d.is_public AS isPublic, d.view_count AS viewCount, d.created_at AS createdAt,
d.preview_image AS previewImage, d.updated_at AS updatedAt,
u.username, u.id AS userId
FROM decks d LEFT JOIN users u ON d.user_id = u.id
ORDER BY d.created_at DESC LIMIT 100
`).all();
res.json({
decks: rows.map((row) => ({
...row,
preview_url: getPreviewUrl(row.shareCode, row.previewImage),
thumbnail_url: getThumbnailUrl(row.shareCode),
})),
});
});
router.get('/previews/orphans', (_req, res) => {
const referencedCodes = db.prepare('SELECT share_code FROM decks').all().map((row) => row.share_code);
const files = listOrphanPreviewImages(referencedCodes);
res.json({
files,
totalCount: files.length,
totalBytes: files.reduce((sum, file) => sum + file.size, 0),
});
});
router.delete('/previews/orphans', (_req, res) => {
const referencedCodes = db.prepare('SELECT share_code FROM decks').all().map((row) => row.share_code);
res.json({ ok: true, ...deleteOrphanPreviewImages(referencedCodes) });
});
router.post('/decks/bulk-delete', (req, res) => {
const shareCodes = Array.isArray(req.body?.shareCodes)
? [...new Set(req.body.shareCodes)]
.filter((code) => typeof code === 'string' && code.length >= 1 && code.length <= 64)
.slice(0, 100)
: [];
if (!shareCodes.length) {
return res.status(400).json({ error: '삭제할 덱을 선택하세요.' });
}
const placeholders = shareCodes.map(() => '?').join(',');
const existing = db.prepare(`
SELECT share_code AS shareCode
FROM decks
WHERE share_code IN (${placeholders})
`).all(...shareCodes);
const deleteDecks = db.transaction((codes) => {
db.prepare(`
DELETE FROM decks
WHERE share_code IN (${codes.map(() => '?').join(',')})
`).run(...codes);
});
const existingCodes = existing.map((row) => row.shareCode);
if (existingCodes.length) deleteDecks(existingCodes);
let previewDeletedCount = 0;
existingCodes.forEach((shareCode) => {
if (deletePreviewImage(shareCode)) previewDeletedCount += 1;
});
res.json({
ok: true,
deletedCount: existingCodes.length,
previewDeletedCount,
});
});
router.delete('/decks/:shareCode', (req, res) => {
const row = db.prepare('SELECT id, share_code FROM decks WHERE share_code = ?').get(req.params.shareCode);
if (!row) {
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
}
db.prepare('DELETE FROM decks WHERE id = ?').run(row.id);
const previewDeleted = deletePreviewImage(row.share_code);
res.json({ ok: true, previewDeleted });
});
return router;
}

640
api/src/routes/auth.js Normal file
View File

@@ -0,0 +1,640 @@
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import path from 'path';
import crypto from 'crypto';
import bcrypt from 'bcryptjs';
import { signToken, requireAuth } from '../middleware/auth.js';
import { nowIso } from '../utils.js';
import { getAvatarFilePath, getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
import { sendPasswordResetEmail, sendVerificationEmail } from '../services/emailService.js';
import { deleteUserAccount } from '../services/accountDeletion.js';
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const USERNAME_PATTERN = /^[\p{L}\p{N}_-]+$/u;
const VERIFICATION_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
const PASSWORD_RESET_TOKEN_TTL_MS = 30 * 60 * 1000;
const EMAIL_RESEND_COOLDOWN_MS = 60 * 1000;
const MONTHLY_EMAIL_LIMIT = 3;
const EMAIL_LIMIT_MESSAGE =
'이 계정의 월간 이메일 발송 한도(3회)를 초과했습니다. 관리자 zenn@sori.studio로 직접 문의해 주세요.';
const EMAIL_LIMIT_CODE = 'MONTHLY_EMAIL_LIMIT_EXCEEDED';
/** 로그인 무차별 대입 방어 (IP 기준) */
const loginLimiter = rateLimit({
windowMs: 10 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.' },
});
/** 계정·이메일 관련 쓰기 요청 남용 방어 (IP 기준) */
const authWriteLimiter = rateLimit({
windowMs: 10 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.' },
});
/**
* 한국 시간 기준 월 키
* @returns {string}
*/
function getKoreanMonthKey() {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Seoul',
year: 'numeric',
month: '2-digit',
}).formatToParts(new Date());
const year = parts.find((part) => part.type === 'year')?.value;
const month = parts.find((part) => part.type === 'month')?.value;
return `${year}-${month}`;
}
/**
* 월간 이메일 발송 횟수 예약
* @param {import('better-sqlite3').Database} db
* @param {number} userId
* @param {'verify_email'|'reset_password'} type
* @returns {number|null} 예약 로그 ID. 한도 초과 시 null.
*/
function reserveEmailSend(db, userId, type) {
const monthKey = getKoreanMonthKey();
const transaction = db.transaction(() => {
db.prepare(`
DELETE FROM email_send_logs
WHERE status = 'reserved' AND created_at < datetime('now', '-10 minutes')
`).run();
const row = db.prepare(`
SELECT COUNT(*) AS count
FROM email_send_logs
WHERE user_id = ? AND month_key = ? AND status IN ('reserved', 'sent')
`).get(userId, monthKey);
if (row.count >= MONTHLY_EMAIL_LIMIT) return null;
const result = db.prepare(`
INSERT INTO email_send_logs (user_id, type, month_key, status)
VALUES (?, ?, ?, 'reserved')
`).run(userId, type, monthKey);
return Number(result.lastInsertRowid);
});
return transaction();
}
/**
* 예약한 이메일 발송을 성공 처리
* @param {import('better-sqlite3').Database} db
* @param {number} logId
*/
function completeEmailSend(db, logId) {
db.prepare(`
UPDATE email_send_logs
SET status = 'sent', sent_at = ?
WHERE id = ? AND status = 'reserved'
`).run(nowIso(), logId);
}
/**
* 실패한 이메일 발송 예약 취소
* @param {import('better-sqlite3').Database} db
* @param {number|null} logId
*/
function cancelEmailSend(db, logId) {
if (!logId) return;
db.prepare("DELETE FROM email_send_logs WHERE id = ? AND status = 'reserved'").run(logId);
}
/**
* 인증용 원문 토큰과 DB 저장용 해시 생성
* @returns {{token: string, tokenHash: string}}
*/
function createOpaqueToken() {
const token = crypto.randomBytes(32).toString('base64url');
return {
token,
tokenHash: crypto.createHash('sha256').update(token).digest('hex'),
};
}
/**
* 원문 토큰 해시
* @param {string} token
* @returns {string}
*/
function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
/**
* 사용자 인증 토큰 저장. 같은 목적의 기존 미사용 토큰은 폐기한다.
* @param {import('better-sqlite3').Database} db
* @param {number} userId
* @param {'verify_email'|'reset_password'} type
* @param {number} ttlMs
* @returns {string}
*/
function issueAuthToken(db, userId, type, ttlMs) {
const { token, tokenHash } = createOpaqueToken();
const expiresAt = new Date(Date.now() + ttlMs).toISOString();
const transaction = db.transaction(() => {
db.prepare('DELETE FROM auth_tokens WHERE user_id = ? AND type = ? AND used_at IS NULL')
.run(userId, type);
db.prepare(`
INSERT INTO auth_tokens (user_id, type, token_hash, expires_at)
VALUES (?, ?, ?, ?)
`).run(userId, type, tokenHash, expiresAt);
});
transaction();
return token;
}
/**
* 이메일 형식 검증
* @param {string} email
* @returns {boolean}
*/
function isValidEmail(email) {
return email.length <= 254 && EMAIL_PATTERN.test(email);
}
/**
* 사용자명 형식 검증
* @param {string} username
* @returns {boolean}
*/
function isValidUsername(username) {
return username.length >= 2 && username.length <= 30 && USERNAME_PATTERN.test(username);
}
/**
* DB 사용자 행을 API 응답 형식으로 변환
* @param {object} user
* @returns {object}
*/
function toUserResponse(user) {
if (!user) return null;
return {
...user,
displayName: user.username,
avatarUrl: getAvatarUrl(user.id) || user.avatarUrl || null,
defaultDeckPublic: (user.default_deck_public ?? user.defaultDeckPublic ?? 1) !== 0,
skipDeckPublicPrompt: Boolean(user.skip_deck_public_prompt ?? user.skipDeckPublicPrompt),
};
}
/**
* 인증 라우트
* @param {import('better-sqlite3').Database} db
* @returns {import('express').Router}
*/
export function createAuthRouter(db) {
const router = Router();
router.get('/avatar/:userId', (req, res) => {
const filePath = getAvatarFilePath(req.params.userId);
if (!filePath) {
return res.status(404).json({ error: '아바타가 없습니다.' });
}
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.sendFile(path.resolve(filePath), (err) => {
if (err && !res.headersSent) {
res.status(404).json({ error: '아바타가 없습니다.' });
}
});
});
router.post('/register', authWriteLimiter, async (req, res, next) => {
const { email, username, password } = req.body || {};
if (!email || !username || !password) {
return res.status(400).json({ error: '이메일, 사용자명, 비밀번호는 필수입니다.' });
}
const normalizedEmail = String(email).trim().toLowerCase();
const trimmedUsername = String(username).trim();
const rawPassword = String(password);
if (!isValidEmail(normalizedEmail)) {
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
}
if (!isValidUsername(trimmedUsername)) {
return res.status(400).json({
error: '사용자명은 2~30자의 한글·영문·숫자·밑줄·하이픈만 사용할 수 있습니다.',
});
}
if (rawPassword.length < 8) {
return res.status(400).json({ error: '비밀번호는 8자 이상이어야 합니다.' });
}
if (db.prepare('SELECT id FROM users WHERE email = ?').get(normalizedEmail)) {
return res.status(409).json({ error: '이미 사용 중인 이메일입니다.' });
}
if (db.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE').get(trimmedUsername)) {
return res.status(409).json({ error: '이미 사용 중인 사용자명입니다.' });
}
const hash = bcrypt.hashSync(rawPassword, 12);
let userId;
let emailLogId;
try {
const result = db.prepare(`
INSERT INTO users (email, username, password_hash, display_name, email_verified_at)
VALUES (?, ?, ?, ?, NULL)
`).run(normalizedEmail, trimmedUsername, hash, trimmedUsername);
userId = Number(result.lastInsertRowid);
emailLogId = reserveEmailSend(db, userId, 'verify_email');
if (!emailLogId) {
db.prepare('DELETE FROM users WHERE id = ? AND email_verified_at IS NULL').run(userId);
return res.status(429).json({
error: EMAIL_LIMIT_MESSAGE,
code: EMAIL_LIMIT_CODE,
});
}
const token = issueAuthToken(db, userId, 'verify_email', VERIFICATION_TOKEN_TTL_MS);
await sendVerificationEmail({
email: normalizedEmail,
username: trimmedUsername,
token,
});
completeEmailSend(db, emailLogId);
res.status(201).json({
ok: true,
email: normalizedEmail,
message: '인증 메일을 보냈습니다. 이메일 인증 후 로그인해 주세요.',
});
} catch (err) {
cancelEmailSend(db, emailLogId);
if (userId) {
db.prepare('DELETE FROM users WHERE id = ? AND email_verified_at IS NULL').run(userId);
}
if (String(err.message).includes('UNIQUE')) {
return res.status(409).json({ error: '이미 사용 중인 이메일 또는 사용자명입니다.' });
}
next(err);
}
});
router.post('/resend-verification', authWriteLimiter, async (req, res, next) => {
const normalizedEmail = String(req.body?.email || '').trim().toLowerCase();
if (!isValidEmail(normalizedEmail)) {
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
}
const user = db.prepare(`
SELECT id, email, username, email_verified_at
FROM users WHERE email = ? AND is_active = 1
`).get(normalizedEmail);
if (!user || user.email_verified_at) {
return res.json({
ok: true,
message: '인증이 필요한 계정이라면 이메일을 다시 보냈습니다.',
});
}
const recent = db.prepare(`
SELECT created_at
FROM auth_tokens
WHERE user_id = ? AND type = 'verify_email'
ORDER BY id DESC
LIMIT 1
`).get(user.id);
if (recent && Date.now() - new Date(`${recent.created_at}Z`).getTime() < EMAIL_RESEND_COOLDOWN_MS) {
return res.status(429).json({
error: '인증 메일은 1분 후 다시 요청할 수 있습니다.',
code: 'EMAIL_RESEND_COOLDOWN',
});
}
let emailLogId;
try {
emailLogId = reserveEmailSend(db, user.id, 'verify_email');
if (!emailLogId) {
return res.status(429).json({
error: EMAIL_LIMIT_MESSAGE,
code: EMAIL_LIMIT_CODE,
});
}
const token = issueAuthToken(db, user.id, 'verify_email', VERIFICATION_TOKEN_TTL_MS);
await sendVerificationEmail({ email: user.email, username: user.username, token });
completeEmailSend(db, emailLogId);
res.json({ ok: true, message: '인증 메일을 다시 보냈습니다.' });
} catch (err) {
cancelEmailSend(db, emailLogId);
next(err);
}
});
router.post('/verify-email', authWriteLimiter, (req, res) => {
const token = String(req.body?.token || '');
if (!token) {
return res.status(400).json({
error: '인증 토큰이 없습니다.',
code: 'VERIFICATION_TOKEN_MISSING',
});
}
const tokenHash = hashToken(token);
const row = db.prepare(`
SELECT t.id, t.user_id AS userId, t.expires_at AS expiresAt, t.used_at AS usedAt,
u.email_verified_at AS emailVerifiedAt
FROM auth_tokens t
JOIN users u ON u.id = t.user_id
WHERE t.token_hash = ? AND t.type = 'verify_email' AND u.is_active = 1
`).get(tokenHash);
if (!row || row.usedAt || new Date(row.expiresAt).getTime() <= Date.now()) {
return res.status(400).json({
error: '인증 링크가 유효하지 않거나 만료되었습니다.',
code: 'VERIFICATION_TOKEN_INVALID',
});
}
const transaction = db.transaction(() => {
db.prepare(`
UPDATE users
SET email_verified_at = ?, updated_at = ?
WHERE id = ?
`).run(nowIso(), nowIso(), row.userId);
db.prepare('UPDATE auth_tokens SET used_at = ? WHERE id = ?').run(nowIso(), row.id);
});
transaction();
res.json({ ok: true, message: '이메일 인증이 완료되었습니다. 로그인해 주세요.' });
});
router.post('/login', loginLimiter, (req, res) => {
const { email, password } = req.body || {};
if (!email || !password) {
return res.status(400).json({ error: '이메일과 비밀번호를 입력하세요.' });
}
const normalizedEmail = String(email).trim().toLowerCase();
const rawPassword = String(password);
const user = db.prepare(`
SELECT id, email, username, password_hash, display_name, avatar_url, role, is_active,
email_verified_at
FROM users WHERE email = ?
`).get(normalizedEmail);
if (!user || !user.is_active) {
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' });
}
if (!bcrypt.compareSync(rawPassword, user.password_hash)) {
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' });
}
if (!user.email_verified_at) {
return res.status(403).json({
error: '이메일 인증이 필요합니다. 가입한 이메일의 인증 링크를 확인해 주세요.',
code: 'EMAIL_NOT_VERIFIED',
});
}
db.prepare('UPDATE users SET last_login_at = ? WHERE id = ?').run(nowIso(), user.id);
const row = db.prepare(`
SELECT id, email, username, avatar_url, role, updated_at AS updatedAt, last_login_at AS lastLoginAt
FROM users WHERE id = ?
`).get(user.id);
const safeUser = toUserResponse(row);
res.json({ token: signToken(safeUser), user: safeUser });
});
router.post('/forgot-password', authWriteLimiter, async (req, res, next) => {
const normalizedEmail = String(req.body?.email || '').trim().toLowerCase();
if (!isValidEmail(normalizedEmail)) {
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
}
const genericResponse = {
ok: true,
message: '가입된 이메일이라면 비밀번호 재설정 링크를 보냈습니다.',
};
const user = db.prepare(`
SELECT id, email, username
FROM users
WHERE email = ? AND is_active = 1 AND email_verified_at IS NOT NULL
`).get(normalizedEmail);
if (!user) {
return res.json(genericResponse);
}
let emailLogId;
try {
const recent = db.prepare(`
SELECT created_at
FROM auth_tokens
WHERE user_id = ? AND type = 'reset_password'
ORDER BY id DESC
LIMIT 1
`).get(user.id);
if (recent && Date.now() - new Date(`${recent.created_at}Z`).getTime() < EMAIL_RESEND_COOLDOWN_MS) {
return res.json(genericResponse);
}
emailLogId = reserveEmailSend(db, user.id, 'reset_password');
if (!emailLogId) {
return res.status(429).json({
error: EMAIL_LIMIT_MESSAGE,
code: EMAIL_LIMIT_CODE,
});
}
const token = issueAuthToken(db, user.id, 'reset_password', PASSWORD_RESET_TOKEN_TTL_MS);
await sendPasswordResetEmail({ email: user.email, username: user.username, token });
completeEmailSend(db, emailLogId);
res.json(genericResponse);
} catch (err) {
cancelEmailSend(db, emailLogId);
next(err);
}
});
router.post('/reset-password', authWriteLimiter, (req, res) => {
const token = String(req.body?.token || '');
const newPassword = String(req.body?.newPassword || '');
if (!token) {
return res.status(400).json({ error: '재설정 토큰이 없습니다.' });
}
if (newPassword.length < 8) {
return res.status(400).json({ error: '새 비밀번호는 8자 이상이어야 합니다.' });
}
const row = db.prepare(`
SELECT t.id, t.user_id AS userId, t.expires_at AS expiresAt, t.used_at AS usedAt
FROM auth_tokens t
JOIN users u ON u.id = t.user_id
WHERE t.token_hash = ? AND t.type = 'reset_password' AND u.is_active = 1
`).get(hashToken(token));
if (!row || row.usedAt || new Date(row.expiresAt).getTime() <= Date.now()) {
return res.status(400).json({ error: '재설정 링크가 유효하지 않거나 만료되었습니다.' });
}
const passwordHash = bcrypt.hashSync(newPassword, 12);
const transaction = db.transaction(() => {
db.prepare(`
UPDATE users
SET password_hash = ?, updated_at = ?
WHERE id = ? AND is_active = 1
`).run(passwordHash, nowIso(), row.userId);
db.prepare(`
UPDATE auth_tokens
SET used_at = ?
WHERE user_id = ? AND type = 'reset_password' AND used_at IS NULL
`).run(nowIso(), row.userId);
});
transaction();
res.json({ ok: true, message: '비밀번호가 변경되었습니다. 새 비밀번호로 로그인해 주세요.' });
});
router.get('/me', requireAuth, (req, res) => {
db.prepare('UPDATE users SET last_login_at = ? WHERE id = ? AND is_active = 1').run(
nowIso(),
req.user.id,
);
const user = db.prepare(`
SELECT id, email, username, avatar_url AS avatarUrl, role,
default_deck_public AS defaultDeckPublic,
skip_deck_public_prompt AS skipDeckPublicPrompt,
last_login_at AS lastLoginAt,
created_at AS createdAt, updated_at AS updatedAt
FROM users WHERE id = ? AND is_active = 1
`).get(req.user.id);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
res.json({ user: toUserResponse(user) });
});
router.put('/me', requireAuth, (req, res) => {
const { email, username, avatarImage, removeAvatar, defaultDeckPublic, skipDeckPublicPrompt } =
req.body || {};
const fields = ['updated_at = ?'];
const values = [nowIso()];
const trimmedEmail = email?.trim().toLowerCase();
const trimmedUsername = username?.trim();
if (trimmedEmail) {
const current = db.prepare('SELECT email FROM users WHERE id = ?').get(req.user.id);
if (!isValidEmail(trimmedEmail)) {
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
}
if (trimmedEmail !== current?.email) {
return res.status(400).json({
error: '인증된 이메일 주소는 현재 직접 변경할 수 없습니다.',
});
}
}
if (trimmedUsername) {
if (!isValidUsername(trimmedUsername)) {
return res.status(400).json({
error: '사용자명은 2~30자의 한글·영문·숫자·밑줄·하이픈만 사용할 수 있습니다.',
});
}
const duplicate = db.prepare(`
SELECT id FROM users
WHERE username = ? COLLATE NOCASE AND id != ?
`).get(trimmedUsername, req.user.id);
if (duplicate) {
return res.status(409).json({ error: '이미 사용 중인 사용자명입니다.' });
}
fields.push('username = ?', 'display_name = ?');
values.push(trimmedUsername, trimmedUsername);
}
if (avatarImage) {
const saved = saveAvatarImage(req.user.id, avatarImage);
if (!saved) {
return res.status(400).json({ error: '프로필 이미지 저장에 실패했습니다.' });
}
const avatarUrl = getAvatarUrl(req.user.id);
fields.push('avatar_url = ?');
values.push(avatarUrl);
} else if (removeAvatar === true) {
deleteAvatarImage(req.user.id);
fields.push('avatar_url = ?');
values.push(null);
}
if (typeof defaultDeckPublic === 'boolean') {
fields.push('default_deck_public = ?');
values.push(defaultDeckPublic ? 1 : 0);
}
if (typeof skipDeckPublicPrompt === 'boolean') {
fields.push('skip_deck_public_prompt = ?');
values.push(skipDeckPublicPrompt ? 1 : 0);
}
try {
values.push(req.user.id);
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
} catch (err) {
if (String(err.message).includes('UNIQUE')) {
return res.status(409).json({ error: '이미 사용 중인 이메일 또는 사용자명입니다.' });
}
throw err;
}
const user = db.prepare(`
SELECT id, email, username, avatar_url AS avatarUrl, role,
default_deck_public AS defaultDeckPublic,
skip_deck_public_prompt AS skipDeckPublicPrompt,
created_at AS createdAt, updated_at AS updatedAt
FROM users WHERE id = ?
`).get(req.user.id);
res.json({ user: toUserResponse(user) });
});
router.post('/me/password', requireAuth, (req, res) => {
const { currentPassword, newPassword } = req.body || {};
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: '현재 비밀번호와 새 비밀번호를 입력하세요.' });
}
if (newPassword.length < 8) {
return res.status(400).json({ error: '새 비밀번호는 8자 이상이어야 합니다.' });
}
const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
if (!bcrypt.compareSync(currentPassword, row.password_hash)) {
return res.status(401).json({ error: '현재 비밀번호가 올바르지 않습니다.' });
}
const hash = bcrypt.hashSync(newPassword, 12);
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?').run(
hash,
nowIso(),
req.user.id,
);
res.json({ ok: true, message: '비밀번호가 변경되었습니다.' });
});
router.delete('/me', requireAuth, (req, res) => {
const confirmation = String(req.body?.confirmation || '');
if (confirmation !== 'DELETE') {
return res.status(400).json({
error: '탈퇴 확인 입력란에 DELETE를 정확히 입력해 주세요.',
code: 'ACCOUNT_DELETE_CONFIRMATION_REQUIRED',
});
}
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(req.user.id);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
if (user.role === 'admin') {
return res.status(400).json({
error: '관리자 계정은 다른 관리자가 회원 관리 화면에서 탈퇴 처리해야 합니다.',
});
}
const result = deleteUserAccount(db, user.id);
res.json({
ok: true,
...result,
message: '회원 탈퇴가 완료되었습니다.',
});
});
return router;
}

400
api/src/routes/decks.js Normal file
View File

@@ -0,0 +1,400 @@
import { Router } from 'express';
import path from 'path';
import { requireAuth, optionalAuth } from '../middleware/auth.js';
import { resolveDeckCards } from '../services/cardResolver.js';
import { generateShareCode, validateCards, nowIso } from '../utils.js';
import {
savePreviewImage,
saveThumbnailImage,
getPreviewFilePath,
getThumbnailFilePath,
getPreviewUrl,
getThumbnailUrl,
deletePreviewImage,
} from '../services/previewImage.js';
/**
* 덱 행을 API 응답 형식으로 변환
* @param {object} row
* @param {object|null} author
* @param {object[]} resolvedCards
* @param {number} totalCards
* @returns {object}
*/
function toDeckResponse(row, author, resolvedCards, totalCards) {
return {
v: 1,
shareCode: row.share_code,
shareType: row.share_type,
series: row.series,
seriesName: row.series_name,
name: row.name,
totalCards,
cards: resolvedCards,
author,
createdAt: row.created_at,
updatedAt: row.updated_at,
viewCount: row.view_count,
isPublic: Boolean(row.is_public),
expiresAt: row.expires_at,
previewUrl: getPreviewUrl(row.share_code, row.preview_image),
thumbnailUrl: getThumbnailUrl(row.share_code),
};
}
/**
* 작성자 정보 조회
* @param {import('better-sqlite3').Database} db
* @param {number|null} userId
* @returns {object|null}
*/
function getAuthor(db, userId) {
if (!userId) return null;
const user = db.prepare(`
SELECT id, username, avatar_url AS avatarUrl
FROM users WHERE id = ? AND is_active = 1
`).get(userId);
if (!user) return null;
return { ...user, displayName: user.username };
}
/**
* 덱 라우트
* @param {import('better-sqlite3').Database} db
* @returns {import('express').Router}
*/
export function createDecksRouter(db) {
const router = Router();
/**
* 현재 사용자가 접근할 수 없는 즐겨찾기 정리
* @param {number} userId
*/
function cleanupUnavailableFavorites(userId) {
db.prepare(`
DELETE FROM deck_favorites
WHERE user_id = ?
AND share_code IN (
SELECT f.share_code
FROM deck_favorites f
JOIN decks d ON d.share_code = f.share_code
WHERE f.user_id = ?
AND (
(d.expires_at IS NOT NULL AND d.expires_at <= datetime('now'))
OR (d.is_public = 0 AND (d.user_id IS NULL OR d.user_id != ?))
)
)
`).run(userId, userId, userId);
}
router.get('/favorites', requireAuth, (req, res) => {
cleanupUnavailableFavorites(req.user.id);
const favorites = db.prepare(`
SELECT share_code
FROM deck_favorites
WHERE user_id = ?
ORDER BY created_at DESC
`).all(req.user.id);
res.json({ favorites: favorites.map((row) => row.share_code) });
});
router.post('/favorites', requireAuth, (req, res) => {
const shareCodes = Array.isArray(req.body?.shareCodes)
? [...new Set(req.body.shareCodes.filter((code) => typeof code === 'string' && code.length <= 64))].slice(0, 500)
: [];
const insert = db.prepare(`
INSERT OR IGNORE INTO deck_favorites (user_id, share_code)
SELECT ?, share_code
FROM decks
WHERE share_code = ?
AND (expires_at IS NULL OR expires_at > datetime('now'))
AND (is_public = 1 OR user_id = ?)
`);
const sync = db.transaction((codes) => {
codes.forEach((code) => insert.run(req.user.id, code, req.user.id));
});
sync(shareCodes);
cleanupUnavailableFavorites(req.user.id);
const favorites = db.prepare(`
SELECT share_code FROM deck_favorites WHERE user_id = ? ORDER BY created_at DESC
`).all(req.user.id);
res.json({ favorites: favorites.map((row) => row.share_code) });
});
router.put('/favorites/:shareCode', requireAuth, (req, res) => {
const deck = db.prepare(`
SELECT share_code
FROM decks
WHERE share_code = ?
AND (expires_at IS NULL OR expires_at > datetime('now'))
AND (is_public = 1 OR user_id = ?)
`).get(req.params.shareCode, req.user.id);
if (!deck) {
return res.status(404).json({ error: '즐겨찾기에 추가할 수 있는 덱을 찾을 수 없습니다.' });
}
db.prepare(`
INSERT OR IGNORE INTO deck_favorites (user_id, share_code) VALUES (?, ?)
`).run(req.user.id, deck.share_code);
res.json({ ok: true, favorite: true });
});
router.delete('/favorites/:shareCode', requireAuth, (req, res) => {
db.prepare('DELETE FROM deck_favorites WHERE user_id = ? AND share_code = ?')
.run(req.user.id, req.params.shareCode);
res.json({ ok: true, favorite: false });
});
router.get('/presets', async (_req, res, next) => {
try {
const rows = db.prepare(`
SELECT d.share_code, d.series, d.series_name, d.name, d.view_count,
d.preview_image, d.created_at, d.updated_at,
u.display_name, u.username
FROM decks d
LEFT JOIN users u ON d.user_id = u.id
WHERE d.share_type = 'user' AND d.is_public = 1
AND (d.expires_at IS NULL OR d.expires_at > datetime('now'))
ORDER BY d.updated_at DESC
`).all();
res.json({
presets: rows.map((row) => ({
share_code: row.share_code,
series: row.series,
series_name: row.series_name,
name: row.name,
view_count: row.view_count,
author: row.username || '익명',
created_at: row.created_at,
updated_at: row.updated_at,
preview_url: getPreviewUrl(row.share_code, row.preview_image),
thumbnail_url: getThumbnailUrl(row.share_code),
})),
});
} catch (err) {
next(err);
}
});
router.get('/:shareCode/preview', (req, res) => {
const filePath = getPreviewFilePath(req.params.shareCode);
if (!filePath) {
return res.status(404).json({ error: '미리보기 이미지가 없습니다.' });
}
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.sendFile(path.resolve(filePath), (err) => {
if (err && !res.headersSent) {
res.status(404).json({ error: '미리보기 이미지가 없습니다.' });
}
});
});
router.get('/:shareCode/thumbnail', (req, res) => {
const filePath = getThumbnailFilePath(req.params.shareCode);
if (!filePath) {
return res.status(404).json({ error: '썸네일 이미지가 없습니다.' });
}
res.setHeader('Content-Type', 'image/webp');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.sendFile(path.resolve(filePath), (err) => {
if (err && !res.headersSent) {
res.status(404).json({ error: '썸네일 이미지가 없습니다.' });
}
});
});
router.get('/:shareCode', optionalAuth, async (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(req.params.shareCode);
if (!row) {
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
}
if (row.expires_at && new Date(row.expires_at) < new Date()) {
return res.status(410).json({ error: '만료된 공유 링크입니다.' });
}
const isOwner = req.user && row.user_id === req.user.id;
const isAdmin = req.user?.role === 'admin';
if (!row.is_public && !isOwner && !isAdmin) {
return res.status(403).json({ error: '비공개 덱입니다.' });
}
const cards = JSON.parse(row.cards_json);
const { cards: resolved, totalCards } = await resolveDeckCards(row.series, cards);
const author = getAuthor(db, row.user_id);
db.prepare('UPDATE decks SET view_count = view_count + 1 WHERE id = ?').run(row.id);
row.view_count += 1;
res.json(toDeckResponse(row, author, resolved, totalCards));
} catch (err) {
next(err);
}
});
router.get('/', requireAuth, async (req, res, next) => {
try {
const rows = db.prepare(`
SELECT share_code, share_type, series, series_name, name, is_public, view_count,
preview_image, created_at, updated_at
FROM decks WHERE user_id = ? AND share_type = 'user'
ORDER BY updated_at DESC
`).all(req.user.id);
res.json({
decks: rows.map((row) => ({
share_code: row.share_code,
share_type: row.share_type,
series: row.series,
series_name: row.series_name,
name: row.name,
is_public: row.is_public,
view_count: row.view_count,
created_at: row.created_at,
updated_at: row.updated_at,
preview_url: getPreviewUrl(row.share_code, row.preview_image),
thumbnail_url: getThumbnailUrl(row.share_code),
})),
});
} catch (err) {
next(err);
}
});
router.post('/', optionalAuth, async (req, res, next) => {
try {
const { series, seriesName, name, cards, isPublic, previewImage, thumbnailImage } = req.body || {};
if (!series || !cards) {
return res.status(400).json({ error: 'series와 cards는 필수입니다.' });
}
const normalized = validateCards(cards);
const cardsJson = JSON.stringify(normalized);
const shareCode = generateShareCode();
const userId = req.user?.id ?? null;
const expiresAt = userId ? null : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
const isPublicValue = userId ? (isPublic === true ? 1 : 0) : 1;
let previewFile = null;
if (previewImage) {
previewFile = savePreviewImage(shareCode, previewImage);
if (!previewFile) {
console.warn(`[preview] 저장 실패: ${shareCode} (dataUrl 길이 ${previewImage.length})`);
}
}
if (thumbnailImage) {
saveThumbnailImage(shareCode, thumbnailImage);
}
db.prepare(`
INSERT INTO decks (share_code, share_type, user_id, series, series_name, name, cards_json, is_public, expires_at, preview_image)
VALUES (?, 'user', ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
shareCode,
userId,
series,
seriesName || series,
name || '',
cardsJson,
isPublicValue,
expiresAt,
previewFile,
);
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(shareCode);
const { cards: resolved, totalCards } = await resolveDeckCards(series, normalized);
const author = getAuthor(db, userId);
res.status(201).json(toDeckResponse(row, author, resolved, totalCards));
} catch (err) {
if (err.message?.includes('카드') || err.message?.includes('덱')) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
router.put('/:shareCode', requireAuth, async (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(req.params.shareCode);
if (!row) {
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
}
if (row.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: '수정 권한이 없습니다.' });
}
if (row.share_type === 'preset' && req.user.role !== 'admin') {
return res.status(403).json({ error: '레거시 프리셋 덱은 관리자만 수정할 수 있습니다.' });
}
const { name, cards, isPublic, seriesName, previewImage, thumbnailImage } = req.body || {};
let cardsJson = row.cards_json;
let series = row.series;
let previewFile = row.preview_image;
if (cards) {
const normalized = validateCards(cards);
cardsJson = JSON.stringify(normalized);
}
if (req.body?.series) {
series = req.body.series;
}
if (previewImage) {
previewFile = savePreviewImage(row.share_code, previewImage);
}
if (thumbnailImage) {
saveThumbnailImage(row.share_code, thumbnailImage);
}
db.prepare(`
UPDATE decks SET
name = COALESCE(?, name),
series_name = COALESCE(?, series_name),
series = ?,
cards_json = ?,
is_public = COALESCE(?, is_public),
preview_image = COALESCE(?, preview_image),
updated_at = ?
WHERE id = ?
`).run(
name ?? null,
seriesName ?? null,
series,
cardsJson,
isPublic === undefined ? null : (isPublic ? 1 : 0),
previewFile,
nowIso(),
row.id,
);
const updated = db.prepare('SELECT * FROM decks WHERE id = ?').get(row.id);
const parsed = JSON.parse(updated.cards_json);
const { cards: resolved, totalCards } = await resolveDeckCards(updated.series, parsed);
res.json(toDeckResponse(updated, getAuthor(db, updated.user_id), resolved, totalCards));
} catch (err) {
if (err.message?.includes('카드') || err.message?.includes('덱')) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
router.delete('/:shareCode', requireAuth, (req, res) => {
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(req.params.shareCode);
if (!row) {
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
}
if (row.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: '삭제 권한이 없습니다.' });
}
db.prepare('DELETE FROM decks WHERE id = ?').run(row.id);
const previewDeleted = deletePreviewImage(row.share_code);
res.json({ ok: true, previewDeleted });
});
return router;
}

70
api/src/routes/stats.js Normal file
View File

@@ -0,0 +1,70 @@
import crypto from 'crypto';
import { Router } from 'express';
import { getCardStats, getDatabaseStats } from '../services/statsService.js';
/**
* 토큰 값을 안전하게 비교한다.
* @param {string} received
* @param {string} expected
* @returns {boolean}
*/
function isSameToken(received, expected) {
const receivedBuffer = Buffer.from(received);
const expectedBuffer = Buffer.from(expected);
return receivedBuffer.length === expectedBuffer.length
&& crypto.timingSafeEqual(receivedBuffer, expectedBuffer);
}
/**
* 통계 API 토큰이 설정된 경우 요청을 보호한다.
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
* @returns {void}
*/
function requireStatsToken(req, res, next) {
const expectedToken = process.env.STATS_API_TOKEN;
if (!expectedToken) {
return next();
}
const bearer = req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice(7)
: '';
const receivedToken = req.headers['x-stats-token'] || bearer;
if (typeof receivedToken === 'string' && isSameToken(receivedToken, expectedToken)) {
return next();
}
res.status(401).json({ error: '통계 API 토큰이 필요합니다.' });
}
/**
* 외부 대시보드용 통계 라우트
* @param {import('better-sqlite3').Database} db
* @param {string} appVersion
* @returns {import('express').Router}
*/
export function createStatsRouter(db, appVersion) {
const router = Router();
router.get('/', requireStatsToken, async (_req, res, next) => {
try {
const databaseStats = getDatabaseStats(db);
const cardStats = await getCardStats();
res.json({
status: 'online',
ok: true,
version: appVersion,
lastUpdated: new Date().toISOString(),
...databaseStats,
cards: cardStats,
});
} catch (err) {
next(err);
}
});
return router;
}

View File

@@ -0,0 +1,33 @@
import { deleteAvatarImage } from './avatarImage.js';
/**
* 사용자 계정과 개인 데이터를 영구 삭제하고 저장 덱은 익명으로 보존한다.
* @param {import('better-sqlite3').Database} db
* @param {number} userId
* @returns {{deleted: boolean, retainedDeckCount: number, avatarDeleted: boolean}}
*/
export function deleteUserAccount(db, userId) {
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(userId);
if (!user) {
return {
deleted: false,
retainedDeckCount: 0,
avatarDeleted: false,
};
}
const retainedDeckCount = db.prepare(`
SELECT COUNT(*) AS count
FROM decks
WHERE user_id = ?
`).get(userId).count;
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
const avatarDeleted = deleteAvatarImage(userId);
return {
deleted: true,
retainedDeckCount,
avatarDeleted,
};
}

View File

@@ -0,0 +1,71 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* 아바타 이미지 저장 디렉터리
* @returns {string}
*/
export function getAvatarsDir() {
const dir = process.env.AVATARS_PATH || path.join(__dirname, '..', '..', 'data', 'avatars');
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
return dir;
}
/**
* @param {number|string} userId
* @returns {string}
*/
export function getAvatarFilePath(userId) {
const filePath = path.join(getAvatarsDir(), `${userId}.png`);
return fs.existsSync(filePath) ? filePath : null;
}
/**
* @param {number|string} userId
* @returns {string|null}
*/
export function getAvatarUrl(userId) {
return getAvatarFilePath(userId) ? `/api/v1/auth/avatar/${userId}` : null;
}
/**
* base64 data URL을 PNG로 저장
* @param {number|string} userId
* @param {string} dataUrl
* @returns {string|null}
*/
export function saveAvatarImage(userId, dataUrl) {
if (!dataUrl || !dataUrl.startsWith('data:image')) {
return null;
}
const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, '');
if (!base64) {
return null;
}
const fileName = `${userId}.png`;
const filePath = path.join(getAvatarsDir(), fileName);
fs.writeFileSync(filePath, Buffer.from(base64, 'base64'));
return fs.existsSync(filePath) ? fileName : null;
}
/**
* 아바타 이미지 삭제
* @param {number|string} userId
* @returns {boolean}
*/
export function deleteAvatarImage(userId) {
const filePath = path.join(getAvatarsDir(), `${userId}.png`);
if (!fs.existsSync(filePath)) return false;
try {
fs.unlinkSync(filePath);
return true;
} catch (err) {
console.warn(`[avatar] 삭제 실패: ${filePath}`, err);
return false;
}
}

View File

@@ -0,0 +1,78 @@
import { pathToFileURL } from 'url';
import path from 'path';
/**
* @typedef {object} CardIndex
* @property {Map<string, object>} byNumber
* @property {Map<string, object>} byImgSrc
*/
/** @type {Map<string, CardIndex>} */
const cache = new Map();
/**
* 시리즈 카드 인덱스 로드
* @param {string} seriesKey
* @returns {Promise<CardIndex>}
*/
export async function getCardMap(seriesKey) {
if (cache.has(seriesKey)) {
return cache.get(seriesKey);
}
const datasPath = process.env.DATAS_PATH || path.join(process.cwd(), '..', 'datas');
const filePath = path.join(datasPath, `${seriesKey}.js`);
let mod;
try {
mod = await import(pathToFileURL(filePath).href);
} catch {
throw new Error(`시리즈 데이터를 찾을 수 없습니다: ${seriesKey}`);
}
const byNumber = new Map();
const byImgSrc = new Map();
for (const card of mod.cardList) {
if (!byNumber.has(card.Number)) {
byNumber.set(card.Number, card);
}
if (card.imgSrc && !byImgSrc.has(card.imgSrc)) {
byImgSrc.set(card.imgSrc, card);
}
}
const index = { byNumber, byImgSrc };
cache.set(seriesKey, index);
return index;
}
/**
* 저장된 덱 카드를 풀 카드 객체로 합성
* @param {string} seriesKey
* @param {Array<{number?: string, imgSrc?: string, count: number, key: boolean}>} cards
* @returns {Promise<{cards: object[], totalCards: number}>}
*/
export async function resolveDeckCards(seriesKey, cards) {
const index = await getCardMap(seriesKey);
const resolved = [];
let totalCards = 0;
for (const item of cards) {
const base = (item.imgSrc && index.byImgSrc.get(item.imgSrc)) || index.byNumber.get(item.number);
if (!base) continue;
totalCards += item.count;
resolved.push({
number: base.Number,
count: item.count,
key: item.key,
imgSrc: base.imgSrc,
color: base.color,
rarity: base.rarity,
trigger: base.trigger ?? null,
parallel: Boolean(base.parallel),
});
}
return { cards: resolved, totalCards };
}

View File

@@ -0,0 +1,111 @@
const RESEND_API_URL = 'https://api.resend.com/emails';
/**
* HTML 특수문자 이스케이프
* @param {string} value
* @returns {string}
*/
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
/**
* 서비스 공개 URL
* @returns {string}
*/
function getAppBaseUrl() {
const value = process.env.APP_BASE_URL?.trim().replace(/\/+$/, '');
if (!value) {
throw new Error('APP_BASE_URL 환경변수가 설정되지 않았습니다.');
}
return value;
}
/**
* Resend 메일 전송
* @param {{to: string, subject: string, html: string, text: string, idempotencyKey: string}} message
* @returns {Promise<object>}
*/
async function sendEmail(message) {
const apiKey = process.env.RESEND_API_KEY?.trim();
const from = process.env.RESEND_FROM_EMAIL?.trim();
if (!apiKey || !from) {
throw new Error('RESEND_API_KEY 또는 RESEND_FROM_EMAIL 환경변수가 설정되지 않았습니다.');
}
const response = await fetch(RESEND_API_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Idempotency-Key': message.idempotencyKey,
},
body: JSON.stringify({
from,
to: [message.to],
subject: message.subject,
html: message.html,
text: message.text,
}),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`Resend 메일 전송 실패 (${response.status}): ${data.message || '알 수 없는 오류'}`);
}
return data;
}
/**
* 이메일 인증 메일 전송
* @param {{email: string, username: string, token: string}} params
* @returns {Promise<object>}
*/
export function sendVerificationEmail({ email, username, token }) {
const url = `${getAppBaseUrl()}/verify-email.html?v=4.0.16&token=${encodeURIComponent(token)}`;
const safeName = escapeHtml(username);
const safeUrl = escapeHtml(url);
return sendEmail({
to: email,
subject: '[UNIARE] 이메일 주소를 인증해 주세요',
idempotencyKey: `verify-${token.slice(0, 32)}`,
html: `
<div style="font-family:Arial,sans-serif;line-height:1.6;color:#0f172a">
<h1 style="font-size:22px">UNIARE 이메일 인증</h1>
<p>${safeName}님, 아래 버튼을 눌러 회원가입을 완료해 주세요.</p>
<p><a href="${safeUrl}" style="display:inline-block;padding:12px 18px;border-radius:10px;background:#2563eb;color:#fff;text-decoration:none;font-weight:700">이메일 인증하기</a></p>
<p style="font-size:13px;color:#64748b">이 링크는 24시간 동안 한 번만 사용할 수 있습니다.</p>
</div>
`,
text: `${username}님, 다음 링크에서 UNIARE 이메일 인증을 완료해 주세요.\n${url}\n\n이 링크는 24시간 동안 한 번만 사용할 수 있습니다.`,
});
}
/**
* 비밀번호 재설정 메일 전송
* @param {{email: string, username: string, token: string}} params
* @returns {Promise<object>}
*/
export function sendPasswordResetEmail({ email, username, token }) {
const url = `${getAppBaseUrl()}/reset-password.html?v=4.0.16&token=${encodeURIComponent(token)}`;
const safeName = escapeHtml(username);
const safeUrl = escapeHtml(url);
return sendEmail({
to: email,
subject: '[UNIARE] 비밀번호 재설정 안내',
idempotencyKey: `reset-${token.slice(0, 32)}`,
html: `
<div style="font-family:Arial,sans-serif;line-height:1.6;color:#0f172a">
<h1 style="font-size:22px">UNIARE 비밀번호 재설정</h1>
<p>${safeName}님, 요청하신 비밀번호 재설정을 진행해 주세요.</p>
<p><a href="${safeUrl}" style="display:inline-block;padding:12px 18px;border-radius:10px;background:#2563eb;color:#fff;text-decoration:none;font-weight:700">비밀번호 재설정하기</a></p>
<p style="font-size:13px;color:#64748b">이 링크는 30분 동안 한 번만 사용할 수 있습니다. 요청하지 않았다면 이 메일을 무시하세요.</p>
</div>
`,
text: `${username}님, 다음 링크에서 UNIARE 비밀번호를 재설정해 주세요.\n${url}\n\n이 링크는 30분 동안 한 번만 사용할 수 있습니다.`,
});
}

View File

@@ -0,0 +1,166 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** 목록용 축소 썸네일 파일 접미사 */
const THUMB_SUFFIX = '.thumb.webp';
/**
* 미리보기 파일명에서 공유 코드 추출 (원본/썸네일 공통)
* @param {string} fileName
* @returns {string|null}
*/
function shareCodeOfPreviewFile(fileName) {
const lower = fileName.toLowerCase();
if (lower.endsWith(THUMB_SUFFIX)) return fileName.slice(0, -THUMB_SUFFIX.length);
if (lower.endsWith('.png')) return fileName.slice(0, -'.png'.length);
return null;
}
/**
* 미리보기 이미지 저장 디렉터리
* @returns {string}
*/
export function getPreviewsDir() {
const dir = process.env.PREVIEWS_PATH || path.join(__dirname, '..', '..', 'data', 'previews');
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
return dir;
}
/**
* base64 data URL을 PNG 파일로 저장
* @param {string} shareCode
* @param {string} dataUrl
* @returns {string} 파일명
*/
export function savePreviewImage(shareCode, dataUrl) {
if (!dataUrl || !dataUrl.startsWith('data:image')) {
return null;
}
const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, '');
const fileName = `${shareCode}.png`;
fs.writeFileSync(path.join(getPreviewsDir(), fileName), Buffer.from(base64, 'base64'));
return fileName;
}
/**
* 목록용 WebP 썸네일 저장 (data URL 형식만 허용)
* @param {string} shareCode
* @param {string} dataUrl
* @returns {string|null} 파일명
*/
export function saveThumbnailImage(shareCode, dataUrl) {
if (!dataUrl || !dataUrl.startsWith('data:image/webp')) {
return null;
}
const base64 = dataUrl.replace(/^data:image\/webp;base64,/, '');
const fileName = `${shareCode}${THUMB_SUFFIX}`;
fs.writeFileSync(path.join(getPreviewsDir(), fileName), Buffer.from(base64, 'base64'));
return fileName;
}
/**
* @param {string} shareCode
* @returns {string|null}
*/
export function getPreviewFilePath(shareCode) {
const filePath = path.join(getPreviewsDir(), `${shareCode}.png`);
return fs.existsSync(filePath) ? filePath : null;
}
/**
* @param {string} shareCode
* @returns {string|null}
*/
export function getThumbnailFilePath(shareCode) {
const filePath = path.join(getPreviewsDir(), `${shareCode}${THUMB_SUFFIX}`);
return fs.existsSync(filePath) ? filePath : null;
}
/**
* @param {string} shareCode
* @param {string|null} [previewImageRecord] DB preview_image 컬럼
* @returns {string|null}
*/
export function getPreviewUrl(shareCode, previewImageRecord = null) {
const hasFile = Boolean(getPreviewFilePath(shareCode));
if (hasFile || previewImageRecord) {
return `/api/v1/decks/${shareCode}/preview`;
}
return null;
}
/**
* 목록용 썸네일 URL (파일이 있을 때만 반환)
* @param {string} shareCode
* @returns {string|null}
*/
export function getThumbnailUrl(shareCode) {
return getThumbnailFilePath(shareCode) ? `/api/v1/decks/${shareCode}/thumbnail` : null;
}
/**
* 공유 코드에 연결된 미리보기 파일(원본 PNG + 썸네일 WebP) 삭제
* @param {string} shareCode
* @returns {boolean}
*/
export function deletePreviewImage(shareCode) {
let deleted = false;
for (const fileName of [`${shareCode}.png`, `${shareCode}${THUMB_SUFFIX}`]) {
const filePath = path.join(getPreviewsDir(), fileName);
if (!fs.existsSync(filePath)) continue;
try {
fs.unlinkSync(filePath);
deleted = true;
} catch (err) {
console.warn(`[preview] 삭제 실패: ${filePath}`, err);
}
}
return deleted;
}
/**
* DB에서 참조하지 않는 미리보기 파일 목록 (원본 PNG + 썸네일 WebP)
* @param {Iterable<string>} referencedShareCodes
* @returns {Array<{fileName: string, size: number, modifiedAt: string}>}
*/
export function listOrphanPreviewImages(referencedShareCodes) {
const referenced = new Set([...referencedShareCodes]);
return fs
.readdirSync(getPreviewsDir(), { withFileTypes: true })
.filter((entry) => {
if (!entry.isFile()) return false;
const code = shareCodeOfPreviewFile(entry.name);
return Boolean(code) && !referenced.has(code);
})
.map((entry) => {
const stat = fs.statSync(path.join(getPreviewsDir(), entry.name));
return {
fileName: entry.name,
size: stat.size,
modifiedAt: stat.mtime.toISOString(),
};
})
.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
}
/**
* 미참조 미리보기 파일 일괄 삭제
* @param {Iterable<string>} referencedShareCodes
* @returns {{deletedCount: number, deletedBytes: number}}
*/
export function deleteOrphanPreviewImages(referencedShareCodes) {
const orphans = listOrphanPreviewImages(referencedShareCodes);
let deletedBytes = 0;
orphans.forEach((file) => {
fs.unlinkSync(path.join(getPreviewsDir(), file.fileName));
deletedBytes += file.size;
});
return { deletedCount: orphans.length, deletedBytes };
}

View File

@@ -0,0 +1,118 @@
import fs from 'fs/promises';
import path from 'path';
import { pathToFileURL } from 'url';
const SERIES_FILE_PATTERN = /^(ua\d+|uapr)\.js$/;
/**
* COUNT 쿼리 결과를 숫자로 변환한다.
* @param {{ count: number } | undefined} row
* @returns {number}
*/
function countValue(row) {
return Number(row?.count ?? 0);
}
/**
* 외부 대시보드용 DB 집계값을 만든다.
* @param {import('better-sqlite3').Database} db
* @returns {object}
*/
export function getDatabaseStats(db) {
const userStats = db.prepare(`
SELECT
COUNT(*) AS total,
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN is_active = 0 THEN 1 ELSE 0 END) AS inactive,
SUM(CASE WHEN email_verified_at IS NOT NULL THEN 1 ELSE 0 END) AS verified,
SUM(CASE WHEN email_verified_at IS NULL THEN 1 ELSE 0 END) AS unverified,
SUM(CASE WHEN created_at >= datetime('now', '-1 day') THEN 1 ELSE 0 END) AS created24h,
MAX(created_at) AS latestCreatedAt,
MAX(last_login_at) AS latestLoginAt
FROM users
`).get();
const deckStats = db.prepare(`
SELECT
COUNT(*) AS total,
SUM(CASE WHEN share_type = 'user' THEN 1 ELSE 0 END) AS userTotal,
SUM(CASE WHEN share_type = 'preset' THEN 1 ELSE 0 END) AS presetTotal,
SUM(CASE WHEN is_public = 1 THEN 1 ELSE 0 END) AS publicTotal,
SUM(CASE WHEN is_public = 0 THEN 1 ELSE 0 END) AS privateTotal,
SUM(CASE WHEN user_id IS NULL THEN 1 ELSE 0 END) AS anonymousTotal,
SUM(CASE WHEN created_at >= datetime('now', '-1 day') THEN 1 ELSE 0 END) AS created24h,
SUM(CASE WHEN updated_at >= datetime('now', '-1 day') THEN 1 ELSE 0 END) AS updated24h,
COALESCE(SUM(view_count), 0) AS totalViews,
MAX(created_at) AS latestCreatedAt,
MAX(updated_at) AS latestUpdatedAt
FROM decks
`).get();
return {
members: {
total: countValue({ count: userStats.total }),
active: countValue({ count: userStats.active }),
inactive: countValue({ count: userStats.inactive }),
verified: countValue({ count: userStats.verified }),
unverified: countValue({ count: userStats.unverified }),
created24h: countValue({ count: userStats.created24h }),
latestCreatedAt: userStats.latestCreatedAt,
latestLoginAt: userStats.latestLoginAt,
},
decks: {
total: countValue({ count: deckStats.total }),
userTotal: countValue({ count: deckStats.userTotal }),
presetTotal: countValue({ count: deckStats.presetTotal }),
publicTotal: countValue({ count: deckStats.publicTotal }),
privateTotal: countValue({ count: deckStats.privateTotal }),
anonymousTotal: countValue({ count: deckStats.anonymousTotal }),
created24h: countValue({ count: deckStats.created24h }),
updated24h: countValue({ count: deckStats.updated24h }),
totalViews: countValue({ count: deckStats.totalViews }),
latestCreatedAt: deckStats.latestCreatedAt,
latestUpdatedAt: deckStats.latestUpdatedAt,
},
};
}
/**
* 카드 데이터 파일 하나의 통계를 읽는다.
* @param {string} datasPath
* @param {string} fileName
* @returns {Promise<object>}
*/
async function getSeriesStats(datasPath, fileName) {
const series = fileName.replace(/\.js$/, '');
const filePath = path.join(datasPath, fileName);
const mod = await import(pathToFileURL(filePath).href);
const cards = Array.isArray(mod.cardList) ? mod.cardList : [];
const uniqueNumbers = new Set(cards.map((card) => card.Number).filter(Boolean));
const parallelPrints = cards.filter((card) => card.parallel === true).length;
return {
series,
cardTypes: uniqueNumbers.size,
cardPrints: cards.length,
parallelPrints,
};
}
/**
* datas/*.js 기반 카드 시리즈·카드 수 집계값을 만든다.
* @returns {Promise<object>}
*/
export async function getCardStats() {
const datasPath = process.env.DATAS_PATH || path.join(process.cwd(), '..', 'datas');
const files = (await fs.readdir(datasPath))
.filter((fileName) => SERIES_FILE_PATTERN.test(fileName))
.sort((a, b) => a.localeCompare(b, 'en'));
const bySeries = await Promise.all(files.map((fileName) => getSeriesStats(datasPath, fileName)));
return {
seriesTotal: bySeries.length,
cardTypesTotal: bySeries.reduce((sum, row) => sum + row.cardTypes, 0),
cardPrintsTotal: bySeries.reduce((sum, row) => sum + row.cardPrints, 0),
parallelPrintsTotal: bySeries.reduce((sum, row) => sum + row.parallelPrints, 0),
bySeries,
};
}

90
api/src/utils.js Normal file
View File

@@ -0,0 +1,90 @@
import crypto from 'crypto';
const SHARE_ALPHABET = '0123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
/**
* URL-safe 공유 코드 생성
* @param {number} [length=8]
* @returns {string}
*/
export function generateShareCode(length = 8) {
const bytes = crypto.randomBytes(length);
let code = '';
for (let i = 0; i < length; i++) {
code += SHARE_ALPHABET[bytes[i] % SHARE_ALPHABET.length];
}
return code;
}
/**
* 덱 본문 정규화 (해시용)
* @param {Array<{imgSrc: string, number?: string, count: number, key: boolean}>} cards
* @returns {string}
*/
export function normalizeCards(cards) {
const sorted = [...cards]
.map((c) => ({
imgSrc: String(c.imgSrc).trim(),
number: String(c.number).trim(),
count: Number(c.count),
key: Boolean(c.key),
}))
.sort((a, b) => a.imgSrc.localeCompare(b.imgSrc));
return JSON.stringify(sorted);
}
/**
* 프리셋 덱용 내용 해시 (8자)
* @param {Array<{imgSrc: string, number?: string, count: number, key: boolean}>} cards
* @returns {string}
*/
export function contentHash(cards) {
const hash = crypto.createHash('sha256').update(normalizeCards(cards)).digest('hex');
return hash.slice(0, 8);
}
/**
* 덱 카드 배열 검증
* @param {unknown} cards
* @returns {Array<{imgSrc: string, number: string, count: number, key: boolean}>}
*/
export function validateCards(cards) {
if (!Array.isArray(cards) || cards.length === 0) {
throw new Error('카드 목록이 비어 있습니다.');
}
let total = 0;
const normalized = cards.map((c) => {
if (!c.imgSrc || typeof c.imgSrc !== 'string' || !c.imgSrc.trim()) {
throw new Error('카드 형식이 올바르지 않습니다. (imgSrc 필요)');
}
if (!c.number || c.count == null) {
throw new Error('카드 형식이 올바르지 않습니다.');
}
const count = Number(c.count);
if (!Number.isInteger(count) || count < 1 || count > 4) {
throw new Error(`잘못된 수량: ${c.number}`);
}
total += count;
return {
imgSrc: String(c.imgSrc).trim(),
number: String(c.number).trim(),
count,
key: Boolean(c.key),
};
});
if (total > 50) {
throw new Error('덱은 최대 50장까지 가능합니다.');
}
return normalized;
}
/**
* ISO 현재 시각
* @returns {string}
*/
export function nowIso() {
return new Date().toISOString();
}

View File

@@ -0,0 +1,312 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import bcrypt from 'bcryptjs';
import express from 'express';
import { initDb } from '../src/db.js';
import { createAdminRouter } from '../src/routes/admin.js';
import { createAuthRouter } from '../src/routes/auth.js';
import { deleteUserAccount } from '../src/services/accountDeletion.js';
test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정보가 연결되지 않는다', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-account-delete-'));
const dbPath = path.join(tempDir, 'test.db');
process.env.AVATARS_PATH = path.join(tempDir, 'avatars');
const db = initDb(dbPath);
try {
const oldUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, datetime('now'))
`).run('old@example.com', 'test', 'old-password-hash', 'test').lastInsertRowid,
);
db.prepare(`
INSERT INTO decks (
share_code, share_type, user_id, series, series_name, name, cards_json, is_public
)
VALUES ('OLDDECK1', 'user', ?, 'ua01', '테스트', '기존 덱', '[]', 1)
`).run(oldUserId);
db.prepare(`
INSERT INTO deck_favorites (user_id, share_code)
VALUES (?, 'OLDDECK1')
`).run(oldUserId);
const referenceUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, datetime('now'))
`).run(
'reference@example.com',
'reference',
'reference-password-hash',
'reference',
).lastInsertRowid,
);
db.prepare(`
INSERT INTO deck_favorites (user_id, share_code)
VALUES (?, 'OLDDECK1')
`).run(referenceUserId);
db.prepare(`
INSERT INTO auth_tokens (user_id, type, token_hash, expires_at)
VALUES (?, 'verify_email', 'old-token', datetime('now', '+1 day'))
`).run(oldUserId);
db.prepare(`
INSERT INTO email_send_logs (user_id, type, month_key, status)
VALUES (?, 'verify_email', '2026-06', 'sent')
`).run(oldUserId);
const deletionResult = deleteUserAccount(db, oldUserId);
const newUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, datetime('now'))
`).run('new@example.com', 'test', 'new-password-hash', 'test').lastInsertRowid,
);
const oldDeck = db.prepare(`
SELECT user_id AS userId FROM decks WHERE share_code = 'OLDDECK1'
`).get();
assert.equal(deletionResult.deleted, true);
assert.equal(deletionResult.retainedDeckCount, 1);
assert.ok(newUserId > oldUserId);
assert.equal(oldDeck.userId, null);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM deck_favorites WHERE user_id = ?').get(oldUserId).count,
0,
);
assert.equal(
db.prepare(`
SELECT COUNT(*) AS count
FROM deck_favorites
WHERE user_id = ? AND share_code = 'OLDDECK1'
`).get(referenceUserId).count,
1,
);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM auth_tokens WHERE user_id = ?').get(oldUserId).count,
0,
);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM email_send_logs WHERE user_id = ?').get(oldUserId).count,
0,
);
assert.equal(
db.prepare(`
SELECT COUNT(*) AS count FROM decks
WHERE share_code = 'OLDDECK1' AND user_id = ?
`).get(newUserId).count,
0,
);
} finally {
db.close();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('이메일 승인·본인 탈퇴·관리자 탈퇴·덱 일괄 삭제 API가 동작한다', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-admin-verify-'));
const dbPath = path.join(tempDir, 'test.db');
const db = initDb(dbPath);
process.env.JWT_SECRET = 'test-secret-for-admin-email-verification';
const adminPassword = 'admin-password';
const adminUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, role, email_verified_at
)
VALUES (?, ?, ?, ?, 'admin', datetime('now'))
`).run(
'admin@example.com',
'admin',
bcrypt.hashSync(adminPassword, 4),
'admin',
).lastInsertRowid,
);
const targetUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, NULL)
`).run(
'pending@example.com',
'pending',
bcrypt.hashSync('pending-password', 4),
'pending',
).lastInsertRowid,
);
db.prepare(`
INSERT INTO auth_tokens (user_id, type, token_hash, expires_at)
VALUES (?, 'verify_email', 'pending-token', datetime('now', '+1 day'))
`).run(targetUserId);
db.prepare(`
INSERT INTO decks (
share_code, share_type, user_id, series, series_name, name, cards_json, is_public
)
VALUES ('PENDING1', 'user', ?, 'ua01', '테스트', '탈퇴 후 보존 덱', '[]', 1)
`).run(targetUserId);
const forcedUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, datetime('now'))
`).run(
'forced@example.com',
'forced',
bcrypt.hashSync('forced-password', 4),
'forced',
).lastInsertRowid,
);
db.prepare(`
INSERT INTO decks (
share_code, share_type, user_id, series, series_name, name, cards_json, is_public
)
VALUES
('BULKDEL1', 'user', ?, 'ua01', '테스트', '일괄 삭제 1', '[]', 1),
('BULKDEL2', 'user', ?, 'ua01', '테스트', '일괄 삭제 2', '[]', 1)
`).run(adminUserId, adminUserId);
const app = express();
app.use(express.json());
app.use('/api/v1/auth', createAuthRouter(db));
app.use('/api/v1/admin', createAdminRouter(db));
const server = app.listen(0);
try {
await new Promise((resolve) => server.once('listening', resolve));
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}/api/v1`;
const loginResponse = await fetch(`${baseUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'admin@example.com',
password: adminPassword,
}),
});
assert.equal(loginResponse.status, 200);
const { token } = await loginResponse.json();
const adminSelfDeleteResponse = await fetch(`${baseUrl}/admin/users/${adminUserId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ confirmation: 'DELETE' }),
});
assert.equal(adminSelfDeleteResponse.status, 400);
const approveResponse = await fetch(
`${baseUrl}/admin/users/${targetUserId}/verify-email`,
{
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
},
);
assert.equal(approveResponse.status, 200);
const target = db.prepare(`
SELECT email_verified_at AS emailVerifiedAt
FROM users WHERE id = ?
`).get(targetUserId);
const pendingToken = db.prepare(`
SELECT used_at AS usedAt
FROM auth_tokens WHERE user_id = ? AND type = 'verify_email'
`).get(targetUserId);
assert.ok(target.emailVerifiedAt);
assert.ok(pendingToken.usedAt);
const pendingLoginResponse = await fetch(`${baseUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'pending@example.com',
password: 'pending-password',
}),
});
assert.equal(pendingLoginResponse.status, 200);
const { token: pendingUserToken } = await pendingLoginResponse.json();
const invalidSelfDeleteResponse = await fetch(`${baseUrl}/auth/me`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${pendingUserToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ confirmation: 'delete' }),
});
assert.equal(invalidSelfDeleteResponse.status, 400);
const selfDeleteResponse = await fetch(`${baseUrl}/auth/me`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${pendingUserToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ confirmation: 'DELETE' }),
});
assert.equal(selfDeleteResponse.status, 200);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM users WHERE id = ?').get(targetUserId).count,
0,
);
assert.equal(
db.prepare(`
SELECT user_id AS userId FROM decks WHERE share_code = 'PENDING1'
`).get().userId,
null,
);
const adminDeleteResponse = await fetch(`${baseUrl}/admin/users/${forcedUserId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ confirmation: 'DELETE' }),
});
assert.equal(adminDeleteResponse.status, 200);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM users WHERE id = ?').get(forcedUserId).count,
0,
);
const bulkDeleteResponse = await fetch(`${baseUrl}/admin/decks/bulk-delete`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ shareCodes: ['BULKDEL1', 'BULKDEL2'] }),
});
assert.equal(bulkDeleteResponse.status, 200);
const bulkDeleteResult = await bulkDeleteResponse.json();
assert.equal(bulkDeleteResult.deletedCount, 2);
assert.equal(
db.prepare(`
SELECT COUNT(*) AS count
FROM decks WHERE share_code IN ('BULKDEL1', 'BULKDEL2')
`).get().count,
0,
);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
db.close();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});

155
api/test/stats.test.js Normal file
View File

@@ -0,0 +1,155 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import express from 'express';
import { initDb } from '../src/db.js';
import { createStatsRouter } from '../src/routes/stats.js';
/**
* 테스트용 카드 데이터 파일을 생성한다.
* @param {string} datasPath
* @returns {void}
*/
function writeTestDatas(datasPath) {
fs.mkdirSync(datasPath, { recursive: true });
fs.writeFileSync(
path.join(datasPath, 'ua01.js'),
`
export const cardList = [
{ imgSrc: '/ua01/001.png', Number: 'UA01-001', parallel: false },
{ imgSrc: '/ua01/001_p1.png', Number: 'UA01-001', parallel: true },
{ imgSrc: '/ua01/002.png', Number: 'UA01-002', parallel: false },
];
`,
);
fs.writeFileSync(
path.join(datasPath, 'uapr.js'),
`
export const cardList = [
{ imgSrc: '/uapr/001.png', Number: 'UAPR-001', parallel: false },
];
`,
);
fs.writeFileSync(
path.join(datasPath, 'ua99 sample.js'),
`
export const cardList = [
{ imgSrc: '/sample/001.png', Number: 'SAMPLE-001', parallel: false },
];
`,
);
}
/**
* 테스트 중 바꾼 환경변수를 원래 상태로 되돌린다.
* @param {string} key
* @param {string | undefined} value
* @returns {void}
*/
function restoreEnv(key, value) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
test('통계 API가 DB와 카드 데이터 운영 지표를 반환한다', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-stats-'));
const dbPath = path.join(tempDir, 'test.db');
const datasPath = path.join(tempDir, 'datas');
const previousDatasPath = process.env.DATAS_PATH;
const previousToken = process.env.STATS_API_TOKEN;
process.env.DATAS_PATH = datasPath;
delete process.env.STATS_API_TOKEN;
writeTestDatas(datasPath);
const db = initDb(dbPath);
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at, last_login_at
)
VALUES
('active@example.com', 'active', 'hash', 'active', datetime('now'), datetime('now')),
('pending@example.com', 'pending', 'hash', 'pending', NULL, NULL)
`).run();
db.prepare(`
INSERT INTO decks (
share_code, share_type, user_id, series, series_name, name, cards_json, is_public, view_count
)
VALUES
('PUBLIC01', 'user', 1, 'ua01', '테스트', '공개 덱', '[]', 1, 3),
('PRIVATE1', 'user', 1, 'ua01', '테스트', '비공개 덱', '[]', 0, 2),
('PRESET01', 'preset', NULL, 'uapr', '프로모', '프리셋', '[]', 1, 5)
`).run();
const app = express();
app.use('/api/stats', createStatsRouter(db, '4.0.test'));
const server = app.listen(0);
try {
await new Promise((resolve) => server.once('listening', resolve));
const address = server.address();
const response = await fetch(`http://127.0.0.1:${address.port}/api/stats`);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.status, 'online');
assert.equal(body.version, '4.0.test');
assert.equal(body.members.total, 2);
assert.equal(body.members.verified, 1);
assert.equal(body.members.unverified, 1);
assert.equal(body.decks.total, 3);
assert.equal(body.decks.publicTotal, 2);
assert.equal(body.decks.privateTotal, 1);
assert.equal(body.decks.totalViews, 10);
assert.equal(body.cards.seriesTotal, 2);
assert.equal(body.cards.cardTypesTotal, 3);
assert.equal(body.cards.cardPrintsTotal, 4);
assert.equal(body.cards.parallelPrintsTotal, 1);
} finally {
server.close();
db.close();
restoreEnv('DATAS_PATH', previousDatasPath);
restoreEnv('STATS_API_TOKEN', previousToken);
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('통계 API 토큰이 설정되면 인증 헤더를 요구한다', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-stats-token-'));
const dbPath = path.join(tempDir, 'test.db');
const datasPath = path.join(tempDir, 'datas');
const previousDatasPath = process.env.DATAS_PATH;
const previousToken = process.env.STATS_API_TOKEN;
process.env.DATAS_PATH = datasPath;
process.env.STATS_API_TOKEN = 'dashboard-secret';
writeTestDatas(datasPath);
const db = initDb(dbPath);
const app = express();
app.use('/api/stats', createStatsRouter(db, '4.0.test'));
const server = app.listen(0);
try {
await new Promise((resolve) => server.once('listening', resolve));
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}/api/stats`;
const denied = await fetch(baseUrl);
assert.equal(denied.status, 401);
const allowed = await fetch(baseUrl, {
headers: { Authorization: 'Bearer dashboard-secret' },
});
assert.equal(allowed.status, 200);
} finally {
server.close();
db.close();
restoreEnv('DATAS_PATH', previousDatasPath);
restoreEnv('STATS_API_TOKEN', previousToken);
fs.rmSync(tempDir, { recursive: true, force: true });
}
});

View File

@@ -6,7 +6,9 @@
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4516420168710424" crossorigin="anonymous"></script>
<title>Card List</title>
<script src="/script/navigation.js"></script>
<script src="/script/api.js"></script>
<link rel="stylesheet" href="./style/output.css" />
<link rel="stylesheet" href="./style/auth-header.css" />
<script type="module" src="/i18n/i18n.js"></script>
<link href="/style/navigation.css" rel="stylesheet" />
<link href="/style/style.css" rel="stylesheet" />
@@ -80,16 +82,19 @@
<div class="animate-spin rounded-full h-12 w-12 border-4 border-blue-500 border-t-transparent"></div>
</div>
<div class="sticky top-0 z-50 h-16 bg-white/80 backdrop-blur-md border-b px-4 py-3 flex justify-between items-center">
<button onclick="history.back()" class="font-bold text-slate-600 hover:text-blue-600 cursor-pointer">← BACK</button>
<div class="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b">
<div class="max-w-7xl mx-auto px-4 sm:px-6 h-16 flex justify-between items-center">
<button type="button" id="i18n-home" onclick="goToHome()" class="font-bold text-slate-600 hover:text-blue-600 cursor-pointer">← 홈</button>
<div class="font-bold text-slate-800">
<span id="i18n-searchResults">검색결과</span>
:
<span id="SearchResultsCounter" class="text-blue-600">0</span>
</div>
<div id="auth-header-slot"></div>
</div>
</div>
<div class="max-w-[1400px] mx-auto p-4 flex flex-col lg:flex-row gap-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-4 flex flex-col lg:flex-row gap-6">
<aside class="w-full lg:w-64 space-y-4 lg:sticky lg:top-20 lg:h-fit">
<div class="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm">
<button id="resetSelection" class="w-full mb-4 flex items-center justify-center gap-2 py-2 bg-red-50 hover:bg-red-100 text-red-600 rounded-xl font-bold text-xs transition-all border border-red-100">
@@ -240,6 +245,23 @@
let cardList = [];
let selectedCards = [];
let selectedColors = new Set(['All']);
let currentShareCode = null;
let currentDeckName = '';
const CARD_IMAGE_FALLBACK_SRC = '/images/uapr/comingsoon.png';
/**
* 카드 데이터의 실제 렌더링 이미지 URL 생성
* @param {string} currentSeriesKey
* @param {string} cardImgSrc
* @returns {string}
*/
function resolveCardImageSrc(currentSeriesKey, cardImgSrc) {
const fileName = (cardImgSrc || '').split('/').pop();
if (!currentSeriesKey || !fileName) {
return CARD_IMAGE_FALLBACK_SRC;
}
return `/images/${currentSeriesKey}/${fileName}`;
}
const getEl = () => ({
searchQuery: document.getElementById('searchQuery'),
@@ -253,37 +275,63 @@
specialCount: document.getElementById('specialCardCount'),
});
const seriesKey = new URLSearchParams(window.location.search).get('series');
const nameKey = new URLSearchParams(window.location.search).get('seriesName');
let seriesKey = new URLSearchParams(window.location.search).get('series');
let nameKey = new URLSearchParams(window.location.search).get('seriesName');
let t = {};
async function init() {
const urlParams = new URLSearchParams(window.location.search);
const lang = urlParams.get('lang') || localStorage.getItem('lang') || 'ko';
const shareCode = urlParams.get('d');
if (!seriesKey) return;
const el = getEl();
if (el.loadingScreen) el.loadingScreen.style.display = 'flex';
try {
// 1. 다국어 데이터 로드
const tResponse = await fetch(`./i18n/translations.${lang}.json`);
t = await tResponse.json();
applyI18n();
let deckCards = null;
if (shareCode && window.UaDeckApi) {
const deck = await UaDeckApi.getDeck(shareCode);
seriesKey = deck.series;
nameKey = deck.seriesName || deck.series;
currentDeckName = deck.name || '';
deckCards = deck.cards;
const me = UaDeckApi.getCachedUser();
if (me && deck.author && deck.author.id === me.id) {
currentShareCode = deck.shareCode;
}
if (!urlParams.get('series')) {
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('series', seriesKey);
newUrl.searchParams.set('seriesName', nameKey);
window.history.replaceState({}, '', newUrl);
}
}
if (!seriesKey) return;
const module = await import(`./datas/${seriesKey}.js`);
cardList = module.cardList;
initSeriesFilter();
const cardsParam = new URLSearchParams(window.location.search).get('cards');
const savedData = cardsParam ? decodeURIComponent(cardsParam) : localStorage.getItem('selectedCards');
if (savedData) {
const parsed = JSON.parse(decodeURIComponent(savedData));
cardList = syncCardData(cardList, parsed);
selectedCards = parsed;
if (deckCards) {
cardList = syncCardData(cardList, deckCards);
selectedCards = deckCards;
} else {
const savedData = localStorage.getItem('selectedCards');
if (savedData) {
const parsed = JSON.parse(decodeURIComponent(savedData));
cardList = syncCardData(cardList, parsed);
selectedCards = parsed;
}
}
applyInitialFilter();
@@ -375,13 +423,12 @@
cardContainer.className = 'cardContainer group flex flex-col p-2 bg-white rounded-lg border border-slate-200 shadow-sm hover:border-blue-300 transition-colors';
cardContainer.setAttribute('data-imgsrc', card.imgSrc);
const fileName = card.imgSrc.split('/').pop();
const imgSrc = `images/${seriesKey}/${fileName}`;
const imgSrc = resolveCardImageSrc(seriesKey, card.imgSrc);
cardContainer.innerHTML = `
<div class="card_image cursor-pointer relative w-full mb-2 rounded overflow-hidden bg-slate-50" style="aspect-ratio: 2/3;">
<img src="${imgSrc}" class="absolute inset-0 w-full h-full object-contain transition-transform"
onerror="console.error('이미지 로드 실패:', '${card.Number}', '${imgSrc}'); this.src='/images/uapr/comingsoon.png'; this.onerror=null;">
<img src="${imgSrc}" loading="lazy" decoding="async" class="absolute inset-0 w-full h-full object-contain transition-transform"
onerror="this.src='${CARD_IMAGE_FALLBACK_SRC}'; this.onerror=null;">
</div>
<div class="flex justify-between items-center mb-1.5 px-0.5">
@@ -447,6 +494,7 @@
};
safeSet('i18n-back', 'back');
safeSet('i18n-home', 'homeNav');
safeSet('i18n-searchResults', 'searchResults');
safeSet('i18n-resetBtn', 'resetSelection');
safeSet('i18n-labelSearch', 'search');
@@ -466,58 +514,97 @@
}
// 전역 핸들러
window.goToHome = function () {
const lang =
new URLSearchParams(window.location.search).get('lang') ||
localStorage.getItem('lang') ||
'ko';
window.location.href = lang && lang !== 'ko' ? `/?lang=${lang}` : '/';
};
/**
* 동일 카드 번호 그룹 합계
* @param {string} cardNumber
* @returns {number}
*/
function getNumberGroupTotal(cardNumber) {
return cardList
.filter((c) => c.Number === cardNumber)
.reduce((sum, c) => sum + c.count, 0);
}
/**
* 동일 카드 번호 그룹 최대 수량
* @param {string} cardNumber
* @returns {number}
*/
function getGroupMaxCount(cardNumber) {
const cards = cardList.filter((c) => c.Number === cardNumber);
if (!cards.length) return 4;
return Math.max(...cards.map((c) => c.maxCount || 4));
}
/**
* 카드 컨테이너 조회
* @param {string} imgSrc
* @returns {HTMLElement|null}
*/
function getCardContainer(imgSrc) {
return document.querySelector(`.cardContainer[data-imgsrc="${CSS.escape(imgSrc)}"]`);
}
/**
* 동일 번호 카드들의 +/- UI 동기화
* @param {string} cardNumber
*/
function updateCountControlsForNumber(cardNumber) {
const groupTotal = getNumberGroupTotal(cardNumber);
const groupAtMax = groupTotal >= getGroupMaxCount(cardNumber);
cardList
.filter((c) => c.Number === cardNumber)
.forEach((sibling) => {
const container = getCardContainer(sibling.imgSrc);
if (!container) return;
const minusBtn = container.querySelector('.minusBtn');
const plusBtn = container.querySelector('.plusBtn');
const countSpan = container.querySelector('.countText');
const keyBtn = container.querySelector('.keyBtn');
if (countSpan) countSpan.textContent = sibling.count;
if (minusBtn) {
minusBtn.classList.toggle('invisible', sibling.count === 0);
minusBtn.classList.toggle('visible', sibling.count > 0);
}
if (plusBtn) {
plusBtn.classList.toggle('invisible', groupAtMax);
plusBtn.classList.toggle('visible', !groupAtMax);
}
if (keyBtn) {
keyBtn.className = `keyBtn w-full py-1 rounded text-[9px] lg:text-[10px] font-bold border transition-all ${sibling.key && sibling.count > 0 ? 'bg-indigo-600 border-indigo-600 text-white shadow-md' : 'bg-white border-slate-200 text-slate-400'}`;
}
});
}
window.handleCount = function (imgSrc, delta, event) {
event.stopPropagation();
const card = cardList.find((c) => c.imgSrc === imgSrc);
if (!card) return;
// 1. 데이터 업데이트
const currentTotal = cardList.filter((c) => c.Number === card.Number).reduce((s, c) => s + c.count, 0);
if (delta > 0 && currentTotal < (card.maxCount || 4)) {
const maxCount = getGroupMaxCount(card.Number);
const currentTotal = getNumberGroupTotal(card.Number);
if (delta > 0 && currentTotal < maxCount) {
card.count++;
} else if (delta < 0 && card.count > 0) {
card.count--;
if (card.count === 0) card.key = false;
}
// 2. UI 업데이트
const container = document.querySelector(`.cardContainer[data-imgsrc="${imgSrc}"]`);
if (container) {
const minusBtn = container.querySelector('.minusBtn');
const plusBtn = container.querySelector('.plusBtn');
const countSpan = container.querySelector('.countText');
const keyBtn = container.querySelector('.keyBtn'); // 클래스로 직접 참조
if (countSpan) countSpan.textContent = card.count;
// 마이너스 버튼 제어
if (minusBtn) {
if (card.count > 0) {
minusBtn.classList.remove('invisible');
minusBtn.classList.add('visible');
} else {
minusBtn.classList.remove('visible');
minusBtn.classList.add('invisible');
}
}
// 플러스 버튼 제어 (isMax 결과에 따라 즉시 반영)
if (plusBtn) {
if (isMax(card)) {
plusBtn.classList.remove('visible');
plusBtn.classList.add('invisible');
} else {
plusBtn.classList.remove('invisible');
plusBtn.classList.add('visible');
}
}
// 키 버튼 상태 업데이트
if (keyBtn) {
keyBtn.className = `keyBtn w-full py-1 rounded text-[9px] lg:text-[10px] font-bold border transition-all ${card.key && card.count > 0 ? 'bg-indigo-600 border-indigo-600 text-white shadow-md' : 'bg-white border-slate-200 text-slate-400'}`;
}
}
updateCountControlsForNumber(card.Number);
syncToStorageAndCounters();
};
@@ -546,7 +633,7 @@
};
function isMax(card) {
return cardList.filter((c) => c.Number === card.Number).reduce((s, c) => s + c.count, 0) >= (card.maxCount || 4);
return getNumberGroupTotal(card.Number) >= getGroupMaxCount(card.Number);
}
function updateCardList() {
@@ -605,24 +692,23 @@
}
function syncCardData(base, saved) {
const map = new Map(base.map((c) => [c.imgSrc, c]));
const byImg = new Map(base.map((c) => [c.imgSrc, c]));
const byNum = new Map(base.map((c) => [c.Number, c]));
saved.forEach((s) => {
const item = map.get(s.imgSrc);
const item = byImg.get(s.imgSrc) || byNum.get(s.number || s.Number);
if (item) {
item.count = s.count;
item.key = s.key;
item.key = Boolean(s.key);
}
});
return Array.from(map.values());
return base;
}
function saveAndRefresh() {
selectedCards = cardList.filter((c) => c.count > 0);
const encoded = encodeURIComponent(JSON.stringify(selectedCards));
localStorage.setItem('selectedCards', encoded);
const url = new URL(window.location.href);
url.searchParams.set('cards', encoded);
window.history.replaceState({}, '', url);
localStorage.setItem('selectedSeries', seriesKey);
updateCounters();
updateCardList();
}
@@ -665,7 +751,11 @@
function showOverlay(card, index, list) {
const overlay = document.getElementById('imageOverlay');
const img = overlay.querySelector('img');
img.src = `images/${seriesKey}/${card.imgSrc.split('/').pop()}`;
img.src = resolveCardImageSrc(seriesKey, card.imgSrc);
img.onerror = () => {
img.onerror = null;
img.src = CARD_IMAGE_FALLBACK_SRC;
};
overlay.style.display = 'flex';
overlay.onclick = (e) => {
if (e.target === overlay) overlay.style.display = 'none';
@@ -713,8 +803,11 @@
// 4. 저장소 및 URL 동기화
localStorage.removeItem('selectedCards');
localStorage.removeItem('selectedSeries');
currentShareCode = null;
currentDeckName = '';
const url = new URL(window.location.href);
url.searchParams.delete('cards');
url.searchParams.delete('d');
window.history.replaceState({}, '', url);
// 5. 전체 갱신
@@ -750,19 +843,21 @@
document.querySelectorAll('input[name="triggerType"]').forEach((r) => {
r.addEventListener('change', updateCardList);
});
document.getElementById('goToResultPage').addEventListener('click', () => {
if (this.disabled) return; // 비활성화 상태면 무시
document.getElementById('goToResultPage').addEventListener('click', function () {
if (this.disabled) return;
// 현재 페이지에서 선택된(cardList에 있는) 카드만 필터링해서 전송
const currentSeriesSelected = cardList.filter(c => c.count > 0);
const currentSeriesSelected = cardList.filter((c) => c.count > 0);
if (currentSeriesSelected.length === 0) {
alert(translations.noCardsSelected || '선택된 카드가 없습니다.');
return;
}
if (currentSeriesSelected.length === 0) {
alert(translations.noCardsSelected || "선택된 카드가 없습니다.");
return;
}
const queryParams = encodeURIComponent(JSON.stringify(selectedCards));
window.location.href = `/result.html?series=${seriesKey}&seriesName=${nameKey}&cards=${queryParams}`;
selectedCards = currentSeriesSelected;
const encoded = encodeURIComponent(JSON.stringify(selectedCards));
localStorage.setItem('selectedCards', encoded);
localStorage.setItem('selectedSeries', seriesKey);
const lang = new URLSearchParams(window.location.search).get('lang') || localStorage.getItem('lang') || 'ko';
window.location.href = `/result.html?series=${seriesKey}&seriesName=${encodeURIComponent(nameKey || '')}&lang=${lang}`;
});
const resetModal = document.getElementById('resetModal');
@@ -822,16 +917,12 @@
}
}
// 3. 로컬 스토리지 저장 (현재 시리즈 데이터만 깨끗하게 유지)
// 3. 로컬 스토리지 저장
const encoded = encodeURIComponent(JSON.stringify(selectedCards));
localStorage.setItem('selectedCards', encoded);
localStorage.setItem('selectedSeries', seriesKey);
// 4. URL 업데이트 (새로고침 시 데이터 유지용)
const url = new URL(window.location.href);
url.searchParams.set('cards', encoded);
window.history.replaceState({}, '', url);
updateCounters(); // 하단 바 숫자만 업데이트
updateCounters();
}
let selectedSeriesGroups = new Set(['All']);
@@ -889,5 +980,6 @@
});
}
</script>
<script src="/script/auth-header.js"></script>
</body>
</html>

View File

@@ -2681,7 +2681,7 @@ export const cardList = [
key: false,
color: 'Yellow',
parallel: false,
rarity: 'SR',
rarity: 'OBC',
trigger: null,
},
{
@@ -2691,7 +2691,47 @@ export const cardList = [
key: false,
color: 'Yellow',
parallel: true,
rarity: 'SR',
rarity: 'OBC',
trigger: null,
},
{
imgSrc: '/ua03/UAPR_HTR-2-074.png',
Number: 'UAPR/HTR-2-074',
count: 0,
key: false,
color: 'Yellow',
parallel: false,
rarity: 'PR',
trigger: null,
},
{
imgSrc: '/ua03/UAPR_HTR-2-075.png',
Number: 'UAPR/HTR-2-075',
count: 0,
key: false,
color: 'Blue',
parallel: false,
rarity: 'PR',
trigger: 'COLOR',
},
{
imgSrc: '/ua03/UAPR_HTR-2-076.png',
Number: 'UAPR/HTR-2-076',
count: 0,
key: false,
color: 'Purple',
parallel: false,
rarity: 'PR',
trigger: null,
},
{
imgSrc: '/ua03/UAPR_HTR-2-077.png',
Number: 'UAPR/HTR-2-077',
count: 0,
key: false,
color: 'Green',
parallel: false,
rarity: 'PR',
trigger: null,
},
{

View File

@@ -2083,7 +2083,7 @@ export const cardList = [
trigger: null,
},
{
imgSrc: '/ua04/PC01BT_IMS-3-A07.png',
imgSrc: '/ua04/PC01BT_IMS-3-AP07.png',
Number: 'PC01BT/IMS-3-A07',
count: 0,
key: false,

View File

@@ -1613,6 +1613,46 @@ export const cardList = [
rarity: '',
trigger: null,
},
{
imgSrc: '/ua06/UAPR_TOA-P-001.png',
Number: 'UAPR/TOA-P-001',
count: 0,
key: false,
color: 'Blue',
parallel: false,
rarity: 'PR',
trigger: null,
},
{
imgSrc: '/ua06/UAPR_TOA-P-002.png',
Number: 'UAPR/TOA-P-002',
count: 0,
key: false,
color: 'Blue',
parallel: false,
rarity: 'PR',
trigger: null,
},
{
imgSrc: '/ua06/UAPR_TOA-P-003.png',
Number: 'UAPR/TOA-P-003',
count: 0,
key: false,
color: 'Red',
parallel: false,
rarity: 'PR',
trigger: null,
},
{
imgSrc: '/ua06/UAPR_TOA-P-004.png',
Number: 'UAPR/TOA-P-004',
count: 0,
key: false,
color: 'Red',
parallel: false,
rarity: 'PR',
trigger: null,
}
];

File diff suppressed because it is too large Load Diff

View File

@@ -2722,6 +2722,36 @@ export const cardList = [
parallel: false,
rarity: '',
trigger: null,
},
{
imgSrc: '/ua10/UAPR_MHA-P-001.png',
Number: 'UAPR/MHA-P-001',
count: 0,
key: false,
color: 'Yellow',
parallel: false,
rarity: 'PR',
trigger: null,
},
{
imgSrc: '/ua10/UAPR_MHA-P-002.png',
Number: 'UAPR/MHA-P-002',
count: 0,
key: false,
color: 'Purple',
parallel: false,
rarity: 'PR',
trigger: null,
},
{
imgSrc: '/ua10/UAPR_MHA-P-003.png',
Number: 'UAPR/MHA-P-003',
count: 0,
key: false,
color: 'Red',
parallel: false,
rarity: 'PR',
trigger: null,
},
];

View File

@@ -942,7 +942,7 @@ export const cardList = [
},
{
imgSrc: '/ua27/EX13BT_GIM-2-059.png',
Number: 'EX13BT/GIM-2-058',
Number: 'EX13BT/GIM-2-059',
count: 0,
key: false,
color: 'Red',

View File

@@ -660,7 +660,7 @@ export const cardList = [
trigger: null,
},
{
imgSrc: '/ua30/EX11BT_ARK-2-05_p1.png',
imgSrc: '/ua30/EX11BT_ARK-2-050_p1.png',
Number: 'EX11BT/ARK-2-050',
count: 0,
key: false,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1321,7 +1321,7 @@ export const cardList = [
trigger: null,
},
{
imgSrc: '/ua37/UA37BT_FMA-1-AP01_s.png',
imgSrc: '/ua37/UA37BT_FMA-1-AP01_p1.png',
Number: 'UA37BT/FMA-1-AP01',
count: 0,
key: false,

View File

@@ -1061,7 +1061,7 @@ export const cardList = [
trigger: null,
},
{
imgSrc: '/ua38/UA38BT_WBK-1-AP01_s.png',
imgSrc: '/ua38/UA38BT_WBK-1-AP01_p1.png',
Number: 'UA38BT/WBK-1-AP01',
count: 0,
key: false,
@@ -1081,7 +1081,7 @@ export const cardList = [
trigger: null,
},
{
imgSrc: '/ua38/UA38BT_WBK-1-AP02_s.png',
imgSrc: '/ua38/UA38BT_WBK-1-AP02_p1.png',
Number: 'UA38BT/WBK-1-AP02',
count: 0,
key: false,
@@ -1101,7 +1101,7 @@ export const cardList = [
trigger: null,
},
{
imgSrc: '/ua38/UA38BT_WBK-1-AP03_s.png',
imgSrc: '/ua38/UA38BT_WBK-1-AP03_p1.png',
Number: 'UA38BT/WBK-1-AP03',
count: 0,
key: false,

File diff suppressed because it is too large Load Diff

View File

@@ -1683,27 +1683,6 @@ export const cardList = [
rarity: 'PR',
trigger: 'FINAL',
},
// UAPR - AP
// {
// imgSrc: '/ua44/UAPR_EVA-AP01.png',
// Number: 'UAPR/EVA-AP01',
// count: 0,
// key: false,
// color: '',
// parallel: false,
// rarity: '',
// trigger: null,
// },
// {
// imgSrc: '/ua44/UAPR_EVA-AP02.png',
// Number: 'UAPR/EVA-AP02',
// count: 0,
// key: false,
// color: '',
// parallel: false,
// rarity: '',
// trigger: null,
// },
];

View File

@@ -1271,8 +1271,8 @@ export const cardList = [
trigger: null,
},
{
imgSrc: '/ua45/UA45BT_TLR-1-A07.png',
Number: 'UA45BT/TLR-1-A07',
imgSrc: '/ua45/UA45BT_TLR-1-AP07.png',
Number: 'UA45BT/TLR-1-AP07',
count: 0,
key: false,
color: 'AP',

1217
datas/ua50.js Normal file

File diff suppressed because it is too large Load Diff

1217
datas/ua51.js Normal file

File diff suppressed because it is too large Load Diff

1357
datas/ua52.js Normal file

File diff suppressed because it is too large Load Diff

1277
datas/ua53.js Normal file

File diff suppressed because it is too large Load Diff

1236
datas/ua54.js Normal file

File diff suppressed because it is too large Load Diff

2166
datas/ua55.js Normal file

File diff suppressed because it is too large Load Diff

1315
datas/ua56.js Normal file

File diff suppressed because it is too large Load Diff

1286
datas/ua57.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -881,8 +881,8 @@ export const cardList = [
trigger: null,
},
{
imgSrc: '/ua45/UA45BT_TLR-1-A07.png',
Number: 'UA45BT/TLR-1-A07',
imgSrc: '/ua45/UA45BT_TLR-1-AP07.png',
Number: 'UA45BT/TLR-1-AP07',
count: 0,
key: false,
color: '',

592
deckHistory.html Normal file
View File

@@ -0,0 +1,592 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4516420168710424" crossorigin="anonymous"></script>
<link rel="stylesheet" href="./style/output.css?v=4.0.8" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet" />
<title>내 덱 관리 - UNIARE</title>
</head>
<body class="app-page deck-history-page">
<nav class="app-nav">
<div class="app-nav-inner">
<a href="/" class="app-brand">
<img src="/images/assets/uniare-h48.png" alt="UNIARE" class="app-brand-logo" height="32" />
</a>
<div id="auth-header-slot"></div>
</div>
</nav>
<div class="app-ad-top">
<div class="app-ad-slot app-ad-slot--leaderboard" id="ad-leaderboard" aria-label="광고"></div>
</div>
<div class="app-shell">
<div class="app-body-layout">
<main class="app-main deck-history-main">
<header class="deck-history-header">
<div class="page-title-row">
<h1 data-i18n="deckHistoryTitle">내 덱 관리</h1>
<button type="button" class="page-help-btn" data-i18n-tooltip="deckHistoryDesc" aria-expanded="false">?</button>
</div>
</header>
<div id="deck-toolbar" class="deck-history-toolbar hidden">
<div class="deck-toolbar-fav-row">
<button type="button" id="deck-favorite-filter" class="deck-favorite-filter" aria-pressed="false">
<span class="deck-favorite-filter-icon deck-star-icon" aria-hidden="true"></span>
<span data-i18n="deckFavoriteFilterOnly">즐겨찾기만 보기</span>
<span id="deck-fav-count" class="deck-fav-count"></span>
</button>
</div>
<div class="deck-toolbar-main-row">
<input
type="search"
id="deck-search"
class="deck-search-input"
data-i18n-placeholder="deckSearchPlaceholder"
placeholder="덱 이름 또는 시리즈 검색..."
autocomplete="off"
/>
<select id="deck-series-filter" class="deck-series-select" aria-label="시리즈 필터">
<option value="">모든 시리즈</option>
</select>
<span id="deck-count" class="deck-count-label"></span>
</div>
</div>
<div id="deck-list">
<div class="deck-status-message" data-i18n="deckLoading">불러오는 중...</div>
</div>
</main>
<aside class="app-ad-rail" aria-label="광고">
<div class="app-ad-slot app-ad-slot--skyscraper" id="ad-sidebar">
<ins
class="adsbygoogle"
style="display: block"
data-ad-client="ca-pub-4516420168710424"
data-ad-slot="4260332296"
data-ad-format="auto"
data-full-width-responsive="true"
></ins>
</div>
</aside>
</div>
</div>
<script src="/script/api.js?v=4.0.8"></script>
<script src="/script/ads.js?v=4.0.8"></script>
<script src="/script/deck-favorites.js?v=4.0.8"></script>
<script src="/script/deck-favorites-shim.js?v=4.0.8"></script>
<script src="/script/deck-preview-hold.js?v=4.0.8"></script>
<script src="/script/deck-list-pager.js?v=4.0.19"></script>
<script src="/script/page-i18n.js?v=4.0.8"></script>
<script src="/script/page-help.js?v=4.0.8"></script>
<script src="/script/auth-header.js?v=4.0.8"></script>
<script>
/** @type {Array<object>} */
let allDecks = [];
/**
* @param {string} key
* @param {string} fallback
* @returns {string}
*/
function t(key, fallback) {
return window.UaPageI18n?.t(key, fallback) || fallback;
}
/**
* @param {number} filtered
* @param {number} total
* @returns {string}
*/
function formatDeckCount(filtered, total) {
return t('deckCountFormat', '{filtered} / {total}개')
.replace('{filtered}', String(filtered))
.replace('{total}', String(total));
}
/**
* 미리보기 없음 플레이스홀더 HTML
* @returns {string}
*/
function noPreviewHtml() {
return `<div class="deck-card-preview-placeholder">${escapeHtml(t('deckNoPreview', '미리보기 없음'))}<br><span>${escapeHtml(t('deckResaveHint', 'Preview에서 다시 저장'))}</span></div>`;
}
/**
* HTML 이스케이프
* @param {string} str
* @returns {string}
*/
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* 덱 표시 이름
* @param {object} deck
* @returns {string}
*/
function getDeckTitle(deck) {
return deck.name || deck.series_name || deck.series || t('deckUntitled', '이름 없음');
}
/**
* 시리즈 필터 옵션 갱신
* @param {Array<object>} decks
*/
function populateSeriesFilter(decks) {
const select = document.getElementById('deck-series-filter');
const current = select.value;
const seriesMap = new Map();
decks.forEach((d) => {
if (!seriesMap.has(d.series)) {
seriesMap.set(d.series, d.series_name || d.series);
}
});
const sorted = [...seriesMap.entries()].sort((a, b) =>
String(a[1]).localeCompare(String(b[1]), 'ko'),
);
select.innerHTML =
`<option value="">${escapeHtml(t('deckAllSeries', '모든 시리즈'))}</option>` +
sorted
.map(
([key, label]) =>
`<option value="${escapeHtml(key)}">${escapeHtml(label)}</option>`,
)
.join('');
if (current && seriesMap.has(current)) {
select.value = current;
}
}
/**
* 검색·필터 적용
* @returns {Array<object>}
*/
function getFilteredDecks() {
const query = document.getElementById('deck-search').value.trim().toLowerCase();
const series = document.getElementById('deck-series-filter').value;
const favOnly =
document.getElementById('deck-favorite-filter')?.getAttribute('aria-pressed') === 'true';
return allDecks.filter((d) => {
if (favOnly && !UaDeckFavorites.isFavorite(d.share_code)) return false;
if (series && d.series !== series) return false;
if (!query) return true;
const haystack = [
getDeckTitle(d),
d.series_name,
d.series,
d.share_code,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
}
/**
* 즐겨찾기 개수
* @returns {number}
*/
function countFavorites() {
return allDecks.filter((d) => UaDeckFavorites.isFavorite(d.share_code)).length;
}
/**
* 즐겨찾기 개수 라벨 갱신
*/
function updateFavCount() {
const el = document.getElementById('deck-fav-count');
if (!el) return;
const n = countFavorites();
el.textContent = n > 0 ? String(n) : '';
}
/**
* 즐겨찾기 버튼 HTML
* @param {string} shareCode
* @returns {string}
*/
function favoriteButtonHtml(shareCode) {
const on = UaDeckFavorites.isFavorite(shareCode);
const cls = on ? 'deck-card-btn-favorite is-on' : 'deck-card-btn-favorite';
const starCls = on ? 'deck-star-icon is-on' : 'deck-star-icon';
return `<button type="button" class="deck-card-btn ${cls}" data-favorite="${escapeHtml(shareCode)}" aria-pressed="${on ? 'true' : 'false'}" aria-label="${escapeHtml(t('deckFavorite', '즐겨찾기'))}"><span class="${starCls}" aria-hidden="true"></span>${escapeHtml(t('deckFavorite', '즐겨찾기'))}</button>`;
}
/**
* 미디어 URL에 갱신 시각 기반 캐시 무효화 쿼리를 붙인다.
* @param {string|null} apiPath
* @param {string|null} updatedAt
* @returns {string|null}
*/
function withVersion(apiPath, updatedAt) {
if (!apiPath) return null;
const src = UaDeckApi.resolveMediaUrl(apiPath);
const version = updatedAt ? `?v=${encodeURIComponent(updatedAt)}` : '';
return src + version;
}
/**
* 목록 표시용 이미지(썸네일 우선)
* @param {object} d
* @returns {string|null}
*/
function getThumbSrc(d) {
return withVersion(d.thumbnail_url || d.preview_url, d.updated_at);
}
/**
* 확대 표시용 원본 이미지(원본 우선)
* @param {object} d
* @returns {string|null}
*/
function getFullSrc(d) {
return withVersion(d.preview_url || d.thumbnail_url, d.updated_at);
}
/**
* 미리보기 HTML
* @param {object} d
* @param {string} title
* @returns {string}
*/
function renderPreviewHtml(d, title) {
const thumbSrc = getThumbSrc(d);
if (!thumbSrc) {
return `<div class="deck-card-preview"><div class="deck-card-preview-placeholder">${escapeHtml(t('deckNoPreview', '미리보기 없음'))}<br><span>${escapeHtml(t('deckResaveHint', 'Preview에서 다시 저장'))}</span></div></div>`;
}
const fullSrc = getFullSrc(d) || thumbSrc;
const holdAttrs = `data-hold-preview data-preview-src="${escapeHtml(fullSrc)}"`;
return `<div class="deck-card-preview deck-card-preview--holdable" ${holdAttrs}><img src="${escapeHtml(thumbSrc)}" alt="${escapeHtml(title)} 미리보기" loading="lazy" class="deck-card-preview-img" draggable="false" /></div>`;
}
/**
* 덱 카드 HTML
* @param {object} d
* @returns {string}
*/
function renderDeckCard(d) {
const title = escapeHtml(getDeckTitle(d));
const seriesLabel = escapeHtml(d.series_name || d.series);
const date = new Date(d.updated_at).toLocaleDateString('ko-KR');
const isPublic = Boolean(d.is_public);
const visibilityBadge = isPublic
? `<span class="deck-card-badge-public">${escapeHtml(t('deckBadgePublic', '공개'))}</span>`
: `<span class="deck-card-badge-private">${escapeHtml(t('deckBadgePrivate', '비공개'))}</span>`;
const toggleLabel = isPublic
? t('deckMakePrivate', '비공개 전환')
: t('deckMakePublic', '공개 전환');
const favClass = UaDeckFavorites.isFavorite(d.share_code) ? 'deck-card is-favorite' : 'deck-card';
return `
<article class="${favClass}" data-code="${escapeHtml(d.share_code)}">
${renderPreviewHtml(d, title)}
<div class="deck-card-body">
<div class="deck-card-visibility">
<span class="deck-card-series">${seriesLabel}</span>
${visibilityBadge}
</div>
<h2 class="deck-card-title">${title}</h2>
<div class="deck-card-meta">${date}</div>
<div class="deck-card-actions deck-card-actions--primary">
${favoriteButtonHtml(d.share_code)}
<button type="button" class="deck-card-btn deck-card-btn-load" data-load="${escapeHtml(d.share_code)}" data-series="${escapeHtml(d.series)}" data-name="${escapeHtml(d.series_name || '')}">${escapeHtml(t('deckLoad', '불러오기'))}</button>
<div class="deck-card-more-wrap">
<button type="button" class="deck-card-more-btn" aria-haspopup="true" aria-expanded="false" aria-label="${escapeHtml(t('deckMoreActions', '더 보기'))}">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="8" cy="3" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="8" cy="13" r="1.5"/></svg>
</button>
<div class="deck-card-more-menu hidden" role="menu">
<button type="button" class="deck-card-more-item" role="menuitem" data-copy="${escapeHtml(d.share_code)}">${escapeHtml(t('deckCopyLink', '링크 복사'))}</button>
<button type="button" class="deck-card-more-item" role="menuitem" data-rename="${escapeHtml(d.share_code)}" data-current-name="${escapeHtml(getDeckTitle(d))}">${escapeHtml(t('deckRename', '이름 변경'))}</button>
<button type="button" class="deck-card-more-item" role="menuitem" data-toggle="${escapeHtml(d.share_code)}" data-public="${isPublic ? '1' : '0'}">${escapeHtml(toggleLabel)}</button>
<button type="button" class="deck-card-more-item deck-card-more-item--danger" role="menuitem" data-delete="${escapeHtml(d.share_code)}">${escapeHtml(t('deckDelete', '삭제'))}</button>
</div>
</div>
</div>
</div>
</article>`;
}
/**
* 열린 더보기 메뉴 닫기
*/
function closeAllDeckMenus() {
document.querySelectorAll('.deck-card-more-menu').forEach((menu) => {
menu.classList.add('hidden');
});
document.querySelectorAll('.deck-card-more-btn').forEach((btn) => {
btn.setAttribute('aria-expanded', 'false');
});
}
/** @type {{ refresh: (items: object[]) => void }|null} */
let deckPager = null;
/**
* 현재 필터 상태의 빈 목록 메시지
* @returns {string}
*/
function emptyMessage() {
const favOnly =
document.getElementById('deck-favorite-filter')?.getAttribute('aria-pressed') === 'true';
return favOnly
? t('deckNoFavorites', '즐겨찾기한 덱이 없습니다.')
: t('deckNoMatch', '조건에 맞는 덱이 없습니다.');
}
/**
* 이미지 로드 실패 시 미리보기 없음 표시로 교체 (append 되는 카드까지 위임 처리)
*/
function bindPreviewErrorHandler() {
const list = document.getElementById('deck-list');
if (!list || list.dataset.previewErrBound === '1') return;
list.dataset.previewErrBound = '1';
list.addEventListener(
'error',
(e) => {
const img = e.target;
if (!(img instanceof HTMLImageElement) || !img.classList.contains('deck-card-preview-img')) {
return;
}
const wrap = img.closest('.deck-card-preview');
if (wrap) {
wrap.classList.remove('deck-card-preview--holdable');
wrap.removeAttribute('data-hold-preview');
wrap.removeAttribute('data-preview-src');
wrap.innerHTML = noPreviewHtml();
}
},
true,
);
}
/**
* 즐겨찾기 상태를 카드 DOM에 즉시 반영 (전체 리렌더 없이 스크롤 유지)
* @param {string} shareCode
* @param {boolean} on
*/
function applyFavoriteState(shareCode, on) {
const article = document.querySelector(`#deck-list .deck-card[data-code="${CSS.escape(shareCode)}"]`);
if (article) article.classList.toggle('is-favorite', on);
const btn = document.querySelector(`#deck-list [data-favorite="${CSS.escape(shareCode)}"]`);
if (btn) {
btn.classList.toggle('is-on', on);
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
const star = btn.querySelector('.deck-star-icon');
if (star) star.classList.toggle('is-on', on);
}
}
/**
* 목록 렌더링
*/
function renderDeckList() {
const list = document.getElementById('deck-list');
const filtered = getFilteredDecks();
document.getElementById('deck-count').textContent = formatDeckCount(filtered.length, allDecks.length);
updateFavCount();
if (!deckPager) {
bindPreviewErrorHandler();
deckPager = UaDeckPager.create({
container: list,
renderItem: renderDeckCard,
emptyHtml: () =>
`<div class="deck-grid"><div class="deck-grid-empty">${escapeHtml(emptyMessage())}</div></div>`,
});
}
deckPager.refresh(filtered);
closeAllDeckMenus();
}
/**
* 덱 목록 API 로드
*/
async function loadDecks() {
const list = document.getElementById('deck-list');
const toolbar = document.getElementById('deck-toolbar');
if (!UaDeckApi.getToken()) {
location.href = '/login.html?redirect=' + encodeURIComponent(location.pathname);
return;
}
try {
const { decks } = await UaDeckApi.getMyDecks();
allDecks = decks;
if (!decks.length) {
toolbar.classList.add('hidden');
list.innerHTML =
`<div class="deck-status-message">${escapeHtml(t('deckEmpty', '저장된 덱이 없습니다.'))}</div>`;
return;
}
toolbar.classList.remove('hidden');
populateSeriesFilter(decks);
renderDeckList();
} catch (err) {
toolbar.classList.add('hidden');
list.innerHTML = `<div class="deck-status-message error">${escapeHtml(err.message)}</div>`;
}
}
document.addEventListener('DOMContentLoaded', async () => {
if (window.UaPageI18n?.applyPageI18n) {
await window.UaPageI18n.applyPageI18n();
}
if (typeof UaDeckFavorites?.init === 'function') {
await UaDeckFavorites.init();
}
loadDecks();
document.getElementById('deck-search').addEventListener('input', renderDeckList);
document.getElementById('deck-series-filter').addEventListener('change', renderDeckList);
const favFilterBtn = document.getElementById('deck-favorite-filter');
const favFilterIcon = favFilterBtn.querySelector('.deck-star-icon');
favFilterBtn.addEventListener('click', () => {
const on = favFilterBtn.getAttribute('aria-pressed') !== 'true';
favFilterBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
favFilterIcon.classList.toggle('is-on', on);
renderDeckList();
});
document.getElementById('deck-list').addEventListener('click', async (e) => {
const moreBtn = e.target.closest('.deck-card-more-btn');
if (moreBtn) {
e.stopPropagation();
const menu = moreBtn.parentElement?.querySelector('.deck-card-more-menu');
const isOpen = menu && !menu.classList.contains('hidden');
closeAllDeckMenus();
if (!isOpen && menu) {
menu.classList.remove('hidden');
moreBtn.setAttribute('aria-expanded', 'true');
}
return;
}
if (!e.target.closest('.deck-card-more-wrap')) {
closeAllDeckMenus();
}
const favBtn = e.target.closest('[data-favorite]');
if (favBtn) {
try {
const code = favBtn.dataset.favorite;
const nowFav = await UaDeckFavorites.toggle(code);
const favOnly =
document.getElementById('deck-favorite-filter')?.getAttribute('aria-pressed') === 'true';
if (favOnly) {
renderDeckList();
} else {
applyFavoriteState(code, nowFav);
updateFavCount();
}
} catch (err) {
alert(err.message || t('deckFavoriteFailed', '즐겨찾기 변경에 실패했습니다.'));
}
return;
}
const loadBtn = e.target.closest('[data-load]');
if (loadBtn) {
const code = loadBtn.dataset.load;
const series = loadBtn.dataset.series;
const name = loadBtn.dataset.name;
location.href = `/cardList.html?series=${series}&seriesName=${encodeURIComponent(name)}&d=${code}`;
return;
}
const copyBtn = e.target.closest('[data-copy]');
if (copyBtn) {
const url = `${location.origin}/d/${copyBtn.dataset.copy}`;
await navigator.clipboard.writeText(url);
closeAllDeckMenus();
alert(t('deckLinkCopied', '공유 링크가 복사되었습니다.'));
return;
}
const renameBtn = e.target.closest('[data-rename]');
if (renameBtn) {
const code = renameBtn.dataset.rename;
const current = renameBtn.dataset.currentName || '';
const newName = prompt(t('deckRenamePrompt', '새 덱 이름을 입력하세요'), current);
if (newName === null) return;
const trimmed = newName.trim();
if (!trimmed) {
alert(t('deckRenameEmpty', '이름을 입력해 주세요.'));
return;
}
if (trimmed === current) {
closeAllDeckMenus();
return;
}
try {
await UaDeckApi.updateDeck(code, { name: trimmed });
const deck = allDecks.find((d) => d.share_code === code);
if (deck) deck.name = trimmed;
closeAllDeckMenus();
renderDeckList();
} catch (err) {
alert(err.message || t('deckRenameFailed', '이름 변경에 실패했습니다.'));
}
return;
}
const toggleBtn = e.target.closest('[data-toggle]');
if (toggleBtn) {
const code = toggleBtn.dataset.toggle;
const nextPublic = toggleBtn.dataset.public !== '1';
const label = nextPublic
? t('deckMakePublicConfirm', '이 덱을 공개 덱 목록에 표시합니다. 계속할까요?')
: t('deckMakePrivateConfirm', '비공개로 전환합니다. 공개 목록에서 숨겨집니다.');
if (!confirm(label)) return;
await UaDeckApi.updateDeck(code, { isPublic: nextPublic });
const deck = allDecks.find((d) => d.share_code === code);
if (deck) deck.is_public = nextPublic ? 1 : 0;
closeAllDeckMenus();
renderDeckList();
return;
}
const deleteBtn = e.target.closest('[data-delete]');
if (deleteBtn) {
closeAllDeckMenus();
if (!confirm(t('deckDeleteConfirm', '이 덱을 삭제하시겠습니까?'))) return;
await UaDeckApi.deleteDeck(deleteBtn.dataset.delete);
UaDeckFavorites.forget(deleteBtn.dataset.delete);
allDecks = allDecks.filter((d) => d.share_code !== deleteBtn.dataset.delete);
if (!allDecks.length) {
document.getElementById('deck-toolbar').classList.add('hidden');
document.getElementById('deck-list').innerHTML =
`<div class="deck-status-message">${escapeHtml(t('deckEmpty', '저장된 덱이 없습니다.'))}</div>`;
return;
}
populateSeriesFilter(allDecks);
renderDeckList();
}
});
});
</script>
</body>
</html>

74
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,74 @@
# UNIARE v4 프로덕션 스택 (NAS Docker)
# 사용: docker compose -f docker-compose.prod.yml up -d --build
#
# 정적 파일(HTML, images, datas)은 필요한 경로만 read-only 마운트 — 카드/이미지 추가 시 재빌드 불필요
services:
uniare-api:
build: ./api
container_name: uniare-api
restart: unless-stopped
expose:
- "3000"
environment:
- PORT=3000
- JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRES=${JWT_EXPIRES:-7d}
- APP_BASE_URL=${APP_BASE_URL}
- RESEND_API_KEY=${RESEND_API_KEY}
- RESEND_FROM_EMAIL=${RESEND_FROM_EMAIL}
- ADMIN_EMAIL=${ADMIN_EMAIL}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- CORS_ORIGINS=${CORS_ORIGINS:-*}
- PREVIEWS_PATH=/data/previews
- AVATARS_PATH=/data/avatars
- DB_PATH=/data/uadeck.db
- DATAS_PATH=/datas
volumes:
- uniare-data:/data
- ./datas:/datas:ro
- ./api/data/previews:/data/previews
- ./api/data/avatars:/data/avatars
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
uniare-web:
build:
context: .
dockerfile: nginx/Dockerfile
image: uniare-web:latest
container_name: uniare-web
restart: unless-stopped
ports:
- "${WEB_PORT:-44082}:80"
volumes:
- ./index.html:/usr/share/nginx/html/index.html:ro
- ./cardList.html:/usr/share/nginx/html/cardList.html:ro
- ./result.html:/usr/share/nginx/html/result.html:ro
- ./login.html:/usr/share/nginx/html/login.html:ro
- ./signup.html:/usr/share/nginx/html/signup.html:ro
- ./verify-email.html:/usr/share/nginx/html/verify-email.html:ro
- ./forgot-password.html:/usr/share/nginx/html/forgot-password.html:ro
- ./reset-password.html:/usr/share/nginx/html/reset-password.html:ro
- ./deckHistory.html:/usr/share/nginx/html/deckHistory.html:ro
- ./preset.html:/usr/share/nginx/html/preset.html:ro
- ./profile.html:/usr/share/nginx/html/profile.html:ro
- ./admin.html:/usr/share/nginx/html/admin.html:ro
- ./favicon.ico:/usr/share/nginx/html/favicon.ico:ro
- ./script:/usr/share/nginx/html/script:ro
- ./style:/usr/share/nginx/html/style:ro
- ./i18n:/usr/share/nginx/html/i18n:ro
- ./datas:/usr/share/nginx/html/datas:ro
- ./images:/usr/share/nginx/html/images:ro
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
uniare-api:
condition: service_healthy
volumes:
uniare-data:

36
docker-compose.yml Normal file
View File

@@ -0,0 +1,36 @@
services:
uniare-api:
build: ./api
container_name: uniare-api
restart: unless-stopped
ports:
- "${API_PORT:-3000}:3000"
environment:
- PORT=3000
- JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRES=${JWT_EXPIRES:-7d}
- APP_BASE_URL=${APP_BASE_URL}
- RESEND_API_KEY=${RESEND_API_KEY}
- RESEND_FROM_EMAIL=${RESEND_FROM_EMAIL}
- ADMIN_EMAIL=${ADMIN_EMAIL}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- CORS_ORIGINS=${CORS_ORIGINS:-*}
- PREVIEWS_PATH=/data/previews
- AVATARS_PATH=/data/avatars
- DB_PATH=/data/uadeck.db
- DATAS_PATH=/datas
volumes:
- uniare-data:/data
- ./datas:/datas:ro
- ./api/data/previews:/data/previews
- ./api/data/avatars:/data/avatars
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
volumes:
uniare-data:

176
docs/api-integration.md Normal file
View File

@@ -0,0 +1,176 @@
# 옆 프로젝트 API 연동 가이드
UNIARE v4부터 덱 데이터는 **공유 코드 + REST API**로 제공합니다.
URL 쿼리(`?cards=`) 방식은 사용하지 않습니다.
## 공식 엔드포인트
```http
GET https://uniare.sori.studio/api/v1/decks/:shareCode
```
### 응답 예시
```json
{
"v": 1,
"shareCode": "x7Kp9m2Q",
"shareType": "user",
"series": "ua36",
"seriesName": "마크로스",
"name": "블루 aggro",
"totalCards": 50,
"cards": [
{
"number": "UA36BT/MCR-1-052",
"count": 4,
"key": true,
"imgSrc": "/ua36/UA36BT_MCR-1-052.png",
"color": "Blue",
"rarity": "SR",
"trigger": null,
"parallel": false
}
],
"author": {
"id": 1,
"displayName": "닉네임",
"avatarUrl": null
},
"createdAt": "2026-06-19T12:00:00Z",
"updatedAt": "2026-06-19T12:00:00Z",
"viewCount": 42,
"isPublic": true
}
```
## shareCode 얻는 방법
| 방법 | 예시 |
|---|---|
| UNIARE 공유 버튼 | `https://uniare.sori.studio/d/x7Kp9m2Q` |
| URL 파라미터 | `cardList.html?d=x7Kp9m2Q` |
| 코드만 | `x7Kp9m2Q` |
## 클라이언트 라이브러리
프로젝트에 포함된 파일:
```
integrations/uadeck-deck-client.js
```
### 브라우저
```html
<script src="https://uniare.sori.studio/integrations/uadeck-deck-client.js"></script>
<script>
const client = new UaDeckDeckClient({
baseUrl: 'https://uniare.sori.studio/api/v1',
imageBase: 'https://uniare.sori.studio',
});
// 공유 링크에서 덱 로드
const deck = await client.fetchFromUrl('https://uniare.sori.studio/d/x7Kp9m2Q');
deck.cards.forEach((card) => {
console.log(card.number, card.count, card.imageUrl);
});
</script>
```
### Node.js / ESM
```javascript
import { UaDeckDeckClient } from './integrations/uadeck-deck-client.js';
const client = new UaDeckDeckClient({
baseUrl: 'https://uniare.sori.studio/api/v1',
});
const deck = await client.fetchDeck('x7Kp9m2Q');
```
### fetch 직접 사용
```javascript
const res = await fetch('https://uniare.sori.studio/api/v1/decks/x7Kp9m2Q');
const deck = await res.json();
if (!res.ok) throw new Error(deck.error);
```
## 이미지 URL 규칙
API의 `imgSrc`는 상대 경로입니다. 전체 URL:
```
https://uniare.sori.studio/images{imgSrc}
```
예: `imgSrc: "/ua36/UA36BT_MCR-1-052.png"`
→ `https://uniare.sori.studio/images/ua36/UA36BT_MCR-1-052.png`
`UaDeckDeckClient`는 `card.imageUrl`에 자동 변환합니다.
## CORS
다른 도메인에서 브라우저 `fetch`를 쓰려면 UNIARE 서버 `.env`의 `CORS_ORIGINS`에 해당 origin을 추가하세요.
```
CORS_ORIGINS=https://your-app.sori.studio,https://uniare.sori.studio
```
서버 간 호출(Node, Python 등)은 CORS가 필요 없습니다.
## 마이그레이션 (기존 ?cards= 코드)
**Before (폐기):**
```javascript
const params = new URLSearchParams(location.search);
const cards = JSON.parse(decodeURIComponent(params.get('cards')));
```
**After:**
```javascript
const code = UaDeckDeckClient.parseShareCode(location.href)
|| new URLSearchParams(location.search).get('d');
const client = new UaDeckDeckClient({ baseUrl: '/api/v1' });
const deck = await client.fetchDeck(code);
const cards = deck.cards;
```
## 공개 덱 목록
```http
GET /api/v1/decks/presets
```
```json
{
"presets": [
{
"share_code": "a7f3b2c1",
"series": "ua36",
"series_name": "마크로스",
"name": "공식 블루 템플릿",
"view_count": 120
}
]
}
```
엔드포인트 이름은 레거시 호환을 위해 `presets`를 유지하지만, 현재 응답 대상은 관리자가 등록한 프리셋이 아니라 사용자가 공개한 미만료 덱입니다.
상세 조회는 동일하게 `GET /decks/:shareCode`를 사용합니다.
## 에러 코드
| HTTP | 의미 |
|---|---|
| 404 | shareCode 없음 |
| 403 | 비공개 덱 |
| 410 | 만료된 임시 공유 (비회원 30일) |

227
docs/changelog.md Normal file
View File

@@ -0,0 +1,227 @@
# 변경사항
## v4.0.28
### Re:제로 EX17BT 카드 추가
`Re:제로부터 시작하는 이세계 생활` 시리즈에서 EX17BT 카드를 확인하고 덱을 만들 수 있습니다.
일부 패러렐 이미지는 추후 추가될 예정이며, 이미지가 준비된 일반 카드부터 먼저 표시됩니다.
### 기존 시리즈 패러렐 이미지 보강
이누야샤, 무직전생, 슈타인즈 게이트의 패러렐 카드 이미지를 추가해 카드 목록과 덱에서 해당 판본을 확인할 수 있습니다.
## v4.0.27
### 슈타인즈 게이트 추가
메인 화면에서 `슈타인즈 게이트`를 선택해 UA57 카드로 덱을 만들 수 있습니다. 패러렐 이미지 33개는 추후 추가될 예정입니다.
### 마도카 이미지 보강
마도카 EX16BT 패러렐 이미지 36개를 추가했습니다.
## v4.0.26
### 魔法少女まどか☆マギカ EX16BT 추가
`魔法少女まどか☆マギカ` 시리즈에서 EX16BT/MMM-2 카드를 함께 확인하고 덱을 만들 수 있습니다.
일부 패러렐 이미지는 추후 추가될 예정이며, 이미지가 준비된 일반 카드부터 먼저 표시됩니다.
## v4.0.25
### 천원돌파 그렌라간 이미지 보정
`UA56BT/TTG-1-015` 카드 이미지가 올바르게 표시되도록 보정했습니다.
## v4.0.24
### 천원돌파 그렌라간 추가
메인 화면에서 `천원돌파 그렌라간` 시리즈를 선택해 UA56 카드로 덱을 만들 수 있습니다.
### BLEACH 카드 이미지 보강
BLEACH 시리즈의 BLC-4 카드와 일부 누락 카드 이미지를 추가해 카드 검색과 덱 구성에서 더 많은 판본을 확인할 수 있습니다.
## v4.0.23
### 운영 지표 API 추가
통합 대시보드에서 서비스 상태를 확인할 수 있도록 `GET /api/stats`를 추가했습니다.
회원 수, 신규 회원 수, 덱 수, 신규 덱 수, 카드 시리즈 수, 등록 카드 수 같은 집계 정보를 확인할 수 있습니다. 개인정보나 상세 회원 목록은 포함하지 않습니다.
## v4.0.22
### 아이돌마스터 신데렐라 걸즈 추가
메인 화면에서 `아이돌마스터 신데렐라 걸즈` 시리즈를 선택해 UA55 카드로 덱을 만들 수 있습니다.
## v4.0.21
### 보안·안정성 강화
로그인과 이메일 발송 요청에 과도한 반복 시도를 막는 보호를 추가하고, 페이지 응답에 표준 보안 헤더를 적용했습니다. 공개 덱 목록 조회 성능도 개선했습니다. 일반 사용에는 영향이 없으며, 비정상적으로 많은 요청만 잠시 제한됩니다.
## v4.0.20
### 페이지 로딩 속도 개선
카드 데이터와 스타일 등 텍스트 자원을 압축 전송하도록 개선해 페이지가 더 빠르게 열립니다. 카드 목록 화면은 화면에 보이는 카드부터 순차적으로 이미지를 불러오도록 바뀌어, 시리즈를 처음 열 때 대기 시간이 줄어듭니다. 자주 보는 이미지의 캐시 유지 기간도 늘려 재방문 시 더 빠릅니다.
## v4.0.19
### 덱 목록 무한 스크롤
`내 덱 관리`와 `공개 덱` 목록이 무한 스크롤로 바뀌었습니다. 화면을 아래로 내리면 덱이 자동으로 이어서 나타나, 덱이 많아도 목록이 훨씬 가볍게 열립니다. 특정 덱은 검색으로 빠르게 찾을 수 있습니다.
### 목록 이미지 로딩 최적화
목록에서는 원본 대신 가벼운 썸네일 이미지를 사용하도록 개선했습니다. 이미지 용량이 크게 줄어 목록이 더 빠르게 표시되며, 썸네일을 길게 누르면 기존처럼 원본 크기로 크게 볼 수 있습니다.
> 썸네일은 덱을 새로 저장할 때 생성됩니다. 이전에 저장한 덱은 다시 저장하면 최적화된 썸네일이 적용됩니다.
## v4.0.18
### 관리자 덱 관리 편의 복구
관리자 덱 카드에서 썸네일 길게 눌러 크게 보기와 `불러오기` 동작을 다시 사용할 수 있도록 개선했습니다. 체크박스 다중 선택과 함께 사용해도 서로 충돌하지 않습니다.
### 관리자 회원 탈퇴 동선 정리
회원 수정 모달의 별도 탈퇴 버튼을 제거하고, `역할 변경하기` 안에서 `회원 탈퇴 처리`를 선택한 뒤 저장 시 2단계 확인 후 탈퇴가 진행되도록 변경했습니다.
## v4.0.17
### 공유 덱 카드 복원 정확도 개선
공유 링크로 덱을 불러올 때 카드 복원 기준을 `imgSrc`로 통일해, 같은 카드 번호를 가진 일반·패러렐 카드가 섞여 있어도 선택한 카드가 정확히 다시 표시됩니다.
### 누락 이미지 표시 방식 개선
카드 목록과 덱 프리뷰에서 이미지 경로가 잘못되었거나 파일이 없는 경우 깨진 이미지 대신 카드 뒷면 이미지를 표시하도록 개선했습니다.
## v4.0.16
### 회원 탈퇴
프로필 화면 가장 아래의 `회원 탈퇴`를 눌러 직접 탈퇴할 수 있습니다. 확인란에 `DELETE`를 입력한 뒤 `YES`를 누르면 계정과 개인 정보가 삭제됩니다.
기존에 저장한 덱은 다른 사용자가 계속 참고할 수 있도록 작성자만 `익명`으로 변경되어 유지됩니다.
### 관리자 회원·덱 정리
관리자는 회원 정보 화면에서 회원을 탈퇴 처리할 수 있습니다. 덱 관리에서는 여러 덱을 체크하거나 현재 목록을 전체 선택해 썸네일과 함께 일괄 삭제할 수 있습니다.
## v4.0.15
### 인증 메일 재발송 동선 개선
이메일 인증을 완료하지 않은 상태로 로그인하면 로그인 화면에서 인증 메일을 바로 다시 요청할 수 있습니다.
인증·비밀번호 재설정 메일은 최초 발송을 포함해 계정당 월 3회까지 제공되며, 한도 초과 시 관리자 문의 방법을 함께 안내합니다.
관리자는 문의한 미인증 회원을 확인한 뒤 관리자 화면에서 이메일 인증을 직접 승인할 수 있습니다.
### 인증·비밀번호 화면 다국어 개선
인증 완료 안내와 `비밀번호를 잊으셨나요?`, 비밀번호 찾기·재설정 화면을 한국어·일본어·영어로 제공합니다. 별도 언어 선택이 없으면 한국어로 표시됩니다.
## v4.0.14
### 계정별 이메일 발송 한도
가입 인증과 비밀번호 재설정 메일은 계정당 한 달에 최대 3회까지 받을 수 있습니다.
한도를 초과한 경우 다음 달에 다시 요청하거나 `zenn@sori.studio`로 문의해 주세요.
## v4.0.13
### 이메일 인증 오류 수정
이메일의 인증 버튼을 눌렀을 때 일부 브라우저에서 구버전 파일이 남아 인증되지 않던 문제를 수정했습니다.
### 인증 화면 중복 요청 방지
회원가입, 로그인, 이메일 전송과 비밀번호 변경 처리 중에는 버튼에 진행 상태를 표시하고 반복 제출을 차단합니다. 메일 재전송은 60초 후 다시 요청할 수 있습니다.
## v4.0.12
### 이메일 인증 회원가입
새로 가입하는 사용자는 가입한 이메일로 전송된 링크를 눌러야 로그인할 수 있습니다. 인증 메일을 받지 못한 경우 인증 화면에서 다시 요청할 수 있습니다.
### 비밀번호 직접 재설정
로그인 화면의 `비밀번호를 잊으셨나요?`에서 가입 이메일로 재설정 링크를 받아 새 비밀번호를 설정할 수 있습니다.
### 중복 가입 검증 강화
이메일 형식을 서버에서도 확인하며, 사용자명은 영문 대소문자만 다른 경우에도 중복 가입을 차단합니다.
## v4.0.11
### 로그인 유지 선택
로그인 화면에서 `로그인 유지`를 선택할 수 있습니다. 선택하지 않으면 브라우저 세션이 끝날 때 로그인 정보가 제거됩니다.
### 마지막 접속 기록 개선
이미 로그인된 사용자도 사이트를 다시 이용하면 관리자 화면의 마지막 접속 시간이 갱신됩니다.
## v4.0.3
### 즐겨찾기 계정 동기화
로그인한 사용자의 즐겨찾기가 계정에 저장됩니다. 다른 브라우저나 기기에서도 같은 계정으로 로그인하면 즐겨찾기가 유지됩니다.
기존 브라우저에 저장된 즐겨찾기는 로그인 시 계정으로 이전되며, 삭제되거나 더 이상 공개되지 않는 덱은 자동으로 정리됩니다.
## v4.0.2
### 관리자 덱 관리 화면 개선
관리자 화면에서도 내 덱과 동일한 형태로 시리즈, 공개 상태, 수정일을 확인할 수 있습니다. 삭제 기능은 각 카드의 더보기 메뉴로 이동했습니다.
### 불필요한 덱 썸네일 정리
덱을 삭제하면 연결된 썸네일도 함께 삭제됩니다. 기존에 남은 미참조 썸네일은 관리자 화면에서 목록과 용량을 확인한 뒤 정리할 수 있습니다.
## v4.0.1
### 프로젝트 운영 문서 정비
기능 동작은 변경하지 않고 개발·배포 문서를 현재 서비스 구조에 맞게 정리했습니다.
로컬 개발과 NAS 운영, 카드 이미지·데이터 업데이트, 백업·복구 절차를 구분해 이후 업데이트를 더 안전하게 진행할 수 있습니다.
## v4.0.0
### 회원 계정과 내 덱 관리
회원가입과 로그인을 지원합니다. 프로필에서 사용자명, 이메일, 프로필 이미지, 비밀번호와 덱 공개 기본값을 관리할 수 있습니다.
완성한 덱은 결과 화면의 `내 덱으로 저장` 버튼으로 저장하고, `내 덱` 화면에서 이름 변경, 공개 전환, 삭제, 다시 불러오기를 할 수 있습니다.
### 짧은 공유 링크
긴 카드 데이터 URL 대신 `/d/공유코드` 형식의 링크를 사용합니다. 공개 덱은 링크를 받은 누구나 불러올 수 있으며 비공개 덱은 소유자와 관리자만 볼 수 있습니다.
### 공개 덱 탐색과 즐겨찾기
`공개 덱` 화면에서 다른 사용자가 공개한 덱을 검색하고 시리즈별로 필터링할 수 있습니다. 썸네일을 길게 눌러 크게 보고, 자주 확인하는 덱은 즐겨찾기에 보관할 수 있습니다.
### 덱 이미지 저장 개선
저장되는 덱 미리보기 크기를 1200px 기준으로 통일했습니다. 브라우저별로 썸네일 크기가 달라지거나 큰 이미지 저장이 실패하던 문제를 줄였습니다.
### 다국어 화면 확대
로그인, 회원가입, 프로필, 내 덱, 공개 덱과 주요 저장 안내를 한국어, 일본어, 영어로 제공합니다.
### UNIARE 브랜드 적용
서비스 표시 이름과 헤더 로고를 UNIARE로 통일하고, 페이지 이동 시 헤더와 본문 너비가 안정적으로 유지되도록 개선했습니다.

71
docs/codex-handoff.md Normal file
View File

@@ -0,0 +1,71 @@
# 작업 인계 가이드
## Handoff(핸드오프)란?
다른 개발자나 운영 담당자가 지금 작업을 이어받을 때 빠르게 상황을 이해하도록 정리한 문서입니다. 비개발자에게는 '작업 인수인계 노트'라고 생각하면 됩니다.
## 우선 확인
1. 프로젝트 루트 `AGENTS.md`
2. `docs/spec.md`
3. `docs/todo.md`
4. `docs/history.md`
5. `docs/convention.md`
6. `docs/map.md`
7. `docs/deploy.md`
현재 버전은 루트 `VERSION`을 기준으로 확인한다.
## 프로젝트 핵심
- 정적 HTML·JavaScript 프론트와 Express·SQLite API를 함께 사용한다.
- 운영은 `docker-compose.prod.yml`의 `uniare-web`, `uniare-api` 구성이 기본이다.
- 덱 공유는 `/d/<shareCode>`와 `GET /api/v1/decks/:shareCode`를 사용한다.
- `?cards=<JSON>` 공유 방식은 사용하지 않는다.
- 표시 브랜드는 UNIARE지만 `UaDeckApi`, `uadeck_token`, `uadeck.db` 등 내부 식별자는 호환을 위해 유지한다.
## 카드 데이터 작업
신규 시리즈 또는 카드는 아래 항목을 한 세트로 확인한다.
```text
datas/<series>.js
images/<series>/
i18n/translations.ko.json
i18n/translations.ja.json
i18n/translations.en.json
```
- `imgSrc`와 실제 이미지 파일명이 일치해야 한다.
- 동일 카드의 패러렐 판본은 같은 `Number`를 사용한다.
- `images/**`는 Git LFS 대상이다.
- `datas/` 변경 후 API 카드 캐시를 갱신하려면 `uniare-api`를 재시작한다.
## 변경 후 문서
- 현재 구현 변경: `spec.md`
- 이번 작업 기록: `update.md`
- 결정 이유: `history.md`
- 남은 작업: `todo.md`
- 사용자 공개 변경: `changelog.md`
- 파일 역할 변경: `map.md`
- 배포 절차 변경: `deploy.md`
## 검증
```bash
git diff --check
git status --short
```
변경 성격에 따라 다음도 확인한다.
- HTML·정적 자산: 브라우저 새로고침
- `datas/`: API 재시작 후 카드 목록과 저장 덱 조회
- API 코드: Docker 재빌드와 `/api/v1/health`
- Tailwind 클래스: `npm run build:css`
## 커밋
- 커밋 메시지는 한국어로 작성한다.
- 커밋하는 완료 작업은 `VERSION`을 증가시킨다.
- 민감 정보와 `api/data/` 운영 파일을 커밋하지 않는다.

View File

@@ -1,43 +1,68 @@
# 코딩 컨벤션
# 프로젝트 컨벤션
## 기본 원칙
- 모든 문서와 커뮤니케이션은 한국어 기준으로 유지한다.
- 코드 주석은 필요 시 `JSDoc` 형식을 우선한다.
- 기존 정적 사이트 구조를 존중하고, 대규모 구조 변경 시에는 먼저 `docs/history.md`에 의사결정을 기록한다.
## 공통
## 파일 및 폴더 규칙
- 시리즈 데이터 파일은 `datas/uaXX.js`, `datas/uapr.js`처럼 시리즈 키와 일치하게 관리한다.
- 이미지 폴더는 `images/<seriesKey>/` 규칙을 유지한다.
- 새 문서는 `docs/` 하위에서 목적별 단일 파일에 누적 관리한다.
- 모든 문서와 커뮤니케이션은 한국어로 작성한다.
- 실제 코드와 문서가 다르면 코드를 기준으로 문서를 갱신한다.
- 요청 범위를 넘는 대규모 리팩터링을 하지 않는다.
- 운영 URL, 비밀번호, 토큰, DB 정보는 코드에 하드코딩하지 않는다.
- JavaScript 함수와 클래스에는 필요한 JSDoc을 작성한다.
## JavaScript 규칙
- 기존 코드 스타일에 맞춰 기본 들여쓰기는 4칸을 사용한다.
- 상수는 `const`, 재할당 변수는 `let`을 사용한다.
- DOM 참조가 반복될 경우 `getEl()`처럼 묶어서 관리하는 패턴을 우선 고려한다.
- URL 파라미터, `localStorage`, 동적 import는 예외 처리와 함께 사용한다.
- 카드 데이터 객체의 필드명은 기존 호환성을 위해 현재 형태(`imgSrc`, `Number`, `count`, `key`)를 유지한다.
## 버전
## HTML 규칙
- 정적 페이지는 의미 단위별로 `header`, `main`, `aside` 등을 우선 사용한다.
- 페이지별 스크립트가 길어질 경우 신규 기능은 별도 JS 파일 분리 여부를 먼저 검토한다.
- 인라인 이벤트(`onclick`)는 기존 파일에 이미 존재하므로 즉시 제거 대상은 아니지만, 신규 대형 기능은 `addEventListener` 방식 우선 검토한다.
## CSS 규칙
- 공통 UI는 가능한 한 Tailwind 유틸리티 클래스를 우선 사용한다.
- 페이지 특화 보정 스타일만 인라인 `<style>` 또는 전용 CSS 파일에 둔다.
- 캡처 안정성처럼 이유가 분명한 예외 스타일은 주석으로 목적을 남긴다.
## 데이터 관리 규칙
- 카드 데이터 추가 시 최소 필드는 `imgSrc`, `Number`, `count`, `key`, `color`, `parallel`, `rarity`, `trigger`를 맞춘다.
- 동일 카드의 패러렐 판본은 동일 `Number`를 유지하고 `parallel` 값으로 구분한다.
- 파일명 변경 시 `datas/*.js`와 `images/<seriesKey>/`가 함께 일치하는지 확인한다.
## Git 규칙
- 현재 버전은 프로젝트 루트 `VERSION`을 기준으로 한다.
- 커밋을 포함하는 완료 작업은 버전을 증가시키고 `VERSION`, `docs/update.md`, 필요 시 `docs/changelog.md`를 함께 갱신한다.
- 커밋 메시지는 한국어로 작성한다.
- 버전 상승 작업은 `docs/update.md` 반영 후 태그명과 맞춘다.
- 민감 정보, 로컬 경로, 개인 식별 정보가 커밋되지 않도록 확인한다.
## 이미지 자산 규칙
- `images/**`는 `Git LFS`로 관리한다.
- 이미지 파일을 새로 추가하거나 교체할 때는 일반 Git 객체로 들어가지 않도록 `.gitattributes` 설정을 유지한다.
- 대량 이미지 추가 전에는 원격 저장소의 LFS 정책과 비용 한도를 확인한다.
## 카드 데이터
- 시리즈 파일은 `datas/uaXX.js` 또는 `datas/uapr.js` 형식을 사용한다.
- 카드 객체의 호환 필드 `imgSrc`, `Number`, `count`, `key` 이름은 변경하지 않는다.
- 동일 카드의 패러렐 판본은 같은 `Number`를 사용하고 `parallel`로 구분한다.
- 신규 시리즈는 데이터, 이미지, 한국어·일본어·영어 번역을 한 작업 단위로 검증한다.
- 이미지 파일명 변경 시 관련 `datas/*.js`의 `imgSrc`도 함께 갱신한다.
## 프론트
- 현재 정적 HTML/JavaScript 구조를 유지한다.
- 신규 대형 기능은 페이지 인라인 스크립트보다 `script/` 모듈 분리를 우선 검토한다.
- URL 파라미터, 동적 import, `localStorage` 접근에는 실패 처리를 둔다.
- 기존 인라인 이벤트는 즉시 전면 교체하지 않으며 신규 기능은 `addEventListener`를 우선한다.
## 스타일
- 기존 CSS는 `todo.md`의 화면별 순서에 따라 한 화면씩 Tailwind로 전환한다.
- 새 단순 스타일은 Tailwind 유틸리티를 우선 검토하되 기존 페이지 패턴과 빌드 여부를 함께 확인한다.
- 반복되는 공통 스타일은 공통 CSS 또는 컴포넌트 역할의 클래스로 분리한다.
- `style/output.css`를 직접 편집하지 않고 `npm run build:css`로 생성한다.
- Tailwind 스캔 대상은 `style/input.css`의 `@source`로 지정한다. HTML·JS 전환 후에는 반드시 CSS를 재빌드한다.
- 공통 UI(앱 셸·덱 카드·프로필·인증 헤더)는 `style/components.css`의 `@layer components`에 두고 `input.css`에서 import한다.
- 각 HTML 페이지는 `style/output.css`만 로드한다(`brand.css`는 빌드에 포함).
- 다크모드와 Pretendard 계열 기본 글꼴을 고려한다.
## API·DB
- API 베이스 경로는 `/api/v1`을 유지한다.
- 운영 설정은 `.env`로 관리하며 `.env.example`에는 예시값만 둔다.
- 로컬 DB와 운영 DB를 같은 파일 또는 볼륨으로 공유하지 않는다.
- DB에는 최소 카드 정보만 저장하고 카드 메타는 `datas/`에서 합성한다.
- 권한 검사는 화면뿐 아니라 API에서도 수행한다.
## 이미지·Git
- `images/**`는 Git LFS로 관리한다.
- `api/data/`의 DB, 아바타, 덱 미리보기는 커밋하지 않는다.
- 대량 이미지 추가 전 변경 수와 LFS 추적 여부를 확인한다.
- 민감 정보와 개인 로컬 경로가 diff에 포함되지 않았는지 확인한다.
## 문서
- `spec.md`: 현재 구현 상태
- `update.md`: 개발자 작업 로그
- `history.md`: 유지할 의사결정과 이유
- `todo.md`: 미완료 작업만
- `deploy.md`: 실행·배포·롤백
- `widget.md`: 외부 서비스 연동
- `map.md`: 파일과 기능 연결
- `changelog.md`: 사용자 공개 변경사항
- `convention.md`: 반복 적용 규칙

253
docs/deploy.md Normal file
View File

@@ -0,0 +1,253 @@
# UNIARE 실행·배포 가이드
필요한 부분만 골라서 읽으면 된다.
1. 내 컴퓨터에서 실행: `1. 로컬 실행`
2. NAS에 처음 설치: `2. NAS 최초 배포`
3. 이미지나 카드 데이터 반영: `3. 정적 파일 업데이트`
4. API나 Docker 설정 반영: `4. 전체 업데이트`
로컬 주소는 `http://localhost:44082`, 운영 주소는 `https://uniare.sori.studio`이다.
Git 저장소는 `https://git.sori.studio/sori.studio/uniare.sori.studio.git`이다.
## 시작 전 확인
- 명령은 프로젝트 폴더에서 실행한다.
- 로컬과 NAS는 각각 다른 `.env`와 DB를 사용한다.
- `.env`, DB, 비밀번호는 Git에 올리지 않는다.
- 카드 이미지는 Git LFS로 관리한다.
- 로컬 위치는 `<LOCAL_PROJECT_DIR>/uniare.sori.studio`, NAS 위치는 `<NAS_PROJECT_DIR>/uniare.sori.studio`로 표기한다.
## 1. 로컬 실행
최초 한 번 프로젝트 폴더에서 `cp .env.example .env`를 실행한다.
`.env`에서 다음 값을 변경한다.
```env
JWT_SECRET=32자_이상의_랜덤_문자열
ADMIN_EMAIL=관리자_이메일
ADMIN_PASSWORD=8자_이상의_비밀번호
WEB_PORT=44082
APP_BASE_URL=http://localhost:44082
RESEND_API_KEY=Resend_API_Key
RESEND_FROM_EMAIL=UNIARE <인증된_발신주소>
STATS_API_TOKEN=대시보드용_토큰_또는_빈값
```
랜덤 문자열은 `openssl rand -hex 32`로 만들 수 있다.
실행은 `npm start`, 종료는 `npm stop`, 로그 확인은 `npm run logs`를 사용한다.
실행 후 브라우저에서 `http://localhost:44082`에 접속한다.
## 2. NAS 최초 배포
NAS에 Git, Git LFS, Docker, Docker Compose가 설치되어 있어야 한다.
저장소를 받는다.
```bash
cd <NAS_PROJECT_DIR>
git clone https://git.sori.studio/sori.studio/uniare.sori.studio.git uniare.sori.studio
cd uniare.sori.studio
git lfs pull
cp .env.example .env
```
`.env`에서 다음 값을 운영 환경에 맞게 변경한다.
```env
JWT_SECRET=32자_이상의_랜덤_문자열
ADMIN_EMAIL=관리자_이메일
ADMIN_PASSWORD=8자_이상의_비밀번호
ADMIN_USERNAME=admin
WEB_PORT=44082
CORS_ORIGINS=https://uniare.sori.studio
APP_BASE_URL=https://uniare.sori.studio
RESEND_API_KEY=Resend_API_Key
RESEND_FROM_EMAIL=UNIARE <account@인증된_발신도메인>
STATS_API_TOKEN=대시보드용_긴_랜덤_문자열
```
Resend에서 소유한 도메인 또는 발신용 하위 도메인을 먼저 추가하고 DNS 인증을 완료해야 한다. API Key에는 메일 발송 권한이 필요하며 `.env` 외에는 기록하지 않는다.
Docker 실행: `docker compose -f docker-compose.prod.yml up -d --build`
상태 확인: `docker compose -f docker-compose.prod.yml ps`
API 확인: `curl -fsS http://localhost:44082/api/v1/health`
운영 지표 확인: `curl -fsS http://localhost:44082/api/stats`
정상이면 `{"ok":true,"version":"4.0.16"}`이 표시된다.
이메일 기능 확인:
1. 실제 수신 가능한 주소로 신규 가입한다.
2. 인증 메일 링크를 누르기 전 로그인이 차단되고 로그인 화면에 `인증 메일 다시 보내기`가 표시되는지 확인한다.
3. 로그인 화면에서 재발송 후 60초 대기와 최초 발송 포함 월 3회 안내가 표시되는지 확인한다.
4. 인증 후 기본 한국어 완료 안내와 로그인이 정상 동작하는지 확인한다.
5. 로그인 화면의 `비밀번호를 잊으셨나요?`와 비밀번호 찾기·재설정 화면이 선택 언어로 표시되는지 확인한다.
6. 같은 계정에서 인증·재설정 메일을 합산해 월 3회 초과 시 관리자 문의 안내가 표시되는지 확인한다.
7. 관리자 화면에서 미인증 회원의 `이메일 인증 승인` 버튼으로 수동 승인 후 로그인되는지 확인한다.
회원 탈퇴 확인:
1. 일반 회원 프로필 최하단의 작은 `회원 탈퇴` 문구를 눌러 모달이 열리는지 확인한다.
2. `DELETE`를 정확히 입력하기 전에는 `YES` 버튼이 비활성화되는지 확인한다.
3. 탈퇴 후 로그아웃되고 같은 계정으로 로그인할 수 없는지 확인한다.
4. 탈퇴 회원의 덱은 삭제되지 않고 작성자가 `익명`으로 표시되는지 확인한다.
5. 관리자 회원 수정 모달에서 다른 회원을 탈퇴 처리할 수 있고, 자신의 관리자 계정은 삭제할 수 없는지 확인한다.
6. 관리자 덱 관리에서 복수 선택·현재 목록 전체 선택·일괄 삭제와 썸네일 정리가 동작하는지 확인한다.
NAS 리버스 프록시는 `https://uniare.sori.studio`을 `http://<NAS_IP>:44082`로 연결한다.
## 3. 정적 파일 업데이트
이미지, 카드 데이터, HTML, CSS, 프론트 JavaScript는 Docker 이미지를 다시 빌드하지 않아도 된다.
### 이미지만 변경
NAS 프로젝트 폴더에서 `git pull --ff-only`와 `git lfs pull`을 실행한 뒤 브라우저를 강력 새로고침한다. 컨테이너 재시작은 필요 없다.
### `datas/*.js` 변경
NAS 프로젝트 폴더에서 다음 명령을 실행한다.
```bash
git pull --ff-only
git lfs pull
docker compose -f docker-compose.prod.yml restart uniare-api
```
API가 카드 데이터를 메모리에 저장하므로 `datas/` 변경 시 API만 재시작한다. 이미지와 `datas/*.js`를 함께 변경한 경우도 같은 절차를 사용한다.
확인할 내용:
- `imgSrc`와 실제 이미지 파일명이 같은가?
- 파일명 대소문자가 같은가?
- 신규 시리즈가 번역 파일 3개에 등록되어 있는가?
- 카드 목록과 저장 덱에서 모두 정상 표시되는가?
### HTML·일반 CSS·프론트 JavaScript 변경
NAS는 npm이 없어도 된다. `style/output.css`는 로컬에서 빌드해 Git에 포함되므로 **`git pull`만 하면** 스타일이 반영된다.
```bash
git pull --ff-only
git lfs pull
```
배포 후 확인 (NAS 프로젝트 폴더):
```bash
wc -c style/output.css # 약 55000 (55KB) — 44000대면 구버전, pull 누락
head -1 login.html # output.css?v= 버전 쿼리 포함 여부
```
브라우저에서도 `개발자 도구 → Network → output.css`·`deck-favorites.js` 크기·상태를 확인한다. Cloudflare를 쓰면 구 파일이 `immutable`로 남을 수 있어 HTML에 `?v=` 쿼리를 붙인다 (v4.0.6+ CSS, v4.0.7+ 덱 페이지 JS).
`script/deck-favorites.js`는 `window.UaDeckFavorites.init`이 있어야 한다. 없으면 `git pull` 누락 또는 JS CDN 캐시다.
`nginx/default.conf`를 변경했다면 **반드시** 웹 컨테이너를 재생성한다 (볼륨 마운트 반영):
```bash
docker compose -f docker-compose.prod.yml up -d --build uniare-web
```
배포 검증 (NAS 프로젝트 폴더):
```bash
# 호스트 파일 — v4.0.8·shim 포함 여부
rg "4\.0\.8|deck-favorites-shim" deckHistory.html
# 컨테이너가 같은 파일을 보는지 (다르면 compose 경로/마운트 문제)
docker exec uniare-web rg "deck-favorites-shim|4\.0\.8" /usr/share/nginx/html/deckHistory.html
# Docker 직접 접속 (Cloudflare 우회) — 위와 같아야 함
curl -s http://127.0.0.1:44082/deckHistory.html | rg "deck-favorites-shim|4\.0\.8"
curl -s http://127.0.0.1:44082/script/deck-favorites.js | tail -1
```
호스트·컨테이너·`curl 127.0.0.1`은 최신인데 `https://uniare.sori.studio`만 구버전이면 **Cloudflare 캐시 Purge**가 필요하다.
HTML·`style/`·`script/`만 바뀐 경우 API 컨테이너 재시작은 필요 없다.
이메일 기능은 API와 Compose 환경 변수가 함께 바뀌므로 `git pull`만으로 반영되지 않는다. 반드시 `.env` 설정 후 전체 스택을 다시 빌드한다.
## 4. 전체 업데이트
`api/`, `nginx/`, Dockerfile, Compose 설정을 변경했다면 NAS 프로젝트 폴더에서 다음 명령을 실행한다.
```bash
git pull --ff-only
git lfs pull
docker compose -f docker-compose.prod.yml up -d --build
```
상태 확인은 `docker compose -f docker-compose.prod.yml ps`, API 확인은 `curl -fsS http://localhost:44082/api/v1/health`를 사용한다.
`.env`를 변경했다면 `docker compose -f docker-compose.prod.yml up -d --force-recreate`로 컨테이너를 재생성한다.
## 5. CSS 빌드
Tailwind 클래스를 새로 추가했을 때만 로컬에서 `npm install`과 `npm run build:css`를 실행한다.
생성된 `style/output.css`도 Git에 포함한다.
이미지, 카드 데이터, 기존 CSS만 수정했다면 실행하지 않아도 된다.
## 6. 자주 쓰는 운영 명령
- 전체 재시작: `docker compose -f docker-compose.prod.yml restart`
- API만 재시작: `docker compose -f docker-compose.prod.yml restart uniare-api`
- 로그 확인: `docker compose -f docker-compose.prod.yml logs -f`
- 종료: `docker compose -f docker-compose.prod.yml down`
- 다시 실행: `docker compose -f docker-compose.prod.yml up -d`
## 7. 백업
DB는 Docker 볼륨의 `/data/uadeck.db`에 있다.
덱 미리보기와 아바타는 `api/data/previews/`, `api/data/avatars/`에 있다.
DB 백업:
```bash
mkdir -p backup
BACKUP_FILE="uadeck-$(date +%Y%m%d-%H%M%S).db"
docker exec uniare-api node --input-type=module -e "import Database from 'better-sqlite3'; const db = new Database('/data/uadeck.db'); await db.backup('/data/$BACKUP_FILE'); db.close();"
docker cp "uniare-api:/data/$BACKUP_FILE" "./backup/$BACKUP_FILE"
docker exec uniare-api rm "/data/$BACKUP_FILE"
```
미리보기와 아바타 백업: `tar -czf "./backup/uploads-$(date +%Y%m%d-%H%M%S).tar.gz" api/data/previews api/data/avatars`
`.env`는 Git이 아닌 접근 제한된 별도 위치에 보관한다.
## 8. 롤백
문제가 발생하면 DB를 먼저 백업하고 `git log --oneline -10`으로 이전 정상 커밋을 확인한다.
```bash
git checkout <정상_커밋_해시>
git lfs pull
docker compose -f docker-compose.prod.yml up -d --build
```
API 확인: `curl -fsS http://localhost:44082/api/v1/health`
최신 `main`으로 돌아갈 때는 `git checkout main`과 `git pull --ff-only`를 실행한다.
## 9. 문제가 생겼을 때
웹이 열리지 않으면 `docker compose -f docker-compose.prod.yml ps`와 `docker compose -f docker-compose.prod.yml logs --tail=100`을 확인한다.
로그인이나 저장이 안 되면 `curl -fsS http://localhost:44082/api/v1/health`로 API 상태를 확인한다.
카드 이미지가 보이지 않으면 `git lfs pull`을 실행하고 `datas/*.js`의 `imgSrc`와 실제 이미지 파일명을 비교한다.
## 완료 확인
- `uniare-web`, `uniare-api`가 실행 중인가?
- API가 `ok: true`를 반환하는가?
- 홈과 카드 이미지가 열리는가?
- 로그인과 덱 저장이 동작하는가?
- `.env`와 DB가 Git에 포함되지 않았는가?

View File

@@ -1,44 +1,256 @@
# 의사결정 이력
## 2026-03-30 / 문서 체계 초기화
- 배경: 프로젝트 루트에 AI 작업 규칙은 있었지만 `docs/` 실체가 없어 규칙과 실제 운영 상태가 분리되어 있었다.
- 결정: 규칙 문서가 요구하는 핵심 문서 6종을 먼저 생성하고, 이후 변경 시 이 파일들을 기준 문서로 유지한다.
- 기대 효과: 작업 내역, 기술 구조, 화면-파일 연결, 향후 할 일을 한곳에서 추적할 수 있다.
## 2026-10-24 v4.0.28 — Re:제로 EX17BT와 기존 시리즈 이미지를 함께 보강
## 2026-03-30 / 이미지 관리 방식 1차 판단
- 배경: `images/` 디렉터리 용량이 약 `1.7G`, 파일 수가 `9,370개` 수준으로 증가했다.
- 관찰:
- 개별 파일이 아주 크지 않아도 Git 일반 객체로 누적되면 clone 및 fetch 비용이 계속 증가한다.
- 이미지 추가가 잦은 프로젝트 특성상 저장소 히스토리 비대화가 반복될 가능성이 높다.
- 결정:
- 신규 이미지 유입 관리 목적이라면 `Git LFS` 도입을 권장한다.
- 단, 이미 커진 저장소를 즉시 가볍게 만드는 해결책으로 `Git LFS`만 기대해서는 안 된다.
- 히스토리 크기까지 줄여야 하면 `git lfs migrate` 또는 자산 저장소 분리/CDN 이전을 별도 의사결정으로 다룬다.
- 보류 사유:
- 원격 저장소의 LFS 지원 여부, 무료 용량, 대역폭 정책을 아직 확인하지 않았다.
- 기존 협업자들의 개발 환경에 `git lfs` 설치를 요구하게 되므로 도입 공지가 필요하다.
💡 신규 카드를 제공하면서 이미지가 준비된 기존 패러렐 판본도 같은 배포 단위로 보강하기 위함.
🔧 `ua40` EX17BT 데이터 116건·이미지 81개와 `ua50`·`ua54`·`ua57` 패러렐 이미지 87개, `ua54` UAPR 판본 1건을 반영.
## 2026-03-30 / 이미지 히스토리 전체 LFS 마이그레이션 결정
- 배경: 목적이 "앞으로 예방"이 아니라 "기존 저장소 용량 절감"으로 명확해졌다.
- 확인:
- 로컬 환경에 `git lfs`가 설치되어 있다.
- 원격 저장소에 LFS 엔드포인트가 설정되어 있다.
- 결정:
- `images/**` 전체 히스토리를 `Git LFS`로 마이그레이션한다.
- 현재 작업 중인 `ua27` 데이터 및 이미지 추가분도 함께 반영한다.
- 영향:
- 기존 커밋 해시가 모두 재작성된다.
- 원격 반영 시 강제 푸시가 필요하다.
- 협업 중인 다른 클론은 재동기화 절차가 필요하다.
## 2026-10-12 v4.0.27 — UA57 패러렐 데이터는 이미지보다 먼저 반영
## 2026-03-30 / 새 루트 커밋 기준 저장소 재시작 결정
- 배경: `images/**`는 LFS 포인터로 전환됐지만, 저장소 자체는 과거 Git 객체 영향으로 여전히 비대했다.
- 결정:
- 현재 로컬 작업본을 단일 기준 스냅샷으로 보고 새 루트 커밋을 만든다.
- 원격 `main`은 새 루트 커밋으로 교체한다.
- 기존 히스토리는 로컬 백업 브랜치에서만 보존한다.
- 새 버전 체계는 `v3.0.0`부터 다시 시작한다.
- 이미지 자산은 새 시작 이후에도 `Git LFS` 정책을 유지한다.
- 이유:
- 가장 확실하게 Git 일반 히스토리 용량을 줄일 수 있다.
- 추가적인 서버 GC 상태나 숨은 과거 객체를 더 추적하지 않고도 정리 목적을 달성할 수 있다.
💡 기존 EX16BT 반영 방식과 일관되게 확보된 일반 카드 이미지부터 제공하기 위함.
🔧 UA57 데이터·3개 언어 번역·일반 이미지와 확보한 EX16BT 패러렐 이미지를 함께 반영하고 누락 이미지는 후속 작업으로 기록.
## 2026-09-22 v4.0.26 — EX16BT 패러렐은 데이터 선반영
💡 패러렐 이미지는 후속 확보 대상이지만 카드 번호와 판본 정보는 덱 구성 데이터에 먼저 필요하기 때문.
🔧 `ua31`에 EX16BT/MMM-2와 관련 프로모 데이터를 추가하고, 일반 이미지만 먼저 커밋 대상으로 반영.
## 2026-09-15 v4.0.25 — 누락 이미지는 실제 카드 파일로 재검증
💡 임시 파일명 보정만으로는 카드별 이미지 정합성을 보장할 수 없기 때문.
🔧 `UA56BT/TTG-1-015` 실제 이미지를 교체하고 데이터 참조 파일 존재 검증을 다시 수행.
## 2026-09-15 v4.0.24 — 신규 시리즈와 기존 시리즈 보강을 같은 배포 단위로 반영
💡 신규 시리즈 노출과 기존 BLEACH 누락 카드 보강은 모두 카드 검색·덱 구성 데이터 정합성에 영향을 주기 때문.
🔧 `ua56` 데이터·이미지·번역을 추가하고 `ua08` BLC-4 및 관련 누락 카드와 이미지를 함께 보강.
## 2026-08-12 v4.0.23 — 운영 지표는 집계 API로만 공개
💡 통합 대시보드에서 변화를 감지하되 개인 정보와 상세 덱 목록을 외부로 내보내지 않기 위함.
🔧 `/api/stats`와 `/api/v1/stats`에 회원·덱·카드 집계값을 제공하고, `STATS_API_TOKEN` 설정 시 토큰 인증을 적용.
## 2026-07-03 v4.0.21 — 인증 rate limit은 IP 기준 + 프록시 신뢰 1홉
💡 API가 nginx 뒤에만 노출되므로 `trust proxy=1`로 X-Forwarded-For를 신뢰해야 컨테이너 IP가 아닌 실제 클라이언트 IP로 제한이 걸림.
🔧 로그인은 10회/10분으로 좁게, 이메일·계정 쓰기는 30회/10분으로 두고, health 버전은 배포 누락 방지를 위해 package.json에서 읽음.
## 2026-07-03 v4.0.20 — 원본 이미지 변환 전 저비용 전송 최적화부터 적용
💡 1.8GB PNG의 WebP 일괄 변환은 파이프라인 부담이 커, 리스크가 낮고 효과가 큰 gzip·lazy-load·캐시부터 선적용하기 위함.
🔧 nginx gzip(텍스트 한정) + `cardList` 이미지 lazy-load + 이미지 캐시 30d로 연장, 원본 WebP 변환은 후속 과제로 분리.
## 2026-07-02 v4.0.19 — 덱 목록은 무한 스크롤 + WebP 썸네일 이원화
💡 페이지네이션 대신 무한 스크롤로 초기 DOM 부담을 줄이되, 원본 1200px PNG를 목록마다 내려받는 대역폭 낭비를 없애기 위함.
🔧 목록은 420px WebP 썸네일을 별도 저장(`<code>.thumb.webp`)해 우선 사용하고 확대 시에만 원본 PNG 사용, 썸네일 인코딩은 브라우저 canvas로 처리하고 미지원 시 원본으로 폴백.
## 2026-06-25 v4.0.17 — 공유 덱 카드는 imgSrc를 기준으로 복원
💡 동일 `Number`의 일반·패러렐·프로모가 공존해 번호만으로는 원본 카드를 안정적으로 식별할 수 없기 때문.
🔧 덱 저장 payload에 `imgSrc`를 필수 포함하고 서버 복원은 `imgSrc` 우선, 레거시 데이터만 `number`로 보조 복원.
## 2026-06-25 v4.0.16 — 탈퇴 계정의 덱은 익명 콘텐츠로 보존
💡 공개 덱을 참고하거나 즐겨찾기한 다른 사용자의 이용 흐름을 탈퇴로 끊지 않기 위함.
🔧 계정·개인 인증정보·아바타만 삭제하고 덱은 익명 유지, 관리자는 필요 시 복수 선택해 별도 삭제.
## 2026-06-25 v4.0.15 — 미인증 복구 동선은 로그인 화면에서 제공
💡 인증 메일을 잃어버린 사용자가 별도 화면을 찾아가지 않고 로그인 실패 지점에서 바로 복구하게 하기 위함.
🔧 미인증 오류 코드를 프론트에 유지하고 재발송·월 3회 제한·관리자 문의를 표시, 관리자는 미인증 계정을 수동 승인.
## 2026-06-25 v4.0.14 — 계정별 자동 메일은 월 3회로 제한
💡 무료 메일 한도를 소수 계정의 반복 요청이 소진하지 않도록 보호하기 위함.
🔧 한국 시간 월 기준으로 인증·재설정 메일을 합산하고, 4회째부터 관리자 이메일 문의를 안내.
## 2026-06-25 v4.0.13 — 인증 요청은 화면 잠금과 서버 제약을 함께 적용
💡 메일 전송 지연 중 반복 클릭과 정적 API 클라이언트 캐시 불일치를 동시에 방지하기 위함.
🔧 인증 JS에 버전 쿼리·직접 호출 폴백을 적용하고 제출 중 잠금, 스피너, 메일 60초 재요청 대기를 표시.
## 2026-06-25 v4.0.12 — 신규 가입부터 이메일 소유 확인 적용
💡 기존 회원을 잠그지 않으면서 허위 이메일 가입과 관리자 의존 비밀번호 초기화를 막기 위함.
🔧 기존 회원은 인증 완료로 이관하고 신규 회원은 Resend 인증 후 로그인, 재설정 토큰은 해시·1회용·30분 만료로 저장.
## 2026-06-24 v4.0.11 — 로그인 유지와 실제 접속 시각을 분리 관리
💡 브라우저 종료 후 로그인 유지 여부는 사용자가 선택하고, 접속 기록은 재로그인 없이도 실제 이용을 반영해야 하기 때문.
🔧 체크 시 로컬 저장소, 미체크 시 세션 저장소를 사용하고 `/auth/me` 성공 시 `last_login_at`을 갱신.
## 2026-06-23 v4.0.10 — 위험한 관리자 변경은 명시적 동작으로 제한
💡 역할 변경은 빈도가 낮고 영향이 커서 상시 노출보다 의도적 진입이 안전하기 때문.
🔧 관리자 회원 수정 모달의 역할 선택을 기본 숨김으로 두고 버튼 클릭 시에만 표시.
## 2026-06-23 v4.0.10 — 사용자 마지막 접속 시각을 운영 지표로 기록
💡 관리자 화면에서 실제 이용 활성도를 빠르게 판단할 기준이 필요하기 때문.
🔧 로그인 성공 시 `users.last_login_at`을 저장하고 회원 목록에 상대시간으로 표시.
## 2026-06-19 v4.0.9 — 관리자 회원 목록에 덱 이용률 표시
💡 사이트 이용 현황을 관리자가 한눈에 파악할 수 있어야 하기 때문.
🔧 회원 API에 `share_type='user'` 저장 덱의 공개·전체 수를 집계하고, 회원 카드를 2열 그리드로 배치.
## 2026-06-23 v4.0.5 — 공통 UI는 Tailwind components 레이어로 통합
💡 화면마다 분리된 CSS 파일을 유지하면 Tailwind 빌드 누락·로드 순서 문제가 반복되기 때문.
🔧 `app-shell`·`deck-history`·`profile`·`auth-header`를 `style/components.css`로 옮기고 페이지는 `output.css`만 로드.
## 2026-06-23 v4.0.4 — 덱 저장과 공개는 회원 기능으로 제한
💡 이미지 다운로드는 자유롭게 제공하되 서버 저장·공개 기능은 계정 기반 관리와 연결하기 위함.
🔧 비회원 저장 API를 차단하고 결과 화면은 로그인 후 저장할 수 있음을 명확히 안내.
## 2026-06-23 v4.0.3 — 검색 설명은 정적 기본 콘텐츠를 제공
💡 JavaScript 번역 로딩 전 빈 문구나 네트워크 오류가 검색 결과 설명으로 수집되는 것을 막기 위함.
🔧 메인 제목·설명 기본값과 SEO 메타데이터를 HTML에 기록하고 내부 오류 메시지는 사용자 안내로 대체.
## 2026-06-23 v4.0.3 — Tailwind 전환은 화면 단위로 진행
💡 여러 화면의 CSS를 동시에 바꾸면 시각 회귀 범위와 검수 부담이 지나치게 커지기 때문.
🔧 관리자와 인증 화면부터 전환하고 전용 CSS는 제거하며 공용 덱 카드 CSS는 해당 화면 차례까지 유지.
## 2026-06-23 v4.0.3 — 로그인 즐겨찾기는 계정 DB에 저장
💡 브라우저를 바꾸거나 데이터를 지워도 사용자의 즐겨찾기가 유지되어야 하기 때문.
🔧 계정별 `deck_favorites`를 추가하고 기존 로컬 항목은 유효한 덱만 최초 로그인 시 이전.
## 2026-06-23 v4.0.3 — 접근할 수 없는 즐겨찾기는 자동 정리
💡 삭제·만료·비공개 전환된 덱이 즐겨찾기 목록에 남아 빈 항목을 만들지 않게 하기 위함.
🔧 덱 삭제는 외래키 연쇄 삭제, 만료·타인 비공개 덱은 즐겨찾기 조회 시 제거.
## 2026-06-23 v4.0.2 — 덱 삭제와 미리보기 파일 수명주기를 연결
💡 DB에서 덱만 삭제하면 더 이상 참조되지 않는 PNG가 운영 스토리지에 계속 누적되기 때문.
🔧 덱 삭제 시 연결 PNG를 함께 지우고, 관리자는 과거 고아 파일을 조회·일괄 정리할 수 있게 구성.
## 2026-06-23 v4.0.2 — 관리자 덱 목록도 사용자 덱 카드 구조를 사용
💡 같은 덱 정보를 화면마다 다르게 표시하면 관리자가 시리즈·공개 상태·수정일을 비교하기 어렵기 때문.
🔧 사용자 덱과 같은 카드·배지·날짜 구조를 적용하고 관리자 삭제는 더보기 메뉴로 이동.
## 2026-06-23 v4.0.0 — SQLite 백업은 온라인 백업 API를 사용
💡 WAL 모드에서 DB 본파일만 복사하면 아직 병합되지 않은 최신 트랜잭션이 누락될 수 있기 때문.
🔧 운영 중에는 `better-sqlite3`의 `backup()`으로 일관된 백업본을 만든 뒤 컨테이너 밖으로 복사.
## 2026-06-23 v4.0.0 — 헤더 로고 규격을 공통 스타일로 관리
💡 페이지마다 로고 크기를 따로 지정하면 화면 이동 시 높이가 달라지고 수정 지점도 늘어나기 때문.
🔧 `style/brand.css`의 `.app-brand-logo`와 `--brand-logo-height`를 모든 헤더의 단일 기준으로 채택.
## 2026-06-23 v4.0.0 — 표시 브랜드는 UNIARE, 내부 식별자는 호환 유지
💡 사용자에게 보이는 제품명은 통일하되 기존 API 클라이언트와 저장 데이터의 호환성은 깨지 않기 위함.
🔧 화면·문서·다운로드 파일명은 `UNIARE`로 변경하고 `uadeck_token`, `uadeck.db`, `UaDeckApi`, `/api/v1`은 유지.
## 2026-06-23 v4.0.0 — 주요 화면 너비를 1280px로 통일
💡 페이지 이동 때 헤더와 본문 폭이 달라지는 시각적 흔들림을 줄이고 광고 레일을 안정적으로 배치하기 위함.
🔧 홈·편집·결과·프로필·덱 목록의 공통 최대 너비를 `80rem` 기준으로 정렬.
## 2026-06-22 v4.0.0 — Docker 서비스 이름을 `uniare-*`로 통일
💡 NAS에서 구·신규 컨테이너가 혼재할 때 포트 충돌과 운영 명령 실수를 줄이기 위함.
🔧 Compose 서비스·컨테이너·볼륨과 Nginx 프록시 대상을 `uniare-web`, `uniare-api`, `uniare-data`로 정렬.
## 2026-06-22 v4.0.0 — 운영 웹 컨테이너는 허용 자산만 읽기 전용 공개
💡 프로젝트 루트 전체를 웹 루트로 노출하면 `.env`, `.git`, `docs`, API 소스가 외부에 노출될 수 있기 때문.
🔧 HTML과 `script`, `style`, `i18n`, `datas`, `images`만 Nginx에 read-only 볼륨으로 연결.
## 2026-06-22 v4.0.0 — 공개 덱은 사용자 공개 설정을 기준으로 구성
💡 관리자 전용 프리셋보다 사용자가 직접 공개한 덱을 탐색하는 방식이 서비스 흐름에 더 자연스럽기 때문.
🔧 `/preset.html`과 `GET /api/v1/decks/presets`는 만료되지 않은 공개 사용자 덱을 최신순으로 제공.
## 2026-06-22 v4.0.0 — 덱 미리보기는 1200px PNG로 정규화
💡 브라우저와 옵션에 따라 저장 썸네일 크기가 달라지고 큰 base64 요청이 413 오류를 일으켰기 때문.
🔧 저장 이미지는 1200px 기준 PNG로 만들고 Nginx 요청 한도 25MB, Express JSON 한도 20MB를 적용.
## 2026-06-22 v4.0.0 — 즐겨찾기는 브라우저 로컬 상태로 우선 제공
💡 서버 스키마를 늘리지 않고도 공개 덱과 내 덱 탐색 편의를 빠르게 제공하기 위함.
🔧 공유 코드를 키로 `localStorage`에 저장하며 계정 간 동기화는 후속 과제로 남김.
## 2026-06-22 v4.0.0 — 정적 프론트를 유지하며 회원·덱 API를 추가
💡 기존 카드 데이터와 화면을 전면 재작성하지 않고 로그인, 소유권, 공유 기능을 확장하기 위함.
🔧 정적 HTML/JavaScript는 유지하고 Express·SQLite API와 Docker Compose 운영 구성을 추가.
## 2026-06-22 v4.0.0 — 덱 공유는 공유 코드와 REST API를 사용
💡 `?cards=<JSON>` 방식의 긴 URL과 중복 데이터를 없애고 공개 범위·작성자·수정 권한을 관리하기 위함.
🔧 DB에는 `{number,count,key}`만 저장하고 `GET /api/v1/decks/:shareCode` 응답에서 카드 메타를 합성.
## 2026-06-22 v4.0.0 — 회원 덱과 비회원 공유의 보존 기간을 분리
💡 계정 소유 덱은 영구 관리하고 익명 공유 데이터는 무기한 누적되지 않게 하기 위함.
🔧 회원 덱은 만료 없이 저장하고 비회원 덱은 공개 상태로 30일 뒤 만료되도록 구성.
## 2026-06-22 v4.0.0 — 프로필 이미지는 클라이언트에서 PNG로 정규화
💡 JPEG 등 원본 바이트를 `.png` 확장자로 저장해 브라우저 표시가 실패하는 문제를 막기 위함.
🔧 업로드 전에 Canvas로 PNG 변환·리사이즈하고 `updatedAt` 쿼리로 캐시를 갱신.
## 2026-06-17 v3.1.2 — 유니티켓은 기존 시리즈 데이터에 병합
💡 별도 시리즈를 만들기보다 동일 작품의 카드 번호 체계와 필터 흐름을 유지하는 편이 검색과 덱 구성이 일관되기 때문.
🔧 `ua03`, `ua06`, `ua10` 데이터와 이미지에 헌터×헌터·테일즈 오브 어라이즈·히로아카 유니티켓을 추가.
## 2026-06-15 v3.1.2 — 후속 부스터도 기존 시리즈 키를 재사용
💡 작품 단위 진입 구조를 유지하면서 신규 부스터와 기존 카드를 한 화면에서 다루기 위함.
🔧 마크로스 2nd는 `ua36`에 병합하고 무직전생 1st는 신규 `ua54` 데이터·이미지·번역 세트로 추가.
## 2026-05-26 v3.1.2 — 신규 시리즈 반영은 데이터·이미지·번역을 한 세트로 관리
💡 세 축 중 하나라도 빠지면 홈 노출, 카드 로딩, 이미지 표시 중 일부가 즉시 깨지기 때문.
🔧 `ua50`, `ua53` 추가와 `ua52` 병렬 이미지 보강을 같은 릴리즈 단위로 반영.
## 2026-05-16 v3.1.1 — 신규 시리즈와 기존 누락 자산을 함께 보강
💡 새 시리즈 배포 시 인접 시리즈의 확인된 누락 이미지도 함께 정리해 데이터 신뢰도를 유지하기 위함.
🔧 `ua52` 데이터·이미지·3개 언어명을 추가하고 `ua51` 병렬 이미지 누락분을 보강.
## 2026-04-19 v3.1.0 — 릴리즈 기준과 작업 인계 문서를 명시
💡 화면 버전과 실제 변경 이력이 어긋나 후속 작업자가 현재 배포 기준을 판단하기 어려웠기 때문.
🔧 `ua51` 릴리즈를 `v3.1.0`으로 정의하고 데이터·이미지·번역 검증 절차를 문서화.
## 2026-04-08 v3.0.1 — 카드 목록 상호작용은 기존 동작을 우선 보존
💡 구조 개선 과정에서 수량 조절과 카드 선택 UX가 달라져 기존 사용 흐름이 훼손되었기 때문.
🔧 변경된 목록 상호작용을 되돌리고 이후 UI 수정은 기존 동작 검증을 선행하도록 기준화.
## 2026-04-08 v3.0.1 — 카드 번호와 덱 집계는 동일 식별자를 기준으로 처리
💡 잘못된 카드 번호 하나가 패러렐 묶음과 덱 수량 계산까지 연쇄적으로 어긋나게 하기 때문.
🔧 `ua27` 카드 번호를 정정하고 동일 `Number` 그룹의 수량 표시를 다시 동기화.
## 2026-03-30 v3.0.1 — 데이터 경로는 실제 이미지 파일명을 기준으로 정합성 유지
💡 데이터의 `imgSrc`와 저장 파일명이 다르면 카드가 존재해도 화면에서는 누락으로 보이기 때문.
🔧 오기입 경로를 수정하고 누락 이미지 추가와 사용하지 않는 주석 데이터 제거를 함께 수행.
## 2026-03-30 v3.0.0 — 저장소를 현재 스냅샷 기준 새 루트로 재시작
💡 과거 대용량 이미지 객체로 비대해진 Git 이력을 가장 확실하게 줄이기 위함.
🔧 현재 작업본을 새 루트 커밋으로 삼고 버전은 `v3.0.0`부터 재시작하며 기존 이력은 로컬 백업 브랜치에 보존.
## 2026-03-30 v3.0.0 — 이미지 자산은 Git LFS로 관리
💡 수천 개 카드 이미지가 일반 Git 객체로 누적되는 비용을 줄이고 코드 이력과 대용량 자산 관리를 분리하기 위함.
🔧 `.gitattributes`에서 `images/**`를 LFS 대상으로 지정하고 신규 이미지도 같은 정책을 적용.
## 2026-03-30 v3.0.0 — 문서를 목적별 기준 파일로 분리
💡 구현 상태, 작업 로그, 결정 이유, 할 일을 한 문서에 섞지 않고 후속 작업자가 빠르게 맥락을 찾게 하기 위함.
🔧 `spec`, `update`, `history`, `todo`, `deploy`, `map`, `convention`을 역할별 기준 문서로 운영.

View File

@@ -1,50 +1,201 @@
# 파일-화면 매핑 가이드
# 파일-기능 지도
## `/`
- 진입 파일: `index.html`
- 화면 역할: 시리즈 선택 홈 화면
- 사용자 행동:
- 언어 선택
- 시리즈 카드 클릭
- 연결 파일:
- `i18n/translations.<lang>.json`: 제목, 설명, 시리즈명
- `script/navigation.js`: 공통 네비게이션 오버레이
- `style/output.css`: 공통 스타일
## 화면
## `/cardList.html?series=<seriesKey>`
- 진입 파일: `cardList.html`
- 화면 역할: 카드 검색, 필터링, 수량 선택, 키 카드 지정
- 사용자 행동:
- 검색어 입력
- 색상/타입/트리거 필터
- 카드 수량 조절
- 키 카드 지정
- 덱 결과 페이지 이동
- 연결 파일:
- `datas/<seriesKey>.js`: 현재 시리즈 카드 원본 데이터
- `images/<seriesKey>/`: 카드 이미지 원본
- `i18n/translations.<lang>.json`: 버튼/문구 번역
- `script/navigation.js`: 공통 네비게이션
```text
/
index.html
## `/result.html`
- 진입 파일: `result.html`
- 화면 역할: 선택한 덱 미리보기 및 이미지 저장
- 사용자 행동:
- 덱 이름 입력
- 출력 스타일 선택
- 고화질 저장 토글
- PNG 다운로드
- 연결 파일:
- `localStorage.selectedCards` 또는 `cards` 쿼리스트링: 덱 데이터 소스
- `images/<seriesKey>/`: 결과 이미지 렌더링 대상
- `i18n/translations.<lang>.json`: 결과 화면 번역
/cardList.html
cardList.html
## 공통 보조 파일
- `script/navigation.js`: 오버레이 메뉴 생성 및 이동 처리
- `i18n/i18n.js`: `data-i18n` 기반 공통 번역 적용
- `style/style.css`, `style/navigation.css`, `style/output.css`: 공통/페이지 스타일
/result.html
result.html
## 초보자용 이해 포인트
- 이 프로젝트는 프레임워크 앱이 아니라 HTML 파일 3개가 직접 화면 역할을 나눠 갖는 구조다.
- 카드 정보는 `datas/`에, 실제 카드 이미지는 `images/`에 따로 있다.
- 화면에서 보이는 한 장의 카드는 "데이터 파일의 객체 1개 + 이미지 폴더의 파일 1개" 조합으로 만들어진다.
/login.html
login.html
/signup.html
signup.html
/verify-email.html
verify-email.html
/forgot-password.html
forgot-password.html
/reset-password.html
reset-password.html
/profile.html
profile.html
/deckHistory.html
deckHistory.html
/preset.html
preset.html
/admin.html
admin.html
/d/<shareCode>
nginx/default.conf
nginx/uadeck.conf
```
## 프론트 공통 모듈
```text
API 요청
script/api.js
인증 폼 로딩·중복 제출 잠금·메일 재요청 대기
script/auth-form.js
인증 헤더
script/auth-header.js
style/components.css (@layer components)
페이지 공통 레이아웃·덱 카드·프로필·광고 레일
style/components.css
Tailwind 빌드 진입점
style/input.css
style/output.css
브랜드 로고 규격
style/brand.css
페이지 번역
script/page-i18n.js
i18n/translations.ko.json
i18n/translations.ja.json
i18n/translations.en.json
페이지 도움말
script/page-help.js
덱 공개 기본값
script/deck-prefs.js
덱 즐겨찾기
script/deck-favorites.js
계정 즐겨찾기 DB·API
api/src/db.js
api/src/routes/decks.js
덱 썸네일 확대
script/deck-preview-hold.js
덱 목록 무한 스크롤 페이저
script/deck-list-pager.js
이미지 PNG 변환
script/image-utils.js
광고 초기화
script/ads.js
```
## 페이지 전용 스타일
```text
관리자·로그인·회원가입
admin.html (회원 2열·역할 변경·이메일 승인·회원 탈퇴·덱 복수 선택 삭제), login.html (로그인 유지·미인증 메일 재발송·비밀번호 찾기), signup.html — Tailwind 유틸리티
내 덱·공개 덱·프로필
deckHistory.html, preset.html, profile.html (프로필 최하단 회원 탈퇴 모달) — HTML 클래스 + components.css
Tailwind 입력·산출물
style/input.css
style/output.css
```
## API
```text
서버 진입·헬스 체크
api/src/index.js
외부 통계 API
api/src/routes/stats.js
api/src/services/statsService.js
SQLite 스키마·관리자 시드
api/src/db.js
계정별 월간 이메일 발송 기록
api/src/db.js (`email_send_logs`)
계정 삭제 후 사용자명 재사용 회귀 테스트
api/test/account-deletion.test.js
회원 탈퇴·개인 데이터 삭제·덱 익명 보존
api/src/services/accountDeletion.js
JWT 인증·권한
api/src/middleware/auth.js
회원가입·이메일 인증·비밀번호 재설정·로그인·프로필·인증 세션 접속 시각 갱신
api/src/routes/auth.js
Resend 인증·비밀번호 재설정 메일
api/src/services/emailService.js
덱 저장·조회·공개 목록
api/src/routes/decks.js
회원·이메일 인증 승인·회원 탈퇴·덱 일괄 삭제 관리자 API
api/src/routes/admin.js
카드 메타 합성
api/src/services/cardResolver.js
아바타 저장
api/src/services/avatarImage.js
덱 미리보기 저장
api/src/services/previewImage.js
미참조 덱 미리보기 조회·정리
api/src/routes/admin.js
api/src/services/previewImage.js
```
## 데이터·자산
```text
카드 데이터
datas/uaXX.js
datas/uapr.js
카드 이미지
images/<seriesKey>/
브랜드·파비콘
images/assets/
favicon.ico
```
## 운영·외부 연동
```text
API 단독 Compose
docker-compose.yml
Nginx + API Compose
docker-compose.prod.yml
Nginx 이미지
nginx/Dockerfile
nginx/default.conf
외부 덱 API 클라이언트
integrations/uadeck-deck-client.js
외부 연동 문서
docs/api-integration.md
docs/widget.md
```

View File

@@ -1,44 +1,52 @@
# 기술 명세
## 1. 프로젝트 개요
- 프로젝트명: `UADECK V3.1 - Union Arena Deck Builder`
- 형태: 정적 HTML/CSS/JavaScript 기반 덱 빌더
- 패키지 의존성: `tailwindcss`, `@tailwindcss/cli`
- 빌드 산출 스타일: `style/output.css`
## 프로젝트 개요
## 2. 화면 구조
- 서비스명: `UNIARE - Union Arena Deck Builder`
- 현재 버전: `v4.0.28`
- 프론트: 정적 HTML, CSS, JavaScript
- 백엔드: Express, SQLite(`better-sqlite3`)
- 운영: Docker Compose 기반 Nginx + API
- 지원 언어: 한국어, 일본어, 영어
- 표시 브랜드는 `UNIARE`이며 `UaDeckApi`, `uadeck_token`, `uadeck.db` 등 내부 레거시 식별자는 호환을 위해 유지한다.
### `/index.html`
- 시리즈 선택 랜딩 페이지다.
- `i18n/translations.<lang>.json`을 직접 fetch하여 시리즈명과 소개 문구를 렌더링한다.
- 사용자가 시리즈를 선택하면 `cardList.html?series=<key>&seriesName=<name>&lang=<lang>`로 이동한다.
## 화면 기능
### `/cardList.html`
- 특정 시리즈의 카드 목록을 보여주고 덱을 편집한다.
- 쿼리스트링의 `series` 값을 기준으로 `./datas/${seriesKey}.js`를 동적 import 한다.
- 필터 기능:
- 검색어
- 색상(`All`, `Red`, `Blue`, `Green`, `Yellow`, `Purple`)
- 시리즈 하위 그룹
- 타입(`Basic`, `AP`, `Parallel`)
- 트리거(`All`, `Special`, `Color`, `Final`)
- 카드 수량 증감, 키 카드 지정, 초기화, 상세 오버레이, 결과 페이지 이동을 담당한다.
| 경로 | 기능 |
|---|---|
| `/` | 시리즈 선택, 언어 전환, 인증 헤더 |
| `/cardList.html?series=<key>` | 카드 검색·필터, 수량 조절, 키 카드 지정, 덱 편집 |
| `/cardList.html?d=<shareCode>` | API 공유 덱 불러오기 및 편집 |
| `/result.html` | 덱 미리보기, PNG 다운로드, 회원 덱 저장 |
| `/login.html` | 로그인 |
| `/signup.html` | 회원가입 |
| `/verify-email.html` | 가입 이메일 인증·인증 메일 재전송 |
| `/forgot-password.html` | 비밀번호 재설정 메일 요청 |
| `/reset-password.html?token=<token>` | 1회용 토큰으로 비밀번호 재설정 |
| `/profile.html` | 이메일·사용자명·아바타·비밀번호·덱 공개 기본값·회원 탈퇴 관리 |
| `/deckHistory.html` | 내 덱 검색·필터·이름 변경·공개 설정·삭제·불러오기 |
| `/preset.html` | 공개 사용자 덱 검색·필터·즐겨찾기·불러오기 |
| `/admin.html` | 회원과 덱 관리 |
| `/d/<shareCode>` | Nginx에서 `cardList.html?d=<shareCode>`로 연결 |
### `/result.html`
- 선택한 카드 목록을 미리보기하고 이미지로 저장한다.
- 스타일 모드:
- `type1`: 키 카드 강조형
- `type2`: 클래식 5열형
- `html-to-image` CDN을 사용해 덱 이미지를 PNG로 저장한다.
## 검색 노출
## 3. 데이터 구조
- 메인 검색 제목은 `UNIARE | 유니온 아레나 덱 빌더`를 사용한다.
- 메인 설명은 카드 검색, 덱 제작·저장·공유, 공개 덱과 즐겨찾기 기능을 안내한다.
- JavaScript 실행 전에도 메인 제목과 설명이 HTML에 표시된다.
- canonical, Open Graph, Twitter Card, WebSite·WebApplication 구조화 데이터를 제공한다.
- 번역 파일 로딩 실패 시 내부 오류 대신 새로고침 안내를 표시한다.
### 카드 데이터 파일
- 위치: `datas/*.js`
- 파일 수: 현재 `52개`
- 각 파일은 `export const cardList = [...]` 형태를 사용한다.
## 카드 데이터
- 데이터 위치: `datas/*.js`
- 실제 시리즈 데이터: `ua01`~`ua57` 중 현재 존재하는 파일과 `uapr.js`
- `ua40` Re:제로 EX17BT는 116개 판본을 등록하고 이미지 81개를 제공한다. 패러렐 중심 이미지 35개는 미확보 상태다.
- `ua54` 무직전생은 `UAPR/MST-1-054` UR 패러렐 판본을 포함한다.
- `ua57` 슈타인즈 게이트는 128개 판본과 이미지를 제공한다.
- 샘플 데이터: `ua99 sample.js`, `ua99 sample mini.js`
- 모듈 형식: `export const cardList = [...]`
### 카드 객체 스키마
```js
{
imgSrc: '/ua24/UA24BT_SHY-1-001.png',
@@ -49,53 +57,269 @@
parallel: false,
rarity: 'C',
trigger: null,
maxCount: 4,
}
```
### 필드 의미
- `imgSrc`: 원본 상대 식별자. 실제 렌더링 시 `images/${seriesKey}/${fileName}` 형태로 재구성한다.
- `Number`: 카드 번호. 동일 카드 판본 묶음과 필터 기준으로 사용한다.
- `count`: 현재 덱에 담긴 수량.
- `key`: 키 카드 여부.
- `color`: 카드 색상 또는 일부 특수 분류 값.
- `parallel`: 패러렐 여부.
- `rarity`: 희귀도 표기.
- `trigger`: `null`, `COLOR`, `FINAL`, `SPECIAL` 등 트리거 정보.
- `maxCount`: 일부 카드만 선택적으로 사용하며 기본값은 `4`로 동작한다.
- `Number`가 같은 판본은 동일 카드 수량 그룹으로 처리한다.
- `parallel`은 패러렐 판본 구분에 사용한다.
- `trigger`는 `null`, `COLOR`, `FINAL`, `SPECIAL` 값을 사용한다.
- `maxCount`가 없으면 기본 최대 수량은 4장이다.
## 4. 이미지 규칙
- 기본 위치: `images/<seriesKey>/`
- 저장 정책: `images/**`는 `Git LFS` 대상으로 관리한다.
- 렌더링 규칙:
- 카드 목록 화면: `images/${seriesKey}/${fileName}`
- 결과 화면: `/images/${seriesKey}/${fileName}`
## 이미지
- 카드 이미지: `images/<seriesKey>/<fileName>`
- 폴백 이미지: `/images/uapr/comingsoon.png`
- 현재 용량 현황:
- `images/` 약 `1.7G`
- 이미지 파일 약 `9,370개`
- 브랜드 자산: `images/assets/`
- `images/**`는 Git LFS로 관리한다.
- 카드 목록·덱 결과 화면에서 카드 이미지 로드 실패 시 폴백 이미지를 표시한다.
- 아바타와 덱 미리보기는 API 데이터 디렉터리에 저장하며 Git에 포함하지 않는다.
- 덱 저장 미리보기는 1200px 기준 PNG로 정규화한다.
- 덱 저장 시 목록용 축소 썸네일을 420px WebP(`<shareCode>.thumb.webp`)로 함께 생성한다. (브라우저 WebP 인코딩 미지원 시 썸네일 생략, 원본으로 폴백)
- 목록 화면(내 덱·공개 덱·관리자)은 썸네일을 우선 표시하고, 길게 눌러 확대할 때만 원본 PNG를 사용한다.
- 내 덱·공개 덱 목록은 클라이언트 무한 스크롤(청크 24개)로 렌더링한다.
- 덱 삭제·미참조 정리 시 해당 공유 코드의 원본 PNG와 썸네일 WebP를 함께 삭제한다.
- 관리자는 DB에서 참조하지 않는 미리보기 파일을 조회하고 일괄 삭제할 수 있다.
- nginx는 텍스트 자산(css/js/json/svg 등)을 gzip으로 압축 전송하고, 정적 이미지는 30일 immutable 캐시로 제공한다.
- nginx는 HTML에 `X-Content-Type-Options`/`X-Frame-Options`/`Referrer-Policy`, 정적 자산에 `X-Content-Type-Options: nosniff` 헤더를 적용한다.
- 카드 목록 화면의 카드 이미지는 `loading="lazy"`로 지연 로드한다.
- 인증 API는 IP 기준 rate limit을 적용한다. (로그인 10회/10분, 계정·이메일 쓰기 30회/10분) API는 nginx 프록시 1홉을 신뢰한다.
- API `/api/v1/health`는 `package.json` 버전을 반환한다.
## 5. 다국어
- 지원 언어: `ko`, `ja`, `en`
- 번역 파일:
- `i18n/translations.ko.json`
- `i18n/translations.ja.json`
- `i18n/translations.en.json`
- `index.html`은 번역 JSON을 직접 읽는다.
- 공통 텍스트 치환은 `i18n/i18n.js`의 `data-i18n` 기반 로직을 사용한다.
## 브라우저 상태
## 6. 상태 저장
- `localStorage.lang`: 현재 언어
- `localStorage.selectedCards`: 선택 카드 목록(JSON 문자열을 `encodeURIComponent` 처리)
- `localStorage.selectedSeries`: 일부 흐름에서 사용하는 선택 시리즈 값
| 키 | 용도 |
|---|---|
| `lang` | 선택 언어 |
| `selectedCards` | 편집 중인 덱 |
| `selectedSeries` | 현재 시리즈 |
| `uadeck_token` | JWT. 로그인 유지 선택 시 `localStorage`, 미선택 시 `sessionStorage` |
| `uadeck_user` | 로그인 사용자 캐시. 토큰과 같은 저장소 사용 |
| `uadeck.deckFavorites` | 비로그인 즐겨찾기 및 로그인 시 최초 이전 대상 |
## 7. 스타일 및 자산
- Tailwind 유틸리티 클래스와 페이지별 인라인 스타일을 혼용한다.
- 공통 스타일 파일:
- `style/output.css`
- `style/style.css`
- `style/navigation.css`
## 인증 및 권한
## 8. 알려진 제약
- `cardList.html`과 `result.html`의 스크립트가 페이지 내부에 크게 포함되어 있어 모듈 분리 전까지는 변경 영향 범위가 넓다.
- `script/navigation.js`의 일부 이동 대상 페이지는 현재 저장소에 존재하지 않는다.
- 이미지가 Git 일반 객체로 계속 누적되면 저장소 히스토리 크기가 지속 증가한다.
- 비밀번호는 bcrypt 비용 12로 해시한다.
- 신규 회원은 Resend 인증 링크로 이메일 소유 확인을 완료해야 로그인할 수 있다.
- 기존 회원은 이메일 인증 기능 배포 시 인증 완료 상태로 마이그레이션한다.
- 인증 링크는 24시간, 비밀번호 재설정 링크는 30분 동안 한 번만 사용할 수 있다.
- 인증·재설정 원문 토큰은 DB에 저장하지 않고 SHA-256 해시만 저장한다.
- 인증 메일 링크와 인증 화면은 버전 쿼리로 최신 HTML·API 클라이언트를 로드하고, 캐시된 구 클라이언트에서도 직접 인증 API를 호출한다.
- 인증 폼은 요청 중 제출 버튼을 잠그고 로딩 상태를 표시하며, 메일 재요청은 화면과 API에서 1분 간격으로 제한한다.
- 가입 인증·인증 재전송·비밀번호 재설정 메일은 계정별 한국 시간 기준 월 3회까지만 발송한다.
- 월간 한도 초과 시 자동 발송을 중단하고 관리자 이메일 `zenn@sori.studio` 문의를 안내한다.
- 미인증 사용자가 로그인하면 로그인 화면에서 인증 메일을 즉시 재전송할 수 있으며, 최초 발송 포함 월 3회 제한과 관리자 문의 방법을 함께 표시한다.
- 인증 완료 안내와 비밀번호 찾기·재설정 화면은 한국어를 기본값으로 한국어·일본어·영어 번역을 제공한다.
- 사용자명은 대소문자를 구분하지 않고 중복을 차단한다.
- 인증된 이메일은 프로필에서 직접 변경할 수 없다.
- JWT 기본 만료는 7일이며 `JWT_EXPIRES`로 변경할 수 있다.
- 로그인 유지 선택 시 브라우저를 닫아도 인증 정보를 유지하고, 미선택 시 현재 브라우저 세션에서만 유지한다.
- 인증된 화면 진입 시 `/auth/me` 검증과 함께 `last_login_at`을 갱신한다.
- `user`는 자신의 프로필과 덱만 수정·삭제할 수 있다.
- 일반 회원은 프로필 최하단에서 `DELETE` 확인 입력 후 직접 탈퇴할 수 있다.
- `admin`은 회원 상태·역할·비밀번호·탈퇴와 전체 덱을 관리할 수 있다.
- 관리자는 자신의 관리자 역할을 해제하거나 자신의 계정을 비활성화할 수 없다.
- 관리자는 현재 로그인한 자신의 계정을 관리자 화면에서 탈퇴시킬 수 없다.
- 비공개 덱은 소유자와 관리자만 조회할 수 있다.
- 비회원은 덱을 서버에 저장하거나 공개 링크를 생성할 수 없다.
- 로그인 사용자의 즐겨찾기는 계정 DB에 저장되어 브라우저와 기기가 달라도 유지된다.
- 비로그인 즐겨찾기는 브라우저에 저장하고 로그인 시 유효한 덱만 계정으로 이전한다.
- 계정 탈퇴 시 사용자·인증 토큰·메일 기록·즐겨찾기·아바타를 삭제한다.
- 탈퇴 회원의 저장 덱과 썸네일은 다른 사용자가 계속 참고할 수 있도록 유지하고 `user_id`만 `null`로 변경해 작성자를 익명 처리한다.
- 같은 사용자명으로 새로 가입해도 새 사용자 ID가 부여되어 기존 익명 덱이나 개인정보와 연결되지 않는다.
## 덱 저장 정책
- 공유 코드는 저장 시 생성되는 8자 코드다.
- 덱 저장과 공개 링크 생성은 로그인한 회원만 사용할 수 있다.
- 비회원은 덱 구성과 PNG 이미지 다운로드만 사용할 수 있다.
- 기존 비회원 공유 덱은 호환을 위해 원래 만료일까지 조회할 수 있다.
- DB의 `cards_json`에는 `{imgSrc, number, count, key}`를 저장한다.
- API 응답 시 `datas/<series>.js`에서 이미지·색상·희귀도·트리거 정보를 합성한다.
- 같은 `Number`에 여러 판본이 있을 경우 API는 저장된 `imgSrc`를 기준으로 카드 메타를 복원한다.
- 공개 덱 목록은 `share_type='user'`, `is_public=1`, 미만료 조건을 사용한다.
- `preset` 타입과 관리자 프리셋 API는 레거시 호환용으로 코드에 남아 있으며 현재 공개 덱 화면에서는 사용하지 않는다.
## 데이터베이스
### `users`
| 컬럼 | 설명 |
|---|---|
| `id` | 기본 키 |
| `email`, `username` | 고유 로그인·표시 식별자 |
| `password_hash` | bcrypt 해시 |
| `display_name` | 레거시 표시명, 현재 `username`과 함께 갱신 |
| `avatar_url` | 아바타 API 경로 |
| `role` | `user` 또는 `admin` |
| `is_active` | 계정 활성 상태 |
| `default_deck_public` | 덱 저장 기본 공개 여부 |
| `skip_deck_public_prompt` | 저장 시 공개 확인 생략 여부 |
| `last_login_at` | 마지막 로그인 또는 인증 세션 확인 시각 |
| `email_verified_at` | 이메일 인증 완료 시각. 기존 회원은 마이그레이션 시 채움 |
| `created_at`, `updated_at` | 생성·수정 시각 |
### `auth_tokens`
| 컬럼 | 설명 |
|---|---|
| `user_id` | 인증 대상 사용자 |
| `type` | `verify_email` 또는 `reset_password` |
| `token_hash` | 원문 토큰의 SHA-256 해시 |
| `expires_at`, `used_at` | 만료·사용 시각 |
| `created_at` | 발급 시각 |
### `email_send_logs`
| 컬럼 | 설명 |
|---|---|
| `user_id` | 발송 대상 사용자 |
| `type` | `verify_email` 또는 `reset_password` |
| `month_key` | 한국 시간 기준 `YYYY-MM` 월 |
| `status` | 동시 요청 방지용 `reserved`, 발송 완료 `sent` |
| `created_at`, `sent_at` | 예약·발송 시각 |
### `decks`
| 컬럼 | 설명 |
|---|---|
| `share_code` | 고유 공유 코드 |
| `share_type` | `user` 또는 레거시 `preset` |
| `content_hash` | 레거시 프리셋 중복 판별 |
| `user_id` | 소유 회원, 과거 비회원 덱은 `null` |
| `series`, `series_name` | 시리즈 키와 표시명 |
| `name` | 덱 이름 |
| `cards_json` | 최소 카드 구성 JSON |
| `is_public` | 공개 여부 |
| `view_count` | 상세 조회 횟수 |
| `expires_at` | 과거 비회원 공유 덱의 만료 시각 |
| `preview_image` | 저장된 미리보기 파일 정보 |
| `created_at`, `updated_at` | 생성·수정 시각 |
### `deck_favorites`
| 컬럼 | 설명 |
|---|---|
| `user_id` | 즐겨찾기 사용자, 사용자 삭제 시 함께 삭제 |
| `share_code` | 즐겨찾기 덱, 덱 삭제 시 함께 삭제 |
| `created_at` | 즐겨찾기 등록 시각 |
## API
### 공통
| Method | Path | 권한 | 기능 |
|---|---|---|---|
| GET | `/api/v1/health` | 공개 | API 상태와 버전 |
| GET | `/api/stats` | 공개 또는 토큰 | 외부 대시보드용 운영 지표 |
| GET | `/api/v1/stats` | 공개 또는 토큰 | 외부 대시보드용 운영 지표 |
`/api/stats`와 `/api/v1/stats`는 같은 응답을 반환한다. `.env`의 `STATS_API_TOKEN`이 비어 있으면 공개 집계 API로 동작하고, 값이 있으면 `Authorization: Bearer <token>` 또는 `X-Stats-Token: <token>` 헤더가 필요하다. 응답에는 개인정보 없이 집계값만 포함한다.
```json
{
"status": "online",
"ok": true,
"version": "4.0.27",
"lastUpdated": "2026-08-12T00:00:00.000Z",
"members": {
"total": 0,
"active": 0,
"inactive": 0,
"verified": 0,
"unverified": 0,
"created24h": 0,
"latestCreatedAt": null,
"latestLoginAt": null
},
"decks": {
"total": 0,
"userTotal": 0,
"presetTotal": 0,
"publicTotal": 0,
"privateTotal": 0,
"anonymousTotal": 0,
"created24h": 0,
"updated24h": 0,
"totalViews": 0,
"latestCreatedAt": null,
"latestUpdatedAt": null
},
"cards": {
"seriesTotal": 0,
"cardTypesTotal": 0,
"cardPrintsTotal": 0,
"parallelPrintsTotal": 0,
"bySeries": []
}
}
```
### 인증
| Method | Path | 권한 | 기능 |
|---|---|---|---|
| POST | `/api/v1/auth/register` | 공개 | 미인증 회원 생성·가입 인증 메일 발송 |
| POST | `/api/v1/auth/resend-verification` | 공개 | 가입 인증 메일 재전송 |
| POST | `/api/v1/auth/verify-email` | 공개 | 이메일 인증 토큰 확인 |
| POST | `/api/v1/auth/login` | 공개 | 로그인과 JWT 발급, `last_login_at` 갱신 |
| POST | `/api/v1/auth/forgot-password` | 공개 | 비밀번호 재설정 메일 요청 |
| POST | `/api/v1/auth/reset-password` | 공개 | 1회용 토큰으로 비밀번호 변경 |
| GET | `/api/v1/auth/me` | 회원 | 내 정보와 인증 세션 확인, `last_login_at` 갱신 |
| PUT | `/api/v1/auth/me` | 회원 | 프로필·덱 저장 설정 변경 |
| POST | `/api/v1/auth/me/password` | 회원 | 비밀번호 변경 |
| DELETE | `/api/v1/auth/me` | 회원 | `DELETE` 확인 후 본인 계정 탈퇴 |
| GET | `/api/v1/auth/avatar/:userId` | 공개 | PNG 아바타 |
### 덱
| Method | Path | 권한 | 기능 |
|---|---|---|---|
| GET | `/api/v1/decks/presets` | 공개 | 공개 사용자 덱 목록 |
| GET | `/api/v1/decks/:shareCode/preview` | 공개 | 덱 미리보기 PNG |
| GET | `/api/v1/decks/:shareCode/thumbnail` | 공개 | 덱 목록용 축소 WebP 썸네일 |
| GET | `/api/v1/decks/:shareCode` | 조건부 | 공개 덱 또는 소유 덱 상세 |
| GET | `/api/v1/decks` | 회원 | 내 덱 목록 |
| POST | `/api/v1/decks` | 회원 | 내 덱 저장과 공개 링크 생성 |
| PUT | `/api/v1/decks/:shareCode` | 소유자·관리자 | 덱 수정 |
| DELETE | `/api/v1/decks/:shareCode` | 소유자·관리자 | 덱 삭제 |
| GET | `/api/v1/decks/favorites` | 회원 | 내 즐겨찾기 코드 목록 |
| POST | `/api/v1/decks/favorites` | 회원 | 브라우저 즐겨찾기 일괄 이전 |
| PUT | `/api/v1/decks/favorites/:shareCode` | 회원 | 즐겨찾기 추가 |
| DELETE | `/api/v1/decks/favorites/:shareCode` | 회원 | 즐겨찾기 제거 |
### 관리자
| Method | Path | 기능 |
|---|---|---|
| GET | `/api/v1/admin/users` | 회원 목록·검색 (`deckPublic`, `deckTotal`, `lastLoginAt` 포함) |
| GET | `/api/v1/admin/users/:id` | 회원 상세 (`deckPublic`, `deckTotal`, `deckCount`, `lastLoginAt` 포함) |
| PUT | `/api/v1/admin/users/:id` | 회원 정보·역할·활성 상태 변경 |
| POST | `/api/v1/admin/users/:id/verify-email` | 미인증 회원 이메일 수동 승인 |
| POST | `/api/v1/admin/users/:id/reset-password` | 비밀번호 초기화 |
| DELETE | `/api/v1/admin/users/:id` | `DELETE` 확인 후 회원 탈퇴 처리 |
| GET | `/api/v1/admin/decks` | 전체 덱 목록 |
| POST | `/api/v1/admin/decks/bulk-delete` | 선택한 덱 최대 100개와 썸네일 일괄 삭제 |
| DELETE | `/api/v1/admin/decks/:shareCode` | 덱 삭제 |
| GET | `/api/v1/admin/previews/orphans` | 미참조 덱 미리보기 목록 |
| DELETE | `/api/v1/admin/previews/orphans` | 미참조 덱 미리보기 일괄 삭제 |
| POST | `/api/v1/admin/decks/preset` | 레거시 프리셋 생성 |
## 스타일
- 현재는 Tailwind 유틸리티, 공통 CSS, 페이지 전용 CSS, 일부 인라인 스타일을 혼용한다.
- 기존 CSS는 `todo.md`의 화면별 체크리스트 순서에 따라 점진적으로 Tailwind로 전환한다.
- 관리자와 로그인·회원가입 화면은 Tailwind 전환을 완료하고 전용 CSS를 제거했다.
- 관리자 탭·모달 상태는 JavaScript에서 `border-*`, `text-*`, `hidden`, `flex` 유틸리티를 전환한다.
- 덱 카드·툴바·프로필·앱 셸 스타일은 `style/components.css`(`@layer components`)에 통합되어 `output.css`로 빌드된다.
- `style/output.css`는 `style/input.css`에서 생성하는 빌드 산출물이다.
- 헤더 로고 크기는 `style/brand.css`만 기준으로 사용한다.
- 기본 글꼴은 Pretendard 계열을 우선 사용한다.
## 알려진 제약
- `cardList.html`, `result.html`의 인라인 스크립트가 커서 변경 영향 범위가 넓다.
- `admin.html`은 3개 언어 번역 적용이 완료되지 않았다.
- 외부 통계 API는 집계값만 제공하며, 상세 사용자·덱 정보는 포함하지 않는다.
- 현재 Compose의 컨테이너명(`uniare-api`, `uniare-web`)과 데이터 볼륨명(`uniare-data`)은 고정값이며 환경 변수화되지 않았다.
- Resend 발송은 운영 `.env`의 API 키·발신 주소와 Resend에서 인증된 발신 도메인이 필요하다.

View File

@@ -1,19 +1,36 @@
# 할 일 및 이슈
# 할 일
## 현재 이슈
- `images/` 디렉터리가 약 `1.7G`까지 증가해 저장소 clone, fetch, checkout 비용이 커졌다.
- 이미지 파일 수가 `9,000+` 단위라서 카드 추가 빈도가 올라갈수록 Git 객체 수와 히스토리 부담이 계속 누적될 가능성이 높다.
- `script/navigation.js`에는 현재 프로젝트에 없는 페이지(`deckHistory.html`, `preset.html`, `myDatabase.html`)로 이동하는 코드가 남아 있다.
- `cardList.html`과 `result.html` 내부 스크립트 비중이 커서 기능 확장 시 유지보수 난도가 올라갈 수 있다.
- 데이터 파일 네이밍과 샘플 파일(`datas/ua99 sample.js`, `datas/ua99 sample mini.js`) 처리 기준이 문서상 명확하지 않았다.
## 운영
## 다음 작업 제안
- 원격 저장소에서 `Git LFS` 업로드가 정상 완료되었는지 확인한다.
- 협업자가 있다면 히스토리 재작성 이후 동기화 방법을 공지한다.
- `Git LFS` 도입 후에도 저장소 운영 비용이 크면 이미지 저장소 분리/CDN 이전을 재검토한다.
- `cardList.html`의 인라인 스크립트를 별도 JS 모듈로 분리하는 리팩터링을 검토한다.
- 존재하지 않는 페이지 링크를 정리하거나, 향후 구현 예정이면 문서에 상태를 명확히 표기한다.
- NAS에서 `uniare-web`, `uniare-api` 스택의 재기동과 헬스 체크를 최종 확인한다.
- SEO 메타데이터 배포 후 Google Search Console에서 메인 URL 재색인을 요청한다.
- 운영 `.env`의 `JWT_SECRET`, 관리자 계정, CORS 허용 도메인을 실제 환경 기준으로 점검한다.
- Resend에서 발신용 하위 도메인을 인증하고 운영 `.env`의 `RESEND_API_KEY`, `RESEND_FROM_EMAIL`, `APP_BASE_URL`을 설정한 뒤 실제 수신을 확인한다.
- DB와 업로드 이미지(`api/data/previews`, `api/data/avatars`)의 정기 백업·복구 절차를 검증한다.
- 원격 저장소의 Git LFS 업로드 상태와 용량 정책을 확인한다.
- Compose의 컨테이너명과 데이터 볼륨명을 환경 변수로 관리할지 결정하고, 적용 시 운영 마이그레이션 절차를 작성한다.
## 판단 가이드
- 신규 이미지 추가 관리가 목적이면 `Git LFS` 추적만으로도 충분하다.
- 기존 clone 크기까지 줄이는 것이 목적이면 `Git LFS` 히스토리 마이그레이션 또는 자산 저장소 분리가 필요하다.
## 기능
- `admin.html`의 고정 한국어 문구를 `ko`, `ja`, `en` 번역 체계에 연결한다.
- 옆 프로젝트에 `integrations/uadeck-deck-client.js`를 적용하고 실제 공유 덱 조회를 검증한다.
## 유지보수
- UA40 EX17BT 미확보 이미지 35개(패러렐 34개, `EX17BT_REZ-1-029.png` 1개)를 확보해 추가한다.
- UA31 `UAPB_MMM-1-AP01.png` 이미지를 확보해 추가한다.
- 공개 덱·내 덱 목록에 서버 커서 페이지네이션 + 서버 검색을 도입한다(옵션 B). 즐겨찾기 필터는 별도 조회로 처리하고, `preset.html`·`deckHistory.html`의 클라이언트 전량 로드 전제를 걷어낸다. 회귀 위험이 커 데이터 규모가 커질 때 착수한다.
- 카드 원본 이미지(약 1.8GB PNG)를 WebP로 일괄 변환하고 참조 경로·폴백을 정비해 대역폭을 절감한다.
- 유난히 큰 개별 이미지(예: `ua44/…EVA-1-079_p2.png` 1.2MB, `ua14/UAPR_DST-1-039.png` 1.0MB, `logo_unionarena.png` 431KB)를 재압축한다.
- `cardList.html`, `result.html`의 대형 인라인 스크립트를 기능 단위 모듈로 분리한다.
- `datas/ua99 sample.js`, `datas/ua99 sample mini.js`의 보존 목적과 배포 제외 여부를 확정한다.
## 화면별 Tailwind 전환
- [x] 관리자 (`admin.html`)
- [x] 로그인·회원가입 (`login.html`, `signup.html`)
- [x] 내 덱 (`deckHistory.html` — `components.css` 빌드 포함)
- [x] 공개 덱 (`preset.html` — `components.css` 빌드 포함)
- [ ] 프로필 (`profile.html`, `style/profile.css` — 기존 CSS 유지)
- [ ] 카드 목록·덱 결과·메인 (`cardList.html`, `result.html`, `index.html` — 원래 Tailwind 기반, 레거시 CSS 정리만 검토)

View File

@@ -1,5 +1,603 @@
# 업데이트 이력
## 2026-10-24 / v4.0.28 — Re:제로 EX17BT 카드·패러렐 이미지 추가
### 수행 작업
- `ua40` Re:제로 EX17BT 카드 데이터 116건 추가
- EX17BT 카드 이미지 81개 추가
- `ua50` 이누야샤 패러렐 이미지 25개, `ua54` 무직전생 패러렐 이미지 29개 추가
- `ua54` `UAPR/MST-1-054` UR 패러렐 카드 데이터 추가
- `ua57` 슈타인즈 게이트 패러렐 이미지 33개 추가
- VERSION과 API 버전을 v4.0.28로 통일
### 검증
- `ua40` 브라우저 모듈 문법 검증 통과
- EX17BT 116개 판본 중 이미지 81개 존재, 미확보 35개 확인
- 신규 이미지 168개의 Git LFS 추적과 카드 데이터 참조 확인
- CSS 변경이 없어 CSS 빌드 생략
- API 테스트는 로컬 의존성(`bcryptjs`, `express`) 미설치로 실행하지 못함
## 2026-10-12 / v4.0.27 — 슈타인즈 게이트 추가·마도카 이미지 보강
### 수행 작업
- UA57 카드 데이터 128건, 일반 이미지 95개, 한국어·일본어·영어 시리즈명 추가
- 마도카 EX16BT 패러렐 이미지 36개 추가
- VERSION과 API 버전을 v4.0.27로 통일
- Git 원격 주소와 배포 문서의 저장소 주소를 새 경로로 갱신
### 검증
- 카드 데이터 모듈 로딩 및 번역 JSON 파싱 확인
- UA57 이미지 95개 존재 확인, 패러렐 이미지 33개 미확보
- UA31 참조 이미지 중 기존 `UAPB_MMM-1-AP01.png` 1개 누락 확인
- CSS 변경이 없어 CSS 빌드 생략, 운영 배포·브라우저 검증 미수행
## 2026-09-22 / v4.0.26 — 魔法少女まどか☆マギカ EX16BT 추가
### 수행 작업
- `ua31` 魔法少女まどか☆マギカ 카드 데이터에 EX16BT/MMM-2 카드와 관련 UAPR·UAPB 카드를 추가
- EX16BT/MMM-2 일반 카드 이미지와 관련 프로모 이미지를 `images/ua31`에 추가
- 패러렐 판본은 데이터 참조를 먼저 반영하고, 실제 이미지는 후속 추가 대상으로 유지
### 검증
- `datas/ua31.js` 문법 검증 통과
- EX16BT 일반 이미지와 관련 프로모 이미지 파일 존재 확인
## 2026-09-15 / v4.0.25 — UA56 이미지 보정
### 수행 작업
- `UA56BT/TTG-1-015` 카드 이미지를 실제 누락 이미지로 교체
### 검증
- UA56 카드 데이터 131건의 참조 이미지 파일 존재 확인 예정
## 2026-09-15 / v4.0.24 — UA56 카드 시리즈 추가·BLC 이미지 보강
### 수행 작업
- `ua56` 천원돌파 그렌라간 카드 데이터 131건 추가
- UA56 카드 이미지 131개 추가
- `ua08` BLEACH BLC-4 및 관련 누락 카드 데이터·이미지 보강
- 한국어·일본어·영어 시리즈명 번역 추가
### 검증
- 번역 JSON 3개 파싱 통과 예정
- 카드 데이터 참조 이미지 파일 존재 확인 예정
## 2026-08-12 / v4.0.23 — 운영 지표 API 추가
### 수행 작업
- 외부 대시보드용 `GET /api/stats`, `GET /api/v1/stats` 추가
- 회원·덱 집계와 카드 시리즈·고유 카드 번호·패러렐 포함 카드 수 집계 추가
- `.env`의 `STATS_API_TOKEN` 설정 시 Bearer 또는 `X-Stats-Token` 인증 적용
- 외부 대시보드 위젯 문서와 API 명세 갱신
### 검증
- 통계 API 라우트 테스트 추가
- API 테스트 전체 실행 예정
## 2026-08-09 / v4.0.22 — UA55 카드 시리즈 추가
### 수행 작업
- `ua55` 아이돌마스터 신데렐라 걸즈 카드 데이터 216건 추가
- UA55 카드 이미지 216개 추가
- 한국어·일본어·영어 시리즈명 번역 추가
### 검증
- 번역 JSON 3개 파싱 통과
- 카드 데이터 216건과 참조 이미지 216개의 파일 존재·대소문자 일치 확인
## 2026-07-03 / v4.0.21 — 정합성·보안·인덱스 개선
### 수행 작업
- `/api/v1/health` 응답과 시작 로그 버전을 `package.json` 기준으로 동적화(기존 `4.0.16` 고정값 제거)
- `express-rate-limit` 도입: 로그인 10회/10분, 계정·이메일 쓰기(회원가입·인증메일·이메일검증·비밀번호 찾기/재설정) 30회/10분(IP 기준)으로 제한
- API에 `trust proxy = 1` 설정으로 nginx 뒤에서 실제 클라이언트 IP 기준 제한 적용
- nginx 보안 헤더 추가: HTML은 `X-Content-Type-Options`/`X-Frame-Options`/`Referrer-Policy`, 정적 자산(css·js·이미지)은 `X-Content-Type-Options: nosniff`
- 공개 덱 목록 조회용 복합 인덱스 `idx_decks_public_list(share_type, is_public, updated_at)` 추가
### 검증
- 서버 모듈 `node --check` 및 auth 라우트 import 검증 통과
- nginx 설정은 표준 디렉티브만 사용(배포 시 `nginx -t` 권장)
## 2026-07-03 / v4.0.20 — 전송 최적화(gzip·lazy-load·캐시)
### 수행 작업
- nginx에 텍스트 자산 gzip 압축 추가(css/js/json/svg 등, `gzip_min_length 1024`, `comp_level 6`) — output.css·카드 데이터·HTML 전송량 약 70~80% 절감
- `cardList.html` 카드 그리드 이미지에 `loading="lazy" decoding="async"` 추가 — 시리즈 진입 시 전체(~90장) 즉시 다운로드 방지
- 정적 이미지 캐시 기간 `expires 7d → 30d`로 연장(내용 고정, immutable 유지)
- 미사용 `style/deck-history.css`(10.8KB) 삭제 (현재 어떤 화면도 참조하지 않음)
### 검증
- nginx 설정은 문법상 표준 디렉티브만 사용(배포 시 `nginx -t` 권장)
- `deck-history.css` 참조가 HTML/CSS/Dockerfile에 없음을 확인 후 삭제
## 2026-07-02 / v4.0.19 — 덱 목록 무한 스크롤·WebP 썸네일
### 수행 작업
- 공용 무한 스크롤 페이저 모듈 `script/deck-list-pager.js` 추가 (IntersectionObserver 기반, 청크 24개, 하단 근접 시 이어붙임, 미지원 시 전체 렌더 폴백)
- `preset.html`, `deckHistory.html` 덱 목록을 무한 스크롤로 전환해 대량 덱에서도 초기 DOM 렌더 부담 완화
- 즐겨찾기 토글 시 전체 리렌더 대신 해당 카드만 in-place 갱신해 스크롤 위치 유지(즐겨찾기 필터 모드에서는 리렌더 유지)
- 저장 시 클라이언트가 원본(1200px PNG)과 함께 목록용 축소 썸네일(420px WebP, 품질 0.82)을 생성·전송 (`result.html`)
- API가 썸네일을 `<shareCode>.thumb.webp`로 저장하고 `GET /decks/:shareCode/thumbnail` 및 응답 `thumbnail_url`/`thumbnailUrl` 제공
- 목록 화면(내 덱·공개 덱·관리자)은 썸네일 우선 표시, 길게 눌러 확대할 때만 원본 사용
- 덱 삭제·미참조 정리(orphan)가 원본 PNG와 썸네일 WebP를 함께 처리하도록 확장
### 검증
- 서버 모듈 `node --check` 문법 검증 통과 (previewImage.js, decks.js, admin.js)
- 프론트 lint 확인(신규/수정 파일 오류 없음, 기존 Tailwind 경고만 잔존)
## 2026-06-25 / v4.0.18 — 누락 이미지 폴백·공유 덱 매핑 보정
### 수행 작업
- 덱 저장 카드 payload를 `imgSrc` 필수로 확장하고 API 검증에서 `imgSrc` 누락 요청을 차단
- 덱 복원은 `imgSrc` 기준으로 정확 매핑하고, 기존 구데이터 호환을 위해서만 `number`를 보조 fallback으로 유지
- `cardList.html`, `result.html` 카드 렌더링에서 이미지 로드 실패 시 공통 폴백(`/images/uapr/comingsoon.png`)을 사용
- 카드 목록 확대 미리보기(오버레이)도 동일 폴백을 적용해 이미지 누락 시 깨짐 대신 뒷면 노출
- 관리자 덱 카드에 `불러오기` 버튼과 길게 눌러 썸네일 확대 미리보기를 복구하고, 체크박스 다중 선택 기능과 충돌하지 않도록 이벤트를 분리
- 관리자 회원 수정에서 별도 탈퇴 버튼을 제거하고 `역할 변경하기` 선택지에 `회원 탈퇴 처리`를 통합, 저장 시 2단계 확인 후 탈퇴 실행으로 변경
## 2026-06-25 / v4.0.16 — 회원 탈퇴·관리자 덱 일괄 삭제
### 수행 작업
- 프로필 최하단에 10px 연회색 일반 텍스트 형태의 `회원 탈퇴` 진입점 추가
- 탈퇴 모달에서 `DELETE` 확인 입력과 `YES`·`NO` 버튼 제공
- 본인 탈퇴 및 관리자 회원 탈퇴 API 추가, 현재 로그인한 관리자 본인 삭제 차단
- 탈퇴 시 계정·인증 토큰·메일 기록·즐겨찾기·아바타 삭제
- 탈퇴 회원의 저장 덱과 썸네일은 유지하고 작성자 연결만 제거해 익명 처리
- 관리자 덱 관리에 복수 체크·현재 목록 전체 선택·최대 100개 일괄 삭제 추가
- 계정 탈퇴 후 동일 사용자명 재가입과 익명 덱 분리, 본인·관리자 탈퇴 API 회귀 테스트 보강
## 2026-06-25 / v4.0.15 — 미인증 로그인 복구·인증 화면 다국어
### 수행 작업
- 미인증 계정 로그인 시 같은 화면에서 인증 메일 재발송 기능 표시
- 최초 발송 포함 월 3회 제한과 초과 시 `zenn@sori.studio` 관리자 문의 안내 추가
- 관리자 회원 화면에 미인증 상태와 `이메일 인증 승인` 기능 추가
- API 오류의 상태·코드를 프론트까지 전달해 미인증·월 한도 오류를 구분
- 인증 완료 후 로그인 화면에 선택 언어 기준 완료 메시지 표시, 기본 언어는 한국어로 보정
- `비밀번호를 잊으셨나요?`, 인증·비밀번호 찾기·재설정 화면을 한국어·일본어·영어 번역에 연결
- 계정 삭제 후 동일 사용자명 재가입 시 기존 덱·인증 정보가 연결되지 않는 DB 회귀 테스트 추가
## 2026-06-25 / v4.0.14 — 계정별 월간 메일 한도
### 수행 작업
- 가입 인증·인증 재전송·비밀번호 재설정 메일을 계정별 월 3회로 통합 제한
- 한국 시간 기준 월별 발송 기록을 `email_send_logs`에 저장
- 동시 요청은 발송 전 횟수를 예약해 월 한도 우회 방지
- Resend 발송 실패 시 예약을 취소해 무료 횟수가 잘못 차감되지 않도록 처리
- 한도 초과 시 `zenn@sori.studio` 관리자 문의 안내 반환
## 2026-06-25 / v4.0.13 — 인증 캐시·중복 제출 방지
### 수행 작업
- 이메일 인증 화면이 캐시된 구 `api.js`를 불러와 `verifyEmail`을 찾지 못하던 문제 수정
- 인증 메일 링크·인증 화면에 `v=4.0.13` 캐시 무효화와 직접 API 호출 폴백 적용
- 회원가입·로그인·인증 메일·비밀번호 찾기·재설정 요청 중 버튼 잠금과 로딩 스피너 표시
- 인증 메일과 비밀번호 재설정 메일 전송 후 60초 재요청 대기 표시
## 2026-06-25 / v4.0.12 — 이메일 인증·비밀번호 재설정
### 수행 작업
- 신규 회원가입 시 Resend 이메일 인증 링크 발송, 인증 완료 전 로그인 차단
- 인증 메일 재전송과 24시간·1회용 인증 토큰 적용
- 비밀번호 찾기·30분 만료 재설정 링크·사용자 직접 비밀번호 변경 화면 추가
- 이메일 형식 서버 검증과 사용자명 대소문자 무시 중복 검사 적용
- 기존 회원은 배포 시 이메일 인증 완료 상태로 마이그레이션
- Resend 환경 변수와 운영 배포·확인 절차 문서화
## 2026-06-24 / v4.0.11 — 로그인 유지·마지막 접속 갱신 개선
### 수행 작업
- 로그인 화면에 `로그인 유지` 선택 항목 추가
- 선택 시 인증 정보를 `localStorage`, 미선택 시 `sessionStorage`에 저장하도록 분리
- 기존 로그인 유지 사용자의 로컬 인증 정보 호환 유지
- 인증된 사용자의 `/auth/me` 세션 확인 성공 시 `last_login_at` 갱신
- 로그인 유지 문구를 한국어·일본어·영어 번역에 반영
## 2026-06-23 / v4.0.10 — 관리자 권한 변경 안전장치·접속 시간 표시
### 수행 작업
- `admin.html` 회원 수정 모달에서 역할 선택 UI를 기본 숨김으로 전환하고, `역할 변경하기` 버튼을 눌렀을 때만 노출
- 회원 카드에 마지막 접속 상대시간(`분/시간/일/달 전`) 표시 추가
- `users` 스키마에 `last_login_at` 컬럼 추가(마이그레이션 포함), 로그인 성공 시 마지막 접속 시각 기록
- `GET /api/v1/admin/users`, `GET /api/v1/admin/users/:id` 응답에 `lastLoginAt` 포함
- `login.html`에 비밀번호 분실 시 `zenn@sori.studio` 문의 안내 문구 추가
## 2026-06-19 / v4.0.9 — 관리자 회원 목록 2열·덱 이용 통계
### 수행 작업
- `admin.html` 회원 카드: `sm:grid-cols-2` 2열 그리드, 카드 패딩·아바타 크기 축소
- 회원별 저장 덱 수 `공개/전체` 형식 표시 (관리자 전용)
- `GET /api/v1/admin/users`: `deckPublic`, `deckTotal` 필드 추가 (`share_type='user'` 기준)
- `GET /api/v1/admin/users/:id`: `deckPublic`, `deckTotal` 분리 반환
- `admin.html` `output.css?v=4.0.9`, `npm run build:css` 재실행
## 2026-06-23 / v4.0.8 — nginx 정적 캐시·deck-favorites 배포 수정
### 수행 작업
- 운영 HTML이 v4.0.6·JS가 CDN `immutable` 구버전으로 남아 `init` 오류 지속
- `docker-compose.prod.yml`: `nginx/default.conf` 볼륨 마운트 추가
- nginx: HTML·`deck-favorites.js`·`api.js` `no-cache`, JS `immutable` 제거
- `script/deck-favorites-shim.js`: 구버전 JS에 `init`/`forget` 폴리해
- `docs/deploy.md`: 호스트 vs 컨테이너 vs Cloudflare 3단계 검증 명령 추가
## 2026-06-23 / v4.0.7 — 덱 목록 JS 캐시·init 오류 수정
### 수행 작업
- 운영 `deck-favorites.js` 구버전( `init` 없음) + CDN JS 캐시로 `UaDeckFavorites.init is not a function` 발생 → 덱 로드 중단
- `deckHistory.html`·`preset.html`: 스크립트에 `?v=4.0.7` 캐시 무효화, `init` 없을 때도 `loadDecks` 실행되도록 방어 코드
## 2026-06-23 / v4.0.6 — CSS 캐시·배포 반영 수정
### 수행 작업
- 운영 확인: `login.html`은 갱신됐으나 `output.css`가 구버전(44KB) + CDN 7일 immutable 캐시로 Tailwind 클래스 미적용
- 로그인·회원가입·내 덱·공개 덱·관리자: `output.css?v=4.0.6` 쿼리로 캐시 무효화
- `nginx/default.conf`: CSS는 `must-revalidate` 1시간, JS·이미지는 기존 7일 유지
- NAS 배포 시 `git pull` 후 `wc -c style/output.css`로 약 55KB 확인 (`docs/deploy.md`)
## 2026-06-23 / v4.0.5 — 로그인·덱·관리자 Tailwind 레이아웃 통합
### 수행 작업
- `style/components.css` 신설: 앱 셸·인증 헤더·덱 카드 스타일을 `@layer components`로 빌드에 포함
- `style/input.css`: `brand.css`·`components.css` import, `@source`로 HTML·JS 스캔
- 로그인·회원가입·관리자: `app-page`/`app-nav`/`app-shell` + Tailwind 유틸리티로 레이아웃 정리
- 내 덱·공개 덱: `output.css` 단일 로드 (AdSense 레일 유지)
- `cardList`·`result`·`index`·`profile` 등 기존 페이지는 변경 없음
## 2026-06-23 / v4.0.4 — Tailwind 빌드·로그인/관리자 레이아웃 수정
### 수행 작업
- `style/input.css`: `@source`로 HTML·JS만 스캔하도록 지정 (`images/`·`datas/` 제외) — arbitrary 클래스가 `output.css`에 누락되던 문제 해결
- `focus:ring-3`(미지원) → `focus:ring-2`로 수정
- `npm run build:css` 재실행
- `docs/posts/` gitignore 처리 및 프로젝트 문서에서 posts 참조 제거
## 2026-06-23 / v4.0.4 — 비회원 덱 저장 제한
### 수행 작업
- 비회원은 덱 구성과 PNG 이미지 다운로드만 이용하도록 정책 변경
- `POST /api/v1/decks`에 필수 인증 적용
- 결과 화면의 비회원 저장 버튼을 `로그인 후 덱 저장`으로 안내
- 기존 비회원 공유 덱은 만료일까지 조회 호환 유지
- 서비스 소개 글과 기능 명세·변경사항 갱신
## 2026-06-23 / v4.0.3 — 계정 즐겨찾기 동기화
### 수행 작업
- 로그인 사용자의 즐겨찾기를 `deck_favorites` DB 테이블에 계정별 저장
- 다른 브라우저와 기기에서도 같은 계정의 즐겨찾기 유지
- 기존 브라우저 즐겨찾기는 로그인 시 접근 가능한 덱만 계정으로 자동 이전
- 덱 삭제 시 외래키로 관련 즐겨찾기 자동 삭제
- 만료되거나 타인이 비공개로 전환한 덱은 즐겨찾기 조회 시 자동 정리
- 비로그인 사용자는 기존처럼 브라우저 `localStorage` 즐겨찾기 사용
- 개인 NAS·자체 Git LFS 운영 구조에 불필요한 CDN 분리 TODO 제거
- `todo.md`에 화면별 Tailwind 전환 체크리스트 추가
- 관리자 레이아웃·회원 관리·모달·정리 도구를 Tailwind 유틸리티로 전환
- 관리자 탭·모달 상태도 JavaScript에서 Tailwind 클래스를 전환하도록 변경하고 `style/admin.css` 제거
- 로그인·회원가입 화면을 Tailwind 유틸리티로 전환하고 `style/auth-pages.css` 제거
- Google Fonts 외부 호출을 제거하고 Pretendard 우선 시스템 폰트 스택 적용
- `style/output.css` 재빌드
- 메인 페이지 검색 제목·설명을 UNIARE 기준으로 변경하고 canonical·Open Graph·Twitter·구조화 데이터 추가
- 검색 엔진이 빈 제목이나 `Failed to fetch` 오류를 수집하지 않도록 정적 기본 문구와 사용자용 오류 안내 적용
## 2026-06-23 / v4.0.2 — 관리자 덱 관리·썸네일 정리 개선
### 수행 작업
- 관리자 화면 본문에 반응형 좌우 여백을 적용해 좁은 화면에서 콘텐츠가 벽에 붙는 문제 수정
- 관리자 덱 목록을 내 덱 화면과 같은 시리즈·공개 상태·수정일 카드 구조로 변경
- 조회 수와 `/d/공유코드` 표시 제거, 삭제 기능을 더보기 메뉴로 이동
- 일반 사용자와 관리자 덱 삭제 API에서 연결된 미리보기 PNG도 함께 삭제
- 관리자가 미참조 썸네일의 파일명·용량·수정일을 확인하고 일괄 삭제하는 기능 추가
## 2026-06-23 / v4.0.1 — 프로젝트 문서 체계 정비
### 수행 작업
- 실제 코드와 Git 이력을 기준으로 `spec.md`, `todo.md`, `history.md`, `map.md`, `convention.md`를 현재 상태에 맞게 재작성
- 과거 주요 구현 결정을 `history.md` 형식에 맞춰 날짜·버전·결정 이유·적용 내용으로 복원
- 누락된 `VERSION`, `docs/widget.md`, `docs/changelog.md` 추가
- `deploy.md`를 최초 설치·실행·업데이트·백업·롤백 순서의 운영 가이드로 전면 재작성
- `deploy.md`에서 로컬 개발과 NAS 운영을 분리하고 이미지·`datas/`·API 변경별 반영 명령을 구분
- `AGENTS.md` 재검수 결과를 반영해 SQLite WAL 안전 백업·복구 절차와 Compose 고정 이름 제약을 문서화
- Custom API YAML을 공식 문서 기준으로 재확인하고 참고 URL 추가
- `.cursorrules`의 잘못된 `.AGENTS.md` 참조를 `AGENTS.md`로 수정하고 배포 문서의 개인 로컬 절대 경로를 제거
- `deploy.md`의 중복 설명을 제거하고 초심자가 상황별 절차를 바로 선택할 수 있도록 간소화
- `deploy.md`의 Markdown 표를 제거하고 불필요한 줄바꿈을 줄여 명령 중심으로 재정리
- `deploy.md`의 한 줄 명령은 인라인 코드로 변경하고 여러 명령을 연속 실행하는 경우만 코드 블록 유지
- 완료 항목이 남아 있던 `todo.md`를 미완료 작업만 유지하도록 정리
- 현재 단계에서는 기존 CSS 구조를 유지하고 Tailwind 일괄 전환을 제외한다고 명시
## 2026-06-22 / v4.0.0 — 헤더 로고 크기 통일
### 수행 작업
- `style/brand.css` 신설: `--brand-logo-height: 2rem`(32px), `.app-brand-logo` 단일 정의
- `app-shell.css`, `auth-pages.css`, `admin.css`에서 중복 로고 스타일 제거 → `@import brand.css`
- `index.html`: Tailwind `h-8` 제거 → `app-brand-logo` + `brand.css` 로드
## 2026-06-22 / v4.0.0 — 덱 카드 조회 수 제거
### 수행 작업
- `deckHistory.html`, `preset.html`: 카드 메타에서 조회 수 표시 제거 (저장일·작성자만 유지)
## 2026-06-22 / v4.0.0 — 제품명 UNIARE·로고 이미지
### 수행 작업
- 표시 제품명 `UADECK` → **UNIARE** (페이지 title, i18n, 관리자, 연동 문서)
- 상단 헤더 텍스트 로고 → `images/assets/uniare-h48.png` 이미지 로고
- 이미지 다운로드 파일명 접두사 `uadeck_` → `uniare_`
- 내부 호환 유지: `UaDeckApi`, `uadeck_token`, `uadeck.db`, `UADECK_API_BASE` 등
## 2026-06-22 / v4.0.0 — Tailwind CSS 재빌드
### 수행 작업
- `package.json`: `npm run build:css` 스크립트 추가
- `style/output.css` 재생성 — `sm:px-6` 등 HTML에 추가됐으나 빌드에 없던 클래스 반영
- `cardList.html`, `result.html`: `px-4 sm:px-6` 유지 (클래스 우회 제거)
## 2026-06-22 / v4.0.0 — 레이아웃·덱 이름 변경
### 수행 작업
- 헤더 `v4.0.0` 표시 제거 (로그인 페이지 하단 푸터에만 작게 표시)
- `deckHistory.html`: 더보기 메뉴에 **이름 변경** 추가 (`PUT /decks/:code`)
- `cardList.html`, `result.html`: 콘텐츠·헤더 최대 너비 `1400px` → **1280px** (`max-w-7xl`) 통일
- `profile.css`: 프로필 본문 `max-width: 42rem` 제거 → 내 덱·공개 덱과 동일한 `app-main` 너비
## 2026-06-22 / v4.0.0 — Docker 서비스명 `uniare-*` 통일
### 수행 작업
- `docker-compose.prod.yml`: 서비스/컨테이너/이미지/볼륨 이름을 `uadeck-*`에서 `uniare-*`로 변경
- `docker-compose.yml`: API 서비스/컨테이너/볼륨 이름을 `uniare-*`로 변경
- `nginx/default.conf`: 내부 API 프록시 대상 `uadeck-api` → `uniare-api` 반영
- `nginx/uadeck.conf`: API 프록시 대상 컨테이너 이름을 `uniare-api`로 갱신
- `docs/deploy.md`: 운영 명령 및 컨테이너 확인 예시 이름을 `uniare-*` 기준으로 갱신
### 메모
- API DB 파일명(`uadeck.db`)과 API 경로(`/api/v1/*`)는 호환성을 위해 유지했다.
## 2026-06-19 / v4.0.0 — 덱 썸네일 미리보기·저장 크기 통일
### 수행 작업
- `deckHistory.html`: `deck-preview-hold.js` 연동 — 내 덱 썸네일도 클릭·길게 눌러 확대
- `preset.html`: 썸네일 위「클릭·길게 눌러…」안내 문구 제거 (홀드 기능 유지)
- `result.html`: 저장용 미리보기를 다운로드와 동일한 **1200px** 기준으로 정규화 (`normalizePreviewForSave`)
- 저장 시 HQ 옵션 무시 (`highQuality: false`) 후 너비 1200px로 스케일
- 기존 960px 축소만 하던 `compressPreviewForSave` 제거 → 크기 제각각이던 원인 해소
- `style/deck-history.css`: 미사용 `.deck-card-preview-hint` 제거
### 참고
- 이미 저장된 덱 썸네일은 예전 크기 그대로이며, Preview에서 **다시 저장**하면 1200px로 갱신됨
## 2026-06-19 / v4.0.0 — 덱 저장 413 수정
### 원인
- `localhost:8080` 요청은 Nginx(`client_max_body_size 2m`)를 거침
- 덱 저장 시 미리보기 PNG(base64)가 JSON에 포함되어 2MB 초과 → **413 Request Entity Too Large**
### 수행 작업
- `nginx/default.conf`: `client_max_body_size` 2m → **25m** (API location 포함)
- `api/src/index.js`: JSON limit 20mb, 413 전용 오류 메시지
- `script/api.js`: 413 사용자 안내 문구
- `result.html`: 저장용 미리보기 최대 너비 960px 리사이즈
### 적용
```bash
npm stop && npm start
```
## 2026-06-19 / v4.0.0 — 공개 덱 미리보기 수정
### 수행 작업
- `deck-preview-hold.js`: mouseleave 즉시 닫힘 버그 수정, 길게 누르는 동안만 확대 (짧은 클릭 고정 제거)
- ~~안내 문구:「클릭·길게 눌러 크게 보기」~~ → 이후 제거됨
## 2026-06-19 / v4.0.0 — 프로필 덱 저장 설정 UI
### 수행 작업
- 덱 저장 설정: 설명 문단 제거 → `?` 툴팁, 설정 패널·토글 ON/OFF 표시·즉시 저장 안내 메시지
- 토글 클릭 시 API/localStorage 동기화는 기존 `deck-prefs.js` 유지
## 2026-06-19 / v4.0.0 — 메인 프로필 i18n·페이지 도움말
### 수행 작업
- `index.html`: `page-i18n.js` 로드, 언어 전환 시 `UaPageI18n` + `UaDeckAuth.refresh()`로 프로필 드롭다운 재번역
- `script/page-help.js`: 제목 옆 `?` 버튼 클릭 시 설명 툴팁
- `deckHistory.html`, `preset.html`, `profile.html`: 상단 설명 `<p>` 제거 → `?` 도움말 버튼
- `style/app-shell.css`: `.page-title-row`, `.page-help-btn`, `.page-help-tooltip`
## 2026-06-19 / v4.0.0 — 덱 즐겨찾기·공개 덱 미리보기
### 수행 작업
- `script/deck-favorites.js`: share_code 기준 즐겨찾기 (localStorage)
- `script/deck-preview-hold.js`: 썸네일 길게 누르기 확대 미리보기
- `deckHistory.html`: 카드 하단 즐겨찾기 버튼, 툴바「즐겨찾기」필터
- `preset.html`: 카드 클릭 이동 제거 → 홀드 미리보기 + 하단「즐겨찾기」「불러오기」버튼
- `style/deck-history.css`: 즐겨찾기·홀드 오버레이 스타일
- i18n: `deckFavorite`, `deckFavoriteFilter`, `deckHoldPreviewHint`
### 메모
- 즐겨찾기는 브라우저 localStorage에 저장 (기기·브라우저별). 추후 계정 동기화 API 확장 가능.
## 2026-06-19 / v4.0.0 — AdSense·i18n 보완
### 수행 작업
- Google AdSense 우측 슬롯(`4260332296`) 삽입: `deckHistory.html`, `preset.html`, `profile.html` `#ad-sidebar`
- `script/ads.js`: `ins.adsbygoogle` 초기화 헬퍼
- `script/page-i18n.js`: `data-i18n` / `data-i18n-html` / `data-i18n-placeholder` + `UaPageI18n.t()` 헬퍼
- `script/auth-header.js`: 드롭다운 메뉴 3개국어(ko/en/ja)
- `login.html`, `signup.html`, `profile.html`, `deckHistory.html`, `preset.html` 정적 문구 i18n 연결
- `cardList.html`: `← 홈` 버튼 `homeNav` 키 적용
- `result.html`: 저장 모달·로딩 확인·홈 버튼 기존 `applyTranslationsToUI` 유지, `page-i18n`으로 헤더 메뉴 번역
- `deckHistory.html` / `preset.html`: 목록·버튼·확인 대화상자 동적 문구 i18n
- `i18n/translations.{ko,en,ja}.json`: auth·덱 관리·프로필·공개 덱 관련 키 추가
### 확인
- `?lang=en` 또는 `?lang=ja` 로 각 페이지 문구 전환 확인
- AdSense는 로컬/비승인 도메인에서는 빈 슬롯으로 보일 수 있음
## 2026-06-19 / v4.0.0
### 수행 작업
- v4.0.0 회원·덱 API·공유 시스템 1차 구현
- `api/` Express + SQLite 백엔드 (인증, 덱 CRUD, 관리자 API)
- `docker-compose.yml`, `.env.example`, `nginx/uadeck.conf` 추가
- 프론트: `script/api.js`, `script/auth-header.js`, `style/auth-header.css`
- 신규 페이지: `login.html`, `signup.html`, `deckHistory.html`, `preset.html`, `admin.html`
- `index.html` v4.0.0 + 프로필 헤더
- `cardList.html`: `?cards=` 제거, `?d=` API 로드, 저장·공유 버튼
- `result.html`: localStorage 기반, 프로필 헤더
- `docs/spec.md` §9 API·DB 명세 반영
### 배포 방법
1. `.env.example`을 `.env`로 복사 후 `JWT_SECRET`, `ADMIN_EMAIL`, `ADMIN_PASSWORD` 설정
2. `docker compose up -d --build`
3. 리버스 프록시에 `nginx/uadeck.conf` 규칙 적용 (`/api/`, `/d/`)
### 메모
- 비회원도 저장·공유 가능 (30일 만료). 회원은 영구 저장.
- 외부 연동: `GET /api/v1/decks/:shareCode`, 클라이언트 `integrations/uadeck-deck-client.js`
## 2026-06-19 / v4.0.0 덱 저장 UX
### 수행 작업
- `cardList.html` 저장·공유 버튼 제거
- `result.html` 하단 Back 제거 →「내 덱에 저장」「공유」버튼 추가
- 저장·공유 시 Preview 이미지 PNG 함께 API 저장 (`preview_image`)
- 덱 이름: 입력값 없으면 `시리즈명 YYYY-MM-DD`
- `deckHistory.html` 미리보기 썸네일 표시
- 로그아웃 시 메인(`/`)으로 이동
- `seedAdmin`: `ADMIN_EMAIL`로 먼저 가입한 경우 역할을 `admin`으로 승격 (기존에는 INSERT 실패로 관리자 미생성)
- `auth-header.js`: 프로필 메뉴 열 때 `/auth/me`로 role 재동기화 (관리자 메뉴 미표시 수정)
- 관리자 API: JWT role 대신 DB role 확인 (`createRequireAdmin`)
- `npm start` / `npm stop` / `npm run logs` 로컬 실행 명령 정리 (`package.json`)
- 관리자 덱 관리 썸네일, 회원 모달 개편 (아바타·비밀번호 분리 모달)
- 본인 계정 역할/비활성 변경 서버·UI 차단, 표시 이름 필드 제거 (사용자명 사용)
## 2026-06-19 / v4.0.0 — 공개 덱·관리자 UI
### 수행 작업
- 메타 덱: 관리자 프리셋 등록 폐기 → 사용자 `is_public` 공개 덱이 `/preset.html`(공개 덱)에 노출
- 내 덱 저장 기본 비공개, 공유 시 공개, 내 덱 관리에서 공개/비공개 전환
- 덱 이름 기본값: `YYYYMMDD_HHMMSS` (시리즈명 제외)
- `admin.html` 레이아웃 개편 (`style/admin.css`), 프리셋 등록 탭 제거
- `preset.html` 카드 그리드 + 필터 UI
## 2026-06-19 / v4.0.0 — 프로필·아바타
### 수행 작업
- **썸네일(프로필 사진) 미적용 원인**: JPEG 등을 `.png`로 그대로 저장해 브라우저 표시 실패 → 업로드 전 `canvas`로 PNG 변환 (`script/image-utils.js`)
- `profile.html` 신규: 일반 사용자가 이메일·사용자명·아바타·비밀번호 변경 (`PUT /auth/me`, `POST /auth/me/password`)
- 헤더 드롭다운에「내 프로필」추가 (`script/auth-header.js`)
- 아바타 URL에 `updatedAt` 쿼리로 캐시 무효화 (저장 후 즉시 반영)
- `admin.html` 아바타 업로드도 동일 PNG 변환 적용
- `script/api.js`: `updateProfile`, `changePassword` 추가
- `docs/spec.md` 인증 API 엔드포인트 보강
### 확인 방법
1. `npm stop && npm start` 로 API 재시작 (auth 라우트 변경 반영)
2. http://localhost:8080/profile.html 에서 사진 업로드 →「프로필 저장」
3. 헤더 썸네일 클릭 →「내 프로필」메뉴 확인
### 후속
- 헤더 프로필 버튼: 로그인 시 아바타 옆 사용자명 표시 (`auth-header.js`, `auth-header.css`)
## 2026-06-19 / v4.0.0 — 덱 저장 UX·네비게이션
### 수행 작업
- `deckHistory.html`: `/d/코드` 경로 표시 제거 (링크 복사 버튼 유지)
- `result.html` 하단 버튼: `홈으로` / `이미지로 다운로드` / `내 덱으로 저장`
- 저장 시「모두에게 공개할까요?」모달 +「다음부터 물어보지 않기」토글
- `script/deck-prefs.js`: 공개 기본값·프롬프트 스킵 (localStorage + API 동기화)
- `users` 테이블: `default_deck_public`, `skip_deck_public_prompt` 컬럼 추가
- `profile.html`: 덱 저장 설정 (기본 공개, 물어보기 on/off)
- 뒤로가기 루프 수정: `result.html` 편집 복귀 시 `history.back()`, `cardList` ← 홈 버튼
### 확인
- API 변경 반영: `npm stop && npm start` (또는 `docker compose up -d --build`)
## 2026-06-19 / v4.0.0 — 레이아웃 통일·광고 공간
### 수행 작업
- `style/app-shell.css` 추가: 헤더·본문 `80rem`(1280px, index `max-w-7xl`과 동일)
- `deckHistory.html`, `preset.html`, `profile.html` 공통 셸 적용 (페이지 이동 시 헤더 너비 점프 제거)
- 데스크톱: 본문 좌측 + 우측 300px 광고 레일(`app-ad-rail`), 모바일: 상단 리더보드 슬롯
- 프로필 폼은 콘텐츠 영역 내 `max-width: 42rem` 유지
- `auth-pages.css`, `admin.css` 너비도 80rem으로 통일
### 후속 UI
- `result.html` 저장 모달 토글: Tailwind `translate-x-6` 미빌드 → 전용 `.save-deck-switch` CSS
- `result.html` 하단 버튼 비율: 홈 0.5 · 다운로드 0.5 · 내 덱 저장 2
- `result.html` 덱 저장: 브라우저 alert 제거 → 로딩 모달 완료 메시지 후 홈 이동
- `signup.html`: 비밀번호 확인 필드, 가입 후 홈(`/`) 이동
- `cardList.html`: 동일 카드 번호(패러랠 포함) 수량 공유 시 +/- 버튼 UI 동기화
## 2026-06-19 / v4.0.0 — 덱 썸네일 수정
### 수행 작업
- Preview 캡처 시 카드 이미지 로드 대기 + `cacheBust` 추가 (`result.html`)
- 캡처 실패해도 덱 저장은 진행, 안내 메시지 표시
- `deckHistory.html`: `UaDeckApi.resolveMediaUrl()`로 미리보기 URL 보정 (Live Server 등)
- 이미지 로드 실패 시 플레이스홀더 표시
- Docker 미리보기 저장 경로를 `./api/data/previews` 호스트 바인드 마운트로 변경
- API `getPreviewUrl`: DB `preview_image` 컬럼도 참고
## 2026-06-19 / v4.0.0 UI — 내 덱 관리
### 수행 작업
- `deckHistory.html` 카드 그리드 레이아웃으로 개편 (미리보기 이미지 상단 배치)
- `style/deck-history.css` 추가: 상단 여백, 툴바, 카드 그리드 전용 스타일
- 시리즈별 필터 드롭다운 + 덱 이름·시리즈 검색 입력 추가
- Tailwind 미빌드 클래스 의존 제거
## 2026-06-19 / v4.0.0 UI — result 하단 버튼
### 수행 작업
- `result.html` 하단「내 덱에 저장」「공유」버튼이 `style/output.css`에 없는 Tailwind 클래스(`bg-slate-700`, `bg-emerald-600`)로 인해 보이지 않던 문제 수정
- 페이지 내 `<style>`에 `.result-footer-*`, `.result-btn-*` 전용 스타일 추가 (인증 페이지와 동일한 패턴)
## 2026-06-19 / v4.0.0 UI — 인증 페이지
### 수행 작업
- `style/auth-pages.css` 추가: 폼 최대 너비 28rem, 상단 여백, index와 동일한 그라데이션·폰트
- `login.html`, `signup.html` 레이아웃 개선 (Tailwind 미빌드 클래스 의존 제거)
## 2026-06-19 / v4.0.0 배포·연동 보강
### 수행 작업
- `docker-compose.prod.yml`: Nginx + API 풀스택 (정적 파일 볼륨 마운트, 재빌드 불필요)
- `nginx/default.conf`, `docs/deploy.md` NAS 배포 가이드
- `integrations/uadeck-deck-client.js` 옆 프로젝트용 클라이언트
- `docs/api-integration.md` API 연동 문서
- `admin.html` 프리셋 등록 탭 추가
## 2026-06-19 / v4.0.0
### 수행 작업
- v4 회원·덱 API·공유 방식 설계를 확정했다.
- URL 쿼리(`?cards=`) 기반 공유는 폐기하고, 공유 코드 + `GET /api/v1/decks/:shareCode` API로 전환하기로 했다.
- 개인 덱은 저장마다 고유 `share_code`, 메타 덱·프리셋은 내용 해시 기반 고정 코드를 사용한다.
- DB 스키마 초안(`users`, `decks`)과 API 엔드포인트 목록을 `docs/spec.md` §9에 반영했다.
- 의사결정 내용을 `docs/history.md`에 기록했다.
### 메모
- 구현은 이 설계를 기준으로 진행한다. 실제 코드·Docker·화면 작업은 다음 단계다.
- 옆 프로젝트는 API 계약만 맞추면 되며, 레거시 쿼리 호환은 필요 없다.
## 2026-04-19 / v3.1.0
### 수행 작업
- `datas/ua51.js`를 추가해 `ua51` 시리즈 데이터를 등록했다.
- `images/ua51/`에 `나 혼자만 레벨업` 카드 이미지 자산을 반영했다.
- `images/ua49/`에 누락되어 있던 `마도정병의 슬레이브` 병렬 카드 이미지를 보강했다.
- `i18n/translations.ko.json`, `i18n/translations.ja.json`, `i18n/translations.en.json`에 `ua51` 시리즈명을 추가했다.
- `index.html`과 `docs/spec.md`의 버전 표기를 `v3.1.0`으로 통일했다.
- 다음 작업자가 동일한 기준으로 이어받을 수 있도록 `docs/codex-handoff.md`를 작성했다.
### 메모
- 이번 릴리즈는 신규 시리즈(`ua51`) 반영과 누락 자산 보강이 핵심이다.
- 배포 전에는 `datas/<series>.js`, `images/<series>/`, `i18n/translations.*.json` 세 축이 모두 맞물렸는지 반드시 함께 확인해야 한다.
- 이미지 자산은 계속 `Git LFS` 대상으로 유지한다.
## 2026-03-30 / docs-bootstrap-1
### 수행 작업
@@ -38,3 +636,15 @@
### 메모
- 이 작업 후 원격 저장소는 사실상 "현재 스냅샷 기반 새 저장소"처럼 동작한다.
- 과거 커밋 이력, 기존 태그, 옛 해시는 원격 기준으로 더 이상 사용하지 않게 된다.
## 2026-03-30 / v3.0.1
### 수행 작업
- 일부 시리즈 데이터의 잘못된 이미지 경로를 실제 파일명 기준으로 수정했다.
- `ua04`, `ua30`, `ua37`, `ua38`, `ua45`의 `imgSrc` 오기입을 정정했다.
- `ua44`에서 더 이상 사용하지 않는 주석 처리된 `UAPR/EVA-AP01`, `UAPR/EVA-AP02` 항목을 제거했다.
- 누락되어 있던 이미지 파일 `images/ua08/UAPB_BLC-AP01.png`, `images/ua09/UAPR_BTR-AP03.png`를 반영했다.
### 메모
- 이번 반영은 카드 데이터와 실제 이미지 파일 간의 불일치를 줄이는 목적이다.
- 버전 태그 체계는 `v3.x` 기준으로 이어간다.

151
docs/widget.md Normal file
View File

@@ -0,0 +1,151 @@
# 외부 서비스 연동
## 기본 정보
- 운영 URL: `https://uniare.sori.studio`
- API 상태: `https://uniare.sori.studio/api/v1/health`
- 운영 지표: `https://uniare.sori.studio/api/stats`
- 공유 덱: `https://uniare.sori.studio/api/v1/decks/<shareCode>`
- `STATS_API_TOKEN`이 설정된 경우 운영 지표 API는 Bearer 토큰 또는 `X-Stats-Token` 헤더가 필요하다.
## 상태 확인
```bash
curl -fsS https://uniare.sori.studio/api/v1/health
```
```json
{
"ok": true,
"version": "4.0.25"
}
```
로컬 Docker에서는 다음 주소를 사용한다.
```bash
curl -fsS http://localhost:44082/api/v1/health
```
## 대시보드 위젯
운영 지표 API를 사용하는 예시다.
Custom API 공식 문서:
```text
https://gethomepage.dev/widgets/services/customapi/
```
```yaml
- UNIARE:
icon: mdi-cards-playing
href: https://uniare.sori.studio
description: Union Arena Deck Builder
widget:
type: customapi
url: https://uniare.sori.studio/api/stats
refreshInterval: 30000
mappings:
- field: status
label: 상태
format: text
- field: version
label: 버전
format: text
- field: members.total
label: 회원
format: number
- field: members.created24h
label: 신규 회원 24h
format: number
- field: decks.total
label: 덱
format: number
- field: decks.created24h
label: 신규 덱 24h
format: number
- field: cards.seriesTotal
label: 시리즈
format: number
- field: cards.cardTypesTotal
label: 카드 종류
format: number
```
Docker 내부의 대시보드 컨테이너에서 같은 Compose 네트워크를 사용할 경우 공개 도메인 대신 `http://uniare-web/api/stats`를 사용할 수 있다. 서로 다른 Compose 프로젝트라면 공통 외부 네트워크 연결이 필요하다.
토큰을 설정한 경우 curl 예시는 다음과 같다.
```bash
curl -fsS \
-H "Authorization: Bearer $STATS_API_TOKEN" \
https://uniare.sori.studio/api/stats
```
응답 예시:
```json
{
"status": "online",
"ok": true,
"version": "4.0.25",
"lastUpdated": "2026-08-12T00:00:00.000Z",
"members": {
"total": 12,
"active": 12,
"inactive": 0,
"verified": 10,
"unverified": 2,
"created24h": 1,
"latestCreatedAt": "2026-08-12 09:00:00",
"latestLoginAt": "2026-08-12T10:00:00.000Z"
},
"decks": {
"total": 34,
"userTotal": 34,
"presetTotal": 0,
"publicTotal": 20,
"privateTotal": 14,
"anonymousTotal": 0,
"created24h": 3,
"updated24h": 5,
"totalViews": 120,
"latestCreatedAt": "2026-08-12 09:30:00",
"latestUpdatedAt": "2026-08-12T10:10:00.000Z"
},
"cards": {
"seriesTotal": 56,
"cardTypesTotal": 5400,
"cardPrintsTotal": 6200,
"parallelPrintsTotal": 800,
"bySeries": [
{
"series": "ua01",
"cardTypes": 90,
"cardPrints": 110,
"parallelPrints": 20
}
]
}
}
```
## 공유 덱 조회
```bash
curl -fsS https://uniare.sori.studio/api/v1/decks/x7Kp9m2Q
```
브라우저와 Node.js 연동은 `integrations/uadeck-deck-client.js` 또는 `docs/api-integration.md`를 사용한다.
## 운영 지표 범위
현재 운영 지표 API는 다음 집계값을 제공한다.
- API 상태와 현재 버전
- 전체·활성·인증 회원 수, 최근 24시간 신규 회원 수, 최근 가입·접속 시각
- 전체·공개·비공개·익명 덱 수, 최근 24시간 생성·수정 덱 수, 전체 조회 수
- 등록 카드 시리즈 수, 고유 카드 번호 수, 패러렐 포함 카드 수, 시리즈별 카드 수
DB 백업 시각과 미리보기·아바타 저장 공간 사용량은 아직 포함하지 않는다.

87
forgot-password.html Normal file
View File

@@ -0,0 +1,87 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./style/output.css?v=4.0.16" />
<title>비밀번호 찾기 - UNIARE</title>
</head>
<body class="app-page">
<nav class="app-nav">
<div class="app-nav-inner">
<a href="/" class="app-brand"><img src="/images/assets/uniare-h48.png" alt="UNIARE" class="app-brand-logo" height="32" /></a>
</div>
</nav>
<main class="mx-auto w-full max-w-md px-6 pt-12 pb-16 sm:pt-16 sm:pb-20">
<header class="mb-8 text-center">
<h1 class="text-3xl font-black tracking-tight text-slate-900" data-i18n="forgotPasswordTitle">비밀번호 찾기</h1>
<p class="mt-2 text-sm leading-relaxed text-slate-500" data-i18n="forgotPasswordDesc">가입한 이메일로 재설정 링크를 보내드립니다.</p>
</header>
<form id="forgot-form" class="rounded-2xl border border-slate-200 bg-white p-7 shadow-lg shadow-slate-900/5">
<label class="mb-1.5 block text-xs font-bold uppercase tracking-wider text-slate-400" for="email" data-i18n="emailLabel">이메일</label>
<input type="email" id="email" required autocomplete="email" placeholder="you@example.com" class="box-border block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15" />
<p id="status-msg" class="mt-4 hidden text-sm text-slate-500"></p>
<button type="submit" id="forgot-submit" class="mt-6 block w-full cursor-pointer rounded-xl border-0 bg-blue-600 px-4 py-3.5 text-sm font-bold text-white transition hover:bg-blue-700 disabled:cursor-wait disabled:opacity-70" data-i18n="sendResetEmail">재설정 메일 보내기</button>
<p class="mt-3 text-xs leading-relaxed text-slate-500" data-i18n="emailMonthlyLimitNotice">인증·비밀번호 재설정 메일은 최초 발송을 포함해 계정당 월 3회까지 발송됩니다.</p>
</form>
<p class="mt-6 text-center"><a href="/login.html" class="text-sm font-bold text-blue-600 hover:underline" data-i18n="backToLogin">로그인으로 돌아가기</a></p>
</main>
<script src="/script/page-i18n.js"></script>
<script src="/script/api.js?v=4.0.16"></script>
<script src="/script/auth-form.js?v=4.0.16"></script>
<script>
/**
* 현재 언어 번역 문자열
* @param {string} key
* @param {string} fallback
* @returns {string}
*/
function forgotText(key, fallback) {
return window.UaPageI18n?.t(key, fallback) || fallback;
}
document.getElementById('forgot-form').addEventListener('submit', async (event) => {
event.preventDefault();
const statusEl = document.getElementById('status-msg');
const submitButton = document.getElementById('forgot-submit');
if (submitButton.disabled) return;
statusEl.classList.remove('hidden');
UaAuthForm.setLoading(
submitButton,
true,
forgotText('sendingLabel', '전송 중...'),
);
let sent = false;
try {
const email = document.getElementById('email').value;
await (typeof UaDeckApi.forgotPassword === 'function'
? await UaDeckApi.forgotPassword(email)
: await UaAuthForm.request('/auth/forgot-password', { email }));
statusEl.textContent = forgotText(
'resetEmailSent',
'가입된 이메일이라면 비밀번호 재설정 링크를 보냈습니다.',
);
sent = true;
} catch (err) {
statusEl.textContent =
err.code === 'MONTHLY_EMAIL_LIMIT_EXCEEDED'
? forgotText(
'emailLimitExceeded',
'월간 이메일 발송 한도(3회)를 초과했습니다. 관리자에게 직접 문의해 주세요.',
)
: err.message;
} finally {
if (sent) {
UaAuthForm.startCooldown(
submitButton,
60,
forgotText('requestAgain', '다시 요청'),
);
} else {
UaAuthForm.setLoading(submitButton, false);
}
}
});
</script>
</body>
</html>

View File

@@ -20,7 +20,7 @@
"editDeck": "← EDIT DECK",
"deckPreview": "Deck Preview",
"deckNameLabel": "Deck Name",
"deckNamePlaceholder": "Series name used if empty",
"deckNamePlaceholder": "YYYYMMDD_HHMMSS if empty",
"exportStyle": "Export Style",
"type1Title": "Type 1: Signature",
"type1Desc": "Standard style highlighting Key Cards",
@@ -31,13 +31,159 @@
"exportNotice1": "* If image fails to generate, please check 'Pop-up' settings.",
"exportNotice2": "* On iOS, you may need to long-press the image to save.",
"generatingImage": "Generating Image...",
"savingPreview": "Creating preview...",
"savingToMyDeck": "Saving to My Decks...",
"sharingDeck": "Creating share link...",
"saveComplete": "Saved!",
"shareComplete": "Ready to share!",
"downloadComplete": "Download complete!",
"downloadImage": "DOWNLOAD IMAGE",
"saveToMyDeck": "Save to My Decks",
"shareDeck": "Share",
"keyCardLabel": "KEY CARD",
"back": "BACK",
"saveError": "Failed to save image",
"goHome": "Home",
"downloadImageLabel": "Download Image",
"saveToMyDeckLabel": "Save to My Deck",
"savePublicTitle": "Make this deck public?",
"savePublicDesc": "Public decks appear in the Public Decks list.",
"savePublicLabel": "Public",
"savePublicDefault": "Default: Yes",
"saveSkipLabel": "Don't ask again",
"saveSkipHint": "Your choice becomes the default",
"saveButton": "Save",
"okButton": "OK",
"deckSavedPublic": "Deck saved as public.",
"deckSavedPrivate": "Deck saved as private.",
"previewFailedNote": "(Preview image could not be generated)",
"redirectingHome": "Redirecting to home...",
"saveFailedTitle": "Save failed.",
"tryAgain": "Please try again.",
"noCardsToSave": "No cards to save.",
"homeNav": "← Home",
"authLogin": "Log in",
"authSignup": "Sign up",
"authMyProfile": "My Profile",
"authMyDecks": "My Decks",
"authPublicDecks": "Public Decks",
"authAdmin": "Admin",
"authLogout": "Log out",
"authAccountMenu": "Account menu",
"authAccountMenuNamed": "{name} account menu",
"loginPageTitle": "Log in",
"loginPageDesc": "Sign in to save and manage<br />your decks.",
"signupPageTitle": "Sign up",
"signupPageDesc": "Create a UNIARE account to<br />save and share decks.",
"emailLabel": "Email",
"passwordLabel": "Password",
"usernameLabel": "Username",
"passwordConfirmLabel": "Confirm password",
"passwordConfirmPlaceholder": "Re-enter password",
"signupPasswordPlaceholder": "8+ characters",
"passwordMismatch": "Passwords do not match.",
"signupButton": "Create account",
"loginButton": "Log in",
"rememberLogin": "Keep me logged in",
"noAccount": "Don't have an account?",
"hasAccount": "Already have an account?",
"forgotPasswordLink": "Forgot your password?",
"forgotPasswordTitle": "Forgot password",
"forgotPasswordDesc": "We'll send a reset link to your registered email.",
"sendResetEmail": "Send reset email",
"resetEmailSent": "If the email is registered, a password reset link has been sent.",
"requestAgain": "Request again",
"resetPasswordTitle": "Reset password",
"resetPasswordDesc": "Enter the new password you want to use.",
"passwordResetComplete": "Your password has been changed. Log in with your new password.",
"changingLabel": "Updating...",
"changeComplete": "Updated",
"backToLogin": "Back to login",
"verifyEmailTitle": "Verify email",
"verifyEmailSentDesc": "A verification link has been sent to your registered email.",
"verifyingEmail": "Verifying your email...",
"verificationCompleteLoginNotice": "Email verification is complete. Please log in.",
"verificationLinkInvalid": "The verification link is invalid or has expired.",
"verificationRequiredTitle": "Email verification is required.",
"verificationRequiredDesc": "If the email did not arrive or was deleted, you can request another one below.",
"verificationRequiredLoginError": "Verify your email before logging in.",
"resendVerificationButton": "Resend verification email",
"resendVerificationShort": "Resend",
"verificationResent": "The verification email has been sent again.",
"emailMonthlyLimitNotice": "Verification and password reset emails are limited to three per account each month, including the initial email.",
"emailLimitAdminNotice": "If you exceed three emails in a month, contact the administrator directly for review and approval.",
"emailLimitExceeded": "The monthly email limit of three has been reached. Contact the administrator directly.",
"sendingLabel": "Sending...",
"goToLogin": "Go to login",
"deckHistoryTitle": "My Decks",
"deckHistoryDesc": "Your saved decks. Public decks appear in the Public Decks list.",
"publicDecksTitle": "Public Decks",
"publicDecksDesc": "Decks marked public by users. Toggle visibility in My Decks.",
"profileTitle": "My Profile",
"profileDesc": "Update your photo, username, email, and password.",
"profileSave": "Save profile",
"profileDeckSettings": "Deck save settings",
"profileDeckSettingsDesc": "Default visibility when saving from the result screen.",
"profileDefaultPublic": "Public by default",
"profileDefaultPublicHint": "New saves default to public",
"profileAskOnSave": "Ask visibility on save",
"profileAskOnSaveHint": "Off uses your default without asking",
"profileDeckPrefsSaved": "Deck save settings applied.",
"profileSettingOn": "ON",
"profileSettingOff": "OFF",
"changePassword": "Change password",
"changePasswordButton": "Update",
"newPasswordLabel": "New password",
"confirmPasswordLabel": "Confirm new password",
"loadingLabel": "Loading...",
"accountWithdrawalLink": "Delete account",
"accountWithdrawalTitle": "Delete account",
"accountWithdrawalDesc": "Your account and personal information will be deleted. Saved decks will remain anonymous so other users can continue referencing them. This cannot be undone.",
"accountWithdrawalConfirmLabel": "Enter DELETE to confirm.",
"accountWithdrawalFailed": "Failed to delete the account.",
"deckSearchPlaceholder": "Search deck name or series...",
"deckAllSeries": "All series",
"deckLoading": "Loading...",
"deckEmpty": "No saved decks yet.",
"deckNoMatch": "No decks match your filters.",
"deckUntitled": "Untitled",
"deckNoPreview": "No preview",
"deckResaveHint": "Re-save from Preview",
"deckBadgePublic": "Public",
"deckBadgePrivate": "Private",
"deckMakePublic": "Make public",
"deckMakePrivate": "Make private",
"deckLoad": "Load",
"deckCopyLink": "Copy link",
"deckRename": "Rename",
"deckRenamePrompt": "Enter a new deck name",
"deckRenameEmpty": "Please enter a name.",
"deckRenameFailed": "Failed to rename deck.",
"deckDelete": "Delete",
"deckViews": "Views",
"deckCountFormat": "{filtered} / {total}",
"publicDeckEmpty": "No public decks yet.",
"publicDeckSearchPlaceholder": "Search deck, series, or author...",
"deckLinkCopied": "Share link copied.",
"deckMakePublicConfirm": "Show this deck in the public list?",
"deckMakePrivateConfirm": "Make this deck private? It will be hidden from the public list.",
"deckDeleteConfirm": "Delete this deck?",
"deckFavorite": "Favorite",
"deckFavoriteFilter": "Favorites",
"deckFavoriteFilterOnly": "Favorites only",
"deckMoreActions": "More actions",
"deckNoFavorites": "No favorite decks yet.",
"deckHoldPreviewHint": "Click or hold to enlarge",
"pageHelpLabel": "Page help",
"all": "All",
"metaDeck": "Meta Deck",
"metaDeck": "Public Decks",
"titlePrompt": "Please select a title",
"selectTitle": "Select Title",
"deckName": "Deck Name",
@@ -124,7 +270,15 @@
"ua46": "Kagurabachi",
"ua47": "Tokyo Ghoul",
"ua48": "Kingdom",
"ua49": "Chained Soldier"
"ua49": "Chained Soldier",
"ua51": "SOLO LEVELING",
"ua52": "𝚃𝚑𝚎 𝙴𝚖𝚒𝚗𝚎𝚗𝚌𝚎 𝚒𝚗 𝚂𝚑𝚊𝚍𝚘𝚠",
"ua53": "Chainsaw Man",
"ua50": "InuYasha",
"ua54": "Mushoku Tensei: Jobless Reincarnation",
"ua55": "THE IDOLM@STER CINDERELLA GIRLS",
"ua56": "Tengen Toppa Gurren Lagann",
"ua57": "STEINS;GATE"
},
"uapr": "UNION ARENA"
}

View File

@@ -20,7 +20,7 @@
"editDeck": "← デッキを編集",
"deckPreview": "デッキプレビュー",
"deckNameLabel": "デッキ名",
"deckNamePlaceholder": "未入力時はシリーズ名を使用",
"deckNamePlaceholder": "未入力時は YYYYMMDD_HHMMSS",
"exportStyle": "書き出しスタイル",
"type1Title": "タイプ 1: Signature",
"type1Desc": "キーカードを強調する基本スタイル",
@@ -31,13 +31,159 @@
"exportNotice1": "* 画像が生成されない場合は「ポップアップ許可」を確認してください。",
"exportNotice2": "* iOS環境では画像を長押しして保存する必要がある場合があります。",
"generatingImage": "画像生成中...",
"savingPreview": "プレビュー生成中...",
"savingToMyDeck": "マイデックに保存中...",
"sharingDeck": "共有リンク作成中...",
"saveComplete": "保存完了!",
"shareComplete": "共有準備完了!",
"downloadComplete": "ダウンロード完了!",
"downloadImage": "画像を保存する",
"saveToMyDeck": "マイデックに保存",
"shareDeck": "共有",
"keyCardLabel": "KEY CARD",
"back": "戻る",
"saveError": "画像の保存に失敗しました",
"goHome": "ホームへ",
"downloadImageLabel": "画像をダウンロード",
"saveToMyDeckLabel": "マイデックに保存",
"savePublicTitle": "全体に公開しますか?",
"savePublicDesc": "公開すると「公開デッキ」一覧に表示されます。",
"savePublicLabel": "公開",
"savePublicDefault": "初期値: はい",
"saveSkipLabel": "次回から表示しない",
"saveSkipHint": "選択した公開設定が初期値になります",
"saveButton": "保存",
"okButton": "確認",
"deckSavedPublic": "デッキを公開で保存しました。",
"deckSavedPrivate": "デッキを非公開で保存しました。",
"previewFailedNote": "(プレビュー画像の生成に失敗しました)",
"redirectingHome": "まもなくホームへ移動します。",
"saveFailedTitle": "保存に失敗しました。",
"tryAgain": "もう一度お試しください。",
"noCardsToSave": "保存するカードがありません。",
"homeNav": "← ホーム",
"authLogin": "ログイン",
"authSignup": "会員登録",
"authMyProfile": "マイプロフィール",
"authMyDecks": "マイデック管理",
"authPublicDecks": "公開デッキ",
"authAdmin": "管理者",
"authLogout": "ログアウト",
"authAccountMenu": "アカウントメニュー",
"authAccountMenuNamed": "{name} アカウントメニュー",
"loginPageTitle": "ログイン",
"loginPageDesc": "デッキを保存・管理するには<br />アカウントでログインしてください。",
"signupPageTitle": "会員登録",
"signupPageDesc": "UNIAREアカウントを作成して<br />デッキを保存・共有しましょう。",
"emailLabel": "メール",
"passwordLabel": "パスワード",
"usernameLabel": "ユーザー名",
"passwordConfirmLabel": "パスワード確認",
"passwordConfirmPlaceholder": "パスワード再入力",
"signupPasswordPlaceholder": "8文字以上",
"passwordMismatch": "パスワードが一致しません。",
"signupButton": "登録する",
"loginButton": "ログイン",
"rememberLogin": "ログイン状態を保持",
"noAccount": "アカウントをお持ちでない方",
"hasAccount": "すでにアカウントをお持ちの方",
"forgotPasswordLink": "パスワードをお忘れですか?",
"forgotPasswordTitle": "パスワードを忘れた方",
"forgotPasswordDesc": "登録メールアドレスに再設定リンクを送信します。",
"sendResetEmail": "再設定メールを送信",
"resetEmailSent": "登録済みのメールアドレスであれば、再設定リンクを送信しました。",
"requestAgain": "再リクエスト",
"resetPasswordTitle": "パスワード再設定",
"resetPasswordDesc": "新しく使用するパスワードを入力してください。",
"passwordResetComplete": "パスワードを変更しました。新しいパスワードでログインしてください。",
"changingLabel": "変更中...",
"changeComplete": "変更完了",
"backToLogin": "ログインに戻る",
"verifyEmailTitle": "メール認証",
"verifyEmailSentDesc": "登録メールアドレスに認証リンクを送信しました。",
"verifyingEmail": "メールアドレスを認証しています...",
"verificationCompleteLoginNotice": "メール認証が完了しました。ログインしてください。",
"verificationLinkInvalid": "認証リンクが無効または期限切れです。",
"verificationRequiredTitle": "メール認証が必要です。",
"verificationRequiredDesc": "メールが届かない、または削除した場合は下のボタンから再送できます。",
"verificationRequiredLoginError": "メール認証後にログインできます。",
"resendVerificationButton": "認証メールを再送",
"resendVerificationShort": "再送",
"verificationResent": "認証メールを再送しました。",
"emailMonthlyLimitNotice": "認証・パスワード再設定メールは初回送信を含め、1アカウントにつき月3回までです。",
"emailLimitAdminNotice": "月3回を超えた場合は管理者へ直接メールを送信してください。確認後に承認します。",
"emailLimitExceeded": "月間メール送信上限(3回)を超えました。管理者へ直接お問い合わせください。",
"sendingLabel": "送信中...",
"goToLogin": "ログインへ",
"deckHistoryTitle": "マイデック管理",
"deckHistoryDesc": "保存したデッキ一覧です。公開にすると「公開デッキ」に表示されます。",
"publicDecksTitle": "公開デッキ",
"publicDecksDesc": "ユーザーが公開したデッキ一覧です。マイデック管理で「公開切替」できます。",
"profileTitle": "マイプロフィール",
"profileDesc": "プロフィール写真・ユーザー名・メール・パスワードを変更できます。",
"profileSave": "プロフィール保存",
"profileDeckSettings": "デッキ保存設定",
"profileDeckSettingsDesc": "「マイデックに保存」時の公開設定の初期値です。",
"profileDefaultPublic": "デフォルト公開",
"profileDefaultPublicHint": "保存時に公開デッキへ表示",
"profileAskOnSave": "保存時に公開を確認",
"profileAskOnSaveHint": "オフにすると初期値で即保存",
"profileDeckPrefsSaved": "デッキ保存設定を反映しました。",
"profileSettingOn": "ON",
"profileSettingOff": "OFF",
"changePassword": "パスワード変更",
"changePasswordButton": "変更",
"newPasswordLabel": "新しいパスワード",
"confirmPasswordLabel": "新しいパスワード確認",
"loadingLabel": "読み込み中...",
"accountWithdrawalLink": "退会",
"accountWithdrawalTitle": "退会",
"accountWithdrawalDesc": "アカウントと個人情報は削除されますが、保存したデッキは他のユーザーが参照できるよう匿名で維持されます。この操作は元に戻せません。",
"accountWithdrawalConfirmLabel": "確認のため DELETE と入力してください。",
"accountWithdrawalFailed": "退会処理に失敗しました。",
"deckSearchPlaceholder": "デッキ名またはシリーズを検索...",
"deckAllSeries": "すべてのシリーズ",
"deckLoading": "読み込み中...",
"deckEmpty": "保存されたデッキがありません。",
"deckNoMatch": "条件に合うデッキがありません。",
"deckUntitled": "名称なし",
"deckNoPreview": "プレビューなし",
"deckResaveHint": "プレビュー画面から再保存",
"deckBadgePublic": "公開",
"deckBadgePrivate": "非公開",
"deckMakePublic": "公開にする",
"deckMakePrivate": "非公開にする",
"deckLoad": "読み込み",
"deckCopyLink": "リンクをコピー",
"deckRename": "名前を変更",
"deckRenamePrompt": "新しいデッキ名を入力してください",
"deckRenameEmpty": "名前を入力してください。",
"deckRenameFailed": "名前の変更に失敗しました。",
"deckDelete": "削除",
"deckViews": "閲覧",
"deckCountFormat": "{filtered} / {total}件",
"publicDeckEmpty": "公開デッキがありません。",
"publicDeckSearchPlaceholder": "デッキ名・シリーズ・作者を検索...",
"deckLinkCopied": "共有リンクをコピーしました。",
"deckMakePublicConfirm": "このデッキを公開リストに表示しますか?",
"deckMakePrivateConfirm": "非公開にします。公開リストから非表示になります。",
"deckDeleteConfirm": "このデッキを削除しますか?",
"deckFavorite": "お気に入り",
"deckFavoriteFilter": "お気に入り",
"deckFavoriteFilterOnly": "お気に入りのみ",
"deckMoreActions": "その他",
"deckNoFavorites": "お気に入りのデッキがありません。",
"deckHoldPreviewHint": "クリック・長押しで拡大",
"pageHelpLabel": "ページの説明",
"all": "全て",
"metaDeck": "メタデッキ",
"metaDeck": "公開デッキ",
"titlePrompt": "タイトルを選択してください",
"selectTitle": "タイトルを選択",
"deckName": "デッキ名",
@@ -117,7 +263,15 @@
"ua46": "カグラバチ",
"ua47": "東京喰種トーキョーグール",
"ua48": "キングダム",
"ua49": "魔都精兵のスレイブ"
"ua49": "魔都精兵のスレイブ",
"ua51": "俺だけレベルアップな件",
"ua52": "陰の実力者になりたくて!",
"ua53": "チェンソーマン",
"ua50": "犬夜叉",
"ua54": "無職転生 ~異世界行ったら本気だす~",
"ua55": "アイドルマスター シンデレラガールズ",
"ua56": "天元突破 グレンラガン",
"ua57": "シュタインズ・ゲート"
},
"uapr": "UNION ARENA",
"CardList": {

View File

@@ -20,7 +20,7 @@
"editDeck": "← EDIT DECK",
"deckPreview": "Deck Preview",
"deckNameLabel": "Deck Name",
"deckNamePlaceholder": "미입력 시 시리즈명 사용",
"deckNamePlaceholder": "미입력 시 YYYYMMDD_HHMMSS",
"exportStyle": "Export Style",
"type1Title": "Type 1: Signature",
"type1Desc": "키 카드를 강조하는 기본 스타일",
@@ -31,13 +31,159 @@
"exportNotice1": "* 이미지가 생성되지 않을 경우 브라우저의 '팝업 허용'을 확인해주세요.",
"exportNotice2": "* iOS 환경에서는 이미지를 길게 눌러 저장해야 할 수도 있습니다.",
"generatingImage": "이미지 생성 중...",
"savingPreview": "미리보기 생성 중...",
"savingToMyDeck": "내 덱에 저장 중...",
"sharingDeck": "공유 링크 생성 중...",
"saveComplete": "저장 완료!",
"shareComplete": "공유 준비 완료!",
"downloadComplete": "다운로드 완료!",
"downloadImage": "DOWNLOAD IMAGE",
"saveToMyDeck": "내 덱에 저장",
"shareDeck": "공유",
"keyCardLabel": "KEY CARD",
"back": "BACK",
"saveError": "이미지 저장 실패",
"goHome": "홈으로",
"downloadImageLabel": "이미지로 다운로드",
"saveToMyDeckLabel": "내 덱으로 저장",
"savePublicTitle": "모두에게 공개할까요?",
"savePublicDesc": "공개하면「공개 덱」목록에 표시됩니다.",
"savePublicLabel": "공개",
"savePublicDefault": "기본값: 예",
"saveSkipLabel": "다음부터 물어보지 않기",
"saveSkipHint": "선택한 공개 여부가 기본값이 됩니다",
"saveButton": "저장",
"okButton": "확인",
"deckSavedPublic": "덱이 공개로 저장되었습니다.",
"deckSavedPrivate": "덱이 비공개로 저장되었습니다.",
"previewFailedNote": "(미리보기 이미지 생성에 실패했습니다)",
"redirectingHome": "잠시 후 홈으로 이동합니다.",
"saveFailedTitle": "저장에 실패했습니다.",
"tryAgain": "다시 시도해 주세요.",
"noCardsToSave": "저장할 카드가 없습니다.",
"homeNav": "← 홈",
"authLogin": "로그인",
"authSignup": "회원가입",
"authMyProfile": "내 프로필",
"authMyDecks": "내 덱 관리",
"authPublicDecks": "공개 덱",
"authAdmin": "관리자",
"authLogout": "로그아웃",
"authAccountMenu": "계정 메뉴",
"authAccountMenuNamed": "{name} 계정 메뉴",
"loginPageTitle": "로그인",
"loginPageDesc": "덱을 저장하고 관리하려면<br />계정으로 로그인하세요.",
"signupPageTitle": "회원가입",
"signupPageDesc": "UNIARE 계정을 만들고<br />덱을 저장·공유해 보세요.",
"emailLabel": "이메일",
"passwordLabel": "비밀번호",
"usernameLabel": "사용자명",
"passwordConfirmLabel": "비밀번호 확인",
"passwordConfirmPlaceholder": "비밀번호 재입력",
"signupPasswordPlaceholder": "8자 이상",
"passwordMismatch": "비밀번호가 일치하지 않습니다.",
"signupButton": "가입하기",
"loginButton": "로그인",
"rememberLogin": "로그인 유지",
"noAccount": "계정이 없으신가요?",
"hasAccount": "이미 계정이 있으신가요?",
"forgotPasswordLink": "비밀번호를 잊으셨나요?",
"forgotPasswordTitle": "비밀번호 찾기",
"forgotPasswordDesc": "가입한 이메일로 재설정 링크를 보내드립니다.",
"sendResetEmail": "재설정 메일 보내기",
"resetEmailSent": "가입된 이메일이라면 비밀번호 재설정 링크를 보냈습니다.",
"requestAgain": "다시 요청",
"resetPasswordTitle": "비밀번호 재설정",
"resetPasswordDesc": "새로 사용할 비밀번호를 입력하세요.",
"passwordResetComplete": "비밀번호가 변경되었습니다. 새 비밀번호로 로그인해 주세요.",
"changingLabel": "변경 중...",
"changeComplete": "변경 완료",
"backToLogin": "로그인으로 돌아가기",
"verifyEmailTitle": "이메일 인증",
"verifyEmailSentDesc": "가입한 이메일로 인증 링크를 보냈습니다.",
"verifyingEmail": "이메일을 인증하는 중입니다...",
"verificationCompleteLoginNotice": "이메일 인증이 완료되었습니다. 로그인해 주세요.",
"verificationLinkInvalid": "인증 링크가 유효하지 않거나 만료되었습니다.",
"verificationRequiredTitle": "이메일 인증이 필요합니다.",
"verificationRequiredDesc": "메일을 받지 못했거나 삭제한 경우 아래 버튼으로 다시 요청할 수 있습니다.",
"verificationRequiredLoginError": "이메일 인증 후 로그인할 수 있습니다.",
"resendVerificationButton": "인증 메일 다시 보내기",
"resendVerificationShort": "다시 보내기",
"verificationResent": "인증 메일을 다시 보냈습니다.",
"emailMonthlyLimitNotice": "인증·비밀번호 재설정 메일은 최초 발송을 포함해 계정당 월 3회까지 발송됩니다.",
"emailLimitAdminNotice": "월 3회를 초과한 경우 관리자에게 직접 메일을 보내면 확인 후 승인해 드립니다.",
"emailLimitExceeded": "월간 이메일 발송 한도(3회)를 초과했습니다. 관리자에게 직접 문의해 주세요.",
"sendingLabel": "전송 중...",
"goToLogin": "로그인으로 이동",
"deckHistoryTitle": "내 덱 관리",
"deckHistoryDesc": "저장한 덱 목록입니다. 공개 전환 시「공개 덱」목록에 표시됩니다.",
"publicDecksTitle": "공개 덱",
"publicDecksDesc": "사용자가 공개로 전환한 덱 목록입니다. 내 덱 관리에서「공개 전환」하면 여기에 표시됩니다.",
"profileTitle": "내 프로필",
"profileDesc": "프로필 사진, 사용자명, 이메일, 비밀번호를 변경할 수 있습니다.",
"profileSave": "프로필 저장",
"profileDeckSettings": "덱 저장 설정",
"profileDeckSettingsDesc": "「내 덱으로 저장」시 공개 여부 기본값과 확인 방식을 설정합니다.",
"profileDefaultPublic": "기본 공개",
"profileDefaultPublicHint": "저장 시 기본으로 공개 덱에 노출",
"profileAskOnSave": "저장 시 공개 여부 물어보기",
"profileAskOnSaveHint": "끄면 설정한 기본값으로 바로 저장",
"profileDeckPrefsSaved": "덱 저장 설정이 적용되었습니다.",
"profileSettingOn": "ON",
"profileSettingOff": "OFF",
"changePassword": "비밀번호 변경",
"changePasswordButton": "변경",
"newPasswordLabel": "새 비밀번호",
"confirmPasswordLabel": "새 비밀번호 확인",
"loadingLabel": "불러오는 중...",
"accountWithdrawalLink": "회원 탈퇴",
"accountWithdrawalTitle": "회원 탈퇴",
"accountWithdrawalDesc": "계정과 개인 정보는 삭제되며 저장한 덱은 다른 사용자가 계속 참고할 수 있도록 익명으로 유지됩니다. 이 작업은 복구할 수 없습니다.",
"accountWithdrawalConfirmLabel": "확인을 위해 DELETE를 입력하세요.",
"accountWithdrawalFailed": "회원 탈퇴에 실패했습니다.",
"deckSearchPlaceholder": "덱 이름 또는 시리즈 검색...",
"deckAllSeries": "모든 시리즈",
"deckLoading": "불러오는 중...",
"deckEmpty": "저장된 덱이 없습니다.",
"deckNoMatch": "조건에 맞는 덱이 없습니다.",
"deckUntitled": "이름 없음",
"deckNoPreview": "미리보기 없음",
"deckResaveHint": "Preview에서 다시 저장",
"deckBadgePublic": "공개",
"deckBadgePrivate": "비공개",
"deckMakePublic": "공개 전환",
"deckMakePrivate": "비공개 전환",
"deckLoad": "불러오기",
"deckCopyLink": "링크 복사",
"deckRename": "이름 변경",
"deckRenamePrompt": "새 덱 이름을 입력하세요",
"deckRenameEmpty": "이름을 입력해 주세요.",
"deckRenameFailed": "이름 변경에 실패했습니다.",
"deckDelete": "삭제",
"deckViews": "조회",
"deckCountFormat": "{filtered} / {total}개",
"publicDeckEmpty": "공개된 덱이 없습니다.",
"publicDeckSearchPlaceholder": "덱 이름·시리즈·작성자 검색...",
"deckLinkCopied": "공유 링크가 복사되었습니다.",
"deckMakePublicConfirm": "이 덱을 공개 덱 목록에 표시합니다. 계속할까요?",
"deckMakePrivateConfirm": "비공개로 전환합니다. 공개 목록에서 숨겨집니다.",
"deckDeleteConfirm": "이 덱을 삭제하시겠습니까?",
"deckFavorite": "즐겨찾기",
"deckFavoriteFilter": "즐겨찾기",
"deckFavoriteFilterOnly": "즐겨찾기만 보기",
"deckMoreActions": "더 보기",
"deckNoFavorites": "즐겨찾기한 덱이 없습니다.",
"deckHoldPreviewHint": "클릭·길게 눌러 크게 보기",
"pageHelpLabel": "페이지 안내",
"all": "전체",
"metaDeck": "메타 덱",
"metaDeck": "공개 덱",
"titlePrompt": "작품을 선택해주세요",
"selectTitle": "작품 선택",
"deckName": "덱 이름",
@@ -113,7 +259,15 @@
"ua46": "카구라바치",
"ua47": "도쿄 구울",
"ua48": "킹덤",
"ua49": "마도정병의 슬레이브"
"ua49": "마도정병의 슬레이브",
"ua51": "나 혼자만 레벨업",
"ua52": "어둠의 실력자가 되고 싶어서!",
"ua53": "체인소 맨",
"ua50": "이누야샤",
"ua54": "무직전생 ~이세계에 갔으면 최선을 다한다~",
"ua55": "아이돌마스터 신데렐라 걸즈",
"ua56": "천원돌파 그렌라간",
"ua57": "슈타인즈 게이트"
},
"uapr": "UNION ARENA",
"CardList": {

BIN
images/assets/uniare-h48.png LFS Normal file

Binary file not shown.

BIN
images/ua03/UAPR_HTR-2-074.png LFS Normal file

Binary file not shown.

BIN
images/ua03/UAPR_HTR-2-075.png LFS Normal file

Binary file not shown.

BIN
images/ua03/UAPR_HTR-2-076.png LFS Normal file

Binary file not shown.

BIN
images/ua03/UAPR_HTR-2-077.png LFS Normal file

Binary file not shown.

BIN
images/ua06/UAPR_TOA-P-001.png LFS Normal file

Binary file not shown.

BIN
images/ua06/UAPR_TOA-P-002.png LFS Normal file

Binary file not shown.

BIN
images/ua06/UAPR_TOA-P-003.png LFS Normal file

Binary file not shown.

BIN
images/ua06/UAPR_TOA-P-004.png LFS Normal file

Binary file not shown.

BIN
images/ua08/EX15BT_BLC-4-001.png LFS Normal file

Binary file not shown.

BIN
images/ua08/EX15BT_BLC-4-002.png LFS Normal file

Binary file not shown.

BIN
images/ua08/EX15BT_BLC-4-003.png LFS Normal file

Binary file not shown.

Binary file not shown.

BIN
images/ua08/EX15BT_BLC-4-004.png LFS Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
images/ua08/EX15BT_BLC-4-005.png LFS Normal file

Binary file not shown.

BIN
images/ua08/EX15BT_BLC-4-006.png LFS Normal file

Binary file not shown.

BIN
images/ua08/EX15BT_BLC-4-007.png LFS Normal file

Binary file not shown.

BIN
images/ua08/EX15BT_BLC-4-008.png LFS Normal file

Binary file not shown.

Binary file not shown.

BIN
images/ua08/EX15BT_BLC-4-009.png LFS Normal file

Binary file not shown.

BIN
images/ua08/EX15BT_BLC-4-010.png LFS Normal file

Binary file not shown.

BIN
images/ua08/EX15BT_BLC-4-011.png LFS Normal file

Binary file not shown.

BIN
images/ua08/EX15BT_BLC-4-012.png LFS Normal file

Binary file not shown.

Binary file not shown.

BIN
images/ua08/EX15BT_BLC-4-013.png LFS Normal file

Binary file not shown.

BIN
images/ua08/EX15BT_BLC-4-014.png LFS Normal file

Binary file not shown.

BIN
images/ua08/EX15BT_BLC-4-015.png LFS Normal file

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More