메뉴 관리: 탭 분리·드래그 정렬·상단 트리·공개 접기 (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 bcff96aa4c
19 changed files with 1146 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: [] 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> </script>
<template> <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__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"> <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"> <nav class="left-sidebar__nav" data-nav="menu">
<ul class="flex flex-col gap-[3px] text-[15px] text-[var(--site-text)]"> <SidebarPrimaryNavList :nodes="navigation.primary" />
<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>
</nav> </nav>
</div> </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>

View File

@@ -0,0 +1,9 @@
-- 상단(primary) 네비게이션 계층·폴더(접기) 지원, 하단(footer)은 평면 유지
ALTER TABLE navigation_items
ADD COLUMN IF NOT EXISTS parent_id UUID REFERENCES navigation_items (id) ON DELETE CASCADE,
ADD COLUMN IF NOT EXISTS is_folder BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE navigation_items DROP CONSTRAINT IF EXISTS navigation_items_location_label_url_key;
CREATE INDEX IF NOT EXISTS navigation_items_location_parent_sort_idx
ON navigation_items (location, parent_id, sort_order ASC, label ASC);

View File

@@ -28,6 +28,7 @@
- Tailwind 엔트리는 `nuxt.config.js``tailwindcss.cssPath: '~/assets/css/main.css'`로 통일한다(`@nuxtjs/tailwindcss` 기본 `assets/css/tailwind.css` 부재 시 패키지 `tailwind.css`가 중복 주입될 수 있음). - Tailwind 엔트리는 `nuxt.config.js``tailwindcss.cssPath: '~/assets/css/main.css'`로 통일한다(`@nuxtjs/tailwindcss` 기본 `assets/css/tailwind.css` 부재 시 패키지 `tailwind.css`가 중복 주입될 수 있음).
- 관리자 글 에디터는 블록 단위 UI로 작성하되 저장 값은 기존 마크다운 문자열을 유지 - 관리자 글 에디터는 블록 단위 UI로 작성하되 저장 값은 기존 마크다운 문자열을 유지
- 관리자 미디어 화면에서 모달에 가릴 수 있는 오류·안내는 `useAdminToast``showToast`로 우측 상단(`z-[100]`)에 표시한다. - 관리자 미디어 화면에서 모달에 가릴 수 있는 오류·안내는 `useAdminToast``showToast`로 우측 상단(`z-[100]`)에 표시한다.
- 관리자 메뉴 관리는 상단/하단 탭으로 나누고, 순서는 드래그로 조정한다(상단은 동일 부모의 형제끼리만).
```html ```html
<main class="site-main w-full max-w-full lg:max-w-[720px]"> <main class="site-main w-full max-w-full lg:max-w-[720px]">

View File

@@ -169,6 +169,7 @@ docker run -d -p 3000:3000 sori.studio:latest
- NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성 - NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development``--env-file .env.development`를 함께 사용 - 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development``--env-file .env.development`를 함께 사용
- 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행 - 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행
- 네비게이션 계층(`parent_id`, `is_folder`)은 `017_navigation_hierarchy.sql` 적용 후 저장 API가 정상 동작한다(미적용 시 `INSERT` 컬럼 불일치).
### 개발/운영 DB 분리 검증 절차 ### 개발/운영 DB 분리 검증 절차

View File

@@ -1,5 +1,11 @@
# 의사결정 이력 # 의사결정 이력
## 2026-05-12 v0.0.94
### 메뉴 관리 UX와 상단 네비 트리
위치를 셀렉트로 바꾸면 실수로 상·하단을 오가기 쉽고, 인덱스 입력은 모달·레이아웃과 겹칠 때 피드백이 약하다. 미디어처럼 탭으로 영역을 나누고 순서는 드래그로 통일했다. Ghost형 상단 그룹은 `parent_id`와 공개 트리 API, 사이드바에서 chevron 접기로 맞췄다.
## 2026-05-12 v0.0.93 ## 2026-05-12 v0.0.93
### 관리자 미디어 오류 표시를 토스트로 ### 관리자 미디어 오류 표시를 토스트로

View File

@@ -16,6 +16,13 @@
| 파일 | 용도 | | 파일 | 용도 |
|------|------| |------|------|
| composables/formatPostDate.js | 공개 화면 게시일 `YYYY.MM.DD` 포맷 | | composables/formatPostDate.js | 공개 화면 게시일 `YYYY.MM.DD` 포맷 |
| composables/useAdminToast.js | 관리자 우측 상단 토스트(성공·오류·안내; 모달보다 위 z-index) |
## 공유 라이브러리(서버·클라이언트 공통)
| 파일 | 용도 |
|------|------|
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree` |
## Nuxt 모듈 ## Nuxt 모듈
@@ -30,7 +37,8 @@
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) | | components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 우측 사용자 아바타 드롭다운(로그인: 설정/로그아웃, 비로그인: Sign up/Sign in), `lg`~`xl` 헤더 여백·반응형 검색창 폭, `/`·검색 영역으로 `SiteSearchModal` 연동 | | components/site/SiteHeader.vue | 모든 공개 페이지 상단, 우측 사용자 아바타 드롭다운(로그인: 설정/로그아웃, 비로그인: Sign up/Sign in), `lg`~`xl` 헤더 여백·반응형 검색창 폭, `/`·검색 영역으로 `SiteSearchModal` 연동 |
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 | | components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+``sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 푸터 `px-4`/`sm:px-5` | | components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+``sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`) |
| components/site/SidebarPrimaryNavList.vue | 공개 상단 네비 트리(접기/펼치기, `inject`로 펼침 상태 공유) |
| components/site/RightSidebar.vue | 오른쪽 사이드바, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` | | components/site/RightSidebar.vue | 오른쪽 사이드바, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 | | components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 | | components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 |
@@ -44,6 +52,7 @@
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 하단 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 배지형 태그 입력(한글 유지), 로컬 자동 저장, 예약 발행 시각 입력, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 | | components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 하단 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 배지형 태그 입력(한글 유지), 로컬 자동 저장, 예약 발행 시각 입력, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 | | components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 | | components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리 편집(드래그·하위 추가·폴더 체크, 재귀) |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) | | components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
## 콘텐츠 컴포넌트 ## 콘텐츠 컴포넌트
@@ -82,7 +91,7 @@
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 | | pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 | | pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) | | pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
| pages/admin/navigation/index.vue | 메뉴/네비게이션 관리(변경 시에만 메뉴 저장 활성) | | pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단 탭, 상단 트리+드래그(동일 부모 내), 하단 평면 드래그, `useAdminToast` |
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 | | pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 |
| pages/admin/tags/new.vue | 태그 생성 | | pages/admin/tags/new.vue | 태그 생성 |
| pages/admin/tags/[id].vue | 태그 수정 | | pages/admin/tags/[id].vue | 태그 수정 |
@@ -174,7 +183,8 @@
| server/utils/admin-navigation-input.js | 관리자 네비게이션 입력값 검증 스키마 | | server/utils/admin-navigation-input.js | 관리자 네비게이션 입력값 검증 스키마 |
| server/utils/admin-tag-input.js | 관리자 태그 입력값 검증 스키마 | | server/utils/admin-tag-input.js | 관리자 태그 입력값 검증 스키마 |
| server/utils/site-settings.js | 사이트 설정 기본값 유틸리티 | | server/utils/site-settings.js | 사이트 설정 기본값 유틸리티 |
| server/utils/navigation-items.js | 네비게이션 기본값과 그룹 유틸리티 | | server/utils/navigation-items.js | DB 없을 때 기본 네비 항목(UUID id·parentId·isFolder) |
| server/utils/navigation-tree.js | 네비 검증·삽입 순서·공개 primary 트리·DFS sort_order 재부여 |
| server/utils/media-library.js | 업로드 미디어·논리 폴더(`미분류`, 예약 `썸네일``avatarOwner` 부착·아바타 삭제/이름변경 차단 | | server/utils/media-library.js | 업로드 미디어·논리 폴더(`미분류`, 예약 `썸네일``avatarOwner` 부착·아바타 삭제/이름변경 차단 |
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 | | server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 | | server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
@@ -189,6 +199,7 @@
| db/migrations/002_seed_development.sql | 개발용 샘플 데이터 | | db/migrations/002_seed_development.sql | 개발용 샘플 데이터 |
| db/migrations/003_add_tag_display_fields.sql | 태그 표시 순서와 색상 필드 추가 | | db/migrations/003_add_tag_display_fields.sql | 태그 표시 순서와 색상 필드 추가 |
| db/migrations/004_add_site_settings.sql | 사이트 설정 테이블 추가 | | db/migrations/004_add_site_settings.sql | 사이트 설정 테이블 추가 |
| db/migrations/017_navigation_hierarchy.sql | `navigation_items``parent_id`·`is_folder`, `(location,label,url)` 유니크 제거 |
| db/migrations/005_add_navigation_items.sql | 네비게이션 항목 테이블 추가 | | db/migrations/005_add_navigation_items.sql | 네비게이션 항목 테이블 추가 |
| db/migrations/006_add_media_metadata.sql | 미디어 메타데이터 테이블 추가 | | db/migrations/006_add_media_metadata.sql | 미디어 메타데이터 테이블 추가 |
| db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 | | db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 |
@@ -212,7 +223,6 @@
| assets/css/main.css | 전역 스타일, `#fcfcfc` 단일 배경 기준, 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과 | | assets/css/main.css | 전역 스타일, `#fcfcfc` 단일 배경 기준, 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과 |
| composables/useMenuState.js | 좌측 메뉴 열림 상태·`closeMenu`(모바일 백드롭 등) | | composables/useMenuState.js | 좌측 메뉴 열림 상태·`closeMenu`(모바일 백드롭 등) |
| composables/useThemeMode.js | 사용자 화면 라이트/다크 테마 상태 관리 | | composables/useThemeMode.js | 사용자 화면 라이트/다크 테마 상태 관리 |
| composables/useAdminToast.js | 관리자 우측 상단 토스트(성공·오류·안내; 모달보다 위 z-index) |
| middleware/admin-auth.global.js | 관리자 페이지 접근 인증 | | middleware/admin-auth.global.js | 관리자 페이지 접근 인증 |
| scripts/dev-server.js | 로컬 개발 서버 링크 요약 출력 | | scripts/dev-server.js | 로컬 개발 서버 링크 요약 출력 |
| scripts/migrate-development-db.js | 로컬 개발 DB 마이그레이션 실행 | | scripts/migrate-development-db.js | 로컬 개발 DB 마이그레이션 실행 |

View File

@@ -348,7 +348,7 @@ components/content/
- `GET /api/tags` - 태그 목록 - `GET /api/tags` - 태그 목록
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만) - `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
- `GET /api/site-settings` - 공개 사이트 설정 - `GET /api/site-settings` - 공개 사이트 설정
- `GET /api/navigation` - 공개 네비게이션 - `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`는 평면, 상세는 위 메뉴/네비게이션 절)
- `POST /api/auth/signup` - 회원 가입 - `POST /api/auth/signup` - 회원 가입
- `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부 조회 - `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부 조회
- `POST /api/auth/login` - 회원 로그인 - `POST /api/auth/login` - 회원 로그인
@@ -517,11 +517,13 @@ components/content/
### 메뉴/네비게이션 ### 메뉴/네비게이션
- 네비게이션은 `navigation_items` 테이블로 관리한다. - 네비게이션은 `navigation_items` 테이블로 관리한다.
- 관리자는 메뉴 라벨, URL, 위치, 순서, 표시 여부를 수정할 수 있다. - 컬럼: `parent_id`(nullable, self FK, `ON DELETE CASCADE`), `is_folder`(boolean, 상단 그룹 표시용), `location`(`primary`|`footer`), `sort_order`, `label`, `url`, `is_visible`. `(location, label, url)` 유니크 제약은 제거되었다.
- 공개 왼쪽 사이드바의 상단 메뉴는 `primary` 위치 항목을 사용한다. - `GET /api/navigation` 응답: `primary`**트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. `footer`**평면** 배열(`parent_id` 없음)이며 `id`, `label`, `url`, `isVisible`만 내려간다.
- 공개 왼쪽 사이드바 하단 메뉴는 `footer` 위치 항목을 사용한다. - `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 서버가 위치별 트리 DFS 순으로 다시 부여한다. `parent_id``primary`에서만 허용되며 `footer` 항목은 항상 루트다.
- URL은 `/`로 시작하는 내부 경로 또는 `http://`, `https://` 외부 URL 허용한다. - URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#` 허용한다.
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 필드 조합이 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다. - 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션** 탭으로 구분한다(위치 셀렉트 없음). 상단은 `AdminNavPrimaryBranch`로 트리 편집·같은 부모 내 드래그 순서 변경·하위 추가·폴더 체크를 제공한다. 하단은 한 단계 목록만 드래그 정렬한다.
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링하며, 하위가 있는 노드는 chevron으로 펼침·접기한다. 펼침 상태는 `localStorage``sori-primary-nav-expanded`에 저장된다.
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
### 관리자 인증 ### 관리자 인증

View File

@@ -1,5 +1,11 @@
# 업데이트 이력 # 업데이트 이력
## v0.0.94
- 메뉴 관리: 상단/하단 탭 분리, 순서는 드래그(태그 관리와 유사). 상단은 `parent_id` 트리·하위 추가·폴더(`is_folder`)·동일 부모 내 순서 변경.
- `GET /api/navigation``primary`는 트리 응답, 좌측 사이드바는 `SidebarPrimaryNavList`로 접기/펼치기(`localStorage` 유지).
- 마이그레이션 `017_navigation_hierarchy.sql`, 공유 `lib/navigation-editor-tree.js`, `server/utils/navigation-tree.js` 검증·저장 순서.
## v0.0.93 ## v0.0.93
- `composables/useAdminToast.js` 추가: 관리자 우측 상단 토스트(자동 숨김). - `composables/useAdminToast.js` 추가: 관리자 우측 상단 토스트(자동 숨김).

View File

@@ -0,0 +1,44 @@
/**
* 관리자 UI용: 동일 location 항목을 부모 기준 트리 래퍼로 만든다(항목 객체는 원본 참조).
* @param {Array<Object>} flat - 전체 항목
* @param {'primary'|'footer'} location - 위치
* @returns {Array<{ item: Object, children: Array<{ item: Object, children: any[] }> }>}
*/
export const buildNavigationEditorTree = (flat, location) => {
const filtered = flat.filter((i) => i.location === location)
const nodeMap = new Map()
for (const item of filtered) {
nodeMap.set(String(item.id).trim(), { item, children: [] })
}
const roots = []
for (const item of filtered) {
const id = String(item.id).trim()
const wrap = nodeMap.get(id)
const p = item.parentId
if (p != null && String(p).trim() !== '') {
const pid = String(p).trim()
if (nodeMap.has(pid)) {
nodeMap.get(pid).children.push(wrap)
continue
}
}
roots.push(wrap)
}
/**
* @param {Array<{ item: Object, children: any[] }>} nodes - 노드
* @returns {void}
*/
const sortRec = (nodes) => {
nodes.sort((a, b) => (a.item.sortOrder || 0) - (b.item.sortOrder || 0))
for (const n of nodes) {
sortRec(n.children)
}
}
sortRec(roots)
return roots
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "sori.studio", "name": "sori.studio",
"version": "0.0.93", "version": "0.0.94",
"private": true, "private": true,
"type": "module", "type": "module",
"imports": { "imports": {

View File

@@ -1,31 +1,55 @@
<script setup> <script setup>
import { buildNavigationEditorTree } from '~/lib/navigation-editor-tree.js'
definePageMeta({ definePageMeta({
layout: 'admin' layout: 'admin'
}) })
const saving = ref(false) const saving = ref(false)
const errorMessage = ref('') const activeTab = ref('primary')
const toast = ref(null) const { toast, showToast, clearToast } = useAdminToast()
let toastTimer = null
const { data: navigationItems } = await useFetch('/admin/api/navigation', { const { data: navigationItems } = await useFetch('/admin/api/navigation', {
default: () => [] default: () => []
}) })
const items = ref(navigationItems.value.map((item) => ({ ...item }))) const items = ref(navigationItems.value.map((item) => ({
...item,
parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder)
})))
const navDraggingId = ref('')
const navDragParentKey = ref('')
const navDragOverId = ref('')
const footerDraggingId = ref('')
const footerDragOverId = ref('')
/** /**
* 네비게이션 항목을 저장 API와 동일한 형태로 직렬화한다. * 네비게이션 항목을 저장 API와 동일한 형태로 직렬화한다.
* @param {Array<Object>} list - 항목 목록 * @param {Array<Object>} list - 항목 목록
* @returns {string} 비교용 JSON 문자열 * @returns {string} 비교용 JSON 문자열
*/ */
const serializeNavigationItems = (list) => JSON.stringify(list.map((item) => ({ const serializeNavigationItems = (list) => JSON.stringify(
label: String(item.label || '').trim(), [...list]
url: String(item.url || '').trim(), .sort((a, b) => {
location: item.location, if (a.location !== b.location) {
sortOrder: Number(item.sortOrder || 0), return String(a.location).localeCompare(String(b.location))
isVisible: Boolean(item.isVisible) }
}))) return (a.sortOrder || 0) - (b.sortOrder || 0)
})
.map((item) => ({
id: String(item.id || '').trim(),
label: String(item.label || '').trim(),
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
}))
)
/** 서버에서 불러온 네비게이션 직렬화 스냅샷 */ /** 서버에서 불러온 네비게이션 직렬화 스냅샷 */
const navigationBaseline = ref(serializeNavigationItems(items.value)) const navigationBaseline = ref(serializeNavigationItems(items.value))
@@ -36,43 +60,234 @@ const navigationBaseline = ref(serializeNavigationItems(items.value))
*/ */
const isNavigationDirty = computed(() => serializeNavigationItems(items.value) !== navigationBaseline.value) const isNavigationDirty = computed(() => serializeNavigationItems(items.value) !== navigationBaseline.value)
const primaryTree = computed(() => buildNavigationEditorTree(items.value, 'primary'))
const footerItemsSorted = computed(() =>
items.value
.filter((item) => item.location === 'footer' && !item.parentId)
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
)
/** /**
* 저장 상태 토스트 표시 * 하위 항목 id를 모두 모은 뒤 삭제한다.
* @param {'success'|'error'|'info'} type - 토스트 타입 * @param {string} rootId - 루트 id
* @param {string} message - 표시 메시지
* @returns {void} * @returns {void}
*/ */
const showToast = (type, message) => { const removeItemCascade = (rootId) => {
window.clearTimeout(toastTimer) const toRemove = new Set([String(rootId)])
toast.value = { type, message } let growing = true
toastTimer = window.setTimeout(() => { while (growing) {
toast.value = null growing = false
}, 3200) for (const it of items.value) {
const pid = it.parentId ? String(it.parentId) : ''
if (pid && toRemove.has(pid) && !toRemove.has(String(it.id))) {
toRemove.add(String(it.id))
growing = true
}
}
}
items.value = items.value.filter((i) => !toRemove.has(String(i.id)))
} }
/** /**
* 새 네비게이션 항목 추가 * 상단 메뉴 동일 부모 형제 사이에서 순서만 바꾼다.
* @param {'primary'|'footer'} location - 표시 위치 * @param {string|null} parentId - 부모 id
* @param {string} sourceId - 이동할 id
* @param {string} targetId - 놓인 위치 기준 id
* @returns {void} * @returns {void}
*/ */
const addNavigationItem = (location = 'primary') => { const reorderPrimarySiblings = (parentId, sourceId, targetId) => {
items.value.push({ const pid = parentId || null
id: `new-${Date.now()}`, const siblings = items.value
label: '', .filter((i) => i.location === 'primary' && (pid ? i.parentId === pid : !i.parentId))
url: '/', .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
location, const ids = siblings.map((s) => String(s.id))
sortOrder: items.value.length * 10 + 10, const si = ids.indexOf(String(sourceId))
isVisible: true const ti = ids.indexOf(String(targetId))
if (si < 0 || ti < 0 || si === ti) {
return
}
const nextOrder = [...siblings]
const [mv] = nextOrder.splice(si, 1)
nextOrder.splice(ti, 0, mv)
nextOrder.forEach((row, idx) => {
row.sortOrder = (idx + 1) * 10
}) })
} }
/** /**
* 네비게이션 항목 삭제 * 하단 메뉴 순서 변경
* @param {number} index - 항목 인덱스 * @param {string} sourceId - 이동할 id
* @param {string} targetId - 기준 id
* @returns {void} * @returns {void}
*/ */
const removeNavigationItem = (index) => { const reorderFooter = (sourceId, targetId) => {
items.value.splice(index, 1) const list = footerItemsSorted.value.map((i) => i)
const ids = list.map((s) => String(s.id))
const si = ids.indexOf(String(sourceId))
const ti = ids.indexOf(String(targetId))
if (si < 0 || ti < 0 || si === ti) {
return
}
const [mv] = list.splice(si, 1)
list.splice(ti, 0, mv)
list.forEach((row, idx) => {
row.sortOrder = (idx + 1) * 10
})
}
/**
* 상단 트리 드래그 시작
* @param {{ parentKey: string, itemId: string }} payload - 부모 키와 항목 id
* @returns {void}
*/
const onPrimaryDragStart = ({ parentKey, itemId }) => {
navDragParentKey.value = parentKey
navDraggingId.value = itemId
}
/**
* 상단 트리 드래그 오버
* @param {string} itemId - 항목 id
* @returns {void}
*/
const onPrimaryDragOver = (itemId) => {
navDragOverId.value = itemId
}
/**
* 상단 트리 드래그 종료
* @returns {void}
*/
const onPrimaryDragEnd = () => {
navDraggingId.value = ''
navDragParentKey.value = ''
navDragOverId.value = ''
}
/**
* 상단 트리 드롭
* @param {{ parentKey: string, targetId: string }} payload - 부모 키와 대상 id
* @returns {void}
*/
const onPrimaryDrop = ({ parentKey, targetId }) => {
if (navDragParentKey.value !== parentKey) {
showToast('error', '같은 단계의 메뉴 안에서만 순서를 바꿀 수 있습니다.')
onPrimaryDragEnd()
return
}
const parentId = parentKey === 'root' ? null : parentKey
reorderPrimarySiblings(parentId, navDraggingId.value, targetId)
onPrimaryDragEnd()
}
/**
* 하단 행 드래그 시작
* @param {DragEvent} event - 이벤트
* @param {string} id - 항목 id
* @returns {void}
*/
const onFooterDragStart = (event, id) => {
if (!event.dataTransfer) {
return
}
footerDraggingId.value = id
event.dataTransfer.effectAllowed = 'move'
}
/**
* 하단 행 드래그 오버
* @param {DragEvent} event - 이벤트
* @param {string} id - 항목 id
* @returns {void}
*/
const onFooterDragOver = (event, id) => {
event.preventDefault()
footerDragOverId.value = id
}
/**
* 하단 행 드롭
* @param {DragEvent} event - 이벤트
* @param {string} targetId - 대상 id
* @returns {void}
*/
const onFooterDrop = (event, targetId) => {
event.preventDefault()
if (!footerDraggingId.value) {
return
}
reorderFooter(footerDraggingId.value, targetId)
footerDraggingId.value = ''
footerDragOverId.value = ''
}
/**
* 하단 드래그 종료
* @returns {void}
*/
const onFooterDragEnd = () => {
footerDraggingId.value = ''
footerDragOverId.value = ''
}
/**
* 상단 루트 항목 추가
* @returns {void}
*/
const addPrimaryRoot = () => {
const roots = items.value.filter((i) => i.location === 'primary' && !i.parentId)
const maxOrder = Math.max(0, ...roots.map((r) => r.sortOrder || 0))
items.value.push({
id: crypto.randomUUID(),
label: '새 메뉴',
url: '/',
location: 'primary',
parentId: null,
sortOrder: maxOrder + 10,
isVisible: true,
isFolder: false
})
}
/**
* 상단 특정 항목의 하위 추가
* @param {string} parentId - 부모 id
* @returns {void}
*/
const addPrimaryChild = (parentId) => {
const pid = String(parentId)
const siblings = items.value.filter((i) => i.location === 'primary' && String(i.parentId || '') === pid)
const maxOrder = Math.max(0, ...siblings.map((r) => r.sortOrder || 0))
items.value.push({
id: crypto.randomUUID(),
label: '새 하위 메뉴',
url: '#',
location: 'primary',
parentId: pid,
sortOrder: maxOrder + 10,
isVisible: true,
isFolder: false
})
}
/**
* 하단 항목 추가
* @returns {void}
*/
const addFooterItem = () => {
const list = items.value.filter((i) => i.location === 'footer' && !i.parentId)
const maxOrder = Math.max(0, ...list.map((r) => r.sortOrder || 0))
items.value.push({
id: crypto.randomUUID(),
label: '',
url: '/',
location: 'footer',
parentId: null,
sortOrder: maxOrder + 10,
isVisible: true,
isFolder: false
})
} }
/** /**
@@ -85,7 +300,7 @@ const saveNavigation = async () => {
} }
saving.value = true saving.value = true
errorMessage.value = '' clearToast()
showToast('info', '네비게이션을 저장하는 중입니다.') showToast('info', '네비게이션을 저장하는 중입니다.')
try { try {
@@ -93,34 +308,36 @@ const saveNavigation = async () => {
method: 'PUT', method: 'PUT',
body: { body: {
items: items.value.map((item) => ({ items: items.value.map((item) => ({
id: item.id,
label: item.label, label: item.label,
url: item.url, url: item.url,
location: item.location, location: item.location,
sortOrder: Number(item.sortOrder || 0), sortOrder: Number(item.sortOrder || 0),
isVisible: Boolean(item.isVisible) isVisible: Boolean(item.isVisible),
parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder)
})) }))
} }
}) })
items.value = savedItems.map((item) => ({ ...item })) items.value = savedItems.map((item) => ({
...item,
parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder)
}))
navigationBaseline.value = serializeNavigationItems(items.value) navigationBaseline.value = serializeNavigationItems(items.value)
showToast('success', '네비게이션이 저장되었습니다.') showToast('success', '네비게이션이 저장되었습니다.')
} catch (error) { } catch (error) {
errorMessage.value = error?.data?.message || '네비게이션을 저장하지 못했습니다.' showToast('error', error?.data?.message || '네비게이션을 저장하지 못했습니다.')
showToast('error', errorMessage.value)
} finally { } finally {
saving.value = false saving.value = false
} }
} }
onBeforeUnmount(() => {
window.clearTimeout(toastTimer)
})
</script> </script>
<template> <template>
<section class="admin-navigation bg-paper p-6"> <section class="admin-navigation bg-paper p-6">
<div class="admin-navigation__header mb-8 flex items-start justify-between gap-4"> <div class="admin-navigation__header mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<p class="admin-navigation__eyebrow text-xs font-semibold uppercase text-muted"> <p class="admin-navigation__eyebrow text-xs font-semibold uppercase text-muted">
Navigation Navigation
@@ -128,99 +345,136 @@ onBeforeUnmount(() => {
<h1 class="admin-navigation__title mt-2 text-3xl font-semibold"> <h1 class="admin-navigation__title mt-2 text-3xl font-semibold">
메뉴 관리 메뉴 관리
</h1> </h1>
<p class="admin-navigation__lead mt-2 max-w-xl text-sm text-muted">
상단·하단을 탭으로 나누어 편집합니다. 상단은 드래그(::) 같은 단계끼리 순서를 바꿀 있고, 폴더·하위 메뉴를 있습니다. 하단은 목록만 지원합니다.
</p>
</div> </div>
<div class="admin-navigation__header-actions flex flex-wrap gap-2"> <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 <button
class="admin-navigation__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50" class="admin-navigation__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
type="submit" type="button"
:disabled="saving || !isNavigationDirty" :disabled="saving || !isNavigationDirty"
@click="saveNavigation"
> >
{{ saving ? '저장 중' : '메뉴 저장' }} {{ saving ? '저장 중' : '메뉴 저장' }}
</button> </button>
</div> </div>
</form> </div>
<div class="admin-navigation__tabs mb-6 flex gap-2 border-b border-line">
<button
class="admin-navigation__tab -mb-px border-b-2 px-4 py-2 text-sm font-semibold transition-colors"
:class="activeTab === 'primary' ? 'border-ink text-ink' : 'border-transparent text-muted hover:text-ink'"
type="button"
@click="activeTab = 'primary'"
>
상단 네비게이션
</button>
<button
class="admin-navigation__tab -mb-px border-b-2 px-4 py-2 text-sm font-semibold transition-colors"
:class="activeTab === 'footer' ? 'border-ink text-ink' : 'border-transparent text-muted hover:text-ink'"
type="button"
@click="activeTab = 'footer'"
>
하단 네비게이션
</button>
</div>
<div v-show="activeTab === 'primary'" class="admin-navigation__panel-primary space-y-4">
<div class="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="addPrimaryRoot"
>
상단 메뉴 추가
</button>
</div>
<div v-if="primaryTree.length === 0" class="admin-navigation__empty rounded border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
상단 메뉴가 없습니다. 버튼으로 항목을 추가하세요.
</div>
<AdminNavPrimaryBranch
v-else
:wraps="primaryTree"
parent-key="root"
:dragging-id="navDraggingId"
:drag-over-id="navDragOverId"
@drag-start="onPrimaryDragStart"
@drag-over="onPrimaryDragOver"
@drag-end="onPrimaryDragEnd"
@drop="onPrimaryDrop"
@add-child="addPrimaryChild"
@remove="removeItemCascade"
/>
</div>
<div v-show="activeTab === 'footer'" class="admin-navigation__panel-footer space-y-4">
<div class="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="addFooterItem"
>
하단 메뉴 추가
</button>
</div>
<div v-if="footerItemsSorted.length === 0" class="admin-navigation__empty rounded border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
하단 메뉴가 없습니다.
</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>
<div <div
v-if="toast" 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="admin-navigation__toast fixed right-5 top-5 z-[100] max-w-sm rounded border px-4 py-3 text-sm font-semibold shadow-lg"
:class="{ :class="{
'border-green-200 bg-green-50 text-green-800': toast.type === 'success', '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-red-200 bg-red-50 text-red-800': toast.type === 'error',

View File

@@ -2,6 +2,6 @@ import { getPublicNavigation } from '../repositories/content-repository'
/** /**
* 공개 네비게이션 API * 공개 네비게이션 API
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>}>} 위치별 네비게이션 항목 * @returns {Promise<{primary: Array<Object>, footer: Array<Object>}>} `primary`는 트리(`children` 선택), `footer`는 평면
*/ */
export default defineEventHandler(() => getPublicNavigation()) export default defineEventHandler(() => getPublicNavigation())

View File

@@ -5,7 +5,8 @@ import {
getSamplePosts, getSamplePosts,
getSampleTags getSampleTags
} from '../utils/sample-content' } from '../utils/sample-content'
import { getDefaultNavigationItems, groupNavigationItems } from '../utils/navigation-items' import { getDefaultNavigationItems } from '../utils/navigation-items'
import { buildPublicPrimaryTree, orderNavigationItemsForInsert } from '../utils/navigation-tree'
import { getDefaultSiteSettings } from '../utils/site-settings' import { getDefaultSiteSettings } from '../utils/site-settings'
import { getPostgresClient } from './postgres-client' import { getPostgresClient } from './postgres-client'
@@ -89,6 +90,8 @@ const mapNavigationItemRow = (row) => ({
location: row.location, location: row.location,
sortOrder: row.sort_order, sortOrder: row.sort_order,
isVisible: row.is_visible, isVisible: row.is_visible,
parentId: row.parent_id ?? null,
isFolder: Boolean(row.is_folder),
createdAt: row.created_at.toISOString(), createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString() updatedAt: row.updated_at.toISOString()
}) })
@@ -806,7 +809,23 @@ export const listNavigationItems = async ({ visibleOnly = false } = {}) => {
* 공개 네비게이션 조회 * 공개 네비게이션 조회
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>}>} 위치별 공개 네비게이션 * @returns {Promise<{primary: Array<Object>, footer: Array<Object>}>} 위치별 공개 네비게이션
*/ */
export const getPublicNavigation = async () => groupNavigationItems(await listNavigationItems({ visibleOnly: true })) export const getPublicNavigation = async () => {
const flat = await listNavigationItems({ visibleOnly: true })
const primaryFlat = flat.filter((item) => item.location === 'primary')
const footerFlat = flat
.filter((item) => item.location === 'footer' && !item.parentId)
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
return {
primary: buildPublicPrimaryTree(primaryFlat),
footer: footerFlat.map((item) => ({
id: item.id,
label: item.label,
url: item.url,
isVisible: item.isVisible
}))
}
}
/** /**
* 관리자 네비게이션 항목 일괄 저장 * 관리자 네비게이션 항목 일괄 저장
@@ -825,21 +844,28 @@ export const updateNavigationItems = async (items) => {
DELETE FROM navigation_items DELETE FROM navigation_items
` `
for (const item of items) { const ordered = orderNavigationItemsForInsert(items)
for (const item of ordered) {
await transaction` await transaction`
INSERT INTO navigation_items ( INSERT INTO navigation_items (
id,
label, label,
url, url,
location, location,
sort_order, sort_order,
is_visible is_visible,
parent_id,
is_folder
) )
VALUES ( VALUES (
${item.id},
${item.label}, ${item.label},
${item.url}, ${item.url},
${item.location}, ${item.location},
${item.sortOrder}, ${item.sortOrder},
${item.isVisible} ${item.isVisible},
${item.parentId ?? null},
${Boolean(item.isFolder)}
) )
` `
} }

View File

@@ -2,6 +2,7 @@ import { createError, readBody } from 'h3'
import { requireAdminSession } from '../../../utils/admin-auth' import { requireAdminSession } from '../../../utils/admin-auth'
import { parseAdminNavigationInput } from '../../../utils/admin-navigation-input' import { parseAdminNavigationInput } from '../../../utils/admin-navigation-input'
import { updateNavigationItems } from '../../../repositories/content-repository' import { updateNavigationItems } from '../../../repositories/content-repository'
import { renumberSortOrderByTree, validateNavigationItems } from '../../../utils/navigation-tree'
/** /**
* 관리자 네비게이션 일괄 저장 API * 관리자 네비게이션 일괄 저장 API
@@ -20,5 +21,27 @@ export default defineEventHandler(async (event) => {
}) })
} }
return updateNavigationItems(parsedBody.data.items) const items = parsedBody.data.items.map((row) => ({
id: row.id,
label: row.label.trim(),
url: row.url.trim(),
location: row.location,
sortOrder: row.sortOrder,
isVisible: row.isVisible,
parentId: row.parentId ?? null,
isFolder: Boolean(row.isFolder)
}))
const checked = validateNavigationItems(items)
if (!checked.ok) {
throw createError({
statusCode: 400,
message: checked.message
})
}
renumberSortOrderByTree(items, 'primary')
renumberSortOrderByTree(items, 'footer')
return updateNavigationItems(items)
}) })

View File

@@ -1,12 +1,22 @@
import { z } from 'zod' import { z } from 'zod'
export const adminNavigationItemInputSchema = z.object({ export const adminNavigationItemInputSchema = z.object({
id: z.string().optional().nullable(), id: z.string().uuid(),
label: z.string().trim().min(1), label: z.string().trim().min(1),
url: z.string().trim().min(1).regex(/^(\/|https?:\/\/)/), url: z.string().trim().min(1).regex(/^(\/|https?:\/\/|#)/),
location: z.enum(['primary', 'footer']), location: z.enum(['primary', 'footer']),
sortOrder: z.coerce.number().int().min(0).default(0), sortOrder: z.coerce.number().int().min(0).default(0),
isVisible: z.boolean().default(true) isVisible: z.boolean().default(true),
parentId: z.union([z.string().uuid(), z.null()]).optional(),
isFolder: z.boolean().default(false)
}).superRefine((data, ctx) => {
if (data.location === 'footer' && data.parentId) {
ctx.addIssue({
code: 'custom',
path: ['parentId'],
message: '하단 메뉴는 하위 항목을 가질 수 없습니다.'
})
}
}) })
export const adminNavigationInputSchema = z.object({ export const adminNavigationInputSchema = z.object({

View File

@@ -1,26 +1,17 @@
/** /**
* 기본 네비게이션 항목 반환 * 기본 네비게이션 항목 반환(DB 없을 때)
* id는 UUID 형식이어야 관리자 저장 검증과 맞는다.
* @returns {Array<Object>} 기본 네비게이션 항목 * @returns {Array<Object>} 기본 네비게이션 항목
*/ */
export const getDefaultNavigationItems = () => [ export const getDefaultNavigationItems = () => [
{ id: 'default-primary-home', label: 'Home pages', url: '/', location: 'primary', sortOrder: 10, isVisible: true }, { id: 'a0000001-0001-4001-8001-000000000001', label: 'Home pages', url: '/', location: 'primary', sortOrder: 10, isVisible: true, parentId: null, isFolder: false },
{ id: 'default-primary-tags', label: 'Tags', url: '/tags', location: 'primary', sortOrder: 20, isVisible: true }, { id: 'a0000001-0001-4001-8001-000000000002', label: 'Tags', url: '/tags', location: 'primary', sortOrder: 20, isVisible: true, parentId: null, isFolder: false },
{ id: 'default-primary-authors', label: 'Authors', url: '/pages/about', location: 'primary', sortOrder: 30, isVisible: true }, { id: 'a0000001-0001-4001-8001-000000000003', label: 'Authors', url: '/pages/about', location: 'primary', sortOrder: 30, isVisible: true, parentId: null, isFolder: false },
{ id: 'default-primary-style', label: 'Style', url: '/post/hello-sori-studio', location: 'primary', sortOrder: 40, isVisible: true }, { id: 'a0000001-0001-4001-8001-000000000004', label: 'Style', url: '/post/hello-sori-studio', location: 'primary', sortOrder: 40, isVisible: true, parentId: null, isFolder: false },
{ id: 'default-primary-post-types', label: 'Post types', url: '/post/custom-writing-tool', location: 'primary', sortOrder: 50, isVisible: true }, { id: 'a0000001-0001-4001-8001-000000000005', label: 'Post types', url: '/post/custom-writing-tool', location: 'primary', sortOrder: 50, isVisible: true, parentId: null, isFolder: false },
{ id: 'default-primary-members', label: 'Members', url: '/pages/contact', location: 'primary', sortOrder: 60, isVisible: true }, { id: 'a0000001-0001-4001-8001-000000000006', label: 'Members', url: '/pages/contact', location: 'primary', sortOrder: 60, isVisible: true, parentId: null, isFolder: false },
{ id: 'default-primary-landing', label: 'Landing pages', url: '/pages/projects', location: 'primary', sortOrder: 70, isVisible: true }, { id: 'a0000001-0001-4001-8001-000000000007', label: 'Landing pages', url: '/pages/projects', location: 'primary', sortOrder: 70, isVisible: true, parentId: null, isFolder: false },
{ id: 'default-footer-portal', label: 'Portal', url: '/pages/links', location: 'footer', sortOrder: 10, isVisible: true }, { id: 'a0000002-0002-4002-8002-000000000001', label: 'Portal', url: '/pages/links', location: 'footer', sortOrder: 10, isVisible: true, parentId: null, isFolder: false },
{ id: 'default-footer-docs', label: 'Docs', url: '/pages/about', location: 'footer', sortOrder: 20, isVisible: true }, { id: 'a0000002-0002-4002-8002-000000000002', label: 'Docs', url: '/pages/about', location: 'footer', sortOrder: 20, isVisible: true, parentId: null, isFolder: false },
{ id: 'default-footer-projects', label: 'Projects', url: '/pages/projects', location: 'footer', sortOrder: 30, isVisible: true } { id: 'a0000002-0002-4002-8002-000000000003', label: 'Projects', url: '/pages/projects', location: 'footer', sortOrder: 30, isVisible: true, parentId: null, isFolder: false }
] ]
/**
* 네비게이션 항목을 위치별로 묶기
* @param {Array<Object>} items - 네비게이션 항목 목록
* @returns {{primary: Array<Object>, footer: Array<Object>}} 위치별 네비게이션 항목
*/
export const groupNavigationItems = (items) => ({
primary: items.filter((item) => item.location === 'primary'),
footer: items.filter((item) => item.location === 'footer')
})

View File

@@ -0,0 +1,230 @@
import { buildNavigationEditorTree } from '../../lib/navigation-editor-tree.js'
/**
* 네비게이션 항목 배열이 유효한지 검증한다.
* @param {Array<Object>} items - { id, label, url, location, sortOrder, isVisible, isFolder, parentId }
* @returns {{ ok: true } | { ok: false, message: string }}
*/
export const validateNavigationItems = (items) => {
if (!Array.isArray(items) || items.length === 0) {
return { ok: true }
}
const idSet = new Set()
for (const item of items) {
const id = String(item.id || '').trim()
if (!id) {
return { ok: false, message: '네비게이션 항목 id가 비어 있습니다.' }
}
if (idSet.has(id)) {
return { ok: false, message: '네비게이션 항목 id가 중복되었습니다.' }
}
idSet.add(id)
}
const byId = new Map(items.map((i) => [String(i.id).trim(), i]))
for (const item of items) {
const id = String(item.id).trim()
const loc = item.location
if (loc === 'footer') {
const p = item.parentId
if (p != null && String(p).trim() !== '') {
return { ok: false, message: '하단 네비게이션에는 하위 항목을 둘 수 없습니다.' }
}
}
const p = item.parentId
if (p != null && String(p).trim() !== '') {
const pid = String(p).trim()
if (!byId.has(pid)) {
return { ok: false, message: '상위 메뉴 참조가 잘못되었습니다.' }
}
const parent = byId.get(pid)
if (parent.location !== loc) {
return { ok: false, message: '상위 메뉴는 같은 위치(상단/하단)에 있어야 합니다.' }
}
}
}
const maxDepth = 12
for (const item of items) {
const path = new Set()
let cur = item
let depth = 0
while (cur) {
const cid = String(cur.id).trim()
if (path.has(cid)) {
return { ok: false, message: '메뉴 상위 참조에 순환이 있습니다.' }
}
path.add(cid)
const p = cur.parentId
if (p == null || String(p).trim() === '') {
break
}
const pid = String(p).trim()
cur = byId.get(pid)
depth += 1
if (depth > maxDepth) {
return { ok: false, message: '메뉴 계층이 너무 깊습니다.' }
}
if (!cur) {
break
}
}
}
return { ok: true }
}
/**
* 부모가 항상 자식보다 먼저 오도록 삽입 순서를 정한다.
* @param {Array<Object>} items - 네비게이션 항목
* @returns {Array<Object>} 정렬된 동일 배열의 얕은 복사
*/
export const orderNavigationItemsForInsert = (items) => {
const byId = new Map(items.map((i) => [String(i.id).trim(), i]))
const result = []
const placed = new Set()
/**
* @param {Object} item - 항목
* @param {Set<string>} stack - 순환 방지
* @returns {void}
*/
const place = (item, stack) => {
const id = String(item.id).trim()
if (placed.has(id)) {
return
}
const p = item.parentId
if (p != null && String(p).trim() !== '') {
const pid = String(p).trim()
if (byId.has(pid)) {
if (stack.has(pid)) {
return
}
stack.add(pid)
place(byId.get(pid), stack)
stack.delete(pid)
}
}
if (!placed.has(id)) {
result.push(item)
placed.add(id)
}
}
for (const item of items) {
place(item, new Set())
}
if (result.length !== items.length) {
for (const item of items) {
const id = String(item.id).trim()
if (!placed.has(id)) {
result.push(item)
placed.add(id)
}
}
}
return result
}
/**
* location 기준 DFS로 sort_order를 10단위로 다시 부여한다.
* @param {Array<Object>} items - 전체 항목(변경됨)
* @param {'primary'|'footer'} location - 위치
* @returns {void}
*/
export const renumberSortOrderByTree = (items, location) => {
const tree = buildNavigationEditorTree(items, location)
let n = 0
/**
* @param {Array<{ item: Object, children: any[] }>} nodes - 노드
* @returns {void}
*/
const walk = (nodes) => {
for (const { item, children } of nodes) {
n += 1
item.sortOrder = n * 10
if (children.length) {
walk(children)
}
}
}
walk(tree)
}
/**
* 공개 API용 primary 트리(순환 참조 없음).
* @param {Array<Object>} flatPrimary - location primary인 항목만
* @returns {Array<Object>}
*/
export const buildPublicPrimaryTree = (flatPrimary) => {
const list = flatPrimary.map((row) => ({
id: row.id,
label: row.label,
url: row.url,
isFolder: Boolean(row.isFolder),
isVisible: row.isVisible !== false,
sortOrder: row.sortOrder || 0,
parentId: row.parentId || null
}))
const byId = new Map(list.map((i) => [String(i.id), { ...i, children: [] }]))
const roots = []
for (const row of list) {
const id = String(row.id)
const node = byId.get(id)
const p = row.parentId
if (p && byId.has(String(p))) {
byId.get(String(p)).children.push(node)
} else {
roots.push(node)
}
}
/**
* @param {Array<any>} nodes - 노드
* @returns {void}
*/
const sortNodes = (nodes) => {
nodes.sort((a, b) => a.sortOrder - b.sortOrder)
for (const node of nodes) {
if (node.children.length) {
sortNodes(node.children)
}
}
}
sortNodes(roots)
/**
* @param {any} node - 노드
* @returns {Object}
*/
const strip = (node) => {
const base = {
id: node.id,
label: node.label,
url: node.url,
isFolder: node.isFolder,
isVisible: node.isVisible
}
if (node.children.length) {
return {
...base,
children: node.children.map(strip)
}
}
return base
}
return roots.map(strip)
}