Files
sori.studio/pages/admin/navigation/index.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>