Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bc1af268f | |||
| 8af2726574 |
@@ -404,25 +404,38 @@ async function createCustomItem({ id, ownerId, src, label }) {
|
|||||||
return { id, ownerId, src, label, origin: 'custom', createdAt }
|
return { id, ownerId, src, label, origin: 'custom', createdAt }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listCustomItems({ queryText = '', page = 1, limit = 50 } = {}) {
|
async function getCustomItemUsageMap() {
|
||||||
|
const rows = await query('SELECT groups_json, pool_json FROM tierlists')
|
||||||
|
const usageMap = new Map()
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const groups = parseJson(row.groups_json, [])
|
||||||
|
const pool = parseJson(row.pool_json, [])
|
||||||
|
|
||||||
|
groups.forEach((group) => {
|
||||||
|
;(group?.itemIds || []).forEach((itemId) => {
|
||||||
|
usageMap.set(itemId, (usageMap.get(itemId) || 0) + 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
pool.forEach((item) => {
|
||||||
|
if (item?.id) {
|
||||||
|
usageMap.set(item.id, (usageMap.get(item.id) || 0) + 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return usageMap
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) {
|
||||||
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||||
const normalizedPage = Math.max(Number(page) || 1, 1)
|
const normalizedPage = Math.max(Number(page) || 1, 1)
|
||||||
const offset = (normalizedPage - 1) * normalizedLimit
|
|
||||||
const hasQuery = !!(queryText || '').trim()
|
const hasQuery = !!(queryText || '').trim()
|
||||||
const search = `%${(queryText || '').trim()}%`
|
const search = `%${(queryText || '').trim()}%`
|
||||||
const whereClause = hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''
|
const whereClause = hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''
|
||||||
const params = hasQuery ? [search, search, search, search] : []
|
const params = hasQuery ? [search, search, search, search] : []
|
||||||
|
|
||||||
const countRows = await query(
|
|
||||||
`
|
|
||||||
SELECT COUNT(*) AS count
|
|
||||||
FROM custom_items c
|
|
||||||
INNER JOIN users u ON u.id = c.owner_id
|
|
||||||
${whereClause}
|
|
||||||
`,
|
|
||||||
params
|
|
||||||
)
|
|
||||||
|
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -437,13 +450,13 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50 } = {}) {
|
|||||||
INNER JOIN users u ON u.id = c.owner_id
|
INNER JOIN users u ON u.id = c.owner_id
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY c.created_at DESC
|
ORDER BY c.created_at DESC
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
`,
|
`,
|
||||||
[...params, normalizedLimit, offset]
|
params
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
const usageMap = await getCustomItemUsageMap()
|
||||||
items: rows.map((row) => ({
|
const allItems = rows
|
||||||
|
.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
ownerId: row.owner_id,
|
ownerId: row.owner_id,
|
||||||
src: row.src,
|
src: row.src,
|
||||||
@@ -451,13 +464,61 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50 } = {}) {
|
|||||||
createdAt: Number(row.created_at),
|
createdAt: Number(row.created_at),
|
||||||
ownerName: row.nickname || row.email,
|
ownerName: row.nickname || row.email,
|
||||||
ownerEmail: row.email,
|
ownerEmail: row.email,
|
||||||
})),
|
usageCount: usageMap.get(row.id) || 0,
|
||||||
total: Number(countRows[0]?.count || 0),
|
}))
|
||||||
|
.filter((item) => (orphanOnly ? item.usageCount === 0 : true))
|
||||||
|
|
||||||
|
const total = allItems.length
|
||||||
|
const offset = (normalizedPage - 1) * normalizedLimit
|
||||||
|
const pagedItems = allItems.slice(offset, offset + normalizedLimit)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: pagedItems,
|
||||||
|
total,
|
||||||
page: normalizedPage,
|
page: normalizedPage,
|
||||||
limit: normalizedLimit,
|
limit: normalizedLimit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findUnusedCustomItems({ queryText = '' } = {}) {
|
||||||
|
const hasQuery = !!(queryText || '').trim()
|
||||||
|
const search = `%${(queryText || '').trim()}%`
|
||||||
|
const whereClause = hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''
|
||||||
|
const params = hasQuery ? [search, search, search, search] : []
|
||||||
|
|
||||||
|
const rows = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.owner_id,
|
||||||
|
c.src,
|
||||||
|
c.label,
|
||||||
|
c.created_at,
|
||||||
|
u.nickname,
|
||||||
|
u.email
|
||||||
|
FROM custom_items c
|
||||||
|
INNER JOIN users u ON u.id = c.owner_id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY c.created_at DESC
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
|
||||||
|
const usageMap = await getCustomItemUsageMap()
|
||||||
|
return rows
|
||||||
|
.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
ownerId: row.owner_id,
|
||||||
|
src: row.src,
|
||||||
|
label: row.label,
|
||||||
|
createdAt: Number(row.created_at),
|
||||||
|
ownerName: row.nickname || row.email,
|
||||||
|
ownerEmail: row.email,
|
||||||
|
usageCount: usageMap.get(row.id) || 0,
|
||||||
|
}))
|
||||||
|
.filter((item) => item.usageCount === 0)
|
||||||
|
}
|
||||||
|
|
||||||
async function listPublicTierLists(gameId) {
|
async function listPublicTierLists(gameId) {
|
||||||
const params = []
|
const params = []
|
||||||
let whereClause = 'WHERE t.is_public = 1'
|
let whereClause = 'WHERE t.is_public = 1'
|
||||||
@@ -531,6 +592,37 @@ async function findTierListById(id) {
|
|||||||
return mapTierListRow(rows[0])
|
return mapTierListRow(rows[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteTierList(id) {
|
||||||
|
await query('DELETE FROM tierlists WHERE id = ?', [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findCustomItemsByIds(ids) {
|
||||||
|
if (!ids.length) return []
|
||||||
|
const placeholders = ids.map(() => '?').join(', ')
|
||||||
|
const rows = await query(
|
||||||
|
`
|
||||||
|
SELECT id, owner_id, src, label, created_at
|
||||||
|
FROM custom_items
|
||||||
|
WHERE id IN (${placeholders})
|
||||||
|
`,
|
||||||
|
ids
|
||||||
|
)
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
ownerId: row.owner_id,
|
||||||
|
src: row.src,
|
||||||
|
label: row.label,
|
||||||
|
createdAt: Number(row.created_at),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCustomItems(ids) {
|
||||||
|
if (!ids.length) return
|
||||||
|
const placeholders = ids.map(() => '?').join(', ')
|
||||||
|
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, description, isPublic, groups, pool }) {
|
||||||
const existing = id ? await findTierListById(id) : null
|
const existing = id ? await findTierListById(id) : null
|
||||||
|
|
||||||
@@ -582,8 +674,12 @@ module.exports = {
|
|||||||
deleteGame,
|
deleteGame,
|
||||||
createCustomItem,
|
createCustomItem,
|
||||||
listCustomItems,
|
listCustomItems,
|
||||||
|
findUnusedCustomItems,
|
||||||
listPublicTierLists,
|
listPublicTierLists,
|
||||||
listUserTierLists,
|
listUserTierLists,
|
||||||
findTierListById,
|
findTierListById,
|
||||||
|
deleteTierList,
|
||||||
|
findCustomItemsByIds,
|
||||||
|
deleteCustomItems,
|
||||||
saveTierList,
|
saveTierList,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const fs = require('fs/promises')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const express = require('express')
|
const express = require('express')
|
||||||
const multer = require('multer')
|
const multer = require('multer')
|
||||||
@@ -13,6 +14,9 @@ const {
|
|||||||
deleteGameItem,
|
deleteGameItem,
|
||||||
deleteGame,
|
deleteGame,
|
||||||
listCustomItems,
|
listCustomItems,
|
||||||
|
findUnusedCustomItems,
|
||||||
|
findCustomItemsByIds,
|
||||||
|
deleteCustomItems,
|
||||||
listUsers,
|
listUsers,
|
||||||
adminUpdateUser,
|
adminUpdateUser,
|
||||||
adminUpdateUserPassword,
|
adminUpdateUserPassword,
|
||||||
@@ -89,6 +93,11 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
|||||||
q: z.string().trim().max(120).optional().default(''),
|
q: z.string().trim().max(120).optional().default(''),
|
||||||
page: z.coerce.number().int().min(1).optional().default(1),
|
page: z.coerce.number().int().min(1).optional().default(1),
|
||||||
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
||||||
|
orphanOnly: z
|
||||||
|
.union([z.literal('true'), z.literal('false'), z.boolean()])
|
||||||
|
.optional()
|
||||||
|
.default('false')
|
||||||
|
.transform((value) => value === true || value === 'true'),
|
||||||
})
|
})
|
||||||
const parsed = schema.safeParse(req.query)
|
const parsed = schema.safeParse(req.query)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
@@ -97,10 +106,51 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
|||||||
queryText: parsed.data.q,
|
queryText: parsed.data.q,
|
||||||
page: parsed.data.page,
|
page: parsed.data.page,
|
||||||
limit: parsed.data.limit,
|
limit: parsed.data.limit,
|
||||||
|
orphanOnly: parsed.data.orphanOnly,
|
||||||
})
|
})
|
||||||
res.json(result)
|
res.json(result)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function removeCustomItemFiles(items) {
|
||||||
|
await Promise.all(
|
||||||
|
items.map(async (item) => {
|
||||||
|
if (!item?.src || !item.src.startsWith('/uploads/custom/')) return
|
||||||
|
const absolutePath = path.join(__dirname, '..', '..', item.src.replace(/^\//, ''))
|
||||||
|
try {
|
||||||
|
await fs.unlink(absolutePath)
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.code !== 'ENOENT') throw e
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||||
|
const result = await listCustomItems({ page: 1, limit: 200, orphanOnly: false })
|
||||||
|
const target = result.items.find((item) => item.id === req.params.itemId)
|
||||||
|
if (!target) return res.status(404).json({ error: 'not_found' })
|
||||||
|
if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
|
||||||
|
|
||||||
|
const items = await findCustomItemsByIds([target.id])
|
||||||
|
await deleteCustomItems([target.id])
|
||||||
|
await removeCustomItemFiles(items)
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/custom-items', requireAdmin, async (req, res) => {
|
||||||
|
const schema = z.object({
|
||||||
|
q: z.string().trim().max(120).optional().default(''),
|
||||||
|
})
|
||||||
|
const parsed = schema.safeParse(req.query)
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
|
const items = await findUnusedCustomItems({ queryText: parsed.data.q })
|
||||||
|
const ids = items.map((item) => item.id)
|
||||||
|
await deleteCustomItems(ids)
|
||||||
|
await removeCustomItemFiles(items)
|
||||||
|
res.json({ ok: true, deletedCount: ids.length })
|
||||||
|
})
|
||||||
|
|
||||||
router.get('/users', requireAdmin, async (req, res) => {
|
router.get('/users', requireAdmin, async (req, res) => {
|
||||||
const users = await listUsers()
|
const users = await listUsers()
|
||||||
res.json({ users })
|
res.json({ users })
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const {
|
|||||||
findTierListById,
|
findTierListById,
|
||||||
listPublicTierLists,
|
listPublicTierLists,
|
||||||
listUserTierLists,
|
listUserTierLists,
|
||||||
|
deleteTierList,
|
||||||
saveTierList,
|
saveTierList,
|
||||||
createCustomItem,
|
createCustomItem,
|
||||||
} = require('../db')
|
} = require('../db')
|
||||||
@@ -94,6 +95,15 @@ router.get('/:id', async (req, res) => {
|
|||||||
res.json({ tierList: normalizeTierList(t) })
|
res.json({ tierList: normalizeTierList(t) })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.delete('/:id', requireAuth, async (req, res) => {
|
||||||
|
const tierList = await findTierListById(req.params.id)
|
||||||
|
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||||
|
if (tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
|
||||||
|
|
||||||
|
await deleteTierList(tierList.id)
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
router.post('/custom-items', requireAuth, upload.single('image'), async (req, res) => {
|
router.post('/custom-items', requireAuth, upload.single('image'), async (req, res) => {
|
||||||
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
if (!req.file) return res.status(400).json({ error: 'file_required' })
|
||||||
|
|
||||||
|
|||||||
@@ -62,3 +62,7 @@
|
|||||||
- 사용자 커스텀 아이템은 수량이 빠르게 커질 수 있으므로, 검색과 페이지네이션을 기본 제공하는 관리 화면으로 보는 방향을 채택했다.
|
- 사용자 커스텀 아이템은 수량이 빠르게 커질 수 있으므로, 검색과 페이지네이션을 기본 제공하는 관리 화면으로 보는 방향을 채택했다.
|
||||||
- 메일 기반 복구가 없으므로, 관리자가 회원 비밀번호를 직접 초기화할 수 있는 기능을 제공하기로 결정했다.
|
- 메일 기반 복구가 없으므로, 관리자가 회원 비밀번호를 직접 초기화할 수 있는 기능을 제공하기로 결정했다.
|
||||||
- 티어표 구조는 게임마다 다르므로, 기본 5단은 시작 템플릿일 뿐 사용자가 행을 직접 추가/삭제할 수 있어야 한다고 결정했다.
|
- 티어표 구조는 게임마다 다르므로, 기본 5단은 시작 템플릿일 뿐 사용자가 행을 직접 추가/삭제할 수 있어야 한다고 결정했다.
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.17
|
||||||
|
- 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다.
|
||||||
|
- 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다.
|
||||||
|
|||||||
@@ -22,13 +22,13 @@
|
|||||||
|
|
||||||
## `/me`
|
## `/me`
|
||||||
- 화면 파일: `frontend/src/views/MyTierListsView.vue`
|
- 화면 파일: `frontend/src/views/MyTierListsView.vue`
|
||||||
- 역할: 내 티어표 목록 조회, 편집 화면으로 이동
|
- 역할: 내 티어표 목록 조회, 편집 화면으로 이동, 작성자 본인 티어표 삭제
|
||||||
- 연동 API: `GET /api/tierlists/me`
|
- 연동 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`, `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`, `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`
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
- `GET /api/tierlists/public`
|
- `GET /api/tierlists/public`
|
||||||
- `GET /api/tierlists/me`
|
- `GET /api/tierlists/me`
|
||||||
- `GET /api/tierlists/:id`
|
- `GET /api/tierlists/:id`
|
||||||
|
- `DELETE /api/tierlists/:id`
|
||||||
- `POST /api/tierlists/custom-items`
|
- `POST /api/tierlists/custom-items`
|
||||||
- `POST /api/tierlists`
|
- `POST /api/tierlists`
|
||||||
- 관리자
|
- 관리자
|
||||||
@@ -81,6 +82,8 @@
|
|||||||
- `POST /api/admin/games/:gameId/thumbnail`
|
- `POST /api/admin/games/:gameId/thumbnail`
|
||||||
- `POST /api/admin/games/:gameId/images`
|
- `POST /api/admin/games/:gameId/images`
|
||||||
- `GET /api/admin/custom-items`
|
- `GET /api/admin/custom-items`
|
||||||
|
- `DELETE /api/admin/custom-items/:itemId`
|
||||||
|
- `DELETE /api/admin/custom-items`
|
||||||
- `GET /api/admin/users`
|
- `GET /api/admin/users`
|
||||||
- `PATCH /api/admin/users/:userId`
|
- `PATCH /api/admin/users/:userId`
|
||||||
- `PATCH /api/admin/users/:userId/password`
|
- `PATCH /api/admin/users/:userId/password`
|
||||||
@@ -94,6 +97,7 @@
|
|||||||
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
|
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
|
||||||
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
|
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
|
||||||
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
||||||
|
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
|
||||||
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
|
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
|
||||||
|
|
||||||
## 티어표 접근 메모
|
## 티어표 접근 메모
|
||||||
@@ -101,6 +105,7 @@
|
|||||||
- 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다.
|
- 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다.
|
||||||
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
|
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
|
||||||
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
|
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
|
||||||
|
- 작성자는 `내 티어표` 목록에서 저장한 티어표를 직접 삭제할 수 있다.
|
||||||
|
|
||||||
## 운영 환경 변수
|
## 운영 환경 변수
|
||||||
- 프런트엔드
|
- 프런트엔드
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
## 즉시 확인 필요
|
## 즉시 확인 필요
|
||||||
- 사용자 커스텀 아이템을 관리자 기본 템플릿으로 승격하는 승인/복제 흐름은 아직 없다.
|
- 사용자 커스텀 아이템을 관리자 기본 템플릿으로 승격하는 승인/복제 흐름은 아직 없다.
|
||||||
- 회원 관리에서 아바타/작성 티어표 수/최근 활동 같은 보조 정보는 아직 표시하지 않는다.
|
- 회원 관리에서 아바타/작성 티어표 수/최근 활동 같은 보조 정보는 아직 표시하지 않는다.
|
||||||
|
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
|
||||||
|
|
||||||
## 배포 전 작업
|
## 배포 전 작업
|
||||||
- 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-19 v0.1.18
|
||||||
|
- **미사용 아이콘 필터 수정**: 관리자 아이템 관리의 `미사용 아이콘 보기` 체크 상태가 실제 API 요청의 `orphanOnly` 파라미터로 전달되도록 수정
|
||||||
|
- **삭제 활성화 흐름 정상화**: 미사용 아이콘만 조회했을 때 `usageCount = 0` 항목의 개별 삭제 버튼이 의도대로 활성화되도록 정리
|
||||||
|
|
||||||
## 2026-03-19 v0.1.0
|
## 2026-03-19 v0.1.0
|
||||||
- **초기 스캐폴딩**: `frontend/`에 Vue3(Vite, JavaScript) 프로젝트 생성
|
- **초기 스캐폴딩**: `frontend/`에 Vue3(Vite, JavaScript) 프로젝트 생성
|
||||||
- **라우팅/화면 골격**: 게임 선택(`/`), 게임 허브(`/games/:gameId`), 에디터(`/editor/:gameId/...`), 로그인(`/login`), 내 티어표(`/me`), 관리자(`/admin`) 라우트 추가
|
- **라우팅/화면 골격**: 게임 선택(`/`), 게임 허브(`/games/:gameId`), 에디터(`/editor/:gameId/...`), 로그인(`/login`), 내 티어표(`/me`), 관리자(`/admin`) 라우트 추가
|
||||||
@@ -103,3 +107,7 @@
|
|||||||
- **티어표 헤더 마감 정리**: 제목/설명 입력을 각각 한 줄 폭으로 정리하고, 액션 영역과 분리해 헤더 가독성을 개선
|
- **티어표 헤더 마감 정리**: 제목/설명 입력을 각각 한 줄 폭으로 정리하고, 액션 영역과 분리해 헤더 가독성을 개선
|
||||||
- **export 정보 보강**: 이미지 저장 시 제목 아래에 설명이 함께 표시되도록 보강
|
- **export 정보 보강**: 이미지 저장 시 제목 아래에 설명이 함께 표시되도록 보강
|
||||||
- **보드 여백/정렬 정리**: 보드 내부 패딩을 늘리고, 티어 그룹 제목을 중앙 정렬로 조정해 완성본 느낌을 개선
|
- **보드 여백/정렬 정리**: 보드 내부 패딩을 늘리고, 티어 그룹 제목을 중앙 정렬로 조정해 완성본 느낌을 개선
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.17
|
||||||
|
- **내 티어표 삭제 추가**: `내 티어표` 목록에서 작성자가 자신의 티어표를 직접 삭제할 수 있도록 삭제 버튼과 API를 추가
|
||||||
|
- **미사용 커스텀 이미지 관리 추가**: 관리자 아이템 탭에서 커스텀 이미지의 사용 횟수를 표시하고, 미사용 항목만 따로 필터링해 개별/일괄 삭제할 수 있도록 보강
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ 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)}`),
|
||||||
listAdminCustomItems: ({ q = '', page = 1, limit = 50 } = {}) =>
|
listAdminCustomItems: ({ q = '', page = 1, limit = 50, orphanOnly = false } = {}) =>
|
||||||
request(`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
|
request(
|
||||||
|
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}`
|
||||||
|
),
|
||||||
listAdminUsers: () => request('/api/admin/users'),
|
listAdminUsers: () => request('/api/admin/users'),
|
||||||
updateAdminUser: (userId, payload) =>
|
updateAdminUser: (userId, payload) =>
|
||||||
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
|
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
|
||||||
@@ -45,5 +47,9 @@ export const api = {
|
|||||||
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),
|
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),
|
||||||
listMyTierLists: () => request('/api/tierlists/me'),
|
listMyTierLists: () => request('/api/tierlists/me'),
|
||||||
getTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`),
|
getTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`),
|
||||||
|
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 }),
|
||||||
|
deleteAdminCustomItem: (itemId) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}`, { method: 'DELETE' }),
|
||||||
|
deleteAdminUnusedCustomItems: ({ q = '' } = {}) =>
|
||||||
|
request(`/api/admin/custom-items?q=${encodeURIComponent(q)}`, { method: 'DELETE' }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const customItemQuery = ref('')
|
|||||||
const customItemPage = ref(1)
|
const customItemPage = ref(1)
|
||||||
const customItemLimit = ref(50)
|
const customItemLimit = ref(50)
|
||||||
const customItemTotal = ref(0)
|
const customItemTotal = ref(0)
|
||||||
|
const customItemOrphanOnly = ref(false)
|
||||||
|
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ async function refreshCustomItems() {
|
|||||||
q: customItemQuery.value,
|
q: customItemQuery.value,
|
||||||
page: customItemPage.value,
|
page: customItemPage.value,
|
||||||
limit: customItemLimit.value,
|
limit: customItemLimit.value,
|
||||||
|
orphanOnly: customItemOrphanOnly.value,
|
||||||
})
|
})
|
||||||
customItems.value = data.items || []
|
customItems.value = data.items || []
|
||||||
customItemTotal.value = data.total || 0
|
customItemTotal.value = data.total || 0
|
||||||
@@ -348,6 +350,11 @@ function submitCustomItemSearch() {
|
|||||||
refreshCustomItems()
|
refreshCustomItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleCustomItemOrphanOnly() {
|
||||||
|
customItemPage.value = 1
|
||||||
|
refreshCustomItems()
|
||||||
|
}
|
||||||
|
|
||||||
function changeCustomItemLimit(limit) {
|
function changeCustomItemLimit(limit) {
|
||||||
customItemLimit.value = limit
|
customItemLimit.value = limit
|
||||||
customItemPage.value = 1
|
customItemPage.value = 1
|
||||||
@@ -361,6 +368,39 @@ function moveCustomItemPage(direction) {
|
|||||||
refreshCustomItems()
|
refreshCustomItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeCustomItem(item) {
|
||||||
|
resetMessages()
|
||||||
|
if (item.usageCount > 0) {
|
||||||
|
error.value = '사용 중인 커스텀 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = window.confirm(`"${item.label}" 미사용 커스텀 이미지를 삭제할까요?`)
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.deleteAdminCustomItem(item.id)
|
||||||
|
await refreshCustomItems()
|
||||||
|
success.value = '미사용 커스텀 이미지를 삭제했어요.'
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '커스텀 이미지 삭제에 실패했어요.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUnusedCustomItems() {
|
||||||
|
resetMessages()
|
||||||
|
const ok = window.confirm('현재 검색 조건에 맞는 미사용 커스텀 이미지를 모두 삭제할까요?')
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.deleteAdminUnusedCustomItems({ q: customItemQuery.value })
|
||||||
|
await refreshCustomItems()
|
||||||
|
success.value = `${data.deletedCount || 0}개의 미사용 커스텀 이미지를 삭제했어요.`
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '미사용 커스텀 이미지 일괄 삭제에 실패했어요.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const displayThumbnailUrl = computed(() => {
|
const displayThumbnailUrl = computed(() => {
|
||||||
if (thumbPreviewUrl.value) return thumbPreviewUrl.value
|
if (thumbPreviewUrl.value) return thumbPreviewUrl.value
|
||||||
if (selectedGame.value?.game?.thumbnailSrc) return toApiUrl(selectedGame.value.game.thumbnailSrc)
|
if (selectedGame.value?.game?.thumbnailSrc) return toApiUrl(selectedGame.value.game.thumbnailSrc)
|
||||||
@@ -497,10 +537,20 @@ function fmt(ts) {
|
|||||||
<button class="btn btn--ghost toolbar__button" @click="submitCustomItemSearch">검색</button>
|
<button class="btn btn--ghost toolbar__button" @click="submitCustomItemSearch">검색</button>
|
||||||
<select :value="customItemLimit" class="select toolbar__select" @change="changeCustomItemLimit(Number($event.target.value))">
|
<select :value="customItemLimit" class="select toolbar__select" @change="changeCustomItemLimit(Number($event.target.value))">
|
||||||
<option :value="50">50개씩 보기</option>
|
<option :value="50">50개씩 보기</option>
|
||||||
<option :value="100">100개씩 보기</option>
|
<option :value="200">200개씩 보기</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar toolbar--secondary">
|
||||||
|
<label class="checkRow checkRow--toolbar">
|
||||||
|
<input v-model="customItemOrphanOnly" type="checkbox" @change="toggleCustomItemOrphanOnly" />
|
||||||
|
<span>미사용 커스텀 이미지만 보기</span>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--danger toolbar__button" :disabled="!customItems.length" @click="removeUnusedCustomItems">
|
||||||
|
미사용 이미지 일괄 삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="!customItems.length" class="hint">조건에 맞는 커스텀 아이템이 없어요.</div>
|
<div v-if="!customItems.length" class="hint">조건에 맞는 커스텀 아이템이 없어요.</div>
|
||||||
<div v-else class="customItemGrid">
|
<div v-else class="customItemGrid">
|
||||||
<article v-for="item in customItems" :key="item.id" class="customItemCard">
|
<article v-for="item in customItems" :key="item.id" class="customItemCard">
|
||||||
@@ -509,8 +559,12 @@ function fmt(ts) {
|
|||||||
<div class="customItemCard__title">{{ item.label }}</div>
|
<div class="customItemCard__title">{{ item.label }}</div>
|
||||||
<div class="customItemCard__meta">파일: {{ item.src.split('/').pop() }}</div>
|
<div class="customItemCard__meta">파일: {{ item.src.split('/').pop() }}</div>
|
||||||
<div class="customItemCard__meta">업로더: {{ item.ownerName }}</div>
|
<div class="customItemCard__meta">업로더: {{ item.ownerName }}</div>
|
||||||
|
<div class="customItemCard__meta">사용 중: {{ item.usageCount }}개 티어표</div>
|
||||||
<div class="customItemCard__meta">{{ fmt(item.createdAt) }}</div>
|
<div class="customItemCard__meta">{{ fmt(item.createdAt) }}</div>
|
||||||
<a class="btn btn--small btn--ghost" :href="toApiUrl(item.src)" :download="item.label">이미지 다운로드</a>
|
<div class="customItemCard__actions">
|
||||||
|
<a class="btn btn--small btn--ghost" :href="toApiUrl(item.src)" :download="item.label">이미지 다운로드</a>
|
||||||
|
<button class="btn btn--small btn--danger" :disabled="item.usageCount > 0" @click="removeCustomItem(item)">개별 삭제</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -675,6 +729,10 @@ function fmt(ts) {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
.toolbar--secondary {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
.toolbar__search,
|
.toolbar__search,
|
||||||
.toolbar__select {
|
.toolbar__select {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -892,6 +950,12 @@ function fmt(ts) {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
.customItemCard__actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
.customItemCard__title {
|
.customItemCard__title {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
@@ -971,6 +1035,9 @@ function fmt(ts) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
opacity: 0.88;
|
opacity: 0.88;
|
||||||
}
|
}
|
||||||
|
.checkRow--toolbar {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.section--topGrid,
|
.section--topGrid,
|
||||||
.toolbar,
|
.toolbar,
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ onMounted(async () => {
|
|||||||
function openList(t) {
|
function openList(t) {
|
||||||
router.push(`/editor/${t.gameId}/${t.id}`)
|
router.push(`/editor/${t.gameId}/${t.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeList(t) {
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const ok = window.confirm(`"${t.title}" 티어표를 삭제할까요?`)
|
||||||
|
if (!ok) return
|
||||||
|
await api.deleteTierList(t.id)
|
||||||
|
myLists.value = myLists.value.filter((entry) => entry.id !== t.id)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '티어표 삭제에 실패했어요.'
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -42,12 +54,15 @@ function openList(t) {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
|
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
|
||||||
<div v-else class="list">
|
<div v-else class="list">
|
||||||
<button v-for="t in myLists" :key="t.id" class="row" @click="openList(t)">
|
<article v-for="t in myLists" :key="t.id" class="row">
|
||||||
<div class="row__title">{{ t.title }}</div>
|
<button class="row__body" @click="openList(t)">
|
||||||
<div class="row__meta">
|
<div class="row__title">{{ t.title }}</div>
|
||||||
{{ t.gameId }} · 저장: {{ fmt(t.createdAt || t.updatedAt) }} · 업데이트: {{ fmt(t.updatedAt) }}
|
<div class="row__meta">
|
||||||
</div>
|
{{ t.gameId }} · 저장: {{ fmt(t.createdAt || t.updatedAt) }} · 업데이트: {{ fmt(t.updatedAt) }}
|
||||||
</button>
|
</div>
|
||||||
|
</button>
|
||||||
|
<button class="link link--danger" @click="removeList(t)">삭제</button>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -96,14 +111,26 @@ function openList(t) {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.row {
|
.row {
|
||||||
text-align: left;
|
display: flex;
|
||||||
cursor: pointer;
|
gap: 10px;
|
||||||
padding: 12px;
|
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);
|
||||||
}
|
}
|
||||||
|
.row__body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
.row__title {
|
.row__title {
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
@@ -112,5 +139,8 @@ function openList(t) {
|
|||||||
opacity: 0.76;
|
opacity: 0.76;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
.link--danger {
|
||||||
|
background: rgba(239, 68, 68, 0.14);
|
||||||
|
border-color: rgba(239, 68, 68, 0.28);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user