Redesign app shell: Sidebar with cutout content panel (#336)
This commit is contained in:
parent
4f17fe4942
commit
615ecce033
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"}
|
||||||
|
|||||||
@ -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"}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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"}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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} />;
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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} />}
|
||||||
|
|||||||
@ -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")}
|
||||||
|
|||||||
@ -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}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user