Compare commits

..

7 Commits

8 changed files with 655 additions and 87 deletions

View File

@@ -115,6 +115,21 @@ function mapTopicItemRow(row) {
}
}
function mapCustomItemRow(row) {
if (!row) return null
return {
id: row.id,
ownerId: row.owner_id,
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
replacedByItemId: row.replaced_by_item_id || '',
replacedBySrc: row.replaced_by_src || '',
replacedByLabel: row.replaced_by_label || '',
replacedAt: Number(row.replaced_at || 0),
}
}
function mapImageAssetRow(row) {
if (!row) return null
return {
@@ -360,12 +375,21 @@ async function ensureSchema() {
owner_id VARCHAR(64) NOT NULL,
src VARCHAR(255) NOT NULL,
label VARCHAR(120) NOT NULL,
replaced_by_item_id VARCHAR(64) NOT NULL DEFAULT '',
replaced_by_src VARCHAR(255) NOT NULL DEFAULT '',
replaced_by_label VARCHAR(120) NOT NULL DEFAULT '',
replaced_at BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
INDEX idx_custom_items_owner_id (owner_id),
CONSTRAINT fk_custom_items_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_item_id VARCHAR(64) NOT NULL DEFAULT '' AFTER label`)
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_src VARCHAR(255) NOT NULL DEFAULT '' AFTER replaced_by_item_id`)
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_label VARCHAR(120) NOT NULL DEFAULT '' AFTER replaced_by_src`)
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_at BIGINT NOT NULL DEFAULT 0 AFTER replaced_by_label`)
await query(`
CREATE TABLE IF NOT EXISTS tierlists (
id VARCHAR(64) PRIMARY KEY,
@@ -1114,24 +1138,50 @@ async function listReferencedUploadUsage() {
.sort((a, b) => a.src.localeCompare(b.src))
}
function replaceItemSrc(items, fromSrc, toSrc) {
function replaceItemSrc(items, fromSrc, toSrc, toLabel = '') {
let changed = false
const nextItems = (items || []).map((item) => {
if (item?.src !== fromSrc) return item
changed = true
return { ...item, src: toSrc }
return {
...item,
src: toSrc,
...(typeof toLabel === 'string' && toLabel.trim() ? { label: toLabel.trim().slice(0, 60) } : {}),
}
})
return { changed, items: nextItems }
}
async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
function replaceItemById(items, itemId, nextSrc, nextLabel = '') {
let changed = false
const normalizedLabel = typeof nextLabel === 'string' ? nextLabel.trim().slice(0, 60) : ''
const nextItems = (items || []).map((item) => {
if (item?.id !== itemId) return item
changed = true
return {
...item,
...(typeof nextSrc === 'string' && nextSrc ? { src: nextSrc } : {}),
...(normalizedLabel ? { label: normalizedLabel } : {}),
}
})
return { changed, items: nextItems }
}
async function replaceUploadSourceReferences({ fromSrc, toSrc, toLabel = '', updateCustomItemsBySrc = true }) {
if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 }
const normalizedLabel = typeof toLabel === 'string' ? toLabel.trim().slice(0, 60) : ''
const [userResult, topicResult, topicItemResult, customItemResult] = await Promise.all([
query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]),
query('UPDATE topics SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]),
query('UPDATE topic_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
normalizedLabel
? query('UPDATE topic_items SET src = ?, label = ? WHERE src = ?', [toSrc, normalizedLabel, fromSrc])
: query('UPDATE topic_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
updateCustomItemsBySrc
? normalizedLabel
? query('UPDATE custom_items SET src = ?, label = ? WHERE src = ?', [toSrc, normalizedLabel, fromSrc])
: query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc])
: Promise.resolve({ affectedRows: 0 }),
])
let updatedRows = Number(userResult.affectedRows || 0) + Number(topicResult.affectedRows || 0) + Number(topicItemResult.affectedRows || 0) + Number(customItemResult.affectedRows || 0)
@@ -1145,7 +1195,7 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
changed = true
}
const replacedPool = replaceItemSrc(parseJson(row.pool_json, []), fromSrc, toSrc)
const replacedPool = replaceItemSrc(parseJson(row.pool_json, []), fromSrc, toSrc, normalizedLabel)
if (replacedPool.changed) changed = true
if (changed) {
@@ -1168,8 +1218,8 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
changed = true
}
const replacedItems = replaceItemSrc(parseJson(row.items_json, []), fromSrc, toSrc)
const replacedBoardItems = replaceItemSrc(parseJson(row.board_items_json, []), fromSrc, toSrc)
const replacedItems = replaceItemSrc(parseJson(row.items_json, []), fromSrc, toSrc, normalizedLabel)
const replacedBoardItems = replaceItemSrc(parseJson(row.board_items_json, []), fromSrc, toSrc, normalizedLabel)
if (replacedItems.changed || replacedBoardItems.changed) changed = true
if (changed) {
@@ -1187,6 +1237,40 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
return { updatedRows }
}
async function updateCustomItemDisplayReferences({ itemId, src = '', label = '' }) {
if (!itemId) return { updatedRows: 0 }
const normalizedLabel = typeof label === 'string' ? label.trim().slice(0, 60) : ''
let updatedRows = 0
const tierListRows = await query('SELECT id, pool_json FROM tierlists')
for (const row of tierListRows) {
const replacedPool = replaceItemById(parseJson(row.pool_json, []), itemId, src, normalizedLabel)
if (!replacedPool.changed) continue
await query('UPDATE tierlists SET pool_json = ?, updated_at = ? WHERE id = ?', [
serializeJson(replacedPool.items),
now(),
row.id,
])
updatedRows += 1
}
const requestRows = await query('SELECT id, items_json, board_items_json FROM template_requests')
for (const row of requestRows) {
const replacedItems = replaceItemById(parseJson(row.items_json, []), itemId, src, normalizedLabel)
const replacedBoardItems = replaceItemById(parseJson(row.board_items_json, []), itemId, src, normalizedLabel)
if (!replacedItems.changed && !replacedBoardItems.changed) continue
await query('UPDATE template_requests SET items_json = ?, board_items_json = ?, updated_at = ? WHERE id = ?', [
serializeJson(replacedItems.items),
serializeJson(replacedBoardItems.items),
now(),
row.id,
])
updatedRows += 1
}
return { updatedRows }
}
async function listImageAssets() {
const rows = await query(
'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets ORDER BY created_at DESC'
@@ -1509,7 +1593,7 @@ async function updateTopicItemDisplayOrder(topicId, itemIds) {
async function updateCustomItemLabel(itemId, label) {
await query('UPDATE custom_items SET label = ? WHERE id = ?', [label, itemId])
const rows = await query(`
SELECT c.id, c.owner_id, c.src, c.label, c.created_at, u.nickname, u.email
SELECT c.id, c.owner_id, c.src, c.label, c.replaced_by_item_id, c.replaced_by_src, c.replaced_by_label, c.replaced_at, c.created_at, u.nickname, u.email
FROM custom_items c
INNER JOIN users u ON u.id = c.owner_id
WHERE c.id = ?
@@ -1518,16 +1602,28 @@ async function updateCustomItemLabel(itemId, label) {
const row = rows[0]
if (!row) return null
return {
id: row.id,
ownerId: row.owner_id,
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
...mapCustomItemRow(row),
ownerName: row.nickname || row.email,
ownerEmail: row.email,
}
}
async function markCustomItemReplaced({ itemId, replacedByItemId = '', replacedBySrc = '', replacedByLabel = '' }) {
await query(
'UPDATE custom_items SET replaced_by_item_id = ?, replaced_by_src = ?, replaced_by_label = ?, replaced_at = ? WHERE id = ?',
[replacedByItemId || '', replacedBySrc || '', replacedByLabel || '', now(), itemId]
)
return findCustomItemById(itemId)
}
async function clearCustomItemReplacement(itemId) {
await query(
'UPDATE custom_items SET replaced_by_item_id = ?, replaced_by_src = ?, replaced_by_label = ?, replaced_at = 0 WHERE id = ?',
['', '', '', itemId]
)
return findCustomItemById(itemId)
}
async function updateImageAssetLabel(assetId, label) {
await query('UPDATE image_assets SET label_override = ? WHERE id = ?', [label, assetId])
const rows = await query('SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id = ? LIMIT 1', [assetId])
@@ -1617,7 +1713,7 @@ async function syncOwnedCustomItemLabels({ ownerId, items }) {
async function findCustomItemById(id) {
const rows = await query(
`
SELECT id, owner_id, src, label, created_at
SELECT id, owner_id, src, label, replaced_by_item_id, replaced_by_src, replaced_by_label, replaced_at, created_at
FROM custom_items
WHERE id = ?
LIMIT 1
@@ -1626,14 +1722,7 @@ async function findCustomItemById(id) {
)
const row = rows[0]
if (!row) return null
return {
id: row.id,
ownerId: row.owner_id,
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
}
return mapCustomItemRow(row)
}
async function getCustomItemUsageMeta() {
@@ -1698,6 +1787,10 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
c.owner_id,
c.src,
c.label,
c.replaced_by_item_id,
c.replaced_by_src,
c.replaced_by_label,
c.replaced_at,
c.created_at,
u.nickname,
u.email
@@ -1777,17 +1870,14 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
const linkedTemplates = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values())
return {
id: row.id,
ownerId: row.owner_id,
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
...mapCustomItemRow(row),
ownerName: row.nickname || row.email,
ownerEmail: row.email,
usageCount: usageMeta.usageMap.get(row.id) || 0,
linkedTemplates,
assetKind: resolveLibraryAssetKind(row.src),
sourceType: 'user',
sourceLabel: '사용자 아이템',
sourceLabel: Number(row.replaced_at || 0) > 0 ? '대체된 사용자 아이템' : '사용자 아이템',
canDelete: true,
}
})
@@ -1807,7 +1897,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
src: row.src,
label: row.label_override || (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음',
createdAt: Number(row.created_at || 0),
ownerName: '관리자 보관 자산',
ownerName: '관리자 미사용 이미지',
ownerEmail: '',
usageCount: 0,
linkedTemplates: [],
@@ -1886,6 +1976,10 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
usageCount: entry.usageCount || 0,
linkedTemplates: entry.linkedTemplates || [],
isAssetLibraryItem: !!entry.isAssetLibraryItem,
replacedByItemId: entry.replacedByItemId || '',
replacedBySrc: entry.replacedBySrc || '',
replacedByLabel: entry.replacedByLabel || '',
replacedAt: entry.replacedAt || 0,
})),
}
})
@@ -1903,8 +1997,12 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
return item.assetKind === 'avatar'
case 'library':
return item.sourceType === 'user' || (item.sourceType === 'template' && !item.isAssetLibraryItem)
case 'unused':
return (item.sourceType === 'user' && ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt)) || item.sourceType === 'asset' || !!item.isAssetLibraryItem
case 'unused-user':
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedTemplates.length === 0
return item.sourceType === 'user' && ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt)
case 'replaced-user':
return item.sourceType === 'user' && !!item.replacedAt
case 'unused-admin':
return item.sourceType === 'asset' || !!item.isAssetLibraryItem
default:
@@ -1931,37 +2029,57 @@ async function findUnusedCustomItems({ queryText = '' } = {}) {
const whereClause = hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''
const params = hasQuery ? [search, search, search, search] : []
const rows = await query(
`
SELECT
c.id,
c.owner_id,
c.src,
c.label,
c.created_at,
u.nickname,
u.email
FROM custom_items c
INNER JOIN users u ON u.id = c.owner_id
${whereClause}
ORDER BY c.created_at DESC
`,
params
)
const [rows, topicItemRows, usageMeta] = await Promise.all([
query(
`
SELECT
c.id,
c.owner_id,
c.src,
c.label,
c.replaced_by_item_id,
c.replaced_by_src,
c.replaced_by_label,
c.replaced_at,
c.created_at,
u.nickname,
u.email
FROM custom_items c
INNER JOIN users u ON u.id = c.owner_id
${whereClause}
ORDER BY c.created_at DESC
`,
params
),
query(
`
SELECT ti.topic_id, tp.name AS topic_name, ti.src
FROM topic_items ti
LEFT JOIN topics tp ON tp.id = ti.topic_id
`
),
getCustomItemUsageMeta(),
])
const templateLinkedBySrc = new Map()
topicItemRows.forEach((row) => {
if (!row?.src) return
if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map())
templateLinkedBySrc.get(row.src).set(row.topic_id, {
id: row.topic_id,
name: row.topic_name || row.topic_id,
})
})
const { usageMap } = await getCustomItemUsageMeta()
return rows
.map((row) => ({
id: row.id,
ownerId: row.owner_id,
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
...mapCustomItemRow(row),
ownerName: row.nickname || row.email,
ownerEmail: row.email,
usageCount: usageMap.get(row.id) || 0,
usageCount: usageMeta.usageMap.get(row.id) || 0,
linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
}))
.filter((item) => item.usageCount === 0)
.filter((item) => ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt))
}
async function getFavoriteStatsForTierListIds(tierListIds, userId = '') {
@@ -2886,20 +3004,14 @@ async function findCustomItemsByIds(ids) {
const placeholders = ids.map(() => '?').join(', ')
const rows = await query(
`
SELECT id, owner_id, src, label, created_at
SELECT id, owner_id, src, label, replaced_by_item_id, replaced_by_src, replaced_by_label, replaced_at, created_at
FROM custom_items
WHERE id IN (${placeholders})
`,
ids
)
return rows.map((row) => ({
id: row.id,
ownerId: row.owner_id,
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
}))
return rows.map(mapCustomItemRow)
}
async function deleteCustomItems(ids) {
@@ -3044,6 +3156,7 @@ module.exports = {
listReferencedUploadSources,
listReferencedUploadUsage,
replaceUploadSourceReferences,
updateCustomItemDisplayReferences,
clearImageOptimizationJobs,
getImageAssetStats,
cleanupMissingUploadReferences,
@@ -3055,6 +3168,8 @@ module.exports = {
deleteTopic,
updateTopicDisplayOrder,
updateCustomItemLabel,
clearCustomItemReplacement,
markCustomItemReplaced,
updateImageAssetLabel,
createCustomItem,
findCustomItemById,

View File

@@ -33,6 +33,7 @@ const {
findUnusedCustomItems,
findCustomItemsByIds,
deleteCustomItems,
markCustomItemReplaced,
listUsers,
findPrimaryAdminUser,
listAdminTierLists,
@@ -53,6 +54,10 @@ const {
listRecentImageOptimizationJobs,
clearImageOptimizationJobs,
cleanupMissingUploadReferences,
listReferencedUploadSources,
replaceUploadSourceReferences,
updateCustomItemDisplayReferences,
clearCustomItemReplacement,
} = require('../db')
const { requireAdmin } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
@@ -356,7 +361,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
filter: z
.enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused-user', 'unused-admin'])
.enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused', 'unused-user', 'replaced-user', 'unused-admin'])
.optional()
.default('library'),
})
@@ -542,6 +547,12 @@ async function removeCustomItemFiles(items) {
)
}
async function removeUnreferencedCustomItemFiles(items) {
const referencedSrcs = new Set(await listReferencedUploadSources())
const removableItems = (items || []).filter((item) => item?.src && !referencedSrcs.has(item.src))
await removeCustomItemFiles(removableItems)
}
async function promoteLibraryItemToTemplateItem({ item, templateId }) {
return createTopicItem({
id: nanoid(),
@@ -551,6 +562,57 @@ async function promoteLibraryItemToTemplateItem({ item, templateId }) {
})
}
async function findLibraryItemForReplacement(itemId, sourceType = '') {
const normalizedId = String(itemId || '').trim()
const normalizedSourceType = String(sourceType || '').trim()
if (!normalizedId) return null
if (normalizedId.startsWith('asset:') || normalizedSourceType === 'asset') {
const assetId = normalizedId.startsWith('asset:') ? normalizedId.slice(6) : normalizedId
const asset = await findImageAssetById(assetId)
if (!asset) return null
return {
id: `asset:${asset.id}`,
sourceType: 'asset',
src: asset.src || '',
label: asset.labelOverride || buildItemLabelFromSrc(asset.src),
}
}
if (normalizedSourceType === 'template') {
const item = await findTopicItemById(normalizedId)
if (!item) return null
return {
id: item.id,
sourceType: 'template',
src: item.src || '',
label: item.label || buildItemLabelFromSrc(item.src),
}
}
const customItem = await findCustomItemById(normalizedId)
if (customItem) {
return {
id: customItem.id,
sourceType: 'user',
src: customItem.src || '',
label: customItem.label || buildItemLabelFromSrc(customItem.src),
}
}
const templateItem = await findTopicItemById(normalizedId)
if (templateItem) {
return {
id: templateItem.id,
sourceType: 'template',
src: templateItem.src || '',
label: templateItem.label || buildItemLabelFromSrc(templateItem.src),
}
}
return null
}
async function copyUploadIntoTopicAsset(src) {
if (typeof src !== 'string') return ''
const raw = src.trim()
@@ -721,13 +783,14 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
return res.json({ ok: true, sourceType: 'template' })
}
const canDeleteReplacedUserItem = target.sourceType === 'user' && !!target.replacedAt
if (!target.canDelete) return res.status(409).json({ error: 'item_locked' })
if (target.linkedTemplates.length > 0) return res.status(409).json({ error: 'item_linked' })
if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
if (!canDeleteReplacedUserItem && target.linkedTemplates.length > 0) return res.status(409).json({ error: 'item_linked' })
if (!canDeleteReplacedUserItem && target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
const items = await findCustomItemsByIds([target.id])
await deleteCustomItems([target.id])
await removeCustomItemFiles(items)
await removeUnreferencedCustomItemFiles(items)
res.json({ ok: true, sourceType: 'user' })
})
@@ -760,6 +823,69 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
res.json({ item })
})
router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => {
const schema = z.object({
targetItemId: z.string().trim().min(1),
targetSourceType: z.enum(['template', 'user', 'asset']).optional().default('user'),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const sourceItem = await findLibraryItemForReplacement(req.params.itemId)
if (!sourceItem?.src) return res.status(404).json({ error: 'source_not_found' })
if (sourceItem.sourceType !== 'user') return res.status(400).json({ error: 'user_item_required' })
const targetItem = await findLibraryItemForReplacement(parsed.data.targetItemId, parsed.data.targetSourceType)
if (!targetItem?.src) return res.status(404).json({ error: 'target_not_found' })
if (sourceItem.src === targetItem.src && (sourceItem.label || '') === (targetItem.label || '')) {
return res.status(409).json({ error: 'same_target' })
}
const result = await replaceUploadSourceReferences({
fromSrc: sourceItem.src,
toSrc: targetItem.src,
toLabel: targetItem.label || '',
updateCustomItemsBySrc: false,
})
const displayResult = await updateCustomItemDisplayReferences({
itemId: sourceItem.id,
src: targetItem.src,
label: targetItem.label || '',
})
await markCustomItemReplaced({
itemId: sourceItem.id,
replacedByItemId: targetItem.id || '',
replacedBySrc: targetItem.src || '',
replacedByLabel: targetItem.label || '',
})
res.json({
ok: true,
updatedRows: (result.updatedRows || 0) + (displayResult.updatedRows || 0),
sourceItem,
targetItem,
})
})
router.post('/custom-items/:itemId/restore', requireAdmin, async (req, res) => {
const sourceItem = await findCustomItemById(req.params.itemId)
if (!sourceItem?.id) return res.status(404).json({ error: 'not_found' })
if (!sourceItem.replacedAt) return res.status(409).json({ error: 'not_replaced' })
const restored = await updateCustomItemDisplayReferences({
itemId: sourceItem.id,
src: sourceItem.src,
label: sourceItem.label || '',
})
await clearCustomItemReplacement(sourceItem.id)
res.json({
ok: true,
restoredRows: restored.updatedRows || 0,
item: await findCustomItemById(sourceItem.id),
})
})
router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => {
const schema = z.object({
topicId: z.string().min(1),
@@ -1014,11 +1140,27 @@ router.delete('/custom-items', requireAdmin, async (req, res) => {
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const items = await findUnusedCustomItems({ queryText: parsed.data.q })
const ids = items.map((item) => item.id)
await deleteCustomItems(ids)
await removeCustomItemFiles(items)
res.json({ ok: true, deletedCount: ids.length })
const result = await listCustomItems({
queryText: parsed.data.q,
page: 1,
limit: 10000,
filterMode: 'unused',
})
const customItems = result.items.filter((item) => item?.sourceType === 'user')
const assetItems = result.items.filter((item) => item?.sourceType === 'asset' || item?.isAssetLibraryItem)
const customItemIds = customItems.map((item) => item.id)
const assetIds = assetItems
.map((item) => String(item.id || ''))
.filter((id) => id.startsWith('asset:'))
.map((id) => id.slice('asset:'.length))
await deleteCustomItems(customItemIds)
await removeUnreferencedCustomItemFiles(customItems)
const deletedAssets = await deleteImageAssets(assetIds)
await removeImageAssetFiles(deletedAssets)
res.json({ ok: true, deletedCount: customItemIds.length + deletedAssets.length })
})
router.get('/users', requireAdmin, async (req, res) => {

View File

@@ -1,5 +1,37 @@
# 의사결정 이력
## 2026-04-06 v1.4.85
- 썸네일과 프로필 이미지는 이미 별도 필터로 분리돼 있고, 템플릿 아이템/사용자 아이템도 각각 구분되고 있으므로, 그 어디에도 속하지 않는 관리자 업로드 자산은 운영 의미상 `보관 자산`보다 `미사용 이미지`로 보는 편이 더 직관적이라고 정리했다.
- 또 관리자 아이템 화면에서 삭제 버튼이 필터 조건에 따라 갑자기 비활성으로만 보이면 흐름을 이해하기 어려우므로, 평소에는 `미사용 이미지 확인`, 해당 화면 안에서만 `미사용 이미지 일괄 삭제`로 바뀌는 점진적 동작이 더 낫다고 판단했다.
## 2026-04-06 v1.4.84
- 대체된 원본 아이템은 운영상 이미 교체 완료된 이력이므로, 현재 저장본에서 같은 item id 사용량이 남아 있더라도 개별 삭제를 막아 두는 쪽보다 명시적으로 정리 가능하게 두는 편이 더 맞다고 판단했다.
- 또 관리자 목록에서 보이는 `미사용` 기준과 실제 일괄 삭제 API 기준이 다르면 운영자가 버튼 상태를 신뢰하기 어려워지므로, 둘은 반드시 같은 조건으로 맞추기로 정리했다.
- 마지막으로 업로드 파일은 하나의 레코드가 없어졌다고 곧바로 지우기보다, 전체 참조를 다시 확인한 뒤 정말 고아 파일일 때만 삭제하는 방식이 더 안전하다고 판단했다.
## 2026-04-06 v1.4.83
- 대체 이력이 쌓이기 시작하면 일반 사용자 업로드 목록 안에 섞여 보이는 것보다, 운영자가 후속 검수와 정리를 위해 `대체된 항목만` 따로 모아 보는 필터가 있는 편이 더 실용적이라고 판단했다.
## 2026-04-06 v1.4.82
- 원본 A를 보존하더라도 실제 저장본이 계속 A의 item id를 쓰는 구조라면, 대체/복구는 `custom_items` 레코드 자체를 바꾸는 방식보다 “그 item id가 참조되는 보드 표시 데이터(src/label)만 바꾸는 방식”이 더 자연스럽다고 판단했다.
- 또 대체된 원본은 집계상 사용량이 남더라도 운영 의미상 이미 정리 가능한 이력이므로, `미사용 아이템 일괄 삭제`에서 제외하면 오히려 계속 쌓이게 된다. 그래서 대체 이력 아이템은 예외적으로 미사용 정리 대상으로 포함하는 쪽이 맞다고 정리했다.
## 2026-04-06 v1.4.81
- 원본 보존을 하더라도 실제 `custom_items.src``label`까지 대체 대상 값으로 덮어쓰면 관리자 입장에서는 “원본을 남겼다”기보다 “같은 새 이미지가 두 번 보이는 상태”로 읽히므로, 원본 아이템 레코드는 실제 이미지/라벨을 그대로 유지하고 참조 이동 대상에서만 제외하는 편이 맞다고 정리했다.
- 즉 대체 이력 메타(`어떤 아이템으로 대체됐는지`)와 원본 본문 데이터(`원래 A 이미지/이름`)는 분리해서 보존해야, 이후 복구나 검수 기준으로 의미가 살아난다고 판단했다.
## 2026-04-06 v1.4.80
- 이미지 대체 직후 원본 사용자 아이템을 완전히 지워버리면 관리자 입장에서는 “왜 바꿨는지”, “나중에 정리해도 되는지”를 다시 확인할 근거가 사라지므로, 참조 이동과 원본 보존을 분리하는 편이 운영 흐름에 더 맞다고 판단했다.
- 다만 대체 완료된 원본까지 별도 보호 대상으로 빼면 라이브러리 정리가 끝없이 쌓일 수 있으므로, 원본은 `대체됨` 상태로 계속 보이게 하되 이미 미사용인 이상 `미사용 아이템 일괄 삭제`와 개별 삭제로 언제든 정리할 수 있게 두는 쪽이 균형이 맞는다고 정리했다.
## 2026-04-06 v1.4.79
- 관리자 대체 모달은 열자마자 현재 라이브러리 결과를 그대로 쏟아내면 “같은 보드 안의 비슷한 항목을 고르는 화면”처럼 읽히기 쉬우므로, 검색 전에는 후보를 비워 두고 운영자가 의도적으로 찾은 뒤 고르는 방식이 더 분명하다고 판단했다.
- 또 사용자 업로드 A를 F로 대체했을 때 관리자 목록에 F가 두 장 보이면 “참조 이동”보다 “복제 생성”처럼 느껴지므로, 사용자 업로드 대체는 참조를 옮긴 뒤 원본 레코드 자체를 정리해 결과적으로 목표 이미지 한 장만 남는 쪽이 운영 기대와 더 잘 맞는다고 정리했다.
## 2026-04-06 v1.4.78
- 사용자 업로드 이미지의 “같은 캐릭터인데 파일만 다른 경우”는 자동 판별하려 들수록 오탐 위험이 커지므로, 관리자 모달에서 대상 이미지를 직접 검색·선택하는 수동 치환 흐름으로 시작하는 편이 가장 안전하다고 판단했다.
- 이때 `src`만 바꾸고 기존 라벨을 남기면 운영자가 통합한 뒤에도 표기가 제각각 남을 수 있으므로, 치환 대상의 `라벨`을 기준으로 사용자 업로드 행과 저장된 티어표/요청 스냅샷 내부 라벨까지 함께 맞춰 주는 편이 운영 목적에 더 부합한다고 정리했다.
## 2026-04-06 v1.4.77
- 작성자 프로필 화면의 공개 티어표 카드는 같은 계열의 다른 목록 뷰와 거의 동일한 마크업을 쓰고 있었지만, `overflow: hidden`과 일부 최소 너비 제약이 빠져 있어 긴 제목/메타/썸네일이 카드 라운드 경계 안에서 안정적으로 잘리지 못한다고 판단했다.
- 또 공유용 프리뷰는 “완성된 티어표 보드”를 보여주는 화면이므로, 편집 중 보조 영역인 미사용 풀까지 노출하면 실제 배치 결과보다 산만해질 수 있어 프리뷰에서는 보드에 배치된 아이템만 노출하는 쪽이 더 일관된다고 정리했다.

View File

@@ -1,36 +1,77 @@
# 업데이트 로그
## 2026-04-06 v1.4.85
- 관리자 아이템 관리의 `미사용 이미지` 범위를 다시 정리해, 사용자 업로드 미사용 항목뿐 아니라 현재 어디에도 연결되지 않은 관리자 자산(`asset`)도 같은 미사용 이미지 목록에서 함께 보이도록 통합했다.
- 그래서 이제 관리자 업로드 이미지라도 템플릿 아이템, 사용자 아이템, 썸네일, 프로필 등 어느 경로에도 연결되지 않으면 `미사용 이미지`로 보고 일괄 삭제할 수 있다.
- 필터 UI도 함께 다듬어, 다른 필터를 보고 있을 때는 위험한 삭제 버튼 대신 `미사용 이미지 확인` 버튼을 보여주고, 그 화면에 들어왔을 때만 `미사용 이미지 일괄 삭제` 버튼이 나타나도록 바꿨다.
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build`
## 2026-04-06 v1.4.84
- 대체된 사용자 업로드 아이템은 이미 다른 이미지로 참조가 옮겨진 상태라면, 관리자 개별 삭제에서 더 이상 `삭제 실패`로 막히지 않도록 삭제 조건을 보정했다.
- `미사용 아이템` 필터 화면과 `미사용 아이템 일괄 삭제` API가 같은 기준으로 움직이도록 맞췄다. 이제 사용 중인 티어표가 0개이고 템플릿 연결도 없을 때만 일반 미사용으로 잡히고, 대체된 아이템은 별도 예외로 계속 정리 대상에 포함된다.
- 커스텀 이미지 레코드를 지운 뒤 실제 업로드 파일을 정리할 때는, 다른 곳에서 같은 파일 경로를 아직 참조 중이면 파일은 남겨 두도록 안전장치를 추가했다.
## 2026-04-06 v1.4.83
- 관리자 아이템 관리 필터에 `대체된 아이템` 모드를 추가해, 이미지 대체 이력이 있는 사용자 업로드 항목만 따로 모아 볼 수 있게 했다.
- 이 필터는 `replaced_at` 메타가 있는 사용자 아이템만 대상으로 하므로, 운영자는 대체 이력 검수와 후속 정리를 일반 사용자 업로드 목록과 분리해 확인할 수 있다.
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build`
## 2026-04-06 v1.4.82
- 관리자 이미지 대체 구조를 다시 정리해, 대체 후에도 원본 A 아이템은 원래 썸네일/라벨 그대로 남고 `대체됨` 메타만 표시되도록 맞췄다. 더 이상 원본 카드가 F 이미지처럼 덮여 보이지 않는다.
- 실제 대체는 이제 `A 아이템 ID`가 가리키는 저장본 표시 정보만 새 이미지/라벨로 바꾸는 방식이라, 대체된 원본 카드에서 `원래 이미지로 복구`를 눌러 A 기준으로 되돌릴 수 있다.
- 대체된 사용자 아이템은 사용량 집계가 남아 있어도 운영상 이미 정리 가능한 상태로 간주해 `미사용 아이템 일괄 삭제`와 개별 삭제 대상에 포함되도록 조정했다.
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build`
## 2026-04-06 v1.4.81
- 관리자 이미지 대체 시 원본 사용자 아이템 레코드의 `src / label`까지 대체 대상 이미지로 덮어써져, 카드와 상세 모달에서 원래 A 이미지 정보를 다시 볼 수 없던 문제를 바로잡았다.
- 이제 대체 처리에서는 티어표/요청/템플릿 참조만 새 이미지로 옮기고, 원본 사용자 아이템 레코드는 원래 이미지와 이름을 그대로 유지한 채 `대체됨` 메타만 남긴다.
- 그래서 관리자 화면에서는 원래 A 썸네일과 A 라벨을 계속 확인하면서도, 이 아이템이 어떤 대상(Y)으로 대체되었는지 함께 보고 이후 수동 정리나 복구 판단을 할 수 있다.
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build`
## 2026-04-06 v1.4.80
- 관리자 이미지 대체는 더 이상 원본 사용자 아이템을 즉시 삭제하지 않고, 원본 레코드와 파일을 남겨 둔 채 `어떤 아이템으로 대체됐는지` 메타만 기록하도록 바꿨다.
- 따라서 대체 후에도 원본 이미지는 아이템 라이브러리에서 계속 확인할 수 있고, 카드와 상세 모달에서 `대체됨` 상태와 대체 대상 라벨을 함께 볼 수 있다.
- 다만 이 원본은 이미 참조가 옮겨진 미사용 사용자 아이템이므로, 기존 `미사용 아이템 일괄 삭제`와 개별 삭제 대상에는 그대로 포함되게 유지해 운영자가 원할 때 정리할 수 있게 했다.
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build`
## 2026-04-06 v1.4.79
- 관리자 이미지 대체 모달은 처음 열었을 때 기존 목록을 자동으로 보여주지 않고, 검색을 실행한 뒤에만 대체 후보를 표시하도록 바꿨다. 같은 티어표/같은 문맥의 항목이 처음부터 섞여 보여 혼란스럽던 점을 줄이기 위한 조정이다.
- 사용자 업로드 아이템을 다른 이미지로 대체할 때는 이제 원본 항목을 그대로 `대상 이미지와 라벨`로 덮어써 중복 항목을 남기지 않고, 참조를 옮긴 뒤 원본 사용자 아이템 레코드와 파일을 함께 정리해 관리자 라이브러리에 동일 이미지가 두 번 보이지 않도록 맞췄다.
- 이미지 대체 기능은 현재 사용자 업로드 아이템에만 노출되도록 제한해, 템플릿 기본 아이템이나 보관 자산까지 같은 방식으로 다뤄 생길 수 있는 오해를 줄였다.
- 확인: `node --check backend/src/routes/admin.js`, `npm run build`
## 2026-04-06 v1.4.78
- 관리자 아이템 관리 모달에 `선택한 이미지로 대체` 기능을 추가했다. 운영자는 대체할 원본 아이템을 연 뒤, 모달 안에서 다른 라이브러리 이미지를 검색·선택해 수동으로 치환할 수 있다.
- 이 치환은 단순히 `src`만 바꾸는 것이 아니라, 선택한 대상 이미지의 `라벨`도 함께 따라가도록 처리해 사용자 업로드 아이템과 티어표 저장 JSON 안의 표기가 같은 이름으로 정리되게 맞췄다.
- 백엔드의 업로드 참조 치환 로직은 이제 `custom_items`, `topic_items`, `tierlists.pool_json`, `template_requests.items_json / board_items_json`까지 `src + label`을 함께 갱신하므로, 같은 캐릭터를 서로 다른 저화질 이미지로 올린 경우도 관리자가 고화질 기준 이미지 하나로 수동 통합할 수 있다.
- 치환 후 기존 이미지 참조가 0이 되면, 기존처럼 미사용 이미지 정리 대상으로 후속 삭제할 수 있다.
- 확인: `node --check backend/src/routes/admin.js`, `node --check backend/src/db.js`, `npm run build`
## 2026-04-06 v1.4.77
- 작성자 프로필 보기 화면에서 공개 티어표 카드의 내부 콘텐츠가 카드 라운드 테두리 밖으로 밀려 보이거나 일부가 잘려 보일 수 있던 문제를 정리했다.
- `UserProfileView`의 카드 본문/헤더에 `overflow: hidden`을 맞추고 썸네일 래퍼에도 `min-width: 0`을 추가해, 다른 목록 화면과 같은 카드 경계 안에서 안정적으로 렌더링되도록 조정했다.
- 티어표 프리뷰 화면에서는 더 이상 `남은 아이템` 풀을 노출하지 않도록 바꿔, 실제 완성본과 공유 미리보기가 같은 기준으로 보이게 맞췄다.
- 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
## 2026-04-03 v1.4.76
- 모바일 티어표 프리뷰에서 오른쪽 레일의 `VIEWER MODE` 카드가 패널 바닥에 딱 붙고, 카피라이트 문구가 카드 뒤쪽 중간 높이에 겹쳐 보일 수 있던 배치를 보정했다.
- 모바일 오른쪽 overlay 레일에서는 `rightRail__content`가 남는 높이를 억지로 채우지 않도록 `flex: 0 0 auto`로 풀고, `localRightRailRoot`의 최소 높이도 `auto`로 낮춰 footer와 콘텐츠가 자연스럽게 순서대로 쌓이게 했다.
- 프리뷰 전용 `viewerSidebar__section``margin-top: auto`는 모바일에서만 끄고, 광고 아래에 바로 카드가 이어지도록 조정했다.
- 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
## 2026-04-03 v1.4.75
- 모바일에서 오른쪽 레일을 열었을 때 패널이 `calc(100vw - 20px)` 폭의 좁은 서랍처럼 떠서 화면 전체를 채우지 못하고, 아래쪽도 어색하게 비어 보이던 부분을 조정했다.
- 모바일 오른쪽 레일 overlay는 `inset: 0`, `width: 100vw`, `height: 100dvh`로 화면 전체를 덮는 패널처럼 열리게 바꾸고, 하단 액션/공유 버튼이 바닥에 붙거나 잘려 보이지 않도록 내부 패딩을 `32px + safe-area`까지 늘렸다.
- 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
## 2026-04-03 v1.4.74
- 모바일 본문 영역에서 `workspaceBody` 배경색이 좌우 마진 안쪽에만 칠해져 중앙에 어설픈 배경 박스가 떠 있는 것처럼 보이던 부분을 정리했다.
- 모바일에서는 공통 워크스페이스 배경을 투명하게 두고, 실제 화면별 카드/섹션 배경만 남겨 덜 미완성처럼 보이도록 조정했다.
- 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
## 2026-04-03 v1.4.73
- 모바일에서 왼쪽 레일 아래 메인 컨텐츠가 화면 중간부터 시작하는 것처럼 보이던 회귀를 수정했다.
- 원인은 모바일 `.appShell`이 1열 그리드로 바뀐 상태에서 세로 행 정의가 없어 `leftRail` 행과 `appMain` 행이 남는 높이를 나눠 가지며 위쪽이 불필요하게 늘어날 수 있던 점이었다. 모바일 그리드를 `auto + minmax(0, 1fr)` 행으로 고정하고 `align-content: start`를 적용해 상단 레일 바로 아래에 본문이 이어지도록 보정했다.
- 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
## 2026-04-03 v1.4.72
- 모바일 공통 상단 헤더(`railHeader`) 좌우 패딩을 `20px`로 넓혀, 오른쪽 레일 토글 버튼과 화면 가장자리 간격이 왼쪽 유저 카드 쪽과 더 자연스럽게 맞도록 조정했다.
- 모바일에서 오른쪽 레일 열기/닫기 아이콘도 왼쪽 네비게이션 토글과 같은 버튼형 카드 스타일로 보이도록 `42px` 크기, 테두리, 배경, 둥근 모서리를 맞췄다.
- 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
## 2026-04-03 v1.4.71
- 모바일에서 본문 페이지나 로그인 화면 하단이 카드/버튼 바로 아래에서 끊겨 보여 답답했던 부분을 줄이기 위해, 공통 워크스페이스 본문 하단에 모바일 safe-area 기반 여백을 추가했다.

View File

@@ -25,6 +25,7 @@ const props = defineProps({
>
{{ item.sourceLabel }}
</span>
<span v-if="item.replacedAt" class="customItemCard__badge customItemCard__badge--replaced">대체됨</span>
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
</button>

View File

@@ -16,6 +16,11 @@ export function useAdminCustomItems({
customItemModalDraftLabel,
customItemModalLabelSaving,
customItemModalTargetTemplateId,
customItemReplacementQuery,
customItemReplacementItems,
customItemReplacementLoading,
customItemReplacementTargetId,
customItemReplacementBusy,
templates,
selectedTemplateId,
refreshCustomItems,
@@ -56,10 +61,38 @@ export function useAdminCustomItems({
customItemModalHistoryActive.value = true
}
async function refreshReplacementCandidates() {
const currentItemId = modalTargetCustomItem.value?.id || ''
if (!currentItemId) {
customItemReplacementItems.value = []
return
}
try {
customItemReplacementLoading.value = true
const data = await api.listAdminCustomItems({
q: customItemReplacementQuery.value,
page: 1,
limit: 50,
filter: 'all',
})
customItemReplacementItems.value = (data.items || []).filter((item) => item?.id && item.id !== currentItemId)
} catch (e) {
error.value = '대체할 이미지 목록을 불러오지 못했어요.'
customItemReplacementItems.value = []
} finally {
customItemReplacementLoading.value = false
}
}
function openCustomItemModal(item) {
modalTargetCustomItem.value = item || null
customItemModalDraftLabel.value = item?.label || ''
customItemModalTargetTemplateId.value = ''
customItemReplacementQuery.value = ''
customItemReplacementItems.value = []
customItemReplacementTargetId.value = ''
customItemReplacementBusy.value = false
customItemModalOpen.value = true
pushCustomItemModalHistoryState()
}
@@ -71,6 +104,11 @@ export function useAdminCustomItems({
customItemModalDraftLabel.value = ''
customItemModalLabelSaving.value = false
customItemModalTargetTemplateId.value = ''
customItemReplacementQuery.value = ''
customItemReplacementItems.value = []
customItemReplacementTargetId.value = ''
customItemReplacementLoading.value = false
customItemReplacementBusy.value = false
if (fromPopState) {
customItemModalHistoryActive.value = false
@@ -85,7 +123,7 @@ export function useAdminCustomItems({
function openCustomItemDeleteModal(item) {
if (!item) return
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
if (item.sourceType === 'user' && !item.replacedAt && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return
}
@@ -109,7 +147,7 @@ export function useAdminCustomItems({
async function removeCustomItem(item = modalTargetCustomItem.value) {
resetMessages()
if (!item) return
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
if (item.sourceType === 'user' && !item.replacedAt && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return
}
@@ -137,18 +175,26 @@ export function useAdminCustomItems({
async function removeUnusedCustomItems() {
resetMessages()
const ok = window.confirm('현재 검색 조건에 맞는 미사용 커스텀 이미지를 모두 삭제할까요?')
const ok = window.confirm('현재 검색 조건에 맞는 미사용 이미지를 모두 삭제할까요?')
if (!ok) return
try {
const data = await api.deleteAdminUnusedCustomItems({ q: customItemQuery.value })
await refreshCustomItems()
success.value = `${data.deletedCount || 0}개의 미사용 사용자 업로드 이미지를 삭제했어요.`
success.value = `${data.deletedCount || 0}개의 미사용 이미지를 삭제했어요.`
} catch (e) {
error.value = '미사용 커스텀 이미지 일괄 삭제에 실패했어요.'
error.value = '미사용 이미지 일괄 삭제에 실패했어요.'
}
}
function showUnusedCustomItems() {
if (customItemFilter.value === 'unused') return
resetMessages()
customItemFilter.value = 'unused'
customItemPage.value = 1
refreshCustomItems()
}
async function saveCustomItemModalLabel() {
const item = modalTargetCustomItem.value
const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60)
@@ -190,6 +236,56 @@ export function useAdminCustomItems({
}
}
async function replaceCustomItem(item = modalTargetCustomItem.value) {
resetMessages()
const targetItem = customItemReplacementItems.value.find((entry) => entry.id === customItemReplacementTargetId.value)
if (!item?.id) {
error.value = '대체할 원본 아이템을 찾지 못했어요.'
return
}
if (!targetItem?.id) {
error.value = '대체할 대상 이미지를 먼저 선택해주세요.'
return
}
try {
customItemReplacementBusy.value = true
const data = await api.replaceAdminCustomItem(item.id, {
targetItemId: targetItem.id,
targetSourceType: targetItem.sourceType || 'user',
})
if (selectedTemplateId.value) await loadTemplate()
await refreshCustomItems()
closeCustomItemModal()
success.value = `"${item.label}" 이미지를 "${data.targetItem?.label || targetItem.label}" 기준으로 대체했어요.`
} catch (e) {
error.value = e?.status === 409 ? '같은 이미지/이름으로는 대체할 수 없어요.' : '이미지 대체에 실패했어요.'
} finally {
customItemReplacementBusy.value = false
}
}
async function restoreCustomItem(item = modalTargetCustomItem.value) {
resetMessages()
if (!item?.id || !item.replacedAt) {
error.value = '복구할 대체 이력이 없어요.'
return
}
try {
customItemReplacementBusy.value = true
await api.restoreAdminCustomItem(item.id)
if (selectedTemplateId.value) await loadTemplate()
await refreshCustomItems()
closeCustomItemModal()
success.value = `"${item.label}" 아이템을 원래 이미지로 복구했어요.`
} catch (e) {
error.value = '원래 이미지로 복구하지 못했어요.'
} finally {
customItemReplacementBusy.value = false
}
}
return {
submitCustomItemSearch,
changeCustomItemFilter,
@@ -203,7 +299,11 @@ export function useAdminCustomItems({
jumpToTemplateAdmin,
removeCustomItem,
removeUnusedCustomItems,
showUnusedCustomItems,
saveCustomItemModalLabel,
promoteCustomItem,
refreshReplacementCandidates,
replaceCustomItem,
restoreCustomItem,
}
}

View File

@@ -104,6 +104,10 @@ export const api = {
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
promoteAdminTemplateItem: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
replaceAdminCustomItem: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/replace`, { method: 'POST', body: payload }),
restoreAdminCustomItem: (itemId) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/restore`, { method: 'POST', body: {} }),
updateAdminCustomItemLabel: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
promoteAdminTierListItems: (tierListId, payload) =>

View File

@@ -46,6 +46,11 @@ const customItemLimit = ref(50)
const customItemTotal = ref(0)
const customItemFilter = ref('library')
const customItemModalTargetTemplateId = ref('')
const customItemReplacementQuery = ref('')
const customItemReplacementItems = ref([])
const customItemReplacementLoading = ref(false)
const customItemReplacementTargetId = ref('')
const customItemReplacementBusy = ref(false)
const adminTierLists = ref([])
const adminTierListQuery = ref('')
@@ -227,6 +232,9 @@ const filteredTemplatePickerTemplates = computed(() => {
})
})
const customItemTargetTemplate = computed(() => templates.value.find((template) => template.id === customItemModalTargetTemplateId.value) || null)
const customItemReplacementTarget = computed(
() => customItemReplacementItems.value.find((item) => item.id === customItemReplacementTargetId.value) || null
)
const importModalItemCount = computed(() => importModalItems.value.length)
const activeTabTitle = computed(() => {
if (activeTab.value === 'featured') return '목록 관리'
@@ -647,6 +655,18 @@ const visibleLinkedTemplates = computed(() =>
(modalTargetCustomItem.value?.linkedTemplates || []).filter((template) => template?.id && template.id !== 'freeform')
)
const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean)))
const canReplaceModalTarget = computed(() => modalTargetCustomItem.value?.sourceType === 'user')
const replacementCandidateCount = computed(() => customItemReplacementItems.value.length)
const hasDeletableUnusedItems = computed(() =>
customItems.value.some(
(item) =>
(item?.sourceType === 'user' &&
(!!item.replacedAt ||
(Number(item.usageCount || 0) === 0 && !(Array.isArray(item.linkedTemplates) && item.linkedTemplates.length > 0)))) ||
item?.sourceType === 'asset' ||
!!item?.isAssetLibraryItem
)
)
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
const imageStatsYearOptions = computed(() => {
@@ -1039,8 +1059,12 @@ const {
jumpToTemplateAdmin,
removeCustomItem,
removeUnusedCustomItems,
showUnusedCustomItems,
saveCustomItemModalLabel,
promoteCustomItem,
refreshReplacementCandidates,
replaceCustomItem,
restoreCustomItem,
} = useAdminCustomItems({
api,
toast,
@@ -1057,6 +1081,11 @@ const {
customItemModalDraftLabel,
customItemModalLabelSaving,
customItemModalTargetTemplateId,
customItemReplacementQuery,
customItemReplacementItems,
customItemReplacementLoading,
customItemReplacementTargetId,
customItemReplacementBusy,
templates,
selectedTemplateId,
refreshCustomItems,
@@ -2079,6 +2108,54 @@ function openUserProfile(user) {
<button class="btn btn--ghost" type="button" @click="openTemplatePickerModal('custom-item-target')">템플릿 선택</button>
<button class="btn btn--ghost btn--small customItemModal__createTemplateButton" type="button" @click="openTemplateCreateModal"> 템플릿 만들기</button>
</div>
<template v-if="canReplaceModalTarget">
<div class="customItemModal__pickerHead">
<div class="customItemModal__pickerEyebrow">IMAGE REPLACEMENT</div>
<div class="customItemModal__pickerTitle">대체할 이미지 선택</div>
</div>
<div class="adminSelectionCard">
<div class="adminSelectionCard__label">선택한 대체 이미지</div>
<div class="adminSelectionCard__title">{{ customItemReplacementTarget?.label || '아직 선택하지 않음' }}</div>
<div class="adminSelectionCard__meta">{{ customItemReplacementTarget?.sourceLabel || '검색 후 대체할 이미지를 골라 주세요.' }}</div>
</div>
<div class="customItemModal__pickerActions">
<input
v-model="customItemReplacementQuery"
class="input"
type="text"
maxlength="120"
placeholder="대체할 이미지 이름 또는 파일명 검색"
@keydown.enter.prevent="refreshReplacementCandidates"
/>
<button class="btn btn--ghost btn--small" type="button" @click="refreshReplacementCandidates">
{{ customItemReplacementLoading ? '검색중...' : '대체 이미지 검색' }}
</button>
</div>
<div class="customItemModal__replacementList">
<button
v-for="item in customItemReplacementItems"
:key="item.id"
class="adminTemplatePicker__item"
:class="{ 'adminTemplatePicker__item--active': customItemReplacementTargetId === item.id }"
type="button"
@click="customItemReplacementTargetId = item.id"
>
<span class="customItemModal__replacementRow">
<img class="customItemModal__replacementThumb" :src="toApiUrl(item.src)" :alt="item.label" />
<span class="customItemModal__replacementCopy">
<span class="adminTemplatePicker__name">{{ item.label }}</span>
<span class="adminTemplatePicker__meta">{{ item.sourceLabel }} · {{ item.ownerName || '알 수 없음' }}</span>
</span>
</span>
</button>
<div v-if="!customItemReplacementLoading && !replacementCandidateCount" class="hint hint--tight">
검색 전에는 대체 후보를 보여주지 않아요. 검색어를 입력한 직접 찾아 선택해주세요.
</div>
</div>
</template>
<div v-else class="hint hint--tight">
이미지 대체는 현재 사용자 업로드 아이템에서만 지원합니다.
</div>
</aside>
<div class="customItemModal__body">
<button class="customItemModal__close" type="button" @click="closeCustomItemModal">닫기</button>
@@ -2114,12 +2191,31 @@ function openUserProfile(user) {
</div>
<div v-else class="hint hint--tight">아직 템플릿에 연결된 항목이 없어요.</div>
</div>
<div v-if="modalTargetCustomItem.replacedAt" class="customItemModal__linked">
<span class="customItemModal__label">대체 상태</span>
<div class="customItemModal__titleRow">
<div>
<div class="customItemModal__title">{{ modalTargetCustomItem.replacedByLabel || '대체 대상 이름 없음' }}</div>
<div class="customItemModal__source"> 아이템은 대상 이미지로 대체된 상태예요.</div>
</div>
</div>
<div class="customItemModal__metaRow">
<span>대체 시각</span>
<strong>{{ fmt(modalTargetCustomItem.replacedAt) }}</strong>
</div>
</div>
<div class="customItemModal__actions">
<a class="btn btn--ghost customItemModal__action" :href="toApiUrl(modalTargetCustomItem.src)" :download="modalTargetCustomItem.label">이미지 다운로드</a>
<button class="btn btn--ghost customItemModal__action" :disabled="!customItemModalTargetTemplateId || modalTargetCustomItem.isPromoting" @click="promoteCustomItem(modalTargetCustomItem)">
{{ modalTargetCustomItem.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
</button>
<button v-if="modalTargetCustomItem.canDelete" class="btn btn--danger customItemModal__action" :disabled="modalTargetCustomItem.sourceType === 'user' && (modalTargetCustomItem.usageCount > 0 || visibleLinkedTemplates.length > 0)" @click="openCustomItemDeleteModal(modalTargetCustomItem)">삭제</button>
<button v-if="canReplaceModalTarget" class="btn btn--primary customItemModal__action" :disabled="!customItemReplacementTargetId || customItemReplacementBusy" @click="replaceCustomItem(modalTargetCustomItem)">
{{ customItemReplacementBusy ? '대체중...' : '선택한 이미지로 대체' }}
</button>
<button v-if="modalTargetCustomItem.replacedAt" class="btn btn--ghost customItemModal__action" :disabled="customItemReplacementBusy" @click="restoreCustomItem(modalTargetCustomItem)">
{{ customItemReplacementBusy ? '복구중...' : '원래 이미지로 복구' }}
</button>
<button v-if="modalTargetCustomItem.canDelete" class="btn btn--danger customItemModal__action" :disabled="modalTargetCustomItem.sourceType === 'user' && !modalTargetCustomItem.replacedAt && (modalTargetCustomItem.usageCount > 0 || visibleLinkedTemplates.length > 0)" @click="openCustomItemDeleteModal(modalTargetCustomItem)">삭제</button>
</div>
</div>
</div>
@@ -2361,14 +2457,23 @@ function openUserProfile(user) {
<option value="library">아이템(템플릿 + 사용자)</option>
<option value="template">템플릿 아이템</option>
<option value="user">사용자 아이템</option>
<option value="replaced-user">대체된 아이템</option>
<option value="thumbnail">썸네일 이미지</option>
<option value="avatar">프로필 이미지</option>
<option value="unused-user">미사용 아이템</option>
<option value="unused">미사용 이미지</option>
</select>
</div>
<div class="adminSidebar__actions">
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
<button class="btn btn--danger" :disabled="customItemFilter !== 'unused-user' || !customItems.length" @click="removeUnusedCustomItems">미사용 아이템 일괄 삭제</button>
<button
v-if="customItemFilter === 'unused'"
class="btn btn--danger"
:disabled="!hasDeletableUnusedItems"
@click="removeUnusedCustomItems"
>
미사용 이미지 일괄 삭제
</button>
<button v-else class="btn btn--ghost" @click="showUnusedCustomItems">미사용 이미지 확인</button>
</div>
<div class="adminSidebar__stats">
<div class="sidebarStat">
@@ -3552,6 +3657,11 @@ function openUserProfile(user) {
.adminUiScope .customItemCard__badge--asset {
background: rgba(251, 191, 36, 0.18);
}
.adminUiScope .customItemCard__badge--replaced {
top: 40px;
background: rgba(245, 158, 11, 0.2);
color: #fde68a;
}
.adminUiScope .customItemCard:hover {
border-color: rgba(126, 162, 255, 0.42);
background: rgba(255, 255, 255, 0.06);
@@ -3611,6 +3721,29 @@ function openUserProfile(user) {
display: grid;
gap: 10px;
}
.adminUiScope .customItemModal__replacementList {
display: grid;
gap: 8px;
}
.adminUiScope .customItemModal__replacementRow {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
gap: 10px;
align-items: center;
}
.adminUiScope .customItemModal__replacementThumb {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 12px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
}
.adminUiScope .customItemModal__replacementCopy {
min-width: 0;
display: grid;
gap: 2px;
}
.adminUiScope .customItemModal__createTemplateButton {
justify-self: start;
}
@@ -3736,7 +3869,7 @@ function openUserProfile(user) {
}
.adminUiScope .customItemModal__actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
align-self: end;
}