Compare commits

...

19 Commits

Author SHA1 Message Date
66d408dca8 릴리스: v1.3.49 템플릿 요청 저장 흐름과 관리자 미리보기 정리 2026-04-01 19:01:07 +09:00
d5b4de1629 릴리스: v1.3.48 관리자 미리보기와 새로고침 로딩 보정 2026-04-01 18:37:01 +09:00
6828b868bc 릴리스: v1.3.47 관리자 템플릿 요청 카드 정렬 2026-04-01 18:24:01 +09:00
397461b7c0 릴리스: v1.3.46 관리자 요청 미리보기 정리 2026-04-01 18:11:16 +09:00
bd3ef5d13d 릴리스: v1.3.45 템플릿 요청 저장 오류 수정 2026-04-01 18:05:59 +09:00
322b72c511 릴리스: v1.3.44 관리자 미리보기 흐름 복구 2026-04-01 18:02:49 +09:00
508806bacd 릴리스: v1.3.43 템플릿 요청 토스트 상태 보정 2026-04-01 17:58:19 +09:00
c3af696cae 릴리스: v1.3.42 템플릿 요청 저장 분기 호환 보강 2026-04-01 17:53:02 +09:00
14674bc7ac 릴리스: v1.3.41 템플릿 요청 DB 마이그레이션 보강 2026-04-01 17:48:30 +09:00
d6576dc661 릴리스: v1.3.40 관리자 모달과 요청 미리보기 정리 2026-04-01 17:38:51 +09:00
fd2969c780 릴리스: v1.3.39 요청 미리보기와 아이템 이름 편집 보강 2026-04-01 17:07:42 +09:00
8aa60231a3 릴리스: v1.3.38 설정 우측 패널과 아이템 모달 보정 2026-04-01 16:52:35 +09:00
64b3e3e3df 릴리스: v1.3.37 가이드와 관리자 아이템 모달 정리 2026-04-01 16:43:21 +09:00
5f6f01942e 릴리스: v1.3.36 관리자 아이템 라이브러리 보강 2026-04-01 16:30:58 +09:00
7e80320e9f 릴리스: v1.3.35 라이트모드와 아이템 모달 보정 2026-04-01 16:11:24 +09:00
fb00ddb1d8 릴리스: v1.3.34 관리자 아이템 라이브러리 정리 2026-04-01 15:59:09 +09:00
6bbbbc1633 릴리스: v1.3.33 관리자와 에디터 테마 후속 보정 2026-04-01 15:40:33 +09:00
9ad985f7c5 릴리스: v1.3.32 라이트 다크 모드 1차 도입 2026-04-01 15:25:21 +09:00
3b5e744130 릴리스: v1.3.31 관리자 게임 선택 리스트 CSS 반영 2026-04-01 15:16:06 +09:00
22 changed files with 1857 additions and 764 deletions

View File

@@ -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
@@ -1867,6 +2021,7 @@ async function saveTierList({
return findTierListById(existing.id, authorId)
}
const nextId = id || nanoid()
const createdAt = now()
await query(
`
@@ -1875,9 +2030,9 @@ async function saveTierList({
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
[nextId, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
)
return findTierListById(id, authorId)
return findTierListById(nextId, authorId)
}
async function duplicateTierListForUser({ tierList, targetUserId }) {
@@ -1935,11 +2090,13 @@ module.exports = {
listGames,
findGameById,
listGameItems,
findGameItemById,
getGameDetail,
createGame,
updateGameThumbnail,
findImageAssetByHash,
findImageAssetBySrc,
findImageAssetById,
createImageAsset,
createImageOptimizationJob,
findImageOptimizationJobById,
@@ -1954,6 +2111,8 @@ module.exports = {
getImageAssetStats,
createGameItem,
updateGameItemLabel,
updateCustomItemLabel,
updateImageAssetLabel,
deleteGameItem,
deleteGame,
updateGameDisplayOrder,

View File

@@ -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

View File

@@ -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 })
})

View File

@@ -67,7 +67,6 @@ const templateRequestSchema = z.object({
thumbnailSrc: z.string().max(255).optional().default(''),
isPublic: z.boolean().optional().default(false),
showCharacterNames: z.boolean().optional().default(false),
saveToMyTierList: z.boolean().optional().default(true),
groups: z.array(
z.object({
id: z.string().min(1),
@@ -243,31 +242,14 @@ router.post('/template-request', requireAuth, async (req, res) => {
if (sourceTierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
}
let savedTierList = null
if (payload.saveToMyTierList) {
savedTierList = await saveTierList({
id: sourceTierList?.id || undefined,
authorId: req.session.userId,
gameId: payload.gameId,
title: payload.requestTitle,
thumbnailSrc: payload.thumbnailSrc || '',
description: payload.requestDescription || '',
isPublic: !!payload.isPublic,
showCharacterNames: !!payload.showCharacterNames,
sourceTierListId: sourceTierList?.sourceTierListId || '',
sourceSnapshotTitle: sourceTierList?.sourceSnapshotTitle || '',
sourceSnapshotAuthor: sourceTierList?.sourceSnapshotAuthor || '',
groups: payload.groups,
pool: normalizedBoardItems,
})
}
if (!payload.sourceTierListId) return res.status(400).json({ error: 'source_tierlist_required' })
try {
const request = await createTemplateRequest({
id: nanoid(),
type: payload.type,
requesterId: req.session.userId,
sourceTierListId: savedTierList?.id || sourceTierList?.id || '',
sourceTierListId: sourceTierList?.id || '',
sourceGameId: payload.gameId,
targetGameId: payload.type === 'update' ? payload.gameId : '',
title: payload.requestTitle,
@@ -278,7 +260,7 @@ router.post('/template-request', requireAuth, async (req, res) => {
boardItems: normalizedBoardItems,
showCharacterNames: !!payload.showCharacterNames,
})
return res.json({ request, savedTierList: savedTierList ? normalizeTierList(savedTierList) : null })
return res.json({ request })
} catch (e) {
if (e?.code === 'TEMPLATE_REQUEST_EXISTS') {
return res.status(409).json({ error: 'template_request_exists' })

View File

@@ -1,5 +1,27 @@
# 의사결정 이력
## 2026-04-01 v1.3.49
- 템플릿 요청은 임시 편집 상태에 기대기보다, 먼저 저장된 티어표를 기준으로 요청 스냅샷을 만드는 편이 훨씬 안정적이라고 정리했다. 그래서 요청 버튼은 저장본이 있을 때만 노출하고, 제목이 비어 있으면 사람이 쓰는 기본 문구 대신 고유한 랜덤 제목으로 먼저 저장본을 만든 뒤 요청을 이어가도록 했다.
- 관리자 템플릿 요청 미리보기는 별도 요청 전용 보드 레이아웃을 유지하기보다, 일반 티어표 완성본과 같은 행·열·남은 아이템 문법을 그대로 재사용하는 편이 검수 체감이 가장 자연스럽다고 판단했다.
## 2026-04-01 v1.3.48
- 관리자 탭 데이터는 첫 진입 로딩만 믿기보다, 인증 완료와 탭 전환 시점에 필요한 목록을 다시 채워 넣는 편이 실제 운영 화면에서 더 안정적이라고 정리했다.
- 템플릿 요청 미리보기는 일반 티어표 보기와 완전히 같은 구현을 억지로 분기하기보다, 같은 내부 프레임 문법과 정보 밀도를 먼저 맞춰 체감 차이를 줄이는 쪽이 현실적이라고 판단했다.
## 2026-04-01 v1.3.47
- 관리자 `사용자 템플릿 요청`도 결국 검수용 카드이므로, 요청 전용 카드 문법을 따로 두기보다 `전체 티어표 관리`와 같은 카드 구조를 재사용하는 편이 더 직관적이라고 정리했다.
- 새 템플릿 생성 요청의 기본 게임 ID는 사람이 읽기 어려운 난수보다 요청 단위에서 유일한 임시값을 먼저 채워두고, 승인 전에 관리자가 수정하는 흐름이 더 현실적이라고 판단했다.
## 2026-04-01 v1.3.46
- 관리자 전체 티어표 카드에서는 좌측 영역 전체를 버튼처럼 만드는 것보다, 실제 썸네일 이미지만 미리보기 진입점으로 읽히게 두는 편이 카드 정보 구조가 덜 흔들린다고 정리했다.
- 템플릿 요청 미리보기는 일반 티어표 보기와 다른 요약 레이아웃을 새로 두기보다, 같은 내부 프레임 문법 안에서 보드 자체를 먼저 보여주는 편이 사용자가 더 자연스럽게 이해한다고 판단했다.
## 2026-04-01 v1.3.45
- 템플릿 요청에서 `내 티어리스트에도 저장`은 별도 부가 기능이 아니라 실제 저장본 생성 경로를 타므로, 새 저장본 ID는 호출자에 기대지 말고 저장 함수 내부에서 항상 보장하는 편이 더 안전하다고 정리했다.
- 개발 단계의 내부 조치 문구인 `백엔드 재시작` 같은 표현은 사용자 토스트에 직접 노출하지 않고, 운영형 재시도 안내로 낮추는 편이 맞다고 판단했다.
## 2026-04-01 v1.3.44
- 관리자 티어표 목록에서는 `보기` 버튼을 없애더라도 완성본 확인 기능 자체는 유지해야 하므로, 별도 액션 버튼보다 카드 썸네일 클릭을 미리보기 진입점으로 쓰는 편이 더 자연스럽다고 정리했다.
- 템플릿 요청 미리보기도 별도 요약 카드보다 실제 보드 구조를 우선 보여주는 쪽이 관리자 검수 흐름에 더 맞으므로, 일반 티어표 미리보기와 가까운 방향으로 통일하기로 했다.
## 2026-03-30 v1.2.25
- 홈 게임 카드는 메인 썸네일까지 없애는 것보다, 큰 썸네일은 유지하고 ID 옆의 작은 보조 표시만 제거하는 편이 원래 의도와 맞다고 정리했다.
- 좌우 하단 액션 여백은 `margin`으로 푸터 전체를 밀기보다, 푸터 내부 `padding-bottom`으로 확보해야 버튼 자체는 항상 보이고 여백만 남는다고 판단했다.

View File

@@ -12,8 +12,8 @@
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
- 화면 파일: `frontend/src/views/TierEditorView.vue`
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드
- 연동 API: `GET /api/games/:gameId`, `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`
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청
- 연동 API: `GET /api/games/:gameId`, `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`
- 화면 파일: `frontend/src/views/LoginView.vue`
@@ -37,7 +37,7 @@
## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-game-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
## `/profile`

View File

@@ -49,12 +49,12 @@
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
- 공통 상단 토글 버튼은 Teleport로 이동한 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다.
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다.
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다. 템플릿 등록/업데이트 요청 버튼은 저장된 티어표가 있을 때만 노출하며, 제목이 비어 있는 상태에서 저장하면 랜덤 고유 제목을 먼저 부여해 저장본을 만든다.
- 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
- 관리자 화면
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다.
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다. 템플릿 요청 카드는 전체 티어표 카드와 같은 썸네일 좌측/정보 우측 구조를 따르며, 요청 미리보기는 일반 티어표 완성본과 같은 행·열 보드 문법으로 검수한다.
- 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다.
## DB 스키마

View File

@@ -1,7 +1,8 @@
# 할 일 및 이슈
## 중기 개선
- 현재 다크 톤 기준 UI를 유지하되, 다음 단계에서 라이트모드/다크모드 전환 구조를 설계하고 테마 토글을 추가한다.
- 관리자 템플릿 요청 미리보기는 실제 완성본 iframe 방식과의 체감 차이를 마지막으로 한 번 더 QA한다.
- 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다.
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
@@ -10,3 +11,16 @@
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다.
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
- 관리자 템플릿 요청 카드와 전체 티어표 카드가 같은 문법으로 맞춰졌으므로, 이후에는 라이트모드까지 포함해 두 카드의 썸네일 높이·입력창 밀도·아이템 그리드를 한 번 더 비교 QA한다.
- 템플릿 요청 저장 흐름은 저장된 티어표 기준으로 바뀌었으므로, 이후 실제 데이터로 빈 제목 저장 시 자동 생성 제목·요청 버튼 노출 시점·관리자 요청 미리보기 밀도를 한 번 더 비교 QA한다.

View File

@@ -1,5 +1,93 @@
# 업데이트 로그
## 2026-04-01 v1.3.49
- 티어표 에디터의 템플릿 요청 흐름은 저장된 티어표를 기준으로만 요청을 보낼 수 있도록 다시 정리하고, 요청 모달의 `내 티어리스트에도 저장` 분기는 제거함. 제목이 비어 있는 상태에서 저장하면 `직접 티어표 만들기` 대신 랜덤한 고유 제목을 먼저 부여해 저장본을 만들고, 그 이후에만 템플릿 요청 버튼이 노출되도록 맞춤.
- 관리자 `사용자 템플릿 요청` 카드는 `전체 티어표 관리`와 같은 카드 문법으로 유지하되, 썸네일은 상단에 고정된 클릭 진입점으로 다시 정리하고 카드 본문과 별도 입력 영역의 밀도를 맞춤.
- 템플릿 요청 미리보기는 일반 티어표 완성본과 같은 보드 문법으로 다시 구성하고, `cells` 기반 배치 아이템도 남은 아이템 계산에 정확히 반영해 요청 미리보기와 일반 완성본 보기의 차이를 줄임.
## 2026-04-01 v1.3.48
- 관리자 화면은 새로고침 직후에도 `티어표 관리 / 회원 관리` 목록이 비지 않도록, 관리자 인증이 확정되거나 탭이 바뀔 때 해당 목록을 다시 불러오는 흐름으로 보강함.
- 관리자 아이템 모달은 내부 스크롤바를 숨기고 스크롤 체인을 끊어 배경이 함께 움직이지 않게 했고, 게임 선택 패널과 본문 패널의 상단 정렬도 다시 맞춤.
- 템플릿 요청 미리보기는 누락돼 있던 `requestPreview__frame / __header` 스타일을 보강해 일반 티어표 완성본과 더 비슷한 내부 프레임 구조와 보드 밀도로 다시 정리함.
## 2026-04-01 v1.3.47
- 관리자 `사용자 템플릿 요청` 카드는 별도 요청 전용 레이아웃 대신 `전체 티어표 관리`와 같은 카드 문법으로 맞추고, 왼쪽 썸네일 클릭으로 같은 미리보기 모달이 열리도록 정리함.
- 새 템플릿 요청에는 썸네일 아래에 `게임 이름 / 게임 ID` 입력을 두고, 초기 `게임 ID``new-template` 대신 요청 ID 기반의 임시 고유값으로 채워 나중에 수정하기 쉽게 바꿈.
- 요청 카드 오른쪽에는 제목, 설명, 요청 메타, 추가 아이템 목록, 승인/반려 버튼을 같은 정보 계층으로 배치해 전체 티어표 관리와 읽는 흐름을 통일함.
## 2026-04-01 v1.3.46
- 관리자 `전체 티어표 관리`의 썸네일 영역은 카드 좌측 전체가 눌리는 버튼처럼 보이지 않도록 이미지 영역만 상단에 붙여 클릭 진입점으로 유지하고, 카드 본문과의 시각적 분리를 다시 다듬음.
- `템플릿 요청 관리` 미리보기는 별도 썸네일 요약형이 아니라, 제목·설명·행/열 보드·남은 아이템이 하나의 내부 프레임 안에서 이어지는 실제 티어표 완성본형 레이아웃으로 다시 정리함.
## 2026-04-01 v1.3.45
- 템플릿 요청에서 `내 티어리스트에도 저장`이 켜져 있을 때 발생하던 500 오류는 새 저장본 생성 시 `tierlists.id``undefined`가 들어가던 문제였고, 이제 `saveTierList()`가 생성 시 자동으로 `nanoid()`를 부여하도록 고쳐 저장 분기 자체를 안정화함.
- 사용자에게 노출되던 `백엔드를 재시작해주세요` 문구는 제거하고, 저장 분기 실패 시에도 일반적인 재시도 안내만 보이도록 조정함.
- 루트에 잘못 남아 있던 `update.md` 진입점 파일은 제거하고, 업데이트 기록은 다시 `docs/update.md` 한 곳으로 정리함.
## 2026-04-01 v1.3.44
- 관리자 `전체 티어표 관리`에서는 별도 `완성본 보기` 버튼은 다시 두지 않되, 카드 썸네일 자체를 눌러 기존처럼 완성본 미리보기 모달을 열 수 있게 복구함.
- `템플릿 요청 관리`의 요청 미리보기는 요약 썸네일 중심 레이아웃을 줄이고, 실제 보드 구조를 먼저 읽는 방향으로 정리해 일반 티어표 완성본을 보는 흐름과 더 비슷하게 맞춤.
## 2026-04-01 v1.3.43
- 템플릿 요청 모달은 `내 티어리스트에도 저장` 토글 상태를 요청 직전에 별도로 고정해 사용하도록 바꿔, 모달이 닫히며 draft가 초기화된 뒤 성공 토스트가 반대로 나오던 문제를 바로잡음.
- 따라서 저장을 끈 상태에서는 `요청만 보냈어요` 문구가 정확히 유지되고, 저장을 켠 상태에서 500이 나는 경우에는 저장 단계에서 실패했다는 안내를 더 분명하게 보여주도록 보강함.
## 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차 보정으로 관리자 화면과 티어 에디터의 카드, 패널, 입력창, 모달, 썸네일 프레임을 전역 테마 변수 기준으로 다시 맞춰, 후속 화면에서도 명도 차가 더 자연스럽게 이어지도록 정리함.
- 공통 셸도 함께 손봐서 좌측 사이드 아이콘 필터와 텍스트 대비를 테마 변수 기반으로 전환하고, 가이드 모달·축소 검색 모달·내비 활성 상태까지 라이트모드에서 읽기 쉬운 톤으로 보정함.
- 전역 스타일 변수의 다크 기본값과 아이콘 필터 값을 바로잡아, 카드 배경과 텍스트 변수의 자기참조/오동작 가능성을 줄이고 이후 테마 QA 기준을 더 안정적으로 맞춤.
## 2026-04-01 v1.3.32
- 전역 테마 변수와 로컬 저장 기반 테마 토글을 추가해, Settings 화면 오른쪽 사이드에서 라이트모드/다크모드를 전환하고 재방문 시 같은 테마를 유지할 수 있게 함.
- 앱 셸, 홈, 게임 허브, 내 티어표, 즐겨찾기, 검색, 로그인, 설정 화면의 공통 카드·입력·텍스트 색을 테마 변수 기준으로 바꿔, 주요 사용자 화면은 라이트/다크 전환이 자연스럽게 이어지도록 1차 정리함.
- 관리자 화면과 티어 에디터처럼 스타일 밀도가 높은 화면은 후속 단계에서 세부 톤을 더 정교하게 맞추도록 todo 기준도 갱신함.
## 2026-04-01 v1.3.31
- 관리자 게임 관리의 오른쪽 사이드 게임 선택 리스트는 더 많은 항목을 한 번에 볼 수 있도록 최대 높이를 늘리고, 게임 카드 내부 간격도 사용자가 조정한 CSS 기준으로 반영해 목록 밀도를 다시 다듬음.
## 2026-04-01 v1.3.30
- 헤더의 `Tier Maker` 로고는 레인보우 그라데이션 텍스트로 바꿔 서비스 첫인상이 더 또렷하게 보이도록 정리하고, `by zenn`은 새 창으로 프로필 페이지를 여는 외부 링크로 연결함.
- 다음 단계 작업용으로 라이트모드/다크모드 전환 항목을 todo 문서에 추가해, 현재의 다크 톤 UI를 유지하면서도 이후 테마 확장 흐름을 공식 작업 목록에 올림.

View File

@@ -26,6 +26,7 @@ const searchQuery = ref('')
const searchPlaceholder = computed(() => (route.name === 'home' ? '게임 템플릿 검색' : '전체 티어표 검색'))
const isCollapsedSearchOpen = ref(false)
const isGuideModalOpen = ref(false)
const themeMode = ref('dark')
const guideStepIndex = ref(0)
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
provide('rightRailOpen', rightRailOpen)
@@ -125,6 +126,9 @@ const guideSteps = [
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
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(() => false && route.name === 'profile')
const showGameHubViewToggle = computed(() => route.name === 'gameHub')
const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
const leftBottomPrimaryAction = computed(() => {
@@ -240,7 +244,22 @@ function syncViewportWidth() {
viewportWidth.value = window.innerWidth
}
function applyTheme(mode) {
themeMode.value = mode === 'light' ? 'light' : 'dark'
if (typeof document !== 'undefined') document.documentElement.dataset.theme = themeMode.value
if (typeof window !== 'undefined') window.localStorage.setItem('tier-maker:theme', themeMode.value)
}
function toggleTheme() {
applyTheme(isLightTheme.value ? 'dark' : 'light')
}
onMounted(async () => {
if (typeof window !== 'undefined') {
const savedTheme = window.localStorage.getItem('tier-maker:theme')
if (savedTheme === 'light' || savedTheme === 'dark') applyTheme(savedTheme)
else applyTheme(window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
}
await auth.refresh()
if (typeof window !== 'undefined') {
syncViewportWidth()
@@ -507,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"
@@ -574,7 +599,17 @@ function submitGlobalSearch() {
<div class="rightRail__content">
<div v-if="usesLocalRightRail" id="local-right-rail-root" class="localRightRailRoot"></div>
<template v-else>
<RightRailAd />
<section v-if="showSettingsThemePanel" class="settingsThemePanel">
<div class="settingsThemePanel__eyebrow">Appearance</div>
<div class="settingsThemePanel__title">테마 설정</div>
<div class="settingsThemePanel__desc">밝은 톤과 어두운 원하는 작업 환경으로 전환할 있어요.</div>
<label class="toggleSwitch settingsThemePanel__toggle">
<input :checked="isLightTheme" type="checkbox" @change="toggleTheme" />
<span class="toggleSwitch__label">{{ isLightTheme ? '라이트 모드' : '다크 모드' }}</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
</section>
<RightRailAd v-else />
</template>
</div>
<div class="rightRail__bottom">
@@ -607,8 +642,8 @@ function submitGlobalSearch() {
min-height: 100dvh;
display: grid;
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr) var(--right-rail-width, 325px);
background: rgba(14, 14, 14, 0.96);
color: rgba(255, 255, 255, 0.92);
background: var(--theme-shell-bg);
color: var(--theme-text);
transition: grid-template-columns 220ms ease;
}
@@ -619,8 +654,8 @@ function submitGlobalSearch() {
.leftRail,
.rightRail {
min-height: 100dvh;
border-right: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(14, 14, 14, 0.92);
border-right: 1px solid var(--theme-border);
background: var(--theme-rail-bg);
box-sizing: border-box;
min-width: 0;
display: flex;
@@ -630,7 +665,7 @@ function submitGlobalSearch() {
.rightRail {
border-right: 0;
border-left: 1px solid rgba(255, 255, 255, 0.08);
border-left: 1px solid var(--theme-border);
transition:
opacity 220ms ease,
transform 220ms ease,
@@ -654,7 +689,8 @@ function submitGlobalSearch() {
display: flex;
align-items: center;
padding: 0 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: 1px solid var(--theme-border);
background: var(--theme-rail-bg);
box-sizing: border-box;
}
@@ -713,9 +749,9 @@ function submitGlobalSearch() {
height: 28px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.72);
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
color: var(--theme-text-soft);
cursor: pointer;
display: inline-flex;
align-items: center;
@@ -742,7 +778,7 @@ function submitGlobalSearch() {
width: 24px;
height: 24px;
display: block;
filter: brightness(0) saturate(100%) invert(94%) sepia(6%) saturate(207%) hue-rotate(186deg) brightness(96%) contrast(92%);
filter: var(--theme-icon-filter);
}
.ghostIcon--iconOnly {
@@ -783,13 +819,13 @@ function submitGlobalSearch() {
object-fit: cover;
flex: 0 0 auto;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.08);
background: var(--theme-surface-soft-3);
}
.appUserCard__avatar--fallback {
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.12);
background: var(--theme-surface-soft-3);
font-weight: 900;
}
@@ -809,7 +845,7 @@ function submitGlobalSearch() {
.appUserCard__email {
font-size: 12px;
color: rgba(255, 255, 255, 0.56);
color: var(--theme-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -822,9 +858,9 @@ function submitGlobalSearch() {
gap: 10px;
padding: 11px 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.62);
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
color: var(--theme-text-soft);
margin-bottom: 14px;
box-sizing: border-box;
transition: padding 220ms ease, justify-content 220ms ease;
@@ -836,7 +872,7 @@ function submitGlobalSearch() {
max-width: 100%;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.92);
color: var(--theme-text);
outline: none;
font: inherit;
overflow: hidden;
@@ -844,7 +880,7 @@ function submitGlobalSearch() {
}
.searchStub__input::placeholder {
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
}
.searchStub__iconButton {
@@ -877,7 +913,7 @@ function submitGlobalSearch() {
gap: 12px;
padding: 11px 12px;
border-radius: 14px;
color: rgba(255, 255, 255, 0.76);
color: var(--theme-text-muted);
text-decoration: none;
transition: background 180ms ease, color 180ms ease, transform 180ms ease;
}
@@ -892,8 +928,8 @@ function submitGlobalSearch() {
.leftNav__item--active,
.leftNav__item.router-link-active {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.96);
background: var(--theme-surface-soft-3);
color: var(--theme-text-strong);
}
.leftNav__glyph {
@@ -978,9 +1014,9 @@ function submitGlobalSearch() {
gap: 8px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft);
color: var(--theme-text);
text-decoration: none;
box-sizing: border-box;
font-weight: 800;
@@ -998,9 +1034,9 @@ function submitGlobalSearch() {
min-width: 0;
min-height: 0;
box-sizing: border-box;
background: rgba(18, 18, 18, 0.98);
border-left: 1px solid rgba(255, 255, 255, 0.08);
border-right: 1px solid rgba(255, 255, 255, 0.08);
background: var(--theme-main-bg);
border-left: 1px solid var(--theme-border);
border-right: 1px solid var(--theme-border);
}
.appMain--preview {
@@ -1036,22 +1072,19 @@ 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 {
font-size: 13px;
font-weight: 700;
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-muted);
text-decoration: none;
transition: color 180ms ease, opacity 180ms ease;
}
.workspaceHead__brandSub:hover {
color: rgba(255, 255, 255, 0.92);
color: var(--theme-text);
}
.workspaceHead__actions {
@@ -1067,7 +1100,7 @@ function submitGlobalSearch() {
gap: 6px;
padding: 4px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
background: var(--theme-surface-soft);
}
.viewToggle .ghostIcon--iconOnly {
@@ -1078,7 +1111,7 @@ function submitGlobalSearch() {
}
.ghostIcon--active {
background: rgba(255, 255, 255, 0.08);
background: var(--theme-surface-soft-3);
}
.workspaceBody {
@@ -1086,7 +1119,7 @@ function submitGlobalSearch() {
padding: 18px 18px 32px;
border: 0;
border-radius: 0;
background: rgba(24, 24, 24, 0.92);
background: var(--theme-workspace-bg);
box-shadow: none;
margin: 0;
}
@@ -1096,7 +1129,7 @@ function submitGlobalSearch() {
padding: 18px 18px 32px;
border: 0;
border-radius: 0;
background: rgba(24, 24, 24, 0.92);
background: var(--theme-workspace-bg);
box-shadow: none;
margin: 0;
}
@@ -1118,13 +1151,105 @@ function submitGlobalSearch() {
padding-top: 12px;
}
.settingsThemePanel {
display: grid;
gap: 10px;
padding: 18px;
border-radius: 22px;
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
}
.settingsThemePanel__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--theme-text-soft);
}
.settingsThemePanel__title {
font-size: 22px;
font-weight: 800;
color: var(--theme-text-strong);
}
.settingsThemePanel__desc {
color: var(--theme-text-muted);
line-height: 1.6;
}
.settingsThemePanel__toggle {
margin-top: 4px;
}
.toggleSwitch {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft);
cursor: pointer;
user-select: none;
}
.toggleSwitch input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.toggleSwitch__track {
position: relative;
width: 42px;
height: 24px;
border-radius: 999px;
background: var(--theme-surface-soft-3);
border: 1px solid var(--theme-border-strong);
transition: background 180ms ease, border-color 180ms ease;
flex: 0 0 auto;
}
.toggleSwitch__thumb {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.24);
transition: transform 180ms ease;
}
:root[data-theme='light'] .toggleSwitch__thumb {
background: #fff;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.16);
}
.toggleSwitch__label {
font-weight: 800;
color: var(--theme-text);
}
.toggleSwitch input:checked ~ .toggleSwitch__track {
background: rgba(96, 165, 250, 0.34);
border-color: rgba(96, 165, 250, 0.42);
}
.toggleSwitch input:checked ~ .toggleSwitch__track .toggleSwitch__thumb {
transform: translateX(18px);
}
.rightRailAction__button {
width: 100%;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(77, 127, 233, 0.96);
background: rgba(77, 127, 233, 0.88);
color: #fff;
background: var(--theme-accent-bg);
color: var(--theme-accent-text);
font-weight: 800;
cursor: pointer;
}
@@ -1147,12 +1272,12 @@ 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;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid var(--theme-border-strong);
background: linear-gradient(180deg, rgba(34, 34, 34, 0.98), rgba(18, 18, 18, 0.98));
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.42);
}
@@ -1162,15 +1287,15 @@ function submitGlobalSearch() {
align-content: start;
gap: 18px;
padding: 28px 22px;
background: rgba(255, 255, 255, 0.03);
border-right: 1px solid rgba(255, 255, 255, 0.08);
background: var(--theme-pill-bg);
border-right: 1px solid var(--theme-border);
}
.guideModal__eyebrow {
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.4);
color: var(--theme-text-faint);
}
.guideModal__title {
@@ -1180,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;
@@ -1192,9 +1339,9 @@ function submitGlobalSearch() {
align-items: center;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.02);
color: rgba(255, 255, 255, 0.8);
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
color: var(--theme-text-muted);
cursor: pointer;
text-align: left;
}
@@ -1202,13 +1349,13 @@ function submitGlobalSearch() {
.guideModal__listItem--active {
border-color: rgba(77, 127, 233, 0.5);
background: rgba(77, 127, 233, 0.14);
color: rgba(255, 255, 255, 0.96);
color: var(--theme-text-strong);
}
.guideModal__listIndex {
font-size: 12px;
font-weight: 900;
color: rgba(255, 255, 255, 0.54);
color: var(--theme-text-faint);
}
.guideModal__listLabel {
@@ -1222,19 +1369,22 @@ 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;
background: transparent;
color: rgba(255, 255, 255, 0.56);
color: var(--theme-text-muted);
cursor: pointer;
font-size: 13px;
}
.guideModal__content {
min-width: 0;
min-height: 0;
display: grid;
grid-template-columns: 52px minmax(0, 1fr) 52px;
gap: 16px;
@@ -1243,6 +1393,7 @@ function submitGlobalSearch() {
.guideModal__body {
min-width: 0;
min-height: 0;
display: grid;
gap: 18px;
}
@@ -1256,7 +1407,7 @@ function submitGlobalSearch() {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.08);
border: 1px solid var(--theme-border);
background: radial-gradient(circle at top, rgba(77, 127, 233, 0.18), rgba(255, 255, 255, 0.02) 52%), rgba(255, 255, 255, 0.03);
display: grid;
align-content: center;
@@ -1279,7 +1430,7 @@ function submitGlobalSearch() {
.guideModal__mediaHint {
font-size: 13px;
color: rgba(255, 255, 255, 0.48);
color: var(--theme-text-faint);
}
.guideModal__text {
@@ -1291,7 +1442,7 @@ function submitGlobalSearch() {
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
}
.guideModal__stepTitle {
@@ -1303,14 +1454,14 @@ function submitGlobalSearch() {
.guideModal__stepSummary {
font-size: 16px;
font-weight: 700;
color: rgba(255, 255, 255, 0.86);
color: var(--theme-text);
}
.guideModal__stepDescription {
margin: 0;
max-width: 720px;
line-height: 1.7;
color: rgba(255, 255, 255, 0.62);
color: var(--theme-text-soft);
}
.guideModal__footer {
@@ -1331,7 +1482,7 @@ function submitGlobalSearch() {
height: 10px;
border-radius: 999px;
border: 0;
background: rgba(255, 255, 255, 0.18);
background: var(--theme-surface-soft-3);
cursor: pointer;
}
@@ -1344,8 +1495,8 @@ function submitGlobalSearch() {
padding: 12px 18px;
border-radius: 14px;
border: 1px solid rgba(77, 127, 233, 0.96);
background: rgba(77, 127, 233, 0.88);
color: #fff;
background: var(--theme-accent-bg);
color: var(--theme-accent-text);
font-weight: 800;
cursor: pointer;
}
@@ -1354,9 +1505,9 @@ function submitGlobalSearch() {
width: 52px;
height: 52px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
font-size: 28px;
line-height: 1;
cursor: pointer;
@@ -1375,7 +1526,7 @@ function submitGlobalSearch() {
justify-content: center;
align-items: flex-start;
padding: 88px 20px 20px;
background: rgba(0, 0, 0, 0.44);
background: color-mix(in srgb, var(--theme-body-bg) 72%, transparent);
backdrop-filter: blur(6px);
}
@@ -1386,8 +1537,8 @@ function submitGlobalSearch() {
gap: 14px;
padding: 18px 22px;
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(26, 26, 26, 0.96);
border: 1px solid var(--theme-border-strong);
background: var(--theme-main-bg);
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.34);
}
@@ -1404,7 +1555,7 @@ function submitGlobalSearch() {
width: 28px;
height: 28px;
display: block;
filter: brightness(0) saturate(100%) invert(94%) sepia(6%) saturate(207%) hue-rotate(186deg) brightness(96%) contrast(92%);
filter: var(--theme-icon-filter);
}
.collapsedSearchBar__input {
@@ -1412,14 +1563,14 @@ function submitGlobalSearch() {
flex: 1;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.92);
color: var(--theme-text);
font-size: 18px;
font-weight: 700;
outline: none;
}
.collapsedSearchBar__input::placeholder {
color: rgba(255, 255, 255, 0.46);
color: var(--theme-text-faint);
}
.localRightRailRoot {
@@ -1450,8 +1601,8 @@ function submitGlobalSearch() {
justify-content: space-between;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(11, 18, 32, 0.94);
border: 1px solid var(--theme-border-strong);
background: color-mix(in srgb, var(--theme-main-bg) 94%, transparent);
backdrop-filter: blur(12px);
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.28);
opacity: 1;
@@ -1484,13 +1635,13 @@ function submitGlobalSearch() {
.toast__count {
margin-top: 6px;
font-size: 12px;
color: rgba(255, 255, 255, 0.56);
color: var(--theme-text-muted);
}
.toast__close {
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.68);
color: var(--theme-text-muted);
cursor: pointer;
font-size: 12px;
}
@@ -1498,12 +1649,12 @@ function submitGlobalSearch() {
@media (max-width: 1200px) {
.guideModal__dialog {
grid-template-columns: 1fr;
min-height: auto;
height: min(860px, calc(100dvh - 40px));
}
.guideModal__sidebar {
border-right: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: 1px solid var(--theme-border);
}
.guideModal__content {
@@ -1530,8 +1681,8 @@ function submitGlobalSearch() {
width: min(360px, calc(100vw - 20px));
height: 100dvh;
z-index: 30;
border-left: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(14, 14, 14, 0.96);
border-left: 1px solid var(--theme-border);
background: var(--theme-shell-bg);
box-shadow: -18px 0 36px rgba(0, 0, 0, 0.34);
}
@@ -1550,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;
@@ -1576,10 +1762,6 @@ function submitGlobalSearch() {
.guideDockButton {
display: none;
}
.guideModal__list {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 860px) {
@@ -1592,7 +1774,7 @@ function submitGlobalSearch() {
min-height: auto;
height: auto;
border-right: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: 1px solid var(--theme-border);
}
.leftRail__top {

View File

@@ -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) =>

View File

@@ -2,12 +2,69 @@
font-family: 'Pretendard', 'Inter', 'Segoe UI', sans-serif;
line-height: 1.5;
font-weight: 400;
color: rgba(255, 255, 255, 0.92);
background: #121212;
color: var(--theme-text);
background: var(--theme-body-bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--theme-body-bg: #121212;
--theme-shell-bg: rgba(14, 14, 14, 0.96);
--theme-rail-bg: rgba(14, 14, 14, 0.92);
--theme-main-bg: rgba(18, 18, 18, 0.98);
--theme-workspace-bg: rgba(24, 24, 24, 0.92);
--theme-card-bg: rgba(62, 62, 62, 0.82);
--theme-card-bg-hover: rgba(70, 70, 70, 0.96);
--theme-card-border: rgba(255, 255, 255, 0.16);
--theme-card-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
--theme-surface-soft: rgba(255, 255, 255, 0.05);
--theme-surface-soft-2: rgba(255, 255, 255, 0.06);
--theme-surface-soft-3: rgba(255, 255, 255, 0.08);
--theme-pill-bg: rgba(255, 255, 255, 0.03);
--theme-border: rgba(255, 255, 255, 0.08);
--theme-border-strong: rgba(255, 255, 255, 0.12);
--theme-text: rgba(255, 255, 255, 0.92);
--theme-text-strong: rgba(255, 255, 255, 0.98);
--theme-text-muted: rgba(255, 255, 255, 0.74);
--theme-text-soft: rgba(255, 255, 255, 0.62);
--theme-text-faint: rgba(255, 255, 255, 0.4);
--theme-thumb-fallback-bg: #555;
--theme-select-arrow: rgba(255, 255, 255, 0.68);
--theme-danger-bg: rgba(239, 68, 68, 0.1);
--theme-danger-border: rgba(239, 68, 68, 0.18);
--theme-accent-bg: rgba(76, 133, 245, 0.92);
--theme-accent-text: #fff;
--theme-icon-filter: brightness(0) saturate(100%) invert(94%) sepia(6%) saturate(207%) hue-rotate(186deg) brightness(96%) contrast(92%);
}
:root[data-theme='light'] {
--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.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);
--theme-accent-text: #fff;
--theme-icon-filter: brightness(0) saturate(100%) invert(14%) sepia(14%) saturate(652%) hue-rotate(182deg) brightness(95%) contrast(91%);
}
* {
@@ -22,7 +79,9 @@ body,
body {
margin: 0;
background: #121212;
background: var(--theme-body-bg);
color: var(--theme-text);
transition: background 220ms ease, color 220ms ease;
}
button,
@@ -43,7 +102,7 @@ a {
input,
select,
textarea {
color: rgba(255, 255, 255, 0.92);
color: var(--theme-text);
}
select {
@@ -51,8 +110,8 @@ select {
-webkit-appearance: none;
-moz-appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, rgba(255, 255, 255, 0.72) 50%),
linear-gradient(135deg, rgba(255, 255, 255, 0.72) 50%, transparent 50%);
linear-gradient(45deg, transparent 50%, var(--theme-select-arrow) 50%),
linear-gradient(135deg, var(--theme-select-arrow) 50%, transparent 50%);
background-position:
calc(100% - 20px) calc(50% - 2px),
calc(100% - 14px) calc(50% - 2px);
@@ -99,19 +158,19 @@ p {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
}
.pageHead__title {
font-size: 32px;
line-height: 1.05;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
color: var(--theme-text-strong);
}
.pageHead__desc {
max-width: 720px;
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-muted);
}
.pageHead__aside {

File diff suppressed because it is too large Load Diff

View File

@@ -110,16 +110,16 @@ onMounted(loadFavorites)
.select {
padding: 11px 13px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
}
.btn {
padding: 11px 13px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft-2);
color: var(--theme-text);
font-weight: 800;
cursor: pointer;
}
@@ -133,18 +133,18 @@ onMounted(loadFavorites)
}
.boardCard {
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
overflow: hidden;
display: grid;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition:
transform 0.16s ease,
background 0.16s ease;
}
.boardCard:hover {
transform: translateY(-2px);
background: rgba(70, 70, 70, 0.96);
background: var(--theme-card-bg-hover);
}
.boardCard__body {
border: 0;
@@ -172,10 +172,10 @@ onMounted(loadFavorites)
object-fit: cover;
}
.boardCard__thumbPlaceholder {
background: #555;
background: var(--theme-thumb-fallback-bg);
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
color: var(--theme-text-faint);
font-size: 13px;
font-weight: 700;
}
@@ -222,7 +222,7 @@ onMounted(loadFavorites)
height: 22px;
border-radius: 9999px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
background: var(--theme-border);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
@@ -235,7 +235,7 @@ onMounted(loadFavorites)
.favoriteStat {
flex: 0 0 auto;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
color: var(--theme-text-faint);
white-space: nowrap;
}

View File

@@ -149,7 +149,7 @@ function submitSearch() {
}
.dashboardHero__eyebrow {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
text-transform: uppercase;
letter-spacing: 0.08em;
}
@@ -157,15 +157,15 @@ function submitSearch() {
margin: 4px 0 6px;
font-size: 32px;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
color: var(--theme-text-strong);
}
.dashboardHero__desc {
margin: 0;
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-muted);
max-width: 720px;
}
.panel {
/* border: 1px solid rgba(255, 255, 255, 0.08); */
/* border: 1px solid var(--theme-border); */
background: transparent;
border-radius: 0;
padding: 0;
@@ -174,8 +174,8 @@ function submitSearch() {
margin: 10px 0 14px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.3);
background: rgba(239, 68, 68, 0.12);
border: 1px solid var(--theme-danger-border);
background: var(--theme-danger-bg);
}
.panel__title {
font-weight: 800;
@@ -183,7 +183,7 @@ function submitSearch() {
}
.panel__sub {
margin-top: 6px;
color: rgba(255, 255, 255, 0.56);
color: var(--theme-text-muted);
font-size: 13px;
}
.panel__head {
@@ -204,16 +204,16 @@ function submitSearch() {
min-width: 240px;
padding: 11px 13px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
}
.searchBar__button {
padding: 11px 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft-2);
color: var(--theme-text);
font-weight: 800;
cursor: pointer;
}
@@ -232,18 +232,18 @@ function submitSearch() {
.boardCard {
min-width: 0;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
color: var(--theme-text);
display: grid;
overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition:
transform 0.16s ease,
background 0.16s ease;
}
.boardCard:hover {
background: rgba(70, 70, 70, 0.96);
background: var(--theme-card-bg-hover);
transform: translateY(-2px);
}
.boardCard__body {
@@ -293,10 +293,10 @@ function submitSearch() {
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
background: #555;
background: var(--theme-thumb-fallback-bg);
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
color: var(--theme-text-faint);
font-size: 13px;
font-weight: 700;
border-radius: 18px;
@@ -362,7 +362,7 @@ function submitSearch() {
border-radius: 9999px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
background: var(--theme-border);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
@@ -377,7 +377,7 @@ function submitSearch() {
min-width: 0;
max-width: 100%;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
color: var(--theme-text-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -120,31 +120,31 @@ function thumbUrl(g) {
margin: 0 0 16px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.18);
background: rgba(239, 68, 68, 0.1);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-danger-border);
background: var(--theme-danger-bg);
color: var(--theme-text);
}
.pageHead__searchState {
margin-top: 8px;
color: rgba(255, 255, 255, 0.62);
color: var(--theme-text-muted);
}
.libraryCard {
position: relative;
text-align: left;
padding: 14px;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
color: var(--theme-text);
cursor: pointer;
display: grid;
gap: 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition: transform 0.16s ease, background 0.16s ease;
will-change: transform, opacity;
}
.libraryCard:hover {
background: rgba(70, 70, 70, 0.96);
background: var(--theme-card-bg-hover);
transform: translateY(-2px);
}
.libraryCard__main {
@@ -191,8 +191,8 @@ function thumbUrl(g) {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: #555;
border: 1px solid var(--theme-surface-soft-2);
background: var(--theme-thumb-fallback-bg);
overflow: hidden;
display: grid;
place-items: center;
@@ -204,7 +204,7 @@ function thumbUrl(g) {
}
.libraryCard__thumbFallback {
font-size: 14px;
color: rgba(255, 255, 255, 0.4);
color: var(--theme-text-faint);
}
.libraryCard__body {
display: grid;
@@ -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;
@@ -241,7 +241,7 @@ function thumbUrl(g) {
.libraryEmpty {
padding: 20px 0;
color: rgba(255, 255, 255, 0.62);
color: var(--theme-text-muted);
}
@media (max-width: 1400px) {
.libraryGrid {

View File

@@ -154,7 +154,7 @@ async function submit() {
}
.authLoading {
color: rgba(255, 255, 255, 0.62);
color: var(--theme-text-muted);
font-size: 15px;
}
@@ -164,8 +164,8 @@ async function submit() {
width: fit-content;
padding: 6px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
}
.authTabs__button {
@@ -174,14 +174,14 @@ async function submit() {
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.62);
color: var(--theme-text-muted);
font-weight: 700;
cursor: pointer;
}
.authTabs__button--active {
background: rgba(76, 133, 245, 0.22);
color: rgba(255, 255, 255, 0.96);
color: var(--theme-text-strong);
}
.authFields {
@@ -196,16 +196,16 @@ async function submit() {
.field__label {
font-size: 13px;
color: rgba(255, 255, 255, 0.62);
color: var(--theme-text-muted);
}
.field__input {
width: 100%;
padding: 14px 0;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
border-bottom: 1px solid var(--theme-border-strong);
background: transparent;
color: rgba(255, 255, 255, 0.94);
color: var(--theme-text);
outline: none;
font-size: 18px;
letter-spacing: -0.02em;
@@ -217,7 +217,7 @@ async function submit() {
.field__hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
}
.roleBadge {
@@ -226,7 +226,7 @@ async function submit() {
border-radius: 999px;
border: 1px solid rgba(96, 165, 250, 0.28);
background: rgba(96, 165, 250, 0.1);
color: rgba(191, 219, 254, 0.92);
color: var(--theme-text);
font-size: 12px;
font-weight: 700;
}
@@ -248,14 +248,14 @@ async function submit() {
.primaryAction {
border: 1px solid rgba(76, 133, 245, 0.96);
background: rgba(76, 133, 245, 0.92);
color: #fff;
background: var(--theme-accent-bg);
color: var(--theme-accent-text);
}
.secondaryAction {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.86);
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft);
color: var(--theme-text);
}
@media (max-width: 720px) {

View File

@@ -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: rgba(255, 255, 255, 0.42);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.dashboardHero__title {
margin: 4px 0 6px;
font-size: 32px;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
}
.dashboardHero__desc {
margin: 0;
color: rgba(255, 255, 255, 0.58);
max-width: 720px;
}
.panel {
background: transparent;
border-radius: 0;
@@ -155,18 +126,18 @@ function openList(t) {
.boardCard {
min-width: 0;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
color: var(--theme-text);
overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition:
transform 0.16s ease,
background 0.16s ease;
}
.boardCard:hover {
transform: translateY(-2px);
background: rgba(70, 70, 70, 0.96);
background: var(--theme-card-bg-hover);
}
.boardCard__body {
min-width: 0;
@@ -197,10 +168,10 @@ function openList(t) {
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
background: #555;
background: var(--theme-thumb-fallback-bg);
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
color: var(--theme-text-faint);
font-size: 13px;
font-weight: 700;
border-radius: 18px;
@@ -259,7 +230,7 @@ function openList(t) {
border-radius: 9999px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
background: var(--theme-border);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
@@ -274,7 +245,7 @@ function openList(t) {
min-width: 0;
max-width: 100%;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
color: var(--theme-text-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -200,7 +200,7 @@ async function logout() {
}
.settingsLoading {
color: rgba(255, 255, 255, 0.62);
color: var(--theme-text-muted);
font-size: 15px;
}
@@ -221,15 +221,15 @@ async function logout() {
position: relative;
width: 120px;
height: 120px;
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid var(--theme-border-strong);
border-radius: 9999px;
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.92);
background: var(--theme-pill-bg);
color: var(--theme-text);
overflow: hidden;
cursor: pointer;
display: grid;
place-items: center;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: var(--theme-card-shadow);
}
.avatarButton__image {
@@ -241,7 +241,7 @@ async function logout() {
.avatarButton__fallback {
font-size: 34px;
font-weight: 900;
color: rgba(255, 255, 255, 0.86);
color: var(--theme-text);
}
.avatarButton__overlay {
@@ -251,7 +251,7 @@ async function logout() {
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.72));
font-size: 12px;
font-weight: 700;
color: rgba(255, 255, 255, 0.82);
color: var(--theme-text);
}
.avatarButton__remove {
@@ -262,8 +262,8 @@ async function logout() {
height: 30px;
border: 0;
border-radius: 999px;
background: rgba(10, 10, 10, 0.72);
color: rgba(255, 255, 255, 0.88);
background: var(--theme-shell-bg);
color: var(--theme-text);
display: grid;
place-items: center;
cursor: pointer;
@@ -283,7 +283,7 @@ async function logout() {
.avatarButton__remove:hover {
background: rgba(190, 24, 24, 0.88);
color: #fff;
color: var(--theme-accent-text);
}
.identityMeta {
@@ -295,7 +295,7 @@ async function logout() {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.36);
color: var(--theme-text-soft);
}
.identityMeta__title {
@@ -305,7 +305,7 @@ async function logout() {
}
.identityMeta__desc {
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-muted);
line-height: 1.6;
}
@@ -326,16 +326,16 @@ async function logout() {
.field__label {
font-size: 13px;
color: rgba(255, 255, 255, 0.62);
color: var(--theme-text-muted);
}
.field__input {
width: 100%;
padding: 14px 0;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
border-bottom: 1px solid var(--theme-border-strong);
background: transparent;
color: rgba(255, 255, 255, 0.94);
color: var(--theme-text);
outline: none;
font-size: 18px;
letter-spacing: -0.02em;
@@ -346,12 +346,12 @@ async function logout() {
}
.field__input--readonly {
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-muted);
}
.field__hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
}
.roleBadge {
@@ -360,7 +360,7 @@ async function logout() {
border-radius: 999px;
border: 1px solid rgba(96, 165, 250, 0.28);
background: rgba(96, 165, 250, 0.1);
color: rgba(191, 219, 254, 0.92);
color: var(--theme-text);
font-size: 12px;
font-weight: 700;
}
@@ -382,14 +382,14 @@ async function logout() {
.primaryAction {
border: 1px solid rgba(76, 133, 245, 0.96);
background: rgba(76, 133, 245, 0.92);
color: #fff;
background: var(--theme-accent-bg);
color: var(--theme-accent-text);
}
.secondaryAction {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.86);
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft);
color: var(--theme-text);
}
@media (max-width: 720px) {

View File

@@ -122,24 +122,24 @@ watch(
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
}
.title {
margin: 4px 0 0;
font-size: 32px;
color: rgba(255, 255, 255, 0.96);
color: var(--theme-text-strong);
letter-spacing: -0.04em;
}
.desc {
margin-top: 6px;
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-muted);
}
.error {
margin: 0 0 8px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.18);
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--theme-danger-border);
background: var(--theme-danger-bg);
}
.empty {
opacity: 0.76;
@@ -151,16 +151,16 @@ watch(
}
.boardCard {
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
overflow: hidden;
display: grid;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition: transform 0.16s ease, background 0.16s ease;
}
.boardCard:hover {
transform: translateY(-2px);
background: rgba(70, 70, 70, 0.96);
background: var(--theme-card-bg-hover);
}
.boardCard__body {
border: 0;
@@ -188,10 +188,10 @@ watch(
object-fit: cover;
}
.boardCard__thumbPlaceholder {
background: #555;
background: var(--theme-thumb-fallback-bg);
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
color: var(--theme-text-faint);
font-size: 13px;
font-weight: 700;
}
@@ -238,7 +238,7 @@ watch(
height: 22px;
border-radius: 9999px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
background: var(--theme-border);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
@@ -251,7 +251,7 @@ watch(
.favoriteStat {
flex: 0 0 auto;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
color: var(--theme-text-faint);
white-space: nowrap;
}

View File

@@ -34,6 +34,7 @@ const pool = ref([])
const itemsById = ref({})
const title = ref('')
const persistedTierListId = ref('')
const thumbnailSrc = ref('')
const pendingThumbnailFile = ref(null)
const thumbnailPreviewUrl = ref('')
@@ -48,7 +49,6 @@ const isTemplateRequestModalOpen = ref(false)
const isTemplateUpdateModalOpen = ref(false)
const templateRequestDraftTitle = ref('')
const templateRequestDraftDescription = ref('')
const templateRequestSaveToMyTierList = ref(true)
const isDeleteModalOpen = ref(false)
const isGroupDeleteModalOpen = ref(false)
const isColumnDeleteModalOpen = ref(false)
@@ -94,10 +94,13 @@ const effectiveAuthorName = computed(() => {
if (currentEmail) return currentEmail.split('@')[0] || currentEmail
return (authorAccountName.value || '').trim() || 'unknown'
})
const autoGeneratedTitle = ref(createAutoTierListTitle())
const effectiveTitle = computed(() => {
const customTitle = (title.value || '').trim()
if (customTitle) return customTitle
return (gameName.value || gameId.value || 'Tier Maker').trim()
if (persistedTierListId.value) return persistedTierListId.value
if (tierListId.value && tierListId.value !== 'new') return tierListId.value
return autoGeneratedTitle.value
})
const displayThumbnailUrl = computed(() => thumbnailPreviewUrl.value || (thumbnailSrc.value ? resolveItemSrc({ src: thumbnailSrc.value }) : ''))
const untitledWarning = computed(
@@ -120,11 +123,12 @@ const customItems = computed(() =>
.filter((item) => item?.origin === 'custom')
.sort((a, b) => (a.label || '').localeCompare(b.label || '', 'ko'))
)
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
const canRequestTemplateCreate = computed(
() => canEdit.value && !isNewTierList.value && gameId.value === 'freeform' && customItems.value.length > 0
() => canEdit.value && hasSavedTierList.value && gameId.value === 'freeform' && customItems.value.length > 0
)
const canRequestTemplateUpdate = computed(
() => canEdit.value && !isNewTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
() => canEdit.value && hasSavedTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
)
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
@@ -136,6 +140,12 @@ watch(error, (message) => {
error.value = ''
})
function createAutoTierListTitle() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const pick = (size) => Array.from({ length: size }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
return pick(10) + '-' + pick(10)
}
function formatTitleDate(ts) {
const date = new Date(ts)
const year = date.getFullYear()
@@ -652,9 +662,12 @@ function buildPayload(existingId) {
async function persistTierList({ showModal = false } = {}) {
await uploadPendingCustomItems()
await uploadPendingThumbnail()
const payload = buildPayload(tierListId.value && tierListId.value !== 'new' ? tierListId.value : null)
const currentTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
const payload = buildPayload(currentTierListId || null)
const res = await api.saveTierList(payload)
const savedTierListId = res.tierList?.id || tierListId.value
const savedTierListId = res.tierList?.id || currentTierListId || tierListId.value
persistedTierListId.value = savedTierListId || ''
title.value = res.tierList?.title || payload.title
if (tierListId.value === 'new' && res.tierList?.id) {
await router.replace(`/editor/${gameId.value}/${res.tierList.id}`)
}
@@ -686,7 +699,6 @@ function closeSaveModal() {
function resetTemplateRequestDrafts() {
templateRequestDraftTitle.value = (title.value || '').trim()
templateRequestDraftDescription.value = (description.value || '').trim()
templateRequestSaveToMyTierList.value = true
}
function openTemplateRequestModal() {
@@ -764,52 +776,37 @@ async function toggleFavorite() {
async function requestTemplate(type) {
try {
isRequestingTemplate.value = true
await uploadPendingCustomItems()
const uploadedThumbnailSrc = await uploadPendingThumbnail()
const response = await api.requestTierListTemplate({
title.value = templateRequestDraftTitle.value.trim()
description.value = templateRequestDraftDescription.value.trim()
const saved = await persistTierList({ showModal: false })
const sourceId = saved.savedTierListId || persistedTierListId.value || ''
if (!sourceId) throw new Error('save_required')
await api.requestTierListTemplate({
type,
sourceTierListId: tierListId.value !== 'new' ? tierListId.value : '',
sourceTierListId: sourceId,
gameId: gameId.value,
requestTitle: templateRequestDraftTitle.value.trim(),
requestDescription: templateRequestDraftDescription.value.trim(),
thumbnailSrc: uploadedThumbnailSrc || thumbnailSrc.value || '',
thumbnailSrc: saved.tierList?.thumbnailSrc || thumbnailSrc.value || '',
isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value,
saveToMyTierList: !!templateRequestSaveToMyTierList.value,
groups: buildGroupPayload(),
boardItems: Object.values(itemsById.value),
})
const savedTierList = response?.savedTierList
if (savedTierList) {
title.value = savedTierList.title || title.value
description.value = savedTierList.description || ''
updatedAt.value = Number(savedTierList.updatedAt || Date.now())
authorName.value = savedTierList.authorName || effectiveAuthorName.value
authorAccountName.value = savedTierList.authorAccountName || authorAccountName.value
favoriteCount.value = Number(savedTierList.favoriteCount || favoriteCount.value || 0)
isFavorited.value = !!savedTierList.isFavorited
if (tierListId.value === 'new' && savedTierList.id) {
await router.replace(`/editor/${gameId.value}/${savedTierList.id}`)
}
}
if (type === 'create') closeTemplateRequestModal()
if (type === 'update') closeTemplateUpdateModal()
toast.success(
type === 'create'
? templateRequestSaveToMyTierList.value
? '템플릿 등록 요청과 내 티어표 저장을 함께 완료했어요.'
: '템플릿 등록 요청을 보냈어요.'
: templateRequestSaveToMyTierList.value
? '템플릿 업데이트 요청과 내 티어표 저장을 함께 완료했어요.'
: '템플릿 업데이트 요청을 보냈어요.'
)
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
} catch (e) {
if (e?.message === 'custom_upload_failed') {
toast.error('커스텀 이미지 이름이 너무 길거나 업로드 조건에 맞지 않아 요청 전에 저장하지 못했어요. 아이템 이름을 60자 이하로 줄인 뒤 다시 시도해주세요.')
return
}
if (e?.message === 'save_required') {
toast.error('먼저 현재 티어표를 저장한 뒤 다시 요청해주세요.')
return
}
if (e?.status === 409) {
toast.error('이미 처리 대기 중인 같은 요청이 있어요.')
return
@@ -818,6 +815,10 @@ async function requestTemplate(type) {
toast.error('먼저 커스텀 아이템을 추가한 뒤 요청해주세요.')
return
}
if (e?.status === 400 && e?.data?.error === 'source_tierlist_required') {
toast.error('저장된 티어표에서만 템플릿 요청을 보낼 수 있어요.')
return
}
if (e?.status === 400 && e?.data?.error === 'bad_request') {
toast.error('요청 제목, 설명, 아이템 이름 중 길이 제한을 넘긴 값이 없는지 확인해주세요.')
return
@@ -861,6 +862,7 @@ onMounted(() => {
const res = await api.getTierList(tierListId.value)
const t = res.tierList
ownerId.value = t.authorId
persistedTierListId.value = t.id || ''
title.value = t.title
thumbnailSrc.value = t.thumbnailSrc || ''
description.value = t.description || ''
@@ -970,14 +972,6 @@ onUnmounted(() => {
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="1000" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
<span class="templateRequestDraft__hint">{{ templateRequestDraftDescription.length }}/1000</span>
</label>
<label class="toggleSwitch">
<input v-model="templateRequestSaveToMyTierList" type="checkbox" />
<span class="toggleSwitch__label"> 티어 리스트에도 저장</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<div class="templateRequestDraft__note">
저장을 끄면 요청 시점 스냅샷만 관리자에게 전달되고, 티어 리스트에는 별도로 남기지 않아요.
</div>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateRequestModal">취소</button>
@@ -1009,14 +1003,6 @@ onUnmounted(() => {
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="1000" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
<span class="templateRequestDraft__hint">{{ templateRequestDraftDescription.length }}/1000</span>
</label>
<label class="toggleSwitch">
<input v-model="templateRequestSaveToMyTierList" type="checkbox" />
<span class="toggleSwitch__label"> 티어 리스트에도 저장</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<div class="templateRequestDraft__note">
저장을 끄면 관리자 확인용 요청 스냅샷만 남고, 현재 작업 중인 티어표는 따로 저장하지 않아요.
</div>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateUpdateModal">요청 취소</button>
@@ -1280,7 +1266,7 @@ onUnmounted(() => {
<div class="editorSidebar__label">커스텀 이름 정리</div>
<div class="customItemEditor customItemEditor--sidebar">
<div class="customItemEditor__desc">
아래에서 이름 정리해두면 관리자 요청 그대로 전달됩니다.
아래에서 이름 정리 저장하면, 템플릿 요청 그대로 전달됩니다.
</div>
<div class="customItemEditor__list">
<label v-for="item in customItems" :key="item.id" class="customItemEditor__row">
@@ -1361,7 +1347,7 @@ onUnmounted(() => {
letter-spacing: -0.04em;
}
.editorMain__subtitle {
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-soft);
font-size: 13px;
line-height: 1.5;
}
@@ -1372,13 +1358,13 @@ onUnmounted(() => {
gap: 8px;
flex-wrap: wrap;
font-size: 12px;
color: rgba(255, 255, 255, 0.62);
color: var(--theme-text-soft);
}
.editorMain__sourceLink {
border: 0;
padding: 0;
background: transparent;
color: rgba(191, 219, 254, 0.94);
color: color-mix(in srgb, var(--theme-accent-bg) 78%, white);
font: inherit;
cursor: pointer;
}
@@ -1388,7 +1374,7 @@ onUnmounted(() => {
box-sizing: border-box;
background:
radial-gradient(circle at top, rgba(96, 165, 250, 0.14), transparent 38%),
rgba(11, 18, 32, 0.98);
var(--theme-shell-bg);
}
.previewOnly__sheet {
display: grid;
@@ -1451,13 +1437,13 @@ onUnmounted(() => {
text-align: center;
font-weight: 900;
border-radius: 14px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
background: var(--theme-surface-soft-2);
border: 1px solid var(--theme-border-strong);
}
.previewOnly__drop {
border-radius: 14px;
background: rgba(0, 0, 0, 0.18);
border: 1px solid rgba(255, 255, 255, 0.1);
background: var(--theme-pill-bg);
border: 1px solid var(--theme-border);
min-height: calc(var(--thumb-size, 80px) + 24px);
padding: 10px;
display: flex;
@@ -1498,8 +1484,8 @@ onUnmounted(() => {
gap: 12px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
cursor: pointer;
user-select: none;
}
@@ -1513,8 +1499,8 @@ onUnmounted(() => {
width: 42px;
height: 24px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.12);
background: var(--theme-surface-soft-3);
border: 1px solid var(--theme-border-strong);
transition: background 180ms ease, border-color 180ms ease;
flex: 0 0 auto;
}
@@ -1525,13 +1511,13 @@ onUnmounted(() => {
width: 18px;
height: 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
background: var(--theme-text-strong);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.24);
transition: transform 180ms ease;
}
.toggleSwitch__label {
font-weight: 800;
color: rgba(255, 255, 255, 0.9);
color: var(--theme-text);
}
.toggleSwitch input:checked ~ .toggleSwitch__track {
background: rgba(96, 165, 250, 0.34);
@@ -1547,14 +1533,14 @@ onUnmounted(() => {
.btn {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft-2);
color: var(--theme-text);
cursor: pointer;
font-weight: 700;
}
.btn:hover {
background: rgba(255, 255, 255, 0.08);
background: var(--theme-surface-soft-3);
}
.btn--primary {
background: rgba(110, 231, 183, 0.18);
@@ -1600,8 +1586,8 @@ onUnmounted(() => {
}
.board {
width: min(100%, 960px);
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(180deg, rgba(55, 55, 55, 0.86), rgba(42, 42, 42, 0.82));
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;
padding: 20px;
align-self: start;
@@ -1614,15 +1600,15 @@ onUnmounted(() => {
display: grid;
place-items: center;
padding: 20px;
background: rgba(4, 8, 16, 0.68);
background: color-mix(in srgb, var(--theme-body-bg) 76%, transparent);
backdrop-filter: blur(4px);
}
.modalCard {
width: min(100%, 420px);
border-radius: 20px;
padding: 24px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: linear-gradient(180deg, rgba(17, 24, 39, 0.96), rgba(11, 18, 32, 0.96));
border: 1px solid var(--theme-border-strong);
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-main-bg) 98%, transparent), color-mix(in srgb, var(--theme-shell-bg) 98%, transparent));
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.38);
display: grid;
gap: 10px;
@@ -1695,24 +1681,24 @@ onUnmounted(() => {
}
.templateRequestDraft__label {
font-size: 12px;
color: rgba(255, 255, 255, 0.64);
color: var(--theme-text-soft);
}
.templateRequestDraft__hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.46);
color: var(--theme-text-faint);
}
.templateRequestDraft__note {
font-size: 12px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.6);
color: var(--theme-text-soft);
}
.templateRequestDraft__input {
width: 100%;
padding: 14px 0;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
border-bottom: 1px solid var(--theme-border-strong);
background: transparent;
color: rgba(255, 255, 255, 0.94);
color: var(--theme-text-strong);
outline: none;
font-size: 18px;
line-height: 1.5;
@@ -1723,7 +1709,7 @@ onUnmounted(() => {
border-bottom-color: rgba(96, 165, 250, 0.9);
}
.templateRequestDraft__input::placeholder {
color: rgba(255, 255, 255, 0.34);
color: var(--theme-text-faint);
}
.templateRequestDraft__textarea {
min-height: 92px;
@@ -1738,8 +1724,8 @@ onUnmounted(() => {
flex-wrap: wrap;
padding: 10px 12px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
}
.boardTools__left,
.boardTools__right {
@@ -1758,9 +1744,9 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.9);
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft);
color: var(--theme-text);
cursor: pointer;
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
}
@@ -1811,9 +1797,9 @@ onUnmounted(() => {
min-width: 48px;
padding: 9px 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft);
color: var(--theme-text);
cursor: pointer;
font-weight: 800;
}
@@ -1831,7 +1817,7 @@ onUnmounted(() => {
border-radius: 28px;
background:
radial-gradient(circle at top, rgba(96, 165, 250, 0.14), transparent 38%),
rgba(11, 18, 32, 0.98);
var(--theme-shell-bg);
}
.exportBoard__title {
font-size: 28px;
@@ -1869,8 +1855,8 @@ onUnmounted(() => {
.row__label {
position: relative;
border-radius: 16px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
background: var(--theme-surface-soft-3);
border: 1px solid var(--theme-border-strong);
display: flex;
align-items: center;
justify-content: center;
@@ -1898,9 +1884,9 @@ onUnmounted(() => {
.columnName {
width: 100%;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
border-bottom: 1px solid var(--theme-border-strong);
background: transparent;
color: rgba(255, 255, 255, 0.88);
color: var(--theme-text);
padding: 4px 0;
text-align: center;
font-size: 12px;
@@ -1909,7 +1895,7 @@ onUnmounted(() => {
outline: none;
}
.columnName::placeholder {
color: rgba(255, 255, 255, 0.34);
color: var(--theme-text-faint);
}
.columnRemoveText {
position: absolute;
@@ -1924,15 +1910,15 @@ onUnmounted(() => {
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.56);
color: var(--theme-text-soft);
font-size: 16px;
line-height: 1;
font-weight: 800;
cursor: pointer;
}
.columnRemoveText:hover {
color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.06);
color: var(--theme-text);
background: var(--theme-surface-soft-2);
}
.columnRemoveText:disabled {
opacity: 0.32;
@@ -1949,15 +1935,15 @@ onUnmounted(() => {
display: grid;
place-items: center;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid var(--theme-border);
background: rgba(0, 0, 0, 0.16);
font-size: 12px;
}
.groupName {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.18);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border-strong);
background: var(--theme-pill-bg);
color: var(--theme-text);
border-radius: 10px;
padding: 8px 10px;
font-weight: 900;
@@ -1977,15 +1963,15 @@ onUnmounted(() => {
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.6);
color: var(--theme-text-soft);
cursor: pointer;
font-size: 16px;
line-height: 1;
font-weight: 800;
}
.rowRemoveText:hover {
color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.06);
color: var(--theme-text);
background: var(--theme-surface-soft-2);
}
.rowRemoveText:disabled {
opacity: 0.32;
@@ -1999,7 +1985,7 @@ onUnmounted(() => {
}
.row__drop {
border-radius: 16px;
background: rgba(0, 0, 0, 0.18);
background: var(--theme-pill-bg);
border: 1px solid rgba(255, 255, 255, 0.10);
min-height: calc(var(--thumb-size, 80px) + 24px);
padding: 10px;
@@ -2055,7 +2041,7 @@ onUnmounted(() => {
border-radius: 999px;
border: 1px solid rgba(239, 68, 68, 0.32);
background: rgba(11, 18, 32, 0.92);
color: rgba(255, 255, 255, 0.92);
color: var(--theme-text);
font-size: 16px;
line-height: 1;
font-weight: 900;
@@ -2070,14 +2056,14 @@ onUnmounted(() => {
width: var(--thumb-size, 80px);
height: var(--thumb-size, 80px);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft-2);
object-fit: cover;
}
.sidebar {
min-width: 0;
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(180deg, rgba(52, 52, 52, 0.84), rgba(36, 36, 36, 0.8));
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;
padding: 14px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
@@ -2114,7 +2100,7 @@ onUnmounted(() => {
.editorSidebar__label {
font-size: 11px;
font-weight: 800;
color: rgba(255, 255, 255, 0.52);
color: var(--theme-text-faint);
text-transform: uppercase;
letter-spacing: 0.12em;
}
@@ -2122,9 +2108,9 @@ onUnmounted(() => {
.editorSidebar__textarea {
width: 100%;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
padding: 11px 12px;
outline: none;
resize: vertical;
@@ -2135,7 +2121,7 @@ onUnmounted(() => {
.editorSidebar__hint {
font-size: 12px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.56);
color: var(--theme-text-soft);
word-break: keep-all;
}
.editorSidebar__hint--warn {
@@ -2147,8 +2133,8 @@ onUnmounted(() => {
aspect-ratio: 16 / 9;
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
background: #4c4c4c;
border: 1px solid var(--theme-border);
background: var(--theme-thumb-fallback-bg);
}
.editorSidebar__thumbFrame--active {
@@ -2165,7 +2151,7 @@ onUnmounted(() => {
height: 100%;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.36);
color: var(--theme-text-faint);
font-size: 13px;
}
@@ -2184,7 +2170,7 @@ onUnmounted(() => {
}
.editorSidebar__fileName {
font-size: 12px;
color: rgba(255, 255, 255, 0.56);
color: var(--theme-text-soft);
word-break: break-word;
}
.editorSidebar__favorite {
@@ -2195,9 +2181,9 @@ onUnmounted(() => {
width: 100%;
padding: 11px 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.9);
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
color: var(--theme-text);
font-weight: 800;
cursor: pointer;
}
@@ -2223,7 +2209,7 @@ onUnmounted(() => {
border: 0;
padding: 0;
background: transparent;
color: rgba(255, 255, 255, 0.74);
color: var(--theme-text-muted);
font-size: 14px;
cursor: pointer;
}
@@ -2285,16 +2271,16 @@ onUnmounted(() => {
height: 44px;
border-radius: 10px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.14);
border: 1px solid var(--theme-border-strong);
}
.customItemEditor__input {
width: 100%;
min-width: 0;
padding: 9px 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border-strong);
background: var(--theme-pill-bg);
color: var(--theme-text);
outline: none;
box-sizing: border-box;
}
@@ -2303,7 +2289,7 @@ onUnmounted(() => {
padding: 14px;
border-radius: 16px;
border: 1px dashed rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.04);
background: var(--theme-surface-soft);
}
.dropzone--active {
border-color: rgba(110, 231, 183, 0.6);
@@ -2334,7 +2320,7 @@ onUnmounted(() => {
padding: 10px 8px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.10);
background: rgba(0, 0, 0, 0.18);
background: var(--theme-pill-bg);
}
.poolItem--readonly {
opacity: 0.58;
@@ -2363,7 +2349,7 @@ onUnmounted(() => {
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-soft);
}
.hidden {
display: none;

View File

@@ -1,9 +0,0 @@
# Update Log Entry Point
이 프로젝트의 상세 업데이트 로그는 [docs/update.md](/Users/bicute/Desktop/zenn.dev/tier-cursor/docs/update.md)에 계속 누적됩니다.
## 2026-03-30
- 루트 `package.json`에 공용 실행 스크립트(`dev:frontend`, `dev:backend`, `build`, `start`)를 추가했습니다.
- 루트에서도 바로 `npm run build` 같은 공용 명령을 사용할 수 있게 정리했습니다.
- 업데이트 로그 진입점을 루트 `update.md`로 추가해, 이후 작업 시 파일 위치를 바로 찾을 수 있게 했습니다.