feat: group navigation into dropdown menus (#110)

* feat: group navigation into Keywords, Domain, and AI dropdowns

Reorganize the top nav from 7 flat items into grouped dropdowns:
- Keywords: Keyword Research, Saved Keywords, Rank Tracking
- Domain: Domain Overview, Backlinks, Site Audit
- AI: standalone link

Mobile sidebar uses section headers for the same groupings.
Active dropdown items are highlighted with primary tint.

* fix: use matchSegment lookup instead of hardcoded array indices in nav groups
This commit is contained in:
Ben Senescu 2026-04-14 20:14:09 -04:00 committed by Ben Senescu
parent 53a83996ef
commit e68ced7203
3 changed files with 163 additions and 41 deletions

View File

@ -1,6 +1,6 @@
import { Link } from "@tanstack/react-router";
import { ChevronsUpDown, X } from "lucide-react";
import { getProjectNavItems } from "@/client/navigation/items";
import { getProjectNavGroups } from "@/client/navigation/items";
interface SidebarProps {
projectId: string;
@ -9,7 +9,7 @@ interface SidebarProps {
}
export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
const projectNavItems = getProjectNavItems(projectId);
const navGroups = getProjectNavGroups(projectId);
return (
<div className="sidebar w-64 border-r border-base-300 h-full bg-base-100 flex flex-col">
@ -41,10 +41,39 @@ export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
</div>
{/* Navigation */}
<nav className="flex-1 py-4 pl-3 overflow-y-auto">
{projectNavItems.map((item) => {
const { icon: Icon, ...linkProps } = item;
<nav className="flex-1 py-2 pl-3 overflow-y-auto">
{navGroups.map((entry) => {
if (entry.type === "standalone") {
const { icon: Icon, ...linkProps } = entry.item;
return (
<Link
key={linkProps.to}
{...linkProps}
onClick={onNavigate}
activeOptions={{ exact: false, includeSearch: false }}
className="relative flex items-center gap-3 px-4 py-2 text-sm text-base-content/60 transition-colors hover:bg-base-200 hover:text-base-content"
activeProps={{ className: "text-base-content font-medium" }}
>
{({ isActive }: { isActive: boolean }) => (
<>
{isActive ? (
<div className="absolute left-0 top-1 bottom-1 w-[3px] rounded-r-full bg-primary" />
) : null}
<Icon className="h-5 w-5" />
{entry.item.label}
</>
)}
</Link>
);
}
return (
<div key={entry.label} className="mb-2">
<div className="px-4 pb-1 pt-3 text-xs font-semibold uppercase tracking-wider text-base-content/40">
{entry.label}
</div>
{entry.items.map((item) => {
const { icon: Icon, ...linkProps } = item;
return (
<Link
key={linkProps.to}
@ -66,6 +95,9 @@ export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
</Link>
);
})}
</div>
);
})}
</nav>
</div>
);

View File

@ -1,6 +1,7 @@
import * as React from "react";
import { Link, useLocation } from "@tanstack/react-router";
import {
ChevronDown,
ChevronsUpDown,
CircleHelp,
CreditCard,
@ -13,7 +14,7 @@ import {
SeoApiStatusBanners,
} from "@/client/layout/AppShellParts";
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { getProjectNavItems } from "@/client/navigation/items";
import { getProjectNavGroups } from "@/client/navigation/items";
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { BILLING_ROUTE } from "@/shared/billing";
@ -151,7 +152,7 @@ function TopNav({
pathname: string;
onOpenDrawer: () => void;
}) {
const projectNavItems = projectId ? getProjectNavItems(projectId) : [];
const navGroups = projectId ? getProjectNavGroups(projectId) : [];
const isSupportActive = pathname === SUPPORT_PATH;
return (
@ -178,10 +179,10 @@ function TopNav({
OpenSEO
</Link>
{projectId
? projectNavItems.map((item) => {
const { icon: Icon, matchSegment, ...linkProps } = item;
? navGroups.map((entry) => {
if (entry.type === "standalone") {
const { icon: Icon, matchSegment, ...linkProps } = entry.item;
const isActive = pathname.includes(matchSegment);
return (
<Link
key={linkProps.to}
@ -191,10 +192,65 @@ function TopNav({
? "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">
<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 mt-1 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}

View File

@ -54,7 +54,7 @@ const projectNavItems = [
},
] as const;
export function getProjectNavItems(projectId: string) {
function getProjectNavItems(projectId: string) {
return linkOptions(
projectNavItems.map((item) => ({
...item,
@ -64,6 +64,40 @@ export function getProjectNavItems(projectId: string) {
);
}
export function getProjectNavGroups(projectId: string) {
const all = getProjectNavItems(projectId);
const bySegment = (seg: string) => all.find((i) => i.matchSegment === seg)!;
return [
{
type: "group" as const,
label: "Keywords",
icon: Search,
matchSegments: ["/keywords", "/saved", "/rank-tracking"],
items: [
bySegment("/keywords"),
bySegment("/saved"),
bySegment("/rank-tracking"),
],
},
{
type: "group" as const,
label: "Domain",
icon: Globe,
matchSegments: ["/domain", "/backlinks", "/audit"],
items: [
bySegment("/domain"),
bySegment("/backlinks"),
bySegment("/audit"),
],
},
{
type: "standalone" as const,
item: bySegment("/ai"),
},
];
}
export const dataforseoHelpLinkOptions = linkOptions({
to: "/help/dataforseo-api-key",
});