Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a64dc44c8 | |||
| 91e16ba415 | |||
| a550385ed8 | |||
| 5b53c73b56 | |||
| 7952f2f289 | |||
| b851100c89 | |||
| e70e685a06 | |||
| 09acebc2d5 | |||
| e3391b5f07 | |||
| 22220494d6 | |||
| 909ed72502 | |||
| c352bf459f | |||
| 730a87b923 | |||
| e9a049241d | |||
| 0fec84de13 | |||
| 7fe4eff7b7 | |||
| b2a838ff34 | |||
| 695c0bd4dd |
@@ -154,7 +154,7 @@ function mapTemplateRequestRow(row) {
|
|||||||
requesterName: getUserDisplayName(row),
|
requesterName: getUserDisplayName(row),
|
||||||
requesterAccountName: getUserAccountName(row),
|
requesterAccountName: getUserAccountName(row),
|
||||||
requesterAvatarSrc: row.requester_avatar_src || '',
|
requesterAvatarSrc: row.requester_avatar_src || '',
|
||||||
sourceTierListId: row.source_tierlist_id,
|
sourceTierListId: row.source_tierlist_id || '',
|
||||||
sourceGameId: row.source_game_id,
|
sourceGameId: row.source_game_id,
|
||||||
sourceGameName: row.source_game_name || '',
|
sourceGameName: row.source_game_name || '',
|
||||||
sourceTierListTitle: row.title_snapshot || '',
|
sourceTierListTitle: row.title_snapshot || '',
|
||||||
@@ -164,6 +164,9 @@ function mapTemplateRequestRow(row) {
|
|||||||
targetGameName: row.target_game_name || '',
|
targetGameName: row.target_game_name || '',
|
||||||
status: row.status,
|
status: row.status,
|
||||||
items: parseJson(row.items_json, []),
|
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),
|
createdAt: Number(row.created_at),
|
||||||
updatedAt: Number(row.updated_at),
|
updatedAt: Number(row.updated_at),
|
||||||
}
|
}
|
||||||
@@ -389,6 +392,23 @@ async function ensureSchema() {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) 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'")
|
const tierListThumbnailColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'thumbnail_src'")
|
||||||
if (!tierListThumbnailColumns.length) {
|
if (!tierListThumbnailColumns.length) {
|
||||||
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
|
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)
|
return findUserById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listUsers() {
|
async function findPrimaryAdminUser() {
|
||||||
const rows = await query(`
|
const rows = await query(
|
||||||
SELECT
|
'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'
|
||||||
u.id,
|
)
|
||||||
u.email,
|
return mapUserRow(rows[0])
|
||||||
u.nickname,
|
}
|
||||||
u.is_admin,
|
|
||||||
u.avatar_src,
|
async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' } = {}) {
|
||||||
u.created_at,
|
const where = []
|
||||||
COUNT(t.id) AS tierlist_count,
|
const params = []
|
||||||
GREATEST(
|
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,
|
u.created_at,
|
||||||
COALESCE(MAX(t.updated_at), 0)
|
COUNT(t.id) AS tierlist_count,
|
||||||
) AS recent_activity_at
|
GREATEST(
|
||||||
FROM users u
|
u.created_at,
|
||||||
LEFT JOIN tierlists t ON t.author_id = u.id
|
COALESCE(MAX(t.updated_at), 0)
|
||||||
GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at
|
) AS recent_activity_at
|
||||||
ORDER BY recent_activity_at DESC, u.created_at ASC, u.email ASC
|
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)
|
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 game_items WHERE src <> ''"),
|
||||||
query("SELECT src FROM custom_items WHERE src <> ''"),
|
query("SELECT src FROM custom_items WHERE src <> ''"),
|
||||||
query("SELECT thumbnail_src, pool_json FROM tierlists"),
|
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)
|
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) {
|
for (const row of templateRequestRows) {
|
||||||
if (row.thumbnail_src_snapshot) referencedSrcs.add(row.thumbnail_src_snapshot)
|
if (row.thumbnail_src_snapshot) referencedSrcs.add(row.thumbnail_src_snapshot)
|
||||||
collectUploadSrcsFromItems(parseJson(row.items_json, []), referencedSrcs)
|
collectUploadSrcsFromItems(parseJson(row.items_json, []), referencedSrcs)
|
||||||
|
collectUploadSrcsFromItems(parseJson(row.board_items_json, []), referencedSrcs)
|
||||||
}
|
}
|
||||||
|
|
||||||
return assets.filter((asset) => !referencedSrcs.has(asset.src))
|
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 game_items WHERE src <> ''"),
|
||||||
query("SELECT src FROM custom_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, 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')
|
for (const row of userRows) addUsage(row.avatar_src, 'avatar')
|
||||||
@@ -788,6 +843,7 @@ async function listReferencedUploadUsage() {
|
|||||||
for (const row of templateRequestRows) {
|
for (const row of templateRequestRows) {
|
||||||
addUsage(row.thumbnail_src_snapshot, 'template-thumbnail')
|
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.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())
|
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) {
|
for (const row of requestRows) {
|
||||||
let nextThumbnail = row.thumbnail_src_snapshot
|
let nextThumbnail = row.thumbnail_src_snapshot
|
||||||
let changed = false
|
let changed = false
|
||||||
@@ -850,12 +906,14 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const replacedItems = replaceItemSrc(parseJson(row.items_json, []), 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) {
|
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 || '',
|
nextThumbnail || '',
|
||||||
serializeJson(replacedItems.items),
|
serializeJson(replacedItems.items),
|
||||||
|
serializeJson(replacedBoardItems.items),
|
||||||
now(),
|
now(),
|
||||||
row.id,
|
row.id,
|
||||||
])
|
])
|
||||||
@@ -1602,19 +1660,24 @@ async function createTemplateRequest({
|
|||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
requesterId,
|
requesterId,
|
||||||
sourceTierListId,
|
sourceTierListId = '',
|
||||||
sourceGameId,
|
sourceGameId,
|
||||||
targetGameId = '',
|
targetGameId = '',
|
||||||
title,
|
title,
|
||||||
description = '',
|
description = '',
|
||||||
thumbnailSrc = '',
|
thumbnailSrc = '',
|
||||||
items = [],
|
items = [],
|
||||||
|
groups = [],
|
||||||
|
boardItems = [],
|
||||||
|
showCharacterNames = false,
|
||||||
}) {
|
}) {
|
||||||
const existing = await findPendingTemplateRequestByTierList({ sourceTierListId, type })
|
if (sourceTierListId) {
|
||||||
if (existing) {
|
const existing = await findPendingTemplateRequestByTierList({ sourceTierListId, type })
|
||||||
const err = new Error('template_request_exists')
|
if (existing) {
|
||||||
err.code = 'TEMPLATE_REQUEST_EXISTS'
|
const err = new Error('template_request_exists')
|
||||||
throw err
|
err.code = 'TEMPLATE_REQUEST_EXISTS'
|
||||||
|
throw err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdAt = now()
|
const createdAt = now()
|
||||||
@@ -1632,22 +1695,28 @@ async function createTemplateRequest({
|
|||||||
description_snapshot,
|
description_snapshot,
|
||||||
thumbnail_src_snapshot,
|
thumbnail_src_snapshot,
|
||||||
items_json,
|
items_json,
|
||||||
|
groups_json,
|
||||||
|
board_items_json,
|
||||||
|
show_character_names_snapshot,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
requesterId,
|
requesterId,
|
||||||
sourceTierListId,
|
sourceTierListId || null,
|
||||||
sourceGameId,
|
sourceGameId,
|
||||||
targetGameId,
|
targetGameId,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
thumbnailSrc,
|
thumbnailSrc,
|
||||||
serializeJson(items),
|
serializeJson(items),
|
||||||
|
serializeJson(groups),
|
||||||
|
serializeJson(boardItems),
|
||||||
|
showCharacterNames ? 1 : 0,
|
||||||
createdAt,
|
createdAt,
|
||||||
createdAt,
|
createdAt,
|
||||||
]
|
]
|
||||||
@@ -1670,6 +1739,9 @@ async function findTemplateRequestById(id) {
|
|||||||
tr.description_snapshot,
|
tr.description_snapshot,
|
||||||
tr.thumbnail_src_snapshot,
|
tr.thumbnail_src_snapshot,
|
||||||
tr.items_json,
|
tr.items_json,
|
||||||
|
tr.groups_json,
|
||||||
|
tr.board_items_json,
|
||||||
|
tr.show_character_names_snapshot,
|
||||||
tr.created_at,
|
tr.created_at,
|
||||||
tr.updated_at,
|
tr.updated_at,
|
||||||
u.nickname,
|
u.nickname,
|
||||||
@@ -1705,6 +1777,9 @@ async function listAdminTemplateRequests({ status = 'pending' } = {}) {
|
|||||||
tr.description_snapshot,
|
tr.description_snapshot,
|
||||||
tr.thumbnail_src_snapshot,
|
tr.thumbnail_src_snapshot,
|
||||||
tr.items_json,
|
tr.items_json,
|
||||||
|
tr.groups_json,
|
||||||
|
tr.board_items_json,
|
||||||
|
tr.show_character_names_snapshot,
|
||||||
tr.created_at,
|
tr.created_at,
|
||||||
tr.updated_at,
|
tr.updated_at,
|
||||||
u.nickname,
|
u.nickname,
|
||||||
@@ -1852,6 +1927,7 @@ module.exports = {
|
|||||||
findUserById,
|
findUserById,
|
||||||
createUser,
|
createUser,
|
||||||
updateUserProfile,
|
updateUserProfile,
|
||||||
|
findPrimaryAdminUser,
|
||||||
listUsers,
|
listUsers,
|
||||||
adminUpdateUser,
|
adminUpdateUser,
|
||||||
adminUpdateUserPassword,
|
adminUpdateUserPassword,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const {
|
|||||||
findCustomItemsByIds,
|
findCustomItemsByIds,
|
||||||
deleteCustomItems,
|
deleteCustomItems,
|
||||||
listUsers,
|
listUsers,
|
||||||
|
findPrimaryAdminUser,
|
||||||
listAdminTierLists,
|
listAdminTierLists,
|
||||||
findTierListById,
|
findTierListById,
|
||||||
listAdminTemplateRequests,
|
listAdminTemplateRequests,
|
||||||
@@ -62,6 +63,30 @@ function buildItemLabelFromFilename(file) {
|
|||||||
const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 })
|
const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 })
|
||||||
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
|
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) => {
|
router.post('/games', requireAdmin, async (req, res) => {
|
||||||
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
|
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
|
||||||
const parsed = schema.safeParse(req.body)
|
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) => {
|
router.get('/users', requireAdmin, async (req, res) => {
|
||||||
const users = await listUsers()
|
const schema = z.object({
|
||||||
res.json({ users })
|
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) => {
|
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)
|
const parsed = schema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
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) {
|
if (req.params.userId === req.session.userId && !parsed.data.isAdmin) {
|
||||||
return res.status(400).json({ error: 'self_admin_required' })
|
return res.status(400).json({ error: 'self_admin_required' })
|
||||||
}
|
}
|
||||||
|
if (targetIsPrimaryAdmin && !actingIsPrimaryAdmin) {
|
||||||
const user = await findUserById(req.params.userId)
|
return res.status(403).json({ error: 'primary_admin_protected' })
|
||||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
}
|
||||||
|
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 {
|
try {
|
||||||
const updated = await adminUpdateUser({
|
const updated = await adminUpdateUser({
|
||||||
id: user.id,
|
id: targetUser.id,
|
||||||
email: parsed.data.email,
|
email: parsed.data.email,
|
||||||
nickname: parsed.data.nickname,
|
nickname: parsed.data.nickname,
|
||||||
isAdmin: parsed.data.isAdmin,
|
isAdmin: parsed.data.isAdmin,
|
||||||
})
|
})
|
||||||
res.json({ user: updated })
|
res.json({ user: decorateAdminUser(updated, primaryAdmin) })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e && e.code === 'ER_DUP_ENTRY') {
|
if (e && e.code === 'ER_DUP_ENTRY') {
|
||||||
return res.status(409).json({ error: 'email_taken' })
|
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)
|
const parsed = schema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
const user = await findUserById(req.params.userId)
|
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
|
||||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
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
|
const optimized = req.file
|
||||||
? await writeOptimizedImage({
|
? await writeOptimizedImage({
|
||||||
@@ -594,16 +646,16 @@ router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar')
|
|||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
const shouldRemoveAvatar = parsed.data.removeAvatar === '1'
|
const shouldRemoveAvatar = parsed.data.removeAvatar === '1'
|
||||||
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || ''
|
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || targetUser.avatarSrc || ''
|
||||||
const updated = await adminUpdateUser({
|
const updated = await adminUpdateUser({
|
||||||
id: user.id,
|
id: targetUser.id,
|
||||||
email: user.email,
|
email: targetUser.email,
|
||||||
nickname: user.nickname || '',
|
nickname: targetUser.nickname || '',
|
||||||
isAdmin: !!user.isAdmin,
|
isAdmin: !!targetUser.isAdmin,
|
||||||
avatarSrc: nextAvatarSrc,
|
avatarSrc: nextAvatarSrc,
|
||||||
})
|
})
|
||||||
|
|
||||||
res.json({ user: updated })
|
res.json({ user: decorateAdminUser(updated, primaryAdmin) })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.delete('/users/:userId', requireAdmin, async (req, res) => {
|
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' })
|
return res.status(400).json({ error: 'cannot_delete_self' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await findUserById(req.params.userId)
|
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
|
||||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
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 })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -625,11 +686,14 @@ router.patch('/users/:userId/password', requireAdmin, async (req, res) => {
|
|||||||
const parsed = schema.safeParse(req.body)
|
const parsed = schema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
const user = await findUserById(req.params.userId)
|
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
|
||||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
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)
|
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 })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const {
|
|||||||
findUserById,
|
findUserById,
|
||||||
createUser,
|
createUser,
|
||||||
updateUserProfile,
|
updateUserProfile,
|
||||||
|
findPrimaryAdminUser,
|
||||||
} = require('../db')
|
} = require('../db')
|
||||||
const { requireAuth } = require('../middleware/auth')
|
const { requireAuth } = require('../middleware/auth')
|
||||||
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
|
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
|
||||||
@@ -25,6 +26,38 @@ const profileSchema = z.object({
|
|||||||
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
|
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) => {
|
router.post('/signup', async (req, res) => {
|
||||||
const parsed = signupSchema.safeParse(req.body)
|
const parsed = signupSchema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
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 isAdmin = (await countUsers()) === 0
|
||||||
const user = await createUser({ id: nanoid(), email, nickname: '', passwordHash, isAdmin })
|
const user = await createUser({ id: nanoid(), email, nickname: '', passwordHash, isAdmin })
|
||||||
|
|
||||||
req.session.userId = user.id
|
try {
|
||||||
req.session.isAdmin = !!user.isAdmin
|
await establishSession(req, user)
|
||||||
req.session.save((err) => {
|
res.json(await serializeUser(user))
|
||||||
if (err) return res.status(500).json({ error: 'session_save_failed' })
|
} catch (err) {
|
||||||
res.json(user)
|
return res.status(500).json({ error: 'session_save_failed' })
|
||||||
})
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/login', async (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
@@ -56,19 +89,12 @@ router.post('/login', async (req, res) => {
|
|||||||
const ok = await bcrypt.compare(password, user.passwordHash)
|
const ok = await bcrypt.compare(password, user.passwordHash)
|
||||||
if (!ok) return res.status(401).json({ error: 'invalid_credentials' })
|
if (!ok) return res.status(401).json({ error: 'invalid_credentials' })
|
||||||
|
|
||||||
req.session.userId = user.id
|
try {
|
||||||
req.session.isAdmin = !!user.isAdmin
|
await establishSession(req, user)
|
||||||
req.session.save((err) => {
|
res.json(await serializeUser(user))
|
||||||
if (err) return res.status(500).json({ error: 'session_save_failed' })
|
} catch (err) {
|
||||||
res.json({
|
return res.status(500).json({ error: 'session_save_failed' })
|
||||||
id: user.id,
|
}
|
||||||
email: user.email,
|
|
||||||
nickname: user.nickname || '',
|
|
||||||
isAdmin: !!user.isAdmin,
|
|
||||||
avatarSrc: user.avatarSrc || '',
|
|
||||||
createdAt: user.createdAt,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/logout', async (req, res) => {
|
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 })
|
if (!req.session || !req.session.userId) return res.json({ user: null })
|
||||||
const user = await findUserById(req.session.userId)
|
const user = await findUserById(req.session.userId)
|
||||||
if (!user) return res.json({ user: null })
|
if (!user) return res.json({ user: null })
|
||||||
res.json({ user })
|
res.json({ user: await serializeUser(user) })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/meta', async (req, res) => {
|
router.get('/meta', async (req, res) => {
|
||||||
@@ -115,7 +141,7 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
|
|||||||
avatarSrc: nextAvatarSrc,
|
avatarSrc: nextAvatarSrc,
|
||||||
})
|
})
|
||||||
|
|
||||||
res.json({ user: updated })
|
res.json({ user: await serializeUser(updated) })
|
||||||
})
|
})
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|||||||
@@ -58,6 +58,33 @@ function getCustomTemplateItems(tierList) {
|
|||||||
const upload = createMemoryUpload(multer, { fileSize: 6 * 1024 * 1024 })
|
const upload = createMemoryUpload(multer, { fileSize: 6 * 1024 * 1024 })
|
||||||
const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 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({
|
const tierListUpsertSchema = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
gameId: z.string().min(1),
|
gameId: z.string().min(1),
|
||||||
@@ -73,8 +100,8 @@ const tierListUpsertSchema = z.object({
|
|||||||
z.object({
|
z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
name: z.string().min(1).max(16),
|
name: z.string().min(1).max(16),
|
||||||
itemIds: z.array(z.string()),
|
itemIds: z.array(z.string()).optional().default([]),
|
||||||
})
|
}).passthrough()
|
||||||
),
|
),
|
||||||
pool: z.array(
|
pool: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -194,42 +221,64 @@ router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), asyn
|
|||||||
res.json({ thumbnailSrc: optimized.src })
|
res.json({ thumbnailSrc: optimized.src })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/:id/template-request', requireAuth, async (req, res) => {
|
router.post('/template-request', requireAuth, async (req, res) => {
|
||||||
const schema = z.object({
|
const parsed = templateRequestSchema.safeParse(req.body)
|
||||||
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)
|
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
const tierList = await findTierListById(req.params.id, req.session.userId)
|
const payload = parsed.data
|
||||||
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
const normalizedBoardItems = payload.boardItems.map(normalizePoolItem)
|
||||||
if (tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
|
const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom')
|
||||||
|
|
||||||
const customItems = getCustomTemplateItems(tierList)
|
|
||||||
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
|
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
|
||||||
|
|
||||||
if (parsed.data.type === 'create') {
|
if (payload.type === 'create') {
|
||||||
if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
|
if (payload.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||||
} else {
|
} else if (payload.gameId === FREEFORM_GAME_ID) {
|
||||||
if (tierList.gameId === FREEFORM_GAME_ID) return res.status(400).json({ error: 'game_template_required' })
|
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 {
|
try {
|
||||||
const request = await createTemplateRequest({
|
const request = await createTemplateRequest({
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
type: parsed.data.type,
|
type: payload.type,
|
||||||
requesterId: req.session.userId,
|
requesterId: req.session.userId,
|
||||||
sourceTierListId: tierList.id,
|
sourceTierListId: savedTierList?.id || sourceTierList?.id || '',
|
||||||
sourceGameId: tierList.gameId,
|
sourceGameId: payload.gameId,
|
||||||
targetGameId: parsed.data.type === 'update' ? tierList.gameId : '',
|
targetGameId: payload.type === 'update' ? payload.gameId : '',
|
||||||
title: parsed.data.requestTitle,
|
title: payload.requestTitle,
|
||||||
description: parsed.data.requestDescription,
|
description: payload.requestDescription,
|
||||||
thumbnailSrc: tierList.thumbnailSrc || '',
|
thumbnailSrc: payload.thumbnailSrc || '',
|
||||||
items: customItems,
|
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) {
|
} catch (e) {
|
||||||
if (e?.code === 'TEMPLATE_REQUEST_EXISTS') {
|
if (e?.code === 'TEMPLATE_REQUEST_EXISTS') {
|
||||||
return res.status(409).json({ error: 'template_request_exists' })
|
return res.status(409).json({ error: 'template_request_exists' })
|
||||||
|
|||||||
22
docs/todo.md
22
docs/todo.md
@@ -1,21 +1,11 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
## 즉시 확인 필요
|
|
||||||
- 티어표 형식 추가 필요. 최근 게임들은 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 사이의 버전 차이가 크지 않도록 유지한다.
|
|
||||||
|
|
||||||
## 중기 개선
|
## 중기 개선
|
||||||
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
|
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
|
||||||
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다.
|
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
|
||||||
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
||||||
|
- 로그인/회원가입/관리자 비밀번호 초기화에 요청 횟수 제한을 추가한다.
|
||||||
|
- 업로드 파일은 MIME 타입뿐 아니라 파일 시그니처 기반 검증까지 확장한다.
|
||||||
|
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
|
||||||
|
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
|
||||||
|
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
|
||||||
|
|||||||
@@ -1,5 +1,81 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 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
|
## 2026-04-01 v1.3.10
|
||||||
- 게임 허브 공개 티어표 카드 그리드는 최소/최대 폭을 고정해, 목록이 1~2장뿐일 때도 카드가 화면 전체를 먹으며 과하게 커지지 않도록 보정함.
|
- 게임 허브 공개 티어표 카드 그리드는 최소/최대 폭을 고정해, 목록이 1~2장뿐일 때도 카드가 화면 전체를 먹으며 과하게 커지지 않도록 보정함.
|
||||||
- 티어표 행 삭제는 상단 아이콘 대신 우측 하단의 작은 텍스트 액션으로 바꿔, 랭크 카드 안에서 더 조용하고 정돈된 편집 흐름으로 정리함.
|
- 티어표 행 삭제는 상단 아이콘 대신 우측 하단의 작은 텍스트 액션으로 바꿔, 랭크 카드 안에서 더 조용하고 정돈된 편집 흐름으로 정리함.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import iconFavorite from './assets/icons/favorite.svg'
|
|||||||
import iconLists from './assets/icons/lists.svg'
|
import iconLists from './assets/icons/lists.svg'
|
||||||
import iconSearch from './assets/icons/search.svg'
|
import iconSearch from './assets/icons/search.svg'
|
||||||
import iconSettings from './assets/icons/settings.svg'
|
import iconSettings from './assets/icons/settings.svg'
|
||||||
|
import iconMenuBook from './assets/icons/menu_book.svg'
|
||||||
import RightRailAd from './components/RightRailAd.vue'
|
import RightRailAd from './components/RightRailAd.vue'
|
||||||
import SvgIcon from './components/SvgIcon.vue'
|
import SvgIcon from './components/SvgIcon.vue'
|
||||||
|
|
||||||
@@ -24,6 +25,8 @@ const rightRailOpen = ref(true)
|
|||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const searchPlaceholder = computed(() => (route.name === 'home' ? '게임 템플릿 검색' : '전체 티어표 검색'))
|
const searchPlaceholder = computed(() => (route.name === 'home' ? '게임 템플릿 검색' : '전체 티어표 검색'))
|
||||||
const isCollapsedSearchOpen = ref(false)
|
const isCollapsedSearchOpen = ref(false)
|
||||||
|
const isGuideModalOpen = ref(false)
|
||||||
|
const guideStepIndex = ref(0)
|
||||||
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
|
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
|
||||||
provide('rightRailOpen', rightRailOpen)
|
provide('rightRailOpen', rightRailOpen)
|
||||||
provide('localRightRailTarget', '#local-right-rail-root')
|
provide('localRightRailTarget', '#local-right-rail-root')
|
||||||
@@ -44,7 +47,7 @@ const accountName = computed(() => {
|
|||||||
const accountEmail = computed(() => (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.')
|
const accountEmail = computed(() => (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.')
|
||||||
const shellStyle = computed(() => ({
|
const shellStyle = computed(() => ({
|
||||||
'--left-rail-width': leftRailCollapsed.value ? '76px' : '248px',
|
'--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 leftNavItems = computed(() => {
|
||||||
const items = [
|
const items = [
|
||||||
@@ -56,6 +59,67 @@ const leftNavItems = computed(() => {
|
|||||||
return items.filter((item) => !item.requiresAuth || auth.user)
|
return items.filter((item) => !item.requiresAuth || auth.user)
|
||||||
})
|
})
|
||||||
const showRightRailAction = computed(() => false)
|
const showRightRailAction = computed(() => false)
|
||||||
|
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 showGameHubViewToggle = computed(() => route.name === 'gameHub')
|
const showGameHubViewToggle = computed(() => route.name === 'gameHub')
|
||||||
const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||||
const leftBottomPrimaryAction = computed(() => {
|
const leftBottomPrimaryAction = computed(() => {
|
||||||
@@ -185,6 +249,10 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function handleGlobalKeydown(event) {
|
function handleGlobalKeydown(event) {
|
||||||
|
if (event.key === 'Escape' && isGuideModalOpen.value) {
|
||||||
|
closeGuideModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (event.key === 'Escape' && isCollapsedSearchOpen.value) {
|
if (event.key === 'Escape' && isCollapsedSearchOpen.value) {
|
||||||
closeCollapsedSearch()
|
closeCollapsedSearch()
|
||||||
}
|
}
|
||||||
@@ -202,6 +270,7 @@ watch(
|
|||||||
() => {
|
() => {
|
||||||
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
||||||
isCollapsedSearchOpen.value = false
|
isCollapsedSearchOpen.value = false
|
||||||
|
isGuideModalOpen.value = false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -262,6 +331,29 @@ function closeCollapsedSearch() {
|
|||||||
isCollapsedSearchOpen.value = false
|
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() {
|
function handleLeftRailSearch() {
|
||||||
if (leftRailCollapsed.value && !isMobileLayout.value) {
|
if (leftRailCollapsed.value && !isMobileLayout.value) {
|
||||||
openCollapsedSearch()
|
openCollapsedSearch()
|
||||||
@@ -392,6 +484,66 @@ function submitGlobalSearch() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
<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">
|
<aside class="rightRail" :class="{ 'rightRail--closed': !rightRailOpen, 'rightRail--overlay': isRightRailOverlay }" :aria-hidden="!rightRailOpen">
|
||||||
@@ -415,6 +567,9 @@ function submitGlobalSearch() {
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
<button class="guideDockButton" type="button" aria-label="사용법 열기" @click="openGuideModal()">
|
||||||
|
<SvgIcon :src="iconMenuBook" :size="22" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -436,7 +591,7 @@ function submitGlobalSearch() {
|
|||||||
.appShell {
|
.appShell {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr) var(--right-rail-width, 320px);
|
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr) var(--right-rail-width, 325px);
|
||||||
background: rgba(14, 14, 14, 0.96);
|
background: rgba(14, 14, 14, 0.96);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
transition: grid-template-columns 220ms ease;
|
transition: grid-template-columns 220ms ease;
|
||||||
@@ -924,9 +1079,30 @@ function submitGlobalSearch() {
|
|||||||
.rightRail__bottom {
|
.rightRail__bottom {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.guideDockButton {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideDockButton:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
.rightRailAction__button {
|
.rightRailAction__button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
@@ -942,6 +1118,240 @@ function submitGlobalSearch() {
|
|||||||
display: none;
|
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: rgba(255, 255, 255, 0.03);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 rgba(255, 255, 255, 0.08);
|
||||||
|
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: rgba(255, 255, 255, 0.56);
|
||||||
|
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 rgba(255, 255, 255, 0.08);
|
||||||
|
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: rgba(255, 255, 255, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: rgba(77, 127, 233, 0.88);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideModal__arrow {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideModal__arrow:disabled {
|
||||||
|
opacity: 0.28;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.collapsedSearchModal {
|
.collapsedSearchModal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1071,6 +1481,20 @@ function submitGlobalSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
|
.guideModal__dialog {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideModal__sidebar {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guideModal__content {
|
||||||
|
grid-template-columns: 40px minmax(0, 1fr) 40px;
|
||||||
|
}
|
||||||
|
|
||||||
.appShell {
|
.appShell {
|
||||||
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr);
|
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
@@ -1104,6 +1528,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) {
|
@media (max-width: 860px) {
|
||||||
.appShell {
|
.appShell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
1
frontend/src/assets/icons/add_column_right.svg
Normal file
1
frontend/src/assets/icons/add_column_right.svg
Normal 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 |
1
frontend/src/assets/icons/add_row_below.svg
Normal file
1
frontend/src/assets/icons/add_row_below.svg
Normal 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 |
1
frontend/src/assets/icons/menu_book.svg
Normal file
1
frontend/src/assets/icons/menu_book.svg
Normal 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 |
@@ -77,15 +77,16 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rightRailAd__frame {
|
.rightRailAd__frame {
|
||||||
min-height: 520px;
|
width: min(100%, 300px);
|
||||||
padding: 14px;
|
min-height: 600px;
|
||||||
border-radius: 20px;
|
margin: 0 auto;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rightRailAd__slot {
|
.rightRailAd__slot {
|
||||||
width: 100%;
|
display: block;
|
||||||
min-height: 490px;
|
width: 300px;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ export const api = {
|
|||||||
approveAdminTemplateRequest: (requestId, payload) =>
|
approveAdminTemplateRequest: (requestId, payload) =>
|
||||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/approve`, { method: 'POST', body: 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: {} }),
|
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) =>
|
updateAdminUser: (userId, payload) =>
|
||||||
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
|
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
|
||||||
updateAdminUserPassword: (userId, payload) =>
|
updateAdminUserPassword: (userId, payload) =>
|
||||||
@@ -100,7 +101,7 @@ export const api = {
|
|||||||
unfavoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'DELETE' }),
|
unfavoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'DELETE' }),
|
||||||
deleteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
deleteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
duplicateTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/duplicate`, { method: 'POST' }),
|
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 }),
|
saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }),
|
||||||
uploadTierListThumbnail: async (file) => {
|
uploadTierListThumbnail: async (file) => {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const games = ref([])
|
|||||||
const selectedGameId = ref('')
|
const selectedGameId = ref('')
|
||||||
const selectedGame = ref(null)
|
const selectedGame = ref(null)
|
||||||
const featuredGameIds = ref([])
|
const featuredGameIds = ref([])
|
||||||
|
const gameAdminQuery = ref('')
|
||||||
|
const gameAdminSort = ref('recent')
|
||||||
|
|
||||||
const customItems = ref([])
|
const customItems = ref([])
|
||||||
const customItemQuery = ref('')
|
const customItemQuery = ref('')
|
||||||
@@ -47,6 +49,7 @@ const importModalNewGameId = ref('')
|
|||||||
const importModalNewGameName = ref('')
|
const importModalNewGameName = ref('')
|
||||||
const previewModalOpen = ref(false)
|
const previewModalOpen = ref(false)
|
||||||
const previewTierList = ref(null)
|
const previewTierList = ref(null)
|
||||||
|
const userEditModalOpen = ref(false)
|
||||||
const userPasswordModalOpen = ref(false)
|
const userPasswordModalOpen = ref(false)
|
||||||
const userDeleteModalOpen = ref(false)
|
const userDeleteModalOpen = ref(false)
|
||||||
const userRoleModalOpen = ref(false)
|
const userRoleModalOpen = ref(false)
|
||||||
@@ -55,9 +58,15 @@ const customItemDeleteModalOpen = ref(false)
|
|||||||
const modalTargetUser = ref(null)
|
const modalTargetUser = ref(null)
|
||||||
const modalPasswordDraft = ref('')
|
const modalPasswordDraft = ref('')
|
||||||
const modalRoleNextAdmin = ref(false)
|
const modalRoleNextAdmin = ref(false)
|
||||||
|
const modalUserDraftEmail = ref('')
|
||||||
|
const modalUserDraftNickname = ref('')
|
||||||
|
const modalUserDraftIsAdmin = ref(false)
|
||||||
const modalTargetCustomItem = ref(null)
|
const modalTargetCustomItem = ref(null)
|
||||||
|
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
|
const userQuery = ref('')
|
||||||
|
const userSort = ref('recent')
|
||||||
|
const userSortDirection = ref('desc')
|
||||||
const imageStats = ref(null)
|
const imageStats = ref(null)
|
||||||
const imageQueue = ref({ concurrency: 1, activeCount: 0, pendingCount: 0 })
|
const imageQueue = ref({ concurrency: 1, activeCount: 0, pendingCount: 0 })
|
||||||
const imageRecentJobs = ref([])
|
const imageRecentJobs = ref([])
|
||||||
@@ -97,6 +106,19 @@ const featuredGames = computed(() =>
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
)
|
)
|
||||||
const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id)))
|
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 importModalItemCount = computed(() => importModalItems.value.length)
|
||||||
const activeTabTitle = computed(() => {
|
const activeTabTitle = computed(() => {
|
||||||
if (activeTab.value === 'featured') return '목록 관리'
|
if (activeTab.value === 'featured') return '목록 관리'
|
||||||
@@ -128,7 +150,7 @@ const adminOverviewStats = computed(() => {
|
|||||||
const publishedTierLists = adminTierLists.value.filter((tierList) => tierList.isPublic).length
|
const publishedTierLists = adminTierLists.value.filter((tierList) => tierList.isPublic).length
|
||||||
const pendingRequests = templateRequests.value.length
|
const pendingRequests = templateRequests.value.length
|
||||||
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).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') {
|
if (activeTab.value === 'featured') {
|
||||||
return [
|
return [
|
||||||
@@ -263,6 +285,49 @@ const visibleLinkedGames = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
|
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() {
|
async function refreshImageDiagnostics() {
|
||||||
try {
|
try {
|
||||||
@@ -328,6 +393,12 @@ async function handleSelectedGameChange(event) {
|
|||||||
await loadGame()
|
await loadGame()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function selectAdminGame(gameId) {
|
||||||
|
if (!gameId || selectedGameId.value === gameId) return
|
||||||
|
selectedGameId.value = gameId
|
||||||
|
await loadGame()
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshGames() {
|
async function refreshGames() {
|
||||||
try {
|
try {
|
||||||
const data = await api.listGames()
|
const data = await api.listGames()
|
||||||
@@ -351,9 +422,25 @@ function setUserAvatarInput(userId, el) {
|
|||||||
userAvatarInputs.value[userId] = el
|
userAvatarInputs.value[userId] = el
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUserDirty(user) {
|
const canManageModalRole = computed(() => {
|
||||||
if (!user) return false
|
if (!auth.user?.isPrimaryAdmin) return false
|
||||||
return user.draftEmail !== user.email || (user.draftNickname || '') !== (user.nickname || '') || !!user.draftIsAdmin !== !!user.isAdmin
|
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) {
|
function openUserAvatarPicker(user) {
|
||||||
@@ -372,17 +459,14 @@ async function uploadUserAvatar(user, file, { remove = false } = {}) {
|
|||||||
entry.id === updated.id
|
entry.id === updated.id
|
||||||
? {
|
? {
|
||||||
...entry,
|
...entry,
|
||||||
avatarSrc: updated.avatarSrc || '',
|
...updated,
|
||||||
email: updated.email,
|
|
||||||
nickname: updated.nickname || '',
|
|
||||||
isAdmin: !!updated.isAdmin,
|
|
||||||
draftEmail: updated.email,
|
|
||||||
draftNickname: updated.nickname || '',
|
|
||||||
draftIsAdmin: !!updated.isAdmin,
|
|
||||||
isAvatarBusy: false,
|
isAvatarBusy: false,
|
||||||
}
|
}
|
||||||
: entry
|
: entry
|
||||||
)
|
)
|
||||||
|
if (modalTargetUser.value?.id === updated.id) {
|
||||||
|
modalTargetUser.value = { ...modalTargetUser.value, ...updated }
|
||||||
|
}
|
||||||
if (updated.id === auth.user?.id) await auth.refresh()
|
if (updated.id === auth.user?.id) await auth.refresh()
|
||||||
await refreshUsers()
|
await refreshUsers()
|
||||||
success.value = remove ? '회원 썸네일을 삭제했어요.' : '회원 썸네일을 업데이트했어요.'
|
success.value = remove ? '회원 썸네일을 삭제했어요.' : '회원 썸네일을 업데이트했어요.'
|
||||||
@@ -496,12 +580,9 @@ async function refreshTemplateRequests() {
|
|||||||
async function refreshUsers() {
|
async function refreshUsers() {
|
||||||
if (!auth.user?.isAdmin) return
|
if (!auth.user?.isAdmin) return
|
||||||
try {
|
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) => ({
|
users.value = (data.users || []).map((user) => ({
|
||||||
...user,
|
...user,
|
||||||
draftEmail: user.email,
|
|
||||||
draftNickname: user.nickname || '',
|
|
||||||
draftIsAdmin: !!user.isAdmin,
|
|
||||||
isAvatarBusy: false,
|
isAvatarBusy: false,
|
||||||
}))
|
}))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -778,29 +859,45 @@ async function removeGame() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveUser(user) {
|
function openUserEditModal(user) {
|
||||||
resetMessages()
|
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 {
|
try {
|
||||||
const data = await api.updateAdminUser(user.id, {
|
const data = await api.updateAdminUser(modalTargetUser.value.id, {
|
||||||
email: user.draftEmail,
|
email: modalUserDraftEmail.value.trim(),
|
||||||
nickname: user.draftNickname,
|
nickname: modalUserDraftNickname.value.trim(),
|
||||||
isAdmin: !!user.draftIsAdmin,
|
isAdmin: !!modalUserDraftIsAdmin.value,
|
||||||
})
|
})
|
||||||
const updated = data.user
|
const updated = data.user
|
||||||
users.value = users.value.map((entry) =>
|
users.value = users.value.map((entry) =>
|
||||||
entry.id === updated.id
|
entry.id === updated.id
|
||||||
? {
|
? {
|
||||||
...entry,
|
...entry,
|
||||||
email: updated.email,
|
...updated,
|
||||||
nickname: updated.nickname || '',
|
isAvatarBusy: entry.isAvatarBusy || false,
|
||||||
isAdmin: !!updated.isAdmin,
|
|
||||||
draftEmail: updated.email,
|
|
||||||
draftNickname: updated.nickname || '',
|
|
||||||
draftIsAdmin: !!updated.isAdmin,
|
|
||||||
}
|
}
|
||||||
: entry
|
: entry
|
||||||
)
|
)
|
||||||
if (updated.id === auth.user?.id) await auth.refresh()
|
if (updated.id === auth.user?.id) await auth.refresh()
|
||||||
|
closeUserEditModal()
|
||||||
await refreshUsers()
|
await refreshUsers()
|
||||||
success.value = '회원 정보를 저장했어요.'
|
success.value = '회원 정보를 저장했어요.'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -810,7 +907,7 @@ async function saveUser(user) {
|
|||||||
|
|
||||||
function openUserPasswordModal(user) {
|
function openUserPasswordModal(user) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
modalTargetUser.value = user || null
|
modalTargetUser.value = user ? { ...user } : null
|
||||||
modalPasswordDraft.value = ''
|
modalPasswordDraft.value = ''
|
||||||
userPasswordModalOpen.value = true
|
userPasswordModalOpen.value = true
|
||||||
}
|
}
|
||||||
@@ -842,7 +939,7 @@ async function confirmUserPasswordReset() {
|
|||||||
|
|
||||||
function openUserDeleteModal(user) {
|
function openUserDeleteModal(user) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
modalTargetUser.value = user || null
|
modalTargetUser.value = user ? { ...user } : null
|
||||||
userDeleteModalOpen.value = true
|
userDeleteModalOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,34 +966,31 @@ async function confirmUserDelete() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function openUserRoleModal(user) {
|
function openUserRoleModal(user, nextIsAdmin = !modalUserDraftIsAdmin.value) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
modalTargetUser.value = user || null
|
modalTargetUser.value = user ? { ...user } : null
|
||||||
modalRoleNextAdmin.value = !user?.draftIsAdmin
|
modalRoleNextAdmin.value = !!nextIsAdmin
|
||||||
userRoleModalOpen.value = true
|
userRoleModalOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeUserRoleModal() {
|
function closeUserRoleModal() {
|
||||||
userRoleModalOpen.value = false
|
userRoleModalOpen.value = false
|
||||||
modalTargetUser.value = null
|
if (!userEditModalOpen.value) modalTargetUser.value = null
|
||||||
modalRoleNextAdmin.value = false
|
modalRoleNextAdmin.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmUserRoleDraft() {
|
function confirmUserRoleDraft() {
|
||||||
if (!modalTargetUser.value?.id) return
|
if (!modalTargetUser.value?.id) return
|
||||||
users.value = users.value.map((entry) =>
|
modalUserDraftIsAdmin.value = modalRoleNextAdmin.value
|
||||||
entry.id === modalTargetUser.value.id
|
const targetLabel = modalRoleNextAdmin.value ? '운영자 권한을 저장 대기 상태로 반영했어요.' : '운영자 권한 해제를 저장 대기 상태로 반영했어요.'
|
||||||
? {
|
|
||||||
...entry,
|
|
||||||
draftIsAdmin: modalRoleNextAdmin.value,
|
|
||||||
}
|
|
||||||
: entry
|
|
||||||
)
|
|
||||||
const targetLabel = modalRoleNextAdmin.value ? '관리자로 지정했어요. 저장하면 반영됩니다.' : '관리자 권한 해제로 표시했어요. 저장하면 반영됩니다.'
|
|
||||||
closeUserRoleModal()
|
closeUserRoleModal()
|
||||||
success.value = targetLabel
|
success.value = targetLabel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submitUserFilters() {
|
||||||
|
refreshUsers()
|
||||||
|
}
|
||||||
|
|
||||||
function submitCustomItemSearch() {
|
function submitCustomItemSearch() {
|
||||||
customItemPage.value = 1
|
customItemPage.value = 1
|
||||||
refreshCustomItems()
|
refreshCustomItems()
|
||||||
@@ -1035,6 +1129,38 @@ function openAdminTierList(tierList) {
|
|||||||
previewModalOpen.value = true
|
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() {
|
function closePreviewModal() {
|
||||||
previewModalOpen.value = false
|
previewModalOpen.value = false
|
||||||
previewTierList.value = null
|
previewTierList.value = null
|
||||||
@@ -1440,13 +1566,14 @@ async function saveFeaturedOrder() {
|
|||||||
<div class="templateRequestCard__head">
|
<div class="templateRequestCard__head">
|
||||||
<div>
|
<div>
|
||||||
<div class="templateRequestCard__title">{{ request.sourceTierListTitle }}</div>
|
<div class="templateRequestCard__title">{{ request.sourceTierListTitle }}</div>
|
||||||
|
<div v-if="request.sourceDescription" class="templateRequestCard__desc">{{ request.sourceDescription }}</div>
|
||||||
<div class="templateRequestCard__meta">
|
<div class="templateRequestCard__meta">
|
||||||
{{ templateRequestTypeLabel(request) }} · {{ request.requesterName }} · {{ fmt(request.createdAt) }}
|
{{ templateRequestTypeLabel(request) }} · {{ request.requesterName }} · {{ fmt(request.createdAt) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="templateRequestCard__meta">{{ templateRequestTargetLabel(request) }}</div>
|
<div class="templateRequestCard__meta">{{ templateRequestTargetLabel(request) }}</div>
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1548,6 +1675,25 @@ async function saveFeaturedOrder() {
|
|||||||
</div>
|
</div>
|
||||||
</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-if="!users.length" class="hint">아직 가입한 회원이 없어요.</div>
|
||||||
<div v-else class="userList">
|
<div v-else class="userList">
|
||||||
<article v-for="user in users" :key="user.id" class="userCard">
|
<article v-for="user in users" :key="user.id" class="userCard">
|
||||||
@@ -1584,25 +1730,17 @@ async function saveFeaturedOrder() {
|
|||||||
</div>
|
</div>
|
||||||
</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="userInfoList">
|
||||||
<div class="userInfoLine"><span>가입일</span><strong>{{ fmt(user.createdAt) }}</strong></div>
|
<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>{{ user.tierListCount }}개</strong></div>
|
||||||
<div class="userInfoLine"><span>최근 활동</span><strong>{{ fmt(user.recentActivityAt || user.createdAt) }}</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>
|
</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">
|
<div class="userCard__actions userCard__actions--compact">
|
||||||
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="openUserPasswordModal(user)">
|
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="openUserPasswordModal(user)">
|
||||||
<SvgIcon class="iconActionButton__icon" :src="lockResetIcon" :size="18" />
|
<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)">
|
<button class="iconActionButton iconActionButton--danger" type="button" title="회원 삭제" @click="openUserDeleteModal(user)">
|
||||||
<SvgIcon class="iconActionButton__icon" :src="deleteIcon" :size="18" />
|
<SvgIcon class="iconActionButton__icon" :src="deleteIcon" :size="18" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -1622,8 +1760,22 @@ async function saveFeaturedOrder() {
|
|||||||
<div class="modalCard__title">새 게임 만들기</div>
|
<div class="modalCard__title">새 게임 만들기</div>
|
||||||
<div class="modalCard__desc">게임 이름과 고유 ID를 입력한 뒤 생성하면 바로 아래 상세 관리 화면으로 이어집니다.</div>
|
<div class="modalCard__desc">게임 이름과 고유 ID를 입력한 뒤 생성하면 바로 아래 상세 관리 화면으로 이어집니다.</div>
|
||||||
<div class="modalCard__form">
|
<div class="modalCard__form">
|
||||||
<input v-model="newGameName" class="input" placeholder="게임 이름" />
|
<label class="field">
|
||||||
<input v-model="newGameId" class="input" placeholder="game id (영문/숫자)" @keydown.enter.prevent="createGame" />
|
<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>
|
||||||
<div class="modalCard__actions">
|
<div class="modalCard__actions">
|
||||||
<button class="btn btn--ghost" @click="closeGameCreateModal">취소</button>
|
<button class="btn btn--ghost" @click="closeGameCreateModal">취소</button>
|
||||||
@@ -1632,12 +1784,56 @@ async function saveFeaturedOrder() {
|
|||||||
</div>
|
</div>
|
||||||
</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 v-if="userPasswordModalOpen" class="modalOverlay" @click.self="closeUserPasswordModal">
|
||||||
<div class="modalCard" role="dialog" aria-modal="true">
|
<div class="modalCard" role="dialog" aria-modal="true">
|
||||||
<div class="modalCard__title">비밀번호 초기화</div>
|
<div class="modalCard__title">비밀번호 초기화</div>
|
||||||
<div class="modalCard__desc">{{ modalTargetUser ? `${userDisplayName(modalTargetUser)} 계정에 설정할 새 비밀번호를 입력해주세요.` : '' }}</div>
|
<div class="modalCard__desc">{{ modalTargetUser ? `${userDisplayName(modalTargetUser)} 계정에 설정할 새 비밀번호를 입력해주세요.` : '' }}</div>
|
||||||
<div class="modalCard__form">
|
<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>
|
||||||
<div class="modalCard__actions">
|
<div class="modalCard__actions">
|
||||||
<button class="btn btn--ghost" @click="closeUserPasswordModal">취소</button>
|
<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 v-if="userRoleModalOpen" class="modalOverlay" @click.self="closeUserRoleModal">
|
||||||
<div class="modalCard" role="dialog" aria-modal="true">
|
<div class="modalCard" role="dialog" aria-modal="true">
|
||||||
<div class="modalCard__title">관리자 권한 변경</div>
|
<div class="modalCard__title">운영자 권한 변경</div>
|
||||||
<div class="modalCard__desc">
|
<div class="modalCard__desc">
|
||||||
{{
|
{{
|
||||||
modalTargetUser
|
modalTargetUser
|
||||||
? modalRoleNextAdmin
|
? modalRoleNextAdmin
|
||||||
? `${userDisplayName(modalTargetUser)} 사용자를 관리자로 임명할까요?`
|
? `${userDisplayName(modalTargetUser)} 사용자를 운영자로 지정할까요?`
|
||||||
: `${userDisplayName(modalTargetUser)} 사용자의 관리자 권한을 해제할까요?`
|
: `${userDisplayName(modalTargetUser)} 사용자의 운영자 권한을 해제할까요?`
|
||||||
: ''
|
: ''
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
@@ -1787,8 +1983,45 @@ async function saveFeaturedOrder() {
|
|||||||
<div class="modalCard__title">{{ previewTierList?.title || '티어표 미리보기' }}</div>
|
<div class="modalCard__title">{{ previewTierList?.title || '티어표 미리보기' }}</div>
|
||||||
<button class="btn btn--ghost btn--small" @click="closePreviewModal">닫기</button>
|
<button class="btn btn--ghost btn--small" @click="closePreviewModal">닫기</button>
|
||||||
</div>
|
</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
|
<iframe
|
||||||
v-if="previewTierList"
|
v-else-if="previewTierList"
|
||||||
class="previewFrame"
|
class="previewFrame"
|
||||||
:src="previewTierListUrl(previewTierList)"
|
:src="previewTierListUrl(previewTierList)"
|
||||||
title="티어표 미리보기"
|
title="티어표 미리보기"
|
||||||
@@ -1818,10 +2051,25 @@ async function saveFeaturedOrder() {
|
|||||||
<div class="adminSidebar__label">Game</div>
|
<div class="adminSidebar__label">Game</div>
|
||||||
<div class="adminSidebar__group">
|
<div class="adminSidebar__group">
|
||||||
<button class="btn btn--primary" @click="openGameCreateModal">새 게임 생성</button>
|
<button class="btn btn--primary" @click="openGameCreateModal">새 게임 생성</button>
|
||||||
<select :value="selectedGameId" class="select" @change="handleSelectedGameChange">
|
<input v-model="gameAdminQuery" class="input" placeholder="게임 이름 또는 ID 검색" />
|
||||||
<option value="">게임을 선택해주세요</option>
|
<select v-model="gameAdminSort" class="select">
|
||||||
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
|
<option value="recent">최신순</option>
|
||||||
|
<option value="oldest">오래된순</option>
|
||||||
</select>
|
</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 v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="hasSelectedGame" class="adminSidebar__group">
|
<div v-if="hasSelectedGame" class="adminSidebar__group">
|
||||||
@@ -1931,8 +2179,18 @@ async function saveFeaturedOrder() {
|
|||||||
|
|
||||||
<section v-if="activeTab === 'featured'" class="adminSidebar__panel">
|
<section v-if="activeTab === 'featured'" class="adminSidebar__panel">
|
||||||
<div class="adminSidebar__label">Image Optimization</div>
|
<div class="adminSidebar__label">Image Optimization</div>
|
||||||
<div class="adminSidebar__group">
|
<div class="adminSidebar__group adminSidebar__group--monthPicker">
|
||||||
<input v-model="imageStatsMonth" class="input" type="month" />
|
<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">
|
<select v-model.number="imageStatsLimit" class="select">
|
||||||
<option :value="6">최근 6건</option>
|
<option :value="6">최근 6건</option>
|
||||||
<option :value="12">최근 12건</option>
|
<option :value="12">최근 12건</option>
|
||||||
@@ -2119,6 +2377,24 @@ async function saveFeaturedOrder() {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
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 {
|
.adminSidebar__actions--stack .btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -2130,6 +2406,39 @@ async function saveFeaturedOrder() {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: rgba(255, 255, 255, 0.84);
|
color: rgba(255, 255, 255, 0.84);
|
||||||
}
|
}
|
||||||
|
.adminGamePicker {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 320px;
|
||||||
|
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 {
|
.sidebarStat {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -2459,11 +2768,11 @@ async function saveFeaturedOrder() {
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
.btn {
|
.btn {
|
||||||
|
height: 100%;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
word-break: keep-all;
|
word-break: keep-all;
|
||||||
margin-top: 12px;
|
|
||||||
padding: 11px 13px;
|
padding: 11px 13px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
@@ -2995,6 +3304,44 @@ async function saveFeaturedOrder() {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 900;
|
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 {
|
.userCard__actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -3003,6 +3350,7 @@ async function saveFeaturedOrder() {
|
|||||||
.userCard__actions--compact {
|
.userCard__actions--compact {
|
||||||
grid-template-columns: auto auto minmax(0, 1fr);
|
grid-template-columns: auto auto minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
.roleBadge {
|
.roleBadge {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
@@ -3086,6 +3434,12 @@ async function saveFeaturedOrder() {
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
font-size: 18px;
|
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 {
|
.templateRequestCard__meta {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -3134,6 +3488,76 @@ async function saveFeaturedOrder() {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
flex-wrap: wrap;
|
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 {
|
.tierAdminList {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -222,8 +222,7 @@ function submitSearch() {
|
|||||||
}
|
}
|
||||||
.list {
|
.list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 320px));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
justify-content: start;
|
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ async function submit() {
|
|||||||
<form class="authFields" @submit.prevent="submit">
|
<form class="authFields" @submit.prevent="submit">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">이메일</span>
|
<span class="field__label">이메일</span>
|
||||||
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" />
|
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" maxlength="255" />
|
||||||
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다.</span>
|
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다. {{ email.length }}/255자</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@@ -91,8 +91,9 @@ async function submit() {
|
|||||||
type="password"
|
type="password"
|
||||||
placeholder="********"
|
placeholder="********"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
|
maxlength="120"
|
||||||
/>
|
/>
|
||||||
<span class="field__hint">8자 이상으로 설정하면 더 안전하게 사용할 수 있어요.</span>
|
<span class="field__hint">6~120자 입력 가능 · {{ password.length }}/120자</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label v-if="mode === 'signup'" class="field">
|
<label v-if="mode === 'signup'" class="field">
|
||||||
@@ -103,8 +104,9 @@ async function submit() {
|
|||||||
type="password"
|
type="password"
|
||||||
placeholder="********"
|
placeholder="********"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
|
maxlength="120"
|
||||||
/>
|
/>
|
||||||
<span class="field__hint">같은 비밀번호를 한 번 더 입력해주세요.</span>
|
<span class="field__hint">같은 비밀번호를 한 번 더 입력해주세요. {{ passwordConfirm.length }}/120자</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div v-if="!hasUsers" class="roleBadge">첫 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
|
<div v-if="!hasUsers" class="roleBadge">첫 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const router = useRouter()
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const myLists = ref([])
|
const myLists = ref([])
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const brokenThumbnailIds = ref({})
|
||||||
|
|
||||||
watch(error, (message) => {
|
watch(error, (message) => {
|
||||||
if (!message) return
|
if (!message) return
|
||||||
@@ -37,12 +38,19 @@ function avatarFallbackOf(tierList) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tierListThumbnailUrl(tierList) {
|
function tierListThumbnailUrl(tierList) {
|
||||||
|
if (!tierList?.id || brokenThumbnailIds.value[tierList.id]) return ''
|
||||||
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
|
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleThumbnailError(tierListId) {
|
||||||
|
if (!tierListId || brokenThumbnailIds.value[tierListId]) return
|
||||||
|
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.listMyTierLists()
|
const data = await api.listMyTierLists()
|
||||||
|
brokenThumbnailIds.value = {}
|
||||||
myLists.value = data.tierLists || []
|
myLists.value = data.tierLists || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error('로그인이 필요해요.')
|
toast.error('로그인이 필요해요.')
|
||||||
@@ -51,53 +59,87 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function openList(t) {
|
function openList(t) {
|
||||||
router.push(`/editor/${t.gameId}/${t.id}`)
|
router.push(
|
||||||
|
"/editor/" + t.gameId + "/" + t.id,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="pageWrap">
|
<section class="dashboardHero">
|
||||||
<header class="pageHead">
|
<div class="dashboardHero__left">
|
||||||
<div class="pageHead__main">
|
<div class="dashboardHero__eyebrow">Library</div>
|
||||||
<div class="pageHead__eyebrow">Library</div>
|
<h2 class="dashboardHero__title">내 티어표</h2>
|
||||||
<h2 class="pageHead__title">내 티어표</h2>
|
<p class="dashboardHero__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</p>
|
||||||
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</header>
|
|
||||||
<div class="card">
|
<section class="panel">
|
||||||
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
|
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
|
||||||
<div v-else class="list">
|
<div v-else class="list">
|
||||||
<article v-for="t in myLists" :key="t.id" class="boardCard">
|
<article v-for="t in myLists" :key="t.id" class="boardCard">
|
||||||
<button class="boardCard__body" @click="openList(t)">
|
<button class="boardCard__body" @click="openList(t)">
|
||||||
<div class="boardCard__thumbWrap">
|
<div class="boardCard__thumbWrap">
|
||||||
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
|
<img
|
||||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
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>
|
||||||
<div class="boardCard__head">
|
<div class="boardCard__metaRow">
|
||||||
<div class="boardCard__titleRow">
|
<div class="boardCard__author">
|
||||||
<div class="boardCard__title">{{ t.title }}</div>
|
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
|
||||||
<div class="favoriteStat">♡ {{ t.favoriteCount || 0 }}</div>
|
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
||||||
</div>
|
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
|
||||||
<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>
|
</div>
|
||||||
|
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</article>
|
</button>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.card {
|
.dashboardHero {
|
||||||
border: 0;
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 6px 2px 18px;
|
||||||
|
}
|
||||||
|
.dashboardHero__left {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.dashboardHero__eyebrow {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.42);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.dashboardHero__title {
|
||||||
|
margin: 4px 0 6px;
|
||||||
|
font-size: 32px;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
color: rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
.dashboardHero__desc {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(255, 255, 255, 0.58);
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -107,12 +149,10 @@ function openList(t) {
|
|||||||
}
|
}
|
||||||
.list {
|
.list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 320px));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
justify-content: start;
|
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
.boardCard {
|
.boardCard {
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
@@ -129,7 +169,6 @@ function openList(t) {
|
|||||||
background: rgba(70, 70, 70, 0.96);
|
background: rgba(70, 70, 70, 0.96);
|
||||||
}
|
}
|
||||||
.boardCard__body {
|
.boardCard__body {
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -137,9 +176,12 @@ function openList(t) {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.boardCard__thumbWrap {
|
.boardCard__thumbWrap {
|
||||||
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
padding: 14px 14px 0;
|
padding: 14px 14px 0;
|
||||||
@@ -164,46 +206,46 @@ function openList(t) {
|
|||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
}
|
}
|
||||||
.boardCard__title {
|
.boardCard__title {
|
||||||
flex: 1 1 auto;
|
font-weight: 800;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-weight: 900;
|
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 1.3;
|
line-height: 1.35;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
.boardCard__head {
|
.boardCard__head {
|
||||||
|
min-width: 0;
|
||||||
padding: 16px 18px 18px;
|
padding: 16px 18px 18px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.boardCard__titleRow,
|
.boardCard__titleRow,
|
||||||
.boardCard__metaRow {
|
.boardCard__metaRow {
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: center;
|
display: grid;
|
||||||
justify-content: space-between;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.boardCard__titleRow {
|
.boardCard__titleRow {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.boardCard__metaRow {
|
.boardCard__metaRow {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
.boardCard__author {
|
.boardCard__author {
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
opacity: 0.84;
|
opacity: 0.86;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.boardCard__authorName {
|
.boardCard__authorName {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -229,14 +271,28 @@ function openList(t) {
|
|||||||
.boardCard__date,
|
.boardCard__date,
|
||||||
.favoriteStat {
|
.favoriteStat {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: rgba(255, 255, 255, 0.64);
|
color: rgba(255, 255, 255, 0.64);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.boardCard__date {
|
.boardCard__date {
|
||||||
font-size: 10px;
|
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 {
|
.list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,8 +156,8 @@ async function logout() {
|
|||||||
<div class="settingsFields">
|
<div class="settingsFields">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">닉네임</span>
|
<span class="field__label">닉네임</span>
|
||||||
<input v-model="nickname" class="field__input" placeholder="작성자 닉네임" />
|
<input v-model="nickname" class="field__input" maxlength="40" placeholder="작성자 닉네임" />
|
||||||
<span class="field__hint">티어표 작성자 이름으로 표시됩니다.</span>
|
<span class="field__hint">티어표 작성자 이름으로 표시됩니다. {{ nickname.length }}/40자</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watc
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import Sortable from 'sortablejs'
|
import Sortable from 'sortablejs'
|
||||||
import * as htmlToImage from 'html-to-image'
|
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 { api } from '../lib/api'
|
||||||
import { toApiUrl } from '../lib/runtime'
|
import { toApiUrl } from '../lib/runtime'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
@@ -19,11 +22,12 @@ const tierListId = computed(() => route.params.tierListId)
|
|||||||
const previewMode = computed(() => route.query.preview === '1')
|
const previewMode = computed(() => route.query.preview === '1')
|
||||||
const gameName = ref('')
|
const gameName = ref('')
|
||||||
|
|
||||||
|
const columns = ref([{ id: 'col-1', name: '' }])
|
||||||
const groups = ref([
|
const groups = ref([
|
||||||
{ id: 'gS', name: 'S', itemIds: [] },
|
{ id: 'gS', name: 'S', itemIds: [], cells: [[]] },
|
||||||
{ id: 'gA', name: 'A', itemIds: [] },
|
{ id: 'gA', name: 'A', itemIds: [], cells: [[]] },
|
||||||
{ id: 'gB', name: 'B', itemIds: [] },
|
{ id: 'gB', name: 'B', itemIds: [], cells: [[]] },
|
||||||
{ id: 'gC', name: 'C', itemIds: [] },
|
{ id: 'gC', name: 'C', itemIds: [], cells: [[]] },
|
||||||
])
|
])
|
||||||
|
|
||||||
const pool = ref([])
|
const pool = ref([])
|
||||||
@@ -44,9 +48,12 @@ const isTemplateRequestModalOpen = ref(false)
|
|||||||
const isTemplateUpdateModalOpen = ref(false)
|
const isTemplateUpdateModalOpen = ref(false)
|
||||||
const templateRequestDraftTitle = ref('')
|
const templateRequestDraftTitle = ref('')
|
||||||
const templateRequestDraftDescription = ref('')
|
const templateRequestDraftDescription = ref('')
|
||||||
|
const templateRequestSaveToMyTierList = ref(true)
|
||||||
const isDeleteModalOpen = ref(false)
|
const isDeleteModalOpen = ref(false)
|
||||||
const isGroupDeleteModalOpen = ref(false)
|
const isGroupDeleteModalOpen = ref(false)
|
||||||
|
const isColumnDeleteModalOpen = ref(false)
|
||||||
const pendingRemoveGroupId = ref('')
|
const pendingRemoveGroupId = ref('')
|
||||||
|
const pendingRemoveColumnIndex = ref(-1)
|
||||||
const ownerId = ref('')
|
const ownerId = ref('')
|
||||||
const authorName = ref('')
|
const authorName = ref('')
|
||||||
const authorAccountName = ref('')
|
const authorAccountName = ref('')
|
||||||
@@ -119,14 +126,7 @@ const canRequestTemplateCreate = computed(
|
|||||||
const canRequestTemplateUpdate = computed(
|
const canRequestTemplateUpdate = computed(
|
||||||
() => canEdit.value && !isNewTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
|
() => canEdit.value && !isNewTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
|
||||||
)
|
)
|
||||||
const templateRequestChecks = computed(() => [
|
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||||
{
|
|
||||||
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 canSubmitTemplateUpdateRequest = 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 || '선택한 게임')))
|
const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 게임')))
|
||||||
|
|
||||||
@@ -160,38 +160,107 @@ function setIconSize(nextSize) {
|
|||||||
iconSize.value = nextSize
|
iconSize.value = nextSize
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeItemFromGroup(groupId, itemId) {
|
function getGroupCellIds(group, columnIndex) {
|
||||||
if (!canEdit.value || !groupId || !itemId) return
|
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)
|
const targetGroup = groups.value.find((group) => group.id === groupId)
|
||||||
if (!targetGroup) return
|
if (!targetGroup) return
|
||||||
if (!targetGroup.itemIds.includes(itemId)) return
|
const nextCells = [...targetGroup.cells]
|
||||||
targetGroup.itemIds = targetGroup.itemIds.filter((id) => id !== itemId)
|
nextCells[columnIndex] = getGroupCellIds(targetGroup, columnIndex).filter((id) => id !== itemId)
|
||||||
|
targetGroup.cells = nextCells
|
||||||
|
syncGroupItemIds(targetGroup)
|
||||||
pool.value = [itemId, ...pool.value.filter((id) => id !== itemId)]
|
pool.value = [itemId, ...pool.value.filter((id) => id !== itemId)]
|
||||||
}
|
}
|
||||||
|
|
||||||
function setGroupDropEl(groupId, el) {
|
function setGroupDropEl(groupId, columnIndex, el) {
|
||||||
|
const key = `${groupId}::${columnIndex}`
|
||||||
if (!el) {
|
if (!el) {
|
||||||
delete groupDropEls.value[groupId]
|
delete groupDropEls.value[key]
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
groupDropEls.value[groupId] = el
|
groupDropEls.value[key] = el
|
||||||
}
|
}
|
||||||
|
|
||||||
function getListByContainer(containerEl) {
|
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')
|
const t = containerEl.getAttribute('data-list-type')
|
||||||
if (t === 'pool') return { type: 'pool', groupId: null }
|
if (t === 'pool') return { type: 'pool', groupId: null, columnIndex: null }
|
||||||
if (t === 'group') return { type: 'group', groupId: containerEl.getAttribute('data-group-id') }
|
if (t === 'group') {
|
||||||
return { type: null, groupId: null }
|
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) {
|
function normalizeSort(containerEl) {
|
||||||
const ids = Array.from(containerEl.querySelectorAll('[data-item-id]')).map((n) => n.getAttribute('data-item-id'))
|
const ids = Array.from(containerEl.querySelectorAll('[data-item-id]')).map((n) => n.getAttribute('data-item-id'))
|
||||||
const meta = getListByContainer(containerEl)
|
const meta = getListByContainer(containerEl)
|
||||||
if (meta.type === 'pool') pool.value = ids
|
if (meta.type === 'pool') {
|
||||||
|
pool.value = ids
|
||||||
|
return
|
||||||
|
}
|
||||||
if (meta.type === 'group') {
|
if (meta.type === 'group') {
|
||||||
const g = groups.value.find((x) => x.id === meta.groupId)
|
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),
|
onAdd: () => normalizeSort(poolEl.value),
|
||||||
})
|
})
|
||||||
|
|
||||||
dropSortables.value = Object.entries(groupDropEls.value).map(([gid, el]) =>
|
dropSortables.value = Object.entries(groupDropEls.value).map(([, el]) =>
|
||||||
Sortable.create(el, {
|
Sortable.create(el, {
|
||||||
group: 'tier-items',
|
group: 'tier-items',
|
||||||
animation: 160,
|
animation: 160,
|
||||||
@@ -263,32 +332,96 @@ async function syncSortables() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createGroupName() {
|
function createGroupName(index = groups.value.length) {
|
||||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||||
const index = groups.value.length
|
|
||||||
if (index < alphabet.length) return alphabet[index]
|
if (index < alphabet.length) return alphabet[index]
|
||||||
return `Tier ${index + 1}`
|
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() {
|
async function addGroup() {
|
||||||
groups.value = [
|
groups.value = [
|
||||||
...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(),
|
name: createGroupName(),
|
||||||
itemIds: [],
|
itemIds: [],
|
||||||
|
cells: columns.value.map(() => []),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
await syncSortables()
|
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) {
|
async function performRemoveGroup(groupId) {
|
||||||
if (groups.value.length <= 1) return
|
if (groups.value.length <= 1) return
|
||||||
const target = groups.value.find((group) => group.id === groupId)
|
const target = groups.value.find((group) => group.id === groupId)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
pool.value = [...target.itemIds, ...pool.value]
|
pool.value = [...target.itemIds, ...pool.value]
|
||||||
groups.value = groups.value.filter((group) => group.id !== groupId)
|
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()
|
await syncSortables()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +449,7 @@ function addCustomImage(file) {
|
|||||||
const id = `c-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
const id = `c-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
itemsById.value = {
|
itemsById.value = {
|
||||||
...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]
|
pool.value = [id, ...pool.value]
|
||||||
}
|
}
|
||||||
@@ -450,7 +583,7 @@ async function uploadPendingCustomItems() {
|
|||||||
|
|
||||||
for (const item of entries) {
|
for (const item of entries) {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('label', item.label || 'custom')
|
fd.append('label', createCustomItemLabel(item.label || 'custom'))
|
||||||
fd.append('image', item.pendingFile)
|
fd.append('image', item.pendingFile)
|
||||||
|
|
||||||
const res = await fetch(toApiUrl('/api/tierlists/custom-items'), {
|
const res = await fetch(toApiUrl('/api/tierlists/custom-items'), {
|
||||||
@@ -475,10 +608,14 @@ async function uploadPendingCustomItems() {
|
|||||||
}
|
}
|
||||||
itemsById.value = nextItemsById
|
itemsById.value = nextItemsById
|
||||||
pool.value = pool.value.map((currentId) => (currentId === item.id ? uploaded.id : currentId))
|
pool.value = pool.value.map((currentId) => (currentId === item.id ? uploaded.id : currentId))
|
||||||
groups.value = groups.value.map((group) => ({
|
groups.value = groups.value.map((group) => {
|
||||||
...group,
|
const nextGroup = {
|
||||||
itemIds: group.itemIds.map((currentId) => (currentId === item.id ? uploaded.id : currentId)),
|
...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 || '',
|
sourceTierListId: sourceTierListId.value || '',
|
||||||
sourceSnapshotTitle: sourceSnapshotTitle.value || '',
|
sourceSnapshotTitle: sourceSnapshotTitle.value || '',
|
||||||
sourceSnapshotAuthor: sourceSnapshotAuthor.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),
|
pool: Object.values(itemsById.value),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -547,8 +684,9 @@ function closeSaveModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetTemplateRequestDrafts() {
|
function resetTemplateRequestDrafts() {
|
||||||
templateRequestDraftTitle.value = ''
|
templateRequestDraftTitle.value = (title.value || '').trim()
|
||||||
templateRequestDraftDescription.value = ''
|
templateRequestDraftDescription.value = (description.value || '').trim()
|
||||||
|
templateRequestSaveToMyTierList.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTemplateRequestModal() {
|
function openTemplateRequestModal() {
|
||||||
@@ -624,25 +762,52 @@ async function toggleFavorite() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function requestTemplate(type) {
|
async function requestTemplate(type) {
|
||||||
if (isNewTierList.value) {
|
|
||||||
toast.error('요청 전에 먼저 티어표를 저장해주세요.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isRequestingTemplate.value = true
|
isRequestingTemplate.value = true
|
||||||
const persisted = await persistTierList({ showModal: false })
|
await uploadPendingCustomItems()
|
||||||
await api.requestTierListTemplate(persisted.savedTierListId, {
|
const uploadedThumbnailSrc = await uploadPendingThumbnail()
|
||||||
|
const response = await api.requestTierListTemplate({
|
||||||
type,
|
type,
|
||||||
|
sourceTierListId: tierListId.value !== 'new' ? tierListId.value : '',
|
||||||
|
gameId: gameId.value,
|
||||||
requestTitle: templateRequestDraftTitle.value.trim(),
|
requestTitle: templateRequestDraftTitle.value.trim(),
|
||||||
requestDescription: templateRequestDraftDescription.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 === 'create') closeTemplateRequestModal()
|
||||||
if (type === 'update') closeTemplateUpdateModal()
|
if (type === 'update') closeTemplateUpdateModal()
|
||||||
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
|
toast.success(
|
||||||
|
type === 'create'
|
||||||
|
? templateRequestSaveToMyTierList.value
|
||||||
|
? '템플릿 등록 요청과 내 티어표 저장을 함께 완료했어요.'
|
||||||
|
: '템플릿 등록 요청을 보냈어요.'
|
||||||
|
: templateRequestSaveToMyTierList.value
|
||||||
|
? '템플릿 업데이트 요청과 내 티어표 저장을 함께 완료했어요.'
|
||||||
|
: '템플릿 업데이트 요청을 보냈어요.'
|
||||||
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e?.status === 400 && e?.data?.error === 'title_required') {
|
if (e?.message === 'custom_upload_failed') {
|
||||||
toast.error('템플릿 등록 요청 전에는 티어표 이름을 직접 입력해주세요.')
|
toast.error('커스텀 이미지 이름이 너무 길거나 업로드 조건에 맞지 않아 요청 전에 저장하지 못했어요. 아이템 이름을 60자 이하로 줄인 뒤 다시 시도해주세요.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e?.status === 409) {
|
if (e?.status === 409) {
|
||||||
@@ -653,6 +818,10 @@ async function requestTemplate(type) {
|
|||||||
toast.error('먼저 커스텀 아이템을 추가한 뒤 요청해주세요.')
|
toast.error('먼저 커스텀 아이템을 추가한 뒤 요청해주세요.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (e?.status === 400 && e?.data?.error === 'bad_request') {
|
||||||
|
toast.error('요청 제목, 설명, 아이템 이름 중 길이 제한을 넘긴 값이 없는지 확인해주세요.')
|
||||||
|
return
|
||||||
|
}
|
||||||
toast.error(type === 'create' ? '템플릿 등록 요청에 실패했어요.' : '템플릿 업데이트 요청에 실패했어요.')
|
toast.error(type === 'create' ? '템플릿 등록 요청에 실패했어요.' : '템플릿 업데이트 요청에 실패했어요.')
|
||||||
} finally {
|
} finally {
|
||||||
isRequestingTemplate.value = false
|
isRequestingTemplate.value = false
|
||||||
@@ -705,12 +874,13 @@ onMounted(() => {
|
|||||||
sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || ''
|
sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || ''
|
||||||
favoriteCount.value = Number(t.favoriteCount || 0)
|
favoriteCount.value = Number(t.favoriteCount || 0)
|
||||||
isFavorited.value = !!t.isFavorited
|
isFavorited.value = !!t.isFavorited
|
||||||
groups.value = t.groups
|
columns.value = normalizeLoadedColumns(t.groups)
|
||||||
|
groups.value = normalizeLoadedGroups(t.groups, columns.value)
|
||||||
const map = {}
|
const map = {}
|
||||||
;(t.pool || []).forEach((it) => (map[it.id] = it))
|
;(t.pool || []).forEach((it) => (map[it.id] = it))
|
||||||
itemsById.value = map
|
itemsById.value = map
|
||||||
const grouped = new Set()
|
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))
|
pool.value = Object.keys(itemsById.value).filter((id) => !grouped.has(id))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '티어표를 불러오지 못했어요.'
|
error.value = '티어표를 불러오지 못했어요.'
|
||||||
@@ -735,13 +905,23 @@ onUnmounted(() => {
|
|||||||
<div class="previewOnly__sheet">
|
<div class="previewOnly__sheet">
|
||||||
<div class="previewOnly__title">{{ effectiveTitle }}</div>
|
<div class="previewOnly__title">{{ effectiveTitle }}</div>
|
||||||
<div v-if="description" class="previewOnly__description">{{ description }}</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 class="previewOnly__rows">
|
||||||
<div v-for="g in groups" :key="g.id" class="previewOnly__row">
|
<div v-for="g in groups" :key="g.id" class="previewOnly__row">
|
||||||
<div class="previewOnly__label">{{ g.name }}</div>
|
<div class="previewOnly__label">{{ g.name }}</div>
|
||||||
<div class="previewOnly__drop">
|
<div class="previewOnly__dropGrid" :style="{ '--column-count': columns.length }">
|
||||||
<div v-for="id in g.itemIds" :key="id" class="previewOnly__cell">
|
<div v-for="(column, columnIndex) in columns" :key="column.id" class="previewOnly__dropColumn">
|
||||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
<div class="previewOnly__drop">
|
||||||
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -773,18 +953,7 @@ onUnmounted(() => {
|
|||||||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="templateRequestTitle">
|
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="templateRequestTitle">
|
||||||
<div id="templateRequestTitle" class="modalCard__title">템플릿 등록 요청</div>
|
<div id="templateRequestTitle" class="modalCard__title">템플릿 등록 요청</div>
|
||||||
<div class="modalCard__desc">
|
<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>
|
||||||
<div class="requestChecklist__hint">
|
<div class="requestChecklist__hint">
|
||||||
제목과 설명을 함께 적어두면 관리자가 어떤 신규 템플릿인지 훨씬 빠르게 파악할 수 있어요.
|
제목과 설명을 함께 적어두면 관리자가 어떤 신규 템플릿인지 훨씬 빠르게 파악할 수 있어요.
|
||||||
@@ -792,13 +961,23 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="templateRequestDraft">
|
<div class="templateRequestDraft">
|
||||||
<label class="templateRequestDraft__field">
|
<label class="templateRequestDraft__field">
|
||||||
<span class="templateRequestDraft__label">요청 제목</span>
|
<span class="templateRequestDraft__label">티어표 제목</span>
|
||||||
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 등록 요청" />
|
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="120" placeholder="예: 템플릿 등록 요청" />
|
||||||
|
<span class="templateRequestDraft__hint">{{ templateRequestDraftTitle.length }}/120자</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="templateRequestDraft__field">
|
<label class="templateRequestDraft__field">
|
||||||
<span class="templateRequestDraft__label">요청 설명</span>
|
<span class="templateRequestDraft__label">티어표 설명</span>
|
||||||
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
|
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="1000" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
|
||||||
|
<span class="templateRequestDraft__hint">{{ templateRequestDraftDescription.length }}/1000자</span>
|
||||||
</label>
|
</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>
|
||||||
<div class="modalCard__actions">
|
<div class="modalCard__actions">
|
||||||
<button class="btn btn--ghost" @click="closeTemplateRequestModal">취소</button>
|
<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 class="modalCard" role="dialog" aria-modal="true" aria-labelledby="templateUpdateTitle">
|
||||||
<div id="templateUpdateTitle" class="modalCard__title">템플릿 요청하기</div>
|
<div id="templateUpdateTitle" class="modalCard__title">템플릿 요청하기</div>
|
||||||
<div class="modalCard__desc">
|
<div class="modalCard__desc">
|
||||||
{{ templateRequestTargetLabel }}에 직접 추가한 아이템을 포함해 달라고 관리자에게 요청을 보냅니다.
|
{{ templateRequestTargetLabel }}에 직접 추가한 아이템을 포함해 달라고 관리자에게 요청합니다. 현재 티어표 제목과 설명은 그대로 가져와 두었어요.
|
||||||
</div>
|
</div>
|
||||||
<div class="modalCard__note">
|
<div class="modalCard__note">
|
||||||
모두가 사용하는 기본 템플릿이니 개인적인 항목이 아닌 공통된 항목만 추가한 뒤 신청해주세요.
|
모두가 사용하는 기본 템플릿이니 개인적인 항목이 아닌 공통된 항목만 추가한 뒤 신청해주세요.
|
||||||
@@ -821,13 +1000,23 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="templateRequestDraft">
|
<div class="templateRequestDraft">
|
||||||
<label class="templateRequestDraft__field">
|
<label class="templateRequestDraft__field">
|
||||||
<span class="templateRequestDraft__label">요청 제목</span>
|
<span class="templateRequestDraft__label">티어표 제목</span>
|
||||||
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 업데이트 요청" />
|
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="120" placeholder="예: 템플릿 업데이트 요청" />
|
||||||
|
<span class="templateRequestDraft__hint">{{ templateRequestDraftTitle.length }}/120자</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="templateRequestDraft__field">
|
<label class="templateRequestDraft__field">
|
||||||
<span class="templateRequestDraft__label">요청 설명</span>
|
<span class="templateRequestDraft__label">티어표 설명</span>
|
||||||
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
|
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="1000" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
|
||||||
|
<span class="templateRequestDraft__hint">{{ templateRequestDraftDescription.length }}/1000자</span>
|
||||||
</label>
|
</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>
|
||||||
<div class="modalCard__actions">
|
<div class="modalCard__actions">
|
||||||
<button class="btn btn--ghost" @click="closeTemplateUpdateModal">요청 취소</button>
|
<button class="btn btn--ghost" @click="closeTemplateUpdateModal">요청 취소</button>
|
||||||
@@ -855,13 +1044,26 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<div v-if="isGroupDeleteModalOpen" class="modalOverlay" @click.self="closeGroupDeleteModal">
|
<div v-if="isGroupDeleteModalOpen" class="modalOverlay" @click.self="closeGroupDeleteModal">
|
||||||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteGroupTitle">
|
<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 class="modalCard__desc">
|
||||||
이 라인을 삭제하면 현재 들어 있는 아이템은 모두 아래 아이템 영역으로 이동합니다. 삭제 후에도 아이템 자체는 유지돼요.
|
이 행을 삭제하면 현재 들어 있는 아이템은 모두 아래 아이템 영역으로 이동합니다. 삭제 후에도 아이템 자체는 유지돼요.
|
||||||
</div>
|
</div>
|
||||||
<div class="modalCard__actions">
|
<div class="modalCard__actions">
|
||||||
<button class="btn btn--ghost" @click="closeGroupDeleteModal">취소</button>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -873,7 +1075,7 @@ onUnmounted(() => {
|
|||||||
<div class="editorMain__title">{{ gameName || gameId }}</div>
|
<div class="editorMain__title">{{ gameName || gameId }}</div>
|
||||||
<div class="editorMain__subtitle">
|
<div class="editorMain__subtitle">
|
||||||
<template v-if="canEdit">
|
<template v-if="canEdit">
|
||||||
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다.
|
행/열 이름과 순서를 바꾸고 아이템을 드래그해서 배치할 수 있어요.
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 수 있어요.
|
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 수 있어요.
|
||||||
@@ -890,7 +1092,12 @@ onUnmounted(() => {
|
|||||||
<div ref="boardEl" class="board">
|
<div ref="boardEl" class="board">
|
||||||
<div v-if="canEdit && !isExporting" class="boardTools">
|
<div v-if="canEdit && !isExporting" class="boardTools">
|
||||||
<div class="boardTools__left">
|
<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>
|
||||||
<div class="boardTools__right">
|
<div class="boardTools__right">
|
||||||
<span class="boardTools__label">아이콘 크기</span>
|
<span class="boardTools__label">아이콘 크기</span>
|
||||||
@@ -910,6 +1117,22 @@ onUnmounted(() => {
|
|||||||
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
|
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
|
||||||
<div v-if="isExporting" class="exportBoard__title">{{ effectiveTitle }}</div>
|
<div v-if="isExporting" class="exportBoard__title">{{ effectiveTitle }}</div>
|
||||||
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</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 ref="groupListEl" class="rows">
|
||||||
<div v-for="g in groups" :key="g.id" class="row">
|
<div v-for="g in groups" :key="g.id" class="row">
|
||||||
<div class="row__label">
|
<div class="row__label">
|
||||||
@@ -917,40 +1140,46 @@ onUnmounted(() => {
|
|||||||
<div class="row__exportName">{{ g.name }}</div>
|
<div class="row__exportName">{{ g.name }}</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<span class="grab" title="드래그로 순서 변경" data-group-handle>↕</span>
|
||||||
|
<input v-model="g.name" class="groupName" maxlength="16" :readonly="!canEdit" />
|
||||||
<button
|
<button
|
||||||
v-if="canEdit"
|
v-if="canEdit"
|
||||||
class="rowRemoveText"
|
class="rowRemoveText"
|
||||||
type="button"
|
type="button"
|
||||||
title="티어 라인 삭제"
|
title="행 삭제"
|
||||||
|
aria-label="행 삭제"
|
||||||
:disabled="groups.length <= 1"
|
:disabled="groups.length <= 1"
|
||||||
@click="openGroupDeleteModal(g.id)"
|
@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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1004,7 +1233,8 @@ onUnmounted(() => {
|
|||||||
<template v-if="globalRightRailOpen">
|
<template v-if="globalRightRailOpen">
|
||||||
<div class="editorSidebar__section">
|
<div class="editorSidebar__section">
|
||||||
<div class="editorSidebar__label">Title</div>
|
<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 v-if="untitledWarning" class="editorSidebar__hint editorSidebar__hint--warn">{{ untitledWarning }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1014,8 +1244,10 @@ onUnmounted(() => {
|
|||||||
v-model="description"
|
v-model="description"
|
||||||
class="editorSidebar__textarea"
|
class="editorSidebar__textarea"
|
||||||
placeholder="Description Text"
|
placeholder="Description Text"
|
||||||
|
maxlength="1000"
|
||||||
:readonly="!canEdit"
|
:readonly="!canEdit"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<div class="editorSidebar__hint">{{ description.length }}/1000자</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editorSidebar__section">
|
<div class="editorSidebar__section">
|
||||||
@@ -1176,15 +1408,42 @@ onUnmounted(() => {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
opacity: 0.76;
|
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 {
|
.previewOnly__rows {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.previewOnly__row {
|
.previewOnly__row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 180px 1fr;
|
grid-template-columns: 132px 1fr;
|
||||||
gap: 10px;
|
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 {
|
.previewOnly__label {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -1438,6 +1697,15 @@ onUnmounted(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.64);
|
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 {
|
.templateRequestDraft__input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 14px 0;
|
padding: 14px 0;
|
||||||
@@ -1483,6 +1751,51 @@ onUnmounted(() => {
|
|||||||
.boardTools__left {
|
.boardTools__left {
|
||||||
margin-right: auto;
|
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 {
|
.boardTools__label {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
opacity: 0.76;
|
opacity: 0.76;
|
||||||
@@ -1549,7 +1862,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.row {
|
.row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 180px 1fr;
|
grid-template-columns: 132px 1fr;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
@@ -1559,24 +1872,86 @@ onUnmounted(() => {
|
|||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 10px 12px 30px;
|
padding: 14px 28px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.grab {
|
.row__content {
|
||||||
cursor: grab;
|
display: grid;
|
||||||
opacity: 0.85;
|
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
|
||||||
width: 26px;
|
gap: 10px;
|
||||||
height: 26px;
|
}
|
||||||
|
.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;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 10px;
|
padding: 0;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
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);
|
background: rgba(0, 0, 0, 0.16);
|
||||||
flex: 0 0 auto;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.groupName {
|
.groupName {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1584,7 +1959,7 @@ onUnmounted(() => {
|
|||||||
background: rgba(0, 0, 0, 0.18);
|
background: rgba(0, 0, 0, 0.18);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 6px 8px;
|
padding: 8px 10px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -1592,19 +1967,25 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.rowRemoveText {
|
.rowRemoveText {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 12px;
|
top: 10px;
|
||||||
bottom: 10px;
|
right: 10px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 16px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
.rowRemoveText:hover {
|
.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 {
|
.rowRemoveText:disabled {
|
||||||
opacity: 0.32;
|
opacity: 0.32;
|
||||||
@@ -1646,17 +2027,21 @@ onUnmounted(() => {
|
|||||||
.itemNameOverlay {
|
.itemNameOverlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: auto 0 0 0;
|
inset: auto 0 0 0;
|
||||||
padding: 16px 8px 6px;
|
min-height: 26px;
|
||||||
|
padding: 18px 8px 6px;
|
||||||
border-radius: 0 0 10px 10px;
|
border-radius: 0 0 10px 10px;
|
||||||
background: linear-gradient(180deg, rgba(7, 10, 18, 0), rgba(7, 10, 18, 0.92));
|
background: linear-gradient(180deg, rgba(7, 10, 18, 0), rgba(7, 10, 18, 0.92));
|
||||||
color: rgba(255, 255, 255, 0.96);
|
color: rgba(255, 255, 255, 0.96);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.25;
|
line-height: 1.2;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.cellRemoveBtn {
|
.cellRemoveBtn {
|
||||||
@@ -2000,6 +2385,9 @@ onUnmounted(() => {
|
|||||||
.editorCanvas {
|
.editorCanvas {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.row__content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.row {
|
.row {
|
||||||
grid-template-columns: 150px 1fr;
|
grid-template-columns: 150px 1fr;
|
||||||
}
|
}
|
||||||
@@ -2031,7 +2419,18 @@ onUnmounted(() => {
|
|||||||
.previewOnly {
|
.previewOnly {
|
||||||
padding: 14px;
|
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;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.pool {
|
.pool {
|
||||||
|
|||||||
Reference in New Issue
Block a user