Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f9a7cc947 | |||
| 880c79bbc4 | |||
| 7967361cac |
@@ -5,7 +5,10 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor nodemon --legacy-watch --watch index.js --watch src index.js",
|
||||
"start": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js"
|
||||
"start": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js",
|
||||
"images:backfill": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/backfill-legacy-image-assets.js",
|
||||
"images:migrate-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-legacy-uploads-to-assets.js",
|
||||
"uploads:cleanup-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/cleanup-unreferenced-legacy-uploads.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
106
backend/scripts/backfill-legacy-image-assets.js
Normal file
106
backend/scripts/backfill-legacy-image-assets.js
Normal file
@@ -0,0 +1,106 @@
|
||||
const fs = require('fs/promises')
|
||||
const path = require('path')
|
||||
const crypto = require('crypto')
|
||||
const sharp = require('sharp')
|
||||
const { nanoid } = require('nanoid')
|
||||
const {
|
||||
ensureData,
|
||||
closePool,
|
||||
listReferencedUploadSources,
|
||||
findImageAssetBySrc,
|
||||
createImageAsset,
|
||||
} = require('../src/db')
|
||||
|
||||
const BACKEND_ROOT = path.join(__dirname, '..')
|
||||
|
||||
function inferMimeType(src, metadata) {
|
||||
const format = String(metadata?.format || '').toLowerCase()
|
||||
if (format === 'jpeg' || format === 'jpg') return 'image/jpeg'
|
||||
if (format === 'png') return 'image/png'
|
||||
if (format === 'gif') return 'image/gif'
|
||||
if (format === 'webp') return 'image/webp'
|
||||
if (format === 'svg' || format === 'svg+xml') return 'image/svg+xml'
|
||||
if (format === 'avif') return 'image/avif'
|
||||
|
||||
const ext = path.extname(src || '').toLowerCase()
|
||||
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
||||
if (ext === '.png') return 'image/png'
|
||||
if (ext === '.gif') return 'image/gif'
|
||||
if (ext === '.webp') return 'image/webp'
|
||||
if (ext === '.svg') return 'image/svg+xml'
|
||||
if (ext === '.avif') return 'image/avif'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureData()
|
||||
|
||||
const referencedSrcs = Array.from(new Set(await listReferencedUploadSources()))
|
||||
.filter((src) => typeof src === 'string' && src.startsWith('/uploads/'))
|
||||
.sort()
|
||||
|
||||
const summary = {
|
||||
scanned: referencedSrcs.length,
|
||||
skippedExisting: 0,
|
||||
backfilled: 0,
|
||||
missingFiles: 0,
|
||||
failed: 0,
|
||||
}
|
||||
|
||||
for (const src of referencedSrcs) {
|
||||
const existing = await findImageAssetBySrc(src)
|
||||
if (existing) {
|
||||
summary.skippedExisting += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const absolutePath = path.join(BACKEND_ROOT, src.replace(/^\//, ''))
|
||||
|
||||
try {
|
||||
const [buffer, stat] = await Promise.all([fs.readFile(absolutePath), fs.stat(absolutePath)])
|
||||
let metadata = {}
|
||||
try {
|
||||
metadata = await sharp(buffer, { failOn: 'none' }).metadata()
|
||||
} catch (error) {
|
||||
metadata = {}
|
||||
}
|
||||
|
||||
const rawHash = crypto.createHash('sha256').update(buffer).digest('hex')
|
||||
const contentHash = crypto.createHash('sha256').update(`${rawHash}|${src}`).digest('hex')
|
||||
|
||||
await createImageAsset({
|
||||
id: nanoid(),
|
||||
contentHash,
|
||||
src,
|
||||
mimeType: inferMimeType(src, metadata),
|
||||
byteSize: Number(stat.size || 0),
|
||||
originalByteSize: Number(stat.size || 0),
|
||||
width: Number(metadata.width || 0),
|
||||
height: Number(metadata.height || 0),
|
||||
})
|
||||
summary.backfilled += 1
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
summary.missingFiles += 1
|
||||
continue
|
||||
}
|
||||
if (error?.code === 'ER_DUP_ENTRY') {
|
||||
summary.skippedExisting += 1
|
||||
continue
|
||||
}
|
||||
summary.failed += 1
|
||||
console.error('[backfill-legacy-image-assets] failed:', src, error?.message || error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await closePool()
|
||||
})
|
||||
56
backend/scripts/cleanup-unreferenced-legacy-uploads.js
Normal file
56
backend/scripts/cleanup-unreferenced-legacy-uploads.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const fs = require('fs/promises')
|
||||
const path = require('path')
|
||||
const {
|
||||
ensureData,
|
||||
closePool,
|
||||
listReferencedUploadSources,
|
||||
} = require('../src/db')
|
||||
|
||||
const BACKEND_ROOT = path.join(__dirname, '..')
|
||||
const TARGET_DIRS = ['avatars', 'custom', 'games', 'tierlists']
|
||||
|
||||
async function main() {
|
||||
await ensureData()
|
||||
|
||||
const referenced = new Set(await listReferencedUploadSources())
|
||||
const deleted = []
|
||||
const missing = []
|
||||
let scanned = 0
|
||||
|
||||
for (const dir of TARGET_DIRS) {
|
||||
const absoluteDir = path.join(BACKEND_ROOT, 'uploads', dir)
|
||||
let entries = []
|
||||
try {
|
||||
entries = await fs.readdir(absoluteDir, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') continue
|
||||
throw error
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue
|
||||
scanned += 1
|
||||
const src = `/uploads/${dir}/${entry.name}`
|
||||
if (referenced.has(src)) continue
|
||||
const absolutePath = path.join(absoluteDir, entry.name)
|
||||
try {
|
||||
await fs.unlink(absolutePath)
|
||||
deleted.push(src)
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') missing.push(src)
|
||||
else throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ scanned, deletedCount: deleted.length, missingCount: missing.length, deleted, missing }, null, 2))
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await closePool()
|
||||
})
|
||||
112
backend/scripts/migrate-legacy-uploads-to-assets.js
Normal file
112
backend/scripts/migrate-legacy-uploads-to-assets.js
Normal file
@@ -0,0 +1,112 @@
|
||||
const fs = require('fs/promises')
|
||||
const path = require('path')
|
||||
const sharp = require('sharp')
|
||||
const {
|
||||
ensureData,
|
||||
closePool,
|
||||
listReferencedUploadUsage,
|
||||
replaceUploadSourceReferences,
|
||||
} = require('../src/db')
|
||||
const { writeOptimizedImage } = require('../src/lib/image-storage')
|
||||
|
||||
const BACKEND_ROOT = path.join(__dirname, '..')
|
||||
|
||||
function inferMimeType(src, metadata) {
|
||||
const format = String(metadata?.format || '').toLowerCase()
|
||||
if (format === 'jpeg' || format === 'jpg') return 'image/jpeg'
|
||||
if (format === 'png') return 'image/png'
|
||||
if (format === 'gif') return 'image/gif'
|
||||
if (format === 'webp') return 'image/webp'
|
||||
if (format === 'svg' || format === 'svg+xml') return 'image/svg+xml'
|
||||
if (format === 'avif') return 'image/avif'
|
||||
|
||||
const ext = path.extname(src || '').toLowerCase()
|
||||
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
||||
if (ext === '.png') return 'image/png'
|
||||
if (ext === '.gif') return 'image/gif'
|
||||
if (ext === '.webp') return 'image/webp'
|
||||
if (ext === '.svg') return 'image/svg+xml'
|
||||
if (ext === '.avif') return 'image/avif'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
function getOptimizationConfig(roles) {
|
||||
const roleSet = new Set(roles || [])
|
||||
if (roleSet.has('avatar')) {
|
||||
return { directory: 'avatars', width: 512, height: 512, fit: 'cover', quality: 82 }
|
||||
}
|
||||
if (roleSet.has('game-thumbnail') || roleSet.has('tierlist-thumbnail') || roleSet.has('template-thumbnail')) {
|
||||
return { directory: 'legacy-thumbnails', width: 1280, height: 1280, fit: 'inside', quality: 84 }
|
||||
}
|
||||
return { directory: 'legacy-items', width: 512, height: 512, fit: 'inside', quality: 84 }
|
||||
}
|
||||
|
||||
async function createFileLike(src) {
|
||||
const absolutePath = path.join(BACKEND_ROOT, src.replace(/^\//, ''))
|
||||
const [buffer, stat] = await Promise.all([fs.readFile(absolutePath), fs.stat(absolutePath)])
|
||||
let metadata = {}
|
||||
try {
|
||||
metadata = await sharp(buffer, { failOn: 'none' }).metadata()
|
||||
} catch (error) {
|
||||
metadata = {}
|
||||
}
|
||||
return {
|
||||
file: {
|
||||
originalname: path.basename(src),
|
||||
mimetype: inferMimeType(src, metadata),
|
||||
size: Number(stat.size || 0),
|
||||
buffer,
|
||||
},
|
||||
absolutePath,
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureData()
|
||||
const usageEntries = await listReferencedUploadUsage()
|
||||
const legacyEntries = usageEntries.filter((entry) => entry.src && entry.src.startsWith('/uploads/') && !entry.src.startsWith('/uploads/assets/'))
|
||||
|
||||
const summary = {
|
||||
scanned: legacyEntries.length,
|
||||
migrated: 0,
|
||||
reusedAsset: 0,
|
||||
unchanged: 0,
|
||||
missingFiles: 0,
|
||||
failed: 0,
|
||||
updatedRows: 0,
|
||||
}
|
||||
|
||||
for (const entry of legacyEntries) {
|
||||
const config = getOptimizationConfig(entry.roles)
|
||||
try {
|
||||
const { file } = await createFileLike(entry.src)
|
||||
const optimized = await writeOptimizedImage({ file, ...config })
|
||||
if (optimized.src === entry.src) {
|
||||
summary.unchanged += 1
|
||||
continue
|
||||
}
|
||||
const replaced = await replaceUploadSourceReferences({ fromSrc: entry.src, toSrc: optimized.src })
|
||||
summary.updatedRows += Number(replaced.updatedRows || 0)
|
||||
if (optimized.reused) summary.reusedAsset += 1
|
||||
else summary.migrated += 1
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
summary.missingFiles += 1
|
||||
continue
|
||||
}
|
||||
summary.failed += 1
|
||||
console.error('[migrate-legacy-uploads-to-assets] failed:', entry.src, error?.message || error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await closePool()
|
||||
})
|
||||
@@ -223,6 +223,14 @@ async function query(sql, params = []) {
|
||||
return rows
|
||||
}
|
||||
|
||||
async function closePool() {
|
||||
if (!poolPromise) return
|
||||
const pool = await poolPromise
|
||||
await pool.end()
|
||||
poolPromise = null
|
||||
initPromise = null
|
||||
}
|
||||
|
||||
async function ensureSchema() {
|
||||
if (initPromise) return initPromise
|
||||
initPromise = (async () => {
|
||||
@@ -618,6 +626,14 @@ async function findImageAssetByHash(contentHash) {
|
||||
return mapImageAssetRow(rows[0])
|
||||
}
|
||||
|
||||
async function findImageAssetBySrc(src) {
|
||||
const rows = await query(
|
||||
'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE src = ? LIMIT 1',
|
||||
[src]
|
||||
)
|
||||
return mapImageAssetRow(rows[0])
|
||||
}
|
||||
|
||||
async function createImageAsset({ id, contentHash, src, mimeType = "image/webp", byteSize, originalByteSize, width, height }) {
|
||||
const createdAt = now()
|
||||
await query(
|
||||
@@ -738,32 +754,116 @@ async function deleteImageAssets(ids) {
|
||||
}
|
||||
|
||||
async function listReferencedUploadSources() {
|
||||
const referencedSrcs = new Set()
|
||||
const usage = await listReferencedUploadUsage()
|
||||
return usage.map((entry) => entry.src)
|
||||
}
|
||||
|
||||
async function listReferencedUploadUsage() {
|
||||
const usageMap = new Map()
|
||||
const addUsage = (src, role) => {
|
||||
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return
|
||||
if (!usageMap.has(src)) usageMap.set(src, new Set())
|
||||
usageMap.get(src).add(role)
|
||||
}
|
||||
|
||||
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
|
||||
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
|
||||
query("SELECT thumbnail_src FROM games WHERE thumbnail_src <> ''"),
|
||||
query("SELECT src FROM game_items WHERE src <> ''"),
|
||||
query("SELECT src FROM custom_items WHERE src <> ''"),
|
||||
query("SELECT thumbnail_src, pool_json FROM tierlists"),
|
||||
query("SELECT thumbnail_src_snapshot, items_json FROM template_requests"),
|
||||
query("SELECT id, thumbnail_src, pool_json FROM tierlists"),
|
||||
query("SELECT id, thumbnail_src_snapshot, items_json FROM template_requests"),
|
||||
])
|
||||
|
||||
for (const row of userRows) if (row.avatar_src) referencedSrcs.add(row.avatar_src)
|
||||
for (const row of gameRows) if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
|
||||
for (const row of gameItemRows) if (row.src) referencedSrcs.add(row.src)
|
||||
for (const row of customItemRows) if (row.src) referencedSrcs.add(row.src)
|
||||
for (const row of userRows) addUsage(row.avatar_src, 'avatar')
|
||||
for (const row of gameRows) addUsage(row.thumbnail_src, 'game-thumbnail')
|
||||
for (const row of gameItemRows) addUsage(row.src, 'game-item')
|
||||
for (const row of customItemRows) addUsage(row.src, 'custom-item')
|
||||
|
||||
for (const row of tierListRows) {
|
||||
if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
|
||||
collectUploadSrcsFromItems(parseJson(row.pool_json, []), referencedSrcs)
|
||||
addUsage(row.thumbnail_src, 'tierlist-thumbnail')
|
||||
for (const item of parseJson(row.pool_json, [])) addUsage(item?.src, 'tierlist-pool')
|
||||
}
|
||||
|
||||
for (const row of templateRequestRows) {
|
||||
if (row.thumbnail_src_snapshot) referencedSrcs.add(row.thumbnail_src_snapshot)
|
||||
collectUploadSrcsFromItems(parseJson(row.items_json, []), referencedSrcs)
|
||||
addUsage(row.thumbnail_src_snapshot, 'template-thumbnail')
|
||||
for (const item of parseJson(row.items_json, [])) addUsage(item?.src, 'template-item')
|
||||
}
|
||||
|
||||
return Array.from(referencedSrcs)
|
||||
return Array.from(usageMap.entries())
|
||||
.map(([src, roles]) => ({ src, roles: Array.from(roles).sort() }))
|
||||
.sort((a, b) => a.src.localeCompare(b.src))
|
||||
}
|
||||
|
||||
function replaceItemSrc(items, fromSrc, toSrc) {
|
||||
let changed = false
|
||||
const nextItems = (items || []).map((item) => {
|
||||
if (item?.src !== fromSrc) return item
|
||||
changed = true
|
||||
return { ...item, src: toSrc }
|
||||
})
|
||||
return { changed, items: nextItems }
|
||||
}
|
||||
|
||||
async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
|
||||
if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 }
|
||||
|
||||
const [userResult, gameResult, gameItemResult, customItemResult] = await Promise.all([
|
||||
query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]),
|
||||
query('UPDATE games SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]),
|
||||
query('UPDATE game_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
|
||||
query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
|
||||
])
|
||||
|
||||
let updatedRows = Number(userResult.affectedRows || 0) + Number(gameResult.affectedRows || 0) + Number(gameItemResult.affectedRows || 0) + Number(customItemResult.affectedRows || 0)
|
||||
|
||||
const tierListRows = await query('SELECT id, thumbnail_src, pool_json FROM tierlists')
|
||||
for (const row of tierListRows) {
|
||||
let nextThumbnail = row.thumbnail_src
|
||||
let changed = false
|
||||
if (row.thumbnail_src === fromSrc) {
|
||||
nextThumbnail = toSrc
|
||||
changed = true
|
||||
}
|
||||
|
||||
const replacedPool = replaceItemSrc(parseJson(row.pool_json, []), fromSrc, toSrc)
|
||||
if (replacedPool.changed) changed = true
|
||||
|
||||
if (changed) {
|
||||
await query('UPDATE tierlists SET thumbnail_src = ?, pool_json = ?, updated_at = ? WHERE id = ?', [
|
||||
nextThumbnail || '',
|
||||
serializeJson(replacedPool.items),
|
||||
now(),
|
||||
row.id,
|
||||
])
|
||||
updatedRows += 1
|
||||
}
|
||||
}
|
||||
|
||||
const requestRows = await query('SELECT id, thumbnail_src_snapshot, items_json 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() {
|
||||
@@ -1746,6 +1846,7 @@ async function unfavoriteGame({ userId, gameId }) {
|
||||
module.exports = {
|
||||
DB_NAME,
|
||||
ensureData,
|
||||
closePool,
|
||||
countUsers,
|
||||
findUserByEmail,
|
||||
findUserById,
|
||||
@@ -1762,6 +1863,7 @@ module.exports = {
|
||||
createGame,
|
||||
updateGameThumbnail,
|
||||
findImageAssetByHash,
|
||||
findImageAssetBySrc,
|
||||
createImageAsset,
|
||||
createImageOptimizationJob,
|
||||
findImageOptimizationJobById,
|
||||
@@ -1770,6 +1872,8 @@ module.exports = {
|
||||
listUnusedImageAssets,
|
||||
deleteImageAssets,
|
||||
listReferencedUploadSources,
|
||||
listReferencedUploadUsage,
|
||||
replaceUploadSourceReferences,
|
||||
clearImageOptimizationJobs,
|
||||
getImageAssetStats,
|
||||
createGameItem,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 즉시 확인 필요
|
||||
- 레거시 업로드 파일도 현재 실사용 용량에는 포함되지만, 과거 자산까지 'image_assets' 메타에 백필한 상태는 아니므로 필요하면 1회 백필 스크립트를 추가해 절감률/중복 통계를 완전히 일원화한다.
|
||||
- 티어표 랭크부분 삭제 버튼 최소화 필요 (각 라인별 우측 상단에 absolute 방식의 x 아이콘으로 변경. 클릭시 라인 삭제 경고를 보여주고 확인후 삭제 )
|
||||
- 티어표 형식 추가 필요. 최근 게임들은 S, A, B,C 같은 랭크 뿐만 아니라 가로 열도 나누어진형태의 티어표를 원함 (공격, 방어, 지원 등 각 파트별 랭크를 보고싶어함)
|
||||
- 레거시 파일 정리 스크립트는 준비됐으므로, 운영 단계에서는 cron 등으로 주기 실행할지와 삭제 전 보관 기간을 함께 정한다.
|
||||
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
||||
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
|
||||
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 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건 선택과 월 단위 필터를 지원해, 특정 기간 사용량과 최적화 이력을 운영 관점에서 바로 확인할 수 있게 정리함.
|
||||
|
||||
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 |
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import kidStarIcon from '../assets/icons/kid_star.svg'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
@@ -91,7 +92,7 @@ function thumbUrl(g) {
|
||||
:disabled="loadingFavoriteId === g.id"
|
||||
@click.stop="toggleFavorite(g, $event)"
|
||||
>
|
||||
{{ g.isFavorited ? '★' : '☆' }}
|
||||
<img class="libraryCard__favoriteIcon" :src="kidStarIcon" alt="" aria-hidden="true" />
|
||||
</button>
|
||||
<button class="libraryCard__main" type="button" @click="goGame(g.id)">
|
||||
<div class="libraryCard__thumbWrap">
|
||||
@@ -169,9 +170,24 @@ function thumbUrl(g) {
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.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 {
|
||||
width: 100%;
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
"dev:backend": "npm --prefix backend run dev",
|
||||
"build": "npm --prefix frontend run build",
|
||||
"start": "npm --prefix backend run start",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"images:backfill": "npm --prefix backend run images:backfill",
|
||||
"images:migrate-legacy": "npm --prefix backend run images:migrate-legacy",
|
||||
"uploads:cleanup-legacy": "npm --prefix backend run uploads:cleanup-legacy"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
Reference in New Issue
Block a user