Compare commits

..

54 Commits

Author SHA1 Message Date
aa114a170e 릴리스: v1.3.59 관리자 템플릿 요청 중복 방지 및 신규 템플릿 연결 흐름 정리 2026-04-02 12:46:59 +09:00
4f300e7dbc 릴리스: v1.3.58 관리자 드래그 fallback 및 드롭존 재정비 2026-04-02 12:06:13 +09:00
717e3b97f0 릴리스: v1.3.57 관리자 기본 아이템 카드 전체 드래그 복원 2026-04-02 12:02:00 +09:00
4fc7bcb29a 릴리스: v1.3.56 관리자 배지 색상 적용 및 아이템 드래그 핸들 추가 2026-04-02 11:59:15 +09:00
0a3fce2130 릴리스: v1.3.55 관리자 요청 썸네일 승계 및 배지 색상 구분 2026-04-02 11:54:53 +09:00
1d8e8581b8 릴리스: v1.3.54 관리자 요청 카드 정보 밀도 조정 2026-04-02 11:49:13 +09:00
036fc84fa6 릴리스: v1.3.53 관리자 featured/custom item 로직 분리 2026-04-02 11:40:10 +09:00
472b511b89 릴리스: v1.3.52 관리자 스타일 범위 및 티어표 모드 정리 2026-04-02 11:35:30 +09:00
6f8de5adf3 관리자 회원 관리 로직 composable 분리 2026-04-02 11:28:25 +09:00
147ff963ab 관리자 화면 분리 및 요청/아이템 관리 흐름 정리 2026-04-02 11:23:33 +09:00
66d408dca8 릴리스: v1.3.49 템플릿 요청 저장 흐름과 관리자 미리보기 정리 2026-04-01 19:01:07 +09:00
d5b4de1629 릴리스: v1.3.48 관리자 미리보기와 새로고침 로딩 보정 2026-04-01 18:37:01 +09:00
6828b868bc 릴리스: v1.3.47 관리자 템플릿 요청 카드 정렬 2026-04-01 18:24:01 +09:00
397461b7c0 릴리스: v1.3.46 관리자 요청 미리보기 정리 2026-04-01 18:11:16 +09:00
bd3ef5d13d 릴리스: v1.3.45 템플릿 요청 저장 오류 수정 2026-04-01 18:05:59 +09:00
322b72c511 릴리스: v1.3.44 관리자 미리보기 흐름 복구 2026-04-01 18:02:49 +09:00
508806bacd 릴리스: v1.3.43 템플릿 요청 토스트 상태 보정 2026-04-01 17:58:19 +09:00
c3af696cae 릴리스: v1.3.42 템플릿 요청 저장 분기 호환 보강 2026-04-01 17:53:02 +09:00
14674bc7ac 릴리스: v1.3.41 템플릿 요청 DB 마이그레이션 보강 2026-04-01 17:48:30 +09:00
d6576dc661 릴리스: v1.3.40 관리자 모달과 요청 미리보기 정리 2026-04-01 17:38:51 +09:00
fd2969c780 릴리스: v1.3.39 요청 미리보기와 아이템 이름 편집 보강 2026-04-01 17:07:42 +09:00
8aa60231a3 릴리스: v1.3.38 설정 우측 패널과 아이템 모달 보정 2026-04-01 16:52:35 +09:00
64b3e3e3df 릴리스: v1.3.37 가이드와 관리자 아이템 모달 정리 2026-04-01 16:43:21 +09:00
5f6f01942e 릴리스: v1.3.36 관리자 아이템 라이브러리 보강 2026-04-01 16:30:58 +09:00
7e80320e9f 릴리스: v1.3.35 라이트모드와 아이템 모달 보정 2026-04-01 16:11:24 +09:00
fb00ddb1d8 릴리스: v1.3.34 관리자 아이템 라이브러리 정리 2026-04-01 15:59:09 +09:00
6bbbbc1633 릴리스: v1.3.33 관리자와 에디터 테마 후속 보정 2026-04-01 15:40:33 +09:00
9ad985f7c5 릴리스: v1.3.32 라이트 다크 모드 1차 도입 2026-04-01 15:25:21 +09:00
3b5e744130 릴리스: v1.3.31 관리자 게임 선택 리스트 CSS 반영 2026-04-01 15:16:06 +09:00
28cf4fdfa0 릴리스: v1.3.30 헤더 브랜딩과 테마 할 일 정리 2026-04-01 15:13:45 +09:00
cf96e931e9 릴리스: v1.3.29 가이드 진입점과 인증 초기화 안정화 2026-04-01 15:07:58 +09:00
3a64dc44c8 릴리스: v1.3.28 사용법 모달 기능 안내 확장 2026-04-01 15:02:49 +09:00
91e16ba415 릴리스: v1.3.27 사용법 모달 구조 추가 2026-04-01 14:52:50 +09:00
a550385ed8 릴리스: v1.3.26 오른쪽 광고 슬롯 규격 정리 2026-04-01 14:40:50 +09:00
5b53c73b56 릴리스: v1.3.25 관리자 게임 선택 UX와 세션 보안 보강 2026-04-01 14:23:04 +09:00
7952f2f289 릴리스: v1.3.24 게임 허브 티어표 그리드 정렬 2026-04-01 14:11:10 +09:00
b851100c89 릴리스: v1.3.23 내 티어표 그리드 열 수 정렬 2026-04-01 14:07:56 +09:00
e70e685a06 릴리스: v1.3.22 내 티어표 카드 폭과 광고 프레임 정리 2026-04-01 14:04:15 +09:00
09acebc2d5 릴리스: v1.3.21 내 티어표 카드 레이아웃을 게임 목록과 통일 2026-04-01 13:53:48 +09:00
e3391b5f07 릴리스: v1.3.20 내 티어표 카드 그리드 밀도 보정 2026-04-01 13:49:54 +09:00
22220494d6 릴리스: v1.3.19 관리자 이미지 최적화 기간 선택 레이아웃 보정 2026-04-01 13:45:45 +09:00
909ed72502 릴리스: v1.3.18 템플릿 요청 실패 보완과 이미지 최적화 기간 선택 개선 2026-04-01 13:32:25 +09:00
c352bf459f 릴리스: v1.3.17 티어 에디터 열 정렬과 삭제 확인 흐름 보정 2026-04-01 12:29:49 +09:00
730a87b923 릴리스: v1.3.16 티어 에디터 행열 삭제 액션과 열 제목 정렬 보정 2026-04-01 12:22:21 +09:00
e9a049241d 릴리스: v1.3.15 티어 에디터 열 헤더와 액션 아이콘 정리 2026-04-01 12:16:06 +09:00
0fec84de13 릴리스: v1.3.14 티어 에디터 행열 보드 확장 2026-04-01 12:07:17 +09:00
7fe4eff7b7 릴리스: v1.3.13 템플릿 요청 스냅샷과 저장 분리 2026-04-01 11:50:54 +09:00
b2a838ff34 릴리스: v1.3.12 회원 정렬 방향과 입력 길이 피드백 2026-04-01 11:15:49 +09:00
695c0bd4dd 릴리스: v1.3.11 회원 관리 모달과 최고 관리자 보호 2026-04-01 10:53:14 +09:00
7b1ba19572 릴리스: v1.3.10 게임 허브 카드 폭과 SVG 아이콘 렌더링 정리 2026-04-01 10:29:34 +09:00
b4ada4b9a2 릴리스: v1.3.9 관리자 최적화 패널 범위와 티어 행 삭제 UX 정리 2026-04-01 10:11:48 +09:00
7f9a7cc947 릴리스: v1.3.8 홈 즐겨찾기 아이콘과 레거시 업로드 정리 2026-03-31 18:53:39 +09:00
880c79bbc4 릴리스: v1.3.7 레거시 업로드 자산 마이그레이션 스크립트 추가 2026-03-31 18:44:10 +09:00
7967361cac 릴리스: v1.3.6 레거시 이미지 메타 백필 스크립트 추가 2026-03-31 18:37:51 +09:00
46 changed files with 6860 additions and 1983 deletions

View File

@@ -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": "",

View File

@@ -0,0 +1,106 @@
const fs = require('fs/promises')
const path = require('path')
const crypto = require('crypto')
const sharp = require('sharp')
const { nanoid } = require('nanoid')
const {
ensureData,
closePool,
listReferencedUploadSources,
findImageAssetBySrc,
createImageAsset,
} = require('../src/db')
const BACKEND_ROOT = path.join(__dirname, '..')
function inferMimeType(src, metadata) {
const format = String(metadata?.format || '').toLowerCase()
if (format === 'jpeg' || format === 'jpg') return 'image/jpeg'
if (format === 'png') return 'image/png'
if (format === 'gif') return 'image/gif'
if (format === 'webp') return 'image/webp'
if (format === 'svg' || format === 'svg+xml') return 'image/svg+xml'
if (format === 'avif') return 'image/avif'
const ext = path.extname(src || '').toLowerCase()
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
if (ext === '.png') return 'image/png'
if (ext === '.gif') return 'image/gif'
if (ext === '.webp') return 'image/webp'
if (ext === '.svg') return 'image/svg+xml'
if (ext === '.avif') return 'image/avif'
return 'application/octet-stream'
}
async function main() {
await ensureData()
const referencedSrcs = Array.from(new Set(await listReferencedUploadSources()))
.filter((src) => typeof src === 'string' && src.startsWith('/uploads/'))
.sort()
const summary = {
scanned: referencedSrcs.length,
skippedExisting: 0,
backfilled: 0,
missingFiles: 0,
failed: 0,
}
for (const src of referencedSrcs) {
const existing = await findImageAssetBySrc(src)
if (existing) {
summary.skippedExisting += 1
continue
}
const absolutePath = path.join(BACKEND_ROOT, src.replace(/^\//, ''))
try {
const [buffer, stat] = await Promise.all([fs.readFile(absolutePath), fs.stat(absolutePath)])
let metadata = {}
try {
metadata = await sharp(buffer, { failOn: 'none' }).metadata()
} catch (error) {
metadata = {}
}
const rawHash = crypto.createHash('sha256').update(buffer).digest('hex')
const contentHash = crypto.createHash('sha256').update(`${rawHash}|${src}`).digest('hex')
await createImageAsset({
id: nanoid(),
contentHash,
src,
mimeType: inferMimeType(src, metadata),
byteSize: Number(stat.size || 0),
originalByteSize: Number(stat.size || 0),
width: Number(metadata.width || 0),
height: Number(metadata.height || 0),
})
summary.backfilled += 1
} catch (error) {
if (error?.code === 'ENOENT') {
summary.missingFiles += 1
continue
}
if (error?.code === 'ER_DUP_ENTRY') {
summary.skippedExisting += 1
continue
}
summary.failed += 1
console.error('[backfill-legacy-image-assets] failed:', src, error?.message || error)
}
}
console.log(JSON.stringify(summary, null, 2))
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(async () => {
await closePool()
})

View File

@@ -0,0 +1,56 @@
const fs = require('fs/promises')
const path = require('path')
const {
ensureData,
closePool,
listReferencedUploadSources,
} = require('../src/db')
const BACKEND_ROOT = path.join(__dirname, '..')
const TARGET_DIRS = ['avatars', 'custom', 'games', 'tierlists']
async function main() {
await ensureData()
const referenced = new Set(await listReferencedUploadSources())
const deleted = []
const missing = []
let scanned = 0
for (const dir of TARGET_DIRS) {
const absoluteDir = path.join(BACKEND_ROOT, 'uploads', dir)
let entries = []
try {
entries = await fs.readdir(absoluteDir, { withFileTypes: true })
} catch (error) {
if (error?.code === 'ENOENT') continue
throw error
}
for (const entry of entries) {
if (!entry.isFile()) continue
scanned += 1
const src = `/uploads/${dir}/${entry.name}`
if (referenced.has(src)) continue
const absolutePath = path.join(absoluteDir, entry.name)
try {
await fs.unlink(absolutePath)
deleted.push(src)
} catch (error) {
if (error?.code === 'ENOENT') missing.push(src)
else throw error
}
}
}
console.log(JSON.stringify({ scanned, deletedCount: deleted.length, missingCount: missing.length, deleted, missing }, null, 2))
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(async () => {
await closePool()
})

View File

@@ -0,0 +1,112 @@
const fs = require('fs/promises')
const path = require('path')
const sharp = require('sharp')
const {
ensureData,
closePool,
listReferencedUploadUsage,
replaceUploadSourceReferences,
} = require('../src/db')
const { writeOptimizedImage } = require('../src/lib/image-storage')
const BACKEND_ROOT = path.join(__dirname, '..')
function inferMimeType(src, metadata) {
const format = String(metadata?.format || '').toLowerCase()
if (format === 'jpeg' || format === 'jpg') return 'image/jpeg'
if (format === 'png') return 'image/png'
if (format === 'gif') return 'image/gif'
if (format === 'webp') return 'image/webp'
if (format === 'svg' || format === 'svg+xml') return 'image/svg+xml'
if (format === 'avif') return 'image/avif'
const ext = path.extname(src || '').toLowerCase()
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
if (ext === '.png') return 'image/png'
if (ext === '.gif') return 'image/gif'
if (ext === '.webp') return 'image/webp'
if (ext === '.svg') return 'image/svg+xml'
if (ext === '.avif') return 'image/avif'
return 'application/octet-stream'
}
function getOptimizationConfig(roles) {
const roleSet = new Set(roles || [])
if (roleSet.has('avatar')) {
return { directory: 'avatars', width: 512, height: 512, fit: 'cover', quality: 82 }
}
if (roleSet.has('game-thumbnail') || roleSet.has('tierlist-thumbnail') || roleSet.has('template-thumbnail')) {
return { directory: 'legacy-thumbnails', width: 1280, height: 1280, fit: 'inside', quality: 84 }
}
return { directory: 'legacy-items', width: 512, height: 512, fit: 'inside', quality: 84 }
}
async function createFileLike(src) {
const absolutePath = path.join(BACKEND_ROOT, src.replace(/^\//, ''))
const [buffer, stat] = await Promise.all([fs.readFile(absolutePath), fs.stat(absolutePath)])
let metadata = {}
try {
metadata = await sharp(buffer, { failOn: 'none' }).metadata()
} catch (error) {
metadata = {}
}
return {
file: {
originalname: path.basename(src),
mimetype: inferMimeType(src, metadata),
size: Number(stat.size || 0),
buffer,
},
absolutePath,
}
}
async function main() {
await ensureData()
const usageEntries = await listReferencedUploadUsage()
const legacyEntries = usageEntries.filter((entry) => entry.src && entry.src.startsWith('/uploads/') && !entry.src.startsWith('/uploads/assets/'))
const summary = {
scanned: legacyEntries.length,
migrated: 0,
reusedAsset: 0,
unchanged: 0,
missingFiles: 0,
failed: 0,
updatedRows: 0,
}
for (const entry of legacyEntries) {
const config = getOptimizationConfig(entry.roles)
try {
const { file } = await createFileLike(entry.src)
const optimized = await writeOptimizedImage({ file, ...config })
if (optimized.src === entry.src) {
summary.unchanged += 1
continue
}
const replaced = await replaceUploadSourceReferences({ fromSrc: entry.src, toSrc: optimized.src })
summary.updatedRows += Number(replaced.updatedRows || 0)
if (optimized.reused) summary.reusedAsset += 1
else summary.migrated += 1
} catch (error) {
if (error?.code === 'ENOENT') {
summary.missingFiles += 1
continue
}
summary.failed += 1
console.error('[migrate-legacy-uploads-to-assets] failed:', entry.src, error?.message || error)
}
}
console.log(JSON.stringify(summary, null, 2))
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(async () => {
await closePool()
})

View File

@@ -85,6 +85,7 @@ function mapGameItemRow(row) {
gameId: row.game_id,
src: row.src,
label: row.label,
displayOrder: row.display_order == null ? null : Number(row.display_order),
createdAt: Number(row.created_at),
}
}
@@ -95,6 +96,7 @@ function mapImageAssetRow(row) {
id: row.id,
contentHash: row.content_hash,
src: row.src || '',
labelOverride: row.label_override || '',
mimeType: row.mime_type || 'image/webp',
byteSize: Number(row.byte_size || 0),
originalByteSize: Number(row.original_byte_size || 0),
@@ -154,7 +156,7 @@ function mapTemplateRequestRow(row) {
requesterName: getUserDisplayName(row),
requesterAccountName: getUserAccountName(row),
requesterAvatarSrc: row.requester_avatar_src || '',
sourceTierListId: row.source_tierlist_id,
sourceTierListId: row.source_tierlist_id || '',
sourceGameId: row.source_game_id,
sourceGameName: row.source_game_name || '',
sourceTierListTitle: row.title_snapshot || '',
@@ -164,6 +166,9 @@ function mapTemplateRequestRow(row) {
targetGameName: row.target_game_name || '',
status: row.status,
items: parseJson(row.items_json, []),
snapshotGroups: parseJson(row.groups_json, []),
snapshotItems: parseJson(row.board_items_json, []),
snapshotShowCharacterNames: !!row.show_character_names_snapshot,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
}
@@ -223,6 +228,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 () => {
@@ -259,12 +272,18 @@ async function ensureSchema() {
game_id VARCHAR(120) NOT NULL,
src VARCHAR(255) NOT NULL,
label VARCHAR(120) NOT NULL,
display_order INT NULL DEFAULT NULL,
created_at BIGINT NOT NULL,
INDEX idx_game_items_game_id (game_id),
CONSTRAINT fk_game_items_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const gameItemDisplayOrderColumns = await query("SHOW COLUMNS FROM game_items LIKE 'display_order'")
if (!gameItemDisplayOrderColumns.length) {
await query('ALTER TABLE game_items ADD COLUMN display_order INT NULL DEFAULT NULL AFTER label')
}
await query(`
CREATE TABLE IF NOT EXISTS custom_items (
id VARCHAR(64) PRIMARY KEY,
@@ -331,6 +350,7 @@ async function ensureSchema() {
id VARCHAR(64) PRIMARY KEY,
content_hash CHAR(64) NOT NULL UNIQUE,
src VARCHAR(255) NOT NULL UNIQUE,
label_override VARCHAR(120) NOT NULL DEFAULT '',
mime_type VARCHAR(32) NOT NULL DEFAULT 'image/webp',
byte_size INT UNSIGNED NOT NULL,
original_byte_size INT UNSIGNED NOT NULL,
@@ -341,6 +361,11 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const imageAssetLabelColumns = await query("SHOW COLUMNS FROM image_assets LIKE 'label_override'")
if (!imageAssetLabelColumns.length) {
await query("ALTER TABLE image_assets ADD COLUMN label_override VARCHAR(120) NOT NULL DEFAULT '' AFTER src")
}
await query(`
CREATE TABLE IF NOT EXISTS image_optimization_jobs (
id VARCHAR(64) PRIMARY KEY,
@@ -381,6 +406,39 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const templateRequestSourceTierListColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_tierlist_id'")
if (templateRequestSourceTierListColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE template_requests MODIFY source_tierlist_id VARCHAR(64) NULL')
}
const templateRequestTypeColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'request_type'")
if (!templateRequestTypeColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN request_type VARCHAR(20) NOT NULL DEFAULT 'create' AFTER id")
}
const templateRequestSourceGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_game_id'")
if (!templateRequestSourceGameColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN source_game_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id")
}
const templateRequestTargetGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'target_game_id'")
if (!templateRequestTargetGameColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN target_game_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_game_id")
}
const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'")
if (!templateRequestStatusColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending' AFTER target_game_id")
}
const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'")
if (!templateRequestGroupsColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN groups_json LONGTEXT NOT NULL AFTER items_json")
}
const templateRequestBoardItemsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'board_items_json'")
if (!templateRequestBoardItemsColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN board_items_json LONGTEXT NOT NULL AFTER groups_json")
}
const templateRequestShowNamesColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'show_character_names_snapshot'")
if (!templateRequestShowNamesColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN show_character_names_snapshot TINYINT(1) NOT NULL DEFAULT 0 AFTER board_items_json")
}
const tierListThumbnailColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'thumbnail_src'")
if (!tierListThumbnailColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
@@ -392,6 +450,8 @@ async function ensureSchema() {
const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'")
if (!tierListSourceIdColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER show_character_names")
} else if (tierListSourceIdColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE tierlists MODIFY source_tierlist_id VARCHAR(64) NULL DEFAULT NULL')
}
const tierListSourceTitleColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_title'")
if (!tierListSourceTitleColumns.length) {
@@ -498,25 +558,59 @@ async function updateUserProfile({ id, nickname, avatarSrc }) {
return findUserById(id)
}
async function listUsers() {
const rows = await query(`
SELECT
u.id,
u.email,
u.nickname,
u.is_admin,
u.avatar_src,
u.created_at,
COUNT(t.id) AS tierlist_count,
GREATEST(
async function findPrimaryAdminUser() {
const rows = await query(
'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
)
return mapUserRow(rows[0])
}
async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' } = {}) {
const where = []
const params = []
const trimmedQuery = typeof queryText === 'string' ? queryText.trim() : ''
if (trimmedQuery) {
where.push('(u.email LIKE ? OR u.nickname LIKE ?)')
params.push(`%${trimmedQuery}%`, `%${trimmedQuery}%`)
}
const isAsc = direction === 'asc'
const orderBy =
sort === 'created'
? isAsc
? 'u.created_at ASC, recent_activity_at ASC, u.email ASC'
: 'u.created_at DESC, recent_activity_at DESC, u.email ASC'
: sort === 'tierlists'
? isAsc
? 'tierlist_count ASC, recent_activity_at ASC, u.email ASC'
: 'tierlist_count DESC, recent_activity_at DESC, u.email ASC'
: isAsc
? 'recent_activity_at ASC, u.created_at ASC, u.email ASC'
: 'recent_activity_at DESC, u.created_at ASC, u.email ASC'
const rows = await query(
`
SELECT
u.id,
u.email,
u.nickname,
u.is_admin,
u.avatar_src,
u.created_at,
COALESCE(MAX(t.updated_at), 0)
) AS recent_activity_at
FROM users u
LEFT JOIN tierlists t ON t.author_id = u.id
GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at
ORDER BY recent_activity_at DESC, u.created_at ASC, u.email ASC
`)
COUNT(t.id) AS tierlist_count,
GREATEST(
u.created_at,
COALESCE(MAX(t.updated_at), 0)
) AS recent_activity_at
FROM users u
LEFT JOIN tierlists t ON t.author_id = u.id
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at
ORDER BY ${orderBy}
`,
params
)
return rows.map(mapUserRow)
}
@@ -581,12 +675,26 @@ async function findGameById(id) {
async function listGameItems(gameId) {
const rows = await query(
'SELECT id, game_id, src, label, created_at FROM game_items WHERE game_id = ? ORDER BY created_at ASC',
`
SELECT id, game_id, src, label, display_order, created_at
FROM game_items
WHERE game_id = ?
ORDER BY
CASE WHEN display_order IS NULL THEN 1 ELSE 0 END ASC,
display_order ASC,
created_at DESC,
id DESC
`,
[gameId]
)
return rows.map(mapGameItemRow)
}
async function findGameItemById(itemId) {
const rows = await query('SELECT id, game_id, src, label, display_order, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId])
return mapGameItemRow(rows[0])
}
async function getGameDetail(gameId) {
const game = await findGameById(gameId)
if (!game) return null
@@ -612,12 +720,20 @@ async function updateGameThumbnail(gameId, thumbnailSrc) {
async function findImageAssetByHash(contentHash) {
const rows = await query(
'SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE content_hash = ? LIMIT 1',
'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE content_hash = ? LIMIT 1',
[contentHash]
)
return mapImageAssetRow(rows[0])
}
async function findImageAssetBySrc(src) {
const rows = await query(
'SELECT id, content_hash, src, label_override, 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(
@@ -690,7 +806,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
const cutoff = now() - safeMinAgeHours * 60 * 60 * 1000
const assets = (await query(
`SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE created_at <= ? ORDER BY created_at ASC LIMIT ${safeLimit}`,
`SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE created_at <= ? ORDER BY created_at ASC LIMIT ${safeLimit}`,
[cutoff]
)).map(mapImageAssetRow)
@@ -704,7 +820,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
query("SELECT src FROM game_items WHERE src <> ''"),
query("SELECT src FROM custom_items WHERE src <> ''"),
query("SELECT thumbnail_src, pool_json FROM tierlists"),
query("SELECT thumbnail_src_snapshot, items_json FROM template_requests"),
query("SELECT thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"),
])
for (const row of userRows) if (row.avatar_src) referencedSrcs.add(row.avatar_src)
@@ -720,6 +836,7 @@ async function listUnusedImageAssets({ limit = 100, minAgeHours = 24 } = {}) {
for (const row of templateRequestRows) {
if (row.thumbnail_src_snapshot) referencedSrcs.add(row.thumbnail_src_snapshot)
collectUploadSrcsFromItems(parseJson(row.items_json, []), referencedSrcs)
collectUploadSrcsFromItems(parseJson(row.board_items_json, []), referencedSrcs)
}
return assets.filter((asset) => !referencedSrcs.has(asset.src))
@@ -730,7 +847,7 @@ async function deleteImageAssets(ids) {
if (!uniqueIds.length) return []
const placeholders = uniqueIds.map(() => '?').join(', ')
const rows = await query(
`SELECT id, content_hash, src, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id IN (${placeholders})`,
`SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id IN (${placeholders})`,
uniqueIds
)
await query(`DELETE FROM image_assets WHERE id IN (${placeholders})`, uniqueIds)
@@ -738,41 +855,136 @@ async function deleteImageAssets(ids) {
}
async function listReferencedUploadSources() {
const referencedSrcs = new Set()
const usage = await listReferencedUploadUsage()
return usage.map((entry) => entry.src)
}
async function listReferencedUploadUsage() {
const usageMap = new Map()
const addUsage = (src, role) => {
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return
if (!usageMap.has(src)) usageMap.set(src, new Set())
usageMap.get(src).add(role)
}
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
query("SELECT avatar_src FROM users WHERE avatar_src <> ''"),
query("SELECT thumbnail_src FROM games WHERE thumbnail_src <> ''"),
query("SELECT src FROM game_items WHERE src <> ''"),
query("SELECT src FROM custom_items WHERE src <> ''"),
query("SELECT thumbnail_src, pool_json FROM tierlists"),
query("SELECT thumbnail_src_snapshot, items_json FROM template_requests"),
query("SELECT id, thumbnail_src, pool_json FROM tierlists"),
query("SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests"),
])
for (const row of userRows) if (row.avatar_src) referencedSrcs.add(row.avatar_src)
for (const row of gameRows) if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
for (const row of gameItemRows) if (row.src) referencedSrcs.add(row.src)
for (const row of customItemRows) if (row.src) referencedSrcs.add(row.src)
for (const row of userRows) addUsage(row.avatar_src, 'avatar')
for (const row of gameRows) addUsage(row.thumbnail_src, 'game-thumbnail')
for (const row of gameItemRows) addUsage(row.src, 'game-item')
for (const row of customItemRows) addUsage(row.src, 'custom-item')
for (const row of tierListRows) {
if (row.thumbnail_src) referencedSrcs.add(row.thumbnail_src)
collectUploadSrcsFromItems(parseJson(row.pool_json, []), referencedSrcs)
addUsage(row.thumbnail_src, 'tierlist-thumbnail')
for (const item of parseJson(row.pool_json, [])) addUsage(item?.src, 'tierlist-pool')
}
for (const row of templateRequestRows) {
if (row.thumbnail_src_snapshot) referencedSrcs.add(row.thumbnail_src_snapshot)
collectUploadSrcsFromItems(parseJson(row.items_json, []), referencedSrcs)
addUsage(row.thumbnail_src_snapshot, 'template-thumbnail')
for (const item of parseJson(row.items_json, [])) addUsage(item?.src, 'template-item')
for (const item of parseJson(row.board_items_json, [])) addUsage(item?.src, 'template-board-item')
}
return Array.from(referencedSrcs)
return Array.from(usageMap.entries())
.map(([src, roles]) => ({ src, roles: Array.from(roles).sort() }))
.sort((a, b) => a.src.localeCompare(b.src))
}
function replaceItemSrc(items, fromSrc, toSrc) {
let changed = false
const nextItems = (items || []).map((item) => {
if (item?.src !== fromSrc) return item
changed = true
return { ...item, src: toSrc }
})
return { changed, items: nextItems }
}
async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 }
const [userResult, gameResult, gameItemResult, customItemResult] = await Promise.all([
query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]),
query('UPDATE games SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]),
query('UPDATE game_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
])
let updatedRows = Number(userResult.affectedRows || 0) + Number(gameResult.affectedRows || 0) + Number(gameItemResult.affectedRows || 0) + Number(customItemResult.affectedRows || 0)
const tierListRows = await query('SELECT id, thumbnail_src, pool_json FROM tierlists')
for (const row of tierListRows) {
let nextThumbnail = row.thumbnail_src
let changed = false
if (row.thumbnail_src === fromSrc) {
nextThumbnail = toSrc
changed = true
}
const replacedPool = replaceItemSrc(parseJson(row.pool_json, []), fromSrc, toSrc)
if (replacedPool.changed) changed = true
if (changed) {
await query('UPDATE tierlists SET thumbnail_src = ?, pool_json = ?, updated_at = ? WHERE id = ?', [
nextThumbnail || '',
serializeJson(replacedPool.items),
now(),
row.id,
])
updatedRows += 1
}
}
const requestRows = await query('SELECT id, thumbnail_src_snapshot, items_json, board_items_json FROM template_requests')
for (const row of requestRows) {
let nextThumbnail = row.thumbnail_src_snapshot
let changed = false
if (row.thumbnail_src_snapshot === fromSrc) {
nextThumbnail = toSrc
changed = true
}
const replacedItems = replaceItemSrc(parseJson(row.items_json, []), fromSrc, toSrc)
const replacedBoardItems = replaceItemSrc(parseJson(row.board_items_json, []), fromSrc, toSrc)
if (replacedItems.changed || replacedBoardItems.changed) changed = true
if (changed) {
await query('UPDATE template_requests SET thumbnail_src_snapshot = ?, items_json = ?, board_items_json = ?, updated_at = ? WHERE id = ?', [
nextThumbnail || '',
serializeJson(replacedItems.items),
serializeJson(replacedBoardItems.items),
now(),
row.id,
])
updatedRows += 1
}
}
return { updatedRows }
}
async function listImageAssets() {
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'
'SELECT id, content_hash, src, label_override, 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 findImageAssetById(id) {
const rows = await query(
'SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id = ? LIMIT 1',
[id]
)
return mapImageAssetRow(rows[0])
}
async function getReferencedUploadFootprint() {
const [referencedSrcs, assets] = await Promise.all([listReferencedUploadSources(), listImageAssets()])
const assetMap = new Map(assets.map((asset) => [asset.src, asset]))
@@ -881,23 +1093,70 @@ async function clearImageOptimizationJobs({ month } = {}) {
}
async function createGameItem({ id, gameId, src, label }) {
const createdAt = now()
await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM game_items WHERE game_id = ?', [gameId])
const nextDisplayOrder =
minOrderRows[0]?.min_display_order == null ? 0 : Number(minOrderRows[0].min_display_order) - 1
await query('INSERT INTO game_items (id, game_id, src, label, display_order, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
id,
gameId,
src,
label,
nextDisplayOrder,
createdAt,
])
const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [id])
const rows = await query('SELECT id, game_id, src, label, display_order, created_at FROM game_items WHERE id = ? LIMIT 1', [id])
return mapGameItemRow(rows[0])
}
async function updateGameItemLabel(itemId, label) {
await query('UPDATE game_items SET label = ? WHERE id = ?', [label, itemId])
const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId])
const rows = await query('SELECT id, game_id, src, label, display_order, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId])
return mapGameItemRow(rows[0])
}
async function updateGameItemDisplayOrder(gameId, itemIds) {
const normalizedIds = Array.from(new Set((itemIds || []).filter(Boolean)))
const existingItems = await listGameItems(gameId)
const existingIdSet = new Set(existingItems.map((item) => item.id))
const orderedIds = normalizedIds.filter((id) => existingIdSet.has(id))
const remainingIds = existingItems.map((item) => item.id).filter((id) => !orderedIds.includes(id))
const finalIds = [...orderedIds, ...remainingIds]
await Promise.all(
finalIds.map((itemId, index) => query('UPDATE game_items SET display_order = ? WHERE id = ? AND game_id = ?', [index + 1, itemId, gameId]))
)
return listGameItems(gameId)
}
async function updateCustomItemLabel(itemId, label) {
await query('UPDATE custom_items SET label = ? WHERE id = ?', [label, itemId])
const rows = await query(`
SELECT c.id, c.owner_id, c.src, c.label, c.created_at, u.nickname, u.email
FROM custom_items c
INNER JOIN users u ON u.id = c.owner_id
WHERE c.id = ?
LIMIT 1
`, [itemId])
const row = rows[0]
if (!row) return null
return {
id: row.id,
ownerId: row.owner_id,
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
ownerName: row.nickname || row.email,
ownerEmail: row.email,
}
}
async function updateImageAssetLabel(assetId, label) {
await query('UPDATE image_assets SET label_override = ? WHERE id = ?', [label, assetId])
const rows = await query('SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id = ? LIMIT 1', [assetId])
return mapImageAssetRow(rows[0])
}
async function deleteGameItem(itemId) {
const gameItemRows = await query('SELECT game_id FROM game_items WHERE id = ? LIMIT 1', [itemId])
const gameId = gameItemRows[0]?.game_id
@@ -1050,32 +1309,70 @@ async function getCustomItemUsageMeta() {
async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) {
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
const normalizedPage = Math.max(Number(page) || 1, 1)
const hasQuery = !!(queryText || '').trim()
const search = `%${(queryText || '').trim()}%`
const whereClause = hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''
const params = hasQuery ? [search, search, search, search] : []
const searchText = (queryText || '').trim()
const hasQuery = !!searchText
const search = `%${searchText}%`
const rows = await query(
`
SELECT
c.id,
c.owner_id,
c.src,
c.label,
c.created_at,
u.nickname,
u.email
FROM custom_items c
INNER JOIN users u ON u.id = c.owner_id
${whereClause}
ORDER BY c.created_at DESC
`,
params
)
const [customRows, gameItemRows, assetRows, usageMeta] = await Promise.all([
query(
`
SELECT
c.id,
c.owner_id,
c.src,
c.label,
c.created_at,
u.nickname,
u.email
FROM custom_items c
INNER JOIN users u ON u.id = c.owner_id
${hasQuery ? 'WHERE c.label LIKE ? OR c.src LIKE ? OR u.email LIKE ? OR u.nickname LIKE ?' : ''}
ORDER BY c.created_at DESC
`,
hasQuery ? [search, search, search, search] : []
),
query(
`
SELECT
gi.id,
gi.game_id,
gi.src,
gi.label,
gi.created_at,
g.name AS game_name
FROM game_items gi
INNER JOIN games g ON g.id = gi.game_id
${hasQuery ? 'WHERE gi.label LIKE ? OR gi.src LIKE ? OR gi.game_id LIKE ? OR g.name LIKE ?' : ''}
ORDER BY gi.created_at DESC
`,
hasQuery ? [search, search, search, search] : []
),
query(
`
SELECT ia.id, ia.src, ia.label_override, ia.created_at
FROM image_assets ia
WHERE ia.src LIKE '/uploads/assets/%'
${hasQuery ? 'AND ia.src LIKE ?' : ''}
ORDER BY ia.created_at DESC
`,
hasQuery ? [search] : []
),
getCustomItemUsageMeta(),
])
const { usageMap, linkedGamesMap } = await getCustomItemUsageMeta()
const allItems = rows
.map((row) => ({
const templateLinkedBySrc = new Map()
gameItemRows.forEach((row) => {
if (!row?.src) return
if (!templateLinkedBySrc.has(row.src)) templateLinkedBySrc.set(row.src, new Map())
templateLinkedBySrc.get(row.src).set(row.game_id, {
id: row.game_id,
name: row.game_name || row.game_id,
})
})
const customItems = customRows.map((row) => {
const linkedGames = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values())
return {
id: row.id,
ownerId: row.owner_id,
src: row.src,
@@ -1083,10 +1380,60 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
createdAt: Number(row.created_at),
ownerName: row.nickname || row.email,
ownerEmail: row.email,
usageCount: usageMap.get(row.id) || 0,
linkedGames: linkedGamesMap.get(row.id) || [],
usageCount: usageMeta.usageMap.get(row.id) || 0,
linkedGames,
sourceType: 'user',
sourceLabel: '사용자 업로드',
canDelete: true,
}
})
const templateSrcSet = new Set(gameItemRows.map((row) => row.src).filter(Boolean))
const customSrcSet = new Set(customRows.map((row) => row.src).filter(Boolean))
const assetLibraryItems = assetRows
.filter((row) => row?.src && !templateSrcSet.has(row.src) && !customSrcSet.has(row.src))
.map((row) => ({
id: `asset:${row.id}`,
assetId: row.id,
ownerId: '',
src: row.src,
label: row.label_override || (row.src.split('/').pop() || '').replace(/\.[^.]+$/, '') || '이름 없음',
createdAt: Number(row.created_at || 0),
ownerName: '관리자 보관 자산',
ownerEmail: '',
usageCount: 0,
linkedGames: [],
sourceType: 'template',
sourceLabel: '관리자 템플릿',
canDelete: true,
sourceGameId: '',
sourceGameName: '',
isAssetLibraryItem: true,
}))
.filter((item) => (orphanOnly ? item.usageCount === 0 : true))
const templateItems = gameItemRows.map((row) => ({
id: row.id,
ownerId: '',
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
ownerName: row.game_name || row.game_id,
ownerEmail: '',
usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size,
linkedGames: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
sourceType: 'template',
sourceLabel: '관리자 템플릿',
canDelete: true,
sourceGameId: row.game_id,
sourceGameName: row.game_name || row.game_id,
}))
const allItems = [...customItems, ...templateItems, ...assetLibraryItems]
.filter((item) => {
if (!orphanOnly) return true
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0
})
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
const total = allItems.length
const offset = (normalizedPage - 1) * normalizedLimit
@@ -1502,19 +1849,24 @@ async function createTemplateRequest({
id,
type,
requesterId,
sourceTierListId,
sourceTierListId = '',
sourceGameId,
targetGameId = '',
title,
description = '',
thumbnailSrc = '',
items = [],
groups = [],
boardItems = [],
showCharacterNames = false,
}) {
const existing = await findPendingTemplateRequestByTierList({ sourceTierListId, type })
if (existing) {
const err = new Error('template_request_exists')
err.code = 'TEMPLATE_REQUEST_EXISTS'
throw err
if (sourceTierListId) {
const existing = await findPendingTemplateRequestByTierList({ sourceTierListId, type })
if (existing) {
const err = new Error('template_request_exists')
err.code = 'TEMPLATE_REQUEST_EXISTS'
throw err
}
}
const createdAt = now()
@@ -1532,22 +1884,28 @@ async function createTemplateRequest({
description_snapshot,
thumbnail_src_snapshot,
items_json,
groups_json,
board_items_json,
show_character_names_snapshot,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[
id,
type,
requesterId,
sourceTierListId,
sourceTierListId || null,
sourceGameId,
targetGameId,
title,
description,
thumbnailSrc,
serializeJson(items),
serializeJson(groups),
serializeJson(boardItems),
showCharacterNames ? 1 : 0,
createdAt,
createdAt,
]
@@ -1570,6 +1928,9 @@ async function findTemplateRequestById(id) {
tr.description_snapshot,
tr.thumbnail_src_snapshot,
tr.items_json,
tr.groups_json,
tr.board_items_json,
tr.show_character_names_snapshot,
tr.created_at,
tr.updated_at,
u.nickname,
@@ -1590,7 +1951,11 @@ async function findTemplateRequestById(id) {
return mapTemplateRequestRow(rows[0])
}
async function listAdminTemplateRequests({ status = 'pending' } = {}) {
async function listAdminTemplateRequests({ status = 'pending', statuses = [] } = {}) {
const requestedStatuses = Array.isArray(statuses) && statuses.length ? statuses : [status]
const validStatuses = requestedStatuses.filter((entry) => typeof entry === 'string' && entry.trim())
const normalizedStatuses = validStatuses.length ? validStatuses : ['pending']
const placeholders = normalizedStatuses.map(() => '?').join(', ')
const rows = await query(
`
SELECT
@@ -1605,6 +1970,9 @@ async function listAdminTemplateRequests({ status = 'pending' } = {}) {
tr.description_snapshot,
tr.thumbnail_src_snapshot,
tr.items_json,
tr.groups_json,
tr.board_items_json,
tr.show_character_names_snapshot,
tr.created_at,
tr.updated_at,
u.nickname,
@@ -1616,10 +1984,16 @@ async function listAdminTemplateRequests({ status = 'pending' } = {}) {
INNER JOIN users u ON u.id = tr.requester_id
LEFT JOIN games sg ON sg.id = tr.source_game_id
LEFT JOIN games tg ON tg.id = tr.target_game_id
WHERE tr.status = ?
ORDER BY tr.created_at DESC
WHERE tr.status IN (${placeholders})
ORDER BY
CASE tr.status
WHEN 'pending' THEN 0
WHEN 'reviewing' THEN 1
ELSE 2
END,
tr.created_at DESC
`,
[status]
normalizedStatuses
)
return rows.map(mapTemplateRequestRow)
@@ -1630,6 +2004,11 @@ async function updateTemplateRequestStatus({ id, status }) {
return findTemplateRequestById(id)
}
async function updateTemplateRequestTargetGame({ id, targetGameId }) {
await query('UPDATE template_requests SET target_game_id = ?, updated_at = ? WHERE id = ?', [targetGameId || '', now(), id])
return findTemplateRequestById(id)
}
async function deleteTierList(id) {
await query('DELETE FROM tierlists WHERE id = ?', [id])
}
@@ -1692,6 +2071,7 @@ async function saveTierList({
return findTierListById(existing.id, authorId)
}
const nextId = id || nanoid()
const createdAt = now()
await query(
`
@@ -1700,9 +2080,9 @@ async function saveTierList({
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
[nextId, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
)
return findTierListById(id, authorId)
return findTierListById(nextId, authorId)
}
async function duplicateTierListForUser({ tierList, targetUserId }) {
@@ -1746,11 +2126,13 @@ async function unfavoriteGame({ userId, gameId }) {
module.exports = {
DB_NAME,
ensureData,
closePool,
countUsers,
findUserByEmail,
findUserById,
createUser,
updateUserProfile,
findPrimaryAdminUser,
listUsers,
adminUpdateUser,
adminUpdateUserPassword,
@@ -1758,10 +2140,13 @@ module.exports = {
listGames,
findGameById,
listGameItems,
findGameItemById,
getGameDetail,
createGame,
updateGameThumbnail,
findImageAssetByHash,
findImageAssetBySrc,
findImageAssetById,
createImageAsset,
createImageOptimizationJob,
findImageOptimizationJobById,
@@ -1770,10 +2155,15 @@ module.exports = {
listUnusedImageAssets,
deleteImageAssets,
listReferencedUploadSources,
listReferencedUploadUsage,
replaceUploadSourceReferences,
clearImageOptimizationJobs,
getImageAssetStats,
createGameItem,
updateGameItemLabel,
updateGameItemDisplayOrder,
updateCustomItemLabel,
updateImageAssetLabel,
deleteGameItem,
deleteGame,
updateGameDisplayOrder,
@@ -1799,4 +2189,5 @@ module.exports = {
findTemplateRequestById,
listAdminTemplateRequests,
updateTemplateRequestStatus,
updateTemplateRequestTargetGame,
}

View File

@@ -75,7 +75,7 @@ async function optimizeAndPersist({ file, width, height, fit, quality }) {
}
}
const filename = String(Date.now()) + '-' + nanoid() + '.webp'
const filename = nanoid() + '.webp'
const absoluteDir = path.join(UPLOAD_ROOT, OPTIMIZED_DIR)
const absolutePath = path.join(absoluteDir, filename)
const src = '/uploads/' + OPTIMIZED_DIR + '/' + filename

View File

@@ -8,11 +8,17 @@ const { nanoid } = require('nanoid')
const {
findUserById,
findGameById,
findGameItemById,
listGameItems,
findImageAssetById,
createGame,
listGames,
updateGameThumbnail,
createGameItem,
updateGameItemLabel,
updateGameItemDisplayOrder,
updateCustomItemLabel,
updateImageAssetLabel,
deleteGameItem,
deleteGame,
updateGameDisplayOrder,
@@ -22,11 +28,13 @@ const {
findCustomItemsByIds,
deleteCustomItems,
listUsers,
findPrimaryAdminUser,
listAdminTierLists,
findTierListById,
listAdminTemplateRequests,
findTemplateRequestById,
updateTemplateRequestStatus,
updateTemplateRequestTargetGame,
adminUpdateUser,
adminUpdateUserPassword,
adminDeleteUser,
@@ -59,17 +67,60 @@ function buildItemLabelFromFilename(file) {
return normalized || 'item'
}
function buildItemLabelFromSrc(src) {
const raw = typeof src === 'string' ? src : ''
const base = path.basename(raw.split('?')[0] || '', path.extname(raw.split('?')[0] || ''))
const normalized = base
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 60)
return normalized || 'item'
}
const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 })
const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
function decorateAdminUser(user, primaryAdmin) {
if (!user) return null
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
return {
...user,
isPrimaryAdmin,
isOperator: !!user.isAdmin && !isPrimaryAdmin,
role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user',
}
}
async function getAdminUserContext(targetUserId, actingUserId) {
const [targetUser, actingUser, primaryAdmin] = await Promise.all([
findUserById(targetUserId),
findUserById(actingUserId),
findPrimaryAdminUser(),
])
return { targetUser, actingUser, primaryAdmin }
}
function canManageAdminRole(actingUser, primaryAdmin) {
return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id
}
router.post('/games', requireAdmin, async (req, res) => {
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
const schema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(60),
thumbnailSrc: z.string().max(255).optional().default(''),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findGameById(parsed.data.id)
if (exists) return res.status(409).json({ error: 'game_id_taken' })
const game = await createGame({ id: parsed.data.id, name: parsed.data.name })
res.json({ game })
if (parsed.data.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
await updateGameThumbnail(game.id, copiedThumb)
}
res.json({ game: await findGameById(game.id) })
})
router.patch('/games/display-order', requireAdmin, async (req, res) => {
@@ -86,6 +137,20 @@ router.patch('/games/display-order', requireAdmin, async (req, res) => {
res.json({ games: updatedGames })
})
router.patch('/games/:gameId/items/display-order', requireAdmin, async (req, res) => {
const schema = z.object({
itemIds: z.array(z.string().min(1)).min(1),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(req.params.gameId)
if (!game) return res.status(404).json({ error: 'not_found' })
const items = await updateGameItemDisplayOrder(game.id, parsed.data.itemIds)
res.json({ items })
})
router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'file_required' })
const game = await findGameById(req.params.gameId)
@@ -165,6 +230,32 @@ router.delete('/games/:gameId', requireAdmin, async (req, res) => {
res.json({ ok: true })
})
router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
const schema = z.object({
label: z.string().trim().min(1).max(60),
sourceType: z.enum(['template', 'user']).optional().default('user'),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const itemId = req.params.itemId
if (itemId.startsWith('asset:')) {
const updated = await updateImageAssetLabel(itemId.slice(6), parsed.data.label)
if (!updated) return res.status(404).json({ error: 'not_found' })
return res.json({ item: updated })
}
if (parsed.data.sourceType === 'template') {
const updated = await updateGameItemLabel(itemId, parsed.data.label)
if (!updated) return res.status(404).json({ error: 'not_found' })
return res.json({ item: updated })
}
const updated = await updateCustomItemLabel(itemId, parsed.data.label)
if (!updated) return res.status(404).json({ error: 'not_found' })
return res.json({ item: updated })
})
router.get('/custom-items', requireAdmin, async (req, res) => {
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
@@ -207,7 +298,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
})
router.get('/template-requests', requireAdmin, async (req, res) => {
const requests = await listAdminTemplateRequests({ status: 'pending' })
const requests = await listAdminTemplateRequests({ statuses: ['pending', 'reviewing'] })
res.json({ requests })
})
@@ -283,6 +374,20 @@ router.post('/image-assets/stats/reset', requireAdmin, async (req, res) => {
res.json({ deletedCount })
})
async function removeUploadFiles(srcs) {
await Promise.all(
(srcs || []).map(async (src) => {
if (!src || !src.startsWith('/uploads/')) return
const absolutePath = path.join(__dirname, '..', '..', src.replace(/^\//, ''))
try {
await fs.unlink(absolutePath)
} catch (e) {
if (e?.code !== 'ENOENT') throw e
}
})
)
}
async function removeCustomItemFiles(items) {
await Promise.all(
items.map(async (item) => {
@@ -297,19 +402,35 @@ async function removeCustomItemFiles(items) {
)
}
async function promoteCustomItemToGameItem({ customItem, gameId }) {
async function promoteLibraryItemToGameItem({ item, gameId }) {
return createGameItem({
id: nanoid(),
gameId,
src: customItem.src || '',
label: customItem.label,
src: item.src || '',
label: item.label,
})
}
async function copyUploadIntoGameAsset(src) {
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return src || ''
if (src.startsWith('/uploads/assets/')) return src
return src
if (typeof src !== 'string') return ''
const raw = src.trim()
if (!raw) return ''
if (raw.startsWith('/uploads/')) {
if (raw.startsWith('/uploads/assets/')) return raw
return raw
}
try {
const url = new URL(raw)
if (url.pathname.startsWith('/uploads/')) {
return url.pathname
}
} catch (error) {
return raw
}
return raw
}
function uniqueTierListPoolItems(tierList) {
@@ -343,10 +464,17 @@ async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
}
async function promoteSnapshotItemsToGame({ items, gameId }) {
const existingItems = await listGameItems(gameId)
const existingSrcs = new Set(
existingItems
.map((item) => (typeof item?.src === 'string' ? item.src.trim() : ''))
.filter(Boolean)
)
const createdItems = []
for (const item of items || []) {
const copiedSrc = await copyUploadIntoGameAsset(item.src)
if (!copiedSrc || existingSrcs.has(copiedSrc)) continue
createdItems.push(
await createGameItem({
id: nanoid(),
@@ -355,11 +483,38 @@ async function promoteSnapshotItemsToGame({ items, gameId }) {
label: item.label,
})
)
existingSrcs.add(copiedSrc)
}
return createdItems
}
function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}, itemSrcs = []) {
const requestedIds = new Set((itemIds || []).filter(Boolean))
const requestedSrcs = new Set((itemSrcs || []).filter((src) => typeof src === 'string' && src.trim()).map((src) => src.trim()))
const items = Array.isArray(templateRequest?.items) ? templateRequest.items : []
const filtered =
requestedIds.size || requestedSrcs.size
? items.filter((item) => (item?.id && requestedIds.has(item.id)) || (typeof item?.src === 'string' && requestedSrcs.has(item.src.trim())))
: items
return filtered
.filter((item) => typeof item?.src === 'string' && item.src.trim())
.map((item) => {
const draftLabel =
typeof itemLabels?.[item.id] === 'string' && itemLabels[item.id].trim()
? itemLabels[item.id].trim().slice(0, 60)
: typeof item?.label === 'string' && item.label.trim()
? item.label.trim().slice(0, 60)
: buildItemLabelFromSrc(item.src)
return {
...item,
src: item.src.trim(),
label: draftLabel,
}
})
}
async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
await createGame({ id: gameId, name: gameName })
if (tierList.thumbnailSrc) {
@@ -400,15 +555,31 @@ async function createGameTemplateFromRequest({ templateRequest, gameId, gameName
}
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
const result = await listCustomItems({ page: 1, limit: 200, orphanOnly: false })
const result = await listCustomItems({ page: 1, limit: 10000, orphanOnly: false })
const target = result.items.find((item) => item.id === req.params.itemId)
if (!target) return res.status(404).json({ error: 'not_found' })
if (target.sourceType === 'template') {
if (String(target.id || '').startsWith('asset:')) {
const assetId = String(target.id).slice('asset:'.length)
const asset = await findImageAssetById(assetId)
if (!asset) return res.status(404).json({ error: 'not_found' })
await deleteImageAssets([assetId])
await removeUploadFiles([asset.src])
return res.json({ ok: true, sourceType: 'template-asset' })
}
await deleteGameItem(target.id)
return res.json({ ok: true, sourceType: 'template' })
}
if (!target.canDelete) return res.status(409).json({ error: 'item_locked' })
if (target.linkedGames.length > 0) return res.status(409).json({ error: 'item_linked' })
if (target.usageCount > 0) return res.status(409).json({ error: 'item_in_use' })
const items = await findCustomItemsByIds([target.id])
await deleteCustomItems([target.id])
await removeCustomItemFiles(items)
res.json({ ok: true })
res.json({ ok: true, sourceType: 'user' })
})
router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
@@ -422,9 +593,21 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
if (!game) return res.status(404).json({ error: 'game_not_found' })
const customItem = await findCustomItemById(req.params.itemId)
if (!customItem) return res.status(404).json({ error: 'not_found' })
const gameItem = customItem ? null : await findGameItemById(req.params.itemId)
const assetItemId = String(req.params.itemId || '')
const imageAsset = !customItem && !gameItem && assetItemId.startsWith('asset:') ? await findImageAssetById(assetItemId.slice(6)) : null
const sourceItem =
customItem ||
gameItem ||
(imageAsset
? {
src: imageAsset.src || '',
label: imageAsset.labelOverride || path.basename(imageAsset.src || '', path.extname(imageAsset.src || '')) || 'item',
}
: null)
if (!sourceItem) return res.status(404).json({ error: 'not_found' })
const item = await promoteCustomItemToGameItem({ customItem, gameId: game.id })
const item = await promoteLibraryItemToGameItem({ item: sourceItem, gameId: game.id })
res.json({ item })
})
@@ -513,6 +696,111 @@ router.post('/template-requests/:requestId/approve', requireAdmin, async (req, r
res.json({ request, ...result })
})
router.post('/template-requests/:requestId/review', requireAdmin, async (req, res) => {
const templateRequest = await findTemplateRequestById(req.params.requestId)
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
if (templateRequest.status === 'completed' || templateRequest.status === 'rejected' || templateRequest.status === 'approved') {
return res.status(409).json({ error: 'request_already_handled' })
}
if (templateRequest.status === 'reviewing') {
return res.json({ request: templateRequest })
}
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'reviewing' })
res.json({ request })
})
router.post('/template-requests/:requestId/link-game', requireAdmin, async (req, res) => {
const schema = z.object({
gameId: z.string().trim().min(1).max(120),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const templateRequest = await findTemplateRequestById(req.params.requestId)
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
if (templateRequest.type !== 'create') return res.status(409).json({ error: 'create_request_required' })
if (!['pending', 'reviewing'].includes(templateRequest.status)) {
return res.status(409).json({ error: 'request_already_handled' })
}
const game = await findGameById(parsed.data.gameId)
if (!game) return res.status(404).json({ error: 'game_not_found' })
const request = await updateTemplateRequestTargetGame({
id: templateRequest.id,
targetGameId: game.id,
})
res.json({ request })
})
router.post('/template-requests/:requestId/promote-items', requireAdmin, async (req, res) => {
const schema = z.object({
gameId: z.string().trim().min(1).max(120),
itemIds: z.array(z.string().min(1)).optional().default([]),
itemSrcs: z.array(z.string().min(1)).optional().default([]),
itemLabels: z.record(z.string(), z.string().min(1).max(60)).optional().default({}),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const templateRequest = await findTemplateRequestById(req.params.requestId)
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
if (!['pending', 'reviewing'].includes(templateRequest.status)) {
return res.status(409).json({ error: 'request_already_handled' })
}
const game = await findGameById(parsed.data.gameId)
if (!game) return res.status(404).json({ error: 'game_not_found' })
const promotableItems = pickTemplateRequestItems(templateRequest, parsed.data.itemIds, parsed.data.itemLabels, parsed.data.itemSrcs)
if (!promotableItems.length) {
return res.status(400).json({ error: 'no_items_selected' })
}
let items = []
try {
items = await promoteSnapshotItemsToGame({
items: promotableItems,
gameId: game.id,
})
} catch (error) {
console.error('[admin] template request promote-items failed', {
requestId: templateRequest.id,
gameId: game.id,
itemCount: promotableItems.length,
message: error?.message || 'unknown_error',
code: error?.code || '',
stack: error?.stack || '',
})
return res.status(500).json({
error: 'promote_items_failed',
detail: error?.message || 'unknown_error',
code: error?.code || '',
})
}
const request =
templateRequest.status === 'reviewing'
? templateRequest
: await updateTemplateRequestStatus({ id: templateRequest.id, status: 'reviewing' })
res.json({ request, items })
})
router.post('/template-requests/:requestId/complete', requireAdmin, async (req, res) => {
const templateRequest = await findTemplateRequestById(req.params.requestId)
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
if (templateRequest.status === 'completed') return res.json({ request: templateRequest })
if (templateRequest.status === 'rejected' || templateRequest.status === 'approved') {
return res.status(409).json({ error: 'request_already_handled' })
}
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'completed' })
res.json({ request })
})
router.post('/template-requests/:requestId/reject', requireAdmin, async (req, res) => {
const templateRequest = await findTemplateRequestById(req.params.requestId)
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
@@ -537,8 +825,19 @@ router.delete('/custom-items', requireAdmin, async (req, res) => {
})
router.get('/users', requireAdmin, async (req, res) => {
const users = await listUsers()
res.json({ users })
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
sort: z.enum(['recent', 'created', 'tierlists']).optional().default('recent'),
direction: z.enum(['asc', 'desc']).optional().default('desc'),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const [users, primaryAdmin] = await Promise.all([
listUsers({ queryText: parsed.data.q, sort: parsed.data.sort, direction: parsed.data.direction }),
findPrimaryAdminUser(),
])
res.json({ users: users.map((user) => decorateAdminUser(user, primaryAdmin)) })
})
router.patch('/users/:userId', requireAdmin, async (req, res) => {
@@ -550,21 +849,34 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!targetUser) return res.status(404).json({ error: 'not_found' })
const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin)
const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id
const roleChanged = parsed.data.isAdmin !== !!targetUser.isAdmin
if (req.params.userId === req.session.userId && !parsed.data.isAdmin) {
return res.status(400).json({ error: 'self_admin_required' })
}
const user = await findUserById(req.params.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
if (targetIsPrimaryAdmin && !actingIsPrimaryAdmin) {
return res.status(403).json({ error: 'primary_admin_protected' })
}
if (targetIsPrimaryAdmin && !parsed.data.isAdmin) {
return res.status(400).json({ error: 'primary_admin_required' })
}
if (roleChanged && !actingIsPrimaryAdmin) {
return res.status(403).json({ error: 'primary_admin_only' })
}
try {
const updated = await adminUpdateUser({
id: user.id,
id: targetUser.id,
email: parsed.data.email,
nickname: parsed.data.nickname,
isAdmin: parsed.data.isAdmin,
})
res.json({ user: updated })
res.json({ user: decorateAdminUser(updated, primaryAdmin) })
} catch (e) {
if (e && e.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'email_taken' })
@@ -580,8 +892,11 @@ router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar')
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserById(req.params.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!targetUser) return res.status(404).json({ error: 'not_found' })
if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) {
return res.status(403).json({ error: 'primary_admin_protected' })
}
const optimized = req.file
? await writeOptimizedImage({
@@ -594,16 +909,16 @@ router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar')
})
: null
const shouldRemoveAvatar = parsed.data.removeAvatar === '1'
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || ''
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || targetUser.avatarSrc || ''
const updated = await adminUpdateUser({
id: user.id,
email: user.email,
nickname: user.nickname || '',
isAdmin: !!user.isAdmin,
id: targetUser.id,
email: targetUser.email,
nickname: targetUser.nickname || '',
isAdmin: !!targetUser.isAdmin,
avatarSrc: nextAvatarSrc,
})
res.json({ user: updated })
res.json({ user: decorateAdminUser(updated, primaryAdmin) })
})
router.delete('/users/:userId', requireAdmin, async (req, res) => {
@@ -611,10 +926,19 @@ router.delete('/users/:userId', requireAdmin, async (req, res) => {
return res.status(400).json({ error: 'cannot_delete_self' })
}
const user = await findUserById(req.params.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!targetUser) return res.status(404).json({ error: 'not_found' })
await adminDeleteUser(user.id)
const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin)
const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id
if (targetIsPrimaryAdmin) {
return res.status(400).json({ error: 'cannot_delete_primary_admin' })
}
if (targetUser.isAdmin && !actingIsPrimaryAdmin) {
return res.status(403).json({ error: 'primary_admin_only' })
}
await adminDeleteUser(targetUser.id)
res.json({ ok: true })
})
@@ -625,11 +949,14 @@ router.patch('/users/:userId/password', requireAdmin, async (req, res) => {
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserById(req.params.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId)
if (!targetUser) return res.status(404).json({ error: 'not_found' })
if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) {
return res.status(403).json({ error: 'primary_admin_protected' })
}
const passwordHash = await bcrypt.hash(parsed.data.password, 10)
await adminUpdateUserPassword({ id: user.id, passwordHash })
await adminUpdateUserPassword({ id: targetUser.id, passwordHash })
res.json({ ok: true })
})

View File

@@ -9,6 +9,7 @@ const {
findUserById,
createUser,
updateUserProfile,
findPrimaryAdminUser,
} = require('../db')
const { requireAuth } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
@@ -25,6 +26,38 @@ const profileSchema = z.object({
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
})
function establishSession(req, user) {
return new Promise((resolve, reject) => {
req.session.regenerate((regenerateError) => {
if (regenerateError) return reject(regenerateError)
req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin
req.session.save((saveError) => {
if (saveError) return reject(saveError)
resolve()
})
})
})
}
async function serializeUser(user) {
if (!user) return null
const primaryAdmin = await findPrimaryAdminUser()
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
return {
id: user.id,
email: user.email,
nickname: user.nickname || '',
isAdmin: !!user.isAdmin,
isPrimaryAdmin,
isOperator: !!user.isAdmin && !isPrimaryAdmin,
role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user',
avatarSrc: user.avatarSrc || '',
createdAt: user.createdAt,
}
}
router.post('/signup', async (req, res) => {
const parsed = signupSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -37,12 +70,12 @@ router.post('/signup', async (req, res) => {
const isAdmin = (await countUsers()) === 0
const user = await createUser({ id: nanoid(), email, nickname: '', passwordHash, isAdmin })
req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin
req.session.save((err) => {
if (err) return res.status(500).json({ error: 'session_save_failed' })
res.json(user)
})
try {
await establishSession(req, user)
res.json(await serializeUser(user))
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
})
router.post('/login', async (req, res) => {
@@ -56,19 +89,12 @@ router.post('/login', async (req, res) => {
const ok = await bcrypt.compare(password, user.passwordHash)
if (!ok) return res.status(401).json({ error: 'invalid_credentials' })
req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin
req.session.save((err) => {
if (err) return res.status(500).json({ error: 'session_save_failed' })
res.json({
id: user.id,
email: user.email,
nickname: user.nickname || '',
isAdmin: !!user.isAdmin,
avatarSrc: user.avatarSrc || '',
createdAt: user.createdAt,
})
})
try {
await establishSession(req, user)
res.json(await serializeUser(user))
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
})
router.post('/logout', async (req, res) => {
@@ -80,7 +106,7 @@ router.get('/me', async (req, res) => {
if (!req.session || !req.session.userId) return res.json({ user: null })
const user = await findUserById(req.session.userId)
if (!user) return res.json({ user: null })
res.json({ user })
res.json({ user: await serializeUser(user) })
})
router.get('/meta', async (req, res) => {
@@ -115,7 +141,7 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
avatarSrc: nextAvatarSrc,
})
res.json({ user: updated })
res.json({ user: await serializeUser(updated) })
})
module.exports = router

View File

@@ -58,6 +58,32 @@ function getCustomTemplateItems(tierList) {
const upload = createMemoryUpload(multer, { fileSize: 6 * 1024 * 1024 })
const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024 })
const templateRequestSchema = z.object({
type: z.enum(['create', 'update']),
sourceTierListId: z.string().max(64).optional().default(''),
gameId: z.string().min(1).max(120),
requestTitle: z.string().trim().min(1).max(120),
requestDescription: z.string().trim().min(1).max(1000),
thumbnailSrc: z.string().max(255).optional().default(''),
isPublic: z.boolean().optional().default(false),
showCharacterNames: z.boolean().optional().default(false),
groups: z.array(
z.object({
id: z.string().min(1),
name: z.string().min(1).max(16),
itemIds: z.array(z.string()).optional().default([]),
}).passthrough()
),
boardItems: z.array(
z.object({
id: z.string().min(1),
src: z.string().min(1),
label: z.string().min(1).max(60),
origin: z.enum(['game', 'custom']).default('game'),
})
),
})
const tierListUpsertSchema = z.object({
id: z.string().optional(),
gameId: z.string().min(1),
@@ -73,8 +99,8 @@ const tierListUpsertSchema = z.object({
z.object({
id: z.string().min(1),
name: z.string().min(1).max(16),
itemIds: z.array(z.string()),
})
itemIds: z.array(z.string()).optional().default([]),
}).passthrough()
),
pool: z.array(
z.object({
@@ -194,40 +220,45 @@ router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), asyn
res.json({ thumbnailSrc: optimized.src })
})
router.post('/:id/template-request', requireAuth, async (req, res) => {
const schema = z.object({
type: z.enum(['create', 'update']),
requestTitle: z.string().trim().min(1).max(80),
requestDescription: z.string().trim().min(1).max(240),
})
const parsed = schema.safeParse(req.body)
router.post('/template-request', requireAuth, async (req, res) => {
const parsed = templateRequestSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const tierList = await findTierListById(req.params.id, req.session.userId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
if (tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
const customItems = getCustomTemplateItems(tierList)
const payload = parsed.data
const normalizedBoardItems = payload.boardItems.map(normalizePoolItem)
const customItems = normalizedBoardItems.filter((item) => item?.origin === 'custom')
if (!customItems.length) return res.status(400).json({ error: 'custom_items_required' })
if (parsed.data.type === 'create') {
if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
} else {
if (tierList.gameId === FREEFORM_GAME_ID) return res.status(400).json({ error: 'game_template_required' })
if (payload.type === 'create') {
if (payload.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
} else if (payload.gameId === FREEFORM_GAME_ID) {
return res.status(400).json({ error: 'game_template_required' })
}
let sourceTierList = null
if (payload.sourceTierListId) {
sourceTierList = await findTierListById(payload.sourceTierListId, req.session.userId)
if (!sourceTierList) return res.status(404).json({ error: 'not_found' })
if (sourceTierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
}
if (!payload.sourceTierListId) return res.status(400).json({ error: 'source_tierlist_required' })
try {
const request = await createTemplateRequest({
id: nanoid(),
type: parsed.data.type,
type: payload.type,
requesterId: req.session.userId,
sourceTierListId: tierList.id,
sourceGameId: tierList.gameId,
targetGameId: parsed.data.type === 'update' ? tierList.gameId : '',
title: parsed.data.requestTitle,
description: parsed.data.requestDescription,
thumbnailSrc: tierList.thumbnailSrc || '',
sourceTierListId: sourceTierList?.id || '',
sourceGameId: payload.gameId,
targetGameId: payload.type === 'update' ? payload.gameId : '',
title: payload.requestTitle,
description: payload.requestDescription,
thumbnailSrc: payload.thumbnailSrc || '',
items: customItems,
groups: payload.groups,
boardItems: normalizedBoardItems,
showCharacterNames: !!payload.showCharacterNames,
})
return res.json({ request })
} catch (e) {

View File

@@ -1,5 +1,63 @@
# 의사결정 이력
## 2026-04-02 v1.3.59
- 템플릿 요청 아이템 반영은 관리자 주의에만 맡기기보다, 서버에서 같은 게임 안의 동일 `src` 중복 생성을 막고 프런트에서도 이미 반영된 요청 아이템을 다시 드래프트에 올리지 않는 편이 운영 안정성에 더 낫다고 정리했다.
- 신규 템플릿 요청은 “완전한 임시 게임” 구조로 바로 바꾸기보다, 우선 한 번 만든 게임을 요청과 연결해 다시 `확인하기`를 눌러도 같은 게임을 재사용하게 만드는 편이 리스크 대비 효과가 크다고 판단했다.
- 신규 템플릿 요청 카드는 생성 여부가 관리자의 머릿속 상태가 아니라 UI 메타로 드러나야 하므로, `연결된 게임 있음/없음``이미 반영 n개`를 카드와 작업 패널 양쪽에서 함께 보여주는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.55
- 관리자 요청/업로드 배지는 문구만 다르면 빠르게 구분하기 어려우므로, 같은 `pill` 구조를 유지하되 색으로도 역할을 나누는 편이 운영 판단에 더 적합하다고 정리했다.
- 신규 템플릿 요청으로 새 게임을 만들 때는 아이템만 가져오고 썸네일이 비어 있으면 식별성이 떨어지므로, 요청 썸네일도 기본값으로 함께 승계하는 편이 맞다고 판단했다.
## 2026-04-02 v1.3.54
- 관리자 요청 카드는 운영자가 이미 흐름을 알고 있다는 전제에서, 설명형 힌트보다 즉시 판단에 필요한 메타와 액션만 남기는 편이 더 적합하다고 정리했다.
- 요청 종류 표시는 중복 텍스트보다 오른쪽 상단의 짧은 상태 배지 하나로 고정하고, 하단 액션 줄은 `보조 링크는 왼쪽 / 실제 처리 버튼은 오른쪽` 구조가 더 읽기 쉽다고 판단했다.
## 2026-04-02 v1.3.53
- 관리자 후속 리팩터링은 남은 큰 액션 묶음인 `상단 고정 게임 정렬``커스텀 아이템 검수`부터 composable로 분리하는 편이 `AdminView.vue` 체감 복잡도를 가장 빨리 낮춘다고 판단했다.
- 이 단계에서도 레이아웃이나 문구보다 로직 책임 경계를 먼저 옮기고, 실제 스타일 파일 분리는 그 다음 단계로 이어가는 편이 안전하다고 정리했다.
## 2026-04-02 v1.3.52
- 관리자 화면은 본문을 컴포넌트로 나눈 뒤에도 같은 시각 문법을 유지해야 하므로, `scoped`를 유지한 채 각 섹션에 스타일을 복붙하기보다 관리자 범위 공통 스타일로 다시 묶는 편이 더 안전하다고 정리했다.
- `템플릿 요청 관리 / 전체 티어표 관리` 내부 모드 값은 URL과 버튼 상태가 어긋나지 않도록 `all` 하나로 통일하는 편이 맞다고 판단했다.
- 릴리스 기록은 문서 버전만 올라가고 태그가 빠지면 추적이 끊기므로, 뒤늦게라도 누락 태그를 다시 맞춰 버전 흐름을 복구하는 편이 낫다고 정리했다.
## 2026-04-02 v1.3.51
- 관리자 리팩터링은 본문 분리 다음 단계에서 `회원 관리`처럼 모달과 부수 액션이 많은 영역을 composable로 떼어내는 편이 효과가 크다고 판단했다.
- 이 단계에서는 UI 문구나 사용자가 이미 손본 CSS를 다시 건드리기보다, 현재 동작을 유지한 채 책임 경계만 옮기는 쪽이 더 안전하다고 정리했다.
## 2026-04-02 v1.3.50
- 템플릿 요청 카드는 게임 이름/ID만 남기기보다 대표 썸네일까지 유지하는 편이 운영자가 요청 대상을 훨씬 빨리 구분할 수 있다고 정리했다.
- 템플릿 요청의 `확인하기`는 단순히 해당 게임을 선택하는 동작이 아니라, 게임 관리 화면에서 요청 아이템 후보가 실제 작업 상태로 복원되어야 한다고 판단했다.
- 관리자 화면은 기능이 많아진 만큼 단일 `/admin` 상태보다 섹션별 경로를 갖는 편이 뒤로가기와 직접 진입 모두에서 더 안정적이라고 정리했다.
- 관리자 URL은 보이기만 막는 수준이 아니라, 라우터 단계에서 비로그인/비관리자 접근 자체를 차단하는 편이 맞다고 정리했다.
- 티어표 에디터의 커스텀 아이템 이름 편집 목록은 “사전순 재정렬”보다 입력 안정성이 더 중요하므로, 실시간 라벨 기준 정렬은 제거하는 쪽으로 결정했다.
- 게임 기본 아이템은 최신 추가 항목이 먼저 보이도록 하되, 관리자가 필요하면 직접 드래그해 기준 순서를 고정할 수 있어야 한다고 판단했다.
- 관리자 리팩터링은 한 번에 로직까지 갈아엎기보다, 먼저 각 관리 본문을 섹션 컴포넌트로 분리해 `AdminView.vue`의 책임을 줄이는 단계형 접근이 더 안전하다고 정리했다.
- 본문 템플릿 분리 다음 단계에서는 `게임 관리``템플릿 요청`처럼 상태가 무거운 영역부터 composable로 옮겨, 뷰 파일과 업무 로직 파일의 경계를 먼저 세우는 편이 맞다고 판단했다.
## 2026-04-01 v1.3.49
- 템플릿 요청은 임시 편집 상태에 기대기보다, 먼저 저장된 티어표를 기준으로 요청 스냅샷을 만드는 편이 훨씬 안정적이라고 정리했다. 그래서 요청 버튼은 저장본이 있을 때만 노출하고, 제목이 비어 있으면 사람이 쓰는 기본 문구 대신 고유한 랜덤 제목으로 먼저 저장본을 만든 뒤 요청을 이어가도록 했다.
- 관리자 템플릿 요청 미리보기는 별도 요청 전용 보드 레이아웃을 유지하기보다, 일반 티어표 완성본과 같은 행·열·남은 아이템 문법을 그대로 재사용하는 편이 검수 체감이 가장 자연스럽다고 판단했다.
## 2026-04-01 v1.3.48
- 관리자 탭 데이터는 첫 진입 로딩만 믿기보다, 인증 완료와 탭 전환 시점에 필요한 목록을 다시 채워 넣는 편이 실제 운영 화면에서 더 안정적이라고 정리했다.
- 템플릿 요청 미리보기는 일반 티어표 보기와 완전히 같은 구현을 억지로 분기하기보다, 같은 내부 프레임 문법과 정보 밀도를 먼저 맞춰 체감 차이를 줄이는 쪽이 현실적이라고 판단했다.
## 2026-04-01 v1.3.47
- 관리자 `사용자 템플릿 요청`도 결국 검수용 카드이므로, 요청 전용 카드 문법을 따로 두기보다 `전체 티어표 관리`와 같은 카드 구조를 재사용하는 편이 더 직관적이라고 정리했다.
- 새 템플릿 생성 요청의 기본 게임 ID는 사람이 읽기 어려운 난수보다 요청 단위에서 유일한 임시값을 먼저 채워두고, 승인 전에 관리자가 수정하는 흐름이 더 현실적이라고 판단했다.
## 2026-04-01 v1.3.46
- 관리자 전체 티어표 카드에서는 좌측 영역 전체를 버튼처럼 만드는 것보다, 실제 썸네일 이미지만 미리보기 진입점으로 읽히게 두는 편이 카드 정보 구조가 덜 흔들린다고 정리했다.
- 템플릿 요청 미리보기는 일반 티어표 보기와 다른 요약 레이아웃을 새로 두기보다, 같은 내부 프레임 문법 안에서 보드 자체를 먼저 보여주는 편이 사용자가 더 자연스럽게 이해한다고 판단했다.
## 2026-04-01 v1.3.45
- 템플릿 요청에서 `내 티어리스트에도 저장`은 별도 부가 기능이 아니라 실제 저장본 생성 경로를 타므로, 새 저장본 ID는 호출자에 기대지 말고 저장 함수 내부에서 항상 보장하는 편이 더 안전하다고 정리했다.
- 개발 단계의 내부 조치 문구인 `백엔드 재시작` 같은 표현은 사용자 토스트에 직접 노출하지 않고, 운영형 재시도 안내로 낮추는 편이 맞다고 판단했다.
## 2026-04-01 v1.3.44
- 관리자 티어표 목록에서는 `보기` 버튼을 없애더라도 완성본 확인 기능 자체는 유지해야 하므로, 별도 액션 버튼보다 카드 썸네일 클릭을 미리보기 진입점으로 쓰는 편이 더 자연스럽다고 정리했다.
- 템플릿 요청 미리보기도 별도 요약 카드보다 실제 보드 구조를 우선 보여주는 쪽이 관리자 검수 흐름에 더 맞으므로, 일반 티어표 미리보기와 가까운 방향으로 통일하기로 했다.
## 2026-03-30 v1.2.25
- 홈 게임 카드는 메인 썸네일까지 없애는 것보다, 큰 썸네일은 유지하고 ID 옆의 작은 보조 표시만 제거하는 편이 원래 의도와 맞다고 정리했다.
- 좌우 하단 액션 여백은 `margin`으로 푸터 전체를 밀기보다, 푸터 내부 `padding-bottom`으로 확보해야 버튼 자체는 항상 보이고 여백만 남는다고 판단했다.

View File

@@ -12,8 +12,8 @@
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
- 화면 파일: `frontend/src/views/TierEditorView.vue`
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request`
## `/login`
- 화면 파일: `frontend/src/views/LoginView.vue`
@@ -37,7 +37,7 @@
## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-game-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
## `/profile`

View File

@@ -49,12 +49,12 @@
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
- 공통 상단 토글 버튼은 Teleport로 이동한 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다.
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다.
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다. 템플릿 등록/업데이트 요청 버튼은 저장된 티어표가 있을 때만 노출하며, 제목이 비어 있는 상태에서 저장하면 랜덤 고유 제목을 먼저 부여해 저장본을 만든다.
- 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
- 관리자 화면
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다.
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다. 템플릿 요청 카드는 전체 티어표 카드와 같은 썸네일 좌측/정보 우측 구조를 따르며, 요청 미리보기는 일반 티어표 완성본과 같은 행·열 보드 문법으로 검수한다.
- 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다.
## DB 스키마

View File

@@ -1,20 +1,35 @@
# 할 일 및 이슈
## 즉시 확인 필요
- 레거시 업로드 파일도 현재 실사용 용량에는 포함되지만, 과거 자산까지 'image_assets' 메타에 백필한 상태는 아니므로 필요하면 1회 백필 스크립트를 추가해 절감률/중복 통계를 완전히 일원화한다.
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.
- 이미지 최적화 기록은 월별 조회/비우기까지 지원하므로, 운영 단계에서는 보관 기간 정책과 자동 아카이브 기준을 정한다.
## 배포 전 작업
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
- MariaDB 접속 정보 `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`를 설정한다.
- HTTPS를 사용할 경우 `SESSION_COOKIE_SECURE=true`로 설정하고 리버스 프록시 헤더 전달을 확인한다.
- `backend/uploads/`, `backend/.sessions/`, MariaDB 백업 정책을 정한다.
- 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다.
## 중기 개선
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
- 관리자 템플릿 요청은 동일 `src` 중복 생성 방지와 `연결된 게임/이미 반영 n개` 표시까지 붙였으므로, 이후에는 요청별 반영 이력(예: 어떤 아이템을 언제 반영했는지)까지 별도로 남길지 검토한다.
- 관리자 URL 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue` 단일 대형 파일을 섹션별 뷰/컴포저블로 쪼개고 직접 진입 시 선택 게임/요청 작업 상태 복원 범위도 함께 정리한다.
- 관리자 본문 컴포넌트 분리와 `게임/템플릿 요청/회원 관리/아이템 관리/목록 관리` composable 분리는 시작했으므로, 다음 단계에서는 공통 모달 상태를 어느 계층에서 소유할지 정리하고 남은 관리자 유틸 함수를 더 줄인다.
- 관리자 화면은 섹션 경로 분리까지 끝났으므로, 다음 단계에서는 `AdminView.vue`를 실제 레이아웃 뷰와 섹션별 라우트 컴포넌트로 더 쪼갤지 결정한다.
- 관리자 공통 스타일은 `adminUiScope` 기준으로 다시 묶었으므로, 다음 단계에서는 각 섹션을 별도 파일로 완전히 분리할 때 스타일도 `admin.css` 또는 섹션별 스타일로 옮길지 결정한다.
- 관리자 요청 카드 밀도는 줄였으므로, 다음 단계에서는 전체 티어표 카드와 요청 카드의 상단/하단 액션 정렬을 한 번 더 통일할지 비교 QA한다.
- 신규 템플릿 요청 썸네일 기본 승계는 붙였으므로, 다음 단계에서는 요청 아이템 반영 후 `처리 완료`까지의 관리자 흐름을 실제 데이터로 한 번 더 QA한다.
- 관리자 게임 아이템 순서 저장은 추가됐으므로, 다음 단계에서는 새 아이템 추가 직후 `자동 맨 앞 배치``관리자 수동 고정 순서`의 우선순위를 실제 운영 흐름 기준으로 한 번 더 QA한다.
- 관리자 템플릿 요청 미리보기는 실제 완성본 iframe 방식과의 체감 차이를 마지막으로 한 번 더 QA한다.
- 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다.
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다.
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
- 로그인/회원가입/관리자 비밀번호 초기화에 요청 횟수 제한을 추가한다.
- 업로드 파일은 MIME 타입뿐 아니라 파일 시그니처 기반 검증까지 확장한다.
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다.
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
- 관리자 템플릿 요청 카드와 전체 티어표 카드가 같은 문법으로 맞춰졌으므로, 이후에는 라이트모드까지 포함해 두 카드의 썸네일 높이·입력창 밀도·아이템 그리드를 한 번 더 비교 QA한다.
- 템플릿 요청 저장 흐름은 저장된 티어표 기준으로 바뀌었으므로, 이후 실제 데이터로 빈 제목 저장 시 자동 생성 제목·요청 버튼 노출 시점·관리자 요청 미리보기 밀도를 한 번 더 비교 QA한다.

View File

@@ -1,5 +1,243 @@
# 업데이트 로그
## 2026-04-02 v1.3.59
- 관리자 템플릿 요청의 `promote-items` 처리에서는 잘못된 `z.record` 스키마 때문에 500이 나던 서버 파싱 버그를 수정하고, 요청 아이템 `src`까지 함께 받아 실제 요청 데이터와 더 안정적으로 매칭하도록 보강함.
- 요청 아이템을 게임에 반영할 때는 이제 같은 게임 안에 동일한 `src`가 이미 있으면 새 기본 아이템을 다시 만들지 않도록 막고, 관리자 화면에서도 이미 반영된 요청 아이템은 드래프트에 다시 올리지 않게 정리함.
- 신규 템플릿 요청으로 새 게임을 한 번 만들면 해당 요청과 새 게임을 연결해 저장하고, 이후 같은 요청에서 다시 `확인하기`를 눌렀을 때는 새 게임을 또 만들지 않고 기존에 연결된 게임으로 바로 복귀하도록 흐름을 정리함.
- 따라서 요청 카드와 게임 관리 작업 패널에서는 `연결된 게임`, `이미 반영 n개` 같은 상태를 함께 보여, 처리 완료 전에도 현재 진행 정도와 재작업 위험을 더 쉽게 구분할 수 있게 함.
## 2026-04-02 v1.3.55
- 관리자 요청 카드 오른쪽 상단의 `신규 템플릿 / 보유 템플릿` 배지는 서로 다른 색상으로 분리해, 카드 타입을 텍스트보다 더 빠르게 구분할 수 있게 조정함.
- 게임 관리의 기본 아이템 추가 미리보기에서도 `요청 아이템 / 직접 추가 파일` 배지를 서로 다른 색상으로 구분해, 요청 반영분과 직접 업로드분이 한눈에 섞이지 않도록 정리함.
- 신규 템플릿 요청에서 `새 게임 만들기`를 진행할 때는 요청 티어표 대표 썸네일도 함께 새 게임 썸네일로 복사되도록 보강해, 관리자가 이후 수정하더라도 초기 식별용 썸네일은 바로 이어받을 수 있게 함.
## 2026-04-02 v1.3.54
- 관리자 `티어표 관리` 요청 카드에서는 사용법 힌트 문구와 중복 타입 텍스트를 제거해, 카드 본문이 관리 정보만 더 빠르게 읽히도록 정리함.
- `신규 템플릿 / 보유 템플릿` 구분은 카드 오른쪽 상단의 별도 배지로 옮기고, 기존 `추가 아이템 / 확인함 여부` 배지는 그대로 유지해 정보 계층을 더 단순하게 맞춤.
- `요청 티어표 보기` 링크는 하단 액션 줄의 왼쪽으로 옮기고 `확인하기 / 처리 완료` 버튼은 오른쪽에 정렬해, 실제 작업 버튼과 보조 링크의 역할이 한 줄 안에서도 분명하게 보이도록 조정함.
## 2026-04-02 v1.3.53
- 관리자 리팩터링 4차로 `목록 관리` 정렬 로직과 `아이템 관리` 모달/삭제/승격 액션을 각각 `useAdminFeaturedGames`, `useAdminCustomItems` composable로 분리해 `AdminView.vue`의 직접 액션 코드를 더 줄임.
- 따라서 관리자 메인 뷰는 섹션 연결과 공통 상태 중심으로 더 가까워졌고, 상단 고정 게임 정렬과 커스텀 아이템 처리 흐름은 각 영역 책임에 맞는 파일로 옮겨 유지보수 범위를 좁힘.
## 2026-04-02 v1.3.52
- 관리자 본문 섹션을 컴포넌트로 나눈 뒤 `AdminView.vue` 스타일이 `scoped`에 묶여 자식 컴포넌트까지 제대로 닿지 않던 문제를 정리하고, 관리자 전용 공통 스타일을 `adminUiScope` 범위로 다시 묶어 각 페이지 CSS가 함께 살아나도록 보강함.
- 템플릿 요청 카드의 신규 게임 입력 영역에는 `게임 이름 / 게임 ID` 필드 스타일을 다시 붙여, 요청 카드만 따로 풀린 것처럼 보이던 레이아웃을 복구함.
- 관리자 사이드바의 `전체 티어표 관리` 모드는 내부 값이 `lists``all`로 엇갈리던 상태를 `all` 기준으로 통일해, 버튼 활성 상태와 실제 목록 전환이 어긋나지 않게 정리함.
- 운영 이력 정합성을 위해 누락돼 있던 릴리스 태그도 다시 점검하고, `v1.3.50`, `v1.3.51`, `v1.3.52` 흐름으로 이어서 관리함.
## 2026-04-02 v1.3.51
- 관리자 리팩터링 3차로 회원 관리 액션을 `useAdminUsers` composable로 분리해, 아바타 변경, 회원 정보 수정, 비밀번호 초기화, 권한 변경, 삭제 모달 흐름을 `AdminView.vue` 밖으로 옮김.
- 따라서 관리자 메인 뷰는 섹션 연결과 공통 상태에 더 집중하고, 회원 관리 로직은 다른 관리자 영역과 같은 composable 분리 기준으로 맞추기 시작함.
- 이번 정리에서도 관리자 화면에 직접 반영돼 있던 텍스트와 게임 관리 CSS 수정분은 유지한 채 구조만 옮기도록 정리함.
## 2026-04-02 v1.3.50
- 관리자 `템플릿 요청 관리` 카드에서는 대표 썸네일을 다시 복구해 게임 이름/ID와 함께 요청 대상을 더 빠르게 식별할 수 있게 정리함.
- `확인하기` 후 게임을 불러오면서 요청 아이템 임시 목록이 비워지던 흐름을 수정하고, 신규 게임 생성 직후에도 요청 아이템이 기본 아이템 추가 미리보기에 유지되도록 보강함.
- 관리자 상단 작업 모드는 `/admin/featured`, `/admin/games`, `/admin/items`, `/admin/tierlists`, `/admin/users` 경로로 나눠 뒤로가기 시 관리자 밖으로 바로 이탈하던 흐름을 줄임.
- 관리자 경로는 이제 라우터 가드에서 로그인/관리자 여부를 먼저 확인하고, 권한이 없으면 관리자 화면 자체에 접근하지 못하도록 홈으로 되돌림.
- 티어표 에디터의 커스텀 아이템 이름 편집 목록은 입력 중 실시간 라벨 정렬을 제거해, 입력 도중 포커스가 풀리거나 글자가 끊기던 현상을 막음.
- 게임 기본 아이템은 최신 추가 항목이 앞에 오도록 기본 정렬 기준을 바꾸고, 관리자 게임 관리 화면에서 현재 목록을 그대로 드래그해 순서를 저장할 수 있게 함.
- 관리자 대형 단일 뷰 정리를 시작하면서 `목록/게임/아이템/티어표/회원 관리` 본문을 섹션 컴포넌트로 분리해, `AdminView.vue`는 상태·모달·사이드바 중심 셸로 가볍게 정리함.
- 관리자 리팩터링 2차로 `게임 관리``템플릿 요청 처리` 로직을 `useAdminGameManager`, `useAdminTemplateRequests` composable로 분리해, `AdminView.vue` 스크립트에서도 섹션별 책임이 더 명확해지도록 정리함.
## 2026-04-01 v1.3.49
- 티어표 에디터의 템플릿 요청 흐름은 저장된 티어표를 기준으로만 요청을 보낼 수 있도록 다시 정리하고, 요청 모달의 `내 티어리스트에도 저장` 분기는 제거함. 제목이 비어 있는 상태에서 저장하면 `직접 티어표 만들기` 대신 랜덤한 고유 제목을 먼저 부여해 저장본을 만들고, 그 이후에만 템플릿 요청 버튼이 노출되도록 맞춤.
- 관리자 `사용자 템플릿 요청` 카드는 `전체 티어표 관리`와 같은 카드 문법으로 유지하되, 썸네일은 상단에 고정된 클릭 진입점으로 다시 정리하고 카드 본문과 별도 입력 영역의 밀도를 맞춤.
- 템플릿 요청 미리보기는 일반 티어표 완성본과 같은 보드 문법으로 다시 구성하고, `cells` 기반 배치 아이템도 남은 아이템 계산에 정확히 반영해 요청 미리보기와 일반 완성본 보기의 차이를 줄임.
## 2026-04-01 v1.3.48
- 관리자 화면은 새로고침 직후에도 `티어표 관리 / 회원 관리` 목록이 비지 않도록, 관리자 인증이 확정되거나 탭이 바뀔 때 해당 목록을 다시 불러오는 흐름으로 보강함.
- 관리자 아이템 모달은 내부 스크롤바를 숨기고 스크롤 체인을 끊어 배경이 함께 움직이지 않게 했고, 게임 선택 패널과 본문 패널의 상단 정렬도 다시 맞춤.
- 템플릿 요청 미리보기는 누락돼 있던 `requestPreview__frame / __header` 스타일을 보강해 일반 티어표 완성본과 더 비슷한 내부 프레임 구조와 보드 밀도로 다시 정리함.
## 2026-04-01 v1.3.47
- 관리자 `사용자 템플릿 요청` 카드는 별도 요청 전용 레이아웃 대신 `전체 티어표 관리`와 같은 카드 문법으로 맞추고, 왼쪽 썸네일 클릭으로 같은 미리보기 모달이 열리도록 정리함.
- 새 템플릿 요청에는 썸네일 아래에 `게임 이름 / 게임 ID` 입력을 두고, 초기 `게임 ID``new-template` 대신 요청 ID 기반의 임시 고유값으로 채워 나중에 수정하기 쉽게 바꿈.
- 요청 카드 오른쪽에는 제목, 설명, 요청 메타, 추가 아이템 목록, 승인/반려 버튼을 같은 정보 계층으로 배치해 전체 티어표 관리와 읽는 흐름을 통일함.
## 2026-04-01 v1.3.46
- 관리자 `전체 티어표 관리`의 썸네일 영역은 카드 좌측 전체가 눌리는 버튼처럼 보이지 않도록 이미지 영역만 상단에 붙여 클릭 진입점으로 유지하고, 카드 본문과의 시각적 분리를 다시 다듬음.
- `템플릿 요청 관리` 미리보기는 별도 썸네일 요약형이 아니라, 제목·설명·행/열 보드·남은 아이템이 하나의 내부 프레임 안에서 이어지는 실제 티어표 완성본형 레이아웃으로 다시 정리함.
## 2026-04-01 v1.3.45
- 템플릿 요청에서 `내 티어리스트에도 저장`이 켜져 있을 때 발생하던 500 오류는 새 저장본 생성 시 `tierlists.id``undefined`가 들어가던 문제였고, 이제 `saveTierList()`가 생성 시 자동으로 `nanoid()`를 부여하도록 고쳐 저장 분기 자체를 안정화함.
- 사용자에게 노출되던 `백엔드를 재시작해주세요` 문구는 제거하고, 저장 분기 실패 시에도 일반적인 재시도 안내만 보이도록 조정함.
- 루트에 잘못 남아 있던 `update.md` 진입점 파일은 제거하고, 업데이트 기록은 다시 `docs/update.md` 한 곳으로 정리함.
## 2026-04-01 v1.3.44
- 관리자 `전체 티어표 관리`에서는 별도 `완성본 보기` 버튼은 다시 두지 않되, 카드 썸네일 자체를 눌러 기존처럼 완성본 미리보기 모달을 열 수 있게 복구함.
- `템플릿 요청 관리`의 요청 미리보기는 요약 썸네일 중심 레이아웃을 줄이고, 실제 보드 구조를 먼저 읽는 방향으로 정리해 일반 티어표 완성본을 보는 흐름과 더 비슷하게 맞춤.
## 2026-04-01 v1.3.43
- 템플릿 요청 모달은 `내 티어리스트에도 저장` 토글 상태를 요청 직전에 별도로 고정해 사용하도록 바꿔, 모달이 닫히며 draft가 초기화된 뒤 성공 토스트가 반대로 나오던 문제를 바로잡음.
- 따라서 저장을 끈 상태에서는 `요청만 보냈어요` 문구가 정확히 유지되고, 저장을 켠 상태에서 500이 나는 경우에는 저장 단계에서 실패했다는 안내를 더 분명하게 보여주도록 보강함.
## 2026-04-01 v1.3.42
- 템플릿 요청 시 `내 티어리스트에도 저장`이 켜져 있을 때만 500 오류가 날 수 있던 레거시 `tierlists.source_tierlist_id` nullability 문제도 함께 보강해, 오래된 DB 스키마에서도 요청 전 저장 흐름이 막히지 않도록 정리함.
- 따라서 템플릿 요청 관련 레거시 호환 보정은 `template_requests``tierlists` 양쪽에 모두 반영됐고, 실제 적용을 위해서는 백엔드 재시작 후 재확인이 필요함.
## 2026-04-01 v1.3.41
- 템플릿 요청 등록 시 500 오류가 날 수 있던 레거시 DB 호환 문제를 보강해, 기존 `template_requests` 테이블에 `request_type`, `source_game_id`, `target_game_id`, `status` 컬럼이 빠져 있어도 서버 시작 시 자동으로 마이그레이션되도록 함.
- 따라서 저장 여부와 무관하게 템플릿 요청 흐름은 유지되고, 구버전 DB를 사용 중이더라도 백엔드 재시작 후 같은 요청이 정상 저장되도록 안정성을 높임.
## 2026-04-01 v1.3.40
- 관리자 아이템 상세 모달은 내부 스크롤바를 숨기고 본문 스크롤이 배경으로 전파되지 않도록 body scroll lock과 ESC 닫기를 추가해, 검수 중 배경 화면이 함께 움직이던 불편을 줄임.
- 관리자 티어표 관리에서는 `완성본 보기` 흐름을 제거하고, 전체 티어표의 추가 아이템을 클릭하면 같은 아이템 관리 모달로 열어 게임 검색·템플릿 추가·새 템플릿 생성까지 같은 문법으로 처리할 수 있게 통일함.
- 템플릿 요청 관리의 `요청 미리보기`는 단순 썸네일이 아니라 행·열 구조, 열 이름, 배치된 아이템, 미사용 아이템까지 함께 보이는 실제 보드형 미리보기로 다시 구성해 요청 내용을 한 번에 검수할 수 있게 함.
## 2026-04-01 v1.3.39
- 관리자 템플릿 요청 미리보기는 요청 시 저장된 보드 스냅샷이 비어 있을 경우 요청 아이템 배열을 fallback으로 사용해, 대표 썸네일만 보이는 상황을 줄이고 요청 내용을 더 안정적으로 확인할 수 있게 보정함.
- 관리자 아이템 상세 모달에는 아이템 이름 입력과 저장 버튼을 추가해, 템플릿 아이템·사용자 업로드·보관 자산 모두 파일명과 무관하게 사람이 읽기 좋은 이름으로 다시 정리할 수 있게 함.
- 보관 자산용 image asset에는 이름 override 컬럼을 추가해, 무작위 WebP 파일명을 그대로 노출하지 않고 라이브러리 표시명만 따로 관리할 수 있게 확장함.
## 2026-04-01 v1.3.38
- Settings 화면 오른쪽 사이드의 테마 설정 패널은 다시 쓰기 전까지 숨김 처리하고, 현재 기본 다크모드를 유지한 채 다른 화면과 동일하게 스폰서 광고만 노출되도록 정리함.
- 관리자 아이템 모달에서 템플릿에 사용 중인 게임 배지는 다크모드에서도 읽히는 텍스트 색으로 맞추고, hover/focus 전환 효과를 추가해 상호작용이 더 분명하게 보이도록 보강함.
- 관리자 아이템 모달은 데스크톱에서 최소 폭을 800px로 늘리고 최대 높이를 뷰포트 안으로 제한했으며, 16:9 이미지는 높이 상한을 둬서 모달이 넓어질 때도 이미지와 하단 버튼이 과하게 뭉개지지 않도록 정리함.
## 2026-04-01 v1.3.37
- 가이드 모달은 모바일에서 왼쪽 단계 목록 대신 현재 단계만 선택하는 셀렉트형 피커를 중심으로 쓰도록 높이와 내부 스크롤 구조를 다시 잡아, 작은 화면에서도 내용이 잘리지 않고 조작할 수 있게 정리함.
- 관리자 아이템 상세 모달은 가이드 모달과 같은 큰 2단 셸 문법으로 다시 묶어, 왼쪽 게임 선택 패널과 오른쪽 이미지·메타·액션 영역이 더 넓고 여유 있게 보이도록 재구성함.
- 아이템 상세 모달 내부 정보 카드와 액션 영역도 같은 톤의 패널형 블록으로 정리해, 가이드와 관리자 모달 사이의 시각적 통일감을 높임.
## 2026-04-01 v1.3.36
- `내 티어표` 화면 헤더를 공통 `pageHead` 문법으로 통일하고, 라이트모드에서는 공통 `railHeader` 배경을 사이드 레일과 같은 톤으로 맞춰 화면 간 상단 밀도 차를 줄임.
- 관리자 아이템 상세 모달은 더 넓은 비율로 키우고, 템플릿에 연결된 게임 이름은 hover 가능한 버튼으로 바꿔 클릭 시 해당 게임이 선택된 `게임 관리` 탭으로 바로 이동할 수 있게 함.
- 관리자 아이템 라이브러리는 이제 게임에 연결된 템플릿 이미지뿐 아니라 연결이 해제된 `/uploads/assets/` 보관 자산도 함께 보여줘, 게임 목록에서 아이템을 제거해도 아이템 관리에서는 계속 검수·재연결할 수 있게 정리함.
- 아이템 관리 탭은 다른 탭으로 이동했다가 돌아오면 검색어와 필터를 초기화해, 결과가 남아 있어 목록이 비어 보이는 오해를 줄이도록 조정함.
## 2026-04-01 v1.3.35
- 라이트모드에서 홈 게임 카드의 메타 텍스트와 대표 썸네일 플레이스홀더, 브랜드 타이틀 색을 다시 정리하고, 전체 밝기도 약간 눌러 눈부심이 덜한 회백색 톤으로 보정함.
- 관리자 아이템 상세 모달은 더 넓은 2단 레이아웃으로 키우고, 브라우저 뒤로가기 시 페이지 이탈 대신 모달이 먼저 닫히도록 히스토리 동작을 보강함.
- 아이템 라이브러리의 삭제 기준을 다시 정리해, 사용자 업로드는 어디에도 연결되지 않았을 때만 삭제하고 관리자 템플릿 이미지는 라이브러리에서도 해당 템플릿 항목을 제거할 수 있게 확장함.
## 2026-04-01 v1.3.34
- 관리자 아이템 관리 오른쪽 사이드에서는 `가져올 게임` 셀렉트를 제거하고, 사용자 업로드와 관리자 템플릿 이미지를 함께 검수하는 라이브러리 흐름으로 단순화함.
- 아이템 상세 모달은 좌측에 검색/정렬 가능한 게임 리스트를 두고 우측에 이미지·메타·액션을 배치하는 2단 레이아웃으로 재구성해, 많은 게임 속에서도 직접 검수 후 템플릿에 연결하기 쉽게 정리함.
- 아이템 라이브러리에는 이제 관리자 템플릿 이미지도 함께 표시하고, 배지로 `사용자 업로드 / 관리자 템플릿`을 구분하며 새 업로드 WebP 파일명에서는 시간 정보처럼 보이는 접두 숫자를 제거함.
- 템플릿 아이템까지 함께 보이는 구조에 맞춰 삭제 API도 사용자 업로드이면서 템플릿에 연결되지 않은 항목만 지울 수 있도록 안전 장치를 보강함.
## 2026-04-01 v1.3.33
- 라이트모드/다크모드 2차 보정으로 관리자 화면과 티어 에디터의 카드, 패널, 입력창, 모달, 썸네일 프레임을 전역 테마 변수 기준으로 다시 맞춰, 후속 화면에서도 명도 차가 더 자연스럽게 이어지도록 정리함.
- 공통 셸도 함께 손봐서 좌측 사이드 아이콘 필터와 텍스트 대비를 테마 변수 기반으로 전환하고, 가이드 모달·축소 검색 모달·내비 활성 상태까지 라이트모드에서 읽기 쉬운 톤으로 보정함.
- 전역 스타일 변수의 다크 기본값과 아이콘 필터 값을 바로잡아, 카드 배경과 텍스트 변수의 자기참조/오동작 가능성을 줄이고 이후 테마 QA 기준을 더 안정적으로 맞춤.
## 2026-04-01 v1.3.32
- 전역 테마 변수와 로컬 저장 기반 테마 토글을 추가해, Settings 화면 오른쪽 사이드에서 라이트모드/다크모드를 전환하고 재방문 시 같은 테마를 유지할 수 있게 함.
- 앱 셸, 홈, 게임 허브, 내 티어표, 즐겨찾기, 검색, 로그인, 설정 화면의 공통 카드·입력·텍스트 색을 테마 변수 기준으로 바꿔, 주요 사용자 화면은 라이트/다크 전환이 자연스럽게 이어지도록 1차 정리함.
- 관리자 화면과 티어 에디터처럼 스타일 밀도가 높은 화면은 후속 단계에서 세부 톤을 더 정교하게 맞추도록 todo 기준도 갱신함.
## 2026-04-01 v1.3.31
- 관리자 게임 관리의 오른쪽 사이드 게임 선택 리스트는 더 많은 항목을 한 번에 볼 수 있도록 최대 높이를 늘리고, 게임 카드 내부 간격도 사용자가 조정한 CSS 기준으로 반영해 목록 밀도를 다시 다듬음.
## 2026-04-01 v1.3.30
- 헤더의 `Tier Maker` 로고는 레인보우 그라데이션 텍스트로 바꿔 서비스 첫인상이 더 또렷하게 보이도록 정리하고, `by zenn`은 새 창으로 프로필 페이지를 여는 외부 링크로 연결함.
- 다음 단계 작업용으로 라이트모드/다크모드 전환 항목을 todo 문서에 추가해, 현재의 다크 톤 UI를 유지하면서도 이후 테마 확장 흐름을 공식 작업 목록에 올림.
## 2026-04-01 v1.3.29
- 책 아이콘 사용법 모달 진입점은 항상 보이는 오른쪽 사이드 하단 버튼 대신, Settings 화면에서만 왼쪽 사이드 하단의 보조 액션 버튼으로 옮겨 더 필요할 때만 찾게 되는 문맥형 진입 방식으로 정리함.
- 인증 스토어에 초기 세션 동기화 완료 상태를 추가하고, 앱 셸·로그인 화면·프로필 화면은 세션 확인 전까지 비로그인 UI를 먼저 그리지 않도록 보강해 첫 진입 시 화면이 갑자기 로그인 상태로 뒤집히는 플래시를 줄임.
## 2026-04-01 v1.3.28
- 책 아이콘 기반 사용법 모달은 기존의 단순 제작 흐름 안내를 넘어, 다른 사람 티어표 복사, 템플릿 업그레이드 요청, 새 템플릿 추가 요청, 즐겨찾기/내 티어표 관리까지 포함한 전체 기능 안내 허브로 확장함.
- 사용법 모달 제목과 단계 표기를 더 넓은 개념의 `기능 안내` 기준으로 정리하고, 실제 스크린샷이 없어도 설명만으로 핵심 기능을 순서대로 이해할 수 있게 단계 문구를 전면 보강함.
## 2026-04-01 v1.3.27
- 오른쪽 사이드 하단에 책 아이콘 진입점을 추가하고, 중앙 대형 사용법 모달을 열어 좌측 기능 리스트와 우측 16:9 설명 영역, 좌우 이동, 하단 페이지네이션까지 포함한 기본 가이드 흐름을 붙임.
- 사용법 모달의 스크린샷 영역은 우선 16:9 플레이스홀더와 설명 텍스트만 배치해, 실제 이미지 자산은 나중에 채워 넣을 수 있게 구조를 먼저 준비함.
## 2026-04-01 v1.3.26
- 오른쪽 사이드는 실제 광고 슬롯 기준을 300x600 세로 비율로 잡고, 데스크톱 우측 레일 폭도 325px로 조정해 300px 광고가 내부 패딩과 보더를 제외한 실폭 안에 자연스럽게 들어가도록 보정함.
## 2026-04-01 v1.3.25
- todo 문서에서는 운영 정책/배포 체크 성격 항목을 우선 제거하고, 제품/보안 후속 작업 중심으로 다시 정리함.
- 관리자 게임 관리는 우측 셀렉트 박스 대신 검색 가능한 리스트와 최신순/오래된순 정렬로 바꿔, 게임 수가 많아져도 실제로 선택 가능한 구조로 개선함.
- 로그인과 회원가입은 기존 세션을 그대로 덮어쓰지 않고 세션을 재생성한 뒤 사용자 정보를 저장하도록 바꿔, 세션 고정 공격 방어를 보강함.
## 2026-04-01 v1.3.24
- 게임 선택 후 보이는 공개 티어표 목록 그리드도 auto-fit 최대폭 방식 대신 4/3/2/1열 고정 반응형 규칙으로 바꿔, 넓은 화면에서 카드 한 장이 애매하게 다음 줄로 넘어가며 공백이 크게 남던 문제를 줄임.
## 2026-04-01 v1.3.23
- 내 티어표 목록 그리드는 auto-fit 최대폭 방식 대신 게임 목록과 같은 4/3/2/1열 고정 반응형 규칙으로 맞춰, 넓은 화면에서 카드 한 장이 애매하게 다음 줄로 떨어지며 여백이 크게 남던 문제를 줄임.
## 2026-04-01 v1.3.22
- 내 티어표 카드는 게임 목록과 같은 상단 히어로/패널 문법으로 다시 맞추고, 깨진 썸네일은 alt 텍스트가 카드 폭을 밀지 않도록 플레이스홀더로 즉시 대체해 카드 수와 헤더 폭이 흔들리지 않게 보정함.
- 오른쪽 사이드 광고 프레임은 별도 보더·패딩·배경을 제거해, 광고 자체가 가진 각진 형태와 색이 그대로 보이도록 더 담백하게 정리함.
## 2026-04-01 v1.3.21
- 내 티어표 카드는 게임 목록 화면과 같은 카드 폭/헤더/메타 배치 문법으로 맞춰, 화면 간 카드 크기와 정보 정렬이 더 통일된 인상으로 보이도록 정리함.
## 2026-04-01 v1.3.20
- 내 티어표 카드 그리드는 카드 최대폭 우선 규칙 대신 더 촘촘한 auto-fill 기준으로 조정해, 넓은 화면에서도 한 줄에 더 많은 카드가 자연스럽게 배치되도록 보정함.
## 2026-04-01 v1.3.19
- 관리자 Image Optimization 기간 선택은 연도/월을 가로로 나란히 두고, 연도를 고르기 전에는 월 셀렉트를 숨겨 비어 있는 박스처럼 보이던 상태를 없앰.
- 전체 초기화 버튼도 실제 월이 선택된 경우에만 보이도록 정리해, 사이드바 상단 필터 줄이 더 단정하게 보이도록 보정함.
## 2026-04-01 v1.3.18
- 커스텀 아이템 기본 이름은 파일명 전체를 그대로 쓰지 않고 확장자 제거·공백 정리·60자 제한을 먼저 적용하도록 바꿔, 템플릿 요청 전에 커스텀 업로드가 길이 제한으로 실패하던 흐름을 줄임.
- 템플릿 요청 실패 안내는 커스텀 이미지 업로드 실패와 일반 bad request를 구분해, 사용자가 제목/설명/아이템 이름 길이 제한 문제를 더 쉽게 파악할 수 있게 보강함.
- 관리자 Image Optimization 월 필터는 기본 month input 대신 연도/월 셀렉트와 전체 초기화 버튼으로 바꿔, 기간 선택을 더 직관적으로 조작할 수 있게 정리함.
## 2026-04-01 v1.3.17
- 티어 에디터 열 헤더 입력창과 행 라벨은 좌우 패딩을 대칭으로 다시 잡아, 드래그 핸들과 삭제 아이콘이 있어도 제목이 한쪽으로 쏠려 보이지 않도록 보정함.
- 열 삭제도 이제 행 삭제와 같은 확인 모달을 거쳐 진행되도록 바꿔, 실수로 즉시 제거되던 문제를 막음.
- 내보내기 보드는 여전히 960px 고정 폭이라 열 수가 늘수록 각 칸 폭이 줄어드는 구조라는 점을 기준으로 정리했고, 현재 보정은 헤더 정렬 문제를 우선 해결하는 쪽에 맞춤.
## 2026-04-01 v1.3.16
- 티어 에디터의 행 삭제와 열 삭제는 다시 작은 X 아이콘 액션으로 정리해, 행/열 이름 주변의 반복 텍스트 때문에 보드가 답답해 보이던 문제를 줄임.
- 열 헤더 편집 영역은 입력창 오른쪽에 아이콘 삭제만 남기고, 행 라벨도 상단 우측의 작은 제거 버튼으로 맞춰 더 압축된 편집 밀도를 유지하도록 조정함.
- 저장 이미지에서 열 제목이 살짝 위로 떠 보이던 문제는 내보내기 헤더의 비대칭 패딩을 제거하고 flex 중앙 정렬로 바꿔, 시각적으로 정확한 중앙에 오도록 보정함.
## 2026-04-01 v1.3.15
- 티어 에디터의 열 이름은 각 행 안에서 반복 렌더링되지 않도록 공통 상단 헤더로 분리해, 행 제목과 같은 구조로 더 또렷하게 구분되도록 수정함.
- 행 추가/열 추가 액션은 새 SVG 아이콘 버튼으로 압축해, 텍스트 때문에 보드 상단 툴바 높이가 과하게 커지던 문제를 정리함.
- 미리보기와 삭제 모달 문구도 행/열 기준으로 함께 정리해, 전체 티어 에디터 흐름을 더 일관된 용어와 레이아웃으로 다듬음.
## 2026-04-01 v1.3.14
- 티어 에디터를 단일 세로 랭크형에서 행/열 혼합 보드로 확장해, 공격·방어·지원 같은 가로 열을 추가하고 각 열 이름도 직접 입력할 수 있게 함.
- 에디터 액션 문구를 `행 추가 / 열 추가` 기준으로 정리하고, 행 라벨 폭과 드래그 아이콘 위치를 다듬어 실제 사용 빈도에 맞는 더 압축된 보드 레이아웃으로 보정함.
- 이름 오버레이 정렬과 저장용 미리보기 보드도 함께 손봐서, 이미지 다운로드 시 라벨 텍스트가 하단 중앙에 더 안정적으로 배치되도록 수정함.
## 2026-04-01 v1.3.13
- 템플릿 등록/업데이트 요청 모달은 이제 현재 티어표 제목·설명을 기본값으로 가져오고, 비어 있더라도 모달 안에서 바로 작성해 요청할 수 있도록 흐름을 단순화함.
- 템플릿 요청 시 `내 티어 리스트에도 저장` 토글을 추가해, 요청 스냅샷만 관리자에게 전달할지 아니면 현재 양식도 내 티어표로 함께 저장할지 분리함.
- 관리자 템플릿 요청 관리는 더 이상 원본 티어표 링크에 의존하지 않고, 요청 시점의 그룹/아이템/이름표시 상태를 그대로 담은 스냅샷 미리보기를 직접 열어 확인할 수 있게 확장함.
## 2026-04-01 v1.3.12
- 관리자 회원 관리 상단에 정렬 방향 선택을 추가해, 최근 활동순·가입순·작성 티어표순을 각각 오름차순/내림차순으로 다시 볼 수 있게 확장함.
- 회원 정보 수정, 새 게임 생성, 비밀번호 초기화 모달은 Settings 톤 입력 스타일을 유지하면서 각 입력칸에 글자 수 피드백을 함께 보여주도록 정리함.
- 로그인, 설정, 티어 에디터 제목·설명·요청 제목·요청 설명·티어 행 이름에도 최대 길이와 현재 입력 길이 안내를 붙여, 제출 전에 제한을 바로 인지할 수 있게 개선함.
## 2026-04-01 v1.3.11
- **회원 관리 편집 모달 전환**: 관리자 회원 카드를 읽기 전용 정보 카드로 바꾸고, `회원 정보 수정` 버튼으로 Settings 톤의 편집 모달에서 이메일/닉네임/운영자 권한을 저장하도록 재구성
- **회원 검색/정렬 추가**: 회원 관리 상단에 이메일/닉네임 검색과 `최근 활동순`, `가입순`, `작성 티어표 많은 순` 정렬을 추가해 운영자가 원하는 기준으로 목록을 다시 볼 수 있도록 확장
- **최고 관리자 보호 도입**: 가장 먼저 생성된 관리자 계정을 `최고 관리자`로 구분하고, 운영자는 최고 관리자 권한/아바타/비밀번호/삭제를 변경할 수 없도록 백엔드 보호 로직과 역할 메타데이터를 추가
## 2026-04-01 v1.3.10
- 게임 허브 공개 티어표 카드 그리드는 최소/최대 폭을 고정해, 목록이 1~2장뿐일 때도 카드가 화면 전체를 먹으며 과하게 커지지 않도록 보정함.
- 티어표 행 삭제는 상단 아이콘 대신 우측 하단의 작은 텍스트 액션으로 바꿔, 랭크 카드 안에서 더 조용하고 정돈된 편집 흐름으로 정리함.
- 공통 `SvgIcon` 컴포넌트를 추가하고 앱 셸, 홈 즐겨찾기, 관리자 회원 액션 같은 UI 아이콘은 `img` 대신 SVG 아이콘 컴포넌트로 렌더링하도록 전환함.
## 2026-04-01 v1.3.9
- 관리자 오른쪽 사이드의 Image Optimization 패널은 이제 기본 탭인 목록 관리에서만 노출되도록 줄여, 게임/아이템/티어표/회원 관리 화면에서는 실제 작업 패널에 더 집중할 수 있게 정리함.
- 커스텀 아이템 상세의 '이미 사용 중인 게임' 목록에서는 개인 보드용 freeform 템플릿을 제외하고, 실제 템플릿에 연결된 게임만 보이도록 다듬음.
- 티어표 행 삭제는 큰 버튼 대신 우측 상단의 작은 x 아이콘으로 바꾸고, 삭제 시 아이템이 풀 영역으로 돌아간다는 안내를 포함한 확인 모달을 거친 뒤 삭제되도록 개선함.
## 2026-03-31 v1.3.8
- 홈 화면 게임 즐겨찾기 버튼은 일반 문자 별 대신 'kid_star.svg' 아이콘을 사용하도록 바꿔, 기존 아이콘 시스템과 같은 문법으로 정리함.
- 실제로 더 이상 참조되지 않는 예전 업로드 파일을 정리하는 레거시 업로드 클린업 스크립트를 추가하고, 루트/백엔드 실행 스크립트도 함께 연결함.
- todo 문서도 이제 운영 반영 후 레거시 파일 정리 배치를 주기화하는 쪽으로 기준을 갱신함.
## 2026-03-31 v1.3.7
- 현재 참조 중인 레거시 업로드를 다시 최적화 자산 경로로 편입하고 DB 참조를 일괄 교체하는 1회 마이그레이션 스크립트를 추가함.
- 아바타/썸네일/아이템 역할에 따라 기존 업로드를 512px 또는 1280px 규격으로 다시 정리해, 실제 참조 경로도 '/uploads/assets/' 체계에 점진적으로 수렴시킬 수 있게 함.
- 루트와 백엔드에 레거시 마이그레이션 실행 스크립트를 연결하고, todo 문서도 다음 단계 기준으로 갱신함.
## 2026-03-31 v1.3.6
- 현재 참조 중인 레거시 업로드 파일을 'image_assets' 메타에 안전하게 편입하는 1회 백필 스크립트를 추가해, 과거 이미지도 최적화 대시보드와 같은 통계 체계 안에서 집계할 수 있게 함.
- 루트와 백엔드에 백필 실행 스크립트를 연결해 운영 중 필요할 때 즉시 재실행할 수 있도록 정리함.
- todo 문서의 즉시 확인 항목도 백필 완료 상태에 맞춰 후속 마이그레이션 과제로 갱신함.
## 2026-03-31 v1.3.5
- 관리자 이미지 최적화 대시보드는 이제 'image_assets'만이 아니라 현재 실제로 참조 중인 업로드 파일 전체를 합산해, 기존 레거시 업로드까지 포함한 실사용 용량을 함께 보여주도록 확장함.
- 최근 최적화 작업은 기본 12건으로 늘리고 6/12/24건 선택과 월 단위 필터를 지원해, 특정 기간 사용량과 최적화 이력을 운영 관점에서 바로 확인할 수 있게 정리함.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M160-760v560h240v-560H160ZM80-120v-720h720v160h-80v-80H480v560h240v-80h80v160H80Zm400-360Zm-80 0h80-80Zm0 0Zm320 120v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 283 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M200-560h560v-240H200v240Zm-80 400v-720h720v720H680v-80h80v-240H200v240h80v80H120Zm360-320Zm0-80v80-80Zm0 0ZM440-80v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 282 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="m305-704 112-145q12-16 28.5-23.5T480-880q18 0 34.5 7.5T543-849l112 145 170 57q26 8 41 29.5t15 47.5q0 12-3.5 24T866-523L756-367l4 164q1 35-23 59t-56 24q-2 0-22-3l-179-50-179 50q-5 2-11 2.5t-11 .5q-32 0-56-24t-23-59l4-165L95-523q-8-11-11.5-23T80-570q0-25 14.5-46.5T135-647l170-57Zm49 69-194 64 124 179-4 191 200-55 200 56-4-192 124-177-194-66-126-165-126 165Zm126 135Z"/></svg>

After

Width:  |  Height:  |  Size: 490 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M560-564v-68q33-14 67.5-21t72.5-7q26 0 51 4t49 10v64q-24-9-48.5-13.5T700-600q-38 0-73 9.5T560-564Zm0 220v-68q33-14 67.5-21t72.5-7q26 0 51 4t49 10v64q-24-9-48.5-13.5T700-380q-38 0-73 9t-67 27Zm0-110v-68q33-14 67.5-21t72.5-7q26 0 51 4t49 10v64q-24-9-48.5-13.5T700-490q-38 0-73 9.5T560-454ZM260-320q47 0 91.5 10.5T440-278v-394q-41-24-87-36t-93-12q-36 0-71.5 7T120-692v396q35-12 69.5-18t70.5-6Zm260 42q44-21 88.5-31.5T700-320q36 0 70.5 6t69.5 18v-396q-33-14-68.5-21t-71.5-7q-47 0-93 12t-87 36v394Zm-40 118q-48-38-104-59t-116-21q-42 0-82.5 11T100-198q-21 11-40.5-1T40-234v-482q0-11 5.5-21T62-752q46-24 96-36t102-12q58 0 113.5 15T480-740q51-30 106.5-45T700-800q52 0 102 12t96 36q11 5 16.5 15t5.5 21v482q0 23-19.5 35t-40.5 1q-37-20-77.5-31T700-240q-60 0-116 21t-104 59ZM280-494Z"/></svg>

After

Width:  |  Height:  |  Size: 895 B

View File

@@ -77,15 +77,16 @@ onMounted(async () => {
}
.rightRailAd__frame {
min-height: 520px;
padding: 14px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.02);
width: min(100%, 300px);
min-height: 600px;
margin: 0 auto;
}
.rightRailAd__slot {
width: 100%;
min-height: 490px;
display: block;
width: 300px;
max-width: 100%;
min-height: 600px;
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,38 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
src: { type: String, required: true },
size: { type: [Number, String], default: 20 },
color: { type: String, default: 'currentColor' },
})
const normalizedSize = computed(() => (typeof props.size === "number" ? `${props.size}px` : props.size))
const iconStyle = computed(() => ({
"--svg-icon-src": `url("${props.src}")`,
"--svg-icon-size": normalizedSize.value,
"--svg-icon-color": props.color,
}))
</script>
<template>
<span class="svgIcon" :style="iconStyle" aria-hidden="true"></span>
</template>
<style scoped>
.svgIcon {
display: inline-block;
width: var(--svg-icon-size);
height: var(--svg-icon-size);
background-color: var(--svg-icon-color);
-webkit-mask-image: var(--svg-icon-src);
mask-image: var(--svg-icon-src);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-size: contain;
mask-size: contain;
flex: 0 0 auto;
}
</style>

View File

@@ -0,0 +1,64 @@
<script setup>
const props = defineProps({
featuredGames: { type: Array, required: true },
availableGamesForFeatured: { type: Array, required: true },
featuredGameIds: { type: Array, required: true },
featuredListRef: { type: Function, required: true },
saveFeaturedOrder: { type: Function, required: true },
moveFeaturedGame: { type: Function, required: true },
removeFeaturedGame: { type: Function, required: true },
addFeaturedGame: { type: Function, required: true },
})
</script>
<template>
<div class="panel">
<div class="sectionHeader">
<div>
<div class="panel__title"> 화면 상단 고정 순서</div>
<div class="hint hint--tight">여기에 넣은 게임은 지정한 순서대로 먼저 노출되고, 나머지 게임은 최근 생성순으로 뒤에 이어집니다. 최대 50개까지 설정할 있어요.</div>
</div>
<button class="btn btn--primary" @click="props.saveFeaturedOrder">순서 저장</button>
</div>
<div class="featuredOrderPanel">
<div class="featuredOrderPanel__list">
<div class="section__title">상단 고정 목록</div>
<div v-if="!props.featuredGames.length" class="hint">아직 상단 고정 게임이 없어요.</div>
<div v-else :ref="props.featuredListRef" class="featuredList">
<article v-for="(game, index) in props.featuredGames" :key="game.id" class="featuredCard" :data-featured-id="game.id">
<div class="featuredCard__meta">
<span class="featuredCard__rank">{{ index + 1 }}</span>
<div>
<div class="featuredCard__title">{{ game.name }}</div>
<div class="featuredCard__id">{{ game.id }}</div>
</div>
</div>
<div class="featuredCard__actions">
<button class="btn btn--ghost btn--small" data-featured-handle>드래그</button>
<button class="btn btn--ghost btn--small" :disabled="index === 0" @click="props.moveFeaturedGame(game.id, -1)">위로</button>
<button class="btn btn--ghost btn--small" :disabled="index === props.featuredGames.length - 1" @click="props.moveFeaturedGame(game.id, 1)">아래로</button>
<button class="btn btn--danger btn--small" @click="props.removeFeaturedGame(game.id)">제외</button>
</div>
</article>
</div>
</div>
<div class="featuredOrderPanel__picker">
<div class="section__title">게임 추가</div>
<div class="featuredPickerList">
<button
v-for="game in props.availableGamesForFeatured"
:key="game.id"
class="featuredPickerItem"
:disabled="props.featuredGameIds.length >= 50"
@click="props.addFeaturedGame(game.id)"
>
<span>{{ game.name }}</span>
<span class="featuredPickerItem__id">{{ game.id }}</span>
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,191 @@
<script setup>
import { toApiUrl } from '../../lib/runtime'
const props = defineProps({
activeTemplateRequest: { type: Object, default: null },
templateRequestSourceUrl: { type: Function, required: true },
stagedRequestDraftCount: { type: Number, required: true },
appliedRequestItemCount: { type: Number, required: true },
openGameCreateModal: { type: Function, required: true },
isGameLoading: { type: Boolean, required: true },
hasSelectedGame: { type: Boolean, required: true },
selectedGame: { type: Object, default: null },
itemFileInputRef: { type: Function, required: true },
onFile: { type: Function, required: true },
isItemDragOver: { type: Boolean, required: true },
onItemDragEnter: { type: Function, required: true },
onItemDragOver: { type: Function, required: true },
onItemDragLeave: { type: Function, required: true },
onItemDrop: { type: Function, required: true },
openItemFilePicker: { type: Function, required: true },
uploadItemDrafts: { type: Array, required: true },
clearItemFiles: { type: Function, required: true },
canAddItem: { type: Boolean, required: true },
uploadItem: { type: Function, required: true },
removeUploadDraft: { type: Function, required: true },
hasGameItemOrderChanges: { type: Boolean, required: true },
saveGameItemOrder: { type: Function, required: true },
gameItemListRef: { type: Function, required: true },
saveGameItemLabel: { type: Function, required: true },
removeGameItem: { type: Function, required: true },
selectedGameId: { type: String, default: '' },
})
function setGameItemListElement(el) {
props.gameItemListRef(el)
}
</script>
<template>
<div v-if="props.activeTemplateRequest" class="panel requestWorkspace">
<div class="requestWorkspace__head">
<div>
<div class="panel__title">진행 중인 요청 작업</div>
<div class="requestWorkspace__title">{{ props.activeTemplateRequest.sourceTierListTitle || '템플릿 요청' }}</div>
<div class="hint hint--tight">
{{
props.activeTemplateRequest.type === 'create'
? (props.activeTemplateRequest.targetGameId
? '이미 연결된 신규 템플릿이 있어요. 필요한 아이템만 골라 저장하세요.'
: '새 게임을 한 번 만든 뒤 필요한 아이템만 골라 저장하세요.')
: '필요한 아이템만 남긴 뒤 기본 아이템 추가 버튼으로 반영하세요.'
}}
</div>
</div>
<div class="requestWorkspace__stats">
<span class="pill pill--accent">{{ props.activeTemplateRequest.type === 'create' ? '신규 게임 요청' : '기존 게임 업데이트' }}</span>
<span class="pill">요청 아이템 {{ props.stagedRequestDraftCount }}</span>
<span v-if="props.appliedRequestItemCount" class="pill pill--soft">이미 반영 {{ props.appliedRequestItemCount }}</span>
<span v-if="props.activeTemplateRequest.type === 'create' && (props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId)" class="pill pill--soft">
연결된 게임 · {{ props.activeTemplateRequest.targetGameName || props.activeTemplateRequest.targetGameId }}
</span>
</div>
</div>
<div class="requestWorkspace__actions">
<a
v-if="props.templateRequestSourceUrl(props.activeTemplateRequest)"
class="btn btn--ghost btn--small"
:href="props.templateRequestSourceUrl(props.activeTemplateRequest)"
target="_blank"
rel="noreferrer"
>
요청 티어표 보기
</a>
<button
v-if="props.activeTemplateRequest.type === 'create' && !props.activeTemplateRequest.targetGameId"
class="btn btn--ghost btn--small"
type="button"
@click="props.openGameCreateModal"
>
게임 만들기
</button>
</div>
</div>
<div v-if="props.isGameLoading" class="panel panel--empty">
<div class="emptyState">
<div class="emptyState__title">게임 정보를 불러오는 중이에요.</div>
<div class="emptyState__desc">선택한 게임의 썸네일과 기본 아이템을 표시합니다.</div>
</div>
</div>
<div v-else-if="props.hasSelectedGame" class="panel">
<div class="detailHead">
<div>
<div class="panel__title">선택된 게임 정보</div>
<div class="selectedGame__name">{{ props.selectedGame.game.name }}</div>
<div class="selectedGame__id">{{ props.selectedGame.game.id }}</div>
</div>
</div>
<div class="section">
<section class="adminCard">
<div class="section__title">기본 아이템 추가</div>
<div class="itemComposer">
<div class="itemComposer__form">
<input :ref="props.itemFileInputRef" type="file" accept="image/*" multiple class="srOnlyInput" @change="props.onFile" />
<div
class="dropZone"
:class="{ 'dropZone--active': props.isItemDragOver }"
@click="props.openItemFilePicker"
@dragenter="props.onItemDragEnter"
@dragover="props.onItemDragOver"
@dragleave="props.onItemDragLeave"
@drop="props.onItemDrop"
>
<div class="dropZone__title">이미지를 드래그해서 기본 아이템으로 추가</div>
<div class="dropZone__desc">
여러 파일을 번에 올릴 있고, 저장 라벨은 파일명으로 자동 생성됩니다.
<span v-if="props.stagedRequestDraftCount"> 현재 요청에서 가져온 아이템 {{ props.stagedRequestDraftCount }}개도 함께 검토 중이에요.</span>
</div>
<div class="dropZone__actions">
<button class="btn btn--ghost btn--small" type="button" @click.stop="props.openItemFilePicker">파일 선택</button>
</div>
</div>
</div>
<div class="itemPreviewCard">
<div v-if="props.uploadItemDrafts.length" class="itemDraftList">
<div
v-for="draft in props.uploadItemDrafts"
:key="draft.kind + ':' + (draft.itemId || draft.file?.name || draft.previewUrl)"
class="itemDraftRow"
>
<div class="itemDraftRow__preview">
<img class="itemPreviewImage" :src="draft.previewUrl" :alt="draft.sourceName || 'item preview'" />
</div>
<div class="itemDraftRow__body">
<input v-model="draft.label" class="input input--labelEdit input--dense" maxlength="60" placeholder="아이템 이름" />
<div class="hint hint--tight">{{ draft.sourceName }}</div>
<div class="itemDraftRow__meta">
<span class="pill" :class="draft.kind === 'request' ? 'pill--requestItem' : 'pill--directFile'">
{{ draft.kind === 'request' ? '요청 아이템' : '직접 추가 파일' }}
</span>
<button class="btn btn--danger btn--small" type="button" @click="props.removeUploadDraft(draft)">제외</button>
</div>
</div>
</div>
</div>
<div v-else class="itemPreviewEmpty">등록한 기본 아이템 미리보기가 여기에 표시됩니다.</div>
<button class="btn itemPreviewCard__submit" :disabled="!props.canAddItem" @click="props.uploadItem">
{{ props.uploadItemDrafts.length ? `아이템 ${props.uploadItemDrafts.length} 추가` : '아이템 추가' }}
</button>
</div>
</div>
</section>
</div>
<div class="section">
<div class="sectionHeader">
<div>
<div class="section__title">현재 기본 아이템 목록</div>
<div class="hint hint--tight">드래그해서 기본 노출 순서를 바꿀 있어요. 저장 전까지는 화면에서만 순서가 바뀝니다.</div>
</div>
<button class="btn btn--primary btn--small" :disabled="!props.hasGameItemOrderChanges" @click="props.saveGameItemOrder">순서 저장</button>
</div>
<div v-if="!props.selectedGame?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
<div v-else :ref="setGameItemListElement" class="thumbGrid">
<div v-for="item in props.selectedGame.items" :key="item.id" class="thumbCard" :data-game-item-id="item.id">
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />
<div class="thumbCard__actions">
<button
class="btn btn--ghost btn--small"
data-no-drag
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label"
@click="props.saveGameItemLabel(item)"
>
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
</button>
<button class="btn btn--danger btn--small" data-no-drag @click="props.removeGameItem(item.id)">아이템 삭제</button>
</div>
</div>
</div>
</div>
</div>
<div v-else class="panel panel--empty">
<div class="emptyState">
<div class="emptyState__title">게임을 선택해 주세요.</div>
<div v-if="props.activeTemplateRequest?.type === 'create'" class="hint hint--tight">진행 중인 신규 게임 요청이 있어요. 위의 ` 게임 만들기` 게임을 만든 아이템을 추가할 있습니다.</div>
<div v-if="props.selectedGameId" class="hint hint--tight">선택한 게임을 찾지 못했거나 로딩 오류가 발생했어요. 다시 선택해보세요.</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script setup>
import { toApiUrl } from '../../lib/runtime'
const props = defineProps({
customItems: { type: Array, required: true },
openCustomItemModal: { type: Function, required: true },
customItemPage: { type: Number, required: true },
customItemPageCount: { type: Number, required: true },
customItemTotal: { type: Number, required: true },
moveCustomItemPage: { type: Function, required: true },
})
</script>
<template>
<div class="panel">
<div v-if="!props.customItems.length" class="hint">조건에 맞는 관리 대상 아이템이 없어요.</div>
<div v-else class="customItemGrid">
<button v-for="item in props.customItems" :key="item.id" type="button" class="customItemCard" @click="props.openCustomItemModal(item)">
<span class="customItemCard__badge" :class="{ 'customItemCard__badge--template': item.sourceType === 'template' }">{{ item.sourceLabel }}</span>
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
</button>
</div>
<div class="pager">
<button class="btn btn--ghost" :disabled="props.customItemPage <= 1" @click="props.moveCustomItemPage(-1)">이전</button>
<div class="pager__info">{{ props.customItemPage }} / {{ props.customItemPageCount }} 페이지 · {{ props.customItemTotal }}</div>
<button class="btn btn--ghost" :disabled="props.customItemPage >= props.customItemPageCount" @click="props.moveCustomItemPage(1)">다음</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,184 @@
<script setup>
import { toApiUrl } from '../../lib/runtime'
const props = defineProps({
tierlistsMode: { type: String, required: true },
templateRequests: { type: Array, required: true },
openTemplateRequestPreview: { type: Function, required: true },
fmt: { type: Function, required: true },
templateRequestTargetLabel: { type: Function, required: true },
templateRequestStatusLabel: { type: Function, required: true },
templateRequestSourceUrl: { type: Function, required: true },
startTemplateRequestReview: { type: Function, required: true },
completeTemplateRequest: { type: Function, required: true },
adminTierLists: { type: Array, required: true },
tierListThumbUrl: { type: Function, required: true },
openAdminTierList: { type: Function, required: true },
tierListAuthorDisplayName: { type: Function, required: true },
tierListVisibilityLabel: { type: Function, required: true },
openTierListExtraItemModal: { type: Function, required: true },
openTierListImportModal: { type: Function, required: true },
adminTierListPage: { type: Number, required: true },
adminTierListPageCount: { type: Number, required: true },
adminTierListTotal: { type: Number, required: true },
moveAdminTierListPage: { type: Function, required: true },
})
</script>
<template>
<div v-if="props.tierlistsMode === 'requests'" class="panel">
<div class="sectionHeader">
<div>
<div class="panel__title">사용자 요청</div>
</div>
</div>
<div v-if="!props.templateRequests.length" class="hint">현재 처리 대기 중인 템플릿 요청이 없어요.</div>
<div v-else class="templateRequestList">
<article v-for="request in props.templateRequests" :key="request.id" class="tierAdminCard templateRequestCard templateRequestCard--aligned">
<div class="templateRequestCard__side">
<button class="tierAdminCard__preview templateRequestCard__preview" type="button" @click="props.openTemplateRequestPreview(request)">
<img v-if="request.thumbnailSrc" class="tierAdminCard__thumb" :src="toApiUrl(request.thumbnailSrc)" :alt="request.sourceTierListTitle" />
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
</button>
<div class="templateRequestCard__thumbMeta">
<template v-if="request.type === 'create'">
<label class="templateRequestField">
<span class="templateRequestField__label">게임 이름</span>
<input v-model="request.draftGameName" class="input" placeholder="새 게임 이름" />
</label>
<label class="templateRequestField">
<span class="templateRequestField__label">게임 ID</span>
<input v-model="request.draftGameId" class="input" placeholder="임시 게임 ID" />
</label>
</template>
<template v-else>
<div class="templateRequestCard__thumbLabel">게임 이름</div>
<div class="templateRequestCard__thumbValue">{{ request.draftGameName || request.sourceGameName || '-' }}</div>
<div class="templateRequestCard__thumbLabel">게임 ID</div>
<div class="templateRequestCard__thumbValue">{{ request.draftGameId || request.sourceGameId || '-' }}</div>
</template>
</div>
</div>
<div class="tierAdminCard__body">
<div class="tierAdminCard__head">
<div>
<span
class="pill templateRequestCard__cornerBadge"
:class="request.type === 'create' ? 'pill--create' : 'pill--owned'"
>
{{ request.type === 'create' ? '신규 템플릿' : '보유 템플릿' }}
</span>
<div class="tierAdminCard__title">{{ request.sourceTierListTitle }}</div>
<div v-if="request.sourceDescription" class="tierAdminCard__desc">{{ request.sourceDescription }}</div>
<div class="tierAdminCard__meta">
{{ request.requesterName }} · {{ props.fmt(request.createdAt) }}
</div>
<div class="tierAdminCard__meta">{{ props.templateRequestTargetLabel(request) }}</div>
</div>
</div>
<div class="tierAdminCard__stats">
<span class="pill">추가 아이템 {{ request.items?.length || 0 }}</span>
<span v-if="request.type === 'create' && (request.targetGameName || request.targetGameId)" class="pill pill--soft">
연결됨 · {{ request.targetGameName || request.targetGameId }}
</span>
<span class="pill" :class="{ 'pill--accent': request.status === 'reviewing' }">{{ props.templateRequestStatusLabel(request) }}</span>
</div>
<div v-if="request.items?.length" class="tierAdminItemList templateRequestCard__items">
<button v-for="item in request.items" :key="item.id" class="tierAdminItem" type="button">
<img class="tierAdminItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="tierAdminItem__title">{{ item.label }}</div>
</button>
</div>
<div class="templateRequestCard__footer">
<div class="templateRequestCard__footerLeft">
<a
v-if="props.templateRequestSourceUrl(request)"
class="btn btn--ghost btn--small"
:href="props.templateRequestSourceUrl(request)"
target="_blank"
rel="noreferrer"
>
요청 티어표 보기
</a>
</div>
<div class="templateRequestCard__actions">
<button class="btn btn--primary" :disabled="request.isHandling" @click="props.startTemplateRequestReview(request)">
{{
request.isHandling
? '이동중...'
: request.type === 'create' && (request.targetGameName || request.targetGameId)
? '연결된 게임 열기'
: '확인하기'
}}
</button>
<button class="btn btn--ghost" :disabled="request.isHandling || request.status !== 'reviewing'" @click="props.completeTemplateRequest(request)">처리 완료</button>
</div>
</div>
</div>
</article>
</div>
</div>
<div v-else class="panel">
<div class="sectionHeader">
<div>
<div class="panel__title">전체 티어표 관리</div>
</div>
</div>
<div v-if="!props.adminTierLists.length" class="hint">조건에 맞는 티어표가 없어요.</div>
<div v-else class="tierAdminList">
<article v-for="tierList in props.adminTierLists" :key="tierList.id" class="tierAdminCard">
<button class="tierAdminCard__preview" type="button" @click="props.openAdminTierList(tierList)">
<img v-if="props.tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="props.tierListThumbUrl(tierList)" :alt="tierList.title" />
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
</button>
<div class="tierAdminCard__body">
<div class="tierAdminCard__head">
<div>
<div class="tierAdminCard__title">{{ tierList.title }}</div>
<div v-if="tierList.description" class="tierAdminCard__desc">{{ tierList.description }}</div>
<div class="tierAdminCard__meta">
{{ tierList.gameName || tierList.gameId }} · {{ props.tierListAuthorDisplayName(tierList) }} · {{ props.tierListVisibilityLabel(tierList) }}
</div>
<div class="tierAdminCard__meta">{{ props.fmt(tierList.updatedAt) }}</div>
</div>
</div>
<div class="tierAdminCard__stats">
<span class="pill">전체 아이템 {{ tierList.itemCount }}</span>
<span class="pill" :class="{ 'pill--accent': tierList.extraItemCount > 0 }">추가 아이템 {{ tierList.extraItemCount }}</span>
</div>
<div v-if="tierList.extraItems?.length" class="tierAdminSection">
<div class="tierAdminSection__title">추가로 넣은 아이템</div>
<div class="tierAdminItemList">
<button v-for="item in tierList.extraItems" :key="item.id" class="tierAdminItem" @click="props.openTierListExtraItemModal(item, tierList)">
<img class="tierAdminItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="tierAdminItem__title">{{ item.label }}</div>
</button>
</div>
<div class="tierAdminSection__actions">
<button class="btn btn--ghost btn--small" @click="props.openTierListImportModal(tierList, tierList.extraItems)">추가 아이템 전체 가져오기</button>
<button v-if="tierList.gameId === 'freeform'" class="btn btn--primary btn--small" @click="props.openTierListImportModal(tierList, tierList.extraItems)">
템플릿으로 가져오기
</button>
</div>
</div>
</div>
</article>
</div>
<div class="pager">
<button class="btn btn--ghost" :disabled="props.adminTierListPage <= 1" @click="props.moveAdminTierListPage(-1)">이전</button>
<div class="pager__info">{{ props.adminTierListPage }} / {{ props.adminTierListPageCount }} 페이지 · {{ props.adminTierListTotal }}</div>
<button class="btn btn--ghost" :disabled="props.adminTierListPage >= props.adminTierListPageCount" @click="props.moveAdminTierListPage(1)">다음</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,125 @@
<script setup>
import { computed } from 'vue'
import SvgIcon from '../SvgIcon.vue'
const props = defineProps({
userQuery: { type: String, required: true },
userSort: { type: String, required: true },
userSortDirection: { type: String, required: true },
users: { type: Array, required: true },
submitUserFilters: { type: Function, required: true },
setUserAvatarInput: { type: Function, required: true },
onUserAvatarChange: { type: Function, required: true },
openUserAvatarPicker: { type: Function, required: true },
userAvatarUrl: { type: Function, required: true },
userDisplayName: { type: Function, required: true },
userAvatarFallback: { type: Function, required: true },
removeUserAvatar: { type: Function, required: true },
roleLabelOf: { type: Function, required: true },
fmt: { type: Function, required: true },
openUserPasswordModal: { type: Function, required: true },
openUserDeleteModal: { type: Function, required: true },
openUserEditModal: { type: Function, required: true },
lockResetIcon: { type: String, required: true },
deleteIcon: { type: String, required: true },
})
const emit = defineEmits(['update:userQuery', 'update:userSort', 'update:userSortDirection'])
const userQueryModel = computed({
get: () => props.userQuery,
set: (value) => emit('update:userQuery', value),
})
const userSortModel = computed({
get: () => props.userSort,
set: (value) => emit('update:userSort', value),
})
const userSortDirectionModel = computed({
get: () => props.userSortDirection,
set: (value) => emit('update:userSortDirection', value),
})
</script>
<template>
<div class="panel">
<div class="sectionHeader">
<div>
<div class="panel__title">회원 관리</div>
<div class="hint hint--tight">회원 프로필을 정리하고, 필요한 경우에만 권한 변경과 비밀번호 초기화를 진행할 있어요.</div>
</div>
</div>
<div class="toolbar toolbar--secondary">
<input v-model="userQueryModel" class="input toolbar__search" placeholder="이메일, 닉네임 검색" @keydown.enter.prevent="props.submitUserFilters" />
<select v-model="userSortModel" class="select toolbar__select" @change="props.submitUserFilters">
<option value="recent">최근 활동순</option>
<option value="created">가입순</option>
<option value="tierlists">작성 티어표 많은 </option>
</select>
<select v-model="userSortDirectionModel" class="select toolbar__select" @change="props.submitUserFilters">
<option value="desc">내림차순</option>
<option value="asc">오름차순</option>
</select>
<button class="btn btn--ghost toolbar__button" type="button" @click="props.submitUserFilters">조회</button>
</div>
<div v-if="!props.users.length" class="hint">아직 가입한 회원이 없어요.</div>
<div v-else class="userList">
<article v-for="user in props.users" :key="user.id" class="userCard">
<div class="userCard__head">
<div class="userCard__identity">
<input
:ref="(el) => props.setUserAvatarInput(user.id, el)"
type="file"
accept="image/*"
class="srOnlyInput"
@change="props.onUserAvatarChange(user, $event)"
/>
<div class="userAvatarWrap">
<button class="userAvatar userAvatarButton" type="button" :disabled="user.isAvatarBusy" @click="props.openUserAvatarPicker(user)">
<img v-if="props.userAvatarUrl(user)" class="userAvatar__image" :src="props.userAvatarUrl(user)" :alt="props.userDisplayName(user)" />
<span v-else class="userAvatar__fallback">{{ props.userAvatarFallback(user) }}</span>
<span class="userAvatarButton__overlay">{{ user.isAvatarBusy ? '업데이트중...' : '수정' }}</span>
</button>
<button
v-if="user?.avatarSrc"
class="userAvatarRemoveButton"
type="button"
title="회원 썸네일 삭제"
:disabled="user.isAvatarBusy"
@click.stop="props.removeUserAvatar(user)"
>
<SvgIcon class="userAvatarRemoveIcon" :src="props.deleteIcon" :size="12" />
</button>
</div>
<div class="userCard__identityMeta">
<div class="userCard__title">{{ props.userDisplayName(user) }}</div>
<div class="userCard__meta">{{ user.email }}</div>
</div>
</div>
</div>
<div v-if="user.isAdmin" class="roleBadge userCard__roleBadge">{{ props.roleLabelOf(user) }}</div>
<div class="userInfoList">
<div class="userInfoLine"><span>가입일</span><strong>{{ props.fmt(user.createdAt) }}</strong></div>
<div class="userInfoLine"><span>작성 티어표</span><strong>{{ user.tierListCount }}</strong></div>
<div class="userInfoLine"><span>최근 활동</span><strong>{{ props.fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
<div class="userInfoLine"><span>계정명</span><strong>{{ user.email }}</strong></div>
<div class="userInfoLine"><span>닉네임</span><strong>{{ user.nickname || '미설정' }}</strong></div>
<div class="userInfoLine"><span>권한</span><strong>{{ props.roleLabelOf(user) }}</strong></div>
</div>
<div class="userCard__actions userCard__actions--compact">
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="props.openUserPasswordModal(user)">
<SvgIcon class="iconActionButton__icon" :src="props.lockResetIcon" :size="18" />
</button>
<button class="iconActionButton iconActionButton--danger" type="button" title="회원 삭제" @click="props.openUserDeleteModal(user)">
<SvgIcon class="iconActionButton__icon" :src="props.deleteIcon" :size="18" />
</button>
<button class="btn btn--ghost userSaveButton" type="button" @click="props.openUserEditModal(user)">회원 정보 수정</button>
</div>
</article>
</div>
</div>
</template>

View File

@@ -0,0 +1,203 @@
import { nextTick } from 'vue'
export function useAdminCustomItems({
api,
toast,
customItems,
customItemPage,
customItemLimit,
customItemPageCount,
customItemQuery,
customItemOrphanOnly,
customItemModalOpen,
customItemDeleteModalOpen,
customItemModalHistoryActive,
modalTargetCustomItem,
customItemModalDraftLabel,
customItemModalLabelSaving,
customItemModalTargetGameId,
customItemModalGameQuery,
customItemModalGameSort,
games,
selectedGameId,
refreshCustomItems,
loadGame,
setTab,
selectAdminGame,
resetMessages,
success,
error,
}) {
function submitCustomItemSearch() {
customItemPage.value = 1
refreshCustomItems()
}
function toggleCustomItemOrphanOnly() {
customItemPage.value = 1
refreshCustomItems()
}
function changeCustomItemLimit(limit) {
customItemLimit.value = limit
customItemPage.value = 1
refreshCustomItems()
}
function moveCustomItemPage(direction) {
const nextPage = customItemPage.value + direction
if (nextPage < 1 || nextPage > customItemPageCount.value) return
customItemPage.value = nextPage
refreshCustomItems()
}
function pushCustomItemModalHistoryState() {
if (typeof window === 'undefined') return
window.history.pushState({ ...(window.history.state || {}), adminCustomItemModal: true }, '', window.location.href)
customItemModalHistoryActive.value = true
}
function openCustomItemModal(item) {
modalTargetCustomItem.value = item || null
customItemModalDraftLabel.value = item?.label || ''
customItemModalTargetGameId.value = ''
customItemModalGameQuery.value = ''
customItemModalGameSort.value = 'recent'
customItemModalOpen.value = true
pushCustomItemModalHistoryState()
}
function closeCustomItemModal({ fromPopState = false } = {}) {
customItemModalOpen.value = false
customItemDeleteModalOpen.value = false
modalTargetCustomItem.value = null
customItemModalDraftLabel.value = ''
customItemModalLabelSaving.value = false
customItemModalTargetGameId.value = ''
customItemModalGameQuery.value = ''
customItemModalGameSort.value = 'recent'
if (fromPopState) {
customItemModalHistoryActive.value = false
return
}
if (customItemModalHistoryActive.value && typeof window !== 'undefined') {
customItemModalHistoryActive.value = false
window.history.back()
}
}
function openCustomItemDeleteModal(item) {
if (!item) return
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) {
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return
}
modalTargetCustomItem.value = item
customItemDeleteModalOpen.value = true
}
function closeCustomItemDeleteModal() {
customItemDeleteModalOpen.value = false
}
function jumpToGameAdmin(gameId) {
if (!gameId) return
closeCustomItemModal()
setTab('game-admin')
nextTick(() => {
selectAdminGame(gameId)
})
}
async function removeCustomItem(item = modalTargetCustomItem.value) {
resetMessages()
if (!item) return
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) {
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return
}
try {
await api.deleteAdminCustomItem(item.id)
closeCustomItemDeleteModal()
closeCustomItemModal()
await refreshCustomItems()
success.value = item.sourceType === 'template' ? '선택한 템플릿 아이템을 제거했어요.' : '사용자 업로드 이미지를 삭제했어요.'
} catch (e) {
error.value = item.sourceType === 'template' ? '템플릿 아이템 제거에 실패했어요.' : '사용자 업로드 이미지 삭제에 실패했어요.'
}
}
async function removeUnusedCustomItems() {
resetMessages()
const ok = window.confirm('현재 검색 조건에 맞는 미사용 커스텀 이미지를 모두 삭제할까요?')
if (!ok) return
try {
const data = await api.deleteAdminUnusedCustomItems({ q: customItemQuery.value })
await refreshCustomItems()
success.value = `${data.deletedCount || 0}개의 미사용 사용자 업로드 이미지를 삭제했어요.`
} catch (e) {
error.value = '미사용 커스텀 이미지 일괄 삭제에 실패했어요.'
}
}
async function saveCustomItemModalLabel() {
const item = modalTargetCustomItem.value
const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60)
if (!item || !nextLabel || nextLabel === item.label || customItemModalLabelSaving.value) return
try {
customItemModalLabelSaving.value = true
const data = await api.updateAdminCustomItemLabel(item.id, { label: nextLabel, sourceType: item.sourceType })
item.label = data.item?.label || nextLabel
customItemModalDraftLabel.value = item.label
customItems.value = customItems.value.map((entry) => (entry.id === item.id ? { ...entry, label: item.label } : entry))
toast.success('아이템 이름을 변경했어요.')
} catch (e) {
error.value = '아이템 이름 변경에 실패했어요.'
} finally {
customItemModalLabelSaving.value = false
}
}
async function promoteCustomItem(item) {
resetMessages()
if (!customItemModalTargetGameId.value) {
error.value = '추가할 게임을 먼저 선택해주세요.'
return
}
try {
item.isPromoting = true
await api.promoteAdminCustomItem(item.id, { gameId: customItemModalTargetGameId.value })
const targetGameName = games.value.find((game) => game.id === customItemModalTargetGameId.value)?.name || customItemModalTargetGameId.value
if (selectedGameId.value === customItemModalTargetGameId.value) await loadGame()
closeCustomItemModal()
success.value = `"${item.label}" 이미지를 ${targetGameName} 템플릿으로 추가했어요.`
} catch (e) {
error.value = '선택한 이미지를 템플릿으로 추가하지 못했어요.'
} finally {
item.isPromoting = false
}
}
return {
submitCustomItemSearch,
toggleCustomItemOrphanOnly,
changeCustomItemLimit,
moveCustomItemPage,
pushCustomItemModalHistoryState,
openCustomItemModal,
closeCustomItemModal,
openCustomItemDeleteModal,
closeCustomItemDeleteModal,
jumpToGameAdmin,
removeCustomItem,
removeUnusedCustomItems,
saveCustomItemModalLabel,
promoteCustomItem,
}
}

View File

@@ -0,0 +1,93 @@
import { nextTick } from 'vue'
import Sortable from 'sortablejs'
export function useAdminFeaturedGames({
api,
featuredListEl,
featuredSortable,
featuredGameIds,
games,
resetMessages,
success,
error,
}) {
function destroyFeaturedSortable() {
if (featuredSortable.value) {
featuredSortable.value.destroy()
featuredSortable.value = null
}
}
async function syncFeaturedSortable() {
await nextTick()
destroyFeaturedSortable()
if (!featuredListEl.value) return
featuredSortable.value = Sortable.create(featuredListEl.value, {
animation: 160,
draggable: '[data-featured-id]',
handle: '[data-featured-handle]',
ghostClass: 'ghost',
chosenClass: 'chosen',
onEnd: (evt) => {
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
const nextIds = [...featuredGameIds.value]
const [moved] = nextIds.splice(evt.oldIndex, 1)
nextIds.splice(evt.newIndex, 0, moved)
featuredGameIds.value = nextIds
},
})
}
function addFeaturedGame(gameId) {
resetMessages()
if (!gameId || featuredGameIds.value.includes(gameId)) return
if (featuredGameIds.value.length >= 50) {
error.value = '상단 고정 게임은 최대 50개까지만 설정할 수 있어요.'
return
}
featuredGameIds.value = [...featuredGameIds.value, gameId]
syncFeaturedSortable()
}
function removeFeaturedGame(gameId) {
resetMessages()
featuredGameIds.value = featuredGameIds.value.filter((id) => id !== gameId)
syncFeaturedSortable()
}
function moveFeaturedGame(gameId, direction) {
const currentIndex = featuredGameIds.value.indexOf(gameId)
const nextIndex = currentIndex + direction
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= featuredGameIds.value.length) return
const nextIds = [...featuredGameIds.value]
const [moved] = nextIds.splice(currentIndex, 1)
nextIds.splice(nextIndex, 0, moved)
featuredGameIds.value = nextIds
syncFeaturedSortable()
}
async function saveFeaturedOrder() {
resetMessages()
try {
const data = await api.updateAdminGameDisplayOrder({ gameIds: featuredGameIds.value })
games.value = data.games || []
featuredGameIds.value = games.value
.filter((game) => game.displayRank != null)
.sort((a, b) => a.displayRank - b.displayRank)
.map((game) => game.id)
success.value = '홈 화면 게임 순서를 저장했어요.'
} catch (e) {
error.value = '게임 순서 저장에 실패했어요.'
}
}
return {
destroyFeaturedSortable,
syncFeaturedSortable,
addFeaturedGame,
removeFeaturedGame,
moveFeaturedGame,
saveFeaturedOrder,
}
}

View File

@@ -0,0 +1,348 @@
import { nextTick } from 'vue'
import Sortable from 'sortablejs'
export function useAdminGameManager({
api,
toApiUrl,
selectedGameId,
selectedGame,
uploadFiles,
uploadItemDrafts,
thumbFile,
itemPreviewUrls,
itemFileInput,
gameItemListEl,
gameItemSortable,
savedGameItemOrderIds,
isGameLoading,
activeTemplateRequest,
templateRequests,
customItemModalOpen,
customItemModalTargetGameId,
newGameId,
newGameName,
clearPreviewUrl,
resetFileInput,
resetUploadState,
refreshGames,
closeGameCreateModal,
resetMessages,
success,
error,
}) {
function normalizeDraftSrc(src) {
if (typeof src !== 'string') return ''
const raw = src.trim()
if (!raw) return ''
if (raw.startsWith('/uploads/')) return raw
try {
const url = new URL(raw)
return url.pathname || raw
} catch (e) {
return raw
}
}
function requestItemFilename(item = {}) {
const src = typeof item.src === 'string' ? item.src : ''
return src.split('/').pop() || item.file?.name || 'item'
}
function destroyGameItemSortable() {
if (gameItemSortable.value) {
gameItemSortable.value.destroy()
gameItemSortable.value = null
}
}
async function syncGameItemSortable() {
await nextTick()
destroyGameItemSortable()
if (!gameItemListEl.value || !selectedGame.value?.items?.length) return
gameItemSortable.value = Sortable.create(gameItemListEl.value, {
animation: 160,
draggable: '[data-game-item-id]',
forceFallback: true,
fallbackOnBody: false,
filter: '[data-no-drag]',
preventOnFilter: false,
fallbackClass: 'thumbCard--dragging',
ghostClass: 'ghost',
chosenClass: 'chosen',
onEnd: (evt) => {
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
const nextItems = [...(selectedGame.value?.items || [])]
const [moved] = nextItems.splice(evt.oldIndex, 1)
nextItems.splice(evt.newIndex, 0, moved)
selectedGame.value = {
...selectedGame.value,
items: nextItems,
}
},
})
}
function mergeRequestItemsIntoDrafts(request) {
const requestId = request?.id
if (!requestId) return
const existingGameSrcs = new Set((selectedGame.value?.items || []).map((item) => normalizeDraftSrc(item?.src)).filter(Boolean))
const existingKeys = new Set(uploadItemDrafts.value.map((draft) => `${draft.kind}:${draft.requestId || ''}:${draft.itemId || draft.file?.name || ''}`))
const nextRequestDrafts = (request.items || [])
.filter((item) => item?.src)
.map((item) => ({
kind: 'request',
requestId,
itemId: item.id,
previewUrl: toApiUrl(item.src),
label: item.label || '',
sourceName: requestItemFilename(item),
src: item.src,
}))
.filter((draft) => !existingGameSrcs.has(normalizeDraftSrc(draft.src)))
.filter((draft) => !existingKeys.has(`${draft.kind}:${draft.requestId}:${draft.itemId}`))
if (nextRequestDrafts.length) {
uploadItemDrafts.value = [...uploadItemDrafts.value, ...nextRequestDrafts]
}
}
function removeUploadDraft(targetDraft) {
const targetKey = `${targetDraft.kind}:${targetDraft.requestId || ''}:${targetDraft.itemId || targetDraft.file?.name || ''}:${targetDraft.previewUrl || ''}`
uploadItemDrafts.value = uploadItemDrafts.value.filter((draft) => {
const currentKey = `${draft.kind}:${draft.requestId || ''}:${draft.itemId || draft.file?.name || ''}:${draft.previewUrl || ''}`
return currentKey !== targetKey
})
uploadFiles.value = uploadItemDrafts.value.filter((draft) => draft.kind === 'file').map((draft) => draft.file).filter(Boolean)
}
async function loadGame() {
resetMessages()
resetUploadState()
if (!selectedGameId.value) {
selectedGame.value = null
savedGameItemOrderIds.value = []
destroyGameItemSortable()
return
}
try {
isGameLoading.value = true
const data = await api.getGame(selectedGameId.value)
selectedGame.value = {
...data,
items: (data.items || []).map((item) => ({
...item,
draftLabel: item.label,
})),
}
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
await syncGameItemSortable()
} catch (e) {
console.error('[AdminView] loadGame failed', selectedGameId.value, e)
selectedGame.value = null
error.value = '게임 정보를 불러오지 못했어요.'
} finally {
isGameLoading.value = false
}
}
async function createGame() {
resetMessages()
try {
const res = await fetch(toApiUrl('/api/admin/games'), {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: newGameId.value.trim(),
name: newGameName.value.trim(),
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
}),
})
if (!res.ok) throw new Error('failed')
const data = await res.json()
if (activeTemplateRequest.value?.type === 'create' && activeTemplateRequest.value?.id) {
const linkData = await api.linkAdminTemplateRequestGame(activeTemplateRequest.value.id, {
gameId: data.game.id,
})
activeTemplateRequest.value = {
...activeTemplateRequest.value,
targetGameId: linkData.request?.targetGameId || data.game.id,
targetGameName: linkData.request?.targetGameName || data.game.name || newGameName.value.trim(),
}
const requestIndex = templateRequests.value.findIndex((entry) => entry.id === activeTemplateRequest.value.id)
if (requestIndex >= 0) {
templateRequests.value.splice(requestIndex, 1, {
...templateRequests.value[requestIndex],
targetGameId: linkData.request?.targetGameId || data.game.id,
targetGameName: linkData.request?.targetGameName || data.game.name || newGameName.value.trim(),
})
}
}
await refreshGames()
selectedGameId.value = data.game.id
if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id
closeGameCreateModal()
await loadGame()
if (activeTemplateRequest.value?.id) {
const sourceRequest = templateRequests.value.find((entry) => entry.id === activeTemplateRequest.value.id) || activeTemplateRequest.value
mergeRequestItemsIntoDrafts(sourceRequest)
}
success.value = '게임이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
} catch (e) {
error.value = '게임 생성 실패(관리자 권한/중복 ID 확인)'
}
}
function handleItemFiles(fileList) {
const files = Array.from(fileList || []).filter((file) => (file.type || '').startsWith('image/'))
const requestDrafts = uploadItemDrafts.value.filter((draft) => draft.kind === 'request')
const previousFileDrafts = uploadItemDrafts.value.filter((draft) => draft.kind === 'file')
previousFileDrafts.forEach((draft) => {
if (draft.previewUrl) URL.revokeObjectURL(draft.previewUrl)
})
itemPreviewUrls.value = []
uploadFiles.value = files
uploadItemDrafts.value = requestDrafts
if (!files.length) {
resetFileInput('item')
return
}
itemPreviewUrls.value = files.map((file) => URL.createObjectURL(file))
const fileDrafts = files.map((file, index) => ({
kind: 'file',
file,
previewUrl: itemPreviewUrls.value[index],
label: (file.name || '').replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60),
sourceName: file.name,
}))
uploadItemDrafts.value = [...requestDrafts, ...fileDrafts]
resetFileInput('item')
}
function onFile(event) {
handleItemFiles(event.target.files)
}
function openItemFilePicker() {
itemFileInput.value?.click()
}
function clearItemFiles() {
uploadFiles.value = []
uploadItemDrafts.value = []
itemPreviewUrls.value.forEach((url) => {
if (url) URL.revokeObjectURL(url)
})
itemPreviewUrls.value = []
resetFileInput('item')
}
async function uploadItem() {
resetMessages()
if (!uploadItemDrafts.value.length || !selectedGameId.value) {
error.value = '아이템 파일을 선택해주세요.'
return
}
try {
const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file')
const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request')
let uploadCount = 0
if (fileDrafts.length) {
const fd = new FormData()
fileDrafts.forEach((entry) => {
fd.append('images', entry.file)
fd.append('labels', entry.label.trim())
})
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedGameId.value)}/images`), {
method: 'POST',
credentials: 'include',
body: fd,
})
if (!res.ok) throw new Error('failed')
uploadCount += fileDrafts.length
}
if (requestDrafts.length) {
const requestIds = [...new Set(requestDrafts.map((entry) => entry.requestId).filter(Boolean))]
for (const requestId of requestIds) {
const draftsForRequest = requestDrafts.filter((entry) => entry.requestId === requestId)
const result = await api.promoteAdminTemplateRequestItems(requestId, {
gameId: selectedGameId.value,
itemIds: draftsForRequest.map((entry) => entry.itemId).filter(Boolean),
itemSrcs: draftsForRequest.map((entry) => entry.src).filter(Boolean),
itemLabels: draftsForRequest.reduce((acc, entry) => {
if (entry.itemId) acc[entry.itemId] = entry.label.trim()
return acc
}, {}),
})
uploadCount += Array.isArray(result?.items) ? result.items.length : 0
}
}
resetUploadState()
await loadGame()
success.value = `게임 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
} catch (e) {
const apiError = e?.data?.error || ''
if (apiError === 'no_items_selected') {
error.value = '추가할 요청 아이템이 없어요.'
return
}
if (apiError === 'promote_items_failed') {
const detail = e?.data?.detail ? ` (${e.data.detail})` : ''
error.value = `요청 아이템을 게임 기본 아이템으로 옮기지 못했어요.${detail}`
return
}
if (apiError === 'game_not_found') {
error.value = '선택한 게임을 찾지 못했어요.'
return
}
error.value = '아이템 추가에 실패했어요.'
}
}
async function saveGameItemOrder() {
resetMessages()
if (!selectedGameId.value || !selectedGame.value?.items?.length) return
try {
const data = await api.updateAdminGameItemDisplayOrder(selectedGameId.value, {
itemIds: selectedGame.value.items.map((item) => item.id),
})
selectedGame.value = {
...selectedGame.value,
items: (data.items || []).map((item) => ({
...item,
draftLabel: item.label,
})),
}
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
await syncGameItemSortable()
success.value = '기본 아이템 순서를 저장했어요.'
} catch (e) {
error.value = '기본 아이템 순서 저장에 실패했어요.'
}
}
return {
requestItemFilename,
destroyGameItemSortable,
syncGameItemSortable,
mergeRequestItemsIntoDrafts,
removeUploadDraft,
loadGame,
createGame,
handleItemFiles,
onFile,
openItemFilePicker,
clearItemFiles,
uploadItem,
saveGameItemOrder,
}
}

View File

@@ -0,0 +1,102 @@
export function useAdminTemplateRequests({
api,
activeTemplateRequest,
refreshTemplateRequests,
setTab,
openGameCreateModal,
newGameId,
newGameName,
selectAdminGame,
mergeRequestItemsIntoDrafts,
resetMessages,
success,
error,
}) {
function updateActiveTemplateRequest(request) {
if (!request?.id) return
activeTemplateRequest.value = {
id: request.id,
type: request.type,
status: request.status,
thumbnailSrc: request.thumbnailSrc || '',
draftGameId: request.draftGameId || '',
draftGameName: request.draftGameName || '',
sourceTierListId: request.sourceTierListId || '',
sourceGameId: request.sourceGameId || '',
sourceTierListTitle: request.sourceTierListTitle || '',
targetGameId: request.targetGameId || '',
targetGameName: request.targetGameName || '',
requesterName: request.requesterName || '',
}
}
function templateRequestStatusLabel(request) {
return request.status === 'reviewing' ? '확인함' : '미확인'
}
function templateRequestSourceUrl(request) {
if (!request?.sourceGameId || !request?.sourceTierListId) return ''
return `/editor/${request.sourceGameId}/${request.sourceTierListId}?preview=1`
}
function templateRequestReviewHint(request) {
if (request.type === 'create') return '게임 생성 후 필요한 아이템만 골라 추가하고, 끝나면 여기서 처리 완료하세요.'
return '확인하기를 누르면 게임 관리 화면에 요청 아이템이 임시 추가되어 필요한 것만 선별 저장할 수 있어요.'
}
async function startTemplateRequestReview(request) {
resetMessages()
try {
request.isHandling = true
const data = await api.startAdminTemplateRequestReview(request.id)
request.status = data.request?.status || 'reviewing'
updateActiveTemplateRequest(request)
setTab('game-admin')
if (request.type === 'create') {
const linkedGameId = request.targetGameId || ''
if (linkedGameId) {
await selectAdminGame(linkedGameId)
} else {
openGameCreateModal()
newGameId.value = (request.draftGameId || '').trim()
newGameName.value = (request.draftGameName || '').trim()
}
mergeRequestItemsIntoDrafts(request)
} else {
const nextGameId = request.targetGameId || request.sourceGameId || ''
if (nextGameId) await selectAdminGame(nextGameId)
mergeRequestItemsIntoDrafts(request)
}
success.value = '요청 아이템을 게임 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.'
} catch (e) {
error.value = '요청 확인 단계로 이동하지 못했어요.'
} finally {
request.isHandling = false
}
}
async function completeTemplateRequest(request) {
resetMessages()
try {
request.isHandling = true
await api.completeAdminTemplateRequest(request.id)
if (activeTemplateRequest.value?.id === request.id) activeTemplateRequest.value = null
await refreshTemplateRequests()
success.value = '요청 카드를 처리 완료로 정리했어요.'
} catch (e) {
error.value = '요청 완료 처리에 실패했어요.'
} finally {
request.isHandling = false
}
}
return {
updateActiveTemplateRequest,
templateRequestStatusLabel,
templateRequestSourceUrl,
templateRequestReviewHint,
startTemplateRequestReview,
completeTemplateRequest,
}
}

View File

@@ -0,0 +1,265 @@
import { computed } from 'vue'
export function useAdminUsers({
api,
auth,
users,
userQuery,
userSort,
userSortDirection,
userAvatarInputs,
modalTargetUser,
modalPasswordDraft,
modalRoleNextAdmin,
modalUserDraftEmail,
modalUserDraftNickname,
modalUserDraftIsAdmin,
userEditModalOpen,
userPasswordModalOpen,
userDeleteModalOpen,
userRoleModalOpen,
resetMessages,
refreshUsers,
success,
error,
}) {
function setUserAvatarInput(userId, el) {
if (!userId) return
if (!el) {
delete userAvatarInputs.value[userId]
return
}
userAvatarInputs.value[userId] = el
}
const canManageModalRole = computed(() => {
if (!auth.user?.isPrimaryAdmin) return false
if (!modalTargetUser.value) return false
return !modalTargetUser.value.isPrimaryAdmin
})
const isUserEditDirty = computed(() => {
if (!modalTargetUser.value) return false
return (
modalUserDraftEmail.value.trim() !== (modalTargetUser.value.email || '') ||
modalUserDraftNickname.value.trim() !== (modalTargetUser.value.nickname || '') ||
!!modalUserDraftIsAdmin.value !== !!modalTargetUser.value.isAdmin
)
})
function roleLabelOf(user) {
if (user?.isPrimaryAdmin) return '최고 관리자'
if (user?.isAdmin) return '운영자'
return '일반 회원'
}
function openUserAvatarPicker(user) {
userAvatarInputs.value[user?.id]?.click()
}
async function uploadUserAvatar(user, file, { remove = false } = {}) {
resetMessages()
if (!user?.id) return
try {
user.isAvatarBusy = true
const data = await api.updateAdminUserAvatar(user.id, { file, removeAvatar: remove })
const updated = data.user
users.value = users.value.map((entry) =>
entry.id === updated.id
? {
...entry,
...updated,
isAvatarBusy: false,
}
: entry
)
if (modalTargetUser.value?.id === updated.id) {
modalTargetUser.value = { ...modalTargetUser.value, ...updated }
}
if (updated.id === auth.user?.id) await auth.refresh()
await refreshUsers()
success.value = remove ? '회원 썸네일을 삭제했어요.' : '회원 썸네일을 업데이트했어요.'
} catch (e) {
error.value = remove ? '회원 썸네일 삭제에 실패했어요.' : '회원 썸네일 변경에 실패했어요.'
} finally {
const target = users.value.find((entry) => entry.id === user.id)
if (target) target.isAvatarBusy = false
}
}
async function onUserAvatarChange(user, event) {
const file = event.target.files && event.target.files[0] ? event.target.files[0] : null
event.target.value = ''
if (!file) return
await uploadUserAvatar(user, file)
}
async function removeUserAvatar(user) {
if (!user?.avatarSrc) return
await uploadUserAvatar(user, null, { remove: true })
}
function openUserEditModal(user) {
resetMessages()
modalTargetUser.value = user ? { ...user } : null
modalUserDraftEmail.value = user?.email || ''
modalUserDraftNickname.value = user?.nickname || ''
modalUserDraftIsAdmin.value = !!user?.isAdmin
userEditModalOpen.value = true
}
function closeUserEditModal() {
userEditModalOpen.value = false
modalTargetUser.value = null
modalUserDraftEmail.value = ''
modalUserDraftNickname.value = ''
modalUserDraftIsAdmin.value = false
}
async function saveUserEdit() {
resetMessages()
if (!modalTargetUser.value?.id) return
try {
const data = await api.updateAdminUser(modalTargetUser.value.id, {
email: modalUserDraftEmail.value.trim(),
nickname: modalUserDraftNickname.value.trim(),
isAdmin: !!modalUserDraftIsAdmin.value,
})
const updated = data.user
users.value = users.value.map((entry) =>
entry.id === updated.id
? {
...entry,
...updated,
isAvatarBusy: entry.isAvatarBusy || false,
}
: entry
)
if (updated.id === auth.user?.id) await auth.refresh()
closeUserEditModal()
await refreshUsers()
success.value = '회원 정보를 저장했어요.'
} catch (e) {
error.value = '회원 정보 저장에 실패했어요.'
}
}
function openUserPasswordModal(user) {
resetMessages()
modalTargetUser.value = user ? { ...user } : null
modalPasswordDraft.value = ''
userPasswordModalOpen.value = true
}
function closeUserPasswordModal() {
userPasswordModalOpen.value = false
modalTargetUser.value = null
modalPasswordDraft.value = ''
}
function userDisplayName(user) {
return user?.nickname || user?.email?.split('@')[0] || '알 수 없음'
}
async function confirmUserPasswordReset() {
resetMessages()
if (!modalTargetUser.value?.id) return
const password = modalPasswordDraft.value.trim()
if (!password) {
error.value = '초기화할 비밀번호를 입력해주세요.'
return
}
try {
await api.updateAdminUserPassword(modalTargetUser.value.id, { password })
success.value = `${userDisplayName(modalTargetUser.value)} 계정 비밀번호를 초기화했어요.`
closeUserPasswordModal()
} catch (e) {
error.value = '비밀번호 초기화에 실패했어요.'
}
}
function openUserDeleteModal(user) {
resetMessages()
modalTargetUser.value = user ? { ...user } : null
userDeleteModalOpen.value = true
}
function closeUserDeleteModal() {
userDeleteModalOpen.value = false
modalTargetUser.value = null
}
async function confirmUserDelete() {
resetMessages()
if (!modalTargetUser.value?.id) return
try {
const deletingSelf = modalTargetUser.value.id === auth.user?.id
const deletedName = userDisplayName(modalTargetUser.value)
await api.deleteAdminUser(modalTargetUser.value.id)
users.value = users.value.filter((entry) => entry.id !== modalTargetUser.value.id)
closeUserDeleteModal()
success.value = `${deletedName} 계정을 삭제했어요.`
if (deletingSelf) await auth.refresh()
} catch (e) {
error.value = '회원 삭제에 실패했어요.'
}
}
function openUserRoleModal(user, nextIsAdmin = !modalUserDraftIsAdmin.value) {
resetMessages()
modalTargetUser.value = user ? { ...user } : null
modalRoleNextAdmin.value = !!nextIsAdmin
userRoleModalOpen.value = true
}
function closeUserRoleModal() {
userRoleModalOpen.value = false
if (!userEditModalOpen.value) modalTargetUser.value = null
modalRoleNextAdmin.value = false
}
function confirmUserRoleDraft() {
if (!modalTargetUser.value?.id) return
modalUserDraftIsAdmin.value = modalRoleNextAdmin.value
const targetLabel = modalRoleNextAdmin.value ? '운영자 권한을 저장 대기 상태로 반영했어요.' : '운영자 권한 해제를 저장 대기 상태로 반영했어요.'
closeUserRoleModal()
success.value = targetLabel
}
function submitUserFilters() {
refreshUsers({
q: userQuery.value,
sort: userSort.value,
direction: userSortDirection.value,
})
}
return {
setUserAvatarInput,
canManageModalRole,
isUserEditDirty,
roleLabelOf,
openUserAvatarPicker,
onUserAvatarChange,
removeUserAvatar,
openUserEditModal,
closeUserEditModal,
saveUserEdit,
openUserPasswordModal,
closeUserPasswordModal,
confirmUserPasswordReset,
openUserDeleteModal,
closeUserDeleteModal,
confirmUserDelete,
openUserRoleModal,
closeUserRoleModal,
confirmUserRoleDraft,
submitUserFilters,
userDisplayName,
}
}

View File

@@ -35,6 +35,8 @@ export const api = {
favoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'POST' }),
unfavoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'DELETE' }),
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
updateAdminGameItemDisplayOrder: (gameId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/display-order`, { method: 'PATCH', body: payload }),
updateAdminGameItem: (gameId, itemId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
listAdminCustomItems: ({ q = '', page = 1, limit = 50, orphanOnly = false } = {}) =>
@@ -55,14 +57,25 @@ export const api = {
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
promoteAdminCustomItem: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
updateAdminCustomItemLabel: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
promoteAdminTierListItems: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
createAdminGameTemplateFromTierList: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
startAdminTemplateRequestReview: (requestId) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/review`, { method: 'POST', body: {} }),
linkAdminTemplateRequestGame: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/link-game`, { method: 'POST', body: payload }),
promoteAdminTemplateRequestItems: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/promote-items`, { method: 'POST', body: payload }),
completeAdminTemplateRequest: (requestId) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/complete`, { method: 'POST', body: {} }),
approveAdminTemplateRequest: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/approve`, { method: 'POST', body: payload || {} }),
rejectAdminTemplateRequest: (requestId) => request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/reject`, { method: 'POST', body: {} }),
listAdminUsers: () => request('/api/admin/users'),
listAdminUsers: ({ q = '', sort = 'recent', direction = 'desc' } = {}) =>
request(`/api/admin/users?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}&direction=${encodeURIComponent(direction)}`),
updateAdminUser: (userId, payload) =>
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
updateAdminUserPassword: (userId, payload) =>
@@ -100,7 +113,7 @@ export const api = {
unfavoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'DELETE' }),
deleteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`, { method: 'DELETE' }),
duplicateTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/duplicate`, { method: 'POST' }),
requestTierListTemplate: (id, payload) => request(`/api/tierlists/${encodeURIComponent(id)}/template-request`, { method: 'POST', body: payload }),
requestTierListTemplate: (payload) => request('/api/tierlists/template-request', { method: 'POST', body: payload }),
saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }),
uploadTierListThumbnail: async (file) => {
const fd = new FormData()

View File

@@ -9,9 +9,10 @@ import FavoriteTierListsView from '../views/FavoriteTierListsView.vue'
import AdminView from '../views/AdminView.vue'
import ProfileView from '../views/ProfileView.vue'
import SearchResultsView from '../views/SearchResultsView.vue'
import { useAuthStore } from '../stores/auth'
export function createRouter() {
return _createRouter({
const router = _createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: HomeView },
@@ -22,8 +23,27 @@ export function createRouter() {
{ path: '/me', name: 'me', component: MyTierListsView },
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView },
{ path: '/search', name: 'search', component: SearchResultsView },
{ path: '/admin', name: 'admin', component: AdminView },
{ path: '/admin', redirect: '/admin/featured' },
{ path: '/admin/featured', name: 'adminFeatured', component: AdminView },
{ path: '/admin/games', name: 'adminGames', component: AdminView },
{ path: '/admin/items', name: 'adminItems', component: AdminView },
{ path: '/admin/tierlists', name: 'adminTierlists', component: AdminView },
{ path: '/admin/users', name: 'adminUsers', component: AdminView },
{ path: '/profile', name: 'profile', component: ProfileView },
],
})
router.beforeEach(async (to) => {
const routeName = String(to.name || '')
if (!routeName.startsWith('admin')) return true
const auth = useAuthStore()
if (!auth.hydrated) await auth.refresh()
if (!auth.user?.isAdmin) {
return { path: '/' }
}
return true
})
return router
}

View File

@@ -5,30 +5,40 @@ export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
status: 'idle',
hydrated: false,
}),
actions: {
async refresh() {
if (this.status === 'loading') return this.user
this.status = 'loading'
try {
const data = await api.me()
this.user = data.user
return this.user
} catch (error) {
this.user = null
return null
} finally {
this.status = 'idle'
this.hydrated = true
}
},
async signup(email, password) {
const user = await api.signup({ email, password })
this.user = user
this.hydrated = true
return user
},
async login(email, password) {
const user = await api.login({ email, password })
this.user = user
this.hydrated = true
return user
},
async logout() {
await api.logout()
this.user = null
this.hydrated = true
},
},
})

View File

@@ -2,12 +2,69 @@
font-family: 'Pretendard', 'Inter', 'Segoe UI', sans-serif;
line-height: 1.5;
font-weight: 400;
color: rgba(255, 255, 255, 0.92);
background: #121212;
color: var(--theme-text);
background: var(--theme-body-bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--theme-body-bg: #121212;
--theme-shell-bg: rgba(14, 14, 14, 0.96);
--theme-rail-bg: rgba(14, 14, 14, 0.92);
--theme-main-bg: rgba(18, 18, 18, 0.98);
--theme-workspace-bg: rgba(24, 24, 24, 0.92);
--theme-card-bg: rgba(62, 62, 62, 0.82);
--theme-card-bg-hover: rgba(70, 70, 70, 0.96);
--theme-card-border: rgba(255, 255, 255, 0.16);
--theme-card-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
--theme-surface-soft: rgba(255, 255, 255, 0.05);
--theme-surface-soft-2: rgba(255, 255, 255, 0.06);
--theme-surface-soft-3: rgba(255, 255, 255, 0.08);
--theme-pill-bg: rgba(255, 255, 255, 0.03);
--theme-border: rgba(255, 255, 255, 0.08);
--theme-border-strong: rgba(255, 255, 255, 0.12);
--theme-text: rgba(255, 255, 255, 0.92);
--theme-text-strong: rgba(255, 255, 255, 0.98);
--theme-text-muted: rgba(255, 255, 255, 0.74);
--theme-text-soft: rgba(255, 255, 255, 0.62);
--theme-text-faint: rgba(255, 255, 255, 0.4);
--theme-thumb-fallback-bg: #555;
--theme-select-arrow: rgba(255, 255, 255, 0.68);
--theme-danger-bg: rgba(239, 68, 68, 0.1);
--theme-danger-border: rgba(239, 68, 68, 0.18);
--theme-accent-bg: rgba(76, 133, 245, 0.92);
--theme-accent-text: #fff;
--theme-icon-filter: brightness(0) saturate(100%) invert(94%) sepia(6%) saturate(207%) hue-rotate(186deg) brightness(96%) contrast(92%);
}
:root[data-theme='light'] {
--theme-body-bg: #e7ebf2;
--theme-shell-bg: rgba(237, 241, 247, 0.98);
--theme-rail-bg: rgba(243, 246, 251, 0.97);
--theme-main-bg: rgba(232, 236, 243, 0.98);
--theme-workspace-bg: rgba(247, 249, 252, 0.96);
--theme-card-bg: rgba(252, 253, 255, 0.98);
--theme-card-bg-hover: rgba(244, 247, 251, 0.98);
--theme-card-border: rgba(31, 41, 55, 0.11);
--theme-card-shadow: 0 18px 34px rgba(31, 41, 55, 0.07);
--theme-surface-soft: rgba(30, 41, 59, 0.055);
--theme-surface-soft-2: rgba(30, 41, 59, 0.075);
--theme-surface-soft-3: rgba(30, 41, 59, 0.105);
--theme-pill-bg: rgba(30, 41, 59, 0.045);
--theme-border: rgba(30, 41, 59, 0.11);
--theme-border-strong: rgba(30, 41, 59, 0.16);
--theme-text: rgba(20, 27, 40, 0.92);
--theme-text-strong: rgba(10, 15, 28, 0.98);
--theme-text-muted: rgba(55, 65, 81, 0.76);
--theme-text-soft: rgba(75, 85, 99, 0.72);
--theme-text-faint: rgba(100, 116, 139, 0.88);
--theme-thumb-fallback-bg: #f6f8fb;
--theme-select-arrow: rgba(55, 65, 81, 0.74);
--theme-danger-bg: rgba(239, 68, 68, 0.1);
--theme-danger-border: rgba(239, 68, 68, 0.22);
--theme-accent-bg: rgba(64, 110, 226, 0.94);
--theme-accent-text: #fff;
--theme-icon-filter: brightness(0) saturate(100%) invert(14%) sepia(14%) saturate(652%) hue-rotate(182deg) brightness(95%) contrast(91%);
}
* {
@@ -22,7 +79,9 @@ body,
body {
margin: 0;
background: #121212;
background: var(--theme-body-bg);
color: var(--theme-text);
transition: background 220ms ease, color 220ms ease;
}
button,
@@ -43,7 +102,7 @@ a {
input,
select,
textarea {
color: rgba(255, 255, 255, 0.92);
color: var(--theme-text);
}
select {
@@ -51,8 +110,8 @@ select {
-webkit-appearance: none;
-moz-appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, rgba(255, 255, 255, 0.72) 50%),
linear-gradient(135deg, rgba(255, 255, 255, 0.72) 50%, transparent 50%);
linear-gradient(45deg, transparent 50%, var(--theme-select-arrow) 50%),
linear-gradient(135deg, var(--theme-select-arrow) 50%, transparent 50%);
background-position:
calc(100% - 20px) calc(50% - 2px),
calc(100% - 14px) calc(50% - 2px);
@@ -99,19 +158,19 @@ p {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
}
.pageHead__title {
font-size: 32px;
line-height: 1.05;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
color: var(--theme-text-strong);
}
.pageHead__desc {
max-width: 720px;
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-muted);
}
.pageHead__aside {

File diff suppressed because it is too large Load Diff

View File

@@ -110,16 +110,16 @@ onMounted(loadFavorites)
.select {
padding: 11px 13px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
}
.btn {
padding: 11px 13px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft-2);
color: var(--theme-text);
font-weight: 800;
cursor: pointer;
}
@@ -133,18 +133,18 @@ onMounted(loadFavorites)
}
.boardCard {
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
overflow: hidden;
display: grid;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition:
transform 0.16s ease,
background 0.16s ease;
}
.boardCard:hover {
transform: translateY(-2px);
background: rgba(70, 70, 70, 0.96);
background: var(--theme-card-bg-hover);
}
.boardCard__body {
border: 0;
@@ -172,10 +172,10 @@ onMounted(loadFavorites)
object-fit: cover;
}
.boardCard__thumbPlaceholder {
background: #555;
background: var(--theme-thumb-fallback-bg);
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
color: var(--theme-text-faint);
font-size: 13px;
font-weight: 700;
}
@@ -222,7 +222,7 @@ onMounted(loadFavorites)
height: 22px;
border-radius: 9999px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
background: var(--theme-border);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
@@ -235,7 +235,7 @@ onMounted(loadFavorites)
.favoriteStat {
flex: 0 0 auto;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
color: var(--theme-text-faint);
white-space: nowrap;
}

View File

@@ -149,7 +149,7 @@ function submitSearch() {
}
.dashboardHero__eyebrow {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
text-transform: uppercase;
letter-spacing: 0.08em;
}
@@ -157,15 +157,15 @@ function submitSearch() {
margin: 4px 0 6px;
font-size: 32px;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
color: var(--theme-text-strong);
}
.dashboardHero__desc {
margin: 0;
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-muted);
max-width: 720px;
}
.panel {
/* border: 1px solid rgba(255, 255, 255, 0.08); */
/* border: 1px solid var(--theme-border); */
background: transparent;
border-radius: 0;
padding: 0;
@@ -174,8 +174,8 @@ function submitSearch() {
margin: 10px 0 14px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.3);
background: rgba(239, 68, 68, 0.12);
border: 1px solid var(--theme-danger-border);
background: var(--theme-danger-bg);
}
.panel__title {
font-weight: 800;
@@ -183,7 +183,7 @@ function submitSearch() {
}
.panel__sub {
margin-top: 6px;
color: rgba(255, 255, 255, 0.56);
color: var(--theme-text-muted);
font-size: 13px;
}
.panel__head {
@@ -204,16 +204,16 @@ function submitSearch() {
min-width: 240px;
padding: 11px 13px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
}
.searchBar__button {
padding: 11px 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft-2);
color: var(--theme-text);
font-weight: 800;
cursor: pointer;
}
@@ -222,7 +222,7 @@ function submitSearch() {
}
.list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
@@ -232,18 +232,18 @@ function submitSearch() {
.boardCard {
min-width: 0;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
color: var(--theme-text);
display: grid;
overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition:
transform 0.16s ease,
background 0.16s ease;
}
.boardCard:hover {
background: rgba(70, 70, 70, 0.96);
background: var(--theme-card-bg-hover);
transform: translateY(-2px);
}
.boardCard__body {
@@ -293,10 +293,10 @@ function submitSearch() {
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
background: #555;
background: var(--theme-thumb-fallback-bg);
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
color: var(--theme-text-faint);
font-size: 13px;
font-weight: 700;
border-radius: 18px;
@@ -362,7 +362,7 @@ function submitSearch() {
border-radius: 9999px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
background: var(--theme-border);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
@@ -377,7 +377,7 @@ function submitSearch() {
min-width: 0;
max-width: 100%;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
color: var(--theme-text-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -2,6 +2,8 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api'
import SvgIcon from '../components/SvgIcon.vue'
import kidStarIcon from '../assets/icons/kid_star.svg'
import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth'
@@ -91,7 +93,7 @@ function thumbUrl(g) {
:disabled="loadingFavoriteId === g.id"
@click.stop="toggleFavorite(g, $event)"
>
{{ g.isFavorited ? '★' : '☆' }}
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
</button>
<button class="libraryCard__main" type="button" @click="goGame(g.id)">
<div class="libraryCard__thumbWrap">
@@ -118,31 +120,31 @@ function thumbUrl(g) {
margin: 0 0 16px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.18);
background: rgba(239, 68, 68, 0.1);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-danger-border);
background: var(--theme-danger-bg);
color: var(--theme-text);
}
.pageHead__searchState {
margin-top: 8px;
color: rgba(255, 255, 255, 0.62);
color: var(--theme-text-muted);
}
.libraryCard {
position: relative;
text-align: left;
padding: 14px;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
color: var(--theme-text);
cursor: pointer;
display: grid;
gap: 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition: transform 0.16s ease, background 0.16s ease;
will-change: transform, opacity;
}
.libraryCard:hover {
background: rgba(70, 70, 70, 0.96);
background: var(--theme-card-bg-hover);
transform: translateY(-2px);
}
.libraryCard__main {
@@ -169,16 +171,28 @@ function thumbUrl(g) {
line-height: 1;
cursor: pointer;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
}
.libraryCard__favorite--active {
background: rgba(54, 45, 10, 0.92);
border-color: rgba(255, 216, 107, 0.28);
}
.libraryCard__favoriteIcon {
opacity: 0.76;
color: rgba(255, 255, 255, 0.94);
}
.libraryCard__favorite--active .libraryCard__favoriteIcon {
opacity: 1;
color: #ffd86b;
}
.libraryCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: #555;
border: 1px solid var(--theme-surface-soft-2);
background: var(--theme-thumb-fallback-bg);
overflow: hidden;
display: grid;
place-items: center;
@@ -190,7 +204,7 @@ function thumbUrl(g) {
}
.libraryCard__thumbFallback {
font-size: 14px;
color: rgba(255, 255, 255, 0.4);
color: var(--theme-text-faint);
}
.libraryCard__body {
display: grid;
@@ -201,7 +215,7 @@ function thumbUrl(g) {
font-size: 18px;
}
.libraryCard__meta {
color: rgba(255, 255, 255, 0.6);
color: var(--theme-text-soft);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
@@ -227,7 +241,7 @@ function thumbUrl(g) {
.libraryEmpty {
padding: 20px 0;
color: rgba(255, 255, 255, 0.62);
color: var(--theme-text-muted);
}
@media (max-width: 1400px) {
.libraryGrid {

View File

@@ -30,8 +30,15 @@ const description = computed(() =>
: '저장한 티어표와 즐겨찾기, 프로필 설정을 이어서 관리할 수 있어요.'
)
const submitLabel = computed(() => (mode.value === 'signup' ? '가입하기' : '로그인'))
const authReady = computed(() => auth.hydrated)
const checkingSession = computed(() => !authReady.value || auth.status === 'loading')
onMounted(async () => {
if (!auth.hydrated) await auth.refresh()
if (auth.user) {
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
return
}
try {
const meta = await api.authMeta()
hasUsers.value = !!meta.hasUsers
@@ -40,6 +47,15 @@ onMounted(async () => {
}
})
watch(
() => [auth.hydrated, auth.user],
([hydrated, user]) => {
if (!hydrated || !user) return
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
},
{ immediate: true }
)
async function submit() {
error.value = ''
if (mode.value === 'signup' && password.value !== passwordConfirm.value) {
@@ -66,7 +82,11 @@ async function submit() {
</div>
</header>
<section class="authScreen">
<section v-if="checkingSession" class="authScreen authScreen--loading">
<div class="authLoading">로그인 상태를 확인하고 있어요.</div>
</section>
<section v-else class="authScreen">
<div class="authTabs" role="tablist" aria-label="로그인 또는 회원가입">
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'login' }" @click="mode = 'login'">
로그인
@@ -79,8 +99,8 @@ async function submit() {
<form class="authFields" @submit.prevent="submit">
<label class="field">
<span class="field__label">이메일</span>
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" />
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다.</span>
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" maxlength="255" />
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다. {{ email.length }}/255</span>
</label>
<label class="field">
@@ -91,8 +111,9 @@ async function submit() {
type="password"
placeholder="********"
autocomplete="current-password"
maxlength="120"
/>
<span class="field__hint">8 이상으로 설정하면 안전하게 사용할 있어요.</span>
<span class="field__hint">6~120 입력 가능 · {{ password.length }}/120</span>
</label>
<label v-if="mode === 'signup'" class="field">
@@ -103,8 +124,9 @@ async function submit() {
type="password"
placeholder="********"
autocomplete="new-password"
maxlength="120"
/>
<span class="field__hint">같은 비밀번호를 입력해주세요.</span>
<span class="field__hint">같은 비밀번호를 입력해주세요. {{ passwordConfirm.length }}/120</span>
</label>
<div v-if="!hasUsers" class="roleBadge"> 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
@@ -126,14 +148,24 @@ async function submit() {
padding-top: 4px;
}
.authScreen--loading {
min-height: 220px;
align-items: center;
}
.authLoading {
color: var(--theme-text-muted);
font-size: 15px;
}
.authTabs {
display: inline-flex;
gap: 8px;
width: fit-content;
padding: 6px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
}
.authTabs__button {
@@ -142,14 +174,14 @@ async function submit() {
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.62);
color: var(--theme-text-muted);
font-weight: 700;
cursor: pointer;
}
.authTabs__button--active {
background: rgba(76, 133, 245, 0.22);
color: rgba(255, 255, 255, 0.96);
color: var(--theme-text-strong);
}
.authFields {
@@ -164,16 +196,16 @@ async function submit() {
.field__label {
font-size: 13px;
color: rgba(255, 255, 255, 0.62);
color: var(--theme-text-muted);
}
.field__input {
width: 100%;
padding: 14px 0;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
border-bottom: 1px solid var(--theme-border-strong);
background: transparent;
color: rgba(255, 255, 255, 0.94);
color: var(--theme-text);
outline: none;
font-size: 18px;
letter-spacing: -0.02em;
@@ -185,7 +217,7 @@ async function submit() {
.field__hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
}
.roleBadge {
@@ -194,7 +226,7 @@ async function submit() {
border-radius: 999px;
border: 1px solid rgba(96, 165, 250, 0.28);
background: rgba(96, 165, 250, 0.1);
color: rgba(191, 219, 254, 0.92);
color: var(--theme-text);
font-size: 12px;
font-weight: 700;
}
@@ -216,14 +248,14 @@ async function submit() {
.primaryAction {
border: 1px solid rgba(76, 133, 245, 0.96);
background: rgba(76, 133, 245, 0.92);
color: #fff;
background: var(--theme-accent-bg);
color: var(--theme-accent-text);
}
.secondaryAction {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.86);
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft);
color: var(--theme-text);
}
@media (max-width: 720px) {

View File

@@ -9,6 +9,7 @@ const router = useRouter()
const toast = useToast()
const myLists = ref([])
const error = ref('')
const brokenThumbnailIds = ref({})
watch(error, (message) => {
if (!message) return
@@ -37,12 +38,19 @@ function avatarFallbackOf(tierList) {
}
function tierListThumbnailUrl(tierList) {
if (!tierList?.id || brokenThumbnailIds.value[tierList.id]) return ''
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
}
function handleThumbnailError(tierListId) {
if (!tierListId || brokenThumbnailIds.value[tierListId]) return
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
}
onMounted(async () => {
try {
const data = await api.listMyTierLists()
brokenThumbnailIds.value = {}
myLists.value = data.tierLists || []
} catch (e) {
toast.error('로그인이 필요해요.')
@@ -51,53 +59,58 @@ onMounted(async () => {
})
function openList(t) {
router.push(`/editor/${t.gameId}/${t.id}`)
router.push(
"/editor/" + t.gameId + "/" + t.id,
)
}
</script>
<template>
<section class="pageWrap">
<header class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Library</div>
<h2 class="pageHead__title"> 티어표</h2>
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 있어요.</div>
</div>
</header>
<div class="card">
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
<div v-else class="list">
<article v-for="t in myLists" :key="t.id" class="boardCard">
<button class="boardCard__body" @click="openList(t)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
<section class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Library</div>
<h2 class="pageHead__title"> 티어표</h2>
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 있어요.</div>
</div>
</section>
<section class="panel">
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
<div v-else class="list">
<article v-for="t in myLists" :key="t.id" class="boardCard">
<button class="boardCard__body" @click="openList(t)">
<div class="boardCard__thumbWrap">
<img
v-if="tierListThumbnailUrl(t)"
class="boardCard__thumb"
:src="tierListThumbnailUrl(t)"
alt=""
@error="handleThumbnailError(t.id)"
/>
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ t.title }}</div>
<div class="favoriteStat"> {{ t.favoriteCount || 0 }}</div>
</div>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ t.title }}</div>
<div class="favoriteStat"> {{ t.favoriteCount || 0 }}</div>
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
</div>
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
</div>
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
</div>
</button>
</article>
</div>
</div>
</button>
</article>
</div>
</section>
</template>
<style scoped>
.card {
border: 0;
.panel {
background: transparent;
border-radius: 0;
padding: 0;
@@ -107,29 +120,26 @@ function openList(t) {
}
.list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 320px));
justify-content: start;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.boardCard {
display: grid;
min-width: 0;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
color: rgba(255, 255, 255, 0.92);
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
color: var(--theme-text);
overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition:
transform 0.16s ease,
background 0.16s ease;
}
.boardCard:hover {
transform: translateY(-2px);
background: rgba(70, 70, 70, 0.96);
background: var(--theme-card-bg-hover);
}
.boardCard__body {
flex: 1 1 auto;
min-width: 0;
text-align: left;
cursor: pointer;
@@ -137,9 +147,12 @@ function openList(t) {
background: transparent;
color: inherit;
padding: 0;
width: 100%;
display: grid;
overflow: hidden;
}
.boardCard__thumbWrap {
min-width: 0;
width: 100%;
aspect-ratio: 16 / 9;
padding: 14px 14px 0;
@@ -155,55 +168,55 @@ function openList(t) {
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
background: #555;
background: var(--theme-thumb-fallback-bg);
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
color: var(--theme-text-faint);
font-size: 13px;
font-weight: 700;
border-radius: 18px;
}
.boardCard__title {
flex: 1 1 auto;
font-weight: 800;
min-width: 0;
font-weight: 900;
font-size: 18px;
line-height: 1.3;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
.boardCard__head {
min-width: 0;
padding: 16px 18px 18px;
display: grid;
gap: 8px;
min-width: 0;
overflow: hidden;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 10px;
min-width: 0;
align-items: center;
justify-content: space-between;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.boardCard__titleRow {
align-items: flex-start;
}
.boardCard__metaRow {
align-items: flex-end;
}
.boardCard__author {
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
display: inline-flex;
gap: 7px;
align-items: center;
font-size: 13px;
opacity: 0.84;
opacity: 0.86;
overflow: hidden;
}
.boardCard__authorName {
min-width: 0;
@@ -217,7 +230,7 @@ function openList(t) {
border-radius: 9999px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
background: var(--theme-border);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
@@ -229,14 +242,28 @@ function openList(t) {
.boardCard__date,
.favoriteStat {
flex: 0 0 auto;
min-width: 0;
max-width: 100%;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
color: var(--theme-text-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__date {
font-size: 10px;
}
@media (max-width: 720px) {
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1200px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.list {
grid-template-columns: 1fr;
}

View File

@@ -30,14 +30,19 @@ const avatarUrl = computed(() => {
return toApiUrl(auth.user.avatarSrc)
})
const authReady = computed(() => auth.hydrated)
const displayInitial = computed(() => {
const email = auth.user?.email || 'U'
return email[0].toUpperCase()
})
onMounted(async () => {
await auth.refresh()
if (!auth.user) router.push('/login')
if (!auth.hydrated) await auth.refresh()
if (!auth.user) {
router.replace('/login')
return
}
nickname.value = auth.user?.nickname || ''
removeAvatar.value = false
})
@@ -121,7 +126,11 @@ async function logout() {
</div>
</header>
<section v-if="auth.user" class="settingsScreen">
<section v-if="!authReady" class="settingsScreen settingsScreen--loading">
<div class="settingsLoading">계정 정보를 불러오고 있어요.</div>
</section>
<section v-else-if="auth.user" class="settingsScreen">
<div class="settingsIdentity">
<div class="avatarButtonWrap">
<button class="avatarButton" type="button" @click="openAvatarPicker">
@@ -156,8 +165,8 @@ async function logout() {
<div class="settingsFields">
<label class="field">
<span class="field__label">닉네임</span>
<input v-model="nickname" class="field__input" placeholder="작성자 닉네임" />
<span class="field__hint">티어표 작성자 이름으로 표시됩니다.</span>
<input v-model="nickname" class="field__input" maxlength="40" placeholder="작성자 닉네임" />
<span class="field__hint">티어표 작성자 이름으로 표시됩니다. {{ nickname.length }}/40</span>
</label>
<label class="field">
@@ -185,6 +194,16 @@ async function logout() {
padding-top: 4px;
}
.settingsScreen--loading {
min-height: 240px;
align-items: center;
}
.settingsLoading {
color: var(--theme-text-muted);
font-size: 15px;
}
.settingsIdentity {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
@@ -202,15 +221,15 @@ async function logout() {
position: relative;
width: 120px;
height: 120px;
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid var(--theme-border-strong);
border-radius: 9999px;
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.92);
background: var(--theme-pill-bg);
color: var(--theme-text);
overflow: hidden;
cursor: pointer;
display: grid;
place-items: center;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: var(--theme-card-shadow);
}
.avatarButton__image {
@@ -222,7 +241,7 @@ async function logout() {
.avatarButton__fallback {
font-size: 34px;
font-weight: 900;
color: rgba(255, 255, 255, 0.86);
color: var(--theme-text);
}
.avatarButton__overlay {
@@ -232,7 +251,7 @@ async function logout() {
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.72));
font-size: 12px;
font-weight: 700;
color: rgba(255, 255, 255, 0.82);
color: var(--theme-text);
}
.avatarButton__remove {
@@ -243,8 +262,8 @@ async function logout() {
height: 30px;
border: 0;
border-radius: 999px;
background: rgba(10, 10, 10, 0.72);
color: rgba(255, 255, 255, 0.88);
background: var(--theme-shell-bg);
color: var(--theme-text);
display: grid;
place-items: center;
cursor: pointer;
@@ -264,7 +283,7 @@ async function logout() {
.avatarButton__remove:hover {
background: rgba(190, 24, 24, 0.88);
color: #fff;
color: var(--theme-accent-text);
}
.identityMeta {
@@ -276,7 +295,7 @@ async function logout() {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.36);
color: var(--theme-text-soft);
}
.identityMeta__title {
@@ -286,7 +305,7 @@ async function logout() {
}
.identityMeta__desc {
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-muted);
line-height: 1.6;
}
@@ -307,16 +326,16 @@ async function logout() {
.field__label {
font-size: 13px;
color: rgba(255, 255, 255, 0.62);
color: var(--theme-text-muted);
}
.field__input {
width: 100%;
padding: 14px 0;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
border-bottom: 1px solid var(--theme-border-strong);
background: transparent;
color: rgba(255, 255, 255, 0.94);
color: var(--theme-text);
outline: none;
font-size: 18px;
letter-spacing: -0.02em;
@@ -327,12 +346,12 @@ async function logout() {
}
.field__input--readonly {
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-muted);
}
.field__hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
}
.roleBadge {
@@ -341,7 +360,7 @@ async function logout() {
border-radius: 999px;
border: 1px solid rgba(96, 165, 250, 0.28);
background: rgba(96, 165, 250, 0.1);
color: rgba(191, 219, 254, 0.92);
color: var(--theme-text);
font-size: 12px;
font-weight: 700;
}
@@ -363,14 +382,14 @@ async function logout() {
.primaryAction {
border: 1px solid rgba(76, 133, 245, 0.96);
background: rgba(76, 133, 245, 0.92);
color: #fff;
background: var(--theme-accent-bg);
color: var(--theme-accent-text);
}
.secondaryAction {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.86);
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft);
color: var(--theme-text);
}
@media (max-width: 720px) {

View File

@@ -122,24 +122,24 @@ watch(
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
color: var(--theme-text-soft);
}
.title {
margin: 4px 0 0;
font-size: 32px;
color: rgba(255, 255, 255, 0.96);
color: var(--theme-text-strong);
letter-spacing: -0.04em;
}
.desc {
margin-top: 6px;
color: rgba(255, 255, 255, 0.58);
color: var(--theme-text-muted);
}
.error {
margin: 0 0 8px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.18);
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--theme-danger-border);
background: var(--theme-danger-bg);
}
.empty {
opacity: 0.76;
@@ -151,16 +151,16 @@ watch(
}
.boardCard {
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
border: 1px solid var(--theme-card-border);
background: var(--theme-card-bg);
overflow: hidden;
display: grid;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
transition: transform 0.16s ease, background 0.16s ease;
}
.boardCard:hover {
transform: translateY(-2px);
background: rgba(70, 70, 70, 0.96);
background: var(--theme-card-bg-hover);
}
.boardCard__body {
border: 0;
@@ -188,10 +188,10 @@ watch(
object-fit: cover;
}
.boardCard__thumbPlaceholder {
background: #555;
background: var(--theme-thumb-fallback-bg);
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
color: var(--theme-text-faint);
font-size: 13px;
font-weight: 700;
}
@@ -238,7 +238,7 @@ watch(
height: 22px;
border-radius: 9999px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
background: var(--theme-border);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
@@ -251,7 +251,7 @@ watch(
.favoriteStat {
flex: 0 0 auto;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
color: var(--theme-text-faint);
white-space: nowrap;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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": "",

View File

@@ -1,9 +0,0 @@
# Update Log Entry Point
이 프로젝트의 상세 업데이트 로그는 [docs/update.md](/Users/bicute/Desktop/zenn.dev/tier-cursor/docs/update.md)에 계속 누적됩니다.
## 2026-03-30
- 루트 `package.json`에 공용 실행 스크립트(`dev:frontend`, `dev:backend`, `build`, `start`)를 추가했습니다.
- 루트에서도 바로 `npm run build` 같은 공용 명령을 사용할 수 있게 정리했습니다.
- 업데이트 로그 진입점을 루트 `update.md`로 추가해, 이후 작업 시 파일 위치를 바로 찾을 수 있게 했습니다.