Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e1b93f668 | |||
| 7109f62a51 | |||
| ed2f3131ab | |||
| 9077d6c255 | |||
| 4a309d3f6b | |||
| 06442f8344 | |||
| 65818cf2ee | |||
| 97215f155d | |||
| af721d327d | |||
| a90bdcbe13 | |||
| 63aabf61c4 | |||
| ee00b2aa89 | |||
| c193975c86 | |||
| 2c4f622408 | |||
| a5b7d720d6 | |||
| 5fbd58bd07 | |||
| 35a2976182 | |||
| edf48e53a5 | |||
| 5a56ddcede | |||
| bc30356c8b | |||
| eb4b35bccc | |||
| 72d73a54bd | |||
| f3b7ac6cf0 | |||
| ac1ab7d8c0 | |||
| ba89235207 | |||
| 93e81ce3ea | |||
| 00e05dbe3f | |||
| e11b1dc6df | |||
| a281d731a1 | |||
| 2228757ea5 | |||
| 331d6c5fe9 | |||
| 2ecf6201c0 | |||
| dbf02241e2 | |||
| f8c954f24b | |||
| dc80f50bda | |||
| fa59f6f6ea | |||
| 4479391207 | |||
| 7970962e18 | |||
| db227dd02e | |||
| ebebe1b12d | |||
| eed4604432 |
54
.ai-rules.md
54
.ai-rules.md
@@ -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/` 문서를 먼저 확인한다.
|
||||
- 작업 완료 후 문서 반영 누락이 없도록 확인한다.
|
||||
|
||||
@@ -1 +1 @@
|
||||
모든 작업 시 프로젝트 루트의 .ai-rules.md 지침을 엄격히 준수하고, 작업 종료 시마다 docs/ 폴더 내 문서를 업데이트할 것.
|
||||
모든 작업 시 프로젝트 루트의 AGENTS.md 지침을 엄격히 준수할 것.
|
||||
|
||||
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
# Docker 빌드 시 API 이미지에서 제외
|
||||
api/node_modules
|
||||
api/data
|
||||
.git
|
||||
.env
|
||||
*.md
|
||||
docs/
|
||||
.cursor/
|
||||
.cursorrules
|
||||
29
.env.example
Normal file
29
.env.example
Normal 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
5
.gitignore
vendored
@@ -27,4 +27,7 @@ logs/
|
||||
.DS_Store
|
||||
|
||||
# 기타
|
||||
/coverage
|
||||
/coverage
|
||||
|
||||
# 로컬 초안 (git 미추적)
|
||||
docs/posts/
|
||||
480
AGENTS.md
Normal file
480
AGENTS.md
Normal 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 여부
|
||||
802
admin.html
Normal file
802
admin.html
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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, ''')}' 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
3
api/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
data/
|
||||
.env
|
||||
18
api/Dockerfile
Normal file
18
api/Dockerfile
Normal 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
20
api/package.json
Normal 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
178
api/src/db.js
Normal 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
75
api/src/index.js
Normal 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)'}`);
|
||||
});
|
||||
91
api/src/middleware/auth.js
Normal file
91
api/src/middleware/auth.js
Normal 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
440
api/src/routes/admin.js
Normal 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
640
api/src/routes/auth.js
Normal 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
400
api/src/routes/decks.js
Normal 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
70
api/src/routes/stats.js
Normal 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;
|
||||
}
|
||||
33
api/src/services/accountDeletion.js
Normal file
33
api/src/services/accountDeletion.js
Normal 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,
|
||||
};
|
||||
}
|
||||
71
api/src/services/avatarImage.js
Normal file
71
api/src/services/avatarImage.js
Normal 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;
|
||||
}
|
||||
}
|
||||
78
api/src/services/cardResolver.js
Normal file
78
api/src/services/cardResolver.js
Normal 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 };
|
||||
}
|
||||
111
api/src/services/emailService.js
Normal file
111
api/src/services/emailService.js
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 공개 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분 동안 한 번만 사용할 수 있습니다.`,
|
||||
});
|
||||
}
|
||||
166
api/src/services/previewImage.js
Normal file
166
api/src/services/previewImage.js
Normal 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 };
|
||||
}
|
||||
118
api/src/services/statsService.js
Normal file
118
api/src/services/statsService.js
Normal 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
90
api/src/utils.js
Normal 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();
|
||||
}
|
||||
312
api/test/account-deletion.test.js
Normal file
312
api/test/account-deletion.test.js
Normal 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
155
api/test/stats.test.js
Normal 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 });
|
||||
}
|
||||
});
|
||||
266
cardList.html
266
cardList.html
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
|
||||
1615
datas/ua08.js
1615
datas/ua08.js
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
1229
datas/ua31.js
1229
datas/ua31.js
File diff suppressed because it is too large
Load Diff
1230
datas/ua36.js
1230
datas/ua36.js
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
1162
datas/ua40.js
1162
datas/ua40.js
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
// },
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -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
1217
datas/ua50.js
Normal file
File diff suppressed because it is too large
Load Diff
1217
datas/ua51.js
Normal file
1217
datas/ua51.js
Normal file
File diff suppressed because it is too large
Load Diff
1357
datas/ua52.js
Normal file
1357
datas/ua52.js
Normal file
File diff suppressed because it is too large
Load Diff
1277
datas/ua53.js
Normal file
1277
datas/ua53.js
Normal file
File diff suppressed because it is too large
Load Diff
1236
datas/ua54.js
Normal file
1236
datas/ua54.js
Normal file
File diff suppressed because it is too large
Load Diff
2166
datas/ua55.js
Normal file
2166
datas/ua55.js
Normal file
File diff suppressed because it is too large
Load Diff
1315
datas/ua56.js
Normal file
1315
datas/ua56.js
Normal file
File diff suppressed because it is too large
Load Diff
1286
datas/ua57.js
Normal file
1286
datas/ua57.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
592
deckHistory.html
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 덱 표시 이름
|
||||
* @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
74
docker-compose.prod.yml
Normal 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
36
docker-compose.yml
Normal 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
176
docs/api-integration.md
Normal 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
227
docs/changelog.md
Normal 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
71
docs/codex-handoff.md
Normal 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/` 운영 파일을 커밋하지 않는다.
|
||||
@@ -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
253
docs/deploy.md
Normal 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에 포함되지 않았는가?
|
||||
290
docs/history.md
290
docs/history.md
@@ -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`을 역할별 기준 문서로 운영.
|
||||
|
||||
241
docs/map.md
241
docs/map.md
@@ -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
|
||||
```
|
||||
|
||||
370
docs/spec.md
370
docs/spec.md
@@ -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에서 인증된 발신 도메인이 필요하다.
|
||||
|
||||
49
docs/todo.md
49
docs/todo.md
@@ -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 정리만 검토)
|
||||
|
||||
610
docs/update.md
610
docs/update.md
@@ -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
151
docs/widget.md
Normal 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
87
forgot-password.html
Normal 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>
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
BIN
images/assets/uniare-h48.png
LFS
Normal file
Binary file not shown.
BIN
images/ua03/UAPR_HTR-2-074.png
LFS
Normal file
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
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
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
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
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
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
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
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
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
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
BIN
images/ua08/EX15BT_BLC-4-003.png
LFS
Normal file
Binary file not shown.
BIN
images/ua08/EX15BT_BLC-4-003_p1.png
LFS
Normal file
BIN
images/ua08/EX15BT_BLC-4-003_p1.png
LFS
Normal file
Binary file not shown.
BIN
images/ua08/EX15BT_BLC-4-004.png
LFS
Normal file
BIN
images/ua08/EX15BT_BLC-4-004.png
LFS
Normal file
Binary file not shown.
BIN
images/ua08/EX15BT_BLC-4-004_p1.png
LFS
Normal file
BIN
images/ua08/EX15BT_BLC-4-004_p1.png
LFS
Normal file
Binary file not shown.
BIN
images/ua08/EX15BT_BLC-4-004_p2.png
LFS
Normal file
BIN
images/ua08/EX15BT_BLC-4-004_p2.png
LFS
Normal file
Binary file not shown.
BIN
images/ua08/EX15BT_BLC-4-004_p3.png
LFS
Normal file
BIN
images/ua08/EX15BT_BLC-4-004_p3.png
LFS
Normal file
Binary file not shown.
BIN
images/ua08/EX15BT_BLC-4-005.png
LFS
Normal file
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
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
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
BIN
images/ua08/EX15BT_BLC-4-008.png
LFS
Normal file
Binary file not shown.
BIN
images/ua08/EX15BT_BLC-4-008_p1.png
LFS
Normal file
BIN
images/ua08/EX15BT_BLC-4-008_p1.png
LFS
Normal file
Binary file not shown.
BIN
images/ua08/EX15BT_BLC-4-009.png
LFS
Normal file
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
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
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
BIN
images/ua08/EX15BT_BLC-4-012.png
LFS
Normal file
Binary file not shown.
BIN
images/ua08/EX15BT_BLC-4-012_p1.png
LFS
Normal file
BIN
images/ua08/EX15BT_BLC-4-012_p1.png
LFS
Normal file
Binary file not shown.
BIN
images/ua08/EX15BT_BLC-4-013.png
LFS
Normal file
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
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
BIN
images/ua08/EX15BT_BLC-4-015.png
LFS
Normal file
Binary file not shown.
BIN
images/ua08/EX15BT_BLC-4-015_p1.png
LFS
Normal file
BIN
images/ua08/EX15BT_BLC-4-015_p1.png
LFS
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user