Compare commits

...

1 Commits

Author SHA1 Message Date
85863b1b36 릴리스: v1.4.32 내부 이름층 topic/template 정리 마감 2026-04-02 21:50:36 +09:00
14 changed files with 125 additions and 114 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

@@ -67,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,
@@ -81,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,
@@ -292,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')
} }
@@ -316,8 +316,8 @@ 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')
} }
@@ -705,7 +705,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])
@@ -718,7 +718,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) {
@@ -735,12 +735,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) {
@@ -868,7 +868,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 <> ''"),
@@ -878,8 +878,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) {
@@ -921,7 +921,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 <> ''"),
@@ -931,8 +931,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) {
@@ -964,14 +964,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) {
@@ -1120,16 +1120,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 <> ''"),
@@ -1144,16 +1144,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()
@@ -1313,13 +1313,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) {
@@ -1521,7 +1521,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
@@ -1569,7 +1569,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, {
@@ -1596,7 +1596,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))
@@ -1619,7 +1619,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,
@@ -1995,12 +1995,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)
} }
@@ -2080,12 +2080,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)
} }

View File

@@ -128,7 +128,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 +469,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 +507,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 +531,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 +576,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 +600,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)
} }

View File

@@ -1,5 +1,9 @@
# 의사결정 이력 # 의사결정 이력
## 2026-04-02 v1.4.32
- 서비스 공개 전 마감 단계에서는 사용자 노출 텍스트만이 아니라 파일명·composable 이름·관리자 CSS 클래스·백엔드 헬퍼 함수명까지 같이 정리해 두는 편이 이후 유지보수 비용을 확실히 낮춘다고 판단했다.
- 이 시점부터는 `game`이 데이터 호환층도 아닌 단순 내부 이름으로 남아 있는 것조차 혼란을 만들 수 있으므로, 실제 기능을 바꾸지 않는 선에서 이름층까지 끝까지 정리해 코드 검색 결과 자체를 깨끗하게 만드는 방향으로 마감했다.
## 2026-04-02 v1.4.31 ## 2026-04-02 v1.4.31
- 서비스가 아직 외부 공개 전이고 예전 북마크/예전 데이터베이스를 이어갈 필요가 없다는 전제가 확인되었으므로, 남겨둔 호환층을 유지하는 것보다 지금 마감 시점에 완전히 제거해 구조를 단순화하는 편이 맞다고 판단했다. - 서비스가 아직 외부 공개 전이고 예전 북마크/예전 데이터베이스를 이어갈 필요가 없다는 전제가 확인되었으므로, 남겨둔 호환층을 유지하는 것보다 지금 마감 시점에 완전히 제거해 구조를 단순화하는 편이 맞다고 판단했다.
- 이 단계에서는 “기존 것도 읽어준다”보다 “현재 구조만 남긴다”가 더 중요한 목표가 되었으므로, redirect·legacy migration·`origin: 'game'` 허용까지 함께 정리해 실제 코드 검색에서 `game` 흔적을 0건으로 맞추는 방향으로 마감했다. - 이 단계에서는 “기존 것도 읽어준다”보다 “현재 구조만 남긴다”가 더 중요한 목표가 되었으므로, redirect·legacy migration·`origin: 'game'` 허용까지 함께 정리해 실제 코드 검색에서 `game` 흔적을 0건으로 맞추는 방향으로 마감했다.

View File

@@ -1,6 +1,8 @@
# 할 일 및 이슈 # 할 일 및 이슈
## 단기 확인 ## 단기 확인
- `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 기준 흐름만 집중적으로 확인하면 된다. - `v1.4.31`에서 `/games` redirect와 legacy DB 마이그레이션까지 제거했으므로, 실제 QA에서는 오직 현재 주소(`/topics`, `/admin/templates`)와 새 DB 기준 흐름만 집중적으로 확인하면 된다.
- 현재 `backend/src`, `frontend/src` 기준 `game` 검색은 0건이므로, 이후 남는 확인 작업은 기능 QA와 운영 환경 배포 점검 쪽에만 집중한다. - 현재 `backend/src`, `frontend/src` 기준 `game` 검색은 0건이므로, 이후 남는 확인 작업은 기능 QA와 운영 환경 배포 점검 쪽에만 집중한다.
- `v1.4.30`에서 빈 로컬 MariaDB 재초기화 검증까지 통과했으므로, 다음 실제 QA에서는 “기존 데이터가 있는 환경”에서 `ensureData()`가 저장 티어표와 템플릿 요청 스냅샷의 legacy origin을 정상 정규화하는지만 추가 확인하면 된다. - `v1.4.30`에서 빈 로컬 MariaDB 재초기화 검증까지 통과했으므로, 다음 실제 QA에서는 “기존 데이터가 있는 환경”에서 `ensureData()`가 저장 티어표와 템플릿 요청 스냅샷의 legacy origin을 정상 정규화하는지만 추가 확인하면 된다.

View File

@@ -1,5 +1,10 @@
# 업데이트 로그 # 업데이트 로그
## 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 ## 2026-04-02 v1.4.31
- 서비스가 아직 공개 전이고 예전 링크/예전 DB를 이어갈 필요가 없다는 전제에 맞춰, `/games` redirect와 관리자 `/admin/games` redirect, DB 레거시 마이그레이션 코드, legacy origin 정규화 코드를 실제로 제거했다. - 서비스가 아직 공개 전이고 예전 링크/예전 DB를 이어갈 필요가 없다는 전제에 맞춰, `/games` redirect와 관리자 `/admin/games` redirect, DB 레거시 마이그레이션 코드, legacy origin 정규화 코드를 실제로 제거했다.
- 티어표 저장/request schema도 이제 `origin: 'template' | 'custom'`만 받도록 정리했고, 관리자 최근 최적화 작업 분류 fallback에 남아 있던 `games` 처리도 걷어냈다. - 티어표 저장/request schema도 이제 `origin: 'template' | 'custom'`만 받도록 정리했고, 관리자 최근 최적화 작업 분류 fallback에 남아 있던 `games` 처리도 걷어냈다.

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"
@@ -132,15 +132,15 @@ function setThumbFileElement(el) {
</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 +215,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

@@ -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,7 +16,7 @@ export function createRouter() {
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ path: '/', name: 'home', component: HomeView }, { path: '/', name: 'home', component: HomeView },
{ 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/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 },

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()
} }
) )
@@ -715,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 = '누락 이미지 참조 정리에 실패했어요.'
@@ -822,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,
}) })
@@ -839,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,
@@ -919,7 +919,7 @@ const {
removeFeaturedTemplate, removeFeaturedTemplate,
moveFeaturedTemplate, moveFeaturedTemplate,
saveFeaturedOrder, saveFeaturedOrder,
} = useAdminFeaturedGames({ } = useAdminFeaturedTemplates({
api, api,
featuredListEl, featuredListEl,
featuredSortable, featuredSortable,
@@ -943,7 +943,7 @@ const {
clearItemFiles, clearItemFiles,
uploadItem, uploadItem,
saveTemplateItemOrder, saveTemplateItemOrder,
} = useAdminGameManager({ } = useAdminTemplateManager({
api, api,
toApiUrl, toApiUrl,
selectedTemplateId, selectedTemplateId,
@@ -1306,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()
} }
@@ -1327,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
} }
@@ -1700,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"
@@ -1739,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"
@@ -2046,34 +2046,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>
@@ -2305,11 +2305,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>
@@ -2583,14 +2583,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;
@@ -2601,32 +2601,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;
@@ -2873,16 +2873,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;
@@ -3048,37 +3048,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;
@@ -3101,11 +3101,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;
@@ -4479,8 +4479,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,