Compare commits

...

4 Commits

24 changed files with 645 additions and 300 deletions

View File

@@ -8,6 +8,7 @@
"start": "APP_ORIGIN=http://localhost:5173 DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js",
"images:backfill": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/backfill-legacy-image-assets.js",
"images:migrate-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-legacy-uploads-to-assets.js",
"images:shard-assets": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-flat-assets-to-sharded.js",
"uploads:cleanup-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/cleanup-unreferenced-legacy-uploads.js"
},
"keywords": [],

View File

@@ -0,0 +1,102 @@
const fs = require('fs/promises')
const path = require('path')
const {
ensureData,
closePool,
updateImageAssetSrc,
replaceUploadSourceReferences,
} = require('../src/db')
const BACKEND_ROOT = path.join(__dirname, '..')
const ASSETS_ROOT = path.join(BACKEND_ROOT, 'uploads', 'assets')
const FLAT_ASSET_PATTERN = /^\/uploads\/assets\/[^/]+$/
function getShardedAssetSrc(src) {
const filename = path.basename(src || '')
const shardDirectory = filename.slice(0, 2)
if (!filename || shardDirectory.length < 2) return ''
return `/uploads/assets/${shardDirectory}/${filename}`
}
async function moveAssetFile(fromSrc, toSrc) {
const fromPath = path.join(BACKEND_ROOT, fromSrc.replace(/^\//, ''))
const toPath = path.join(BACKEND_ROOT, toSrc.replace(/^\//, ''))
await fs.mkdir(path.dirname(toPath), { recursive: true })
try {
await fs.rename(fromPath, toPath)
return 'moved'
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
try {
await fs.access(toPath)
return 'already_moved'
} catch (error) {
if (error?.code === 'ENOENT') return 'missing'
throw error
}
}
async function main() {
await ensureData()
let dirEntries = []
try {
dirEntries = await fs.readdir(ASSETS_ROOT, { withFileTypes: true })
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
const flatAssets = dirEntries
.filter((entry) => entry.isFile())
.map((entry) => ({ src: `/uploads/assets/${entry.name}` }))
.filter((asset) => FLAT_ASSET_PATTERN.test(asset.src || ''))
const summary = {
scanned: flatAssets.length,
migrated: 0,
alreadyMoved: 0,
skipped: 0,
missingFiles: 0,
failed: 0,
updatedRows: 0,
}
for (const asset of flatAssets) {
const nextSrc = getShardedAssetSrc(asset.src)
if (!nextSrc) {
summary.skipped += 1
continue
}
try {
const moveStatus = await moveAssetFile(asset.src, nextSrc)
if (moveStatus === 'missing') {
summary.missingFiles += 1
continue
}
await updateImageAssetSrc({ fromSrc: asset.src, toSrc: nextSrc })
const replaced = await replaceUploadSourceReferences({ fromSrc: asset.src, toSrc: nextSrc })
summary.updatedRows += Number(replaced.updatedRows || 0)
if (moveStatus === 'already_moved') summary.alreadyMoved += 1
else summary.migrated += 1
} catch (error) {
summary.failed += 1
console.error('[migrate-flat-assets-to-sharded] failed:', asset.src, error?.message || error)
}
}
console.log(JSON.stringify(summary, null, 2))
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(async () => {
await closePool()
})

View File

@@ -1,6 +1,7 @@
const fs = require('fs/promises')
const path = require('path')
const mysql = require('mysql2/promise')
const { nanoid } = require('nanoid')
const DB_HOST = process.env.DB_HOST || '127.0.0.1'
const DB_PORT = process.env.DB_PORT ? Number(process.env.DB_PORT) : 3306
@@ -9,6 +10,7 @@ const DB_PASSWORD = process.env.DB_PASSWORD || ''
const DB_NAME = process.env.DB_NAME || 'tier_cursor'
const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10
const FREEFORM_TOPIC_ID = 'freeform'
const TOPIC_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
let poolPromise = null
let initPromise = null
@@ -30,6 +32,20 @@ function serializeJson(value) {
return JSON.stringify(value || [])
}
function normalizeTopicSlug(value) {
return String(value || '').trim().toLowerCase()
}
function assertTopicSlug(slug) {
const normalized = normalizeTopicSlug(slug)
if (!normalized || normalized.length > 120 || !TOPIC_SLUG_PATTERN.test(normalized)) {
const err = new Error('topic_slug_invalid')
err.code = 'TOPIC_SLUG_INVALID'
throw err
}
return normalized
}
function collectUploadSrcsFromItems(items, bucket) {
for (const item of items || []) {
if (typeof item?.src === 'string' && item.src.startsWith('/uploads/')) {
@@ -75,8 +91,10 @@ function mapTopicRow(row) {
if (!row) return null
return {
id: row.id,
slug: row.slug || row.id,
name: row.name,
topicId: row.id,
topicSlug: row.slug || row.id,
topicName: row.name,
thumbnailSrc: row.thumbnail_src || '',
isPublic: row.is_public == null ? true : !!row.is_public,
@@ -138,6 +156,7 @@ function mapTierListRow(row) {
authorAccountName: getUserAccountName(row),
authorAvatarSrc: row.avatar_src || '',
topicId: row.topic_id,
topicSlug: row.topic_slug || row.topic_id,
topicName: row.topic_name || '',
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
@@ -169,11 +188,13 @@ function mapTemplateRequestRow(row) {
requesterAvatarSrc: row.requester_avatar_src || '',
sourceTierListId: row.source_tierlist_id || '',
sourceTopicId: row.source_topic_id,
sourceTopicSlug: row.source_topic_slug || row.source_topic_id,
sourceTopicName: row.source_topic_name || '',
sourceTierListTitle: row.title_snapshot || '',
sourceDescription: row.description_snapshot || '',
thumbnailSrc: row.thumbnail_src_snapshot || '',
targetTopicId: row.target_topic_id || '',
targetTopicSlug: row.target_topic_slug || row.target_topic_id || '',
targetTopicName: row.target_topic_name || '',
status: row.status,
items: parseJson(row.items_json, []),
@@ -254,32 +275,6 @@ async function query(sql, params = []) {
return rows
}
async function tableExists(name) {
const rows = await query(
`
SELECT TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
LIMIT 1
`,
[DB_NAME, name]
)
return rows.length > 0
}
async function columnExists(tableName, columnName) {
const rows = await query(
`
SELECT COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?
LIMIT 1
`,
[DB_NAME, tableName, columnName]
)
return rows.length > 0
}
async function closePool() {
if (!poolPromise) return
const pool = await poolPromise
@@ -305,18 +300,6 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const userEmailVerifiedColumns = await query("SHOW COLUMNS FROM users LIKE 'email_verified'")
if (!userEmailVerifiedColumns.length) {
await query('ALTER TABLE users ADD COLUMN email_verified TINYINT(1) NOT NULL DEFAULT 1 AFTER password_hash')
await query('UPDATE users SET email_verified = 1 WHERE email_verified IS NULL')
}
const userLastLoginColumns = await query("SHOW COLUMNS FROM users LIKE 'last_login_at'")
if (!userLastLoginColumns.length) {
await query('ALTER TABLE users ADD COLUMN last_login_at BIGINT NOT NULL DEFAULT 0 AFTER avatar_src')
await query('UPDATE users SET last_login_at = created_at WHERE last_login_at = 0')
}
await query(`
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id VARCHAR(64) PRIMARY KEY,
@@ -348,25 +331,16 @@ async function ensureSchema() {
await query(`
CREATE TABLE IF NOT EXISTS topics (
id VARCHAR(120) PRIMARY KEY,
slug VARCHAR(120) NOT NULL,
name VARCHAR(120) NOT NULL,
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
is_public TINYINT(1) NOT NULL DEFAULT 1,
display_rank INT NULL DEFAULT NULL,
created_at BIGINT NOT NULL
created_at BIGINT NOT NULL,
UNIQUE KEY uq_topics_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const topicIsPublicColumns = await query("SHOW COLUMNS FROM topics LIKE 'is_public'")
if (!topicIsPublicColumns.length) {
await query('ALTER TABLE topics ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER thumbnail_src')
await query('UPDATE topics SET is_public = 1 WHERE is_public IS NULL')
}
const displayRankColumns = await query("SHOW COLUMNS FROM topics LIKE 'display_rank'")
if (!displayRankColumns.length) {
await query('ALTER TABLE topics ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src')
}
await query(`
CREATE TABLE IF NOT EXISTS topic_items (
id VARCHAR(64) PRIMARY KEY,
@@ -380,11 +354,6 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const topicItemDisplayOrderColumns = await query("SHOW COLUMNS FROM topic_items LIKE 'display_order'")
if (!topicItemDisplayOrderColumns.length) {
await query('ALTER TABLE topic_items ADD COLUMN display_order INT NULL DEFAULT NULL AFTER label')
}
await query(`
CREATE TABLE IF NOT EXISTS custom_items (
id VARCHAR(64) PRIMARY KEY,
@@ -479,11 +448,6 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const imageAssetLabelColumns = await query("SHOW COLUMNS FROM image_assets LIKE 'label_override'")
if (!imageAssetLabelColumns.length) {
await query("ALTER TABLE image_assets ADD COLUMN label_override VARCHAR(120) NOT NULL DEFAULT '' AFTER src")
}
await query(`
CREATE TABLE IF NOT EXISTS image_optimization_jobs (
id VARCHAR(64) PRIMARY KEY,
@@ -524,125 +488,14 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const templateRequestSourceTierListColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_tierlist_id'")
if (templateRequestSourceTierListColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE template_requests MODIFY source_tierlist_id VARCHAR(64) NULL')
}
const templateRequestTypeColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'request_type'")
if (!templateRequestTypeColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN request_type VARCHAR(20) NOT NULL DEFAULT 'create' AFTER id")
}
const hasSourceTopicId = await columnExists('template_requests', 'source_topic_id')
if (!hasSourceTopicId) {
await query("ALTER TABLE template_requests ADD COLUMN source_topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id")
}
const hasTargetTopicId = await columnExists('template_requests', 'target_topic_id')
if (!hasTargetTopicId) {
await query("ALTER TABLE template_requests ADD COLUMN target_topic_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_topic_id")
}
const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'")
if (!templateRequestStatusColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending' AFTER target_topic_id")
}
const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'")
if (!templateRequestGroupsColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN groups_json LONGTEXT NOT NULL AFTER items_json")
}
const templateRequestBoardItemsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'board_items_json'")
if (!templateRequestBoardItemsColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN board_items_json LONGTEXT NOT NULL AFTER groups_json")
}
const templateRequestShowNamesColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'show_character_names_snapshot'")
if (!templateRequestShowNamesColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN show_character_names_snapshot TINYINT(1) NOT NULL DEFAULT 0 AFTER board_items_json")
}
const tierListThumbnailColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'thumbnail_src'")
if (!tierListThumbnailColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
}
const tierListTopicIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'topic_id'")
if (!tierListTopicIdColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER author_id")
}
const tierListShowNamesColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'show_character_names'")
if (!tierListShowNamesColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
}
const tierListFeaturedColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'is_featured'")
if (!tierListFeaturedColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN is_featured TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
}
const tierListFeaturedAtColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'featured_at'")
if (!tierListFeaturedAtColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN featured_at BIGINT NOT NULL DEFAULT 0 AFTER is_featured")
}
const tierListFeaturedByColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'featured_by'")
if (!tierListFeaturedByColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN featured_by VARCHAR(64) NOT NULL DEFAULT '' AFTER featured_at")
}
const tierListIconSizeColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'icon_size'")
if (!tierListIconSizeColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN icon_size INT NOT NULL DEFAULT 80 AFTER show_character_names")
}
const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'")
if (!tierListSourceIdColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER icon_size")
} else if (tierListSourceIdColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE tierlists MODIFY source_tierlist_id VARCHAR(64) NULL DEFAULT NULL')
}
const tierListSourceTitleColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_title'")
if (!tierListSourceTitleColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN source_snapshot_title VARCHAR(120) NOT NULL DEFAULT '' AFTER source_tierlist_id")
}
const tierListSourceAuthorColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_author'")
if (!tierListSourceAuthorColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '' AFTER source_snapshot_title")
}
await query(
`
INSERT INTO topics (id, name, thumbnail_src, created_at)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name)
INSERT INTO topics (id, slug, name, thumbnail_src, is_public, created_at)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE slug = VALUES(slug), name = VALUES(name), is_public = VALUES(is_public)
`,
[FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', now()]
[FREEFORM_TOPIC_ID, FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', 1, now()]
)
const countRows = await query('SELECT COUNT(*) AS count FROM topics')
if (Number(countRows[0]?.count || 0) <= 1) {
const createdAt = now()
await query(
`
INSERT INTO topics (id, name, thumbnail_src, created_at)
VALUES
(?, ?, ?, ?),
(?, ?, ?, ?)
`,
['example-topic', '예시 주제', '', createdAt, 'another-topic', '다른 예시 주제', '', createdAt]
)
await query(
`
INSERT INTO topic_items (id, topic_id, src, label, created_at)
VALUES
(?, ?, ?, ?, ?),
(?, ?, ?, ?, ?)
`,
[
'img-1',
'example-topic',
'/uploads/seeds/example1.png',
'샘플 1',
createdAt,
'img-2',
'example-topic',
'/uploads/seeds/example2.png',
'샘플 2',
createdAt,
]
)
}
})()
return initPromise
@@ -954,7 +807,7 @@ async function listTopics(currentUserId = '', options = {}) {
const includePrivate = !!options.includePrivate
const rows = await query(
`
SELECT id, name, thumbnail_src, is_public, display_rank, created_at
SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at
FROM topics
WHERE id <> ?
${includePrivate ? '' : 'AND is_public = 1'}
@@ -978,7 +831,39 @@ async function listTopics(currentUserId = '', options = {}) {
}
async function findTopicById(id) {
const rows = await query('SELECT id, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1', [id])
const rows = await query(
'SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1',
[id]
)
return mapTopicRow(rows[0])
}
async function findTopicBySlug(slug) {
const normalizedSlug = normalizeTopicSlug(slug)
if (!normalizedSlug) return null
const rows = await query(
'SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE slug = ? LIMIT 1',
[normalizedSlug]
)
return mapTopicRow(rows[0])
}
async function findTopicByIdentifier(topicRef) {
const rawRef = String(topicRef || '').trim()
if (!rawRef) return null
if (rawRef === FREEFORM_TOPIC_ID) return findTopicById(FREEFORM_TOPIC_ID)
const rows = await query(
`
SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at
FROM topics
WHERE id = ? OR slug = ?
ORDER BY
CASE WHEN id = ? THEN 0 ELSE 1 END ASC
LIMIT 1
`,
[rawRef, normalizeTopicSlug(rawRef), rawRef]
)
return mapTopicRow(rows[0])
}
@@ -1004,23 +889,37 @@ async function findTopicItemById(itemId) {
return mapTopicItemRow(rows[0])
}
async function getTopicDetail(topicId) {
const topic = await findTopicById(topicId)
async function getTopicDetail(topicRef) {
const topic = await findTopicByIdentifier(topicRef)
if (!topic) return null
const items = await listTopicItems(topicId)
const items = await listTopicItems(topic.id)
return { topic, template: topic, items }
}
async function createTopic({ id, name, isPublic = true }) {
await query('INSERT INTO topics (id, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
id,
async function createTopic({ slug, name, isPublic = true }) {
const topicId = nanoid()
const topicSlug = assertTopicSlug(slug)
await query('INSERT INTO topics (id, slug, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [
topicId,
topicSlug,
name,
'',
isPublic ? 1 : 0,
null,
now(),
])
return findTopicById(id)
return findTopicById(topicId)
}
async function updateTopicMeta(topicId, { slug, name, isPublic }) {
const topicSlug = assertTopicSlug(slug)
await query('UPDATE topics SET slug = ?, name = ?, is_public = ? WHERE id = ?', [
topicSlug,
name,
isPublic ? 1 : 0,
topicId,
])
return findTopicById(topicId)
}
async function updateTopicThumbnail(topicId, thumbnailSrc) {
@@ -1300,6 +1199,12 @@ async function findImageAssetById(id) {
return mapImageAssetRow(rows[0])
}
async function updateImageAssetSrc({ fromSrc, toSrc }) {
if (!fromSrc || !toSrc || fromSrc === toSrc) return null
await query('UPDATE image_assets SET src = ? WHERE src = ?', [toSrc, fromSrc])
return findImageAssetBySrc(toSrc)
}
async function getReferencedUploadFootprint() {
const [referencedSrcs, assets] = await Promise.all([listReferencedUploadSources(), listImageAssets()])
const assetMap = new Map(assets.map((asset) => [asset.src, asset]))
@@ -1829,6 +1734,32 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
getCustomItemUsageMeta(),
])
const [userAvatarRows, topicThumbnailRows, tierListThumbnailRows, templateRequestThumbnailRows] = await Promise.all([
query("SELECT avatar_src AS src FROM users WHERE avatar_src <> ''"),
query("SELECT thumbnail_src AS src FROM topics WHERE thumbnail_src <> ''"),
query("SELECT thumbnail_src AS src FROM tierlists WHERE thumbnail_src <> ''"),
query("SELECT thumbnail_src_snapshot AS src FROM template_requests WHERE thumbnail_src_snapshot <> ''"),
])
const avatarSrcSet = new Set(userAvatarRows.map((row) => row.src).filter(Boolean))
const thumbnailSrcSet = new Set([
...topicThumbnailRows.map((row) => row.src).filter(Boolean),
...tierListThumbnailRows.map((row) => row.src).filter(Boolean),
...templateRequestThumbnailRows.map((row) => row.src).filter(Boolean),
])
const resolveLibraryAssetKind = (src) => {
if (avatarSrcSet.has(src)) return 'avatar'
if (thumbnailSrcSet.has(src)) return 'thumbnail'
return getAssetLibraryKind(src)
}
const resolveLibraryAssetLabel = (src) => {
if (avatarSrcSet.has(src)) return '프로필 아바타'
if (thumbnailSrcSet.has(src)) return '썸네일 이미지'
return getAssetLibrarySourceLabel(src)
}
const templateLinkedBySrc = new Map()
topicItemRows.forEach((row) => {
if (!row?.src) return
@@ -1851,6 +1782,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerEmail: row.email,
usageCount: usageMeta.usageMap.get(row.id) || 0,
linkedTemplates,
assetKind: resolveLibraryAssetKind(row.src),
sourceType: 'user',
sourceLabel: '사용자 아이템',
canDelete: true,
@@ -1873,8 +1805,8 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
usageCount: 0,
linkedTemplates: [],
sourceType: 'asset',
sourceLabel: getAssetLibrarySourceLabel(row.src),
assetKind: getAssetLibraryKind(row.src),
sourceLabel: resolveLibraryAssetLabel(row.src),
assetKind: resolveLibraryAssetKind(row.src),
canDelete: true,
sourceTopicId: '',
sourceTopicName: '',
@@ -1891,6 +1823,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerEmail: '',
usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size,
linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
assetKind: resolveLibraryAssetKind(row.src),
sourceType: 'template',
sourceLabel: '템플릿 아이템',
canDelete: true,
@@ -1958,9 +1891,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
case 'asset':
return item.sourceType === 'asset' || !!item.isAssetLibraryItem
case 'thumbnail':
return item.sourceType === 'asset' && item.assetKind === 'thumbnail'
return item.assetKind === 'thumbnail'
case 'avatar':
return item.sourceType === 'asset' && item.assetKind === 'avatar'
return item.assetKind === 'avatar'
case 'library':
return item.sourceType === 'user' || (item.sourceType === 'template' && !item.isAssetLibraryItem)
case 'unused-user':
@@ -2072,8 +2005,15 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
const params = []
let whereClause = 'WHERE t.is_public = 1'
if (topicId) {
const topic = await findTopicByIdentifier(topicId)
if (!topic) {
return {
featuredTierLists: [],
tierLists: [],
}
}
whereClause += ' AND t.topic_id = ?'
params.push(topicId)
params.push(topic.id)
}
if ((queryText || '').trim()) {
const search = `%${queryText.trim()}%`
@@ -2086,6 +2026,8 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
SELECT
t.id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
t.is_featured,
@@ -2099,6 +2041,7 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
u.avatar_src
FROM tierlists t
INNER JOIN users u ON u.id = t.author_id
INNER JOIN topics tp ON tp.id = t.topic_id
${whereClause}
ORDER BY t.is_featured DESC, t.featured_at DESC, t.updated_at DESC
LIMIT 200
@@ -2109,6 +2052,8 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
const tierLists = rows.map((row) => ({
id: row.id,
topicId: row.topic_id,
topicSlug: row.topic_slug || row.topic_id,
topicName: row.topic_name || '',
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
isFeatured: !!row.is_featured,
@@ -2168,6 +2113,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
t.id,
t.author_id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
@@ -2218,6 +2164,8 @@ async function listUserTierLists(userId) {
SELECT
t.id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
t.created_at,
@@ -2228,6 +2176,7 @@ async function listUserTierLists(userId) {
u.avatar_src
FROM tierlists t
INNER JOIN users u ON u.id = t.author_id
INNER JOIN topics tp ON tp.id = t.topic_id
WHERE t.author_id = ?
ORDER BY updated_at DESC
`,
@@ -2237,6 +2186,8 @@ async function listUserTierLists(userId) {
const tierLists = rows.map((row) => ({
id: row.id,
topicId: row.topic_id,
topicSlug: row.topic_slug || row.topic_id,
topicName: row.topic_name || '',
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
createdAt: Number(row.created_at),
@@ -2335,6 +2286,7 @@ async function listPublicTierListsByAuthor(authorId, currentUserId = '', queryTe
SELECT
t.id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
@@ -2361,6 +2313,7 @@ async function listPublicTierListsByAuthor(authorId, currentUserId = '', queryTe
const tierLists = rows.map((row) => ({
id: row.id,
topicId: row.topic_id,
topicSlug: row.topic_slug || row.topic_id,
topicName: row.topic_name || '',
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
@@ -2396,6 +2349,7 @@ async function listFollowingTierLists(userId, queryText = '') {
SELECT
t.id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
@@ -2424,6 +2378,7 @@ async function listFollowingTierLists(userId, queryText = '') {
const tierLists = rows.map((row) => ({
id: row.id,
topicId: row.topic_id,
topicSlug: row.topic_slug || row.topic_id,
topicName: row.topic_name || '',
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
@@ -2502,11 +2457,12 @@ async function listAdminTierLists({
whereParts.push(`(
t.title LIKE ?
OR tp.name LIKE ?
OR tp.slug LIKE ?
OR tp.id LIKE ?
OR u.email LIKE ?
OR u.nickname LIKE ?
)`)
params.push(search, search, search, search, search)
params.push(search, search, search, search, search, search)
}
const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
@@ -2517,6 +2473,7 @@ async function listAdminTierLists({
t.id,
t.author_id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
@@ -2614,11 +2571,12 @@ async function summarizeAdminTierLists({ queryText = '', topicId = '', minFavori
whereParts.push(`(
t.title LIKE ?
OR tp.name LIKE ?
OR tp.slug LIKE ?
OR tp.id LIKE ?
OR u.email LIKE ?
OR u.nickname LIKE ?
)`)
params.push(search, search, search, search, search)
params.push(search, search, search, search, search, search)
}
const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
@@ -2659,6 +2617,7 @@ async function findTierListById(id, currentUserId = '') {
t.id,
t.author_id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
@@ -2797,7 +2756,9 @@ async function findTemplateRequestById(id) {
u.nickname,
u.email,
u.avatar_src AS requester_avatar_src,
sg.slug AS source_topic_slug,
sg.name AS source_topic_name,
tg.slug AS target_topic_slug,
tg.name AS target_topic_name
FROM template_requests tr
INNER JOIN users u ON u.id = tr.requester_id
@@ -2839,7 +2800,9 @@ async function listAdminTemplateRequests({ status = 'pending', statuses = [] } =
u.nickname,
u.email,
u.avatar_src AS requester_avatar_src,
sg.slug AS source_topic_slug,
sg.name AS source_topic_name,
tg.slug AS target_topic_slug,
tg.name AS target_topic_name
FROM template_requests tr
INNER JOIN users u ON u.id = tr.requester_id
@@ -3050,15 +3013,19 @@ module.exports = {
adminDeleteUser,
listTopics,
findTopicById,
findTopicBySlug,
findTopicByIdentifier,
listTopicItems,
findTopicItemById,
getTopicDetail,
createTopic,
updateTopicMeta,
updateTopicThumbnail,
updateTopicVisibility,
findImageAssetByHash,
findImageAssetBySrc,
findImageAssetById,
updateImageAssetSrc,
createImageAsset,
createImageOptimizationJob,
findImageOptimizationJobById,
@@ -3066,6 +3033,7 @@ module.exports = {
listRecentImageOptimizationJobs,
listUnusedImageAssets,
deleteImageAssets,
listImageAssets,
listReferencedUploadSources,
listReferencedUploadUsage,
replaceUploadSourceReferences,

View File

@@ -75,10 +75,13 @@ async function optimizeAndPersist({ file, width, height, fit, quality }) {
}
}
const filename = nanoid() + '.webp'
const absoluteDir = path.join(UPLOAD_ROOT, OPTIMIZED_DIR)
const basename = nanoid()
const shardDirectory = basename.slice(0, 2)
const filename = basename + '.webp'
const relativeDir = path.join(OPTIMIZED_DIR, shardDirectory)
const absoluteDir = path.join(UPLOAD_ROOT, relativeDir)
const absolutePath = path.join(absoluteDir, filename)
const src = '/uploads/' + OPTIMIZED_DIR + '/' + filename
const src = '/uploads/' + relativeDir.split(path.sep).join('/') + '/' + filename
await fs.mkdir(absoluteDir, { recursive: true })
await fs.writeFile(absolutePath, data)

View File

@@ -10,13 +10,14 @@ const {
findUserByEmail,
findUserByNickname,
findTopicById,
findTopicBySlug,
findTopicItemById,
listTopicItems,
findImageAssetById,
createTopic,
listTopics,
updateTopicMeta,
updateTopicThumbnail,
updateTopicVisibility,
createTopicItem,
updateTopicItemLabel,
updateTopicItemDisplayOrder,
@@ -119,16 +120,23 @@ function canManageAdminRole(actingUser, primaryAdmin) {
router.post('/templates', requireAdmin, async (req, res) => {
const schema = z.object({
id: z.string().min(1),
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
name: z.string().min(1).max(60),
isPublic: z.boolean().optional().default(false),
thumbnailSrc: z.string().max(255).optional().default(''),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findTopicById(parsed.data.id)
if (exists) return res.status(409).json({ error: 'topic_id_taken' })
const template = await createTopic({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic })
const exists = await findTopicBySlug(parsed.data.slug)
if (exists) return res.status(409).json({ error: 'topic_slug_taken' })
let template
try {
template = await createTopic({ slug: parsed.data.slug, name: parsed.data.name, isPublic: parsed.data.isPublic })
} catch (error) {
if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' })
if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' })
throw error
}
if (parsed.data.thumbnailSrc) {
const copiedThumb = await copyUploadIntoTopicAsset(parsed.data.thumbnailSrc)
await updateTopicThumbnail(template.id, copiedThumb)
@@ -139,7 +147,9 @@ router.post('/templates', requireAdmin, async (req, res) => {
router.patch('/templates/:templateId', requireAdmin, async (req, res) => {
const schema = z.object({
isPublic: z.boolean(),
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
name: z.string().trim().min(1).max(60).optional(),
isPublic: z.boolean().optional(),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -148,8 +158,21 @@ router.patch('/templates/:templateId', requireAdmin, async (req, res) => {
const template = await findTopicById(templateId)
if (!template) return res.status(404).json({ error: 'not_found' })
const updated = await updateTopicVisibility(template.id, parsed.data.isPublic)
res.json({ template: updated })
try {
const updated =
typeof parsed.data.name === 'string' || typeof parsed.data.slug === 'string' || typeof parsed.data.isPublic === 'boolean'
? await updateTopicMeta(template.id, {
slug: parsed.data.slug || template.slug,
name: parsed.data.name || template.name,
isPublic: typeof parsed.data.isPublic === 'boolean' ? parsed.data.isPublic : template.isPublic,
})
: await findTopicById(template.id)
return res.json({ template: updated })
} catch (error) {
if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' })
if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' })
throw error
}
})
router.patch('/templates/display-order', requireAdmin, async (req, res) => {
@@ -632,11 +655,11 @@ function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}
})
}
async function createTemplateFromTierList({ tierList, templateId, templateName }) {
await createTopic({ id: templateId, name: templateName, isPublic: false })
async function createTemplateFromTierList({ tierList, templateSlug, templateName }) {
const template = await createTopic({ slug: templateSlug, name: templateName, isPublic: false })
if (tierList.thumbnailSrc) {
const copiedThumb = await copyUploadIntoTopicAsset(tierList.thumbnailSrc)
await updateTopicThumbnail(templateId, copiedThumb)
await updateTopicThumbnail(template.id, copiedThumb)
}
const createdItems = []
@@ -645,30 +668,30 @@ async function createTemplateFromTierList({ tierList, templateId, templateName }
createdItems.push(
await createTopicItem({
id: nanoid(),
topicId: templateId,
topicId: template.id,
src: copiedSrc,
label: item.label,
})
)
}
return { template: await findTopicById(templateId), items: createdItems }
return { template: await findTopicById(template.id), items: createdItems }
}
async function createTemplateFromRequest({ templateRequest, templateId, templateName }) {
await createTopic({ id: templateId, name: templateName, isPublic: false })
async function createTemplateFromRequest({ templateRequest, templateSlug, templateName }) {
const template = await createTopic({ slug: templateSlug, name: templateName, isPublic: false })
if (templateRequest.thumbnailSrc) {
const copiedThumb = await copyUploadIntoTopicAsset(templateRequest.thumbnailSrc)
await updateTopicThumbnail(templateId, copiedThumb)
await updateTopicThumbnail(template.id, copiedThumb)
}
const items = await promoteSnapshotItemsToTemplate({
items: templateRequest.items || [],
templateId,
templateId: template.id,
})
return { template: await findTopicById(templateId), items }
return { template: await findTopicById(template.id), items }
}
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
@@ -761,28 +784,34 @@ router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, re
router.post('/tierlists/:tierListId/create-template', requireAdmin, async (req, res) => {
const schema = z.object({
topicId: z.string().trim().min(1).max(120),
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
name: z.string().trim().min(1).max(120),
itemIds: z.array(z.string().min(1)).optional().default([]),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findTopicById(parsed.data.topicId)
if (exists) return res.status(409).json({ error: 'topic_id_taken' })
const exists = await findTopicBySlug(parsed.data.slug)
if (exists) return res.status(409).json({ error: 'topic_slug_taken' })
const tierList = await findTierListById(req.params.tierListId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
const result = await createTemplateFromTierList({
tierList: {
...tierList,
pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool,
},
templateId: parsed.data.topicId,
templateName: parsed.data.name,
})
res.json(result)
try {
const result = await createTemplateFromTierList({
tierList: {
...tierList,
pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool,
},
templateSlug: parsed.data.slug,
templateName: parsed.data.name,
})
return res.json(result)
} catch (error) {
if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' })
if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' })
throw error
}
})
router.patch('/tierlists/:tierListId', requireAdmin, async (req, res) => {
@@ -832,20 +861,27 @@ router.post('/template-requests/:requestId/approve', requireAdmin, async (req, r
}
const schema = z.object({
topicId: z.string().trim().min(1).max(120),
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
name: z.string().trim().min(1).max(120),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findTopicById(parsed.data.topicId)
if (exists) return res.status(409).json({ error: 'topic_id_taken' })
const exists = await findTopicBySlug(parsed.data.slug)
if (exists) return res.status(409).json({ error: 'topic_slug_taken' })
const result = await createTemplateFromRequest({
templateRequest,
templateId: parsed.data.topicId,
templateName: parsed.data.name,
})
let result
try {
result = await createTemplateFromRequest({
templateRequest,
templateSlug: parsed.data.slug,
templateName: parsed.data.name,
})
} catch (error) {
if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' })
if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' })
throw error
}
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'approved' })
res.json({ request, ...result })
})

View File

@@ -12,6 +12,7 @@ const {
createCustomItem,
createTemplateRequest,
findUserById,
findTopicByIdentifier,
favoriteTierList,
unfavoriteTierList,
duplicateTierListForUser,
@@ -234,14 +235,15 @@ router.post('/template-request', requireAuth, async (req, res) => {
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const payload = parsed.data
const topicId = payload.topicId
const topic = await findTopicByIdentifier(payload.topicId)
if (!topic) return res.status(404).json({ error: 'not_found' })
const normalizedBoardItems = payload.boardItems.map(normalizePoolItem)
const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom')
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
if (payload.type === 'create') {
if (topicId !== FREEFORM_TOPIC_ID) return res.status(400).json({ error: 'freeform_required' })
} else if (topicId === FREEFORM_TOPIC_ID) {
if (topic.id !== FREEFORM_TOPIC_ID) return res.status(400).json({ error: 'freeform_required' })
} else if (topic.id === FREEFORM_TOPIC_ID) {
return res.status(400).json({ error: 'topic_template_required' })
}
@@ -260,8 +262,8 @@ router.post('/template-request', requireAuth, async (req, res) => {
type: payload.type,
requesterId: req.session.userId,
sourceTierListId: sourceTierList?.id || '',
sourceTopicId: topicId,
targetTopicId: payload.type === 'update' ? topicId : '',
sourceTopicId: topic.id,
targetTopicId: payload.type === 'update' ? topic.id : '',
title: payload.requestTitle,
description: payload.requestDescription,
thumbnailSrc: payload.thumbnailSrc || '',
@@ -283,7 +285,8 @@ router.post('/', requireAuth, async (req, res) => {
const parsed = tierListUpsertSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const payload = parsed.data
const topicId = payload.topicId
const topic = await findTopicByIdentifier(payload.topicId)
if (!topic) return res.status(404).json({ error: 'not_found' })
const normalizedPool = payload.pool.map(normalizePoolItem)
let existing = null
@@ -313,7 +316,7 @@ router.post('/', requireAuth, async (req, res) => {
const created = await saveTierList({
id: nanoid(),
authorId: req.session.userId,
topicId,
topicId: topic.id,
title: payload.title,
thumbnailSrc: payload.thumbnailSrc || '',
description: payload.description || '',

View File

@@ -1,5 +1,5 @@
const express = require('express')
const { listTopics, getTopicDetail, findTopicById, favoriteTopic, unfavoriteTopic } = require('../db')
const { listTopics, getTopicDetail, findTopicByIdentifier, favoriteTopic, unfavoriteTopic } = require('../db')
const { requireAuth } = require('../middleware/auth')
const router = express.Router()
@@ -10,7 +10,7 @@ router.get('/', async (req, res) => {
})
router.post('/:topicId/favorite', requireAuth, async (req, res) => {
const topic = await findTopicById(req.params.topicId)
const topic = await findTopicByIdentifier(req.params.topicId)
if (!topic || topic.id === 'freeform') return res.status(404).json({ error: 'not_found' })
await favoriteTopic({ userId: req.session.userId, topicId: topic.id })
const topics = await listTopics(req.session.userId)
@@ -19,7 +19,7 @@ router.post('/:topicId/favorite', requireAuth, async (req, res) => {
})
router.delete('/:topicId/favorite', requireAuth, async (req, res) => {
const topic = await findTopicById(req.params.topicId)
const topic = await findTopicByIdentifier(req.params.topicId)
if (!topic || topic.id === 'freeform') return res.status(404).json({ error: 'not_found' })
await unfavoriteTopic({ userId: req.session.userId, topicId: topic.id })
const topics = await listTopics(req.session.userId)

View File

@@ -1,5 +1,22 @@
# 의사결정 이력
## 2026-04-03 v1.4.62
- 운영 서버를 새 DB로 다시 시작하는 절차는 “일반 업데이트 재빌드”와 “볼륨까지 삭제하는 완전 초기화”가 같은 문서 안에 섞이면 실수로 데이터를 날릴 위험이 크므로, 배포 문서에서 두 흐름을 별도 섹션으로 나누는 편이 맞다고 판단했다.
- DB만 비우고 업로드 볼륨을 남기는 방식도 가능하지만, 현재 서비스는 DB 레코드와 업로드 파일 참조가 강하게 연결되어 있으므로 이 방법 역시 “운영 데이터를 전부 버리는 전제”라는 경고를 같이 적어두는 쪽으로 정리했다.
## 2026-04-03 v1.4.61
- 운영자가 쓰는 템플릿 주소를 `topics.id` 자체로 두면 나중에 이름/URL을 다듬고 싶을 때 참조 FK와 기존 링크까지 같이 흔들릴 수 있으므로, 내부 참조용 랜덤 `id`와 공개/관리용 `slug`를 분리하는 구조가 더 안전하다고 판단했다.
- 운영 DB와 로컬 DB를 모두 새로 시작할 수 있는 상황이라면 예전 `id -> slug` 백필이나 레거시 호환 코드를 남기는 편이 오히려 유지보수 비용만 늘리므로, 이번 변경은 새 스키마 기준으로 깔끔하게 정리하고 기존 데이터 호환 마이그레이션은 두지 않기로 했다.
- 빈 DB 초기화 시 예시 템플릿 2개가 자동 생성되면 운영자가 “진짜 운영 데이터인지 샘플인지”를 매번 구분해야 하므로, 시스템 필수 `freeform`만 남기고 빈 예시 템플릿 시드는 제거하는 편이 맞다고 정리했다.
## 2026-04-03 v1.4.60
- 신규 업로드만 샤딩 저장하고 기존 평면 `assets` 파일을 그대로 두면 운영자가 파일 구조를 볼 때 두 방식이 오래 섞여 보여 정리성이 떨어지므로, 기존 평면 자산도 같은 규칙으로 옮기는 일회성 마이그레이션 스크립트를 제공하는 편이 맞다고 판단했다.
- 기존 파일을 재인코딩해서 새 자산으로 다시 만드는 방식은 해시 중복 처리와 품질/메타 차이가 다시 얽힐 수 있으므로, 이번 샤딩 정리는 실제 파일 rename과 경로 참조 치환만 수행해 이미지 내용 자체는 건드리지 않는 쪽으로 정리했다.
## 2026-04-03 v1.4.59
- 최근 최적화 이미지가 `assets` 바로 아래 평면 파일로 저장되면서 경로만으로 프로필/썸네일 역할을 구분할 수 없게 되었으므로, 관리자 아이템 분류는 폴더명 규칙 하나에만 기대지 말고 실제 DB 참조 컬럼을 역추적해 판별하는 편이 더 안전하다고 판단했다.
- 이미지가 장기적으로 많이 쌓일 수 있는 서비스라면 한 폴더에 모든 파일을 계속 몰아넣기보다 적당한 수준의 하위 폴더 분산이 낫다고 보고, 신규 파일만 ID 앞 2글자로 1단계 샤딩 저장하되 기존 평면 경로는 그대로 유지하는 점진 방식으로 정리했다.
## 2026-04-03 v1.4.58
- 작성자 프로필 화면 상단에서 닉네임과 `@accountName`을 다시 보여주면 바로 아래 프로필 카드의 동일 정보와 역할이 겹치므로, 상단은 페이지 성격을 설명하는 공통 제목으로 두고 실제 사용자 식별 정보는 프로필 카드 한 곳에만 모으는 편이 낫다고 판단했다.
- `@accountName`은 사용자가 직접 만든 핸들이 아니라 이메일 앞부분 기반 표시라서 계정명이 따로 존재하는 것처럼 오해를 만들 수 있으므로, 별도 사용자명 정책을 도입하기 전까지는 공개 프로필 UI에서 숨기는 쪽으로 정리했다.

View File

@@ -7,12 +7,12 @@
## `/topics/:topicId`
- 화면 파일: `frontend/src/views/TopicHubView.vue`
- 역할: 선택한 주제 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
- 역할: 선택한 주제 slug 기준 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
- 화면 파일: `frontend/src/views/TierEditorView.vue`
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰 렌더링
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰 렌더링
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request`
## `/login`
@@ -47,7 +47,7 @@
## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/받은 즐겨찾기 수 표시/인기순 정렬/최소 즐겨찾기 필터/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원의 작성 티어표·팔로워·받은 즐겨찾기·최근 콘텐츠 활동·마지막 접속일 확인과 회원 정보·권한 수정 및 공개 프로필 보기, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 템플릿 이름/slug 수정, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/받은 즐겨찾기 수 표시/인기순 정렬/최소 즐겨찾기 필터/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원의 작성 티어표·팔로워·받은 즐겨찾기·최근 콘텐츠 활동·마지막 접속일 확인과 회원 정보·권한 수정 및 공개 프로필 보기, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
- 연동 API: `POST /api/admin/templates`, `POST /api/admin/templates/:templateId/thumbnail`, `POST /api/admin/templates/:templateId/images`, `PATCH /api/admin/templates/:templateId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/tierlists/stats`, `PATCH /api/admin/tierlists/:tierListId/featured`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/template-requests/:requestId/link-template`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/templates/:templateId/items/:itemId`, `DELETE /api/admin/templates/:templateId`
## `/profile`

View File

@@ -24,6 +24,8 @@
- 아바타: `backend/uploads/avatars/`
- 커스텀 아이템: `backend/uploads/custom/`
- 시드 이미지: `backend/uploads/seeds/`
- 최적화 이미지 자산: 신규 업로드는 `backend/uploads/assets/<앞2글자>/<파일명>.webp` 형태로 1단계 샤딩 저장하고, 기존 `backend/uploads/assets/<파일명>.webp` 평면 경로도 계속 읽는다.
- 기존 평면 자산을 샤딩 구조로 정리할 때는 `npm --prefix backend run images:shard-assets`를 실행하며, 스크립트가 파일 이동과 DB/JSON 참조 치환을 함께 처리한다.
## 화면 구조
- 좌측 패널
@@ -84,17 +86,23 @@
- `expiresAt`: number
- `consumedAt`: number
- `createdAt`: number
- `games`
- `topics`
- `id`: string
- 서버가 자동 생성하는 내부 참조용 랜덤 ID이며, 공개 URL 노출값으로 직접 사용하지 않는다.
- `slug`: string
- 운영자가 지정/수정하는 공개 주소용 식별자이며, 영문 소문자/숫자/하이픈 조합만 허용한다.
- `name`: string
- `thumbnailSrc`: string
- `isPublic`: boolean
- `displayRank`: number | null
- `createdAt`: number
- 시스템 전용 `freeform` 레코드는 홈 화면의 `직접 티어표 만들기` 저장 대상이며 일반 게임 목록에서는 숨긴다.
- `gameItems`
- 시스템 전용 `freeform` 레코드는 홈 화면의 `직접 티어표 만들기` 저장 대상이며 일반 주제 목록에서는 숨긴다. 신규 빈 DB 초기화 시 자동 생성되는 템플릿은 이 `freeform` 한 건만 유지한다.
- `topicItems`
- `id`: string
- `gameId`: string
- `topicId`: string
- `src`: string
- `label`: string
- `displayOrder`: number | null
- `createdAt`: number
- `customItems`
- `id`: string
@@ -105,7 +113,8 @@
- `tierLists`
- `id`: string
- `authorId`: string
- `gameId`: string
- `topicId`: string
- DB에는 내부 `topics.id`를 저장하고, API 응답에는 공개 경로용 `topicSlug`도 함께 내려준다.
- `title`: string
- `thumbnailSrc`: string
- 사용자가 직접 지정하지 않으면 저장 시 티어표 대표 아이템 이미지로 자동 채운다.
@@ -126,9 +135,9 @@
- `followerId`: string
- `followingId`: string
- `createdAt`: number
- `gameSuggestions`
- `id`: string
- `name`: string
- `favoriteTopics`
- `userId`: string
- `topicId`: string
- `createdAt`: number
## 주요 API
@@ -157,10 +166,11 @@
- 주제
- `GET /api/topics`
- `GET /api/topics/:topicId`
- `:topicId`는 공개 URL에서는 보통 `slug`를 받지만, 내부 ID를 넘겨도 같은 템플릿을 찾을 수 있게 서버가 레코드를 해석한다.
- 티어표
- `GET /api/tierlists/public`
- `featuredTierLists`와 일반 공개 `tierLists`를 분리해서 반환한다.
- `topicId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
- `topicId`에는 주제 `slug`를 우선 전달하며, `topicId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
- `GET /api/tierlists/me`
- `GET /api/tierlists/favorites/me`
- `GET /api/tierlists/:id`
@@ -182,6 +192,9 @@
- `DELETE /api/users/:userId/follow`
- 관리자
- `POST /api/admin/templates`
- 요청 본문은 `slug`, `name`, `isPublic`, `thumbnailSrc`를 사용하고, 내부 `topics.id`는 서버가 자동 생성한다.
- `PATCH /api/admin/templates/:templateId`
- 내부 ID로 템플릿을 찾아 `name`, `slug`, `isPublic`을 수정한다.
- `POST /api/admin/templates/:templateId/thumbnail`
- `POST /api/admin/templates/:templateId/images`
- 여러 이미지를 한 번에 최대 `100개`까지 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
@@ -198,7 +211,7 @@
- `POST /api/admin/tierlists/:tierListId/promote-items`
- `POST /api/admin/tierlists/:tierListId/create-template`
- `GET /api/admin/custom-items`
- `filter=library`를 기본값으로 사용해 반복 사용 가능한 `템플릿 아이템 + 사용자 아이템`만 먼저 보여주고, `filter=thumbnail` / `filter=avatar`로는 썸네일 이미지와 프로필 이미지를 따로 조회한다.
- `filter=library`를 기본값으로 사용해 반복 사용 가능한 `템플릿 아이템 + 사용자 아이템`만 먼저 보여주고, `filter=thumbnail` / `filter=avatar`로는 현재 참조 역할이 썸네일/프로필 이미지를 따로 조회한다.
- `filter=all|library|template|user|thumbnail|avatar|unused-user`를 사용하며, `filter=asset|unused-admin`은 과거 UI 호환용으로만 유지한다.
- `POST /api/admin/custom-items/:itemId/promote`
- `DELETE /api/admin/custom-items/:itemId`
@@ -223,13 +236,13 @@
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
- 사용자 아이템은 사용 횟수(`usageCount`)를 표시하며, `미사용 아이템` 필터에서 저장 티어표/템플릿 참조가 더 이상 없는 항목만 개별/일괄 삭제할 수 있다. 사용자가 계정 탈퇴 등으로 삭제된 경우는 `custom_items.owner_id` 외래키로 레코드가 같이 사라지므로 보통 `미사용 아이템`으로 남지 않는다.
- 아이템 관리 기본 필터는 `아이템(템플릿 + 사용자)`이며, 우측 필터 순서는 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템`을 사용한다.
- `/uploads/assets/avatars/``프로필 아바타`, `/uploads/assets/tierlists/``/uploads/assets/topics/``썸네일 이미지`, 그 외 보관 이미지는 `보관 자산` 배지로 표시한다. 실제 템플릿 기본 항목은 `템플릿 아이템`, 사용자 커스텀 항목은 `사용자 아이템` 배지를 사용한다.
- `/uploads/assets/avatars/``프로필 아바타`, `/uploads/assets/tierlists/``/uploads/assets/topics/``썸네일 이미지`, 그 외 보관 이미지는 `보관 자산` 배지로 표시한다. 최근처럼 `/uploads/assets/<파일명>.webp` 또는 `/uploads/assets/<앞2글자>/<파일명>.webp` 경로만 보고 종류를 알 수 없는 자산은 DB 참조(`avatar_src`, `thumbnail_src`, `thumbnail_src_snapshot`)를 역추적해 프로필/썸네일 여부를 분류한다. 실제 템플릿 기본 항목은 `템플릿 아이템`, 사용자 커스텀 항목은 `사용자 아이템` 배지를 사용한다.
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다.
- `전체 티어표 관리` 카드에는 받은 즐겨찾기 수를 함께 보여주며, 우측 운영 패널에서 즐겨찾기 많은 순 정렬과 최소 즐겨찾기 수 필터로 추천 후보를 좁힐 수 있다.
- `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다.
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
- `freeform` 티어표는 관리자 화면에서 새 템플릿 slug/이름을 입력해 새로운 템플릿으로 복제 생성할 수 있다. 내부 ID는 서버가 자동 생성하므로 운영자가 직접 입력하지 않는다.
- 관리자 티어표 관리 상단에는 사용자가 보낸 템플릿 등록/업데이트 요청 목록이 별도로 표시되며, 여기서 승인/반려를 바로 처리할 수 있다.
- 관리자 템플릿 요청 목록에서 `반려 후 숨김`을 누르면 해당 요청은 pending 목록에서 즉시 제외된다.
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.

View File

@@ -1,6 +1,15 @@
# 할 일 및 이슈
## 단기 확인
- `v1.4.62`에서 NAS 배포 문서에 운영 DB 완전 초기화 절차를 추가했으므로, 실제 NAS에서 `git pull → docker compose ... down -v → up -d --build` 순서로 재배포했을 때 빈 DB가 현재 스키마로 다시 올라오고 `freeform`만 생성되는지 확인한다.
- `docker volume rm tier-maker_tmaker_mariadb_data` 방식은 프로젝트 디렉터리명에 따라 실제 볼륨 이름이 달라질 수 있으므로, 운영 NAS에서는 먼저 `docker volume ls | grep tmaker`로 이름을 확인한 뒤 문서 명령이 그대로 맞는지 점검한다.
- `v1.4.61`에서 템플릿 공개 주소를 `slug`로 분리했으므로, 홈 카드/주제 상세/나의 티어표/즐겨찾기/검색 결과/팔로우 피드/사용자 프로필에서 열리는 URL이 `/topics/:slug`, `/editor/:slug/...` 형태로 바뀌고, 실제 화면 내용도 같은 주제 템플릿으로 정확히 열리는지 QA한다.
- 관리자 템플릿 생성/설정은 이제 내부 ID가 아니라 `slug + 이름`만 입력하므로, 새 템플릿 생성, 기존 템플릿 이름/slug 저장, 중복 slug 입력, 대문자/특수문자 slug 입력, 공개/비공개 토글, 썸네일/기본 아이템 관리가 모두 같은 템플릿에 정상 반영되는지 확인한다.
- 신규 빈 DB 초기화 시 `topics``freeform` 한 건만 생성되고 `example-topic`, `another-topic` 같은 예시 템플릿이 더 이상 자동으로 생기지 않는지 운영/로컬 재배포 후 확인한다.
- `v1.4.60`에서 추가한 `npm --prefix backend run images:shard-assets`를 로컬/운영에 적용할 때는 먼저 백업을 확보한 뒤 실행하고, 평면 `/uploads/assets/<파일명>.webp` 파일이 샤딩 폴더로 이동하면서 `image_assets.src`와 각 참조 컬럼/JSON이 모두 새 경로로 바뀌었는지 확인한다.
- `v1.4.59`에서 `thumbnail/avatar` 필터를 실제 DB 참조 역할 기준으로 다시 판별하도록 바꿨으므로, 최근 업로드처럼 `/uploads/assets/<파일명>.webp` 또는 `/uploads/assets/<앞2글자>/<파일명>.webp` 경로여도 썸네일 이미지/프로필 이미지 필터에서 빠지지 않는지 확인한다.
- 신규 업로드 이미지는 `/uploads/assets/<앞2글자>/<파일명>.webp`로 저장되므로, 템플릿 썸네일/티어표 썸네일/프로필 아바타/아이템 업로드를 각각 새로 올린 뒤 실제 파일이 샤딩 폴더에 생성되고, 브라우저 표시·삭제·중복 재사용이 모두 기존처럼 동작하는지 QA한다.
- 기존 `/uploads/assets/<파일명>.webp` 평면 경로는 그대로 유지되므로, 예전에 만든 티어표 썸네일과 아이템 이미지가 새 저장 구조 변경 후에도 깨지지 않는지 확인한다.
- `v1.4.58`에서 작성자 프로필 상단 헤더를 `사용자 프로필` 공통 제목으로 바꾸고 `@accountName` 노출을 뺐으므로, `/users/:userId`에서 상단 문구와 본문 프로필 카드가 중복되지 않고 닉네임/아바타/팔로우 버튼만 자연스럽게 읽히는지 확인한다.
- `v1.4.57`에서 관리자 아이템 필터 순서를 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템`으로 바꿨으므로, 우측 셀렉트 순서와 실제 필터링 결과가 같은 의미로 동작하는지 QA한다.
- `썸네일 이미지` 필터에서는 `/uploads/assets/tierlists`, `/uploads/assets/topics`만 모이고, `프로필 이미지` 필터에서는 `/uploads/assets/avatars`만 모이며, 각 카드 배지가 `썸네일 이미지 / 프로필 아바타`로 구분되는지 확인한다.

View File

@@ -93,7 +93,41 @@ docker compose --env-file .env.production -f docker-compose.prod.yml up -d --bui
- Dockerfile, Nginx 설정, 프런트 소스, 백엔드 소스가 바뀐 경우에는 `--build`를 유지한다.
- 단순 재시작만 필요할 때도 있지만, 운영에서는 실수 방지를 위해 `up -d --build`를 기본값으로 두는 편이 안전하다.
## 3-2. 이번 v0.1.34까지 적용하는 예시
## 3-2. 운영 DB/업로드/세션까지 완전 초기화하고 새로 빌드하기
- 운영 데이터를 전부 버리고 새 DB로 다시 시작할 때만 사용한다.
- 아래 명령은 MariaDB 데이터, 업로드 이미지, 세션 파일 볼륨까지 같이 삭제하므로 실행 전에 정말 초기화해도 되는지 반드시 확인한다.
- `.env.production`은 프로젝트 폴더에 그대로 남고, Docker volume 데이터만 제거된다.
```bash
cd /volume1/docker/projects/apps/tier-maker
git pull origin main
docker compose --env-file .env.production -f docker-compose.prod.yml down -v
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
```
- 이렇게 올리면 백엔드가 빈 MariaDB에 현재 스키마를 새로 만들고, 초기 템플릿은 시스템용 `freeform` 한 건만 생성한다.
- `down -v` 후 첫 기동은 MariaDB 초기화 때문에 조금 더 오래 걸릴 수 있으니, 아래 명령으로 상태를 확인한다.
```bash
docker compose --env-file .env.production -f docker-compose.prod.yml ps
docker compose --env-file .env.production -f docker-compose.prod.yml logs -f mariadb
docker compose --env-file .env.production -f docker-compose.prod.yml logs -f backend
```
## 3-3. DB만 비우고 업로드/세션 볼륨은 유지하기
- 이미지 파일과 세션 볼륨은 유지하고 MariaDB 데이터만 새로 시작하고 싶다면 `tmaker_mariadb_data` 볼륨만 지운다.
- 이 경우에도 기존 티어표/유저 DB 레코드와 업로드 파일 참조가 끊길 수 있으므로, 현재 운영 데이터를 전부 버리는 전제에서만 사용한다.
```bash
cd /volume1/docker/projects/apps/tier-maker
docker compose --env-file .env.production -f docker-compose.prod.yml down
docker volume rm tier-maker_tmaker_mariadb_data
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
```
- 만약 볼륨 이름이 다르게 잡혀 있는지 확인하고 싶다면 먼저 `docker volume ls | grep tmaker`로 실제 이름을 확인한 뒤 지운다.
## 3-4. 이번 최신 main까지 적용하는 예시
- 이미 NAS 폴더가 Git clone 상태라면:
```bash
@@ -153,6 +187,8 @@ docker compose --env-file .env.production -f docker-compose.prod.yml down -v
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
```
- 이 명령도 `down -v` 때문에 DB/업로드/세션 볼륨을 모두 삭제한다. 데이터를 유지해야 하는 상황이면 `-v`를 빼고 다시 올린다.
## 9. 참고
- 현재 업로드 이미지는 서버 저장 전에 리사이즈/압축하지 않는다.
- 운영 중 원본 이미지가 많이 쌓이면 이후 `sharp` 기반 최적화 단계를 추가하는 것이 좋다.

View File

@@ -1,5 +1,27 @@
# 업데이트 로그
## 2026-04-03 v1.4.62
- UGREEN NAS 운영 배포 문서에 `git pull origin main` 후 일반 재빌드하는 절차와, 운영 데이터를 전부 버리고 `docker compose ... down -v`로 MariaDB/업로드/세션 볼륨까지 초기화한 뒤 새로 `up -d --build` 하는 절차를 분리해서 추가했다.
- DB만 비우고 싶을 때 `tmaker_mariadb_data` 볼륨만 삭제하는 방법과, 실제 볼륨 이름이 다를 수 있으니 `docker volume ls | grep tmaker`로 먼저 확인하는 안내도 함께 적었다.
- 새로 초기화한 운영 DB로 올리면 현재 스키마가 다시 생성되고 시스템 템플릿은 `freeform` 한 건만 들어간다는 점을 배포 문서에 명시했다.
## 2026-04-03 v1.4.61
- 템플릿 공개 URL과 내부 참조를 분리해, `topics.id`는 서버가 자동 생성하는 랜덤 내부 ID로 두고 운영자가 직접 관리하는 공개 주소는 `topics.slug`로 저장하도록 바꿨다.
- 공개 주제/에디터 경로는 `slug`를 우선 사용하고, 백엔드는 `/api/topics/:topicId`, `/api/tierlists/public?topicId=...`, 티어표 저장/템플릿 요청의 `topicId` 입력을 `slug` 또는 내부 ID에서 실제 템플릿 레코드로 해석한 뒤 내부 `topic_id`를 저장하도록 정리했다.
- 관리자 템플릿 생성 모달과 템플릿 설정 카드에서 내부 ID 대신 `템플릿 이름 + slug`를 입력/수정할 수 있게 바꾸고, `slug` 중복/형식 오류는 `이미 사용 중인 템플릿 slug입니다.`, `slug는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.`처럼 원인 문구를 분리했다.
- 새 DB를 처음 만들 때는 시스템 전용 `freeform` 템플릿만 생성하고, 예전에 기본 시드로 넣던 빈 예시 템플릿 `example-topic`, `another-topic`과 샘플 아이템은 더 이상 자동 생성하지 않도록 제거했다.
- 로컬 MariaDB를 한 번 비운 뒤 새 스키마로 `ensureData()`를 실행해, 초기 `topics``[{ id: "freeform", slug: "freeform", name: "직접 티어표 만들기" }]` 한 건만 생성되는 상태까지 확인했다.
## 2026-04-03 v1.4.60
- 샤딩 구조가 생기기 전에 이미 `/uploads/assets/<파일명>.webp`로 평면 저장된 기존 최적화 이미지도 `/uploads/assets/<앞2글자>/<파일명>.webp`로 옮길 수 있도록 일회성 마이그레이션 스크립트 `backend/scripts/migrate-flat-assets-to-sharded.js`를 추가했다.
- 이 스크립트는 `backend/uploads/assets` 루트에 남아 있는 실제 평면 파일을 기준으로 샤딩 폴더로 이동하고, `image_assets.src`와 사용자 아바타/주제 썸네일/템플릿 아이템/사용자 아이템/티어표 JSON/템플릿 요청 JSON 참조도 같은 새 경로로 일괄 치환한다.
- 로컬 실행용 `npm --prefix backend run images:shard-assets` 스크립트를 추가해, 기존 100여 개 수준의 평면 자산도 별도 수작업 없이 한 번에 정리할 수 있게 했다.
## 2026-04-03 v1.4.59
- 최근 업로드된 최적화 이미지가 `/uploads/assets/<파일명>.webp`처럼 하위 폴더 없이 저장되면서, `썸네일 이미지 / 프로필 이미지` 필터가 경로 문자열만으로 자산 종류를 판별하지 못해 비어 보일 수 있던 문제를 고쳤다.
- 관리자 아이템 목록 생성 시 `users.avatar_src`, `topics.thumbnail_src`, `tierlists.thumbnail_src`, `template_requests.thumbnail_src_snapshot`을 역으로 모아 해당 `src`가 프로필 이미지인지 썸네일 이미지인지 먼저 판별하고, `thumbnail/avatar` 필터는 `sourceType`이 아니라 이 실제 참조 역할(`assetKind`) 기준으로 걸리도록 보정했다.
- 신규 최적화 이미지 저장은 한 폴더에 무한정 쌓이지 않도록 파일 ID 앞 2글자 기준으로 `/uploads/assets/ab/<파일명>.webp`처럼 1단계 샤딩 디렉터리를 사용하게 바꿨다. 기존에 이미 저장된 `/uploads/assets/<파일명>.webp` 평면 경로는 그대로 유지해 과거 이미지 링크가 깨지지 않게 했다.
## 2026-04-03 v1.4.58
- 작성자 프로필 화면 상단 헤더가 `Author + 닉네임 + @accountName`을 다시 보여주면서, 바로 아래 프로필 카드의 아바타/닉네임 정보와 거의 같은 내용이 반복되던 구성을 정리했다.
- 상단 헤더는 공통 제목 `사용자 프로필`과 안내 문구로 바꾸고, 실제 닉네임은 아래 프로필 카드에서만 보여주도록 나눠 화면의 정보 역할이 겹치지 않게 했다.

View File

@@ -31,7 +31,7 @@ const props = defineProps({
<span class="featuredCard__rank">{{ index + 1 }}</span>
<div>
<div class="featuredCard__title">{{ template.name }}</div>
<div class="featuredCard__id">{{ template.id }}</div>
<div class="featuredCard__id">{{ template.slug || template.id }}</div>
</div>
</div>
<div class="featuredCard__actions">
@@ -55,7 +55,7 @@ const props = defineProps({
@click="props.addFeaturedTemplate(template.id)"
>
<span>{{ template.name }}</span>
<span class="featuredPickerItem__id">{{ template.id }}</span>
<span class="featuredPickerItem__id">{{ template.slug || template.id }}</span>
</button>
</div>
</div>

View File

@@ -13,6 +13,11 @@ const props = defineProps({
hasSelectedTemplate: { type: Boolean, required: true },
selectedTemplate: { type: Object, default: null },
displayThumbnailUrl: { type: String, default: '' },
templateMetaDraftName: { type: String, default: '' },
templateMetaDraftSlug: { type: String, default: '' },
templateMetaSaving: { type: Boolean, required: true },
canSaveTemplateMeta: { type: Boolean, required: true },
saveTemplateMeta: { type: Function, required: true },
canApplyThumbnail: { type: Boolean, required: true },
templateVisibilitySaving: { type: Boolean, required: true },
thumbFileInputRef: { type: Function, required: true },
@@ -47,6 +52,8 @@ const props = defineProps({
selectedTemplateId: { type: String, default: '' },
})
defineEmits(['update:templateMetaDraftName', 'update:templateMetaDraftSlug'])
function setTemplateItemListElement(el) {
props.templateItemListRef(el)
}
@@ -131,13 +138,40 @@ function setThumbFileElement(el) {
</div>
<div class="templateSettingsCard__body">
<div class="panel__title">템플릿 설정</div>
<div class="templateSettingsCard__meta">{{ props.selectedTemplate.template.name }} · {{ props.selectedTemplate.template.id }}</div>
<div class="templateMetaForm">
<label class="templateMetaField">
<span class="templateMetaField__label">템플릿 이름</span>
<input
class="input input--dense"
type="text"
maxlength="60"
:value="props.templateMetaDraftName"
placeholder="템플릿 이름"
@input="$emit('update:templateMetaDraftName', $event.target.value)"
/>
</label>
<label class="templateMetaField">
<span class="templateMetaField__label">템플릿 slug</span>
<input
class="input input--dense"
type="text"
maxlength="120"
:value="props.templateMetaDraftSlug"
placeholder="예: idol-rhythm"
@input="$emit('update:templateMetaDraftSlug', $event.target.value)"
/>
</label>
</div>
<div class="templateSettingsCard__meta">공개 URL: /topics/{{ props.selectedTemplate.template.slug || props.selectedTemplate.template.id }}</div>
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.templateVisibilitySaving }">
<input :checked="!!props.selectedTemplate.template.isPublic" type="checkbox" @change="props.toggleSelectedTemplateVisibility($event.target.checked)" />
<span class="toggleSwitch__label">{{ props.selectedTemplate.template.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<div class="templateSettingsCard__actions">
<button class="btn btn--ghost" :disabled="!props.canSaveTemplateMeta || props.templateMetaSaving" @click="props.saveTemplateMeta">
{{ props.templateMetaSaving ? '저장중...' : '이름/slug 저장' }}
</button>
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
<button class="btn btn--danger" @click="props.removeTemplate">템플릿 삭제</button>
</div>
@@ -239,3 +273,25 @@ function setThumbFileElement(el) {
</div>
</div>
</template>
<style scoped>
.templateMetaForm {
display: grid;
gap: 10px;
}
.templateMetaField {
display: grid;
gap: 6px;
}
.templateMetaField__label {
font-size: 12px;
font-weight: 800;
color: var(--theme-text-soft);
}
.input--dense {
padding: 11px 13px;
}
</style>

View File

@@ -152,7 +152,10 @@ export function useAdminTemplateManager({
}
async function createTemplate(options = {}) {
const nextTopicId = typeof options.topicId === 'string' ? options.topicId.trim() : newTemplateId.value.trim()
const nextTopicSlug =
typeof options.topicId === 'string'
? options.topicId.trim().toLowerCase()
: newTemplateId.value.trim().toLowerCase()
const nextTopicName = typeof options.topicName === 'string' ? options.topicName.trim() : newTemplateName.value.trim()
const preserveUploadState = !!options.preserveUploadState
resetMessages()
@@ -162,15 +165,18 @@ export function useAdminTemplateManager({
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: nextTopicId,
slug: nextTopicSlug,
name: nextTopicName,
isPublic: !!newTemplateIsPublic.value,
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
}),
})
if (!res.ok) throw new Error('failed')
const data = await res.json()
if (!res.ok) {
const requestError = new Error('failed')
requestError.data = data
throw requestError
}
const createdTemplate = data.template || {}
if (activeTemplateRequest.value?.type === 'create' && activeTemplateRequest.value?.id) {
const linkData = await api.linkAdminTemplateRequestTemplate(activeTemplateRequest.value.id, {
@@ -201,7 +207,16 @@ export function useAdminTemplateManager({
}
success.value = '템플릿이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
} catch (e) {
error.value = '템플릿 생성 실패(관리자 권한/중복 ID 확인)'
const errorCode = e?.data?.error || ''
if (errorCode === 'topic_slug_taken') {
error.value = '이미 사용 중인 템플릿 주소(slug)입니다.'
return
}
if (errorCode === 'topic_slug_invalid') {
error.value = '템플릿 주소(slug)는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.'
return
}
error.value = '템플릿 생성 실패(관리자 권한/템플릿 주소 중복 확인)'
}
}

View File

@@ -26,8 +26,10 @@ export function useAdminTemplateRequests({
draftTopicIsPublic: !!request.draftTopicIsPublic,
sourceTierListId: request.sourceTierListId || '',
sourceTopicId: request.sourceTopicId || '',
sourceTopicSlug: request.sourceTopicSlug || '',
sourceTierListTitle: request.sourceTierListTitle || '',
targetTopicId: request.targetTopicId || '',
targetTopicSlug: request.targetTopicSlug || '',
targetTopicName: request.targetTopicName || '',
requesterName: request.requesterName || '',
}
@@ -38,8 +40,9 @@ export function useAdminTemplateRequests({
}
function templateRequestSourceUrl(request) {
if (!request?.sourceTopicId || !request?.sourceTierListId) return ''
return editorPath(request.sourceTopicId, request.sourceTierListId, { preview: true })
const topicRef = request?.sourceTopicSlug || request?.sourceTopicId || ''
if (!topicRef || !request?.sourceTierListId) return ''
return editorPath(topicRef, request.sourceTierListId, { preview: true })
}
function templateRequestReviewHint(request) {

View File

@@ -110,6 +110,9 @@ const success = ref('')
const newTemplateId = ref('')
const newTemplateName = ref('')
const newTemplateIsPublic = ref(false)
const templateMetaDraftName = ref('')
const templateMetaDraftSlug = ref('')
const templateMetaSaving = ref(false)
const templateVisibilitySaving = ref(false)
const uploadFiles = ref([])
@@ -177,6 +180,17 @@ function normalizeAdminSrc(src) {
}
const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.template?.id)
const canSaveTemplateMeta = computed(() => {
const template = selectedTemplate.value?.template
if (!template?.id) return false
const nextName = templateMetaDraftName.value.trim()
const nextSlug = templateMetaDraftSlug.value.trim()
return (
!!nextName &&
!!nextSlug &&
(nextName !== (template.name || '') || nextSlug !== (template.slug || template.id || ''))
)
})
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value)
const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedTemplateId.value)
const stagedRequestDraftCount = computed(() => uploadItemDrafts.value.filter((item) => item.kind === 'request').length)
@@ -203,7 +217,7 @@ const filteredTemplatePickerTemplates = computed(() => {
const query = templatePickerQuery.value.trim().toLowerCase()
const list = templates.value.filter((template) => {
if (!query) return true
const haystack = `${template.name || ''} ${template.id || ''}`.toLowerCase()
const haystack = `${template.name || ''} ${template.slug || ''} ${template.id || ''}`.toLowerCase()
return haystack.includes(query)
})
@@ -462,6 +476,8 @@ watch(
watch(
() => selectedTemplate.value?.template?.id || '',
async (templateId) => {
templateMetaDraftName.value = selectedTemplate.value?.template?.name || ''
templateMetaDraftSlug.value = selectedTemplate.value?.template?.slug || selectedTemplate.value?.template?.id || ''
await refreshSelectedTemplateTierListStats(templateId)
},
{ immediate: true }
@@ -893,7 +909,7 @@ async function refreshTemplateRequests() {
draftTopicId:
request.type === 'create'
? ('tmpl-' + String(request.id || 'request').replace(/[^a-zA-Z0-9]+/g, '').slice(0, 12).toLowerCase())
: request.targetTopicId || request.sourceTopicId || '',
: request.targetTopicSlug || request.sourceTopicSlug || request.targetTopicId || request.sourceTopicId || '',
draftTopicName:
request.type === 'create'
? `${request.sourceTierListTitle || request.sourceTopicName || '새 템플릿'}`
@@ -1212,6 +1228,44 @@ async function saveTemplateVisibility() {
}
}
async function saveTemplateMeta() {
if (!selectedTemplate.value?.template?.id || templateMetaSaving.value || !canSaveTemplateMeta.value) return
try {
templateMetaSaving.value = true
const data = await api.updateAdminTemplate(selectedTemplate.value.template.id, {
name: templateMetaDraftName.value.trim(),
slug: templateMetaDraftSlug.value.trim().toLowerCase(),
isPublic: !!selectedTemplate.value.template.isPublic,
})
const nextTemplate = data.template || {}
selectedTemplate.value = {
...selectedTemplate.value,
template: {
...selectedTemplate.value.template,
...nextTemplate,
},
}
templateMetaDraftName.value = nextTemplate.name || selectedTemplate.value.template.name || ''
templateMetaDraftSlug.value = nextTemplate.slug || selectedTemplate.value.template.slug || selectedTemplate.value.template.id || ''
await refreshTemplates()
success.value = '템플릿 이름과 slug를 저장했어요.'
} catch (e) {
const errorCode = e?.data?.error || ''
if (errorCode === 'topic_slug_taken') {
error.value = '이미 사용 중인 템플릿 slug입니다.'
return
}
if (errorCode === 'topic_slug_invalid') {
error.value = 'slug는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.'
return
}
error.value = '템플릿 이름/slug를 저장하지 못했어요.'
} finally {
templateMetaSaving.value = false
}
}
async function toggleSelectedTemplateVisibility(nextValue) {
if (!selectedTemplate.value?.template?.id || templateVisibilitySaving.value) return
const previous = !!selectedTemplate.value.template.isPublic
@@ -1603,8 +1657,9 @@ function closePreviewModal() {
}
function previewTierListUrl(tierList) {
if (!tierList?.topicId || !tierList?.id) return ''
return editorPath(tierList.topicId, tierList.id, { preview: true })
const topicRef = tierList?.topicSlug || tierList?.topicId || ''
if (!topicRef || !tierList?.id) return ''
return editorPath(topicRef, tierList.id, { preview: true })
}
function openTierListImportModal(tierList, items) {
@@ -1619,9 +1674,10 @@ function openTierListImportModal(tierList, items) {
importModalItems.value = nextItems
importModalMode.value = 'existing'
importModalTargetTemplateId.value = ''
importModalNewTemplateId.value = tierList.topicId === 'freeform' ? '' : `${tierList.topicId}-copy`
const baseSlug = tierList.topicSlug || tierList.topicId || ''
importModalNewTemplateId.value = baseSlug === 'freeform' ? '' : `${baseSlug}-copy`
importModalNewTemplateName.value =
tierList.topicId === 'freeform' ? `${tierList.title} 템플릿` : `${tierList.topicName || tierList.topicId} 파생 템플릿`
baseSlug === 'freeform' ? `${tierList.title} 템플릿` : `${tierList.topicName || baseSlug} 파생 템플릿`
importModalOpen.value = true
}
@@ -1655,15 +1711,15 @@ async function confirmTierListImport() {
if (selectedTemplateId.value === importModalTargetTemplateId.value) await loadTemplate()
success.value = `${data.items?.length || 0}개의 아이템을 기존 템플릿에 추가했어요.`
} else {
const nextTopicId = (importModalNewTemplateId.value || '').trim()
const nextTopicId = (importModalNewTemplateId.value || '').trim().toLowerCase()
const nextTopicName = (importModalNewTemplateName.value || '').trim()
if (!nextTopicId || !nextTopicName) {
error.value = '새 템플릿 ID와 이름을 모두 입력해주세요.'
error.value = '새 템플릿 slug와 이름을 모두 입력해주세요.'
return
}
const data = await api.createAdminTemplateFromTierList(tierList.id, {
topicId: nextTopicId,
slug: nextTopicId,
name: nextTopicName,
itemIds,
})
@@ -1684,11 +1740,11 @@ function templateRequestTypeLabel(request) {
function templateRequestTargetLabel(request) {
if (request.type === 'create') {
if (request.targetTopicName || request.targetTopicId) {
return `연결된 템플릿 · ${request.targetTopicName || request.targetTopicId}`
return `연결된 템플릿 · ${request.targetTopicName || request.targetTopicSlug || request.targetTopicId}`
}
return '연결된 템플릿 없음'
}
return request.targetTopicName || request.targetTopicId || request.sourceTopicName
return request.targetTopicName || request.targetTopicSlug || request.targetTopicId || request.sourceTopicName
}
const displayThumbnailUrl = computed(() => {
@@ -1768,6 +1824,11 @@ function openUserProfile(user) {
:has-selected-template="hasSelectedTemplate"
:selected-template="selectedTemplate"
:display-thumbnail-url="displayThumbnailUrl"
v-model:template-meta-draft-name="templateMetaDraftName"
v-model:template-meta-draft-slug="templateMetaDraftSlug"
:template-meta-saving="templateMetaSaving"
:can-save-template-meta="canSaveTemplateMeta"
:save-template-meta="saveTemplateMeta"
:can-apply-thumbnail="canApplyThumbnail"
:template-visibility-saving="templateVisibilitySaving"
:thumb-file-input-ref="setThumbFileInputRef"
@@ -1868,9 +1929,9 @@ function openUserProfile(user) {
/>
<div v-if="templateCreateModalOpen" class="modalOverlay" @click.self="closeTemplateCreateModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title"> 템플릿 만들기</div>
<div class="modalCard__desc">템플릿 이름과 고유 ID를 입력한 생성하면 바로 아래 상세 관리 화면으로 이어집니다.</div>
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title"> 템플릿 만들기</div>
<div class="modalCard__desc">템플릿 이름과 공개 주소용 slug를 입력하면, 내부 ID는 서버가 자동으로 생성합니다.</div>
<div class="modalCard__form">
<label class="field">
<span class="field__label">템플릿 이름</span>
@@ -1878,15 +1939,15 @@ function openUserProfile(user) {
<span class="field__hint">{{ newTemplateName.length }}/60</span>
</label>
<label class="field">
<span class="field__label">템플릿 ID</span>
<span class="field__label">템플릿 slug</span>
<input
v-model="newTemplateId"
class="field__input"
maxlength="120"
placeholder="topic id (영문/숫자)"
placeholder="예: idol-rhythm"
@keydown.enter.prevent="createTemplate"
/>
<span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newTemplateId.length }}/120</span>
<span class="field__hint">영문 소문자, 숫자, 하이픈 사용 · {{ newTemplateId.length }}/120</span>
</label>
<label class="toggleSwitch">
<input v-model="newTemplateIsPublic" type="checkbox" />
@@ -1988,7 +2049,7 @@ function openUserProfile(user) {
</div>
<div v-else class="modalCard__form">
<input v-model="importModalNewTemplateId" class="input" placeholder="새 템플릿 ID" />
<input v-model="importModalNewTemplateId" class="input" placeholder="새 템플릿 slug" />
<input v-model="importModalNewTemplateName" class="input" placeholder="새 템플릿 이름" />
</div>
@@ -2012,7 +2073,7 @@ function openUserProfile(user) {
<div class="adminSelectionCard">
<div class="adminSelectionCard__label">선택한 템플릿</div>
<div class="adminSelectionCard__title">{{ customItemTargetTemplate?.name || '아직 선택하지 않음' }}</div>
<div class="adminSelectionCard__meta">{{ customItemTargetTemplate?.id || '템플릿을 골라 주세요.' }}</div>
<div class="adminSelectionCard__meta">{{ customItemTargetTemplate?.slug || customItemTargetTemplate?.id || '템플릿을 골라 주세요.' }}</div>
</div>
<div class="customItemModal__pickerActions">
<button class="btn btn--ghost" type="button" @click="openTemplatePickerModal('custom-item-target')">템플릿 선택</button>
@@ -2078,7 +2139,7 @@ function openUserProfile(user) {
<button class="btn btn--ghost btn--small" @click="closeTemplatePickerModal">닫기</button>
</div>
<div class="modalCard__form">
<input v-model="templatePickerQuery" class="input" placeholder="템플릿 이름 또는 ID 검색" />
<input v-model="templatePickerQuery" class="input" placeholder="템플릿 이름 또는 slug 검색" />
<select v-model="templatePickerSort" class="select">
<option value="recent">최신순</option>
<option value="oldest">오래된순</option>
@@ -2110,7 +2171,7 @@ function openUserProfile(user) {
@click="chooseTemplateFromPicker(template.id)"
>
<span class="adminTemplatePicker__name">{{ template.name }}</span>
<span class="adminTemplatePicker__meta">{{ template.id }}</span>
<span class="adminTemplatePicker__meta">{{ template.slug || template.id }}</span>
<span v-if="templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id)" class="adminTemplatePicker__state">이미 추가됨</span>
</button>
<div v-if="!filteredTemplatePickerTemplates.length" class="hint hint--tight">검색 결과가 없어요.</div>
@@ -2276,7 +2337,7 @@ function openUserProfile(user) {
<div v-if="selectedTemplate?.template" class="adminSelectionCard">
<div class="adminSelectionCard__label">선택한 템플릿</div>
<div class="adminSelectionCard__title">{{ selectedTemplate.template.name }}</div>
<div class="adminSelectionCard__meta">{{ selectedTemplate.template.id }}</div>
<div class="adminSelectionCard__meta">{{ selectedTemplate.template.slug || selectedTemplate.template.id }}</div>
</div>
<div v-if="selectedTemplateId && !hasSelectedTemplate && !isTemplateLoading" class="hint hint--tight">선택된 템플릿 ID: {{ selectedTemplateId }}</div>
</div>

View File

@@ -48,7 +48,7 @@ async function loadFavorites() {
}
function openTierList(tierList) {
router.push(editorPath(tierList.topicId, tierList.id))
router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
}
onMounted(loadFavorites)

View File

@@ -66,7 +66,7 @@ async function loadFollowingFeed() {
}
function openTierList(tierList) {
router.push(editorPath(tierList.topicId, tierList.id))
router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
}
function openAuthorProfile(tierList) {

View File

@@ -21,7 +21,7 @@ const templates = computed(() => {
.filter((item) => item.id !== 'freeform')
.filter((item) => {
if (!query.value) return true
const haystack = `${item.name || ''} ${item.id || ''}`.toLowerCase()
const haystack = `${item.name || ''} ${item.slug || ''}`.toLowerCase()
return haystack.includes(query.value)
})
@@ -49,8 +49,8 @@ async function loadTemplates() {
onMounted(loadTemplates)
watch(() => auth.user?.id, loadTemplates)
function openTopic(templateId) {
router.push(topicPath(templateId))
function openTopic(template) {
router.push(topicPath(template?.slug || template?.id || ''))
}
async function toggleFavorite(template, event) {
@@ -99,14 +99,14 @@ function templateThumbUrl(template) {
>
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
</button>
<button class="libraryCard__main" type="button" @click="openTopic(template.id)">
<button class="libraryCard__main" type="button" @click="openTopic(template)">
<div class="libraryCard__thumbWrap">
<img v-if="templateThumbUrl(template)" class="libraryCard__thumb" :src="templateThumbUrl(template)" :alt="template.name" draggable="false" />
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
</div>
<div class="libraryCard__body">
<div class="libraryCard__title">{{ template.name }}</div>
<div class="libraryCard__meta">{{ template.id }}</div>
<div class="libraryCard__meta">{{ template.slug || template.id }}</div>
</div>
</button>
</article>

View File

@@ -60,7 +60,7 @@ onMounted(async () => {
})
function openList(t) {
router.push(editorPath(t.topicId, t.id))
router.push(editorPath(t.topicSlug || t.topicId, t.id))
}
</script>

View File

@@ -38,7 +38,7 @@ function tierListThumbnailUrl(tierList) {
}
function openTierList(tierList) {
router.push(editorPath(tierList.topicId, tierList.id))
router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
}
async function loadResults() {

View File

@@ -103,7 +103,7 @@ async function toggleFollow() {
}
function openTierList(tierList) {
router.push(editorPath(tierList.topicId, tierList.id))
router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
}
watch(userId, loadProfile, { immediate: true })