메뉴 관리: 탭 분리·드래그 정렬·상단 트리·공개 접기 (v0.0.94)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
91
components/site/SidebarPrimaryNavList.vue
Normal file
91
components/site/SidebarPrimaryNavList.vue
Normal 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>
|
||||
Reference in New Issue
Block a user