pre-push commit
This commit is contained in:
62
battleflow/static/css/board.css
Normal file
62
battleflow/static/css/board.css
Normal file
@@ -0,0 +1,62 @@
|
||||
/* Extracted from single-file version */
|
||||
:root{ --panel: rgba(20,22,30,0.88); --text:#e7eaf1; --muted:#9aa0aa; --pc:#7dd3fc; --npc:#a78bfa; --monster:#fca5a5; --hl:#fde047; --dead:#ef4444; }
|
||||
*{ box-sizing:border-box; }
|
||||
html,body{ margin:0; padding:0; background:transparent; color:var(--text); font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Helvetica Neue,Arial,sans-serif; }
|
||||
.wrapper{ width:100%; display:flex; justify-content:center; }
|
||||
.panel{ margin:8px; padding:10px 12px; border-radius:14px; background:var(--panel); backdrop-filter: blur(6px); width: calc(100vw - 16px); max-width:none; }
|
||||
.header{ display:flex; align-items:flex-end; justify-content:space-between; margin-bottom:6px; gap:8px; }
|
||||
.h-title{ font-weight:700; letter-spacing:0.5px; }
|
||||
.h-sub{ font-size:12px; color:var(--muted); }
|
||||
.list{ display:flex; flex-wrap:wrap; gap:10px; align-items:stretch; justify-content:flex-start; }
|
||||
|
||||
/* Status pill */
|
||||
.statuspill{ font-size:12px; padding:2px 8px; border-radius:999px; border:1px solid rgba(255,255,255,0.18); background:rgba(255,255,255,0.06); }
|
||||
.statuspill.ok{ border-color:#14532d; background:#052e16; color:#bbf7d0; }
|
||||
.statuspill.warn{ border-color:#334155; background:#0b1220; color:#e5e7eb; }
|
||||
.statuspill.bad{ border-color:#7f1d1d; background:#1a0b0b; color:#fecaca; }
|
||||
|
||||
/* Vertical cards that flow left→right */
|
||||
.row{ position:relative; display:flex; flex-direction:column; align-items:center; gap:8px; padding:10px; border-radius:12px; background: rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.06); width: clamp(150px, 14vw, 220px); transition: width .15s ease, opacity .15s ease, transform .15s ease; }
|
||||
.row.active{ outline:2px solid var(--hl); box-shadow:0 0 16px rgba(253,224,71,0.35) inset; }
|
||||
.type-pc{ border-top:3px solid var(--pc); }
|
||||
.type-npc{ border-top:3px solid var(--npc); }
|
||||
.type-monster{ border-top:3px solid var(--monster); }
|
||||
|
||||
.portrait{ width:96px; height:96px; border-radius:12px; object-fit:cover; background:rgba(255,255,255,0.06); }
|
||||
.noavatar{ width:96px; height:96px; border-radius:12px; background:rgba(255,255,255,0.06); }
|
||||
.name{ display:flex; flex-direction:column; align-items:center; text-align:center; min-width:0; }
|
||||
.name .n{ font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width: 100%; }
|
||||
.name .meta{ font-size:12px; color:var(--muted); }
|
||||
.tags{ display:flex; gap:6px; flex-wrap:wrap; justify-content:center; margin-top:2px; font-size:12px; }
|
||||
.tag{ display:inline-flex; align-items:center; gap:6px; padding:2px 8px; border-radius:10px; border:1px solid rgba(255,255,255,0.12); background:rgba(255,255,255,0.06); }
|
||||
.tag .ico_wrap{ width:16px; height:16px; border-radius:5px; overflow:hidden; background:#0b1220; display:inline-flex; }
|
||||
.tag img.ico{ width:100%; height:100%; display:block; }
|
||||
.concStar{ position:absolute; top:8px; right:8px; font-weight:700; filter: drop-shadow(0 0 6px rgba(253,224,71,0.6)); }
|
||||
|
||||
/* DEAD state */
|
||||
.dead{ border-color: rgba(239,68,68,0.7); box-shadow: inset 0 0 0 2px rgba(239,68,68,0.35); }
|
||||
.dead .portrait{ filter: grayscale(1) brightness(0.55); }
|
||||
.deathBadge{ position:absolute; top:8px; left:8px; width:22px; height:22px; border-radius:999px; background:#7f1d1d; color:#fff; display:flex; align-items:center; justify-content:center; font-size:13px; box-shadow:0 0 2px rgba(239,68,68,0.7), 0 2px 6px rgba(0,0,0,0.4); }
|
||||
.dead .name .n{ text-decoration: line-through; opacity:0.7; }
|
||||
.dead .tags{ opacity:0.7; }
|
||||
/* Shrink mode for dead cards */
|
||||
.shrunk{ width: clamp(120px, 11vw, 170px); opacity:.72; transform: scale(.95); }
|
||||
|
||||
.footer{ margin-top:6px; font-size:12px; color:var(--muted); display:flex; justify-content:space-between; }
|
||||
.hideall{ display:none; }
|
||||
|
||||
/* --- overlay for auth/errors --- */
|
||||
.overlay{ position:fixed; inset:0; display:none; align-items:center; justify-content:center; background:rgba(0,0,0,0.6); z-index:9999; }
|
||||
.overlay.show{ display:flex; }
|
||||
.overlay .box{ background:#0b0d12; color:#e9eef4; border:1px solid #293145; border-radius:12px; padding:16px; width:min(520px,92vw); }
|
||||
.overlay .box h3{ margin:0 0 8px; font-size:18px; }
|
||||
.overlay .box p{ margin:8px 0; color:#94a3b8; }
|
||||
.overlay .row{ display:flex; gap:8px; align-items:center; margin-top:10px; }
|
||||
.overlay input{ flex:1; padding:8px; border-radius:8px; border:1px solid #293145; background:#0d111a; color:#e9eef4; }
|
||||
.overlay button{ padding:8px 10px; border-radius:10px; border:1px solid #334155; background:#111827; color:#e9eef4; cursor:pointer; }
|
||||
|
||||
@media (max-width: 740px){
|
||||
.list{ flex-direction:column; }
|
||||
.row{ width:auto; align-items:flex-start; }
|
||||
.name{ align-items:flex-start; text-align:left; }
|
||||
}
|
||||
4
battleflow/static/favicon.svg
Normal file
4
battleflow/static/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
|
||||
<rect x='4' y='4' width='56' height='56' rx='12' fill='#0b1220'/>
|
||||
<polygon points='32,9 38.5,23 54,23 41,32.5 46,50 32,40.5 18,50 23,32.5 10,23 25.5,23' fill='#fde047'/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 241 B |
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