From e8371f6d3b49be530db98e13faf36ae0dc585787 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 17:43:39 +0200 Subject: [PATCH] P15c: client UI for shared campaigns + publishing characters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/features/settings/CloudCampaigns.tsx | 112 +++++++++++++++++++++++ src/features/settings/SettingsPage.tsx | 3 + src/lib/cloud/campaigns.ts | 45 +++++++++ 3 files changed, 160 insertions(+) create mode 100644 src/features/settings/CloudCampaigns.tsx create mode 100644 src/lib/cloud/campaigns.ts diff --git a/src/features/settings/CloudCampaigns.tsx b/src/features/settings/CloudCampaigns.tsx new file mode 100644 index 0000000..16eaf42 --- /dev/null +++ b/src/features/settings/CloudCampaigns.tsx @@ -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([]); + const [msg, setMsg] = useState(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(null); + const [party, setParty] = useState([]); + + useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]); + const refresh = () => listCloudCampaigns().then(setList).catch(() => {}); + + const run = (fn: () => Promise) => 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 ( +
+

Shared campaigns (cloud)

+

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.

+
+ ); + } + + const owned = list.filter((c) => c.role === 'owner'); + + return ( +
+

Shared campaigns (cloud)

+ + {list.length > 0 && ( +
    + {list.map((c) => ( +
  • +
    + {c.name} + {c.role} + {c.inviteCode && ( + + )} + {c.role === 'owner' && } +
    + {viewing === c.id && ( +
      + {party.length === 0 ?
    • No characters published yet.
    • : party.map((p) => ( +
    • {p.name}{p.mine ? 'yours' : 'a player’s'}
    • + ))} +
    + )} +
  • + ))} +
+ )} + +
+
+

Publish a campaign (you = GM)

+
+ + +
+
+ +
+

Join a campaign (you = player)

+
+ setInvite(e.target.value.toUpperCase())} placeholder="Invite code" aria-label="Invite code" className="flex-1" /> + +
+
+
+ +
+

Publish a character (stays yours, syncs on edit)

+
+ + + +
+
+ + {msg &&

{msg}

} +

{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.'}

+
+ ); +} diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx index 0b03635..26bd3b7 100644 --- a/src/features/settings/SettingsPage.tsx +++ b/src/features/settings/SettingsPage.tsx @@ -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() { + +

Data sources & licenses

    diff --git a/src/lib/cloud/campaigns.ts b/src/lib/cloud/campaigns.ts new file mode 100644 index 0000000..852cd7a --- /dev/null +++ b/src/lib/cloud/campaigns.ts @@ -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 { + 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(path: string, init?: RequestInit): Promise { + 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 & { message?: string }; + if (!res.ok) throw new CloudError(data.message ?? 'Request failed.'); + return data as T; +} + +export function listCloudCampaigns(): Promise { + return req('/campaigns'); +} +export function createCloudCampaign(name: string, system: string): Promise { + return req('/campaigns', { method: 'POST', body: JSON.stringify({ name, system }) }); +} +export function joinCloudCampaign(inviteCode: string): Promise { + return req('/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 { + return req(`/campaigns/${campaignId}/characters`); +} +export async function cloudUsageBytes(): Promise { + return (await req<{ bytes: number }>('/usage')).bytes; +}