Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f9a7cc947 | |||
| 880c79bbc4 | |||
| 7967361cac | |||
| fde62dbb43 | |||
| a5c632d9ae |
@@ -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": "",
|
||||||
|
|||||||
106
backend/scripts/backfill-legacy-image-assets.js
Normal file
106
backend/scripts/backfill-legacy-image-assets.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
const fs = require('fs/promises')
|
||||||
|
const path = require('path')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const sharp = require('sharp')
|
||||||
|
const { nanoid } = require('nanoid')
|
||||||
|
const {
|
||||||
|
ensureData,
|
||||||
|
closePool,
|
||||||
|
listReferencedUploadSources,
|
||||||
|
findImageAssetBySrc,
|
||||||
|
createImageAsset,
|
||||||
|
} = require('../src/db')
|
||||||
|
|
||||||
|
const BACKEND_ROOT = path.join(__dirname, '..')
|
||||||
|
|
||||||
|
function inferMimeType(src, metadata) {
|
||||||
|
const format = String(metadata?.format || '').toLowerCase()
|
||||||
|
if (format === 'jpeg' || format === 'jpg') return 'image/jpeg'
|
||||||
|
if (format === 'png') return 'image/png'
|
||||||
|
if (format === 'gif') return 'image/gif'
|
||||||
|
if (format === 'webp') return 'image/webp'
|
||||||
|
if (format === 'svg' || format === 'svg+xml') return 'image/svg+xml'
|
||||||
|
if (format === 'avif') return 'image/avif'
|
||||||
|
|
||||||
|
const ext = path.extname(src || '').toLowerCase()
|
||||||
|
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
||||||
|
if (ext === '.png') return 'image/png'
|
||||||
|
if (ext === '.gif') return 'image/gif'
|
||||||
|
if (ext === '.webp') return 'image/webp'
|
||||||
|
if (ext === '.svg') return 'image/svg+xml'
|
||||||
|
if (ext === '.avif') return 'image/avif'
|
||||||
|
return 'application/octet-stream'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await ensureData()
|
||||||
|
|
||||||
|
const referencedSrcs = Array.from(new Set(await listReferencedUploadSources()))
|
||||||
|
.filter((src) => typeof src === 'string' && src.startsWith('/uploads/'))
|
||||||
|
.sort()
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
scanned: referencedSrcs.length,
|
||||||
|
skippedExisting: 0,
|
||||||
|
backfilled: 0,
|
||||||
|
missingFiles: 0,
|
||||||
|
failed: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const src of referencedSrcs) {
|
||||||
|
const existing = await findImageAssetBySrc(src)
|
||||||
|
if (existing) {
|
||||||
|
summary.skippedExisting += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = path.join(BACKEND_ROOT, src.replace(/^\//, ''))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [buffer, stat] = await Promise.all([fs.readFile(absolutePath), fs.stat(absolutePath)])
|
||||||
|
let metadata = {}
|
||||||
|
try {
|
||||||
|
metadata = await sharp(buffer, { failOn: 'none' }).metadata()
|
||||||
|
} catch (error) {
|
||||||
|
metadata = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawHash = crypto.createHash('sha256').update(buffer).digest('hex')
|
||||||
|
const contentHash = crypto.createHash('sha256').update(`${rawHash}|${src}`).digest('hex')
|
||||||
|
|
||||||
|
await createImageAsset({
|
||||||
|
id: nanoid(),
|
||||||
|
contentHash,
|
||||||
|
src,
|
||||||
|
mimeType: inferMimeType(src, metadata),
|
||||||
|
byteSize: Number(stat.size || 0),
|
||||||
|
originalByteSize: Number(stat.size || 0),
|
||||||
|
width: Number(metadata.width || 0),
|
||||||
|
height: Number(metadata.height || 0),
|
||||||
|
})
|
||||||
|
summary.backfilled += 1
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') {
|
||||||
|
summary.missingFiles += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (error?.code === 'ER_DUP_ENTRY') {
|
||||||
|
summary.skippedExisting += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
summary.failed += 1
|
||||||
|
console.error('[backfill-legacy-image-assets] failed:', src, error?.message || error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify(summary, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await closePool()
|
||||||
|
})
|
||||||
56
backend/scripts/cleanup-unreferenced-legacy-uploads.js
Normal file
56
backend/scripts/cleanup-unreferenced-legacy-uploads.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
const fs = require('fs/promises')
|
||||||
|
const path = require('path')
|
||||||
|
const {
|
||||||
|
ensureData,
|
||||||
|
closePool,
|
||||||
|
listReferencedUploadSources,
|
||||||
|
} = require('../src/db')
|
||||||
|
|
||||||
|
const BACKEND_ROOT = path.join(__dirname, '..')
|
||||||
|
const TARGET_DIRS = ['avatars', 'custom', 'games', 'tierlists']
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await ensureData()
|
||||||
|
|
||||||
|
const referenced = new Set(await listReferencedUploadSources())
|
||||||
|
const deleted = []
|
||||||
|
const missing = []
|
||||||
|
let scanned = 0
|
||||||
|
|
||||||
|
for (const dir of TARGET_DIRS) {
|
||||||
|
const absoluteDir = path.join(BACKEND_ROOT, 'uploads', dir)
|
||||||
|
let entries = []
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(absoluteDir, { withFileTypes: true })
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') continue
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) continue
|
||||||
|
scanned += 1
|
||||||
|
const src = `/uploads/${dir}/${entry.name}`
|
||||||
|
if (referenced.has(src)) continue
|
||||||
|
const absolutePath = path.join(absoluteDir, entry.name)
|
||||||
|
try {
|
||||||
|
await fs.unlink(absolutePath)
|
||||||
|
deleted.push(src)
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') missing.push(src)
|
||||||
|
else throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ scanned, deletedCount: deleted.length, missingCount: missing.length, deleted, missing }, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await closePool()
|
||||||
|
})
|
||||||
112
backend/scripts/migrate-legacy-uploads-to-assets.js
Normal file
112
backend/scripts/migrate-legacy-uploads-to-assets.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
const fs = require('fs/promises')
|
||||||
|
const path = require('path')
|
||||||
|
const sharp = require('sharp')
|
||||||
|
const {
|
||||||
|
ensureData,
|
||||||
|
closePool,
|
||||||
|
listReferencedUploadUsage,
|
||||||
|
replaceUploadSourceReferences,
|
||||||
|
} = require('../src/db')
|
||||||
|
const { writeOptimizedImage } = require('../src/lib/image-storage')
|
||||||
|
|
||||||
|
const BACKEND_ROOT = path.join(__dirname, '..')
|
||||||
|
|
||||||
|
function inferMimeType(src, metadata) {
|
||||||
|
const format = String(metadata?.format || '').toLowerCase()
|
||||||
|
if (format === 'jpeg' || format === 'jpg') return 'image/jpeg'
|
||||||
|
if (format === 'png') return 'image/png'
|
||||||
|
if (format === 'gif') return 'image/gif'
|
||||||
|
if (format === 'webp') return 'image/webp'
|
||||||
|
if (format === 'svg' || format === 'svg+xml') return 'image/svg+xml'
|
||||||
|
if (format === 'avif') return 'image/avif'
|
||||||
|
|
||||||
|
const ext = path.extname(src || '').toLowerCase()
|
||||||
|
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
||||||
|
if (ext === '.png') return 'image/png'
|
||||||
|
if (ext === '.gif') return 'image/gif'
|
||||||
|
if (ext === '.webp') return 'image/webp'
|
||||||
|
if (ext === '.svg') return 'image/svg+xml'
|
||||||
|
if (ext === '.avif') return 'image/avif'
|
||||||
|
return 'application/octet-stream'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptimizationConfig(roles) {
|
||||||
|
const roleSet = new Set(roles || [])
|
||||||
|
if (roleSet.has('avatar')) {
|
||||||
|
return { directory: 'avatars', width: 512, height: 512, fit: 'cover', quality: 82 }
|
||||||
|
}
|
||||||
|
if (roleSet.has('game-thumbnail') || roleSet.has('tierlist-thumbnail') || roleSet.has('template-thumbnail')) {
|
||||||
|
return { directory: 'legacy-thumbnails', width: 1280, height: 1280, fit: 'inside', quality: 84 }
|
||||||
|
}
|
||||||
|
return { directory: 'legacy-items', width: 512, height: 512, fit: 'inside', quality: 84 }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFileLike(src) {
|
||||||
|
const absolutePath = path.join(BACKEND_ROOT, src.replace(/^\//, ''))
|
||||||
|
const [buffer, stat] = await Promise.all([fs.readFile(absolutePath), fs.stat(absolutePath)])
|
||||||
|
let metadata = {}
|
||||||
|
try {
|
||||||
|
metadata = await sharp(buffer, { failOn: 'none' }).metadata()
|
||||||
|
} catch (error) {
|
||||||
|
metadata = {}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
file: {
|
||||||
|
originalname: path.basename(src),
|
||||||
|
mimetype: inferMimeType(src, metadata),
|
||||||
|
size: Number(stat.size || 0),
|
||||||
|
buffer,
|
||||||
|
},
|
||||||
|
absolutePath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await ensureData()
|
||||||
|
const usageEntries = await listReferencedUploadUsage()
|
||||||
|
const legacyEntries = usageEntries.filter((entry) => entry.src && entry.src.startsWith('/uploads/') && !entry.src.startsWith('/uploads/assets/'))
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
scanned: legacyEntries.length,
|
||||||
|
migrated: 0,
|
||||||
|
reusedAsset: 0,
|
||||||
|
unchanged: 0,
|
||||||
|
missingFiles: 0,
|
||||||
|
failed: 0,
|
||||||
|
updatedRows: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of legacyEntries) {
|
||||||
|
const config = getOptimizationConfig(entry.roles)
|
||||||
|
try {
|
||||||
|
const { file } = await createFileLike(entry.src)
|
||||||
|
const optimized = await writeOptimizedImage({ file, ...config })
|
||||||
|
if (optimized.src === entry.src) {
|
||||||
|
summary.unchanged += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const replaced = await replaceUploadSourceReferences({ fromSrc: entry.src, toSrc: optimized.src })
|
||||||
|
summary.updatedRows += Number(replaced.updatedRows || 0)
|
||||||
|
if (optimized.reused) summary.reusedAsset += 1
|
||||||
|
else summary.migrated += 1
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') {
|
||||||
|
summary.missingFiles += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
summary.failed += 1
|
||||||
|
console.error('[migrate-legacy-uploads-to-assets] failed:', entry.src, error?.message || error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify(summary, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await closePool()
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
@@ -36,6 +38,21 @@ function collectUploadSrcsFromItems(items, bucket) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -206,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 () => {
|
||||||
@@ -601,6 +626,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(
|
||||||
@@ -645,10 +678,24 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -705,6 +752,233 @@ async function deleteImageAssets(ids) {
|
|||||||
await query(`DELETE FROM image_assets WHERE id IN (${placeholders})`, uniqueIds)
|
await query(`DELETE FROM image_assets WHERE id IN (${placeholders})`, uniqueIds)
|
||||||
return rows.map(mapImageAssetRow)
|
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 (?, ?, ?, ?, ?)', [
|
||||||
@@ -1572,6 +1846,7 @@ async function unfavoriteGame({ userId, gameId }) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
DB_NAME,
|
DB_NAME,
|
||||||
ensureData,
|
ensureData,
|
||||||
|
closePool,
|
||||||
countUsers,
|
countUsers,
|
||||||
findUserByEmail,
|
findUserByEmail,
|
||||||
findUserById,
|
findUserById,
|
||||||
@@ -1588,6 +1863,7 @@ module.exports = {
|
|||||||
createGame,
|
createGame,
|
||||||
updateGameThumbnail,
|
updateGameThumbnail,
|
||||||
findImageAssetByHash,
|
findImageAssetByHash,
|
||||||
|
findImageAssetBySrc,
|
||||||
createImageAsset,
|
createImageAsset,
|
||||||
createImageOptimizationJob,
|
createImageOptimizationJob,
|
||||||
findImageOptimizationJobById,
|
findImageOptimizationJobById,
|
||||||
@@ -1595,6 +1871,11 @@ module.exports = {
|
|||||||
listRecentImageOptimizationJobs,
|
listRecentImageOptimizationJobs,
|
||||||
listUnusedImageAssets,
|
listUnusedImageAssets,
|
||||||
deleteImageAssets,
|
deleteImageAssets,
|
||||||
|
listReferencedUploadSources,
|
||||||
|
listReferencedUploadUsage,
|
||||||
|
replaceUploadSourceReferences,
|
||||||
|
clearImageOptimizationJobs,
|
||||||
|
getImageAssetStats,
|
||||||
createGameItem,
|
createGameItem,
|
||||||
updateGameItemLabel,
|
updateGameItemLabel,
|
||||||
deleteGameItem,
|
deleteGameItem,
|
||||||
|
|||||||
@@ -32,9 +32,12 @@ const {
|
|||||||
adminDeleteUser,
|
adminDeleteUser,
|
||||||
listUnusedImageAssets,
|
listUnusedImageAssets,
|
||||||
deleteImageAssets,
|
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()
|
||||||
|
|
||||||
@@ -248,6 +251,38 @@ router.post('/image-assets/cleanup', requireAdmin, async (req, res) => {
|
|||||||
res.json({ deletedCount: deleted.length, assets: 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) => {
|
||||||
|
|||||||
37
docs/todo.md
37
docs/todo.md
@@ -1,40 +1,13 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
## 즉시 확인 필요
|
## 즉시 확인 필요
|
||||||
- 피그마 기반 리디자인은 현재 공통 셸과 카드 목록 화면, 포커스 화면 안정화까지만 반영된 상태이므로, 에디터/관리자 우측 옵션 패널을 시안 구조에 맞게 실제 기능 패널로 이관하는 작업이 남아 있다.
|
- 티어표 랭크부분 삭제 버튼 최소화 필요 (각 라인별 우측 상단에 absolute 방식의 x 아이콘으로 변경. 클릭시 라인 삭제 경고를 보여주고 확인후 삭제 )
|
||||||
- 홈/게임 허브/내 티어표/즐겨찾기 카드 문법은 어느 정도 통일됐지만, 아직 실제 SVG 아이콘, 미세 간격, hover/selection 상태 같은 디테일은 더 다듬을 필요가 있다.
|
- 티어표 형식 추가 필요. 최근 게임들은 S, A, B,C 같은 랭크 뿐만 아니라 가로 열도 나누어진형태의 티어표를 원함 (공격, 방어, 지원 등 각 파트별 랭크를 보고싶어함)
|
||||||
- 목록 화면 상단 도구 막대는 공통 카드 문법으로 거의 맞췄지만, 실제 피그마처럼 필터 토글/정렬 상태를 시각적으로 더 강하게 드러내는 디테일은 남아 있다.
|
- 레거시 파일 정리 스크립트는 준비됐으므로, 운영 단계에서는 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 +17,6 @@
|
|||||||
- 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다.
|
- 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다.
|
||||||
|
|
||||||
## 중기 개선
|
## 중기 개선
|
||||||
- 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다.
|
|
||||||
- 자동 테스트와 최소한의 배포 체크리스트를 만든다.
|
|
||||||
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
|
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
|
||||||
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다.
|
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다.
|
||||||
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
||||||
|
|||||||
@@ -1,5 +1,30 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 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
|
## 2026-03-31 v1.3.3
|
||||||
- `image_assets` 참조를 전수 점검해 아무 곳에서도 사용하지 않는 최적화 이미지 자산만 추려내는 정리 배치 로직을 추가함.
|
- `image_assets` 참조를 전수 점검해 아무 곳에서도 사용하지 않는 최적화 이미지 자산만 추려내는 정리 배치 로직을 추가함.
|
||||||
- 관리자용 미사용 자산 조회/정리 API를 추가해 오래된 고아 이미지 자산을 미리 확인하거나 실제로 삭제할 수 있도록 확장함.
|
- 관리자용 미사용 자산 조회/정리 API를 추가해 오래된 고아 이미지 자산을 미리 확인하거나 실제로 삭제할 수 있도록 확장함.
|
||||||
|
|||||||
1
frontend/src/assets/icons/kid_star.svg
Normal file
1
frontend/src/assets/icons/kid_star.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="m305-704 112-145q12-16 28.5-23.5T480-880q18 0 34.5 7.5T543-849l112 145 170 57q26 8 41 29.5t15 47.5q0 12-3.5 24T866-523L756-367l4 164q1 35-23 59t-56 24q-2 0-22-3l-179-50-179 50q-5 2-11 2.5t-11 .5q-32 0-56-24t-23-59l4-165L95-523q-8-11-11.5-23T80-570q0-25 14.5-46.5T135-647l170-57Zm49 69-194 64 124 179-4 191 200-55 200 56-4-192 124-177-194-66-126-165-126 165Zm126 135Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 490 B |
@@ -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) =>
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ const modalRoleNextAdmin = ref(false)
|
|||||||
const modalTargetCustomItem = ref(null)
|
const modalTargetCustomItem = ref(null)
|
||||||
|
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
|
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('')
|
||||||
@@ -166,7 +172,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 +232,69 @@ 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 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
|
||||||
@@ -1695,6 +1764,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 +1924,62 @@ async function saveFeaturedOrder() {
|
|||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<section 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 +2070,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 +2118,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;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
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 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 +92,7 @@ function thumbUrl(g) {
|
|||||||
:disabled="loadingFavoriteId === g.id"
|
:disabled="loadingFavoriteId === g.id"
|
||||||
@click.stop="toggleFavorite(g, $event)"
|
@click.stop="toggleFavorite(g, $event)"
|
||||||
>
|
>
|
||||||
{{ g.isFavorited ? '★' : '☆' }}
|
<img class="libraryCard__favoriteIcon" :src="kidStarIcon" alt="" aria-hidden="true" />
|
||||||
</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,9 +170,24 @@ 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 {
|
||||||
color: #ffd86b;
|
background: rgba(54, 45, 10, 0.92);
|
||||||
|
border-color: rgba(255, 216, 107, 0.28);
|
||||||
|
}
|
||||||
|
.libraryCard__favoriteIcon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: block;
|
||||||
|
opacity: 0.76;
|
||||||
|
filter: brightness(0) saturate(100%) invert(100%);
|
||||||
|
}
|
||||||
|
.libraryCard__favorite--active .libraryCard__favoriteIcon {
|
||||||
|
opacity: 1;
|
||||||
|
filter: brightness(0) saturate(100%) invert(86%) sepia(45%) saturate(529%) hue-rotate(351deg) brightness(103%) contrast(101%);
|
||||||
}
|
}
|
||||||
.libraryCard__thumbWrap {
|
.libraryCard__thumbWrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -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": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user