Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 | 30x 30x 30x 30x 30x 30x 30x 30x 30x 30x 30x 30x 30x 3x 3x 30x 30x 30x 25x 30x 25x 2x 30x 1x 30x | import { Link } from "@tanstack/react-router";
import { Bell } from "lucide-react";
import { type ReactNode, useCallback, useMemo, useRef, useState } from "react";
import { useRouterState } from "@tanstack/react-router";
import { useUnreadCount } from "../../hooks/queries/useNotificationQueries";
import { useSocialDrafts } from "../../hooks/queries/useSocialDraftQueries";
import { useIsMobile } from "../../hooks/useIsMobile";
import { useAuth } from "../../lib/auth-context";
import { MARKETPLACE_URL } from "../../lib/env";
import { usePro } from "../../lib/pro-context";
import { FeedbackButton } from "../feedback/FeedbackButton";
import { ErrorBoundary } from "../ui/error-boundary";
import { ThemeSwitcher } from "../ui/theme-switcher";
import { BottomNav } from "./bottom-nav";
import {
type NavBadges,
PRO_NAV,
NAV_STORAGE_KEYS,
resolveNav,
} from "./nav-config";
import { NavRail } from "./nav-rail";
import { ScrollHideHeader } from "./scroll-hide-header";
import { SidebarPrompts } from "./sidebar-prompts";
import { TopBar } from "./top-bar";
type DashboardLayoutProps = {
children: ReactNode;
};
/** `Owner · Live` — the role, then whether the public listing is up. */
function identityMeta(
proRole: string | null,
proStatus: string | undefined,
): string {
const role = proRole
? proRole.charAt(0).toUpperCase() + proRole.slice(1)
: "Member";
return `${role} · ${proStatus === "published" ? "Live" : "Draft"}`;
}
export function DashboardLayout({ children }: DashboardLayoutProps) {
const [navOpen, setNavOpen] = useState(false);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const mainRef = useRef<HTMLElement>(null);
const { signOut, user, isAdmin, hasProAccess } = useAuth();
const { proRole, pro, proId, isLoading: isProLoading } = usePro();
const { data: unreadCount = 0 } = useUnreadCount();
const { data: socialDrafts } = useSocialDrafts(proId ?? null);
const currentPath = useRouterState().location.pathname;
const isMobile = useIsMobile();
const openNav = useCallback(() => setNavOpen(true), []);
const closeNav = useCallback(() => {
setNavOpen(false);
menuButtonRef.current?.focus();
}, []);
// #425: deep-link to the pro's OWN listing, not the marketplace homepage —
// the use case is "let me check how MY profile looks". Only once a
// super-admin has published the pro and the pro has a slug; before that
// there is no public URL to link to.
const isProPublished = pro?.status === "published";
const marketplaceProfileUrl =
!isProLoading && isProPublished && pro?.slug
? `${MARKETPLACE_URL}/pros/${pro.slug}`
: null;
const entries = useMemo(
() =>
resolveNav(PRO_NAV, {
proRole,
isAdmin,
hasProAccess,
hrefs: { marketplaceProfile: marketplaceProfileUrl },
}),
[proRole, isAdmin, hasProAccess, marketplaceProfileUrl],
);
// Only the counts that have a source today. `today` and `leads` are
// declared in the config and deliberately unsupplied: inventing a query
// for them here would put two more requests on every page load for a
// screen that phase 4 builds.
const badges: NavBadges = useMemo(
() => ({
notifications: unreadCount,
studio:
(socialDrafts?.pendingCount ?? 0) +
(socialDrafts?.drafts.filter((draft) => draft.status === "ready")
.length ?? 0),
}),
[unreadCount, socialDrafts],
);
const openFeedback = useCallback(() => {
window.dispatchEvent(new CustomEvent("open-feedback"));
}, []);
return (
<div className="flex h-screen flex-col overflow-hidden bg-background-base">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:rounded-md focus:bg-background-elevated focus:p-4 focus:text-foreground-default focus:shadow-lg"
>
Skip to main content
</a>
{/* The 48px bar. It hides on scroll only on a phone — on a desktop
there is nothing to reclaim, and nothing in it is load-bearing. */}
<ScrollHideHeader
scrollRef={mainRef}
enabled={isMobile}
className="z-30 flex-none"
>
<TopBar
onOpenNav={openNav}
navOpen={navOpen}
menuButtonRef={menuButtonRef}
// Phones navigate from the bottom nav, so the hamburger is a
// tablet affordance only.
menuClassName="hidden sm:flex lg:hidden"
>
<Link
to="/notifications"
className="relative flex size-8 items-center justify-center text-foreground-muted transition-colors hover:text-foreground-default pointer-coarse:size-11"
>
<Bell className="size-5" aria-hidden="true" />
<span className="sr-only">Notifications</span>
{unreadCount > 0 && (
<span className="absolute top-0 right-0 text-xs font-semibold tabular-nums text-foreground-default">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</Link>
<ThemeSwitcher variant="icon" size="sm" />
</TopBar>
</ScrollHideHeader>
<div className="flex min-h-0 flex-1">
{navOpen && (
<button
type="button"
aria-label="Close sidebar overlay"
className="fixed inset-0 z-40 cursor-default bg-overlay-light lg:hidden"
onClick={closeNav}
/>
)}
<NavRail
entries={entries}
currentPath={currentPath}
badges={badges}
storageKey={NAV_STORAGE_KEYS.pro}
ariaLabel="Main navigation"
identity={{
name: user?.name ?? "",
meta: identityMeta(proRole, pro?.status),
}}
onSignOut={signOut}
onAction={openFeedback}
isOpen={navOpen}
onClose={closeNav}
>
<SidebarPrompts />
</NavRail>
<div
className="flex min-w-0 flex-1 flex-col overflow-hidden"
aria-hidden={navOpen || undefined}
>
<main
id="main-content"
ref={mainRef}
className="flex-1 overflow-auto"
>
<div className="p-4 pb-20 sm:p-6 sm:pb-6 lg:p-8 lg:pb-8">
<ErrorBoundary>{children}</ErrorBoundary>
</div>
</main>
{/* Desktop-only FAB; on mobile Feedback is a row in More. */}
<FeedbackButton />
</div>
</div>
{/* The bottom nav never hides: it is the only way out of a screen. */}
{isMobile && <BottomNav entries={entries} badges={badges} />}
</div>
);
}
|