Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecd3e69b5a | |||
| 3dba9b0a4d | |||
| 56b0035a45 | |||
| 929ffb2ed6 | |||
| 08ec6f42d1 | |||
| 360ec5ac3d | |||
| 71a13488d9 | |||
| ba9ba8013b | |||
| da35351747 | |||
| 305160663d | |||
| 58b8df51ab | |||
| bdc7ee42e2 | |||
| fd3f61ca2b | |||
| 47638b8b3e | |||
| 632bebb8f9 | |||
| fe79c91e82 | |||
| 2d5506e35a | |||
| 8b3d469503 | |||
| a2fc8f8cd4 | |||
| b134431d91 | |||
| c7cafb87c3 | |||
| a716ee0062 | |||
| 7164d32ae8 | |||
| dddc29fd4b | |||
| 947837fe40 | |||
| 5ef833fde5 | |||
| 67e192b0e1 | |||
| 8ef011bfc8 | |||
| 9403e3698d | |||
| 5b15ec12fa | |||
| 28c6dafa02 | |||
| f98524390b | |||
| da37fe9fc9 | |||
| d09cd7e508 | |||
| 907ea75182 | |||
| 4285883e28 | |||
| b16aa046e7 |
@@ -14,6 +14,7 @@ const topicsRoutes = require('./src/routes/topics')
|
||||
const tierListsRoutes = require('./src/routes/tierlists')
|
||||
const usersRoutes = require('./src/routes/users')
|
||||
const adminRoutes = require('./src/routes/admin')
|
||||
const shareRoutes = require('./src/routes/share')
|
||||
|
||||
const app = express()
|
||||
const PORT = process.env.PORT ? Number(process.env.PORT) : 5179
|
||||
@@ -88,6 +89,7 @@ app.use('/api/topics', topicsRoutes)
|
||||
app.use('/api/tierlists', tierListsRoutes)
|
||||
app.use('/api/users', usersRoutes)
|
||||
app.use('/api/admin', adminRoutes)
|
||||
app.use('/share', shareRoutes)
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[backend] listening on http://localhost:${PORT}`)
|
||||
|
||||
@@ -32,6 +32,21 @@ function serializeJson(value) {
|
||||
return JSON.stringify(value || [])
|
||||
}
|
||||
|
||||
function normalizeTags(tags) {
|
||||
const values = Array.isArray(tags)
|
||||
? tags
|
||||
: typeof tags === 'string'
|
||||
? tags.split(',')
|
||||
: []
|
||||
return Array.from(
|
||||
new Set(
|
||||
values
|
||||
.map((tag) => String(tag || '').trim().replace(/^#/, '').slice(0, 40))
|
||||
.filter(Boolean)
|
||||
)
|
||||
).slice(0, 30)
|
||||
}
|
||||
|
||||
function normalizeTopicSlug(value) {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
@@ -100,6 +115,7 @@ function mapTopicRow(row) {
|
||||
isPublic: row.is_public == null ? true : !!row.is_public,
|
||||
displayRank: row.display_rank == null ? null : Number(row.display_rank),
|
||||
createdAt: Number(row.created_at),
|
||||
tags: normalizeTags(parseJson(row.tags_json, [])),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,9 +128,57 @@ function mapTopicItemRow(row) {
|
||||
label: row.label,
|
||||
displayOrder: row.display_order == null ? null : Number(row.display_order),
|
||||
createdAt: Number(row.created_at),
|
||||
tags: normalizeTags(parseJson(row.tags_json, [])),
|
||||
}
|
||||
}
|
||||
|
||||
function mapCustomItemRow(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
ownerId: row.owner_id,
|
||||
src: row.src,
|
||||
label: row.label,
|
||||
createdAt: Number(row.created_at),
|
||||
replacedByItemId: row.replaced_by_item_id || '',
|
||||
replacedBySrc: row.replaced_by_src || '',
|
||||
replacedByLabel: row.replaced_by_label || '',
|
||||
replacedAt: Number(row.replaced_at || 0),
|
||||
tags: normalizeTags(parseJson(row.tags_json, [])),
|
||||
}
|
||||
}
|
||||
|
||||
function getSharedItemDisplayPriority(item) {
|
||||
if (!item) return 99
|
||||
if (item.sourceType === 'user' && !item.replacedAt) return 0
|
||||
if (item.sourceType === 'user') return 1
|
||||
if (item.sourceType === 'template') return 2
|
||||
if (item.sourceType === 'asset' || item.isAssetLibraryItem) return 3
|
||||
return 4
|
||||
}
|
||||
|
||||
function collapseSharedLibraryItems(items) {
|
||||
const grouped = new Map()
|
||||
for (const item of items || []) {
|
||||
const key = String(item?.src || '').trim()
|
||||
if (!key) continue
|
||||
if (!grouped.has(key)) grouped.set(key, [])
|
||||
grouped.get(key).push(item)
|
||||
}
|
||||
|
||||
return Array.from(grouped.values())
|
||||
.map((group) =>
|
||||
group
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const priorityDiff = getSharedItemDisplayPriority(a) - getSharedItemDisplayPriority(b)
|
||||
if (priorityDiff !== 0) return priorityDiff
|
||||
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
|
||||
})[0]
|
||||
)
|
||||
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
|
||||
}
|
||||
|
||||
function mapImageAssetRow(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
@@ -334,6 +398,7 @@ async function ensureSchema() {
|
||||
slug VARCHAR(120) NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
|
||||
tags_json LONGTEXT NULL,
|
||||
is_public TINYINT(1) NOT NULL DEFAULT 1,
|
||||
display_rank INT NULL DEFAULT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
@@ -347,6 +412,7 @@ async function ensureSchema() {
|
||||
topic_id VARCHAR(120) NOT NULL,
|
||||
src VARCHAR(255) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
tags_json LONGTEXT NULL,
|
||||
display_order INT NULL DEFAULT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
INDEX idx_topic_items_topic_id (topic_id),
|
||||
@@ -360,12 +426,25 @@ async function ensureSchema() {
|
||||
owner_id VARCHAR(64) NOT NULL,
|
||||
src VARCHAR(255) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
tags_json LONGTEXT NULL,
|
||||
replaced_by_item_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
replaced_by_src VARCHAR(255) NOT NULL DEFAULT '',
|
||||
replaced_by_label VARCHAR(120) NOT NULL DEFAULT '',
|
||||
replaced_at BIGINT NOT NULL DEFAULT 0,
|
||||
created_at BIGINT NOT NULL,
|
||||
INDEX idx_custom_items_owner_id (owner_id),
|
||||
CONSTRAINT fk_custom_items_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`ALTER TABLE topics ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER thumbnail_src`)
|
||||
await query(`ALTER TABLE topic_items ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER label`)
|
||||
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER label`)
|
||||
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_item_id VARCHAR(64) NOT NULL DEFAULT '' AFTER label`)
|
||||
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_src VARCHAR(255) NOT NULL DEFAULT '' AFTER replaced_by_item_id`)
|
||||
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_label VARCHAR(120) NOT NULL DEFAULT '' AFTER replaced_by_src`)
|
||||
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_at BIGINT NOT NULL DEFAULT 0 AFTER replaced_by_label`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS tierlists (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
@@ -493,11 +572,11 @@ async function ensureSchema() {
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO topics (id, slug, name, thumbnail_src, is_public, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE slug = VALUES(slug), name = VALUES(name), is_public = VALUES(is_public)
|
||||
INSERT INTO topics (id, slug, name, thumbnail_src, tags_json, is_public, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE slug = VALUES(slug), name = VALUES(name), tags_json = VALUES(tags_json), is_public = VALUES(is_public)
|
||||
`,
|
||||
[FREEFORM_TOPIC_ID, FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', 1, now()]
|
||||
[FREEFORM_TOPIC_ID, FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', '[]', 1, now()]
|
||||
)
|
||||
})()
|
||||
|
||||
@@ -810,7 +889,7 @@ async function listTopics(currentUserId = '', options = {}) {
|
||||
const includePrivate = !!options.includePrivate
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at
|
||||
SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at
|
||||
FROM topics
|
||||
WHERE id <> ?
|
||||
${includePrivate ? '' : 'AND is_public = 1'}
|
||||
@@ -835,7 +914,7 @@ async function listTopics(currentUserId = '', options = {}) {
|
||||
|
||||
async function findTopicById(id) {
|
||||
const rows = await query(
|
||||
'SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1',
|
||||
'SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1',
|
||||
[id]
|
||||
)
|
||||
return mapTopicRow(rows[0])
|
||||
@@ -845,7 +924,7 @@ async function findTopicBySlug(slug) {
|
||||
const normalizedSlug = normalizeTopicSlug(slug)
|
||||
if (!normalizedSlug) return null
|
||||
const rows = await query(
|
||||
'SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE slug = ? LIMIT 1',
|
||||
'SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at FROM topics WHERE slug = ? LIMIT 1',
|
||||
[normalizedSlug]
|
||||
)
|
||||
return mapTopicRow(rows[0])
|
||||
@@ -858,7 +937,7 @@ async function findTopicByIdentifier(topicRef) {
|
||||
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at
|
||||
SELECT id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at
|
||||
FROM topics
|
||||
WHERE id = ? OR slug = ?
|
||||
ORDER BY
|
||||
@@ -873,7 +952,7 @@ async function findTopicByIdentifier(topicRef) {
|
||||
async function listTopicItems(topicId) {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT id, topic_id, src, label, display_order, created_at
|
||||
SELECT id, topic_id, src, label, tags_json, display_order, created_at
|
||||
FROM topic_items
|
||||
WHERE topic_id = ?
|
||||
ORDER BY
|
||||
@@ -888,7 +967,7 @@ async function listTopicItems(topicId) {
|
||||
}
|
||||
|
||||
async function findTopicItemById(itemId) {
|
||||
const rows = await query('SELECT id, topic_id, src, label, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
|
||||
const rows = await query('SELECT id, topic_id, src, label, tags_json, display_order, created_at FROM topic_items WHERE id = ? LIMIT 1', [itemId])
|
||||
return mapTopicItemRow(rows[0])
|
||||
}
|
||||
|
||||
@@ -899,14 +978,15 @@ async function getTopicDetail(topicRef) {
|
||||
return { topic, template: topic, items }
|
||||
}
|
||||
|
||||
async function createTopic({ slug, name, isPublic = true }) {
|
||||
async function createTopic({ slug, name, tags = [], isPublic = true }) {
|
||||
const topicId = nanoid()
|
||||
const topicSlug = assertTopicSlug(slug)
|
||||
await query('INSERT INTO topics (id, slug, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [
|
||||
await query('INSERT INTO topics (id, slug, name, thumbnail_src, tags_json, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [
|
||||
topicId,
|
||||
topicSlug,
|
||||
name,
|
||||
'',
|
||||
serializeJson(normalizeTags(tags)),
|
||||
isPublic ? 1 : 0,
|
||||
null,
|
||||
now(),
|
||||
@@ -914,11 +994,12 @@ async function createTopic({ slug, name, isPublic = true }) {
|
||||
return findTopicById(topicId)
|
||||
}
|
||||
|
||||
async function updateTopicMeta(topicId, { slug, name, isPublic }) {
|
||||
async function updateTopicMeta(topicId, { slug, name, tags = [], isPublic }) {
|
||||
const topicSlug = assertTopicSlug(slug)
|
||||
await query('UPDATE topics SET slug = ?, name = ?, is_public = ? WHERE id = ?', [
|
||||
await query('UPDATE topics SET slug = ?, name = ?, tags_json = ?, is_public = ? WHERE id = ?', [
|
||||
topicSlug,
|
||||
name,
|
||||
serializeJson(normalizeTags(tags)),
|
||||
isPublic ? 1 : 0,
|
||||
topicId,
|
||||
])
|
||||
@@ -1114,24 +1195,50 @@ async function listReferencedUploadUsage() {
|
||||
.sort((a, b) => a.src.localeCompare(b.src))
|
||||
}
|
||||
|
||||
function replaceItemSrc(items, fromSrc, toSrc) {
|
||||
function replaceItemSrc(items, fromSrc, toSrc, toLabel = '') {
|
||||
let changed = false
|
||||
const nextItems = (items || []).map((item) => {
|
||||
if (item?.src !== fromSrc) return item
|
||||
changed = true
|
||||
return { ...item, src: toSrc }
|
||||
return {
|
||||
...item,
|
||||
src: toSrc,
|
||||
...(typeof toLabel === 'string' && toLabel.trim() ? { label: toLabel.trim().slice(0, 60) } : {}),
|
||||
}
|
||||
})
|
||||
return { changed, items: nextItems }
|
||||
}
|
||||
|
||||
async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
|
||||
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) : ''
|
||||
|
||||
const [userResult, topicResult, topicItemResult, customItemResult] = await Promise.all([
|
||||
query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]),
|
||||
query('UPDATE topics SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]),
|
||||
query('UPDATE topic_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
|
||||
query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
|
||||
normalizedLabel
|
||||
? query('UPDATE topic_items SET src = ?, label = ? WHERE src = ?', [toSrc, normalizedLabel, fromSrc])
|
||||
: query('UPDATE topic_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)
|
||||
@@ -1145,7 +1252,7 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
|
||||
changed = true
|
||||
}
|
||||
|
||||
const replacedPool = replaceItemSrc(parseJson(row.pool_json, []), fromSrc, toSrc)
|
||||
const replacedPool = replaceItemSrc(parseJson(row.pool_json, []), fromSrc, toSrc, normalizedLabel)
|
||||
if (replacedPool.changed) changed = true
|
||||
|
||||
if (changed) {
|
||||
@@ -1168,8 +1275,8 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
|
||||
changed = true
|
||||
}
|
||||
|
||||
const replacedItems = replaceItemSrc(parseJson(row.items_json, []), fromSrc, toSrc)
|
||||
const replacedBoardItems = replaceItemSrc(parseJson(row.board_items_json, []), fromSrc, toSrc)
|
||||
const replacedItems = replaceItemSrc(parseJson(row.items_json, []), fromSrc, toSrc, normalizedLabel)
|
||||
const replacedBoardItems = replaceItemSrc(parseJson(row.board_items_json, []), fromSrc, toSrc, normalizedLabel)
|
||||
if (replacedItems.changed || replacedBoardItems.changed) changed = true
|
||||
|
||||
if (changed) {
|
||||
@@ -1187,6 +1294,40 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
|
||||
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'
|
||||
@@ -1468,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])
|
||||
}
|
||||
|
||||
@@ -1509,7 +1661,7 @@ async function updateTopicItemDisplayOrder(topicId, itemIds) {
|
||||
async function updateCustomItemLabel(itemId, label) {
|
||||
await query('UPDATE custom_items SET label = ? WHERE id = ?', [label, itemId])
|
||||
const rows = await query(`
|
||||
SELECT c.id, c.owner_id, c.src, c.label, c.created_at, u.nickname, u.email
|
||||
SELECT c.id, c.owner_id, c.src, c.label, c.tags_json, c.replaced_by_item_id, c.replaced_by_src, c.replaced_by_label, c.replaced_at, c.created_at, u.nickname, u.email
|
||||
FROM custom_items c
|
||||
INNER JOIN users u ON u.id = c.owner_id
|
||||
WHERE c.id = ?
|
||||
@@ -1518,16 +1670,50 @@ async function updateCustomItemLabel(itemId, label) {
|
||||
const row = rows[0]
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
ownerId: row.owner_id,
|
||||
src: row.src,
|
||||
label: row.label,
|
||||
createdAt: Number(row.created_at),
|
||||
...mapCustomItemRow(row),
|
||||
ownerName: row.nickname || row.email,
|
||||
ownerEmail: row.email,
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCustomItemMeta(itemId, { label, tags = [] }) {
|
||||
await query('UPDATE custom_items SET label = ?, tags_json = ? WHERE id = ?', [
|
||||
label,
|
||||
serializeJson(normalizeTags(tags)),
|
||||
itemId,
|
||||
])
|
||||
const rows = await query(`
|
||||
SELECT c.id, c.owner_id, c.src, c.label, c.tags_json, c.replaced_by_item_id, c.replaced_by_src, c.replaced_by_label, c.replaced_at, c.created_at, u.nickname, u.email
|
||||
FROM custom_items c
|
||||
INNER JOIN users u ON u.id = c.owner_id
|
||||
WHERE c.id = ?
|
||||
LIMIT 1
|
||||
`, [itemId])
|
||||
const row = rows[0]
|
||||
if (!row) return null
|
||||
return {
|
||||
...mapCustomItemRow(row),
|
||||
ownerName: row.nickname || row.email,
|
||||
ownerEmail: row.email,
|
||||
}
|
||||
}
|
||||
|
||||
async function markCustomItemReplaced({ itemId, replacedByItemId = '', replacedBySrc = '', replacedByLabel = '' }) {
|
||||
await query(
|
||||
'UPDATE custom_items SET replaced_by_item_id = ?, replaced_by_src = ?, replaced_by_label = ?, replaced_at = ? WHERE id = ?',
|
||||
[replacedByItemId || '', replacedBySrc || '', replacedByLabel || '', now(), itemId]
|
||||
)
|
||||
return findCustomItemById(itemId)
|
||||
}
|
||||
|
||||
async function clearCustomItemReplacement(itemId) {
|
||||
await query(
|
||||
'UPDATE custom_items SET replaced_by_item_id = ?, replaced_by_src = ?, replaced_by_label = ?, replaced_at = 0 WHERE id = ?',
|
||||
['', '', '', itemId]
|
||||
)
|
||||
return findCustomItemById(itemId)
|
||||
}
|
||||
|
||||
async function updateImageAssetLabel(assetId, label) {
|
||||
await query('UPDATE image_assets SET label_override = ? WHERE id = ?', [label, assetId])
|
||||
const rows = await query('SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id = ? LIMIT 1', [assetId])
|
||||
@@ -1584,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 }) {
|
||||
@@ -1617,7 +1804,7 @@ async function syncOwnedCustomItemLabels({ ownerId, items }) {
|
||||
async function findCustomItemById(id) {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT id, owner_id, src, label, created_at
|
||||
SELECT id, owner_id, src, label, tags_json, replaced_by_item_id, replaced_by_src, replaced_by_label, replaced_at, created_at
|
||||
FROM custom_items
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
@@ -1626,14 +1813,7 @@ async function findCustomItemById(id) {
|
||||
)
|
||||
|
||||
const row = rows[0]
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
ownerId: row.owner_id,
|
||||
src: row.src,
|
||||
label: row.label,
|
||||
createdAt: Number(row.created_at),
|
||||
}
|
||||
return mapCustomItemRow(row)
|
||||
}
|
||||
|
||||
async function getCustomItemUsageMeta() {
|
||||
@@ -1683,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()
|
||||
@@ -1698,15 +1878,20 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
c.owner_id,
|
||||
c.src,
|
||||
c.label,
|
||||
c.tags_json,
|
||||
c.replaced_by_item_id,
|
||||
c.replaced_by_src,
|
||||
c.replaced_by_label,
|
||||
c.replaced_at,
|
||||
c.created_at,
|
||||
u.nickname,
|
||||
u.email
|
||||
FROM custom_items c
|
||||
INNER JOIN users u ON u.id = c.owner_id
|
||||
${hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''}
|
||||
${hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR c.tags_json LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''}
|
||||
ORDER BY c.created_at DESC
|
||||
`,
|
||||
hasQuery ? [search, search, search, search] : []
|
||||
hasQuery ? [search, search, search, search, search] : []
|
||||
),
|
||||
query(
|
||||
`
|
||||
@@ -1715,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(
|
||||
`
|
||||
@@ -1777,17 +1963,14 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
const linkedTemplates = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values())
|
||||
return {
|
||||
id: row.id,
|
||||
ownerId: row.owner_id,
|
||||
src: row.src,
|
||||
label: row.label,
|
||||
createdAt: Number(row.created_at),
|
||||
...mapCustomItemRow(row),
|
||||
ownerName: row.nickname || row.email,
|
||||
ownerEmail: row.email,
|
||||
usageCount: usageMeta.usageMap.get(row.id) || 0,
|
||||
linkedTemplates,
|
||||
assetKind: resolveLibraryAssetKind(row.src),
|
||||
sourceType: 'user',
|
||||
sourceLabel: '사용자 아이템',
|
||||
sourceLabel: Number(row.replaced_at || 0) > 0 ? '대체된 사용자 아이템' : '사용자 아이템',
|
||||
canDelete: true,
|
||||
}
|
||||
})
|
||||
@@ -1795,15 +1978,20 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
const templateSrcSet = new Set(topicItemRows.map((row) => row.src).filter(Boolean))
|
||||
const customSrcSet = new Set(customRows.map((row) => row.src).filter(Boolean))
|
||||
const assetLibraryItems = assetRows
|
||||
.filter((row) => row?.src && !templateSrcSet.has(row.src) && !customSrcSet.has(row.src))
|
||||
.filter((row) => {
|
||||
if (!row?.src) return false
|
||||
if (avatarSrcSet.has(row.src) || thumbnailSrcSet.has(row.src)) return true
|
||||
return !templateSrcSet.has(row.src) && !customSrcSet.has(row.src)
|
||||
})
|
||||
.map((row) => ({
|
||||
id: `asset:${row.id}`,
|
||||
assetId: row.id,
|
||||
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: [],
|
||||
@@ -1821,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: '',
|
||||
@@ -1882,6 +2071,11 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
usageCount: entry.usageCount || 0,
|
||||
linkedTemplates: entry.linkedTemplates || [],
|
||||
isAssetLibraryItem: !!entry.isAssetLibraryItem,
|
||||
tags: entry.tags || [],
|
||||
replacedByItemId: entry.replacedByItemId || '',
|
||||
replacedBySrc: entry.replacedBySrc || '',
|
||||
replacedByLabel: entry.replacedByLabel || '',
|
||||
replacedAt: entry.replacedAt || 0,
|
||||
})),
|
||||
}
|
||||
})
|
||||
@@ -1899,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:
|
||||
@@ -1909,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,
|
||||
@@ -1924,40 +2123,59 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
async function findUnusedCustomItems({ queryText = '' } = {}) {
|
||||
const hasQuery = !!(queryText || '').trim()
|
||||
const search = `%${(queryText || '').trim()}%`
|
||||
const whereClause = hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''
|
||||
const params = hasQuery ? [search, search, search, search] : []
|
||||
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT
|
||||
c.id,
|
||||
c.owner_id,
|
||||
c.src,
|
||||
c.label,
|
||||
c.created_at,
|
||||
u.nickname,
|
||||
u.email
|
||||
FROM custom_items c
|
||||
INNER JOIN users u ON u.id = c.owner_id
|
||||
${whereClause}
|
||||
ORDER BY c.created_at DESC
|
||||
`,
|
||||
params
|
||||
)
|
||||
const [rows, topicItemRows, usageMeta] = await Promise.all([
|
||||
query(
|
||||
`
|
||||
SELECT
|
||||
c.id,
|
||||
c.owner_id,
|
||||
c.src,
|
||||
c.label,
|
||||
c.tags_json,
|
||||
c.replaced_by_item_id,
|
||||
c.replaced_by_src,
|
||||
c.replaced_by_label,
|
||||
c.replaced_at,
|
||||
c.created_at,
|
||||
u.nickname,
|
||||
u.email
|
||||
FROM custom_items c
|
||||
INNER JOIN users u ON u.id = c.owner_id
|
||||
${hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR c.tags_json LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''}
|
||||
ORDER BY c.created_at DESC
|
||||
`,
|
||||
hasQuery ? [search, search, search, search, search] : []
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT ti.topic_id, tp.name AS topic_name, ti.src
|
||||
FROM topic_items ti
|
||||
LEFT JOIN topics tp ON tp.id = ti.topic_id
|
||||
`
|
||||
),
|
||||
getCustomItemUsageMeta(),
|
||||
])
|
||||
|
||||
const templateLinkedBySrc = new Map()
|
||||
topicItemRows.forEach((row) => {
|
||||
if (!row?.src) return
|
||||
if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map())
|
||||
templateLinkedBySrc.get(row.src).set(row.topic_id, {
|
||||
id: row.topic_id,
|
||||
name: row.topic_name || row.topic_id,
|
||||
})
|
||||
})
|
||||
|
||||
const { usageMap } = await getCustomItemUsageMeta()
|
||||
return rows
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
ownerId: row.owner_id,
|
||||
src: row.src,
|
||||
label: row.label,
|
||||
createdAt: Number(row.created_at),
|
||||
...mapCustomItemRow(row),
|
||||
ownerName: row.nickname || row.email,
|
||||
ownerEmail: row.email,
|
||||
usageCount: usageMap.get(row.id) || 0,
|
||||
usageCount: usageMeta.usageMap.get(row.id) || 0,
|
||||
linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
|
||||
}))
|
||||
.filter((item) => item.usageCount === 0)
|
||||
.filter((item) => ((item.usageCount === 0 && item.linkedTemplates.length === 0) || !!item.replacedAt))
|
||||
}
|
||||
|
||||
async function getFavoriteStatsForTierListIds(tierListIds, userId = '') {
|
||||
@@ -2882,20 +3100,14 @@ async function findCustomItemsByIds(ids) {
|
||||
const placeholders = ids.map(() => '?').join(', ')
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT id, owner_id, src, label, created_at
|
||||
SELECT id, owner_id, src, label, tags_json, replaced_by_item_id, replaced_by_src, replaced_by_label, replaced_at, created_at
|
||||
FROM custom_items
|
||||
WHERE id IN (${placeholders})
|
||||
`,
|
||||
ids
|
||||
)
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
ownerId: row.owner_id,
|
||||
src: row.src,
|
||||
label: row.label,
|
||||
createdAt: Number(row.created_at),
|
||||
}))
|
||||
return rows.map(mapCustomItemRow)
|
||||
}
|
||||
|
||||
async function deleteCustomItems(ids) {
|
||||
@@ -3040,17 +3252,22 @@ module.exports = {
|
||||
listReferencedUploadSources,
|
||||
listReferencedUploadUsage,
|
||||
replaceUploadSourceReferences,
|
||||
updateCustomItemDisplayReferences,
|
||||
clearImageOptimizationJobs,
|
||||
getImageAssetStats,
|
||||
cleanupMissingUploadReferences,
|
||||
createTopicItem,
|
||||
updateTopicItemLabel,
|
||||
updateTopicItemMeta,
|
||||
updateTopicItemDisplayOrder,
|
||||
countTierListsUsingTopicItem,
|
||||
deleteTopicItem,
|
||||
deleteTopic,
|
||||
updateTopicDisplayOrder,
|
||||
updateCustomItemLabel,
|
||||
updateCustomItemMeta,
|
||||
clearCustomItemReplacement,
|
||||
markCustomItemReplaced,
|
||||
updateImageAssetLabel,
|
||||
createCustomItem,
|
||||
findCustomItemById,
|
||||
|
||||
@@ -20,9 +20,11 @@ const {
|
||||
updateTopicThumbnail,
|
||||
createTopicItem,
|
||||
updateTopicItemLabel,
|
||||
updateTopicItemMeta,
|
||||
updateTopicItemDisplayOrder,
|
||||
countTierListsUsingTopicItem,
|
||||
updateCustomItemLabel,
|
||||
updateCustomItemMeta,
|
||||
updateImageAssetLabel,
|
||||
deleteTopicItem,
|
||||
deleteTopic,
|
||||
@@ -33,6 +35,7 @@ const {
|
||||
findUnusedCustomItems,
|
||||
findCustomItemsByIds,
|
||||
deleteCustomItems,
|
||||
markCustomItemReplaced,
|
||||
listUsers,
|
||||
findPrimaryAdminUser,
|
||||
listAdminTierLists,
|
||||
@@ -53,6 +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')
|
||||
@@ -94,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
|
||||
@@ -122,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(''),
|
||||
})
|
||||
@@ -131,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' })
|
||||
@@ -149,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)
|
||||
@@ -164,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)
|
||||
@@ -304,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 })
|
||||
})
|
||||
@@ -327,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)
|
||||
@@ -340,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 })
|
||||
})
|
||||
@@ -355,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'),
|
||||
})
|
||||
@@ -368,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)
|
||||
})
|
||||
@@ -542,15 +586,77 @@ 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 || [],
|
||||
})
|
||||
}
|
||||
|
||||
async function findLibraryItemForReplacement(itemId, sourceType = '') {
|
||||
const normalizedId = String(itemId || '').trim()
|
||||
const normalizedSourceType = String(sourceType || '').trim()
|
||||
if (!normalizedId) return null
|
||||
|
||||
if (normalizedId.startsWith('asset:') || normalizedSourceType === 'asset') {
|
||||
const assetId = normalizedId.startsWith('asset:') ? normalizedId.slice(6) : normalizedId
|
||||
const asset = await findImageAssetById(assetId)
|
||||
if (!asset) return null
|
||||
return {
|
||||
id: `asset:${asset.id}`,
|
||||
sourceType: 'asset',
|
||||
src: asset.src || '',
|
||||
label: asset.labelOverride || buildItemLabelFromSrc(asset.src),
|
||||
tags: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedSourceType === 'template') {
|
||||
const item = await findTopicItemById(normalizedId)
|
||||
if (!item) return null
|
||||
return {
|
||||
id: item.id,
|
||||
sourceType: 'template',
|
||||
src: item.src || '',
|
||||
label: item.label || buildItemLabelFromSrc(item.src),
|
||||
tags: item.tags || [],
|
||||
}
|
||||
}
|
||||
|
||||
const customItem = await findCustomItemById(normalizedId)
|
||||
if (customItem) {
|
||||
return {
|
||||
id: customItem.id,
|
||||
sourceType: 'user',
|
||||
src: customItem.src || '',
|
||||
label: customItem.label || buildItemLabelFromSrc(customItem.src),
|
||||
tags: customItem.tags || [],
|
||||
}
|
||||
}
|
||||
|
||||
const templateItem = await findTopicItemById(normalizedId)
|
||||
if (templateItem) {
|
||||
return {
|
||||
id: templateItem.id,
|
||||
sourceType: 'template',
|
||||
src: templateItem.src || '',
|
||||
label: templateItem.label || buildItemLabelFromSrc(templateItem.src),
|
||||
tags: templateItem.tags || [],
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function copyUploadIntoTopicAsset(src) {
|
||||
if (typeof src !== 'string') return ''
|
||||
const raw = src.trim()
|
||||
@@ -721,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' })
|
||||
})
|
||||
|
||||
@@ -760,6 +867,90 @@ 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),
|
||||
targetSourceType: z.enum(['template', 'user', 'asset']).optional().default('user'),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const sourceItem = await findLibraryItemForReplacement(req.params.itemId)
|
||||
if (!sourceItem?.src) return res.status(404).json({ error: 'source_not_found' })
|
||||
if (sourceItem.sourceType !== 'user') return res.status(400).json({ error: 'user_item_required' })
|
||||
|
||||
const targetItem = await findLibraryItemForReplacement(parsed.data.targetItemId, parsed.data.targetSourceType)
|
||||
if (!targetItem?.src) return res.status(404).json({ error: 'target_not_found' })
|
||||
if (sourceItem.src === targetItem.src && (sourceItem.label || '') === (targetItem.label || '')) {
|
||||
return res.status(409).json({ error: 'same_target' })
|
||||
}
|
||||
|
||||
const result = await replaceUploadSourceReferences({
|
||||
fromSrc: sourceItem.src,
|
||||
toSrc: targetItem.src,
|
||||
toLabel: targetItem.label || '',
|
||||
updateCustomItemsBySrc: false,
|
||||
})
|
||||
const displayResult = await updateCustomItemDisplayReferences({
|
||||
itemId: sourceItem.id,
|
||||
src: targetItem.src,
|
||||
label: targetItem.label || '',
|
||||
})
|
||||
await markCustomItemReplaced({
|
||||
itemId: sourceItem.id,
|
||||
replacedByItemId: targetItem.id || '',
|
||||
replacedBySrc: targetItem.src || '',
|
||||
replacedByLabel: targetItem.label || '',
|
||||
})
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
updatedRows: (result.updatedRows || 0) + (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),
|
||||
@@ -1014,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) => {
|
||||
|
||||
75
backend/src/routes/share.js
Normal file
75
backend/src/routes/share.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const express = require('express')
|
||||
const { findTierListById } = require('../db')
|
||||
|
||||
const router = express.Router()
|
||||
const APP_ORIGIN = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '')
|
||||
const DEFAULT_TITLE = 'Tier Maker | 템플릿으로 쉽게 만드는 티어표'
|
||||
const DEFAULT_DESCRIPTION = '템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요.'
|
||||
const DEFAULT_IMAGE_URL = `${APP_ORIGIN}/og-card.png`
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function toAbsoluteUrl(pathname) {
|
||||
const src = String(pathname || '').trim()
|
||||
if (!src) return DEFAULT_IMAGE_URL
|
||||
if (/^https?:\/\//i.test(src)) return src
|
||||
return `${APP_ORIGIN}${src.startsWith('/') ? src : `/${src}`}`
|
||||
}
|
||||
|
||||
function buildShareHtml({ title, description, imageUrl, shareUrl, appUrl }) {
|
||||
const safeTitle = escapeHtml(title || DEFAULT_TITLE)
|
||||
const safeDescription = escapeHtml(description || DEFAULT_DESCRIPTION)
|
||||
const safeImageUrl = escapeHtml(imageUrl || DEFAULT_IMAGE_URL)
|
||||
const safeShareUrl = escapeHtml(shareUrl || APP_ORIGIN)
|
||||
const safeAppUrl = escapeHtml(appUrl || APP_ORIGIN)
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${safeTitle}</title>
|
||||
<meta name="description" content="${safeDescription}" />
|
||||
<link rel="canonical" href="${safeAppUrl}" />
|
||||
<meta property="og:site_name" content="Tier Maker" />
|
||||
<meta property="og:locale" content="ko_KR" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="${safeShareUrl}" />
|
||||
<meta property="og:title" content="${safeTitle}" />
|
||||
<meta property="og:description" content="${safeDescription}" />
|
||||
<meta property="og:image" content="${safeImageUrl}" />
|
||||
<meta property="og:image:alt" content="${safeTitle}" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="${safeTitle}" />
|
||||
<meta name="twitter:description" content="${safeDescription}" />
|
||||
<meta name="twitter:image" content="${safeImageUrl}" />
|
||||
<meta http-equiv="refresh" content="0; url=${safeAppUrl}" />
|
||||
</head>
|
||||
<body>
|
||||
<script>window.location.replace(${JSON.stringify(appUrl || APP_ORIGIN)})</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
router.get('/editor/:topicId/:tierListId', async (req, res) => {
|
||||
const { topicId, tierListId } = req.params
|
||||
const appUrl = `${APP_ORIGIN}/editor/${encodeURIComponent(topicId)}/${encodeURIComponent(tierListId)}?preview=1`
|
||||
const shareUrl = `${APP_ORIGIN}${req.originalUrl || `/share/editor/${encodeURIComponent(topicId)}/${encodeURIComponent(tierListId)}`}`
|
||||
|
||||
const tierList = await findTierListById(tierListId)
|
||||
const isPublicMatch = tierList?.isPublic && (tierList.topicSlug === topicId || tierList.topicId === topicId)
|
||||
const title = isPublicMatch ? tierList.title : DEFAULT_TITLE
|
||||
const description = isPublicMatch && tierList.description ? tierList.description : DEFAULT_DESCRIPTION
|
||||
const imageUrl = isPublicMatch ? toAbsoluteUrl(tierList.thumbnailSrc) : DEFAULT_IMAGE_URL
|
||||
|
||||
res.type('html').send(buildShareHtml({ title, description, imageUrl, shareUrl, appUrl }))
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
146
docs/history.md
146
docs/history.md
@@ -1,5 +1,147 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 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
|
||||
- 이미지 대체 직후 원본 사용자 아이템을 완전히 지워버리면 관리자 입장에서는 “왜 바꿨는지”, “나중에 정리해도 되는지”를 다시 확인할 근거가 사라지므로, 참조 이동과 원본 보존을 분리하는 편이 운영 흐름에 더 맞다고 판단했다.
|
||||
- 다만 대체 완료된 원본까지 별도 보호 대상으로 빼면 라이브러리 정리가 끝없이 쌓일 수 있으므로, 원본은 `대체됨` 상태로 계속 보이게 하되 이미 미사용인 이상 `미사용 아이템 일괄 삭제`와 개별 삭제로 언제든 정리할 수 있게 두는 쪽이 균형이 맞는다고 정리했다.
|
||||
|
||||
## 2026-04-06 v1.4.79
|
||||
- 관리자 대체 모달은 열자마자 현재 라이브러리 결과를 그대로 쏟아내면 “같은 보드 안의 비슷한 항목을 고르는 화면”처럼 읽히기 쉬우므로, 검색 전에는 후보를 비워 두고 운영자가 의도적으로 찾은 뒤 고르는 방식이 더 분명하다고 판단했다.
|
||||
- 또 사용자 업로드 A를 F로 대체했을 때 관리자 목록에 F가 두 장 보이면 “참조 이동”보다 “복제 생성”처럼 느껴지므로, 사용자 업로드 대체는 참조를 옮긴 뒤 원본 레코드 자체를 정리해 결과적으로 목표 이미지 한 장만 남는 쪽이 운영 기대와 더 잘 맞는다고 정리했다.
|
||||
|
||||
## 2026-04-06 v1.4.78
|
||||
- 사용자 업로드 이미지의 “같은 캐릭터인데 파일만 다른 경우”는 자동 판별하려 들수록 오탐 위험이 커지므로, 관리자 모달에서 대상 이미지를 직접 검색·선택하는 수동 치환 흐름으로 시작하는 편이 가장 안전하다고 판단했다.
|
||||
- 이때 `src`만 바꾸고 기존 라벨을 남기면 운영자가 통합한 뒤에도 표기가 제각각 남을 수 있으므로, 치환 대상의 `라벨`을 기준으로 사용자 업로드 행과 저장된 티어표/요청 스냅샷 내부 라벨까지 함께 맞춰 주는 편이 운영 목적에 더 부합한다고 정리했다.
|
||||
|
||||
## 2026-04-06 v1.4.77
|
||||
- 작성자 프로필 화면의 공개 티어표 카드는 같은 계열의 다른 목록 뷰와 거의 동일한 마크업을 쓰고 있었지만, `overflow: hidden`과 일부 최소 너비 제약이 빠져 있어 긴 제목/메타/썸네일이 카드 라운드 경계 안에서 안정적으로 잘리지 못한다고 판단했다.
|
||||
- 또 공유용 프리뷰는 “완성된 티어표 보드”를 보여주는 화면이므로, 편집 중 보조 영역인 미사용 풀까지 노출하면 실제 배치 결과보다 산만해질 수 있어 프리뷰에서는 보드에 배치된 아이템만 노출하는 쪽이 더 일관된다고 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.76
|
||||
- 프리뷰용 `viewerSidebar__section`은 데스크톱 오른쪽 레일에서 하단 액션 카드처럼 보이게 하려고 `margin-top: auto`를 갖고 있었지만, 모바일 전체 화면 overlay에서는 이 규칙이 카드를 바닥으로 밀어 과도하게 붙은 인상을 만들 수 있다고 판단했다.
|
||||
- 게다가 `localRightRailRoot`가 최소 높이 100%를 유지한 채 상위 콘텐츠 컨테이너도 flex 남은 높이를 채우면, 하단 footer 영역과 Teleport 콘텐츠의 시각적 쌓임이 어색해질 수 있으므로 모바일 overlay에서는 콘텐츠 컨테이너를 내용 높이 기준으로 풀어 footer가 자연스럽게 아래로 따라오게 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.75
|
||||
- 데스크톱/태블릿에서는 오른쪽 레일을 폭이 정해진 서랍 패널처럼 여는 게 맞지만, 모바일에서는 같은 폭 규칙을 유지하면 오히려 “오른쪽에서 덜 열린 반쪽 패널”처럼 보여 하단 공간까지 어색해질 수 있다고 판단했다.
|
||||
- 그래서 모바일 한정으로 오른쪽 레일 overlay를 전체 화면 패널로 바꾸고, 공유/복사 같은 하단 액션이 기기 하단 UI나 safe-area에 붙어 잘리지 않도록 내부 바디 패딩을 더 넉넉하게 두는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.74
|
||||
- 모바일에서는 `workspaceBody` 자체가 카드처럼 배경색을 가지는 것보다, 바깥 앱 셸 배경 위에 각 화면의 실제 카드/섹션만 떠 있는 편이 시각 구조가 더 명확하다고 판단했다.
|
||||
- 특히 `workspaceBody`가 좌우 마진을 가진 상태로 별도 배경색을 칠하면 “내용과 무관한 중간 레이어 박스”처럼 보일 수 있으므로, 모바일 한정으로 공통 워크스페이스 배경을 투명 처리해 불필요한 레이어감을 줄이는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.73
|
||||
- 모바일 `appShell`은 PC처럼 좌우 3열이 아니라 위쪽 레일과 아래쪽 본문이 세로로 쌓이는 2행 구조이므로, 열 정의만 1fr로 바꾸고 행 정의를 비워두면 암묵 그리드 행이 남는 높이를 늘려 본문이 아래로 밀려 보일 수 있다고 판단했다.
|
||||
- 이 문제는 각 화면 본문을 개별 조정하기보다 모바일 셸 컨테이너에서 첫 행은 `auto`, 본문 행은 `minmax(0, 1fr)`로 고정하고 전체 콘텐츠 정렬을 위쪽으로 붙이는 편이 공통 회귀를 가장 작게 되돌리는 방법이라고 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.72
|
||||
- 모바일 상단 레일은 왼쪽 유저 카드 영역과 오른쪽 패널 토글 버튼이 같은 시각적 간격 체계를 가져야 전체 셸이 덜 비뚤어져 보이므로, 모바일에서만 `railHeader` 좌우 패딩을 본문 카드 여백보다 조금 넓은 `20px`로 맞추는 편이 낫다고 판단했다.
|
||||
- 오른쪽 레일 토글 아이콘이 모바일에서 테두리 없는 아이콘만 보이면 왼쪽 네비 토글 버튼과 컴포넌트 문법이 달라 보이므로, 모바일 한정으로 같은 버튼형 배경/테두리/라운드를 적용해 조작 가능한 컨트롤처럼 통일하는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.71
|
||||
- 모바일에서 공통 본문 하단이 딱 붙어 보이는 문제는 로그인 화면 하나만 고치는 것보다 `workspaceBody` 공통 하단 여백을 safe-area까지 포함해 보강하는 편이 이후 모든 본문 화면에 일괄 적용되어 유지보수상 낫다고 판단했다.
|
||||
- 모바일 왼쪽 네비게이션은 데스크톱의 폭 축소형 접기와 목적이 다르므로, 기존 `leftRailCollapsed`를 억지로 재사용하기보다 `mobileLeftNavOpen` 상태를 분리하고 유저 카드 우측 버튼으로 검색/메뉴 묶음만 접는 방식이 더 자연스럽다고 정리했다.
|
||||
- 오른쪽 레일은 모바일에서 기본 자동 열림이 실제 조작 공간을 빼앗는 경우가 많으므로, 모바일 진입과 라우트 이동 시 기본 닫힘으로 두되 PC 레이아웃으로 돌아오면 다시 기본 열림을 복원하는 쪽으로 맞췄다.
|
||||
- 모바일 터치에서는 짧은 탭 선택과 드래그 시작이 같은 포인터 입력에서 충돌하기 쉬우므로, Sortable에 터치 전용 지연과 threshold를 둬 탭은 선택, 길게 누르고 움직이면 드래그가 되도록 의도를 분리했다.
|
||||
|
||||
## 2026-04-03 v1.4.70
|
||||
- 카카오톡/디스코드/X 공유 미리보기는 대개 프런트 SPA 자바스크립트를 실행하기 전에 HTML 메타를 먼저 읽으므로, 기존 `index.html` 고정 메타를 프런트 런타임에서 바꾸는 방식만으로는 티어표별 썸네일/제목/설명을 안정적으로 보여주기 어렵다고 판단했다.
|
||||
- 현재 운영 구조가 프런트 Nginx 정적 서빙 + 백엔드 API 분리 형태이므로, 모든 SPA 경로를 SSR로 바꾸기보다 공유 버튼만 `/share/editor/...` 서버 렌더링 경로를 사용하게 하고, 이 경로에서 OG 메타를 만든 뒤 기존 `preview=1` 화면으로 넘기는 방식이 가장 작은 변경이라고 정리했다.
|
||||
- 다만 비공개 티어표의 제목/설명/썸네일이 외부 크롤러에게 노출되면 안 되므로, 공유 메타 생성은 공개 티어표이면서 URL의 주제 식별자와 실제 티어표 소속이 일치하는 경우에만 개별 메타를 사용하고, 그 외에는 서비스 기본 메타로 떨어지게 제한했다.
|
||||
|
||||
## 2026-04-03 v1.4.69
|
||||
- 아이템 검색 실패가 라벨 누락이나 이벤트 문제처럼 보일 수도 있지만, 코드상 필터링 조건 자체는 단순했으므로 한글 입력/저장 문자열의 유니코드 정규형 차이까지 먼저 흡수하는 편이 더 안전하다고 판단했다.
|
||||
- 검색 시점에만 임시 보정하는 것보다, 검색어와 저장 라벨 비교를 같은 정규화 함수로 통일하고 커스텀 파일명 기반 기본 라벨 생성도 `NFC`로 맞춰 이후 신규 업로드 항목까지 같은 규칙을 타게 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.68
|
||||
- 우클릭 복제 UX는 카드 영역과 썸네일 이미지 중 어디를 눌러도 같은 동작이어야 하므로, 개별 카드의 버블링 이벤트만 믿기보다 전역 캡처 단계에서 아이템 우클릭을 먼저 가로채는 방식이 더 안전하다고 판단했다.
|
||||
- 편집기에서는 아이템 이미지를 브라우저 기본 이미지처럼 드래그하거나 저장 메뉴로 여는 것보다 보드 조작이 우선이므로, 썸네일 이미지의 기본 드래그도 명시적으로 꺼두는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.67
|
||||
- 이미지 최적화는 해시 기반 중복 재사용을 하기 때문에, 프로필 아바타로 올린 이미지가 우연히 템플릿/사용자 아이템과 같은 `src`를 공유할 수 있다. 이때 자산 카드 쪽을 무조건 숨기면 “실제로는 프로필 이미지로 쓰이는데 관리자 필터에서 안 보이는 상태”가 생기므로, 아바타/썸네일 참조가 있는 `src`는 자산 카드도 유지하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.66
|
||||
- 특정 템플릿은 같은 아이템을 조건별로 여러 칸에 동시에 배치해야 하므로, 기존 아이템을 직접 공유해서 재사용하기보다 우클릭으로 새 복제본을 만들어 미사용 풀에 넣는 방식이 더 자연스럽다고 판단했다.
|
||||
- 현재 티어표 저장 구조는 아이템 ID 기준으로 위치를 추적하므로, 복제본이 원본과 같은 ID를 쓰면 중복 배치가 불가능해진다. 따라서 복제 시 `dup-...` 형태의 새 ID를 발급해 원본과 복제본을 별도 인스턴스로 관리하기로 했다.
|
||||
|
||||
## 2026-04-03 v1.4.62
|
||||
- 운영 서버를 새 DB로 다시 시작하는 절차는 “일반 업데이트 재빌드”와 “볼륨까지 삭제하는 완전 초기화”가 같은 문서 안에 섞이면 실수로 데이터를 날릴 위험이 크므로, 배포 문서에서 두 흐름을 별도 섹션으로 나누는 편이 맞다고 판단했다.
|
||||
- DB만 비우고 업로드 볼륨을 남기는 방식도 가능하지만, 현재 서비스는 DB 레코드와 업로드 파일 참조가 강하게 연결되어 있으므로 이 방법 역시 “운영 데이터를 전부 버리는 전제”라는 경고를 같이 적어두는 쪽으로 정리했다.
|
||||
@@ -645,6 +787,10 @@
|
||||
- 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다.
|
||||
- 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다.
|
||||
|
||||
## 2026-04-03 v1.4.65
|
||||
- 운영 환경에서 루트 정적 favicon 요청이 계속 `403`으로 떨어지는 상황에서는 원인을 프록시/권한 계층에서 끝까지 추적하기보다, 브라우저 탭용 파비콘을 인라인 데이터 URL로 제공해 해당 요청 자체를 없애는 편이 더 단순하고 안정적이라고 판단했다.
|
||||
- 다만 iOS 홈 화면용 `apple-touch-icon.png`와 외부 공유용 `og-card.png`는 실제 파일이 필요하므로, 일반 브라우저 탭 favicon만 인라인 처리하는 선으로 범위를 제한했다.
|
||||
|
||||
## 2026-04-03 v1.4.64
|
||||
- 운영/로컬 DB를 새로 미는 흐름을 공식화한 만큼, 더 이상 “기존 DB에서만 우연히 남아 있던 컬럼”에 기대지 않고 `ensureSchema()`의 신규 생성 정의만으로 관리자 화면 전체가 떠야 한다고 다시 정리했다.
|
||||
- `template_requests`는 요청 목록 카드뿐 아니라 요청 미리보기와 이미지 참조 추적에도 쓰이므로, 저장 스냅샷 컬럼(`groups_json`, `board_items_json`, `show_character_names_snapshot`)을 초기 스키마에 반드시 포함하기로 결정했다.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
|
||||
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
||||
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰 렌더링
|
||||
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰 렌더링
|
||||
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request`
|
||||
|
||||
## `/login`
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- 업로드 저장소: 로컬 파일 시스템(`backend/uploads/`)
|
||||
- 운영 배포: `frontend(Nginx 정적 서빙 + /api,/uploads 프록시) + backend + mariadb` Docker Compose 구조
|
||||
- NAS HTTPS 리버스 프록시 운영 시 프런트 Nginx는 백엔드로 `X-Forwarded-Proto: https`를 전달하고, Express 세션은 프록시 환경에서 `secure` 쿠키를 허용하도록 설정한다.
|
||||
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
|
||||
- 프런트 브라우저 탭 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 SVG 데이터 URL로 제공하고, iOS 홈 화면용 `apple-touch-icon.png`와 공유 미리보기용 `og-card.png`만 정적 파일로 유지한다.
|
||||
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, `preview=1` 모드에서도 같은 셸을 유지한 채 중앙 본문만 완성본 프리뷰로 렌더링한다.
|
||||
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
|
||||
- 좌측 패널은 필요 시 축소형 레일로 접을 수 있으며, 접힌 상태에서는 아이콘 중심 내비게이션과 축약된 바로가기만 유지한다.
|
||||
@@ -254,6 +254,7 @@
|
||||
- 사용자 아이템은 사용 횟수(`usageCount`)를 표시하며, `미사용 아이템` 필터에서 저장 티어표/템플릿 참조가 더 이상 없는 항목만 개별/일괄 삭제할 수 있다. 사용자가 계정 탈퇴 등으로 삭제된 경우는 `custom_items.owner_id` 외래키로 레코드가 같이 사라지므로 보통 `미사용 아이템`으로 남지 않는다.
|
||||
- 아이템 관리 기본 필터는 `아이템(템플릿 + 사용자)`이며, 우측 필터 순서는 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템`을 사용한다.
|
||||
- `/uploads/assets/avatars/`는 `프로필 아바타`, `/uploads/assets/tierlists/`와 `/uploads/assets/topics/`는 `썸네일 이미지`, 그 외 보관 이미지는 `보관 자산` 배지로 표시한다. 최근처럼 `/uploads/assets/<파일명>.webp` 또는 `/uploads/assets/<앞2글자>/<파일명>.webp` 경로만 보고 종류를 알 수 없는 자산은 DB 참조(`avatar_src`, `thumbnail_src`, `thumbnail_src_snapshot`)를 역추적해 프로필/썸네일 여부를 분류한다. 실제 템플릿 기본 항목은 `템플릿 아이템`, 사용자 커스텀 항목은 `사용자 아이템` 배지를 사용한다.
|
||||
- 같은 이미지 `src`가 해시 중복 재사용으로 템플릿 아이템/사용자 아이템과 프로필 아바타 또는 썸네일 자산에서 동시에 공유되더라도, 아바타/썸네일로 참조 중인 `src`는 자산 카드도 함께 유지해 `프로필 이미지`, `썸네일 이미지`, `전체 이미지` 필터에서 누락되지 않게 한다.
|
||||
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
|
||||
- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다.
|
||||
- `전체 티어표 관리` 카드에는 받은 즐겨찾기 수를 함께 보여주며, 우측 운영 패널에서 즐겨찾기 많은 순 정렬과 최소 즐겨찾기 수 필터로 추천 후보를 좁힐 수 있다.
|
||||
@@ -295,6 +296,7 @@
|
||||
- 티어 행에 넣은 아이템은 작은 제거 버튼으로 다시 우측 아이템 풀로 되돌릴 수 있다.
|
||||
- 편집 가능한 티어표에서는 아이템을 드래그로 옮길 수 있고, 아이템을 클릭해 선택한 뒤 원하는 셀이나 아이템 풀 빈 영역을 클릭하는 방식으로도 위치를 바꿀 수 있다.
|
||||
- 클릭 배치 모드에서 선택된 아이템은 파란 포커스 테두리로 표시하며, 드래그를 시작하면 선택 상태를 해제하고 드래그 직후 짧은 클릭 입력은 무시해 드래그/클릭 조작이 섞이지 않게 한다.
|
||||
- 보드 칸이나 미사용 아이템 풀의 아이템을 우클릭하면 `아이템 복제` 메뉴가 열리고, 실행 시 같은 이미지/이름/출처를 가진 새 아이템 인스턴스를 미사용 풀 맨 앞에 추가한다. 복제본은 `dup-...` 형태의 새 ID를 쓰므로 원본과 복제본을 서로 다른 칸에 동시에 배치할 수 있다.
|
||||
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때는 저장본의 그룹/풀을 먼저 복원하고, 이후 현재 템플릿에 새로 추가된 기본 아이템만 미사용 풀 끝에 병합한다.
|
||||
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 포함되어 있던 항목은 자동 제거하지 않고 그대로 보존한다.
|
||||
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `v1.4.68`에서 아이템 우클릭 처리를 `window` 캡처 단계로 보강했으므로, 보드에 배치된 아이템/미사용 풀 아이템/아이템 썸네일 이미지 위에서 각각 우클릭했을 때 브라우저 기본 메뉴 대신 `아이템 복제` 메뉴가 바로 뜨는지 QA한다.
|
||||
- `v1.4.67`에서 같은 `src`가 프로필 아바타와 템플릿/사용자 아이템으로 동시에 쓰여도 자산 카드를 유지하도록 바꿨으므로, 운영 관리자 화면의 `전체 이미지`와 `프로필 이미지` 필터에서 실제 아바타가 보이고 상세 모달의 공유 참조 목록도 자연스럽게 읽히는지 QA한다.
|
||||
- 아이템 우클릭 복제 기능을 추가했으므로, 템플릿 아이템 복제/커스텀 아이템 복제/이미 보드에 배치된 아이템 복제 각각에서 복제본이 미사용 풀 맨 앞에 생기고 원본과 복제본을 서로 다른 칸에 동시에 둘 수 있는지 QA한다.
|
||||
- 복제본은 `dup-...` 새 ID로 저장되므로, 저장 후 재진입/티어표 복사본 생성/뷰어 모드 열람에서도 복제본이 그대로 유지되는지와, 템플릿 업데이트 요청에 복제된 커스텀 아이템이 포함될 때 운영상 이상이 없는지 확인한다.
|
||||
- `v1.4.62`에서 NAS 배포 문서에 운영 DB 완전 초기화 절차를 추가했으므로, 실제 NAS에서 `git pull → docker compose ... down -v → up -d --build` 순서로 재배포했을 때 빈 DB가 현재 스키마로 다시 올라오고 `freeform`만 생성되는지 확인한다.
|
||||
- `docker volume rm tier-maker_tmaker_mariadb_data` 방식은 프로젝트 디렉터리명에 따라 실제 볼륨 이름이 달라질 수 있으므로, 운영 NAS에서는 먼저 `docker volume ls | grep tmaker`로 이름을 확인한 뒤 문서 명령이 그대로 맞는지 점검한다.
|
||||
- `v1.4.61`에서 템플릿 공개 주소를 `slug`로 분리했으므로, 홈 카드/주제 상세/나의 티어표/즐겨찾기/검색 결과/팔로우 피드/사용자 프로필에서 열리는 URL이 `/topics/:slug`, `/editor/:slug/...` 형태로 바뀌고, 실제 화면 내용도 같은 주제 템플릿으로 정확히 열리는지 QA한다.
|
||||
@@ -117,7 +121,7 @@
|
||||
- 주제 상세 컬렉션 화면은 `pageHead` 공통 레이아웃과 `/topics` 기본 경로로 옮겼으므로, 직접 진입·뒤로가기·검색 후 재진입 시 주소와 헤더 흐름이 자연스러운지 한 번 더 QA한다.
|
||||
- `/topics/:gameId`를 기본 경로로 세우고 `/games/:gameId`는 alias로 남겼으므로, 다음 단계에서는 에디터/검색/공유 흐름에서 어떤 링크를 새 경로로 더 전환할지 범위를 정한다.
|
||||
- 신규 DB 기준 `template_requests` 스키마 누락은 보정했으므로, 운영 NAS에서 `down -v` 후 재배포했을 때 관리자 `/admin/featured`, `/admin/tierlists`, `/admin/users` 진입과 템플릿 요청/이미지 통계 API가 모두 500 없이 동작하는지 한 번 더 QA한다.
|
||||
- 운영 환경에서 `/favicon.svg`, `/favicon-32x32.png`가 `403 Forbidden`으로 떨어지는 현상이 남아 있으므로, 배포 컨테이너 안의 `/usr/share/nginx/html` 실제 파일/권한, 프런트 빌드 산출물 복사 상태, NAS 또는 Cloudflare 앞단 보안 규칙을 순서대로 확인한다.
|
||||
- 브라우저 탭 파비콘은 다시 인라인 SVG로 돌려 정적 `/favicon.svg`, `/favicon-32x32.png` 요청 자체를 끊었으므로, 최신 배포 후 강력 새로고침 기준으로 favicon 403 로그가 실제로 사라졌는지 한 번 더 QA한다.
|
||||
- 우측 레일 Teleport 대상 DOM을 상시 유지하는 방식으로 바꿨으므로, 관리자 `/admin/...` → 설정 `/profile` → 홈 `/`처럼 전용 우측 레일과 일반 우측 레일을 오가는 라우트 전환에서 콘솔 오류가 더 이상 재현되지 않는지 운영 브라우저로 한 번 더 QA한다.
|
||||
- 내부 리네이밍 2단계로 관리자 `selectedTemplate / templates / loadTemplate / refreshTemplates` 묶음까지 정리했으므로, 다음 단계에서는 `/games/:gameId` 라우트와 프런트 API 호출부를 어디까지 `topic/template` 의미로 감쌀지 범위를 먼저 정리한다.
|
||||
- 화면/문서 층의 용어 정리는 거의 마무리됐으므로, 다음 단계에서는 내부 `gameId / games` 모델을 실제로 옮길지, 아니면 라우트 alias/리다이렉트부터 둘지 점진 전환 순서를 정한다.
|
||||
|
||||
191
docs/update.md
191
docs/update.md
@@ -1,5 +1,192 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 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
|
||||
- 관리자 이미지 대체는 더 이상 원본 사용자 아이템을 즉시 삭제하지 않고, 원본 레코드와 파일을 남겨 둔 채 `어떤 아이템으로 대체됐는지` 메타만 기록하도록 바꿨다.
|
||||
- 따라서 대체 후에도 원본 이미지는 아이템 라이브러리에서 계속 확인할 수 있고, 카드와 상세 모달에서 `대체됨` 상태와 대체 대상 라벨을 함께 볼 수 있다.
|
||||
- 다만 이 원본은 이미 참조가 옮겨진 미사용 사용자 아이템이므로, 기존 `미사용 아이템 일괄 삭제`와 개별 삭제 대상에는 그대로 포함되게 유지해 운영자가 원할 때 정리할 수 있게 했다.
|
||||
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.79
|
||||
- 관리자 이미지 대체 모달은 처음 열었을 때 기존 목록을 자동으로 보여주지 않고, 검색을 실행한 뒤에만 대체 후보를 표시하도록 바꿨다. 같은 티어표/같은 문맥의 항목이 처음부터 섞여 보여 혼란스럽던 점을 줄이기 위한 조정이다.
|
||||
- 사용자 업로드 아이템을 다른 이미지로 대체할 때는 이제 원본 항목을 그대로 `대상 이미지와 라벨`로 덮어써 중복 항목을 남기지 않고, 참조를 옮긴 뒤 원본 사용자 아이템 레코드와 파일을 함께 정리해 관리자 라이브러리에 동일 이미지가 두 번 보이지 않도록 맞췄다.
|
||||
- 이미지 대체 기능은 현재 사용자 업로드 아이템에만 노출되도록 제한해, 템플릿 기본 아이템이나 보관 자산까지 같은 방식으로 다뤄 생길 수 있는 오해를 줄였다.
|
||||
- 확인: `node --check backend/src/routes/admin.js`, `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.78
|
||||
- 관리자 아이템 관리 모달에 `선택한 이미지로 대체` 기능을 추가했다. 운영자는 대체할 원본 아이템을 연 뒤, 모달 안에서 다른 라이브러리 이미지를 검색·선택해 수동으로 치환할 수 있다.
|
||||
- 이 치환은 단순히 `src`만 바꾸는 것이 아니라, 선택한 대상 이미지의 `라벨`도 함께 따라가도록 처리해 사용자 업로드 아이템과 티어표 저장 JSON 안의 표기가 같은 이름으로 정리되게 맞췄다.
|
||||
- 백엔드의 업로드 참조 치환 로직은 이제 `custom_items`, `topic_items`, `tierlists.pool_json`, `template_requests.items_json / board_items_json`까지 `src + label`을 함께 갱신하므로, 같은 캐릭터를 서로 다른 저화질 이미지로 올린 경우도 관리자가 고화질 기준 이미지 하나로 수동 통합할 수 있다.
|
||||
- 치환 후 기존 이미지 참조가 0이 되면, 기존처럼 미사용 이미지 정리 대상으로 후속 삭제할 수 있다.
|
||||
- 확인: `node --check backend/src/routes/admin.js`, `node --check backend/src/db.js`, `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.77
|
||||
- 작성자 프로필 보기 화면에서 공개 티어표 카드의 내부 콘텐츠가 카드 라운드 테두리 밖으로 밀려 보이거나 일부가 잘려 보일 수 있던 문제를 정리했다.
|
||||
- `UserProfileView`의 카드 본문/헤더에 `overflow: hidden`을 맞추고 썸네일 래퍼에도 `min-width: 0`을 추가해, 다른 목록 화면과 같은 카드 경계 안에서 안정적으로 렌더링되도록 조정했다.
|
||||
- 티어표 프리뷰 화면에서는 더 이상 `남은 아이템` 풀을 노출하지 않도록 바꿔, 실제 완성본과 공유 미리보기가 같은 기준으로 보이게 맞췄다.
|
||||
|
||||
## 2026-04-03 v1.4.76
|
||||
- 모바일 티어표 프리뷰에서 오른쪽 레일의 `VIEWER MODE` 카드가 패널 바닥에 딱 붙고, 카피라이트 문구가 카드 뒤쪽 중간 높이에 겹쳐 보일 수 있던 배치를 보정했다.
|
||||
- 모바일 오른쪽 overlay 레일에서는 `rightRail__content`가 남는 높이를 억지로 채우지 않도록 `flex: 0 0 auto`로 풀고, `localRightRailRoot`의 최소 높이도 `auto`로 낮춰 footer와 콘텐츠가 자연스럽게 순서대로 쌓이게 했다.
|
||||
- 프리뷰 전용 `viewerSidebar__section`의 `margin-top: auto`는 모바일에서만 끄고, 광고 아래에 바로 카드가 이어지도록 조정했다.
|
||||
|
||||
## 2026-04-03 v1.4.75
|
||||
- 모바일에서 오른쪽 레일을 열었을 때 패널이 `calc(100vw - 20px)` 폭의 좁은 서랍처럼 떠서 화면 전체를 채우지 못하고, 아래쪽도 어색하게 비어 보이던 부분을 조정했다.
|
||||
- 모바일 오른쪽 레일 overlay는 `inset: 0`, `width: 100vw`, `height: 100dvh`로 화면 전체를 덮는 패널처럼 열리게 바꾸고, 하단 액션/공유 버튼이 바닥에 붙거나 잘려 보이지 않도록 내부 패딩을 `32px + safe-area`까지 늘렸다.
|
||||
|
||||
## 2026-04-03 v1.4.74
|
||||
- 모바일 본문 영역에서 `workspaceBody` 배경색이 좌우 마진 안쪽에만 칠해져 중앙에 어설픈 배경 박스가 떠 있는 것처럼 보이던 부분을 정리했다.
|
||||
- 모바일에서는 공통 워크스페이스 배경을 투명하게 두고, 실제 화면별 카드/섹션 배경만 남겨 덜 미완성처럼 보이도록 조정했다.
|
||||
|
||||
## 2026-04-03 v1.4.73
|
||||
- 모바일에서 왼쪽 레일 아래 메인 컨텐츠가 화면 중간부터 시작하는 것처럼 보이던 회귀를 수정했다.
|
||||
- 원인은 모바일 `.appShell`이 1열 그리드로 바뀐 상태에서 세로 행 정의가 없어 `leftRail` 행과 `appMain` 행이 남는 높이를 나눠 가지며 위쪽이 불필요하게 늘어날 수 있던 점이었다. 모바일 그리드를 `auto + minmax(0, 1fr)` 행으로 고정하고 `align-content: start`를 적용해 상단 레일 바로 아래에 본문이 이어지도록 보정했다.
|
||||
|
||||
## 2026-04-03 v1.4.72
|
||||
- 모바일 공통 상단 헤더(`railHeader`) 좌우 패딩을 `20px`로 넓혀, 오른쪽 레일 토글 버튼과 화면 가장자리 간격이 왼쪽 유저 카드 쪽과 더 자연스럽게 맞도록 조정했다.
|
||||
- 모바일에서 오른쪽 레일 열기/닫기 아이콘도 왼쪽 네비게이션 토글과 같은 버튼형 카드 스타일로 보이도록 `42px` 크기, 테두리, 배경, 둥근 모서리를 맞췄다.
|
||||
|
||||
## 2026-04-03 v1.4.71
|
||||
- 모바일에서 본문 페이지나 로그인 화면 하단이 카드/버튼 바로 아래에서 끊겨 보여 답답했던 부분을 줄이기 위해, 공통 워크스페이스 본문 하단에 모바일 safe-area 기반 여백을 추가했다.
|
||||
- 모바일 왼쪽 네비게이션은 유저 프로필 카드 오른쪽 토글 버튼으로 접고 펼칠 수 있게 바꾸고, 닫힘/열림 전환 시 검색창과 메뉴가 위아래로 부드럽게 스르륵 접히는 애니메이션을 추가했다.
|
||||
- 모바일 진입 시 오른쪽 레일은 기본 닫힘으로 시작하고, 모바일에서 직접 오른쪽 레일을 열었을 때도 레일 하단 컨텐츠가 화면 바닥에 붙지 않도록 safe-area 여백을 더했다.
|
||||
- 티어표 편집기 모바일 터치 조작에서 아이템을 짧게 탭하면 선택만 하고, 길게 누른 뒤 움직일 때 드래그가 시작되도록 Sortable 터치 시작 지연과 이동 임계값을 추가했다.
|
||||
- 서버 점검 안내 문구는 `서비스 내부 점검이 필요합니다.` 대신 `서비스 내부 점검중입니다.`로 다듬었고, 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
|
||||
|
||||
## 2026-04-03 v1.4.70
|
||||
- 저장된 티어표의 `공유하기` 버튼이 기존 `preview=1` 편집기 주소 대신 `/share/editor/:topicId/:tierListId` 공유 전용 주소를 복사하도록 바꿨다.
|
||||
- 이 공유 전용 주소는 공개 티어표인 경우 해당 티어표의 제목, 설명, 썸네일을 기반으로 Open Graph/Twitter 메타 태그를 서버에서 동적으로 생성한 뒤, 실제 뷰어 화면 `/editor/:topicId/:tierListId?preview=1`로 즉시 이동시킨다.
|
||||
- 비공개 티어표이거나 주제 경로와 티어표 소속이 맞지 않는 경우에는 개별 제목/설명/썸네일을 노출하지 않고 서비스 기본 공유 메타를 사용하도록 제한했다.
|
||||
- 운영 프런트 Nginx에서 `/share/` 경로를 백엔드로 프록시하도록 추가해, 카카오톡/디스코드/X 같은 크롤러가 JS 실행 전에 공유 메타를 먼저 읽을 수 있게 했다.
|
||||
- `backend/index.js`, `backend/src/routes/share.js` 문법 검사와 프런트 프로덕션 빌드(`npm run build`)까지 통과하는 것을 확인했다.
|
||||
|
||||
## 2026-04-03 v1.4.69
|
||||
- 티어표 편집 화면의 아이템 검색에서 한글 아이템명이 검색어와 눈으로는 같아 보여도 내부 유니코드 정규형 차이 때문에 일부 항목이 매칭되지 않을 수 있던 문제를 보강했다.
|
||||
- 검색어와 아이템 라벨을 비교하기 전에 `NFC`로 정규화하도록 바꾸고, 커스텀 이미지 파일명에서 기본 라벨을 만들 때도 같은 정규화를 거쳐 한글 조합형 차이로 검색이 빗나가는 상황을 줄였다.
|
||||
- 프런트 프로덕션 빌드(`npm run build`)까지 통과하는 것을 확인했다.
|
||||
|
||||
## 2026-04-03 v1.4.68
|
||||
- 티어표 편집 화면에서 아이템을 우클릭해도 브라우저 기본 컨텍스트 메뉴가 먼저 떠서 `아이템 복제` 메뉴를 누르기 어려울 수 있던 부분을 보강했다.
|
||||
- 기존에는 각 아이템 카드의 `@contextmenu.prevent`에 주로 의존했지만, 이제는 `window` 캡처 단계에서 `[data-item-id]` 대상 우클릭을 먼저 잡아 기본 메뉴를 막고 커스텀 복제 메뉴를 열도록 바꿨다.
|
||||
- 아이템 썸네일 이미지에도 `draggable="false"`를 명시해, 이미지 자체의 기본 드래그/컨텍스트 동작이 편집 조작보다 앞서는 상황을 줄였다.
|
||||
|
||||
## 2026-04-03 v1.4.67
|
||||
- 관리자 아이템 관리에서 프로필 아바타가 `전체 이미지`와 `프로필 이미지` 필터에 보이지 않을 수 있던 문제를 수정했다.
|
||||
- 원인은 `image_assets`의 같은 `src`가 템플릿 아이템이나 사용자 아이템에서도 쓰이는 경우, 자산 카드 생성 단계에서 해당 `src`를 무조건 제외하던 필터였다. 이제는 `users.avatar_src`나 각종 썸네일 참조로 실제 사용 중인 자산이면 같은 이미지가 다른 아이템에 재사용되더라도 자산 카드도 함께 유지한다.
|
||||
- 로컬 MariaDB를 새로 만든 뒤 `프로필 아바타`와 사용자 아이템이 같은 `src`를 공유하는 테스트 데이터를 직접 넣고, `listCustomItems({ filterMode: 'avatar' })`와 `filterMode: 'all'` 결과에 프로필 자산 카드가 포함되는 것까지 확인했다.
|
||||
|
||||
## 2026-04-03 v1.4.66
|
||||
- 티어표 편집 화면에서 보드 위 아이템이나 미사용 아이템 풀의 아이템을 우클릭하면 `아이템 복제` 메뉴가 뜨고, 선택한 아이템의 이미지/이름/출처를 유지한 새 복제본을 미사용 풀 맨 앞에 추가하도록 구현했다.
|
||||
- 기존 아이템 ID를 그대로 다시 쓰면 같은 항목을 서로 다른 칸에 동시에 둘 수 없으므로, 복제 시 `dup-...` 새 ID를 발급해 원본과 복제본을 별도 아이템 인스턴스로 저장하도록 정리했다.
|
||||
- 우클릭 메뉴는 메뉴 밖 클릭, 다른 곳 우클릭, 스크롤, 창 포커스 이탈 시 닫히도록 했고, 화면 가장자리에서는 메뉴가 뷰포트 밖으로 나가지 않게 좌표를 보정했다.
|
||||
|
||||
## 2026-04-03 v1.4.62
|
||||
- UGREEN NAS 운영 배포 문서에 `git pull origin main` 후 일반 재빌드하는 절차와, 운영 데이터를 전부 버리고 `docker compose ... down -v`로 MariaDB/업로드/세션 볼륨까지 초기화한 뒤 새로 `up -d --build` 하는 절차를 분리해서 추가했다.
|
||||
- DB만 비우고 싶을 때 `tmaker_mariadb_data` 볼륨만 삭제하는 방법과, 실제 볼륨 이름이 다를 수 있으니 `docker volume ls | grep tmaker`로 먼저 확인하는 안내도 함께 적었다.
|
||||
@@ -1199,6 +1386,10 @@
|
||||
- **아이템 카드 레이아웃 개선**: 아이템 목록과 추가 미리보기를 1:1 비율 기준으로 재구성하고 더 촘촘한 카드 그리드로 조정
|
||||
- **레거시 파일 역할 정리**: `db.json`과 lowdb 관련 코드는 현재 MariaDB 기본 런타임에는 필수가 아니며, 마이그레이션/예외 fallback 용도임을 문서에 명시
|
||||
|
||||
## 2026-04-03 v1.4.65
|
||||
- **파비콘 403 재발 차단**: 운영 환경에서 `/favicon.svg`, `/favicon-32x32.png` 정적 요청이 계속 `403 Forbidden`으로 떨어지던 문제를 피하기 위해, 브라우저 탭 파비콘을 다시 `index.html` 인라인 SVG 데이터 URL로 전환하고 해당 정적 favicon 링크를 제거
|
||||
- **광고 스크립트 외부 DNS 오류 분리**: `e.dlx.addthis.com ... net::ERR_NAME_NOT_RESOLVED`는 애드센스/광고 네트워크에서 발생한 외부 도메인 해석 실패 로그로, 서비스 파비콘/관리자 API 오류와는 별개 현상으로 분리
|
||||
|
||||
## 2026-04-03 v1.4.64
|
||||
- **신규 DB 관리자 페이지 500 수정**: 빈 DB를 새로 만든 직후 `/admin/...` 진입 시 `GET /api/admin/template-requests`와 `GET /api/admin/image-assets/stats`가 500으로 터지던 문제를 수정
|
||||
- **`template_requests` 초기 스키마 보정**: 새 테이블 생성 정의에 누락돼 있던 `groups_json`, `board_items_json`, `show_character_names_snapshot` 컬럼을 추가하고, `source_tierlist_id`는 요청 종류에 따라 비어 있을 수 있도록 `NULL` 허용으로 정리
|
||||
|
||||
@@ -12,8 +12,11 @@
|
||||
<meta name="application-name" content="Tier Maker" />
|
||||
|
||||
<link rel="canonical" href="https://tmaker.sori.studio/" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='18' fill='%23090d16'/%3E%3Cpath d='M17 15h30v10H36v24H26V25H17V15Z' fill='%237fe7d6'/%3E%3Cpath d='M39 31h8v18h-8V31Z' fill='%235fcaff' opacity='.9'/%3E%3C/svg%3E"
|
||||
/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
|
||||
<meta property="og:site_name" content="Tier Maker" />
|
||||
|
||||
@@ -19,6 +19,15 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
location /share/ {
|
||||
proxy_pass http://backend:5179/share/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
location /uploads/ {
|
||||
proxy_pass http://backend:5179/uploads/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -22,11 +22,12 @@ 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 || '')
|
||||
|
||||
const leftRailCollapsed = ref(false)
|
||||
const mobileLeftNavOpen = ref(false)
|
||||
const rightRailOpen = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const leftRailSearchPlaceholder = '주제 템플릿 검색'
|
||||
@@ -37,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')
|
||||
|
||||
@@ -61,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',
|
||||
@@ -135,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)
|
||||
@@ -145,6 +155,7 @@ const showSettingsThemePanel = computed(() => route.name === 'profile')
|
||||
const showTopicViewToggle = computed(() => route.name === 'topicHub')
|
||||
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
|
||||
const shouldLockRightRailBodyScroll = computed(() => isRightRailOverlay.value && rightRailOpen.value && !showBackendFallback.value)
|
||||
const leftBottomPrimaryAction = computed(() => {
|
||||
if (!authReady.value) return null
|
||||
if (route.name === 'home' && auth.user) {
|
||||
@@ -285,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
|
||||
@@ -295,6 +311,11 @@ function toggleTheme() {
|
||||
applyTheme(isLightTheme.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
function syncRightRailBodyScrollLock(shouldLock) {
|
||||
if (typeof document === 'undefined') return
|
||||
document.body.style.overflow = shouldLock ? 'hidden' : ''
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedTheme = window.localStorage.getItem('tier-maker:theme')
|
||||
@@ -304,14 +325,23 @@ 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')
|
||||
if (saved === '0') rightRailOpen.value = false
|
||||
}
|
||||
if (isMobileLayout.value) {
|
||||
mobileLeftNavOpen.value = false
|
||||
rightRailOpen.value = false
|
||||
} else {
|
||||
rightRailOpen.value = true
|
||||
}
|
||||
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
||||
})
|
||||
|
||||
@@ -322,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('전체 화면 전환을 실행하지 못했어요.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +408,10 @@ 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)
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -339,13 +420,27 @@ watch(
|
||||
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
||||
isCollapsedSearchOpen.value = false
|
||||
isGuideModalOpen.value = false
|
||||
if (isMobileLayout.value) {
|
||||
mobileLeftNavOpen.value = false
|
||||
rightRailOpen.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
isMobileLayout,
|
||||
(mobile) => {
|
||||
if (mobile) leftRailCollapsed.value = false
|
||||
if (mobile) {
|
||||
leftRailCollapsed.value = false
|
||||
mobileLeftNavOpen.value = false
|
||||
rightRailOpen.value = false
|
||||
return
|
||||
}
|
||||
mobileLeftNavOpen.value = false
|
||||
rightRailOpen.value = true
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem('tier-maker:right-rail-open', '1')
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -353,7 +448,7 @@ watch(
|
||||
watch(
|
||||
usesLocalRightRail,
|
||||
(needed) => {
|
||||
if (!needed || rightRailOpen.value) return
|
||||
if (!needed || rightRailOpen.value || isMobileLayout.value) return
|
||||
rightRailOpen.value = true
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem('tier-maker:right-rail-open', '1')
|
||||
@@ -362,13 +457,24 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
shouldLockRightRailBodyScroll,
|
||||
(shouldLock) => {
|
||||
syncRightRailBodyScrollLock(shouldLock)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function isRouteActive(path) {
|
||||
if (path === '/') return route.path === '/'
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
function toggleLeftRail() {
|
||||
if (isMobileLayout.value) return
|
||||
if (isMobileLayout.value) {
|
||||
mobileLeftNavOpen.value = !mobileLeftNavOpen.value
|
||||
return
|
||||
}
|
||||
leftRailCollapsed.value = !leftRailCollapsed.value
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem('tier-maker:left-rail-collapsed', leftRailCollapsed.value ? '1' : '0')
|
||||
@@ -449,6 +555,7 @@ function reloadApp() {
|
||||
class="appShell"
|
||||
:class="{
|
||||
'appShell--leftCollapsed': leftRailCollapsed,
|
||||
'appShell--mobileNavClosed': isMobileLayout && !mobileLeftNavOpen,
|
||||
'appShell--rightClosed': !rightRailOpen,
|
||||
'appShell--rightOverlay': isRightRailOverlay,
|
||||
}"
|
||||
@@ -483,48 +590,60 @@ function reloadApp() {
|
||||
|
||||
<div class="leftRail__body">
|
||||
<div class="leftRail__content">
|
||||
<div v-if="authReady && auth.user" class="appUserCard">
|
||||
<div v-if="authReady" class="appUserCard">
|
||||
<div class="appUserCard__button">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" draggable="false" />
|
||||
<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"
|
||||
class="appUserCard__navToggle"
|
||||
type="button"
|
||||
:aria-label="mobileLeftNavOpen ? '네비게이션 메뉴 닫기' : '네비게이션 메뉴 열기'"
|
||||
:aria-expanded="mobileLeftNavOpen"
|
||||
@click="toggleLeftRail"
|
||||
>
|
||||
<SvgIcon :src="mobileLeftNavOpen ? iconDockToLeft : iconDockToRight" :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="searchStub" @submit.prevent="submitGlobalSearch">
|
||||
<button class="searchStub__iconButton" type="button" :aria-label="leftRailCollapsed ? '검색 열기' : '검색'" @click="handleLeftRailSearch">
|
||||
<span class="searchStub__icon">
|
||||
<SvgIcon :src="iconSearch" :size="24" />
|
||||
</span>
|
||||
</button>
|
||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
|
||||
</form>
|
||||
<div class="leftRail__mobileMenu">
|
||||
<form class="searchStub" @submit.prevent="submitGlobalSearch">
|
||||
<button class="searchStub__iconButton" type="button" :aria-label="leftRailCollapsed ? '검색 열기' : '검색'" @click="handleLeftRailSearch">
|
||||
<span class="searchStub__icon">
|
||||
<SvgIcon :src="iconSearch" :size="24" />
|
||||
</span>
|
||||
</button>
|
||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
|
||||
</form>
|
||||
|
||||
<nav
|
||||
class="leftNav"
|
||||
:class="{ 'leftNav--hasActive': activeLeftNavIndex >= 0 }"
|
||||
:style="{ '--left-nav-active-index': String(Math.max(activeLeftNavIndex, 0)) }"
|
||||
>
|
||||
<span class="leftNav__indicator" aria-hidden="true"></span>
|
||||
<RouterLink
|
||||
v-for="item in leftNavItems"
|
||||
:key="item.key"
|
||||
:to="item.path"
|
||||
class="leftNav__item"
|
||||
:class="{ 'leftNav__item--active': isRouteActive(item.path) }"
|
||||
:title="leftRailCollapsed ? item.label : ''"
|
||||
:aria-label="leftRailCollapsed ? item.label : undefined"
|
||||
<nav
|
||||
class="leftNav"
|
||||
:class="{ 'leftNav--hasActive': activeLeftNavIndex >= 0 }"
|
||||
:style="{ '--left-nav-active-index': String(Math.max(activeLeftNavIndex, 0)) }"
|
||||
>
|
||||
<span class="leftNav__glyph">
|
||||
<SvgIcon v-if="item.iconSrc" :src="item.iconSrc" :size="24" />
|
||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
|
||||
</span>
|
||||
<span class="leftNav__label">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<span class="leftNav__indicator" aria-hidden="true"></span>
|
||||
<RouterLink
|
||||
v-for="item in leftNavItems"
|
||||
:key="item.key"
|
||||
:to="item.path"
|
||||
class="leftNav__item"
|
||||
:class="{ 'leftNav__item--active': isRouteActive(item.path) }"
|
||||
:title="leftRailCollapsed ? item.label : ''"
|
||||
:aria-label="leftRailCollapsed ? item.label : undefined"
|
||||
>
|
||||
<span class="leftNav__glyph">
|
||||
<SvgIcon v-if="item.iconSrc" :src="item.iconSrc" :size="24" />
|
||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
|
||||
</span>
|
||||
<span class="leftNav__label">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="leftRail__bottom">
|
||||
@@ -930,6 +1049,7 @@ function reloadApp() {
|
||||
.appUserCard__button,
|
||||
.appUserCard__guest {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
@@ -968,21 +1088,53 @@ function reloadApp() {
|
||||
transition: opacity 180ms ease, max-width 220ms ease, transform 220ms ease;
|
||||
}
|
||||
|
||||
.leftRail__mobileMenu {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.appUserCard__navToggle {
|
||||
display: none;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
margin-left: auto;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text-soft);
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -1975,9 +2127,15 @@ function reloadApp() {
|
||||
@media (max-width: 860px) {
|
||||
.appShell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
align-content: start;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.railHeader {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.leftRail {
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
@@ -1994,6 +2152,43 @@ function reloadApp() {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.appUserCard {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.appUserCard__button {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.appUserCard__meta {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.appUserCard__navToggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.workspaceHead .ghostIcon--iconOnly,
|
||||
.rightRail__top .ghostIcon--iconOnly {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
min-width: 42px;
|
||||
border: 1px solid var(--theme-border);
|
||||
border-radius: 14px;
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
|
||||
.rightRail--overlay {
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
min-width: 0;
|
||||
height: 100dvh;
|
||||
min-height: 100dvh;
|
||||
border-left: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.appMain {
|
||||
min-height: auto;
|
||||
border-left: 0;
|
||||
@@ -2011,6 +2206,18 @@ function reloadApp() {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.leftRail__mobileMenu {
|
||||
max-height: 540px;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 260ms ease,
|
||||
opacity 220ms ease,
|
||||
transform 220ms ease,
|
||||
margin-top 220ms ease;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__top {
|
||||
display: none;
|
||||
}
|
||||
@@ -2046,17 +2253,44 @@ function reloadApp() {
|
||||
}
|
||||
|
||||
.workspaceBody {
|
||||
padding: 0;
|
||||
padding: 0 0 calc(28px + env(safe-area-inset-bottom));
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
margin: 14px 14px 0;
|
||||
}
|
||||
|
||||
.workspaceBody--localRail {
|
||||
padding: 0;
|
||||
padding: 0 0 calc(28px + env(safe-area-inset-bottom));
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
margin: 14px 14px 0;
|
||||
}
|
||||
|
||||
.appShell--mobileNavClosed .leftRail__mobileMenu {
|
||||
max-height: 0;
|
||||
margin-top: -8px;
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.appShell--mobileNavClosed .leftRail__bottom {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rightRail--overlay .rightRail__body {
|
||||
padding: 14px 20px calc(32px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.rightRail--overlay .rightRail__content {
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.rightRail--overlay .localRightRailRoot {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.collapsedSearchModal {
|
||||
padding: 72px 16px 16px;
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -25,6 +25,7 @@ const props = defineProps({
|
||||
>
|
||||
{{ item.sourceLabel }}
|
||||
</span>
|
||||
<span v-if="item.replacedAt" class="customItemCard__badge customItemCard__badge--replaced">대체됨</span>
|
||||
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
|
||||
</button>
|
||||
|
||||
@@ -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,14 +12,18 @@ export function useAdminCustomItems({
|
||||
customItemModalHistoryActive,
|
||||
modalTargetCustomItem,
|
||||
customItemModalDraftLabel,
|
||||
customItemModalDraftTags,
|
||||
customItemModalLabelSaving,
|
||||
customItemModalTargetTemplateId,
|
||||
customItemReplacementQuery,
|
||||
customItemReplacementItems,
|
||||
customItemReplacementLoading,
|
||||
customItemReplacementTargetId,
|
||||
customItemReplacementBusy,
|
||||
templates,
|
||||
selectedTemplateId,
|
||||
refreshCustomItems,
|
||||
loadTemplate,
|
||||
setTab,
|
||||
selectAdminTemplate,
|
||||
resetMessages,
|
||||
success,
|
||||
error,
|
||||
@@ -56,10 +58,40 @@ export function useAdminCustomItems({
|
||||
customItemModalHistoryActive.value = true
|
||||
}
|
||||
|
||||
async function refreshReplacementCandidates() {
|
||||
const currentItemId = modalTargetCustomItem.value?.id || ''
|
||||
if (!currentItemId) {
|
||||
customItemReplacementItems.value = []
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
customItemReplacementLoading.value = true
|
||||
const data = await api.listAdminCustomItems({
|
||||
q: customItemReplacementQuery.value,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
filter: 'all',
|
||||
collapseShared: true,
|
||||
})
|
||||
customItemReplacementItems.value = (data.items || []).filter((item) => item?.id && item.id !== currentItemId)
|
||||
} catch (e) {
|
||||
error.value = '대체할 이미지 목록을 불러오지 못했어요.'
|
||||
customItemReplacementItems.value = []
|
||||
} finally {
|
||||
customItemReplacementLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 = []
|
||||
customItemReplacementTargetId.value = ''
|
||||
customItemReplacementBusy.value = false
|
||||
customItemModalOpen.value = true
|
||||
pushCustomItemModalHistoryState()
|
||||
}
|
||||
@@ -69,8 +101,14 @@ export function useAdminCustomItems({
|
||||
customItemDeleteModalOpen.value = false
|
||||
modalTargetCustomItem.value = null
|
||||
customItemModalDraftLabel.value = ''
|
||||
customItemModalDraftTags.value = []
|
||||
customItemModalLabelSaving.value = false
|
||||
customItemModalTargetTemplateId.value = ''
|
||||
customItemReplacementQuery.value = ''
|
||||
customItemReplacementItems.value = []
|
||||
customItemReplacementTargetId.value = ''
|
||||
customItemReplacementLoading.value = false
|
||||
customItemReplacementBusy.value = false
|
||||
|
||||
if (fromPopState) {
|
||||
customItemModalHistoryActive.value = false
|
||||
@@ -85,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
|
||||
}
|
||||
@@ -97,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
|
||||
}
|
||||
@@ -137,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
|
||||
}
|
||||
@@ -190,6 +243,80 @@ 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)
|
||||
if (!item?.id) {
|
||||
error.value = '대체할 원본 아이템을 찾지 못했어요.'
|
||||
return
|
||||
}
|
||||
if (!targetItem?.id) {
|
||||
error.value = '대체할 대상 이미지를 먼저 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
customItemReplacementBusy.value = true
|
||||
const data = await api.replaceAdminCustomItem(item.id, {
|
||||
targetItemId: targetItem.id,
|
||||
targetSourceType: targetItem.sourceType || 'user',
|
||||
})
|
||||
if (selectedTemplateId.value) await loadTemplate()
|
||||
await refreshCustomItems()
|
||||
closeCustomItemModal()
|
||||
success.value = `"${item.label}" 이미지를 "${data.targetItem?.label || targetItem.label}" 기준으로 대체했어요.`
|
||||
} catch (e) {
|
||||
error.value = e?.status === 409 ? '같은 이미지/이름으로는 대체할 수 없어요.' : '이미지 대체에 실패했어요.'
|
||||
} finally {
|
||||
customItemReplacementBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -200,10 +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,
|
||||
|
||||
@@ -39,7 +39,7 @@ async function request(path, { method = 'GET', body, headers } = {}) {
|
||||
} else if (res.status >= 500) {
|
||||
emitBackendStatus({
|
||||
state: 'maintenance',
|
||||
message: '서비스 내부 점검이 필요합니다. 잠시 후 다시 이용해주세요.',
|
||||
message: '서비스 내부 점검중입니다. 잠시 후 다시 이용해주세요.',
|
||||
path,
|
||||
})
|
||||
}
|
||||
@@ -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,6 +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) =>
|
||||
|
||||
@@ -25,6 +25,10 @@ export function editorPath(topicId, tierListId, { preview = false } = {}) {
|
||||
return preview ? `${base}?preview=1` : base
|
||||
}
|
||||
|
||||
export function shareEditorPath(topicId, tierListId) {
|
||||
return `/share/editor/${encodeSegment(topicId)}/${encodeSegment(tierListId)}`
|
||||
}
|
||||
|
||||
export function mePath() {
|
||||
return '/me'
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { Teleport, computed, inject, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Sortable from 'sortablejs'
|
||||
import * as htmlToImage from 'html-to-image'
|
||||
@@ -10,7 +10,7 @@ import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
|
||||
import shareIcon from '../assets/icons/share.svg'
|
||||
import RightRailAd from '../components/RightRailAd.vue'
|
||||
import { api } from '../lib/api'
|
||||
import { editorNewPath, editorPath, loginPath, mePath, topicPath, userProfilePath } from '../lib/paths'
|
||||
import { editorNewPath, editorPath, loginPath, mePath, shareEditorPath, topicPath, userProfilePath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from '../composables/useToast'
|
||||
@@ -79,23 +79,39 @@ const poolSearchQuery = ref('')
|
||||
const selectedItemId = ref('')
|
||||
const recentDragFinishedAt = ref(0)
|
||||
const savedEditorSnapshot = ref('')
|
||||
const itemContextMenu = ref({
|
||||
open: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
itemId: '',
|
||||
})
|
||||
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)
|
||||
const canEdit = computed(() => !!auth.user && !previewMode.value && (!ownerId.value || ownerId.value === auth.user.id))
|
||||
const iconSizeOptions = [48, 64, 80, 96, 112]
|
||||
const touchSortableOptions = {
|
||||
delayOnTouchOnly: true,
|
||||
delay: 180,
|
||||
touchStartThreshold: 8,
|
||||
fallbackTolerance: 8,
|
||||
}
|
||||
const hasCustomTitle = computed(() => !!(title.value || '').trim())
|
||||
const fallbackTimestamp = computed(() => (updatedAt.value ? updatedAt.value : Date.now()))
|
||||
const effectiveAuthorName = computed(() => {
|
||||
@@ -135,7 +151,7 @@ const copiedFromLabel = computed(() => {
|
||||
return parts.join(' · ') || '복사해 온 티어표'
|
||||
})
|
||||
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
|
||||
const normalizedPoolSearchQuery = computed(() => poolSearchQuery.value.trim().toLowerCase())
|
||||
const normalizedPoolSearchQuery = computed(() => normalizeSearchText(poolSearchQuery.value))
|
||||
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
|
||||
const canRequestTemplateCreate = computed(
|
||||
() => canEdit.value && hasSavedTierList.value && templateId.value === 'freeform' && customItems.value.length > 0
|
||||
@@ -149,8 +165,9 @@ const templateRequestTargetLabel = computed(() => (templateId.value === 'freefor
|
||||
const shareTierListUrl = computed(() => {
|
||||
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
||||
if (!savedTierListId) return ''
|
||||
if (typeof window === 'undefined') return editorPath(templateId.value, savedTierListId, { preview: true })
|
||||
return new URL(editorPath(templateId.value, savedTierListId, { preview: true }), window.location.origin).toString()
|
||||
const sharePath = shareEditorPath(templateId.value, savedTierListId)
|
||||
if (typeof window === 'undefined') return sharePath
|
||||
return new URL(sharePath, window.location.origin).toString()
|
||||
})
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
@@ -158,6 +175,13 @@ watch(error, (message) => {
|
||||
error.value = ''
|
||||
})
|
||||
|
||||
function normalizeSearchText(text) {
|
||||
return String(text || '')
|
||||
.normalize('NFC')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function createAutoTierListTitle() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
const pick = (size) => Array.from({ length: size }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
|
||||
@@ -223,7 +247,7 @@ function isPoolItemVisible(itemId) {
|
||||
const query = normalizedPoolSearchQuery.value
|
||||
if (!query) return true
|
||||
const item = itemsById.value[itemId]
|
||||
const label = String(item?.label || itemId || '').toLowerCase()
|
||||
const label = normalizeSearchText(item?.label || itemId || '')
|
||||
return label.includes(query)
|
||||
}
|
||||
|
||||
@@ -329,6 +353,60 @@ function shouldIgnoreItemClick() {
|
||||
return Date.now() - recentDragFinishedAt.value < 180
|
||||
}
|
||||
|
||||
function closeItemContextMenu() {
|
||||
if (!itemContextMenu.value.open) return
|
||||
itemContextMenu.value = {
|
||||
open: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
itemId: '',
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
const viewportWidth = typeof window === 'undefined' ? 0 : window.innerWidth
|
||||
const viewportHeight = typeof window === 'undefined' ? 0 : window.innerHeight
|
||||
const menuX = Number(event?.clientX || 0)
|
||||
const menuY = Number(event?.clientY || 0)
|
||||
itemContextMenu.value = {
|
||||
open: true,
|
||||
x: viewportWidth ? Math.max(12, Math.min(menuX, viewportWidth - 180)) : menuX,
|
||||
y: viewportHeight ? Math.max(12, Math.min(menuY, viewportHeight - 72)) : menuY,
|
||||
itemId,
|
||||
}
|
||||
}
|
||||
|
||||
function getItemLocation(itemId) {
|
||||
if (!itemId) return { type: null, groupId: '', columnIndex: -1, index: -1 }
|
||||
|
||||
@@ -405,6 +483,75 @@ function moveSelectedItemToPool() {
|
||||
selectedItemId.value = ''
|
||||
}
|
||||
|
||||
function duplicateItemToPool() {
|
||||
if (!canEdit.value || !itemContextMenu.value.itemId) return
|
||||
|
||||
const sourceItem = itemsById.value[itemContextMenu.value.itemId]
|
||||
if (!sourceItem) {
|
||||
closeItemContextMenu()
|
||||
return
|
||||
}
|
||||
|
||||
const clonedId = createClonedItemId()
|
||||
itemsById.value = {
|
||||
...itemsById.value,
|
||||
[clonedId]: {
|
||||
...sourceItem,
|
||||
id: clonedId,
|
||||
},
|
||||
}
|
||||
pool.value = [clonedId, ...pool.value]
|
||||
selectedItemId.value = clonedId
|
||||
closeItemContextMenu()
|
||||
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]')) {
|
||||
event?.preventDefault?.()
|
||||
event?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
|
||||
const itemEl = target?.closest?.('[data-item-id]')
|
||||
if (canEdit.value && itemEl?.dataset?.itemId) {
|
||||
event?.preventDefault?.()
|
||||
event?.stopPropagation?.()
|
||||
openItemContextMenu(itemEl.dataset.itemId, event)
|
||||
return
|
||||
}
|
||||
|
||||
if (!itemContextMenu.value.open) return
|
||||
closeItemContextMenu()
|
||||
}
|
||||
|
||||
function handleGlobalPointerDown(event) {
|
||||
if (!itemContextMenu.value.open) return
|
||||
const target = event?.target
|
||||
if (target?.closest?.('[data-item-context-menu]')) return
|
||||
closeItemContextMenu()
|
||||
}
|
||||
|
||||
function setGroupDropEl(groupId, columnIndex, el) {
|
||||
const key = `${groupId}::${columnIndex}`
|
||||
if (!el) {
|
||||
@@ -458,6 +605,7 @@ async function initSortables() {
|
||||
destroySortables()
|
||||
|
||||
groupSortable.value = Sortable.create(groupListEl.value, {
|
||||
...touchSortableOptions,
|
||||
animation: 160,
|
||||
handle: '[data-group-handle]',
|
||||
ghostClass: 'ghost',
|
||||
@@ -471,6 +619,7 @@ async function initSortables() {
|
||||
})
|
||||
|
||||
poolSortable.value = Sortable.create(poolEl.value, {
|
||||
...touchSortableOptions,
|
||||
group: 'tier-items',
|
||||
animation: 160,
|
||||
draggable: '[data-item-id]',
|
||||
@@ -488,6 +637,7 @@ async function initSortables() {
|
||||
|
||||
dropSortables.value = Object.entries(groupDropEls.value).map(([, el]) =>
|
||||
Sortable.create(el, {
|
||||
...touchSortableOptions,
|
||||
group: 'tier-items',
|
||||
animation: 160,
|
||||
draggable: '[data-item-id]',
|
||||
@@ -537,6 +687,7 @@ function createColumnName(index = columns.value.length) {
|
||||
|
||||
function createCustomItemLabel(fileName = '') {
|
||||
const normalized = String(fileName || '')
|
||||
.normalize('NFC')
|
||||
.replace(/\.[^.]+$/, '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
@@ -544,6 +695,10 @@ function createCustomItemLabel(fileName = '') {
|
||||
return (normalized || 'custom').slice(0, 60)
|
||||
}
|
||||
|
||||
function createClonedItemId() {
|
||||
return `dup-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
|
||||
}
|
||||
|
||||
async function addGroup() {
|
||||
groups.value = [
|
||||
...groups.value,
|
||||
@@ -1135,6 +1290,7 @@ function resetEditorStateForRoute() {
|
||||
selectedItemId.value = ''
|
||||
recentDragFinishedAt.value = 0
|
||||
savedEditorSnapshot.value = ''
|
||||
closeItemContextMenu()
|
||||
resetTemplateRequestDrafts()
|
||||
}
|
||||
|
||||
@@ -1227,6 +1383,7 @@ async function loadEditorState() {
|
||||
if (loadToken !== editorLoadToken) return
|
||||
|
||||
syncSavedEditorSnapshot()
|
||||
scheduleEditorSidebarMeasure()
|
||||
if (canEdit.value) {
|
||||
await initSortables()
|
||||
}
|
||||
@@ -1240,7 +1397,32 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
window.addEventListener('pointerdown', handleGlobalPointerDown)
|
||||
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(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('pointerdown', handleGlobalPointerDown)
|
||||
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()
|
||||
})
|
||||
@@ -1278,14 +1460,6 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pool.length" class="previewOnly__pool">
|
||||
<div class="previewOnly__poolTitle">남은 아이템</div>
|
||||
<div class="previewOnly__poolGrid">
|
||||
<div v-for="id in pool" :key="id" class="previewOnly__poolItem previewOnly__poolItem--inactive">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="previewOnly__footer">
|
||||
<span>{{ effectiveAuthorName }}</span>
|
||||
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
||||
@@ -1453,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">
|
||||
행/열 이름과 순서를 바꾸고 아이템을 드래그해서 배치할 수 있어요.
|
||||
@@ -1557,7 +1739,12 @@ onUnmounted(() => {
|
||||
:data-item-id="id"
|
||||
@click.stop="selectItemByClick(id)"
|
||||
>
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<img
|
||||
:src="resolveItemSrc(itemsById[id])"
|
||||
class="thumb"
|
||||
:alt="itemsById[id]?.label || id"
|
||||
draggable="false"
|
||||
/>
|
||||
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
||||
<button
|
||||
v-if="canEdit && !isExporting"
|
||||
@@ -1569,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>
|
||||
@@ -1602,50 +1799,92 @@ 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" />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="itemContextMenu.open && canEdit"
|
||||
class="itemContextMenu"
|
||||
:style="{ left: `${itemContextMenu.x}px`, top: `${itemContextMenu.y}px` }"
|
||||
data-item-context-menu
|
||||
@click.stop
|
||||
@contextmenu.prevent.stop
|
||||
>
|
||||
<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>
|
||||
|
||||
</section>
|
||||
@@ -1785,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;
|
||||
@@ -2561,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;
|
||||
@@ -2568,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 {
|
||||
@@ -2882,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;
|
||||
@@ -2937,9 +3238,58 @@ 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;
|
||||
}
|
||||
|
||||
.itemContextMenu {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
min-width: 150px;
|
||||
padding: 8px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: color-mix(in srgb, var(--theme-main-bg) 96%, transparent);
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.itemContextMenu__action {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
padding: 11px 12px;
|
||||
background: transparent;
|
||||
color: var(--theme-text);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.itemContextMenu__action:hover {
|
||||
background: var(--theme-pill-bg);
|
||||
}
|
||||
.itemContextMenu__action--danger {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -3002,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;
|
||||
}
|
||||
@@ -3028,6 +3384,9 @@ onUnmounted(() => {
|
||||
.previewOnly {
|
||||
padding: 14px;
|
||||
}
|
||||
.viewerSidebar__section {
|
||||
margin-top: 0;
|
||||
}
|
||||
.pool {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@@ -333,8 +333,10 @@ watch(userId, loadProfile, { immediate: true })
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard__thumbWrap {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
padding: 14px 14px 0;
|
||||
@@ -363,6 +365,7 @@ watch(userId, loadProfile, { immediate: true })
|
||||
padding: 16px 18px 18px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard__titleRow,
|
||||
.boardCard__metaRow {
|
||||
|
||||
Reference in New Issue
Block a user