릴리스: v1.3.60 관리자 공개 토글과 인증 새로고침 안정화
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 } = {}) =>
|
||||
|
||||
@@ -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', {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
/>
|
||||
<span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newGameId.length }}/120자</span>
|
||||
</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 class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeGameCreateModal">취소</button>
|
||||
@@ -1853,6 +1916,11 @@ function userAvatarFallback(user) {
|
||||
<div v-if="hasSelectedGame" class="adminSidebar__group">
|
||||
<div class="selectedGameSidebar__name">{{ selectedGame.game.name }}</div>
|
||||
<div class="selectedGameSidebar__id">{{ selectedGame.game.id }}</div>
|
||||
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': gameVisibilitySaving }">
|
||||
<input :checked="!!selectedGame.game.isPublic" type="checkbox" @change="toggleSelectedGameVisibility($event.target.checked)" />
|
||||
<span class="toggleSwitch__label">{{ selectedGame.game.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
|
||||
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
||||
</label>
|
||||
<input ref="thumbFileInput" type="file" accept="image/*" class="srOnlyInput" @change="onThumb" />
|
||||
<button
|
||||
class="thumbDropZone"
|
||||
@@ -1867,7 +1935,7 @@ function userAvatarFallback(user) {
|
||||
<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 class="thumbDropZone__title">{{ displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="adminSidebar__actions adminSidebar__actions--stack">
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user