사이드바 상단 네비: 부모 행 통합 토글·chevron·높이 애니메이션 (v0.0.96)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 12:16:50 +09:00
parent 8b8a80034d
commit 4e1056311d
6 changed files with 52 additions and 45 deletions

View File

@@ -1,5 +1,5 @@
<script setup>
const props = defineProps({
defineProps({
/** 공개 API `primary` 트리 노드 */
nodes: {
type: Array,
@@ -18,72 +18,69 @@ const toggleBranch = inject('sidebarPrimaryNavToggle')
const isExpanded = (id) => expandedSet?.value?.has(String(id)) ?? true
/**
* 노드 URL이 실제 링크로 쓸 수 있는지
* @param {string} url - URL
* @returns {boolean}
* 부모 행(이름·행 전체) 클릭으로 하위 접기/펼치기
* @param {string} id - 노드 id
* @returns {void}
*/
const hasNavigableUrl = (url) => Boolean(url && String(url).trim() !== '' && String(url).trim() !== '#')
const onBranchClick = (id) => {
toggleBranch(id)
}
</script>
<template>
<ul class="sidebar-primary-nav-list flex flex-col gap-[3px] text-[15px] text-[var(--site-text)]">
<ul class="sidebar-primary-nav-list flex w-full 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"
class="sidebar-primary-nav-list__branch w-full"
>
<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
type="button"
class="sidebar-primary-nav-list__branch-toggle site-panel-hover group flex w-full max-w-full items-center gap-2 rounded-[10px] py-1.5 pr-2 pl-0 text-left leading-tight text-[var(--site-text)] transition-[padding,background-color] duration-200 hover:px-3"
:aria-expanded="isExpanded(node.id)"
:aria-label="isExpanded(node.id) ? `${node.label} 하위 메뉴 접기` : `${node.label} 하위 메뉴 펼치기`"
@click="onBranchClick(node.id)"
>
<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 class="sidebar-primary-nav-list__dot h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--site-muted)]" />
<span class="sidebar-primary-nav-list__branch-label min-w-0 flex-1 truncate font-medium transition-transform duration-200 group-hover:translate-x-[3px]">{{ node.label }}</span>
<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)]"
class="sidebar-primary-nav-list__chevron-wrap grid h-5 w-5 shrink-0 place-items-center text-[var(--site-muted)] transition-transform duration-200 ease-out group-hover:text-[var(--site-text)]"
:class="{ '-rotate-180': isExpanded(node.id) }"
aria-hidden="true"
>
<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>
<svg class="sidebar-primary-nav-list__chevron-svg h-3.5 w-3.5" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.5 4.25L6 7.75L9.5 4.25" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</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"
</button>
<div
class="sidebar-primary-nav-list__sub-grid grid min-h-0 w-full max-w-full transition-[grid-template-rows] duration-200 ease-out"
:class="isExpanded(node.id) ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
>
<SidebarPrimaryNavList :nodes="node.children" />
</ul>
<div class="sidebar-primary-nav-list__sub-clip min-h-0 overflow-hidden">
<ul class="sidebar-primary-nav-list__sub ml-0 border-l border-[var(--site-line)] pl-2 pt-0">
<SidebarPrimaryNavList :nodes="node.children" />
</ul>
</div>
</div>
</li>
<li
v-else
class="sidebar-primary-nav-list__leaf group relative flex w-full items-center"
class="sidebar-primary-nav-list__leaf group relative flex w-full max-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))]"
v-if="node.url && String(node.url).trim() !== '' && String(node.url).trim() !== '#'"
class="sidebar-primary-nav-list__nav-link site-panel-hover flex min-w-0 max-w-full 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>
<span class="sidebar-primary-nav-list__label min-w-0 flex-1 truncate 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)]"
class="sidebar-primary-nav-list__leaf-static flex min-w-0 max-w-full 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 class="min-w-0 flex-1 truncate font-medium">{{ node.label }}</span>
</span>
</li>
</template>

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-12 v0.0.96
### 사이드바 상단 네비 폴더 토글 UX
chevron 전용 버튼과 펼침 배경은 원본 Ghost류 UI와 어긋난다. 부모 한 줄을 단일 컨트롤로 두고 chevron·패널 높이에 전환을 줘서 끊김을 줄였다.
## 2026-05-12 v0.0.95
### 메뉴 관리 단순화와 드래그 UX

View File

@@ -38,7 +38,7 @@
| 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` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`) |
| components/site/SidebarPrimaryNavList.vue | 공개 상단 네비 트리(접기/펼치기, `inject`로 펼침 상태 공유) |
| components/site/SidebarPrimaryNavList.vue | 공개 상단 네비 트리: 하위 있으면 행 전체 `button` 토글·SVG chevron 회전·`grid` 높이 애니메이션, `inject`로 펼침·`localStorage` `sori-primary-nav-expanded` |
| components/site/RightSidebar.vue | 오른쪽 사이드바, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 |

View File

@@ -523,7 +523,7 @@ components/content/
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다.
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·번호 열에서 시작한다. 상단은 `AdminNavPrimaryBranch`로 트리·동일 부모 내 순서 변경·하위 추가를 제공한다. 하단은 한 단계 목록만 드래그 정렬한다.
- `parent_id` / `is_folder` 컬럼이 DB에 없을 때 저장은 실패한다. `npm run db:migrate:dev``017_navigation_hierarchy.sql`을 적용해야 한다(저장 API는 해당 경우 한국어 안내 메시지를 반환할 수 있다).
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링하며, 하위가 있는 노드는 chevron으로 펼침·접기한다. 펼침 상태는 `localStorage``sori-primary-nav-expanded`에 저장된다.
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 점·chevron) 클릭**으로 접기/펼친다(별도 chevron 전용 버튼 없음). chevron은 SVG에 `transition-transform`으로 회전하고, 하위 목록은 `grid-template-rows` `0fr``1fr` 전환으로 높이가 자연스럽게 바뀐다. 펼침 행에만 깔리던 배경 하이라이트는 두지 않는다. **하위가 있는 부모**는 토글 전용이므로 공개 화면에서 부모 `url`로 이동하지 않는다(링크는 리프·하위 항목에 둔다). 펼침 상태는 `localStorage``sori-primary-nav-expanded`에 저장된다.
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
### 관리자 인증

View File

@@ -1,5 +1,9 @@
# 업데이트 이력
## v0.0.96
- `SidebarPrimaryNavList`: 하위가 있는 부모는 **행 전체 버튼**으로 토글, chevron **회전 애니메이션**, 하위 영역 **높이 전환 애니메이션**, 펼침 전용 배경 제거·너비·간격 리프와 통일.
## v0.0.95
- 메뉴 관리: `표시`·`폴더` 체크 제거(항목은 항상 공개, `is_folder`는 저장 시 자식 유무로 서버 설정). 테이블+행 드래그 UX를 태그 메인 태그와 동일 톤(`bg-[#f9f9f7]`·`opacity-50`, 입력 위에서는 드래그 시작 안 함).

View File

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