Compare commits

...

22 Commits

Author SHA1 Message Date
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
28cf4fdfa0 릴리스: v1.3.30 헤더 브랜딩과 테마 할 일 정리 2026-04-01 15:13:45 +09:00
cf96e931e9 릴리스: v1.3.29 가이드 진입점과 인증 초기화 안정화 2026-04-01 15:07:58 +09:00
3a64dc44c8 릴리스: v1.3.28 사용법 모달 기능 안내 확장 2026-04-01 15:02:49 +09:00
91e16ba415 릴리스: v1.3.27 사용법 모달 구조 추가 2026-04-01 14:52:50 +09:00
a550385ed8 릴리스: v1.3.26 오른쪽 광고 슬롯 규격 정리 2026-04-01 14:40:50 +09:00
5b53c73b56 릴리스: v1.3.25 관리자 게임 선택 UX와 세션 보안 보강 2026-04-01 14:23:04 +09:00
7952f2f289 릴리스: v1.3.24 게임 허브 티어표 그리드 정렬 2026-04-01 14:11:10 +09:00
b851100c89 릴리스: v1.3.23 내 티어표 그리드 열 수 정렬 2026-04-01 14:07:56 +09:00
e70e685a06 릴리스: v1.3.22 내 티어표 카드 폭과 광고 프레임 정리 2026-04-01 14:04:15 +09:00
09acebc2d5 릴리스: v1.3.21 내 티어표 카드 레이아웃을 게임 목록과 통일 2026-04-01 13:53:48 +09:00
e3391b5f07 릴리스: v1.3.20 내 티어표 카드 그리드 밀도 보정 2026-04-01 13:49:54 +09:00
22220494d6 릴리스: v1.3.19 관리자 이미지 최적화 기간 선택 레이아웃 보정 2026-04-01 13:45:45 +09:00
909ed72502 릴리스: v1.3.18 템플릿 요청 실패 보완과 이미지 최적화 기간 선택 개선 2026-04-01 13:32:25 +09:00
c352bf459f 릴리스: v1.3.17 티어 에디터 열 정렬과 삭제 확인 흐름 보정 2026-04-01 12:29:49 +09:00
730a87b923 릴리스: v1.3.16 티어 에디터 행열 삭제 액션과 열 제목 정렬 보정 2026-04-01 12:22:21 +09:00
e9a049241d 릴리스: v1.3.15 티어 에디터 열 헤더와 액션 아이콘 정리 2026-04-01 12:16:06 +09:00
0fec84de13 릴리스: v1.3.14 티어 에디터 행열 보드 확장 2026-04-01 12:07:17 +09:00
7fe4eff7b7 릴리스: v1.3.13 템플릿 요청 스냅샷과 저장 분리 2026-04-01 11:50:54 +09:00
b2a838ff34 릴리스: v1.3.12 회원 정렬 방향과 입력 길이 피드백 2026-04-01 11:15:49 +09:00
695c0bd4dd 릴리스: v1.3.11 회원 관리 모달과 최고 관리자 보호 2026-04-01 10:53:14 +09:00
23 changed files with 2442 additions and 544 deletions

View File

@@ -154,7 +154,7 @@ function mapTemplateRequestRow(row) {
requesterName: getUserDisplayName(row),
requesterAccountName: getUserAccountName(row),
requesterAvatarSrc: row.requester_avatar_src || '',
sourceTierListId: row.source_tierlist_id,
sourceTierListId: row.source_tierlist_id || '',
sourceGameId: row.source_game_id,
sourceGameName: row.source_game_name || '',
sourceTierListTitle: row.title_snapshot || '',
@@ -164,6 +164,9 @@ function mapTemplateRequestRow(row) {
targetGameName: row.target_game_name || '',
status: row.status,
items: parseJson(row.items_json, []),
snapshotGroups: parseJson(row.groups_json, []),
snapshotItems: parseJson(row.board_items_json, []),
snapshotShowCharacterNames: !!row.show_character_names_snapshot,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
}
@@ -389,6 +392,23 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const templateRequestSourceTierListColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_tierlist_id'")
if (templateRequestSourceTierListColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE template_requests MODIFY source_tierlist_id VARCHAR(64) NULL')
}
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")
}
const templateRequestBoardItemsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'board_items_json'")
if (!templateRequestBoardItemsColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN board_items_json LONGTEXT NOT NULL AFTER groups_json")
}
const templateRequestShowNamesColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'show_character_names_snapshot'")
if (!templateRequestShowNamesColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN show_character_names_snapshot TINYINT(1) NOT NULL DEFAULT 0 AFTER board_items_json")
}
const tierListThumbnailColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'thumbnail_src'")
if (!tierListThumbnailColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
@@ -506,25 +526,59 @@ async function updateUserProfile({ id, nickname, avatarSrc }) {
return findUserById(id)
}
async function listUsers() {
const rows = await query(`
SELECT
u.id,
u.email,
u.nickname,
u.is_admin,
u.avatar_src,
u.created_at,
COUNT(t.id) AS tierlist_count,
GREATEST(
async function findPrimaryAdminUser() {
const rows = await query(
'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
)
return mapUserRow(rows[0])
}
async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' } = {}) {
const where = []
const params = []
const trimmedQuery = typeof queryText === 'string' ? queryText.trim() : ''
if (trimmedQuery) {
where.push('(u.email LIKE ? OR u.nickname LIKE ?)')
params.push(`%${trimmedQuery}%`, `%${trimmedQuery}%`)
}
const isAsc = direction === 'asc'
const orderBy =
sort === 'created'
? isAsc
? 'u.created_at ASC, recent_activity_at ASC, u.email ASC'
: 'u.created_at DESC, recent_activity_at DESC, u.email ASC'
: sort === 'tierlists'
? isAsc
? 'tierlist_count ASC, recent_activity_at ASC, u.email ASC'
: 'tierlist_count DESC, recent_activity_at DESC, u.email ASC'
: isAsc
? 'recent_activity_at ASC, u.created_at ASC, u.email ASC'
: 'recent_activity_at DESC, u.created_at ASC, u.email ASC'
const rows = await query(
`
SELECT
u.id,
u.email,
u.nickname,
u.is_admin,
u.avatar_src,
u.created_at,
COALESCE(MAX(t.updated_at), 0)
) AS recent_activity_at
FROM users u
LEFT JOIN tierlists t ON t.author_id = u.id
GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at
ORDER BY recent_activity_at DESC, u.created_at ASC, u.email ASC
`)
COUNT(t.id) AS tierlist_count,
GREATEST(
u.created_at,
COALESCE(MAX(t.updated_at), 0)
) AS recent_activity_at
FROM users u
LEFT JOIN tierlists t ON t.author_id = u.id
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at
ORDER BY ${orderBy}
`,
params
)
return rows.map(mapUserRow)
}
@@ -720,7 +774,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
query("SELECT src FROM game_items WHERE src <> ''"),
query("SELECT src FROM custom_items WHERE src <> ''"),
query("SELECT thumbnail_src, pool_json FROM tierlists"),
query("SELECT thumbnail_src_snapshot, items_json FROM template_requests"),
query("SELECT thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"),
])
for (const row of userRows) if (row.avatar_src) referencedSrcs.add(row.avatar_src)
@@ -736,6 +790,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
for (const row of templateRequestRows) {
if (row.thumbnail_src_snapshot) referencedSrcs.add(row.thumbnail_src_snapshot)
collectUploadSrcsFromItems(parseJson(row.items_json, []), referencedSrcs)
collectUploadSrcsFromItems(parseJson(row.board_items_json, []), referencedSrcs)
}
return assets.filter((asset) => !referencedSrcs.has(asset.src))
@@ -772,7 +827,7 @@ async function listReferencedUploadUsage() {
query("SELECT src FROM game_items WHERE src <> ''"),
query("SELECT src FROM custom_items WHERE src <> ''"),
query("SELECT id, thumbnail_src, pool_json FROM tierlists"),
query("SELECT id, thumbnail_src_snapshot, items_json FROM template_requests"),
query("SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"),
])
for (const row of userRows) addUsage(row.avatar_src, 'avatar')
@@ -788,6 +843,7 @@ async function listReferencedUploadUsage() {
for (const row of templateRequestRows) {
addUsage(row.thumbnail_src_snapshot, 'template-thumbnail')
for (const item of parseJson(row.items_json, [])) addUsage(item?.src, 'template-item')
for (const item of parseJson(row.board_items_json, [])) addUsage(item?.src, 'template-board-item')
}
return Array.from(usageMap.entries())
@@ -840,7 +896,7 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
}
}
const requestRows = await query('SELECT id, thumbnail_src_snapshot, items_json FROM template_requests')
const requestRows = await query('SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests')
for (const row of requestRows) {
let nextThumbnail = row.thumbnail_src_snapshot
let changed = false
@@ -850,12 +906,14 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
}
const replacedItems = replaceItemSrc(parseJson(row.items_json, []), fromSrc, toSrc)
if (replacedItems.changed) changed = true
const replacedBoardItems = replaceItemSrc(parseJson(row.board_items_json, []), fromSrc, toSrc)
if (replacedItems.changed || replacedBoardItems.changed) changed = true
if (changed) {
await query('UPDATE template_requests SET thumbnail_src_snapshot = ?, items_json = ?, updated_at = ? WHERE id = ?', [
await query('UPDATE template_requests SET thumbnail_src_snapshot = ?, items_json = ?, board_items_json = ?, updated_at = ? WHERE id = ?', [
nextThumbnail || '',
serializeJson(replacedItems.items),
serializeJson(replacedBoardItems.items),
now(),
row.id,
])
@@ -1602,19 +1660,24 @@ async function createTemplateRequest({
id,
type,
requesterId,
sourceTierListId,
sourceTierListId = '',
sourceGameId,
targetGameId = '',
title,
description = '',
thumbnailSrc = '',
items = [],
groups = [],
boardItems = [],
showCharacterNames = false,
}) {
const existing = await findPendingTemplateRequestByTierList({ sourceTierListId, type })
if (existing) {
const err = new Error('template_request_exists')
err.code = 'TEMPLATE_REQUEST_EXISTS'
throw err
if (sourceTierListId) {
const existing = await findPendingTemplateRequestByTierList({ sourceTierListId, type })
if (existing) {
const err = new Error('template_request_exists')
err.code = 'TEMPLATE_REQUEST_EXISTS'
throw err
}
}
const createdAt = now()
@@ -1632,22 +1695,28 @@ async function createTemplateRequest({
description_snapshot,
thumbnail_src_snapshot,
items_json,
groups_json,
board_items_json,
show_character_names_snapshot,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[
id,
type,
requesterId,
sourceTierListId,
sourceTierListId || null,
sourceGameId,
targetGameId,
title,
description,
thumbnailSrc,
serializeJson(items),
serializeJson(groups),
serializeJson(boardItems),
showCharacterNames ? 1 : 0,
createdAt,
createdAt,
]
@@ -1670,6 +1739,9 @@ async function findTemplateRequestById(id) {
tr.description_snapshot,
tr.thumbnail_src_snapshot,
tr.items_json,
tr.groups_json,
tr.board_items_json,
tr.show_character_names_snapshot,
tr.created_at,
tr.updated_at,
u.nickname,
@@ -1705,6 +1777,9 @@ async function listAdminTemplateRequests({ status = 'pending' } = {}) {
tr.description_snapshot,
tr.thumbnail_src_snapshot,
tr.items_json,
tr.groups_json,
tr.board_items_json,
tr.show_character_names_snapshot,
tr.created_at,
tr.updated_at,
u.nickname,
@@ -1852,6 +1927,7 @@ module.exports = {
findUserById,
createUser,
updateUserProfile,
findPrimaryAdminUser,
listUsers,
adminUpdateUser,
adminUpdateUserPassword,

View File

@@ -22,6 +22,7 @@ const {
findCustomItemsByIds,
deleteCustomItems,
listUsers,
findPrimaryAdminUser,
listAdminTierLists,
findTierListById,
listAdminTemplateRequests,
@@ -62,6 +63,30 @@ function buildItemLabelFromFilename(file) {
const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 })
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
function decorateAdminUser(user, primaryAdmin) {
if (!user) return null
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
return {
...user,
isPrimaryAdmin,
isOperator: !!user.isAdmin && !isPrimaryAdmin,
role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user',
}
}
async function getAdminUserContext(targetUserId, actingUserId) {
const [targetUser, actingUser, primaryAdmin] = await Promise.all([
findUserById(targetUserId),
findUserById(actingUserId),
findPrimaryAdminUser(),
])
return { targetUser, actingUser, primaryAdmin }
}
function canManageAdminRole(actingUser, primaryAdmin) {
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
}
router.post('/games', requireAdmin, async (req, res) => {
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
const parsed = schema.safeParse(req.body)
@@ -537,8 +562,19 @@ router.delete('/custom-items', requireAdmin, async (req, res) => {
})
router.get('/users', requireAdmin, async (req, res) => {
const users = await listUsers()
res.json({ users })
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
sort: z.enum(['recent', 'created', 'tierlists']).optional().default('recent'),
direction: z.enum(['asc', 'desc']).optional().default('desc'),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const [users, primaryAdmin] = await Promise.all([
listUsers({ queryText: parsed.data.q, sort: parsed.data.sort, direction: parsed.data.direction }),
findPrimaryAdminUser(),
])
res.json({ users: users.map((user) => decorateAdminUser(user, primaryAdmin)) })
})
router.patch('/users/:userId', requireAdmin, async (req, res) => {
@@ -550,21 +586,34 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!targetUser) return res.status(404).json({ error: 'not_found' })
const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin)
const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id
const roleChanged = parsed.data.isAdmin !== !!targetUser.isAdmin
if (req.params.userId === req.session.userId && !parsed.data.isAdmin) {
return res.status(400).json({ error: 'self_admin_required' })
}
const user = await findUserById(req.params.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
if (targetIsPrimaryAdmin && !actingIsPrimaryAdmin) {
return res.status(403).json({ error: 'primary_admin_protected' })
}
if (targetIsPrimaryAdmin && !parsed.data.isAdmin) {
return res.status(400).json({ error: 'primary_admin_required' })
}
if (roleChanged && !actingIsPrimaryAdmin) {
return res.status(403).json({ error: 'primary_admin_only' })
}
try {
const updated = await adminUpdateUser({
id: user.id,
id: targetUser.id,
email: parsed.data.email,
nickname: parsed.data.nickname,
isAdmin: parsed.data.isAdmin,
})
res.json({ user: updated })
res.json({ user: decorateAdminUser(updated, primaryAdmin) })
} catch (e) {
if (e && e.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'email_taken' })
@@ -580,8 +629,11 @@ router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar')
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserById(req.params.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!targetUser) return res.status(404).json({ error: 'not_found' })
if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) {
return res.status(403).json({ error: 'primary_admin_protected' })
}
const optimized = req.file
? await writeOptimizedImage({
@@ -594,16 +646,16 @@ router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar')
})
: null
const shouldRemoveAvatar = parsed.data.removeAvatar === '1'
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || ''
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || targetUser.avatarSrc || ''
const updated = await adminUpdateUser({
id: user.id,
email: user.email,
nickname: user.nickname || '',
isAdmin: !!user.isAdmin,
id: targetUser.id,
email: targetUser.email,
nickname: targetUser.nickname || '',
isAdmin: !!targetUser.isAdmin,
avatarSrc: nextAvatarSrc,
})
res.json({ user: updated })
res.json({ user: decorateAdminUser(updated, primaryAdmin) })
})
router.delete('/users/:userId', requireAdmin, async (req, res) => {
@@ -611,10 +663,19 @@ router.delete('/users/:userId', requireAdmin, async (req, res) => {
return res.status(400).json({ error: 'cannot_delete_self' })
}
const user = await findUserById(req.params.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!targetUser) return res.status(404).json({ error: 'not_found' })
await adminDeleteUser(user.id)
const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin)
const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id
if (targetIsPrimaryAdmin) {
return res.status(400).json({ error: 'cannot_delete_primary_admin' })
}
if (targetUser.isAdmin && !actingIsPrimaryAdmin) {
return res.status(403).json({ error: 'primary_admin_only' })
}
await adminDeleteUser(targetUser.id)
res.json({ ok: true })
})
@@ -625,11 +686,14 @@ router.patch('/users/:userId/password', requireAdmin, async (req, res) => {
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserById(req.params.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!targetUser) return res.status(404).json({ error: 'not_found' })
if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) {
return res.status(403).json({ error: 'primary_admin_protected' })
}
const passwordHash = await bcrypt.hash(parsed.data.password, 10)
await adminUpdateUserPassword({ id: user.id, passwordHash })
await adminUpdateUserPassword({ id: targetUser.id, passwordHash })
res.json({ ok: true })
})

View File

@@ -9,6 +9,7 @@ const {
findUserById,
createUser,
updateUserProfile,
findPrimaryAdminUser,
} = require('../db')
const { requireAuth } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
@@ -25,6 +26,38 @@ const profileSchema = z.object({
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
})
function establishSession(req, user) {
return new Promise((resolve, reject) => {
req.session.regenerate((regenerateError) => {
if (regenerateError) return reject(regenerateError)
req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin
req.session.save((saveError) => {
if (saveError) return reject(saveError)
resolve()
})
})
})
}
async function serializeUser(user) {
if (!user) return null
const primaryAdmin = await findPrimaryAdminUser()
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
return {
id: user.id,
email: user.email,
nickname: user.nickname || '',
isAdmin: !!user.isAdmin,
isPrimaryAdmin,
isOperator: !!user.isAdmin && !isPrimaryAdmin,
role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user',
avatarSrc: user.avatarSrc || '',
createdAt: user.createdAt,
}
}
router.post('/signup', async (req, res) => {
const parsed = signupSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -37,12 +70,12 @@ router.post('/signup', async (req, res) => {
const isAdmin = (await countUsers()) === 0
const user = await createUser({ id: nanoid(), email, nickname: '', passwordHash, isAdmin })
req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin
req.session.save((err) => {
if (err) return res.status(500).json({ error: 'session_save_failed' })
res.json(user)
})
try {
await establishSession(req, user)
res.json(await serializeUser(user))
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
})
router.post('/login', async (req, res) => {
@@ -56,19 +89,12 @@ router.post('/login', async (req, res) => {
const ok = await bcrypt.compare(password, user.passwordHash)
if (!ok) return res.status(401).json({ error: 'invalid_credentials' })
req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin
req.session.save((err) => {
if (err) return res.status(500).json({ error: 'session_save_failed' })
res.json({
id: user.id,
email: user.email,
nickname: user.nickname || '',
isAdmin: !!user.isAdmin,
avatarSrc: user.avatarSrc || '',
createdAt: user.createdAt,
})
})
try {
await establishSession(req, user)
res.json(await serializeUser(user))
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
})
router.post('/logout', async (req, res) => {
@@ -80,7 +106,7 @@ router.get('/me', async (req, res) => {
if (!req.session || !req.session.userId) return res.json({ user: null })
const user = await findUserById(req.session.userId)
if (!user) return res.json({ user: null })
res.json({ user })
res.json({ user: await serializeUser(user) })
})
router.get('/meta', async (req, res) => {
@@ -115,7 +141,7 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
avatarSrc: nextAvatarSrc,
})
res.json({ user: updated })
res.json({ user: await serializeUser(updated) })
})
module.exports = router

View File

@@ -58,6 +58,33 @@ function getCustomTemplateItems(tierList) {
const upload = createMemoryUpload(multer, { fileSize: 6 * 1024 * 1024 })
const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024 })
const templateRequestSchema = z.object({
type: z.enum(['create', 'update']),
sourceTierListId: z.string().max(64).optional().default(''),
gameId: z.string().min(1).max(120),
requestTitle: z.string().trim().min(1).max(120),
requestDescription: z.string().trim().min(1).max(1000),
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),
name: z.string().min(1).max(16),
itemIds: z.array(z.string()).optional().default([]),
}).passthrough()
),
boardItems: z.array(
z.object({
id: z.string().min(1),
src: z.string().min(1),
label: z.string().min(1).max(60),
origin: z.enum(['game', 'custom']).default('game'),
})
),
})
const tierListUpsertSchema = z.object({
id: z.string().optional(),
gameId: z.string().min(1),
@@ -73,8 +100,8 @@ const tierListUpsertSchema = z.object({
z.object({
id: z.string().min(1),
name: z.string().min(1).max(16),
itemIds: z.array(z.string()),
})
itemIds: z.array(z.string()).optional().default([]),
}).passthrough()
),
pool: z.array(
z.object({
@@ -194,42 +221,64 @@ router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), asyn
res.json({ thumbnailSrc: optimized.src })
})
router.post('/:id/template-request', requireAuth, async (req, res) => {
const schema = z.object({
type: z.enum(['create', 'update']),
requestTitle: z.string().trim().min(1).max(80),
requestDescription: z.string().trim().min(1).max(240),
})
const parsed = schema.safeParse(req.body)
router.post('/template-request', requireAuth, async (req, res) => {
const parsed = templateRequestSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const tierList = await findTierListById(req.params.id, req.session.userId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
if (tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
const customItems = getCustomTemplateItems(tierList)
const payload = parsed.data
const normalizedBoardItems = payload.boardItems.map(normalizePoolItem)
const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom')
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
if (parsed.data.type === 'create') {
if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
} else {
if (tierList.gameId === FREEFORM_GAME_ID) return res.status(400).json({ error: 'game_template_required' })
if (payload.type === 'create') {
if (payload.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
} else if (payload.gameId === FREEFORM_GAME_ID) {
return res.status(400).json({ error: 'game_template_required' })
}
let sourceTierList = null
if (payload.sourceTierListId) {
sourceTierList = await findTierListById(payload.sourceTierListId, req.session.userId)
if (!sourceTierList) return res.status(404).json({ error: 'not_found' })
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,
})
}
try {
const request = await createTemplateRequest({
id: nanoid(),
type: parsed.data.type,
type: payload.type,
requesterId: req.session.userId,
sourceTierListId: tierList.id,
sourceGameId: tierList.gameId,
targetGameId: parsed.data.type === 'update' ? tierList.gameId : '',
title: parsed.data.requestTitle,
description: parsed.data.requestDescription,
thumbnailSrc: tierList.thumbnailSrc || '',
sourceTierListId: savedTierList?.id || sourceTierList?.id || '',
sourceGameId: payload.gameId,
targetGameId: payload.type === 'update' ? payload.gameId : '',
title: payload.requestTitle,
description: payload.requestDescription,
thumbnailSrc: payload.thumbnailSrc || '',
items: customItems,
groups: payload.groups,
boardItems: normalizedBoardItems,
showCharacterNames: !!payload.showCharacterNames,
})
return res.json({ request })
return res.json({ request, savedTierList: savedTierList ? normalizeTierList(savedTierList) : null })
} catch (e) {
if (e?.code === 'TEMPLATE_REQUEST_EXISTS') {
return res.status(409).json({ error: 'template_request_exists' })

View File

@@ -1,21 +1,12 @@
# 할 일 및 이슈
## 즉시 확인 필요
- 티어표 형식 추가 필요. 최근 게임들은 S, A, B,C 같은 랭크 뿐만 아니라 가로 열도 나누어진형태의 티어표를 원함 (공격, 방어, 지원 등 각 파트별 랭크를 보고싶어함)
- 레거시 파일 정리 스크립트는 준비됐으므로, 운영 단계에서는 cron 등으로 주기 실행할지와 삭제 전 보관 기간을 함께 정한다.
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.
- 이미지 최적화 기록은 월별 조회/비우기까지 지원하므로, 운영 단계에서는 보관 기간 정책과 자동 아카이브 기준을 정한다.
## 배포 전 작업
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
- MariaDB 접속 정보 `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`를 설정한다.
- HTTPS를 사용할 경우 `SESSION_COOKIE_SECURE=true`로 설정하고 리버스 프록시 헤더 전달을 확인한다.
- `backend/uploads/`, `backend/.sessions/`, MariaDB 백업 정책을 정한다.
- 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다.
## 중기 개선
- 라이트모드/다크모드 1차 전환은 붙였으므로, 관리자 화면과 티어 에디터까지 세부 색상 균형을 더 정교하게 맞추는 후속 테마 보정 작업을 이어간다.
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다.
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
- 로그인/회원가입/관리자 비밀번호 초기화에 요청 횟수 제한을 추가한다.
- 업로드 파일은 MIME 타입뿐 아니라 파일 시그니처 기반 검증까지 확장한다.
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.

View File

@@ -1,5 +1,97 @@
# 업데이트 로그
## 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를 유지하면서도 이후 테마 확장 흐름을 공식 작업 목록에 올림.
## 2026-04-01 v1.3.29
- 책 아이콘 사용법 모달 진입점은 항상 보이는 오른쪽 사이드 하단 버튼 대신, Settings 화면에서만 왼쪽 사이드 하단의 보조 액션 버튼으로 옮겨 더 필요할 때만 찾게 되는 문맥형 진입 방식으로 정리함.
- 인증 스토어에 초기 세션 동기화 완료 상태를 추가하고, 앱 셸·로그인 화면·프로필 화면은 세션 확인 전까지 비로그인 UI를 먼저 그리지 않도록 보강해 첫 진입 시 화면이 갑자기 로그인 상태로 뒤집히는 플래시를 줄임.
## 2026-04-01 v1.3.28
- 책 아이콘 기반 사용법 모달은 기존의 단순 제작 흐름 안내를 넘어, 다른 사람 티어표 복사, 템플릿 업그레이드 요청, 새 템플릿 추가 요청, 즐겨찾기/내 티어표 관리까지 포함한 전체 기능 안내 허브로 확장함.
- 사용법 모달 제목과 단계 표기를 더 넓은 개념의 `기능 안내` 기준으로 정리하고, 실제 스크린샷이 없어도 설명만으로 핵심 기능을 순서대로 이해할 수 있게 단계 문구를 전면 보강함.
## 2026-04-01 v1.3.27
- 오른쪽 사이드 하단에 책 아이콘 진입점을 추가하고, 중앙 대형 사용법 모달을 열어 좌측 기능 리스트와 우측 16:9 설명 영역, 좌우 이동, 하단 페이지네이션까지 포함한 기본 가이드 흐름을 붙임.
- 사용법 모달의 스크린샷 영역은 우선 16:9 플레이스홀더와 설명 텍스트만 배치해, 실제 이미지 자산은 나중에 채워 넣을 수 있게 구조를 먼저 준비함.
## 2026-04-01 v1.3.26
- 오른쪽 사이드는 실제 광고 슬롯 기준을 300x600 세로 비율로 잡고, 데스크톱 우측 레일 폭도 325px로 조정해 300px 광고가 내부 패딩과 보더를 제외한 실폭 안에 자연스럽게 들어가도록 보정함.
## 2026-04-01 v1.3.25
- todo 문서에서는 운영 정책/배포 체크 성격 항목을 우선 제거하고, 제품/보안 후속 작업 중심으로 다시 정리함.
- 관리자 게임 관리는 우측 셀렉트 박스 대신 검색 가능한 리스트와 최신순/오래된순 정렬로 바꿔, 게임 수가 많아져도 실제로 선택 가능한 구조로 개선함.
- 로그인과 회원가입은 기존 세션을 그대로 덮어쓰지 않고 세션을 재생성한 뒤 사용자 정보를 저장하도록 바꿔, 세션 고정 공격 방어를 보강함.
## 2026-04-01 v1.3.24
- 게임 선택 후 보이는 공개 티어표 목록 그리드도 auto-fit 최대폭 방식 대신 4/3/2/1열 고정 반응형 규칙으로 바꿔, 넓은 화면에서 카드 한 장이 애매하게 다음 줄로 넘어가며 공백이 크게 남던 문제를 줄임.
## 2026-04-01 v1.3.23
- 내 티어표 목록 그리드는 auto-fit 최대폭 방식 대신 게임 목록과 같은 4/3/2/1열 고정 반응형 규칙으로 맞춰, 넓은 화면에서 카드 한 장이 애매하게 다음 줄로 떨어지며 여백이 크게 남던 문제를 줄임.
## 2026-04-01 v1.3.22
- 내 티어표 카드는 게임 목록과 같은 상단 히어로/패널 문법으로 다시 맞추고, 깨진 썸네일은 alt 텍스트가 카드 폭을 밀지 않도록 플레이스홀더로 즉시 대체해 카드 수와 헤더 폭이 흔들리지 않게 보정함.
- 오른쪽 사이드 광고 프레임은 별도 보더·패딩·배경을 제거해, 광고 자체가 가진 각진 형태와 색이 그대로 보이도록 더 담백하게 정리함.
## 2026-04-01 v1.3.21
- 내 티어표 카드는 게임 목록 화면과 같은 카드 폭/헤더/메타 배치 문법으로 맞춰, 화면 간 카드 크기와 정보 정렬이 더 통일된 인상으로 보이도록 정리함.
## 2026-04-01 v1.3.20
- 내 티어표 카드 그리드는 카드 최대폭 우선 규칙 대신 더 촘촘한 auto-fill 기준으로 조정해, 넓은 화면에서도 한 줄에 더 많은 카드가 자연스럽게 배치되도록 보정함.
## 2026-04-01 v1.3.19
- 관리자 Image Optimization 기간 선택은 연도/월을 가로로 나란히 두고, 연도를 고르기 전에는 월 셀렉트를 숨겨 비어 있는 박스처럼 보이던 상태를 없앰.
- 전체 초기화 버튼도 실제 월이 선택된 경우에만 보이도록 정리해, 사이드바 상단 필터 줄이 더 단정하게 보이도록 보정함.
## 2026-04-01 v1.3.18
- 커스텀 아이템 기본 이름은 파일명 전체를 그대로 쓰지 않고 확장자 제거·공백 정리·60자 제한을 먼저 적용하도록 바꿔, 템플릿 요청 전에 커스텀 업로드가 길이 제한으로 실패하던 흐름을 줄임.
- 템플릿 요청 실패 안내는 커스텀 이미지 업로드 실패와 일반 bad request를 구분해, 사용자가 제목/설명/아이템 이름 길이 제한 문제를 더 쉽게 파악할 수 있게 보강함.
- 관리자 Image Optimization 월 필터는 기본 month input 대신 연도/월 셀렉트와 전체 초기화 버튼으로 바꿔, 기간 선택을 더 직관적으로 조작할 수 있게 정리함.
## 2026-04-01 v1.3.17
- 티어 에디터 열 헤더 입력창과 행 라벨은 좌우 패딩을 대칭으로 다시 잡아, 드래그 핸들과 삭제 아이콘이 있어도 제목이 한쪽으로 쏠려 보이지 않도록 보정함.
- 열 삭제도 이제 행 삭제와 같은 확인 모달을 거쳐 진행되도록 바꿔, 실수로 즉시 제거되던 문제를 막음.
- 내보내기 보드는 여전히 960px 고정 폭이라 열 수가 늘수록 각 칸 폭이 줄어드는 구조라는 점을 기준으로 정리했고, 현재 보정은 헤더 정렬 문제를 우선 해결하는 쪽에 맞춤.
## 2026-04-01 v1.3.16
- 티어 에디터의 행 삭제와 열 삭제는 다시 작은 X 아이콘 액션으로 정리해, 행/열 이름 주변의 반복 텍스트 때문에 보드가 답답해 보이던 문제를 줄임.
- 열 헤더 편집 영역은 입력창 오른쪽에 아이콘 삭제만 남기고, 행 라벨도 상단 우측의 작은 제거 버튼으로 맞춰 더 압축된 편집 밀도를 유지하도록 조정함.
- 저장 이미지에서 열 제목이 살짝 위로 떠 보이던 문제는 내보내기 헤더의 비대칭 패딩을 제거하고 flex 중앙 정렬로 바꿔, 시각적으로 정확한 중앙에 오도록 보정함.
## 2026-04-01 v1.3.15
- 티어 에디터의 열 이름은 각 행 안에서 반복 렌더링되지 않도록 공통 상단 헤더로 분리해, 행 제목과 같은 구조로 더 또렷하게 구분되도록 수정함.
- 행 추가/열 추가 액션은 새 SVG 아이콘 버튼으로 압축해, 텍스트 때문에 보드 상단 툴바 높이가 과하게 커지던 문제를 정리함.
- 미리보기와 삭제 모달 문구도 행/열 기준으로 함께 정리해, 전체 티어 에디터 흐름을 더 일관된 용어와 레이아웃으로 다듬음.
## 2026-04-01 v1.3.14
- 티어 에디터를 단일 세로 랭크형에서 행/열 혼합 보드로 확장해, 공격·방어·지원 같은 가로 열을 추가하고 각 열 이름도 직접 입력할 수 있게 함.
- 에디터 액션 문구를 `행 추가 / 열 추가` 기준으로 정리하고, 행 라벨 폭과 드래그 아이콘 위치를 다듬어 실제 사용 빈도에 맞는 더 압축된 보드 레이아웃으로 보정함.
- 이름 오버레이 정렬과 저장용 미리보기 보드도 함께 손봐서, 이미지 다운로드 시 라벨 텍스트가 하단 중앙에 더 안정적으로 배치되도록 수정함.
## 2026-04-01 v1.3.13
- 템플릿 등록/업데이트 요청 모달은 이제 현재 티어표 제목·설명을 기본값으로 가져오고, 비어 있더라도 모달 안에서 바로 작성해 요청할 수 있도록 흐름을 단순화함.
- 템플릿 요청 시 `내 티어 리스트에도 저장` 토글을 추가해, 요청 스냅샷만 관리자에게 전달할지 아니면 현재 양식도 내 티어표로 함께 저장할지 분리함.
- 관리자 템플릿 요청 관리는 더 이상 원본 티어표 링크에 의존하지 않고, 요청 시점의 그룹/아이템/이름표시 상태를 그대로 담은 스냅샷 미리보기를 직접 열어 확인할 수 있게 확장함.
## 2026-04-01 v1.3.12
- 관리자 회원 관리 상단에 정렬 방향 선택을 추가해, 최근 활동순·가입순·작성 티어표순을 각각 오름차순/내림차순으로 다시 볼 수 있게 확장함.
- 회원 정보 수정, 새 게임 생성, 비밀번호 초기화 모달은 Settings 톤 입력 스타일을 유지하면서 각 입력칸에 글자 수 피드백을 함께 보여주도록 정리함.
- 로그인, 설정, 티어 에디터 제목·설명·요청 제목·요청 설명·티어 행 이름에도 최대 길이와 현재 입력 길이 안내를 붙여, 제출 전에 제한을 바로 인지할 수 있게 개선함.
## 2026-04-01 v1.3.11
- **회원 관리 편집 모달 전환**: 관리자 회원 카드를 읽기 전용 정보 카드로 바꾸고, `회원 정보 수정` 버튼으로 Settings 톤의 편집 모달에서 이메일/닉네임/운영자 권한을 저장하도록 재구성
- **회원 검색/정렬 추가**: 회원 관리 상단에 이메일/닉네임 검색과 `최근 활동순`, `가입순`, `작성 티어표 많은 순` 정렬을 추가해 운영자가 원하는 기준으로 목록을 다시 볼 수 있도록 확장
- **최고 관리자 보호 도입**: 가장 먼저 생성된 관리자 계정을 `최고 관리자`로 구분하고, 운영자는 최고 관리자 권한/아바타/비밀번호/삭제를 변경할 수 없도록 백엔드 보호 로직과 역할 메타데이터를 추가
## 2026-04-01 v1.3.10
- 게임 허브 공개 티어표 카드 그리드는 최소/최대 폭을 고정해, 목록이 1~2장뿐일 때도 카드가 화면 전체를 먹으며 과하게 커지지 않도록 보정함.
- 티어표 행 삭제는 상단 아이콘 대신 우측 하단의 작은 텍스트 액션으로 바꿔, 랭크 카드 안에서 더 조용하고 정돈된 편집 흐름으로 정리함.

View File

@@ -11,6 +11,7 @@ import iconFavorite from './assets/icons/favorite.svg'
import iconLists from './assets/icons/lists.svg'
import iconSearch from './assets/icons/search.svg'
import iconSettings from './assets/icons/settings.svg'
import iconMenuBook from './assets/icons/menu_book.svg'
import RightRailAd from './components/RightRailAd.vue'
import SvgIcon from './components/SvgIcon.vue'
@@ -24,11 +25,15 @@ const rightRailOpen = ref(true)
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)
provide('localRightRailTarget', '#local-right-rail-root')
const isAdmin = computed(() => !!auth.user?.isAdmin)
const authReady = computed(() => auth.hydrated)
const isAdmin = computed(() => authReady.value && !!auth.user?.isAdmin)
const isPreviewMode = computed(() => route.query.preview === '1')
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor', 'admin'].includes(String(route.name || '')))
const isRightRailOverlay = computed(() => viewportWidth.value <= 1200)
@@ -41,10 +46,13 @@ const accountName = computed(() => {
if (email) return email.split('@')[0] || email
return 'Guest'
})
const accountEmail = computed(() => (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.')
const accountEmail = computed(() => {
if (!authReady.value) return '계정 상태를 확인하고 있어요.'
return (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.'
})
const shellStyle = computed(() => ({
'--left-rail-width': leftRailCollapsed.value ? '76px' : '248px',
'--right-rail-width': !isRightRailOverlay.value && rightRailOpen.value ? '320px' : '0px',
'--right-rail-width': !isRightRailOverlay.value && rightRailOpen.value ? '325px' : '0px',
}))
const leftNavItems = computed(() => {
const items = [
@@ -53,12 +61,78 @@ const leftNavItems = computed(() => {
{ key: 'favorites', label: 'Favorites', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
{ key: 'profile', label: 'Settings', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
]
return items.filter((item) => !item.requiresAuth || auth.user)
return items.filter((item) => !item.requiresAuth || (authReady.value && auth.user))
})
const showRightRailAction = computed(() => false)
const showSettingsGuideButton = computed(() => route.name === 'profile')
const guideSteps = [
{
id: 'select-game',
title: '게임 또는 양식 선택',
summary: '게임 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
description:
'홈 화면에서는 게임 템플릿을 선택하거나 커스텀 티어표 만들기로 바로 새 보드를 열 수 있어요. 게임을 먼저 고르면 해당 게임의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
},
{
id: 'arrange-board',
title: '행과 열 구성',
summary: '랭크 행과 가로 열을 정리해 보드 구조를 먼저 잡습니다.',
description:
'기본 랭크를 그대로 써도 되고, 행 이름을 바꾸거나 행과 열을 추가해 공격·방어·지원처럼 더 세밀한 구조로 나눌 수도 있어요. 먼저 판을 정리한 뒤 배치를 시작하면 뒤에서 크게 손댈 일이 줄어듭니다.',
},
{
id: 'drop-items',
title: '아이템 배치와 커스텀 추가',
summary: '프리셋 아이템과 직접 올린 이미지를 드래그로 배치합니다.',
description:
'오른쪽 아이템 영역의 이미지를 원하는 칸으로 끌어다 놓으면 바로 배치됩니다. 게임 템플릿에 없는 이미지는 커스텀 이미지로 추가해 같이 쓸 수 있고, 이름 표시 옵션을 켜면 결과 이미지를 더 설명적으로 정리할 수 있어요.',
},
{
id: 'save-share',
title: '저장과 이미지 다운로드',
summary: '완성한 티어표를 내 목록에 저장하거나 PNG 이미지로 내려받습니다.',
description:
'보드 작업이 끝나면 저장해서 내 티어표 목록에 남길 수 있고, 이미지 다운로드로 한 장의 결과물로 바로 공유할 수도 있어요. 공개 여부도 함께 정할 수 있어서 개인 메모용과 공유용 흐름을 나눠 쓰기 좋습니다.',
},
{
id: 'copy-existing',
title: '다른 사람 티어표 복사',
summary: '공개된 티어표를 그대로 가져와 내 이름의 새 작업본으로 이어서 수정합니다.',
description:
'누군가 만든 티어표가 거의 마음에 드는데 일부만 바꾸고 싶다면, 복사 기능으로 현재 배치 상태를 그대로 가져와 새 티어표로 시작할 수 있어요. 복사본에는 원본을 참고했다는 정보가 함께 남아서 출처도 자연스럽게 구분됩니다.',
},
{
id: 'request-template-update',
title: '템플릿 업그레이드 요청',
summary: '현재 게임 템플릿에 공통 아이템을 추가해 달라고 관리자에게 요청합니다.',
description:
'직접 추가한 아이템 중 여러 사람이 함께 써도 좋을 것 같은 항목이 있다면 템플릿 업데이트 요청을 보낼 수 있어요. 요청 모달에서는 현재 티어표 제목과 설명을 기본값으로 가져오고, 필요하면 요청 제목과 설명을 더 다듬어 공통 템플릿에 왜 필요한지 설명할 수 있습니다.',
},
{
id: 'request-new-template',
title: '새 템플릿 추가 요청',
summary: '아직 없는 게임이나 새로운 양식을 관리자에게 제안합니다.',
description:
'원하는 게임 템플릿이 아직 없다면 새 템플릿 추가 요청으로 관리자에게 직접 제안할 수 있어요. 이때는 제목과 설명에 어떤 게임인지, 어떤 캐릭터나 항목이 기본으로 필요할지 적어두면 검토 속도가 훨씬 빨라집니다.',
},
{
id: 'manage-library',
title: '즐겨찾기와 내 티어표 관리',
summary: '마음에 드는 템플릿과 저장한 결과물을 나중에 다시 쉽게 찾습니다.',
description:
'게임 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
},
]
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(() => route.name === 'profile')
const showGameHubViewToggle = computed(() => route.name === 'gameHub')
const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
const leftBottomPrimaryAction = computed(() => {
if (!authReady.value) return null
if (route.name === 'home' && auth.user) {
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new' }
}
@@ -170,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()
@@ -185,6 +274,10 @@ onMounted(async () => {
})
function handleGlobalKeydown(event) {
if (event.key === 'Escape' && isGuideModalOpen.value) {
closeGuideModal()
return
}
if (event.key === 'Escape' && isCollapsedSearchOpen.value) {
closeCollapsedSearch()
}
@@ -202,6 +295,7 @@ watch(
() => {
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
isCollapsedSearchOpen.value = false
isGuideModalOpen.value = false
}
)
@@ -262,6 +356,29 @@ function closeCollapsedSearch() {
isCollapsedSearchOpen.value = false
}
function openGuideModal(stepIndex = 0) {
guideStepIndex.value = Math.min(Math.max(Number(stepIndex) || 0, 0), guideSteps.length - 1)
isGuideModalOpen.value = true
}
function closeGuideModal() {
isGuideModalOpen.value = false
}
function selectGuideStep(index) {
guideStepIndex.value = Math.min(Math.max(index, 0), guideSteps.length - 1)
}
function showPrevGuideStep() {
if (isGuidePrevDisabled.value) return
guideStepIndex.value -= 1
}
function showNextGuideStep() {
if (isGuideNextDisabled.value) return
guideStepIndex.value += 1
}
function handleLeftRailSearch() {
if (leftRailCollapsed.value && !isMobileLayout.value) {
openCollapsedSearch()
@@ -309,7 +426,7 @@ function submitGlobalSearch() {
<div class="leftRail__body">
<div class="leftRail__content">
<div v-if="auth.user" class="appUserCard">
<div v-if="authReady && auth.user" class="appUserCard">
<div class="appUserCard__button">
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" />
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
@@ -350,8 +467,12 @@ function submitGlobalSearch() {
</div>
<div class="leftRail__bottom">
<RouterLink v-if="leftBottomPrimaryAction" :to="leftBottomPrimaryAction.to" class="adminButton">{{ leftBottomPrimaryAction.label }}</RouterLink>
<RouterLink v-if="isAdmin" to="/admin" class="adminButton">관리자 메뉴</RouterLink>
<RouterLink v-else-if="!auth.user" to="/login" class="adminButton">로그인</RouterLink>
<button v-if="showSettingsGuideButton" class="adminButton adminButton--icon" type="button" @click="openGuideModal()">
<SvgIcon :src="iconMenuBook" :size="18" class="adminButton__icon" />
<span>가이드 보기</span>
</button>
<RouterLink v-if="authReady && isAdmin" to="/admin" class="adminButton">관리자 메뉴</RouterLink>
<RouterLink v-else-if="authReady && !auth.user" to="/login" class="adminButton">로그인</RouterLink>
</div>
</div>
</aside>
@@ -361,7 +482,15 @@ function submitGlobalSearch() {
<header class="workspaceHead railHeader">
<div class="workspaceHead__brand" @click="$router.push('/')">
<span class="workspaceHead__brandTitle">Tier Maker</span>
<span class="workspaceHead__brandSub">by zenn</span>
<a
class="workspaceHead__brandSub"
href="https://zenn.town/@murabito"
target="_blank"
rel="noreferrer"
@click.stop
>
by zenn
</a>
</div>
<div class="workspaceHead__actions">
<div v-if="showGameHubViewToggle" class="viewToggle" role="group" aria-label="티어표 보기 방식">
@@ -392,6 +521,66 @@ function submitGlobalSearch() {
</form>
</div>
<div v-if="isGuideModalOpen" class="guideModal" role="dialog" aria-modal="true" aria-label="티어 메이커 기능 안내" @click.self="closeGuideModal">
<div class="guideModal__dialog">
<div class="guideModal__sidebar">
<div class="guideModal__eyebrow">Guide</div>
<div class="guideModal__title">티어 메이커 기능 안내</div>
<div class="guideModal__list">
<button
v-for="(step, index) in guideSteps"
:key="step.id"
class="guideModal__listItem"
:class="{ 'guideModal__listItem--active': index === guideStepIndex }"
type="button"
@click="selectGuideStep(index)"
>
<span class="guideModal__listIndex">{{ index + 1 }}</span>
<span class="guideModal__listLabel">{{ step.title }}</span>
</button>
</div>
</div>
<div class="guideModal__main">
<button class="guideModal__close" type="button" aria-label="사용법 닫기" @click="closeGuideModal">닫기</button>
<div class="guideModal__content">
<button class="guideModal__arrow" type="button" aria-label="이전 단계" :disabled="isGuidePrevDisabled" @click="showPrevGuideStep"></button>
<div class="guideModal__body">
<div class="guideModal__media">
<div class="guideModal__mediaPlaceholder">
<div class="guideModal__mediaBadge">16:9</div>
<div class="guideModal__mediaTitle">{{ currentGuideStep.title }}</div>
<div class="guideModal__mediaHint">스크린샷 준비 </div>
</div>
</div>
<div class="guideModal__text">
<div class="guideModal__stepLabel">GUIDE {{ guideStepIndex + 1 }}</div>
<div class="guideModal__stepTitle">{{ currentGuideStep.title }}</div>
<div class="guideModal__stepSummary">{{ currentGuideStep.summary }}</div>
<p class="guideModal__stepDescription">{{ currentGuideStep.description }}</p>
</div>
<div class="guideModal__footer">
<div class="guideModal__pagination">
<button
v-for="(step, index) in guideSteps"
:key="step.id + '-dot'"
class="guideModal__dot"
:class="{ 'guideModal__dot--active': index === guideStepIndex }"
type="button"
:aria-label="step.title"
@click="selectGuideStep(index)"
></button>
</div>
<button class="guideModal__next" type="button" @click="isGuideNextDisabled ? closeGuideModal() : showNextGuideStep()">
{{ isGuideNextDisabled ? '닫기' : '다음' }}
</button>
</div>
</div>
<button class="guideModal__arrow" type="button" aria-label="다음 단계" :disabled="isGuideNextDisabled" @click="showNextGuideStep"></button>
</div>
</div>
</div>
</div>
<button v-if="rightRailOpen && isRightRailOverlay" class="rightRailBackdrop" type="button" aria-label="오른쪽 패널 닫기" @click="toggleRightRail"></button>
<aside class="rightRail" :class="{ 'rightRail--closed': !rightRailOpen, 'rightRail--overlay': isRightRailOverlay }" :aria-hidden="!rightRailOpen">
@@ -404,7 +593,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">
@@ -436,9 +635,9 @@ function submitGlobalSearch() {
.appShell {
min-height: 100dvh;
display: grid;
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr) var(--right-rail-width, 320px);
background: rgba(14, 14, 14, 0.96);
color: rgba(255, 255, 255, 0.92);
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr) var(--right-rail-width, 325px);
background: var(--theme-shell-bg);
color: var(--theme-text);
transition: grid-template-columns 220ms ease;
}
@@ -449,8 +648,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;
@@ -460,7 +659,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,
@@ -484,7 +683,7 @@ 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);
box-sizing: border-box;
}
@@ -543,8 +742,8 @@ 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);
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
color: rgba(255, 255, 255, 0.72);
cursor: pointer;
display: inline-flex;
@@ -613,7 +812,7 @@ 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 {
@@ -639,7 +838,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;
@@ -652,8 +851,8 @@ 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);
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
color: rgba(255, 255, 255, 0.62);
margin-bottom: 14px;
box-sizing: border-box;
@@ -666,7 +865,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;
@@ -674,7 +873,7 @@ function submitGlobalSearch() {
}
.searchStub__input::placeholder {
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
}
.searchStub__iconButton {
@@ -805,23 +1004,32 @@ function submitGlobalSearch() {
display: inline-flex;
justify-content: center;
align-items: center;
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;
}
.adminButton--icon {
text-align: center;
}
.adminButton__icon {
flex: 0 0 auto;
}
.appMain {
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 {
@@ -857,12 +1065,22 @@ 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;
}
.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: var(--theme-text);
}
.workspaceHead__actions {
@@ -878,7 +1096,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 {
@@ -889,7 +1107,7 @@ function submitGlobalSearch() {
}
.ghostIcon--active {
background: rgba(255, 255, 255, 0.08);
background: var(--theme-surface-soft-3);
}
.workspaceBody {
@@ -897,7 +1115,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;
}
@@ -907,7 +1125,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;
}
@@ -924,16 +1142,110 @@ function submitGlobalSearch() {
.rightRail__bottom {
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: 10px;
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;
}
@@ -942,6 +1254,240 @@ function submitGlobalSearch() {
display: none;
}
.guideModal {
position: fixed;
inset: 0;
z-index: 36;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 20px;
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(10px);
}
.guideModal__dialog {
width: min(1180px, calc(100vw - 40px));
min-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);
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);
}
.guideModal__sidebar {
display: grid;
align-content: start;
gap: 18px;
padding: 28px 22px;
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);
}
.guideModal__title {
font-size: 28px;
font-weight: 900;
line-height: 1.1;
letter-spacing: -0.04em;
}
.guideModal__list {
display: grid;
gap: 8px;
}
.guideModal__listItem {
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid var(--theme-border);
background: rgba(255, 255, 255, 0.02);
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
text-align: left;
}
.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);
}
.guideModal__listIndex {
font-size: 12px;
font-weight: 900;
color: rgba(255, 255, 255, 0.54);
}
.guideModal__listLabel {
min-width: 0;
font-size: 14px;
font-weight: 700;
}
.guideModal__main {
min-width: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
padding: 24px 28px 28px;
}
.guideModal__close {
justify-self: end;
border: 0;
background: transparent;
color: var(--theme-text-muted);
cursor: pointer;
font-size: 13px;
}
.guideModal__content {
min-width: 0;
display: grid;
grid-template-columns: 52px minmax(0, 1fr) 52px;
gap: 16px;
align-items: center;
}
.guideModal__body {
min-width: 0;
display: grid;
gap: 18px;
}
.guideModal__media {
width: 100%;
}
.guideModal__mediaPlaceholder {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 24px;
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;
justify-items: center;
gap: 8px;
text-align: center;
}
.guideModal__mediaBadge {
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.38);
}
.guideModal__mediaTitle {
font-size: 24px;
font-weight: 900;
}
.guideModal__mediaHint {
font-size: 13px;
color: rgba(255, 255, 255, 0.48);
}
.guideModal__text {
display: grid;
gap: 8px;
}
.guideModal__stepLabel {
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--theme-text-soft);
}
.guideModal__stepTitle {
font-size: 28px;
font-weight: 900;
letter-spacing: -0.04em;
}
.guideModal__stepSummary {
font-size: 16px;
font-weight: 700;
color: rgba(255, 255, 255, 0.86);
}
.guideModal__stepDescription {
margin: 0;
max-width: 720px;
line-height: 1.7;
color: rgba(255, 255, 255, 0.62);
}
.guideModal__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.guideModal__pagination {
display: flex;
align-items: center;
gap: 8px;
}
.guideModal__dot {
width: 10px;
height: 10px;
border-radius: 999px;
border: 0;
background: rgba(255, 255, 255, 0.18);
cursor: pointer;
}
.guideModal__dot--active {
width: 26px;
background: rgba(77, 127, 233, 0.9);
}
.guideModal__next {
padding: 12px 18px;
border-radius: 14px;
border: 1px solid rgba(77, 127, 233, 0.96);
background: var(--theme-accent-bg);
color: var(--theme-accent-text);
font-weight: 800;
cursor: pointer;
}
.guideModal__arrow {
width: 52px;
height: 52px;
border-radius: 999px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
font-size: 28px;
line-height: 1;
cursor: pointer;
}
.guideModal__arrow:disabled {
opacity: 0.28;
cursor: default;
}
.collapsedSearchModal {
position: fixed;
inset: 0;
@@ -987,7 +1533,7 @@ 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;
@@ -1025,7 +1571,7 @@ function submitGlobalSearch() {
justify-content: space-between;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
border: 1px solid var(--theme-border-strong);
background: rgba(11, 18, 32, 0.94);
backdrop-filter: blur(12px);
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.28);
@@ -1059,7 +1605,7 @@ function submitGlobalSearch() {
.toast__count {
margin-top: 6px;
font-size: 12px;
color: rgba(255, 255, 255, 0.56);
color: var(--theme-text-muted);
}
.toast__close {
@@ -1071,6 +1617,20 @@ function submitGlobalSearch() {
}
@media (max-width: 1200px) {
.guideModal__dialog {
grid-template-columns: 1fr;
min-height: auto;
}
.guideModal__sidebar {
border-right: 0;
border-bottom: 1px solid var(--theme-border);
}
.guideModal__content {
grid-template-columns: 40px minmax(0, 1fr) 40px;
}
.appShell {
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr);
}
@@ -1091,8 +1651,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);
}
@@ -1104,6 +1664,45 @@ function submitGlobalSearch() {
}
}
@media (max-width: 860px) {
.guideModal {
padding: 20px 12px;
}
.guideModal__dialog {
width: min(100%, calc(100vw - 24px));
}
.guideModal__main {
padding: 20px 18px 18px;
}
.guideModal__content {
grid-template-columns: 1fr;
}
.guideModal__arrow {
display: none;
}
.guideModal__footer {
flex-direction: column;
align-items: stretch;
}
.guideModal__next {
width: 100%;
}
.guideDockButton {
display: none;
}
.guideModal__list {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 860px) {
.appShell {
grid-template-columns: 1fr;
@@ -1114,7 +1713,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

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M160-760v560h240v-560H160ZM80-120v-720h720v160h-80v-80H480v560h240v-80h80v160H80Zm400-360Zm-80 0h80-80Zm0 0Zm320 120v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 283 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M200-560h560v-240H200v240Zm-80 400v-720h720v720H680v-80h80v-240H200v240h80v80H120Zm360-320Zm0-80v80-80Zm0 0ZM440-80v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 282 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M560-564v-68q33-14 67.5-21t72.5-7q26 0 51 4t49 10v64q-24-9-48.5-13.5T700-600q-38 0-73 9.5T560-564Zm0 220v-68q33-14 67.5-21t72.5-7q26 0 51 4t49 10v64q-24-9-48.5-13.5T700-380q-38 0-73 9t-67 27Zm0-110v-68q33-14 67.5-21t72.5-7q26 0 51 4t49 10v64q-24-9-48.5-13.5T700-490q-38 0-73 9.5T560-454ZM260-320q47 0 91.5 10.5T440-278v-394q-41-24-87-36t-93-12q-36 0-71.5 7T120-692v396q35-12 69.5-18t70.5-6Zm260 42q44-21 88.5-31.5T700-320q36 0 70.5 6t69.5 18v-396q-33-14-68.5-21t-71.5-7q-47 0-93 12t-87 36v394Zm-40 118q-48-38-104-59t-116-21q-42 0-82.5 11T100-198q-21 11-40.5-1T40-234v-482q0-11 5.5-21T62-752q46-24 96-36t102-12q58 0 113.5 15T480-740q51-30 106.5-45T700-800q52 0 102 12t96 36q11 5 16.5 15t5.5 21v482q0 23-19.5 35t-40.5 1q-37-20-77.5-31T700-240q-60 0-116 21t-104 59ZM280-494Z"/></svg>

After

Width:  |  Height:  |  Size: 895 B

View File

@@ -77,15 +77,16 @@ onMounted(async () => {
}
.rightRailAd__frame {
min-height: 520px;
padding: 14px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.02);
width: min(100%, 300px);
min-height: 600px;
margin: 0 auto;
}
.rightRailAd__slot {
width: 100%;
min-height: 490px;
display: block;
width: 300px;
max-width: 100%;
min-height: 600px;
margin: 0 auto;
}
</style>

View File

@@ -62,7 +62,8 @@ export const api = {
approveAdminTemplateRequest: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/approve`, { method: 'POST', body: payload || {} }),
rejectAdminTemplateRequest: (requestId) => request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/reject`, { method: 'POST', body: {} }),
listAdminUsers: () => request('/api/admin/users'),
listAdminUsers: ({ q = '', sort = 'recent', direction = 'desc' } = {}) =>
request(`/api/admin/users?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}&direction=${encodeURIComponent(direction)}`),
updateAdminUser: (userId, payload) =>
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
updateAdminUserPassword: (userId, payload) =>
@@ -100,7 +101,7 @@ export const api = {
unfavoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'DELETE' }),
deleteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`, { method: 'DELETE' }),
duplicateTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/duplicate`, { method: 'POST' }),
requestTierListTemplate: (id, payload) => request(`/api/tierlists/${encodeURIComponent(id)}/template-request`, { method: 'POST', body: payload }),
requestTierListTemplate: (payload) => request('/api/tierlists/template-request', { method: 'POST', body: payload }),
saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }),
uploadTierListThumbnail: async (file) => {
const fd = new FormData()

View File

@@ -5,30 +5,40 @@ export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
status: 'idle',
hydrated: false,
}),
actions: {
async refresh() {
if (this.status === 'loading') return this.user
this.status = 'loading'
try {
const data = await api.me()
this.user = data.user
return this.user
} catch (error) {
this.user = null
return null
} finally {
this.status = 'idle'
this.hydrated = true
}
},
async signup(email, password) {
const user = await api.signup({ email, password })
this.user = user
this.hydrated = true
return user
},
async login(email, password) {
const user = await api.login({ email, password })
this.user = user
this.hydrated = true
return user
},
async logout() {
await api.logout()
this.user = null
this.hydrated = true
},
},
})

View File

@@ -2,12 +2,67 @@
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: var(--theme-text-strong);
--theme-text-muted: var(--theme-text-muted);
--theme-text-soft: var(--theme-text-soft);
--theme-text-faint: rgba(255, 255, 255, 0.4);
--theme-thumb-fallback-bg: #555;
--theme-select-arrow: var(--theme-select-arrow);
--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;
}
:root[data-theme='light'] {
--theme-body-bg: #edf1f7;
--theme-shell-bg: rgba(244, 247, 252, 0.98);
--theme-rail-bg: rgba(248, 250, 253, 0.96);
--theme-main-bg: rgba(241, 244, 249, 0.98);
--theme-workspace-bg: rgba(250, 252, 255, 0.95);
--theme-card-bg: var(--theme-text-strong);
--theme-card-bg-hover: rgba(245, 248, 255, 0.98);
--theme-card-border: rgba(26, 32, 44, 0.1);
--theme-card-shadow: 0 18px 34px rgba(31, 41, 55, 0.08);
--theme-surface-soft: rgba(15, 23, 42, 0.05);
--theme-surface-soft-2: rgba(15, 23, 42, 0.07);
--theme-surface-soft-3: rgba(15, 23, 42, 0.1);
--theme-pill-bg: rgba(15, 23, 42, 0.04);
--theme-border: rgba(15, 23, 42, 0.1);
--theme-border-strong: rgba(15, 23, 42, 0.14);
--theme-text: rgba(20, 27, 40, 0.9);
--theme-text-strong: rgba(10, 15, 28, 0.98);
--theme-text-muted: rgba(55, 65, 81, 0.74);
--theme-text-soft: rgba(75, 85, 99, 0.64);
--theme-text-faint: rgba(100, 116, 139, 0.82);
--theme-thumb-fallback-bg: #d8dde8;
--theme-select-arrow: rgba(55, 65, 81, 0.72);
--theme-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;
}
* {
@@ -22,7 +77,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 +100,7 @@ a {
input,
select,
textarea {
color: rgba(255, 255, 255, 0.92);
color: var(--theme-text);
}
select {
@@ -51,8 +108,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 +156,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 {

View File

@@ -22,6 +22,8 @@ const games = ref([])
const selectedGameId = ref('')
const selectedGame = ref(null)
const featuredGameIds = ref([])
const gameAdminQuery = ref('')
const gameAdminSort = ref('recent')
const customItems = ref([])
const customItemQuery = ref('')
@@ -47,6 +49,7 @@ const importModalNewGameId = ref('')
const importModalNewGameName = ref('')
const previewModalOpen = ref(false)
const previewTierList = ref(null)
const userEditModalOpen = ref(false)
const userPasswordModalOpen = ref(false)
const userDeleteModalOpen = ref(false)
const userRoleModalOpen = ref(false)
@@ -55,9 +58,15 @@ const customItemDeleteModalOpen = ref(false)
const modalTargetUser = ref(null)
const modalPasswordDraft = ref('')
const modalRoleNextAdmin = ref(false)
const modalUserDraftEmail = ref('')
const modalUserDraftNickname = ref('')
const modalUserDraftIsAdmin = ref(false)
const modalTargetCustomItem = ref(null)
const users = ref([])
const userQuery = ref('')
const userSort = ref('recent')
const userSortDirection = ref('desc')
const imageStats = ref(null)
const imageQueue = ref({ concurrency: 1, activeCount: 0, pendingCount: 0 })
const imageRecentJobs = ref([])
@@ -97,6 +106,19 @@ const featuredGames = computed(() =>
.filter(Boolean)
)
const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id)))
const filteredAdminGames = computed(() => {
const query = gameAdminQuery.value.trim().toLowerCase()
const list = games.value.filter((game) => {
if (!query) return true
const haystack = `${game.name || ''} ${game.id || ''}`.toLowerCase()
return haystack.includes(query)
})
return list.slice().sort((a, b) => {
if (gameAdminSort.value === 'oldest') return Number(a.createdAt || 0) - Number(b.createdAt || 0)
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
})
})
const importModalItemCount = computed(() => importModalItems.value.length)
const activeTabTitle = computed(() => {
if (activeTab.value === 'featured') return '목록 관리'
@@ -128,7 +150,7 @@ const adminOverviewStats = computed(() => {
const publishedTierLists = adminTierLists.value.filter((tierList) => tierList.isPublic).length
const pendingRequests = templateRequests.value.length
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length
const adminCount = users.value.filter((user) => user.isAdmin || user.draftIsAdmin).length
const adminCount = users.value.filter((user) => user.isAdmin).length
if (activeTab.value === 'featured') {
return [
@@ -263,6 +285,49 @@ const visibleLinkedGames = computed(() =>
)
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
const imageStatsYearOptions = computed(() => {
const currentYear = new Date().getFullYear()
return Array.from({ length: 6 }, (_, index) => String(currentYear - index))
})
const imageStatsMonthOptions = [
{ value: '01', label: '1월' },
{ value: '02', label: '2월' },
{ value: '03', label: '3월' },
{ value: '04', label: '4월' },
{ value: '05', label: '5월' },
{ value: '06', label: '6월' },
{ value: '07', label: '7월' },
{ value: '08', label: '8월' },
{ value: '09', label: '9월' },
{ value: '10', label: '10월' },
{ value: '11', label: '11월' },
{ value: '12', label: '12월' },
]
const selectedImageStatsYear = computed({
get: () => (imageStatsMonth.value ? imageStatsMonth.value.slice(0, 4) : ''),
set: (year) => {
if (!year) {
imageStatsMonth.value = ''
return
}
const month = imageStatsMonth.value ? imageStatsMonth.value.slice(5, 7) : '01'
imageStatsMonth.value = `${year}-${month}`
},
})
const selectedImageStatsMonthNumber = computed({
get: () => (imageStatsMonth.value ? imageStatsMonth.value.slice(5, 7) : ''),
set: (month) => {
if (!month) {
imageStatsMonth.value = ''
return
}
const year = imageStatsMonth.value ? imageStatsMonth.value.slice(0, 4) : String(new Date().getFullYear())
imageStatsMonth.value = `${year}-${month}`
},
})
function clearImageStatsMonth() {
imageStatsMonth.value = ''
}
async function refreshImageDiagnostics() {
try {
@@ -328,6 +393,12 @@ async function handleSelectedGameChange(event) {
await loadGame()
}
async function selectAdminGame(gameId) {
if (!gameId || selectedGameId.value === gameId) return
selectedGameId.value = gameId
await loadGame()
}
async function refreshGames() {
try {
const data = await api.listGames()
@@ -351,9 +422,25 @@ function setUserAvatarInput(userId, el) {
userAvatarInputs.value[userId] = el
}
function isUserDirty(user) {
if (!user) return false
return user.draftEmail !== user.email || (user.draftNickname || '') !== (user.nickname || '') || !!user.draftIsAdmin !== !!user.isAdmin
const canManageModalRole = computed(() => {
if (!auth.user?.isPrimaryAdmin) return false
if (!modalTargetUser.value) return false
return !modalTargetUser.value.isPrimaryAdmin
})
const isUserEditDirty = computed(() => {
if (!modalTargetUser.value) return false
return (
modalUserDraftEmail.value.trim() !== (modalTargetUser.value.email || '') ||
modalUserDraftNickname.value.trim() !== (modalTargetUser.value.nickname || '') ||
!!modalUserDraftIsAdmin.value !== !!modalTargetUser.value.isAdmin
)
})
function roleLabelOf(user) {
if (user?.isPrimaryAdmin) return '최고 관리자'
if (user?.isAdmin) return '운영자'
return '일반 회원'
}
function openUserAvatarPicker(user) {
@@ -372,17 +459,14 @@ async function uploadUserAvatar(user, file, { remove = false } = {}) {
entry.id === updated.id
? {
...entry,
avatarSrc: updated.avatarSrc || '',
email: updated.email,
nickname: updated.nickname || '',
isAdmin: !!updated.isAdmin,
draftEmail: updated.email,
draftNickname: updated.nickname || '',
draftIsAdmin: !!updated.isAdmin,
...updated,
isAvatarBusy: false,
}
: entry
)
if (modalTargetUser.value?.id === updated.id) {
modalTargetUser.value = { ...modalTargetUser.value, ...updated }
}
if (updated.id === auth.user?.id) await auth.refresh()
await refreshUsers()
success.value = remove ? '회원 썸네일을 삭제했어요.' : '회원 썸네일을 업데이트했어요.'
@@ -496,12 +580,9 @@ async function refreshTemplateRequests() {
async function refreshUsers() {
if (!auth.user?.isAdmin) return
try {
const data = await api.listAdminUsers()
const data = await api.listAdminUsers({ q: userQuery.value, sort: userSort.value, direction: userSortDirection.value })
users.value = (data.users || []).map((user) => ({
...user,
draftEmail: user.email,
draftNickname: user.nickname || '',
draftIsAdmin: !!user.isAdmin,
isAvatarBusy: false,
}))
} catch (e) {
@@ -778,29 +859,45 @@ async function removeGame() {
}
}
async function saveUser(user) {
function openUserEditModal(user) {
resetMessages()
modalTargetUser.value = user ? { ...user } : null
modalUserDraftEmail.value = user?.email || ''
modalUserDraftNickname.value = user?.nickname || ''
modalUserDraftIsAdmin.value = !!user?.isAdmin
userEditModalOpen.value = true
}
function closeUserEditModal() {
userEditModalOpen.value = false
modalTargetUser.value = null
modalUserDraftEmail.value = ''
modalUserDraftNickname.value = ''
modalUserDraftIsAdmin.value = false
}
async function saveUserEdit() {
resetMessages()
if (!modalTargetUser.value?.id) return
try {
const data = await api.updateAdminUser(user.id, {
email: user.draftEmail,
nickname: user.draftNickname,
isAdmin: !!user.draftIsAdmin,
const data = await api.updateAdminUser(modalTargetUser.value.id, {
email: modalUserDraftEmail.value.trim(),
nickname: modalUserDraftNickname.value.trim(),
isAdmin: !!modalUserDraftIsAdmin.value,
})
const updated = data.user
users.value = users.value.map((entry) =>
entry.id === updated.id
? {
...entry,
email: updated.email,
nickname: updated.nickname || '',
isAdmin: !!updated.isAdmin,
draftEmail: updated.email,
draftNickname: updated.nickname || '',
draftIsAdmin: !!updated.isAdmin,
...updated,
isAvatarBusy: entry.isAvatarBusy || false,
}
: entry
)
if (updated.id === auth.user?.id) await auth.refresh()
closeUserEditModal()
await refreshUsers()
success.value = '회원 정보를 저장했어요.'
} catch (e) {
@@ -810,7 +907,7 @@ async function saveUser(user) {
function openUserPasswordModal(user) {
resetMessages()
modalTargetUser.value = user || null
modalTargetUser.value = user ? { ...user } : null
modalPasswordDraft.value = ''
userPasswordModalOpen.value = true
}
@@ -842,7 +939,7 @@ async function confirmUserPasswordReset() {
function openUserDeleteModal(user) {
resetMessages()
modalTargetUser.value = user || null
modalTargetUser.value = user ? { ...user } : null
userDeleteModalOpen.value = true
}
@@ -869,34 +966,31 @@ async function confirmUserDelete() {
}
function openUserRoleModal(user) {
function openUserRoleModal(user, nextIsAdmin = !modalUserDraftIsAdmin.value) {
resetMessages()
modalTargetUser.value = user || null
modalRoleNextAdmin.value = !user?.draftIsAdmin
modalTargetUser.value = user ? { ...user } : null
modalRoleNextAdmin.value = !!nextIsAdmin
userRoleModalOpen.value = true
}
function closeUserRoleModal() {
userRoleModalOpen.value = false
modalTargetUser.value = null
if (!userEditModalOpen.value) modalTargetUser.value = null
modalRoleNextAdmin.value = false
}
function confirmUserRoleDraft() {
if (!modalTargetUser.value?.id) return
users.value = users.value.map((entry) =>
entry.id === modalTargetUser.value.id
? {
...entry,
draftIsAdmin: modalRoleNextAdmin.value,
}
: entry
)
const targetLabel = modalRoleNextAdmin.value ? '관리자로 지정했어요. 저장하면 반영됩니다.' : '관리자 권한 해제로 표시했어요. 저장하면 반영됩니다.'
modalUserDraftIsAdmin.value = modalRoleNextAdmin.value
const targetLabel = modalRoleNextAdmin.value ? '운영자 권한을 저장 대기 상태로 반영했어요.' : '운영자 권한 해제를 저장 대기 상태로 반영했어요.'
closeUserRoleModal()
success.value = targetLabel
}
function submitUserFilters() {
refreshUsers()
}
function submitCustomItemSearch() {
customItemPage.value = 1
refreshCustomItems()
@@ -1035,6 +1129,38 @@ function openAdminTierList(tierList) {
previewModalOpen.value = true
}
function previewRequestItemsById(preview) {
const items = Array.isArray(preview?.snapshotItems) ? preview.snapshotItems : []
return items.reduce((acc, item) => {
if (item?.id) acc[item.id] = item
return acc
}, {})
}
function previewRequestGroupItems(preview, group) {
const itemsById = previewRequestItemsById(preview)
return (group?.itemIds || []).map((itemId) => itemsById[itemId]).filter(Boolean)
}
function previewRequestPoolItems(preview) {
const groupedIds = new Set((preview?.snapshotGroups || []).flatMap((group) => group.itemIds || []))
return (preview?.snapshotItems || []).filter((item) => !groupedIds.has(item.id))
}
function openTemplateRequestPreview(request) {
previewTierList.value = {
id: request.id,
title: request.sourceTierListTitle || '템플릿 요청 미리보기',
description: request.sourceDescription || '',
thumbnailSrc: request.thumbnailSrc || '',
requestPreview: true,
snapshotGroups: request.snapshotGroups || [],
snapshotItems: request.snapshotItems || [],
snapshotShowCharacterNames: !!request.snapshotShowCharacterNames,
}
previewModalOpen.value = true
}
function closePreviewModal() {
previewModalOpen.value = false
previewTierList.value = null
@@ -1440,13 +1566,14 @@ async function saveFeaturedOrder() {
<div class="templateRequestCard__head">
<div>
<div class="templateRequestCard__title">{{ request.sourceTierListTitle }}</div>
<div v-if="request.sourceDescription" class="templateRequestCard__desc">{{ request.sourceDescription }}</div>
<div class="templateRequestCard__meta">
{{ templateRequestTypeLabel(request) }} · {{ request.requesterName }} · {{ fmt(request.createdAt) }}
</div>
<div class="templateRequestCard__meta">{{ templateRequestTargetLabel(request) }}</div>
</div>
<button class="btn btn--ghost btn--small" @click="openAdminTierList({ id: request.sourceTierListId, gameId: request.sourceGameId })">
원본 보기
<button class="btn btn--ghost btn--small" @click="openTemplateRequestPreview(request)">
요청 미리보기
</button>
</div>
@@ -1548,6 +1675,25 @@ async function saveFeaturedOrder() {
</div>
</div>
<div class="toolbar toolbar--secondary">
<input
v-model="userQuery"
class="input toolbar__search"
placeholder="이메일, 닉네임 검색"
@keydown.enter.prevent="submitUserFilters"
/>
<select v-model="userSort" class="select toolbar__select" @change="submitUserFilters">
<option value="recent">최근 활동순</option>
<option value="created">가입순</option>
<option value="tierlists">작성 티어표 많은 </option>
</select>
<select v-model="userSortDirection" class="select toolbar__select" @change="submitUserFilters">
<option value="desc">내림차순</option>
<option value="asc">오름차순</option>
</select>
<button class="btn btn--ghost toolbar__button" type="button" @click="submitUserFilters">조회</button>
</div>
<div v-if="!users.length" class="hint">아직 가입한 회원이 없어요.</div>
<div v-else class="userList">
<article v-for="user in users" :key="user.id" class="userCard">
@@ -1584,25 +1730,17 @@ async function saveFeaturedOrder() {
</div>
</div>
<div v-if="user.draftIsAdmin" class="roleBadge userCard__roleBadge">Administrator</div>
<div v-if="user.isAdmin" class="roleBadge userCard__roleBadge">{{ roleLabelOf(user) }}</div>
<div class="userInfoList">
<div class="userInfoLine"><span>가입일</span><strong>{{ fmt(user.createdAt) }}</strong></div>
<div class="userInfoLine"><span>작성 티어표</span><strong>{{ user.tierListCount }}</strong></div>
<div class="userInfoLine"><span>최근 활동</span><strong>{{ fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
<div class="userInfoLine"><span>계정명</span><strong>{{ user.email }}</strong></div>
<div class="userInfoLine"><span>닉네임</span><strong>{{ user.nickname || '미설정' }}</strong></div>
<div class="userInfoLine"><span>권한</span><strong>{{ roleLabelOf(user) }}</strong></div>
</div>
<input v-model="user.draftEmail" class="input" placeholder="이메일" />
<input v-model="user.draftNickname" class="input" placeholder="닉네임" />
<button
class="userRoleAction"
type="button"
:disabled="user.id === auth.user?.id"
@click="openUserRoleModal(user)"
>
{{ user.draftIsAdmin ? '관리자 권한 해제' : '관리자 권한 임명' }}
</button>
<div class="userCard__actions userCard__actions--compact">
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="openUserPasswordModal(user)">
<SvgIcon class="iconActionButton__icon" :src="lockResetIcon" :size="18" />
@@ -1610,7 +1748,7 @@ async function saveFeaturedOrder() {
<button class="iconActionButton iconActionButton--danger" type="button" title="회원 삭제" @click="openUserDeleteModal(user)">
<SvgIcon class="iconActionButton__icon" :src="deleteIcon" :size="18" />
</button>
<button class="btn btn--ghost userSaveButton" :disabled="!isUserDirty(user)" @click="saveUser(user)">회원정보 저장</button>
<button class="btn btn--ghost userSaveButton" type="button" @click="openUserEditModal(user)">회원 정보 수정</button>
</div>
</article>
</div>
@@ -1622,8 +1760,22 @@ async function saveFeaturedOrder() {
<div class="modalCard__title"> 게임 만들기</div>
<div class="modalCard__desc">게임 이름과 고유 ID를 입력한 생성하면 바로 아래 상세 관리 화면으로 이어집니다.</div>
<div class="modalCard__form">
<input v-model="newGameName" class="input" placeholder="게임 이름" />
<input v-model="newGameId" class="input" placeholder="game id (영문/숫자)" @keydown.enter.prevent="createGame" />
<label class="field">
<span class="field__label">게임 이름</span>
<input v-model="newGameName" class="field__input" maxlength="60" placeholder="게임 이름" />
<span class="field__hint">{{ newGameName.length }}/60</span>
</label>
<label class="field">
<span class="field__label">게임 ID</span>
<input
v-model="newGameId"
class="field__input"
maxlength="120"
placeholder="game id (영문/숫자)"
@keydown.enter.prevent="createGame"
/>
<span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newGameId.length }}/120</span>
</label>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeGameCreateModal">취소</button>
@@ -1632,12 +1784,56 @@ async function saveFeaturedOrder() {
</div>
</div>
<div v-if="userEditModalOpen" class="modalOverlay" @click.self="closeUserEditModal">
<div class="modalCard modalCard--userEdit" role="dialog" aria-modal="true">
<div class="modalCard__title">회원 정보 수정</div>
<div class="modalCard__desc">{{ modalTargetUser ? `${userDisplayName(modalTargetUser)} 계정의 정보와 권한을 조정할 수 있어요.` : '' }}</div>
<div class="userEditForm">
<label class="field">
<span class="field__label">이메일</span>
<input v-model="modalUserDraftEmail" class="field__input" maxlength="255" placeholder="계정 이메일" />
<span class="field__hint">로그인 계정으로 사용하는 이메일입니다. {{ modalUserDraftEmail.length }}/255</span>
</label>
<label class="field">
<span class="field__label">닉네임</span>
<input v-model="modalUserDraftNickname" class="field__input" maxlength="40" placeholder="표시용 닉네임" />
<span class="field__hint">티어표 작성자명과 프로필에 표시됩니다. {{ modalUserDraftNickname.length }}/40</span>
</label>
<button
v-if="canManageModalRole"
class="userRoleAction"
type="button"
@click="openUserRoleModal(modalTargetUser, !modalUserDraftIsAdmin)"
>
{{ modalUserDraftIsAdmin ? '운영자 권한 해제' : '운영자 권한 부여' }}
</button>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeUserEditModal">취소</button>
<button class="btn btn--primary" :disabled="!isUserEditDirty" @click="saveUserEdit">회원 정보 저장</button>
</div>
</div>
</div>
<div v-if="userPasswordModalOpen" class="modalOverlay" @click.self="closeUserPasswordModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">비밀번호 초기화</div>
<div class="modalCard__desc">{{ modalTargetUser ? `${userDisplayName(modalTargetUser)} 계정에 설정할 새 비밀번호를 입력해주세요.` : '' }}</div>
<div class="modalCard__form">
<input v-model="modalPasswordDraft" class="input" type="password" placeholder="초기화할 비밀번호 입력" @keydown.enter.prevent="confirmUserPasswordReset" />
<label class="field">
<span class="field__label"> 비밀번호</span>
<input
v-model="modalPasswordDraft"
class="field__input"
type="password"
maxlength="120"
placeholder="초기화할 비밀번호 입력"
@keydown.enter.prevent="confirmUserPasswordReset"
/>
<span class="field__hint">6~120 권장 · {{ modalPasswordDraft.length }}/120</span>
</label>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeUserPasswordModal">취소</button>
@@ -1659,13 +1855,13 @@ async function saveFeaturedOrder() {
<div v-if="userRoleModalOpen" class="modalOverlay" @click.self="closeUserRoleModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">관리 권한 변경</div>
<div class="modalCard__title">운영 권한 변경</div>
<div class="modalCard__desc">
{{
modalTargetUser
? modalRoleNextAdmin
? `${userDisplayName(modalTargetUser)} 사용자를 관리자로 임명할까요?`
: `${userDisplayName(modalTargetUser)} 사용자의 관리자 권한을 해제할까요?`
? `${userDisplayName(modalTargetUser)} 사용자를 운영자로 지정할까요?`
: `${userDisplayName(modalTargetUser)} 사용자의 운영자 권한을 해제할까요?`
: ''
}}
</div>
@@ -1787,8 +1983,45 @@ async function saveFeaturedOrder() {
<div class="modalCard__title">{{ previewTierList?.title || '티어표 미리보기' }}</div>
<button class="btn btn--ghost btn--small" @click="closePreviewModal">닫기</button>
</div>
<div v-if="previewTierList?.requestPreview" class="requestPreview">
<img
v-if="previewTierList.thumbnailSrc"
class="requestPreview__thumb"
:src="toApiUrl(previewTierList.thumbnailSrc)"
:alt="previewTierList.title"
/>
<div v-if="previewTierList.description" class="requestPreview__desc">{{ previewTierList.description }}</div>
<div class="requestPreview__rows">
<div v-for="group in previewTierList.snapshotGroups" :key="group.id" class="requestPreview__row">
<div class="requestPreview__rowLabel">{{ group.name }}</div>
<div class="requestPreview__rowItems">
<div
v-for="item in previewRequestGroupItems(previewTierList, group)"
:key="item.id"
class="requestPreview__item"
>
<img class="requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div v-if="previewTierList.snapshotShowCharacterNames" class="requestPreview__itemLabel">{{ item.label }}</div>
</div>
</div>
</div>
</div>
<div v-if="previewRequestPoolItems(previewTierList).length" class="requestPreview__pool">
<div class="requestPreview__poolLabel">남은 아이템</div>
<div class="requestPreview__rowItems">
<div
v-for="item in previewRequestPoolItems(previewTierList)"
:key="item.id"
class="requestPreview__item requestPreview__item--muted"
>
<img class="requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div v-if="previewTierList.snapshotShowCharacterNames" class="requestPreview__itemLabel">{{ item.label }}</div>
</div>
</div>
</div>
</div>
<iframe
v-if="previewTierList"
v-else-if="previewTierList"
class="previewFrame"
:src="previewTierListUrl(previewTierList)"
title="티어표 미리보기"
@@ -1818,10 +2051,25 @@ async function saveFeaturedOrder() {
<div class="adminSidebar__label">Game</div>
<div class="adminSidebar__group">
<button class="btn btn--primary" @click="openGameCreateModal"> 게임 생성</button>
<select :value="selectedGameId" class="select" @change="handleSelectedGameChange">
<option value="">게임을 선택해주세요</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
<input v-model="gameAdminQuery" class="input" placeholder="게임 이름 또는 ID 검색" />
<select v-model="gameAdminSort" class="select">
<option value="recent">최신순</option>
<option value="oldest">오래된순</option>
</select>
<div class="adminGamePicker">
<button
v-for="game in filteredAdminGames"
:key="game.id"
class="adminGamePicker__item"
:class="{ 'adminGamePicker__item--active': selectedGameId === game.id }"
type="button"
@click="selectAdminGame(game.id)"
>
<span class="adminGamePicker__name">{{ game.name }}</span>
<span class="adminGamePicker__meta">{{ game.id }}</span>
</button>
<div v-if="!filteredAdminGames.length" class="hint hint--tight">검색 결과가 없어요.</div>
</div>
<div v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div>
</div>
<div v-if="hasSelectedGame" class="adminSidebar__group">
@@ -1931,8 +2179,18 @@ async function saveFeaturedOrder() {
<section v-if="activeTab === 'featured'" class="adminSidebar__panel">
<div class="adminSidebar__label">Image Optimization</div>
<div class="adminSidebar__group">
<input v-model="imageStatsMonth" class="input" type="month" />
<div class="adminSidebar__group adminSidebar__group--monthPicker">
<div class="monthPicker">
<select v-model="selectedImageStatsYear" class="select monthPicker__select monthPicker__select--year">
<option value="">전체 기간</option>
<option v-for="year in imageStatsYearOptions" :key="year" :value="year">{{ year }}</option>
</select>
<select v-if="selectedImageStatsYear" v-model="selectedImageStatsMonthNumber" class="select monthPicker__select monthPicker__select--month">
<option value=""> 선택</option>
<option v-for="month in imageStatsMonthOptions" :key="month.value" :value="month.value">{{ month.label }}</option>
</select>
<button v-if="imageStatsMonth" class="btn btn--ghost btn--tiny" type="button" @click="clearImageStatsMonth">전체</button>
</div>
<select v-model.number="imageStatsLimit" class="select">
<option :value="6">최근 6</option>
<option :value="12">최근 12</option>
@@ -2119,6 +2377,24 @@ async function saveFeaturedOrder() {
display: grid;
gap: 10px;
}
.adminSidebar__group--monthPicker {
align-items: start;
}
.monthPicker {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.monthPicker__select {
min-width: 0;
}
.monthPicker__select--year {
flex: 1 1 132px;
}
.monthPicker__select--month {
flex: 1 1 108px;
}
.adminSidebar__actions--stack .btn {
width: 100%;
}
@@ -2130,6 +2406,39 @@ async function saveFeaturedOrder() {
font-weight: 800;
color: rgba(255, 255, 255, 0.84);
}
.adminGamePicker {
display: grid;
gap: 8px;
max-height: 640px;
overflow: auto;
padding-right: 4px;
}
.adminGamePicker__item {
display: grid;
/* gap: 2px; */
padding: 11px 12px;
text-align: left;
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);
cursor: pointer;
}
.adminGamePicker__item--active {
border-color: rgba(77, 127, 233, 0.58);
background: rgba(77, 127, 233, 0.12);
}
.adminGamePicker__name {
font-size: 13px;
font-weight: 800;
}
.adminGamePicker__meta {
font-size: 11px;
color: rgba(255, 255, 255, 0.56);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebarStat {
display: grid;
gap: 4px;
@@ -2459,11 +2768,11 @@ async function saveFeaturedOrder() {
border: 0;
}
.btn {
height: 100%;
font-size: 12px;
line-height: 1.2;
white-space: nowrap;
word-break: keep-all;
margin-top: 12px;
padding: 11px 13px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.14);
@@ -2995,6 +3304,44 @@ async function saveFeaturedOrder() {
font-size: 14px;
font-weight: 900;
}
.field {
display: grid;
gap: 8px;
}
.field__label {
font-size: 13px;
color: rgba(255, 255, 255, 0.62);
}
.field__input {
width: 100%;
padding: 14px 0;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
background: transparent;
color: rgba(255, 255, 255, 0.94);
outline: none;
font-size: 18px;
letter-spacing: -0.02em;
}
.field__input:focus {
border-bottom-color: rgba(96, 165, 250, 0.9);
}
.field__hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
}
.userEditForm {
display: grid;
gap: 18px;
}
.userEditForm .field {
gap: 10px;
}
.modalCard--userEdit {
max-width: 520px;
}
.userCard__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -3003,6 +3350,7 @@ async function saveFeaturedOrder() {
.userCard__actions--compact {
grid-template-columns: auto auto minmax(0, 1fr);
align-items: center;
margin-top: 12px;
}
.roleBadge {
width: fit-content;
@@ -3086,6 +3434,12 @@ async function saveFeaturedOrder() {
font-weight: 900;
font-size: 18px;
}
.templateRequestCard__desc {
margin-top: 6px;
color: rgba(255, 255, 255, 0.74);
line-height: 1.55;
white-space: pre-line;
}
.templateRequestCard__meta {
margin-top: 4px;
font-size: 13px;
@@ -3134,6 +3488,76 @@ async function saveFeaturedOrder() {
justify-content: flex-end;
flex-wrap: wrap;
}
.requestPreview {
display: grid;
gap: 18px;
}
.requestPreview__thumb {
width: 100%;
max-height: 240px;
object-fit: cover;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
}
.requestPreview__desc {
color: rgba(255, 255, 255, 0.74);
line-height: 1.6;
white-space: pre-line;
}
.requestPreview__rows,
.requestPreview__pool {
display: grid;
gap: 12px;
}
.requestPreview__row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.requestPreview__rowLabel,
.requestPreview__poolLabel {
font-size: 13px;
font-weight: 800;
color: rgba(255, 255, 255, 0.86);
}
.requestPreview__rowItems {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 10px;
}
.requestPreview__item {
position: relative;
overflow: hidden;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
min-height: 72px;
}
.requestPreview__item--muted {
opacity: 0.52;
filter: grayscale(0.2) brightness(0.78);
}
.requestPreview__itemThumb {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
display: block;
}
.requestPreview__itemLabel {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
padding: 4px 6px;
border-radius: 8px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0.7));
font-size: 11px;
font-weight: 700;
text-align: center;
line-height: 1.3;
}
.tierAdminList {
margin-top: 14px;
display: grid;

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;
}
@@ -222,8 +222,7 @@ function submitSearch() {
}
.list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 320px));
justify-content: start;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
@@ -233,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 {
@@ -294,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;
@@ -363,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 {
@@ -378,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;
@@ -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

@@ -30,8 +30,15 @@ const description = computed(() =>
: '저장한 티어표와 즐겨찾기, 프로필 설정을 이어서 관리할 수 있어요.'
)
const submitLabel = computed(() => (mode.value === 'signup' ? '가입하기' : '로그인'))
const authReady = computed(() => auth.hydrated)
const checkingSession = computed(() => !authReady.value || auth.status === 'loading')
onMounted(async () => {
if (!auth.hydrated) await auth.refresh()
if (auth.user) {
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
return
}
try {
const meta = await api.authMeta()
hasUsers.value = !!meta.hasUsers
@@ -40,6 +47,15 @@ onMounted(async () => {
}
})
watch(
() => [auth.hydrated, auth.user],
([hydrated, user]) => {
if (!hydrated || !user) return
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
},
{ immediate: true }
)
async function submit() {
error.value = ''
if (mode.value === 'signup' && password.value !== passwordConfirm.value) {
@@ -66,7 +82,11 @@ async function submit() {
</div>
</header>
<section class="authScreen">
<section v-if="checkingSession" class="authScreen authScreen--loading">
<div class="authLoading">로그인 상태를 확인하고 있어요.</div>
</section>
<section v-else class="authScreen">
<div class="authTabs" role="tablist" aria-label="로그인 또는 회원가입">
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'login' }" @click="mode = 'login'">
로그인
@@ -79,8 +99,8 @@ async function submit() {
<form class="authFields" @submit.prevent="submit">
<label class="field">
<span class="field__label">이메일</span>
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" />
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다.</span>
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" maxlength="255" />
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다. {{ email.length }}/255</span>
</label>
<label class="field">
@@ -91,8 +111,9 @@ async function submit() {
type="password"
placeholder="********"
autocomplete="current-password"
maxlength="120"
/>
<span class="field__hint">8 이상으로 설정하면 안전하게 사용할 있어요.</span>
<span class="field__hint">6~120 입력 가능 · {{ password.length }}/120</span>
</label>
<label v-if="mode === 'signup'" class="field">
@@ -103,8 +124,9 @@ async function submit() {
type="password"
placeholder="********"
autocomplete="new-password"
maxlength="120"
/>
<span class="field__hint">같은 비밀번호를 입력해주세요.</span>
<span class="field__hint">같은 비밀번호를 입력해주세요. {{ passwordConfirm.length }}/120</span>
</label>
<div v-if="!hasUsers" class="roleBadge"> 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
@@ -126,14 +148,24 @@ async function submit() {
padding-top: 4px;
}
.authScreen--loading {
min-height: 220px;
align-items: center;
}
.authLoading {
color: var(--theme-text-muted);
font-size: 15px;
}
.authTabs {
display: inline-flex;
gap: 8px;
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 {
@@ -142,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 {
@@ -164,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;
@@ -185,7 +217,7 @@ async function submit() {
.field__hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
}
.roleBadge {
@@ -194,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;
}
@@ -216,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

@@ -9,6 +9,7 @@ const router = useRouter()
const toast = useToast()
const myLists = ref([])
const error = ref('')
const brokenThumbnailIds = ref({})
watch(error, (message) => {
if (!message) return
@@ -37,12 +38,19 @@ function avatarFallbackOf(tierList) {
}
function tierListThumbnailUrl(tierList) {
if (!tierList?.id || brokenThumbnailIds.value[tierList.id]) return ''
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
}
function handleThumbnailError(tierListId) {
if (!tierListId || brokenThumbnailIds.value[tierListId]) return
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
}
onMounted(async () => {
try {
const data = await api.listMyTierLists()
brokenThumbnailIds.value = {}
myLists.value = data.tierLists || []
} catch (e) {
toast.error('로그인이 필요해요.')
@@ -51,53 +59,87 @@ onMounted(async () => {
})
function openList(t) {
router.push(`/editor/${t.gameId}/${t.id}`)
router.push(
"/editor/" + t.gameId + "/" + t.id,
)
}
</script>
<template>
<section class="pageWrap">
<header class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Library</div>
<h2 class="pageHead__title"> 티어표</h2>
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 있어요.</div>
</div>
</header>
<div class="card">
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
<div v-else class="list">
<article v-for="t in myLists" :key="t.id" class="boardCard">
<button class="boardCard__body" @click="openList(t)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
<section class="dashboardHero">
<div class="dashboardHero__left">
<div class="dashboardHero__eyebrow">Library</div>
<h2 class="dashboardHero__title"> 티어표</h2>
<p class="dashboardHero__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 있어요.</p>
</div>
</section>
<section class="panel">
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
<div v-else class="list">
<article v-for="t in myLists" :key="t.id" class="boardCard">
<button class="boardCard__body" @click="openList(t)">
<div class="boardCard__thumbWrap">
<img
v-if="tierListThumbnailUrl(t)"
class="boardCard__thumb"
:src="tierListThumbnailUrl(t)"
alt=""
@error="handleThumbnailError(t.id)"
/>
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ t.title }}</div>
<div class="favoriteStat"> {{ t.favoriteCount || 0 }}</div>
</div>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ t.title }}</div>
<div class="favoriteStat"> {{ t.favoriteCount || 0 }}</div>
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
</div>
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
</div>
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
</div>
</button>
</article>
</div>
</div>
</button>
</article>
</div>
</section>
</template>
<style scoped>
.card {
border: 0;
.dashboardHero {
display: flex;
gap: 18px;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
padding: 6px 2px 18px;
}
.dashboardHero__left {
display: grid;
gap: 8px;
}
.dashboardHero__eyebrow {
font-size: 12px;
color: var(--theme-text-soft);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.dashboardHero__title {
margin: 4px 0 6px;
font-size: 32px;
letter-spacing: -0.04em;
color: var(--theme-text-strong);
}
.dashboardHero__desc {
margin: 0;
color: var(--theme-text-muted);
max-width: 720px;
}
.panel {
background: transparent;
border-radius: 0;
padding: 0;
@@ -107,29 +149,26 @@ function openList(t) {
}
.list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 320px));
justify-content: start;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.boardCard {
display: grid;
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 {
flex: 1 1 auto;
min-width: 0;
text-align: left;
cursor: pointer;
@@ -137,9 +176,12 @@ function openList(t) {
background: transparent;
color: inherit;
padding: 0;
width: 100%;
display: grid;
overflow: hidden;
}
.boardCard__thumbWrap {
min-width: 0;
width: 100%;
aspect-ratio: 16 / 9;
padding: 14px 14px 0;
@@ -155,55 +197,55 @@ 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;
}
.boardCard__title {
flex: 1 1 auto;
font-weight: 800;
min-width: 0;
font-weight: 900;
font-size: 18px;
line-height: 1.3;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
.boardCard__head {
min-width: 0;
padding: 16px 18px 18px;
display: grid;
gap: 8px;
min-width: 0;
overflow: hidden;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 10px;
min-width: 0;
align-items: center;
justify-content: space-between;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.boardCard__titleRow {
align-items: flex-start;
}
.boardCard__metaRow {
align-items: flex-end;
}
.boardCard__author {
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
display: inline-flex;
gap: 7px;
align-items: center;
font-size: 13px;
opacity: 0.84;
opacity: 0.86;
overflow: hidden;
}
.boardCard__authorName {
min-width: 0;
@@ -217,7 +259,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 {
@@ -229,14 +271,28 @@ function openList(t) {
.boardCard__date,
.favoriteStat {
flex: 0 0 auto;
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;
}
.boardCard__date {
font-size: 10px;
}
@media (max-width: 720px) {
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1200px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.list {
grid-template-columns: 1fr;
}

View File

@@ -30,14 +30,19 @@ const avatarUrl = computed(() => {
return toApiUrl(auth.user.avatarSrc)
})
const authReady = computed(() => auth.hydrated)
const displayInitial = computed(() => {
const email = auth.user?.email || 'U'
return email[0].toUpperCase()
})
onMounted(async () => {
await auth.refresh()
if (!auth.user) router.push('/login')
if (!auth.hydrated) await auth.refresh()
if (!auth.user) {
router.replace('/login')
return
}
nickname.value = auth.user?.nickname || ''
removeAvatar.value = false
})
@@ -121,7 +126,11 @@ async function logout() {
</div>
</header>
<section v-if="auth.user" class="settingsScreen">
<section v-if="!authReady" class="settingsScreen settingsScreen--loading">
<div class="settingsLoading">계정 정보를 불러오고 있어요.</div>
</section>
<section v-else-if="auth.user" class="settingsScreen">
<div class="settingsIdentity">
<div class="avatarButtonWrap">
<button class="avatarButton" type="button" @click="openAvatarPicker">
@@ -156,8 +165,8 @@ async function logout() {
<div class="settingsFields">
<label class="field">
<span class="field__label">닉네임</span>
<input v-model="nickname" class="field__input" placeholder="작성자 닉네임" />
<span class="field__hint">티어표 작성자 이름으로 표시됩니다.</span>
<input v-model="nickname" class="field__input" maxlength="40" placeholder="작성자 닉네임" />
<span class="field__hint">티어표 작성자 이름으로 표시됩니다. {{ nickname.length }}/40</span>
</label>
<label class="field">
@@ -185,6 +194,16 @@ async function logout() {
padding-top: 4px;
}
.settingsScreen--loading {
min-height: 240px;
align-items: center;
}
.settingsLoading {
color: var(--theme-text-muted);
font-size: 15px;
}
.settingsIdentity {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
@@ -202,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 {
@@ -222,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 {
@@ -232,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 {
@@ -243,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;
@@ -264,7 +283,7 @@ async function logout() {
.avatarButton__remove:hover {
background: rgba(190, 24, 24, 0.88);
color: #fff;
color: var(--theme-accent-text);
}
.identityMeta {
@@ -276,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 {
@@ -286,7 +305,7 @@ async function logout() {
}
.identityMeta__desc {
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-muted);
line-height: 1.6;
}
@@ -307,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;
@@ -327,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 {
@@ -341,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;
}
@@ -363,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

@@ -3,6 +3,9 @@ import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watc
import { useRoute, useRouter } from 'vue-router'
import Sortable from 'sortablejs'
import * as htmlToImage from 'html-to-image'
import SvgIcon from '../components/SvgIcon.vue'
import addColumnRightIcon from '../assets/icons/add_column_right.svg'
import addRowBelowIcon from '../assets/icons/add_row_below.svg'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth'
@@ -19,11 +22,12 @@ const tierListId = computed(() => route.params.tierListId)
const previewMode = computed(() => route.query.preview === '1')
const gameName = ref('')
const columns = ref([{ id: 'col-1', name: '' }])
const groups = ref([
{ id: 'gS', name: 'S', itemIds: [] },
{ id: 'gA', name: 'A', itemIds: [] },
{ id: 'gB', name: 'B', itemIds: [] },
{ id: 'gC', name: 'C', itemIds: [] },
{ id: 'gS', name: 'S', itemIds: [], cells: [[]] },
{ id: 'gA', name: 'A', itemIds: [], cells: [[]] },
{ id: 'gB', name: 'B', itemIds: [], cells: [[]] },
{ id: 'gC', name: 'C', itemIds: [], cells: [[]] },
])
const pool = ref([])
@@ -44,9 +48,12 @@ 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)
const pendingRemoveGroupId = ref('')
const pendingRemoveColumnIndex = ref(-1)
const ownerId = ref('')
const authorName = ref('')
const authorAccountName = ref('')
@@ -119,14 +126,7 @@ const canRequestTemplateCreate = computed(
const canRequestTemplateUpdate = computed(
() => canEdit.value && !isNewTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
)
const templateRequestChecks = computed(() => [
{
id: 'title',
label: '티어표 이름(게임 이름)을 직접 입력했는지',
passed: !!(title.value || '').trim() && (title.value || '').trim() !== (gameName.value || '').trim(),
},
])
const canSubmitTemplateCreateRequest = computed(() => templateRequestChecks.value.every((item) => item.passed) && !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 게임')))
@@ -160,38 +160,107 @@ function setIconSize(nextSize) {
iconSize.value = nextSize
}
function removeItemFromGroup(groupId, itemId) {
if (!canEdit.value || !groupId || !itemId) return
function getGroupCellIds(group, columnIndex) {
return Array.isArray(group?.cells?.[columnIndex]) ? group.cells[columnIndex] : []
}
function syncGroupItemIds(group) {
group.itemIds = (group.cells || []).flat()
}
function normalizeLoadedColumns(rawGroups) {
const fromGroup = Array.isArray(rawGroups) ? rawGroups.find((group) => Array.isArray(group?.columnNames) && group.columnNames.length) : null
const rawColumns = Array.isArray(fromGroup?.columnNames) ? fromGroup.columnNames : []
const cellCount = Math.max(1, ...(Array.isArray(rawGroups) ? rawGroups.map((group) => (Array.isArray(group?.cells) ? group.cells.length : 0)) : [0]))
const size = Math.max(rawColumns.length || 0, cellCount)
return Array.from({ length: size || 1 }, (_, index) => ({
id: rawColumns[index]?.id || `col-${index + 1}` ,
name: typeof rawColumns[index]?.name === 'string' ? rawColumns[index].name.slice(0, 16) : '',
}))
}
function normalizeLoadedGroups(rawGroups, nextColumns = columns.value) {
if (!Array.isArray(rawGroups) || !rawGroups.length) {
return [
{ id: 'gS', name: 'S', itemIds: [], cells: nextColumns.map(() => []) },
{ id: 'gA', name: 'A', itemIds: [], cells: nextColumns.map(() => []) },
{ id: 'gB', name: 'B', itemIds: [], cells: nextColumns.map(() => []) },
{ id: 'gC', name: 'C', itemIds: [], cells: nextColumns.map(() => []) },
]
}
return rawGroups.map((group, index) => {
const cells = Array.from({ length: nextColumns.length }, (_, cellIndex) => {
if (Array.isArray(group?.cells?.[cellIndex])) return [...group.cells[cellIndex]]
if (cellIndex === 0 && Array.isArray(group?.itemIds)) return [...group.itemIds]
return []
})
return {
id: typeof group?.id === 'string' && group.id ? group.id : `g-${index + 1}` ,
name: typeof group?.name === 'string' && group.name ? group.name.slice(0, 16) : 'Tier',
itemIds: cells.flat(),
cells,
}
})
}
function buildGroupPayload() {
return groups.value.map((group) => ({
id: group.id,
name: group.name,
itemIds: (group.cells || []).flat(),
cells: (group.cells || []).map((cell) => [...cell]),
columnNames: columns.value.map((column) => ({ id: column.id, name: column.name || '' })),
}))
}
function removeItemFromGroup(groupId, columnIndex, itemId) {
if (!canEdit.value || !groupId || columnIndex == null || !itemId) return
const targetGroup = groups.value.find((group) => group.id === groupId)
if (!targetGroup) return
if (!targetGroup.itemIds.includes(itemId)) return
targetGroup.itemIds = targetGroup.itemIds.filter((id) => id !== itemId)
const nextCells = [...targetGroup.cells]
nextCells[columnIndex] = getGroupCellIds(targetGroup, columnIndex).filter((id) => id !== itemId)
targetGroup.cells = nextCells
syncGroupItemIds(targetGroup)
pool.value = [itemId, ...pool.value.filter((id) => id !== itemId)]
}
function setGroupDropEl(groupId, el) {
function setGroupDropEl(groupId, columnIndex, el) {
const key = `${groupId}::${columnIndex}`
if (!el) {
delete groupDropEls.value[groupId]
delete groupDropEls.value[key]
return
}
groupDropEls.value[groupId] = el
groupDropEls.value[key] = el
}
function getListByContainer(containerEl) {
if (!containerEl) return { type: null, groupId: null }
if (!containerEl) return { type: null, groupId: null, columnIndex: null }
const t = containerEl.getAttribute('data-list-type')
if (t === 'pool') return { type: 'pool', groupId: null }
if (t === 'group') return { type: 'group', groupId: containerEl.getAttribute('data-group-id') }
return { type: null, groupId: null }
if (t === 'pool') return { type: 'pool', groupId: null, columnIndex: null }
if (t === 'group') {
return {
type: 'group',
groupId: containerEl.getAttribute('data-group-id'),
columnIndex: Number(containerEl.getAttribute('data-column-index')),
}
}
return { type: null, groupId: null, columnIndex: null }
}
function normalizeSort(containerEl) {
const ids = Array.from(containerEl.querySelectorAll('[data-item-id]')).map((n) => n.getAttribute('data-item-id'))
const meta = getListByContainer(containerEl)
if (meta.type === 'pool') pool.value = ids
if (meta.type === 'pool') {
pool.value = ids
return
}
if (meta.type === 'group') {
const g = groups.value.find((x) => x.id === meta.groupId)
if (g) g.itemIds = ids
if (!g || !Number.isInteger(meta.columnIndex)) return
const nextCells = [...g.cells]
nextCells[meta.columnIndex] = ids
g.cells = nextCells
syncGroupItemIds(g)
}
}
@@ -230,7 +299,7 @@ async function initSortables() {
onAdd: () => normalizeSort(poolEl.value),
})
dropSortables.value = Object.entries(groupDropEls.value).map(([gid, el]) =>
dropSortables.value = Object.entries(groupDropEls.value).map(([, el]) =>
Sortable.create(el, {
group: 'tier-items',
animation: 160,
@@ -263,32 +332,96 @@ async function syncSortables() {
}
}
function createGroupName() {
function createGroupName(index = groups.value.length) {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
const index = groups.value.length
if (index < alphabet.length) return alphabet[index]
return `Tier ${index + 1}`
}
function createColumnName(index = columns.value.length) {
return `${index + 1}`
}
function createCustomItemLabel(fileName = '') {
const normalized = String(fileName || '')
.replace(/\.[^.]+$/, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
return (normalized || 'custom').slice(0, 60)
}
async function addGroup() {
groups.value = [
...groups.value,
{
id: `g-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
id: `g-${Date.now()}-${Math.random().toString(16).slice(2, 8)}` ,
name: createGroupName(),
itemIds: [],
cells: columns.value.map(() => []),
},
]
await syncSortables()
}
async function addColumn() {
columns.value = [
...columns.value,
{ id: `col-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, name: createColumnName() },
]
groups.value = groups.value.map((group) => ({
...group,
cells: [...group.cells, []],
itemIds: [...group.itemIds],
}))
await syncSortables()
}
async function removeColumn(columnIndex) {
if (!canEdit.value || columns.value.length <= 1) return
const nextColumns = columns.value.filter((_, index) => index !== columnIndex)
groups.value = groups.value.map((group) => {
const nextCells = group.cells.filter((_, index) => index !== columnIndex)
const removed = Array.isArray(group.cells[columnIndex]) ? group.cells[columnIndex] : []
if (nextCells[0] && removed.length) nextCells[0] = [...removed, ...nextCells[0]]
const nextGroup = { ...group, cells: nextCells }
syncGroupItemIds(nextGroup)
return nextGroup
})
Object.keys(groupDropEls.value).forEach((key) => {
if (key.endsWith(`::${columnIndex}`)) delete groupDropEls.value[key]
})
columns.value = nextColumns
await syncSortables()
}
function openColumnDeleteModal(columnIndex) {
if (!canEdit.value || columns.value.length <= 1) return
pendingRemoveColumnIndex.value = columnIndex
isColumnDeleteModalOpen.value = true
}
function closeColumnDeleteModal() {
isColumnDeleteModalOpen.value = false
pendingRemoveColumnIndex.value = -1
}
async function confirmRemoveColumn() {
const columnIndex = pendingRemoveColumnIndex.value
closeColumnDeleteModal()
if (columnIndex < 0) return
await removeColumn(columnIndex)
}
async function performRemoveGroup(groupId) {
if (groups.value.length <= 1) return
const target = groups.value.find((group) => group.id === groupId)
if (!target) return
pool.value = [...target.itemIds, ...pool.value]
groups.value = groups.value.filter((group) => group.id !== groupId)
delete groupDropEls.value[groupId]
Object.keys(groupDropEls.value).forEach((key) => {
if (key.startsWith(`${groupId}::`)) delete groupDropEls.value[key]
})
await syncSortables()
}
@@ -316,7 +449,7 @@ function addCustomImage(file) {
const id = `c-${Date.now()}-${Math.random().toString(16).slice(2)}`
itemsById.value = {
...itemsById.value,
[id]: { id, src: url, label: file.name || 'custom', origin: 'custom', pendingFile: file },
[id]: { id, src: url, label: createCustomItemLabel(file.name), origin: 'custom', pendingFile: file },
}
pool.value = [id, ...pool.value]
}
@@ -450,7 +583,7 @@ async function uploadPendingCustomItems() {
for (const item of entries) {
const fd = new FormData()
fd.append('label', item.label || 'custom')
fd.append('label', createCustomItemLabel(item.label || 'custom'))
fd.append('image', item.pendingFile)
const res = await fetch(toApiUrl('/api/tierlists/custom-items'), {
@@ -475,10 +608,14 @@ async function uploadPendingCustomItems() {
}
itemsById.value = nextItemsById
pool.value = pool.value.map((currentId) => (currentId === item.id ? uploaded.id : currentId))
groups.value = groups.value.map((group) => ({
...group,
itemIds: group.itemIds.map((currentId) => (currentId === item.id ? uploaded.id : currentId)),
}))
groups.value = groups.value.map((group) => {
const nextGroup = {
...group,
cells: group.cells.map((cell) => cell.map((currentId) => (currentId === item.id ? uploaded.id : currentId))),
}
syncGroupItemIds(nextGroup)
return nextGroup
})
}
}
@@ -507,7 +644,7 @@ function buildPayload(existingId) {
sourceTierListId: sourceTierListId.value || '',
sourceSnapshotTitle: sourceSnapshotTitle.value || '',
sourceSnapshotAuthor: sourceSnapshotAuthor.value || '',
groups: groups.value.map((g) => ({ id: g.id, name: g.name, itemIds: g.itemIds })),
groups: buildGroupPayload(),
pool: Object.values(itemsById.value),
}
}
@@ -547,8 +684,9 @@ function closeSaveModal() {
}
function resetTemplateRequestDrafts() {
templateRequestDraftTitle.value = ''
templateRequestDraftDescription.value = ''
templateRequestDraftTitle.value = (title.value || '').trim()
templateRequestDraftDescription.value = (description.value || '').trim()
templateRequestSaveToMyTierList.value = true
}
function openTemplateRequestModal() {
@@ -624,25 +762,52 @@ async function toggleFavorite() {
}
async function requestTemplate(type) {
if (isNewTierList.value) {
toast.error('요청 전에 먼저 티어표를 저장해주세요.')
return
}
try {
isRequestingTemplate.value = true
const persisted = await persistTierList({ showModal: false })
await api.requestTierListTemplate(persisted.savedTierListId, {
await uploadPendingCustomItems()
const uploadedThumbnailSrc = await uploadPendingThumbnail()
const response = await api.requestTierListTemplate({
type,
sourceTierListId: tierListId.value !== 'new' ? tierListId.value : '',
gameId: gameId.value,
requestTitle: templateRequestDraftTitle.value.trim(),
requestDescription: templateRequestDraftDescription.value.trim(),
thumbnailSrc: uploadedThumbnailSrc || 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' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
toast.success(
type === 'create'
? templateRequestSaveToMyTierList.value
? '템플릿 등록 요청과 내 티어표 저장을 함께 완료했어요.'
: '템플릿 등록 요청을 보냈어요.'
: templateRequestSaveToMyTierList.value
? '템플릿 업데이트 요청과 내 티어표 저장을 함께 완료했어요.'
: '템플릿 업데이트 요청을 보냈어요.'
)
} catch (e) {
if (e?.status === 400 && e?.data?.error === 'title_required') {
toast.error('템플릿 등록 요청 전에는 티어표 이름을 직접 입력해주세요.')
if (e?.message === 'custom_upload_failed') {
toast.error('커스텀 이미지 이름이 너무 길거나 업로드 조건에 맞지 않아 요청 전에 저장하지 못했어요. 아이템 이름을 60자 이하로 줄인 뒤 다시 시도해주세요.')
return
}
if (e?.status === 409) {
@@ -653,6 +818,10 @@ async function requestTemplate(type) {
toast.error('먼저 커스텀 아이템을 추가한 뒤 요청해주세요.')
return
}
if (e?.status === 400 && e?.data?.error === 'bad_request') {
toast.error('요청 제목, 설명, 아이템 이름 중 길이 제한을 넘긴 값이 없는지 확인해주세요.')
return
}
toast.error(type === 'create' ? '템플릿 등록 요청에 실패했어요.' : '템플릿 업데이트 요청에 실패했어요.')
} finally {
isRequestingTemplate.value = false
@@ -705,12 +874,13 @@ onMounted(() => {
sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || ''
favoriteCount.value = Number(t.favoriteCount || 0)
isFavorited.value = !!t.isFavorited
groups.value = t.groups
columns.value = normalizeLoadedColumns(t.groups)
groups.value = normalizeLoadedGroups(t.groups, columns.value)
const map = {}
;(t.pool || []).forEach((it) => (map[it.id] = it))
itemsById.value = map
const grouped = new Set()
groups.value.forEach((g) => g.itemIds.forEach((id) => grouped.add(id)))
groups.value.forEach((group) => group.itemIds.forEach((id) => grouped.add(id)))
pool.value = Object.keys(itemsById.value).filter((id) => !grouped.has(id))
} catch (e) {
error.value = '티어표를 불러오지 못했어요.'
@@ -735,13 +905,23 @@ onUnmounted(() => {
<div class="previewOnly__sheet">
<div class="previewOnly__title">{{ effectiveTitle }}</div>
<div v-if="description" class="previewOnly__description">{{ description }}</div>
<div v-if="columns.length > 1" class="previewOnly__columns">
<div class="previewOnly__columnsSpacer" aria-hidden="true"></div>
<div class="previewOnly__columnsGrid" :style="{ '--column-count': columns.length }">
<div v-for="(column, columnIndex) in columns" :key="column.id" class="previewOnly__columnHeader">{{ column.name || ' ' + (columnIndex + 1) }}</div>
</div>
</div>
<div class="previewOnly__rows">
<div v-for="g in groups" :key="g.id" class="previewOnly__row">
<div class="previewOnly__label">{{ g.name }}</div>
<div class="previewOnly__drop">
<div v-for="id in g.itemIds" :key="id" class="previewOnly__cell">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
<div class="previewOnly__dropGrid" :style="{ '--column-count': columns.length }">
<div v-for="(column, columnIndex) in columns" :key="column.id" class="previewOnly__dropColumn">
<div class="previewOnly__drop">
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="previewOnly__cell">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
</div>
</div>
</div>
</div>
</div>
@@ -773,18 +953,7 @@ onUnmounted(() => {
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="templateRequestTitle">
<div id="templateRequestTitle" class="modalCard__title">템플릿 등록 요청</div>
<div class="modalCard__desc">
여러 사용자가 비슷한 주제로 요청할 있으니, 관리자에게 전달되기 전에 아래 조건을 먼저 확인해주세.
</div>
<div class="requestChecklist">
<div
v-for="check in templateRequestChecks"
:key="check.id"
class="requestChecklist__item"
:class="{ 'requestChecklist__item--passed': check.passed }"
>
<span class="requestChecklist__label">{{ check.label }}</span>
<span class="requestChecklist__icon">{{ check.passed ? '완료' : '확인 필요' }}</span>
</div>
티어표에 적어둔 제목과 설명이 있다면 그대로 가져와 두었어요. 비어 있다면 여기서 바로 작성해도 괜찮아.
</div>
<div class="requestChecklist__hint">
제목과 설명을 함께 적어두면 관리자가 어떤 신규 템플릿인지 훨씬 빠르게 파악할 있어요.
@@ -792,13 +961,23 @@ onUnmounted(() => {
</div>
<div class="templateRequestDraft">
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 제목</span>
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 등록 요청" />
<span class="templateRequestDraft__label">티어표 제목</span>
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="120" placeholder="예: 템플릿 등록 요청" />
<span class="templateRequestDraft__hint">{{ templateRequestDraftTitle.length }}/120</span>
</label>
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 설명</span>
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
<span class="templateRequestDraft__label">티어표 설명</span>
<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>
@@ -813,7 +992,7 @@ onUnmounted(() => {
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="templateUpdateTitle">
<div id="templateUpdateTitle" class="modalCard__title">템플릿 요청하기</div>
<div class="modalCard__desc">
{{ templateRequestTargetLabel }} 직접 추가한 아이템을 포함해 달라고 관리자에게 요청 보냅니다.
{{ templateRequestTargetLabel }} 직접 추가한 아이템을 포함해 달라고 관리자에게 요청합니다. 현재 티어표 제목과 설명은 그대로 가져와 두었어요.
</div>
<div class="modalCard__note">
모두가 사용하는 기본 템플릿이니 개인적인 항목이 아닌 공통된 항목만 추가한 신청해주세요.
@@ -821,13 +1000,23 @@ onUnmounted(() => {
</div>
<div class="templateRequestDraft">
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 제목</span>
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 업데이트 요청" />
<span class="templateRequestDraft__label">티어표 제목</span>
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="120" placeholder="예: 템플릿 업데이트 요청" />
<span class="templateRequestDraft__hint">{{ templateRequestDraftTitle.length }}/120</span>
</label>
<label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 설명</span>
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
<span class="templateRequestDraft__label">티어표 설명</span>
<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>
@@ -855,13 +1044,26 @@ onUnmounted(() => {
<div v-if="isGroupDeleteModalOpen" class="modalOverlay" @click.self="closeGroupDeleteModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteGroupTitle">
<div id="deleteGroupTitle" class="modalCard__title">티어 라인 삭제</div>
<div id="deleteGroupTitle" class="modalCard__title">티어 삭제</div>
<div class="modalCard__desc">
라인 삭제하면 현재 들어 있는 아이템은 모두 아래 아이템 영역으로 이동합니다. 삭제 후에도 아이템 자체는 유지돼요.
삭제하면 현재 들어 있는 아이템은 모두 아래 아이템 영역으로 이동합니다. 삭제 후에도 아이템 자체는 유지돼요.
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeGroupDeleteModal">취소</button>
<button class="btn btn--danger" @click="confirmRemoveGroup">라인 삭제</button>
<button class="btn btn--danger" @click="confirmRemoveGroup"> 삭제</button>
</div>
</div>
</div>
<div v-if="isColumnDeleteModalOpen" class="modalOverlay" @click.self="closeColumnDeleteModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteColumnTitle">
<div id="deleteColumnTitle" class="modalCard__title">티어 삭제</div>
<div class="modalCard__desc">
열을 삭제하면 현재 들어 있는 아이템은 모두 번째 열로 이동합니다. 삭제 후에도 아이템 자체는 유지돼요.
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeColumnDeleteModal">취소</button>
<button class="btn btn--danger" @click="confirmRemoveColumn"> 삭제</button>
</div>
</div>
</div>
@@ -873,7 +1075,7 @@ onUnmounted(() => {
<div class="editorMain__title">{{ gameName || gameId }}</div>
<div class="editorMain__subtitle">
<template v-if="canEdit">
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다.
/ 이름 순서 바꾸고 아이템 드래그해서 배치할 있어요.
</template>
<template v-else>
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 있어요.
@@ -890,7 +1092,12 @@ onUnmounted(() => {
<div ref="boardEl" class="board">
<div v-if="canEdit && !isExporting" class="boardTools">
<div class="boardTools__left">
<button class="btn btn--ghost" @click="addGroup">티어 추가</button>
<button class="boardActionIcon" type="button" title="행 추가" aria-label=" 추가" @click="addGroup">
<SvgIcon :src="addRowBelowIcon" :size="18" />
</button>
<button class="boardActionIcon" type="button" title="열 추가" aria-label=" 추가" @click="addColumn">
<SvgIcon :src="addColumnRightIcon" :size="18" />
</button>
</div>
<div class="boardTools__right">
<span class="boardTools__label">아이콘 크기</span>
@@ -910,6 +1117,22 @@ onUnmounted(() => {
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
<div v-if="isExporting" class="exportBoard__title">{{ effectiveTitle }}</div>
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
<div v-if="columns.length > 1" class="boardColumnsHeader" :class="{ 'boardColumnsHeader--export': isExporting }">
<div class="boardColumnsHeader__spacer" aria-hidden="true"></div>
<div class="boardColumnsHeader__grid" :style="{ '--column-count': columns.length }">
<div v-for="(column, columnIndex) in columns" :key="column.id" class="boardColumnsHeader__cell">
<template v-if="isExporting">
<div class="boardColumnsHeader__name">{{ column.name || '열 ' + (columnIndex + 1) }}</div>
</template>
<template v-else>
<div class="columnHeader">
<input v-model="column.name" class="columnName" maxlength="16" placeholder="열 이름" />
<button class="columnRemoveText" type="button" title="열 삭제" aria-label=" 삭제" :disabled="columns.length <= 1" @click="openColumnDeleteModal(columnIndex)">×</button>
</div>
</template>
</div>
</div>
</div>
<div ref="groupListEl" class="rows">
<div v-for="g in groups" :key="g.id" class="row">
<div class="row__label">
@@ -917,40 +1140,46 @@ onUnmounted(() => {
<div class="row__exportName">{{ g.name }}</div>
</template>
<template v-else>
<span class="grab" title="드래그로 순서 변경" data-group-handle></span>
<input v-model="g.name" class="groupName" maxlength="16" :readonly="!canEdit" />
<button
v-if="canEdit"
class="rowRemoveText"
type="button"
title="티어 라인 삭제"
title=" 삭제"
aria-label=" 삭제"
:disabled="groups.length <= 1"
@click="openGroupDeleteModal(g.id)"
>
삭제
</button>
<span class="grab" title="드래그로 순서 변경" data-group-handle></span>
<input v-model="g.name" class="groupName" :readonly="!canEdit" />
</template>
</div>
<div
class="row__drop"
:data-list-type="'group'"
:data-group-id="g.id"
:ref="(el) => setGroupDropEl(g.id, el)"
>
<div v-if="!isExporting" class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
<div v-for="id in g.itemIds" :key="id" class="cell" :data-item-id="id">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
<button
v-if="canEdit && !isExporting"
class="cellRemoveBtn"
type="button"
title="아이템 빼내기"
@pointerdown.stop
@click.stop="removeItemFromGroup(g.id, id)"
>
×
</button>
</template>
</div>
<div class="row__content" :style="{ '--column-count': columns.length }">
<div v-for="(column, columnIndex) in columns" :key="column.id" class="row__column">
<div
class="row__drop"
:data-list-type="'group'"
:data-group-id="g.id"
:data-column-index="columnIndex"
:ref="(el) => setGroupDropEl(g.id, columnIndex, el)"
>
<div v-if="!isExporting" class="row__empty" v-show="getGroupCellIds(g, columnIndex).length === 0">여기로 드래그해서 배치</div>
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="cell" :data-item-id="id">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
<button
v-if="canEdit && !isExporting"
class="cellRemoveBtn"
type="button"
title="아이템 빼내기"
@pointerdown.stop
@click.stop="removeItemFromGroup(g.id, columnIndex, id)"
>
×
</button>
</div>
</div>
</div>
</div>
</div>
@@ -1004,7 +1233,8 @@ onUnmounted(() => {
<template v-if="globalRightRailOpen">
<div class="editorSidebar__section">
<div class="editorSidebar__label">Title</div>
<input v-model="title" class="editorSidebar__input" placeholder="Title Text" :readonly="!canEdit" />
<input v-model="title" class="editorSidebar__input" maxlength="120" placeholder="Title Text" :readonly="!canEdit" />
<div class="editorSidebar__hint">{{ title.length }}/120</div>
<div v-if="untitledWarning" class="editorSidebar__hint editorSidebar__hint--warn">{{ untitledWarning }}</div>
</div>
@@ -1014,8 +1244,10 @@ onUnmounted(() => {
v-model="description"
class="editorSidebar__textarea"
placeholder="Description Text"
maxlength="1000"
:readonly="!canEdit"
></textarea>
<div class="editorSidebar__hint">{{ description.length }}/1000</div>
</div>
<div class="editorSidebar__section">
@@ -1176,15 +1408,42 @@ onUnmounted(() => {
line-height: 1.6;
opacity: 0.76;
}
.previewOnly__columns {
display: grid;
grid-template-columns: 132px 1fr;
gap: 10px;
margin-bottom: 10px;
}
.previewOnly__columnsGrid {
display: grid;
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
gap: 10px;
}
.previewOnly__columnHeader {
min-height: 20px;
font-size: 12px;
font-weight: 800;
text-align: center;
opacity: 0.72;
}
.previewOnly__rows {
display: grid;
gap: 10px;
}
.previewOnly__row {
display: grid;
grid-template-columns: 180px 1fr;
grid-template-columns: 132px 1fr;
gap: 10px;
}
.previewOnly__dropGrid {
display: grid;
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
gap: 10px;
}
.previewOnly__dropColumn {
display: grid;
gap: 8px;
}
.previewOnly__label {
display: grid;
place-items: center;
@@ -1438,6 +1697,15 @@ onUnmounted(() => {
font-size: 12px;
color: rgba(255, 255, 255, 0.64);
}
.templateRequestDraft__hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.46);
}
.templateRequestDraft__note {
font-size: 12px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.6);
}
.templateRequestDraft__input {
width: 100%;
padding: 14px 0;
@@ -1483,6 +1751,51 @@ onUnmounted(() => {
.boardTools__left {
margin-right: auto;
}
.boardActionIcon {
width: 40px;
height: 40px;
display: inline-flex;
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);
cursor: pointer;
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
}
.boardActionIcon:hover {
background: rgba(96, 165, 250, 0.12);
border-color: rgba(96, 165, 250, 0.28);
color: rgba(255, 255, 255, 0.98);
}
.boardColumnsHeader {
display: grid;
grid-template-columns: 132px 1fr;
gap: 10px;
margin-bottom: 10px;
}
.boardColumnsHeader__grid {
display: grid;
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
gap: 10px;
}
.boardColumnsHeader__cell {
min-width: 0;
position: relative;
}
.boardColumnsHeader__name {
min-height: 38px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 12px;
text-align: center;
font-size: 12px;
line-height: 1.2;
font-weight: 800;
opacity: 0.74;
}
.boardTools__label {
font-size: 13px;
opacity: 0.76;
@@ -1549,7 +1862,7 @@ onUnmounted(() => {
}
.row {
display: grid;
grid-template-columns: 180px 1fr;
grid-template-columns: 132px 1fr;
gap: 10px;
align-items: stretch;
}
@@ -1559,24 +1872,86 @@ onUnmounted(() => {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
padding: 10px 12px 30px;
padding: 14px 28px;
font-weight: 900;
overflow: hidden;
}
.grab {
cursor: grab;
opacity: 0.85;
width: 26px;
height: 26px;
.row__content {
display: grid;
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
gap: 10px;
}
.row__column {
display: grid;
gap: 8px;
min-width: 0;
}
.columnHeader {
position: relative;
display: flex;
align-items: center;
min-height: 38px;
padding: 0 28px;
}
.columnName {
width: 100%;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
background: transparent;
color: rgba(255, 255, 255, 0.88);
padding: 4px 0;
text-align: center;
font-size: 12px;
line-height: 1.2;
font-weight: 800;
outline: none;
}
.columnName::placeholder {
color: rgba(255, 255, 255, 0.34);
}
.columnRemoveText {
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
width: 20px;
height: 20px;
display: grid;
place-items: center;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.56);
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);
}
.columnRemoveText:disabled {
opacity: 0.32;
cursor: not-allowed;
}
.grab {
position: absolute;
top: 10px;
left: 10px;
cursor: grab;
opacity: 0.72;
width: 22px;
height: 22px;
display: grid;
place-items: center;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.16);
flex: 0 0 auto;
font-size: 12px;
}
.groupName {
width: 100%;
@@ -1584,7 +1959,7 @@ onUnmounted(() => {
background: rgba(0, 0, 0, 0.18);
color: rgba(255, 255, 255, 0.92);
border-radius: 10px;
padding: 6px 8px;
padding: 8px 10px;
font-weight: 900;
text-align: center;
outline: none;
@@ -1592,19 +1967,25 @@ onUnmounted(() => {
}
.rowRemoveText {
position: absolute;
right: 12px;
bottom: 10px;
top: 10px;
right: 10px;
width: 20px;
height: 20px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
font-size: 12px;
font-size: 16px;
line-height: 1;
font-weight: 800;
}
.rowRemoveText:hover {
color: rgba(255, 255, 255, 0.9);
color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.06);
}
.rowRemoveText:disabled {
opacity: 0.32;
@@ -1646,17 +2027,21 @@ onUnmounted(() => {
.itemNameOverlay {
position: absolute;
inset: auto 0 0 0;
padding: 16px 8px 6px;
min-height: 26px;
padding: 18px 8px 6px;
border-radius: 0 0 10px 10px;
background: linear-gradient(180deg, rgba(7, 10, 18, 0), rgba(7, 10, 18, 0.92));
color: rgba(255, 255, 255, 0.96);
font-size: 11px;
line-height: 1.25;
line-height: 1.2;
font-weight: 800;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: flex-end;
justify-content: center;
pointer-events: none;
}
.cellRemoveBtn {
@@ -2000,6 +2385,9 @@ onUnmounted(() => {
.editorCanvas {
grid-template-columns: 1fr;
}
.row__content {
grid-template-columns: 1fr;
}
.row {
grid-template-columns: 150px 1fr;
}
@@ -2031,7 +2419,18 @@ onUnmounted(() => {
.previewOnly {
padding: 14px;
}
.previewOnly__row {
.previewOnly__columns,
.previewOnly__row,
.boardColumnsHeader,
.row {
grid-template-columns: 1fr;
}
.previewOnly__columnsSpacer,
.boardColumnsHeader__spacer {
display: none;
}
.previewOnly__dropGrid,
.boardColumnsHeader__grid {
grid-template-columns: 1fr;
}
.pool {