메뉴 관리: 탭 분리·드래그 정렬·상단 트리·공개 접기 (v0.0.94)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
160
components/admin/AdminNavPrimaryBranch.vue
Normal file
160
components/admin/AdminNavPrimaryBranch.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
9
db/migrations/017_navigation_hierarchy.sql
Normal file
9
db/migrations/017_navigation_hierarchy.sql
Normal 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);
|
||||||
@@ -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]">
|
||||||
|
|||||||
@@ -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 분리 검증 절차
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
### 관리자 미디어 오류 표시를 토스트로
|
### 관리자 미디어 오류 표시를 토스트로
|
||||||
|
|||||||
18
docs/map.md
18
docs/map.md
@@ -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 마이그레이션 실행 |
|
||||||
|
|||||||
14
docs/spec.md
14
docs/spec.md
@@ -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로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
|
||||||
|
|
||||||
### 관리자 인증
|
### 관리자 인증
|
||||||
|
|
||||||
|
|||||||
@@ -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` 추가: 관리자 우측 상단 토스트(자동 숨김).
|
||||||
|
|||||||
44
lib/navigation-editor-tree.js
Normal file
44
lib/navigation-editor-tree.js
Normal 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
|
||||||
|
}
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)}
|
||||||
)
|
)
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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')
|
|
||||||
})
|
|
||||||
|
|||||||
230
server/utils/navigation-tree.js
Normal file
230
server/utils/navigation-tree.js
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user