feat: Mongo-backed Pods with full CRUD + member management

Pods are persisted in MongoDB and CRUD'd via /api/pods (list/create/get/
update/delete + add/remove member). Shared Pod reshaped to a persisted entity
(members: string[], description, timestamps) + PodInput. Backend seeds Demo/
Frontend/Backend Pods on first run (Squad -> Pod rename). Frontend replaces
hardcoded teams with live data: PodCard (member chips add/remove, inline edit,
delete, join) + CreatePodForm; App fetches+mutates via API. Removes teams.ts/
TeamCard; gitignores test artifacts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kartikeya
2026-06-27 15:48:55 -07:00
parent 789f0da61b
commit 5a03ad4d6b
13 changed files with 708 additions and 148 deletions
+109 -62
View File
@@ -1,50 +1,97 @@
import { useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import type { Room } from 'livekit-client';
import type { Pod, PodInput } from '@podman/shared';
import { joinPod } from './lib/pod.js';
import { TEAMS, teamById } from './lib/teams.js';
import { TeamCard } from './components/TeamCard.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';
export default function App() {
const [teamId, setTeamId] = useState(TEAMS[0]!.id);
const team = useMemo(() => teamById(teamId), [teamId]);
const [member, setMember] = useState(team.members[0]!);
const [room, setRoom] = useState<Room | null>(null);
const [joined, setJoined] = useState(false);
const [devMode, setDevMode] = useState(false);
const [pods, setPods] = useState<Pod[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [connecting, setConnecting] = useState(false);
function handleTeamChange(id: string) {
setTeamId(id);
const next = teamById(id);
if (!next.members.includes(member)) setMember(next.members[0]!);
// join state
const [joinedPod, setJoinedPod] = useState<Pod | null>(null);
const [member, setMember] = useState('');
const [devMode, setDevMode] = useState(false);
const [room, setRoom] = useState<Room | null>(null);
async function refresh() {
setLoading(true);
try {
setPods(await api.listPods());
setError(null);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}
async function handleJoin() {
useEffect(() => {
void refresh();
}, []);
/** Run a mutation, reflect the result in local state, surface errors. */
async function mutate(fn: () => Promise<void>) {
setBusy(true);
setError(null);
setConnecting(true);
try {
const identity = `${member}-${Math.random().toString(36).slice(2, 7)}`;
const result = await joinPod(team.id, identity, member);
await fn();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
const upsert = (p: Pod) => setPods((cur) => cur.map((x) => (x.id === p.id ? p : x)));
const handleCreate = (input: PodInput) =>
mutate(async () => {
const created = await api.createPod(input);
setPods((cur) => [...cur, created]);
});
const handleUpdate = (id: string, patch: PodInput) =>
mutate(async () => upsert(await api.updatePod(id, patch)));
const handleDelete = (id: string) =>
mutate(async () => {
await api.deletePod(id);
setPods((cur) => cur.filter((x) => x.id !== id));
});
const handleAddMember = (id: string, name: string) =>
mutate(async () => upsert(await api.addMember(id, name)));
const handleRemoveMember = (id: string, name: string) =>
mutate(async () => upsert(await api.removeMember(id, name)));
async function handleJoin(pod: Pod, who: string) {
setBusy(true);
setError(null);
try {
const identity = `${who}-${Math.random().toString(36).slice(2, 7)}`;
const result = await joinPod(pod.id, identity, who);
setRoom(result.room);
setDevMode(result.mode === 'dev');
setJoined(true);
} catch (err) {
setError((err as Error).message);
setMember(who);
setJoinedPod(pod);
} catch (e) {
setError((e as Error).message);
} finally {
setConnecting(false);
setBusy(false);
}
}
function handleLeave() {
room?.disconnect();
setRoom(null);
setJoined(false);
setJoinedPod(null);
}
return (
<div className="mx-auto min-h-screen w-full max-w-4xl px-4 py-8 sm:px-6">
<div className="mx-auto min-h-screen w-full max-w-5xl px-4 py-8 sm:px-6">
<header className="mb-8 flex items-center gap-3">
<span className="text-3xl">🛰</span>
<div>
@@ -55,48 +102,48 @@ export default function App() {
</div>
</header>
{joined ? (
<PodView team={team} me={member} devMode={devMode} onLeave={handleLeave} />
{joinedPod ? (
<PodView team={joinedPod} me={member} devMode={devMode} onLeave={handleLeave} />
) : (
<main className="flex flex-col gap-6">
<section>
<h2 className="mb-3 text-sm font-medium text-slate-400">Pick a team</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{TEAMS.map((t) => (
<TeamCard
key={t.id}
team={t}
selected={t.id === teamId}
onSelect={handleTeamChange}
<main className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-slate-400">
Pods {loading ? '…' : `(${pods.length})`}
</h2>
<button
className="text-xs text-slate-400 hover:text-slate-200 disabled:opacity-50"
onClick={() => void refresh()}
disabled={loading}
>
Refresh
</button>
</div>
{error && (
<p className="rounded-md border border-red-900/60 bg-red-950/40 px-3 py-2 text-sm text-red-400">
{error}
</p>
)}
{loading ? (
<p className="text-sm text-slate-500">Loading pods</p>
) : (
<div className="grid gap-4 md:grid-cols-2">
{pods.map((pod) => (
<PodCard
key={pod.id}
pod={pod}
busy={busy}
onJoin={handleJoin}
onAddMember={handleAddMember}
onRemoveMember={handleRemoveMember}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
))}
<CreatePodForm busy={busy} onCreate={handleCreate} />
</div>
</section>
<section className="flex flex-col gap-3 rounded-xl border border-slate-800 bg-slate-900/40 p-4 sm:flex-row sm:items-end">
<label className="flex flex-1 flex-col gap-1 text-sm text-slate-400">
Join {team.name} as
<select
className="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-base text-slate-100"
value={member}
onChange={(e) => setMember(e.target.value)}
>
{team.members.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</label>
<button
className="rounded-md bg-emerald-600 px-5 py-2 font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
onClick={handleJoin}
disabled={connecting}
>
{connecting ? 'Joining…' : 'Join pod'}
</button>
</section>
{error && <p className="text-sm text-red-400">{error}</p>}
)}
</main>
)}
</div>