Files
podman/frontend/src/App.tsx
T

520 lines
16 KiB
TypeScript

import { useEffect, useState } from 'react';
import {
Show,
SignUp,
SignInButton,
SignUpButton,
UserButton,
useAuth,
useUser,
} from '@clerk/react';
import type { Room } from 'livekit-client';
import { AlertCircleIcon, RefreshCwIcon, SparklesIcon } from 'lucide-react';
import type { Pod, PodInput } from '@podman/shared';
import { joinPod } from './lib/pod.js';
import * as api from './lib/api.js';
import { PodCard } from './components/PodCard.js';
import { CreatePodForm } from './components/CreatePodForm.js';
import { PodView } from './components/PodView.js';
import { GraphView } from './components/GraphView.js';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
import { Skeleton } from '@/components/ui/skeleton';
const SESSION_KEY = 'podman.session';
function pathPodId(): string | null {
const [segment] = window.location.pathname.split('/').filter(Boolean);
return segment ? decodeURIComponent(segment) : null;
}
function setPodPath(podId: string, replace = false): void {
const next = `/${encodeURIComponent(podId)}`;
if (window.location.pathname === next) return;
window.history[replace ? 'replaceState' : 'pushState']({}, '', next);
}
function setHomePath(): void {
if (window.location.pathname === '/') return;
window.history.pushState({}, '', '/');
}
function replacePath(path: string): void {
window.history.replaceState({}, '', path || '/');
}
function firstNameFrom(value: string | null | undefined): string {
return value?.trim().split(/\s+/).filter(Boolean)[0] ?? '';
}
export default function App() {
const { getToken, isLoaded, isSignedIn } = useAuth();
const { user } = useUser();
const [pods, setPods] = useState<Pod[]>([]);
const [loading, setLoading] = useState(true);
const [pending, setPending] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
const [presence, setPresence] = useState<Record<string, string[]>>({});
const [joinedPodId, setJoinedPodId] = useState<string | null>(null);
const [member, setMember] = useState('');
const [devMode, setDevMode] = useState(false);
const [room, setRoom] = useState<Room | null>(null);
const [restoring, setRestoring] = useState(false);
const [graphPodId, setGraphPodId] = useState<string | null>(null);
const joinedPod = joinedPodId ? (pods.find((p) => p.id === joinedPodId) ?? null) : null;
const userEmail = user?.primaryEmailAddress?.emailAddress;
const defaultMemberName =
user?.firstName?.trim() ||
firstNameFrom(user?.fullName) ||
firstNameFrom(userEmail?.split('@')[0]);
const currentUserProfile = {
displayName: defaultMemberName,
email: userEmail,
imageUrl: user?.imageUrl,
};
useEffect(() => {
api.setAuthTokenGetter(isSignedIn ? getToken : null);
return () => api.setAuthTokenGetter(null);
}, [getToken, isSignedIn]);
async function refresh() {
setLoading(true);
try {
const [nextPods, nextPresence] = await Promise.all([
api.listPods(),
api.getPresence().catch(() => presence),
]);
setPods(nextPods);
setPresence(nextPresence);
setError(null);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}
const startPending = (key: string) => setPending((s) => new Set(s).add(key));
const endPending = (key: string) =>
setPending((s) => {
const n = new Set(s);
n.delete(key);
return n;
});
async function connectToPod(podId: string, who: string, replaceRoute = false) {
const previousPath = window.location.pathname;
setPodPath(podId, replaceRoute);
try {
const result = await joinPod(
podId,
who,
who,
isSignedIn ? getToken : undefined,
currentUserProfile,
);
setRoom(result.room);
setDevMode(result.mode === 'dev');
setMember(who);
setJoinedPodId(podId);
sessionStorage.setItem(SESSION_KEY, JSON.stringify({ podId, member: who }));
} catch (e) {
replacePath(previousPath);
throw e;
}
}
useEffect(() => {
if (!isSignedIn) {
setLoading(false);
return;
}
void refresh();
}, [isSignedIn]);
useEffect(() => {
if (!isSignedIn || joinedPodId) return;
let alive = true;
const tick = async () => {
try {
const p = await api.getPresence();
if (alive) {
setPresence(p);
}
} catch {
/* presence is best-effort */
}
};
void tick();
const id = window.setInterval(() => void tick(), 5000);
return () => {
alive = false;
window.clearInterval(id);
};
}, [isSignedIn, joinedPodId]);
useEffect(() => {
if (!isSignedIn) return;
const routedPodId = pathPodId();
const raw = sessionStorage.getItem(SESSION_KEY);
if (!raw) {
return;
}
let saved: { podId: string; member: string };
try {
saved = JSON.parse(raw);
} catch {
sessionStorage.removeItem(SESSION_KEY);
return;
}
const podId = routedPodId ?? saved.podId;
setRestoring(true);
void (async () => {
try {
await connectToPod(podId, saved.member, !!routedPodId);
} catch {
sessionStorage.removeItem(SESSION_KEY);
} finally {
setRestoring(false);
}
})();
}, [isSignedIn]);
useEffect(() => {
const onPopState = () => {
if (!isSignedIn) return;
const routedPodId = pathPodId();
if (!routedPodId) {
room?.disconnect();
setRoom(null);
setJoinedPodId(null);
return;
}
const raw = sessionStorage.getItem(SESSION_KEY);
if (!raw) return;
try {
const saved = JSON.parse(raw) as { member: string };
if (saved.member && routedPodId !== joinedPodId) {
void connectToPod(routedPodId, saved.member, true);
}
} catch {
sessionStorage.removeItem(SESSION_KEY);
}
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, [isSignedIn, joinedPodId, room]);
async function run(key: string, fn: () => Promise<void>) {
startPending(key);
setError(null);
try {
await fn();
} catch (e) {
setError((e as Error).message);
} finally {
endPending(key);
}
}
const upsert = (p: Pod) => setPods((cur) => cur.map((x) => (x.id === p.id ? p : x)));
async function handleCreate(input: PodInput): Promise<void> {
startPending('new');
setError(null);
try {
const created = await api.createPod(input, currentUserProfile);
setPods((cur) => [...cur, created]);
} catch (e) {
setError((e as Error).message);
throw e;
} finally {
endPending('new');
}
}
const handleUpdate = (id: string, patch: PodInput) =>
run(id, async () => upsert(await api.updatePod(id, patch)));
const handleDelete = (id: string) =>
run(id, async () => {
await api.deletePod(id);
setPods((cur) => cur.filter((x) => x.id !== id));
if (pathPodId() === id) setHomePath();
});
const handleAddMember = (id: string, name: string) =>
run(id, async () => upsert(await api.addMember(id, name, currentUserProfile)));
const handleRemoveMember = (id: string, name: string) =>
run(id, async () => upsert(await api.removeMember(id, name)));
async function handleJoin(pod: Pod, who: string) {
startPending(pod.id);
setError(null);
try {
await connectToPod(pod.id, who);
} catch (e) {
setError((e as Error).message);
} finally {
endPending(pod.id);
}
}
async function handleAddAndJoin(pod: Pod) {
startPending(pod.id);
setError(null);
try {
if (!defaultMemberName) {
throw new Error('Sign in with Clerk before joining a pod.');
}
upsert(await api.addMember(pod.id, defaultMemberName, currentUserProfile));
await connectToPod(pod.id, defaultMemberName);
} catch (e) {
setError((e as Error).message);
} finally {
endPending(pod.id);
}
}
function handleLeave() {
room?.disconnect();
setRoom(null);
setJoinedPodId(null);
sessionStorage.removeItem(SESSION_KEY);
setHomePath();
void refresh();
}
const showReconnecting = restoring || (joinedPodId !== null && joinedPod === null);
if (!isLoaded) {
return (
<div className="grid min-h-screen place-items-center bg-background text-foreground">
<Skeleton className="h-12 w-64" />
</div>
);
}
if (!isSignedIn) {
return <AuthGate />;
}
if (joinedPod) {
return (
<PodView
team={joinedPod}
me={member}
room={room}
devMode={devMode}
currentUserProfile={currentUserProfile}
onLeave={handleLeave}
/>
);
}
if (graphPodId) {
return <GraphView podId={graphPodId} onClose={() => setGraphPodId(null)} />;
}
return (
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto flex min-h-screen w-full max-w-[1440px] flex-col gap-6 px-4 py-4 sm:px-6 lg:px-8">
<header className="sticky top-0 z-10 -mx-4 border-b bg-background/86 px-4 pb-4 pt-2 backdrop-blur-xl sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<img
src="/podman-logo.svg"
alt=""
className="size-11 shrink-0 rounded-lg"
aria-hidden="true"
/>
<div className="min-w-0">
<h1 className="text-[1.95rem] font-semibold leading-none tracking-tight">PodMan</h1>
</div>
</div>
<div className="flex items-center gap-2">
<Show when="signed-out">
<SignInButton mode="modal">
<Button variant="outline">Sign in</Button>
</SignInButton>
<SignUpButton mode="modal">
<Button>Sign up</Button>
</SignUpButton>
</Show>
<Show when="signed-in">
<UserButton />
</Show>
<Button variant="outline" onClick={() => void refresh()} disabled={loading}>
<RefreshCwIcon data-icon="inline-start" />
Refresh
</Button>
</div>
</div>
</header>
{error && (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>PodMan could not complete that action</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{showReconnecting ? (
<Card>
<CardHeader>
<CardTitle>Reconnecting to your pod</CardTitle>
<CardDescription>Restoring the saved LiveKit session.</CardDescription>
<CardAction>
<Button variant="outline" size="sm" onClick={handleLeave}>
Cancel
</Button>
</CardAction>
</CardHeader>
<CardContent>
<Skeleton className="h-2 w-full" />
</CardContent>
</Card>
) : (
<main className="grid min-w-0 flex-1 gap-5 lg:grid-cols-[minmax(0,1fr)_360px]">
<section className="flex min-w-0 flex-col gap-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<p className="text-xs font-medium uppercase text-muted-foreground">Workspaces</p>
<h2 className="text-xl font-semibold tracking-tight">Active pods</h2>
</div>
</div>
{loading ? (
<div className="grid gap-4 xl:grid-cols-2">
<PodSkeleton />
<PodSkeleton />
</div>
) : pods.length ? (
<div className="grid gap-4 xl:grid-cols-2">
{pods.map((pod) => (
<PodCard
key={pod.id}
pod={pod}
busy={pending.has(pod.id)}
presence={presence[pod.id] ?? []}
currentUserProfile={currentUserProfile}
onJoin={handleJoin}
onAddAndJoin={handleAddAndJoin}
onAddMember={handleAddMember}
onRemoveMember={handleRemoveMember}
onUpdate={handleUpdate}
onDelete={handleDelete}
onOpenGraph={setGraphPodId}
/>
))}
</div>
) : (
<Empty className="min-h-[360px] rounded-lg border bg-card">
<EmptyHeader>
<EmptyMedia variant="icon">
<SparklesIcon />
</EmptyMedia>
<EmptyTitle>No pods yet</EmptyTitle>
<EmptyDescription>Create the first room for this team.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<CreatePodForm
busy={pending.has('new')}
defaultMemberName={defaultMemberName}
onCreate={handleCreate}
compact
/>
</EmptyContent>
</Empty>
)}
</section>
<aside className="flex flex-col gap-4">
<CreatePodForm
busy={pending.has('new')}
defaultMemberName={defaultMemberName}
onCreate={handleCreate}
/>
</aside>
</main>
)}
</div>
</div>
);
}
function AuthGate() {
return (
<div className="min-h-screen bg-background text-foreground">
<div className="mx-auto flex min-h-screen w-full max-w-[1120px] flex-col px-4 py-4 sm:px-6 lg:px-8">
<header className="flex items-center justify-between border-b pb-4 pt-2">
<div className="flex min-w-0 items-center gap-3">
<img
src="/podman-logo.svg"
alt=""
className="size-11 shrink-0 rounded-lg"
aria-hidden="true"
/>
<h1 className="text-[1.95rem] font-semibold leading-none tracking-tight">PodMan</h1>
</div>
<SignInButton mode="modal">
<Button variant="outline">Sign in</Button>
</SignInButton>
</header>
<main className="grid flex-1 items-center gap-8 py-8 lg:grid-cols-[minmax(0,1fr)_420px]">
<section className="max-w-xl">
<p className="text-xs font-medium uppercase text-muted-foreground">Team memory</p>
<h2 className="mt-2 text-3xl font-semibold tracking-tight">
Create your account to enter PodMan
</h2>
<p className="mt-3 text-base text-muted-foreground">
PodMan saves your context across pods so agents can learn from your work in every room
you join.
</p>
</section>
<div className="flex justify-center lg:justify-end">
<SignUp routing="hash" />
</div>
</main>
</div>
</div>
);
}
function PodSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-56" />
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-8 w-full" />
</div>
</CardContent>
</Card>
);
}