Compare commits

...

9 Commits

12 changed files with 1342 additions and 197 deletions

View File

@@ -32,6 +32,21 @@ function serializeJson(value) {
return JSON.stringify(value || [])
}
function normalizeTags(tags) {
const values = Array.isArray(tags)
? tags
: typeof tags === 'string'
? tags.split(',')
: []
return Array.from(
new Set(
values
.map((tag) => String(tag || '').trim().replace(/^#/, '').slice(0, 40))
.filter(Boolean)
)
).slice(0, 30)
}
function normalizeTopicSlug(value) {
return String(value || '').trim().toLowerCase()
}
@@ -100,6 +115,7 @@ function mapTopicRow(row) {
isPublic: row.is_public == null ? true : !!row.is_public,
displayRank: row.display_rank == null ? null : Number(row.display_rank),
createdAt: Number(row.created_at),
tags: normalizeTags(parseJson(row.tags_json, [])),
}
}
@@ -112,9 +128,57 @@ function mapTopicItemRow(row) {
label: row.label,
displayOrder: row.display_order == null ? null : Number(row.display_order),
createdAt: Number(row.created_at),
tags: normalizeTags(parseJson(row.tags_json, [])),
}
}
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),
tags: normalizeTags(parseJson(row.tags_json, [])),
}
}
function getSharedItemDisplayPriority(item) {
if (!item) return 99
if (item.sourceType === 'user' && !item.replacedAt) return 0
if (item.sourceType === 'user') return 1
if (item.sourceType === 'template') return 2
if (item.sourceType === 'asset' || item.isAssetLibraryItem) return 3
return 4
}
function collapseSharedLibraryItems(items) {
const grouped = new Map()
for (const item of items || []) {
const key = String(item?.src || '').trim()
if (!key) continue
if (!grouped.has(key)) grouped.set(key, [])
grouped.get(key).push(item)
}
return Array.from(grouped.values())
.map((group) =>
group
.slice()
.sort((a, b) => {
const priorityDiff = getSharedItemDisplayPriority(a) - getSharedItemDisplayPriority(b)
if (priorityDiff !== 0) return priorityDiff
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
})[0]
)
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
}
function mapImageAssetRow(row) {
if (!row) return null
return {
@@ -334,6 +398,7 @@ async function ensureSchema() {
slug VARCHAR(120) NOT NULL,
name VARCHAR(120) NOT NULL,
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
tags_json LONGTEXT NULL,
is_public TINYINT(1) NOT NULL DEFAULT 1,
display_rank INT NULL DEFAULT NULL,
created_at BIGINT NOT NULL,
@@ -347,6 +412,7 @@ async function ensureSchema() {
topic_id VARCHAR(120) NOT NULL,
src VARCHAR(255) NOT NULL,
label VARCHAR(120) NOT NULL,
tags_json LONGTEXT NULL,
display_order INT NULL DEFAULT NULL,
created_at BIGINT NOT NULL,
INDEX idx_topic_items_topic_id (topic_id),
@@ -360,12 +426,25 @@ async function ensureSchema() {
owner_id VARCHAR(64) NOT NULL,
src VARCHAR(255) NOT NULL,
label VARCHAR(120) NOT NULL,
tags_json LONGTEXT 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 topics ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER thumbnail_src`)
await query(`ALTER TABLE topic_items ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER label`)
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER label`)
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,
@@ -493,11 +572,11 @@ async function ensureSchema() {
await query(
`
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)
INSERT INTO topics (id, slug, name, thumbnail_src, tags_json, is_public, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE slug = VALUES(slug), name = VALUES(name), tags_json = VALUES(tags_json), is_public = VALUES(is_public)
`,
[FREEFORM_TOPIC_ID, FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', 1, now()]
[FREEFORM_TOPIC_ID, FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', '[]', 1, now()]
)
})()
@@ -810,7 +889,7 @@ async function listTopics(currentUserId = '', options = {}) {
const includePrivate = !!options.includePrivate
const rows = await query(
`
SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at
SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at
FROM topics
WHERE id <> ?
${includePrivate ? '' : 'AND is_public = 1'}
@@ -835,7 +914,7 @@ async function listTopics(currentUserId = '', options = {}) {
async function findTopicById(id) {
const rows = await query(
'SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1',
'SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1',
[id]
)
return mapTopicRow(rows[0])
@@ -845,7 +924,7 @@ 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',
'SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at FROM topics WHERE slug = ? LIMIT 1',
[normalizedSlug]
)
return mapTopicRow(rows[0])
@@ -858,7 +937,7 @@ async function findTopicByIdentifier(topicRef) {
const rows = await query(
`
SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at
SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at
FROM topics
WHERE id = ? OR slug = ?
ORDER BY
@@ -873,7 +952,7 @@ async function findTopicByIdentifier(topicRef) {
async function listTopicItems(topicId) {
const rows = await query(
`
SELECT id, topic_id, src, label, display_order, created_at
SELECT id, topic_id, src, label, tags_json, display_order, created_at
FROM topic_items
WHERE topic_id = ?
ORDER BY
@@ -888,7 +967,7 @@ async function listTopicItems(topicId) {
}
async function findTopicItemById(itemId) {
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
return mapTopicItemRow(rows[0])
}
@@ -899,14 +978,15 @@ async function getTopicDetail(topicRef) {
return { topic, template: topic, items }
}
async function createTopic({ slug, name, isPublic = true }) {
async function createTopic({ slug, name, tags = [], 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 (?, ?, ?, ?, ?, ?, ?)', [
await query('INSERT INTO topics (id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [
topicId,
topicSlug,
name,
'',
serializeJson(normalizeTags(tags)),
isPublic ? 1 : 0,
null,
now(),
@@ -914,11 +994,12 @@ async function createTopic({ slug, name, isPublic = true }) {
return findTopicById(topicId)
}
async function updateTopicMeta(topicId, { slug, name, isPublic }) {
async function updateTopicMeta(topicId, { slug, name, tags = [], isPublic }) {
const topicSlug = assertTopicSlug(slug)
await query('UPDATE topics SET slug = ?, name = ?, is_public = ? WHERE id = ?', [
await query('UPDATE topics SET slug = ?, name = ?, tags_json = ?, is_public = ? WHERE id = ?', [
topicSlug,
name,
serializeJson(normalizeTags(tags)),
isPublic ? 1 : 0,
topicId,
])
@@ -1128,7 +1209,22 @@ function replaceItemSrc(items, fromSrc, toSrc, toLabel = '') {
return { changed, items: nextItems }
}
async function replaceUploadSourceReferences({ fromSrc, toSrc, toLabel = '' }) {
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) : ''
@@ -1138,9 +1234,11 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc, toLabel = '' }) {
normalizedLabel
? query('UPDATE topic_items SET src = ?, label = ? WHERE src = ?', [toSrc, normalizedLabel, fromSrc])
: query('UPDATE topic_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
normalizedLabel
? query('UPDATE custom_items SET src = ?, label = ? WHERE src = ?', [toSrc, normalizedLabel, fromSrc])
: query('UPDATE custom_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)
@@ -1196,6 +1294,40 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc, toLabel = '' }) {
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'
@@ -1477,26 +1609,37 @@ async function clearImageOptimizationJobs({ month } = {}) {
const result = await query('DELETE FROM image_optimization_jobs')
return Number(result.affectedRows || 0)
}
async function createTopicItem({ id, topicId, src, label }) {
async function createTopicItem({ id, topicId, src, label, tags = [] }) {
const createdAt = now()
const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM topic_items WHERE topic_id = ?', [topicId])
const nextDisplayOrder =
minOrderRows[0]?.min_display_order == null ? 0 : Number(minOrderRows[0].min_display_order) - 1
await query('INSERT INTO topic_items (id, topic_id, src, label, display_order, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
await query('INSERT INTO topic_items (id, topic_id, src, label, tags_json, display_order, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [
id,
topicId,
src,
label,
serializeJson(normalizeTags(tags)),
nextDisplayOrder,
createdAt,
])
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [id])
const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [id])
return mapTopicItemRow(rows[0])
}
async function updateTopicItemLabel(itemId, label) {
await query('UPDATE topic_items SET label = ? WHERE id = ?', [label, itemId])
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
return mapTopicItemRow(rows[0])
}
async function updateTopicItemMeta(itemId, { label, tags = [] }) {
await query('UPDATE topic_items SET label = ?, tags_json = ? WHERE id = ?', [
label,
serializeJson(normalizeTags(tags)),
itemId,
])
const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
return mapTopicItemRow(rows[0])
}
@@ -1518,7 +1661,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.tags_json, 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 = ?
@@ -1527,16 +1670,50 @@ 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 updateCustomItemMeta(itemId, { label, tags = [] }) {
await query('UPDATE custom_items SET label = ?, tags_json = ? WHERE id = ?', [
label,
serializeJson(normalizeTags(tags)),
itemId,
])
const rows = await query(`
SELECT c.id, c.owner_id, c.src, c.label, c.tags_json, 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 = ?
LIMIT 1
`, [itemId])
const row = rows[0]
if (!row) return null
return {
...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])
@@ -1593,16 +1770,17 @@ async function updateTopicDisplayOrder(topicIds) {
return listTopics()
}
async function createCustomItem({ id, ownerId, src, label }) {
async function createCustomItem({ id, ownerId, src, label, tags = [] }) {
const createdAt = now()
await query('INSERT INTO custom_items (id, owner_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
await query('INSERT INTO custom_items (id, owner_id, src, label, tags_json, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
id,
ownerId,
src,
label,
serializeJson(normalizeTags(tags)),
createdAt,
])
return { id, ownerId, src, label, origin: 'custom', createdAt }
return { id, ownerId, src, label, tags: normalizeTags(tags), origin: 'custom', createdAt }
}
async function syncOwnedCustomItemLabels({ ownerId, items }) {
@@ -1626,7 +1804,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, tags_json, replaced_by_item_id, replaced_by_src, replaced_by_label, replaced_at, created_at
FROM custom_items
WHERE id = ?
LIMIT 1
@@ -1635,14 +1813,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() {
@@ -1692,7 +1863,7 @@ async function getCustomItemUsageMeta() {
}
}
async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMode = 'all' } = {}) {
async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMode = 'all', collapseShared = false } = {}) {
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
const normalizedPage = Math.max(Number(page) || 1, 1)
const searchText = (queryText || '').trim()
@@ -1707,15 +1878,20 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
c.owner_id,
c.src,
c.label,
c.tags_json,
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
${hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''}
${hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR c.tags_json LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''}
ORDER BY c.created_at DESC
`,
hasQuery ? [search, search, search, search] : []
hasQuery ? [search, search, search, search, search] : []
),
query(
`
@@ -1724,14 +1900,15 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
gi.topic_id,
gi.src,
gi.label,
gi.tags_json,
gi.created_at,
tp.name AS topic_name
FROM topic_items gi
INNER JOIN topics tp ON tp.id = gi.topic_id
${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.topic_id LIKE ? OR tp.name LIKE ?' : ''}
${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.tags_json LIKE ? OR gi.topic_id LIKE ? OR tp.name LIKE ?' : ''}
ORDER BY gi.created_at DESC
`,
hasQuery ? [search, search, search, search] : []
hasQuery ? [search, search, search, search, search] : []
),
query(
`
@@ -1786,17 +1963,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,
}
})
@@ -1815,8 +1989,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerId: '',
src: row.src,
label: row.label_override || (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음',
tags: [],
createdAt: Number(row.created_at || 0),
ownerName: '관리자 보관 자산',
ownerName: '관리자 미사용 이미지',
ownerEmail: '',
usageCount: 0,
linkedTemplates: [],
@@ -1834,6 +2009,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerId: '',
src: row.src,
label: row.label,
tags: normalizeTags(parseJson(row.tags_json, [])),
createdAt: Number(row.created_at),
ownerName: row.topic_name || row.topic_id,
ownerEmail: '',
@@ -1895,6 +2071,11 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
usageCount: entry.usageCount || 0,
linkedTemplates: entry.linkedTemplates || [],
isAssetLibraryItem: !!entry.isAssetLibraryItem,
tags: entry.tags || [],
replacedByItemId: entry.replacedByItemId || '',
replacedBySrc: entry.replacedBySrc || '',
replacedByLabel: entry.replacedByLabel || '',
replacedAt: entry.replacedAt || 0,
})),
}
})
@@ -1912,8 +2093,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:
@@ -1922,9 +2107,10 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
})
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
const total = allItems.length
const visibleItems = collapseShared ? collapseSharedLibraryItems(allItems) : allItems
const total = visibleItems.length
const offset = (normalizedPage - 1) * normalizedLimit
const pagedItems = allItems.slice(offset, offset + normalizedLimit)
const pagedItems = visibleItems.slice(offset, offset + normalizedLimit)
return {
items: pagedItems,
@@ -1937,40 +2123,59 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
async function findUnusedCustomItems({ queryText = '' } = {}) {
const hasQuery = !!(queryText || '').trim()
const search = `%${(queryText || '').trim()}%`
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.tags_json,
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
${hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR c.tags_json LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''}
ORDER BY c.created_at DESC
`,
hasQuery ? [search, search, search, search, search] : []
),
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 = '') {
@@ -2895,20 +3100,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, tags_json, 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) {
@@ -3053,17 +3252,22 @@ module.exports = {
listReferencedUploadSources,
listReferencedUploadUsage,
replaceUploadSourceReferences,
updateCustomItemDisplayReferences,
clearImageOptimizationJobs,
getImageAssetStats,
cleanupMissingUploadReferences,
createTopicItem,
updateTopicItemLabel,
updateTopicItemMeta,
updateTopicItemDisplayOrder,
countTierListsUsingTopicItem,
deleteTopicItem,
deleteTopic,
updateTopicDisplayOrder,
updateCustomItemLabel,
updateCustomItemMeta,
clearCustomItemReplacement,
markCustomItemReplaced,
updateImageAssetLabel,
createCustomItem,
findCustomItemById,

View File

@@ -20,9 +20,11 @@ const {
updateTopicThumbnail,
createTopicItem,
updateTopicItemLabel,
updateTopicItemMeta,
updateTopicItemDisplayOrder,
countTierListsUsingTopicItem,
updateCustomItemLabel,
updateCustomItemMeta,
updateImageAssetLabel,
deleteTopicItem,
deleteTopic,
@@ -33,6 +35,7 @@ const {
findUnusedCustomItems,
findCustomItemsByIds,
deleteCustomItems,
markCustomItemReplaced,
listUsers,
findPrimaryAdminUser,
listAdminTierLists,
@@ -53,7 +56,10 @@ const {
listRecentImageOptimizationJobs,
clearImageOptimizationJobs,
cleanupMissingUploadReferences,
listReferencedUploadSources,
replaceUploadSourceReferences,
updateCustomItemDisplayReferences,
clearCustomItemReplacement,
} = require('../db')
const { requireAdmin } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
@@ -95,6 +101,17 @@ function buildItemLabelFromSrc(src) {
const upload = createMemoryUpload(multer, { fileSize: 20 * 1024 * 1024, maxCount: 100 })
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
function normalizeRouteTags(tags) {
const values = Array.isArray(tags) ? tags : typeof tags === 'string' ? tags.split(',') : []
return Array.from(
new Set(
values
.map((tag) => String(tag || '').trim().replace(/^#/, '').slice(0, 40))
.filter(Boolean)
)
).slice(0, 30)
}
function decorateAdminUser(user, primaryAdmin) {
if (!user) return null
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
@@ -123,6 +140,7 @@ router.post('/templates', requireAdmin, async (req, res) => {
const schema = z.object({
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
name: z.string().min(1).max(60),
tags: z.array(z.string().trim().min(1).max(40)).max(30).optional().default([]),
isPublic: z.boolean().optional().default(false),
thumbnailSrc: z.string().max(255).optional().default(''),
})
@@ -132,7 +150,12 @@ router.post('/templates', requireAdmin, async (req, res) => {
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 })
template = await createTopic({
slug: parsed.data.slug,
name: parsed.data.name,
tags: normalizeRouteTags(parsed.data.tags),
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' })
@@ -150,6 +173,7 @@ router.patch('/templates/:templateId', requireAdmin, async (req, res) => {
const schema = z.object({
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(),
tags: z.array(z.string().trim().min(1).max(40)).max(30).optional(),
isPublic: z.boolean().optional(),
})
const parsed = schema.safeParse(req.body)
@@ -165,6 +189,7 @@ router.patch('/templates/:templateId', requireAdmin, async (req, res) => {
? await updateTopicMeta(template.id, {
slug: parsed.data.slug || template.slug,
name: parsed.data.name || template.name,
tags: typeof parsed.data.tags !== 'undefined' ? normalizeRouteTags(parsed.data.tags) : template.tags || [],
isPublic: typeof parsed.data.isPublic === 'boolean' ? parsed.data.isPublic : template.isPublic,
})
: await findTopicById(template.id)
@@ -305,14 +330,20 @@ router.get('/templates/:templateId/items/:itemId/usage', requireAdmin, async (re
})
router.patch('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => {
const schema = z.object({ label: z.string().trim().min(1).max(60) })
const schema = z.object({
label: z.string().trim().min(1).max(60),
tags: z.array(z.string().trim().min(1).max(40)).max(30).optional().default([]),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const template = await findTopicById(getTemplateIdFromParams(req))
if (!template) return res.status(404).json({ error: 'not_found' })
const updated = await updateTopicItemLabel(req.params.itemId, parsed.data.label)
const updated = await updateTopicItemMeta(req.params.itemId, {
label: parsed.data.label,
tags: normalizeRouteTags(parsed.data.tags),
})
if (!updated || updated.topicId !== template.id) return res.status(404).json({ error: 'not_found' })
res.json({ item: updated })
})
@@ -328,6 +359,7 @@ router.delete('/templates/:templateId', requireAdmin, async (req, res) => {
router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
const schema = z.object({
label: z.string().trim().min(1).max(60),
tags: z.array(z.string().trim().min(1).max(40)).max(30).optional().default([]),
sourceType: z.enum(['template', 'user', 'asset']).optional().default('user'),
})
const parsed = schema.safeParse(req.body)
@@ -341,12 +373,18 @@ router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
}
if (parsed.data.sourceType === 'template') {
const updated = await updateTopicItemLabel(itemId, parsed.data.label)
const updated = await updateTopicItemMeta(itemId, {
label: parsed.data.label,
tags: normalizeRouteTags(parsed.data.tags),
})
if (!updated) return res.status(404).json({ error: 'not_found' })
return res.json({ item: updated })
}
const updated = await updateCustomItemLabel(itemId, parsed.data.label)
const updated = await updateCustomItemMeta(itemId, {
label: parsed.data.label,
tags: normalizeRouteTags(parsed.data.tags),
})
if (!updated) return res.status(404).json({ error: 'not_found' })
return res.json({ item: updated })
})
@@ -356,8 +394,12 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
q: z.string().trim().max(120).optional().default(''),
page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
collapseShared: z
.union([z.string(), z.boolean(), z.number()])
.optional()
.transform((value) => value === true || value === 1 || value === '1' || value === 'true'),
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'),
})
@@ -369,6 +411,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
page: parsed.data.page,
limit: parsed.data.limit,
filterMode: parsed.data.filter,
collapseShared: parsed.data.collapseShared,
})
res.json(result)
})
@@ -543,12 +586,19 @@ 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(),
topicId: templateId,
src: item.src || '',
label: item.label,
tags: item.tags || [],
})
}
@@ -566,6 +616,7 @@ async function findLibraryItemForReplacement(itemId, sourceType = '') {
sourceType: 'asset',
src: asset.src || '',
label: asset.labelOverride || buildItemLabelFromSrc(asset.src),
tags: [],
}
}
@@ -577,6 +628,7 @@ async function findLibraryItemForReplacement(itemId, sourceType = '') {
sourceType: 'template',
src: item.src || '',
label: item.label || buildItemLabelFromSrc(item.src),
tags: item.tags || [],
}
}
@@ -587,6 +639,7 @@ async function findLibraryItemForReplacement(itemId, sourceType = '') {
sourceType: 'user',
src: customItem.src || '',
label: customItem.label || buildItemLabelFromSrc(customItem.src),
tags: customItem.tags || [],
}
}
@@ -597,6 +650,7 @@ async function findLibraryItemForReplacement(itemId, sourceType = '') {
sourceType: 'template',
src: templateItem.src || '',
label: templateItem.label || buildItemLabelFromSrc(templateItem.src),
tags: templateItem.tags || [],
}
}
@@ -773,13 +827,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' })
})
@@ -812,6 +867,27 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
res.json({ item })
})
router.post('/custom-items/:itemId/unlink-template', requireAdmin, async (req, res) => {
const schema = z.object({
topicId: z.string().trim().min(1),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const template = await findTopicById(parsed.data.topicId)
if (!template) return res.status(404).json({ error: 'topic_not_found' })
const sourceItem = await findLibraryItemForReplacement(req.params.itemId)
if (!sourceItem?.src) return res.status(404).json({ error: 'not_found' })
const templateItems = await listTopicItems(template.id)
const matchedItems = templateItems.filter((item) => item?.src === sourceItem.src)
if (!matchedItems.length) return res.status(404).json({ error: 'linked_template_item_not_found' })
await Promise.all(matchedItems.map((item) => deleteTopicItem(item.id)))
res.json({ ok: true, deletedCount: matchedItems.length, topicId: template.id, src: sourceItem.src })
})
router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => {
const schema = z.object({
targetItemId: z.string().trim().min(1),
@@ -822,6 +898,7 @@ router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => {
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' })
@@ -833,16 +910,47 @@ router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => {
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,
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),
@@ -1097,11 +1205,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,48 @@
# 의사결정 이력
## 2026-04-06 v1.4.88
- 같은 이미지를 사용자 항목, 템플릿 항목, 관리자 자산으로 각각 따로 카드에 늘어놓으면 운영자가 실제로 보고 싶은 “이미지 단위 상태”보다 내부 저장 단위가 더 크게 드러나므로, 관리자 목록과 검색은 기본적으로 같은 `src`를 하나로 묶어 보여주는 편이 더 자연스럽다고 정리했다.
- 아이템 모달의 연결 템플릿 배지는 단순히 해당 템플릿 화면으로 점프하는 것보다, 그 자리에서 `이 템플릿에서 제외`를 바로 수행하는 액션이 훨씬 직접적이므로 배지에 제거 버튼을 붙이는 쪽이 더 낫다고 판단했다.
- 한글 태그 입력은 IME 조합 중 `Enter`가 중간 문자열까지 함께 커밋될 수 있으므로, 배지형 태그 입력에서는 조합 상태를 명시적으로 감지해 완성된 문자열만 태그로 추가하는 편이 맞다고 정리했다.
## 2026-04-06 v1.4.87
- 템플릿 태그는 이름/slug 검색과 역할이 겹치고, 운영자가 실제로 원하는 것은 “템플릿 찾기”보다 “아이템 묶음 분류”에 가까웠으므로 템플릿 화면에서 직접 노출하지 않는 편이 더 맞다고 정리했다.
- 태그 입력도 카드 곳곳에 흩어져 있으면 메인 작업인 업로드와 이름 정리가 묻히기 쉬우므로, 태그는 관리자 아이템 모달에서만 배지형으로 다루고 템플릿 화면 본문은 가볍게 유지하는 쪽을 택했다.
- 기존 템플릿을 통째로 가져오는 기능만으로는 운영자가 “조건에 맞는 일부 아이템만” 빠르게 합치는 흐름이 부족하므로, 이름·파일명·태그 기반 개별 아이템 검색 후 초안 목록으로 담아두는 보조 모달을 추가하는 편이 더 실용적이라고 판단했다.
## 2026-04-06 v1.4.86
- 내부 운영용 분류는 공개 노출용 필드와 분리하는 편이 맞다고 보고, 태그는 템플릿과 아이템의 관리자 전용 메타로만 저장하고 검색에 활용하는 방향으로 정리했다.
- 기존 분기 템플릿이나 요청 아이템을 다시 재조합해 새 템플릿을 만드는 흐름은 완전히 별도 마법 기능보다, 이미 있는 `초안 목록`에 다른 출처의 아이템을 합류시키고 `제외` 버튼으로 정리하는 편이 훨씬 예측 가능하다고 판단했다.
- 사용자가 잘못 올린 이미지는 파일 업로드 자체를 되돌리는 것보다 “현재 티어표에서 이 커스텀 아이템을 제거”하는 조작이 먼저 필요하다고 보고, 저장 전 blob 이미지와 저장 후 커스텀 아이템 모두 같은 제거 UX로 다루는 쪽을 채택했다.
## 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`만 바꾸고 기존 라벨을 남기면 운영자가 통합한 뒤에도 표기가 제각각 남을 수 있으므로, 치환 대상의 `라벨`을 기준으로 사용자 업로드 행과 저장된 티어표/요청 스냅샷 내부 라벨까지 함께 맞춰 주는 편이 운영 목적에 더 부합한다고 정리했다.

View File

@@ -1,5 +1,65 @@
# 업데이트 로그
## 2026-04-06 v1.4.88
- 관리자 아이템 목록과 개별 아이템 검색에서는 같은 이미지 `src`를 공유하는 항목을 하나로 묶어 보여주도록 조정했다. 사용자 아이템, 템플릿 아이템, 관리자 자산이 같은 이미지를 가리키는 경우 카드가 반복해서 보이던 문제를 줄였다.
- 아이템 모달의 `이 이미지를 사용하는 템플릿` 배지는 더 이상 단순 이동 버튼이 아니고, 각 배지의 `X` 버튼으로 해당 템플릿에서 이미지를 바로 제외할 수 있게 바꿨다.
- 아이템 모달의 `새 템플릿 만들기` 버튼은 현재 흐름에선 분기만 늘린다고 보고 숨겼다. 이제 아이템 추가는 이미 선택한 템플릿 기준으로만 진행된다.
- 배지형 태그 입력은 한글 IME 조합 중 `Enter`를 눌렀을 때 초성/중간 문자열이 중복 등록되던 문제를 막기 위해 조합 중 입력을 따로 감지하도록 보강했다.
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build`
## 2026-04-06 v1.4.87
- 템플릿 설정 화면에서는 더 이상 템플릿 태그를 직접 입력하지 않도록 정리했다. 템플릿 자체는 이름과 slug로만 관리하고, 운영용 태그는 아이템 모달 안에서만 다루는 흐름으로 단순화했다.
- 관리자 아이템 모달의 태그 입력은 쉼표 문자열 대신 배지형 입력으로 바꿨다. 태그를 입력하고 `Enter`를 누르면 아래에 배지로 붙고, 각 배지의 `X` 버튼으로 개별 제거할 수 있다.
- 템플릿 관리 액션 영역은 좁은 화면에서도 버튼이 자연스럽게 오른쪽 정렬로 줄바꿈되도록 손봤고, `개별 아이템 검색` 모달을 추가해 이름·파일명·태그로 원하는 아이템만 직접 찾아 현재 템플릿 초안에 넣을 수 있게 했다.
- 템플릿 화면의 기본 아이템 목록과 업로드 초안 카드에서는 태그 입력창을 제거해, 메인 작업 흐름이 이름 편집과 아이템 추가에 더 집중되도록 정리했다.
- 확인: `npm run build`
## 2026-04-06 v1.4.86
- 관리자 전용 내부 태그를 템플릿과 아이템 메타에 추가했다. 이제 템플릿 설정과 기본 아이템/관리자 아이템 모달에서 태그를 쉼표 기준으로 저장할 수 있고, 관리자 검색에서도 이름·파일명뿐 아니라 태그까지 함께 조회할 수 있다.
- 템플릿 관리 화면에 `기존 템플릿 가져오기`를 추가했다. 현재 선택한 템플릿에 다른 템플릿들의 기본 아이템을 초안으로 모아 온 뒤, 필요 없는 항목은 `제외`하고 저장할 수 있다. 여러 분기 템플릿을 합쳐 연간 통합 템플릿을 만드는 흐름을 염두에 둔 기능이다.
- 에디터에서 사용자가 잘못 올린 커스텀 이미지는 이제 현재 티어표 상태에서 바로 제거할 수 있다. 풀 목록과 보드 셀에 `삭제` 버튼을 추가했고, 우클릭 메뉴에서도 같은 동작을 열어 저장 전 임시 이미지와 저장 후 커스텀 아이템을 모두 현재 작업에서 빼낼 수 있게 했다.
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build`
## 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 안의 표기가 같은 이름으로 정리되게 맞췄다.

View File

@@ -0,0 +1,158 @@
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
placeholder: { type: String, default: '태그 입력 후 Enter' },
disabled: { type: Boolean, default: false },
maxTags: { type: Number, default: 30 },
maxTagLength: { type: Number, default: 40 },
})
const emit = defineEmits(['update:modelValue'])
const draft = ref('')
const isComposing = ref(false)
const normalizedTags = computed(() =>
Array.from(
new Set(
(Array.isArray(props.modelValue) ? props.modelValue : [])
.map((tag) => String(tag || '').trim().replace(/^#/, '').slice(0, props.maxTagLength))
.filter(Boolean)
)
).slice(0, props.maxTags)
)
function commitTags(nextTags) {
emit(
'update:modelValue',
Array.from(
new Set(
(nextTags || [])
.map((tag) => String(tag || '').trim().replace(/^#/, '').slice(0, props.maxTagLength))
.filter(Boolean)
)
).slice(0, props.maxTags)
)
}
function addDraftTag() {
if (props.disabled) return
const nextTag = String(draft.value || '').trim().replace(/^#/, '').slice(0, props.maxTagLength)
if (!nextTag) return
commitTags([...normalizedTags.value, nextTag])
draft.value = ''
}
function removeTag(tag) {
if (props.disabled) return
commitTags(normalizedTags.value.filter((entry) => entry !== tag))
}
function handleKeydown(event) {
if (event.isComposing || isComposing.value) return
if (event.key === 'Enter') {
event.preventDefault()
addDraftTag()
return
}
if (event.key === 'Backspace' && !draft.value && normalizedTags.value.length) {
removeTag(normalizedTags.value[normalizedTags.value.length - 1])
}
}
function handleBlur() {
if (isComposing.value) return
addDraftTag()
}
function handleCompositionStart() {
isComposing.value = true
}
function handleCompositionEnd() {
isComposing.value = false
}
</script>
<template>
<div class="tagBadgeInput" :class="{ 'tagBadgeInput--disabled': disabled }">
<div v-if="normalizedTags.length" class="tagBadgeInput__list">
<span v-for="tag in normalizedTags" :key="tag" class="tagBadgeInput__badge">
<span>#{{ tag }}</span>
<button class="tagBadgeInput__remove" type="button" :disabled="disabled" @click="removeTag(tag)">X</button>
</span>
</div>
<input
v-model="draft"
class="tagBadgeInput__input"
type="text"
:placeholder="placeholder"
:disabled="disabled || normalizedTags.length >= maxTags"
:maxlength="maxTagLength"
@keydown="handleKeydown"
@blur="handleBlur"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
/>
</div>
</template>
<style scoped>
.tagBadgeInput {
display: grid;
gap: 10px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid color-mix(in srgb, var(--theme-border-strong) 72%, rgba(255, 255, 255, 0.08));
background: color-mix(in srgb, var(--theme-surface-soft) 82%, rgba(255, 255, 255, 0.02));
}
.tagBadgeInput--disabled {
opacity: 0.72;
}
.tagBadgeInput__list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tagBadgeInput__badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 11px;
border-radius: 999px;
background: color-mix(in srgb, var(--theme-accent-soft) 55%, rgba(255, 255, 255, 0.06));
color: var(--theme-text);
font-size: 12px;
font-weight: 700;
}
.tagBadgeInput__remove {
border: none;
background: transparent;
color: inherit;
font-size: 11px;
font-weight: 800;
cursor: pointer;
padding: 0;
}
.tagBadgeInput__input {
width: 100%;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: var(--theme-text);
font: inherit;
}
.tagBadgeInput__input::placeholder {
color: var(--theme-text-soft);
}
</style>

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

@@ -9,6 +9,8 @@ const props = defineProps({
stagedRequestDraftCount: { type: Number, required: true },
appliedRequestItemCount: { type: Number, required: true },
openTemplateCreateModal: { type: Function, required: true },
openTemplateSourceImportModal: { type: Function, required: true },
openTemplateLibraryItemModal: { type: Function, required: true },
isTemplateLoading: { type: Boolean, required: true },
hasSelectedTemplate: { type: Boolean, required: true },
selectedTemplate: { type: Object, default: null },
@@ -170,8 +172,10 @@ function setThumbFileElement(el) {
</label>
<div class="templateSettingsCard__actions">
<button class="btn btn--ghost" :disabled="!props.canSaveTemplateMeta || props.templateMetaSaving" @click="props.saveTemplateMeta">
{{ props.templateMetaSaving ? '저장중...' : '이름/slug 저장' }}
{{ props.templateMetaSaving ? '저장중...' : '템플릿 메타 저장' }}
</button>
<button class="btn btn--ghost" @click="props.openTemplateLibraryItemModal">개별 아이템 검색</button>
<button class="btn btn--ghost" @click="props.openTemplateSourceImportModal">기존 템플릿 가져오기</button>
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
<button class="btn btn--danger" @click="props.removeTemplate">템플릿 삭제</button>
</div>
@@ -221,7 +225,7 @@ function setThumbFileElement(el) {
<div class="hint hint--tight">{{ draft.sourceName }}</div>
<div class="itemDraftRow__meta">
<span class="pill" :class="draft.kind === 'request' ? 'pill--requestItem' : 'pill--directFile'">
{{ draft.kind === 'request' ? '요청 아이템' : '직접 추가 파일' }}
{{ draft.kind === 'request' ? '요청 아이템' : draft.kind === 'library' ? '기존 템플릿' : '직접 추가 파일' }}
</span>
<button class="btn btn--danger btn--small" type="button" @click="props.removeUploadDraft(draft)">제외</button>
</div>
@@ -246,7 +250,7 @@ function setThumbFileElement(el) {
<button class="btn btn--primary btn--small" :disabled="!props.hasTemplateItemOrderChanges" @click="props.saveTemplateItemOrder">순서 저장</button>
</div>
<div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
<div v-else :ref="setTemplateItemListElement" class="thumbGrid">
<div v-else :ref="setTemplateItemListElement" class="thumbGrid">
<div v-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-template-item-id="item.id">
<img class="thumb thumb--template" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />

View File

@@ -1,5 +1,3 @@
import { nextTick } from 'vue'
export function useAdminCustomItems({
api,
toast,
@@ -14,6 +12,7 @@ export function useAdminCustomItems({
customItemModalHistoryActive,
modalTargetCustomItem,
customItemModalDraftLabel,
customItemModalDraftTags,
customItemModalLabelSaving,
customItemModalTargetTemplateId,
customItemReplacementQuery,
@@ -25,8 +24,6 @@ export function useAdminCustomItems({
selectedTemplateId,
refreshCustomItems,
loadTemplate,
setTab,
selectAdminTemplate,
resetMessages,
success,
error,
@@ -75,6 +72,7 @@ export function useAdminCustomItems({
page: 1,
limit: 50,
filter: 'all',
collapseShared: true,
})
customItemReplacementItems.value = (data.items || []).filter((item) => item?.id && item.id !== currentItemId)
} catch (e) {
@@ -88,14 +86,14 @@ export function useAdminCustomItems({
function openCustomItemModal(item) {
modalTargetCustomItem.value = item || null
customItemModalDraftLabel.value = item?.label || ''
customItemModalDraftTags.value = Array.isArray(item?.tags) ? [...item.tags] : []
customItemModalTargetTemplateId.value = ''
customItemReplacementQuery.value = item?.label || ''
customItemReplacementQuery.value = ''
customItemReplacementItems.value = []
customItemReplacementTargetId.value = ''
customItemReplacementBusy.value = false
customItemModalOpen.value = true
pushCustomItemModalHistoryState()
void refreshReplacementCandidates()
}
function closeCustomItemModal({ fromPopState = false } = {}) {
@@ -103,6 +101,7 @@ export function useAdminCustomItems({
customItemDeleteModalOpen.value = false
modalTargetCustomItem.value = null
customItemModalDraftLabel.value = ''
customItemModalDraftTags.value = []
customItemModalLabelSaving.value = false
customItemModalTargetTemplateId.value = ''
customItemReplacementQuery.value = ''
@@ -124,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
}
@@ -136,19 +135,10 @@ export function useAdminCustomItems({
customItemDeleteModalOpen.value = false
}
function jumpToTemplateAdmin(templateId) {
if (!templateId) return
closeCustomItemModal()
setTab('template-admin')
nextTick(() => {
selectAdminTemplate(templateId)
})
}
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
}
@@ -176,32 +166,56 @@ 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)
if (!item || !nextLabel || nextLabel === item.label || customItemModalLabelSaving.value) return
const nextTags = Array.from(
new Set(
(Array.isArray(customItemModalDraftTags.value) ? customItemModalDraftTags.value : [])
.map((tag) => tag.trim().replace(/^#/, '').slice(0, 40))
.filter(Boolean)
)
).slice(0, 30)
if (
!item ||
!nextLabel ||
(nextLabel === item.label && JSON.stringify(nextTags) === JSON.stringify(Array.isArray(item.tags) ? item.tags : [])) ||
customItemModalLabelSaving.value
) {
return
}
try {
customItemModalLabelSaving.value = true
const data = await api.updateAdminCustomItemLabel(item.id, { label: nextLabel, sourceType: item.sourceType })
const data = await api.updateAdminCustomItemLabel(item.id, { label: nextLabel, tags: nextTags, sourceType: item.sourceType })
item.label = data.item?.label || nextLabel
item.tags = Array.isArray(data.item?.tags) ? data.item.tags : nextTags
customItemModalDraftLabel.value = item.label
customItems.value = customItems.value.map((entry) => (entry.id === item.id ? { ...entry, label: item.label } : entry))
toast.success('아이템 이름을 변경했어요.')
customItemModalDraftTags.value = [...item.tags]
customItems.value = customItems.value.map((entry) => (entry.id === item.id ? { ...entry, label: item.label, tags: item.tags } : entry))
toast.success('아이템 메타를 변경했어요.')
} catch (e) {
error.value = '아이템 이름 변경에 실패했어요.'
error.value = '아이템 메타 변경에 실패했어요.'
} finally {
customItemModalLabelSaving.value = false
}
@@ -229,6 +243,30 @@ export function useAdminCustomItems({
}
}
async function unlinkCustomItemTemplate(item = modalTargetCustomItem.value, template) {
resetMessages()
if (!item?.id || !template?.id) {
error.value = '제외할 템플릿 정보를 찾지 못했어요.'
return
}
const ok = window.confirm(`"${template.name}" 템플릿에서 이 이미지를 제외할까요?`)
if (!ok) return
try {
await api.unlinkAdminCustomItemTemplate(item.id, { topicId: template.id })
if (selectedTemplateId.value === template.id) await loadTemplate()
await refreshCustomItems()
modalTargetCustomItem.value = {
...item,
linkedTemplates: (item.linkedTemplates || []).filter((entry) => entry.id !== template.id),
}
success.value = `"${template.name}" 템플릿에서 이미지를 제외했어요.`
} catch (e) {
error.value = '템플릿 연결 해제에 실패했어요.'
}
}
async function replaceCustomItem(item = modalTargetCustomItem.value) {
resetMessages()
const targetItem = customItemReplacementItems.value.find((entry) => entry.id === customItemReplacementTargetId.value)
@@ -258,6 +296,27 @@ export function useAdminCustomItems({
}
}
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,
@@ -268,12 +327,14 @@ export function useAdminCustomItems({
closeCustomItemModal,
openCustomItemDeleteModal,
closeCustomItemDeleteModal,
jumpToTemplateAdmin,
removeCustomItem,
removeUnusedCustomItems,
showUnusedCustomItems,
saveCustomItemModalLabel,
promoteCustomItem,
unlinkCustomItemTemplate,
refreshReplacementCandidates,
replaceCustomItem,
restoreCustomItem,
}
}

View File

@@ -31,6 +31,17 @@ export function useAdminTemplateManager({
success,
error,
}) {
function parseTagsText(value) {
return Array.from(
new Set(
String(value || '')
.split(',')
.map((tag) => tag.trim().replace(/^#/, '').slice(0, 40))
.filter(Boolean)
)
).slice(0, 30)
}
function normalizeDraftSrc(src) {
if (typeof src !== 'string') return ''
const raw = src.trim()
@@ -108,6 +119,32 @@ export function useAdminTemplateManager({
}
}
function mergeLibraryItemsIntoDrafts(items, sourceLabel = '') {
const existingTemplateSrcs = new Set((selectedTemplate.value?.items || []).map((item) => normalizeDraftSrc(item?.src)).filter(Boolean))
const existingDraftSrcs = new Set(uploadItemDrafts.value.map((draft) => normalizeDraftSrc(draft?.src)).filter(Boolean))
const existingKeys = new Set(uploadItemDrafts.value.map((draft) => `${draft.kind}:${draft.itemSourceType || ''}:${draft.itemId || draft.file?.name || ''}`))
const nextDrafts = (items || [])
.filter((item) => item?.id && item?.src)
.map((item) => ({
kind: 'library',
itemId: item.id,
itemSourceType: item.sourceType || 'template',
previewUrl: toApiUrl(item.src),
label: item.label || '',
tagsText: Array.isArray(item.tags) ? item.tags.join(', ') : '',
sourceName: sourceLabel ? `${sourceLabel} · ${item.label || item.id}` : (item.label || item.id),
src: item.src,
}))
.filter((draft) => !existingTemplateSrcs.has(normalizeDraftSrc(draft.src)))
.filter((draft) => !existingDraftSrcs.has(normalizeDraftSrc(draft.src)))
.filter((draft) => !existingKeys.has(`${draft.kind}:${draft.itemSourceType}:${draft.itemId}`))
if (nextDrafts.length) {
uploadItemDrafts.value = [...uploadItemDrafts.value, ...nextDrafts]
}
return nextDrafts.length
}
function removeUploadDraft(targetDraft) {
const targetKey = `${targetDraft.kind}:${targetDraft.requestId || ''}:${targetDraft.itemId || targetDraft.file?.name || ''}:${targetDraft.previewUrl || ''}`
uploadItemDrafts.value = uploadItemDrafts.value.filter((draft) => {
@@ -139,6 +176,7 @@ export function useAdminTemplateManager({
items: (data.items || []).map((item) => ({
...item,
draftLabel: item.label,
draftTags: Array.isArray(item.tags) ? item.tags.join(', ') : '',
})),
}
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
@@ -293,6 +331,7 @@ export function useAdminTemplateManager({
const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file')
const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request')
const libraryDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'library')
const totalUploadBytes = fileDrafts.reduce((sum, entry) => sum + Number(entry.file?.size || 0), 0)
let uploadCount = 0
@@ -352,6 +391,28 @@ export function useAdminTemplateManager({
}
}
if (libraryDrafts.length) {
for (const draft of libraryDrafts) {
const promoted = await api.promoteAdminTemplateItem(draft.itemId, {
topicId: selectedTemplateId.value,
})
const createdItem = promoted?.item || null
if (createdItem?.id) {
const nextTags = parseTagsText(draft.tagsText)
const needsMetaUpdate =
(draft.label || '').trim() !== (createdItem.label || '').trim() ||
JSON.stringify(nextTags) !== JSON.stringify(Array.isArray(createdItem.tags) ? createdItem.tags : [])
if (needsMetaUpdate) {
await api.updateAdminTemplateItem(selectedTemplateId.value, createdItem.id, {
label: (draft.label || createdItem.label || '').trim(),
tags: nextTags,
})
}
uploadCount += 1
}
}
}
resetUploadState()
await loadTemplate()
success.value = `템플릿 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
@@ -397,6 +458,7 @@ export function useAdminTemplateManager({
items: (data.items || []).map((item) => ({
...item,
draftLabel: item.label,
draftTags: Array.isArray(item.tags) ? item.tags.join(', ') : '',
})),
}
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
@@ -412,6 +474,7 @@ export function useAdminTemplateManager({
destroyTemplateItemSortable,
syncTemplateItemSortable,
mergeRequestItemsIntoDrafts,
mergeLibraryItemsIntoDrafts,
removeUploadDraft,
loadTemplate,
createTemplate,

View File

@@ -77,9 +77,9 @@ export const api = {
request(`/api/admin/templates/${encodeURIComponent(templateId)}`, { method: 'PATCH', body: payload }),
updateAdminTemplateItem: (templateId, itemId, payload) =>
request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all' } = {}) =>
listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all', collapseShared = false } = {}) =>
request(
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}&collapseShared=${encodeURIComponent(collapseShared ? '1' : '0')}`
),
listAdminTierLists: ({ q = '', topicId = '', sort = 'recent', minFavorites = 0, page = 1, limit = 50 } = {}) =>
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}&sort=${encodeURIComponent(sort)}&minFavorites=${encodeURIComponent(minFavorites)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
@@ -104,8 +104,12 @@ 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 }),
unlinkAdminCustomItemTemplate: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/unlink-template`, { 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

@@ -6,6 +6,7 @@ import { editorPath, userProfilePath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import deleteIcon from '../assets/icons/delete.svg'
import SvgIcon from '../components/SvgIcon.vue'
import TagBadgeInput from '../components/TagBadgeInput.vue'
import AdminFeaturedSection from '../components/admin/AdminFeaturedSection.vue'
import AdminTemplatesSection from '../components/admin/AdminTemplatesSection.vue'
import AdminItemsSection from '../components/admin/AdminItemsSection.vue'
@@ -70,6 +71,14 @@ const importModalItems = ref([])
const importModalTargetTemplateId = ref('')
const importModalNewTemplateId = ref('')
const importModalNewTemplateName = ref('')
const templateSourceImportModalOpen = ref(false)
const templateSourceImportQuery = ref('')
const templateSourceImportSelectedIds = ref([])
const templateLibraryItemModalOpen = ref(false)
const templateLibraryItemQuery = ref('')
const templateLibraryItemResults = ref([])
const templateLibraryItemSelectedIds = ref([])
const templateLibraryItemLoading = ref(false)
const previewModalOpen = ref(false)
const previewTierList = ref(null)
const adminTierListManageModalOpen = ref(false)
@@ -89,6 +98,7 @@ const modalUserDraftNickname = ref('')
const modalUserDraftIsAdmin = ref(false)
const modalTargetCustomItem = ref(null)
const customItemModalDraftLabel = ref('')
const customItemModalDraftTags = ref([])
const customItemModalLabelSaving = ref(false)
const modalTargetAdminTierList = ref(null)
const adminTierListDraftTitle = ref('')
@@ -184,6 +194,17 @@ function normalizeAdminSrc(src) {
}
}
function parseAdminTagsText(value) {
const values = Array.isArray(value) ? value : String(value || '').split(',')
return Array.from(
new Set(
values
.map((tag) => tag.trim().replace(/^#/, '').slice(0, 40))
.filter(Boolean)
)
).slice(0, 30)
}
const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.template?.id)
const canSaveTemplateMeta = computed(() => {
const template = selectedTemplate.value?.template
@@ -193,7 +214,8 @@ const canSaveTemplateMeta = computed(() => {
return (
!!nextName &&
!!nextSlug &&
(nextName !== (template.name || '') || nextSlug !== (template.slug || template.id || ''))
(nextName !== (template.name || '') ||
nextSlug !== (template.slug || template.id || ''))
)
})
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value)
@@ -236,6 +258,20 @@ const customItemReplacementTarget = computed(
() => customItemReplacementItems.value.find((item) => item.id === customItemReplacementTargetId.value) || null
)
const importModalItemCount = computed(() => importModalItems.value.length)
const selectedTemplateLibraryItems = computed(() =>
templateLibraryItemResults.value.filter((item) => templateLibraryItemSelectedIds.value.includes(item.id))
)
const filteredTemplateSourceImportTemplates = computed(() => {
const query = templateSourceImportQuery.value.trim().toLowerCase()
return templates.value
.filter((template) => template.id !== selectedTemplateId.value)
.filter((template) => {
if (!query) return true
const haystack = `${template.name || ''} ${template.slug || ''} ${template.id || ''}`.toLowerCase()
return haystack.includes(query)
})
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
})
const activeTabTitle = computed(() => {
if (activeTab.value === 'featured') return '목록 관리'
if (activeTab.value === 'template-admin') return '템플릿 관리'
@@ -327,6 +363,9 @@ const isAnyModalOpen = computed(
userDeleteModalOpen.value ||
userRoleModalOpen.value ||
importModalOpen.value ||
templateSourceImportModalOpen.value ||
templateLibraryItemModalOpen.value ||
templatePickerModalOpen.value ||
customItemModalOpen.value ||
customItemDeleteModalOpen.value ||
adminTierListManageModalOpen.value ||
@@ -655,7 +694,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(() => {
@@ -841,6 +891,7 @@ async function refreshCustomItems() {
page: customItemPage.value,
limit: customItemLimit.value,
filter: customItemFilter.value,
collapseShared: !['user', 'template', 'unused-user', 'replaced-user'].includes(customItemFilter.value),
})
customItems.value = data.items || []
customItemTotal.value = data.total || 0
@@ -975,6 +1026,7 @@ const {
destroyTemplateItemSortable,
syncTemplateItemSortable,
mergeRequestItemsIntoDrafts,
mergeLibraryItemsIntoDrafts,
removeUploadDraft,
loadTemplate,
createTemplate,
@@ -1045,13 +1097,15 @@ const {
closeCustomItemModal,
openCustomItemDeleteModal,
closeCustomItemDeleteModal,
jumpToTemplateAdmin,
removeCustomItem,
removeUnusedCustomItems,
showUnusedCustomItems,
saveCustomItemModalLabel,
promoteCustomItem,
unlinkCustomItemTemplate,
refreshReplacementCandidates,
replaceCustomItem,
restoreCustomItem,
} = useAdminCustomItems({
api,
toast,
@@ -1066,6 +1120,7 @@ const {
customItemModalHistoryActive,
modalTargetCustomItem,
customItemModalDraftLabel,
customItemModalDraftTags,
customItemModalLabelSaving,
customItemModalTargetTemplateId,
customItemReplacementQuery,
@@ -1077,8 +1132,6 @@ const {
selectedTemplateId,
refreshCustomItems,
loadTemplate,
setTab,
selectAdminTemplate,
resetMessages,
success,
error,
@@ -1224,6 +1277,7 @@ async function saveTemplateVisibility() {
templateVisibilitySaving.value = true
const data = await api.updateAdminTemplate(selectedTemplate.value.template.id, {
isPublic: !!selectedTemplate.value.template.isPublic,
tags: Array.isArray(selectedTemplate.value.template.tags) ? selectedTemplate.value.template.tags : [],
})
const nextTemplate = data.template || {}
selectedTemplate.value = {
@@ -1265,7 +1319,7 @@ async function saveTemplateMeta() {
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를 저장했어요.'
success.value = '템플릿 메타를 저장했어요.'
} catch (e) {
const errorCode = e?.data?.error || ''
if (errorCode === 'topic_slug_taken') {
@@ -1276,7 +1330,7 @@ async function saveTemplateMeta() {
error.value = 'slug는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.'
return
}
error.value = '템플릿 이름/slug를 저장하지 못했어요.'
error.value = '템플릿 메타를 저장하지 못했어요.'
} finally {
templateMetaSaving.value = false
}
@@ -1355,9 +1409,13 @@ async function saveTemplateItemLabel(item) {
try {
item.isSavingLabel = true
const data = await api.updateAdminTemplateItem(selectedTemplateId.value, item.id, { label: nextLabel })
const data = await api.updateAdminTemplateItem(selectedTemplateId.value, item.id, {
label: nextLabel,
tags: Array.isArray(item.tags) ? item.tags : [],
})
item.label = data.item.label
item.draftLabel = data.item.label
item.tags = Array.isArray(data.item.tags) ? data.item.tags : []
success.value = '기본 아이템 이름을 수정했어요.'
} catch (e) {
error.value = '기본 아이템 이름 수정에 실패했어요.'
@@ -1469,6 +1527,7 @@ function buildModalItemFromTierListItem(item, tierList) {
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'asset' : 'user'),
sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템',
ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList),
tags: Array.isArray(matchedItem?.tags) ? matchedItem.tags : [],
linkedTemplates: Array.isArray(matchedItem?.linkedTemplates) ? matchedItem.linkedTemplates : [],
usageCount: matchedItem?.usageCount || 0,
canDelete: typeof matchedItem?.canDelete === 'boolean' ? matchedItem.canDelete : false,
@@ -1703,6 +1762,142 @@ function closeTierListImportModal() {
importModalItems.value = []
}
function openTemplateSourceImportModal() {
resetMessages()
if (!selectedTemplateId.value) {
error.value = '먼저 가져올 대상을 받을 템플릿을 선택해주세요.'
return
}
templateSourceImportSelectedIds.value = []
templateSourceImportQuery.value = ''
templateSourceImportModalOpen.value = true
}
function closeTemplateSourceImportModal() {
templateSourceImportModalOpen.value = false
templateSourceImportSelectedIds.value = []
templateSourceImportQuery.value = ''
}
function openTemplateLibraryItemModal() {
resetMessages()
if (!selectedTemplateId.value) {
error.value = '먼저 아이템을 받을 템플릿을 선택해주세요.'
return
}
templateLibraryItemQuery.value = ''
templateLibraryItemResults.value = []
templateLibraryItemSelectedIds.value = []
templateLibraryItemLoading.value = false
templateLibraryItemModalOpen.value = true
}
function closeTemplateLibraryItemModal() {
templateLibraryItemModalOpen.value = false
templateLibraryItemQuery.value = ''
templateLibraryItemResults.value = []
templateLibraryItemSelectedIds.value = []
templateLibraryItemLoading.value = false
}
function toggleTemplateLibraryItemSelection(itemId) {
if (!itemId) return
if (templateLibraryItemSelectedIds.value.includes(itemId)) {
templateLibraryItemSelectedIds.value = templateLibraryItemSelectedIds.value.filter((id) => id !== itemId)
return
}
templateLibraryItemSelectedIds.value = [...templateLibraryItemSelectedIds.value, itemId]
}
async function searchTemplateLibraryItems() {
resetMessages()
const query = templateLibraryItemQuery.value.trim()
if (!query) {
templateLibraryItemResults.value = []
templateLibraryItemSelectedIds.value = []
return
}
try {
templateLibraryItemLoading.value = true
const data = await api.listAdminCustomItems({
q: query,
page: 1,
limit: 50,
filter: 'library',
collapseShared: true,
})
templateLibraryItemResults.value = (data.items || []).filter((item) => item?.id)
templateLibraryItemSelectedIds.value = templateLibraryItemSelectedIds.value.filter((id) =>
templateLibraryItemResults.value.some((item) => item.id === id)
)
} catch (e) {
templateLibraryItemResults.value = []
templateLibraryItemSelectedIds.value = []
error.value = '개별 아이템 검색에 실패했어요.'
} finally {
templateLibraryItemLoading.value = false
}
}
async function confirmTemplateLibraryItemImport() {
resetMessages()
if (!selectedTemplateId.value) {
error.value = '아이템을 받을 템플릿을 먼저 선택해주세요.'
return
}
if (!selectedTemplateLibraryItems.value.length) {
error.value = '가져올 아이템을 하나 이상 선택해주세요.'
return
}
const stagedCount = mergeLibraryItemsIntoDrafts(selectedTemplateLibraryItems.value, '라이브러리 검색')
closeTemplateLibraryItemModal()
success.value = stagedCount
? `${stagedCount}개의 아이템을 초안 목록에 추가했어요. 필요 없는 항목은 제외한 뒤 저장하면 됩니다.`
: '추가로 가져올 새 아이템이 없었어요.'
}
function toggleTemplateSourceImportSelection(templateId) {
if (!templateId) return
if (templateSourceImportSelectedIds.value.includes(templateId)) {
templateSourceImportSelectedIds.value = templateSourceImportSelectedIds.value.filter((id) => id !== templateId)
return
}
templateSourceImportSelectedIds.value = [...templateSourceImportSelectedIds.value, templateId]
}
async function confirmTemplateSourceImport() {
resetMessages()
if (!selectedTemplateId.value) {
error.value = '가져올 대상을 받을 템플릿을 먼저 선택해주세요.'
return
}
if (!templateSourceImportSelectedIds.value.length) {
error.value = '아이템을 가져올 원본 템플릿을 하나 이상 선택해주세요.'
return
}
try {
let stagedCount = 0
for (const templateId of templateSourceImportSelectedIds.value) {
const template = templates.value.find((entry) => entry.id === templateId)
const data = await api.getTopic(templateId)
const libraryItems = (data.items || []).map((item) => ({
...item,
sourceType: 'template',
}))
stagedCount += mergeLibraryItemsIntoDrafts(libraryItems, template?.name || data.topic?.name || templateId)
}
closeTemplateSourceImportModal()
success.value = stagedCount
? `${stagedCount}개의 기존 템플릿 아이템을 초안 목록에 추가했어요. 필요 없는 항목은 제외한 뒤 저장하면 됩니다.`
: '추가로 가져올 새 아이템이 없었어요.'
} catch (e) {
error.value = '기존 템플릿 아이템을 가져오지 못했어요.'
}
}
async function confirmTierListImport() {
resetMessages()
if (!importModalTierList.value || !importModalItems.value.length) {
@@ -1836,6 +2031,8 @@ function openUserProfile(user) {
:staged-request-draft-count="stagedRequestDraftCount"
:applied-request-item-count="appliedRequestItemCount"
:open-template-create-modal="openTemplateCreateModal"
:open-template-source-import-modal="openTemplateSourceImportModal"
:open-template-library-item-modal="openTemplateLibraryItemModal"
:is-template-loading="isTemplateLoading"
:has-selected-template="hasSelectedTemplate"
:selected-template="selectedTemplate"
@@ -2078,6 +2275,93 @@ function openUserProfile(user) {
</div>
</div>
<div v-if="templateSourceImportModalOpen" class="modalOverlay" @click.self="closeTemplateSourceImportModal">
<div class="modalCard modalCard--import" role="dialog" aria-modal="true">
<div class="modalCard__title">기존 템플릿 아이템 가져오기</div>
<div class="modalCard__desc">
여러 템플릿의 기본 아이템을 초안 목록으로 모아둘 있어요. 이후 필요 없는 항목은 제외하고 현재 템플릿에만 저장하면 됩니다.
</div>
<div class="modalCard__form">
<input v-model="templateSourceImportQuery" class="input" placeholder="템플릿 이름 또는 slug 검색" />
</div>
<div class="templateImportList">
<button
v-for="template in filteredTemplateSourceImportTemplates"
:key="template.id"
type="button"
class="adminTemplatePicker__item"
:class="{ 'adminTemplatePicker__item--active': templateSourceImportSelectedIds.includes(template.id) }"
@click="toggleTemplateSourceImportSelection(template.id)"
>
<span class="adminTemplatePicker__name">{{ template.name }}</span>
<span class="adminTemplatePicker__meta">{{ template.slug || template.id }}</span>
</button>
<div v-if="!filteredTemplateSourceImportTemplates.length" class="hint">조건에 맞는 템플릿이 없어요.</div>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateSourceImportModal">취소</button>
<button class="btn btn--primary" @click="confirmTemplateSourceImport">초안으로 가져오기</button>
</div>
</div>
</div>
<div v-if="templateLibraryItemModalOpen" class="modalOverlay" @click.self="closeTemplateLibraryItemModal">
<div class="modalCard modalCard--import" role="dialog" aria-modal="true">
<div class="modalCard__title">개별 아이템 검색</div>
<div class="modalCard__desc">
이름, 파일명, 태그로 검색해서 현재 템플릿 초안 목록에 필요한 아이템만 모아둘 있어요.
</div>
<div class="modalCard__form modalCard__form--search">
<input
v-model="templateLibraryItemQuery"
class="input"
placeholder="아이템 이름, 파일명, 태그 검색"
@keydown.enter.prevent="searchTemplateLibraryItems"
/>
<button class="btn btn--ghost" type="button" @click="searchTemplateLibraryItems">
{{ templateLibraryItemLoading ? '검색중...' : '검색' }}
</button>
</div>
<div class="templateImportList">
<div v-if="!templateLibraryItemQuery.trim() && !templateLibraryItemLoading" class="hint">
검색 전에는 목록을 보여주지 않아요. 필요한 키워드로 직접 찾아서 추가해주세요.
</div>
<button
v-for="item in templateLibraryItemResults"
:key="item.id"
type="button"
class="adminTemplatePicker__item"
:class="{ 'adminTemplatePicker__item--active': templateLibraryItemSelectedIds.includes(item.id) }"
@click="toggleTemplateLibraryItemSelection(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 v-if="item.tags?.length" class="adminTemplatePicker__meta">#{{ item.tags.join(' #') }}</span>
</span>
</span>
</button>
<div v-if="templateLibraryItemQuery.trim() && !templateLibraryItemLoading && !templateLibraryItemResults.length" class="hint">
조건에 맞는 아이템이 없어요.
</div>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateLibraryItemModal">취소</button>
<button class="btn btn--primary" :disabled="!selectedTemplateLibraryItems.length" @click="confirmTemplateLibraryItemImport">
초안으로 가져오기
</button>
</div>
</div>
</div>
<div v-if="customItemModalOpen" class="modalOverlay" @click.self="closeCustomItemModal">
<div class="modalCard modalCard--customItem" role="dialog" aria-modal="true">
<div v-if="modalTargetCustomItem" class="customItemModal">
@@ -2093,50 +2377,54 @@ function openUserProfile(user) {
</div>
<div class="customItemModal__pickerActions">
<button class="btn btn--ghost" type="button" @click="openTemplatePickerModal('custom-item-target')">템플릿 선택</button>
<button class="btn btn--ghost btn--small customItemModal__createTemplateButton" type="button" @click="openTemplateCreateModal"> 템플릿 만들기</button>
</div>
<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">
대체 후보가 없어요. 검색어를 바꾸거나 먼저 관리자 이미지를 등록해주세요.
<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">
@@ -2156,32 +2444,56 @@ function openUserProfile(user) {
<span class="field__label">아이템 이름</span>
<input v-model="customItemModalDraftLabel" class="field__input" type="text" maxlength="60" placeholder="아이템 이름" />
</label>
<button class="btn btn--ghost customItemModal__renameButton" type="button" :disabled="customItemModalLabelSaving || !customItemModalDraftLabel.trim() || customItemModalDraftLabel.trim() === modalTargetCustomItem.label" @click="saveCustomItemModalLabel">
{{ customItemModalLabelSaving ? '저장중...' : '이름 저장' }}
<label v-if="modalTargetCustomItem.sourceType !== 'asset'" class="field">
<span class="field__label">내부 태그</span>
<TagBadgeInput v-model="customItemModalDraftTags" placeholder="태그 입력 후 Enter" />
</label>
<button class="btn btn--ghost customItemModal__renameButton" type="button" :disabled="customItemModalLabelSaving || !customItemModalDraftLabel.trim() || (customItemModalDraftLabel.trim() === modalTargetCustomItem.label && (modalTargetCustomItem.sourceType === 'asset' || JSON.stringify(parseAdminTagsText(customItemModalDraftTags)) === JSON.stringify(modalTargetCustomItem.tags || [])))" @click="saveCustomItemModalLabel">
{{ customItemModalLabelSaving ? '저장중...' : '메타 저장' }}
</button>
</div>
<div class="customItemModal__metaList">
<div class="customItemModal__metaRow"><span>파일</span><strong :title="modalTargetCustomItem.src.split('/').pop()">{{ modalTargetCustomItem.src.split('/').pop() }}</strong></div>
<div class="customItemModal__metaRow"><span>업로더/출처</span><strong :title="modalTargetCustomItem.ownerName">{{ modalTargetCustomItem.ownerName }}</strong></div>
<div class="customItemModal__metaRow"><span>태그</span><strong>{{ modalTargetCustomItem.tags?.length ? '#' + modalTargetCustomItem.tags.join(' #') : '없음' }}</strong></div>
<div class="customItemModal__metaRow"><span>템플릿 연결</span><strong>{{ visibleLinkedTemplates.length }} 템플릿</strong></div>
<div class="customItemModal__metaRow"><span>등록일</span><strong>{{ fmt(modalTargetCustomItem.createdAt) }}</strong></div>
</div>
<div class="customItemModal__linked">
<span class="customItemModal__label"> 이미지를 사용하는 템플릿</span>
<div v-if="visibleLinkedTemplates.length" class="customItemModal__chips">
<button v-for="template in visibleLinkedTemplates" :key="template.id" type="button" class="pill pill--link" @click="jumpToTemplateAdmin(template.id)">{{ template.name }}</button>
<span v-for="template in visibleLinkedTemplates" :key="template.id" class="customItemModal__templateChip">
<span>{{ template.name }}</span>
<button class="customItemModal__templateChipRemove" type="button" @click="unlinkCustomItemTemplate(modalTargetCustomItem, template)">X</button>
</span>
</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 class="btn btn--primary customItemModal__action" :disabled="!customItemReplacementTargetId || customItemReplacementBusy" @click="replaceCustomItem(modalTargetCustomItem)">
<button v-if="canReplaceModalTarget" class="btn btn--primary customItemModal__action" :disabled="!customItemReplacementTargetId || customItemReplacementBusy" @click="replaceCustomItem(modalTargetCustomItem)">
{{ customItemReplacementBusy ? '대체중...' : '선택한 이미지로 대체' }}
</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="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>
@@ -2409,7 +2721,7 @@ function openUserProfile(user) {
<div class="adminSidebar__label">Filters</div>
<div class="adminSidebar__group">
<div class="adminSidebar__inlineRow">
<input v-model="customItemQuery" class="input" placeholder="파일명, 라벨, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
<input v-model="customItemQuery" class="input" placeholder="파일명, 라벨, 태그, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
<button class="btn btn--ghost" @click="submitCustomItemSearch">검색</button>
</div>
</div>
@@ -2423,14 +2735,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">
@@ -2787,6 +3108,16 @@ function openUserProfile(user) {
opacity: 0.58;
border-style: dashed;
}
.adminUiScope .templateImportList {
display: grid;
gap: 10px;
max-height: 360px;
overflow: auto;
}
.adminUiScope .modalCard__form--search {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
.adminUiScope .adminTemplatePicker__name {
font-size: 13px;
font-weight: 800;
@@ -3257,9 +3588,13 @@ function openUserProfile(user) {
}
.adminUiScope .templateSettingsCard__actions {
display: flex;
justify-content: space-between;
justify-content: flex-end;
align-items: center;
gap: 10px;
/* flex-wrap: wrap; */
flex-wrap: wrap;
}
.adminUiScope .templateSettingsCard__actions > .btn {
flex: 0 0 auto;
}
.adminUiScope .selectedThumb {
width: min(100%, 256px);
@@ -3614,6 +3949,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);
@@ -3696,9 +4036,6 @@ function openUserProfile(user) {
display: grid;
gap: 2px;
}
.adminUiScope .customItemModal__createTemplateButton {
justify-self: start;
}
.adminUiScope .customItemModal__body {
min-width: 0;
min-height: 0;
@@ -3783,6 +4120,26 @@ function openUserProfile(user) {
flex-wrap: wrap;
gap: 8px;
}
.adminUiScope .customItemModal__templateChip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 11px;
border-radius: 999px;
background: var(--theme-pill-bg);
border: 1px solid var(--theme-border);
font-size: 12px;
color: var(--theme-text);
}
.adminUiScope .customItemModal__templateChipRemove {
border: 0;
background: transparent;
color: var(--theme-text-soft);
cursor: pointer;
padding: 0;
font-size: 11px;
font-weight: 800;
}
.adminUiScope .customItemModal__title {
font-size: 19px;
font-weight: 900;
@@ -4690,6 +5047,9 @@ function openUserProfile(user) {
.adminUiScope .customItemModal__actions {
grid-template-columns: 1fr;
}
.adminUiScope .modalCard__form--search {
grid-template-columns: 1fr;
}
.adminUiScope.adminSidebar {
display: none;
}

View File

@@ -473,6 +473,25 @@ function duplicateItemToPool() {
toast.success('아이템 추가 완료')
}
function canRemoveEditorItem(itemId) {
return canEdit.value && itemsById.value[itemId]?.origin === 'custom'
}
function deleteEditorItem(itemId) {
if (!canRemoveEditorItem(itemId)) return
const targetItem = itemsById.value[itemId]
detachItemById(itemId)
if (typeof targetItem?.src === 'string' && targetItem.src.startsWith('blob:')) {
URL.revokeObjectURL(targetItem.src)
}
const nextItems = { ...itemsById.value }
delete nextItems[itemId]
itemsById.value = nextItems
if (selectedItemId.value === itemId) selectedItemId.value = ''
if (itemContextMenu.value.itemId === itemId) closeItemContextMenu()
toast.success('커스텀 이미지를 현재 티어표에서 제거했어요.')
}
function handleGlobalContextMenu(event) {
const target = event?.target
if (target?.closest?.('[data-item-context-menu]')) {
@@ -1684,6 +1703,16 @@ onUnmounted(() => {
>
×
</button>
<button
v-if="canRemoveEditorItem(id) && !isExporting"
class="cellDeleteBtn"
type="button"
title="커스텀 이미지 제거"
@pointerdown.stop
@click.stop="deleteEditorItem(id)"
>
삭제
</button>
</div>
</div>
</div>
@@ -1760,6 +1789,16 @@ onUnmounted(() => {
draggable="false"
/>
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
<button
v-if="canRemoveEditorItem(id)"
class="poolItem__deleteBtn"
type="button"
title="커스텀 이미지 제거"
@pointerdown.stop
@click.stop="deleteEditorItem(id)"
>
삭제
</button>
<div v-if="!canEdit" class="poolItem__state">미배치</div>
</div>
</div>
@@ -1777,6 +1816,9 @@ onUnmounted(() => {
<button class="itemContextMenu__action" type="button" @click="duplicateItemToPool">
아이템 복제
</button>
<button v-if="canRemoveEditorItem(itemContextMenu.itemId)" class="itemContextMenu__action itemContextMenu__action--danger" type="button" @click="deleteEditorItem(itemContextMenu.itemId)">
커스텀 이미지 제거
</button>
</div>
</div>
@@ -3024,6 +3066,7 @@ onUnmounted(() => {
cursor: copy;
}
.poolItem {
position: relative;
min-width: 0;
display: grid;
grid-template-columns: 1fr;
@@ -3070,6 +3113,23 @@ onUnmounted(() => {
text-transform: uppercase;
color: var(--theme-text-soft);
}
.poolItem__deleteBtn,
.cellDeleteBtn {
position: absolute;
right: 8px;
bottom: 8px;
border: 0;
border-radius: 999px;
padding: 6px 9px;
background: rgba(11, 18, 32, 0.84);
color: #fff;
font-size: 10px;
font-weight: 800;
cursor: pointer;
}
.cellDeleteBtn {
bottom: 34px;
}
.poolItem--hidden {
display: none;
}
@@ -3101,6 +3161,9 @@ onUnmounted(() => {
.itemContextMenu__action:hover {
background: var(--theme-pill-bg);
}
.itemContextMenu__action--danger {
color: #fca5a5;
}
.hidden {
display: none;