위저드(1~5단계)는 AppShell 사이드바가 사용자와 [로그아웃]을 들고 있는데, 에디터(6단계)는 전체 화면이라 AppShell 을 안 쓴다. 그래서 편집 화면에 들어가는 순간 **누구로 로그인했는지도, 나가는 방법도 화면에서 사라졌다.** - BuilderPage: 에디터 상단 바 오른쪽에 사용자 · 상호와 [로그아웃] 추가 - stores/auth.userLabel: 이름 → 이메일 → 아이디 순. 구글 계정의 로그인 아이디는 google_<sub> 라 그대로 보이면 안 된다. AppShell 도 같은 규칙을 쓰게 바꿨다 (기존 `name ?? id` 는 이름이 빈 문자열이면 그대로 통과시켰다) 브라우저 확인: 가입 → 로그인 → 사이드바 '김사장 · 달빛스테이', 에디터 상단 바 동일 표시, [로그아웃] 클릭 시 RequireAuth 가 /login 으로 되돌림. tsc·eslint·vite build 통과.
129 lines
4.6 KiB
TypeScript
129 lines
4.6 KiB
TypeScript
import type {ComponentType, ReactNode} from 'react';
|
|
import {Link, NavLink, useLocation} from 'react-router';
|
|
import {LayoutGrid, LogOut, Search, Wand2} from 'lucide-react';
|
|
import {cn} from '@/lib/utils';
|
|
import {userLabel, useAuthStore} from '@/stores/auth';
|
|
|
|
export type NavItem = {
|
|
to: string;
|
|
/** 활성 표시용 경로. 쿼리스트링이 붙은 `to` 로는 pathname 을 비교할 수 없다. */
|
|
match: string;
|
|
label: string;
|
|
icon: ComponentType<{className?: string}>;
|
|
};
|
|
|
|
/**
|
|
* ★ 메뉴를 이 파일에 하드코딩하지 않는다 — 앱마다 다르고, **섞이면 새어 나간다.**
|
|
* 내부 메뉴(`/places`, `/local-content`)를 여기 두면 그 경로 이름이 사장님 번들에
|
|
* 문자열로 남는다(실측: 앱을 가른 뒤에도 dist 에서 `local-content` 가 나왔다).
|
|
* 앱을 가른 이유가 그거라 메뉴도 앱이 들고 온다. 내부 메뉴는 `admin/src/app/router.tsx`.
|
|
*
|
|
* ★ 빌더는 `?new=1` 로 간다. 그냥 `/builder` 로 보내면 저장된 위저드 상태가 복원돼
|
|
* 지난번에 편집하던 가게의 에디터가 뜬다(BuilderPage 주석) — 새로 만들러 누른 사람에게는
|
|
* 남의 화면이다.
|
|
*/
|
|
const OWNER_NAV: NavItem[] = [
|
|
{to: '/builder?new=1', match: '/builder', label: '새 사이트', icon: Wand2},
|
|
];
|
|
|
|
export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?: NavItem[]}) {
|
|
const user = useAuthStore((s) => s.user);
|
|
const signOut = useAuthStore((s) => s.signOut);
|
|
const location = useLocation();
|
|
|
|
return (
|
|
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
|
|
<aside className="hidden w-56 shrink-0 flex-col border-r border-sidebar-border bg-sidebar md:flex">
|
|
<Link to={nav[0]?.to ?? '/'} className="flex items-center gap-2 px-4 py-4">
|
|
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-7 w-auto" />
|
|
</Link>
|
|
|
|
<nav className="flex-1 space-y-0.5 px-2">
|
|
{nav.map(({to, match, label, icon: Icon}) => (
|
|
<NavLink
|
|
key={to}
|
|
to={to}
|
|
className={() =>
|
|
cn(
|
|
'flex items-center gap-2 rounded-md px-2.5 py-2 text-xs font-medium transition-colors',
|
|
location.pathname.startsWith(match)
|
|
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
|
: 'text-sidebar-foreground hover:bg-sidebar-accent/60',
|
|
)
|
|
}
|
|
>
|
|
<Icon className="size-4" />
|
|
<span>{label}</span>
|
|
</NavLink>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="border-t border-sidebar-border p-3">
|
|
<div className="mb-2 truncate text-[11px] text-sidebar-foreground">
|
|
{user ? userLabel(user) : ''}
|
|
{user?.companyName ? ` · ${user.companyName}` : ''}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={signOut}
|
|
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60"
|
|
>
|
|
<LogOut className="size-3.5" />
|
|
<span>로그아웃</span>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
<main className="flex-1 overflow-y-auto">{children}</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PageContainer({
|
|
title,
|
|
description,
|
|
actions,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
description?: string;
|
|
actions?: ReactNode;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="mx-auto w-full max-w-6xl px-5 py-6">
|
|
<header className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<h1 className="text-lg font-semibold tracking-tight">{title}</h1>
|
|
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
|
|
</div>
|
|
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
|
</header>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function EmptyState({
|
|
icon: Icon = LayoutGrid,
|
|
title,
|
|
description,
|
|
action,
|
|
}: {
|
|
icon?: typeof Search;
|
|
title: string;
|
|
description?: string;
|
|
action?: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-border px-6 py-14 text-center">
|
|
<Icon className="size-6 text-muted-foreground" />
|
|
<p className="text-sm font-medium">{title}</p>
|
|
{description && <p className="max-w-md text-xs text-muted-foreground">{description}</p>}
|
|
{action && <div className="mt-2">{action}</div>}
|
|
</div>
|
|
);
|
|
}
|