사이드바 푸터 링크 줄바꿈·상단 네비 호버 행 전체 너비 (v0.0.98)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 12:33:10 +09:00
parent 4e1056311d
commit 996965740f
7 changed files with 104 additions and 15 deletions

View File

@@ -191,19 +191,22 @@ onMounted(() => {
</div>
</div>
<footer class="left-sidebar__footer flex shrink-0 items-center justify-between px-4 py-4 text-xs sm:px-5">
<nav class="left-sidebar__footer-nav flex gap-4">
<footer class="left-sidebar__footer flex shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 px-4 py-4 text-xs sm:px-5">
<nav
class="left-sidebar__footer-nav flex min-w-0 flex-1 flex-wrap items-center gap-x-4 gap-y-1"
aria-label="하단 링크"
>
<NuxtLink
v-for="item in navigation.footer"
:key="item.id"
class="site-interactive"
class="left-sidebar__footer-link site-interactive shrink-0"
:to="item.url"
>
{{ item.label }}
</NuxtLink>
</nav>
<button
class="left-sidebar__theme-dot site-panel-hover site-interactive grid h-7 w-7 place-items-center rounded-full"
class="left-sidebar__theme-dot site-panel-hover site-interactive grid h-7 w-7 shrink-0 place-items-center rounded-full"
type="button"
:aria-label="isDarkMode ? '라이트 모드로 전환' : '다크 모드로 전환'"
:title="isDarkMode ? '라이트 모드' : '다크 모드'"

View File

@@ -7,9 +7,26 @@ defineProps({
}
})
const route = useRoute()
const expandedSet = inject('sidebarPrimaryNavExpandedSet')
const toggleBranch = inject('sidebarPrimaryNavToggle')
/** 세로바·호버 시 원형으로 바뀌는 공통 before 스타일(리프 링크와 동일) */
const navBarBeforeBase =
"before:pointer-events-none before:content-[''] before:h-4 before:w-1 before:flex-none before:rounded-sm before:rounded-l-none 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"
/** 비활성 경로: 기본 회색 막대, 호버 시 원형·믹스 색 */
const navBarBeforeInactive =
`${navBarBeforeBase} before:bg-[var(--site-line)] hover:before:bg-[color:color-mix(in_srgb,var(--site-text)_28%,var(--site-line))]`
/** 현재 페이지와 일치하는 내부 링크: 브랜드(`--site-accent`) 막대·호버 원형 */
const navBarBeforeActive =
`${navBarBeforeBase} before:bg-[var(--site-accent)] hover:before:bg-[var(--site-accent)]`
/** 행 공통: site-panel-hover, flex, 패딩 전환(가로 전체 호버 배경) */
const navRowShell =
'site-panel-hover flex w-full min-w-0 max-w-full items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200'
/**
* 노드가 펼쳐져 있는지
* @param {string} id - 노드 id
@@ -25,6 +42,53 @@ const isExpanded = (id) => expandedSet?.value?.has(String(id)) ?? true
const onBranchClick = (id) => {
toggleBranch(id)
}
/**
* 외부 URL 여부
* @param {string} raw - 네비 URL
* @returns {boolean}
*/
const isExternalUrl = (raw) => /^https?:\/\//i.test(String(raw || '').trim())
/**
* 내부 링크이고 현재 경로와 일치하는지(쿼리 무시, 끝 슬래시 정규화)
* @param {string} raw - 네비 URL
* @returns {boolean}
*/
const isInternalNavActive = (raw) => {
const u = String(raw || '').trim()
if (!u || u === '#' || !u.startsWith('/') || u.startsWith('//')) {
return false
}
if (isExternalUrl(u)) {
return false
}
const path = (route.path || '/').split('?')[0] || '/'
/**
* 경로 정규화
* @param {string} s - 경로
* @returns {string}
*/
const norm = (s) => {
let x = s || '/'
if (x.length > 1 && x.endsWith('/')) {
x = x.slice(0, -1)
}
return x || '/'
}
return norm(path) === norm(u)
}
/**
* 리프 `NuxtLink`용 클래스
* @param {string} url - 노드 URL
* @returns {string}
*/
const navLinkClass = (url) => {
const active = isInternalNavActive(url)
const bar = active ? navBarBeforeActive : navBarBeforeInactive
return `sidebar-primary-nav-list__nav-link ${navRowShell} ${bar}`
}
</script>
<template>
@@ -36,12 +100,12 @@ const onBranchClick = (id) => {
>
<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"
class="sidebar-primary-nav-list__branch-toggle group flex w-full max-w-full text-left text-[var(--site-text)]"
:class="`${navRowShell} ${navBarBeforeInactive}`"
:aria-expanded="isExpanded(node.id)"
:aria-label="isExpanded(node.id) ? `${node.label} 하위 메뉴 접기` : `${node.label} 하위 메뉴 펼치기`"
@click="onBranchClick(node.id)"
>
<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
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)]"
@@ -70,17 +134,18 @@ const onBranchClick = (id) => {
>
<NuxtLink
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))]"
:class="navLinkClass(node.url)"
:to="node.url"
:aria-current="isInternalNavActive(node.url) ? 'page' : undefined"
>
<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 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)]"
class="sidebar-primary-nav-list__leaf-static group text-[var(--site-text)]"
:class="`${navRowShell} ${navBarBeforeInactive}`"
>
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--site-muted)]" />
<span class="min-w-0 flex-1 truncate font-medium">{{ node.label }}</span>
<span class="min-w-0 flex-1 truncate font-medium transition-transform duration-200 group-hover:translate-x-[3px]">{{ node.label }}</span>
</span>
</li>
</template>

View File

@@ -1,5 +1,17 @@
# 의사결정 이력
## 2026-05-12 v0.0.98
### 사이드바 푸터 링크 줄바꿈·상단 네비 호버 너비
`footer` 항목이 한 줄 `flex`로만 두면 좁은 사이드바에서 가로로 삐져나간다. `nav``flex-wrap``min-w-0 flex-1`을 주고 링크는 줄 단위로 감싸지게 했다. 상단 네비 행은 링크가 콘텐츠 너비만 차지하면 `site-panel-hover` 배경이 짧게 보이므로 `w-full`로 행 전체와 맞췄다.
## 2026-05-12 v0.0.97
### 상단 네비 장식·현재 페이지 표시
부모 행만 점으로 두면 리프와 시각 언어가 갈린다. `before` 세로바를 통일하고, 내부 활성 경로는 브랜드 악센트로 구분했다.
## 2026-05-12 v0.0.96
### 사이드바 상단 네비 폴더 토글 UX

View File

@@ -37,8 +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` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`) |
| components/site/SidebarPrimaryNavList.vue | 공개 상단 네비 트리: 하위 있으면 행 전체 `button` 토글·SVG chevron 회전·`grid` 높이 애니메이션, `inject`로 펼침·`localStorage` `sori-primary-nav-expanded` |
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+``sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0` |
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`로 패널 호버 배경 폭, `inject`·`localStorage` 펼침 |
| components/site/RightSidebar.vue | 오른쪽 사이드바, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 |

View File

@@ -22,7 +22,7 @@
| Shell | `min-height: 100vh`, `flex` 세로 컬럼 |
| 그리드(데스크톱 `lg+`) | `items-start`, 3열 그리드(`287px / minmax(0,1fr) / 287px`)를 사용하고 열 간 `column-gap`은 두지 않는다(`gap-x-0`). 경계선은 사이드바 보더로만 구분해 이중 패딩처럼 보이는 여백을 방지한다. 긴 본문은 **문서(`html`/`body`) 스크롤**로 처리한다. |
| 그리드(모바일 `lg` 미만) | 단일 세로 흐름: **본문 → 오른쪽 사이드** 순. 왼쪽 사이드는 레이아웃 흐름에서 분리된 고정 슬라이드 패널로 표시 |
| Left Aside | 너비 287px, `sticky top-[57px]`, `h-[calc(100vh-57px)]``max-h` 동일(뷰포트 기준 고정 높이), 내부 상단은 `.site-sidebar-scroll`(스크롤바 숨김), 하단 푸터 `shrink-0`·상단 보더로 스크롤 영역과 구분, 푸터 좌우는 `px-4`~`sm:px-5`로 본문 블록과 유사한 여백 |
| Left Aside | 너비 287px, `sticky top-[57px]`, `h-[calc(100vh-57px)]``max-h` 동일(뷰포트 기준 고정 높이), 내부 상단은 `.site-sidebar-scroll`(스크롤바 숨김), 하단 푸터 `shrink-0`·상단 보더로 스크롤 영역과 구분, 푸터 좌우는 `px-4`~`sm:px-5`로 본문 블록과 유사한 여백. 푸터의 API `footer` 링크 영역은 `flex-wrap`·`min-w-0 flex-1`로 항목이 많을 때 줄바꿈되어 패널 밖으로 넘치지 않는다 |
| Left Aside(모바일) | `fixed` 좌측 패널, 열림 시 `translate-x-0`, 닫힘 시 화면 밖으로 이동. 열린 동안 백드롭과 `html.site-mobile-nav-open`으로 문서 스크롤 잠금 |
| Main | 중앙 열 안에서 `max-width: 720px`·`justify-self: start`, 별도 `overflow-y` 없음. 공개 페이지의 가로 패딩은 레이아웃 그리드(`public-layout__grid`)의 `px-*` 한 번만 사용하고, 본문 섹션의 `px-*`는 두지 않는다. |
| Right Aside | Left와 동일 패턴(스티키·고정 높이·내부 무스크롤바 스크롤·하단 카피라이트) |
@@ -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`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 ·chevron) 클릭**으로 접기/펼친다(별도 chevron 전용 버튼 없음). chevron은 SVG에 `transition-transform`으로 회전하고, 하위 목록은 `grid-template-rows` `0fr``1fr` 전환으로 높이가 자연스럽게 바뀐다. 펼침 행에만 깔리던 배경 하이라이트는 두지 않는다. **하위가 있는 부모**는 토글 전용이므로 공개 화면에서 부모 `url`로 이동하지 않는다(링크는 리프·하위 항목에 둔다). 펼침 상태는 `localStorage``sori-primary-nav-expanded`에 저장된다.
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 세로 데코·chevron) 클릭**으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 **기본 세로 막대(`--site-line`)**, **호버 시에만** 리프와 동일하게 **작은 원형**으로 전환한다. **내부 경로**(`/`로 시작, `//`·`http(s)` 제외)이고 현재 `route.path`와 정규화한 경로가 같으면 장식 색을 **`--site-accent`(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는 `localStorage``sori-primary-nav-expanded`에 저장된다. 상단·리프 링크·부모 버튼 행은 **`w-full`**로 `site-panel-hover` 배경이 가로 전체를 쓴다.
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
### 관리자 인증

View File

@@ -1,5 +1,14 @@
# 업데이트 이력
## v0.0.98
- `SidebarPrimaryNavList`: `navRowShell`**`w-full`** 추가해 `site-panel-hover` 배경이 행 가로 전체를 쓰도록 함.
- `LeftSidebar`: 푸터 API `footer` 링크 **`flex-wrap`**·`nav` **`min-w-0 flex-1`**, 링크·테마 버튼 **`shrink-0`**로 항목이 많을 때 가로 오버플로 방지.
## v0.0.97
- `SidebarPrimaryNavList`: 하위 폴더 부모도 리프와 동일 **세로 막대→호버 원형** `before` 장식. 내부 링크가 현재 경로와 일치하면 장식을 **`--site-accent`**로 표시(`aria-current="page"`).
## v0.0.96
- `SidebarPrimaryNavList`: 하위가 있는 부모는 **행 전체 버튼**으로 토글, chevron **회전 애니메이션**, 하위 영역 **높이 전환 애니메이션**, 펼침 전용 배경 제거·너비·간격 리프와 통일.

View File

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