템플릿 slug 구조와 빈 DB 초기화를 정리

This commit is contained in:
2026-04-03 14:36:52 +09:00
parent 30ec2e55b0
commit f506e31549
20 changed files with 422 additions and 290 deletions

View File

@@ -31,7 +31,7 @@ const props = defineProps({
<span class="featuredCard__rank">{{ index + 1 }}</span>
<div>
<div class="featuredCard__title">{{ template.name }}</div>
<div class="featuredCard__id">{{ template.id }}</div>
<div class="featuredCard__id">{{ template.slug || template.id }}</div>
</div>
</div>
<div class="featuredCard__actions">
@@ -55,7 +55,7 @@ const props = defineProps({
@click="props.addFeaturedTemplate(template.id)"
>
<span>{{ template.name }}</span>
<span class="featuredPickerItem__id">{{ template.id }}</span>
<span class="featuredPickerItem__id">{{ template.slug || template.id }}</span>
</button>
</div>
</div>

View File

@@ -13,6 +13,11 @@ const props = defineProps({
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 },
@@ -47,6 +52,8 @@ const props = defineProps({
selectedTemplateId: { type: String, default: '' },
})
defineEmits(['update:templateMetaDraftName', 'update:templateMetaDraftSlug'])
function setTemplateItemListElement(el) {
props.templateItemListRef(el)
}
@@ -131,13 +138,40 @@ function setThumbFileElement(el) {
</div>
<div class="templateSettingsCard__body">
<div class="panel__title">템플릿 설정</div>
<div class="templateSettingsCard__meta">{{ props.selectedTemplate.template.name }} · {{ props.selectedTemplate.template.id }}</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>
@@ -239,3 +273,25 @@ function setThumbFileElement(el) {
</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>

View File

@@ -152,7 +152,10 @@ export function useAdminTemplateManager({
}
async function createTemplate(options = {}) {
const nextTopicId = typeof options.topicId === 'string' ? options.topicId.trim() : newTemplateId.value.trim()
const nextTopicSlug =
typeof options.topicId === 'string'
? options.topicId.trim().toLowerCase()
: newTemplateId.value.trim().toLowerCase()
const nextTopicName = typeof options.topicName === 'string' ? options.topicName.trim() : newTemplateName.value.trim()
const preserveUploadState = !!options.preserveUploadState
resetMessages()
@@ -162,15 +165,18 @@ export function useAdminTemplateManager({
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: nextTopicId,
slug: nextTopicSlug,
name: nextTopicName,
isPublic: !!newTemplateIsPublic.value,
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
}),
})
if (!res.ok) throw new Error('failed')
const data = await res.json()
if (!res.ok) {
const requestError = new Error('failed')
requestError.data = data
throw requestError
}
const createdTemplate = data.template || {}
if (activeTemplateRequest.value?.type === 'create' && activeTemplateRequest.value?.id) {
const linkData = await api.linkAdminTemplateRequestTemplate(activeTemplateRequest.value.id, {
@@ -201,7 +207,16 @@ export function useAdminTemplateManager({
}
success.value = '템플릿이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
} catch (e) {
error.value = '템플릿 생성 실패(관리자 권한/중복 ID 확인)'
const errorCode = e?.data?.error || ''
if (errorCode === 'topic_slug_taken') {
error.value = '이미 사용 중인 템플릿 주소(slug)입니다.'
return
}
if (errorCode === 'topic_slug_invalid') {
error.value = '템플릿 주소(slug)는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.'
return
}
error.value = '템플릿 생성 실패(관리자 권한/템플릿 주소 중복 확인)'
}
}

View File

@@ -26,8 +26,10 @@ export function useAdminTemplateRequests({
draftTopicIsPublic: !!request.draftTopicIsPublic,
sourceTierListId: request.sourceTierListId || '',
sourceTopicId: request.sourceTopicId || '',
sourceTopicSlug: request.sourceTopicSlug || '',
sourceTierListTitle: request.sourceTierListTitle || '',
targetTopicId: request.targetTopicId || '',
targetTopicSlug: request.targetTopicSlug || '',
targetTopicName: request.targetTopicName || '',
requesterName: request.requesterName || '',
}
@@ -38,8 +40,9 @@ export function useAdminTemplateRequests({
}
function templateRequestSourceUrl(request) {
if (!request?.sourceTopicId || !request?.sourceTierListId) return ''
return editorPath(request.sourceTopicId, request.sourceTierListId, { preview: true })
const topicRef = request?.sourceTopicSlug || request?.sourceTopicId || ''
if (!topicRef || !request?.sourceTierListId) return ''
return editorPath(topicRef, request.sourceTierListId, { preview: true })
}
function templateRequestReviewHint(request) {

View File

@@ -110,6 +110,9 @@ const success = ref('')
const newTemplateId = ref('')
const newTemplateName = ref('')
const newTemplateIsPublic = ref(false)
const templateMetaDraftName = ref('')
const templateMetaDraftSlug = ref('')
const templateMetaSaving = ref(false)
const templateVisibilitySaving = ref(false)
const uploadFiles = ref([])
@@ -177,6 +180,17 @@ function normalizeAdminSrc(src) {
}
const hasSelectedTemplate = computed(() => !!selectedTemplate.value?.template?.id)
const canSaveTemplateMeta = computed(() => {
const template = selectedTemplate.value?.template
if (!template?.id) return false
const nextName = templateMetaDraftName.value.trim()
const nextSlug = templateMetaDraftSlug.value.trim()
return (
!!nextName &&
!!nextSlug &&
(nextName !== (template.name || '') || nextSlug !== (template.slug || template.id || ''))
)
})
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value)
const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedTemplateId.value)
const stagedRequestDraftCount = computed(() => uploadItemDrafts.value.filter((item) => item.kind === 'request').length)
@@ -203,7 +217,7 @@ const filteredTemplatePickerTemplates = computed(() => {
const query = templatePickerQuery.value.trim().toLowerCase()
const list = templates.value.filter((template) => {
if (!query) return true
const haystack = `${template.name || ''} ${template.id || ''}`.toLowerCase()
const haystack = `${template.name || ''} ${template.slug || ''} ${template.id || ''}`.toLowerCase()
return haystack.includes(query)
})
@@ -462,6 +476,8 @@ watch(
watch(
() => selectedTemplate.value?.template?.id || '',
async (templateId) => {
templateMetaDraftName.value = selectedTemplate.value?.template?.name || ''
templateMetaDraftSlug.value = selectedTemplate.value?.template?.slug || selectedTemplate.value?.template?.id || ''
await refreshSelectedTemplateTierListStats(templateId)
},
{ immediate: true }
@@ -893,7 +909,7 @@ async function refreshTemplateRequests() {
draftTopicId:
request.type === 'create'
? ('tmpl-' + String(request.id || 'request').replace(/[^a-zA-Z0-9]+/g, '').slice(0, 12).toLowerCase())
: request.targetTopicId || request.sourceTopicId || '',
: request.targetTopicSlug || request.sourceTopicSlug || request.targetTopicId || request.sourceTopicId || '',
draftTopicName:
request.type === 'create'
? `${request.sourceTierListTitle || request.sourceTopicName || '새 템플릿'}`
@@ -1212,6 +1228,44 @@ async function saveTemplateVisibility() {
}
}
async function saveTemplateMeta() {
if (!selectedTemplate.value?.template?.id || templateMetaSaving.value || !canSaveTemplateMeta.value) return
try {
templateMetaSaving.value = true
const data = await api.updateAdminTemplate(selectedTemplate.value.template.id, {
name: templateMetaDraftName.value.trim(),
slug: templateMetaDraftSlug.value.trim().toLowerCase(),
isPublic: !!selectedTemplate.value.template.isPublic,
})
const nextTemplate = data.template || {}
selectedTemplate.value = {
...selectedTemplate.value,
template: {
...selectedTemplate.value.template,
...nextTemplate,
},
}
templateMetaDraftName.value = nextTemplate.name || selectedTemplate.value.template.name || ''
templateMetaDraftSlug.value = nextTemplate.slug || selectedTemplate.value.template.slug || selectedTemplate.value.template.id || ''
await refreshTemplates()
success.value = '템플릿 이름과 slug를 저장했어요.'
} catch (e) {
const errorCode = e?.data?.error || ''
if (errorCode === 'topic_slug_taken') {
error.value = '이미 사용 중인 템플릿 slug입니다.'
return
}
if (errorCode === 'topic_slug_invalid') {
error.value = 'slug는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.'
return
}
error.value = '템플릿 이름/slug를 저장하지 못했어요.'
} finally {
templateMetaSaving.value = false
}
}
async function toggleSelectedTemplateVisibility(nextValue) {
if (!selectedTemplate.value?.template?.id || templateVisibilitySaving.value) return
const previous = !!selectedTemplate.value.template.isPublic
@@ -1603,8 +1657,9 @@ function closePreviewModal() {
}
function previewTierListUrl(tierList) {
if (!tierList?.topicId || !tierList?.id) return ''
return editorPath(tierList.topicId, tierList.id, { preview: true })
const topicRef = tierList?.topicSlug || tierList?.topicId || ''
if (!topicRef || !tierList?.id) return ''
return editorPath(topicRef, tierList.id, { preview: true })
}
function openTierListImportModal(tierList, items) {
@@ -1619,9 +1674,10 @@ function openTierListImportModal(tierList, items) {
importModalItems.value = nextItems
importModalMode.value = 'existing'
importModalTargetTemplateId.value = ''
importModalNewTemplateId.value = tierList.topicId === 'freeform' ? '' : `${tierList.topicId}-copy`
const baseSlug = tierList.topicSlug || tierList.topicId || ''
importModalNewTemplateId.value = baseSlug === 'freeform' ? '' : `${baseSlug}-copy`
importModalNewTemplateName.value =
tierList.topicId === 'freeform' ? `${tierList.title} 템플릿` : `${tierList.topicName || tierList.topicId} 파생 템플릿`
baseSlug === 'freeform' ? `${tierList.title} 템플릿` : `${tierList.topicName || baseSlug} 파생 템플릿`
importModalOpen.value = true
}
@@ -1655,15 +1711,15 @@ async function confirmTierListImport() {
if (selectedTemplateId.value === importModalTargetTemplateId.value) await loadTemplate()
success.value = `${data.items?.length || 0}개의 아이템을 기존 템플릿에 추가했어요.`
} else {
const nextTopicId = (importModalNewTemplateId.value || '').trim()
const nextTopicId = (importModalNewTemplateId.value || '').trim().toLowerCase()
const nextTopicName = (importModalNewTemplateName.value || '').trim()
if (!nextTopicId || !nextTopicName) {
error.value = '새 템플릿 ID와 이름을 모두 입력해주세요.'
error.value = '새 템플릿 slug와 이름을 모두 입력해주세요.'
return
}
const data = await api.createAdminTemplateFromTierList(tierList.id, {
topicId: nextTopicId,
slug: nextTopicId,
name: nextTopicName,
itemIds,
})
@@ -1684,11 +1740,11 @@ function templateRequestTypeLabel(request) {
function templateRequestTargetLabel(request) {
if (request.type === 'create') {
if (request.targetTopicName || request.targetTopicId) {
return `연결된 템플릿 · ${request.targetTopicName || request.targetTopicId}`
return `연결된 템플릿 · ${request.targetTopicName || request.targetTopicSlug || request.targetTopicId}`
}
return '연결된 템플릿 없음'
}
return request.targetTopicName || request.targetTopicId || request.sourceTopicName
return request.targetTopicName || request.targetTopicSlug || request.targetTopicId || request.sourceTopicName
}
const displayThumbnailUrl = computed(() => {
@@ -1768,6 +1824,11 @@ function openUserProfile(user) {
:has-selected-template="hasSelectedTemplate"
:selected-template="selectedTemplate"
:display-thumbnail-url="displayThumbnailUrl"
v-model:template-meta-draft-name="templateMetaDraftName"
v-model:template-meta-draft-slug="templateMetaDraftSlug"
:template-meta-saving="templateMetaSaving"
:can-save-template-meta="canSaveTemplateMeta"
:save-template-meta="saveTemplateMeta"
:can-apply-thumbnail="canApplyThumbnail"
:template-visibility-saving="templateVisibilitySaving"
:thumb-file-input-ref="setThumbFileInputRef"
@@ -1868,9 +1929,9 @@ function openUserProfile(user) {
/>
<div v-if="templateCreateModalOpen" class="modalOverlay" @click.self="closeTemplateCreateModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title"> 템플릿 만들기</div>
<div class="modalCard__desc">템플릿 이름과 고유 ID를 입력한 생성하면 바로 아래 상세 관리 화면으로 이어집니다.</div>
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title"> 템플릿 만들기</div>
<div class="modalCard__desc">템플릿 이름과 공개 주소용 slug를 입력하면, 내부 ID는 서버가 자동으로 생성합니다.</div>
<div class="modalCard__form">
<label class="field">
<span class="field__label">템플릿 이름</span>
@@ -1878,15 +1939,15 @@ function openUserProfile(user) {
<span class="field__hint">{{ newTemplateName.length }}/60</span>
</label>
<label class="field">
<span class="field__label">템플릿 ID</span>
<span class="field__label">템플릿 slug</span>
<input
v-model="newTemplateId"
class="field__input"
maxlength="120"
placeholder="topic id (영문/숫자)"
placeholder="예: idol-rhythm"
@keydown.enter.prevent="createTemplate"
/>
<span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newTemplateId.length }}/120</span>
<span class="field__hint">영문 소문자, 숫자, 하이픈 사용 · {{ newTemplateId.length }}/120</span>
</label>
<label class="toggleSwitch">
<input v-model="newTemplateIsPublic" type="checkbox" />
@@ -1988,7 +2049,7 @@ function openUserProfile(user) {
</div>
<div v-else class="modalCard__form">
<input v-model="importModalNewTemplateId" class="input" placeholder="새 템플릿 ID" />
<input v-model="importModalNewTemplateId" class="input" placeholder="새 템플릿 slug" />
<input v-model="importModalNewTemplateName" class="input" placeholder="새 템플릿 이름" />
</div>
@@ -2012,7 +2073,7 @@ function openUserProfile(user) {
<div class="adminSelectionCard">
<div class="adminSelectionCard__label">선택한 템플릿</div>
<div class="adminSelectionCard__title">{{ customItemTargetTemplate?.name || '아직 선택하지 않음' }}</div>
<div class="adminSelectionCard__meta">{{ customItemTargetTemplate?.id || '템플릿을 골라 주세요.' }}</div>
<div class="adminSelectionCard__meta">{{ customItemTargetTemplate?.slug || customItemTargetTemplate?.id || '템플릿을 골라 주세요.' }}</div>
</div>
<div class="customItemModal__pickerActions">
<button class="btn btn--ghost" type="button" @click="openTemplatePickerModal('custom-item-target')">템플릿 선택</button>
@@ -2078,7 +2139,7 @@ function openUserProfile(user) {
<button class="btn btn--ghost btn--small" @click="closeTemplatePickerModal">닫기</button>
</div>
<div class="modalCard__form">
<input v-model="templatePickerQuery" class="input" placeholder="템플릿 이름 또는 ID 검색" />
<input v-model="templatePickerQuery" class="input" placeholder="템플릿 이름 또는 slug 검색" />
<select v-model="templatePickerSort" class="select">
<option value="recent">최신순</option>
<option value="oldest">오래된순</option>
@@ -2110,7 +2171,7 @@ function openUserProfile(user) {
@click="chooseTemplateFromPicker(template.id)"
>
<span class="adminTemplatePicker__name">{{ template.name }}</span>
<span class="adminTemplatePicker__meta">{{ template.id }}</span>
<span class="adminTemplatePicker__meta">{{ template.slug || template.id }}</span>
<span v-if="templatePickerMode === 'custom-item-target' && linkedCustomItemTemplateIds.has(template.id)" class="adminTemplatePicker__state">이미 추가됨</span>
</button>
<div v-if="!filteredTemplatePickerTemplates.length" class="hint hint--tight">검색 결과가 없어요.</div>
@@ -2276,7 +2337,7 @@ function openUserProfile(user) {
<div v-if="selectedTemplate?.template" class="adminSelectionCard">
<div class="adminSelectionCard__label">선택한 템플릿</div>
<div class="adminSelectionCard__title">{{ selectedTemplate.template.name }}</div>
<div class="adminSelectionCard__meta">{{ selectedTemplate.template.id }}</div>
<div class="adminSelectionCard__meta">{{ selectedTemplate.template.slug || selectedTemplate.template.id }}</div>
</div>
<div v-if="selectedTemplateId && !hasSelectedTemplate && !isTemplateLoading" class="hint hint--tight">선택된 템플릿 ID: {{ selectedTemplateId }}</div>
</div>

View File

@@ -48,7 +48,7 @@ async function loadFavorites() {
}
function openTierList(tierList) {
router.push(editorPath(tierList.topicId, tierList.id))
router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
}
onMounted(loadFavorites)

View File

@@ -66,7 +66,7 @@ async function loadFollowingFeed() {
}
function openTierList(tierList) {
router.push(editorPath(tierList.topicId, tierList.id))
router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
}
function openAuthorProfile(tierList) {

View File

@@ -21,7 +21,7 @@ const templates = computed(() => {
.filter((item) => item.id !== 'freeform')
.filter((item) => {
if (!query.value) return true
const haystack = `${item.name || ''} ${item.id || ''}`.toLowerCase()
const haystack = `${item.name || ''} ${item.slug || ''}`.toLowerCase()
return haystack.includes(query.value)
})
@@ -49,8 +49,8 @@ async function loadTemplates() {
onMounted(loadTemplates)
watch(() => auth.user?.id, loadTemplates)
function openTopic(templateId) {
router.push(topicPath(templateId))
function openTopic(template) {
router.push(topicPath(template?.slug || template?.id || ''))
}
async function toggleFavorite(template, event) {
@@ -99,14 +99,14 @@ function templateThumbUrl(template) {
>
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
</button>
<button class="libraryCard__main" type="button" @click="openTopic(template.id)">
<button class="libraryCard__main" type="button" @click="openTopic(template)">
<div class="libraryCard__thumbWrap">
<img v-if="templateThumbUrl(template)" class="libraryCard__thumb" :src="templateThumbUrl(template)" :alt="template.name" draggable="false" />
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
</div>
<div class="libraryCard__body">
<div class="libraryCard__title">{{ template.name }}</div>
<div class="libraryCard__meta">{{ template.id }}</div>
<div class="libraryCard__meta">{{ template.slug || template.id }}</div>
</div>
</button>
</article>

View File

@@ -60,7 +60,7 @@ onMounted(async () => {
})
function openList(t) {
router.push(editorPath(t.topicId, t.id))
router.push(editorPath(t.topicSlug || t.topicId, t.id))
}
</script>

View File

@@ -38,7 +38,7 @@ function tierListThumbnailUrl(tierList) {
}
function openTierList(tierList) {
router.push(editorPath(tierList.topicId, tierList.id))
router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
}
async function loadResults() {

View File

@@ -103,7 +103,7 @@ async function toggleFollow() {
}
function openTierList(tierList) {
router.push(editorPath(tierList.topicId, tierList.id))
router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
}
watch(userId, loadProfile, { immediate: true })