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:
@@ -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 player’s'}</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 campaign’s 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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { pickTextFile } from '@/lib/io/file';
|
||||
import { seedSampleCampaign } from '@/lib/sample';
|
||||
import { cloudUsername, register as cloudRegister, login as cloudLogin, logout as cloudLogout, pushBackup, pullBackup, CloudError } from '@/lib/cloud/client';
|
||||
import { AssistantSettings } from './AssistantSettings';
|
||||
import { CloudCampaigns } from './CloudCampaigns';
|
||||
import { Page, PageHeader } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
@@ -71,6 +72,8 @@ export function SettingsPage() {
|
||||
|
||||
<CloudSync />
|
||||
|
||||
<CloudCampaigns />
|
||||
|
||||
<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 & licenses</h2>
|
||||
<ul className="space-y-1 text-sm text-muted">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user