diff --git a/backend/src/db.js b/backend/src/db.js index a4859a6..b9a18ff 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -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,6 +128,7 @@ 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, [])), } } @@ -127,6 +144,7 @@ function mapCustomItemRow(row) { replacedBySrc: row.replaced_by_src || '', replacedByLabel: row.replaced_by_label || '', replacedAt: Number(row.replaced_at || 0), + tags: normalizeTags(parseJson(row.tags_json, [])), } } @@ -349,6 +367,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, @@ -362,6 +381,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), @@ -375,6 +395,7 @@ 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 '', @@ -385,6 +406,9 @@ async function ensureSchema() { ) 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`) @@ -517,11 +541,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()] ) })() @@ -834,7 +858,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'} @@ -859,7 +883,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]) @@ -869,7 +893,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]) @@ -882,7 +906,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 @@ -897,7 +921,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 @@ -912,7 +936,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]) } @@ -923,14 +947,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(), @@ -938,11 +963,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, ]) @@ -1552,26 +1578,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]) } @@ -1593,7 +1630,29 @@ 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.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 INNER JOIN users u ON u.id = c.owner_id WHERE c.id = ? @@ -1680,16 +1739,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 }) { @@ -1713,7 +1773,7 @@ async function syncOwnedCustomItemLabels({ ownerId, items }) { async function findCustomItemById(id) { 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 WHERE id = ? LIMIT 1 @@ -1787,6 +1847,7 @@ 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, @@ -1796,10 +1857,10 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod 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( ` @@ -1808,14 +1869,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( ` @@ -1896,6 +1958,7 @@ 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: '관리자 미사용 이미지', ownerEmail: '', @@ -1915,6 +1978,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: '', @@ -1976,6 +2040,7 @@ 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 || '', @@ -2026,8 +2091,6 @@ 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, topicItemRows, usageMeta] = await Promise.all([ query( @@ -2037,6 +2100,7 @@ async function findUnusedCustomItems({ queryText = '' } = {}) { c.owner_id, c.src, c.label, + c.tags_json, c.replaced_by_item_id, c.replaced_by_src, c.replaced_by_label, @@ -2046,10 +2110,10 @@ async function findUnusedCustomItems({ queryText = '' } = {}) { u.email FROM custom_items c 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 `, - params + hasQuery ? [search, search, search, search, search] : [] ), query( ` @@ -3004,7 +3068,7 @@ async function findCustomItemsByIds(ids) { const placeholders = ids.map(() => '?').join(', ') 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 WHERE id IN (${placeholders}) `, @@ -3162,12 +3226,14 @@ module.exports = { cleanupMissingUploadReferences, createTopicItem, updateTopicItemLabel, + updateTopicItemMeta, updateTopicItemDisplayOrder, countTierListsUsingTopicItem, deleteTopicItem, deleteTopic, updateTopicDisplayOrder, updateCustomItemLabel, + updateCustomItemMeta, clearCustomItemReplacement, markCustomItemReplaced, updateImageAssetLabel, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 91ee171..c6f7444 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -20,9 +20,11 @@ const { updateTopicThumbnail, createTopicItem, updateTopicItemLabel, + updateTopicItemMeta, updateTopicItemDisplayOrder, countTierListsUsingTopicItem, updateCustomItemLabel, + updateCustomItemMeta, updateImageAssetLabel, deleteTopicItem, deleteTopic, @@ -99,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 @@ -127,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(''), }) @@ -136,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' }) @@ -154,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) @@ -169,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) @@ -309,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 }) }) @@ -332,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) @@ -345,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 }) }) @@ -559,6 +593,7 @@ async function promoteLibraryItemToTemplateItem({ item, templateId }) { topicId: templateId, src: item.src || '', label: item.label, + tags: item.tags || [], }) } @@ -576,6 +611,7 @@ async function findLibraryItemForReplacement(itemId, sourceType = '') { sourceType: 'asset', src: asset.src || '', label: asset.labelOverride || buildItemLabelFromSrc(asset.src), + tags: [], } } @@ -587,6 +623,7 @@ async function findLibraryItemForReplacement(itemId, sourceType = '') { sourceType: 'template', src: item.src || '', label: item.label || buildItemLabelFromSrc(item.src), + tags: item.tags || [], } } @@ -597,6 +634,7 @@ async function findLibraryItemForReplacement(itemId, sourceType = '') { sourceType: 'user', src: customItem.src || '', label: customItem.label || buildItemLabelFromSrc(customItem.src), + tags: customItem.tags || [], } } @@ -607,6 +645,7 @@ async function findLibraryItemForReplacement(itemId, sourceType = '') { sourceType: 'template', src: templateItem.src || '', label: templateItem.label || buildItemLabelFromSrc(templateItem.src), + tags: templateItem.tags || [], } } diff --git a/docs/history.md b/docs/history.md index 664d362..2465417 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-04-06 v1.4.86 +- 내부 운영용 분류는 공개 노출용 필드와 분리하는 편이 맞다고 보고, 태그는 템플릿과 아이템의 관리자 전용 메타로만 저장하고 검색에 활용하는 방향으로 정리했다. +- 기존 분기 템플릿이나 요청 아이템을 다시 재조합해 새 템플릿을 만드는 흐름은 완전히 별도 마법 기능보다, 이미 있는 `초안 목록`에 다른 출처의 아이템을 합류시키고 `제외` 버튼으로 정리하는 편이 훨씬 예측 가능하다고 판단했다. +- 사용자가 잘못 올린 이미지는 파일 업로드 자체를 되돌리는 것보다 “현재 티어표에서 이 커스텀 아이템을 제거”하는 조작이 먼저 필요하다고 보고, 저장 전 blob 이미지와 저장 후 커스텀 아이템 모두 같은 제거 UX로 다루는 쪽을 채택했다. + ## 2026-04-06 v1.4.85 - 썸네일과 프로필 이미지는 이미 별도 필터로 분리돼 있고, 템플릿 아이템/사용자 아이템도 각각 구분되고 있으므로, 그 어디에도 속하지 않는 관리자 업로드 자산은 운영 의미상 `보관 자산`보다 `미사용 이미지`로 보는 편이 더 직관적이라고 정리했다. - 또 관리자 아이템 화면에서 삭제 버튼이 필터 조건에 따라 갑자기 비활성으로만 보이면 흐름을 이해하기 어려우므로, 평소에는 `미사용 이미지 확인`, 해당 화면 안에서만 `미사용 이미지 일괄 삭제`로 바뀌는 점진적 동작이 더 낫다고 판단했다. diff --git a/docs/update.md b/docs/update.md index cf24ec0..f9b840a 100644 --- a/docs/update.md +++ b/docs/update.md @@ -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 - 관리자 아이템 관리의 `미사용 이미지` 범위를 다시 정리해, 사용자 업로드 미사용 항목뿐 아니라 현재 어디에도 연결되지 않은 관리자 자산(`asset`)도 같은 미사용 이미지 목록에서 함께 보이도록 통합했다. - 그래서 이제 관리자 업로드 이미지라도 템플릿 아이템, 사용자 아이템, 썸네일, 프로필 등 어느 경로에도 연결되지 않으면 `미사용 이미지`로 보고 일괄 삭제할 수 있다. diff --git a/frontend/src/components/admin/AdminTemplatesSection.vue b/frontend/src/components/admin/AdminTemplatesSection.vue index 98b9f13..ecca34a 100644 --- a/frontend/src/components/admin/AdminTemplatesSection.vue +++ b/frontend/src/components/admin/AdminTemplatesSection.vue @@ -9,12 +9,14 @@ const props = defineProps({ stagedRequestDraftCount: { type: Number, required: true }, appliedRequestItemCount: { type: Number, required: true }, openTemplateCreateModal: { type: Function, required: true }, + openTemplateSourceImportModal: { type: Function, required: true }, isTemplateLoading: { type: Boolean, required: true }, hasSelectedTemplate: { type: Boolean, required: true }, selectedTemplate: { type: Object, default: null }, displayThumbnailUrl: { type: String, default: '' }, templateMetaDraftName: { type: String, default: '' }, templateMetaDraftSlug: { type: String, default: '' }, + templateMetaDraftTags: { type: String, default: '' }, templateMetaSaving: { type: Boolean, required: true }, canSaveTemplateMeta: { type: Boolean, required: true }, saveTemplateMeta: { type: Function, required: true }, @@ -52,7 +54,7 @@ const props = defineProps({ selectedTemplateId: { type: String, default: '' }, }) -defineEmits(['update:templateMetaDraftName', 'update:templateMetaDraftSlug']) +defineEmits(['update:templateMetaDraftName', 'update:templateMetaDraftSlug', 'update:templateMetaDraftTags']) function setTemplateItemListElement(el) { props.templateItemListRef(el) @@ -161,6 +163,17 @@ function setThumbFileElement(el) { @input="$emit('update:templateMetaDraftSlug', $event.target.value)" /> +
공개 URL: /topics/{{ props.selectedTemplate.template.slug || props.selectedTemplate.template.id }}
+
@@ -218,10 +232,11 @@ function setThumbFileElement(el) {
+
{{ draft.sourceName }}
- {{ draft.kind === 'request' ? '요청 아이템' : '직접 추가 파일' }} + {{ draft.kind === 'request' ? '요청 아이템' : draft.kind === 'library' ? '기존 템플릿' : '직접 추가 파일' }}
@@ -246,18 +261,19 @@ function setThumbFileElement(el) {
아직 등록된 기본 아이템이 없어요.
-
+
+
diff --git a/frontend/src/composables/useAdminCustomItems.js b/frontend/src/composables/useAdminCustomItems.js index 860c2b0..a998bf8 100644 --- a/frontend/src/composables/useAdminCustomItems.js +++ b/frontend/src/composables/useAdminCustomItems.js @@ -14,6 +14,7 @@ export function useAdminCustomItems({ customItemModalHistoryActive, modalTargetCustomItem, customItemModalDraftLabel, + customItemModalDraftTags, customItemModalLabelSaving, customItemModalTargetTemplateId, customItemReplacementQuery, @@ -88,6 +89,7 @@ export function useAdminCustomItems({ function openCustomItemModal(item) { modalTargetCustomItem.value = item || null customItemModalDraftLabel.value = item?.label || '' + customItemModalDraftTags.value = Array.isArray(item?.tags) ? item.tags.join(', ') : '' customItemModalTargetTemplateId.value = '' customItemReplacementQuery.value = '' customItemReplacementItems.value = [] @@ -102,6 +104,7 @@ export function useAdminCustomItems({ customItemDeleteModalOpen.value = false modalTargetCustomItem.value = null customItemModalDraftLabel.value = '' + customItemModalDraftTags.value = '' customItemModalLabelSaving.value = false customItemModalTargetTemplateId.value = '' customItemReplacementQuery.value = '' @@ -198,17 +201,34 @@ export function useAdminCustomItems({ 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( + 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 { 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.join(', ') + 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 } diff --git a/frontend/src/composables/useAdminTemplateManager.js b/frontend/src/composables/useAdminTemplateManager.js index c3cb594..0565778 100644 --- a/frontend/src/composables/useAdminTemplateManager.js +++ b/frontend/src/composables/useAdminTemplateManager.js @@ -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, diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 70545c1..7506553 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -70,6 +70,9 @@ const importModalItems = ref([]) const importModalTargetTemplateId = ref('') const importModalNewTemplateId = ref('') const importModalNewTemplateName = ref('') +const templateSourceImportModalOpen = ref(false) +const templateSourceImportQuery = ref('') +const templateSourceImportSelectedIds = ref([]) const previewModalOpen = ref(false) const previewTierList = ref(null) const adminTierListManageModalOpen = ref(false) @@ -89,6 +92,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('') @@ -117,6 +121,7 @@ const newTemplateName = ref('') const newTemplateIsPublic = ref(false) const templateMetaDraftName = ref('') const templateMetaDraftSlug = ref('') +const templateMetaDraftTags = ref('') const templateMetaSaving = 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 canSaveTemplateMeta = computed(() => { const template = selectedTemplate.value?.template if (!template?.id) return false const nextName = templateMetaDraftName.value.trim() const nextSlug = templateMetaDraftSlug.value.trim() + const nextTags = parseAdminTagsText(templateMetaDraftTags.value) return ( !!nextName && !!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) @@ -222,7 +241,7 @@ const filteredTemplatePickerTemplates = computed(() => { const query = templatePickerQuery.value.trim().toLowerCase() const list = templates.value.filter((template) => { if (!query) return true - const haystack = `${template.name || ''} ${template.slug || ''} ${template.id || ''}`.toLowerCase() + const haystack = `${template.name || ''} ${template.slug || ''} ${template.id || ''} ${(template.tags || []).join(' ')}`.toLowerCase() return haystack.includes(query) }) @@ -236,6 +255,17 @@ const customItemReplacementTarget = computed( () => customItemReplacementItems.value.find((item) => item.id === customItemReplacementTargetId.value) || null ) 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(() => { if (activeTab.value === 'featured') return '목록 관리' if (activeTab.value === 'template-admin') return '템플릿 관리' @@ -486,6 +516,7 @@ watch( async (templateId) => { templateMetaDraftName.value = selectedTemplate.value?.template?.name || '' 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) }, { immediate: true } @@ -986,6 +1017,7 @@ const { destroyTemplateItemSortable, syncTemplateItemSortable, mergeRequestItemsIntoDrafts, + mergeLibraryItemsIntoDrafts, removeUploadDraft, loadTemplate, createTemplate, @@ -1079,6 +1111,7 @@ const { customItemModalHistoryActive, modalTargetCustomItem, customItemModalDraftLabel, + customItemModalDraftTags, customItemModalLabelSaving, customItemModalTargetTemplateId, customItemReplacementQuery, @@ -1237,6 +1270,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,6 +1299,7 @@ async function saveTemplateMeta() { const data = await api.updateAdminTemplate(selectedTemplate.value.template.id, { name: templateMetaDraftName.value.trim(), slug: templateMetaDraftSlug.value.trim().toLowerCase(), + tags: parseAdminTagsText(templateMetaDraftTags.value), isPublic: !!selectedTemplate.value.template.isPublic, }) const nextTemplate = data.template || {} @@ -1277,8 +1312,9 @@ async function saveTemplateMeta() { } templateMetaDraftName.value = nextTemplate.name || selectedTemplate.value.template.name || '' 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() - success.value = '템플릿 이름과 slug를 저장했어요.' + success.value = '템플릿 메타를 저장했어요.' } catch (e) { const errorCode = e?.data?.error || '' if (errorCode === 'topic_slug_taken') { @@ -1289,7 +1325,7 @@ async function saveTemplateMeta() { error.value = 'slug는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.' return } - error.value = '템플릿 이름/slug를 저장하지 못했어요.' + error.value = '템플릿 메타를 저장하지 못했어요.' } finally { templateMetaSaving.value = false } @@ -1360,20 +1396,23 @@ async function saveTemplateItemLabel(item) { resetMessages() if (!selectedTemplateId.value) return const nextLabel = (item.draftLabel || '').trim() + const nextTags = parseAdminTagsText(item.draftTags || '') if (!nextLabel) { error.value = '아이템 이름을 입력해주세요.' return } - if (nextLabel === item.label) return + if (nextLabel === item.label && JSON.stringify(nextTags) === JSON.stringify(Array.isArray(item.tags) ? item.tags : [])) return 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: nextTags }) item.label = 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) { - error.value = '기본 아이템 이름 수정에 실패했어요.' + error.value = '기본 아이템 메타 수정에 실패했어요.' } finally { item.isSavingLabel = false } @@ -1482,6 +1521,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, @@ -1716,6 +1756,63 @@ 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 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) { @@ -1849,12 +1946,14 @@ function openUserProfile(user) { :staged-request-draft-count="stagedRequestDraftCount" :applied-request-item-count="appliedRequestItemCount" :open-template-create-modal="openTemplateCreateModal" + :open-template-source-import-modal="openTemplateSourceImportModal" :is-template-loading="isTemplateLoading" :has-selected-template="hasSelectedTemplate" :selected-template="selectedTemplate" :display-thumbnail-url="displayThumbnailUrl" v-model:template-meta-draft-name="templateMetaDraftName" v-model:template-meta-draft-slug="templateMetaDraftSlug" + v-model:template-meta-draft-tags="templateMetaDraftTags" :template-meta-saving="templateMetaSaving" :can-save-template-meta="canSaveTemplateMeta" :save-template-meta="saveTemplateMeta" @@ -2091,6 +2190,40 @@ function openUserProfile(user) {
+
+ +
+
@@ -1760,6 +1789,16 @@ onUnmounted(() => { draggable="false" />
{{ itemsById[id]?.label || id }}
+
미배치
@@ -1777,6 +1816,9 @@ onUnmounted(() => { + @@ -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;