From c5138beb258fffe9020b0426ac6828c43ad7127d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:56:40 +0100 Subject: [PATCH] =?UTF-8?q?feat(web):=20user=20dashboard=20=E2=80=94=20my?= =?UTF-8?q?=20meshes,=20detail=20view,=20invites=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../dashboard/(user)/invites/page.tsx | 111 ++++++++++++ .../app/[locale]/dashboard/(user)/layout.tsx | 11 +- .../(user)/meshes/[id]/invite/page.tsx | 34 ++++ .../dashboard/(user)/meshes/[id]/page.tsx | 158 ++++++++++++++++++ .../dashboard/(user)/meshes/new/page.tsx | 30 ++++ .../[locale]/dashboard/(user)/meshes/page.tsx | 100 +++++++++++ apps/web/src/config/paths.ts | 7 + 7 files changed, 448 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/app/[locale]/dashboard/(user)/invites/page.tsx create mode 100644 apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/invite/page.tsx create mode 100644 apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/page.tsx create mode 100644 apps/web/src/app/[locale]/dashboard/(user)/meshes/new/page.tsx create mode 100644 apps/web/src/app/[locale]/dashboard/(user)/meshes/page.tsx 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. +

+
+ ) : ( +
+ + + + + + + + + + + + {sent.map((inv) => ( + + + + + + + + ))} + +
MeshRoleUsesExpiresStatus
+ {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`,