235 lines
8.1 KiB
Vue
235 lines
8.1 KiB
Vue
<script setup>
|
|
definePageMeta({
|
|
layout: 'admin'
|
|
})
|
|
|
|
const saving = ref(false)
|
|
const errorMessage = ref('')
|
|
const toast = ref(null)
|
|
let toastTimer = null
|
|
|
|
const { data: navigationItems } = await useFetch('/admin/api/navigation', {
|
|
default: () => []
|
|
})
|
|
|
|
const items = ref(navigationItems.value.map((item) => ({ ...item })))
|
|
|
|
/**
|
|
* 네비게이션 항목을 저장 API와 동일한 형태로 직렬화한다.
|
|
* @param {Array<Object>} list - 항목 목록
|
|
* @returns {string} 비교용 JSON 문자열
|
|
*/
|
|
const serializeNavigationItems = (list) => JSON.stringify(list.map((item) => ({
|
|
label: String(item.label || '').trim(),
|
|
url: String(item.url || '').trim(),
|
|
location: item.location,
|
|
sortOrder: Number(item.sortOrder || 0),
|
|
isVisible: Boolean(item.isVisible)
|
|
})))
|
|
|
|
/** 서버에서 불러온 네비게이션 직렬화 스냅샷 */
|
|
const navigationBaseline = ref(serializeNavigationItems(items.value))
|
|
|
|
/**
|
|
* 현재 편집본이 서버 스냅샷과 다른지 여부
|
|
* @returns {boolean} 변경 여부
|
|
*/
|
|
const isNavigationDirty = computed(() => serializeNavigationItems(items.value) !== navigationBaseline.value)
|
|
|
|
/**
|
|
* 저장 상태 토스트 표시
|
|
* @param {'success'|'error'|'info'} type - 토스트 타입
|
|
* @param {string} message - 표시 메시지
|
|
* @returns {void}
|
|
*/
|
|
const showToast = (type, message) => {
|
|
window.clearTimeout(toastTimer)
|
|
toast.value = { type, message }
|
|
toastTimer = window.setTimeout(() => {
|
|
toast.value = null
|
|
}, 3200)
|
|
}
|
|
|
|
/**
|
|
* 새 네비게이션 항목 추가
|
|
* @param {'primary'|'footer'} location - 표시 위치
|
|
* @returns {void}
|
|
*/
|
|
const addNavigationItem = (location = 'primary') => {
|
|
items.value.push({
|
|
id: `new-${Date.now()}`,
|
|
label: '',
|
|
url: '/',
|
|
location,
|
|
sortOrder: items.value.length * 10 + 10,
|
|
isVisible: true
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 네비게이션 항목 삭제
|
|
* @param {number} index - 항목 인덱스
|
|
* @returns {void}
|
|
*/
|
|
const removeNavigationItem = (index) => {
|
|
items.value.splice(index, 1)
|
|
}
|
|
|
|
/**
|
|
* 네비게이션 항목 목록 저장
|
|
* @returns {Promise<void>} 저장 결과
|
|
*/
|
|
const saveNavigation = async () => {
|
|
if (saving.value || !isNavigationDirty.value) {
|
|
return
|
|
}
|
|
|
|
saving.value = true
|
|
errorMessage.value = ''
|
|
showToast('info', '네비게이션을 저장하는 중입니다.')
|
|
|
|
try {
|
|
const savedItems = await $fetch('/admin/api/navigation', {
|
|
method: 'PUT',
|
|
body: {
|
|
items: items.value.map((item) => ({
|
|
label: item.label,
|
|
url: item.url,
|
|
location: item.location,
|
|
sortOrder: Number(item.sortOrder || 0),
|
|
isVisible: Boolean(item.isVisible)
|
|
}))
|
|
}
|
|
})
|
|
|
|
items.value = savedItems.map((item) => ({ ...item }))
|
|
navigationBaseline.value = serializeNavigationItems(items.value)
|
|
showToast('success', '네비게이션이 저장되었습니다.')
|
|
} catch (error) {
|
|
errorMessage.value = error?.data?.message || '네비게이션을 저장하지 못했습니다.'
|
|
showToast('error', errorMessage.value)
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
window.clearTimeout(toastTimer)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<section class="admin-navigation bg-paper p-6">
|
|
<div class="admin-navigation__header mb-8 flex items-start justify-between gap-4">
|
|
<div>
|
|
<p class="admin-navigation__eyebrow text-xs font-semibold uppercase text-muted">
|
|
Navigation
|
|
</p>
|
|
<h1 class="admin-navigation__title mt-2 text-3xl font-semibold">
|
|
메뉴 관리
|
|
</h1>
|
|
</div>
|
|
<div class="admin-navigation__header-actions flex flex-wrap gap-2">
|
|
<button class="admin-navigation__add rounded border border-line bg-white px-4 py-2 text-sm font-semibold" type="button" @click="addNavigationItem('primary')">
|
|
상단 메뉴 추가
|
|
</button>
|
|
<button class="admin-navigation__add rounded border border-line bg-white px-4 py-2 text-sm font-semibold" type="button" @click="addNavigationItem('footer')">
|
|
하단 메뉴 추가
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="errorMessage" class="admin-navigation__error mb-5 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
{{ errorMessage }}
|
|
</p>
|
|
|
|
<form class="admin-navigation__form grid gap-5" @submit.prevent="saveNavigation">
|
|
<div class="admin-navigation__table overflow-hidden border border-line bg-white">
|
|
<table class="admin-navigation__table-inner w-full border-collapse text-left text-sm">
|
|
<thead class="admin-navigation__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
|
|
<tr>
|
|
<th class="admin-navigation__cell px-4 py-3">표시</th>
|
|
<th class="admin-navigation__cell px-4 py-3">라벨</th>
|
|
<th class="admin-navigation__cell px-4 py-3">URL</th>
|
|
<th class="admin-navigation__cell px-4 py-3">위치</th>
|
|
<th class="admin-navigation__cell px-4 py-3">순서</th>
|
|
<th class="admin-navigation__cell px-4 py-3">관리</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="admin-navigation__table-body divide-y divide-line">
|
|
<tr v-for="(item, index) in items" :key="item.id || index" class="admin-navigation__row">
|
|
<td class="admin-navigation__cell px-4 py-3">
|
|
<input v-model="item.isVisible" class="admin-navigation__checkbox h-4 w-4" type="checkbox">
|
|
</td>
|
|
<td class="admin-navigation__cell px-4 py-3">
|
|
<input
|
|
v-model="item.label"
|
|
class="admin-navigation__input w-full rounded border border-line px-3 py-2"
|
|
type="text"
|
|
required
|
|
>
|
|
</td>
|
|
<td class="admin-navigation__cell px-4 py-3">
|
|
<input
|
|
v-model="item.url"
|
|
class="admin-navigation__input w-full rounded border border-line px-3 py-2"
|
|
type="text"
|
|
required
|
|
pattern="^(\/|https?:\/\/).*"
|
|
>
|
|
</td>
|
|
<td class="admin-navigation__cell px-4 py-3">
|
|
<select v-model="item.location" class="admin-navigation__select rounded border border-line px-3 py-2">
|
|
<option value="primary">상단</option>
|
|
<option value="footer">하단</option>
|
|
</select>
|
|
</td>
|
|
<td class="admin-navigation__cell px-4 py-3">
|
|
<input
|
|
v-model.number="item.sortOrder"
|
|
class="admin-navigation__sort w-24 rounded border border-line px-3 py-2"
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
>
|
|
</td>
|
|
<td class="admin-navigation__cell px-4 py-3">
|
|
<button class="admin-navigation__remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700" type="button" @click="removeNavigationItem(index)">
|
|
삭제
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<p v-if="items.length === 0" class="admin-navigation__empty rounded border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
|
|
메뉴 항목이 없습니다.
|
|
</p>
|
|
|
|
<div class="admin-navigation__actions flex justify-end border-t border-line pt-5">
|
|
<button
|
|
class="admin-navigation__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
|
type="submit"
|
|
:disabled="saving || !isNavigationDirty"
|
|
>
|
|
{{ saving ? '저장 중' : '메뉴 저장' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<div
|
|
v-if="toast"
|
|
class="admin-navigation__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
|
:class="{
|
|
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
|
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
|
'border-line bg-white text-ink': toast.type === 'info'
|
|
}"
|
|
role="status"
|
|
>
|
|
{{ toast.message }}
|
|
</div>
|
|
</section>
|
|
</template>
|