Compare commits

...

4 Commits

13 changed files with 437 additions and 68 deletions

View File

@@ -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,
} }

View File

@@ -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 })

View File

@@ -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' })

View File

@@ -62,3 +62,7 @@
- 사용자 커스텀 아이템은 수량이 빠르게 커질 수 있으므로, 검색과 페이지네이션을 기본 제공하는 관리 화면으로 보는 방향을 채택했다. - 사용자 커스텀 아이템은 수량이 빠르게 커질 수 있으므로, 검색과 페이지네이션을 기본 제공하는 관리 화면으로 보는 방향을 채택했다.
- 메일 기반 복구가 없으므로, 관리자가 회원 비밀번호를 직접 초기화할 수 있는 기능을 제공하기로 결정했다. - 메일 기반 복구가 없으므로, 관리자가 회원 비밀번호를 직접 초기화할 수 있는 기능을 제공하기로 결정했다.
- 티어표 구조는 게임마다 다르므로, 기본 5단은 시작 템플릿일 뿐 사용자가 행을 직접 추가/삭제할 수 있어야 한다고 결정했다. - 티어표 구조는 게임마다 다르므로, 기본 5단은 시작 템플릿일 뿐 사용자가 행을 직접 추가/삭제할 수 있어야 한다고 결정했다.
## 2026-03-19 v0.1.17
- 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다.
- 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다.

View File

@@ -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`

View File

@@ -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단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
- 작성자는 `내 티어표` 목록에서 저장한 티어표를 직접 삭제할 수 있다.
## 운영 환경 변수 ## 운영 환경 변수
- 프런트엔드 - 프런트엔드

View File

@@ -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` 값을 설정한다.

View File

@@ -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`) 라우트 추가
@@ -91,3 +95,19 @@
- **커스텀 아이템 조회 강화**: 사용자 커스텀 아이템 목록에 파일명 검색, `50/200` 단위 페이지네이션, 다운로드 흐름 추가 - **커스텀 아이템 조회 강화**: 사용자 커스텀 아이템 목록에 파일명 검색, `50/200` 단위 페이지네이션, 다운로드 흐름 추가
- **회원 비밀번호 초기화 추가**: 관리자 페이지와 API에서 회원 비밀번호를 직접 재설정할 수 있도록 기능 추가 - **회원 비밀번호 초기화 추가**: 관리자 페이지와 API에서 회원 비밀번호를 직접 재설정할 수 있도록 기능 추가
- **가변 티어 행 지원**: 티어표 에디터에서 `S~D` 고정 5단이 아니라 티어 행을 직접 추가/삭제할 수 있도록 보강 - **가변 티어 행 지원**: 티어표 에디터에서 `S~D` 고정 5단이 아니라 티어 행을 직접 추가/삭제할 수 있도록 보강
## 2026-03-19 v0.1.14
- **커스텀 아이템 카드 반응형 수정**: 관리자 아이템 관리 탭의 커스텀 아이템 카드에서 이미지 폭을 유동값으로 조정하고, 텍스트 영역에 `min-width: 0`과 강제 줄바꿈 기준을 추가해 카드 바깥 overflow를 방지
## 2026-03-19 v0.1.15
- **셀렉트 화살표 여백 정리**: 전역 `select` 스타일에 커스텀 화살표 위치와 오른쪽 여백을 추가해 텍스트와 화살표가 지나치게 붙지 않도록 조정
- **티어표 다운로드 결과 개선**: `TierEditorView`의 이미지 저장을 Blob 다운로드 방식으로 바꾸고, 캡처 대상을 보드 영역만 포함하는 전용 export 뷰로 분리해 우측 아이템 영역과 편집용 버튼/입력 UI가 저장 이미지에 섞이지 않도록 수정
## 2026-03-19 v0.1.16
- **티어표 헤더 마감 정리**: 제목/설명 입력을 각각 한 줄 폭으로 정리하고, 액션 영역과 분리해 헤더 가독성을 개선
- **export 정보 보강**: 이미지 저장 시 제목 아래에 설명이 함께 표시되도록 보강
- **보드 여백/정렬 정리**: 보드 내부 패딩을 늘리고, 티어 그룹 제목을 중앙 정렬로 조정해 완성본 느낌을 개선
## 2026-03-19 v0.1.17
- **내 티어표 삭제 추가**: `내 티어표` 목록에서 작성자가 자신의 티어표를 직접 삭제할 수 있도록 삭제 버튼과 API를 추가
- **미사용 커스텀 이미지 관리 추가**: 관리자 아이템 탭에서 커스텀 이미지의 사용 횟수를 표시하고, 미사용 항목만 따로 필터링해 개별/일괄 삭제할 수 있도록 보강

View File

@@ -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' }),
} }

View File

@@ -54,6 +54,21 @@ body {
margin: 0; margin: 0;
} }
select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, rgba(255, 255, 255, 0.78) 50%),
linear-gradient(135deg, rgba(255, 255, 255, 0.78) 50%, transparent 50%);
background-position:
calc(100% - 18px) calc(50% - 2px),
calc(100% - 12px) calc(50% - 2px);
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
padding-right: 36px;
}
h1, h1,
h2 { h2 {
font-family: var(--heading); font-family: var(--heading);

View File

@@ -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)
@@ -501,7 +541,15 @@ function fmt(ts) {
</select> </select>
</div> </div>
<div class="hint">현재 목록은 사용자 커스텀 이미지 전용입니다. 여기서 보는 항목은 게임 기본 아이템 삭제와 연결되지 않아요.</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">
@@ -511,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>
@@ -677,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;
@@ -726,6 +782,7 @@ function fmt(ts) {
margin-top: 0; margin-top: 0;
} }
.btn { .btn {
font-size: 12px;
margin-top: 12px; margin-top: 12px;
padding: 10px 12px; padding: 10px 12px;
border-radius: 12px; border-radius: 12px;
@@ -751,9 +808,6 @@ function fmt(ts) {
.btn--ghost { .btn--ghost {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
} }
.btn--small {
width: 100%;
}
.detailHead { .detailHead {
display: flex; display: flex;
gap: 12px; gap: 12px;
@@ -866,7 +920,7 @@ function fmt(ts) {
.customItemGrid { .customItemGrid {
margin-top: 14px; margin-top: 14px;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px; gap: 12px;
} }
.customItemCard { .customItemCard {
@@ -874,27 +928,45 @@ function fmt(ts) {
border-radius: 16px; border-radius: 16px;
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
overflow: hidden; overflow: hidden;
display: flex;
gap: 12px;
align-items: flex-start;
padding: 12px;
min-width: 0;
} }
.customItemCard__image { .customItemCard__image {
width: min(100%, 150px); width: clamp(88px, 22vw, 150px);
flex: 0 1 150px;
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
object-fit: cover; object-fit: cover;
display: block; display: block;
margin: 12px auto 0;
border-radius: 12px; border-radius: 12px;
background: rgba(0, 0, 0, 0.18); background: rgba(0, 0, 0, 0.18);
} }
.customItemCard__body { .customItemCard__body {
display: grid; display: flex;
flex-direction: column;
gap: 6px; gap: 6px;
padding: 12px; min-width: 0;
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;
overflow-wrap: anywhere;
word-break: break-word;
font-weight: 900; font-weight: 900;
} }
.customItemCard__meta { .customItemCard__meta {
opacity: 0.72; opacity: 0.72;
font-size: 13px; font-size: 13px;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word; word-break: break-word;
} }
.pager { .pager {
@@ -963,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,
@@ -979,5 +1054,12 @@ function fmt(ts) {
.userList { .userList {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.customItemCard {
align-items: stretch;
}
.customItemCard__image {
width: clamp(72px, 28vw, 120px);
flex-basis: 120px;
}
} }
</style> </style>

View File

@@ -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>

View File

@@ -30,10 +30,12 @@ const description = ref('')
const isPublic = ref(false) const isPublic = ref(false)
const error = ref('') const error = ref('')
const isSaving = ref(false) const isSaving = ref(false)
const isExporting = ref(false)
const ownerId = ref('') const ownerId = ref('')
const isDragActive = ref(false) const isDragActive = ref(false)
const boardEl = ref(null) const boardEl = ref(null)
const exportBoardEl = ref(null)
const groupListEl = ref(null) const groupListEl = ref(null)
const poolEl = ref(null) const poolEl = ref(null)
const groupDropEls = ref({}) const groupDropEls = ref({})
@@ -211,11 +213,25 @@ function onDropFiles(event) {
async function downloadImage() { async function downloadImage() {
if (!boardEl.value) return if (!boardEl.value) return
const dataUrl = await htmlToImage.toPng(boardEl.value, { pixelRatio: 2, backgroundColor: '#0b1220' }) isExporting.value = true
const a = document.createElement('a') await nextTick()
a.href = dataUrl
a.download = `${(title.value || gameName.value || 'tierlist').trim()}.png` try {
a.click() const targetEl = exportBoardEl.value || boardEl.value
const blob = await htmlToImage.toBlob(targetEl, { pixelRatio: 2, backgroundColor: '#0b1220' })
if (!blob) throw new Error('image_export_failed')
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${(title.value || gameName.value || 'tierlist').trim()}.png`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} finally {
isExporting.value = false
}
} }
async function uploadPendingCustomItems() { async function uploadPendingCustomItems() {
@@ -343,7 +359,7 @@ onUnmounted(() => {
<template> <template>
<section class="head"> <section class="head">
<div> <div class="head__meta">
<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" />
<input <input
@@ -375,15 +391,23 @@ onUnmounted(() => {
<section class="layout"> <section class="layout">
<div ref="boardEl" class="board"> <div ref="boardEl" class="board">
<div v-if="canEdit" class="boardTools"> <div v-if="canEdit && !isExporting" class="boardTools">
<button class="btn btn--ghost" @click="addGroup">티어 추가</button> <button class="btn btn--ghost" @click="addGroup">티어 추가</button>
</div> </div>
<div ref="groupListEl" class="rows"> <div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
<div v-if="isExporting" class="exportBoard__title">{{ title || gameName || gameId }}</div>
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
<div ref="groupListEl" class="rows">
<div v-for="g in groups" :key="g.id" class="row"> <div v-for="g in groups" :key="g.id" class="row">
<div class="row__label"> <div class="row__label">
<span class="grab" title="드래그로 순서 변경" data-group-handle></span> <template v-if="isExporting">
<input v-model="g.name" class="groupName" :readonly="!canEdit" /> <div class="row__exportName">{{ g.name }}</div>
<button v-if="canEdit" class="rowRemoveBtn" :disabled="groups.length <= 1" @click="removeGroup(g.id)">삭제</button> </template>
<template v-else>
<span class="grab" title="드래그로 순서 변경" data-group-handle></span>
<input v-model="g.name" class="groupName" :readonly="!canEdit" />
<button v-if="canEdit" class="rowRemoveBtn" :disabled="groups.length <= 1" @click="removeGroup(g.id)">삭제</button>
</template>
</div> </div>
<div <div
class="row__drop" class="row__drop"
@@ -398,6 +422,7 @@ onUnmounted(() => {
</div> </div>
</div> </div>
</div> </div>
</div>
</div> </div>
<div class="sidebar"> <div class="sidebar">
@@ -431,20 +456,20 @@ onUnmounted(() => {
<style scoped> <style scoped>
.head { .head {
display: flex; display: grid;
gap: 14px; gap: 14px;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
padding: 6px 2px 14px; padding: 6px 2px 14px;
} }
.head__meta {
display: grid;
gap: 10px;
}
.kicker { .kicker {
font-size: 12px; font-size: 12px;
opacity: 0.7; opacity: 0.7;
margin-bottom: 6px;
} }
.titleInput { .titleInput {
width: min(520px, 92vw); width: min(100%, 920px);
font-size: 22px; font-size: 22px;
font-weight: 800; font-weight: 800;
letter-spacing: -0.02em; letter-spacing: -0.02em;
@@ -456,8 +481,7 @@ onUnmounted(() => {
outline: none; outline: none;
} }
.descInput { .descInput {
margin-top: 10px; width: min(100%, 920px);
width: min(520px, 92vw);
padding: 10px 12px; padding: 10px 12px;
border-radius: 14px; border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
@@ -468,13 +492,14 @@ onUnmounted(() => {
} }
.hint { .hint {
opacity: 0.78; opacity: 0.78;
margin-top: 8px;
font-size: 13px; font-size: 13px;
} }
.actions { .actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
} }
.toggle { .toggle {
display: inline-flex; display: inline-flex;
@@ -522,6 +547,7 @@ onUnmounted(() => {
display: grid; display: grid;
grid-template-columns: 1fr 320px; grid-template-columns: 1fr 320px;
gap: 14px; gap: 14px;
align-items: start;
} }
.error { .error {
margin: 10px 0 14px; margin: 10px 0 14px;
@@ -534,12 +560,30 @@ onUnmounted(() => {
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);
border-radius: 16px; border-radius: 16px;
padding: 12px; padding: 20px;
align-self: start;
} }
.boardTools { .boardTools {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-bottom: 10px; margin-bottom: 14px;
}
.exportBoard--active {
display: grid;
gap: 12px;
}
.exportBoard__title {
font-size: 28px;
font-weight: 900;
letter-spacing: -0.03em;
text-align: left;
}
.exportBoard__description {
margin-top: -4px;
font-size: 14px;
line-height: 1.5;
opacity: 0.74;
text-align: left;
} }
.rows { .rows {
display: grid; display: grid;
@@ -558,7 +602,7 @@ onUnmounted(() => {
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: center;
padding: 10px 8px; padding: 10px 8px;
font-weight: 900; font-weight: 900;
overflow: hidden; overflow: hidden;
@@ -583,10 +627,16 @@ onUnmounted(() => {
border-radius: 10px; border-radius: 10px;
padding: 6px 8px; padding: 6px 8px;
font-weight: 900; font-weight: 900;
text-align: left; text-align: center;
outline: none; outline: none;
min-width: 0; min-width: 0;
} }
.row__exportName {
width: 100%;
text-align: center;
font-weight: 900;
word-break: break-word;
}
.rowRemoveBtn { .rowRemoveBtn {
padding: 6px 10px; padding: 6px 10px;
border-radius: 10px; border-radius: 10px;