Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3af696cae | |||
| 14674bc7ac | |||
| d6576dc661 | |||
| fd2969c780 | |||
| 8aa60231a3 | |||
| 64b3e3e3df | |||
| 5f6f01942e | |||
| 7e80320e9f | |||
| fb00ddb1d8 |
@@ -95,6 +95,7 @@ function mapImageAssetRow(row) {
|
||||
id: row.id,
|
||||
contentHash: row.content_hash,
|
||||
src: row.src || '',
|
||||
labelOverride: row.label_override || '',
|
||||
mimeType: row.mime_type || 'image/webp',
|
||||
byteSize: Number(row.byte_size || 0),
|
||||
originalByteSize: Number(row.original_byte_size || 0),
|
||||
@@ -342,6 +343,7 @@ async function ensureSchema() {
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
content_hash CHAR(64) NOT NULL UNIQUE,
|
||||
src VARCHAR(255) NOT NULL UNIQUE,
|
||||
label_override VARCHAR(120) NOT NULL DEFAULT '',
|
||||
mime_type VARCHAR(32) NOT NULL DEFAULT 'image/webp',
|
||||
byte_size INT UNSIGNED NOT NULL,
|
||||
original_byte_size INT UNSIGNED NOT NULL,
|
||||
@@ -352,6 +354,11 @@ async function ensureSchema() {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
const imageAssetLabelColumns = await query("SHOW COLUMNS FROM image_assets LIKE 'label_override'")
|
||||
if (!imageAssetLabelColumns.length) {
|
||||
await query("ALTER TABLE image_assets ADD COLUMN label_override VARCHAR(120) NOT NULL DEFAULT '' AFTER src")
|
||||
}
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS image_optimization_jobs (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
@@ -396,6 +403,22 @@ async function ensureSchema() {
|
||||
if (templateRequestSourceTierListColumns[0]?.Null !== 'YES') {
|
||||
await query('ALTER TABLE template_requests MODIFY source_tierlist_id VARCHAR(64) NULL')
|
||||
}
|
||||
const templateRequestTypeColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'request_type'")
|
||||
if (!templateRequestTypeColumns.length) {
|
||||
await query("ALTER TABLE template_requests ADD COLUMN request_type VARCHAR(20) NOT NULL DEFAULT 'create' AFTER id")
|
||||
}
|
||||
const templateRequestSourceGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_game_id'")
|
||||
if (!templateRequestSourceGameColumns.length) {
|
||||
await query("ALTER TABLE template_requests ADD COLUMN source_game_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id")
|
||||
}
|
||||
const templateRequestTargetGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'target_game_id'")
|
||||
if (!templateRequestTargetGameColumns.length) {
|
||||
await query("ALTER TABLE template_requests ADD COLUMN target_game_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_game_id")
|
||||
}
|
||||
const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'")
|
||||
if (!templateRequestStatusColumns.length) {
|
||||
await query("ALTER TABLE template_requests ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending' AFTER target_game_id")
|
||||
}
|
||||
const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'")
|
||||
if (!templateRequestGroupsColumns.length) {
|
||||
await query("ALTER TABLE template_requests ADD COLUMN groups_json LONGTEXT NOT NULL AFTER items_json")
|
||||
@@ -420,6 +443,8 @@ async function ensureSchema() {
|
||||
const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'")
|
||||
if (!tierListSourceIdColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER show_character_names")
|
||||
} else if (tierListSourceIdColumns[0]?.Null !== 'YES') {
|
||||
await query('ALTER TABLE tierlists MODIFY source_tierlist_id VARCHAR(64) NULL DEFAULT NULL')
|
||||
}
|
||||
const tierListSourceTitleColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_title'")
|
||||
if (!tierListSourceTitleColumns.length) {
|
||||
@@ -649,6 +674,11 @@ async function listGameItems(gameId) {
|
||||
return rows.map(mapGameItemRow)
|
||||
}
|
||||
|
||||
async function findGameItemById(itemId) {
|
||||
const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId])
|
||||
return mapGameItemRow(rows[0])
|
||||
}
|
||||
|
||||
async function getGameDetail(gameId) {
|
||||
const game = await findGameById(gameId)
|
||||
if (!game) return null
|
||||
@@ -674,7 +704,7 @@ async function updateGameThumbnail(gameId, thumbnailSrc) {
|
||||
|
||||
async function findImageAssetByHash(contentHash) {
|
||||
const rows = await query(
|
||||
'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE content_hash = ? LIMIT 1',
|
||||
'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE content_hash = ? LIMIT 1',
|
||||
[contentHash]
|
||||
)
|
||||
return mapImageAssetRow(rows[0])
|
||||
@@ -682,7 +712,7 @@ async function findImageAssetByHash(contentHash) {
|
||||
|
||||
async function findImageAssetBySrc(src) {
|
||||
const rows = await query(
|
||||
'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE src = ? LIMIT 1',
|
||||
'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE src = ? LIMIT 1',
|
||||
[src]
|
||||
)
|
||||
return mapImageAssetRow(rows[0])
|
||||
@@ -760,7 +790,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
|
||||
const cutoff = now() - safeMinAgeHours * 60 * 60 * 1000
|
||||
|
||||
const assets = (await query(
|
||||
`SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE created_at <= ? ORDER BY created_at ASC LIMIT ${safeLimit}`,
|
||||
`SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE created_at <= ? ORDER BY created_at ASC LIMIT ${safeLimit}`,
|
||||
[cutoff]
|
||||
)).map(mapImageAssetRow)
|
||||
|
||||
@@ -801,7 +831,7 @@ async function deleteImageAssets(ids) {
|
||||
if (!uniqueIds.length) return []
|
||||
const placeholders = uniqueIds.map(() => '?').join(', ')
|
||||
const rows = await query(
|
||||
`SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id IN (${placeholders})`,
|
||||
`SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id IN (${placeholders})`,
|
||||
uniqueIds
|
||||
)
|
||||
await query(`DELETE FROM image_assets WHERE id IN (${placeholders})`, uniqueIds)
|
||||
@@ -926,11 +956,19 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
|
||||
|
||||
async function listImageAssets() {
|
||||
const rows = await query(
|
||||
'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets ORDER BY created_at DESC'
|
||||
'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'
|
||||
)
|
||||
return rows.map(mapImageAssetRow)
|
||||
}
|
||||
|
||||
async function findImageAssetById(id) {
|
||||
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',
|
||||
[id]
|
||||
)
|
||||
return mapImageAssetRow(rows[0])
|
||||
}
|
||||
|
||||
async function getReferencedUploadFootprint() {
|
||||
const [referencedSrcs, assets] = await Promise.all([listReferencedUploadSources(), listImageAssets()])
|
||||
const assetMap = new Map(assets.map((asset) => [asset.src, asset]))
|
||||
@@ -1056,6 +1094,34 @@ async function updateGameItemLabel(itemId, label) {
|
||||
return mapGameItemRow(rows[0])
|
||||
}
|
||||
|
||||
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
|
||||
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 {
|
||||
id: row.id,
|
||||
ownerId: row.owner_id,
|
||||
src: row.src,
|
||||
label: row.label,
|
||||
createdAt: Number(row.created_at),
|
||||
ownerName: row.nickname || row.email,
|
||||
ownerEmail: row.email,
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
return mapImageAssetRow(rows[0])
|
||||
}
|
||||
|
||||
async function deleteGameItem(itemId) {
|
||||
const gameItemRows = await query('SELECT game_id FROM game_items WHERE id = ? LIMIT 1', [itemId])
|
||||
const gameId = gameItemRows[0]?.game_id
|
||||
@@ -1208,32 +1274,70 @@ async function getCustomItemUsageMeta() {
|
||||
async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) {
|
||||
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||
const normalizedPage = Math.max(Number(page) || 1, 1)
|
||||
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 searchText = (queryText || '').trim()
|
||||
const hasQuery = !!searchText
|
||||
const search = `%${searchText}%`
|
||||
|
||||
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 [customRows, gameItemRows, assetRows, usageMeta] = await Promise.all([
|
||||
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
|
||||
${hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''}
|
||||
ORDER BY c.created_at DESC
|
||||
`,
|
||||
hasQuery ? [search, search, search, search] : []
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT
|
||||
gi.id,
|
||||
gi.game_id,
|
||||
gi.src,
|
||||
gi.label,
|
||||
gi.created_at,
|
||||
g.name AS game_name
|
||||
FROM game_items gi
|
||||
INNER JOIN games g ON g.id = gi.game_id
|
||||
${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.game_id LIKE ? OR g.name LIKE ?' : ''}
|
||||
ORDER BY gi.created_at DESC
|
||||
`,
|
||||
hasQuery ? [search, search, search, search] : []
|
||||
),
|
||||
query(
|
||||
`
|
||||
SELECT ia.id, ia.src, ia.label_override, ia.created_at
|
||||
FROM image_assets ia
|
||||
WHERE ia.src LIKE '/uploads/assets/%'
|
||||
${hasQuery ? 'AND ia.src LIKE ?' : ''}
|
||||
ORDER BY ia.created_at DESC
|
||||
`,
|
||||
hasQuery ? [search] : []
|
||||
),
|
||||
getCustomItemUsageMeta(),
|
||||
])
|
||||
|
||||
const { usageMap, linkedGamesMap } = await getCustomItemUsageMeta()
|
||||
const allItems = rows
|
||||
.map((row) => ({
|
||||
const templateLinkedBySrc = new Map()
|
||||
gameItemRows.forEach((row) => {
|
||||
if (!row?.src) return
|
||||
if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map())
|
||||
templateLinkedBySrc.get(row.src).set(row.game_id, {
|
||||
id: row.game_id,
|
||||
name: row.game_name || row.game_id,
|
||||
})
|
||||
})
|
||||
|
||||
const customItems = customRows.map((row) => {
|
||||
const linkedGames = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values())
|
||||
return {
|
||||
id: row.id,
|
||||
ownerId: row.owner_id,
|
||||
src: row.src,
|
||||
@@ -1241,10 +1345,60 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
|
||||
createdAt: Number(row.created_at),
|
||||
ownerName: row.nickname || row.email,
|
||||
ownerEmail: row.email,
|
||||
usageCount: usageMap.get(row.id) || 0,
|
||||
linkedGames: linkedGamesMap.get(row.id) || [],
|
||||
usageCount: usageMeta.usageMap.get(row.id) || 0,
|
||||
linkedGames,
|
||||
sourceType: 'user',
|
||||
sourceLabel: '사용자 업로드',
|
||||
canDelete: true,
|
||||
}
|
||||
})
|
||||
|
||||
const templateSrcSet = new Set(gameItemRows.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))
|
||||
.map((row) => ({
|
||||
id: `asset:${row.id}`,
|
||||
assetId: row.id,
|
||||
ownerId: '',
|
||||
src: row.src,
|
||||
label: row.label_override || (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음',
|
||||
createdAt: Number(row.created_at || 0),
|
||||
ownerName: '관리자 보관 자산',
|
||||
ownerEmail: '',
|
||||
usageCount: 0,
|
||||
linkedGames: [],
|
||||
sourceType: 'template',
|
||||
sourceLabel: '관리자 템플릿',
|
||||
canDelete: true,
|
||||
sourceGameId: '',
|
||||
sourceGameName: '',
|
||||
isAssetLibraryItem: true,
|
||||
}))
|
||||
.filter((item) => (orphanOnly ? item.usageCount === 0 : true))
|
||||
|
||||
const templateItems = gameItemRows.map((row) => ({
|
||||
id: row.id,
|
||||
ownerId: '',
|
||||
src: row.src,
|
||||
label: row.label,
|
||||
createdAt: Number(row.created_at),
|
||||
ownerName: row.game_name || row.game_id,
|
||||
ownerEmail: '',
|
||||
usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size,
|
||||
linkedGames: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
|
||||
sourceType: 'template',
|
||||
sourceLabel: '관리자 템플릿',
|
||||
canDelete: true,
|
||||
sourceGameId: row.game_id,
|
||||
sourceGameName: row.game_name || row.game_id,
|
||||
}))
|
||||
|
||||
const allItems = [...customItems, ...templateItems, ...assetLibraryItems]
|
||||
.filter((item) => {
|
||||
if (!orphanOnly) return true
|
||||
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0
|
||||
})
|
||||
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
|
||||
|
||||
const total = allItems.length
|
||||
const offset = (normalizedPage - 1) * normalizedLimit
|
||||
@@ -1935,11 +2089,13 @@ module.exports = {
|
||||
listGames,
|
||||
findGameById,
|
||||
listGameItems,
|
||||
findGameItemById,
|
||||
getGameDetail,
|
||||
createGame,
|
||||
updateGameThumbnail,
|
||||
findImageAssetByHash,
|
||||
findImageAssetBySrc,
|
||||
findImageAssetById,
|
||||
createImageAsset,
|
||||
createImageOptimizationJob,
|
||||
findImageOptimizationJobById,
|
||||
@@ -1954,6 +2110,8 @@ module.exports = {
|
||||
getImageAssetStats,
|
||||
createGameItem,
|
||||
updateGameItemLabel,
|
||||
updateCustomItemLabel,
|
||||
updateImageAssetLabel,
|
||||
deleteGameItem,
|
||||
deleteGame,
|
||||
updateGameDisplayOrder,
|
||||
|
||||
@@ -75,7 +75,7 @@ async function optimizeAndPersist({ file, width, height, fit, quality }) {
|
||||
}
|
||||
}
|
||||
|
||||
const filename = String(Date.now()) + '-' + nanoid() + '.webp'
|
||||
const filename = nanoid() + '.webp'
|
||||
const absoluteDir = path.join(UPLOAD_ROOT, OPTIMIZED_DIR)
|
||||
const absolutePath = path.join(absoluteDir, filename)
|
||||
const src = '/uploads/' + OPTIMIZED_DIR + '/' + filename
|
||||
|
||||
@@ -8,11 +8,15 @@ const { nanoid } = require('nanoid')
|
||||
const {
|
||||
findUserById,
|
||||
findGameById,
|
||||
findGameItemById,
|
||||
findImageAssetById,
|
||||
createGame,
|
||||
listGames,
|
||||
updateGameThumbnail,
|
||||
createGameItem,
|
||||
updateGameItemLabel,
|
||||
updateCustomItemLabel,
|
||||
updateImageAssetLabel,
|
||||
deleteGameItem,
|
||||
deleteGame,
|
||||
updateGameDisplayOrder,
|
||||
@@ -190,6 +194,32 @@ router.delete('/games/:gameId', requireAdmin, async (req, res) => {
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
label: z.string().trim().min(1).max(60),
|
||||
sourceType: z.enum(['template', 'user']).optional().default('user'),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const itemId = req.params.itemId
|
||||
if (itemId.startsWith('asset:')) {
|
||||
const updated = await updateImageAssetLabel(itemId.slice(6), parsed.data.label)
|
||||
if (!updated) return res.status(404).json({ error: 'not_found' })
|
||||
return res.json({ item: updated })
|
||||
}
|
||||
|
||||
if (parsed.data.sourceType === 'template') {
|
||||
const updated = await updateGameItemLabel(itemId, parsed.data.label)
|
||||
if (!updated) return res.status(404).json({ error: 'not_found' })
|
||||
return res.json({ item: updated })
|
||||
}
|
||||
|
||||
const updated = await updateCustomItemLabel(itemId, parsed.data.label)
|
||||
if (!updated) return res.status(404).json({ error: 'not_found' })
|
||||
return res.json({ item: updated })
|
||||
})
|
||||
|
||||
router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
@@ -308,6 +338,20 @@ router.post('/image-assets/stats/reset', requireAdmin, async (req, res) => {
|
||||
res.json({ deletedCount })
|
||||
})
|
||||
|
||||
async function removeUploadFiles(srcs) {
|
||||
await Promise.all(
|
||||
(srcs || []).map(async (src) => {
|
||||
if (!src || !src.startsWith('/uploads/')) return
|
||||
const absolutePath = path.join(__dirname, '..', '..', src.replace(/^\//, ''))
|
||||
try {
|
||||
await fs.unlink(absolutePath)
|
||||
} catch (e) {
|
||||
if (e?.code !== 'ENOENT') throw e
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function removeCustomItemFiles(items) {
|
||||
await Promise.all(
|
||||
items.map(async (item) => {
|
||||
@@ -322,12 +366,12 @@ async function removeCustomItemFiles(items) {
|
||||
)
|
||||
}
|
||||
|
||||
async function promoteCustomItemToGameItem({ customItem, gameId }) {
|
||||
async function promoteLibraryItemToGameItem({ item, gameId }) {
|
||||
return createGameItem({
|
||||
id: nanoid(),
|
||||
gameId,
|
||||
src: customItem.src || '',
|
||||
label: customItem.label,
|
||||
src: item.src || '',
|
||||
label: item.label,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -425,15 +469,31 @@ async function createGameTemplateFromRequest({ templateRequest, gameId, gameName
|
||||
}
|
||||
|
||||
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
const result = await listCustomItems({ page: 1, limit: 200, orphanOnly: false })
|
||||
const result = await listCustomItems({ page: 1, limit: 10000, orphanOnly: false })
|
||||
const target = result.items.find((item) => item.id === req.params.itemId)
|
||||
if (!target) return res.status(404).json({ error: 'not_found' })
|
||||
if (target.sourceType === 'template') {
|
||||
if (String(target.id || '').startsWith('asset:')) {
|
||||
const assetId = String(target.id).slice('asset:'.length)
|
||||
const asset = await findImageAssetById(assetId)
|
||||
if (!asset) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteImageAssets([assetId])
|
||||
await removeUploadFiles([asset.src])
|
||||
return res.json({ ok: true, sourceType: 'template-asset' })
|
||||
}
|
||||
|
||||
await deleteGameItem(target.id)
|
||||
return res.json({ ok: true, sourceType: 'template' })
|
||||
}
|
||||
|
||||
if (!target.canDelete) return res.status(409).json({ error: 'item_locked' })
|
||||
if (target.linkedGames.length > 0) return res.status(409).json({ error: 'item_linked' })
|
||||
if (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)
|
||||
res.json({ ok: true })
|
||||
res.json({ ok: true, sourceType: 'user' })
|
||||
})
|
||||
|
||||
router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
|
||||
@@ -447,9 +507,21 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
|
||||
if (!game) return res.status(404).json({ error: 'game_not_found' })
|
||||
|
||||
const customItem = await findCustomItemById(req.params.itemId)
|
||||
if (!customItem) return res.status(404).json({ error: 'not_found' })
|
||||
const gameItem = customItem ? null : await findGameItemById(req.params.itemId)
|
||||
const assetItemId = String(req.params.itemId || '')
|
||||
const imageAsset = !customItem && !gameItem && assetItemId.startsWith('asset:') ? await findImageAssetById(assetItemId.slice(6)) : null
|
||||
const sourceItem =
|
||||
customItem ||
|
||||
gameItem ||
|
||||
(imageAsset
|
||||
? {
|
||||
src: imageAsset.src || '',
|
||||
label: imageAsset.labelOverride || path.basename(imageAsset.src || '', path.extname(imageAsset.src || '')) || 'item',
|
||||
}
|
||||
: null)
|
||||
if (!sourceItem) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const item = await promoteCustomItemToGameItem({ customItem, gameId: game.id })
|
||||
const item = await promoteLibraryItemToGameItem({ item: sourceItem, gameId: game.id })
|
||||
res.json({ item })
|
||||
})
|
||||
|
||||
|
||||
11
docs/todo.md
11
docs/todo.md
@@ -10,3 +10,14 @@
|
||||
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
|
||||
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
|
||||
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
|
||||
|
||||
- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다.
|
||||
|
||||
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
|
||||
|
||||
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
|
||||
|
||||
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
|
||||
|
||||
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
|
||||
- 템플릿 요청 500 오류 대응으로 `template_requests`와 `tierlists` 레거시 컬럼 자동 마이그레이션은 반영했으므로, 이후에는 실제 운영 DB에서 백엔드 재시작 후 요청/승인 흐름을 한 번 더 확인한다.
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-01 v1.3.42
|
||||
- 템플릿 요청 시 `내 티어리스트에도 저장`이 켜져 있을 때만 500 오류가 날 수 있던 레거시 `tierlists.source_tierlist_id` nullability 문제도 함께 보강해, 오래된 DB 스키마에서도 요청 전 저장 흐름이 막히지 않도록 정리함.
|
||||
- 따라서 템플릿 요청 관련 레거시 호환 보정은 `template_requests`와 `tierlists` 양쪽에 모두 반영됐고, 실제 적용을 위해서는 백엔드 재시작 후 재확인이 필요함.
|
||||
|
||||
## 2026-04-01 v1.3.41
|
||||
- 템플릿 요청 등록 시 500 오류가 날 수 있던 레거시 DB 호환 문제를 보강해, 기존 `template_requests` 테이블에 `request_type`, `source_game_id`, `target_game_id`, `status` 컬럼이 빠져 있어도 서버 시작 시 자동으로 마이그레이션되도록 함.
|
||||
- 따라서 저장 여부와 무관하게 템플릿 요청 흐름은 유지되고, 구버전 DB를 사용 중이더라도 백엔드 재시작 후 같은 요청이 정상 저장되도록 안정성을 높임.
|
||||
|
||||
## 2026-04-01 v1.3.40
|
||||
- 관리자 아이템 상세 모달은 내부 스크롤바를 숨기고 본문 스크롤이 배경으로 전파되지 않도록 body scroll lock과 ESC 닫기를 추가해, 검수 중 배경 화면이 함께 움직이던 불편을 줄임.
|
||||
- 관리자 티어표 관리에서는 `완성본 보기` 흐름을 제거하고, 전체 티어표의 추가 아이템을 클릭하면 같은 아이템 관리 모달로 열어 게임 검색·템플릿 추가·새 템플릿 생성까지 같은 문법으로 처리할 수 있게 통일함.
|
||||
- 템플릿 요청 관리의 `요청 미리보기`는 단순 썸네일이 아니라 행·열 구조, 열 이름, 배치된 아이템, 미사용 아이템까지 함께 보이는 실제 보드형 미리보기로 다시 구성해 요청 내용을 한 번에 검수할 수 있게 함.
|
||||
|
||||
## 2026-04-01 v1.3.39
|
||||
- 관리자 템플릿 요청 미리보기는 요청 시 저장된 보드 스냅샷이 비어 있을 경우 요청 아이템 배열을 fallback으로 사용해, 대표 썸네일만 보이는 상황을 줄이고 요청 내용을 더 안정적으로 확인할 수 있게 보정함.
|
||||
- 관리자 아이템 상세 모달에는 아이템 이름 입력과 저장 버튼을 추가해, 템플릿 아이템·사용자 업로드·보관 자산 모두 파일명과 무관하게 사람이 읽기 좋은 이름으로 다시 정리할 수 있게 함.
|
||||
- 보관 자산용 image asset에는 이름 override 컬럼을 추가해, 무작위 WebP 파일명을 그대로 노출하지 않고 라이브러리 표시명만 따로 관리할 수 있게 확장함.
|
||||
|
||||
## 2026-04-01 v1.3.38
|
||||
- Settings 화면 오른쪽 사이드의 테마 설정 패널은 다시 쓰기 전까지 숨김 처리하고, 현재 기본 다크모드를 유지한 채 다른 화면과 동일하게 스폰서 광고만 노출되도록 정리함.
|
||||
- 관리자 아이템 모달에서 템플릿에 사용 중인 게임 배지는 다크모드에서도 읽히는 텍스트 색으로 맞추고, hover/focus 전환 효과를 추가해 상호작용이 더 분명하게 보이도록 보강함.
|
||||
- 관리자 아이템 모달은 데스크톱에서 최소 폭을 800px로 늘리고 최대 높이를 뷰포트 안으로 제한했으며, 16:9 이미지는 높이 상한을 둬서 모달이 넓어질 때도 이미지와 하단 버튼이 과하게 뭉개지지 않도록 정리함.
|
||||
|
||||
## 2026-04-01 v1.3.37
|
||||
- 가이드 모달은 모바일에서 왼쪽 단계 목록 대신 현재 단계만 선택하는 셀렉트형 피커를 중심으로 쓰도록 높이와 내부 스크롤 구조를 다시 잡아, 작은 화면에서도 내용이 잘리지 않고 조작할 수 있게 정리함.
|
||||
- 관리자 아이템 상세 모달은 가이드 모달과 같은 큰 2단 셸 문법으로 다시 묶어, 왼쪽 게임 선택 패널과 오른쪽 이미지·메타·액션 영역이 더 넓고 여유 있게 보이도록 재구성함.
|
||||
- 아이템 상세 모달 내부 정보 카드와 액션 영역도 같은 톤의 패널형 블록으로 정리해, 가이드와 관리자 모달 사이의 시각적 통일감을 높임.
|
||||
|
||||
## 2026-04-01 v1.3.36
|
||||
- `내 티어표` 화면 헤더를 공통 `pageHead` 문법으로 통일하고, 라이트모드에서는 공통 `railHeader` 배경을 사이드 레일과 같은 톤으로 맞춰 화면 간 상단 밀도 차를 줄임.
|
||||
- 관리자 아이템 상세 모달은 더 넓은 비율로 키우고, 템플릿에 연결된 게임 이름은 hover 가능한 버튼으로 바꿔 클릭 시 해당 게임이 선택된 `게임 관리` 탭으로 바로 이동할 수 있게 함.
|
||||
- 관리자 아이템 라이브러리는 이제 게임에 연결된 템플릿 이미지뿐 아니라 연결이 해제된 `/uploads/assets/` 보관 자산도 함께 보여줘, 게임 목록에서 아이템을 제거해도 아이템 관리에서는 계속 검수·재연결할 수 있게 정리함.
|
||||
- 아이템 관리 탭은 다른 탭으로 이동했다가 돌아오면 검색어와 필터를 초기화해, 결과가 남아 있어 목록이 비어 보이는 오해를 줄이도록 조정함.
|
||||
|
||||
## 2026-04-01 v1.3.35
|
||||
- 라이트모드에서 홈 게임 카드의 메타 텍스트와 대표 썸네일 플레이스홀더, 브랜드 타이틀 색을 다시 정리하고, 전체 밝기도 약간 눌러 눈부심이 덜한 회백색 톤으로 보정함.
|
||||
- 관리자 아이템 상세 모달은 더 넓은 2단 레이아웃으로 키우고, 브라우저 뒤로가기 시 페이지 이탈 대신 모달이 먼저 닫히도록 히스토리 동작을 보강함.
|
||||
- 아이템 라이브러리의 삭제 기준을 다시 정리해, 사용자 업로드는 어디에도 연결되지 않았을 때만 삭제하고 관리자 템플릿 이미지는 라이브러리에서도 해당 템플릿 항목을 제거할 수 있게 확장함.
|
||||
|
||||
## 2026-04-01 v1.3.34
|
||||
- 관리자 아이템 관리 오른쪽 사이드에서는 `가져올 게임` 셀렉트를 제거하고, 사용자 업로드와 관리자 템플릿 이미지를 함께 검수하는 라이브러리 흐름으로 단순화함.
|
||||
- 아이템 상세 모달은 좌측에 검색/정렬 가능한 게임 리스트를 두고 우측에 이미지·메타·액션을 배치하는 2단 레이아웃으로 재구성해, 많은 게임 속에서도 직접 검수 후 템플릿에 연결하기 쉽게 정리함.
|
||||
- 아이템 라이브러리에는 이제 관리자 템플릿 이미지도 함께 표시하고, 배지로 `사용자 업로드 / 관리자 템플릿`을 구분하며 새 업로드 WebP 파일명에서는 시간 정보처럼 보이는 접두 숫자를 제거함.
|
||||
- 템플릿 아이템까지 함께 보이는 구조에 맞춰 삭제 API도 사용자 업로드이면서 템플릿에 연결되지 않은 항목만 지울 수 있도록 안전 장치를 보강함.
|
||||
|
||||
## 2026-04-01 v1.3.33
|
||||
- 라이트모드/다크모드 2차 보정으로 관리자 화면과 티어 에디터의 카드, 패널, 입력창, 모달, 썸네일 프레임을 전역 테마 변수 기준으로 다시 맞춰, 후속 화면에서도 명도 차가 더 자연스럽게 이어지도록 정리함.
|
||||
- 공통 셸도 함께 손봐서 좌측 사이드 아이콘 필터와 텍스트 대비를 테마 변수 기반으로 전환하고, 가이드 모달·축소 검색 모달·내비 활성 상태까지 라이트모드에서 읽기 쉬운 톤으로 보정함.
|
||||
|
||||
@@ -128,7 +128,7 @@ const isGuidePrevDisabled = computed(() => guideStepIndex.value <= 0)
|
||||
const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.length - 1)
|
||||
const isLightTheme = computed(() => themeMode.value === 'light')
|
||||
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드'))
|
||||
const showSettingsThemePanel = computed(() => route.name === 'profile')
|
||||
const showSettingsThemePanel = computed(() => false && route.name === 'profile')
|
||||
const showGameHubViewToggle = computed(() => route.name === 'gameHub')
|
||||
const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||
const leftBottomPrimaryAction = computed(() => {
|
||||
@@ -526,6 +526,12 @@ function submitGlobalSearch() {
|
||||
<div class="guideModal__sidebar">
|
||||
<div class="guideModal__eyebrow">Guide</div>
|
||||
<div class="guideModal__title">티어 메이커 기능 안내</div>
|
||||
<div class="guideModal__mobilePicker">
|
||||
<label class="guideModal__mobileLabel" for="guide-step-select">단계 선택</label>
|
||||
<select id="guide-step-select" class="guideModal__mobileSelect" :value="guideStepIndex" @change="selectGuideStep(Number($event.target.value))">
|
||||
<option v-for="(step, index) in guideSteps" :key="step.id + '-select'" :value="index">{{ index + 1 }}. {{ step.title }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="guideModal__list">
|
||||
<button
|
||||
v-for="(step, index) in guideSteps"
|
||||
@@ -684,6 +690,7 @@ function submitGlobalSearch() {
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--theme-border);
|
||||
background: var(--theme-rail-bg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -1065,10 +1072,7 @@ function submitGlobalSearch() {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.05em;
|
||||
background-image: linear-gradient(90deg, #ff75c3 0%, #ffa647 20%, #ffe83f 40%, #9fff5b 60%, #70e2ff 80%, #cd93ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
|
||||
.workspaceHead__brandSub {
|
||||
@@ -1268,7 +1272,7 @@ function submitGlobalSearch() {
|
||||
|
||||
.guideModal__dialog {
|
||||
width: min(1180px, calc(100vw - 40px));
|
||||
min-height: min(760px, calc(100dvh - 64px));
|
||||
height: min(760px, calc(100dvh - 64px));
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
border-radius: 28px;
|
||||
@@ -1301,6 +1305,28 @@ function submitGlobalSearch() {
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.guideModal__mobilePicker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.guideModal__mobileLabel {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
|
||||
.guideModal__mobileSelect {
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
padding: 0 18px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(77, 127, 233, 0.46);
|
||||
background: rgba(77, 127, 233, 0.14);
|
||||
color: var(--theme-text-strong);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.guideModal__list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -1343,8 +1369,10 @@ function submitGlobalSearch() {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
padding: 24px 28px 28px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
|
||||
.guideModal__close {
|
||||
justify-self: end;
|
||||
border: 0;
|
||||
@@ -1356,6 +1384,7 @@ function submitGlobalSearch() {
|
||||
|
||||
.guideModal__content {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 52px minmax(0, 1fr) 52px;
|
||||
gap: 16px;
|
||||
@@ -1364,6 +1393,7 @@ function submitGlobalSearch() {
|
||||
|
||||
.guideModal__body {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
@@ -1619,7 +1649,7 @@ function submitGlobalSearch() {
|
||||
@media (max-width: 1200px) {
|
||||
.guideModal__dialog {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: auto;
|
||||
height: min(860px, calc(100dvh - 40px));
|
||||
}
|
||||
|
||||
.guideModal__sidebar {
|
||||
@@ -1671,20 +1701,55 @@ function submitGlobalSearch() {
|
||||
|
||||
.guideModal__dialog {
|
||||
width: min(100%, calc(100vw - 24px));
|
||||
height: min(100%, calc(100dvh - 24px));
|
||||
}
|
||||
|
||||
.guideModal__sidebar {
|
||||
gap: 14px;
|
||||
padding: 20px 18px;
|
||||
}
|
||||
|
||||
.guideModal__mobilePicker {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.guideModal__list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.guideModal__main {
|
||||
padding: 20px 18px 18px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.guideModal__content {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.guideModal__arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.guideModal__body {
|
||||
align-content: start;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.guideModal__mediaPlaceholder {
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.guideModal__stepTitle {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.guideModal__stepSummary {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.guideModal__footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -1697,10 +1762,6 @@ function submitGlobalSearch() {
|
||||
.guideDockButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.guideModal__list {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
|
||||
@@ -55,6 +55,8 @@ export const api = {
|
||||
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
|
||||
promoteAdminCustomItem: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||
updateAdminCustomItemLabel: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
|
||||
promoteAdminTierListItems: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
|
||||
createAdminGameTemplateFromTierList: (tierListId, payload) =>
|
||||
|
||||
@@ -38,28 +38,28 @@
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
--theme-body-bg: #edf1f7;
|
||||
--theme-shell-bg: rgba(244, 247, 252, 0.98);
|
||||
--theme-rail-bg: rgba(248, 250, 253, 0.96);
|
||||
--theme-main-bg: rgba(241, 244, 249, 0.98);
|
||||
--theme-workspace-bg: rgba(250, 252, 255, 0.95);
|
||||
--theme-card-bg: rgba(255, 255, 255, 0.98);
|
||||
--theme-card-bg-hover: rgba(245, 248, 255, 0.98);
|
||||
--theme-card-border: rgba(26, 32, 44, 0.1);
|
||||
--theme-card-shadow: 0 18px 34px rgba(31, 41, 55, 0.08);
|
||||
--theme-surface-soft: rgba(15, 23, 42, 0.05);
|
||||
--theme-surface-soft-2: rgba(15, 23, 42, 0.07);
|
||||
--theme-surface-soft-3: rgba(15, 23, 42, 0.1);
|
||||
--theme-pill-bg: rgba(15, 23, 42, 0.04);
|
||||
--theme-border: rgba(15, 23, 42, 0.1);
|
||||
--theme-border-strong: rgba(15, 23, 42, 0.14);
|
||||
--theme-text: rgba(20, 27, 40, 0.9);
|
||||
--theme-body-bg: #e7ebf2;
|
||||
--theme-shell-bg: rgba(237, 241, 247, 0.98);
|
||||
--theme-rail-bg: rgba(243, 246, 251, 0.97);
|
||||
--theme-main-bg: rgba(232, 236, 243, 0.98);
|
||||
--theme-workspace-bg: rgba(247, 249, 252, 0.96);
|
||||
--theme-card-bg: rgba(252, 253, 255, 0.98);
|
||||
--theme-card-bg-hover: rgba(244, 247, 251, 0.98);
|
||||
--theme-card-border: rgba(31, 41, 55, 0.11);
|
||||
--theme-card-shadow: 0 18px 34px rgba(31, 41, 55, 0.07);
|
||||
--theme-surface-soft: rgba(30, 41, 59, 0.055);
|
||||
--theme-surface-soft-2: rgba(30, 41, 59, 0.075);
|
||||
--theme-surface-soft-3: rgba(30, 41, 59, 0.105);
|
||||
--theme-pill-bg: rgba(30, 41, 59, 0.045);
|
||||
--theme-border: rgba(30, 41, 59, 0.11);
|
||||
--theme-border-strong: rgba(30, 41, 59, 0.16);
|
||||
--theme-text: rgba(20, 27, 40, 0.92);
|
||||
--theme-text-strong: rgba(10, 15, 28, 0.98);
|
||||
--theme-text-muted: rgba(55, 65, 81, 0.74);
|
||||
--theme-text-soft: rgba(75, 85, 99, 0.64);
|
||||
--theme-text-faint: rgba(100, 116, 139, 0.82);
|
||||
--theme-thumb-fallback-bg: #d8dde8;
|
||||
--theme-select-arrow: rgba(55, 65, 81, 0.72);
|
||||
--theme-text-muted: rgba(55, 65, 81, 0.76);
|
||||
--theme-text-soft: rgba(75, 85, 99, 0.72);
|
||||
--theme-text-faint: rgba(100, 116, 139, 0.88);
|
||||
--theme-thumb-fallback-bg: #f6f8fb;
|
||||
--theme-select-arrow: rgba(55, 65, 81, 0.74);
|
||||
--theme-danger-bg: rgba(239, 68, 68, 0.1);
|
||||
--theme-danger-border: rgba(239, 68, 68, 0.22);
|
||||
--theme-accent-bg: rgba(64, 110, 226, 0.94);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -215,7 +215,7 @@ function thumbUrl(g) {
|
||||
font-size: 18px;
|
||||
}
|
||||
.libraryCard__meta {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
color: var(--theme-text-soft);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -66,11 +66,11 @@ function openList(t) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="dashboardHero">
|
||||
<div class="dashboardHero__left">
|
||||
<div class="dashboardHero__eyebrow">Library</div>
|
||||
<h2 class="dashboardHero__title">내 티어표</h2>
|
||||
<p class="dashboardHero__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</p>
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Library</div>
|
||||
<h2 class="pageHead__title">내 티어표</h2>
|
||||
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -110,35 +110,6 @@ function openList(t) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboardHero {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 2px 18px;
|
||||
}
|
||||
.dashboardHero__left {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.dashboardHero__eyebrow {
|
||||
font-size: 12px;
|
||||
color: var(--theme-text-soft);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.dashboardHero__title {
|
||||
margin: 4px 0 6px;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--theme-text-strong);
|
||||
}
|
||||
.dashboardHero__desc {
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
max-width: 720px;
|
||||
}
|
||||
.panel {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
|
||||
Reference in New Issue
Block a user