From beaec9326bd9bc55e2688e37a5158c02d748a558 Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 19 Mar 2026 15:20:13 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v0.1.9=20MariaDB?= =?UTF-8?q?=20=EC=A0=84=EC=9A=A9=20=EC=BD=94=EB=93=9C=EB=B2=A0=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 - ai-rules.md | 8 +- backend/data/db.json | 341 ------------ backend/package-lock.json | 28 - backend/package.json | 8 +- backend/scripts/migrate-lowdb-to-mariadb.js | 103 ---- backend/src/db.js | 585 ++++++-------------- backend/src/routes/admin.js | 16 + docs/convention.md | 1 + docs/history.md | 14 + docs/local-mariadb.md | 10 +- docs/map.md | 4 +- docs/spec.md | 9 +- docs/todo.md | 6 +- docs/update.md | 17 + frontend/src/views/AdminView.vue | 513 ++++++++++++----- package-lock.json | 28 - package.json | 1 - 18 files changed, 632 insertions(+), 1067 deletions(-) delete mode 100644 backend/data/db.json delete mode 100644 backend/scripts/migrate-lowdb-to-mariadb.js diff --git a/README.md b/README.md index 83be77c..80d26ec 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,6 @@ VITE_API_ORIGIN=http://localhost:5179 npm run dev - 접속: `http://localhost:5173` -### 4) 기존 lowdb 데이터 이관 - -```bash -cd backend -npm run migrate:lowdb -``` - 자세한 내용은 [docs/local-mariadb.md](/Users/bicute/Desktop/zenn.dev/tier-cursor/docs/local-mariadb.md)를 참고하세요. ## 사용 흐름(현재 구현) diff --git a/ai-rules.md b/ai-rules.md index 89abd11..ae866a6 100644 --- a/ai-rules.md +++ b/ai-rules.md @@ -4,6 +4,12 @@ - 모든 대화와 문서는 **한국어**로 작성한다. - 코드 내 주석은 반드시 **JSDoc 형식**을 사용한다. +## 🧾 Git 및 버전 관리 규칙 +- Git 작성자 정보는 프로젝트 기준 계정으로 통일한다. +- Git 커밋 메시지는 반드시 **한국어**로 작성한다. +- 버전이 올라가는 작업은 `docs/update.md`에 먼저 반영하고, 같은 버전명을 Git 태그에도 맞춘다. +- 원격 저장소에 푸시하기 전, 민감 정보(실명, 개인 이메일, 비밀키, 로컬 경로)가 포함되지 않았는지 확인한다. + ## 📂 문서 자동 관리 규칙 모든 작업 수행 후, AI는 관련 내용을 아래 지정된 파일에 즉시 반영해야 한다. @@ -28,4 +34,4 @@ ## ⚠️ 실행 지침 - 새로운 코드를 작성하거나 수정하기 전, 반드시 `docs/` 내 관련 문서들을 먼저 참조한다. -- 작업 완료 후 위 문서들의 업데이트가 누락되지 않도록 확인한다. \ No newline at end of file +- 작업 완료 후 위 문서들의 업데이트가 누락되지 않도록 확인한다. diff --git a/backend/data/db.json b/backend/data/db.json deleted file mode 100644 index 1d49ad7..0000000 --- a/backend/data/db.json +++ /dev/null @@ -1,341 +0,0 @@ -{ - "users": [ - { - "id": "nsTJTtyrDHSqfmRu5glSN", - "email": "zenn.message@gmail.com", - "passwordHash": "$2b$10$DxSEaZctF5u8A5rYDQRZOu4rkRNEaCytX0m0raQn6Fwjx.G0h9k8K", - "isAdmin": true, - "createdAt": 1773887700454, - "avatarSrc": "/uploads/avatars/1773889900538-fef80900076ae-스크린샷 2026-03-19 오전 11.38.05.png" - } - ], - "games": [ - { - "id": "example-game", - "name": "예시 게임", - "createdAt": 1773887395598 - }, - { - "id": "another-game", - "name": "다른 예시 게임", - "createdAt": 1773887395598 - }, - { - "id": "stellasora", - "name": "스텔라소라", - "createdAt": 1773887727309, - "thumbnailSrc": "/uploads/games/1773890252955-fbdff9a881edf-스크린샷 2026-03-19 오전 11.23.48(2).png" - } - ], - "gameImages": [ - { - "id": "img-1", - "gameId": "example-game", - "src": "/uploads/seeds/example1.png", - "label": "샘플 1", - "createdAt": 1773887395598 - }, - { - "id": "img-2", - "gameId": "example-game", - "src": "/uploads/seeds/example2.png", - "label": "샘플 2", - "createdAt": 1773887395598 - }, - { - "id": "dVx9t49q3CbKe_dnY9Zit", - "gameId": "stellasora", - "src": "/uploads/games/1773887834722-a3ce962de80228-Chitose-head-xxl.webp", - "label": "치토세", - "createdAt": 1773887834727 - }, - { - "id": "Tf3MgX_4Pk4YZszOTfGEQ", - "gameId": "stellasora", - "src": "/uploads/games/1773887843354-3dd04644093c5-Freesia-head-xxl.webp", - "label": "프리지아", - "createdAt": 1773887843356 - }, - { - "id": "ceaMtFXf7Jq-83f66T-UM", - "gameId": "stellasora", - "src": "/uploads/games/1773887854172-ea9752e9139b-Donna-head-xxl.webp", - "label": "도나", - "createdAt": 1773887854174 - }, - { - "id": "2odX6CHJOyFroPZ8s57oe", - "gameId": "stellasora", - "src": "/uploads/games/1773887863903-3635cbc2e569e8-Fuyuka-head-xxl.webp", - "label": "후유카", - "createdAt": 1773887863905 - }, - { - "id": "OfDKYixqM12e57ZAAcGlW", - "gameId": "stellasora", - "src": "/uploads/games/1773887879061-801daab97ebb98-Snowish_Laru-head-xxl.webp", - "label": "라루(크리스마스)", - "createdAt": 1773887879064 - }, - { - "id": "P1BOwa2sMv_YbAOkhMPVS", - "gameId": "stellasora", - "src": "/uploads/games/1773890202889-54aedfa2cea6e-스크린샷 2026-03-16 오후 6.35.30.png", - "label": "기본이미지?", - "createdAt": 1773890202906 - } - ], - "tierLists": [ - { - "id": "PiAKeKNvjJb68IPYiUv-r", - "authorId": "nsTJTtyrDHSqfmRu5glSN", - "gameId": "stellasora", - "title": "새 티어표1", - "isPublic": false, - "groups": [ - { - "id": "gS", - "name": "S", - "itemIds": [ - "dVx9t49q3CbKe_dnY9Zit" - ] - }, - { - "id": "gA", - "name": "A", - "itemIds": [ - "ceaMtFXf7Jq-83f66T-UM" - ] - }, - { - "id": "gB", - "name": "B", - "itemIds": [ - "2odX6CHJOyFroPZ8s57oe", - "OfDKYixqM12e57ZAAcGlW" - ] - }, - { - "id": "gC", - "name": "C", - "itemIds": [ - "Tf3MgX_4Pk4YZszOTfGEQ" - ] - }, - { - "id": "gD", - "name": "D", - "itemIds": [] - } - ], - "pool": [ - { - "id": "dVx9t49q3CbKe_dnY9Zit", - "src": "http://localhost:5179/uploads/games/1773887834722-a3ce962de80228-Chitose-head-xxl.webp", - "label": "치토세", - "origin": "game" - }, - { - "id": "Tf3MgX_4Pk4YZszOTfGEQ", - "src": "http://localhost:5179/uploads/games/1773887843354-3dd04644093c5-Freesia-head-xxl.webp", - "label": "프리지아", - "origin": "game" - }, - { - "id": "ceaMtFXf7Jq-83f66T-UM", - "src": "http://localhost:5179/uploads/games/1773887854172-ea9752e9139b-Donna-head-xxl.webp", - "label": "도나", - "origin": "game" - }, - { - "id": "2odX6CHJOyFroPZ8s57oe", - "src": "http://localhost:5179/uploads/games/1773887863903-3635cbc2e569e8-Fuyuka-head-xxl.webp", - "label": "후유카", - "origin": "game" - }, - { - "id": "OfDKYixqM12e57ZAAcGlW", - "src": "http://localhost:5179/uploads/games/1773887879061-801daab97ebb98-Snowish_Laru-head-xxl.webp", - "label": "라루(크리스마스)", - "origin": "game" - } - ], - "createdAt": 1773888446012, - "updatedAt": 1773888446013 - }, - { - "id": "F1qD3tWgW1aPJkLPjeWWA", - "authorId": "nsTJTtyrDHSqfmRu5glSN", - "gameId": "stellasora", - "title": "새 티어표1", - "isPublic": true, - "groups": [ - { - "id": "gS", - "name": "S", - "itemIds": [ - "dVx9t49q3CbKe_dnY9Zit" - ] - }, - { - "id": "gA", - "name": "A", - "itemIds": [ - "ceaMtFXf7Jq-83f66T-UM" - ] - }, - { - "id": "gB", - "name": "B", - "itemIds": [ - "2odX6CHJOyFroPZ8s57oe", - "OfDKYixqM12e57ZAAcGlW" - ] - }, - { - "id": "gC", - "name": "C", - "itemIds": [ - "Tf3MgX_4Pk4YZszOTfGEQ" - ] - }, - { - "id": "gD", - "name": "D", - "itemIds": [ - "c-1773888464832-e963464a5f73e8" - ] - } - ], - "pool": [ - { - "id": "dVx9t49q3CbKe_dnY9Zit", - "src": "http://localhost:5179/uploads/games/1773887834722-a3ce962de80228-Chitose-head-xxl.webp", - "label": "치토세", - "origin": "game" - }, - { - "id": "Tf3MgX_4Pk4YZszOTfGEQ", - "src": "http://localhost:5179/uploads/games/1773887843354-3dd04644093c5-Freesia-head-xxl.webp", - "label": "프리지아", - "origin": "game" - }, - { - "id": "ceaMtFXf7Jq-83f66T-UM", - "src": "http://localhost:5179/uploads/games/1773887854172-ea9752e9139b-Donna-head-xxl.webp", - "label": "도나", - "origin": "game" - }, - { - "id": "2odX6CHJOyFroPZ8s57oe", - "src": "http://localhost:5179/uploads/games/1773887863903-3635cbc2e569e8-Fuyuka-head-xxl.webp", - "label": "후유카", - "origin": "game" - }, - { - "id": "OfDKYixqM12e57ZAAcGlW", - "src": "http://localhost:5179/uploads/games/1773887879061-801daab97ebb98-Snowish_Laru-head-xxl.webp", - "label": "라루(크리스마스)", - "origin": "game" - }, - { - "id": "c-1773888464832-e963464a5f73e8", - "src": "blob:http://localhost:5174/10e01324-bbc9-403f-bfe4-0dd7e47eace7", - "label": "Chixia-head-xxl.webp", - "origin": "custom" - } - ], - "createdAt": 1773888448774, - "updatedAt": 1773890284754, - "description": "" - }, - { - "id": "zq8qdUD6q541v5l6X0wAg", - "authorId": "nsTJTtyrDHSqfmRu5glSN", - "gameId": "stellasora", - "title": "스텔라소라 개쩜", - "description": "설명 이렇게 적어봄 이건 개쩌는 티어표임", - "isPublic": true, - "groups": [ - { - "id": "gS", - "name": "S", - "itemIds": [ - "dVx9t49q3CbKe_dnY9Zit", - "ceaMtFXf7Jq-83f66T-UM" - ] - }, - { - "id": "gA", - "name": "A", - "itemIds": [ - "Tf3MgX_4Pk4YZszOTfGEQ" - ] - }, - { - "id": "gC", - "name": "C", - "itemIds": [ - "OfDKYixqM12e57ZAAcGlW" - ] - }, - { - "id": "gB", - "name": "B", - "itemIds": [ - "2odX6CHJOyFroPZ8s57oe" - ] - }, - { - "id": "gD", - "name": "쓰레기", - "itemIds": [ - "P1BOwa2sMv_YbAOkhMPVS" - ] - } - ], - "pool": [ - { - "id": "dVx9t49q3CbKe_dnY9Zit", - "src": "http://localhost:5179/uploads/games/1773887834722-a3ce962de80228-Chitose-head-xxl.webp", - "label": "치토세", - "origin": "game" - }, - { - "id": "Tf3MgX_4Pk4YZszOTfGEQ", - "src": "http://localhost:5179/uploads/games/1773887843354-3dd04644093c5-Freesia-head-xxl.webp", - "label": "프리지아", - "origin": "game" - }, - { - "id": "ceaMtFXf7Jq-83f66T-UM", - "src": "http://localhost:5179/uploads/games/1773887854172-ea9752e9139b-Donna-head-xxl.webp", - "label": "도나", - "origin": "game" - }, - { - "id": "2odX6CHJOyFroPZ8s57oe", - "src": "http://localhost:5179/uploads/games/1773887863903-3635cbc2e569e8-Fuyuka-head-xxl.webp", - "label": "후유카", - "origin": "game" - }, - { - "id": "OfDKYixqM12e57ZAAcGlW", - "src": "http://localhost:5179/uploads/games/1773887879061-801daab97ebb98-Snowish_Laru-head-xxl.webp", - "label": "라루(크리스마스)", - "origin": "game" - }, - { - "id": "P1BOwa2sMv_YbAOkhMPVS", - "src": "http://localhost:5179/uploads/games/1773890202889-54aedfa2cea6e-스크린샷 2026-03-16 오후 6.35.30.png", - "label": "기본이미지?", - "origin": "game" - } - ], - "createdAt": 1773890445513, - "updatedAt": 1773890445513 - } - ], - "customItems": [], - "gameSuggestions": [] -} \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 31c3cfc..f2fb120 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,7 +13,6 @@ "cors": "^2.8.6", "express": "^5.2.1", "express-session": "^1.19.0", - "lowdb": "^7.0.1", "multer": "^2.1.1", "mysql2": "^3.20.0", "nanoid": "^5.1.7", @@ -889,21 +888,6 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, - "node_modules/lowdb": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-7.0.1.tgz", - "integrity": "sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==", - "license": "MIT", - "dependencies": { - "steno": "^4.0.2" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } - }, "node_modules/lru.min": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", @@ -1589,18 +1573,6 @@ "node": ">= 0.8" } }, - "node_modules/steno": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/steno/-/steno-4.0.2.tgz", - "integrity": "sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } - }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 2f0acb9..804c5b8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,11 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "dev": "DB_CLIENT=mariadb DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor nodemon --legacy-watch --watch index.js --watch src index.js", - "start": "DB_CLIENT=mariadb DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js", - "dev:lowdb": "nodemon --legacy-watch --watch index.js --watch src index.js", - "start:lowdb": "node index.js", - "migrate:lowdb": "DB_CLIENT=mariadb DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-lowdb-to-mariadb.js" + "dev": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor nodemon --legacy-watch --watch index.js --watch src index.js", + "start": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js" }, "keywords": [], "author": "", @@ -19,7 +16,6 @@ "cors": "^2.8.6", "express": "^5.2.1", "express-session": "^1.19.0", - "lowdb": "^7.0.1", "multer": "^2.1.1", "mysql2": "^3.20.0", "nanoid": "^5.1.7", diff --git a/backend/scripts/migrate-lowdb-to-mariadb.js b/backend/scripts/migrate-lowdb-to-mariadb.js deleted file mode 100644 index 51c758e..0000000 --- a/backend/scripts/migrate-lowdb-to-mariadb.js +++ /dev/null @@ -1,103 +0,0 @@ -const fs = require('fs') -const path = require('path') - -const { - ensureData, - findUserByEmail, - createUser, - findGameById, - createGame, - updateGameThumbnail, - createGameItem, - createCustomItem, - findTierListById, - saveTierList, - createGameSuggestion, -} = require('../src/db') - -async function run() { - const sourcePath = path.join(__dirname, '..', 'data', 'db.json') - const raw = fs.readFileSync(sourcePath, 'utf8') - const data = JSON.parse(raw) - - await ensureData() - - for (const user of data.users || []) { - const existing = await findUserByEmail(user.email) - if (!existing) { - await createUser({ - id: user.id, - email: user.email, - nickname: user.nickname || '', - passwordHash: user.passwordHash, - isAdmin: !!user.isAdmin, - }) - } - } - - for (const game of data.games || []) { - const existing = await findGameById(game.id) - if (!existing) { - await createGame({ id: game.id, name: game.name }) - } - if (game.thumbnailSrc) { - await updateGameThumbnail(game.id, game.thumbnailSrc) - } - } - - for (const item of data.gameImages || []) { - try { - await createGameItem({ - id: item.id, - gameId: item.gameId, - src: item.src, - label: item.label, - }) - } catch (e) {} - } - - for (const suggestion of data.gameSuggestions || []) { - try { - await createGameSuggestion({ id: suggestion.id, name: suggestion.name }) - } catch (e) {} - } - - const seenCustomIds = new Set() - for (const tierList of data.tierLists || []) { - for (const item of tierList.pool || []) { - if (item.origin !== 'custom' || seenCustomIds.has(item.id)) continue - seenCustomIds.add(item.id) - try { - await createCustomItem({ - id: item.id, - ownerId: tierList.authorId, - src: item.src, - label: item.label, - }) - } catch (e) {} - } - } - - for (const tierList of data.tierLists || []) { - const existing = await findTierListById(tierList.id) - if (!existing) { - await saveTierList({ - id: tierList.id, - authorId: tierList.authorId, - gameId: tierList.gameId, - title: tierList.title, - description: tierList.description || '', - isPublic: !!tierList.isPublic, - groups: tierList.groups || [], - pool: tierList.pool || [], - }) - } - } - - console.log('migrate-lowdb-to-mariadb: done') -} - -run().catch((error) => { - console.error(error) - process.exit(1) -}) diff --git a/backend/src/db.js b/backend/src/db.js index a6ab81d..b9c9c97 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -1,9 +1,4 @@ -const path = require('path') const mysql = require('mysql2/promise') -const { Low } = require('lowdb') -const { JSONFile } = require('lowdb/node') - -const DB_CLIENT = process.env.DB_CLIENT || 'lowdb' const DB_HOST = process.env.DB_HOST || '127.0.0.1' const DB_PORT = process.env.DB_PORT ? Number(process.env.DB_PORT) : 3306 @@ -12,9 +7,6 @@ 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 LOWDB_PATH = path.join(__dirname, '..', 'data', 'db.json') - -let lowdbPromise = null let poolPromise = null let initPromise = null @@ -22,23 +14,6 @@ function now() { return Date.now() } -function defaultData() { - return { - users: [], - games: [ - { id: 'example-game', name: '예시 게임', thumbnailSrc: '', createdAt: now() }, - { id: 'another-game', name: '다른 예시 게임', thumbnailSrc: '', createdAt: now() }, - ], - gameImages: [ - { id: 'img-1', gameId: 'example-game', src: '/uploads/seeds/example1.png', label: '샘플 1', createdAt: now() }, - { id: 'img-2', gameId: 'example-game', src: '/uploads/seeds/example2.png', label: '샘플 2', createdAt: now() }, - ], - customItems: [], - tierLists: [], - gameSuggestions: [], - } -} - function parseJson(value, fallback) { if (!value) return fallback try { @@ -101,25 +76,6 @@ function mapTierListRow(row) { } } -async function getLowdb() { - if (lowdbPromise) return lowdbPromise - lowdbPromise = (async () => { - const adapter = new JSONFile(LOWDB_PATH) - const db = new Low(adapter, defaultData()) - await db.read() - db.data ||= defaultData() - db.data.users ||= [] - db.data.games ||= [] - db.data.gameImages ||= [] - db.data.customItems ||= [] - db.data.tierLists ||= [] - db.data.gameSuggestions ||= [] - await db.write() - return db - })() - return lowdbPromise -} - async function createPool() { const rootConnection = await mysql.createConnection({ host: DB_HOST, @@ -158,7 +114,7 @@ async function query(sql, params = []) { return rows } -async function ensureMariaSchema() { +async function ensureSchema() { if (initPromise) return initPromise initPromise = (async () => { await query(` @@ -274,154 +230,69 @@ async function ensureMariaSchema() { } async function ensureData() { - if (DB_CLIENT === 'mariadb') { - await ensureMariaSchema() - return - } - await getLowdb() + await ensureSchema() } async function countUsers() { - if (DB_CLIENT === 'mariadb') { - const rows = await query('SELECT COUNT(*) AS count FROM users') - return Number(rows[0]?.count || 0) - } - const db = await getLowdb() - return db.data.users.length + const rows = await query('SELECT COUNT(*) AS count FROM users') + return Number(rows[0]?.count || 0) } async function findUserByEmail(email) { - if (DB_CLIENT === 'mariadb') { - const rows = await query( - 'SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at FROM users WHERE email = ? LIMIT 1', - [email] - ) - const row = rows[0] - if (!row) return null - return { ...mapUserRow(row), passwordHash: row.password_hash } - } - - const db = await getLowdb() - return db.data.users.find((user) => user.email === email) || null + const rows = await query( + 'SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at FROM users WHERE email = ? LIMIT 1', + [email] + ) + const row = rows[0] + if (!row) return null + return { ...mapUserRow(row), passwordHash: row.password_hash } } async function findUserById(id) { - if (DB_CLIENT === 'mariadb') { - const rows = await query( - 'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1', - [id] - ) - return mapUserRow(rows[0]) - } - - const db = await getLowdb() - const user = db.data.users.find((entry) => entry.id === id) - if (!user) return null - return { - id: user.id, - email: user.email, - nickname: user.nickname || '', - isAdmin: !!user.isAdmin, - avatarSrc: user.avatarSrc || '', - createdAt: Number(user.createdAt), - } + const rows = await query( + 'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1', + [id] + ) + return mapUserRow(rows[0]) } async function createUser({ id, email, nickname, passwordHash, isAdmin }) { - if (DB_CLIENT === 'mariadb') { - const createdAt = now() - await query( - ` - INSERT INTO users (id, email, nickname, password_hash, is_admin, avatar_src, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, - [id, email, nickname || '', passwordHash, isAdmin ? 1 : 0, '', createdAt] - ) - return findUserById(id) - } - - const db = await getLowdb() - const user = { - id, - email, - nickname: nickname || '', - passwordHash, - isAdmin: !!isAdmin, - avatarSrc: '', - createdAt: now(), - } - db.data.users.push(user) - await db.write() + const createdAt = now() + await query( + ` + INSERT INTO users (id, email, nickname, password_hash, is_admin, avatar_src, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, + [id, email, nickname || '', passwordHash, isAdmin ? 1 : 0, '', createdAt] + ) return findUserById(id) } async function updateUserProfile({ id, nickname, avatarSrc }) { - if (DB_CLIENT === 'mariadb') { - if (typeof avatarSrc === 'string') { - await query('UPDATE users SET nickname = ?, avatar_src = ? WHERE id = ?', [nickname || '', avatarSrc, id]) - } else { - await query('UPDATE users SET nickname = ? WHERE id = ?', [nickname || '', id]) - } - return findUserById(id) + if (typeof avatarSrc === 'string') { + await query('UPDATE users SET nickname = ?, avatar_src = ? WHERE id = ?', [nickname || '', avatarSrc, id]) + } else { + await query('UPDATE users SET nickname = ? WHERE id = ?', [nickname || '', id]) } - - const db = await getLowdb() - const user = db.data.users.find((entry) => entry.id === id) - if (!user) return null - user.nickname = nickname || '' - if (typeof avatarSrc === 'string') user.avatarSrc = avatarSrc - await db.write() return findUserById(id) } async function listGames() { - if (DB_CLIENT === 'mariadb') { - const rows = await query('SELECT id, name, thumbnail_src, created_at FROM games ORDER BY created_at ASC, name ASC') - return rows.map(mapGameRow) - } - const db = await getLowdb() - return db.data.games.map((game) => ({ - id: game.id, - name: game.name, - thumbnailSrc: game.thumbnailSrc || '', - createdAt: Number(game.createdAt), - })) + const rows = await query('SELECT id, name, thumbnail_src, created_at FROM games ORDER BY created_at ASC, name ASC') + return rows.map(mapGameRow) } async function findGameById(id) { - if (DB_CLIENT === 'mariadb') { - const rows = await query('SELECT id, name, thumbnail_src, created_at FROM games WHERE id = ? LIMIT 1', [id]) - return mapGameRow(rows[0]) - } - const db = await getLowdb() - const game = db.data.games.find((entry) => entry.id === id) - if (!game) return null - return { - id: game.id, - name: game.name, - thumbnailSrc: game.thumbnailSrc || '', - createdAt: Number(game.createdAt), - } + const rows = await query('SELECT id, name, thumbnail_src, created_at FROM games WHERE id = ? LIMIT 1', [id]) + return mapGameRow(rows[0]) } async function listGameItems(gameId) { - if (DB_CLIENT === 'mariadb') { - const rows = await query( - 'SELECT id, game_id, src, label, created_at FROM game_items WHERE game_id = ? ORDER BY created_at ASC', - [gameId] - ) - return rows.map(mapGameItemRow) - } - const db = await getLowdb() - return db.data.gameImages - .filter((item) => item.gameId === gameId) - .map((item) => ({ - id: item.id, - gameId: item.gameId, - src: item.src, - label: item.label, - createdAt: Number(item.createdAt), - })) + const rows = await query( + 'SELECT id, game_id, src, label, created_at FROM game_items WHERE game_id = ? ORDER BY created_at ASC', + [gameId] + ) + return rows.map(mapGameItemRow) } async function getGameDetail(gameId) { @@ -432,276 +303,184 @@ async function getGameDetail(gameId) { } async function createGame({ id, name }) { - if (DB_CLIENT === 'mariadb') { - await query('INSERT INTO games (id, name, thumbnail_src, created_at) VALUES (?, ?, ?, ?)', [id, name, '', now()]) - return findGameById(id) - } - const db = await getLowdb() - db.data.games.push({ id, name, thumbnailSrc: '', createdAt: now() }) - await db.write() + await query('INSERT INTO games (id, name, thumbnail_src, created_at) VALUES (?, ?, ?, ?)', [id, name, '', now()]) return findGameById(id) } async function updateGameThumbnail(gameId, thumbnailSrc) { - if (DB_CLIENT === 'mariadb') { - await query('UPDATE games SET thumbnail_src = ? WHERE id = ?', [thumbnailSrc, gameId]) - return findGameById(gameId) - } - const db = await getLowdb() - const game = db.data.games.find((entry) => entry.id === gameId) - if (!game) return null - game.thumbnailSrc = thumbnailSrc - await db.write() + await query('UPDATE games SET thumbnail_src = ? WHERE id = ?', [thumbnailSrc, gameId]) return findGameById(gameId) } async function createGameItem({ id, gameId, src, label }) { - if (DB_CLIENT === 'mariadb') { - const createdAt = now() - await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [ - id, - gameId, - src, - label, - createdAt, - ]) - const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [id]) - return mapGameItemRow(rows[0]) - } - - const db = await getLowdb() - const item = { id, gameId, src, label, createdAt: now() } - db.data.gameImages.push(item) - await db.write() - return { - id: item.id, - gameId: item.gameId, - src: item.src, - label: item.label, - createdAt: Number(item.createdAt), - } + const createdAt = now() + await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [ + id, + gameId, + src, + label, + createdAt, + ]) + const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [id]) + return mapGameItemRow(rows[0]) } -async function createCustomItem({ id, ownerId, src, label }) { - if (DB_CLIENT === 'mariadb') { - const createdAt = now() - await query('INSERT INTO custom_items (id, owner_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [ - id, - ownerId, - src, - label, - createdAt, - ]) - return { id, ownerId, src, label, origin: 'custom', createdAt } - } +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 - const db = await getLowdb() - const item = { id, ownerId, src, label, origin: 'custom', createdAt: now() } - db.data.customItems.push(item) - await db.write() - return item -} - -async function createGameSuggestion({ id, name }) { - if (DB_CLIENT === 'mariadb') { - const createdAt = now() - await query('INSERT INTO game_suggestions (id, name, created_at) VALUES (?, ?, ?)', [id, name, createdAt]) - return { id, name, createdAt } - } - const db = await getLowdb() - const suggestion = { id, name, createdAt: now() } - db.data.gameSuggestions.push(suggestion) - await db.write() - return suggestion -} - -async function listPublicTierLists(gameId) { - if (DB_CLIENT === 'mariadb') { - const params = [] - let whereClause = 'WHERE t.is_public = 1' - if (gameId) { - whereClause += ' AND t.game_id = ?' - params.push(gameId) - } - - const rows = await query( - ` - SELECT - t.id, - t.game_id, - t.title, - t.created_at, - t.updated_at, - t.author_id, - u.nickname, - u.email - FROM tierlists t - INNER JOIN users u ON u.id = t.author_id - ${whereClause} - ORDER BY t.updated_at DESC - LIMIT 50 - `, - params - ) - - return rows.map((row) => ({ - id: row.id, - gameId: row.game_id, - title: row.title, - createdAt: Number(row.created_at), - updatedAt: Number(row.updated_at), - authorId: row.author_id, - authorName: row.nickname || row.email, - })) - } - - const db = await getLowdb() - return db.data.tierLists - .filter((tierList) => tierList.isPublic && (!gameId || tierList.gameId === gameId)) - .sort((a, b) => Number(b.updatedAt) - Number(a.updatedAt)) - .slice(0, 50) - .map((tierList) => { - const author = db.data.users.find((user) => user.id === tierList.authorId) - return { - id: tierList.id, - gameId: tierList.gameId, - title: tierList.title, - createdAt: Number(tierList.createdAt), - updatedAt: Number(tierList.updatedAt), - authorId: tierList.authorId, - authorName: author?.nickname || author?.email || '알 수 없음', - } - }) -} - -async function listUserTierLists(userId) { - if (DB_CLIENT === 'mariadb') { - const rows = await query( - ` - SELECT id, game_id, title, created_at, updated_at, is_public - FROM tierlists - WHERE author_id = ? - ORDER BY updated_at DESC - `, - [userId] - ) - - return rows.map((row) => ({ - id: row.id, - gameId: row.game_id, - title: row.title, - createdAt: Number(row.created_at), - updatedAt: Number(row.updated_at), - isPublic: !!row.is_public, - })) - } - - const db = await getLowdb() - return db.data.tierLists - .filter((tierList) => tierList.authorId === userId) - .sort((a, b) => Number(b.updatedAt) - Number(a.updatedAt)) - .map((tierList) => ({ - id: tierList.id, - gameId: tierList.gameId, - title: tierList.title, - createdAt: Number(tierList.createdAt), - updatedAt: Number(tierList.updatedAt), - isPublic: !!tierList.isPublic, - })) -} - -async function findTierListById(id) { - if (DB_CLIENT === 'mariadb') { - const rows = await query( + if (gameId) { + const tierListRows = await query( ` SELECT id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at FROM tierlists - WHERE id = ? - LIMIT 1 + WHERE game_id = ? `, - [id] + [gameId] ) - return mapTierListRow(rows[0]) + + for (const row of tierListRows) { + const tierList = mapTierListRow(row) + const nextGroups = (tierList.groups || []).map((group) => ({ + ...group, + itemIds: (group.itemIds || []).filter((id) => id !== itemId), + })) + const nextPool = (tierList.pool || []).filter((item) => item.id !== itemId) + + await query( + 'UPDATE tierlists SET groups_json = ?, pool_json = ?, updated_at = ? WHERE id = ?', + [serializeJson(nextGroups), serializeJson(nextPool), now(), tierList.id] + ) + } } - const db = await getLowdb() - const tierList = db.data.tierLists.find((entry) => entry.id === id) - if (!tierList) return null - return { - id: tierList.id, - authorId: tierList.authorId, - gameId: tierList.gameId, - title: tierList.title, - description: tierList.description || '', - isPublic: !!tierList.isPublic, - groups: Array.isArray(tierList.groups) ? tierList.groups : [], - pool: Array.isArray(tierList.pool) ? tierList.pool : [], - createdAt: Number(tierList.createdAt), - updatedAt: Number(tierList.updatedAt), + await query('DELETE FROM game_items WHERE id = ?', [itemId]) +} + +async function deleteGame(gameId) { + await query('DELETE FROM games WHERE id = ?', [gameId]) +} + +async function createCustomItem({ id, ownerId, src, label }) { + const createdAt = now() + await query('INSERT INTO custom_items (id, owner_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [ + id, + ownerId, + src, + label, + createdAt, + ]) + 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 listPublicTierLists(gameId) { + const params = [] + let whereClause = 'WHERE t.is_public = 1' + if (gameId) { + whereClause += ' AND t.game_id = ?' + params.push(gameId) } + + const rows = await query( + ` + SELECT + t.id, + t.game_id, + t.title, + t.created_at, + t.updated_at, + t.author_id, + u.nickname, + u.email + FROM tierlists t + INNER JOIN users u ON u.id = t.author_id + ${whereClause} + ORDER BY t.updated_at DESC + LIMIT 50 + `, + params + ) + + return rows.map((row) => ({ + id: row.id, + gameId: row.game_id, + title: row.title, + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + authorId: row.author_id, + authorName: row.nickname || row.email, + })) +} + +async function listUserTierLists(userId) { + const rows = await query( + ` + SELECT id, game_id, title, created_at, updated_at, is_public + FROM tierlists + WHERE author_id = ? + ORDER BY updated_at DESC + `, + [userId] + ) + + return rows.map((row) => ({ + id: row.id, + gameId: row.game_id, + title: row.title, + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + isPublic: !!row.is_public, + })) +} + +async function findTierListById(id) { + const rows = await query( + ` + SELECT id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at + FROM tierlists + WHERE id = ? + LIMIT 1 + `, + [id] + ) + return mapTierListRow(rows[0]) } async function saveTierList({ id, authorId, gameId, title, description, isPublic, groups, pool }) { - if (DB_CLIENT === 'mariadb') { - const existing = id ? await findTierListById(id) : null + const existing = id ? await findTierListById(id) : null - if (existing) { - await query( - ` - UPDATE tierlists - SET title = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ? - WHERE id = ? - `, - [title, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id] - ) - return findTierListById(existing.id) - } - - const createdAt = now() + if (existing) { await query( ` - INSERT INTO tierlists ( - id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + UPDATE tierlists + SET title = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ? + WHERE id = ? `, - [id, authorId, gameId, title, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt] + [title, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id] ) - return findTierListById(id) - } - - const db = await getLowdb() - const existing = db.data.tierLists.find((entry) => entry.id === id) - if (existing) { - existing.title = title - existing.description = description || '' - existing.isPublic = !!isPublic - existing.groups = groups - existing.pool = pool - existing.updatedAt = now() - await db.write() return findTierListById(existing.id) } - const tierList = { - id, - authorId, - gameId, - title, - description: description || '', - isPublic: !!isPublic, - groups, - pool, - createdAt: now(), - updatedAt: now(), - } - db.data.tierLists.push(tierList) - await db.write() + const createdAt = now() + await query( + ` + INSERT INTO tierlists ( + id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [id, authorId, gameId, title, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt] + ) return findTierListById(id) } module.exports = { - DB_CLIENT, DB_NAME, ensureData, countUsers, @@ -716,6 +495,8 @@ module.exports = { createGame, updateGameThumbnail, createGameItem, + deleteGameItem, + deleteGame, createCustomItem, createGameSuggestion, listPublicTierLists, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 04824f1..7043340 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -8,6 +8,8 @@ const { createGame, updateGameThumbnail, createGameItem, + deleteGameItem, + deleteGame, } = require('../db') const { requireAdmin } = require('../middleware/auth') @@ -61,4 +63,18 @@ router.post('/games/:gameId/images', requireAdmin, upload.single('image'), async res.json({ item }) }) +router.delete('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => { + const game = await findGameById(req.params.gameId) + if (!game) return res.status(404).json({ error: 'not_found' }) + await deleteGameItem(req.params.itemId) + res.json({ ok: true }) +}) + +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' }) + await deleteGame(req.params.gameId) + res.json({ ok: true }) +}) + module.exports = router diff --git a/docs/convention.md b/docs/convention.md index 904aa36..08240c1 100644 --- a/docs/convention.md +++ b/docs/convention.md @@ -7,6 +7,7 @@ - 경로, 주소, 운영 설정은 하드코딩보다 환경변수 기반 구성을 우선한다. - Git 커밋 메시지는 한국어로 작성한다. - 버전 릴리스가 포함된 작업은 `docs/update.md`의 버전 표기와 Git 태그를 함께 맞춘다. +- Git 푸시 전에는 민감 정보(실명, 개인 이메일, 비밀키, 로컬 절대 경로) 포함 여부를 다시 확인한다. ## 프런트엔드 - API 호출은 `frontend/src/lib/api.js` 또는 런타임 유틸을 통해 통합한다. diff --git a/docs/history.md b/docs/history.md index 331d9c3..fd3887c 100644 --- a/docs/history.md +++ b/docs/history.md @@ -26,3 +26,17 @@ ## 2026-03-19 v0.1.6 - 저장소 운영 규칙을 정리하면서 Git 작성자 정보는 프로젝트 기준 계정으로 통일하고, 커밋 메시지는 한국어로 남기기로 결정했다. + +## 2026-03-19 v0.1.7 +- 관리자 페이지는 여러 작업을 동시에 나열하는 구조보다 “하나의 작업 모드를 선택하고 그 작업에 집중하는 구조”가 더 적합하다고 판단해 단계형 UI로 전환했다. +- 관리자에게는 생성뿐 아니라 삭제 책임도 필요하므로 게임 삭제와 아이템 삭제 기능을 추가하기로 결정했다. +- 아이템 삭제는 단순 파일/레코드 삭제만으로 끝내면 안 되고, 기존 티어표 데이터의 참조까지 함께 정리해야 한다고 결정했다. + +## 2026-03-19 v0.1.8 +- 관리자 업로드 작업은 선택 즉시 결과를 예측할 수 있어야 하므로, 썸네일과 아이템 모두 “파일 선택 → 미리보기 → 실제 업로드” 흐름으로 보강했다. +- 게임 썸네일은 대표 이미지 성격이 강하므로 16:9 비율로, 아이템은 캐릭터/오브젝트 단위 식별이 중요하므로 1:1 비율로 보는 방향을 채택했다. +- 현재 `db.json`과 lowdb 관련 코드는 기본 운영 런타임이 아니라 마이그레이션/예외 fallback 성격임을 분명히 정리했다. + +## 2026-03-19 v0.1.9 +- 로컬과 운영 환경을 완전히 같은 DB 계층으로 맞추기 위해 lowdb fallback을 제거하고 MariaDB만 지원하는 코드베이스로 정리했다. +- 마이그레이션 종료 이후에는 레거시 JSON 저장소와 예외 실행 스크립트를 남겨두는 비용이 더 크다고 판단해 삭제하기로 결정했다. diff --git a/docs/local-mariadb.md b/docs/local-mariadb.md index 5955917..bf1c442 100644 --- a/docs/local-mariadb.md +++ b/docs/local-mariadb.md @@ -33,14 +33,6 @@ cd frontend VITE_API_ORIGIN=http://localhost:5179 npm run dev ``` -## 5. 기존 lowdb 데이터 이관 -MariaDB 컨테이너와 백엔드 의존성이 준비된 뒤 아래 명령을 실행한다. - -```bash -cd backend -npm run migrate:lowdb -``` - ## 메모 -- 긴급 확인용으로만 `npm run dev:lowdb`를 남겨두었고, 기본 개발 기준은 MariaDB다. +- 현재 코드베이스는 MariaDB 전용이며, 로컬과 NAS 모두 같은 DB 계층을 사용한다. - NAS 배포 시에도 동일하게 MariaDB를 사용하므로 로컬과 운영 간 DB 계층 차이를 줄일 수 있다. diff --git a/docs/map.md b/docs/map.md index 924a70e..507efc5 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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` +- 역할: 작업 모드 선택, 기존 게임 선택 또는 새 게임 생성, 선택된 게임의 썸네일/아이템 관리, 파일 선택 즉시 미리보기, 아이템 삭제, 게임 삭제 +- 연동 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` ## `/profile` - 화면 파일: `frontend/src/views/ProfileView.vue` diff --git a/docs/spec.md b/docs/spec.md index 931dbe1..109dc58 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -15,7 +15,6 @@ - 아바타: `backend/uploads/avatars/` - 커스텀 아이템: `backend/uploads/custom/` - 시드 이미지: `backend/uploads/seeds/` -- 레거시 마이그레이션 소스: `backend/data/db.json` ## DB 스키마 - `users` @@ -81,12 +80,13 @@ - `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` ## 운영 환경 변수 - 프런트엔드 - `VITE_API_ORIGIN`: API 및 업로드 파일 절대 기준 주소 - 백엔드 - - `DB_CLIENT`: 기본 개발 기준은 `mariadb` - `DB_HOST`: MariaDB 호스트 - `DB_PORT`: MariaDB 포트 - `DB_USER`: MariaDB 계정 @@ -105,9 +105,8 @@ - MariaDB 컨테이너 또는 NAS 패키지 설치 - phpMyAdmin 또는 Adminer 설치 - 앱은 환경변수로 해당 DB에 연결 -- 레거시 `db.json` 데이터는 `node backend/scripts/migrate-lowdb-to-mariadb.js`로 이관한다. ## 로컬 개발 기준 - 기본 로컬 개발도 `docker compose`로 띄운 MariaDB를 사용한다. -- 기본 백엔드 실행 스크립트 `backend/package.json`의 `dev`, `start`, `migrate:lowdb`는 로컬 MariaDB(`127.0.0.1:3307`) 기준으로 맞춰져 있다. -- 예외적으로만 `dev:lowdb`, `start:lowdb`를 사용한다. +- 기본 백엔드 실행 스크립트 `backend/package.json`의 `dev`, `start`는 로컬 MariaDB(`127.0.0.1:3307`) 기준으로 맞춰져 있다. +- `backend/src/db.js`는 MariaDB만 대상으로 동작하며, 파일 기반 fallback은 제거되었다. diff --git a/docs/todo.md b/docs/todo.md index fa56722..8ec2e82 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -2,8 +2,6 @@ ## 즉시 확인 필요 - 관리자 화면에 게임 제안(`gameSuggestions`) 조회/처리 UI가 아직 없다. -- MariaDB 실제 서버가 준비되면 `backend/scripts/migrate-lowdb-to-mariadb.js`를 실행해 기존 `db.json` 데이터를 이관해야 한다. -- 기존 `backend/data/db.json`의 절대 로컬 URL/깨진 파일명 데이터는 마이그레이션 후 수동 정리가 필요할 수 있다. ## 배포 전 작업 - NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다. @@ -13,6 +11,6 @@ - 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다. ## 중기 개선 -- 게임/이미지/티어표 삭제 및 수정 이력 관리 기능을 추가한다. +- 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다. - 자동 테스트와 최소한의 배포 체크리스트를 만든다. -- 관리자용 게임 제안 승인/반려, 아이템 삭제/정렬 UI를 추가한다. +- 관리자용 게임 제안 승인/반려, 아이템 정렬 UI를 추가한다. diff --git a/docs/update.md b/docs/update.md index 4abb5a4..dce54d9 100644 --- a/docs/update.md +++ b/docs/update.md @@ -50,3 +50,20 @@ ## 2026-03-19 v0.1.6 - **저장소 메타데이터 정리**: Git 작성자 정보를 프로젝트 계정 기준으로 통일하고, 초기 릴리스 커밋 메시지를 한국어 기준으로 재작성 - **버전 관리 규칙 보강**: 커밋 메시지 한국어 작성 및 문서 버전과 Git 태그를 함께 맞추는 규칙을 문서에 반영 + +## 2026-03-19 v0.1.7 +- **AI 작업 규칙 보강**: `ai-rules.md`에 Git 작성자 정보, 한국어 커밋 메시지, 버전/태그 동기화, 민감 정보 확인 규칙 추가 +- **관리자 화면 재구성**: `/admin`을 좌우 병렬 구조에서 `모드 선택 → 게임 선택/생성 → 선택된 게임 상세 관리` 흐름으로 재구성 +- **관리자 삭제 기능 추가**: 등록된 게임 자체 삭제 및 등록된 아이템 개별 삭제 기능 추가 +- **데이터 정합성 보강**: 관리자 아이템 삭제 시 관련 티어표의 `groups/pool` 참조를 함께 정리하도록 백엔드 로직 보강 + +## 2026-03-19 v0.1.8 +- **관리자 업로드 UX 개선**: 썸네일과 아이템 추가 시 파일 선택 직후 미리보기 표시 +- **썸네일 비율 정리**: 관리자 썸네일 미리보기와 대표 썸네일 표시를 16:9, 약 256px 폭 기준으로 조정 +- **아이템 카드 레이아웃 개선**: 아이템 목록과 추가 미리보기를 1:1 비율 기준으로 재구성하고 더 촘촘한 카드 그리드로 조정 +- **레거시 파일 역할 정리**: `db.json`과 lowdb 관련 코드는 현재 MariaDB 기본 런타임에는 필수가 아니며, 마이그레이션/예외 fallback 용도임을 문서에 명시 + +## 2026-03-19 v0.1.9 +- **MariaDB 전용 전환 완료**: `backend/src/db.js`에서 lowdb 분기와 `DB_CLIENT` 기반 fallback을 제거하고 MariaDB 전용 저장 계층으로 정리 +- **레거시 파일 제거**: `backend/data/db.json`, `backend/scripts/migrate-lowdb-to-mariadb.js`, `dev:lowdb/start:lowdb/migrate:lowdb` 스크립트 및 `lowdb` 의존성 제거 +- **실행 문서 정리**: `README.md`, `docs/local-mariadb.md`, `docs/spec.md`, `docs/todo.md`, `docs/history.md`를 현재 MariaDB 전용 개발/배포 흐름 기준으로 갱신 diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 6d41172..013e9f8 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -1,5 +1,5 @@ @@ -217,47 +361,81 @@ async function uploadThumbnail() { opacity: 0.82; line-height: 1.5; } -.warn { - margin-top: 10px; - padding: 10px 12px; - border-radius: 12px; - border: 1px solid rgba(245, 158, 11, 0.35); - background: rgba(245, 158, 11, 0.14); -} -.error { - margin-top: 10px; - padding: 10px 12px; - border-radius: 12px; - border: 1px solid rgba(239, 68, 68, 0.3); - background: rgba(239, 68, 68, 0.12); -} +.warn, +.error, .success { margin-top: 10px; padding: 10px 12px; border-radius: 12px; +} +.warn { + border: 1px solid rgba(245, 158, 11, 0.35); + background: rgba(245, 158, 11, 0.14); +} +.error { + border: 1px solid rgba(239, 68, 68, 0.3); + background: rgba(239, 68, 68, 0.12); +} +.success { border: 1px solid rgba(52, 211, 153, 0.32); background: rgba(52, 211, 153, 0.14); } -.grid { - margin-top: 12px; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; +.modeTabs { + margin-top: 14px; + display: flex; + gap: 10px; + flex-wrap: wrap; +} +.modeTab { + padding: 10px 14px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.92); + cursor: pointer; + font-weight: 800; +} +.modeTab--active { + background: rgba(96, 165, 250, 0.2); } .panel { + margin-top: 14px; border: 1px solid rgba(255, 255, 255, 0.12); background: rgba(0, 0, 0, 0.12); border-radius: 16px; - padding: 12px; + padding: 14px; } -.panel__title { +.panel--compact { + max-width: 640px; +} +.panel__title, +.section__title { font-weight: 900; - margin-bottom: 8px; } -.hint { - opacity: 0.78; - font-size: 13px; - margin-bottom: 10px; +.section { + margin-top: 18px; + padding-top: 18px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} +.uploadPreviewCard { + margin-top: 10px; + 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; } .select, .input { @@ -269,11 +447,16 @@ async function uploadThumbnail() { background: rgba(0, 0, 0, 0.18); color: rgba(255, 255, 255, 0.92); outline: none; - margin-bottom: 10px; + margin-top: 10px; +} +.hint { + margin-top: 10px; + opacity: 0.78; + font-size: 13px; } .inputFile { width: 100%; - margin-bottom: 10px; + margin-top: 12px; } .btn { margin-top: 12px; @@ -285,25 +468,26 @@ async function uploadThumbnail() { cursor: pointer; font-weight: 800; } -.btn:hover { - background: rgba(255, 255, 255, 0.08); +.btn--primary { + background: rgba(96, 165, 250, 0.2); } -.thumbGrid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; +.btn--danger { + background: rgba(239, 68, 68, 0.14); + border-color: rgba(239, 68, 68, 0.28); } -.selectedGame { +.btn--small { + width: 100%; +} +.detailHead { display: flex; - align-items: center; - justify-content: space-between; gap: 12px; - padding: 12px; - border-radius: 14px; - border: 1px solid rgba(255, 255, 255, 0.12); - background: rgba(255, 255, 255, 0.04); + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; } .selectedGame__name { + margin-top: 8px; + font-size: 22px; font-weight: 900; } .selectedGame__id { @@ -311,13 +495,68 @@ async function uploadThumbnail() { opacity: 0.72; word-break: break-all; } +.detailHead__actions { + display: flex; + gap: 8px; +} +.thumbnailRow { + margin-top: 10px; +} .selectedThumb { - width: 90px; - height: 90px; + width: 256px; + aspect-ratio: 16 / 9; object-fit: cover; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); +} +.selectedThumb--empty { + display: grid; + place-items: center; + color: rgba(255, 255, 255, 0.62); +} +.itemComposer { + margin-top: 10px; + display: grid; + grid-template-columns: minmax(0, 1fr) 180px; + gap: 16px; + align-items: start; +} +.itemComposer__form { + min-width: 0; +} +.itemPreviewCard { + padding: 10px; border-radius: 14px; border: 1px solid rgba(255, 255, 255, 0.12); - flex: none; + background: rgba(255, 255, 255, 0.04); +} +.itemPreviewFrame { + width: 100%; + aspect-ratio: 1 / 1; + border-radius: 12px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(0, 0, 0, 0.18); +} +.itemPreviewImage { + width: 100%; + height: 100%; + object-fit: cover; +} +.itemPreviewEmpty { + width: 100%; + height: 100%; + display: grid; + place-items: center; + color: rgba(255, 255, 255, 0.62); + font-size: 13px; +} +.thumbGrid { + margin-top: 12px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 12px; } .thumbCard { border: 1px solid rgba(255, 255, 255, 0.12); @@ -328,7 +567,7 @@ async function uploadThumbnail() { } .thumb { width: 100%; - height: 92px; + aspect-ratio: 1 / 1; object-fit: cover; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.12); @@ -340,8 +579,22 @@ async function uploadThumbnail() { opacity: 0.9; word-break: break-word; } +.thumbLabel--preview { + text-align: center; +} @media (max-width: 980px) { - .grid { + .uploadPreviewCard--wide { + grid-template-columns: 1fr; + } + .itemComposer { + grid-template-columns: 1fr; + } +} +@media (max-width: 640px) { + .selectedThumb { + width: 100%; + } + .thumbGrid { grid-template-columns: 1fr; } } diff --git a/package-lock.json b/package-lock.json index d4e0d5d..4aa17b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "cors": "^2.8.6", "express": "^5.2.1", "express-session": "^1.19.0", - "lowdb": "^7.0.1", "multer": "^2.1.1", "nanoid": "^5.1.7", "session-file-store": "^1.5.0", @@ -839,21 +838,6 @@ "node": ">6" } }, - "node_modules/lowdb": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-7.0.1.tgz", - "integrity": "sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==", - "license": "MIT", - "dependencies": { - "steno": "^4.0.2" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1475,18 +1459,6 @@ "node": ">= 0.8" } }, - "node_modules/steno": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/steno/-/steno-4.0.2.tgz", - "integrity": "sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } - }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", diff --git a/package.json b/package.json index 2c62208..fae6738 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "cors": "^2.8.6", "express": "^5.2.1", "express-session": "^1.19.0", - "lowdb": "^7.0.1", "multer": "^2.1.1", "nanoid": "^5.1.7", "session-file-store": "^1.5.0",