Skip to content

Commit d0d5410

Browse files
hotlongCopilot
andcommitted
feat(studio): GitHub/Vercel-style tabs for project detail page
Before, the project detail page was a single ~990-line route that piled overview stats, recent revisions, endpoints, database info, members, credential, hostname editor, visibility editor and a danger zone into one scrolling surface. It read more like a settings dump than a project page — and as we added members/visibility/revisions/etc. it kept getting busier. Refactor to the convention used by GitHub repo pages and Vercel project pages: a shared, sticky header (breadcrumb + name + status badges + Refresh) followed by a tab bar that links to dedicated routes: Overview · Revisions · Packages · Members · Settings What moved: - new apps/studio/src/components/projects/project-header.tsx Renders the breadcrumb, title row (name + status + visibility + project-id chip + Refresh action) and the 5-tab nav. Used by every project-scoped surface so the header stays consistent across tabs. The active tab is driven by an explicit `active` prop rather than route-matching to keep the underline stable during route transitions. - new apps/studio/src/routes/projects.$projectId.members.tsx Members tab. Hosts MembersPanel (invite / role / remove). - new apps/studio/src/routes/projects.$projectId.settings.tsx Settings tab. Hosts the previously-overview sections: Visibility editor (Select with private/public + explainer), Custom domain editor, Credential status + rotate, and the typed-confirmation Danger zone. - edit apps/studio/src/routes/projects.$projectId.index.tsx Overview tab. Slimmed from 991 → ~510 LOC. Drops the inline visibility control / hostname editor / credential card / danger zone (now in Settings) and the MembersPanel (now in Members). Adds the shared ProjectHeader at the top. Keeps: 4 at-a-glance stat cards (current commit, visibility, members count, packages count), recent revisions, endpoints panel + CLI publish hint, compact Project info + Database + Domain summary cards. - edit apps/studio/src/routes/projects.$projectId.revisions.tsx Removed the bespoke header (back button + inline title + visibility badge) in favour of ProjectHeader. The page now opens with the same header layout as every other tab and only scopes its own "Published revisions" sub-heading + table. - edit apps/studio/src/routes/projects.$projectId.packages.tsx Mounts ProjectHeader at the top via a small wrapper that skips rendering for the platform pseudo-project (no real project row exists). - gen apps/studio/src/routeTree.gen.ts (auto-regenerated by the TanStack Router plugin to register the two new routes). Behaviour verified end-to-end in the browser: created a fresh `Tabs Demo` project, confirmed each of the four tabs (Overview / Settings / Members / Revisions) navigates and shows the correct underline highlight. Build + 100/100 tests still green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6594015 commit d0d5410

7 files changed

Lines changed: 1133 additions & 823 deletions
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ProjectHeader — GitHub/Vercel-style project page header.
5+
*
6+
* Renders breadcrumb, title row with status / visibility, and a tab bar
7+
* that links to the project's primary surfaces:
8+
* Overview · Revisions · Packages · Members · Settings
9+
*
10+
* Used by every top-level project route so the header is consistent and
11+
* the user can switch surfaces without jumping back to a hub.
12+
*/
13+
14+
import { Link } from '@tanstack/react-router';
15+
import {
16+
Building2,
17+
ChevronRight,
18+
Copy,
19+
Eye,
20+
History,
21+
Layers,
22+
Lock,
23+
Package,
24+
RefreshCw,
25+
Settings,
26+
Users,
27+
} from 'lucide-react';
28+
import type { ReactNode } from 'react';
29+
import { Badge } from '@/components/ui/badge';
30+
import { Button } from '@/components/ui/button';
31+
import { ProjectStatusBadge } from '@/components/project-status-badge';
32+
import { toast } from '@/hooks/use-toast';
33+
import type {
34+
ProjectDetail,
35+
ProjectRow,
36+
} from '@/hooks/useProjects';
37+
38+
export type ProjectTabKey =
39+
| 'overview'
40+
| 'revisions'
41+
| 'packages'
42+
| 'members'
43+
| 'settings';
44+
45+
interface Props {
46+
projectId: string;
47+
project: ProjectRow;
48+
detail: ProjectDetail | null | undefined;
49+
/** Optional refresh handler; renders the Refresh button when provided. */
50+
onReload?: () => void;
51+
loading?: boolean;
52+
/** Active tab — drives underline highlight. */
53+
active: ProjectTabKey;
54+
/** Extra controls rendered in the title-row right-side. */
55+
rightSlot?: ReactNode;
56+
}
57+
58+
const TABS: Array<{ key: ProjectTabKey; label: string; icon: typeof Eye; to: string }> = [
59+
{ key: 'overview', label: 'Overview', icon: Layers, to: '/projects/$projectId' },
60+
{ key: 'revisions', label: 'Revisions', icon: History, to: '/projects/$projectId/revisions' },
61+
{ key: 'packages', label: 'Packages', icon: Package, to: '/projects/$projectId/packages' },
62+
{ key: 'members', label: 'Members', icon: Users, to: '/projects/$projectId/members' },
63+
{ key: 'settings', label: 'Settings', icon: Settings, to: '/projects/$projectId/settings' },
64+
];
65+
66+
function VisibilityBadge({ visibility }: { visibility: 'private' | 'public' }) {
67+
const variant = visibility === 'public' ? 'default' : 'outline';
68+
return (
69+
<Badge
70+
variant={variant}
71+
className="gap-1 text-xs"
72+
title={
73+
visibility === 'public'
74+
? 'Public — listed and freely downloadable.'
75+
: 'Private — share-by-link. Anonymous downloads need an exact ?commit=.'
76+
}
77+
>
78+
{visibility === 'public' ? <Eye className="h-3 w-3" /> : <Lock className="h-3 w-3" />}
79+
{visibility}
80+
</Badge>
81+
);
82+
}
83+
84+
export function ProjectHeader({
85+
projectId,
86+
project,
87+
detail,
88+
onReload,
89+
loading,
90+
active,
91+
rightSlot,
92+
}: Props) {
93+
const visibilityRaw = ((project as any)?.visibility ?? 'private') as string;
94+
const visibility: 'private' | 'public' = visibilityRaw === 'public' ? 'public' : 'private';
95+
96+
const copyId = async () => {
97+
try {
98+
await navigator.clipboard.writeText(project.id);
99+
toast({ title: 'Project ID copied', description: project.id });
100+
} catch (err) {
101+
toast({
102+
title: 'Copy failed',
103+
description: (err as Error).message,
104+
variant: 'destructive',
105+
});
106+
}
107+
};
108+
109+
return (
110+
<div className="border-b bg-background">
111+
<div className="mx-auto w-full max-w-7xl px-6 pt-5">
112+
{/* Breadcrumb */}
113+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
114+
{detail?.organization && (
115+
<>
116+
<Building2 className="h-3.5 w-3.5" />
117+
<span>{detail.organization.displayName ?? detail.organization.name}</span>
118+
<ChevronRight className="h-3 w-3" />
119+
</>
120+
)}
121+
<Link to="/projects" className="hover:text-foreground">
122+
Projects
123+
</Link>
124+
<ChevronRight className="h-3 w-3" />
125+
<span className="text-foreground">{project.display_name}</span>
126+
</div>
127+
128+
{/* Title row */}
129+
<div className="mt-3 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
130+
<div className="min-w-0 flex-1">
131+
<div className="flex flex-wrap items-center gap-3">
132+
<h1 className="truncate text-2xl font-semibold tracking-tight">
133+
{project.display_name}
134+
</h1>
135+
{project.is_default && <Badge variant="outline">default</Badge>}
136+
<ProjectStatusBadge status={(project.status ?? 'provisioning') as any} />
137+
<VisibilityBadge visibility={visibility} />
138+
</div>
139+
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
140+
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">{project.id}</code>
141+
<button
142+
type="button"
143+
onClick={copyId}
144+
className="inline-flex items-center gap-1 rounded p-1 hover:bg-muted hover:text-foreground"
145+
title="Copy project ID"
146+
>
147+
<Copy className="h-3 w-3" />
148+
</button>
149+
<span>·</span>
150+
<span>Plan {project.plan ?? 'free'}</span>
151+
{project.created_at && (
152+
<>
153+
<span>·</span>
154+
<span>Created {new Date(project.created_at).toLocaleDateString()}</span>
155+
</>
156+
)}
157+
</div>
158+
</div>
159+
<div className="flex flex-wrap items-center gap-2">
160+
{onReload && (
161+
<Button
162+
variant="outline"
163+
size="sm"
164+
onClick={onReload}
165+
disabled={loading}
166+
className="gap-2"
167+
title="Refresh project status"
168+
>
169+
<RefreshCw className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
170+
Refresh
171+
</Button>
172+
)}
173+
{rightSlot}
174+
</div>
175+
</div>
176+
177+
{/* Tab bar */}
178+
<nav className="mt-4 -mb-px flex flex-wrap gap-0 overflow-x-auto" aria-label="Project sections">
179+
{TABS.map((tab) => {
180+
const Icon = tab.icon;
181+
const isActive = tab.key === active;
182+
return (
183+
<Link
184+
key={tab.key}
185+
to={tab.to}
186+
params={{ projectId }}
187+
className={[
188+
'group inline-flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm transition-colors',
189+
isActive
190+
? 'border-foreground text-foreground'
191+
: 'border-transparent text-muted-foreground hover:border-muted-foreground/40 hover:text-foreground',
192+
].join(' ')}
193+
>
194+
<Icon className="h-3.5 w-3.5" />
195+
{tab.label}
196+
</Link>
197+
);
198+
})}
199+
</nav>
200+
</div>
201+
</div>
202+
);
203+
}

apps/studio/src/routeTree.gen.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ import { Route as OrganizationsNewRouteImport } from './routes/organizations.new
2424
import { Route as OrganizationsOrgIdRouteImport } from './routes/organizations.$orgId'
2525
import { Route as AuthDeviceRouteImport } from './routes/auth.device'
2626
import { Route as ProjectsProjectIdIndexRouteImport } from './routes/projects.$projectId.index'
27+
import { Route as ProjectsProjectIdSettingsRouteImport } from './routes/projects.$projectId.settings'
2728
import { Route as ProjectsProjectIdRevisionsRouteImport } from './routes/projects.$projectId.revisions'
2829
import { Route as ProjectsProjectIdPackagesRouteImport } from './routes/projects.$projectId.packages'
30+
import { Route as ProjectsProjectIdMembersRouteImport } from './routes/projects.$projectId.members'
2931
import { Route as ProjectsProjectIdApiConsoleRouteImport } from './routes/projects.$projectId.api-console'
3032
import { Route as ProjectsProjectIdPackageRouteImport } from './routes/projects.$projectId.$package'
3133
import { Route as PackageObjectsNameRouteImport } from './routes/$package.objects.$name'
@@ -109,6 +111,12 @@ const ProjectsProjectIdIndexRoute = ProjectsProjectIdIndexRouteImport.update({
109111
path: '/',
110112
getParentRoute: () => ProjectsProjectIdRoute,
111113
} as any)
114+
const ProjectsProjectIdSettingsRoute =
115+
ProjectsProjectIdSettingsRouteImport.update({
116+
id: '/settings',
117+
path: '/settings',
118+
getParentRoute: () => ProjectsProjectIdRoute,
119+
} as any)
112120
const ProjectsProjectIdRevisionsRoute =
113121
ProjectsProjectIdRevisionsRouteImport.update({
114122
id: '/revisions',
@@ -121,6 +129,12 @@ const ProjectsProjectIdPackagesRoute =
121129
path: '/packages',
122130
getParentRoute: () => ProjectsProjectIdRoute,
123131
} as any)
132+
const ProjectsProjectIdMembersRoute =
133+
ProjectsProjectIdMembersRouteImport.update({
134+
id: '/members',
135+
path: '/members',
136+
getParentRoute: () => ProjectsProjectIdRoute,
137+
} as any)
124138
const ProjectsProjectIdApiConsoleRoute =
125139
ProjectsProjectIdApiConsoleRouteImport.update({
126140
id: '/api-console',
@@ -180,8 +194,10 @@ export interface FileRoutesByFullPath {
180194
'/$package/objects/$name': typeof PackageObjectsNameRoute
181195
'/projects/$projectId/$package': typeof ProjectsProjectIdPackageRouteWithChildren
182196
'/projects/$projectId/api-console': typeof ProjectsProjectIdApiConsoleRoute
197+
'/projects/$projectId/members': typeof ProjectsProjectIdMembersRoute
183198
'/projects/$projectId/packages': typeof ProjectsProjectIdPackagesRoute
184199
'/projects/$projectId/revisions': typeof ProjectsProjectIdRevisionsRoute
200+
'/projects/$projectId/settings': typeof ProjectsProjectIdSettingsRoute
185201
'/projects/$projectId/': typeof ProjectsProjectIdIndexRoute
186202
'/$package/metadata/$type/$name': typeof PackageMetadataTypeNameRoute
187203
'/projects/$projectId/$package/': typeof ProjectsProjectIdPackageIndexRoute
@@ -203,8 +219,10 @@ export interface FileRoutesByTo {
203219
'/projects': typeof ProjectsIndexRoute
204220
'/$package/objects/$name': typeof PackageObjectsNameRoute
205221
'/projects/$projectId/api-console': typeof ProjectsProjectIdApiConsoleRoute
222+
'/projects/$projectId/members': typeof ProjectsProjectIdMembersRoute
206223
'/projects/$projectId/packages': typeof ProjectsProjectIdPackagesRoute
207224
'/projects/$projectId/revisions': typeof ProjectsProjectIdRevisionsRoute
225+
'/projects/$projectId/settings': typeof ProjectsProjectIdSettingsRoute
208226
'/projects/$projectId': typeof ProjectsProjectIdIndexRoute
209227
'/$package/metadata/$type/$name': typeof PackageMetadataTypeNameRoute
210228
'/projects/$projectId/$package': typeof ProjectsProjectIdPackageIndexRoute
@@ -230,8 +248,10 @@ export interface FileRoutesById {
230248
'/$package/objects/$name': typeof PackageObjectsNameRoute
231249
'/projects/$projectId/$package': typeof ProjectsProjectIdPackageRouteWithChildren
232250
'/projects/$projectId/api-console': typeof ProjectsProjectIdApiConsoleRoute
251+
'/projects/$projectId/members': typeof ProjectsProjectIdMembersRoute
233252
'/projects/$projectId/packages': typeof ProjectsProjectIdPackagesRoute
234253
'/projects/$projectId/revisions': typeof ProjectsProjectIdRevisionsRoute
254+
'/projects/$projectId/settings': typeof ProjectsProjectIdSettingsRoute
235255
'/projects/$projectId/': typeof ProjectsProjectIdIndexRoute
236256
'/$package/metadata/$type/$name': typeof PackageMetadataTypeNameRoute
237257
'/projects/$projectId/$package/': typeof ProjectsProjectIdPackageIndexRoute
@@ -258,8 +278,10 @@ export interface FileRouteTypes {
258278
| '/$package/objects/$name'
259279
| '/projects/$projectId/$package'
260280
| '/projects/$projectId/api-console'
281+
| '/projects/$projectId/members'
261282
| '/projects/$projectId/packages'
262283
| '/projects/$projectId/revisions'
284+
| '/projects/$projectId/settings'
263285
| '/projects/$projectId/'
264286
| '/$package/metadata/$type/$name'
265287
| '/projects/$projectId/$package/'
@@ -281,8 +303,10 @@ export interface FileRouteTypes {
281303
| '/projects'
282304
| '/$package/objects/$name'
283305
| '/projects/$projectId/api-console'
306+
| '/projects/$projectId/members'
284307
| '/projects/$projectId/packages'
285308
| '/projects/$projectId/revisions'
309+
| '/projects/$projectId/settings'
286310
| '/projects/$projectId'
287311
| '/$package/metadata/$type/$name'
288312
| '/projects/$projectId/$package'
@@ -307,8 +331,10 @@ export interface FileRouteTypes {
307331
| '/$package/objects/$name'
308332
| '/projects/$projectId/$package'
309333
| '/projects/$projectId/api-console'
334+
| '/projects/$projectId/members'
310335
| '/projects/$projectId/packages'
311336
| '/projects/$projectId/revisions'
337+
| '/projects/$projectId/settings'
312338
| '/projects/$projectId/'
313339
| '/$package/metadata/$type/$name'
314340
| '/projects/$projectId/$package/'
@@ -439,6 +465,13 @@ declare module '@tanstack/react-router' {
439465
preLoaderRoute: typeof ProjectsProjectIdIndexRouteImport
440466
parentRoute: typeof ProjectsProjectIdRoute
441467
}
468+
'/projects/$projectId/settings': {
469+
id: '/projects/$projectId/settings'
470+
path: '/settings'
471+
fullPath: '/projects/$projectId/settings'
472+
preLoaderRoute: typeof ProjectsProjectIdSettingsRouteImport
473+
parentRoute: typeof ProjectsProjectIdRoute
474+
}
442475
'/projects/$projectId/revisions': {
443476
id: '/projects/$projectId/revisions'
444477
path: '/revisions'
@@ -453,6 +486,13 @@ declare module '@tanstack/react-router' {
453486
preLoaderRoute: typeof ProjectsProjectIdPackagesRouteImport
454487
parentRoute: typeof ProjectsProjectIdRoute
455488
}
489+
'/projects/$projectId/members': {
490+
id: '/projects/$projectId/members'
491+
path: '/members'
492+
fullPath: '/projects/$projectId/members'
493+
preLoaderRoute: typeof ProjectsProjectIdMembersRouteImport
494+
parentRoute: typeof ProjectsProjectIdRoute
495+
}
456496
'/projects/$projectId/api-console': {
457497
id: '/projects/$projectId/api-console'
458498
path: '/api-console'
@@ -543,16 +583,20 @@ const ProjectsProjectIdPackageRouteWithChildren =
543583
interface ProjectsProjectIdRouteChildren {
544584
ProjectsProjectIdPackageRoute: typeof ProjectsProjectIdPackageRouteWithChildren
545585
ProjectsProjectIdApiConsoleRoute: typeof ProjectsProjectIdApiConsoleRoute
586+
ProjectsProjectIdMembersRoute: typeof ProjectsProjectIdMembersRoute
546587
ProjectsProjectIdPackagesRoute: typeof ProjectsProjectIdPackagesRoute
547588
ProjectsProjectIdRevisionsRoute: typeof ProjectsProjectIdRevisionsRoute
589+
ProjectsProjectIdSettingsRoute: typeof ProjectsProjectIdSettingsRoute
548590
ProjectsProjectIdIndexRoute: typeof ProjectsProjectIdIndexRoute
549591
}
550592

551593
const ProjectsProjectIdRouteChildren: ProjectsProjectIdRouteChildren = {
552594
ProjectsProjectIdPackageRoute: ProjectsProjectIdPackageRouteWithChildren,
553595
ProjectsProjectIdApiConsoleRoute: ProjectsProjectIdApiConsoleRoute,
596+
ProjectsProjectIdMembersRoute: ProjectsProjectIdMembersRoute,
554597
ProjectsProjectIdPackagesRoute: ProjectsProjectIdPackagesRoute,
555598
ProjectsProjectIdRevisionsRoute: ProjectsProjectIdRevisionsRoute,
599+
ProjectsProjectIdSettingsRoute: ProjectsProjectIdSettingsRoute,
556600
ProjectsProjectIdIndexRoute: ProjectsProjectIdIndexRoute,
557601
}
558602

0 commit comments

Comments
 (0)