P14c: targeted private share (handout/note/image) from the roster
- GM clicks a player's 📨 in the session roster → composer (title/body/image via resizeToMax) → sendPrivateHandout to that player only (server-routed; non- recipients never receive it). Covers "share a secret map/note to the scout". - The recipient sees a prominent "📨 Just for you" card on their player view (PlayerBoards renders playerSessionStore.privateHandout, image inline). - Realtime e2e: GM shares a private note; only the targeted player sees it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,11 +3,14 @@ import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { useSessionStore } from '@/stores/sessionStore';
|
||||
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { sendChat } from '@/lib/sync/wsSync';
|
||||
import { sendChat, sendPrivateHandout } from '@/lib/sync/wsSync';
|
||||
import { sessionLogRepo } from '@/lib/db/repositories';
|
||||
import type { SessionLogEntry } from '@/lib/schemas';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import type { RosterEntry } from '@/lib/sync/messages';
|
||||
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
|
||||
import { Input, Textarea } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
/**
|
||||
@@ -48,6 +51,19 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
// GM: targeted private share (handout/note/image) to one player
|
||||
const [shareTarget, setShareTarget] = useState<RosterEntry | null>(null);
|
||||
const [shareTitle, setShareTitle] = useState('');
|
||||
const [shareBody, setShareBody] = useState('');
|
||||
const [shareImage, setShareImage] = useState<string | undefined>(undefined);
|
||||
const openShare = (p: RosterEntry) => { setShareTarget(p); setShareTitle(''); setShareBody(''); setShareImage(undefined); };
|
||||
const onShareImage = async (file: File) => setShareImage(await resizeToMax(await fileToDataUrl(file), 1280));
|
||||
const sendShare = () => {
|
||||
if (!shareTarget) return;
|
||||
sendPrivateHandout(shareTarget.playerId, { title: shareTitle.trim() || 'A note', body: shareBody.trim(), ...(shareImage ? { image: shareImage } : {}) });
|
||||
setShareTarget(null);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
@@ -81,6 +97,9 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
|
||||
<li key={p.playerId} className={cn('flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs', p.playerId === 'gm' ? 'border-accent/50 text-accent' : 'border-line text-ink')}>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success" aria-hidden />
|
||||
{p.name}
|
||||
{isGm && p.playerId !== 'gm' && (
|
||||
<button onClick={() => openShare(p)} title={`Share privately with ${p.name}`} aria-label={`Share with ${p.name}`} className="ml-0.5 text-muted hover:text-info">📨</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -157,6 +176,25 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{shareTarget && (
|
||||
<Modal open onClose={() => setShareTarget(null)} title={`Share privately with ${shareTarget.name}`}
|
||||
footer={<>
|
||||
<Button variant="ghost" onClick={() => setShareTarget(null)}>Cancel</Button>
|
||||
<Button variant="primary" onClick={sendShare}>Send to {shareTarget.name}</Button>
|
||||
</>}>
|
||||
<div className="space-y-3">
|
||||
<Input value={shareTitle} onChange={(e) => setShareTitle(e.target.value)} placeholder="Title (e.g. A scrawled map)" aria-label="Share title" />
|
||||
<Textarea value={shareBody} onChange={(e) => setShareBody(e.target.value)} placeholder="Text only this player sees…" rows={4} aria-label="Share text" />
|
||||
<div className="flex items-center gap-2">
|
||||
{shareImage && <img src={shareImage} alt="" className="h-16 w-16 rounded border border-line object-cover" />}
|
||||
<label className="cursor-pointer rounded-md border border-line px-2 py-1 text-xs text-ink hover:border-accent">Image…<input type="file" accept="image/*" className="hidden" aria-label="Share image" onChange={(e) => { const f = e.target.files?.[0]; if (f) void onShareImage(f); e.target.value = ''; }} /></label>
|
||||
{shareImage && <Button size="sm" variant="ghost" onClick={() => setShareImage(undefined)}>Remove</Button>}
|
||||
</div>
|
||||
<p className="text-xs text-muted">Only <span className="text-ink">{shareTarget.name}</span> receives this — a secret map, note, or image (e.g. for a scout who went ahead).</p>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Snapshot } from '@/lib/sync/messages';
|
||||
import type { PlayerMap } from '@/lib/map';
|
||||
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
||||
import { EmptyState } from '@/components/ui/Page';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { PlayerMapView } from './PlayerMapView';
|
||||
@@ -8,8 +9,18 @@ import { PlayerMapView } from './PlayerMapView';
|
||||
export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot; image?: string; images?: Record<string, string> }) {
|
||||
const { party, encounter, map, handout } = snapshot;
|
||||
const handoutImage = handout?.imageId ? images?.[handout.imageId] : undefined;
|
||||
const privateHandout = usePlayerSessionStore((s) => s.privateHandout);
|
||||
return (
|
||||
<>
|
||||
{privateHandout && (
|
||||
<section className="mb-6 rounded-xl border border-info/60 bg-info/5 p-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-info">📨 Just for you</div>
|
||||
<h2 className="font-display text-2xl font-bold text-ink">{privateHandout.title || 'A private note'}</h2>
|
||||
{privateHandout.body && <p className="mt-1 whitespace-pre-wrap text-ink">{privateHandout.body}</p>}
|
||||
{privateHandout.image && <img src={privateHandout.image} alt="" className="mt-3 max-h-[60vh] w-auto rounded-lg border border-line" />}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{handout && (
|
||||
<section className="mb-6 rounded-xl border border-accent/50 bg-accent/5 p-4">
|
||||
<h2 className="font-display text-2xl font-bold text-accent">{handout.title || 'Handout'}</h2>
|
||||
|
||||
Reference in New Issue
Block a user