Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d778e9c20 |
@@ -24,7 +24,7 @@ const allowedOrigins = (process.env.CORS_ORIGINS || '')
|
|||||||
|
|
||||||
const FileStore = FileStoreFactory(session)
|
const FileStore = FileStoreFactory(session)
|
||||||
|
|
||||||
;['uploads/avatars', 'uploads/games', 'uploads/custom', '.sessions'].forEach((relativePath) => {
|
;['uploads/avatars', 'uploads/games', 'uploads/custom', 'uploads/tierlists', '.sessions'].forEach((relativePath) => {
|
||||||
fs.mkdirSync(path.join(__dirname, relativePath), { recursive: true })
|
fs.mkdirSync(path.join(__dirname, relativePath), { recursive: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ function mapTierListRow(row) {
|
|||||||
authorAvatarSrc: row.avatar_src || '',
|
authorAvatarSrc: row.avatar_src || '',
|
||||||
gameId: row.game_id,
|
gameId: row.game_id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
|
thumbnailSrc: row.thumbnail_src || '',
|
||||||
description: row.description || '',
|
description: row.description || '',
|
||||||
isPublic: !!row.is_public,
|
isPublic: !!row.is_public,
|
||||||
groups: parseJson(row.groups_json, []),
|
groups: parseJson(row.groups_json, []),
|
||||||
@@ -195,6 +196,7 @@ async function ensureSchema() {
|
|||||||
author_id VARCHAR(64) NOT NULL,
|
author_id VARCHAR(64) NOT NULL,
|
||||||
game_id VARCHAR(120) NOT NULL,
|
game_id VARCHAR(120) NOT NULL,
|
||||||
title VARCHAR(120) NOT NULL,
|
title VARCHAR(120) NOT NULL,
|
||||||
|
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
is_public TINYINT(1) NOT NULL DEFAULT 0,
|
is_public TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
groups_json LONGTEXT NOT NULL,
|
groups_json LONGTEXT NOT NULL,
|
||||||
@@ -209,6 +211,11 @@ async function ensureSchema() {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
const tierListThumbnailColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'thumbnail_src'")
|
||||||
|
if (!tierListThumbnailColumns.length) {
|
||||||
|
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
|
||||||
|
}
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`
|
`
|
||||||
INSERT INTO games (id, name, thumbnail_src, created_at)
|
INSERT INTO games (id, name, thumbnail_src, created_at)
|
||||||
@@ -397,6 +404,12 @@ async function createGameItem({ id, gameId, src, label }) {
|
|||||||
return mapGameItemRow(rows[0])
|
return mapGameItemRow(rows[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateGameItemLabel(itemId, label) {
|
||||||
|
await query('UPDATE game_items SET label = ? WHERE id = ?', [label, itemId])
|
||||||
|
const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId])
|
||||||
|
return mapGameItemRow(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteGameItem(itemId) {
|
async function deleteGameItem(itemId) {
|
||||||
const gameItemRows = await query('SELECT game_id FROM game_items WHERE id = ? LIMIT 1', [itemId])
|
const gameItemRows = await query('SELECT game_id FROM game_items WHERE id = ? LIMIT 1', [itemId])
|
||||||
const gameId = gameItemRows[0]?.game_id
|
const gameId = gameItemRows[0]?.game_id
|
||||||
@@ -588,6 +601,7 @@ async function listPublicTierLists(gameId) {
|
|||||||
t.id,
|
t.id,
|
||||||
t.game_id,
|
t.game_id,
|
||||||
t.title,
|
t.title,
|
||||||
|
t.thumbnail_src,
|
||||||
t.created_at,
|
t.created_at,
|
||||||
t.updated_at,
|
t.updated_at,
|
||||||
t.author_id,
|
t.author_id,
|
||||||
@@ -607,6 +621,7 @@ async function listPublicTierLists(gameId) {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
gameId: row.game_id,
|
gameId: row.game_id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
|
thumbnailSrc: row.thumbnail_src || '',
|
||||||
createdAt: Number(row.created_at),
|
createdAt: Number(row.created_at),
|
||||||
updatedAt: Number(row.updated_at),
|
updatedAt: Number(row.updated_at),
|
||||||
authorId: row.author_id,
|
authorId: row.author_id,
|
||||||
@@ -623,6 +638,7 @@ async function listUserTierLists(userId) {
|
|||||||
t.id,
|
t.id,
|
||||||
t.game_id,
|
t.game_id,
|
||||||
t.title,
|
t.title,
|
||||||
|
t.thumbnail_src,
|
||||||
t.created_at,
|
t.created_at,
|
||||||
t.updated_at,
|
t.updated_at,
|
||||||
t.is_public,
|
t.is_public,
|
||||||
@@ -641,6 +657,7 @@ async function listUserTierLists(userId) {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
gameId: row.game_id,
|
gameId: row.game_id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
|
thumbnailSrc: row.thumbnail_src || '',
|
||||||
createdAt: Number(row.created_at),
|
createdAt: Number(row.created_at),
|
||||||
updatedAt: Number(row.updated_at),
|
updatedAt: Number(row.updated_at),
|
||||||
isPublic: !!row.is_public,
|
isPublic: !!row.is_public,
|
||||||
@@ -658,6 +675,7 @@ async function findTierListById(id) {
|
|||||||
t.author_id,
|
t.author_id,
|
||||||
t.game_id,
|
t.game_id,
|
||||||
t.title,
|
t.title,
|
||||||
|
t.thumbnail_src,
|
||||||
t.description,
|
t.description,
|
||||||
t.is_public,
|
t.is_public,
|
||||||
t.groups_json,
|
t.groups_json,
|
||||||
@@ -708,17 +726,17 @@ async function deleteCustomItems(ids) {
|
|||||||
await query(`DELETE FROM custom_items WHERE id IN (${placeholders})`, ids)
|
await query(`DELETE FROM custom_items WHERE id IN (${placeholders})`, ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveTierList({ id, authorId, gameId, title, description, isPublic, groups, pool }) {
|
async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', description, isPublic, groups, pool }) {
|
||||||
const existing = id ? await findTierListById(id) : null
|
const existing = id ? await findTierListById(id) : null
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await query(
|
await query(
|
||||||
`
|
`
|
||||||
UPDATE tierlists
|
UPDATE tierlists
|
||||||
SET title = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ?
|
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`,
|
`,
|
||||||
[title, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
|
[title, thumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
|
||||||
)
|
)
|
||||||
return findTierListById(existing.id)
|
return findTierListById(existing.id)
|
||||||
}
|
}
|
||||||
@@ -727,11 +745,11 @@ async function saveTierList({ id, authorId, gameId, title, description, isPublic
|
|||||||
await query(
|
await query(
|
||||||
`
|
`
|
||||||
INSERT INTO tierlists (
|
INSERT INTO tierlists (
|
||||||
id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at
|
id, author_id, game_id, title, thumbnail_src, description, is_public, groups_json, pool_json, created_at, updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
[id, authorId, gameId, title, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
|
[id, authorId, gameId, title, thumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
|
||||||
)
|
)
|
||||||
return findTierListById(id)
|
return findTierListById(id)
|
||||||
}
|
}
|
||||||
@@ -755,6 +773,7 @@ module.exports = {
|
|||||||
createGame,
|
createGame,
|
||||||
updateGameThumbnail,
|
updateGameThumbnail,
|
||||||
createGameItem,
|
createGameItem,
|
||||||
|
updateGameItemLabel,
|
||||||
deleteGameItem,
|
deleteGameItem,
|
||||||
deleteGame,
|
deleteGame,
|
||||||
updateGameDisplayOrder,
|
updateGameDisplayOrder,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const {
|
|||||||
listGames,
|
listGames,
|
||||||
updateGameThumbnail,
|
updateGameThumbnail,
|
||||||
createGameItem,
|
createGameItem,
|
||||||
|
updateGameItemLabel,
|
||||||
deleteGameItem,
|
deleteGameItem,
|
||||||
deleteGame,
|
deleteGame,
|
||||||
updateGameDisplayOrder,
|
updateGameDisplayOrder,
|
||||||
@@ -116,6 +117,19 @@ router.delete('/games/:gameId/items/:itemId', requireAdmin, async (req, res) =>
|
|||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.patch('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
|
||||||
|
const schema = z.object({ label: z.string().trim().min(1).max(60) })
|
||||||
|
const parsed = schema.safeParse(req.body)
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
|
const game = await findGameById(req.params.gameId)
|
||||||
|
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
|
const updated = await updateGameItemLabel(req.params.itemId, parsed.data.label)
|
||||||
|
if (!updated || updated.gameId !== game.id) return res.status(404).json({ error: 'not_found' })
|
||||||
|
res.json({ item: updated })
|
||||||
|
})
|
||||||
|
|
||||||
router.delete('/games/:gameId', requireAdmin, async (req, res) => {
|
router.delete('/games/:gameId', requireAdmin, async (req, res) => {
|
||||||
const game = await findGameById(req.params.gameId)
|
const game = await findGameById(req.params.gameId)
|
||||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|||||||
@@ -52,10 +52,19 @@ const upload = multer({
|
|||||||
limits: { fileSize: 6 * 1024 * 1024 },
|
limits: { fileSize: 6 * 1024 * 1024 },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const thumbnailUpload = multer({
|
||||||
|
storage: multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'tierlists')),
|
||||||
|
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
|
||||||
|
}),
|
||||||
|
limits: { fileSize: 6 * 1024 * 1024 },
|
||||||
|
})
|
||||||
|
|
||||||
const tierListUpsertSchema = z.object({
|
const tierListUpsertSchema = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
gameId: z.string().min(1),
|
gameId: z.string().min(1),
|
||||||
title: z.string().min(1).max(120),
|
title: z.string().min(1).max(120),
|
||||||
|
thumbnailSrc: z.string().max(255).optional().default(''),
|
||||||
description: z.string().max(1000).optional().default(''),
|
description: z.string().max(1000).optional().default(''),
|
||||||
isPublic: z.boolean().default(false),
|
isPublic: z.boolean().default(false),
|
||||||
groups: z.array(
|
groups: z.array(
|
||||||
@@ -121,6 +130,11 @@ router.post('/custom-items', requireAuth, upload.single('image'), async (req, re
|
|||||||
res.json({ item })
|
res.json({ item })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), async (req, res) => {
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
||||||
|
res.json({ thumbnailSrc: `/uploads/tierlists/${req.file.filename}` })
|
||||||
|
})
|
||||||
|
|
||||||
router.post('/', requireAuth, async (req, res) => {
|
router.post('/', requireAuth, async (req, res) => {
|
||||||
const parsed = tierListUpsertSchema.safeParse(req.body)
|
const parsed = tierListUpsertSchema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
@@ -137,6 +151,7 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
authorId: existing.authorId,
|
authorId: existing.authorId,
|
||||||
gameId: existing.gameId,
|
gameId: existing.gameId,
|
||||||
title: payload.title,
|
title: payload.title,
|
||||||
|
thumbnailSrc: payload.thumbnailSrc || '',
|
||||||
description: payload.description || '',
|
description: payload.description || '',
|
||||||
isPublic: !!payload.isPublic,
|
isPublic: !!payload.isPublic,
|
||||||
groups: payload.groups,
|
groups: payload.groups,
|
||||||
@@ -150,6 +165,7 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
authorId: req.session.userId,
|
authorId: req.session.userId,
|
||||||
gameId: payload.gameId,
|
gameId: payload.gameId,
|
||||||
title: payload.title,
|
title: payload.title,
|
||||||
|
thumbnailSrc: payload.thumbnailSrc || '',
|
||||||
description: payload.description || '',
|
description: payload.description || '',
|
||||||
isPublic: !!payload.isPublic,
|
isPublic: !!payload.isPublic,
|
||||||
groups: payload.groups,
|
groups: payload.groups,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.38
|
||||||
|
- 관리자 기본 아이템은 업로드 시점에만 이름을 정할 수 있으면 운영 중 수정이 어려우므로, 목록에서 직접 이름을 바꾸고 저장할 수 있게 하기로 결정했다.
|
||||||
|
- 게임별 티어표 목록도 식별성이 중요하므로, 사용자가 편집 시 별도 썸네일을 지정할 수 있게 하고 목록 카드에서는 게임 카드와 비슷한 상단 썸네일 구조를 사용하기로 결정했다.
|
||||||
|
|
||||||
## 2026-03-19
|
## 2026-03-19
|
||||||
- 초기 저장소는 빠른 구현을 위해 `lowdb(JSON 파일)`를 채택했다.
|
- 초기 저장소는 빠른 구현을 위해 `lowdb(JSON 파일)`를 채택했다.
|
||||||
- 인증은 JWT 대신 서버 세션(`express-session` + `session-file-store`)을 사용했다.
|
- 인증은 JWT 대신 서버 세션(`express-session` + `session-file-store`)을 사용했다.
|
||||||
|
|||||||
12
docs/map.md
12
docs/map.md
@@ -7,13 +7,13 @@
|
|||||||
|
|
||||||
## `/games/:gameId`
|
## `/games/:gameId`
|
||||||
- 화면 파일: `frontend/src/views/GameHubView.vue`
|
- 화면 파일: `frontend/src/views/GameHubView.vue`
|
||||||
- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 작성자 닉네임 노출, 새 티어표 작성 진입
|
- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 티어표별 상단 썸네일/작성자 표시, 새 티어표 작성 진입
|
||||||
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`
|
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`
|
||||||
|
|
||||||
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
|
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
|
||||||
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
||||||
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 작성 권한 제어, 저장, 공개 여부 설정, PNG 다운로드
|
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 티어표 썸네일 선택, 작성 권한 제어, 저장, 공개 여부 설정, PNG 다운로드
|
||||||
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`
|
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`
|
||||||
|
|
||||||
## `/login`
|
## `/login`
|
||||||
- 화면 파일: `frontend/src/views/LoginView.vue`
|
- 화면 파일: `frontend/src/views/LoginView.vue`
|
||||||
@@ -22,13 +22,13 @@
|
|||||||
|
|
||||||
## `/me`
|
## `/me`
|
||||||
- 화면 파일: `frontend/src/views/MyTierListsView.vue`
|
- 화면 파일: `frontend/src/views/MyTierListsView.vue`
|
||||||
- 역할: 내 티어표 목록 조회, 편집 화면으로 이동, 작성자 본인 티어표 삭제
|
- 역할: 내 티어표 목록 조회, 상단 썸네일 카드 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제
|
||||||
- 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id`
|
- 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id`
|
||||||
|
|
||||||
## `/admin`
|
## `/admin`
|
||||||
- 화면 파일: `frontend/src/views/AdminView.vue`
|
- 화면 파일: `frontend/src/views/AdminView.vue`
|
||||||
- 역할: `게임 관리 / 아이템 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
|
- 역할: `게임 관리 / 아이템 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
|
||||||
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `GET /api/admin/custom-items`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
|
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
|
||||||
|
|
||||||
## `/profile`
|
## `/profile`
|
||||||
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
- `authorId`: string
|
- `authorId`: string
|
||||||
- `gameId`: string
|
- `gameId`: string
|
||||||
- `title`: string
|
- `title`: string
|
||||||
|
- `thumbnailSrc`: string
|
||||||
- `description`: string
|
- `description`: string
|
||||||
- `isPublic`: boolean
|
- `isPublic`: boolean
|
||||||
- `groups`: `{ id, name, itemIds[] }[]`
|
- `groups`: `{ id, name, itemIds[] }[]`
|
||||||
@@ -78,6 +79,7 @@
|
|||||||
- `GET /api/tierlists/me`
|
- `GET /api/tierlists/me`
|
||||||
- `GET /api/tierlists/:id`
|
- `GET /api/tierlists/:id`
|
||||||
- `DELETE /api/tierlists/:id`
|
- `DELETE /api/tierlists/:id`
|
||||||
|
- `POST /api/tierlists/thumbnail`
|
||||||
- `POST /api/tierlists/custom-items`
|
- `POST /api/tierlists/custom-items`
|
||||||
- `POST /api/tierlists`
|
- `POST /api/tierlists`
|
||||||
- 관리자
|
- 관리자
|
||||||
@@ -85,6 +87,7 @@
|
|||||||
- `POST /api/admin/games/:gameId/thumbnail`
|
- `POST /api/admin/games/:gameId/thumbnail`
|
||||||
- `POST /api/admin/games/:gameId/images`
|
- `POST /api/admin/games/:gameId/images`
|
||||||
- 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
|
- 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
|
||||||
|
- `PATCH /api/admin/games/:gameId/items/:itemId`
|
||||||
- `GET /api/admin/custom-items`
|
- `GET /api/admin/custom-items`
|
||||||
- `DELETE /api/admin/custom-items/:itemId`
|
- `DELETE /api/admin/custom-items/:itemId`
|
||||||
- `DELETE /api/admin/custom-items`
|
- `DELETE /api/admin/custom-items`
|
||||||
@@ -98,6 +101,7 @@
|
|||||||
## 관리자 화면 메모
|
## 관리자 화면 메모
|
||||||
- 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다.
|
- 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다.
|
||||||
- 게임 기본 아이템 추가는 드래그 앤 드롭 또는 다중 파일 선택으로 처리하고, 미리보기 카드에서 여러 장을 함께 확인할 수 있다.
|
- 게임 기본 아이템 추가는 드래그 앤 드롭 또는 다중 파일 선택으로 처리하고, 미리보기 카드에서 여러 장을 함께 확인할 수 있다.
|
||||||
|
- 현재 기본 아이템 목록에서는 등록된 아이템 이름을 직접 수정하고 저장할 수 있다.
|
||||||
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
|
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
|
||||||
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
|
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
|
||||||
- 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다.
|
- 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다.
|
||||||
@@ -113,6 +117,7 @@
|
|||||||
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
||||||
- 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다.
|
- 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다.
|
||||||
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
|
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
|
||||||
|
- 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다.
|
||||||
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
||||||
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
||||||
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
|
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
|
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
|
||||||
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
|
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
|
||||||
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
||||||
|
- 티어표 썸네일은 현재 업로드/교체만 지원하므로, 필요하면 자동 썸네일 추출이나 업로드 이미지 크롭 UX를 추가 검토한다.
|
||||||
|
|
||||||
## 배포 전 작업
|
## 배포 전 작업
|
||||||
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
|
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.38
|
||||||
|
- **관리자 기본 아이템 이름 수정 추가**: 게임 관리 화면의 현재 기본 아이템 목록에서 이름을 직접 수정하고 저장할 수 있도록 API와 UI를 보강
|
||||||
|
- **티어표 썸네일 추가**: 티어표 편집 화면에서 별도 썸네일 이미지를 선택해 저장할 수 있도록 업로드 흐름을 추가하고, 게임별 공개 티어표/내 티어표 목록은 게임 카드처럼 상단 썸네일 + 하단 제목/작성자 정보 카드 구조로 변경
|
||||||
|
|
||||||
## 2026-03-26 v0.1.37
|
## 2026-03-26 v0.1.37
|
||||||
- **운영 포트 설정 반영**: 프로덕션 컴포즈의 `frontend/phpMyAdmin` 외부 포트를 `18080/18081` 기준으로 유지하고, NAS 배포 문서와 기술 명세의 리버스 프록시 포트 안내도 동일하게 정리
|
- **운영 포트 설정 반영**: 프로덕션 컴포즈의 `frontend/phpMyAdmin` 외부 포트를 `18080/18081` 기준으로 유지하고, NAS 배포 문서와 기술 명세의 리버스 프록시 포트 안내도 동일하게 정리
|
||||||
- **인증 라우트 정리**: NAS 로그인 문제를 확인하기 위해 넣었던 `auth` 디버그 로그를 제거하고, 실제 운영에 필요한 세션 저장 보강만 유지
|
- **인증 라우트 정리**: NAS 로그인 문제를 확인하기 위해 넣었던 `auth` 디버그 로그를 제거하고, 실제 운영에 필요한 세션 저장 보강만 유지
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export const api = {
|
|||||||
listGames: () => request('/api/games'),
|
listGames: () => request('/api/games'),
|
||||||
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
|
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
|
||||||
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
|
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
|
||||||
|
updateAdminGameItem: (gameId, itemId, payload) =>
|
||||||
|
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||||
listAdminCustomItems: ({ q = '', page = 1, limit = 50, orphanOnly = false } = {}) =>
|
listAdminCustomItems: ({ q = '', page = 1, limit = 50, orphanOnly = false } = {}) =>
|
||||||
request(
|
request(
|
||||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}`
|
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}`
|
||||||
@@ -50,6 +52,23 @@ export const api = {
|
|||||||
getTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`),
|
getTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`),
|
||||||
deleteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
deleteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }),
|
saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }),
|
||||||
|
uploadTierListThumbnail: async (file) => {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('thumbnail', file)
|
||||||
|
const res = await fetch(toApiUrl('/api/tierlists/thumbnail'), {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: fd,
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = new Error('request_failed')
|
||||||
|
err.status = res.status
|
||||||
|
err.data = data
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
},
|
||||||
deleteAdminCustomItem: (itemId) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}`, { method: 'DELETE' }),
|
deleteAdminCustomItem: (itemId) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}`, { method: 'DELETE' }),
|
||||||
deleteAdminUnusedCustomItems: ({ q = '' } = {}) =>
|
deleteAdminUnusedCustomItems: ({ q = '' } = {}) =>
|
||||||
request(`/api/admin/custom-items?q=${encodeURIComponent(q)}`, { method: 'DELETE' }),
|
request(`/api/admin/custom-items?q=${encodeURIComponent(q)}`, { method: 'DELETE' }),
|
||||||
|
|||||||
@@ -196,7 +196,13 @@ async function loadGame() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.getGame(selectedGameId.value)
|
const data = await api.getGame(selectedGameId.value)
|
||||||
selectedGame.value = data
|
selectedGame.value = {
|
||||||
|
...data,
|
||||||
|
items: (data.items || []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
draftLabel: item.label,
|
||||||
|
})),
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '게임 정보를 불러오지 못했어요.'
|
error.value = '게임 정보를 불러오지 못했어요.'
|
||||||
}
|
}
|
||||||
@@ -346,6 +352,24 @@ async function removeGameItem(itemId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveGameItemLabel(item) {
|
||||||
|
resetMessages()
|
||||||
|
if (!selectedGameId.value) return
|
||||||
|
const nextLabel = (item.draftLabel || '').trim()
|
||||||
|
if (!nextLabel) {
|
||||||
|
error.value = '아이템 이름을 입력해주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.updateAdminGameItem(selectedGameId.value, item.id, { label: nextLabel })
|
||||||
|
await loadGame()
|
||||||
|
success.value = '기본 아이템 이름을 수정했어요.'
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '기본 아이템 이름 수정에 실패했어요.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeGame() {
|
async function removeGame() {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
if (!selectedGameId.value || !selectedGame.value?.game) return
|
if (!selectedGameId.value || !selectedGame.value?.game) return
|
||||||
@@ -708,8 +732,11 @@ async function saveFeaturedOrder() {
|
|||||||
<div v-else class="thumbGrid">
|
<div v-else class="thumbGrid">
|
||||||
<div v-for="item in selectedGame.items" :key="item.id" class="thumbCard">
|
<div v-for="item in selectedGame.items" :key="item.id" class="thumbCard">
|
||||||
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" />
|
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||||
<div class="thumbLabel">{{ item.label }}</div>
|
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" />
|
||||||
<button class="btn btn--danger btn--small" @click="removeGameItem(item.id)">아이템 삭제</button>
|
<div class="thumbCard__actions">
|
||||||
|
<button class="btn btn--ghost btn--small" @click="saveGameItemLabel(item)">이름 저장</button>
|
||||||
|
<button class="btn btn--danger btn--small" @click="removeGameItem(item.id)">아이템 삭제</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1047,6 +1074,9 @@ async function saveFeaturedOrder() {
|
|||||||
.input--compact {
|
.input--compact {
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
}
|
}
|
||||||
|
.input--labelEdit {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
.hint {
|
.hint {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
opacity: 0.78;
|
opacity: 0.78;
|
||||||
@@ -1251,6 +1281,11 @@ async function saveFeaturedOrder() {
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
.thumbCard__actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
.thumbLabel--preview {
|
.thumbLabel--preview {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ function avatarFallbackOf(tierList) {
|
|||||||
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tierListThumbnailUrl(tierList) {
|
||||||
|
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const [gameRes, listRes] = await Promise.all([api.getGame(gameId.value), api.listPublicTierLists(gameId.value)])
|
const [gameRes, listRes] = await Promise.all([api.getGame(gameId.value), api.listPublicTierLists(gameId.value)])
|
||||||
@@ -78,6 +82,10 @@ function openTierList(id) {
|
|||||||
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
|
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
|
||||||
<div v-else class="list">
|
<div v-else class="list">
|
||||||
<button v-for="t in tierLists" :key="t.id" class="row" @click="openTierList(t.id)">
|
<button v-for="t in tierLists" :key="t.id" class="row" @click="openTierList(t.id)">
|
||||||
|
<div class="row__thumbWrap">
|
||||||
|
<img v-if="tierListThumbnailUrl(t)" class="row__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
|
||||||
|
<div v-else class="row__thumbPlaceholder"></div>
|
||||||
|
</div>
|
||||||
<div class="row__head">
|
<div class="row__head">
|
||||||
<div class="row__title">{{ t.title }}</div>
|
<div class="row__title">{{ t.title }}</div>
|
||||||
<div class="row__author">
|
<div class="row__author">
|
||||||
@@ -153,7 +161,7 @@ function openTierList(id) {
|
|||||||
}
|
}
|
||||||
.row {
|
.row {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 14px;
|
padding: 0;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
@@ -164,10 +172,28 @@ function openTierList(id) {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
min-height: 168px;
|
min-height: 168px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.row:hover {
|
.row:hover {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
.row__thumbWrap {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
.row__thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.row__thumbPlaceholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
|
||||||
|
}
|
||||||
.row__title {
|
.row__title {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -175,6 +201,7 @@ function openTierList(id) {
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
.row__head {
|
.row__head {
|
||||||
|
padding: 14px 14px 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
@@ -202,6 +229,7 @@ function openTierList(id) {
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
.row__meta {
|
.row__meta {
|
||||||
|
padding: 0 14px 14px;
|
||||||
opacity: 0.78;
|
opacity: 0.78;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ function avatarFallbackOf(tierList) {
|
|||||||
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tierListThumbnailUrl(tierList) {
|
||||||
|
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.listMyTierLists()
|
const data = await api.listMyTierLists()
|
||||||
@@ -68,6 +72,10 @@ async function removeList(t) {
|
|||||||
<div v-else class="list">
|
<div v-else class="list">
|
||||||
<article v-for="t in myLists" :key="t.id" class="row">
|
<article v-for="t in myLists" :key="t.id" class="row">
|
||||||
<button class="row__body" @click="openList(t)">
|
<button class="row__body" @click="openList(t)">
|
||||||
|
<div class="row__thumbWrap">
|
||||||
|
<img v-if="tierListThumbnailUrl(t)" class="row__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
|
||||||
|
<div v-else class="row__thumbPlaceholder"></div>
|
||||||
|
</div>
|
||||||
<div class="row__head">
|
<div class="row__head">
|
||||||
<div class="row__title">{{ t.title }}</div>
|
<div class="row__title">{{ t.title }}</div>
|
||||||
<div class="row__author">
|
<div class="row__author">
|
||||||
@@ -125,18 +133,17 @@ async function removeList(t) {
|
|||||||
}
|
}
|
||||||
.list {
|
.list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
}
|
}
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||||
background: rgba(0, 0, 0, 0.16);
|
background: rgba(0, 0, 0, 0.16);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.row__body {
|
.row__body {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@@ -147,12 +154,32 @@ async function removeList(t) {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.row__thumbWrap {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
.row__thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.row__thumbPlaceholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
|
||||||
}
|
}
|
||||||
.row__title {
|
.row__title {
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.row__head {
|
.row__head {
|
||||||
|
padding: 0 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -181,6 +208,7 @@ async function removeList(t) {
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
.row__meta {
|
.row__meta {
|
||||||
|
padding: 0 14px;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
opacity: 0.76;
|
opacity: 0.76;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -188,5 +216,16 @@ async function removeList(t) {
|
|||||||
.link--danger {
|
.link--danger {
|
||||||
background: rgba(239, 68, 68, 0.14);
|
background: rgba(239, 68, 68, 0.14);
|
||||||
border-color: rgba(239, 68, 68, 0.28);
|
border-color: rgba(239, 68, 68, 0.28);
|
||||||
|
margin: 0 14px 14px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.list {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ const pool = ref([])
|
|||||||
const itemsById = ref({})
|
const itemsById = ref({})
|
||||||
|
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
|
const thumbnailSrc = ref('')
|
||||||
|
const pendingThumbnailFile = ref(null)
|
||||||
|
const thumbnailPreviewUrl = ref('')
|
||||||
const description = ref('')
|
const description = ref('')
|
||||||
const isPublic = ref(true)
|
const isPublic = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
@@ -45,6 +48,7 @@ const groupListEl = ref(null)
|
|||||||
const poolEl = ref(null)
|
const poolEl = ref(null)
|
||||||
const groupDropEls = ref({})
|
const groupDropEls = ref({})
|
||||||
const fileEl = ref(null)
|
const fileEl = ref(null)
|
||||||
|
const thumbnailFileEl = ref(null)
|
||||||
const groupSortable = ref(null)
|
const groupSortable = ref(null)
|
||||||
const poolSortable = ref(null)
|
const poolSortable = ref(null)
|
||||||
const dropSortables = ref([])
|
const dropSortables = ref([])
|
||||||
@@ -67,6 +71,7 @@ const effectiveTitle = computed(() => {
|
|||||||
if (customTitle) return customTitle
|
if (customTitle) return customTitle
|
||||||
return (gameName.value || gameId.value || 'Tier Maker').trim()
|
return (gameName.value || gameId.value || 'Tier Maker').trim()
|
||||||
})
|
})
|
||||||
|
const displayThumbnailUrl = computed(() => thumbnailPreviewUrl.value || (thumbnailSrc.value ? resolveItemSrc({ src: thumbnailSrc.value }) : ''))
|
||||||
const untitledWarning = computed(
|
const untitledWarning = computed(
|
||||||
() =>
|
() =>
|
||||||
canEdit.value &&
|
canEdit.value &&
|
||||||
@@ -237,6 +242,31 @@ function openFile() {
|
|||||||
fileEl.value?.click()
|
fileEl.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openThumbnailFile() {
|
||||||
|
if (!canEdit.value) return
|
||||||
|
thumbnailFileEl.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onThumbnailChange(event) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (thumbnailPreviewUrl.value) {
|
||||||
|
URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
||||||
|
thumbnailPreviewUrl.value = ''
|
||||||
|
}
|
||||||
|
pendingThumbnailFile.value = file || null
|
||||||
|
if (file) thumbnailPreviewUrl.value = URL.createObjectURL(file)
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearThumbnail() {
|
||||||
|
if (thumbnailPreviewUrl.value) {
|
||||||
|
URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
||||||
|
thumbnailPreviewUrl.value = ''
|
||||||
|
}
|
||||||
|
pendingThumbnailFile.value = null
|
||||||
|
thumbnailSrc.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
function onFileChange(e) {
|
function onFileChange(e) {
|
||||||
const files = Array.from(e.target.files || [])
|
const files = Array.from(e.target.files || [])
|
||||||
if (!files.length) return
|
if (!files.length) return
|
||||||
@@ -322,12 +352,25 @@ async function uploadPendingCustomItems() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadPendingThumbnail() {
|
||||||
|
if (!pendingThumbnailFile.value) return thumbnailSrc.value || ''
|
||||||
|
const data = await api.uploadTierListThumbnail(pendingThumbnailFile.value)
|
||||||
|
if (thumbnailPreviewUrl.value) {
|
||||||
|
URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
||||||
|
thumbnailPreviewUrl.value = ''
|
||||||
|
}
|
||||||
|
pendingThumbnailFile.value = null
|
||||||
|
thumbnailSrc.value = data.thumbnailSrc || ''
|
||||||
|
return thumbnailSrc.value
|
||||||
|
}
|
||||||
|
|
||||||
function buildPayload(existingId) {
|
function buildPayload(existingId) {
|
||||||
const finalTitle = effectiveTitle.value
|
const finalTitle = effectiveTitle.value
|
||||||
return {
|
return {
|
||||||
id: existingId || undefined,
|
id: existingId || undefined,
|
||||||
gameId: gameId.value,
|
gameId: gameId.value,
|
||||||
title: finalTitle,
|
title: finalTitle,
|
||||||
|
thumbnailSrc: thumbnailSrc.value || '',
|
||||||
description: (description.value || '').trim(),
|
description: (description.value || '').trim(),
|
||||||
isPublic: !!isPublic.value,
|
isPublic: !!isPublic.value,
|
||||||
groups: groups.value.map((g) => ({ id: g.id, name: g.name, itemIds: g.itemIds })),
|
groups: groups.value.map((g) => ({ id: g.id, name: g.name, itemIds: g.itemIds })),
|
||||||
@@ -340,6 +383,7 @@ async function save() {
|
|||||||
isSaving.value = true
|
isSaving.value = true
|
||||||
try {
|
try {
|
||||||
await uploadPendingCustomItems()
|
await uploadPendingCustomItems()
|
||||||
|
await uploadPendingThumbnail()
|
||||||
const payload = buildPayload(tierListId.value && tierListId.value !== 'new' ? tierListId.value : null)
|
const payload = buildPayload(tierListId.value && tierListId.value !== 'new' ? tierListId.value : null)
|
||||||
const res = await api.saveTierList(payload)
|
const res = await api.saveTierList(payload)
|
||||||
if (tierListId.value === 'new') history.replaceState({}, '', `/editor/${gameId.value}/${res.tierList.id}`)
|
if (tierListId.value === 'new') history.replaceState({}, '', `/editor/${gameId.value}/${res.tierList.id}`)
|
||||||
@@ -405,6 +449,7 @@ onMounted(() => {
|
|||||||
const t = res.tierList
|
const t = res.tierList
|
||||||
ownerId.value = t.authorId
|
ownerId.value = t.authorId
|
||||||
title.value = t.title
|
title.value = t.title
|
||||||
|
thumbnailSrc.value = t.thumbnailSrc || ''
|
||||||
description.value = t.description || ''
|
description.value = t.description || ''
|
||||||
isPublic.value = !!t.isPublic
|
isPublic.value = !!t.isPublic
|
||||||
authorName.value = t.authorName || ''
|
authorName.value = t.authorName || ''
|
||||||
@@ -430,6 +475,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
if (thumbnailPreviewUrl.value) URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
||||||
destroySortables()
|
destroySortables()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -440,6 +486,17 @@ onUnmounted(() => {
|
|||||||
<div class="kicker">{{ gameName || gameId }}</div>
|
<div class="kicker">{{ gameName || gameId }}</div>
|
||||||
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" :readonly="!canEdit" />
|
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" :readonly="!canEdit" />
|
||||||
<div v-if="untitledWarning" class="titleNotice">{{ untitledWarning }}</div>
|
<div v-if="untitledWarning" class="titleNotice">{{ untitledWarning }}</div>
|
||||||
|
<div v-if="canEdit" class="thumbComposer">
|
||||||
|
<input ref="thumbnailFileEl" type="file" accept="image/*" class="hidden" @change="onThumbnailChange" />
|
||||||
|
<div class="thumbComposer__preview">
|
||||||
|
<img v-if="displayThumbnailUrl" class="thumbComposer__image" :src="displayThumbnailUrl" alt="썸네일 미리보기" />
|
||||||
|
<div v-else class="thumbComposer__empty">썸네일 없음</div>
|
||||||
|
</div>
|
||||||
|
<div class="thumbComposer__actions">
|
||||||
|
<button class="btn btn--ghost" @click="openThumbnailFile">썸네일 선택</button>
|
||||||
|
<button class="btn btn--danger" :disabled="!pendingThumbnailFile && !thumbnailSrc" @click="clearThumbnail">썸네일 제거</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
v-model="description"
|
v-model="description"
|
||||||
class="descInput"
|
class="descInput"
|
||||||
@@ -612,6 +669,36 @@ onUnmounted(() => {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: rgba(251, 191, 36, 0.94);
|
color: rgba(251, 191, 36, 0.94);
|
||||||
}
|
}
|
||||||
|
.thumbComposer {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
width: min(100%, 920px);
|
||||||
|
}
|
||||||
|
.thumbComposer__preview {
|
||||||
|
width: min(100%, 360px);
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
.thumbComposer__image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.thumbComposer__empty {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: rgba(255, 255, 255, 0.62);
|
||||||
|
}
|
||||||
|
.thumbComposer__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
|||||||
Reference in New Issue
Block a user