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:
parent
53a83996ef
commit
e68ced7203
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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",
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user