feat: add boilerplate
This commit is contained in:
319
webapp/index.html
Normal file
319
webapp/index.html
Normal 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
111
webapp/styles.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user