Compare commits

..

13 Commits

36 changed files with 958 additions and 518 deletions

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@ backend/.sessions/
backend/uploads/avatars/
backend/uploads/games/
backend/uploads/custom/
backend/uploads/assets/
.DS_Store
.env.production

View File

@@ -24,7 +24,7 @@ const allowedOrigins = (process.env.CORS_ORIGINS || '')
const FileStore = FileStoreFactory(session)
;['uploads/avatars', 'uploads/games', 'uploads/custom', 'uploads/tierlists', '.sessions'].forEach((relativePath) => {
;['uploads/avatars', 'uploads/topics', 'uploads/custom', 'uploads/tierlists', '.sessions'].forEach((relativePath) => {
fs.mkdirSync(path.join(__dirname, relativePath), { recursive: true })
})
@@ -80,7 +80,6 @@ app.use(async (req, res, next) => {
})
app.use('/api/auth', authRoutes)
app.use('/api/games', topicsRoutes)
app.use('/api/topics', topicsRoutes)
app.use('/api/tierlists', tierListsRoutes)
app.use('/api/admin', adminRoutes)

View File

@@ -7,7 +7,7 @@ const {
} = require('../src/db')
const BACKEND_ROOT = path.join(__dirname, '..')
const TARGET_DIRS = ['avatars', 'custom', 'games', 'tierlists']
const TARGET_DIRS = ['avatars', 'custom', 'topics', 'tierlists']
async function main() {
await ensureData()

View File

@@ -35,7 +35,7 @@ function getOptimizationConfig(roles) {
if (roleSet.has('avatar')) {
return { directory: 'avatars', width: 512, height: 512, fit: 'cover', quality: 82 }
}
if (roleSet.has('game-thumbnail') || roleSet.has('tierlist-thumbnail') || roleSet.has('template-thumbnail')) {
if (roleSet.has('topic-thumbnail') || roleSet.has('tierlist-thumbnail') || roleSet.has('template-thumbnail')) {
return { directory: 'legacy-thumbnails', width: 1280, height: 1280, fit: 'inside', quality: 84 }
}
return { directory: 'legacy-items', width: 512, height: 512, fit: 'inside', quality: 84 }

View File

@@ -9,7 +9,6 @@ const DB_PASSWORD = process.env.DB_PASSWORD || ''
const DB_NAME = process.env.DB_NAME || 'tier_cursor'
const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10
const FREEFORM_TOPIC_ID = 'freeform'
const FREEFORM_GAME_ID = FREEFORM_TOPIC_ID
let poolPromise = null
let initPromise = null
@@ -68,7 +67,7 @@ function mapUserRow(row) {
}
}
function mapGameRow(row) {
function mapTopicRow(row) {
if (!row) return null
return {
id: row.id,
@@ -82,7 +81,7 @@ function mapGameRow(row) {
}
}
function mapGameItemRow(row) {
function mapTopicItemRow(row) {
if (!row) return null
return {
id: row.id,
@@ -270,10 +269,6 @@ async function closePool() {
async function ensureSchema() {
if (initPromise) return initPromise
initPromise = (async () => {
const legacyGamesExists = await tableExists('games')
const legacyGameItemsExists = await tableExists('game_items')
const legacyFavoriteGamesExists = await tableExists('favorite_games')
await query(`
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(64) PRIMARY KEY,
@@ -297,8 +292,8 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const gameIsPublicColumns = await query("SHOW COLUMNS FROM topics LIKE 'is_public'")
if (!gameIsPublicColumns.length) {
const topicIsPublicColumns = await query("SHOW COLUMNS FROM topics LIKE 'is_public'")
if (!topicIsPublicColumns.length) {
await query('ALTER TABLE topics ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER thumbnail_src')
await query('UPDATE topics SET is_public = 1 WHERE is_public IS NULL')
}
@@ -308,14 +303,6 @@ async function ensureSchema() {
await query('ALTER TABLE topics ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src')
}
if (legacyGamesExists) {
await query(`
INSERT IGNORE INTO topics (id, name, thumbnail_src, is_public, display_rank, created_at)
SELECT id, name, thumbnail_src, COALESCE(is_public, 1), display_rank, created_at
FROM games
`)
}
await query(`
CREATE TABLE IF NOT EXISTS topic_items (
id VARCHAR(64) PRIMARY KEY,
@@ -329,22 +316,11 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const gameItemDisplayOrderColumns = await query("SHOW COLUMNS FROM topic_items LIKE 'display_order'")
if (!gameItemDisplayOrderColumns.length) {
const topicItemDisplayOrderColumns = await query("SHOW COLUMNS FROM topic_items LIKE 'display_order'")
if (!topicItemDisplayOrderColumns.length) {
await query('ALTER TABLE topic_items ADD COLUMN display_order INT NULL DEFAULT NULL AFTER label')
}
if (legacyGameItemsExists) {
const legacyItemDisplayOrderColumns = await query("SHOW COLUMNS FROM game_items LIKE 'display_order'")
await query(
`
INSERT IGNORE INTO topic_items (id, topic_id, src, label, display_order, created_at)
SELECT id, game_id, src, label, ${legacyItemDisplayOrderColumns.length ? 'display_order' : 'NULL'}, created_at
FROM game_items
`
)
}
await query(`
CREATE TABLE IF NOT EXISTS custom_items (
id VARCHAR(64) PRIMARY KEY,
@@ -407,14 +383,6 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
if (legacyFavoriteGamesExists) {
await query(`
INSERT IGNORE INTO favorite_topics (user_id, topic_id, created_at)
SELECT user_id, game_id, created_at
FROM favorite_games
`)
}
await query(`
CREATE TABLE IF NOT EXISTS image_assets (
id VARCHAR(64) PRIMARY KEY,
@@ -487,16 +455,10 @@ async function ensureSchema() {
const hasSourceTopicId = await columnExists('template_requests', 'source_topic_id')
if (!hasSourceTopicId) {
await query("ALTER TABLE template_requests ADD COLUMN source_topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id")
if (await columnExists('template_requests', 'source_game_id')) {
await query('UPDATE template_requests SET source_topic_id = source_game_id WHERE source_topic_id = ?', [FREEFORM_TOPIC_ID])
}
}
const hasTargetTopicId = await columnExists('template_requests', 'target_topic_id')
if (!hasTargetTopicId) {
await query("ALTER TABLE template_requests ADD COLUMN target_topic_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_topic_id")
if (await columnExists('template_requests', 'target_game_id')) {
await query("UPDATE template_requests SET target_topic_id = target_game_id WHERE target_topic_id = ''")
}
}
const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'")
if (!templateRequestStatusColumns.length) {
@@ -522,9 +484,6 @@ async function ensureSchema() {
const tierListTopicIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'topic_id'")
if (!tierListTopicIdColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER author_id")
if (await columnExists('tierlists', 'game_id')) {
await query('UPDATE tierlists SET topic_id = game_id WHERE topic_id = ?', [FREEFORM_TOPIC_ID])
}
}
const tierListShowNamesColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'show_character_names'")
if (!tierListShowNamesColumns.length) {
@@ -568,7 +527,7 @@ async function ensureSchema() {
(?, ?, ?, ?),
(?, ?, ?, ?)
`,
['example-game', '예시 게임', '', createdAt, 'another-game', '다른 예시 게임', '', createdAt]
['example-topic', '예시 주제', '', createdAt, 'another-topic', '다른 예시 주제', '', createdAt]
)
await query(
@@ -580,12 +539,12 @@ async function ensureSchema() {
`,
[
'img-1',
'example-game',
'example-topic',
'/uploads/seeds/example1.png',
'샘플 1',
createdAt,
'img-2',
'example-game',
'example-topic',
'/uploads/seeds/example2.png',
'샘플 2',
createdAt,
@@ -616,6 +575,33 @@ async function findUserByEmail(email) {
return { ...mapUserRow(row), passwordHash: row.password_hash }
}
async function findUserByNickname(nickname, excludeUserId = '') {
const normalized = String(nickname || '').trim()
if (!normalized) return null
const rows = excludeUserId
? await query(
`
SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at
FROM users
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) AND id <> ?
LIMIT 1
`,
[normalized, excludeUserId]
)
: await query(
`
SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at
FROM users
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?))
LIMIT 1
`,
[normalized]
)
const row = rows[0]
if (!row) return null
return { ...mapUserRow(row), passwordHash: row.password_hash }
}
async function findUserById(id) {
const rows = await query(
'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1',
@@ -746,7 +732,7 @@ async function listTopics(currentUserId = '', options = {}) {
`,
[FREEFORM_TOPIC_ID]
)
const topics = rows.map(mapGameRow)
const topics = rows.map(mapTopicRow)
if (!currentUserId) return topics.map((topic) => ({ ...topic, isFavorited: false }))
const favoriteRows = await query('SELECT topic_id FROM favorite_topics WHERE user_id = ?', [currentUserId])
@@ -759,7 +745,7 @@ async function listTopics(currentUserId = '', options = {}) {
async function findTopicById(id) {
const rows = await query('SELECT id, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1', [id])
return mapGameRow(rows[0])
return mapTopicRow(rows[0])
}
async function listTopicItems(topicId) {
@@ -776,19 +762,19 @@ async function listTopicItems(topicId) {
`,
[topicId]
)
return rows.map(mapGameItemRow)
return rows.map(mapTopicItemRow)
}
async function findTopicItemById(itemId) {
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
return mapGameItemRow(rows[0])
return mapTopicItemRow(rows[0])
}
async function getTopicDetail(topicId) {
const topic = await findTopicById(topicId)
if (!topic) return null
const items = await listTopicItems(topicId)
return { topic, game: topic, items }
return { topic, template: topic, items }
}
async function createTopic({ id, name, isPublic = true }) {
@@ -909,7 +895,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
const referencedSrcs = new Set()
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
const [userRows, topicRows, topicItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
query("SELECT src FROM topic_items WHERE src <> ''"),
@@ -919,8 +905,8 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
])
for (const row of userRows) if (row.avatar_src) referencedSrcs.add(row.avatar_src)
for (const row of gameRows) if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
for (const row of gameItemRows) if (row.src) referencedSrcs.add(row.src)
for (const row of topicRows) if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
for (const row of topicItemRows) if (row.src) referencedSrcs.add(row.src)
for (const row of customItemRows) if (row.src) referencedSrcs.add(row.src)
for (const row of tierListRows) {
@@ -962,7 +948,7 @@ async function listReferencedUploadUsage() {
usageMap.get(src).add(role)
}
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
const [userRows, topicRows, topicItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
query("SELECT src FROM topic_items WHERE src <> ''"),
@@ -972,8 +958,8 @@ async function listReferencedUploadUsage() {
])
for (const row of userRows) addUsage(row.avatar_src, 'avatar')
for (const row of gameRows) addUsage(row.thumbnail_src, 'game-thumbnail')
for (const row of gameItemRows) addUsage(row.src, 'game-item')
for (const row of topicRows) addUsage(row.thumbnail_src, 'topic-thumbnail')
for (const row of topicItemRows) addUsage(row.src, 'topic-item')
for (const row of customItemRows) addUsage(row.src, 'custom-item')
for (const row of tierListRows) {
@@ -1005,14 +991,14 @@ function replaceItemSrc(items, fromSrc, toSrc) {
async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 }
const [userResult, gameResult, gameItemResult, customItemResult] = await Promise.all([
const [userResult, topicResult, topicItemResult, customItemResult] = await Promise.all([
query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]),
query('UPDATE topics SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]),
query('UPDATE topic_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
])
let updatedRows = Number(userResult.affectedRows || 0) + Number(gameResult.affectedRows || 0) + Number(gameItemResult.affectedRows || 0) + Number(customItemResult.affectedRows || 0)
let updatedRows = Number(userResult.affectedRows || 0) + Number(topicResult.affectedRows || 0) + Number(topicItemResult.affectedRows || 0) + Number(customItemResult.affectedRows || 0)
const tierListRows = await query('SELECT id, thumbnail_src, pool_json FROM tierlists')
for (const row of tierListRows) {
@@ -1161,16 +1147,16 @@ function stripMissingItems(items, missingItemIds, missingSrcs) {
async function cleanupMissingUploadReferences() {
const stats = {
clearedAvatars: 0,
clearedGameThumbnails: 0,
clearedTopicThumbnails: 0,
clearedTierListThumbnails: 0,
clearedTemplateRequestThumbnails: 0,
deletedGameItems: 0,
deletedTopicItems: 0,
updatedTierLists: 0,
updatedTemplateRequests: 0,
deletedCustomItems: 0,
}
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
const [userRows, topicRows, topicItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
query("SELECT id, avatar_src FROM users WHERE avatar_src <> ''"),
query("SELECT id, thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
query("SELECT id, src FROM topic_items WHERE src <> ''"),
@@ -1185,16 +1171,16 @@ async function cleanupMissingUploadReferences() {
stats.clearedAvatars += 1
}
for (const row of gameRows) {
for (const row of topicRows) {
if (await fileExistsForUploadSrc(row.thumbnail_src)) continue
await query('UPDATE topics SET thumbnail_src = ? WHERE id = ?', ['', row.id])
stats.clearedGameThumbnails += 1
stats.clearedTopicThumbnails += 1
}
for (const row of gameItemRows) {
for (const row of topicItemRows) {
if (await fileExistsForUploadSrc(row.src)) continue
await deleteGameItem(row.id)
stats.deletedGameItems += 1
await deleteTopicItem(row.id)
stats.deletedTopicItems += 1
}
const missingCustomItemIds = new Set()
@@ -1354,13 +1340,13 @@ async function createTopicItem({ id, topicId, src, label }) {
createdAt,
])
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [id])
return mapGameItemRow(rows[0])
return mapTopicItemRow(rows[0])
}
async function updateTopicItemLabel(itemId, label) {
await query('UPDATE topic_items SET label = ? WHERE id = ?', [label, itemId])
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
return mapGameItemRow(rows[0])
return mapTopicItemRow(rows[0])
}
async function updateTopicItemDisplayOrder(topicId, itemIds) {
@@ -1468,22 +1454,6 @@ async function createCustomItem({ id, ownerId, src, label }) {
return { id, ownerId, src, label, origin: 'custom', createdAt }
}
const listGames = listTopics
const findGameById = findTopicById
const listGameItems = listTopicItems
const findGameItemById = findTopicItemById
const getGameDetail = getTopicDetail
const createGame = createTopic
const updateGameThumbnail = updateTopicThumbnail
const updateGameVisibility = updateTopicVisibility
const createGameItem = createTopicItem
const updateGameItemLabel = updateTopicItemLabel
const updateGameItemDisplayOrder = updateTopicItemDisplayOrder
const countTierListsUsingGameItem = countTierListsUsingTopicItem
const deleteGameItem = deleteTopicItem
const deleteGame = deleteTopic
const updateGameDisplayOrder = updateTopicDisplayOrder
async function syncOwnedCustomItemLabels({ ownerId, items }) {
const customItems = Array.from(
new Map(
@@ -1533,7 +1503,7 @@ async function getCustomItemUsageMeta() {
`
)
const usageMap = new Map()
const linkedGamesMap = new Map()
const linkedTemplatesMap = new Map()
rows.forEach((row) => {
const groups = parseJson(row.groups_json, [])
@@ -1557,8 +1527,8 @@ async function getCustomItemUsageMeta() {
if (!row.topic_id) return
seenItemIds.forEach((itemId) => {
if (!linkedGamesMap.has(itemId)) linkedGamesMap.set(itemId, new Map())
linkedGamesMap.get(itemId).set(row.topic_id, {
if (!linkedTemplatesMap.has(itemId)) linkedTemplatesMap.set(itemId, new Map())
linkedTemplatesMap.get(itemId).set(row.topic_id, {
id: row.topic_id,
name: row.topic_name || row.topic_id,
})
@@ -1567,7 +1537,7 @@ async function getCustomItemUsageMeta() {
return {
usageMap,
linkedGamesMap: new Map(Array.from(linkedGamesMap.entries()).map(([itemId, gameMap]) => [itemId, Array.from(gameMap.values())])),
linkedTemplatesMap: new Map(Array.from(linkedTemplatesMap.entries()).map(([itemId, templateMap]) => [itemId, Array.from(templateMap.values())])),
}
}
@@ -1578,7 +1548,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
const hasQuery = !!searchText
const search = `%${searchText}%`
const [customRows, gameItemRows, assetRows, usageMeta] = await Promise.all([
const [customRows, topicItemRows, assetRows, usageMeta] = await Promise.all([
query(
`
SELECT
@@ -1626,7 +1596,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
])
const templateLinkedBySrc = new Map()
gameItemRows.forEach((row) => {
topicItemRows.forEach((row) => {
if (!row?.src) return
if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map())
templateLinkedBySrc.get(row.src).set(row.topic_id, {
@@ -1636,7 +1606,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
})
const customItems = customRows.map((row) => {
const linkedGames = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values())
const linkedTemplates = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values())
return {
id: row.id,
ownerId: row.owner_id,
@@ -1646,14 +1616,14 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerName: row.nickname || row.email,
ownerEmail: row.email,
usageCount: usageMeta.usageMap.get(row.id) || 0,
linkedGames,
linkedTemplates,
sourceType: 'user',
sourceLabel: '사용자 업로드',
canDelete: true,
}
})
const templateSrcSet = new Set(gameItemRows.map((row) => row.src).filter(Boolean))
const templateSrcSet = new Set(topicItemRows.map((row) => row.src).filter(Boolean))
const customSrcSet = new Set(customRows.map((row) => row.src).filter(Boolean))
const assetLibraryItems = assetRows
.filter((row) => row?.src && !templateSrcSet.has(row.src) && !customSrcSet.has(row.src))
@@ -1667,7 +1637,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerName: '관리자 보관 자산',
ownerEmail: '',
usageCount: 0,
linkedGames: [],
linkedTemplates: [],
sourceType: 'template',
sourceLabel: '관리자 템플릿',
canDelete: true,
@@ -1676,7 +1646,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
isAssetLibraryItem: true,
}))
const templateItems = gameItemRows.map((row) => ({
const templateItems = topicItemRows.map((row) => ({
id: row.id,
ownerId: '',
src: row.src,
@@ -1685,7 +1655,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerName: row.topic_name || row.topic_id,
ownerEmail: '',
usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size,
linkedGames: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
sourceType: 'template',
sourceLabel: '관리자 템플릿',
canDelete: true,
@@ -1704,7 +1674,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
const allItems = baseItems
.map((item) => {
const siblings = groupedBySrc.get(item.src) || [item]
const linkedGames = new Map()
const linkedTemplates = new Map()
let userReferenceCount = 0
let templateReferenceCount = 0
let assetReferenceCount = 0
@@ -1713,8 +1683,8 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
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)
;(entry.linkedTemplates || []).forEach((template) => {
if (template?.id) linkedTemplates.set(template.id, template)
})
})
@@ -1724,7 +1694,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
sharedUserReferenceCount: userReferenceCount,
sharedTemplateReferenceCount: templateReferenceCount,
sharedAssetReferenceCount: assetReferenceCount,
sharedLinkedGameCount: linkedGames.size,
sharedLinkedTemplateCount: linkedTemplates.size,
sharedEntries: siblings
.slice()
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
@@ -1738,7 +1708,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
sourceTopicId: entry.sourceTopicId || '',
sourceTopicName: entry.sourceTopicName || '',
usageCount: entry.usageCount || 0,
linkedGames: entry.linkedGames || [],
linkedTemplates: entry.linkedTemplates || [],
isAssetLibraryItem: !!entry.isAssetLibraryItem,
})),
}
@@ -1752,7 +1722,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
case 'asset':
return !!item.isAssetLibraryItem
case 'unused-user':
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedTemplates.length === 0
case 'unused-admin':
return !!item.isAssetLibraryItem
default:
@@ -2027,7 +1997,7 @@ function uniqueTierListItems(poolItems) {
id: item.id,
src: item.src || '',
label: item.label || 'item',
origin: item.origin || 'game',
origin: item.origin || 'template',
})
})
return Array.from(map.values())
@@ -2052,12 +2022,12 @@ async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limi
const normalizedPage = Math.max(Number(page) || 1, 1)
const hasQuery = !!(queryText || '').trim()
const resolvedTopicId = (topicId || '').trim()
const hasGameId = !!resolvedTopicId
const hasTopicId = !!resolvedTopicId
const search = `%${(queryText || '').trim()}%`
const whereParts = []
const params = []
if (hasGameId) {
if (hasTopicId) {
whereParts.push('t.topic_id = ?')
params.push(resolvedTopicId)
}
@@ -2137,12 +2107,12 @@ async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limi
async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) {
const hasQuery = !!(queryText || '').trim()
const resolvedTopicId = (topicId || '').trim()
const hasGameId = !!resolvedTopicId
const hasTopicId = !!resolvedTopicId
const search = `%${(queryText || '').trim()}%`
const whereParts = []
const params = []
if (hasGameId) {
if (hasTopicId) {
whereParts.push('t.topic_id = ?')
params.push(resolvedTopicId)
}
@@ -2522,15 +2492,13 @@ async function unfavoriteTopic({ userId, topicId }) {
await query('DELETE FROM favorite_topics WHERE user_id = ? AND topic_id = ?', [userId, topicId])
}
const favoriteGame = favoriteTopic
const unfavoriteGame = unfavoriteTopic
module.exports = {
DB_NAME,
ensureData,
closePool,
countUsers,
findUserByEmail,
findUserByNickname,
findUserById,
createUser,
updateUserProfile,
@@ -2547,14 +2515,6 @@ module.exports = {
createTopic,
updateTopicThumbnail,
updateTopicVisibility,
listGames,
findGameById,
listGameItems,
findGameItemById,
getGameDetail,
createGame,
updateGameThumbnail,
updateGameVisibility,
findImageAssetByHash,
findImageAssetBySrc,
findImageAssetById,
@@ -2578,15 +2538,8 @@ module.exports = {
deleteTopicItem,
deleteTopic,
updateTopicDisplayOrder,
createGameItem,
updateGameItemLabel,
updateGameItemDisplayOrder,
countTierListsUsingGameItem,
updateCustomItemLabel,
updateImageAssetLabel,
deleteGameItem,
deleteGame,
updateGameDisplayOrder,
createCustomItem,
findCustomItemById,
listCustomItems,
@@ -2602,8 +2555,6 @@ module.exports = {
unfavoriteTopic,
favoriteTierList,
unfavoriteTierList,
favoriteGame,
unfavoriteGame,
deleteTierList,
findCustomItemsByIds,
deleteCustomItems,
@@ -2614,5 +2565,4 @@ module.exports = {
listAdminTemplateRequests,
updateTemplateRequestStatus,
updateTemplateRequestTargetTopic,
updateTemplateRequestTargetGame: updateTemplateRequestTargetTopic,
}

View File

@@ -0,0 +1,48 @@
const RESERVED_NICKNAME_KEYWORDS = [
'admin',
'administrator',
'operator',
'owner',
'master',
'staff',
'system',
'root',
'support',
'manager',
'mod',
'moderator',
'official',
'service',
'team',
'zenn',
'운영자',
'관리자',
'오너',
'마스터',
'스태프',
'시스템',
'루트',
'서포트',
'매니저',
'모더레이터',
'공식',
]
function normalizeNickname(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/\s+/g, '')
}
function isReservedNickname(value) {
const normalized = normalizeNickname(value)
if (!normalized) return false
return RESERVED_NICKNAME_KEYWORDS.some((keyword) => normalized.includes(normalizeNickname(keyword)))
}
module.exports = {
RESERVED_NICKNAME_KEYWORDS,
normalizeNickname,
isReservedNickname,
}

View File

@@ -7,6 +7,8 @@ const { z } = require('zod')
const { nanoid } = require('nanoid')
const {
findUserById,
findUserByEmail,
findUserByNickname,
findTopicById,
findTopicItemById,
listTopicItems,
@@ -52,11 +54,10 @@ const {
} = require('../db')
const { requireAdmin } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
const router = express.Router()
function getTemplateIdParam(req) {
return req.params.templateId || req.params.gameId || ''
function getTemplateIdFromParams(req) {
return req.params.templateId || ''
}
function buildUploadFilename(file) {
@@ -115,7 +116,7 @@ function canManageAdminRole(actingUser, primaryAdmin) {
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
}
router.post(['/games', '/templates'], requireAdmin, async (req, res) => {
router.post('/templates', requireAdmin, async (req, res) => {
const schema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(60),
@@ -125,24 +126,24 @@ router.post(['/games', '/templates'], requireAdmin, async (req, res) => {
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findTopicById(parsed.data.id)
if (exists) return res.status(409).json({ error: 'game_id_taken' })
if (exists) return res.status(409).json({ error: 'topic_id_taken' })
const template = await createTopic({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic })
if (parsed.data.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
const copiedThumb = await copyUploadIntoTopicAsset(parsed.data.thumbnailSrc)
await updateTopicThumbnail(template.id, copiedThumb)
}
const savedTemplate = await findTopicById(template.id)
res.json({ template: savedTemplate })
})
router.patch(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => {
router.patch('/templates/:templateId', requireAdmin, async (req, res) => {
const schema = z.object({
isPublic: z.boolean(),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const templateId = getTemplateIdParam(req)
const templateId = getTemplateIdFromParams(req)
const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' })
@@ -150,7 +151,7 @@ router.patch(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (
res.json({ template: updated })
})
router.patch(['/games/display-order', '/templates/display-order'], requireAdmin, async (req, res) => {
router.patch('/templates/display-order', requireAdmin, async (req, res) => {
const schema = z.object({
topicIds: z.array(z.string().min(1)).max(50),
})
@@ -164,14 +165,14 @@ router.patch(['/games/display-order', '/templates/display-order'], requireAdmin,
res.json({ templates: updatedTemplates })
})
router.patch(['/games/:gameId/items/display-order', '/templates/:templateId/items/display-order'], requireAdmin, async (req, res) => {
router.patch('/templates/:templateId/items/display-order', requireAdmin, async (req, res) => {
const schema = z.object({
itemIds: z.array(z.string().min(1)).min(1),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const templateId = getTemplateIdParam(req)
const templateId = getTemplateIdFromParams(req)
const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' })
@@ -179,15 +180,15 @@ router.patch(['/games/:gameId/items/display-order', '/templates/:templateId/item
res.json({ items })
})
router.post(['/games/:gameId/thumbnail', '/templates/:templateId/thumbnail'], requireAdmin, upload.single('thumbnail'), async (req, res) => {
router.post('/templates/:templateId/thumbnail', requireAdmin, upload.single('thumbnail'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'file_required' })
const templateId = getTemplateIdParam(req)
const templateId = getTemplateIdFromParams(req)
const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' })
const optimized = await writeOptimizedImage({
file: req.file,
directory: 'games',
directory: 'topics',
width: 1280,
height: 1280,
fit: 'inside',
@@ -198,10 +199,10 @@ router.post(['/games/:gameId/thumbnail', '/templates/:templateId/thumbnail'], re
res.json({ template: updated })
})
router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireAdmin, upload.array('images', 50), async (req, res) => {
router.post('/templates/:templateId/images', requireAdmin, upload.array('images', 50), async (req, res) => {
const files = Array.isArray(req.files) ? req.files : []
if (!files.length) return res.status(400).json({ error: 'file_required' })
const templateId = getTemplateIdParam(req)
const templateId = getTemplateIdFromParams(req)
const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' })
@@ -209,39 +210,68 @@ router.post(['/games/:gameId/images', '/templates/:templateId/images'], requireA
const labels = Array.isArray(labelsRaw) ? labelsRaw : labelsRaw ? [labelsRaw] : []
const normalizedLabels = labels.map((label) => (typeof label === 'string' ? label.trim().slice(0, 60) : ''))
if (normalizedLabels.some((label) => label.length > 60)) return res.status(400).json({ error: 'bad_request' })
const totalBytes = files.reduce((sum, file) => sum + Number(file?.size || 0), 0)
const items = await Promise.all(
files.map(async (file, index) => {
const optimized = await writeOptimizedImage({
file,
directory: 'games',
width: 512,
height: 512,
fit: 'inside',
quality: 84,
})
console.info('[admin] template image upload start', {
templateId: template.id,
fileCount: files.length,
totalBytes,
fileNames: files.map((file) => file.originalname),
})
return createTopicItem({
id: nanoid(),
topicId: template.id,
src: optimized.src,
label: normalizedLabels[index] || buildItemLabelFromFilename(file),
try {
const items = await Promise.all(
files.map(async (file, index) => {
const optimized = await writeOptimizedImage({
file,
directory: 'topics',
width: 512,
height: 512,
fit: 'inside',
quality: 84,
})
return createTopicItem({
id: nanoid(),
topicId: template.id,
src: optimized.src,
label: normalizedLabels[index] || buildItemLabelFromFilename(file),
})
})
)
console.info('[admin] template image upload success', {
templateId: template.id,
fileCount: items.length,
totalBytes,
})
)
res.json({ item: items[0], items })
res.json({ item: items[0], items })
} catch (error) {
console.error('[admin] template image upload failed', {
templateId: template.id,
fileCount: files.length,
totalBytes,
message: error?.message || 'upload_failed',
code: error?.code || '',
stack: error?.stack || '',
})
res.status(500).json({
error: 'template_image_upload_failed',
detail: error?.message || 'upload_failed',
})
}
})
router.delete(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => {
const template = await findTopicById(getTemplateIdParam(req))
router.delete('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => {
const template = await findTopicById(getTemplateIdFromParams(req))
if (!template) return res.status(404).json({ error: 'not_found' })
await deleteTopicItem(req.params.itemId)
res.json({ ok: true })
})
router.get(['/games/:gameId/items/:itemId/usage', '/templates/:templateId/items/:itemId/usage'], requireAdmin, async (req, res) => {
const template = await findTopicById(getTemplateIdParam(req))
router.get('/templates/:templateId/items/:itemId/usage', requireAdmin, async (req, res) => {
const template = await findTopicById(getTemplateIdFromParams(req))
if (!template) return res.status(404).json({ error: 'not_found' })
const item = await findTopicItemById(req.params.itemId)
if (!item || item.topicId !== template.id) return res.status(404).json({ error: 'not_found' })
@@ -249,12 +279,12 @@ router.get(['/games/:gameId/items/:itemId/usage', '/templates/:templateId/items/
res.json({ usage })
})
router.patch(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => {
router.patch('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => {
const schema = z.object({ label: z.string().trim().min(1).max(60) })
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const template = await findTopicById(getTemplateIdParam(req))
const template = await findTopicById(getTemplateIdFromParams(req))
if (!template) return res.status(404).json({ error: 'not_found' })
const updated = await updateTopicItemLabel(req.params.itemId, parsed.data.label)
@@ -262,8 +292,8 @@ router.patch(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:ite
res.json({ item: updated })
})
router.delete(['/games/:gameId', '/templates/:templateId'], requireAdmin, async (req, res) => {
const templateId = getTemplateIdParam(req)
router.delete('/templates/:templateId', requireAdmin, async (req, res) => {
const templateId = getTemplateIdFromParams(req)
const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' })
await deleteTopic(templateId)
@@ -469,7 +499,7 @@ async function promoteLibraryItemToTemplateItem({ item, templateId }) {
})
}
async function copyUploadIntoGameAsset(src) {
async function copyUploadIntoTopicAsset(src) {
if (typeof src !== 'string') return ''
const raw = src.trim()
if (!raw) return ''
@@ -507,7 +537,7 @@ async function promoteTierListItemsToTemplate({ tierList, templateId, itemIds =
const createdItems = []
for (const item of itemsToCopy) {
const copiedSrc = await copyUploadIntoGameAsset(item.src)
const copiedSrc = await copyUploadIntoTopicAsset(item.src)
createdItems.push(
await createTopicItem({
id: nanoid(),
@@ -531,7 +561,7 @@ async function promoteSnapshotItemsToTemplate({ items, templateId }) {
const createdItems = []
for (const item of items || []) {
const copiedSrc = await copyUploadIntoGameAsset(item.src)
const copiedSrc = await copyUploadIntoTopicAsset(item.src)
if (!copiedSrc || existingSrcs.has(copiedSrc)) continue
createdItems.push(
await createTopicItem({
@@ -576,13 +606,13 @@ function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}
async function createTemplateFromTierList({ tierList, templateId, templateName }) {
await createTopic({ id: templateId, name: templateName, isPublic: false })
if (tierList.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc)
const copiedThumb = await copyUploadIntoTopicAsset(tierList.thumbnailSrc)
await updateTopicThumbnail(templateId, copiedThumb)
}
const createdItems = []
for (const item of uniqueTierListPoolItems(tierList)) {
const copiedSrc = await copyUploadIntoGameAsset(item.src)
const copiedSrc = await copyUploadIntoTopicAsset(item.src)
createdItems.push(
await createTopicItem({
id: nanoid(),
@@ -593,14 +623,14 @@ async function createTemplateFromTierList({ tierList, templateId, templateName }
)
}
return { game: await findTopicById(templateId), items: createdItems }
return { template: await findTopicById(templateId), items: createdItems }
}
async function createTemplateFromRequest({ templateRequest, templateId, templateName }) {
await createTopic({ id: templateId, name: templateName, isPublic: false })
if (templateRequest.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc)
const copiedThumb = await copyUploadIntoTopicAsset(templateRequest.thumbnailSrc)
await updateTopicThumbnail(templateId, copiedThumb)
}
@@ -609,7 +639,7 @@ async function createTemplateFromRequest({ templateRequest, templateId, template
templateId,
})
return { game: await findTopicById(templateId), items }
return { template: await findTopicById(templateId), items }
}
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
@@ -631,7 +661,7 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
}
if (!target.canDelete) return res.status(409).json({ error: 'item_locked' })
if (target.linkedGames.length > 0) return res.status(409).json({ error: 'item_linked' })
if (target.linkedTemplates.length > 0) return res.status(409).json({ error: 'item_linked' })
if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
const items = await findCustomItemsByIds([target.id])
@@ -691,7 +721,7 @@ router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, re
res.json({ items })
})
router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (req, res) => {
router.post('/tierlists/:tierListId/create-template', requireAdmin, async (req, res) => {
const schema = z.object({
topicId: z.string().trim().min(1).max(120),
name: z.string().trim().min(1).max(120),
@@ -804,7 +834,7 @@ router.post('/template-requests/:requestId/review', requireAdmin, async (req, re
res.json({ request })
})
router.post('/template-requests/:requestId/link-game', requireAdmin, async (req, res) => {
router.post('/template-requests/:requestId/link-template', requireAdmin, async (req, res) => {
const schema = z.object({
topicId: z.string().trim().min(1).max(120),
})
@@ -962,6 +992,15 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
return res.status(403).json({ error: 'primary_admin_only' })
}
const duplicateEmail = await findUserByEmail(parsed.data.email)
if (duplicateEmail && duplicateEmail.id !== targetUser.id) {
return res.status(409).json({ error: 'email_taken' })
}
const duplicateNickname = await findUserByNickname(parsed.data.nickname, targetUser.id)
if (duplicateNickname) {
return res.status(409).json({ error: 'nickname_taken' })
}
try {
const updated = await adminUpdateUser({
id: targetUser.id,

View File

@@ -6,6 +6,7 @@ const multer = require('multer')
const {
countUsers,
findUserByEmail,
findUserByNickname,
findUserById,
createUser,
updateUserProfile,
@@ -13,11 +14,13 @@ const {
} = require('../db')
const { requireAuth } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
const { isReservedNickname } = require('../lib/user-validation')
const router = express.Router()
const signupSchema = z.object({
email: z.string().email(),
nickname: z.string().trim().min(2).max(40),
password: z.string().min(6),
})
@@ -62,13 +65,16 @@ router.post('/signup', async (req, res) => {
const parsed = signupSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const { email, password } = parsed.data
const { email, nickname, password } = parsed.data
const exists = await findUserByEmail(email)
if (exists) return res.status(409).json({ error: 'email_taken' })
if (isReservedNickname(nickname)) return res.status(400).json({ error: 'nickname_reserved' })
const nicknameExists = await findUserByNickname(nickname)
if (nicknameExists) return res.status(409).json({ error: 'nickname_taken' })
const passwordHash = await bcrypt.hash(password, 10)
const isAdmin = (await countUsers()) === 0
const user = await createUser({ id: nanoid(), email, nickname: '', passwordHash, isAdmin })
const user = await createUser({ id: nanoid(), email, nickname, passwordHash, isAdmin })
try {
await establishSession(req, user)
@@ -79,7 +85,10 @@ router.post('/signup', async (req, res) => {
})
router.post('/login', async (req, res) => {
const parsed = signupSchema.safeParse(req.body)
const parsed = z.object({
email: z.string().email(),
password: z.string().min(6),
}).safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const { email, password } = parsed.data
@@ -121,6 +130,9 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
const user = await findUserById(req.session.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
if (isReservedNickname(parsed.data.nickname)) return res.status(400).json({ error: 'nickname_reserved' })
const nicknameExists = await findUserByNickname(parsed.data.nickname, user.id)
if (nicknameExists) return res.status(409).json({ error: 'nickname_taken' })
const optimized = req.file
? await writeOptimizedImage({

View File

@@ -24,7 +24,7 @@ const FREEFORM_TOPIC_ID = 'freeform'
const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기'
function normalizePoolItem(item) {
if (!item || item.origin !== 'game' || typeof item.src !== 'string') return item
if (!item || item.origin !== 'template' || typeof item.src !== 'string') return item
if (item.src.startsWith('/uploads/')) return item
try {
@@ -83,7 +83,7 @@ const templateRequestSchema = z.object({
id: z.string().min(1),
src: z.string().min(1),
label: z.string().min(1).max(60),
origin: z.enum(['game', 'custom']).default('game'),
origin: z.enum(['template', 'custom']).default('template'),
})
),
})
@@ -112,7 +112,7 @@ const tierListUpsertSchema = z.object({
id: z.string().min(1),
src: z.string().min(1),
label: z.string().min(1).max(60),
origin: z.enum(['game', 'custom']).default('game'),
origin: z.enum(['template', 'custom']).default('template'),
})
),
}).superRefine((value, ctx) => {

View File

@@ -1,5 +1,43 @@
# 의사결정 이력
## 2026-04-03 v1.4.38
- 최고 관리자 보호는 서버에서만 막고 프런트 버튼은 열어두면 운영자가 계속 실패 액션을 누르게 되므로, 최고 관리자 대상 행은 일반 운영자 화면에서 버튼 자체를 비활성화하는 편이 맞다고 판단했다.
- 예약어 닉네임은 일반 가입/프로필 수정에서는 계속 막아야 하지만, 운영자가 직접 회원 계정을 정리할 때는 `공식`, `운영자` 같은 표기를 의도적으로 부여해야 할 수 있으므로 관리자 수정 API만 예외를 두는 쪽으로 정리했다.
- 공유 프리뷰는 별도 단독 페이지처럼 보이는 것보다 홈과 같은 좌측/우측 레일, 같은 중앙 헤더, 같은 메인 배경 위에 자연스럽게 얹히는 편이 서비스 유입과 탐색 전환에 더 유리하다고 판단해, 프리뷰 전용 셸을 공통 앱 셸로 흡수했다.
## 2026-04-02 v1.4.33
- 서비스 공개 전 단계에서는 가입 자체를 열어두는 것보다, 이메일/닉네임 중복과 운영자 사칭성 닉네임을 먼저 막아두는 편이 훨씬 중요하다고 판단했다.
- 닉네임 제한은 회원가입 한 곳에만 두면 이후 프로필 수정이나 관리자 수정으로 쉽게 우회되므로, auth/profile/admin 수정 흐름 전부가 같은 예약어 정책을 공유하도록 정리했다.
- 라이트 모드는 취향상 필요한 사용자가 있을 수 있으므로 완전히 제거하기보다, 기본값만 다크로 고정하고 설정 화면에서만 직접 토글하도록 두는 편이 더 균형 잡힌 선택이라고 정리했다.
## 2026-04-02 v1.4.32
- 서비스 공개 전 마감 단계에서는 사용자 노출 텍스트만이 아니라 파일명·composable 이름·관리자 CSS 클래스·백엔드 헬퍼 함수명까지 같이 정리해 두는 편이 이후 유지보수 비용을 확실히 낮춘다고 판단했다.
- 이 시점부터는 `game`이 데이터 호환층도 아닌 단순 내부 이름으로 남아 있는 것조차 혼란을 만들 수 있으므로, 실제 기능을 바꾸지 않는 선에서 이름층까지 끝까지 정리해 코드 검색 결과 자체를 깨끗하게 만드는 방향으로 마감했다.
## 2026-04-02 v1.4.31
- 서비스가 아직 외부 공개 전이고 예전 북마크/예전 데이터베이스를 이어갈 필요가 없다는 전제가 확인되었으므로, 남겨둔 호환층을 유지하는 것보다 지금 마감 시점에 완전히 제거해 구조를 단순화하는 편이 맞다고 판단했다.
- 이 단계에서는 “기존 것도 읽어준다”보다 “현재 구조만 남긴다”가 더 중요한 목표가 되었으므로, redirect·legacy migration·`origin: 'game'` 허용까지 함께 정리해 실제 코드 검색에서 `game` 흔적을 0건으로 맞추는 방향으로 마감했다.
## 2026-04-02 v1.4.30
- 로컬 MariaDB는 테스트용으로 새로 밀어도 된다는 전제가 확인되었으므로, 개발 환경에서는 기존 데이터를 끌고 가는 것보다 현재 스키마가 “빈 DB에서 바로 정상 부팅되는지”를 먼저 검증하는 편이 더 가치 있다고 판단했다.
- `origin: 'game'` 호환층은 즉시 제거하기보다, `ensureData()`에서 저장 데이터와 요청 스냅샷을 자동 정규화하게 만들어 두고 새 DB에서도 legacy 값이 생기지 않는 상태를 먼저 확인한 뒤 다음 제거 판단으로 넘기는 편이 가장 안전하다고 정리했다.
## 2026-04-02 v1.4.29
- `origin: 'game'`는 이미 저장된 티어표 데이터와 직접 맞물리므로, 이 단계에서는 새 데이터 기본값만 `template`로 옮기고 예전 값도 계속 받아주는 점진 호환이 가장 안전하다고 판단했다.
- 아이템 라이브러리의 `linkedGames`는 실제 의미가 템플릿 연결 정보이므로, 이 응답 키까지 `linkedTemplates`로 바꿔두는 편이 이후 관리자 유지보수에서 훨씬 덜 헷갈린다고 정리했다.
## 2026-04-02 v1.4.28
- 이 시점 이후 코드 검색에 남는 `game`는 대부분 레거시 데이터 마이그레이션, 옛 주소 redirect, 저장 데이터의 `origin` 호환처럼 의도된 층이므로, 무리하게 전부 0으로 만들기보다 기능을 깨뜨리지 않는 선에서 의미 있는 이름층만 더 줄이는 편이 맞다고 판단했다.
- 관리자 화면 내부 상태명(`selectedTemplate.game`, `isGameLoading`, `gameVisibilitySaving`)은 실제 기능 의미와 어긋나므로, QA 전에 한 번 더 `template` 기준으로 옮겨두는 편이 이후 유지보수에 더 유리하다고 정리했다.
## 2026-04-02 v1.4.27
- 공개/관리자 API 표면까지 `topic/template`로 정리된 뒤에는, 관리자 내부 상태 이름과 DB export alias에 남은 `game` 흔적도 계속 유지할 이유가 작아졌으므로 이 단계에서 함께 걷어내는 편이 맞다고 판단했다.
- 다만 외부에서 직접 참조할 수 있는 공개 북마크와 달리, `adminGames`, `game-admin`, `favoriteGame` 같은 이름은 내부 구현 용어라서 이번 단계에서 정리해도 위험이 낮다고 정리했다.
## 2026-04-02 v1.4.26
- `topic/template` 표면을 거의 마감한 시점에서는 관리자 API와 관리자 화면 경로까지 계속 `/games` alias를 유지하는 편보다, 실제 사용하는 `templates` 경로만 남기고 예전 관리자 주소는 redirect로만 정리하는 편이 더 일관되고 안전하다고 판단했다.
- 공개 사용자 북마크는 여전히 `/games -> /topics` redirect가 필요하지만, 백엔드 API의 `/api/games`까지 계속 유지할 이유는 작아졌으므로 이 단계에서 `/api/topics`만 남기는 편이 맞다고 정리했다.
## 2026-04-02 v1.4.25
- 이제 프런트와 백엔드 소비층이 `topic/template`를 기본으로 읽을 준비가 되었으므로, 응답과 payload에 `gameId / gameName` 호환 키를 오래 남기는 것보다 실제 표면을 먼저 정리하는 편이 더 낫다고 판단했다.
- 다만 오래된 외부 링크까지 한 번에 끊는 건 위험하므로, 이번 단계에서는 데이터/응답/프런트 소비는 `topic`으로 마감하되 `/games/:gameId`와 관리자 route alias 같은 레거시 주소만 마지막 호환 레이어로 남기는 점진 종료가 가장 안전하다고 정리했다.
@@ -579,6 +617,22 @@
- 게임 이미지 경로는 저장 시 상대 경로(`/uploads/...`)를 유지하는 방향으로 정리했다.
- 현재 단계에서는 구조 변경 비용을 고려해 DB를 유지하되, 운영/확장성 요구가 커지기 전 RDB 이관 판단이 필요하다고 기록했다.
## 2026-04-03 v1.4.37
- 드롭 또는 클릭 안내를 보여주는 업로드 박스는 실제 클릭 동작까지 연결돼 있어야 UX가 자연스러우므로, 이후 같은 패턴에서는 박스 자체가 트리거 역할을 하도록 맞추기로 했다.
## 2026-04-03 v1.4.36
- 복사 기능은 “타인 티어표 가져오기”에만 묶기보다, 본인 작업도 파생본을 빠르게 만드는 용도로 열어두는 편이 실제 제작 흐름에 더 맞는다고 정리했다.
- 공유 프리뷰도 서비스와 완전히 단절된 단일 화면보다, 광고 레일과 카피라이트를 포함한 가벼운 사이트 문맥을 유지하는 편이 자연스럽다고 판단했다.
## 2026-04-03 v1.4.35
- 실제 사용 테스트에서 아이템 수가 80개 안팎으로 늘어나면 “기능은 있는데 찾을 수 없는 상태”가 되기 쉬워, 편집기 풀에는 가벼운 이름 검색이 필수라고 정리했다.
- 공유 링크는 완성본만 보여주는 데서 끝내지 말고 서비스 메인으로 돌아오는 손잡이를 함께 두는 편이 자연스럽다고 판단했다.
- 공개 프리뷰의 작성 시각은 분 단위까지 노출할 필요가 낮아, 신뢰에 필요한 최소 정보만 남기는 쪽으로 줄이기로 했다.
## 2026-04-02 v1.4.34
- 라이트모드는 단순 토글 존재만으로 충분하지 않고, 셸/카드/버튼/오버레이가 같은 색 문법을 공유해야 품질이 안정된다고 판단해 공통 토큰을 다시 정리했다.
- 홈 카드 즐겨찾기 버튼처럼 다크 전용 하드코딩이 남아 있으면 전체 인상이 쉽게 무너지므로, 이후 테마 보정은 공통 변수 우선 원칙으로 계속 가져가기로 했다.
## 2026-03-19
- 초기 저장소는 빠른 구현을 위해 `lowdb(JSON 파일)`를 채택했다.
- 인증은 JWT 대신 서버 세션(`express-session` + `session-file-store`)을 사용했다.

View File

@@ -2,18 +2,18 @@
## `/`
- 화면 파일: `frontend/src/views/HomeView.vue`
- 역할: 데스크톱 기본 4열 게임 카드 라이브러리 대시보드, 상단 메인 썸네일과 `게임명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
- 연동 API: `GET /api/games`
- 역할: 데스크톱 기본 4열 주제 카드 라이브러리 대시보드, 상단 메인 썸네일과 `주제명 + 작은 ID` 메타를 가진 템플릿 선택 카드, 주제 카드 클릭 이동, `직접 티어표 만들기` 진입
- 연동 API: `GET /api/topics`
## `/games/:gameId`
- 화면 파일: `frontend/src/views/GameHubView.vue`
- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
## `/topics/:topicId`
- 화면 파일: `frontend/src/views/TopicHubView.vue`
- 역할: 선택한 주제 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
- 화면 파일: `frontend/src/views/TierEditorView.vue`
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request`
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰 렌더링
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request`
## `/login`
- 화면 파일: `frontend/src/views/LoginView.vue`
@@ -37,8 +37,8 @@
## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-game-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
- 연동 API: `POST /api/admin/templates`, `POST /api/admin/templates/:templateId/thumbnail`, `POST /api/admin/templates/:templateId/images`, `PATCH /api/admin/templates/:templateId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/template-requests/:requestId/link-template`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/templates/:templateId/items/:itemId`, `DELETE /api/admin/templates/:templateId`
## `/profile`
- 화면 파일: `frontend/src/views/ProfileView.vue`
@@ -47,7 +47,7 @@
## 공통 레이아웃
- 앱 셸 파일: `frontend/src/App.vue`
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker`와 서비스 설명이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
## 백엔드 진입점
@@ -56,6 +56,6 @@
- 로컬 DB 실행 설정: `docker-compose.yml`
- 로컬 MariaDB 가이드: `docs/local-mariadb.md`
- 인증 라우트: `backend/src/routes/auth.js`
- 게임 라우트: `backend/src/routes/games.js`
- 주제 라우트: `backend/src/routes/topics.js`
- 티어표 라우트: `backend/src/routes/tierlists.js`
- 관리자 라우트: `backend/src/routes/admin.js`

View File

@@ -9,7 +9,7 @@
- 운영 배포: `frontend(Nginx 정적 서빙 + /api,/uploads 프록시) + backend + mariadb` Docker Compose 구조
- NAS HTTPS 리버스 프록시 운영 시 프런트 Nginx는 백엔드로 `X-Forwarded-Proto: https`를 전달하고, Express 세션은 프록시 환경에서 `secure` 쿠키를 허용하도록 설정한다.
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, preview 모드에서는 이 셸을 숨기고 콘텐츠만 렌더링한다.
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, `preview=1` 모드에서도 같은 셸을 유지한 채 중앙 본문만 완성본 프리뷰로 렌더링한다.
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
- 좌측 패널은 필요 시 축소형 레일로 접을 수 있으며, 접힌 상태에서는 아이콘 중심 내비게이션과 축약된 바로가기만 유지한다.
- 이 3단 셸 구조는 홈, 게임 허브, 에디터, 관리자 등 일반 페이지 전반의 공통 뼈대로 유지하고, 페이지별 차이는 중앙/우측에 어떤 콘텐츠를 넣는지만 달라지도록 관리한다.
@@ -45,6 +45,7 @@
- 좌우 레일의 주요 CTA는 스크롤되는 본문과 분리된 하단 `56px` 액션 영역에 배치한다.
- 하단 액션은 화면 바닥에 바로 붙지 않도록 푸터 내부에 추가 하단 여백을 둔다.
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
- 광고 영역은 상단 헤더와 시각적으로 너무 붙지 않도록 `78px` 상단 여백을 두고, 하단 카피라이트는 중앙 정렬된 공통 footer로 표시한다.
- 티어표 편집 화면
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
@@ -54,7 +55,7 @@
- 관리자 화면
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다. 템플릿 요청 카드는 전체 티어표 카드와 같은 썸네일 좌측/정보 우측 구조를 따르며, 요청 미리보기 일반 티어표 완성본과 같은 행·열 보드 문법으로 검수한다.
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다. 템플릿 요청 카드는 전체 티어표 카드와 같은 썸네일 좌측/정보 우측 구조를 따르며, 요청 미리보기`preview=1` 공유 프리뷰는 공통 앱 셸 안에서 일반 티어표 완성본과 같은 행·열 보드 문법으로 검수한다.
- 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다.
## DB 스키마
@@ -114,12 +115,12 @@
- `GET /api/auth/me`
- `GET /api/auth/meta`
- `POST /api/auth/profile`
- 게임
- `GET /api/games`
- `GET /api/games/:gameId`
- 주제
- `GET /api/topics`
- `GET /api/topics/:topicId`
- 티어표
- `GET /api/tierlists/public`
- `gameId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
- `topicId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
- `GET /api/tierlists/me`
- `GET /api/tierlists/favorites/me`
- `GET /api/tierlists/:id`
@@ -131,17 +132,18 @@
- `POST /api/tierlists/custom-items`
- `POST /api/tierlists`
- 관리자
- `POST /api/admin/games`
- `POST /api/admin/games/:gameId/thumbnail`
- `POST /api/admin/games/:gameId/images`
- `POST /api/admin/templates`
- `POST /api/admin/templates/:templateId/thumbnail`
- `POST /api/admin/templates/:templateId/images`
- 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
- `PATCH /api/admin/games/:gameId/items/:itemId`
- `PATCH /api/admin/templates/:templateId/items/:itemId`
- `GET /api/admin/tierlists`
- `GET /api/admin/template-requests`
- `POST /api/admin/template-requests/:requestId/approve`
- `POST /api/admin/template-requests/:requestId/reject`
- `POST /api/admin/template-requests/:requestId/link-template`
- `POST /api/admin/tierlists/:tierListId/promote-items`
- `POST /api/admin/tierlists/:tierListId/create-game-template`
- `POST /api/admin/tierlists/:tierListId/create-template`
- `GET /api/admin/custom-items`
- `POST /api/admin/custom-items/:itemId/promote`
- `DELETE /api/admin/custom-items/:itemId`
@@ -150,8 +152,8 @@
- `PATCH /api/admin/users/:userId`
- `PATCH /api/admin/users/:userId/password`
- `DELETE /api/admin/users/:userId`
- `DELETE /api/admin/games/:gameId/items/:itemId`
- `DELETE /api/admin/games/:gameId`
- `DELETE /api/admin/templates/:templateId/items/:itemId`
- `DELETE /api/admin/templates/:templateId`
## 관리자 화면 메모
- 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다.
@@ -171,6 +173,8 @@
- 관리자 티어표 관리 상단에는 사용자가 보낸 템플릿 등록/업데이트 요청 목록이 별도로 표시되며, 여기서 승인/반려를 바로 처리할 수 있다.
- 관리자 템플릿 요청 목록에서 `반려 후 숨김`을 누르면 해당 요청은 pending 목록에서 즉시 제외된다.
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
- 단, 일반 운영자는 최고 관리자 계정의 프로필 이미지/회원 정보/비밀번호/삭제 버튼을 사용할 수 없고, 최고 관리자만 다른 관리자 권한을 변경할 수 있다.
- 관리자 회원 정보 수정은 운영상 필요한 경우 예약어 닉네임도 저장할 수 있지만, 일반 회원가입과 개인 프로필 수정에서는 운영자 사칭성 예약어 닉네임을 계속 차단한다.
- 회원 관리 카드에는 아바타, 작성 티어표 수, 최근 활동 시각을 함께 표시한다.
## 티어표 접근 메모

View File

@@ -1,8 +1,31 @@
# 할 일 및 이슈
## 단기 확인
- `v1.4.38`에서 운영자 계정의 최고 관리자 관리 버튼을 프런트에서 비활성화했으므로, 최고 관리자/일반 운영자/일반 회원 세 계정 조합으로 회원 정보 수정, 썸네일 변경, 비밀번호 초기화, 삭제 버튼 활성 상태와 서버 차단 응답이 기대대로 맞는지 확인한다.
- 관리자 회원 정보 수정에서는 예약어 닉네임을 허용하도록 예외를 열었으므로, 일반 회원가입/개인 프로필 수정은 여전히 예약어가 막히고 관리자 수정만 저장되는지 한 번 더 QA한다.
- `preview=1` 화면이 공통 앱 셸을 그대로 쓰도록 바뀌었으므로, 좌측 레일 접기/펼치기, 우측 레일 닫기/열기, 중앙 헤더 타이틀, 메인 배경색, 오른쪽 광고와 카피라이트 정렬이 홈 화면과 같은 문법으로 보이는지 비교 QA한다.
- 프리뷰 우측 광고에 `padding-top: 78px`를 넣었으므로, 데스크톱/태블릿 폭에서 광고 시작 위치가 너무 내려가거나 모바일 오버레이 레이아웃에서 어색해지지 않는지 확인한다.
- `v1.4.33`에서 회원가입에 닉네임 입력과 중복/예약어 검사를 붙였으므로, 실제 QA에서는 이메일 중복, 닉네임 중복, 예약 닉네임, 프로필 닉네임 변경, 관리자 회원 수정 흐름이 같은 규칙으로 막히는지 확인한다.
- 테마는 저장값이 없을 때 무조건 다크로 시작하게 바꿨고 설정 화면 토글도 다시 열었으므로, 첫 접속/새 브라우저/다른 운영체제에서 기본 다크 시작과 수동 토글 저장이 그대로 정상인지 확인한다.
- 관리자 템플릿 썸네일 드롭존 빈 상태 아이콘 제거와 아이템 상세 모달 썸네일 프리뷰가 들어갔으므로, 관리자 화면에서 썸네일 교체와 아이템 선택 모달 가독성을 한 번 더 QA한다.
- `v1.4.32`에서 파일명·composable·관리자 클래스명·백엔드 헬퍼 함수명까지 `topic/template` 기준으로 끝까지 정리했으므로, 다음 실제 QA는 기능 동작 확인에 집중하고 이름층 회귀는 별도 체크만 하면 된다.
- 현재 `backend/src`, `frontend/src`, `backend/scripts`, `backend/index.js` 기준 `game/Game` 검색은 0건이므로, 이후 남는 확인 작업은 서비스 동작과 배포 환경 쪽에만 집중한다.
- `v1.4.31`에서 `/games` redirect와 legacy DB 마이그레이션까지 제거했으므로, 실제 QA에서는 오직 현재 주소(`/topics`, `/admin/templates`)와 새 DB 기준 흐름만 집중적으로 확인하면 된다.
- 현재 `backend/src`, `frontend/src` 기준 `game` 검색은 0건이므로, 이후 남는 확인 작업은 기능 QA와 운영 환경 배포 점검 쪽에만 집중한다.
- `v1.4.30`에서 빈 로컬 MariaDB 재초기화 검증까지 통과했으므로, 다음 실제 QA에서는 “기존 데이터가 있는 환경”에서 `ensureData()`가 저장 티어표와 템플릿 요청 스냅샷의 legacy origin을 정상 정규화하는지만 추가 확인하면 된다.
- 개발 환경 기준으로는 새 DB에서 `legacyTierItems=0`, `legacyRequestItems=0`가 확인됐으므로, 이후에는 `origin: 'game'` 호환 코드를 언제 완전히 제거할지 운영 데이터 기준으로만 판단하면 된다.
- `v1.4.29`에서 새 티어표 데이터 기본 origin을 `template`로 바꿨으므로, 저장 후 다시 열기/복사/요청 생성/관리자 가져오기 흐름에서 예전 데이터와 새 데이터가 함께 섞여도 정상 동작하는지 한 번 더 확인한다.
- 관리자 아이템 라이브러리 응답 키가 `linkedTemplates`로 정리됐으므로, 사용자 업로드 이미지 삭제 차단과 템플릿 이동 모달이 그대로 정상 동작하는지 확인한다.
- 현재 남아 있는 `game`는 레거시 redirect, DB 마이그레이션, 호환용 origin만 남겨둔 상태이므로, `v1.4` QA 후에는 이 레거시 층을 언제 제거할지 별도 마감 판단만 하면 된다.
- `v1.4.28`에서 관리자 템플릿 상세 상태와 기본 아이템 정렬 상태 이름을 `template` 기준으로 더 정리했으므로, 관리자 템플릿 선택/공개 전환/기본 아이템 정렬 저장이 그대로 정상인지 한 번 더 확인한다.
- 새 템플릿 썸네일/기본 아이템 업로드는 이제 `topics` 디렉터리로 저장되므로, 실제 업로드 후 최적화 작업 분류와 관리자 최근 작업 표시가 자연스럽게 보이는지 확인한다.
- 현재 코드 검색에 남는 `game`는 레거시 redirect, DB 마이그레이션, `origin: 'game'` 호환이 중심이므로, 이 층까지 실제로 없앨지 여부는 `v1.4` QA 후 안정성 기준으로 다시 판단한다.
- `v1.4.27`에서 관리자 내부 탭/라우트 이름과 DB alias export까지 더 정리했으므로, 관리자 템플릿 탭 이동, 커스텀 아이템에서 템플릿 관리로 점프, 템플릿 요청 확인하기 이동이 모두 정상인지 한 번 더 확인한다.
- `v1.4.26`에서 관리자 기본 경로를 `/admin/templates`로 바꾸고 `/api/admin/templates`만 남겼으므로, 관리자 진입/새로고침/뒤로가기와 템플릿 생성·썸네일 업로드·아이템 추가가 모두 정상인지 확인한다.
- `v1.4.26`에서 공개 API `/api/games`를 제거했으므로, 실제 서버 재시작 후 홈/주제 상세/티어표 편집기에서 `/api/topics`만으로 모두 정상 동작하는지 확인한다.
- 오래된 관리자 주소 `/admin/games`는 redirect만 남겼으므로, 북마크로 직접 진입해도 `/admin/templates`로 자연스럽게 바뀌는지 본다.
- `v1.4.25`에서 티어표/요청 응답의 `gameId / gameName` 호환 키를 실제로 제거했으므로, 브라우저에서 홈 목록, 주제 상세, 저장된 티어표 열기, 즐겨찾기, 검색 결과, 관리자 템플릿 요청/전체 티어표 관리가 모두 정상 동작하는지 한 번 더 QA한다.
- `v1.4.25`에서 관리자 route query와 편집기 저장/request payload를 `topicId` 기준으로 옮겼으므로, `/admin/games?topicId=...`, `/admin/tierlists?mode=all&topicId=...`, 티어표 저장, 템플릿 요청, 추가 아이템 가져오기 흐름이 모두 정상인지 확인한다.
- `v1.4.25`에서 관리자 route query와 편집기 저장/request payload를 `topicId` 기준으로 옮겼으므로, `/admin/templates?topicId=...`, `/admin/tierlists?mode=all&topicId=...`, 티어표 저장, 템플릿 요청, 추가 아이템 가져오기 흐름이 모두 정상인지 확인한다.
- 남은 `gameId`는 의도적으로 유지한 레거시 주소 alias(`/games/:gameId`)와 관리자 alias route path뿐이므로, 오래된 외부 링크 진입 후 주소가 새 `topic` 체계로 자연스럽게 정규화되는지만 마지막으로 본다.
- `v1.4.24`에서 공개 주제 API와 관리자 템플릿 API의 기본 응답 키를 더 줄였으므로, 실제 브라우저에서 홈 목록, 즐겨찾기 토글, 주제 상세, 티어표 편집기, 관리자 템플릿 공개 전환/생성이 모두 그대로 정상인지 한 번 더 QA한다.
- 다음 단계에서는 `mapTierListRow`, `mapTemplateRequestRow`, 관리자 route query, 저장 payload 입력 호환에 남아 있는 `gameId/gameName/sourceGameId/targetGameId`를 끝까지 걷어낼지 최종 결정한다.
@@ -30,7 +53,6 @@
- 프런트 API 호출부는 `topic/template` 의미 이름으로 옮겼으므로, 다음 단계에서는 `api.js` 안의 레거시 alias를 얼마나 더 유지할지와 백엔드 API 경로를 실제로 바꿀지 범위를 정한다.
- 관리자 템플릿/주제 화면과 홈·에디터·즐겨찾기에서 새 API 이름층으로 바뀐 뒤에도 저장, 즐겨찾기, 템플릿 생성, 아이템 정렬 흐름이 자연스러운지 한 번 더 QA한다.
- `topicHub / topicId`를 기본 라우트 기준으로 세웠으므로, 기존 `/games/...` 북마크와 새 `/topics/...` 주소 양쪽에서 주제 상세와 에디터 진입이 모두 자연스럽게 이어지는지 한 번 더 QA한다.
- 다음 단계에서는 `api.getGame`, `listGames`, `favoriteGame`처럼 남아 있는 프런트 API 이름을 어느 수준까지 `topic/template` 의미로 감쌀지 정리한다.
- 경로 헬퍼를 사용자 주요 화면에 연결했으므로, 로그인 리다이렉트·공유 프리뷰·복사본 이동·주제 복귀 흐름이 실제 브라우저에서 모두 같은 주소 체계로 자연스럽게 이어지는지 한 번 더 QA한다.
- 다음 단계에서는 `router/index.js``gameHub`, `route.params.gameId`, `api.getGame`처럼 남아 있는 프런트 내부 이름도 어디까지 `topic/template` 의미로 감쌀지 범위를 더 좁힌다.
- 주제 상세 화면 제목은 ID fallback 대신 로딩 문구를 쓰도록 바꿨으므로, 직접 진입·뒤로가기·다른 주제로 연속 이동할 때 헤더가 깜빡이거나 이전 제목을 잠깐 유지하지 않는지 한 번 더 QA한다.
@@ -95,6 +117,7 @@
- 관리자 게임 아이템 순서 저장은 추가됐으므로, 다음 단계에서는 새 아이템 추가 직후 `자동 맨 앞 배치``관리자 수동 고정 순서`의 우선순위를 실제 운영 흐름 기준으로 한 번 더 QA한다.
- 관리자 템플릿 요청 미리보기는 실제 완성본 iframe 방식과의 체감 차이를 마지막으로 한 번 더 QA한다.
- 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다.
- 라이트모드 공통 토큰 재정비와 카드/아바타/즐겨찾기 버튼 보정까지 반영했으므로, 다음 QA에서는 로그인/홈/주제 허브/에디터/관리자 순으로 실제 플로우를 돌리며 남은 하드코딩 색과 과한 대비가 없는지 확인한다.
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
@@ -105,6 +128,8 @@
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다.
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
- 템플릿 기본 아이템 다중 업로드는 8개까지 성공, 9개 이상 한 번에 전송 시 실패하는 사례가 있었으므로 NAS/리버스 프록시의 업로드 body 제한(`client_max_body_size` 등)과 실제 응답 코드를 운영 환경에서 확인한다.
- 프리뷰 우측 광고 레일을 붙였으므로, 실제 운영 환경에서 광고가 로드될 때 프리뷰 본문 폭이 과하게 줄지 않는지 데스크톱 기준으로 한 번 더 확인한다.
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.

View File

@@ -1,5 +1,53 @@
# 업데이트 로그
## 2026-04-03 v1.4.38
- 관리자 회원 관리에서 운영자 계정으로는 최고 관리자 계정의 썸네일 변경, 비밀번호 초기화, 회원 삭제, 회원 정보 수정 버튼이 비활성화되도록 프런트 보호를 추가했고, 자기 자신 삭제 버튼도 함께 막았다.
- 관리자 회원 정보 수정에서는 운영자/관리자 예약어가 들어간 닉네임도 저장할 수 있도록 서버 검증 예외를 분리했고, 일반 회원가입과 개인 프로필 수정의 예약어 차단은 그대로 유지했다.
- `preview=1` 티어표 화면은 별도 프리뷰 셸을 걷어내고 공통 좌측 레일·중앙 헤더·우측 레일을 그대로 쓰도록 바꿨으며, 프리뷰 본문 제목도 홈 화면과 같은 `pageHead` 문법으로 맞췄다.
- 프리뷰/일반 화면의 오른쪽 광고 레일은 같은 공통 레일 footer를 사용하게 되어 카피라이트 중앙 정렬이 맞춰졌고, 애드센스 영역 상단에는 `padding-top: 78px`를 적용해 시각적 시작점을 조금 아래로 내렸다.
- 로컬에서 생성되는 `backend/uploads/assets/` 최적화 이미지가 Git 변경분에 섞이지 않도록 `.gitignore` 제외 경로에 추가했다.
## 2026-04-02 v1.4.33
- 회원가입 시 닉네임 입력을 함께 받도록 바꾸고, 이메일 중복과 닉네임 중복을 서버에서 명확히 차단하도록 정리했다.
- `admin`, `운영자`, `관리자`, `official`, `zenn`처럼 운영자·공식 계정으로 오해될 수 있는 닉네임은 예약어로 막고, 프로필 수정/관리자 회원 수정에서도 같은 규칙을 공유하도록 맞췄다.
- 로그인·회원가입 화면은 중복된 이메일/닉네임일 때 빨간색 오류 메시지를 바로 보여주도록 보강했고, 테마는 저장값이 없을 때 무조건 다크로 시작하면서 설정 화면에서만 라이트/다크 토글을 다시 노출하도록 정리했다.
- 관리자 템플릿 썸네일 드롭존의 빈 상태 아이콘은 제거했고, 아이템 상세 모달에는 선택한 썸네일 프리뷰를 추가해 현재 선택한 이미지가 더 잘 보이게 했다.
## 2026-04-02 v1.4.32
- 파일명과 내부 심볼 이름까지 `topic/template` 기준으로 마감했다. `GameHubView``TopicHubView`, `AdminGamesSection``AdminTemplatesSection`, `useAdminGameManager``useAdminFeaturedGames`는 각각 `useAdminTemplateManager`, `useAdminFeaturedTemplates`로 정리했다.
- 관리자 화면 내부 상태와 스타일 클래스도 `adminTemplatePicker`, `templateManagerGrid`, `templateSettingsCard` 기준으로 바꿔, 사용자에게는 안 보이지만 코드 검색에서 남던 `Game` 흔적을 더 걷어냈다.
- 백엔드도 `copyUploadIntoTopicAsset`, `mapTopicRow`, `mapTopicItemRow`처럼 내부 함수명을 맞추고, 업로드 디렉터리/정리 스크립트도 `topics` 기준으로 통일해 `backend/src`, `frontend/src`, `backend/scripts`, `backend/index.js` 범위의 `game/Game` 검색 결과를 0건으로 정리했다.
## 2026-04-02 v1.4.31
- 서비스가 아직 공개 전이고 예전 링크/예전 DB를 이어갈 필요가 없다는 전제에 맞춰, `/games` redirect와 관리자 `/admin/games` redirect, DB 레거시 마이그레이션 코드, legacy origin 정규화 코드를 실제로 제거했다.
- 티어표 저장/request schema도 이제 `origin: 'template' | 'custom'`만 받도록 정리했고, 관리자 최근 최적화 작업 분류 fallback에 남아 있던 `games` 처리도 걷어냈다.
- seed 데이터 ID까지 `example-topic`, `another-topic` 기준으로 바꿔, 현재 `backend/src``frontend/src` 코드 검색에서 `game` 흔적이 0건인 상태까지 정리했다.
## 2026-04-02 v1.4.30
- `ensureData()` 단계에서 저장된 티어표 `pool_json`과 템플릿 요청 스냅샷(`items_json`, `board_items_json`) 안에 남아 있을 수 있는 `origin: 'game'` 값을 자동으로 `template`로 정리하도록 보강했다.
- 로컬 MariaDB를 비운 뒤 현재 스키마로 다시 올리는 검증도 함께 진행했고, 새 DB 기준으로 `topics=3`, `tierlists=0`, `legacyTierItems=0`, `legacyRequestItems=0` 상태까지 확인했다.
- 즉 현재 개발 환경에서는 새로 생성되거나 다시 초기화한 데이터에 `game` 기반 origin이 남지 않으며, 남은 `game` 코드는 레거시 redirect·DB 마이그레이션 감지·과거 데이터 호환층만 담당하게 됐다.
## 2026-04-02 v1.4.29
- 티어표 저장/request schema는 이제 새 데이터에서 `origin: 'template'`를 기본으로 쓰고, 예전 `origin: 'game'`도 계속 읽을 수 있게 호환 레이어를 남겼다.
- 관리자 아이템 라이브러리의 템플릿 연결 정보도 `linkedTemplates` 기준으로 정리해, 내부 응답/프런트 상태에 남아 있던 `linkedGames` 흔적을 제거했다.
- 현재 `game` 검색에 남는 것은 레거시 주소 redirect, DB 마이그레이션용 legacy 테이블/컬럼명, 과거 저장 데이터 호환용 `origin: 'game'`처럼 의도적으로 남겨둔 층만 남도록 정리했다.
## 2026-04-02 v1.4.28
- 관리자 템플릿 상세 상태(`selectedTemplate.game`)와 관련 응답 키를 `template` 기준으로 정리해, 내부 코드 검색에서 남던 `game` 흔적을 더 줄였다.
- 관리자 기본 아이템 정렬/로딩 상태 이름도 `templateItem*`, `isTemplateLoading`, `templateVisibilitySaving` 기준으로 바꾸고, 새 템플릿 자산 업로드는 `topics` 디렉터리로 저장되게 맞췄다.
- 현재 코드 검색에서 남는 `game`는 주로 레거시 주소 redirect(`/games/:gameId`), DB 마이그레이션용 legacy 테이블/컬럼명, 기존 저장 데이터와 맞춘 `origin: 'game'` 값처럼 의도적으로 남겨둔 호환층만 남도록 정리했다.
## 2026-04-02 v1.4.27
- 관리자 내부 탭/라우트 이름도 `template-admin`, `adminTemplates`, `/admin/templates` 기준으로 더 정리해, 화면 상태값과 라우트 이름에 남아 있던 `game-admin`, `adminGames` 흔적을 줄였다.
- 더 이상 참조되지 않는 DB alias export(`listGames`, `createGame`, `favoriteGame` 등)와 `updateTemplateRequestTargetGame` 별칭도 제거해, 백엔드 모듈 표면에서 남아 있던 레거시 `game` 이름층을 더 걷어냈다.
- 커스텀 아이템 모달 내부 클래스명도 `createTemplateButton` 기준으로 정리해, 관리자 코드 검색에서 남는 `createGame` 흔적을 줄였다.
## 2026-04-02 v1.4.26
- 관리자 API 레거시 `/games` alias를 걷어내고 `POST /api/admin/templates`, `.../templates/:templateId/...`만 남기도록 정리했다. 관리자 템플릿 연결/가져오기 액션도 `link-template`, `create-template` path로 바꿨다.
- 백엔드 공개 주제 라우트도 이제 `/api/topics`만 마운트하고, 오래된 `/api/games` 경로는 제거했다. 관리자 화면 URL 역시 `/admin/games` 대신 `/admin/templates`를 기본 경로로 쓰고, 예전 주소는 redirect만 남겼다.
- 문서의 API/화면 매핑도 현재 구조 기준으로 갱신해, `games` 중심 설명 대신 `topics / templates` 기준으로 읽히게 맞췄다.
## 2026-04-02 v1.4.25
- 티어표와 템플릿 요청 응답에서 `gameId / gameName / sourceGameId / targetGameId` 호환 키를 실제로 제거하고, 프런트 화면도 `topicId / topicName / sourceTopicId / targetTopicId`만 읽도록 정리했다.
- 관리자 전체 티어표 관리와 템플릿 요청 관리, 나의 티어표/즐겨찾기/검색 결과 이동, 티어표 편집기 저장·요청 payload도 `topicId` 기준으로 맞춰, 화면과 요청 바디에서 보이는 `game` 흔적을 더 줄였다.
@@ -1063,6 +1111,28 @@
- **티어표 데이터 정규화**: 게임 이미지 경로가 절대 로컬 URL로 저장되지 않도록 저장/조회 시 `/uploads/...` 상대 경로로 정규화
- **프로젝트 점검 결과 문서화**: DB 구조, 화면-파일 매핑, 코딩 규칙, 기술 명세, 남은 위험 요소를 `docs/`에 신규 정리
## 2026-04-03 v1.4.37
- **썸네일 업로드 UX 보정**: 티어표 편집기 우측 `대표 썸네일` 프레임을 클릭/엔터/스페이스로 바로 파일 선택할 수 있게 바꾸고, 중복이던 `파일 업로드` 버튼은 제거
## 2026-04-03 v1.4.36
- **자기 티어표 복사 허용**: 기존에는 타인의 저장본만 복사할 수 있었지만, 이제는 본인 티어표도 저장본이면 복사해서 일부만 수정한 새 버전으로 다시 작업할 수 있게 변경
- **프리뷰 우측 레일 추가**: 공유 프리뷰 화면도 본 사이트 문법을 더 닮도록 우측에 300×600 광고 레일과 카피라이트를 붙이고, 모바일 폭에서는 자동으로 숨기도록 정리
## 2026-04-03 v1.4.35
- **에디터 아이템 검색 추가**: 미배치 아이템이 많아졌을 때 바로 찾을 수 있도록 사이드바에 `아이템 이름 검색` 입력과 `표시 개수 / 전체 개수`를 추가
- **검색 중 드래그 유지**: 아이템 풀 검색은 목록 순서를 바꾸지 않고 일치하지 않는 항목만 숨기는 방식으로 넣어, 검색 중에도 바로 드래그 배치할 수 있게 유지
- **공유 프리뷰 유입선 보강**: 공유 링크 프리뷰 좌상단에 `Tier Maker` 로고 링크를 추가해, 미리보기에서 메인 화면으로 자연스럽게 돌아올 수 있게 함
- **작성 시각 노출 축소**: 프리뷰와 이미지 저장 하단 메타 정보의 시간 표시를 제거하고 날짜까지만 남겨 개인 생활 패턴 노출을 줄임
- **업로드 추적 로그 보강**: 관리자 템플릿 기본 아이템 업로드는 프런트/백엔드 양쪽에서 파일 수·총 용량·응답 상태를 콘솔에 남기도록 해, 다중 업로드 실패 원인을 다음 재현 때 바로 좁힐 수 있게 보강
- **카피라이트 링크 변경**: 우측 레일 하단 카피라이트의 `zenn` 링크를 `https://x.com/zennbox`로 변경
## 2026-04-02 v1.4.34
- **라이트모드 팔레트 재정비**: 공통 라이트 테마 색상을 회색 위주에서 더 정돈된 청회색 계열로 다시 잡고, 셸/레일/메인/카드 표면 대비를 처음부터 재조정
- **공통 토큰 확장**: 강조색 강도, 강조 배경, 오버레이 스크림, 아바타 테두리, 즐겨찾기 버튼 상태색을 공통 변수로 분리해 화면별 하드코딩을 줄임
- **홈 카드 보정**: 주제 카드 즐겨찾기 버튼이 라이트모드에서 검은 플로팅 버튼처럼 뜨던 문제를 테마 변수 기반으로 수정
- **목록 카드 통일**: 주제 허브/나의 티어표/즐겨찾기/검색 결과 카드의 아바타 테두리를 공통 토큰으로 맞춰 라이트모드에서 카드 밀도가 덜 어색하게 보이도록 정리
- **전역 셸 보정**: 백엔드 점검 안내 버튼과 가이드 모달 오버레이도 라이트모드에 맞는 공통 색상 체계로 통일
## 2026-03-19 v0.1.2
- **로그인 UI 개선**: 로그인 카드 중앙 배치, 중복 타이틀 제거, 입력 overflow 수정, 엔터로 로그인/회원가입 제출
- **안내문 조건화**: “첫 회원가입 계정은 admin” 문구는 유저가 0명일 때만 표시(`/api/auth/meta`)

View File

@@ -22,7 +22,7 @@ const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const { toasts, dismissToast } = useToast()
const RIGHT_RAIL_COPYRIGHT_URL = 'https://zenn.town/@murabito'
const RIGHT_RAIL_COPYRIGHT_URL = 'https://x.com/zennbox'
const currentTopicId = computed(() => route.params.topicId || '')
const leftRailCollapsed = ref(false)
@@ -43,7 +43,9 @@ const authReady = computed(() => auth.hydrated)
const isAdmin = computed(() => authReady.value && !!auth.user?.isAdmin)
const isPreviewMode = computed(() => route.query.preview === '1')
const isAdminRoute = computed(() => String(route.name || '').startsWith('admin'))
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor'].includes(String(route.name || '')) || isAdminRoute.value)
const usesLocalRightRail = computed(
() => !isPreviewMode.value && (['editEditor', 'newEditor'].includes(String(route.name || '')) || isAdminRoute.value)
)
const isRightRailOverlay = computed(() => viewportWidth.value <= 1200)
const isMobileLayout = computed(() => viewportWidth.value <= 860)
const avatarUrl = computed(() => (auth.user?.avatarSrc ? toApiUrl(auth.user.avatarSrc) : ''))
@@ -76,7 +78,7 @@ const showRightRailAction = computed(() => false)
const showSettingsGuideButton = computed(() => route.name === 'profile')
const guideSteps = [
{
id: 'select-game',
id: 'select-topic',
title: '주제 또는 양식 선택',
summary: '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
description:
@@ -137,7 +139,7 @@ const isGuidePrevDisabled = computed(() => guideStepIndex.value <= 0)
const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.length - 1)
const isLightTheme = computed(() => themeMode.value === 'light')
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드'))
const showSettingsThemePanel = computed(() => false && route.name === 'profile')
const showSettingsThemePanel = computed(() => route.name === 'profile')
const showTopicViewToggle = computed(() => route.name === 'topicHub')
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
@@ -275,7 +277,7 @@ onMounted(async () => {
if (typeof window !== 'undefined') {
const savedTheme = window.localStorage.getItem('tier-maker:theme')
if (savedTheme === 'light' || savedTheme === 'dark') applyTheme(savedTheme)
else applyTheme(window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
else applyTheme('dark')
}
await auth.refresh()
if (typeof window !== 'undefined') {
@@ -424,7 +426,6 @@ function reloadApp() {
<div
class="appShell"
:class="{
'appShell--preview': isPreviewMode,
'appShell--leftCollapsed': leftRailCollapsed,
'appShell--rightClosed': !rightRailOpen,
'appShell--rightOverlay': isRightRailOverlay,
@@ -450,11 +451,6 @@ function reloadApp() {
</section>
</main>
</template>
<template v-else-if="isPreviewMode">
<main class="appMain appMain--preview">
<RouterView />
</main>
</template>
<template v-else>
<aside class="leftRail">
<div class="leftRail__top railHeader">
@@ -697,6 +693,7 @@ function reloadApp() {
}
.backendFallback {
min-width: 100dvw;
min-height: 100dvh;
display: grid;
place-items: center;
@@ -748,17 +745,13 @@ function reloadApp() {
min-width: 128px;
padding: 12px 18px;
border-radius: 999px;
border: 1px solid rgba(98, 170, 255, 0.32);
background: rgba(98, 170, 255, 0.18);
border: 1px solid var(--theme-accent-soft-strong);
background: var(--theme-accent-soft);
color: var(--theme-text-strong);
font-weight: 700;
cursor: pointer;
}
.appShell--preview {
display: block;
}
.leftRail,
.rightRail {
min-height: 100dvh;
@@ -929,7 +922,7 @@ function reloadApp() {
border-radius: 999px;
object-fit: cover;
flex: 0 0 auto;
border: 1px solid rgba(255, 255, 255, 0.14);
border: 1px solid var(--theme-avatar-border);
background: var(--theme-surface-soft-3);
}
@@ -1220,10 +1213,6 @@ function reloadApp() {
border-right: 1px solid var(--theme-border);
}
.appMain--preview {
padding: 0;
}
.workspace {
display: grid;
grid-template-rows: 56px minmax(0, 1fr);
@@ -1453,7 +1442,7 @@ function reloadApp() {
align-items: center;
justify-content: center;
padding: 32px 20px;
background: rgba(0, 0, 0, 0.62);
background: var(--theme-overlay-scrim);
backdrop-filter: blur(10px);
}

View File

@@ -67,6 +67,7 @@ onMounted(async () => {
.rightRailAd {
display: grid;
gap: 12px;
padding-top: 78px;
}
.rightRailAd__eyebrow {

View File

@@ -9,12 +9,12 @@ const props = defineProps({
stagedRequestDraftCount: { type: Number, required: true },
appliedRequestItemCount: { type: Number, required: true },
openTemplateCreateModal: { type: Function, required: true },
isGameLoading: { type: Boolean, required: true },
isTemplateLoading: { type: Boolean, required: true },
hasSelectedTemplate: { type: Boolean, required: true },
selectedTemplate: { type: Object, default: null },
displayThumbnailUrl: { type: String, default: '' },
canApplyThumbnail: { type: Boolean, required: true },
gameVisibilitySaving: { type: Boolean, required: true },
templateVisibilitySaving: { type: Boolean, required: true },
thumbFileInputRef: { type: Function, required: true },
openThumbFilePicker: { type: Function, required: true },
onThumb: { type: Function, required: true },
@@ -41,14 +41,14 @@ const props = defineProps({
removeUploadDraft: { type: Function, required: true },
hasTemplateItemOrderChanges: { type: Boolean, required: true },
saveTemplateItemOrder: { type: Function, required: true },
gameItemListRef: { type: Function, required: true },
templateItemListRef: { type: Function, required: true },
saveTemplateItemLabel: { type: Function, required: true },
removeTemplateItem: { type: Function, required: true },
selectedTemplateId: { type: String, default: '' },
})
function setGameItemListElement(el) {
props.gameItemListRef(el)
function setTemplateItemListElement(el) {
props.templateItemListRef(el)
}
function setThumbFileElement(el) {
@@ -102,15 +102,15 @@ function setThumbFileElement(el) {
</div>
</div>
<div v-if="props.isGameLoading" class="panel panel--empty">
<div v-if="props.isTemplateLoading" class="panel panel--empty">
<div class="emptyState">
<div class="emptyState__title">템플릿 정보를 불러오는 중이에요.</div>
<div class="emptyState__desc">선택한 템플릿의 썸네일과 기본 아이템을 표시합니다.</div>
</div>
</div>
<div v-else-if="props.hasSelectedTemplate" class="panel">
<section class="adminCard gameSettingsCard">
<div class="gameSettingsCard__media">
<section class="adminCard templateSettingsCard">
<div class="templateSettingsCard__media">
<input :ref="setThumbFileElement" type="file" accept="image/*" class="srOnlyInput" @change="props.onThumb" />
<button
class="thumbDropZone"
@@ -122,25 +122,22 @@ function setThumbFileElement(el) {
@dragleave="props.onThumbDragLeave"
@drop="props.onThumbDrop"
>
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedTemplate.game.name" />
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedTemplate.template.name" />
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
<div class="thumbDropZone__copy">
<div v-if="!props.displayThumbnailUrl" class="thumbDropZone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="thumbDropZone__icon" />
</div>
<div class="thumbDropZone__title">{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
</div>
</button>
</div>
<div class="gameSettingsCard__body">
<div class="templateSettingsCard__body">
<div class="panel__title">템플릿 설정</div>
<div class="gameSettingsCard__meta">{{ props.selectedTemplate.game.name }} · {{ props.selectedTemplate.game.id }}</div>
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.gameVisibilitySaving }">
<input :checked="!!props.selectedTemplate.game.isPublic" type="checkbox" @change="props.toggleSelectedTemplateVisibility($event.target.checked)" />
<span class="toggleSwitch__label">{{ props.selectedTemplate.game.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
<div class="templateSettingsCard__meta">{{ props.selectedTemplate.template.name }} · {{ props.selectedTemplate.template.id }}</div>
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.templateVisibilitySaving }">
<input :checked="!!props.selectedTemplate.template.isPublic" type="checkbox" @change="props.toggleSelectedTemplateVisibility($event.target.checked)" />
<span class="toggleSwitch__label">{{ props.selectedTemplate.template.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<div class="gameSettingsCard__actions">
<div class="templateSettingsCard__actions">
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
<button class="btn btn--danger" @click="props.removeTemplate">템플릿 삭제</button>
</div>
@@ -215,9 +212,9 @@ function setThumbFileElement(el) {
<button class="btn btn--primary btn--small" :disabled="!props.hasTemplateItemOrderChanges" @click="props.saveTemplateItemOrder">순서 저장</button>
</div>
<div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
<div v-else :ref="setGameItemListElement" class="thumbGrid">
<div v-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-game-item-id="item.id">
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
<div v-else :ref="setTemplateItemListElement" class="thumbGrid">
<div v-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-template-item-id="item.id">
<img class="thumb thumb--template" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />
<div class="thumbCard__actions">
<button

View File

@@ -15,6 +15,10 @@ const props = defineProps({
userDisplayName: { type: Function, required: true },
userAvatarFallback: { type: Function, required: true },
removeUserAvatar: { type: Function, required: true },
canEditUserAvatar: { type: Function, required: true },
canEditUserInfo: { type: Function, required: true },
canResetUserPassword: { type: Function, required: true },
canDeleteUser: { type: Function, required: true },
roleLabelOf: { type: Function, required: true },
fmt: { type: Function, required: true },
openUserPasswordModal: { type: Function, required: true },
@@ -76,7 +80,12 @@ const userSortDirectionModel = computed({
@change="props.onUserAvatarChange(user, $event)"
/>
<div class="userAvatarWrap">
<button class="userAvatar userAvatarButton" type="button" :disabled="user.isAvatarBusy" @click="props.openUserAvatarPicker(user)">
<button
class="userAvatar userAvatarButton"
type="button"
:disabled="user.isAvatarBusy || !props.canEditUserAvatar(user)"
@click="props.openUserAvatarPicker(user)"
>
<img v-if="props.userAvatarUrl(user)" class="userAvatar__image" :src="props.userAvatarUrl(user)" :alt="props.userDisplayName(user)" />
<span v-else class="userAvatar__fallback">{{ props.userAvatarFallback(user) }}</span>
<span class="userAvatarButton__overlay">{{ user.isAvatarBusy ? '업데이트중...' : '수정' }}</span>
@@ -86,7 +95,7 @@ const userSortDirectionModel = computed({
class="userAvatarRemoveButton"
type="button"
title="회원 썸네일 삭제"
:disabled="user.isAvatarBusy"
:disabled="user.isAvatarBusy || !props.canEditUserAvatar(user)"
@click.stop="props.removeUserAvatar(user)"
>
<SvgIcon class="userAvatarRemoveIcon" :src="props.deleteIcon" :size="12" />
@@ -111,13 +120,32 @@ const userSortDirectionModel = computed({
</div>
<div class="userCard__actions userCard__actions--compact">
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="props.openUserPasswordModal(user)">
<button
class="iconActionButton"
type="button"
title="비밀번호 초기화"
:disabled="!props.canResetUserPassword(user)"
@click="props.openUserPasswordModal(user)"
>
<SvgIcon class="iconActionButton__icon" :src="props.lockResetIcon" :size="18" />
</button>
<button class="iconActionButton iconActionButton--danger" type="button" title="회원 삭제" @click="props.openUserDeleteModal(user)">
<button
class="iconActionButton iconActionButton--danger"
type="button"
title="회원 삭제"
:disabled="!props.canDeleteUser(user)"
@click="props.openUserDeleteModal(user)"
>
<SvgIcon class="iconActionButton__icon" :src="props.deleteIcon" :size="18" />
</button>
<button class="btn btn--ghost userSaveButton" type="button" @click="props.openUserEditModal(user)">회원 정보 수정</button>
<button
class="btn btn--ghost userSaveButton"
type="button"
:disabled="!props.canEditUserInfo(user)"
@click="props.openUserEditModal(user)"
>
회원 정보 수정
</button>
</div>
</article>
</div>

View File

@@ -85,7 +85,7 @@ export function useAdminCustomItems({
function openCustomItemDeleteModal(item) {
if (!item) return
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) {
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return
}
@@ -100,7 +100,7 @@ export function useAdminCustomItems({
function jumpToTemplateAdmin(templateId) {
if (!templateId) return
closeCustomItemModal()
setTab('game-admin')
setTab('template-admin')
nextTick(() => {
selectAdminTemplate(templateId)
})
@@ -109,7 +109,7 @@ export function useAdminCustomItems({
async function removeCustomItem(item = modalTargetCustomItem.value) {
resetMessages()
if (!item) return
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) {
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return
}

View File

@@ -1,7 +1,7 @@
import { nextTick } from 'vue'
import Sortable from 'sortablejs'
export function useAdminFeaturedGames({
export function useAdminFeaturedTemplates({
api,
featuredListEl,
featuredSortable,

View File

@@ -1,7 +1,7 @@
import { nextTick } from 'vue'
import Sortable from 'sortablejs'
export function useAdminGameManager({
export function useAdminTemplateManager({
api,
toApiUrl,
selectedTemplateId,
@@ -11,10 +11,10 @@ export function useAdminGameManager({
thumbFile,
itemPreviewUrls,
itemFileInput,
gameItemListEl,
gameItemSortable,
savedGameItemOrderIds,
isGameLoading,
templateItemListEl,
templateItemSortable,
savedTemplateItemOrderIds,
isTemplateLoading,
activeTemplateRequest,
templateRequests,
customItemModalOpen,
@@ -49,21 +49,21 @@ export function useAdminGameManager({
return src.split('/').pop() || item.file?.name || 'item'
}
function destroyGameItemSortable() {
if (gameItemSortable.value) {
gameItemSortable.value.destroy()
gameItemSortable.value = null
function destroyTemplateItemSortable() {
if (templateItemSortable.value) {
templateItemSortable.value.destroy()
templateItemSortable.value = null
}
}
async function syncGameItemSortable() {
async function syncTemplateItemSortable() {
await nextTick()
destroyGameItemSortable()
if (!gameItemListEl.value || !selectedTemplate.value?.items?.length) return
destroyTemplateItemSortable()
if (!templateItemListEl.value || !selectedTemplate.value?.items?.length) return
gameItemSortable.value = Sortable.create(gameItemListEl.value, {
templateItemSortable.value = Sortable.create(templateItemListEl.value, {
animation: 160,
draggable: '[data-game-item-id]',
draggable: '[data-template-item-id]',
forceFallback: true,
fallbackOnBody: false,
filter: '[data-no-drag]',
@@ -124,31 +124,30 @@ export function useAdminGameManager({
if (!selectedTemplateId.value) {
selectedTemplate.value = null
savedGameItemOrderIds.value = []
destroyGameItemSortable()
savedTemplateItemOrderIds.value = []
destroyTemplateItemSortable()
return
}
try {
isGameLoading.value = true
isTemplateLoading.value = true
const data = await api.getTopic(selectedTemplateId.value)
const loadedTemplate = data.template || data.topic || null
selectedTemplate.value = {
...data,
game: loadedTemplate,
template: loadedTemplate,
items: (data.items || []).map((item) => ({
...item,
draftLabel: item.label,
})),
}
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
await syncGameItemSortable()
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
await syncTemplateItemSortable()
} catch (e) {
selectedTemplate.value = null
error.value = '템플릿 정보를 불러오지 못했어요.'
} finally {
isGameLoading.value = false
isTemplateLoading.value = false
}
}
@@ -174,7 +173,7 @@ export function useAdminGameManager({
const data = await res.json()
const createdTemplate = data.template || {}
if (activeTemplateRequest.value?.type === 'create' && activeTemplateRequest.value?.id) {
const linkData = await api.linkAdminTemplateRequestGame(activeTemplateRequest.value.id, {
const linkData = await api.linkAdminTemplateRequestTemplate(activeTemplateRequest.value.id, {
topicId: createdTemplate.id,
})
activeTemplateRequest.value = {
@@ -279,9 +278,16 @@ export function useAdminGameManager({
const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file')
const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request')
const totalUploadBytes = fileDrafts.reduce((sum, entry) => sum + Number(entry.file?.size || 0), 0)
let uploadCount = 0
if (fileDrafts.length) {
console.info('[admin] template item upload start', {
topicId: selectedTemplateId.value,
fileCount: fileDrafts.length,
totalBytes: totalUploadBytes,
labels: fileDrafts.map((entry) => entry.label.trim()),
})
const fd = new FormData()
fileDrafts.forEach((entry) => {
fd.append('images', entry.file)
@@ -292,7 +298,25 @@ export function useAdminGameManager({
credentials: 'include',
body: fd,
})
if (!res.ok) throw new Error('failed')
if (!res.ok) {
const responseText = await res.text().catch(() => '')
console.error('[admin] template item upload failed', {
topicId: selectedTemplateId.value,
fileCount: fileDrafts.length,
totalBytes: totalUploadBytes,
status: res.status,
body: responseText,
})
const uploadError = new Error('failed')
uploadError.status = res.status
uploadError.body = responseText
throw uploadError
}
console.info('[admin] template item upload success', {
topicId: selectedTemplateId.value,
fileCount: fileDrafts.length,
totalBytes: totalUploadBytes,
})
uploadCount += fileDrafts.length
}
@@ -317,6 +341,12 @@ export function useAdminGameManager({
await loadTemplate()
success.value = `템플릿 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
} catch (e) {
console.error('[admin] uploadItem error', {
message: e?.message || '',
status: e?.status || 0,
body: e?.body || '',
data: e?.data || null,
})
const apiError = e?.data?.error || ''
if (apiError === 'no_items_selected') {
error.value = '추가할 요청 아이템이 없어요.'
@@ -331,6 +361,10 @@ export function useAdminGameManager({
error.value = '선택한 템플릿을 찾지 못했어요.'
return
}
if (e?.status === 413) {
error.value = '한 번에 업로드한 파일 용량이 너무 커서 실패했어요.'
return
}
error.value = '아이템 추가에 실패했어요.'
}
}
@@ -350,8 +384,8 @@ export function useAdminGameManager({
draftLabel: item.label,
})),
}
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
await syncGameItemSortable()
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
await syncTemplateItemSortable()
success.value = '기본 아이템 순서를 저장했어요.'
} catch (e) {
error.value = '기본 아이템 순서 저장에 실패했어요.'
@@ -360,8 +394,8 @@ export function useAdminGameManager({
return {
requestItemFilename,
destroyGameItemSortable,
syncGameItemSortable,
destroyTemplateItemSortable,
syncTemplateItemSortable,
mergeRequestItemsIntoDrafts,
removeUploadDraft,
loadTemplate,

View File

@@ -62,7 +62,7 @@ export function useAdminTemplateRequests({
Object.assign(request, syncedRequest)
request.status = syncedRequest.status || 'reviewing'
updateActiveTemplateRequest(syncedRequest)
setTab('game-admin')
setTab('template-admin')
if (request.type === 'create') {
const linkedTopicId = syncedRequest.targetTopicId || ''

View File

@@ -38,6 +38,29 @@ export function useAdminUsers({
return !modalTargetUser.value.isPrimaryAdmin
})
function canManageUser(user) {
if (!user?.isPrimaryAdmin) return true
return !!auth.user?.isPrimaryAdmin
}
function canEditUserAvatar(user) {
return canManageUser(user)
}
function canEditUserInfo(user) {
return canManageUser(user)
}
function canResetUserPassword(user) {
return canManageUser(user)
}
function canDeleteUser(user) {
if (!canManageUser(user)) return false
if (user?.isAdmin && !auth.user?.isPrimaryAdmin) return false
return user?.id !== auth.user?.id
}
const isUserEditDirty = computed(() => {
if (!modalTargetUser.value) return false
return (
@@ -54,6 +77,7 @@ export function useAdminUsers({
}
function openUserAvatarPicker(user) {
if (!canEditUserAvatar(user)) return
userAvatarInputs.value[user?.id]?.click()
}
@@ -96,11 +120,13 @@ export function useAdminUsers({
}
async function removeUserAvatar(user) {
if (!canEditUserAvatar(user)) return
if (!user?.avatarSrc) return
await uploadUserAvatar(user, null, { remove: true })
}
function openUserEditModal(user) {
if (!canEditUserInfo(user)) return
resetMessages()
modalTargetUser.value = user ? { ...user } : null
modalUserDraftEmail.value = user?.email || ''
@@ -147,6 +173,7 @@ export function useAdminUsers({
}
function openUserPasswordModal(user) {
if (!canResetUserPassword(user)) return
resetMessages()
modalTargetUser.value = user ? { ...user } : null
modalPasswordDraft.value = ''
@@ -183,6 +210,7 @@ export function useAdminUsers({
}
function openUserDeleteModal(user) {
if (!canDeleteUser(user)) return
resetMessages()
modalTargetUser.value = user ? { ...user } : null
userDeleteModalOpen.value = true
@@ -242,6 +270,10 @@ export function useAdminUsers({
return {
setUserAvatarInput,
canManageModalRole,
canEditUserAvatar,
canEditUserInfo,
canResetUserPassword,
canDeleteUser,
isUserEditDirty,
roleLabelOf,
openUserAvatarPicker,

View File

@@ -55,7 +55,7 @@ async function request(path, { method = 'GET', body, headers } = {}) {
export const api = {
me: () => request('/api/auth/me'),
authMeta: () => request('/api/auth/meta'),
signup: ({ email, password }) => request('/api/auth/signup', { method: 'POST', body: { email, password } }),
signup: ({ email, nickname, password }) => request('/api/auth/signup', { method: 'POST', body: { email, nickname, password } }),
login: ({ email, password }) => request('/api/auth/login', { method: 'POST', body: { email, password } }),
logout: () => request('/api/auth/logout', { method: 'POST' }),
@@ -100,11 +100,11 @@ export const api = {
promoteAdminTierListItems: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
createAdminTemplateFromTierList: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-template`, { method: 'POST', body: payload }),
startAdminTemplateRequestReview: (requestId) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/review`, { method: 'POST', body: {} }),
linkAdminTemplateRequestGame: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/link-game`, { method: 'POST', body: payload }),
linkAdminTemplateRequestTemplate: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/link-template`, { method: 'POST', body: payload }),
promoteAdminTemplateRequestItems: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/promote-items`, { method: 'POST', body: payload }),
completeAdminTemplateRequest: (requestId) =>

View File

@@ -1,7 +1,7 @@
import { createRouter as _createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import GameHubView from '../views/GameHubView.vue'
import TopicHubView from '../views/TopicHubView.vue'
import TierEditorView from '../views/TierEditorView.vue'
import LoginView from '../views/LoginView.vue'
import MyTierListsView from '../views/MyTierListsView.vue'
@@ -16,8 +16,7 @@ export function createRouter() {
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: HomeView },
{ path: '/games/:gameId', redirect: (to) => `/topics/${encodeURIComponent(String(to.params.gameId || ''))}` },
{ path: '/topics/:topicId', name: 'topicHub', component: GameHubView },
{ path: '/topics/:topicId', name: 'topicHub', component: TopicHubView },
{ path: '/editor/:topicId/new', name: 'newEditor', component: TierEditorView },
{ path: '/editor/:topicId/:tierListId', name: 'editEditor', component: TierEditorView },
{ path: '/login', name: 'login', component: LoginView },
@@ -26,7 +25,7 @@ export function createRouter() {
{ path: '/search', name: 'search', component: SearchResultsView },
{ path: '/admin', redirect: '/admin/featured' },
{ path: '/admin/featured', name: 'adminFeatured', component: AdminView },
{ path: '/admin/games', name: 'adminGames', component: AdminView },
{ path: '/admin/templates', name: 'adminTemplates', component: AdminView },
{ path: '/admin/items', name: 'adminItems', component: AdminView },
{ path: '/admin/tierlists', name: 'adminTierlists', component: AdminView },
{ path: '/admin/users', name: 'adminUsers', component: AdminView },

View File

@@ -29,8 +29,8 @@ export const useAuthStore = defineStore('auth', {
})()
return refreshPromise
},
async signup(email, password) {
const user = await api.signup({ email, password })
async signup(email, nickname, password) {
const user = await api.signup({ email, nickname, password })
this.user = user
this.hydrated = true
return user

View File

@@ -33,37 +33,59 @@
--theme-danger-bg: rgba(239, 68, 68, 0.1);
--theme-danger-border: rgba(239, 68, 68, 0.18);
--theme-accent-bg: rgba(76, 133, 245, 0.92);
--theme-accent-strong: rgba(137, 183, 255, 0.96);
--theme-accent-soft: rgba(76, 133, 245, 0.18);
--theme-accent-soft-strong: rgba(76, 133, 245, 0.3);
--theme-accent-text: #fff;
--theme-overlay-scrim: rgba(0, 0, 0, 0.62);
--theme-avatar-border: rgba(255, 255, 255, 0.14);
--theme-favorite-bg: rgba(12, 14, 18, 0.72);
--theme-favorite-border: rgba(255, 255, 255, 0.14);
--theme-favorite-icon: rgba(255, 255, 255, 0.94);
--theme-favorite-active-bg: rgba(54, 45, 10, 0.92);
--theme-favorite-active-border: rgba(255, 216, 107, 0.28);
--theme-favorite-active-icon: #ffd86b;
--theme-icon-filter: brightness(0) saturate(100%) invert(94%) sepia(6%) saturate(207%) hue-rotate(186deg) brightness(96%) contrast(92%);
}
:root[data-theme='light'] {
--theme-body-bg: #e7ebf2;
--theme-shell-bg: rgba(237, 241, 247, 0.98);
--theme-rail-bg: rgba(243, 246, 251, 0.97);
--theme-main-bg: rgba(232, 236, 243, 0.98);
--theme-workspace-bg: rgba(247, 249, 252, 0.96);
--theme-card-bg: rgba(252, 253, 255, 0.98);
--theme-card-bg-hover: rgba(244, 247, 251, 0.98);
--theme-card-border: rgba(31, 41, 55, 0.11);
--theme-card-shadow: 0 18px 34px rgba(31, 41, 55, 0.07);
--theme-surface-soft: rgba(30, 41, 59, 0.055);
--theme-surface-soft-2: rgba(30, 41, 59, 0.075);
--theme-surface-soft-3: rgba(30, 41, 59, 0.105);
--theme-pill-bg: rgba(30, 41, 59, 0.045);
--theme-border: rgba(30, 41, 59, 0.11);
--theme-border-strong: rgba(30, 41, 59, 0.16);
--theme-text: rgba(20, 27, 40, 0.92);
--theme-text-strong: rgba(10, 15, 28, 0.98);
--theme-text-muted: rgba(55, 65, 81, 0.76);
--theme-text-soft: rgba(75, 85, 99, 0.72);
--theme-text-faint: rgba(100, 116, 139, 0.88);
--theme-thumb-fallback-bg: #f6f8fb;
--theme-select-arrow: rgba(55, 65, 81, 0.74);
--theme-body-bg: #eef2f8;
--theme-shell-bg: rgba(241, 245, 251, 0.98);
--theme-rail-bg: rgba(248, 250, 253, 0.98);
--theme-main-bg: rgba(234, 239, 247, 0.98);
--theme-workspace-bg: rgba(250, 252, 255, 0.97);
--theme-card-bg: rgba(255, 255, 255, 0.96);
--theme-card-bg-hover: rgba(246, 249, 253, 0.98);
--theme-card-border: rgba(71, 85, 105, 0.12);
--theme-card-shadow: 0 14px 30px rgba(57, 72, 92, 0.08);
--theme-surface-soft: rgba(75, 85, 99, 0.052);
--theme-surface-soft-2: rgba(75, 85, 99, 0.078);
--theme-surface-soft-3: rgba(75, 85, 99, 0.11);
--theme-pill-bg: rgba(75, 85, 99, 0.048);
--theme-border: rgba(71, 85, 105, 0.12);
--theme-border-strong: rgba(71, 85, 105, 0.18);
--theme-text: rgba(24, 33, 48, 0.93);
--theme-text-strong: rgba(11, 18, 32, 0.98);
--theme-text-muted: rgba(51, 65, 85, 0.78);
--theme-text-soft: rgba(71, 85, 105, 0.76);
--theme-text-faint: rgba(100, 116, 139, 0.9);
--theme-thumb-fallback-bg: #f3f6fb;
--theme-select-arrow: rgba(51, 65, 85, 0.72);
--theme-danger-bg: rgba(239, 68, 68, 0.1);
--theme-danger-border: rgba(239, 68, 68, 0.22);
--theme-accent-bg: rgba(64, 110, 226, 0.94);
--theme-accent-bg: rgba(56, 105, 226, 0.94);
--theme-accent-strong: rgba(47, 87, 194, 0.96);
--theme-accent-soft: rgba(56, 105, 226, 0.12);
--theme-accent-soft-strong: rgba(56, 105, 226, 0.22);
--theme-accent-text: #fff;
--theme-overlay-scrim: rgba(17, 24, 39, 0.28);
--theme-avatar-border: rgba(71, 85, 105, 0.16);
--theme-favorite-bg: rgba(255, 255, 255, 0.9);
--theme-favorite-border: rgba(71, 85, 105, 0.16);
--theme-favorite-icon: rgba(51, 65, 85, 0.92);
--theme-favorite-active-bg: rgba(255, 243, 199, 0.96);
--theme-favorite-active-border: rgba(217, 119, 6, 0.22);
--theme-favorite-active-icon: #b45309;
--theme-icon-filter: brightness(0) saturate(100%) invert(14%) sepia(14%) saturate(652%) hue-rotate(182deg) brightness(95%) contrast(91%);
}

View File

@@ -8,13 +8,13 @@ import lockResetIcon from '../assets/icons/lock_reset.svg'
import deleteIcon from '../assets/icons/delete.svg'
import SvgIcon from '../components/SvgIcon.vue'
import AdminFeaturedSection from '../components/admin/AdminFeaturedSection.vue'
import AdminGamesSection from '../components/admin/AdminGamesSection.vue'
import AdminTemplatesSection from '../components/admin/AdminTemplatesSection.vue'
import AdminItemsSection from '../components/admin/AdminItemsSection.vue'
import AdminTierlistsSection from '../components/admin/AdminTierlistsSection.vue'
import AdminUsersSection from '../components/admin/AdminUsersSection.vue'
import { useAdminCustomItems } from '../composables/useAdminCustomItems'
import { useAdminFeaturedGames } from '../composables/useAdminFeaturedGames'
import { useAdminGameManager } from '../composables/useAdminGameManager'
import { useAdminFeaturedTemplates } from '../composables/useAdminFeaturedTemplates'
import { useAdminTemplateManager } from '../composables/useAdminTemplateManager'
import { useAdminTemplateRequests } from '../composables/useAdminTemplateRequests'
import { useAdminUsers } from '../composables/useAdminUsers'
import { useAuthStore } from '../stores/auth'
@@ -36,7 +36,7 @@ const selectedTemplateId = ref('')
const selectedTemplate = ref(null)
const featuredTemplateIds = ref([])
const templatePickerModalOpen = ref(false)
const templatePickerMode = ref('game-admin')
const templatePickerMode = ref('template-admin')
const templatePickerQuery = ref('')
const templatePickerSort = ref('recent')
@@ -50,7 +50,7 @@ const customItemModalTargetTemplateId = ref('')
const adminTierLists = ref([])
const adminTierListQuery = ref('')
const adminTierListGameId = ref('')
const adminTierListTopicId = ref('')
const adminTierListPage = ref(1)
const adminTierListLimit = ref(50)
const adminTierListTotal = ref(0)
@@ -109,7 +109,7 @@ const success = ref('')
const newTemplateId = ref('')
const newTemplateName = ref('')
const newTemplateIsPublic = ref(false)
const gameVisibilitySaving = ref(false)
const templateVisibilitySaving = ref(false)
const uploadFiles = ref([])
const uploadItemDrafts = ref([])
@@ -122,12 +122,12 @@ const itemFileInput = ref(null)
const thumbFileInput = ref(null)
const featuredListEl = ref(null)
const featuredSortable = ref(null)
const gameItemListEl = ref(null)
const gameItemSortable = ref(null)
let gameItemSortableSyncTimer = null
const savedGameItemOrderIds = ref([])
const templateItemListEl = ref(null)
const templateItemSortable = ref(null)
let templateItemSortableSyncTimer = null
const savedTemplateItemOrderIds = ref([])
const userAvatarInputs = ref({})
const isGameLoading = ref(false)
const isTemplateLoading = ref(false)
const templateCreateModalOpen = ref(false)
const previousBodyOverflow = ref('')
@@ -143,23 +143,23 @@ function setThumbFileInputRef(el) {
thumbFileInput.value = el
}
function scheduleGameItemSortableSync() {
if (gameItemSortableSyncTimer) {
clearTimeout(gameItemSortableSyncTimer)
gameItemSortableSyncTimer = null
function scheduleTemplateItemSortableSync() {
if (templateItemSortableSyncTimer) {
clearTimeout(templateItemSortableSyncTimer)
templateItemSortableSyncTimer = null
}
if (!gameItemListEl.value || !selectedTemplate.value?.items?.length) return
if (!templateItemListEl.value || !selectedTemplate.value?.items?.length) return
gameItemSortableSyncTimer = setTimeout(() => {
gameItemSortableSyncTimer = null
syncGameItemSortable()
templateItemSortableSyncTimer = setTimeout(() => {
templateItemSortableSyncTimer = null
syncTemplateItemSortable()
}, 0)
}
function setGameItemListRef(el) {
gameItemListEl.value = el
function setTemplateItemListRef(el) {
templateItemListEl.value = el
if (!el) return
scheduleGameItemSortableSync()
scheduleTemplateItemSortableSync()
}
function normalizeAdminSrc(src) {
@@ -175,7 +175,7 @@ function normalizeAdminSrc(src) {
}
}
const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.game?.id)
const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.template?.id)
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value)
const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedTemplateId.value)
const stagedRequestDraftCount = computed(() => uploadItemDrafts.value.filter((item) => item.kind === 'request').length)
@@ -188,7 +188,7 @@ const appliedRequestItemCount = computed(() => {
})
const hasTemplateItemOrderChanges = computed(() => {
const currentIds = (selectedTemplate.value?.items || []).map((item) => item.id)
return currentIds.join('|') !== savedGameItemOrderIds.value.join('|')
return currentIds.join('|') !== savedTemplateItemOrderIds.value.join('|')
})
const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value)))
const adminTierListPageCount = computed(() => Math.max(1, Math.ceil(adminTierListTotal.value / adminTierListLimit.value)))
@@ -215,7 +215,7 @@ const customItemTargetTemplate = computed(() => templates.value.find((template)
const importModalItemCount = computed(() => importModalItems.value.length)
const activeTabTitle = computed(() => {
if (activeTab.value === 'featured') return '목록 관리'
if (activeTab.value === 'game-admin') return '템플릿 관리'
if (activeTab.value === 'template-admin') return '템플릿 관리'
if (activeTab.value === 'items') return '아이템 관리'
if (activeTab.value === 'tierlists') {
return tierlistsMode.value === 'requests' ? '템플릿 요청 관리' : '전체 티어표 관리'
@@ -226,7 +226,7 @@ const activeTabDescription = computed(() => {
if (activeTab.value === 'featured') {
return '홈 화면 상단에 고정 노출되는 템플릿 순서를 따로 관리합니다.'
}
if (activeTab.value === 'game-admin') {
if (activeTab.value === 'template-admin') {
return '템플릿 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.'
}
if (activeTab.value === 'items') {
@@ -251,7 +251,7 @@ const adminOverviewStats = computed(() => {
{ label: '추가 가능', value: `${Math.max(0, 50 - featuredTemplateIds.value.length)}` },
]
}
if (activeTab.value === 'game-admin') {
if (activeTab.value === 'template-admin') {
return [
{ label: '전체 템플릿', value: `${templates.value.length}` },
{ label: '티어표 전체', value: `${selectedTemplateTierListStats.value.total || 0}` },
@@ -305,14 +305,14 @@ const isAnyModalOpen = computed(
)
const adminRouteNameByTab = {
featured: 'adminFeatured',
'game-admin': 'adminGames',
'template-admin': 'adminTemplates',
items: 'adminItems',
tierlists: 'adminTierlists',
users: 'adminUsers',
}
function tabFromAdminRoute(name) {
if (name === 'adminGames') return 'game-admin'
if (name === 'adminTemplates') return 'template-admin'
if (name === 'adminItems') return 'items'
if (name === 'adminTierlists') return 'tierlists'
if (name === 'adminUsers') return 'users'
@@ -375,12 +375,12 @@ onUnmounted(() => {
if (typeof document !== 'undefined') document.body.style.overflow = previousBodyOverflow.value || ''
clearPreviewUrl('item')
clearPreviewUrl('thumb')
if (gameItemSortableSyncTimer) {
clearTimeout(gameItemSortableSyncTimer)
gameItemSortableSyncTimer = null
if (templateItemSortableSyncTimer) {
clearTimeout(templateItemSortableSyncTimer)
templateItemSortableSyncTimer = null
}
destroyFeaturedSortable()
destroyGameItemSortable()
destroyTemplateItemSortable()
})
function clearPreviewUrl(kind) {
@@ -423,7 +423,7 @@ watch(
() => route.name,
(name) => {
activeTab.value = tabFromAdminRoute(name)
if (name === 'adminGames') {
if (name === 'adminTemplates') {
const nextTopicId = typeof route.query.topicId === 'string' ? route.query.topicId : ''
if (nextTopicId && nextTopicId !== selectedTemplateId.value) {
selectedTemplateId.value = nextTopicId
@@ -437,7 +437,7 @@ watch(
const nextMode = route.query.mode === 'all' ? 'all' : 'requests'
if (tierlistsMode.value !== nextMode) tierlistsMode.value = nextMode
const nextTierListTopicId = typeof route.query.topicId === 'string' ? route.query.topicId : ''
if (adminTierListGameId.value !== nextTierListTopicId) adminTierListGameId.value = nextTierListTopicId
if (adminTierListTopicId.value !== nextTierListTopicId) adminTierListTopicId.value = nextTierListTopicId
}
},
{ immediate: true }
@@ -446,13 +446,13 @@ watch(
watch(
() => selectedTemplateId.value,
(templateId) => {
if (route.name !== 'adminGames') return
if (route.name !== 'adminTemplates') return
syncAdminRouteQuery({ topicId: templateId || undefined })
}
)
watch(
() => selectedTemplate.value?.game?.id || '',
() => selectedTemplate.value?.template?.id || '',
async (templateId) => {
await refreshSelectedTemplateTierListStats(templateId)
},
@@ -465,13 +465,13 @@ watch(
if (route.name !== 'adminTierlists') return
syncAdminRouteQuery({
mode: mode === 'all' ? 'all' : undefined,
topicId: mode === 'all' && adminTierListGameId.value ? adminTierListGameId.value : undefined,
topicId: mode === 'all' && adminTierListTopicId.value ? adminTierListTopicId.value : undefined,
})
}
)
watch(
() => adminTierListGameId.value,
() => adminTierListTopicId.value,
(topicId) => {
if (route.name !== 'adminTierlists' || tierlistsMode.value !== 'all') return
syncAdminRouteQuery({ topicId: topicId || undefined })
@@ -481,7 +481,7 @@ watch(
watch(
() => activeTab.value,
async (tab) => {
if (tab === 'game-admin' && selectedTemplateId.value && !selectedTemplate.value?.game?.id) {
if (tab === 'template-admin' && selectedTemplateId.value && !selectedTemplate.value?.template?.id) {
await loadTemplate()
return
}
@@ -524,10 +524,10 @@ watch(
)
watch(
() => [selectedTemplate.value?.game?.id || '', selectedTemplate.value?.items?.length || 0, !!gameItemListEl.value],
() => [selectedTemplate.value?.template?.id || '', selectedTemplate.value?.items?.length || 0, !!templateItemListEl.value],
([templateId, itemCount, hasListEl]) => {
if (!templateId || !itemCount || !hasListEl) return
scheduleGameItemSortableSync()
scheduleTemplateItemSortableSync()
}
)
@@ -571,7 +571,7 @@ function formatImageJobSourceCategory(category) {
return '커스텀 아이템'
case 'tierlists':
return '티어표 썸네일'
case 'games':
case 'topics':
return '주제/템플릿 이미지'
case 'avatars':
return '프로필 아바타'
@@ -619,7 +619,7 @@ const imageDiagnosticsCards = computed(() => {
]
})
const visibleLinkedTemplates = computed(() =>
(modalTargetCustomItem.value?.linkedGames || []).filter((template) => template?.id && template.id !== 'freeform')
(modalTargetCustomItem.value?.linkedTemplates || []).filter((template) => template?.id && template.id !== 'freeform')
)
const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean)))
@@ -715,10 +715,10 @@ async function cleanupMissingImageReferences() {
success.value =
`누락 참조를 정리했어요. ` +
`아바타 ${result.clearedAvatars || 0}건, ` +
`템플릿 썸네일 ${result.clearedGameThumbnails || 0}건, ` +
`템플릿 썸네일 ${result.clearedTopicThumbnails || 0}건, ` +
`티어표 썸네일 ${result.clearedTierListThumbnails || 0}건, ` +
`요청 썸네일 ${result.clearedTemplateRequestThumbnails || 0}건, ` +
`템플릿 아이템 ${result.deletedGameItems || 0}건, ` +
`템플릿 아이템 ${result.deletedTopicItems || 0}건, ` +
`커스텀 아이템 ${result.deletedCustomItems || 0}`
} catch (e) {
error.value = '누락 이미지 참조 정리에 실패했어요.'
@@ -732,7 +732,7 @@ function setTab(tab) {
const nextRouteName = adminRouteNameByTab[tab]
if (nextRouteName && route.name !== nextRouteName) {
const nextQuery =
tab === 'game-admin'
tab === 'template-admin'
? { topicId: selectedTemplateId.value || undefined }
: tab === 'tierlists' && tierlistsMode.value === 'all'
? { mode: 'all' }
@@ -822,7 +822,7 @@ async function refreshAdminTierLists() {
try {
const data = await api.listAdminTierLists({
q: adminTierListQuery.value,
topicId: adminTierListGameId.value,
topicId: adminTierListTopicId.value,
page: adminTierListPage.value,
limit: adminTierListLimit.value,
})
@@ -839,7 +839,7 @@ async function refreshAdminTierLists() {
async function refreshAdminTierListStats() {
if (!auth.user?.isAdmin) return
try {
const data = await api.getAdminTierListStats({ q: adminTierListQuery.value, topicId: adminTierListGameId.value })
const data = await api.getAdminTierListStats({ q: adminTierListQuery.value, topicId: adminTierListTopicId.value })
adminTierListStats.value = {
total: data.total || 0,
publicCount: data.publicCount || 0,
@@ -919,7 +919,7 @@ const {
removeFeaturedTemplate,
moveFeaturedTemplate,
saveFeaturedOrder,
} = useAdminFeaturedGames({
} = useAdminFeaturedTemplates({
api,
featuredListEl,
featuredSortable,
@@ -931,8 +931,8 @@ const {
})
const {
destroyGameItemSortable,
syncGameItemSortable,
destroyTemplateItemSortable,
syncTemplateItemSortable,
mergeRequestItemsIntoDrafts,
removeUploadDraft,
loadTemplate,
@@ -943,7 +943,7 @@ const {
clearItemFiles,
uploadItem,
saveTemplateItemOrder,
} = useAdminGameManager({
} = useAdminTemplateManager({
api,
toApiUrl,
selectedTemplateId,
@@ -953,10 +953,10 @@ const {
thumbFile,
itemPreviewUrls,
itemFileInput,
gameItemListEl,
gameItemSortable,
savedGameItemOrderIds,
isGameLoading,
templateItemListEl,
templateItemSortable,
savedTemplateItemOrderIds,
isTemplateLoading,
activeTemplateRequest,
templateRequests,
customItemModalOpen,
@@ -1039,6 +1039,10 @@ const {
const {
setUserAvatarInput,
canManageModalRole,
canEditUserAvatar,
canEditUserInfo,
canResetUserPassword,
canDeleteUser,
isUserEditDirty,
roleLabelOf,
openUserAvatarPicker,
@@ -1167,17 +1171,17 @@ async function uploadThumbnail() {
}
async function saveTemplateVisibility() {
if (!selectedTemplate.value?.game?.id) return
if (!selectedTemplate.value?.template?.id) return
try {
gameVisibilitySaving.value = true
const data = await api.updateAdminTemplate(selectedTemplate.value.game.id, {
isPublic: !!selectedTemplate.value.game.isPublic,
templateVisibilitySaving.value = true
const data = await api.updateAdminTemplate(selectedTemplate.value.template.id, {
isPublic: !!selectedTemplate.value.template.isPublic,
})
const nextTemplate = data.template || {}
selectedTemplate.value = {
...selectedTemplate.value,
game: {
...selectedTemplate.value.game,
template: {
...selectedTemplate.value.template,
...nextTemplate,
},
}
@@ -1188,17 +1192,17 @@ async function saveTemplateVisibility() {
error.value = '템플릿 공개 상태를 저장하지 못했어요.'
return false
} finally {
gameVisibilitySaving.value = false
templateVisibilitySaving.value = false
}
}
async function toggleSelectedTemplateVisibility(nextValue) {
if (!selectedTemplate.value?.game?.id || gameVisibilitySaving.value) return
const previous = !!selectedTemplate.value.game.isPublic
if (!selectedTemplate.value?.template?.id || templateVisibilitySaving.value) return
const previous = !!selectedTemplate.value.template.isPublic
selectedTemplate.value = {
...selectedTemplate.value,
game: {
...selectedTemplate.value.game,
template: {
...selectedTemplate.value.template,
isPublic: !!nextValue,
},
}
@@ -1206,8 +1210,8 @@ async function toggleSelectedTemplateVisibility(nextValue) {
if (!saved) {
selectedTemplate.value = {
...selectedTemplate.value,
game: {
...selectedTemplate.value.game,
template: {
...selectedTemplate.value.template,
isPublic: previous,
},
}
@@ -1278,9 +1282,9 @@ async function saveTemplateItemLabel(item) {
async function removeTemplate() {
resetMessages()
if (!selectedTemplateId.value || !selectedTemplate.value?.game) return
if (!selectedTemplateId.value || !selectedTemplate.value?.template) return
const ok = window.confirm(`"${selectedTemplate.value.game.name}" 템플릿을 삭제할까요? 관련 기본 아이템과 티어표도 함께 삭제됩니다.`)
const ok = window.confirm(`"${selectedTemplate.value.template.name}" 템플릿을 삭제할까요? 관련 기본 아이템과 티어표도 함께 삭제됩니다.`)
if (!ok) return
try {
@@ -1290,7 +1294,7 @@ async function removeTemplate() {
})
if (!res.ok) throw new Error('failed')
const deletedName = selectedTemplate.value.game.name
const deletedName = selectedTemplate.value.template.name
selectedTemplateId.value = ''
selectedTemplate.value = null
resetUploadState()
@@ -1306,13 +1310,13 @@ function submitAdminTierListSearch() {
refreshAdminTierLists()
}
function setAdminTierListGameId(topicId) {
adminTierListGameId.value = topicId || ''
function setAdminTierListTopicId(topicId) {
adminTierListTopicId.value = topicId || ''
adminTierListPage.value = 1
refreshAdminTierLists()
}
function openTemplatePickerModal(mode = 'game-admin') {
function openTemplatePickerModal(mode = 'template-admin') {
templatePickerMode.value = mode
templatePickerQuery.value = ''
templatePickerSort.value = 'recent'
@@ -1327,7 +1331,7 @@ function closeTemplatePickerModal() {
async function chooseTemplateFromPicker(templateId) {
if (!templateId) return
if (templatePickerMode.value === 'tierlists-filter') {
setAdminTierListGameId(templateId)
setAdminTierListTopicId(templateId)
closeTemplatePickerModal()
return
}
@@ -1368,7 +1372,7 @@ function buildModalItemFromTierListItem(item, tierList) {
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'template' : 'user'),
sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템',
ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList),
linkedGames: Array.isArray(matchedItem?.linkedGames) ? matchedItem.linkedGames : [],
linkedTemplates: Array.isArray(matchedItem?.linkedTemplates) ? matchedItem.linkedTemplates : [],
usageCount: matchedItem?.usageCount || 0,
canDelete: typeof matchedItem?.canDelete === 'boolean' ? matchedItem.canDelete : false,
isPromoting: false,
@@ -1432,7 +1436,7 @@ async function saveAdminTierListMeta() {
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(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.game?.id || '')])
await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.template?.id || '')])
success.value = '티어표 정보를 수정했어요.'
closeAdminTierListManageModal()
} catch (e) {
@@ -1454,7 +1458,7 @@ async function deleteAdminTierListEntry() {
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(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.game?.id || '')])
await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.template?.id || '')])
success.value = '티어표를 삭제했어요.'
closeAdminTierListManageModal()
if (!adminTierLists.value.length && adminTierListPage.value > 1) {
@@ -1641,7 +1645,7 @@ function templateRequestTargetLabel(request) {
const displayThumbnailUrl = computed(() => {
if (thumbPreviewUrl.value) return thumbPreviewUrl.value
if (selectedTemplate.value?.game?.thumbnailSrc) return toApiUrl(selectedTemplate.value.game.thumbnailSrc)
if (selectedTemplate.value?.template?.thumbnailSrc) return toApiUrl(selectedTemplate.value.template.thumbnailSrc)
return ''
})
@@ -1700,19 +1704,19 @@ function userAvatarFallback(user) {
:add-featured-template="addFeaturedTemplate"
/>
<AdminGamesSection
v-else-if="activeTab === 'game-admin'"
<AdminTemplatesSection
v-else-if="activeTab === 'template-admin'"
:active-template-request="activeTemplateRequest"
:template-request-source-url="templateRequestSourceUrl"
:staged-request-draft-count="stagedRequestDraftCount"
:applied-request-item-count="appliedRequestItemCount"
:open-template-create-modal="openTemplateCreateModal"
:is-game-loading="isGameLoading"
:is-template-loading="isTemplateLoading"
:has-selected-template="hasSelectedTemplate"
:selected-template="selectedTemplate"
:display-thumbnail-url="displayThumbnailUrl"
:can-apply-thumbnail="canApplyThumbnail"
:game-visibility-saving="gameVisibilitySaving"
:template-visibility-saving="templateVisibilitySaving"
:thumb-file-input-ref="setThumbFileInputRef"
:open-thumb-file-picker="openThumbFilePicker"
:on-thumb="onThumb"
@@ -1739,7 +1743,7 @@ function userAvatarFallback(user) {
:remove-upload-draft="removeUploadDraft"
:has-template-item-order-changes="hasTemplateItemOrderChanges"
:save-template-item-order="saveTemplateItemOrder"
:game-item-list-ref="setGameItemListRef"
:template-item-list-ref="setTemplateItemListRef"
:save-template-item-label="saveTemplateItemLabel"
:remove-template-item="removeTemplateItem"
:selected-template-id="selectedTemplateId"
@@ -1795,6 +1799,10 @@ function userAvatarFallback(user) {
:user-display-name="userDisplayName"
:user-avatar-fallback="userAvatarFallback"
:remove-user-avatar="removeUserAvatar"
:can-edit-user-avatar="canEditUserAvatar"
:can-edit-user-info="canEditUserInfo"
:can-reset-user-password="canResetUserPassword"
:can-delete-user="canDeleteUser"
:role-label-of="roleLabelOf"
:fmt="fmt"
:open-user-password-modal="openUserPasswordModal"
@@ -1823,7 +1831,7 @@ function userAvatarFallback(user) {
v-model="newTemplateId"
class="field__input"
maxlength="120"
placeholder="game id (영문/숫자)"
placeholder="topic id (영문/숫자)"
@keydown.enter.prevent="createTemplate"
/>
<span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newTemplateId.length }}/120</span>
@@ -1981,12 +1989,15 @@ function userAvatarFallback(user) {
</div>
<div class="customItemModal__pickerActions">
<button class="btn btn--ghost" type="button" @click="openTemplatePickerModal('custom-item-target')">템플릿 선택</button>
<button class="btn btn--ghost btn--small customItemModal__createGameButton" type="button" @click="openTemplateCreateModal"> 템플릿 만들기</button>
<button class="btn btn--ghost btn--small customItemModal__createTemplateButton" type="button" @click="openTemplateCreateModal"> 템플릿 만들기</button>
</div>
</aside>
<div class="customItemModal__body">
<button class="customItemModal__close" type="button" @click="closeCustomItemModal">닫기</button>
<div class="customItemModal__content">
<div class="customItemModal__preview">
<img class="customItemModal__previewImage" :src="toApiUrl(modalTargetCustomItem.src)" :alt="modalTargetCustomItem.label" />
</div>
<div class="customItemModal__titleRow">
<div>
<div class="customItemModal__title">{{ modalTargetCustomItem.label }}</div>
@@ -2046,34 +2057,34 @@ function userAvatarFallback(user) {
<option value="oldest">오래된순</option>
</select>
<button
v-if="templatePickerMode === 'tierlists-filter' && adminTierListGameId"
v-if="templatePickerMode === 'tierlists-filter' && adminTierListTopicId"
class="btn btn--ghost"
type="button"
@click="setAdminTierListGameId(''); closeTemplatePickerModal()"
@click="setAdminTierListTopicId(''); closeTemplatePickerModal()"
>
모든 주제 보기
</button>
</div>
<div class="gamePickerModalList">
<div class="templatePickerModalList">
<button
v-for="template in filteredTemplatePickerTemplates"
:key="template.id"
class="adminGamePicker__item"
class="adminTemplatePicker__item"
:class="{
'adminGamePicker__item--active': templatePickerMode === 'tierlists-filter'
? adminTierListGameId === template.id
'adminTemplatePicker__item--active': templatePickerMode === 'tierlists-filter'
? adminTierListTopicId === template.id
: templatePickerMode === 'custom-item-target'
? customItemModalTargetTemplateId === template.id
: selectedTemplateId === template.id,
'adminGamePicker__item--disabled': templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id),
'adminTemplatePicker__item--disabled': templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id),
}"
type="button"
:disabled="templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id)"
@click="chooseTemplateFromPicker(template.id)"
>
<span class="adminGamePicker__name">{{ template.name }}</span>
<span class="adminGamePicker__meta">{{ template.id }}</span>
<span v-if="templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id)" class="adminGamePicker__state">이미 추가됨</span>
<span class="adminTemplatePicker__name">{{ template.name }}</span>
<span class="adminTemplatePicker__meta">{{ template.id }}</span>
<span v-if="templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id)" class="adminTemplatePicker__state">이미 추가됨</span>
</button>
<div v-if="!filteredTemplatePickerTemplates.length" class="hint hint--tight">검색 결과가 없어요.</div>
</div>
@@ -2223,24 +2234,24 @@ function userAvatarFallback(user) {
<div class="adminSidebar__label">Mode</div>
<div class="adminSidebar__tabs">
<button class="tab" :class="{ 'tab--active': activeTab === 'featured' }" @click="setTab('featured')">목록 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'game-admin' }" @click="setTab('game-admin')">템플릿 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'template-admin' }" @click="setTab('template-admin')">템플릿 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
</div>
</section>
<section v-if="activeTab === 'game-admin'" class="adminSidebar__panel">
<section v-if="activeTab === 'template-admin'" class="adminSidebar__panel">
<div class="adminSidebar__label">Template</div>
<div class="adminSidebar__group">
<button class="btn btn--primary" @click="openTemplateCreateModal"> 템플릿 생성</button>
<button class="btn btn--ghost" @click="openTemplatePickerModal('game-admin')">템플릿 선택</button>
<div v-if="selectedTemplate?.game" class="adminSelectionCard">
<button class="btn btn--ghost" @click="openTemplatePickerModal('template-admin')">템플릿 선택</button>
<div v-if="selectedTemplate?.template" class="adminSelectionCard">
<div class="adminSelectionCard__label">선택한 템플릿</div>
<div class="adminSelectionCard__title">{{ selectedTemplate.game.name }}</div>
<div class="adminSelectionCard__meta">{{ selectedTemplate.game.id }}</div>
<div class="adminSelectionCard__title">{{ selectedTemplate.template.name }}</div>
<div class="adminSelectionCard__meta">{{ selectedTemplate.template.id }}</div>
</div>
<div v-if="selectedTemplateId && !hasSelectedTemplate && !isGameLoading" class="hint hint--tight">선택된 템플릿 ID: {{ selectedTemplateId }}</div>
<div v-if="selectedTemplateId && !hasSelectedTemplate && !isTemplateLoading" class="hint hint--tight">선택된 템플릿 ID: {{ selectedTemplateId }}</div>
</div>
</section>
@@ -2305,11 +2316,11 @@ function userAvatarFallback(user) {
<button class="btn btn--ghost" @click="submitAdminTierListSearch">검색</button>
</div>
<button class="btn btn--ghost" @click="openTemplatePickerModal('tierlists-filter')">주제 선택</button>
<div v-if="adminTierListGameId" class="adminSelectionCard">
<div v-if="adminTierListTopicId" class="adminSelectionCard">
<div class="adminSelectionCard__label">필터된 주제</div>
<div class="adminSelectionCard__title">{{ templates.find((template) => template.id === adminTierListGameId)?.name || adminTierListGameId }}</div>
<div class="adminSelectionCard__meta">{{ adminTierListGameId }}</div>
<button class="btn btn--ghost btn--small" @click="setAdminTierListGameId('')">필터 해제</button>
<div class="adminSelectionCard__title">{{ templates.find((template) => template.id === adminTierListTopicId)?.name || adminTierListTopicId }}</div>
<div class="adminSelectionCard__meta">{{ adminTierListTopicId }}</div>
<button class="btn btn--ghost btn--small" @click="setAdminTierListTopicId('')">필터 해제</button>
</div>
<select :value="adminTierListLimit" class="select" @change="changeAdminTierListLimit(Number($event.target.value))">
<option :value="50">50개씩 보기</option>
@@ -2583,14 +2594,14 @@ function userAvatarFallback(user) {
font-weight: 800;
color: var(--theme-text);
}
.adminUiScope .adminGamePicker {
.adminUiScope .adminTemplatePicker {
display: grid;
gap: 8px;
max-height: 640px;
overflow: auto;
padding-right: 4px;
}
.adminUiScope .adminGamePicker__item {
.adminUiScope .adminTemplatePicker__item {
display: grid;
/* gap: 2px; */
padding: 11px 12px;
@@ -2601,32 +2612,32 @@ function userAvatarFallback(user) {
color: var(--theme-text);
cursor: pointer;
}
.adminUiScope .adminGamePicker__item--active {
.adminUiScope .adminTemplatePicker__item--active {
border-color: rgba(77, 127, 233, 0.58);
background: rgba(77, 127, 233, 0.12);
}
.adminUiScope .adminGamePicker__item--disabled {
.adminUiScope .adminTemplatePicker__item--disabled {
cursor: not-allowed;
opacity: 0.58;
border-style: dashed;
}
.adminUiScope .adminGamePicker__name {
.adminUiScope .adminTemplatePicker__name {
font-size: 13px;
font-weight: 800;
}
.adminUiScope .adminGamePicker__meta {
.adminUiScope .adminTemplatePicker__meta {
font-size: 11px;
color: var(--theme-text-soft);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.adminUiScope .adminGamePicker__state {
.adminUiScope .adminTemplatePicker__state {
margin-top: 4px;
font-size: 11px;
color: var(--theme-text-faint);
}
.adminUiScope .gamePickerModalList {
.adminUiScope .templatePickerModalList {
margin-top: 14px;
display: grid;
gap: 8px;
@@ -2873,16 +2884,16 @@ function userAvatarFallback(user) {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.adminUiScope .gameManagerGrid {
.adminUiScope .templateManagerGrid {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.adminUiScope .gameManagerGrid--single {
.adminUiScope .templateManagerGrid--single {
grid-template-columns: minmax(0, 1fr);
}
.adminUiScope .gameManagerCard__body {
.adminUiScope .templateManagerCard__body {
margin-top: 10px;
display: grid;
gap: 10px;
@@ -3048,37 +3059,37 @@ function userAvatarFallback(user) {
display: flex;
gap: 8px;
}
.adminUiScope .selectedGame__name {
.adminUiScope .selectedTemplate__name {
margin-top: 8px;
font-size: 22px;
font-weight: 900;
}
.adminUiScope .selectedGame__id {
.adminUiScope .selectedTemplate__id {
margin-top: 6px;
opacity: 0.72;
word-break: break-all;
}
.adminUiScope .gameSettingsCard {
.adminUiScope .templateSettingsCard {
display: grid;
grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
gap: 18px;
align-items: center;
}
.adminUiScope .gameSettingsCard__media {
.adminUiScope .templateSettingsCard__media {
min-width: 0;
}
.adminUiScope .gameSettingsCard__body {
.adminUiScope .templateSettingsCard__body {
display: grid;
gap: 14px;
align-content: center;
}
.adminUiScope .gameSettingsCard__meta {
.adminUiScope .templateSettingsCard__meta {
color: var(--theme-text-soft);
font-size: 13px;
line-height: 1.5;
word-break: break-all;
}
.adminUiScope .gameSettingsCard__actions {
.adminUiScope .templateSettingsCard__actions {
display: flex;
justify-content: space-between;
gap: 10px;
@@ -3101,11 +3112,11 @@ function userAvatarFallback(user) {
.adminUiScope .selectedThumb--sidebar {
width: 100%;
}
.adminUiScope .selectedGameSidebar__name {
.adminUiScope .selectedTemplateSidebar__name {
font-size: 18px;
font-weight: 900;
}
.adminUiScope .selectedGameSidebar__id {
.adminUiScope .selectedTemplateSidebar__id {
font-size: 12px;
opacity: 0.68;
word-break: break-all;
@@ -3330,7 +3341,7 @@ function userAvatarFallback(user) {
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.adminUiScope .thumb--game {
.adminUiScope .thumb--template {
max-width: 150px;
margin: 0 auto;
display: block;
@@ -3493,7 +3504,7 @@ function userAvatarFallback(user) {
display: grid;
gap: 10px;
}
.adminUiScope .customItemModal__createGameButton {
.adminUiScope .customItemModal__createTemplateButton {
justify-self: start;
}
.adminUiScope .customItemModal__body {
@@ -3517,6 +3528,19 @@ function userAvatarFallback(user) {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
}
.adminUiScope .customItemModal__preview {
display: flex;
justify-content: flex-start;
}
.adminUiScope .customItemModal__previewImage {
width: 88px;
height: 88px;
object-fit: cover;
border-radius: 18px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
flex: 0 0 auto;
}
.adminUiScope .customItemModal__pickerPanel::-webkit-scrollbar,
.adminUiScope .customItemModal__content::-webkit-scrollbar {
width: 8px;
@@ -4479,8 +4503,8 @@ function userAvatarFallback(user) {
}
.adminUiScope .featuredOrderPanel,
.adminUiScope .section--topGrid,
.adminUiScope .gameManagerGrid,
.adminUiScope .gameSettingsCard,
.adminUiScope .templateManagerGrid,
.adminUiScope .templateSettingsCard,
.adminUiScope .toolbar,
.adminUiScope .itemComposer,
.adminUiScope .tierAdminCard,

View File

@@ -223,6 +223,7 @@ onMounted(loadFavorites)
height: 22px;
border-radius: 9999px;
object-fit: cover;
border: 1px solid var(--theme-avatar-border);
background: var(--theme-border);
flex: 0 0 auto;
}

View File

@@ -165,9 +165,9 @@ function templateThumbUrl(template) {
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(15, 15, 15, 0.72);
color: rgba(255, 255, 255, 0.82);
border: 1px solid var(--theme-favorite-border);
background: var(--theme-favorite-bg);
color: var(--theme-favorite-icon);
font-size: 17px;
line-height: 1;
cursor: pointer;
@@ -177,16 +177,16 @@ function templateThumbUrl(template) {
justify-content: center;
}
.libraryCard__favorite--active {
background: rgba(54, 45, 10, 0.92);
border-color: rgba(255, 216, 107, 0.28);
background: var(--theme-favorite-active-bg);
border-color: var(--theme-favorite-active-border);
}
.libraryCard__favoriteIcon {
opacity: 0.76;
color: rgba(255, 255, 255, 0.94);
color: var(--theme-favorite-icon);
}
.libraryCard__favorite--active .libraryCard__favoriteIcon {
opacity: 1;
color: #ffd86b;
color: var(--theme-favorite-active-icon);
}
.libraryCard__thumbWrap {
width: 100%;

View File

@@ -4,25 +4,20 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { api } from '../lib/api'
import { homePath, mePath } from '../lib/paths'
import { useToast } from '../composables/useToast'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const toast = useToast()
const email = ref('')
const nickname = ref('')
const password = ref('')
const passwordConfirm = ref('')
const mode = ref('login')
const error = ref('')
const hasUsers = ref(true)
watch(error, (message) => {
if (!message) return
toast.error(message)
error.value = ''
})
const emailError = ref('')
const nicknameError = ref('')
const title = computed(() => (mode.value === 'signup' ? '회원가입' : '로그인'))
const description = computed(() =>
@@ -57,18 +52,59 @@ watch(
{ immediate: true }
)
watch(mode, () => {
error.value = ''
emailError.value = ''
nicknameError.value = ''
})
watch(email, () => {
emailError.value = ''
if (error.value === '이메일이 이미 사용 중이에요.') error.value = ''
})
watch(nickname, () => {
nicknameError.value = ''
if (error.value === '닉네임이 이미 사용 중이에요.' || error.value === '사용할 수 없는 닉네임이에요.') error.value = ''
})
async function submit() {
error.value = ''
emailError.value = ''
nicknameError.value = ''
if (mode.value === 'signup' && nickname.value.trim().length < 2) {
nicknameError.value = '닉네임은 2자 이상 입력해주세요.'
error.value = '닉네임을 확인해주세요.'
return
}
if (mode.value === 'signup' && password.value !== passwordConfirm.value) {
error.value = '비밀번호 확인이 일치하지 않아요.'
return
}
try {
if (mode.value === 'signup') await auth.signup(email.value, password.value)
if (mode.value === 'signup') await auth.signup(email.value, nickname.value, password.value)
else await auth.login(email.value, password.value)
router.push(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
} catch (e) {
error.value = '로그인/회원가입에 실패했어요.'
const code = e?.data?.error
if (mode.value === 'signup') {
if (code === 'email_taken') {
emailError.value = '이미 사용 중인 이메일입니다.'
error.value = '이메일이 이미 사용 중이에요.'
return
}
if (code === 'nickname_taken') {
nicknameError.value = '이미 사용 중인 닉네임입니다.'
error.value = '닉네임이 이미 사용 중이에요.'
return
}
if (code === 'nickname_reserved') {
nicknameError.value = '운영자/관리자처럼 혼동될 수 있는 닉네임은 사용할 수 없어요.'
error.value = '사용할 수 없는 닉네임이에요.'
return
}
}
error.value = mode.value === 'signup' ? '회원가입에 실패했어요.' : '로그인에 실패했어요.'
}
}
</script>
@@ -102,9 +138,17 @@ async function submit() {
<label class="field">
<span class="field__label">이메일</span>
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" maxlength="255" />
<span v-if="emailError" class="field__error">{{ emailError }}</span>
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다. {{ email.length }}/255</span>
</label>
<label v-if="mode === 'signup'" class="field">
<span class="field__label">닉네임</span>
<input v-model="nickname" class="field__input" placeholder="사용할 닉네임" autocomplete="nickname" maxlength="40" />
<span v-if="nicknameError" class="field__error">{{ nicknameError }}</span>
<span class="field__hint">다른 사용자와 구분되는 이름으로 2~40자까지 입력할 있어요.</span>
</label>
<label class="field">
<span class="field__label">비밀번호</span>
<input
@@ -132,6 +176,7 @@ async function submit() {
</label>
<div v-if="!hasUsers" class="roleBadge"> 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
<div v-if="error" class="authError">{{ error }}</div>
<div class="authActions">
<button class="secondaryAction" type="button" @click="router.push(homePath())">취소</button>
@@ -244,6 +289,12 @@ async function submit() {
color: var(--theme-text-soft);
}
.field__error {
font-size: 12px;
color: #ff7b7b;
font-weight: 700;
}
.roleBadge {
width: fit-content;
padding: 6px 10px;
@@ -255,6 +306,16 @@ async function submit() {
font-weight: 700;
}
.authError {
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(239, 68, 68, 0.28);
background: rgba(239, 68, 68, 0.1);
color: #ff9b9b;
font-size: 13px;
font-weight: 700;
}
.authActions {
display: flex;
gap: 12px;

View File

@@ -229,7 +229,7 @@ function openList(t) {
height: 22px;
border-radius: 9999px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
border: 1px solid var(--theme-avatar-border);
background: var(--theme-border);
flex: 0 0 auto;
}

View File

@@ -215,6 +215,7 @@ watch(
height: 22px;
border-radius: 9999px;
object-fit: cover;
border: 1px solid var(--theme-avatar-border);
background: var(--theme-border);
flex: 0 0 auto;
}

View File

@@ -72,6 +72,7 @@ const favoriteCount = ref(0)
const isFavorited = ref(false)
const isRequestingTemplate = ref(false)
const isDeleting = ref(false)
const poolSearchQuery = ref('')
const boardEl = ref(null)
const exportBoardEl = ref(null)
@@ -113,7 +114,7 @@ const untitledWarning = computed(
'제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.'
)
const canFavorite = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
const canDuplicate = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
const canDuplicate = computed(() => !!auth.user && hasSavedTierList.value)
const copiedFromLabel = computed(() => {
if (!sourceTierListId.value) return ''
const parts = []
@@ -122,6 +123,7 @@ const copiedFromLabel = computed(() => {
return parts.join(' · ') || '복사해 온 티어표'
})
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
const normalizedPoolSearchQuery = computed(() => poolSearchQuery.value.trim().toLowerCase())
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
const canRequestTemplateCreate = computed(
() => canEdit.value && hasSavedTierList.value && templateId.value === 'freeform' && customItems.value.length > 0
@@ -138,7 +140,6 @@ const shareTierListUrl = computed(() => {
if (typeof window === 'undefined') return editorPath(templateId.value, savedTierListId, { preview: true })
return new URL(editorPath(templateId.value, savedTierListId, { preview: true }), window.location.origin).toString()
})
watch(error, (message) => {
if (!message) return
toast.error(message)
@@ -166,8 +167,6 @@ function formatExportDate(ts) {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
@@ -194,6 +193,16 @@ function getOrderedItems() {
return getOrderedItemIds().map((itemId) => itemsById.value[itemId]).filter(Boolean)
}
function isPoolItemVisible(itemId) {
const query = normalizedPoolSearchQuery.value
if (!query) return true
const item = itemsById.value[itemId]
const label = String(item?.label || itemId || '').toLowerCase()
return label.includes(query)
}
const visiblePoolCount = computed(() => pool.value.filter((itemId) => isPoolItemVisible(itemId)).length)
function setIconSize(nextSize) {
iconSize.value = nextSize
}
@@ -904,7 +913,7 @@ onMounted(() => {
id: img.id,
src: img.src,
label: img.label,
origin: 'game',
origin: 'template',
}))
const map = {}
base.forEach((it) => (map[it.id] = it))
@@ -962,9 +971,14 @@ onUnmounted(() => {
<template>
<section v-if="previewMode" class="previewOnly" :style="{ '--thumb-size': `${iconSize}px` }">
<header class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Preview</div>
<h1 class="pageHead__title">{{ effectiveTitle }}</h1>
<p v-if="description" class="pageHead__desc">{{ description }}</p>
</div>
</header>
<div class="previewOnly__sheet">
<div class="previewOnly__title">{{ effectiveTitle }}</div>
<div v-if="description" class="previewOnly__description">{{ description }}</div>
<div v-if="columns.length > 1" class="previewOnly__columns">
<div class="previewOnly__columnsSpacer" aria-hidden="true"></div>
<div class="previewOnly__columnsGrid" :style="{ '--column-count': columns.length }">
@@ -1264,12 +1278,28 @@ onUnmounted(() => {
</div>
<div class="sidebar">
<div class="sidebar__title">아이템</div>
<div class="sidebar__titleRow">
<div class="sidebar__title">아이템</div>
<div class="sidebar__count">{{ visiblePoolCount }} / {{ pool.length }}</div>
</div>
<div class="sidebar__hint">
{{ canEdit ? '등록된 아이템 리스트입니다. 드래그해서 표에 넣을 수 있습니다.' : '공개 티어표는 보기 전용입니다.' }}
</div>
<input
v-model="poolSearchQuery"
class="sidebar__search"
type="text"
maxlength="60"
placeholder="아이템 이름 검색"
/>
<div ref="poolEl" class="pool" data-list-type="pool">
<div v-for="id in pool" :key="id" class="poolItem" :class="{ 'poolItem--readonly': !canEdit }" :data-item-id="id">
<div
v-for="id in pool"
:key="id"
class="poolItem"
:class="{ 'poolItem--readonly': !canEdit, 'poolItem--hidden': !isPoolItemVisible(id) }"
:data-item-id="id"
>
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
<div v-if="!canEdit" class="poolItem__state">미배치</div>
@@ -1309,6 +1339,11 @@ onUnmounted(() => {
<div
class="editorSidebar__thumbFrame"
:class="{ 'editorSidebar__thumbFrame--active': isThumbnailDragActive }"
:role="canEdit ? 'button' : undefined"
:tabindex="canEdit ? 0 : undefined"
@click="canEdit ? openThumbnailFile() : null"
@keydown.enter.prevent="canEdit ? openThumbnailFile() : null"
@keydown.space.prevent="canEdit ? openThumbnailFile() : null"
@dragenter.prevent="onThumbnailDragEnter"
@dragover.prevent="onThumbnailDragEnter"
@dragleave="onThumbnailDragLeave"
@@ -1318,7 +1353,6 @@ onUnmounted(() => {
<div v-else class="editorSidebar__thumbEmpty">대표 썸네일</div>
<div class="editorSidebar__thumbOverlay">드래그 또는 클릭으로 썸네일 추가</div>
</div>
<button v-if="canEdit" class="btn btn--ghost editorSidebar__button" @click="openThumbnailFile">파일 업로드</button>
<div v-if="pendingThumbnailFile" class="editorSidebar__fileName">{{ pendingThumbnailFile.name }}</div>
</div>
@@ -1440,30 +1474,18 @@ onUnmounted(() => {
cursor: pointer;
}
.previewOnly {
min-height: 100vh;
padding: 20px;
box-sizing: border-box;
background:
radial-gradient(circle at top, rgba(96, 165, 250, 0.14), transparent 38%),
var(--theme-shell-bg);
display: grid;
gap: 18px;
}
.previewOnly__sheet {
display: grid;
gap: 16px;
width: 100%;
max-width: 1280px;
margin: 0 auto;
}
.previewOnly__title {
font-size: 28px;
font-weight: 900;
letter-spacing: -0.03em;
}
.previewOnly__description {
margin-top: -8px;
font-size: 14px;
line-height: 1.6;
opacity: 0.76;
padding: 18px;
border-radius: 24px;
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
}
.previewOnly__columns {
display: grid;
@@ -2443,6 +2465,30 @@ onUnmounted(() => {
font-size: 13px;
line-height: 1.4;
}
.sidebar__titleRow {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.sidebar__count {
font-size: 12px;
font-weight: 700;
color: var(--theme-text-soft);
}
.sidebar__search {
width: 100%;
margin-top: 12px;
margin-bottom: 12px;
padding: 11px 14px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-card-bg);
color: var(--theme-text);
}
.sidebar__search::placeholder {
color: var(--theme-text-faint);
}
.pool {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
@@ -2490,6 +2536,9 @@ onUnmounted(() => {
text-transform: uppercase;
color: var(--theme-text-soft);
}
.poolItem--hidden {
display: none;
}
.hidden {
display: none;
}

View File

@@ -319,7 +319,7 @@ watch(
height: 22px;
border-radius: 9999px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
border: 1px solid var(--theme-avatar-border);
background: var(--theme-border);
flex: 0 0 auto;
}