316 lines
8.9 KiB
TypeScript

import * as React from "react";
import { Link, useLocation } from "@tanstack/react-router";
import {
ChevronsUpDown,
CircleHelp,
CreditCard,
Menu,
User,
} from "lucide-react";
import {
AppContent,
MissingSeoSetupModal,
SeoApiStatusBanners,
} from "@/client/layout/AppShellParts";
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { getProjectNavItems } from "@/client/navigation/items";
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { BILLING_ROUTE } from "@/shared/billing";
import { getSeoApiKeyStatus } from "@/serverFunctions/config";
const DATAFORSEO_HELP_PATH = "/help/dataforseo-api-key";
const SUPPORT_PATH = "/support";
export function AuthenticatedAppLayout({
children,
projectId,
banner,
}: {
children: React.ReactNode;
projectId?: string;
banner?: React.ReactNode;
}) {
const location = useLocation();
const [drawerOpen, setDrawerOpen] = React.useState(false);
const setupModalRef = React.useRef<HTMLDivElement | null>(null);
const [isSeoApiKeyConfigured, setIsSeoApiKeyConfigured] = React.useState<
boolean | null
>(null);
const [seoApiKeyStatusError, setSeoApiKeyStatusError] = React.useState(false);
const [showMissingSeoApiKeyModal, setShowMissingSeoApiKeyModal] =
React.useState(false);
React.useEffect(() => {
if (location.pathname === BILLING_ROUTE) {
setSeoApiKeyStatusError(false);
setIsSeoApiKeyConfigured(null);
setShowMissingSeoApiKeyModal(false);
return;
}
let cancelled = false;
const checkSeoApiKeyStatus = async () => {
try {
const result = await getSeoApiKeyStatus();
if (cancelled) return;
setSeoApiKeyStatusError(false);
setIsSeoApiKeyConfigured(result.configured);
if (!result.configured) {
setShowMissingSeoApiKeyModal(true);
}
} catch {
if (cancelled) return;
setSeoApiKeyStatusError(true);
setIsSeoApiKeyConfigured(null);
setShowMissingSeoApiKeyModal(false);
}
};
void checkSeoApiKeyStatus();
return () => {
cancelled = true;
};
}, [location.pathname]);
const shouldShowMissingSeoApiKeyModal =
showMissingSeoApiKeyModal && location.pathname !== DATAFORSEO_HELP_PATH;
const shouldShowSeoApiWarning =
!seoApiKeyStatusError &&
isSeoApiKeyConfigured === false &&
!shouldShowMissingSeoApiKeyModal;
React.useEffect(() => {
if (!shouldShowMissingSeoApiKeyModal) return;
setupModalRef.current?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setShowMissingSeoApiKeyModal(false);
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [shouldShowMissingSeoApiKeyModal]);
React.useEffect(() => {
if (!projectId) {
setDrawerOpen(false);
}
}, [projectId]);
return (
<div className="flex h-[100dvh] flex-col bg-base-200">
<TopNav
drawerOpen={drawerOpen}
projectId={projectId ?? null}
pathname={location.pathname}
onOpenDrawer={() => setDrawerOpen(true)}
/>
<SeoApiStatusBanners
shouldShowSeoApiWarning={shouldShowSeoApiWarning}
seoApiKeyStatusError={seoApiKeyStatusError}
/>
{banner}
<AppContent
drawerOpen={drawerOpen}
projectId={projectId ?? null}
onCloseDrawer={() => setDrawerOpen(false)}
>
{children}
</AppContent>
<MissingSeoSetupModal
ref={setupModalRef}
isOpen={shouldShowMissingSeoApiKeyModal}
onClose={() => setShowMissingSeoApiKeyModal(false)}
/>
</div>
);
}
function TopNav({
drawerOpen,
projectId,
pathname,
onOpenDrawer,
}: {
drawerOpen: boolean;
projectId: string | null;
pathname: string;
onOpenDrawer: () => void;
}) {
const projectNavItems = projectId ? getProjectNavItems(projectId) : [];
const isSupportActive = pathname === SUPPORT_PATH;
return (
<div className="navbar shrink-0 gap-2 border-b border-base-300 bg-base-100">
<div className="flex flex-none items-center md:hidden">
{projectId ? (
<button
type="button"
className="btn btn-square btn-ghost"
aria-label="Toggle sidebar"
aria-expanded={drawerOpen}
onClick={onOpenDrawer}
>
<Menu className="h-6 w-6" />
</button>
) : null}
<Link to="/" className="ml-1 font-semibold text-base-content">
OpenSEO
</Link>
</div>
<div className="hidden items-center gap-1 md:flex">
<Link to="/" className="px-2 text-lg font-semibold text-base-content">
OpenSEO
</Link>
{projectId
? projectNavItems.map((item) => {
const { icon: Icon, matchSegment, ...linkProps } = item;
const isActive = pathname.includes(matchSegment);
return (
<Link
key={linkProps.to}
{...linkProps}
className={`btn btn-sm gap-2 ${
isActive
? "border-transparent bg-primary/10 font-medium text-primary"
: "btn-ghost text-base-content/60 hover:text-base-content"
}`}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})
: null}
</div>
<div className="flex-1" />
<div className="hidden flex-none items-center gap-2 md:flex">
<div className="tooltip tooltip-bottom" data-tip="Help & Community">
<Link
to={SUPPORT_PATH}
className={`btn btn-ghost btn-circle btn-sm ${
isSupportActive
? "bg-primary/10 text-primary"
: "text-base-content/60 hover:text-base-content"
}`}
>
<CircleHelp className="h-4 w-4" />
</Link>
</div>
<div className="flex items-center rounded-full border border-base-300 bg-base-100/70 px-1 py-1 shadow-sm">
<div
className="tooltip tooltip-left before:whitespace-nowrap"
data-tip="Multiple projects coming soon"
>
<button
type="button"
className="flex h-10 cursor-default items-center gap-2 rounded-full px-3 text-left transition-colors hover:bg-base-200/80"
aria-label="Current project"
>
<span className="max-w-28 truncate text-sm font-medium text-base-content">
Default
</span>
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/35" />
</button>
</div>
<AccountMenu />
</div>
</div>
<AccountMenu mobileOnly />
</div>
);
}
function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) {
const { data: session } = useSession();
const isHostedMode = isHostedClientAuthMode();
const email = session?.user?.email;
const handleSignOut = () => signOutAndRedirect();
const menu = (
<div className={mobileOnly ? "ml-2 flex-none md:hidden" : "flex-none"}>
<div className="dropdown dropdown-end">
<button
type="button"
tabIndex={0}
className={`btn btn-ghost btn-circle ${mobileOnly ? "" : "hover:bg-base-200/80"}`}
aria-label="Open account menu"
>
<User className="h-5 w-5" />
</button>
<ul
tabIndex={0}
className="dropdown-content z-20 menu mt-3 min-w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
{email ? (
<li className="menu-title max-w-full">
<span className="truncate text-base-content">{email}</span>
</li>
) : null}
{mobileOnly ? (
<li>
<Link to={SUPPORT_PATH} className="flex items-center gap-2">
<CircleHelp className="h-4 w-4" />
Help & Community
</Link>
</li>
) : null}
{isHostedMode ? (
<li>
<a href={BILLING_ROUTE} className="flex items-center gap-2">
<CreditCard className="h-4 w-4" />
Billing
</a>
</li>
) : null}
{isHostedMode && email ? (
<li>
<button
type="button"
className="text-error"
onClick={handleSignOut}
>
Sign out
</button>
</li>
) : null}
<ThemePreferenceMenuItems />
</ul>
</div>
</div>
);
if (mobileOnly) {
return menu;
}
return (
<>
<div className="mx-1 h-6 w-px bg-base-300" />
{menu}
</>
);
}