From c4a096dc2555cf2669c694df5bd8d8f4ee9b9b25 Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 8 Oct 2026 05:02:50 +0000 Subject: [PATCH] Mirror character portrait upload from 9ddc680 --- README.md | 3 ++ server/storage.mjs | 2 +- src/App.vue | 5 +++- src/PortraitPicker.vue | 66 +++++++++++++++++++++++++++++++++++++++++ src/model.ts | 5 ++-- src/style.css | 21 +++++++++++++ tests/character.test.ts | 19 +++++++++++- tests/sync.test.ts | 14 +++++++++ 8 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 src/PortraitPicker.vue diff --git a/README.md b/README.md index c94286c..b9f7f47 100644 --- a/README.md +++ b/README.md @@ -68,3 +68,6 @@ Gear tracks gold (gp), silver (sp) and copper (cp) separately, with integer add/ ## 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. + +## 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. diff --git a/server/storage.mjs b/server/storage.mjs index 214a0fc..032f078 100644 --- a/server/storage.mjs +++ b/server/storage.mjs @@ -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 put(owner,id,character,revision,deleted=false){ // 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]); if(!result.rows[0])throw new Conflict();return result.rows[0]; diff --git a/src/App.vue b/src/App.vue index 9741238..b99c9a1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -7,6 +7,7 @@ import RulesPicker,{type RuleEntry} from './RulesPicker.vue'; import InventoryFolio from './InventoryFolio.vue'; import CompanionsFolio from './CompanionsFolio.vue'; import SpellSlotsFolio from './SpellSlotsFolio.vue'; +import PortraitPicker from './PortraitPicker.vue'; 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'}]; 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}); 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)}); +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 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.'}} @@ -57,7 +59,7 @@ function openAction(action:Character['actions'][number]){selectedAction.value=ac

{{message}}

A BLANK PAGE. A NEW STORY.

Who will you become?

Create a character and keep everything you need
for game night in your pocket.