feat: add boilerplate

This commit is contained in:
2026-02-16 00:12:46 +01:00
parent a0e9786c07
commit 0691c8e04e
2 changed files with 430 additions and 0 deletions

319
webapp/index.html Normal file
View File

@@ -0,0 +1,319 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>AnA Character Sheet</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="styles.css">
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=IM+Fell+English&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
</head>
<body>
<header>
<div class="title">
<i class="fa-solid fa-dragon"></i>
<span>AnA Character Sheet</span>
<span id="lastSaved" class="last-saved">Last saved: —</span>
</div>
<div>
<select id="characterSelect"></select>
<button onclick="newCharacter()" title="New"><i class="fa-solid fa-user-plus"></i></button>
<button onclick="deleteCharacter()" title="Delete"><i class="fa-solid fa-skull-crossbones"></i></button>
<button onclick="exportCharacter()" title="Export"><i class="fa-solid fa-scroll"></i></button>
<button onclick="importCharacter()" title="Import"><i class="fa-solid fa-file-import"></i></button>
</div>
</header>
<div class="container">
<!-- CHARACTER INFO -->
<div class="section">
<h2 onclick="toggleSection(this)">
<i class="fa-solid fa-id-card section-icon"></i>
Character Info
<i class="fa-solid fa-chevron-down toggle-icon"></i>
</h2>
<div class="collapsible-content">
<div class="grid">
<div class="field"><label>Name</label><input id="name"></div>
<div class="field"><label>Race</label><input id="race"></div>
<div class="field"><label>Class</label><input id="class"></div>
<div class="field"><label>Background</label><input id="background"></div>
<div class="field"><label>Alignment</label><input id="alignment"></div>
<div class="field"><label>Proficiency Bonus</label><input id="proficiency" type="number" value="2"></div>
</div>
</div>
</div>
<!-- ABILITIES -->
<div class="section">
<h2 onclick="toggleSection(this)">
<i class="fa-solid fa-dumbbell section-icon"></i>
Ability Scores
<i class="fa-solid fa-chevron-down toggle-icon"></i>
</h2>
<div id="abilities" class="collapsible-content ability-grid"></div>
</div>
<!-- COMBAT -->
<div class="section">
<h2 onclick="toggleSection(this)">
<i class="fa-solid fa-swords section-icon"></i>
Combat
<i class="fa-solid fa-chevron-down toggle-icon"></i>
</h2>
<div class="collapsible-content grid">
<div class="field"><label>Max HP</label><input id="hp_max" type="number"></div>
<div class="field"><label>Current HP</label><input id="hp_current" type="number"></div>
<div class="field"><label>Temp HP</label><input id="hp_temp" type="number"></div>
<div class="field"><label>Hit Dice</label><input id="hit_dice"></div>
</div>
</div>
<!-- ACTIONS -->
<div class="section">
<h2 onclick="toggleSection(this)">
<i class="fa-solid fa-bolt section-icon"></i>
Actions & Abilities
<i class="fa-solid fa-chevron-down toggle-icon"></i>
</h2>
<div class="collapsible-content">
<div id="actionsList"></div>
<button onclick="addAction()">Add Ability</button>
</div>
</div>
<!-- INVENTORY -->
<div class="section">
<h2 onclick="toggleSection(this)">
<i class="fa-solid fa-backpack section-icon"></i>
Inventory & Currency
<i class="fa-solid fa-chevron-down toggle-icon"></i>
</h2>
<div class="collapsible-content">
<label>Inventory</label>
<textarea id="inventory"></textarea>
<div class="grid">
<div class="field"><label>Gold</label><input id="gp" type="number"></div>
<div class="field"><label>Silver</label><input id="sp" type="number"></div>
<div class="field"><label>Copper</label><input id="cp" type="number"></div>
</div>
</div>
</div>
<!-- SPELLS -->
<div class="section">
<h2 onclick="toggleSection(this)">
<i class="fa-solid fa-hat-wizard section-icon"></i>
Spells
<i class="fa-solid fa-chevron-down toggle-icon"></i>
</h2>
<div class="collapsible-content">
<label>Spell List</label>
<textarea id="spells"></textarea>
<div class="field"><label>Spell Slots</label><input id="spell_slots"></div>
</div>
</div>
<!-- NOTES -->
<div class="section">
<h2 onclick="toggleSection(this)">
<i class="fa-solid fa-scroll section-icon"></i>
Notes
<i class="fa-solid fa-chevron-down toggle-icon"></i>
</h2>
<div class="collapsible-content">
<textarea id="notes"></textarea>
</div>
</div>
</div>
<script>
const abilities=[
{short:"STR",full:"Strength"},
{short:"DEX",full:"Dexterity"},
{short:"CON",full:"Constitution"},
{short:"INT",full:"Intelligence"},
{short:"WIS",full:"Wisdom"},
{short:"CHA",full:"Charisma"}
];
let currentCharacter=null;
let autosaveTimer=null;
/* SECTION TOGGLE (REAL) */
function toggleSection(header){
const content = header.nextElementSibling;
const icon = header.querySelector(".toggle-icon");
const isHidden = content.style.display === "none";
content.style.display = isHidden ? "block" : "none";
icon.style.transform = isHidden ? "rotate(0deg)" : "rotate(-90deg)";
}
/* ABILITIES */
function initAbilities(){
const c=document.getElementById("abilities");
c.innerHTML="";
abilities.forEach(s=>{
c.innerHTML+=`
<div class="ability-card">
<div class="ability-name">${s.full}</div>
<div class="ability-short">${s.short}</div>
<input type="number" id="${s.short}" value="10">
<div class="modifier" id="${s.short}_mod">+0</div>
</div>`;
});
}
function calculate(){
abilities.forEach(s=>{
const v=parseInt(document.getElementById(s.short).value)||10;
const m=Math.floor((v-10)/2);
document.getElementById(s.short+"_mod").innerText=m>=0?`+${m}`:m;
});
}
/* ACTIONS */
function addAction(data=null){
const c=document.getElementById("actionsList");
const d=document.createElement("div");
d.className="action-item";
d.innerHTML=`
<label>Name</label><input class="action-name" value="${data?.name||""}">
<label>Type</label>
<select class="action-type">
<option>Action</option><option>Bonus Action</option>
<option>Reaction</option><option>Passive</option>
</select>
<label>Attack Bonus</label><input class="action-bonus" value="${data?.bonus||""}">
<label>Damage</label><input class="action-damage" value="${data?.damage||""}">
<label>Description</label><textarea class="action-desc">${data?.desc||""}</textarea>
<button onclick="confirmRemoveAction(this)">
<i class="fa-solid fa-trash-can"></i>
</button>`;
c.appendChild(d);
}
function confirmRemoveAction(btn){
if(confirm("Remove this ability?")){
btn.parentElement.remove();
saveCharacter();
}
}
/* SAVE / LOAD */
function saveCharacter(){
if(!currentCharacter) return;
const data={};
document.querySelectorAll("input[id], textarea[id]").forEach(e=>data[e.id]=e.value);
data.actions=[];
document.querySelectorAll(".action-item").forEach(i=>{
data.actions.push({
name:i.querySelector(".action-name").value,
type:i.querySelector(".action-type").value,
bonus:i.querySelector(".action-bonus").value,
damage:i.querySelector(".action-damage").value,
desc:i.querySelector(".action-desc").value
});
});
const now=new Date().toLocaleString();
data._lastSaved=now;
localStorage.setItem("char_"+currentCharacter,JSON.stringify(data));
document.getElementById("lastSaved").innerText="Last saved: "+now;
}
function loadCharacter(n){
const d=JSON.parse(localStorage.getItem("char_"+n));
if(!d) return;
currentCharacter=n;
document.querySelectorAll("input[id], textarea[id]").forEach(e=>{
if(d[e.id]!=null) e.value=d[e.id];
});
document.getElementById("actionsList").innerHTML="";
d.actions?.forEach(a=>addAction(a));
document.getElementById("lastSaved").innerText="Last saved: "+(d._lastSaved||"—");
startAutosave();
calculate();
}
/* AUTOSAVE */
function startAutosave(){
clearInterval(autosaveTimer);
autosaveTimer=setInterval(()=>currentCharacter&&saveCharacter(),5000);
}
/* CHARACTER CRUD */
function newCharacter(){
const n=prompt("Character name?");
if(!n) return;
localStorage.setItem("char_"+n,"{}");
updateDropdown();
loadCharacter(n);
}
function deleteCharacter(){
if(!currentCharacter||!confirm("Delete this character permanently?")) return;
localStorage.removeItem("char_"+currentCharacter);
currentCharacter=null;
clearInterval(autosaveTimer);
document.getElementById("lastSaved").innerText="Last saved: —";
updateDropdown();
}
/* EXPORT / IMPORT */
function exportCharacter(){
if(!currentCharacter) return;
const data=localStorage.getItem("char_"+currentCharacter);
const blob=new Blob([data],{type:"application/json"});
const a=document.createElement("a");
a.href=URL.createObjectURL(blob);
a.download=currentCharacter+".json";
a.click();
}
function importCharacter(){
const input=document.createElement("input");
input.type="file";
input.accept=".json";
input.onchange=e=>{
const reader=new FileReader();
reader.onload=()=>{
const n=prompt("Name for imported character?");
if(!n) return;
localStorage.setItem("char_"+n,reader.result);
updateDropdown();
};
reader.readAsText(e.target.files[0]);
};
input.click();
}
/* DROPDOWN */
function updateDropdown(){
const s=document.getElementById("characterSelect");
s.innerHTML="";
for(let i=0;i<localStorage.length;i++){
const k=localStorage.key(i);
if(k.startsWith("char_")){
const o=document.createElement("option");
o.value=o.textContent=k.replace("char_","");
s.appendChild(o);
}
}
}
document.getElementById("characterSelect").onchange=e=>loadCharacter(e.target.value);
/* INIT */
initAbilities();
updateDropdown();
document.addEventListener("input",calculate);
</script>
</body>
</html>

111
webapp/styles.css Normal file
View File

@@ -0,0 +1,111 @@
body{
margin:0;
font-family:'IM Fell English', serif;
background:#e8d8b0;
color:#3b2f1b;
}
header{
background:#5a3e1b;
color:#f5e6c8;
padding:15px;
display:flex;
justify-content:space-between;
align-items:center;
font-family:'Cinzel', serif;
}
.title{
display:flex;
align-items:center;
gap:15px;
}
.last-saved{
font-size:12px;
opacity:0.85;
font-style:italic;
}
button{
background:#7a5c2e;
border:none;
color:#f5e6c8;
padding:6px 12px;
cursor:pointer;
}
button:hover{
background:#a67c3b;
}
.container{
padding:25px;
max-width:1100px;
margin:auto;
}
.section{
background:#f5e6c8;
border:2px solid #5a3e1b;
padding:20px;
margin-bottom:25px;
border-radius:8px;
box-shadow:4px 4px 8px rgba(0,0,0,0.2);
}
.section h2{
cursor:pointer;
margin-top:0;
border-bottom:1px solid #5a3e1b;
}
.grid{
display:grid;
gap:22px;
grid-template-columns:repeat(auto-fit,minmax(180px,1fr));
}
.field{
display:flex;
flex-direction:column;
}
label{
font-size:13px;
margin-bottom:4px;
font-family:'Cinzel', serif;
}
input, textarea, select{
padding:8px;
background:#fff8e7;
border:1px solid #5a3e1b;
border-radius:4px;
}
textarea{
min-height:220px;
width:98%;
}
.ability-grid{
display:grid;
grid-template-columns:repeat(auto-fit,minmax(170px,1fr));
gap:25px;
}
.ability-card{
background:#fff8e7;
border:2px solid #5a3e1b;
text-align:center;
padding:15px;
border-radius:6px;
}
.action-item{
border:1px solid #5a3e1b;
padding:18px;
margin-bottom:15px;
background:#fff8e7;
}