diff --git a/backend/src/db.js b/backend/src/db.js index b9c9c97..044776b 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -6,6 +6,7 @@ const DB_USER = process.env.DB_USER || 'root' const DB_PASSWORD = process.env.DB_PASSWORD || '' const DB_NAME = process.env.DB_NAME || 'tier_cursor' const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10 +const FREEFORM_GAME_ID = 'freeform' let poolPromise = null let initPromise = null @@ -182,16 +183,17 @@ async function ensureSchema() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) - await query(` - CREATE TABLE IF NOT EXISTS game_suggestions ( - id VARCHAR(64) PRIMARY KEY, - name VARCHAR(120) NOT NULL, - created_at BIGINT NOT NULL - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 - `) + await query( + ` + INSERT INTO games (id, name, thumbnail_src, created_at) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE name = VALUES(name) + `, + [FREEFORM_GAME_ID, '직접 티어표 만들기', '', now()] + ) const countRows = await query('SELECT COUNT(*) AS count FROM games') - if (Number(countRows[0]?.count || 0) === 0) { + if (Number(countRows[0]?.count || 0) <= 1) { const createdAt = now() await query( ` @@ -278,7 +280,10 @@ async function updateUserProfile({ id, nickname, avatarSrc }) { } async function listGames() { - const rows = await query('SELECT id, name, thumbnail_src, created_at FROM games ORDER BY created_at ASC, name ASC') + const rows = await query( + 'SELECT id, name, thumbnail_src, created_at FROM games WHERE id <> ? ORDER BY created_at ASC, name ASC', + [FREEFORM_GAME_ID] + ) return rows.map(mapGameRow) } @@ -373,10 +378,33 @@ async function createCustomItem({ id, ownerId, src, label }) { return { id, ownerId, src, label, origin: 'custom', createdAt } } -async function createGameSuggestion({ id, name }) { - const createdAt = now() - await query('INSERT INTO game_suggestions (id, name, created_at) VALUES (?, ?, ?)', [id, name, createdAt]) - return { id, name, createdAt } +async function listCustomItems() { + const rows = await query( + ` + SELECT + c.id, + c.owner_id, + c.src, + c.label, + c.created_at, + u.nickname, + u.email + FROM custom_items c + INNER JOIN users u ON u.id = c.owner_id + ORDER BY c.created_at DESC + LIMIT 200 + ` + ) + + return rows.map((row) => ({ + id: row.id, + ownerId: row.owner_id, + src: row.src, + label: row.label, + createdAt: Number(row.created_at), + ownerName: row.nickname || row.email, + ownerEmail: row.email, + })) } async function listPublicTierLists(gameId) { @@ -498,7 +526,7 @@ module.exports = { deleteGameItem, deleteGame, createCustomItem, - createGameSuggestion, + listCustomItems, listPublicTierLists, listUserTierLists, findTierListById, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 7043340..151a657 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -10,6 +10,7 @@ const { createGameItem, deleteGameItem, deleteGame, + listCustomItems, } = require('../db') const { requireAdmin } = require('../middleware/auth') @@ -77,4 +78,9 @@ router.delete('/games/:gameId', requireAdmin, async (req, res) => { res.json({ ok: true }) }) +router.get('/custom-items', requireAdmin, async (req, res) => { + const items = await listCustomItems() + res.json({ items }) +}) + module.exports = router diff --git a/backend/src/routes/games.js b/backend/src/routes/games.js index f7e19a5..f6c999b 100644 --- a/backend/src/routes/games.js +++ b/backend/src/routes/games.js @@ -1,7 +1,5 @@ const express = require('express') -const { z } = require('zod') -const { nanoid } = require('nanoid') -const { listGames, getGameDetail, createGameSuggestion } = require('../db') +const { listGames, getGameDetail } = require('../db') const router = express.Router() @@ -16,16 +14,4 @@ router.get('/:gameId', async (req, res) => { res.json({ game: detail.game, items: detail.items }) }) -router.post('/suggest', async (req, res) => { - const schema = z.object({ name: z.string().min(1).max(60) }) - const parsed = schema.safeParse(req.body) - if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - - const suggestion = await createGameSuggestion({ - id: nanoid(), - name: parsed.data.name, - }) - res.json({ suggestion }) -}) - module.exports = router diff --git a/docs/history.md b/docs/history.md index 4b8794a..e7f785b 100644 --- a/docs/history.md +++ b/docs/history.md @@ -45,3 +45,8 @@ - 관리자 업로드 작업은 "파일 선택 후 적용"이 더 정확하므로, 썸네일 버튼 문구와 활성화 조건을 그 흐름에 맞추기로 결정했다. - 작은 화면에서 미리보기가 실제 작업 영역을 압박하지 않도록, 아이템 미리보기는 정사각형을 유지하되 최대 크기를 제한하는 방향을 채택했다. - 파일 입력은 업로드 성공 후와 게임 전환 시 초기화해 같은 파일 재선택이 막히지 않도록 정리했다. + +## 2026-03-19 v0.1.11 +- 관리자 화면은 좌우 여백이 크게 남는 구조보다, 상단 2열 작업 카드와 하단 목록 영역으로 나누는 편이 더 안정적이라고 판단해 레이아웃을 재정리했다. +- 게임 목록에 없는 주제로도 바로 작업할 수 있도록, 시스템 전용 `freeform` 게임을 내부적으로 유지하고 홈 화면에서는 `직접 티어표 만들기` 카드로 노출하기로 결정했다. +- 게임 제안은 현재 운영 흐름과 맞지 않아 사용자 진입점과 프런트 API에서 제거하고, 대신 관리자에게는 사용자 커스텀 아이템 검토 기능을 제공하기로 했다. diff --git a/docs/map.md b/docs/map.md index 0949dcc..98b72e4 100644 --- a/docs/map.md +++ b/docs/map.md @@ -2,8 +2,8 @@ ## `/` - 화면 파일: `frontend/src/views/HomeView.vue` -- 역할: 게임 목록 표시, 게임 카드 클릭 이동, 새 게임 제안 모달 -- 연동 API: `GET /api/games`, `POST /api/games/suggest` +- 역할: 게임 목록 표시, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입 +- 연동 API: `GET /api/games` ## `/games/:gameId` - 화면 파일: `frontend/src/views/GameHubView.vue` @@ -27,8 +27,8 @@ ## `/admin` - 화면 파일: `frontend/src/views/AdminView.vue` -- 역할: 작업 모드 선택, 기존 게임 선택 또는 새 게임 생성, 선택된 게임의 썸네일/아이템 관리, 파일 선택 즉시 미리보기, 파일 입력 초기화, 아이템 삭제, 게임 삭제 -- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `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`, `GET /api/admin/custom-items`, `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 a105048..13e5887 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -30,6 +30,7 @@ - `name`: string - `thumbnailSrc`: string - `createdAt`: number + - 시스템 전용 `freeform` 레코드는 홈 화면의 `직접 티어표 만들기` 저장 대상이며 일반 게임 목록에서는 숨긴다. - `gameItems` - `id`: string - `gameId`: string @@ -69,7 +70,6 @@ - 게임 - `GET /api/games` - `GET /api/games/:gameId` - - `POST /api/games/suggest` - 티어표 - `GET /api/tierlists/public` - `GET /api/tierlists/me` @@ -80,6 +80,7 @@ - `POST /api/admin/games` - `POST /api/admin/games/:gameId/thumbnail` - `POST /api/admin/games/:gameId/images` + - `GET /api/admin/custom-items` - `DELETE /api/admin/games/:gameId/items/:itemId` - `DELETE /api/admin/games/:gameId` @@ -88,6 +89,7 @@ - 아이템 추가는 이름 입력, 파일 선택, 1:1 미리보기 확인 뒤 저장하는 흐름이다. - 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다. - 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다. +- 사용자 업로드 커스텀 아이템은 관리자 화면 하단 검토 영역에서 목록/다운로드할 수 있다. ## 운영 환경 변수 - 프런트엔드 diff --git a/docs/todo.md b/docs/todo.md index 8ec2e82..42e1ad2 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,7 +1,7 @@ # 할 일 및 이슈 ## 즉시 확인 필요 -- 관리자 화면에 게임 제안(`gameSuggestions`) 조회/처리 UI가 아직 없다. +- 사용자 커스텀 아이템을 관리자 기본 템플릿으로 승격하는 승인/복제 흐름은 아직 없다. ## 배포 전 작업 - NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다. @@ -13,4 +13,4 @@ ## 중기 개선 - 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다. - 자동 테스트와 최소한의 배포 체크리스트를 만든다. -- 관리자용 게임 제안 승인/반려, 아이템 정렬 UI를 추가한다. +- 관리자용 커스텀 아이템 승인/복제, 아이템 정렬 UI를 추가한다. diff --git a/docs/update.md b/docs/update.md index f7c4fad..5b95b70 100644 --- a/docs/update.md +++ b/docs/update.md @@ -73,3 +73,9 @@ - **아이템 추가 폼 정리**: 아이템 이름 입력 너비를 줄이고, 과한 미리보기 안내 문구를 제거해 작업 집중도를 높임 - **반응형 미리보기 보정**: 태블릿 이하 화면에서도 아이템 1:1 미리보기가 최대 `192px` 범위 안에서 보이도록 조정 - **파일 재선택 버그 수정**: 아이템 추가나 게임 전환 뒤 파일 입력 값을 초기화해 같은 이미지를 다시 선택해도 정상 인식되도록 수정 + +## 2026-03-19 v0.1.11 +- **관리자 레이아웃 재구성**: 인라인 스타일을 제거하고, 썸네일 적용과 아이템 추가를 상단 2열 카드로 재배치한 뒤 아이템 목록은 하단 리스트로 분리 +- **직접 티어표 만들기 추가**: 홈 화면에 게임 카드와 동일한 형태의 `직접 티어표 만들기` 진입점을 추가하고, 내부 전용 `freeform` 게임 레코드로 1회성 빈 티어표 저장 흐름을 지원 +- **게임 제안 흐름 제거**: 홈 화면의 `새로운 게임 제안` 버튼/모달과 관련 프런트 API를 제거해 현재 운영 흐름에 맞게 단순화 +- **커스텀 아이템 검토 영역 추가**: 관리자 페이지에서 사용자 업로드 커스텀 아이템을 목록으로 보고 다운로드할 수 있는 검토 영역과 조회 API를 추가 diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 8448b7d..2e09c46 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -32,7 +32,7 @@ export const api = { listGames: () => request('/api/games'), getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`), - suggestGame: (name) => request('/api/games/suggest', { method: 'POST', body: { name } }), + listAdminCustomItems: () => request('/api/admin/custom-items'), listPublicTierLists: (gameId) => request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`), diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index edeb5e6..064b454 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -8,6 +8,7 @@ const auth = useAuthStore() const isAdmin = computed(() => !!auth.user?.isAdmin) const games = ref([]) +const customItems = ref([]) const adminMode = ref('existing') const selectedGameId = ref('') const selectedGame = ref(null) @@ -26,9 +27,13 @@ const thumbPreviewUrl = ref('') const itemFileInput = ref(null) const thumbFileInput = ref(null) +const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id) +const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value) +const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value) + onMounted(async () => { await auth.refresh() - await refreshGames() + await Promise.all([refreshGames(), refreshCustomItems()]) }) onUnmounted(() => { @@ -36,8 +41,6 @@ onUnmounted(() => { clearPreviewUrl('thumb') }) -const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id) - async function refreshGames() { try { const data = await api.listGames() @@ -47,11 +50,31 @@ async function refreshGames() { } } +async function refreshCustomItems() { + if (!auth.user?.isAdmin) return + try { + const data = await api.listAdminCustomItems() + customItems.value = data.items || [] + } catch (e) { + error.value = '사용자 커스텀 아이템을 불러오지 못했어요.' + } +} + function resetMessages() { error.value = '' success.value = '' } +function resetUploadState() { + uploadLabel.value = '' + uploadFile.value = null + thumbFile.value = null + resetFileInput('item') + resetFileInput('thumb') + clearPreviewUrl('item') + clearPreviewUrl('thumb') +} + function setMode(mode) { resetMessages() adminMode.value = mode @@ -59,13 +82,7 @@ function setMode(mode) { selectedGame.value = null newGameId.value = '' newGameName.value = '' - uploadLabel.value = '' - uploadFile.value = null - thumbFile.value = null - resetFileInput('item') - resetFileInput('thumb') - clearPreviewUrl('item') - clearPreviewUrl('thumb') + resetUploadState() } function clearPreviewUrl(type) { @@ -90,13 +107,7 @@ function resetFileInput(type) { async function loadGame() { resetMessages() - uploadLabel.value = '' - uploadFile.value = null - thumbFile.value = null - resetFileInput('item') - resetFileInput('thumb') - clearPreviewUrl('item') - clearPreviewUrl('thumb') + resetUploadState() if (!selectedGameId.value) { selectedGame.value = null @@ -195,10 +206,7 @@ async function uploadItem() { }) if (!res.ok) throw new Error('failed') - uploadLabel.value = '' - uploadFile.value = null - resetFileInput('item') - clearPreviewUrl('item') + resetUploadState() await loadGame() success.value = '아이템이 추가됐어요.' } catch (e) { @@ -242,13 +250,7 @@ async function removeGame() { const deletedName = selectedGame.value.game.name selectedGameId.value = '' selectedGame.value = null - uploadLabel.value = '' - uploadFile.value = null - thumbFile.value = null - resetFileInput('item') - resetFileInput('thumb') - clearPreviewUrl('item') - clearPreviewUrl('thumb') + resetUploadState() await refreshGames() success.value = `${deletedName} 게임을 삭제했어요.` } catch (e) { @@ -262,15 +264,22 @@ const displayThumbnailUrl = computed(() => { return '' }) -const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value) -const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value) +function fmt(ts) { + return new Date(ts).toLocaleString(undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} @@ -448,25 +478,28 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value) padding-top: 18px; border-top: 1px solid rgba(255, 255, 255, 0.08); } +.section--topGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} +.adminCard { + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.03); + border-radius: 16px; + padding: 14px; + min-width: 0; +} .uploadPreviewCard { margin-top: 10px; + display: flex; + justify-content: center; +} +.uploadControls { + margin-top: 14px; display: grid; gap: 12px; - align-items: start; -} -.uploadPreviewCard--wide { - grid-template-columns: minmax(256px, 256px) minmax(0, 1fr); -} -.uploadPreviewMeta { - display: grid; - gap: 8px; -} -.uploadPreviewTitle { - font-weight: 900; -} -.uploadPreviewDesc { - opacity: 0.76; - line-height: 1.5; + justify-items: center; } .select, .input { @@ -481,16 +514,22 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value) margin-top: 10px; } .input--compact { - max-width: 360px; + max-width: 320px; } .hint { margin-top: 10px; opacity: 0.78; font-size: 13px; } +.hint--tight { + margin-top: 6px; +} .inputFile { width: 100%; - margin-top: 12px; + max-width: 360px; +} +.inputFile--tight { + margin-top: 0; } .btn { margin-top: 12px; @@ -501,6 +540,8 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value) color: rgba(255, 255, 255, 0.92); cursor: pointer; font-weight: 800; + text-align: center; + text-decoration: none; } .btn:disabled { cursor: not-allowed; @@ -513,6 +554,9 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value) background: rgba(239, 68, 68, 0.14); border-color: rgba(239, 68, 68, 0.28); } +.btn--ghost { + background: rgba(255, 255, 255, 0.03); +} .btn--small { width: 100%; } @@ -537,9 +581,6 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value) display: flex; gap: 8px; } -.thumbnailRow { - margin-top: 10px; -} .selectedThumb { width: min(100%, 256px); aspect-ratio: 16 / 9; @@ -561,7 +602,9 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value) align-items: start; } .itemComposer__form { - min-width: 0; + display: grid; + gap: 12px; + align-items: start; } .itemPreviewCard { padding: 10px; @@ -621,8 +664,47 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value) .thumbLabel--preview { text-align: center; } +.sectionHeader { + display: flex; + gap: 12px; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; +} +.customItemGrid { + margin-top: 14px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px; +} +.customItemCard { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 16px; + background: rgba(255, 255, 255, 0.04); + overflow: hidden; +} +.customItemCard__image { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + display: block; + background: rgba(0, 0, 0, 0.18); +} +.customItemCard__body { + display: grid; + gap: 6px; + padding: 12px; +} +.customItemCard__title { + font-weight: 900; +} +.customItemCard__meta { + opacity: 0.72; + font-size: 13px; + word-break: break-word; +} @media (max-width: 980px) { - .uploadPreviewCard--wide { + .section--topGrid { grid-template-columns: 1fr; } .itemComposer { @@ -636,11 +718,12 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value) .selectedThumb { width: min(100%, 256px); } + .thumbGrid, + .customItemGrid { + grid-template-columns: 1fr; + } .itemPreviewCard { max-width: 192px; } - .thumbGrid { - grid-template-columns: 1fr; - } } diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 73488b9..28a84ca 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -9,9 +9,6 @@ const router = useRouter() const items = ref([]) const error = ref('') const games = computed(() => items.value) -const suggestOpen = ref(false) -const suggestName = ref('') -const suggestError = ref('') onMounted(async () => { try { @@ -26,26 +23,15 @@ function goGame(gameId) { router.push(`/games/${gameId}`) } +function goFreeform() { + router.push('/editor/freeform/new') +} + function thumbUrl(g) { if (!g.thumbnailSrc) return '' return toApiUrl(g.thumbnailSrc) } -async function submitSuggest() { - suggestError.value = '' - const name = (suggestName.value || '').trim() - if (!name) { - suggestError.value = '게임 이름을 입력해주세요.' - return - } - try { - await api.suggestGame(name) - suggestName.value = '' - suggestOpen.value = false - } catch (e) { - suggestError.value = '제안 전송에 실패했어요.' - } -}