Mirror Spellbook folio from f051a7741b0e34532b33aaec2c228055b02524e4

This commit is contained in:
Admin 2026-10-07 19:36:13 +00:00
parent 41278b03f9
commit 7fe49f6910
2 changed files with 26 additions and 6 deletions

View File

@ -12,7 +12,8 @@ const c=computed(()=>store.all.find(x=>x.id===activeId.value));
const draft=ref<Character|null>(null),hpAmount=ref(1),restType=ref<'short-rest'|'long-rest'>('short-rest'),rollResult=ref(''),selectedAction=ref<Character['actions'][number]|null>(null);
const drawerIcon=computed<IconName>(()=>({Character:'sheet',Account:'notes','Hit points':'hp','Add action':'actions','Add resource':'charges','Manage resources':'slots','Add spell':'spells',Roll:'actions',Dice:'initiative',Rest:restType.value==='long-rest'?'long-rest':'short-rest'} as Record<string,IconName>)[drawer.value]||'sheet');
const restChanges=computed(()=>c.value?restPreview(c.value,restType.value):[]);
const editingActionId=ref<string|null>(null);
const editingActionId=ref<string|null>(null),editingSpellId=ref<string|null>(null);
const spellLevels=computed(()=>c.value?[...new Set(c.value.spells.map(s=>s.level))].sort((a,b)=>a-b):[]);
const actionDraft=ref({name:'',bonus:0,damage:'',notes:''}),resourceDraft=ref({name:'',current:1,max:1,reset:'long-rest' as 'short-rest'|'long-rest'|'manual'}),spellDraft=ref({name:'',level:1,prepared:true,notes:''});
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}}
@ -24,7 +25,8 @@ function hp(amount:number){if(c.value)changeHP(c.value,amount)}
function actionEditor(action?:Character['actions'][number]){editingActionId.value=action?.id||null;actionDraft.value=action?{name:action.name,bonus:action.bonus,damage:action.damage,notes:action.notes}:{name:'',bonus:0,damage:'',notes:''};drawer.value='Add action'}
function addAction(){if(!c.value||!actionDraft.value.name.trim())return;const value={...actionDraft.value,name:actionDraft.value.name.trim(),id:editingActionId.value||crypto.randomUUID()};if(editingActionId.value){const index=c.value.actions.findIndex(a=>a.id===editingActionId.value);if(index<0){message.value='This action changed. Close the editor and reload it.';return}c.value.actions[index]=value}else c.value.actions.push(value);editingActionId.value=null;actionDraft.value={name:'',bonus:0,damage:'',notes:''};drawer.value=''}
function addResource(){if(c.value&&resourceDraft.value.name.trim()&&resourceDraft.value.max>=0&&resourceDraft.value.current>=0&&resourceDraft.value.current<=resourceDraft.value.max){c.value.resources.push({...resourceDraft.value,id:crypto.randomUUID()});resourceDraft.value={name:'',current:1,max:1,reset:'long-rest'};drawer.value=''}else message.value='Remaining uses must be between zero and maximum.'}
function addSpell(){if(c.value&&spellDraft.value.name.trim()){c.value.spells.push({...spellDraft.value,id:crypto.randomUUID()});spellDraft.value={name:'',level:1,prepared:true,notes:''};drawer.value=''}}
function spellEditor(spell?:Character['spells'][number]){editingSpellId.value=spell?.id||null;spellDraft.value=spell?{name:spell.name,level:spell.level,prepared:spell.prepared,notes:spell.notes}:{name:'',level:1,prepared:true,notes:''};drawer.value='Add spell'}
function addSpell(){if(!c.value||!spellDraft.value.name.trim())return;const value={...spellDraft.value,name:spellDraft.value.name.trim(),id:editingSpellId.value||crypto.randomUUID()};if(editingSpellId.value){const index=c.value.spells.findIndex(s=>s.id===editingSpellId.value);if(index<0){message.value='This spell changed. Close the editor and reload it.';return}c.value.spells[index]=value}else c.value.spells.push(value);editingSpellId.value=null;spellDraft.value={name:'',level:1,prepared:true,notes:''};drawer.value=''}
function rest(type:'short-rest'|'long-rest'){restType.value=type;drawer.value='Rest'}
function confirmRest(){if(c.value)applyRest(c.value,restType.value);drawer.value='';message.value='Rest applied. Your sheet is saved.'}
function die(sides:number){const range=4294967296-(4294967296%sides);let n:number;do{n=crypto.getRandomValues(new Uint32Array(1))[0]}while(n>=range);return n%sides+1}
@ -64,14 +66,25 @@ function openAction(action:Character['actions'][number]){selectedAction.value=ac
<details class="folio-options"><summary>Notes & options</summary><p v-if="action.notes" class="folio-action-notes">{{action.notes}}</p><div class="folio-tools"><button class="quiet" @click="actionEditor(action)">Edit action</button><button class="quiet danger-text" @click="c.actions=c.actions.filter(a=>a.id!==action.id)" :aria-label="`Remove ${action.name}`">Remove</button></div></details>
</article>
</section>
<section class="panel" v-if="tab==='Spells'"><div class="section-title"><h2><UiIcon name="spells" />Spellbook</h2><button @click="drawer='Add spell'">+ Add spell</button></div><p class="hint">Record your own spells. Track spell slots with resources on the Sheet tab.</p><div class="spell-row" v-for="spell in [...c.spells].sort((a,b)=>a.level-b.level)"><label><input type="checkbox" v-model="spell.prepared"><span><b>{{spell.name}}</b><small>{{spell.level?'Level '+spell.level:'Cantrip'}} · {{spell.prepared?'Prepared':'Not prepared'}}</small><p>{{spell.notes}}</p></span></label><button class="quiet" @click="c.spells=c.spells.filter(s=>s.id!==spell.id)">Remove</button></div></section>
<section class="actions-folio spells-folio" v-if="tab==='Spells'" aria-labelledby="spells-heading">
<div class="folio-heading"><h2 id="spells-heading"><UiIcon name="spells" />Spellbook</h2><button class="quiet folio-add" @click="spellEditor()">+ Add</button></div>
<div class="folio-empty" v-if="!c.spells.length"><UiIcon name="spells" /><h3>A little magic starts here.</h3><p>Record your spells and cantrips, prepare your favourites and keep their notes close.</p><button class="primary" @click="spellEditor()">+ Add your first spell</button></div>
<template v-else><p class="folio-spell-hint">Spell slots are tracked with resources on the Sheet tab.</p>
<section v-for="level in spellLevels" :key="level" class="spell-chapter" :aria-labelledby="`spell-level-${level}`"><h3 :id="`spell-level-${level}`" class="spell-chapter-title">{{level===0?'Cantrips':'Level '+level}}</h3>
<article class="folio-action folio-spell" v-for="spell in c.spells.filter(s=>s.level===level)" :key="spell.id">
<div class="folio-action-main"><UiIcon name="spells" class="folio-action-icon" /><div class="folio-action-copy"><h3>{{spell.name}}</h3><p class="folio-modifier">{{spell.level?'Level '+spell.level:'Cantrip'}} · {{spell.prepared?'Prepared':'Not prepared'}}</p></div><label class="spell-prepare"><input type="checkbox" v-model="spell.prepared" :aria-label="`Prepare ${spell.name}`"><span>Prepared</span></label></div>
<details class="folio-options"><summary>Spell notes & options</summary><p v-if="spell.notes" class="folio-action-notes">{{spell.notes}}</p><p v-else class="folio-spell-hint">Add casting time, range, components, duration and effects to your notes.</p><div class="folio-tools"><button class="quiet" @click="spellEditor(spell)">Edit spell</button><button class="quiet danger-text" @click="c.spells=c.spells.filter(s=>s.id!==spell.id)" :aria-label="`Remove ${spell.name}`">Remove</button></div></details>
</article>
</section>
</template>
</section>
<section class="panel" v-if="tab==='Gear'"><div class="section-title"><h2><UiIcon name="gear" />Gear & inventory</h2><span class="chip">Saved automatically</span></div><p class="hint">Equipment, currency, attunement and anything you picked up along the way.</p><textarea v-model="c.inventory" rows="14" placeholder="Longbow, 20 arrows…" maxlength="12000"></textarea></section>
<template v-if="tab==='More'"><section class="panel"><div class="section-title"><h2><UiIcon name="notes" />Your story</h2><span class="chip">Saved automatically</span></div><textarea v-model="c.notes" rows="8" placeholder="Backstory, allies, session notes…" maxlength="12000"></textarea></section><section class="panel"><h2>Character & account</h2><p class="hint">Signed in as {{user.name}}. Characters sync to your Arrowstack account and stay available on this device for offline play. Export backups to keep an extra copy.</p><div class="settings-actions"><button @click="edit">Edit character</button><button @click="exportCharacter">Export JSON backup</button><label class="button">Import JSON<input type="file" accept="application/json,.json" hidden @change="importCharacter"></label><button @click="create">New character</button><button @click="drawer='Manage resources'">Manage resources</button><button class="danger-text" @click="remove">Delete character</button><button @click="logout" :disabled="offline">Sign out</button></div><p class="hint">Offline play lasts until your 12-hour sign-in session expires. Reconnect to sign in again. Attack modifiers, spell details and resource reset rules are entered manually; automated class rules are a future feature.</p></section></template>
</template>
<footer v-if="campaignMode||tab!=='Actions'">✦ ARROWSTACK CHARACTER <span>v0.3 · Made for game night</span></footer>
<footer v-if="campaignMode||!['Actions','Spells'].includes(tab)">✦ ARROWSTACK CHARACTER <span>v0.3 · Made for game night</span></footer>
</main>
<nav class="bottom-nav" v-if="c&&!campaignMode"><button v-for="item in navigation" :key="item.label" :class="{active:tab===item.label}" :aria-current="tab===item.label?'page':undefined" @click="tab=item.label"><UiIcon :name="item.icon" />{{item.label}}</button></nav>
<div v-if="drawer" class="overlay" @click.self="drawer=''"><section class="drawer" :class="{'character-drawer':drawer==='Character'}" role="dialog" aria-modal="true" :aria-label="drawer==='Add action'&&editingActionId?'Edit action':drawer"><div class="section-title drawer-heading"><h2><UiIcon :name="drawerIcon" />{{drawer==='Rest'?(restType==='long-rest'?'Long rest':'Short rest'):drawer==='Add action'&&editingActionId?'Edit action':drawer}}</h2><button class="close" @click="drawer=''" aria-label="Close dialog">×</button></div>
<div v-if="drawer" class="overlay" @click.self="drawer=''"><section class="drawer" :class="{'character-drawer':drawer==='Character'}" role="dialog" aria-modal="true" :aria-label="drawer==='Add action'&&editingActionId?'Edit action':drawer==='Add spell'&&editingSpellId?'Edit spell':drawer"><div class="section-title drawer-heading"><h2><UiIcon :name="drawerIcon" />{{drawer==='Rest'?(restType==='long-rest'?'Long rest':'Short rest'):drawer==='Add action'&&editingActionId?'Edit action':drawer==='Add spell'&&editingSpellId?'Edit spell':drawer}}</h2><button class="close" @click="drawer=''" aria-label="Close dialog">×</button></div>
<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">
<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>
@ -86,7 +99,7 @@ function openAction(action:Character['actions'][number]){selectedAction.value=ac
<form v-if="drawer==='Add action'" @submit.prevent="addAction"><label>Name<input v-model="actionDraft.name" required maxlength="120"></label><div class="form-grid"><label>Attack bonus<input type="number" v-model.number="actionDraft.bonus" min="-50" max="100" required></label><label>Damage<input v-model="actionDraft.damage" placeholder="1d8 + 4 piercing" maxlength="120"></label></div><label>Notes<textarea v-model="actionDraft.notes" maxlength="12000"></textarea></label><button class="primary wide">{{editingActionId?'Save action':'Add action'}}</button></form>
<form v-if="drawer==='Add resource'" @submit.prevent="addResource"><label>Name<input v-model="resourceDraft.name" placeholder="Level 1 spell slots" required maxlength="120"></label><div class="form-grid"><label>Remaining<input type="number" v-model.number="resourceDraft.current" min="0" :max="resourceDraft.max" required></label><label>Maximum<input type="number" v-model.number="resourceDraft.max" min="0" max="9999" required></label></div><label>Recovery<select v-model="resourceDraft.reset"><option value="short-rest">Short or long rest</option><option value="long-rest">Long rest</option><option value="manual">Manual only</option></select></label><button class="primary wide">Add resource</button></form>
<template v-if="drawer==='Manage resources'&&c"><div class="resource" v-for="r in c.resources"><b>{{r.name}}</b><button class="quiet danger-text" @click="c.resources=c.resources.filter(x=>x.id!==r.id)">Remove</button></div><button @click="drawer='Add resource'">+ Add resource</button></template>
<form v-if="drawer==='Add spell'" @submit.prevent="addSpell"><label>Name<input v-model="spellDraft.name" required maxlength="120"></label><label>Level (0 = cantrip)<input type="number" v-model.number="spellDraft.level" min="0" max="9" required></label><label>Spell notes<textarea v-model="spellDraft.notes" rows="6" maxlength="12000" placeholder="Casting time, range, components, duration, effects…"></textarea></label><button class="primary wide">Add spell</button></form>
<form v-if="drawer==='Add spell'" @submit.prevent="addSpell"><label>Name<input v-model="spellDraft.name" required maxlength="120"></label><label>Level (0 = cantrip)<input type="number" v-model.number="spellDraft.level" min="0" max="9" required></label><label>Spell notes<textarea v-model="spellDraft.notes" rows="6" maxlength="12000" placeholder="Casting time, range, components, duration, effects…"></textarea></label><button class="primary wide">{{editingSpellId?'Save spell':'Add spell'}}</button></form>
<template v-if="drawer==='Rest'&&c"><p class="hint">Review before applying your rest.</p><div class="preview-row" v-if="restType==='long-rest'"><span>Hit points</span><b>{{c.hp.current}} → {{c.hp.max}}</b></div><div class="preview-row" v-if="restType==='long-rest'"><span>Temporary HP</span><b>{{c.hp.temp}} → 0</b></div><div class="preview-row" v-for="r in restChanges"><span>{{r.name}}</span><b>{{r.current}} → {{r.max}}</b></div><p class="hint">Manual resources, notes and gear remain as entered. Hit dice and class-specific rest rules are not automated yet.</p><button class="primary wide" @click="confirmRest">Confirm rest</button></template>
<template v-if="drawer==='Roll'&&selectedAction"><h3>{{selectedAction.name}}</h3><p class="hint">Attack {{signed(selectedAction.bonus)}} · {{selectedAction.damage}}</p><p>{{selectedAction.notes}}</p><div class="roll-buttons"><button @click="roll(selectedAction.bonus,-1)">Disadvantage</button><button class="primary" @click="roll(selectedAction.bonus)">Roll d20</button><button @click="roll(selectedAction.bonus,1)">Advantage</button></div><p class="roll-result" role="status">{{rollResult}}</p></template>
<template v-if="drawer==='Dice'"><p class="roll-result" role="status">{{rollResult}}</p><p class="hint">Includes the modifier shown on your sheet.</p></template>

View File

@ -96,3 +96,10 @@ input[type=checkbox]{accent-color:#3b603d}
.bottom-nav button,.bottom-nav button:hover{box-shadow:none;background:transparent;min-height:62px}.bottom-nav button.active{background:#dce0b4;box-shadow:none;border-radius:8px}
@media(max-width:700px){.workspace-tabs{gap:10px;padding-bottom:9px}.workspace-tabs button{font-size:13px;padding:8px 4px}.workspace-tabs .ui-icon{width:32px;height:32px}.hero{gap:12px;margin-bottom:22px}.hero .edit{margin-top:0;padding:8px 0}.folio-heading{padding:14px 0}.folio-heading h2{font-size:27px;gap:10px}.folio-heading h2 .ui-icon{width:40px;height:40px}.folio-action-main{gap:10px}.folio-action-icon{width:33px;height:33px}.folio-action h3{font-size:22px}.folio-options{margin-left:43px}.folio-roll{padding:9px 11px;font-size:13px}.folio-roll .ui-icon{width:20px;height:20px}.folio-empty{padding:28px 6px 32px}.folio-empty h3{font-size:24px}.bottom-nav{width:100%}.bottom-nav .ui-icon{width:34px;height:34px}}
@media(max-width:360px){.folio-action-icon{display:none}.folio-options{margin-left:0}.folio-roll .ui-icon{display:none}.folio-action h3{font-size:21px}}
/* Spellbook chapters share the action folio, with preparation kept explicit. */
.folio-spell-hint{font-size:13px;line-height:1.7;color:#6e6549;margin:16px 0 20px}
.spell-chapter{margin:24px 0}.spell-chapter .spell-chapter-title{font-family:inherit;font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#78643b;margin:0;padding:10px 0;border-bottom:1px solid #ad8d51}
.spell-prepare{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:7px;flex-shrink:0;min-width:68px;min-height:52px;margin:0;color:#4c6140;font-size:11px;cursor:pointer;padding:6px}
.spell-prepare input[type=checkbox]{width:22px;height:22px;min-height:22px;cursor:pointer}.folio-spell .folio-options .folio-spell-hint{margin:4px 0 8px}
@media(max-width:700px){.spell-chapter{margin:20px 0}.spell-prepare{min-width:60px;font-size:10px;padding:4px}.folio-spell .folio-action-main{gap:10px}}