feat: 사이드바 접기 + 모바일 드로어 + 검색 아이콘 제거

- 데스크톱: 사이드바 접기 토글(아이콘 레일, 상태 localStorage 보존)
  접힘 시 브랜드/네비/사용자가 아이콘만 표시, 메뉴는 레일 우측으로 펼침
- 모바일(≤48rem): 사이드바를 오버레이 드로어로 전환, 툴바 햄버거로 열고
  배경/화면 전환 시 닫힘(접힘 상태는 모바일에서 무시)
- 전역 도구에서 미사용 검색 아이콘 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 21:33:39 +09:00
parent f60f705f56
commit f4a1253526
+212 -26
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 앱 공통 셸 — 좌측 사이드바(브랜드/네비/사용자) + 우측 본문 컬럼(상단 툴바 + 슬롯)
// 모든 인증 이후 화면이 이 레이아웃을 공유한다.
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
@@ -17,6 +17,16 @@ const authStore = useAuthStore()
// AI 채팅 패널 — 모든 화면 툴바에서 열 수 있다
const agentOpen = ref(false)
// 사이드바 접기(데스크톱) — localStorage 에 상태 보존
const collapsed = ref(localStorage.getItem('relay.sidebar.collapsed') === '1')
function toggleCollapse() {
collapsed.value = !collapsed.value
localStorage.setItem('relay.sidebar.collapsed', collapsed.value ? '1' : '0')
}
// 모바일 사이드바 드로어 — 화면 전환 시 자동 닫힘
const mobileOpen = ref(false)
// 현재 경로 기준으로 사이드바 네비 활성 항목을 판별
const activeNav = computed<'tasks' | 'projects'>(() =>
route.path.startsWith('/projects') ? 'projects' : 'tasks',
@@ -40,6 +50,14 @@ const {
const notiOpen = ref(false)
// 화면 전환 시 모바일 드로어 닫기
watch(
() => route.path,
() => {
mobileOpen.value = false
},
)
// 알림 항목 클릭 — 읽음 처리 후 대상 화면으로 이동
async function onNotiClick(v: NotificationView) {
notiOpen.value = false
@@ -63,12 +81,23 @@ async function onLogout() {
</script>
<template>
<div class="shell">
<div
class="shell"
:class="{ collapsed, 'mobile-open': mobileOpen }"
>
<!-- 모바일 드로어 배경 -->
<div
v-if="mobileOpen"
class="sidebar-backdrop"
@click="mobileOpen = false"
/>
<!-- 좌측 사이드바 -->
<aside class="sidebar">
<RouterLink
to="/projects"
class="brand"
title="Relay"
>
<div class="logo">
R
@@ -80,6 +109,7 @@ async function onLogout() {
<RouterLink
to="/tasks"
class="nav-item"
title="내 업무"
:class="{ active: activeNav === 'tasks' }"
>
<svg
@@ -105,6 +135,7 @@ async function onLogout() {
<RouterLink
to="/projects"
class="nav-item"
title="프로젝트"
:class="{ active: activeNav === 'projects' }"
>
<svg
@@ -148,11 +179,29 @@ async function onLogout() {
</RouterLink>
</nav>
<!-- 하단 사용자 프로필 + 메뉴(위로 펼침) -->
<!-- 하단: 접기 토글 + 사용자 프로필 + 메뉴(위로 펼침) -->
<div class="side-bottom">
<button
class="collapse-btn"
type="button"
:title="collapsed ? '사이드바 펼치기' : '사이드바 접기'"
:aria-label="collapsed ? '사이드바 펼치기' : '사이드바 접기'"
@click="toggleCollapse"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M15 18l-6-6 6-6" /></svg>
<span>사이드바 접기</span>
</button>
<button
class="side-me"
type="button"
:title="me?.name ?? '사용자'"
:aria-label="`${me?.name ?? '사용자'} 메뉴`"
aria-haspopup="menu"
:aria-expanded="menuOpen"
@@ -193,6 +242,27 @@ async function onLogout() {
<!-- 우측 본문 컬럼 -->
<div class="main">
<header class="topbar">
<!-- 모바일 사이드바 열기(데스크톱에서는 숨김) -->
<button
class="icon-btn hamburger"
type="button"
title="메뉴"
aria-label="메뉴 열기"
@click="mobileOpen = true"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
</button>
<!-- 화면별 추가 액션을 끼워 넣는 슬롯 -->
<slot name="actions" />
@@ -220,28 +290,6 @@ async function onLogout() {
</svg>
</button>
<button
class="icon-btn"
type="button"
title="검색"
aria-label="검색"
>
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle
cx="11"
cy="11"
r="7"
/>
<path d="m21 21-4-4" />
</svg>
</button>
<div class="noti-wrap">
<button
class="icon-btn noti-btn"
@@ -378,6 +426,54 @@ async function onLogout() {
flex-direction: column;
padding: 0.875rem 0;
z-index: 50;
transition: width 0.18s ease;
}
/* 접힘(아이콘 레일) */
.shell.collapsed .sidebar {
width: 3.75rem;
}
.shell.collapsed .brand {
padding-left: 0;
padding-right: 0;
justify-content: center;
}
.shell.collapsed .brand-name {
display: none;
}
.shell.collapsed .sidenav {
padding: 0 0.5rem;
}
.shell.collapsed .nav-item {
justify-content: center;
padding-left: 0;
padding-right: 0;
}
.shell.collapsed .nav-item span {
display: none;
}
.shell.collapsed .side-me {
justify-content: center;
padding-left: 0;
padding-right: 0;
}
.shell.collapsed .side-me-text {
display: none;
}
.shell.collapsed .collapse-btn {
justify-content: center;
padding-left: 0;
padding-right: 0;
}
.shell.collapsed .collapse-btn span {
display: none;
}
.shell.collapsed .collapse-btn svg {
transform: rotate(180deg);
}
/* 접힘 상태에서 사용자 메뉴는 레일 오른쪽으로 펼침 */
.shell.collapsed .side-menu {
left: 0.5rem;
right: auto;
}
.brand {
@@ -438,13 +534,41 @@ async function onLogout() {
font-weight: 600;
}
/* 사이드바 하단 사용자 프로필 */
/* 사이드바 하단: 접기 토글 + 사용자 프로필 */
.side-bottom {
margin-top: auto;
position: relative;
padding: 0.625rem;
border-top: 1px solid var(--border);
}
.collapse-btn {
display: flex;
align-items: center;
gap: 0.6875rem;
width: 100%;
padding: 0.5rem 0.75rem;
margin-bottom: 0.25rem;
border: none;
background: transparent;
border-radius: var(--radius);
color: var(--text-3);
cursor: pointer;
font-family: inherit;
font-size: 0.781rem;
font-weight: 500;
line-height: 1;
white-space: nowrap;
}
.collapse-btn:hover {
background: #f1f2f4;
color: var(--text);
}
.collapse-btn svg {
width: 1.125rem;
height: 1.125rem;
flex-shrink: 0;
transition: transform 0.18s ease;
}
.side-me {
display: flex;
align-items: center;
@@ -735,4 +859,66 @@ async function onLogout() {
inset: 0;
z-index: 55;
}
/* 햄버거(모바일 전용) / 드로어 배경 — 데스크톱에서는 숨김 */
.hamburger {
display: none;
}
.sidebar-backdrop {
display: none;
}
/* ===== 모바일(≤48rem) — 사이드바를 오버레이 드로어로 ===== */
@media (max-width: 48rem) {
.hamburger {
display: grid;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
width: 15rem;
transform: translateX(-100%);
transition: transform 0.2s ease;
box-shadow: 0 8px 30px rgba(20, 24, 33, 0.18);
}
/* 모바일에서는 접힘 상태를 무시(항상 전체 폭 드로어) */
.shell.collapsed .sidebar {
width: 15rem;
}
.shell.mobile-open .sidebar {
transform: translateX(0);
}
/* 접기 토글은 모바일에서 불필요 */
.collapse-btn {
display: none;
}
/* 접힘 축소 규칙 무효화(드로어는 항상 펼친 모양) */
.shell.collapsed .brand,
.shell.collapsed .nav-item,
.shell.collapsed .side-me,
.shell.collapsed .collapse-btn {
justify-content: flex-start;
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.shell.collapsed .brand-name,
.shell.collapsed .nav-item span,
.shell.collapsed .side-me-text {
display: inline;
}
.shell.collapsed .side-me-text {
display: flex;
}
.shell.collapsed .brand {
padding-left: 1.125rem;
}
.sidebar-backdrop {
display: block;
position: fixed;
inset: 0;
background: rgba(20, 24, 33, 0.45);
z-index: 49;
}
}
</style>