Compare commits

..

5 Commits

15 changed files with 622 additions and 134 deletions

View File

@@ -73,6 +73,7 @@ function mapGameRow(row) {
id: row.id, id: row.id,
name: row.name, name: row.name,
thumbnailSrc: row.thumbnail_src || '', thumbnailSrc: row.thumbnail_src || '',
isPublic: row.is_public == null ? true : !!row.is_public,
displayRank: row.display_rank == null ? null : Number(row.display_rank), displayRank: row.display_rank == null ? null : Number(row.display_rank),
createdAt: Number(row.created_at), createdAt: Number(row.created_at),
} }
@@ -256,11 +257,18 @@ async function ensureSchema() {
id VARCHAR(120) PRIMARY KEY, id VARCHAR(120) PRIMARY KEY,
name VARCHAR(120) NOT NULL, name VARCHAR(120) NOT NULL,
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '', thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
is_public TINYINT(1) NOT NULL DEFAULT 1,
display_rank INT NULL DEFAULT NULL, display_rank INT NULL DEFAULT NULL,
created_at BIGINT NOT NULL created_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`) `)
const gameIsPublicColumns = await query("SHOW COLUMNS FROM games LIKE 'is_public'")
if (!gameIsPublicColumns.length) {
await query('ALTER TABLE games ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER thumbnail_src')
await query('UPDATE games SET is_public = 1 WHERE is_public IS NULL')
}
const displayRankColumns = await query("SHOW COLUMNS FROM games LIKE 'display_rank'") const displayRankColumns = await query("SHOW COLUMNS FROM games LIKE 'display_rank'")
if (!displayRankColumns.length) { if (!displayRankColumns.length) {
await query('ALTER TABLE games ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src') await query('ALTER TABLE games ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src')
@@ -643,12 +651,14 @@ async function adminDeleteUser(id) {
await query('DELETE FROM users WHERE id = ?', [id]) await query('DELETE FROM users WHERE id = ?', [id])
} }
async function listGames(currentUserId = '') { async function listGames(currentUserId = '', options = {}) {
const includePrivate = !!options.includePrivate
const rows = await query( const rows = await query(
` `
SELECT id, name, thumbnail_src, display_rank, created_at SELECT id, name, thumbnail_src, is_public, display_rank, created_at
FROM games FROM games
WHERE id <> ? WHERE id <> ?
${includePrivate ? '' : 'AND is_public = 1'}
ORDER BY ORDER BY
CASE WHEN display_rank IS NULL THEN 1 ELSE 0 END ASC, CASE WHEN display_rank IS NULL THEN 1 ELSE 0 END ASC,
display_rank ASC, display_rank ASC,
@@ -669,7 +679,7 @@ async function listGames(currentUserId = '') {
} }
async function findGameById(id) { async function findGameById(id) {
const rows = await query('SELECT id, name, thumbnail_src, display_rank, created_at FROM games WHERE id = ? LIMIT 1', [id]) const rows = await query('SELECT id, name, thumbnail_src, is_public, display_rank, created_at FROM games WHERE id = ? LIMIT 1', [id])
return mapGameRow(rows[0]) return mapGameRow(rows[0])
} }
@@ -702,11 +712,12 @@ async function getGameDetail(gameId) {
return { game, items } return { game, items }
} }
async function createGame({ id, name }) { async function createGame({ id, name, isPublic = true }) {
await query('INSERT INTO games (id, name, thumbnail_src, display_rank, created_at) VALUES (?, ?, ?, ?, ?)', [ await query('INSERT INTO games (id, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
id, id,
name, name,
'', '',
isPublic ? 1 : 0,
null, null,
now(), now(),
]) ])
@@ -718,6 +729,11 @@ async function updateGameThumbnail(gameId, thumbnailSrc) {
return findGameById(gameId) return findGameById(gameId)
} }
async function updateGameVisibility(gameId, isPublic) {
await query('UPDATE games SET is_public = ? WHERE id = ?', [isPublic ? 1 : 0, gameId])
return findGameById(gameId)
}
async function findImageAssetByHash(contentHash) { async function findImageAssetByHash(contentHash) {
const rows = await query( 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 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',
@@ -1306,7 +1322,7 @@ async function getCustomItemUsageMeta() {
} }
} }
async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) { async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMode = 'all' } = {}) {
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200) const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
const normalizedPage = Math.max(Number(page) || 1, 1) const normalizedPage = Math.max(Number(page) || 1, 1)
const searchText = (queryText || '').trim() const searchText = (queryText || '').trim()
@@ -1430,8 +1446,20 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
const allItems = [...customItems, ...templateItems, ...assetLibraryItems] const allItems = [...customItems, ...templateItems, ...assetLibraryItems]
.filter((item) => { .filter((item) => {
if (!orphanOnly) return true switch (filterMode) {
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0 case 'user':
return item.sourceType === 'user'
case 'template':
return item.sourceType === 'template' && !item.isAssetLibraryItem
case 'asset':
return !!item.isAssetLibraryItem
case 'unused-user':
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0
case 'unused-admin':
return !!item.isAssetLibraryItem
default:
return true
}
}) })
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0)) .sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
@@ -2144,6 +2172,7 @@ module.exports = {
getGameDetail, getGameDetail,
createGame, createGame,
updateGameThumbnail, updateGameThumbnail,
updateGameVisibility,
findImageAssetByHash, findImageAssetByHash,
findImageAssetBySrc, findImageAssetBySrc,
findImageAssetById, findImageAssetById,

View File

@@ -14,6 +14,7 @@ const {
createGame, createGame,
listGames, listGames,
updateGameThumbnail, updateGameThumbnail,
updateGameVisibility,
createGameItem, createGameItem,
updateGameItemLabel, updateGameItemLabel,
updateGameItemDisplayOrder, updateGameItemDisplayOrder,
@@ -109,13 +110,14 @@ router.post('/games', requireAdmin, async (req, res) => {
const schema = z.object({ const schema = z.object({
id: z.string().min(1), id: z.string().min(1),
name: z.string().min(1).max(60), name: z.string().min(1).max(60),
isPublic: z.boolean().optional().default(false),
thumbnailSrc: z.string().max(255).optional().default(''), thumbnailSrc: z.string().max(255).optional().default(''),
}) })
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findGameById(parsed.data.id) const exists = await findGameById(parsed.data.id)
if (exists) return res.status(409).json({ error: 'game_id_taken' }) if (exists) return res.status(409).json({ error: 'game_id_taken' })
const game = await createGame({ id: parsed.data.id, name: parsed.data.name }) const game = await createGame({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic })
if (parsed.data.thumbnailSrc) { if (parsed.data.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc) const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
await updateGameThumbnail(game.id, copiedThumb) await updateGameThumbnail(game.id, copiedThumb)
@@ -123,6 +125,20 @@ router.post('/games', requireAdmin, async (req, res) => {
res.json({ game: await findGameById(game.id) }) res.json({ game: await findGameById(game.id) })
}) })
router.patch('/games/:gameId', requireAdmin, async (req, res) => {
const schema = z.object({
isPublic: z.boolean(),
})
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 updated = await updateGameVisibility(game.id, parsed.data.isPublic)
res.json({ game: updated })
})
router.patch('/games/display-order', requireAdmin, async (req, res) => { router.patch('/games/display-order', requireAdmin, async (req, res) => {
const schema = z.object({ const schema = z.object({
gameIds: z.array(z.string().min(1)).max(50), gameIds: z.array(z.string().min(1)).max(50),
@@ -130,7 +146,7 @@ router.patch('/games/display-order', requireAdmin, async (req, res) => {
const parsed = schema.safeParse(req.body) const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const games = await listGames() const games = await listGames('', { includePrivate: true })
const validGameIds = new Set(games.map((game) => game.id)) const validGameIds = new Set(games.map((game) => game.id))
const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId)) const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId))
const updatedGames = await updateGameDisplayOrder(filteredIds) const updatedGames = await updateGameDisplayOrder(filteredIds)
@@ -261,11 +277,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
q: z.string().trim().max(120).optional().default(''), q: z.string().trim().max(120).optional().default(''),
page: z.coerce.number().int().min(1).optional().default(1), page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(200).optional().default(50), limit: z.coerce.number().int().min(1).max(200).optional().default(50),
orphanOnly: z filter: z.enum(['all', 'user', 'template', 'asset', 'unused-user', 'unused-admin']).optional().default('all'),
.union([z.literal('true'), z.literal('false'), z.boolean()])
.optional()
.default('false')
.transform((value) => value === true || value === 'true'),
}) })
const parsed = schema.safeParse(req.query) const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -274,7 +286,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
queryText: parsed.data.q, queryText: parsed.data.q,
page: parsed.data.page, page: parsed.data.page,
limit: parsed.data.limit, limit: parsed.data.limit,
orphanOnly: parsed.data.orphanOnly, filterMode: parsed.data.filter,
}) })
res.json(result) res.json(result)
}) })
@@ -516,7 +528,7 @@ function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}
} }
async function createGameTemplateFromTierList({ tierList, gameId, gameName }) { async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
await createGame({ id: gameId, name: gameName }) await createGame({ id: gameId, name: gameName, isPublic: false })
if (tierList.thumbnailSrc) { if (tierList.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc) const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc)
await updateGameThumbnail(gameId, copiedThumb) await updateGameThumbnail(gameId, copiedThumb)
@@ -539,7 +551,7 @@ async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
} }
async function createGameTemplateFromRequest({ templateRequest, gameId, gameName }) { async function createGameTemplateFromRequest({ templateRequest, gameId, gameName }) {
await createGame({ id: gameId, name: gameName }) await createGame({ id: gameId, name: gameName, isPublic: false })
if (templateRequest.thumbnailSrc) { if (templateRequest.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc) const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc)
@@ -555,7 +567,7 @@ async function createGameTemplateFromRequest({ templateRequest, gameId, gameName
} }
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => { router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
const result = await listCustomItems({ page: 1, limit: 10000, orphanOnly: false }) const result = await listCustomItems({ page: 1, limit: 10000, filterMode: 'all' })
const target = result.items.find((item) => item.id === req.params.itemId) const target = result.items.find((item) => item.id === req.params.itemId)
if (!target) return res.status(404).json({ error: 'not_found' }) if (!target) return res.status(404).json({ error: 'not_found' })
if (target.sourceType === 'template') { if (target.sourceType === 'template') {
@@ -697,12 +709,19 @@ router.post('/template-requests/:requestId/approve', requireAdmin, async (req, r
}) })
router.post('/template-requests/:requestId/review', requireAdmin, async (req, res) => { router.post('/template-requests/:requestId/review', requireAdmin, async (req, res) => {
const templateRequest = await findTemplateRequestById(req.params.requestId) let templateRequest = await findTemplateRequestById(req.params.requestId)
if (!templateRequest) return res.status(404).json({ error: 'not_found' }) if (!templateRequest) return res.status(404).json({ error: 'not_found' })
if (templateRequest.status === 'completed' || templateRequest.status === 'rejected' || templateRequest.status === 'approved') { if (templateRequest.status === 'completed' || templateRequest.status === 'rejected' || templateRequest.status === 'approved') {
return res.status(409).json({ error: 'request_already_handled' }) return res.status(409).json({ error: 'request_already_handled' })
} }
if (templateRequest.type === 'create' && templateRequest.targetGameId && !templateRequest.targetGameName) {
templateRequest = await updateTemplateRequestTargetGame({
id: templateRequest.id,
targetGameId: '',
})
}
if (templateRequest.status === 'reviewing') { if (templateRequest.status === 'reviewing') {
return res.json({ request: templateRequest }) return res.json({ request: templateRequest })
} }

View File

@@ -5,7 +5,7 @@ const { requireAuth } = require('../middleware/auth')
const router = express.Router() const router = express.Router()
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
const games = await listGames(req.session?.userId || '') const games = await listGames(req.session?.userId || '', { includePrivate: !!req.session?.isAdmin })
res.json({ games }) res.json({ games })
}) })
@@ -30,6 +30,7 @@ router.delete('/:gameId/favorite', requireAuth, async (req, res) => {
router.get('/:gameId', async (req, res) => { router.get('/:gameId', async (req, res) => {
const detail = await getGameDetail(req.params.gameId) const detail = await getGameDetail(req.params.gameId)
if (!detail) return res.status(404).json({ error: 'not_found' }) if (!detail) return res.status(404).json({ error: 'not_found' })
if (!detail.game.isPublic && !req.session?.isAdmin) return res.status(404).json({ error: 'not_found' })
res.json({ game: detail.game, items: detail.items }) res.json({ game: detail.game, items: detail.items })
}) })

View File

@@ -1,5 +1,25 @@
# 의사결정 이력 # 의사결정 이력
## 2026-04-02 v1.3.64
- 관리자 이미지 최적화 기록은 “재사용” 자체보다 이번 업로드와 재사용 자산의 관계가 더 중요하므로, 용량 숫자와 설명을 함께 보여주는 편이 운영자가 오해하지 않기 쉽다고 정리했다.
- 관리자 아이템 라이브러리는 `사용자 업로드 미사용만` 같은 단일 체크박스보다 출처와 사용 상태를 함께 고를 수 있는 필터 모드가 더 실용적이므로, 사용자·템플릿·보관 자산·미사용 상태를 분리해 보는 구조가 맞다고 판단했다.
- 게임 목록이 커질수록 선택 게임 설정을 사이드바 하단에 두는 구조는 스크롤 부담이 커지므로, 공개 상태와 썸네일 관리 액션은 선택된 게임 본문 상단 카드로 올리는 편이 더 안정적이라고 정리했다.
## 2026-04-02 v1.3.63
- 이미지 최적화 기록은 내부 라우트 카테고리를 그대로 보여주면 운영자가 실제 의미를 해석해야 하므로, 관리자 화면에는 기능 기준의 한국어 라벨과 재사용 여부를 함께 보여주는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.62
- 커스텀 이미지가 많은 상태에서 저장할 때 사용자 체감 순서가 흔들리는 것은 업로드 성공보다 더 직접적인 UX 문제이므로, 내부 객체 키 순서가 아니라 현재 화면 배치 순서를 저장 기준으로 삼는 편이 맞다고 정리했다.
- 템플릿 요청이 저장본에서만 가능하다면 삭제도 같은 기준을 따르는 편이 흐름상 자연스러우므로, 저장되지 않은 초안에는 삭제 액션을 노출하지 않는 쪽으로 판단했다.
## 2026-04-02 v1.3.61
- 업로드 드롭존은 기능만 같고 생김새가 다르면 운영자와 사용자 모두 맥락 전환 비용이 생기므로, 관리자와 에디터에서 같은 아이콘·점선 보더·버튼 문법으로 읽히게 맞추는 편이 낫다고 정리했다.
- 썸네일 교체 영역은 일반 입력 필드처럼 보이면 클릭 가능성이 떨어지므로, 이미지 미리보기 위에서도 업로드 박스라는 인상이 유지되게 밝은 배경과 아이콘을 함께 쓰는 쪽으로 판단했다.
## 2026-04-02 v1.3.60
- 관리자 접근 차단은 유지하되, 이미 로그인된 관리자가 새로고침할 때 홈으로 튕기는 체감은 권한 제어보다 더 큰 문제이므로 인증 결과가 나올 때까지 같은 세션 확인 요청을 기다리는 편이 맞다고 정리했다.
- 관리자 썸네일 드롭존과 에디터 보드 드롭존은 기능은 같아도 현재 상태가 문구와 형태로 바로 드러나야 하므로, 빈 상태와 교체 상태를 텍스트로 구분하고 점선 박스 형태를 더 적극적으로 드러내는 쪽으로 판단했다.
## 2026-04-02 v1.3.59 ## 2026-04-02 v1.3.59
- 템플릿 요청 아이템 반영은 관리자 주의에만 맡기기보다, 서버에서 같은 게임 안의 동일 `src` 중복 생성을 막고 프런트에서도 이미 반영된 요청 아이템을 다시 드래프트에 올리지 않는 편이 운영 안정성에 더 낫다고 정리했다. - 템플릿 요청 아이템 반영은 관리자 주의에만 맡기기보다, 서버에서 같은 게임 안의 동일 `src` 중복 생성을 막고 프런트에서도 이미 반영된 요청 아이템을 다시 드래프트에 올리지 않는 편이 운영 안정성에 더 낫다고 정리했다.
- 신규 템플릿 요청은 “완전한 임시 게임” 구조로 바로 바꾸기보다, 우선 한 번 만든 게임을 요청과 연결해 다시 `확인하기`를 눌러도 같은 게임을 재사용하게 만드는 편이 리스크 대비 효과가 크다고 판단했다. - 신규 템플릿 요청은 “완전한 임시 게임” 구조로 바로 바꾸기보다, 우선 한 번 만든 게임을 요청과 연결해 다시 `확인하기`를 눌러도 같은 게임을 재사용하게 만드는 편이 리스크 대비 효과가 크다고 판단했다.

View File

@@ -1,5 +1,13 @@
# 할 일 및 이슈 # 할 일 및 이슈
## 단기 확인
- 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다.
- 티어표 만들기 화면의 보드 드롭존은 점선/높이/중앙 정렬로 존재감이 커졌으므로, 데스크톱과 모바일에서 파일 선택 버튼과 안내 문구 밀도를 한 번 더 QA한다.
- 관리자 썸네일 드롭존과 기본 아이템 추가 드롭존은 에디터 드롭존과 시각 문법을 맞췄으므로, 라이트모드와 좁은 화면에서 아이콘 대비와 배경 밝기가 과하지 않은지 한 번 더 QA한다.
- 커스텀 이미지가 많은 티어표를 저장할 때 커스텀 이름 정리 목록과 미배치 아이템 영역 순서가 실제로 안정적으로 유지되는지, 10장 이상 업로드한 상태로 한 번 더 QA한다.
- 관리자 이미지 최적화 현황은 최근 작업 라벨을 읽기 쉬운 이름으로 바꿨으므로, 실제 운영 데이터에서 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타` 흐름이 기대와 맞게 찍히는지 한 번 더 QA한다.
- 관리자 게임 설정 카드를 본문 상단으로 옮겼으므로, 긴 게임 목록 상태에서 선택-썸네일 교체-공개 전환-삭제 흐름이 스크롤 없이 자연스러운지 한 번 더 QA한다.
## 중기 개선 ## 중기 개선
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다. - 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
- 관리자 템플릿 요청은 동일 `src` 중복 생성 방지와 `연결된 게임/이미 반영 n개` 표시까지 붙였으므로, 이후에는 요청별 반영 이력(예: 어떤 아이템을 언제 반영했는지)까지 별도로 남길지 검토한다. - 관리자 템플릿 요청은 동일 `src` 중복 생성 방지와 `연결된 게임/이미 반영 n개` 표시까지 붙였으므로, 이후에는 요청별 반영 이력(예: 어떤 아이템을 언제 반영했는지)까지 별도로 남길지 검토한다.

View File

@@ -1,5 +1,30 @@
# 업데이트 로그 # 업데이트 로그
## 2026-04-02 v1.3.64
- 관리자 이미지 최적화 최근 작업에서 `기존 최적화 파일 재사용` 건은 이제 `이번 업로드 용량 · 재사용 자산 용량` 형태로 표시하고, 동일한 최적화 결과가 이미 있어 새 파일을 다시 만들지 않았다는 설명을 함께 보여 혼동을 줄임.
- 관리자 아이템 관리는 기존의 `미사용 사용자 업로드만 보기` 체크박스 대신 `전체 / 사용자 업로드 / 템플릿 사용 이미지 / 관리자 보관 자산 / 미사용 사용자 / 미사용 관리자` 필터로 바꿔, 관리자 업로드 자산도 별도로 확인할 수 있게 정리함.
- 게임 관리의 선택된 게임 설정은 더 이상 우측 사이드바 아래쪽에 쌓지 않고, 본문 상단에 썸네일과 공개 상태·썸네일 적용·게임 삭제 액션을 함께 둔 카드로 옮겨 게임 목록이 많아져도 작업 영역을 더 안정적으로 읽을 수 있게 조정함.
## 2026-04-02 v1.3.63
- 관리자 이미지 최적화 최근 작업 목록은 더 이상 내부 카테고리 문자열 `custom / tierlists / games / avatars`를 그대로 노출하지 않고, 각각 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타`처럼 사람이 이해할 수 있는 이름으로 표시함.
- 같은 이미지 해시를 다시 업로드해 기존 최적화 파일을 재사용한 경우에는 최근 작업 목록에 `기존 최적화 파일 재사용` 문구를 함께 보여, 새로 압축된 건지 중복 자산이 재사용된 건지 운영자가 바로 구분할 수 있게 함.
## 2026-04-02 v1.3.62
- 티어표 저장과 템플릿 요청 전 커스텀 이미지 업로드에서는 더 이상 `itemsById` 객체 키 순서에 기대지 않고, 실제 화면에 보이는 `아이템 영역 + 보드 배치 순서` 기준으로 아이템 배열을 만들도록 바꿔 저장 중 이미지 목록이 흔들리던 현상을 줄임.
- 따라서 커스텀 아이템 이름 정리 목록, 저장 payload, 템플릿 요청 payload 모두 같은 순서 기준을 공유하게 되어, 이미지를 여러 장 올린 뒤 저장해도 사용자가 보고 있던 흐름이 덜 흔들리도록 정리함.
- 티어표 삭제 버튼은 이제 템플릿 요청과 같은 기준으로 `저장된 티어표`에서만 노출되며, 실제 삭제도 저장본 ID가 있을 때만 동작하도록 맞춰 저장 전 초안 상태의 어색한 삭제 액션을 제거함.
## 2026-04-02 v1.3.61
- 관리자 게임 관리의 썸네일 드롭존, 관리자 기본 아이템 추가 드롭존, 티어표 에디터의 커스텀 이미지 드롭존에 `add_photo_alternate` 아이콘을 넣어 업로드 영역임을 더 빠르게 인식할 수 있게 정리함.
- 관리자와 에디터 드롭존은 점선 보더 굵기, 라운드, 밝은 배경 톤, 활성화 상태 색 변화, 파일 선택 버튼 크기를 같은 계열로 맞춰 서로 다른 화면에서도 같은 업로드 컴포넌트처럼 읽히도록 통일함.
- 썸네일 드롭존 역시 배경을 일반 입력 필드보다 더 밝고 넓은 업로드 박스처럼 보이게 조정해, 일반 폼 필드와 대표 이미지 교체 영역을 시각적으로 더 분명하게 구분함.
## 2026-04-02 v1.3.60
- 관리자 게임 관리의 대표 썸네일 드롭존은 이제 썸네일이 없을 때는 `클릭 & 드래그`, 이미 등록된 썸네일이 있을 때는 `썸네일 변경`으로 문구가 바뀌어 현재 동작을 더 바로 읽을 수 있게 함.
- 관리자 인증 상태는 라우터 가드와 앱 셸이 동시에 `/api/auth/me`를 호출할 때, 가드가 아직 끝나지 않은 요청을 기다리지 못해 새로고침 직후 홈으로 튕기던 흐름이 있었으므로 인증 스토어에서 진행 중인 `refresh` Promise를 재사용하도록 정리함.
- 따라서 관리자 계정으로 로그인된 상태에서는 `/admin/...` 경로를 새로고침해도 세션 확인이 끝날 때까지 같은 요청을 기다린 뒤 관리자 화면에 남도록 안정성을 보강함.
- 티어표 만들기 화면의 보드 드롭존은 점선 테두리, 더 높은 박스, 중앙 정렬된 안내 문구와 버튼을 적용해 커스텀 이미지 추가 영역임을 더 즉시 인식할 수 있게 조정함.
## 2026-04-02 v1.3.59 ## 2026-04-02 v1.3.59
- 관리자 템플릿 요청의 `promote-items` 처리에서는 잘못된 `z.record` 스키마 때문에 500이 나던 서버 파싱 버그를 수정하고, 요청 아이템 `src`까지 함께 받아 실제 요청 데이터와 더 안정적으로 매칭하도록 보강함. - 관리자 템플릿 요청의 `promote-items` 처리에서는 잘못된 `z.record` 스키마 때문에 500이 나던 서버 파싱 버그를 수정하고, 요청 아이템 `src`까지 함께 받아 실제 요청 데이터와 더 안정적으로 매칭하도록 보강함.
- 요청 아이템을 게임에 반영할 때는 이제 같은 게임 안에 동일한 `src`가 이미 있으면 새 기본 아이템을 다시 만들지 않도록 막고, 관리자 화면에서도 이미 반영된 요청 아이템은 드래프트에 다시 올리지 않게 정리함. - 요청 아이템을 게임에 반영할 때는 이제 같은 게임 안에 동일한 `src`가 이미 있으면 새 기본 아이템을 다시 만들지 않도록 막고, 관리자 화면에서도 이미 반영된 요청 아이템은 드래프트에 다시 올리지 않게 정리함.

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="M480-480ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h320v80H200v560h560v-320h80v320q0 33-23.5 56.5T760-120H200Zm40-160h480L570-480 450-320l-90-120-120 160Zm440-320v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 347 B

View File

@@ -1,5 +1,7 @@
<script setup> <script setup>
import { toApiUrl } from '../../lib/runtime' import { toApiUrl } from '../../lib/runtime'
import SvgIcon from '../SvgIcon.vue'
import addPhotoAlternateIcon from '../../assets/icons/add_photo_alternate.svg'
const props = defineProps({ const props = defineProps({
activeTemplateRequest: { type: Object, default: null }, activeTemplateRequest: { type: Object, default: null },
@@ -10,6 +12,20 @@ const props = defineProps({
isGameLoading: { type: Boolean, required: true }, isGameLoading: { type: Boolean, required: true },
hasSelectedGame: { type: Boolean, required: true }, hasSelectedGame: { type: Boolean, required: true },
selectedGame: { type: Object, default: null }, selectedGame: { type: Object, default: null },
displayThumbnailUrl: { type: String, default: '' },
canApplyThumbnail: { type: Boolean, required: true },
gameVisibilitySaving: { type: Boolean, required: true },
thumbFileInputRef: { type: Function, required: true },
openThumbFilePicker: { type: Function, required: true },
onThumb: { type: Function, required: true },
onThumbDragEnter: { type: Function, required: true },
onThumbDragOver: { type: Function, required: true },
onThumbDragLeave: { type: Function, required: true },
onThumbDrop: { type: Function, required: true },
isThumbDragOver: { type: Boolean, required: true },
uploadThumbnail: { type: Function, required: true },
removeGame: { type: Function, required: true },
toggleSelectedGameVisibility: { type: Function, required: true },
itemFileInputRef: { type: Function, required: true }, itemFileInputRef: { type: Function, required: true },
onFile: { type: Function, required: true }, onFile: { type: Function, required: true },
isItemDragOver: { type: Boolean, required: true }, isItemDragOver: { type: Boolean, required: true },
@@ -34,6 +50,10 @@ const props = defineProps({
function setGameItemListElement(el) { function setGameItemListElement(el) {
props.gameItemListRef(el) props.gameItemListRef(el)
} }
function setThumbFileElement(el) {
props.thumbFileInputRef(el)
}
</script> </script>
<template> <template>
@@ -89,13 +109,43 @@ function setGameItemListElement(el) {
</div> </div>
</div> </div>
<div v-else-if="props.hasSelectedGame" class="panel"> <div v-else-if="props.hasSelectedGame" class="panel">
<div class="detailHead"> <section class="adminCard gameSettingsCard">
<div> <div class="gameSettingsCard__media">
<div class="panel__title">선택된 게임 정보</div> <input :ref="setThumbFileElement" type="file" accept="image/*" class="srOnlyInput" @change="props.onThumb" />
<div class="selectedGame__name">{{ props.selectedGame.game.name }}</div> <button
<div class="selectedGame__id">{{ props.selectedGame.game.id }}</div> class="thumbDropZone"
:class="{ 'thumbDropZone--active': props.isThumbDragOver }"
type="button"
@click="props.openThumbFilePicker"
@dragenter="props.onThumbDragEnter"
@dragover="props.onThumbDragOver"
@dragleave="props.onThumbDragLeave"
@drop="props.onThumbDrop"
>
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedGame.game.name" />
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
<div class="thumbDropZone__copy">
<div class="thumbDropZone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="thumbDropZone__icon" />
</div>
<div class="thumbDropZone__title">{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
</div>
</button>
</div> </div>
</div> <div class="gameSettingsCard__body">
<div class="panel__title">게임 설정</div>
<div class="gameSettingsCard__meta">{{ props.selectedGame.game.name }} · {{ props.selectedGame.game.id }}</div>
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.gameVisibilitySaving }">
<input :checked="!!props.selectedGame.game.isPublic" type="checkbox" @change="props.toggleSelectedGameVisibility($event.target.checked)" />
<span class="toggleSwitch__label">{{ props.selectedGame.game.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<div class="gameSettingsCard__actions">
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
<button class="btn btn--danger" @click="props.removeGame">게임 삭제</button>
</div>
</div>
</section>
<div class="section"> <div class="section">
<section class="adminCard"> <section class="adminCard">
@@ -112,13 +162,16 @@ function setGameItemListElement(el) {
@dragleave="props.onItemDragLeave" @dragleave="props.onItemDragLeave"
@drop="props.onItemDrop" @drop="props.onItemDrop"
> >
<div class="dropZone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="dropZone__icon" />
</div>
<div class="dropZone__title">이미지를 드래그해서 기본 아이템으로 추가</div> <div class="dropZone__title">이미지를 드래그해서 기본 아이템으로 추가</div>
<div class="dropZone__desc"> <div class="dropZone__desc">
여러 파일을 번에 올릴 있고, 저장 라벨은 파일명으로 자동 생성됩니다. 여러 파일을 번에 올릴 있고, 저장 라벨은 파일명으로 자동 생성됩니다.
<span v-if="props.stagedRequestDraftCount"> 현재 요청에서 가져온 아이템 {{ props.stagedRequestDraftCount }}개도 함께 검토 중이에요.</span> <span v-if="props.stagedRequestDraftCount"> 현재 요청에서 가져온 아이템 {{ props.stagedRequestDraftCount }}개도 함께 검토 중이에요.</span>
</div> </div>
<div class="dropZone__actions"> <div class="dropZone__actions">
<button class="btn btn--ghost btn--small" type="button" @click.stop="props.openItemFilePicker">파일 선택</button> <button class="btn btn--ghost btn--small dropZone__button" type="button" @click.stop="props.openItemFilePicker">파일 선택</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -8,7 +8,7 @@ export function useAdminCustomItems({
customItemLimit, customItemLimit,
customItemPageCount, customItemPageCount,
customItemQuery, customItemQuery,
customItemOrphanOnly, customItemFilter,
customItemModalOpen, customItemModalOpen,
customItemDeleteModalOpen, customItemDeleteModalOpen,
customItemModalHistoryActive, customItemModalHistoryActive,
@@ -33,7 +33,8 @@ export function useAdminCustomItems({
refreshCustomItems() refreshCustomItems()
} }
function toggleCustomItemOrphanOnly() { function changeCustomItemFilter(filter) {
customItemFilter.value = filter
customItemPage.value = 1 customItemPage.value = 1
refreshCustomItems() refreshCustomItems()
} }
@@ -186,7 +187,7 @@ export function useAdminCustomItems({
return { return {
submitCustomItemSearch, submitCustomItemSearch,
toggleCustomItemOrphanOnly, changeCustomItemFilter,
changeCustomItemLimit, changeCustomItemLimit,
moveCustomItemPage, moveCustomItemPage,
pushCustomItemModalHistoryState, pushCustomItemModalHistoryState,

View File

@@ -21,6 +21,7 @@ export function useAdminGameManager({
customItemModalTargetGameId, customItemModalTargetGameId,
newGameId, newGameId,
newGameName, newGameName,
newGameIsPublic,
clearPreviewUrl, clearPreviewUrl,
resetFileInput, resetFileInput,
resetUploadState, resetUploadState,
@@ -116,9 +117,10 @@ export function useAdminGameManager({
uploadFiles.value = uploadItemDrafts.value.filter((draft) => draft.kind === 'file').map((draft) => draft.file).filter(Boolean) uploadFiles.value = uploadItemDrafts.value.filter((draft) => draft.kind === 'file').map((draft) => draft.file).filter(Boolean)
} }
async function loadGame() { async function loadGame(options = {}) {
const preserveUploadState = !!options.preserveUploadState
resetMessages() resetMessages()
resetUploadState() if (!preserveUploadState) resetUploadState()
if (!selectedGameId.value) { if (!selectedGameId.value) {
selectedGame.value = null selectedGame.value = null
@@ -140,7 +142,6 @@ export function useAdminGameManager({
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id) savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
await syncGameItemSortable() await syncGameItemSortable()
} catch (e) { } catch (e) {
console.error('[AdminView] loadGame failed', selectedGameId.value, e)
selectedGame.value = null selectedGame.value = null
error.value = '게임 정보를 불러오지 못했어요.' error.value = '게임 정보를 불러오지 못했어요.'
} finally { } finally {
@@ -148,7 +149,10 @@ export function useAdminGameManager({
} }
} }
async function createGame() { async function createGame(options = {}) {
const nextGameId = typeof options.gameId === 'string' ? options.gameId.trim() : newGameId.value.trim()
const nextGameName = typeof options.gameName === 'string' ? options.gameName.trim() : newGameName.value.trim()
const preserveUploadState = !!options.preserveUploadState
resetMessages() resetMessages()
try { try {
const res = await fetch(toApiUrl('/api/admin/games'), { const res = await fetch(toApiUrl('/api/admin/games'), {
@@ -156,8 +160,9 @@ export function useAdminGameManager({
credentials: 'include', credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
id: newGameId.value.trim(), id: nextGameId,
name: newGameName.value.trim(), name: nextGameName,
isPublic: !!newGameIsPublic.value,
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '', thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
}), }),
}) })
@@ -171,14 +176,14 @@ export function useAdminGameManager({
activeTemplateRequest.value = { activeTemplateRequest.value = {
...activeTemplateRequest.value, ...activeTemplateRequest.value,
targetGameId: linkData.request?.targetGameId || data.game.id, targetGameId: linkData.request?.targetGameId || data.game.id,
targetGameName: linkData.request?.targetGameName || data.game.name || newGameName.value.trim(), targetGameName: linkData.request?.targetGameName || data.game.name || nextGameName,
} }
const requestIndex = templateRequests.value.findIndex((entry) => entry.id === activeTemplateRequest.value.id) const requestIndex = templateRequests.value.findIndex((entry) => entry.id === activeTemplateRequest.value.id)
if (requestIndex >= 0) { if (requestIndex >= 0) {
templateRequests.value.splice(requestIndex, 1, { templateRequests.value.splice(requestIndex, 1, {
...templateRequests.value[requestIndex], ...templateRequests.value[requestIndex],
targetGameId: linkData.request?.targetGameId || data.game.id, targetGameId: linkData.request?.targetGameId || data.game.id,
targetGameName: linkData.request?.targetGameName || data.game.name || newGameName.value.trim(), targetGameName: linkData.request?.targetGameName || data.game.name || nextGameName,
}) })
} }
} }
@@ -186,8 +191,8 @@ export function useAdminGameManager({
selectedGameId.value = data.game.id selectedGameId.value = data.game.id
if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id
closeGameCreateModal() closeGameCreateModal()
await loadGame() await loadGame({ preserveUploadState })
if (activeTemplateRequest.value?.id) { if (!preserveUploadState && activeTemplateRequest.value?.id) {
const sourceRequest = templateRequests.value.find((entry) => entry.id === activeTemplateRequest.value.id) || activeTemplateRequest.value const sourceRequest = templateRequests.value.find((entry) => entry.id === activeTemplateRequest.value.id) || activeTemplateRequest.value
mergeRequestItemsIntoDrafts(sourceRequest) mergeRequestItemsIntoDrafts(sourceRequest)
} }
@@ -243,12 +248,31 @@ export function useAdminGameManager({
async function uploadItem() { async function uploadItem() {
resetMessages() resetMessages()
if (!uploadItemDrafts.value.length || !selectedGameId.value) { if (!uploadItemDrafts.value.length) {
error.value = '아이템 파일을 선택해주세요.' error.value = '아이템 파일을 선택해주세요.'
return return
} }
try { try {
if (!selectedGameId.value && activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetGameId) {
const draftGameId = (activeTemplateRequest.value?.draftGameId || '').trim()
const draftGameName = (activeTemplateRequest.value?.draftGameName || '').trim()
if (!draftGameId || !draftGameName) {
error.value = '먼저 신규 템플릿의 게임 이름과 게임 ID를 저장해주세요.'
return
}
await createGame({
gameId: draftGameId,
gameName: draftGameName,
preserveUploadState: true,
})
}
if (!selectedGameId.value) {
error.value = '게임을 먼저 선택해주세요.'
return
}
const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file') const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file')
const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request') const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request')
let uploadCount = 0 let uploadCount = 0

View File

@@ -21,6 +21,7 @@ export function useAdminTemplateRequests({
thumbnailSrc: request.thumbnailSrc || '', thumbnailSrc: request.thumbnailSrc || '',
draftGameId: request.draftGameId || '', draftGameId: request.draftGameId || '',
draftGameName: request.draftGameName || '', draftGameName: request.draftGameName || '',
draftGameIsPublic: !!request.draftGameIsPublic,
sourceTierListId: request.sourceTierListId || '', sourceTierListId: request.sourceTierListId || '',
sourceGameId: request.sourceGameId || '', sourceGameId: request.sourceGameId || '',
sourceTierListTitle: request.sourceTierListTitle || '', sourceTierListTitle: request.sourceTierListTitle || '',
@@ -49,24 +50,32 @@ export function useAdminTemplateRequests({
try { try {
request.isHandling = true request.isHandling = true
const data = await api.startAdminTemplateRequestReview(request.id) const data = await api.startAdminTemplateRequestReview(request.id)
request.status = data.request?.status || 'reviewing' const syncedRequest = {
updateActiveTemplateRequest(request) ...request,
...(data.request || {}),
draftGameId: request.draftGameId || '',
draftGameName: request.draftGameName || '',
draftGameIsPublic: !!request.draftGameIsPublic,
}
Object.assign(request, syncedRequest)
request.status = syncedRequest.status || 'reviewing'
updateActiveTemplateRequest(syncedRequest)
setTab('game-admin') setTab('game-admin')
if (request.type === 'create') { if (request.type === 'create') {
const linkedGameId = request.targetGameId || '' const linkedGameId = syncedRequest.targetGameId || ''
if (linkedGameId) { if (linkedGameId) {
await selectAdminGame(linkedGameId) await selectAdminGame(linkedGameId)
} else { } else {
openGameCreateModal() openGameCreateModal()
newGameId.value = (request.draftGameId || '').trim() newGameId.value = (syncedRequest.draftGameId || '').trim()
newGameName.value = (request.draftGameName || '').trim() newGameName.value = (syncedRequest.draftGameName || '').trim()
} }
mergeRequestItemsIntoDrafts(request) mergeRequestItemsIntoDrafts(syncedRequest)
} else { } else {
const nextGameId = request.targetGameId || request.sourceGameId || '' const nextGameId = syncedRequest.targetGameId || syncedRequest.sourceGameId || ''
if (nextGameId) await selectAdminGame(nextGameId) if (nextGameId) await selectAdminGame(nextGameId)
mergeRequestItemsIntoDrafts(request) mergeRequestItemsIntoDrafts(syncedRequest)
} }
success.value = '요청 아이템을 게임 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.' success.value = '요청 아이템을 게임 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.'
} catch (e) { } catch (e) {

View File

@@ -37,11 +37,13 @@ export const api = {
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }), updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
updateAdminGameItemDisplayOrder: (gameId, payload) => updateAdminGameItemDisplayOrder: (gameId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/display-order`, { method: 'PATCH', body: payload }), request(`/api/admin/games/${encodeURIComponent(gameId)}/items/display-order`, { method: 'PATCH', body: payload }),
updateAdminGame: (gameId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}`, { method: 'PATCH', body: payload }),
updateAdminGameItem: (gameId, itemId, payload) => updateAdminGameItem: (gameId, itemId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }), request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
listAdminCustomItems: ({ q = '', page = 1, limit = 50, orphanOnly = false } = {}) => listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all' } = {}) =>
request( request(
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}` `/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
), ),
listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) => listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`), request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),

View File

@@ -1,6 +1,8 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { api } from '../lib/api' import { api } from '../lib/api'
let refreshPromise = null
export const useAuthStore = defineStore('auth', { export const useAuthStore = defineStore('auth', {
state: () => ({ state: () => ({
user: null, user: null,
@@ -9,19 +11,23 @@ export const useAuthStore = defineStore('auth', {
}), }),
actions: { actions: {
async refresh() { async refresh() {
if (this.status === 'loading') return this.user if (refreshPromise) return refreshPromise
this.status = 'loading' this.status = 'loading'
try { refreshPromise = (async () => {
const data = await api.me() try {
this.user = data.user const data = await api.me()
return this.user this.user = data.user
} catch (error) { return this.user
this.user = null } catch (error) {
return null this.user = null
} finally { return null
this.status = 'idle' } finally {
this.hydrated = true this.status = 'idle'
} this.hydrated = true
refreshPromise = null
}
})()
return refreshPromise
}, },
async signup(email, password) { async signup(email, password) {
const user = await api.signup({ email, password }) const user = await api.signup({ email, password })
@@ -42,4 +48,3 @@ export const useAuthStore = defineStore('auth', {
}, },
}, },
}) })

View File

@@ -42,7 +42,7 @@ const customItemQuery = ref('')
const customItemPage = ref(1) const customItemPage = ref(1)
const customItemLimit = ref(50) const customItemLimit = ref(50)
const customItemTotal = ref(0) const customItemTotal = ref(0)
const customItemOrphanOnly = ref(false) const customItemFilter = ref('all')
const customItemModalTargetGameId = ref('') const customItemModalTargetGameId = ref('')
const customItemModalGameQuery = ref('') const customItemModalGameQuery = ref('')
const customItemModalGameSort = ref('recent') const customItemModalGameSort = ref('recent')
@@ -96,6 +96,8 @@ const success = ref('')
const newGameId = ref('') const newGameId = ref('')
const newGameName = ref('') const newGameName = ref('')
const newGameIsPublic = ref(false)
const gameVisibilitySaving = ref(false)
const uploadFiles = ref([]) const uploadFiles = ref([])
const uploadItemDrafts = ref([]) const uploadItemDrafts = ref([])
@@ -125,6 +127,10 @@ function setItemFileInputRef(el) {
itemFileInput.value = el itemFileInput.value = el
} }
function setThumbFileInputRef(el) {
thumbFileInput.value = el
}
function scheduleGameItemSortableSync() { function scheduleGameItemSortableSync() {
if (gameItemSortableSyncTimer) { if (gameItemSortableSyncTimer) {
clearTimeout(gameItemSortableSyncTimer) clearTimeout(gameItemSortableSyncTimer)
@@ -442,7 +448,7 @@ watch(
if (tab === 'items') { if (tab === 'items') {
customItemQuery.value = '' customItemQuery.value = ''
customItemOrphanOnly.value = false customItemFilter.value = 'all'
customItemPage.value = 1 customItemPage.value = 1
customItemModalGameQuery.value = '' customItemModalGameQuery.value = ''
await refreshCustomItems() await refreshCustomItems()
@@ -520,6 +526,36 @@ function formatBytes(value) {
return `${current >= 10 || unitIndex === 0 ? current.toFixed(0) : current.toFixed(1)} ${units[unitIndex]}` return `${current >= 10 || unitIndex === 0 ? current.toFixed(0) : current.toFixed(1)} ${units[unitIndex]}`
} }
function formatImageJobSourceCategory(category) {
switch (String(category || '').trim()) {
case 'custom':
return '커스텀 아이템'
case 'tierlists':
return '티어표 썸네일'
case 'games':
return '게임/템플릿 이미지'
case 'avatars':
return '프로필 아바타'
default:
return '기타 이미지'
}
}
function formatImageJobStatus(status) {
switch (String(status || '').trim()) {
case 'queued':
return '대기'
case 'processing':
return '처리중'
case 'completed':
return '완료'
case 'failed':
return '실패'
default:
return status || '알 수 없음'
}
}
const imageDiagnosticsCards = computed(() => { const imageDiagnosticsCards = computed(() => {
const stats = imageStats.value const stats = imageStats.value
if (!stats) return [] if (!stats) return []
@@ -649,7 +685,7 @@ function setTab(tab) {
} }
if (tab === 'items') { if (tab === 'items') {
customItemQuery.value = '' customItemQuery.value = ''
customItemOrphanOnly.value = false customItemFilter.value = 'all'
customItemPage.value = 1 customItemPage.value = 1
refreshCustomItems() refreshCustomItems()
} }
@@ -662,8 +698,15 @@ function setTierlistsMode(mode) {
function openGameCreateModal() { function openGameCreateModal() {
resetMessages() resetMessages()
newGameId.value = '' if (activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetGameId) {
newGameName.value = '' newGameId.value = activeTemplateRequest.value?.draftGameId || ''
newGameName.value = activeTemplateRequest.value?.draftGameName || ''
newGameIsPublic.value = !!activeTemplateRequest.value?.draftGameIsPublic
} else {
newGameId.value = ''
newGameName.value = ''
newGameIsPublic.value = false
}
gameCreateModalOpen.value = true gameCreateModalOpen.value = true
} }
@@ -703,7 +746,7 @@ async function refreshCustomItems() {
q: customItemQuery.value, q: customItemQuery.value,
page: customItemPage.value, page: customItemPage.value,
limit: customItemLimit.value, limit: customItemLimit.value,
orphanOnly: customItemOrphanOnly.value, filter: customItemFilter.value,
}) })
customItems.value = data.items || [] customItems.value = data.items || []
customItemTotal.value = data.total || 0 customItemTotal.value = data.total || 0
@@ -745,6 +788,7 @@ async function refreshTemplateRequests() {
request.type === 'create' request.type === 'create'
? `${request.sourceTierListTitle || request.sourceGameName || '새 템플릿'}` ? `${request.sourceTierListTitle || request.sourceGameName || '새 템플릿'}`
: request.targetGameName || request.sourceGameName || '', : request.targetGameName || request.sourceGameName || '',
draftGameIsPublic: false,
})) }))
} catch (e) { } catch (e) {
error.value = '템플릿 요청 목록을 불러오지 못했어요.' error.value = '템플릿 요청 목록을 불러오지 못했어요.'
@@ -825,6 +869,7 @@ const {
customItemModalTargetGameId, customItemModalTargetGameId,
newGameId, newGameId,
newGameName, newGameName,
newGameIsPublic,
clearPreviewUrl, clearPreviewUrl,
resetFileInput, resetFileInput,
resetUploadState, resetUploadState,
@@ -858,7 +903,7 @@ const {
const { const {
submitCustomItemSearch, submitCustomItemSearch,
toggleCustomItemOrphanOnly, changeCustomItemFilter,
changeCustomItemLimit, changeCustomItemLimit,
moveCustomItemPage, moveCustomItemPage,
openCustomItemModal, openCustomItemModal,
@@ -878,7 +923,7 @@ const {
customItemLimit, customItemLimit,
customItemPageCount, customItemPageCount,
customItemQuery, customItemQuery,
customItemOrphanOnly, customItemFilter,
customItemModalOpen, customItemModalOpen,
customItemDeleteModalOpen, customItemDeleteModalOpen,
customItemModalHistoryActive, customItemModalHistoryActive,
@@ -1029,6 +1074,53 @@ async function uploadThumbnail() {
} }
} }
async function saveGameVisibility() {
if (!selectedGame.value?.game?.id) return
try {
gameVisibilitySaving.value = true
const data = await api.updateAdminGame(selectedGame.value.game.id, {
isPublic: !!selectedGame.value.game.isPublic,
})
selectedGame.value = {
...selectedGame.value,
game: {
...selectedGame.value.game,
...data.game,
},
}
await refreshGames()
success.value = data.game?.isPublic ? '게임을 공개 상태로 전환했어요.' : '게임을 비공개 상태로 전환했어요.'
return true
} catch (e) {
error.value = '게임 공개 상태를 저장하지 못했어요.'
return false
} finally {
gameVisibilitySaving.value = false
}
}
async function toggleSelectedGameVisibility(nextValue) {
if (!selectedGame.value?.game?.id || gameVisibilitySaving.value) return
const previous = !!selectedGame.value.game.isPublic
selectedGame.value = {
...selectedGame.value,
game: {
...selectedGame.value.game,
isPublic: !!nextValue,
},
}
const saved = await saveGameVisibility()
if (!saved) {
selectedGame.value = {
...selectedGame.value,
game: {
...selectedGame.value.game,
isPublic: previous,
},
}
}
}
async function removeGameItem(itemId) { async function removeGameItem(itemId) {
resetMessages() resetMessages()
try { try {
@@ -1394,6 +1486,20 @@ function userAvatarFallback(user) {
:is-game-loading="isGameLoading" :is-game-loading="isGameLoading"
:has-selected-game="hasSelectedGame" :has-selected-game="hasSelectedGame"
:selected-game="selectedGame" :selected-game="selectedGame"
:display-thumbnail-url="displayThumbnailUrl"
:can-apply-thumbnail="canApplyThumbnail"
:game-visibility-saving="gameVisibilitySaving"
:thumb-file-input-ref="setThumbFileInputRef"
:open-thumb-file-picker="openThumbFilePicker"
:on-thumb="onThumb"
:on-thumb-drag-enter="onThumbDragEnter"
:on-thumb-drag-over="onThumbDragOver"
:on-thumb-drag-leave="onThumbDragLeave"
:on-thumb-drop="onThumbDrop"
:is-thumb-drag-over="isThumbDragOver"
:upload-thumbnail="uploadThumbnail"
:remove-game="removeGame"
:toggle-selected-game-visibility="toggleSelectedGameVisibility"
:item-file-input-ref="setItemFileInputRef" :item-file-input-ref="setItemFileInputRef"
:on-file="onFile" :on-file="onFile"
:is-item-drag-over="isItemDragOver" :is-item-drag-over="isItemDragOver"
@@ -1496,6 +1602,11 @@ function userAvatarFallback(user) {
/> />
<span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newGameId.length }}/120</span> <span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newGameId.length }}/120</span>
</label> </label>
<label class="toggleSwitch">
<input v-model="newGameIsPublic" type="checkbox" />
<span class="toggleSwitch__label">{{ newGameIsPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
</div> </div>
<div class="modalCard__actions"> <div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeGameCreateModal">취소</button> <button class="btn btn--ghost" @click="closeGameCreateModal">취소</button>
@@ -1850,31 +1961,6 @@ function userAvatarFallback(user) {
</div> </div>
<div v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div> <div v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div>
</div> </div>
<div v-if="hasSelectedGame" class="adminSidebar__group">
<div class="selectedGameSidebar__name">{{ selectedGame.game.name }}</div>
<div class="selectedGameSidebar__id">{{ selectedGame.game.id }}</div>
<input ref="thumbFileInput" type="file" accept="image/*" class="srOnlyInput" @change="onThumb" />
<button
class="thumbDropZone"
:class="{ 'thumbDropZone--active': isThumbDragOver }"
type="button"
@click="openThumbFilePicker"
@dragenter="onThumbDragEnter"
@dragover="onThumbDragOver"
@dragleave="onThumbDragLeave"
@drop="onThumbDrop"
>
<img v-if="displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="displayThumbnailUrl" :alt="selectedGame.game.name" />
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
<div class="thumbDropZone__copy">
<div class="thumbDropZone__title">클릭 or 드래그</div>
</div>
</button>
<div class="adminSidebar__actions adminSidebar__actions--stack">
<button class="btn" :disabled="!canApplyThumbnail" @click="uploadThumbnail">썸네일 적용</button>
<button class="btn btn--danger" @click="removeGame">게임 삭제</button>
</div>
</div>
</section> </section>
<section v-else-if="activeTab === 'items'" class="adminSidebar__panel"> <section v-else-if="activeTab === 'items'" class="adminSidebar__panel">
@@ -1888,14 +1974,18 @@ function userAvatarFallback(user) {
<option :value="50">50개씩 보기</option> <option :value="50">50개씩 보기</option>
<option :value="200">200개씩 보기</option> <option :value="200">200개씩 보기</option>
</select> </select>
<label class="checkRow checkRow--compact"> <select :value="customItemFilter" class="select" @change="changeCustomItemFilter($event.target.value)">
<input v-model="customItemOrphanOnly" type="checkbox" @change="toggleCustomItemOrphanOnly" /> <option value="all">전체 이미지</option>
<span>미사용 사용자 업로드 보기</span> <option value="user">사용자 업로드</option>
</label> <option value="template">템플릿 사용 이미지</option>
<option value="asset">관리자 보관 자산</option>
<option value="unused-user">미사용 사용자 업로드</option>
<option value="unused-admin">미사용 관리자 자산</option>
</select>
</div> </div>
<div class="adminSidebar__actions"> <div class="adminSidebar__actions">
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button> <button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
<button class="btn btn--danger" :disabled="!customItems.length" @click="removeUnusedCustomItems">미사용 이미지 일괄 삭제</button> <button class="btn btn--danger" :disabled="customItemFilter !== 'unused-user' || !customItems.length" @click="removeUnusedCustomItems">미사용 사용자 이미지 일괄 삭제</button>
</div> </div>
<div class="adminSidebar__stats"> <div class="adminSidebar__stats">
<div class="sidebarStat"> <div class="sidebarStat">
@@ -2007,10 +2097,17 @@ function userAvatarFallback(user) {
<div v-else class="imageJobList"> <div v-else class="imageJobList">
<article v-for="job in imageRecentJobs" :key="job.id" class="imageJobRow"> <article v-for="job in imageRecentJobs" :key="job.id" class="imageJobRow">
<div class="imageJobRow__head"> <div class="imageJobRow__head">
<strong>{{ job.sourceCategory || 'asset' }}</strong> <strong>{{ formatImageJobSourceCategory(job.sourceCategory) }}</strong>
<span class="imageJobRow__status">{{ job.status }}</span> <span class="imageJobRow__status">{{ formatImageJobStatus(job.status) }}</span>
</div> </div>
<div class="hint hint--tight">{{ formatBytes(job.originalByteSize) }} {{ formatBytes(job.optimizedByteSize) }}</div> <div class="hint hint--tight">
{{
job.reusedAsset
? `이번 업로드 ${formatBytes(job.originalByteSize)} · 재사용 자산 ${formatBytes(job.optimizedByteSize)}`
: `${formatBytes(job.originalByteSize)}${formatBytes(job.optimizedByteSize)}`
}}
</div>
<div v-if="job.reusedAsset" class="hint hint--tight">동일한 최적화 결과가 이미 있어 파일을 다시 만들지 않았어요.</div>
<div class="hint hint--tight">{{ fmt(job.queuedAt) }}</div> <div class="hint hint--tight">{{ fmt(job.queuedAt) }}</div>
</article> </article>
</div> </div>
@@ -2617,6 +2714,31 @@ function userAvatarFallback(user) {
opacity: 0.72; opacity: 0.72;
word-break: break-all; word-break: break-all;
} }
.adminUiScope .gameSettingsCard {
display: grid;
grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
gap: 18px;
align-items: center;
}
.adminUiScope .gameSettingsCard__media {
min-width: 0;
}
.adminUiScope .gameSettingsCard__body {
display: grid;
gap: 14px;
align-content: center;
}
.adminUiScope .gameSettingsCard__meta {
color: var(--theme-text-soft);
font-size: 13px;
line-height: 1.5;
word-break: break-all;
}
.adminUiScope .gameSettingsCard__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.adminUiScope .selectedThumb { .adminUiScope .selectedThumb {
width: min(100%, 256px); width: min(100%, 256px);
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
@@ -2646,17 +2768,23 @@ function userAvatarFallback(user) {
.adminUiScope .thumbDropZone { .adminUiScope .thumbDropZone {
position: relative; position: relative;
width: 100%; width: 100%;
display: block; display: grid;
gap: 0;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
border-radius: 18px; border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.12); border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12));
background: var(--theme-pill-bg); background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 88%, white 4%), color-mix(in srgb, var(--theme-card-bg-hover) 82%, white 6%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 60%);
text-align: left; text-align: left;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease; transition: border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
} }
.adminUiScope .thumbDropZone--active { .adminUiScope .thumbDropZone--active {
border-color: rgba(96, 165, 250, 0.56); border-color: rgba(96, 165, 250, 0.56);
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg-hover) 90%, white 6%), color-mix(in srgb, var(--theme-card-bg) 84%, white 4%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 18%, transparent), transparent 60%);
box-shadow: 0 0 0 1px rgba(96, 165, 250, 0.18); box-shadow: 0 0 0 1px rgba(96, 165, 250, 0.18);
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -2665,13 +2793,27 @@ function userAvatarFallback(user) {
inset: auto 0 0 0; inset: auto 0 0 0;
display: grid; display: grid;
place-items: center; place-items: center;
min-height: 52px; gap: 8px;
padding: 12px 16px; min-height: 80px;
padding: 16px 18px;
background: linear-gradient(180deg, transparent 0%, color-mix(in srgb, var(--theme-main-bg) 82%, transparent) 46%, color-mix(in srgb, var(--theme-main-bg) 94%, transparent) 100%); background: linear-gradient(180deg, transparent 0%, color-mix(in srgb, var(--theme-main-bg) 82%, transparent) 46%, color-mix(in srgb, var(--theme-main-bg) 94%, transparent) 100%);
} }
.adminUiScope .thumbDropZone__iconWrap {
width: 46px;
height: 46px;
display: grid;
place-items: center;
border-radius: 14px;
background: color-mix(in srgb, var(--theme-text) 10%, transparent);
}
.adminUiScope .thumbDropZone__icon {
width: 24px;
height: 24px;
opacity: 0.86;
}
.adminUiScope .thumbDropZone__title { .adminUiScope .thumbDropZone__title {
font-weight: 900; font-weight: 900;
font-size: 13px; font-size: 14px;
letter-spacing: 0.01em; letter-spacing: 0.01em;
color: var(--theme-text); color: var(--theme-text);
} }
@@ -2689,10 +2831,12 @@ function userAvatarFallback(user) {
} }
.adminUiScope .dropZone { .adminUiScope .dropZone {
min-height: 180px; min-height: 180px;
padding: 24px 18px; padding: 28px 22px;
border-radius: 16px; border-radius: 22px;
border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12)); border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12));
background: var(--theme-pill-bg); background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 88%, white 4%), color-mix(in srgb, var(--theme-card-bg-hover) 82%, white 6%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 60%);
display: grid; display: grid;
place-items: center; place-items: center;
align-content: center; align-content: center;
@@ -2705,9 +2849,25 @@ function userAvatarFallback(user) {
} }
.adminUiScope .dropZone--active { .adminUiScope .dropZone--active {
border-color: rgba(96, 165, 250, 0.56); border-color: rgba(96, 165, 250, 0.56);
background: rgba(96, 165, 250, 0.08); background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg-hover) 90%, white 6%), color-mix(in srgb, var(--theme-card-bg) 84%, white 4%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 18%, transparent), transparent 60%);
transform: translateY(-1px); transform: translateY(-1px);
} }
.adminUiScope .dropZone__iconWrap {
width: 52px;
height: 52px;
margin: 0 auto 12px;
display: grid;
place-items: center;
border-radius: 16px;
background: color-mix(in srgb, var(--theme-text) 10%, transparent);
}
.adminUiScope .dropZone__icon {
width: 28px;
height: 28px;
opacity: 0.86;
}
.adminUiScope .dropZone__title { .adminUiScope .dropZone__title {
font-weight: 900; font-weight: 900;
font-size: 16px; font-size: 16px;
@@ -2726,6 +2886,10 @@ function userAvatarFallback(user) {
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
} }
.adminUiScope .dropZone__button {
min-width: 124px;
min-height: 34px;
}
.adminUiScope .itemPreviewCard { .adminUiScope .itemPreviewCard {
margin-top: 12px; margin-top: 12px;
padding: 12px; padding: 12px;
@@ -3872,6 +4036,59 @@ function userAvatarFallback(user) {
align-items: center; align-items: center;
opacity: 0.88; opacity: 0.88;
} }
.adminUiScope .toggleSwitch {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
cursor: pointer;
user-select: none;
}
.adminUiScope .toggleSwitch input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.adminUiScope .toggleSwitch__track {
position: relative;
width: 42px;
height: 24px;
border-radius: 999px;
background: var(--theme-surface-soft-3);
border: 1px solid var(--theme-border-strong);
transition: background 180ms ease, border-color 180ms ease;
flex: 0 0 auto;
}
.adminUiScope .toggleSwitch__thumb {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 999px;
background: var(--theme-text-strong);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.24);
transition: transform 180ms ease;
}
.adminUiScope .toggleSwitch__label {
font-weight: 800;
color: var(--theme-text);
}
.adminUiScope .toggleSwitch input:checked ~ .toggleSwitch__track {
background: rgba(96, 165, 250, 0.34);
border-color: rgba(96, 165, 250, 0.42);
}
.adminUiScope .toggleSwitch input:checked ~ .toggleSwitch__track .toggleSwitch__thumb {
transform: translateX(18px);
}
.adminUiScope .toggleSwitch--disabled {
opacity: 0.55;
pointer-events: none;
}
.adminUiScope .checkRow--compact { .adminUiScope .checkRow--compact {
margin-top: 0; margin-top: 0;
} }
@@ -3924,6 +4141,7 @@ function userAvatarFallback(user) {
.adminUiScope .featuredOrderPanel, .adminUiScope .featuredOrderPanel,
.adminUiScope .section--topGrid, .adminUiScope .section--topGrid,
.adminUiScope .gameManagerGrid, .adminUiScope .gameManagerGrid,
.adminUiScope .gameSettingsCard,
.adminUiScope .toolbar, .adminUiScope .toolbar,
.adminUiScope .itemComposer, .adminUiScope .itemComposer,
.adminUiScope .tierAdminCard, .adminUiScope .tierAdminCard,

View File

@@ -6,6 +6,7 @@ import * as htmlToImage from 'html-to-image'
import SvgIcon from '../components/SvgIcon.vue' import SvgIcon from '../components/SvgIcon.vue'
import addColumnRightIcon from '../assets/icons/add_column_right.svg' import addColumnRightIcon from '../assets/icons/add_column_right.svg'
import addRowBelowIcon from '../assets/icons/add_row_below.svg' import addRowBelowIcon from '../assets/icons/add_row_below.svg'
import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
import { api } from '../lib/api' import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime' import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@@ -118,10 +119,7 @@ const copiedFromLabel = computed(() => {
if (sourceSnapshotAuthor.value) parts.push(sourceSnapshotAuthor.value) if (sourceSnapshotAuthor.value) parts.push(sourceSnapshotAuthor.value)
return parts.join(' · ') || '복사해 온 티어표' return parts.join(' · ') || '복사해 온 티어표'
}) })
const customItems = computed(() => const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
Object.values(itemsById.value)
.filter((item) => item?.origin === 'custom')
)
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new'))) const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
const canRequestTemplateCreate = computed( const canRequestTemplateCreate = computed(
() => canEdit.value && hasSavedTierList.value && gameId.value === 'freeform' && customItems.value.length > 0 () => canEdit.value && hasSavedTierList.value && gameId.value === 'freeform' && customItems.value.length > 0
@@ -165,6 +163,29 @@ function formatExportDate(ts) {
}) })
} }
function getOrderedItemIds() {
const orderedIds = []
const seen = new Set()
const pushId = (itemId) => {
if (!itemId || seen.has(itemId) || !itemsById.value[itemId]) return
seen.add(itemId)
orderedIds.push(itemId)
}
pool.value.forEach(pushId)
groups.value.forEach((group) => {
;(group.cells || []).forEach((cell) => {
;(cell || []).forEach(pushId)
})
})
Object.keys(itemsById.value).forEach(pushId)
return orderedIds
}
function getOrderedItems() {
return getOrderedItemIds().map((itemId) => itemsById.value[itemId]).filter(Boolean)
}
function setIconSize(nextSize) { function setIconSize(nextSize) {
iconSize.value = nextSize iconSize.value = nextSize
} }
@@ -654,7 +675,7 @@ function buildPayload(existingId) {
sourceSnapshotTitle: sourceSnapshotTitle.value || '', sourceSnapshotTitle: sourceSnapshotTitle.value || '',
sourceSnapshotAuthor: sourceSnapshotAuthor.value || '', sourceSnapshotAuthor: sourceSnapshotAuthor.value || '',
groups: buildGroupPayload(), groups: buildGroupPayload(),
pool: Object.values(itemsById.value), pool: getOrderedItems(),
} }
} }
@@ -721,6 +742,7 @@ function closeTemplateUpdateModal() {
} }
function openDeleteModal() { function openDeleteModal() {
if (!hasSavedTierList.value) return
isDeleteModalOpen.value = true isDeleteModalOpen.value = true
} }
@@ -729,11 +751,12 @@ function closeDeleteModal() {
} }
async function confirmDeleteTierList() { async function confirmDeleteTierList() {
if (!canEdit.value || isNewTierList.value || isDeleting.value) return const currentTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
if (!canEdit.value || !currentTierListId || isDeleting.value) return
error.value = '' error.value = ''
try { try {
isDeleting.value = true isDeleting.value = true
await api.deleteTierList(tierListId.value) await api.deleteTierList(currentTierListId)
closeDeleteModal() closeDeleteModal()
toast.success('티어표를 삭제했어요.') toast.success('티어표를 삭제했어요.')
router.push(gameId.value === 'freeform' ? '/me' : `/games/${gameId.value}`) router.push(gameId.value === 'freeform' ? '/me' : `/games/${gameId.value}`)
@@ -791,7 +814,7 @@ async function requestTemplate(type) {
isPublic: !!isPublic.value, isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value, showCharacterNames: !!showCharacterNames.value,
groups: buildGroupPayload(), groups: buildGroupPayload(),
boardItems: Object.values(itemsById.value), boardItems: getOrderedItems(),
}) })
if (type === 'create') closeTemplateRequestModal() if (type === 'create') closeTemplateRequestModal()
@@ -1184,13 +1207,16 @@ onUnmounted(() => {
@dragleave="onDragLeave" @dragleave="onDragLeave"
@drop.prevent="onDropFiles" @drop.prevent="onDropFiles"
> >
<div> <div>
<div class="dropzone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="dropzone__icon" />
</div>
<div class="dropzone__title">커스텀 이미지 추가</div> <div class="dropzone__title">커스텀 이미지 추가</div>
<div class="dropzone__desc">이곳으로 이미지를 드래그하거나 파일 선택으로 번에 추가할 있어요.</div> <div class="dropzone__desc">이곳으로 이미지를 드래그하거나 파일 선택으로 번에 추가할 있어요.</div>
</div> </div>
<div class="dropzone__actions"> <div class="dropzone__actions">
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" /> <input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
<button class="btn btn--ghost dropzone__button" @click="openFile">파일 선택</button> <button class="btn btn--ghost btn--small dropzone__button" @click="openFile">파일 선택</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1298,7 +1324,7 @@ onUnmounted(() => {
<button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button> <button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
</div> </div>
<div class="editorSidebar__utilityLinks"> <div class="editorSidebar__utilityLinks">
<button v-if="canEdit && !isNewTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button> <button v-if="canEdit && hasSavedTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button>
<button v-if="canDuplicate" class="editorSidebar__utilityLink" @click="duplicateCurrentTierList">복사해서 티어표로 가져오기</button> <button v-if="canDuplicate" class="editorSidebar__utilityLink" @click="duplicateCurrentTierList">복사해서 티어표로 가져오기</button>
<button <button
v-if="canRequestTemplateCreate" v-if="canRequestTemplateCreate"
@@ -2073,9 +2099,26 @@ onUnmounted(() => {
.dropzone--board { .dropzone--board {
margin-top: 18px; margin-top: 18px;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
gap: 18px; gap: 18px;
min-height: 180px;
padding: 28px 22px;
border-radius: 22px;
border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12));
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 88%, white 4%), color-mix(in srgb, var(--theme-card-bg-hover) 82%, white 6%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 60%);
text-align: center;
}
.dropzone--board.dropzone--active {
border-color: rgba(96, 165, 250, 0.56);
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg-hover) 90%, white 6%), color-mix(in srgb, var(--theme-card-bg) 84%, white 4%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 18%, transparent), transparent 60%);
transform: translateY(-1px);
} }
.dropzone__actions { .dropzone__actions {
@@ -2083,10 +2126,39 @@ onUnmounted(() => {
align-items: center; align-items: center;
gap: 12px; gap: 12px;
flex: 0 0 auto; flex: 0 0 auto;
justify-content: center;
} }
.dropzone__button { .dropzone__button {
min-width: 148px; min-width: 124px;
min-height: 34px;
font-size: 11px;
}
/* .dropzone__iconWrap {
width: 52px;
height: 52px;
display: grid;
place-items: center;
border-radius: 16px;
background: color-mix(in srgb, var(--theme-text) 10%, transparent);
} */
.dropzone__icon {
width: 48px;
height: 48px;
opacity: 0.86;
}
.dropzone--board .dropzone__title {
font-size: 18px;
font-weight: 800;
}
.dropzone--board .dropzone__desc {
max-width: 520px;
color: var(--theme-text-soft);
line-height: 1.6;
} }
.editorSidebar__section { .editorSidebar__section {
display: grid; display: grid;
@@ -2285,10 +2357,11 @@ onUnmounted(() => {
} }
.dropzone { .dropzone {
margin-top: 12px; margin-top: 12px;
padding: 14px; padding: 28px 22px;
border-radius: 16px; border-radius: 16px;
border: 1px dashed rgba(255, 255, 255, 0.18); border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12));
background: var(--theme-surface-soft); background: linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 88%, white 4%), color-mix(in srgb, var(--theme-card-bg-hover) 82%, white 6%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 60%);
} }
.dropzone--active { .dropzone--active {
border-color: rgba(110, 231, 183, 0.6); border-color: rgba(110, 231, 183, 0.6);