Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -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개 파일)**:
|
||||
| 파일 | 용도 | 이미지 소스 |
|
||||
|------|------|-------------|
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const [displayMode, setDisplayMode] = useState<DisplayMode>('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 (
|
||||
<div className="flex items-center space-x-0.5 sm:space-x-1">
|
||||
{visible.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
if (!Icon) return null;
|
||||
return (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleClick(item)}
|
||||
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] p-0 rounded-lg min-[320px]:rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center"
|
||||
title={item.label}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{overflow.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] p-0 rounded-lg min-[320px]:rounded-xl bg-slate-600 hover:bg-slate-700 text-white flex items-center justify-center"
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={`p-0 rounded-lg min-[320px]:rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center ${
|
||||
isMobile
|
||||
? 'min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px]'
|
||||
: 'w-10 h-10'
|
||||
}`}
|
||||
title="즐겨찾기"
|
||||
>
|
||||
<Star className="h-4 w-4 fill-white" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{favorites.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{overflow.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// 데스크톱
|
||||
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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-10 h-10 p-0 rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center"
|
||||
title="즐겨찾기"
|
||||
>
|
||||
<Star className="h-4 w-4 fill-white" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{favorites.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div ref={containerRef} className="flex items-center space-x-2">
|
||||
{visibleItems.map((item) => {
|
||||
<div className="flex items-center gap-2">
|
||||
{favorites.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
if (!Icon) return null;
|
||||
|
||||
if (displayMode === 'full') {
|
||||
return (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleClick(item)}
|
||||
className="rounded-xl bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 flex items-center gap-2 transition-all duration-200"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="hidden xl:inline">{item.label}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// icon-only 또는 overflow의 visible 부분
|
||||
return (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -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"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -174,35 +149,6 @@ export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{overflowItems.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-xl p-2 flex items-center justify-center"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{overflowItems.map((item) => {
|
||||
const Icon = getIcon(item.iconName);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onClick={() => handleClick(item)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -699,7 +699,7 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
||||
return (
|
||||
<div className="flex flex-col bg-background min-h-screen">
|
||||
{/* 모바일 헤더 - sam-design 스타일 */}
|
||||
<header className="clean-glass sticky top-0 z-40 px-1.5 py-1.5 m-1.5 min-[320px]:px-2 min-[320px]:py-2 min-[320px]:m-2 sm:px-4 sm:py-4 sm:m-3 rounded-2xl clean-shadow">
|
||||
<header className="sticky top-0 z-40 px-1.5 py-1.5 m-1.5 min-[320px]:px-2 min-[320px]:py-2 min-[320px]:m-2 sm:px-4 sm:py-4 sm:m-3 rounded-2xl clean-shadow bg-background border border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 좌측 영역: 햄버거 메뉴 + (대시보드일 때는 로고, 다른 페이지일 때는 이전/홈 버튼) */}
|
||||
<div className="flex items-center space-x-1 min-[320px]:space-x-2">
|
||||
|
||||
@@ -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<FavoritesState>()(
|
||||
|
||||
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';
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user