릴리스: v0.1.6 MariaDB 개발 환경 및 저장소 설정 정리
This commit is contained in:
1
.cursorrules
Normal file
1
.cursorrules
Normal file
@@ -0,0 +1 @@
|
||||
모든 작업 시 프로젝트 루트의 .ai-rules.md 지침을 엄격히 준수하고, 작업 종료 시마다 docs/ 폴더 내 문서를 업데이트할 것.
|
||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
backend/node_modules/
|
||||
|
||||
frontend/dist/
|
||||
|
||||
backend/.sessions/
|
||||
backend/uploads/avatars/
|
||||
backend/uploads/games/
|
||||
backend/uploads/custom/
|
||||
|
||||
.DS_Store
|
||||
65
README.md
Normal file
65
README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Tier Maker (Vue)
|
||||
|
||||
리액트/타입스크립트 없이 **Vue3 + JavaScript**로 만드는 티어표 메이커 프로젝트입니다.
|
||||
|
||||
## 권장 로컬 실행
|
||||
|
||||
로컬과 서버의 DB 환경 차이를 줄이기 위해 기본 개발 환경도 `MariaDB + phpMyAdmin` 기준으로 맞춥니다.
|
||||
|
||||
### 1) MariaDB + phpMyAdmin 실행
|
||||
|
||||
```bash
|
||||
docker compose up -d mariadb phpmyadmin
|
||||
```
|
||||
|
||||
- MariaDB: `127.0.0.1:3307`
|
||||
- phpMyAdmin: `http://localhost:8081`
|
||||
|
||||
### 2) 백엔드 실행
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- 기본 주소: `http://localhost:5179`
|
||||
- 업로드 경로: `backend/uploads/`
|
||||
- 기본 개발 DB: MariaDB(`tier_cursor`)
|
||||
|
||||
### 3) 프론트엔드 실행
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
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)를 참고하세요.
|
||||
|
||||
## 사용 흐름(현재 구현)
|
||||
|
||||
- **게임 선택**: `/`에서 게임 클릭
|
||||
- **게임 허브**: 새 티어표 만들기 / 공개 티어표 목록
|
||||
- **에디터**:
|
||||
- 기본 그룹 5개(S/A/B/C/D)
|
||||
- 그룹 이름 수정, 그룹 순서 드래그 변경
|
||||
- 아이템 드래그&드롭(풀 ↔ 그룹, 그룹 ↔ 그룹)
|
||||
- 커스텀 이미지 추가 후 저장 시 서버 업로드
|
||||
- 저장(로그인 필요) / PNG 다운로드
|
||||
- **로그인/회원가입**: 첫 회원가입 계정은 개발용으로 admin 권한 부여
|
||||
- **프로필**: 작성자 닉네임 수정, 아바타 미리보기 후 저장
|
||||
- **관리자**: `/admin`에서 게임 추가, 게임 선택 후 썸네일/아이템 관리
|
||||
|
||||
## 업데이트 내역
|
||||
|
||||
`UPDATE.md`를 확인하세요.
|
||||
31
ai-rules.md
Normal file
31
ai-rules.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 프로젝트 AI 가이드라인 및 문서화 규칙
|
||||
|
||||
## 🌐 기본 응답 원칙
|
||||
- 모든 대화와 문서는 **한국어**로 작성한다.
|
||||
- 코드 내 주석은 반드시 **JSDoc 형식**을 사용한다.
|
||||
|
||||
## 📂 문서 자동 관리 규칙
|
||||
모든 작업 수행 후, AI는 관련 내용을 아래 지정된 파일에 즉시 반영해야 한다.
|
||||
|
||||
1. **작업 이력 (docs/update.md)**
|
||||
- 수행한 작업 내용을 상세히 기록한다.
|
||||
- 당일 업데이트라도 수정 시마다 버전을 갱신하여 하나의 파일에 누적 관리한다. (별도 파일 생성 금지)
|
||||
|
||||
2. **할 일 및 이슈 (docs/todo.md)**
|
||||
- 현재 직면한 문제점과 다음에 이어서 진행할 작업 목록을 정리한다.
|
||||
|
||||
3. **기술 명세 (docs/spec.md)**
|
||||
- API 명세, 기획 내용, 데이터베이스 스키마 구조를 최신 상태로 유지한다.
|
||||
|
||||
4. **코딩 컨벤션 (docs/convention.md)**
|
||||
- 프로젝트 전용 코딩 스타일 및 네이밍 규칙(Variable, Function, Class 등)을 정의하고 준수한다.
|
||||
|
||||
5. **의사결정 이력 (docs/history.md)**
|
||||
- 주요 설계 결정 사항 및 시스템 아키텍처 변경 이력을 기록한다.
|
||||
|
||||
6. **파일-화면 매핑 가이드 (docs/map.md)**
|
||||
- 연결성 기록: 특정 파일(Ex: Login.jsx)이 브라우저 화면의 어느 경로(/login)와 어느 기능(로그인 버튼 등)을 담당하는지 초보자 관점에서 기록한다.
|
||||
|
||||
## ⚠️ 실행 지침
|
||||
- 새로운 코드를 작성하거나 수정하기 전, 반드시 `docs/` 내 관련 문서들을 먼저 참조한다.
|
||||
- 작업 완료 후 위 문서들의 업데이트가 누락되지 않도록 확인한다.
|
||||
341
backend/data/db.json
Normal file
341
backend/data/db.json
Normal file
@@ -0,0 +1,341 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
87
backend/index.js
Normal file
87
backend/index.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const express = require('express')
|
||||
const cors = require('cors')
|
||||
const session = require('express-session')
|
||||
const FileStoreFactory = require('session-file-store')
|
||||
|
||||
const { ensureData } = require('./src/db')
|
||||
const authRoutes = require('./src/routes/auth')
|
||||
const gamesRoutes = require('./src/routes/games')
|
||||
const tierListsRoutes = require('./src/routes/tierlists')
|
||||
const adminRoutes = require('./src/routes/admin')
|
||||
|
||||
const app = express()
|
||||
const PORT = process.env.PORT ? Number(process.env.PORT) : 5179
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET || 'dev-secret-change-me'
|
||||
const SESSION_COOKIE_SECURE = process.env.SESSION_COOKIE_SECURE === 'true'
|
||||
const SESSION_COOKIE_SAME_SITE = process.env.SESSION_COOKIE_SAME_SITE || 'lax'
|
||||
const TRUST_PROXY = process.env.TRUST_PROXY ? Number(process.env.TRUST_PROXY) : 1
|
||||
const allowedOrigins = (process.env.CORS_ORIGINS || '')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const FileStore = FileStoreFactory(session)
|
||||
|
||||
;['uploads/avatars', 'uploads/games', 'uploads/custom', '.sessions'].forEach((relativePath) => {
|
||||
fs.mkdirSync(path.join(__dirname, relativePath), { recursive: true })
|
||||
})
|
||||
|
||||
app.set('trust proxy', TRUST_PROXY)
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, cb) => {
|
||||
if (!origin) return cb(null, true)
|
||||
if (allowedOrigins.includes(origin)) return cb(null, true)
|
||||
if (/^http:\/\/localhost:517\d$/.test(origin)) return cb(null, true) // 5170~5179
|
||||
if (/^https?:\/\/127\.0\.0\.1:517\d$/.test(origin)) return cb(null, true)
|
||||
if (origin === 'http://localhost:5173') return cb(null, true)
|
||||
cb(new Error('not_allowed_by_cors'))
|
||||
},
|
||||
credentials: true,
|
||||
})
|
||||
)
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
|
||||
app.use(
|
||||
session({
|
||||
store: new FileStore({
|
||||
path: path.join(__dirname, '.sessions'),
|
||||
ttl: 60 * 60 * 24 * 30,
|
||||
retries: 0,
|
||||
}),
|
||||
secret: SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: SESSION_COOKIE_SAME_SITE,
|
||||
secure: SESSION_COOKIE_SECURE,
|
||||
maxAge: 1000 * 60 * 60 * 24 * 30,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')))
|
||||
|
||||
app.get('/health', (req, res) => res.json({ ok: true }))
|
||||
|
||||
app.use(async (req, res, next) => {
|
||||
try {
|
||||
await ensureData()
|
||||
next()
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'db_init_failed' })
|
||||
}
|
||||
})
|
||||
|
||||
app.use('/api/auth', authRoutes)
|
||||
app.use('/api/games', gamesRoutes)
|
||||
app.use('/api/tierlists', tierListsRoutes)
|
||||
app.use('/api/admin', adminRoutes)
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[backend] listening on http://localhost:${PORT}`)
|
||||
})
|
||||
1782
backend/package-lock.json
generated
Normal file
1782
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
backend/package.json
Normal file
32
backend/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"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"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"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",
|
||||
"session-file-store": "^1.5.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.14"
|
||||
}
|
||||
}
|
||||
103
backend/scripts/migrate-lowdb-to-mariadb.js
Normal file
103
backend/scripts/migrate-lowdb-to-mariadb.js
Normal file
@@ -0,0 +1,103 @@
|
||||
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)
|
||||
})
|
||||
725
backend/src/db.js
Normal file
725
backend/src/db.js
Normal file
@@ -0,0 +1,725 @@
|
||||
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
|
||||
const DB_USER = process.env.DB_USER || 'root'
|
||||
const DB_PASSWORD = process.env.DB_PASSWORD || ''
|
||||
const DB_NAME = process.env.DB_NAME || 'tier_cursor'
|
||||
const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10
|
||||
|
||||
const LOWDB_PATH = path.join(__dirname, '..', 'data', 'db.json')
|
||||
|
||||
let lowdbPromise = null
|
||||
let poolPromise = null
|
||||
let initPromise = null
|
||||
|
||||
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 {
|
||||
return JSON.parse(value)
|
||||
} catch (e) {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function serializeJson(value) {
|
||||
return JSON.stringify(value || [])
|
||||
}
|
||||
|
||||
function mapUserRow(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
nickname: row.nickname || '',
|
||||
isAdmin: !!row.is_admin,
|
||||
avatarSrc: row.avatar_src || '',
|
||||
createdAt: Number(row.created_at),
|
||||
}
|
||||
}
|
||||
|
||||
function mapGameRow(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
thumbnailSrc: row.thumbnail_src || '',
|
||||
createdAt: Number(row.created_at),
|
||||
}
|
||||
}
|
||||
|
||||
function mapGameItemRow(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
gameId: row.game_id,
|
||||
src: row.src,
|
||||
label: row.label,
|
||||
createdAt: Number(row.created_at),
|
||||
}
|
||||
}
|
||||
|
||||
function mapTierListRow(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
authorId: row.author_id,
|
||||
gameId: row.game_id,
|
||||
title: row.title,
|
||||
description: row.description || '',
|
||||
isPublic: !!row.is_public,
|
||||
groups: parseJson(row.groups_json, []),
|
||||
pool: parseJson(row.pool_json, []),
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
port: DB_PORT,
|
||||
user: DB_USER,
|
||||
password: DB_PASSWORD,
|
||||
multipleStatements: true,
|
||||
})
|
||||
|
||||
await rootConnection.query(
|
||||
`CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
|
||||
)
|
||||
await rootConnection.end()
|
||||
|
||||
return mysql.createPool({
|
||||
host: DB_HOST,
|
||||
port: DB_PORT,
|
||||
user: DB_USER,
|
||||
password: DB_PASSWORD,
|
||||
database: DB_NAME,
|
||||
connectionLimit: DB_CONNECTION_LIMIT,
|
||||
charset: 'utf8mb4',
|
||||
})
|
||||
}
|
||||
|
||||
async function getPool() {
|
||||
if (!poolPromise) {
|
||||
poolPromise = createPool()
|
||||
}
|
||||
return poolPromise
|
||||
}
|
||||
|
||||
async function query(sql, params = []) {
|
||||
const pool = await getPool()
|
||||
const [rows] = await pool.execute(sql, params)
|
||||
return rows
|
||||
}
|
||||
|
||||
async function ensureMariaSchema() {
|
||||
if (initPromise) return initPromise
|
||||
initPromise = (async () => {
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
nickname VARCHAR(80) NOT NULL DEFAULT '',
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
is_admin TINYINT(1) NOT NULL DEFAULT 0,
|
||||
avatar_src VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at BIGINT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS games (
|
||||
id VARCHAR(120) PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at BIGINT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS game_items (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
game_id VARCHAR(120) NOT NULL,
|
||||
src VARCHAR(255) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
INDEX idx_game_items_game_id (game_id),
|
||||
CONSTRAINT fk_game_items_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS custom_items (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
owner_id VARCHAR(64) NOT NULL,
|
||||
src VARCHAR(255) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
INDEX idx_custom_items_owner_id (owner_id),
|
||||
CONSTRAINT fk_custom_items_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS tierlists (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
author_id VARCHAR(64) NOT NULL,
|
||||
game_id VARCHAR(120) NOT NULL,
|
||||
title VARCHAR(120) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
is_public TINYINT(1) NOT NULL DEFAULT 0,
|
||||
groups_json LONGTEXT NOT NULL,
|
||||
pool_json LONGTEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
INDEX idx_tierlists_author_id (author_id),
|
||||
INDEX idx_tierlists_game_id (game_id),
|
||||
INDEX idx_tierlists_public_game_updated (is_public, game_id, updated_at),
|
||||
CONSTRAINT fk_tierlists_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tierlists_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS game_suggestions (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
const countRows = await query('SELECT COUNT(*) AS count FROM games')
|
||||
if (Number(countRows[0]?.count || 0) === 0) {
|
||||
const createdAt = now()
|
||||
await query(
|
||||
`
|
||||
INSERT INTO games (id, name, thumbnail_src, created_at)
|
||||
VALUES
|
||||
(?, ?, ?, ?),
|
||||
(?, ?, ?, ?)
|
||||
`,
|
||||
['example-game', '예시 게임', '', createdAt, 'another-game', '다른 예시 게임', '', createdAt]
|
||||
)
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO game_items (id, game_id, src, label, created_at)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
'img-1',
|
||||
'example-game',
|
||||
'/uploads/seeds/example1.png',
|
||||
'샘플 1',
|
||||
createdAt,
|
||||
'img-2',
|
||||
'example-game',
|
||||
'/uploads/seeds/example2.png',
|
||||
'샘플 2',
|
||||
createdAt,
|
||||
]
|
||||
)
|
||||
}
|
||||
})()
|
||||
|
||||
return initPromise
|
||||
}
|
||||
|
||||
async function ensureData() {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
await ensureMariaSchema()
|
||||
return
|
||||
}
|
||||
await getLowdb()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
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)
|
||||
}
|
||||
|
||||
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),
|
||||
}))
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}))
|
||||
}
|
||||
|
||||
async function getGameDetail(gameId) {
|
||||
const game = await findGameById(gameId)
|
||||
if (!game) return null
|
||||
const items = await listGameItems(gameId)
|
||||
return { game, items }
|
||||
}
|
||||
|
||||
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()
|
||||
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()
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
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(
|
||||
`
|
||||
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])
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTierList({ id, authorId, gameId, title, description, isPublic, groups, pool }) {
|
||||
if (DB_CLIENT === 'mariadb') {
|
||||
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()
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
return findTierListById(id)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DB_CLIENT,
|
||||
DB_NAME,
|
||||
ensureData,
|
||||
countUsers,
|
||||
findUserByEmail,
|
||||
findUserById,
|
||||
createUser,
|
||||
updateUserProfile,
|
||||
listGames,
|
||||
findGameById,
|
||||
listGameItems,
|
||||
getGameDetail,
|
||||
createGame,
|
||||
updateGameThumbnail,
|
||||
createGameItem,
|
||||
createCustomItem,
|
||||
createGameSuggestion,
|
||||
listPublicTierLists,
|
||||
listUserTierLists,
|
||||
findTierListById,
|
||||
saveTierList,
|
||||
}
|
||||
13
backend/src/middleware/auth.js
Normal file
13
backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,13 @@
|
||||
function requireAuth(req, res, next) {
|
||||
if (!req.session || !req.session.userId) return res.status(401).json({ error: 'unauthorized' })
|
||||
next()
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (!req.session || !req.session.userId) return res.status(401).json({ error: 'unauthorized' })
|
||||
if (!req.session.isAdmin) return res.status(403).json({ error: 'forbidden' })
|
||||
next()
|
||||
}
|
||||
|
||||
module.exports = { requireAuth, requireAdmin }
|
||||
|
||||
64
backend/src/routes/admin.js
Normal file
64
backend/src/routes/admin.js
Normal file
@@ -0,0 +1,64 @@
|
||||
const path = require('path')
|
||||
const express = require('express')
|
||||
const multer = require('multer')
|
||||
const { z } = require('zod')
|
||||
const { nanoid } = require('nanoid')
|
||||
const {
|
||||
findGameById,
|
||||
createGame,
|
||||
updateGameThumbnail,
|
||||
createGameItem,
|
||||
} = require('../db')
|
||||
const { requireAdmin } = require('../middleware/auth')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
function buildUploadFilename(file) {
|
||||
const ext = path.extname(file.originalname || '').toLowerCase()
|
||||
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
|
||||
return `${Date.now()}-${nanoid()}${safeExt}`
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'games')),
|
||||
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
|
||||
}),
|
||||
limits: { fileSize: 6 * 1024 * 1024 },
|
||||
})
|
||||
|
||||
router.post('/games', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
const exists = await findGameById(parsed.data.id)
|
||||
if (exists) return res.status(409).json({ error: 'game_id_taken' })
|
||||
const game = await createGame({ id: parsed.data.id, name: parsed.data.name })
|
||||
res.json({ game })
|
||||
})
|
||||
|
||||
router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
const updated = await updateGameThumbnail(req.params.gameId, `/uploads/games/${req.file.filename}`)
|
||||
res.json({ game: updated })
|
||||
})
|
||||
|
||||
router.post('/games/:gameId/images', requireAdmin, upload.single('image'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
||||
const schema = z.object({ label: z.string().min(1).max(60) })
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
const item = await createGameItem({
|
||||
id: nanoid(),
|
||||
gameId: game.id,
|
||||
src: `/uploads/games/${req.file.filename}`,
|
||||
label: parsed.data.label,
|
||||
})
|
||||
res.json({ item })
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
114
backend/src/routes/auth.js
Normal file
114
backend/src/routes/auth.js
Normal file
@@ -0,0 +1,114 @@
|
||||
const express = require('express')
|
||||
const path = require('path')
|
||||
const bcrypt = require('bcryptjs')
|
||||
const { z } = require('zod')
|
||||
const { nanoid } = require('nanoid')
|
||||
const multer = require('multer')
|
||||
const {
|
||||
countUsers,
|
||||
findUserByEmail,
|
||||
findUserById,
|
||||
createUser,
|
||||
updateUserProfile,
|
||||
} = require('../db')
|
||||
const { requireAuth } = require('../middleware/auth')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
function buildUploadFilename(file) {
|
||||
const ext = path.extname(file.originalname || '').toLowerCase()
|
||||
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
|
||||
return `${Date.now()}-${nanoid()}${safeExt}`
|
||||
}
|
||||
|
||||
const signupSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
})
|
||||
|
||||
const profileSchema = z.object({
|
||||
nickname: z.string().trim().min(1).max(40),
|
||||
})
|
||||
|
||||
router.post('/signup', async (req, res) => {
|
||||
const parsed = signupSchema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const { email, password } = parsed.data
|
||||
const exists = await findUserByEmail(email)
|
||||
if (exists) return res.status(409).json({ error: 'email_taken' })
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10)
|
||||
const isAdmin = (await countUsers()) === 0
|
||||
const user = await createUser({ id: nanoid(), email, nickname: '', passwordHash, isAdmin })
|
||||
|
||||
req.session.userId = user.id
|
||||
req.session.isAdmin = !!user.isAdmin
|
||||
res.json(user)
|
||||
})
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
const parsed = signupSchema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const { email, password } = parsed.data
|
||||
const user = await findUserByEmail(email)
|
||||
if (!user) return res.status(401).json({ error: 'invalid_credentials' })
|
||||
|
||||
const ok = await bcrypt.compare(password, user.passwordHash)
|
||||
if (!ok) return res.status(401).json({ error: 'invalid_credentials' })
|
||||
|
||||
req.session.userId = user.id
|
||||
req.session.isAdmin = !!user.isAdmin
|
||||
res.json({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nickname: user.nickname || '',
|
||||
isAdmin: !!user.isAdmin,
|
||||
avatarSrc: user.avatarSrc || '',
|
||||
createdAt: user.createdAt,
|
||||
})
|
||||
})
|
||||
|
||||
router.post('/logout', async (req, res) => {
|
||||
if (!req.session) return res.json({ ok: true })
|
||||
req.session.destroy(() => res.json({ ok: true }))
|
||||
})
|
||||
|
||||
router.get('/me', async (req, res) => {
|
||||
if (!req.session || !req.session.userId) return res.json({ user: null })
|
||||
const user = await findUserById(req.session.userId)
|
||||
if (!user) return res.json({ user: null })
|
||||
res.json({ user })
|
||||
})
|
||||
|
||||
router.get('/meta', async (req, res) => {
|
||||
res.json({ hasUsers: (await countUsers()) > 0 })
|
||||
})
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'avatars')),
|
||||
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
|
||||
}),
|
||||
limits: { fileSize: 3 * 1024 * 1024 },
|
||||
})
|
||||
|
||||
router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) => {
|
||||
const parsed = profileSchema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const user = await findUserById(req.session.userId)
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const nextAvatarSrc = req.file ? `/uploads/avatars/${req.file.filename}` : user.avatarSrc || ''
|
||||
const updated = await updateUserProfile({
|
||||
id: user.id,
|
||||
nickname: parsed.data.nickname,
|
||||
avatarSrc: nextAvatarSrc,
|
||||
})
|
||||
|
||||
res.json({ user: updated })
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
31
backend/src/routes/games.js
Normal file
31
backend/src/routes/games.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const express = require('express')
|
||||
const { z } = require('zod')
|
||||
const { nanoid } = require('nanoid')
|
||||
const { listGames, getGameDetail, createGameSuggestion } = require('../db')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const games = await listGames()
|
||||
res.json({ games })
|
||||
})
|
||||
|
||||
router.get('/:gameId', async (req, res) => {
|
||||
const detail = await getGameDetail(req.params.gameId)
|
||||
if (!detail) return res.status(404).json({ error: 'not_found' })
|
||||
res.json({ game: detail.game, items: detail.items })
|
||||
})
|
||||
|
||||
router.post('/suggest', async (req, res) => {
|
||||
const schema = z.object({ name: z.string().min(1).max(60) })
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const suggestion = await createGameSuggestion({
|
||||
id: nanoid(),
|
||||
name: parsed.data.name,
|
||||
})
|
||||
res.json({ suggestion })
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
151
backend/src/routes/tierlists.js
Normal file
151
backend/src/routes/tierlists.js
Normal file
@@ -0,0 +1,151 @@
|
||||
const express = require('express')
|
||||
const path = require('path')
|
||||
const multer = require('multer')
|
||||
const { z } = require('zod')
|
||||
const { nanoid } = require('nanoid')
|
||||
const {
|
||||
findTierListById,
|
||||
listPublicTierLists,
|
||||
listUserTierLists,
|
||||
saveTierList,
|
||||
createCustomItem,
|
||||
} = require('../db')
|
||||
const { requireAuth } = require('../middleware/auth')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
function normalizePoolItem(item) {
|
||||
if (!item || item.origin !== 'game' || typeof item.src !== 'string') return item
|
||||
if (item.src.startsWith('/uploads/')) return item
|
||||
|
||||
try {
|
||||
const url = new URL(item.src)
|
||||
if (url.pathname.startsWith('/uploads/')) {
|
||||
return { ...item, src: url.pathname }
|
||||
}
|
||||
} catch (e) {
|
||||
return item
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
function normalizeTierList(tierList) {
|
||||
return {
|
||||
...tierList,
|
||||
pool: Array.isArray(tierList.pool) ? tierList.pool.map(normalizePoolItem) : [],
|
||||
}
|
||||
}
|
||||
|
||||
function buildUploadFilename(file) {
|
||||
const ext = path.extname(file.originalname || '').toLowerCase()
|
||||
const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : ''
|
||||
return `${Date.now()}-${nanoid()}${safeExt}`
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'custom')),
|
||||
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
|
||||
}),
|
||||
limits: { fileSize: 6 * 1024 * 1024 },
|
||||
})
|
||||
|
||||
const tierListUpsertSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
gameId: z.string().min(1),
|
||||
title: z.string().min(1).max(120),
|
||||
description: z.string().max(1000).optional().default(''),
|
||||
isPublic: z.boolean().default(false),
|
||||
groups: z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(16),
|
||||
itemIds: z.array(z.string()),
|
||||
})
|
||||
),
|
||||
pool: z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
src: z.string().min(1),
|
||||
label: z.string().min(1).max(60),
|
||||
origin: z.enum(['game', 'custom']).default('game'),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
router.get('/public', async (req, res) => {
|
||||
const gameId = req.query.gameId
|
||||
const lists = await listPublicTierLists(gameId)
|
||||
res.json({ tierLists: lists })
|
||||
})
|
||||
|
||||
router.get('/me', requireAuth, async (req, res) => {
|
||||
const lists = await listUserTierLists(req.session.userId)
|
||||
res.json({ tierLists: lists })
|
||||
})
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
const t = await findTierListById(req.params.id)
|
||||
if (!t) return res.status(404).json({ error: 'not_found' })
|
||||
if (!t.isPublic) {
|
||||
if (!req.session || req.session.userId !== t.authorId) return res.status(403).json({ error: 'forbidden' })
|
||||
}
|
||||
res.json({ tierList: normalizeTierList(t) })
|
||||
})
|
||||
|
||||
router.post('/custom-items', requireAuth, upload.single('image'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
||||
|
||||
const schema = z.object({ label: z.string().trim().min(1).max(60) })
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const item = await createCustomItem({
|
||||
id: nanoid(),
|
||||
ownerId: req.session.userId,
|
||||
src: `/uploads/custom/${req.file.filename}`,
|
||||
label: parsed.data.label,
|
||||
})
|
||||
|
||||
res.json({ item })
|
||||
})
|
||||
|
||||
router.post('/', requireAuth, async (req, res) => {
|
||||
const parsed = tierListUpsertSchema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
const payload = parsed.data
|
||||
const normalizedPool = payload.pool.map(normalizePoolItem)
|
||||
|
||||
let existing = null
|
||||
if (payload.id) existing = await findTierListById(payload.id)
|
||||
|
||||
if (existing) {
|
||||
if (existing.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
|
||||
const updated = await saveTierList({
|
||||
id: existing.id,
|
||||
authorId: existing.authorId,
|
||||
gameId: existing.gameId,
|
||||
title: payload.title,
|
||||
description: payload.description || '',
|
||||
isPublic: !!payload.isPublic,
|
||||
groups: payload.groups,
|
||||
pool: normalizedPool,
|
||||
})
|
||||
return res.json({ tierList: normalizeTierList(updated) })
|
||||
}
|
||||
|
||||
const created = await saveTierList({
|
||||
id: nanoid(),
|
||||
authorId: req.session.userId,
|
||||
gameId: payload.gameId,
|
||||
title: payload.title,
|
||||
description: payload.description || '',
|
||||
isPublic: !!payload.isPublic,
|
||||
groups: payload.groups,
|
||||
pool: normalizedPool,
|
||||
})
|
||||
res.json({ tierList: normalizeTierList(created) })
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
5
backend/uploads/seeds/README.md
Normal file
5
backend/uploads/seeds/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
이 폴더는 예시(시드) 이미지가 들어갈 위치입니다.
|
||||
|
||||
현재는 샘플 데이터가 `src/db.js`에 `example1.png`, `example2.png`를 참조하도록 되어 있습니다.
|
||||
원하면 실제 PNG 파일을 넣거나, 이후 프론트에서 관리자 업로드로 대체할 수 있습니다.
|
||||
|
||||
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11.4
|
||||
container_name: tier-cursor-mariadb
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: root1234
|
||||
MARIADB_DATABASE: tier_cursor
|
||||
MARIADB_USER: tier_cursor
|
||||
MARIADB_PASSWORD: tier_cursor1234
|
||||
ports:
|
||||
- '3307:3306'
|
||||
volumes:
|
||||
- mariadb_data:/var/lib/mysql
|
||||
command:
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
|
||||
phpmyadmin:
|
||||
image: phpmyadmin:5.2-apache
|
||||
container_name: tier-cursor-phpmyadmin
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- mariadb
|
||||
environment:
|
||||
PMA_HOST: mariadb
|
||||
PMA_PORT: 3306
|
||||
PMA_USER: tier_cursor
|
||||
PMA_PASSWORD: tier_cursor1234
|
||||
ports:
|
||||
- '8081:80'
|
||||
|
||||
volumes:
|
||||
mariadb_data:
|
||||
22
docs/convention.md
Normal file
22
docs/convention.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# 코딩 컨벤션
|
||||
|
||||
## 공통
|
||||
- 문서와 사용자 응답은 한국어로 작성한다.
|
||||
- JavaScript는 세미콜론 없이 현재 코드 스타일을 유지한다.
|
||||
- 새 주석이 필요할 경우 JSDoc 형식을 사용한다.
|
||||
- 경로, 주소, 운영 설정은 하드코딩보다 환경변수 기반 구성을 우선한다.
|
||||
- Git 커밋 메시지는 한국어로 작성한다.
|
||||
- 버전 릴리스가 포함된 작업은 `docs/update.md`의 버전 표기와 Git 태그를 함께 맞춘다.
|
||||
|
||||
## 프런트엔드
|
||||
- API 호출은 `frontend/src/lib/api.js` 또는 런타임 유틸을 통해 통합한다.
|
||||
- 정적 파일 URL 조합은 `toApiUrl()`로 처리한다.
|
||||
- 화면 상태는 `ref`, `computed`, `onMounted` 중심의 단순한 Composition API 패턴을 유지한다.
|
||||
|
||||
## 백엔드
|
||||
- 라우트 검증은 `zod`로 처리한다.
|
||||
- 파일 업로드명은 ASCII 안전 문자열을 사용한다.
|
||||
- 인증/권한 분기는 미들웨어(`requireAuth`, `requireAdmin`)로 분리한다.
|
||||
- DB 저장 전에는 배포 환경에 종속되는 값(예: 로컬 절대 URL)이 들어가지 않도록 정규화한다.
|
||||
- 사용자 프로필처럼 “선택 후 저장” 흐름이 필요한 기능은 파일 선택과 실제 저장 요청을 분리한다.
|
||||
- 관리자 지정 데이터와 사용자 커스텀 데이터는 테이블과 업로드 경로를 분리해서 관리한다.
|
||||
28
docs/history.md
Normal file
28
docs/history.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-03-19
|
||||
- 초기 저장소는 빠른 구현을 위해 `lowdb(JSON 파일)`를 채택했다.
|
||||
- 인증은 JWT 대신 서버 세션(`express-session` + `session-file-store`)을 사용했다.
|
||||
- 업로드 파일은 외부 스토리지 없이 로컬 디스크(`backend/uploads/`)에 저장하기로 했다.
|
||||
|
||||
## 2026-03-19 v0.1.3
|
||||
- 배포 환경 호환성을 위해 프런트엔드의 API 기준 주소를 환경변수(`VITE_API_ORIGIN`)로 통합했다.
|
||||
- NAS/리버스 프록시 환경을 고려해 CORS 및 세션 쿠키 옵션을 환경변수 기반으로 전환했다.
|
||||
- 파일명 깨짐과 URL 이식성 문제를 줄이기 위해 업로드 파일명을 ASCII 기반으로 생성하도록 변경했다.
|
||||
- 게임 이미지 경로는 저장 시 상대 경로(`/uploads/...`)를 유지하는 방향으로 정리했다.
|
||||
- 현재 단계에서는 구조 변경 비용을 고려해 DB를 유지하되, 운영/확장성 요구가 커지기 전 RDB 이관 판단이 필요하다고 기록했다.
|
||||
|
||||
## 2026-03-19 v0.1.4
|
||||
- 운영 편의성과 NAS 환경에서의 데이터 조회 필요성 때문에 저장소를 MariaDB(MySQL 호환) 기준으로 전환했다.
|
||||
- 관리자 지정 아이템과 사용자 커스텀 이미지는 책임과 수명 주기가 다르므로 별도 테이블(`game_items`, `custom_items`)로 분리했다.
|
||||
- 작성자 식별성을 위해 공개 티어표에 닉네임을 표시하고, 프로필에서 닉네임을 수정할 수 있게 했다.
|
||||
- 아바타 업로드는 즉시 반영보다 “선택 후 저장” 흐름이 맞다고 판단해 미리보기와 실제 저장을 분리했다.
|
||||
- 관리자 페이지는 게임 선택 후 상세 관리가 열리는 단계형 흐름으로 바꾸는 것이 실사용에 더 안전하다고 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.5
|
||||
- 로컬 개발과 운영 환경의 차이를 줄이기 위해 기본 로컬 개발 DB도 MariaDB로 고정했다.
|
||||
- 로컬 실행 편의를 위해 `docker-compose.yml`에 `mariadb`와 `phpMyAdmin` 서비스를 추가했다.
|
||||
- 백엔드 기본 `dev/start/migrate` 스크립트는 로컬 MariaDB 기준 값으로 정리하고, lowdb는 예외용 fallback 스크립트로만 남겼다.
|
||||
|
||||
## 2026-03-19 v0.1.6
|
||||
- 저장소 운영 규칙을 정리하면서 Git 작성자 정보는 프로젝트 기준 계정으로 통일하고, 커밋 메시지는 한국어로 남기기로 결정했다.
|
||||
46
docs/local-mariadb.md
Normal file
46
docs/local-mariadb.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 로컬 MariaDB 실행 가이드
|
||||
|
||||
## 목표
|
||||
- 로컬 개발 환경도 서버와 같은 `MariaDB(MySQL 호환)` 기반으로 맞춘다.
|
||||
- 데이터 확인은 `phpMyAdmin`으로 직접 한다.
|
||||
|
||||
## 1. DB 컨테이너 실행
|
||||
프로젝트 루트에서 아래 명령으로 실행한다.
|
||||
|
||||
```bash
|
||||
docker compose up -d mariadb phpmyadmin
|
||||
```
|
||||
|
||||
## 2. 접속 정보
|
||||
- MariaDB 호스트: `127.0.0.1`
|
||||
- MariaDB 포트: `3307`
|
||||
- DB 이름: `tier_cursor`
|
||||
- 계정: `tier_cursor`
|
||||
- 비밀번호: `tier_cursor1234`
|
||||
- phpMyAdmin: `http://localhost:8081`
|
||||
|
||||
## 3. 백엔드 실행
|
||||
아래 명령은 로컬 MariaDB 기준 환경변수를 포함한다.
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 4. 프런트엔드 실행
|
||||
```bash
|
||||
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다.
|
||||
- NAS 배포 시에도 동일하게 MariaDB를 사용하므로 로컬과 운영 간 DB 계층 차이를 줄일 수 있다.
|
||||
50
docs/map.md
Normal file
50
docs/map.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 파일-화면 매핑 가이드
|
||||
|
||||
## `/`
|
||||
- 화면 파일: `frontend/src/views/HomeView.vue`
|
||||
- 역할: 게임 목록 표시, 게임 카드 클릭 이동, 새 게임 제안 모달
|
||||
- 연동 API: `GET /api/games`, `POST /api/games/suggest`
|
||||
|
||||
## `/games/:gameId`
|
||||
- 화면 파일: `frontend/src/views/GameHubView.vue`
|
||||
- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 작성자 닉네임 노출, 새 티어표 작성 진입
|
||||
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`
|
||||
|
||||
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
|
||||
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
||||
- 역할: 티어 그룹 편집, 관리자 아이템/커스텀 아이템 드래그 앤 드롭, 저장, 공개 여부 설정, PNG 다운로드
|
||||
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`
|
||||
|
||||
## `/login`
|
||||
- 화면 파일: `frontend/src/views/LoginView.vue`
|
||||
- 역할: 로그인/회원가입 전환, 첫 가입 안내
|
||||
- 연동 API: `GET /api/auth/meta`, `POST /api/auth/login`, `POST /api/auth/signup`
|
||||
|
||||
## `/me`
|
||||
- 화면 파일: `frontend/src/views/MyTierListsView.vue`
|
||||
- 역할: 내 티어표 목록 조회, 편집 화면으로 이동
|
||||
- 연동 API: `GET /api/tierlists/me`
|
||||
|
||||
## `/admin`
|
||||
- 화면 파일: `frontend/src/views/AdminView.vue`
|
||||
- 역할: 게임 추가, 게임 선택 후 썸네일 업로드, 관리자 아이템 추가, 현재 아이템 목록 확인
|
||||
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`
|
||||
|
||||
## `/profile`
|
||||
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
||||
- 역할: 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장
|
||||
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`
|
||||
|
||||
## 공통 레이아웃
|
||||
- 앱 셸 파일: `frontend/src/App.vue`
|
||||
- 역할: 상단 내비게이션, 로그인 상태 반영, 아바타 메뉴, 관리자 메뉴 노출 제어
|
||||
|
||||
## 백엔드 진입점
|
||||
- 서버 엔트리: `backend/index.js`
|
||||
- 데이터 초기화: `backend/src/db.js`
|
||||
- 로컬 DB 실행 설정: `docker-compose.yml`
|
||||
- 로컬 MariaDB 가이드: `docs/local-mariadb.md`
|
||||
- 인증 라우트: `backend/src/routes/auth.js`
|
||||
- 게임 라우트: `backend/src/routes/games.js`
|
||||
- 티어표 라우트: `backend/src/routes/tierlists.js`
|
||||
- 관리자 라우트: `backend/src/routes/admin.js`
|
||||
113
docs/spec.md
Normal file
113
docs/spec.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# 기술 명세
|
||||
|
||||
## 현재 아키텍처
|
||||
- 프런트엔드: Vue 3 + Vite + Pinia + Vue Router
|
||||
- 백엔드: Express 5
|
||||
- 데이터 저장소: MariaDB(MySQL 호환)
|
||||
- 세션 저장소: `session-file-store` 기반 파일 세션
|
||||
- 업로드 저장소: 로컬 파일 시스템(`backend/uploads/`)
|
||||
|
||||
## 데이터 저장 구조
|
||||
- 메인 데이터베이스: MariaDB `tier_cursor` (기본값)
|
||||
- 세션 파일: `backend/.sessions/*.json`
|
||||
- 업로드 파일:
|
||||
- 게임 이미지: `backend/uploads/games/`
|
||||
- 아바타: `backend/uploads/avatars/`
|
||||
- 커스텀 아이템: `backend/uploads/custom/`
|
||||
- 시드 이미지: `backend/uploads/seeds/`
|
||||
- 레거시 마이그레이션 소스: `backend/data/db.json`
|
||||
|
||||
## DB 스키마
|
||||
- `users`
|
||||
- `id`: string
|
||||
- `email`: string
|
||||
- `nickname`: string
|
||||
- `passwordHash`: string
|
||||
- `isAdmin`: boolean
|
||||
- `avatarSrc`: string
|
||||
- `createdAt`: number
|
||||
- `games`
|
||||
- `id`: string
|
||||
- `name`: string
|
||||
- `thumbnailSrc`: string
|
||||
- `createdAt`: number
|
||||
- `gameItems`
|
||||
- `id`: string
|
||||
- `gameId`: string
|
||||
- `src`: string
|
||||
- `label`: string
|
||||
- `createdAt`: number
|
||||
- `customItems`
|
||||
- `id`: string
|
||||
- `ownerId`: string
|
||||
- `src`: string
|
||||
- `label`: string
|
||||
- `createdAt`: number
|
||||
- `tierLists`
|
||||
- `id`: string
|
||||
- `authorId`: string
|
||||
- `gameId`: string
|
||||
- `title`: string
|
||||
- `description`: string
|
||||
- `isPublic`: boolean
|
||||
- `groups`: `{ id, name, itemIds[] }[]`
|
||||
- `pool`: `{ id, src, label, origin }[]`
|
||||
- `createdAt`: number
|
||||
- `updatedAt`: number
|
||||
- `gameSuggestions`
|
||||
- `id`: string
|
||||
- `name`: string
|
||||
- `createdAt`: number
|
||||
|
||||
## 주요 API
|
||||
- 인증
|
||||
- `POST /api/auth/signup`
|
||||
- `POST /api/auth/login`
|
||||
- `POST /api/auth/logout`
|
||||
- `GET /api/auth/me`
|
||||
- `GET /api/auth/meta`
|
||||
- `POST /api/auth/profile`
|
||||
- 게임
|
||||
- `GET /api/games`
|
||||
- `GET /api/games/:gameId`
|
||||
- `POST /api/games/suggest`
|
||||
- 티어표
|
||||
- `GET /api/tierlists/public`
|
||||
- `GET /api/tierlists/me`
|
||||
- `GET /api/tierlists/:id`
|
||||
- `POST /api/tierlists/custom-items`
|
||||
- `POST /api/tierlists`
|
||||
- 관리자
|
||||
- `POST /api/admin/games`
|
||||
- `POST /api/admin/games/:gameId/thumbnail`
|
||||
- `POST /api/admin/games/:gameId/images`
|
||||
|
||||
## 운영 환경 변수
|
||||
- 프런트엔드
|
||||
- `VITE_API_ORIGIN`: API 및 업로드 파일 절대 기준 주소
|
||||
- 백엔드
|
||||
- `DB_CLIENT`: 기본 개발 기준은 `mariadb`
|
||||
- `DB_HOST`: MariaDB 호스트
|
||||
- `DB_PORT`: MariaDB 포트
|
||||
- `DB_USER`: MariaDB 계정
|
||||
- `DB_PASSWORD`: MariaDB 비밀번호
|
||||
- `DB_NAME`: 데이터베이스명
|
||||
- `PORT`: 서버 포트
|
||||
- `SESSION_SECRET`: 세션 서명 키
|
||||
- `CORS_ORIGINS`: 허용할 프런트 도메인 목록(쉼표 구분)
|
||||
- `TRUST_PROXY`: 프록시 홉 수
|
||||
- `SESSION_COOKIE_SECURE`: `true`면 HTTPS 전용 쿠키
|
||||
- `SESSION_COOKIE_SAME_SITE`: 기본 `lax`
|
||||
|
||||
## NAS 배포 메모
|
||||
- 현재 구조는 MariaDB/MySQL 계열이므로 NAS에 MariaDB를 올리면 phpMyAdmin 또는 Adminer로 직접 데이터 확인이 가능하다.
|
||||
- 추천 구성:
|
||||
- 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`를 사용한다.
|
||||
18
docs/todo.md
Normal file
18
docs/todo.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 즉시 확인 필요
|
||||
- 관리자 화면에 게임 제안(`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` 값을 설정한다.
|
||||
- MariaDB 접속 정보 `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`를 설정한다.
|
||||
- HTTPS를 사용할 경우 `SESSION_COOKIE_SECURE=true`로 설정하고 리버스 프록시 헤더 전달을 확인한다.
|
||||
- `backend/uploads/`, `backend/.sessions/`, MariaDB 백업 정책을 정한다.
|
||||
- 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다.
|
||||
|
||||
## 중기 개선
|
||||
- 게임/이미지/티어표 삭제 및 수정 이력 관리 기능을 추가한다.
|
||||
- 자동 테스트와 최소한의 배포 체크리스트를 만든다.
|
||||
- 관리자용 게임 제안 승인/반려, 아이템 삭제/정렬 UI를 추가한다.
|
||||
52
docs/update.md
Normal file
52
docs/update.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-03-19 v0.1.0
|
||||
- **초기 스캐폴딩**: `frontend/`에 Vue3(Vite, JavaScript) 프로젝트 생성
|
||||
- **라우팅/화면 골격**: 게임 선택(`/`), 게임 허브(`/games/:gameId`), 에디터(`/editor/:gameId/...`), 로그인(`/login`), 내 티어표(`/me`), 관리자(`/admin`) 라우트 추가
|
||||
- **레이아웃**: 상단 네비게이션 + 기본 다크 UI 스타일 적용
|
||||
- **백엔드 골격**: `backend/`에 Express 서버 추가(세션 기반 인증, 게임/티어표 API, 업로드 경로)
|
||||
- **저장소**: 네이티브 DB 대신 파일 DB(`lowdb`)로 유저/게임/이미지/티어표 데이터 영속화
|
||||
- **에디터 핵심 기능(1차)**: 그룹 이름/순서 편집, 아이템 드래그&드롭(풀↔그룹), 커스텀 이미지 추가, 보드 PNG 다운로드, 백엔드 저장/불러오기 연결
|
||||
- **관리자 UI(1차)**: admin 전용 게임 추가 + 게임별 기본 이미지 업로드/목록 화면 추가
|
||||
- **버그 수정**: Vite 포트 변경(5174 등) 시 백엔드 CORS가 막히는 문제 해결(로컬 5170~5179 허용)
|
||||
- **네비/권한 UX**: 관리자 메뉴는 admin 로그인 시에만 노출, 로그인 대신 아바타 버튼/메뉴 노출
|
||||
- **프로필**: `/profile` 페이지 추가, 아바타 업로드 API(`/api/auth/avatar`) 및 표시 지원
|
||||
- **에디터 버그 수정**: 드래그 시 아이템들이 “묶음”으로 같이 움직이던 문제 해결(드롭 영역 DOM 구조/Sortable 옵션 수정), 드롭 영역 overflow/배치 레이아웃 개선
|
||||
|
||||
## 2026-03-19 v0.1.1
|
||||
- **티어표 메타데이터 개선**: 제목 미입력 시 저장 시점에 게임 이름 기반 자동 제목 적용, 설명(선택) 필드 추가
|
||||
- **시간 정보 표시**: 내 티어표/공개 목록에서 저장 시간(createdAt)과 업데이트 시간(updatedAt)을 시:분:초까지 표시
|
||||
- **에디터 UX 수정**: 빈 티어 칸 안내 문구가 첫 드래그 배치를 가리던 문제 수정(오버레이 처리), 제목 상단에 게임 이름 표시
|
||||
|
||||
## 2026-03-19 v0.1.2
|
||||
- **로그인 UI 개선**: 로그인 카드 중앙 배치, 중복 타이틀 제거, 입력 overflow 수정, 엔터로 로그인/회원가입 제출
|
||||
- **안내문 조건화**: “첫 회원가입 계정은 admin” 문구는 유저가 0명일 때만 표시(`/api/auth/meta`)
|
||||
- **게임 목록 UI 개선**: 게임 카드에 썸네일 표시, 중복 텍스트 제거, “새로운 게임 제안” 모달 추가
|
||||
- **관리자 기능 추가**: 게임 썸네일 업로드 API(`/api/admin/games/:gameId/thumbnail`) 및 UI 추가
|
||||
- **에디터 레이아웃 개선**: 등급(그룹) 라벨 칼럼 확장으로 텍스트 잘림 방지, 설명 입력 1줄, 정렬을 좌측 기준으로 조정
|
||||
|
||||
## 2026-03-19 v0.1.3
|
||||
- **배포 설정 개선**: 프런트엔드의 API/정적 파일 주소 하드코딩(`http://localhost:5179`)을 `VITE_API_ORIGIN` 기반으로 통합
|
||||
- **백엔드 운영 설정 추가**: `CORS_ORIGINS`, `TRUST_PROXY`, `SESSION_COOKIE_SECURE`, `SESSION_COOKIE_SAME_SITE`, `SESSION_SECRET` 환경변수 기반으로 NAS/리버스 프록시 배포 대응
|
||||
- **업로드 파일명 안정화**: 한글 원본 파일명 기반 저장을 제거하고 ASCII 안전 파일명으로 저장하도록 변경
|
||||
- **티어표 데이터 정규화**: 게임 이미지 경로가 절대 로컬 URL로 저장되지 않도록 저장/조회 시 `/uploads/...` 상대 경로로 정규화
|
||||
- **프로젝트 점검 결과 문서화**: DB 구조, 화면-파일 매핑, 코딩 규칙, 기술 명세, 남은 위험 요소를 `docs/`에 신규 정리
|
||||
|
||||
## 2026-03-19 v0.1.4
|
||||
- **DB 마이그레이션 준비**: 런타임 저장소를 `MariaDB(MySQL 호환)` 기준으로 재구성하고 `backend/scripts/migrate-lowdb-to-mariadb.js` 마이그레이션 스크립트 추가
|
||||
- **데이터 구조 분리**: 관리자 지정 아이템은 `game_items`, 유저 커스텀 이미지는 `custom_items`로 분리
|
||||
- **프로필 개선**: 작성자 닉네임 저장 지원, 아바타는 파일 선택 시 미리보기만 변경되고 저장 버튼 클릭 시 실제 반영되도록 수정
|
||||
- **공개 티어표 목록 개선**: 공개 티어표 목록에 작성자 닉네임(없으면 이메일) 표시
|
||||
- **관리자 UI 개편**: 게임 선택 전에는 우측 관리 패널을 숨기고, 선택 후에만 썸네일/아이템 관리가 보이도록 단계형 흐름으로 수정
|
||||
- **관리자 레이아웃 수정**: 새 게임 입력 필드와 카드 셀 overflow 문제를 줄이도록 `box-sizing`, 썸네일/아이템 카드 레이아웃 정리
|
||||
- **커스텀 아이템 저장 흐름 수정**: 에디터의 커스텀 이미지는 저장 시 서버 업로드 후 티어표에 반영되도록 변경
|
||||
|
||||
## 2026-03-19 v0.1.5
|
||||
- **로컬 개발 환경 정렬**: 기본 백엔드 실행 기준을 lowdb가 아닌 로컬 MariaDB로 전환
|
||||
- **개발용 인프라 추가**: 루트 `docker-compose.yml`에 `MariaDB + phpMyAdmin` 추가
|
||||
- **실행 문서 정리**: `README.md`, `docs/local-mariadb.md`, `docs/spec.md`에 로컬 MariaDB 실행 절차 반영
|
||||
- **Fallback 분리**: `backend/package.json`에 `dev:lowdb`, `start:lowdb` 예외 스크립트 추가
|
||||
|
||||
## 2026-03-19 v0.1.6
|
||||
- **저장소 메타데이터 정리**: Git 작성자 정보를 프로젝트 계정 기준으로 통일하고, 초기 릴리스 커밋 메시지를 한국어 기준으로 재작성
|
||||
- **버전 관리 규칙 보강**: 커밋 메시지 한국어 작성 및 문서 버전과 Git 태그를 함께 맞추는 규칙을 문서에 반영
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
5
frontend/README.md
Normal file
5
frontend/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1281
frontend/package-lock.json
generated
Normal file
1281
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"html-to-image": "^1.11.13",
|
||||
"pinia": "^3.0.4",
|
||||
"sortablejs": "^1.15.7",
|
||||
"vue": "^3.5.30",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
201
frontend/src/App.vue
Normal file
201
frontend/src/App.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { toApiUrl } from './lib/runtime'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
||||
const avatarUrl = computed(() => {
|
||||
if (!auth.user?.avatarSrc) return ''
|
||||
return toApiUrl(auth.user.avatarSrc)
|
||||
})
|
||||
|
||||
const menuOpen = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.refresh()
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
menuOpen.value = false
|
||||
}
|
||||
)
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen.value = !menuOpen.value
|
||||
}
|
||||
|
||||
function onDocumentClick(event) {
|
||||
if (!event.target.closest('.user')) {
|
||||
menuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goProfile() {
|
||||
menuOpen.value = false
|
||||
router.push('/profile')
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
menuOpen.value = false
|
||||
await auth.logout()
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<header class="app-header">
|
||||
<div class="brand" @click="$router.push('/')">
|
||||
<span class="brand__title">Tier Maker</span>
|
||||
<span class="brand__sub">Vue</span>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<RouterLink to="/" class="nav__link">게임</RouterLink>
|
||||
<RouterLink to="/me" class="nav__link">내 티어표</RouterLink>
|
||||
<RouterLink v-if="isAdmin" to="/admin" class="nav__link">관리자</RouterLink>
|
||||
|
||||
<RouterLink v-if="!auth.user" to="/login" class="nav__link">로그인</RouterLink>
|
||||
<div v-else class="user">
|
||||
<button class="avatarBtn" @click.stop="toggleMenu" :title="auth.user.email">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="avatarImg" alt="avatar" />
|
||||
<div v-else class="avatarFallback">{{ (auth.user.email || 'U')[0].toUpperCase() }}</div>
|
||||
</button>
|
||||
<div v-if="menuOpen" class="menu">
|
||||
<button class="menuItem" @click="goProfile">프로필</button>
|
||||
<button class="menuItem" @click="logout">로그아웃</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="app-main">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(1200px 800px at 20% 10%, rgba(110, 231, 183, 0.18), transparent 55%),
|
||||
radial-gradient(1000px 700px at 80% 20%, rgba(96, 165, 250, 0.18), transparent 55%),
|
||||
#0b1220;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(11, 18, 32, 0.72);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.brand__title {
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.brand__sub {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 999px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.nav__link {
|
||||
text-decoration: none;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.nav__link.router-link-active {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.app-main {
|
||||
padding: 20px 18px 60px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.user {
|
||||
position: relative;
|
||||
}
|
||||
.avatarBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.avatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.avatarFallback {
|
||||
font-weight: 900;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
width: 160px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(11, 18, 32, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 6px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.menuItem {
|
||||
text-align: left;
|
||||
padding: 10px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
.menuItem:hover {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
</style>
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
6
frontend/src/components/HelloWorld.vue
Normal file
6
frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
42
frontend/src/lib/api.js
Normal file
42
frontend/src/lib/api.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { toApiUrl } from './runtime'
|
||||
|
||||
async function request(path, { method = 'GET', body, headers } = {}) {
|
||||
const res = await fetch(toApiUrl(path), {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(headers || {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
const contentType = res.headers.get('content-type') || ''
|
||||
const data = contentType.includes('application/json') ? await res.json() : await res.text()
|
||||
|
||||
if (!res.ok) {
|
||||
const err = new Error('request_failed')
|
||||
err.status = res.status
|
||||
err.data = data
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => request('/api/auth/me'),
|
||||
authMeta: () => request('/api/auth/meta'),
|
||||
signup: ({ email, password }) => request('/api/auth/signup', { method: 'POST', body: { email, password } }),
|
||||
login: ({ email, password }) => request('/api/auth/login', { method: 'POST', body: { email, password } }),
|
||||
logout: () => request('/api/auth/logout', { method: 'POST' }),
|
||||
|
||||
listGames: () => request('/api/games'),
|
||||
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
|
||||
suggestGame: (name) => request('/api/games/suggest', { method: 'POST', body: { name } }),
|
||||
|
||||
listPublicTierLists: (gameId) =>
|
||||
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),
|
||||
listMyTierLists: () => request('/api/tierlists/me'),
|
||||
getTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`),
|
||||
saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }),
|
||||
}
|
||||
9
frontend/src/lib/runtime.js
Normal file
9
frontend/src/lib/runtime.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const FALLBACK_API_ORIGIN = 'http://localhost:5179'
|
||||
|
||||
export const API_ORIGIN = (import.meta.env.VITE_API_ORIGIN || FALLBACK_API_ORIGIN).replace(/\/+$/, '')
|
||||
|
||||
export function toApiUrl(path = '') {
|
||||
if (!path) return API_ORIGIN
|
||||
if (/^https?:\/\//.test(path)) return path
|
||||
return `${API_ORIGIN}${path.startsWith('/') ? path : `/${path}`}`
|
||||
}
|
||||
10
frontend/src/main.js
Normal file
10
frontend/src/main.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createRouter } from './router'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(createRouter())
|
||||
app.mount('#app')
|
||||
26
frontend/src/router/index.js
Normal file
26
frontend/src/router/index.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createRouter as _createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import GameHubView from '../views/GameHubView.vue'
|
||||
import TierEditorView from '../views/TierEditorView.vue'
|
||||
import LoginView from '../views/LoginView.vue'
|
||||
import MyTierListsView from '../views/MyTierListsView.vue'
|
||||
import AdminView from '../views/AdminView.vue'
|
||||
import ProfileView from '../views/ProfileView.vue'
|
||||
|
||||
export function createRouter() {
|
||||
return _createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: HomeView },
|
||||
{ path: '/games/:gameId', name: 'gameHub', component: GameHubView },
|
||||
{ path: '/editor/:gameId/new', name: 'newEditor', component: TierEditorView },
|
||||
{ path: '/editor/:gameId/:tierListId', name: 'editEditor', component: TierEditorView },
|
||||
{ path: '/login', name: 'login', component: LoginView },
|
||||
{ path: '/me', name: 'me', component: MyTierListsView },
|
||||
{ path: '/admin', name: 'admin', component: AdminView },
|
||||
{ path: '/profile', name: 'profile', component: ProfileView },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
35
frontend/src/stores/auth.js
Normal file
35
frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
user: null,
|
||||
status: 'idle',
|
||||
}),
|
||||
actions: {
|
||||
async refresh() {
|
||||
this.status = 'loading'
|
||||
try {
|
||||
const data = await api.me()
|
||||
this.user = data.user
|
||||
} finally {
|
||||
this.status = 'idle'
|
||||
}
|
||||
},
|
||||
async signup(email, password) {
|
||||
const user = await api.signup({ email, password })
|
||||
this.user = user
|
||||
return user
|
||||
},
|
||||
async login(email, password) {
|
||||
const user = await api.login({ email, password })
|
||||
this.user = user
|
||||
return user
|
||||
},
|
||||
async logout() {
|
||||
await api.logout()
|
||||
this.user = null
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
296
frontend/src/style.css
Normal file
296
frontend/src/style.css
Normal file
@@ -0,0 +1,296 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
348
frontend/src/views/AdminView.vue
Normal file
348
frontend/src/views/AdminView.vue
Normal file
@@ -0,0 +1,348 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
||||
|
||||
const games = ref([])
|
||||
const selectedGameId = ref('')
|
||||
const selectedGame = ref(null)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
const newGameId = ref('')
|
||||
const newGameName = ref('')
|
||||
|
||||
const uploadLabel = ref('')
|
||||
const uploadFile = ref(null)
|
||||
const thumbFile = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.refresh()
|
||||
await refreshGames()
|
||||
})
|
||||
|
||||
async function refreshGames() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
const data = await api.listGames()
|
||||
games.value = data.games || []
|
||||
} catch (e) {
|
||||
error.value = '게임 목록을 불러오지 못했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGame() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
if (!selectedGameId.value) {
|
||||
selectedGame.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await api.getGame(selectedGameId.value)
|
||||
selectedGame.value = data
|
||||
} catch (e) {
|
||||
error.value = '게임 정보를 불러오지 못했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
async function createGame() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
const res = await fetch(toApiUrl('/api/admin/games'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: newGameId.value, name: newGameName.value }),
|
||||
})
|
||||
if (!res.ok) throw new Error('failed')
|
||||
newGameId.value = ''
|
||||
newGameName.value = ''
|
||||
await refreshGames()
|
||||
selectedGameId.value = ''
|
||||
selectedGame.value = null
|
||||
success.value = '게임이 생성됐어요. 목록에서 선택한 뒤 썸네일과 아이템을 등록해주세요.'
|
||||
} catch (e) {
|
||||
error.value = '게임 생성 실패(관리자 권한/중복 ID 확인)'
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadItem() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
if (!uploadFile.value) {
|
||||
error.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) {
|
||||
uploadFile.value = e.target.files && e.target.files[0] ? e.target.files[0] : null
|
||||
}
|
||||
|
||||
function onThumb(e) {
|
||||
thumbFile.value = e.target.files && e.target.files[0] ? e.target.files[0] : null
|
||||
}
|
||||
|
||||
async function uploadThumbnail() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
if (!thumbFile.value) {
|
||||
error.value = '썸네일 파일을 선택해주세요.'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('thumbnail', thumbFile.value)
|
||||
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedGameId.value)}/thumbnail`), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
})
|
||||
if (!res.ok) throw new Error('failed')
|
||||
thumbFile.value = null
|
||||
await refreshGames()
|
||||
await loadGame()
|
||||
success.value = '썸네일이 반영됐어요.'
|
||||
} catch (e) {
|
||||
error.value = '썸네일 업로드 실패(관리자 권한/파일 크기 확인)'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="wrap">
|
||||
<h2 class="title">관리자</h2>
|
||||
<div class="card">
|
||||
<div class="desc">게임 생성 후 선택한 게임에만 썸네일과 관리자 아이템을 등록할 수 있습니다.</div>
|
||||
<div v-if="!auth.user" class="warn">로그인이 필요해요.</div>
|
||||
<div v-else-if="!isAdmin" class="warn">이 계정은 관리자 권한이 없어요.</div>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-if="success" class="success">{{ success }}</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel__title">게임 선택</div>
|
||||
<select v-model="selectedGameId" class="select" :disabled="!isAdmin" @change="loadGame">
|
||||
<option value="">게임을 선택해주세요</option>
|
||||
<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 v-if="selectedGame" class="panel">
|
||||
<div class="panel__title">선택된 게임</div>
|
||||
<div class="selectedGame">
|
||||
<div>
|
||||
<div class="selectedGame__name">{{ selectedGame.game.name }}</div>
|
||||
<div class="selectedGame__id">{{ selectedGame.game.id }}</div>
|
||||
</div>
|
||||
<img
|
||||
v-if="selectedGame.game.thumbnailSrc"
|
||||
class="selectedThumb"
|
||||
:src="toApiUrl(selectedGame.game.thumbnailSrc)"
|
||||
:alt="selectedGame.game.name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="panel__title" style="margin-top: 14px">게임 썸네일 업로드</div>
|
||||
<div class="hint">홈 화면의 게임 카드에 바로 반영되는 대표 이미지입니다.</div>
|
||||
<input type="file" accept="image/*" class="inputFile" :disabled="!isAdmin" @change="onThumb" />
|
||||
<button class="btn" :disabled="!isAdmin" @click="uploadThumbnail">썸네일 업로드</button>
|
||||
|
||||
<div class="panel__title" style="margin-top: 14px">아이템 추가</div>
|
||||
<div class="hint">관리자가 지정한 아이템만 이 목록에 포함됩니다.</div>
|
||||
<input v-model="uploadLabel" class="input" placeholder="아이템 이름" :disabled="!isAdmin" />
|
||||
<input type="file" accept="image/*" class="inputFile" :disabled="!isAdmin" @change="onFile" />
|
||||
<button class="btn" :disabled="!isAdmin" @click="uploadItem">아이템 추가</button>
|
||||
|
||||
<div class="panel__title" style="margin-top: 14px">현재 아이템 목록</div>
|
||||
<div v-if="!selectedGame?.items?.length" class="hint">아직 등록된 아이템이 없어요.</div>
|
||||
<div v-else class="thumbGrid">
|
||||
<div v-for="img in selectedGame.items" :key="img.id" class="thumbCard">
|
||||
<img class="thumb" :src="toApiUrl(img.src)" :alt="img.label" />
|
||||
<div class="thumbLabel">{{ img.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
padding: 10px 2px;
|
||||
}
|
||||
.title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
}
|
||||
.desc {
|
||||
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);
|
||||
}
|
||||
.success {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
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;
|
||||
}
|
||||
.panel {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
}
|
||||
.panel__title {
|
||||
font-weight: 900;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.hint {
|
||||
opacity: 0.78;
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.select,
|
||||
.input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
outline: none;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.inputFile {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.btn {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
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;
|
||||
}
|
||||
.btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.thumbGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.selectedGame {
|
||||
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);
|
||||
}
|
||||
.selectedGame__name {
|
||||
font-weight: 900;
|
||||
}
|
||||
.selectedGame__id {
|
||||
margin-top: 6px;
|
||||
opacity: 0.72;
|
||||
word-break: break-all;
|
||||
}
|
||||
.selectedThumb {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
object-fit: cover;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
flex: none;
|
||||
}
|
||||
.thumbCard {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
padding: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.thumb {
|
||||
width: 100%;
|
||||
height: 92px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.thumbLabel {
|
||||
margin-top: 8px;
|
||||
font-weight: 900;
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
word-break: break-word;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
150
frontend/src/views/GameHubView.vue
Normal file
150
frontend/src/views/GameHubView.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const gameId = computed(() => route.params.gameId)
|
||||
|
||||
const gameName = ref('')
|
||||
const tierLists = ref([])
|
||||
const error = ref('')
|
||||
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [gameRes, listRes] = await Promise.all([api.getGame(gameId.value), api.listPublicTierLists(gameId.value)])
|
||||
gameName.value = gameRes.game?.name || gameId.value
|
||||
tierLists.value = listRes.tierLists || []
|
||||
} catch (e) {
|
||||
error.value = '게임 정보를 불러오지 못했어요.'
|
||||
}
|
||||
})
|
||||
|
||||
function createNew() {
|
||||
router.push(`/editor/${gameId.value}/new`)
|
||||
}
|
||||
|
||||
function openTierList(id) {
|
||||
router.push(`/editor/${gameId.value}/${id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="head">
|
||||
<div class="head__left">
|
||||
<div class="kicker">게임</div>
|
||||
<h2 class="title">{{ gameName || gameId }}</h2>
|
||||
<p class="desc">새 티어표를 만들거나, 다른 사람들이 올린 티어표를 확인하세요.</p>
|
||||
</div>
|
||||
<div class="head__right">
|
||||
<button class="primary" @click="createNew">새로운 티어표 만들기</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<section class="panel">
|
||||
<div class="panel__title">공개 티어표</div>
|
||||
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<button v-for="t in tierLists" :key="t.id" class="row" @click="openTierList(t.id)">
|
||||
<div class="row__title">{{ t.title }}</div>
|
||||
<div class="row__meta">
|
||||
작성자: {{ t.authorName || '알 수 없음' }} · 저장: {{ fmt(t.createdAt || t.updatedAt) }} · 업데이트: {{ fmt(t.updatedAt) }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 2px 14px;
|
||||
}
|
||||
.kicker {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.title {
|
||||
margin: 4px 0 6px;
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.desc {
|
||||
margin: 0;
|
||||
opacity: 0.84;
|
||||
}
|
||||
.primary {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
.primary:hover {
|
||||
background: rgba(96, 165, 250, 0.26);
|
||||
}
|
||||
.panel {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
}
|
||||
.error {
|
||||
margin: 10px 0 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
.panel__title {
|
||||
font-weight: 800;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.empty {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.row {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
}
|
||||
.row:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.row__title {
|
||||
font-weight: 800;
|
||||
}
|
||||
.row__meta {
|
||||
opacity: 0.78;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
229
frontend/src/views/HomeView.vue
Normal file
229
frontend/src/views/HomeView.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const items = ref([])
|
||||
const error = ref('')
|
||||
const games = computed(() => items.value)
|
||||
const suggestOpen = ref(false)
|
||||
const suggestName = ref('')
|
||||
const suggestError = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await api.listGames()
|
||||
items.value = data.games || []
|
||||
} catch (e) {
|
||||
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
|
||||
}
|
||||
})
|
||||
|
||||
function goGame(gameId) {
|
||||
router.push(`/games/${gameId}`)
|
||||
}
|
||||
|
||||
function thumbUrl(g) {
|
||||
if (!g.thumbnailSrc) return ''
|
||||
return toApiUrl(g.thumbnailSrc)
|
||||
}
|
||||
|
||||
async function submitSuggest() {
|
||||
suggestError.value = ''
|
||||
const name = (suggestName.value || '').trim()
|
||||
if (!name) {
|
||||
suggestError.value = '게임 이름을 입력해주세요.'
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.suggestGame(name)
|
||||
suggestName.value = ''
|
||||
suggestOpen.value = false
|
||||
} catch (e) {
|
||||
suggestError.value = '제안 전송에 실패했어요.'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="hero">
|
||||
<h1 class="hero__title">티어표 메이커</h1>
|
||||
<p class="hero__desc">
|
||||
게임을 선택하면 새 티어표를 만들거나, 다른 사람들이 올린 티어표를 볼 수 있어요.
|
||||
</p>
|
||||
<div class="hero__actions">
|
||||
<button class="smallBtn" @click="suggestOpen = true">새로운 게임 제안</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<section class="grid">
|
||||
<button v-for="g in games" :key="g.id" class="card" @click="goGame(g.id)">
|
||||
<div class="thumbWrap">
|
||||
<img v-if="thumbUrl(g)" class="thumb" :src="thumbUrl(g)" :alt="g.name" />
|
||||
<div v-else class="thumbFallback">{{ g.name[0] }}</div>
|
||||
</div>
|
||||
<div class="card__title">{{ g.name }}</div>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div v-if="suggestOpen" class="modalBack" @click.self="suggestOpen = false">
|
||||
<div class="modal">
|
||||
<div class="modal__title">새로운 게임 제안</div>
|
||||
<div class="modal__desc">목록에 없는 게임을 입력해 주세요. (관리자가 확인 후 추가)</div>
|
||||
<input v-model="suggestName" class="modal__input" placeholder="게임 이름" @keydown.enter.prevent="submitSuggest" />
|
||||
<div v-if="suggestError" class="modal__error">{{ suggestError }}</div>
|
||||
<div class="modal__actions">
|
||||
<button class="smallBtn" @click="suggestOpen = false">닫기</button>
|
||||
<button class="smallBtn smallBtn--primary" @click="submitSuggest">제안하기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero {
|
||||
padding: 18px 2px 14px;
|
||||
}
|
||||
.hero__title {
|
||||
font-size: 34px;
|
||||
letter-spacing: -0.03em;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.hero__desc {
|
||||
margin: 0;
|
||||
opacity: 0.86;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.hero__actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.smallBtn {
|
||||
padding: 8px 10px;
|
||||
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;
|
||||
}
|
||||
.smallBtn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.smallBtn--primary {
|
||||
background: rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
.smallBtn--primary:hover {
|
||||
background: rgba(96, 165, 250, 0.24);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.error {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.card {
|
||||
text-align: left;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.card:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
.thumbWrap {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.thumbFallback {
|
||||
font-weight: 900;
|
||||
font-size: 28px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.card__title {
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.modalBack {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 50;
|
||||
}
|
||||
.modal {
|
||||
width: min(520px, 92vw);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(11, 18, 32, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 14px;
|
||||
}
|
||||
.modal__title {
|
||||
font-weight: 900;
|
||||
font-size: 18px;
|
||||
}
|
||||
.modal__desc {
|
||||
margin-top: 6px;
|
||||
opacity: 0.78;
|
||||
font-size: 13px;
|
||||
}
|
||||
.modal__input {
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal__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);
|
||||
}
|
||||
.modal__actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
149
frontend/src/views/LoginView.vue
Normal file
149
frontend/src/views/LoginView.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const mode = ref('login')
|
||||
const error = ref('')
|
||||
const hasUsers = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const meta = await api.authMeta()
|
||||
hasUsers.value = !!meta.hasUsers
|
||||
} catch (e) {
|
||||
hasUsers.value = true
|
||||
}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
try {
|
||||
if (mode.value === 'signup') await auth.signup(email.value, password.value)
|
||||
else await auth.login(email.value, password.value)
|
||||
router.push('/me')
|
||||
} catch (e) {
|
||||
error.value = '로그인/회원가입에 실패했어요.'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="wrap">
|
||||
<form class="card" @submit.prevent="submit">
|
||||
<div class="tabs">
|
||||
<button type="button" class="tab" :class="{ 'tab--active': mode === 'login' }" @click="mode = 'login'">
|
||||
로그인
|
||||
</button>
|
||||
<button type="button" class="tab" :class="{ 'tab--active': mode === 'signup' }" @click="mode = 'signup'">
|
||||
회원가입
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
|
||||
<label class="label">이메일</label>
|
||||
<input v-model="email" class="input" placeholder="you@example.com" autocomplete="email" />
|
||||
|
||||
<label class="label">비밀번호</label>
|
||||
<input
|
||||
v-model="password"
|
||||
class="input"
|
||||
type="password"
|
||||
placeholder="********"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
|
||||
<button class="btn" type="submit">{{ mode === 'signup' ? '회원가입' : '로그인' }}</button>
|
||||
|
||||
<div v-if="!hasUsers" class="hint">첫 회원가입 계정은 자동으로 admin 권한이 부여됩니다(개발용).</div>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
min-height: calc(100vh - 74px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 14px 2px;
|
||||
}
|
||||
.card {
|
||||
max-width: 420px;
|
||||
width: min(420px, 92vw);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tab {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tab--active {
|
||||
background: rgba(96, 165, 250, 0.18);
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
.error {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
opacity: 0.78;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.btn {
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
.btn:hover {
|
||||
background: rgba(96, 165, 250, 0.26);
|
||||
}
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
opacity: 0.72;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
116
frontend/src/views/MyTierListsView.vue
Normal file
116
frontend/src/views/MyTierListsView.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
const router = useRouter()
|
||||
const myLists = ref([])
|
||||
const error = ref('')
|
||||
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await api.listMyTierLists()
|
||||
myLists.value = data.tierLists || []
|
||||
} catch (e) {
|
||||
error.value = '로그인이 필요해요.'
|
||||
}
|
||||
})
|
||||
|
||||
function openList(t) {
|
||||
router.push(`/editor/${t.gameId}/${t.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="wrap">
|
||||
<h2 class="title">내 티어표</h2>
|
||||
<div class="card">
|
||||
<div v-if="error" class="error">
|
||||
{{ error }}
|
||||
<button class="link" @click="$router.push('/login')">로그인 하러가기</button>
|
||||
</div>
|
||||
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<button v-for="t in myLists" :key="t.id" class="row" @click="openList(t)">
|
||||
<div class="row__title">{{ t.title }}</div>
|
||||
<div class="row__meta">
|
||||
{{ t.gameId }} · 저장: {{ fmt(t.createdAt || t.updatedAt) }} · 업데이트: {{ fmt(t.updatedAt) }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
padding: 10px 2px;
|
||||
}
|
||||
.title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
}
|
||||
.error {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.link {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
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;
|
||||
}
|
||||
.empty {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.row {
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.row__title {
|
||||
font-weight: 900;
|
||||
}
|
||||
.row__meta {
|
||||
margin-top: 6px;
|
||||
opacity: 0.76;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
199
frontend/src/views/ProfileView.vue
Normal file
199
frontend/src/views/ProfileView.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const error = ref('')
|
||||
const saving = ref(false)
|
||||
const nickname = ref('')
|
||||
const previewUrl = ref('')
|
||||
const avatarFile = ref(null)
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
if (previewUrl.value) return previewUrl.value
|
||||
if (!auth.user?.avatarSrc) return ''
|
||||
return toApiUrl(auth.user.avatarSrc)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.refresh()
|
||||
if (!auth.user) router.push('/login')
|
||||
nickname.value = auth.user?.nickname || ''
|
||||
})
|
||||
|
||||
function onAvatarChange(e) {
|
||||
const file = e.target.files && e.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
error.value = ''
|
||||
avatarFile.value = file
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = URL.createObjectURL(file)
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('nickname', nickname.value)
|
||||
if (avatarFile.value) fd.append('avatar', avatarFile.value)
|
||||
const res = await fetch(toApiUrl('/api/auth/profile'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
})
|
||||
if (!res.ok) throw new Error('upload_failed')
|
||||
const data = await res.json()
|
||||
auth.user = data.user
|
||||
avatarFile.value = null
|
||||
if (previewUrl.value) {
|
||||
URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
}
|
||||
} catch (e2) {
|
||||
error.value = '프로필 저장에 실패했어요.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="wrap">
|
||||
<h2 class="title">프로필</h2>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
|
||||
<div class="card" v-if="auth.user">
|
||||
<div class="row">
|
||||
<div class="avatar">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="avatarImg" alt="avatar" />
|
||||
<div v-else class="avatarFallback">{{ (auth.user.email || 'U')[0].toUpperCase() }}</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div class="email">{{ auth.user.email }}</div>
|
||||
<input v-model="nickname" class="nicknameInput" placeholder="작성자 닉네임" />
|
||||
<div class="badge" v-if="auth.user.isAdmin">admin</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload">
|
||||
<label class="label">아바타 업로드</label>
|
||||
<input class="file" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
|
||||
<div class="hint">파일 선택은 미리보기만 바뀌고, 실제 반영은 저장 버튼을 눌렀을 때 진행됩니다.</div>
|
||||
<button class="saveBtn" :disabled="saving" @click="saveProfile">
|
||||
{{ saving ? '저장중...' : '프로필 저장' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
padding: 10px 2px;
|
||||
}
|
||||
.title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.error {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
.card {
|
||||
max-width: 520px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.avatar {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.avatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.avatarFallback {
|
||||
font-weight: 900;
|
||||
font-size: 20px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.meta {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
.email {
|
||||
font-weight: 900;
|
||||
}
|
||||
.nicknameInput {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.badge {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 999px;
|
||||
width: fit-content;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.upload {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
opacity: 0.78;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.file {
|
||||
width: 100%;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 8px;
|
||||
opacity: 0.72;
|
||||
font-size: 13px;
|
||||
}
|
||||
.saveBtn {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
</style>
|
||||
543
frontend/src/views/TierEditorView.vue
Normal file
543
frontend/src/views/TierEditorView.vue
Normal file
@@ -0,0 +1,543 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import Sortable from 'sortablejs'
|
||||
import * as htmlToImage from 'html-to-image'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
|
||||
const route = useRoute()
|
||||
const gameId = computed(() => route.params.gameId)
|
||||
const tierListId = computed(() => route.params.tierListId)
|
||||
const gameName = ref('')
|
||||
|
||||
const groups = ref([
|
||||
{ id: 'gS', name: 'S', itemIds: [] },
|
||||
{ id: 'gA', name: 'A', itemIds: [] },
|
||||
{ id: 'gB', name: 'B', itemIds: [] },
|
||||
{ id: 'gC', name: 'C', itemIds: [] },
|
||||
{ id: 'gD', name: 'D', itemIds: [] },
|
||||
])
|
||||
|
||||
const pool = ref([])
|
||||
const itemsById = ref({})
|
||||
|
||||
const title = ref('')
|
||||
const description = ref('')
|
||||
const isPublic = ref(false)
|
||||
const error = ref('')
|
||||
const isSaving = ref(false)
|
||||
|
||||
const boardEl = ref(null)
|
||||
const groupListEl = ref(null)
|
||||
const poolEl = ref(null)
|
||||
const groupDropEls = ref({})
|
||||
const fileEl = ref(null)
|
||||
|
||||
function setGroupDropEl(groupId, el) {
|
||||
if (!el) return
|
||||
groupDropEls.value[groupId] = el
|
||||
}
|
||||
|
||||
function getListByContainer(containerEl) {
|
||||
if (!containerEl) return { type: null, groupId: null }
|
||||
const t = containerEl.getAttribute('data-list-type')
|
||||
if (t === 'pool') return { type: 'pool', groupId: null }
|
||||
if (t === 'group') return { type: 'group', groupId: containerEl.getAttribute('data-group-id') }
|
||||
return { type: null, groupId: null }
|
||||
}
|
||||
|
||||
function normalizeSort(containerEl) {
|
||||
const ids = Array.from(containerEl.querySelectorAll('[data-item-id]')).map((n) => n.getAttribute('data-item-id'))
|
||||
const meta = getListByContainer(containerEl)
|
||||
if (meta.type === 'pool') pool.value = ids
|
||||
if (meta.type === 'group') {
|
||||
const g = groups.value.find((x) => x.id === meta.groupId)
|
||||
if (g) g.itemIds = ids
|
||||
}
|
||||
}
|
||||
|
||||
function resolveItemSrc(item) {
|
||||
const src = item?.src || ''
|
||||
if (!src) return ''
|
||||
if (src.startsWith('blob:')) return src
|
||||
return toApiUrl(src)
|
||||
}
|
||||
|
||||
async function initSortables() {
|
||||
if (!poolEl.value || !groupListEl.value) return
|
||||
|
||||
Sortable.create(groupListEl.value, {
|
||||
animation: 160,
|
||||
handle: '[data-group-handle]',
|
||||
ghostClass: 'ghost',
|
||||
chosenClass: 'chosen',
|
||||
onEnd: (evt) => {
|
||||
const next = [...groups.value]
|
||||
const [moved] = next.splice(evt.oldIndex, 1)
|
||||
next.splice(evt.newIndex, 0, moved)
|
||||
groups.value = next
|
||||
},
|
||||
})
|
||||
|
||||
Sortable.create(poolEl.value, {
|
||||
group: 'tier-items',
|
||||
animation: 160,
|
||||
draggable: '[data-item-id]',
|
||||
ghostClass: 'ghost',
|
||||
chosenClass: 'chosen',
|
||||
onSort: () => normalizeSort(poolEl.value),
|
||||
onAdd: () => normalizeSort(poolEl.value),
|
||||
})
|
||||
|
||||
Object.entries(groupDropEls.value).forEach(([gid, el]) => {
|
||||
Sortable.create(el, {
|
||||
group: 'tier-items',
|
||||
animation: 160,
|
||||
draggable: '[data-item-id]',
|
||||
ghostClass: 'ghost',
|
||||
chosenClass: 'chosen',
|
||||
onSort: () => normalizeSort(el),
|
||||
onAdd: () => normalizeSort(el),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function addCustomImage(file) {
|
||||
const url = URL.createObjectURL(file)
|
||||
const id = `c-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
itemsById.value = {
|
||||
...itemsById.value,
|
||||
[id]: { id, src: url, label: file.name || 'custom', origin: 'custom', pendingFile: file },
|
||||
}
|
||||
pool.value = [id, ...pool.value]
|
||||
}
|
||||
|
||||
function openFile() {
|
||||
fileEl.value?.click()
|
||||
}
|
||||
|
||||
function onFileChange(e) {
|
||||
const file = e.target.files && e.target.files[0]
|
||||
if (!file) return
|
||||
addCustomImage(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
async function downloadImage() {
|
||||
if (!boardEl.value) return
|
||||
const dataUrl = await htmlToImage.toPng(boardEl.value, { pixelRatio: 2, backgroundColor: '#0b1220' })
|
||||
const a = document.createElement('a')
|
||||
a.href = dataUrl
|
||||
a.download = `${(title.value || gameName.value || 'tierlist').trim()}.png`
|
||||
a.click()
|
||||
}
|
||||
|
||||
async function uploadPendingCustomItems() {
|
||||
const entries = Object.values(itemsById.value).filter((item) => item?.origin === 'custom' && item?.pendingFile)
|
||||
|
||||
for (const item of entries) {
|
||||
const fd = new FormData()
|
||||
fd.append('label', item.label || 'custom')
|
||||
fd.append('image', item.pendingFile)
|
||||
|
||||
const res = await fetch(toApiUrl('/api/tierlists/custom-items'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('custom_upload_failed')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const uploaded = data.item
|
||||
const nextItemsById = { ...itemsById.value }
|
||||
delete nextItemsById[item.id]
|
||||
nextItemsById[uploaded.id] = {
|
||||
id: uploaded.id,
|
||||
src: uploaded.src,
|
||||
label: uploaded.label,
|
||||
origin: 'custom',
|
||||
}
|
||||
itemsById.value = nextItemsById
|
||||
pool.value = pool.value.map((currentId) => (currentId === item.id ? uploaded.id : currentId))
|
||||
groups.value = groups.value.map((group) => ({
|
||||
...group,
|
||||
itemIds: group.itemIds.map((currentId) => (currentId === item.id ? uploaded.id : currentId)),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(existingId) {
|
||||
const finalTitle = (title.value || '').trim() || `${(gameName.value || gameId.value).trim()} 티어표`
|
||||
return {
|
||||
id: existingId || undefined,
|
||||
gameId: gameId.value,
|
||||
title: finalTitle,
|
||||
description: (description.value || '').trim(),
|
||||
isPublic: !!isPublic.value,
|
||||
groups: groups.value.map((g) => ({ id: g.id, name: g.name, itemIds: g.itemIds })),
|
||||
pool: Object.values(itemsById.value),
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
isSaving.value = true
|
||||
try {
|
||||
await uploadPendingCustomItems()
|
||||
const payload = buildPayload(tierListId.value && tierListId.value !== 'new' ? tierListId.value : null)
|
||||
const res = await api.saveTierList(payload)
|
||||
if (tierListId.value === 'new') history.replaceState({}, '', `/editor/${gameId.value}/${res.tierList.id}`)
|
||||
} catch (e) {
|
||||
error.value = '저장 실패: 로그인 상태인지 확인해주세요.'
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
;(async () => {
|
||||
try {
|
||||
const gameRes = await api.getGame(gameId.value)
|
||||
gameName.value = gameRes.game?.name || gameId.value
|
||||
const base = (gameRes.items || []).map((img) => ({
|
||||
id: img.id,
|
||||
src: img.src,
|
||||
label: img.label,
|
||||
origin: 'game',
|
||||
}))
|
||||
const map = {}
|
||||
base.forEach((it) => (map[it.id] = it))
|
||||
itemsById.value = map
|
||||
pool.value = base.map((it) => it.id)
|
||||
} catch (e) {
|
||||
error.value = '게임 기본 이미지를 불러오지 못했어요.'
|
||||
}
|
||||
|
||||
if (tierListId.value && tierListId.value !== 'new') {
|
||||
try {
|
||||
const res = await api.getTierList(tierListId.value)
|
||||
const t = res.tierList
|
||||
title.value = t.title
|
||||
description.value = t.description || ''
|
||||
isPublic.value = !!t.isPublic
|
||||
groups.value = t.groups
|
||||
const map = {}
|
||||
;(t.pool || []).forEach((it) => (map[it.id] = it))
|
||||
itemsById.value = map
|
||||
const grouped = new Set()
|
||||
groups.value.forEach((g) => g.itemIds.forEach((id) => grouped.add(id)))
|
||||
pool.value = Object.keys(itemsById.value).filter((id) => !grouped.has(id))
|
||||
} catch (e) {
|
||||
error.value = '티어표를 불러오지 못했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
await initSortables()
|
||||
})()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="head">
|
||||
<div>
|
||||
<div class="kicker">{{ gameName || gameId }}</div>
|
||||
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" />
|
||||
<input
|
||||
v-model="description"
|
||||
class="descInput"
|
||||
placeholder="설명(선택): 이 티어표의 기준/룰"
|
||||
/>
|
||||
<div class="hint">
|
||||
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다. 저장하려면 로그인 후 <b>저장</b>을 누르세요.
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<label class="toggle">
|
||||
<input v-model="isPublic" type="checkbox" />
|
||||
<span>공개</span>
|
||||
</label>
|
||||
<button class="btn" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
|
||||
<button class="btn btn--primary" @click="downloadImage">이미지로 다운로드</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
|
||||
<section class="layout">
|
||||
<div ref="boardEl" class="board">
|
||||
<div ref="groupListEl" class="rows">
|
||||
<div v-for="g in groups" :key="g.id" class="row">
|
||||
<div class="row__label">
|
||||
<span class="grab" title="드래그로 순서 변경" data-group-handle>↕</span>
|
||||
<input v-model="g.name" class="groupName" />
|
||||
</div>
|
||||
<div
|
||||
class="row__drop"
|
||||
:data-list-type="'group'"
|
||||
:data-group-id="g.id"
|
||||
:ref="(el) => setGroupDropEl(g.id, el)"
|
||||
>
|
||||
<div class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
|
||||
<div v-for="id in g.itemIds" :key="id" class="cell" :data-item-id="id">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="sidebar__title">아이템</div>
|
||||
<div class="sidebar__hint">게임별 기본 이미지 + 커스텀 업로드를 여기에 모읍니다.</div>
|
||||
<div ref="poolEl" class="pool" data-list-type="pool">
|
||||
<div v-for="id in pool" :key="id" class="poolItem" :data-item-id="id">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<input ref="fileEl" type="file" accept="image/*" class="hidden" @change="onFileChange" />
|
||||
<button class="btn btn--ghost" @click="openFile">커스텀 이미지 추가</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 2px 14px;
|
||||
}
|
||||
.kicker {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.titleInput {
|
||||
width: min(520px, 92vw);
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
outline: none;
|
||||
}
|
||||
.descInput {
|
||||
margin-top: 10px;
|
||||
width: min(520px, 92vw);
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.hint {
|
||||
opacity: 0.78;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.toggle input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.btn {
|
||||
padding: 10px 12px;
|
||||
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: 700;
|
||||
}
|
||||
.btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.btn--primary {
|
||||
background: rgba(110, 231, 183, 0.18);
|
||||
}
|
||||
.btn--primary:hover {
|
||||
background: rgba(110, 231, 183, 0.24);
|
||||
}
|
||||
.btn--ghost {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 14px;
|
||||
}
|
||||
.error {
|
||||
margin: 10px 0 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
.board {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
}
|
||||
.rows {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.row__label {
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 10px 8px;
|
||||
font-weight: 900;
|
||||
overflow: hidden;
|
||||
}
|
||||
.grab {
|
||||
cursor: grab;
|
||||
opacity: 0.85;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.groupName {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 10px;
|
||||
padding: 6px 8px;
|
||||
font-weight: 900;
|
||||
text-align: left;
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
}
|
||||
.row__drop {
|
||||
border-radius: 14px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
min-height: 74px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-content: flex-start;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.row__empty {
|
||||
opacity: 0.6;
|
||||
font-size: 13px;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.cell {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
object-fit: cover;
|
||||
}
|
||||
.sidebar {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
}
|
||||
.sidebar__title {
|
||||
font-weight: 900;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.sidebar__hint {
|
||||
opacity: 0.78;
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.pool {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.poolItem {
|
||||
display: grid;
|
||||
grid-template-columns: 52px 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
.poolItem__label {
|
||||
font-weight: 800;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.ghost {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.chosen {
|
||||
outline: 2px solid rgba(110, 231, 183, 0.5);
|
||||
border-radius: 14px;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.row {
|
||||
grid-template-columns: 150px 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
7
frontend/vite.config.js
Normal file
7
frontend/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
})
|
||||
1661
package-lock.json
generated
Normal file
1661
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "tier-cursor",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"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",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.14"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user