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 { Link } from "@tanstack/react-router";
|
||||||
import { ChevronsUpDown, X } from "lucide-react";
|
import { ChevronsUpDown, X } from "lucide-react";
|
||||||
import { getProjectNavItems } from "@/client/navigation/items";
|
import { getProjectNavGroups } from "@/client/navigation/items";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -9,7 +9,7 @@ interface SidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
|
export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
|
||||||
const projectNavItems = getProjectNavItems(projectId);
|
const navGroups = getProjectNavGroups(projectId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar w-64 border-r border-base-300 h-full bg-base-100 flex flex-col">
|
<div className="sidebar w-64 border-r border-base-300 h-full bg-base-100 flex flex-col">
|
||||||
@ -41,29 +41,61 @@ export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 py-4 pl-3 overflow-y-auto">
|
<nav className="flex-1 py-2 pl-3 overflow-y-auto">
|
||||||
{projectNavItems.map((item) => {
|
{navGroups.map((entry) => {
|
||||||
const { icon: Icon, ...linkProps } = item;
|
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 (
|
return (
|
||||||
<Link
|
<div key={entry.label} className="mb-2">
|
||||||
key={linkProps.to}
|
<div className="px-4 pb-1 pt-3 text-xs font-semibold uppercase tracking-wider text-base-content/40">
|
||||||
{...linkProps}
|
{entry.label}
|
||||||
onClick={onNavigate}
|
</div>
|
||||||
activeOptions={{ exact: false, includeSearch: false }}
|
{entry.items.map((item) => {
|
||||||
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"
|
const { icon: Icon, ...linkProps } = item;
|
||||||
activeProps={{ className: "text-base-content font-medium" }}
|
return (
|
||||||
>
|
<Link
|
||||||
{({ isActive }: { isActive: boolean }) => (
|
key={linkProps.to}
|
||||||
<>
|
{...linkProps}
|
||||||
{isActive ? (
|
onClick={onNavigate}
|
||||||
<div className="absolute left-0 top-1 bottom-1 w-[3px] rounded-r-full bg-primary" />
|
activeOptions={{ exact: false, includeSearch: false }}
|
||||||
) : null}
|
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"
|
||||||
<Icon className="h-5 w-5" />
|
activeProps={{ className: "text-base-content font-medium" }}
|
||||||
{item.label}
|
>
|
||||||
</>
|
{({ isActive }: { isActive: boolean }) => (
|
||||||
)}
|
<>
|
||||||
</Link>
|
{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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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 {
|
import {
|
||||||
|
ChevronDown,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
@ -13,7 +14,7 @@ import {
|
|||||||
SeoApiStatusBanners,
|
SeoApiStatusBanners,
|
||||||
} from "@/client/layout/AppShellParts";
|
} from "@/client/layout/AppShellParts";
|
||||||
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
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 { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||||
import { BILLING_ROUTE } from "@/shared/billing";
|
import { BILLING_ROUTE } from "@/shared/billing";
|
||||||
@ -151,7 +152,7 @@ function TopNav({
|
|||||||
pathname: string;
|
pathname: string;
|
||||||
onOpenDrawer: () => void;
|
onOpenDrawer: () => void;
|
||||||
}) {
|
}) {
|
||||||
const projectNavItems = projectId ? getProjectNavItems(projectId) : [];
|
const navGroups = projectId ? getProjectNavGroups(projectId) : [];
|
||||||
const isSupportActive = pathname === SUPPORT_PATH;
|
const isSupportActive = pathname === SUPPORT_PATH;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -178,23 +179,78 @@ function TopNav({
|
|||||||
OpenSEO
|
OpenSEO
|
||||||
</Link>
|
</Link>
|
||||||
{projectId
|
{projectId
|
||||||
? projectNavItems.map((item) => {
|
? navGroups.map((entry) => {
|
||||||
const { icon: Icon, matchSegment, ...linkProps } = item;
|
if (entry.type === "standalone") {
|
||||||
const isActive = pathname.includes(matchSegment);
|
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 (
|
return (
|
||||||
<Link
|
<div key={entry.label} className="dropdown">
|
||||||
key={linkProps.to}
|
<button
|
||||||
{...linkProps}
|
type="button"
|
||||||
className={`btn btn-sm gap-2 ${
|
tabIndex={0}
|
||||||
isActive
|
className={`btn btn-sm gap-1.5 ${
|
||||||
? "border-transparent bg-primary/10 font-medium text-primary"
|
isGroupActive
|
||||||
: "btn-ghost text-base-content/60 hover:text-base-content"
|
? "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" />
|
>
|
||||||
{item.label}
|
<GroupIcon className="h-4 w-4" />
|
||||||
</Link>
|
{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}
|
: null}
|
||||||
|
|||||||
@ -54,7 +54,7 @@ const projectNavItems = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function getProjectNavItems(projectId: string) {
|
function getProjectNavItems(projectId: string) {
|
||||||
return linkOptions(
|
return linkOptions(
|
||||||
projectNavItems.map((item) => ({
|
projectNavItems.map((item) => ({
|
||||||
...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({
|
export const dataforseoHelpLinkOptions = linkOptions({
|
||||||
to: "/help/dataforseo-api-key",
|
to: "/help/dataforseo-api-key",
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user