diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 4506199..7cfb4b7 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -508,7 +508,17 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => { const customItem = await findCustomItemById(req.params.itemId) const gameItem = customItem ? null : await findGameItemById(req.params.itemId) - const sourceItem = customItem || gameItem + const assetItemId = String(req.params.itemId || '') + const imageAsset = !customItem && !gameItem && assetItemId.startsWith('asset:') ? await findImageAssetById(assetItemId.slice(6)) : null + const sourceItem = + customItem || + gameItem || + (imageAsset + ? { + src: imageAsset.src || '', + label: imageAsset.labelOverride || path.basename(imageAsset.src || '', path.extname(imageAsset.src || '')) || 'item', + } + : null) if (!sourceItem) return res.status(404).json({ error: 'not_found' }) const item = await promoteLibraryItemToGameItem({ item: sourceItem, gameId: game.id }) diff --git a/docs/todo.md b/docs/todo.md index f2541e1..9fc4689 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,4 @@ # 할 일 및 이슈 -- 아이템 관리, 티어표 관리, 전체 티어표 관리 등에서 아이템의 이름을 관리자가 직접 수정할 수 있어야 한다. -(사용자가 이름없이 파일을 그대로 올렸을 경우 해당 아이템을 사용 할 수 없기 때문) ## 중기 개선 - 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다. diff --git a/docs/update.md b/docs/update.md index a5d305e..fb48ab7 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-01 v1.3.40 +- 관리자 아이템 상세 모달은 내부 스크롤바를 숨기고 본문 스크롤이 배경으로 전파되지 않도록 body scroll lock과 ESC 닫기를 추가해, 검수 중 배경 화면이 함께 움직이던 불편을 줄임. +- 관리자 티어표 관리에서는 `완성본 보기` 흐름을 제거하고, 전체 티어표의 추가 아이템을 클릭하면 같은 아이템 관리 모달로 열어 게임 검색·템플릿 추가·새 템플릿 생성까지 같은 문법으로 처리할 수 있게 통일함. +- 템플릿 요청 관리의 `요청 미리보기`는 단순 썸네일이 아니라 행·열 구조, 열 이름, 배치된 아이템, 미사용 아이템까지 함께 보이는 실제 보드형 미리보기로 다시 구성해 요청 내용을 한 번에 검수할 수 있게 함. + ## 2026-04-01 v1.3.39 - 관리자 템플릿 요청 미리보기는 요청 시 저장된 보드 스냅샷이 비어 있을 경우 요청 아이템 배열을 fallback으로 사용해, 대표 썸네일만 보이는 상황을 줄이고 요청 내용을 더 안정적으로 확인할 수 있게 보정함. - 관리자 아이템 상세 모달에는 아이템 이름 입력과 저장 버튼을 추가해, 템플릿 아이템·사용자 업로드·보관 자산 모두 파일명과 무관하게 사람이 읽기 좋은 이름으로 다시 정리할 수 있게 함. diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 8956ec9..24a5368 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -98,6 +98,7 @@ const featuredSortable = ref(null) const userAvatarInputs = ref({}) const isGameLoading = ref(false) const gameCreateModalOpen = ref(false) +const previousBodyOverflow = ref('') const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id) const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value) @@ -196,6 +197,19 @@ const adminOverviewStats = computed(() => { { label: '활동 계정', value: `${users.value.filter((user) => user.tierListCount > 0).length}` }, ] }) +const isAnyModalOpen = computed( + () => + gameCreateModalOpen.value || + userEditModalOpen.value || + userPasswordModalOpen.value || + userDeleteModalOpen.value || + userRoleModalOpen.value || + importModalOpen.value || + customItemModalOpen.value || + customItemDeleteModalOpen.value || + imageResetModalOpen.value || + previewModalOpen.value +) function handleAdminPopState() { if (customItemDeleteModalOpen.value) { @@ -208,15 +222,40 @@ function handleAdminPopState() { } } +function handleAdminKeydown(event) { + if (event.key !== 'Escape') return + if (customItemDeleteModalOpen.value) { + event.preventDefault() + closeCustomItemDeleteModal() + return + } + if (customItemModalOpen.value) { + event.preventDefault() + closeCustomItemModal() + return + } + if (previewModalOpen.value) { + event.preventDefault() + closePreviewModal() + } +} + onMounted(async () => { - if (typeof window !== 'undefined') window.addEventListener('popstate', handleAdminPopState) + if (typeof window !== 'undefined') { + window.addEventListener('popstate', handleAdminPopState) + window.addEventListener('keydown', handleAdminKeydown) + } await auth.refresh() await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests(), refreshImageDiagnostics()]) await syncFeaturedSortable() }) onUnmounted(() => { - if (typeof window !== 'undefined') window.removeEventListener('popstate', handleAdminPopState) + if (typeof window !== 'undefined') { + window.removeEventListener('popstate', handleAdminPopState) + window.removeEventListener('keydown', handleAdminKeydown) + } + if (typeof document !== 'undefined') document.body.style.overflow = previousBodyOverflow.value || '' clearPreviewUrl('item') clearPreviewUrl('thumb') destroyFeaturedSortable() @@ -267,6 +306,22 @@ watch( } ) + +watch( + () => isAnyModalOpen.value, + (open) => { + if (typeof document === 'undefined') return + if (open) { + if (!previousBodyOverflow.value) previousBodyOverflow.value = document.body.style.overflow || '' + document.body.style.overflow = 'hidden' + return + } + document.body.style.overflow = previousBodyOverflow.value || '' + previousBodyOverflow.value = '' + }, + { immediate: true } +) + function resetMessages() { error.value = '' success.value = '' @@ -680,6 +735,7 @@ async function createGame() { const data = await res.json() await refreshGames() selectedGameId.value = data.game.id + if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id closeGameCreateModal() await loadGame() success.value = '게임이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.' @@ -1203,6 +1259,32 @@ async function promoteCustomItem(item) { } } + +function buildModalItemFromTierListItem(item, tierList) { + const matchedItem = customItems.value.find((entry) => entry.id === item?.id || entry.src === item?.src) + const id = matchedItem?.id || item?.id || '' + return { + ...matchedItem, + ...item, + id, + label: item?.label || matchedItem?.label || '이름 없음', + src: item?.src || matchedItem?.src || '', + sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'template' : 'user'), + sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템', + ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList), + linkedGames: Array.isArray(matchedItem?.linkedGames) ? matchedItem.linkedGames : [], + usageCount: matchedItem?.usageCount || 0, + canDelete: typeof matchedItem?.canDelete === 'boolean' ? matchedItem.canDelete : false, + isPromoting: false, + createdAt: matchedItem?.createdAt || item?.createdAt || tierList?.updatedAt || tierList?.createdAt || Date.now(), + } +} + +function openTierListExtraItemModal(item, tierList) { + if (!item) return + openCustomItemModal(buildModalItemFromTierListItem(item, tierList)) +} + function tierListThumbUrl(tierList) { return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : '' } @@ -1233,6 +1315,38 @@ function previewRequestGroupItems(preview, group) { return (group?.itemIds || []).map((itemId) => itemsById[itemId]).filter(Boolean) } + +function previewRequestColumns(preview) { + const groups = Array.isArray(preview?.snapshotGroups) ? preview.snapshotGroups : [] + const columnSource = groups.find((group) => Array.isArray(group?.columnNames) && group.columnNames.length) || null + const namedColumns = Array.isArray(columnSource?.columnNames) ? columnSource.columnNames : [] + const cellCount = Math.max(1, namedColumns.length, ...groups.map((group) => (Array.isArray(group?.cells) ? group.cells.length : 0))) + + return Array.from({ length: cellCount }, (_, index) => ({ + id: namedColumns[index]?.id || ('column-' + index), + name: namedColumns[index]?.name || '', + })) +} + +function previewRequestHasColumns(preview) { + const columns = previewRequestColumns(preview) + return columns.length > 1 || columns.some((column) => column.name) +} + +function previewRequestGridStyle(preview) { + const count = previewRequestColumns(preview).length + return { gridTemplateColumns: 'repeat(' + count + ', minmax(0, 1fr))' } +} + +function previewRequestGroupCellItems(preview, group, columnIndex) { + const itemsById = previewRequestItemsById(preview) + if (Array.isArray(group?.cells?.[columnIndex])) { + return group.cells[columnIndex].map((itemId) => itemsById[itemId]).filter(Boolean) + } + if (columnIndex === 0) return previewRequestGroupItems(preview, group) + return [] +} + function previewRequestPoolItems(preview) { const groupedIds = new Set((preview?.snapshotGroups || []).flatMap((group) => group.itemIds || [])) return (preview?.snapshotItems || []).filter((item) => !groupedIds.has(item.id)) @@ -1709,7 +1823,7 @@ async function saveFeaturedOrder() {
조건에 맞는 티어표가 없어요.
-
+
@@ -1724,7 +1838,6 @@ async function saveFeaturedOrder() {
{{ fmt(tierList.updatedAt) }}
-
@@ -1735,7 +1848,7 @@ async function saveFeaturedOrder() {
추가로 넣은 아이템
- @@ -2009,6 +2122,7 @@ async function saveFeaturedOrder() {
GAME PICKER
템플릿으로 추가할 게임
+
@@ -2044,6 +2158,7 @@ async function saveFeaturedOrder() {
{{ modalTargetCustomItem.sourceLabel }}
+
-
파일{{ modalTargetCustomItem.src.split('/').pop() }}
업로더/출처{{ modalTargetCustomItem.ownerName }}
@@ -2110,32 +2224,64 @@ async function saveFeaturedOrder() {
{{ previewTierList?.title || '티어표 미리보기' }}
+
- -
{{ previewTierList.description }}
-
-
-
{{ group.name }}
-
+
+
+
{{ previewTierList.description }}
+
+ {{ previewRequestHasColumns(previewTierList) ? (previewRequestColumns(previewTierList).length + '열 구성') : '단일 열 구성' }} · + {{ previewTierList.snapshotGroups?.length || 0 }}개 행 · + {{ previewTierList.snapshotItems?.length || 0 }}개 아이템 +
+
+ +
+
+
+
+
- -
{{ item.label }}
+ {{ column.name || ('열 ' + (columnIndex + 1)) }} +
+
+
+
+
+
{{ group.name }}
+
+
+
+
+ +
{{ item.label }}
+
+
+
남은 아이템
-
+