메뉴 관리: 탭 분리·드래그 정렬·상단 트리·공개 접기 (v0.0.94)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 11:16:43 +09:00
parent 4de5589bcb
commit 77e1a050b9
18 changed files with 1145 additions and 181 deletions

View File

@@ -0,0 +1,160 @@
<script setup>
import AdminNavPrimaryBranch from './AdminNavPrimaryBranch.vue'
const props = defineProps({
/** buildNavigationEditorTree 결과 */
wraps: {
type: Array,
required: true
},
/** 들여쓰기 단계 */
depth: {
type: Number,
default: 0
},
/** 루트면 `'root'`, 아니면 부모 항목 id */
parentKey: {
type: String,
default: 'root'
},
/** 드래그 중인 항목 id */
draggingId: {
type: String,
default: ''
},
/** 드롭 대상 위에 올린 항목 id */
dragOverId: {
type: String,
default: ''
}
})
const emit = defineEmits([
'drag-start',
'drag-over',
'drag-end',
'drop',
'add-child',
'remove'
])
/**
* 드래그 시작
* @param {DragEvent} event - 이벤트
* @param {string} itemId - 항목 id
* @returns {void}
*/
const onDragStart = (event, itemId) => {
if (!event.dataTransfer) {
return
}
emit('drag-start', { parentKey: props.parentKey, itemId })
event.dataTransfer.effectAllowed = 'move'
}
/**
* 드래그 오버
* @param {DragEvent} event - 이벤트
* @param {string} itemId - 항목 id
* @returns {void}
*/
const onDragOver = (event, itemId) => {
event.preventDefault()
emit('drag-over', itemId)
}
/**
* 드롭
* @param {DragEvent} event - 이벤트
* @param {string} itemId - 대상 id
* @returns {void}
*/
const onDrop = (event, itemId) => {
event.preventDefault()
emit('drop', { parentKey: props.parentKey, targetId: itemId })
}
/**
* 드래그 종료
* @returns {void}
*/
const onDragEnd = () => {
emit('drag-end')
}
</script>
<template>
<ul class="admin-nav-primary-branch space-y-2" :class="depth ? 'mt-1 border-l border-line pl-3' : ''">
<li
v-for="wrap in wraps"
:key="wrap.item.id"
class="admin-nav-primary-branch__row rounded border border-transparent bg-white px-2 py-2 transition-colors"
:class="dragOverId === wrap.item.id ? 'border-ink/20 bg-[#f5f5f2]' : draggingId === wrap.item.id ? 'opacity-60' : ''"
>
<div class="admin-nav-primary-branch__controls flex flex-wrap items-center gap-2" :style="{ paddingLeft: `${depth * 4}px` }">
<span
class="admin-nav-primary-branch__handle cursor-grab select-none text-muted active:cursor-grabbing"
draggable="true"
title="드래그하여 순서 변경"
@dragstart="onDragStart($event, wrap.item.id)"
@dragover="onDragOver($event, wrap.item.id)"
@drop="onDrop($event, wrap.item.id)"
@dragend="onDragEnd"
>
::
</span>
<label class="admin-nav-primary-branch__visible flex items-center gap-1 text-xs text-muted">
<input v-model="wrap.item.isVisible" class="h-4 w-4" type="checkbox">
표시
</label>
<label class="admin-nav-primary-branch__folder flex items-center gap-1 text-xs text-muted">
<input v-model="wrap.item.isFolder" class="h-4 w-4" type="checkbox">
폴더
</label>
<input
v-model="wrap.item.label"
class="admin-nav-primary-branch__label min-w-[120px] flex-1 rounded border border-line px-2 py-1.5 text-sm"
type="text"
placeholder="라벨"
required
>
<input
v-model="wrap.item.url"
class="admin-nav-primary-branch__url min-w-[160px] flex-1 rounded border border-line px-2 py-1.5 text-sm font-mono"
type="text"
placeholder="URL (# 또는 /경로)"
required
>
<button
class="admin-nav-primary-branch__add-child rounded border border-line px-2 py-1 text-xs font-semibold"
type="button"
@click="emit('add-child', wrap.item.id)"
>
하위
</button>
<button
class="admin-nav-primary-branch__remove rounded border border-red-200 px-2 py-1 text-xs font-semibold text-red-700"
type="button"
@click="emit('remove', wrap.item.id)"
>
삭제
</button>
</div>
<AdminNavPrimaryBranch
v-if="wrap.children.length"
class="admin-nav-primary-branch__children mt-2"
:wraps="wrap.children"
:depth="depth + 1"
:parent-key="String(wrap.item.id)"
:dragging-id="draggingId"
:drag-over-id="dragOverId"
@drag-start="emit('drag-start', $event)"
@drag-over="emit('drag-over', $event)"
@drag-end="emit('drag-end')"
@drop="emit('drop', $event)"
@add-child="emit('add-child', $event)"
@remove="emit('remove', $event)"
/>
</li>
</ul>
</template>

View File

@@ -18,6 +18,120 @@ const { data: navigation } = await useFetch('/api/navigation', {
footer: []
})
})
const STORAGE_KEY = 'sori-primary-nav-expanded'
/**
* 트리에서 하위가 있는 노드 id를 모은다.
* @param {Array<Object>} list - 노드 목록
* @returns {string[]} id 목록
*/
const collectBranchIds = (list) => {
const out = []
for (const node of list || []) {
if (node.children?.length) {
out.push(String(node.id))
out.push(...collectBranchIds(node.children))
}
}
return out
}
/**
* localStorage에서 펼침 상태를 읽는다.
* @returns {string[]|null} id 배열
*/
const readStoredExpanded = () => {
if (typeof window === 'undefined') {
return null
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) {
return null
}
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.map(String) : null
} catch {
return null
}
}
/**
* 펼침 상태를 localStorage에 저장한다.
* @param {Set<string>} set - id 집합
* @returns {void}
*/
const persistExpanded = (set) => {
if (typeof window === 'undefined') {
return
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...set]))
}
const primaryNavExpandedSet = ref(new Set())
/**
* 트리 구조에 맞게 펼침 집합을 맞춘다.
* @param {Array<Object>} nodes - primary 트리
* @param {boolean} useStorage - 최초·복원 시 저장값 반영
* @returns {void}
*/
const syncPrimaryNavExpanded = (nodes, useStorage = false) => {
const allBranch = new Set(collectBranchIds(nodes))
if (useStorage) {
const stored = readStoredExpanded()
if (stored && stored.length) {
const next = new Set()
for (const id of stored) {
if (allBranch.has(id)) {
next.add(id)
}
}
primaryNavExpandedSet.value = next.size ? next : allBranch
return
}
}
const next = new Set()
for (const id of primaryNavExpandedSet.value) {
if (allBranch.has(id)) {
next.add(id)
}
}
primaryNavExpandedSet.value = next.size ? next : allBranch
}
/**
* 상단 네비 폴더 펼침 토글
* @param {string} id - 노드 id
* @returns {void}
*/
const togglePrimaryNavBranch = (id) => {
const key = String(id)
const next = new Set(primaryNavExpandedSet.value)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
primaryNavExpandedSet.value = next
persistExpanded(next)
}
provide('sidebarPrimaryNavExpandedSet', primaryNavExpandedSet)
provide('sidebarPrimaryNavToggle', togglePrimaryNavBranch)
watch(
() => navigation.value?.primary,
(nodes) => {
syncPrimaryNavExpanded(nodes || [], false)
},
{ deep: true }
)
onMounted(() => {
syncPrimaryNavExpanded(navigation.value?.primary || [], true)
})
</script>
<template>
@@ -31,20 +145,7 @@ const { data: navigation } = await useFetch('/api/navigation', {
<div class="left-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
<div class="left-sidebar__block site-sidebar-section py-3 pl-4 pr-3 sm:pl-5 xl:pl-0">
<nav class="left-sidebar__nav" data-nav="menu">
<ul class="flex flex-col gap-[3px] text-[15px] text-[var(--site-text)]">
<li
v-for="item in navigation.primary"
:key="item.id"
class="group relative flex w-full items-center"
>
<NuxtLink
class="left-sidebar__nav-link site-panel-hover flex flex-1 items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200 before:h-4 before:w-1 before:flex-none before:rounded-sm before:rounded-l-none before:bg-[var(--site-line)] before:transition-[width,height,border-radius,background-color] before:duration-200 hover:px-3 hover:before:h-2 hover:before:w-2 hover:before:rounded-full hover:before:bg-[color:color-mix(in_srgb,var(--site-text)_28%,var(--site-line))]"
:to="item.url"
>
<span class="left-sidebar__nav-link-label flex-1 transition-transform duration-200 group-hover:translate-x-[3px]">{{ item.label }}</span>
</NuxtLink>
</li>
</ul>
<SidebarPrimaryNavList :nodes="navigation.primary" />
</nav>
</div>

View File

@@ -0,0 +1,91 @@
<script setup>
const props = defineProps({
/** 공개 API `primary` 트리 노드 */
nodes: {
type: Array,
default: () => []
}
})
const expandedSet = inject('sidebarPrimaryNavExpandedSet')
const toggleBranch = inject('sidebarPrimaryNavToggle')
/**
* 노드가 펼쳐져 있는지
* @param {string} id - 노드 id
* @returns {boolean}
*/
const isExpanded = (id) => expandedSet?.value?.has(String(id)) ?? true
/**
* 노드 URL이 실제 링크로 쓸 수 있는지
* @param {string} url - URL
* @returns {boolean}
*/
const hasNavigableUrl = (url) => Boolean(url && String(url).trim() !== '' && String(url).trim() !== '#')
</script>
<template>
<ul class="sidebar-primary-nav-list flex flex-col gap-[3px] text-[15px] text-[var(--site-text)]">
<template v-for="node in nodes" :key="node.id">
<li
v-if="node.children?.length"
class="sidebar-primary-nav-list__branch group relative flex w-full flex-col"
>
<div
class="sidebar-primary-nav-list__branch-row flex w-full items-stretch gap-0.5 rounded-[10px] py-1.5 pr-1 leading-tight"
:class="isExpanded(node.id) ? 'bg-[color:color-mix(in_srgb,var(--site-text)_6%,transparent)]' : ''"
>
<button
class="sidebar-primary-nav-list__chevron site-panel-hover grid w-8 shrink-0 place-items-center rounded-lg text-xs text-[var(--site-muted)] transition-colors hover:text-[var(--site-text)]"
type="button"
:aria-expanded="isExpanded(node.id)"
:aria-label="isExpanded(node.id) ? '하위 메뉴 접기' : '하위 메뉴 펼치기'"
@click="toggleBranch(node.id)"
>
<span class="select-none">{{ isExpanded(node.id) ? '⌃' : '⌄' }}</span>
</button>
<NuxtLink
v-if="hasNavigableUrl(node.url)"
class="sidebar-primary-nav-list__nav-link site-panel-hover flex min-w-0 flex-1 items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200 before:h-4 before:w-1 before:flex-none before:rounded-sm before:rounded-l-none before:bg-[var(--site-line)] before:transition-[width,height,border-radius,background-color] before:duration-200 hover:px-3 hover:before:h-2 hover:before:w-2 hover:before:rounded-full hover:before:bg-[color:color-mix(in_srgb,var(--site-text)_28%,var(--site-line))]"
:to="node.url"
>
<span class="sidebar-primary-nav-list__label flex-1 truncate transition-transform duration-200 group-hover:translate-x-[3px]">{{ node.label }}</span>
</NuxtLink>
<span
v-else
class="sidebar-primary-nav-list__folder-label flex min-w-0 flex-1 items-center gap-2 py-1.5 pr-3 pl-2 text-[var(--site-text)]"
>
<span class="sidebar-primary-nav-list__dot h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--site-muted)]" />
<span class="truncate font-medium">{{ node.label }}</span>
</span>
</div>
<ul
v-show="isExpanded(node.id)"
class="sidebar-primary-nav-list__sub ml-2 mt-0.5 border-l border-[var(--site-line)] pl-2"
>
<SidebarPrimaryNavList :nodes="node.children" />
</ul>
</li>
<li
v-else
class="sidebar-primary-nav-list__leaf group relative flex w-full items-center"
>
<NuxtLink
v-if="hasNavigableUrl(node.url)"
class="sidebar-primary-nav-list__nav-link site-panel-hover flex flex-1 items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200 before:h-4 before:w-1 before:flex-none before:rounded-sm before:rounded-l-none before:bg-[var(--site-line)] before:transition-[width,height,border-radius,background-color] before:duration-200 hover:px-3 hover:before:h-2 hover:before:w-2 hover:before:rounded-full hover:before:bg-[color:color-mix(in_srgb,var(--site-text)_28%,var(--site-line))]"
:to="node.url"
>
<span class="sidebar-primary-nav-list__label flex-1 transition-transform duration-200 group-hover:translate-x-[3px]">{{ node.label }}</span>
</NuxtLink>
<span
v-else
class="sidebar-primary-nav-list__leaf-static flex flex-1 items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-2 text-[var(--site-text)]"
>
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--site-muted)]" />
<span class="font-medium">{{ node.label }}</span>
</span>
</li>
</template>
</ul>
</template>