관리자 화면 분리 및 요청/아이템 관리 흐름 정리
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user