import { useEffect, useState } from 'react'; import { Link } from '@tanstack/react-router'; 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, rotateInvite, removeCloudMember, getUsage, 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([]); const [usage, setUsage] = useState<{ bytes: number; quota: number; admin: boolean } | null>(null); useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]); useEffect(() => { if (signedIn) getUsage().then(setUsage).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' && 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'} {!p.mine && ( )}
    • ))}
    )}
  • ))}
)}

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)

{usage && (() => { const pct = usage.quota > 0 ? Math.min(100, Math.round((usage.bytes / usage.quota) * 100)) : 0; const near = pct >= 90; return (

Your cloud storage: {fmtBytes(usage.bytes)} of {fmtBytes(usage.quota)} ({pct}%) {near && ' — almost full; trim old data or ask the admin to raise your quota.'}

); })()} {usage?.admin && (
You’re the instance admin — manage accounts, storage, campaigns, and live rooms in the{' '} Admin panel.
)} {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.'}

); } function fmtBytes(b: number): string { if (b >= 1e9) return `${(b / 1e9).toFixed(2)} GB`; if (b >= 1e6) return `${(b / 1e6).toFixed(1)} MB`; return `${Math.max(0, Math.round(b / 1024))} KB`; }