From a67509b42e00bb7bf6863e4ebaf67e7c33a4db0e Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 7 Oct 2026 21:37:40 +0000 Subject: [PATCH] Release DM fieldbook v0.6.0 --- README.md | 3 ++ package-lock.json | 4 +-- package.json | 2 +- server/campaigns.mjs | 8 ++--- server/index.mjs | 2 +- server/mcp.mjs | 2 +- src/CampaignsView.vue | 68 +++++++++++++++++++++++++++-------------- src/style.css | 19 ++++++++++++ tests/campaigns.test.ts | 25 +++++++++++++++ 9 files changed, 101 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 61759cc..491823f 100644 --- a/README.md +++ b/README.md @@ -54,3 +54,6 @@ Authenticated API: GET /api/catalogue?edition=2024&kind=spell&q=Fire%20Bolt (nam ## Inventory Gear uses structured inventory entries with quantities, equipment and attunement markers, item notes and SRD provenance. Repeated catalogue selections increment the same entry; editions remain distinct. Existing inventory text remains in the inventory notebook. The additive optional `inventoryItems` field is preserved when older clients omit it, under the existing account and revision guards. Explicitly sending an empty array clears the collection. Equipment markers do not automatically change character stats. + +## DM fieldbook +Campaigns open to Session for DMs and Party for players. Session combines active initiative, snapshot damage/healing, manual conditions, a session agenda, DM-only quick notes and completed session/encounter history. Party shows owner names and saved-HP delegation. Campaign holds single-use player/co-DM invitations and member management; Journal holds notes/handouts and NPCs. Condition markers do not automate effects or update saved characters. Existing encounters without a condition list remain usable. The existing MCP advance_campaign_encounter tool accepts action `conditions`, participant_id and a replacement conditions array (empty clears), guarded by campaign DM role and revision. Loot proposals and richer adventure preparation are later stages. diff --git a/package-lock.json b/package-lock.json index eb1dd9d..760476b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dnd-character-pwa", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dnd-character-pwa", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.32.1", "dexie": "^4.2.0", diff --git a/package.json b/package.json index 2e54a4b..e2a92af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dnd-character-pwa", - "version": "0.5.0", + "version": "0.6.0", "private": true, "type": "module", "scripts": { diff --git a/server/campaigns.mjs b/server/campaigns.mjs index ae96319..dee4d7b 100644 --- a/server/campaigns.mjs +++ b/server/campaigns.mjs @@ -15,7 +15,7 @@ export const campaignInputs={ removeNpc:z.object({id}), session:z.object({id:id.optional(),title,date:z.string().max(60).default(''),summary:text.default(''),status:z.enum(['planned','active','complete']).default('planned')}), encounter:z.object({name:title,participants:z.array(z.object({name:title,initiative:z.number().int().min(-50).max(100),hp:z.number().int().min(0).max(9999),maxHp:z.number().int().min(1).max(9999),ac:z.number().int().min(0).max(9999)}).refine(x=>x.hp<=x.maxHp)).min(1).max(50)}), - encounterStep:z.object({id,action:z.enum(['next','finish','damage','heal']),participant_id:id.optional(),amount:z.number().int().min(1).max(9999).optional()}), + encounterStep:z.object({id,action:z.enum(['next','finish','damage','heal','conditions']),participant_id:id.optional(),amount:z.number().int().min(1).max(9999).optional(),conditions:z.array(z.string().trim().min(1).max(60)).max(12).optional()}), partyHp:z.object({owner:id,character_id:id,expected_revision:z.number().int().min(1),amount:z.number().int().min(-9999).max(9999)}) }; const hash=s=>createHash('sha256').update(s).digest('hex'); @@ -49,8 +49,8 @@ export class Campaigns{ else if(action==='removeNote'){const n=d.notes.find(n=>n.id===p.id);if(n?.visibility==='dm')dm(d,sub);if(!n||n.author!==sub)throw new CampaignError('Only the author can delete this note');d.notes=d.notes.filter(n=>n.id!==p.id)} else if(action==='npc'||action==='session'){dm(d,sub);const key=action==='npc'?'npcs':'sessions',items=d[key],old=p.id?items.find(n=>n.id===p.id):null;if(p.id&&!old)throw new CampaignError('Entry not found',404);if(!old&&items.length>=200)throw new CampaignError('Entry limit reached',400);const n={...p,id:p.id||randomUUID()};if(old)Object.assign(old,n);else items.push(n)} else if(action==='removeNpc'){dm(d,sub);d.npcs=d.npcs.filter(n=>n.id!==p.id)} - else if(action==='encounter'){dm(d,sub);if(d.encounters.filter(e=>e.status==='active').length>=10)throw new CampaignError('Finish an active encounter first',400);d.encounters=d.encounters.filter(e=>e.status==='active').concat(d.encounters.filter(e=>e.status==='complete').slice(-49));d.encounters.push({id:randomUUID(),name:p.name,participants:p.participants.map(x=>({...x,id:randomUUID()})).sort((a,b)=>b.initiative-a.initiative),round:1,turn:0,status:'active'})} - else if(action==='encounterStep'){dm(d,sub);const e=d.encounters.find(x=>x.id===p.id);if(!e||e.status!=='active')throw new CampaignError('Active encounter not found',404);if(p.action==='finish')e.status='complete';else if(p.action==='next'){e.turn=(e.turn+1)%e.participants.length;if(e.turn===0)e.round++}else{const participant=e.participants.find(x=>x.id===p.participant_id);if(!participant||!p.amount)throw new CampaignError('Participant and amount required',400);participant.hp=p.action==='damage'?Math.max(0,participant.hp-p.amount):Math.min(participant.maxHp,participant.hp+p.amount)}} + else if(action==='encounter'){dm(d,sub);if(d.encounters.filter(e=>e.status==='active').length>=10)throw new CampaignError('Finish an active encounter first',400);d.encounters=d.encounters.filter(e=>e.status==='active').concat(d.encounters.filter(e=>e.status==='complete').slice(-49));d.encounters.push({id:randomUUID(),name:p.name,participants:p.participants.map(x=>({...x,id:randomUUID(),conditions:[]})).sort((a,b)=>b.initiative-a.initiative),round:1,turn:0,status:'active'})} + else if(action==='encounterStep'){dm(d,sub);const e=d.encounters.find(x=>x.id===p.id);if(!e||e.status!=='active')throw new CampaignError('Active encounter not found',404);if(p.action==='finish')e.status='complete';else if(p.action==='next'){e.turn=(e.turn+1)%e.participants.length;if(e.turn===0)e.round++}else{const participant=e.participants.find(x=>x.id===p.participant_id);if(!participant)throw new CampaignError('Participant required',400);if(p.action==='conditions'){if(!p.conditions)throw new CampaignError('Conditions required',400);participant.conditions=[...new Set(p.conditions)]}else{if(!p.amount)throw new CampaignError('Amount required',400);participant.hp=p.action==='damage'?Math.max(0,participant.hp-p.amount):Math.min(participant.maxHp,participant.hp+p.amount)}}} const updated=await this.pool.query('UPDATE campaigns SET data=$2,revision=revision+1 WHERE id=$1 AND revision=$3 RETURNING revision',[cid,d,expected]);if(!updated.rows[0])throw new CampaignError('Campaign changed. Reload before saving.',409); return {...extra,campaign:d.members.some(m=>m.sub===sub)?await this.view(sub,cid):null}; } @@ -73,7 +73,7 @@ export function registerCampaignTools({tool,campaigns,sub,allowed,read,write}){ tool('get_campaign','Read your party, visible notes, NPCs, sessions and encounters. Private notes and DM secrets are filtered server-side.',{campaign_id},read,async a=>({campaign:await campaigns.view(sub,a.campaign_id)})); tool('create_campaign','Create a campaign; you become its first DM.',campaignInputs.create.shape,write,async a=>{allowed();return {campaign:await campaigns.create(sub,'MCP adventurer',a)}}); tool('join_campaign','Redeem a single-use, seven-day invitation. Do not share the invitation code with unintended recipients.',campaignInputs.join.shape,write,async a=>{allowed();return {campaign:await campaigns.join(sub,'MCP adventurer',a)}}); - for(const [action,name,description] of [['invite','invite_campaign_member','DM: create a single-use player or co-DM invitation.'],['assign','assign_campaign_character','Assign your own PC and explicitly allow or revoke DM combat editing.'],['unassign','unassign_campaign_character','Remove your own PC from this campaign.'],['member','manage_campaign_member','DM: change a member role or remove a member; the last DM cannot be removed.'],['note','save_campaign_note','Save your own private, party-visible or DM-only note. Players cannot create DM-only notes.'],['removeNote','delete_campaign_note','Delete your own campaign note.'],['npc','save_campaign_npc','DM: create/edit an NPC; hidden by default. NPC notes remain DM-only even when visible.'],['removeNpc','delete_campaign_npc','DM: delete an NPC.'],['session','save_campaign_session','DM: plan/update a session and party-visible summary.'],['encounter','start_campaign_encounter','DM: start initiative tracking with HP snapshots. Encounter HP changes do not alter saved PC sheets.'],['encounterStep','advance_campaign_encounter','DM: advance turns, damage/heal a snapshot, or finish an encounter.']]){ + for(const [action,name,description] of [['invite','invite_campaign_member','DM: create a single-use player or co-DM invitation.'],['assign','assign_campaign_character','Assign your own PC and explicitly allow or revoke DM combat editing.'],['unassign','unassign_campaign_character','Remove your own PC from this campaign.'],['member','manage_campaign_member','DM: change a member role or remove a member; the last DM cannot be removed.'],['note','save_campaign_note','Save your own private, party-visible or DM-only note. Players cannot create DM-only notes.'],['removeNote','delete_campaign_note','Delete your own campaign note.'],['npc','save_campaign_npc','DM: create/edit an NPC; hidden by default. NPC notes remain DM-only even when visible.'],['removeNpc','delete_campaign_npc','DM: delete an NPC.'],['session','save_campaign_session','DM: plan/update a session and party-visible summary.'],['encounter','start_campaign_encounter','DM: start initiative tracking with HP snapshots. Encounter HP changes do not alter saved PC sheets.'],['encounterStep','advance_campaign_encounter','DM: advance turns, damage/heal a snapshot, set manual conditions, or finish an encounter. Conditions replace the participant’s list and do not automate rules or change saved sheets.']]){ tool(name,description,{campaign_id,expected_revision,input:campaignInputs[action]}, {...write,destructiveHint:['removeNote','removeNpc','member'].includes(action)},async a=>{allowed();return campaigns.mutate(sub,a.campaign_id,a.expected_revision,action,a.input)}) } tool('change_party_hit_points','DM: update actual PC HP only when its owner delegated combat editing. Requires the PC revision from get_campaign.',{campaign_id,...campaignInputs.partyHp.shape},write,async a=>{allowed();return campaigns.partyHp(sub,a.campaign_id,a)}); diff --git a/server/index.mjs b/server/index.mjs index de2a10b..f28f603 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -19,7 +19,7 @@ let config;async function provider(){return config??=await oidc.discovery(new UR const cookie=(res,name,value,seconds)=>res.append('Set-Cookie',`${name}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${seconds}`); const readCookie=(req,name)=>{const raw=(req.headers.cookie||'').split(';').map(x=>x.trim()).find(x=>x.startsWith(`${name}=`));return raw?raw.slice(name.length+1):''}; app.use((req,res,next)=>{res.set({'X-Content-Type-Options':'nosniff','Referrer-Policy':'same-origin','Content-Security-Policy':"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",'Cache-Control':'no-store'});next()}); -app.get('/health',(_,res)=>res.json({status:'ok',version:'0.5.0',catalogue:catalogue.summary,revision:process.env.SOURCE_COMMIT||'unknown'})); +app.get('/health',(_,res)=>res.json({status:'ok',version:'0.6.0',catalogue:catalogue.summary,revision:process.env.SOURCE_COMMIT||'unknown'})); app.get('/auth/login',async(req,res,next)=>{try{const pkce=oidc.randomPKCECodeVerifier(),state=oidc.randomState(),nonce=oidc.randomNonce();cookie(res,'__Host-character-flow',await codec.seal({purpose:'character-flow',pkce,state,nonce},600),600);const url=oidc.buildAuthorizationUrl(await provider(),{redirect_uri:`${origin}/auth/callback`,scope:'openid profile',state,nonce,code_challenge:await oidc.calculatePKCECodeChallenge(pkce),code_challenge_method:'S256'});res.redirect(url.href)}catch(e){next(e)}}); app.get('/auth/callback',async(req,res,next)=>{try{const flow=await codec.open(readCookie(req,'__Host-character-flow'));cookie(res,'__Host-character-flow','',0);if(!flow||typeof flow.pkce!=='string'||typeof flow.state!=='string'||typeof flow.nonce!=='string')return res.status(400).send('Sign-in expired. Start again at /auth/login.');const url=new URL(req.originalUrl,origin);const tokens=await oidc.authorizationCodeGrant(await provider(),url,{pkceCodeVerifier:flow.pkce,expectedState:flow.state,expectedNonce:flow.nonce});const claims=tokens.claims();if(!claims?.sub)throw new Error('Missing identity');cookie(res,'__Host-character-session',await codec.seal({purpose:'character-session',sub:claims.sub,name:claims.name||claims.preferred_username||'Adventurer'}),43200);const back=await codec.open(readCookie(req,'__Host-character-mcp-return'));cookie(res,'__Host-character-mcp-return','',0);res.redirect(back?.purpose==='mcp-return'?`/mcp/consent?ticket=${encodeURIComponent(back.ticket)}`:'/')}catch(e){next(e)}}); app.use(async(req,res,next)=>{const session=await codec.open(readCookie(req,'__Host-character-session'));req.identity=session?.sub&&typeof session.name==='string'&&(!session.purpose||session.purpose==='character-session')?session:null;next()}); diff --git a/server/mcp.mjs b/server/mcp.mjs index ddc5a20..63d4375 100644 --- a/server/mcp.mjs +++ b/server/mcp.mjs @@ -7,7 +7,7 @@ import {randomInt,randomUUID} from 'node:crypto'; import {characterSchema,newCharacter,changeHP,applyRest} from './model.mjs'; const id=z.string().min(1).max(200),revision=z.number().int().min(1),result=data=>({content:[{type:'text',text:JSON.stringify(data)}],structuredContent:data}); export function characterMcp({storage,campaigns,catalogue,sub,scopes}){ - const server=new McpServer({name:'arrowstack-character',version:'0.5.0'}); + const server=new McpServer({name:'arrowstack-character',version:'0.6.0'}); const read={readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false}; const write={readOnlyHint:false,destructiveHint:false,idempotentHint:false,openWorldHint:false}; const allowed=()=>{if(!scopes.includes('characters:write'))throw new Error('Reconnect with characters:write permission to edit sheets.')}; diff --git a/src/CampaignsView.vue b/src/CampaignsView.vue index d1915a3..d33bd6f 100644 --- a/src/CampaignsView.vue +++ b/src/CampaignsView.vue @@ -5,35 +5,45 @@ type Member={sub:string,name:string,role:'dm'|'player'}; type Npc={id:string,name:string,hp:number,maxHp:number,ac:number,initiative:number,notes?:string,visible:boolean}; type Note={id:string,title:string,body:string,visibility:'private'|'party'|'dm',author:string}; type Session={id:string,title:string,date:string,summary:string,status:'planned'|'active'|'complete'}; -type Participant={id:string,name:string,hp:number,maxHp:number,ac:number,initiative:number}; +type Participant={id:string,name:string,hp:number,maxHp:number,ac:number,initiative:number,conditions?:string[]}; type Encounter={id:string,name:string,status:string,round:number,turn:number,participants:Participant[]}; type Campaign={id:string,name:string,description:string,revision:number,role:'dm'|'player',members:Member[],party:{owner:string,id:string,revision:number,allowDmEdit:boolean,character:Character}[],notes:Note[],npcs:Npc[],sessions:Session[],encounters:Encounter[]}; -const list=ref<{id:string,name:string,description:string,role:string,revision:number}[]>([]),current=ref(null),busy=ref(false),error=ref(''),notice=ref(''),inviteCode=ref(''),tab=ref('Party'),self=ref(''); +const list=ref<{id:string,name:string,description:string,role:string,revision:number}[]>([]),current=ref(null),busy=ref(false),error=ref(''),notice=ref(''),inviteCode=ref(''),tab=ref('Session'),journalTab=ref('Notes'),self=ref(''); const newName=ref(''),description=ref(''),joinCode=ref(''),inviteRole=ref('player'),selectedPc=ref(''),delegate=ref(false),hpAmount=ref(1); const note=ref<{id?:string,title:string,body:string,visibility:'private'|'party'|'dm'}>({title:'',body:'',visibility:'private'}); const npc=ref<{id?:string,name:string,hp:number,maxHp:number,ac:number,initiative:number,notes:string,visible:boolean}>({name:'',hp:10,maxHp:10,ac:10,initiative:0,notes:'',visible:false}); const session=ref<{id?:string,title:string,date:string,summary:string,status:'planned'|'active'|'complete'}>({title:'',date:'',summary:'',status:'planned'}); const encounterName=ref(''),participants=ref[]>([]),customName=ref(''); const isDm=computed(()=>current.value?.role==='dm'); +const activeEncounters=computed(()=>current.value?.encounters.filter(e=>e.status==='active')||[]),pastEncounters=computed(()=>current.value?.encounters.filter(e=>e.status==='complete').slice().reverse()||[]); +const upcomingSessions=computed(()=>current.value?.sessions.filter(s=>s.status!=='complete')||[]),pastSessions=computed(()=>current.value?.sessions.filter(s=>s.status==='complete').slice().reverse()||[]); +const quickTitle=ref(''),quickBody=ref(''),showEncounter=ref(false),showSession=ref(false),conditionDraft=ref>({}); +const conditionChoices=['Blinded','Charmed','Deafened','Frightened','Grappled','Incapacitated','Invisible','Paralyzed','Petrified','Poisoned','Prone','Restrained','Stunned','Unconscious']; +const partyOwner=(owner:string)=>current.value?.members.find(m=>m.sub===owner)?.name||'Party member'; +function resetWorkspace(){tab.value=current.value?.role==='dm'?'Session':'Party';journalTab.value='Notes';inviteCode.value='';notice.value='';quickTitle.value='';quickBody.value='';conditionDraft.value={};participants.value=[];encounterName.value='';showEncounter.value=false;showSession.value=false;note.value={title:'',body:'',visibility:'private'};npc.value={name:'',hp:10,maxHp:10,ac:10,initiative:0,notes:'',visible:false};session.value={title:'',date:'',summary:'',status:'planned'}} +async function quickNote(){await action('note',{title:quickTitle.value,body:quickBody.value,visibility:'dm'});if(!error.value){quickTitle.value='';quickBody.value=''}} +async function setCondition(e:Encounter,p:Participant,condition:string,remove=false){const value=condition.trim();if(!value)return;const values=remove?(p.conditions||[]).filter(c=>c!==value):[...new Set([...(p.conditions||[]),value])];await action('encounterStep',{id:e.id,action:'conditions',participant_id:p.id,conditions:values});if(!error.value)conditionDraft.value[p.id]=''} +async function setSessionStatus(s:Session,status:Session['status']){await action('session',{...s,status})} + const online=ref(navigator.onLine);const connectivity=()=>{online.value=navigator.onLine};window.addEventListener('online',connectivity);window.addEventListener('offline',connectivity);onUnmounted(()=>{window.removeEventListener('online',connectivity);window.removeEventListener('offline',connectivity)}); async function request(path:string,body?:unknown){if(!online.value)throw new Error('Campaigns need a connection. Your character sheets remain available offline.');const response=await fetch('/api/campaigns'+path,{method:body===undefined?'GET':'POST',headers:body===undefined?{}:{'Content-Type':'application/json'},body:body===undefined?undefined:JSON.stringify(body),cache:'no-store'});const data=await response.json();if(!response.ok)throw new Error(data.error||'Could not load campaign');return data} async function run(fn:()=>Promise){if(busy.value)return;busy.value=true;error.value='';try{await fn()}catch(e){error.value=(e as Error).message}finally{busy.value=false}} async function refreshList(){list.value=(await request('')).campaigns} -async function open(id:string){await run(async()=>{current.value=await request('/'+id);tab.value='Party';inviteCode.value='';notice.value='';await refreshList()})} +async function open(id:string){await run(async()=>{current.value=await request('/'+id);resetWorkspace();await refreshList()})} async function reload(){if(current.value)await run(async()=>{current.value=await request('/'+current.value!.id);await refreshList()})} -async function create(){await run(async()=>{const result=await request('',{name:newName.value,description:description.value});current.value=await request('/'+result.id);newName.value='';description.value='';await refreshList()})} -async function join(){await run(async()=>{current.value=await request('/join',{code:joinCode.value.trim()});joinCode.value='';await refreshList()})} +async function create(){await run(async()=>{const result=await request('',{name:newName.value,description:description.value});current.value=await request('/'+result.id);newName.value='';description.value='';resetWorkspace();await refreshList()})} +async function join(){await run(async()=>{current.value=await request('/join',{code:joinCode.value.trim()});joinCode.value='';resetWorkspace();await refreshList()})} async function action(name:string,input:unknown){if(!current.value)return;await run(async()=>{const result=await request(`/${current.value!.id}/${name}`,{revision:current.value!.revision,input});current.value=result.campaign;if(result.invite_code)inviteCode.value=result.invite_code;notice.value='Campaign saved.';await refreshList()})} async function assign(){if(!current.value)return;await run(async()=>{await props.syncCharacters();const result=await request(`/${current.value!.id}/assign`,{revision:current.value!.revision,input:{character_id:selectedPc.value,allow_dm_edit:delegate.value}});current.value=result.campaign;notice.value='Character shared with the party.';await refreshList()})} async function partyHp(pc:Campaign['party'][number],amount:number){await run(async()=>{await request('/'+current.value!.id+'/hp',{owner:pc.owner,character_id:pc.id,expected_revision:pc.revision,amount});current.value=await request('/'+current.value!.id);notice.value='Saved PC hit points updated.'})} -function editNote(n:Note){note.value={id:n.id,title:n.title,body:n.body,visibility:n.visibility}} +function editNote(n:Note){note.value={id:n.id,title:n.title,body:n.body,visibility:n.visibility};tab.value='Journal';journalTab.value='Notes'} function editNpc(n:Npc){npc.value={...n,notes:n.notes||''}} -function editSession(s:Session){session.value={...s}} +function editSession(s:Session){session.value={...s};showSession.value=true;tab.value='Session'} async function saveNote(){await action('note',note.value);if(!error.value)note.value={title:'',body:'',visibility:'private'}} async function saveNpc(){await action('npc',npc.value);if(!error.value)npc.value={name:'',hp:10,maxHp:10,ac:10,initiative:0,notes:'',visible:false}} -async function saveSession(){await action('session',session.value);if(!error.value)session.value={title:'',date:'',summary:'',status:'planned'}} -function addCombatant(name:string,hp=10,maxHp=10,ac=10,initiative=0){if(participants.value.length<50)participants.value.push({name,hp,maxHp,ac,initiative});customName.value=''} -async function start(){await action('encounter',{name:encounterName.value,participants:participants.value});if(!error.value){participants.value=[];encounterName.value=''}} +async function saveSession(){await action('session',session.value);if(!error.value){session.value={title:'',date:'',summary:'',status:'planned'};showSession.value=false}} +function addCombatant(name:string,hp=10,maxHp=10,ac=10,initiative=0){if(name.trim()&&participants.value.length<50)participants.value.push({name:name.trim(),hp,maxHp,ac,initiative});customName.value=''} +async function start(){await action('encounter',{name:encounterName.value,participants:participants.value});if(!error.value){participants.value=[];encounterName.value='';showEncounter.value=false}} async function step(id:string,actionName:string,participantId?:string){await action('encounterStep',{id,action:actionName,participant_id:participantId,amount:participantId?hpAmount.value:undefined})} async function copyInvite(){try{await navigator.clipboard.writeText(inviteCode.value);notice.value='Invitation copied. Share it with your intended campaign member.'}catch{notice.value='Select the invitation code to copy it.'}} onMounted(()=>void run(async()=>{const identity=await fetch('/api/session').then(r=>r.json());self.value=identity.sub;await refreshList()})); @@ -50,28 +60,40 @@ onMounted(()=>void run(async()=>{const identity=await fetch('/api/session').then