Compare commits

...

1 Commits

9 changed files with 482 additions and 60 deletions

View File

@@ -32,6 +32,21 @@ function serializeJson(value) {
return JSON.stringify(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) { function normalizeTopicSlug(value) {
return String(value || '').trim().toLowerCase() return String(value || '').trim().toLowerCase()
} }
@@ -100,6 +115,7 @@ function mapTopicRow(row) {
isPublic: row.is_public == null ? true : !!row.is_public, isPublic: row.is_public == null ? true : !!row.is_public,
displayRank: row.display_rank == null ? null : Number(row.display_rank), displayRank: row.display_rank == null ? null : Number(row.display_rank),
createdAt: Number(row.created_at), createdAt: Number(row.created_at),
tags: normalizeTags(parseJson(row.tags_json, [])),
} }
} }
@@ -112,6 +128,7 @@ function mapTopicItemRow(row) {
label: row.label, label: row.label,
displayOrder: row.display_order == null ? null : Number(row.display_order), displayOrder: row.display_order == null ? null : Number(row.display_order),
createdAt: Number(row.created_at), createdAt: Number(row.created_at),
tags: normalizeTags(parseJson(row.tags_json, [])),
} }
} }
@@ -127,6 +144,7 @@ function mapCustomItemRow(row) {
replacedBySrc: row.replaced_by_src || '', replacedBySrc: row.replaced_by_src || '',
replacedByLabel: row.replaced_by_label || '', replacedByLabel: row.replaced_by_label || '',
replacedAt: Number(row.replaced_at || 0), replacedAt: Number(row.replaced_at || 0),
tags: normalizeTags(parseJson(row.tags_json, [])),
} }
} }
@@ -349,6 +367,7 @@ async function ensureSchema() {
slug VARCHAR(120) NOT NULL, slug VARCHAR(120) NOT NULL,
name VARCHAR(120) NOT NULL, name VARCHAR(120) NOT NULL,
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '', thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
tags_json LONGTEXT NULL,
is_public TINYINT(1) NOT NULL DEFAULT 1, is_public TINYINT(1) NOT NULL DEFAULT 1,
display_rank INT NULL DEFAULT NULL, display_rank INT NULL DEFAULT NULL,
created_at BIGINT NOT NULL, created_at BIGINT NOT NULL,
@@ -362,6 +381,7 @@ async function ensureSchema() {
topic_id VARCHAR(120) NOT NULL, topic_id VARCHAR(120) NOT NULL,
src VARCHAR(255) NOT NULL, src VARCHAR(255) NOT NULL,
label VARCHAR(120) NOT NULL, label VARCHAR(120) NOT NULL,
tags_json LONGTEXT NULL,
display_order INT NULL DEFAULT NULL, display_order INT NULL DEFAULT NULL,
created_at BIGINT NOT NULL, created_at BIGINT NOT NULL,
INDEX idx_topic_items_topic_id (topic_id), INDEX idx_topic_items_topic_id (topic_id),
@@ -375,6 +395,7 @@ async function ensureSchema() {
owner_id VARCHAR(64) NOT NULL, owner_id VARCHAR(64) NOT NULL,
src VARCHAR(255) NOT NULL, src VARCHAR(255) NOT NULL,
label VARCHAR(120) NOT NULL, label VARCHAR(120) NOT NULL,
tags_json LONGTEXT NULL,
replaced_by_item_id VARCHAR(64) NOT NULL DEFAULT '', replaced_by_item_id VARCHAR(64) NOT NULL DEFAULT '',
replaced_by_src VARCHAR(255) NOT NULL DEFAULT '', replaced_by_src VARCHAR(255) NOT NULL DEFAULT '',
replaced_by_label VARCHAR(120) NOT NULL DEFAULT '', replaced_by_label VARCHAR(120) NOT NULL DEFAULT '',
@@ -385,6 +406,9 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) 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_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_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_by_label VARCHAR(120) NOT NULL DEFAULT '' AFTER replaced_by_src`)
@@ -517,11 +541,11 @@ async function ensureSchema() {
await query( await query(
` `
INSERT INTO topics (id, slug, name, thumbnail_src, is_public, created_at) INSERT INTO topics (id, slug, name, thumbnail_src, tags_json, is_public, created_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE slug = VALUES(slug), name = VALUES(name), is_public = VALUES(is_public) 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()]
) )
})() })()
@@ -834,7 +858,7 @@ async function listTopics(currentUserId = '', options = {}) {
const includePrivate = !!options.includePrivate const includePrivate = !!options.includePrivate
const rows = await query( 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 FROM topics
WHERE id <> ? WHERE id <> ?
${includePrivate ? '' : 'AND is_public = 1'} ${includePrivate ? '' : 'AND is_public = 1'}
@@ -859,7 +883,7 @@ async function listTopics(currentUserId = '', options = {}) {
async function findTopicById(id) { async function findTopicById(id) {
const rows = await query( 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] [id]
) )
return mapTopicRow(rows[0]) return mapTopicRow(rows[0])
@@ -869,7 +893,7 @@ async function findTopicBySlug(slug) {
const normalizedSlug = normalizeTopicSlug(slug) const normalizedSlug = normalizeTopicSlug(slug)
if (!normalizedSlug) return null if (!normalizedSlug) return null
const rows = await query( 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] [normalizedSlug]
) )
return mapTopicRow(rows[0]) return mapTopicRow(rows[0])
@@ -882,7 +906,7 @@ async function findTopicByIdentifier(topicRef) {
const rows = await query( 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 FROM topics
WHERE id = ? OR slug = ? WHERE id = ? OR slug = ?
ORDER BY ORDER BY
@@ -897,7 +921,7 @@ async function findTopicByIdentifier(topicRef) {
async function listTopicItems(topicId) { async function listTopicItems(topicId) {
const rows = await query( 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 FROM topic_items
WHERE topic_id = ? WHERE topic_id = ?
ORDER BY ORDER BY
@@ -912,7 +936,7 @@ async function listTopicItems(topicId) {
} }
async function findTopicItemById(itemId) { async function findTopicItemById(itemId) {
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId]) const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
return mapTopicItemRow(rows[0]) return mapTopicItemRow(rows[0])
} }
@@ -923,14 +947,15 @@ async function getTopicDetail(topicRef) {
return { topic, template: topic, items } return { topic, template: topic, items }
} }
async function createTopic({ slug, name, isPublic = true }) { async function createTopic({ slug, name, tags = [], isPublic = true }) {
const topicId = nanoid() const topicId = nanoid()
const topicSlug = assertTopicSlug(slug) 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, topicId,
topicSlug, topicSlug,
name, name,
'', '',
serializeJson(normalizeTags(tags)),
isPublic ? 1 : 0, isPublic ? 1 : 0,
null, null,
now(), now(),
@@ -938,11 +963,12 @@ async function createTopic({ slug, name, isPublic = true }) {
return findTopicById(topicId) return findTopicById(topicId)
} }
async function updateTopicMeta(topicId, { slug, name, isPublic }) { async function updateTopicMeta(topicId, { slug, name, tags = [], isPublic }) {
const topicSlug = assertTopicSlug(slug) 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, topicSlug,
name, name,
serializeJson(normalizeTags(tags)),
isPublic ? 1 : 0, isPublic ? 1 : 0,
topicId, topicId,
]) ])
@@ -1552,26 +1578,37 @@ async function clearImageOptimizationJobs({ month } = {}) {
const result = await query('DELETE FROM image_optimization_jobs') const result = await query('DELETE FROM image_optimization_jobs')
return Number(result.affectedRows || 0) return Number(result.affectedRows || 0)
} }
async function createTopicItem({ id, topicId, src, label }) { async function createTopicItem({ id, topicId, src, label, tags = [] }) {
const createdAt = now() const createdAt = now()
const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM topic_items WHERE topic_id = ?', [topicId]) const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM topic_items WHERE topic_id = ?', [topicId])
const nextDisplayOrder = const nextDisplayOrder =
minOrderRows[0]?.min_display_order == null ? 0 : Number(minOrderRows[0].min_display_order) - 1 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, id,
topicId, topicId,
src, src,
label, label,
serializeJson(normalizeTags(tags)),
nextDisplayOrder, nextDisplayOrder,
createdAt, createdAt,
]) ])
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [id]) const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [id])
return mapTopicItemRow(rows[0]) return mapTopicItemRow(rows[0])
} }
async function updateTopicItemLabel(itemId, label) { async function updateTopicItemLabel(itemId, label) {
await query('UPDATE topic_items SET label = ? WHERE id = ?', [label, itemId]) await query('UPDATE topic_items SET label = ? WHERE id = ?', [label, itemId])
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId]) const rows = await query('SELECT id, topic_id, src, label, 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]) return mapTopicItemRow(rows[0])
} }
@@ -1593,7 +1630,29 @@ async function updateTopicItemDisplayOrder(topicId, itemIds) {
async function updateCustomItemLabel(itemId, label) { async function updateCustomItemLabel(itemId, label) {
await query('UPDATE custom_items SET label = ? WHERE id = ?', [label, itemId]) await query('UPDATE custom_items SET label = ? WHERE id = ?', [label, itemId])
const rows = await query(` const rows = await query(`
SELECT c.id, c.owner_id, c.src, c.label, c.replaced_by_item_id, c.replaced_by_src, c.replaced_by_label, c.replaced_at, c.created_at, u.nickname, u.email 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 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 FROM custom_items c
INNER JOIN users u ON u.id = c.owner_id INNER JOIN users u ON u.id = c.owner_id
WHERE c.id = ? WHERE c.id = ?
@@ -1680,16 +1739,17 @@ async function updateTopicDisplayOrder(topicIds) {
return listTopics() return listTopics()
} }
async function createCustomItem({ id, ownerId, src, label }) { async function createCustomItem({ id, ownerId, src, label, tags = [] }) {
const createdAt = now() 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, id,
ownerId, ownerId,
src, src,
label, label,
serializeJson(normalizeTags(tags)),
createdAt, 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 }) { async function syncOwnedCustomItemLabels({ ownerId, items }) {
@@ -1713,7 +1773,7 @@ async function syncOwnedCustomItemLabels({ ownerId, items }) {
async function findCustomItemById(id) { async function findCustomItemById(id) {
const rows = await query( const rows = await query(
` `
SELECT id, owner_id, src, label, replaced_by_item_id, replaced_by_src, replaced_by_label, replaced_at, 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 FROM custom_items
WHERE id = ? WHERE id = ?
LIMIT 1 LIMIT 1
@@ -1787,6 +1847,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
c.owner_id, c.owner_id,
c.src, c.src,
c.label, c.label,
c.tags_json,
c.replaced_by_item_id, c.replaced_by_item_id,
c.replaced_by_src, c.replaced_by_src,
c.replaced_by_label, c.replaced_by_label,
@@ -1796,10 +1857,10 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
u.email u.email
FROM custom_items c FROM custom_items c
INNER JOIN users u ON u.id = c.owner_id 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 ORDER BY c.created_at DESC
`, `,
hasQuery ? [search, search, search, search] : [] hasQuery ? [search, search, search, search, search] : []
), ),
query( query(
` `
@@ -1808,14 +1869,15 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
gi.topic_id, gi.topic_id,
gi.src, gi.src,
gi.label, gi.label,
gi.tags_json,
gi.created_at, gi.created_at,
tp.name AS topic_name tp.name AS topic_name
FROM topic_items gi FROM topic_items gi
INNER JOIN topics tp ON tp.id = gi.topic_id 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 ORDER BY gi.created_at DESC
`, `,
hasQuery ? [search, search, search, search] : [] hasQuery ? [search, search, search, search, search] : []
), ),
query( query(
` `
@@ -1896,6 +1958,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerId: '', ownerId: '',
src: row.src, src: row.src,
label: row.label_override || (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음', label: row.label_override || (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음',
tags: [],
createdAt: Number(row.created_at || 0), createdAt: Number(row.created_at || 0),
ownerName: '관리자 미사용 이미지', ownerName: '관리자 미사용 이미지',
ownerEmail: '', ownerEmail: '',
@@ -1915,6 +1978,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerId: '', ownerId: '',
src: row.src, src: row.src,
label: row.label, label: row.label,
tags: normalizeTags(parseJson(row.tags_json, [])),
createdAt: Number(row.created_at), createdAt: Number(row.created_at),
ownerName: row.topic_name || row.topic_id, ownerName: row.topic_name || row.topic_id,
ownerEmail: '', ownerEmail: '',
@@ -1976,6 +2040,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
usageCount: entry.usageCount || 0, usageCount: entry.usageCount || 0,
linkedTemplates: entry.linkedTemplates || [], linkedTemplates: entry.linkedTemplates || [],
isAssetLibraryItem: !!entry.isAssetLibraryItem, isAssetLibraryItem: !!entry.isAssetLibraryItem,
tags: entry.tags || [],
replacedByItemId: entry.replacedByItemId || '', replacedByItemId: entry.replacedByItemId || '',
replacedBySrc: entry.replacedBySrc || '', replacedBySrc: entry.replacedBySrc || '',
replacedByLabel: entry.replacedByLabel || '', replacedByLabel: entry.replacedByLabel || '',
@@ -2026,8 +2091,6 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
async function findUnusedCustomItems({ queryText = '' } = {}) { async function findUnusedCustomItems({ queryText = '' } = {}) {
const hasQuery = !!(queryText || '').trim() const hasQuery = !!(queryText || '').trim()
const search = `%${(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, topicItemRows, usageMeta] = await Promise.all([ const [rows, topicItemRows, usageMeta] = await Promise.all([
query( query(
@@ -2037,6 +2100,7 @@ async function findUnusedCustomItems({ queryText = '' } = {}) {
c.owner_id, c.owner_id,
c.src, c.src,
c.label, c.label,
c.tags_json,
c.replaced_by_item_id, c.replaced_by_item_id,
c.replaced_by_src, c.replaced_by_src,
c.replaced_by_label, c.replaced_by_label,
@@ -2046,10 +2110,10 @@ async function findUnusedCustomItems({ queryText = '' } = {}) {
u.email u.email
FROM custom_items c FROM custom_items c
INNER JOIN users u ON u.id = c.owner_id INNER JOIN users u ON u.id = c.owner_id
${whereClause} ${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 ORDER BY c.created_at DESC
`, `,
params hasQuery ? [search, search, search, search, search] : []
), ),
query( query(
` `
@@ -3004,7 +3068,7 @@ async function findCustomItemsByIds(ids) {
const placeholders = ids.map(() => '?').join(', ') const placeholders = ids.map(() => '?').join(', ')
const rows = await query( const rows = await query(
` `
SELECT id, owner_id, src, label, replaced_by_item_id, replaced_by_src, replaced_by_label, replaced_at, 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 FROM custom_items
WHERE id IN (${placeholders}) WHERE id IN (${placeholders})
`, `,
@@ -3162,12 +3226,14 @@ module.exports = {
cleanupMissingUploadReferences, cleanupMissingUploadReferences,
createTopicItem, createTopicItem,
updateTopicItemLabel, updateTopicItemLabel,
updateTopicItemMeta,
updateTopicItemDisplayOrder, updateTopicItemDisplayOrder,
countTierListsUsingTopicItem, countTierListsUsingTopicItem,
deleteTopicItem, deleteTopicItem,
deleteTopic, deleteTopic,
updateTopicDisplayOrder, updateTopicDisplayOrder,
updateCustomItemLabel, updateCustomItemLabel,
updateCustomItemMeta,
clearCustomItemReplacement, clearCustomItemReplacement,
markCustomItemReplaced, markCustomItemReplaced,
updateImageAssetLabel, updateImageAssetLabel,

View File

@@ -20,9 +20,11 @@ const {
updateTopicThumbnail, updateTopicThumbnail,
createTopicItem, createTopicItem,
updateTopicItemLabel, updateTopicItemLabel,
updateTopicItemMeta,
updateTopicItemDisplayOrder, updateTopicItemDisplayOrder,
countTierListsUsingTopicItem, countTierListsUsingTopicItem,
updateCustomItemLabel, updateCustomItemLabel,
updateCustomItemMeta,
updateImageAssetLabel, updateImageAssetLabel,
deleteTopicItem, deleteTopicItem,
deleteTopic, deleteTopic,
@@ -99,6 +101,17 @@ function buildItemLabelFromSrc(src) {
const upload = createMemoryUpload(multer, { fileSize: 20 * 1024 * 1024, maxCount: 100 }) const upload = createMemoryUpload(multer, { fileSize: 20 * 1024 * 1024, maxCount: 100 })
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 }) 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) { function decorateAdminUser(user, primaryAdmin) {
if (!user) return null if (!user) return null
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
@@ -127,6 +140,7 @@ router.post('/templates', requireAdmin, async (req, res) => {
const schema = z.object({ const schema = z.object({
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
name: z.string().min(1).max(60), 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), isPublic: z.boolean().optional().default(false),
thumbnailSrc: z.string().max(255).optional().default(''), thumbnailSrc: z.string().max(255).optional().default(''),
}) })
@@ -136,7 +150,12 @@ router.post('/templates', requireAdmin, async (req, res) => {
if (exists) return res.status(409).json({ error: 'topic_slug_taken' }) if (exists) return res.status(409).json({ error: 'topic_slug_taken' })
let template let template
try { 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) { } catch (error) {
if (error?.code === 'TOPIC_SLUG_INVALID') return res.status(400).json({ error: 'topic_slug_invalid' }) 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' }) if (error?.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'topic_slug_taken' })
@@ -154,6 +173,7 @@ router.patch('/templates/:templateId', requireAdmin, async (req, res) => {
const schema = z.object({ const schema = z.object({
slug: z.string().trim().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(), 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(), 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(), isPublic: z.boolean().optional(),
}) })
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
@@ -169,6 +189,7 @@ router.patch('/templates/:templateId', requireAdmin, async (req, res) => {
? await updateTopicMeta(template.id, { ? await updateTopicMeta(template.id, {
slug: parsed.data.slug || template.slug, slug: parsed.data.slug || template.slug,
name: parsed.data.name || template.name, 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, isPublic: typeof parsed.data.isPublic === 'boolean' ? parsed.data.isPublic : template.isPublic,
}) })
: await findTopicById(template.id) : await findTopicById(template.id)
@@ -309,14 +330,20 @@ router.get('/templates/:templateId/items/:itemId/usage', requireAdmin, async (re
}) })
router.patch('/templates/:templateId/items/:itemId', requireAdmin, async (req, res) => { 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) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const template = await findTopicById(getTemplateIdFromParams(req)) const template = await findTopicById(getTemplateIdFromParams(req))
if (!template) return res.status(404).json({ error: 'not_found' }) 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' }) if (!updated || updated.topicId !== template.id) return res.status(404).json({ error: 'not_found' })
res.json({ item: updated }) res.json({ item: updated })
}) })
@@ -332,6 +359,7 @@ router.delete('/templates/:templateId', requireAdmin, async (req, res) => {
router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => { router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
const schema = z.object({ const schema = z.object({
label: z.string().trim().min(1).max(60), 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'), sourceType: z.enum(['template', 'user', 'asset']).optional().default('user'),
}) })
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
@@ -345,12 +373,18 @@ router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
} }
if (parsed.data.sourceType === 'template') { 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' }) if (!updated) return res.status(404).json({ error: 'not_found' })
return res.json({ item: updated }) 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' }) if (!updated) return res.status(404).json({ error: 'not_found' })
return res.json({ item: updated }) return res.json({ item: updated })
}) })
@@ -559,6 +593,7 @@ async function promoteLibraryItemToTemplateItem({ item, templateId }) {
topicId: templateId, topicId: templateId,
src: item.src || '', src: item.src || '',
label: item.label, label: item.label,
tags: item.tags || [],
}) })
} }
@@ -576,6 +611,7 @@ async function findLibraryItemForReplacement(itemId, sourceType = '') {
sourceType: 'asset', sourceType: 'asset',
src: asset.src || '', src: asset.src || '',
label: asset.labelOverride || buildItemLabelFromSrc(asset.src), label: asset.labelOverride || buildItemLabelFromSrc(asset.src),
tags: [],
} }
} }
@@ -587,6 +623,7 @@ async function findLibraryItemForReplacement(itemId, sourceType = '') {
sourceType: 'template', sourceType: 'template',
src: item.src || '', src: item.src || '',
label: item.label || buildItemLabelFromSrc(item.src), label: item.label || buildItemLabelFromSrc(item.src),
tags: item.tags || [],
} }
} }
@@ -597,6 +634,7 @@ async function findLibraryItemForReplacement(itemId, sourceType = '') {
sourceType: 'user', sourceType: 'user',
src: customItem.src || '', src: customItem.src || '',
label: customItem.label || buildItemLabelFromSrc(customItem.src), label: customItem.label || buildItemLabelFromSrc(customItem.src),
tags: customItem.tags || [],
} }
} }
@@ -607,6 +645,7 @@ async function findLibraryItemForReplacement(itemId, sourceType = '') {
sourceType: 'template', sourceType: 'template',
src: templateItem.src || '', src: templateItem.src || '',
label: templateItem.label || buildItemLabelFromSrc(templateItem.src), label: templateItem.label || buildItemLabelFromSrc(templateItem.src),
tags: templateItem.tags || [],
} }
} }

View File

@@ -1,5 +1,10 @@
# 의사결정 이력 # 의사결정 이력
## 2026-04-06 v1.4.86
- 내부 운영용 분류는 공개 노출용 필드와 분리하는 편이 맞다고 보고, 태그는 템플릿과 아이템의 관리자 전용 메타로만 저장하고 검색에 활용하는 방향으로 정리했다.
- 기존 분기 템플릿이나 요청 아이템을 다시 재조합해 새 템플릿을 만드는 흐름은 완전히 별도 마법 기능보다, 이미 있는 `초안 목록`에 다른 출처의 아이템을 합류시키고 `제외` 버튼으로 정리하는 편이 훨씬 예측 가능하다고 판단했다.
- 사용자가 잘못 올린 이미지는 파일 업로드 자체를 되돌리는 것보다 “현재 티어표에서 이 커스텀 아이템을 제거”하는 조작이 먼저 필요하다고 보고, 저장 전 blob 이미지와 저장 후 커스텀 아이템 모두 같은 제거 UX로 다루는 쪽을 채택했다.
## 2026-04-06 v1.4.85 ## 2026-04-06 v1.4.85
- 썸네일과 프로필 이미지는 이미 별도 필터로 분리돼 있고, 템플릿 아이템/사용자 아이템도 각각 구분되고 있으므로, 그 어디에도 속하지 않는 관리자 업로드 자산은 운영 의미상 `보관 자산`보다 `미사용 이미지`로 보는 편이 더 직관적이라고 정리했다. - 썸네일과 프로필 이미지는 이미 별도 필터로 분리돼 있고, 템플릿 아이템/사용자 아이템도 각각 구분되고 있으므로, 그 어디에도 속하지 않는 관리자 업로드 자산은 운영 의미상 `보관 자산`보다 `미사용 이미지`로 보는 편이 더 직관적이라고 정리했다.
- 또 관리자 아이템 화면에서 삭제 버튼이 필터 조건에 따라 갑자기 비활성으로만 보이면 흐름을 이해하기 어려우므로, 평소에는 `미사용 이미지 확인`, 해당 화면 안에서만 `미사용 이미지 일괄 삭제`로 바뀌는 점진적 동작이 더 낫다고 판단했다. - 또 관리자 아이템 화면에서 삭제 버튼이 필터 조건에 따라 갑자기 비활성으로만 보이면 흐름을 이해하기 어려우므로, 평소에는 `미사용 이미지 확인`, 해당 화면 안에서만 `미사용 이미지 일괄 삭제`로 바뀌는 점진적 동작이 더 낫다고 판단했다.

View File

@@ -1,5 +1,11 @@
# 업데이트 로그 # 업데이트 로그
## 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 ## 2026-04-06 v1.4.85
- 관리자 아이템 관리의 `미사용 이미지` 범위를 다시 정리해, 사용자 업로드 미사용 항목뿐 아니라 현재 어디에도 연결되지 않은 관리자 자산(`asset`)도 같은 미사용 이미지 목록에서 함께 보이도록 통합했다. - 관리자 아이템 관리의 `미사용 이미지` 범위를 다시 정리해, 사용자 업로드 미사용 항목뿐 아니라 현재 어디에도 연결되지 않은 관리자 자산(`asset`)도 같은 미사용 이미지 목록에서 함께 보이도록 통합했다.
- 그래서 이제 관리자 업로드 이미지라도 템플릿 아이템, 사용자 아이템, 썸네일, 프로필 등 어느 경로에도 연결되지 않으면 `미사용 이미지`로 보고 일괄 삭제할 수 있다. - 그래서 이제 관리자 업로드 이미지라도 템플릿 아이템, 사용자 아이템, 썸네일, 프로필 등 어느 경로에도 연결되지 않으면 `미사용 이미지`로 보고 일괄 삭제할 수 있다.

View File

@@ -9,12 +9,14 @@ const props = defineProps({
stagedRequestDraftCount: { type: Number, required: true }, stagedRequestDraftCount: { type: Number, required: true },
appliedRequestItemCount: { type: Number, required: true }, appliedRequestItemCount: { type: Number, required: true },
openTemplateCreateModal: { type: Function, required: true }, openTemplateCreateModal: { type: Function, required: true },
openTemplateSourceImportModal: { type: Function, required: true },
isTemplateLoading: { type: Boolean, required: true }, isTemplateLoading: { type: Boolean, required: true },
hasSelectedTemplate: { type: Boolean, required: true }, hasSelectedTemplate: { type: Boolean, required: true },
selectedTemplate: { type: Object, default: null }, selectedTemplate: { type: Object, default: null },
displayThumbnailUrl: { type: String, default: '' }, displayThumbnailUrl: { type: String, default: '' },
templateMetaDraftName: { type: String, default: '' }, templateMetaDraftName: { type: String, default: '' },
templateMetaDraftSlug: { type: String, default: '' }, templateMetaDraftSlug: { type: String, default: '' },
templateMetaDraftTags: { type: String, default: '' },
templateMetaSaving: { type: Boolean, required: true }, templateMetaSaving: { type: Boolean, required: true },
canSaveTemplateMeta: { type: Boolean, required: true }, canSaveTemplateMeta: { type: Boolean, required: true },
saveTemplateMeta: { type: Function, required: true }, saveTemplateMeta: { type: Function, required: true },
@@ -52,7 +54,7 @@ const props = defineProps({
selectedTemplateId: { type: String, default: '' }, selectedTemplateId: { type: String, default: '' },
}) })
defineEmits(['update:templateMetaDraftName', 'update:templateMetaDraftSlug']) defineEmits(['update:templateMetaDraftName', 'update:templateMetaDraftSlug', 'update:templateMetaDraftTags'])
function setTemplateItemListElement(el) { function setTemplateItemListElement(el) {
props.templateItemListRef(el) props.templateItemListRef(el)
@@ -161,6 +163,17 @@ function setThumbFileElement(el) {
@input="$emit('update:templateMetaDraftSlug', $event.target.value)" @input="$emit('update:templateMetaDraftSlug', $event.target.value)"
/> />
</label> </label>
<label class="templateMetaField">
<span class="templateMetaField__label">내부 태그</span>
<input
class="input input--dense"
type="text"
maxlength="240"
:value="props.templateMetaDraftTags"
placeholder="예: 2026Q1, 애니, 여캐릭"
@input="$emit('update:templateMetaDraftTags', $event.target.value)"
/>
</label>
</div> </div>
<div class="templateSettingsCard__meta">공개 URL: /topics/{{ props.selectedTemplate.template.slug || props.selectedTemplate.template.id }}</div> <div class="templateSettingsCard__meta">공개 URL: /topics/{{ props.selectedTemplate.template.slug || props.selectedTemplate.template.id }}</div>
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.templateVisibilitySaving }"> <label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.templateVisibilitySaving }">
@@ -170,8 +183,9 @@ function setThumbFileElement(el) {
</label> </label>
<div class="templateSettingsCard__actions"> <div class="templateSettingsCard__actions">
<button class="btn btn--ghost" :disabled="!props.canSaveTemplateMeta || props.templateMetaSaving" @click="props.saveTemplateMeta"> <button class="btn btn--ghost" :disabled="!props.canSaveTemplateMeta || props.templateMetaSaving" @click="props.saveTemplateMeta">
{{ props.templateMetaSaving ? '저장중...' : '이름/slug 저장' }} {{ props.templateMetaSaving ? '저장중...' : '템플릿 메타 저장' }}
</button> </button>
<button class="btn btn--ghost" @click="props.openTemplateSourceImportModal">기존 템플릿 가져오기</button>
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button> <button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
<button class="btn btn--danger" @click="props.removeTemplate">템플릿 삭제</button> <button class="btn btn--danger" @click="props.removeTemplate">템플릿 삭제</button>
</div> </div>
@@ -218,10 +232,11 @@ function setThumbFileElement(el) {
</div> </div>
<div class="itemDraftRow__body"> <div class="itemDraftRow__body">
<input v-model="draft.label" class="input input--labelEdit input--dense" maxlength="60" placeholder="아이템 이름" /> <input v-model="draft.label" class="input input--labelEdit input--dense" maxlength="60" placeholder="아이템 이름" />
<input v-model="draft.tagsText" class="input input--labelEdit input--dense" maxlength="240" placeholder="내부 태그 (쉼표로 구분)" />
<div class="hint hint--tight">{{ draft.sourceName }}</div> <div class="hint hint--tight">{{ draft.sourceName }}</div>
<div class="itemDraftRow__meta"> <div class="itemDraftRow__meta">
<span class="pill" :class="draft.kind === 'request' ? 'pill--requestItem' : 'pill--directFile'"> <span class="pill" :class="draft.kind === 'request' ? 'pill--requestItem' : 'pill--directFile'">
{{ draft.kind === 'request' ? '요청 아이템' : '직접 추가 파일' }} {{ draft.kind === 'request' ? '요청 아이템' : draft.kind === 'library' ? '기존 템플릿' : '직접 추가 파일' }}
</span> </span>
<button class="btn btn--danger btn--small" type="button" @click="props.removeUploadDraft(draft)">제외</button> <button class="btn btn--danger btn--small" type="button" @click="props.removeUploadDraft(draft)">제외</button>
</div> </div>
@@ -246,18 +261,19 @@ function setThumbFileElement(el) {
<button class="btn btn--primary btn--small" :disabled="!props.hasTemplateItemOrderChanges" @click="props.saveTemplateItemOrder">순서 저장</button> <button class="btn btn--primary btn--small" :disabled="!props.hasTemplateItemOrderChanges" @click="props.saveTemplateItemOrder">순서 저장</button>
</div> </div>
<div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div> <div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
<div v-else :ref="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"> <div v-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-template-item-id="item.id">
<img class="thumb thumb--template" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" /> <img class="thumb thumb--template" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag /> <input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />
<input v-model="item.draftTags" class="input input--labelEdit input--dense" placeholder="내부 태그 (쉼표로 구분)" data-no-drag />
<div class="thumbCard__actions"> <div class="thumbCard__actions">
<button <button
class="btn btn--ghost btn--small" class="btn btn--ghost btn--small"
data-no-drag data-no-drag
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label" :disabled="item.isSavingLabel || !item.draftLabel?.trim() || (item.draftLabel.trim() === item.label && (item.draftTags || '') === ((item.tags || []).join(', ')))"
@click="props.saveTemplateItemLabel(item)" @click="props.saveTemplateItemLabel(item)"
> >
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }} {{ item.isSavingLabel ? '저장중...' : '메타 저장' }}
</button> </button>
<button class="btn btn--danger btn--small" data-no-drag @click="props.removeTemplateItem(item.id)">아이템 삭제</button> <button class="btn btn--danger btn--small" data-no-drag @click="props.removeTemplateItem(item.id)">아이템 삭제</button>
</div> </div>

View File

@@ -14,6 +14,7 @@ export function useAdminCustomItems({
customItemModalHistoryActive, customItemModalHistoryActive,
modalTargetCustomItem, modalTargetCustomItem,
customItemModalDraftLabel, customItemModalDraftLabel,
customItemModalDraftTags,
customItemModalLabelSaving, customItemModalLabelSaving,
customItemModalTargetTemplateId, customItemModalTargetTemplateId,
customItemReplacementQuery, customItemReplacementQuery,
@@ -88,6 +89,7 @@ export function useAdminCustomItems({
function openCustomItemModal(item) { function openCustomItemModal(item) {
modalTargetCustomItem.value = item || null modalTargetCustomItem.value = item || null
customItemModalDraftLabel.value = item?.label || '' customItemModalDraftLabel.value = item?.label || ''
customItemModalDraftTags.value = Array.isArray(item?.tags) ? item.tags.join(', ') : ''
customItemModalTargetTemplateId.value = '' customItemModalTargetTemplateId.value = ''
customItemReplacementQuery.value = '' customItemReplacementQuery.value = ''
customItemReplacementItems.value = [] customItemReplacementItems.value = []
@@ -102,6 +104,7 @@ export function useAdminCustomItems({
customItemDeleteModalOpen.value = false customItemDeleteModalOpen.value = false
modalTargetCustomItem.value = null modalTargetCustomItem.value = null
customItemModalDraftLabel.value = '' customItemModalDraftLabel.value = ''
customItemModalDraftTags.value = ''
customItemModalLabelSaving.value = false customItemModalLabelSaving.value = false
customItemModalTargetTemplateId.value = '' customItemModalTargetTemplateId.value = ''
customItemReplacementQuery.value = '' customItemReplacementQuery.value = ''
@@ -198,17 +201,34 @@ export function useAdminCustomItems({
async function saveCustomItemModalLabel() { async function saveCustomItemModalLabel() {
const item = modalTargetCustomItem.value const item = modalTargetCustomItem.value
const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60) const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60)
if (!item || !nextLabel || nextLabel === item.label || customItemModalLabelSaving.value) return const nextTags = Array.from(
new Set(
String(customItemModalDraftTags.value || '')
.split(',')
.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 { try {
customItemModalLabelSaving.value = true 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.label = data.item?.label || nextLabel
item.tags = Array.isArray(data.item?.tags) ? data.item.tags : nextTags
customItemModalDraftLabel.value = item.label customItemModalDraftLabel.value = item.label
customItems.value = customItems.value.map((entry) => (entry.id === item.id ? { ...entry, label: item.label } : entry)) customItemModalDraftTags.value = item.tags.join(', ')
toast.success('아이템 이름을 변경했어요.') customItems.value = customItems.value.map((entry) => (entry.id === item.id ? { ...entry, label: item.label, tags: item.tags } : entry))
toast.success('아이템 메타를 변경했어요.')
} catch (e) { } catch (e) {
error.value = '아이템 이름 변경에 실패했어요.' error.value = '아이템 메타 변경에 실패했어요.'
} finally { } finally {
customItemModalLabelSaving.value = false customItemModalLabelSaving.value = false
} }

View File

@@ -31,6 +31,17 @@ export function useAdminTemplateManager({
success, success,
error, 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) { function normalizeDraftSrc(src) {
if (typeof src !== 'string') return '' if (typeof src !== 'string') return ''
const raw = src.trim() 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) { function removeUploadDraft(targetDraft) {
const targetKey = `${targetDraft.kind}:${targetDraft.requestId || ''}:${targetDraft.itemId || targetDraft.file?.name || ''}:${targetDraft.previewUrl || ''}` const targetKey = `${targetDraft.kind}:${targetDraft.requestId || ''}:${targetDraft.itemId || targetDraft.file?.name || ''}:${targetDraft.previewUrl || ''}`
uploadItemDrafts.value = uploadItemDrafts.value.filter((draft) => { uploadItemDrafts.value = uploadItemDrafts.value.filter((draft) => {
@@ -139,6 +176,7 @@ export function useAdminTemplateManager({
items: (data.items || []).map((item) => ({ items: (data.items || []).map((item) => ({
...item, ...item,
draftLabel: item.label, draftLabel: item.label,
draftTags: Array.isArray(item.tags) ? item.tags.join(', ') : '',
})), })),
} }
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id) 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 fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file')
const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request') 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) const totalUploadBytes = fileDrafts.reduce((sum, entry) => sum + Number(entry.file?.size || 0), 0)
let uploadCount = 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() resetUploadState()
await loadTemplate() await loadTemplate()
success.value = `템플릿 기본 아이템 ${uploadCount}개 추가를 완료했어요.` success.value = `템플릿 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
@@ -397,6 +458,7 @@ export function useAdminTemplateManager({
items: (data.items || []).map((item) => ({ items: (data.items || []).map((item) => ({
...item, ...item,
draftLabel: item.label, draftLabel: item.label,
draftTags: Array.isArray(item.tags) ? item.tags.join(', ') : '',
})), })),
} }
savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id) savedTemplateItemOrderIds.value = (data.items || []).map((item) => item.id)
@@ -412,6 +474,7 @@ export function useAdminTemplateManager({
destroyTemplateItemSortable, destroyTemplateItemSortable,
syncTemplateItemSortable, syncTemplateItemSortable,
mergeRequestItemsIntoDrafts, mergeRequestItemsIntoDrafts,
mergeLibraryItemsIntoDrafts,
removeUploadDraft, removeUploadDraft,
loadTemplate, loadTemplate,
createTemplate, createTemplate,

View File

@@ -70,6 +70,9 @@ const importModalItems = ref([])
const importModalTargetTemplateId = ref('') const importModalTargetTemplateId = ref('')
const importModalNewTemplateId = ref('') const importModalNewTemplateId = ref('')
const importModalNewTemplateName = ref('') const importModalNewTemplateName = ref('')
const templateSourceImportModalOpen = ref(false)
const templateSourceImportQuery = ref('')
const templateSourceImportSelectedIds = ref([])
const previewModalOpen = ref(false) const previewModalOpen = ref(false)
const previewTierList = ref(null) const previewTierList = ref(null)
const adminTierListManageModalOpen = ref(false) const adminTierListManageModalOpen = ref(false)
@@ -89,6 +92,7 @@ const modalUserDraftNickname = ref('')
const modalUserDraftIsAdmin = ref(false) const modalUserDraftIsAdmin = ref(false)
const modalTargetCustomItem = ref(null) const modalTargetCustomItem = ref(null)
const customItemModalDraftLabel = ref('') const customItemModalDraftLabel = ref('')
const customItemModalDraftTags = ref('')
const customItemModalLabelSaving = ref(false) const customItemModalLabelSaving = ref(false)
const modalTargetAdminTierList = ref(null) const modalTargetAdminTierList = ref(null)
const adminTierListDraftTitle = ref('') const adminTierListDraftTitle = ref('')
@@ -117,6 +121,7 @@ const newTemplateName = ref('')
const newTemplateIsPublic = ref(false) const newTemplateIsPublic = ref(false)
const templateMetaDraftName = ref('') const templateMetaDraftName = ref('')
const templateMetaDraftSlug = ref('') const templateMetaDraftSlug = ref('')
const templateMetaDraftTags = ref('')
const templateMetaSaving = ref(false) const templateMetaSaving = ref(false)
const templateVisibilitySaving = ref(false) const templateVisibilitySaving = ref(false)
@@ -184,16 +189,30 @@ function normalizeAdminSrc(src) {
} }
} }
function parseAdminTagsText(value) {
return Array.from(
new Set(
String(value || '')
.split(',')
.map((tag) => tag.trim().replace(/^#/, '').slice(0, 40))
.filter(Boolean)
)
).slice(0, 30)
}
const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.template?.id) const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.template?.id)
const canSaveTemplateMeta = computed(() => { const canSaveTemplateMeta = computed(() => {
const template = selectedTemplate.value?.template const template = selectedTemplate.value?.template
if (!template?.id) return false if (!template?.id) return false
const nextName = templateMetaDraftName.value.trim() const nextName = templateMetaDraftName.value.trim()
const nextSlug = templateMetaDraftSlug.value.trim() const nextSlug = templateMetaDraftSlug.value.trim()
const nextTags = parseAdminTagsText(templateMetaDraftTags.value)
return ( return (
!!nextName && !!nextName &&
!!nextSlug && !!nextSlug &&
(nextName !== (template.name || '') || nextSlug !== (template.slug || template.id || '')) (nextName !== (template.name || '') ||
nextSlug !== (template.slug || template.id || '') ||
JSON.stringify(nextTags) !== JSON.stringify(Array.isArray(template.tags) ? template.tags : []))
) )
}) })
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value) const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value)
@@ -222,7 +241,7 @@ const filteredTemplatePickerTemplates = computed(() => {
const query = templatePickerQuery.value.trim().toLowerCase() const query = templatePickerQuery.value.trim().toLowerCase()
const list = templates.value.filter((template) => { const list = templates.value.filter((template) => {
if (!query) return true if (!query) return true
const haystack = `${template.name || ''} ${template.slug || ''} ${template.id || ''}`.toLowerCase() const haystack = `${template.name || ''} ${template.slug || ''} ${template.id || ''} ${(template.tags || []).join(' ')}`.toLowerCase()
return haystack.includes(query) return haystack.includes(query)
}) })
@@ -236,6 +255,17 @@ const customItemReplacementTarget = computed(
() => customItemReplacementItems.value.find((item) => item.id === customItemReplacementTargetId.value) || null () => customItemReplacementItems.value.find((item) => item.id === customItemReplacementTargetId.value) || null
) )
const importModalItemCount = computed(() => importModalItems.value.length) const importModalItemCount = computed(() => importModalItems.value.length)
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 || ''} ${(template.tags || []).join(' ')}`.toLowerCase()
return haystack.includes(query)
})
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
})
const activeTabTitle = computed(() => { const activeTabTitle = computed(() => {
if (activeTab.value === 'featured') return '목록 관리' if (activeTab.value === 'featured') return '목록 관리'
if (activeTab.value === 'template-admin') return '템플릿 관리' if (activeTab.value === 'template-admin') return '템플릿 관리'
@@ -486,6 +516,7 @@ watch(
async (templateId) => { async (templateId) => {
templateMetaDraftName.value = selectedTemplate.value?.template?.name || '' templateMetaDraftName.value = selectedTemplate.value?.template?.name || ''
templateMetaDraftSlug.value = selectedTemplate.value?.template?.slug || selectedTemplate.value?.template?.id || '' templateMetaDraftSlug.value = selectedTemplate.value?.template?.slug || selectedTemplate.value?.template?.id || ''
templateMetaDraftTags.value = Array.isArray(selectedTemplate.value?.template?.tags) ? selectedTemplate.value.template.tags.join(', ') : ''
await refreshSelectedTemplateTierListStats(templateId) await refreshSelectedTemplateTierListStats(templateId)
}, },
{ immediate: true } { immediate: true }
@@ -986,6 +1017,7 @@ const {
destroyTemplateItemSortable, destroyTemplateItemSortable,
syncTemplateItemSortable, syncTemplateItemSortable,
mergeRequestItemsIntoDrafts, mergeRequestItemsIntoDrafts,
mergeLibraryItemsIntoDrafts,
removeUploadDraft, removeUploadDraft,
loadTemplate, loadTemplate,
createTemplate, createTemplate,
@@ -1079,6 +1111,7 @@ const {
customItemModalHistoryActive, customItemModalHistoryActive,
modalTargetCustomItem, modalTargetCustomItem,
customItemModalDraftLabel, customItemModalDraftLabel,
customItemModalDraftTags,
customItemModalLabelSaving, customItemModalLabelSaving,
customItemModalTargetTemplateId, customItemModalTargetTemplateId,
customItemReplacementQuery, customItemReplacementQuery,
@@ -1237,6 +1270,7 @@ async function saveTemplateVisibility() {
templateVisibilitySaving.value = true templateVisibilitySaving.value = true
const data = await api.updateAdminTemplate(selectedTemplate.value.template.id, { const data = await api.updateAdminTemplate(selectedTemplate.value.template.id, {
isPublic: !!selectedTemplate.value.template.isPublic, isPublic: !!selectedTemplate.value.template.isPublic,
tags: Array.isArray(selectedTemplate.value.template.tags) ? selectedTemplate.value.template.tags : [],
}) })
const nextTemplate = data.template || {} const nextTemplate = data.template || {}
selectedTemplate.value = { selectedTemplate.value = {
@@ -1265,6 +1299,7 @@ async function saveTemplateMeta() {
const data = await api.updateAdminTemplate(selectedTemplate.value.template.id, { const data = await api.updateAdminTemplate(selectedTemplate.value.template.id, {
name: templateMetaDraftName.value.trim(), name: templateMetaDraftName.value.trim(),
slug: templateMetaDraftSlug.value.trim().toLowerCase(), slug: templateMetaDraftSlug.value.trim().toLowerCase(),
tags: parseAdminTagsText(templateMetaDraftTags.value),
isPublic: !!selectedTemplate.value.template.isPublic, isPublic: !!selectedTemplate.value.template.isPublic,
}) })
const nextTemplate = data.template || {} const nextTemplate = data.template || {}
@@ -1277,8 +1312,9 @@ async function saveTemplateMeta() {
} }
templateMetaDraftName.value = nextTemplate.name || selectedTemplate.value.template.name || '' templateMetaDraftName.value = nextTemplate.name || selectedTemplate.value.template.name || ''
templateMetaDraftSlug.value = nextTemplate.slug || selectedTemplate.value.template.slug || selectedTemplate.value.template.id || '' templateMetaDraftSlug.value = nextTemplate.slug || selectedTemplate.value.template.slug || selectedTemplate.value.template.id || ''
templateMetaDraftTags.value = Array.isArray(nextTemplate.tags) ? nextTemplate.tags.join(', ') : templateMetaDraftTags.value
await refreshTemplates() await refreshTemplates()
success.value = '템플릿 이름과 slug를 저장했어요.' success.value = '템플릿 메타를 저장했어요.'
} catch (e) { } catch (e) {
const errorCode = e?.data?.error || '' const errorCode = e?.data?.error || ''
if (errorCode === 'topic_slug_taken') { if (errorCode === 'topic_slug_taken') {
@@ -1289,7 +1325,7 @@ async function saveTemplateMeta() {
error.value = 'slug는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.' error.value = 'slug는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.'
return return
} }
error.value = '템플릿 이름/slug를 저장하지 못했어요.' error.value = '템플릿 메타를 저장하지 못했어요.'
} finally { } finally {
templateMetaSaving.value = false templateMetaSaving.value = false
} }
@@ -1360,20 +1396,23 @@ async function saveTemplateItemLabel(item) {
resetMessages() resetMessages()
if (!selectedTemplateId.value) return if (!selectedTemplateId.value) return
const nextLabel = (item.draftLabel || '').trim() const nextLabel = (item.draftLabel || '').trim()
const nextTags = parseAdminTagsText(item.draftTags || '')
if (!nextLabel) { if (!nextLabel) {
error.value = '아이템 이름을 입력해주세요.' error.value = '아이템 이름을 입력해주세요.'
return return
} }
if (nextLabel === item.label) return if (nextLabel === item.label && JSON.stringify(nextTags) === JSON.stringify(Array.isArray(item.tags) ? item.tags : [])) return
try { try {
item.isSavingLabel = true 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: nextTags })
item.label = data.item.label item.label = data.item.label
item.draftLabel = data.item.label item.draftLabel = data.item.label
success.value = '기본 아이템 이름을 수정했어요.' item.tags = Array.isArray(data.item.tags) ? data.item.tags : []
item.draftTags = item.tags.join(', ')
success.value = '기본 아이템 메타를 수정했어요.'
} catch (e) { } catch (e) {
error.value = '기본 아이템 이름 수정에 실패했어요.' error.value = '기본 아이템 메타 수정에 실패했어요.'
} finally { } finally {
item.isSavingLabel = false item.isSavingLabel = false
} }
@@ -1482,6 +1521,7 @@ function buildModalItemFromTierListItem(item, tierList) {
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'asset' : 'user'), sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'asset' : 'user'),
sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템', sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템',
ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList), ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList),
tags: Array.isArray(matchedItem?.tags) ? matchedItem.tags : [],
linkedTemplates: Array.isArray(matchedItem?.linkedTemplates) ? matchedItem.linkedTemplates : [], linkedTemplates: Array.isArray(matchedItem?.linkedTemplates) ? matchedItem.linkedTemplates : [],
usageCount: matchedItem?.usageCount || 0, usageCount: matchedItem?.usageCount || 0,
canDelete: typeof matchedItem?.canDelete === 'boolean' ? matchedItem.canDelete : false, canDelete: typeof matchedItem?.canDelete === 'boolean' ? matchedItem.canDelete : false,
@@ -1716,6 +1756,63 @@ function closeTierListImportModal() {
importModalItems.value = [] 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 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() { async function confirmTierListImport() {
resetMessages() resetMessages()
if (!importModalTierList.value || !importModalItems.value.length) { if (!importModalTierList.value || !importModalItems.value.length) {
@@ -1849,12 +1946,14 @@ function openUserProfile(user) {
:staged-request-draft-count="stagedRequestDraftCount" :staged-request-draft-count="stagedRequestDraftCount"
:applied-request-item-count="appliedRequestItemCount" :applied-request-item-count="appliedRequestItemCount"
:open-template-create-modal="openTemplateCreateModal" :open-template-create-modal="openTemplateCreateModal"
:open-template-source-import-modal="openTemplateSourceImportModal"
:is-template-loading="isTemplateLoading" :is-template-loading="isTemplateLoading"
:has-selected-template="hasSelectedTemplate" :has-selected-template="hasSelectedTemplate"
:selected-template="selectedTemplate" :selected-template="selectedTemplate"
:display-thumbnail-url="displayThumbnailUrl" :display-thumbnail-url="displayThumbnailUrl"
v-model:template-meta-draft-name="templateMetaDraftName" v-model:template-meta-draft-name="templateMetaDraftName"
v-model:template-meta-draft-slug="templateMetaDraftSlug" v-model:template-meta-draft-slug="templateMetaDraftSlug"
v-model:template-meta-draft-tags="templateMetaDraftTags"
:template-meta-saving="templateMetaSaving" :template-meta-saving="templateMetaSaving"
:can-save-template-meta="canSaveTemplateMeta" :can-save-template-meta="canSaveTemplateMeta"
:save-template-meta="saveTemplateMeta" :save-template-meta="saveTemplateMeta"
@@ -2091,6 +2190,40 @@ function openUserProfile(user) {
</div> </div>
</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>
<span v-if="template.tags?.length" class="adminTemplatePicker__meta">#{{ template.tags.join(' #') }}</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="customItemModalOpen" class="modalOverlay" @click.self="closeCustomItemModal"> <div v-if="customItemModalOpen" class="modalOverlay" @click.self="closeCustomItemModal">
<div class="modalCard modalCard--customItem" role="dialog" aria-modal="true"> <div class="modalCard modalCard--customItem" role="dialog" aria-modal="true">
<div v-if="modalTargetCustomItem" class="customItemModal"> <div v-if="modalTargetCustomItem" class="customItemModal">
@@ -2174,13 +2307,18 @@ function openUserProfile(user) {
<span class="field__label">아이템 이름</span> <span class="field__label">아이템 이름</span>
<input v-model="customItemModalDraftLabel" class="field__input" type="text" maxlength="60" placeholder="아이템 이름" /> <input v-model="customItemModalDraftLabel" class="field__input" type="text" maxlength="60" placeholder="아이템 이름" />
</label> </label>
<button class="btn btn--ghost customItemModal__renameButton" type="button" :disabled="customItemModalLabelSaving || !customItemModalDraftLabel.trim() || customItemModalDraftLabel.trim() === modalTargetCustomItem.label" @click="saveCustomItemModalLabel"> <label v-if="modalTargetCustomItem.sourceType !== 'asset'" class="field">
{{ customItemModalLabelSaving ? '저장중...' : '이름 저장' }} <span class="field__label">내부 태그</span>
<input v-model="customItemModalDraftTags" class="field__input" type="text" maxlength="240" placeholder="예: 여캐릭, 귀멸, 2026Q1" />
</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> </button>
</div> </div>
<div class="customItemModal__metaList"> <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.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 :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>{{ visibleLinkedTemplates.length }} 템플릿</strong></div>
<div class="customItemModal__metaRow"><span>등록일</span><strong>{{ fmt(modalTargetCustomItem.createdAt) }}</strong></div> <div class="customItemModal__metaRow"><span>등록일</span><strong>{{ fmt(modalTargetCustomItem.createdAt) }}</strong></div>
</div> </div>
@@ -2443,7 +2581,7 @@ function openUserProfile(user) {
<div class="adminSidebar__label">Filters</div> <div class="adminSidebar__label">Filters</div>
<div class="adminSidebar__group"> <div class="adminSidebar__group">
<div class="adminSidebar__inlineRow"> <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> <button class="btn btn--ghost" @click="submitCustomItemSearch">검색</button>
</div> </div>
</div> </div>
@@ -2830,6 +2968,12 @@ function openUserProfile(user) {
opacity: 0.58; opacity: 0.58;
border-style: dashed; border-style: dashed;
} }
.adminUiScope .templateImportList {
display: grid;
gap: 10px;
max-height: 360px;
overflow: auto;
}
.adminUiScope .adminTemplatePicker__name { .adminUiScope .adminTemplatePicker__name {
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 800;
@@ -3302,7 +3446,7 @@ function openUserProfile(user) {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
/* flex-wrap: wrap; */ flex-wrap: wrap;
} }
.adminUiScope .selectedThumb { .adminUiScope .selectedThumb {
width: min(100%, 256px); width: min(100%, 256px);

View File

@@ -473,6 +473,25 @@ function duplicateItemToPool() {
toast.success('아이템 추가 완료') 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) { function handleGlobalContextMenu(event) {
const target = event?.target const target = event?.target
if (target?.closest?.('[data-item-context-menu]')) { if (target?.closest?.('[data-item-context-menu]')) {
@@ -1684,6 +1703,16 @@ onUnmounted(() => {
> >
× ×
</button> </button>
<button
v-if="canRemoveEditorItem(id) && !isExporting"
class="cellDeleteBtn"
type="button"
title="커스텀 이미지 제거"
@pointerdown.stop
@click.stop="deleteEditorItem(id)"
>
삭제
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1760,6 +1789,16 @@ onUnmounted(() => {
draggable="false" draggable="false"
/> />
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div> <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 v-if="!canEdit" class="poolItem__state">미배치</div>
</div> </div>
</div> </div>
@@ -1777,6 +1816,9 @@ onUnmounted(() => {
<button class="itemContextMenu__action" type="button" @click="duplicateItemToPool"> <button class="itemContextMenu__action" type="button" @click="duplicateItemToPool">
아이템 복제 아이템 복제
</button> </button>
<button v-if="canRemoveEditorItem(itemContextMenu.itemId)" class="itemContextMenu__action itemContextMenu__action--danger" type="button" @click="deleteEditorItem(itemContextMenu.itemId)">
커스텀 이미지 제거
</button>
</div> </div>
</div> </div>
@@ -3024,6 +3066,7 @@ onUnmounted(() => {
cursor: copy; cursor: copy;
} }
.poolItem { .poolItem {
position: relative;
min-width: 0; min-width: 0;
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -3070,6 +3113,23 @@ onUnmounted(() => {
text-transform: uppercase; text-transform: uppercase;
color: var(--theme-text-soft); 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 { .poolItem--hidden {
display: none; display: none;
} }
@@ -3101,6 +3161,9 @@ onUnmounted(() => {
.itemContextMenu__action:hover { .itemContextMenu__action:hover {
background: var(--theme-pill-bg); background: var(--theme-pill-bg);
} }
.itemContextMenu__action--danger {
color: #fca5a5;
}
.hidden { .hidden {
display: none; display: none;