Compare commits

...

10 Commits

20 changed files with 1478 additions and 222 deletions

View File

@@ -5,7 +5,10 @@
"main": "index.js", "main": "index.js",
"scripts": { "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", "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": [], "keywords": [],
"author": "", "author": "",

View 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()
})

View 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()
})

View 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()
})

View File

@@ -1,3 +1,5 @@
const fs = require('fs/promises')
const path = require('path')
const mysql = require('mysql2/promise') const mysql = require('mysql2/promise')
const DB_HOST = process.env.DB_HOST || '127.0.0.1' const DB_HOST = process.env.DB_HOST || '127.0.0.1'
@@ -28,6 +30,29 @@ function serializeJson(value) {
return JSON.stringify(value || []) return JSON.stringify(value || [])
} }
function collectUploadSrcsFromItems(items, bucket) {
for (const item of items || []) {
if (typeof item?.src === 'string' && item.src.startsWith('/uploads/')) {
bucket.add(item.src)
}
}
}
function resolveMonthRange(month) {
if (typeof month !== 'string') return null
const match = month.trim().match(/^(\d{4})-(\d{2})$/)
if (!match) return null
const year = Number(match[1])
const monthIndex = Number(match[2]) - 1
if (!Number.isInteger(year) || monthIndex < 0 || monthIndex > 11) return null
return {
start: new Date(year, monthIndex, 1).getTime(),
end: new Date(year, monthIndex + 1, 1).getTime(),
}
}
function mapUserRow(row) { function mapUserRow(row) {
if (!row) return null if (!row) return null
return { return {
@@ -198,6 +223,14 @@ async function query(sql, params = []) {
return rows return rows
} }
async function closePool() {
if (!poolPromise) return
const pool = await poolPromise
await pool.end()
poolPromise = null
initPromise = null
}
async function ensureSchema() { async function ensureSchema() {
if (initPromise) return initPromise if (initPromise) return initPromise
initPromise = (async () => { initPromise = (async () => {
@@ -473,25 +506,59 @@ async function updateUserProfile({ id, nickname, avatarSrc }) {
return findUserById(id) return findUserById(id)
} }
async function listUsers() { async function findPrimaryAdminUser() {
const rows = await query(` const rows = await query(
SELECT 'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
u.id, )
u.email, return mapUserRow(rows[0])
u.nickname, }
u.is_admin,
u.avatar_src, async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' } = {}) {
u.created_at, const where = []
COUNT(t.id) AS tierlist_count, const params = []
GREATEST( const trimmedQuery = typeof queryText === 'string' ? queryText.trim() : ''
if (trimmedQuery) {
where.push('(u.email LIKE ? OR u.nickname LIKE ?)')
params.push(`%${trimmedQuery}%`, `%${trimmedQuery}%`)
}
const isAsc = direction === 'asc'
const orderBy =
sort === 'created'
? isAsc
? 'u.created_at ASC, recent_activity_at ASC, u.email ASC'
: 'u.created_at DESC, recent_activity_at DESC, u.email ASC'
: sort === 'tierlists'
? isAsc
? 'tierlist_count ASC, recent_activity_at ASC, u.email ASC'
: 'tierlist_count DESC, recent_activity_at DESC, u.email ASC'
: isAsc
? 'recent_activity_at ASC, u.created_at ASC, u.email ASC'
: 'recent_activity_at DESC, u.created_at ASC, u.email ASC'
const rows = await query(
`
SELECT
u.id,
u.email,
u.nickname,
u.is_admin,
u.avatar_src,
u.created_at, u.created_at,
COALESCE(MAX(t.updated_at), 0) COUNT(t.id) AS tierlist_count,
) AS recent_activity_at GREATEST(
FROM users u u.created_at,
LEFT JOIN tierlists t ON t.author_id = u.id COALESCE(MAX(t.updated_at), 0)
GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at ) AS recent_activity_at
ORDER BY recent_activity_at DESC, u.created_at ASC, u.email ASC FROM users u
`) LEFT JOIN tierlists t ON t.author_id = u.id
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at
ORDER BY ${orderBy}
`,
params
)
return rows.map(mapUserRow) return rows.map(mapUserRow)
} }
@@ -593,6 +660,14 @@ async function findImageAssetByHash(contentHash) {
return mapImageAssetRow(rows[0]) 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 }) { async function createImageAsset({ id, contentHash, src, mimeType = "image/webp", byteSize, originalByteSize, width, height }) {
const createdAt = now() const createdAt = now()
await query( await query(
@@ -637,13 +712,307 @@ async function updateImageOptimizationJobStatus({ id, status, optimizedByteSize
return findImageOptimizationJobById(id) return findImageOptimizationJobById(id)
} }
async function listRecentImageOptimizationJobs(limit = 20) { async function listRecentImageOptimizationJobs(limit = 20, { month } = {}) {
const safeLimit = Math.max(1, Math.min(100, Number(limit) || 20)) const safeLimit = Math.max(1, Math.min(100, Number(limit) || 20))
const range = resolveMonthRange(month)
const where = []
const params = []
if (range) {
where.push('queued_at >= ? AND queued_at < ?')
params.push(range.start, range.end)
}
const rows = await query( const rows = await query(
`SELECT id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at FROM image_optimization_jobs ORDER BY queued_at DESC LIMIT ${safeLimit}` `SELECT id, status, source_category, target_directory, original_byte_size, optimized_byte_size, reused_asset, error_message, queued_at, started_at, finished_at
FROM image_optimization_jobs
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY queued_at DESC
LIMIT ${safeLimit}`,
params
) )
return rows.map(mapImageOptimizationJobRow) return rows.map(mapImageOptimizationJobRow)
} }
async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
const safeLimit = Math.max(1, Math.min(500, Number(limit) || 100))
const safeMinAgeHours = Math.max(0, Number(minAgeHours) || 24)
const cutoff = now() - safeMinAgeHours * 60 * 60 * 1000
const assets = (await query(
`SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE created_at <= ? ORDER BY created_at ASC LIMIT ${safeLimit}`,
[cutoff]
)).map(mapImageAssetRow)
if (!assets.length) return []
const referencedSrcs = new Set()
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"),
])
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 tierListRows) {
if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
collectUploadSrcsFromItems(parseJson(row.pool_json, []), referencedSrcs)
}
for (const row of templateRequestRows) {
if (row.thumbnail_src_snapshot) referencedSrcs.add(row.thumbnail_src_snapshot)
collectUploadSrcsFromItems(parseJson(row.items_json, []), referencedSrcs)
}
return assets.filter((asset) => !referencedSrcs.has(asset.src))
}
async function deleteImageAssets(ids) {
const uniqueIds = Array.from(new Set((ids || []).filter(Boolean)))
if (!uniqueIds.length) return []
const placeholders = uniqueIds.map(() => '?').join(', ')
const rows = await query(
`SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id IN (${placeholders})`,
uniqueIds
)
await query(`DELETE FROM image_assets WHERE id IN (${placeholders})`, uniqueIds)
return rows.map(mapImageAssetRow)
}
async function listReferencedUploadSources() {
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 id, thumbnail_src, pool_json FROM tierlists"),
query("SELECT id, thumbnail_src_snapshot, items_json FROM template_requests"),
])
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) {
addUsage(row.thumbnail_src, 'tierlist-thumbnail')
for (const item of parseJson(row.pool_json, [])) addUsage(item?.src, 'tierlist-pool')
}
for (const row of templateRequestRows) {
addUsage(row.thumbnail_src_snapshot, 'template-thumbnail')
for (const item of parseJson(row.items_json, [])) addUsage(item?.src, 'template-item')
}
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 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)
if (replacedItems.changed) changed = true
if (changed) {
await query('UPDATE template_requests SET thumbnail_src_snapshot = ?, items_json = ?, updated_at = ? WHERE id = ?', [
nextThumbnail || '',
serializeJson(replacedItems.items),
now(),
row.id,
])
updatedRows += 1
}
}
return { updatedRows }
}
async function listImageAssets() {
const rows = await query(
'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets ORDER BY created_at DESC'
)
return rows.map(mapImageAssetRow)
}
async function getReferencedUploadFootprint() {
const [referencedSrcs, assets] = await Promise.all([listReferencedUploadSources(), listImageAssets()])
const assetMap = new Map(assets.map((asset) => [asset.src, asset]))
let totalReferencedByteSize = 0
let trackedReferencedByteSize = 0
let legacyReferencedByteSize = 0
let trackedReferencedCount = 0
let legacyReferencedCount = 0
let missingCount = 0
for (const src of referencedSrcs) {
if (typeof src !== 'string' || !src.startsWith('/uploads/')) continue
const absolutePath = path.join(__dirname, '..', src.replace(/^\//, ''))
try {
const stat = await fs.stat(absolutePath)
const size = Number(stat.size || 0)
totalReferencedByteSize += size
if (assetMap.has(src)) {
trackedReferencedCount += 1
trackedReferencedByteSize += size
} else {
legacyReferencedCount += 1
legacyReferencedByteSize += size
}
} catch (error) {
if (error?.code === 'ENOENT') missingCount += 1
}
}
return {
referencedCount: referencedSrcs.length,
totalReferencedByteSize,
trackedReferencedCount,
trackedReferencedByteSize,
legacyReferencedCount,
legacyReferencedByteSize,
missingCount,
}
}
async function getImageAssetStats({ month } = {}) {
const range = resolveMonthRange(month)
const jobWhere = []
const jobParams = []
if (range) {
jobWhere.push('queued_at >= ? AND queued_at < ?')
jobParams.push(range.start, range.end)
}
const [assetRows, jobRows, footprint] = await Promise.all([
query(
`SELECT COUNT(*) AS asset_count, COALESCE(SUM(byte_size), 0) AS total_byte_size, COALESCE(SUM(original_byte_size), 0) AS total_original_byte_size FROM image_assets`
),
query(
`SELECT
COALESCE(SUM(CASE WHEN status = 'queued' THEN 1 ELSE 0 END), 0) AS queued_count,
COALESCE(SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END), 0) AS processing_count,
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0) AS completed_count,
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS failed_count,
COALESCE(SUM(CASE WHEN status = 'completed' AND reused_asset = 1 THEN 1 ELSE 0 END), 0) AS reused_count
FROM image_optimization_jobs
${jobWhere.length ? `WHERE ${jobWhere.join(' AND ')}` : ''}`,
jobParams
),
getReferencedUploadFootprint(),
])
const asset = assetRows[0] || {}
const jobs = jobRows[0] || {}
const totalByteSize = Number(asset.total_byte_size || 0)
const totalOriginalByteSize = Number(asset.total_original_byte_size || 0)
const savedByteSize = Math.max(0, totalOriginalByteSize - totalByteSize)
return {
assetCount: Number(asset.asset_count || 0),
totalByteSize,
totalOriginalByteSize,
savedByteSize,
savingsRatio: totalOriginalByteSize > 0 ? savedByteSize / totalOriginalByteSize : 0,
referencedCount: Number(footprint.referencedCount || 0),
referencedByteSize: Number(footprint.totalReferencedByteSize || 0),
trackedReferencedCount: Number(footprint.trackedReferencedCount || 0),
trackedReferencedByteSize: Number(footprint.trackedReferencedByteSize || 0),
legacyReferencedCount: Number(footprint.legacyReferencedCount || 0),
legacyReferencedByteSize: Number(footprint.legacyReferencedByteSize || 0),
missingReferencedCount: Number(footprint.missingCount || 0),
queuedCount: Number(jobs.queued_count || 0),
processingCount: Number(jobs.processing_count || 0),
completedCount: Number(jobs.completed_count || 0),
failedCount: Number(jobs.failed_count || 0),
reusedCount: Number(jobs.reused_count || 0),
}
}
async function clearImageOptimizationJobs({ month } = {}) {
const range = resolveMonthRange(month)
if (range) {
const result = await query('DELETE FROM image_optimization_jobs WHERE queued_at >= ? AND queued_at < ?', [range.start, range.end])
return Number(result.affectedRows || 0)
}
const result = await query('DELETE FROM image_optimization_jobs')
return Number(result.affectedRows || 0)
}
async function createGameItem({ id, gameId, src, label }) { async function createGameItem({ id, gameId, src, label }) {
const createdAt = now() const createdAt = now()
await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [ await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
@@ -1511,11 +1880,13 @@ async function unfavoriteGame({ userId, gameId }) {
module.exports = { module.exports = {
DB_NAME, DB_NAME,
ensureData, ensureData,
closePool,
countUsers, countUsers,
findUserByEmail, findUserByEmail,
findUserById, findUserById,
createUser, createUser,
updateUserProfile, updateUserProfile,
findPrimaryAdminUser,
listUsers, listUsers,
adminUpdateUser, adminUpdateUser,
adminUpdateUserPassword, adminUpdateUserPassword,
@@ -1527,11 +1898,19 @@ module.exports = {
createGame, createGame,
updateGameThumbnail, updateGameThumbnail,
findImageAssetByHash, findImageAssetByHash,
findImageAssetBySrc,
createImageAsset, createImageAsset,
createImageOptimizationJob, createImageOptimizationJob,
findImageOptimizationJobById, findImageOptimizationJobById,
updateImageOptimizationJobStatus, updateImageOptimizationJobStatus,
listRecentImageOptimizationJobs, listRecentImageOptimizationJobs,
listUnusedImageAssets,
deleteImageAssets,
listReferencedUploadSources,
listReferencedUploadUsage,
replaceUploadSourceReferences,
clearImageOptimizationJobs,
getImageAssetStats,
createGameItem, createGameItem,
updateGameItemLabel, updateGameItemLabel,
deleteGameItem, deleteGameItem,

View File

@@ -22,6 +22,7 @@ const {
findCustomItemsByIds, findCustomItemsByIds,
deleteCustomItems, deleteCustomItems,
listUsers, listUsers,
findPrimaryAdminUser,
listAdminTierLists, listAdminTierLists,
findTierListById, findTierListById,
listAdminTemplateRequests, listAdminTemplateRequests,
@@ -30,9 +31,14 @@ const {
adminUpdateUser, adminUpdateUser,
adminUpdateUserPassword, adminUpdateUserPassword,
adminDeleteUser, adminDeleteUser,
listUnusedImageAssets,
deleteImageAssets,
getImageAssetStats,
listRecentImageOptimizationJobs,
clearImageOptimizationJobs,
} = require('../db') } = require('../db')
const { requireAdmin } = require('../middleware/auth') const { requireAdmin } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage') const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
const router = express.Router() const router = express.Router()
@@ -57,6 +63,30 @@ function buildItemLabelFromFilename(file) {
const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 }) const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 })
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 }) const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
function decorateAdminUser(user, primaryAdmin) {
if (!user) return null
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
return {
...user,
isPrimaryAdmin,
isOperator: !!user.isAdmin && !isPrimaryAdmin,
role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user',
}
}
async function getAdminUserContext(targetUserId, actingUserId) {
const [targetUser, actingUser, primaryAdmin] = await Promise.all([
findUserById(targetUserId),
findUserById(actingUserId),
findPrimaryAdminUser(),
])
return { targetUser, actingUser, primaryAdmin }
}
function canManageAdminRole(actingUser, primaryAdmin) {
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
}
router.post('/games', requireAdmin, async (req, res) => { router.post('/games', requireAdmin, async (req, res) => {
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) }) const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
@@ -206,6 +236,78 @@ router.get('/template-requests', requireAdmin, async (req, res) => {
res.json({ requests }) res.json({ requests })
}) })
router.get('/image-assets/orphans', requireAdmin, async (req, res) => {
const schema = z.object({
limit: z.coerce.number().int().min(1).max(500).optional().default(100),
minAgeHours: z.coerce.number().min(0).max(24 * 365).optional().default(24),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const assets = await listUnusedImageAssets(parsed.data)
res.json({ assets })
})
async function removeImageAssetFiles(assets) {
await Promise.all(
(assets || []).map(async (asset) => {
if (!asset?.src || !asset.src.startsWith('/uploads/')) return
const absolutePath = path.join(__dirname, '..', '..', asset.src.replace(/^\//, ''))
try {
await fs.unlink(absolutePath)
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
})
)
}
router.post('/image-assets/cleanup', requireAdmin, async (req, res) => {
const schema = z.object({
limit: z.coerce.number().int().min(1).max(500).optional().default(100),
minAgeHours: z.coerce.number().min(0).max(24 * 365).optional().default(24),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const assets = await listUnusedImageAssets(parsed.data)
const deleted = await deleteImageAssets(assets.map((asset) => asset.id))
await removeImageAssetFiles(deleted)
res.json({ deletedCount: deleted.length, assets: deleted })
})
router.get('/image-assets/stats', requireAdmin, async (req, res) => {
const schema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
limit: z.coerce.number().int().min(1).max(24).optional().default(12),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const filters = { month: parsed.data.month }
const [stats, recentJobs] = await Promise.all([
getImageAssetStats(filters),
listRecentImageOptimizationJobs(parsed.data.limit, filters),
])
res.json({
stats,
filters,
queue: getImageOptimizationQueueState(),
recentJobs,
})
})
router.post('/image-assets/stats/reset', requireAdmin, async (req, res) => {
const schema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/).optional().nullable(),
})
const parsed = schema.safeParse(req.body || {})
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const deletedCount = await clearImageOptimizationJobs({ month: parsed.data.month || undefined })
res.json({ deletedCount })
})
async function removeCustomItemFiles(items) { async function removeCustomItemFiles(items) {
await Promise.all( await Promise.all(
items.map(async (item) => { items.map(async (item) => {
@@ -221,33 +323,18 @@ async function removeCustomItemFiles(items) {
} }
async function promoteCustomItemToGameItem({ customItem, gameId }) { async function promoteCustomItemToGameItem({ customItem, gameId }) {
const originalName = path.basename(customItem.src || '')
const nextFilename = buildUploadFilename({ originalname: originalName })
const sourcePath = path.join(__dirname, '..', '..', customItem.src.replace(/^\//, ''))
const targetRelativePath = path.join('uploads', 'games', nextFilename)
const targetPath = path.join(__dirname, '..', '..', targetRelativePath)
await fs.copyFile(sourcePath, targetPath)
return createGameItem({ return createGameItem({
id: nanoid(), id: nanoid(),
gameId, gameId,
src: `/${targetRelativePath.replace(/\\/g, '/')}`, src: customItem.src || '',
label: customItem.label, label: customItem.label,
}) })
} }
async function copyUploadIntoGameAsset(src) { async function copyUploadIntoGameAsset(src) {
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return src || '' if (typeof src !== 'string' || !src.startsWith('/uploads/')) return src || ''
if (src.startsWith('/uploads/assets/')) return src
const originalName = path.basename(src) return src
const nextFilename = buildUploadFilename({ originalname: originalName })
const sourcePath = path.join(__dirname, '..', '..', src.replace(/^\//, ''))
const targetRelativePath = path.join('uploads', 'games', nextFilename)
const targetPath = path.join(__dirname, '..', '..', targetRelativePath)
await fs.copyFile(sourcePath, targetPath)
return `/${targetRelativePath.replace(/\\/g, '/')}`
} }
function uniqueTierListPoolItems(tierList) { function uniqueTierListPoolItems(tierList) {
@@ -475,8 +562,19 @@ router.delete('/custom-items', requireAdmin, async (req, res) => {
}) })
router.get('/users', requireAdmin, async (req, res) => { router.get('/users', requireAdmin, async (req, res) => {
const users = await listUsers() const schema = z.object({
res.json({ users }) q: z.string().trim().max(120).optional().default(''),
sort: z.enum(['recent', 'created', 'tierlists']).optional().default('recent'),
direction: z.enum(['asc', 'desc']).optional().default('desc'),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const [users, primaryAdmin] = await Promise.all([
listUsers({ queryText: parsed.data.q, sort: parsed.data.sort, direction: parsed.data.direction }),
findPrimaryAdminUser(),
])
res.json({ users: users.map((user) => decorateAdminUser(user, primaryAdmin)) })
}) })
router.patch('/users/:userId', requireAdmin, async (req, res) => { router.patch('/users/:userId', requireAdmin, async (req, res) => {
@@ -488,21 +586,34 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!targetUser) return res.status(404).json({ error: 'not_found' })
const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin)
const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id
const roleChanged = parsed.data.isAdmin !== !!targetUser.isAdmin
if (req.params.userId === req.session.userId && !parsed.data.isAdmin) { if (req.params.userId === req.session.userId && !parsed.data.isAdmin) {
return res.status(400).json({ error: 'self_admin_required' }) return res.status(400).json({ error: 'self_admin_required' })
} }
if (targetIsPrimaryAdmin && !actingIsPrimaryAdmin) {
const user = await findUserById(req.params.userId) return res.status(403).json({ error: 'primary_admin_protected' })
if (!user) return res.status(404).json({ error: 'not_found' }) }
if (targetIsPrimaryAdmin && !parsed.data.isAdmin) {
return res.status(400).json({ error: 'primary_admin_required' })
}
if (roleChanged && !actingIsPrimaryAdmin) {
return res.status(403).json({ error: 'primary_admin_only' })
}
try { try {
const updated = await adminUpdateUser({ const updated = await adminUpdateUser({
id: user.id, id: targetUser.id,
email: parsed.data.email, email: parsed.data.email,
nickname: parsed.data.nickname, nickname: parsed.data.nickname,
isAdmin: parsed.data.isAdmin, isAdmin: parsed.data.isAdmin,
}) })
res.json({ user: updated }) res.json({ user: decorateAdminUser(updated, primaryAdmin) })
} catch (e) { } catch (e) {
if (e && e.code === 'ER_DUP_ENTRY') { if (e && e.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'email_taken' }) return res.status(409).json({ error: 'email_taken' })
@@ -518,8 +629,11 @@ router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar')
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserById(req.params.userId) const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!user) return res.status(404).json({ error: 'not_found' }) if (!targetUser) return res.status(404).json({ error: 'not_found' })
if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) {
return res.status(403).json({ error: 'primary_admin_protected' })
}
const optimized = req.file const optimized = req.file
? await writeOptimizedImage({ ? await writeOptimizedImage({
@@ -532,16 +646,16 @@ router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar')
}) })
: null : null
const shouldRemoveAvatar = parsed.data.removeAvatar === '1' const shouldRemoveAvatar = parsed.data.removeAvatar === '1'
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || '' const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || targetUser.avatarSrc || ''
const updated = await adminUpdateUser({ const updated = await adminUpdateUser({
id: user.id, id: targetUser.id,
email: user.email, email: targetUser.email,
nickname: user.nickname || '', nickname: targetUser.nickname || '',
isAdmin: !!user.isAdmin, isAdmin: !!targetUser.isAdmin,
avatarSrc: nextAvatarSrc, avatarSrc: nextAvatarSrc,
}) })
res.json({ user: updated }) res.json({ user: decorateAdminUser(updated, primaryAdmin) })
}) })
router.delete('/users/:userId', requireAdmin, async (req, res) => { router.delete('/users/:userId', requireAdmin, async (req, res) => {
@@ -549,10 +663,19 @@ router.delete('/users/:userId', requireAdmin, async (req, res) => {
return res.status(400).json({ error: 'cannot_delete_self' }) return res.status(400).json({ error: 'cannot_delete_self' })
} }
const user = await findUserById(req.params.userId) const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!user) return res.status(404).json({ error: 'not_found' }) if (!targetUser) return res.status(404).json({ error: 'not_found' })
await adminDeleteUser(user.id) const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin)
const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id
if (targetIsPrimaryAdmin) {
return res.status(400).json({ error: 'cannot_delete_primary_admin' })
}
if (targetUser.isAdmin && !actingIsPrimaryAdmin) {
return res.status(403).json({ error: 'primary_admin_only' })
}
await adminDeleteUser(targetUser.id)
res.json({ ok: true }) res.json({ ok: true })
}) })
@@ -563,11 +686,14 @@ router.patch('/users/:userId/password', requireAdmin, async (req, res) => {
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserById(req.params.userId) const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!user) return res.status(404).json({ error: 'not_found' }) if (!targetUser) return res.status(404).json({ error: 'not_found' })
if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) {
return res.status(403).json({ error: 'primary_admin_protected' })
}
const passwordHash = await bcrypt.hash(parsed.data.password, 10) const passwordHash = await bcrypt.hash(parsed.data.password, 10)
await adminUpdateUserPassword({ id: user.id, passwordHash }) await adminUpdateUserPassword({ id: targetUser.id, passwordHash })
res.json({ ok: true }) res.json({ ok: true })
}) })

View File

@@ -9,6 +9,7 @@ const {
findUserById, findUserById,
createUser, createUser,
updateUserProfile, updateUserProfile,
findPrimaryAdminUser,
} = require('../db') } = require('../db')
const { requireAuth } = require('../middleware/auth') const { requireAuth } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage') const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
@@ -25,6 +26,24 @@ const profileSchema = z.object({
removeAvatar: z.union([z.string(), z.undefined()]).optional(), 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) => { router.post('/signup', async (req, res) => {
const parsed = signupSchema.safeParse(req.body) const parsed = signupSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -39,9 +58,9 @@ router.post('/signup', async (req, res) => {
req.session.userId = user.id req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin 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' }) 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.userId = user.id
req.session.isAdmin = !!user.isAdmin 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' }) if (err) return res.status(500).json({ error: 'session_save_failed' })
res.json({ res.json(await serializeUser(user))
id: user.id,
email: user.email,
nickname: user.nickname || '',
isAdmin: !!user.isAdmin,
avatarSrc: user.avatarSrc || '',
createdAt: user.createdAt,
})
}) })
}) })
@@ -80,7 +92,7 @@ router.get('/me', async (req, res) => {
if (!req.session || !req.session.userId) return res.json({ user: null }) if (!req.session || !req.session.userId) return res.json({ user: null })
const user = await findUserById(req.session.userId) const user = await findUserById(req.session.userId)
if (!user) return res.json({ user: null }) if (!user) return res.json({ user: null })
res.json({ user }) res.json({ user: await serializeUser(user) })
}) })
router.get('/meta', async (req, res) => { router.get('/meta', async (req, res) => {
@@ -115,7 +127,7 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
avatarSrc: nextAvatarSrc, avatarSrc: nextAvatarSrc,
}) })
res.json({ user: updated }) res.json({ user: await serializeUser(updated) })
}) })
module.exports = router module.exports = router

View File

@@ -1,40 +1,12 @@
# 할 일 및 이슈 # 할 일 및 이슈
## 즉시 확인 필요 ## 즉시 확인 필요
- 피그마 기반 리디자인은 현재 공통 셸과 카드 목록 화면, 포커스 화면 안정화까지만 반영된 상태이므로, 에디터/관리자 우측 옵션 패널을 시안 구조에 맞게 실제 기능 패널로 이관하는 작업이 남아 있다. - 티어표 형식 추가 필요. 최근 게임들은 S, A, B,C 같은 랭크 뿐만 아니라 가로 열도 나누어진형태의 티어표를 원함 (공격, 방어, 지원 등 각 파트별 랭크를 보고싶어함)
- 홈/게임 허브/내 티어표/즐겨찾기 카드 문법은 어느 정도 통일됐지만, 아직 실제 SVG 아이콘, 미세 간격, hover/selection 상태 같은 디테일은 더 다듬을 필요가 있다. - 레거시 파일 정리 스크립트는 준비됐으므로, 운영 단계에서는 cron 등으로 주기 실행할지와 삭제 전 보관 기간을 함께 정한다.
- 목록 화면 상단 도구 막대는 공통 카드 문법으로 거의 맞췄지만, 실제 피그마처럼 필터 토글/정렬 상태를 시각적으로 더 강하게 드러내는 디테일은 남아 있다.
- 현재 공통 셸에는 임시 선형 SVG 아이콘을 사용하므로, 최종 머티리얼 아이콘 에셋을 받으면 교체하고 아이콘 크기/정렬을 다시 미세 조정할 필요가 있다.
- 공통 셸과 에디터에는 일부 실제 SVG 아이콘을 연결했지만, 아직 즐겨찾기/설정/관리자 등 나머지 내비 아이콘은 임시 선형 SVG이므로 추가 에셋 교체가 남아 있다.
- 공통 우측 패널의 토글과 독립 컬럼 구조는 반영되었지만, 현재는 안내 카드 중심이므로 실제 화면별 기능 컨트롤을 이 패널로 옮기는 작업이 남아 있다.
- 티어표 편집 화면과 관리자 화면 모두 로컬 우측 패널 구조로 옮겼지만, 아직 세부 카드 밀도와 아이콘/모션 디테일은 피그마 시안 수준으로 더 다듬을 필요가 있다.
- 에디터/관리자 로컬 우측 패널은 셸 카드에서 분리됐지만, 아직 실제 피그마처럼 패널 토글 전환 모션과 상태 강조가 더 필요하다.
- 에디터 로컬 우측 패널은 공통 토글과 연결됐지만, 아직 완전한 피그마 수준의 패널 애니메이션과 내부 카드 재배치는 더 다듬을 필요가 있다.
- 에디터 우측 패널은 셸의 세 번째 컬럼으로 옮겼지만, 내부 카드 간격과 섹션 구분선은 아직 첨부 시안처럼 더 촘촘하게 정리할 필요가 있다.
- 에디터 우측 패널 외곽 래퍼는 제거했으므로, 다음 단계는 공통 오른쪽 컬럼 안에서 입력/버튼/구분선 간격을 시안처럼 더 정교하게 다듬는 작업이다.
- 공통 56px 셸 헤더는 반영했으므로, 다음 단계는 좌/중앙/우 헤더 안에 실제 아이콘/상태 요소를 시안 순서에 맞게 하나씩 채워 넣는 작업이다.
- 좌측 레일은 최근 즐겨찾기와 전역 검색까지 붙었으므로, 다음 단계는 검색 자동완성이나 즐겨찾기 썸네일 품질 같은 디테일을 더 다듬는 작업이다.
- 좌측 레일 축소형은 반영했으므로, 다음 단계는 축소 상태에서 관리자/로그인 진입점과 hover 툴팁 같은 보조 UX를 더 다듬는 작업이다.
- 좌우 하단 액션 영역은 분리했으므로, 다음 단계는 축소된 왼쪽 레일에서도 관리자/로그인 버튼을 아이콘형으로 어떻게 유지할지 검토할 수 있다.
- 홈 게임 카드 메타는 간소화했으므로, 이후 필요하면 게임 썸네일은 상세 허브나 우측 패널처럼 더 맥락이 분명한 위치에만 쓰는 방향을 검토할 수 있다.
- 좌우 하단 액션은 항상 보이도록 보정했으므로, 다음 단계는 축소된 레일 상태에서 액션 버튼의 아이콘화 여부를 추가 검토할 수 있다.
- 카드 목록은 4열 기준과 메타 줄 구성까지 통일했으므로, 다음 단계는 필터 상태 배지나 hover·selection 강조 같은 상호작용 디테일을 더 다듬는 작업이다.
- 검색 결과 화면은 좌측 전역 검색 입력만 쓰도록 정리됐으므로, 다음 단계는 결과 필터/정렬 여부를 검토하는 식으로 확장하면 된다.
- 공통 3단 셸 구조는 고정했지만, 관리자/에디터 우측 패널 내부에 아직 바디에 남아 있는 제어 요소를 더 옮겨야 한다.
- 홈 화면 우측 사이드는 CTA 하나만 남긴 상태이므로, 이후 필요할 때도 임시 정보 카드 다수를 다시 넣기보다 실제 필요한 기능만 선별해 추가해야 한다.
- 관리자 화면은 헤더 요약 통계와 카드 계층까지 정리됐지만, 아직 표준 SVG 아이콘 교체와 더 세밀한 상태 색상/선택 상태 표현은 남아 있다.
- 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다.
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다. - 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다. - 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
- 템플릿 등록/업데이트 요청은 현재 승인/반려만 지원하므로, 필요하면 관리자 코멘트나 사용자용 요청 상태 이력 화면을 추가 검토한다.
- 관리자 티어표 관리의 템플릿 생성은 현재 `freeform`만 직접 지원하므로, 필요하면 일반 게임 티어표의 전체 아이템을 복제한 파생 템플릿 생성 UX도 검토한다.
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다. - 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.
- 즐겨찾기는 현재 `내 즐겨찾기` 목록과 정렬까지 지원하므로, 필요하면 폴더 분류나 메모 같은 개인 정리추가 검토한다. - 이미지 최적화 기록은 월별 조회/비우기까지 지원하므로, 운영 단계에서는 보관 기간 정책과 자동 아카이브한다.
- 전역 토스트는 중복 합치기와 페이드아웃까지 지원하므로, 필요하면 액션 링크나 수동 고정(pin) 같은 상호작용 확장을 검토한다.
- 공개 티어표 검색은 현재 게임별 허브 안에서만 제공하므로, 필요하면 홈 전역 통합 검색도 검토한다.
- 즐겨찾기 토글은 현재 상세 화면 중심이므로, 필요하면 카드 목록에서도 안전한 보조 인터랙션(예: 길게 누르기, 별도 메뉴)을 검토한다.
## 배포 전 작업 ## 배포 전 작업
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다. - NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
@@ -44,8 +16,6 @@
- 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다. - 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다.
## 중기 개선 ## 중기 개선
- 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다.
- 자동 테스트와 최소한의 배포 체크리스트를 만든다.
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다. - 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다. - 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다. - 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.

View File

@@ -1,5 +1,55 @@
# 업데이트 로그 # 업데이트 로그
## 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건 선택과 월 단위 필터를 지원해, 특정 기간 사용량과 최적화 이력을 운영 관점에서 바로 확인할 수 있게 정리함.
- 관리자에서 월별 또는 전체 최적화 기록을 비우는 정리 액션을 추가하고, todo 문서도 현재 이미지 최적화 흐름에 맞게 갱신함.
## 2026-03-31 v1.3.4
- 관리자 API에 이미지 자산 통계 엔드포인트를 추가해 총 자산 수, 현재 용량, 원본 대비 절감 용량/절감률, 작업 누적 상태를 조회할 수 있게 확장함.
- 관리자 오른쪽 사이드 하단에 `Image Optimization` 패널을 추가해 큐 상태, 절감 통계, 최근 최적화 작업을 바로 확인할 수 있도록 대시보드를 구성함.
- 미사용 자산 정리 API와 작업 기록 큐를 기반으로, 운영 중 이미지 스토리지 상태를 관리자 화면에서 직접 점검할 수 있는 흐름을 완성함.
## 2026-03-31 v1.3.3
- `image_assets` 참조를 전수 점검해 아무 곳에서도 사용하지 않는 최적화 이미지 자산만 추려내는 정리 배치 로직을 추가함.
- 관리자용 미사용 자산 조회/정리 API를 추가해 오래된 고아 이미지 자산을 미리 확인하거나 실제로 삭제할 수 있도록 확장함.
- 관리자 승격/템플릿 생성 과정은 기존 `/uploads/assets/` 자산을 그대로 재사용하도록 바꿔, 불필요한 복제 파일이 다시 생기지 않게 정리함.
## 2026-03-31 v1.3.2 ## 2026-03-31 v1.3.2
- 업로드 최적화는 이제 백엔드 내부 대기열을 통해 처리되어, 다수 이미지가 한 번에 들어와도 설정된 동시성 안에서 순차적으로 안정적으로 변환되도록 정리함. - 업로드 최적화는 이제 백엔드 내부 대기열을 통해 처리되어, 다수 이미지가 한 번에 들어와도 설정된 동시성 안에서 순차적으로 안정적으로 변환되도록 정리함.
- `image_optimization_jobs` 작업 기록 테이블을 추가해 queued/processing/completed/failed 상태와 원본·최적화 용량, 재사용 여부, 시작/종료 시각을 저장하도록 확장함. - `image_optimization_jobs` 작업 기록 테이블을 추가해 queued/processing/completed/failed 상태와 원본·최적화 용량, 재사용 여부, 시작/종료 시각을 저장하도록 확장함.

View File

@@ -12,6 +12,7 @@ import iconLists from './assets/icons/lists.svg'
import iconSearch from './assets/icons/search.svg' import iconSearch from './assets/icons/search.svg'
import iconSettings from './assets/icons/settings.svg' import iconSettings from './assets/icons/settings.svg'
import RightRailAd from './components/RightRailAd.vue' import RightRailAd from './components/RightRailAd.vue'
import SvgIcon from './components/SvgIcon.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -302,7 +303,7 @@ function submitGlobalSearch() {
<aside class="leftRail"> <aside class="leftRail">
<div class="leftRail__top railHeader"> <div class="leftRail__top railHeader">
<button v-if="!isMobileLayout" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="왼쪽 패널 토글" @click="toggleLeftRail"> <button v-if="!isMobileLayout" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="왼쪽 패널 토글" @click="toggleLeftRail">
<img :src="iconDockToRight" alt="" /> <SvgIcon :src="iconDockToRight" :size="24" />
</button> </button>
</div> </div>
@@ -322,7 +323,7 @@ function submitGlobalSearch() {
<form class="searchStub" @submit.prevent="submitGlobalSearch"> <form class="searchStub" @submit.prevent="submitGlobalSearch">
<button class="searchStub__iconButton" type="button" :aria-label="leftRailCollapsed ? '검색 열기' : '검색'" @click="handleLeftRailSearch"> <button class="searchStub__iconButton" type="button" :aria-label="leftRailCollapsed ? '검색 열기' : '검색'" @click="handleLeftRailSearch">
<span class="searchStub__icon"> <span class="searchStub__icon">
<img :src="iconSearch" alt="" /> <SvgIcon :src="iconSearch" :size="24" />
</span> </span>
</button> </button>
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : searchPlaceholder" /> <input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : searchPlaceholder" />
@@ -339,7 +340,7 @@ function submitGlobalSearch() {
:aria-label="leftRailCollapsed ? item.label : undefined" :aria-label="leftRailCollapsed ? item.label : undefined"
> >
<span class="leftNav__glyph"> <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> <svg v-else viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
</span> </span>
<span class="leftNav__label">{{ item.label }}</span> <span class="leftNav__label">{{ item.label }}</span>
@@ -365,14 +366,14 @@ function submitGlobalSearch() {
<div class="workspaceHead__actions"> <div class="workspaceHead__actions">
<div v-if="showGameHubViewToggle" class="viewToggle" role="group" aria-label="티어표 보기 방식"> <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')"> <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>
<button class="ghostIcon ghostIcon--iconOnly" :class="{ 'ghostIcon--active': gameHubViewMode === 'list' }" type="button" aria-label="리스트 보기" @click="setGameHubViewMode('list')"> <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> </button>
</div> </div>
<button v-if="!rightRailOpen" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="패널 열기" @click="toggleRightRail"> <button v-if="!rightRailOpen" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="패널 열기" @click="toggleRightRail">
<img :src="iconDockToLeft" alt="" /> <SvgIcon :src="iconDockToLeft" :size="24" />
</button> </button>
</div> </div>
</header> </header>
@@ -385,7 +386,7 @@ function submitGlobalSearch() {
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="searchPlaceholder" @click.self="closeCollapsedSearch"> <div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="searchPlaceholder" @click.self="closeCollapsedSearch">
<form class="collapsedSearchBar" @submit.prevent="submitGlobalSearch"> <form class="collapsedSearchBar" @submit.prevent="submitGlobalSearch">
<span class="collapsedSearchBar__icon"> <span class="collapsedSearchBar__icon">
<img :src="iconSearch" alt="" /> <SvgIcon :src="iconSearch" :size="24" />
</span> </span>
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="searchPlaceholder" autofocus /> <input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="searchPlaceholder" autofocus />
</form> </form>
@@ -396,7 +397,7 @@ function submitGlobalSearch() {
<aside class="rightRail" :class="{ 'rightRail--closed': !rightRailOpen, 'rightRail--overlay': isRightRailOverlay }" :aria-hidden="!rightRailOpen"> <aside class="rightRail" :class="{ 'rightRail--closed': !rightRailOpen, 'rightRail--overlay': isRightRailOverlay }" :aria-hidden="!rightRailOpen">
<div class="rightRail__top railHeader"> <div class="rightRail__top railHeader">
<button v-if="rightRailOpen" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="패널 닫기" @click="toggleRightRail"> <button v-if="rightRailOpen" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="패널 닫기" @click="toggleRightRail">
<img :src="iconDockToLeft" alt="" /> <SvgIcon :src="iconDockToLeft" :size="24" />
</button> </button>
</div> </div>
<div class="rightRail__body"> <div class="rightRail__body">

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="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

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

View File

@@ -44,6 +44,15 @@ export const api = {
listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) => listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`), request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
listAdminTemplateRequests: () => request('/api/admin/template-requests'), listAdminTemplateRequests: () => request('/api/admin/template-requests'),
getAdminImageAssetStats: ({ month = '', limit = 12 } = {}) => {
const query = new URLSearchParams()
if (month) query.set('month', month)
query.set('limit', String(limit))
return request(`/api/admin/image-assets/stats?${query.toString()}`)
},
resetAdminImageAssetStats: (payload) => request('/api/admin/image-assets/stats/reset', { method: 'POST', body: payload || {} }),
listAdminUnusedImageAssets: ({ limit = 100, minAgeHours = 24 } = {}) => request(`/api/admin/image-assets/orphans?limit=${encodeURIComponent(limit)}&minAgeHours=${encodeURIComponent(minAgeHours)}`),
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
promoteAdminCustomItem: (itemId, payload) => promoteAdminCustomItem: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }), request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
promoteAdminTierListItems: (tierListId, payload) => promoteAdminTierListItems: (tierListId, payload) =>
@@ -53,7 +62,8 @@ export const api = {
approveAdminTemplateRequest: (requestId, payload) => approveAdminTemplateRequest: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/approve`, { method: 'POST', body: payload || {} }), request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/approve`, { method: 'POST', body: payload || {} }),
rejectAdminTemplateRequest: (requestId) => request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/reject`, { method: 'POST', body: {} }), rejectAdminTemplateRequest: (requestId) => request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/reject`, { method: 'POST', body: {} }),
listAdminUsers: () => request('/api/admin/users'), listAdminUsers: ({ q = '', sort = 'recent', direction = 'desc' } = {}) =>
request(`/api/admin/users?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}&direction=${encodeURIComponent(direction)}`),
updateAdminUser: (userId, payload) => updateAdminUser: (userId, payload) =>
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }), request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
updateAdminUserPassword: (userId, payload) => updateAdminUserPassword: (userId, payload) =>

View File

@@ -5,6 +5,7 @@ import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime' import { toApiUrl } from '../lib/runtime'
import lockResetIcon from '../assets/icons/lock_reset.svg' import lockResetIcon from '../assets/icons/lock_reset.svg'
import deleteIcon from '../assets/icons/delete.svg' import deleteIcon from '../assets/icons/delete.svg'
import SvgIcon from '../components/SvgIcon.vue'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useToast } from '../composables/useToast' import { useToast } from '../composables/useToast'
@@ -46,6 +47,7 @@ const importModalNewGameId = ref('')
const importModalNewGameName = ref('') const importModalNewGameName = ref('')
const previewModalOpen = ref(false) const previewModalOpen = ref(false)
const previewTierList = ref(null) const previewTierList = ref(null)
const userEditModalOpen = ref(false)
const userPasswordModalOpen = ref(false) const userPasswordModalOpen = ref(false)
const userDeleteModalOpen = ref(false) const userDeleteModalOpen = ref(false)
const userRoleModalOpen = ref(false) const userRoleModalOpen = ref(false)
@@ -54,9 +56,21 @@ const customItemDeleteModalOpen = ref(false)
const modalTargetUser = ref(null) const modalTargetUser = ref(null)
const modalPasswordDraft = ref('') const modalPasswordDraft = ref('')
const modalRoleNextAdmin = ref(false) const modalRoleNextAdmin = ref(false)
const modalUserDraftEmail = ref('')
const modalUserDraftNickname = ref('')
const modalUserDraftIsAdmin = ref(false)
const modalTargetCustomItem = ref(null) const modalTargetCustomItem = ref(null)
const users = ref([]) const users = ref([])
const userQuery = ref('')
const userSort = ref('recent')
const userSortDirection = ref('desc')
const imageStats = ref(null)
const imageQueue = ref({ concurrency: 1, activeCount: 0, pendingCount: 0 })
const imageRecentJobs = ref([])
const imageStatsMonth = ref('')
const imageStatsLimit = ref(12)
const imageResetModalOpen = ref(false)
const error = ref('') const error = ref('')
const success = ref('') const success = ref('')
@@ -121,7 +135,7 @@ const adminOverviewStats = computed(() => {
const publishedTierLists = adminTierLists.value.filter((tierList) => tierList.isPublic).length const publishedTierLists = adminTierLists.value.filter((tierList) => tierList.isPublic).length
const pendingRequests = templateRequests.value.length const pendingRequests = templateRequests.value.length
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length
const adminCount = users.value.filter((user) => user.isAdmin || user.draftIsAdmin).length const adminCount = users.value.filter((user) => user.isAdmin).length
if (activeTab.value === 'featured') { if (activeTab.value === 'featured') {
return [ return [
@@ -166,7 +180,7 @@ const adminOverviewStats = computed(() => {
onMounted(async () => { onMounted(async () => {
await auth.refresh() await auth.refresh()
await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests()]) await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests(), refreshImageDiagnostics()])
await syncFeaturedSortable() await syncFeaturedSortable()
}) })
@@ -226,6 +240,72 @@ function resetMessages() {
success.value = '' success.value = ''
} }
function formatBytes(value) {
const size = Number(value || 0)
if (!size) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let current = size
let unitIndex = 0
while (current >= 1024 && unitIndex < units.length - 1) {
current /= 1024
unitIndex += 1
}
return `${current >= 10 || unitIndex === 0 ? current.toFixed(0) : current.toFixed(1)} ${units[unitIndex]}`
}
const imageDiagnosticsCards = computed(() => {
const stats = imageStats.value
if (!stats) return []
return [
{ label: '실사용 파일', value: `${stats.referencedCount || 0}` },
{ label: '현재 용량', value: formatBytes(stats.referencedByteSize) },
{ label: '추적 자산', value: `${stats.trackedReferencedCount || 0}` },
{ label: '레거시 참조', value: `${stats.legacyReferencedCount || 0}` },
{ label: '절감 용량', value: formatBytes(stats.savedByteSize) },
{ 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} 기준` : '전체 기간'))
async function refreshImageDiagnostics() {
try {
const data = await api.getAdminImageAssetStats({
month: imageStatsMonth.value || '',
limit: imageStatsLimit.value,
})
imageStats.value = data.stats || null
imageQueue.value = data.queue || { concurrency: 1, activeCount: 0, pendingCount: 0 }
imageRecentJobs.value = data.recentJobs || []
} catch (e) {
error.value = '이미지 최적화 현황을 불러오지 못했어요.'
}
}
function openImageResetModal() {
imageResetModalOpen.value = true
}
function closeImageResetModal() {
imageResetModalOpen.value = false
}
async function confirmImageReset() {
try {
const data = await api.resetAdminImageAssetStats({ month: imageStatsMonth.value || null })
success.value = imageStatsMonth.value
? `${imageStatsMonth.value} 기록 ${data.deletedCount || 0}건을 정리했어요.`
: `전체 최적화 기록 ${data.deletedCount || 0}건을 정리했어요.`
closeImageResetModal()
await refreshImageDiagnostics()
} catch (e) {
error.value = '이미지 최적화 기록을 정리하지 못했어요.'
}
}
function setTab(tab) { function setTab(tab) {
resetMessages() resetMessages()
activeTab.value = tab activeTab.value = tab
@@ -278,9 +358,25 @@ function setUserAvatarInput(userId, el) {
userAvatarInputs.value[userId] = el userAvatarInputs.value[userId] = el
} }
function isUserDirty(user) { const canManageModalRole = computed(() => {
if (!user) return false if (!auth.user?.isPrimaryAdmin) return false
return user.draftEmail !== user.email || (user.draftNickname || '') !== (user.nickname || '') || !!user.draftIsAdmin !== !!user.isAdmin if (!modalTargetUser.value) return false
return !modalTargetUser.value.isPrimaryAdmin
})
const isUserEditDirty = computed(() => {
if (!modalTargetUser.value) return false
return (
modalUserDraftEmail.value.trim() !== (modalTargetUser.value.email || '') ||
modalUserDraftNickname.value.trim() !== (modalTargetUser.value.nickname || '') ||
!!modalUserDraftIsAdmin.value !== !!modalTargetUser.value.isAdmin
)
})
function roleLabelOf(user) {
if (user?.isPrimaryAdmin) return '최고 관리자'
if (user?.isAdmin) return '운영자'
return '일반 회원'
} }
function openUserAvatarPicker(user) { function openUserAvatarPicker(user) {
@@ -299,17 +395,14 @@ async function uploadUserAvatar(user, file, { remove = false } = {}) {
entry.id === updated.id entry.id === updated.id
? { ? {
...entry, ...entry,
avatarSrc: updated.avatarSrc || '', ...updated,
email: updated.email,
nickname: updated.nickname || '',
isAdmin: !!updated.isAdmin,
draftEmail: updated.email,
draftNickname: updated.nickname || '',
draftIsAdmin: !!updated.isAdmin,
isAvatarBusy: false, isAvatarBusy: false,
} }
: entry : entry
) )
if (modalTargetUser.value?.id === updated.id) {
modalTargetUser.value = { ...modalTargetUser.value, ...updated }
}
if (updated.id === auth.user?.id) await auth.refresh() if (updated.id === auth.user?.id) await auth.refresh()
await refreshUsers() await refreshUsers()
success.value = remove ? '회원 썸네일을 삭제했어요.' : '회원 썸네일을 업데이트했어요.' success.value = remove ? '회원 썸네일을 삭제했어요.' : '회원 썸네일을 업데이트했어요.'
@@ -423,12 +516,9 @@ async function refreshTemplateRequests() {
async function refreshUsers() { async function refreshUsers() {
if (!auth.user?.isAdmin) return if (!auth.user?.isAdmin) return
try { try {
const data = await api.listAdminUsers() const data = await api.listAdminUsers({ q: userQuery.value, sort: userSort.value, direction: userSortDirection.value })
users.value = (data.users || []).map((user) => ({ users.value = (data.users || []).map((user) => ({
...user, ...user,
draftEmail: user.email,
draftNickname: user.nickname || '',
draftIsAdmin: !!user.isAdmin,
isAvatarBusy: false, isAvatarBusy: false,
})) }))
} catch (e) { } catch (e) {
@@ -705,29 +795,45 @@ async function removeGame() {
} }
} }
async function saveUser(user) { function openUserEditModal(user) {
resetMessages() resetMessages()
modalTargetUser.value = user ? { ...user } : null
modalUserDraftEmail.value = user?.email || ''
modalUserDraftNickname.value = user?.nickname || ''
modalUserDraftIsAdmin.value = !!user?.isAdmin
userEditModalOpen.value = true
}
function closeUserEditModal() {
userEditModalOpen.value = false
modalTargetUser.value = null
modalUserDraftEmail.value = ''
modalUserDraftNickname.value = ''
modalUserDraftIsAdmin.value = false
}
async function saveUserEdit() {
resetMessages()
if (!modalTargetUser.value?.id) return
try { try {
const data = await api.updateAdminUser(user.id, { const data = await api.updateAdminUser(modalTargetUser.value.id, {
email: user.draftEmail, email: modalUserDraftEmail.value.trim(),
nickname: user.draftNickname, nickname: modalUserDraftNickname.value.trim(),
isAdmin: !!user.draftIsAdmin, isAdmin: !!modalUserDraftIsAdmin.value,
}) })
const updated = data.user const updated = data.user
users.value = users.value.map((entry) => users.value = users.value.map((entry) =>
entry.id === updated.id entry.id === updated.id
? { ? {
...entry, ...entry,
email: updated.email, ...updated,
nickname: updated.nickname || '', isAvatarBusy: entry.isAvatarBusy || false,
isAdmin: !!updated.isAdmin,
draftEmail: updated.email,
draftNickname: updated.nickname || '',
draftIsAdmin: !!updated.isAdmin,
} }
: entry : entry
) )
if (updated.id === auth.user?.id) await auth.refresh() if (updated.id === auth.user?.id) await auth.refresh()
closeUserEditModal()
await refreshUsers() await refreshUsers()
success.value = '회원 정보를 저장했어요.' success.value = '회원 정보를 저장했어요.'
} catch (e) { } catch (e) {
@@ -737,7 +843,7 @@ async function saveUser(user) {
function openUserPasswordModal(user) { function openUserPasswordModal(user) {
resetMessages() resetMessages()
modalTargetUser.value = user || null modalTargetUser.value = user ? { ...user } : null
modalPasswordDraft.value = '' modalPasswordDraft.value = ''
userPasswordModalOpen.value = true userPasswordModalOpen.value = true
} }
@@ -769,7 +875,7 @@ async function confirmUserPasswordReset() {
function openUserDeleteModal(user) { function openUserDeleteModal(user) {
resetMessages() resetMessages()
modalTargetUser.value = user || null modalTargetUser.value = user ? { ...user } : null
userDeleteModalOpen.value = true userDeleteModalOpen.value = true
} }
@@ -796,34 +902,31 @@ async function confirmUserDelete() {
} }
function openUserRoleModal(user) { function openUserRoleModal(user, nextIsAdmin = !modalUserDraftIsAdmin.value) {
resetMessages() resetMessages()
modalTargetUser.value = user || null modalTargetUser.value = user ? { ...user } : null
modalRoleNextAdmin.value = !user?.draftIsAdmin modalRoleNextAdmin.value = !!nextIsAdmin
userRoleModalOpen.value = true userRoleModalOpen.value = true
} }
function closeUserRoleModal() { function closeUserRoleModal() {
userRoleModalOpen.value = false userRoleModalOpen.value = false
modalTargetUser.value = null if (!userEditModalOpen.value) modalTargetUser.value = null
modalRoleNextAdmin.value = false modalRoleNextAdmin.value = false
} }
function confirmUserRoleDraft() { function confirmUserRoleDraft() {
if (!modalTargetUser.value?.id) return if (!modalTargetUser.value?.id) return
users.value = users.value.map((entry) => modalUserDraftIsAdmin.value = modalRoleNextAdmin.value
entry.id === modalTargetUser.value.id const targetLabel = modalRoleNextAdmin.value ? '운영자 권한을 저장 대기 상태로 반영했어요.' : '운영자 권한 해제를 저장 대기 상태로 반영했어요.'
? {
...entry,
draftIsAdmin: modalRoleNextAdmin.value,
}
: entry
)
const targetLabel = modalRoleNextAdmin.value ? '관리자로 지정했어요. 저장하면 반영됩니다.' : '관리자 권한 해제로 표시했어요. 저장하면 반영됩니다.'
closeUserRoleModal() closeUserRoleModal()
success.value = targetLabel success.value = targetLabel
} }
function submitUserFilters() {
refreshUsers()
}
function submitCustomItemSearch() { function submitCustomItemSearch() {
customItemPage.value = 1 customItemPage.value = 1
refreshCustomItems() refreshCustomItems()
@@ -1475,6 +1578,25 @@ async function saveFeaturedOrder() {
</div> </div>
</div> </div>
<div class="toolbar toolbar--secondary">
<input
v-model="userQuery"
class="input toolbar__search"
placeholder="이메일, 닉네임 검색"
@keydown.enter.prevent="submitUserFilters"
/>
<select v-model="userSort" class="select toolbar__select" @change="submitUserFilters">
<option value="recent">최근 활동순</option>
<option value="created">가입순</option>
<option value="tierlists">작성 티어표 많은 </option>
</select>
<select v-model="userSortDirection" class="select toolbar__select" @change="submitUserFilters">
<option value="desc">내림차순</option>
<option value="asc">오름차순</option>
</select>
<button class="btn btn--ghost toolbar__button" type="button" @click="submitUserFilters">조회</button>
</div>
<div v-if="!users.length" class="hint">아직 가입한 회원이 없어요.</div> <div v-if="!users.length" class="hint">아직 가입한 회원이 없어요.</div>
<div v-else class="userList"> <div v-else class="userList">
<article v-for="user in users" :key="user.id" class="userCard"> <article v-for="user in users" :key="user.id" class="userCard">
@@ -1501,7 +1623,7 @@ async function saveFeaturedOrder() {
:disabled="user.isAvatarBusy" :disabled="user.isAvatarBusy"
@click.stop="removeUserAvatar(user)" @click.stop="removeUserAvatar(user)"
> >
<img :src="deleteIcon" alt="" /> <SvgIcon class="userAvatarRemoveIcon" :src="deleteIcon" :size="12" />
</button> </button>
</div> </div>
<div class="userCard__identityMeta"> <div class="userCard__identityMeta">
@@ -1511,33 +1633,25 @@ async function saveFeaturedOrder() {
</div> </div>
</div> </div>
<div v-if="user.draftIsAdmin" class="roleBadge userCard__roleBadge">Administrator</div> <div v-if="user.isAdmin" class="roleBadge userCard__roleBadge">{{ roleLabelOf(user) }}</div>
<div class="userInfoList"> <div class="userInfoList">
<div class="userInfoLine"><span>가입일</span><strong>{{ fmt(user.createdAt) }}</strong></div> <div class="userInfoLine"><span>가입일</span><strong>{{ fmt(user.createdAt) }}</strong></div>
<div class="userInfoLine"><span>작성 티어표</span><strong>{{ user.tierListCount }}</strong></div> <div class="userInfoLine"><span>작성 티어표</span><strong>{{ user.tierListCount }}</strong></div>
<div class="userInfoLine"><span>최근 활동</span><strong>{{ fmt(user.recentActivityAt || user.createdAt) }}</strong></div> <div class="userInfoLine"><span>최근 활동</span><strong>{{ fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
<div class="userInfoLine"><span>계정명</span><strong>{{ user.email }}</strong></div>
<div class="userInfoLine"><span>닉네임</span><strong>{{ user.nickname || '미설정' }}</strong></div>
<div class="userInfoLine"><span>권한</span><strong>{{ roleLabelOf(user) }}</strong></div>
</div> </div>
<input v-model="user.draftEmail" class="input" placeholder="이메일" />
<input v-model="user.draftNickname" class="input" placeholder="닉네임" />
<button
class="userRoleAction"
type="button"
:disabled="user.id === auth.user?.id"
@click="openUserRoleModal(user)"
>
{{ user.draftIsAdmin ? '관리자 권한 해제' : '관리자 권한 임명' }}
</button>
<div class="userCard__actions userCard__actions--compact"> <div class="userCard__actions userCard__actions--compact">
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="openUserPasswordModal(user)"> <button class="iconActionButton" type="button" title="비밀번호 초기화" @click="openUserPasswordModal(user)">
<img :src="lockResetIcon" alt="" /> <SvgIcon class="iconActionButton__icon" :src="lockResetIcon" :size="18" />
</button> </button>
<button class="iconActionButton iconActionButton--danger" type="button" title="회원 삭제" @click="openUserDeleteModal(user)"> <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>
<button class="btn btn--ghost userSaveButton" :disabled="!isUserDirty(user)" @click="saveUser(user)">회원정보 저장</button> <button class="btn btn--ghost userSaveButton" type="button" @click="openUserEditModal(user)">회원 정보 수정</button>
</div> </div>
</article> </article>
</div> </div>
@@ -1549,8 +1663,22 @@ async function saveFeaturedOrder() {
<div class="modalCard__title"> 게임 만들기</div> <div class="modalCard__title"> 게임 만들기</div>
<div class="modalCard__desc">게임 이름과 고유 ID를 입력한 생성하면 바로 아래 상세 관리 화면으로 이어집니다.</div> <div class="modalCard__desc">게임 이름과 고유 ID를 입력한 생성하면 바로 아래 상세 관리 화면으로 이어집니다.</div>
<div class="modalCard__form"> <div class="modalCard__form">
<input v-model="newGameName" class="input" placeholder="게임 이름" /> <label class="field">
<input v-model="newGameId" class="input" placeholder="game id (영문/숫자)" @keydown.enter.prevent="createGame" /> <span class="field__label">게임 이름</span>
<input v-model="newGameName" class="field__input" maxlength="60" placeholder="게임 이름" />
<span class="field__hint">{{ newGameName.length }}/60</span>
</label>
<label class="field">
<span class="field__label">게임 ID</span>
<input
v-model="newGameId"
class="field__input"
maxlength="120"
placeholder="game id (영문/숫자)"
@keydown.enter.prevent="createGame"
/>
<span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newGameId.length }}/120</span>
</label>
</div> </div>
<div class="modalCard__actions"> <div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeGameCreateModal">취소</button> <button class="btn btn--ghost" @click="closeGameCreateModal">취소</button>
@@ -1559,12 +1687,56 @@ async function saveFeaturedOrder() {
</div> </div>
</div> </div>
<div v-if="userEditModalOpen" class="modalOverlay" @click.self="closeUserEditModal">
<div class="modalCard modalCard--userEdit" role="dialog" aria-modal="true">
<div class="modalCard__title">회원 정보 수정</div>
<div class="modalCard__desc">{{ modalTargetUser ? `${userDisplayName(modalTargetUser)} 계정의 정보와 권한을 조정할 수 있어요.` : '' }}</div>
<div class="userEditForm">
<label class="field">
<span class="field__label">이메일</span>
<input v-model="modalUserDraftEmail" class="field__input" maxlength="255" placeholder="계정 이메일" />
<span class="field__hint">로그인 계정으로 사용하는 이메일입니다. {{ modalUserDraftEmail.length }}/255</span>
</label>
<label class="field">
<span class="field__label">닉네임</span>
<input v-model="modalUserDraftNickname" class="field__input" maxlength="40" placeholder="표시용 닉네임" />
<span class="field__hint">티어표 작성자명과 프로필에 표시됩니다. {{ modalUserDraftNickname.length }}/40</span>
</label>
<button
v-if="canManageModalRole"
class="userRoleAction"
type="button"
@click="openUserRoleModal(modalTargetUser, !modalUserDraftIsAdmin)"
>
{{ modalUserDraftIsAdmin ? '운영자 권한 해제' : '운영자 권한 부여' }}
</button>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeUserEditModal">취소</button>
<button class="btn btn--primary" :disabled="!isUserEditDirty" @click="saveUserEdit">회원 정보 저장</button>
</div>
</div>
</div>
<div v-if="userPasswordModalOpen" class="modalOverlay" @click.self="closeUserPasswordModal"> <div v-if="userPasswordModalOpen" class="modalOverlay" @click.self="closeUserPasswordModal">
<div class="modalCard" role="dialog" aria-modal="true"> <div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">비밀번호 초기화</div> <div class="modalCard__title">비밀번호 초기화</div>
<div class="modalCard__desc">{{ modalTargetUser ? `${userDisplayName(modalTargetUser)} 계정에 설정할 새 비밀번호를 입력해주세요.` : '' }}</div> <div class="modalCard__desc">{{ modalTargetUser ? `${userDisplayName(modalTargetUser)} 계정에 설정할 새 비밀번호를 입력해주세요.` : '' }}</div>
<div class="modalCard__form"> <div class="modalCard__form">
<input v-model="modalPasswordDraft" class="input" type="password" placeholder="초기화할 비밀번호 입력" @keydown.enter.prevent="confirmUserPasswordReset" /> <label class="field">
<span class="field__label"> 비밀번호</span>
<input
v-model="modalPasswordDraft"
class="field__input"
type="password"
maxlength="120"
placeholder="초기화할 비밀번호 입력"
@keydown.enter.prevent="confirmUserPasswordReset"
/>
<span class="field__hint">6~120 권장 · {{ modalPasswordDraft.length }}/120</span>
</label>
</div> </div>
<div class="modalCard__actions"> <div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeUserPasswordModal">취소</button> <button class="btn btn--ghost" @click="closeUserPasswordModal">취소</button>
@@ -1586,13 +1758,13 @@ async function saveFeaturedOrder() {
<div v-if="userRoleModalOpen" class="modalOverlay" @click.self="closeUserRoleModal"> <div v-if="userRoleModalOpen" class="modalOverlay" @click.self="closeUserRoleModal">
<div class="modalCard" role="dialog" aria-modal="true"> <div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">관리 권한 변경</div> <div class="modalCard__title">운영 권한 변경</div>
<div class="modalCard__desc"> <div class="modalCard__desc">
{{ {{
modalTargetUser modalTargetUser
? modalRoleNextAdmin ? modalRoleNextAdmin
? `${userDisplayName(modalTargetUser)} 사용자를 관리자로 임명할까요?` ? `${userDisplayName(modalTargetUser)} 사용자를 운영자로 지정할까요?`
: `${userDisplayName(modalTargetUser)} 사용자의 관리자 권한을 해제할까요?` : `${userDisplayName(modalTargetUser)} 사용자의 운영자 권한을 해제할까요?`
: '' : ''
}} }}
</div> </div>
@@ -1657,11 +1829,11 @@ async function saveFeaturedOrder() {
</select> </select>
</div> </div>
<div class="customItemModal__linked"> <div class="customItemModal__linked">
<span class="customItemModal__label">이미 사용 중인 게임</span> <span class="customItemModal__label">템플릿에 사용 중인 게임</span>
<div v-if="modalTargetCustomItem.linkedGames?.length" class="customItemModal__chips"> <div v-if="visibleLinkedGames.length" class="customItemModal__chips">
<span v-for="game in modalTargetCustomItem.linkedGames" :key="game.id" class="pill">{{ game.name }}</span> <span v-for="game in visibleLinkedGames" :key="game.id" class="pill">{{ game.name }}</span>
</div> </div>
<div v-else class="hint hint--tight">아직 연결된 게임이 없어요.</div> <div v-else class="hint hint--tight">아직 템플릿에 연결된 게임이 없어요.</div>
</div> </div>
</div> </div>
<div class="customItemModal__body"> <div class="customItemModal__body">
@@ -1695,6 +1867,19 @@ async function saveFeaturedOrder() {
</div> </div>
</div> </div>
<div v-if="imageResetModalOpen" class="modalOverlay" @click.self="closeImageResetModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">최적화 기록 비우기</div>
<div class="modalCard__desc">
{{ imageStatsMonth ? `${imageStatsMonth} 기간의 최적화 기록만 삭제합니다.` : '전체 최적화 작업 기록을 비웁니다. 실제 이미지 파일은 삭제되지 않아요.' }}
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeImageResetModal">취소</button>
<button class="btn btn--danger" @click="confirmImageReset">기록 비우기</button>
</div>
</div>
</div>
<div v-if="previewModalOpen" class="modalOverlay" @click.self="closePreviewModal"> <div v-if="previewModalOpen" class="modalOverlay" @click.self="closePreviewModal">
<div class="modalCard modalCard--preview" role="dialog" aria-modal="true"> <div class="modalCard modalCard--preview" role="dialog" aria-modal="true">
<div class="modalCard__titleRow"> <div class="modalCard__titleRow">
@@ -1842,6 +2027,62 @@ async function saveFeaturedOrder() {
</template> </template>
</section> </section>
<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" />
<select v-model.number="imageStatsLimit" class="select">
<option :value="6">최근 6</option>
<option :value="12">최근 12</option>
<option :value="24">최근 24</option>
</select>
</div>
<div class="adminSidebar__actions adminSidebar__actions--split">
<button class="btn btn--ghost" @click="refreshImageDiagnostics">현황 새로고침</button>
<button class="btn btn--ghost" @click="openImageResetModal">기록 비우기</button>
</div>
<div class="hint hint--tight">{{ imageStatsPeriodLabel }}</div>
<div v-if="imageDiagnosticsCards.length" class="adminSidebar__stats adminSidebar__stats--grid">
<article v-for="stat in imageDiagnosticsCards" :key="stat.label" class="sidebarStat">
<span class="sidebarStat__label">{{ stat.label }}</span>
<strong class="sidebarStat__value">{{ stat.value }}</strong>
</article>
</div>
<div class="adminSidebar__stats">
<div class="sidebarStat">
<span class="sidebarStat__label"> 상태</span>
<strong class="sidebarStat__value">{{ imageQueue.activeCount }} 실행 / {{ imageQueue.pendingCount }} 대기</strong>
</div>
<div class="sidebarStat">
<span class="sidebarStat__label">작업 누적</span>
<strong class="sidebarStat__value">{{ imageStats?.completedCount || 0 }} 완료 · {{ imageStats?.failedCount || 0 }} 실패</strong>
</div>
<div class="sidebarStat">
<span class="sidebarStat__label">중복 재사용</span>
<strong class="sidebarStat__value">{{ imageStats?.reusedCount || 0 }}</strong>
</div>
<div class="sidebarStat">
<span class="sidebarStat__label">누락 파일</span>
<strong class="sidebarStat__value">{{ imageStats?.missingReferencedCount || 0 }}</strong>
</div>
</div>
<div class="adminSidebar__group">
<div class="section__title">최근 최적화 작업</div>
<div class="hint hint--tight">현재 {{ imageRecentJobs.length }} 표시 </div>
<div v-if="!imageRecentJobs.length" class="hint hint--tight">아직 기록된 최적화 작업이 없어요.</div>
<div v-else class="imageJobList">
<article v-for="job in imageRecentJobs" :key="job.id" class="imageJobRow">
<div class="imageJobRow__head">
<strong>{{ job.sourceCategory || 'asset' }}</strong>
<span class="imageJobRow__status">{{ job.status }}</span>
</div>
<div class="hint hint--tight">{{ formatBytes(job.originalByteSize) }} {{ formatBytes(job.optimizedByteSize) }}</div>
<div class="hint hint--tight">{{ fmt(job.queuedAt) }}</div>
</article>
</div>
</div>
</section>
</aside> </aside>
</Teleport> </Teleport>
</template> </template>
@@ -1932,6 +2173,38 @@ async function saveFeaturedOrder() {
rgba(13, 13, 13, 0.94); rgba(13, 13, 13, 0.94);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18); box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18);
} }
.adminSidebar__stats--grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.imageJobList {
display: grid;
gap: 8px;
}
.imageJobRow {
border: 1px solid var(--line);
border-radius: 14px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.02);
display: grid;
gap: 4px;
}
.imageJobRow__head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
font-size: 12px;
}
.imageJobRow__status {
color: var(--text-muted);
text-transform: capitalize;
}
.adminSidebar__label { .adminSidebar__label {
font-size: 11px; font-size: 11px;
letter-spacing: 0.12em; letter-spacing: 0.12em;
@@ -1948,6 +2221,9 @@ async function saveFeaturedOrder() {
.adminSidebar__actions--stack .btn { .adminSidebar__actions--stack .btn {
width: 100%; width: 100%;
} }
.adminSidebar__actions--split {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.adminSidebar__groupTitle { .adminSidebar__groupTitle {
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 800;
@@ -2282,11 +2558,11 @@ async function saveFeaturedOrder() {
border: 0; border: 0;
} }
.btn { .btn {
height: 100%;
font-size: 12px; font-size: 12px;
line-height: 1.2; line-height: 1.2;
white-space: nowrap; white-space: nowrap;
word-break: keep-all; word-break: keep-all;
margin-top: 12px;
padding: 11px 13px; padding: 11px 13px;
border-radius: 14px; border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.14); border: 1px solid rgba(255, 255, 255, 0.14);
@@ -2752,10 +3028,8 @@ async function saveFeaturedOrder() {
transform: translateY(2px) scale(0.96); transform: translateY(2px) scale(0.96);
transition: opacity 160ms ease, transform 160ms ease, background 160ms ease, visibility 160ms ease; transition: opacity 160ms ease, transform 160ms ease, background 160ms ease, visibility 160ms ease;
} }
.userAvatarRemoveButton img { .userAvatarRemoveIcon {
width: 12px; color: rgba(255, 255, 255, 0.96);
height: 12px;
filter: brightness(0) invert(1);
} }
.userAvatarRemoveButton:disabled { .userAvatarRemoveButton:disabled {
opacity: 0.45; opacity: 0.45;
@@ -2820,6 +3094,44 @@ async function saveFeaturedOrder() {
font-size: 14px; font-size: 14px;
font-weight: 900; font-weight: 900;
} }
.field {
display: grid;
gap: 8px;
}
.field__label {
font-size: 13px;
color: rgba(255, 255, 255, 0.62);
}
.field__input {
width: 100%;
padding: 14px 0;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
background: transparent;
color: rgba(255, 255, 255, 0.94);
outline: none;
font-size: 18px;
letter-spacing: -0.02em;
}
.field__input:focus {
border-bottom-color: rgba(96, 165, 250, 0.9);
}
.field__hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
}
.userEditForm {
display: grid;
gap: 18px;
}
.userEditForm .field {
gap: 10px;
}
.modalCard--userEdit {
max-width: 520px;
}
.userCard__actions { .userCard__actions {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2828,6 +3140,7 @@ async function saveFeaturedOrder() {
.userCard__actions--compact { .userCard__actions--compact {
grid-template-columns: auto auto minmax(0, 1fr); grid-template-columns: auto auto minmax(0, 1fr);
align-items: center; align-items: center;
margin-top: 12px;
} }
.roleBadge { .roleBadge {
width: fit-content; width: fit-content;
@@ -2857,9 +3170,8 @@ async function saveFeaturedOrder() {
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
cursor: pointer; cursor: pointer;
} }
.iconActionButton img { .iconActionButton__icon {
width: 18px; color: rgba(255, 255, 255, 0.92);
height: 18px;
} }
.iconActionButton:disabled { .iconActionButton:disabled {
cursor: not-allowed; cursor: not-allowed;

View File

@@ -222,7 +222,8 @@ function submitSearch() {
} }
.list { .list {
display: grid; 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; gap: 18px;
} }

View File

@@ -2,6 +2,8 @@
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api' 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 { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@@ -91,7 +93,7 @@ function thumbUrl(g) {
:disabled="loadingFavoriteId === g.id" :disabled="loadingFavoriteId === g.id"
@click.stop="toggleFavorite(g, $event)" @click.stop="toggleFavorite(g, $event)"
> >
{{ g.isFavorited ? '★' : '☆' }} <SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
</button> </button>
<button class="libraryCard__main" type="button" @click="goGame(g.id)"> <button class="libraryCard__main" type="button" @click="goGame(g.id)">
<div class="libraryCard__thumbWrap"> <div class="libraryCard__thumbWrap">
@@ -169,8 +171,20 @@ function thumbUrl(g) {
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
z-index: 2; z-index: 2;
display: flex;
align-items: center;
justify-content: center;
} }
.libraryCard__favorite--active { .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; color: #ffd86b;
} }
.libraryCard__thumbWrap { .libraryCard__thumbWrap {

View File

@@ -79,8 +79,8 @@ async function submit() {
<form class="authFields" @submit.prevent="submit"> <form class="authFields" @submit.prevent="submit">
<label class="field"> <label class="field">
<span class="field__label">이메일</span> <span class="field__label">이메일</span>
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" /> <input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" maxlength="255" />
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다.</span> <span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다. {{ email.length }}/255</span>
</label> </label>
<label class="field"> <label class="field">
@@ -91,8 +91,9 @@ async function submit() {
type="password" type="password"
placeholder="********" placeholder="********"
autocomplete="current-password" autocomplete="current-password"
maxlength="120"
/> />
<span class="field__hint">8 이상으로 설정하면 안전하게 사용할 있어요.</span> <span class="field__hint">6~120 입력 가능 · {{ password.length }}/120</span>
</label> </label>
<label v-if="mode === 'signup'" class="field"> <label v-if="mode === 'signup'" class="field">
@@ -103,8 +104,9 @@ async function submit() {
type="password" type="password"
placeholder="********" placeholder="********"
autocomplete="new-password" autocomplete="new-password"
maxlength="120"
/> />
<span class="field__hint">같은 비밀번호를 입력해주세요.</span> <span class="field__hint">같은 비밀번호를 입력해주세요. {{ passwordConfirm.length }}/120</span>
</label> </label>
<div v-if="!hasUsers" class="roleBadge"> 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div> <div v-if="!hasUsers" class="roleBadge"> 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>

View File

@@ -156,8 +156,8 @@ async function logout() {
<div class="settingsFields"> <div class="settingsFields">
<label class="field"> <label class="field">
<span class="field__label">닉네임</span> <span class="field__label">닉네임</span>
<input v-model="nickname" class="field__input" placeholder="작성자 닉네임" /> <input v-model="nickname" class="field__input" maxlength="40" placeholder="작성자 닉네임" />
<span class="field__hint">티어표 작성자 이름으로 표시됩니다.</span> <span class="field__hint">티어표 작성자 이름으로 표시됩니다. {{ nickname.length }}/40</span>
</label> </label>
<label class="field"> <label class="field">

View File

@@ -45,6 +45,8 @@ const isTemplateUpdateModalOpen = ref(false)
const templateRequestDraftTitle = ref('') const templateRequestDraftTitle = ref('')
const templateRequestDraftDescription = ref('') const templateRequestDraftDescription = ref('')
const isDeleteModalOpen = ref(false) const isDeleteModalOpen = ref(false)
const isGroupDeleteModalOpen = ref(false)
const pendingRemoveGroupId = ref('')
const ownerId = ref('') const ownerId = ref('')
const authorName = ref('') const authorName = ref('')
const authorAccountName = ref('') const authorAccountName = ref('')
@@ -280,7 +282,7 @@ async function addGroup() {
await syncSortables() await syncSortables()
} }
async function removeGroup(groupId) { async function performRemoveGroup(groupId) {
if (groups.value.length <= 1) return if (groups.value.length <= 1) return
const target = groups.value.find((group) => group.id === groupId) const target = groups.value.find((group) => group.id === groupId)
if (!target) return if (!target) return
@@ -290,6 +292,24 @@ async function removeGroup(groupId) {
await syncSortables() await syncSortables()
} }
function openGroupDeleteModal(groupId) {
if (!canEdit.value || groups.value.length <= 1 || !groupId) return
pendingRemoveGroupId.value = groupId
isGroupDeleteModalOpen.value = true
}
function closeGroupDeleteModal() {
isGroupDeleteModalOpen.value = false
pendingRemoveGroupId.value = ''
}
async function confirmRemoveGroup() {
const groupId = pendingRemoveGroupId.value
closeGroupDeleteModal()
if (!groupId) return
await performRemoveGroup(groupId)
}
function addCustomImage(file) { function addCustomImage(file) {
if (!file || !file.type.startsWith('image/')) return if (!file || !file.type.startsWith('image/')) return
const url = URL.createObjectURL(file) const url = URL.createObjectURL(file)
@@ -774,10 +794,12 @@ onUnmounted(() => {
<label class="templateRequestDraft__field"> <label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 제목</span> <span class="templateRequestDraft__label">요청 제목</span>
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 등록 요청" /> <input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 등록 요청" />
<span class="templateRequestDraft__hint">{{ templateRequestDraftTitle.length }}/80</span>
</label> </label>
<label class="templateRequestDraft__field"> <label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 설명</span> <span class="templateRequestDraft__label">요청 설명</span>
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." /> <textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
<span class="templateRequestDraft__hint">{{ templateRequestDraftDescription.length }}/240</span>
</label> </label>
</div> </div>
<div class="modalCard__actions"> <div class="modalCard__actions">
@@ -803,10 +825,12 @@ onUnmounted(() => {
<label class="templateRequestDraft__field"> <label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 제목</span> <span class="templateRequestDraft__label">요청 제목</span>
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 업데이트 요청" /> <input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 업데이트 요청" />
<span class="templateRequestDraft__hint">{{ templateRequestDraftTitle.length }}/80</span>
</label> </label>
<label class="templateRequestDraft__field"> <label class="templateRequestDraft__field">
<span class="templateRequestDraft__label">요청 설명</span> <span class="templateRequestDraft__label">요청 설명</span>
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가" /> <textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
<span class="templateRequestDraft__hint">{{ templateRequestDraftDescription.length }}/240</span>
</label> </label>
</div> </div>
<div class="modalCard__actions"> <div class="modalCard__actions">
@@ -833,6 +857,19 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<div v-if="isGroupDeleteModalOpen" class="modalOverlay" @click.self="closeGroupDeleteModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteGroupTitle">
<div id="deleteGroupTitle" class="modalCard__title">티어 라인 삭제</div>
<div class="modalCard__desc">
라인을 삭제하면 현재 들어 있는 아이템은 모두 아래 아이템 영역으로 이동합니다. 삭제 후에도 아이템 자체는 유지돼요.
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeGroupDeleteModal">취소</button>
<button class="btn btn--danger" @click="confirmRemoveGroup">라인 삭제</button>
</div>
</div>
</div>
<section class="layout" :style="{ '--thumb-size': `${iconSize}px` }"> <section class="layout" :style="{ '--thumb-size': `${iconSize}px` }">
<div class="editorMain"> <div class="editorMain">
<section class="head"> <section class="head">
@@ -884,9 +921,18 @@ onUnmounted(() => {
<div class="row__exportName">{{ g.name }}</div> <div class="row__exportName">{{ g.name }}</div>
</template> </template>
<template v-else> <template v-else>
<button
v-if="canEdit"
class="rowRemoveText"
type="button"
title="티어 라인 삭제"
:disabled="groups.length <= 1"
@click="openGroupDeleteModal(g.id)"
>
삭제
</button>
<span class="grab" title="드래그로 순서 변경" data-group-handle></span> <span class="grab" title="드래그로 순서 변경" data-group-handle></span>
<input v-model="g.name" class="groupName" :readonly="!canEdit" /> <input v-model="g.name" class="groupName" maxlength="16" :readonly="!canEdit" />
<button v-if="canEdit" class="rowRemoveBtn" :disabled="groups.length <= 1" @click="removeGroup(g.id)">삭제</button>
</template> </template>
</div> </div>
<div <div
@@ -962,7 +1008,8 @@ onUnmounted(() => {
<template v-if="globalRightRailOpen"> <template v-if="globalRightRailOpen">
<div class="editorSidebar__section"> <div class="editorSidebar__section">
<div class="editorSidebar__label">Title</div> <div class="editorSidebar__label">Title</div>
<input v-model="title" class="editorSidebar__input" placeholder="Title Text" :readonly="!canEdit" /> <input v-model="title" class="editorSidebar__input" maxlength="120" placeholder="Title Text" :readonly="!canEdit" />
<div class="editorSidebar__hint">{{ title.length }}/120</div>
<div v-if="untitledWarning" class="editorSidebar__hint editorSidebar__hint--warn">{{ untitledWarning }}</div> <div v-if="untitledWarning" class="editorSidebar__hint editorSidebar__hint--warn">{{ untitledWarning }}</div>
</div> </div>
@@ -972,8 +1019,10 @@ onUnmounted(() => {
v-model="description" v-model="description"
class="editorSidebar__textarea" class="editorSidebar__textarea"
placeholder="Description Text" placeholder="Description Text"
maxlength="1000"
:readonly="!canEdit" :readonly="!canEdit"
></textarea> ></textarea>
<div class="editorSidebar__hint">{{ description.length }}/1000</div>
</div> </div>
<div class="editorSidebar__section"> <div class="editorSidebar__section">
@@ -1146,7 +1195,7 @@ onUnmounted(() => {
.previewOnly__label { .previewOnly__label {
display: grid; display: grid;
place-items: center; place-items: center;
padding: 10px 8px; padding: 10px 12px;
text-align: center; text-align: center;
font-weight: 900; font-weight: 900;
border-radius: 14px; border-radius: 14px;
@@ -1396,6 +1445,10 @@ onUnmounted(() => {
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.64); color: rgba(255, 255, 255, 0.64);
} }
.templateRequestDraft__hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.46);
}
.templateRequestDraft__input { .templateRequestDraft__input {
width: 100%; width: 100%;
padding: 14px 0; padding: 14px 0;
@@ -1512,6 +1565,7 @@ onUnmounted(() => {
align-items: stretch; align-items: stretch;
} }
.row__label { .row__label {
position: relative;
border-radius: 16px; border-radius: 16px;
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
@@ -1519,7 +1573,7 @@ onUnmounted(() => {
gap: 8px; gap: 8px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 10px 8px; padding: 10px 12px 30px;
font-weight: 900; font-weight: 900;
overflow: hidden; overflow: hidden;
} }
@@ -1547,26 +1601,32 @@ onUnmounted(() => {
outline: none; outline: none;
min-width: 0; min-width: 0;
} }
.rowRemoveText {
position: absolute;
right: 12px;
bottom: 10px;
padding: 0;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
font-size: 12px;
line-height: 1;
font-weight: 800;
}
.rowRemoveText:hover {
color: rgba(255, 255, 255, 0.9);
}
.rowRemoveText:disabled {
opacity: 0.32;
cursor: not-allowed;
}
.row__exportName { .row__exportName {
width: 100%; width: 100%;
text-align: center; text-align: center;
font-weight: 900; font-weight: 900;
word-break: break-word; word-break: break-word;
} }
.rowRemoveBtn {
padding: 6px 10px;
border-radius: 10px;
border: 1px solid rgba(239, 68, 68, 0.28);
background: rgba(239, 68, 68, 0.12);
color: rgba(255, 255, 255, 0.92);
cursor: pointer;
font-weight: 800;
flex: 0 0 auto;
}
.rowRemoveBtn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.row__drop { .row__drop {
border-radius: 16px; border-radius: 16px;
background: rgba(0, 0, 0, 0.18); background: rgba(0, 0, 0, 0.18);

View File

@@ -8,7 +8,10 @@
"dev:backend": "npm --prefix backend run dev", "dev:backend": "npm --prefix backend run dev",
"build": "npm --prefix frontend run build", "build": "npm --prefix frontend run build",
"start": "npm --prefix backend run start", "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": [], "keywords": [],
"author": "", "author": "",