P15c: client UI for shared campaigns + publishing characters

- src/lib/cloud/campaigns.ts wraps the campaign/character API.
- Settings "Shared campaigns (cloud)" panel (signed-in only): publish a local
  campaign → get an invite code; join a campaign by code; publish a local character
  to a campaign (stays yours, re-publish to sync); owners "View party" to read all
  published characters (read-all, per the View+suggest model).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 17:43:39 +02:00
parent a28183326e
commit e8371f6d3b
3 changed files with 160 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useState } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db/db';
import { cloudUsername, CloudError } from '@/lib/cloud/client';
import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, type CloudCampaignInfo, type CloudCharInfo } from '@/lib/cloud/campaigns';
import { useCampaigns } from '@/features/campaigns/hooks';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
/** Settings: publish a campaign for players, join one, and publish your character (it stays yours + syncs). */
export function CloudCampaigns() {
const signedIn = !!cloudUsername();
const [list, setList] = useState<CloudCampaignInfo[]>([]);
const [msg, setMsg] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const localCampaigns = useCampaigns();
const localChars = useLiveQuery(() => db.characters.toArray(), [], []);
const [invite, setInvite] = useState('');
const [pubCampaign, setPubCampaign] = useState('');
const [charId, setCharId] = useState('');
const [targetCloud, setTargetCloud] = useState('');
const [viewing, setViewing] = useState<string | null>(null);
const [party, setParty] = useState<CloudCharInfo[]>([]);
useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]);
const refresh = () => listCloudCampaigns().then(setList).catch(() => {});
const run = (fn: () => Promise<void>) => async () => {
setBusy(true); setMsg(null);
try { await fn(); } catch (e) { setMsg(e instanceof CloudError ? e.message : 'Something went wrong.'); } finally { setBusy(false); }
};
if (!signedIn) {
return (
<section className="rounded-lg border border-line bg-panel p-4">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Shared campaigns (cloud)</h2>
<p className="text-sm text-muted">Sign in above to publish a campaign for your players, or join one and publish your character it stays yours and syncs when you edit it.</p>
</section>
);
}
const owned = list.filter((c) => c.role === 'owner');
return (
<section className="space-y-4 rounded-lg border border-line bg-panel p-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted">Shared campaigns (cloud)</h2>
{list.length > 0 && (
<ul className="space-y-2">
{list.map((c) => (
<li key={c.id} className="rounded-md border border-line bg-surface p-2 text-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-ink">{c.name}</span>
<span className="rounded-full bg-elevated px-1.5 py-0.5 text-[10px] uppercase text-muted">{c.role}</span>
{c.inviteCode && (
<button className="font-mono text-xs text-accent" title="Copy invite code" onClick={() => void navigator.clipboard?.writeText(c.inviteCode!).then(() => setMsg(`Invite code ${c.inviteCode} copied.`))}>invite: {c.inviteCode}</button>
)}
{c.role === 'owner' && <Button size="sm" variant="ghost" onClick={run(async () => { setViewing(c.id); setParty(await listCloudCharacters(c.id)); })}>View party</Button>}
</div>
{viewing === c.id && (
<ul className="mt-1 space-y-0.5 border-t border-line pt-1 text-xs">
{party.length === 0 ? <li className="text-muted">No characters published yet.</li> : party.map((p) => (
<li key={p.id} className="flex justify-between"><span className="text-ink">{p.name}</span><span className="text-muted">{p.mine ? 'yours' : 'a players'}</span></li>
))}
</ul>
)}
</li>
))}
</ul>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div>
<h3 className="mb-1 text-xs font-medium text-muted">Publish a campaign (you = GM)</h3>
<div className="flex gap-1">
<Select value={pubCampaign} onChange={(e) => setPubCampaign(e.target.value)} aria-label="Local campaign to publish" className="flex-1">
<option value="">Choose a campaign</option>
{localCampaigns.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
<Button size="sm" variant="primary" disabled={busy || !pubCampaign} onClick={run(async () => { const c = localCampaigns.find((x) => x.id === pubCampaign); if (!c) return; await createCloudCampaign(c.name, c.system); setMsg(`Published “${c.name}” — share its invite code.`); refresh(); })}>Publish</Button>
</div>
</div>
<div>
<h3 className="mb-1 text-xs font-medium text-muted">Join a campaign (you = player)</h3>
<div className="flex gap-1">
<Input value={invite} onChange={(e) => setInvite(e.target.value.toUpperCase())} placeholder="Invite code" aria-label="Invite code" className="flex-1" />
<Button size="sm" variant="secondary" disabled={busy || !invite.trim()} onClick={run(async () => { const c = await joinCloudCampaign(invite.trim()); setInvite(''); setMsg(`Joined “${c.name}”.`); refresh(); })}>Join</Button>
</div>
</div>
</div>
<div>
<h3 className="mb-1 text-xs font-medium text-muted">Publish a character (stays yours, syncs on edit)</h3>
<div className="flex flex-wrap gap-1">
<Select value={charId} onChange={(e) => setCharId(e.target.value)} aria-label="Character to publish">
<option value="">Choose a character</option>
{(localChars ?? []).map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
<Select value={targetCloud} onChange={(e) => setTargetCloud(e.target.value)} aria-label="Target shared campaign">
<option value="">to which campaign</option>
{list.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
<Button size="sm" variant="primary" disabled={busy || !charId || !targetCloud} onClick={run(async () => { const ch = (localChars ?? []).find((c) => c.id === charId); if (!ch) return; await publishCharacter(targetCloud, { id: ch.id, name: ch.name, data: JSON.stringify(ch) }); setMsg(`Published ${ch.name}.`); if (viewing === targetCloud) setParty(await listCloudCharacters(targetCloud)); })}>Publish</Button>
</div>
</div>
{msg && <p className="text-sm text-accent">{msg}</p>}
<p className="text-xs text-muted">{owned.length > 0 ? 'Share an owner campaigns invite code with your players. They sign in, join, and publish their characters — you see them under “View party”.' : 'Publish one of your campaigns to get an invite code for players.'}</p>
</section>
);
}
+3
View File
@@ -6,6 +6,7 @@ import { pickTextFile } from '@/lib/io/file';
import { seedSampleCampaign } from '@/lib/sample'; import { seedSampleCampaign } from '@/lib/sample';
import { cloudUsername, register as cloudRegister, login as cloudLogin, logout as cloudLogout, pushBackup, pullBackup, CloudError } from '@/lib/cloud/client'; import { cloudUsername, register as cloudRegister, login as cloudLogin, logout as cloudLogout, pushBackup, pullBackup, CloudError } from '@/lib/cloud/client';
import { AssistantSettings } from './AssistantSettings'; import { AssistantSettings } from './AssistantSettings';
import { CloudCampaigns } from './CloudCampaigns';
import { Page, PageHeader } from '@/components/ui/Page'; import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
@@ -71,6 +72,8 @@ export function SettingsPage() {
<CloudSync /> <CloudSync />
<CloudCampaigns />
<section className="rounded-lg border border-line bg-panel p-4"> <section className="rounded-lg border border-line bg-panel p-4">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Data sources &amp; licenses</h2> <h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Data sources &amp; licenses</h2>
<ul className="space-y-1 text-sm text-muted"> <ul className="space-y-1 text-sm text-muted">
+45
View File
@@ -0,0 +1,45 @@
import { CloudError } from './client';
/** Client for the shared-campaign + member-owned-character API (same-origin /api). */
const TOKEN_KEY = 'ttrpg-cloud-token';
const base = () => `${location.origin}/api`;
function authHeaders(): Record<string, string> {
const t = localStorage.getItem(TOKEN_KEY);
return t ? { authorization: `Bearer ${t}` } : {};
}
export interface CloudCampaignInfo { id: string; name: string; system: string; role: 'owner' | 'member'; inviteCode?: string }
export interface CloudCharInfo { id: string; name: string; ownerUserId: string; mine: boolean; data: string; updatedAt: number }
async function req<T>(path: string, init?: RequestInit): Promise<T> {
let res: Response;
try {
res = await fetch(`${base()}${path}`, { ...init, headers: { 'content-type': 'application/json', ...authHeaders(), ...(init?.headers ?? {}) } });
} catch {
throw new CloudError('Could not reach the server.');
}
if (res.status === 401) throw new CloudError('Sign in to use shared campaigns.');
const data = (await res.json().catch(() => ({}))) as Record<string, unknown> & { message?: string };
if (!res.ok) throw new CloudError(data.message ?? 'Request failed.');
return data as T;
}
export function listCloudCampaigns(): Promise<CloudCampaignInfo[]> {
return req<CloudCampaignInfo[]>('/campaigns');
}
export function createCloudCampaign(name: string, system: string): Promise<CloudCampaignInfo> {
return req<CloudCampaignInfo>('/campaigns', { method: 'POST', body: JSON.stringify({ name, system }) });
}
export function joinCloudCampaign(inviteCode: string): Promise<CloudCampaignInfo> {
return req<CloudCampaignInfo>('/campaigns/join', { method: 'POST', body: JSON.stringify({ inviteCode }) });
}
export function publishCharacter(campaignId: string, character: { id: string; name: string; data: string }): Promise<{ ok: boolean }> {
return req('/characters', { method: 'PUT', body: JSON.stringify({ campaignId, character }) });
}
export function listCloudCharacters(campaignId: string): Promise<CloudCharInfo[]> {
return req<CloudCharInfo[]>(`/campaigns/${campaignId}/characters`);
}
export async function cloudUsageBytes(): Promise<number> {
return (await req<{ bytes: number }>('/usage')).bytes;
}