Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b851100c89 | |||
| e70e685a06 | |||
| 09acebc2d5 | |||
| e3391b5f07 | |||
| 22220494d6 | |||
| 909ed72502 | |||
| c352bf459f | |||
| 730a87b923 | |||
| e9a049241d | |||
| 0fec84de13 | |||
| 7fe4eff7b7 | |||
| b2a838ff34 | |||
| 695c0bd4dd | |||
| 7b1ba19572 | |||
| b4ada4b9a2 | |||
| 7f9a7cc947 | |||
| 880c79bbc4 | |||
| 7967361cac |
@@ -5,7 +5,10 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor nodemon --legacy-watch --watch index.js --watch src index.js",
|
||||
"start": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js"
|
||||
"start": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js",
|
||||
"images:backfill": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/backfill-legacy-image-assets.js",
|
||||
"images:migrate-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-legacy-uploads-to-assets.js",
|
||||
"uploads:cleanup-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/cleanup-unreferenced-legacy-uploads.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
106
backend/scripts/backfill-legacy-image-assets.js
Normal file
106
backend/scripts/backfill-legacy-image-assets.js
Normal file
@@ -0,0 +1,106 @@
|
||||
const fs = require('fs/promises')
|
||||
const path = require('path')
|
||||
const crypto = require('crypto')
|
||||
const sharp = require('sharp')
|
||||
const { nanoid } = require('nanoid')
|
||||
const {
|
||||
ensureData,
|
||||
closePool,
|
||||
listReferencedUploadSources,
|
||||
findImageAssetBySrc,
|
||||
createImageAsset,
|
||||
} = require('../src/db')
|
||||
|
||||
const BACKEND_ROOT = path.join(__dirname, '..')
|
||||
|
||||
function inferMimeType(src, metadata) {
|
||||
const format = String(metadata?.format || '').toLowerCase()
|
||||
if (format === 'jpeg' || format === 'jpg') return 'image/jpeg'
|
||||
if (format === 'png') return 'image/png'
|
||||
if (format === 'gif') return 'image/gif'
|
||||
if (format === 'webp') return 'image/webp'
|
||||
if (format === 'svg' || format === 'svg+xml') return 'image/svg+xml'
|
||||
if (format === 'avif') return 'image/avif'
|
||||
|
||||
const ext = path.extname(src || '').toLowerCase()
|
||||
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
||||
if (ext === '.png') return 'image/png'
|
||||
if (ext === '.gif') return 'image/gif'
|
||||
if (ext === '.webp') return 'image/webp'
|
||||
if (ext === '.svg') return 'image/svg+xml'
|
||||
if (ext === '.avif') return 'image/avif'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureData()
|
||||
|
||||
const referencedSrcs = Array.from(new Set(await listReferencedUploadSources()))
|
||||
.filter((src) => typeof src === 'string' && src.startsWith('/uploads/'))
|
||||
.sort()
|
||||
|
||||
const summary = {
|
||||
scanned: referencedSrcs.length,
|
||||
skippedExisting: 0,
|
||||
backfilled: 0,
|
||||
missingFiles: 0,
|
||||
failed: 0,
|
||||
}
|
||||
|
||||
for (const src of referencedSrcs) {
|
||||
const existing = await findImageAssetBySrc(src)
|
||||
if (existing) {
|
||||
summary.skippedExisting += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const absolutePath = path.join(BACKEND_ROOT, src.replace(/^\//, ''))
|
||||
|
||||
try {
|
||||
const [buffer, stat] = await Promise.all([fs.readFile(absolutePath), fs.stat(absolutePath)])
|
||||
let metadata = {}
|
||||
try {
|
||||
metadata = await sharp(buffer, { failOn: 'none' }).metadata()
|
||||
} catch (error) {
|
||||
metadata = {}
|
||||
}
|
||||
|
||||
const rawHash = crypto.createHash('sha256').update(buffer).digest('hex')
|
||||
const contentHash = crypto.createHash('sha256').update(`${rawHash}|${src}`).digest('hex')
|
||||
|
||||
await createImageAsset({
|
||||
id: nanoid(),
|
||||
contentHash,
|
||||
src,
|
||||
mimeType: inferMimeType(src, metadata),
|
||||
byteSize: Number(stat.size || 0),
|
||||
originalByteSize: Number(stat.size || 0),
|
||||
width: Number(metadata.width || 0),
|
||||
height: Number(metadata.height || 0),
|
||||
})
|
||||
summary.backfilled += 1
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
summary.missingFiles += 1
|
||||
continue
|
||||
}
|
||||
if (error?.code === 'ER_DUP_ENTRY') {
|
||||
summary.skippedExisting += 1
|
||||
continue
|
||||
}
|
||||
summary.failed += 1
|
||||
console.error('[backfill-legacy-image-assets] failed:', src, error?.message || error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await closePool()
|
||||
})
|
||||
56
backend/scripts/cleanup-unreferenced-legacy-uploads.js
Normal file
56
backend/scripts/cleanup-unreferenced-legacy-uploads.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const fs = require('fs/promises')
|
||||
const path = require('path')
|
||||
const {
|
||||
ensureData,
|
||||
closePool,
|
||||
listReferencedUploadSources,
|
||||
} = require('../src/db')
|
||||
|
||||
const BACKEND_ROOT = path.join(__dirname, '..')
|
||||
const TARGET_DIRS = ['avatars', 'custom', 'games', 'tierlists']
|
||||
|
||||
async function main() {
|
||||
await ensureData()
|
||||
|
||||
const referenced = new Set(await listReferencedUploadSources())
|
||||
const deleted = []
|
||||
const missing = []
|
||||
let scanned = 0
|
||||
|
||||
for (const dir of TARGET_DIRS) {
|
||||
const absoluteDir = path.join(BACKEND_ROOT, 'uploads', dir)
|
||||
let entries = []
|
||||
try {
|
||||
entries = await fs.readdir(absoluteDir, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') continue
|
||||
throw error
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue
|
||||
scanned += 1
|
||||
const src = `/uploads/${dir}/${entry.name}`
|
||||
if (referenced.has(src)) continue
|
||||
const absolutePath = path.join(absoluteDir, entry.name)
|
||||
try {
|
||||
await fs.unlink(absolutePath)
|
||||
deleted.push(src)
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') missing.push(src)
|
||||
else throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ scanned, deletedCount: deleted.length, missingCount: missing.length, deleted, missing }, null, 2))
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await closePool()
|
||||
})
|
||||
112
backend/scripts/migrate-legacy-uploads-to-assets.js
Normal file
112
backend/scripts/migrate-legacy-uploads-to-assets.js
Normal file
@@ -0,0 +1,112 @@
|
||||
const fs = require('fs/promises')
|
||||
const path = require('path')
|
||||
const sharp = require('sharp')
|
||||
const {
|
||||
ensureData,
|
||||
closePool,
|
||||
listReferencedUploadUsage,
|
||||
replaceUploadSourceReferences,
|
||||
} = require('../src/db')
|
||||
const { writeOptimizedImage } = require('../src/lib/image-storage')
|
||||
|
||||
const BACKEND_ROOT = path.join(__dirname, '..')
|
||||
|
||||
function inferMimeType(src, metadata) {
|
||||
const format = String(metadata?.format || '').toLowerCase()
|
||||
if (format === 'jpeg' || format === 'jpg') return 'image/jpeg'
|
||||
if (format === 'png') return 'image/png'
|
||||
if (format === 'gif') return 'image/gif'
|
||||
if (format === 'webp') return 'image/webp'
|
||||
if (format === 'svg' || format === 'svg+xml') return 'image/svg+xml'
|
||||
if (format === 'avif') return 'image/avif'
|
||||
|
||||
const ext = path.extname(src || '').toLowerCase()
|
||||
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
||||
if (ext === '.png') return 'image/png'
|
||||
if (ext === '.gif') return 'image/gif'
|
||||
if (ext === '.webp') return 'image/webp'
|
||||
if (ext === '.svg') return 'image/svg+xml'
|
||||
if (ext === '.avif') return 'image/avif'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
function getOptimizationConfig(roles) {
|
||||
const roleSet = new Set(roles || [])
|
||||
if (roleSet.has('avatar')) {
|
||||
return { directory: 'avatars', width: 512, height: 512, fit: 'cover', quality: 82 }
|
||||
}
|
||||
if (roleSet.has('game-thumbnail') || roleSet.has('tierlist-thumbnail') || roleSet.has('template-thumbnail')) {
|
||||
return { directory: 'legacy-thumbnails', width: 1280, height: 1280, fit: 'inside', quality: 84 }
|
||||
}
|
||||
return { directory: 'legacy-items', width: 512, height: 512, fit: 'inside', quality: 84 }
|
||||
}
|
||||
|
||||
async function createFileLike(src) {
|
||||
const absolutePath = path.join(BACKEND_ROOT, src.replace(/^\//, ''))
|
||||
const [buffer, stat] = await Promise.all([fs.readFile(absolutePath), fs.stat(absolutePath)])
|
||||
let metadata = {}
|
||||
try {
|
||||
metadata = await sharp(buffer, { failOn: 'none' }).metadata()
|
||||
} catch (error) {
|
||||
metadata = {}
|
||||
}
|
||||
return {
|
||||
file: {
|
||||
originalname: path.basename(src),
|
||||
mimetype: inferMimeType(src, metadata),
|
||||
size: Number(stat.size || 0),
|
||||
buffer,
|
||||
},
|
||||
absolutePath,
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureData()
|
||||
const usageEntries = await listReferencedUploadUsage()
|
||||
const legacyEntries = usageEntries.filter((entry) => entry.src && entry.src.startsWith('/uploads/') && !entry.src.startsWith('/uploads/assets/'))
|
||||
|
||||
const summary = {
|
||||
scanned: legacyEntries.length,
|
||||
migrated: 0,
|
||||
reusedAsset: 0,
|
||||
unchanged: 0,
|
||||
missingFiles: 0,
|
||||
failed: 0,
|
||||
updatedRows: 0,
|
||||
}
|
||||
|
||||
for (const entry of legacyEntries) {
|
||||
const config = getOptimizationConfig(entry.roles)
|
||||
try {
|
||||
const { file } = await createFileLike(entry.src)
|
||||
const optimized = await writeOptimizedImage({ file, ...config })
|
||||
if (optimized.src === entry.src) {
|
||||
summary.unchanged += 1
|
||||
continue
|
||||
}
|
||||
const replaced = await replaceUploadSourceReferences({ fromSrc: entry.src, toSrc: optimized.src })
|
||||
summary.updatedRows += Number(replaced.updatedRows || 0)
|
||||
if (optimized.reused) summary.reusedAsset += 1
|
||||
else summary.migrated += 1
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
summary.missingFiles += 1
|
||||
continue
|
||||
}
|
||||
summary.failed += 1
|
||||
console.error('[migrate-legacy-uploads-to-assets] failed:', entry.src, error?.message || error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await closePool()
|
||||
})
|
||||
@@ -154,7 +154,7 @@ function mapTemplateRequestRow(row) {
|
||||
requesterName: getUserDisplayName(row),
|
||||
requesterAccountName: getUserAccountName(row),
|
||||
requesterAvatarSrc: row.requester_avatar_src || '',
|
||||
sourceTierListId: row.source_tierlist_id,
|
||||
sourceTierListId: row.source_tierlist_id || '',
|
||||
sourceGameId: row.source_game_id,
|
||||
sourceGameName: row.source_game_name || '',
|
||||
sourceTierListTitle: row.title_snapshot || '',
|
||||
@@ -164,6 +164,9 @@ function mapTemplateRequestRow(row) {
|
||||
targetGameName: row.target_game_name || '',
|
||||
status: row.status,
|
||||
items: parseJson(row.items_json, []),
|
||||
snapshotGroups: parseJson(row.groups_json, []),
|
||||
snapshotItems: parseJson(row.board_items_json, []),
|
||||
snapshotShowCharacterNames: !!row.show_character_names_snapshot,
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at),
|
||||
}
|
||||
@@ -223,6 +226,14 @@ async function query(sql, params = []) {
|
||||
return rows
|
||||
}
|
||||
|
||||
async function closePool() {
|
||||
if (!poolPromise) return
|
||||
const pool = await poolPromise
|
||||
await pool.end()
|
||||
poolPromise = null
|
||||
initPromise = null
|
||||
}
|
||||
|
||||
async function ensureSchema() {
|
||||
if (initPromise) return initPromise
|
||||
initPromise = (async () => {
|
||||
@@ -381,6 +392,23 @@ async function ensureSchema() {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
const templateRequestSourceTierListColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_tierlist_id'")
|
||||
if (templateRequestSourceTierListColumns[0]?.Null !== 'YES') {
|
||||
await query('ALTER TABLE template_requests MODIFY source_tierlist_id VARCHAR(64) NULL')
|
||||
}
|
||||
const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'")
|
||||
if (!templateRequestGroupsColumns.length) {
|
||||
await query("ALTER TABLE template_requests ADD COLUMN groups_json LONGTEXT NOT NULL AFTER items_json")
|
||||
}
|
||||
const templateRequestBoardItemsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'board_items_json'")
|
||||
if (!templateRequestBoardItemsColumns.length) {
|
||||
await query("ALTER TABLE template_requests ADD COLUMN board_items_json LONGTEXT NOT NULL AFTER groups_json")
|
||||
}
|
||||
const templateRequestShowNamesColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'show_character_names_snapshot'")
|
||||
if (!templateRequestShowNamesColumns.length) {
|
||||
await query("ALTER TABLE template_requests ADD COLUMN show_character_names_snapshot TINYINT(1) NOT NULL DEFAULT 0 AFTER board_items_json")
|
||||
}
|
||||
|
||||
const tierListThumbnailColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'thumbnail_src'")
|
||||
if (!tierListThumbnailColumns.length) {
|
||||
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
|
||||
@@ -498,25 +526,59 @@ async function updateUserProfile({ id, nickname, avatarSrc }) {
|
||||
return findUserById(id)
|
||||
}
|
||||
|
||||
async function listUsers() {
|
||||
const rows = await query(`
|
||||
SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
u.nickname,
|
||||
u.is_admin,
|
||||
u.avatar_src,
|
||||
u.created_at,
|
||||
COUNT(t.id) AS tierlist_count,
|
||||
GREATEST(
|
||||
async function findPrimaryAdminUser() {
|
||||
const rows = await query(
|
||||
'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
|
||||
)
|
||||
return mapUserRow(rows[0])
|
||||
}
|
||||
|
||||
async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' } = {}) {
|
||||
const where = []
|
||||
const params = []
|
||||
const trimmedQuery = typeof queryText === 'string' ? queryText.trim() : ''
|
||||
|
||||
if (trimmedQuery) {
|
||||
where.push('(u.email LIKE ? OR u.nickname LIKE ?)')
|
||||
params.push(`%${trimmedQuery}%`, `%${trimmedQuery}%`)
|
||||
}
|
||||
|
||||
const isAsc = direction === 'asc'
|
||||
const orderBy =
|
||||
sort === 'created'
|
||||
? isAsc
|
||||
? 'u.created_at ASC, recent_activity_at ASC, u.email ASC'
|
||||
: 'u.created_at DESC, recent_activity_at DESC, u.email ASC'
|
||||
: sort === 'tierlists'
|
||||
? isAsc
|
||||
? 'tierlist_count ASC, recent_activity_at ASC, u.email ASC'
|
||||
: 'tierlist_count DESC, recent_activity_at DESC, u.email ASC'
|
||||
: isAsc
|
||||
? 'recent_activity_at ASC, u.created_at ASC, u.email ASC'
|
||||
: 'recent_activity_at DESC, u.created_at ASC, u.email ASC'
|
||||
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
u.nickname,
|
||||
u.is_admin,
|
||||
u.avatar_src,
|
||||
u.created_at,
|
||||
COALESCE(MAX(t.updated_at), 0)
|
||||
) AS recent_activity_at
|
||||
FROM users u
|
||||
LEFT JOIN tierlists t ON t.author_id = u.id
|
||||
GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at
|
||||
ORDER BY recent_activity_at DESC, u.created_at ASC, u.email ASC
|
||||
`)
|
||||
COUNT(t.id) AS tierlist_count,
|
||||
GREATEST(
|
||||
u.created_at,
|
||||
COALESCE(MAX(t.updated_at), 0)
|
||||
) AS recent_activity_at
|
||||
FROM users u
|
||||
LEFT JOIN tierlists t ON t.author_id = u.id
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at
|
||||
ORDER BY ${orderBy}
|
||||
`,
|
||||
params
|
||||
)
|
||||
return rows.map(mapUserRow)
|
||||
}
|
||||
|
||||
@@ -618,6 +680,14 @@ async function findImageAssetByHash(contentHash) {
|
||||
return mapImageAssetRow(rows[0])
|
||||
}
|
||||
|
||||
async function findImageAssetBySrc(src) {
|
||||
const rows = await query(
|
||||
'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE src = ? LIMIT 1',
|
||||
[src]
|
||||
)
|
||||
return mapImageAssetRow(rows[0])
|
||||
}
|
||||
|
||||
async function createImageAsset({ id, contentHash, src, mimeType = "image/webp", byteSize, originalByteSize, width, height }) {
|
||||
const createdAt = now()
|
||||
await query(
|
||||
@@ -704,7 +774,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
|
||||
query("SELECT src FROM game_items WHERE src <> ''"),
|
||||
query("SELECT src FROM custom_items WHERE src <> ''"),
|
||||
query("SELECT thumbnail_src, pool_json FROM tierlists"),
|
||||
query("SELECT thumbnail_src_snapshot, items_json FROM template_requests"),
|
||||
query("SELECT thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"),
|
||||
])
|
||||
|
||||
for (const row of userRows) if (row.avatar_src) referencedSrcs.add(row.avatar_src)
|
||||
@@ -720,6 +790,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
|
||||
for (const row of templateRequestRows) {
|
||||
if (row.thumbnail_src_snapshot) referencedSrcs.add(row.thumbnail_src_snapshot)
|
||||
collectUploadSrcsFromItems(parseJson(row.items_json, []), referencedSrcs)
|
||||
collectUploadSrcsFromItems(parseJson(row.board_items_json, []), referencedSrcs)
|
||||
}
|
||||
|
||||
return assets.filter((asset) => !referencedSrcs.has(asset.src))
|
||||
@@ -738,32 +809,119 @@ async function deleteImageAssets(ids) {
|
||||
}
|
||||
|
||||
async function listReferencedUploadSources() {
|
||||
const referencedSrcs = new Set()
|
||||
const usage = await listReferencedUploadUsage()
|
||||
return usage.map((entry) => entry.src)
|
||||
}
|
||||
|
||||
async function listReferencedUploadUsage() {
|
||||
const usageMap = new Map()
|
||||
const addUsage = (src, role) => {
|
||||
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return
|
||||
if (!usageMap.has(src)) usageMap.set(src, new Set())
|
||||
usageMap.get(src).add(role)
|
||||
}
|
||||
|
||||
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
|
||||
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
|
||||
query("SELECT thumbnail_src FROM games WHERE thumbnail_src <> ''"),
|
||||
query("SELECT src FROM game_items WHERE src <> ''"),
|
||||
query("SELECT src FROM custom_items WHERE src <> ''"),
|
||||
query("SELECT thumbnail_src, pool_json FROM tierlists"),
|
||||
query("SELECT thumbnail_src_snapshot, items_json FROM template_requests"),
|
||||
query("SELECT id, thumbnail_src, pool_json FROM tierlists"),
|
||||
query("SELECT id, 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 gameRows) if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
|
||||
for (const row of gameItemRows) if (row.src) referencedSrcs.add(row.src)
|
||||
for (const row of customItemRows) if (row.src) referencedSrcs.add(row.src)
|
||||
for (const row of userRows) addUsage(row.avatar_src, 'avatar')
|
||||
for (const row of gameRows) addUsage(row.thumbnail_src, 'game-thumbnail')
|
||||
for (const row of gameItemRows) addUsage(row.src, 'game-item')
|
||||
for (const row of customItemRows) addUsage(row.src, 'custom-item')
|
||||
|
||||
for (const row of tierListRows) {
|
||||
if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
|
||||
collectUploadSrcsFromItems(parseJson(row.pool_json, []), referencedSrcs)
|
||||
addUsage(row.thumbnail_src, 'tierlist-thumbnail')
|
||||
for (const item of parseJson(row.pool_json, [])) addUsage(item?.src, 'tierlist-pool')
|
||||
}
|
||||
|
||||
for (const row of templateRequestRows) {
|
||||
if (row.thumbnail_src_snapshot) referencedSrcs.add(row.thumbnail_src_snapshot)
|
||||
collectUploadSrcsFromItems(parseJson(row.items_json, []), referencedSrcs)
|
||||
addUsage(row.thumbnail_src_snapshot, 'template-thumbnail')
|
||||
for (const item of parseJson(row.items_json, [])) addUsage(item?.src, 'template-item')
|
||||
for (const item of parseJson(row.board_items_json, [])) addUsage(item?.src, 'template-board-item')
|
||||
}
|
||||
|
||||
return Array.from(referencedSrcs)
|
||||
return Array.from(usageMap.entries())
|
||||
.map(([src, roles]) => ({ src, roles: Array.from(roles).sort() }))
|
||||
.sort((a, b) => a.src.localeCompare(b.src))
|
||||
}
|
||||
|
||||
function replaceItemSrc(items, fromSrc, toSrc) {
|
||||
let changed = false
|
||||
const nextItems = (items || []).map((item) => {
|
||||
if (item?.src !== fromSrc) return item
|
||||
changed = true
|
||||
return { ...item, src: toSrc }
|
||||
})
|
||||
return { changed, items: nextItems }
|
||||
}
|
||||
|
||||
async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
|
||||
if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 }
|
||||
|
||||
const [userResult, gameResult, gameItemResult, customItemResult] = await Promise.all([
|
||||
query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]),
|
||||
query('UPDATE games SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]),
|
||||
query('UPDATE game_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
|
||||
query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
|
||||
])
|
||||
|
||||
let updatedRows = Number(userResult.affectedRows || 0) + Number(gameResult.affectedRows || 0) + Number(gameItemResult.affectedRows || 0) + Number(customItemResult.affectedRows || 0)
|
||||
|
||||
const tierListRows = await query('SELECT id, thumbnail_src, pool_json FROM tierlists')
|
||||
for (const row of tierListRows) {
|
||||
let nextThumbnail = row.thumbnail_src
|
||||
let changed = false
|
||||
if (row.thumbnail_src === fromSrc) {
|
||||
nextThumbnail = toSrc
|
||||
changed = true
|
||||
}
|
||||
|
||||
const replacedPool = replaceItemSrc(parseJson(row.pool_json, []), fromSrc, toSrc)
|
||||
if (replacedPool.changed) changed = true
|
||||
|
||||
if (changed) {
|
||||
await query('UPDATE tierlists SET thumbnail_src = ?, pool_json = ?, updated_at = ? WHERE id = ?', [
|
||||
nextThumbnail || '',
|
||||
serializeJson(replacedPool.items),
|
||||
now(),
|
||||
row.id,
|
||||
])
|
||||
updatedRows += 1
|
||||
}
|
||||
}
|
||||
|
||||
const requestRows = await query('SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests')
|
||||
for (const row of requestRows) {
|
||||
let nextThumbnail = row.thumbnail_src_snapshot
|
||||
let changed = false
|
||||
if (row.thumbnail_src_snapshot === fromSrc) {
|
||||
nextThumbnail = toSrc
|
||||
changed = true
|
||||
}
|
||||
|
||||
const replacedItems = replaceItemSrc(parseJson(row.items_json, []), fromSrc, toSrc)
|
||||
const replacedBoardItems = replaceItemSrc(parseJson(row.board_items_json, []), fromSrc, toSrc)
|
||||
if (replacedItems.changed || replacedBoardItems.changed) changed = true
|
||||
|
||||
if (changed) {
|
||||
await query('UPDATE template_requests SET thumbnail_src_snapshot = ?, items_json = ?, board_items_json = ?, updated_at = ? WHERE id = ?', [
|
||||
nextThumbnail || '',
|
||||
serializeJson(replacedItems.items),
|
||||
serializeJson(replacedBoardItems.items),
|
||||
now(),
|
||||
row.id,
|
||||
])
|
||||
updatedRows += 1
|
||||
}
|
||||
}
|
||||
|
||||
return { updatedRows }
|
||||
}
|
||||
|
||||
async function listImageAssets() {
|
||||
@@ -1502,19 +1660,24 @@ async function createTemplateRequest({
|
||||
id,
|
||||
type,
|
||||
requesterId,
|
||||
sourceTierListId,
|
||||
sourceTierListId = '',
|
||||
sourceGameId,
|
||||
targetGameId = '',
|
||||
title,
|
||||
description = '',
|
||||
thumbnailSrc = '',
|
||||
items = [],
|
||||
groups = [],
|
||||
boardItems = [],
|
||||
showCharacterNames = false,
|
||||
}) {
|
||||
const existing = await findPendingTemplateRequestByTierList({ sourceTierListId, type })
|
||||
if (existing) {
|
||||
const err = new Error('template_request_exists')
|
||||
err.code = 'TEMPLATE_REQUEST_EXISTS'
|
||||
throw err
|
||||
if (sourceTierListId) {
|
||||
const existing = await findPendingTemplateRequestByTierList({ sourceTierListId, type })
|
||||
if (existing) {
|
||||
const err = new Error('template_request_exists')
|
||||
err.code = 'TEMPLATE_REQUEST_EXISTS'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const createdAt = now()
|
||||
@@ -1532,22 +1695,28 @@ async function createTemplateRequest({
|
||||
description_snapshot,
|
||||
thumbnail_src_snapshot,
|
||||
items_json,
|
||||
groups_json,
|
||||
board_items_json,
|
||||
show_character_names_snapshot,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
id,
|
||||
type,
|
||||
requesterId,
|
||||
sourceTierListId,
|
||||
sourceTierListId || null,
|
||||
sourceGameId,
|
||||
targetGameId,
|
||||
title,
|
||||
description,
|
||||
thumbnailSrc,
|
||||
serializeJson(items),
|
||||
serializeJson(groups),
|
||||
serializeJson(boardItems),
|
||||
showCharacterNames ? 1 : 0,
|
||||
createdAt,
|
||||
createdAt,
|
||||
]
|
||||
@@ -1570,6 +1739,9 @@ async function findTemplateRequestById(id) {
|
||||
tr.description_snapshot,
|
||||
tr.thumbnail_src_snapshot,
|
||||
tr.items_json,
|
||||
tr.groups_json,
|
||||
tr.board_items_json,
|
||||
tr.show_character_names_snapshot,
|
||||
tr.created_at,
|
||||
tr.updated_at,
|
||||
u.nickname,
|
||||
@@ -1605,6 +1777,9 @@ async function listAdminTemplateRequests({ status = 'pending' } = {}) {
|
||||
tr.description_snapshot,
|
||||
tr.thumbnail_src_snapshot,
|
||||
tr.items_json,
|
||||
tr.groups_json,
|
||||
tr.board_items_json,
|
||||
tr.show_character_names_snapshot,
|
||||
tr.created_at,
|
||||
tr.updated_at,
|
||||
u.nickname,
|
||||
@@ -1746,11 +1921,13 @@ async function unfavoriteGame({ userId, gameId }) {
|
||||
module.exports = {
|
||||
DB_NAME,
|
||||
ensureData,
|
||||
closePool,
|
||||
countUsers,
|
||||
findUserByEmail,
|
||||
findUserById,
|
||||
createUser,
|
||||
updateUserProfile,
|
||||
findPrimaryAdminUser,
|
||||
listUsers,
|
||||
adminUpdateUser,
|
||||
adminUpdateUserPassword,
|
||||
@@ -1762,6 +1939,7 @@ module.exports = {
|
||||
createGame,
|
||||
updateGameThumbnail,
|
||||
findImageAssetByHash,
|
||||
findImageAssetBySrc,
|
||||
createImageAsset,
|
||||
createImageOptimizationJob,
|
||||
findImageOptimizationJobById,
|
||||
@@ -1770,6 +1948,8 @@ module.exports = {
|
||||
listUnusedImageAssets,
|
||||
deleteImageAssets,
|
||||
listReferencedUploadSources,
|
||||
listReferencedUploadUsage,
|
||||
replaceUploadSourceReferences,
|
||||
clearImageOptimizationJobs,
|
||||
getImageAssetStats,
|
||||
createGameItem,
|
||||
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
findCustomItemsByIds,
|
||||
deleteCustomItems,
|
||||
listUsers,
|
||||
findPrimaryAdminUser,
|
||||
listAdminTierLists,
|
||||
findTierListById,
|
||||
listAdminTemplateRequests,
|
||||
@@ -62,6 +63,30 @@ function buildItemLabelFromFilename(file) {
|
||||
const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 })
|
||||
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
|
||||
|
||||
function decorateAdminUser(user, primaryAdmin) {
|
||||
if (!user) return null
|
||||
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
|
||||
return {
|
||||
...user,
|
||||
isPrimaryAdmin,
|
||||
isOperator: !!user.isAdmin && !isPrimaryAdmin,
|
||||
role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user',
|
||||
}
|
||||
}
|
||||
|
||||
async function getAdminUserContext(targetUserId, actingUserId) {
|
||||
const [targetUser, actingUser, primaryAdmin] = await Promise.all([
|
||||
findUserById(targetUserId),
|
||||
findUserById(actingUserId),
|
||||
findPrimaryAdminUser(),
|
||||
])
|
||||
return { targetUser, actingUser, primaryAdmin }
|
||||
}
|
||||
|
||||
function canManageAdminRole(actingUser, primaryAdmin) {
|
||||
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
|
||||
}
|
||||
|
||||
router.post('/games', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
|
||||
const parsed = schema.safeParse(req.body)
|
||||
@@ -537,8 +562,19 @@ router.delete('/custom-items', requireAdmin, async (req, res) => {
|
||||
})
|
||||
|
||||
router.get('/users', requireAdmin, async (req, res) => {
|
||||
const users = await listUsers()
|
||||
res.json({ users })
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
sort: z.enum(['recent', 'created', 'tierlists']).optional().default('recent'),
|
||||
direction: z.enum(['asc', 'desc']).optional().default('desc'),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const [users, primaryAdmin] = await Promise.all([
|
||||
listUsers({ queryText: parsed.data.q, sort: parsed.data.sort, direction: parsed.data.direction }),
|
||||
findPrimaryAdminUser(),
|
||||
])
|
||||
res.json({ users: users.map((user) => decorateAdminUser(user, primaryAdmin)) })
|
||||
})
|
||||
|
||||
router.patch('/users/:userId', requireAdmin, async (req, res) => {
|
||||
@@ -550,21 +586,34 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
|
||||
if (!targetUser) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin)
|
||||
const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id
|
||||
const roleChanged = parsed.data.isAdmin !== !!targetUser.isAdmin
|
||||
|
||||
if (req.params.userId === req.session.userId && !parsed.data.isAdmin) {
|
||||
return res.status(400).json({ error: 'self_admin_required' })
|
||||
}
|
||||
|
||||
const user = await findUserById(req.params.userId)
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
if (targetIsPrimaryAdmin && !actingIsPrimaryAdmin) {
|
||||
return res.status(403).json({ error: 'primary_admin_protected' })
|
||||
}
|
||||
if (targetIsPrimaryAdmin && !parsed.data.isAdmin) {
|
||||
return res.status(400).json({ error: 'primary_admin_required' })
|
||||
}
|
||||
if (roleChanged && !actingIsPrimaryAdmin) {
|
||||
return res.status(403).json({ error: 'primary_admin_only' })
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await adminUpdateUser({
|
||||
id: user.id,
|
||||
id: targetUser.id,
|
||||
email: parsed.data.email,
|
||||
nickname: parsed.data.nickname,
|
||||
isAdmin: parsed.data.isAdmin,
|
||||
})
|
||||
res.json({ user: updated })
|
||||
res.json({ user: decorateAdminUser(updated, primaryAdmin) })
|
||||
} catch (e) {
|
||||
if (e && e.code === 'ER_DUP_ENTRY') {
|
||||
return res.status(409).json({ error: 'email_taken' })
|
||||
@@ -580,8 +629,11 @@ router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar')
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const user = await findUserById(req.params.userId)
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
|
||||
if (!targetUser) return res.status(404).json({ error: 'not_found' })
|
||||
if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) {
|
||||
return res.status(403).json({ error: 'primary_admin_protected' })
|
||||
}
|
||||
|
||||
const optimized = req.file
|
||||
? await writeOptimizedImage({
|
||||
@@ -594,16 +646,16 @@ router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar')
|
||||
})
|
||||
: null
|
||||
const shouldRemoveAvatar = parsed.data.removeAvatar === '1'
|
||||
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || ''
|
||||
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || targetUser.avatarSrc || ''
|
||||
const updated = await adminUpdateUser({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nickname: user.nickname || '',
|
||||
isAdmin: !!user.isAdmin,
|
||||
id: targetUser.id,
|
||||
email: targetUser.email,
|
||||
nickname: targetUser.nickname || '',
|
||||
isAdmin: !!targetUser.isAdmin,
|
||||
avatarSrc: nextAvatarSrc,
|
||||
})
|
||||
|
||||
res.json({ user: updated })
|
||||
res.json({ user: decorateAdminUser(updated, primaryAdmin) })
|
||||
})
|
||||
|
||||
router.delete('/users/:userId', requireAdmin, async (req, res) => {
|
||||
@@ -611,10 +663,19 @@ router.delete('/users/:userId', requireAdmin, async (req, res) => {
|
||||
return res.status(400).json({ error: 'cannot_delete_self' })
|
||||
}
|
||||
|
||||
const user = await findUserById(req.params.userId)
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
|
||||
if (!targetUser) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
await adminDeleteUser(user.id)
|
||||
const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin)
|
||||
const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id
|
||||
if (targetIsPrimaryAdmin) {
|
||||
return res.status(400).json({ error: 'cannot_delete_primary_admin' })
|
||||
}
|
||||
if (targetUser.isAdmin && !actingIsPrimaryAdmin) {
|
||||
return res.status(403).json({ error: 'primary_admin_only' })
|
||||
}
|
||||
|
||||
await adminDeleteUser(targetUser.id)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
@@ -625,11 +686,14 @@ router.patch('/users/:userId/password', requireAdmin, async (req, res) => {
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const user = await findUserById(req.params.userId)
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
|
||||
if (!targetUser) return res.status(404).json({ error: 'not_found' })
|
||||
if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) {
|
||||
return res.status(403).json({ error: 'primary_admin_protected' })
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(parsed.data.password, 10)
|
||||
await adminUpdateUserPassword({ id: user.id, passwordHash })
|
||||
await adminUpdateUserPassword({ id: targetUser.id, passwordHash })
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
findUserById,
|
||||
createUser,
|
||||
updateUserProfile,
|
||||
findPrimaryAdminUser,
|
||||
} = require('../db')
|
||||
const { requireAuth } = require('../middleware/auth')
|
||||
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
|
||||
@@ -25,6 +26,24 @@ const profileSchema = z.object({
|
||||
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
|
||||
})
|
||||
|
||||
async function serializeUser(user) {
|
||||
if (!user) return null
|
||||
const primaryAdmin = await findPrimaryAdminUser()
|
||||
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nickname: user.nickname || '',
|
||||
isAdmin: !!user.isAdmin,
|
||||
isPrimaryAdmin,
|
||||
isOperator: !!user.isAdmin && !isPrimaryAdmin,
|
||||
role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user',
|
||||
avatarSrc: user.avatarSrc || '',
|
||||
createdAt: user.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/signup', async (req, res) => {
|
||||
const parsed = signupSchema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
@@ -39,9 +58,9 @@ router.post('/signup', async (req, res) => {
|
||||
|
||||
req.session.userId = user.id
|
||||
req.session.isAdmin = !!user.isAdmin
|
||||
req.session.save((err) => {
|
||||
req.session.save(async (err) => {
|
||||
if (err) return res.status(500).json({ error: 'session_save_failed' })
|
||||
res.json(user)
|
||||
res.json(await serializeUser(user))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,16 +77,9 @@ router.post('/login', async (req, res) => {
|
||||
|
||||
req.session.userId = user.id
|
||||
req.session.isAdmin = !!user.isAdmin
|
||||
req.session.save((err) => {
|
||||
req.session.save(async (err) => {
|
||||
if (err) return res.status(500).json({ error: 'session_save_failed' })
|
||||
res.json({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nickname: user.nickname || '',
|
||||
isAdmin: !!user.isAdmin,
|
||||
avatarSrc: user.avatarSrc || '',
|
||||
createdAt: user.createdAt,
|
||||
})
|
||||
res.json(await serializeUser(user))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,7 +92,7 @@ router.get('/me', async (req, res) => {
|
||||
if (!req.session || !req.session.userId) return res.json({ user: null })
|
||||
const user = await findUserById(req.session.userId)
|
||||
if (!user) return res.json({ user: null })
|
||||
res.json({ user })
|
||||
res.json({ user: await serializeUser(user) })
|
||||
})
|
||||
|
||||
router.get('/meta', async (req, res) => {
|
||||
@@ -115,7 +127,7 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
|
||||
avatarSrc: nextAvatarSrc,
|
||||
})
|
||||
|
||||
res.json({ user: updated })
|
||||
res.json({ user: await serializeUser(updated) })
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -58,6 +58,33 @@ function getCustomTemplateItems(tierList) {
|
||||
const upload = createMemoryUpload(multer, { fileSize: 6 * 1024 * 1024 })
|
||||
const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024 })
|
||||
|
||||
const templateRequestSchema = z.object({
|
||||
type: z.enum(['create', 'update']),
|
||||
sourceTierListId: z.string().max(64).optional().default(''),
|
||||
gameId: z.string().min(1).max(120),
|
||||
requestTitle: z.string().trim().min(1).max(120),
|
||||
requestDescription: z.string().trim().min(1).max(1000),
|
||||
thumbnailSrc: z.string().max(255).optional().default(''),
|
||||
isPublic: z.boolean().optional().default(false),
|
||||
showCharacterNames: z.boolean().optional().default(false),
|
||||
saveToMyTierList: z.boolean().optional().default(true),
|
||||
groups: z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(16),
|
||||
itemIds: z.array(z.string()).optional().default([]),
|
||||
}).passthrough()
|
||||
),
|
||||
boardItems: z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
src: z.string().min(1),
|
||||
label: z.string().min(1).max(60),
|
||||
origin: z.enum(['game', 'custom']).default('game'),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
const tierListUpsertSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
gameId: z.string().min(1),
|
||||
@@ -73,8 +100,8 @@ const tierListUpsertSchema = z.object({
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(16),
|
||||
itemIds: z.array(z.string()),
|
||||
})
|
||||
itemIds: z.array(z.string()).optional().default([]),
|
||||
}).passthrough()
|
||||
),
|
||||
pool: z.array(
|
||||
z.object({
|
||||
@@ -194,42 +221,64 @@ router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), asyn
|
||||
res.json({ thumbnailSrc: optimized.src })
|
||||
})
|
||||
|
||||
router.post('/:id/template-request', requireAuth, async (req, res) => {
|
||||
const schema = z.object({
|
||||
type: z.enum(['create', 'update']),
|
||||
requestTitle: z.string().trim().min(1).max(80),
|
||||
requestDescription: z.string().trim().min(1).max(240),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
router.post('/template-request', requireAuth, async (req, res) => {
|
||||
const parsed = templateRequestSchema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const tierList = await findTierListById(req.params.id, req.session.userId)
|
||||
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||
if (tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
|
||||
|
||||
const customItems = getCustomTemplateItems(tierList)
|
||||
const payload = parsed.data
|
||||
const normalizedBoardItems = payload.boardItems.map(normalizePoolItem)
|
||||
const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom')
|
||||
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
|
||||
|
||||
if (parsed.data.type === 'create') {
|
||||
if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||
} else {
|
||||
if (tierList.gameId === FREEFORM_GAME_ID) return res.status(400).json({ error: 'game_template_required' })
|
||||
if (payload.type === 'create') {
|
||||
if (payload.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||
} else if (payload.gameId === FREEFORM_GAME_ID) {
|
||||
return res.status(400).json({ error: 'game_template_required' })
|
||||
}
|
||||
|
||||
let sourceTierList = null
|
||||
if (payload.sourceTierListId) {
|
||||
sourceTierList = await findTierListById(payload.sourceTierListId, req.session.userId)
|
||||
if (!sourceTierList) return res.status(404).json({ error: 'not_found' })
|
||||
if (sourceTierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
|
||||
}
|
||||
|
||||
let savedTierList = null
|
||||
if (payload.saveToMyTierList) {
|
||||
savedTierList = await saveTierList({
|
||||
id: sourceTierList?.id || undefined,
|
||||
authorId: req.session.userId,
|
||||
gameId: payload.gameId,
|
||||
title: payload.requestTitle,
|
||||
thumbnailSrc: payload.thumbnailSrc || '',
|
||||
description: payload.requestDescription || '',
|
||||
isPublic: !!payload.isPublic,
|
||||
showCharacterNames: !!payload.showCharacterNames,
|
||||
sourceTierListId: sourceTierList?.sourceTierListId || '',
|
||||
sourceSnapshotTitle: sourceTierList?.sourceSnapshotTitle || '',
|
||||
sourceSnapshotAuthor: sourceTierList?.sourceSnapshotAuthor || '',
|
||||
groups: payload.groups,
|
||||
pool: normalizedBoardItems,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const request = await createTemplateRequest({
|
||||
id: nanoid(),
|
||||
type: parsed.data.type,
|
||||
type: payload.type,
|
||||
requesterId: req.session.userId,
|
||||
sourceTierListId: tierList.id,
|
||||
sourceGameId: tierList.gameId,
|
||||
targetGameId: parsed.data.type === 'update' ? tierList.gameId : '',
|
||||
title: parsed.data.requestTitle,
|
||||
description: parsed.data.requestDescription,
|
||||
thumbnailSrc: tierList.thumbnailSrc || '',
|
||||
sourceTierListId: savedTierList?.id || sourceTierList?.id || '',
|
||||
sourceGameId: payload.gameId,
|
||||
targetGameId: payload.type === 'update' ? payload.gameId : '',
|
||||
title: payload.requestTitle,
|
||||
description: payload.requestDescription,
|
||||
thumbnailSrc: payload.thumbnailSrc || '',
|
||||
items: customItems,
|
||||
groups: payload.groups,
|
||||
boardItems: normalizedBoardItems,
|
||||
showCharacterNames: !!payload.showCharacterNames,
|
||||
})
|
||||
return res.json({ request })
|
||||
return res.json({ request, savedTierList: savedTierList ? normalizeTierList(savedTierList) : null })
|
||||
} catch (e) {
|
||||
if (e?.code === 'TEMPLATE_REQUEST_EXISTS') {
|
||||
return res.status(409).json({ error: 'template_request_exists' })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 즉시 확인 필요
|
||||
- 레거시 업로드 파일도 현재 실사용 용량에는 포함되지만, 과거 자산까지 'image_assets' 메타에 백필한 상태는 아니므로 필요하면 1회 백필 스크립트를 추가해 절감률/중복 통계를 완전히 일원화한다.
|
||||
- 레거시 파일 정리 스크립트는 준비됐으므로, 운영 단계에서는 cron 등으로 주기 실행할지와 삭제 전 보관 기간을 함께 정한다.
|
||||
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
||||
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
|
||||
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.
|
||||
@@ -16,5 +16,5 @@
|
||||
|
||||
## 중기 개선
|
||||
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
|
||||
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다.
|
||||
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
|
||||
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
||||
|
||||
@@ -1,5 +1,87 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-01 v1.3.23
|
||||
- 내 티어표 목록 그리드는 auto-fit 최대폭 방식 대신 게임 목록과 같은 4/3/2/1열 고정 반응형 규칙으로 맞춰, 넓은 화면에서 카드 한 장이 애매하게 다음 줄로 떨어지며 여백이 크게 남던 문제를 줄임.
|
||||
|
||||
## 2026-04-01 v1.3.22
|
||||
- 내 티어표 카드는 게임 목록과 같은 상단 히어로/패널 문법으로 다시 맞추고, 깨진 썸네일은 alt 텍스트가 카드 폭을 밀지 않도록 플레이스홀더로 즉시 대체해 카드 수와 헤더 폭이 흔들리지 않게 보정함.
|
||||
- 오른쪽 사이드 광고 프레임은 별도 보더·패딩·배경을 제거해, 광고 자체가 가진 각진 형태와 색이 그대로 보이도록 더 담백하게 정리함.
|
||||
|
||||
## 2026-04-01 v1.3.21
|
||||
- 내 티어표 카드는 게임 목록 화면과 같은 카드 폭/헤더/메타 배치 문법으로 맞춰, 화면 간 카드 크기와 정보 정렬이 더 통일된 인상으로 보이도록 정리함.
|
||||
|
||||
## 2026-04-01 v1.3.20
|
||||
- 내 티어표 카드 그리드는 카드 최대폭 우선 규칙 대신 더 촘촘한 auto-fill 기준으로 조정해, 넓은 화면에서도 한 줄에 더 많은 카드가 자연스럽게 배치되도록 보정함.
|
||||
|
||||
## 2026-04-01 v1.3.19
|
||||
- 관리자 Image Optimization 기간 선택은 연도/월을 가로로 나란히 두고, 연도를 고르기 전에는 월 셀렉트를 숨겨 비어 있는 박스처럼 보이던 상태를 없앰.
|
||||
- 전체 초기화 버튼도 실제 월이 선택된 경우에만 보이도록 정리해, 사이드바 상단 필터 줄이 더 단정하게 보이도록 보정함.
|
||||
|
||||
## 2026-04-01 v1.3.18
|
||||
- 커스텀 아이템 기본 이름은 파일명 전체를 그대로 쓰지 않고 확장자 제거·공백 정리·60자 제한을 먼저 적용하도록 바꿔, 템플릿 요청 전에 커스텀 업로드가 길이 제한으로 실패하던 흐름을 줄임.
|
||||
- 템플릿 요청 실패 안내는 커스텀 이미지 업로드 실패와 일반 bad request를 구분해, 사용자가 제목/설명/아이템 이름 길이 제한 문제를 더 쉽게 파악할 수 있게 보강함.
|
||||
- 관리자 Image Optimization 월 필터는 기본 month input 대신 연도/월 셀렉트와 전체 초기화 버튼으로 바꿔, 기간 선택을 더 직관적으로 조작할 수 있게 정리함.
|
||||
|
||||
## 2026-04-01 v1.3.17
|
||||
- 티어 에디터 열 헤더 입력창과 행 라벨은 좌우 패딩을 대칭으로 다시 잡아, 드래그 핸들과 삭제 아이콘이 있어도 제목이 한쪽으로 쏠려 보이지 않도록 보정함.
|
||||
- 열 삭제도 이제 행 삭제와 같은 확인 모달을 거쳐 진행되도록 바꿔, 실수로 즉시 제거되던 문제를 막음.
|
||||
- 내보내기 보드는 여전히 960px 고정 폭이라 열 수가 늘수록 각 칸 폭이 줄어드는 구조라는 점을 기준으로 정리했고, 현재 보정은 헤더 정렬 문제를 우선 해결하는 쪽에 맞춤.
|
||||
|
||||
## 2026-04-01 v1.3.16
|
||||
- 티어 에디터의 행 삭제와 열 삭제는 다시 작은 X 아이콘 액션으로 정리해, 행/열 이름 주변의 반복 텍스트 때문에 보드가 답답해 보이던 문제를 줄임.
|
||||
- 열 헤더 편집 영역은 입력창 오른쪽에 아이콘 삭제만 남기고, 행 라벨도 상단 우측의 작은 제거 버튼으로 맞춰 더 압축된 편집 밀도를 유지하도록 조정함.
|
||||
- 저장 이미지에서 열 제목이 살짝 위로 떠 보이던 문제는 내보내기 헤더의 비대칭 패딩을 제거하고 flex 중앙 정렬로 바꿔, 시각적으로 정확한 중앙에 오도록 보정함.
|
||||
|
||||
## 2026-04-01 v1.3.15
|
||||
- 티어 에디터의 열 이름은 각 행 안에서 반복 렌더링되지 않도록 공통 상단 헤더로 분리해, 행 제목과 같은 구조로 더 또렷하게 구분되도록 수정함.
|
||||
- 행 추가/열 추가 액션은 새 SVG 아이콘 버튼으로 압축해, 텍스트 때문에 보드 상단 툴바 높이가 과하게 커지던 문제를 정리함.
|
||||
- 미리보기와 삭제 모달 문구도 행/열 기준으로 함께 정리해, 전체 티어 에디터 흐름을 더 일관된 용어와 레이아웃으로 다듬음.
|
||||
|
||||
## 2026-04-01 v1.3.14
|
||||
- 티어 에디터를 단일 세로 랭크형에서 행/열 혼합 보드로 확장해, 공격·방어·지원 같은 가로 열을 추가하고 각 열 이름도 직접 입력할 수 있게 함.
|
||||
- 에디터 액션 문구를 `행 추가 / 열 추가` 기준으로 정리하고, 행 라벨 폭과 드래그 아이콘 위치를 다듬어 실제 사용 빈도에 맞는 더 압축된 보드 레이아웃으로 보정함.
|
||||
- 이름 오버레이 정렬과 저장용 미리보기 보드도 함께 손봐서, 이미지 다운로드 시 라벨 텍스트가 하단 중앙에 더 안정적으로 배치되도록 수정함.
|
||||
|
||||
## 2026-04-01 v1.3.13
|
||||
- 템플릿 등록/업데이트 요청 모달은 이제 현재 티어표 제목·설명을 기본값으로 가져오고, 비어 있더라도 모달 안에서 바로 작성해 요청할 수 있도록 흐름을 단순화함.
|
||||
- 템플릿 요청 시 `내 티어 리스트에도 저장` 토글을 추가해, 요청 스냅샷만 관리자에게 전달할지 아니면 현재 양식도 내 티어표로 함께 저장할지 분리함.
|
||||
- 관리자 템플릿 요청 관리는 더 이상 원본 티어표 링크에 의존하지 않고, 요청 시점의 그룹/아이템/이름표시 상태를 그대로 담은 스냅샷 미리보기를 직접 열어 확인할 수 있게 확장함.
|
||||
|
||||
## 2026-04-01 v1.3.12
|
||||
- 관리자 회원 관리 상단에 정렬 방향 선택을 추가해, 최근 활동순·가입순·작성 티어표순을 각각 오름차순/내림차순으로 다시 볼 수 있게 확장함.
|
||||
- 회원 정보 수정, 새 게임 생성, 비밀번호 초기화 모달은 Settings 톤 입력 스타일을 유지하면서 각 입력칸에 글자 수 피드백을 함께 보여주도록 정리함.
|
||||
- 로그인, 설정, 티어 에디터 제목·설명·요청 제목·요청 설명·티어 행 이름에도 최대 길이와 현재 입력 길이 안내를 붙여, 제출 전에 제한을 바로 인지할 수 있게 개선함.
|
||||
|
||||
## 2026-04-01 v1.3.11
|
||||
- **회원 관리 편집 모달 전환**: 관리자 회원 카드를 읽기 전용 정보 카드로 바꾸고, `회원 정보 수정` 버튼으로 Settings 톤의 편집 모달에서 이메일/닉네임/운영자 권한을 저장하도록 재구성
|
||||
- **회원 검색/정렬 추가**: 회원 관리 상단에 이메일/닉네임 검색과 `최근 활동순`, `가입순`, `작성 티어표 많은 순` 정렬을 추가해 운영자가 원하는 기준으로 목록을 다시 볼 수 있도록 확장
|
||||
- **최고 관리자 보호 도입**: 가장 먼저 생성된 관리자 계정을 `최고 관리자`로 구분하고, 운영자는 최고 관리자 권한/아바타/비밀번호/삭제를 변경할 수 없도록 백엔드 보호 로직과 역할 메타데이터를 추가
|
||||
|
||||
## 2026-04-01 v1.3.10
|
||||
- 게임 허브 공개 티어표 카드 그리드는 최소/최대 폭을 고정해, 목록이 1~2장뿐일 때도 카드가 화면 전체를 먹으며 과하게 커지지 않도록 보정함.
|
||||
- 티어표 행 삭제는 상단 아이콘 대신 우측 하단의 작은 텍스트 액션으로 바꿔, 랭크 카드 안에서 더 조용하고 정돈된 편집 흐름으로 정리함.
|
||||
- 공통 `SvgIcon` 컴포넌트를 추가하고 앱 셸, 홈 즐겨찾기, 관리자 회원 액션 같은 UI 아이콘은 `img` 대신 SVG 아이콘 컴포넌트로 렌더링하도록 전환함.
|
||||
|
||||
## 2026-04-01 v1.3.9
|
||||
- 관리자 오른쪽 사이드의 Image Optimization 패널은 이제 기본 탭인 목록 관리에서만 노출되도록 줄여, 게임/아이템/티어표/회원 관리 화면에서는 실제 작업 패널에 더 집중할 수 있게 정리함.
|
||||
- 커스텀 아이템 상세의 '이미 사용 중인 게임' 목록에서는 개인 보드용 freeform 템플릿을 제외하고, 실제 템플릿에 연결된 게임만 보이도록 다듬음.
|
||||
- 티어표 행 삭제는 큰 버튼 대신 우측 상단의 작은 x 아이콘으로 바꾸고, 삭제 시 아이템이 풀 영역으로 돌아간다는 안내를 포함한 확인 모달을 거친 뒤 삭제되도록 개선함.
|
||||
|
||||
## 2026-03-31 v1.3.8
|
||||
- 홈 화면 게임 즐겨찾기 버튼은 일반 문자 별 대신 'kid_star.svg' 아이콘을 사용하도록 바꿔, 기존 아이콘 시스템과 같은 문법으로 정리함.
|
||||
- 실제로 더 이상 참조되지 않는 예전 업로드 파일을 정리하는 레거시 업로드 클린업 스크립트를 추가하고, 루트/백엔드 실행 스크립트도 함께 연결함.
|
||||
- todo 문서도 이제 운영 반영 후 레거시 파일 정리 배치를 주기화하는 쪽으로 기준을 갱신함.
|
||||
|
||||
## 2026-03-31 v1.3.7
|
||||
- 현재 참조 중인 레거시 업로드를 다시 최적화 자산 경로로 편입하고 DB 참조를 일괄 교체하는 1회 마이그레이션 스크립트를 추가함.
|
||||
- 아바타/썸네일/아이템 역할에 따라 기존 업로드를 512px 또는 1280px 규격으로 다시 정리해, 실제 참조 경로도 '/uploads/assets/' 체계에 점진적으로 수렴시킬 수 있게 함.
|
||||
- 루트와 백엔드에 레거시 마이그레이션 실행 스크립트를 연결하고, todo 문서도 다음 단계 기준으로 갱신함.
|
||||
|
||||
## 2026-03-31 v1.3.6
|
||||
- 현재 참조 중인 레거시 업로드 파일을 'image_assets' 메타에 안전하게 편입하는 1회 백필 스크립트를 추가해, 과거 이미지도 최적화 대시보드와 같은 통계 체계 안에서 집계할 수 있게 함.
|
||||
- 루트와 백엔드에 백필 실행 스크립트를 연결해 운영 중 필요할 때 즉시 재실행할 수 있도록 정리함.
|
||||
- todo 문서의 즉시 확인 항목도 백필 완료 상태에 맞춰 후속 마이그레이션 과제로 갱신함.
|
||||
|
||||
## 2026-03-31 v1.3.5
|
||||
- 관리자 이미지 최적화 대시보드는 이제 'image_assets'만이 아니라 현재 실제로 참조 중인 업로드 파일 전체를 합산해, 기존 레거시 업로드까지 포함한 실사용 용량을 함께 보여주도록 확장함.
|
||||
- 최근 최적화 작업은 기본 12건으로 늘리고 6/12/24건 선택과 월 단위 필터를 지원해, 특정 기간 사용량과 최적화 이력을 운영 관점에서 바로 확인할 수 있게 정리함.
|
||||
|
||||
@@ -12,6 +12,7 @@ import iconLists from './assets/icons/lists.svg'
|
||||
import iconSearch from './assets/icons/search.svg'
|
||||
import iconSettings from './assets/icons/settings.svg'
|
||||
import RightRailAd from './components/RightRailAd.vue'
|
||||
import SvgIcon from './components/SvgIcon.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -302,7 +303,7 @@ function submitGlobalSearch() {
|
||||
<aside class="leftRail">
|
||||
<div class="leftRail__top railHeader">
|
||||
<button v-if="!isMobileLayout" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="왼쪽 패널 토글" @click="toggleLeftRail">
|
||||
<img :src="iconDockToRight" alt="" />
|
||||
<SvgIcon :src="iconDockToRight" :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -322,7 +323,7 @@ function submitGlobalSearch() {
|
||||
<form class="searchStub" @submit.prevent="submitGlobalSearch">
|
||||
<button class="searchStub__iconButton" type="button" :aria-label="leftRailCollapsed ? '검색 열기' : '검색'" @click="handleLeftRailSearch">
|
||||
<span class="searchStub__icon">
|
||||
<img :src="iconSearch" alt="" />
|
||||
<SvgIcon :src="iconSearch" :size="24" />
|
||||
</span>
|
||||
</button>
|
||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : searchPlaceholder" />
|
||||
@@ -339,7 +340,7 @@ function submitGlobalSearch() {
|
||||
:aria-label="leftRailCollapsed ? item.label : undefined"
|
||||
>
|
||||
<span class="leftNav__glyph">
|
||||
<img v-if="item.iconSrc" :src="item.iconSrc" alt="" />
|
||||
<SvgIcon v-if="item.iconSrc" :src="item.iconSrc" :size="24" />
|
||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
|
||||
</span>
|
||||
<span class="leftNav__label">{{ item.label }}</span>
|
||||
@@ -365,14 +366,14 @@ function submitGlobalSearch() {
|
||||
<div class="workspaceHead__actions">
|
||||
<div v-if="showGameHubViewToggle" class="viewToggle" role="group" aria-label="티어표 보기 방식">
|
||||
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': gameHubViewMode === 'grid' }" type="button" aria-label="그리드 보기" @click="setGameHubViewMode('grid')">
|
||||
<img :src="iconGridView" alt="" />
|
||||
<SvgIcon :src="iconGridView" :size="24" />
|
||||
</button>
|
||||
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': gameHubViewMode === 'list' }" type="button" aria-label="리스트 보기" @click="setGameHubViewMode('list')">
|
||||
<img :src="iconLists" alt="" />
|
||||
<SvgIcon :src="iconLists" :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
<button v-if="!rightRailOpen" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="패널 열기" @click="toggleRightRail">
|
||||
<img :src="iconDockToLeft" alt="" />
|
||||
<SvgIcon :src="iconDockToLeft" :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -385,7 +386,7 @@ function submitGlobalSearch() {
|
||||
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="searchPlaceholder" @click.self="closeCollapsedSearch">
|
||||
<form class="collapsedSearchBar" @submit.prevent="submitGlobalSearch">
|
||||
<span class="collapsedSearchBar__icon">
|
||||
<img :src="iconSearch" alt="" />
|
||||
<SvgIcon :src="iconSearch" :size="24" />
|
||||
</span>
|
||||
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="searchPlaceholder" autofocus />
|
||||
</form>
|
||||
@@ -396,7 +397,7 @@ function submitGlobalSearch() {
|
||||
<aside class="rightRail" :class="{ 'rightRail--closed': !rightRailOpen, 'rightRail--overlay': isRightRailOverlay }" :aria-hidden="!rightRailOpen">
|
||||
<div class="rightRail__top railHeader">
|
||||
<button v-if="rightRailOpen" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="패널 닫기" @click="toggleRightRail">
|
||||
<img :src="iconDockToLeft" alt="" />
|
||||
<SvgIcon :src="iconDockToLeft" :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="rightRail__body">
|
||||
|
||||
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/kid_star.svg
Normal file
1
frontend/src/assets/icons/kid_star.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="m305-704 112-145q12-16 28.5-23.5T480-880q18 0 34.5 7.5T543-849l112 145 170 57q26 8 41 29.5t15 47.5q0 12-3.5 24T866-523L756-367l4 164q1 35-23 59t-56 24q-2 0-22-3l-179-50-179 50q-5 2-11 2.5t-11 .5q-32 0-56-24t-23-59l4-165L95-523q-8-11-11.5-23T80-570q0-25 14.5-46.5T135-647l170-57Zm49 69-194 64 124 179-4 191 200-55 200 56-4-192 124-177-194-66-126-165-126 165Zm126 135Z"/></svg>
|
||||
|
After Width: | Height: | Size: 490 B |
@@ -78,14 +78,10 @@ onMounted(async () => {
|
||||
|
||||
.rightRailAd__frame {
|
||||
min-height: 520px;
|
||||
padding: 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.rightRailAd__slot {
|
||||
width: 100%;
|
||||
min-height: 490px;
|
||||
min-height: 520px;
|
||||
}
|
||||
</style>
|
||||
|
||||
38
frontend/src/components/SvgIcon.vue
Normal file
38
frontend/src/components/SvgIcon.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
src: { type: String, required: true },
|
||||
size: { type: [Number, String], default: 20 },
|
||||
color: { type: String, default: 'currentColor' },
|
||||
})
|
||||
|
||||
const normalizedSize = computed(() => (typeof props.size === "number" ? `${props.size}px` : props.size))
|
||||
const iconStyle = computed(() => ({
|
||||
"--svg-icon-src": `url("${props.src}")`,
|
||||
"--svg-icon-size": normalizedSize.value,
|
||||
"--svg-icon-color": props.color,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="svgIcon" :style="iconStyle" aria-hidden="true"></span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.svgIcon {
|
||||
display: inline-block;
|
||||
width: var(--svg-icon-size);
|
||||
height: var(--svg-icon-size);
|
||||
background-color: var(--svg-icon-color);
|
||||
-webkit-mask-image: var(--svg-icon-src);
|
||||
mask-image: var(--svg-icon-src);
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
mask-position: center;
|
||||
-webkit-mask-size: contain;
|
||||
mask-size: contain;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -62,7 +62,8 @@ export const api = {
|
||||
approveAdminTemplateRequest: (requestId, payload) =>
|
||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/approve`, { method: 'POST', body: payload || {} }),
|
||||
rejectAdminTemplateRequest: (requestId) => request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/reject`, { method: 'POST', body: {} }),
|
||||
listAdminUsers: () => request('/api/admin/users'),
|
||||
listAdminUsers: ({ q = '', sort = 'recent', direction = 'desc' } = {}) =>
|
||||
request(`/api/admin/users?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}&direction=${encodeURIComponent(direction)}`),
|
||||
updateAdminUser: (userId, payload) =>
|
||||
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
|
||||
updateAdminUserPassword: (userId, payload) =>
|
||||
@@ -100,7 +101,7 @@ export const api = {
|
||||
unfavoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'DELETE' }),
|
||||
deleteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
duplicateTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/duplicate`, { method: 'POST' }),
|
||||
requestTierListTemplate: (id, payload) => request(`/api/tierlists/${encodeURIComponent(id)}/template-request`, { method: 'POST', body: payload }),
|
||||
requestTierListTemplate: (payload) => request('/api/tierlists/template-request', { method: 'POST', body: payload }),
|
||||
saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }),
|
||||
uploadTierListThumbnail: async (file) => {
|
||||
const fd = new FormData()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import lockResetIcon from '../assets/icons/lock_reset.svg'
|
||||
import deleteIcon from '../assets/icons/delete.svg'
|
||||
import SvgIcon from '../components/SvgIcon.vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
@@ -46,6 +47,7 @@ const importModalNewGameId = ref('')
|
||||
const importModalNewGameName = ref('')
|
||||
const previewModalOpen = ref(false)
|
||||
const previewTierList = ref(null)
|
||||
const userEditModalOpen = ref(false)
|
||||
const userPasswordModalOpen = ref(false)
|
||||
const userDeleteModalOpen = ref(false)
|
||||
const userRoleModalOpen = ref(false)
|
||||
@@ -54,9 +56,15 @@ const customItemDeleteModalOpen = ref(false)
|
||||
const modalTargetUser = ref(null)
|
||||
const modalPasswordDraft = ref('')
|
||||
const modalRoleNextAdmin = ref(false)
|
||||
const modalUserDraftEmail = ref('')
|
||||
const modalUserDraftNickname = ref('')
|
||||
const modalUserDraftIsAdmin = ref(false)
|
||||
const modalTargetCustomItem = ref(null)
|
||||
|
||||
const users = ref([])
|
||||
const userQuery = ref('')
|
||||
const userSort = ref('recent')
|
||||
const userSortDirection = ref('desc')
|
||||
const imageStats = ref(null)
|
||||
const imageQueue = ref({ concurrency: 1, activeCount: 0, pendingCount: 0 })
|
||||
const imageRecentJobs = ref([])
|
||||
@@ -127,7 +135,7 @@ const adminOverviewStats = computed(() => {
|
||||
const publishedTierLists = adminTierLists.value.filter((tierList) => tierList.isPublic).length
|
||||
const pendingRequests = templateRequests.value.length
|
||||
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length
|
||||
const adminCount = users.value.filter((user) => user.isAdmin || user.draftIsAdmin).length
|
||||
const adminCount = users.value.filter((user) => user.isAdmin).length
|
||||
|
||||
if (activeTab.value === 'featured') {
|
||||
return [
|
||||
@@ -257,8 +265,54 @@ const imageDiagnosticsCards = computed(() => {
|
||||
{ label: '절감률', value: `${Math.round((stats.savingsRatio || 0) * 100)}%` },
|
||||
]
|
||||
})
|
||||
const visibleLinkedGames = computed(() =>
|
||||
(modalTargetCustomItem.value?.linkedGames || []).filter((game) => game?.id && game.id !== 'freeform')
|
||||
)
|
||||
|
||||
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
|
||||
const imageStatsYearOptions = computed(() => {
|
||||
const currentYear = new Date().getFullYear()
|
||||
return Array.from({ length: 6 }, (_, index) => String(currentYear - index))
|
||||
})
|
||||
const imageStatsMonthOptions = [
|
||||
{ value: '01', label: '1월' },
|
||||
{ value: '02', label: '2월' },
|
||||
{ value: '03', label: '3월' },
|
||||
{ value: '04', label: '4월' },
|
||||
{ value: '05', label: '5월' },
|
||||
{ value: '06', label: '6월' },
|
||||
{ value: '07', label: '7월' },
|
||||
{ value: '08', label: '8월' },
|
||||
{ value: '09', label: '9월' },
|
||||
{ value: '10', label: '10월' },
|
||||
{ value: '11', label: '11월' },
|
||||
{ value: '12', label: '12월' },
|
||||
]
|
||||
const selectedImageStatsYear = computed({
|
||||
get: () => (imageStatsMonth.value ? imageStatsMonth.value.slice(0, 4) : ''),
|
||||
set: (year) => {
|
||||
if (!year) {
|
||||
imageStatsMonth.value = ''
|
||||
return
|
||||
}
|
||||
const month = imageStatsMonth.value ? imageStatsMonth.value.slice(5, 7) : '01'
|
||||
imageStatsMonth.value = `${year}-${month}`
|
||||
},
|
||||
})
|
||||
const selectedImageStatsMonthNumber = computed({
|
||||
get: () => (imageStatsMonth.value ? imageStatsMonth.value.slice(5, 7) : ''),
|
||||
set: (month) => {
|
||||
if (!month) {
|
||||
imageStatsMonth.value = ''
|
||||
return
|
||||
}
|
||||
const year = imageStatsMonth.value ? imageStatsMonth.value.slice(0, 4) : String(new Date().getFullYear())
|
||||
imageStatsMonth.value = `${year}-${month}`
|
||||
},
|
||||
})
|
||||
function clearImageStatsMonth() {
|
||||
imageStatsMonth.value = ''
|
||||
}
|
||||
|
||||
async function refreshImageDiagnostics() {
|
||||
try {
|
||||
@@ -347,9 +401,25 @@ function setUserAvatarInput(userId, el) {
|
||||
userAvatarInputs.value[userId] = el
|
||||
}
|
||||
|
||||
function isUserDirty(user) {
|
||||
if (!user) return false
|
||||
return user.draftEmail !== user.email || (user.draftNickname || '') !== (user.nickname || '') || !!user.draftIsAdmin !== !!user.isAdmin
|
||||
const canManageModalRole = computed(() => {
|
||||
if (!auth.user?.isPrimaryAdmin) return false
|
||||
if (!modalTargetUser.value) return false
|
||||
return !modalTargetUser.value.isPrimaryAdmin
|
||||
})
|
||||
|
||||
const isUserEditDirty = computed(() => {
|
||||
if (!modalTargetUser.value) return false
|
||||
return (
|
||||
modalUserDraftEmail.value.trim() !== (modalTargetUser.value.email || '') ||
|
||||
modalUserDraftNickname.value.trim() !== (modalTargetUser.value.nickname || '') ||
|
||||
!!modalUserDraftIsAdmin.value !== !!modalTargetUser.value.isAdmin
|
||||
)
|
||||
})
|
||||
|
||||
function roleLabelOf(user) {
|
||||
if (user?.isPrimaryAdmin) return '최고 관리자'
|
||||
if (user?.isAdmin) return '운영자'
|
||||
return '일반 회원'
|
||||
}
|
||||
|
||||
function openUserAvatarPicker(user) {
|
||||
@@ -368,17 +438,14 @@ async function uploadUserAvatar(user, file, { remove = false } = {}) {
|
||||
entry.id === updated.id
|
||||
? {
|
||||
...entry,
|
||||
avatarSrc: updated.avatarSrc || '',
|
||||
email: updated.email,
|
||||
nickname: updated.nickname || '',
|
||||
isAdmin: !!updated.isAdmin,
|
||||
draftEmail: updated.email,
|
||||
draftNickname: updated.nickname || '',
|
||||
draftIsAdmin: !!updated.isAdmin,
|
||||
...updated,
|
||||
isAvatarBusy: false,
|
||||
}
|
||||
: entry
|
||||
)
|
||||
if (modalTargetUser.value?.id === updated.id) {
|
||||
modalTargetUser.value = { ...modalTargetUser.value, ...updated }
|
||||
}
|
||||
if (updated.id === auth.user?.id) await auth.refresh()
|
||||
await refreshUsers()
|
||||
success.value = remove ? '회원 썸네일을 삭제했어요.' : '회원 썸네일을 업데이트했어요.'
|
||||
@@ -492,12 +559,9 @@ async function refreshTemplateRequests() {
|
||||
async function refreshUsers() {
|
||||
if (!auth.user?.isAdmin) return
|
||||
try {
|
||||
const data = await api.listAdminUsers()
|
||||
const data = await api.listAdminUsers({ q: userQuery.value, sort: userSort.value, direction: userSortDirection.value })
|
||||
users.value = (data.users || []).map((user) => ({
|
||||
...user,
|
||||
draftEmail: user.email,
|
||||
draftNickname: user.nickname || '',
|
||||
draftIsAdmin: !!user.isAdmin,
|
||||
isAvatarBusy: false,
|
||||
}))
|
||||
} catch (e) {
|
||||
@@ -774,29 +838,45 @@ async function removeGame() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUser(user) {
|
||||
function openUserEditModal(user) {
|
||||
resetMessages()
|
||||
modalTargetUser.value = user ? { ...user } : null
|
||||
modalUserDraftEmail.value = user?.email || ''
|
||||
modalUserDraftNickname.value = user?.nickname || ''
|
||||
modalUserDraftIsAdmin.value = !!user?.isAdmin
|
||||
userEditModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeUserEditModal() {
|
||||
userEditModalOpen.value = false
|
||||
modalTargetUser.value = null
|
||||
modalUserDraftEmail.value = ''
|
||||
modalUserDraftNickname.value = ''
|
||||
modalUserDraftIsAdmin.value = false
|
||||
}
|
||||
|
||||
async function saveUserEdit() {
|
||||
resetMessages()
|
||||
if (!modalTargetUser.value?.id) return
|
||||
|
||||
try {
|
||||
const data = await api.updateAdminUser(user.id, {
|
||||
email: user.draftEmail,
|
||||
nickname: user.draftNickname,
|
||||
isAdmin: !!user.draftIsAdmin,
|
||||
const data = await api.updateAdminUser(modalTargetUser.value.id, {
|
||||
email: modalUserDraftEmail.value.trim(),
|
||||
nickname: modalUserDraftNickname.value.trim(),
|
||||
isAdmin: !!modalUserDraftIsAdmin.value,
|
||||
})
|
||||
const updated = data.user
|
||||
users.value = users.value.map((entry) =>
|
||||
entry.id === updated.id
|
||||
? {
|
||||
...entry,
|
||||
email: updated.email,
|
||||
nickname: updated.nickname || '',
|
||||
isAdmin: !!updated.isAdmin,
|
||||
draftEmail: updated.email,
|
||||
draftNickname: updated.nickname || '',
|
||||
draftIsAdmin: !!updated.isAdmin,
|
||||
...updated,
|
||||
isAvatarBusy: entry.isAvatarBusy || false,
|
||||
}
|
||||
: entry
|
||||
)
|
||||
if (updated.id === auth.user?.id) await auth.refresh()
|
||||
closeUserEditModal()
|
||||
await refreshUsers()
|
||||
success.value = '회원 정보를 저장했어요.'
|
||||
} catch (e) {
|
||||
@@ -806,7 +886,7 @@ async function saveUser(user) {
|
||||
|
||||
function openUserPasswordModal(user) {
|
||||
resetMessages()
|
||||
modalTargetUser.value = user || null
|
||||
modalTargetUser.value = user ? { ...user } : null
|
||||
modalPasswordDraft.value = ''
|
||||
userPasswordModalOpen.value = true
|
||||
}
|
||||
@@ -838,7 +918,7 @@ async function confirmUserPasswordReset() {
|
||||
|
||||
function openUserDeleteModal(user) {
|
||||
resetMessages()
|
||||
modalTargetUser.value = user || null
|
||||
modalTargetUser.value = user ? { ...user } : null
|
||||
userDeleteModalOpen.value = true
|
||||
}
|
||||
|
||||
@@ -865,34 +945,31 @@ async function confirmUserDelete() {
|
||||
}
|
||||
|
||||
|
||||
function openUserRoleModal(user) {
|
||||
function openUserRoleModal(user, nextIsAdmin = !modalUserDraftIsAdmin.value) {
|
||||
resetMessages()
|
||||
modalTargetUser.value = user || null
|
||||
modalRoleNextAdmin.value = !user?.draftIsAdmin
|
||||
modalTargetUser.value = user ? { ...user } : null
|
||||
modalRoleNextAdmin.value = !!nextIsAdmin
|
||||
userRoleModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeUserRoleModal() {
|
||||
userRoleModalOpen.value = false
|
||||
modalTargetUser.value = null
|
||||
if (!userEditModalOpen.value) modalTargetUser.value = null
|
||||
modalRoleNextAdmin.value = false
|
||||
}
|
||||
|
||||
function confirmUserRoleDraft() {
|
||||
if (!modalTargetUser.value?.id) return
|
||||
users.value = users.value.map((entry) =>
|
||||
entry.id === modalTargetUser.value.id
|
||||
? {
|
||||
...entry,
|
||||
draftIsAdmin: modalRoleNextAdmin.value,
|
||||
}
|
||||
: entry
|
||||
)
|
||||
const targetLabel = modalRoleNextAdmin.value ? '관리자로 지정했어요. 저장하면 반영됩니다.' : '관리자 권한 해제로 표시했어요. 저장하면 반영됩니다.'
|
||||
modalUserDraftIsAdmin.value = modalRoleNextAdmin.value
|
||||
const targetLabel = modalRoleNextAdmin.value ? '운영자 권한을 저장 대기 상태로 반영했어요.' : '운영자 권한 해제를 저장 대기 상태로 반영했어요.'
|
||||
closeUserRoleModal()
|
||||
success.value = targetLabel
|
||||
}
|
||||
|
||||
function submitUserFilters() {
|
||||
refreshUsers()
|
||||
}
|
||||
|
||||
function submitCustomItemSearch() {
|
||||
customItemPage.value = 1
|
||||
refreshCustomItems()
|
||||
@@ -1031,6 +1108,38 @@ function openAdminTierList(tierList) {
|
||||
previewModalOpen.value = true
|
||||
}
|
||||
|
||||
function previewRequestItemsById(preview) {
|
||||
const items = Array.isArray(preview?.snapshotItems) ? preview.snapshotItems : []
|
||||
return items.reduce((acc, item) => {
|
||||
if (item?.id) acc[item.id] = item
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
function previewRequestGroupItems(preview, group) {
|
||||
const itemsById = previewRequestItemsById(preview)
|
||||
return (group?.itemIds || []).map((itemId) => itemsById[itemId]).filter(Boolean)
|
||||
}
|
||||
|
||||
function previewRequestPoolItems(preview) {
|
||||
const groupedIds = new Set((preview?.snapshotGroups || []).flatMap((group) => group.itemIds || []))
|
||||
return (preview?.snapshotItems || []).filter((item) => !groupedIds.has(item.id))
|
||||
}
|
||||
|
||||
function openTemplateRequestPreview(request) {
|
||||
previewTierList.value = {
|
||||
id: request.id,
|
||||
title: request.sourceTierListTitle || '템플릿 요청 미리보기',
|
||||
description: request.sourceDescription || '',
|
||||
thumbnailSrc: request.thumbnailSrc || '',
|
||||
requestPreview: true,
|
||||
snapshotGroups: request.snapshotGroups || [],
|
||||
snapshotItems: request.snapshotItems || [],
|
||||
snapshotShowCharacterNames: !!request.snapshotShowCharacterNames,
|
||||
}
|
||||
previewModalOpen.value = true
|
||||
}
|
||||
|
||||
function closePreviewModal() {
|
||||
previewModalOpen.value = false
|
||||
previewTierList.value = null
|
||||
@@ -1436,13 +1545,14 @@ async function saveFeaturedOrder() {
|
||||
<div class="templateRequestCard__head">
|
||||
<div>
|
||||
<div class="templateRequestCard__title">{{ request.sourceTierListTitle }}</div>
|
||||
<div v-if="request.sourceDescription" class="templateRequestCard__desc">{{ request.sourceDescription }}</div>
|
||||
<div class="templateRequestCard__meta">
|
||||
{{ templateRequestTypeLabel(request) }} · {{ request.requesterName }} · {{ fmt(request.createdAt) }}
|
||||
</div>
|
||||
<div class="templateRequestCard__meta">{{ templateRequestTargetLabel(request) }}</div>
|
||||
</div>
|
||||
<button class="btn btn--ghost btn--small" @click="openAdminTierList({ id: request.sourceTierListId, gameId: request.sourceGameId })">
|
||||
원본 보기
|
||||
<button class="btn btn--ghost btn--small" @click="openTemplateRequestPreview(request)">
|
||||
요청 미리보기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1544,6 +1654,25 @@ async function saveFeaturedOrder() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar toolbar--secondary">
|
||||
<input
|
||||
v-model="userQuery"
|
||||
class="input toolbar__search"
|
||||
placeholder="이메일, 닉네임 검색"
|
||||
@keydown.enter.prevent="submitUserFilters"
|
||||
/>
|
||||
<select v-model="userSort" class="select toolbar__select" @change="submitUserFilters">
|
||||
<option value="recent">최근 활동순</option>
|
||||
<option value="created">가입순</option>
|
||||
<option value="tierlists">작성 티어표 많은 순</option>
|
||||
</select>
|
||||
<select v-model="userSortDirection" class="select toolbar__select" @change="submitUserFilters">
|
||||
<option value="desc">내림차순</option>
|
||||
<option value="asc">오름차순</option>
|
||||
</select>
|
||||
<button class="btn btn--ghost toolbar__button" type="button" @click="submitUserFilters">조회</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!users.length" class="hint">아직 가입한 회원이 없어요.</div>
|
||||
<div v-else class="userList">
|
||||
<article v-for="user in users" :key="user.id" class="userCard">
|
||||
@@ -1570,7 +1699,7 @@ async function saveFeaturedOrder() {
|
||||
:disabled="user.isAvatarBusy"
|
||||
@click.stop="removeUserAvatar(user)"
|
||||
>
|
||||
<img :src="deleteIcon" alt="" />
|
||||
<SvgIcon class="userAvatarRemoveIcon" :src="deleteIcon" :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="userCard__identityMeta">
|
||||
@@ -1580,33 +1709,25 @@ async function saveFeaturedOrder() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="user.draftIsAdmin" class="roleBadge userCard__roleBadge">Administrator</div>
|
||||
<div v-if="user.isAdmin" class="roleBadge userCard__roleBadge">{{ roleLabelOf(user) }}</div>
|
||||
|
||||
<div class="userInfoList">
|
||||
<div class="userInfoLine"><span>가입일</span><strong>{{ fmt(user.createdAt) }}</strong></div>
|
||||
<div class="userInfoLine"><span>작성 티어표</span><strong>{{ user.tierListCount }}개</strong></div>
|
||||
<div class="userInfoLine"><span>최근 활동</span><strong>{{ fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
|
||||
<div class="userInfoLine"><span>계정명</span><strong>{{ user.email }}</strong></div>
|
||||
<div class="userInfoLine"><span>닉네임</span><strong>{{ user.nickname || '미설정' }}</strong></div>
|
||||
<div class="userInfoLine"><span>권한</span><strong>{{ roleLabelOf(user) }}</strong></div>
|
||||
</div>
|
||||
|
||||
<input v-model="user.draftEmail" class="input" placeholder="이메일" />
|
||||
<input v-model="user.draftNickname" class="input" placeholder="닉네임" />
|
||||
<button
|
||||
class="userRoleAction"
|
||||
type="button"
|
||||
:disabled="user.id === auth.user?.id"
|
||||
@click="openUserRoleModal(user)"
|
||||
>
|
||||
{{ user.draftIsAdmin ? '관리자 권한 해제' : '관리자 권한 임명' }}
|
||||
</button>
|
||||
|
||||
<div class="userCard__actions userCard__actions--compact">
|
||||
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="openUserPasswordModal(user)">
|
||||
<img :src="lockResetIcon" alt="" />
|
||||
<SvgIcon class="iconActionButton__icon" :src="lockResetIcon" :size="18" />
|
||||
</button>
|
||||
<button class="iconActionButton iconActionButton--danger" type="button" title="회원 삭제" @click="openUserDeleteModal(user)">
|
||||
<img :src="deleteIcon" alt="" />
|
||||
<SvgIcon class="iconActionButton__icon" :src="deleteIcon" :size="18" />
|
||||
</button>
|
||||
<button class="btn btn--ghost userSaveButton" :disabled="!isUserDirty(user)" @click="saveUser(user)">회원정보 저장</button>
|
||||
<button class="btn btn--ghost userSaveButton" type="button" @click="openUserEditModal(user)">회원 정보 수정</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -1618,8 +1739,22 @@ async function saveFeaturedOrder() {
|
||||
<div class="modalCard__title">새 게임 만들기</div>
|
||||
<div class="modalCard__desc">게임 이름과 고유 ID를 입력한 뒤 생성하면 바로 아래 상세 관리 화면으로 이어집니다.</div>
|
||||
<div class="modalCard__form">
|
||||
<input v-model="newGameName" class="input" placeholder="게임 이름" />
|
||||
<input v-model="newGameId" class="input" placeholder="game id (영문/숫자)" @keydown.enter.prevent="createGame" />
|
||||
<label class="field">
|
||||
<span class="field__label">게임 이름</span>
|
||||
<input v-model="newGameName" class="field__input" maxlength="60" placeholder="게임 이름" />
|
||||
<span class="field__hint">{{ newGameName.length }}/60자</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">게임 ID</span>
|
||||
<input
|
||||
v-model="newGameId"
|
||||
class="field__input"
|
||||
maxlength="120"
|
||||
placeholder="game id (영문/숫자)"
|
||||
@keydown.enter.prevent="createGame"
|
||||
/>
|
||||
<span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newGameId.length }}/120자</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeGameCreateModal">취소</button>
|
||||
@@ -1628,12 +1763,56 @@ async function saveFeaturedOrder() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userEditModalOpen" class="modalOverlay" @click.self="closeUserEditModal">
|
||||
<div class="modalCard modalCard--userEdit" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">회원 정보 수정</div>
|
||||
<div class="modalCard__desc">{{ modalTargetUser ? `${userDisplayName(modalTargetUser)} 계정의 정보와 권한을 조정할 수 있어요.` : '' }}</div>
|
||||
<div class="userEditForm">
|
||||
<label class="field">
|
||||
<span class="field__label">이메일</span>
|
||||
<input v-model="modalUserDraftEmail" class="field__input" maxlength="255" placeholder="계정 이메일" />
|
||||
<span class="field__hint">로그인 계정으로 사용하는 이메일입니다. {{ modalUserDraftEmail.length }}/255자</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field__label">닉네임</span>
|
||||
<input v-model="modalUserDraftNickname" class="field__input" maxlength="40" placeholder="표시용 닉네임" />
|
||||
<span class="field__hint">티어표 작성자명과 프로필에 표시됩니다. {{ modalUserDraftNickname.length }}/40자</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
v-if="canManageModalRole"
|
||||
class="userRoleAction"
|
||||
type="button"
|
||||
@click="openUserRoleModal(modalTargetUser, !modalUserDraftIsAdmin)"
|
||||
>
|
||||
{{ modalUserDraftIsAdmin ? '운영자 권한 해제' : '운영자 권한 부여' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeUserEditModal">취소</button>
|
||||
<button class="btn btn--primary" :disabled="!isUserEditDirty" @click="saveUserEdit">회원 정보 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userPasswordModalOpen" class="modalOverlay" @click.self="closeUserPasswordModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">비밀번호 초기화</div>
|
||||
<div class="modalCard__desc">{{ modalTargetUser ? `${userDisplayName(modalTargetUser)} 계정에 설정할 새 비밀번호를 입력해주세요.` : '' }}</div>
|
||||
<div class="modalCard__form">
|
||||
<input v-model="modalPasswordDraft" class="input" type="password" placeholder="초기화할 비밀번호 입력" @keydown.enter.prevent="confirmUserPasswordReset" />
|
||||
<label class="field">
|
||||
<span class="field__label">새 비밀번호</span>
|
||||
<input
|
||||
v-model="modalPasswordDraft"
|
||||
class="field__input"
|
||||
type="password"
|
||||
maxlength="120"
|
||||
placeholder="초기화할 비밀번호 입력"
|
||||
@keydown.enter.prevent="confirmUserPasswordReset"
|
||||
/>
|
||||
<span class="field__hint">6~120자 권장 · {{ modalPasswordDraft.length }}/120자</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeUserPasswordModal">취소</button>
|
||||
@@ -1655,13 +1834,13 @@ async function saveFeaturedOrder() {
|
||||
|
||||
<div v-if="userRoleModalOpen" class="modalOverlay" @click.self="closeUserRoleModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">관리자 권한 변경</div>
|
||||
<div class="modalCard__title">운영자 권한 변경</div>
|
||||
<div class="modalCard__desc">
|
||||
{{
|
||||
modalTargetUser
|
||||
? modalRoleNextAdmin
|
||||
? `${userDisplayName(modalTargetUser)} 사용자를 관리자로 임명할까요?`
|
||||
: `${userDisplayName(modalTargetUser)} 사용자의 관리자 권한을 해제할까요?`
|
||||
? `${userDisplayName(modalTargetUser)} 사용자를 운영자로 지정할까요?`
|
||||
: `${userDisplayName(modalTargetUser)} 사용자의 운영자 권한을 해제할까요?`
|
||||
: ''
|
||||
}}
|
||||
</div>
|
||||
@@ -1726,11 +1905,11 @@ async function saveFeaturedOrder() {
|
||||
</select>
|
||||
</div>
|
||||
<div class="customItemModal__linked">
|
||||
<span class="customItemModal__label">이미 사용 중인 게임</span>
|
||||
<div v-if="modalTargetCustomItem.linkedGames?.length" class="customItemModal__chips">
|
||||
<span v-for="game in modalTargetCustomItem.linkedGames" :key="game.id" class="pill">{{ game.name }}</span>
|
||||
<span class="customItemModal__label">템플릿에 사용 중인 게임</span>
|
||||
<div v-if="visibleLinkedGames.length" class="customItemModal__chips">
|
||||
<span v-for="game in visibleLinkedGames" :key="game.id" class="pill">{{ game.name }}</span>
|
||||
</div>
|
||||
<div v-else class="hint hint--tight">아직 연결된 게임이 없어요.</div>
|
||||
<div v-else class="hint hint--tight">아직 템플릿에 연결된 게임이 없어요.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="customItemModal__body">
|
||||
@@ -1783,8 +1962,45 @@ async function saveFeaturedOrder() {
|
||||
<div class="modalCard__title">{{ previewTierList?.title || '티어표 미리보기' }}</div>
|
||||
<button class="btn btn--ghost btn--small" @click="closePreviewModal">닫기</button>
|
||||
</div>
|
||||
<div v-if="previewTierList?.requestPreview" class="requestPreview">
|
||||
<img
|
||||
v-if="previewTierList.thumbnailSrc"
|
||||
class="requestPreview__thumb"
|
||||
:src="toApiUrl(previewTierList.thumbnailSrc)"
|
||||
:alt="previewTierList.title"
|
||||
/>
|
||||
<div v-if="previewTierList.description" class="requestPreview__desc">{{ previewTierList.description }}</div>
|
||||
<div class="requestPreview__rows">
|
||||
<div v-for="group in previewTierList.snapshotGroups" :key="group.id" class="requestPreview__row">
|
||||
<div class="requestPreview__rowLabel">{{ group.name }}</div>
|
||||
<div class="requestPreview__rowItems">
|
||||
<div
|
||||
v-for="item in previewRequestGroupItems(previewTierList, group)"
|
||||
:key="item.id"
|
||||
class="requestPreview__item"
|
||||
>
|
||||
<img class="requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<div v-if="previewTierList.snapshotShowCharacterNames" class="requestPreview__itemLabel">{{ item.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="previewRequestPoolItems(previewTierList).length" class="requestPreview__pool">
|
||||
<div class="requestPreview__poolLabel">남은 아이템</div>
|
||||
<div class="requestPreview__rowItems">
|
||||
<div
|
||||
v-for="item in previewRequestPoolItems(previewTierList)"
|
||||
:key="item.id"
|
||||
class="requestPreview__item requestPreview__item--muted"
|
||||
>
|
||||
<img class="requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<div v-if="previewTierList.snapshotShowCharacterNames" class="requestPreview__itemLabel">{{ item.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
v-if="previewTierList"
|
||||
v-else-if="previewTierList"
|
||||
class="previewFrame"
|
||||
:src="previewTierListUrl(previewTierList)"
|
||||
title="티어표 미리보기"
|
||||
@@ -1925,10 +2141,20 @@ async function saveFeaturedOrder() {
|
||||
</section>
|
||||
|
||||
|
||||
<section class="adminSidebar__panel">
|
||||
<section v-if="activeTab === 'featured'" class="adminSidebar__panel">
|
||||
<div class="adminSidebar__label">Image Optimization</div>
|
||||
<div class="adminSidebar__group">
|
||||
<input v-model="imageStatsMonth" class="input" type="month" />
|
||||
<div class="adminSidebar__group adminSidebar__group--monthPicker">
|
||||
<div class="monthPicker">
|
||||
<select v-model="selectedImageStatsYear" class="select monthPicker__select monthPicker__select--year">
|
||||
<option value="">전체 기간</option>
|
||||
<option v-for="year in imageStatsYearOptions" :key="year" :value="year">{{ year }}년</option>
|
||||
</select>
|
||||
<select v-if="selectedImageStatsYear" v-model="selectedImageStatsMonthNumber" class="select monthPicker__select monthPicker__select--month">
|
||||
<option value="">월 선택</option>
|
||||
<option v-for="month in imageStatsMonthOptions" :key="month.value" :value="month.value">{{ month.label }}</option>
|
||||
</select>
|
||||
<button v-if="imageStatsMonth" class="btn btn--ghost btn--tiny" type="button" @click="clearImageStatsMonth">전체</button>
|
||||
</div>
|
||||
<select v-model.number="imageStatsLimit" class="select">
|
||||
<option :value="6">최근 6건</option>
|
||||
<option :value="12">최근 12건</option>
|
||||
@@ -2115,6 +2341,24 @@ async function saveFeaturedOrder() {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.adminSidebar__group--monthPicker {
|
||||
align-items: start;
|
||||
}
|
||||
.monthPicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.monthPicker__select {
|
||||
min-width: 0;
|
||||
}
|
||||
.monthPicker__select--year {
|
||||
flex: 1 1 132px;
|
||||
}
|
||||
.monthPicker__select--month {
|
||||
flex: 1 1 108px;
|
||||
}
|
||||
.adminSidebar__actions--stack .btn {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -2455,11 +2699,11 @@ async function saveFeaturedOrder() {
|
||||
border: 0;
|
||||
}
|
||||
.btn {
|
||||
height: 100%;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
word-break: keep-all;
|
||||
margin-top: 12px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
@@ -2925,10 +3169,8 @@ async function saveFeaturedOrder() {
|
||||
transform: translateY(2px) scale(0.96);
|
||||
transition: opacity 160ms ease, transform 160ms ease, background 160ms ease, visibility 160ms ease;
|
||||
}
|
||||
.userAvatarRemoveButton img {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
filter: brightness(0) invert(1);
|
||||
.userAvatarRemoveIcon {
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
.userAvatarRemoveButton:disabled {
|
||||
opacity: 0.45;
|
||||
@@ -2993,6 +3235,44 @@ async function saveFeaturedOrder() {
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.field__label {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
.field__input {
|
||||
width: 100%;
|
||||
padding: 14px 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
outline: none;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.field__input:focus {
|
||||
border-bottom-color: rgba(96, 165, 250, 0.9);
|
||||
}
|
||||
.field__hint {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
|
||||
.userEditForm {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.userEditForm .field {
|
||||
gap: 10px;
|
||||
}
|
||||
.modalCard--userEdit {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.userCard__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -3001,6 +3281,7 @@ async function saveFeaturedOrder() {
|
||||
.userCard__actions--compact {
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.roleBadge {
|
||||
width: fit-content;
|
||||
@@ -3030,9 +3311,8 @@ async function saveFeaturedOrder() {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
cursor: pointer;
|
||||
}
|
||||
.iconActionButton img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
.iconActionButton__icon {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.iconActionButton:disabled {
|
||||
cursor: not-allowed;
|
||||
@@ -3085,6 +3365,12 @@ async function saveFeaturedOrder() {
|
||||
font-weight: 900;
|
||||
font-size: 18px;
|
||||
}
|
||||
.templateRequestCard__desc {
|
||||
margin-top: 6px;
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
line-height: 1.55;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.templateRequestCard__meta {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
@@ -3133,6 +3419,76 @@ async function saveFeaturedOrder() {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.requestPreview {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.requestPreview__thumb {
|
||||
width: 100%;
|
||||
max-height: 240px;
|
||||
object-fit: cover;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.requestPreview__desc {
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
line-height: 1.6;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.requestPreview__rows,
|
||||
.requestPreview__pool {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.requestPreview__row {
|
||||
display: grid;
|
||||
grid-template-columns: 88px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
.requestPreview__rowLabel,
|
||||
.requestPreview__poolLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
.requestPreview__rowItems {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.requestPreview__item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
min-height: 72px;
|
||||
}
|
||||
.requestPreview__item--muted {
|
||||
opacity: 0.52;
|
||||
filter: grayscale(0.2) brightness(0.78);
|
||||
}
|
||||
.requestPreview__itemThumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.requestPreview__itemLabel {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0.7));
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.tierAdminList {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
|
||||
@@ -222,7 +222,8 @@ function submitSearch() {
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 320px));
|
||||
justify-content: start;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import SvgIcon from '../components/SvgIcon.vue'
|
||||
import kidStarIcon from '../assets/icons/kid_star.svg'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
@@ -91,7 +93,7 @@ function thumbUrl(g) {
|
||||
:disabled="loadingFavoriteId === g.id"
|
||||
@click.stop="toggleFavorite(g, $event)"
|
||||
>
|
||||
{{ g.isFavorited ? '★' : '☆' }}
|
||||
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
|
||||
</button>
|
||||
<button class="libraryCard__main" type="button" @click="goGame(g.id)">
|
||||
<div class="libraryCard__thumbWrap">
|
||||
@@ -169,8 +171,20 @@ function thumbUrl(g) {
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.libraryCard__favorite--active {
|
||||
background: rgba(54, 45, 10, 0.92);
|
||||
border-color: rgba(255, 216, 107, 0.28);
|
||||
}
|
||||
.libraryCard__favoriteIcon {
|
||||
opacity: 0.76;
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
.libraryCard__favorite--active .libraryCard__favoriteIcon {
|
||||
opacity: 1;
|
||||
color: #ffd86b;
|
||||
}
|
||||
.libraryCard__thumbWrap {
|
||||
|
||||
@@ -79,8 +79,8 @@ async function submit() {
|
||||
<form class="authFields" @submit.prevent="submit">
|
||||
<label class="field">
|
||||
<span class="field__label">이메일</span>
|
||||
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" />
|
||||
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다.</span>
|
||||
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" maxlength="255" />
|
||||
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다. {{ email.length }}/255자</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
@@ -91,8 +91,9 @@ async function submit() {
|
||||
type="password"
|
||||
placeholder="********"
|
||||
autocomplete="current-password"
|
||||
maxlength="120"
|
||||
/>
|
||||
<span class="field__hint">8자 이상으로 설정하면 더 안전하게 사용할 수 있어요.</span>
|
||||
<span class="field__hint">6~120자 입력 가능 · {{ password.length }}/120자</span>
|
||||
</label>
|
||||
|
||||
<label v-if="mode === 'signup'" class="field">
|
||||
@@ -103,8 +104,9 @@ async function submit() {
|
||||
type="password"
|
||||
placeholder="********"
|
||||
autocomplete="new-password"
|
||||
maxlength="120"
|
||||
/>
|
||||
<span class="field__hint">같은 비밀번호를 한 번 더 입력해주세요.</span>
|
||||
<span class="field__hint">같은 비밀번호를 한 번 더 입력해주세요. {{ passwordConfirm.length }}/120자</span>
|
||||
</label>
|
||||
|
||||
<div v-if="!hasUsers" class="roleBadge">첫 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ const router = useRouter()
|
||||
const toast = useToast()
|
||||
const myLists = ref([])
|
||||
const error = ref('')
|
||||
const brokenThumbnailIds = ref({})
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
@@ -37,12 +38,19 @@ function avatarFallbackOf(tierList) {
|
||||
}
|
||||
|
||||
function tierListThumbnailUrl(tierList) {
|
||||
if (!tierList?.id || brokenThumbnailIds.value[tierList.id]) return ''
|
||||
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
|
||||
}
|
||||
|
||||
function handleThumbnailError(tierListId) {
|
||||
if (!tierListId || brokenThumbnailIds.value[tierListId]) return
|
||||
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await api.listMyTierLists()
|
||||
brokenThumbnailIds.value = {}
|
||||
myLists.value = data.tierLists || []
|
||||
} catch (e) {
|
||||
toast.error('로그인이 필요해요.')
|
||||
@@ -51,53 +59,87 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
function openList(t) {
|
||||
router.push(`/editor/${t.gameId}/${t.id}`)
|
||||
router.push(
|
||||
"/editor/" + t.gameId + "/" + t.id,
|
||||
)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pageWrap">
|
||||
<header class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Library</div>
|
||||
<h2 class="pageHead__title">내 티어표</h2>
|
||||
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="card">
|
||||
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="t in myLists" :key="t.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openList(t)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
<section class="dashboardHero">
|
||||
<div class="dashboardHero__left">
|
||||
<div class="dashboardHero__eyebrow">Library</div>
|
||||
<h2 class="dashboardHero__title">내 티어표</h2>
|
||||
<p class="dashboardHero__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="t in myLists" :key="t.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openList(t)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img
|
||||
v-if="tierListThumbnailUrl(t)"
|
||||
class="boardCard__thumb"
|
||||
:src="tierListThumbnailUrl(t)"
|
||||
alt=""
|
||||
@error="handleThumbnailError(t.id)"
|
||||
/>
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="boardCard__head">
|
||||
<div class="boardCard__titleRow">
|
||||
<div class="boardCard__title">{{ t.title }}</div>
|
||||
<div class="favoriteStat">♡ {{ t.favoriteCount || 0 }}</div>
|
||||
</div>
|
||||
<div class="boardCard__head">
|
||||
<div class="boardCard__titleRow">
|
||||
<div class="boardCard__title">{{ t.title }}</div>
|
||||
<div class="favoriteStat">♡ {{ t.favoriteCount || 0 }}</div>
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
|
||||
</div>
|
||||
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
|
||||
</div>
|
||||
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border: 0;
|
||||
.dashboardHero {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 2px 18px;
|
||||
}
|
||||
.dashboardHero__left {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.dashboardHero__eyebrow {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.dashboardHero__title {
|
||||
margin: 4px 0 6px;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.04em;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
.dashboardHero__desc {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
max-width: 720px;
|
||||
}
|
||||
.panel {
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
@@ -107,12 +149,10 @@ function openList(t) {
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 320px));
|
||||
justify-content: start;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.boardCard {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
@@ -129,7 +169,6 @@ function openList(t) {
|
||||
background: rgba(70, 70, 70, 0.96);
|
||||
}
|
||||
.boardCard__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
@@ -137,9 +176,12 @@ function openList(t) {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard__thumbWrap {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
padding: 14px 14px 0;
|
||||
@@ -164,46 +206,46 @@ function openList(t) {
|
||||
border-radius: 18px;
|
||||
}
|
||||
.boardCard__title {
|
||||
flex: 1 1 auto;
|
||||
font-weight: 800;
|
||||
min-width: 0;
|
||||
font-weight: 900;
|
||||
font-size: 18px;
|
||||
line-height: 1.3;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
}
|
||||
.boardCard__head {
|
||||
min-width: 0;
|
||||
padding: 16px 18px 18px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard__titleRow,
|
||||
.boardCard__metaRow {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.boardCard__titleRow {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.boardCard__metaRow {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.boardCard__author {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
opacity: 0.84;
|
||||
opacity: 0.86;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard__authorName {
|
||||
min-width: 0;
|
||||
@@ -229,14 +271,28 @@ function openList(t) {
|
||||
.boardCard__date,
|
||||
.favoriteStat {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.boardCard__date {
|
||||
font-size: 10px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
@media (max-width: 1400px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -156,8 +156,8 @@ async function logout() {
|
||||
<div class="settingsFields">
|
||||
<label class="field">
|
||||
<span class="field__label">닉네임</span>
|
||||
<input v-model="nickname" class="field__input" placeholder="작성자 닉네임" />
|
||||
<span class="field__hint">티어표 작성자 이름으로 표시됩니다.</span>
|
||||
<input v-model="nickname" class="field__input" maxlength="40" placeholder="작성자 닉네임" />
|
||||
<span class="field__hint">티어표 작성자 이름으로 표시됩니다. {{ nickname.length }}/40자</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,10 @@
|
||||
"dev:backend": "npm --prefix backend run dev",
|
||||
"build": "npm --prefix frontend run build",
|
||||
"start": "npm --prefix backend run start",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"images:backfill": "npm --prefix backend run images:backfill",
|
||||
"images:migrate-legacy": "npm --prefix backend run images:migrate-legacy",
|
||||
"uploads:cleanup-legacy": "npm --prefix backend run uploads:cleanup-legacy"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
Reference in New Issue
Block a user