diff --git a/backend/src/db.js b/backend/src/db.js index 79911bc..7308f44 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -73,6 +73,7 @@ function mapGameRow(row) { id: row.id, name: row.name, thumbnailSrc: row.thumbnail_src || '', + isPublic: row.is_public == null ? true : !!row.is_public, displayRank: row.display_rank == null ? null : Number(row.display_rank), createdAt: Number(row.created_at), } @@ -256,11 +257,18 @@ async function ensureSchema() { id VARCHAR(120) PRIMARY KEY, name VARCHAR(120) NOT NULL, thumbnail_src VARCHAR(255) NOT NULL DEFAULT '', + is_public TINYINT(1) NOT NULL DEFAULT 1, display_rank INT NULL DEFAULT NULL, created_at BIGINT NOT NULL ) 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'") if (!displayRankColumns.length) { 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]) } -async function listGames(currentUserId = '') { +async function listGames(currentUserId = '', options = {}) { + const includePrivate = !!options.includePrivate 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 WHERE id <> ? + ${includePrivate ? '' : 'AND is_public = 1'} ORDER BY CASE WHEN display_rank IS NULL THEN 1 ELSE 0 END ASC, display_rank ASC, @@ -669,7 +679,7 @@ async function listGames(currentUserId = '') { } 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]) } @@ -702,11 +712,12 @@ async function getGameDetail(gameId) { return { game, items } } -async function createGame({ id, name }) { - await query('INSERT INTO games (id, name, thumbnail_src, display_rank, created_at) VALUES (?, ?, ?, ?, ?)', [ +async function createGame({ id, name, isPublic = true }) { + await query('INSERT INTO games (id, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?)', [ id, name, '', + isPublic ? 1 : 0, null, now(), ]) @@ -718,6 +729,11 @@ async function updateGameThumbnail(gameId, thumbnailSrc) { 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) { 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', @@ -2144,6 +2160,7 @@ module.exports = { getGameDetail, createGame, updateGameThumbnail, + updateGameVisibility, findImageAssetByHash, findImageAssetBySrc, findImageAssetById, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 62d7d24..8e38587 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -14,6 +14,7 @@ const { createGame, listGames, updateGameThumbnail, + updateGameVisibility, createGameItem, updateGameItemLabel, updateGameItemDisplayOrder, @@ -109,13 +110,14 @@ router.post('/games', requireAdmin, async (req, res) => { const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60), + isPublic: z.boolean().optional().default(false), 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 }) + const game = await createGame({ id: parsed.data.id, name: parsed.data.name, isPublic: parsed.data.isPublic }) if (parsed.data.thumbnailSrc) { const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc) await updateGameThumbnail(game.id, copiedThumb) @@ -123,6 +125,20 @@ router.post('/games', requireAdmin, async (req, res) => { 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) => { const schema = z.object({ 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) 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 filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId)) const updatedGames = await updateGameDisplayOrder(filteredIds) @@ -516,7 +532,7 @@ function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {} } async function createGameTemplateFromTierList({ tierList, gameId, gameName }) { - await createGame({ id: gameId, name: gameName }) + await createGame({ id: gameId, name: gameName, isPublic: false }) if (tierList.thumbnailSrc) { const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc) await updateGameThumbnail(gameId, copiedThumb) @@ -539,7 +555,7 @@ async function createGameTemplateFromTierList({ tierList, 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) { const copiedThumb = await copyUploadIntoGameAsset(templateRequest.thumbnailSrc) @@ -697,12 +713,19 @@ router.post('/template-requests/:requestId/approve', requireAdmin, async (req, r }) 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.status === 'completed' || templateRequest.status === 'rejected' || templateRequest.status === 'approved') { 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') { return res.json({ request: templateRequest }) } diff --git a/backend/src/routes/games.js b/backend/src/routes/games.js index 37ab1d9..2629ad3 100644 --- a/backend/src/routes/games.js +++ b/backend/src/routes/games.js @@ -5,7 +5,7 @@ const { requireAuth } = require('../middleware/auth') const router = express.Router() 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 }) }) @@ -30,6 +30,7 @@ router.delete('/:gameId/favorite', requireAuth, async (req, res) => { router.get('/:gameId', async (req, res) => { const detail = await getGameDetail(req.params.gameId) 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 }) }) diff --git a/docs/history.md b/docs/history.md index 58d2f9b..aa59399 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-04-02 v1.3.60 +- 관리자 접근 차단은 유지하되, 이미 로그인된 관리자가 새로고침할 때 홈으로 튕기는 체감은 권한 제어보다 더 큰 문제이므로 인증 결과가 나올 때까지 같은 세션 확인 요청을 기다리는 편이 맞다고 정리했다. +- 관리자 썸네일 드롭존과 에디터 보드 드롭존은 기능은 같아도 현재 상태가 문구와 형태로 바로 드러나야 하므로, 빈 상태와 교체 상태를 텍스트로 구분하고 점선 박스 형태를 더 적극적으로 드러내는 쪽으로 판단했다. + ## 2026-04-02 v1.3.59 - 템플릿 요청 아이템 반영은 관리자 주의에만 맡기기보다, 서버에서 같은 게임 안의 동일 `src` 중복 생성을 막고 프런트에서도 이미 반영된 요청 아이템을 다시 드래프트에 올리지 않는 편이 운영 안정성에 더 낫다고 정리했다. - 신규 템플릿 요청은 “완전한 임시 게임” 구조로 바로 바꾸기보다, 우선 한 번 만든 게임을 요청과 연결해 다시 `확인하기`를 눌러도 같은 게임을 재사용하게 만드는 편이 리스크 대비 효과가 크다고 판단했다. diff --git a/docs/todo.md b/docs/todo.md index e2cb145..a719f0d 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,5 +1,9 @@ # 할 일 및 이슈 +## 단기 확인 +- 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다. +- 티어표 만들기 화면의 보드 드롭존은 점선/높이/중앙 정렬로 존재감이 커졌으므로, 데스크톱과 모바일에서 파일 선택 버튼과 안내 문구 밀도를 한 번 더 QA한다. + ## 중기 개선 - 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다. - 관리자 템플릿 요청은 동일 `src` 중복 생성 방지와 `연결된 게임/이미 반영 n개` 표시까지 붙였으므로, 이후에는 요청별 반영 이력(예: 어떤 아이템을 언제 반영했는지)까지 별도로 남길지 검토한다. diff --git a/docs/update.md b/docs/update.md index d2805e0..664bf13 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 로그 +## 2026-04-02 v1.3.60 +- 관리자 게임 관리의 대표 썸네일 드롭존은 이제 썸네일이 없을 때는 `클릭 & 드래그`, 이미 등록된 썸네일이 있을 때는 `썸네일 변경`으로 문구가 바뀌어 현재 동작을 더 바로 읽을 수 있게 함. +- 관리자 인증 상태는 라우터 가드와 앱 셸이 동시에 `/api/auth/me`를 호출할 때, 가드가 아직 끝나지 않은 요청을 기다리지 못해 새로고침 직후 홈으로 튕기던 흐름이 있었으므로 인증 스토어에서 진행 중인 `refresh` Promise를 재사용하도록 정리함. +- 따라서 관리자 계정으로 로그인된 상태에서는 `/admin/...` 경로를 새로고침해도 세션 확인이 끝날 때까지 같은 요청을 기다린 뒤 관리자 화면에 남도록 안정성을 보강함. +- 티어표 만들기 화면의 보드 드롭존은 점선 테두리, 더 높은 박스, 중앙 정렬된 안내 문구와 버튼을 적용해 커스텀 이미지 추가 영역임을 더 즉시 인식할 수 있게 조정함. + ## 2026-04-02 v1.3.59 - 관리자 템플릿 요청의 `promote-items` 처리에서는 잘못된 `z.record` 스키마 때문에 500이 나던 서버 파싱 버그를 수정하고, 요청 아이템 `src`까지 함께 받아 실제 요청 데이터와 더 안정적으로 매칭하도록 보강함. - 요청 아이템을 게임에 반영할 때는 이제 같은 게임 안에 동일한 `src`가 이미 있으면 새 기본 아이템을 다시 만들지 않도록 막고, 관리자 화면에서도 이미 반영된 요청 아이템은 드래프트에 다시 올리지 않게 정리함. diff --git a/frontend/src/composables/useAdminGameManager.js b/frontend/src/composables/useAdminGameManager.js index 67060df..8da8cd3 100644 --- a/frontend/src/composables/useAdminGameManager.js +++ b/frontend/src/composables/useAdminGameManager.js @@ -21,6 +21,7 @@ export function useAdminGameManager({ customItemModalTargetGameId, newGameId, newGameName, + newGameIsPublic, clearPreviewUrl, resetFileInput, resetUploadState, @@ -116,9 +117,10 @@ export function useAdminGameManager({ 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() - resetUploadState() + if (!preserveUploadState) resetUploadState() if (!selectedGameId.value) { selectedGame.value = null @@ -140,7 +142,6 @@ export function useAdminGameManager({ 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 { @@ -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() try { const res = await fetch(toApiUrl('/api/admin/games'), { @@ -156,8 +160,9 @@ export function useAdminGameManager({ credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - id: newGameId.value.trim(), - name: newGameName.value.trim(), + id: nextGameId, + name: nextGameName, + isPublic: !!newGameIsPublic.value, thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '', }), }) @@ -171,14 +176,14 @@ export function useAdminGameManager({ activeTemplateRequest.value = { ...activeTemplateRequest.value, 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) 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(), + targetGameName: linkData.request?.targetGameName || data.game.name || nextGameName, }) } } @@ -186,8 +191,8 @@ export function useAdminGameManager({ selectedGameId.value = data.game.id if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id closeGameCreateModal() - await loadGame() - if (activeTemplateRequest.value?.id) { + await loadGame({ preserveUploadState }) + if (!preserveUploadState && activeTemplateRequest.value?.id) { const sourceRequest = templateRequests.value.find((entry) => entry.id === activeTemplateRequest.value.id) || activeTemplateRequest.value mergeRequestItemsIntoDrafts(sourceRequest) } @@ -243,12 +248,31 @@ export function useAdminGameManager({ async function uploadItem() { resetMessages() - if (!uploadItemDrafts.value.length || !selectedGameId.value) { + if (!uploadItemDrafts.value.length) { error.value = '아이템 파일을 선택해주세요.' return } 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 requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request') let uploadCount = 0 diff --git a/frontend/src/composables/useAdminTemplateRequests.js b/frontend/src/composables/useAdminTemplateRequests.js index 656dd7c..2912dbf 100644 --- a/frontend/src/composables/useAdminTemplateRequests.js +++ b/frontend/src/composables/useAdminTemplateRequests.js @@ -21,6 +21,7 @@ export function useAdminTemplateRequests({ thumbnailSrc: request.thumbnailSrc || '', draftGameId: request.draftGameId || '', draftGameName: request.draftGameName || '', + draftGameIsPublic: !!request.draftGameIsPublic, sourceTierListId: request.sourceTierListId || '', sourceGameId: request.sourceGameId || '', sourceTierListTitle: request.sourceTierListTitle || '', @@ -49,24 +50,32 @@ export function useAdminTemplateRequests({ try { request.isHandling = true const data = await api.startAdminTemplateRequestReview(request.id) - request.status = data.request?.status || 'reviewing' - updateActiveTemplateRequest(request) + const syncedRequest = { + ...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') if (request.type === 'create') { - const linkedGameId = request.targetGameId || '' + const linkedGameId = syncedRequest.targetGameId || '' if (linkedGameId) { await selectAdminGame(linkedGameId) } else { openGameCreateModal() - newGameId.value = (request.draftGameId || '').trim() - newGameName.value = (request.draftGameName || '').trim() + newGameId.value = (syncedRequest.draftGameId || '').trim() + newGameName.value = (syncedRequest.draftGameName || '').trim() } - mergeRequestItemsIntoDrafts(request) + mergeRequestItemsIntoDrafts(syncedRequest) } else { - const nextGameId = request.targetGameId || request.sourceGameId || '' + const nextGameId = syncedRequest.targetGameId || syncedRequest.sourceGameId || '' if (nextGameId) await selectAdminGame(nextGameId) - mergeRequestItemsIntoDrafts(request) + mergeRequestItemsIntoDrafts(syncedRequest) } success.value = '요청 아이템을 게임 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.' } catch (e) { diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 191a9b8..d233937 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -37,6 +37,8 @@ export const api = { 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 }), + updateAdminGame: (gameId, payload) => + request(`/api/admin/games/${encodeURIComponent(gameId)}`, { 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 } = {}) => diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 27ebf26..39787c3 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -1,6 +1,8 @@ import { defineStore } from 'pinia' import { api } from '../lib/api' +let refreshPromise = null + export const useAuthStore = defineStore('auth', { state: () => ({ user: null, @@ -9,19 +11,23 @@ export const useAuthStore = defineStore('auth', { }), actions: { async refresh() { - if (this.status === 'loading') return this.user + if (refreshPromise) return refreshPromise 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 - } + refreshPromise = (async () => { + 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 + refreshPromise = null + } + })() + return refreshPromise }, async signup(email, password) { const user = await api.signup({ email, password }) @@ -42,4 +48,3 @@ export const useAuthStore = defineStore('auth', { }, }, }) - diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index ddd50ed..fbb00a5 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -96,6 +96,8 @@ const success = ref('') const newGameId = ref('') const newGameName = ref('') +const newGameIsPublic = ref(false) +const gameVisibilitySaving = ref(false) const uploadFiles = ref([]) const uploadItemDrafts = ref([]) @@ -662,8 +664,15 @@ function setTierlistsMode(mode) { function openGameCreateModal() { resetMessages() - newGameId.value = '' - newGameName.value = '' + if (activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetGameId) { + 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 } @@ -745,6 +754,7 @@ async function refreshTemplateRequests() { request.type === 'create' ? `${request.sourceTierListTitle || request.sourceGameName || '새 템플릿'}` : request.targetGameName || request.sourceGameName || '', + draftGameIsPublic: false, })) } catch (e) { error.value = '템플릿 요청 목록을 불러오지 못했어요.' @@ -825,6 +835,7 @@ const { customItemModalTargetGameId, newGameId, newGameName, + newGameIsPublic, clearPreviewUrl, resetFileInput, resetUploadState, @@ -1029,6 +1040,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) { resetMessages() try { @@ -1496,6 +1554,11 @@ function userAvatarFallback(user) { /> 영문, 숫자, 하이픈 조합 권장 · {{ newGameId.length }}/120자 +
@@ -1853,6 +1916,11 @@ function userAvatarFallback(user) {
{{ selectedGame.game.name }}
{{ selectedGame.game.id }}
+
@@ -3872,6 +3940,59 @@ function userAvatarFallback(user) { align-items: center; 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 { margin-top: 0; } diff --git a/frontend/src/views/TierEditorView.vue b/frontend/src/views/TierEditorView.vue index 4e6c02a..281ac7e 100644 --- a/frontend/src/views/TierEditorView.vue +++ b/frontend/src/views/TierEditorView.vue @@ -2073,9 +2073,25 @@ onUnmounted(() => { .dropzone--board { margin-top: 18px; display: flex; + flex-direction: column; align-items: center; - justify-content: space-between; + justify-content: center; gap: 18px; + min-height: 176px; + padding: 28px 24px; + border: 2px dashed color-mix(in srgb, var(--theme-accent) 48%, var(--theme-border)); + border-radius: 22px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 82%, transparent), color-mix(in srgb, var(--theme-card-bg-hover) 74%, transparent)), + radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 58%); + text-align: center; +} + +.dropzone--board.dropzone--active { + border-color: color-mix(in srgb, var(--theme-accent) 78%, white); + background: + linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg-hover) 88%, transparent), color-mix(in srgb, var(--theme-card-bg) 82%, transparent)), + radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 20%, transparent), transparent 58%); } .dropzone__actions { @@ -2083,11 +2099,23 @@ onUnmounted(() => { align-items: center; gap: 12px; flex: 0 0 auto; + justify-content: center; } .dropzone__button { min-width: 148px; } + +.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 { display: grid; gap: 10px;