diff --git a/apps/web/src/app/[locale]/dashboard/(user)/invites/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/invites/page.tsx
new file mode 100644
index 0000000..90f2fe5
--- /dev/null
+++ b/apps/web/src/app/[locale]/dashboard/(user)/invites/page.tsx
@@ -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 (
+ <>
+
+
+ Invites
+
+ Invite links you've issued across all your meshes.
+
+
+
+ {sent.length === 0 ? (
+
+
+ You haven't issued any invites yet. Open a mesh and generate
+ one.
+
+
+ ) : (
+
+
+
+
+ | Mesh |
+ Role |
+ Uses |
+ Expires |
+ Status |
+
+
+
+ {sent.map((inv) => (
+
+ |
+ {inv.meshId ? (
+
+
+ {inv.meshName ?? "—"}
+
+
+ {inv.meshSlug ?? "—"}
+
+
+ ) : (
+ —
+ )}
+ |
+
+ {inv.role}
+ |
+
+ {inv.usedCount} / {inv.maxUses}
+ |
+
+ {new Date(inv.expiresAt).toLocaleDateString()}
+ |
+
+ {inv.revokedAt ? (
+
+ revoked
+
+ ) : new Date(inv.expiresAt) < new Date() ? (
+
+ expired
+
+ ) : inv.usedCount >= inv.maxUses ? (
+
+ exhausted
+
+ ) : (
+
+ active
+
+ )}
+ |
+
+ ))}
+
+
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx b/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx
index d4d9118..8e5bd04 100644
--- a/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx
+++ b/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx
@@ -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,
},
],
},
diff --git a/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/invite/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/invite/page.tsx
new file mode 100644
index 0000000..5b507c8
--- /dev/null
+++ b/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/invite/page.tsx
@@ -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 (
+ <>
+
+
+ Invite teammate
+
+ Generate a one-time or reusable invite link.
+
+
+
+
+ >
+ );
+}
diff --git a/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/page.tsx
new file mode 100644
index 0000000..1b1b2cd
--- /dev/null
+++ b/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/page.tsx
@@ -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 (
+ <>
+
+
+
+
+
+ {mesh.name}
+
+ {mesh.slug}
+
+
+
+
+ {mesh.isOwner ? "You own this mesh" : `You're a ${mesh.myRole}`}{" "}
+ · tier {mesh.tier} · {mesh.visibility} · {mesh.transport}
+
+
+
+ Generate invite link
+
+
+
+
+
+
+
+
+ Members{" "}
+ ({members.length})
+
+
+ {members.length === 0 ? (
+
+ No members yet.
+
+ ) : (
+
+ {members.map((m) => (
+
+
+
+ {m.displayName}
+ {m.isMe && (
+
+ you
+
+ )}
+
+
+ {m.role}
+
+ {m.revokedAt && (
+
+ revoked
+
+ )}
+
+
+ joined {new Date(m.joinedAt).toLocaleDateString()}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ Active invites{" "}
+
+ ({activeInvites.length})
+
+
+
+ {activeInvites.length === 0 ? (
+
+ No active invites. Generate one to add teammates.
+
+ ) : (
+
+ {activeInvites.map((inv) => (
+
+
+
+ {inv.token.slice(0, 12)}…
+
+
+ {inv.role}
+
+
+ {inv.usedCount} / {inv.maxUses} used
+
+
+
+ expires {new Date(inv.expiresAt).toLocaleDateString()}
+
+
+ ))}
+
+ )}
+
+
+ >
+ );
+}
diff --git a/apps/web/src/app/[locale]/dashboard/(user)/meshes/new/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/meshes/new/page.tsx
new file mode 100644
index 0000000..df94daa
--- /dev/null
+++ b/apps/web/src/app/[locale]/dashboard/(user)/meshes/new/page.tsx
@@ -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 (
+ <>
+
+
+ New mesh
+
+ One mesh per team, project, or rollout. You can archive it later.
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/web/src/app/[locale]/dashboard/(user)/meshes/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/meshes/page.tsx
new file mode 100644
index 0000000..2209c7c
--- /dev/null
+++ b/apps/web/src/app/[locale]/dashboard/(user)/meshes/page.tsx
@@ -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 (
+ <>
+
+
+
+ Meshes
+
+ Meshes you own or have joined. Click any to open.
+
+
+
+ New mesh
+
+
+
+
+ {data.length === 0 ? (
+
+
+ You haven't joined any meshes yet.
+
+
+ Create your first mesh
+
+
+ ) : (
+
+ {data.map((m) => (
+
+
+
+
+ {m.name}
+
+
+ {m.slug}
+
+
+
+ {m.isOwner ? "owner" : m.myRole}
+
+
+
+
+ {m.tier}
+
+
+ {m.memberCount} {m.memberCount === 1 ? "member" : "members"}
+
+ {m.archivedAt && (
+
+ archived
+
+ )}
+
+
+ ))}
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/src/config/paths.ts b/apps/web/src/config/paths.ts
index 8df1407..1bf056c 100644
--- a/apps/web/src/config/paths.ts
+++ b/apps/web/src/config/paths.ts
@@ -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`,