feat(web): universe dashboard — meshes + incoming invitations
New /dashboard landing that surfaces meshes and invitations-to-you in one view. Replaces the simple mesh grid at /dashboard (preserved at /dashboard/legacy). Backend additions: - GET /api/my/invites/incoming — pending_invite rows addressed to the authed user's email, joined with invite for role + expiry and user/mesh for display. Unaccepted + unrevoked + unexpired only. - DELETE /api/my/invites/incoming/:id — dismiss a pending invite (revokes the pending_invite row only; underlying invite code stays valid so the inviter can re-send). Web additions (all under apps/web/src/modules/dashboard/universe/): - welcome.tsx — editorial serif header with mesh + invite counts - invitations.tsx — client card with Accept (→ /i/:code claim flow) and optimistic Decline - meshes-grid.tsx — hero card + compact grid, linked to mesh detail - reveal.tsx — fade-up motion matching marketing _reveal.tsx Styling uses the existing claudemesh design tokens (--cm-clay, --cm-bg-elevated, Anthropic Sans/Serif/Mono) — nothing redefined. Onboarding redirect (0 meshes → /meshes/new?onboarding=1) preserved, now gated on 0 invitations too so users with pending invites still land on the dashboard. Sidebar icon switched to Atom for the "universe" concept. Standalone prototype saved at prototypes/live-dashboard.html for reference. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@ const menu = [
|
|||||||
{
|
{
|
||||||
title: "dashboard",
|
title: "dashboard",
|
||||||
href: pathsConfig.dashboard.user.index,
|
href: pathsConfig.dashboard.user.index,
|
||||||
icon: Icons.Home,
|
icon: Icons.Atom,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "meshes",
|
title: "meshes",
|
||||||
|
|||||||
79
apps/web/src/app/[locale]/dashboard/(user)/legacy/page.tsx
Normal file
79
apps/web/src/app/[locale]/dashboard/(user)/legacy/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
export const generateMetadata = getMetadata({
|
||||||
|
title: "Legacy dashboard",
|
||||||
|
description: "Simple legacy view of your meshes.",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function LegacyDashboardHomePage() {
|
||||||
|
const { data } = await handle(api.my.meshes.$get, {
|
||||||
|
schema: getMyMeshesResponseSchema,
|
||||||
|
})({
|
||||||
|
query: { page: "1", perPage: "6", sort: JSON.stringify([]) },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-medium tracking-tight">Your meshes</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Open one to see its members, generate invites, or share it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{data.map((m) => (
|
||||||
|
<Link
|
||||||
|
key={m.id}
|
||||||
|
href={pathsConfig.dashboard.user.meshes.mesh(m.id)}
|
||||||
|
className="group rounded-lg border p-5 transition-colors hover:border-primary hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="group-hover:text-primary truncate font-medium">
|
||||||
|
{m.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground truncate font-mono text-xs">
|
||||||
|
{m.slug}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{m.isOwner ? "owner" : m.myRole}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{m.tier}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{m.memberCount} {m.memberCount === 1 ? "member" : "members"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Link
|
||||||
|
href={pathsConfig.dashboard.user.meshes.index}
|
||||||
|
className={buttonVariants({ variant: "outline" })}
|
||||||
|
>
|
||||||
|
All meshes
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={pathsConfig.dashboard.user.meshes.new}
|
||||||
|
className={buttonVariants({ variant: "default" })}
|
||||||
|
>
|
||||||
|
New mesh
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,84 +1,71 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { getMyMeshesResponseSchema } from "@turbostarter/api/schema";
|
import {
|
||||||
|
getMyInvitesIncomingResponseSchema,
|
||||||
|
getMyMeshesResponseSchema,
|
||||||
|
} from "@turbostarter/api/schema";
|
||||||
import { handle } from "@turbostarter/api/utils";
|
import { handle } from "@turbostarter/api/utils";
|
||||||
import { Badge } from "@turbostarter/ui-web/badge";
|
|
||||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
|
||||||
|
|
||||||
|
import { appConfig } from "~/config/app";
|
||||||
import { pathsConfig } from "~/config/paths";
|
import { pathsConfig } from "~/config/paths";
|
||||||
import { api } from "~/lib/api/server";
|
import { api } from "~/lib/api/server";
|
||||||
|
import { getSession } from "~/lib/auth/server";
|
||||||
import { getMetadata } from "~/lib/metadata";
|
import { getMetadata } from "~/lib/metadata";
|
||||||
|
import { InvitationsSection } from "~/modules/dashboard/universe/invitations";
|
||||||
|
import { MeshesGrid } from "~/modules/dashboard/universe/meshes-grid";
|
||||||
|
import { UniverseWelcome } from "~/modules/dashboard/universe/welcome";
|
||||||
|
|
||||||
export const generateMetadata = getMetadata({
|
export const generateMetadata = getMetadata({
|
||||||
title: "Dashboard",
|
title: "Your universe",
|
||||||
description: "Your meshes.",
|
description: "Meshes, peers, and invitations — all in one place.",
|
||||||
});
|
});
|
||||||
|
|
||||||
export default async function DashboardHomePage() {
|
export default async function UniversePage() {
|
||||||
const { data } = await handle(api.my.meshes.$get, {
|
const { user } = await getSession();
|
||||||
|
const name = user?.name ?? "there";
|
||||||
|
|
||||||
|
const [{ data: meshes }, { incoming }] = await Promise.all([
|
||||||
|
handle(api.my.meshes.$get, {
|
||||||
schema: getMyMeshesResponseSchema,
|
schema: getMyMeshesResponseSchema,
|
||||||
})({
|
})({
|
||||||
query: { page: "1", perPage: "6", sort: JSON.stringify([]) },
|
query: { page: "1", perPage: "50", sort: JSON.stringify([]) },
|
||||||
});
|
}),
|
||||||
|
handle(api.my.invites.incoming.$get, {
|
||||||
|
schema: getMyInvitesIncomingResponseSchema,
|
||||||
|
})(),
|
||||||
|
]);
|
||||||
|
|
||||||
// First-time onboarding: 0-mesh user → bounce to create
|
const activeMeshes = meshes.filter((m) => !m.archivedAt);
|
||||||
if (data.length === 0) {
|
|
||||||
|
// First-time onboarding: brand-new user with nothing waiting → create flow.
|
||||||
|
if (activeMeshes.length === 0 && incoming.length === 0) {
|
||||||
redirect(`${pathsConfig.dashboard.user.meshes.new}?onboarding=1`);
|
redirect(`${pathsConfig.dashboard.user.meshes.new}?onboarding=1`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="@container relative h-full p-6 md:p-10">
|
||||||
<div>
|
{/* Subtle radial backdrop, matching marketing hero */}
|
||||||
<h1 className="text-2xl font-medium tracking-tight">Your meshes</h1>
|
<div
|
||||||
<p className="text-muted-foreground text-sm">
|
aria-hidden
|
||||||
Open one to see its members, generate invites, or share it.
|
className="pointer-events-none absolute inset-0 z-0"
|
||||||
</p>
|
style={{
|
||||||
</div>
|
background:
|
||||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
"radial-gradient(ellipse 70% 50% at 15% -5%, rgba(217,119,87,0.08), transparent 70%)",
|
||||||
{data.map((m) => (
|
}}
|
||||||
<Link
|
/>
|
||||||
key={m.id}
|
<div className="relative z-10 mx-auto max-w-[1400px]">
|
||||||
href={pathsConfig.dashboard.user.meshes.mesh(m.id)}
|
<UniverseWelcome
|
||||||
className="group rounded-lg border p-5 transition-colors hover:border-primary hover:bg-muted/30"
|
name={name}
|
||||||
>
|
meshCount={activeMeshes.length}
|
||||||
<div className="mb-3 flex items-start justify-between gap-2">
|
inviteCount={incoming.length}
|
||||||
<div className="min-w-0 flex-1">
|
/>
|
||||||
<h3 className="group-hover:text-primary truncate font-medium">
|
|
||||||
{m.name}
|
<InvitationsSection
|
||||||
</h3>
|
incoming={incoming}
|
||||||
<p className="text-muted-foreground truncate font-mono text-xs">
|
appBaseUrl={appConfig.url ?? "https://claudemesh.com"}
|
||||||
{m.slug}
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
<MeshesGrid meshes={activeMeshes} />
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{m.isOwner ? "owner" : m.myRole}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 text-xs">
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{m.tier}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{m.memberCount} {m.memberCount === 1 ? "member" : "members"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Link
|
|
||||||
href={pathsConfig.dashboard.user.meshes.index}
|
|
||||||
className={buttonVariants({ variant: "outline" })}
|
|
||||||
>
|
|
||||||
All meshes
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={pathsConfig.dashboard.user.meshes.new}
|
|
||||||
className={buttonVariants({ variant: "default" })}
|
|
||||||
>
|
|
||||||
New mesh
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ const pathsConfig = {
|
|||||||
dashboard: {
|
dashboard: {
|
||||||
user: {
|
user: {
|
||||||
index: DASHBOARD_PREFIX,
|
index: DASHBOARD_PREFIX,
|
||||||
|
legacy: `${DASHBOARD_PREFIX}/legacy`,
|
||||||
ai: `${DASHBOARD_PREFIX}/ai`,
|
ai: `${DASHBOARD_PREFIX}/ai`,
|
||||||
vocabulary: `${DASHBOARD_PREFIX}/vocabulary`,
|
vocabulary: `${DASHBOARD_PREFIX}/vocabulary`,
|
||||||
meshes: {
|
meshes: {
|
||||||
|
|||||||
152
apps/web/src/modules/dashboard/universe/invitations.tsx
Normal file
152
apps/web/src/modules/dashboard/universe/invitations.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Reveal } from "./reveal";
|
||||||
|
|
||||||
|
interface IncomingInvite {
|
||||||
|
id: string;
|
||||||
|
meshId: string;
|
||||||
|
meshName: string | null;
|
||||||
|
meshSlug: string | null;
|
||||||
|
code: string;
|
||||||
|
role: "admin" | "member" | null;
|
||||||
|
expiresAt: string | Date | null;
|
||||||
|
sentAt: string | Date;
|
||||||
|
inviterName: string | null;
|
||||||
|
inviterEmail: string | null;
|
||||||
|
memberCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardStatus = "idle" | "declining" | "declined";
|
||||||
|
|
||||||
|
const formatExpiry = (d: string | Date | null): string => {
|
||||||
|
if (!d) return "NO EXPIRY";
|
||||||
|
const date = typeof d === "string" ? new Date(d) : d;
|
||||||
|
const diffMs = date.getTime() - Date.now();
|
||||||
|
if (diffMs <= 0) return "EXPIRED";
|
||||||
|
const h = Math.floor(diffMs / 36e5);
|
||||||
|
const days = Math.floor(h / 24);
|
||||||
|
const hoursRem = h % 24;
|
||||||
|
if (days > 0) return `EXPIRES IN ${days}D ${hoursRem}H`;
|
||||||
|
return `EXPIRES IN ${h}H`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InvitationsSection = ({
|
||||||
|
incoming,
|
||||||
|
appBaseUrl,
|
||||||
|
}: {
|
||||||
|
incoming: IncomingInvite[];
|
||||||
|
appBaseUrl: string;
|
||||||
|
}) => {
|
||||||
|
const [dismissed, setDismissed] = useState<Record<string, CardStatus>>({});
|
||||||
|
|
||||||
|
const visible = incoming.filter((i) => dismissed[i.id] !== "declined");
|
||||||
|
|
||||||
|
if (visible.length === 0) return null;
|
||||||
|
|
||||||
|
const decline = async (id: string) => {
|
||||||
|
setDismissed((s) => ({ ...s, [id]: "declining" }));
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/my/invites/incoming/${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
setDismissed((s) => ({ ...s, [id]: "declined" }));
|
||||||
|
} catch {
|
||||||
|
setDismissed((s) => ({ ...s, [id]: "idle" }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-14">
|
||||||
|
<Reveal delay={0}>
|
||||||
|
<div className="mb-6 flex items-baseline justify-between gap-6">
|
||||||
|
<h2
|
||||||
|
className="text-[28px] leading-none tracking-tight"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)", fontWeight: 400 }}
|
||||||
|
>
|
||||||
|
Invitations <span className="italic text-[var(--cm-clay)]">waiting</span>
|
||||||
|
</h2>
|
||||||
|
<span className="font-mono text-[11px] uppercase tracking-[0.08em] text-[var(--cm-fg-tertiary)]">
|
||||||
|
{visible.length} pending
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{visible.map((inv, idx) => {
|
||||||
|
const status = dismissed[inv.id] ?? "idle";
|
||||||
|
const inviterLabel =
|
||||||
|
inv.inviterName ?? inv.inviterEmail ?? "someone";
|
||||||
|
const joinHref = `${appBaseUrl}/i/${inv.code}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Reveal key={inv.id} delay={idx + 1}>
|
||||||
|
<article
|
||||||
|
className="group relative overflow-hidden rounded-md border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 pb-5 pt-6 transition-colors duration-300 hover:border-[var(--cm-border-hover)]"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"linear-gradient(180deg, rgba(196,102,134,0.04), transparent 60%)",
|
||||||
|
opacity: status === "declining" ? 0.5 : 1,
|
||||||
|
pointerEvents: status === "declining" ? "none" : "auto",
|
||||||
|
transition: "opacity 0.3s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="absolute left-0 top-0 h-full w-[3px] bg-[var(--cm-fig)]" />
|
||||||
|
|
||||||
|
<div className="mb-1.5 font-mono text-[11px] uppercase tracking-[0.08em] text-[var(--cm-fg-tertiary)]">
|
||||||
|
From ·{" "}
|
||||||
|
<span
|
||||||
|
className="text-[13px] normal-case tracking-normal text-[var(--cm-fig)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
{inviterLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3
|
||||||
|
className="mb-1 text-[22px] leading-tight tracking-tight text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)", fontWeight: 400 }}
|
||||||
|
>
|
||||||
|
Join{" "}
|
||||||
|
<em className="italic text-[var(--cm-clay)]">
|
||||||
|
{inv.meshName ?? inv.meshSlug ?? "a mesh"}
|
||||||
|
</em>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="mb-5 text-[13px] text-[var(--cm-fg-secondary)]">
|
||||||
|
{inv.memberCount}{" "}
|
||||||
|
{inv.memberCount === 1 ? "member" : "members"} · you’d join as{" "}
|
||||||
|
<strong className="font-medium text-[var(--cm-fg)]">
|
||||||
|
{inv.role ?? "member"}
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<a
|
||||||
|
href={joinHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-sm bg-[var(--cm-clay)] px-4 py-2 text-[13px] font-medium text-[var(--cm-gray-050)] transition-colors hover:bg-[var(--cm-clay-hover)]"
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => decline(inv.id)}
|
||||||
|
disabled={status !== "idle"}
|
||||||
|
className="rounded-sm border border-[var(--cm-border)] px-4 py-2 text-[13px] text-[var(--cm-fg-secondary)] transition-colors hover:border-[var(--cm-border-hover)] hover:text-[var(--cm-fg)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{status === "declining" ? "Declining…" : "Decline"}
|
||||||
|
</button>
|
||||||
|
<span className="ml-auto font-mono text-[11px] tracking-wide text-[var(--cm-fg-tertiary)]">
|
||||||
|
{formatExpiry(inv.expiresAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</Reveal>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
210
apps/web/src/modules/dashboard/universe/meshes-grid.tsx
Normal file
210
apps/web/src/modules/dashboard/universe/meshes-grid.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { pathsConfig } from "~/config/paths";
|
||||||
|
|
||||||
|
import { Reveal } from "./reveal";
|
||||||
|
|
||||||
|
interface MeshSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
tier: "free" | "pro" | "team" | "enterprise";
|
||||||
|
myRole: "admin" | "member";
|
||||||
|
isOwner: boolean;
|
||||||
|
memberCount: number;
|
||||||
|
archivedAt: Date | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_CHIPS = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact member-count chips. Real per-session live status would require
|
||||||
|
* polling /stream for each mesh — we show the structure here and defer the
|
||||||
|
* live overlay to the per-mesh live page.
|
||||||
|
*/
|
||||||
|
const MemberChips = ({ count }: { count: number }) => {
|
||||||
|
if (count === 0) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-[var(--cm-border-soft,rgba(217,119,87,0.1))] bg-[var(--cm-bg)] px-2 py-1 text-[11px] text-[var(--cm-fg-tertiary)]">
|
||||||
|
<span className="size-[6px] rounded-full bg-[var(--cm-fg-tertiary)]" />
|
||||||
|
empty
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const shown = Math.min(count, MAX_CHIPS);
|
||||||
|
const extra = count - shown;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: shown }).map((_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--cm-border-soft,rgba(217,119,87,0.1))] bg-[var(--cm-bg)] px-2 py-1 text-[11px] text-[var(--cm-fg-secondary)]"
|
||||||
|
>
|
||||||
|
<span className="size-[6px] rounded-full bg-[var(--cm-cactus)]" />
|
||||||
|
member
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{extra > 0 ? (
|
||||||
|
<span className="px-1 font-mono text-[11px] text-[var(--cm-fg-tertiary)]">
|
||||||
|
+{extra}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleClass = (isOwner: boolean, role: string) => {
|
||||||
|
if (isOwner) return "text-[var(--cm-clay)] border-[rgba(217,119,87,0.4)]";
|
||||||
|
if (role === "admin") return "text-[var(--cm-cactus)] border-[rgba(188,209,202,0.4)]";
|
||||||
|
return "text-[var(--cm-fg-secondary)]";
|
||||||
|
};
|
||||||
|
|
||||||
|
const MeshCard = ({
|
||||||
|
mesh,
|
||||||
|
size = "compact",
|
||||||
|
}: {
|
||||||
|
mesh: MeshSummary;
|
||||||
|
size?: "hero" | "compact";
|
||||||
|
}) => {
|
||||||
|
const isHero = size === "hero";
|
||||||
|
const href = pathsConfig.dashboard.user.meshes.mesh(mesh.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={[
|
||||||
|
"group relative flex flex-col rounded-md border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] transition-colors duration-300 hover:border-[var(--cm-border-hover)] hover:bg-[var(--cm-bg-hover)]",
|
||||||
|
isHero ? "px-8 py-7" : "px-5 py-5",
|
||||||
|
mesh.archivedAt ? "opacity-60" : "",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{isHero ? (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -right-[30%] -top-[30%] h-[120%] w-[70%] opacity-60"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"radial-gradient(ellipse, rgba(217,119,87,0.10), transparent 60%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mb-3 flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3
|
||||||
|
className={[
|
||||||
|
"truncate tracking-tight",
|
||||||
|
isHero ? "text-[34px]" : "text-[20px]",
|
||||||
|
].join(" ")}
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)", fontWeight: 400 }}
|
||||||
|
>
|
||||||
|
{isHero ? (
|
||||||
|
<em className="italic text-[var(--cm-clay)]">{mesh.name}</em>
|
||||||
|
) : (
|
||||||
|
mesh.name
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
className="truncate text-[12px] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
{mesh.slug}
|
||||||
|
{isHero ? ` · id ${mesh.id.slice(0, 8)}…` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"whitespace-nowrap rounded-sm border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.14em]",
|
||||||
|
roleClass(mesh.isOwner, mesh.myRole),
|
||||||
|
"border-[var(--cm-border)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{mesh.archivedAt ? "archived" : mesh.isOwner ? "owner" : mesh.myRole}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-1.5">
|
||||||
|
<MemberChips count={mesh.memberCount} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"}
|
||||||
|
{" · "}
|
||||||
|
<span className="uppercase">{mesh.tier}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--cm-fg-tertiary)] transition-transform duration-300 group-hover:translate-x-0.5">
|
||||||
|
open →
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MeshesGrid = ({ meshes }: { meshes: MeshSummary[] }) => {
|
||||||
|
if (meshes.length === 0) {
|
||||||
|
return (
|
||||||
|
<section className="mb-14">
|
||||||
|
<div className="rounded-md border border-dashed border-[var(--cm-border)] px-10 py-14 text-center">
|
||||||
|
<p className="mb-5 text-[var(--cm-fg-secondary)]">
|
||||||
|
You haven’t joined any meshes yet.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={pathsConfig.dashboard.user.meshes.new}
|
||||||
|
className="rounded-sm bg-[var(--cm-clay)] px-4 py-2 text-[13px] font-medium text-[var(--cm-gray-050)] transition-colors hover:bg-[var(--cm-clay-hover)]"
|
||||||
|
>
|
||||||
|
Create your first mesh
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [hero, ...rest] = meshes;
|
||||||
|
const heroMesh = hero!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-14">
|
||||||
|
<Reveal delay={0}>
|
||||||
|
<div className="mb-6 flex items-baseline justify-between gap-6">
|
||||||
|
<h2
|
||||||
|
className="text-[28px] leading-none tracking-tight"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)", fontWeight: 400 }}
|
||||||
|
>
|
||||||
|
Your <span className="italic text-[var(--cm-clay)]">meshes</span>
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
href={pathsConfig.dashboard.user.meshes.new}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-sm bg-[var(--cm-clay)] px-3 py-1.5 text-[13px] font-medium text-[var(--cm-gray-050)] transition-colors hover:bg-[var(--cm-clay-hover)]"
|
||||||
|
>
|
||||||
|
<span>+</span> New mesh
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1.5fr_1fr]">
|
||||||
|
<Reveal delay={1} className="row-span-2">
|
||||||
|
<MeshCard mesh={heroMesh} size="hero" />
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-1">
|
||||||
|
{rest.slice(0, 2).map((m, i) => (
|
||||||
|
<Reveal key={m.id} delay={i + 2}>
|
||||||
|
<MeshCard mesh={m} />
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rest.length > 2 ? (
|
||||||
|
<div className="col-span-full grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{rest.slice(2).map((m, i) => (
|
||||||
|
<Reveal key={m.id} delay={i + 4}>
|
||||||
|
<MeshCard mesh={m} />
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
38
apps/web/src/modules/dashboard/universe/reveal.tsx
Normal file
38
apps/web/src/modules/dashboard/universe/reveal.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion, type Variants } from "motion/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
const fade: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 20, filter: "blur(4px)" },
|
||||||
|
visible: (i: number = 0) => ({
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
filter: "blur(0px)",
|
||||||
|
transition: {
|
||||||
|
duration: 0.7,
|
||||||
|
ease: [0.22, 0.61, 0.36, 1],
|
||||||
|
delay: i * 0.08,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Reveal = ({
|
||||||
|
children,
|
||||||
|
delay = 0,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
delay?: number;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<motion.div
|
||||||
|
className={className}
|
||||||
|
variants={fade}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
custom={delay}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
77
apps/web/src/modules/dashboard/universe/welcome.tsx
Normal file
77
apps/web/src/modules/dashboard/universe/welcome.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Reveal } from "./reveal";
|
||||||
|
|
||||||
|
interface WelcomeProps {
|
||||||
|
name: string;
|
||||||
|
meshCount: number;
|
||||||
|
inviteCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UniverseWelcome = ({ name, meshCount, inviteCount }: WelcomeProps) => {
|
||||||
|
const inviteLine =
|
||||||
|
inviteCount === 0
|
||||||
|
? null
|
||||||
|
: inviteCount === 1
|
||||||
|
? "1 invitation"
|
||||||
|
: `${inviteCount} invitations`;
|
||||||
|
|
||||||
|
const firstName = name.split(" ")[0] ?? name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="mb-14 grid gap-10 border-b border-[var(--cm-border-soft,rgba(217,119,87,0.1))] pb-10 md:mb-16 md:grid-cols-[1fr_auto] md:items-end md:gap-16 md:pb-14">
|
||||||
|
<div>
|
||||||
|
<Reveal delay={0}>
|
||||||
|
<h1
|
||||||
|
className="text-[clamp(2.25rem,1.8rem+3vw,3.75rem)] leading-[1.02] tracking-tight"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)", fontWeight: 400 }}
|
||||||
|
>
|
||||||
|
Welcome back,{" "}
|
||||||
|
<span className="italic text-[var(--cm-clay)]">{firstName}</span>.
|
||||||
|
<br />
|
||||||
|
<span className="italic text-[var(--cm-fg-tertiary)]">Your universe is</span>{" "}
|
||||||
|
active.
|
||||||
|
</h1>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal delay={1}>
|
||||||
|
<p
|
||||||
|
className="mt-5 max-w-2xl text-[17px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
You own or belong to{" "}
|
||||||
|
<strong className="font-medium text-[var(--cm-fg)]">
|
||||||
|
{meshCount} {meshCount === 1 ? "mesh" : "meshes"}
|
||||||
|
</strong>
|
||||||
|
{inviteLine ? (
|
||||||
|
<>
|
||||||
|
{" "}— and{" "}
|
||||||
|
<strong className="font-medium text-[var(--cm-clay)]">
|
||||||
|
{inviteLine}
|
||||||
|
</strong>{" "}
|
||||||
|
waiting for an answer.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Reveal delay={2}>
|
||||||
|
<div className="text-right font-mono text-[11px] uppercase tracking-[0.08em] text-[var(--cm-fg-tertiary)]">
|
||||||
|
<span
|
||||||
|
className="mb-1 block text-right text-[42px] leading-none text-[var(--cm-fg)] tabular-nums"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)", fontWeight: 400 }}
|
||||||
|
>
|
||||||
|
{meshCount}
|
||||||
|
<span className="not-italic text-[var(--cm-clay)] italic"> / {meshCount + inviteCount}</span>
|
||||||
|
</span>
|
||||||
|
<span className="mt-2 block">
|
||||||
|
<span className="mr-2 inline-block size-[7px] animate-pulse rounded-full bg-[var(--cm-cactus)] align-middle" />
|
||||||
|
meshes · your reach
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block">updated just now</span>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -164,6 +164,38 @@ export const archiveMyMesh = async ({
|
|||||||
return updated;
|
return updated;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decline an incoming pending invite addressed to this user's email.
|
||||||
|
* Marks the pending_invite row as revoked so it no longer surfaces
|
||||||
|
* in /invites/incoming. The underlying short-code invite is NOT revoked
|
||||||
|
* (inviter may re-send), only this user's copy is dismissed.
|
||||||
|
*/
|
||||||
|
export const declineIncomingInvite = async ({
|
||||||
|
email,
|
||||||
|
pendingInviteId,
|
||||||
|
}: {
|
||||||
|
email: string;
|
||||||
|
pendingInviteId: string;
|
||||||
|
}) => {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(pendingInvite)
|
||||||
|
.set({ revokedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pendingInvite.id, pendingInviteId),
|
||||||
|
eq(pendingInvite.email, email),
|
||||||
|
isNull(pendingInvite.acceptedAt),
|
||||||
|
isNull(pendingInvite.revokedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: pendingInvite.id });
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error("Invitation not found or already resolved.");
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
};
|
||||||
|
|
||||||
export const leaveMyMesh = async ({
|
export const leaveMyMesh = async ({
|
||||||
userId,
|
userId,
|
||||||
meshId,
|
meshId,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
desc,
|
desc,
|
||||||
eq,
|
eq,
|
||||||
getOrderByFromSort,
|
getOrderByFromSort,
|
||||||
|
gt,
|
||||||
ilike,
|
ilike,
|
||||||
isNull,
|
isNull,
|
||||||
or,
|
or,
|
||||||
@@ -16,7 +17,9 @@ import {
|
|||||||
mesh,
|
mesh,
|
||||||
meshMember,
|
meshMember,
|
||||||
messageQueue,
|
messageQueue,
|
||||||
|
pendingInvite,
|
||||||
presence,
|
presence,
|
||||||
|
user,
|
||||||
} from "@turbostarter/db/schema";
|
} from "@turbostarter/db/schema";
|
||||||
import { db } from "@turbostarter/db/server";
|
import { db } from "@turbostarter/db/server";
|
||||||
|
|
||||||
@@ -345,6 +348,48 @@ export const getMyExport = async ({ userId }: { userId: string }) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending invitations addressed to this user's email. A pending_invite row is
|
||||||
|
* created when someone calls `claudemesh share <email>`; we join it against the
|
||||||
|
* underlying `invite` row to get role + expiry, and against `user` (inviter)
|
||||||
|
* and `mesh` (target) for display. Returned only when unaccepted, unrevoked,
|
||||||
|
* and not expired.
|
||||||
|
*/
|
||||||
|
export const getMyInvitesIncoming = async ({ email }: { email: string }) => {
|
||||||
|
const now = new Date();
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: pendingInvite.id,
|
||||||
|
meshId: pendingInvite.meshId,
|
||||||
|
meshName: mesh.name,
|
||||||
|
meshSlug: mesh.slug,
|
||||||
|
code: pendingInvite.code,
|
||||||
|
role: invite.role,
|
||||||
|
expiresAt: invite.expiresAt,
|
||||||
|
sentAt: pendingInvite.sentAt,
|
||||||
|
inviterName: user.name,
|
||||||
|
inviterEmail: user.email,
|
||||||
|
memberCount: sql<number>`(
|
||||||
|
SELECT COUNT(*)::int FROM mesh.member
|
||||||
|
WHERE mesh_id = ${pendingInvite.meshId} AND revoked_at IS NULL
|
||||||
|
)`,
|
||||||
|
})
|
||||||
|
.from(pendingInvite)
|
||||||
|
.leftJoin(mesh, eq(pendingInvite.meshId, mesh.id))
|
||||||
|
.leftJoin(invite, eq(pendingInvite.code, invite.code))
|
||||||
|
.leftJoin(user, eq(pendingInvite.createdBy, user.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pendingInvite.email, email),
|
||||||
|
isNull(pendingInvite.acceptedAt),
|
||||||
|
isNull(pendingInvite.revokedAt),
|
||||||
|
or(isNull(invite.expiresAt), gt(invite.expiresAt, now)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(pendingInvite.sentAt))
|
||||||
|
.limit(50);
|
||||||
|
};
|
||||||
|
|
||||||
export const getMyInvitesSent = async ({ userId }: { userId: string }) =>
|
export const getMyInvitesSent = async ({ userId }: { userId: string }) =>
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ import {
|
|||||||
createEmailInvite,
|
createEmailInvite,
|
||||||
createMyInvite,
|
createMyInvite,
|
||||||
createMyMesh,
|
createMyMesh,
|
||||||
|
declineIncomingInvite,
|
||||||
leaveMyMesh,
|
leaveMyMesh,
|
||||||
} from "./mutations";
|
} from "./mutations";
|
||||||
import {
|
import {
|
||||||
getMyExport,
|
getMyExport,
|
||||||
|
getMyInvitesIncoming,
|
||||||
getMyInvitesSent,
|
getMyInvitesSent,
|
||||||
getMyMeshById,
|
getMyMeshById,
|
||||||
getMyMeshStream,
|
getMyMeshStream,
|
||||||
@@ -150,6 +152,29 @@ export const myRouter = new Hono<Env>()
|
|||||||
const user = c.var.user;
|
const user = c.var.user;
|
||||||
return c.json({ sent: await getMyInvitesSent({ userId: user.id }) });
|
return c.json({ sent: await getMyInvitesSent({ userId: user.id }) });
|
||||||
})
|
})
|
||||||
|
.get("/invites/incoming", async (c) => {
|
||||||
|
const user = c.var.user;
|
||||||
|
if (!user.email) return c.json({ incoming: [] });
|
||||||
|
return c.json({
|
||||||
|
incoming: await getMyInvitesIncoming({ email: user.email }),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.delete("/invites/incoming/:id", async (c) => {
|
||||||
|
const user = c.var.user;
|
||||||
|
if (!user.email) return c.json({ error: "No email on session" }, 400);
|
||||||
|
try {
|
||||||
|
await declineIncomingInvite({
|
||||||
|
email: user.email,
|
||||||
|
pendingInviteId: c.req.param("id"),
|
||||||
|
});
|
||||||
|
return c.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
return c.json(
|
||||||
|
{ error: e instanceof Error ? e.message : "Failed to decline." },
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
.get("/export", async (c) => {
|
.get("/export", async (c) => {
|
||||||
const user = c.var.user;
|
const user = c.var.user;
|
||||||
const data = await getMyExport({ userId: user.id });
|
const data = await getMyExport({ userId: user.id });
|
||||||
|
|||||||
@@ -296,3 +296,24 @@ export const getMyInvitesResponseSchema = z.object({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
export type GetMyInvitesResponse = z.infer<typeof getMyInvitesResponseSchema>;
|
export type GetMyInvitesResponse = z.infer<typeof getMyInvitesResponseSchema>;
|
||||||
|
|
||||||
|
export const getMyInvitesIncomingResponseSchema = z.object({
|
||||||
|
incoming: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
meshId: z.string(),
|
||||||
|
meshName: z.string().nullable(),
|
||||||
|
meshSlug: z.string().nullable(),
|
||||||
|
code: z.string(),
|
||||||
|
role: meshRoleEnum.nullable(),
|
||||||
|
expiresAt: z.coerce.date().nullable(),
|
||||||
|
sentAt: z.coerce.date(),
|
||||||
|
inviterName: z.string().nullable(),
|
||||||
|
inviterEmail: z.string().nullable(),
|
||||||
|
memberCount: z.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
export type GetMyInvitesIncomingResponse = z.infer<
|
||||||
|
typeof getMyInvitesIncomingResponseSchema
|
||||||
|
>;
|
||||||
|
|||||||
1001
prototypes/live-dashboard.html
Normal file
1001
prototypes/live-dashboard.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user