Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecd3e69b5a | |||
| 3dba9b0a4d | |||
| 56b0035a45 | |||
| 929ffb2ed6 | |||
| 08ec6f42d1 | |||
| 360ec5ac3d | |||
| 71a13488d9 | |||
| ba9ba8013b | |||
| da35351747 | |||
| 305160663d | |||
| 58b8df51ab | |||
| bdc7ee42e2 | |||
| fd3f61ca2b | |||
| 47638b8b3e | |||
| 632bebb8f9 | |||
| fe79c91e82 | |||
| 2d5506e35a | |||
| 8b3d469503 | |||
| a2fc8f8cd4 | |||
| b134431d91 |
@@ -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,9 +144,41 @@ 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, [])),
|
||||
}
|
||||
}
|
||||
|
||||
function getSharedItemDisplayPriority(item) {
|
||||
if (!item) return 99
|
||||
if (item.sourceType === 'user' && !item.replacedAt) return 0
|
||||
if (item.sourceType === 'user') return 1
|
||||
if (item.sourceType === 'template') return 2
|
||||
if (item.sourceType === 'asset' || item.isAssetLibraryItem) return 3
|
||||
return 4
|
||||
}
|
||||
|
||||
function collapseSharedLibraryItems(items) {
|
||||
const grouped = new Map()
|
||||
for (const item of items || []) {
|
||||
const key = String(item?.src || '').trim()
|
||||
if (!key) continue
|
||||
if (!grouped.has(key)) grouped.set(key, [])
|
||||
grouped.get(key).push(item)
|
||||
}
|
||||
|
||||
return Array.from(grouped.values())
|
||||
.map((group) =>
|
||||
group
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const priorityDiff = getSharedItemDisplayPriority(a) - getSharedItemDisplayPriority(b)
|
||||
if (priorityDiff !== 0) return priorityDiff
|
||||
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
|
||||
})[0]
|
||||
)
|
||||
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
|
||||
}
|
||||
|
||||
function mapImageAssetRow(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
@@ -349,6 +398,7 @@ async function ensureSchema() {
|
||||
slug VARCHAR(120) NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
|
||||
tags_json LONGTEXT NULL,
|
||||
is_public TINYINT(1) NOT NULL DEFAULT 1,
|
||||
display_rank INT NULL DEFAULT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
@@ -362,6 +412,7 @@ async function ensureSchema() {
|
||||
topic_id VARCHAR(120) NOT NULL,
|
||||
src VARCHAR(255) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
tags_json LONGTEXT NULL,
|
||||
display_order INT NULL DEFAULT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
INDEX idx_topic_items_topic_id (topic_id),
|
||||
@@ -375,6 +426,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 +437,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 +572,11 @@ async function ensureSchema() {
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO topics (id, slug, name, thumbnail_src, is_public, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE slug = VALUES(slug), name = VALUES(name), is_public = VALUES(is_public)
|
||||
INSERT INTO topics (id, slug, name, thumbnail_src, tags_json, is_public, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE slug = VALUES(slug), name = VALUES(name), tags_json = VALUES(tags_json), is_public = VALUES(is_public)
|
||||
`,
|
||||
[FREEFORM_TOPIC_ID, FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', 1, now()]
|
||||
[FREEFORM_TOPIC_ID, FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', '[]', 1, now()]
|
||||
)
|
||||
})()
|
||||
|
||||
@@ -834,7 +889,7 @@ async function listTopics(currentUserId = '', options = {}) {
|
||||
const includePrivate = !!options.includePrivate
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at
|
||||
SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at
|
||||
FROM topics
|
||||
WHERE id <> ?
|
||||
${includePrivate ? '' : 'AND is_public = 1'}
|
||||
@@ -859,7 +914,7 @@ async function listTopics(currentUserId = '', options = {}) {
|
||||
|
||||
async function findTopicById(id) {
|
||||
const rows = await query(
|
||||
'SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1',
|
||||
'SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1',
|
||||
[id]
|
||||
)
|
||||
return mapTopicRow(rows[0])
|
||||
@@ -869,7 +924,7 @@ async function findTopicBySlug(slug) {
|
||||
const normalizedSlug = normalizeTopicSlug(slug)
|
||||
if (!normalizedSlug) return null
|
||||
const rows = await query(
|
||||
'SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE slug = ? LIMIT 1',
|
||||
'SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at FROM topics WHERE slug = ? LIMIT 1',
|
||||
[normalizedSlug]
|
||||
)
|
||||
return mapTopicRow(rows[0])
|
||||
@@ -882,7 +937,7 @@ async function findTopicByIdentifier(topicRef) {
|
||||
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at
|
||||
SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at
|
||||
FROM topics
|
||||
WHERE id = ? OR slug = ?
|
||||
ORDER BY
|
||||
@@ -897,7 +952,7 @@ async function findTopicByIdentifier(topicRef) {
|
||||
async function listTopicItems(topicId) {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT id, topic_id, src, label, display_order, created_at
|
||||
SELECT id, topic_id, src, label, tags_json, display_order, created_at
|
||||
FROM topic_items
|
||||
WHERE topic_id = ?
|
||||
ORDER BY
|
||||
@@ -912,7 +967,7 @@ async function listTopicItems(topicId) {
|
||||
}
|
||||
|
||||
async function findTopicItemById(itemId) {
|
||||
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
|
||||
const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
|
||||
return mapTopicItemRow(rows[0])
|
||||
}
|
||||
|
||||
@@ -923,14 +978,15 @@ async function getTopicDetail(topicRef) {
|
||||
return { topic, template: topic, items }
|
||||
}
|
||||
|
||||
async function createTopic({ slug, name, isPublic = true }) {
|
||||
async function createTopic({ slug, name, tags = [], isPublic = true }) {
|
||||
const topicId = nanoid()
|
||||
const topicSlug = assertTopicSlug(slug)
|
||||
await query('INSERT INTO topics (id, slug, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [
|
||||
await query('INSERT INTO topics (id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [
|
||||
topicId,
|
||||
topicSlug,
|
||||
name,
|
||||
'',
|
||||
serializeJson(normalizeTags(tags)),
|
||||
isPublic ? 1 : 0,
|
||||
null,
|
||||
now(),
|
||||
@@ -938,11 +994,12 @@ async function createTopic({ slug, name, isPublic = true }) {
|
||||
return findTopicById(topicId)
|
||||
}
|
||||
|
||||
async function updateTopicMeta(topicId, { slug, name, isPublic }) {
|
||||
async function updateTopicMeta(topicId, { slug, name, tags = [], isPublic }) {
|
||||
const topicSlug = assertTopicSlug(slug)
|
||||
await query('UPDATE topics SET slug = ?, name = ?, is_public = ? WHERE id = ?', [
|
||||
await query('UPDATE topics SET slug = ?, name = ?, tags_json = ?, is_public = ? WHERE id = ?', [
|
||||
topicSlug,
|
||||
name,
|
||||
serializeJson(normalizeTags(tags)),
|
||||
isPublic ? 1 : 0,
|
||||
topicId,
|
||||
])
|
||||
@@ -1152,7 +1209,22 @@ function replaceItemSrc(items, fromSrc, toSrc, toLabel = '') {
|
||||
return { changed, items: nextItems }
|
||||
}
|
||||
|
||||
async function replaceUploadSourceReferences({ fromSrc, toSrc, toLabel = '' }) {
|
||||
function replaceItemById(items, itemId, nextSrc, nextLabel = '') {
|
||||
let changed = false
|
||||
const normalizedLabel = typeof nextLabel === 'string' ? nextLabel.trim().slice(0, 60) : ''
|
||||
const nextItems = (items || []).map((item) => {
|
||||
if (item?.id !== itemId) return item
|
||||
changed = true
|
||||
return {
|
||||
...item,
|
||||
...(typeof nextSrc === 'string' && nextSrc ? { src: nextSrc } : {}),
|
||||
...(normalizedLabel ? { label: normalizedLabel } : {}),
|
||||
}
|
||||
})
|
||||
return { changed, items: nextItems }
|
||||
}
|
||||
|
||||
async function replaceUploadSourceReferences({ fromSrc, toSrc, toLabel = '', updateCustomItemsBySrc = true }) {
|
||||
if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 }
|
||||
const normalizedLabel = typeof toLabel === 'string' ? toLabel.trim().slice(0, 60) : ''
|
||||
|
||||
@@ -1162,9 +1234,11 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc, toLabel = '' }) {
|
||||
normalizedLabel
|
||||
? query('UPDATE topic_items SET src = ?, label = ? WHERE src = ?', [toSrc, normalizedLabel, fromSrc])
|
||||
: query('UPDATE topic_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
|
||||
normalizedLabel
|
||||
? query('UPDATE custom_items SET src = ?, label = ? WHERE src = ?', [toSrc, normalizedLabel, fromSrc])
|
||||
: query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
|
||||
updateCustomItemsBySrc
|
||||
? normalizedLabel
|
||||
? query('UPDATE custom_items SET src = ?, label = ? WHERE src = ?', [toSrc, normalizedLabel, fromSrc])
|
||||
: query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc])
|
||||
: Promise.resolve({ affectedRows: 0 }),
|
||||
])
|
||||
|
||||
let updatedRows = Number(userResult.affectedRows || 0) + Number(topicResult.affectedRows || 0) + Number(topicItemResult.affectedRows || 0) + Number(customItemResult.affectedRows || 0)
|
||||
@@ -1220,6 +1294,40 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc, toLabel = '' }) {
|
||||
return { updatedRows }
|
||||
}
|
||||
|
||||
async function updateCustomItemDisplayReferences({ itemId, src = '', label = '' }) {
|
||||
if (!itemId) return { updatedRows: 0 }
|
||||
const normalizedLabel = typeof label === 'string' ? label.trim().slice(0, 60) : ''
|
||||
let updatedRows = 0
|
||||
|
||||
const tierListRows = await query('SELECT id, pool_json FROM tierlists')
|
||||
for (const row of tierListRows) {
|
||||
const replacedPool = replaceItemById(parseJson(row.pool_json, []), itemId, src, normalizedLabel)
|
||||
if (!replacedPool.changed) continue
|
||||
await query('UPDATE tierlists SET pool_json = ?, updated_at = ? WHERE id = ?', [
|
||||
serializeJson(replacedPool.items),
|
||||
now(),
|
||||
row.id,
|
||||
])
|
||||
updatedRows += 1
|
||||
}
|
||||
|
||||
const requestRows = await query('SELECT id, items_json, board_items_json FROM template_requests')
|
||||
for (const row of requestRows) {
|
||||
const replacedItems = replaceItemById(parseJson(row.items_json, []), itemId, src, normalizedLabel)
|
||||
const replacedBoardItems = replaceItemById(parseJson(row.board_items_json, []), itemId, src, normalizedLabel)
|
||||
if (!replacedItems.changed && !replacedBoardItems.changed) continue
|
||||
await query('UPDATE template_requests SET items_json = ?, board_items_json = ?, updated_at = ? WHERE id = ?', [
|
||||
serializeJson(replacedItems.items),
|
||||
serializeJson(replacedBoardItems.items),
|
||||
now(),
|
||||
row.id,
|
||||
])
|
||||
updatedRows += 1
|
||||
}
|
||||
|
||||
return { updatedRows }
|
||||
}
|
||||
|
||||
async function listImageAssets() {
|
||||
const rows = await query(
|
||||
'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets ORDER BY created_at DESC'
|
||||
@@ -1501,26 +1609,37 @@ async function clearImageOptimizationJobs({ month } = {}) {
|
||||
const result = await query('DELETE FROM image_optimization_jobs')
|
||||
return Number(result.affectedRows || 0)
|
||||
}
|
||||
async function createTopicItem({ id, topicId, src, label }) {
|
||||
async function createTopicItem({ id, topicId, src, label, tags = [] }) {
|
||||
const createdAt = now()
|
||||
const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM topic_items WHERE topic_id = ?', [topicId])
|
||||
const nextDisplayOrder =
|
||||
minOrderRows[0]?.min_display_order == null ? 0 : Number(minOrderRows[0].min_display_order) - 1
|
||||
await query('INSERT INTO topic_items (id, topic_id, src, label, display_order, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
|
||||
await query('INSERT INTO topic_items (id, topic_id, src, label, tags_json, display_order, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [
|
||||
id,
|
||||
topicId,
|
||||
src,
|
||||
label,
|
||||
serializeJson(normalizeTags(tags)),
|
||||
nextDisplayOrder,
|
||||
createdAt,
|
||||
])
|
||||
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [id])
|
||||
const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [id])
|
||||
return mapTopicItemRow(rows[0])
|
||||
}
|
||||
|
||||
async function updateTopicItemLabel(itemId, label) {
|
||||
await query('UPDATE topic_items SET label = ? WHERE id = ?', [label, itemId])
|
||||
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
|
||||
const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
|
||||
return mapTopicItemRow(rows[0])
|
||||
}
|
||||
|
||||
async function updateTopicItemMeta(itemId, { label, tags = [] }) {
|
||||
await query('UPDATE topic_items SET label = ?, tags_json = ? WHERE id = ?', [
|
||||
label,
|
||||
serializeJson(normalizeTags(tags)),
|
||||
itemId,
|
||||
])
|
||||
const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
|
||||
return mapTopicItemRow(rows[0])
|
||||
}
|
||||
|
||||
@@ -1542,7 +1661,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 = ?
|
||||
@@ -1565,6 +1706,14 @@ async function markCustomItemReplaced({ itemId, replacedByItemId = '', replacedB
|
||||
return findCustomItemById(itemId)
|
||||
}
|
||||
|
||||
async function clearCustomItemReplacement(itemId) {
|
||||
await query(
|
||||
'UPDATE custom_items SET replaced_by_item_id = ?, replaced_by_src = ?, replaced_by_label = ?, replaced_at = 0 WHERE id = ?',
|
||||
['', '', '', itemId]
|
||||
)
|
||||
return findCustomItemById(itemId)
|
||||
}
|
||||
|
||||
async function updateImageAssetLabel(assetId, label) {
|
||||
await query('UPDATE image_assets SET label_override = ? WHERE id = ?', [label, assetId])
|
||||
const rows = await query('SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id = ? LIMIT 1', [assetId])
|
||||
@@ -1621,16 +1770,17 @@ async function updateTopicDisplayOrder(topicIds) {
|
||||
return listTopics()
|
||||
}
|
||||
|
||||
async function createCustomItem({ id, ownerId, src, label }) {
|
||||
async function createCustomItem({ id, ownerId, src, label, tags = [] }) {
|
||||
const createdAt = now()
|
||||
await query('INSERT INTO custom_items (id, owner_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
|
||||
await query('INSERT INTO custom_items (id, owner_id, src, label, tags_json, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
|
||||
id,
|
||||
ownerId,
|
||||
src,
|
||||
label,
|
||||
serializeJson(normalizeTags(tags)),
|
||||
createdAt,
|
||||
])
|
||||
return { id, ownerId, src, label, origin: 'custom', createdAt }
|
||||
return { id, ownerId, src, label, tags: normalizeTags(tags), origin: 'custom', createdAt }
|
||||
}
|
||||
|
||||
async function syncOwnedCustomItemLabels({ ownerId, items }) {
|
||||
@@ -1654,7 +1804,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
|
||||
@@ -1713,7 +1863,7 @@ async function getCustomItemUsageMeta() {
|
||||
}
|
||||
}
|
||||
|
||||
async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMode = 'all' } = {}) {
|
||||
async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMode = 'all', collapseShared = false } = {}) {
|
||||
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||
const normalizedPage = Math.max(Number(page) || 1, 1)
|
||||
const searchText = (queryText || '').trim()
|
||||
@@ -1728,6 +1878,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,
|
||||
@@ -1737,10 +1888,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(
|
||||
`
|
||||
@@ -1749,14 +1900,15 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
gi.topic_id,
|
||||
gi.src,
|
||||
gi.label,
|
||||
gi.tags_json,
|
||||
gi.created_at,
|
||||
tp.name AS topic_name
|
||||
FROM topic_items gi
|
||||
INNER JOIN topics tp ON tp.id = gi.topic_id
|
||||
${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.topic_id LIKE ? OR tp.name LIKE ?' : ''}
|
||||
${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.tags_json LIKE ? OR gi.topic_id LIKE ? OR tp.name LIKE ?' : ''}
|
||||
ORDER BY gi.created_at DESC
|
||||
`,
|
||||
hasQuery ? [search, search, search, search] : []
|
||||
hasQuery ? [search, search, search, search, search] : []
|
||||
),
|
||||
query(
|
||||
`
|
||||
@@ -1837,8 +1989,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
ownerId: '',
|
||||
src: row.src,
|
||||
label: row.label_override || (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음',
|
||||
tags: [],
|
||||
createdAt: Number(row.created_at || 0),
|
||||
ownerName: '관리자 보관 자산',
|
||||
ownerName: '관리자 미사용 이미지',
|
||||
ownerEmail: '',
|
||||
usageCount: 0,
|
||||
linkedTemplates: [],
|
||||
@@ -1856,6 +2009,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
ownerId: '',
|
||||
src: row.src,
|
||||
label: row.label,
|
||||
tags: normalizeTags(parseJson(row.tags_json, [])),
|
||||
createdAt: Number(row.created_at),
|
||||
ownerName: row.topic_name || row.topic_id,
|
||||
ownerEmail: '',
|
||||
@@ -1917,6 +2071,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 || '',
|
||||
@@ -1938,8 +2093,12 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
return item.assetKind === 'avatar'
|
||||
case 'library':
|
||||
return item.sourceType === 'user' || (item.sourceType === 'template' && !item.isAssetLibraryItem)
|
||||
case 'unused':
|
||||
return (item.sourceType === 'user' && ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt)) || item.sourceType === 'asset' || !!item.isAssetLibraryItem
|
||||
case 'unused-user':
|
||||
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedTemplates.length === 0
|
||||
return item.sourceType === 'user' && ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt)
|
||||
case 'replaced-user':
|
||||
return item.sourceType === 'user' && !!item.replacedAt
|
||||
case 'unused-admin':
|
||||
return item.sourceType === 'asset' || !!item.isAssetLibraryItem
|
||||
default:
|
||||
@@ -1948,9 +2107,10 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
})
|
||||
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
|
||||
|
||||
const total = allItems.length
|
||||
const visibleItems = collapseShared ? collapseSharedLibraryItems(allItems) : allItems
|
||||
const total = visibleItems.length
|
||||
const offset = (normalizedPage - 1) * normalizedLimit
|
||||
const pagedItems = allItems.slice(offset, offset + normalizedLimit)
|
||||
const pagedItems = visibleItems.slice(offset, offset + normalizedLimit)
|
||||
|
||||
return {
|
||||
items: pagedItems,
|
||||
@@ -1963,40 +2123,59 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
async function findUnusedCustomItems({ queryText = '' } = {}) {
|
||||
const hasQuery = !!(queryText || '').trim()
|
||||
const search = `%${(queryText || '').trim()}%`
|
||||
const whereClause = hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''
|
||||
const params = hasQuery ? [search, search, search, search] : []
|
||||
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT
|
||||
c.id,
|
||||
c.owner_id,
|
||||
c.src,
|
||||
c.label,
|
||||
c.replaced_by_item_id,
|
||||
c.replaced_by_src,
|
||||
c.replaced_by_label,
|
||||
c.replaced_at,
|
||||
c.created_at,
|
||||
u.nickname,
|
||||
u.email
|
||||
FROM custom_items c
|
||||
INNER JOIN users u ON u.id = c.owner_id
|
||||
${whereClause}
|
||||
ORDER BY c.created_at DESC
|
||||
`,
|
||||
params
|
||||
)
|
||||
const [rows, topicItemRows, usageMeta] = await Promise.all([
|
||||
query(
|
||||
`
|
||||
SELECT
|
||||
c.id,
|
||||
c.owner_id,
|
||||
c.src,
|
||||
c.label,
|
||||
c.tags_json,
|
||||
c.replaced_by_item_id,
|
||||
c.replaced_by_src,
|
||||
c.replaced_by_label,
|
||||
c.replaced_at,
|
||||
c.created_at,
|
||||
u.nickname,
|
||||
u.email
|
||||
FROM custom_items c
|
||||
INNER JOIN users u ON u.id = c.owner_id
|
||||
${hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR c.tags_json LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''}
|
||||
ORDER BY c.created_at DESC
|
||||
`,
|
||||
hasQuery ? [search, search, search, search, search] : []
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT ti.topic_id, tp.name AS topic_name, ti.src
|
||||
FROM topic_items ti
|
||||
LEFT JOIN topics tp ON tp.id = ti.topic_id
|
||||
`
|
||||
),
|
||||
getCustomItemUsageMeta(),
|
||||
])
|
||||
|
||||
const templateLinkedBySrc = new Map()
|
||||
topicItemRows.forEach((row) => {
|
||||
if (!row?.src) return
|
||||
if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map())
|
||||
templateLinkedBySrc.get(row.src).set(row.topic_id, {
|
||||
id: row.topic_id,
|
||||
name: row.topic_name || row.topic_id,
|
||||
})
|
||||
})
|
||||
|
||||
const { usageMap } = await getCustomItemUsageMeta()
|
||||
return rows
|
||||
.map((row) => ({
|
||||
...mapCustomItemRow(row),
|
||||
ownerName: row.nickname || row.email,
|
||||
ownerEmail: row.email,
|
||||
usageCount: usageMap.get(row.id) || 0,
|
||||
usageCount: usageMeta.usageMap.get(row.id) || 0,
|
||||
linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
|
||||
}))
|
||||
.filter((item) => item.usageCount === 0)
|
||||
.filter((item) => ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt))
|
||||
}
|
||||
|
||||
async function getFavoriteStatsForTierListIds(tierListIds, userId = '') {
|
||||
@@ -2921,7 +3100,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})
|
||||
`,
|
||||
@@ -3073,17 +3252,21 @@ module.exports = {
|
||||
listReferencedUploadSources,
|
||||
listReferencedUploadUsage,
|
||||
replaceUploadSourceReferences,
|
||||
updateCustomItemDisplayReferences,
|
||||
clearImageOptimizationJobs,
|
||||
getImageAssetStats,
|
||||
cleanupMissingUploadReferences,
|
||||
createTopicItem,
|
||||
updateTopicItemLabel,
|
||||
updateTopicItemMeta,
|
||||
updateTopicItemDisplayOrder,
|
||||
countTierListsUsingTopicItem,
|
||||
deleteTopicItem,
|
||||
deleteTopic,
|
||||
updateTopicDisplayOrder,
|
||||
updateCustomItemLabel,
|
||||
updateCustomItemMeta,
|
||||
clearCustomItemReplacement,
|
||||
markCustomItemReplaced,
|
||||
updateImageAssetLabel,
|
||||
createCustomItem,
|
||||
|
||||
@@ -20,9 +20,11 @@ const {
|
||||
updateTopicThumbnail,
|
||||
createTopicItem,
|
||||
updateTopicItemLabel,
|
||||
updateTopicItemMeta,
|
||||
updateTopicItemDisplayOrder,
|
||||
countTierListsUsingTopicItem,
|
||||
updateCustomItemLabel,
|
||||
updateCustomItemMeta,
|
||||
updateImageAssetLabel,
|
||||
deleteTopicItem,
|
||||
deleteTopic,
|
||||
@@ -54,7 +56,10 @@ const {
|
||||
listRecentImageOptimizationJobs,
|
||||
clearImageOptimizationJobs,
|
||||
cleanupMissingUploadReferences,
|
||||
listReferencedUploadSources,
|
||||
replaceUploadSourceReferences,
|
||||
updateCustomItemDisplayReferences,
|
||||
clearCustomItemReplacement,
|
||||
} = require('../db')
|
||||
const { requireAdmin } = require('../middleware/auth')
|
||||
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
|
||||
@@ -96,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
|
||||
@@ -124,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(''),
|
||||
})
|
||||
@@ -133,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' })
|
||||
@@ -151,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)
|
||||
@@ -166,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)
|
||||
@@ -306,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 })
|
||||
})
|
||||
@@ -329,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)
|
||||
@@ -342,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 })
|
||||
})
|
||||
@@ -357,8 +394,12 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
page: z.coerce.number().int().min(1).optional().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
||||
collapseShared: z
|
||||
.union([z.string(), z.boolean(), z.number()])
|
||||
.optional()
|
||||
.transform((value) => value === true || value === 1 || value === '1' || value === 'true'),
|
||||
filter: z
|
||||
.enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused-user', 'unused-admin'])
|
||||
.enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused', 'unused-user', 'replaced-user', 'unused-admin'])
|
||||
.optional()
|
||||
.default('library'),
|
||||
})
|
||||
@@ -370,6 +411,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
page: parsed.data.page,
|
||||
limit: parsed.data.limit,
|
||||
filterMode: parsed.data.filter,
|
||||
collapseShared: parsed.data.collapseShared,
|
||||
})
|
||||
res.json(result)
|
||||
})
|
||||
@@ -544,12 +586,19 @@ async function removeCustomItemFiles(items) {
|
||||
)
|
||||
}
|
||||
|
||||
async function removeUnreferencedCustomItemFiles(items) {
|
||||
const referencedSrcs = new Set(await listReferencedUploadSources())
|
||||
const removableItems = (items || []).filter((item) => item?.src && !referencedSrcs.has(item.src))
|
||||
await removeCustomItemFiles(removableItems)
|
||||
}
|
||||
|
||||
async function promoteLibraryItemToTemplateItem({ item, templateId }) {
|
||||
return createTopicItem({
|
||||
id: nanoid(),
|
||||
topicId: templateId,
|
||||
src: item.src || '',
|
||||
label: item.label,
|
||||
tags: item.tags || [],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -567,6 +616,7 @@ async function findLibraryItemForReplacement(itemId, sourceType = '') {
|
||||
sourceType: 'asset',
|
||||
src: asset.src || '',
|
||||
label: asset.labelOverride || buildItemLabelFromSrc(asset.src),
|
||||
tags: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,6 +628,7 @@ async function findLibraryItemForReplacement(itemId, sourceType = '') {
|
||||
sourceType: 'template',
|
||||
src: item.src || '',
|
||||
label: item.label || buildItemLabelFromSrc(item.src),
|
||||
tags: item.tags || [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +639,7 @@ async function findLibraryItemForReplacement(itemId, sourceType = '') {
|
||||
sourceType: 'user',
|
||||
src: customItem.src || '',
|
||||
label: customItem.label || buildItemLabelFromSrc(customItem.src),
|
||||
tags: customItem.tags || [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,6 +650,7 @@ async function findLibraryItemForReplacement(itemId, sourceType = '') {
|
||||
sourceType: 'template',
|
||||
src: templateItem.src || '',
|
||||
label: templateItem.label || buildItemLabelFromSrc(templateItem.src),
|
||||
tags: templateItem.tags || [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,13 +827,14 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
return res.json({ ok: true, sourceType: 'template' })
|
||||
}
|
||||
|
||||
const canDeleteReplacedUserItem = target.sourceType === 'user' && !!target.replacedAt
|
||||
if (!target.canDelete) return res.status(409).json({ error: 'item_locked' })
|
||||
if (target.linkedTemplates.length > 0) return res.status(409).json({ error: 'item_linked' })
|
||||
if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
|
||||
if (!canDeleteReplacedUserItem && target.linkedTemplates.length > 0) return res.status(409).json({ error: 'item_linked' })
|
||||
if (!canDeleteReplacedUserItem && target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
|
||||
|
||||
const items = await findCustomItemsByIds([target.id])
|
||||
await deleteCustomItems([target.id])
|
||||
await removeCustomItemFiles(items)
|
||||
await removeUnreferencedCustomItemFiles(items)
|
||||
res.json({ ok: true, sourceType: 'user' })
|
||||
})
|
||||
|
||||
@@ -813,6 +867,27 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
|
||||
res.json({ item })
|
||||
})
|
||||
|
||||
router.post('/custom-items/:itemId/unlink-template', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
topicId: z.string().trim().min(1),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const template = await findTopicById(parsed.data.topicId)
|
||||
if (!template) return res.status(404).json({ error: 'topic_not_found' })
|
||||
|
||||
const sourceItem = await findLibraryItemForReplacement(req.params.itemId)
|
||||
if (!sourceItem?.src) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const templateItems = await listTopicItems(template.id)
|
||||
const matchedItems = templateItems.filter((item) => item?.src === sourceItem.src)
|
||||
if (!matchedItems.length) return res.status(404).json({ error: 'linked_template_item_not_found' })
|
||||
|
||||
await Promise.all(matchedItems.map((item) => deleteTopicItem(item.id)))
|
||||
res.json({ ok: true, deletedCount: matchedItems.length, topicId: template.id, src: sourceItem.src })
|
||||
})
|
||||
|
||||
router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
targetItemId: z.string().trim().min(1),
|
||||
@@ -835,6 +910,12 @@ router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => {
|
||||
fromSrc: sourceItem.src,
|
||||
toSrc: targetItem.src,
|
||||
toLabel: targetItem.label || '',
|
||||
updateCustomItemsBySrc: false,
|
||||
})
|
||||
const displayResult = await updateCustomItemDisplayReferences({
|
||||
itemId: sourceItem.id,
|
||||
src: targetItem.src,
|
||||
label: targetItem.label || '',
|
||||
})
|
||||
await markCustomItemReplaced({
|
||||
itemId: sourceItem.id,
|
||||
@@ -845,12 +926,31 @@ router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => {
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
updatedRows: result.updatedRows || 0,
|
||||
updatedRows: (result.updatedRows || 0) + (displayResult.updatedRows || 0),
|
||||
sourceItem,
|
||||
targetItem,
|
||||
})
|
||||
})
|
||||
|
||||
router.post('/custom-items/:itemId/restore', requireAdmin, async (req, res) => {
|
||||
const sourceItem = await findCustomItemById(req.params.itemId)
|
||||
if (!sourceItem?.id) return res.status(404).json({ error: 'not_found' })
|
||||
if (!sourceItem.replacedAt) return res.status(409).json({ error: 'not_replaced' })
|
||||
|
||||
const restored = await updateCustomItemDisplayReferences({
|
||||
itemId: sourceItem.id,
|
||||
src: sourceItem.src,
|
||||
label: sourceItem.label || '',
|
||||
})
|
||||
await clearCustomItemReplacement(sourceItem.id)
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
restoredRows: restored.updatedRows || 0,
|
||||
item: await findCustomItemById(sourceItem.id),
|
||||
})
|
||||
})
|
||||
|
||||
router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
topicId: z.string().min(1),
|
||||
@@ -1105,11 +1205,27 @@ router.delete('/custom-items', requireAdmin, async (req, res) => {
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const items = await findUnusedCustomItems({ queryText: parsed.data.q })
|
||||
const ids = items.map((item) => item.id)
|
||||
await deleteCustomItems(ids)
|
||||
await removeCustomItemFiles(items)
|
||||
res.json({ ok: true, deletedCount: ids.length })
|
||||
const result = await listCustomItems({
|
||||
queryText: parsed.data.q,
|
||||
page: 1,
|
||||
limit: 10000,
|
||||
filterMode: 'unused',
|
||||
})
|
||||
const customItems = result.items.filter((item) => item?.sourceType === 'user')
|
||||
const assetItems = result.items.filter((item) => item?.sourceType === 'asset' || item?.isAssetLibraryItem)
|
||||
const customItemIds = customItems.map((item) => item.id)
|
||||
const assetIds = assetItems
|
||||
.map((item) => String(item.id || ''))
|
||||
.filter((id) => id.startsWith('asset:'))
|
||||
.map((id) => id.slice('asset:'.length))
|
||||
|
||||
await deleteCustomItems(customItemIds)
|
||||
await removeUnreferencedCustomItemFiles(customItems)
|
||||
|
||||
const deletedAssets = await deleteImageAssets(assetIds)
|
||||
await removeImageAssetFiles(deletedAssets)
|
||||
|
||||
res.json({ ok: true, deletedCount: customItemIds.length + deletedAssets.length })
|
||||
})
|
||||
|
||||
router.get('/users', requireAdmin, async (req, res) => {
|
||||
|
||||
@@ -1,5 +1,85 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-06 v1.4.101
|
||||
- 실제 문제는 `editorCanvas` 자체보다 그 아래 `workspaceBody--localRail`의 하단 패딩까지 문서가 더 스크롤되는 구간에서 발생했다. 따라서 오른쪽 sticky 카드만 보정하는 것보다, 오른쪽 컬럼을 별도 래퍼로 감싸고 workspace 하단 패딩까지 고려한 여유 공간을 래퍼에 주는 편이 더 정확하다고 판단했다.
|
||||
- 아이템 풀은 내부 스크롤이 필요하지만, 오른쪽 패널 안에서 스크롤바가 항상 보이면 작업 화면이 좁고 복잡해 보일 수 있으므로 스크롤 동작만 유지하고 스크롤바 시각 요소는 숨기는 편이 낫다고 정리했다.
|
||||
|
||||
## 2026-04-06 v1.4.100
|
||||
- 오른쪽 아이템 패널은 sticky로 동작하지만 부모 컨테이너의 끝에 닿으면 sticky 제한 때문에 마지막 스크롤 위치에서 위쪽 여백이 무너져 보일 수 있다. 그래서 편집 캔버스 자체에 작은 하단 여유 공간을 두어 마지막 위치에서도 패널 주변 여백이 유지되도록 하는 편이 낫다고 판단했다.
|
||||
|
||||
## 2026-04-06 v1.4.99
|
||||
- 단축키는 실제 키 위치 기준으로 기대하는 경우가 많으므로, 한글 입력 상태에서 S 자리 키가 `ㄴ`으로 들어와도 같은 “아이템 검색” 동작을 실행하고, F 자리 키가 `ㄹ`로 들어와도 같은 “전체 화면” 동작을 실행하는 편이 한국어 사용자에게 자연스럽다고 정리했다.
|
||||
|
||||
## 2026-04-06 v1.4.98
|
||||
- 방송/편집처럼 화면을 자주 정리해야 하는 사용 흐름에서는 사이드 패널과 전체 화면 전환을 마우스로만 조작하면 반복 비용이 커진다. 그래서 `[`/`]`/`F`/`S`처럼 한 손으로 누르기 쉬운 단축키를 두되, 입력창에서는 단축키를 무시해 실제 텍스트 입력을 방해하지 않는 편이 맞다고 정리했다.
|
||||
- `S`는 전역 템플릿 검색이 아니라 티어표 편집 화면의 아이템 검색으로 연결해야 하므로, 앱 셸이 편집 화면에 커스텀 이벤트를 보내고 편집 화면이 자신의 아이템 검색창에 포커스를 주는 방식으로 분리했다.
|
||||
|
||||
## 2026-04-06 v1.4.97
|
||||
- 티어표 편집기의 오른쪽 아이템 패널은 페이지 내부 위치가 헤더, 제목, 스크롤 상태에 따라 달라지므로 `100dvh - 고정값` 방식으로는 왼쪽 레일처럼 하단이 자연스럽게 맞지 않을 수 있다. 실제 패널의 화면 내 시작 위치를 측정해 남은 높이를 계산하는 편이 더 안정적이라고 정리했다.
|
||||
|
||||
## 2026-04-06 v1.4.96
|
||||
- 템플릿 제목을 버튼화하면 접근성은 좋아지지만, 포커스가 남은 상태의 `Space` 입력이 브라우저 스크롤과 섞이면 작업 화면을 갑자기 밀어낼 수 있다. 따라서 제목 버튼에서는 `Space` 기본 스크롤을 막고 의도한 본문 이동만 실행하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-06 v1.4.95
|
||||
- 티어표 편집 중에는 공통 헤더보다 보드와 아이템 풀이 더 중요한 작업 기준점이므로, 템플릿 제목을 본문 위치로 빠르게 이동하는 가벼운 컨트롤로 활용하는 편이 좋다고 정리했다. 별도 버튼을 추가하기보다 기존 제목 클릭 동작으로 두어 화면 복잡도를 늘리지 않는 쪽을 택했다.
|
||||
|
||||
## 2026-04-06 v1.4.94
|
||||
- 아이템 수가 많을 때 오른쪽 풀 때문에 페이지 전체가 길어지면 왼쪽 티어표까지 함께 움직여 방송/녹화 환경에서 기준 화면이 흔들릴 수 있다. 그래서 데스크톱에서는 오른쪽 사이드의 실제 화면 시작 위치를 감안해 높이를 제한하되, 제목과 검색창은 유지하고 아이템 그리드만 스크롤되게 하는 편이 더 적절하다고 정리했다. 모바일에서는 기존처럼 문서 흐름을 유지한다.
|
||||
|
||||
## 2026-04-06 v1.4.93
|
||||
- 티어표 편집기의 커스텀 이미지 추가 영역 아래는 아이템 수가 적을 때 비어 보이기 쉬우므로, 이 공간에는 큰 기능보다 방해되지 않는 작은 작업 팁을 두는 편이 자연스럽다고 정리했다. 특히 우클릭 복제, 미사용 아이템 처리, 브라우저 확대/축소처럼 초반 시행착오를 줄여 주는 내용이 효과적이라고 판단했다.
|
||||
|
||||
## 2026-04-06 v1.4.92
|
||||
- 모바일 왼쪽 레일은 사용자 카드, 검색, 메뉴가 세로로 붙는 구조라 기본 `gap`이 빠지면 브라우저별 렌더링 차이에 따라 훨씬 답답하게 보일 수 있으므로, 이 영역 간격은 명시적으로 주는 편이 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-06 v1.4.91
|
||||
- 관리자 화면 모달이 많아질수록 `Esc` 동작이 일부 모달에서만 먹으면 예측 가능성이 떨어지므로, 열려 있는 공통 모달은 모두 `Esc = 취소` 규칙으로 맞추는 편이 더 자연스럽다고 정리했다.
|
||||
- 왼쪽 레일 사용자 카드의 두 번째 줄은 로그인된 상태에선 이메일이라 말줄임이 맞지만, 로그인 전/확인 중 메시지는 설명 성격이 강하므로 같은 `nowrap` 규칙을 쓰면 가로 스크롤을 유발할 수 있다. 그래서 이메일과 설명 문구의 줄 처리 정책을 분리하는 쪽이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-06 v1.4.90
|
||||
- `templateSettingsCard__actions`는 카드 안에서 버튼이 줄바꿈될 수 있어야 하지만, 공통 버튼 스타일의 높이 100% 규칙까지 그대로 받으면 줄바꿈된 행이 비정상적으로 늘어날 수 있으므로 이 영역의 버튼만 높이를 자동으로 되돌리는 편이 맞다고 정리했다.
|
||||
- 또 템플릿 기본 아이템 삭제는 “기존 저장 티어표에는 영향 없음”이라는 정책 설명이 중요하므로, 브라우저 기본 확인창보다 관리자 공통 모달로 통일해 같은 톤과 문구 체계 안에서 보여주는 쪽이 더 낫다고 판단했다.
|
||||
|
||||
## 2026-04-06 v1.4.89
|
||||
- 템플릿 화면에서 이름/slug 저장과 아이템 태그 일괄 추가는 성격이 다르므로, 기존처럼 하나의 `메타` 개념으로 묶기보다 `이름/주소 저장`과 `공통 태그 추가`를 분리해 보여주는 편이 운영자가 이해하기 쉽다고 정리했다.
|
||||
- 또 `templateSettingsCard`는 버튼 문구가 비교적 길고 썸네일/폼/토글이 함께 들어가는 카드라서, 좁은 폭에서 각 블록의 최소 너비를 풀어 주지 않으면 카드 밖으로 밀려나기 쉬우므로 입력 필드와 액션 버튼 모두 카드 내부에서 줄어들고 줄바꿈되게 하는 쪽이 맞다고 판단했다.
|
||||
- 템플릿 기본 아이템 카드도 작은 썸네일 위에 버튼 두 개를 계속 노출하면 카드 높이가 불필요하게 커지고 반복 조작 밀도가 떨어지므로, 저장은 입력 후 `Enter`, 삭제는 우상단 `X`처럼 더 직접적인 마이크로 인터랙션으로 옮기는 편이 낫다고 정리했다.
|
||||
|
||||
## 2026-04-06 v1.4.88
|
||||
- 같은 이미지를 사용자 항목, 템플릿 항목, 관리자 자산으로 각각 따로 카드에 늘어놓으면 운영자가 실제로 보고 싶은 “이미지 단위 상태”보다 내부 저장 단위가 더 크게 드러나므로, 관리자 목록과 검색은 기본적으로 같은 `src`를 하나로 묶어 보여주는 편이 더 자연스럽다고 정리했다.
|
||||
- 아이템 모달의 연결 템플릿 배지는 단순히 해당 템플릿 화면으로 점프하는 것보다, 그 자리에서 `이 템플릿에서 제외`를 바로 수행하는 액션이 훨씬 직접적이므로 배지에 제거 버튼을 붙이는 쪽이 더 낫다고 판단했다.
|
||||
- 한글 태그 입력은 IME 조합 중 `Enter`가 중간 문자열까지 함께 커밋될 수 있으므로, 배지형 태그 입력에서는 조합 상태를 명시적으로 감지해 완성된 문자열만 태그로 추가하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-06 v1.4.87
|
||||
- 템플릿 태그는 이름/slug 검색과 역할이 겹치고, 운영자가 실제로 원하는 것은 “템플릿 찾기”보다 “아이템 묶음 분류”에 가까웠으므로 템플릿 화면에서 직접 노출하지 않는 편이 더 맞다고 정리했다.
|
||||
- 태그 입력도 카드 곳곳에 흩어져 있으면 메인 작업인 업로드와 이름 정리가 묻히기 쉬우므로, 태그는 관리자 아이템 모달에서만 배지형으로 다루고 템플릿 화면 본문은 가볍게 유지하는 쪽을 택했다.
|
||||
- 기존 템플릿을 통째로 가져오는 기능만으로는 운영자가 “조건에 맞는 일부 아이템만” 빠르게 합치는 흐름이 부족하므로, 이름·파일명·태그 기반 개별 아이템 검색 후 초안 목록으로 담아두는 보조 모달을 추가하는 편이 더 실용적이라고 판단했다.
|
||||
|
||||
## 2026-04-06 v1.4.86
|
||||
- 내부 운영용 분류는 공개 노출용 필드와 분리하는 편이 맞다고 보고, 태그는 템플릿과 아이템의 관리자 전용 메타로만 저장하고 검색에 활용하는 방향으로 정리했다.
|
||||
- 기존 분기 템플릿이나 요청 아이템을 다시 재조합해 새 템플릿을 만드는 흐름은 완전히 별도 마법 기능보다, 이미 있는 `초안 목록`에 다른 출처의 아이템을 합류시키고 `제외` 버튼으로 정리하는 편이 훨씬 예측 가능하다고 판단했다.
|
||||
- 사용자가 잘못 올린 이미지는 파일 업로드 자체를 되돌리는 것보다 “현재 티어표에서 이 커스텀 아이템을 제거”하는 조작이 먼저 필요하다고 보고, 저장 전 blob 이미지와 저장 후 커스텀 아이템 모두 같은 제거 UX로 다루는 쪽을 채택했다.
|
||||
|
||||
## 2026-04-06 v1.4.85
|
||||
- 썸네일과 프로필 이미지는 이미 별도 필터로 분리돼 있고, 템플릿 아이템/사용자 아이템도 각각 구분되고 있으므로, 그 어디에도 속하지 않는 관리자 업로드 자산은 운영 의미상 `보관 자산`보다 `미사용 이미지`로 보는 편이 더 직관적이라고 정리했다.
|
||||
- 또 관리자 아이템 화면에서 삭제 버튼이 필터 조건에 따라 갑자기 비활성으로만 보이면 흐름을 이해하기 어려우므로, 평소에는 `미사용 이미지 확인`, 해당 화면 안에서만 `미사용 이미지 일괄 삭제`로 바뀌는 점진적 동작이 더 낫다고 판단했다.
|
||||
|
||||
## 2026-04-06 v1.4.84
|
||||
- 대체된 원본 아이템은 운영상 이미 교체 완료된 이력이므로, 현재 저장본에서 같은 item id 사용량이 남아 있더라도 개별 삭제를 막아 두는 쪽보다 명시적으로 정리 가능하게 두는 편이 더 맞다고 판단했다.
|
||||
- 또 관리자 목록에서 보이는 `미사용` 기준과 실제 일괄 삭제 API 기준이 다르면 운영자가 버튼 상태를 신뢰하기 어려워지므로, 둘은 반드시 같은 조건으로 맞추기로 정리했다.
|
||||
- 마지막으로 업로드 파일은 하나의 레코드가 없어졌다고 곧바로 지우기보다, 전체 참조를 다시 확인한 뒤 정말 고아 파일일 때만 삭제하는 방식이 더 안전하다고 판단했다.
|
||||
|
||||
## 2026-04-06 v1.4.83
|
||||
- 대체 이력이 쌓이기 시작하면 일반 사용자 업로드 목록 안에 섞여 보이는 것보다, 운영자가 후속 검수와 정리를 위해 `대체된 항목만` 따로 모아 보는 필터가 있는 편이 더 실용적이라고 판단했다.
|
||||
|
||||
## 2026-04-06 v1.4.82
|
||||
- 원본 A를 보존하더라도 실제 저장본이 계속 A의 item id를 쓰는 구조라면, 대체/복구는 `custom_items` 레코드 자체를 바꾸는 방식보다 “그 item id가 참조되는 보드 표시 데이터(src/label)만 바꾸는 방식”이 더 자연스럽다고 판단했다.
|
||||
- 또 대체된 원본은 집계상 사용량이 남더라도 운영 의미상 이미 정리 가능한 이력이므로, `미사용 아이템 일괄 삭제`에서 제외하면 오히려 계속 쌓이게 된다. 그래서 대체 이력 아이템은 예외적으로 미사용 정리 대상으로 포함하는 쪽이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-06 v1.4.81
|
||||
- 원본 보존을 하더라도 실제 `custom_items.src`와 `label`까지 대체 대상 값으로 덮어쓰면 관리자 입장에서는 “원본을 남겼다”기보다 “같은 새 이미지가 두 번 보이는 상태”로 읽히므로, 원본 아이템 레코드는 실제 이미지/라벨을 그대로 유지하고 참조 이동 대상에서만 제외하는 편이 맞다고 정리했다.
|
||||
- 즉 대체 이력 메타(`어떤 아이템으로 대체됐는지`)와 원본 본문 데이터(`원래 A 이미지/이름`)는 분리해서 보존해야, 이후 복구나 검수 기준으로 의미가 살아난다고 판단했다.
|
||||
|
||||
## 2026-04-06 v1.4.80
|
||||
- 이미지 대체 직후 원본 사용자 아이템을 완전히 지워버리면 관리자 입장에서는 “왜 바꿨는지”, “나중에 정리해도 되는지”를 다시 확인할 근거가 사라지므로, 참조 이동과 원본 보존을 분리하는 편이 운영 흐름에 더 맞다고 판단했다.
|
||||
- 다만 대체 완료된 원본까지 별도 보호 대상으로 빼면 라이브러리 정리가 끝없이 쌓일 수 있으므로, 원본은 `대체됨` 상태로 계속 보이게 하되 이미 미사용인 이상 `미사용 아이템 일괄 삭제`와 개별 삭제로 언제든 정리할 수 있게 두는 쪽이 균형이 맞는다고 정리했다.
|
||||
|
||||
108
docs/update.md
108
docs/update.md
@@ -1,5 +1,113 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-06 v1.4.101
|
||||
- `workspaceBody--localRail`의 하단 패딩 구간까지 스크롤했을 때 오른쪽 아이템 카드가 sticky 기준 영역을 벗어나 상단에 붙어 보이던 문제를 보정했다. 오른쪽 sticky 컬럼을 래퍼로 감싸고 하단 여유 공간을 명시해 마지막 스크롤 위치에서도 여백이 유지되도록 했다.
|
||||
- 티어표 편집 화면의 아이템 그리드 내부 스크롤은 유지하되, 시각적인 스크롤바는 보이지 않도록 정리했다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.100
|
||||
- 티어표 편집 화면을 가장 아래까지 스크롤했을 때 오른쪽 아이템 카드가 부모 컨테이너 끝에 걸리며 상단 여백이 무너져 보일 수 있어, 편집 캔버스 하단에 sticky 여백용 패딩을 추가했다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.99
|
||||
- 티어표 편집 화면의 아이템 검색/전체 화면 단축키를 한글 입력 상태에서도 쓸 수 있게 보정했다. `S`뿐 아니라 같은 물리 키에서 들어오는 `ㄴ`도 아이템 검색 포커스로 처리하고, `F` 자리에서 들어오는 `ㄹ`도 전체 화면 토글로 처리한다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.98
|
||||
- 전역 단축키를 추가했다. `[`는 왼쪽 사이드 토글, `]`는 오른쪽 사이드 토글, `F`는 전체 화면 토글, `S`는 티어표 편집 화면의 아이템 검색창 포커스로 동작한다.
|
||||
- 입력창, textarea, select, contenteditable 영역에서는 단축키가 동작하지 않도록 막아 검색이나 이름 입력을 방해하지 않게 했다.
|
||||
- 가이드 보기 마지막 페이지에 단축키 안내를 추가하고, 모달은 `Esc`로 닫을 수 있다는 안내도 함께 정리했다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.97
|
||||
- 티어표 편집 화면의 오른쪽 아이템 패널 높이를 고정 숫자 대신 실제 화면 내 시작 위치 기준으로 계산하도록 바꿨다. 공통 헤더/제목 영역/스크롤 위치가 달라져도 아이템 풀의 하단이 viewport 안에 더 자연스럽게 맞도록 보정했다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.96
|
||||
- 티어표 편집 화면의 템플릿 제목에 포커스가 남은 상태에서 `Space`를 누르면 브라우저 기본 스크롤이 섞일 수 있어, 제목 버튼의 `Space` 기본 동작을 막고 본문 이동만 실행되도록 보정했다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.95
|
||||
- 티어표 편집 화면의 템플릿 제목을 클릭하면 `workspaceBody`가 화면 최상단에 오도록 부드럽게 스크롤되게 했다. 작업 중 공통 헤더를 화면 밖으로 밀어내고 본문 중심으로 볼 수 있다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.94
|
||||
- 티어표 편집 화면에서 아이템이 많을 때 오른쪽 아이템 사이드가 문서 높이를 밀어 왼쪽 티어표까지 함께 움직이던 흐름을 줄였다. 데스크톱에서는 오른쪽 사이드의 실제 화면 시작 위치를 감안해 높이를 제한하고, 아이템 그리드만 내부 스크롤되게 해 검색창은 위에 유지하면서 필요한 아이템을 찾아 가져올 수 있게 했다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.93
|
||||
- 티어표 편집 화면의 커스텀 이미지 추가 영역 아래에는 작은 `작업 팁` 안내를 추가했다. 복수 사용, 미사용 아이템 미리보기/저장 제외, 브라우저 확대/축소 활용 같은 자주 묻는 흐름을 바로 확인할 수 있다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.92
|
||||
- 모바일 왼쪽 사이드 메뉴(`leftRail__mobileMenu`)에 `gap`이 빠져 일부 브라우저에서 사용자 카드와 검색창/메뉴가 더 붙어 보일 수 있던 간격을 다시 정리했다.
|
||||
|
||||
## 2026-04-06 v1.4.91
|
||||
- 관리자 화면의 각종 모달은 이제 `Esc` 키를 누르면 현재 열려 있는 모달이 바로 닫히도록 정리했다. 브라우저 기본 동작 대신 공통 `취소` 흐름으로 맞췄다.
|
||||
- 왼쪽 사이드에서 일부 브라우저 환경에 가로 스크롤이 생기던 문제를 보정했다. 사용자 카드와 검색창에 `min-width: 0`을 더 명확히 주고, 이메일은 계속 말줄임 처리하되 로그인 전 안내 문구처럼 긴 설명은 자연스럽게 줄바꿈되도록 분리했다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.90
|
||||
- `templateSettingsCard__actions` 내부 버튼은 좁은 화면에서 과하게 세로로 늘어나지 않도록 버튼 높이를 자동으로 풀고, 카드 너비 안에서 자연스럽게 줄바꿈되도록 다시 보정했다.
|
||||
- 템플릿 기본 아이템 삭제 확인은 브라우저 기본 `confirm` 대신 관리자 공통 모달로 바꿨다. 저장된 다른 티어표에는 영향을 주지 않고, 이후 새로 만드는 티어표에서만 제외된다는 안내도 모달 안에서 함께 보여준다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.89
|
||||
- 템플릿 관리의 `템플릿 메타 저장` 버튼은 실제 역할에 맞춰 `이름/주소 저장`으로 바꿨다. 이제 이 버튼은 템플릿 이름과 slug 저장만 담당한다.
|
||||
- 대신 현재 템플릿의 기본 아이템 전체에 같은 태그를 한 번에 추가하는 `기본 아이템 공통 태그` 기능을 추가했다. 배지형 입력으로 태그를 넣고 적용하면, 같은 태그는 중복 없이 각 아이템에 합쳐 저장된다.
|
||||
- 운영 문구도 `메타`보다 실제 의미가 분명한 `태그` 기준으로 맞췄다.
|
||||
- `adminCard templateSettingsCard`는 화면이 좁아질 때 내부 액션 버튼과 입력 필드가 카드 밖으로 밀려나지 않도록 최소 너비와 버튼 줄바꿈 규칙을 보정했다.
|
||||
- 템플릿 기본 아이템 카드(`thumbCard`)는 `이름 저장`/`아이템 삭제` 버튼을 걷어내고, 이름 입력 후 `Enter`로 바로 저장되게 바꿨다. 삭제는 티어표 편집기처럼 우상단 `X` 버튼으로 옮겨 카드 높이를 더 작게 유지한다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.88
|
||||
- 관리자 아이템 목록과 개별 아이템 검색에서는 같은 이미지 `src`를 공유하는 항목을 하나로 묶어 보여주도록 조정했다. 사용자 아이템, 템플릿 아이템, 관리자 자산이 같은 이미지를 가리키는 경우 카드가 반복해서 보이던 문제를 줄였다.
|
||||
- 아이템 모달의 `이 이미지를 사용하는 템플릿` 배지는 더 이상 단순 이동 버튼이 아니고, 각 배지의 `X` 버튼으로 해당 템플릿에서 이미지를 바로 제외할 수 있게 바꿨다.
|
||||
- 아이템 모달의 `새 템플릿 만들기` 버튼은 현재 흐름에선 분기만 늘린다고 보고 숨겼다. 이제 아이템 추가는 이미 선택한 템플릿 기준으로만 진행된다.
|
||||
- 배지형 태그 입력은 한글 IME 조합 중 `Enter`를 눌렀을 때 초성/중간 문자열이 중복 등록되던 문제를 막기 위해 조합 중 입력을 따로 감지하도록 보강했다.
|
||||
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.87
|
||||
- 템플릿 설정 화면에서는 더 이상 템플릿 태그를 직접 입력하지 않도록 정리했다. 템플릿 자체는 이름과 slug로만 관리하고, 운영용 태그는 아이템 모달 안에서만 다루는 흐름으로 단순화했다.
|
||||
- 관리자 아이템 모달의 태그 입력은 쉼표 문자열 대신 배지형 입력으로 바꿨다. 태그를 입력하고 `Enter`를 누르면 아래에 배지로 붙고, 각 배지의 `X` 버튼으로 개별 제거할 수 있다.
|
||||
- 템플릿 관리 액션 영역은 좁은 화면에서도 버튼이 자연스럽게 오른쪽 정렬로 줄바꿈되도록 손봤고, `개별 아이템 검색` 모달을 추가해 이름·파일명·태그로 원하는 아이템만 직접 찾아 현재 템플릿 초안에 넣을 수 있게 했다.
|
||||
- 템플릿 화면의 기본 아이템 목록과 업로드 초안 카드에서는 태그 입력창을 제거해, 메인 작업 흐름이 이름 편집과 아이템 추가에 더 집중되도록 정리했다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.86
|
||||
- 관리자 전용 내부 태그를 템플릿과 아이템 메타에 추가했다. 이제 템플릿 설정과 기본 아이템/관리자 아이템 모달에서 태그를 쉼표 기준으로 저장할 수 있고, 관리자 검색에서도 이름·파일명뿐 아니라 태그까지 함께 조회할 수 있다.
|
||||
- 템플릿 관리 화면에 `기존 템플릿 가져오기`를 추가했다. 현재 선택한 템플릿에 다른 템플릿들의 기본 아이템을 초안으로 모아 온 뒤, 필요 없는 항목은 `제외`하고 저장할 수 있다. 여러 분기 템플릿을 합쳐 연간 통합 템플릿을 만드는 흐름을 염두에 둔 기능이다.
|
||||
- 에디터에서 사용자가 잘못 올린 커스텀 이미지는 이제 현재 티어표 상태에서 바로 제거할 수 있다. 풀 목록과 보드 셀에 `삭제` 버튼을 추가했고, 우클릭 메뉴에서도 같은 동작을 열어 저장 전 임시 이미지와 저장 후 커스텀 아이템을 모두 현재 작업에서 빼낼 수 있게 했다.
|
||||
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.85
|
||||
- 관리자 아이템 관리의 `미사용 이미지` 범위를 다시 정리해, 사용자 업로드 미사용 항목뿐 아니라 현재 어디에도 연결되지 않은 관리자 자산(`asset`)도 같은 미사용 이미지 목록에서 함께 보이도록 통합했다.
|
||||
- 그래서 이제 관리자 업로드 이미지라도 템플릿 아이템, 사용자 아이템, 썸네일, 프로필 등 어느 경로에도 연결되지 않으면 `미사용 이미지`로 보고 일괄 삭제할 수 있다.
|
||||
- 필터 UI도 함께 다듬어, 다른 필터를 보고 있을 때는 위험한 삭제 버튼 대신 `미사용 이미지 확인` 버튼을 보여주고, 그 화면에 들어왔을 때만 `미사용 이미지 일괄 삭제` 버튼이 나타나도록 바꿨다.
|
||||
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.84
|
||||
- 대체된 사용자 업로드 아이템은 이미 다른 이미지로 참조가 옮겨진 상태라면, 관리자 개별 삭제에서 더 이상 `삭제 실패`로 막히지 않도록 삭제 조건을 보정했다.
|
||||
- `미사용 아이템` 필터 화면과 `미사용 아이템 일괄 삭제` API가 같은 기준으로 움직이도록 맞췄다. 이제 사용 중인 티어표가 0개이고 템플릿 연결도 없을 때만 일반 미사용으로 잡히고, 대체된 아이템은 별도 예외로 계속 정리 대상에 포함된다.
|
||||
- 커스텀 이미지 레코드를 지운 뒤 실제 업로드 파일을 정리할 때는, 다른 곳에서 같은 파일 경로를 아직 참조 중이면 파일은 남겨 두도록 안전장치를 추가했다.
|
||||
|
||||
## 2026-04-06 v1.4.83
|
||||
- 관리자 아이템 관리 필터에 `대체된 아이템` 모드를 추가해, 이미지 대체 이력이 있는 사용자 업로드 항목만 따로 모아 볼 수 있게 했다.
|
||||
- 이 필터는 `replaced_at` 메타가 있는 사용자 아이템만 대상으로 하므로, 운영자는 대체 이력 검수와 후속 정리를 일반 사용자 업로드 목록과 분리해 확인할 수 있다.
|
||||
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.82
|
||||
- 관리자 이미지 대체 구조를 다시 정리해, 대체 후에도 원본 A 아이템은 원래 썸네일/라벨 그대로 남고 `대체됨` 메타만 표시되도록 맞췄다. 더 이상 원본 카드가 F 이미지처럼 덮여 보이지 않는다.
|
||||
- 실제 대체는 이제 `A 아이템 ID`가 가리키는 저장본 표시 정보만 새 이미지/라벨로 바꾸는 방식이라, 대체된 원본 카드에서 `원래 이미지로 복구`를 눌러 A 기준으로 되돌릴 수 있다.
|
||||
- 대체된 사용자 아이템은 사용량 집계가 남아 있어도 운영상 이미 정리 가능한 상태로 간주해 `미사용 아이템 일괄 삭제`와 개별 삭제 대상에 포함되도록 조정했다.
|
||||
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.81
|
||||
- 관리자 이미지 대체 시 원본 사용자 아이템 레코드의 `src / label`까지 대체 대상 이미지로 덮어써져, 카드와 상세 모달에서 원래 A 이미지 정보를 다시 볼 수 없던 문제를 바로잡았다.
|
||||
- 이제 대체 처리에서는 티어표/요청/템플릿 참조만 새 이미지로 옮기고, 원본 사용자 아이템 레코드는 원래 이미지와 이름을 그대로 유지한 채 `대체됨` 메타만 남긴다.
|
||||
- 그래서 관리자 화면에서는 원래 A 썸네일과 A 라벨을 계속 확인하면서도, 이 아이템이 어떤 대상(Y)으로 대체되었는지 함께 보고 이후 수동 정리나 복구 판단을 할 수 있다.
|
||||
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.80
|
||||
- 관리자 이미지 대체는 더 이상 원본 사용자 아이템을 즉시 삭제하지 않고, 원본 레코드와 파일을 남겨 둔 채 `어떤 아이템으로 대체됐는지` 메타만 기록하도록 바꿨다.
|
||||
- 따라서 대체 후에도 원본 이미지는 아이템 라이브러리에서 계속 확인할 수 있고, 카드와 상세 모달에서 `대체됨` 상태와 대체 대상 라벨을 함께 볼 수 있다.
|
||||
|
||||
@@ -22,7 +22,7 @@ import SvgIcon from './components/SvgIcon.vue'
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const { toasts, dismissToast } = useToast()
|
||||
const { toasts, dismissToast, error: showErrorToast } = useToast()
|
||||
const RIGHT_RAIL_COPYRIGHT_URL = 'https://x.com/zennbox'
|
||||
const currentTopicId = computed(() => route.params.topicId || '')
|
||||
|
||||
@@ -38,6 +38,7 @@ const guideStepIndex = ref(0)
|
||||
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
|
||||
const backendState = ref('online')
|
||||
const backendMessage = ref('')
|
||||
const isFullscreenActive = ref(false)
|
||||
provide('rightRailOpen', rightRailOpen)
|
||||
provide('localRightRailTarget', '#local-right-rail-root')
|
||||
|
||||
@@ -62,6 +63,7 @@ const accountEmail = computed(() => {
|
||||
if (!authReady.value) return '계정 상태를 확인하고 있어요.'
|
||||
return (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.'
|
||||
})
|
||||
const isAccountEmailHint = computed(() => !auth.user)
|
||||
const shellStyle = computed(() => ({
|
||||
'--left-rail-width': leftRailCollapsed.value ? '76px' : '248px',
|
||||
'--right-rail-width': !isRightRailOverlay.value && rightRailOpen.value ? '325px' : '0px',
|
||||
@@ -136,6 +138,13 @@ const guideSteps = [
|
||||
description:
|
||||
'주제 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
|
||||
},
|
||||
{
|
||||
id: 'keyboard-shortcuts',
|
||||
title: '단축키로 빠른 조작',
|
||||
summary: '사이드 패널과 전체 화면을 키보드로 빠르게 전환합니다.',
|
||||
description:
|
||||
'[ 키는 왼쪽 사이드를 열고 닫고, ] 키는 오른쪽 사이드를 열고 닫습니다. F 키는 전체 화면 보기 토글, S 키는 티어표 편집 화면의 아이템 검색창으로 바로 이동할 때 사용할 수 있어요. 한글 입력 상태에서는 F 자리의 ㄹ, S 자리의 ㄴ 키도 같은 단축키로 처리됩니다. 각종 모달은 Esc 키로 닫을 수 있습니다. 단, 검색창이나 입력칸에 글을 쓰는 중에는 단축키가 동작하지 않도록 처리되어 있어요.',
|
||||
},
|
||||
]
|
||||
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
|
||||
const isGuidePrevDisabled = computed(() => guideStepIndex.value <= 0)
|
||||
@@ -287,6 +296,11 @@ function handleBackendStatus(event) {
|
||||
backendMessage.value = typeof event?.detail?.message === 'string' ? event.detail.message : ''
|
||||
}
|
||||
|
||||
function syncFullscreenState() {
|
||||
if (typeof document === 'undefined') return
|
||||
isFullscreenActive.value = !!(document.fullscreenElement || document.webkitFullscreenElement)
|
||||
}
|
||||
|
||||
function applyTheme(mode) {
|
||||
themeMode.value = mode === 'light' ? 'light' : 'dark'
|
||||
if (typeof document !== 'undefined') document.documentElement.dataset.theme = themeMode.value
|
||||
@@ -311,9 +325,12 @@ onMounted(async () => {
|
||||
await auth.refresh()
|
||||
if (typeof window !== 'undefined') {
|
||||
syncViewportWidth()
|
||||
syncFullscreenState()
|
||||
window.addEventListener('tier-maker:backend-status', handleBackendStatus)
|
||||
window.addEventListener('resize', syncViewportWidth)
|
||||
window.addEventListener('keydown', handleGlobalKeydown)
|
||||
document.addEventListener('fullscreenchange', syncFullscreenState)
|
||||
document.addEventListener('webkitfullscreenchange', syncFullscreenState)
|
||||
const leftSaved = window.localStorage.getItem('tier-maker:left-rail-collapsed')
|
||||
if (leftSaved === '1') leftRailCollapsed.value = true
|
||||
const saved = window.localStorage.getItem('tier-maker:right-rail-open')
|
||||
@@ -335,6 +352,54 @@ function handleGlobalKeydown(event) {
|
||||
}
|
||||
if (event.key === 'Escape' && isCollapsedSearchOpen.value) {
|
||||
closeCollapsedSearch()
|
||||
return
|
||||
}
|
||||
if (isGuideModalOpen.value || isCollapsedSearchOpen.value) return
|
||||
if (shouldIgnoreGlobalShortcut(event)) return
|
||||
|
||||
if (event.key === '[') {
|
||||
event.preventDefault()
|
||||
toggleLeftRail()
|
||||
return
|
||||
}
|
||||
if (event.key === ']') {
|
||||
event.preventDefault()
|
||||
toggleRightRail()
|
||||
return
|
||||
}
|
||||
if (['f', 'ㄹ'].includes(String(event.key || '').toLowerCase())) {
|
||||
event.preventDefault()
|
||||
toggleFullscreen()
|
||||
return
|
||||
}
|
||||
if (['s', 'ㄴ'].includes(String(event.key || '').toLowerCase()) && ['editEditor', 'newEditor'].includes(String(route.name || ''))) {
|
||||
event.preventDefault()
|
||||
window.dispatchEvent(new CustomEvent('tier-maker:focus-editor-item-search'))
|
||||
}
|
||||
}
|
||||
|
||||
function shouldIgnoreGlobalShortcut(event) {
|
||||
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return true
|
||||
const target = event.target
|
||||
if (!target || !(target instanceof HTMLElement)) return false
|
||||
const tagName = target.tagName.toLowerCase()
|
||||
return target.isContentEditable || ['input', 'textarea', 'select'].includes(tagName)
|
||||
}
|
||||
|
||||
async function toggleFullscreen() {
|
||||
if (typeof document === 'undefined') return
|
||||
try {
|
||||
const fullscreenElement = document.fullscreenElement || document.webkitFullscreenElement
|
||||
if (fullscreenElement) {
|
||||
const exitFullscreen = document.exitFullscreen || document.webkitExitFullscreen
|
||||
if (exitFullscreen) await exitFullscreen.call(document)
|
||||
return
|
||||
}
|
||||
const target = document.documentElement
|
||||
const requestFullscreen = target.requestFullscreen || target.webkitRequestFullscreen
|
||||
if (requestFullscreen) await requestFullscreen.call(target)
|
||||
} catch (error) {
|
||||
showErrorToast('전체 화면 전환을 실행하지 못했어요.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,6 +408,8 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('tier-maker:backend-status', handleBackendStatus)
|
||||
window.removeEventListener('resize', syncViewportWidth)
|
||||
window.removeEventListener('keydown', handleGlobalKeydown)
|
||||
document.removeEventListener('fullscreenchange', syncFullscreenState)
|
||||
document.removeEventListener('webkitfullscreenchange', syncFullscreenState)
|
||||
}
|
||||
syncRightRailBodyScrollLock(false)
|
||||
})
|
||||
@@ -529,7 +596,7 @@ function reloadApp() {
|
||||
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
|
||||
<div class="appUserCard__meta">
|
||||
<div class="appUserCard__name">{{ accountName }}</div>
|
||||
<div class="appUserCard__email">{{ accountEmail }}</div>
|
||||
<div class="appUserCard__email" :class="{ 'appUserCard__email--hint': isAccountEmailHint }">{{ accountEmail }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="isMobileLayout"
|
||||
@@ -982,6 +1049,7 @@ function reloadApp() {
|
||||
.appUserCard__button,
|
||||
.appUserCard__guest {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
@@ -1022,6 +1090,7 @@ function reloadApp() {
|
||||
|
||||
.leftRail__mobileMenu {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.appUserCard__navToggle {
|
||||
@@ -1042,18 +1111,30 @@ function reloadApp() {
|
||||
.appUserCard__name {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.appUserCard__email {
|
||||
font-size: 12px;
|
||||
color: var(--theme-text-muted);
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.appUserCard__email--hint {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: unset;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.searchStub {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
158
frontend/src/components/TagBadgeInput.vue
Normal file
158
frontend/src/components/TagBadgeInput.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: '태그 입력 후 Enter' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
maxTags: { type: Number, default: 30 },
|
||||
maxTagLength: { type: Number, default: 40 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const draft = ref('')
|
||||
const isComposing = ref(false)
|
||||
|
||||
const normalizedTags = computed(() =>
|
||||
Array.from(
|
||||
new Set(
|
||||
(Array.isArray(props.modelValue) ? props.modelValue : [])
|
||||
.map((tag) => String(tag || '').trim().replace(/^#/, '').slice(0, props.maxTagLength))
|
||||
.filter(Boolean)
|
||||
)
|
||||
).slice(0, props.maxTags)
|
||||
)
|
||||
|
||||
function commitTags(nextTags) {
|
||||
emit(
|
||||
'update:modelValue',
|
||||
Array.from(
|
||||
new Set(
|
||||
(nextTags || [])
|
||||
.map((tag) => String(tag || '').trim().replace(/^#/, '').slice(0, props.maxTagLength))
|
||||
.filter(Boolean)
|
||||
)
|
||||
).slice(0, props.maxTags)
|
||||
)
|
||||
}
|
||||
|
||||
function addDraftTag() {
|
||||
if (props.disabled) return
|
||||
const nextTag = String(draft.value || '').trim().replace(/^#/, '').slice(0, props.maxTagLength)
|
||||
if (!nextTag) return
|
||||
commitTags([...normalizedTags.value, nextTag])
|
||||
draft.value = ''
|
||||
}
|
||||
|
||||
function removeTag(tag) {
|
||||
if (props.disabled) return
|
||||
commitTags(normalizedTags.value.filter((entry) => entry !== tag))
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.isComposing || isComposing.value) return
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
addDraftTag()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace' && !draft.value && normalizedTags.value.length) {
|
||||
removeTag(normalizedTags.value[normalizedTags.value.length - 1])
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
if (isComposing.value) return
|
||||
addDraftTag()
|
||||
}
|
||||
|
||||
function handleCompositionStart() {
|
||||
isComposing.value = true
|
||||
}
|
||||
|
||||
function handleCompositionEnd() {
|
||||
isComposing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tagBadgeInput" :class="{ 'tagBadgeInput--disabled': disabled }">
|
||||
<div v-if="normalizedTags.length" class="tagBadgeInput__list">
|
||||
<span v-for="tag in normalizedTags" :key="tag" class="tagBadgeInput__badge">
|
||||
<span>#{{ tag }}</span>
|
||||
<button class="tagBadgeInput__remove" type="button" :disabled="disabled" @click="removeTag(tag)">X</button>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="draft"
|
||||
class="tagBadgeInput__input"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled || normalizedTags.length >= maxTags"
|
||||
:maxlength="maxTagLength"
|
||||
@keydown="handleKeydown"
|
||||
@blur="handleBlur"
|
||||
@compositionstart="handleCompositionStart"
|
||||
@compositionend="handleCompositionEnd"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tagBadgeInput {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--theme-border-strong) 72%, rgba(255, 255, 255, 0.08));
|
||||
background: color-mix(in srgb, var(--theme-surface-soft) 82%, rgba(255, 255, 255, 0.02));
|
||||
}
|
||||
|
||||
.tagBadgeInput--disabled {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.tagBadgeInput__list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tagBadgeInput__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 11px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--theme-accent-soft) 55%, rgba(255, 255, 255, 0.06));
|
||||
color: var(--theme-text);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tagBadgeInput__remove {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tagBadgeInput__input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--theme-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.tagBadgeInput__input::placeholder {
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,9 @@ const props = defineProps({
|
||||
stagedRequestDraftCount: { type: Number, required: true },
|
||||
appliedRequestItemCount: { type: Number, required: true },
|
||||
openTemplateCreateModal: { type: Function, required: true },
|
||||
openTemplateSourceImportModal: { type: Function, required: true },
|
||||
openTemplateLibraryItemModal: { type: Function, required: true },
|
||||
openTemplateBulkTagModal: { type: Function, required: true },
|
||||
isTemplateLoading: { type: Boolean, required: true },
|
||||
hasSelectedTemplate: { type: Boolean, required: true },
|
||||
selectedTemplate: { type: Object, default: null },
|
||||
@@ -18,6 +21,7 @@ const props = defineProps({
|
||||
templateMetaSaving: { type: Boolean, required: true },
|
||||
canSaveTemplateMeta: { type: Boolean, required: true },
|
||||
saveTemplateMeta: { type: Function, required: true },
|
||||
canBulkTagTemplateItems: { type: Boolean, required: true },
|
||||
canApplyThumbnail: { type: Boolean, required: true },
|
||||
templateVisibilitySaving: { type: Boolean, required: true },
|
||||
thumbFileInputRef: { type: Function, required: true },
|
||||
@@ -170,8 +174,11 @@ function setThumbFileElement(el) {
|
||||
</label>
|
||||
<div class="templateSettingsCard__actions">
|
||||
<button class="btn btn--ghost" :disabled="!props.canSaveTemplateMeta || props.templateMetaSaving" @click="props.saveTemplateMeta">
|
||||
{{ props.templateMetaSaving ? '저장중...' : '이름/slug 저장' }}
|
||||
{{ props.templateMetaSaving ? '저장중...' : '이름/주소 저장' }}
|
||||
</button>
|
||||
<button class="btn btn--ghost" :disabled="!props.canBulkTagTemplateItems" @click="props.openTemplateBulkTagModal">기본 아이템 공통 태그</button>
|
||||
<button class="btn btn--ghost" @click="props.openTemplateLibraryItemModal">개별 아이템 검색</button>
|
||||
<button class="btn btn--ghost" @click="props.openTemplateSourceImportModal">기존 템플릿 가져오기</button>
|
||||
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
|
||||
<button class="btn btn--danger" @click="props.removeTemplate">템플릿 삭제</button>
|
||||
</div>
|
||||
@@ -221,7 +228,7 @@ function setThumbFileElement(el) {
|
||||
<div class="hint hint--tight">{{ draft.sourceName }}</div>
|
||||
<div class="itemDraftRow__meta">
|
||||
<span class="pill" :class="draft.kind === 'request' ? 'pill--requestItem' : 'pill--directFile'">
|
||||
{{ draft.kind === 'request' ? '요청 아이템' : '직접 추가 파일' }}
|
||||
{{ draft.kind === 'request' ? '요청 아이템' : draft.kind === 'library' ? '기존 템플릿' : '직접 추가 파일' }}
|
||||
</span>
|
||||
<button class="btn btn--danger btn--small" type="button" @click="props.removeUploadDraft(draft)">제외</button>
|
||||
</div>
|
||||
@@ -246,21 +253,19 @@ function setThumbFileElement(el) {
|
||||
<button class="btn btn--primary btn--small" :disabled="!props.hasTemplateItemOrderChanges" @click="props.saveTemplateItemOrder">순서 저장</button>
|
||||
</div>
|
||||
<div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
|
||||
<div v-else :ref="setTemplateItemListElement" class="thumbGrid">
|
||||
<div v-else :ref="setTemplateItemListElement" class="thumbGrid">
|
||||
<div v-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-template-item-id="item.id">
|
||||
<img class="thumb thumb--template" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
|
||||
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />
|
||||
<div class="thumbCard__actions">
|
||||
<button
|
||||
class="btn btn--ghost btn--small"
|
||||
data-no-drag
|
||||
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label"
|
||||
@click="props.saveTemplateItemLabel(item)"
|
||||
>
|
||||
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
|
||||
</button>
|
||||
<button class="btn btn--danger btn--small" data-no-drag @click="props.removeTemplateItem(item.id)">아이템 삭제</button>
|
||||
<div class="thumbCard__media">
|
||||
<img class="thumb thumb--template" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
|
||||
<button class="thumbCard__deleteBtn" type="button" data-no-drag @click="props.removeTemplateItem(item)">X</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="item.draftLabel"
|
||||
class="input input--labelEdit"
|
||||
placeholder="아이템 이름"
|
||||
data-no-drag
|
||||
@keydown.enter.prevent="props.saveTemplateItemLabel(item)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
export function useAdminCustomItems({
|
||||
api,
|
||||
toast,
|
||||
@@ -14,6 +12,7 @@ export function useAdminCustomItems({
|
||||
customItemModalHistoryActive,
|
||||
modalTargetCustomItem,
|
||||
customItemModalDraftLabel,
|
||||
customItemModalDraftTags,
|
||||
customItemModalLabelSaving,
|
||||
customItemModalTargetTemplateId,
|
||||
customItemReplacementQuery,
|
||||
@@ -25,8 +24,6 @@ export function useAdminCustomItems({
|
||||
selectedTemplateId,
|
||||
refreshCustomItems,
|
||||
loadTemplate,
|
||||
setTab,
|
||||
selectAdminTemplate,
|
||||
resetMessages,
|
||||
success,
|
||||
error,
|
||||
@@ -75,6 +72,7 @@ export function useAdminCustomItems({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
filter: 'all',
|
||||
collapseShared: true,
|
||||
})
|
||||
customItemReplacementItems.value = (data.items || []).filter((item) => item?.id && item.id !== currentItemId)
|
||||
} catch (e) {
|
||||
@@ -88,6 +86,7 @@ export function useAdminCustomItems({
|
||||
function openCustomItemModal(item) {
|
||||
modalTargetCustomItem.value = item || null
|
||||
customItemModalDraftLabel.value = item?.label || ''
|
||||
customItemModalDraftTags.value = Array.isArray(item?.tags) ? [...item.tags] : []
|
||||
customItemModalTargetTemplateId.value = ''
|
||||
customItemReplacementQuery.value = ''
|
||||
customItemReplacementItems.value = []
|
||||
@@ -102,6 +101,7 @@ export function useAdminCustomItems({
|
||||
customItemDeleteModalOpen.value = false
|
||||
modalTargetCustomItem.value = null
|
||||
customItemModalDraftLabel.value = ''
|
||||
customItemModalDraftTags.value = []
|
||||
customItemModalLabelSaving.value = false
|
||||
customItemModalTargetTemplateId.value = ''
|
||||
customItemReplacementQuery.value = ''
|
||||
@@ -123,7 +123,7 @@ export function useAdminCustomItems({
|
||||
|
||||
function openCustomItemDeleteModal(item) {
|
||||
if (!item) return
|
||||
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
|
||||
if (item.sourceType === 'user' && !item.replacedAt && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
|
||||
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||
return
|
||||
}
|
||||
@@ -135,19 +135,10 @@ export function useAdminCustomItems({
|
||||
customItemDeleteModalOpen.value = false
|
||||
}
|
||||
|
||||
function jumpToTemplateAdmin(templateId) {
|
||||
if (!templateId) return
|
||||
closeCustomItemModal()
|
||||
setTab('template-admin')
|
||||
nextTick(() => {
|
||||
selectAdminTemplate(templateId)
|
||||
})
|
||||
}
|
||||
|
||||
async function removeCustomItem(item = modalTargetCustomItem.value) {
|
||||
resetMessages()
|
||||
if (!item) return
|
||||
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
|
||||
if (item.sourceType === 'user' && !item.replacedAt && (item.usageCount > 0 || item.linkedTemplates.length > 0)) {
|
||||
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||
return
|
||||
}
|
||||
@@ -175,32 +166,56 @@ export function useAdminCustomItems({
|
||||
|
||||
async function removeUnusedCustomItems() {
|
||||
resetMessages()
|
||||
const ok = window.confirm('현재 검색 조건에 맞는 미사용 커스텀 이미지를 모두 삭제할까요?')
|
||||
const ok = window.confirm('현재 검색 조건에 맞는 미사용 이미지를 모두 삭제할까요?')
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
const data = await api.deleteAdminUnusedCustomItems({ q: customItemQuery.value })
|
||||
await refreshCustomItems()
|
||||
success.value = `${data.deletedCount || 0}개의 미사용 사용자 업로드 이미지를 삭제했어요.`
|
||||
success.value = `${data.deletedCount || 0}개의 미사용 이미지를 삭제했어요.`
|
||||
} catch (e) {
|
||||
error.value = '미사용 커스텀 이미지 일괄 삭제에 실패했어요.'
|
||||
error.value = '미사용 이미지 일괄 삭제에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
function showUnusedCustomItems() {
|
||||
if (customItemFilter.value === 'unused') return
|
||||
resetMessages()
|
||||
customItemFilter.value = 'unused'
|
||||
customItemPage.value = 1
|
||||
refreshCustomItems()
|
||||
}
|
||||
|
||||
async function saveCustomItemModalLabel() {
|
||||
const item = modalTargetCustomItem.value
|
||||
const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60)
|
||||
if (!item || !nextLabel || nextLabel === item.label || customItemModalLabelSaving.value) return
|
||||
const nextTags = Array.from(
|
||||
new Set(
|
||||
(Array.isArray(customItemModalDraftTags.value) ? customItemModalDraftTags.value : [])
|
||||
.map((tag) => tag.trim().replace(/^#/, '').slice(0, 40))
|
||||
.filter(Boolean)
|
||||
)
|
||||
).slice(0, 30)
|
||||
if (
|
||||
!item ||
|
||||
!nextLabel ||
|
||||
(nextLabel === item.label && JSON.stringify(nextTags) === JSON.stringify(Array.isArray(item.tags) ? item.tags : [])) ||
|
||||
customItemModalLabelSaving.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
customItemModalLabelSaving.value = true
|
||||
const data = await api.updateAdminCustomItemLabel(item.id, { label: nextLabel, sourceType: item.sourceType })
|
||||
const data = await api.updateAdminCustomItemLabel(item.id, { label: nextLabel, tags: nextTags, sourceType: item.sourceType })
|
||||
item.label = data.item?.label || nextLabel
|
||||
item.tags = Array.isArray(data.item?.tags) ? data.item.tags : nextTags
|
||||
customItemModalDraftLabel.value = item.label
|
||||
customItems.value = customItems.value.map((entry) => (entry.id === item.id ? { ...entry, label: item.label } : entry))
|
||||
toast.success('아이템 이름을 변경했어요.')
|
||||
customItemModalDraftTags.value = [...item.tags]
|
||||
customItems.value = customItems.value.map((entry) => (entry.id === item.id ? { ...entry, label: item.label, tags: item.tags } : entry))
|
||||
toast.success('아이템 메타를 변경했어요.')
|
||||
} catch (e) {
|
||||
error.value = '아이템 이름 변경에 실패했어요.'
|
||||
error.value = '아이템 메타 변경에 실패했어요.'
|
||||
} finally {
|
||||
customItemModalLabelSaving.value = false
|
||||
}
|
||||
@@ -228,6 +243,30 @@ export function useAdminCustomItems({
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkCustomItemTemplate(item = modalTargetCustomItem.value, template) {
|
||||
resetMessages()
|
||||
if (!item?.id || !template?.id) {
|
||||
error.value = '제외할 템플릿 정보를 찾지 못했어요.'
|
||||
return
|
||||
}
|
||||
|
||||
const ok = window.confirm(`"${template.name}" 템플릿에서 이 이미지를 제외할까요?`)
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
await api.unlinkAdminCustomItemTemplate(item.id, { topicId: template.id })
|
||||
if (selectedTemplateId.value === template.id) await loadTemplate()
|
||||
await refreshCustomItems()
|
||||
modalTargetCustomItem.value = {
|
||||
...item,
|
||||
linkedTemplates: (item.linkedTemplates || []).filter((entry) => entry.id !== template.id),
|
||||
}
|
||||
success.value = `"${template.name}" 템플릿에서 이미지를 제외했어요.`
|
||||
} catch (e) {
|
||||
error.value = '템플릿 연결 해제에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceCustomItem(item = modalTargetCustomItem.value) {
|
||||
resetMessages()
|
||||
const targetItem = customItemReplacementItems.value.find((entry) => entry.id === customItemReplacementTargetId.value)
|
||||
@@ -257,6 +296,27 @@ export function useAdminCustomItems({
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreCustomItem(item = modalTargetCustomItem.value) {
|
||||
resetMessages()
|
||||
if (!item?.id || !item.replacedAt) {
|
||||
error.value = '복구할 대체 이력이 없어요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
customItemReplacementBusy.value = true
|
||||
await api.restoreAdminCustomItem(item.id)
|
||||
if (selectedTemplateId.value) await loadTemplate()
|
||||
await refreshCustomItems()
|
||||
closeCustomItemModal()
|
||||
success.value = `"${item.label}" 아이템을 원래 이미지로 복구했어요.`
|
||||
} catch (e) {
|
||||
error.value = '원래 이미지로 복구하지 못했어요.'
|
||||
} finally {
|
||||
customItemReplacementBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
submitCustomItemSearch,
|
||||
changeCustomItemFilter,
|
||||
@@ -267,12 +327,14 @@ export function useAdminCustomItems({
|
||||
closeCustomItemModal,
|
||||
openCustomItemDeleteModal,
|
||||
closeCustomItemDeleteModal,
|
||||
jumpToTemplateAdmin,
|
||||
removeCustomItem,
|
||||
removeUnusedCustomItems,
|
||||
showUnusedCustomItems,
|
||||
saveCustomItemModalLabel,
|
||||
promoteCustomItem,
|
||||
unlinkCustomItemTemplate,
|
||||
refreshReplacementCandidates,
|
||||
replaceCustomItem,
|
||||
restoreCustomItem,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -77,9 +77,9 @@ export const api = {
|
||||
request(`/api/admin/templates/${encodeURIComponent(templateId)}`, { method: 'PATCH', body: payload }),
|
||||
updateAdminTemplateItem: (templateId, itemId, payload) =>
|
||||
request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||
listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all' } = {}) =>
|
||||
listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all', collapseShared = false } = {}) =>
|
||||
request(
|
||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
|
||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}&collapseShared=${encodeURIComponent(collapseShared ? '1' : '0')}`
|
||||
),
|
||||
listAdminTierLists: ({ q = '', topicId = '', sort = 'recent', minFavorites = 0, page = 1, limit = 50 } = {}) =>
|
||||
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}&sort=${encodeURIComponent(sort)}&minFavorites=${encodeURIComponent(minFavorites)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
|
||||
@@ -104,8 +104,12 @@ export const api = {
|
||||
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
|
||||
promoteAdminTemplateItem: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||
unlinkAdminCustomItemTemplate: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/unlink-template`, { method: 'POST', body: payload }),
|
||||
replaceAdminCustomItem: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/replace`, { method: 'POST', body: payload }),
|
||||
restoreAdminCustomItem: (itemId) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/restore`, { method: 'POST', body: {} }),
|
||||
updateAdminCustomItemLabel: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
|
||||
promoteAdminTierListItems: (tierListId, payload) =>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { editorPath, userProfilePath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import deleteIcon from '../assets/icons/delete.svg'
|
||||
import SvgIcon from '../components/SvgIcon.vue'
|
||||
import TagBadgeInput from '../components/TagBadgeInput.vue'
|
||||
import AdminFeaturedSection from '../components/admin/AdminFeaturedSection.vue'
|
||||
import AdminTemplatesSection from '../components/admin/AdminTemplatesSection.vue'
|
||||
import AdminItemsSection from '../components/admin/AdminItemsSection.vue'
|
||||
@@ -70,6 +71,17 @@ const importModalItems = ref([])
|
||||
const importModalTargetTemplateId = ref('')
|
||||
const importModalNewTemplateId = ref('')
|
||||
const importModalNewTemplateName = ref('')
|
||||
const templateSourceImportModalOpen = ref(false)
|
||||
const templateSourceImportQuery = ref('')
|
||||
const templateSourceImportSelectedIds = ref([])
|
||||
const templateLibraryItemModalOpen = ref(false)
|
||||
const templateLibraryItemQuery = ref('')
|
||||
const templateLibraryItemResults = ref([])
|
||||
const templateLibraryItemSelectedIds = ref([])
|
||||
const templateLibraryItemLoading = ref(false)
|
||||
const templateBulkTagModalOpen = ref(false)
|
||||
const templateBulkTagDrafts = ref([])
|
||||
const templateBulkTagSaving = ref(false)
|
||||
const previewModalOpen = ref(false)
|
||||
const previewTierList = ref(null)
|
||||
const adminTierListManageModalOpen = ref(false)
|
||||
@@ -80,6 +92,7 @@ const userDeleteModalOpen = ref(false)
|
||||
const userRoleModalOpen = ref(false)
|
||||
const customItemModalOpen = ref(false)
|
||||
const customItemDeleteModalOpen = ref(false)
|
||||
const templateItemDeleteModalOpen = ref(false)
|
||||
const customItemModalHistoryActive = ref(false)
|
||||
const modalTargetUser = ref(null)
|
||||
const modalPasswordDraft = ref('')
|
||||
@@ -88,7 +101,10 @@ const modalUserDraftEmail = ref('')
|
||||
const modalUserDraftNickname = ref('')
|
||||
const modalUserDraftIsAdmin = ref(false)
|
||||
const modalTargetCustomItem = ref(null)
|
||||
const modalTargetTemplateItem = ref(null)
|
||||
const modalTargetTemplateItemUsage = ref({ totalCount: 0, publicCount: 0, privateCount: 0 })
|
||||
const customItemModalDraftLabel = ref('')
|
||||
const customItemModalDraftTags = ref([])
|
||||
const customItemModalLabelSaving = ref(false)
|
||||
const modalTargetAdminTierList = ref(null)
|
||||
const adminTierListDraftTitle = ref('')
|
||||
@@ -184,6 +200,17 @@ function normalizeAdminSrc(src) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseAdminTagsText(value) {
|
||||
const values = Array.isArray(value) ? value : String(value || '').split(',')
|
||||
return Array.from(
|
||||
new Set(
|
||||
values
|
||||
.map((tag) => tag.trim().replace(/^#/, '').slice(0, 40))
|
||||
.filter(Boolean)
|
||||
)
|
||||
).slice(0, 30)
|
||||
}
|
||||
|
||||
const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.template?.id)
|
||||
const canSaveTemplateMeta = computed(() => {
|
||||
const template = selectedTemplate.value?.template
|
||||
@@ -193,10 +220,12 @@ const canSaveTemplateMeta = computed(() => {
|
||||
return (
|
||||
!!nextName &&
|
||||
!!nextSlug &&
|
||||
(nextName !== (template.name || '') || nextSlug !== (template.slug || template.id || ''))
|
||||
(nextName !== (template.name || '') ||
|
||||
nextSlug !== (template.slug || template.id || ''))
|
||||
)
|
||||
})
|
||||
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value)
|
||||
const canBulkTagTemplateItems = computed(() => !!selectedTemplate.value?.items?.length)
|
||||
const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedTemplateId.value)
|
||||
const stagedRequestDraftCount = computed(() => uploadItemDrafts.value.filter((item) => item.kind === 'request').length)
|
||||
const appliedRequestItemCount = computed(() => {
|
||||
@@ -236,6 +265,20 @@ const customItemReplacementTarget = computed(
|
||||
() => customItemReplacementItems.value.find((item) => item.id === customItemReplacementTargetId.value) || null
|
||||
)
|
||||
const importModalItemCount = computed(() => importModalItems.value.length)
|
||||
const selectedTemplateLibraryItems = computed(() =>
|
||||
templateLibraryItemResults.value.filter((item) => templateLibraryItemSelectedIds.value.includes(item.id))
|
||||
)
|
||||
const filteredTemplateSourceImportTemplates = computed(() => {
|
||||
const query = templateSourceImportQuery.value.trim().toLowerCase()
|
||||
return templates.value
|
||||
.filter((template) => template.id !== selectedTemplateId.value)
|
||||
.filter((template) => {
|
||||
if (!query) return true
|
||||
const haystack = `${template.name || ''} ${template.slug || ''} ${template.id || ''}`.toLowerCase()
|
||||
return haystack.includes(query)
|
||||
})
|
||||
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
|
||||
})
|
||||
const activeTabTitle = computed(() => {
|
||||
if (activeTab.value === 'featured') return '목록 관리'
|
||||
if (activeTab.value === 'template-admin') return '템플릿 관리'
|
||||
@@ -327,8 +370,13 @@ const isAnyModalOpen = computed(
|
||||
userDeleteModalOpen.value ||
|
||||
userRoleModalOpen.value ||
|
||||
importModalOpen.value ||
|
||||
templateSourceImportModalOpen.value ||
|
||||
templateLibraryItemModalOpen.value ||
|
||||
templateBulkTagModalOpen.value ||
|
||||
templatePickerModalOpen.value ||
|
||||
customItemModalOpen.value ||
|
||||
customItemDeleteModalOpen.value ||
|
||||
templateItemDeleteModalOpen.value ||
|
||||
adminTierListManageModalOpen.value ||
|
||||
imageResetModalOpen.value ||
|
||||
previewModalOpen.value
|
||||
@@ -371,6 +419,26 @@ function handleAdminPopState() {
|
||||
|
||||
function handleAdminKeydown(event) {
|
||||
if (event.key !== 'Escape') return
|
||||
if (previewModalOpen.value) {
|
||||
event.preventDefault()
|
||||
closePreviewModal()
|
||||
return
|
||||
}
|
||||
if (imageResetModalOpen.value) {
|
||||
event.preventDefault()
|
||||
closeImageResetModal()
|
||||
return
|
||||
}
|
||||
if (adminTierListManageModalOpen.value) {
|
||||
event.preventDefault()
|
||||
closeAdminTierListManageModal()
|
||||
return
|
||||
}
|
||||
if (templateItemDeleteModalOpen.value) {
|
||||
event.preventDefault()
|
||||
closeTemplateItemDeleteModal()
|
||||
return
|
||||
}
|
||||
if (customItemDeleteModalOpen.value) {
|
||||
event.preventDefault()
|
||||
closeCustomItemDeleteModal()
|
||||
@@ -381,9 +449,55 @@ function handleAdminKeydown(event) {
|
||||
closeCustomItemModal()
|
||||
return
|
||||
}
|
||||
if (previewModalOpen.value) {
|
||||
if (templatePickerModalOpen.value) {
|
||||
event.preventDefault()
|
||||
closePreviewModal()
|
||||
closeTemplatePickerModal()
|
||||
return
|
||||
}
|
||||
if (templateBulkTagModalOpen.value) {
|
||||
event.preventDefault()
|
||||
closeTemplateBulkTagModal()
|
||||
return
|
||||
}
|
||||
if (templateLibraryItemModalOpen.value) {
|
||||
event.preventDefault()
|
||||
closeTemplateLibraryItemModal()
|
||||
return
|
||||
}
|
||||
if (templateSourceImportModalOpen.value) {
|
||||
event.preventDefault()
|
||||
closeTemplateSourceImportModal()
|
||||
return
|
||||
}
|
||||
if (importModalOpen.value) {
|
||||
event.preventDefault()
|
||||
closeTierListImportModal()
|
||||
return
|
||||
}
|
||||
if (userRoleModalOpen.value) {
|
||||
event.preventDefault()
|
||||
closeUserRoleModal()
|
||||
return
|
||||
}
|
||||
if (userDeleteModalOpen.value) {
|
||||
event.preventDefault()
|
||||
closeUserDeleteModal()
|
||||
return
|
||||
}
|
||||
if (userPasswordModalOpen.value) {
|
||||
event.preventDefault()
|
||||
closeUserPasswordModal()
|
||||
return
|
||||
}
|
||||
if (userEditModalOpen.value) {
|
||||
event.preventDefault()
|
||||
closeUserEditModal()
|
||||
return
|
||||
}
|
||||
if (templateCreateModalOpen.value) {
|
||||
event.preventDefault()
|
||||
closeTemplateCreateModal()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,6 +771,16 @@ const visibleLinkedTemplates = computed(() =>
|
||||
const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean)))
|
||||
const canReplaceModalTarget = computed(() => modalTargetCustomItem.value?.sourceType === 'user')
|
||||
const replacementCandidateCount = computed(() => customItemReplacementItems.value.length)
|
||||
const hasDeletableUnusedItems = computed(() =>
|
||||
customItems.value.some(
|
||||
(item) =>
|
||||
(item?.sourceType === 'user' &&
|
||||
(!!item.replacedAt ||
|
||||
(Number(item.usageCount || 0) === 0 && !(Array.isArray(item.linkedTemplates) && item.linkedTemplates.length > 0)))) ||
|
||||
item?.sourceType === 'asset' ||
|
||||
!!item?.isAssetLibraryItem
|
||||
)
|
||||
)
|
||||
|
||||
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
|
||||
const imageStatsYearOptions = computed(() => {
|
||||
@@ -842,6 +966,7 @@ async function refreshCustomItems() {
|
||||
page: customItemPage.value,
|
||||
limit: customItemLimit.value,
|
||||
filter: customItemFilter.value,
|
||||
collapseShared: !['user', 'template', 'unused-user', 'replaced-user'].includes(customItemFilter.value),
|
||||
})
|
||||
customItems.value = data.items || []
|
||||
customItemTotal.value = data.total || 0
|
||||
@@ -976,6 +1101,7 @@ const {
|
||||
destroyTemplateItemSortable,
|
||||
syncTemplateItemSortable,
|
||||
mergeRequestItemsIntoDrafts,
|
||||
mergeLibraryItemsIntoDrafts,
|
||||
removeUploadDraft,
|
||||
loadTemplate,
|
||||
createTemplate,
|
||||
@@ -1046,13 +1172,15 @@ const {
|
||||
closeCustomItemModal,
|
||||
openCustomItemDeleteModal,
|
||||
closeCustomItemDeleteModal,
|
||||
jumpToTemplateAdmin,
|
||||
removeCustomItem,
|
||||
removeUnusedCustomItems,
|
||||
showUnusedCustomItems,
|
||||
saveCustomItemModalLabel,
|
||||
promoteCustomItem,
|
||||
unlinkCustomItemTemplate,
|
||||
refreshReplacementCandidates,
|
||||
replaceCustomItem,
|
||||
restoreCustomItem,
|
||||
} = useAdminCustomItems({
|
||||
api,
|
||||
toast,
|
||||
@@ -1067,6 +1195,7 @@ const {
|
||||
customItemModalHistoryActive,
|
||||
modalTargetCustomItem,
|
||||
customItemModalDraftLabel,
|
||||
customItemModalDraftTags,
|
||||
customItemModalLabelSaving,
|
||||
customItemModalTargetTemplateId,
|
||||
customItemReplacementQuery,
|
||||
@@ -1078,8 +1207,6 @@ const {
|
||||
selectedTemplateId,
|
||||
refreshCustomItems,
|
||||
loadTemplate,
|
||||
setTab,
|
||||
selectAdminTemplate,
|
||||
resetMessages,
|
||||
success,
|
||||
error,
|
||||
@@ -1225,6 +1352,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 = {
|
||||
@@ -1266,7 +1394,7 @@ async function saveTemplateMeta() {
|
||||
templateMetaDraftName.value = nextTemplate.name || selectedTemplate.value.template.name || ''
|
||||
templateMetaDraftSlug.value = nextTemplate.slug || selectedTemplate.value.template.slug || selectedTemplate.value.template.id || ''
|
||||
await refreshTemplates()
|
||||
success.value = '템플릿 이름과 slug를 저장했어요.'
|
||||
success.value = '템플릿 이름과 주소를 저장했어요.'
|
||||
} catch (e) {
|
||||
const errorCode = e?.data?.error || ''
|
||||
if (errorCode === 'topic_slug_taken') {
|
||||
@@ -1277,12 +1405,69 @@ async function saveTemplateMeta() {
|
||||
error.value = 'slug는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.'
|
||||
return
|
||||
}
|
||||
error.value = '템플릿 이름/slug를 저장하지 못했어요.'
|
||||
error.value = '템플릿 이름과 주소를 저장하지 못했어요.'
|
||||
} finally {
|
||||
templateMetaSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openTemplateBulkTagModal() {
|
||||
resetMessages()
|
||||
if (!selectedTemplate.value?.items?.length) {
|
||||
error.value = '태그를 추가할 기본 아이템이 없어요.'
|
||||
return
|
||||
}
|
||||
templateBulkTagDrafts.value = []
|
||||
templateBulkTagSaving.value = false
|
||||
templateBulkTagModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeTemplateBulkTagModal() {
|
||||
templateBulkTagModalOpen.value = false
|
||||
templateBulkTagDrafts.value = []
|
||||
templateBulkTagSaving.value = false
|
||||
}
|
||||
|
||||
async function applyTemplateBulkTags() {
|
||||
resetMessages()
|
||||
if (!selectedTemplateId.value || !selectedTemplate.value?.items?.length) {
|
||||
error.value = '태그를 적용할 템플릿을 먼저 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
const nextTags = parseAdminTagsText(templateBulkTagDrafts.value)
|
||||
if (!nextTags.length) {
|
||||
error.value = '추가할 태그를 하나 이상 입력해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
templateBulkTagSaving.value = true
|
||||
const items = selectedTemplate.value.items || []
|
||||
let updatedCount = 0
|
||||
|
||||
for (const item of items) {
|
||||
const mergedTags = Array.from(new Set([...(Array.isArray(item.tags) ? item.tags : []), ...nextTags]))
|
||||
if (JSON.stringify(mergedTags) === JSON.stringify(Array.isArray(item.tags) ? item.tags : [])) continue
|
||||
const data = await api.updateAdminTemplateItem(selectedTemplateId.value, item.id, {
|
||||
label: item.label,
|
||||
tags: mergedTags,
|
||||
})
|
||||
item.tags = Array.isArray(data.item?.tags) ? data.item.tags : mergedTags
|
||||
updatedCount += 1
|
||||
}
|
||||
|
||||
closeTemplateBulkTagModal()
|
||||
success.value = updatedCount
|
||||
? `기본 아이템 ${updatedCount}개에 공통 태그를 추가했어요.`
|
||||
: '이미 같은 태그가 들어 있어서 바뀐 항목이 없었어요.'
|
||||
} catch (e) {
|
||||
error.value = '기본 아이템 공통 태그 추가에 실패했어요.'
|
||||
} finally {
|
||||
templateBulkTagSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSelectedTemplateVisibility(nextValue) {
|
||||
if (!selectedTemplate.value?.template?.id || templateVisibilitySaving.value) return
|
||||
const previous = !!selectedTemplate.value.template.isPublic
|
||||
@@ -1305,12 +1490,26 @@ async function toggleSelectedTemplateVisibility(nextValue) {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTemplateItem(itemId) {
|
||||
function closeTemplateItemDeleteModal() {
|
||||
templateItemDeleteModalOpen.value = false
|
||||
modalTargetTemplateItem.value = null
|
||||
modalTargetTemplateItemUsage.value = { totalCount: 0, publicCount: 0, privateCount: 0 }
|
||||
}
|
||||
|
||||
function templateItemDeleteImpactText() {
|
||||
const usage = modalTargetTemplateItemUsage.value || { totalCount: 0, publicCount: 0, privateCount: 0 }
|
||||
if (usage.totalCount) {
|
||||
return `이 아이템은 이미 저장된 티어표 ${usage.totalCount}개(공개 ${usage.publicCount}개, 비공개 ${usage.privateCount}개)에서 사용 중이에요. 기존 저장 티어표에는 영향을 주지 않고, 이후 새로 만드는 티어표에서만 제외됩니다.`
|
||||
}
|
||||
return '이 기본 아이템을 삭제할까요? 기존 저장 티어표에는 영향을 주지 않고, 이후 새로 만드는 티어표에서만 제외됩니다.'
|
||||
}
|
||||
|
||||
async function removeTemplateItem(item) {
|
||||
resetMessages()
|
||||
if (!selectedTemplateId.value) return
|
||||
if (!selectedTemplateId.value || !item?.id) return
|
||||
try {
|
||||
const usageRes = await fetch(
|
||||
toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}/usage`),
|
||||
toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(item.id)}/usage`),
|
||||
{
|
||||
credentials: 'include',
|
||||
}
|
||||
@@ -1318,16 +1517,22 @@ async function removeTemplateItem(itemId) {
|
||||
if (!usageRes.ok) throw new Error('usage_failed')
|
||||
|
||||
const usageData = await usageRes.json()
|
||||
const usage = usageData?.usage || { totalCount: 0, publicCount: 0, privateCount: 0 }
|
||||
const impactMessage = usage.totalCount
|
||||
? `이 아이템은 이미 저장된 티어표 ${usage.totalCount}개(공개 ${usage.publicCount}개, 비공개 ${usage.privateCount}개)에서 사용 중이에요.\n기존 티어표에는 영향을 주지 않고, 이후 새로 만드는 티어표에서만 제외됩니다.\n정말 삭제할까요?`
|
||||
: '이 기본 아이템을 삭제할까요?\n기존 저장 티어표에는 영향을 주지 않고, 이후 새로 만드는 티어표에서만 제외됩니다.'
|
||||
const ok = window.confirm(impactMessage)
|
||||
if (!ok) return
|
||||
modalTargetTemplateItem.value = item
|
||||
modalTargetTemplateItemUsage.value = usageData?.usage || { totalCount: 0, publicCount: 0, privateCount: 0 }
|
||||
templateItemDeleteModalOpen.value = true
|
||||
} catch (e) {
|
||||
error.value = '템플릿 기본 아이템 삭제에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmTemplateItemDelete() {
|
||||
resetMessages()
|
||||
if (!selectedTemplateId.value || !modalTargetTemplateItem.value?.id) return
|
||||
|
||||
try {
|
||||
const previousScrollY = window.scrollY
|
||||
const res = await fetch(
|
||||
toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}`),
|
||||
toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(modalTargetTemplateItem.value.id)}`),
|
||||
{
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
@@ -1335,6 +1540,7 @@ async function removeTemplateItem(itemId) {
|
||||
)
|
||||
if (!res.ok) throw new Error('failed')
|
||||
|
||||
closeTemplateItemDeleteModal()
|
||||
await loadTemplate()
|
||||
await nextTick()
|
||||
window.scrollTo({ top: previousScrollY, behavior: 'auto' })
|
||||
@@ -1356,9 +1562,13 @@ async function saveTemplateItemLabel(item) {
|
||||
|
||||
try {
|
||||
item.isSavingLabel = true
|
||||
const data = await api.updateAdminTemplateItem(selectedTemplateId.value, item.id, { label: nextLabel })
|
||||
const data = await api.updateAdminTemplateItem(selectedTemplateId.value, item.id, {
|
||||
label: nextLabel,
|
||||
tags: Array.isArray(item.tags) ? item.tags : [],
|
||||
})
|
||||
item.label = data.item.label
|
||||
item.draftLabel = data.item.label
|
||||
item.tags = Array.isArray(data.item.tags) ? data.item.tags : []
|
||||
success.value = '기본 아이템 이름을 수정했어요.'
|
||||
} catch (e) {
|
||||
error.value = '기본 아이템 이름 수정에 실패했어요.'
|
||||
@@ -1470,6 +1680,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,
|
||||
@@ -1704,6 +1915,142 @@ function closeTierListImportModal() {
|
||||
importModalItems.value = []
|
||||
}
|
||||
|
||||
function openTemplateSourceImportModal() {
|
||||
resetMessages()
|
||||
if (!selectedTemplateId.value) {
|
||||
error.value = '먼저 가져올 대상을 받을 템플릿을 선택해주세요.'
|
||||
return
|
||||
}
|
||||
templateSourceImportSelectedIds.value = []
|
||||
templateSourceImportQuery.value = ''
|
||||
templateSourceImportModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeTemplateSourceImportModal() {
|
||||
templateSourceImportModalOpen.value = false
|
||||
templateSourceImportSelectedIds.value = []
|
||||
templateSourceImportQuery.value = ''
|
||||
}
|
||||
|
||||
function openTemplateLibraryItemModal() {
|
||||
resetMessages()
|
||||
if (!selectedTemplateId.value) {
|
||||
error.value = '먼저 아이템을 받을 템플릿을 선택해주세요.'
|
||||
return
|
||||
}
|
||||
templateLibraryItemQuery.value = ''
|
||||
templateLibraryItemResults.value = []
|
||||
templateLibraryItemSelectedIds.value = []
|
||||
templateLibraryItemLoading.value = false
|
||||
templateLibraryItemModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeTemplateLibraryItemModal() {
|
||||
templateLibraryItemModalOpen.value = false
|
||||
templateLibraryItemQuery.value = ''
|
||||
templateLibraryItemResults.value = []
|
||||
templateLibraryItemSelectedIds.value = []
|
||||
templateLibraryItemLoading.value = false
|
||||
}
|
||||
|
||||
function toggleTemplateLibraryItemSelection(itemId) {
|
||||
if (!itemId) return
|
||||
if (templateLibraryItemSelectedIds.value.includes(itemId)) {
|
||||
templateLibraryItemSelectedIds.value = templateLibraryItemSelectedIds.value.filter((id) => id !== itemId)
|
||||
return
|
||||
}
|
||||
templateLibraryItemSelectedIds.value = [...templateLibraryItemSelectedIds.value, itemId]
|
||||
}
|
||||
|
||||
async function searchTemplateLibraryItems() {
|
||||
resetMessages()
|
||||
const query = templateLibraryItemQuery.value.trim()
|
||||
if (!query) {
|
||||
templateLibraryItemResults.value = []
|
||||
templateLibraryItemSelectedIds.value = []
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
templateLibraryItemLoading.value = true
|
||||
const data = await api.listAdminCustomItems({
|
||||
q: query,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
filter: 'library',
|
||||
collapseShared: true,
|
||||
})
|
||||
templateLibraryItemResults.value = (data.items || []).filter((item) => item?.id)
|
||||
templateLibraryItemSelectedIds.value = templateLibraryItemSelectedIds.value.filter((id) =>
|
||||
templateLibraryItemResults.value.some((item) => item.id === id)
|
||||
)
|
||||
} catch (e) {
|
||||
templateLibraryItemResults.value = []
|
||||
templateLibraryItemSelectedIds.value = []
|
||||
error.value = '개별 아이템 검색에 실패했어요.'
|
||||
} finally {
|
||||
templateLibraryItemLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmTemplateLibraryItemImport() {
|
||||
resetMessages()
|
||||
if (!selectedTemplateId.value) {
|
||||
error.value = '아이템을 받을 템플릿을 먼저 선택해주세요.'
|
||||
return
|
||||
}
|
||||
if (!selectedTemplateLibraryItems.value.length) {
|
||||
error.value = '가져올 아이템을 하나 이상 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
const stagedCount = mergeLibraryItemsIntoDrafts(selectedTemplateLibraryItems.value, '라이브러리 검색')
|
||||
closeTemplateLibraryItemModal()
|
||||
success.value = stagedCount
|
||||
? `${stagedCount}개의 아이템을 초안 목록에 추가했어요. 필요 없는 항목은 제외한 뒤 저장하면 됩니다.`
|
||||
: '추가로 가져올 새 아이템이 없었어요.'
|
||||
}
|
||||
|
||||
function toggleTemplateSourceImportSelection(templateId) {
|
||||
if (!templateId) return
|
||||
if (templateSourceImportSelectedIds.value.includes(templateId)) {
|
||||
templateSourceImportSelectedIds.value = templateSourceImportSelectedIds.value.filter((id) => id !== templateId)
|
||||
return
|
||||
}
|
||||
templateSourceImportSelectedIds.value = [...templateSourceImportSelectedIds.value, templateId]
|
||||
}
|
||||
|
||||
async function confirmTemplateSourceImport() {
|
||||
resetMessages()
|
||||
if (!selectedTemplateId.value) {
|
||||
error.value = '가져올 대상을 받을 템플릿을 먼저 선택해주세요.'
|
||||
return
|
||||
}
|
||||
if (!templateSourceImportSelectedIds.value.length) {
|
||||
error.value = '아이템을 가져올 원본 템플릿을 하나 이상 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let stagedCount = 0
|
||||
for (const templateId of templateSourceImportSelectedIds.value) {
|
||||
const template = templates.value.find((entry) => entry.id === templateId)
|
||||
const data = await api.getTopic(templateId)
|
||||
const libraryItems = (data.items || []).map((item) => ({
|
||||
...item,
|
||||
sourceType: 'template',
|
||||
}))
|
||||
stagedCount += mergeLibraryItemsIntoDrafts(libraryItems, template?.name || data.topic?.name || templateId)
|
||||
}
|
||||
closeTemplateSourceImportModal()
|
||||
success.value = stagedCount
|
||||
? `${stagedCount}개의 기존 템플릿 아이템을 초안 목록에 추가했어요. 필요 없는 항목은 제외한 뒤 저장하면 됩니다.`
|
||||
: '추가로 가져올 새 아이템이 없었어요.'
|
||||
} catch (e) {
|
||||
error.value = '기존 템플릿 아이템을 가져오지 못했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmTierListImport() {
|
||||
resetMessages()
|
||||
if (!importModalTierList.value || !importModalItems.value.length) {
|
||||
@@ -1837,6 +2184,9 @@ function openUserProfile(user) {
|
||||
:staged-request-draft-count="stagedRequestDraftCount"
|
||||
:applied-request-item-count="appliedRequestItemCount"
|
||||
:open-template-create-modal="openTemplateCreateModal"
|
||||
:open-template-source-import-modal="openTemplateSourceImportModal"
|
||||
:open-template-library-item-modal="openTemplateLibraryItemModal"
|
||||
:open-template-bulk-tag-modal="openTemplateBulkTagModal"
|
||||
:is-template-loading="isTemplateLoading"
|
||||
:has-selected-template="hasSelectedTemplate"
|
||||
:selected-template="selectedTemplate"
|
||||
@@ -1846,6 +2196,7 @@ function openUserProfile(user) {
|
||||
:template-meta-saving="templateMetaSaving"
|
||||
:can-save-template-meta="canSaveTemplateMeta"
|
||||
:save-template-meta="saveTemplateMeta"
|
||||
:can-bulk-tag-template-items="canBulkTagTemplateItems"
|
||||
:can-apply-thumbnail="canApplyThumbnail"
|
||||
:template-visibility-saving="templateVisibilitySaving"
|
||||
:thumb-file-input-ref="setThumbFileInputRef"
|
||||
@@ -2079,6 +2430,114 @@ function openUserProfile(user) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="templateSourceImportModalOpen" class="modalOverlay" @click.self="closeTemplateSourceImportModal">
|
||||
<div class="modalCard modalCard--import" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">기존 템플릿 아이템 가져오기</div>
|
||||
<div class="modalCard__desc">
|
||||
여러 템플릿의 기본 아이템을 초안 목록으로 모아둘 수 있어요. 이후 필요 없는 항목은 제외하고 현재 템플릿에만 저장하면 됩니다.
|
||||
</div>
|
||||
|
||||
<div class="modalCard__form">
|
||||
<input v-model="templateSourceImportQuery" class="input" placeholder="템플릿 이름 또는 slug 검색" />
|
||||
</div>
|
||||
|
||||
<div class="templateImportList">
|
||||
<button
|
||||
v-for="template in filteredTemplateSourceImportTemplates"
|
||||
:key="template.id"
|
||||
type="button"
|
||||
class="adminTemplatePicker__item"
|
||||
:class="{ 'adminTemplatePicker__item--active': templateSourceImportSelectedIds.includes(template.id) }"
|
||||
@click="toggleTemplateSourceImportSelection(template.id)"
|
||||
>
|
||||
<span class="adminTemplatePicker__name">{{ template.name }}</span>
|
||||
<span class="adminTemplatePicker__meta">{{ template.slug || template.id }}</span>
|
||||
</button>
|
||||
<div v-if="!filteredTemplateSourceImportTemplates.length" class="hint">조건에 맞는 템플릿이 없어요.</div>
|
||||
</div>
|
||||
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeTemplateSourceImportModal">취소</button>
|
||||
<button class="btn btn--primary" @click="confirmTemplateSourceImport">초안으로 가져오기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="templateLibraryItemModalOpen" class="modalOverlay" @click.self="closeTemplateLibraryItemModal">
|
||||
<div class="modalCard modalCard--import" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">개별 아이템 검색</div>
|
||||
<div class="modalCard__desc">
|
||||
이름, 파일명, 태그로 검색해서 현재 템플릿 초안 목록에 필요한 아이템만 모아둘 수 있어요.
|
||||
</div>
|
||||
|
||||
<div class="modalCard__form modalCard__form--search">
|
||||
<input
|
||||
v-model="templateLibraryItemQuery"
|
||||
class="input"
|
||||
placeholder="아이템 이름, 파일명, 태그 검색"
|
||||
@keydown.enter.prevent="searchTemplateLibraryItems"
|
||||
/>
|
||||
<button class="btn btn--ghost" type="button" @click="searchTemplateLibraryItems">
|
||||
{{ templateLibraryItemLoading ? '검색중...' : '검색' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="templateImportList">
|
||||
<div v-if="!templateLibraryItemQuery.trim() && !templateLibraryItemLoading" class="hint">
|
||||
검색 전에는 목록을 보여주지 않아요. 필요한 키워드로 직접 찾아서 추가해주세요.
|
||||
</div>
|
||||
<button
|
||||
v-for="item in templateLibraryItemResults"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="adminTemplatePicker__item"
|
||||
:class="{ 'adminTemplatePicker__item--active': templateLibraryItemSelectedIds.includes(item.id) }"
|
||||
@click="toggleTemplateLibraryItemSelection(item.id)"
|
||||
>
|
||||
<span class="customItemModal__replacementRow">
|
||||
<img class="customItemModal__replacementThumb" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<span class="customItemModal__replacementCopy">
|
||||
<span class="adminTemplatePicker__name">{{ item.label }}</span>
|
||||
<span class="adminTemplatePicker__meta">{{ item.sourceLabel }} · {{ item.ownerName || '알 수 없음' }}</span>
|
||||
<span v-if="item.tags?.length" class="adminTemplatePicker__meta">#{{ item.tags.join(' #') }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="templateLibraryItemQuery.trim() && !templateLibraryItemLoading && !templateLibraryItemResults.length" class="hint">
|
||||
조건에 맞는 아이템이 없어요.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeTemplateLibraryItemModal">취소</button>
|
||||
<button class="btn btn--primary" :disabled="!selectedTemplateLibraryItems.length" @click="confirmTemplateLibraryItemImport">
|
||||
초안으로 가져오기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="templateBulkTagModalOpen" class="modalOverlay" @click.self="closeTemplateBulkTagModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">기본 아이템 공통 태그</div>
|
||||
<div class="modalCard__desc">
|
||||
현재 템플릿의 기본 아이템 전체에 같은 태그를 한 번에 추가합니다. 이미 있는 태그는 중복 저장하지 않아요.
|
||||
</div>
|
||||
<div class="modalCard__form">
|
||||
<label class="field">
|
||||
<span class="field__label">추가할 태그</span>
|
||||
<TagBadgeInput v-model="templateBulkTagDrafts" placeholder="태그 입력 후 Enter" :disabled="templateBulkTagSaving" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" :disabled="templateBulkTagSaving" @click="closeTemplateBulkTagModal">취소</button>
|
||||
<button class="btn btn--primary" :disabled="templateBulkTagSaving || !templateBulkTagDrafts.length" @click="applyTemplateBulkTags">
|
||||
{{ templateBulkTagSaving ? '적용중...' : '공통 태그 추가' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="customItemModalOpen" class="modalOverlay" @click.self="closeCustomItemModal">
|
||||
<div class="modalCard modalCard--customItem" role="dialog" aria-modal="true">
|
||||
<div v-if="modalTargetCustomItem" class="customItemModal">
|
||||
@@ -2094,7 +2553,6 @@ function openUserProfile(user) {
|
||||
</div>
|
||||
<div class="customItemModal__pickerActions">
|
||||
<button class="btn btn--ghost" type="button" @click="openTemplatePickerModal('custom-item-target')">템플릿 선택</button>
|
||||
<button class="btn btn--ghost btn--small customItemModal__createTemplateButton" type="button" @click="openTemplateCreateModal">새 템플릿 만들기</button>
|
||||
</div>
|
||||
<template v-if="canReplaceModalTarget">
|
||||
<div class="customItemModal__pickerHead">
|
||||
@@ -2162,20 +2620,28 @@ function openUserProfile(user) {
|
||||
<span class="field__label">아이템 이름</span>
|
||||
<input v-model="customItemModalDraftLabel" class="field__input" type="text" maxlength="60" placeholder="아이템 이름" />
|
||||
</label>
|
||||
<button class="btn btn--ghost customItemModal__renameButton" type="button" :disabled="customItemModalLabelSaving || !customItemModalDraftLabel.trim() || customItemModalDraftLabel.trim() === modalTargetCustomItem.label" @click="saveCustomItemModalLabel">
|
||||
{{ customItemModalLabelSaving ? '저장중...' : '이름 저장' }}
|
||||
<label v-if="modalTargetCustomItem.sourceType !== 'asset'" class="field">
|
||||
<span class="field__label">내부 태그</span>
|
||||
<TagBadgeInput v-model="customItemModalDraftTags" placeholder="태그 입력 후 Enter" />
|
||||
</label>
|
||||
<button class="btn btn--ghost customItemModal__renameButton" type="button" :disabled="customItemModalLabelSaving || !customItemModalDraftLabel.trim() || (customItemModalDraftLabel.trim() === modalTargetCustomItem.label && (modalTargetCustomItem.sourceType === 'asset' || JSON.stringify(parseAdminTagsText(customItemModalDraftTags)) === JSON.stringify(modalTargetCustomItem.tags || [])))" @click="saveCustomItemModalLabel">
|
||||
{{ customItemModalLabelSaving ? '저장중...' : '메타 저장' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="customItemModal__metaList">
|
||||
<div class="customItemModal__metaRow"><span>파일</span><strong :title="modalTargetCustomItem.src.split('/').pop()">{{ modalTargetCustomItem.src.split('/').pop() }}</strong></div>
|
||||
<div class="customItemModal__metaRow"><span>업로더/출처</span><strong :title="modalTargetCustomItem.ownerName">{{ modalTargetCustomItem.ownerName }}</strong></div>
|
||||
<div class="customItemModal__metaRow"><span>태그</span><strong>{{ modalTargetCustomItem.tags?.length ? '#' + modalTargetCustomItem.tags.join(' #') : '없음' }}</strong></div>
|
||||
<div class="customItemModal__metaRow"><span>템플릿 연결</span><strong>{{ visibleLinkedTemplates.length }}개 템플릿</strong></div>
|
||||
<div class="customItemModal__metaRow"><span>등록일</span><strong>{{ fmt(modalTargetCustomItem.createdAt) }}</strong></div>
|
||||
</div>
|
||||
<div class="customItemModal__linked">
|
||||
<span class="customItemModal__label">이 이미지를 사용하는 템플릿</span>
|
||||
<div v-if="visibleLinkedTemplates.length" class="customItemModal__chips">
|
||||
<button v-for="template in visibleLinkedTemplates" :key="template.id" type="button" class="pill pill--link" @click="jumpToTemplateAdmin(template.id)">{{ template.name }}</button>
|
||||
<span v-for="template in visibleLinkedTemplates" :key="template.id" class="customItemModal__templateChip">
|
||||
<span>{{ template.name }}</span>
|
||||
<button class="customItemModal__templateChipRemove" type="button" @click="unlinkCustomItemTemplate(modalTargetCustomItem, template)">X</button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="hint hint--tight">아직 템플릿에 연결된 항목이 없어요.</div>
|
||||
</div>
|
||||
@@ -2200,7 +2666,10 @@ function openUserProfile(user) {
|
||||
<button v-if="canReplaceModalTarget" class="btn btn--primary customItemModal__action" :disabled="!customItemReplacementTargetId || customItemReplacementBusy" @click="replaceCustomItem(modalTargetCustomItem)">
|
||||
{{ customItemReplacementBusy ? '대체중...' : '선택한 이미지로 대체' }}
|
||||
</button>
|
||||
<button v-if="modalTargetCustomItem.canDelete" class="btn btn--danger customItemModal__action" :disabled="modalTargetCustomItem.sourceType === 'user' && (modalTargetCustomItem.usageCount > 0 || visibleLinkedTemplates.length > 0)" @click="openCustomItemDeleteModal(modalTargetCustomItem)">삭제</button>
|
||||
<button v-if="modalTargetCustomItem.replacedAt" class="btn btn--ghost customItemModal__action" :disabled="customItemReplacementBusy" @click="restoreCustomItem(modalTargetCustomItem)">
|
||||
{{ customItemReplacementBusy ? '복구중...' : '원래 이미지로 복구' }}
|
||||
</button>
|
||||
<button v-if="modalTargetCustomItem.canDelete" class="btn btn--danger customItemModal__action" :disabled="modalTargetCustomItem.sourceType === 'user' && !modalTargetCustomItem.replacedAt && (modalTargetCustomItem.usageCount > 0 || visibleLinkedTemplates.length > 0)" @click="openCustomItemDeleteModal(modalTargetCustomItem)">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2271,6 +2740,20 @@ function openUserProfile(user) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="templateItemDeleteModalOpen" class="modalOverlay" @click.self="closeTemplateItemDeleteModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">기본 아이템 삭제</div>
|
||||
<div class="modalCard__desc">
|
||||
<strong>{{ modalTargetTemplateItem?.label || '선택한 아이템' }}</strong>
|
||||
{{ ' ' }}{{ templateItemDeleteImpactText() }}
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeTemplateItemDeleteModal">취소</button>
|
||||
<button class="btn btn--danger" @click="confirmTemplateItemDelete">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="adminTierListManageModalOpen" class="modalOverlay" @click.self="closeAdminTierListManageModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">티어표 관리</div>
|
||||
@@ -2428,7 +2911,7 @@ function openUserProfile(user) {
|
||||
<div class="adminSidebar__label">Filters</div>
|
||||
<div class="adminSidebar__group">
|
||||
<div class="adminSidebar__inlineRow">
|
||||
<input v-model="customItemQuery" class="input" placeholder="파일명, 라벨, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
|
||||
<input v-model="customItemQuery" class="input" placeholder="파일명, 라벨, 태그, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
|
||||
<button class="btn btn--ghost" @click="submitCustomItemSearch">검색</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2442,14 +2925,23 @@ function openUserProfile(user) {
|
||||
<option value="library">아이템(템플릿 + 사용자)</option>
|
||||
<option value="template">템플릿 아이템</option>
|
||||
<option value="user">사용자 아이템</option>
|
||||
<option value="replaced-user">대체된 아이템</option>
|
||||
<option value="thumbnail">썸네일 이미지</option>
|
||||
<option value="avatar">프로필 이미지</option>
|
||||
<option value="unused-user">미사용 아이템</option>
|
||||
<option value="unused">미사용 이미지</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
|
||||
<button class="btn btn--danger" :disabled="customItemFilter !== 'unused-user' || !customItems.length" @click="removeUnusedCustomItems">미사용 아이템 일괄 삭제</button>
|
||||
<button
|
||||
v-if="customItemFilter === 'unused'"
|
||||
class="btn btn--danger"
|
||||
:disabled="!hasDeletableUnusedItems"
|
||||
@click="removeUnusedCustomItems"
|
||||
>
|
||||
미사용 이미지 일괄 삭제
|
||||
</button>
|
||||
<button v-else class="btn btn--ghost" @click="showUnusedCustomItems">미사용 이미지 확인</button>
|
||||
</div>
|
||||
<div class="adminSidebar__stats">
|
||||
<div class="sidebarStat">
|
||||
@@ -2806,6 +3298,16 @@ function openUserProfile(user) {
|
||||
opacity: 0.58;
|
||||
border-style: dashed;
|
||||
}
|
||||
.adminUiScope .templateImportList {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
.adminUiScope .modalCard__form--search {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
}
|
||||
.adminUiScope .adminTemplatePicker__name {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
@@ -3259,6 +3761,7 @@ function openUserProfile(user) {
|
||||
grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.adminUiScope .templateSettingsCard__media {
|
||||
min-width: 0;
|
||||
@@ -3267,6 +3770,7 @@ function openUserProfile(user) {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
align-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.adminUiScope .templateSettingsCard__meta {
|
||||
color: var(--theme-text-soft);
|
||||
@@ -3276,9 +3780,30 @@ function openUserProfile(user) {
|
||||
}
|
||||
.adminUiScope .templateSettingsCard__actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
/* flex-wrap: wrap; */
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.adminUiScope .templateSettingsCard__actions > .btn {
|
||||
flex: 0 0 auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
.adminUiScope .templateSettingsCard__actions > a.btn {
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
.adminUiScope .templateMetaForm,
|
||||
.adminUiScope .templateMetaField {
|
||||
min-width: 0;
|
||||
}
|
||||
.adminUiScope .templateMetaField .input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.adminUiScope .selectedThumb {
|
||||
width: min(100%, 256px);
|
||||
@@ -3506,6 +4031,7 @@ function openUserProfile(user) {
|
||||
gap: 12px;
|
||||
}
|
||||
.adminUiScope .thumbCard {
|
||||
position: relative;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 16px;
|
||||
background: var(--theme-surface-soft);
|
||||
@@ -3516,6 +4042,9 @@ function openUserProfile(user) {
|
||||
-webkit-user-drag: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.adminUiScope .thumbCard__media {
|
||||
position: relative;
|
||||
}
|
||||
.adminUiScope .thumbCard:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -3538,10 +4067,25 @@ function openUserProfile(user) {
|
||||
opacity: 0.9;
|
||||
word-break: break-word;
|
||||
}
|
||||
.adminUiScope .thumbCard__actions {
|
||||
margin-top: 10px;
|
||||
.adminUiScope .thumbCard__deleteBtn {
|
||||
position: absolute;
|
||||
top: -24px;
|
||||
right: -24px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(9, 13, 22, 0.82);
|
||||
color: var(--theme-text);
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
}
|
||||
.adminUiScope .thumbCard__deleteBtn:hover {
|
||||
background: rgba(190, 24, 24, 0.9);
|
||||
}
|
||||
.adminUiScope .thumbLabel--preview {
|
||||
text-align: center;
|
||||
@@ -3720,9 +4264,6 @@ function openUserProfile(user) {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.adminUiScope .customItemModal__createTemplateButton {
|
||||
justify-self: start;
|
||||
}
|
||||
.adminUiScope .customItemModal__body {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -3807,6 +4348,26 @@ function openUserProfile(user) {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.adminUiScope .customItemModal__templateChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 11px;
|
||||
border-radius: 999px;
|
||||
background: var(--theme-pill-bg);
|
||||
border: 1px solid var(--theme-border);
|
||||
font-size: 12px;
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.adminUiScope .customItemModal__templateChipRemove {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--theme-text-soft);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.adminUiScope .customItemModal__title {
|
||||
font-size: 19px;
|
||||
font-weight: 900;
|
||||
@@ -4714,6 +5275,13 @@ function openUserProfile(user) {
|
||||
.adminUiScope .customItemModal__actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.adminUiScope .modalCard__form--search {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.adminUiScope .templateSettingsCard__actions > .btn,
|
||||
.adminUiScope .templateSettingsCard__actions > a.btn {
|
||||
width: 100%;
|
||||
}
|
||||
.adminUiScope.adminSidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -90,13 +90,17 @@ let editorLoadToken = 0
|
||||
const boardEl = ref(null)
|
||||
const exportBoardEl = ref(null)
|
||||
const groupListEl = ref(null)
|
||||
const sidebarEl = ref(null)
|
||||
const poolEl = ref(null)
|
||||
const poolSearchEl = ref(null)
|
||||
const groupDropEls = ref({})
|
||||
const fileEl = ref(null)
|
||||
const thumbnailFileEl = ref(null)
|
||||
const groupSortable = ref(null)
|
||||
const poolSortable = ref(null)
|
||||
const dropSortables = ref([])
|
||||
const editorSidebarMaxHeight = ref('')
|
||||
let editorSidebarMeasureFrame = 0
|
||||
|
||||
const isNewTierList = computed(() => tierListId.value === 'new')
|
||||
const isOwnTierList = computed(() => !!auth.user && !!ownerId.value && ownerId.value === auth.user.id)
|
||||
@@ -359,6 +363,35 @@ function closeItemContextMenu() {
|
||||
}
|
||||
}
|
||||
|
||||
function scrollWorkspaceBodyToTop() {
|
||||
const workspaceBody = document.querySelector('.workspaceBody')
|
||||
workspaceBody?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
function updateEditorSidebarMaxHeight() {
|
||||
if (typeof window === 'undefined' || !sidebarEl.value) return
|
||||
const bottomGap = 14
|
||||
const stickyTop = 14
|
||||
const minHeight = 260
|
||||
const sidebarTop = Math.max(sidebarEl.value.getBoundingClientRect().top, stickyTop)
|
||||
const nextHeight = Math.max(minHeight, Math.floor(window.innerHeight - sidebarTop - bottomGap))
|
||||
editorSidebarMaxHeight.value = `${nextHeight}px`
|
||||
}
|
||||
|
||||
function scheduleEditorSidebarMeasure() {
|
||||
if (typeof window === 'undefined') return
|
||||
if (editorSidebarMeasureFrame) return
|
||||
editorSidebarMeasureFrame = window.requestAnimationFrame(() => {
|
||||
editorSidebarMeasureFrame = 0
|
||||
updateEditorSidebarMaxHeight()
|
||||
})
|
||||
}
|
||||
|
||||
function focusPoolSearch() {
|
||||
poolSearchEl.value?.focus()
|
||||
poolSearchEl.value?.select()
|
||||
}
|
||||
|
||||
function openItemContextMenu(itemId, event) {
|
||||
if (!canEdit.value || !itemId || !itemsById.value[itemId] || shouldIgnoreItemClick()) return
|
||||
selectedItemId.value = itemId
|
||||
@@ -473,6 +506,25 @@ function duplicateItemToPool() {
|
||||
toast.success('아이템 추가 완료')
|
||||
}
|
||||
|
||||
function canRemoveEditorItem(itemId) {
|
||||
return canEdit.value && itemsById.value[itemId]?.origin === 'custom'
|
||||
}
|
||||
|
||||
function deleteEditorItem(itemId) {
|
||||
if (!canRemoveEditorItem(itemId)) return
|
||||
const targetItem = itemsById.value[itemId]
|
||||
detachItemById(itemId)
|
||||
if (typeof targetItem?.src === 'string' && targetItem.src.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(targetItem.src)
|
||||
}
|
||||
const nextItems = { ...itemsById.value }
|
||||
delete nextItems[itemId]
|
||||
itemsById.value = nextItems
|
||||
if (selectedItemId.value === itemId) selectedItemId.value = ''
|
||||
if (itemContextMenu.value.itemId === itemId) closeItemContextMenu()
|
||||
toast.success('커스텀 이미지를 현재 티어표에서 제거했어요.')
|
||||
}
|
||||
|
||||
function handleGlobalContextMenu(event) {
|
||||
const target = event?.target
|
||||
if (target?.closest?.('[data-item-context-menu]')) {
|
||||
@@ -1331,6 +1383,7 @@ async function loadEditorState() {
|
||||
if (loadToken !== editorLoadToken) return
|
||||
|
||||
syncSavedEditorSnapshot()
|
||||
scheduleEditorSidebarMeasure()
|
||||
if (canEdit.value) {
|
||||
await initSortables()
|
||||
}
|
||||
@@ -1350,6 +1403,10 @@ onMounted(() => {
|
||||
window.addEventListener('contextmenu', handleGlobalContextMenu, true)
|
||||
window.addEventListener('blur', closeItemContextMenu)
|
||||
window.addEventListener('scroll', closeItemContextMenu, true)
|
||||
window.addEventListener('resize', scheduleEditorSidebarMeasure)
|
||||
window.addEventListener('scroll', scheduleEditorSidebarMeasure, true)
|
||||
window.addEventListener('tier-maker:focus-editor-item-search', focusPoolSearch)
|
||||
nextTick(() => scheduleEditorSidebarMeasure())
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -1358,6 +1415,13 @@ onUnmounted(() => {
|
||||
window.removeEventListener('contextmenu', handleGlobalContextMenu, true)
|
||||
window.removeEventListener('blur', closeItemContextMenu)
|
||||
window.removeEventListener('scroll', closeItemContextMenu, true)
|
||||
window.removeEventListener('resize', scheduleEditorSidebarMeasure)
|
||||
window.removeEventListener('scroll', scheduleEditorSidebarMeasure, true)
|
||||
window.removeEventListener('tier-maker:focus-editor-item-search', focusPoolSearch)
|
||||
if (editorSidebarMeasureFrame) {
|
||||
window.cancelAnimationFrame(editorSidebarMeasureFrame)
|
||||
editorSidebarMeasureFrame = 0
|
||||
}
|
||||
}
|
||||
if (thumbnailPreviewUrl.value) URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
||||
destroySortables()
|
||||
@@ -1563,7 +1627,15 @@ onUnmounted(() => {
|
||||
<div class="editorMain">
|
||||
<section class="head">
|
||||
<div class="editorMain__headCopy">
|
||||
<div class="editorMain__title">{{ templateName || templateId }}</div>
|
||||
<button
|
||||
class="editorMain__title editorMain__titleButton"
|
||||
type="button"
|
||||
title="본문을 화면 위로 이동"
|
||||
@click="scrollWorkspaceBodyToTop"
|
||||
@keydown.space.prevent="scrollWorkspaceBodyToTop"
|
||||
>
|
||||
{{ templateName || templateId }}
|
||||
</button>
|
||||
<div class="editorMain__subtitle">
|
||||
<template v-if="canEdit">
|
||||
행/열 이름과 순서를 바꾸고 아이템을 드래그해서 배치할 수 있어요.
|
||||
@@ -1684,6 +1756,16 @@ onUnmounted(() => {
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<button
|
||||
v-if="canRemoveEditorItem(id) && !isExporting"
|
||||
class="cellDeleteBtn"
|
||||
type="button"
|
||||
title="커스텀 이미지 제거"
|
||||
@pointerdown.stop
|
||||
@click.stop="deleteEditorItem(id)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1717,50 +1799,71 @@ onUnmounted(() => {
|
||||
<button class="btn btn--ghost btn--small dropzone__button" @click="openFile">파일 선택</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editorTips">
|
||||
<div class="editorTips__title">작업 팁</div>
|
||||
<ul class="editorTips__list">
|
||||
<li>마우스 오른쪽 클릭으로 아이템을 복수 사용하거나 커스텀 이미지를 빠르게 정리할 수 있어요.</li>
|
||||
<li>미사용 아이템은 미리보기와 이미지 저장 결과에 표시되지 않으니, 필요한 것만 골라 배치해도 괜찮아요.</li>
|
||||
<li>아이템이 많아 한 번에 보기 어렵다면 브라우저 확대/축소(`Ctrl +`, `Ctrl -`)로 화면 밀도를 조절해보세요.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="sidebar__titleRow">
|
||||
<div class="sidebar__title">아이템</div>
|
||||
<div class="sidebar__count">{{ visiblePoolCount }} / {{ pool.length }}</div>
|
||||
</div>
|
||||
<div class="sidebar__hint">
|
||||
{{ canEdit ? '아이템을 드래그하거나, 클릭으로 선택한 뒤 원하는 셀/풀을 클릭해서 옮길 수 있어요.' : '공개 티어표는 보기 전용입니다.' }}
|
||||
</div>
|
||||
<input
|
||||
v-model="poolSearchQuery"
|
||||
class="sidebar__search"
|
||||
type="text"
|
||||
maxlength="60"
|
||||
placeholder="아이템 이름 검색"
|
||||
/>
|
||||
<div
|
||||
ref="poolEl"
|
||||
class="pool"
|
||||
:class="{ 'pool--clickTarget': canEdit && !!selectedItemId }"
|
||||
data-list-type="pool"
|
||||
@click.self="moveSelectedItemToPool"
|
||||
>
|
||||
<div class="sidebarStickyFrame">
|
||||
<div ref="sidebarEl" class="sidebar" :style="{ '--editor-sidebar-max-height': editorSidebarMaxHeight || undefined }">
|
||||
<div class="sidebar__titleRow">
|
||||
<div class="sidebar__title">아이템</div>
|
||||
<div class="sidebar__count">{{ visiblePoolCount }} / {{ pool.length }}</div>
|
||||
</div>
|
||||
<div class="sidebar__hint">
|
||||
{{ canEdit ? '아이템을 드래그하거나, 클릭으로 선택한 뒤 원하는 셀/풀을 클릭해서 옮길 수 있어요.' : '공개 티어표는 보기 전용입니다.' }}
|
||||
</div>
|
||||
<input
|
||||
ref="poolSearchEl"
|
||||
v-model="poolSearchQuery"
|
||||
class="sidebar__search"
|
||||
type="text"
|
||||
maxlength="60"
|
||||
placeholder="아이템 이름 검색"
|
||||
/>
|
||||
<div
|
||||
v-for="id in pool"
|
||||
:key="id"
|
||||
class="poolItem"
|
||||
:class="{
|
||||
'poolItem--readonly': !canEdit,
|
||||
'poolItem--hidden': !isPoolItemVisible(id),
|
||||
'poolItem--selected': selectedItemId === id,
|
||||
}"
|
||||
:data-item-id="id"
|
||||
@click.stop="selectItemByClick(id)"
|
||||
ref="poolEl"
|
||||
class="pool"
|
||||
:class="{ 'pool--clickTarget': canEdit && !!selectedItemId }"
|
||||
data-list-type="pool"
|
||||
@click.self="moveSelectedItemToPool"
|
||||
>
|
||||
<img
|
||||
:src="resolveItemSrc(itemsById[id])"
|
||||
class="thumb"
|
||||
:alt="itemsById[id]?.label || id"
|
||||
draggable="false"
|
||||
/>
|
||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||
<div v-if="!canEdit" class="poolItem__state">미배치</div>
|
||||
<div
|
||||
v-for="id in pool"
|
||||
:key="id"
|
||||
class="poolItem"
|
||||
:class="{
|
||||
'poolItem--readonly': !canEdit,
|
||||
'poolItem--hidden': !isPoolItemVisible(id),
|
||||
'poolItem--selected': selectedItemId === id,
|
||||
}"
|
||||
:data-item-id="id"
|
||||
@click.stop="selectItemByClick(id)"
|
||||
>
|
||||
<img
|
||||
:src="resolveItemSrc(itemsById[id])"
|
||||
class="thumb"
|
||||
:alt="itemsById[id]?.label || id"
|
||||
draggable="false"
|
||||
/>
|
||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||
<button
|
||||
v-if="canRemoveEditorItem(id)"
|
||||
class="poolItem__deleteBtn"
|
||||
type="button"
|
||||
title="커스텀 이미지 제거"
|
||||
@pointerdown.stop
|
||||
@click.stop="deleteEditorItem(id)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
<div v-if="!canEdit" class="poolItem__state">미배치</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1777,6 +1880,9 @@ onUnmounted(() => {
|
||||
<button class="itemContextMenu__action" type="button" @click="duplicateItemToPool">
|
||||
아이템 복제
|
||||
</button>
|
||||
<button v-if="canRemoveEditorItem(itemContextMenu.itemId)" class="itemContextMenu__action itemContextMenu__action--danger" type="button" @click="deleteEditorItem(itemContextMenu.itemId)">
|
||||
이 커스텀 이미지 제거
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1918,12 +2024,37 @@ onUnmounted(() => {
|
||||
grid-template-columns: minmax(0, clamp(680px, 58vw, 960px)) minmax(280px, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.sidebarStickyFrame {
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 46px;
|
||||
}
|
||||
.editorMain__title {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.editorMain__titleButton {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.editorMain__titleButton:hover {
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
.editorMain__titleButton:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--theme-accent) 70%, white);
|
||||
outline-offset: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.editorMain__subtitle {
|
||||
color: var(--theme-text-soft);
|
||||
font-size: 13px;
|
||||
@@ -2694,6 +2825,8 @@ onUnmounted(() => {
|
||||
}
|
||||
.sidebar {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 94%, transparent), color-mix(in srgb, var(--theme-card-bg-hover) 88%, transparent));
|
||||
border-radius: 22px;
|
||||
@@ -2701,6 +2834,9 @@ onUnmounted(() => {
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
position: sticky;
|
||||
top: 14px;
|
||||
max-height: var(--editor-sidebar-max-height, calc(100dvh - 136px));
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.dropzone--board {
|
||||
@@ -3015,15 +3151,47 @@ onUnmounted(() => {
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
.pool {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
.pool::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.pool--clickTarget {
|
||||
cursor: copy;
|
||||
}
|
||||
.editorTips {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: color-mix(in srgb, var(--theme-card-bg) 82%, transparent);
|
||||
}
|
||||
.editorTips__title {
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
.editorTips__list {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 16px;
|
||||
color: var(--theme-text-faint);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.editorTips__list li + li {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.poolItem {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
@@ -3070,6 +3238,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 +3286,9 @@ onUnmounted(() => {
|
||||
.itemContextMenu__action:hover {
|
||||
background: var(--theme-pill-bg);
|
||||
}
|
||||
.itemContextMenu__action--danger {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
@@ -3164,8 +3352,14 @@ onUnmounted(() => {
|
||||
}
|
||||
.sidebar {
|
||||
position: static;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
.sidebarStickyFrame {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.pool {
|
||||
overflow: visible;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user