Files
tier-maker/frontend/src/components/admin/AdminTemplatesSection.vue

298 lines
15 KiB
Vue

<script setup>
import { toApiUrl } from '../../lib/runtime'
import SvgIcon from '../SvgIcon.vue'
import addPhotoAlternateIcon from '../../assets/icons/add_photo_alternate.svg'
const props = defineProps({
activeTemplateRequest: { type: Object, default: null },
templateRequestSourceUrl: { type: Function, required: true },
stagedRequestDraftCount: { type: Number, required: true },
appliedRequestItemCount: { type: Number, required: true },
openTemplateCreateModal: { type: Function, required: true },
isTemplateLoading: { type: Boolean, required: true },
hasSelectedTemplate: { type: Boolean, required: true },
selectedTemplate: { type: Object, default: null },
displayThumbnailUrl: { type: String, default: '' },
templateMetaDraftName: { type: String, default: '' },
templateMetaDraftSlug: { type: String, default: '' },
templateMetaSaving: { type: Boolean, required: true },
canSaveTemplateMeta: { type: Boolean, required: true },
saveTemplateMeta: { type: Function, required: true },
canApplyThumbnail: { type: Boolean, required: true },
templateVisibilitySaving: { type: Boolean, required: true },
thumbFileInputRef: { type: Function, required: true },
openThumbFilePicker: { type: Function, required: true },
onThumb: { type: Function, required: true },
onThumbDragEnter: { type: Function, required: true },
onThumbDragOver: { type: Function, required: true },
onThumbDragLeave: { type: Function, required: true },
onThumbDrop: { type: Function, required: true },
isThumbDragOver: { type: Boolean, required: true },
uploadThumbnail: { 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 },
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 },
hasTemplateItemOrderChanges: { type: Boolean, required: true },
saveTemplateItemOrder: { type: Function, required: true },
templateItemListRef: { type: Function, required: true },
saveTemplateItemLabel: { type: Function, required: true },
removeTemplateItem: { type: Function, required: true },
selectedTemplateId: { type: String, default: '' },
})
defineEmits(['update:templateMetaDraftName', 'update:templateMetaDraftSlug'])
function setTemplateItemListElement(el) {
props.templateItemListRef(el)
}
function setThumbFileElement(el) {
props.thumbFileInputRef(el)
}
</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'
? (props.activeTemplateRequest.targetTopicId
? '이미 연결된 신규 템플릿이 있어요. 필요한 아이템만 골라 저장하세요.'
: '새 템플릿을 한 번 만든 뒤 필요한 아이템만 골라 저장하세요.')
: '필요한 아이템만 남긴 뒤 기본 아이템 추가 버튼으로 반영하세요.'
}}
</div>
</div>
<div class="requestWorkspace__stats">
<span class="pill pill--accent">{{ props.activeTemplateRequest.type === 'create' ? '신규 템플릿 요청' : '기존 템플릿 업데이트' }}</span>
<span class="pill">요청 아이템 {{ props.stagedRequestDraftCount }}</span>
<span v-if="props.appliedRequestItemCount" class="pill pill--soft">이미 반영 {{ props.appliedRequestItemCount }}</span>
<span v-if="props.activeTemplateRequest.type === 'create' && (props.activeTemplateRequest.targetTopicName || props.activeTemplateRequest.targetTopicId)" class="pill pill--soft">
연결된 템플릿 · {{ props.activeTemplateRequest.targetTopicName || props.activeTemplateRequest.targetTopicId }}
</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' && !props.activeTemplateRequest.targetTopicId"
class="btn btn--ghost btn--small"
type="button"
@click="props.openTemplateCreateModal"
>
템플릿 만들기
</button>
</div>
</div>
<div v-if="props.isTemplateLoading" class="panel panel--empty">
<div class="emptyState">
<div class="emptyState__title">템플릿 정보를 불러오는 중이에요.</div>
<div class="emptyState__desc">선택한 템플릿의 썸네일과 기본 아이템을 표시합니다.</div>
</div>
</div>
<div v-else-if="props.hasSelectedTemplate" class="panel">
<section class="adminCard templateSettingsCard">
<div class="templateSettingsCard__media">
<input :ref="setThumbFileElement" type="file" accept="image/*" class="srOnlyInput" @change="props.onThumb" />
<button
class="thumbDropZone"
:class="{ 'thumbDropZone--active': props.isThumbDragOver }"
type="button"
@click="props.openThumbFilePicker"
@dragenter="props.onThumbDragEnter"
@dragover="props.onThumbDragOver"
@dragleave="props.onThumbDragLeave"
@drop="props.onThumbDrop"
>
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedTemplate.template.name" />
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
<div class="thumbDropZone__copy">
<div class="thumbDropZone__title">{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
</div>
</button>
</div>
<div class="templateSettingsCard__body">
<div class="panel__title">템플릿 설정</div>
<div class="templateMetaForm">
<label class="templateMetaField">
<span class="templateMetaField__label">템플릿 이름</span>
<input
class="input input--dense"
type="text"
maxlength="60"
:value="props.templateMetaDraftName"
placeholder="템플릿 이름"
@input="$emit('update:templateMetaDraftName', $event.target.value)"
/>
</label>
<label class="templateMetaField">
<span class="templateMetaField__label">템플릿 slug</span>
<input
class="input input--dense"
type="text"
maxlength="120"
:value="props.templateMetaDraftSlug"
placeholder="예: idol-rhythm"
@input="$emit('update:templateMetaDraftSlug', $event.target.value)"
/>
</label>
</div>
<div class="templateSettingsCard__meta">공개 URL: /topics/{{ props.selectedTemplate.template.slug || props.selectedTemplate.template.id }}</div>
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.templateVisibilitySaving }">
<input :checked="!!props.selectedTemplate.template.isPublic" type="checkbox" @change="props.toggleSelectedTemplateVisibility($event.target.checked)" />
<span class="toggleSwitch__label">{{ props.selectedTemplate.template.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<div class="templateSettingsCard__actions">
<button class="btn btn--ghost" :disabled="!props.canSaveTemplateMeta || props.templateMetaSaving" @click="props.saveTemplateMeta">
{{ props.templateMetaSaving ? '저장중...' : '이름/slug 저장' }}
</button>
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
<button class="btn btn--danger" @click="props.removeTemplate">템플릿 삭제</button>
</div>
</div>
</section>
<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 }"
@click="props.openItemFilePicker"
@dragenter="props.onItemDragEnter"
@dragover="props.onItemDragOver"
@dragleave="props.onItemDragLeave"
@drop="props.onItemDrop"
>
<div class="dropZone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="dropZone__icon" />
</div>
<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 dropZone__button" type="button" @click.stop="props.openItemFilePicker">파일 선택</button>
</div>
</div>
</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" :class="draft.kind === 'request' ? 'pill--requestItem' : 'pill--directFile'">
{{ 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>
<button class="btn itemPreviewCard__submit" :disabled="!props.canAddItem" @click="props.uploadItem">
{{ props.uploadItemDrafts.length ? `아이템 ${props.uploadItemDrafts.length} 추가` : '아이템 추가' }}
</button>
</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.hasTemplateItemOrderChanges" @click="props.saveTemplateItemOrder">순서 저장</button>
</div>
<div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
<div v-else :ref="setTemplateItemListElement" class="thumbGrid">
<div v-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-template-item-id="item.id">
<img class="thumb thumb--template" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />
<div class="thumbCard__actions">
<button
class="btn btn--ghost btn--small"
data-no-drag
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label"
@click="props.saveTemplateItemLabel(item)"
>
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
</button>
<button class="btn btn--danger btn--small" data-no-drag @click="props.removeTemplateItem(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.selectedTemplateId" class="hint hint--tight">선택한 템플릿을 찾지 못했거나 로딩 오류가 발생했어요. 다시 선택해보세요.</div>
</div>
</div>
</template>
<style scoped>
.templateMetaForm {
display: grid;
gap: 10px;
}
.templateMetaField {
display: grid;
gap: 6px;
}
.templateMetaField__label {
font-size: 12px;
font-weight: 800;
color: var(--theme-text-soft);
}
.input--dense {
padding: 11px 13px;
}
</style>