릴리스: v0.1.9 MariaDB 전용 코드베이스 정리
This commit is contained in:
@@ -37,13 +37,6 @@ VITE_API_ORIGIN=http://localhost:5179 npm run dev
|
|||||||
|
|
||||||
- 접속: `http://localhost:5173`
|
- 접속: `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)를 참고하세요.
|
자세한 내용은 [docs/local-mariadb.md](/Users/bicute/Desktop/zenn.dev/tier-cursor/docs/local-mariadb.md)를 참고하세요.
|
||||||
|
|
||||||
## 사용 흐름(현재 구현)
|
## 사용 흐름(현재 구현)
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
- 모든 대화와 문서는 **한국어**로 작성한다.
|
- 모든 대화와 문서는 **한국어**로 작성한다.
|
||||||
- 코드 내 주석은 반드시 **JSDoc 형식**을 사용한다.
|
- 코드 내 주석은 반드시 **JSDoc 형식**을 사용한다.
|
||||||
|
|
||||||
|
## 🧾 Git 및 버전 관리 규칙
|
||||||
|
- Git 작성자 정보는 프로젝트 기준 계정으로 통일한다.
|
||||||
|
- Git 커밋 메시지는 반드시 **한국어**로 작성한다.
|
||||||
|
- 버전이 올라가는 작업은 `docs/update.md`에 먼저 반영하고, 같은 버전명을 Git 태그에도 맞춘다.
|
||||||
|
- 원격 저장소에 푸시하기 전, 민감 정보(실명, 개인 이메일, 비밀키, 로컬 경로)가 포함되지 않았는지 확인한다.
|
||||||
|
|
||||||
## 📂 문서 자동 관리 규칙
|
## 📂 문서 자동 관리 규칙
|
||||||
모든 작업 수행 후, AI는 관련 내용을 아래 지정된 파일에 즉시 반영해야 한다.
|
모든 작업 수행 후, AI는 관련 내용을 아래 지정된 파일에 즉시 반영해야 한다.
|
||||||
|
|
||||||
@@ -28,4 +34,4 @@
|
|||||||
|
|
||||||
## ⚠️ 실행 지침
|
## ⚠️ 실행 지침
|
||||||
- 새로운 코드를 작성하거나 수정하기 전, 반드시 `docs/` 내 관련 문서들을 먼저 참조한다.
|
- 새로운 코드를 작성하거나 수정하기 전, 반드시 `docs/` 내 관련 문서들을 먼저 참조한다.
|
||||||
- 작업 완료 후 위 문서들의 업데이트가 누락되지 않도록 확인한다.
|
- 작업 완료 후 위 문서들의 업데이트가 누락되지 않도록 확인한다.
|
||||||
|
|||||||
@@ -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": []
|
|
||||||
}
|
|
||||||
28
backend/package-lock.json
generated
28
backend/package-lock.json
generated
@@ -13,7 +13,6 @@
|
|||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-session": "^1.19.0",
|
"express-session": "^1.19.0",
|
||||||
"lowdb": "^7.0.1",
|
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"mysql2": "^3.20.0",
|
"mysql2": "^3.20.0",
|
||||||
"nanoid": "^5.1.7",
|
"nanoid": "^5.1.7",
|
||||||
@@ -889,21 +888,6 @@
|
|||||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/lru.min": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
||||||
@@ -1589,18 +1573,6 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/streamsearch": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||||
|
|||||||
@@ -4,11 +4,8 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"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",
|
"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_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",
|
"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"
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -19,7 +16,6 @@
|
|||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-session": "^1.19.0",
|
"express-session": "^1.19.0",
|
||||||
"lowdb": "^7.0.1",
|
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"mysql2": "^3.20.0",
|
"mysql2": "^3.20.0",
|
||||||
"nanoid": "^5.1.7",
|
"nanoid": "^5.1.7",
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
const path = require('path')
|
|
||||||
const mysql = require('mysql2/promise')
|
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_HOST = process.env.DB_HOST || '127.0.0.1'
|
||||||
const DB_PORT = process.env.DB_PORT ? Number(process.env.DB_PORT) : 3306
|
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_NAME = process.env.DB_NAME || 'tier_cursor'
|
||||||
const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10
|
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 poolPromise = null
|
||||||
let initPromise = null
|
let initPromise = null
|
||||||
|
|
||||||
@@ -22,23 +14,6 @@ function now() {
|
|||||||
return Date.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) {
|
function parseJson(value, fallback) {
|
||||||
if (!value) return fallback
|
if (!value) return fallback
|
||||||
try {
|
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() {
|
async function createPool() {
|
||||||
const rootConnection = await mysql.createConnection({
|
const rootConnection = await mysql.createConnection({
|
||||||
host: DB_HOST,
|
host: DB_HOST,
|
||||||
@@ -158,7 +114,7 @@ async function query(sql, params = []) {
|
|||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureMariaSchema() {
|
async function ensureSchema() {
|
||||||
if (initPromise) return initPromise
|
if (initPromise) return initPromise
|
||||||
initPromise = (async () => {
|
initPromise = (async () => {
|
||||||
await query(`
|
await query(`
|
||||||
@@ -274,154 +230,69 @@ async function ensureMariaSchema() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ensureData() {
|
async function ensureData() {
|
||||||
if (DB_CLIENT === 'mariadb') {
|
await ensureSchema()
|
||||||
await ensureMariaSchema()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await getLowdb()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function countUsers() {
|
async function countUsers() {
|
||||||
if (DB_CLIENT === 'mariadb') {
|
const rows = await query('SELECT COUNT(*) AS count FROM users')
|
||||||
const rows = await query('SELECT COUNT(*) AS count FROM users')
|
return Number(rows[0]?.count || 0)
|
||||||
return Number(rows[0]?.count || 0)
|
|
||||||
}
|
|
||||||
const db = await getLowdb()
|
|
||||||
return db.data.users.length
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findUserByEmail(email) {
|
async function findUserByEmail(email) {
|
||||||
if (DB_CLIENT === 'mariadb') {
|
const rows = await query(
|
||||||
const rows = await query(
|
'SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at FROM users WHERE email = ? LIMIT 1',
|
||||||
'SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at FROM users WHERE email = ? LIMIT 1',
|
[email]
|
||||||
[email]
|
)
|
||||||
)
|
const row = rows[0]
|
||||||
const row = rows[0]
|
if (!row) return null
|
||||||
if (!row) return null
|
return { ...mapUserRow(row), passwordHash: row.password_hash }
|
||||||
return { ...mapUserRow(row), passwordHash: row.password_hash }
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = await getLowdb()
|
|
||||||
return db.data.users.find((user) => user.email === email) || null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findUserById(id) {
|
async function findUserById(id) {
|
||||||
if (DB_CLIENT === 'mariadb') {
|
const rows = await query(
|
||||||
const rows = await query(
|
'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1',
|
||||||
'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1',
|
[id]
|
||||||
[id]
|
)
|
||||||
)
|
return mapUserRow(rows[0])
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createUser({ id, email, nickname, passwordHash, isAdmin }) {
|
async function createUser({ id, email, nickname, passwordHash, isAdmin }) {
|
||||||
if (DB_CLIENT === 'mariadb') {
|
const createdAt = now()
|
||||||
const createdAt = now()
|
await query(
|
||||||
await query(
|
`
|
||||||
`
|
INSERT INTO users (id, email, nickname, password_hash, is_admin, avatar_src, created_at)
|
||||||
INSERT INTO users (id, email, nickname, password_hash, is_admin, avatar_src, created_at)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
`,
|
||||||
`,
|
[id, email, nickname || '', passwordHash, isAdmin ? 1 : 0, '', createdAt]
|
||||||
[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()
|
|
||||||
return findUserById(id)
|
return findUserById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateUserProfile({ id, nickname, avatarSrc }) {
|
async function updateUserProfile({ id, nickname, avatarSrc }) {
|
||||||
if (DB_CLIENT === 'mariadb') {
|
if (typeof avatarSrc === 'string') {
|
||||||
if (typeof avatarSrc === 'string') {
|
await query('UPDATE users SET nickname = ?, avatar_src = ? WHERE id = ?', [nickname || '', avatarSrc, id])
|
||||||
await query('UPDATE users SET nickname = ?, avatar_src = ? WHERE id = ?', [nickname || '', avatarSrc, id])
|
} else {
|
||||||
} else {
|
await query('UPDATE users SET nickname = ? WHERE id = ?', [nickname || '', id])
|
||||||
await query('UPDATE users SET nickname = ? WHERE id = ?', [nickname || '', id])
|
|
||||||
}
|
|
||||||
return findUserById(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)
|
return findUserById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listGames() {
|
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')
|
||||||
const rows = await query('SELECT id, name, thumbnail_src, created_at FROM games ORDER BY created_at ASC, name ASC')
|
return rows.map(mapGameRow)
|
||||||
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),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findGameById(id) {
|
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])
|
||||||
const rows = await query('SELECT id, name, thumbnail_src, created_at FROM games WHERE id = ? LIMIT 1', [id])
|
return mapGameRow(rows[0])
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listGameItems(gameId) {
|
async function listGameItems(gameId) {
|
||||||
if (DB_CLIENT === 'mariadb') {
|
const rows = await query(
|
||||||
const rows = await query(
|
'SELECT id, game_id, src, label, created_at FROM game_items WHERE game_id = ? ORDER BY created_at ASC',
|
||||||
'SELECT id, game_id, src, label, created_at FROM game_items WHERE game_id = ? ORDER BY created_at ASC',
|
[gameId]
|
||||||
[gameId]
|
)
|
||||||
)
|
return rows.map(mapGameItemRow)
|
||||||
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),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getGameDetail(gameId) {
|
async function getGameDetail(gameId) {
|
||||||
@@ -432,276 +303,184 @@ async function getGameDetail(gameId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createGame({ id, name }) {
|
async function createGame({ id, name }) {
|
||||||
if (DB_CLIENT === 'mariadb') {
|
await query('INSERT INTO games (id, name, thumbnail_src, created_at) VALUES (?, ?, ?, ?)', [id, name, '', now()])
|
||||||
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()
|
|
||||||
return findGameById(id)
|
return findGameById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateGameThumbnail(gameId, thumbnailSrc) {
|
async function updateGameThumbnail(gameId, thumbnailSrc) {
|
||||||
if (DB_CLIENT === 'mariadb') {
|
await query('UPDATE games SET thumbnail_src = ? WHERE id = ?', [thumbnailSrc, gameId])
|
||||||
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()
|
|
||||||
return findGameById(gameId)
|
return findGameById(gameId)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createGameItem({ id, gameId, src, label }) {
|
async function createGameItem({ id, gameId, src, label }) {
|
||||||
if (DB_CLIENT === 'mariadb') {
|
const createdAt = now()
|
||||||
const createdAt = now()
|
await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
|
||||||
await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
|
id,
|
||||||
id,
|
gameId,
|
||||||
gameId,
|
src,
|
||||||
src,
|
label,
|
||||||
label,
|
createdAt,
|
||||||
createdAt,
|
])
|
||||||
])
|
const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [id])
|
||||||
const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [id])
|
return mapGameItemRow(rows[0])
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createCustomItem({ id, ownerId, src, label }) {
|
async function deleteGameItem(itemId) {
|
||||||
if (DB_CLIENT === 'mariadb') {
|
const gameItemRows = await query('SELECT game_id FROM game_items WHERE id = ? LIMIT 1', [itemId])
|
||||||
const createdAt = now()
|
const gameId = gameItemRows[0]?.game_id
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = await getLowdb()
|
if (gameId) {
|
||||||
const item = { id, ownerId, src, label, origin: 'custom', createdAt: now() }
|
const tierListRows = await query(
|
||||||
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(
|
|
||||||
`
|
`
|
||||||
SELECT id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at
|
SELECT id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at
|
||||||
FROM tierlists
|
FROM tierlists
|
||||||
WHERE id = ?
|
WHERE game_id = ?
|
||||||
LIMIT 1
|
|
||||||
`,
|
`,
|
||||||
[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()
|
await query('DELETE FROM game_items WHERE id = ?', [itemId])
|
||||||
const tierList = db.data.tierLists.find((entry) => entry.id === id)
|
}
|
||||||
if (!tierList) return null
|
|
||||||
return {
|
async function deleteGame(gameId) {
|
||||||
id: tierList.id,
|
await query('DELETE FROM games WHERE id = ?', [gameId])
|
||||||
authorId: tierList.authorId,
|
}
|
||||||
gameId: tierList.gameId,
|
|
||||||
title: tierList.title,
|
async function createCustomItem({ id, ownerId, src, label }) {
|
||||||
description: tierList.description || '',
|
const createdAt = now()
|
||||||
isPublic: !!tierList.isPublic,
|
await query('INSERT INTO custom_items (id, owner_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
|
||||||
groups: Array.isArray(tierList.groups) ? tierList.groups : [],
|
id,
|
||||||
pool: Array.isArray(tierList.pool) ? tierList.pool : [],
|
ownerId,
|
||||||
createdAt: Number(tierList.createdAt),
|
src,
|
||||||
updatedAt: Number(tierList.updatedAt),
|
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 }) {
|
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) {
|
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()
|
|
||||||
await query(
|
await query(
|
||||||
`
|
`
|
||||||
INSERT INTO tierlists (
|
UPDATE tierlists
|
||||||
id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at
|
SET title = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ?
|
||||||
)
|
WHERE id = ?
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`,
|
`,
|
||||||
[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)
|
return findTierListById(existing.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tierList = {
|
const createdAt = now()
|
||||||
id,
|
await query(
|
||||||
authorId,
|
`
|
||||||
gameId,
|
INSERT INTO tierlists (
|
||||||
title,
|
id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at
|
||||||
description: description || '',
|
)
|
||||||
isPublic: !!isPublic,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
groups,
|
`,
|
||||||
pool,
|
[id, authorId, gameId, title, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
|
||||||
createdAt: now(),
|
)
|
||||||
updatedAt: now(),
|
|
||||||
}
|
|
||||||
db.data.tierLists.push(tierList)
|
|
||||||
await db.write()
|
|
||||||
return findTierListById(id)
|
return findTierListById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
DB_CLIENT,
|
|
||||||
DB_NAME,
|
DB_NAME,
|
||||||
ensureData,
|
ensureData,
|
||||||
countUsers,
|
countUsers,
|
||||||
@@ -716,6 +495,8 @@ module.exports = {
|
|||||||
createGame,
|
createGame,
|
||||||
updateGameThumbnail,
|
updateGameThumbnail,
|
||||||
createGameItem,
|
createGameItem,
|
||||||
|
deleteGameItem,
|
||||||
|
deleteGame,
|
||||||
createCustomItem,
|
createCustomItem,
|
||||||
createGameSuggestion,
|
createGameSuggestion,
|
||||||
listPublicTierLists,
|
listPublicTierLists,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const {
|
|||||||
createGame,
|
createGame,
|
||||||
updateGameThumbnail,
|
updateGameThumbnail,
|
||||||
createGameItem,
|
createGameItem,
|
||||||
|
deleteGameItem,
|
||||||
|
deleteGame,
|
||||||
} = require('../db')
|
} = require('../db')
|
||||||
const { requireAdmin } = require('../middleware/auth')
|
const { requireAdmin } = require('../middleware/auth')
|
||||||
|
|
||||||
@@ -61,4 +63,18 @@ router.post('/games/:gameId/images', requireAdmin, upload.single('image'), async
|
|||||||
res.json({ item })
|
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
|
module.exports = router
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
- 경로, 주소, 운영 설정은 하드코딩보다 환경변수 기반 구성을 우선한다.
|
- 경로, 주소, 운영 설정은 하드코딩보다 환경변수 기반 구성을 우선한다.
|
||||||
- Git 커밋 메시지는 한국어로 작성한다.
|
- Git 커밋 메시지는 한국어로 작성한다.
|
||||||
- 버전 릴리스가 포함된 작업은 `docs/update.md`의 버전 표기와 Git 태그를 함께 맞춘다.
|
- 버전 릴리스가 포함된 작업은 `docs/update.md`의 버전 표기와 Git 태그를 함께 맞춘다.
|
||||||
|
- Git 푸시 전에는 민감 정보(실명, 개인 이메일, 비밀키, 로컬 절대 경로) 포함 여부를 다시 확인한다.
|
||||||
|
|
||||||
## 프런트엔드
|
## 프런트엔드
|
||||||
- API 호출은 `frontend/src/lib/api.js` 또는 런타임 유틸을 통해 통합한다.
|
- API 호출은 `frontend/src/lib/api.js` 또는 런타임 유틸을 통해 통합한다.
|
||||||
|
|||||||
@@ -26,3 +26,17 @@
|
|||||||
|
|
||||||
## 2026-03-19 v0.1.6
|
## 2026-03-19 v0.1.6
|
||||||
- 저장소 운영 규칙을 정리하면서 Git 작성자 정보는 프로젝트 기준 계정으로 통일하고, 커밋 메시지는 한국어로 남기기로 결정했다.
|
- 저장소 운영 규칙을 정리하면서 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 저장소와 예외 실행 스크립트를 남겨두는 비용이 더 크다고 판단해 삭제하기로 결정했다.
|
||||||
|
|||||||
@@ -33,14 +33,6 @@ cd frontend
|
|||||||
VITE_API_ORIGIN=http://localhost:5179 npm run dev
|
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 계층 차이를 줄일 수 있다.
|
- NAS 배포 시에도 동일하게 MariaDB를 사용하므로 로컬과 운영 간 DB 계층 차이를 줄일 수 있다.
|
||||||
|
|||||||
@@ -27,8 +27,8 @@
|
|||||||
|
|
||||||
## `/admin`
|
## `/admin`
|
||||||
- 화면 파일: `frontend/src/views/AdminView.vue`
|
- 화면 파일: `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`
|
## `/profile`
|
||||||
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
- 아바타: `backend/uploads/avatars/`
|
- 아바타: `backend/uploads/avatars/`
|
||||||
- 커스텀 아이템: `backend/uploads/custom/`
|
- 커스텀 아이템: `backend/uploads/custom/`
|
||||||
- 시드 이미지: `backend/uploads/seeds/`
|
- 시드 이미지: `backend/uploads/seeds/`
|
||||||
- 레거시 마이그레이션 소스: `backend/data/db.json`
|
|
||||||
|
|
||||||
## DB 스키마
|
## DB 스키마
|
||||||
- `users`
|
- `users`
|
||||||
@@ -81,12 +80,13 @@
|
|||||||
- `POST /api/admin/games`
|
- `POST /api/admin/games`
|
||||||
- `POST /api/admin/games/:gameId/thumbnail`
|
- `POST /api/admin/games/:gameId/thumbnail`
|
||||||
- `POST /api/admin/games/:gameId/images`
|
- `POST /api/admin/games/:gameId/images`
|
||||||
|
- `DELETE /api/admin/games/:gameId/items/:itemId`
|
||||||
|
- `DELETE /api/admin/games/:gameId`
|
||||||
|
|
||||||
## 운영 환경 변수
|
## 운영 환경 변수
|
||||||
- 프런트엔드
|
- 프런트엔드
|
||||||
- `VITE_API_ORIGIN`: API 및 업로드 파일 절대 기준 주소
|
- `VITE_API_ORIGIN`: API 및 업로드 파일 절대 기준 주소
|
||||||
- 백엔드
|
- 백엔드
|
||||||
- `DB_CLIENT`: 기본 개발 기준은 `mariadb`
|
|
||||||
- `DB_HOST`: MariaDB 호스트
|
- `DB_HOST`: MariaDB 호스트
|
||||||
- `DB_PORT`: MariaDB 포트
|
- `DB_PORT`: MariaDB 포트
|
||||||
- `DB_USER`: MariaDB 계정
|
- `DB_USER`: MariaDB 계정
|
||||||
@@ -105,9 +105,8 @@
|
|||||||
- MariaDB 컨테이너 또는 NAS 패키지 설치
|
- MariaDB 컨테이너 또는 NAS 패키지 설치
|
||||||
- phpMyAdmin 또는 Adminer 설치
|
- phpMyAdmin 또는 Adminer 설치
|
||||||
- 앱은 환경변수로 해당 DB에 연결
|
- 앱은 환경변수로 해당 DB에 연결
|
||||||
- 레거시 `db.json` 데이터는 `node backend/scripts/migrate-lowdb-to-mariadb.js`로 이관한다.
|
|
||||||
|
|
||||||
## 로컬 개발 기준
|
## 로컬 개발 기준
|
||||||
- 기본 로컬 개발도 `docker compose`로 띄운 MariaDB를 사용한다.
|
- 기본 로컬 개발도 `docker compose`로 띄운 MariaDB를 사용한다.
|
||||||
- 기본 백엔드 실행 스크립트 `backend/package.json`의 `dev`, `start`, `migrate:lowdb`는 로컬 MariaDB(`127.0.0.1:3307`) 기준으로 맞춰져 있다.
|
- 기본 백엔드 실행 스크립트 `backend/package.json`의 `dev`, `start`는 로컬 MariaDB(`127.0.0.1:3307`) 기준으로 맞춰져 있다.
|
||||||
- 예외적으로만 `dev:lowdb`, `start:lowdb`를 사용한다.
|
- `backend/src/db.js`는 MariaDB만 대상으로 동작하며, 파일 기반 fallback은 제거되었다.
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
## 즉시 확인 필요
|
## 즉시 확인 필요
|
||||||
- 관리자 화면에 게임 제안(`gameSuggestions`) 조회/처리 UI가 아직 없다.
|
- 관리자 화면에 게임 제안(`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` 값을 설정한다.
|
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
|
||||||
@@ -13,6 +11,6 @@
|
|||||||
- 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다.
|
- 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다.
|
||||||
|
|
||||||
## 중기 개선
|
## 중기 개선
|
||||||
- 게임/이미지/티어표 삭제 및 수정 이력 관리 기능을 추가한다.
|
- 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다.
|
||||||
- 자동 테스트와 최소한의 배포 체크리스트를 만든다.
|
- 자동 테스트와 최소한의 배포 체크리스트를 만든다.
|
||||||
- 관리자용 게임 제안 승인/반려, 아이템 삭제/정렬 UI를 추가한다.
|
- 관리자용 게임 제안 승인/반려, 아이템 정렬 UI를 추가한다.
|
||||||
|
|||||||
@@ -50,3 +50,20 @@
|
|||||||
## 2026-03-19 v0.1.6
|
## 2026-03-19 v0.1.6
|
||||||
- **저장소 메타데이터 정리**: Git 작성자 정보를 프로젝트 계정 기준으로 통일하고, 초기 릴리스 커밋 메시지를 한국어 기준으로 재작성
|
- **저장소 메타데이터 정리**: Git 작성자 정보를 프로젝트 계정 기준으로 통일하고, 초기 릴리스 커밋 메시지를 한국어 기준으로 재작성
|
||||||
- **버전 관리 규칙 보강**: 커밋 메시지 한국어 작성 및 문서 버전과 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 전용 개발/배포 흐름 기준으로 갱신
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { toApiUrl } from '../lib/runtime'
|
import { toApiUrl } from '../lib/runtime'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
@@ -8,8 +8,10 @@ const auth = useAuthStore()
|
|||||||
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
||||||
|
|
||||||
const games = ref([])
|
const games = ref([])
|
||||||
|
const adminMode = ref('existing')
|
||||||
const selectedGameId = ref('')
|
const selectedGameId = ref('')
|
||||||
const selectedGame = ref(null)
|
const selectedGame = ref(null)
|
||||||
|
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const success = ref('')
|
const success = ref('')
|
||||||
|
|
||||||
@@ -19,15 +21,22 @@ const newGameName = ref('')
|
|||||||
const uploadLabel = ref('')
|
const uploadLabel = ref('')
|
||||||
const uploadFile = ref(null)
|
const uploadFile = ref(null)
|
||||||
const thumbFile = ref(null)
|
const thumbFile = ref(null)
|
||||||
|
const itemPreviewUrl = ref('')
|
||||||
|
const thumbPreviewUrl = ref('')
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await auth.refresh()
|
await auth.refresh()
|
||||||
await refreshGames()
|
await refreshGames()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearPreviewUrl('item')
|
||||||
|
clearPreviewUrl('thumb')
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
|
||||||
|
|
||||||
async function refreshGames() {
|
async function refreshGames() {
|
||||||
error.value = ''
|
|
||||||
success.value = ''
|
|
||||||
try {
|
try {
|
||||||
const data = await api.listGames()
|
const data = await api.listGames()
|
||||||
games.value = data.games || []
|
games.value = data.games || []
|
||||||
@@ -36,13 +45,43 @@ async function refreshGames() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadGame() {
|
function resetMessages() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
success.value = ''
|
success.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMode(mode) {
|
||||||
|
resetMessages()
|
||||||
|
adminMode.value = mode
|
||||||
|
selectedGameId.value = ''
|
||||||
|
selectedGame.value = null
|
||||||
|
newGameId.value = ''
|
||||||
|
newGameName.value = ''
|
||||||
|
uploadLabel.value = ''
|
||||||
|
uploadFile.value = null
|
||||||
|
thumbFile.value = null
|
||||||
|
clearPreviewUrl('item')
|
||||||
|
clearPreviewUrl('thumb')
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPreviewUrl(type) {
|
||||||
|
if (type === 'item' && itemPreviewUrl.value) {
|
||||||
|
URL.revokeObjectURL(itemPreviewUrl.value)
|
||||||
|
itemPreviewUrl.value = ''
|
||||||
|
}
|
||||||
|
if (type === 'thumb' && thumbPreviewUrl.value) {
|
||||||
|
URL.revokeObjectURL(thumbPreviewUrl.value)
|
||||||
|
thumbPreviewUrl.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGame() {
|
||||||
|
resetMessages()
|
||||||
if (!selectedGameId.value) {
|
if (!selectedGameId.value) {
|
||||||
selectedGame.value = null
|
selectedGame.value = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.getGame(selectedGameId.value)
|
const data = await api.getGame(selectedGameId.value)
|
||||||
selectedGame.value = data
|
selectedGame.value = data
|
||||||
@@ -52,8 +91,7 @@ async function loadGame() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createGame() {
|
async function createGame() {
|
||||||
error.value = ''
|
resetMessages()
|
||||||
success.value = ''
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(toApiUrl('/api/admin/games'), {
|
const res = await fetch(toApiUrl('/api/admin/games'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -62,58 +100,41 @@ async function createGame() {
|
|||||||
body: JSON.stringify({ id: newGameId.value, name: newGameName.value }),
|
body: JSON.stringify({ id: newGameId.value, name: newGameName.value }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('failed')
|
if (!res.ok) throw new Error('failed')
|
||||||
newGameId.value = ''
|
|
||||||
newGameName.value = ''
|
const data = await res.json()
|
||||||
await refreshGames()
|
await refreshGames()
|
||||||
selectedGameId.value = ''
|
adminMode.value = 'existing'
|
||||||
selectedGame.value = null
|
selectedGameId.value = data.game.id
|
||||||
success.value = '게임이 생성됐어요. 목록에서 선택한 뒤 썸네일과 아이템을 등록해주세요.'
|
await loadGame()
|
||||||
|
success.value = '게임이 생성됐어요. 이어서 썸네일과 아이템을 관리할 수 있어요.'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '게임 생성 실패(관리자 권한/중복 ID 확인)'
|
error.value = '게임 생성 실패(관리자 권한/중복 ID 확인)'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadItem() {
|
function onThumb(event) {
|
||||||
error.value = ''
|
thumbFile.value = event.target.files && event.target.files[0] ? event.target.files[0] : null
|
||||||
success.value = ''
|
clearPreviewUrl('thumb')
|
||||||
if (!uploadFile.value) {
|
if (thumbFile.value) {
|
||||||
error.value = '아이템 파일을 선택해주세요.'
|
thumbPreviewUrl.value = URL.createObjectURL(thumbFile.value)
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const fd = new FormData()
|
|
||||||
fd.append('label', uploadLabel.value || 'image')
|
|
||||||
fd.append('image', uploadFile.value)
|
|
||||||
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedGameId.value)}/images`), {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
body: fd,
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error('failed')
|
|
||||||
uploadLabel.value = ''
|
|
||||||
uploadFile.value = null
|
|
||||||
await loadGame()
|
|
||||||
success.value = '아이템이 추가됐어요.'
|
|
||||||
} catch (e) {
|
|
||||||
error.value = '아이템 추가 실패(관리자 권한/파일 크기 확인)'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFile(e) {
|
function onFile(event) {
|
||||||
uploadFile.value = e.target.files && e.target.files[0] ? e.target.files[0] : null
|
uploadFile.value = event.target.files && event.target.files[0] ? event.target.files[0] : null
|
||||||
}
|
clearPreviewUrl('item')
|
||||||
|
if (uploadFile.value) {
|
||||||
function onThumb(e) {
|
itemPreviewUrl.value = URL.createObjectURL(uploadFile.value)
|
||||||
thumbFile.value = e.target.files && e.target.files[0] ? e.target.files[0] : null
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadThumbnail() {
|
async function uploadThumbnail() {
|
||||||
error.value = ''
|
resetMessages()
|
||||||
success.value = ''
|
if (!thumbFile.value || !selectedGameId.value) {
|
||||||
if (!thumbFile.value) {
|
|
||||||
error.value = '썸네일 파일을 선택해주세요.'
|
error.value = '썸네일 파일을 선택해주세요.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('thumbnail', thumbFile.value)
|
fd.append('thumbnail', thumbFile.value)
|
||||||
@@ -123,7 +144,9 @@ async function uploadThumbnail() {
|
|||||||
body: fd,
|
body: fd,
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('failed')
|
if (!res.ok) throw new Error('failed')
|
||||||
|
|
||||||
thumbFile.value = null
|
thumbFile.value = null
|
||||||
|
clearPreviewUrl('thumb')
|
||||||
await refreshGames()
|
await refreshGames()
|
||||||
await loadGame()
|
await loadGame()
|
||||||
success.value = '썸네일이 반영됐어요.'
|
success.value = '썸네일이 반영됐어요.'
|
||||||
@@ -131,69 +154,190 @@ async function uploadThumbnail() {
|
|||||||
error.value = '썸네일 업로드 실패(관리자 권한/파일 크기 확인)'
|
error.value = '썸네일 업로드 실패(관리자 권한/파일 크기 확인)'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadItem() {
|
||||||
|
resetMessages()
|
||||||
|
if (!uploadFile.value || !selectedGameId.value) {
|
||||||
|
error.value = '아이템 파일을 선택해주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('label', uploadLabel.value || 'item')
|
||||||
|
fd.append('image', uploadFile.value)
|
||||||
|
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedGameId.value)}/images`), {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: fd,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('failed')
|
||||||
|
|
||||||
|
uploadLabel.value = ''
|
||||||
|
uploadFile.value = null
|
||||||
|
clearPreviewUrl('item')
|
||||||
|
await loadGame()
|
||||||
|
success.value = '아이템이 추가됐어요.'
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '아이템 추가 실패(관리자 권한/파일 크기 확인)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeItem(itemId) {
|
||||||
|
resetMessages()
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
toApiUrl(`/api/admin/games/${encodeURIComponent(selectedGameId.value)}/items/${encodeURIComponent(itemId)}`),
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!res.ok) throw new Error('failed')
|
||||||
|
|
||||||
|
await loadGame()
|
||||||
|
success.value = '아이템을 삭제했어요.'
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '아이템 삭제에 실패했어요.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeGame() {
|
||||||
|
resetMessages()
|
||||||
|
if (!selectedGameId.value || !selectedGame.value?.game) return
|
||||||
|
|
||||||
|
const ok = window.confirm(`"${selectedGame.value.game.name}" 게임을 삭제할까요? 관련 아이템과 티어표도 함께 삭제됩니다.`)
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedGameId.value)}`), {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('failed')
|
||||||
|
|
||||||
|
const deletedName = selectedGame.value.game.name
|
||||||
|
selectedGameId.value = ''
|
||||||
|
selectedGame.value = null
|
||||||
|
clearPreviewUrl('item')
|
||||||
|
clearPreviewUrl('thumb')
|
||||||
|
await refreshGames()
|
||||||
|
success.value = `${deletedName} 게임을 삭제했어요.`
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '게임 삭제에 실패했어요.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayThumbnailUrl = computed(() => {
|
||||||
|
if (thumbPreviewUrl.value) return thumbPreviewUrl.value
|
||||||
|
if (selectedGame.value?.game?.thumbnailSrc) return toApiUrl(selectedGame.value.game.thumbnailSrc)
|
||||||
|
return ''
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="wrap">
|
<section class="wrap">
|
||||||
<h2 class="title">관리자</h2>
|
<h2 class="title">관리자</h2>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="desc">게임 생성 후 선택한 게임에만 썸네일과 관리자 아이템을 등록할 수 있습니다.</div>
|
<div class="desc">먼저 작업 방식을 고르고, 그 다음 선택한 게임의 정보만 집중해서 관리합니다.</div>
|
||||||
<div v-if="!auth.user" class="warn">로그인이 필요해요.</div>
|
<div v-if="!auth.user" class="warn">로그인이 필요해요.</div>
|
||||||
<div v-else-if="!isAdmin" class="warn">이 계정은 관리자 권한이 없어요.</div>
|
<div v-else-if="!isAdmin" class="warn">이 계정은 관리자 권한이 없어요.</div>
|
||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<template v-else>
|
||||||
<div v-if="success" class="success">{{ success }}</div>
|
<div class="modeTabs">
|
||||||
|
<button class="modeTab" :class="{ 'modeTab--active': adminMode === 'existing' }" @click="setMode('existing')">
|
||||||
<div class="grid">
|
등록된 게임 선택
|
||||||
<div class="panel">
|
</button>
|
||||||
<div class="panel__title">게임 선택</div>
|
<button class="modeTab" :class="{ 'modeTab--active': adminMode === 'new' }" @click="setMode('new')">
|
||||||
<select v-model="selectedGameId" class="select" :disabled="!isAdmin" @change="loadGame">
|
새 게임 추가
|
||||||
<option value="">게임을 선택해주세요</option>
|
</button>
|
||||||
<option v-for="g in games" :key="g.id" :value="g.id">{{ g.name }} ({{ g.id }})</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<div class="panel__title" style="margin-top: 14px">새 게임 추가</div>
|
|
||||||
<input v-model="newGameId" class="input" placeholder="game id (영문/숫자)" :disabled="!isAdmin" />
|
|
||||||
<input v-model="newGameName" class="input" placeholder="게임 이름" :disabled="!isAdmin" />
|
|
||||||
<button class="btn" :disabled="!isAdmin" @click="createGame">게임 생성</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedGame" class="panel">
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
<div class="panel__title">선택된 게임</div>
|
<div v-if="success" class="success">{{ success }}</div>
|
||||||
<div class="selectedGame">
|
|
||||||
|
<div class="panel panel--compact">
|
||||||
|
<template v-if="adminMode === 'existing'">
|
||||||
|
<div class="panel__title">등록된 게임 선택</div>
|
||||||
|
<select v-model="selectedGameId" class="select" @change="loadGame">
|
||||||
|
<option value="">게임을 선택해주세요</option>
|
||||||
|
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
|
||||||
|
</select>
|
||||||
|
<div class="hint">게임을 고르면 아래에서 썸네일, 아이템 목록, 삭제 작업을 관리할 수 있어요.</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="panel__title">새 게임 정보 입력</div>
|
||||||
|
<input v-model="newGameId" class="input" placeholder="game id (영문/숫자)" />
|
||||||
|
<input v-model="newGameName" class="input" placeholder="게임 이름" />
|
||||||
|
<button class="btn btn--primary" @click="createGame">게임 생성</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasSelectedGame" class="panel panel--detail">
|
||||||
|
<div class="detailHead">
|
||||||
<div>
|
<div>
|
||||||
|
<div class="panel__title">선택된 게임 정보</div>
|
||||||
<div class="selectedGame__name">{{ selectedGame.game.name }}</div>
|
<div class="selectedGame__name">{{ selectedGame.game.name }}</div>
|
||||||
<div class="selectedGame__id">{{ selectedGame.game.id }}</div>
|
<div class="selectedGame__id">{{ selectedGame.game.id }}</div>
|
||||||
</div>
|
</div>
|
||||||
<img
|
<div class="detailHead__actions">
|
||||||
v-if="selectedGame.game.thumbnailSrc"
|
<button class="btn btn--danger" @click="removeGame">게임 삭제</button>
|
||||||
class="selectedThumb"
|
</div>
|
||||||
:src="toApiUrl(selectedGame.game.thumbnailSrc)"
|
|
||||||
:alt="selectedGame.game.name"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel__title" style="margin-top: 14px">게임 썸네일 업로드</div>
|
<div class="section">
|
||||||
<div class="hint">홈 화면의 게임 카드에 바로 반영되는 대표 이미지입니다.</div>
|
<div class="section__title">썸네일</div>
|
||||||
<input type="file" accept="image/*" class="inputFile" :disabled="!isAdmin" @change="onThumb" />
|
<div class="uploadPreviewCard uploadPreviewCard--wide">
|
||||||
<button class="btn" :disabled="!isAdmin" @click="uploadThumbnail">썸네일 업로드</button>
|
<img
|
||||||
|
v-if="displayThumbnailUrl"
|
||||||
|
class="selectedThumb"
|
||||||
|
:src="displayThumbnailUrl"
|
||||||
|
:alt="selectedGame.game.name"
|
||||||
|
/>
|
||||||
|
<div v-else class="selectedThumb selectedThumb--empty">16:9 미리보기</div>
|
||||||
|
<div class="uploadPreviewMeta">
|
||||||
|
<div class="uploadPreviewTitle">대표 썸네일</div>
|
||||||
|
<div class="uploadPreviewDesc">
|
||||||
|
파일을 선택하면 먼저 미리보기로 확인되고, 업로드 버튼을 눌렀을 때 실제 저장됩니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" accept="image/*" class="inputFile" @change="onThumb" />
|
||||||
|
<button class="btn" @click="uploadThumbnail">썸네일 업로드</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panel__title" style="margin-top: 14px">아이템 추가</div>
|
<div class="section">
|
||||||
<div class="hint">관리자가 지정한 아이템만 이 목록에 포함됩니다.</div>
|
<div class="section__title">아이템 추가</div>
|
||||||
<input v-model="uploadLabel" class="input" placeholder="아이템 이름" :disabled="!isAdmin" />
|
<div class="itemComposer">
|
||||||
<input type="file" accept="image/*" class="inputFile" :disabled="!isAdmin" @change="onFile" />
|
<div class="itemComposer__form">
|
||||||
<button class="btn" :disabled="!isAdmin" @click="uploadItem">아이템 추가</button>
|
<input v-model="uploadLabel" class="input" placeholder="아이템 이름" />
|
||||||
|
<input type="file" accept="image/*" class="inputFile" @change="onFile" />
|
||||||
|
<button class="btn" @click="uploadItem">아이템 추가</button>
|
||||||
|
</div>
|
||||||
|
<div class="itemPreviewCard">
|
||||||
|
<div class="itemPreviewFrame">
|
||||||
|
<img v-if="itemPreviewUrl" class="itemPreviewImage" :src="itemPreviewUrl" alt="item preview" />
|
||||||
|
<div v-else class="itemPreviewEmpty">1:1 미리보기</div>
|
||||||
|
</div>
|
||||||
|
<div class="thumbLabel thumbLabel--preview">{{ uploadLabel || '아이템 이름 미리보기' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panel__title" style="margin-top: 14px">현재 아이템 목록</div>
|
<div class="section">
|
||||||
<div v-if="!selectedGame?.items?.length" class="hint">아직 등록된 아이템이 없어요.</div>
|
<div class="section__title">현재 아이템 목록</div>
|
||||||
<div v-else class="thumbGrid">
|
<div v-if="!selectedGame?.items?.length" class="hint">아직 등록된 아이템이 없어요.</div>
|
||||||
<div v-for="img in selectedGame.items" :key="img.id" class="thumbCard">
|
<div v-else class="thumbGrid">
|
||||||
<img class="thumb" :src="toApiUrl(img.src)" :alt="img.label" />
|
<div v-for="item in selectedGame.items" :key="item.id" class="thumbCard">
|
||||||
<div class="thumbLabel">{{ img.label }}</div>
|
<img class="thumb" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||||
|
<div class="thumbLabel">{{ item.label }}</div>
|
||||||
|
<button class="btn btn--danger btn--small" @click="removeItem(item.id)">아이템 삭제</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -217,47 +361,81 @@ async function uploadThumbnail() {
|
|||||||
opacity: 0.82;
|
opacity: 0.82;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.warn {
|
.warn,
|
||||||
margin-top: 10px;
|
.error,
|
||||||
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);
|
|
||||||
}
|
|
||||||
.success {
|
.success {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 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);
|
border: 1px solid rgba(52, 211, 153, 0.32);
|
||||||
background: rgba(52, 211, 153, 0.14);
|
background: rgba(52, 211, 153, 0.14);
|
||||||
}
|
}
|
||||||
.grid {
|
.modeTabs {
|
||||||
margin-top: 12px;
|
margin-top: 14px;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr 1fr;
|
gap: 10px;
|
||||||
gap: 12px;
|
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 {
|
.panel {
|
||||||
|
margin-top: 14px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(0, 0, 0, 0.12);
|
background: rgba(0, 0, 0, 0.12);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 12px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
.panel__title {
|
.panel--compact {
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
.panel__title,
|
||||||
|
.section__title {
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
.hint {
|
.section {
|
||||||
opacity: 0.78;
|
margin-top: 18px;
|
||||||
font-size: 13px;
|
padding-top: 18px;
|
||||||
margin-bottom: 10px;
|
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,
|
.select,
|
||||||
.input {
|
.input {
|
||||||
@@ -269,11 +447,16 @@ async function uploadThumbnail() {
|
|||||||
background: rgba(0, 0, 0, 0.18);
|
background: rgba(0, 0, 0, 0.18);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
outline: none;
|
outline: none;
|
||||||
margin-bottom: 10px;
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin-top: 10px;
|
||||||
|
opacity: 0.78;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.inputFile {
|
.inputFile {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 10px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
.btn {
|
.btn {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
@@ -285,25 +468,26 @@ async function uploadThumbnail() {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
.btn:hover {
|
.btn--primary {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(96, 165, 250, 0.2);
|
||||||
}
|
}
|
||||||
.thumbGrid {
|
.btn--danger {
|
||||||
display: grid;
|
background: rgba(239, 68, 68, 0.14);
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
border-color: rgba(239, 68, 68, 0.28);
|
||||||
gap: 10px;
|
|
||||||
}
|
}
|
||||||
.selectedGame {
|
.btn--small {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.detailHead {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
justify-content: space-between;
|
||||||
border-radius: 14px;
|
align-items: flex-start;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
flex-wrap: wrap;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
}
|
||||||
.selectedGame__name {
|
.selectedGame__name {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 22px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
.selectedGame__id {
|
.selectedGame__id {
|
||||||
@@ -311,13 +495,68 @@ async function uploadThumbnail() {
|
|||||||
opacity: 0.72;
|
opacity: 0.72;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
.detailHead__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.thumbnailRow {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
.selectedThumb {
|
.selectedThumb {
|
||||||
width: 90px;
|
width: 256px;
|
||||||
height: 90px;
|
aspect-ratio: 16 / 9;
|
||||||
object-fit: cover;
|
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-radius: 14px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
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 {
|
.thumbCard {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
@@ -328,7 +567,7 @@ async function uploadThumbnail() {
|
|||||||
}
|
}
|
||||||
.thumb {
|
.thumb {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 92px;
|
aspect-ratio: 1 / 1;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
@@ -340,8 +579,22 @@ async function uploadThumbnail() {
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
.thumbLabel--preview {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@media (max-width: 980px) {
|
@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;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
package-lock.json
generated
28
package-lock.json
generated
@@ -13,7 +13,6 @@
|
|||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-session": "^1.19.0",
|
"express-session": "^1.19.0",
|
||||||
"lowdb": "^7.0.1",
|
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"nanoid": "^5.1.7",
|
"nanoid": "^5.1.7",
|
||||||
"session-file-store": "^1.5.0",
|
"session-file-store": "^1.5.0",
|
||||||
@@ -839,21 +838,6 @@
|
|||||||
"node": ">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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -1475,18 +1459,6 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/streamsearch": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-session": "^1.19.0",
|
"express-session": "^1.19.0",
|
||||||
"lowdb": "^7.0.1",
|
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"nanoid": "^5.1.7",
|
"nanoid": "^5.1.7",
|
||||||
"session-file-store": "^1.5.0",
|
"session-file-store": "^1.5.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user