feat(web): topic discoverability — counts on cards + inline creation
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

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:
Alejandro Gutiérrez
2026-05-02 16:27:19 +01:00
parent c801afd2ab
commit f727620d16
5 changed files with 298 additions and 15 deletions

View File

@@ -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>

View 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 · 150 chars
</p>
)}
</form>
);
}

View 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));
}