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:
+109
-62
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user