메뉴 관리: parent_id 오류 안내·표시/폴더 제거·태그형 드래그 UX (v0.0.95)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 11:53:39 +09:00
parent bcff96aa4c
commit 8b8a80034d
9 changed files with 293 additions and 135 deletions

View File

@@ -16,7 +16,8 @@ const { data: navigationItems } = await useFetch('/admin/api/navigation', {
const items = ref(navigationItems.value.map((item) => ({
...item,
parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder)
isFolder: Boolean(item.isFolder),
isVisible: true
})))
const navDraggingId = ref('')
@@ -45,8 +46,6 @@ const serializeNavigationItems = (list) => JSON.stringify(
url: String(item.url || '').trim(),
location: item.location,
sortOrder: Number(item.sortOrder || 0),
isVisible: Boolean(item.isVisible),
isFolder: Boolean(item.isFolder),
parentId: item.parentId ? String(item.parentId).trim() : null
}))
)
@@ -181,6 +180,19 @@ const onPrimaryDrop = ({ parentKey, targetId }) => {
onPrimaryDragEnd()
}
/**
* 입력·버튼 위에서는 행 드래그를 시작하지 않는다.
* @param {DragEvent} event - 이벤트
* @returns {boolean}
*/
const shouldBlockFooterRowDrag = (event) => {
const el = event.target
if (!el || typeof el.closest !== 'function') {
return false
}
return Boolean(el.closest('input, button, textarea, select, a'))
}
/**
* 하단 행 드래그 시작
* @param {DragEvent} event - 이벤트
@@ -188,6 +200,10 @@ const onPrimaryDrop = ({ parentKey, targetId }) => {
* @returns {void}
*/
const onFooterDragStart = (event, id) => {
if (shouldBlockFooterRowDrag(event)) {
event.preventDefault()
return
}
if (!event.dataTransfer) {
return
}
@@ -231,6 +247,21 @@ const onFooterDragEnd = () => {
footerDragOverId.value = ''
}
/**
* 하단 행 하이라이트(태그 관리와 동일)
* @param {string} id - 항목 id
* @returns {string}
*/
const footerRowClass = (id) => {
if (footerDragOverId.value === id) {
return 'bg-[#f9f9f7]'
}
if (footerDraggingId.value === id) {
return 'opacity-50'
}
return ''
}
/**
* 상단 루트 항목 추가
* @returns {void}
@@ -313,7 +344,7 @@ const saveNavigation = async () => {
url: item.url,
location: item.location,
sortOrder: Number(item.sortOrder || 0),
isVisible: Boolean(item.isVisible),
isVisible: true,
parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder)
}))
@@ -323,12 +354,14 @@ const saveNavigation = async () => {
items.value = savedItems.map((item) => ({
...item,
parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder)
isFolder: Boolean(item.isFolder),
isVisible: true
}))
navigationBaseline.value = serializeNavigationItems(items.value)
showToast('success', '네비게이션이 저장되었습니다.')
} catch (error) {
showToast('error', error?.data?.message || '네비게이션을 저장하지 못했습니다.')
const msg = error?.data?.message || error?.message || '네비게이션을 저장하지 못했습니다.'
showToast('error', msg)
} finally {
saving.value = false
}
@@ -346,7 +379,10 @@ const saveNavigation = async () => {
메뉴 관리
</h1>
<p class="admin-navigation__lead mt-2 max-w-xl text-sm text-muted">
상단·하단을 탭으로 누어 편집합니다. 상단은 드래그(::) 같은 단계끼리 순서를 바꿀 있고, 폴더·하위 메뉴를 습니다. 단은 목록만 지원합니다.
상단·하단을 탭으로 눕니다. 순서는 <span class="font-semibold text-ink">태그 관리의 메인 태그</span> 같이 행을 드래그합니다(번호·여백에서 잡고 이동). 입력란 위에서는 드래그가 시작되지 습니다. 메뉴가 있으면 공개 사이드바에서 접는 그룹으로 보입니다.
</p>
<p class="admin-navigation__migrate-hint mt-2 max-w-xl rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
저장 <code class="rounded bg-white/80 px-1">parent_id</code> 오류가 나면 DB에 마이그레이션이 아직 없는 것입니다. 로컬에서 <code class="rounded bg-white/80 px-1">npm run db:migrate:dev</code> <code class="rounded bg-white/80 px-1">017_navigation_hierarchy.sql</code> 적용하세요.
</p>
</div>
<div class="admin-navigation__header-actions flex flex-wrap gap-2">
@@ -425,51 +461,70 @@ const saveNavigation = async () => {
하단 메뉴가 없습니다.
</div>
<ul v-else class="admin-navigation__footer-list space-y-2 rounded border border-line bg-white p-4">
<li
v-for="item in footerItemsSorted"
:key="item.id"
class="admin-navigation__footer-row flex flex-wrap items-center gap-2 rounded border border-transparent px-2 py-2 transition-colors"
:class="footerDragOverId === item.id ? 'border-ink/20 bg-[#f5f5f2]' : ''"
>
<span
class="admin-navigation__footer-handle cursor-grab select-none text-muted active:cursor-grabbing"
draggable="true"
title="드래그하여 순서 변경"
@dragstart="onFooterDragStart($event, item.id)"
@dragover="onFooterDragOver($event, item.id)"
@drop="onFooterDrop($event, item.id)"
@dragend="onFooterDragEnd"
>
::
</span>
<label class="flex items-center gap-1 text-xs text-muted">
<input v-model="item.isVisible" class="h-4 w-4" type="checkbox">
표시
</label>
<input
v-model="item.label"
class="min-w-[100px] flex-1 rounded border border-line px-2 py-1.5 text-sm"
type="text"
placeholder="라벨"
required
>
<input
v-model="item.url"
class="min-w-[140px] flex-1 rounded border border-line px-2 py-1.5 text-sm font-mono"
type="text"
placeholder="URL"
required
>
<button
class="rounded border border-red-200 px-2 py-1 text-xs font-semibold text-red-700"
type="button"
@click="removeItemCascade(item.id)"
>
삭제
</button>
</li>
</ul>
<div v-else class="admin-navigation__footer-table overflow-hidden rounded border border-line">
<table class="admin-navigation__footer-table-inner w-full border-collapse text-left text-sm">
<thead class="admin-navigation__footer-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
<th class="admin-navigation__footer-cell px-4 py-3">
#
</th>
<th class="admin-navigation__footer-cell px-4 py-3">
라벨
</th>
<th class="admin-navigation__footer-cell px-4 py-3">
URL
</th>
<th class="admin-navigation__footer-cell px-4 py-3">
관리
</th>
</tr>
</thead>
<tbody class="admin-navigation__footer-body divide-y divide-line bg-white">
<tr
v-for="(item, index) in footerItemsSorted"
:key="item.id"
class="admin-navigation__footer-row cursor-move"
:class="footerRowClass(item.id)"
draggable="true"
@dragstart="onFooterDragStart($event, item.id)"
@dragover="onFooterDragOver($event, item.id)"
@drop="onFooterDrop($event, item.id)"
@dragend="onFooterDragEnd"
>
<td class="admin-navigation__footer-cell px-4 py-4 text-muted">
{{ index + 1 }}
</td>
<td class="admin-navigation__footer-cell px-4 py-4">
<input
v-model="item.label"
class="w-full min-w-[8rem] rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="라벨"
required
>
</td>
<td class="admin-navigation__footer-cell px-4 py-4">
<input
v-model="item.url"
class="w-full min-w-[10rem] rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="URL"
required
>
</td>
<td class="admin-navigation__footer-cell px-4 py-4">
<button
class="rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700"
type="button"
@click="removeItemCascade(item.id)"
>
삭제
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div