Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49d4946735 | |||
| bd53cf96dc | |||
| 66c3b1e7b7 | |||
| 494f04d9a7 | |||
| 5aae278fd3 | |||
| 14dfe0ad75 | |||
| a7cfb97131 | |||
| badf250967 | |||
| a16b1e1025 | |||
| c1dfea41a5 | |||
| 188576f8ac | |||
| 5db1e57f13 |
@@ -1042,6 +1042,160 @@ async function getReferencedUploadFootprint() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fileExistsForUploadSrc(src) {
|
||||||
|
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return true
|
||||||
|
const absolutePath = path.join(__dirname, '..', src.replace(/^\//, ''))
|
||||||
|
try {
|
||||||
|
await fs.stat(absolutePath)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') return false
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripItemIdsFromGroups(groups, missingItemIds) {
|
||||||
|
let changed = false
|
||||||
|
const nextGroups = (groups || []).map((group) => {
|
||||||
|
const nextItemIds = (group?.itemIds || []).filter((itemId) => !missingItemIds.has(itemId))
|
||||||
|
if (nextItemIds.length !== (group?.itemIds || []).length) changed = true
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
itemIds: nextItemIds,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { changed, groups: nextGroups }
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripMissingItems(items, missingItemIds, missingSrcs) {
|
||||||
|
let changed = false
|
||||||
|
const nextItems = (items || []).filter((item) => {
|
||||||
|
const shouldRemove =
|
||||||
|
(item?.id && missingItemIds.has(item.id)) ||
|
||||||
|
(typeof item?.src === 'string' && missingSrcs.has(item.src))
|
||||||
|
if (shouldRemove) changed = true
|
||||||
|
return !shouldRemove
|
||||||
|
})
|
||||||
|
return { changed, items: nextItems }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupMissingUploadReferences() {
|
||||||
|
const stats = {
|
||||||
|
clearedAvatars: 0,
|
||||||
|
clearedGameThumbnails: 0,
|
||||||
|
clearedTierListThumbnails: 0,
|
||||||
|
clearedTemplateRequestThumbnails: 0,
|
||||||
|
deletedGameItems: 0,
|
||||||
|
updatedTierLists: 0,
|
||||||
|
updatedTemplateRequests: 0,
|
||||||
|
deletedCustomItems: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
|
||||||
|
query("SELECT id, avatar_src FROM users WHERE avatar_src <> ''"),
|
||||||
|
query("SELECT id, thumbnail_src FROM games WHERE thumbnail_src <> ''"),
|
||||||
|
query("SELECT id, src FROM game_items WHERE src <> ''"),
|
||||||
|
query("SELECT id, src FROM custom_items WHERE src <> ''"),
|
||||||
|
query("SELECT id, thumbnail_src, groups_json, pool_json FROM tierlists"),
|
||||||
|
query("SELECT id, thumbnail_src_snapshot, groups_json, items_json, board_items_json FROM template_requests"),
|
||||||
|
])
|
||||||
|
|
||||||
|
for (const row of userRows) {
|
||||||
|
if (await fileExistsForUploadSrc(row.avatar_src)) continue
|
||||||
|
await query('UPDATE users SET avatar_src = ? WHERE id = ?', ['', row.id])
|
||||||
|
stats.clearedAvatars += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of gameRows) {
|
||||||
|
if (await fileExistsForUploadSrc(row.thumbnail_src)) continue
|
||||||
|
await query('UPDATE games SET thumbnail_src = ? WHERE id = ?', ['', row.id])
|
||||||
|
stats.clearedGameThumbnails += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of gameItemRows) {
|
||||||
|
if (await fileExistsForUploadSrc(row.src)) continue
|
||||||
|
await deleteGameItem(row.id)
|
||||||
|
stats.deletedGameItems += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingCustomItemIds = new Set()
|
||||||
|
const missingCustomSrcs = new Set()
|
||||||
|
for (const row of customItemRows) {
|
||||||
|
if (await fileExistsForUploadSrc(row.src)) continue
|
||||||
|
missingCustomItemIds.add(row.id)
|
||||||
|
missingCustomSrcs.add(row.src)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of tierListRows) {
|
||||||
|
const groups = parseJson(row.groups_json, [])
|
||||||
|
const pool = parseJson(row.pool_json, [])
|
||||||
|
let changed = false
|
||||||
|
let nextThumbnail = row.thumbnail_src || ''
|
||||||
|
|
||||||
|
if (row.thumbnail_src && !(await fileExistsForUploadSrc(row.thumbnail_src))) {
|
||||||
|
nextThumbnail = ''
|
||||||
|
changed = true
|
||||||
|
stats.clearedTierListThumbnails += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const strippedPool = stripMissingItems(pool, missingCustomItemIds, missingCustomSrcs)
|
||||||
|
const strippedGroups = stripItemIdsFromGroups(groups, missingCustomItemIds)
|
||||||
|
if (strippedPool.changed || strippedGroups.changed) changed = true
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await query('UPDATE tierlists SET thumbnail_src = ?, groups_json = ?, pool_json = ?, updated_at = ? WHERE id = ?', [
|
||||||
|
nextThumbnail,
|
||||||
|
serializeJson(strippedGroups.groups),
|
||||||
|
serializeJson(strippedPool.items),
|
||||||
|
now(),
|
||||||
|
row.id,
|
||||||
|
])
|
||||||
|
stats.updatedTierLists += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of templateRequestRows) {
|
||||||
|
const groups = parseJson(row.groups_json, [])
|
||||||
|
const items = parseJson(row.items_json, [])
|
||||||
|
const boardItems = parseJson(row.board_items_json, [])
|
||||||
|
let changed = false
|
||||||
|
let nextThumbnail = row.thumbnail_src_snapshot || ''
|
||||||
|
|
||||||
|
if (row.thumbnail_src_snapshot && !(await fileExistsForUploadSrc(row.thumbnail_src_snapshot))) {
|
||||||
|
nextThumbnail = ''
|
||||||
|
changed = true
|
||||||
|
stats.clearedTemplateRequestThumbnails += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const strippedItems = stripMissingItems(items, missingCustomItemIds, missingCustomSrcs)
|
||||||
|
const strippedBoardItems = stripMissingItems(boardItems, missingCustomItemIds, missingCustomSrcs)
|
||||||
|
const strippedGroups = stripItemIdsFromGroups(groups, missingCustomItemIds)
|
||||||
|
if (strippedItems.changed || strippedBoardItems.changed || strippedGroups.changed) changed = true
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await query(
|
||||||
|
'UPDATE template_requests SET thumbnail_src_snapshot = ?, groups_json = ?, items_json = ?, board_items_json = ?, updated_at = ? WHERE id = ?',
|
||||||
|
[
|
||||||
|
nextThumbnail,
|
||||||
|
serializeJson(strippedGroups.groups),
|
||||||
|
serializeJson(strippedItems.items),
|
||||||
|
serializeJson(strippedBoardItems.items),
|
||||||
|
now(),
|
||||||
|
row.id,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
stats.updatedTemplateRequests += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingCustomItemIds.size) {
|
||||||
|
await deleteCustomItems(Array.from(missingCustomItemIds))
|
||||||
|
stats.deletedCustomItems = missingCustomItemIds.size
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
async function getImageAssetStats({ month } = {}) {
|
async function getImageAssetStats({ month } = {}) {
|
||||||
const range = resolveMonthRange(month)
|
const range = resolveMonthRange(month)
|
||||||
const jobWhere = []
|
const jobWhere = []
|
||||||
@@ -1444,7 +1598,56 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
|||||||
sourceGameName: row.game_name || row.game_id,
|
sourceGameName: row.game_name || row.game_id,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const allItems = [...customItems, ...templateItems, ...assetLibraryItems]
|
const baseItems = [...customItems, ...templateItems, ...assetLibraryItems]
|
||||||
|
const groupedBySrc = new Map()
|
||||||
|
for (const item of baseItems) {
|
||||||
|
if (!item?.src) continue
|
||||||
|
if (!groupedBySrc.has(item.src)) groupedBySrc.set(item.src, [])
|
||||||
|
groupedBySrc.get(item.src).push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allItems = baseItems
|
||||||
|
.map((item) => {
|
||||||
|
const siblings = groupedBySrc.get(item.src) || [item]
|
||||||
|
const linkedGames = new Map()
|
||||||
|
let userReferenceCount = 0
|
||||||
|
let templateReferenceCount = 0
|
||||||
|
let assetReferenceCount = 0
|
||||||
|
|
||||||
|
siblings.forEach((entry) => {
|
||||||
|
if (entry.sourceType === 'user') userReferenceCount += 1
|
||||||
|
else if (entry.isAssetLibraryItem) assetReferenceCount += 1
|
||||||
|
else templateReferenceCount += 1
|
||||||
|
;(entry.linkedGames || []).forEach((game) => {
|
||||||
|
if (game?.id) linkedGames.set(game.id, game)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
sharedReferenceCount: siblings.length,
|
||||||
|
sharedUserReferenceCount: userReferenceCount,
|
||||||
|
sharedTemplateReferenceCount: templateReferenceCount,
|
||||||
|
sharedAssetReferenceCount: assetReferenceCount,
|
||||||
|
sharedLinkedGameCount: linkedGames.size,
|
||||||
|
sharedEntries: siblings
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
|
||||||
|
.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
label: entry.label,
|
||||||
|
sourceLabel: entry.sourceLabel,
|
||||||
|
sourceType: entry.sourceType,
|
||||||
|
ownerName: entry.ownerName,
|
||||||
|
createdAt: entry.createdAt,
|
||||||
|
sourceGameId: entry.sourceGameId || '',
|
||||||
|
sourceGameName: entry.sourceGameName || '',
|
||||||
|
usageCount: entry.usageCount || 0,
|
||||||
|
linkedGames: entry.linkedGames || [],
|
||||||
|
isAssetLibraryItem: !!entry.isAssetLibraryItem,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
switch (filterMode) {
|
switch (filterMode) {
|
||||||
case 'user':
|
case 'user':
|
||||||
@@ -1748,22 +1951,32 @@ function getAutoThumbnailSrc(groups = [], pool = []) {
|
|||||||
return fallbackItem?.src || ''
|
return fallbackItem?.src || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listAdminTierLists({ queryText = '', page = 1, limit = 50, currentUserId = '' } = {}) {
|
async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit = 50, currentUserId = '' } = {}) {
|
||||||
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||||
const normalizedPage = Math.max(Number(page) || 1, 1)
|
const normalizedPage = Math.max(Number(page) || 1, 1)
|
||||||
const hasQuery = !!(queryText || '').trim()
|
const hasQuery = !!(queryText || '').trim()
|
||||||
|
const hasGameId = !!(gameId || '').trim()
|
||||||
const search = `%${(queryText || '').trim()}%`
|
const search = `%${(queryText || '').trim()}%`
|
||||||
const whereClause = hasQuery
|
const whereParts = []
|
||||||
? `
|
const params = []
|
||||||
WHERE
|
|
||||||
t.title LIKE ?
|
if (hasGameId) {
|
||||||
OR g.name LIKE ?
|
whereParts.push('t.game_id = ?')
|
||||||
OR g.id LIKE ?
|
params.push((gameId || '').trim())
|
||||||
OR u.email LIKE ?
|
}
|
||||||
OR u.nickname LIKE ?
|
|
||||||
`
|
if (hasQuery) {
|
||||||
: ''
|
whereParts.push(`(
|
||||||
const params = hasQuery ? [search, search, search, search, search] : []
|
t.title LIKE ?
|
||||||
|
OR g.name LIKE ?
|
||||||
|
OR g.id LIKE ?
|
||||||
|
OR u.email LIKE ?
|
||||||
|
OR u.nickname LIKE ?
|
||||||
|
)`)
|
||||||
|
params.push(search, search, search, search, search)
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
|
||||||
|
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
@@ -1823,6 +2036,50 @@ async function listAdminTierLists({ queryText = '', page = 1, limit = 50, curren
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function summarizeAdminTierLists({ queryText = '', gameId = '' } = {}) {
|
||||||
|
const hasQuery = !!(queryText || '').trim()
|
||||||
|
const hasGameId = !!(gameId || '').trim()
|
||||||
|
const search = `%${(queryText || '').trim()}%`
|
||||||
|
const whereParts = []
|
||||||
|
const params = []
|
||||||
|
|
||||||
|
if (hasGameId) {
|
||||||
|
whereParts.push('t.game_id = ?')
|
||||||
|
params.push((gameId || '').trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasQuery) {
|
||||||
|
whereParts.push(`(
|
||||||
|
t.title LIKE ?
|
||||||
|
OR g.name LIKE ?
|
||||||
|
OR g.id LIKE ?
|
||||||
|
OR u.email LIKE ?
|
||||||
|
OR u.nickname LIKE ?
|
||||||
|
)`)
|
||||||
|
params.push(search, search, search, search, search)
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
|
||||||
|
const rows = await query(
|
||||||
|
`
|
||||||
|
SELECT t.is_public
|
||||||
|
FROM tierlists t
|
||||||
|
INNER JOIN users u ON u.id = t.author_id
|
||||||
|
INNER JOIN games g ON g.id = t.game_id
|
||||||
|
${whereClause}
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
|
||||||
|
const total = rows.length
|
||||||
|
const publicCount = rows.filter((row) => Number(row.is_public) === 1).length
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
publicCount,
|
||||||
|
privateCount: Math.max(0, total - publicCount),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function findTierListById(id, currentUserId = '') {
|
async function findTierListById(id, currentUserId = '') {
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
@@ -2041,6 +2298,18 @@ async function deleteTierList(id) {
|
|||||||
await query('DELETE FROM tierlists WHERE id = ?', [id])
|
await query('DELETE FROM tierlists WHERE id = ?', [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateAdminTierListMeta({ id, title, description = '', isPublic }) {
|
||||||
|
await query(
|
||||||
|
`
|
||||||
|
UPDATE tierlists
|
||||||
|
SET title = ?, description = ?, is_public = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`,
|
||||||
|
[title, description || '', isPublic ? 1 : 0, now(), id]
|
||||||
|
)
|
||||||
|
return findTierListById(id)
|
||||||
|
}
|
||||||
|
|
||||||
async function findCustomItemsByIds(ids) {
|
async function findCustomItemsByIds(ids) {
|
||||||
if (!ids.length) return []
|
if (!ids.length) return []
|
||||||
const placeholders = ids.map(() => '?').join(', ')
|
const placeholders = ids.map(() => '?').join(', ')
|
||||||
@@ -2188,6 +2457,7 @@ module.exports = {
|
|||||||
replaceUploadSourceReferences,
|
replaceUploadSourceReferences,
|
||||||
clearImageOptimizationJobs,
|
clearImageOptimizationJobs,
|
||||||
getImageAssetStats,
|
getImageAssetStats,
|
||||||
|
cleanupMissingUploadReferences,
|
||||||
createGameItem,
|
createGameItem,
|
||||||
updateGameItemLabel,
|
updateGameItemLabel,
|
||||||
updateGameItemDisplayOrder,
|
updateGameItemDisplayOrder,
|
||||||
@@ -2204,7 +2474,9 @@ module.exports = {
|
|||||||
listFavoriteTierLists,
|
listFavoriteTierLists,
|
||||||
listUserTierLists,
|
listUserTierLists,
|
||||||
listAdminTierLists,
|
listAdminTierLists,
|
||||||
|
summarizeAdminTierLists,
|
||||||
findTierListById,
|
findTierListById,
|
||||||
|
updateAdminTierListMeta,
|
||||||
favoriteTierList,
|
favoriteTierList,
|
||||||
unfavoriteTierList,
|
unfavoriteTierList,
|
||||||
favoriteGame,
|
favoriteGame,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const {
|
|||||||
updateImageAssetLabel,
|
updateImageAssetLabel,
|
||||||
deleteGameItem,
|
deleteGameItem,
|
||||||
deleteGame,
|
deleteGame,
|
||||||
|
deleteTierList,
|
||||||
updateGameDisplayOrder,
|
updateGameDisplayOrder,
|
||||||
listCustomItems,
|
listCustomItems,
|
||||||
findCustomItemById,
|
findCustomItemById,
|
||||||
@@ -31,7 +32,9 @@ const {
|
|||||||
listUsers,
|
listUsers,
|
||||||
findPrimaryAdminUser,
|
findPrimaryAdminUser,
|
||||||
listAdminTierLists,
|
listAdminTierLists,
|
||||||
|
summarizeAdminTierLists,
|
||||||
findTierListById,
|
findTierListById,
|
||||||
|
updateAdminTierListMeta,
|
||||||
listAdminTemplateRequests,
|
listAdminTemplateRequests,
|
||||||
findTemplateRequestById,
|
findTemplateRequestById,
|
||||||
updateTemplateRequestStatus,
|
updateTemplateRequestStatus,
|
||||||
@@ -44,6 +47,7 @@ const {
|
|||||||
getImageAssetStats,
|
getImageAssetStats,
|
||||||
listRecentImageOptimizationJobs,
|
listRecentImageOptimizationJobs,
|
||||||
clearImageOptimizationJobs,
|
clearImageOptimizationJobs,
|
||||||
|
cleanupMissingUploadReferences,
|
||||||
} = require('../db')
|
} = require('../db')
|
||||||
const { requireAdmin } = require('../middleware/auth')
|
const { requireAdmin } = require('../middleware/auth')
|
||||||
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
|
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
|
||||||
@@ -294,6 +298,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
|||||||
router.get('/tierlists', requireAdmin, async (req, res) => {
|
router.get('/tierlists', 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(''),
|
||||||
|
gameId: z.string().trim().max(120).optional().default(''),
|
||||||
page: z.coerce.number().int().min(1).optional().default(1),
|
page: z.coerce.number().int().min(1).optional().default(1),
|
||||||
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
||||||
})
|
})
|
||||||
@@ -302,6 +307,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
|
|||||||
|
|
||||||
const result = await listAdminTierLists({
|
const result = await listAdminTierLists({
|
||||||
queryText: parsed.data.q,
|
queryText: parsed.data.q,
|
||||||
|
gameId: parsed.data.gameId,
|
||||||
page: parsed.data.page,
|
page: parsed.data.page,
|
||||||
limit: parsed.data.limit,
|
limit: parsed.data.limit,
|
||||||
currentUserId: req.session?.userId || '',
|
currentUserId: req.session?.userId || '',
|
||||||
@@ -309,6 +315,21 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
|
|||||||
res.json(result)
|
res.json(result)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/tierlists/stats', requireAdmin, async (req, res) => {
|
||||||
|
const schema = z.object({
|
||||||
|
q: z.string().trim().max(120).optional().default(''),
|
||||||
|
gameId: z.string().trim().max(120).optional().default(''),
|
||||||
|
})
|
||||||
|
const parsed = schema.safeParse(req.query)
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
|
const result = await summarizeAdminTierLists({
|
||||||
|
queryText: parsed.data.q,
|
||||||
|
gameId: parsed.data.gameId,
|
||||||
|
})
|
||||||
|
res.json(result)
|
||||||
|
})
|
||||||
|
|
||||||
router.get('/template-requests', requireAdmin, async (req, res) => {
|
router.get('/template-requests', requireAdmin, async (req, res) => {
|
||||||
const requests = await listAdminTemplateRequests({ statuses: ['pending', 'reviewing'] })
|
const requests = await listAdminTemplateRequests({ statuses: ['pending', 'reviewing'] })
|
||||||
res.json({ requests })
|
res.json({ requests })
|
||||||
@@ -386,6 +407,11 @@ router.post('/image-assets/stats/reset', requireAdmin, async (req, res) => {
|
|||||||
res.json({ deletedCount })
|
res.json({ deletedCount })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/image-assets/missing/cleanup', requireAdmin, async (req, res) => {
|
||||||
|
const result = await cleanupMissingUploadReferences()
|
||||||
|
res.json({ result })
|
||||||
|
})
|
||||||
|
|
||||||
async function removeUploadFiles(srcs) {
|
async function removeUploadFiles(srcs) {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
(srcs || []).map(async (src) => {
|
(srcs || []).map(async (src) => {
|
||||||
@@ -671,6 +697,34 @@ router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (
|
|||||||
res.json(result)
|
res.json(result)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.patch('/tierlists/:tierListId', requireAdmin, async (req, res) => {
|
||||||
|
const schema = z.object({
|
||||||
|
title: z.string().trim().min(1).max(120),
|
||||||
|
description: z.string().max(500).optional().default(''),
|
||||||
|
isPublic: z.boolean(),
|
||||||
|
})
|
||||||
|
const parsed = schema.safeParse(req.body)
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
|
const tierList = await findTierListById(req.params.tierListId)
|
||||||
|
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
|
const updated = await updateAdminTierListMeta({
|
||||||
|
id: tierList.id,
|
||||||
|
title: parsed.data.title,
|
||||||
|
description: parsed.data.description || '',
|
||||||
|
isPublic: parsed.data.isPublic,
|
||||||
|
})
|
||||||
|
res.json({ tierList: updated })
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/tierlists/:tierListId', requireAdmin, async (req, res) => {
|
||||||
|
const tierList = await findTierListById(req.params.tierListId)
|
||||||
|
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||||
|
await deleteTierList(tierList.id)
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
router.post('/template-requests/:requestId/approve', requireAdmin, async (req, res) => {
|
router.post('/template-requests/:requestId/approve', requireAdmin, async (req, res) => {
|
||||||
const templateRequest = await findTemplateRequestById(req.params.requestId)
|
const templateRequest = await findTemplateRequestById(req.params.requestId)
|
||||||
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|||||||
@@ -1,5 +1,52 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.76
|
||||||
|
- 왼쪽 사이드 레일을 접었을 때는 텍스트가 사라진 뒤에도 행 높이가 제각각이면 아이콘 전용 탐색기로 읽히지 않으므로, 아바타/검색/내비 항목의 높이를 같은 규격으로 통일하는 편이 맞다고 정리했다.
|
||||||
|
- 왼쪽 레일 검색은 화면에 따라 티어표 검색으로 바뀌면 사용자가 사이드 검색과 메인 검색 역할을 구분하기 어려우므로, 사이드는 게임 검색으로 고정하고 티어표 검색은 메인 화면 문맥에 맡기는 편이 더 자연스럽다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.75
|
||||||
|
- 관리자 공용 모달은 기본 카드 여백을 계속 쓰되, 내부에 자체 셸을 가진 대형 상세 모달까지 같은 패딩을 강제로 받으면 오히려 레이아웃이 무너지므로 예외 클래스로 분리하는 편이 맞다고 정리했다.
|
||||||
|
- 관리자 표기 링크는 텍스트만 두기보다, 추후 주소 변경이 쉬운 한 곳짜리 상수와 새 창 링크로 관리하는 편이 운영 측면에서 더 낫다고 판단했다.
|
||||||
|
- 왼쪽 사이드 레일 접힘 상태는 요소를 좁히는 것만으로는 높이와 정렬 문제가 계속 남으므로, 메타 텍스트는 실제로 숨기고 아이콘 중심 문법으로 따로 정리하는 편이 맞다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.74
|
||||||
|
- 관리자 공용 게임 선택 모달은 단순 검색만 제공하기보다, 현재 문맥에서 이미 선택 불가능한 대상을 `이미 추가됨`으로 명시하고 막아 주는 편이 운영 실수를 줄이는 데 더 효과적이라고 정리했다.
|
||||||
|
- 프로젝트 표기는 관리자 헤더 상단보다 사이드바 최하단의 작은 카피라이트 문구로 빼는 편이 정보 밀도를 덜 방해한다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.73
|
||||||
|
- 게임 선택이 여러 관리자 화면에 퍼지기 시작한 시점에서는 일부 화면만 셀렉트나 내부 리스트를 유지하기보다, 공용 검색 모달 하나로 통일하는 편이 장기적으로 더 일관되고 확장에 강하다고 정리했다.
|
||||||
|
- 검색 입력과 실행 버튼은 세로로 같은 문법으로 쌓기보다, 입력은 입력끼리 실행은 액션으로 읽히게 한 줄 배치로 적당히 구분해주는 편이 운영 화면에서 덜 답답하다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.72
|
||||||
|
- 라우트 복원용 watcher가 composable 반환값 초기화보다 먼저 돌 수 있는 구간에서는 직접 함수를 즉시 호출하기보다, 초기화 완료 뒤 실행되도록 한 템포 미루는 편이 안전하다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.71
|
||||||
|
- 관리자에서 게임 선택 지점이 늘어나는 구조라면 각 화면마다 셀렉트/긴 리스트를 따로 두기보다, 공용 검색 모달 하나로 통일하는 편이 이후 100개 이상 게임이 쌓여도 더 안정적이라고 정리했다.
|
||||||
|
- 아이템 모달은 참조 정보 정리 뒤에도 왼쪽 선택 요약 카드가 여전히 과하다고 판단해, 예전처럼 게임 선택 자체에 더 집중한 구조로 한 단계 더 되돌리는 편이 맞다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.70
|
||||||
|
- 관리자 티어표 목록은 페이지 단위 열람만으로는 운영 개입이 부족하므로, 게임별 필터와 카드 단위 관리 액션을 함께 붙여 실제 검수 도구로 쓰는 편이 맞다고 정리했다.
|
||||||
|
- 공개 여부는 문장 속 메타보다 배지로 따로 떼어 보여주는 편이 다른 관리자 카드들과 문법이 맞고, 공개/비공개 전환도 같은 관리 모달 안에서 바로 처리하는 쪽이 운영 흐름상 자연스럽다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.69
|
||||||
|
- 관리자 아이템 라이브러리의 참조 수는 저장 구조 설명에는 도움이 되지만 실제 운영에서는 대부분 의미가 약하므로, 카드와 모달에서 계속 전면에 두기보다 다시 숨기고 필요한 경우 내부 데이터로만 남기는 편이 맞다고 정리했다.
|
||||||
|
- 관리자 상단 요약 수치는 `활성/대기` 같은 상태 문구보다 실제 운영 판단에 바로 쓰이는 `공개/비공개 개수`가 더 중요하므로, 게임 관리와 티어표 관리 모두 공개 상태 집계를 먼저 보여주는 편이 낫다고 판단했다.
|
||||||
|
- 공개/비공개 수치는 현재 페이지 일부를 세면 오해가 생기기 쉬우므로, 검색/게임 기준 전체 결과를 집계하는 별도 관리자 API로 계산하는 편이 맞다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.68
|
||||||
|
- 관리자 아이템 상세는 새 모달을 겹쳐 올리는 방식보다 기존 모달 안에서 `왼쪽 선택 대상 / 오른쪽 작업과 참조 정보` 역할만 분명히 나누는 편이 더 안정적이라고 정리했다.
|
||||||
|
- 같은 이미지를 두 위치에서 반복 노출하면 “모달이 두 개 겹친 것처럼” 느껴질 수 있으므로, 선택 썸네일은 한 곳에만 두고 양쪽 패널은 각자 스크롤되는 구조로 정리하는 편이 맞다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.67
|
||||||
|
- 같은 이미지 공유 구조는 저장 효율에는 유리하지만 운영자가 관계를 읽기 어렵기 때문에, 카드 단계에서는 참조 수를 바로 보여주고 상세 모달에서는 같은 `src`를 가리키는 기록들을 함께 펼쳐 보여주는 편이 맞다고 정리했다.
|
||||||
|
- 삭제 제한을 과하게 두기보다, 삭제 전 영향 범위를 문구와 개수로 먼저 보여주는 쪽이 운영 측면에서 더 현실적이라고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.66
|
||||||
|
- 누락 참조 정리 도구는 커스텀 아이템 누락이 없어도 티어표/요청 썸네일 누락을 항상 따로 정리해야 하므로, 썸네일 정리를 커스텀 아이템 분기에 묶어두면 안 된다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.65
|
||||||
|
- 누락 파일 수치가 계속 쌓이는 상태에서는 원인 분석만으로는 운영 부담이 줄지 않으므로, 실제 파일이 없는 참조만 골라 썸네일/아바타/게임 아이템/커스텀 아이템 참조를 정리하는 관리자 액션을 제공하는 편이 맞다고 정리했다.
|
||||||
|
|
||||||
## 2026-04-02 v1.3.64
|
## 2026-04-02 v1.3.64
|
||||||
- 관리자 이미지 최적화 기록은 “재사용” 자체보다 이번 업로드와 재사용 자산의 관계가 더 중요하므로, 용량 숫자와 설명을 함께 보여주는 편이 운영자가 오해하지 않기 쉽다고 정리했다.
|
- 관리자 이미지 최적화 기록은 “재사용” 자체보다 이번 업로드와 재사용 자산의 관계가 더 중요하므로, 용량 숫자와 설명을 함께 보여주는 편이 운영자가 오해하지 않기 쉽다고 정리했다.
|
||||||
- 관리자 아이템 라이브러리는 `사용자 업로드 미사용만` 같은 단일 체크박스보다 출처와 사용 상태를 함께 고를 수 있는 필터 모드가 더 실용적이므로, 사용자·템플릿·보관 자산·미사용 상태를 분리해 보는 구조가 맞다고 판단했다.
|
- 관리자 아이템 라이브러리는 `사용자 업로드 미사용만` 같은 단일 체크박스보다 출처와 사용 상태를 함께 고를 수 있는 필터 모드가 더 실용적이므로, 사용자·템플릿·보관 자산·미사용 상태를 분리해 보는 구조가 맞다고 판단했다.
|
||||||
|
|||||||
20
docs/todo.md
20
docs/todo.md
@@ -1,12 +1,26 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
## 단기 확인
|
## 단기 확인
|
||||||
|
- 왼쪽 레일 축소 상태는 아이콘 줄 높이를 50px 기준으로 통일했으므로, 실제 데스크톱에서 아바타/검색/메뉴 아이콘이 시각적으로 같은 리듬으로 보이는지 한 번 더 QA한다.
|
||||||
|
- 왼쪽 레일 검색은 이제 항상 게임 검색으로 홈으로 이동하므로, 홈이 아닌 화면에서 사이드 검색 후 게임 목록 결과로 자연스럽게 이동하는지 한 번 더 QA한다.
|
||||||
|
- 앱 왼쪽 사이드 레일은 접힘 상태 레이아웃을 다시 손봤으므로, 데스크톱에서 접기/펼치기 반복 시 아바타 영역 높이, 아이콘 중앙 정렬, 검색 버튼 간격, 네비게이션 히트 영역이 모두 자연스러운지 한 번 더 QA한다.
|
||||||
|
- 관리자 우측 사이드바 하단 카피라이트 링크는 새 창 외부 링크로 바꿨으므로, 실제 클릭 시 `zenn.town` 연결과 hover 대비가 자연스러운지 한 번 더 QA한다.
|
||||||
|
- 관리자 아이템 상세 모달은 공통 패딩 예외 처리를 다시 넣었으므로, 대형 상세 모달과 일반 게임 선택 모달이 각각 기대한 크기로 보이는지 한 번 더 QA한다.
|
||||||
|
- 아이템 관리 모달의 공용 게임 선택기에서는 이미 연결된 게임이 비활성화되므로, 실제 운영 데이터에서 중복 연결 방지와 `이미 추가됨` 표시가 기대대로 읽히는지 한 번 더 QA한다.
|
||||||
|
- 공용 게임 선택 모달을 아이템 관리 모달에도 붙였으므로, 관리자 `게임 관리 / 전체 티어표 관리 / 아이템 관리` 세 흐름에서 같은 검색/선택 UX가 자연스럽게 느껴지는지 한 번 더 QA한다.
|
||||||
|
- 관리자 `/admin/games?gameId=...` 직접 진입과 새로고침에서 선택 게임이 정상 복원되고 콘솔 오류가 없는지 한 번 더 QA한다.
|
||||||
|
- 공용 `게임 선택` 검색 모달은 새로 붙였으므로, 게임 관리 선택/전체 티어표 관리 필터 양쪽에서 검색어 입력, 선택, 해제, 뒤로가기 흐름을 한 번 더 QA한다.
|
||||||
|
- 관리자 `전체 티어표 관리`의 게임 필터와 관리 모달은 새로 붙였으므로, 실제 운영 데이터에서 필터 전환 후 공개/비공개 집계, 제목 수정, 비공개 전환, 삭제 흐름이 자연스럽게 이어지는지 한 번 더 QA한다.
|
||||||
|
- 관리자 게임 관리 상단의 `전체 / 공개 / 비공개` 티어표 수치와 전체 티어표 관리 상단 집계가 실제 운영 데이터와 맞는지, 공개 전환 직후 숫자가 자연스럽게 갱신되는지 한 번 더 QA한다.
|
||||||
|
- 관리자 아이템 상세 모달은 왼쪽 선택 카드와 오른쪽 상세 본문으로 다시 정리했으므로, 데스크톱/모바일에서 긴 게임 목록과 긴 참조 목록이 각각 자연스럽게 스크롤되는지 한 번 더 QA한다.
|
||||||
- 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다.
|
- 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다.
|
||||||
- 티어표 만들기 화면의 보드 드롭존은 점선/높이/중앙 정렬로 존재감이 커졌으므로, 데스크톱과 모바일에서 파일 선택 버튼과 안내 문구 밀도를 한 번 더 QA한다.
|
- 티어표 만들기 화면의 보드 드롭존은 점선/높이/중앙 정렬로 존재감이 커졌으므로, 데스크톱과 모바일에서 파일 선택 버튼과 안내 문구 밀도를 한 번 더 QA한다.
|
||||||
- 관리자 썸네일 드롭존과 기본 아이템 추가 드롭존은 에디터 드롭존과 시각 문법을 맞췄으므로, 라이트모드와 좁은 화면에서 아이콘 대비와 배경 밝기가 과하지 않은지 한 번 더 QA한다.
|
- 관리자 썸네일 드롭존과 기본 아이템 추가 드롭존은 에디터 드롭존과 시각 문법을 맞췄으므로, 라이트모드와 좁은 화면에서 아이콘 대비와 배경 밝기가 과하지 않은지 한 번 더 QA한다.
|
||||||
- 커스텀 이미지가 많은 티어표를 저장할 때 커스텀 이름 정리 목록과 미배치 아이템 영역 순서가 실제로 안정적으로 유지되는지, 10장 이상 업로드한 상태로 한 번 더 QA한다.
|
- 커스텀 이미지가 많은 티어표를 저장할 때 커스텀 이름 정리 목록과 미배치 아이템 영역 순서가 실제로 안정적으로 유지되는지, 10장 이상 업로드한 상태로 한 번 더 QA한다.
|
||||||
- 관리자 이미지 최적화 현황은 최근 작업 라벨을 읽기 쉬운 이름으로 바꿨으므로, 실제 운영 데이터에서 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타` 흐름이 기대와 맞게 찍히는지 한 번 더 QA한다.
|
- 관리자 이미지 최적화 현황은 최근 작업 라벨을 읽기 쉬운 이름으로 바꿨으므로, 실제 운영 데이터에서 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타` 흐름이 기대와 맞게 찍히는지 한 번 더 QA한다.
|
||||||
- 관리자 게임 설정 카드를 본문 상단으로 옮겼으므로, 긴 게임 목록 상태에서 선택-썸네일 교체-공개 전환-삭제 흐름이 스크롤 없이 자연스러운지 한 번 더 QA한다.
|
- 관리자 게임 설정 카드를 본문 상단으로 옮겼으므로, 긴 게임 목록 상태에서 선택-썸네일 교체-공개 전환-삭제 흐름이 스크롤 없이 자연스러운지 한 번 더 QA한다.
|
||||||
|
- `누락 참조 정리`는 실제 파일이 없는 참조만 지우도록 넣었으므로, 운영 데이터에서 실행했을 때 누락 수치가 줄고 대표 썸네일/티어표/템플릿 요청 표시가 기대대로 비워지는지 한 번 더 QA한다.
|
||||||
|
- 아이템 관리의 같은 이미지 참조 요약과 상세 모달은 붙였으므로, 실제 운영 데이터에서 카드 수치와 모달 목록이 기대한 참조 관계와 맞는지 한 번 더 QA한다.
|
||||||
|
|
||||||
## 중기 개선
|
## 중기 개선
|
||||||
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
|
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
|
||||||
@@ -28,16 +42,10 @@
|
|||||||
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
|
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
|
||||||
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
|
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
|
||||||
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
|
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
|
||||||
|
|
||||||
- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다.
|
- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다.
|
||||||
|
|
||||||
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
|
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
|
||||||
|
|
||||||
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
|
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
|
||||||
|
|
||||||
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
|
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
|
||||||
|
|
||||||
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
|
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
|
||||||
- 관리자 템플릿 요청 카드와 전체 티어표 카드가 같은 문법으로 맞춰졌으므로, 이후에는 라이트모드까지 포함해 두 카드의 썸네일 높이·입력창 밀도·아이템 그리드를 한 번 더 비교 QA한다.
|
- 관리자 템플릿 요청 카드와 전체 티어표 카드가 같은 문법으로 맞춰졌으므로, 이후에는 라이트모드까지 포함해 두 카드의 썸네일 높이·입력창 밀도·아이템 그리드를 한 번 더 비교 QA한다.
|
||||||
|
|
||||||
- 템플릿 요청 저장 흐름은 저장된 티어표 기준으로 바뀌었으므로, 이후 실제 데이터로 빈 제목 저장 시 자동 생성 제목·요청 버튼 노출 시점·관리자 요청 미리보기 밀도를 한 번 더 비교 QA한다.
|
- 템플릿 요청 저장 흐름은 저장된 티어표 기준으로 바뀌었으므로, 이후 실제 데이터로 빈 제목 저장 시 자동 생성 제목·요청 버튼 노출 시점·관리자 요청 미리보기 밀도를 한 번 더 비교 QA한다.
|
||||||
|
|||||||
@@ -1,5 +1,60 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.76
|
||||||
|
- 앱 왼쪽 사이드 레일은 축소 상태에서 아바타, 검색 버튼, 네비게이션 아이콘 버튼 높이를 모두 50px 기준으로 맞추고 검색 아래 여백도 정리해, 아이콘만 보이는 상태에서도 각 줄 높이가 제각각처럼 보이지 않게 정리함.
|
||||||
|
- 왼쪽 사이드 검색은 라우트에 따라 의미가 바뀌지 않도록 `게임 템플릿 검색`으로 고정하고, 축소 검색 모달 역시 같은 플레이스홀더와 같은 동작으로 홈 게임 목록 검색을 수행하도록 통일함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.75
|
||||||
|
- 관리자 공용 모달 카드의 기본 `padding: 20px`는 그대로 두되, 아이템 상세처럼 내부 레이아웃이 이미 큰 셸을 가진 모달은 `modalCard--customItem`에서 다시 덮어쓰지 않도록 분리해 상세 모달 크기와 내부 배치가 무너지지 않게 정리함.
|
||||||
|
- 관리자 우측 사이드바 최하단의 카피라이트 문구는 이제 별도 상수 URL을 참조하는 외부 링크로 바꿔 새 창에서 열리게 했고, 추후 주소를 바꿔야 할 때 한 곳만 수정하면 되도록 정리함.
|
||||||
|
- 앱 왼쪽 사이드 레일의 접힘 상태는 메타 텍스트를 단순히 투명하게 남겨두는 대신 실제로 숨기고, 아바타/검색/내비 아이콘을 다시 중앙 정렬해 접었을 때 높이가 비정상적으로 늘어나거나 간격이 남아 보이던 레이아웃을 정리함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.74
|
||||||
|
- 아이템 관리 상세에서 템플릿 추가 대상 게임을 고를 때, 이미 해당 이미지가 연결된 게임은 공용 게임 선택 모달에서 `이미 추가됨`으로 표시하고 비활성화해 중복 추가 실수를 미리 막도록 정리함.
|
||||||
|
- 관리자 우측 사이드바 최하단에는 작은 카피라이트 문구를 추가해, 헤더에 관리 정보만 남기고 프로젝트 표기는 하단에서 조용히 보이도록 정리함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.73
|
||||||
|
- 전체 티어표 관리 카드 썸네일은 `draggable="false"`로 바꿔, 미리보기 진입 시 브라우저 기본 이미지 드래그가 클릭을 방해하지 않도록 정리함.
|
||||||
|
- 관리자 사이드바의 검색 입력과 검색 버튼은 한 줄로 묶어, 입력/선택/실행 버튼이 모두 같은 크기의 세로 스택처럼 보이던 답답함을 조금 줄이고 역할 구분을 더 분명하게 함.
|
||||||
|
- 아이템 관리 상세 모달의 템플릿 추가 대상 선택도 내부 전용 게임 리스트 대신 공용 `게임 선택` 검색 모달을 쓰도록 바꿔, 향후 게임 수가 많아져도 같은 선택 문법으로 이어지게 정리함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.72
|
||||||
|
- 관리자 화면 초기화 중 `/admin/games?gameId=...` 경로를 즉시 처리하는 watcher가 `loadGame` 초기화보다 먼저 실행되어 브라우저 콘솔에 `Cannot access 'loadGame' before initialization` 오류가 나던 문제를 수정함.
|
||||||
|
- 게임 라우트 진입 시 실제 게임 로딩 호출은 컴포넌트 초기화가 끝난 뒤 microtask로 미뤄 실행하도록 바꿔, 첫 진입/새로고침에서도 게임 선택 복원 흐름이 안전하게 이어지게 정리함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.71
|
||||||
|
- 관리자 아이템 모달은 최근 추가했던 선택 요약 카드를 다시 걷어내고, 더 단순한 `게임 선택 패널 + 상세 작업 영역` 구조로 되돌려 이전 흐름에 가깝게 정리함.
|
||||||
|
- 관리자 `게임 관리`와 `전체 티어표 관리`의 게임 선택은 긴 셀렉트/목록 대신 공용 `게임 선택` 검색 모달로 바꿔, 게임 수가 많아져도 이름·ID 검색으로 바로 찾아 선택할 수 있게 함.
|
||||||
|
- 전체 티어표 관리의 게임 필터 해제도 같은 모달 흐름에 맞춰 `모든 게임 보기`로 처리하고, 사이드바에는 현재 선택된 게임만 요약 카드로 보여줘 긴 리스트가 계속 쌓이지 않게 정리함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.70
|
||||||
|
- 관리자 `전체 티어표 관리`는 이제 게임별 필터를 지원해, 특정 게임에서 만들어진 티어표만 따로 골라 보며 공개/비공개 분포를 확인할 수 있게 함.
|
||||||
|
- 전체 티어표 카드는 공개 여부를 텍스트 대신 다른 관리자 화면과 같은 배지 형식으로 표시하고, 카드의 `관리` 액션에서 제목·설명 수정, 공개/비공개 전환, 삭제를 바로 처리할 수 있게 보강함.
|
||||||
|
- 이를 위해 관리자 전용 티어표 수정/삭제 API와 게임 기준/검색 기준 공개 집계 로직을 함께 추가해, 관리자 화면에서 비공개 개입과 운영성 검수가 한 흐름으로 이어지게 정리함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.69
|
||||||
|
- 관리자 아이템 라이브러리는 참조 수/공유 기록 UI가 실제 운영 판단에 비해 노이즈가 커 보여 카드 수치, 상세 모달의 같은 이미지 참조 섹션, 삭제 확인 문구의 참조 강조를 걷어내고 다시 항목 자체 관리 흐름 위주로 정리함.
|
||||||
|
- 관리자 게임 관리 상단 요약은 더 이상 `선택 상태`처럼 추상적인 문구를 보여주지 않고, 선택된 게임 기준으로 만들어진 티어표의 `전체 / 공개 / 비공개` 개수를 바로 보여주도록 바꿈.
|
||||||
|
- 전체 티어표 관리 상단에도 검색 결과 기준 `전체 / 공개 / 비공개` 수치를 함께 노출하고, 이를 위해 관리자 티어표 집계 API를 별도로 추가해 페이지 단위가 아니라 실제 전체 결과 기준 숫자를 안정적으로 표시함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.68
|
||||||
|
- 관리자 아이템 상세 모달은 같은 이미지를 왼쪽 선택 카드와 오른쪽 본문에서 두 번 보여주던 중복 미리보기를 제거해, 한 모달 안에서 정보가 겹쳐 보이던 문제를 정리함.
|
||||||
|
- 왼쪽 게임 선택 패널과 오른쪽 상세 정보 패널은 각각 독립 스크롤이 되도록 바꾸고, 스크롤바도 다시 보이게 조정해 긴 목록이나 긴 참조 정보가 있어도 레이아웃이 깨지지 않고 탐색할 수 있게 함.
|
||||||
|
- 현재 선택한 이미지 요약 카드에는 별도 배경과 테두리를 추가해, 기존 클릭 모달의 “선택 대상”과 오른쪽 작업 영역이 한눈에 구분되도록 시각 계층을 정리함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.67
|
||||||
|
- 관리자 아이템 관리 카드에는 이제 같은 `src`를 공유하는 참조 수와 연결 게임 수를 함께 표시해, 같은 이미지가 얼마나 넓게 쓰이는지 목록 단계에서 바로 파악할 수 있게 함.
|
||||||
|
- 아이템 상세 모달은 왼쪽 패널 상단에 현재 선택한 이미지와 `총 참조 / 사용자 업로드 / 템플릿 항목 / 보관 자산` 요약을 보여주고, 오른쪽에는 같은 이미지를 가리키는 다른 기록 목록을 함께 표시해 실제로 어떤 참조들이 묶여 있는지 모달 안에서 바로 확인할 수 있게 함.
|
||||||
|
- 삭제 확인 문구도 이제 단순 타입 설명만 하지 않고 `같은 이미지 참조 n건 중 현재 항목만 다룬다`는 영향을 함께 보여, 삭제 전에 범위를 더 명확히 이해할 수 있게 정리함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.66
|
||||||
|
- `누락 참조 정리`는 처음엔 누락 커스텀 아이템이 있을 때만 티어표/요청 썸네일까지 함께 보던 조건 때문에, 누락 썸네일만 남아 있으면 수치가 줄지 않던 문제가 있었으므로 분기를 풀어 티어표 썸네일과 요청 썸네일도 항상 실제 파일 존재 여부를 확인해 정리하도록 수정함.
|
||||||
|
- 정리 완료 메시지에도 `티어표 썸네일`, `요청 썸네일` 항목을 추가해 어떤 종류의 누락 참조가 실제로 정리됐는지 바로 알 수 있게 함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.65
|
||||||
|
- 관리자 이미지 최적화 패널에 `누락 참조 정리` 액션을 추가해, 실제 파일이 없는 `/uploads/...` 참조만 대상으로 썸네일/아바타는 비우고 누락된 게임 아이템·커스텀 아이템은 관련 티어표/템플릿 요청 참조와 함께 정리할 수 있게 함.
|
||||||
|
- 따라서 예전 수동 파일 정리나 레거시 데이터로 인해 쌓인 `누락 파일`은 단순 통계로만 남지 않고, 관리자 화면에서 실제로 줄일 수 있는 관리 도구를 갖게 됨.
|
||||||
|
|
||||||
## 2026-04-02 v1.3.64
|
## 2026-04-02 v1.3.64
|
||||||
- 관리자 이미지 최적화 최근 작업에서 `기존 최적화 파일 재사용` 건은 이제 `이번 업로드 용량 · 재사용 자산 용량` 형태로 표시하고, 동일한 최적화 결과가 이미 있어 새 파일을 다시 만들지 않았다는 설명을 함께 보여 혼동을 줄임.
|
- 관리자 이미지 최적화 최근 작업에서 `기존 최적화 파일 재사용` 건은 이제 `이번 업로드 용량 · 재사용 자산 용량` 형태로 표시하고, 동일한 최적화 결과가 이미 있어 새 파일을 다시 만들지 않았다는 설명을 함께 보여 혼동을 줄임.
|
||||||
- 관리자 아이템 관리는 기존의 `미사용 사용자 업로드만 보기` 체크박스 대신 `전체 / 사용자 업로드 / 템플릿 사용 이미지 / 관리자 보관 자산 / 미사용 사용자 / 미사용 관리자` 필터로 바꿔, 관리자 업로드 자산도 별도로 확인할 수 있게 정리함.
|
- 관리자 아이템 관리는 기존의 `미사용 사용자 업로드만 보기` 체크박스 대신 `전체 / 사용자 업로드 / 템플릿 사용 이미지 / 관리자 보관 자산 / 미사용 사용자 / 미사용 관리자` 필터로 바꿔, 관리자 업로드 자산도 별도로 확인할 수 있게 정리함.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const { toasts, dismissToast } = useToast()
|
|||||||
const leftRailCollapsed = ref(false)
|
const leftRailCollapsed = ref(false)
|
||||||
const rightRailOpen = ref(true)
|
const rightRailOpen = ref(true)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const searchPlaceholder = computed(() => (route.name === 'home' ? '게임 템플릿 검색' : '전체 티어표 검색'))
|
const leftRailSearchPlaceholder = '게임 템플릿 검색'
|
||||||
const isCollapsedSearchOpen = ref(false)
|
const isCollapsedSearchOpen = ref(false)
|
||||||
const isGuideModalOpen = ref(false)
|
const isGuideModalOpen = ref(false)
|
||||||
const themeMode = ref('dark')
|
const themeMode = ref('dark')
|
||||||
@@ -391,11 +391,7 @@ function handleLeftRailSearch() {
|
|||||||
function submitGlobalSearch() {
|
function submitGlobalSearch() {
|
||||||
const query = (searchQuery.value || '').trim()
|
const query = (searchQuery.value || '').trim()
|
||||||
isCollapsedSearchOpen.value = false
|
isCollapsedSearchOpen.value = false
|
||||||
if (route.name === 'home') {
|
router.push(query ? `/?q=${encodeURIComponent(query)}` : '/')
|
||||||
router.push(query ? `/?q=${encodeURIComponent(query)}` : '/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
router.push(query ? `/search?q=${encodeURIComponent(query)}` : '/search')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -444,7 +440,7 @@ function submitGlobalSearch() {
|
|||||||
<SvgIcon :src="iconSearch" :size="24" />
|
<SvgIcon :src="iconSearch" :size="24" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : searchPlaceholder" />
|
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<nav class="leftNav">
|
<nav class="leftNav">
|
||||||
@@ -513,12 +509,12 @@ function submitGlobalSearch() {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="searchPlaceholder" @click.self="closeCollapsedSearch">
|
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="leftRailSearchPlaceholder" @click.self="closeCollapsedSearch">
|
||||||
<form class="collapsedSearchBar" @submit.prevent="submitGlobalSearch">
|
<form class="collapsedSearchBar" @submit.prevent="submitGlobalSearch">
|
||||||
<span class="collapsedSearchBar__icon">
|
<span class="collapsedSearchBar__icon">
|
||||||
<SvgIcon :src="iconSearch" :size="24" />
|
<SvgIcon :src="iconSearch" :size="24" />
|
||||||
</span>
|
</span>
|
||||||
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="searchPlaceholder" autofocus />
|
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="leftRailSearchPlaceholder" autofocus />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -953,21 +949,24 @@ function submitGlobalSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .appUserCard {
|
.appShell--leftCollapsed .appUserCard {
|
||||||
margin-bottom: 10px;
|
min-height: 50px;
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .appUserCard__button,
|
.appShell--leftCollapsed .appUserCard__button,
|
||||||
.appShell--leftCollapsed .appUserCard__guest {
|
.appShell--leftCollapsed .appUserCard__guest {
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .appUserCard__meta,
|
.appShell--leftCollapsed .appUserCard__meta,
|
||||||
.appShell--leftCollapsed .leftNav__label,
|
.appShell--leftCollapsed .leftNav__label,
|
||||||
.appShell--leftCollapsed .searchStub__input {
|
.appShell--leftCollapsed .searchStub__input {
|
||||||
opacity: 0;
|
display: none;
|
||||||
max-width: 0;
|
|
||||||
transform: translateX(-4px);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .appUserCard__avatar {
|
.appShell--leftCollapsed .appUserCard__avatar {
|
||||||
@@ -976,11 +975,16 @@ function submitGlobalSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .searchStub {
|
.appShell--leftCollapsed .searchStub {
|
||||||
|
height: 50px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 11px 0;
|
||||||
|
gap: 0;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .searchStub__iconButton {
|
.appShell--leftCollapsed .searchStub__iconButton {
|
||||||
width: auto;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .leftNav {
|
.appShell--leftCollapsed .leftNav {
|
||||||
@@ -988,14 +992,28 @@ function submitGlobalSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .leftNav__item {
|
.appShell--leftCollapsed .leftNav__item {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 50px;
|
||||||
|
height: 50px;
|
||||||
|
padding: 11px 0;
|
||||||
|
gap: 0;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.appShell--leftCollapsed .leftNav__glyph {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .leftRail__bottom {
|
.appShell--leftCollapsed .leftRail__bottom {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .leftRail__content {
|
.appShell--leftCollapsed .leftRail__content {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
justify-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const props = defineProps({
|
|||||||
adminTierListPage: { type: Number, required: true },
|
adminTierListPage: { type: Number, required: true },
|
||||||
adminTierListPageCount: { type: Number, required: true },
|
adminTierListPageCount: { type: Number, required: true },
|
||||||
adminTierListTotal: { type: Number, required: true },
|
adminTierListTotal: { type: Number, required: true },
|
||||||
|
adminTierListStats: { type: Object, required: true },
|
||||||
|
openAdminTierListManageModal: { type: Function, required: true },
|
||||||
moveAdminTierListPage: { type: Function, required: true },
|
moveAdminTierListPage: { type: Function, required: true },
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -38,7 +40,7 @@ const props = defineProps({
|
|||||||
<article v-for="request in props.templateRequests" :key="request.id" class="tierAdminCard templateRequestCard templateRequestCard--aligned">
|
<article v-for="request in props.templateRequests" :key="request.id" class="tierAdminCard templateRequestCard templateRequestCard--aligned">
|
||||||
<div class="templateRequestCard__side">
|
<div class="templateRequestCard__side">
|
||||||
<button class="tierAdminCard__preview templateRequestCard__preview" type="button" @click="props.openTemplateRequestPreview(request)">
|
<button class="tierAdminCard__preview templateRequestCard__preview" type="button" @click="props.openTemplateRequestPreview(request)">
|
||||||
<img v-if="request.thumbnailSrc" class="tierAdminCard__thumb" :src="toApiUrl(request.thumbnailSrc)" :alt="request.sourceTierListTitle" />
|
<img v-if="request.thumbnailSrc" class="tierAdminCard__thumb" :src="toApiUrl(request.thumbnailSrc)" :alt="request.sourceTierListTitle" draggable="false" />
|
||||||
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
|
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
|
||||||
</button>
|
</button>
|
||||||
<div class="templateRequestCard__thumbMeta">
|
<div class="templateRequestCard__thumbMeta">
|
||||||
@@ -128,6 +130,11 @@ const props = defineProps({
|
|||||||
<div class="sectionHeader">
|
<div class="sectionHeader">
|
||||||
<div>
|
<div>
|
||||||
<div class="panel__title">전체 티어표 관리</div>
|
<div class="panel__title">전체 티어표 관리</div>
|
||||||
|
<div class="tierAdminHeaderStats">
|
||||||
|
<span class="pill">전체 {{ props.adminTierListStats.total || 0 }}개</span>
|
||||||
|
<span class="pill pill--soft">공개 {{ props.adminTierListStats.publicCount || 0 }}개</span>
|
||||||
|
<span class="pill pill--soft">비공개 {{ props.adminTierListStats.privateCount || 0 }}개</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,7 +142,7 @@ const props = defineProps({
|
|||||||
<div v-else class="tierAdminList">
|
<div v-else class="tierAdminList">
|
||||||
<article v-for="tierList in props.adminTierLists" :key="tierList.id" class="tierAdminCard">
|
<article v-for="tierList in props.adminTierLists" :key="tierList.id" class="tierAdminCard">
|
||||||
<button class="tierAdminCard__preview" type="button" @click="props.openAdminTierList(tierList)">
|
<button class="tierAdminCard__preview" type="button" @click="props.openAdminTierList(tierList)">
|
||||||
<img v-if="props.tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="props.tierListThumbUrl(tierList)" :alt="tierList.title" />
|
<img v-if="props.tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="props.tierListThumbUrl(tierList)" :alt="tierList.title" draggable="false" />
|
||||||
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
|
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -145,13 +152,14 @@ const props = defineProps({
|
|||||||
<div class="tierAdminCard__title">{{ tierList.title }}</div>
|
<div class="tierAdminCard__title">{{ tierList.title }}</div>
|
||||||
<div v-if="tierList.description" class="tierAdminCard__desc">{{ tierList.description }}</div>
|
<div v-if="tierList.description" class="tierAdminCard__desc">{{ tierList.description }}</div>
|
||||||
<div class="tierAdminCard__meta">
|
<div class="tierAdminCard__meta">
|
||||||
{{ tierList.gameName || tierList.gameId }} · {{ props.tierListAuthorDisplayName(tierList) }} · {{ props.tierListVisibilityLabel(tierList) }}
|
{{ tierList.gameName || tierList.gameId }} · {{ props.tierListAuthorDisplayName(tierList) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="tierAdminCard__meta">{{ props.fmt(tierList.updatedAt) }}</div>
|
<div class="tierAdminCard__meta">{{ props.fmt(tierList.updatedAt) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tierAdminCard__stats">
|
<div class="tierAdminCard__stats">
|
||||||
|
<span class="pill" :class="tierList.isPublic ? 'pill--public' : 'pill--private'">{{ props.tierListVisibilityLabel(tierList) }}</span>
|
||||||
<span class="pill">전체 아이템 {{ tierList.itemCount }}개</span>
|
<span class="pill">전체 아이템 {{ tierList.itemCount }}개</span>
|
||||||
<span class="pill" :class="{ 'pill--accent': tierList.extraItemCount > 0 }">추가 아이템 {{ tierList.extraItemCount }}개</span>
|
<span class="pill" :class="{ 'pill--accent': tierList.extraItemCount > 0 }">추가 아이템 {{ tierList.extraItemCount }}개</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,6 +179,10 @@ const props = defineProps({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tierAdminSection__actions">
|
||||||
|
<button class="btn btn--ghost btn--small" @click="props.openAdminTierListManageModal(tierList)">관리</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ export function useAdminCustomItems({
|
|||||||
customItemModalDraftLabel,
|
customItemModalDraftLabel,
|
||||||
customItemModalLabelSaving,
|
customItemModalLabelSaving,
|
||||||
customItemModalTargetGameId,
|
customItemModalTargetGameId,
|
||||||
customItemModalGameQuery,
|
|
||||||
customItemModalGameSort,
|
|
||||||
games,
|
games,
|
||||||
selectedGameId,
|
selectedGameId,
|
||||||
refreshCustomItems,
|
refreshCustomItems,
|
||||||
@@ -62,8 +60,6 @@ export function useAdminCustomItems({
|
|||||||
modalTargetCustomItem.value = item || null
|
modalTargetCustomItem.value = item || null
|
||||||
customItemModalDraftLabel.value = item?.label || ''
|
customItemModalDraftLabel.value = item?.label || ''
|
||||||
customItemModalTargetGameId.value = ''
|
customItemModalTargetGameId.value = ''
|
||||||
customItemModalGameQuery.value = ''
|
|
||||||
customItemModalGameSort.value = 'recent'
|
|
||||||
customItemModalOpen.value = true
|
customItemModalOpen.value = true
|
||||||
pushCustomItemModalHistoryState()
|
pushCustomItemModalHistoryState()
|
||||||
}
|
}
|
||||||
@@ -75,8 +71,6 @@ export function useAdminCustomItems({
|
|||||||
customItemModalDraftLabel.value = ''
|
customItemModalDraftLabel.value = ''
|
||||||
customItemModalLabelSaving.value = false
|
customItemModalLabelSaving.value = false
|
||||||
customItemModalTargetGameId.value = ''
|
customItemModalTargetGameId.value = ''
|
||||||
customItemModalGameQuery.value = ''
|
|
||||||
customItemModalGameSort.value = 'recent'
|
|
||||||
|
|
||||||
if (fromPopState) {
|
if (fromPopState) {
|
||||||
customItemModalHistoryActive.value = false
|
customItemModalHistoryActive.value = false
|
||||||
|
|||||||
@@ -45,8 +45,16 @@ export const api = {
|
|||||||
request(
|
request(
|
||||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
|
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
|
||||||
),
|
),
|
||||||
listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
|
listAdminTierLists: ({ q = '', gameId = '', page = 1, limit = 50 } = {}) =>
|
||||||
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
|
request(
|
||||||
|
`/api/admin/tierlists?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`
|
||||||
|
),
|
||||||
|
getAdminTierListStats: ({ q = '', gameId = '' } = {}) =>
|
||||||
|
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}`),
|
||||||
|
updateAdminTierList: (tierListId, payload) =>
|
||||||
|
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }),
|
||||||
|
deleteAdminTierList: (tierListId) =>
|
||||||
|
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'DELETE' }),
|
||||||
listAdminTemplateRequests: () => request('/api/admin/template-requests'),
|
listAdminTemplateRequests: () => request('/api/admin/template-requests'),
|
||||||
getAdminImageAssetStats: ({ month = '', limit = 12 } = {}) => {
|
getAdminImageAssetStats: ({ month = '', limit = 12 } = {}) => {
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
@@ -55,6 +63,7 @@ export const api = {
|
|||||||
return request(`/api/admin/image-assets/stats?${query.toString()}`)
|
return request(`/api/admin/image-assets/stats?${query.toString()}`)
|
||||||
},
|
},
|
||||||
resetAdminImageAssetStats: (payload) => request('/api/admin/image-assets/stats/reset', { method: 'POST', body: payload || {} }),
|
resetAdminImageAssetStats: (payload) => request('/api/admin/image-assets/stats/reset', { method: 'POST', body: payload || {} }),
|
||||||
|
cleanupAdminMissingImageReferences: () => request('/api/admin/image-assets/missing/cleanup', { method: 'POST', body: {} }),
|
||||||
listAdminUnusedImageAssets: ({ limit = 100, minAgeHours = 24 } = {}) => request(`/api/admin/image-assets/orphans?limit=${encodeURIComponent(limit)}&minAgeHours=${encodeURIComponent(minAgeHours)}`),
|
listAdminUnusedImageAssets: ({ limit = 100, minAgeHours = 24 } = {}) => request(`/api/admin/image-assets/orphans?limit=${encodeURIComponent(limit)}&minAgeHours=${encodeURIComponent(minAgeHours)}`),
|
||||||
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
|
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
|
||||||
promoteAdminCustomItem: (itemId, payload) =>
|
promoteAdminCustomItem: (itemId, payload) =>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const router = useRouter()
|
|||||||
const globalRightRailOpen = inject('rightRailOpen', ref(true))
|
const globalRightRailOpen = inject('rightRailOpen', ref(true))
|
||||||
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
|
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
|
||||||
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
||||||
|
const ADMIN_COPYRIGHT_URL = 'https://zenn.town/@murabito'
|
||||||
|
|
||||||
const activeTab = ref('featured')
|
const activeTab = ref('featured')
|
||||||
const tierlistsMode = ref('requests')
|
const tierlistsMode = ref('requests')
|
||||||
@@ -34,8 +35,10 @@ const games = ref([])
|
|||||||
const selectedGameId = ref('')
|
const selectedGameId = ref('')
|
||||||
const selectedGame = ref(null)
|
const selectedGame = ref(null)
|
||||||
const featuredGameIds = ref([])
|
const featuredGameIds = ref([])
|
||||||
const gameAdminQuery = ref('')
|
const gamePickerModalOpen = ref(false)
|
||||||
const gameAdminSort = ref('recent')
|
const gamePickerMode = ref('game-admin')
|
||||||
|
const gamePickerQuery = ref('')
|
||||||
|
const gamePickerSort = ref('recent')
|
||||||
|
|
||||||
const customItems = ref([])
|
const customItems = ref([])
|
||||||
const customItemQuery = ref('')
|
const customItemQuery = ref('')
|
||||||
@@ -44,14 +47,15 @@ const customItemLimit = ref(50)
|
|||||||
const customItemTotal = ref(0)
|
const customItemTotal = ref(0)
|
||||||
const customItemFilter = ref('all')
|
const customItemFilter = ref('all')
|
||||||
const customItemModalTargetGameId = ref('')
|
const customItemModalTargetGameId = ref('')
|
||||||
const customItemModalGameQuery = ref('')
|
|
||||||
const customItemModalGameSort = ref('recent')
|
|
||||||
|
|
||||||
const adminTierLists = ref([])
|
const adminTierLists = ref([])
|
||||||
const adminTierListQuery = ref('')
|
const adminTierListQuery = ref('')
|
||||||
|
const adminTierListGameId = ref('')
|
||||||
const adminTierListPage = ref(1)
|
const adminTierListPage = ref(1)
|
||||||
const adminTierListLimit = ref(50)
|
const adminTierListLimit = ref(50)
|
||||||
const adminTierListTotal = ref(0)
|
const adminTierListTotal = ref(0)
|
||||||
|
const adminTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0 })
|
||||||
|
const selectedGameTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0 })
|
||||||
const templateRequests = ref([])
|
const templateRequests = ref([])
|
||||||
const importModalOpen = ref(false)
|
const importModalOpen = ref(false)
|
||||||
const importModalMode = ref('existing')
|
const importModalMode = ref('existing')
|
||||||
@@ -62,6 +66,7 @@ const importModalNewGameId = ref('')
|
|||||||
const importModalNewGameName = ref('')
|
const importModalNewGameName = ref('')
|
||||||
const previewModalOpen = ref(false)
|
const previewModalOpen = ref(false)
|
||||||
const previewTierList = ref(null)
|
const previewTierList = ref(null)
|
||||||
|
const adminTierListManageModalOpen = ref(false)
|
||||||
const activeTemplateRequest = ref(null)
|
const activeTemplateRequest = ref(null)
|
||||||
const userEditModalOpen = ref(false)
|
const userEditModalOpen = ref(false)
|
||||||
const userPasswordModalOpen = ref(false)
|
const userPasswordModalOpen = ref(false)
|
||||||
@@ -79,6 +84,12 @@ const modalUserDraftIsAdmin = ref(false)
|
|||||||
const modalTargetCustomItem = ref(null)
|
const modalTargetCustomItem = ref(null)
|
||||||
const customItemModalDraftLabel = ref('')
|
const customItemModalDraftLabel = ref('')
|
||||||
const customItemModalLabelSaving = ref(false)
|
const customItemModalLabelSaving = ref(false)
|
||||||
|
const modalTargetAdminTierList = ref(null)
|
||||||
|
const adminTierListDraftTitle = ref('')
|
||||||
|
const adminTierListDraftDescription = ref('')
|
||||||
|
const adminTierListDraftIsPublic = ref(false)
|
||||||
|
const adminTierListSaving = ref(false)
|
||||||
|
const adminTierListDeleting = ref(false)
|
||||||
|
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
const userQuery = ref('')
|
const userQuery = ref('')
|
||||||
@@ -90,6 +101,7 @@ const imageRecentJobs = ref([])
|
|||||||
const imageStatsMonth = ref('')
|
const imageStatsMonth = ref('')
|
||||||
const imageStatsLimit = ref(12)
|
const imageStatsLimit = ref(12)
|
||||||
const imageResetModalOpen = ref(false)
|
const imageResetModalOpen = ref(false)
|
||||||
|
const imageMissingCleanupBusy = ref(false)
|
||||||
|
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const success = ref('')
|
const success = ref('')
|
||||||
@@ -186,8 +198,8 @@ const featuredGames = computed(() =>
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
)
|
)
|
||||||
const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id)))
|
const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id)))
|
||||||
const filteredAdminGames = computed(() => {
|
const filteredGamePickerGames = computed(() => {
|
||||||
const query = gameAdminQuery.value.trim().toLowerCase()
|
const query = gamePickerQuery.value.trim().toLowerCase()
|
||||||
const list = games.value.filter((game) => {
|
const list = games.value.filter((game) => {
|
||||||
if (!query) return true
|
if (!query) return true
|
||||||
const haystack = `${game.name || ''} ${game.id || ''}`.toLowerCase()
|
const haystack = `${game.name || ''} ${game.id || ''}`.toLowerCase()
|
||||||
@@ -195,10 +207,11 @@ const filteredAdminGames = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return list.slice().sort((a, b) => {
|
return list.slice().sort((a, b) => {
|
||||||
if (gameAdminSort.value === 'oldest') return Number(a.createdAt || 0) - Number(b.createdAt || 0)
|
if (gamePickerSort.value === 'oldest') return Number(a.createdAt || 0) - Number(b.createdAt || 0)
|
||||||
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
|
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
const customItemTargetGame = computed(() => games.value.find((game) => game.id === customItemModalTargetGameId.value) || null)
|
||||||
const importModalItemCount = computed(() => importModalItems.value.length)
|
const importModalItemCount = computed(() => importModalItems.value.length)
|
||||||
const activeTabTitle = computed(() => {
|
const activeTabTitle = computed(() => {
|
||||||
if (activeTab.value === 'featured') return '목록 관리'
|
if (activeTab.value === 'featured') return '목록 관리'
|
||||||
@@ -227,7 +240,6 @@ const activeTabDescription = computed(() => {
|
|||||||
return '계정 정보, 권한, 비밀번호와 최근 활동을 더 가볍게 확인하고 수정합니다.'
|
return '계정 정보, 권한, 비밀번호와 최근 활동을 더 가볍게 확인하고 수정합니다.'
|
||||||
})
|
})
|
||||||
const adminOverviewStats = computed(() => {
|
const adminOverviewStats = computed(() => {
|
||||||
const publishedTierLists = adminTierLists.value.filter((tierList) => tierList.isPublic).length
|
|
||||||
const pendingRequests = templateRequests.value.length
|
const pendingRequests = templateRequests.value.length
|
||||||
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length
|
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length
|
||||||
const adminCount = users.value.filter((user) => user.isAdmin).length
|
const adminCount = users.value.filter((user) => user.isAdmin).length
|
||||||
@@ -242,7 +254,9 @@ const adminOverviewStats = computed(() => {
|
|||||||
if (activeTab.value === 'game-admin') {
|
if (activeTab.value === 'game-admin') {
|
||||||
return [
|
return [
|
||||||
{ label: '전체 게임', value: `${games.value.length}` },
|
{ label: '전체 게임', value: `${games.value.length}` },
|
||||||
{ label: '선택 상태', value: hasSelectedGame.value ? '활성' : '대기' },
|
{ label: '티어표 전체', value: `${selectedGameTierListStats.value.total || 0}` },
|
||||||
|
{ label: '공개', value: `${selectedGameTierListStats.value.publicCount || 0}` },
|
||||||
|
{ label: '비공개', value: `${selectedGameTierListStats.value.privateCount || 0}` },
|
||||||
{ label: '기본 아이템', value: `${selectedGame.value?.items?.length || 0}` },
|
{ label: '기본 아이템', value: `${selectedGame.value?.items?.length || 0}` },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -262,8 +276,9 @@ const adminOverviewStats = computed(() => {
|
|||||||
{ label: '업데이트 요청', value: `${templateRequests.value.filter((request) => request.type === 'update').length}` },
|
{ label: '업데이트 요청', value: `${templateRequests.value.filter((request) => request.type === 'update').length}` },
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ label: '검색 결과', value: `${adminTierListTotal.value}` },
|
{ label: '검색 결과', value: `${adminTierListStats.value.total || 0}` },
|
||||||
{ label: '공개 티어표', value: `${publishedTierLists}` },
|
{ label: '공개', value: `${adminTierListStats.value.publicCount || 0}` },
|
||||||
|
{ label: '비공개', value: `${adminTierListStats.value.privateCount || 0}` },
|
||||||
{ label: '현재 페이지', value: `${adminTierListPage.value}/${adminTierListPageCount.value}` },
|
{ label: '현재 페이지', value: `${adminTierListPage.value}/${adminTierListPageCount.value}` },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -276,6 +291,7 @@ const adminOverviewStats = computed(() => {
|
|||||||
const isAnyModalOpen = computed(
|
const isAnyModalOpen = computed(
|
||||||
() =>
|
() =>
|
||||||
gameCreateModalOpen.value ||
|
gameCreateModalOpen.value ||
|
||||||
|
gamePickerModalOpen.value ||
|
||||||
userEditModalOpen.value ||
|
userEditModalOpen.value ||
|
||||||
userPasswordModalOpen.value ||
|
userPasswordModalOpen.value ||
|
||||||
userDeleteModalOpen.value ||
|
userDeleteModalOpen.value ||
|
||||||
@@ -283,6 +299,7 @@ const isAnyModalOpen = computed(
|
|||||||
importModalOpen.value ||
|
importModalOpen.value ||
|
||||||
customItemModalOpen.value ||
|
customItemModalOpen.value ||
|
||||||
customItemDeleteModalOpen.value ||
|
customItemDeleteModalOpen.value ||
|
||||||
|
adminTierListManageModalOpen.value ||
|
||||||
imageResetModalOpen.value ||
|
imageResetModalOpen.value ||
|
||||||
previewModalOpen.value
|
previewModalOpen.value
|
||||||
)
|
)
|
||||||
@@ -410,13 +427,17 @@ watch(
|
|||||||
const nextGameId = typeof route.query.gameId === 'string' ? route.query.gameId : ''
|
const nextGameId = typeof route.query.gameId === 'string' ? route.query.gameId : ''
|
||||||
if (nextGameId && nextGameId !== selectedGameId.value) {
|
if (nextGameId && nextGameId !== selectedGameId.value) {
|
||||||
selectedGameId.value = nextGameId
|
selectedGameId.value = nextGameId
|
||||||
loadGame()
|
queueMicrotask(() => {
|
||||||
|
if (selectedGameId.value === nextGameId) void loadGame()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (name === 'adminTierlists') {
|
if (name === 'adminTierlists') {
|
||||||
const nextMode = route.query.mode === 'all' ? 'all' : 'requests'
|
const nextMode = route.query.mode === 'all' ? 'all' : 'requests'
|
||||||
if (tierlistsMode.value !== nextMode) tierlistsMode.value = nextMode
|
if (tierlistsMode.value !== nextMode) tierlistsMode.value = nextMode
|
||||||
|
const nextTierListGameId = typeof route.query.gameId === 'string' ? route.query.gameId : ''
|
||||||
|
if (adminTierListGameId.value !== nextTierListGameId) adminTierListGameId.value = nextTierListGameId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
@@ -430,11 +451,30 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => selectedGame.value?.game?.id || '',
|
||||||
|
async (gameId) => {
|
||||||
|
await refreshSelectedGameTierListStats(gameId)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => tierlistsMode.value,
|
() => tierlistsMode.value,
|
||||||
(mode) => {
|
(mode) => {
|
||||||
if (route.name !== 'adminTierlists') return
|
if (route.name !== 'adminTierlists') return
|
||||||
syncAdminRouteQuery({ mode: mode === 'all' ? 'all' : undefined })
|
syncAdminRouteQuery({
|
||||||
|
mode: mode === 'all' ? 'all' : undefined,
|
||||||
|
gameId: mode === 'all' && adminTierListGameId.value ? adminTierListGameId.value : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => adminTierListGameId.value,
|
||||||
|
(gameId) => {
|
||||||
|
if (route.name !== 'adminTierlists' || tierlistsMode.value !== 'all') return
|
||||||
|
syncAdminRouteQuery({ gameId: gameId || undefined })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -450,7 +490,6 @@ watch(
|
|||||||
customItemQuery.value = ''
|
customItemQuery.value = ''
|
||||||
customItemFilter.value = 'all'
|
customItemFilter.value = 'all'
|
||||||
customItemPage.value = 1
|
customItemPage.value = 1
|
||||||
customItemModalGameQuery.value = ''
|
|
||||||
await refreshCustomItems()
|
await refreshCustomItems()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -556,6 +595,17 @@ function formatImageJobStatus(status) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function customItemDeleteImpactText(item) {
|
||||||
|
if (!item) return ''
|
||||||
|
if (item.sourceType === 'template') {
|
||||||
|
return item.isAssetLibraryItem
|
||||||
|
? `"${item.label}" 보관 자산 항목을 정리할까요? 라이브러리 항목만 제거되고, 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.`
|
||||||
|
: `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 게임의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `"${item.label}" 사용자 업로드 이미지를 삭제할까요? 현재 항목만 정리됩니다.`
|
||||||
|
}
|
||||||
|
|
||||||
const imageDiagnosticsCards = computed(() => {
|
const imageDiagnosticsCards = computed(() => {
|
||||||
const stats = imageStats.value
|
const stats = imageStats.value
|
||||||
if (!stats) return []
|
if (!stats) return []
|
||||||
@@ -571,21 +621,7 @@ const imageDiagnosticsCards = computed(() => {
|
|||||||
const visibleLinkedGames = computed(() =>
|
const visibleLinkedGames = computed(() =>
|
||||||
(modalTargetCustomItem.value?.linkedGames || []).filter((game) => game?.id && game.id !== 'freeform')
|
(modalTargetCustomItem.value?.linkedGames || []).filter((game) => game?.id && game.id !== 'freeform')
|
||||||
)
|
)
|
||||||
const filteredCustomItemModalGames = computed(() => {
|
const linkedCustomItemGameIds = computed(() => new Set(visibleLinkedGames.value.map((game) => game.id).filter(Boolean)))
|
||||||
const query = customItemModalGameQuery.value.trim().toLowerCase()
|
|
||||||
const linkedIds = new Set(visibleLinkedGames.value.map((game) => game.id))
|
|
||||||
const list = games.value.filter((game) => {
|
|
||||||
if (!query) return true
|
|
||||||
return `${game.name || ''} ${game.id || ''}`.toLowerCase().includes(query)
|
|
||||||
})
|
|
||||||
|
|
||||||
return list.slice().sort((a, b) => {
|
|
||||||
const linkedDelta = Number(linkedIds.has(a.id)) - Number(linkedIds.has(b.id))
|
|
||||||
if (linkedDelta !== 0) return linkedDelta
|
|
||||||
if (customItemModalGameSort.value === 'oldest') return Number(a.createdAt || 0) - Number(b.createdAt || 0)
|
|
||||||
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
|
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
|
||||||
const imageStatsYearOptions = computed(() => {
|
const imageStatsYearOptions = computed(() => {
|
||||||
@@ -667,6 +703,30 @@ async function confirmImageReset() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cleanupMissingImageReferences() {
|
||||||
|
const ok = window.confirm('파일이 실제로 없는 이미지 참조만 정리할까요? 누락된 썸네일은 비워지고, 누락된 게임/커스텀 아이템은 관련 참조와 함께 정리됩니다.')
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
imageMissingCleanupBusy.value = true
|
||||||
|
const data = await api.cleanupAdminMissingImageReferences()
|
||||||
|
await Promise.all([refreshImageDiagnostics(), refreshGames(), refreshCustomItems(), refreshTemplateRequests()])
|
||||||
|
const result = data.result || {}
|
||||||
|
success.value =
|
||||||
|
`누락 참조를 정리했어요. ` +
|
||||||
|
`아바타 ${result.clearedAvatars || 0}건, ` +
|
||||||
|
`게임 썸네일 ${result.clearedGameThumbnails || 0}건, ` +
|
||||||
|
`티어표 썸네일 ${result.clearedTierListThumbnails || 0}건, ` +
|
||||||
|
`요청 썸네일 ${result.clearedTemplateRequestThumbnails || 0}건, ` +
|
||||||
|
`게임 아이템 ${result.deletedGameItems || 0}건, ` +
|
||||||
|
`커스텀 아이템 ${result.deletedCustomItems || 0}건`
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '누락 이미지 참조 정리에 실패했어요.'
|
||||||
|
} finally {
|
||||||
|
imageMissingCleanupBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setTab(tab) {
|
function setTab(tab) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
const nextRouteName = adminRouteNameByTab[tab]
|
const nextRouteName = adminRouteNameByTab[tab]
|
||||||
@@ -762,6 +822,7 @@ async function refreshAdminTierLists() {
|
|||||||
try {
|
try {
|
||||||
const data = await api.listAdminTierLists({
|
const data = await api.listAdminTierLists({
|
||||||
q: adminTierListQuery.value,
|
q: adminTierListQuery.value,
|
||||||
|
gameId: adminTierListGameId.value,
|
||||||
page: adminTierListPage.value,
|
page: adminTierListPage.value,
|
||||||
limit: adminTierListLimit.value,
|
limit: adminTierListLimit.value,
|
||||||
})
|
})
|
||||||
@@ -769,11 +830,44 @@ async function refreshAdminTierLists() {
|
|||||||
adminTierListTotal.value = data.total || 0
|
adminTierListTotal.value = data.total || 0
|
||||||
adminTierListPage.value = data.page || 1
|
adminTierListPage.value = data.page || 1
|
||||||
adminTierListLimit.value = data.limit || adminTierListLimit.value
|
adminTierListLimit.value = data.limit || adminTierListLimit.value
|
||||||
|
await refreshAdminTierListStats()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '관리자 티어표 목록을 불러오지 못했어요.'
|
error.value = '관리자 티어표 목록을 불러오지 못했어요.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshAdminTierListStats() {
|
||||||
|
if (!auth.user?.isAdmin) return
|
||||||
|
try {
|
||||||
|
const data = await api.getAdminTierListStats({ q: adminTierListQuery.value, gameId: adminTierListGameId.value })
|
||||||
|
adminTierListStats.value = {
|
||||||
|
total: data.total || 0,
|
||||||
|
publicCount: data.publicCount || 0,
|
||||||
|
privateCount: data.privateCount || 0,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
adminTierListStats.value = { total: 0, publicCount: 0, privateCount: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSelectedGameTierListStats(gameId = '') {
|
||||||
|
if (!auth.user?.isAdmin || !gameId) {
|
||||||
|
selectedGameTierListStats.value = { total: 0, publicCount: 0, privateCount: 0 }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.getAdminTierListStats({ gameId })
|
||||||
|
selectedGameTierListStats.value = {
|
||||||
|
total: data.total || 0,
|
||||||
|
publicCount: data.publicCount || 0,
|
||||||
|
privateCount: data.privateCount || 0,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
selectedGameTierListStats.value = { total: 0, publicCount: 0, privateCount: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshTemplateRequests() {
|
async function refreshTemplateRequests() {
|
||||||
if (!auth.user?.isAdmin) return
|
if (!auth.user?.isAdmin) return
|
||||||
try {
|
try {
|
||||||
@@ -931,8 +1025,6 @@ const {
|
|||||||
customItemModalDraftLabel,
|
customItemModalDraftLabel,
|
||||||
customItemModalLabelSaving,
|
customItemModalLabelSaving,
|
||||||
customItemModalTargetGameId,
|
customItemModalTargetGameId,
|
||||||
customItemModalGameQuery,
|
|
||||||
customItemModalGameSort,
|
|
||||||
games,
|
games,
|
||||||
selectedGameId,
|
selectedGameId,
|
||||||
refreshCustomItems,
|
refreshCustomItems,
|
||||||
@@ -1193,6 +1285,42 @@ function submitAdminTierListSearch() {
|
|||||||
refreshAdminTierLists()
|
refreshAdminTierLists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setAdminTierListGameId(gameId) {
|
||||||
|
adminTierListGameId.value = gameId || ''
|
||||||
|
adminTierListPage.value = 1
|
||||||
|
refreshAdminTierLists()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGamePickerModal(mode = 'game-admin') {
|
||||||
|
gamePickerMode.value = mode
|
||||||
|
gamePickerQuery.value = ''
|
||||||
|
gamePickerSort.value = 'recent'
|
||||||
|
gamePickerModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeGamePickerModal() {
|
||||||
|
gamePickerModalOpen.value = false
|
||||||
|
gamePickerQuery.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseGameFromPicker(gameId) {
|
||||||
|
if (!gameId) return
|
||||||
|
if (gamePickerMode.value === 'tierlists-filter') {
|
||||||
|
setAdminTierListGameId(gameId)
|
||||||
|
closeGamePickerModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (gamePickerMode.value === 'custom-item-target') {
|
||||||
|
if (linkedCustomItemGameIds.value.has(gameId)) return
|
||||||
|
customItemModalTargetGameId.value = gameId
|
||||||
|
closeGamePickerModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await selectAdminGame(gameId)
|
||||||
|
closeGamePickerModal()
|
||||||
|
}
|
||||||
|
|
||||||
function changeAdminTierListLimit(limit) {
|
function changeAdminTierListLimit(limit) {
|
||||||
adminTierListLimit.value = limit
|
adminTierListLimit.value = limit
|
||||||
adminTierListPage.value = 1
|
adminTierListPage.value = 1
|
||||||
@@ -1244,6 +1372,81 @@ function tierListVisibilityLabel(tierList) {
|
|||||||
return tierList.isPublic ? '공개' : '비공개'
|
return tierList.isPublic ? '공개' : '비공개'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAdminTierListManageModal(tierList) {
|
||||||
|
if (!tierList) return
|
||||||
|
modalTargetAdminTierList.value = tierList
|
||||||
|
adminTierListDraftTitle.value = tierList.title || ''
|
||||||
|
adminTierListDraftDescription.value = tierList.description || ''
|
||||||
|
adminTierListDraftIsPublic.value = !!tierList.isPublic
|
||||||
|
adminTierListManageModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAdminTierListManageModal() {
|
||||||
|
adminTierListManageModalOpen.value = false
|
||||||
|
modalTargetAdminTierList.value = null
|
||||||
|
adminTierListDraftTitle.value = ''
|
||||||
|
adminTierListDraftDescription.value = ''
|
||||||
|
adminTierListDraftIsPublic.value = false
|
||||||
|
adminTierListSaving.value = false
|
||||||
|
adminTierListDeleting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAdminTierListMeta() {
|
||||||
|
if (!modalTargetAdminTierList.value?.id || adminTierListSaving.value) return
|
||||||
|
const nextTitle = adminTierListDraftTitle.value.trim()
|
||||||
|
if (!nextTitle) {
|
||||||
|
error.value = '티어표 제목을 입력해주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetMessages()
|
||||||
|
adminTierListSaving.value = true
|
||||||
|
try {
|
||||||
|
const data = await api.updateAdminTierList(modalTargetAdminTierList.value.id, {
|
||||||
|
title: nextTitle,
|
||||||
|
description: adminTierListDraftDescription.value.trim(),
|
||||||
|
isPublic: !!adminTierListDraftIsPublic.value,
|
||||||
|
})
|
||||||
|
const updated = data.tierList
|
||||||
|
adminTierLists.value = adminTierLists.value.map((tierList) => (tierList.id === updated.id ? { ...tierList, ...updated } : tierList))
|
||||||
|
if (previewTierList.value?.id === updated.id) previewTierList.value = { ...previewTierList.value, ...updated }
|
||||||
|
modalTargetAdminTierList.value = updated
|
||||||
|
await Promise.all([refreshAdminTierListStats(), refreshSelectedGameTierListStats(selectedGame.value?.game?.id || '')])
|
||||||
|
success.value = '티어표 정보를 수정했어요.'
|
||||||
|
closeAdminTierListManageModal()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '티어표 정보 수정에 실패했어요.'
|
||||||
|
} finally {
|
||||||
|
adminTierListSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAdminTierListEntry() {
|
||||||
|
if (!modalTargetAdminTierList.value?.id || adminTierListDeleting.value) return
|
||||||
|
const ok = window.confirm(`"${modalTargetAdminTierList.value.title}" 티어표를 삭제할까요? 이 작업은 되돌릴 수 없어요.`)
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
resetMessages()
|
||||||
|
adminTierListDeleting.value = true
|
||||||
|
try {
|
||||||
|
await api.deleteAdminTierList(modalTargetAdminTierList.value.id)
|
||||||
|
adminTierLists.value = adminTierLists.value.filter((tierList) => tierList.id !== modalTargetAdminTierList.value.id)
|
||||||
|
adminTierListTotal.value = Math.max(0, adminTierListTotal.value - 1)
|
||||||
|
if (previewTierList.value?.id === modalTargetAdminTierList.value.id) previewTierList.value = null
|
||||||
|
await Promise.all([refreshAdminTierListStats(), refreshSelectedGameTierListStats(selectedGame.value?.game?.id || '')])
|
||||||
|
success.value = '티어표를 삭제했어요.'
|
||||||
|
closeAdminTierListManageModal()
|
||||||
|
if (!adminTierLists.value.length && adminTierListPage.value > 1) {
|
||||||
|
adminTierListPage.value -= 1
|
||||||
|
await refreshAdminTierLists()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '티어표 삭제에 실패했어요.'
|
||||||
|
} finally {
|
||||||
|
adminTierListDeleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openAdminTierList(tierList) {
|
function openAdminTierList(tierList) {
|
||||||
previewTierList.value = tierList
|
previewTierList.value = tierList
|
||||||
previewModalOpen.value = true
|
previewModalOpen.value = true
|
||||||
@@ -1552,6 +1755,8 @@ function userAvatarFallback(user) {
|
|||||||
:admin-tier-list-page="adminTierListPage"
|
:admin-tier-list-page="adminTierListPage"
|
||||||
:admin-tier-list-page-count="adminTierListPageCount"
|
:admin-tier-list-page-count="adminTierListPageCount"
|
||||||
:admin-tier-list-total="adminTierListTotal"
|
:admin-tier-list-total="adminTierListTotal"
|
||||||
|
:admin-tier-list-stats="adminTierListStats"
|
||||||
|
:open-admin-tier-list-manage-modal="openAdminTierListManageModal"
|
||||||
:move-admin-tier-list-page="moveAdminTierListPage"
|
:move-admin-tier-list-page="moveAdminTierListPage"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1747,32 +1952,16 @@ function userAvatarFallback(user) {
|
|||||||
<div class="customItemModal__pickerHead">
|
<div class="customItemModal__pickerHead">
|
||||||
<div class="customItemModal__pickerEyebrow">GAME PICKER</div>
|
<div class="customItemModal__pickerEyebrow">GAME PICKER</div>
|
||||||
<div class="customItemModal__pickerTitle">템플릿으로 추가할 게임</div>
|
<div class="customItemModal__pickerTitle">템플릿으로 추가할 게임</div>
|
||||||
|
</div>
|
||||||
|
<div class="adminSelectionCard">
|
||||||
|
<div class="adminSelectionCard__label">선택한 게임</div>
|
||||||
|
<div class="adminSelectionCard__title">{{ customItemTargetGame?.name || '아직 선택하지 않음' }}</div>
|
||||||
|
<div class="adminSelectionCard__meta">{{ customItemTargetGame?.id || '게임을 골라 주세요.' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="customItemModal__pickerActions">
|
||||||
|
<button class="btn btn--ghost" type="button" @click="openGamePickerModal('custom-item-target')">게임 선택</button>
|
||||||
<button class="btn btn--ghost btn--small customItemModal__createGameButton" type="button" @click="openGameCreateModal">새 템플릿 만들기</button>
|
<button class="btn btn--ghost btn--small customItemModal__createGameButton" type="button" @click="openGameCreateModal">새 템플릿 만들기</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="customItemModal__pickerControls">
|
|
||||||
<input v-model="customItemModalGameQuery" class="input input--dense" placeholder="게임 이름, ID 검색" />
|
|
||||||
<select v-model="customItemModalGameSort" class="select">
|
|
||||||
<option value="recent">최신순</option>
|
|
||||||
<option value="oldest">오래된순</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="customItemModal__gameList">
|
|
||||||
<button
|
|
||||||
v-for="game in filteredCustomItemModalGames"
|
|
||||||
:key="game.id"
|
|
||||||
type="button"
|
|
||||||
class="customItemModal__gameItem"
|
|
||||||
:class="{
|
|
||||||
'customItemModal__gameItem--active': customItemModalTargetGameId === game.id,
|
|
||||||
'customItemModal__gameItem--linked': visibleLinkedGames.some((entry) => entry.id === game.id),
|
|
||||||
}"
|
|
||||||
@click="customItemModalTargetGameId = game.id"
|
|
||||||
>
|
|
||||||
<span class="customItemModal__gameName">{{ game.name }}</span>
|
|
||||||
<span class="customItemModal__gameMeta">{{ game.id }}</span>
|
|
||||||
<span v-if="visibleLinkedGames.some((entry) => entry.id === game.id)" class="customItemModal__gameState">이미 포함됨</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
<div class="customItemModal__body">
|
<div class="customItemModal__body">
|
||||||
<button class="customItemModal__close" type="button" @click="closeCustomItemModal">닫기</button>
|
<button class="customItemModal__close" type="button" @click="closeCustomItemModal">닫기</button>
|
||||||
@@ -1783,7 +1972,6 @@ function userAvatarFallback(user) {
|
|||||||
<div class="customItemModal__source">{{ modalTargetCustomItem.sourceLabel }}</div>
|
<div class="customItemModal__source">{{ modalTargetCustomItem.sourceLabel }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<img class="customItemModal__image" :src="toApiUrl(modalTargetCustomItem.src)" :alt="modalTargetCustomItem.label" />
|
|
||||||
<div class="customItemModal__labelEditor">
|
<div class="customItemModal__labelEditor">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">아이템 이름</span>
|
<span class="field__label">아이템 이름</span>
|
||||||
@@ -1819,10 +2007,62 @@ function userAvatarFallback(user) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="gamePickerModalOpen" class="modalOverlay" @click.self="closeGamePickerModal">
|
||||||
|
<div class="modalCard" role="dialog" aria-modal="true">
|
||||||
|
<div class="modalCard__titleRow">
|
||||||
|
<div>
|
||||||
|
<div class="modalCard__title">게임 선택</div>
|
||||||
|
<div class="modalCard__desc">
|
||||||
|
{{ gamePickerMode === 'tierlists-filter' ? '특정 게임의 티어표만 보려면 게임을 선택하세요.' : '관리할 게임을 검색해서 바로 열 수 있어요.' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--ghost btn--small" @click="closeGamePickerModal">닫기</button>
|
||||||
|
</div>
|
||||||
|
<div class="modalCard__form">
|
||||||
|
<input v-model="gamePickerQuery" class="input" placeholder="게임 이름 또는 ID 검색" />
|
||||||
|
<select v-model="gamePickerSort" class="select">
|
||||||
|
<option value="recent">최신순</option>
|
||||||
|
<option value="oldest">오래된순</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
v-if="gamePickerMode === 'tierlists-filter' && adminTierListGameId"
|
||||||
|
class="btn btn--ghost"
|
||||||
|
type="button"
|
||||||
|
@click="setAdminTierListGameId(''); closeGamePickerModal()"
|
||||||
|
>
|
||||||
|
모든 게임 보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="gamePickerModalList">
|
||||||
|
<button
|
||||||
|
v-for="game in filteredGamePickerGames"
|
||||||
|
:key="game.id"
|
||||||
|
class="adminGamePicker__item"
|
||||||
|
:class="{
|
||||||
|
'adminGamePicker__item--active': gamePickerMode === 'tierlists-filter'
|
||||||
|
? adminTierListGameId === game.id
|
||||||
|
: gamePickerMode === 'custom-item-target'
|
||||||
|
? customItemModalTargetGameId === game.id
|
||||||
|
: selectedGameId === game.id,
|
||||||
|
'adminGamePicker__item--disabled': gamePickerMode === 'custom-item-target' && linkedCustomItemGameIds.has(game.id),
|
||||||
|
}"
|
||||||
|
type="button"
|
||||||
|
:disabled="gamePickerMode === 'custom-item-target' && linkedCustomItemGameIds.has(game.id)"
|
||||||
|
@click="chooseGameFromPicker(game.id)"
|
||||||
|
>
|
||||||
|
<span class="adminGamePicker__name">{{ game.name }}</span>
|
||||||
|
<span class="adminGamePicker__meta">{{ game.id }}</span>
|
||||||
|
<span v-if="gamePickerMode === 'custom-item-target' && linkedCustomItemGameIds.has(game.id)" class="adminGamePicker__state">이미 추가됨</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="!filteredGamePickerGames.length" class="hint hint--tight">검색 결과가 없어요.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="customItemDeleteModalOpen" class="modalOverlay" @click.self="closeCustomItemDeleteModal">
|
<div v-if="customItemDeleteModalOpen" class="modalOverlay" @click.self="closeCustomItemDeleteModal">
|
||||||
<div class="modalCard" role="dialog" aria-modal="true">
|
<div class="modalCard" role="dialog" aria-modal="true">
|
||||||
<div class="modalCard__title">아이템 삭제</div>
|
<div class="modalCard__title">아이템 삭제</div>
|
||||||
<div class="modalCard__desc">{{ !modalTargetCustomItem ? '' : modalTargetCustomItem.sourceType === 'template' ? '"' + modalTargetCustomItem.label + '" 항목을 정리할까요? 게임에 연결된 항목이면 해당 템플릿과 저장된 같은 게임의 티어표에서도 함께 빠질 수 있고, 보관 자산이면 라이브러리에서만 제거됩니다.' : '"' + modalTargetCustomItem.label + '" 이미지를 삭제할까요? 사용자 업로드이면서 어디에도 연결되지 않은 이미지에만 삭제를 허용합니다.' }}</div>
|
<div class="modalCard__desc">{{ customItemDeleteImpactText(modalTargetCustomItem) }}</div>
|
||||||
<div class="modalCard__actions">
|
<div class="modalCard__actions">
|
||||||
<button class="btn btn--ghost" @click="closeCustomItemDeleteModal">취소</button>
|
<button class="btn btn--ghost" @click="closeCustomItemDeleteModal">취소</button>
|
||||||
<button class="btn btn--danger" @click="removeCustomItem()">삭제</button>
|
<button class="btn btn--danger" @click="removeCustomItem()">삭제</button>
|
||||||
@@ -1830,6 +2070,39 @@ function userAvatarFallback(user) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="adminTierListManageModalOpen" class="modalOverlay" @click.self="closeAdminTierListManageModal">
|
||||||
|
<div class="modalCard" role="dialog" aria-modal="true">
|
||||||
|
<div class="modalCard__title">티어표 관리</div>
|
||||||
|
<div class="modalCard__desc">
|
||||||
|
{{ modalTargetAdminTierList ? `${modalTargetAdminTierList.gameName || modalTargetAdminTierList.gameId} · ${tierListAuthorDisplayName(modalTargetAdminTierList)}` : '' }}
|
||||||
|
</div>
|
||||||
|
<div class="modalCard__form">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">제목</span>
|
||||||
|
<input v-model="adminTierListDraftTitle" class="field__input" maxlength="120" placeholder="티어표 제목" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">설명</span>
|
||||||
|
<textarea v-model="adminTierListDraftDescription" class="field__input field__input--textarea" rows="4" maxlength="500" placeholder="설명 수정"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="toggleSwitch">
|
||||||
|
<input v-model="adminTierListDraftIsPublic" type="checkbox" />
|
||||||
|
<span class="toggleSwitch__label">{{ adminTierListDraftIsPublic ? '공개 상태' : '비공개 상태' }}</span>
|
||||||
|
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="modalCard__actions">
|
||||||
|
<button class="btn btn--ghost" @click="closeAdminTierListManageModal">취소</button>
|
||||||
|
<button class="btn btn--danger" :disabled="adminTierListDeleting" @click="deleteAdminTierListEntry">
|
||||||
|
{{ adminTierListDeleting ? '삭제중...' : '삭제' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--primary" :disabled="adminTierListSaving || !adminTierListDraftTitle.trim()" @click="saveAdminTierListMeta">
|
||||||
|
{{ adminTierListSaving ? '저장중...' : '저장' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="imageResetModalOpen" class="modalOverlay" @click.self="closeImageResetModal">
|
<div v-if="imageResetModalOpen" class="modalOverlay" @click.self="closeImageResetModal">
|
||||||
<div class="modalCard" role="dialog" aria-modal="true">
|
<div class="modalCard" role="dialog" aria-modal="true">
|
||||||
<div class="modalCard__title">최적화 기록 비우기</div>
|
<div class="modalCard__title">최적화 기록 비우기</div>
|
||||||
@@ -1940,24 +2213,11 @@ function userAvatarFallback(user) {
|
|||||||
<div class="adminSidebar__label">Game</div>
|
<div class="adminSidebar__label">Game</div>
|
||||||
<div class="adminSidebar__group">
|
<div class="adminSidebar__group">
|
||||||
<button class="btn btn--primary" @click="openGameCreateModal">새 게임 생성</button>
|
<button class="btn btn--primary" @click="openGameCreateModal">새 게임 생성</button>
|
||||||
<input v-model="gameAdminQuery" class="input" placeholder="게임 이름 또는 ID 검색" />
|
<button class="btn btn--ghost" @click="openGamePickerModal('game-admin')">게임 선택</button>
|
||||||
<select v-model="gameAdminSort" class="select">
|
<div v-if="selectedGame?.game" class="adminSelectionCard">
|
||||||
<option value="recent">최신순</option>
|
<div class="adminSelectionCard__label">선택한 게임</div>
|
||||||
<option value="oldest">오래된순</option>
|
<div class="adminSelectionCard__title">{{ selectedGame.game.name }}</div>
|
||||||
</select>
|
<div class="adminSelectionCard__meta">{{ selectedGame.game.id }}</div>
|
||||||
<div class="adminGamePicker">
|
|
||||||
<button
|
|
||||||
v-for="game in filteredAdminGames"
|
|
||||||
:key="game.id"
|
|
||||||
class="adminGamePicker__item"
|
|
||||||
:class="{ 'adminGamePicker__item--active': selectedGameId === game.id }"
|
|
||||||
type="button"
|
|
||||||
@click="selectAdminGame(game.id)"
|
|
||||||
>
|
|
||||||
<span class="adminGamePicker__name">{{ game.name }}</span>
|
|
||||||
<span class="adminGamePicker__meta">{{ game.id }}</span>
|
|
||||||
</button>
|
|
||||||
<div v-if="!filteredAdminGames.length" class="hint hint--tight">검색 결과가 없어요.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div>
|
<div v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1966,8 +2226,10 @@ function userAvatarFallback(user) {
|
|||||||
<section v-else-if="activeTab === 'items'" class="adminSidebar__panel">
|
<section v-else-if="activeTab === 'items'" class="adminSidebar__panel">
|
||||||
<div class="adminSidebar__label">Filters</div>
|
<div class="adminSidebar__label">Filters</div>
|
||||||
<div class="adminSidebar__group">
|
<div class="adminSidebar__group">
|
||||||
<input v-model="customItemQuery" class="input" placeholder="파일명, 라벨, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
|
<div class="adminSidebar__inlineRow">
|
||||||
<button class="btn btn--ghost" @click="submitCustomItemSearch">검색</button>
|
<input v-model="customItemQuery" class="input" placeholder="파일명, 라벨, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
|
||||||
|
<button class="btn btn--ghost" @click="submitCustomItemSearch">검색</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="adminSidebar__group">
|
<div class="adminSidebar__group">
|
||||||
<select :value="customItemLimit" class="select" @change="changeCustomItemLimit(Number($event.target.value))">
|
<select :value="customItemLimit" class="select" @change="changeCustomItemLimit(Number($event.target.value))">
|
||||||
@@ -2012,13 +2274,22 @@ function userAvatarFallback(user) {
|
|||||||
<template v-if="tierlistsMode === 'requests'"></template>
|
<template v-if="tierlistsMode === 'requests'"></template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="adminSidebar__group">
|
<div class="adminSidebar__group">
|
||||||
<input
|
<div class="adminSidebar__inlineRow">
|
||||||
v-model="adminTierListQuery"
|
<input
|
||||||
class="input"
|
v-model="adminTierListQuery"
|
||||||
placeholder="제목, 작성자, 게임 이름 검색"
|
class="input"
|
||||||
@keydown.enter.prevent="submitAdminTierListSearch"
|
placeholder="제목, 작성자, 게임 이름 검색"
|
||||||
/>
|
@keydown.enter.prevent="submitAdminTierListSearch"
|
||||||
<button class="btn btn--ghost" @click="submitAdminTierListSearch">검색</button>
|
/>
|
||||||
|
<button class="btn btn--ghost" @click="submitAdminTierListSearch">검색</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--ghost" @click="openGamePickerModal('tierlists-filter')">게임 선택</button>
|
||||||
|
<div v-if="adminTierListGameId" class="adminSelectionCard">
|
||||||
|
<div class="adminSelectionCard__label">필터된 게임</div>
|
||||||
|
<div class="adminSelectionCard__title">{{ games.find((game) => game.id === adminTierListGameId)?.name || adminTierListGameId }}</div>
|
||||||
|
<div class="adminSelectionCard__meta">{{ adminTierListGameId }}</div>
|
||||||
|
<button class="btn btn--ghost btn--small" @click="setAdminTierListGameId('')">필터 해제</button>
|
||||||
|
</div>
|
||||||
<select :value="adminTierListLimit" class="select" @change="changeAdminTierListLimit(Number($event.target.value))">
|
<select :value="adminTierListLimit" class="select" @change="changeAdminTierListLimit(Number($event.target.value))">
|
||||||
<option :value="50">50개씩 보기</option>
|
<option :value="50">50개씩 보기</option>
|
||||||
<option :value="200">200개씩 보기</option>
|
<option :value="200">200개씩 보기</option>
|
||||||
@@ -2065,6 +2336,11 @@ function userAvatarFallback(user) {
|
|||||||
<button class="btn btn--ghost" @click="refreshImageDiagnostics">현황 새로고침</button>
|
<button class="btn btn--ghost" @click="refreshImageDiagnostics">현황 새로고침</button>
|
||||||
<button class="btn btn--ghost" @click="openImageResetModal">기록 비우기</button>
|
<button class="btn btn--ghost" @click="openImageResetModal">기록 비우기</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="adminSidebar__actions">
|
||||||
|
<button class="btn btn--danger" :disabled="!imageStats?.missingReferencedCount || imageMissingCleanupBusy" @click="cleanupMissingImageReferences">
|
||||||
|
{{ imageMissingCleanupBusy ? '누락 참조 정리중...' : '누락 참조 정리' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="hint hint--tight">{{ imageStatsPeriodLabel }}</div>
|
<div class="hint hint--tight">{{ imageStatsPeriodLabel }}</div>
|
||||||
<div v-if="imageDiagnosticsCards.length" class="adminSidebar__stats adminSidebar__stats--grid">
|
<div v-if="imageDiagnosticsCards.length" class="adminSidebar__stats adminSidebar__stats--grid">
|
||||||
<article v-for="stat in imageDiagnosticsCards" :key="stat.label" class="sidebarStat">
|
<article v-for="stat in imageDiagnosticsCards" :key="stat.label" class="sidebarStat">
|
||||||
@@ -2114,6 +2390,11 @@ function userAvatarFallback(user) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
<div v-show="globalRightRailOpen" class="adminSidebarFooter adminUiScope">
|
||||||
|
<span>Copyright © 2026 </span>
|
||||||
|
<a :href="ADMIN_COPYRIGHT_URL" target="_blank" rel="noreferrer">zenn</a>
|
||||||
|
<span>. All rights reserved.</span>
|
||||||
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -2192,6 +2473,23 @@ function userAvatarFallback(user) {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
.adminUiScope.adminSidebarFooter {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 0 4px 2px;
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--theme-text-faint);
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
.adminUiScope.adminSidebarFooter a {
|
||||||
|
color: #00ffff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.adminUiScope.adminSidebarFooter a:hover {
|
||||||
|
color: #00ffff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
.adminUiScope .adminSidebar__panel {
|
.adminUiScope .adminSidebar__panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -2248,6 +2546,15 @@ function userAvatarFallback(user) {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
.adminUiScope .adminSidebar__inlineRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.adminUiScope .adminSidebar__inlineRow .btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.adminUiScope .adminSidebar__group--monthPicker {
|
.adminUiScope .adminSidebar__group--monthPicker {
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
@@ -2299,6 +2606,11 @@ function userAvatarFallback(user) {
|
|||||||
border-color: rgba(77, 127, 233, 0.58);
|
border-color: rgba(77, 127, 233, 0.58);
|
||||||
background: rgba(77, 127, 233, 0.12);
|
background: rgba(77, 127, 233, 0.12);
|
||||||
}
|
}
|
||||||
|
.adminUiScope .adminGamePicker__item--disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.58;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
.adminUiScope .adminGamePicker__name {
|
.adminUiScope .adminGamePicker__name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -2310,6 +2622,39 @@ function userAvatarFallback(user) {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
.adminUiScope .adminGamePicker__state {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--theme-text-faint);
|
||||||
|
}
|
||||||
|
.adminUiScope .gamePickerModalList {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: min(56dvh, 520px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.adminUiScope .adminSelectionCard {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 13px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
|
background: var(--theme-pill-bg);
|
||||||
|
}
|
||||||
|
.adminUiScope .adminSelectionCard__label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--theme-text-faint);
|
||||||
|
}
|
||||||
|
.adminUiScope .adminSelectionCard__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.adminUiScope .adminSelectionCard__meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--theme-text-soft);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
.adminUiScope .sidebarStat {
|
.adminUiScope .sidebarStat {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -2736,8 +3081,9 @@ function userAvatarFallback(user) {
|
|||||||
}
|
}
|
||||||
.adminUiScope .gameSettingsCard__actions {
|
.adminUiScope .gameSettingsCard__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
/* flex-wrap: wrap; */
|
||||||
}
|
}
|
||||||
.adminUiScope .selectedThumb {
|
.adminUiScope .selectedThumb {
|
||||||
width: min(100%, 256px);
|
width: min(100%, 256px);
|
||||||
@@ -3123,9 +3469,12 @@ function userAvatarFallback(user) {
|
|||||||
align-content: start;
|
align-content: start;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
padding: 28px 22px;
|
padding: 28px 22px;
|
||||||
border-right: 1px solid var(--theme-border);
|
border-right: 1px solid var(--theme-border);
|
||||||
background: var(--theme-pill-bg);
|
background: var(--theme-pill-bg);
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
.adminUiScope .customItemModal__pickerHead {
|
.adminUiScope .customItemModal__pickerHead {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -3141,46 +3490,13 @@ function userAvatarFallback(user) {
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
.adminUiScope .customItemModal__pickerControls {
|
.adminUiScope .customItemModal__pickerActions {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.adminUiScope .customItemModal__gameList {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
max-height: 440px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
.adminUiScope .customItemModal__createGameButton {
|
.adminUiScope .customItemModal__createGameButton {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
}
|
}
|
||||||
.adminUiScope .customItemModal__gameItem {
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 12px 13px;
|
|
||||||
border-radius: 16px;
|
|
||||||
border: 1px solid var(--theme-border);
|
|
||||||
background: var(--theme-surface-soft);
|
|
||||||
color: var(--theme-text);
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.adminUiScope .customItemModal__gameItem--active {
|
|
||||||
border-color: rgba(96, 165, 250, 0.42);
|
|
||||||
background: rgba(96, 165, 250, 0.12);
|
|
||||||
}
|
|
||||||
.adminUiScope .customItemModal__gameItem--linked {
|
|
||||||
border-style: dashed;
|
|
||||||
}
|
|
||||||
.adminUiScope .customItemModal__gameName {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
.adminUiScope .customItemModal__gameMeta,
|
|
||||||
.adminUiScope .customItemModal__gameState {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--theme-text-soft);
|
|
||||||
}
|
|
||||||
.adminUiScope .customItemModal__body {
|
.adminUiScope .customItemModal__body {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -3188,6 +3504,7 @@ function userAvatarFallback(user) {
|
|||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 24px 28px 28px;
|
padding: 24px 28px 28px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.adminUiScope .customItemModal__content {
|
.adminUiScope .customItemModal__content {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -3196,14 +3513,24 @@ function userAvatarFallback(user) {
|
|||||||
align-content: start;
|
align-content: start;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding-right: 0;
|
padding-right: 8px;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
scrollbar-width: none;
|
scrollbar-width: thin;
|
||||||
-ms-overflow-style: none;
|
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
|
||||||
}
|
}
|
||||||
|
.adminUiScope .customItemModal__pickerPanel::-webkit-scrollbar,
|
||||||
.adminUiScope .customItemModal__content::-webkit-scrollbar {
|
.adminUiScope .customItemModal__content::-webkit-scrollbar {
|
||||||
width: 0;
|
width: 8px;
|
||||||
height: 0;
|
height: 8px;
|
||||||
|
}
|
||||||
|
.adminUiScope .customItemModal__pickerPanel::-webkit-scrollbar-thumb,
|
||||||
|
.adminUiScope .customItemModal__content::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
.adminUiScope .customItemModal__pickerPanel::-webkit-scrollbar-track,
|
||||||
|
.adminUiScope .customItemModal__content::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
.adminUiScope .customItemModal__labelEditor {
|
.adminUiScope .customItemModal__labelEditor {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3232,15 +3559,6 @@ function userAvatarFallback(user) {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.adminUiScope .customItemModal__image {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
max-height: min(360px, 34dvh);
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 24px;
|
|
||||||
background: radial-gradient(circle at top, rgba(77, 127, 233, 0.18), rgba(255, 255, 255, 0.02) 52%), rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid var(--theme-border);
|
|
||||||
}
|
|
||||||
.adminUiScope .customItemModal__label {
|
.adminUiScope .customItemModal__label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--theme-text-faint);
|
color: var(--theme-text-faint);
|
||||||
@@ -3865,6 +4183,12 @@ function userAvatarFallback(user) {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
.adminUiScope .tierAdminHeaderStats {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
.adminUiScope .pill {
|
.adminUiScope .pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3901,6 +4225,16 @@ function userAvatarFallback(user) {
|
|||||||
background: rgba(251, 191, 36, 0.12);
|
background: rgba(251, 191, 36, 0.12);
|
||||||
color: rgba(253, 230, 138, 0.96);
|
color: rgba(253, 230, 138, 0.96);
|
||||||
}
|
}
|
||||||
|
.adminUiScope .pill--public {
|
||||||
|
border-color: rgba(52, 211, 153, 0.34);
|
||||||
|
background: rgba(52, 211, 153, 0.14);
|
||||||
|
color: rgba(209, 250, 229, 0.98);
|
||||||
|
}
|
||||||
|
.adminUiScope .pill--private {
|
||||||
|
border-color: rgba(251, 191, 36, 0.32);
|
||||||
|
background: rgba(251, 191, 36, 0.12);
|
||||||
|
color: rgba(253, 230, 138, 0.96);
|
||||||
|
}
|
||||||
.adminUiScope .pill--link {
|
.adminUiScope .pill--link {
|
||||||
color: var(--theme-text);
|
color: var(--theme-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -3980,11 +4314,17 @@ function userAvatarFallback(user) {
|
|||||||
width: min(560px, 100%);
|
width: min(560px, 100%);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 20px;
|
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: color-mix(in srgb, var(--theme-main-bg) 96%, transparent);
|
background: color-mix(in srgb, var(--theme-main-bg) 96%, transparent);
|
||||||
}
|
}
|
||||||
|
.adminUiScope .modalCard:not(.modalCard--customItem) {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.adminUiScope .modalCard.modalCard--customItem {
|
||||||
|
gap: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
.adminUiScope .modalCard--preview {
|
.adminUiScope .modalCard--preview {
|
||||||
width: min(1200px, 100%);
|
width: min(1200px, 100%);
|
||||||
max-height: calc(100dvh - 40px);
|
max-height: calc(100dvh - 40px);
|
||||||
|
|||||||
Reference in New Issue
Block a user