Zero-install access to the protocol reference: a fresh `npm i -g claudemesh-cli` user (or someone running the prebuilt binary) can now `claudemesh skill | claude --skill-add -` without copying any files into ~/.claude/skills. The skill markdown is embedded into the CLI bundle at build time via Bun's text-import attribute. Also replaces two `<> ALL(...)` raw SQL fragments in the dashboard unread-count queries with drizzle's notInArray() helper — matches the same fix already applied to /v1/me/topics in the API package. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
254 lines
9.0 KiB
TypeScript
254 lines
9.0 KiB
TypeScript
import Link from "next/link";
|
|
|
|
import { db } from "@turbostarter/db/server";
|
|
import {
|
|
mesh,
|
|
meshMember,
|
|
meshTopic,
|
|
meshTopicMember,
|
|
meshTopicMessage,
|
|
} from "@turbostarter/db/schema/mesh";
|
|
import { and, asc, count, eq, inArray, isNull, notInArray, or, sql } from "drizzle-orm";
|
|
|
|
import { pathsConfig } from "~/config/paths";
|
|
import { getSession } from "~/lib/auth/server";
|
|
import { getMetadata } from "~/lib/metadata";
|
|
import { Reveal } from "~/modules/dashboard/universe/reveal";
|
|
|
|
export const generateMetadata = getMetadata({
|
|
title: "Topics",
|
|
description: "Every topic across every mesh — sorted by activity.",
|
|
});
|
|
|
|
const formatRelative = (iso: string | null) => {
|
|
if (!iso) return "never";
|
|
const sec = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000));
|
|
if (sec < 60) return `${sec}s ago`;
|
|
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
|
if (sec < 86_400) return `${Math.floor(sec / 3600)}h ago`;
|
|
if (sec < 86_400 * 30) return `${Math.floor(sec / 86_400)}d ago`;
|
|
if (sec < 86_400 * 365) return `${Math.floor(sec / (86_400 * 30))}mo ago`;
|
|
return `${Math.floor(sec / (86_400 * 365))}y ago`;
|
|
};
|
|
|
|
export default async function WorkspaceTopicsPage() {
|
|
const { user } = await getSession();
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
// Resolve every active membership for this user → list of (memberId, mesh).
|
|
const memberships = await db
|
|
.select({
|
|
memberId: meshMember.id,
|
|
meshId: meshMember.meshId,
|
|
meshSlug: mesh.slug,
|
|
meshName: mesh.name,
|
|
})
|
|
.from(meshMember)
|
|
.innerJoin(mesh, eq(mesh.id, meshMember.meshId))
|
|
.where(
|
|
and(
|
|
eq(meshMember.userId, user.id),
|
|
isNull(meshMember.revokedAt),
|
|
isNull(mesh.archivedAt),
|
|
),
|
|
);
|
|
|
|
const meshIds = memberships.map((m) => m.meshId);
|
|
const myMemberIds = memberships.map((m) => m.memberId);
|
|
const memberByMeshId = new Map(memberships.map((m) => [m.meshId, m]));
|
|
|
|
const topics = meshIds.length
|
|
? await db
|
|
.select({
|
|
id: meshTopic.id,
|
|
meshId: meshTopic.meshId,
|
|
name: meshTopic.name,
|
|
description: meshTopic.description,
|
|
createdAt: meshTopic.createdAt,
|
|
})
|
|
.from(meshTopic)
|
|
.where(
|
|
and(inArray(meshTopic.meshId, meshIds), isNull(meshTopic.archivedAt)),
|
|
)
|
|
.orderBy(asc(meshTopic.name))
|
|
: [];
|
|
|
|
const topicIds = topics.map((t) => t.id);
|
|
|
|
const lastMessages = topicIds.length
|
|
? await db
|
|
.select({
|
|
topicId: meshTopicMessage.topicId,
|
|
lastAt: sql<Date>`max(${meshTopicMessage.createdAt})`,
|
|
})
|
|
.from(meshTopicMessage)
|
|
.where(inArray(meshTopicMessage.topicId, topicIds))
|
|
.groupBy(meshTopicMessage.topicId)
|
|
: [];
|
|
const lastByTopic = new Map(lastMessages.map((r) => [r.topicId, r.lastAt]));
|
|
|
|
const unreadCounts =
|
|
topicIds.length && myMemberIds.length
|
|
? await db
|
|
.select({
|
|
topicId: meshTopicMessage.topicId,
|
|
n: count(meshTopicMessage.id),
|
|
})
|
|
.from(meshTopicMessage)
|
|
.leftJoin(
|
|
meshTopicMember,
|
|
and(
|
|
eq(meshTopicMember.topicId, meshTopicMessage.topicId),
|
|
inArray(meshTopicMember.memberId, myMemberIds),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
inArray(meshTopicMessage.topicId, topicIds),
|
|
notInArray(meshTopicMessage.senderMemberId, myMemberIds),
|
|
or(
|
|
isNull(meshTopicMember.lastReadAt),
|
|
sql`${meshTopicMessage.createdAt} > ${meshTopicMember.lastReadAt}`,
|
|
),
|
|
),
|
|
)
|
|
.groupBy(meshTopicMessage.topicId)
|
|
: [];
|
|
const unreadByTopic = new Map(unreadCounts.map((r) => [r.topicId, Number(r.n)]));
|
|
|
|
const items = topics
|
|
.map((t) => {
|
|
const m = memberByMeshId.get(t.meshId)!;
|
|
const lastAt = lastByTopic.get(t.id);
|
|
return {
|
|
...t,
|
|
meshSlug: m.meshSlug,
|
|
meshName: m.meshName,
|
|
unread: unreadByTopic.get(t.id) ?? 0,
|
|
lastMessageAt: lastAt ? new Date(lastAt).toISOString() : null,
|
|
};
|
|
})
|
|
.sort((a, b) => {
|
|
if (a.lastMessageAt && b.lastMessageAt) {
|
|
return b.lastMessageAt.localeCompare(a.lastMessageAt);
|
|
}
|
|
if (a.lastMessageAt) return -1;
|
|
if (b.lastMessageAt) return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
const totalUnread = items.reduce((acc, t) => acc + t.unread, 0);
|
|
|
|
return (
|
|
<div className="@container relative h-full p-6 md:p-10">
|
|
<div
|
|
aria-hidden
|
|
className="pointer-events-none absolute inset-0 z-0"
|
|
style={{
|
|
background:
|
|
"radial-gradient(ellipse 70% 50% at 85% -5%, rgba(188,209,202,0.08), transparent 70%)",
|
|
}}
|
|
/>
|
|
<div className="relative z-10 mx-auto max-w-[1100px]">
|
|
<header className="mb-10 grid gap-6 border-b border-[var(--cm-border-soft,rgba(217,119,87,0.1))] pb-8 md:mb-14 md:grid-cols-[1fr_auto] md:items-end md:pb-10">
|
|
<Reveal delay={0}>
|
|
<h1
|
|
className="text-[clamp(2rem,1.6rem+2.5vw,3.25rem)] leading-[1.05] tracking-tight"
|
|
style={{ fontFamily: "var(--cm-font-serif)", fontWeight: 400 }}
|
|
>
|
|
Every <span className="italic text-[var(--cm-clay)]">topic</span>,
|
|
<br />
|
|
<span className="italic text-[var(--cm-fg-tertiary)]">across every</span>{" "}
|
|
mesh.
|
|
</h1>
|
|
</Reveal>
|
|
|
|
<Reveal delay={1}>
|
|
<div className="flex items-baseline gap-6 font-mono text-[12px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]">
|
|
<span>
|
|
<span className="mr-2 text-[var(--cm-fg)]">{items.length}</span>
|
|
topics
|
|
</span>
|
|
<span>
|
|
<span
|
|
className={`mr-2 ${totalUnread > 0 ? "text-[var(--cm-clay)]" : "text-[var(--cm-fg)]"}`}
|
|
>
|
|
{totalUnread}
|
|
</span>
|
|
unread
|
|
</span>
|
|
<span>
|
|
<span className="mr-2 text-[var(--cm-fg)]">{memberships.length}</span>
|
|
meshes
|
|
</span>
|
|
</div>
|
|
</Reveal>
|
|
</header>
|
|
|
|
{items.length === 0 ? (
|
|
<p className="text-[var(--cm-fg-secondary)]">
|
|
No topics yet.{" "}
|
|
<Link
|
|
href={pathsConfig.dashboard.user.meshes.index}
|
|
className="text-[var(--cm-clay)] underline-offset-4 hover:underline"
|
|
>
|
|
Open a mesh
|
|
</Link>{" "}
|
|
to start one.
|
|
</p>
|
|
) : (
|
|
<ul className="divide-y divide-[var(--cm-border-soft,rgba(217,119,87,0.1))] border-y border-[var(--cm-border-soft,rgba(217,119,87,0.1))]">
|
|
{items.map((t, i) => (
|
|
<Reveal key={t.id} delay={Math.min(i, 8)}>
|
|
<li>
|
|
<Link
|
|
href={pathsConfig.dashboard.user.meshes.topic(t.meshId, t.name)}
|
|
className="group flex items-center gap-5 px-2 py-4 transition-colors duration-200 hover:bg-[var(--cm-bg-hover)]"
|
|
>
|
|
<span className="flex w-32 shrink-0 items-center font-mono text-[11px] uppercase tracking-[0.16em] text-[var(--cm-fg-tertiary)]">
|
|
{t.meshSlug}
|
|
</span>
|
|
|
|
<span className="flex min-w-0 flex-1 items-baseline gap-3">
|
|
<span
|
|
className="truncate text-[18px] tracking-tight text-[var(--cm-fg)] group-hover:text-[var(--cm-clay)]"
|
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
|
>
|
|
{t.name}
|
|
</span>
|
|
{t.description ? (
|
|
<span className="hidden truncate text-[13px] text-[var(--cm-fg-tertiary)] md:inline">
|
|
{t.description}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
|
|
<span className="w-24 shrink-0 text-right">
|
|
{t.unread > 0 ? (
|
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-[rgba(217,119,87,0.4)] bg-[rgba(217,119,87,0.08)] px-2.5 py-0.5 font-mono text-[11px] text-[var(--cm-clay)]">
|
|
<span className="size-[6px] rounded-full bg-[var(--cm-clay)]" />
|
|
{t.unread}
|
|
</span>
|
|
) : (
|
|
<span className="font-mono text-[11px] text-[var(--cm-fg-tertiary)]">
|
|
·
|
|
</span>
|
|
)}
|
|
</span>
|
|
|
|
<span className="w-20 shrink-0 text-right font-mono text-[11px] text-[var(--cm-fg-tertiary)]">
|
|
{formatRelative(t.lastMessageAt)}
|
|
</span>
|
|
</Link>
|
|
</li>
|
|
</Reveal>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|