Phase 2: character portraits → token icons (local + realtime)

- character.portrait + mapToken.image (data URLs, Dexie v10, additive).
- src/lib/img/resize.ts: center-crop + downscale helper (160px tokens / 256px
  portraits) keeping IndexedDB + wire payloads small.
- Portrait uploader on the character sheet; token icon uploader in the map token
  modal (falls back to the linked character's portrait). Palette PC entries show
  the portrait thumbnail.
- Tokens render the image clipped to the circle (HP ring + condition badge on top).
- Realtime: generalized the image channel to many images (map bg + token/portrait
  icons), deduped by content and resent on reconnect. Player tokens/party carry an
  imageId resolved from the session image cache; party panel shows portraits.
- Also: fix the encounter-picker dropdown text being clipped (drop fixed height).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 14:42:06 +02:00
parent ca3769eb6b
commit 1aff63f29c
18 changed files with 239 additions and 42 deletions
@@ -5,6 +5,7 @@ import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
import { charactersRepo, campaignsRepo } from '@/lib/db/repositories';
import { encodeClaim } from '@/lib/sync/playerLink';
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
import { PF2E_SAVES } from '@/lib/rules/pf2e/skills';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { rollCheck } from '@/lib/useRoll';
@@ -40,6 +41,11 @@ export function CharacterSheet({ character }: { character: Character }) {
const [genScores, setGenScores] = useState(false);
const [shared, setShared] = useState(false);
const onPortrait = async (file: File) => {
const thumb = await squareThumbnail(await fileToDataUrl(file), 256);
update({ portrait: thumb });
};
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 })}`;
@@ -89,6 +95,13 @@ export function CharacterSheet({ character }: { character: Character }) {
{/* Header */}
<div className="mb-6 flex flex-wrap items-center gap-3">
<label className="group relative h-14 w-14 shrink-0 cursor-pointer overflow-hidden rounded-full border border-line bg-surface" title="Upload portrait (also used as the map token)">
{c.portrait
? <img src={c.portrait} alt="" className="h-full w-full object-cover" />
: <span className="grid h-full w-full place-items-center text-[10px] text-muted">+ art</span>}
<span className="absolute inset-x-0 bottom-0 bg-black/55 text-center text-[9px] text-white opacity-0 transition group-hover:opacity-100">edit</span>
<input type="file" accept="image/*" className="hidden" aria-label="Portrait" onChange={(e) => { const f = e.target.files?.[0]; if (f) void onPortrait(f); e.target.value = ''; }} />
</label>
<Input
className="max-w-xs font-display text-xl font-bold"
value={c.name}