메뉴 관리: parent_id 오류 안내·표시/폴더 제거·태그형 드래그 UX (v0.0.95)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user