Fixes batch 2: player quests, AI parse, advisor, iPad share
- Player view: a Quest log now syncs to players (snapshot += quests; broadcaster passes campaign quests; PlayerBoards renders titles + objective checkboxes). - AI (DeepSeek etc.): robust JSON extraction — strip code fences anywhere + a brace-balanced scanner that ignores prose/braces in strings. Fixes "AI unavailable (parse)" with OpenAI-compatible providers. +4 tests. - Encounter advisor: now bidirectional — for an over-tuned (too-hard) fight it suggests REMOVING/swapping monsters instead of only ever adding. +3 tests. - Character share: works on iPad — native share sheet → clipboard → a copyable link modal fallback (was a silently-failing clipboard write). 230 unit + 34 e2e + 2 realtime green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import { rollCheck } from '@/lib/useRoll';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { Page } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Badge, Meter } from '@/components/ui/Codex';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
import { NumberField } from '@/components/ui/NumberField';
|
||||
@@ -43,6 +44,9 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
const [levelUp, setLevelUp] = useState(false);
|
||||
const [genScores, setGenScores] = useState(false);
|
||||
const [shared, setShared] = useState(false);
|
||||
// Manual fallback: holds the link to show in a selectable dialog when neither
|
||||
// Web Share nor Clipboard is available (e.g. iPad without a secure context).
|
||||
const [shareLink, setShareLink] = useState<string | null>(null);
|
||||
|
||||
const onPortrait = async (file: File) => {
|
||||
const thumb = await squareThumbnail(await fileToDataUrl(file), 256);
|
||||
@@ -52,9 +56,27 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
const shareWithPlayer = async () => {
|
||||
const campaign = await campaignsRepo.get(c.campaignId);
|
||||
const link = `${location.origin}/player?c=${encodeClaim({ character: c, campaignName: campaign?.name ?? 'Campaign', campaignSystem: c.system })}`;
|
||||
try { await navigator.clipboard?.writeText(link); } catch { /* clipboard blocked */ }
|
||||
setShared(true);
|
||||
setTimeout(() => setShared(false), 1500);
|
||||
// Prefer the native share sheet (best on iOS/iPadOS), then the clipboard,
|
||||
// then a selectable dialog so the link is always recoverable.
|
||||
if (typeof navigator.share === 'function') {
|
||||
try {
|
||||
await navigator.share({ title: `${c.name} — character link`, url: link });
|
||||
return;
|
||||
} catch (err) {
|
||||
// User dismissed the share sheet: respect that and do nothing further.
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
// Otherwise fall through to clipboard / manual fallback.
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(link);
|
||||
setShared(true);
|
||||
setTimeout(() => setShared(false), 1500);
|
||||
return;
|
||||
}
|
||||
} catch { /* clipboard blocked (e.g. insecure context on iPad) */ }
|
||||
setShareLink(link);
|
||||
};
|
||||
const save = useDebouncedCallback((next: Character) => {
|
||||
// Persist everything except identity/timestamps (update() stamps updatedAt).
|
||||
@@ -281,6 +303,24 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
{levelUp && (
|
||||
<LevelUpModal character={c} onClose={() => setLevelUp(false)} onApply={(patch) => update(patch)} />
|
||||
)}
|
||||
{shareLink !== null && (
|
||||
<Modal
|
||||
open
|
||||
onClose={() => setShareLink(null)}
|
||||
title="Share with player"
|
||||
footer={<Button size="sm" variant="ghost" onClick={() => setShareLink(null)}>Done</Button>}
|
||||
>
|
||||
<p className="mb-2 text-sm text-muted">Copy this link and send it to the player:</p>
|
||||
<Input
|
||||
data-autofocus
|
||||
readOnly
|
||||
value={shareLink}
|
||||
aria-label="Player link"
|
||||
className="font-mono text-xs"
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user