feat(web): topic discoverability — counts on cards + inline creation
Two UX wins for the v0.2.0 chat surface:
- Mesh cards on /dashboard now show topic count alongside members and
tier ("3 MEMBERS · 2 TOPICS · FREE"). Active topics render in clay,
zero in tertiary. One aggregate query, not N+1.
- Mesh detail page replaces the CLI-hint empty state with an inline
CreateTopicForm. Non-empty topic lists get a compact "+ new topic"
pill in the section header. Server action validates name format
(lowercase letters/digits/dashes, 1-50 chars), inserts via the
unique (meshId, name) index, auto-subscribes the creator as topic
lead, then redirects into the chat.
Sidebar audit — kept platform/manage/dev structure as is. Topics are
mesh-scoped so a top-level "topics" entry would have nothing to land
on without a mesh chosen first. Discoverability lives on the mesh
cards instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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({
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border">
|
||||
<header className="flex items-center justify-between border-b px-4 py-3">
|
||||
<header className="flex items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<h2 className="font-medium">
|
||||
Topics{" "}
|
||||
<span className="text-muted-foreground">({topics.length})</span>
|
||||
</h2>
|
||||
{topics.length > 0 ? (
|
||||
<CreateTopicForm meshId={mesh.id} variant="compact" />
|
||||
) : null}
|
||||
</header>
|
||||
{topics.length === 0 ? (
|
||||
<p className="text-muted-foreground px-4 py-8 text-center text-sm">
|
||||
No topics yet. Run{" "}
|
||||
<code className="bg-muted rounded px-1.5 py-0.5 text-xs">
|
||||
claudemesh topic create <name>
|
||||
</code>{" "}
|
||||
from the CLI.
|
||||
<div className="px-4 py-6">
|
||||
<p
|
||||
className="mb-4 text-center text-[11px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
no topics yet · create one to start a conversation
|
||||
</p>
|
||||
<CreateTopicForm meshId={mesh.id} variant="inline" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{topics.map((t) => (
|
||||
<Link
|
||||
key={t.id}
|
||||
href={pathsConfig.dashboard.user.meshes.topic(mesh.id, t.name)}
|
||||
className="hover:bg-muted/50 flex flex-col gap-1.5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-3"
|
||||
className="group flex flex-col gap-1.5 px-4 py-3 transition-colors hover:bg-[var(--cm-bg-hover)] sm:flex-row sm:items-center sm:justify-between sm:gap-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<span className="font-medium">
|
||||
<span className="text-muted-foreground">#</span>
|
||||
<span className="text-[var(--cm-clay)]">#</span>
|
||||
{t.name}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t.visibility}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{t.description ? (
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
<span className="text-muted-foreground max-w-[40ch] truncate text-xs">
|
||||
{t.description}
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className="text-[11px] text-[var(--cm-fg-tertiary)] transition-transform group-hover:translate-x-0.5"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
open →
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="@container relative h-full p-6 md:p-10">
|
||||
{/* Subtle radial backdrop, matching marketing hero */}
|
||||
@@ -65,7 +86,7 @@ export default async function UniversePage() {
|
||||
appBaseUrl={appConfig.url ?? "https://claudemesh.com"}
|
||||
/>
|
||||
|
||||
<MeshesGrid meshes={activeMeshes} />
|
||||
<MeshesGrid meshes={meshesWithTopics} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 = ({
|
||||
<div className="mt-auto flex items-center justify-between border-t border-[var(--cm-border-soft,rgba(217,119,87,0.1))] pt-3 font-mono text-[11px] tracking-[0.04em] text-[var(--cm-fg-tertiary)]">
|
||||
<span className={mesh.memberCount > 0 ? "text-[var(--cm-cactus)]" : ""}>
|
||||
{mesh.memberCount} {mesh.memberCount === 1 ? "MEMBER" : "MEMBERS"}
|
||||
{mesh.topicCount !== undefined ? (
|
||||
<>
|
||||
{" · "}
|
||||
<span
|
||||
className={
|
||||
mesh.topicCount > 0
|
||||
? "text-[var(--cm-clay)]"
|
||||
: "text-[var(--cm-fg-tertiary)]"
|
||||
}
|
||||
>
|
||||
{mesh.topicCount}{" "}
|
||||
{mesh.topicCount === 1 ? "TOPIC" : "TOPICS"}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{" · "}
|
||||
<span className="uppercase">{mesh.tier}</span>
|
||||
</span>
|
||||
|
||||
120
apps/web/src/modules/mesh/create-topic-form.tsx
Normal file
120
apps/web/src/modules/mesh/create-topic-form.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
if (variant === "compact" && !open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-sm border border-[var(--cm-border)] bg-transparent px-2.5 py-1 font-mono text-[11px] tracking-[0.04em] text-[var(--cm-fg-secondary)] transition-colors hover:border-[var(--cm-border-hover)] hover:text-[var(--cm-clay)]"
|
||||
>
|
||||
<span className="text-[var(--cm-clay)]">+</span> new topic
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
action={(fd) => {
|
||||
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"
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-stretch">
|
||||
<div className="relative flex-1">
|
||||
<span
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--cm-clay)]"
|
||||
style={monoStyle}
|
||||
>
|
||||
#
|
||||
</span>
|
||||
<Input
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="deploys"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
required
|
||||
className="pl-7 font-mono"
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
name="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="what's this topic for? (optional)"
|
||||
autoComplete="off"
|
||||
className="flex-[2]"
|
||||
disabled={pending}
|
||||
/>
|
||||
<Button type="submit" disabled={pending || !name.trim()}>
|
||||
{pending ? "creating…" : "create"}
|
||||
</Button>
|
||||
{variant === "compact" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setName("");
|
||||
setDescription("");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? (
|
||||
<p
|
||||
className="text-[10px] text-[#c46686]"
|
||||
style={monoStyle}
|
||||
>
|
||||
error · {error}
|
||||
</p>
|
||||
) : (
|
||||
<p
|
||||
className="text-[10px] text-[var(--cm-fg-tertiary)]"
|
||||
style={monoStyle}
|
||||
>
|
||||
name · lowercase, digits, dashes only · 1–50 chars
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
112
apps/web/src/modules/mesh/topic-actions.ts
Normal file
112
apps/web/src/modules/mesh/topic-actions.ts
Normal file
@@ -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<void> {
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user