235cdecb53
- Autosave: while signed in, a debounced full backup pushes whenever durable data
changes (useCloudAutosave watches a fingerprint of the main tables; high-churn
tables excluded). No-op signed out.
- Campaign-agnostic characters: PCs are now global — the Characters roster shows
every player character regardless of active campaign, and any PC can be added to
a fight. NPCs stay per-campaign. (charactersRepo.listAllPcs / useAllPcs; difficulty
fallback stays campaign-scoped.)
- Live "bring your own character": a joining player can push one of their own local
characters; the GM's grant inserts it into the campaign and seats them.
- Character builder: a system picker (5e / PF2e, defaults to the campaign) so creation
no longer assumes 5e; PF2e skill wording ("Trained" vs "proficient"); spell step now
explains how many to pick + that they're known spells prepared later.
230 unit + 34 e2e + 2 realtime green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
76 lines
3.8 KiB
TypeScript
76 lines
3.8 KiB
TypeScript
import { UserPlus } from 'lucide-react';
|
|
import type { Snapshot } from '@/lib/sync/messages';
|
|
import type { Character } from '@/lib/schemas';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { EmptyState } from '@/components/ui/Page';
|
|
import { Avatar, Badge } from '@/components/ui/Codex';
|
|
|
|
/**
|
|
* Shown to a joined player before they control a character: pick "you" from the
|
|
* party. If a locally-developed copy exists, its offline edits ride along for
|
|
* the GM to review.
|
|
*/
|
|
export function SeatClaimScreen({ snapshot, localChars, ownCharacters = [], pending, onClaim }: {
|
|
snapshot: Snapshot;
|
|
localChars: Map<string, Character>;
|
|
/** the player's own local characters — they can push one the GM hasn't added yet */
|
|
ownCharacters?: Character[];
|
|
pending: boolean;
|
|
onClaim: (characterId: string) => void;
|
|
}) {
|
|
const partyIds = new Set(snapshot.party.map((p) => p.id));
|
|
const pushable = ownCharacters.filter((c) => c.kind === 'pc' && !partyIds.has(c.id));
|
|
|
|
if (snapshot.party.length === 0 && pushable.length === 0) {
|
|
return <EmptyState title="No characters yet" hint="The GM hasn't added any player characters — make one of your own and it'll appear here to bring to the table." />;
|
|
}
|
|
return (
|
|
<section className="paper-grain mb-6 rounded-xl border border-line bg-panel p-4">
|
|
<div className="font-display text-sm italic text-accent">Claim your seat</div>
|
|
<h2 className="font-display text-xl font-semibold text-ink">Which character is yours?</h2>
|
|
<p className="mt-1 text-sm text-muted">Pick your character to manage its HP, spells, and rolls live. The GM approves the request.</p>
|
|
<hr className="gilt-rule my-3" />
|
|
|
|
{snapshot.party.length > 0 && (
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
{snapshot.party.map((c) => {
|
|
const hasOffline = localChars.has(c.id);
|
|
return (
|
|
<div key={c.id} data-testid="seat-option" className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 p-3">
|
|
<Avatar name={c.name} size={40} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate font-display text-lg font-semibold text-ink">{c.name}</div>
|
|
<div className="font-mono text-xs text-muted">Lv {c.level} · AC {c.ac} · {c.hp.current}/{c.hp.max} HP</div>
|
|
{hasOffline && <Badge tone="arcane" className="mt-1"><UserPlus size={11} aria-hidden /> offline edits ready</Badge>}
|
|
</div>
|
|
<Button size="sm" variant="primary" disabled={pending} onClick={() => onClaim(c.id)}>This is me</Button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{pushable.length > 0 && (
|
|
<div className="mt-4">
|
|
<h3 className="smallcaps mb-1.5 text-muted">Bring your own character</h3>
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
{pushable.map((c) => (
|
|
<div key={c.id} data-testid="seat-option" className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 p-3">
|
|
<Avatar name={c.name} size={40} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate font-display text-lg font-semibold text-ink">{c.name}</div>
|
|
<div className="font-mono text-xs text-muted">Lv {c.level}{c.className ? ` · ${c.className}` : ''}</div>
|
|
<Badge tone="gold" className="mt-1"><UserPlus size={11} aria-hidden /> send to the GM</Badge>
|
|
</div>
|
|
<Button size="sm" variant="secondary" disabled={pending} onClick={() => onClaim(c.id)}>Use this one</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{pending && <p className="mt-3 text-sm text-accent">Waiting for the GM to approve…</p>}
|
|
</section>
|
|
);
|
|
}
|