feat: 모바일 반응형 UI (drawer, 아코디언, 범례) #22
@ -255,8 +255,6 @@ export function DashboardPage() {
|
|||||||
transit={transitCount}
|
transit={transitCount}
|
||||||
pairLinks={pairLinksAll.length}
|
pairLinks={pairLinksAll.length}
|
||||||
alarms={alarms.length}
|
alarms={alarms.length}
|
||||||
pollingStatus={snapshot.status}
|
|
||||||
lastFetchMinutes={snapshot.lastFetchMinutes}
|
|
||||||
clock={clock}
|
clock={clock}
|
||||||
adminMode={adminMode}
|
adminMode={adminMode}
|
||||||
onLogoClick={onLogoClick}
|
onLogoClick={onLogoClick}
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
type Props = {
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
total: number;
|
total: number;
|
||||||
fishing: number;
|
fishing: number;
|
||||||
transit: number;
|
transit: number;
|
||||||
pairLinks: number;
|
pairLinks: number;
|
||||||
alarms: number;
|
alarms: number;
|
||||||
pollingStatus: "idle" | "loading" | "ready" | "error";
|
|
||||||
lastFetchMinutes: number | null;
|
|
||||||
clock: string;
|
clock: string;
|
||||||
adminMode?: boolean;
|
adminMode?: boolean;
|
||||||
onLogoClick?: () => void;
|
onLogoClick?: () => void;
|
||||||
@ -15,90 +15,123 @@ type Props = {
|
|||||||
onToggleTheme?: () => void;
|
onToggleTheme?: () => void;
|
||||||
isSidebarOpen?: boolean;
|
isSidebarOpen?: boolean;
|
||||||
onMenuToggle?: () => void;
|
onMenuToggle?: () => void;
|
||||||
};
|
}
|
||||||
|
|
||||||
export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStatus, lastFetchMinutes, clock, adminMode, onLogoClick, userName, onLogout, theme, onToggleTheme, isSidebarOpen, onMenuToggle }: Props) {
|
function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick<Props, "total" | "fishing" | "transit" | "pairLinks" | "alarms">) {
|
||||||
const statusColor =
|
|
||||||
pollingStatus === "ready" ? "#22C55E" : pollingStatus === "loading" ? "#F59E0B" : pollingStatus === "error" ? "#EF4444" : "var(--muted)";
|
|
||||||
return (
|
return (
|
||||||
<div className="col-span-full flex items-center gap-2.5 border-b border-wing-border bg-wing-surface px-3.5 z-[1000]">
|
<>
|
||||||
{onMenuToggle && (
|
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||||
<button
|
<b className="text-xs text-wing-text">{total}</b>척
|
||||||
className="flex cursor-pointer items-center justify-center rounded border border-wing-border bg-transparent p-1 text-wing-muted transition-colors hover:border-wing-accent hover:text-wing-text md:hidden"
|
|
||||||
onClick={onMenuToggle}
|
|
||||||
aria-label={isSidebarOpen ? "메뉴 닫기" : "메뉴 열기"}
|
|
||||||
>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
||||||
{isSidebarOpen ? (
|
|
||||||
<>
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<line x1="3" y1="6" x2="21" y2="6" />
|
|
||||||
<line x1="3" y1="12" x2="21" y2="12" />
|
|
||||||
<line x1="3" y1="18" x2="21" y2="18" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1.5 whitespace-nowrap text-sm font-extrabold"
|
|
||||||
onClick={onLogoClick}
|
|
||||||
style={{ cursor: onLogoClick ? "pointer" : undefined }}
|
|
||||||
title={adminMode ? "ADMIN" : undefined}
|
|
||||||
>
|
|
||||||
<span className="text-wing-accent">WING</span>
|
|
||||||
<span className="hidden sm:inline">조업감시·선단연관</span>
|
|
||||||
{adminMode ? <span className="text-[10px] text-wing-warning">(ADMIN)</span> : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex flex-wrap justify-end gap-3.5">
|
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||||
<div className="hidden items-center gap-1 text-[10px] text-wing-muted sm:flex">
|
조업 <b style={{ color: "#22C55E" }}>{fishing}</b>
|
||||||
POLL{" "}
|
|
||||||
<b style={{ color: statusColor }}>
|
|
||||||
{pollingStatus.toUpperCase()}
|
|
||||||
{lastFetchMinutes ? `(${lastFetchMinutes}m)` : ""}
|
|
||||||
</b>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
|
||||||
<b className="text-xs text-wing-text">{total}</b>척
|
|
||||||
</div>
|
|
||||||
<div className="hidden items-center gap-1 text-[10px] text-wing-muted lg:flex">
|
|
||||||
조업 <b style={{ color: "#22C55E" }}>{fishing}</b>
|
|
||||||
</div>
|
|
||||||
<div className="hidden items-center gap-1 text-[10px] text-wing-muted lg:flex">
|
|
||||||
항해 <b style={{ color: "#3B82F6" }}>{transit}</b>
|
|
||||||
</div>
|
|
||||||
<div className="hidden items-center gap-1 text-[10px] text-wing-muted md:flex">
|
|
||||||
쌍연결 <b style={{ color: "#F59E0B" }}>{pairLinks}</b>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
|
||||||
경고 <b style={{ color: "#EF4444" }}>{alarms}</b>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-2.5 whitespace-nowrap text-[10px] font-semibold text-wing-accent">{clock}</div>
|
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||||
{onToggleTheme && (
|
항해 <b style={{ color: "#3B82F6" }}>{transit}</b>
|
||||||
<button
|
</div>
|
||||||
className="ml-1 cursor-pointer rounded border border-wing-border bg-transparent px-1.5 py-0.5 text-[9px] text-wing-muted transition-all duration-150 hover:border-wing-accent hover:text-wing-text"
|
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||||
onClick={onToggleTheme}
|
쌍연결 <b style={{ color: "#F59E0B" }}>{pairLinks}</b>
|
||||||
title={theme === "dark" ? "라이트 모드로 전환" : "다크 모드로 전환"}
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||||
|
경고 <b style={{ color: "#EF4444" }}>{alarms}</b>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, adminMode, onLogoClick, userName, onLogout, theme, onToggleTheme, isSidebarOpen, onMenuToggle }: Props) {
|
||||||
|
const [isStatsOpen, setIsStatsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col-span-full relative z-[1000]">
|
||||||
|
<div className="flex h-[44px] items-center gap-2.5 border-b border-wing-border bg-wing-surface px-3.5">
|
||||||
|
{/* 햄버거 메뉴 (모바일) */}
|
||||||
|
{onMenuToggle && (
|
||||||
|
<button
|
||||||
|
className="flex cursor-pointer items-center justify-center rounded border border-wing-border bg-transparent p-1 text-wing-muted transition-colors hover:border-wing-accent hover:text-wing-text md:hidden"
|
||||||
|
onClick={onMenuToggle}
|
||||||
|
aria-label={isSidebarOpen ? "메뉴 닫기" : "메뉴 열기"}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
{isSidebarOpen ? (
|
||||||
|
<>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 로고 */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 whitespace-nowrap text-sm font-extrabold"
|
||||||
|
onClick={onLogoClick}
|
||||||
|
style={{ cursor: onLogoClick ? "pointer" : undefined }}
|
||||||
|
title={adminMode ? "ADMIN" : undefined}
|
||||||
>
|
>
|
||||||
{theme === "dark" ? "Light" : "Dark"}
|
<span className="text-wing-accent">WING</span>
|
||||||
</button>
|
<span className="hidden sm:inline">조업감시·선단연관</span>
|
||||||
)}
|
{adminMode ? <span className="text-[10px] text-wing-warning">(ADMIN)</span> : null}
|
||||||
{userName && (
|
</div>
|
||||||
<div className="ml-2.5 hidden shrink-0 items-center gap-2 sm:flex">
|
|
||||||
<span className="whitespace-nowrap text-[10px] font-medium text-wing-text">{userName}</span>
|
{/* 통계 영역 */}
|
||||||
{onLogout && (
|
<div className="ml-auto flex items-center gap-3.5">
|
||||||
|
{/* 데스크톱: 인라인 통계 */}
|
||||||
|
<div className="hidden items-center gap-3.5 md:flex">
|
||||||
|
<StatChips total={total} fishing={fishing} transit={transit} pairLinks={pairLinks} alarms={alarms} />
|
||||||
|
</div>
|
||||||
|
{/* 모바일: 통계 펼치기 버튼 */}
|
||||||
|
<button
|
||||||
|
className="flex cursor-pointer items-center justify-center rounded border border-wing-border bg-transparent p-1 text-wing-muted transition-colors hover:border-wing-accent hover:text-wing-text md:hidden"
|
||||||
|
onClick={() => setIsStatsOpen((v) => !v)}
|
||||||
|
aria-label={isStatsOpen ? "통계 닫기" : "통계 열기"}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 3v18h18" />
|
||||||
|
<path d="M7 16l4-8 4 4 4-8" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 항상 표시: 시계 + 테마 + 사용자 */}
|
||||||
|
<div className="flex shrink-0 items-center gap-2.5 md:ml-2.5">
|
||||||
|
<span className="whitespace-nowrap text-[10px] font-semibold text-wing-accent">{clock}</span>
|
||||||
|
{onToggleTheme && (
|
||||||
<button
|
<button
|
||||||
className="cursor-pointer whitespace-nowrap rounded border border-wing-border bg-transparent px-1.5 py-0.5 text-[9px] text-wing-muted transition-all duration-150 hover:border-wing-accent hover:text-wing-text"
|
className="cursor-pointer rounded border border-wing-border bg-transparent px-1.5 py-0.5 text-[9px] text-wing-muted transition-all duration-150 hover:border-wing-accent hover:text-wing-text"
|
||||||
onClick={onLogout}
|
onClick={onToggleTheme}
|
||||||
|
title={theme === "dark" ? "라이트 모드로 전환" : "다크 모드로 전환"}
|
||||||
>
|
>
|
||||||
로그아웃
|
{theme === "dark" ? "Light" : "Dark"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{userName && (
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="hidden whitespace-nowrap text-[10px] font-medium text-wing-text sm:inline">{userName}</span>
|
||||||
|
{onLogout && (
|
||||||
|
<button
|
||||||
|
className="cursor-pointer whitespace-nowrap rounded border border-wing-border bg-transparent px-1.5 py-0.5 text-[9px] text-wing-muted transition-all duration-150 hover:border-wing-accent hover:text-wing-text"
|
||||||
|
onClick={onLogout}
|
||||||
|
>
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모바일 통계 바 (펼침 시) */}
|
||||||
|
{isStatsOpen && (
|
||||||
|
<div className="absolute left-0 right-0 top-full flex items-center justify-center gap-4 border-b border-wing-border bg-wing-surface px-3.5 py-2 shadow-md md:hidden">
|
||||||
|
<StatChips total={total} fishing={fishing} transit={transit} pairLinks={pairLinks} alarms={alarms} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user