From dbf02241e2382ae02cee755d1f5f75fa59a1c473 Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 22 Jun 2026 16:59:36 +0900 Subject: [PATCH] =?UTF-8?q?v4.0.0:=20=ED=9A=8C=EC=9B=90=C2=B7=EB=8D=B1=20A?= =?UTF-8?q?PI,=20=EC=9D=B8=EC=A6=9D=20UI,=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Express/SQLite API와 Docker 구성을 추가하고 로그인·덱 저장·공개 덱·관리자·프로필 화면을 구현했다. 결과 화면 저장 UX, 헤더 프로필, 애드센스용 app-shell 레이아웃(1280px) 및 문서를 반영했다. Co-authored-by: Cursor --- .dockerignore | 9 + .env.example | 22 + admin.html | 405 +++++++++++++++++ api/.gitignore | 3 + api/Dockerfile | 18 + api/package.json | 18 + api/src/db.js | 102 +++++ api/src/index.js | 48 ++ api/src/middleware/auth.js | 91 ++++ api/src/routes/admin.js | 250 ++++++++++ api/src/routes/auth.js | 212 +++++++++ api/src/routes/decks.js | 290 ++++++++++++ api/src/services/avatarImage.js | 65 +++ api/src/services/cardResolver.js | 65 +++ api/src/services/previewImage.js | 55 +++ api/src/utils.js | 85 ++++ cardList.html | 116 +++-- deckHistory.html | 315 +++++++++++++ docker-compose.prod.yml | 51 +++ docker-compose.yml | 33 ++ docs/api-integration.md | 174 +++++++ docs/codex-handoff.md | 10 +- docs/convention.md | 1 + docs/deploy.md | 192 ++++++++ docs/history.md | 20 + docs/map.md | 79 ++-- docs/spec.md | 167 ++++++- docs/todo.md | 26 +- docs/update.md | 144 ++++++ i18n/translations.en.json | 12 +- i18n/translations.ja.json | 12 +- i18n/translations.ko.json | 12 +- index.html | 18 +- integrations/README.md | 28 ++ integrations/example.html | 58 +++ integrations/uadeck-deck-client.js | 130 ++++++ login.html | 66 +++ nginx/Dockerfile | 11 + nginx/default.conf | 35 ++ nginx/uadeck.conf | 16 + package.json | 8 + preset.html | 156 +++++++ profile.html | 419 +++++++++++++++++ result.html | 704 +++++++++++++++++++++++++---- script/api.js | 193 ++++++++ script/auth-header.js | 207 +++++++++ script/deck-prefs.js | 55 +++ script/image-utils.js | 35 ++ script/navigation.js | 7 +- signup.html | 72 +++ style/admin.css | 519 +++++++++++++++++++++ style/app-shell.css | 132 ++++++ style/auth-header.css | 134 ++++++ style/auth-pages.css | 187 ++++++++ style/deck-history.css | 318 +++++++++++++ style/profile.css | 305 +++++++++++++ 56 files changed, 6732 insertions(+), 183 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 admin.html create mode 100644 api/.gitignore create mode 100644 api/Dockerfile create mode 100644 api/package.json create mode 100644 api/src/db.js create mode 100644 api/src/index.js create mode 100644 api/src/middleware/auth.js create mode 100644 api/src/routes/admin.js create mode 100644 api/src/routes/auth.js create mode 100644 api/src/routes/decks.js create mode 100644 api/src/services/avatarImage.js create mode 100644 api/src/services/cardResolver.js create mode 100644 api/src/services/previewImage.js create mode 100644 api/src/utils.js create mode 100644 deckHistory.html create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 docs/api-integration.md create mode 100644 docs/deploy.md create mode 100644 integrations/README.md create mode 100644 integrations/example.html create mode 100644 integrations/uadeck-deck-client.js create mode 100644 login.html create mode 100644 nginx/Dockerfile create mode 100644 nginx/default.conf create mode 100644 nginx/uadeck.conf create mode 100644 preset.html create mode 100644 profile.html create mode 100644 script/api.js create mode 100644 script/auth-header.js create mode 100644 script/deck-prefs.js create mode 100644 script/image-utils.js create mode 100644 signup.html create mode 100644 style/admin.css create mode 100644 style/app-shell.css create mode 100644 style/auth-header.css create mode 100644 style/auth-pages.css create mode 100644 style/deck-history.css create mode 100644 style/profile.css diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..eee791f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +# Docker 빌드 시 API 이미지에서 제외 +api/node_modules +api/data +.git +.env +*.md +docs/ +.cursor/ +.cursorrules diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2c2687c --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# UADECK API 환경변수 — 이 파일을 .env로 복사한 뒤 값을 채우세요 + +# --- API --- +API_PORT=3000 +JWT_SECRET=여기에-32자-이상-랜덤-시크릿-입력 +JWT_EXPIRES=7d + +# --- 웹 (docker-compose.prod.yml) --- +WEB_PORT=8080 + +# --- 관리자 (최초 기동 시 자동 생성) --- +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=change-me-8chars +ADMIN_USERNAME=admin + +# --- CORS (옆 프로젝트·로컬 개발 포트 포함, 쉼표 구분) --- +CORS_ORIGINS=https://uniare.sori.studio,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 diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..bf55a7d --- /dev/null +++ b/admin.html @@ -0,0 +1,405 @@ + + + + + + + + + 관리자 - UADECK + + + + +
+
+

관리자

+

회원과 저장된 덱을 관리합니다.

+
+ +
+ + +
+ +
+
+ + +
+
+
+ + +
+ + +
+
+
+
+
+ + + +
+
+

회원 정보

+

+
+
+ +
+
+ +
+ + +
+
+ + +
+
+ + + +
+
+ + + +
+ +
+ +
+ + +
+
+
+
+ + +
+
+
+

비밀번호 변경

+

+
+
+ +
+ + +
+
+ + +
+ +
+ + +
+
+
+
+ + + + + + + diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 0000000..9f77d70 --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +data/ +.env diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..9086c16 --- /dev/null +++ b/api/Dockerfile @@ -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"] diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..ed78c5b --- /dev/null +++ b/api/package.json @@ -0,0 +1,18 @@ +{ + "name": "uadeck-api", + "version": "4.0.0", + "type": "module", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js" + }, + "dependencies": { + "bcryptjs": "^3.0.2", + "better-sqlite3": "^11.9.1", + "cors": "^2.8.5", + "dotenv": "^16.5.0", + "express": "^5.1.0", + "jsonwebtoken": "^9.0.2" + } +} diff --git a/api/src/db.js b/api/src/db.js new file mode 100644 index 0000000..ddb22e5 --- /dev/null +++ b/api/src/db.js @@ -0,0 +1,102 @@ +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, + 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); + `); + + 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'); + } + + 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', 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) + VALUES (?, ?, ?, ?, 'admin') + `).run(email, username, hash, '관리자'); +} diff --git a/api/src/index.js b/api/src/index.js new file mode 100644 index 0000000..0e0519b --- /dev/null +++ b/api/src/index.js @@ -0,0 +1,48 @@ +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +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'; + +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'); + +if (!process.env.DATAS_PATH) { + process.env.DATAS_PATH = path.join(__dirname, '..', '..', 'datas'); +} + +const db = initDb(DB_PATH); +seedAdmin(db); + +const app = express(); + +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: '15mb' })); + +app.get('/api/v1/health', (_req, res) => { + res.json({ ok: true, version: '4.0.0' }); +}); + +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) => { + console.error(err); + res.status(500).json({ error: '서버 오류가 발생했습니다.' }); +}); + +app.listen(PORT, () => { + console.log(`UADECK API v4.0.0 listening on :${PORT}`); + console.log(`DB: ${DB_PATH}`); + console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`); +}); diff --git a/api/src/middleware/auth.js b/api/src/middleware/auth.js new file mode 100644 index 0000000..c9f9cb9 --- /dev/null +++ b/api/src/middleware/auth.js @@ -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(); +} diff --git a/api/src/routes/admin.js b/api/src/routes/admin.js new file mode 100644 index 0000000..c5a470e --- /dev/null +++ b/api/src/routes/admin.js @@ -0,0 +1,250 @@ +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 } from '../services/previewImage.js'; +import { getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js'; + +/** + * 관리자 라우트 + * @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, 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, created_at AS createdAt, updated_at AS updatedAt + FROM users ORDER BY created_at DESC LIMIT ? OFFSET ? + `).all(Number(limit), offset); + } + + res.json({ + users: 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, 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 deckCount = db.prepare('SELECT COUNT(*) AS c FROM decks WHERE user_id = ?').get(user.id).c; + user.avatarUrl = getAvatarUrl(user.id) || user.avatarUrl; + res.json({ user, deckCount }); + }); + + 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) { + fields.push('email = ?'); + values.push(trimmedEmail); + } + if (trimmedUsername) { + 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, 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/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.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), + })), + }); + }); + + router.delete('/decks/:shareCode', (req, res) => { + const row = db.prepare('SELECT id 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); + res.json({ ok: true }); + }); + + return router; +} diff --git a/api/src/routes/auth.js b/api/src/routes/auth.js new file mode 100644 index 0000000..dfc6574 --- /dev/null +++ b/api/src/routes/auth.js @@ -0,0 +1,212 @@ +import { Router } from 'express'; +import path from 'path'; +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'; + +/** + * 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', (req, res) => { + const { email, username, password } = req.body || {}; + + if (!email || !username || !password) { + return res.status(400).json({ error: '이메일, 사용자명, 비밀번호는 필수입니다.' }); + } + if (password.length < 8) { + return res.status(400).json({ error: '비밀번호는 8자 이상이어야 합니다.' }); + } + + const hash = bcrypt.hashSync(password, 12); + + try { + const trimmedUsername = username.trim(); + const result = db.prepare(` + INSERT INTO users (email, username, password_hash, display_name) + VALUES (?, ?, ?, ?) + `).run(email.trim().toLowerCase(), trimmedUsername, hash, trimmedUsername); + + const user = db.prepare(` + SELECT id, email, username, role, created_at AS createdAt, updated_at AS updatedAt + FROM users WHERE id = ? + `).get(result.lastInsertRowid); + + const safeUser = toUserResponse(user); + const token = signToken(safeUser); + res.status(201).json({ token, user: safeUser }); + } catch (err) { + if (String(err.message).includes('UNIQUE')) { + return res.status(409).json({ error: '이미 사용 중인 이메일 또는 사용자명입니다.' }); + } + throw err; + } + }); + + router.post('/login', (req, res) => { + const { email, password } = req.body || {}; + if (!email || !password) { + return res.status(400).json({ error: '이메일과 비밀번호를 입력하세요.' }); + } + + const user = db.prepare(` + SELECT id, email, username, password_hash, display_name, avatar_url, role, is_active + FROM users WHERE email = ? + `).get(email.trim().toLowerCase()); + + if (!user || !user.is_active) { + return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' }); + } + + if (!bcrypt.compareSync(password, user.password_hash)) { + return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' }); + } + + const row = db.prepare(` + SELECT id, email, username, avatar_url, role, updated_at AS updatedAt + FROM users WHERE id = ? + `).get(user.id); + + const safeUser = toUserResponse(row); + res.json({ token: signToken(safeUser), user: safeUser }); + }); + + router.get('/me', requireAuth, (req, res) => { + 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 = ? 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) { + fields.push('email = ?'); + values.push(trimmedEmail); + } + if (trimmedUsername) { + 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: '비밀번호가 변경되었습니다.' }); + }); + + return router; +} diff --git a/api/src/routes/decks.js b/api/src/routes/decks.js new file mode 100644 index 0000000..ebc77d8 --- /dev/null +++ b/api/src/routes/decks.js @@ -0,0 +1,290 @@ +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, getPreviewFilePath, getPreviewUrl } 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), + }; +} + +/** + * 작성자 정보 조회 + * @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(); + + 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), + })), + }); + } 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', 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), + })), + }); + } catch (err) { + next(err); + } + }); + + router.post('/', optionalAuth, async (req, res, next) => { + try { + const { series, seriesName, name, cards, isPublic, previewImage } = 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})`); + } + } + + 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 } = 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); + } + + 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); + res.json({ ok: true }); + }); + + return router; +} diff --git a/api/src/services/avatarImage.js b/api/src/services/avatarImage.js new file mode 100644 index 0000000..0c8799c --- /dev/null +++ b/api/src/services/avatarImage.js @@ -0,0 +1,65 @@ +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 + */ +export function deleteAvatarImage(userId) { + const filePath = path.join(getAvatarsDir(), `${userId}.png`); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } +} diff --git a/api/src/services/cardResolver.js b/api/src/services/cardResolver.js new file mode 100644 index 0000000..b80c437 --- /dev/null +++ b/api/src/services/cardResolver.js @@ -0,0 +1,65 @@ +import { pathToFileURL } from 'url'; +import path from 'path'; + +/** @type {Map>} */ +const cache = new Map(); + +/** + * 시리즈 카드 맵 로드 + * @param {string} seriesKey + * @returns {Promise>} + */ +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(); + for (const card of mod.cardList) { + byNumber.set(card.Number, card); + } + + cache.set(seriesKey, byNumber); + return byNumber; +} + +/** + * 저장된 덱 카드를 풀 카드 객체로 합성 + * @param {string} seriesKey + * @param {Array<{number: string, count: number, key: boolean}>} cards + * @returns {Promise<{cards: object[], totalCards: number}>} + */ +export async function resolveDeckCards(seriesKey, cards) { + const map = await getCardMap(seriesKey); + const resolved = []; + let totalCards = 0; + + for (const item of cards) { + const base = map.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 }; +} diff --git a/api/src/services/previewImage.js b/api/src/services/previewImage.js new file mode 100644 index 0000000..c24dd0d --- /dev/null +++ b/api/src/services/previewImage.js @@ -0,0 +1,55 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** + * 미리보기 이미지 저장 디렉터리 + * @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; +} + +/** + * @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 + * @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; +} diff --git a/api/src/utils.js b/api/src/utils.js new file mode 100644 index 0000000..252d67a --- /dev/null +++ b/api/src/utils.js @@ -0,0 +1,85 @@ +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<{number: string, count: number, key: boolean}>} cards + * @returns {string} + */ +export function normalizeCards(cards) { + const sorted = [...cards] + .map((c) => ({ + number: String(c.number).trim(), + count: Number(c.count), + key: Boolean(c.key), + })) + .sort((a, b) => a.number.localeCompare(b.number)); + return JSON.stringify(sorted); +} + +/** + * 프리셋 덱용 내용 해시 (8자) + * @param {Array<{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<{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.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 { + 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(); +} diff --git a/cardList.html b/cardList.html index bdb5d59..617c214 100644 --- a/cardList.html +++ b/cardList.html @@ -6,7 +6,9 @@ Card List + + @@ -81,12 +83,13 @@
- +
검색결과 : 0
+
@@ -240,6 +243,8 @@ let cardList = []; let selectedCards = []; let selectedColors = new Set(['All']); + let currentShareCode = null; + let currentDeckName = ''; const getEl = () => ({ searchQuery: document.getElementById('searchQuery'), @@ -253,37 +258,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(); @@ -466,6 +497,14 @@ } // 전역 핸들러 + window.goToHome = function () { + const lang = + new URLSearchParams(window.location.search).get('lang') || + localStorage.getItem('lang') || + 'ko'; + window.location.href = lang && lang !== 'ko' ? `/?lang=${lang}` : '/'; + }; + window.handleCount = function (imgSrc, delta, event) { event.stopPropagation(); const card = cardList.find((c) => c.imgSrc === imgSrc); @@ -605,24 +644,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(); } @@ -713,8 +751,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 +791,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 +865,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 +928,6 @@ }); } + diff --git a/deckHistory.html b/deckHistory.html new file mode 100644 index 0000000..49c38d8 --- /dev/null +++ b/deckHistory.html @@ -0,0 +1,315 @@ + + + + + + + + + + + 내 덱 관리 - UADECK + + + + +
+
+
+ +
+
+
+
+

내 덱 관리

+

저장한 덱 목록입니다. 공개 전환 시「공개 덱」목록에 표시됩니다.

+
+ + + +
+
불러오는 중...
+
+
+ + +
+
+ + + + + + diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..bb352f0 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,51 @@ +# UADECK v4 프로덕션 스택 (NAS Docker) +# 사용: docker compose -f docker-compose.prod.yml up -d --build +# +# 정적 파일(HTML, images, datas)은 호스트 볼륨 마운트 — 카드/이미지 추가 시 재빌드 불필요 + +services: + uadeck-api: + build: ./api + container_name: uadeck-api + restart: unless-stopped + expose: + - "3000" + environment: + - PORT=3000 + - JWT_SECRET=${JWT_SECRET} + - JWT_EXPIRES=${JWT_EXPIRES:-7d} + - 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: + - uadeck-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 + + uadeck-web: + image: nginx:alpine + container_name: uadeck-web + restart: unless-stopped + ports: + - "${WEB_PORT:-8080}:80" + volumes: + - ./:/usr/share/nginx/html:ro + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + uadeck-api: + condition: service_healthy + +volumes: + uadeck-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..efcedf9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + uadeck-api: + build: ./api + container_name: uadeck-api + restart: unless-stopped + ports: + - "${API_PORT:-3000}:3000" + environment: + - PORT=3000 + - JWT_SECRET=${JWT_SECRET} + - JWT_EXPIRES=${JWT_EXPIRES:-7d} + - 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: + - uadeck-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: + uadeck-data: diff --git a/docs/api-integration.md b/docs/api-integration.md new file mode 100644 index 0000000..5f34453 --- /dev/null +++ b/docs/api-integration.md @@ -0,0 +1,174 @@ +# 옆 프로젝트 API 연동 가이드 + +UADECK 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 얻는 방법 + +| 방법 | 예시 | +|---|---| +| UADECK 공유 버튼 | `https://uniare.sori.studio/d/x7Kp9m2Q` | +| URL 파라미터 | `cardList.html?d=x7Kp9m2Q` | +| 코드만 | `x7Kp9m2Q` | + +## 클라이언트 라이브러리 + +프로젝트에 포함된 파일: + +``` +integrations/uadeck-deck-client.js +``` + +### 브라우저 + +```html + + +``` + +### 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`를 쓰려면 UADECK 서버 `.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 + } + ] +} +``` + +프리셋도 동일하게 `GET /decks/:shareCode`로 상세 조회합니다. + +## 에러 코드 + +| HTTP | 의미 | +|---|---| +| 404 | shareCode 없음 | +| 403 | 비공개 덱 | +| 410 | 만료된 임시 공유 (비회원 30일) | diff --git a/docs/codex-handoff.md b/docs/codex-handoff.md index 8908b7c..a84d34a 100644 --- a/docs/codex-handoff.md +++ b/docs/codex-handoff.md @@ -5,12 +5,12 @@ - 특히 이 저장소는 정적 HTML/JS 프로젝트이면서 카드 데이터와 이미지 자산 비중이 크기 때문에, "코드 수정"보다 "데이터-번역-이미지 일치"가 더 중요하다. ## 현재 기준 릴리즈 -- 현재 작업 기준 버전: `v3.1.0` +- 현재 작업 기준 버전: `v4.0.0` - 이번 릴리즈 핵심 반영: -- `ua51` 시리즈 추가 -- `ua49` 누락 병렬 이미지 보강 -- 다국어 시리즈명 추가 -- 버전 표기 및 문서 체계 정리 +- 회원·인증·덱 API (`api/`, Docker) +- 공유 코드 기반 덱 공유 (`/d/:code`, `GET /api/v1/decks/:shareCode`) +- 관리자 화면 (`admin.html`) +- `?cards=` URL 방식 폐기 ## 다음 작업자에게 반드시 전달할 것 - 이 프로젝트는 빌드 시스템 중심 앱이 아니라 `index.html`, `cardList.html`, `result.html` 3개 페이지가 직접 동작하는 정적 사이트다. diff --git a/docs/convention.md b/docs/convention.md index 67829c4..0e475b0 100644 --- a/docs/convention.md +++ b/docs/convention.md @@ -24,6 +24,7 @@ ## CSS 규칙 - 공통 UI는 가능한 한 Tailwind 유틸리티 클래스를 우선 사용한다. +- `style/output.css`에 포함되지 않은 유틸리티 클래스는 적용되지 않으므로, 페이지 특화 스타일은 인라인 ` + + +

UADECK API 연동 예제

+

공유 링크 또는 shareCode로 덱을 불러옵니다.

+ + + + + + + + + +

결과

+
—
+
+ + + + + diff --git a/integrations/uadeck-deck-client.js b/integrations/uadeck-deck-client.js new file mode 100644 index 0000000..75ecaef --- /dev/null +++ b/integrations/uadeck-deck-client.js @@ -0,0 +1,130 @@ +/** + * UADECK 덱 API 클라이언트 (옆 프로젝트 연동용) + * + * @example + * const client = new UaDeckDeckClient({ baseUrl: 'https://uniare.sori.studio/api/v1' }); + * const deck = await client.fetchDeck('x7Kp9m2Q'); + * deck.cards.forEach(c => console.log(c.number, c.count, c.imgSrc)); + */ +class UaDeckDeckClient { + /** + * @param {object} options + * @param {string} options.baseUrl API 베이스 (예: https://uniare.sori.studio/api/v1) + * @param {string} [options.imageBase] 이미지 호스트 (기본: baseUrl에서 /api/v1 제거) + */ + constructor(options = {}) { + this.baseUrl = (options.baseUrl || '/api/v1').replace(/\/$/, ''); + this.imageBase = options.imageBase || this.baseUrl.replace(/\/api\/v1$/, ''); + } + + /** + * 공유 코드로 덱 조회 + * @param {string} shareCode + * @returns {Promise} + */ + async fetchDeck(shareCode) { + const res = await fetch(`${this.baseUrl}/decks/${encodeURIComponent(shareCode)}`); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || `덱 조회 실패 (${res.status})`); + } + return this._normalizeDeck(data); + } + + /** + * URL에서 shareCode 추출 + * @param {string} url 예: https://uniare.sori.studio/d/x7Kp9m2Q 또는 ?d=x7Kp9m2Q + * @returns {string|null} + */ + static parseShareCode(url) { + try { + const u = new URL(url, 'https://placeholder.local'); + const fromQuery = u.searchParams.get('d'); + if (fromQuery) return fromQuery; + const match = u.pathname.match(/\/d\/([A-Za-z0-9]+)/); + return match ? match[1] : null; + } catch { + return null; + } + } + + /** + * URL 또는 코드로 덱 로드 + * @param {string} urlOrCode + * @returns {Promise} + */ + async fetchFromUrl(urlOrCode) { + const code = UaDeckDeckClient.parseShareCode(urlOrCode) || urlOrCode; + return this.fetchDeck(code); + } + + /** + * 카드 이미지 전체 URL + * @param {string} imgSrc API 응답의 imgSrc (예: /ua36/UA36BT_MCR-1-052.png) + * @returns {string} + */ + imageUrl(imgSrc) { + return `${this.imageBase}/images${imgSrc.startsWith('/') ? imgSrc : `/${imgSrc}`}`; + } + + /** + * @param {object} raw API 응답 + * @returns {UaDeckDeck} + */ + _normalizeDeck(raw) { + return { + shareCode: raw.shareCode, + shareType: raw.shareType, + series: raw.series, + seriesName: raw.seriesName, + name: raw.name, + totalCards: raw.totalCards, + author: raw.author, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + viewCount: raw.viewCount, + cards: (raw.cards || []).map((c) => ({ + number: c.number, + count: c.count, + key: Boolean(c.key), + imgSrc: c.imgSrc, + imageUrl: this.imageUrl(c.imgSrc), + color: c.color, + rarity: c.rarity, + trigger: c.trigger, + parallel: Boolean(c.parallel), + })), + }; + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { UaDeckDeckClient }; +} +if (typeof window !== 'undefined') { + window.UaDeckDeckClient = UaDeckDeckClient; +} + +/** + * @typedef {object} UaDeckCard + * @property {string} number + * @property {number} count + * @property {boolean} key + * @property {string} imgSrc + * @property {string} imageUrl + * @property {string} color + * @property {string} rarity + * @property {string|null} trigger + * @property {boolean} parallel + */ + +/** + * @typedef {object} UaDeckDeck + * @property {string} shareCode + * @property {string} shareType + * @property {string} series + * @property {string} seriesName + * @property {string} name + * @property {number} totalCards + * @property {UaDeckCard[]} cards + */ diff --git a/login.html b/login.html new file mode 100644 index 0000000..c015c74 --- /dev/null +++ b/login.html @@ -0,0 +1,66 @@ + + + + + + + + + 로그인 - UADECK + + + + +
+
+

로그인

+

덱을 저장하고 관리하려면
계정으로 로그인하세요.

+
+ +
+
+ + +
+
+ + +
+ + +
+ + +
+ + + + + diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..9710663 --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,11 @@ +FROM nginx:alpine + +COPY nginx/default.conf /etc/nginx/conf.d/default.conf + +# 정적 사이트 (docker-compose build context = 프로젝트 루트) +COPY index.html cardList.html result.html login.html signup.html deckHistory.html preset.html admin.html /usr/share/nginx/html/ +COPY script /usr/share/nginx/html/script +COPY style /usr/share/nginx/html/style +COPY i18n /usr/share/nginx/html/i18n +COPY datas /usr/share/nginx/html/datas +COPY images /usr/share/nginx/html/images diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..c4303a0 --- /dev/null +++ b/nginx/default.conf @@ -0,0 +1,35 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + client_max_body_size 2m; + + # 공유 링크: /d/x7Kp9m2Q → cardList?d= + location ~ ^/d/([A-Za-z0-9]+)$ { + return 302 /cardList.html?d=$1; + } + + # API → 백엔드 컨테이너 + location /api/ { + proxy_pass http://uadeck-api:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 정적 자산 캐시 + location ~* \.(js|css|png|jpg|jpeg|gif|webp|ico|svg|woff2?)$ { + expires 7d; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/nginx/uadeck.conf b/nginx/uadeck.conf new file mode 100644 index 0000000..9e21859 --- /dev/null +++ b/nginx/uadeck.conf @@ -0,0 +1,16 @@ +# /d/:shareCode → cardList 리다이렉트 +# 리버스 프록시(Nginx, Caddy, Traefik)에 아래 규칙을 추가하세요. + +location ~ ^/d/([A-Za-z0-9]+)$ { + return 302 /cardList.html?d=$1; +} + +# API 프록시 (정적 사이트와 함께 사용) +location /api/ { + proxy_pass http://uadeck-api:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} diff --git a/package.json b/package.json index 7714e6c..df7d855 100644 --- a/package.json +++ b/package.json @@ -2,5 +2,13 @@ "dependencies": { "@tailwindcss/cli": "^4.1.12", "tailwindcss": "^4.1.12" + }, + "scripts": { + "start": "docker compose -f docker-compose.prod.yml up -d --build && printf '\\n✅ UADECK 실행 중 → http://localhost:8080\\n 종료: npm stop\\n 로그: npm run logs\\n\\n'", + "stop": "docker compose -f docker-compose.prod.yml down", + "logs": "docker compose -f docker-compose.prod.yml logs -f", + "dev": "docker compose -f docker-compose.prod.yml up --build", + "api:dev": "node --env-file=.env --watch api/src/index.js", + "dev:down": "npm run stop" } } diff --git a/preset.html b/preset.html new file mode 100644 index 0000000..f0e9dde --- /dev/null +++ b/preset.html @@ -0,0 +1,156 @@ + + + + + + + + + + + 공개 덱 - UADECK + + + + +
+
+
+ +
+
+
+
+

공개 덱

+

사용자가 공개로 전환한 덱 목록입니다. 내 덱 관리에서「공개 전환」하면 여기에 표시됩니다.

+
+ + + +
+
불러오는 중...
+
+
+ + +
+
+ + + + + + diff --git a/profile.html b/profile.html new file mode 100644 index 0000000..4f37995 --- /dev/null +++ b/profile.html @@ -0,0 +1,419 @@ + + + + + + + + + + + 내 프로필 - UADECK + + + + +
+
+
+ +
+
+
+
+

내 프로필

+

프로필 사진, 사용자명, 이메일, 비밀번호를 변경할 수 있습니다.

+
+ +
+
+

불러오는 중...

+
+
+ + +
+ + +
+
+ +
+
+

비밀번호 변경

+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
+ + + + + + + + diff --git a/result.html b/result.html index f5fa46c..2d7f533 100644 --- a/result.html +++ b/result.html @@ -5,6 +5,8 @@ + + Deck Result - UA Deck Builder
-