pre-push commit

This commit is contained in:
Peter van Arkel
2025-11-20 14:40:42 +01:00
parent 3001e5cffd
commit 644b207997
43 changed files with 1681 additions and 0 deletions

16
battleflow/__init__.py Normal file
View File

@@ -0,0 +1,16 @@
from flask import Flask
from .config import load_config
from .routes.admin import admin_bp
from .routes.board import board_bp
from .routes.api import api_bp
def create_app(**overrides) -> Flask:
app = Flask(__name__, static_folder="static", template_folder="templates")
load_config(app, **overrides)
# Register blueprints
app.register_blueprint(board_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(api_bp)
return app

31
battleflow/cli.py Normal file
View File

@@ -0,0 +1,31 @@
from __future__ import annotations
import argparse, os
from . import create_app
from .storage import ensure_default_icons, load_state
from .state import STATE
def main():
parser = argparse.ArgumentParser(prog="battleflow")
parser.add_argument('--host', default='127.0.0.1')
parser.add_argument('--port', type=int, default=5050)
parser.add_argument('--token', default=os.environ.get('BATTLEFLOW_TOKEN','changeme'))
parser.add_argument('--data-dir', default=os.environ.get('BATTLEFLOW_DATA_DIR'))
args = parser.parse_args()
app = create_app(token=args.token, data_dir=args.data_dir)
with app.app_context():
ensure_default_icons()
load_state(STATE)
print(f"""
Name: {app.config['PRODUCT_NAME']}{app.config['PRODUCT_SUBTITLE']}
Admin: http://{args.host}:{args.port}/admin?token={args.token}
Board: http://{args.host}:{args.port}/board?token={args.token}
Data dir: {app.config['DATA_DIR']}
Avatars: {app.config['AVATAR_DIR']}
Icons: {app.config['ICON_DIR']}
""")
app.run(host=args.host, port=args.port, debug=False)
if __name__ == "__main__":
main()

38
battleflow/config.py Normal file
View File

@@ -0,0 +1,38 @@
# battleflow/config.py
import os
DEFAULT_DATA_DIR = os.path.join(os.getcwd(), "battleflow_data")
def load_config(app, **overrides):
app.config.setdefault("SECRET_KEY", os.environ.get("SECRET_KEY", "dev-secret"))
app.config.setdefault(
"COMBAT_TOKEN",
overrides.get("token") or os.environ.get("BATTLEFLOW_TOKEN", "changeme")
)
# Kies data-dir: override/env > default. Migreer legacy 'initrack_data' → 'battleflow_data' indien nodig.
data_dir = overrides.get("data_dir") or os.environ.get("BATTLEFLOW_DATA_DIR")
if not data_dir:
default_new = DEFAULT_DATA_DIR
legacy = os.path.join(os.getcwd(), "initrack_data")
# Als legacy bestaat en nieuwe nog niet: gebruik legacy (geen dataverlies).
data_dir = legacy if (os.path.isdir(legacy) and not os.path.exists(default_new)) else default_new
app.config["DATA_DIR"] = data_dir
app.config["STATE_PATH"] = os.path.join(data_dir, "state.json")
app.config["PRESETS_PATH"] = os.path.join(data_dir, "presets.json")
app.config["PRESET_GROUPS_PATH"] = os.path.join(data_dir, "preset_groups.json")
app.config["AVATAR_DIR"] = os.path.join(data_dir, "avatars")
app.config["ICON_DIR"] = os.path.join(data_dir, "icons")
app.config.setdefault("MAX_CONTENT_LENGTH", 8 * 1024 * 1024)
# Zorg dat mappen bestaan
os.makedirs(app.config["DATA_DIR"], exist_ok=True)
os.makedirs(app.config["AVATAR_DIR"], exist_ok=True)
os.makedirs(app.config["ICON_DIR"], exist_ok=True)
# Branding
app.config.setdefault("PRODUCT_NAME", "Battleflow")
app.config.setdefault("PRODUCT_SUBTITLE", "by Aetryos Workshop")
return app

64
battleflow/models.py Normal file
View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from dataclasses import dataclass, field, asdict
from typing import List, Dict
@dataclass
class Actor:
id: str
name: str
init: float
hp: int = 0
ac: int = 0
type: str = 'pc' # 'pc' | 'npc' | 'monster'
note: str = ''
avatar: str = '' # URL or local filename under /avatars
active: bool = True
visible: bool = True
dead: bool = False
conc: bool = False
reveal_ac: bool = False
effects: List[str] = field(default_factory=list)
@dataclass
class Preset:
id: str
name: str
hp: int = 0
ac: int = 0
type: str = 'pc'
note: str = ''
avatar: str = ''
@dataclass
class PresetGroup:
id: str
name: str
member_ids: List[str] = field(default_factory=list) # preset ids
# Conditions + icons
KNOWN_CONDITIONS = [
'poisoned','prone','grappled','restrained','stunned','blinded','deafened',
'charmed','frightened','paralyzed','petrified','unconscious'
]
CONDITION_ICON_FILES: Dict[str, str] = { c: f"{c}.svg" for c in KNOWN_CONDITIONS }
CURATED_ICON_SLUGS: Dict[str, tuple] = {
'blinded': ('delapouite', 'blindfold'),
'deafened': ('skoll', 'hearing-disabled'),
'charmed': ('lorc', 'charm'),
'frightened': ('lorc', 'terror'),
'poisoned': ('sbed', 'poison-cloud'),
'prone': ('delapouite', 'half-body-crawling'),
'grappled': ('lorc', 'grab'),
'restrained': ('lorc', 'manacles'),
'stunned': ('skoll', 'knockout'),
'paralyzed': ('delapouite', 'frozen-body'),
'petrified': ('delapouite', 'stone-bust'),
'unconscious':('lorc', 'coma'),
}
CONDITION_EMOJI = {
'poisoned':'☠️','prone':'🧎','grappled':'🤝','restrained':'🪢','stunned':'💫',
'blinded':'🙈','deafened':'🧏','charmed':'💖','frightened':'😱','paralyzed':'🧍',
'petrified':'🪨','unconscious':'💤'
}

View File

@@ -0,0 +1 @@
# namespace placeholder

View File

@@ -0,0 +1,22 @@
from flask import Blueprint, render_template, request, redirect, url_for, current_app
from ..security import require_token
from ..storage import seed_curated_icons
admin_bp = Blueprint("admin", __name__)
@admin_bp.route("/admin")
@require_token
def admin():
token = request.args.get("token", "")
return render_template("admin.html",
token=token,
name=current_app.config["PRODUCT_NAME"],
subtitle=current_app.config["PRODUCT_SUBTITLE"],
data_dir=current_app.config["DATA_DIR"])
@admin_bp.post("/admin/seed_icons")
@require_token
def admin_seed_icons():
seed_curated_icons(overwrite=True)
token = request.args.get("token", "")
return redirect(url_for("admin.admin", token=token))

357
battleflow/routes/api.py Normal file
View File

@@ -0,0 +1,357 @@
from __future__ import annotations
import os, uuid
from flask import Blueprint, jsonify, request, abort, send_from_directory, current_app
from werkzeug.utils import secure_filename
from ..security import require_token
from ..models import Actor, Preset, PresetGroup
from ..state import STATE
from ..storage import save_state, load_state, save_presets, load_presets, save_groups, load_groups, ensure_default_icons
api_bp = Blueprint("api", __name__)
ALLOWED_AVATAR_EXTS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'}
# ---------- Health (public) ----------
@api_bp.get("/health")
def health():
"""
Public health endpoint (no auth required).
Add token via ?token= or X-Token to see authed:true.
"""
supplied = request.args.get("token") or request.headers.get("X-Token")
expected = current_app.config.get("COMBAT_TOKEN")
authed = bool(expected and supplied == expected)
return jsonify({
"ok": True,
"name": current_app.config.get("PRODUCT_NAME"),
"subtitle": current_app.config.get("PRODUCT_SUBTITLE"),
"authed": authed,
"actors": len(STATE.actors),
"visible": STATE.visible,
"round": STATE.round,
"dead_mode": STATE.dead_mode,
"data_dir": current_app.config.get("DATA_DIR"),
})
# ---------- Assets ----------
@api_bp.route("/avatars/<path:fn>")
def avatars(fn: str):
return send_from_directory(current_app.config["AVATAR_DIR"], fn)
@api_bp.route("/icons/<path:fn>")
def icons(fn: str):
return send_from_directory(current_app.config["ICON_DIR"], fn)
# ---------- State polling ----------
@api_bp.get("/api/state")
@require_token
def api_state():
since = 0
try:
since = int(request.args.get("since", "0"))
except ValueError:
since = 0
return jsonify(STATE.wait_for(since, timeout=25.0))
# ---------- Uploads ----------
@api_bp.post("/api/upload_avatar")
@require_token
def api_upload_avatar():
if "file" not in request.files:
abort(400, "missing file field")
f = request.files["file"]
if not f or f.filename == "":
abort(400, "empty file")
fn = secure_filename(f.filename)
ext = os.path.splitext(fn)[1].lower()
if ext not in ALLOWED_AVATAR_EXTS:
abort(415, "unsupported file type")
newname = f"{uuid.uuid4().hex}{ext}"
dest = os.path.join(current_app.config["AVATAR_DIR"], newname)
f.save(dest)
return jsonify({"ok": True, "filename": newname, "url": f"/avatars/{newname}"})
# ---------- Mutations ----------
@api_bp.post("/api/add")
@require_token
def api_add():
data = request.get_json(force=True, silent=True) or {}
t = str(data.get('type', 'pc'))
rev = data.get('reveal_ac', None)
reveal = bool(rev) if rev is not None else (t == 'pc')
a = Actor(
id=str(uuid.uuid4()),
name=str(data.get('name', 'Unknown'))[:64],
init=float(data.get('init', 0)),
hp=int(data.get('hp', 0)),
ac=int(data.get('ac', 0)),
type=t,
note=str(data.get('note', ''))[:120],
avatar=str(data.get('avatar',''))[:200],
reveal_ac=reveal,
)
STATE.actors.append(a)
STATE.normalize()
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True, 'id': a.id})
@api_bp.post("/api/update")
@require_token
def api_update():
data = request.get_json(force=True, silent=True) or {}
aid = str(data.get('id', ''))
target = next((x for x in STATE.actors if x.id == aid), None)
if not target:
abort(404)
for k, v in data.items():
if k == 'id':
continue
if v == 'toggle':
if hasattr(target, k) and isinstance(getattr(target, k), bool):
setattr(target, k, not getattr(target, k))
else:
if k in ('name','type','note','avatar'):
setattr(target, k, str(v))
elif k in ('init','hp','ac'):
setattr(target, k, int(v) if k in ('hp','ac') else float(v))
elif k in ('active','visible','dead','conc','reveal_ac'):
setattr(target, k, bool(v))
STATE.normalize()
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True})
@api_bp.post("/api/remove")
@require_token
def api_remove():
data = request.get_json(force=True, silent=True) or {}
aid = str(data.get('id', ''))
before = len(STATE.actors)
STATE.actors = [x for x in STATE.actors if x.id != aid]
if len(STATE.actors) != before:
STATE.normalize()
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True, 'removed': len(STATE.actors) != before})
@api_bp.post("/api/next")
@require_token
def api_next():
if STATE.actors:
STATE.turn_idx = (STATE.turn_idx + 1) % len(STATE.actors)
if STATE.turn_idx == 0:
STATE.round += 1
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True, 'round': STATE.round, 'turn_idx': STATE.turn_idx})
@api_bp.post("/api/prev")
@require_token
def api_prev():
if STATE.actors:
STATE.turn_idx = (STATE.turn_idx - 1) % len(STATE.actors)
if STATE.turn_idx == len(STATE.actors) - 1:
STATE.round = max(1, STATE.round - 1)
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True, 'round': STATE.round, 'turn_idx': STATE.turn_idx})
@api_bp.post("/api/clear")
@require_token
def api_clear():
STATE.actors.clear()
STATE.turn_idx = 0
STATE.round = 1
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True})
@api_bp.post("/api/toggle_visible")
@require_token
def api_toggle_visible():
STATE.visible = not STATE.visible
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True, 'visible': STATE.visible})
@api_bp.post("/api/dead_mode")
@require_token
def api_dead_mode():
data = request.get_json(force=True, silent=True) or {}
mode = str(data.get('mode','normal')).lower()
if mode not in ('normal','shrink','hide'):
abort(400)
STATE.dead_mode = mode
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True, 'dead_mode': STATE.dead_mode})
@api_bp.post("/api/toggle_effect")
@require_token
def api_toggle_effect():
data = request.get_json(force=True, silent=True) or {}
aid = str(data.get('id',''))
effect = str(data.get('effect','')).strip().lower()
if not effect:
abort(400)
target = next((x for x in STATE.actors if x.id == aid), None)
if not target:
abort(404)
if effect in target.effects:
target.effects = [e for e in target.effects if e != effect]
else:
target.effects.append(effect)
STATE.normalize()
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True, 'effects': target.effects})
@api_bp.post("/api/clear_effects")
@require_token
def api_clear_effects():
data = request.get_json(force=True, silent=True) or {}
aid = str(data.get('id',''))
target = next((x for x in STATE.actors if x.id == aid), None)
if not target:
abort(404)
target.effects = []
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True})
# Presets
@api_bp.get("/api/presets")
@require_token
def api_presets():
items = load_presets()
return jsonify({'items':[x.__dict__ for x in items]})
@api_bp.post("/api/preset/add")
@require_token
def api_preset_add():
data = request.get_json(force=True, silent=True) or {}
p = Preset(
id=str(uuid.uuid4()),
name=str(data.get('name','Unnamed'))[:64],
hp=int(data.get('hp',0)),
ac=int(data.get('ac',0)),
type=str(data.get('type','pc')),
note=str(data.get('note',''))[:120],
avatar=str(data.get('avatar',''))[:200],
)
items = load_presets()
items.append(p)
save_presets(items)
return jsonify({'ok': True, 'id': p.id})
@api_bp.post("/api/preset/remove")
@require_token
def api_preset_remove():
data = request.get_json(force=True, silent=True) or {}
pid = str(data.get('id',''))
items = load_presets()
before = len(items)
items = [x for x in items if x.id != pid]
changed = len(items) != before
if changed:
save_presets(items)
return jsonify({'ok': True, 'removed': changed})
@api_bp.post("/api/preset/apply")
@require_token
def api_preset_apply():
data = request.get_json(force=True, silent=True) or {}
pid = str(data.get('id',''))
initv = float(data.get('init', 0))
items = load_presets()
src = next((x for x in items if x.id == pid), None)
if not src:
abort(404)
reveal = (src.type == 'pc')
a = Actor(
id=str(uuid.uuid4()), name=src.name, init=initv, hp=src.hp, ac=src.ac,
type=src.type, note=src.note, avatar=src.avatar, reveal_ac=reveal,
)
STATE.actors.append(a)
STATE.normalize()
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True, 'id': a.id})
# Preset Groups
@api_bp.get("/api/preset_groups")
@require_token
def api_preset_groups():
items = load_groups()
return jsonify({'items':[x.__dict__ for x in items]})
@api_bp.post("/api/preset/group/add")
@require_token
def api_group_add():
data = request.get_json(force=True, silent=True) or {}
name = str(data.get('name','')).strip()
mids = list(data.get('member_ids') or [])
if not name or not mids:
abort(400)
g = PresetGroup(id=str(uuid.uuid4()), name=name, member_ids=mids)
items = load_groups()
items.append(g)
save_groups(items)
return jsonify({'ok': True, 'id': g.id})
@api_bp.post("/api/preset/group/remove")
@require_token
def api_group_remove():
data = request.get_json(force=True, silent=True) or {}
gid = str(data.get('id',''))
items = load_groups()
before = len(items)
items = [x for x in items if x.id != gid]
changed = len(items) != before
if changed:
save_groups(items)
return jsonify({'ok': True, 'removed': changed})
@api_bp.post("/api/preset/group/apply")
@require_token
def api_group_apply():
data = request.get_json(force=True, silent=True) or {}
gid = str(data.get('id',''))
inits = data.get('inits')
init_single = data.get('init', None)
groups = load_groups()
presets = load_presets()
g = next((x for x in groups if x.id == gid), None)
if not g:
abort(404)
added = []
for idx, pid in enumerate(g.member_ids):
src = next((x for x in presets if x.id == pid), None)
if not src: continue
if isinstance(inits, list) and idx < len(inits):
initv = float(inits[idx] or 0)
elif init_single is not None:
initv = float(init_single)
else:
initv = 10.0
reveal = (src.type == 'pc')
a = Actor(
id=str(uuid.uuid4()), name=src.name, init=initv, hp=src.hp, ac=src.ac,
type=src.type, note=src.note, avatar=src.avatar, reveal_ac=reveal,
)
STATE.actors.append(a)
added.append(a.id)
if added:
STATE.normalize()
save_state(STATE)
STATE.broadcast()
return jsonify({'ok': True, 'added': added, 'count': len(added)})
# Initialize defaults on import (with proper app context)
@api_bp.record_once
def _init_defaults(setup_state):
app = setup_state.app
with app.app_context():
ensure_default_icons()

View File

@@ -0,0 +1,22 @@
from flask import Blueprint, render_template, current_app, Response
board_bp = Blueprint("board", __name__)
FAVICON_SVG = "<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>"
@board_bp.route("/")
def root():
# convenience: redirect to admin with token if configured
from flask import redirect, url_for, current_app
t = current_app.config.get("COMBAT_TOKEN", "changeme")
return redirect(url_for("admin.admin", token=t))
@board_bp.route("/board")
def board():
return render_template("board.html",
name=current_app.config["PRODUCT_NAME"],
subtitle=current_app.config["PRODUCT_SUBTITLE"])
@board_bp.route("/favicon.svg")
def favicon_svg():
return Response(FAVICON_SVG, 200, {"Content-Type": "image/svg+xml"})

12
battleflow/security.py Normal file
View File

@@ -0,0 +1,12 @@
from functools import wraps
from flask import request, abort, current_app
def require_token(f):
@wraps(f)
def wrapper(*args, **kwargs):
expected = current_app.config.get("COMBAT_TOKEN")
supplied = request.args.get("token") or request.headers.get("X-Token")
if not expected or supplied != expected:
abort(401)
return f(*args, **kwargs)
return wrapper

63
battleflow/state.py Normal file
View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import threading, time
from dataclasses import asdict
from typing import Any, Dict, List
from .models import Actor, KNOWN_CONDITIONS, CONDITION_EMOJI, CONDITION_ICON_FILES
def now_ms() -> int:
return int(time.time() * 1000)
class CombatState:
def __init__(self) -> None:
self.actors: List[Actor] = []
self.turn_idx: int = 0
self.round: int = 1
self.visible: bool = True
self.dead_mode: str = 'normal' # 'normal' | 'shrink' | 'hide'
self.updated_at: int = now_ms()
self.version: int = 1
self._cv = threading.Condition()
def touch(self) -> None:
self.updated_at = now_ms()
self.version += 1
def sorted_actors(self) -> List[Actor]:
return sorted(self.actors, key=lambda a: a.init, reverse=True)
def normalize(self) -> None:
self.actors = self.sorted_actors()
if self.turn_idx >= len(self.actors):
self.turn_idx = 0
def to_public(self) -> Dict[str, Any]:
return {
'actors': [asdict(a) for a in self.sorted_actors()],
'turn_idx': self.turn_idx,
'round': self.round,
'visible': self.visible,
'dead_mode': self.dead_mode,
'updated_at': self.updated_at,
'version': self.version,
'condition_icons': CONDITION_ICON_FILES,
'condition_emoji': CONDITION_EMOJI,
'known_conditions': KNOWN_CONDITIONS,
}
def broadcast(self) -> None:
with self._cv:
self.touch()
self._cv.notify_all()
def wait_for(self, since: int, timeout: float = 25.0) -> Dict[str, Any]:
end = time.time() + timeout
with self._cv:
while self.version <= since:
remaining = end - time.time()
if remaining <= 0:
break
self._cv.wait(timeout=remaining)
return self.to_public()
# Singleton
STATE = CombatState()

View 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; }
}

View 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

View 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();
}

View 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();
})();

122
battleflow/storage.py Normal file
View File

@@ -0,0 +1,122 @@
from __future__ import annotations
import json, os, uuid
from typing import List, Dict
from flask import current_app
from .models import Actor, Preset, PresetGroup, CONDITION_ICON_FILES, CURATED_ICON_SLUGS
PLACEHOLDER_BG = '#334155'
PLACEHOLDER_FG = '#e2e8f0'
GAME_ICONS_BASE = 'https://raw.githubusercontent.com/game-icons/icons/master'
def paths():
return dict(
STATE=current_app.config["STATE_PATH"],
PRESETS=current_app.config["PRESETS_PATH"],
GROUPS=current_app.config["PRESET_GROUPS_PATH"],
AVATARS=current_app.config["AVATAR_DIR"],
ICONS=current_app.config["ICON_DIR"],
DATA=current_app.config["DATA_DIR"],
)
def save_state(state) -> None:
p = paths()
tmp = {
'actors': [a.__dict__ for a in state.actors],
'turn_idx': state.turn_idx,
'round': state.round,
'visible': state.visible,
'dead_mode': state.dead_mode,
}
with open(p["STATE"], 'w', encoding='utf-8') as f:
json.dump(tmp, f, ensure_ascii=False, indent=2)
def load_state(state) -> None:
p = paths()
if not os.path.exists(p["STATE"]):
return
try:
with open(p["STATE"], 'r', encoding='utf-8') as f:
data = json.load(f)
state.actors = [Actor(**a) for a in data.get('actors', [])]
state.turn_idx = int(data.get('turn_idx', 0))
state.round = int(data.get('round', 1))
state.visible = bool(data.get('visible', True))
state.dead_mode = str(data.get('dead_mode', 'normal'))
state.normalize()
state.touch()
except Exception:
pass
def save_presets(items: List[Preset]) -> None:
p = paths()
with open(p["PRESETS"], 'w', encoding='utf-8') as f:
json.dump([x.__dict__ for x in items], f, ensure_ascii=False, indent=2)
def load_presets() -> List[Preset]:
p = paths()
if not os.path.exists(p["PRESETS"]):
return []
try:
with open(p["PRESETS"], 'r', encoding='utf-8') as f:
arr = json.load(f)
return [Preset(**x) for x in arr]
except Exception:
return []
def save_groups(items: List[PresetGroup]) -> None:
p = paths()
with open(p["GROUPS"], 'w', encoding='utf-8') as f:
json.dump([x.__dict__ for x in items], f, ensure_ascii=False, indent=2)
def load_groups() -> List[PresetGroup]:
p = paths()
if not os.path.exists(p["GROUPS"]):
return []
try:
with open(p["GROUPS"], 'r', encoding='utf-8') as f:
arr = json.load(f)
return [PresetGroup(**x) for x in arr]
except Exception:
return []
def ensure_default_icons() -> None:
p = paths()
os.makedirs(p["ICONS"], exist_ok=True)
for c, fn in CONDITION_ICON_FILES.items():
dest = os.path.join(p["ICONS"], fn)
if os.path.exists(dest):
continue
letter = (c[:1] or '?').upper()
svg = f"""<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'>
<rect rx='6' width='32' height='32' fill='{PLACEHOLDER_BG}' />
<text x='16' y='21' font-family='sans-serif' font-size='16' text-anchor='middle' fill='{PLACEHOLDER_FG}'>{letter}</text>
</svg>"""
try:
with open(dest, 'w', encoding='utf-8') as f:
f.write(svg)
except Exception:
pass
def seed_curated_icons(overwrite: bool = True) -> list[dict]:
ensure_default_icons()
out = []
try:
from urllib.request import urlopen
from urllib.error import URLError, HTTPError
p = paths()
for cond, (author, slug) in CURATED_ICON_SLUGS.items():
dest = os.path.join(p["ICONS"], f'{cond}.svg')
url = f"https://raw.githubusercontent.com/game-icons/icons/master/{author}/{slug}.svg"
try:
with urlopen(url, timeout=10) as r:
svg = r.read()
if (not os.path.exists(dest)) or overwrite:
with open(dest, 'wb') as f:
f.write(svg)
out.append({'condition': cond, 'status': 'downloaded'})
else:
out.append({'condition': cond, 'status': 'exists'})
except (URLError, HTTPError) as e:
out.append({'condition': cond, 'status': f'fallback ({e.__class__.__name__})'})
finally:
return out

View File

@@ -0,0 +1,235 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>✦ {{ name }} Admin</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<style>
body{ font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial; background:#0b0d12; color:#e9eef4; margin:0; }
.topbar{ display:flex; justify-content:space-between; align-items:flex-start; gap:12px; padding:10px 16px 0; }
.left{ display:flex; flex-direction:column; gap:6px; }
.title{ opacity:.65;font-size:12px }
.subtitle{ opacity:.55; font-size:11px; margin-top:-2px; }
.globalbar{ display:flex; gap:8px; flex-wrap:wrap; }
.seg{ display:inline-flex; gap:6px; align-items:center; }
.seg .btn{ padding:4px 8px; font-size:12px; }
.seg .on{ outline:2px solid #334155; }
.wrap{ display:grid; grid-template-columns: 520px 1fr; gap:16px; padding:16px; }
.card{ background:#121621; border:1px solid #1f2535; border-radius:12px; padding:14px; }
h2{ margin:0 0 10px; font-size:16px; letter-spacing:0.4px; }
label{ font-size:12px; color:#9aa0aa; }
input, select{ width:100%; padding:8px; border-radius:8px; border:1px solid #293145; background:#0d111a; color:#e9eef4; }
.row{ display:grid; grid-template-columns:1fr 1fr; gap:8px; }
table{ width:100%; border-collapse:collapse; }
th,td{ border-bottom:1px solid #1f2535; padding:8px; font-size:14px; vertical-align:top; }
tr.active{ outline:2px solid #fde047; }
button{ padding:6px 8px; border-radius:10px; border:1px solid #293145; background:#0d111a; color:#e9eef4; cursor:pointer; }
.btn{ padding:6px 10px; border-radius:10px; border:1px solid #293145; background:#0d111a; color:#e9eef4; cursor:pointer; }
.btn-danger{ border-color:#7f1d1d; background:#1a0b0b; color:#fecaca; }
.btn-outline{ background:transparent; }
.pill{ display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; border:1px solid #293145; }
.tagType{ display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; border:1px solid transparent; font-weight:600; }
.tagType.pc{ background:#082f35; color:#7dd3fc; border-color:#0c4a59; }
.tagType.npc{ background:#1d1533; color:#c4b5fd; border-color:#352a5f; }
.tagType.monster{ background:#3b1111; color:#fca5a5; border-color:#5b1a1a; }
.bar{ display:flex; gap:8px; margin-top:8px; flex-wrap: wrap; }
.condbar button{ margin:2px; font-size:12px; display:inline-flex; align-items:center; gap:6px; }
.condbar button.on{ background:#1e293b; border-color:#334155; }
.presetlist{ max-height: 260px; overflow:auto; border:1px solid #1f2535; border-radius:10px; padding:6px; }
.preset{ display:flex; align-items:center; gap:8px; padding:6px; border-bottom:1px dashed #1f2535; }
.preset:last-child{ border-bottom:0; }
.pav{ width:22px; height:22px; border-radius:6px; object-fit:cover; background:#0d111a; }
.uploadrow{ display:grid; grid-template-columns: 1fr auto auto 68px; gap:8px; align-items:center; }
.preview{ width:64px; height:64px; border-radius:10px; background:#0d111a; border:1px solid #293145; object-fit:cover; }
.dropzone{ border:2px dashed #293145; border-radius:10px; padding:12px; text-align:center; color:#9aa0aa; }
.dropzone.drag{ background:#0f172a; border-color:#334155; color:#cbd5e1; }
.icoimg{ width:14px; height:14px; display:inline-block; vertical-align:-2px; }
.credit{ font-size:11px; color:#94a3b8; opacity:0.8; margin: 16px; text-align:center; }
.twocol{ display:grid; grid-template-columns:1fr 1fr; gap:16px; }
.scroll{ max-height:180px; overflow:auto; border:1px solid #1f2535; border-radius:10px; padding:8px; }
.muted{ color:#9aa0aa; font-size:12px; }
</style>
</head>
<body>
<div class="topbar">
<div class="left">
<div class="title">{{ name|upper }} ADMIN</div>
<div class="subtitle">{{ subtitle }}</div>
<div class="globalbar">
<button class="btn" onclick="clearAll()">Clear</button>
<button class="btn" onclick="toggleVisible()">Toggle Board</button>
<button class="btn" onclick="prev()">Prev</button>
<button class="btn" onclick="next()">Next</button>
<div class="seg" id="deadseg">
<span class="muted">Dead cards:</span>
<button class="btn" data-mode="normal" onclick="setDeadMode('normal')">Normal</button>
<button class="btn" data-mode="shrink" onclick="setDeadMode('shrink')">Shrink</button>
<button class="btn" data-mode="hide" onclick="setDeadMode('hide')">Hide</button>
</div>
</div>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<form action="/admin/seed_icons?token={{ token }}" method="post" onsubmit="return confirm('Download curated icons from Game-Icons.net now?');" style="margin:0">
<button class="btn">Seed curated icon set</button>
</form>
<button class="btn" onclick="openBoard()">Open Player Board ↗</button>
</div>
</div>
<div class="wrap">
<div class="card">
<h2>Add Actor</h2>
<div class="row">
<div>
<label>Name</label>
<input id="name"/>
</div>
<div>
<label>Init</label>
<input id="init" type="number" step="0.1" />
</div>
</div>
<div class="row" style="margin-top:6px;">
<div>
<label>HP</label>
<input id="hp" type="number"/>
</div>
<div>
<label>AC</label>
<input id="ac" type="number"/>
</div>
</div>
<div class="row" style="margin-top:6px;">
<div>
<label>Type</label>
<select id="type">
<option value="pc">PC</option>
<option value="npc">NPC</option>
<option value="monster">Monster</option>
</select>
</div>
<div>
<label>Note</label>
<input id="note"/>
</div>
</div>
<div class="row" style="margin-top:6px;align-items:center;grid-template-columns:1fr auto auto 68px;">
<div>
<label>Avatar (filename in /avatars or full URL)</label>
<input id="avatar" placeholder="goblin.png or https://..."/>
</div>
<div class="dropzone" id="avatar_drop">Drop image here</div>
<button class="btn" onclick="uploadAvatar('avatar_file','avatar','avatar_preview')">Upload</button>
<img id="avatar_preview" class="preview" alt="preview"/>
</div>
<div class="uploadrow" style="margin-top:6px;">
<input type="file" id="avatar_file" accept="image/*" />
<div class="muted">or drag & drop ↑</div>
<div></div>
<div></div>
</div>
<div class="bar">
<button class="btn" onclick="add()">Add</button>
</div>
</div>
<div class="card">
<h2>Order (desc by init) — Round <span id="round">1</span></h2>
<table>
<thead>
<tr><th>#</th><th>Name</th><th>Init</th><th>HP</th><th>AC</th><th>Type</th><th>Flags</th><th></th></tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
</div>
<div class="wrap">
<div class="card">
<h2>Add Preset</h2>
<div class="row">
<div>
<label>Name</label>
<input id="p_name"/>
</div>
<div>
<label>Type</label>
<select id="p_type">
<option value="pc">PC</option>
<option value="npc">NPC</option>
<option value="monster">Monster</option>
</select>
</div>
</div>
<div class="row" style="margin-top:6px;">
<div>
<label>HP</label>
<input id="p_hp" type="number"/>
</div>
<div>
<label>AC</label>
<input id="p_ac" type="number"/>
</div>
</div>
<div class="row" style="margin-top:6px;">
<div>
<label>Note</label>
<input id="p_note"/>
</div>
<div>
<label>Avatar</label>
<input id="p_avatar" placeholder="filename in /avatars or URL"/>
</div>
</div>
<div class="row" style="margin-top:6px;align-items:center;grid-template-columns:1fr auto auto 68px;">
<div class="dropzone" id="p_avatar_drop">Drop image here</div>
<button class="btn" onclick="uploadAvatar('p_avatar_file','p_avatar','p_avatar_preview')">Upload</button>
<img id="p_avatar_preview" class="preview" alt="preview"/>
<div></div>
</div>
<div class="uploadrow" style="margin-top:6px;">
<input type="file" id="p_avatar_file" accept="image/*" />
<div class="muted">or drag & drop ↑</div>
<div></div>
<div></div>
</div>
<div class="bar">
<button class="btn" onclick="presetAdd()">Save Preset</button>
</div>
</div>
<div class="card">
<h2>Presets</h2>
<div class="presetlist" id="presetlist"></div>
</div>
</div>
<div class="wrap">
<div class="card">
<h2>Create Preset Group (party)</h2>
<div class="twocol">
<div>
<label>Group name</label>
<input id="g_name" placeholder="Party Alpha" />
<div class="bar"><button class="btn" onclick="groupAdd()">Save Group</button></div>
</div>
<div>
<label>Select presets</label>
<div id="g_checks" class="scroll"></div>
</div>
</div>
</div>
<div class="card">
<h2>Preset Groups</h2>
<div class="presetlist" id="pg_list"></div>
</div>
</div>
<p class="credit">Icons live in <code>{{ data_dir }}/icons</code>. Curated set from <strong>Game-Icons.net</strong> (CCBY 3.0). Authors: Lorc, Delapouite, Skoll, sbed. Replace any icon by dropping an SVG with the same name (e.g., <code>poisoned.svg</code>).</p>
<script src="{{ url_for('static', filename='js/admin.js') }}"></script>
<script>
// initialise toggles/preview/dropzones and start polling
initAdmin('{{ token }}');
</script>
</body>
</html>

View File

@@ -0,0 +1,40 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>✦ {{ name }}</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/board.css') }}">
</head>
<body>
<div id="root" class="wrapper">
<div id="panel" class="panel">
<div class="header">
<div>
<div class="h-title">{{ name }}</div>
<div class="h-sub">{{ subtitle }}</div>
</div>
<div style="display:flex; gap:10px; align-items:center;">
<div class="h-sub">Round <span id="round">1</span> · Updated <span id="updated"></span></div>
<div id="statuspill" class="statuspill">Connecting…</div>
</div>
</div>
<div id="list" class="list"></div>
<div class="footer"><span id="count"></span></div>
</div>
</div>
<!-- Auth/Error overlay -->
<div id="authOverlay" class="overlay">
<div class="box">
<h3>Authorization needed</h3>
<p id="authMsg">This board couldn't authenticate. Check the URL token.</p>
<div class="row">
<input id="tokenInput" placeholder="Enter token (will be saved locally)" />
<button id="setTokenBtn">Use token</button>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/board.js') }}"></script>
</body>
</html>