diff --git a/backend/index.js b/backend/index.js index 813bae7..0a0e510 100644 --- a/backend/index.js +++ b/backend/index.js @@ -24,7 +24,7 @@ const allowedOrigins = (process.env.CORS_ORIGINS || '') const FileStore = FileStoreFactory(session) -;['uploads/avatars', 'uploads/games', 'uploads/custom', '.sessions'].forEach((relativePath) => { +;['uploads/avatars', 'uploads/games', 'uploads/custom', 'uploads/tierlists', '.sessions'].forEach((relativePath) => { fs.mkdirSync(path.join(__dirname, relativePath), { recursive: true }) }) diff --git a/backend/src/db.js b/backend/src/db.js index 987d6e6..b325738 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -72,6 +72,7 @@ function mapTierListRow(row) { authorAvatarSrc: row.avatar_src || '', gameId: row.game_id, title: row.title, + thumbnailSrc: row.thumbnail_src || '', description: row.description || '', isPublic: !!row.is_public, groups: parseJson(row.groups_json, []), @@ -195,6 +196,7 @@ async function ensureSchema() { author_id VARCHAR(64) NOT NULL, game_id VARCHAR(120) NOT NULL, title VARCHAR(120) NOT NULL, + thumbnail_src VARCHAR(255) NOT NULL DEFAULT '', description TEXT NOT NULL, is_public TINYINT(1) NOT NULL DEFAULT 0, groups_json LONGTEXT NOT NULL, @@ -209,6 +211,11 @@ async function ensureSchema() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) + const tierListThumbnailColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'thumbnail_src'") + if (!tierListThumbnailColumns.length) { + await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title") + } + await query( ` INSERT INTO games (id, name, thumbnail_src, created_at) @@ -397,6 +404,12 @@ async function createGameItem({ id, gameId, src, label }) { return mapGameItemRow(rows[0]) } +async function updateGameItemLabel(itemId, label) { + await query('UPDATE game_items SET label = ? WHERE id = ?', [label, itemId]) + const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId]) + return mapGameItemRow(rows[0]) +} + async function deleteGameItem(itemId) { const gameItemRows = await query('SELECT game_id FROM game_items WHERE id = ? LIMIT 1', [itemId]) const gameId = gameItemRows[0]?.game_id @@ -588,6 +601,7 @@ async function listPublicTierLists(gameId) { t.id, t.game_id, t.title, + t.thumbnail_src, t.created_at, t.updated_at, t.author_id, @@ -607,6 +621,7 @@ async function listPublicTierLists(gameId) { id: row.id, gameId: row.game_id, title: row.title, + thumbnailSrc: row.thumbnail_src || '', createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), authorId: row.author_id, @@ -623,6 +638,7 @@ async function listUserTierLists(userId) { t.id, t.game_id, t.title, + t.thumbnail_src, t.created_at, t.updated_at, t.is_public, @@ -641,6 +657,7 @@ async function listUserTierLists(userId) { id: row.id, gameId: row.game_id, title: row.title, + thumbnailSrc: row.thumbnail_src || '', createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), isPublic: !!row.is_public, @@ -658,6 +675,7 @@ async function findTierListById(id) { t.author_id, t.game_id, t.title, + t.thumbnail_src, t.description, t.is_public, t.groups_json, @@ -708,17 +726,17 @@ async function deleteCustomItems(ids) { await query(`DELETE FROM custom_items WHERE id IN (${placeholders})`, ids) } -async function saveTierList({ id, authorId, gameId, title, description, isPublic, groups, pool }) { +async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', description, isPublic, groups, pool }) { const existing = id ? await findTierListById(id) : null if (existing) { await query( ` UPDATE tierlists - SET title = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ? + SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ? WHERE id = ? `, - [title, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id] + [title, thumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id] ) return findTierListById(existing.id) } @@ -727,11 +745,11 @@ async function saveTierList({ id, authorId, gameId, title, description, isPublic await query( ` INSERT INTO tierlists ( - id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at + id, author_id, game_id, title, thumbnail_src, description, is_public, groups_json, pool_json, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, - [id, authorId, gameId, title, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt] + [id, authorId, gameId, title, thumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt] ) return findTierListById(id) } @@ -755,6 +773,7 @@ module.exports = { createGame, updateGameThumbnail, createGameItem, + updateGameItemLabel, deleteGameItem, deleteGame, updateGameDisplayOrder, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index c3ee9f7..c695105 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -12,6 +12,7 @@ const { listGames, updateGameThumbnail, createGameItem, + updateGameItemLabel, deleteGameItem, deleteGame, updateGameDisplayOrder, @@ -116,6 +117,19 @@ router.delete('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => res.json({ ok: true }) }) +router.patch('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => { + const schema = z.object({ label: z.string().trim().min(1).max(60) }) + const parsed = schema.safeParse(req.body) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const game = await findGameById(req.params.gameId) + if (!game) return res.status(404).json({ error: 'not_found' }) + + const updated = await updateGameItemLabel(req.params.itemId, parsed.data.label) + if (!updated || updated.gameId !== game.id) return res.status(404).json({ error: 'not_found' }) + res.json({ item: updated }) +}) + router.delete('/games/:gameId', requireAdmin, async (req, res) => { const game = await findGameById(req.params.gameId) if (!game) return res.status(404).json({ error: 'not_found' }) diff --git a/backend/src/routes/tierlists.js b/backend/src/routes/tierlists.js index a78d8ad..db16017 100644 --- a/backend/src/routes/tierlists.js +++ b/backend/src/routes/tierlists.js @@ -52,10 +52,19 @@ const upload = multer({ limits: { fileSize: 6 * 1024 * 1024 }, }) +const thumbnailUpload = multer({ + storage: multer.diskStorage({ + destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'tierlists')), + filename: (req, file, cb) => cb(null, buildUploadFilename(file)), + }), + limits: { fileSize: 6 * 1024 * 1024 }, +}) + const tierListUpsertSchema = z.object({ id: z.string().optional(), gameId: z.string().min(1), title: z.string().min(1).max(120), + thumbnailSrc: z.string().max(255).optional().default(''), description: z.string().max(1000).optional().default(''), isPublic: z.boolean().default(false), groups: z.array( @@ -121,6 +130,11 @@ router.post('/custom-items', requireAuth, upload.single('image'), async (req, re res.json({ item }) }) +router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'file_required' }) + res.json({ thumbnailSrc: `/uploads/tierlists/${req.file.filename}` }) +}) + router.post('/', requireAuth, async (req, res) => { const parsed = tierListUpsertSchema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) @@ -137,6 +151,7 @@ router.post('/', requireAuth, async (req, res) => { authorId: existing.authorId, gameId: existing.gameId, title: payload.title, + thumbnailSrc: payload.thumbnailSrc || '', description: payload.description || '', isPublic: !!payload.isPublic, groups: payload.groups, @@ -150,6 +165,7 @@ router.post('/', requireAuth, async (req, res) => { authorId: req.session.userId, gameId: payload.gameId, title: payload.title, + thumbnailSrc: payload.thumbnailSrc || '', description: payload.description || '', isPublic: !!payload.isPublic, groups: payload.groups, diff --git a/docs/history.md b/docs/history.md index b01f235..159cebd 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-03-26 v0.1.38 +- 관리자 기본 아이템은 업로드 시점에만 이름을 정할 수 있으면 운영 중 수정이 어려우므로, 목록에서 직접 이름을 바꾸고 저장할 수 있게 하기로 결정했다. +- 게임별 티어표 목록도 식별성이 중요하므로, 사용자가 편집 시 별도 썸네일을 지정할 수 있게 하고 목록 카드에서는 게임 카드와 비슷한 상단 썸네일 구조를 사용하기로 결정했다. + ## 2026-03-19 - 초기 저장소는 빠른 구현을 위해 `lowdb(JSON 파일)`를 채택했다. - 인증은 JWT 대신 서버 세션(`express-session` + `session-file-store`)을 사용했다. diff --git a/docs/map.md b/docs/map.md index 098d78b..05ba51a 100644 --- a/docs/map.md +++ b/docs/map.md @@ -7,13 +7,13 @@ ## `/games/:gameId` - 화면 파일: `frontend/src/views/GameHubView.vue` -- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 작성자 닉네임 노출, 새 티어표 작성 진입 +- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 티어표별 상단 썸네일/작성자 표시, 새 티어표 작성 진입 - 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public` ## `/editor/:gameId/new`, `/editor/:gameId/:tierListId` - 화면 파일: `frontend/src/views/TierEditorView.vue` -- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 작성 권한 제어, 저장, 공개 여부 설정, PNG 다운로드 -- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/custom-items`, `POST /api/tierlists` +- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 티어표 썸네일 선택, 작성 권한 제어, 저장, 공개 여부 설정, PNG 다운로드 +- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists` ## `/login` - 화면 파일: `frontend/src/views/LoginView.vue` @@ -22,13 +22,13 @@ ## `/me` - 화면 파일: `frontend/src/views/MyTierListsView.vue` -- 역할: 내 티어표 목록 조회, 편집 화면으로 이동, 작성자 본인 티어표 삭제 +- 역할: 내 티어표 목록 조회, 상단 썸네일 카드 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제 - 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id` ## `/admin` - 화면 파일: `frontend/src/views/AdminView.vue` -- 역할: `게임 관리 / 아이템 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제 -- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `GET /api/admin/custom-items`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId` +- 역할: `게임 관리 / 아이템 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제 +- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId` ## `/profile` - 화면 파일: `frontend/src/views/ProfileView.vue` diff --git a/docs/spec.md b/docs/spec.md index a08fa49..5ba9e50 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -51,6 +51,7 @@ - `authorId`: string - `gameId`: string - `title`: string + - `thumbnailSrc`: string - `description`: string - `isPublic`: boolean - `groups`: `{ id, name, itemIds[] }[]` @@ -78,6 +79,7 @@ - `GET /api/tierlists/me` - `GET /api/tierlists/:id` - `DELETE /api/tierlists/:id` + - `POST /api/tierlists/thumbnail` - `POST /api/tierlists/custom-items` - `POST /api/tierlists` - 관리자 @@ -85,6 +87,7 @@ - `POST /api/admin/games/:gameId/thumbnail` - `POST /api/admin/games/:gameId/images` - 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다. + - `PATCH /api/admin/games/:gameId/items/:itemId` - `GET /api/admin/custom-items` - `DELETE /api/admin/custom-items/:itemId` - `DELETE /api/admin/custom-items` @@ -98,6 +101,7 @@ ## 관리자 화면 메모 - 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다. - 게임 기본 아이템 추가는 드래그 앤 드롭 또는 다중 파일 선택으로 처리하고, 미리보기 카드에서 여러 장을 함께 확인할 수 있다. +- 현재 기본 아이템 목록에서는 등록된 아이템 이름을 직접 수정하고 저장할 수 있다. - 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다. - 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다. - 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다. @@ -113,6 +117,7 @@ - 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다. - 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다. - 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다. +- 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다. - 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다. - 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다. - 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다. diff --git a/docs/todo.md b/docs/todo.md index b70d7b5..e29e5d1 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -6,6 +6,7 @@ - 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다. - 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다. - 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다. +- 티어표 썸네일은 현재 업로드/교체만 지원하므로, 필요하면 자동 썸네일 추출이나 업로드 이미지 크롭 UX를 추가 검토한다. ## 배포 전 작업 - NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다. diff --git a/docs/update.md b/docs/update.md index a6d2fab..770f402 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,9 @@ # 업데이트 로그 +## 2026-03-26 v0.1.38 +- **관리자 기본 아이템 이름 수정 추가**: 게임 관리 화면의 현재 기본 아이템 목록에서 이름을 직접 수정하고 저장할 수 있도록 API와 UI를 보강 +- **티어표 썸네일 추가**: 티어표 편집 화면에서 별도 썸네일 이미지를 선택해 저장할 수 있도록 업로드 흐름을 추가하고, 게임별 공개 티어표/내 티어표 목록은 게임 카드처럼 상단 썸네일 + 하단 제목/작성자 정보 카드 구조로 변경 + ## 2026-03-26 v0.1.37 - **운영 포트 설정 반영**: 프로덕션 컴포즈의 `frontend/phpMyAdmin` 외부 포트를 `18080/18081` 기준으로 유지하고, NAS 배포 문서와 기술 명세의 리버스 프록시 포트 안내도 동일하게 정리 - **인증 라우트 정리**: NAS 로그인 문제를 확인하기 위해 넣었던 `auth` 디버그 로그를 제거하고, 실제 운영에 필요한 세션 저장 보강만 유지 diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 4122ad0..48d107e 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -33,6 +33,8 @@ export const api = { listGames: () => request('/api/games'), getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`), updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }), + updateAdminGameItem: (gameId, itemId, payload) => + request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }), listAdminCustomItems: ({ q = '', page = 1, limit = 50, orphanOnly = false } = {}) => request( `/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}` @@ -50,6 +52,23 @@ export const api = { getTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`), deleteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`, { method: 'DELETE' }), saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }), + uploadTierListThumbnail: async (file) => { + const fd = new FormData() + fd.append('thumbnail', file) + const res = await fetch(toApiUrl('/api/tierlists/thumbnail'), { + method: 'POST', + credentials: 'include', + body: fd, + }) + const data = await res.json() + if (!res.ok) { + const err = new Error('request_failed') + err.status = res.status + err.data = data + throw err + } + return data + }, deleteAdminCustomItem: (itemId) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}`, { method: 'DELETE' }), deleteAdminUnusedCustomItems: ({ q = '' } = {}) => request(`/api/admin/custom-items?q=${encodeURIComponent(q)}`, { method: 'DELETE' }), diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 98105d3..32af1b7 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -196,7 +196,13 @@ async function loadGame() { try { const data = await api.getGame(selectedGameId.value) - selectedGame.value = data + selectedGame.value = { + ...data, + items: (data.items || []).map((item) => ({ + ...item, + draftLabel: item.label, + })), + } } catch (e) { error.value = '게임 정보를 불러오지 못했어요.' } @@ -346,6 +352,24 @@ async function removeGameItem(itemId) { } } +async function saveGameItemLabel(item) { + resetMessages() + if (!selectedGameId.value) return + const nextLabel = (item.draftLabel || '').trim() + if (!nextLabel) { + error.value = '아이템 이름을 입력해주세요.' + return + } + + try { + await api.updateAdminGameItem(selectedGameId.value, item.id, { label: nextLabel }) + await loadGame() + success.value = '기본 아이템 이름을 수정했어요.' + } catch (e) { + error.value = '기본 아이템 이름 수정에 실패했어요.' + } +} + async function removeGame() { resetMessages() if (!selectedGameId.value || !selectedGame.value?.game) return @@ -708,8 +732,11 @@ async function saveFeaturedOrder() {