diff --git a/claudedocs/_index.md b/claudedocs/_index.md index 2a52494a..814b866e 100644 --- a/claudedocs/_index.md +++ b/claudedocs/_index.md @@ -25,6 +25,23 @@ 4. **blob URL 비호환** — 업로드 미리보기(blob:)는 `next/image`가 지원 안 함 5. **설정 부담 > 이점** — `remotePatterns` 설정 + 백엔드 도메인 관리 비용이 실질 이점보다 큼 +### 모바일 헤더 `backdrop-filter` 깜빡임 수정 (2026-02-11) + +**현상**: 모바일(Safari/Chrome)에서 sticky 헤더가 스크롤 시 투명↔불투명 깜빡임 발생. PC 브라우저 축소로는 재현 불가, 실제 모바일 기기에서만 발생. + +**원인 2가지**: +1. `globals.css`에 `* { transition: all 0.2s }` — 전체 요소의 모든 CSS 속성에 전역 transition. 모바일 스크롤 리페인트 시 background/opacity가 매번 애니메이션 +2. 모바일 헤더의 `clean-glass` 클래스: `backdrop-filter: blur(8px)` + `background: rgba(255,255,255, 0.95)` 조합이 모바일 sticky 요소에서 GPU 컴포지팅 충돌 + +**수정**: +- `globals.css`: `*` 전역 transition → `button, a, input, select, textarea, [role]` 인터랙티브 요소만, `transition: all` → `color, background-color, border-color, box-shadow` 속성만 +- 모바일 헤더: `clean-glass` (반투명+blur) → `bg-background border border-border` (불투명 배경) + +**교훈**: +- `transition: all`은 절대 `*`에 걸지 않기. 모바일 성능 저하 + 의도치 않은 애니메이션 발생 +- `backdrop-filter: blur()` + `sticky` 조합은 모바일 브라우저 고질적 리페인트 버그. 모바일 헤더는 불투명 배경 사용 +- 0.95 투명도는 육안 구분 불가 → 불투명 처리해도 시각적 차이 없음 + **사용처 (9개 파일)**: | 파일 | 용도 | 이미지 소스 | |------|------|-------------| diff --git a/src/app/globals.css b/src/app/globals.css index 35397ac4..ccb93f4e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -296,9 +296,12 @@ box-shadow: var(--clean-shadow-xl); } - /* Smooth transitions for all interactive elements */ - * { - transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94); + /* Smooth transitions for interactive elements only */ + button, a, input, select, textarea, + [role="button"], [role="tab"], [role="menuitem"] { + transition-property: color, background-color, border-color, box-shadow; + transition-duration: 0.2s; + transition-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94); } /* Bell ringing animation for notifications */ diff --git a/src/components/layout/HeaderFavoritesBar.tsx b/src/components/layout/HeaderFavoritesBar.tsx index 2ccaa400..669002f5 100644 --- a/src/components/layout/HeaderFavoritesBar.tsx +++ b/src/components/layout/HeaderFavoritesBar.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { MoreHorizontal } from 'lucide-react'; +import { Star } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Tooltip, @@ -20,8 +20,6 @@ import { useFavoritesStore } from '@/stores/favoritesStore'; import { iconMap } from '@/lib/utils/menuTransform'; import type { FavoriteItem } from '@/stores/favoritesStore'; -type DisplayMode = 'full' | 'icon-only' | 'overflow'; - interface HeaderFavoritesBarProps { isMobile: boolean; } @@ -29,35 +27,18 @@ interface HeaderFavoritesBarProps { export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps) { const router = useRouter(); const { favorites } = useFavoritesStore(); - const containerRef = useRef(null); - const [displayMode, setDisplayMode] = useState('full'); + const [isTablet, setIsTablet] = useState(false); - // 반응형: ResizeObserver로 컨테이너 너비 감지 + // 태블릿 감지 (768~1024) useEffect(() => { - if (isMobile) { - setDisplayMode('overflow'); - return; - } - - const container = containerRef.current; - if (!container) return; - - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - const width = entry.contentRect.width; - if (width < 300 || favorites.length > 4) { - setDisplayMode('overflow'); - } else if (width < 600) { - setDisplayMode('icon-only'); - } else { - setDisplayMode('full'); - } - } - }); - - observer.observe(container); - return () => observer.disconnect(); - }, [isMobile, favorites.length]); + const check = () => { + const w = window.innerWidth; + setIsTablet(w >= 768 && w < 1024); + }; + check(); + window.addEventListener('resize', check); + return () => window.removeEventListener('resize', check); + }, []); const handleClick = useCallback( (item: FavoriteItem) => { @@ -69,93 +50,87 @@ export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps if (favorites.length === 0) return null; const getIcon = (iconName: string) => { - const Icon = iconMap[iconName]; - return Icon || null; + return iconMap[iconName] || null; }; - // 모바일: 최대 2개 아이콘 + 나머지 드롭다운 - if (isMobile) { - const visible = favorites.slice(0, 2); - const overflow = favorites.slice(2); - + // 모바일 & 태블릿: 별 아이콘 드롭다운 + if (isMobile || isTablet) { return ( -
- {visible.map((item) => { - const Icon = getIcon(item.iconName); - if (!Icon) return null; - return ( - - ); - })} - {overflow.length > 0 && ( - - - + + + {favorites.map((item) => { + const Icon = getIcon(item.iconName); + return ( + handleClick(item)} + className="flex items-center gap-2 cursor-pointer" > - - - - - {overflow.map((item) => { - const Icon = getIcon(item.iconName); - return ( - handleClick(item)} - className="flex items-center gap-2 cursor-pointer" - > - {Icon && } - {item.label} - - ); - })} - - - )} -
+ {Icon && } + {item.label} + + ); + })} + + ); } - // 데스크톱 - const visibleCount = displayMode === 'overflow' ? 3 : favorites.length; - const visibleItems = favorites.slice(0, visibleCount); - const overflowItems = favorites.slice(visibleCount); + // 데스크톱: 8개 이하 → 아이콘 버튼, 9개 이상 → 별 드롭다운 + const DESKTOP_ICON_LIMIT = 8; + + if (favorites.length > DESKTOP_ICON_LIMIT) { + return ( + + + + + + {favorites.map((item) => { + const Icon = getIcon(item.iconName); + return ( + handleClick(item)} + className="flex items-center gap-2 cursor-pointer" + > + {Icon && } + {item.label} + + ); + })} + + + ); + } return ( -
- {visibleItems.map((item) => { +
+ {favorites.map((item) => { const Icon = getIcon(item.iconName); if (!Icon) return null; - - if (displayMode === 'full') { - return ( - - ); - } - - // icon-only 또는 overflow의 visible 부분 return ( @@ -163,7 +138,7 @@ export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps variant="default" size="sm" onClick={() => handleClick(item)} - className="rounded-xl bg-blue-600 hover:bg-blue-700 text-white p-2 flex items-center justify-center transition-all duration-200" + className="rounded-xl bg-blue-600 hover:bg-blue-700 text-white w-10 h-10 p-0 flex items-center justify-center transition-all duration-200" > @@ -174,35 +149,6 @@ export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps ); })} - - {overflowItems.length > 0 && ( - - - - - - {overflowItems.map((item) => { - const Icon = getIcon(item.iconName); - return ( - handleClick(item)} - className="flex items-center gap-2 cursor-pointer" - > - {Icon && } - {item.label} - - ); - })} - - - )}
); diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 84e8ce7a..ecf9a89d 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,9 +1,10 @@ import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle, Star } from 'lucide-react'; import type { MenuItem } from '@/stores/menuStore'; import { useEffect, useRef, useCallback } from 'react'; -import { useFavoritesStore } from '@/stores/favoritesStore'; +import { useFavoritesStore, MAX_FAVORITES } from '@/stores/favoritesStore'; import { getIconName } from '@/lib/utils/menuTransform'; import type { FavoriteItem } from '@/stores/favoritesStore'; +import { toast } from 'sonner'; interface SidebarProps { menuItems: MenuItem[]; @@ -64,7 +65,10 @@ function MenuItemComponent({ path: item.path, addedAt: Date.now(), }; - toggleFavorite(favItem); + const result = toggleFavorite(favItem); + if (result === 'max_reached') { + toast.warning(`즐겨찾기는 최대 ${MAX_FAVORITES}개까지 등록할 수 있습니다.`); + } }, [item, toggleFavorite]); const handleClick = () => { diff --git a/src/layouts/AuthenticatedLayout.tsx b/src/layouts/AuthenticatedLayout.tsx index dc1561fc..1e1ff007 100644 --- a/src/layouts/AuthenticatedLayout.tsx +++ b/src/layouts/AuthenticatedLayout.tsx @@ -699,7 +699,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro return (
{/* 모바일 헤더 - sam-design 스타일 */} -
+
{/* 좌측 영역: 햄버거 메뉴 + (대시보드일 때는 로고, 다른 페이지일 때는 이전/홈 버튼) */}
diff --git a/src/stores/favoritesStore.ts b/src/stores/favoritesStore.ts index ce8ef6be..a998c871 100644 --- a/src/stores/favoritesStore.ts +++ b/src/stores/favoritesStore.ts @@ -10,7 +10,7 @@ export interface FavoriteItem { addedAt: number; } -const MAX_FAVORITES = 8; +export const MAX_FAVORITES = 10; function getUserId(): string { if (typeof window === 'undefined') return 'default'; @@ -26,7 +26,7 @@ function getStorageKey(): string { interface FavoritesState { favorites: FavoriteItem[]; - toggleFavorite: (item: FavoriteItem) => void; + toggleFavorite: (item: FavoriteItem) => 'added' | 'removed' | 'max_reached'; isFavorite: (id: string) => boolean; setFavorites: (items: FavoriteItem[]) => void; initializeIfEmpty: (defaults: FavoriteItem[]) => void; @@ -43,9 +43,11 @@ export const useFavoritesStore = create()( if (exists) { set({ favorites: favorites.filter((f) => f.id !== item.id) }); + return 'removed'; } else { - if (favorites.length >= MAX_FAVORITES) return; + if (favorites.length >= MAX_FAVORITES) return 'max_reached'; set({ favorites: [...favorites, { ...item, addedAt: Date.now() }] }); + return 'added'; } },