diff --git a/components/admin/AdminNavPrimaryBranch.vue b/components/admin/AdminNavPrimaryBranch.vue new file mode 100644 index 0000000..5ccb101 --- /dev/null +++ b/components/admin/AdminNavPrimaryBranch.vue @@ -0,0 +1,160 @@ + + + + + + + + :: + + + + 표시 + + + + 폴더 + + + + + 하위 + + + 삭제 + + + + + + diff --git a/components/site/LeftSidebar.vue b/components/site/LeftSidebar.vue index 52f8a4d..e8430da 100644 --- a/components/site/LeftSidebar.vue +++ b/components/site/LeftSidebar.vue @@ -18,6 +18,120 @@ const { data: navigation } = await useFetch('/api/navigation', { footer: [] }) }) + +const STORAGE_KEY = 'sori-primary-nav-expanded' + +/** + * 트리에서 하위가 있는 노드 id를 모은다. + * @param {Array} 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} 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} 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) +}) @@ -31,20 +145,7 @@ const { data: navigation } = await useFetch('/api/navigation', { - - - - {{ item.label }} - - - + diff --git a/components/site/SidebarPrimaryNavList.vue b/components/site/SidebarPrimaryNavList.vue new file mode 100644 index 0000000..21c6e72 --- /dev/null +++ b/components/site/SidebarPrimaryNavList.vue @@ -0,0 +1,91 @@ + + + + + + + + + {{ isExpanded(node.id) ? '⌃' : '⌄' }} + + + {{ node.label }} + + + + {{ node.label }} + + + + + + + + + {{ node.label }} + + + + {{ node.label }} + + + + + diff --git a/db/migrations/017_navigation_hierarchy.sql b/db/migrations/017_navigation_hierarchy.sql new file mode 100644 index 0000000..8ea83a8 --- /dev/null +++ b/db/migrations/017_navigation_hierarchy.sql @@ -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); diff --git a/docs/convention.md b/docs/convention.md index c94778d..f5c46c8 100644 --- a/docs/convention.md +++ b/docs/convention.md @@ -28,6 +28,7 @@ - Tailwind 엔트리는 `nuxt.config.js`의 `tailwindcss.cssPath: '~/assets/css/main.css'`로 통일한다(`@nuxtjs/tailwindcss` 기본 `assets/css/tailwind.css` 부재 시 패키지 `tailwind.css`가 중복 주입될 수 있음). - 관리자 글 에디터는 블록 단위 UI로 작성하되 저장 값은 기존 마크다운 문자열을 유지 - 관리자 미디어 화면에서 모달에 가릴 수 있는 오류·안내는 `useAdminToast`의 `showToast`로 우측 상단(`z-[100]`)에 표시한다. +- 관리자 메뉴 관리는 상단/하단 탭으로 나누고, 순서는 드래그로 조정한다(상단은 동일 부모의 형제끼리만). ```html diff --git a/docs/deploy.md b/docs/deploy.md index d1a6f3c..4f242fd 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -169,6 +169,7 @@ docker run -d -p 3000:3000 sori.studio:latest - NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성 - 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 사용 - 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행 +- 네비게이션 계층(`parent_id`, `is_folder`)은 `017_navigation_hierarchy.sql` 적용 후 저장 API가 정상 동작한다(미적용 시 `INSERT` 컬럼 불일치). ### 개발/운영 DB 분리 검증 절차 diff --git a/docs/history.md b/docs/history.md index b25890a..d0c6e35 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-05-12 v0.0.94 + +### 메뉴 관리 UX와 상단 네비 트리 + +위치를 셀렉트로 바꾸면 실수로 상·하단을 오가기 쉽고, 인덱스 입력은 모달·레이아웃과 겹칠 때 피드백이 약하다. 미디어처럼 탭으로 영역을 나누고 순서는 드래그로 통일했다. Ghost형 상단 그룹은 `parent_id`와 공개 트리 API, 사이드바에서 chevron 접기로 맞췄다. + ## 2026-05-12 v0.0.93 ### 관리자 미디어 오류 표시를 토스트로 diff --git a/docs/map.md b/docs/map.md index 0b6016b..a8496e9 100644 --- a/docs/map.md +++ b/docs/map.md @@ -16,6 +16,13 @@ | 파일 | 용도 | |------|------| | composables/formatPostDate.js | 공개 화면 게시일 `YYYY.MM.DD` 포맷 | +| composables/useAdminToast.js | 관리자 우측 상단 토스트(성공·오류·안내; 모달보다 위 z-index) | + +## 공유 라이브러리(서버·클라이언트 공통) + +| 파일 | 용도 | +|------|------| +| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree` | ## Nuxt 모듈 @@ -30,7 +37,8 @@ | components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) | | components/site/SiteHeader.vue | 모든 공개 페이지 상단, 우측 사용자 아바타 드롭다운(로그인: 설정/로그아웃, 비로그인: Sign up/Sign in), `lg`~`xl` 헤더 여백·반응형 검색창 폭, `/`·검색 영역으로 `SiteSearchModal` 연동 | | 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/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 | | components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 | @@ -44,6 +52,7 @@ | components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 하단 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 배지형 태그 입력(한글 유지), 로컬 자동 저장, 예약 발행 시각 입력, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 | | components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 | | components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 | +| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리 편집(드래그·하위 추가·폴더 체크, 재귀) | | components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) | ## 콘텐츠 컴포넌트 @@ -82,7 +91,7 @@ | pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 | | pages/admin/pages/[id].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/new.vue | 태그 생성 | | pages/admin/tags/[id].vue | 태그 수정 | @@ -174,7 +183,8 @@ | server/utils/admin-navigation-input.js | 관리자 네비게이션 입력값 검증 스키마 | | server/utils/admin-tag-input.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/repositories/postgres-client.js | PostgreSQL 클라이언트 | | server/repositories/content-repository.js | 콘텐츠 조회 저장소 | @@ -189,6 +199,7 @@ | db/migrations/002_seed_development.sql | 개발용 샘플 데이터 | | db/migrations/003_add_tag_display_fields.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/006_add_media_metadata.sql | 미디어 메타데이터 테이블 추가 | | db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 | @@ -212,7 +223,6 @@ | assets/css/main.css | 전역 스타일, `#fcfcfc` 단일 배경 기준, 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과 | | composables/useMenuState.js | 좌측 메뉴 열림 상태·`closeMenu`(모바일 백드롭 등) | | composables/useThemeMode.js | 사용자 화면 라이트/다크 테마 상태 관리 | -| composables/useAdminToast.js | 관리자 우측 상단 토스트(성공·오류·안내; 모달보다 위 z-index) | | middleware/admin-auth.global.js | 관리자 페이지 접근 인증 | | scripts/dev-server.js | 로컬 개발 서버 링크 요약 출력 | | scripts/migrate-development-db.js | 로컬 개발 DB 마이그레이션 실행 | diff --git a/docs/spec.md b/docs/spec.md index 4f1516c..a95c201 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -348,7 +348,7 @@ components/content/ - `GET /api/tags` - 태그 목록 - `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만) - `GET /api/site-settings` - 공개 사이트 설정 -- `GET /api/navigation` - 공개 네비게이션 +- `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`는 평면, 상세는 위 메뉴/네비게이션 절) - `POST /api/auth/signup` - 회원 가입 - `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부 조회 - `POST /api/auth/login` - 회원 로그인 @@ -517,11 +517,13 @@ components/content/ ### 메뉴/네비게이션 - 네비게이션은 `navigation_items` 테이블로 관리한다. -- 관리자는 메뉴 라벨, URL, 위치, 순서, 표시 여부를 수정할 수 있다. -- 공개 왼쪽 사이드바의 상단 메뉴는 `primary` 위치 항목을 사용한다. -- 공개 왼쪽 사이드바 하단 메뉴는 `footer` 위치 항목을 사용한다. -- URL은 `/`로 시작하는 내부 경로 또는 `http://`, `https://` 외부 URL을 허용한다. -- 관리자 메뉴 관리 화면에서 저장 API로 보내는 필드 조합이 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다. +- 컬럼: `parent_id`(nullable, self FK, `ON DELETE CASCADE`), `is_folder`(boolean, 상단 그룹 표시용), `location`(`primary`|`footer`), `sort_order`, `label`, `url`, `is_visible`. `(location, label, url)` 유니크 제약은 제거되었다. +- `GET /api/navigation` 응답: `primary`는 **트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. `footer`는 **평면** 배열(`parent_id` 없음)이며 `id`, `label`, `url`, `isVisible`만 내려간다. +- `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 서버가 위치별 트리 DFS 순으로 다시 부여한다. `parent_id`는 `primary`에서만 허용되며 `footer` 항목은 항상 루트다. +- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다. +- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션** 탭으로 구분한다(위치 셀렉트 없음). 상단은 `AdminNavPrimaryBranch`로 트리 편집·같은 부모 내 드래그 순서 변경·하위 추가·폴더 체크를 제공한다. 하단은 한 단계 목록만 드래그 정렬한다. +- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링하며, 하위가 있는 노드는 chevron으로 펼침·접기한다. 펼침 상태는 `localStorage` 키 `sori-primary-nav-expanded`에 저장된다. +- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다. ### 관리자 인증 diff --git a/docs/update.md b/docs/update.md index 0c62455..1445c9f 100644 --- a/docs/update.md +++ b/docs/update.md @@ -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 - `composables/useAdminToast.js` 추가: 관리자 우측 상단 토스트(자동 숨김). diff --git a/lib/navigation-editor-tree.js b/lib/navigation-editor-tree.js new file mode 100644 index 0000000..723af0c --- /dev/null +++ b/lib/navigation-editor-tree.js @@ -0,0 +1,44 @@ +/** + * 관리자 UI용: 동일 location 항목을 부모 기준 트리 래퍼로 만든다(항목 객체는 원본 참조). + * @param {Array} 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 +} diff --git a/package.json b/package.json index c649c01..2bc4d9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.93", + "version": "0.0.94", "private": true, "type": "module", "imports": { diff --git a/pages/admin/navigation/index.vue b/pages/admin/navigation/index.vue index 7efbdec..8dcc4c6 100644 --- a/pages/admin/navigation/index.vue +++ b/pages/admin/navigation/index.vue @@ -1,31 +1,55 @@ - + Navigation @@ -128,99 +345,136 @@ onBeforeUnmount(() => { 메뉴 관리 + + 상단·하단을 탭으로 나누어 편집합니다. 상단은 드래그(::)로 같은 단계끼리 순서를 바꿀 수 있고, 폴더·하위 메뉴를 둘 수 있습니다. 하단은 한 줄 목록만 지원합니다. + - - 상단 메뉴 추가 - - - 하단 메뉴 추가 - - - - - - {{ errorMessage }} - - - - - - - - 표시 - 라벨 - URL - 위치 - 순서 - 관리 - - - - - - - - - - - - - - - - 상단 - 하단 - - - - - - - - 삭제 - - - - - - - - - 메뉴 항목이 없습니다. - - - {{ saving ? '저장 중' : '메뉴 저장' }} - + + + + + 상단 네비게이션 + + + 하단 네비게이션 + + + + + + + 상단 메뉴 추가 + + + + + 상단 메뉴가 없습니다. 위 버튼으로 항목을 추가하세요. + + + + + +
Navigation @@ -128,99 +345,136 @@ onBeforeUnmount(() => {
+ 상단·하단을 탭으로 나누어 편집합니다. 상단은 드래그(::)로 같은 단계끼리 순서를 바꿀 수 있고, 폴더·하위 메뉴를 둘 수 있습니다. 하단은 한 줄 목록만 지원합니다. +
- {{ errorMessage }} -
- 메뉴 항목이 없습니다. -