Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bd9751621 | |||
| b9aa714501 | |||
| 6ddc82b1c7 | |||
| 94152f22b2 | |||
| 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 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ function mapTierListRow(row) {
|
|||||||
authorAccountName: getUserAccountName(row),
|
authorAccountName: getUserAccountName(row),
|
||||||
authorAvatarSrc: row.avatar_src || '',
|
authorAvatarSrc: row.avatar_src || '',
|
||||||
gameId: row.game_id,
|
gameId: row.game_id,
|
||||||
|
gameName: row.game_name || '',
|
||||||
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 +197,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 +212,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 +405,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
|
||||||
@@ -459,6 +473,28 @@ 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 findCustomItemById(id) {
|
||||||
|
const rows = await query(
|
||||||
|
`
|
||||||
|
SELECT id, owner_id, src, label, created_at
|
||||||
|
FROM custom_items
|
||||||
|
WHERE id = ?
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
if (!row) return null
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
ownerId: row.owner_id,
|
||||||
|
src: row.src,
|
||||||
|
label: row.label,
|
||||||
|
createdAt: Number(row.created_at),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getCustomItemUsageMap() {
|
async function getCustomItemUsageMap() {
|
||||||
const rows = await query('SELECT groups_json, pool_json FROM tierlists')
|
const rows = await query('SELECT groups_json, pool_json FROM tierlists')
|
||||||
const usageMap = new Map()
|
const usageMap = new Map()
|
||||||
@@ -588,6 +624,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 +644,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 +661,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 +680,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,
|
||||||
@@ -650,14 +690,46 @@ async function listUserTierLists(userId) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findTierListById(id) {
|
function uniqueTierListItems(poolItems) {
|
||||||
|
const map = new Map()
|
||||||
|
;(poolItems || []).forEach((item) => {
|
||||||
|
if (!item?.id || map.has(item.id)) return
|
||||||
|
map.set(item.id, {
|
||||||
|
id: item.id,
|
||||||
|
src: item.src || '',
|
||||||
|
label: item.label || 'item',
|
||||||
|
origin: item.origin || 'game',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return Array.from(map.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAdminTierLists({ queryText = '', page = 1, limit = 50 } = {}) {
|
||||||
|
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||||
|
const normalizedPage = Math.max(Number(page) || 1, 1)
|
||||||
|
const hasQuery = !!(queryText || '').trim()
|
||||||
|
const search = `%${(queryText || '').trim()}%`
|
||||||
|
const whereClause = hasQuery
|
||||||
|
? `
|
||||||
|
WHERE
|
||||||
|
t.title LIKE ?
|
||||||
|
OR g.name LIKE ?
|
||||||
|
OR g.id LIKE ?
|
||||||
|
OR u.email LIKE ?
|
||||||
|
OR u.nickname LIKE ?
|
||||||
|
`
|
||||||
|
: ''
|
||||||
|
const params = hasQuery ? [search, search, search, search, search] : []
|
||||||
|
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
t.id,
|
t.id,
|
||||||
t.author_id,
|
t.author_id,
|
||||||
t.game_id,
|
t.game_id,
|
||||||
|
g.name AS game_name,
|
||||||
t.title,
|
t.title,
|
||||||
|
t.thumbnail_src,
|
||||||
t.description,
|
t.description,
|
||||||
t.is_public,
|
t.is_public,
|
||||||
t.groups_json,
|
t.groups_json,
|
||||||
@@ -669,6 +741,57 @@ async function findTierListById(id) {
|
|||||||
u.avatar_src
|
u.avatar_src
|
||||||
FROM tierlists t
|
FROM tierlists t
|
||||||
INNER JOIN users u ON u.id = t.author_id
|
INNER JOIN users u ON u.id = t.author_id
|
||||||
|
INNER JOIN games g ON g.id = t.game_id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY t.updated_at DESC, t.created_at DESC
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
|
||||||
|
const allItems = rows.map((row) => {
|
||||||
|
const tierList = mapTierListRow(row)
|
||||||
|
const poolItems = uniqueTierListItems(tierList.pool)
|
||||||
|
const extraItems = poolItems.filter((item) => item.origin === 'custom')
|
||||||
|
return {
|
||||||
|
...tierList,
|
||||||
|
itemCount: poolItems.length,
|
||||||
|
extraItemCount: extraItems.length,
|
||||||
|
extraItems,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const total = allItems.length
|
||||||
|
const offset = (normalizedPage - 1) * normalizedLimit
|
||||||
|
return {
|
||||||
|
tierLists: allItems.slice(offset, offset + normalizedLimit),
|
||||||
|
total,
|
||||||
|
page: normalizedPage,
|
||||||
|
limit: normalizedLimit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findTierListById(id) {
|
||||||
|
const rows = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.author_id,
|
||||||
|
t.game_id,
|
||||||
|
g.name AS game_name,
|
||||||
|
t.title,
|
||||||
|
t.thumbnail_src,
|
||||||
|
t.description,
|
||||||
|
t.is_public,
|
||||||
|
t.groups_json,
|
||||||
|
t.pool_json,
|
||||||
|
t.created_at,
|
||||||
|
t.updated_at,
|
||||||
|
u.nickname,
|
||||||
|
u.email,
|
||||||
|
u.avatar_src
|
||||||
|
FROM tierlists t
|
||||||
|
INNER JOIN users u ON u.id = t.author_id
|
||||||
|
INNER JOIN games g ON g.id = t.game_id
|
||||||
WHERE t.id = ?
|
WHERE t.id = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`,
|
`,
|
||||||
@@ -708,17 +831,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 +850,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,14 +878,17 @@ module.exports = {
|
|||||||
createGame,
|
createGame,
|
||||||
updateGameThumbnail,
|
updateGameThumbnail,
|
||||||
createGameItem,
|
createGameItem,
|
||||||
|
updateGameItemLabel,
|
||||||
deleteGameItem,
|
deleteGameItem,
|
||||||
deleteGame,
|
deleteGame,
|
||||||
updateGameDisplayOrder,
|
updateGameDisplayOrder,
|
||||||
createCustomItem,
|
createCustomItem,
|
||||||
|
findCustomItemById,
|
||||||
listCustomItems,
|
listCustomItems,
|
||||||
findUnusedCustomItems,
|
findUnusedCustomItems,
|
||||||
listPublicTierLists,
|
listPublicTierLists,
|
||||||
listUserTierLists,
|
listUserTierLists,
|
||||||
|
listAdminTierLists,
|
||||||
findTierListById,
|
findTierListById,
|
||||||
deleteTierList,
|
deleteTierList,
|
||||||
findCustomItemsByIds,
|
findCustomItemsByIds,
|
||||||
|
|||||||
@@ -12,14 +12,18 @@ const {
|
|||||||
listGames,
|
listGames,
|
||||||
updateGameThumbnail,
|
updateGameThumbnail,
|
||||||
createGameItem,
|
createGameItem,
|
||||||
|
updateGameItemLabel,
|
||||||
deleteGameItem,
|
deleteGameItem,
|
||||||
deleteGame,
|
deleteGame,
|
||||||
updateGameDisplayOrder,
|
updateGameDisplayOrder,
|
||||||
listCustomItems,
|
listCustomItems,
|
||||||
|
findCustomItemById,
|
||||||
findUnusedCustomItems,
|
findUnusedCustomItems,
|
||||||
findCustomItemsByIds,
|
findCustomItemsByIds,
|
||||||
deleteCustomItems,
|
deleteCustomItems,
|
||||||
listUsers,
|
listUsers,
|
||||||
|
listAdminTierLists,
|
||||||
|
findTierListById,
|
||||||
adminUpdateUser,
|
adminUpdateUser,
|
||||||
adminUpdateUserPassword,
|
adminUpdateUserPassword,
|
||||||
adminDeleteUser,
|
adminDeleteUser,
|
||||||
@@ -116,6 +120,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' })
|
||||||
@@ -146,6 +163,23 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
|||||||
res.json(result)
|
res.json(result)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||||
|
const schema = z.object({
|
||||||
|
q: z.string().trim().max(120).optional().default(''),
|
||||||
|
page: z.coerce.number().int().min(1).optional().default(1),
|
||||||
|
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
||||||
|
})
|
||||||
|
const parsed = schema.safeParse(req.query)
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
|
const result = await listAdminTierLists({
|
||||||
|
queryText: parsed.data.q,
|
||||||
|
page: parsed.data.page,
|
||||||
|
limit: parsed.data.limit,
|
||||||
|
})
|
||||||
|
res.json(result)
|
||||||
|
})
|
||||||
|
|
||||||
async function removeCustomItemFiles(items) {
|
async function removeCustomItemFiles(items) {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
items.map(async (item) => {
|
items.map(async (item) => {
|
||||||
@@ -160,6 +194,89 @@ async function removeCustomItemFiles(items) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function promoteCustomItemToGameItem({ customItem, gameId }) {
|
||||||
|
const originalName = path.basename(customItem.src || '')
|
||||||
|
const nextFilename = buildUploadFilename({ originalname: originalName })
|
||||||
|
const sourcePath = path.join(__dirname, '..', '..', customItem.src.replace(/^\//, ''))
|
||||||
|
const targetRelativePath = path.join('uploads', 'games', nextFilename)
|
||||||
|
const targetPath = path.join(__dirname, '..', '..', targetRelativePath)
|
||||||
|
|
||||||
|
await fs.copyFile(sourcePath, targetPath)
|
||||||
|
|
||||||
|
return createGameItem({
|
||||||
|
id: nanoid(),
|
||||||
|
gameId,
|
||||||
|
src: `/${targetRelativePath.replace(/\\/g, '/')}`,
|
||||||
|
label: customItem.label,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyUploadIntoGameAsset(src) {
|
||||||
|
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return src || ''
|
||||||
|
|
||||||
|
const originalName = path.basename(src)
|
||||||
|
const nextFilename = buildUploadFilename({ originalname: originalName })
|
||||||
|
const sourcePath = path.join(__dirname, '..', '..', src.replace(/^\//, ''))
|
||||||
|
const targetRelativePath = path.join('uploads', 'games', nextFilename)
|
||||||
|
const targetPath = path.join(__dirname, '..', '..', targetRelativePath)
|
||||||
|
|
||||||
|
await fs.copyFile(sourcePath, targetPath)
|
||||||
|
return `/${targetRelativePath.replace(/\\/g, '/')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueTierListPoolItems(tierList) {
|
||||||
|
const seen = new Set()
|
||||||
|
return (tierList?.pool || []).filter((item) => {
|
||||||
|
if (!item?.id || seen.has(item.id)) return false
|
||||||
|
seen.add(item.id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
|
||||||
|
const allowedIds = new Set((itemIds || []).filter(Boolean))
|
||||||
|
const sourceItems = uniqueTierListPoolItems(tierList).filter((item) => item.origin === 'custom')
|
||||||
|
const itemsToCopy = allowedIds.size ? sourceItems.filter((item) => allowedIds.has(item.id)) : sourceItems
|
||||||
|
const createdItems = []
|
||||||
|
|
||||||
|
for (const item of itemsToCopy) {
|
||||||
|
const copiedSrc = await copyUploadIntoGameAsset(item.src)
|
||||||
|
createdItems.push(
|
||||||
|
await createGameItem({
|
||||||
|
id: nanoid(),
|
||||||
|
gameId,
|
||||||
|
src: copiedSrc,
|
||||||
|
label: item.label,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdItems
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
|
||||||
|
await createGame({ id: gameId, name: gameName })
|
||||||
|
if (tierList.thumbnailSrc) {
|
||||||
|
const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc)
|
||||||
|
await updateGameThumbnail(gameId, copiedThumb)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdItems = []
|
||||||
|
for (const item of uniqueTierListPoolItems(tierList)) {
|
||||||
|
const copiedSrc = await copyUploadIntoGameAsset(item.src)
|
||||||
|
createdItems.push(
|
||||||
|
await createGameItem({
|
||||||
|
id: nanoid(),
|
||||||
|
gameId,
|
||||||
|
src: copiedSrc,
|
||||||
|
label: item.label,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { game: await findGameById(gameId), items: createdItems }
|
||||||
|
}
|
||||||
|
|
||||||
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||||
const result = await listCustomItems({ page: 1, limit: 200, orphanOnly: false })
|
const result = await listCustomItems({ page: 1, limit: 200, orphanOnly: false })
|
||||||
const target = result.items.find((item) => item.id === req.params.itemId)
|
const target = result.items.find((item) => item.id === req.params.itemId)
|
||||||
@@ -172,6 +289,67 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
|||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
|
||||||
|
const schema = z.object({
|
||||||
|
gameId: z.string().min(1),
|
||||||
|
})
|
||||||
|
const parsed = schema.safeParse(req.body)
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
|
const game = await findGameById(parsed.data.gameId)
|
||||||
|
if (!game) return res.status(404).json({ error: 'game_not_found' })
|
||||||
|
|
||||||
|
const customItem = await findCustomItemById(req.params.itemId)
|
||||||
|
if (!customItem) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
|
const item = await promoteCustomItemToGameItem({ customItem, gameId: game.id })
|
||||||
|
res.json({ item })
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => {
|
||||||
|
const schema = z.object({
|
||||||
|
gameId: z.string().min(1),
|
||||||
|
itemIds: z.array(z.string().min(1)).optional().default([]),
|
||||||
|
})
|
||||||
|
const parsed = schema.safeParse(req.body)
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
|
const game = await findGameById(parsed.data.gameId)
|
||||||
|
if (!game) return res.status(404).json({ error: 'game_not_found' })
|
||||||
|
|
||||||
|
const tierList = await findTierListById(req.params.tierListId)
|
||||||
|
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
|
const items = await promoteTierListItemsToGame({
|
||||||
|
tierList,
|
||||||
|
gameId: game.id,
|
||||||
|
itemIds: parsed.data.itemIds,
|
||||||
|
})
|
||||||
|
res.json({ items })
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (req, res) => {
|
||||||
|
const schema = z.object({
|
||||||
|
gameId: z.string().trim().min(1).max(120),
|
||||||
|
name: z.string().trim().min(1).max(120),
|
||||||
|
})
|
||||||
|
const parsed = schema.safeParse(req.body)
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
|
const exists = await findGameById(parsed.data.gameId)
|
||||||
|
if (exists) return res.status(409).json({ error: 'game_id_taken' })
|
||||||
|
|
||||||
|
const tierList = await findTierListById(req.params.tierListId)
|
||||||
|
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
|
const result = await createGameTemplateFromTierList({
|
||||||
|
tierList,
|
||||||
|
gameId: parsed.data.gameId,
|
||||||
|
gameName: parsed.data.name,
|
||||||
|
})
|
||||||
|
res.json(result)
|
||||||
|
})
|
||||||
|
|
||||||
router.delete('/custom-items', requireAdmin, async (req, res) => {
|
router.delete('/custom-items', requireAdmin, async (req, res) => {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
q: z.string().trim().max(120).optional().default(''),
|
q: z.string().trim().max(120).optional().default(''),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const {
|
|||||||
deleteTierList,
|
deleteTierList,
|
||||||
saveTierList,
|
saveTierList,
|
||||||
createCustomItem,
|
createCustomItem,
|
||||||
|
findUserById,
|
||||||
} = require('../db')
|
} = require('../db')
|
||||||
const { requireAuth } = require('../middleware/auth')
|
const { requireAuth } = require('../middleware/auth')
|
||||||
|
|
||||||
@@ -52,10 +53,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(
|
||||||
@@ -90,7 +100,9 @@ router.get('/:id', async (req, res) => {
|
|||||||
const t = await findTierListById(req.params.id)
|
const t = await findTierListById(req.params.id)
|
||||||
if (!t) return res.status(404).json({ error: 'not_found' })
|
if (!t) return res.status(404).json({ error: 'not_found' })
|
||||||
if (!t.isPublic) {
|
if (!t.isPublic) {
|
||||||
if (!req.session || req.session.userId !== t.authorId) return res.status(403).json({ error: 'forbidden' })
|
if (!req.session?.userId) return res.status(403).json({ error: 'forbidden' })
|
||||||
|
const currentUser = req.session.userId === t.authorId ? { isAdmin: false } : await findUserById(req.session.userId)
|
||||||
|
if (req.session.userId !== t.authorId && !currentUser?.isAdmin) return res.status(403).json({ error: 'forbidden' })
|
||||||
}
|
}
|
||||||
res.json({ tierList: normalizeTierList(t) })
|
res.json({ tierList: normalizeTierList(t) })
|
||||||
})
|
})
|
||||||
@@ -121,6 +133,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 +154,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 +168,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,25 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.42
|
||||||
|
- 관리자 운영 관점에서는 공개 목록만으로는 부족하므로, 전체 티어표를 검색하고 추가 아이템까지 확인하는 별도 `티어표 관리` 탭을 두는 편이 더 적합하다고 정리했다.
|
||||||
|
- 게임 기반 티어표의 “사용자 추가 아이템”과 `freeform` 티어표의 “전체 아이템”은 활용 목적이 다르므로, 전자는 기존 게임 템플릿 승격 중심으로, 후자는 새 게임 템플릿 생성 중심으로 다루기로 결정했다.
|
||||||
|
- 관리자는 moderation 목적의 완성본 검토가 필요하므로, 작성자가 아니어도 비공개 티어표 상세를 열람할 수 있게 하기로 했다.
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.41
|
||||||
|
- 관리자 커스텀 아이템 승격은 버튼만 보이는 상태로 끝나면 안 되므로, 프런트 API와 백엔드 라우트가 실제로 함께 연결되어야 기능이 완결된다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.40
|
||||||
|
- 관리자 기본 아이템 이름 저장은 눌러도 변화가 없으면 혼란스러우므로, 실제 변경이 있을 때만 버튼이 활성화되는 편이 더 명확하다고 판단했다.
|
||||||
|
- 사용자 커스텀 이미지는 관리자 검토 후 특정 게임의 기본 템플릿으로 복제해 가져올 수 있어야 운영 효율이 높아지므로, 게임 선택 기반 승격 흐름을 추가하기로 결정했다.
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.39
|
||||||
|
- 티어표 편집 헤더는 게임명 kicker보다 제목과 설명이 더 중요하므로, 좌측 입력 중심 구조로 재배치하고 썸네일은 우측 보조 카드로 분리하는 편이 더 자연스럽다고 판단했다.
|
||||||
|
- 썸네일 조작 버튼은 모바일에서도 카드와 함께 유지되는 편이 흐름이 덜 끊기므로, 미리보기 아래 별도 줄로 떨어뜨리기보다 카드 내부의 짧은 액션 행으로 묶기로 결정했다.
|
||||||
|
|
||||||
|
## 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`
|
||||||
- 역할: `게임 관리 / 아이템 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
|
- 역할: `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템 승격, freeform 티어표의 게임 템플릿화, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
|
||||||
- 연동 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`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-game-template`, `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`
|
||||||
|
|||||||
16
docs/spec.md
16
docs/spec.md
@@ -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,7 +87,12 @@
|
|||||||
- `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/tierlists`
|
||||||
|
- `POST /api/admin/tierlists/:tierListId/promote-items`
|
||||||
|
- `POST /api/admin/tierlists/:tierListId/create-game-template`
|
||||||
- `GET /api/admin/custom-items`
|
- `GET /api/admin/custom-items`
|
||||||
|
- `POST /api/admin/custom-items/:itemId/promote`
|
||||||
- `DELETE /api/admin/custom-items/:itemId`
|
- `DELETE /api/admin/custom-items/:itemId`
|
||||||
- `DELETE /api/admin/custom-items`
|
- `DELETE /api/admin/custom-items`
|
||||||
- `GET /api/admin/users`
|
- `GET /api/admin/users`
|
||||||
@@ -98,21 +105,30 @@
|
|||||||
## 관리자 화면 메모
|
## 관리자 화면 메모
|
||||||
- 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다.
|
- 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다.
|
||||||
- 게임 기본 아이템 추가는 드래그 앤 드롭 또는 다중 파일 선택으로 처리하고, 미리보기 카드에서 여러 장을 함께 확인할 수 있다.
|
- 게임 기본 아이템 추가는 드래그 앤 드롭 또는 다중 파일 선택으로 처리하고, 미리보기 카드에서 여러 장을 함께 확인할 수 있다.
|
||||||
|
- 현재 기본 아이템 목록에서는 등록된 아이템 이름을 직접 수정하고 저장할 수 있다.
|
||||||
|
- 기본 아이템 이름 저장 버튼은 값이 실제로 바뀐 경우에만 활성화된다.
|
||||||
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
|
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
|
||||||
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
|
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
|
||||||
- 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다.
|
- 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다.
|
||||||
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
||||||
|
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
|
||||||
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
|
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
|
||||||
|
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 최근 티어표 전체를 제목/게임/작성자 기준으로 검색하고 공개 여부를 함께 확인할 수 있다.
|
||||||
|
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
|
||||||
|
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
|
||||||
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
|
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
|
||||||
|
|
||||||
## 티어표 접근 메모
|
## 티어표 접근 메모
|
||||||
- `new` 작성 경로는 로그인한 사용자만 진입할 수 있다.
|
- `new` 작성 경로는 로그인한 사용자만 진입할 수 있다.
|
||||||
- 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다.
|
- 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다.
|
||||||
|
- 비공개 티어표라도 관리자는 편집 화면에서 완성본을 열람할 수 있다.
|
||||||
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
|
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
|
||||||
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
|
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
|
||||||
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
||||||
- 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다.
|
- 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다.
|
||||||
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
|
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
|
||||||
|
- 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다.
|
||||||
|
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
|
||||||
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
||||||
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
||||||
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
|
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
## 즉시 확인 필요
|
## 즉시 확인 필요
|
||||||
- 사용자 커스텀 아이템을 관리자 기본 템플릿으로 승격하는 승인/복제 흐름은 아직 없다.
|
|
||||||
- 회원 관리에서 아바타/작성 티어표 수/최근 활동 같은 보조 정보는 아직 표시하지 않는다.
|
- 회원 관리에서 아바타/작성 티어표 수/최근 활동 같은 보조 정보는 아직 표시하지 않는다.
|
||||||
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
|
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
|
||||||
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
|
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
|
||||||
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
||||||
|
- 티어표 썸네일은 현재 업로드/교체만 지원하므로, 필요하면 자동 썸네일 추출이나 업로드 이미지 크롭 UX를 추가 검토한다.
|
||||||
|
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
|
||||||
|
- 관리자 티어표 관리의 템플릿 생성은 현재 `freeform`만 직접 지원하므로, 필요하면 일반 게임 티어표의 전체 아이템을 복제한 파생 템플릿 생성 UX도 검토한다.
|
||||||
|
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.
|
||||||
|
|
||||||
## 배포 전 작업
|
## 배포 전 작업
|
||||||
- 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` 값을 설정한다.
|
||||||
@@ -17,6 +20,6 @@
|
|||||||
## 중기 개선
|
## 중기 개선
|
||||||
- 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다.
|
- 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다.
|
||||||
- 자동 테스트와 최소한의 배포 체크리스트를 만든다.
|
- 자동 테스트와 최소한의 배포 체크리스트를 만든다.
|
||||||
- 관리자용 커스텀 아이템 승인/복제, 아이템 정렬 UI를 추가한다.
|
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
|
||||||
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다.
|
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다.
|
||||||
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.42
|
||||||
|
- **관리자 티어표 관리 탭 추가**: 공개/비공개를 포함한 최근 티어표 전체를 관리자 화면에서 검색/페이지네이션으로 확인하고, 제목·작성자·게임·공개 여부를 함께 볼 수 있도록 보강
|
||||||
|
- **추가 아이템 승격 흐름 확장**: 티어표 안에서 사용자가 추가한 커스텀 아이템을 관리자 화면에서 바로 특정 게임의 기본 템플릿으로 개별 또는 일괄 복제할 수 있도록 추가
|
||||||
|
- **커스텀 티어표 템플릿화 추가**: `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 별도 게임 템플릿으로 복제 생성할 수 있도록 지원
|
||||||
|
- **관리자 열람 권한 확장**: 비공개 티어표도 관리자는 편집 화면에서 완성본을 열람할 수 있도록 상세 조회 권한을 확장
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.41
|
||||||
|
- **커스텀 아이템 승격 연결 수정**: 관리자 아이템 관리의 `기본 템플릿에 추가` 버튼이 실제 API와 백엔드 승격 라우트로 연결되도록 누락된 프런트/백엔드 구현을 보완
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.40
|
||||||
|
- **기본 아이템 저장 UX 보강**: 관리자 게임 관리에서 아이템 이름이 실제로 바뀐 경우에만 `이름 저장` 버튼이 활성화되도록 조정하고, 저장 중 상태를 버튼에 표시
|
||||||
|
- **커스텀 아이템 승격 추가**: 관리자 아이템 관리에서 사용자 커스텀 이미지를 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있도록 API와 UI를 추가
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.39
|
||||||
|
- **에디터 헤더 재구성**: 티어표 편집 상단에서 게임명 kicker를 제거하고, 좌측 제목/설명 입력과 우측 썸네일 카드가 나란히 보이는 구조로 재정리
|
||||||
|
- **썸네일 영역 UX 개선**: 썸네일 미리보기와 선택/제거 버튼을 하나의 카드 안에 묶고, 모바일에서도 버튼이 카드 아래로 무너지지 않도록 밀도 있게 조정
|
||||||
|
|
||||||
|
## 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,10 +33,20 @@ 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)}`
|
||||||
),
|
),
|
||||||
|
listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
|
||||||
|
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
|
||||||
|
promoteAdminCustomItem: (itemId, payload) =>
|
||||||
|
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||||
|
promoteAdminTierListItems: (tierListId, payload) =>
|
||||||
|
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
|
||||||
|
createAdminGameTemplateFromTierList: (tierListId, payload) =>
|
||||||
|
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
|
||||||
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 }),
|
||||||
@@ -50,6 +60,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' }),
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import Sortable from 'sortablejs'
|
import Sortable from 'sortablejs'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { toApiUrl } from '../lib/runtime'
|
import { toApiUrl } from '../lib/runtime'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
||||||
|
|
||||||
@@ -22,6 +24,14 @@ 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 customItemOrphanOnly = ref(false)
|
||||||
|
const customItemTargetGameId = ref('')
|
||||||
|
|
||||||
|
const adminTierLists = ref([])
|
||||||
|
const adminTierListQuery = ref('')
|
||||||
|
const adminTierListPage = ref(1)
|
||||||
|
const adminTierListLimit = ref(50)
|
||||||
|
const adminTierListTotal = ref(0)
|
||||||
|
const adminTierListTargetGameId = ref('')
|
||||||
|
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
|
|
||||||
@@ -45,6 +55,7 @@ const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
|
|||||||
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
|
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
|
||||||
const canAddItem = computed(() => uploadFiles.value.length > 0 && !!selectedGameId.value)
|
const canAddItem = computed(() => uploadFiles.value.length > 0 && !!selectedGameId.value)
|
||||||
const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value)))
|
const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value)))
|
||||||
|
const adminTierListPageCount = computed(() => Math.max(1, Math.ceil(adminTierListTotal.value / adminTierListLimit.value)))
|
||||||
const featuredGames = computed(() =>
|
const featuredGames = computed(() =>
|
||||||
featuredGameIds.value
|
featuredGameIds.value
|
||||||
.map((gameId) => games.value.find((game) => game.id === gameId))
|
.map((gameId) => games.value.find((game) => game.id === gameId))
|
||||||
@@ -54,7 +65,7 @@ const availableGamesForFeatured = computed(() => games.value.filter((game) => !f
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await auth.refresh()
|
await auth.refresh()
|
||||||
await Promise.all([refreshGames(), refreshCustomItems(), refreshUsers()])
|
await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers()])
|
||||||
await syncFeaturedSortable()
|
await syncFeaturedSortable()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -72,12 +83,24 @@ function resetMessages() {
|
|||||||
function setTab(tab) {
|
function setTab(tab) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
activeTab.value = tab
|
activeTab.value = tab
|
||||||
|
if (tab === 'items' && !customItemTargetGameId.value && games.value.length) {
|
||||||
|
customItemTargetGameId.value = games.value[0].id
|
||||||
|
}
|
||||||
|
if (tab === 'tierlists' && !adminTierListTargetGameId.value && games.value.length) {
|
||||||
|
adminTierListTargetGameId.value = games.value[0].id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshGames() {
|
async function refreshGames() {
|
||||||
try {
|
try {
|
||||||
const data = await api.listGames()
|
const data = await api.listGames()
|
||||||
games.value = data.games || []
|
games.value = data.games || []
|
||||||
|
if (!customItemTargetGameId.value && games.value.length) {
|
||||||
|
customItemTargetGameId.value = games.value[0].id
|
||||||
|
}
|
||||||
|
if (!adminTierListTargetGameId.value && games.value.length) {
|
||||||
|
adminTierListTargetGameId.value = games.value[0].id
|
||||||
|
}
|
||||||
featuredGameIds.value = games.value
|
featuredGameIds.value = games.value
|
||||||
.filter((game) => game.displayRank != null)
|
.filter((game) => game.displayRank != null)
|
||||||
.sort((a, b) => a.displayRank - b.displayRank)
|
.sort((a, b) => a.displayRank - b.displayRank)
|
||||||
@@ -134,6 +157,30 @@ async function refreshCustomItems() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshAdminTierLists() {
|
||||||
|
if (!auth.user?.isAdmin) return
|
||||||
|
try {
|
||||||
|
const data = await api.listAdminTierLists({
|
||||||
|
q: adminTierListQuery.value,
|
||||||
|
page: adminTierListPage.value,
|
||||||
|
limit: adminTierListLimit.value,
|
||||||
|
})
|
||||||
|
adminTierLists.value = (data.tierLists || []).map((tierList) => ({
|
||||||
|
...tierList,
|
||||||
|
templateGameId: tierList.gameId === 'freeform' ? '' : `${tierList.gameId}-copy`,
|
||||||
|
templateGameName:
|
||||||
|
tierList.gameId === 'freeform'
|
||||||
|
? `${tierList.title} 템플릿`
|
||||||
|
: `${tierList.gameName || tierList.gameId} 확장 템플릿`,
|
||||||
|
}))
|
||||||
|
adminTierListTotal.value = data.total || 0
|
||||||
|
adminTierListPage.value = data.page || 1
|
||||||
|
adminTierListLimit.value = data.limit || adminTierListLimit.value
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '관리자 티어표 목록을 불러오지 못했어요.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshUsers() {
|
async function refreshUsers() {
|
||||||
if (!auth.user?.isAdmin) return
|
if (!auth.user?.isAdmin) return
|
||||||
try {
|
try {
|
||||||
@@ -196,7 +243,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 +399,29 @@ async function removeGameItem(itemId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveGameItemLabel(item) {
|
||||||
|
resetMessages()
|
||||||
|
if (!selectedGameId.value) return
|
||||||
|
const nextLabel = (item.draftLabel || '').trim()
|
||||||
|
if (!nextLabel) {
|
||||||
|
error.value = '아이템 이름을 입력해주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (nextLabel === item.label) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
item.isSavingLabel = true
|
||||||
|
const data = await api.updateAdminGameItem(selectedGameId.value, item.id, { label: nextLabel })
|
||||||
|
item.label = data.item.label
|
||||||
|
item.draftLabel = data.item.label
|
||||||
|
success.value = '기본 아이템 이름을 수정했어요.'
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '기본 아이템 이름 수정에 실패했어요.'
|
||||||
|
} finally {
|
||||||
|
item.isSavingLabel = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeGame() {
|
async function removeGame() {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
if (!selectedGameId.value || !selectedGame.value?.game) return
|
if (!selectedGameId.value || !selectedGame.value?.game) return
|
||||||
@@ -442,6 +518,24 @@ function changeCustomItemLimit(limit) {
|
|||||||
refreshCustomItems()
|
refreshCustomItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submitAdminTierListSearch() {
|
||||||
|
adminTierListPage.value = 1
|
||||||
|
refreshAdminTierLists()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeAdminTierListLimit(limit) {
|
||||||
|
adminTierListLimit.value = limit
|
||||||
|
adminTierListPage.value = 1
|
||||||
|
refreshAdminTierLists()
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveAdminTierListPage(direction) {
|
||||||
|
const nextPage = adminTierListPage.value + direction
|
||||||
|
if (nextPage < 1 || nextPage > adminTierListPageCount.value) return
|
||||||
|
adminTierListPage.value = nextPage
|
||||||
|
refreshAdminTierLists()
|
||||||
|
}
|
||||||
|
|
||||||
function moveCustomItemPage(direction) {
|
function moveCustomItemPage(direction) {
|
||||||
const nextPage = customItemPage.value + direction
|
const nextPage = customItemPage.value + direction
|
||||||
if (nextPage < 1 || nextPage > customItemPageCount.value) return
|
if (nextPage < 1 || nextPage > customItemPageCount.value) return
|
||||||
@@ -482,6 +576,115 @@ async function removeUnusedCustomItems() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function promoteCustomItem(item) {
|
||||||
|
resetMessages()
|
||||||
|
if (!customItemTargetGameId.value) {
|
||||||
|
error.value = '가져올 게임을 먼저 선택해주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
item.isPromoting = true
|
||||||
|
await api.promoteAdminCustomItem(item.id, { gameId: customItemTargetGameId.value })
|
||||||
|
const targetGameName = games.value.find((game) => game.id === customItemTargetGameId.value)?.name || customItemTargetGameId.value
|
||||||
|
if (selectedGameId.value === customItemTargetGameId.value) await loadGame()
|
||||||
|
success.value = `"${item.label}" 아이템을 ${targetGameName} 기본 템플릿으로 추가했어요.`
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '커스텀 아이템을 기본 템플릿으로 가져오지 못했어요.'
|
||||||
|
} finally {
|
||||||
|
item.isPromoting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tierListThumbUrl(tierList) {
|
||||||
|
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function tierListAuthorDisplayName(tierList) {
|
||||||
|
return tierList.authorName || '알 수 없음'
|
||||||
|
}
|
||||||
|
|
||||||
|
function tierListVisibilityLabel(tierList) {
|
||||||
|
return tierList.isPublic ? '공개' : '비공개'
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAdminTierList(tierList) {
|
||||||
|
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promoteTierListExtraItem(tierList, item) {
|
||||||
|
resetMessages()
|
||||||
|
if (!adminTierListTargetGameId.value) {
|
||||||
|
error.value = '아이템을 가져올 게임을 먼저 선택해주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
item.isPromoting = true
|
||||||
|
const data = await api.promoteAdminTierListItems(tierList.id, {
|
||||||
|
gameId: adminTierListTargetGameId.value,
|
||||||
|
itemIds: [item.id],
|
||||||
|
})
|
||||||
|
if (selectedGameId.value === adminTierListTargetGameId.value) await loadGame()
|
||||||
|
success.value = `"${item.label}" 아이템을 기본 템플릿으로 추가했어요. (${data.items?.length || 0}개 반영)`
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '티어표 추가 아이템을 기본 템플릿으로 가져오지 못했어요.'
|
||||||
|
} finally {
|
||||||
|
item.isPromoting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promoteAllTierListExtraItems(tierList) {
|
||||||
|
resetMessages()
|
||||||
|
if (!adminTierListTargetGameId.value) {
|
||||||
|
error.value = '아이템을 가져올 게임을 먼저 선택해주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!tierList.extraItems?.length) {
|
||||||
|
error.value = '가져올 추가 아이템이 없어요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
tierList.isPromotingAll = true
|
||||||
|
const data = await api.promoteAdminTierListItems(tierList.id, {
|
||||||
|
gameId: adminTierListTargetGameId.value,
|
||||||
|
itemIds: tierList.extraItems.map((item) => item.id),
|
||||||
|
})
|
||||||
|
if (selectedGameId.value === adminTierListTargetGameId.value) await loadGame()
|
||||||
|
success.value = `${data.items?.length || 0}개의 추가 아이템을 기본 템플릿으로 가져왔어요.`
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '추가 아이템 일괄 가져오기에 실패했어요.'
|
||||||
|
} finally {
|
||||||
|
tierList.isPromotingAll = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTemplateFromTierList(tierList) {
|
||||||
|
resetMessages()
|
||||||
|
const nextGameId = (tierList.templateGameId || '').trim()
|
||||||
|
const nextName = (tierList.templateGameName || '').trim()
|
||||||
|
|
||||||
|
if (!nextGameId || !nextName) {
|
||||||
|
error.value = '새 게임 ID와 이름을 모두 입력해주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
tierList.isCreatingTemplate = true
|
||||||
|
const data = await api.createAdminGameTemplateFromTierList(tierList.id, {
|
||||||
|
gameId: nextGameId,
|
||||||
|
name: nextName,
|
||||||
|
})
|
||||||
|
await refreshGames()
|
||||||
|
success.value = `"${data.game?.name || nextName}" 게임 템플릿을 생성했어요.`
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '커스텀 티어표를 새 게임 템플릿으로 만들지 못했어요.'
|
||||||
|
} finally {
|
||||||
|
tierList.isCreatingTemplate = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -554,6 +757,7 @@ async function saveFeaturedOrder() {
|
|||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab" :class="{ 'tab--active': activeTab === 'games' }" @click="setTab('games')">게임 관리</button>
|
<button class="tab" :class="{ 'tab--active': activeTab === 'games' }" @click="setTab('games')">게임 관리</button>
|
||||||
<button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button>
|
<button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button>
|
||||||
|
<button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button>
|
||||||
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
|
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -708,8 +912,17 @@ 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"
|
||||||
|
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label"
|
||||||
|
@click="saveGameItemLabel(item)"
|
||||||
|
>
|
||||||
|
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--danger btn--small" @click="removeGameItem(item.id)">아이템 삭제</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -736,6 +949,10 @@ async function saveFeaturedOrder() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar toolbar--secondary">
|
<div class="toolbar toolbar--secondary">
|
||||||
|
<select v-model="customItemTargetGameId" class="select toolbar__select">
|
||||||
|
<option value="">가져올 게임 선택</option>
|
||||||
|
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
|
||||||
|
</select>
|
||||||
<label class="checkRow checkRow--toolbar">
|
<label class="checkRow checkRow--toolbar">
|
||||||
<input v-model="customItemOrphanOnly" type="checkbox" @change="toggleCustomItemOrphanOnly" />
|
<input v-model="customItemOrphanOnly" type="checkbox" @change="toggleCustomItemOrphanOnly" />
|
||||||
<span>미사용 커스텀 이미지만 보기</span>
|
<span>미사용 커스텀 이미지만 보기</span>
|
||||||
@@ -757,6 +974,9 @@ async function saveFeaturedOrder() {
|
|||||||
<div class="customItemCard__meta">{{ fmt(item.createdAt) }}</div>
|
<div class="customItemCard__meta">{{ fmt(item.createdAt) }}</div>
|
||||||
<div class="customItemCard__actions">
|
<div class="customItemCard__actions">
|
||||||
<a class="btn btn--small btn--ghost" :href="toApiUrl(item.src)" :download="item.label">이미지 다운로드</a>
|
<a class="btn btn--small btn--ghost" :href="toApiUrl(item.src)" :download="item.label">이미지 다운로드</a>
|
||||||
|
<button class="btn btn--small btn--ghost" :disabled="!customItemTargetGameId || item.isPromoting" @click="promoteCustomItem(item)">
|
||||||
|
{{ item.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
|
||||||
|
</button>
|
||||||
<button class="btn btn--small btn--danger" :disabled="item.usageCount > 0" @click="removeCustomItem(item)">개별 삭제</button>
|
<button class="btn btn--small btn--danger" :disabled="item.usageCount > 0" @click="removeCustomItem(item)">개별 삭제</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -771,6 +991,111 @@ async function saveFeaturedOrder() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="activeTab === 'tierlists'">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="sectionHeader">
|
||||||
|
<div>
|
||||||
|
<div class="panel__title">전체 티어표 관리</div>
|
||||||
|
<div class="hint hint--tight">공개/비공개를 포함한 최근 티어표를 모두 확인하고, 추가 아이템을 기존 게임 템플릿으로 승격하거나 커스텀 티어표를 새 게임 템플릿으로 만들 수 있어요.</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--ghost" @click="refreshAdminTierLists">새로고침</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<input
|
||||||
|
v-model="adminTierListQuery"
|
||||||
|
class="input toolbar__search"
|
||||||
|
placeholder="제목, 작성자, 게임 이름 검색"
|
||||||
|
@keydown.enter.prevent="submitAdminTierListSearch"
|
||||||
|
/>
|
||||||
|
<button class="btn btn--ghost toolbar__button" @click="submitAdminTierListSearch">검색</button>
|
||||||
|
<select :value="adminTierListLimit" class="select toolbar__select" @change="changeAdminTierListLimit(Number($event.target.value))">
|
||||||
|
<option :value="50">50개씩 보기</option>
|
||||||
|
<option :value="200">200개씩 보기</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar toolbar--secondary">
|
||||||
|
<select v-model="adminTierListTargetGameId" class="select toolbar__select">
|
||||||
|
<option value="">추가 아이템을 넣을 게임 선택</option>
|
||||||
|
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!adminTierLists.length" class="hint">조건에 맞는 티어표가 없어요.</div>
|
||||||
|
<div v-else class="tierAdminList">
|
||||||
|
<article v-for="tierList in adminTierLists" :key="tierList.id" class="tierAdminCard">
|
||||||
|
<div class="tierAdminCard__preview" @click="openAdminTierList(tierList)">
|
||||||
|
<img v-if="tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="tierListThumbUrl(tierList)" :alt="tierList.title" />
|
||||||
|
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tierAdminCard__body">
|
||||||
|
<div class="tierAdminCard__head">
|
||||||
|
<div>
|
||||||
|
<div class="tierAdminCard__title">{{ tierList.title }}</div>
|
||||||
|
<div class="tierAdminCard__meta">
|
||||||
|
{{ tierList.gameName || tierList.gameId }} · {{ tierListAuthorDisplayName(tierList) }} · {{ tierListVisibilityLabel(tierList) }}
|
||||||
|
</div>
|
||||||
|
<div class="tierAdminCard__meta">{{ fmt(tierList.updatedAt) }}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--ghost btn--small" @click="openAdminTierList(tierList)">완성본 보기</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tierAdminCard__stats">
|
||||||
|
<span class="pill">전체 아이템 {{ tierList.itemCount }}개</span>
|
||||||
|
<span class="pill" :class="{ 'pill--accent': tierList.extraItemCount > 0 }">추가 아이템 {{ tierList.extraItemCount }}개</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="tierList.extraItems?.length" class="tierAdminSection">
|
||||||
|
<div class="tierAdminSection__title">추가로 넣은 아이템</div>
|
||||||
|
<div class="tierAdminItemList">
|
||||||
|
<article v-for="item in tierList.extraItems" :key="item.id" class="tierAdminItem">
|
||||||
|
<img class="tierAdminItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||||
|
<div class="tierAdminItem__body">
|
||||||
|
<div class="tierAdminItem__title">{{ item.label }}</div>
|
||||||
|
<div class="tierAdminItem__meta">{{ item.origin === 'custom' ? '사용자 추가 아이템' : '기본 아이템' }}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn--ghost btn--small"
|
||||||
|
:disabled="!adminTierListTargetGameId || item.isPromoting"
|
||||||
|
@click="promoteTierListExtraItem(tierList, item)"
|
||||||
|
>
|
||||||
|
{{ item.isPromoting ? '추가중...' : '이 아이템 추가' }}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn--ghost btn--small"
|
||||||
|
:disabled="!adminTierListTargetGameId || tierList.isPromotingAll"
|
||||||
|
@click="promoteAllTierListExtraItems(tierList)"
|
||||||
|
>
|
||||||
|
{{ tierList.isPromotingAll ? '가져오는 중...' : '추가 아이템 전체 가져오기' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="tierList.gameId === 'freeform'" class="tierAdminSection">
|
||||||
|
<div class="tierAdminSection__title">커스텀 티어표를 게임 템플릿으로 만들기</div>
|
||||||
|
<div class="tierAdminTemplateForm">
|
||||||
|
<input v-model="tierList.templateGameId" class="input" placeholder="새 게임 ID" />
|
||||||
|
<input v-model="tierList.templateGameName" class="input" placeholder="새 게임 이름" />
|
||||||
|
<button class="btn btn--primary" :disabled="tierList.isCreatingTemplate" @click="createTemplateFromTierList(tierList)">
|
||||||
|
{{ tierList.isCreatingTemplate ? '생성중...' : '새 게임 템플릿 만들기' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pager">
|
||||||
|
<button class="btn btn--ghost" :disabled="adminTierListPage <= 1" @click="moveAdminTierListPage(-1)">이전</button>
|
||||||
|
<div class="pager__info">{{ adminTierListPage }} / {{ adminTierListPageCount }} 페이지 · 총 {{ adminTierListTotal }}개</div>
|
||||||
|
<button class="btn btn--ghost" :disabled="adminTierListPage >= adminTierListPageCount" @click="moveAdminTierListPage(1)">다음</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="sectionHeader">
|
<div class="sectionHeader">
|
||||||
@@ -1011,7 +1336,7 @@ async function saveFeaturedOrder() {
|
|||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
.toolbar--secondary {
|
.toolbar--secondary {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.toolbar__search,
|
.toolbar__search,
|
||||||
@@ -1047,6 +1372,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 +1579,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;
|
||||||
}
|
}
|
||||||
@@ -1289,7 +1622,7 @@ async function saveFeaturedOrder() {
|
|||||||
}
|
}
|
||||||
.customItemCard__actions {
|
.customItemCard__actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
@@ -1365,6 +1698,123 @@ async function saveFeaturedOrder() {
|
|||||||
.roleBadge--admin {
|
.roleBadge--admin {
|
||||||
background: rgba(96, 165, 250, 0.18);
|
background: rgba(96, 165, 250, 0.18);
|
||||||
}
|
}
|
||||||
|
.tierAdminList {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.tierAdminCard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.tierAdminCard__preview {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tierAdminCard__thumb {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
.tierAdminCard__thumb--empty {
|
||||||
|
background: linear-gradient(135deg, rgba(255,255,255,0.08), rgba(255,255,255,0.03));
|
||||||
|
}
|
||||||
|
.tierAdminCard__body {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.tierAdminCard__head {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.tierAdminCard__title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
.tierAdminCard__meta {
|
||||||
|
margin-top: 4px;
|
||||||
|
opacity: 0.74;
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.tierAdminCard__stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.pill--accent {
|
||||||
|
border-color: rgba(251, 191, 36, 0.32);
|
||||||
|
background: rgba(251, 191, 36, 0.12);
|
||||||
|
color: rgba(253, 230, 138, 0.96);
|
||||||
|
}
|
||||||
|
.tierAdminSection {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(0, 0, 0, 0.14);
|
||||||
|
}
|
||||||
|
.tierAdminSection__title {
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.tierAdminItemList {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.tierAdminItem {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 56px minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
.tierAdminItem__thumb {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.tierAdminItem__body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.tierAdminItem__title {
|
||||||
|
font-weight: 800;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.tierAdminItem__meta {
|
||||||
|
margin-top: 4px;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.tierAdminTemplateForm {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
.checkRow {
|
.checkRow {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1379,7 +1829,12 @@ async function saveFeaturedOrder() {
|
|||||||
.featuredOrderPanel,
|
.featuredOrderPanel,
|
||||||
.section--topGrid,
|
.section--topGrid,
|
||||||
.toolbar,
|
.toolbar,
|
||||||
.itemComposer {
|
.itemComposer,
|
||||||
|
.tierAdminCard,
|
||||||
|
.tierAdminTemplateForm {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.toolbar--secondary {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.itemPreviewCard {
|
.itemPreviewCard {
|
||||||
@@ -1392,6 +1847,10 @@ async function saveFeaturedOrder() {
|
|||||||
.userList {
|
.userList {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.tierAdminCard__head,
|
||||||
|
.tierAdminItem {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.customItemCard {
|
.customItemCard {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,29 +475,48 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
if (thumbnailPreviewUrl.value) URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
||||||
destroySortables()
|
destroySortables()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="head">
|
<section class="head">
|
||||||
<div class="head__meta">
|
<div class="heroCard">
|
||||||
<div class="kicker">{{ gameName || gameId }}</div>
|
<div class="heroCard__main">
|
||||||
<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>
|
||||||
<input
|
<input
|
||||||
v-model="description"
|
v-model="description"
|
||||||
class="descInput"
|
class="descInput"
|
||||||
placeholder="설명(선택): 이 티어표의 기준/룰"
|
placeholder="설명(선택): 이 티어표의 기준/룰"
|
||||||
:readonly="!canEdit"
|
:readonly="!canEdit"
|
||||||
/>
|
/>
|
||||||
<div class="hint">
|
<div class="hint">
|
||||||
<template v-if="canEdit">
|
<template v-if="canEdit">
|
||||||
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다. 저장하려면 <b>저장</b>을 누르세요.
|
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다. 저장하려면 <b>저장</b>을 누르세요.
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 수 있어요.
|
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 수 있어요.
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="heroCard__side">
|
||||||
|
<div class="thumbComposer">
|
||||||
|
<input ref="thumbnailFileEl" type="file" accept="image/*" class="hidden" @change="onThumbnailChange" />
|
||||||
|
<div class="thumbComposer__header">
|
||||||
|
<div class="thumbComposer__eyebrow">대표 썸네일</div>
|
||||||
|
<div class="thumbComposer__caption">목록 카드 상단에 표시됩니다.</div>
|
||||||
|
</div>
|
||||||
|
<div class="thumbComposer__preview">
|
||||||
|
<img v-if="displayThumbnailUrl" class="thumbComposer__image" :src="displayThumbnailUrl" alt="썸네일 미리보기" />
|
||||||
|
<div v-else class="thumbComposer__empty">썸네일 없음</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="canEdit" class="thumbComposer__actions">
|
||||||
|
<button class="btn btn--ghost thumbComposer__button" @click="openThumbnailFile">썸네일 선택</button>
|
||||||
|
<button class="btn btn--danger thumbComposer__button" :disabled="!pendingThumbnailFile && !thumbnailSrc" @click="clearThumbnail">제거</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -573,30 +637,41 @@ onUnmounted(() => {
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 6px 2px 14px;
|
padding: 6px 2px 14px;
|
||||||
}
|
}
|
||||||
.head__meta {
|
.heroCard {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 360px);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.kicker {
|
.heroCard__main,
|
||||||
font-size: 12px;
|
.heroCard__side {
|
||||||
opacity: 0.7;
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.heroCard__main {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.heroCard__side {
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
.titleInput {
|
.titleInput {
|
||||||
width: min(100%, 920px);
|
width: 100%;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
padding: 10px 12px;
|
padding: 14px 16px;
|
||||||
border-radius: 14px;
|
border-radius: 18px;
|
||||||
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: linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.04));
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.descInput {
|
.descInput {
|
||||||
width: min(100%, 920px);
|
width: 100%;
|
||||||
padding: 10px 12px;
|
min-height: 92px;
|
||||||
border-radius: 14px;
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(0, 0, 0, 0.18);
|
background: rgba(0, 0, 0, 0.18);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
@@ -606,11 +681,69 @@ onUnmounted(() => {
|
|||||||
.hint {
|
.hint {
|
||||||
opacity: 0.78;
|
opacity: 0.78;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 0 2px;
|
||||||
}
|
}
|
||||||
.titleNotice {
|
.titleNotice {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: rgba(251, 191, 36, 0.94);
|
color: rgba(251, 191, 36, 0.94);
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
.thumbComposer {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 22px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(96, 165, 250, 0.12), transparent 46%),
|
||||||
|
rgba(255, 255, 255, 0.04);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.thumbComposer__header {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.thumbComposer__eyebrow {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.thumbComposer__caption {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.68;
|
||||||
|
}
|
||||||
|
.thumbComposer__preview {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(11, 18, 32, 0.78);
|
||||||
|
}
|
||||||
|
.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);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.thumbComposer__actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.thumbComposer__button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -687,7 +820,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.btn--ghost {
|
.btn--ghost {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 10px;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
.layout {
|
.layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -986,6 +1119,9 @@ onUnmounted(() => {
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
|
.heroCard {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.layout {
|
.layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -1002,5 +1138,13 @@ onUnmounted(() => {
|
|||||||
.row {
|
.row {
|
||||||
grid-template-columns: 150px 1fr;
|
grid-template-columns: 150px 1fr;
|
||||||
}
|
}
|
||||||
|
.thumbComposer {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
.titleInput,
|
||||||
|
.descInput {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user