Compare commits

..

4 Commits

21 changed files with 365 additions and 185 deletions

View File

@@ -24,7 +24,7 @@ const allowedOrigins = (process.env.CORS_ORIGINS || '')
const FileStore = FileStoreFactory(session) 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 }) fs.mkdirSync(path.join(__dirname, relativePath), { recursive: true })
}) })

View File

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

View File

@@ -35,7 +35,7 @@ function getOptimizationConfig(roles) {
if (roleSet.has('avatar')) { if (roleSet.has('avatar')) {
return { directory: 'avatars', width: 512, height: 512, fit: 'cover', quality: 82 } 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-thumbnails', width: 1280, height: 1280, fit: 'inside', quality: 84 }
} }
return { directory: 'legacy-items', width: 512, height: 512, 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_NAME = process.env.DB_NAME || 'tier_cursor'
const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10 const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10
const FREEFORM_TOPIC_ID = 'freeform' const FREEFORM_TOPIC_ID = 'freeform'
const FREEFORM_GAME_ID = FREEFORM_TOPIC_ID
let poolPromise = null let poolPromise = null
let initPromise = null let initPromise = null
@@ -68,7 +67,7 @@ function mapUserRow(row) {
} }
} }
function mapGameRow(row) { function mapTopicRow(row) {
if (!row) return null if (!row) return null
return { return {
id: row.id, id: row.id,
@@ -82,7 +81,7 @@ function mapGameRow(row) {
} }
} }
function mapGameItemRow(row) { function mapTopicItemRow(row) {
if (!row) return null if (!row) return null
return { return {
id: row.id, id: row.id,
@@ -270,10 +269,6 @@ async function closePool() {
async function ensureSchema() { async function ensureSchema() {
if (initPromise) return initPromise if (initPromise) return initPromise
initPromise = (async () => { initPromise = (async () => {
const legacyGamesExists = await tableExists('games')
const legacyGameItemsExists = await tableExists('game_items')
const legacyFavoriteGamesExists = await tableExists('favorite_games')
await query(` await query(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
@@ -297,8 +292,8 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`) `)
const gameIsPublicColumns = await query("SHOW COLUMNS FROM topics LIKE 'is_public'") const topicIsPublicColumns = await query("SHOW COLUMNS FROM topics LIKE 'is_public'")
if (!gameIsPublicColumns.length) { if (!topicIsPublicColumns.length) {
await query('ALTER TABLE topics ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER thumbnail_src') 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') 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') 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(` await query(`
CREATE TABLE IF NOT EXISTS topic_items ( CREATE TABLE IF NOT EXISTS topic_items (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
@@ -329,22 +316,11 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`) `)
const gameItemDisplayOrderColumns = await query("SHOW COLUMNS FROM topic_items LIKE 'display_order'") const topicItemDisplayOrderColumns = await query("SHOW COLUMNS FROM topic_items LIKE 'display_order'")
if (!gameItemDisplayOrderColumns.length) { if (!topicItemDisplayOrderColumns.length) {
await query('ALTER TABLE topic_items ADD COLUMN display_order INT NULL DEFAULT NULL AFTER label') 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(` await query(`
CREATE TABLE IF NOT EXISTS custom_items ( CREATE TABLE IF NOT EXISTS custom_items (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
@@ -407,14 +383,6 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) 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(` await query(`
CREATE TABLE IF NOT EXISTS image_assets ( CREATE TABLE IF NOT EXISTS image_assets (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
@@ -487,16 +455,10 @@ async function ensureSchema() {
const hasSourceTopicId = await columnExists('template_requests', 'source_topic_id') const hasSourceTopicId = await columnExists('template_requests', 'source_topic_id')
if (!hasSourceTopicId) { if (!hasSourceTopicId) {
await query("ALTER TABLE template_requests ADD COLUMN source_topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id") 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') const hasTargetTopicId = await columnExists('template_requests', 'target_topic_id')
if (!hasTargetTopicId) { if (!hasTargetTopicId) {
await query("ALTER TABLE template_requests ADD COLUMN target_topic_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_topic_id") 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'") const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'")
if (!templateRequestStatusColumns.length) { if (!templateRequestStatusColumns.length) {
@@ -522,9 +484,6 @@ async function ensureSchema() {
const tierListTopicIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'topic_id'") const tierListTopicIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'topic_id'")
if (!tierListTopicIdColumns.length) { if (!tierListTopicIdColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER author_id") 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'") const tierListShowNamesColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'show_character_names'")
if (!tierListShowNamesColumns.length) { if (!tierListShowNamesColumns.length) {
@@ -568,7 +527,7 @@ async function ensureSchema() {
(?, ?, ?, ?), (?, ?, ?, ?),
(?, ?, ?, ?) (?, ?, ?, ?)
`, `,
['example-game', '예시 게임', '', createdAt, 'another-game', '다른 예시 게임', '', createdAt] ['example-topic', '예시 주제', '', createdAt, 'another-topic', '다른 예시 주제', '', createdAt]
) )
await query( await query(
@@ -580,12 +539,12 @@ async function ensureSchema() {
`, `,
[ [
'img-1', 'img-1',
'example-game', 'example-topic',
'/uploads/seeds/example1.png', '/uploads/seeds/example1.png',
'샘플 1', '샘플 1',
createdAt, createdAt,
'img-2', 'img-2',
'example-game', 'example-topic',
'/uploads/seeds/example2.png', '/uploads/seeds/example2.png',
'샘플 2', '샘플 2',
createdAt, createdAt,
@@ -616,6 +575,33 @@ async function findUserByEmail(email) {
return { ...mapUserRow(row), passwordHash: row.password_hash } 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) { async function findUserById(id) {
const rows = await query( const rows = await query(
'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1', '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] [FREEFORM_TOPIC_ID]
) )
const topics = rows.map(mapGameRow) const topics = rows.map(mapTopicRow)
if (!currentUserId) return topics.map((topic) => ({ ...topic, isFavorited: false })) if (!currentUserId) return topics.map((topic) => ({ ...topic, isFavorited: false }))
const favoriteRows = await query('SELECT topic_id FROM favorite_topics WHERE user_id = ?', [currentUserId]) 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) { 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]) 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) { async function listTopicItems(topicId) {
@@ -776,12 +762,12 @@ async function listTopicItems(topicId) {
`, `,
[topicId] [topicId]
) )
return rows.map(mapGameItemRow) return rows.map(mapTopicItemRow)
} }
async function findTopicItemById(itemId) { 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]) 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) { async function getTopicDetail(topicId) {
@@ -909,7 +895,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
const referencedSrcs = new Set() 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 avatar_src FROM users WHERE avatar_src <> ''"),
query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"), query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
query("SELECT src FROM topic_items WHERE 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 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 topicRows) 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 topicItemRows) if (row.src) referencedSrcs.add(row.src)
for (const row of customItemRows) if (row.src) referencedSrcs.add(row.src) for (const row of customItemRows) if (row.src) referencedSrcs.add(row.src)
for (const row of tierListRows) { for (const row of tierListRows) {
@@ -962,7 +948,7 @@ async function listReferencedUploadUsage() {
usageMap.get(src).add(role) 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 avatar_src FROM users WHERE avatar_src <> ''"),
query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"), query("SELECT thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
query("SELECT src FROM topic_items WHERE 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 userRows) addUsage(row.avatar_src, 'avatar')
for (const row of gameRows) addUsage(row.thumbnail_src, 'topic-thumbnail') for (const row of topicRows) addUsage(row.thumbnail_src, 'topic-thumbnail')
for (const row of gameItemRows) addUsage(row.src, 'topic-item') for (const row of topicItemRows) addUsage(row.src, 'topic-item')
for (const row of customItemRows) addUsage(row.src, 'custom-item') for (const row of customItemRows) addUsage(row.src, 'custom-item')
for (const row of tierListRows) { for (const row of tierListRows) {
@@ -1005,14 +991,14 @@ function replaceItemSrc(items, fromSrc, toSrc) {
async function replaceUploadSourceReferences({ fromSrc, toSrc }) { async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 } 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 users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]),
query('UPDATE topics SET thumbnail_src = ? WHERE thumbnail_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 topic_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
query('UPDATE custom_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') const tierListRows = await query('SELECT id, thumbnail_src, pool_json FROM tierlists')
for (const row of tierListRows) { for (const row of tierListRows) {
@@ -1161,16 +1147,16 @@ function stripMissingItems(items, missingItemIds, missingSrcs) {
async function cleanupMissingUploadReferences() { async function cleanupMissingUploadReferences() {
const stats = { const stats = {
clearedAvatars: 0, clearedAvatars: 0,
clearedGameThumbnails: 0, clearedTopicThumbnails: 0,
clearedTierListThumbnails: 0, clearedTierListThumbnails: 0,
clearedTemplateRequestThumbnails: 0, clearedTemplateRequestThumbnails: 0,
deletedGameItems: 0, deletedTopicItems: 0,
updatedTierLists: 0, updatedTierLists: 0,
updatedTemplateRequests: 0, updatedTemplateRequests: 0,
deletedCustomItems: 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, avatar_src FROM users WHERE avatar_src <> ''"),
query("SELECT id, thumbnail_src FROM topics WHERE thumbnail_src <> ''"), query("SELECT id, thumbnail_src FROM topics WHERE thumbnail_src <> ''"),
query("SELECT id, src FROM topic_items WHERE src <> ''"), query("SELECT id, src FROM topic_items WHERE src <> ''"),
@@ -1185,16 +1171,16 @@ async function cleanupMissingUploadReferences() {
stats.clearedAvatars += 1 stats.clearedAvatars += 1
} }
for (const row of gameRows) { for (const row of topicRows) {
if (await fileExistsForUploadSrc(row.thumbnail_src)) continue if (await fileExistsForUploadSrc(row.thumbnail_src)) continue
await query('UPDATE topics SET thumbnail_src = ? WHERE id = ?', ['', row.id]) 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 if (await fileExistsForUploadSrc(row.src)) continue
await deleteTopicItem(row.id) await deleteTopicItem(row.id)
stats.deletedGameItems += 1 stats.deletedTopicItems += 1
} }
const missingCustomItemIds = new Set() const missingCustomItemIds = new Set()
@@ -1354,13 +1340,13 @@ async function createTopicItem({ id, topicId, src, label }) {
createdAt, createdAt,
]) ])
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [id]) 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) { async function updateTopicItemLabel(itemId, label) {
await query('UPDATE topic_items SET label = ? WHERE id = ?', [label, itemId]) 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]) 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) { async function updateTopicItemDisplayOrder(topicId, itemIds) {
@@ -1562,7 +1548,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
const hasQuery = !!searchText const hasQuery = !!searchText
const search = `%${searchText}%` const search = `%${searchText}%`
const [customRows, gameItemRows, assetRows, usageMeta] = await Promise.all([ const [customRows, topicItemRows, assetRows, usageMeta] = await Promise.all([
query( query(
` `
SELECT SELECT
@@ -1610,7 +1596,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
]) ])
const templateLinkedBySrc = new Map() const templateLinkedBySrc = new Map()
gameItemRows.forEach((row) => { topicItemRows.forEach((row) => {
if (!row?.src) return if (!row?.src) return
if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map()) if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map())
templateLinkedBySrc.get(row.src).set(row.topic_id, { templateLinkedBySrc.get(row.src).set(row.topic_id, {
@@ -1637,7 +1623,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
} }
}) })
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 customSrcSet = new Set(customRows.map((row) => row.src).filter(Boolean))
const assetLibraryItems = assetRows const assetLibraryItems = assetRows
.filter((row) => row?.src && !templateSrcSet.has(row.src) && !customSrcSet.has(row.src)) .filter((row) => row?.src && !templateSrcSet.has(row.src) && !customSrcSet.has(row.src))
@@ -1660,7 +1646,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
isAssetLibraryItem: true, isAssetLibraryItem: true,
})) }))
const templateItems = gameItemRows.map((row) => ({ const templateItems = topicItemRows.map((row) => ({
id: row.id, id: row.id,
ownerId: '', ownerId: '',
src: row.src, src: row.src,
@@ -2036,12 +2022,12 @@ async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limi
const normalizedPage = Math.max(Number(page) || 1, 1) const normalizedPage = Math.max(Number(page) || 1, 1)
const hasQuery = !!(queryText || '').trim() const hasQuery = !!(queryText || '').trim()
const resolvedTopicId = (topicId || '').trim() const resolvedTopicId = (topicId || '').trim()
const hasGameId = !!resolvedTopicId const hasTopicId = !!resolvedTopicId
const search = `%${(queryText || '').trim()}%` const search = `%${(queryText || '').trim()}%`
const whereParts = [] const whereParts = []
const params = [] const params = []
if (hasGameId) { if (hasTopicId) {
whereParts.push('t.topic_id = ?') whereParts.push('t.topic_id = ?')
params.push(resolvedTopicId) params.push(resolvedTopicId)
} }
@@ -2121,12 +2107,12 @@ async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limi
async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) { async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) {
const hasQuery = !!(queryText || '').trim() const hasQuery = !!(queryText || '').trim()
const resolvedTopicId = (topicId || '').trim() const resolvedTopicId = (topicId || '').trim()
const hasGameId = !!resolvedTopicId const hasTopicId = !!resolvedTopicId
const search = `%${(queryText || '').trim()}%` const search = `%${(queryText || '').trim()}%`
const whereParts = [] const whereParts = []
const params = [] const params = []
if (hasGameId) { if (hasTopicId) {
whereParts.push('t.topic_id = ?') whereParts.push('t.topic_id = ?')
params.push(resolvedTopicId) params.push(resolvedTopicId)
} }
@@ -2512,6 +2498,7 @@ module.exports = {
closePool, closePool,
countUsers, countUsers,
findUserByEmail, findUserByEmail,
findUserByNickname,
findUserById, findUserById,
createUser, createUser,
updateUserProfile, updateUserProfile,

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 { nanoid } = require('nanoid')
const { const {
findUserById, findUserById,
findUserByEmail,
findUserByNickname,
findTopicById, findTopicById,
findTopicItemById, findTopicItemById,
listTopicItems, listTopicItems,
@@ -52,6 +54,7 @@ const {
} = require('../db') } = require('../db')
const { requireAdmin } = require('../middleware/auth') const { requireAdmin } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage') const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
const { isReservedNickname } = require('../lib/user-validation')
const router = express.Router() 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' }) 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 }) const template = await createTopic({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic })
if (parsed.data.thumbnailSrc) { if (parsed.data.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc) const copiedThumb = await copyUploadIntoTopicAsset(parsed.data.thumbnailSrc)
await updateTopicThumbnail(template.id, copiedThumb) await updateTopicThumbnail(template.id, copiedThumb)
} }
const savedTemplate = await findTopicById(template.id) const savedTemplate = await findTopicById(template.id)
@@ -469,7 +472,7 @@ async function promoteLibraryItemToTemplateItem({ item, templateId }) {
}) })
} }
async function copyUploadIntoGameAsset(src) { async function copyUploadIntoTopicAsset(src) {
if (typeof src !== 'string') return '' if (typeof src !== 'string') return ''
const raw = src.trim() const raw = src.trim()
if (!raw) return '' if (!raw) return ''
@@ -507,7 +510,7 @@ async function promoteTierListItemsToTemplate({ tierList, templateId, itemIds =
const createdItems = [] const createdItems = []
for (const item of itemsToCopy) { for (const item of itemsToCopy) {
const copiedSrc = await copyUploadIntoGameAsset(item.src) const copiedSrc = await copyUploadIntoTopicAsset(item.src)
createdItems.push( createdItems.push(
await createTopicItem({ await createTopicItem({
id: nanoid(), id: nanoid(),
@@ -531,7 +534,7 @@ async function promoteSnapshotItemsToTemplate({ items, templateId }) {
const createdItems = [] const createdItems = []
for (const item of items || []) { for (const item of items || []) {
const copiedSrc = await copyUploadIntoGameAsset(item.src) const copiedSrc = await copyUploadIntoTopicAsset(item.src)
if (!copiedSrc || existingSrcs.has(copiedSrc)) continue if (!copiedSrc || existingSrcs.has(copiedSrc)) continue
createdItems.push( createdItems.push(
await createTopicItem({ await createTopicItem({
@@ -576,13 +579,13 @@ function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}
async function createTemplateFromTierList({ tierList, templateId, templateName }) { async function createTemplateFromTierList({ tierList, templateId, templateName }) {
await createTopic({ id: templateId, name: templateName, isPublic: false }) await createTopic({ id: templateId, name: templateName, isPublic: false })
if (tierList.thumbnailSrc) { if (tierList.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc) const copiedThumb = await copyUploadIntoTopicAsset(tierList.thumbnailSrc)
await updateTopicThumbnail(templateId, copiedThumb) await updateTopicThumbnail(templateId, copiedThumb)
} }
const createdItems = [] const createdItems = []
for (const item of uniqueTierListPoolItems(tierList)) { for (const item of uniqueTierListPoolItems(tierList)) {
const copiedSrc = await copyUploadIntoGameAsset(item.src) const copiedSrc = await copyUploadIntoTopicAsset(item.src)
createdItems.push( createdItems.push(
await createTopicItem({ await createTopicItem({
id: nanoid(), id: nanoid(),
@@ -600,7 +603,7 @@ async function createTemplateFromRequest({ templateRequest, templateId, template
await createTopic({ id: templateId, name: templateName, isPublic: false }) await createTopic({ id: templateId, name: templateName, isPublic: false })
if (templateRequest.thumbnailSrc) { if (templateRequest.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc) const copiedThumb = await copyUploadIntoTopicAsset(templateRequest.thumbnailSrc)
await updateTopicThumbnail(templateId, copiedThumb) await updateTopicThumbnail(templateId, copiedThumb)
} }
@@ -962,6 +965,18 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
return res.status(403).json({ error: 'primary_admin_only' }) 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 { try {
const updated = await adminUpdateUser({ const updated = await adminUpdateUser({
id: targetUser.id, id: targetUser.id,

View File

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

View File

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

View File

@@ -1,5 +1,22 @@
# 의사결정 이력 # 의사결정 이력
## 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 ## 2026-04-02 v1.4.29
- `origin: 'game'`는 이미 저장된 티어표 데이터와 직접 맞물리므로, 이 단계에서는 새 데이터 기본값만 `template`로 옮기고 예전 값도 계속 받아주는 점진 호환이 가장 안전하다고 판단했다. - `origin: 'game'`는 이미 저장된 티어표 데이터와 직접 맞물리므로, 이 단계에서는 새 데이터 기본값만 `template`로 옮기고 예전 값도 계속 받아주는 점진 호환이 가장 안전하다고 판단했다.
- 아이템 라이브러리의 `linkedGames`는 실제 의미가 템플릿 연결 정보이므로, 이 응답 키까지 `linkedTemplates`로 바꿔두는 편이 이후 관리자 유지보수에서 훨씬 덜 헷갈린다고 정리했다. - 아이템 라이브러리의 `linkedGames`는 실제 의미가 템플릿 연결 정보이므로, 이 응답 키까지 `linkedTemplates`로 바꿔두는 편이 이후 관리자 유지보수에서 훨씬 덜 헷갈린다고 정리했다.

View File

@@ -1,6 +1,15 @@
# 할 일 및 이슈 # 할 일 및 이슈
## 단기 확인 ## 단기 확인
- `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`로 바꿨으므로, 저장 후 다시 열기/복사/요청 생성/관리자 가져오기 흐름에서 예전 데이터와 새 데이터가 함께 섞여도 정상 동작하는지 한 번 더 확인한다. - `v1.4.29`에서 새 티어표 데이터 기본 origin을 `template`로 바꿨으므로, 저장 후 다시 열기/복사/요청 생성/관리자 가져오기 흐름에서 예전 데이터와 새 데이터가 함께 섞여도 정상 동작하는지 한 번 더 확인한다.
- 관리자 아이템 라이브러리 응답 키가 `linkedTemplates`로 정리됐으므로, 사용자 업로드 이미지 삭제 차단과 템플릿 이동 모달이 그대로 정상 동작하는지 확인한다. - 관리자 아이템 라이브러리 응답 키가 `linkedTemplates`로 정리됐으므로, 사용자 업로드 이미지 삭제 차단과 템플릿 이동 모달이 그대로 정상 동작하는지 확인한다.
- 현재 남아 있는 `game`는 레거시 redirect, DB 마이그레이션, 호환용 origin만 남겨둔 상태이므로, `v1.4` QA 후에는 이 레거시 층을 언제 제거할지 별도 마감 판단만 하면 된다. - 현재 남아 있는 `game`는 레거시 redirect, DB 마이그레이션, 호환용 origin만 남겨둔 상태이므로, `v1.4` QA 후에는 이 레거시 층을 언제 제거할지 별도 마감 판단만 하면 된다.

View File

@@ -1,5 +1,26 @@
# 업데이트 로그 # 업데이트 로그
## 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 ## 2026-04-02 v1.4.29
- 티어표 저장/request schema는 이제 새 데이터에서 `origin: 'template'`를 기본으로 쓰고, 예전 `origin: 'game'`도 계속 읽을 수 있게 호환 레이어를 남겼다. - 티어표 저장/request schema는 이제 새 데이터에서 `origin: 'template'`를 기본으로 쓰고, 예전 `origin: 'game'`도 계속 읽을 수 있게 호환 레이어를 남겼다.
- 관리자 아이템 라이브러리의 템플릿 연결 정보도 `linkedTemplates` 기준으로 정리해, 내부 응답/프런트 상태에 남아 있던 `linkedGames` 흔적을 제거했다. - 관리자 아이템 라이브러리의 템플릿 연결 정보도 `linkedTemplates` 기준으로 정리해, 내부 응답/프런트 상태에 남아 있던 `linkedGames` 흔적을 제거했다.

View File

@@ -137,7 +137,7 @@ const isGuidePrevDisabled = computed(() => guideStepIndex.value <= 0)
const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.length - 1) const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.length - 1)
const isLightTheme = computed(() => themeMode.value === 'light') const isLightTheme = computed(() => themeMode.value === 'light')
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드')) 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 showTopicViewToggle = computed(() => route.name === 'topicHub')
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid')) const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value)) const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
@@ -275,7 +275,7 @@ onMounted(async () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const savedTheme = window.localStorage.getItem('tier-maker:theme') const savedTheme = window.localStorage.getItem('tier-maker:theme')
if (savedTheme === 'light' || savedTheme === 'dark') applyTheme(savedTheme) 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() await auth.refresh()
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {

View File

@@ -47,7 +47,7 @@ const props = defineProps({
selectedTemplateId: { type: String, default: '' }, selectedTemplateId: { type: String, default: '' },
}) })
function setGameItemListElement(el) { function setTemplateItemListElement(el) {
props.templateItemListRef(el) props.templateItemListRef(el)
} }
@@ -109,8 +109,8 @@ function setThumbFileElement(el) {
</div> </div>
</div> </div>
<div v-else-if="props.hasSelectedTemplate" class="panel"> <div v-else-if="props.hasSelectedTemplate" class="panel">
<section class="adminCard gameSettingsCard"> <section class="adminCard templateSettingsCard">
<div class="gameSettingsCard__media"> <div class="templateSettingsCard__media">
<input :ref="setThumbFileElement" type="file" accept="image/*" class="srOnlyInput" @change="props.onThumb" /> <input :ref="setThumbFileElement" type="file" accept="image/*" class="srOnlyInput" @change="props.onThumb" />
<button <button
class="thumbDropZone" class="thumbDropZone"
@@ -125,22 +125,19 @@ function setThumbFileElement(el) {
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedTemplate.template.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 v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
<div class="thumbDropZone__copy"> <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 class="thumbDropZone__title">{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
</div> </div>
</button> </button>
</div> </div>
<div class="gameSettingsCard__body"> <div class="templateSettingsCard__body">
<div class="panel__title">템플릿 설정</div> <div class="panel__title">템플릿 설정</div>
<div class="gameSettingsCard__meta">{{ props.selectedTemplate.template.name }} · {{ props.selectedTemplate.template.id }}</div> <div class="templateSettingsCard__meta">{{ props.selectedTemplate.template.name }} · {{ props.selectedTemplate.template.id }}</div>
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.templateVisibilitySaving }"> <label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.templateVisibilitySaving }">
<input :checked="!!props.selectedTemplate.template.isPublic" type="checkbox" @change="props.toggleSelectedTemplateVisibility($event.target.checked)" /> <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__label">{{ props.selectedTemplate.template.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span> <span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label> </label>
<div class="gameSettingsCard__actions"> <div class="templateSettingsCard__actions">
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button> <button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
<button class="btn btn--danger" @click="props.removeTemplate">템플릿 삭제</button> <button class="btn btn--danger" @click="props.removeTemplate">템플릿 삭제</button>
</div> </div>
@@ -215,7 +212,7 @@ function setThumbFileElement(el) {
<button class="btn btn--primary btn--small" :disabled="!props.hasTemplateItemOrderChanges" @click="props.saveTemplateItemOrder">순서 저장</button> <button class="btn btn--primary btn--small" :disabled="!props.hasTemplateItemOrderChanges" @click="props.saveTemplateItemOrder">순서 저장</button>
</div> </div>
<div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div> <div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
<div v-else :ref="setGameItemListElement" class="thumbGrid"> <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"> <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" /> <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 /> <input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />

View File

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

View File

@@ -1,7 +1,7 @@
import { nextTick } from 'vue' import { nextTick } from 'vue'
import Sortable from 'sortablejs' import Sortable from 'sortablejs'
export function useAdminGameManager({ export function useAdminTemplateManager({
api, api,
toApiUrl, toApiUrl,
selectedTemplateId, selectedTemplateId,

View File

@@ -55,7 +55,7 @@ async function request(path, { method = 'GET', body, headers } = {}) {
export const api = { export const api = {
me: () => request('/api/auth/me'), me: () => request('/api/auth/me'),
authMeta: () => request('/api/auth/meta'), 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 } }), login: ({ email, password }) => request('/api/auth/login', { method: 'POST', body: { email, password } }),
logout: () => request('/api/auth/logout', { method: 'POST' }), logout: () => request('/api/auth/logout', { method: 'POST' }),

View File

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

View File

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

View File

@@ -8,13 +8,13 @@ import lockResetIcon from '../assets/icons/lock_reset.svg'
import deleteIcon from '../assets/icons/delete.svg' import deleteIcon from '../assets/icons/delete.svg'
import SvgIcon from '../components/SvgIcon.vue' import SvgIcon from '../components/SvgIcon.vue'
import AdminFeaturedSection from '../components/admin/AdminFeaturedSection.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 AdminItemsSection from '../components/admin/AdminItemsSection.vue'
import AdminTierlistsSection from '../components/admin/AdminTierlistsSection.vue' import AdminTierlistsSection from '../components/admin/AdminTierlistsSection.vue'
import AdminUsersSection from '../components/admin/AdminUsersSection.vue' import AdminUsersSection from '../components/admin/AdminUsersSection.vue'
import { useAdminCustomItems } from '../composables/useAdminCustomItems' import { useAdminCustomItems } from '../composables/useAdminCustomItems'
import { useAdminFeaturedGames } from '../composables/useAdminFeaturedGames' import { useAdminFeaturedTemplates } from '../composables/useAdminFeaturedTemplates'
import { useAdminGameManager } from '../composables/useAdminGameManager' import { useAdminTemplateManager } from '../composables/useAdminTemplateManager'
import { useAdminTemplateRequests } from '../composables/useAdminTemplateRequests' import { useAdminTemplateRequests } from '../composables/useAdminTemplateRequests'
import { useAdminUsers } from '../composables/useAdminUsers' import { useAdminUsers } from '../composables/useAdminUsers'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@@ -50,7 +50,7 @@ const customItemModalTargetTemplateId = ref('')
const adminTierLists = ref([]) const adminTierLists = ref([])
const adminTierListQuery = ref('') const adminTierListQuery = ref('')
const adminTierListGameId = ref('') const adminTierListTopicId = ref('')
const adminTierListPage = ref(1) const adminTierListPage = ref(1)
const adminTierListLimit = ref(50) const adminTierListLimit = ref(50)
const adminTierListTotal = ref(0) const adminTierListTotal = ref(0)
@@ -143,7 +143,7 @@ function setThumbFileInputRef(el) {
thumbFileInput.value = el thumbFileInput.value = el
} }
function scheduleGameItemSortableSync() { function scheduleTemplateItemSortableSync() {
if (templateItemSortableSyncTimer) { if (templateItemSortableSyncTimer) {
clearTimeout(templateItemSortableSyncTimer) clearTimeout(templateItemSortableSyncTimer)
templateItemSortableSyncTimer = null templateItemSortableSyncTimer = null
@@ -156,10 +156,10 @@ function scheduleGameItemSortableSync() {
}, 0) }, 0)
} }
function setGameItemListRef(el) { function setTemplateItemListRef(el) {
templateItemListEl.value = el templateItemListEl.value = el
if (!el) return if (!el) return
scheduleGameItemSortableSync() scheduleTemplateItemSortableSync()
} }
function normalizeAdminSrc(src) { function normalizeAdminSrc(src) {
@@ -437,7 +437,7 @@ watch(
const nextMode = route.query.mode === 'all' ? 'all' : 'requests' const nextMode = route.query.mode === 'all' ? 'all' : 'requests'
if (tierlistsMode.value !== nextMode) tierlistsMode.value = nextMode if (tierlistsMode.value !== nextMode) tierlistsMode.value = nextMode
const nextTierListTopicId = typeof route.query.topicId === 'string' ? route.query.topicId : '' 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 } { immediate: true }
@@ -465,13 +465,13 @@ watch(
if (route.name !== 'adminTierlists') return if (route.name !== 'adminTierlists') return
syncAdminRouteQuery({ syncAdminRouteQuery({
mode: mode === 'all' ? 'all' : undefined, mode: mode === 'all' ? 'all' : undefined,
topicId: mode === 'all' && adminTierListGameId.value ? adminTierListGameId.value : undefined, topicId: mode === 'all' && adminTierListTopicId.value ? adminTierListTopicId.value : undefined,
}) })
} }
) )
watch( watch(
() => adminTierListGameId.value, () => adminTierListTopicId.value,
(topicId) => { (topicId) => {
if (route.name !== 'adminTierlists' || tierlistsMode.value !== 'all') return if (route.name !== 'adminTierlists' || tierlistsMode.value !== 'all') return
syncAdminRouteQuery({ topicId: topicId || undefined }) syncAdminRouteQuery({ topicId: topicId || undefined })
@@ -527,7 +527,7 @@ watch(
() => [selectedTemplate.value?.template?.id || '', selectedTemplate.value?.items?.length || 0, !!templateItemListEl.value], () => [selectedTemplate.value?.template?.id || '', selectedTemplate.value?.items?.length || 0, !!templateItemListEl.value],
([templateId, itemCount, hasListEl]) => { ([templateId, itemCount, hasListEl]) => {
if (!templateId || !itemCount || !hasListEl) return if (!templateId || !itemCount || !hasListEl) return
scheduleGameItemSortableSync() scheduleTemplateItemSortableSync()
} }
) )
@@ -572,7 +572,6 @@ function formatImageJobSourceCategory(category) {
case 'tierlists': case 'tierlists':
return '티어표 썸네일' return '티어표 썸네일'
case 'topics': case 'topics':
case 'games':
return '주제/템플릿 이미지' return '주제/템플릿 이미지'
case 'avatars': case 'avatars':
return '프로필 아바타' return '프로필 아바타'
@@ -716,10 +715,10 @@ async function cleanupMissingImageReferences() {
success.value = success.value =
`누락 참조를 정리했어요. ` + `누락 참조를 정리했어요. ` +
`아바타 ${result.clearedAvatars || 0}건, ` + `아바타 ${result.clearedAvatars || 0}건, ` +
`템플릿 썸네일 ${result.clearedGameThumbnails || 0}건, ` + `템플릿 썸네일 ${result.clearedTopicThumbnails || 0}건, ` +
`티어표 썸네일 ${result.clearedTierListThumbnails || 0}건, ` + `티어표 썸네일 ${result.clearedTierListThumbnails || 0}건, ` +
`요청 썸네일 ${result.clearedTemplateRequestThumbnails || 0}건, ` + `요청 썸네일 ${result.clearedTemplateRequestThumbnails || 0}건, ` +
`템플릿 아이템 ${result.deletedGameItems || 0}건, ` + `템플릿 아이템 ${result.deletedTopicItems || 0}건, ` +
`커스텀 아이템 ${result.deletedCustomItems || 0}` `커스텀 아이템 ${result.deletedCustomItems || 0}`
} catch (e) { } catch (e) {
error.value = '누락 이미지 참조 정리에 실패했어요.' error.value = '누락 이미지 참조 정리에 실패했어요.'
@@ -823,7 +822,7 @@ async function refreshAdminTierLists() {
try { try {
const data = await api.listAdminTierLists({ const data = await api.listAdminTierLists({
q: adminTierListQuery.value, q: adminTierListQuery.value,
topicId: adminTierListGameId.value, topicId: adminTierListTopicId.value,
page: adminTierListPage.value, page: adminTierListPage.value,
limit: adminTierListLimit.value, limit: adminTierListLimit.value,
}) })
@@ -840,7 +839,7 @@ async function refreshAdminTierLists() {
async function refreshAdminTierListStats() { async function refreshAdminTierListStats() {
if (!auth.user?.isAdmin) return if (!auth.user?.isAdmin) return
try { 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 = { adminTierListStats.value = {
total: data.total || 0, total: data.total || 0,
publicCount: data.publicCount || 0, publicCount: data.publicCount || 0,
@@ -920,7 +919,7 @@ const {
removeFeaturedTemplate, removeFeaturedTemplate,
moveFeaturedTemplate, moveFeaturedTemplate,
saveFeaturedOrder, saveFeaturedOrder,
} = useAdminFeaturedGames({ } = useAdminFeaturedTemplates({
api, api,
featuredListEl, featuredListEl,
featuredSortable, featuredSortable,
@@ -944,7 +943,7 @@ const {
clearItemFiles, clearItemFiles,
uploadItem, uploadItem,
saveTemplateItemOrder, saveTemplateItemOrder,
} = useAdminGameManager({ } = useAdminTemplateManager({
api, api,
toApiUrl, toApiUrl,
selectedTemplateId, selectedTemplateId,
@@ -1307,8 +1306,8 @@ function submitAdminTierListSearch() {
refreshAdminTierLists() refreshAdminTierLists()
} }
function setAdminTierListGameId(topicId) { function setAdminTierListTopicId(topicId) {
adminTierListGameId.value = topicId || '' adminTierListTopicId.value = topicId || ''
adminTierListPage.value = 1 adminTierListPage.value = 1
refreshAdminTierLists() refreshAdminTierLists()
} }
@@ -1328,7 +1327,7 @@ function closeTemplatePickerModal() {
async function chooseTemplateFromPicker(templateId) { async function chooseTemplateFromPicker(templateId) {
if (!templateId) return if (!templateId) return
if (templatePickerMode.value === 'tierlists-filter') { if (templatePickerMode.value === 'tierlists-filter') {
setAdminTierListGameId(templateId) setAdminTierListTopicId(templateId)
closeTemplatePickerModal() closeTemplatePickerModal()
return return
} }
@@ -1701,7 +1700,7 @@ function userAvatarFallback(user) {
:add-featured-template="addFeaturedTemplate" :add-featured-template="addFeaturedTemplate"
/> />
<AdminGamesSection <AdminTemplatesSection
v-else-if="activeTab === 'template-admin'" v-else-if="activeTab === 'template-admin'"
:active-template-request="activeTemplateRequest" :active-template-request="activeTemplateRequest"
:template-request-source-url="templateRequestSourceUrl" :template-request-source-url="templateRequestSourceUrl"
@@ -1740,7 +1739,7 @@ function userAvatarFallback(user) {
:remove-upload-draft="removeUploadDraft" :remove-upload-draft="removeUploadDraft"
:has-template-item-order-changes="hasTemplateItemOrderChanges" :has-template-item-order-changes="hasTemplateItemOrderChanges"
:save-template-item-order="saveTemplateItemOrder" :save-template-item-order="saveTemplateItemOrder"
:template-item-list-ref="setGameItemListRef" :template-item-list-ref="setTemplateItemListRef"
:save-template-item-label="saveTemplateItemLabel" :save-template-item-label="saveTemplateItemLabel"
:remove-template-item="removeTemplateItem" :remove-template-item="removeTemplateItem"
:selected-template-id="selectedTemplateId" :selected-template-id="selectedTemplateId"
@@ -1988,6 +1987,9 @@ function userAvatarFallback(user) {
<div class="customItemModal__body"> <div class="customItemModal__body">
<button class="customItemModal__close" type="button" @click="closeCustomItemModal">닫기</button> <button class="customItemModal__close" type="button" @click="closeCustomItemModal">닫기</button>
<div class="customItemModal__content"> <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 class="customItemModal__titleRow">
<div> <div>
<div class="customItemModal__title">{{ modalTargetCustomItem.label }}</div> <div class="customItemModal__title">{{ modalTargetCustomItem.label }}</div>
@@ -2047,34 +2049,34 @@ function userAvatarFallback(user) {
<option value="oldest">오래된순</option> <option value="oldest">오래된순</option>
</select> </select>
<button <button
v-if="templatePickerMode === 'tierlists-filter' && adminTierListGameId" v-if="templatePickerMode === 'tierlists-filter' && adminTierListTopicId"
class="btn btn--ghost" class="btn btn--ghost"
type="button" type="button"
@click="setAdminTierListGameId(''); closeTemplatePickerModal()" @click="setAdminTierListTopicId(''); closeTemplatePickerModal()"
> >
모든 주제 보기 모든 주제 보기
</button> </button>
</div> </div>
<div class="gamePickerModalList"> <div class="templatePickerModalList">
<button <button
v-for="template in filteredTemplatePickerTemplates" v-for="template in filteredTemplatePickerTemplates"
:key="template.id" :key="template.id"
class="adminGamePicker__item" class="adminTemplatePicker__item"
:class="{ :class="{
'adminGamePicker__item--active': templatePickerMode === 'tierlists-filter' 'adminTemplatePicker__item--active': templatePickerMode === 'tierlists-filter'
? adminTierListGameId === template.id ? adminTierListTopicId === template.id
: templatePickerMode === 'custom-item-target' : templatePickerMode === 'custom-item-target'
? customItemModalTargetTemplateId === template.id ? customItemModalTargetTemplateId === template.id
: selectedTemplateId === 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" type="button"
:disabled="templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id)" :disabled="templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id)"
@click="chooseTemplateFromPicker(template.id)" @click="chooseTemplateFromPicker(template.id)"
> >
<span class="adminGamePicker__name">{{ template.name }}</span> <span class="adminTemplatePicker__name">{{ template.name }}</span>
<span class="adminGamePicker__meta">{{ template.id }}</span> <span class="adminTemplatePicker__meta">{{ template.id }}</span>
<span v-if="templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id)" class="adminGamePicker__state">이미 추가됨</span> <span v-if="templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id)" class="adminTemplatePicker__state">이미 추가됨</span>
</button> </button>
<div v-if="!filteredTemplatePickerTemplates.length" class="hint hint--tight">검색 결과가 없어요.</div> <div v-if="!filteredTemplatePickerTemplates.length" class="hint hint--tight">검색 결과가 없어요.</div>
</div> </div>
@@ -2306,11 +2308,11 @@ function userAvatarFallback(user) {
<button class="btn btn--ghost" @click="submitAdminTierListSearch">검색</button> <button class="btn btn--ghost" @click="submitAdminTierListSearch">검색</button>
</div> </div>
<button class="btn btn--ghost" @click="openTemplatePickerModal('tierlists-filter')">주제 선택</button> <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__label">필터된 주제</div>
<div class="adminSelectionCard__title">{{ templates.find((template) => template.id === adminTierListGameId)?.name || adminTierListGameId }}</div> <div class="adminSelectionCard__title">{{ templates.find((template) => template.id === adminTierListTopicId)?.name || adminTierListTopicId }}</div>
<div class="adminSelectionCard__meta">{{ adminTierListGameId }}</div> <div class="adminSelectionCard__meta">{{ adminTierListTopicId }}</div>
<button class="btn btn--ghost btn--small" @click="setAdminTierListGameId('')">필터 해제</button> <button class="btn btn--ghost btn--small" @click="setAdminTierListTopicId('')">필터 해제</button>
</div> </div>
<select :value="adminTierListLimit" class="select" @change="changeAdminTierListLimit(Number($event.target.value))"> <select :value="adminTierListLimit" class="select" @change="changeAdminTierListLimit(Number($event.target.value))">
<option :value="50">50개씩 보기</option> <option :value="50">50개씩 보기</option>
@@ -2584,14 +2586,14 @@ function userAvatarFallback(user) {
font-weight: 800; font-weight: 800;
color: var(--theme-text); color: var(--theme-text);
} }
.adminUiScope .adminGamePicker { .adminUiScope .adminTemplatePicker {
display: grid; display: grid;
gap: 8px; gap: 8px;
max-height: 640px; max-height: 640px;
overflow: auto; overflow: auto;
padding-right: 4px; padding-right: 4px;
} }
.adminUiScope .adminGamePicker__item { .adminUiScope .adminTemplatePicker__item {
display: grid; display: grid;
/* gap: 2px; */ /* gap: 2px; */
padding: 11px 12px; padding: 11px 12px;
@@ -2602,32 +2604,32 @@ function userAvatarFallback(user) {
color: var(--theme-text); color: var(--theme-text);
cursor: pointer; cursor: pointer;
} }
.adminUiScope .adminGamePicker__item--active { .adminUiScope .adminTemplatePicker__item--active {
border-color: rgba(77, 127, 233, 0.58); border-color: rgba(77, 127, 233, 0.58);
background: rgba(77, 127, 233, 0.12); background: rgba(77, 127, 233, 0.12);
} }
.adminUiScope .adminGamePicker__item--disabled { .adminUiScope .adminTemplatePicker__item--disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.58; opacity: 0.58;
border-style: dashed; border-style: dashed;
} }
.adminUiScope .adminGamePicker__name { .adminUiScope .adminTemplatePicker__name {
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 800;
} }
.adminUiScope .adminGamePicker__meta { .adminUiScope .adminTemplatePicker__meta {
font-size: 11px; font-size: 11px;
color: var(--theme-text-soft); color: var(--theme-text-soft);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.adminUiScope .adminGamePicker__state { .adminUiScope .adminTemplatePicker__state {
margin-top: 4px; margin-top: 4px;
font-size: 11px; font-size: 11px;
color: var(--theme-text-faint); color: var(--theme-text-faint);
} }
.adminUiScope .gamePickerModalList { .adminUiScope .templatePickerModalList {
margin-top: 14px; margin-top: 14px;
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -2874,16 +2876,16 @@ function userAvatarFallback(user) {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px; gap: 16px;
} }
.adminUiScope .gameManagerGrid { .adminUiScope .templateManagerGrid {
margin-top: 14px; margin-top: 14px;
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px; gap: 16px;
} }
.adminUiScope .gameManagerGrid--single { .adminUiScope .templateManagerGrid--single {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.adminUiScope .gameManagerCard__body { .adminUiScope .templateManagerCard__body {
margin-top: 10px; margin-top: 10px;
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -3049,37 +3051,37 @@ function userAvatarFallback(user) {
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
.adminUiScope .selectedGame__name { .adminUiScope .selectedTemplate__name {
margin-top: 8px; margin-top: 8px;
font-size: 22px; font-size: 22px;
font-weight: 900; font-weight: 900;
} }
.adminUiScope .selectedGame__id { .adminUiScope .selectedTemplate__id {
margin-top: 6px; margin-top: 6px;
opacity: 0.72; opacity: 0.72;
word-break: break-all; word-break: break-all;
} }
.adminUiScope .gameSettingsCard { .adminUiScope .templateSettingsCard {
display: grid; display: grid;
grid-template-columns: minmax(220px, 300px) minmax(0, 1fr); grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
gap: 18px; gap: 18px;
align-items: center; align-items: center;
} }
.adminUiScope .gameSettingsCard__media { .adminUiScope .templateSettingsCard__media {
min-width: 0; min-width: 0;
} }
.adminUiScope .gameSettingsCard__body { .adminUiScope .templateSettingsCard__body {
display: grid; display: grid;
gap: 14px; gap: 14px;
align-content: center; align-content: center;
} }
.adminUiScope .gameSettingsCard__meta { .adminUiScope .templateSettingsCard__meta {
color: var(--theme-text-soft); color: var(--theme-text-soft);
font-size: 13px; font-size: 13px;
line-height: 1.5; line-height: 1.5;
word-break: break-all; word-break: break-all;
} }
.adminUiScope .gameSettingsCard__actions { .adminUiScope .templateSettingsCard__actions {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
@@ -3102,11 +3104,11 @@ function userAvatarFallback(user) {
.adminUiScope .selectedThumb--sidebar { .adminUiScope .selectedThumb--sidebar {
width: 100%; width: 100%;
} }
.adminUiScope .selectedGameSidebar__name { .adminUiScope .selectedTemplateSidebar__name {
font-size: 18px; font-size: 18px;
font-weight: 900; font-weight: 900;
} }
.adminUiScope .selectedGameSidebar__id { .adminUiScope .selectedTemplateSidebar__id {
font-size: 12px; font-size: 12px;
opacity: 0.68; opacity: 0.68;
word-break: break-all; word-break: break-all;
@@ -3518,6 +3520,19 @@ function userAvatarFallback(user) {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.18) transparent; 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__pickerPanel::-webkit-scrollbar,
.adminUiScope .customItemModal__content::-webkit-scrollbar { .adminUiScope .customItemModal__content::-webkit-scrollbar {
width: 8px; width: 8px;
@@ -4480,8 +4495,8 @@ function userAvatarFallback(user) {
} }
.adminUiScope .featuredOrderPanel, .adminUiScope .featuredOrderPanel,
.adminUiScope .section--topGrid, .adminUiScope .section--topGrid,
.adminUiScope .gameManagerGrid, .adminUiScope .templateManagerGrid,
.adminUiScope .gameSettingsCard, .adminUiScope .templateSettingsCard,
.adminUiScope .toolbar, .adminUiScope .toolbar,
.adminUiScope .itemComposer, .adminUiScope .itemComposer,
.adminUiScope .tierAdminCard, .adminUiScope .tierAdminCard,

View File

@@ -4,25 +4,20 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { api } from '../lib/api' import { api } from '../lib/api'
import { homePath, mePath } from '../lib/paths' import { homePath, mePath } from '../lib/paths'
import { useToast } from '../composables/useToast'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast()
const email = ref('') const email = ref('')
const nickname = ref('')
const password = ref('') const password = ref('')
const passwordConfirm = ref('') const passwordConfirm = ref('')
const mode = ref('login') const mode = ref('login')
const error = ref('') const error = ref('')
const hasUsers = ref(true) const hasUsers = ref(true)
const emailError = ref('')
watch(error, (message) => { const nicknameError = ref('')
if (!message) return
toast.error(message)
error.value = ''
})
const title = computed(() => (mode.value === 'signup' ? '회원가입' : '로그인')) const title = computed(() => (mode.value === 'signup' ? '회원가입' : '로그인'))
const description = computed(() => const description = computed(() =>
@@ -57,18 +52,59 @@ watch(
{ immediate: true } { 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() { async function submit() {
error.value = '' 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) { if (mode.value === 'signup' && password.value !== passwordConfirm.value) {
error.value = '비밀번호 확인이 일치하지 않아요.' error.value = '비밀번호 확인이 일치하지 않아요.'
return return
} }
try { 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) else await auth.login(email.value, password.value)
router.push(typeof route.query.redirect === 'string' ? route.query.redirect : mePath()) router.push(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
} catch (e) { } 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> </script>
@@ -102,9 +138,17 @@ async function submit() {
<label class="field"> <label class="field">
<span class="field__label">이메일</span> <span class="field__label">이메일</span>
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" maxlength="255" /> <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> <span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다. {{ email.length }}/255</span>
</label> </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"> <label class="field">
<span class="field__label">비밀번호</span> <span class="field__label">비밀번호</span>
<input <input
@@ -132,6 +176,7 @@ async function submit() {
</label> </label>
<div v-if="!hasUsers" class="roleBadge"> 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div> <div v-if="!hasUsers" class="roleBadge"> 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
<div v-if="error" class="authError">{{ error }}</div>
<div class="authActions"> <div class="authActions">
<button class="secondaryAction" type="button" @click="router.push(homePath())">취소</button> <button class="secondaryAction" type="button" @click="router.push(homePath())">취소</button>
@@ -244,6 +289,12 @@ async function submit() {
color: var(--theme-text-soft); color: var(--theme-text-soft);
} }
.field__error {
font-size: 12px;
color: #ff7b7b;
font-weight: 700;
}
.roleBadge { .roleBadge {
width: fit-content; width: fit-content;
padding: 6px 10px; padding: 6px 10px;
@@ -255,6 +306,16 @@ async function submit() {
font-weight: 700; 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 { .authActions {
display: flex; display: flex;
gap: 12px; gap: 12px;