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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user