pre-push commit
This commit is contained in:
266
battleflow/static/js/admin.js
Normal file
266
battleflow/static/js/admin.js
Normal file
@@ -0,0 +1,266 @@
|
||||
function initAdmin(token){
|
||||
const overlay = document.getElementById('authOverlay');
|
||||
const authMsg = document.getElementById('authMsg');
|
||||
const tokenInput = document.getElementById('tokenInput');
|
||||
const setTokenBtn = document.getElementById('setTokenBtn');
|
||||
const statusEl = document.getElementById('statuspill');
|
||||
function showOverlay(msg){
|
||||
if(authMsg && msg){ authMsg.textContent = msg; }
|
||||
if(overlay){ overlay.classList.add('show'); }
|
||||
if(setTokenBtn && tokenInput){
|
||||
setTokenBtn.onclick = () => {
|
||||
const t = (tokenInput.value||'').trim();
|
||||
if(!t) return;
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('token', t);
|
||||
location.href = url.toString();
|
||||
};
|
||||
}
|
||||
}
|
||||
function hideOverlay(){ if(overlay){ overlay.classList.remove('show'); } }
|
||||
function setStatus(txt, cls){ if(!statusEl) return; statusEl.textContent = txt; statusEl.className = 'statuspill ' + (cls||''); }
|
||||
|
||||
let state=null, since=0, presets=[], groups=[];
|
||||
const rows = document.getElementById('rows');
|
||||
const presetlist = document.getElementById('presetlist');
|
||||
const pg_list = document.getElementById('pg_list');
|
||||
const g_checks = document.getElementById('g_checks');
|
||||
|
||||
function openBoard(){ window.open('/board?token='+encodeURIComponent(token), '_blank'); }
|
||||
window.openBoard = openBoard;
|
||||
|
||||
function iconImg(name){ return `<span class="ico_wrap"><img class="icoimg" src="/icons/${encodeURIComponent(name)}.svg" alt="${name}"></span>`; }
|
||||
function typeChip(t){ return `<span class="tagType ${t}">${t}</span>`; }
|
||||
|
||||
function row(a, idx){
|
||||
const main = `<tr class="${idx===state.turn_idx? 'active':''}">
|
||||
<td>${idx+1}</td>
|
||||
<td>${a.name}</td>
|
||||
<td>${a.init}</td>
|
||||
<td>${a.hp}</td>
|
||||
<td>${a.ac}${a.reveal_ac?'':' (hidden)'} </td>
|
||||
<td>${typeChip(a.type)}</td>
|
||||
<td>
|
||||
<button class="btn" title="Concentration" onclick=toggle('${a.id}','conc')>✦</button>
|
||||
<button class="btn" title="Dead" onclick=toggle('${a.id}','dead')>☠</button>
|
||||
<button class="btn" title="Visible to players" onclick=toggle('${a.id}','visible')>👁</button>
|
||||
<button class="btn" title="Reveal AC" onclick=toggle('${a.id}','reveal_ac')>🛡</button>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-danger" onclick=removeA('${a.id}')>🗑 Remove</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
const active = (a.effects||[]);
|
||||
const KNOWN_CONDITIONS = ['poisoned','prone','grappled','restrained','stunned','blinded','deafened','charmed','frightened','paralyzed','petrified','unconscious'];
|
||||
const cond = `<tr class="condrow"><td colspan="8"><div class="condbar">${
|
||||
KNOWN_CONDITIONS.map(c=>`<button class="${active.includes(c)?'on':''}" onclick=toggleEffect('${a.id}','${c}')>${iconImg(c)} ${c}</button>`).join(' ')
|
||||
} <button class="btn" style="margin-left:8px" onclick=clearEffects('${a.id}')>Clear conditions</button></div></td></tr>`;
|
||||
return main + cond;
|
||||
}
|
||||
|
||||
function render(){
|
||||
document.getElementById('round').textContent = state.round;
|
||||
rows.innerHTML = '';
|
||||
(state.actors||[]).forEach((a, idx)=>{ rows.insertAdjacentHTML('beforeend', row(a, idx)); });
|
||||
}
|
||||
|
||||
function renderPresets(){
|
||||
presetlist.innerHTML = '';
|
||||
presets.forEach(p=>{
|
||||
const div = document.createElement('div'); div.className='preset';
|
||||
if(p.avatar){ const img=document.createElement('img'); img.className='pav'; img.alt=p.name; img.src=(p.avatar.startsWith('http')||p.avatar.startsWith('/'))?p.avatar:('/avatars/'+encodeURIComponent(p.avatar)); div.appendChild(img); }
|
||||
const span = document.createElement('span'); span.innerHTML = `${typeChip(p.type)} ${p.name}${p.ac? ' · AC '+p.ac: ''}${p.hp? ' · HP '+p.hp: ''}`; div.appendChild(span);
|
||||
const addb = document.createElement('button'); addb.className='btn'; addb.textContent='➕ Add'; addb.onclick=()=>presetApply(p.id); div.appendChild(addb);
|
||||
const delb = document.createElement('button'); delb.className='btn btn-danger'; delb.textContent='🗑'; delb.style.marginLeft='6px'; delb.onclick=()=>presetRemove(p.id); div.appendChild(delb);
|
||||
presetlist.appendChild(div);
|
||||
});
|
||||
renderGroupChecks();
|
||||
}
|
||||
|
||||
function renderGroupChecks(){
|
||||
g_checks.innerHTML = '';
|
||||
if(!presets.length){ g_checks.innerHTML = '<div class="muted">No presets yet.</div>'; return; }
|
||||
presets.forEach(p=>{
|
||||
const id = 'chk_'+p.id; const w=document.createElement('div');
|
||||
w.innerHTML = `<label><input type="checkbox" id="${id}" data-id="${p.id}"/> ${p.name} <span class="muted">(${p.type}${p.ac? ' · AC '+p.ac:''}${p.hp? ' · HP '+p.hp:''})</span></label>`;
|
||||
g_checks.appendChild(w);
|
||||
});
|
||||
}
|
||||
|
||||
function renderGroups(){
|
||||
pg_list.innerHTML = '';
|
||||
groups.forEach(g=>{
|
||||
const div = document.createElement('div'); div.className='preset';
|
||||
const names = g.member_ids.map(id=>{ const p=presets.find(x=>x.id===id); return p? p.name : 'unknown'; }).join(', ');
|
||||
const span = document.createElement('span'); span.innerHTML = `<strong>${g.name}</strong> <span class="muted">(${g.member_ids.length} members)</span> — ${names}`;
|
||||
div.appendChild(span);
|
||||
const addb = document.createElement('button'); addb.className='btn'; addb.textContent='➕ Add Group'; addb.onclick=()=>groupApply(g.id); div.appendChild(addb);
|
||||
const delb = document.createElement('button'); delb.className='btn btn-danger'; delb.textContent='🗑'; delb.style.marginLeft='6px'; delb.onclick=()=>groupRemove(g.id); div.appendChild(delb);
|
||||
pg_list.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
async function post(p, body){
|
||||
const r = await fetch(p+`?token=${encodeURIComponent(token)}`, { method:'POST', headers:{'Content-Type':'application/json'}, body: body?JSON.stringify(body):null });
|
||||
if(r.status===401){ setStatus('Unauthorized', 'bad'); showOverlay('Unauthorized: token missing or invalid. Fix the token and reload.'); return false; }
|
||||
if(!r.ok){ const t = await r.text(); alert('Error '+r.status+' '+t); setStatus('Reconnecting…','warn'); }
|
||||
return r.ok;
|
||||
}
|
||||
async function get(p){
|
||||
const r = await fetch(p+`?token=${encodeURIComponent(token)}`);
|
||||
if(r.status===401){ setStatus('Unauthorized', 'bad'); showOverlay('Unauthorized: token missing or invalid. Fix the token and reload.'); return null; }
|
||||
if(!r.ok){ const t = await r.text(); alert('Error '+r.status+' '+t); setStatus('Reconnecting…','warn'); return null; }
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// Upload helpers
|
||||
async function uploadAvatar(fileInputId, targetInputId, previewId){
|
||||
const fileEl = document.getElementById(fileInputId);
|
||||
if(fileEl && fileEl.files && fileEl.files.length){
|
||||
await uploadAvatarFile(fileEl.files[0], targetInputId, previewId);
|
||||
fileEl.value = '';
|
||||
} else { alert('Select or drop a file first.'); }
|
||||
}
|
||||
async function uploadAvatarFile(file, targetInputId, previewId){
|
||||
const fd = new FormData(); fd.append('file', file);
|
||||
const r = await fetch('/api/upload_avatar?token='+encodeURIComponent(token), { method: 'POST', body: fd });
|
||||
if(r.status===401){ setStatus('Unauthorized', 'bad'); showOverlay('Unauthorized: token missing or invalid.'); return; }
|
||||
if(!r.ok){ const t = await r.text(); alert('Upload error '+r.status+' '+t); setStatus('Reconnecting…','warn'); return; }
|
||||
const j = await r.json();
|
||||
document.getElementById(targetInputId).value = j.filename;
|
||||
if(previewId){ setPreview(j.filename, previewId); }
|
||||
}
|
||||
function setPreview(val, previewId){
|
||||
const img = document.getElementById(previewId);
|
||||
if(!img) return; if(!val){ img.src=''; img.style.opacity=.3; return; }
|
||||
const src = (val.startsWith('http')||val.startsWith('/')) ? val : ('/avatars/'+encodeURIComponent(val));
|
||||
img.src = src; img.style.opacity=1;
|
||||
}
|
||||
function bindPreview(inputId, previewId){ const el = document.getElementById(inputId); el.addEventListener('input', ()=> setPreview(el.value.trim(), previewId)); setPreview(el.value.trim(), previewId); }
|
||||
function makeDropzone(zoneId, targetInputId, previewId){
|
||||
const dz = document.getElementById(zoneId);
|
||||
['dragenter','dragover'].forEach(ev=> dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.add('drag'); }));
|
||||
['dragleave','drop'].forEach(ev=> dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.remove('drag'); }));
|
||||
dz.addEventListener('drop', async (e)=>{ const files = e.dataTransfer.files; if(!files || !files.length) return; await uploadAvatarFile(files[0], targetInputId, previewId); });
|
||||
}
|
||||
|
||||
// Mutations
|
||||
async function add(){
|
||||
const name = document.getElementById('name').value.trim();
|
||||
const init = parseFloat(document.getElementById('init').value||'0');
|
||||
const hp = parseInt(document.getElementById('hp').value||'0');
|
||||
const ac = parseInt(document.getElementById('ac').value||'0');
|
||||
const type = document.getElementById('type').value;
|
||||
const note = document.getElementById('note').value.trim();
|
||||
const avatar = document.getElementById('avatar').value.trim();
|
||||
await post('/api/add', { name, init, hp, ac, type, note, avatar });
|
||||
}
|
||||
async function clearAll(){ await post('/api/clear'); }
|
||||
async function toggleVisible(){ await post('/api/toggle_visible'); }
|
||||
async function next(){ await post('/api/next'); }
|
||||
async function prev(){ await post('/api/prev'); }
|
||||
async function toggle(id, field){ await post('/api/update', { id, [field]: 'toggle' }); }
|
||||
async function toggleEffect(id, effect){ await post('/api/toggle_effect', { id, effect }); }
|
||||
async function clearEffects(id){ await post('/api/clear_effects', { id }); }
|
||||
async function removeA(id){ await post('/api/remove', { id }); }
|
||||
async function setDeadMode(mode){ await post('/api/dead_mode', { mode }); updateDeadSeg(mode); }
|
||||
function updateDeadSeg(mode){ const seg = document.getElementById('deadseg'); if(!seg) return; seg.querySelectorAll('button[data-mode]').forEach(b=>{ b.classList.toggle('on', b.getAttribute('data-mode')===mode); }); }
|
||||
|
||||
// Presets
|
||||
async function presetAdd(){
|
||||
const name = document.getElementById('p_name').value.trim();
|
||||
const type = document.getElementById('p_type').value;
|
||||
const hp = parseInt(document.getElementById('p_hp').value||'0');
|
||||
const ac = parseInt(document.getElementById('p_ac').value||'0');
|
||||
const note = document.getElementById('p_note').value.trim();
|
||||
const avatar = document.getElementById('p_avatar').value.trim();
|
||||
await post('/api/preset/add', { name, type, hp, ac, note, avatar });
|
||||
await loadPresets(); await loadPresetGroups();
|
||||
}
|
||||
async function presetRemove(id){ await post('/api/preset/remove', { id }); await loadPresets(); await loadPresetGroups(); }
|
||||
async function presetApply(id){
|
||||
const init = prompt('Initiative roll for this actor?', '10');
|
||||
const p = parseFloat(init||'0');
|
||||
await post('/api/preset/apply', { id, init: p });
|
||||
}
|
||||
|
||||
// Groups
|
||||
async function groupAdd(){
|
||||
const name = document.getElementById('g_name').value.trim();
|
||||
const member_ids = Array.from(g_checks.querySelectorAll('input[type=checkbox]:checked')).map(x=>x.getAttribute('data-id'));
|
||||
if(!name){ alert('Name required'); return; }
|
||||
if(!member_ids.length){ alert('Select at least one preset'); return; }
|
||||
await post('/api/preset/group/add', { name, member_ids });
|
||||
document.getElementById('g_name').value='';
|
||||
g_checks.querySelectorAll('input[type=checkbox]').forEach(c=> c.checked=false);
|
||||
await loadPresetGroups();
|
||||
}
|
||||
async function groupRemove(id){ await post('/api/preset/group/remove', { id }); await loadPresetGroups(); }
|
||||
async function groupApply(id){
|
||||
const r = await fetch('/api/preset_groups?token='+encodeURIComponent(token));
|
||||
if(r.status===401){ setStatus('Unauthorized', 'bad'); showOverlay('Unauthorized: token missing or invalid.'); return; }
|
||||
const all = await r.json(); groups = all.items||[];
|
||||
const g = groups.find(x=>x.id===id); if(!g){ alert('Group not found'); return; }
|
||||
const mode = (prompt('Group add mode: single | list | per | step','single')||'single').trim().toLowerCase();
|
||||
let payload = { id };
|
||||
if(mode==='list'){
|
||||
const raw = prompt('Comma-separated initiatives in group order','12,11,10');
|
||||
if(raw && raw.trim().length){ payload.inits = raw.split(',').map(s=>parseFloat((s||'0').trim())); }
|
||||
} else if(mode==='per'){
|
||||
const arr = [];
|
||||
g.member_ids.forEach((pid, i)=>{
|
||||
const p = presets.find(x=>x.id===pid); const lbl = p? p.name : ('Member '+(i+1));
|
||||
const v = prompt('Initiative for '+lbl,'10'); arr.push(parseFloat(v||'0'));
|
||||
});
|
||||
payload.inits = arr;
|
||||
} else if(mode==='step'){
|
||||
const base = parseFloat(prompt('Base initiative for first member','12')||'12');
|
||||
const step = parseFloat(prompt('Step per next member (e.g. -1 for descending)','-1')||'-1');
|
||||
payload.inits = g.member_ids.map((_,i)=> base + i*step);
|
||||
} else {
|
||||
const v = prompt('Single initiative value for all members','12');
|
||||
payload.init = parseFloat(v||'12');
|
||||
}
|
||||
await post('/api/preset/group/apply', payload);
|
||||
}
|
||||
|
||||
async function loadPresets(){ const r = await get('/api/presets'); if(r) { presets = r.items||[]; renderPresets(); } }
|
||||
async function loadPresetGroups(){ const r = await get('/api/preset_groups'); if(r){ groups = r.items||[]; renderGroups(); } }
|
||||
|
||||
async function poll(){
|
||||
try{
|
||||
const r = await fetch(`/api/state?since=${since}&token=${encodeURIComponent(token)}`);
|
||||
if(r.status===401){ setStatus('Unauthorized', 'bad'); showOverlay('Unauthorized: token missing or invalid. Fix the token and reload.'); setTimeout(poll, 1500); return; }
|
||||
if(r.ok){ hideOverlay(); setStatus('Connected','ok'); state = await r.json(); since = state.version; render(); updateDeadSeg(state.dead_mode||'normal'); }
|
||||
else { setStatus('Reconnecting…','warn'); }
|
||||
} catch(e){ setStatus('Reconnecting…','warn'); }
|
||||
setTimeout(poll, 300);
|
||||
}
|
||||
|
||||
// init
|
||||
window.clearAll = clearAll;
|
||||
window.toggleVisible = toggleVisible;
|
||||
window.next = next;
|
||||
window.prev = prev;
|
||||
window.toggle = toggle;
|
||||
window.toggleEffect = toggleEffect;
|
||||
window.clearEffects = clearEffects;
|
||||
window.removeA = removeA;
|
||||
window.setDeadMode = setDeadMode;
|
||||
window.presetAdd = presetAdd;
|
||||
window.presetRemove = presetRemove;
|
||||
window.presetApply = presetApply;
|
||||
window.groupAdd = groupAdd;
|
||||
window.groupRemove = groupRemove;
|
||||
window.groupApply = groupApply;
|
||||
|
||||
bindPreview('avatar','avatar_preview');
|
||||
bindPreview('p_avatar','p_avatar_preview');
|
||||
makeDropzone('avatar_drop','avatar','avatar_preview');
|
||||
makeDropzone('p_avatar_drop','p_avatar','p_avatar_preview');
|
||||
|
||||
loadPresets();
|
||||
loadPresetGroups();
|
||||
setStatus('Connecting…','warn');
|
||||
poll();
|
||||
}
|
||||
74
battleflow/static/js/board.js
Normal file
74
battleflow/static/js/board.js
Normal file
@@ -0,0 +1,74 @@
|
||||
(function(){
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
const token = qs.get('token');
|
||||
const panel = document.getElementById('panel');
|
||||
const list = document.getElementById('list');
|
||||
const roundEl = document.getElementById('round');
|
||||
const updatedEl = document.getElementById('updated');
|
||||
const countEl = document.getElementById('count');
|
||||
let ICONS = {}; let EMOJI = {}; let KNOWN = [];
|
||||
function fmtAgo(ms){ const d=Date.now()-ms; if(d<1500)return 'just now'; const s=Math.floor(d/1000); if(s<60) return s+'s ago'; const m=Math.floor(s/60); if(m<60) return m+'m ago'; const h=Math.floor(m/60); return h+'h ago'; }
|
||||
function avatarSrc(a){ if(!a.avatar) return ''; if(a.avatar.startsWith('http') || a.avatar.startsWith('/')) return a.avatar; return '/avatars/' + encodeURIComponent(a.avatar); }
|
||||
function iconSrc(key){ const f = ICONS[key]; return f ? ('/icons/' + encodeURIComponent(f)) : '' }
|
||||
function render(state){
|
||||
ICONS = state.condition_icons||{}; EMOJI = state.condition_emoji||{}; KNOWN = state.known_conditions||[];
|
||||
const deadMode = state.dead_mode || 'normal';
|
||||
if(!state.visible){ panel.classList.add('hideall'); return; } else { panel.classList.remove('hideall'); }
|
||||
roundEl.textContent = state.round;
|
||||
updatedEl.textContent = fmtAgo(state.updated_at);
|
||||
list.innerHTML = '';
|
||||
(state.actors||[]).forEach((a, idx)=>{
|
||||
if(!a.visible) return;
|
||||
if(a.dead && deadMode==='hide') return;
|
||||
const cls = ['row','type-'+(a.type||'pc')];
|
||||
if(idx===state.turn_idx) cls.push('active');
|
||||
if(a.dead){ cls.push('dead'); if(deadMode==='shrink') cls.push('shrunk'); }
|
||||
const card = document.createElement('div');
|
||||
card.className = cls.join(' ');
|
||||
|
||||
// Portrait
|
||||
const portrait = document.createElement(a.avatar? 'img':'div');
|
||||
if(a.avatar){ portrait.src = avatarSrc(a); portrait.className='portrait'; portrait.alt=a.name; }
|
||||
else{ portrait.className='noavatar'; }
|
||||
card.appendChild(portrait);
|
||||
|
||||
// Death & Concentration badges
|
||||
if(a.dead){ const db = document.createElement('div'); db.className='deathBadge'; db.textContent='☠'; card.appendChild(db); }
|
||||
if(a.conc){ const cs = document.createElement('div'); cs.className='concStar'; cs.textContent='✦'; card.appendChild(cs); }
|
||||
|
||||
// Name + meta
|
||||
const name = document.createElement('div'); name.className='name';
|
||||
const n = document.createElement('div'); n.className='n'; n.textContent=a.name; name.appendChild(n);
|
||||
const meta = document.createElement('div'); meta.className='meta';
|
||||
const bits = [];
|
||||
if(a.hp){ bits.push('HP '+a.hp); }
|
||||
if(a.reveal_ac && a.ac){ bits.push('AC '+a.ac); }
|
||||
bits.push('Init '+Math.floor(a.init));
|
||||
if(a.note){ bits.push(a.note); }
|
||||
meta.textContent = bits.join(' · ');
|
||||
name.appendChild(meta);
|
||||
card.appendChild(name);
|
||||
|
||||
// Conditions row
|
||||
if (Array.isArray(a.effects) && a.effects.length){
|
||||
const tags = document.createElement('div'); tags.className='tags';
|
||||
a.effects.forEach(e=>{ const t=document.createElement('span'); t.className='tag'; const wrap=document.createElement('span'); wrap.className='ico_wrap'; const src=iconSrc(e); if(src){ const i=document.createElement('img'); i.className='ico'; i.alt=e; i.src=src; wrap.appendChild(i); } else { wrap.textContent='•'; } t.appendChild(wrap); const lbl=document.createElement('span'); lbl.textContent=e; t.appendChild(lbl); tags.appendChild(t); });
|
||||
card.appendChild(tags);
|
||||
}
|
||||
|
||||
list.appendChild(card);
|
||||
});
|
||||
countEl.textContent = list.childElementCount + ' shown';
|
||||
}
|
||||
let last = null; let since = 0;
|
||||
async function poll(){
|
||||
try{
|
||||
const r = await fetch(`/api/state?since=${since}&token=${encodeURIComponent(token)}`);
|
||||
if(!r.ok){ throw new Error('state '+r.status); }
|
||||
const s = await r.json(); last = s; render(s); since = s.version;
|
||||
}catch(e){ }
|
||||
setTimeout(poll, 200);
|
||||
}
|
||||
setInterval(()=>{ if(last) updatedEl.textContent = fmtAgo(last.updated_at); }, 3000);
|
||||
poll();
|
||||
})();
|
||||
Reference in New Issue
Block a user