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={() => { onClick={() => {
void router.invalidate(); void router.invalidate();
}} }}
className="btn btn-neutral btn-sm uppercase" className="btn btn-primary btn-sm"
> >
Try Again Try Again
</button> </button>
{isRoot ? ( {isRoot ? (
<Link to="/" className="btn btn-neutral btn-sm uppercase"> <Link to="/" className="btn btn-sm">
Home Home
</Link> </Link>
) : ( ) : (
<Link <Link
to="/" to="/"
className="btn btn-neutral btn-sm uppercase" className="btn btn-sm"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
window.history.back(); window.history.back();

View File

@ -1,101 +1,200 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { X } from "lucide-react"; import type { LinkOptions } from "@tanstack/react-router";
import { getProjectNavGroups } from "@/client/navigation/items"; 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 { 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 { interface SidebarProps {
projectId: string; projectId: string | null;
onNavigate?: () => void; onNavigate?: () => void;
onClose?: () => 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) { export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
const navGroups = getProjectNavGroups(projectId); const navGroups = projectId ? getProjectNavGroups(projectId) : [];
return ( return (
<div className="sidebar w-64 border-r border-base-300 h-full bg-base-100 flex flex-col"> <div className="flex h-full w-60 flex-col bg-base-200">
{/* Header */} <div className="flex items-center justify-between px-4 pb-2 pt-3">
<div className="px-4 py-4 border-b border-base-300 flex items-center justify-between"> <Link
<span className="font-semibold text-base-content">OpenSEO</span> to="/"
{onClose && ( onClick={onNavigate}
className="text-base font-semibold text-base-content"
>
OpenSEO
</Link>
{onClose ? (
<button <button
type="button"
onClick={onClose} onClick={onClose}
className="btn btn-ghost btn-sm btn-circle" className="btn btn-ghost btn-sm btn-circle"
aria-label="Close sidebar" aria-label="Close sidebar"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
)} ) : null}
</div> </div>
{/* Project picker */} <div className="px-3 pb-1">
<div className="px-3 py-3 border-b border-base-300">
<ProjectSwitcher <ProjectSwitcher
activeProjectId={projectId} activeProjectId={projectId}
variant="sidebar"
onCloseDrawer={onNavigate} onCloseDrawer={onNavigate}
/> />
</div> </div>
{/* Navigation */} <nav className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
<nav className="flex-1 py-2 pl-3 overflow-y-auto"> {navGroups.map((group) => (
{navGroups.map((entry) => { <div key={group.label} className="mb-1">
if (entry.type === "standalone") { <div className="px-3 pb-1 pt-3 text-xs font-semibold uppercase tracking-wider text-base-content/40">
const { icon: Icon, ...linkProps } = entry.item; {group.label}
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}
</div>
{entry.items.map((item) => {
const { icon: Icon, ...linkProps } = 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" />
{item.label}
</>
)}
</Link>
);
})}
</div> </div>
); {group.items.map((item) => {
})} const { icon, label, ...linkProps } = item;
return (
<SidebarNavLink
key={linkProps.to}
icon={icon}
label={label}
onNavigate={onNavigate}
linkProps={linkProps}
/>
);
})}
</div>
))}
</nav> </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> </div>
); );
} }

View File

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

View File

@ -149,7 +149,7 @@ export function CitationTabsCard({
return ( return (
<section className="overflow-hidden rounded-xl border border-base-300 bg-base-100"> <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 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 <button
type="button" type="button"
role="tab" role="tab"

View File

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

View File

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

View File

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

View File

@ -53,7 +53,7 @@ export function AuthMethodChooser({
<button <button
type="button" type="button"
className="btn btn-soft w-full" className="btn w-full"
onClick={onContinueWithEmail} onClick={onContinueWithEmail}
disabled={disabled || isBusy} 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="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="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 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 }) => ( {BACKLINKS_RESULTS_TABS.map(({ label, tab }) => (
<TabLink <TabLink
key={tab} key={tab}
@ -157,7 +157,7 @@ export function BacklinksResultsCard({
<div <div
role="tablist" role="tablist"
aria-label="Backlinks view" 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 <button
type="button" type="button"

View File

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

View File

@ -108,14 +108,14 @@ export function BacklinksSearchCard({
}} }}
> >
<div className="space-y-3"> <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"> <form.Field name="target">
{(field) => { {(field) => {
const targetError = getFieldError(field.state.meta.errors); const targetError = getFieldError(field.state.meta.errors);
return ( return (
<label <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" /> <Search className="size-4 text-base-content/60" />
<input <input
@ -141,7 +141,7 @@ export function BacklinksSearchCard({
{(isSubmitting) => ( {(isSubmitting) => (
<button <button
type="submit" type="submit"
className="btn btn-primary lg:col-span-2" className="btn btn-primary shrink-0 px-6"
disabled={isSubmitting} disabled={isSubmitting}
> >
{isSubmitting ? "Loading..." : "Search"} {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="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="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 <button
type="button" type="button"
role="tab" role="tab"

View File

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

View File

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

View File

@ -1,5 +1,14 @@
/** Google "G" brand mark, shared across the Search Console connect surfaces. */ /** 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 ( return (
<svg <svg
className={className} className={className}
@ -8,21 +17,26 @@ export function GoogleGlyph({ className }: { className?: string }) {
focusable="false" focusable="false"
> >
<path <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" 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 <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" 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 <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" 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 <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" 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> </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"> <div className="space-y-4">
<p className="text-sm text-base-content/70"> <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> </p>
<button <button
type="button" type="button"
@ -182,14 +182,9 @@ function IntegrationCard({
return ( return (
<div className="overflow-hidden rounded-xl border border-base-300 bg-base-100 shadow-sm"> <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 className="flex items-start justify-between gap-4 p-5 sm:p-6">
<div> <h2 className="text-base font-semibold leading-tight">
<h2 className="text-base font-semibold leading-tight"> Google Search Console
Google Search Console </h2>
</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} {status ? <StatusPill status={status} /> : null}
</div> </div>
<div className="border-t border-base-300 p-5 sm:p-6">{children}</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 <button
type="submit" 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 Search
</button> </button>

View File

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

View File

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

View File

@ -93,7 +93,7 @@ export function RankTrackingDetailHeader({
<option value="90d">vs 90 days ago</option> <option value="90d">vs 90 days ago</option>
</select> </select>
<div className="hidden sm:block h-6 w-px bg-base-300" /> <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" /> <Settings className="size-3.5" />
Configure Configure
</button> </button>

View File

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

View File

@ -209,12 +209,7 @@ export function SearchPerformancePage({ projectId }: { projectId: string }) {
</span> </span>
</div> </div>
) : !report?.connected ? ( ) : !report?.connected ? (
<div className="max-w-2xl space-y-4"> <div className="max-w-2xl">
<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>
<SearchConsoleConnectionCard projectId={projectId} /> <SearchConsoleConnectionCard projectId={projectId} />
</div> </div>
) : ( ) : (
@ -222,7 +217,7 @@ export function SearchPerformancePage({ projectId }: { projectId: string }) {
<TotalsCards report={report} /> <TotalsCards report={report} />
<div className="overflow-hidden rounded-xl border border-base-300 bg-base-100"> <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 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 <TabButton
active={tab === "striking"} active={tab === "striking"}
onClick={() => setTab("striking")} onClick={() => setTab("striking")}

View File

@ -1,31 +1,20 @@
import * as React from "react"; import * as React from "react";
import { Link, useLocation } from "@tanstack/react-router"; import { Link, useLocation } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Menu } from "lucide-react";
import { import {
ChevronDown,
CircleHelp,
CreditCard,
Menu,
Settings,
User,
} from "lucide-react";
import {
AppContent,
MissingSeoSetupModal, MissingSeoSetupModal,
MobileSidebarDrawer,
SeoApiStatusBanners, SeoApiStatusBanners,
} from "@/client/layout/AppShellParts"; } from "@/client/layout/AppShellParts";
import { GscReEngagementModal } from "@/client/features/gsc/GscReEngagementModal"; import { GscReEngagementModal } from "@/client/features/gsc/GscReEngagementModal";
import { getProjectNavGroups } from "@/client/navigation/items"; import { Sidebar } from "@/client/components/Sidebar";
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { BILLING_ROUTE } from "@/shared/billing"; import { BILLING_ROUTE } from "@/shared/billing";
import { getSeoApiKeyStatus } from "@/serverFunctions/config"; import { getSeoApiKeyStatus } from "@/serverFunctions/config";
import { getProjects } from "@/serverFunctions/projects"; import { getProjects } from "@/serverFunctions/projects";
import { ProjectSwitcher } from "@/client/features/projects/ProjectSwitcher";
import { getLastProjectId } from "@/client/lib/active-project"; import { getLastProjectId } from "@/client/lib/active-project";
const DATAFORSEO_HELP_PATH = "/help/dataforseo-api-key"; const DATAFORSEO_HELP_PATH = "/help/dataforseo-api-key";
const SUPPORT_PATH = "/support";
export function AuthenticatedAppLayout({ export function AuthenticatedAppLayout({
children, children,
@ -61,7 +50,7 @@ export function AuthenticatedAppLayout({
?.id ?? ?.id ??
fallbackProjects[0]?.id ?? fallbackProjects[0]?.id ??
null; null;
const headerProjectId = projectId ?? fallbackProjectId; const sidebarProjectId = projectId ?? fallbackProjectId;
const shouldCheckSeoApiKeyStatus = location.pathname !== BILLING_ROUTE; const shouldCheckSeoApiKeyStatus = location.pathname !== BILLING_ROUTE;
const seoApiKeyStatusQuery = useQuery({ const seoApiKeyStatusQuery = useQuery({
queryKey: ["seoApiKeyStatus"], queryKey: ["seoApiKeyStatus"],
@ -120,36 +109,40 @@ export function AuthenticatedAppLayout({
}; };
}, [shouldShowMissingSeoApiKeyModal]); }, [shouldShowMissingSeoApiKeyModal]);
React.useEffect(() => {
if (!projectId) {
setDrawerOpen(false);
}
}, [projectId]);
return ( return (
<div className="flex h-[100dvh] flex-col bg-base-200"> <div className="flex h-[100dvh] bg-base-200">
<TopNav <div className="hidden shrink-0 md:block">
drawerOpen={drawerOpen} <Sidebar projectId={sidebarProjectId} />
projectId={headerProjectId} </div>
pathname={location.pathname}
onOpenDrawer={() => setDrawerOpen(true)} <div className="flex min-w-0 flex-1 flex-col">
<MobileTopBar
drawerOpen={drawerOpen}
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}
/>
{banner}
<div className="min-h-0 flex-1 overflow-auto">{children}</div>
</div>
</div>
</div>
<MobileSidebarDrawer
open={drawerOpen}
projectId={sidebarProjectId}
onClose={() => setDrawerOpen(false)}
/> />
<SeoApiStatusBanners
shouldShowSeoApiWarning={shouldShowSeoApiWarning}
seoApiKeyStatusError={seoApiKeyStatusError}
/>
{banner}
<AppContent
drawerOpen={drawerOpen}
projectId={headerProjectId}
onCloseDrawer={() => setDrawerOpen(false)}
>
{children}
</AppContent>
<MissingSeoSetupModal <MissingSeoSetupModal
ref={setupModalRef} ref={setupModalRef}
isOpen={shouldShowMissingSeoApiKeyModal} isOpen={shouldShowMissingSeoApiKeyModal}
@ -157,231 +150,34 @@ export function AuthenticatedAppLayout({
/> />
<GscReEngagementModal <GscReEngagementModal
projectId={headerProjectId} projectId={sidebarProjectId}
suppressed={shouldShowMissingSeoApiKeyModal} suppressed={shouldShowMissingSeoApiKeyModal}
/> />
</div> </div>
); );
} }
function TopNav({ function MobileTopBar({
drawerOpen, drawerOpen,
projectId,
pathname,
onOpenDrawer, onOpenDrawer,
}: { }: {
drawerOpen: boolean; drawerOpen: boolean;
projectId: string | null;
pathname: string;
onOpenDrawer: () => void; onOpenDrawer: () => void;
}) { }) {
const navGroups = projectId ? getProjectNavGroups(projectId) : [];
const isSupportActive = pathname === SUPPORT_PATH;
return ( return (
<div className="navbar shrink-0 gap-2 border-b border-base-300 bg-base-100"> <div className="flex shrink-0 items-center gap-1 border-b border-base-300 bg-base-100 px-2 py-1.5 md:hidden">
<div className="flex flex-none items-center md:hidden"> <button
{projectId ? ( type="button"
<button className="btn btn-square btn-ghost btn-sm"
type="button" aria-label="Toggle sidebar"
className="btn btn-square btn-ghost" aria-expanded={drawerOpen}
aria-label="Toggle sidebar" onClick={onOpenDrawer}
aria-expanded={drawerOpen} >
onClick={onOpenDrawer} <Menu className="h-5 w-5" />
> </button>
<Menu className="h-6 w-6" /> <Link to="/" className="ml-1 font-semibold text-base-content">
</button> OpenSEO
) : null} </Link>
<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> </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({ function MobileSidebarDrawer({
drawerOpen, open,
projectId, projectId,
onCloseDrawer, onClose,
children,
}: { }: {
drawerOpen: boolean; open: boolean;
projectId: string | null; projectId: string | null;
onCloseDrawer: () => void; onClose: () => void;
children: React.ReactNode;
}) { }) {
if (!open) return null;
return ( return (
<> <div className="fixed inset-0 z-50 md:hidden">
<div className="flex-1 min-h-0 md:hidden"> <button
<div className="h-full overflow-auto">{children}</div> type="button"
aria-label="Close sidebar"
{drawerOpen && projectId ? ( className="absolute inset-0 bg-black/45"
<div className="fixed inset-0 z-50"> onClick={onClose}
<button />
type="button" <div className="absolute left-0 top-0 h-full shadow-xl">
aria-label="Close sidebar" <Sidebar projectId={projectId} onNavigate={onClose} onClose={onClose} />
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}
/>
</div>
</div>
) : null}
</div> </div>
</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"; MissingSeoSetupModal.displayName = "MissingSeoSetupModal";
export { AppContent, MissingSeoSetupModal, SeoApiStatusBanners }; export { MissingSeoSetupModal, MobileSidebarDrawer, SeoApiStatusBanners };

View File

@ -1,5 +1,4 @@
import { import {
BarChart3,
Bookmark, Bookmark,
Bot, Bot,
ClipboardCheck, ClipboardCheck,
@ -11,69 +10,60 @@ import {
TrendingUp, TrendingUp,
} from "lucide-react"; } from "lucide-react";
import { linkOptions } from "@tanstack/react-router"; import { linkOptions } from "@tanstack/react-router";
import { GoogleGlyphMuted } from "@/client/features/gsc/GoogleGlyph";
const projectNavItems = [ const projectNavItems = [
{ {
to: "/p/$projectId/keywords" as const, to: "/p/$projectId/keywords" as const,
label: "Keyword Research", label: "Keyword Research",
icon: Search, icon: Search,
matchSegment: "/keywords",
}, },
{ {
to: "/p/$projectId/saved" as const, to: "/p/$projectId/saved" as const,
label: "Saved Keywords", label: "Saved Keywords",
icon: Bookmark, icon: Bookmark,
matchSegment: "/saved",
}, },
{ {
to: "/p/$projectId/rank-tracking" as const, to: "/p/$projectId/rank-tracking" as const,
label: "Rank Tracking", label: "Rank Tracking",
icon: TrendingUp, icon: TrendingUp,
matchSegment: "/rank-tracking",
}, },
{ {
to: "/p/$projectId/search-performance" as const, to: "/p/$projectId/search-performance" as const,
label: "Search Performance", label: "GSC Insights",
icon: BarChart3, icon: GoogleGlyphMuted,
matchSegment: "/search-performance",
}, },
{ {
to: "/p/$projectId/domain" as const, to: "/p/$projectId/domain" as const,
label: "Domain Overview", label: "Domain Overview",
icon: Globe, icon: Globe,
matchSegment: "/domain",
}, },
{ {
to: "/p/$projectId/backlinks" as const, to: "/p/$projectId/backlinks" as const,
label: "Backlinks", label: "Backlinks",
icon: Link2, icon: Link2,
matchSegment: "/backlinks",
}, },
{ {
to: "/p/$projectId/audit" as const, to: "/p/$projectId/audit" as const,
label: "Site Audit", label: "Site Audit",
icon: ClipboardCheck, icon: ClipboardCheck,
matchSegment: "/audit",
}, },
{ {
to: "/p/$projectId/brand-lookup" as const, to: "/p/$projectId/brand-lookup" as const,
label: "Brand Lookup", label: "Brand Lookup",
icon: Sparkles, icon: Sparkles,
matchSegment: "/brand-lookup",
}, },
{ {
to: "/p/$projectId/prompt-explorer" as const, to: "/p/$projectId/prompt-explorer" as const,
label: "Prompt Explorer", label: "Prompt Explorer",
icon: MessageSquare, icon: MessageSquare,
matchSegment: "/prompt-explorer",
}, },
] as const; ] as const;
const aiNavItem = linkOptions({ export const aiNavItem = linkOptions({
to: "/ai" as const, to: "/ai" as const,
label: "AI & MCP", label: "AI & MCP",
icon: Bot, icon: Bot,
matchSegment: "/ai",
}); });
function getProjectNavItems(projectId: string) { 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) { export function getProjectNavGroups(projectId: string) {
const all = getProjectNavItems(projectId); 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 [ return [
{ {
type: "group" as const, label: "Research",
label: "Keywords",
icon: Search,
matchSegments: ["/keywords", "/saved", "/rank-tracking"],
items: [ items: [
bySegment("/keywords"), byPath("/p/$projectId/keywords"),
bySegment("/saved"), byPath("/p/$projectId/domain"),
bySegment("/rank-tracking"), byPath("/p/$projectId/backlinks"),
byPath("/p/$projectId/brand-lookup"),
byPath("/p/$projectId/prompt-explorer"),
], ],
}, },
{ {
type: "standalone" as const, label: "My Site",
item: bySegment("/search-performance"),
},
{
type: "group" as const,
label: "Domain",
icon: Globe,
matchSegments: ["/domain", "/backlinks", "/audit"],
items: [ items: [
bySegment("/domain"), byPath("/p/$projectId/search-performance"),
bySegment("/backlinks"), byPath("/p/$projectId/rank-tracking"),
bySegment("/audit"), 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-200: oklch(97% 0 0);
--color-base-300: oklch(92% 0 0); --color-base-300: oklch(92% 0 0);
--color-base-content: oklch(20% 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-primary-content: oklch(100% 0 0);
--color-secondary: oklch(55% 0.15 145); --color-secondary: oklch(55% 0.15 145);
--color-secondary-content: oklch(100% 0 0); --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-accent-content: oklch(100% 0 0);
--color-neutral: oklch(25% 0 0); --color-neutral: oklch(25% 0 0);
--color-neutral-content: oklch(100% 0 0); --color-neutral-content: oklch(100% 0 0);
@ -61,14 +61,14 @@
prefersdark: true; prefersdark: true;
color-scheme: dark; color-scheme: dark;
--color-base-100: oklch(18% 0 0); --color-base-100: oklch(18% 0 0);
--color-base-200: oklch(14% 0 0); --color-base-200: oklch(12% 0 0);
--color-base-300: oklch(22% 0 0); --color-base-300: oklch(27% 0 0);
--color-base-content: oklch(92% 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-primary-content: oklch(100% 0 0);
--color-secondary: oklch(60% 0.15 145); --color-secondary: oklch(60% 0.15 145);
--color-secondary-content: oklch(100% 0 0); --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-accent-content: oklch(100% 0 0);
--color-neutral: oklch(85% 0 0); --color-neutral: oklch(85% 0 0);
--color-neutral-content: oklch(20% 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 */ /* Custom alert styling with darker borders and transparent background */
.alert { .alert {
@apply flex gap-3 rounded-lg border p-4 text-base-content/70; @apply flex gap-3 rounded-lg border p-4 text-base-content/70;