Compare commits

...

11 Commits

29 changed files with 874 additions and 423 deletions

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

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,6 +54,7 @@ const {
} = require('../db')
const { requireAdmin } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
const { isReservedNickname } = require('../lib/user-validation')
const router = express.Router()
@@ -128,7 +131,7 @@ router.post('/templates', requireAdmin, async (req, res) => {
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)
@@ -187,7 +190,7 @@ router.post('/templates/:templateId/thumbnail', requireAdmin, upload.single('thu
const optimized = await writeOptimizedImage({
file: req.file,
directory: 'games',
directory: 'topics',
width: 1280,
height: 1280,
fit: 'inside',
@@ -209,28 +212,57 @@ router.post('/templates/:templateId/images', requireAdmin, upload.array('images'
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('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => {
@@ -469,7 +501,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 +539,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 +563,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 +608,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 +625,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 +641,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 +663,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])
@@ -962,6 +994,18 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
return res.status(403).json({ error: 'primary_admin_only' })
}
if (isReservedNickname(parsed.data.nickname)) {
return res.status(400).json({ error: 'nickname_reserved' })
}
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,34 @@
# 의사결정 이력
## 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`만 남기는 편이 맞다고 정리했다.
@@ -583,6 +612,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

@@ -1,6 +1,22 @@
# 할 일 및 이슈
## 단기 확인
- `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`로 자연스럽게 바뀌는지 본다.
@@ -33,7 +49,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한다.
@@ -98,6 +113,7 @@
- 관리자 게임 아이템 순서 저장은 추가됐으므로, 다음 단계에서는 새 아이템 추가 직후 `자동 맨 앞 배치``관리자 수동 고정 순서`의 우선순위를 실제 운영 흐름 기준으로 한 번 더 QA한다.
- 관리자 템플릿 요청 미리보기는 실제 완성본 iframe 방식과의 체감 차이를 마지막으로 한 번 더 QA한다.
- 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다.
- 라이트모드 공통 토큰 재정비와 카드/아바타/즐겨찾기 버튼 보정까지 반영했으므로, 다음 QA에서는 로그인/홈/주제 허브/에디터/관리자 순으로 실제 플로우를 돌리며 남은 하드코딩 색과 과한 대비가 없는지 확인한다.
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
@@ -108,6 +124,8 @@
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다.
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
- 템플릿 기본 아이템 다중 업로드는 8개까지 성공, 9개 이상 한 번에 전송 시 실패하는 사례가 있었으므로 NAS/리버스 프록시의 업로드 body 제한(`client_max_body_size` 등)과 실제 응답 코드를 운영 환경에서 확인한다.
- 프리뷰 우측 광고 레일을 붙였으므로, 실제 운영 환경에서 광고가 로드될 때 프리뷰 본문 폭이 과하게 줄지 않는지 데스크톱 기준으로 한 번 더 확인한다.
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.

View File

@@ -1,5 +1,41 @@
# 업데이트 로그
## 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만 남겼다.
@@ -1068,6 +1104,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)
@@ -76,7 +76,7 @@ const showRightRailAction = computed(() => false)
const showSettingsGuideButton = computed(() => route.name === 'profile')
const guideSteps = [
{
id: 'select-game',
id: 'select-topic',
title: '주제 또는 양식 선택',
summary: '주제 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
description:
@@ -137,7 +137,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 +275,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') {
@@ -452,7 +452,21 @@ function reloadApp() {
</template>
<template v-else-if="isPreviewMode">
<main class="appMain appMain--preview">
<RouterView />
<div class="previewShell">
<div class="previewShell__main">
<RouterView />
</div>
<aside class="previewShell__rail">
<div class="previewShell__railInner">
<RightRailAd class-name="previewShell__ad" />
</div>
<div class="previewShell__footer">
<span>Copyright © 2026 </span>
<a :href="RIGHT_RAIL_COPYRIGHT_URL" target="_blank" rel="noreferrer">zenn</a>
<span>. All rights reserved.</span>
</div>
</aside>
</div>
</main>
</template>
<template v-else>
@@ -748,8 +762,8 @@ 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;
@@ -929,7 +943,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);
}
@@ -1224,6 +1238,43 @@ function reloadApp() {
padding: 0;
}
.previewShell {
min-height: 100dvh;
display: grid;
grid-template-columns: minmax(0, 1fr) 325px;
}
.previewShell__main {
min-width: 0;
}
.previewShell__rail {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 20px;
padding: 16px 18px 20px;
border-left: 1px solid var(--theme-border);
background: var(--theme-rail-bg);
}
.previewShell__railInner {
display: grid;
gap: 16px;
}
.previewShell__footer {
font-size: 10px;
line-height: 1.5;
color: var(--theme-text-faint);
}
.previewShell__footer a {
color: #00ffff;
text-decoration: none;
}
.workspace {
display: grid;
grid-template-rows: 56px minmax(0, 1fr);
@@ -1453,7 +1504,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);
}
@@ -1835,6 +1886,14 @@ function reloadApp() {
}
@media (max-width: 1200px) {
.previewShell {
grid-template-columns: 1fr;
}
.previewShell__rail {
display: none;
}
.guideModal__dialog {
grid-template-columns: 1fr;
height: min(860px, calc(100dvh - 40px));

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

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

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

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,8 +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', redirect: '/admin/templates' },
{ path: '/admin/templates', 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,
@@ -1167,17 +1167,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 +1188,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 +1206,8 @@ async function toggleSelectedTemplateVisibility(nextValue) {
if (!saved) {
selectedTemplate.value = {
...selectedTemplate.value,
game: {
...selectedTemplate.value.game,
template: {
...selectedTemplate.value.template,
isPublic: previous,
},
}
@@ -1278,9 +1278,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 +1290,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 +1306,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 +1327,7 @@ function closeTemplatePickerModal() {
async function chooseTemplateFromPicker(templateId) {
if (!templateId) return
if (templatePickerMode.value === 'tierlists-filter') {
setAdminTierListGameId(templateId)
setAdminTierListTopicId(templateId)
closeTemplatePickerModal()
return
}
@@ -1368,7 +1368,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 +1432,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 +1454,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 +1641,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 +1700,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 +1739,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"
@@ -1823,7 +1823,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 +1981,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 +2049,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 +2226,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 +2308,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 +2586,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 +2604,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 +2876,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 +3051,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 +3104,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 +3333,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 +3496,7 @@ function userAvatarFallback(user) {
display: grid;
gap: 10px;
}
.adminUiScope .customItemModal__createGameButton {
.adminUiScope .customItemModal__createTemplateButton {
justify-self: start;
}
.adminUiScope .customItemModal__body {
@@ -3517,6 +3520,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 +4495,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

@@ -9,7 +9,7 @@ import addRowBelowIcon from '../assets/icons/add_row_below.svg'
import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
import shareIcon from '../assets/icons/share.svg'
import { api } from '../lib/api'
import { editorNewPath, editorPath, loginPath, mePath, topicPath } from '../lib/paths'
import { editorNewPath, editorPath, homePath, loginPath, mePath, topicPath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth'
import { useToast } from '../composables/useToast'
@@ -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,6 +140,7 @@ 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()
})
const previewHomeUrl = computed(() => homePath())
watch(error, (message) => {
if (!message) return
@@ -166,8 +169,6 @@ function formatExportDate(ts) {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
@@ -194,6 +195,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 +915,7 @@ onMounted(() => {
id: img.id,
src: img.src,
label: img.label,
origin: 'game',
origin: 'template',
}))
const map = {}
base.forEach((it) => (map[it.id] = it))
@@ -963,6 +974,10 @@ onUnmounted(() => {
<template>
<section v-if="previewMode" class="previewOnly" :style="{ '--thumb-size': `${iconSize}px` }">
<div class="previewOnly__sheet">
<a class="previewOnly__brand" :href="previewHomeUrl">
<span class="previewOnly__brandMark">TM</span>
<span class="previewOnly__brandText">Tier Maker</span>
</a>
<div class="previewOnly__title">{{ effectiveTitle }}</div>
<div v-if="description" class="previewOnly__description">{{ description }}</div>
<div v-if="columns.length > 1" class="previewOnly__columns">
@@ -1264,12 +1279,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 +1340,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 +1354,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>
@@ -1454,6 +1489,31 @@ onUnmounted(() => {
max-width: 1280px;
margin: 0 auto;
}
.previewOnly__brand {
width: fit-content;
display: inline-flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: var(--theme-text-strong);
}
.previewOnly__brandMark {
width: 34px;
height: 34px;
border-radius: 12px;
display: grid;
place-items: center;
background: var(--theme-accent-bg);
color: var(--theme-accent-text);
font-size: 13px;
font-weight: 900;
letter-spacing: -0.04em;
}
.previewOnly__brandText {
font-size: 14px;
font-weight: 900;
letter-spacing: -0.03em;
}
.previewOnly__title {
font-size: 28px;
font-weight: 900;
@@ -2443,6 +2503,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 +2574,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;
}