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:
111
apps/web/src/app/[locale]/dashboard/(user)/invites/page.tsx
Normal file
111
apps/web/src/app/[locale]/dashboard/(user)/invites/page.tsx
Normal 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'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'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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,9 +21,14 @@ const menu = [
|
|||||||
icon: Icons.Home,
|
icon: Icons.Home,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "aiTools",
|
title: "meshes",
|
||||||
href: pathsConfig.apps.chat.index,
|
href: pathsConfig.dashboard.user.meshes.index,
|
||||||
icon: Icons.Sparkles,
|
icon: Icons.Share,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "invites",
|
||||||
|
href: pathsConfig.dashboard.user.invites,
|
||||||
|
icon: Icons.Link,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/page.tsx
Normal file
158
apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
apps/web/src/app/[locale]/dashboard/(user)/meshes/page.tsx
Normal file
100
apps/web/src/app/[locale]/dashboard/(user)/meshes/page.tsx
Normal 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'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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -90,6 +90,13 @@ const pathsConfig = {
|
|||||||
index: DASHBOARD_PREFIX,
|
index: DASHBOARD_PREFIX,
|
||||||
ai: `${DASHBOARD_PREFIX}/ai`,
|
ai: `${DASHBOARD_PREFIX}/ai`,
|
||||||
vocabulary: `${DASHBOARD_PREFIX}/vocabulary`,
|
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: {
|
settings: {
|
||||||
index: `${DASHBOARD_PREFIX}/settings`,
|
index: `${DASHBOARD_PREFIX}/settings`,
|
||||||
security: `${DASHBOARD_PREFIX}/settings/security`,
|
security: `${DASHBOARD_PREFIX}/settings/security`,
|
||||||
|
|||||||
Reference in New Issue
Block a user