From 20955e277cb514e756047bbdfe56f7ee7797246e Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 2 Apr 2026 18:43:39 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v1.4.6=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=82=B4=EB=B6=80=20=EB=AA=85?= =?UTF-8?q?=EC=B9=AD=20=EC=A0=95=EB=A6=AC=202=EC=B0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/history.md | 3 + docs/todo.md | 4 +- docs/update.md | 4 + .../components/admin/AdminFeaturedSection.vue | 38 +- .../components/admin/AdminGamesSection.vue | 46 +- .../src/composables/useAdminCustomItems.js | 33 +- .../src/composables/useAdminFeaturedGames.js | 46 +- .../src/composables/useAdminGameManager.js | 88 +-- .../composables/useAdminTemplateRequests.js | 18 +- frontend/src/views/AdminView.vue | 514 +++++++++--------- 10 files changed, 401 insertions(+), 393 deletions(-) diff --git a/docs/history.md b/docs/history.md index 3ef053a..30f8683 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,8 @@ # 의사결정 이력 +## 2026-04-02 v1.4.6 +- 내부 리네이밍 2단계는 관리자 화면처럼 상태와 액션이 많은 영역부터 정리해 두는 편이, 이후 `/games` 라우트와 API 계층을 손볼 때 위험을 줄이는 데 더 유리하다고 판단했다. + ## 2026-04-02 v1.4.5 - 내부 리네이밍은 한 번에 API와 DB까지 건드리기보다, 홈·주제 화면·에디터처럼 영향 범위가 비교적 명확한 프런트 핵심 흐름부터 `game` 의존 이름을 줄여 나가는 편이 더 안전하다고 판단했다. diff --git a/docs/todo.md b/docs/todo.md index 4196d8b..5d5f9c6 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,8 +1,8 @@ # 할 일 및 이슈 ## 단기 확인 -- 내부 리네이밍 1단계로 홈/주제 화면/에디터/App 셸의 로컬 상태명은 먼저 정리했으므로, 다음 단계에서는 관리자 내부 `selectedGame / selectedGameId / games` 묶음을 같은 기준으로 옮긴다. -- 화면/문서 층의 용어 정리는 거의 마무리됐으므로, 다음 단계에서는 내부 `gameId / games` 모델을 정말 `topic / template` 쪽으로 옮길지 범위를 먼저 정리한다. +- 내부 리네이밍 2단계로 관리자 `selectedTemplate / templates / loadTemplate / refreshTemplates` 묶음까지 정리했으므로, 다음 단계에서는 `/games/:gameId` 라우트와 프런트 API 호출부를 어디까지 `topic/template` 의미로 감쌀지 범위를 먼저 정리한다. +- 화면/문서 층의 용어 정리는 거의 마무리됐으므로, 다음 단계에서는 내부 `gameId / games` 모델을 실제로 옮길지, 아니면 라우트 alias/리다이렉트부터 둘지 점진 전환 순서를 정한다. - 관리자 화면까지 포함한 사용자 노출 `게임` 문구를 3차까지 정리했으므로, 실제 운영 흐름에서 뜨는 확인창/토스트/선택 모달까지 표현이 자연스러운지 한 번 더 QA한다. - 왼쪽 사이드 메뉴와 각 화면 타이틀을 한글 기준으로 맞췄으므로, 홈/나의 티어표/즐겨찾기/설정 진입 시 실제 체감이 자연스럽고 중복 표현이 어색하지 않은지 한 번 더 QA한다. - 사용자 노출 용어는 `주제 / 템플릿` 기준으로 계속 걷어내고 있으므로, 홈/주제 화면/관리자 템플릿 관리에서 어색하게 남은 `게임` 문구가 없는지 한 번 더 QA한다. diff --git a/docs/update.md b/docs/update.md index 585c893..472ce60 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,9 @@ # 업데이트 로그 +## 2026-04-02 v1.4.6 +- 관리자 내부 리네이밍 2단계로 `AdminView`와 관련 composable/component의 핵심 상태명을 `selectedTemplate / templates / loadTemplate / refreshTemplates / createTemplate` 기준으로 정리했다. +- 요청 검토, 템플릿 생성 모달, 아이템 추가/정렬, 템플릿 선택 모달 흐름도 같은 기준으로 맞춰, 관리자 화면을 읽을 때 내부 이름과 사용자 노출 용어가 덜 어긋나게 정리했다. + ## 2026-04-02 v1.4.5 - 내부 리네이밍 1단계를 시작해 홈, 주제 화면, 티어표 편집기, 앱 셸에서 `games / gameId / gameName` 중심의 로컬 상태명을 `templates / topicId / templateId / templateName` 계열로 먼저 정리했다. - 경로와 API는 그대로 둔 채 프런트 내부에서 자주 읽는 상태명부터 바꿔, 이후 `/games` 라우트와 관리자 상태를 손볼 때 의미 충돌이 덜 나도록 기반을 만들었다. diff --git a/frontend/src/components/admin/AdminFeaturedSection.vue b/frontend/src/components/admin/AdminFeaturedSection.vue index 28c9626..9b7bc52 100644 --- a/frontend/src/components/admin/AdminFeaturedSection.vue +++ b/frontend/src/components/admin/AdminFeaturedSection.vue @@ -1,13 +1,13 @@ @@ -24,21 +24,21 @@ const props = defineProps({
상단 고정 목록
-
아직 상단 고정 템플릿이 없어요.
+
아직 상단 고정 템플릿이 없어요.
-
+
{{ index + 1 }}
-
{{ game.name }}
-
{{ game.id }}
+
{{ template.name }}
+
{{ template.id }}
- - - + + +
@@ -48,14 +48,14 @@ const props = defineProps({
템플릿 추가
diff --git a/frontend/src/components/admin/AdminGamesSection.vue b/frontend/src/components/admin/AdminGamesSection.vue index 6c84a1c..93bd8f7 100644 --- a/frontend/src/components/admin/AdminGamesSection.vue +++ b/frontend/src/components/admin/AdminGamesSection.vue @@ -8,10 +8,10 @@ const props = defineProps({ templateRequestSourceUrl: { type: Function, required: true }, stagedRequestDraftCount: { type: Number, required: true }, appliedRequestItemCount: { type: Number, required: true }, - openGameCreateModal: { type: Function, required: true }, + openTemplateCreateModal: { type: Function, required: true }, isGameLoading: { type: Boolean, required: true }, - hasSelectedGame: { type: Boolean, required: true }, - selectedGame: { type: Object, default: null }, + hasSelectedTemplate: { type: Boolean, required: true }, + selectedTemplate: { type: Object, default: null }, displayThumbnailUrl: { type: String, default: '' }, canApplyThumbnail: { type: Boolean, required: true }, gameVisibilitySaving: { type: Boolean, required: true }, @@ -24,8 +24,8 @@ const props = defineProps({ 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 }, + removeTemplate: { type: Function, required: true }, + toggleSelectedTemplateVisibility: { type: Function, required: true }, itemFileInputRef: { type: Function, required: true }, onFile: { type: Function, required: true }, isItemDragOver: { type: Boolean, required: true }, @@ -39,12 +39,12 @@ const props = defineProps({ canAddItem: { type: Boolean, required: true }, uploadItem: { type: Function, required: true }, removeUploadDraft: { type: Function, required: true }, - hasGameItemOrderChanges: { type: Boolean, required: true }, - saveGameItemOrder: { type: Function, required: true }, + hasTemplateItemOrderChanges: { type: Boolean, required: true }, + saveTemplateItemOrder: { type: Function, required: true }, gameItemListRef: { type: Function, required: true }, - saveGameItemLabel: { type: Function, required: true }, - removeGameItem: { type: Function, required: true }, - selectedGameId: { type: String, default: '' }, + saveTemplateItemLabel: { type: Function, required: true }, + removeTemplateItem: { type: Function, required: true }, + selectedTemplateId: { type: String, default: '' }, }) function setGameItemListElement(el) { @@ -95,7 +95,7 @@ function setThumbFileElement(el) { v-if="props.activeTemplateRequest.type === 'create' && !props.activeTemplateRequest.targetGameId" class="btn btn--ghost btn--small" type="button" - @click="props.openGameCreateModal" + @click="props.openTemplateCreateModal" > 새 템플릿 만들기 @@ -108,7 +108,7 @@ function setThumbFileElement(el) {
선택한 템플릿의 썸네일과 기본 아이템을 곧 표시합니다.
-
+
@@ -122,7 +122,7 @@ function setThumbFileElement(el) { @dragleave="props.onThumbDragLeave" @drop="props.onThumbDrop" > - +
대표 썸네일
@@ -134,15 +134,15 @@ function setThumbFileElement(el) {
템플릿 설정
-
{{ props.selectedGame.game.name }} · {{ props.selectedGame.game.id }}
+
{{ props.selectedTemplate.game.name }} · {{ props.selectedTemplate.game.id }}
- +
@@ -212,11 +212,11 @@ function setThumbFileElement(el) {
현재 기본 아이템 목록
드래그해서 기본 노출 순서를 바꿀 수 있어요. 저장 전까지는 화면에서만 순서가 바뀝니다.
- +
-
아직 등록된 기본 아이템이 없어요.
+
아직 등록된 기본 아이템이 없어요.
-
+
@@ -224,11 +224,11 @@ function setThumbFileElement(el) { class="btn btn--ghost btn--small" data-no-drag :disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label" - @click="props.saveGameItemLabel(item)" + @click="props.saveTemplateItemLabel(item)" > {{ item.isSavingLabel ? '저장중...' : '이름 저장' }} - +
@@ -238,7 +238,7 @@ function setThumbFileElement(el) {
템플릿을 선택해 주세요.
진행 중인 신규 템플릿 요청이 있어요. 위의 `새 템플릿 만들기`로 템플릿을 만든 뒤 아이템을 추가할 수 있습니다.
-
선택한 템플릿을 찾지 못했거나 로딩 중 오류가 발생했어요. 다시 선택해보세요.
+
선택한 템플릿을 찾지 못했거나 로딩 중 오류가 발생했어요. 다시 선택해보세요.
diff --git a/frontend/src/composables/useAdminCustomItems.js b/frontend/src/composables/useAdminCustomItems.js index 0ee5ab1..dab3682 100644 --- a/frontend/src/composables/useAdminCustomItems.js +++ b/frontend/src/composables/useAdminCustomItems.js @@ -15,13 +15,13 @@ export function useAdminCustomItems({ modalTargetCustomItem, customItemModalDraftLabel, customItemModalLabelSaving, - customItemModalTargetGameId, - games, - selectedGameId, + customItemModalTargetTemplateId, + templates, + selectedTemplateId, refreshCustomItems, - loadGame, + loadTemplate, setTab, - selectAdminGame, + selectAdminTemplate, resetMessages, success, error, @@ -59,7 +59,7 @@ export function useAdminCustomItems({ function openCustomItemModal(item) { modalTargetCustomItem.value = item || null customItemModalDraftLabel.value = item?.label || '' - customItemModalTargetGameId.value = '' + customItemModalTargetTemplateId.value = '' customItemModalOpen.value = true pushCustomItemModalHistoryState() } @@ -70,7 +70,7 @@ export function useAdminCustomItems({ modalTargetCustomItem.value = null customItemModalDraftLabel.value = '' customItemModalLabelSaving.value = false - customItemModalTargetGameId.value = '' + customItemModalTargetTemplateId.value = '' if (fromPopState) { customItemModalHistoryActive.value = false @@ -97,12 +97,12 @@ export function useAdminCustomItems({ customItemDeleteModalOpen.value = false } - function jumpToGameAdmin(gameId) { - if (!gameId) return + function jumpToTemplateAdmin(templateId) { + if (!templateId) return closeCustomItemModal() setTab('game-admin') nextTick(() => { - selectAdminGame(gameId) + selectAdminTemplate(templateId) }) } @@ -160,18 +160,19 @@ export function useAdminCustomItems({ async function promoteCustomItem(item) { resetMessages() - if (!customItemModalTargetGameId.value) { + if (!customItemModalTargetTemplateId.value) { error.value = '추가할 템플릿을 먼저 선택해주세요.' return } try { item.isPromoting = true - await api.promoteAdminCustomItem(item.id, { gameId: customItemModalTargetGameId.value }) - const targetGameName = games.value.find((game) => game.id === customItemModalTargetGameId.value)?.name || customItemModalTargetGameId.value - if (selectedGameId.value === customItemModalTargetGameId.value) await loadGame() + await api.promoteAdminCustomItem(item.id, { gameId: customItemModalTargetTemplateId.value }) + const targetTemplateName = + templates.value.find((template) => template.id === customItemModalTargetTemplateId.value)?.name || customItemModalTargetTemplateId.value + if (selectedTemplateId.value === customItemModalTargetTemplateId.value) await loadTemplate() closeCustomItemModal() - success.value = `"${item.label}" 이미지를 ${targetGameName} 템플릿으로 추가했어요.` + success.value = `"${item.label}" 이미지를 ${targetTemplateName} 템플릿으로 추가했어요.` } catch (e) { error.value = '선택한 이미지를 템플릿으로 추가하지 못했어요.' } finally { @@ -189,7 +190,7 @@ export function useAdminCustomItems({ closeCustomItemModal, openCustomItemDeleteModal, closeCustomItemDeleteModal, - jumpToGameAdmin, + jumpToTemplateAdmin, removeCustomItem, removeUnusedCustomItems, saveCustomItemModalLabel, diff --git a/frontend/src/composables/useAdminFeaturedGames.js b/frontend/src/composables/useAdminFeaturedGames.js index 34555f7..a470afc 100644 --- a/frontend/src/composables/useAdminFeaturedGames.js +++ b/frontend/src/composables/useAdminFeaturedGames.js @@ -5,8 +5,8 @@ export function useAdminFeaturedGames({ api, featuredListEl, featuredSortable, - featuredGameIds, - games, + featuredTemplateIds, + templates, resetMessages, success, error, @@ -31,51 +31,51 @@ export function useAdminFeaturedGames({ chosenClass: 'chosen', onEnd: (evt) => { if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return - const nextIds = [...featuredGameIds.value] + const nextIds = [...featuredTemplateIds.value] const [moved] = nextIds.splice(evt.oldIndex, 1) nextIds.splice(evt.newIndex, 0, moved) - featuredGameIds.value = nextIds + featuredTemplateIds.value = nextIds }, }) } - function addFeaturedGame(gameId) { + function addFeaturedTemplate(templateId) { resetMessages() - if (!gameId || featuredGameIds.value.includes(gameId)) return - if (featuredGameIds.value.length >= 50) { + if (!templateId || featuredTemplateIds.value.includes(templateId)) return + if (featuredTemplateIds.value.length >= 50) { error.value = '상단 고정 템플릿은 최대 50개까지만 설정할 수 있어요.' return } - featuredGameIds.value = [...featuredGameIds.value, gameId] + featuredTemplateIds.value = [...featuredTemplateIds.value, templateId] syncFeaturedSortable() } - function removeFeaturedGame(gameId) { + function removeFeaturedTemplate(templateId) { resetMessages() - featuredGameIds.value = featuredGameIds.value.filter((id) => id !== gameId) + featuredTemplateIds.value = featuredTemplateIds.value.filter((id) => id !== templateId) syncFeaturedSortable() } - function moveFeaturedGame(gameId, direction) { - const currentIndex = featuredGameIds.value.indexOf(gameId) + function moveFeaturedTemplate(templateId, direction) { + const currentIndex = featuredTemplateIds.value.indexOf(templateId) const nextIndex = currentIndex + direction - if (currentIndex < 0 || nextIndex < 0 || nextIndex >= featuredGameIds.value.length) return - const nextIds = [...featuredGameIds.value] + if (currentIndex < 0 || nextIndex < 0 || nextIndex >= featuredTemplateIds.value.length) return + const nextIds = [...featuredTemplateIds.value] const [moved] = nextIds.splice(currentIndex, 1) nextIds.splice(nextIndex, 0, moved) - featuredGameIds.value = nextIds + featuredTemplateIds.value = nextIds syncFeaturedSortable() } async function saveFeaturedOrder() { resetMessages() try { - const data = await api.updateAdminGameDisplayOrder({ gameIds: featuredGameIds.value }) - games.value = data.games || [] - featuredGameIds.value = games.value - .filter((game) => game.displayRank != null) + const data = await api.updateAdminGameDisplayOrder({ gameIds: featuredTemplateIds.value }) + templates.value = data.games || [] + featuredTemplateIds.value = templates.value + .filter((template) => template.displayRank != null) .sort((a, b) => a.displayRank - b.displayRank) - .map((game) => game.id) + .map((template) => template.id) success.value = '홈 화면 템플릿 순서를 저장했어요.' } catch (e) { error.value = '템플릿 순서 저장에 실패했어요.' @@ -85,9 +85,9 @@ export function useAdminFeaturedGames({ return { destroyFeaturedSortable, syncFeaturedSortable, - addFeaturedGame, - removeFeaturedGame, - moveFeaturedGame, + addFeaturedTemplate, + removeFeaturedTemplate, + moveFeaturedTemplate, saveFeaturedOrder, } } diff --git a/frontend/src/composables/useAdminGameManager.js b/frontend/src/composables/useAdminGameManager.js index ad75b34..d693d06 100644 --- a/frontend/src/composables/useAdminGameManager.js +++ b/frontend/src/composables/useAdminGameManager.js @@ -4,8 +4,8 @@ import Sortable from 'sortablejs' export function useAdminGameManager({ api, toApiUrl, - selectedGameId, - selectedGame, + selectedTemplateId, + selectedTemplate, uploadFiles, uploadItemDrafts, thumbFile, @@ -18,15 +18,15 @@ export function useAdminGameManager({ activeTemplateRequest, templateRequests, customItemModalOpen, - customItemModalTargetGameId, - newGameId, - newGameName, - newGameIsPublic, + customItemModalTargetTemplateId, + newTemplateId, + newTemplateName, + newTemplateIsPublic, clearPreviewUrl, resetFileInput, resetUploadState, - refreshGames, - closeGameCreateModal, + refreshTemplates, + closeTemplateCreateModal, resetMessages, success, error, @@ -59,7 +59,7 @@ export function useAdminGameManager({ async function syncGameItemSortable() { await nextTick() destroyGameItemSortable() - if (!gameItemListEl.value || !selectedGame.value?.items?.length) return + if (!gameItemListEl.value || !selectedTemplate.value?.items?.length) return gameItemSortable.value = Sortable.create(gameItemListEl.value, { animation: 160, @@ -73,11 +73,11 @@ export function useAdminGameManager({ chosenClass: 'chosen', onEnd: (evt) => { if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return - const nextItems = [...(selectedGame.value?.items || [])] + const nextItems = [...(selectedTemplate.value?.items || [])] const [moved] = nextItems.splice(evt.oldIndex, 1) nextItems.splice(evt.newIndex, 0, moved) - selectedGame.value = { - ...selectedGame.value, + selectedTemplate.value = { + ...selectedTemplate.value, items: nextItems, } }, @@ -87,7 +87,7 @@ export function useAdminGameManager({ function mergeRequestItemsIntoDrafts(request) { const requestId = request?.id if (!requestId) return - const existingGameSrcs = new Set((selectedGame.value?.items || []).map((item) => normalizeDraftSrc(item?.src)).filter(Boolean)) + const existingTemplateSrcs = new Set((selectedTemplate.value?.items || []).map((item) => normalizeDraftSrc(item?.src)).filter(Boolean)) const existingKeys = new Set(uploadItemDrafts.value.map((draft) => `${draft.kind}:${draft.requestId || ''}:${draft.itemId || draft.file?.name || ''}`)) const nextRequestDrafts = (request.items || []) .filter((item) => item?.src) @@ -100,7 +100,7 @@ export function useAdminGameManager({ sourceName: requestItemFilename(item), src: item.src, })) - .filter((draft) => !existingGameSrcs.has(normalizeDraftSrc(draft.src))) + .filter((draft) => !existingTemplateSrcs.has(normalizeDraftSrc(draft.src))) .filter((draft) => !existingKeys.has(`${draft.kind}:${draft.requestId}:${draft.itemId}`)) if (nextRequestDrafts.length) { @@ -117,13 +117,13 @@ export function useAdminGameManager({ uploadFiles.value = uploadItemDrafts.value.filter((draft) => draft.kind === 'file').map((draft) => draft.file).filter(Boolean) } - async function loadGame(options = {}) { + async function loadTemplate(options = {}) { const preserveUploadState = !!options.preserveUploadState resetMessages() if (!preserveUploadState) resetUploadState() - if (!selectedGameId.value) { - selectedGame.value = null + if (!selectedTemplateId.value) { + selectedTemplate.value = null savedGameItemOrderIds.value = [] destroyGameItemSortable() return @@ -131,8 +131,8 @@ export function useAdminGameManager({ try { isGameLoading.value = true - const data = await api.getGame(selectedGameId.value) - selectedGame.value = { + const data = await api.getGame(selectedTemplateId.value) + selectedTemplate.value = { ...data, items: (data.items || []).map((item) => ({ ...item, @@ -142,16 +142,16 @@ export function useAdminGameManager({ savedGameItemOrderIds.value = (data.items || []).map((item) => item.id) await syncGameItemSortable() } catch (e) { - selectedGame.value = null + selectedTemplate.value = null error.value = '템플릿 정보를 불러오지 못했어요.' } finally { isGameLoading.value = false } } - 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() + async function createTemplate(options = {}) { + const nextGameId = typeof options.gameId === 'string' ? options.gameId.trim() : newTemplateId.value.trim() + const nextGameName = typeof options.gameName === 'string' ? options.gameName.trim() : newTemplateName.value.trim() const preserveUploadState = !!options.preserveUploadState resetMessages() try { @@ -162,7 +162,7 @@ export function useAdminGameManager({ body: JSON.stringify({ id: nextGameId, name: nextGameName, - isPublic: !!newGameIsPublic.value, + isPublic: !!newTemplateIsPublic.value, thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '', }), }) @@ -187,11 +187,11 @@ export function useAdminGameManager({ }) } } - await refreshGames() - selectedGameId.value = data.game.id - if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id - closeGameCreateModal() - await loadGame({ preserveUploadState }) + await refreshTemplates() + selectedTemplateId.value = data.game.id + if (customItemModalOpen.value) customItemModalTargetTemplateId.value = data.game.id + closeTemplateCreateModal() + await loadTemplate({ preserveUploadState }) if (!preserveUploadState && activeTemplateRequest.value?.id) { const sourceRequest = templateRequests.value.find((entry) => entry.id === activeTemplateRequest.value.id) || activeTemplateRequest.value mergeRequestItemsIntoDrafts(sourceRequest) @@ -254,21 +254,21 @@ export function useAdminGameManager({ } try { - if (!selectedGameId.value && activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetGameId) { + if (!selectedTemplateId.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({ + await createTemplate({ gameId: draftGameId, gameName: draftGameName, preserveUploadState: true, }) } - if (!selectedGameId.value) { + if (!selectedTemplateId.value) { error.value = '템플릿을 먼저 선택해주세요.' return } @@ -283,7 +283,7 @@ export function useAdminGameManager({ fd.append('images', entry.file) fd.append('labels', entry.label.trim()) }) - const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedGameId.value)}/images`), { + const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}/images`), { method: 'POST', credentials: 'include', body: fd, @@ -297,7 +297,7 @@ export function useAdminGameManager({ for (const requestId of requestIds) { const draftsForRequest = requestDrafts.filter((entry) => entry.requestId === requestId) const result = await api.promoteAdminTemplateRequestItems(requestId, { - gameId: selectedGameId.value, + gameId: selectedTemplateId.value, itemIds: draftsForRequest.map((entry) => entry.itemId).filter(Boolean), itemSrcs: draftsForRequest.map((entry) => entry.src).filter(Boolean), itemLabels: draftsForRequest.reduce((acc, entry) => { @@ -310,7 +310,7 @@ export function useAdminGameManager({ } resetUploadState() - await loadGame() + await loadTemplate() success.value = `템플릿 기본 아이템 ${uploadCount}개 추가를 완료했어요.` } catch (e) { const apiError = e?.data?.error || '' @@ -331,16 +331,16 @@ export function useAdminGameManager({ } } - async function saveGameItemOrder() { + async function saveTemplateItemOrder() { resetMessages() - if (!selectedGameId.value || !selectedGame.value?.items?.length) return + if (!selectedTemplateId.value || !selectedTemplate.value?.items?.length) return try { - const data = await api.updateAdminGameItemDisplayOrder(selectedGameId.value, { - itemIds: selectedGame.value.items.map((item) => item.id), + const data = await api.updateAdminGameItemDisplayOrder(selectedTemplateId.value, { + itemIds: selectedTemplate.value.items.map((item) => item.id), }) - selectedGame.value = { - ...selectedGame.value, + selectedTemplate.value = { + ...selectedTemplate.value, items: (data.items || []).map((item) => ({ ...item, draftLabel: item.label, @@ -360,13 +360,13 @@ export function useAdminGameManager({ syncGameItemSortable, mergeRequestItemsIntoDrafts, removeUploadDraft, - loadGame, - createGame, + loadTemplate, + createTemplate, handleItemFiles, onFile, openItemFilePicker, clearItemFiles, uploadItem, - saveGameItemOrder, + saveTemplateItemOrder, } } diff --git a/frontend/src/composables/useAdminTemplateRequests.js b/frontend/src/composables/useAdminTemplateRequests.js index 859eb09..0d3c1f0 100644 --- a/frontend/src/composables/useAdminTemplateRequests.js +++ b/frontend/src/composables/useAdminTemplateRequests.js @@ -3,10 +3,10 @@ export function useAdminTemplateRequests({ activeTemplateRequest, refreshTemplateRequests, setTab, - openGameCreateModal, - newGameId, - newGameName, - selectAdminGame, + openTemplateCreateModal, + newTemplateId, + newTemplateName, + selectAdminTemplate, mergeRequestItemsIntoDrafts, resetMessages, success, @@ -65,16 +65,16 @@ export function useAdminTemplateRequests({ if (request.type === 'create') { const linkedGameId = syncedRequest.targetGameId || '' if (linkedGameId) { - await selectAdminGame(linkedGameId) + await selectAdminTemplate(linkedGameId) } else { - openGameCreateModal() - newGameId.value = (syncedRequest.draftGameId || '').trim() - newGameName.value = (syncedRequest.draftGameName || '').trim() + openTemplateCreateModal() + newTemplateId.value = (syncedRequest.draftGameId || '').trim() + newTemplateName.value = (syncedRequest.draftGameName || '').trim() } mergeRequestItemsIntoDrafts(syncedRequest) } else { const nextGameId = syncedRequest.targetGameId || syncedRequest.sourceGameId || '' - if (nextGameId) await selectAdminGame(nextGameId) + if (nextGameId) await selectAdminTemplate(nextGameId) mergeRequestItemsIntoDrafts(syncedRequest) } success.value = '요청 아이템을 템플릿 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.' diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 324f8f0..47772da 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -30,14 +30,14 @@ const isAdmin = computed(() => !!auth.user?.isAdmin) const activeTab = ref('featured') const tierlistsMode = ref('requests') -const games = ref([]) -const selectedGameId = ref('') -const selectedGame = ref(null) -const featuredGameIds = ref([]) -const gamePickerModalOpen = ref(false) -const gamePickerMode = ref('game-admin') -const gamePickerQuery = ref('') -const gamePickerSort = ref('recent') +const templates = ref([]) +const selectedTemplateId = ref('') +const selectedTemplate = ref(null) +const featuredTemplateIds = ref([]) +const templatePickerModalOpen = ref(false) +const templatePickerMode = ref('game-admin') +const templatePickerQuery = ref('') +const templatePickerSort = ref('recent') const customItems = ref([]) const customItemQuery = ref('') @@ -45,7 +45,7 @@ const customItemPage = ref(1) const customItemLimit = ref(50) const customItemTotal = ref(0) const customItemFilter = ref('all') -const customItemModalTargetGameId = ref('') +const customItemModalTargetTemplateId = ref('') const adminTierLists = ref([]) const adminTierListQuery = ref('') @@ -54,15 +54,15 @@ const adminTierListPage = ref(1) const adminTierListLimit = ref(50) const adminTierListTotal = ref(0) const adminTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0 }) -const selectedGameTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0 }) +const selectedTemplateTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0 }) const templateRequests = ref([]) const importModalOpen = ref(false) const importModalMode = ref('existing') const importModalTierList = ref(null) const importModalItems = ref([]) -const importModalTargetGameId = ref('') -const importModalNewGameId = ref('') -const importModalNewGameName = ref('') +const importModalTargetTemplateId = ref('') +const importModalNewTemplateId = ref('') +const importModalNewTemplateName = ref('') const previewModalOpen = ref(false) const previewTierList = ref(null) const adminTierListManageModalOpen = ref(false) @@ -105,9 +105,9 @@ const imageMissingCleanupBusy = ref(false) const error = ref('') const success = ref('') -const newGameId = ref('') -const newGameName = ref('') -const newGameIsPublic = ref(false) +const newTemplateId = ref('') +const newTemplateName = ref('') +const newTemplateIsPublic = ref(false) const gameVisibilitySaving = ref(false) const uploadFiles = ref([]) @@ -127,7 +127,7 @@ let gameItemSortableSyncTimer = null const savedGameItemOrderIds = ref([]) const userAvatarInputs = ref({}) const isGameLoading = ref(false) -const gameCreateModalOpen = ref(false) +const templateCreateModalOpen = ref(false) const previousBodyOverflow = ref('') function setFeaturedListRef(el) { @@ -147,7 +147,7 @@ function scheduleGameItemSortableSync() { clearTimeout(gameItemSortableSyncTimer) gameItemSortableSyncTimer = null } - if (!gameItemListEl.value || !selectedGame.value?.items?.length) return + if (!gameItemListEl.value || !selectedTemplate.value?.items?.length) return gameItemSortableSyncTimer = setTimeout(() => { gameItemSortableSyncTimer = null @@ -174,43 +174,43 @@ function normalizeAdminSrc(src) { } } -const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id) -const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value) -const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedGameId.value) +const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.game?.id) +const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value) +const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedTemplateId.value) const stagedRequestDraftCount = computed(() => uploadItemDrafts.value.filter((item) => item.kind === 'request').length) const appliedRequestItemCount = computed(() => { - if (!activeTemplateRequest.value?.id || !selectedGame.value?.items?.length) return 0 + if (!activeTemplateRequest.value?.id || !selectedTemplate.value?.items?.length) return 0 const sourceRequest = templateRequests.value.find((request) => request.id === activeTemplateRequest.value.id) if (!sourceRequest?.items?.length) return 0 - const gameSrcs = new Set((selectedGame.value.items || []).map((item) => normalizeAdminSrc(item?.src)).filter(Boolean)) - return sourceRequest.items.filter((item) => gameSrcs.has(normalizeAdminSrc(item?.src))).length + const templateSrcs = new Set((selectedTemplate.value.items || []).map((item) => normalizeAdminSrc(item?.src)).filter(Boolean)) + return sourceRequest.items.filter((item) => templateSrcs.has(normalizeAdminSrc(item?.src))).length }) -const hasGameItemOrderChanges = computed(() => { - const currentIds = (selectedGame.value?.items || []).map((item) => item.id) +const hasTemplateItemOrderChanges = computed(() => { + const currentIds = (selectedTemplate.value?.items || []).map((item) => item.id) return currentIds.join('|') !== savedGameItemOrderIds.value.join('|') }) const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value))) const adminTierListPageCount = computed(() => Math.max(1, Math.ceil(adminTierListTotal.value / adminTierListLimit.value))) -const featuredGames = computed(() => - featuredGameIds.value - .map((gameId) => games.value.find((game) => game.id === gameId)) +const featuredTemplates = computed(() => + featuredTemplateIds.value + .map((templateId) => templates.value.find((template) => template.id === templateId)) .filter(Boolean) ) -const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id))) -const filteredGamePickerGames = computed(() => { - const query = gamePickerQuery.value.trim().toLowerCase() - const list = games.value.filter((game) => { +const availableTemplatesForFeatured = computed(() => templates.value.filter((template) => !featuredTemplateIds.value.includes(template.id))) +const filteredTemplatePickerTemplates = computed(() => { + const query = templatePickerQuery.value.trim().toLowerCase() + const list = templates.value.filter((template) => { if (!query) return true - const haystack = `${game.name || ''} ${game.id || ''}`.toLowerCase() + const haystack = `${template.name || ''} ${template.id || ''}`.toLowerCase() return haystack.includes(query) }) return list.slice().sort((a, b) => { - if (gamePickerSort.value === 'oldest') return Number(a.createdAt || 0) - Number(b.createdAt || 0) + if (templatePickerSort.value === 'oldest') return Number(a.createdAt || 0) - Number(b.createdAt || 0) return Number(b.createdAt || 0) - Number(a.createdAt || 0) }) }) -const customItemTargetGame = computed(() => games.value.find((game) => game.id === customItemModalTargetGameId.value) || null) +const customItemTargetTemplate = computed(() => templates.value.find((template) => template.id === customItemModalTargetTemplateId.value) || null) const importModalItemCount = computed(() => importModalItems.value.length) const activeTabTitle = computed(() => { if (activeTab.value === 'featured') return '목록 관리' @@ -245,18 +245,18 @@ const adminOverviewStats = computed(() => { if (activeTab.value === 'featured') { return [ - { label: '전체 템플릿', value: `${games.value.length}` }, - { label: '상단 고정', value: `${featuredGameIds.value.length}/50` }, - { label: '추가 가능', value: `${Math.max(0, 50 - featuredGameIds.value.length)}` }, + { label: '전체 템플릿', value: `${templates.value.length}` }, + { label: '상단 고정', value: `${featuredTemplateIds.value.length}/50` }, + { label: '추가 가능', value: `${Math.max(0, 50 - featuredTemplateIds.value.length)}` }, ] } if (activeTab.value === 'game-admin') { return [ - { label: '전체 템플릿', value: `${games.value.length}` }, - { label: '티어표 전체', value: `${selectedGameTierListStats.value.total || 0}` }, - { label: '공개', value: `${selectedGameTierListStats.value.publicCount || 0}` }, - { label: '비공개', value: `${selectedGameTierListStats.value.privateCount || 0}` }, - { label: '기본 아이템', value: `${selectedGame.value?.items?.length || 0}` }, + { label: '전체 템플릿', value: `${templates.value.length}` }, + { label: '티어표 전체', value: `${selectedTemplateTierListStats.value.total || 0}` }, + { label: '공개', value: `${selectedTemplateTierListStats.value.publicCount || 0}` }, + { label: '비공개', value: `${selectedTemplateTierListStats.value.privateCount || 0}` }, + { label: '기본 아이템', value: `${selectedTemplate.value?.items?.length || 0}` }, ] } if (activeTab.value === 'items') { @@ -289,8 +289,8 @@ const adminOverviewStats = computed(() => { }) const isAnyModalOpen = computed( () => - gameCreateModalOpen.value || - gamePickerModalOpen.value || + templateCreateModalOpen.value || + templatePickerModalOpen.value || userEditModalOpen.value || userPasswordModalOpen.value || userDeleteModalOpen.value || @@ -362,7 +362,7 @@ onMounted(async () => { window.addEventListener('keydown', handleAdminKeydown) } await auth.refresh() - await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests(), refreshImageDiagnostics()]) + await Promise.all([refreshTemplates(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests(), refreshImageDiagnostics()]) await syncFeaturedSortable() }) @@ -424,10 +424,10 @@ watch( activeTab.value = tabFromAdminRoute(name) if (name === 'adminGames') { const nextGameId = typeof route.query.gameId === 'string' ? route.query.gameId : '' - if (nextGameId && nextGameId !== selectedGameId.value) { - selectedGameId.value = nextGameId + if (nextGameId && nextGameId !== selectedTemplateId.value) { + selectedTemplateId.value = nextGameId queueMicrotask(() => { - if (selectedGameId.value === nextGameId) void loadGame() + if (selectedTemplateId.value === nextGameId) void loadTemplate() }) } return @@ -443,17 +443,17 @@ watch( ) watch( - () => selectedGameId.value, - (gameId) => { + () => selectedTemplateId.value, + (templateId) => { if (route.name !== 'adminGames') return - syncAdminRouteQuery({ gameId: gameId || undefined }) + syncAdminRouteQuery({ gameId: templateId || undefined }) } ) watch( - () => selectedGame.value?.game?.id || '', - async (gameId) => { - await refreshSelectedGameTierListStats(gameId) + () => selectedTemplate.value?.game?.id || '', + async (templateId) => { + await refreshSelectedTemplateTierListStats(templateId) }, { immediate: true } ) @@ -480,8 +480,8 @@ watch( watch( () => activeTab.value, async (tab) => { - if (tab === 'game-admin' && selectedGameId.value && !selectedGame.value?.game?.id) { - await loadGame() + if (tab === 'game-admin' && selectedTemplateId.value && !selectedTemplate.value?.game?.id) { + await loadTemplate() return } @@ -518,14 +518,14 @@ watch( () => auth.user?.id, async (userId) => { if (!userId || !auth.user?.isAdmin) return - await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests(), refreshImageDiagnostics()]) + await Promise.all([refreshTemplates(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests(), refreshImageDiagnostics()]) } ) watch( - () => [selectedGame.value?.game?.id || '', selectedGame.value?.items?.length || 0, !!gameItemListEl.value], - ([gameId, itemCount, hasListEl]) => { - if (!gameId || !itemCount || !hasListEl) return + () => [selectedTemplate.value?.game?.id || '', selectedTemplate.value?.items?.length || 0, !!gameItemListEl.value], + ([templateId, itemCount, hasListEl]) => { + if (!templateId || !itemCount || !hasListEl) return scheduleGameItemSortableSync() } ) @@ -617,10 +617,10 @@ const imageDiagnosticsCards = computed(() => { { label: '절감률', value: `${Math.round((stats.savingsRatio || 0) * 100)}%` }, ] }) -const visibleLinkedGames = computed(() => - (modalTargetCustomItem.value?.linkedGames || []).filter((game) => game?.id && game.id !== 'freeform') +const visibleLinkedTemplates = computed(() => + (modalTargetCustomItem.value?.linkedGames || []).filter((template) => template?.id && template.id !== 'freeform') ) -const linkedCustomItemGameIds = computed(() => new Set(visibleLinkedGames.value.map((game) => game.id).filter(Boolean))) +const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean))) const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간')) const imageStatsYearOptions = computed(() => { @@ -709,7 +709,7 @@ async function cleanupMissingImageReferences() { try { imageMissingCleanupBusy.value = true const data = await api.cleanupAdminMissingImageReferences() - await Promise.all([refreshImageDiagnostics(), refreshGames(), refreshCustomItems(), refreshTemplateRequests()]) + await Promise.all([refreshImageDiagnostics(), refreshTemplates(), refreshCustomItems(), refreshTemplateRequests()]) const result = data.result || {} success.value = `누락 참조를 정리했어요. ` + @@ -732,7 +732,7 @@ function setTab(tab) { if (nextRouteName && route.name !== nextRouteName) { const nextQuery = tab === 'game-admin' - ? { gameId: selectedGameId.value || undefined } + ? { gameId: selectedTemplateId.value || undefined } : tab === 'tierlists' && tierlistsMode.value === 'all' ? { mode: 'all' } : {} @@ -755,43 +755,43 @@ function setTierlistsMode(mode) { tierlistsMode.value = mode } -function openGameCreateModal() { +function openTemplateCreateModal() { resetMessages() if (activeTemplateRequest.value?.type === 'create' && !activeTemplateRequest.value?.targetGameId) { - newGameId.value = activeTemplateRequest.value?.draftGameId || '' - newGameName.value = activeTemplateRequest.value?.draftGameName || '' - newGameIsPublic.value = !!activeTemplateRequest.value?.draftGameIsPublic + newTemplateId.value = activeTemplateRequest.value?.draftGameId || '' + newTemplateName.value = activeTemplateRequest.value?.draftGameName || '' + newTemplateIsPublic.value = !!activeTemplateRequest.value?.draftGameIsPublic } else { - newGameId.value = '' - newGameName.value = '' - newGameIsPublic.value = false + newTemplateId.value = '' + newTemplateName.value = '' + newTemplateIsPublic.value = false } - gameCreateModalOpen.value = true + templateCreateModalOpen.value = true } -function closeGameCreateModal() { - gameCreateModalOpen.value = false +function closeTemplateCreateModal() { + templateCreateModalOpen.value = false } -async function handleSelectedGameChange(event) { - selectedGameId.value = event?.target?.value || '' - await loadGame() +async function handleSelectedTemplateChange(event) { + selectedTemplateId.value = event?.target?.value || '' + await loadTemplate() } -async function selectAdminGame(gameId) { - if (!gameId || selectedGameId.value === gameId) return - selectedGameId.value = gameId - await loadGame() +async function selectAdminTemplate(templateId) { + if (!templateId || selectedTemplateId.value === templateId) return + selectedTemplateId.value = templateId + await loadTemplate() } -async function refreshGames() { +async function refreshTemplates() { try { const data = await api.listGames() - games.value = data.games || [] - featuredGameIds.value = games.value - .filter((game) => game.displayRank != null) + templates.value = data.games || [] + featuredTemplateIds.value = templates.value + .filter((template) => template.displayRank != null) .sort((a, b) => a.displayRank - b.displayRank) - .map((game) => game.id) + .map((template) => template.id) await syncFeaturedSortable() } catch (e) { error.value = '템플릿 목록을 불러오지 못했어요.' @@ -849,21 +849,21 @@ async function refreshAdminTierListStats() { } } -async function refreshSelectedGameTierListStats(gameId = '') { - if (!auth.user?.isAdmin || !gameId) { - selectedGameTierListStats.value = { total: 0, publicCount: 0, privateCount: 0 } +async function refreshSelectedTemplateTierListStats(templateId = '') { + if (!auth.user?.isAdmin || !templateId) { + selectedTemplateTierListStats.value = { total: 0, publicCount: 0, privateCount: 0 } return } try { - const data = await api.getAdminTierListStats({ gameId }) - selectedGameTierListStats.value = { + const data = await api.getAdminTierListStats({ gameId: templateId }) + selectedTemplateTierListStats.value = { total: data.total || 0, publicCount: data.publicCount || 0, privateCount: data.privateCount || 0, } } catch (e) { - selectedGameTierListStats.value = { total: 0, publicCount: 0, privateCount: 0 } + selectedTemplateTierListStats.value = { total: 0, publicCount: 0, privateCount: 0 } } } @@ -914,16 +914,16 @@ function resetUploadState() { const { destroyFeaturedSortable, syncFeaturedSortable, - addFeaturedGame, - removeFeaturedGame, - moveFeaturedGame, + addFeaturedTemplate, + removeFeaturedTemplate, + moveFeaturedTemplate, saveFeaturedOrder, } = useAdminFeaturedGames({ api, featuredListEl, featuredSortable, - featuredGameIds, - games, + featuredTemplateIds, + templates, resetMessages, success, error, @@ -934,19 +934,19 @@ const { syncGameItemSortable, mergeRequestItemsIntoDrafts, removeUploadDraft, - loadGame, - createGame, + loadTemplate, + createTemplate, handleItemFiles, onFile, openItemFilePicker, clearItemFiles, uploadItem, - saveGameItemOrder, + saveTemplateItemOrder, } = useAdminGameManager({ api, toApiUrl, - selectedGameId, - selectedGame, + selectedTemplateId, + selectedTemplate, uploadFiles, uploadItemDrafts, thumbFile, @@ -959,15 +959,15 @@ const { activeTemplateRequest, templateRequests, customItemModalOpen, - customItemModalTargetGameId, - newGameId, - newGameName, - newGameIsPublic, + customItemModalTargetTemplateId, + newTemplateId, + newTemplateName, + newTemplateIsPublic, clearPreviewUrl, resetFileInput, resetUploadState, - refreshGames, - closeGameCreateModal, + refreshTemplates, + closeTemplateCreateModal, resetMessages, success, error, @@ -984,10 +984,10 @@ const { activeTemplateRequest, refreshTemplateRequests, setTab, - openGameCreateModal, - newGameId, - newGameName, - selectAdminGame, + openTemplateCreateModal, + newTemplateId, + newTemplateName, + selectAdminTemplate, mergeRequestItemsIntoDrafts, resetMessages, success, @@ -1003,7 +1003,7 @@ const { closeCustomItemModal, openCustomItemDeleteModal, closeCustomItemDeleteModal, - jumpToGameAdmin, + jumpToTemplateAdmin, removeCustomItem, removeUnusedCustomItems, saveCustomItemModalLabel, @@ -1023,13 +1023,13 @@ const { modalTargetCustomItem, customItemModalDraftLabel, customItemModalLabelSaving, - customItemModalTargetGameId, - games, - selectedGameId, + customItemModalTargetTemplateId, + templates, + selectedTemplateId, refreshCustomItems, - loadGame, + loadTemplate, setTab, - selectAdminGame, + selectAdminTemplate, resetMessages, success, error, @@ -1139,7 +1139,7 @@ function onItemDrop(event) { async function uploadThumbnail() { resetMessages() - if (!thumbFile.value || !selectedGameId.value) { + if (!thumbFile.value || !selectedTemplateId.value) { error.value = '썸네일 파일을 선택해주세요.' return } @@ -1147,7 +1147,7 @@ async function uploadThumbnail() { try { const fd = new FormData() fd.append('thumbnail', thumbFile.value) - const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedGameId.value)}/thumbnail`), { + const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}/thumbnail`), { method: 'POST', credentials: 'include', body: fd, @@ -1157,29 +1157,29 @@ async function uploadThumbnail() { thumbFile.value = null resetFileInput('thumb') clearPreviewUrl('thumb') - await refreshGames() - await loadGame() + await refreshTemplates() + await loadTemplate() success.value = '썸네일이 반영됐어요.' } catch (e) { error.value = '썸네일 업로드 실패(관리자 권한/파일 크기 확인)' } } -async function saveGameVisibility() { - if (!selectedGame.value?.game?.id) return +async function saveTemplateVisibility() { + if (!selectedTemplate.value?.game?.id) return try { gameVisibilitySaving.value = true - const data = await api.updateAdminGame(selectedGame.value.game.id, { - isPublic: !!selectedGame.value.game.isPublic, + const data = await api.updateAdminGame(selectedTemplate.value.game.id, { + isPublic: !!selectedTemplate.value.game.isPublic, }) - selectedGame.value = { - ...selectedGame.value, + selectedTemplate.value = { + ...selectedTemplate.value, game: { - ...selectedGame.value.game, + ...selectedTemplate.value.game, ...data.game, }, } - await refreshGames() + await refreshTemplates() success.value = data.game?.isPublic ? '템플릿을 공개 상태로 전환했어요.' : '템플릿을 비공개 상태로 전환했어요.' return true } catch (e) { @@ -1190,33 +1190,33 @@ async function saveGameVisibility() { } } -async function toggleSelectedGameVisibility(nextValue) { - if (!selectedGame.value?.game?.id || gameVisibilitySaving.value) return - const previous = !!selectedGame.value.game.isPublic - selectedGame.value = { - ...selectedGame.value, +async function toggleSelectedTemplateVisibility(nextValue) { + if (!selectedTemplate.value?.game?.id || gameVisibilitySaving.value) return + const previous = !!selectedTemplate.value.game.isPublic + selectedTemplate.value = { + ...selectedTemplate.value, game: { - ...selectedGame.value.game, + ...selectedTemplate.value.game, isPublic: !!nextValue, }, } - const saved = await saveGameVisibility() + const saved = await saveTemplateVisibility() if (!saved) { - selectedGame.value = { - ...selectedGame.value, + selectedTemplate.value = { + ...selectedTemplate.value, game: { - ...selectedGame.value.game, + ...selectedTemplate.value.game, isPublic: previous, }, } } } -async function removeGameItem(itemId) { +async function removeTemplateItem(itemId) { resetMessages() try { const res = await fetch( - toApiUrl(`/api/admin/games/${encodeURIComponent(selectedGameId.value)}/items/${encodeURIComponent(itemId)}`), + toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}`), { method: 'DELETE', credentials: 'include', @@ -1224,16 +1224,16 @@ async function removeGameItem(itemId) { ) if (!res.ok) throw new Error('failed') - await loadGame() + await loadTemplate() success.value = '템플릿 기본 아이템을 삭제했어요.' } catch (e) { error.value = '템플릿 기본 아이템 삭제에 실패했어요.' } } -async function saveGameItemLabel(item) { +async function saveTemplateItemLabel(item) { resetMessages() - if (!selectedGameId.value) return + if (!selectedTemplateId.value) return const nextLabel = (item.draftLabel || '').trim() if (!nextLabel) { error.value = '아이템 이름을 입력해주세요.' @@ -1243,7 +1243,7 @@ async function saveGameItemLabel(item) { try { item.isSavingLabel = true - const data = await api.updateAdminGameItem(selectedGameId.value, item.id, { label: nextLabel }) + const data = await api.updateAdminGameItem(selectedTemplateId.value, item.id, { label: nextLabel }) item.label = data.item.label item.draftLabel = data.item.label success.value = '기본 아이템 이름을 수정했어요.' @@ -1254,25 +1254,25 @@ async function saveGameItemLabel(item) { } } -async function removeGame() { +async function removeTemplate() { resetMessages() - if (!selectedGameId.value || !selectedGame.value?.game) return + if (!selectedTemplateId.value || !selectedTemplate.value?.game) return - const ok = window.confirm(`"${selectedGame.value.game.name}" 템플릿을 삭제할까요? 관련 기본 아이템과 티어표도 함께 삭제됩니다.`) + const ok = window.confirm(`"${selectedTemplate.value.game.name}" 템플릿을 삭제할까요? 관련 기본 아이템과 티어표도 함께 삭제됩니다.`) if (!ok) return try { - const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedGameId.value)}`), { + const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedTemplateId.value)}`), { method: 'DELETE', credentials: 'include', }) if (!res.ok) throw new Error('failed') - const deletedName = selectedGame.value.game.name - selectedGameId.value = '' - selectedGame.value = null + const deletedName = selectedTemplate.value.game.name + selectedTemplateId.value = '' + selectedTemplate.value = null resetUploadState() - await refreshGames() + await refreshTemplates() success.value = `${deletedName} 템플릿을 삭제했어요.` } catch (e) { error.value = '템플릿 삭제에 실패했어요.' @@ -1290,34 +1290,34 @@ function setAdminTierListGameId(gameId) { refreshAdminTierLists() } -function openGamePickerModal(mode = 'game-admin') { - gamePickerMode.value = mode - gamePickerQuery.value = '' - gamePickerSort.value = 'recent' - gamePickerModalOpen.value = true +function openTemplatePickerModal(mode = 'game-admin') { + templatePickerMode.value = mode + templatePickerQuery.value = '' + templatePickerSort.value = 'recent' + templatePickerModalOpen.value = true } -function closeGamePickerModal() { - gamePickerModalOpen.value = false - gamePickerQuery.value = '' +function closeTemplatePickerModal() { + templatePickerModalOpen.value = false + templatePickerQuery.value = '' } -async function chooseGameFromPicker(gameId) { - if (!gameId) return - if (gamePickerMode.value === 'tierlists-filter') { - setAdminTierListGameId(gameId) - closeGamePickerModal() +async function chooseTemplateFromPicker(templateId) { + if (!templateId) return + if (templatePickerMode.value === 'tierlists-filter') { + setAdminTierListGameId(templateId) + closeTemplatePickerModal() return } - if (gamePickerMode.value === 'custom-item-target') { - if (linkedCustomItemGameIds.value.has(gameId)) return - customItemModalTargetGameId.value = gameId - closeGamePickerModal() + if (templatePickerMode.value === 'custom-item-target') { + if (linkedCustomItemTemplateIds.value.has(templateId)) return + customItemModalTargetTemplateId.value = templateId + closeTemplatePickerModal() return } - await selectAdminGame(gameId) - closeGamePickerModal() + await selectAdminTemplate(templateId) + closeTemplatePickerModal() } function changeAdminTierListLimit(limit) { @@ -1410,7 +1410,7 @@ async function saveAdminTierListMeta() { adminTierLists.value = adminTierLists.value.map((tierList) => (tierList.id === updated.id ? { ...tierList, ...updated } : tierList)) if (previewTierList.value?.id === updated.id) previewTierList.value = { ...previewTierList.value, ...updated } modalTargetAdminTierList.value = updated - await Promise.all([refreshAdminTierListStats(), refreshSelectedGameTierListStats(selectedGame.value?.game?.id || '')]) + await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.game?.id || '')]) success.value = '티어표 정보를 수정했어요.' closeAdminTierListManageModal() } catch (e) { @@ -1432,7 +1432,7 @@ async function deleteAdminTierListEntry() { adminTierLists.value = adminTierLists.value.filter((tierList) => tierList.id !== modalTargetAdminTierList.value.id) adminTierListTotal.value = Math.max(0, adminTierListTotal.value - 1) if (previewTierList.value?.id === modalTargetAdminTierList.value.id) previewTierList.value = null - await Promise.all([refreshAdminTierListStats(), refreshSelectedGameTierListStats(selectedGame.value?.game?.id || '')]) + await Promise.all([refreshAdminTierListStats(), refreshSelectedTemplateTierListStats(selectedTemplate.value?.game?.id || '')]) success.value = '티어표를 삭제했어요.' closeAdminTierListManageModal() if (!adminTierLists.value.length && adminTierListPage.value > 1) { @@ -1544,9 +1544,9 @@ function openTierListImportModal(tierList, items) { importModalTierList.value = tierList importModalItems.value = nextItems importModalMode.value = 'existing' - importModalTargetGameId.value = '' - importModalNewGameId.value = tierList.gameId === 'freeform' ? '' : `${tierList.gameId}-copy` - importModalNewGameName.value = + importModalTargetTemplateId.value = '' + importModalNewTemplateId.value = tierList.gameId === 'freeform' ? '' : `${tierList.gameId}-copy` + importModalNewTemplateName.value = tierList.gameId === 'freeform' ? `${tierList.title} 템플릿` : `${tierList.gameName || tierList.gameId} 파생 템플릿` importModalOpen.value = true } @@ -1569,20 +1569,20 @@ async function confirmTierListImport() { try { if (importModalMode.value === 'existing') { - if (!importModalTargetGameId.value) { + if (!importModalTargetTemplateId.value) { error.value = '아이템을 추가할 기존 템플릿을 선택해주세요.' return } const data = await api.promoteAdminTierListItems(tierList.id, { - gameId: importModalTargetGameId.value, + gameId: importModalTargetTemplateId.value, itemIds, }) - if (selectedGameId.value === importModalTargetGameId.value) await loadGame() + if (selectedTemplateId.value === importModalTargetTemplateId.value) await loadTemplate() success.value = `${data.items?.length || 0}개의 아이템을 기존 템플릿에 추가했어요.` } else { - const nextGameId = (importModalNewGameId.value || '').trim() - const nextGameName = (importModalNewGameName.value || '').trim() + const nextGameId = (importModalNewTemplateId.value || '').trim() + const nextGameName = (importModalNewTemplateName.value || '').trim() if (!nextGameId || !nextGameName) { error.value = '새 템플릿 ID와 이름을 모두 입력해주세요.' return @@ -1593,7 +1593,7 @@ async function confirmTierListImport() { name: nextGameName, itemIds, }) - await refreshGames() + await refreshTemplates() success.value = `"${data.game?.name || nextGameName}" 템플릿을 생성했어요.` } @@ -1619,7 +1619,7 @@ function templateRequestTargetLabel(request) { const displayThumbnailUrl = computed(() => { if (thumbPreviewUrl.value) return thumbPreviewUrl.value - if (selectedGame.value?.game?.thumbnailSrc) return toApiUrl(selectedGame.value.game.thumbnailSrc) + if (selectedTemplate.value?.game?.thumbnailSrc) return toApiUrl(selectedTemplate.value.game.thumbnailSrc) return '' }) @@ -1668,14 +1668,14 @@ function userAvatarFallback(user) { -
+
@@ -1924,15 +1924,15 @@ function userAvatarFallback(user) {
- - +
- - + +
@@ -1949,17 +1949,17 @@ function userAvatarFallback(user) {
@@ -1983,22 +1983,22 @@ function userAvatarFallback(user) {
파일{{ modalTargetCustomItem.src.split('/').pop() }}
업로더/출처{{ modalTargetCustomItem.ownerName }}
-
템플릿 연결{{ visibleLinkedGames.length }}개 템플릿
+
템플릿 연결{{ visibleLinkedTemplates.length }}개 템플릿
등록일{{ fmt(modalTargetCustomItem.createdAt) }}
이 이미지를 사용하는 템플릿 -
- +
+
아직 템플릿에 연결된 항목이 없어요.
이미지 다운로드 - - +
@@ -2006,54 +2006,54 @@ function userAvatarFallback(user) {
-
+
@@ -2211,14 +2211,14 @@ function userAvatarFallback(user) {
Template
- - -
+ + +
선택한 템플릿
-
{{ selectedGame.game.name }}
-
{{ selectedGame.game.id }}
+
{{ selectedTemplate.game.name }}
+
{{ selectedTemplate.game.id }}
-
선택된 템플릿 ID: {{ selectedGameId }}
+
선택된 템플릿 ID: {{ selectedTemplateId }}
@@ -2282,10 +2282,10 @@ function userAvatarFallback(user) { />
- +
필터된 주제
-
{{ games.find((game) => game.id === adminTierListGameId)?.name || adminTierListGameId }}
+
{{ templates.find((template) => template.id === adminTierListGameId)?.name || adminTierListGameId }}
{{ adminTierListGameId }}