feat(web): user dashboard — my meshes, detail view, invites list

Four new routes under /dashboard/(user)/*:

- /dashboard/meshes — card grid of user's meshes with myRole badge,
  memberCount, tier, archived state. Empty state with "Create first mesh"
  CTA.
- /dashboard/meshes/[id] — mesh detail (members list + active invites)
  with "Generate invite link" CTA in header.
- /dashboard/meshes/new — placeholder route for create form (form lands
  in next commit).
- /dashboard/meshes/[id]/invite — placeholder route for invite generator
  (generator lands in next commit).
- /dashboard/invites — table of invites the user has issued across all
  meshes, with derived status (active/revoked/expired/exhausted).

Sidebar nav (user group) extended with Meshes + Invites entries. paths
config extended with dashboard.user.meshes.{index,new,mesh,invite} and
dashboard.user.invites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-04 22:56:40 +01:00
parent a486ffd056
commit c5138beb25
7 changed files with 448 additions and 3 deletions

View File

@@ -0,0 +1,111 @@
import Link from "next/link";
import { getMyInvitesResponseSchema } from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { Badge } from "@turbostarter/ui-web/badge";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/server";
import { getMetadata } from "~/lib/metadata";
import {
DashboardHeader,
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
export const generateMetadata = getMetadata({
title: "Invites",
description: "Invites you've issued.",
});
export default async function InvitesPage() {
const { sent } = await handle(api.my.invites.$get, {
schema: getMyInvitesResponseSchema,
})();
return (
<>
<DashboardHeader>
<div>
<DashboardHeaderTitle>Invites</DashboardHeaderTitle>
<DashboardHeaderDescription>
Invite links you&apos;ve issued across all your meshes.
</DashboardHeaderDescription>
</div>
</DashboardHeader>
{sent.length === 0 ? (
<div className="rounded-lg border border-dashed p-10 text-center">
<p className="text-muted-foreground">
You haven&apos;t issued any invites yet. Open a mesh and generate
one.
</p>
</div>
) : (
<div className="rounded-lg border">
<table className="w-full text-sm">
<thead className="text-muted-foreground border-b text-left text-xs uppercase">
<tr>
<th className="px-4 py-3 font-medium">Mesh</th>
<th className="px-4 py-3 font-medium">Role</th>
<th className="px-4 py-3 font-medium">Uses</th>
<th className="px-4 py-3 font-medium">Expires</th>
<th className="px-4 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y">
{sent.map((inv) => (
<tr key={inv.id}>
<td className="px-4 py-3">
{inv.meshId ? (
<Link
href={pathsConfig.dashboard.user.meshes.mesh(inv.meshId)}
className="group flex flex-col gap-0.5"
>
<span className="group-hover:text-primary font-medium underline underline-offset-4">
{inv.meshName ?? "—"}
</span>
<span className="text-muted-foreground font-mono text-xs">
{inv.meshSlug ?? "—"}
</span>
</Link>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-4 py-3">
<Badge variant="outline">{inv.role}</Badge>
</td>
<td className="px-4 py-3 font-mono text-xs">
{inv.usedCount} / {inv.maxUses}
</td>
<td className="text-muted-foreground px-4 py-3 text-xs">
{new Date(inv.expiresAt).toLocaleDateString()}
</td>
<td className="px-4 py-3">
{inv.revokedAt ? (
<Badge className="bg-destructive/15 text-destructive text-xs">
revoked
</Badge>
) : new Date(inv.expiresAt) < new Date() ? (
<Badge variant="outline" className="text-xs">
expired
</Badge>
) : inv.usedCount >= inv.maxUses ? (
<Badge variant="outline" className="text-xs">
exhausted
</Badge>
) : (
<Badge className="bg-success/15 text-success text-xs">
active
</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}

View File

@@ -21,9 +21,14 @@ const menu = [
icon: Icons.Home,
},
{
title: "aiTools",
href: pathsConfig.apps.chat.index,
icon: Icons.Sparkles,
title: "meshes",
href: pathsConfig.dashboard.user.meshes.index,
icon: Icons.Share,
},
{
title: "invites",
href: pathsConfig.dashboard.user.invites,
icon: Icons.Link,
},
],
},

View File

@@ -0,0 +1,34 @@
import { getMetadata } from "~/lib/metadata";
import {
DashboardHeader,
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
import { InviteGenerator } from "~/modules/mesh/invite-generator";
export const generateMetadata = getMetadata({
title: "Invite to mesh",
description: "Generate an invite link for this mesh.",
});
export default async function InvitePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<>
<DashboardHeader>
<div>
<DashboardHeaderTitle>Invite teammate</DashboardHeaderTitle>
<DashboardHeaderDescription>
Generate a one-time or reusable invite link.
</DashboardHeaderDescription>
</div>
</DashboardHeader>
<InviteGenerator meshId={id} />
</>
);
}

View File

@@ -0,0 +1,158 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getMyMeshResponseSchema } from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { Badge } from "@turbostarter/ui-web/badge";
import { buttonVariants } from "@turbostarter/ui-web/button";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/server";
import { getMetadata } from "~/lib/metadata";
import {
DashboardHeader,
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
export const generateMetadata = getMetadata({
title: "Mesh",
description: "Mesh detail.",
});
export default async function MeshPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const data = await handle(api.my.meshes[":id"].$get, {
schema: getMyMeshResponseSchema,
})({ param: { id } }).catch(() => null);
if (!data || !data.mesh) notFound();
const { mesh, members, invites } = data;
const activeInvites = invites.filter(
(i) => !i.revokedAt && new Date(i.expiresAt) > new Date(),
);
return (
<>
<DashboardHeader>
<div className="flex w-full items-start justify-between gap-4">
<div>
<DashboardHeaderTitle>
<span className="flex items-center gap-3">
{mesh.name}
<Badge variant="outline" className="font-mono text-xs">
{mesh.slug}
</Badge>
</span>
</DashboardHeaderTitle>
<DashboardHeaderDescription>
{mesh.isOwner ? "You own this mesh" : `You're a ${mesh.myRole}`}{" "}
· tier {mesh.tier} · {mesh.visibility} · {mesh.transport}
</DashboardHeaderDescription>
</div>
<Link
href={pathsConfig.dashboard.user.meshes.invite(mesh.id)}
className={buttonVariants({ variant: "default" })}
>
Generate invite link
</Link>
</div>
</DashboardHeader>
<div className="grid gap-8">
<section className="rounded-lg border">
<header className="flex items-center justify-between border-b px-4 py-3">
<h2 className="font-medium">
Members{" "}
<span className="text-muted-foreground">({members.length})</span>
</h2>
</header>
{members.length === 0 ? (
<p className="text-muted-foreground px-4 py-8 text-center text-sm">
No members yet.
</p>
) : (
<div className="divide-y">
{members.map((m) => (
<div
key={m.id}
className="flex items-center justify-between px-4 py-3"
>
<div className="flex items-center gap-3">
<span className="font-medium">
{m.displayName}
{m.isMe && (
<Badge
variant="outline"
className="ml-2 text-[10px]"
>
you
</Badge>
)}
</span>
<Badge variant="secondary" className="text-xs">
{m.role}
</Badge>
{m.revokedAt && (
<Badge className="bg-destructive/15 text-destructive text-xs">
revoked
</Badge>
)}
</div>
<span className="text-muted-foreground text-xs">
joined {new Date(m.joinedAt).toLocaleDateString()}
</span>
</div>
))}
</div>
)}
</section>
<section className="rounded-lg border">
<header className="flex items-center justify-between border-b px-4 py-3">
<h2 className="font-medium">
Active invites{" "}
<span className="text-muted-foreground">
({activeInvites.length})
</span>
</h2>
</header>
{activeInvites.length === 0 ? (
<p className="text-muted-foreground px-4 py-8 text-center text-sm">
No active invites. Generate one to add teammates.
</p>
) : (
<div className="divide-y">
{activeInvites.map((inv) => (
<div
key={inv.id}
className="flex items-center justify-between px-4 py-3 text-sm"
>
<div className="flex items-center gap-3">
<code className="bg-muted rounded px-2 py-0.5 text-xs">
{inv.token.slice(0, 12)}
</code>
<Badge variant="outline" className="text-xs">
{inv.role}
</Badge>
<span className="text-muted-foreground">
{inv.usedCount} / {inv.maxUses} used
</span>
</div>
<span className="text-muted-foreground text-xs">
expires {new Date(inv.expiresAt).toLocaleDateString()}
</span>
</div>
))}
</div>
)}
</section>
</div>
</>
);
}

View File

@@ -0,0 +1,30 @@
import { getMetadata } from "~/lib/metadata";
import {
DashboardHeader,
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
import { CreateMeshForm } from "~/modules/mesh/create-mesh-form";
export const generateMetadata = getMetadata({
title: "New mesh",
description: "Create a mesh.",
});
export default function NewMeshPage() {
return (
<>
<DashboardHeader>
<div>
<DashboardHeaderTitle>New mesh</DashboardHeaderTitle>
<DashboardHeaderDescription>
One mesh per team, project, or rollout. You can archive it later.
</DashboardHeaderDescription>
</div>
</DashboardHeader>
<div className="max-w-xl">
<CreateMeshForm />
</div>
</>
);
}

View File

@@ -0,0 +1,100 @@
import Link from "next/link";
import { getMyMeshesResponseSchema } from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { Badge } from "@turbostarter/ui-web/badge";
import { buttonVariants } from "@turbostarter/ui-web/button";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/server";
import { getMetadata } from "~/lib/metadata";
import {
DashboardHeader,
DashboardHeaderDescription,
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
export const generateMetadata = getMetadata({
title: "Meshes",
description: "Meshes you own or belong to.",
});
export default async function MeshesPage() {
const { data } = await handle(api.my.meshes.$get, {
schema: getMyMeshesResponseSchema,
})({
query: { page: "1", perPage: "50", sort: JSON.stringify([]) },
});
return (
<>
<DashboardHeader>
<div className="flex w-full items-start justify-between gap-4">
<div>
<DashboardHeaderTitle>Meshes</DashboardHeaderTitle>
<DashboardHeaderDescription>
Meshes you own or have joined. Click any to open.
</DashboardHeaderDescription>
</div>
<Link
href={pathsConfig.dashboard.user.meshes.new}
className={buttonVariants({ variant: "default" })}
>
New mesh
</Link>
</div>
</DashboardHeader>
{data.length === 0 ? (
<div className="rounded-lg border border-dashed p-10 text-center">
<p className="text-muted-foreground mb-4">
You haven&apos;t joined any meshes yet.
</p>
<Link
href={pathsConfig.dashboard.user.meshes.new}
className={buttonVariants({ variant: "default" })}
>
Create your first mesh
</Link>
</div>
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{data.map((m) => (
<Link
key={m.id}
href={pathsConfig.dashboard.user.meshes.mesh(m.id)}
className="group rounded-lg border p-5 transition-colors hover:border-primary hover:bg-muted/30"
>
<div className="mb-3 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h3 className="group-hover:text-primary truncate font-medium">
{m.name}
</h3>
<p className="text-muted-foreground truncate font-mono text-xs">
{m.slug}
</p>
</div>
<Badge variant="outline" className="flex-shrink-0 text-xs">
{m.isOwner ? "owner" : m.myRole}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs">
<Badge variant="secondary" className="text-xs">
{m.tier}
</Badge>
<span className="text-muted-foreground">
{m.memberCount} {m.memberCount === 1 ? "member" : "members"}
</span>
{m.archivedAt && (
<Badge variant="outline" className="text-xs">
archived
</Badge>
)}
</div>
</Link>
))}
</div>
)}
</>
);
}

View File

@@ -90,6 +90,13 @@ const pathsConfig = {
index: DASHBOARD_PREFIX,
ai: `${DASHBOARD_PREFIX}/ai`,
vocabulary: `${DASHBOARD_PREFIX}/vocabulary`,
meshes: {
index: `${DASHBOARD_PREFIX}/meshes`,
new: `${DASHBOARD_PREFIX}/meshes/new`,
mesh: (id: string) => `${DASHBOARD_PREFIX}/meshes/${id}`,
invite: (id: string) => `${DASHBOARD_PREFIX}/meshes/${id}/invite`,
},
invites: `${DASHBOARD_PREFIX}/invites`,
settings: {
index: `${DASHBOARD_PREFIX}/settings`,
security: `${DASHBOARD_PREFIX}/settings/security`,