Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b1ba19572 | |||
| b4ada4b9a2 | |||
| 7f9a7cc947 | |||
| 880c79bbc4 |
@@ -6,7 +6,9 @@
|
|||||||
"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: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": "",
|
||||||
|
|||||||
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()
|
||||||
|
})
|
||||||
@@ -754,32 +754,116 @@ async function deleteImageAssets(ids) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function listReferencedUploadSources() {
|
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([
|
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
|
||||||
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
|
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
|
||||||
query("SELECT thumbnail_src FROM games WHERE thumbnail_src <> ''"),
|
query("SELECT thumbnail_src FROM games WHERE thumbnail_src <> ''"),
|
||||||
query("SELECT src FROM game_items WHERE src <> ''"),
|
query("SELECT src FROM game_items WHERE src <> ''"),
|
||||||
query("SELECT src FROM custom_items WHERE src <> ''"),
|
query("SELECT src FROM custom_items WHERE src <> ''"),
|
||||||
query("SELECT thumbnail_src, pool_json FROM tierlists"),
|
query("SELECT id, thumbnail_src, pool_json FROM tierlists"),
|
||||||
query("SELECT thumbnail_src_snapshot, items_json FROM template_requests"),
|
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 userRows) addUsage(row.avatar_src, 'avatar')
|
||||||
for (const row of gameRows) if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
|
for (const row of gameRows) addUsage(row.thumbnail_src, 'game-thumbnail')
|
||||||
for (const row of gameItemRows) if (row.src) referencedSrcs.add(row.src)
|
for (const row of gameItemRows) addUsage(row.src, 'game-item')
|
||||||
for (const row of customItemRows) if (row.src) referencedSrcs.add(row.src)
|
for (const row of customItemRows) addUsage(row.src, 'custom-item')
|
||||||
|
|
||||||
for (const row of tierListRows) {
|
for (const row of tierListRows) {
|
||||||
if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
|
addUsage(row.thumbnail_src, 'tierlist-thumbnail')
|
||||||
collectUploadSrcsFromItems(parseJson(row.pool_json, []), referencedSrcs)
|
for (const item of parseJson(row.pool_json, [])) addUsage(item?.src, 'tierlist-pool')
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const row of templateRequestRows) {
|
for (const row of templateRequestRows) {
|
||||||
if (row.thumbnail_src_snapshot) referencedSrcs.add(row.thumbnail_src_snapshot)
|
addUsage(row.thumbnail_src_snapshot, 'template-thumbnail')
|
||||||
collectUploadSrcsFromItems(parseJson(row.items_json, []), referencedSrcs)
|
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() {
|
async function listImageAssets() {
|
||||||
@@ -1788,6 +1872,8 @@ module.exports = {
|
|||||||
listUnusedImageAssets,
|
listUnusedImageAssets,
|
||||||
deleteImageAssets,
|
deleteImageAssets,
|
||||||
listReferencedUploadSources,
|
listReferencedUploadSources,
|
||||||
|
listReferencedUploadUsage,
|
||||||
|
replaceUploadSourceReferences,
|
||||||
clearImageOptimizationJobs,
|
clearImageOptimizationJobs,
|
||||||
getImageAssetStats,
|
getImageAssetStats,
|
||||||
createGameItem,
|
createGameItem,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
## 즉시 확인 필요
|
## 즉시 확인 필요
|
||||||
- 레거시 업로드 메타 백필은 가능해졌으므로, 필요하면 다음 단계에서 실제 파일 경로까지 `/uploads/assets/` 기준으로 재정렬하는 마이그레이션을 검토한다.
|
- 티어표 형식 추가 필요. 최근 게임들은 S, A, B,C 같은 랭크 뿐만 아니라 가로 열도 나누어진형태의 티어표를 원함 (공격, 방어, 지원 등 각 파트별 랭크를 보고싶어함)
|
||||||
|
- 레거시 파일 정리 스크립트는 준비됐으므로, 운영 단계에서는 cron 등으로 주기 실행할지와 삭제 전 보관 기간을 함께 정한다.
|
||||||
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
||||||
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
|
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
|
||||||
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.
|
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 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
|
## 2026-03-31 v1.3.6
|
||||||
- 현재 참조 중인 레거시 업로드 파일을 'image_assets' 메타에 안전하게 편입하는 1회 백필 스크립트를 추가해, 과거 이미지도 최적화 대시보드와 같은 통계 체계 안에서 집계할 수 있게 함.
|
- 현재 참조 중인 레거시 업로드 파일을 'image_assets' 메타에 안전하게 편입하는 1회 백필 스크립트를 추가해, 과거 이미지도 최적화 대시보드와 같은 통계 체계 안에서 집계할 수 있게 함.
|
||||||
- 루트와 백엔드에 백필 실행 스크립트를 연결해 운영 중 필요할 때 즉시 재실행할 수 있도록 정리함.
|
- 루트와 백엔드에 백필 실행 스크립트를 연결해 운영 중 필요할 때 즉시 재실행할 수 있도록 정리함.
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
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 |
38
frontend/src/components/SvgIcon.vue
Normal file
38
frontend/src/components/SvgIcon.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
src: { type: String, required: true },
|
||||||
|
size: { type: [Number, String], default: 20 },
|
||||||
|
color: { type: String, default: 'currentColor' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizedSize = computed(() => (typeof props.size === "number" ? `${props.size}px` : props.size))
|
||||||
|
const iconStyle = computed(() => ({
|
||||||
|
"--svg-icon-src": `url("${props.src}")`,
|
||||||
|
"--svg-icon-size": normalizedSize.value,
|
||||||
|
"--svg-icon-color": props.color,
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="svgIcon" :style="iconStyle" aria-hidden="true"></span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.svgIcon {
|
||||||
|
display: inline-block;
|
||||||
|
width: var(--svg-icon-size);
|
||||||
|
height: var(--svg-icon-size);
|
||||||
|
background-color: var(--svg-icon-color);
|
||||||
|
-webkit-mask-image: var(--svg-icon-src);
|
||||||
|
mask-image: var(--svg-icon-src);
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-position: center;
|
||||||
|
mask-position: center;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
mask-size: contain;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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'
|
||||||
|
|
||||||
@@ -257,6 +258,9 @@ const imageDiagnosticsCards = computed(() => {
|
|||||||
{ label: '절감률', value: `${Math.round((stats.savingsRatio || 0) * 100)}%` },
|
{ label: '절감률', value: `${Math.round((stats.savingsRatio || 0) * 100)}%` },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
const visibleLinkedGames = computed(() =>
|
||||||
|
(modalTargetCustomItem.value?.linkedGames || []).filter((game) => game?.id && game.id !== 'freeform')
|
||||||
|
)
|
||||||
|
|
||||||
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
|
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
|
||||||
|
|
||||||
@@ -1570,7 +1574,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">
|
||||||
@@ -1601,10 +1605,10 @@ async function saveFeaturedOrder() {
|
|||||||
|
|
||||||
<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" :disabled="!isUserDirty(user)" @click="saveUser(user)">회원정보 저장</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1726,11 +1730,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">
|
||||||
@@ -1925,7 +1929,7 @@ async function saveFeaturedOrder() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
<section class="adminSidebar__panel">
|
<section v-if="activeTab === 'featured'" class="adminSidebar__panel">
|
||||||
<div class="adminSidebar__label">Image Optimization</div>
|
<div class="adminSidebar__label">Image Optimization</div>
|
||||||
<div class="adminSidebar__group">
|
<div class="adminSidebar__group">
|
||||||
<input v-model="imageStatsMonth" class="input" type="month" />
|
<input v-model="imageStatsMonth" class="input" type="month" />
|
||||||
@@ -2925,10 +2929,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;
|
||||||
@@ -3030,9 +3032,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -833,6 +853,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 +917,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" :readonly="!canEdit" />
|
||||||
<button v-if="canEdit" class="rowRemoveBtn" :disabled="groups.length <= 1" @click="removeGroup(g.id)">삭제</button>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -1146,7 +1188,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;
|
||||||
@@ -1512,6 +1554,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 +1562,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 +1590,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);
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
"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: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