Mirror character portrait upload from 9ddc680

This commit is contained in:
Admin 2026-10-08 05:02:50 +00:00
parent 0fd9ed920f
commit c4a096dc25
8 changed files with 130 additions and 5 deletions

View File

@ -68,3 +68,6 @@ Gear tracks gold (gp), silver (sp) and copper (cp) separately, with integer add/
## Keycloak MCP user authorization ## Keycloak MCP user authorization
The MCP uses Keycloak for OAuth issuance via confidential authorization-code flow with S256 PKCE. ChatGPT client: `dnd-character-mcp` in dev realm, exact redirect `https://chatgpt.com/connector_platform_oauth_redirect`. The app verifies signed Keycloak JWTs via JWKS and accepts only tokens with `azp=dnd-character-mcp`, validates issuer and expiration. The authenticated user's Keycloak `sub` is used directly as sheet/campaign owner, matching the PWA. Machine/service account tokens are not supported. OAuth registration, authorization, consent and token endpoints are on Keycloak, not D&D; legacy in-app OAuth source remains inert. The website retains its separate confidential client and browser session. The MCP application does not mint tokens or store OAuth secrets. The MCP uses Keycloak for OAuth issuance via confidential authorization-code flow with S256 PKCE. ChatGPT client: `dnd-character-mcp` in dev realm, exact redirect `https://chatgpt.com/connector_platform_oauth_redirect`. The app verifies signed Keycloak JWTs via JWKS and accepts only tokens with `azp=dnd-character-mcp`, validates issuer and expiration. The authenticated user's Keycloak `sub` is used directly as sheet/campaign owner, matching the PWA. Machine/service account tokens are not supported. OAuth registration, authorization, consent and token endpoints are on Keycloak, not D&D; legacy in-app OAuth source remains inert. The website retains its separate confidential client and browser session. The MCP application does not mint tokens or store OAuth secrets.
## Character portraits
The character editor and hero portrait frame accept device photo and illustration uploads (JPEG, PNG, WebP, supported HEIC/HEIF). Browser canvas centre-crops at 4:5 and compresses to JPEG/WebP, max 320,000 data URL characters. Unsupported, oversized (>12 MB) or undecodable images are rejected. No raw photo uploads or external image service: compressed portrait is an optional, nullable part of the validated character, so offline IndexedDB, JSON backups, MCP sheet reads and authenticated cross-device sync all retain it. Explicit null removes it; older clients omitting the optional field preserve existing images through the storage merge. Photos remain private to the character owner and are never shared in campaign party projections.

View File

@ -13,7 +13,7 @@ export class Storage{
async get(owner,id){return (await this.pool.query('SELECT id,character,revision,deleted FROM character_sheets WHERE owner=$1 AND id=$2',[owner,id])).rows[0]} async get(owner,id){return (await this.pool.query('SELECT id,character,revision,deleted FROM character_sheets WHERE owner=$1 AND id=$2',[owner,id])).rows[0]}
async put(owner,id,character,revision,deleted=false){ async put(owner,id,character,revision,deleted=false){
// Preserve additive collections omitted by older clients, under the same account/revision guard. // Preserve additive collections omitted by older clients, under the same account/revision guard.
if(character&&!deleted&&revision>0&&(character.inventoryItems===undefined||character.currency===undefined||character.companions===undefined||character.spellSlots===undefined)){const prior=await this.get(owner,id);for(const key of ['inventoryItems','currency','companions','spellSlots'])if(character[key]===undefined&&prior?.character?.[key]!==undefined)character={...character,[key]:prior.character[key]}} if(character&&!deleted&&revision>0&&(character.inventoryItems===undefined||character.currency===undefined||character.companions===undefined||character.spellSlots===undefined||character.portrait===undefined)){const prior=await this.get(owner,id);for(const key of ['inventoryItems','currency','companions','spellSlots','portrait'])if(character[key]===undefined&&prior?.character?.[key]!==undefined)character={...character,[key]:prior.character[key]}}
const result=revision===0?await this.pool.query('INSERT INTO character_sheets(owner,id,character,deleted) VALUES($1,$2,$3,$4) ON CONFLICT(owner,id) DO NOTHING RETURNING id,character,revision,deleted',[owner,id,character,deleted]):await this.pool.query('UPDATE character_sheets SET character=$3,deleted=$4,revision=revision+1 WHERE owner=$1 AND id=$2 AND revision=$5 RETURNING id,character,revision,deleted',[owner,id,character,deleted,revision]); const result=revision===0?await this.pool.query('INSERT INTO character_sheets(owner,id,character,deleted) VALUES($1,$2,$3,$4) ON CONFLICT(owner,id) DO NOTHING RETURNING id,character,revision,deleted',[owner,id,character,deleted]):await this.pool.query('UPDATE character_sheets SET character=$3,deleted=$4,revision=revision+1 WHERE owner=$1 AND id=$2 AND revision=$5 RETURNING id,character,revision,deleted',[owner,id,character,deleted,revision]);
if(!result.rows[0])throw new Conflict();return result.rows[0]; if(!result.rows[0])throw new Conflict();return result.rows[0];

View File

@ -7,6 +7,7 @@ import RulesPicker,{type RuleEntry} from './RulesPicker.vue';
import InventoryFolio from './InventoryFolio.vue'; import InventoryFolio from './InventoryFolio.vue';
import CompanionsFolio from './CompanionsFolio.vue'; import CompanionsFolio from './CompanionsFolio.vue';
import SpellSlotsFolio from './SpellSlotsFolio.vue'; import SpellSlotsFolio from './SpellSlotsFolio.vue';
import PortraitPicker from './PortraitPicker.vue';
import type {IconName} from './icons'; import type {IconName} from './icons';
const navigation: {label:string,icon:IconName}[]=[{label:'Sheet',icon:'sheet'},{label:'Actions',icon:'actions'},{label:'Spells',icon:'spells'},{label:'Gear',icon:'gear'},{label:'More',icon:'notes'}]; const navigation: {label:string,icon:IconName}[]=[{label:'Sheet',icon:'sheet'},{label:'Actions',icon:'actions'},{label:'Spells',icon:'spells'},{label:'Gear',icon:'gear'},{label:'More',icon:'notes'}];
import {abilities,modifier,proficiency,signed,skills,skillBonus,addInventoryItem,changeHP,newCharacter,characterSchema,restPreview,spellSlotRestPreview,applyRest,type Character} from './model'; import {abilities,modifier,proficiency,signed,skills,skillBonus,addInventoryItem,changeHP,newCharacter,characterSchema,restPreview,spellSlotRestPreview,applyRest,type Character} from './model';
@ -24,6 +25,7 @@ const actionDraft=ref({name:'',bonus:0,damage:'',notes:''}),resourceDraft=ref({n
watch(c,()=>{if(c.value&&user.value)store.persist(c.value)},{deep:true}); watch(c,()=>{if(c.value&&user.value)store.persist(c.value)},{deep:true});
async function authenticate(){try{const response=await fetch('/api/session',{cache:'no-store'});if(!response.ok){localStorage.removeItem('character-identity');navigator.serviceWorker.controller?.postMessage('CLEAR_AUTH');throw new Error('Please sign in with Arrowstack to open your characters.')}const identity=await response.json();if(store.owner&&store.owner!==identity.sub){await store.flush();location.reload();return}user.value=identity;error.value='';localStorage.setItem('character-identity',JSON.stringify(user.value))}catch(e){if(e instanceof TypeError){try{const cached=JSON.parse(localStorage.getItem('character-identity')||'null');if(cached?.sub&&cached.expiresAt>Date.now()){user.value=cached;offline.value=true}else throw new Error()}catch{error.value='Connect to the internet to sign in with Arrowstack.'}}else error.value=(e as Error).message}} async function authenticate(){try{const response=await fetch('/api/session',{cache:'no-store'});if(!response.ok){localStorage.removeItem('character-identity');navigator.serviceWorker.controller?.postMessage('CLEAR_AUTH');throw new Error('Please sign in with Arrowstack to open your characters.')}const identity=await response.json();if(store.owner&&store.owner!==identity.sub){await store.flush();location.reload();return}user.value=identity;error.value='';localStorage.setItem('character-identity',JSON.stringify(user.value))}catch(e){if(e instanceof TypeError){try{const cached=JSON.parse(localStorage.getItem('character-identity')||'null');if(cached?.sub&&cached.expiresAt>Date.now()){user.value=cached;offline.value=true}else throw new Error()}catch{error.value='Connect to the internet to sign in with Arrowstack.'}}else error.value=(e as Error).message}}
onMounted(async()=>{await authenticate();if(user.value){try{await store.open(user.value.sub);activeId.value=store.all[0]?.id||'';if('serviceWorker'in navigator){await navigator.serviceWorker.register('/sw.js');const registration=await navigator.serviceWorker.ready;registration.active?.postMessage('CACHE_SHELL')}}catch{error.value='Could not open local character saves. Please retry.'}}loading.value=false;window.addEventListener('offline',()=>offline.value=true);window.addEventListener('online',async()=>{offline.value=false;await authenticate();if(user.value)await store.sync()});setInterval(()=>{if(user.value&&!offline.value)void store.sync()},30000);setInterval(()=>{if(user.value&&user.value.expiresAt<=Date.now()){user.value=null;drawer.value='';error.value='Your session expired. Connect and sign in again.'}},15000)}); onMounted(async()=>{await authenticate();if(user.value){try{await store.open(user.value.sub);activeId.value=store.all[0]?.id||'';if('serviceWorker'in navigator){await navigator.serviceWorker.register('/sw.js');const registration=await navigator.serviceWorker.ready;registration.active?.postMessage('CACHE_SHELL')}}catch{error.value='Could not open local character saves. Please retry.'}}loading.value=false;window.addEventListener('offline',()=>offline.value=true);window.addEventListener('online',async()=>{offline.value=false;await authenticate();if(user.value)await store.sync()});setInterval(()=>{if(user.value&&!offline.value)void store.sync()},30000);setInterval(()=>{if(user.value&&user.value.expiresAt<=Date.now()){user.value=null;drawer.value='';error.value='Your session expired. Connect and sign in again.'}},15000)});
function setPortrait(id:string,value:string|null){const character=store.all.find(x=>x.id===id);if(character)character.portrait=value}
function create(){draft.value=newCharacter();drawer.value='Character'} function create(){draft.value=newCharacter();drawer.value='Character'}
function edit(){if(c.value){draft.value=structuredClone(JSON.parse(JSON.stringify(c.value)));drawer.value='Character'}} function edit(){if(c.value){draft.value=structuredClone(JSON.parse(JSON.stringify(c.value)));drawer.value='Character'}}
function saveDraft(){try{const valid=characterSchema.parse(draft.value);const index=store.all.findIndex(x=>x.id===valid.id);if(index>=0)store.all[index]=valid;else store.all.push(valid);activeId.value=valid.id;store.persist(valid);drawer.value='';error.value=''}catch{message.value='Check your values: level 1–20, abilities 1–30, HP within maximum.'}} function saveDraft(){try{const valid=characterSchema.parse(draft.value);const index=store.all.findIndex(x=>x.id===valid.id);if(index>=0)store.all[index]=valid;else store.all.push(valid);activeId.value=valid.id;store.persist(valid);drawer.value='';error.value=''}catch{message.value='Check your values: level 1–20, abilities 1–30, HP within maximum.'}}
@ -57,7 +59,7 @@ function openAction(action:Character['actions'][number]){selectedAction.value=ac
<p v-if="store.saveError" class="notice danger" role="alert">{{store.saveError}}</p><p v-if="message" class="notice" role="status">{{message}} <button class="quiet" @click="message=''">Dismiss</button></p> <p v-if="store.saveError" class="notice danger" role="alert">{{store.saveError}}</p><p v-if="message" class="notice" role="status">{{message}} <button class="quiet" @click="message=''">Dismiss</button></p>
<section v-if="!campaignMode&&!c" class="empty"><UiIcon name="sheet" class="empty-icon" /><p class="eyebrow">A BLANK PAGE. A NEW STORY.</p><h1>Who will you become?</h1><p>Create a character and keep everything you need<br>for game night in your pocket.</p><button class="primary" @click="create">Create your first character →</button><label class="button quiet">Import a character<input type="file" accept="application/json,.json" hidden @change="importCharacter"></label></section> <section v-if="!campaignMode&&!c" class="empty"><UiIcon name="sheet" class="empty-icon" /><p class="eyebrow">A BLANK PAGE. A NEW STORY.</p><h1>Who will you become?</h1><p>Create a character and keep everything you need<br>for game night in your pocket.</p><button class="primary" @click="create">Create your first character →</button><label class="button quiet">Import a character<input type="file" accept="application/json,.json" hidden @change="importCharacter"></label></section>
<template v-if="!campaignMode&&c"> <template v-if="!campaignMode&&c">
<section class="hero"><div class="monogram">{{c.name.slice(0,1).toUpperCase()}}<span>✦</span></div><div><p class="eyebrow">LEVEL {{c.level}} · {{c.className||'ADVENTURER'}}</p><h1>{{c.name}}</h1><p>{{[c.species,c.background].filter(Boolean).join(' · ')||'Your story is waiting to be written.'}}</p></div><button class="edit" @click="edit">Edit character ↗</button></section> <section class="hero"><PortraitPicker :key="c.id" :model-value="c.portrait" :name="c.name" compact @update:model-value="setPortrait(c.id,$event)" /><div><p class="eyebrow">LEVEL {{c.level}} · {{c.className||'ADVENTURER'}}</p><h1>{{c.name}}</h1><p>{{[c.species,c.background].filter(Boolean).join(' · ')||'Your story is waiting to be written.'}}</p></div><button class="edit" @click="edit">Edit character ↗</button></section>
<template v-if="tab==='Sheet'"> <template v-if="tab==='Sheet'">
<div class="combat-grid"><button class="hp-card" @click="drawer='Hit points'"><div class="card-label"><span class="label-with-icon"><UiIcon name="hp" /> HIT POINTS</span><span>+ / −</span></div><div class="hp-value">{{c.hp.current}}<small>/ {{c.hp.max}}</small></div><div class="hp-track"><i :style="{width:`${100*c.hp.current/c.hp.max}%`}"></i></div><div class="hp-footer"><span>{{c.hp.temp}} temporary</span><span>Tap to damage or heal</span></div></button><div class="stat-card"><UiIcon name="ac" /><span>ARMOUR CLASS</span><strong>{{c.ac}}</strong><small>AC</small></div><div class="stat-card"><UiIcon name="initiative" /><span>INITIATIVE</span><strong>{{signed(modifier(c.abilities.dex))}}</strong><button class="quiet" @click="roll(modifier(c.abilities.dex));drawer='Dice'">Roll d20 ↗</button></div><div class="stat-card"><UiIcon name="speed" /><span>SPEED</span><strong>{{c.speed}}</strong><small>feet</small></div></div> <div class="combat-grid"><button class="hp-card" @click="drawer='Hit points'"><div class="card-label"><span class="label-with-icon"><UiIcon name="hp" /> HIT POINTS</span><span>+ / −</span></div><div class="hp-value">{{c.hp.current}}<small>/ {{c.hp.max}}</small></div><div class="hp-track"><i :style="{width:`${100*c.hp.current/c.hp.max}%`}"></i></div><div class="hp-footer"><span>{{c.hp.temp}} temporary</span><span>Tap to damage or heal</span></div></button><div class="stat-card"><UiIcon name="ac" /><span>ARMOUR CLASS</span><strong>{{c.ac}}</strong><small>AC</small></div><div class="stat-card"><UiIcon name="initiative" /><span>INITIATIVE</span><strong>{{signed(modifier(c.abilities.dex))}}</strong><button class="quiet" @click="roll(modifier(c.abilities.dex));drawer='Dice'">Roll d20 ↗</button></div><div class="stat-card"><UiIcon name="speed" /><span>SPEED</span><strong>{{c.speed}}</strong><small>feet</small></div></div>
<div class="ability-row"><button v-for="a in abilities" @click="roll(modifier(c.abilities[a]));drawer='Dice'"><UiIcon :name="a" /><span>{{a.toUpperCase()}}</span><strong>{{signed(modifier(c.abilities[a]))}}</strong><small>{{c.abilities[a]}}</small></button></div> <div class="ability-row"><button v-for="a in abilities" @click="roll(modifier(c.abilities[a]));drawer='Dice'"><UiIcon :name="a" /><span>{{a.toUpperCase()}}</span><strong>{{signed(modifier(c.abilities[a]))}}</strong><small>{{c.abilities[a]}}</small></button></div>
@ -101,6 +103,7 @@ function openAction(action:Character['actions'][number]){selectedAction.value=ac
<template v-if="drawer==='Account'"><p class="hint">Signed in as {{user.name}}. Characters sync to your account and remain available on this device for offline play.</p><button class="primary wide" @click="logout" :disabled="offline">Sign out of Character</button><p class="hint" v-if="offline">Reconnect to sign out.</p></template> <template v-if="drawer==='Account'"><p class="hint">Signed in as {{user.name}}. Characters sync to your account and remain available on this device for offline play.</p><button class="primary wide" @click="logout" :disabled="offline">Sign out of Character</button><p class="hint" v-if="offline">Reconnect to sign out.</p></template>
<form v-if="drawer==='Character'&&draft" class="character-form" @submit.prevent="saveDraft"> <form v-if="drawer==='Character'&&draft" class="character-form" @submit.prevent="saveDraft">
<div class="editor-intro"><UiIcon name="sheet" /><div><p class="eyebrow">THE NEXT CHAPTER IS YOURS</p><h3>Bring your hero to life.</h3><p>A name, a calling, a story worth telling.</p></div></div> <div class="editor-intro"><UiIcon name="sheet" /><div><p class="eyebrow">THE NEXT CHAPTER IS YOURS</p><h3>Bring your hero to life.</h3><p>A name, a calling, a story worth telling.</p></div></div>
<fieldset class="folio-section portrait-fieldset"><legend>Character portrait</legend><PortraitPicker :model-value="draft.portrait" :name="draft.name" @update:model-value="draft.portrait=$event" /><p class="hint">Choose a photo or illustration. It is cropped, compressed, saved for offline use and synced with this character.</p></fieldset>
<fieldset class="folio-section"><legend><UiIcon name="notes" />Identity</legend><label>Name<input v-model="draft.name" required maxlength="120" autocomplete="off"></label><div class="form-grid"><label>Class<input v-model="draft.className" maxlength="120" placeholder="Your calling"></label><label>Level<input type="number" v-model.number="draft.level" min="1" max="20" required></label><label>Species<input v-model="draft.species" maxlength="120" placeholder="Your ancestry"></label><label>Background<input v-model="draft.background" maxlength="120" placeholder="Your beginnings"></label></div></fieldset> <fieldset class="folio-section"><legend><UiIcon name="notes" />Identity</legend><label>Name<input v-model="draft.name" required maxlength="120" autocomplete="off"></label><div class="form-grid"><label>Class<input v-model="draft.className" maxlength="120" placeholder="Your calling"></label><label>Level<input type="number" v-model.number="draft.level" min="1" max="20" required></label><label>Species<input v-model="draft.species" maxlength="120" placeholder="Your ancestry"></label><label>Background<input v-model="draft.background" maxlength="120" placeholder="Your beginnings"></label></div></fieldset>
<fieldset class="folio-section"><legend><UiIcon name="str" />Ability scores</legend><div class="form-grid three ability-fields"><label v-for="a in abilities" :key="a"><span class="field-caption"><UiIcon :name="a" />{{a.toUpperCase()}}</span><input type="number" v-model.number="draft.abilities[a]" min="1" max="30" required></label></div></fieldset> <fieldset class="folio-section"><legend><UiIcon name="str" />Ability scores</legend><div class="form-grid three ability-fields"><label v-for="a in abilities" :key="a"><span class="field-caption"><UiIcon :name="a" />{{a.toUpperCase()}}</span><input type="number" v-model.number="draft.abilities[a]" min="1" max="30" required></label></div></fieldset>
<fieldset class="folio-section"><legend><UiIcon name="hp" />Vital statistics</legend><div class="form-grid three"><label>Max HP<input type="number" v-model.number="draft.hp.max" min="1" max="9999" required></label><label>Current HP<input type="number" v-model.number="draft.hp.current" min="0" :max="draft.hp.max" required></label><label>Temp HP<input type="number" v-model.number="draft.hp.temp" min="0" max="9999" required></label></div><div class="form-grid"><label><span class="field-caption"><UiIcon name="ac" />Armour class</span><input type="number" v-model.number="draft.ac" min="0" max="9999" required></label><label><span class="field-caption"><UiIcon name="speed" />Speed (ft)</span><input type="number" v-model.number="draft.speed" min="0" max="9999" required></label></div></fieldset> <fieldset class="folio-section"><legend><UiIcon name="hp" />Vital statistics</legend><div class="form-grid three"><label>Max HP<input type="number" v-model.number="draft.hp.max" min="1" max="9999" required></label><label>Current HP<input type="number" v-model.number="draft.hp.current" min="0" :max="draft.hp.max" required></label><label>Temp HP<input type="number" v-model.number="draft.hp.temp" min="0" max="9999" required></label></div><div class="form-grid"><label><span class="field-caption"><UiIcon name="ac" />Armour class</span><input type="number" v-model.number="draft.ac" min="0" max="9999" required></label><label><span class="field-caption"><UiIcon name="speed" />Speed (ft)</span><input type="number" v-model.number="draft.speed" min="0" max="9999" required></label></div></fieldset>

66
src/PortraitPicker.vue Normal file
View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import {ref} from 'vue';
const props=withDefaults(defineProps<{modelValue?:string|null;name:string;compact?:boolean}>(),{compact:false});
const emit=defineEmits<{(event:'update:modelValue',value:string|null):void}>();
const picker=ref<HTMLInputElement|null>(null),busy=ref(false),error=ref('');
const MAX_FILE_BYTES=12_000_000,MAX_DATA_LENGTH=320_000;
async function preparePortrait(file:File):Promise<string>{
if(!/^image\/(?:jpeg|png|webp|heic|heif)$/.test(file.type))throw new Error('Use a JPEG, PNG, WebP or iPhone photo.');
if(file.size>MAX_FILE_BYTES)throw new Error('This image is too large. Choose one under 12 MB.');
const objectUrl=URL.createObjectURL(file);
let image:HTMLImageElement;
try{
image=await new Promise<HTMLImageElement>((resolve,reject)=>{
const img=new Image();img.onload=()=>resolve(img);img.onerror=()=>reject(new Error('This image could not be opened. Try a JPG or PNG.'));img.src=objectUrl;
});
const sourceWidth=image.naturalWidth,sourceHeight=image.naturalHeight;
if(!sourceWidth||!sourceHeight)throw new Error('Image dimensions could not be read.');
// Portrait crop: 4:5 aspect ratio; never upload or store full resolution.
const ratio=4/5;
const sourceRatio=sourceWidth/sourceHeight;
const cropWidth=sourceRatio>ratio?sourceHeight*ratio:sourceWidth;
const cropHeight=sourceRatio>ratio?sourceHeight:sourceWidth/ratio;
const x=(sourceWidth-cropWidth)/2,y=(sourceHeight-cropHeight)/2;
for(const width of [480,400,320]){
const canvas=document.createElement('canvas');canvas.width=width;canvas.height=Math.round(width/ratio);
const ctx=canvas.getContext('2d');if(!ctx)throw new Error('Image editor is not available on this device.');
ctx.fillStyle='#e9dfc7';ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.drawImage(image,x,y,cropWidth,cropHeight,0,0,canvas.width,canvas.height);
for(const quality of [0.82,0.68,0.53,0.4]){
let value=canvas.toDataURL('image/webp',quality);
if(!value.startsWith('data:image/webp;'))value=canvas.toDataURL('image/jpeg',quality);
if(value.length<=MAX_DATA_LENGTH)return value;
}
}
throw new Error('Could not shrink the image enough. Try a simpler or smaller image.');
}finally{URL.revokeObjectURL(objectUrl)}
}
async function upload(event:Event){
const input=event.target as HTMLInputElement,file=input.files?.[0];
input.value='';if(!file||busy.value)return;
busy.value=true;error.value='';
try{const portrait=await preparePortrait(file);emit('update:modelValue',portrait)}
catch(e){error.value=(e as Error).message}
finally{busy.value=false}
}
function open(){if(!busy.value)picker.value?.click()}
</script>
<template>
<div class="portrait-picker" :class="{'portrait-compact':compact}">
<button type="button" class="portrait-frame" :aria-label="(modelValue?'Change':'Upload')+' portrait for '+name" :disabled="busy" @click="open">
<img v-if="modelValue" :src="modelValue" :alt="'Portrait of '+name" />
<span v-else class="portrait-initial">{{name?.slice(0,1).toUpperCase()||'✦'}}</span>
<span class="portrait-camera" aria-hidden="true">✎</span>
</button>
<div v-if="!compact" class="portrait-picker-copy">
<strong>{{modelValue?'Your character portrait':'Give your hero a face'}}</strong>
<p>{{modelValue?'Tap to replace the portrait.':'Select an illustration or photo from this device.'}}</p>
<div class="portrait-picker-actions">
<button type="button" class="quiet" :disabled="busy" @click="open">{{busy?'Preparing image…':modelValue?'Change portrait':'Upload portrait'}}</button>
<button v-if="modelValue" type="button" class="quiet danger-text" :disabled="busy" @click="emit('update:modelValue',null);error=''">Remove</button>
</div>
</div>
<input ref="picker" class="visually-hidden-portrait" type="file" accept="image/jpeg,image/png,image/webp,image/heic,image/heif" :disabled="busy" :aria-label="'Select portrait image for '+name" @change="upload" />
<p v-if="error" class="portrait-error" role="alert">{{error}}</p>
</div>
</template>

View File

@ -133,10 +133,11 @@ export function spellSlotRestPreview(c:Character,type:'short-rest'|'long-rest'){
.filter(s=>s.current!==s.max); .filter(s=>s.current!==s.max);
} }
export const characterSchema=z.object({schemaVersion:z.literal(1),id:z.string(),name:z.string().min(1).max(120),className:text,level:z.number().int().min(1).max(20),species:text,background:text,abilities:z.object({str:score,dex:score,con:score,int:score,wis:score,cha:score}),skills:z.record(z.string(),z.enum(['none','proficient','expert'])),saves:z.array(z.enum(abilities)),hp:z.object({current:counter,max:z.number().int().min(1).max(9999),temp:counter}).refine(h=>h.current<=h.max),ac:counter,speed:counter,actions:z.array(actionSchema).max(200),resources:z.array(resourceSchema).max(200),spells:z.array(spellSchema).max(500),spellSlots:spellSlotsSchema.optional(),inventory:text,currency:currencySchema.optional(),inventoryItems:z.array(inventoryItemSchema).max(300).refine(items=>new Set(items.map(i=>i.id)).size===items.length,'Item ids must be unique').optional(),companions:z.array(companionSchema).max(100).refine(items=>new Set(items.map(i=>i.id)).size===items.length,'Companion ids must be unique').optional(),notes:text}); export const portraitSchema=z.string().max(320000).regex(/^data:image\/(?:webp|jpeg);base64,[A-Za-z0-9+/]+={0,2}$/,'Use a compressed JPEG or WebP portrait.');
export const characterSchema=z.object({schemaVersion:z.literal(1),id:z.string(),name:z.string().min(1).max(120),portrait:portraitSchema.nullable().optional(),className:text,level:z.number().int().min(1).max(20),species:text,background:text,abilities:z.object({str:score,dex:score,con:score,int:score,wis:score,cha:score}),skills:z.record(z.string(),z.enum(['none','proficient','expert'])),saves:z.array(z.enum(abilities)),hp:z.object({current:counter,max:z.number().int().min(1).max(9999),temp:counter}).refine(h=>h.current<=h.max),ac:counter,speed:counter,actions:z.array(actionSchema).max(200),resources:z.array(resourceSchema).max(200),spells:z.array(spellSchema).max(500),spellSlots:spellSlotsSchema.optional(),inventory:text,currency:currencySchema.optional(),inventoryItems:z.array(inventoryItemSchema).max(300).refine(items=>new Set(items.map(i=>i.id)).size===items.length,'Item ids must be unique').optional(),companions:z.array(companionSchema).max(100).refine(items=>new Set(items.map(i=>i.id)).size===items.length,'Companion ids must be unique').optional(),notes:text});
export type Character=z.infer<typeof characterSchema>; export type Character=z.infer<typeof characterSchema>;
export const skills:Record<string,Ability>={'Acrobatics':'dex','Animal Handling':'wis','Arcana':'int','Athletics':'str','Deception':'cha','History':'int','Insight':'wis','Intimidation':'cha','Investigation':'int','Medicine':'wis','Nature':'int','Perception':'wis','Performance':'cha','Persuasion':'cha','Religion':'int','Sleight of Hand':'dex','Stealth':'dex','Survival':'wis'}; export const skills:Record<string,Ability>={'Acrobatics':'dex','Animal Handling':'wis','Arcana':'int','Athletics':'str','Deception':'cha','History':'int','Insight':'wis','Intimidation':'cha','Investigation':'int','Medicine':'wis','Nature':'int','Perception':'wis','Performance':'cha','Persuasion':'cha','Religion':'int','Sleight of Hand':'dex','Stealth':'dex','Survival':'wis'};
export function newCharacter():Character{return {schemaVersion:1,id:crypto.randomUUID(),name:'New adventurer',className:'',level:1,species:'',background:'',abilities:{str:10,dex:10,con:10,int:10,wis:10,cha:10},skills:{},saves:[],hp:{current:10,max:10,temp:0},ac:10,speed:30,actions:[],resources:[],spells:[],spellSlots:{levels:[],pact:null},inventory:'',currency:{gp:0,sp:0,cp:0},inventoryItems:[],companions:[],notes:''}} export function newCharacter():Character{return {schemaVersion:1,id:crypto.randomUUID(),name:'New adventurer',portrait:null,className:'',level:1,species:'',background:'',abilities:{str:10,dex:10,con:10,int:10,wis:10,cha:10},skills:{},saves:[],hp:{current:10,max:10,temp:0},ac:10,speed:30,actions:[],resources:[],spells:[],spellSlots:{levels:[],pact:null},inventory:'',currency:{gp:0,sp:0,cp:0},inventoryItems:[],companions:[],notes:''}}
export const modifier=(n:number)=>Math.floor((n-10)/2); export const modifier=(n:number)=>Math.floor((n-10)/2);
export const proficiency=(level:number)=>2+Math.floor((level-1)/4); export const proficiency=(level:number)=>2+Math.floor((level-1)/4);
export const signed=(n:number)=>n>=0?`+${n}`:`${n}`; export const signed=(n:number)=>n>=0?`+${n}`:`${n}`;

View File

@ -237,3 +237,24 @@ input[type=checkbox]{accent-color:#3b603d}
.dm-coins{display:flex;gap:16px;flex-wrap:wrap;margin:13px 0;font-family:Georgia,serif;font-size:19px;color:#70512a} .dm-coins{display:flex;gap:16px;flex-wrap:wrap;margin:13px 0;font-family:Georgia,serif;font-size:19px;color:#70512a}
.dm-toolkit .campaign-actions{flex-wrap:wrap}.campaign-subnav{flex-wrap:wrap} .dm-toolkit .campaign-actions{flex-wrap:wrap}.campaign-subnav{flex-wrap:wrap}
@media(max-width:700px){.dm-toolkit .folio-heading h2{font-size:22px}.dm-toolkit .folio-heading h2 .ui-icon{width:31px;height:31px}.dm-quest-entry h3,.dm-loot-entry h3{font-size:22px}.dm-tool-editor .form-grid.three{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.dm-toolkit .campaign-actions button{min-height:44px}} @media(max-width:700px){.dm-toolkit .folio-heading h2{font-size:22px}.dm-toolkit .folio-heading h2 .ui-icon{width:31px;height:31px}.dm-quest-entry h3,.dm-loot-entry h3{font-size:22px}.dm-tool-editor .form-grid.three{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.dm-toolkit .campaign-actions button{min-height:44px}}
/* Character portrait: painted picture-frame on the same continuous parchment. */
.portrait-picker{display:flex;gap:16px;align-items:center;flex-wrap:wrap}
.portrait-frame{position:relative;display:block;flex:none;overflow:hidden;width:118px;height:146px;padding:0;border:3px solid #aa8549;border-radius:41% 41% 12px 12px / 19% 19% 12px 12px;background:radial-gradient(ellipse at 35% 25%,#f1dbac,#b99b64);box-shadow:0 0 0 2px #eed7a2,0 3px 0 3px #755a38,0 8px 17px #55422236;min-height:0;isolation:isolate}
.portrait-frame:hover{background:#dfcfad}
.portrait-frame img{width:100%;height:100%;display:block;object-fit:cover;object-position:center}
.portrait-initial{display:grid;place-items:center;width:100%;height:100%;font-family:Georgia,serif;font-size:56px;color:#684c2c;text-shadow:0 2px 0 #fff1d884}
.portrait-camera{position:absolute;bottom:5px;right:6px;display:grid;place-items:center;width:27px;height:27px;background:#284e3d;color:#f7ead0;border:1px solid #f8e3a2;border-radius:50%;box-shadow:0 2px 5px #36280e66;font-size:17px}
.portrait-picker-copy{flex:1;min-width:160px}
.portrait-picker-copy strong{display:block;font-family:Georgia,serif;font-weight:400;font-size:20px;color:#59452a}
.portrait-picker-copy p{font-size:12px;color:#7d6948;line-height:1.45;margin:5px 0 8px}
.portrait-picker-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.portrait-picker-actions .quiet{min-height:44px}
.portrait-fieldset{margin:12px 0 24px!important}
.portrait-fieldset .hint{font-size:11px;margin:15px 0 0}
.portrait-compact{flex:none}
.portrait-compact .portrait-frame{width:92px;height:116px}
.portrait-error{width:100%;color:#9d2e20;font-size:12px;margin:4px 0;line-height:1.5}
.visually-hidden-portrait{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
@media(max-width:700px){.portrait-compact .portrait-frame{width:69px;height:89px}.portrait-compact .portrait-initial{font-size:35px}.portrait-picker:not(.portrait-compact) .portrait-frame{width:104px;height:132px}}

View File

@ -1,9 +1,26 @@
import 'fake-indexeddb/auto'; import 'fake-indexeddb/auto';
import {test} from 'node:test';import assert from 'node:assert/strict'; import {test} from 'node:test';import assert from 'node:assert/strict';
import {newCharacter,changeHP,modifier,proficiency,skillBonus,applyRest,restPreview,characterSchema} from '../src/model.ts'; import {newCharacter,changeHP,modifier,proficiency,skillBonus,applyRest,restPreview,characterSchema,portraitSchema} from '../src/model.ts';
import {CharacterDB,saveCharacter,loadCharacters,deleteCharacter} from '../src/db.ts'; import {CharacterDB,saveCharacter,loadCharacters,deleteCharacter} from '../src/db.ts';
test('damage consumes temporary HP, clamps zero, healing clamps max',()=>{const c=newCharacter();c.hp={current:20,max:30,temp:5};changeHP(c,-3);assert.deepEqual(c.hp,{current:20,max:30,temp:2});changeHP(c,-7);assert.deepEqual(c.hp,{current:15,max:30,temp:0});changeHP(c,-100);assert.equal(c.hp.current,0);changeHP(c,100);assert.equal(c.hp.current,30)}); test('damage consumes temporary HP, clamps zero, healing clamps max',()=>{const c=newCharacter();c.hp={current:20,max:30,temp:5};changeHP(c,-3);assert.deepEqual(c.hp,{current:20,max:30,temp:2});changeHP(c,-7);assert.deepEqual(c.hp,{current:15,max:30,temp:0});changeHP(c,-100);assert.equal(c.hp.current,0);changeHP(c,100);assert.equal(c.hp.current,30)});
test('ability and proficiency calculations include expertise',()=>{assert.equal(modifier(9),-1);assert.equal(proficiency(1),2);assert.equal(proficiency(5),3);assert.equal(proficiency(20),6);const c=newCharacter();c.level=5;c.abilities.dex=18;c.skills.Stealth='expert';assert.equal(skillBonus(c,'Stealth'),10)}); test('ability and proficiency calculations include expertise',()=>{assert.equal(modifier(9),-1);assert.equal(proficiency(1),2);assert.equal(proficiency(5),3);assert.equal(proficiency(20),6);const c=newCharacter();c.level=5;c.abilities.dex=18;c.skills.Stealth='expert';assert.equal(skillBonus(c,'Stealth'),10)});
test('short and long rest reset only declared resources',()=>{const c=newCharacter();c.resources=[{id:'s',name:'Second Wind',current:0,max:1,reset:'short-rest'},{id:'l',name:'Slots',current:0,max:3,reset:'long-rest'},{id:'m',name:'Charges',current:0,max:2,reset:'manual'}];c.hp.current=3;c.hp.temp=4;assert.equal(restPreview(c,'short-rest').length,1);applyRest(c,'short-rest');assert.equal(c.hp.current,3);assert.deepEqual(c.resources.map(r=>r.current),[1,0,0]);applyRest(c,'long-rest');assert.deepEqual(c.resources.map(r=>r.current),[1,3,0]);assert.equal(c.hp.current,10);assert.equal(c.hp.temp,0)}); test('short and long rest reset only declared resources',()=>{const c=newCharacter();c.resources=[{id:'s',name:'Second Wind',current:0,max:1,reset:'short-rest'},{id:'l',name:'Slots',current:0,max:3,reset:'long-rest'},{id:'m',name:'Charges',current:0,max:2,reset:'manual'}];c.hp.current=3;c.hp.temp=4;assert.equal(restPreview(c,'short-rest').length,1);applyRest(c,'short-rest');assert.equal(c.hp.current,3);assert.deepEqual(c.resources.map(r=>r.current),[1,0,0]);applyRest(c,'long-rest');assert.deepEqual(c.resources.map(r=>r.current),[1,3,0]);assert.equal(c.hp.current,10);assert.equal(c.hp.temp,0)});
test('imports reject invalid characters and counters',()=>{const c=newCharacter();c.level=21;assert.equal(characterSchema.safeParse(c).success,false);c.level=1;c.resources=[{id:'x',name:'x',current:2,max:1,reset:'manual'}];assert.equal(characterSchema.safeParse(c).success,false)}); test('imports reject invalid characters and counters',()=>{const c=newCharacter();c.level=21;assert.equal(characterSchema.safeParse(c).success,false);c.level=1;c.resources=[{id:'x',name:'x',current:2,max:1,reset:'manual'}];assert.equal(characterSchema.safeParse(c).success,false)});
test('IndexedDB persists and separates characters by account',async()=>{const database=new CharacterDB('test-'+crypto.randomUUID()),c=newCharacter();c.name='First account';await saveCharacter('alice',c,database);const other=structuredClone(c);other.name='Second account';await saveCharacter('bob',other,database);assert.equal((await loadCharacters('alice',database))[0].name,'First account');assert.equal((await loadCharacters('bob',database))[0].name,'Second account');await deleteCharacter('alice',c.id,database);assert.equal((await loadCharacters('alice',database)).length,0);assert.equal((await loadCharacters('bob',database)).length,1);await database.delete()}); test('IndexedDB persists and separates characters by account',async()=>{const database=new CharacterDB('test-'+crypto.randomUUID()),c=newCharacter();c.name='First account';await saveCharacter('alice',c,database);const other=structuredClone(c);other.name='Second account';await saveCharacter('bob',other,database);assert.equal((await loadCharacters('alice',database))[0].name,'First account');assert.equal((await loadCharacters('bob',database))[0].name,'Second account');await deleteCharacter('alice',c.id,database);assert.equal((await loadCharacters('alice',database)).length,0);assert.equal((await loadCharacters('bob',database)).length,1);await database.delete()});
test('character portrait accepts compact JPEG/WebP image data, rejects unsafe and huge inputs',()=>{
const c=newCharacter(),portrait='data:image/webp;base64,'+'A'.repeat(24);
c.portrait=portrait;assert.equal(characterSchema.parse(c).portrait,portrait);
c.portrait=null;assert.equal(characterSchema.parse(c).portrait,null);
delete c.portrait;assert.equal(characterSchema.parse(c).portrait,undefined);
for(const invalid of ['https://another.site/image.png','data:image/svg+xml;base64,AAAA','data:image/png;base64,AAAA','data:image/webp;base64,@@@','data:image/jpeg;base64,'+'A'.repeat(320001)])
assert.equal(portraitSchema.safeParse(invalid).success,false);
});
test('portrait round trips through offline saves and JSON backup',async()=>{
const db=new CharacterDB('portrait-'+crypto.randomUUID()),c=newCharacter();
c.portrait='data:image/jpeg;base64,'+'a'.repeat(64);
await saveCharacter('alice',c,db);assert.equal((await loadCharacters('alice',db))[0].portrait,c.portrait);
const backup=JSON.stringify((await loadCharacters('alice',db))[0]);
assert.equal(characterSchema.parse(JSON.parse(backup)).portrait,c.portrait);
await db.delete();
});

View File

@ -4,3 +4,17 @@ function remote(){const rows=new Map<string,any>();const request=(async(url:stri
test('existing device saves upload once, pull MCP edits, and delete using account revisions',async()=>{const database=new CharacterDB('sync-'+crypto.randomUUID()),r=remote(),c=newCharacter();await saveCharacter('alice',c,database);await syncCharacters('alice',database,r.request);assert.equal(r.rows.get(c.id).revision,1);await syncCharacters('alice',database,r.request);assert.equal(r.rows.get(c.id).revision,1);r.rows.set(c.id,{...r.rows.get(c.id),revision:2,character:{...c,name:'MCP edit'}});await syncCharacters('alice',database,r.request);assert.equal((await loadCharacters('alice',database))[0].name,'MCP edit');assert.equal(await saveCharacter('alice',(await loadCharacters('alice',database))[0],database),false);await deleteCharacter('alice',c.id,database);await syncCharacters('alice',database,r.request);assert.equal(r.rows.get(c.id).deleted,true);assert.equal((await loadCharacters('alice',database)).length,0);await database.delete()}); test('existing device saves upload once, pull MCP edits, and delete using account revisions',async()=>{const database=new CharacterDB('sync-'+crypto.randomUUID()),r=remote(),c=newCharacter();await saveCharacter('alice',c,database);await syncCharacters('alice',database,r.request);assert.equal(r.rows.get(c.id).revision,1);await syncCharacters('alice',database,r.request);assert.equal(r.rows.get(c.id).revision,1);r.rows.set(c.id,{...r.rows.get(c.id),revision:2,character:{...c,name:'MCP edit'}});await syncCharacters('alice',database,r.request);assert.equal((await loadCharacters('alice',database))[0].name,'MCP edit');assert.equal(await saveCharacter('alice',(await loadCharacters('alice',database))[0],database),false);await deleteCharacter('alice',c.id,database);await syncCharacters('alice',database,r.request);assert.equal(r.rows.get(c.id).deleted,true);assert.equal((await loadCharacters('alice',database)).length,0);await database.delete()});
test('offline conflict preserves local edits as a new copy and pulls remote sheet',async()=>{const database=new CharacterDB('sync-'+crypto.randomUUID()),r=remote(),c=newCharacter();await saveCharacter('alice',c,database);await syncCharacters('alice',database,r.request);await saveCharacter('alice',{...c,notes:'Offline edits'},database);r.rows.set(c.id,{...r.rows.get(c.id),revision:2,character:{...c,notes:'MCP edits'}});const result=await syncCharacters('alice',database,r.request);assert.equal(result.conflicts,1);assert.equal(result.characters.length,2);assert.equal(result.characters.find(x=>x.id===c.id)?.notes,'MCP edits');assert.equal(result.characters.find(x=>x.id!==c.id)?.notes,'Offline edits');await syncCharacters('alice',database,r.request);assert.equal(r.rows.size,2);await database.delete()}); test('offline conflict preserves local edits as a new copy and pulls remote sheet',async()=>{const database=new CharacterDB('sync-'+crypto.randomUUID()),r=remote(),c=newCharacter();await saveCharacter('alice',c,database);await syncCharacters('alice',database,r.request);await saveCharacter('alice',{...c,notes:'Offline edits'},database);r.rows.set(c.id,{...r.rows.get(c.id),revision:2,character:{...c,notes:'MCP edits'}});const result=await syncCharacters('alice',database,r.request);assert.equal(result.conflicts,1);assert.equal(result.characters.length,2);assert.equal(result.characters.find(x=>x.id===c.id)?.notes,'MCP edits');assert.equal(result.characters.find(x=>x.id!==c.id)?.notes,'Offline edits');await syncCharacters('alice',database,r.request);assert.equal(r.rows.size,2);await database.delete()});
test('network failure retains pending saves and does not touch another local account',async()=>{const database=new CharacterDB('sync-'+crypto.randomUUID()),c=newCharacter();await saveCharacter('alice',c,database);await saveCharacter('bob',{...c,name:'Bob'},database);await assert.rejects(syncCharacters('alice',database,(async()=>{throw new TypeError('offline')}) as typeof fetch));assert.equal((await loadCharacters('alice',database)).length,1);assert.equal((await loadCharacters('bob',database))[0].name,'Bob');await database.delete()}); test('network failure retains pending saves and does not touch another local account',async()=>{const database=new CharacterDB('sync-'+crypto.randomUUID()),c=newCharacter();await saveCharacter('alice',c,database);await saveCharacter('bob',{...c,name:'Bob'},database);await assert.rejects(syncCharacters('alice',database,(async()=>{throw new TypeError('offline')}) as typeof fetch));assert.equal((await loadCharacters('alice',database)).length,1);assert.equal((await loadCharacters('bob',database))[0].name,'Bob');await database.delete()});
test('portrait remains after sync and later edits, and explicit removal syncs',async()=>{
const database=new CharacterDB('portrait-sync-'+crypto.randomUUID()),r=remote(),c=newCharacter();
c.portrait='data:image/webp;base64,'+'a'.repeat(72);
await saveCharacter('alice',c,database);await syncCharacters('alice',database,r.request);
assert.equal(r.rows.get(c.id).character.portrait,c.portrait);
const edited=(await loadCharacters('alice',database))[0];edited.name='Updated bard';
await saveCharacter('alice',edited,database);await syncCharacters('alice',database,r.request);
assert.equal(r.rows.get(c.id).character.portrait,c.portrait);
edited.portrait=null;await saveCharacter('alice',edited,database);
await syncCharacters('alice',database,r.request);
assert.equal(r.rows.get(c.id).character.portrait,null);
await database.delete();
});