feat: 템플릿 태그와 병합 가져오기 및 에디터 제거 추가
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 || [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user