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 index f8be2c9..dba2f9e 100644 --- a/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/page.tsx +++ b/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/page.tsx @@ -17,6 +17,7 @@ import { DashboardHeaderDescription, DashboardHeaderTitle, } from "~/modules/common/layout/dashboard/header"; +import { CreateTopicForm } from "~/modules/mesh/create-topic-form"; export const generateMetadata = getMetadata({ title: "Mesh", @@ -144,42 +145,55 @@ export default async function MeshPage({
-
+

Topics{" "} ({topics.length})

+ {topics.length > 0 ? ( + + ) : null}
{topics.length === 0 ? ( -

- No topics yet. Run{" "} - - claudemesh topic create <name> - {" "} - from the CLI. -

+
+

+ no topics yet · create one to start a conversation +

+ +
) : (
{topics.map((t) => (
- # + # {t.name} {t.visibility}
- {t.description ? ( - - {t.description} +
+ {t.description ? ( + + {t.description} + + ) : null} + + open → - ) : null} +
))}
diff --git a/apps/web/src/app/[locale]/dashboard/(user)/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/page.tsx index 394bd7a..5fa9182 100644 --- a/apps/web/src/app/[locale]/dashboard/(user)/page.tsx +++ b/apps/web/src/app/[locale]/dashboard/(user)/page.tsx @@ -5,6 +5,9 @@ import { getMyMeshesResponseSchema, } from "@turbostarter/api/schema"; import { handle } from "@turbostarter/api/utils"; +import { db } from "@turbostarter/db/server"; +import { meshTopic } from "@turbostarter/db/schema/mesh"; +import { and, count, inArray, isNull } from "drizzle-orm"; import { appConfig } from "~/config/app"; import { pathsConfig } from "~/config/paths"; @@ -42,6 +45,24 @@ export default async function UniversePage() { redirect(`${pathsConfig.dashboard.user.meshes.new}?onboarding=1`); } + // Decorate each mesh with its non-archived topic count so MeshesGrid + // can show "X TOPICS" inline. One aggregate query, not N+1. + const meshIds = activeMeshes.map((m) => m.id); + const topicCounts = meshIds.length + ? await db + .select({ meshId: meshTopic.meshId, n: count() }) + .from(meshTopic) + .where( + and(inArray(meshTopic.meshId, meshIds), isNull(meshTopic.archivedAt)), + ) + .groupBy(meshTopic.meshId) + : []; + const topicMap = new Map(topicCounts.map((r) => [r.meshId, Number(r.n)])); + const meshesWithTopics = activeMeshes.map((m) => ({ + ...m, + topicCount: topicMap.get(m.id) ?? 0, + })); + return (
{/* Subtle radial backdrop, matching marketing hero */} @@ -65,7 +86,7 @@ export default async function UniversePage() { appBaseUrl={appConfig.url ?? "https://claudemesh.com"} /> - +
); diff --git a/apps/web/src/modules/dashboard/universe/meshes-grid.tsx b/apps/web/src/modules/dashboard/universe/meshes-grid.tsx index c9c73e2..d375824 100644 --- a/apps/web/src/modules/dashboard/universe/meshes-grid.tsx +++ b/apps/web/src/modules/dashboard/universe/meshes-grid.tsx @@ -12,6 +12,7 @@ interface MeshSummary { myRole: "admin" | "member"; isOwner: boolean; memberCount: number; + topicCount?: number; archivedAt: Date | string | null; } @@ -130,6 +131,21 @@ const MeshCard = ({
0 ? "text-[var(--cm-cactus)]" : ""}> {mesh.memberCount} {mesh.memberCount === 1 ? "MEMBER" : "MEMBERS"} + {mesh.topicCount !== undefined ? ( + <> + {" · "} + 0 + ? "text-[var(--cm-clay)]" + : "text-[var(--cm-fg-tertiary)]" + } + > + {mesh.topicCount}{" "} + {mesh.topicCount === 1 ? "TOPIC" : "TOPICS"} + + + ) : null} {" · "} {mesh.tier} diff --git a/apps/web/src/modules/mesh/create-topic-form.tsx b/apps/web/src/modules/mesh/create-topic-form.tsx new file mode 100644 index 0000000..fcc8b9b --- /dev/null +++ b/apps/web/src/modules/mesh/create-topic-form.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useState, useTransition } from "react"; + +import { Button } from "@turbostarter/ui-web/button"; +import { Input } from "@turbostarter/ui-web/input"; + +import { createTopic } from "./topic-actions"; + +const monoStyle = { fontFamily: "var(--cm-font-mono)" } as const; + +interface Props { + meshId: string; + /** "inline" — sits inside the empty-state card. "compact" — header pill. */ + variant?: "inline" | "compact"; +} + +export function CreateTopicForm({ meshId, variant = "inline" }: Props) { + const [open, setOpen] = useState(variant === "inline"); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + if (variant === "compact" && !open) { + return ( + + ); + } + + return ( +
{ + setError(null); + startTransition(async () => { + try { + await createTopic(meshId, fd); + } catch (e) { + setError((e as Error).message); + } + }); + }} + className={ + variant === "inline" + ? "flex flex-col gap-3" + : "flex flex-col gap-3 rounded-md border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/40 p-4" + } + > +
+
+ + # + + setName(e.target.value)} + placeholder="deploys" + autoComplete="off" + spellCheck={false} + required + className="pl-7 font-mono" + disabled={pending} + /> +
+ setDescription(e.target.value)} + placeholder="what's this topic for? (optional)" + autoComplete="off" + className="flex-[2]" + disabled={pending} + /> + + {variant === "compact" ? ( + + ) : null} +
+ {error ? ( +

+ error · {error} +

+ ) : ( +

+ name · lowercase, digits, dashes only · 1–50 chars +

+ )} +
+ ); +} diff --git a/apps/web/src/modules/mesh/topic-actions.ts b/apps/web/src/modules/mesh/topic-actions.ts new file mode 100644 index 0000000..9db2a25 --- /dev/null +++ b/apps/web/src/modules/mesh/topic-actions.ts @@ -0,0 +1,112 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; + +import { db } from "@turbostarter/db/server"; +import { + mesh, + meshMember, + meshTopic, + meshTopicMember, +} from "@turbostarter/db/schema/mesh"; +import { and, asc, eq, isNull } from "drizzle-orm"; + +import { pathsConfig } from "~/config/paths"; +import { getSession } from "~/lib/auth/server"; + +const TOPIC_NAME_RX = /^[a-z0-9][a-z0-9-]{0,49}$/; + +/** + * Server action: create a topic in a mesh. + * + * The caller must own or be a non-revoked member of the mesh. The newly + * created topic auto-subscribes the creator (rows in mesh.topic_member), + * matching the CLI verb's behavior. On success the page revalidates and + * the user is redirected into the topic chat. + */ +export async function createTopic( + meshId: string, + formData: FormData, +): Promise { + const session = await getSession(); + if (!session?.user?.id) redirect(pathsConfig.auth.login); + + const rawName = String(formData.get("name") ?? "").trim(); + const description = + String(formData.get("description") ?? "").trim() || null; + + const name = rawName.replace(/^#+/, "").toLowerCase(); + if (!TOPIC_NAME_RX.test(name)) { + throw new Error( + "Topic name must be 1-50 characters: lowercase letters, digits, dashes; cannot start with a dash.", + ); + } + + // Authz — own or member. + const [meshRow] = await db + .select({ id: mesh.id, ownerUserId: mesh.ownerUserId }) + .from(mesh) + .where(eq(mesh.id, meshId)) + .limit(1); + if (!meshRow) throw new Error("Mesh not found."); + + const isOwner = meshRow.ownerUserId === session.user.id; + let memberId: string | null = null; + if (isOwner) { + const [m] = await db + .select({ id: meshMember.id }) + .from(meshMember) + .where(and(eq(meshMember.meshId, meshId), isNull(meshMember.revokedAt))) + .orderBy(asc(meshMember.joinedAt)) + .limit(1); + memberId = m?.id ?? null; + } else { + const [m] = await db + .select({ id: meshMember.id }) + .from(meshMember) + .where( + and( + eq(meshMember.meshId, meshId), + eq(meshMember.userId, session.user.id), + isNull(meshMember.revokedAt), + ), + ) + .limit(1); + memberId = m?.id ?? null; + } + if (!memberId) throw new Error("You are not a member of this mesh."); + + // Insert. Unique index on (meshId, name) handles dup detection. + let topicId: string; + try { + const [row] = await db + .insert(meshTopic) + .values({ + meshId, + name, + description, + createdByMemberId: memberId, + visibility: "public", + }) + .returning({ id: meshTopic.id }); + if (!row) throw new Error("Insert returned no row."); + topicId = row.id; + } catch (e) { + const msg = (e as { message?: string }).message ?? ""; + if (msg.includes("topic_mesh_name_unique") || msg.includes("duplicate")) { + throw new Error(`A topic named #${name} already exists in this mesh.`); + } + throw e; + } + + // Auto-subscribe the creator. Idempotent via the unique index. + await db + .insert(meshTopicMember) + .values({ topicId, memberId, role: "lead" }) + .onConflictDoNothing(); + + revalidatePath(pathsConfig.dashboard.user.meshes.mesh(meshId)); + revalidatePath(pathsConfig.dashboard.user.index); + redirect(pathsConfig.dashboard.user.meshes.topic(meshId, name)); +}