관리자 화면 분리 및 요청/아이템 관리 흐름 정리
This commit is contained in:
@@ -35,7 +35,8 @@ provide('localRightRailTarget', '#local-right-rail-root')
|
||||
const authReady = computed(() => auth.hydrated)
|
||||
const isAdmin = computed(() => authReady.value && !!auth.user?.isAdmin)
|
||||
const isPreviewMode = computed(() => route.query.preview === '1')
|
||||
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor', 'admin'].includes(String(route.name || '')))
|
||||
const isAdminRoute = computed(() => String(route.name || '').startsWith('admin'))
|
||||
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor'].includes(String(route.name || '')) || isAdminRoute.value)
|
||||
const isRightRailOverlay = computed(() => viewportWidth.value <= 1200)
|
||||
const isMobileLayout = computed(() => viewportWidth.value <= 860)
|
||||
const avatarUrl = computed(() => (auth.user?.avatarSrc ? toApiUrl(auth.user.avatarSrc) : ''))
|
||||
@@ -179,7 +180,7 @@ const routeMeta = computed(() => {
|
||||
action: () => router.push('/'),
|
||||
}
|
||||
}
|
||||
if (route.name === 'admin') {
|
||||
if (isAdminRoute.value) {
|
||||
return {
|
||||
title: 'Admin Workspace',
|
||||
subtitle: '게임·아이템·회원 관리',
|
||||
@@ -471,7 +472,7 @@ function submitGlobalSearch() {
|
||||
<SvgIcon :src="iconMenuBook" :size="18" class="adminButton__icon" />
|
||||
<span>가이드 보기</span>
|
||||
</button>
|
||||
<RouterLink v-if="authReady && isAdmin" to="/admin" class="adminButton">관리자 메뉴</RouterLink>
|
||||
<RouterLink v-if="authReady && isAdmin" to="/admin/featured" class="adminButton">관리자 메뉴</RouterLink>
|
||||
<RouterLink v-else-if="authReady && !auth.user" to="/login" class="adminButton">로그인</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
64
frontend/src/components/admin/AdminFeaturedSection.vue
Normal file
64
frontend/src/components/admin/AdminFeaturedSection.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
featuredGames: { type: Array, required: true },
|
||||
availableGamesForFeatured: { type: Array, required: true },
|
||||
featuredGameIds: { type: Array, required: true },
|
||||
featuredListRef: { type: Function, required: true },
|
||||
saveFeaturedOrder: { type: Function, required: true },
|
||||
moveFeaturedGame: { type: Function, required: true },
|
||||
removeFeaturedGame: { type: Function, required: true },
|
||||
addFeaturedGame: { type: Function, required: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">홈 화면 상단 고정 순서</div>
|
||||
<div class="hint hint--tight">여기에 넣은 게임은 지정한 순서대로 먼저 노출되고, 나머지 게임은 최근 생성순으로 뒤에 이어집니다. 최대 50개까지 설정할 수 있어요.</div>
|
||||
</div>
|
||||
<button class="btn btn--primary" @click="props.saveFeaturedOrder">순서 저장</button>
|
||||
</div>
|
||||
|
||||
<div class="featuredOrderPanel">
|
||||
<div class="featuredOrderPanel__list">
|
||||
<div class="section__title">상단 고정 목록</div>
|
||||
<div v-if="!props.featuredGames.length" class="hint">아직 상단 고정 게임이 없어요.</div>
|
||||
<div v-else :ref="props.featuredListRef" class="featuredList">
|
||||
<article v-for="(game, index) in props.featuredGames" :key="game.id" class="featuredCard" :data-featured-id="game.id">
|
||||
<div class="featuredCard__meta">
|
||||
<span class="featuredCard__rank">{{ index + 1 }}</span>
|
||||
<div>
|
||||
<div class="featuredCard__title">{{ game.name }}</div>
|
||||
<div class="featuredCard__id">{{ game.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="featuredCard__actions">
|
||||
<button class="btn btn--ghost btn--small" data-featured-handle>드래그</button>
|
||||
<button class="btn btn--ghost btn--small" :disabled="index === 0" @click="props.moveFeaturedGame(game.id, -1)">위로</button>
|
||||
<button class="btn btn--ghost btn--small" :disabled="index === props.featuredGames.length - 1" @click="props.moveFeaturedGame(game.id, 1)">아래로</button>
|
||||
<button class="btn btn--danger btn--small" @click="props.removeFeaturedGame(game.id)">제외</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="featuredOrderPanel__picker">
|
||||
<div class="section__title">게임 추가</div>
|
||||
<div class="featuredPickerList">
|
||||
<button
|
||||
v-for="game in props.availableGamesForFeatured"
|
||||
:key="game.id"
|
||||
class="featuredPickerItem"
|
||||
:disabled="props.featuredGameIds.length >= 50"
|
||||
@click="props.addFeaturedGame(game.id)"
|
||||
>
|
||||
<span>{{ game.name }}</span>
|
||||
<span class="featuredPickerItem__id">{{ game.id }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
171
frontend/src/components/admin/AdminGamesSection.vue
Normal file
171
frontend/src/components/admin/AdminGamesSection.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<script setup>
|
||||
import { toApiUrl } from '../../lib/runtime'
|
||||
|
||||
const props = defineProps({
|
||||
activeTemplateRequest: { type: Object, default: null },
|
||||
templateRequestSourceUrl: { type: Function, required: true },
|
||||
stagedRequestDraftCount: { type: Number, required: true },
|
||||
openGameCreateModal: { type: Function, required: true },
|
||||
isGameLoading: { type: Boolean, required: true },
|
||||
hasSelectedGame: { type: Boolean, required: true },
|
||||
selectedGame: { type: Object, default: null },
|
||||
itemFileInputRef: { type: Function, required: true },
|
||||
onFile: { type: Function, required: true },
|
||||
isItemDragOver: { type: Boolean, required: true },
|
||||
onItemDragEnter: { type: Function, required: true },
|
||||
onItemDragOver: { type: Function, required: true },
|
||||
onItemDragLeave: { type: Function, required: true },
|
||||
onItemDrop: { type: Function, required: true },
|
||||
openItemFilePicker: { type: Function, required: true },
|
||||
uploadItemDrafts: { type: Array, required: true },
|
||||
clearItemFiles: { type: Function, required: true },
|
||||
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 },
|
||||
gameItemListRef: { type: Function, required: true },
|
||||
saveGameItemLabel: { type: Function, required: true },
|
||||
removeGameItem: { type: Function, required: true },
|
||||
selectedGameId: { type: String, default: '' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="props.activeTemplateRequest" class="panel requestWorkspace">
|
||||
<div class="requestWorkspace__head">
|
||||
<div>
|
||||
<div class="panel__title">진행 중인 요청 작업</div>
|
||||
<div class="requestWorkspace__title">{{ props.activeTemplateRequest.sourceTierListTitle || '템플릿 요청' }}</div>
|
||||
<div class="hint hint--tight">
|
||||
{{ props.activeTemplateRequest.type === 'create' ? '새 게임을 만든 뒤 필요한 아이템만 골라 저장하세요.' : '필요한 아이템만 남긴 뒤 기본 아이템 추가 버튼으로 반영하세요.' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="requestWorkspace__stats">
|
||||
<span class="pill pill--accent">{{ props.activeTemplateRequest.type === 'create' ? '신규 게임 요청' : '기존 게임 업데이트' }}</span>
|
||||
<span class="pill">요청 아이템 {{ props.stagedRequestDraftCount }}개</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="requestWorkspace__actions">
|
||||
<a
|
||||
v-if="props.templateRequestSourceUrl(props.activeTemplateRequest)"
|
||||
class="btn btn--ghost btn--small"
|
||||
:href="props.templateRequestSourceUrl(props.activeTemplateRequest)"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
요청 티어표 보기
|
||||
</a>
|
||||
<button v-if="props.activeTemplateRequest.type === 'create'" class="btn btn--ghost btn--small" type="button" @click="props.openGameCreateModal">
|
||||
새 게임 만들기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.isGameLoading" class="panel panel--empty">
|
||||
<div class="emptyState">
|
||||
<div class="emptyState__title">게임 정보를 불러오는 중이에요.</div>
|
||||
<div class="emptyState__desc">선택한 게임의 썸네일과 기본 아이템을 곧 표시합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="props.hasSelectedGame" class="panel">
|
||||
<div class="detailHead">
|
||||
<div>
|
||||
<div class="panel__title">선택된 게임 정보</div>
|
||||
<div class="selectedGame__name">{{ props.selectedGame.game.name }}</div>
|
||||
<div class="selectedGame__id">{{ props.selectedGame.game.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<section class="adminCard">
|
||||
<div class="section__title">기본 아이템 추가</div>
|
||||
<div class="itemComposer">
|
||||
<div class="itemComposer__form">
|
||||
<input :ref="props.itemFileInputRef" type="file" accept="image/*" multiple class="srOnlyInput" @change="props.onFile" />
|
||||
<div
|
||||
class="dropZone"
|
||||
:class="{ 'dropZone--active': props.isItemDragOver }"
|
||||
@dragenter="props.onItemDragEnter"
|
||||
@dragover="props.onItemDragOver"
|
||||
@dragleave="props.onItemDragLeave"
|
||||
@drop="props.onItemDrop"
|
||||
>
|
||||
<div class="dropZone__title">이미지를 드래그해서 기본 아이템으로 추가</div>
|
||||
<div class="dropZone__desc">
|
||||
여러 파일을 한 번에 올릴 수 있고, 저장 라벨은 파일명으로 자동 생성됩니다.
|
||||
<span v-if="props.stagedRequestDraftCount"> 현재 요청에서 가져온 아이템 {{ props.stagedRequestDraftCount }}개도 함께 검토 중이에요.</span>
|
||||
</div>
|
||||
<div class="dropZone__actions">
|
||||
<button class="btn btn--ghost btn--small" type="button" @click="props.openItemFilePicker">파일 선택</button>
|
||||
<button class="btn btn--danger btn--small" type="button" :disabled="!props.uploadItemDrafts.length" @click="props.clearItemFiles">선택 비우기</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn" :disabled="!props.canAddItem" @click="props.uploadItem">
|
||||
아이템 {{ props.uploadItemDrafts.length || 0 }}개 추가
|
||||
</button>
|
||||
</div>
|
||||
<div class="itemPreviewCard">
|
||||
<div v-if="props.uploadItemDrafts.length" class="itemDraftList">
|
||||
<div
|
||||
v-for="draft in props.uploadItemDrafts"
|
||||
:key="draft.kind + ':' + (draft.itemId || draft.file?.name || draft.previewUrl)"
|
||||
class="itemDraftRow"
|
||||
>
|
||||
<div class="itemDraftRow__preview">
|
||||
<img class="itemPreviewImage" :src="draft.previewUrl" :alt="draft.sourceName || 'item preview'" />
|
||||
</div>
|
||||
<div class="itemDraftRow__body">
|
||||
<input v-model="draft.label" class="input input--labelEdit input--dense" maxlength="60" placeholder="아이템 이름" />
|
||||
<div class="hint hint--tight">{{ draft.sourceName }}</div>
|
||||
<div class="itemDraftRow__meta">
|
||||
<span class="pill pill--soft">{{ draft.kind === 'request' ? '요청 아이템' : '직접 추가 파일' }}</span>
|
||||
<button class="btn btn--danger btn--small" type="button" @click="props.removeUploadDraft(draft)">제외</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="itemPreviewEmpty">등록한 기본 아이템 미리보기가 여기에 표시됩니다.</div>
|
||||
<div class="thumbLabel thumbLabel--preview">
|
||||
{{ props.uploadItemDrafts.length ? `추가 예정 아이템 ${props.uploadItemDrafts.length}개` : '아직 선택된 파일이 없어요.' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="section__title">현재 기본 아이템 목록</div>
|
||||
<div class="hint hint--tight">드래그해서 기본 노출 순서를 바꿀 수 있어요. 저장 전까지는 화면에서만 순서가 바뀝니다.</div>
|
||||
</div>
|
||||
<button class="btn btn--primary btn--small" :disabled="!props.hasGameItemOrderChanges" @click="props.saveGameItemOrder">순서 저장</button>
|
||||
</div>
|
||||
<div v-if="!props.selectedGame?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
|
||||
<div v-else :ref="props.gameItemListRef" class="thumbGrid">
|
||||
<div v-for="item in props.selectedGame.items" :key="item.id" class="thumbCard" :data-game-item-id="item.id">
|
||||
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" />
|
||||
<div class="thumbCard__actions">
|
||||
<button
|
||||
class="btn btn--ghost btn--small"
|
||||
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label"
|
||||
@click="props.saveGameItemLabel(item)"
|
||||
>
|
||||
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
|
||||
</button>
|
||||
<button class="btn btn--danger btn--small" @click="props.removeGameItem(item.id)">아이템 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="panel panel--empty">
|
||||
<div class="emptyState">
|
||||
<div class="emptyState__title">게임을 선택해 주세요.</div>
|
||||
<div v-if="props.activeTemplateRequest?.type === 'create'" class="hint hint--tight">진행 중인 신규 게임 요청이 있어요. 위의 `새 게임 만들기`로 게임을 만든 뒤 아이템을 추가할 수 있습니다.</div>
|
||||
<div v-if="props.selectedGameId" class="hint hint--tight">선택한 게임을 찾지 못했거나 로딩 중 오류가 발생했어요. 다시 선택해보세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
31
frontend/src/components/admin/AdminItemsSection.vue
Normal file
31
frontend/src/components/admin/AdminItemsSection.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
import { toApiUrl } from '../../lib/runtime'
|
||||
|
||||
const props = defineProps({
|
||||
customItems: { type: Array, required: true },
|
||||
openCustomItemModal: { type: Function, required: true },
|
||||
customItemPage: { type: Number, required: true },
|
||||
customItemPageCount: { type: Number, required: true },
|
||||
customItemTotal: { type: Number, required: true },
|
||||
moveCustomItemPage: { type: Function, required: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div v-if="!props.customItems.length" class="hint">조건에 맞는 관리 대상 아이템이 없어요.</div>
|
||||
<div v-else class="customItemGrid">
|
||||
<button v-for="item in props.customItems" :key="item.id" type="button" class="customItemCard" @click="props.openCustomItemModal(item)">
|
||||
<span class="customItemCard__badge" :class="{ 'customItemCard__badge--template': item.sourceType === 'template' }">{{ item.sourceLabel }}</span>
|
||||
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pager">
|
||||
<button class="btn btn--ghost" :disabled="props.customItemPage <= 1" @click="props.moveCustomItemPage(-1)">이전</button>
|
||||
<div class="pager__info">{{ props.customItemPage }} / {{ props.customItemPageCount }} 페이지 · 총 {{ props.customItemTotal }}개</div>
|
||||
<button class="btn btn--ghost" :disabled="props.customItemPage >= props.customItemPageCount" @click="props.moveCustomItemPage(1)">다음</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
174
frontend/src/components/admin/AdminTierlistsSection.vue
Normal file
174
frontend/src/components/admin/AdminTierlistsSection.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<script setup>
|
||||
import { toApiUrl } from '../../lib/runtime'
|
||||
|
||||
const props = defineProps({
|
||||
tierlistsMode: { type: String, required: true },
|
||||
templateRequests: { type: Array, required: true },
|
||||
openTemplateRequestPreview: { type: Function, required: true },
|
||||
templateRequestTypeLabel: { type: Function, required: true },
|
||||
fmt: { type: Function, required: true },
|
||||
templateRequestTargetLabel: { type: Function, required: true },
|
||||
templateRequestStatusLabel: { type: Function, required: true },
|
||||
templateRequestSourceUrl: { type: Function, required: true },
|
||||
templateRequestReviewHint: { type: Function, required: true },
|
||||
startTemplateRequestReview: { type: Function, required: true },
|
||||
completeTemplateRequest: { type: Function, required: true },
|
||||
adminTierLists: { type: Array, required: true },
|
||||
tierListThumbUrl: { type: Function, required: true },
|
||||
openAdminTierList: { type: Function, required: true },
|
||||
tierListAuthorDisplayName: { type: Function, required: true },
|
||||
tierListVisibilityLabel: { type: Function, required: true },
|
||||
openTierListExtraItemModal: { type: Function, required: true },
|
||||
openTierListImportModal: { type: Function, required: true },
|
||||
adminTierListPage: { type: Number, required: true },
|
||||
adminTierListPageCount: { type: Number, required: true },
|
||||
adminTierListTotal: { type: Number, required: true },
|
||||
moveAdminTierListPage: { type: Function, required: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="props.tierlistsMode === 'requests'" class="panel">
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">사용자 요청</div>
|
||||
<div class="hint hint--tight">요청 카드는 미확인/확인함 상태로 관리하고, 실제 아이템 반영은 게임 관리 화면에서 직접 진행합니다. 처리 완료를 눌러야 카드가 목록에서 빠져요.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!props.templateRequests.length" class="hint">현재 처리 대기 중인 템플릿 요청이 없어요.</div>
|
||||
<div v-else class="templateRequestList">
|
||||
<article v-for="request in props.templateRequests" :key="request.id" class="tierAdminCard templateRequestCard templateRequestCard--aligned">
|
||||
<div class="templateRequestCard__side">
|
||||
<button class="tierAdminCard__preview templateRequestCard__preview" type="button" @click="props.openTemplateRequestPreview(request)">
|
||||
<img v-if="request.thumbnailSrc" class="tierAdminCard__thumb" :src="toApiUrl(request.thumbnailSrc)" :alt="request.sourceTierListTitle" />
|
||||
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
|
||||
</button>
|
||||
<div class="templateRequestCard__thumbMeta">
|
||||
<template v-if="request.type === 'create'">
|
||||
<label class="templateRequestField">
|
||||
<span class="templateRequestField__label">게임 이름</span>
|
||||
<input v-model="request.draftGameName" class="input" placeholder="새 게임 이름" />
|
||||
</label>
|
||||
<label class="templateRequestField">
|
||||
<span class="templateRequestField__label">게임 ID</span>
|
||||
<input v-model="request.draftGameId" class="input" placeholder="임시 게임 ID" />
|
||||
</label>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="templateRequestCard__thumbLabel">게임 이름</div>
|
||||
<div class="templateRequestCard__thumbValue">{{ request.draftGameName || request.sourceGameName || '-' }}</div>
|
||||
<div class="templateRequestCard__thumbLabel">게임 ID</div>
|
||||
<div class="templateRequestCard__thumbValue">{{ request.draftGameId || request.sourceGameId || '-' }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tierAdminCard__body">
|
||||
<div class="tierAdminCard__head">
|
||||
<div>
|
||||
<div class="tierAdminCard__title">{{ request.sourceTierListTitle }}</div>
|
||||
<div v-if="request.sourceDescription" class="tierAdminCard__desc">{{ request.sourceDescription }}</div>
|
||||
<div class="tierAdminCard__meta">
|
||||
{{ props.templateRequestTypeLabel(request) }} · {{ request.requesterName }} · {{ props.fmt(request.createdAt) }}
|
||||
</div>
|
||||
<div class="tierAdminCard__meta">{{ props.templateRequestTargetLabel(request) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tierAdminCard__stats">
|
||||
<span class="pill">추가 아이템 {{ request.items?.length || 0 }}개</span>
|
||||
<span class="pill">{{ request.type === 'create' ? '새 템플릿' : '기존 템플릿 업데이트' }}</span>
|
||||
<span class="pill" :class="{ 'pill--accent': request.status === 'reviewing' }">{{ props.templateRequestStatusLabel(request) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="request.items?.length" class="tierAdminItemList templateRequestCard__items">
|
||||
<button v-for="item in request.items" :key="item.id" class="tierAdminItem" type="button">
|
||||
<img class="tierAdminItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<div class="tierAdminItem__title">{{ item.label }}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="templateRequestCard__links">
|
||||
<a
|
||||
v-if="props.templateRequestSourceUrl(request)"
|
||||
class="btn btn--ghost btn--small"
|
||||
:href="props.templateRequestSourceUrl(request)"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
요청 티어표 보기
|
||||
</a>
|
||||
<div class="hint hint--tight">{{ props.templateRequestReviewHint(request) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="templateRequestCard__actions">
|
||||
<button class="btn btn--primary" :disabled="request.isHandling" @click="props.startTemplateRequestReview(request)">
|
||||
{{ request.isHandling ? '이동중...' : '확인하기' }}
|
||||
</button>
|
||||
<button class="btn btn--ghost" :disabled="request.isHandling || request.status !== 'reviewing'" @click="props.completeTemplateRequest(request)">처리 완료</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="panel">
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">전체 티어표 관리</div>
|
||||
<div class="hint hint--tight">공개/비공개를 포함한 최근 티어표를 모두 확인하고, 추가 아이템을 기존 게임 템플릿으로 승격하거나 커스텀 티어표를 새 게임 템플릿으로 만들 수 있어요. 여기는 요청 목록과 별개로 전체 저장 티어표를 보는 영역입니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!props.adminTierLists.length" class="hint">조건에 맞는 티어표가 없어요.</div>
|
||||
<div v-else class="tierAdminList">
|
||||
<article v-for="tierList in props.adminTierLists" :key="tierList.id" class="tierAdminCard">
|
||||
<button class="tierAdminCard__preview" type="button" @click="props.openAdminTierList(tierList)">
|
||||
<img v-if="props.tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="props.tierListThumbUrl(tierList)" :alt="tierList.title" />
|
||||
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
|
||||
</button>
|
||||
|
||||
<div class="tierAdminCard__body">
|
||||
<div class="tierAdminCard__head">
|
||||
<div>
|
||||
<div class="tierAdminCard__title">{{ tierList.title }}</div>
|
||||
<div v-if="tierList.description" class="tierAdminCard__desc">{{ tierList.description }}</div>
|
||||
<div class="tierAdminCard__meta">
|
||||
{{ tierList.gameName || tierList.gameId }} · {{ props.tierListAuthorDisplayName(tierList) }} · {{ props.tierListVisibilityLabel(tierList) }}
|
||||
</div>
|
||||
<div class="tierAdminCard__meta">{{ props.fmt(tierList.updatedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tierAdminCard__stats">
|
||||
<span class="pill">전체 아이템 {{ tierList.itemCount }}개</span>
|
||||
<span class="pill" :class="{ 'pill--accent': tierList.extraItemCount > 0 }">추가 아이템 {{ tierList.extraItemCount }}개</span>
|
||||
</div>
|
||||
|
||||
<div v-if="tierList.extraItems?.length" class="tierAdminSection">
|
||||
<div class="tierAdminSection__title">추가로 넣은 아이템</div>
|
||||
<div class="tierAdminItemList">
|
||||
<button v-for="item in tierList.extraItems" :key="item.id" class="tierAdminItem" @click="props.openTierListExtraItemModal(item, tierList)">
|
||||
<img class="tierAdminItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<div class="tierAdminItem__title">{{ item.label }}</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tierAdminSection__actions">
|
||||
<button class="btn btn--ghost btn--small" @click="props.openTierListImportModal(tierList, tierList.extraItems)">추가 아이템 전체 가져오기</button>
|
||||
<button v-if="tierList.gameId === 'freeform'" class="btn btn--primary btn--small" @click="props.openTierListImportModal(tierList, tierList.extraItems)">
|
||||
새 템플릿으로 가져오기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="pager">
|
||||
<button class="btn btn--ghost" :disabled="props.adminTierListPage <= 1" @click="props.moveAdminTierListPage(-1)">이전</button>
|
||||
<div class="pager__info">{{ props.adminTierListPage }} / {{ props.adminTierListPageCount }} 페이지 · 총 {{ props.adminTierListTotal }}개</div>
|
||||
<button class="btn btn--ghost" :disabled="props.adminTierListPage >= props.adminTierListPageCount" @click="props.moveAdminTierListPage(1)">다음</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
125
frontend/src/components/admin/AdminUsersSection.vue
Normal file
125
frontend/src/components/admin/AdminUsersSection.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import SvgIcon from '../SvgIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
userQuery: { type: String, required: true },
|
||||
userSort: { type: String, required: true },
|
||||
userSortDirection: { type: String, required: true },
|
||||
users: { type: Array, required: true },
|
||||
submitUserFilters: { type: Function, required: true },
|
||||
setUserAvatarInput: { type: Function, required: true },
|
||||
onUserAvatarChange: { type: Function, required: true },
|
||||
openUserAvatarPicker: { type: Function, required: true },
|
||||
userAvatarUrl: { type: Function, required: true },
|
||||
userDisplayName: { type: Function, required: true },
|
||||
userAvatarFallback: { type: Function, required: true },
|
||||
removeUserAvatar: { type: Function, required: true },
|
||||
roleLabelOf: { type: Function, required: true },
|
||||
fmt: { type: Function, required: true },
|
||||
openUserPasswordModal: { type: Function, required: true },
|
||||
openUserDeleteModal: { type: Function, required: true },
|
||||
openUserEditModal: { type: Function, required: true },
|
||||
lockResetIcon: { type: String, required: true },
|
||||
deleteIcon: { type: String, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:userQuery', 'update:userSort', 'update:userSortDirection'])
|
||||
|
||||
const userQueryModel = computed({
|
||||
get: () => props.userQuery,
|
||||
set: (value) => emit('update:userQuery', value),
|
||||
})
|
||||
const userSortModel = computed({
|
||||
get: () => props.userSort,
|
||||
set: (value) => emit('update:userSort', value),
|
||||
})
|
||||
const userSortDirectionModel = computed({
|
||||
get: () => props.userSortDirection,
|
||||
set: (value) => emit('update:userSortDirection', value),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">회원 관리</div>
|
||||
<div class="hint hint--tight">회원 프로필을 정리하고, 필요한 경우에만 권한 변경과 비밀번호 초기화를 진행할 수 있어요.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar toolbar--secondary">
|
||||
<input v-model="userQueryModel" class="input toolbar__search" placeholder="이메일, 닉네임 검색" @keydown.enter.prevent="props.submitUserFilters" />
|
||||
<select v-model="userSortModel" class="select toolbar__select" @change="props.submitUserFilters">
|
||||
<option value="recent">최근 활동순</option>
|
||||
<option value="created">가입순</option>
|
||||
<option value="tierlists">작성 티어표 많은 순</option>
|
||||
</select>
|
||||
<select v-model="userSortDirectionModel" class="select toolbar__select" @change="props.submitUserFilters">
|
||||
<option value="desc">내림차순</option>
|
||||
<option value="asc">오름차순</option>
|
||||
</select>
|
||||
<button class="btn btn--ghost toolbar__button" type="button" @click="props.submitUserFilters">조회</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!props.users.length" class="hint">아직 가입한 회원이 없어요.</div>
|
||||
<div v-else class="userList">
|
||||
<article v-for="user in props.users" :key="user.id" class="userCard">
|
||||
<div class="userCard__head">
|
||||
<div class="userCard__identity">
|
||||
<input
|
||||
:ref="(el) => props.setUserAvatarInput(user.id, el)"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="srOnlyInput"
|
||||
@change="props.onUserAvatarChange(user, $event)"
|
||||
/>
|
||||
<div class="userAvatarWrap">
|
||||
<button class="userAvatar userAvatarButton" type="button" :disabled="user.isAvatarBusy" @click="props.openUserAvatarPicker(user)">
|
||||
<img v-if="props.userAvatarUrl(user)" class="userAvatar__image" :src="props.userAvatarUrl(user)" :alt="props.userDisplayName(user)" />
|
||||
<span v-else class="userAvatar__fallback">{{ props.userAvatarFallback(user) }}</span>
|
||||
<span class="userAvatarButton__overlay">{{ user.isAvatarBusy ? '업데이트중...' : '수정' }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="user?.avatarSrc"
|
||||
class="userAvatarRemoveButton"
|
||||
type="button"
|
||||
title="회원 썸네일 삭제"
|
||||
:disabled="user.isAvatarBusy"
|
||||
@click.stop="props.removeUserAvatar(user)"
|
||||
>
|
||||
<SvgIcon class="userAvatarRemoveIcon" :src="props.deleteIcon" :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="userCard__identityMeta">
|
||||
<div class="userCard__title">{{ props.userDisplayName(user) }}</div>
|
||||
<div class="userCard__meta">{{ user.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="user.isAdmin" class="roleBadge userCard__roleBadge">{{ props.roleLabelOf(user) }}</div>
|
||||
|
||||
<div class="userInfoList">
|
||||
<div class="userInfoLine"><span>가입일</span><strong>{{ props.fmt(user.createdAt) }}</strong></div>
|
||||
<div class="userInfoLine"><span>작성 티어표</span><strong>{{ user.tierListCount }}개</strong></div>
|
||||
<div class="userInfoLine"><span>최근 활동</span><strong>{{ props.fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
|
||||
<div class="userInfoLine"><span>계정명</span><strong>{{ user.email }}</strong></div>
|
||||
<div class="userInfoLine"><span>닉네임</span><strong>{{ user.nickname || '미설정' }}</strong></div>
|
||||
<div class="userInfoLine"><span>권한</span><strong>{{ props.roleLabelOf(user) }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="userCard__actions userCard__actions--compact">
|
||||
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="props.openUserPasswordModal(user)">
|
||||
<SvgIcon class="iconActionButton__icon" :src="props.lockResetIcon" :size="18" />
|
||||
</button>
|
||||
<button class="iconActionButton iconActionButton--danger" type="button" title="회원 삭제" @click="props.openUserDeleteModal(user)">
|
||||
<SvgIcon class="iconActionButton__icon" :src="props.deleteIcon" :size="18" />
|
||||
</button>
|
||||
<button class="btn btn--ghost userSaveButton" type="button" @click="props.openUserEditModal(user)">회원 정보 수정</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
291
frontend/src/composables/useAdminGameManager.js
Normal file
291
frontend/src/composables/useAdminGameManager.js
Normal file
@@ -0,0 +1,291 @@
|
||||
import { nextTick } from 'vue'
|
||||
import Sortable from 'sortablejs'
|
||||
|
||||
export function useAdminGameManager({
|
||||
api,
|
||||
toApiUrl,
|
||||
selectedGameId,
|
||||
selectedGame,
|
||||
uploadFiles,
|
||||
uploadItemDrafts,
|
||||
thumbFile,
|
||||
itemPreviewUrls,
|
||||
itemFileInput,
|
||||
gameItemListEl,
|
||||
gameItemSortable,
|
||||
savedGameItemOrderIds,
|
||||
isGameLoading,
|
||||
activeTemplateRequest,
|
||||
templateRequests,
|
||||
customItemModalOpen,
|
||||
customItemModalTargetGameId,
|
||||
newGameId,
|
||||
newGameName,
|
||||
clearPreviewUrl,
|
||||
resetFileInput,
|
||||
resetUploadState,
|
||||
refreshGames,
|
||||
closeGameCreateModal,
|
||||
resetMessages,
|
||||
success,
|
||||
error,
|
||||
}) {
|
||||
function requestItemFilename(item = {}) {
|
||||
const src = typeof item.src === 'string' ? item.src : ''
|
||||
return src.split('/').pop() || item.file?.name || 'item'
|
||||
}
|
||||
|
||||
function destroyGameItemSortable() {
|
||||
if (gameItemSortable.value) {
|
||||
gameItemSortable.value.destroy()
|
||||
gameItemSortable.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function syncGameItemSortable() {
|
||||
await nextTick()
|
||||
destroyGameItemSortable()
|
||||
if (!gameItemListEl.value || !selectedGame.value?.items?.length) return
|
||||
|
||||
gameItemSortable.value = Sortable.create(gameItemListEl.value, {
|
||||
animation: 160,
|
||||
draggable: '[data-game-item-id]',
|
||||
ghostClass: 'ghost',
|
||||
chosenClass: 'chosen',
|
||||
onEnd: (evt) => {
|
||||
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
|
||||
const nextItems = [...(selectedGame.value?.items || [])]
|
||||
const [moved] = nextItems.splice(evt.oldIndex, 1)
|
||||
nextItems.splice(evt.newIndex, 0, moved)
|
||||
selectedGame.value = {
|
||||
...selectedGame.value,
|
||||
items: nextItems,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function mergeRequestItemsIntoDrafts(request) {
|
||||
const requestId = request?.id
|
||||
if (!requestId) return
|
||||
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)
|
||||
.map((item) => ({
|
||||
kind: 'request',
|
||||
requestId,
|
||||
itemId: item.id,
|
||||
previewUrl: toApiUrl(item.src),
|
||||
label: item.label || '',
|
||||
sourceName: requestItemFilename(item),
|
||||
src: item.src,
|
||||
}))
|
||||
.filter((draft) => !existingKeys.has(`${draft.kind}:${draft.requestId}:${draft.itemId}`))
|
||||
|
||||
if (nextRequestDrafts.length) {
|
||||
uploadItemDrafts.value = [...uploadItemDrafts.value, ...nextRequestDrafts]
|
||||
}
|
||||
}
|
||||
|
||||
function removeUploadDraft(targetDraft) {
|
||||
const targetKey = `${targetDraft.kind}:${targetDraft.requestId || ''}:${targetDraft.itemId || targetDraft.file?.name || ''}:${targetDraft.previewUrl || ''}`
|
||||
uploadItemDrafts.value = uploadItemDrafts.value.filter((draft) => {
|
||||
const currentKey = `${draft.kind}:${draft.requestId || ''}:${draft.itemId || draft.file?.name || ''}:${draft.previewUrl || ''}`
|
||||
return currentKey !== targetKey
|
||||
})
|
||||
uploadFiles.value = uploadItemDrafts.value.filter((draft) => draft.kind === 'file').map((draft) => draft.file).filter(Boolean)
|
||||
}
|
||||
|
||||
async function loadGame() {
|
||||
resetMessages()
|
||||
resetUploadState()
|
||||
|
||||
if (!selectedGameId.value) {
|
||||
selectedGame.value = null
|
||||
savedGameItemOrderIds.value = []
|
||||
destroyGameItemSortable()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isGameLoading.value = true
|
||||
const data = await api.getGame(selectedGameId.value)
|
||||
selectedGame.value = {
|
||||
...data,
|
||||
items: (data.items || []).map((item) => ({
|
||||
...item,
|
||||
draftLabel: item.label,
|
||||
})),
|
||||
}
|
||||
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 {
|
||||
isGameLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createGame() {
|
||||
resetMessages()
|
||||
try {
|
||||
const res = await fetch(toApiUrl('/api/admin/games'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: newGameId.value.trim(), name: newGameName.value.trim() }),
|
||||
})
|
||||
if (!res.ok) throw new Error('failed')
|
||||
|
||||
const data = await res.json()
|
||||
await refreshGames()
|
||||
selectedGameId.value = data.game.id
|
||||
if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id
|
||||
closeGameCreateModal()
|
||||
await loadGame()
|
||||
if (activeTemplateRequest.value?.id) {
|
||||
const sourceRequest = templateRequests.value.find((entry) => entry.id === activeTemplateRequest.value.id) || activeTemplateRequest.value
|
||||
mergeRequestItemsIntoDrafts(sourceRequest)
|
||||
}
|
||||
success.value = '게임이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
|
||||
} catch (e) {
|
||||
error.value = '게임 생성 실패(관리자 권한/중복 ID 확인)'
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemFiles(fileList) {
|
||||
const files = Array.from(fileList || []).filter((file) => (file.type || '').startsWith('image/'))
|
||||
const requestDrafts = uploadItemDrafts.value.filter((draft) => draft.kind === 'request')
|
||||
const previousFileDrafts = uploadItemDrafts.value.filter((draft) => draft.kind === 'file')
|
||||
previousFileDrafts.forEach((draft) => {
|
||||
if (draft.previewUrl) URL.revokeObjectURL(draft.previewUrl)
|
||||
})
|
||||
itemPreviewUrls.value = []
|
||||
uploadFiles.value = files
|
||||
uploadItemDrafts.value = requestDrafts
|
||||
if (!files.length) {
|
||||
resetFileInput('item')
|
||||
return
|
||||
}
|
||||
itemPreviewUrls.value = files.map((file) => URL.createObjectURL(file))
|
||||
const fileDrafts = files.map((file, index) => ({
|
||||
kind: 'file',
|
||||
file,
|
||||
previewUrl: itemPreviewUrls.value[index],
|
||||
label: (file.name || '').replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60),
|
||||
sourceName: file.name,
|
||||
}))
|
||||
uploadItemDrafts.value = [...requestDrafts, ...fileDrafts]
|
||||
resetFileInput('item')
|
||||
}
|
||||
|
||||
function onFile(event) {
|
||||
handleItemFiles(event.target.files)
|
||||
}
|
||||
|
||||
function openItemFilePicker() {
|
||||
itemFileInput.value?.click()
|
||||
}
|
||||
|
||||
function clearItemFiles() {
|
||||
uploadFiles.value = []
|
||||
uploadItemDrafts.value = []
|
||||
itemPreviewUrls.value.forEach((url) => {
|
||||
if (url) URL.revokeObjectURL(url)
|
||||
})
|
||||
itemPreviewUrls.value = []
|
||||
resetFileInput('item')
|
||||
}
|
||||
|
||||
async function uploadItem() {
|
||||
resetMessages()
|
||||
if (!uploadItemDrafts.value.length || !selectedGameId.value) {
|
||||
error.value = '아이템 파일을 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file')
|
||||
const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request')
|
||||
let uploadCount = 0
|
||||
|
||||
if (fileDrafts.length) {
|
||||
const fd = new FormData()
|
||||
fileDrafts.forEach((entry) => {
|
||||
fd.append('images', entry.file)
|
||||
fd.append('labels', entry.label.trim())
|
||||
})
|
||||
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedGameId.value)}/images`), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
})
|
||||
if (!res.ok) throw new Error('failed')
|
||||
uploadCount += fileDrafts.length
|
||||
}
|
||||
|
||||
if (requestDrafts.length) {
|
||||
const requestIds = [...new Set(requestDrafts.map((entry) => entry.requestId).filter(Boolean))]
|
||||
for (const requestId of requestIds) {
|
||||
const draftsForRequest = requestDrafts.filter((entry) => entry.requestId === requestId)
|
||||
await api.promoteAdminTemplateRequestItems(requestId, {
|
||||
gameId: selectedGameId.value,
|
||||
itemIds: draftsForRequest.map((entry) => entry.itemId).filter(Boolean),
|
||||
itemLabels: draftsForRequest.reduce((acc, entry) => {
|
||||
if (entry.itemId) acc[entry.itemId] = entry.label.trim()
|
||||
return acc
|
||||
}, {}),
|
||||
})
|
||||
uploadCount += draftsForRequest.length
|
||||
}
|
||||
}
|
||||
|
||||
resetUploadState()
|
||||
await loadGame()
|
||||
success.value = `게임 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
|
||||
} catch (e) {
|
||||
error.value = '아이템 추가 실패(관리자 권한/파일 크기 확인)'
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGameItemOrder() {
|
||||
resetMessages()
|
||||
if (!selectedGameId.value || !selectedGame.value?.items?.length) return
|
||||
|
||||
try {
|
||||
const data = await api.updateAdminGameItemDisplayOrder(selectedGameId.value, {
|
||||
itemIds: selectedGame.value.items.map((item) => item.id),
|
||||
})
|
||||
selectedGame.value = {
|
||||
...selectedGame.value,
|
||||
items: (data.items || []).map((item) => ({
|
||||
...item,
|
||||
draftLabel: item.label,
|
||||
})),
|
||||
}
|
||||
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
|
||||
await syncGameItemSortable()
|
||||
success.value = '기본 아이템 순서를 저장했어요.'
|
||||
} catch (e) {
|
||||
error.value = '기본 아이템 순서 저장에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestItemFilename,
|
||||
destroyGameItemSortable,
|
||||
syncGameItemSortable,
|
||||
mergeRequestItemsIntoDrafts,
|
||||
removeUploadDraft,
|
||||
loadGame,
|
||||
createGame,
|
||||
handleItemFiles,
|
||||
onFile,
|
||||
openItemFilePicker,
|
||||
clearItemFiles,
|
||||
uploadItem,
|
||||
saveGameItemOrder,
|
||||
}
|
||||
}
|
||||
95
frontend/src/composables/useAdminTemplateRequests.js
Normal file
95
frontend/src/composables/useAdminTemplateRequests.js
Normal file
@@ -0,0 +1,95 @@
|
||||
export function useAdminTemplateRequests({
|
||||
api,
|
||||
activeTemplateRequest,
|
||||
refreshTemplateRequests,
|
||||
setTab,
|
||||
openGameCreateModal,
|
||||
newGameId,
|
||||
newGameName,
|
||||
selectAdminGame,
|
||||
mergeRequestItemsIntoDrafts,
|
||||
resetMessages,
|
||||
success,
|
||||
error,
|
||||
}) {
|
||||
function updateActiveTemplateRequest(request) {
|
||||
if (!request?.id) return
|
||||
activeTemplateRequest.value = {
|
||||
id: request.id,
|
||||
type: request.type,
|
||||
status: request.status,
|
||||
draftGameId: request.draftGameId || '',
|
||||
draftGameName: request.draftGameName || '',
|
||||
sourceTierListId: request.sourceTierListId || '',
|
||||
sourceGameId: request.sourceGameId || '',
|
||||
sourceTierListTitle: request.sourceTierListTitle || '',
|
||||
targetGameId: request.targetGameId || '',
|
||||
requesterName: request.requesterName || '',
|
||||
}
|
||||
}
|
||||
|
||||
function templateRequestStatusLabel(request) {
|
||||
return request.status === 'reviewing' ? '확인함' : '미확인'
|
||||
}
|
||||
|
||||
function templateRequestSourceUrl(request) {
|
||||
if (!request?.sourceGameId || !request?.sourceTierListId) return ''
|
||||
return `/editor/${request.sourceGameId}/${request.sourceTierListId}?preview=1`
|
||||
}
|
||||
|
||||
function templateRequestReviewHint(request) {
|
||||
if (request.type === 'create') return '게임 생성 후 필요한 아이템만 골라 추가하고, 끝나면 여기서 처리 완료하세요.'
|
||||
return '확인하기를 누르면 게임 관리 화면에 요청 아이템이 임시 추가되어 필요한 것만 선별 저장할 수 있어요.'
|
||||
}
|
||||
|
||||
async function startTemplateRequestReview(request) {
|
||||
resetMessages()
|
||||
try {
|
||||
request.isHandling = true
|
||||
const data = await api.startAdminTemplateRequestReview(request.id)
|
||||
request.status = data.request?.status || 'reviewing'
|
||||
updateActiveTemplateRequest(request)
|
||||
setTab('game-admin')
|
||||
|
||||
if (request.type === 'create') {
|
||||
openGameCreateModal()
|
||||
newGameId.value = (request.draftGameId || '').trim()
|
||||
newGameName.value = (request.draftGameName || '').trim()
|
||||
mergeRequestItemsIntoDrafts(request)
|
||||
} else {
|
||||
const nextGameId = request.targetGameId || request.sourceGameId || ''
|
||||
if (nextGameId) await selectAdminGame(nextGameId)
|
||||
mergeRequestItemsIntoDrafts(request)
|
||||
}
|
||||
success.value = '요청 아이템을 게임 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.'
|
||||
} catch (e) {
|
||||
error.value = '요청 확인 단계로 이동하지 못했어요.'
|
||||
} finally {
|
||||
request.isHandling = false
|
||||
}
|
||||
}
|
||||
|
||||
async function completeTemplateRequest(request) {
|
||||
resetMessages()
|
||||
try {
|
||||
request.isHandling = true
|
||||
await api.completeAdminTemplateRequest(request.id)
|
||||
if (activeTemplateRequest.value?.id === request.id) activeTemplateRequest.value = null
|
||||
await refreshTemplateRequests()
|
||||
success.value = '요청 카드를 처리 완료로 정리했어요.'
|
||||
} catch (e) {
|
||||
error.value = '요청 완료 처리에 실패했어요.'
|
||||
} finally {
|
||||
request.isHandling = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updateActiveTemplateRequest,
|
||||
templateRequestStatusLabel,
|
||||
templateRequestSourceUrl,
|
||||
templateRequestReviewHint,
|
||||
startTemplateRequestReview,
|
||||
completeTemplateRequest,
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ export const api = {
|
||||
favoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'POST' }),
|
||||
unfavoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'DELETE' }),
|
||||
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 }),
|
||||
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 } = {}) =>
|
||||
@@ -61,6 +63,12 @@ export const api = {
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
|
||||
createAdminGameTemplateFromTierList: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
|
||||
startAdminTemplateRequestReview: (requestId) =>
|
||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/review`, { method: 'POST', body: {} }),
|
||||
promoteAdminTemplateRequestItems: (requestId, payload) =>
|
||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/promote-items`, { method: 'POST', body: payload }),
|
||||
completeAdminTemplateRequest: (requestId) =>
|
||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/complete`, { method: 'POST', body: {} }),
|
||||
approveAdminTemplateRequest: (requestId, payload) =>
|
||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/approve`, { method: 'POST', body: payload || {} }),
|
||||
rejectAdminTemplateRequest: (requestId) => request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/reject`, { method: 'POST', body: {} }),
|
||||
|
||||
@@ -9,9 +9,10 @@ import FavoriteTierListsView from '../views/FavoriteTierListsView.vue'
|
||||
import AdminView from '../views/AdminView.vue'
|
||||
import ProfileView from '../views/ProfileView.vue'
|
||||
import SearchResultsView from '../views/SearchResultsView.vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
export function createRouter() {
|
||||
return _createRouter({
|
||||
const router = _createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: HomeView },
|
||||
@@ -22,8 +23,27 @@ export function createRouter() {
|
||||
{ path: '/me', name: 'me', component: MyTierListsView },
|
||||
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView },
|
||||
{ path: '/search', name: 'search', component: SearchResultsView },
|
||||
{ path: '/admin', name: 'admin', component: AdminView },
|
||||
{ path: '/admin', redirect: '/admin/featured' },
|
||||
{ path: '/admin/featured', name: 'adminFeatured', component: AdminView },
|
||||
{ path: '/admin/games', name: 'adminGames', component: AdminView },
|
||||
{ path: '/admin/items', name: 'adminItems', component: AdminView },
|
||||
{ path: '/admin/tierlists', name: 'adminTierlists', component: AdminView },
|
||||
{ path: '/admin/users', name: 'adminUsers', component: AdminView },
|
||||
{ path: '/profile', name: 'profile', component: ProfileView },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const routeName = String(to.name || '')
|
||||
if (!routeName.startsWith('admin')) return true
|
||||
|
||||
const auth = useAuthStore()
|
||||
if (!auth.hydrated) await auth.refresh()
|
||||
if (!auth.user?.isAdmin) {
|
||||
return { path: '/' }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -121,7 +121,6 @@ const copiedFromLabel = computed(() => {
|
||||
const customItems = computed(() =>
|
||||
Object.values(itemsById.value)
|
||||
.filter((item) => item?.origin === 'custom')
|
||||
.sort((a, b) => (a.label || '').localeCompare(b.label || '', 'ko'))
|
||||
)
|
||||
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
|
||||
const canRequestTemplateCreate = computed(
|
||||
|
||||
Reference in New Issue
Block a user