Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 21 additions & 29 deletions app/(app)/projects/[id]/layout-client.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { ProjectTabsNav } from "@/components/project-tabs-nav";
import { ProjectStatusControls } from "@/components/project-status-controls";
import { ScheduleToggle } from "@/components/schedule-toggle";
Expand All @@ -10,11 +9,10 @@ import { ProjectHeaderLogo } from "@/components/project-header-logo";
import type { ProjectStatus } from "@/app/actions/projects";
import type { Engine } from "@/lib/credits";

// On the autoblog/social tabs the layout owns the header chrome.
// Mirrors ProjectShell exactly (logo + name + action bar + tabs) so the
// tabs nav stays at the same Y across all project tabs — otherwise it
// jumps when switching e.g. /scans → /autoblog because the action bar
// height changes.
// Owns the project-level chrome (breadcrumb + logo/title + action bar +
// tab nav) on EVERY /projects/[id]/* route, so the nav is always there
// and never shifts position between tabs. Tab pages render their inner
// content into `children`.
export function ProjectLayoutClient({
projectId,
name,
Expand All @@ -34,33 +32,27 @@ export function ProjectLayoutClient({
engines: Engine[];
children: React.ReactNode;
}) {
const pathname = usePathname() ?? "";
const isSubTab = /\/projects\/[^/]+\/(autoblog|social)(\/|$)/.test(pathname);

return (
<div className="space-y-6">
{isSubTab && (
<>
<div>
<Link href="/dashboard" className="text-sm text-[var(--color-muted)]">
← Dashboard
</Link>
<div className="mt-3 flex items-center gap-3">
<ProjectHeaderLogo url={logoUrl} name={name} />
<h1 className="text-3xl font-bold">{name}</h1>
</div>
<p className="mt-1 break-all text-[var(--color-muted)]">{url}</p>
</div>
<div>
<Link href="/dashboard" className="text-sm text-[var(--color-muted)]">
← Dashboard
</Link>
<div className="mt-3 flex items-center gap-3">
<ProjectHeaderLogo url={logoUrl} name={name} />
<h1 className="text-3xl font-bold">{name}</h1>
</div>
<p className="mt-1 break-all text-[var(--color-muted)]">{url}</p>
</div>

<div className="flex flex-wrap items-center gap-3">
<ProjectStatusControls projectId={projectId} status={status} />
<ScheduleToggle projectId={projectId} current={schedule} />
<RunNowButton projectId={projectId} url={url} engines={engines} />
</div>

<div className="flex flex-wrap items-center gap-3">
<ProjectStatusControls projectId={projectId} status={status} />
<ScheduleToggle projectId={projectId} current={schedule} />
<RunNowButton projectId={projectId} url={url} engines={engines} />
</div>
<ProjectTabsNav projectId={projectId} />

<ProjectTabsNav projectId={projectId} />
</>
)}
<div>{children}</div>
</div>
);
Expand Down
95 changes: 12 additions & 83 deletions components/project-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import Link from "next/link";
import { ScheduleToggle } from "@/components/schedule-toggle";
import { ProjectStatusControls } from "@/components/project-status-controls";
import { RunNowButton } from "@/components/run-now-button";
import { ProjectHeaderLogo } from "@/components/project-header-logo";
// Deprecated: the project chrome (breadcrumb, header, status controls,
// tab nav) now lives in /projects/[id]/layout.tsx + layout-client.tsx,
// so every project page picks it up automatically. ProjectShell is kept
// as a pass-through for compatibility with the six pages that still
// render <ProjectShell>{content}</ProjectShell>. Removing those wrappers
// in a follow-up PR is fine; this file is intentionally trivial.

import type { ProjectStatus } from "@/app/actions/projects";
import type { Engine } from "@/lib/credits";

Expand All @@ -26,30 +28,6 @@ type ProjectLite = {
logo_url?: string | null;
};

const TABS: { id: ProjectShellTab; label: string; href: (id: string) => string }[] = [
{
id: "getting-started",
label: "Getting Started",
href: (id) => `/projects/${id}/getting-started`,
},
{ id: "overview", label: "Overview", href: (id) => `/projects/${id}` },
{
id: "performance",
label: "Performance",
href: (id) => `/projects/${id}/performance`,
},
{ id: "scans", label: "Scans", href: (id) => `/projects/${id}/scans` },
{ id: "stats", label: "Stats", href: (id) => `/projects/${id}/stats` },
{ id: "autoblog", label: "Autoblog", href: (id) => `/projects/${id}/autoblog` },
{ id: "social", label: "Social", href: (id) => `/projects/${id}/social` },
{ id: "repos", label: "Repos", href: (id) => `/projects/${id}/repos` },
];

// Shared shell for the three project tab pages. Renders the breadcrumb,
// project header, action bar (status / schedule / run-now), and tab nav.
// Each tab page wraps its own content with this so we don't need a
// layout file (the run-detail page hangs off the same /projects/[id]
// route and shouldn't show the tab nav).
export function ProjectShell({
project,
currentTab,
Expand All @@ -59,58 +37,9 @@ export function ProjectShell({
currentTab: ProjectShellTab;
children: React.ReactNode;
}) {
const engines: Engine[] = project.engines ?? ["rule"];
return (
<div className="space-y-6">
<div>
<Link href="/dashboard" className="text-sm text-[var(--color-muted)]">
← Dashboard
</Link>
<div className="mt-3 flex items-center gap-3">
<ProjectHeaderLogo url={project.logo_url ?? null} name={project.name} />
<h1 className="text-3xl font-bold">{project.name}</h1>
</div>
<p className="mt-1 break-all text-[var(--color-muted)]">{project.url}</p>
</div>

<div className="flex flex-wrap items-center gap-3">
<ProjectStatusControls
projectId={project.id}
status={(project.status ?? "active") as ProjectStatus}
/>
<ScheduleToggle projectId={project.id} current={project.schedule} />
<RunNowButton
projectId={project.id}
url={project.url}
engines={engines}
/>
</div>

<nav
role="tablist"
className="flex gap-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-1 text-sm"
>
{TABS.map((t) => {
const active = t.id === currentTab;
return (
<Link
key={t.id}
href={t.href(project.id)}
role="tab"
aria-selected={active}
className={`rounded-md px-3 py-1.5 ${
active
? "bg-[var(--color-bg)] font-semibold"
: "text-[var(--color-muted)]"
}`}
>
{t.label}
</Link>
);
})}
</nav>

<div>{children}</div>
</div>
);
// Both props are intentionally unused — the layout reads the project
// and the tab nav resolves currentTab from the URL.
void project;
void currentTab;
return <>{children}</>;
}
59 changes: 43 additions & 16 deletions components/project-tabs-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,62 +2,89 @@

import Link from "next/link";
import { usePathname } from "next/navigation";
import type { ProjectShellTab } from "./project-shell";

const TABS: { id: ProjectShellTab; label: string; href: (id: string) => string; match: RegExp }[] = [
interface ProjectTab {
id: string;
label: string;
href: (id: string) => string;
/** Returns true when the pathname should highlight this tab. */
matches: (pathname: string, projectId: string) => boolean;
}

// Order = visual order in the nav.
const TABS: ProjectTab[] = [
{
id: "getting-started",
label: "Getting Started",
href: (id) => `/projects/${id}/getting-started`,
matches: (p, id) => p.startsWith(`/projects/${id}/getting-started`),
},
{
id: "overview",
label: "Overview",
href: (id) => `/projects/${id}`,
match: /^\/projects\/[^/]+\/?$/,
// Exact match — every other tab is a sub-route, but Overview is the bare project URL.
matches: (p, id) => p === `/projects/${id}` || p === `/projects/${id}/`,
},
{
id: "performance",
label: "Performance",
href: (id) => `/projects/${id}/performance`,
match: /^\/projects\/[^/]+\/performance(\/|$)/,
matches: (p, id) => p.startsWith(`/projects/${id}/performance`),
},
{
id: "scans",
label: "Scans",
href: (id) => `/projects/${id}/scans`,
match: /^\/projects\/[^/]+\/scans(\/|$)/,
matches: (p, id) =>
p.startsWith(`/projects/${id}/scans`) ||
// Run-detail pages live under /projects/[id]/runs/[runId] but
// visually belong with the Scans tab.
p.startsWith(`/projects/${id}/runs`),
},
{
id: "stats",
label: "Stats",
href: (id) => `/projects/${id}/stats`,
matches: (p, id) => p.startsWith(`/projects/${id}/stats`),
},
{
id: "autoblog",
label: "Autoblog",
href: (id) => `/projects/${id}/autoblog`,
match: /^\/projects\/[^/]+\/autoblog(\/|$)/,
matches: (p, id) => p.startsWith(`/projects/${id}/autoblog`),
},
{
id: "social",
label: "Social",
href: (id) => `/projects/${id}/social`,
match: /^\/projects\/[^/]+\/social(\/|$)/,
matches: (p, id) => p.startsWith(`/projects/${id}/social`),
},
{
id: "repos",
label: "Repos",
href: (id) => `/projects/${id}/repos`,
matches: (p, id) => p.startsWith(`/projects/${id}/repos`),
},
];

// Resolves the active tab from the URL so the layout doesn't need to
// pass it down. Returns null on /projects/[id]/runs/[runId] where no
// tab should appear selected.
export function ProjectTabsNav({ projectId }: { projectId: string }) {
const pathname = usePathname();
const active = TABS.find((t) => t.match.test(pathname ?? ""));
const pathname = usePathname() ?? "";
return (
<nav
role="tablist"
className="flex gap-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-1 text-sm"
className="flex flex-wrap gap-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-1 text-sm"
>
{TABS.map((t) => {
const isActive = t.id === active?.id;
const active = t.matches(pathname, projectId);
return (
<Link
key={t.id}
href={t.href(projectId)}
role="tab"
aria-selected={isActive}
aria-selected={active}
className={`rounded-md px-3 py-1.5 ${
isActive
active
? "bg-[var(--color-bg)] font-semibold"
: "text-[var(--color-muted)]"
}`}
Expand Down
Loading