Redesign app shell: Sidebar with cutout content panel (#336)

This commit is contained in:
Ben Senescu 2026-07-02 19:06:13 -04:00 committed by GitHub
parent 4f17fe4942
commit 615ecce033
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 356 additions and 468 deletions

View File

@ -72,18 +72,18 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
onClick={() => {
void router.invalidate();
}}
className="btn btn-neutral btn-sm uppercase"
className="btn btn-primary btn-sm"
>
Try Again
</button>
{isRoot ? (
<Link to="/" className="btn btn-neutral btn-sm uppercase">
<Link to="/" className="btn btn-sm">
Home
</Link>
) : (
<Link
to="/"
className="btn btn-neutral btn-sm uppercase"
className="btn btn-sm"
onClick={(e) => {
e.preventDefault();
window.history.back();

View File

@ -1,101 +1,200 @@
import { Link } from "@tanstack/react-router";
import { X } from "lucide-react";
import { getProjectNavGroups } from "@/client/navigation/items";
import type { LinkOptions } from "@tanstack/react-router";
import type { ComponentType } from "react";
import {
CircleHelp,
CreditCard,
LogOut,
Settings,
User,
X,
} from "lucide-react";
import { aiNavItem, getProjectNavGroups } from "@/client/navigation/items";
import { ProjectSwitcher } from "@/client/features/projects/ProjectSwitcher";
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { BILLING_ROUTE } from "@/shared/billing";
interface SidebarProps {
projectId: string;
projectId: string | null;
onNavigate?: () => void;
onClose?: () => void;
}
const navItemBaseClass =
"relative flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm text-base-content/70";
const navItemClass = `${navItemBaseClass} transition-colors hover:bg-base-300/50 hover:text-base-content`;
const navItemActiveProps = {
className: "bg-base-300/50 font-medium text-base-content",
};
function SidebarNavLink({
icon: Icon,
label,
onNavigate,
linkProps,
}: {
icon: ComponentType<{ className?: string }>;
label: string;
onNavigate?: () => void;
linkProps: LinkOptions;
}) {
return (
<Link
{...linkProps}
onClick={onNavigate}
activeOptions={{ exact: false, includeSearch: false }}
className={navItemClass}
activeProps={navItemActiveProps}
>
{({ isActive }: { isActive: boolean }) => (
<>
{isActive ? (
<div className="absolute left-0 top-1 bottom-1 w-[3px] rounded-r-full bg-primary" />
) : null}
<Icon className="h-4 w-4 shrink-0" />
<span className="truncate">{label}</span>
</>
)}
</Link>
);
}
export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
const navGroups = getProjectNavGroups(projectId);
const navGroups = projectId ? getProjectNavGroups(projectId) : [];
return (
<div className="sidebar w-64 border-r border-base-300 h-full bg-base-100 flex flex-col">
{/* Header */}
<div className="px-4 py-4 border-b border-base-300 flex items-center justify-between">
<span className="font-semibold text-base-content">OpenSEO</span>
{onClose && (
<div className="flex h-full w-60 flex-col bg-base-200">
<div className="flex items-center justify-between px-4 pb-2 pt-3">
<Link
to="/"
onClick={onNavigate}
className="text-base font-semibold text-base-content"
>
OpenSEO
</Link>
{onClose ? (
<button
type="button"
onClick={onClose}
className="btn btn-ghost btn-sm btn-circle"
aria-label="Close sidebar"
>
<X className="h-5 w-5" />
</button>
)}
) : null}
</div>
{/* Project picker */}
<div className="px-3 py-3 border-b border-base-300">
<div className="px-3 pb-1">
<ProjectSwitcher
activeProjectId={projectId}
variant="sidebar"
onCloseDrawer={onNavigate}
/>
</div>
{/* Navigation */}
<nav className="flex-1 py-2 pl-3 overflow-y-auto">
{navGroups.map((entry) => {
if (entry.type === "standalone") {
const { icon: Icon, ...linkProps } = entry.item;
return (
<Link
key={linkProps.to}
{...linkProps}
onClick={onNavigate}
activeOptions={{ exact: false, includeSearch: false }}
className="relative flex items-center gap-3 px-4 py-2 text-sm text-base-content/60 transition-colors hover:bg-base-200 hover:text-base-content"
activeProps={{ className: "text-base-content font-medium" }}
>
{({ isActive }: { isActive: boolean }) => (
<>
{isActive ? (
<div className="absolute left-0 top-1 bottom-1 w-[3px] rounded-r-full bg-primary" />
) : null}
<Icon className="h-5 w-5" />
{entry.item.label}
</>
)}
</Link>
);
}
return (
<div key={entry.label} className="mb-2">
<div className="px-4 pb-1 pt-3 text-xs font-semibold uppercase tracking-wider text-base-content/40">
{entry.label}
<nav className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
{navGroups.map((group) => (
<div key={group.label} className="mb-1">
<div className="px-3 pb-1 pt-3 text-xs font-semibold uppercase tracking-wider text-base-content/40">
{group.label}
</div>
{entry.items.map((item) => {
const { icon: Icon, ...linkProps } = item;
{group.items.map((item) => {
const { icon, label, ...linkProps } = item;
return (
<Link
<SidebarNavLink
key={linkProps.to}
{...linkProps}
onClick={onNavigate}
activeOptions={{ exact: false, includeSearch: false }}
className="relative flex items-center gap-3 px-4 py-2 text-sm text-base-content/60 transition-colors hover:bg-base-200 hover:text-base-content"
activeProps={{ className: "text-base-content font-medium" }}
>
{({ isActive }: { isActive: boolean }) => (
<>
{isActive ? (
<div className="absolute left-0 top-1 bottom-1 w-[3px] rounded-r-full bg-primary" />
) : null}
<Icon className="h-5 w-5" />
{item.label}
</>
)}
</Link>
icon={icon}
label={label}
onNavigate={onNavigate}
linkProps={linkProps}
/>
);
})}
</div>
);
})}
))}
</nav>
<SidebarFooter onNavigate={onNavigate} />
</div>
);
}
function SidebarFooter({ onNavigate }: { onNavigate?: () => void }) {
const { data: session } = useSession();
const isHostedMode = isHostedClientAuthMode();
const email = session?.user?.email;
const { icon: aiIcon, label: aiLabel, ...aiLinkProps } = aiNavItem;
return (
<div className="shrink-0 border-t border-base-300 px-2 py-2 pb-safe">
<SidebarNavLink
icon={aiIcon}
label={aiLabel}
onNavigate={onNavigate}
linkProps={aiLinkProps}
/>
<SidebarNavLink
icon={CircleHelp}
label="Help & Community"
onNavigate={onNavigate}
linkProps={{ to: "/support" }}
/>
{isHostedMode ? (
<SidebarNavLink
icon={CreditCard}
label="Billing"
onNavigate={onNavigate}
linkProps={{ to: BILLING_ROUTE }}
/>
) : null}
<SidebarNavLink
icon={Settings}
label="Settings"
onNavigate={onNavigate}
linkProps={{ to: "/settings" }}
/>
{email ? (
isHostedMode ? (
<div className="dropdown dropdown-top w-full">
<button
type="button"
tabIndex={0}
className={`${navItemClass} w-full`}
aria-label="Open account menu"
>
<User className="h-4 w-4 shrink-0" />
<span className="truncate" data-ph-mask>
{email}
</span>
</button>
<ul
tabIndex={0}
className="dropdown-content z-30 menu mb-1 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
<li>
<button
type="button"
className="text-error"
onClick={() => signOutAndRedirect()}
>
<LogOut className="h-4 w-4" />
Sign out
</button>
</li>
</ul>
</div>
) : (
<div className={navItemBaseClass}>
<User className="h-4 w-4 shrink-0" />
<span className="truncate" data-ph-mask>
{email}
</span>
</div>
)
) : null}
</div>
);
}

View File

@ -62,7 +62,7 @@ export function AccessGate({
{isRefetching ? refetchingLabel : buttonLabel}
</button>
<a
className="btn btn-outline"
className="btn"
href={externalUrl}
target="_blank"
rel="noreferrer"

View File

@ -149,7 +149,7 @@ export function CitationTabsCard({
return (
<section className="overflow-hidden rounded-xl border border-base-300 bg-base-100">
<div className="flex items-center justify-between gap-3 border-b border-base-300 px-4 py-3">
<div role="tablist" className="tabs tabs-box w-fit">
<div role="tablist" className="tabs tabs-border w-fit">
<button
type="button"
role="tab"

View File

@ -81,7 +81,7 @@ export function BrandLookupSearchCard({
<button
type="submit"
className="btn btn-primary px-6"
className="btn btn-primary shrink-0 px-6"
disabled={isLoading}
>
{isLoading ? "Looking up..." : "Look up"}

View File

@ -174,7 +174,7 @@ export function PromptExplorerForm({
</div>
<button
type="submit"
className="btn btn-primary"
className="btn btn-primary shrink-0 px-6"
disabled={isLoading || form.models.length === 0}
>
{isLoading ? "Running…" : "Run"}

View File

@ -139,7 +139,7 @@ function ResultsHeader({
return (
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
{hasPerformanceTab ? (
<div role="tablist" className="tabs tabs-box w-fit">
<div role="tablist" className="tabs tabs-border w-fit">
{tabs.map(({ label, tab }) => {
const isActive = activeTab === tab;

View File

@ -53,7 +53,7 @@ export function AuthMethodChooser({
<button
type="button"
className="btn btn-soft w-full"
className="btn w-full"
onClick={onContinueWithEmail}
disabled={disabled || isBusy}
>

View File

@ -106,7 +106,7 @@ export function BacklinksResultsCard({
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 px-4 py-3 border-b border-base-300">
<div className="space-y-2">
<div role="tablist" className="tabs tabs-box w-fit">
<div role="tablist" className="tabs tabs-border w-fit">
{BACKLINKS_RESULTS_TABS.map(({ label, tab }) => (
<TabLink
key={tab}
@ -157,7 +157,7 @@ export function BacklinksResultsCard({
<div
role="tablist"
aria-label="Backlinks view"
className="ml-auto tabs tabs-box tabs-xs w-fit"
className="ml-auto tabs tabs-border tabs-xs w-fit"
>
<button
type="button"

View File

@ -84,7 +84,7 @@ export function BacklinksErrorState({
</p>
</div>
</div>
<button className="btn btn-outline btn-sm" onClick={onRetry}>
<button className="btn btn-sm" onClick={onRetry}>
Retry
</button>
</section>

View File

@ -108,14 +108,14 @@ export function BacklinksSearchCard({
}}
>
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
<div className="flex flex-col gap-3 lg:flex-row">
<form.Field name="target">
{(field) => {
const targetError = getFieldError(field.state.meta.errors);
return (
<label
className={`input input-bordered lg:col-span-10 flex items-center gap-2 ${targetError ? "input-error" : ""}`}
className={`input input-bordered flex flex-1 items-center gap-2 ${targetError ? "input-error" : ""}`}
>
<Search className="size-4 text-base-content/60" />
<input
@ -141,7 +141,7 @@ export function BacklinksSearchCard({
{(isSubmitting) => (
<button
type="submit"
className="btn btn-primary lg:col-span-2"
className="btn btn-primary shrink-0 px-6"
disabled={isSubmitting}
>
{isSubmitting ? "Loading..." : "Search"}

View File

@ -596,7 +596,7 @@ export function DomainOverviewPage({
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 px-4 py-3 border-b border-base-300">
<div role="tablist" className="tabs tabs-box w-fit">
<div role="tablist" className="tabs tabs-border w-fit">
<button
type="button"
role="tab"

View File

@ -176,7 +176,7 @@ function DomainKeywordsTableComponent({
</div>
<AppDataTable
table={table}
className="table table-zebra table-sm"
className="table table-sm"
wrapperClassName=""
empty={
<div className="py-6 text-center text-base-content/60">

View File

@ -92,7 +92,7 @@ function DomainPagesTableComponent({
return (
<AppDataTable
table={table}
className="table table-zebra table-sm"
className="table table-sm"
empty={
<div className="py-6 text-center text-base-content/60">
No pages match this search.

View File

@ -1,5 +1,14 @@
/** Google "G" brand mark, shared across the Search Console connect surfaces. */
export function GoogleGlyph({ className }: { className?: string }) {
export function GoogleGlyph({
className,
muted = false,
}: {
className?: string;
/** Render in currentColor so the mark inherits muted nav/icon styling. */
muted?: boolean;
}) {
const fill = (brand: string) => (muted ? "currentColor" : brand);
return (
<svg
className={className}
@ -8,21 +17,26 @@ export function GoogleGlyph({ className }: { className?: string }) {
focusable="false"
>
<path
fill="#EA4335"
fill={fill("#EA4335")}
d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
/>
<path
fill="#4285F4"
fill={fill("#4285F4")}
d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
/>
<path
fill="#FBBC05"
fill={fill("#FBBC05")}
d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
/>
<path
fill="#34A853"
fill={fill("#34A853")}
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
/>
</svg>
);
}
/** Monochrome variant with a LucideIcon-compatible signature for nav slots. */
export function GoogleGlyphMuted({ className }: { className?: string }) {
return <GoogleGlyph muted className={className} />;
}

View File

@ -152,7 +152,7 @@ export function SearchConsoleConnectionCard({
) : (
<div className="space-y-4">
<p className="text-sm text-base-content/70">
Real clicks, impressions, and rankings. No credits used.
Connect your Google Search Console to get insights in OpenSEO.
</p>
<button
type="button"
@ -182,14 +182,9 @@ function IntegrationCard({
return (
<div className="overflow-hidden rounded-xl border border-base-300 bg-base-100 shadow-sm">
<div className="flex items-start justify-between gap-4 p-5 sm:p-6">
<div>
<h2 className="text-base font-semibold leading-tight">
Google Search Console
</h2>
<p className="mt-0.5 text-sm text-base-content/55">
Your search data, straight from Google.
</p>
</div>
{status ? <StatusPill status={status} /> : null}
</div>
<div className="border-t border-base-300 p-5 sm:p-6">{children}</div>

View File

@ -117,7 +117,7 @@ export function KeywordResearchSearchBar({ controller }: Props) {
<button
type="submit"
className="btn btn-primary w-full px-6 font-semibold lg:w-auto lg:shrink-0"
className="btn btn-primary w-full px-6 lg:w-auto lg:shrink-0"
>
Search
</button>

View File

@ -154,7 +154,7 @@ export function PostSignupOnboarding({
</button>
<button
type="button"
className="btn btn-soft"
className="btn btn-primary"
disabled={!canContinue || isSaving}
onClick={onNext}
>
@ -216,7 +216,7 @@ function McpRecommendation({
<button
type="button"
className="btn btn-neutral mt-5 w-full"
className="btn btn-primary mt-5 w-full"
disabled={isSaving}
onClick={onSetup}
>

View File

@ -13,11 +13,9 @@ function closeDropdown() {
export function ProjectSwitcher({
activeProjectId,
variant = "topbar",
onCloseDrawer,
}: {
activeProjectId: string | null;
variant?: "topbar" | "sidebar";
// Mobile sidebar passes this so switching / navigating away also closes the
// drawer overlay.
onCloseDrawer?: () => void;
@ -31,8 +29,6 @@ export function ProjectSwitcher({
const activeProject =
projects.find((project) => project.id === activeProjectId) ?? null;
const isSidebar = variant === "sidebar";
const handleSelect = (project: ProjectSummary) => {
closeDropdown();
onCloseDrawer?.();
@ -45,16 +41,12 @@ export function ProjectSwitcher({
};
return (
<div className={`dropdown ${isSidebar ? "w-full" : "dropdown-end"}`}>
<div className="dropdown w-full">
<button
type="button"
tabIndex={0}
aria-label="Switch project"
className={
isSidebar
? "btn btn-ghost btn-sm w-full justify-between font-medium"
: "flex h-10 max-w-[12rem] items-center gap-2 rounded-full px-3 text-left transition-colors hover:bg-base-200/80"
}
className="flex w-full items-center justify-between gap-2 rounded-lg border border-base-300 bg-base-100 px-3 py-1.5 text-left transition-colors hover:border-base-content/25"
>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-base-content">
@ -71,9 +63,7 @@ export function ProjectSwitcher({
<ul
tabIndex={0}
className={`dropdown-content z-30 menu rounded-box border border-base-300 bg-base-100 p-2 shadow-lg ${
isSidebar ? "w-full" : "mt-2 w-64"
}`}
className="dropdown-content z-30 menu w-full rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
{projects.map((project) => {
const isActive = project.id === activeProjectId;
@ -101,9 +91,10 @@ export function ProjectSwitcher({
})}
{projects.length > 0 ? (
<li>
<hr className="my-1 border-base-300" />
</li>
<li
aria-hidden="true"
className="pointer-events-none my-1 h-px bg-base-300 p-0"
/>
) : null}
<li>

View File

@ -93,7 +93,7 @@ export function RankTrackingDetailHeader({
<option value="90d">vs 90 days ago</option>
</select>
<div className="hidden sm:block h-6 w-px bg-base-300" />
<button className="btn btn-outline btn-sm gap-1" onClick={onEdit}>
<button className="btn btn-sm gap-1" onClick={onEdit}>
<Settings className="size-3.5" />
Configure
</button>

View File

@ -131,7 +131,7 @@ export function SavedKeywordsTable({
return (
<AppDataTable
table={table}
className="table table-zebra table-sm"
className="table table-sm"
isLoading={isLoading}
loading={<SavedKeywordsSkeleton />}
empty={<SavedKeywordsEmptyState hasActiveFilters={hasActiveFilters} />}

View File

@ -209,12 +209,7 @@ export function SearchPerformancePage({ projectId }: { projectId: string }) {
</span>
</div>
) : !report?.connected ? (
<div className="max-w-2xl space-y-4">
<p className="text-sm text-base-content/70">
Find your striking-distance keywords queries ranking just off
the top of page one, where a small improvement can win the most
new clicks. Connect Search Console to see them.
</p>
<div className="max-w-2xl">
<SearchConsoleConnectionCard projectId={projectId} />
</div>
) : (
@ -222,7 +217,7 @@ export function SearchPerformancePage({ projectId }: { projectId: string }) {
<TotalsCards report={report} />
<div className="overflow-hidden rounded-xl border border-base-300 bg-base-100">
<div className="flex flex-col gap-3 border-b border-base-300 px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<div role="tablist" className="tabs tabs-box w-fit">
<div role="tablist" className="tabs tabs-border w-fit">
<TabButton
active={tab === "striking"}
onClick={() => setTab("striking")}

View File

@ -1,31 +1,20 @@
import * as React from "react";
import { Link, useLocation } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { Menu } from "lucide-react";
import {
ChevronDown,
CircleHelp,
CreditCard,
Menu,
Settings,
User,
} from "lucide-react";
import {
AppContent,
MissingSeoSetupModal,
MobileSidebarDrawer,
SeoApiStatusBanners,
} from "@/client/layout/AppShellParts";
import { GscReEngagementModal } from "@/client/features/gsc/GscReEngagementModal";
import { getProjectNavGroups } from "@/client/navigation/items";
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { Sidebar } from "@/client/components/Sidebar";
import { BILLING_ROUTE } from "@/shared/billing";
import { getSeoApiKeyStatus } from "@/serverFunctions/config";
import { getProjects } from "@/serverFunctions/projects";
import { ProjectSwitcher } from "@/client/features/projects/ProjectSwitcher";
import { getLastProjectId } from "@/client/lib/active-project";
const DATAFORSEO_HELP_PATH = "/help/dataforseo-api-key";
const SUPPORT_PATH = "/support";
export function AuthenticatedAppLayout({
children,
@ -61,7 +50,7 @@ export function AuthenticatedAppLayout({
?.id ??
fallbackProjects[0]?.id ??
null;
const headerProjectId = projectId ?? fallbackProjectId;
const sidebarProjectId = projectId ?? fallbackProjectId;
const shouldCheckSeoApiKeyStatus = location.pathname !== BILLING_ROUTE;
const seoApiKeyStatusQuery = useQuery({
queryKey: ["seoApiKeyStatus"],
@ -120,21 +109,22 @@ export function AuthenticatedAppLayout({
};
}, [shouldShowMissingSeoApiKeyModal]);
React.useEffect(() => {
if (!projectId) {
setDrawerOpen(false);
}
}, [projectId]);
return (
<div className="flex h-[100dvh] flex-col bg-base-200">
<TopNav
<div className="flex h-[100dvh] bg-base-200">
<div className="hidden shrink-0 md:block">
<Sidebar projectId={sidebarProjectId} />
</div>
<div className="flex min-w-0 flex-1 flex-col">
<MobileTopBar
drawerOpen={drawerOpen}
projectId={headerProjectId}
pathname={location.pathname}
onOpenDrawer={() => setDrawerOpen(true)}
/>
{/* PostHog-style cutout: the main content sits on a raised panel with a
thin strip of the sidebar background above it and a hairline border. */}
<div className="flex min-h-0 flex-1 flex-col md:pt-2">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-base-100 md:rounded-tl-lg md:border-l md:border-t md:border-base-300">
<SeoApiStatusBanners
shouldShowSeoApiWarning={shouldShowSeoApiWarning}
seoApiKeyStatusError={seoApiKeyStatusError}
@ -142,13 +132,16 @@ export function AuthenticatedAppLayout({
{banner}
<AppContent
drawerOpen={drawerOpen}
projectId={headerProjectId}
onCloseDrawer={() => setDrawerOpen(false)}
>
{children}
</AppContent>
<div className="min-h-0 flex-1 overflow-auto">{children}</div>
</div>
</div>
</div>
<MobileSidebarDrawer
open={drawerOpen}
projectId={sidebarProjectId}
onClose={() => setDrawerOpen(false)}
/>
<MissingSeoSetupModal
ref={setupModalRef}
@ -157,231 +150,34 @@ export function AuthenticatedAppLayout({
/>
<GscReEngagementModal
projectId={headerProjectId}
projectId={sidebarProjectId}
suppressed={shouldShowMissingSeoApiKeyModal}
/>
</div>
);
}
function TopNav({
function MobileTopBar({
drawerOpen,
projectId,
pathname,
onOpenDrawer,
}: {
drawerOpen: boolean;
projectId: string | null;
pathname: string;
onOpenDrawer: () => void;
}) {
const navGroups = projectId ? getProjectNavGroups(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 ? (
<div className="flex shrink-0 items-center gap-1 border-b border-base-300 bg-base-100 px-2 py-1.5 md:hidden">
<button
type="button"
className="btn btn-square btn-ghost"
className="btn btn-square btn-ghost btn-sm"
aria-label="Toggle sidebar"
aria-expanded={drawerOpen}
onClick={onOpenDrawer}
>
<Menu className="h-6 w-6" />
<Menu className="h-5 w-5" />
</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
? navGroups.map((entry) => {
if (entry.type === "standalone") {
const { icon: Icon, matchSegment, ...linkProps } = entry.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" />
{entry.item.label}
</Link>
);
}
const GroupIcon = entry.icon;
const isGroupActive = entry.matchSegments.some((seg) =>
pathname.includes(seg),
);
return (
<div key={entry.label} className="dropdown dropdown-hover">
<button
type="button"
tabIndex={0}
className={`btn btn-sm gap-1.5 ${
isGroupActive
? "border-transparent bg-primary/10 font-medium text-primary"
: "btn-ghost text-base-content/60 hover:text-base-content"
}`}
>
<GroupIcon className="h-4 w-4" />
{entry.label}
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
<ul
tabIndex={0}
className="dropdown-content z-20 menu w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
>
{entry.items.map((item) => {
const { icon: Icon, matchSegment, ...linkProps } = item;
const isActive = pathname.includes(matchSegment);
return (
<li key={linkProps.to}>
<Link
{...linkProps}
className={
isActive
? "bg-primary/10 font-medium text-primary"
: ""
}
onClick={() => {
if (
document.activeElement instanceof HTMLElement
) {
document.activeElement.blur();
}
}}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
</li>
);
})}
</ul>
</div>
);
})
: 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">
<ProjectSwitcher activeProjectId={projectId} variant="topbar" />
<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" data-ph-mask>
{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>
<Link to={BILLING_ROUTE} className="flex items-center gap-2">
<CreditCard className="h-4 w-4" />
Billing
</Link>
</li>
) : null}
<li>
<Link to="/settings" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
Settings
</Link>
</li>
{isHostedMode && email ? (
<li>
<button
type="button"
className="text-error"
onClick={handleSignOut}
>
Sign out
</button>
</li>
) : null}
</ul>
</div>
</div>
);
if (mobileOnly) {
return menu;
}
return (
<>
<div className="mx-1 h-6 w-px bg-base-300" />
{menu}
</>
);
}

View File

@ -58,45 +58,29 @@ function SeoApiStatusBanners({
);
}
function AppContent({
drawerOpen,
function MobileSidebarDrawer({
open,
projectId,
onCloseDrawer,
children,
onClose,
}: {
drawerOpen: boolean;
open: boolean;
projectId: string | null;
onCloseDrawer: () => void;
children: React.ReactNode;
onClose: () => void;
}) {
return (
<>
<div className="flex-1 min-h-0 md:hidden">
<div className="h-full overflow-auto">{children}</div>
if (!open) return null;
{drawerOpen && projectId ? (
<div className="fixed inset-0 z-50">
return (
<div className="fixed inset-0 z-50 md:hidden">
<button
type="button"
aria-label="Close sidebar"
className="absolute inset-0 bg-black/45"
onClick={onCloseDrawer}
/>
<div className="absolute left-0 top-0 h-full">
<Sidebar
projectId={projectId}
onNavigate={onCloseDrawer}
onClose={onCloseDrawer}
onClick={onClose}
/>
<div className="absolute left-0 top-0 h-full shadow-xl">
<Sidebar projectId={projectId} onNavigate={onClose} onClose={onClose} />
</div>
</div>
) : null}
</div>
<div className="hidden md:block flex-1 min-h-0 overflow-auto">
{children}
</div>
</>
);
}
@ -160,4 +144,4 @@ const MissingSeoSetupModal = React.forwardRef<
MissingSeoSetupModal.displayName = "MissingSeoSetupModal";
export { AppContent, MissingSeoSetupModal, SeoApiStatusBanners };
export { MissingSeoSetupModal, MobileSidebarDrawer, SeoApiStatusBanners };

View File

@ -1,5 +1,4 @@
import {
BarChart3,
Bookmark,
Bot,
ClipboardCheck,
@ -11,69 +10,60 @@ import {
TrendingUp,
} from "lucide-react";
import { linkOptions } from "@tanstack/react-router";
import { GoogleGlyphMuted } from "@/client/features/gsc/GoogleGlyph";
const projectNavItems = [
{
to: "/p/$projectId/keywords" as const,
label: "Keyword Research",
icon: Search,
matchSegment: "/keywords",
},
{
to: "/p/$projectId/saved" as const,
label: "Saved Keywords",
icon: Bookmark,
matchSegment: "/saved",
},
{
to: "/p/$projectId/rank-tracking" as const,
label: "Rank Tracking",
icon: TrendingUp,
matchSegment: "/rank-tracking",
},
{
to: "/p/$projectId/search-performance" as const,
label: "Search Performance",
icon: BarChart3,
matchSegment: "/search-performance",
label: "GSC Insights",
icon: GoogleGlyphMuted,
},
{
to: "/p/$projectId/domain" as const,
label: "Domain Overview",
icon: Globe,
matchSegment: "/domain",
},
{
to: "/p/$projectId/backlinks" as const,
label: "Backlinks",
icon: Link2,
matchSegment: "/backlinks",
},
{
to: "/p/$projectId/audit" as const,
label: "Site Audit",
icon: ClipboardCheck,
matchSegment: "/audit",
},
{
to: "/p/$projectId/brand-lookup" as const,
label: "Brand Lookup",
icon: Sparkles,
matchSegment: "/brand-lookup",
},
{
to: "/p/$projectId/prompt-explorer" as const,
label: "Prompt Explorer",
icon: MessageSquare,
matchSegment: "/prompt-explorer",
},
] as const;
const aiNavItem = linkOptions({
export const aiNavItem = linkOptions({
to: "/ai" as const,
label: "AI & MCP",
icon: Bot,
matchSegment: "/ai",
});
function getProjectNavItems(projectId: string) {
@ -86,48 +76,33 @@ function getProjectNavItems(projectId: string) {
);
}
// Grouped by scope: "My Site" is the project's own domain (tracked data),
// "Research" is point-at-anything lookup tools.
export function getProjectNavGroups(projectId: string) {
const all = getProjectNavItems(projectId);
const bySegment = (seg: string) => all.find((i) => i.matchSegment === seg)!;
const byPath = (path: (typeof projectNavItems)[number]["to"]) =>
all.find((i) => i.to === path)!;
return [
{
type: "group" as const,
label: "Keywords",
icon: Search,
matchSegments: ["/keywords", "/saved", "/rank-tracking"],
label: "Research",
items: [
bySegment("/keywords"),
bySegment("/saved"),
bySegment("/rank-tracking"),
byPath("/p/$projectId/keywords"),
byPath("/p/$projectId/domain"),
byPath("/p/$projectId/backlinks"),
byPath("/p/$projectId/brand-lookup"),
byPath("/p/$projectId/prompt-explorer"),
],
},
{
type: "standalone" as const,
item: bySegment("/search-performance"),
},
{
type: "group" as const,
label: "Domain",
icon: Globe,
matchSegments: ["/domain", "/backlinks", "/audit"],
label: "My Site",
items: [
bySegment("/domain"),
bySegment("/backlinks"),
bySegment("/audit"),
byPath("/p/$projectId/search-performance"),
byPath("/p/$projectId/rank-tracking"),
byPath("/p/$projectId/saved"),
byPath("/p/$projectId/audit"),
],
},
{
type: "group" as const,
label: "AI Visibility",
icon: Sparkles,
matchSegments: ["/brand-lookup", "/prompt-explorer"],
items: [bySegment("/brand-lookup"), bySegment("/prompt-explorer")],
},
{
type: "standalone" as const,
item: aiNavItem,
},
];
}

View File

@ -29,11 +29,11 @@
--color-base-200: oklch(97% 0 0);
--color-base-300: oklch(92% 0 0);
--color-base-content: oklch(20% 0 0);
--color-primary: oklch(55% 0.18 260);
--color-primary: oklch(50% 0.12 262);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(55% 0.15 145);
--color-secondary-content: oklch(100% 0 0);
--color-accent: oklch(55% 0.18 260);
--color-accent: oklch(50% 0.12 262);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(25% 0 0);
--color-neutral-content: oklch(100% 0 0);
@ -61,14 +61,14 @@
prefersdark: true;
color-scheme: dark;
--color-base-100: oklch(18% 0 0);
--color-base-200: oklch(14% 0 0);
--color-base-300: oklch(22% 0 0);
--color-base-200: oklch(12% 0 0);
--color-base-300: oklch(27% 0 0);
--color-base-content: oklch(92% 0 0);
--color-primary: oklch(60% 0.18 260);
--color-primary: oklch(66% 0.12 262);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(60% 0.15 145);
--color-secondary-content: oklch(100% 0 0);
--color-accent: oklch(60% 0.18 260);
--color-accent: oklch(66% 0.12 262);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(85% 0 0);
--color-neutral-content: oklch(20% 0 0);
@ -148,6 +148,45 @@ select {
}
}
/* Dark mode: neutral interactive surfaces should lift above the panel, not
sink below it. DaisyUI derives default buttons and the tabs-box well from
base-200, which is darker than the base-100 content panel in dark mode,
so they read as sunken/disabled. Colored variants (primary, error, ...)
set their own --btn-color and are excluded. */
html[data-theme="openseo-dark"]
.btn:not(
.btn-primary,
.btn-error,
.btn-neutral,
.btn-ghost,
.btn-outline,
.btn-soft
) {
--btn-color: oklch(25% 0 0);
}
/* Light mode: the base-200 default button is nearly invisible on the
base-100 content panel; use a white surface with a visible border,
matching inputs/selects. */
html[data-theme="openseo"]
.btn:not(
.btn-primary,
.btn-error,
.btn-neutral,
.btn-ghost,
.btn-outline,
.btn-soft
) {
--btn-color: var(--color-base-100);
--btn-border: var(--color-base-300);
}
/* Underline tabs: DaisyUI's tabs-border underlines the active tab with
currentColor; use the primary accent instead (PostHog-style). */
.tabs-border > .tab.tab-active::before {
--tab-border-color: var(--color-primary);
}
/* Custom alert styling with darker borders and transparent background */
.alert {
@apply flex gap-3 rounded-lg border p-4 text-base-content/70;