pre-push commit
This commit is contained in:
16
battleflow/__init__.py
Normal file
16
battleflow/__init__.py
Normal 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
31
battleflow/cli.py
Normal 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
38
battleflow/config.py
Normal 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
64
battleflow/models.py
Normal 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':'💤'
|
||||
}
|
||||
1
battleflow/routes/__init__.py
Normal file
1
battleflow/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# namespace placeholder
|
||||
22
battleflow/routes/admin.py
Normal file
22
battleflow/routes/admin.py
Normal 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
357
battleflow/routes/api.py
Normal 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()
|
||||
22
battleflow/routes/board.py
Normal file
22
battleflow/routes/board.py
Normal 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
12
battleflow/security.py
Normal 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
63
battleflow/state.py
Normal 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()
|
||||
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();
|
||||
})();
|
||||
122
battleflow/storage.py
Normal file
122
battleflow/storage.py
Normal 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
|
||||
235
battleflow/templates/admin.html
Normal file
235
battleflow/templates/admin.html
Normal 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> (CC‑BY 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>
|
||||
40
battleflow/templates/board.html
Normal file
40
battleflow/templates/board.html
Normal 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>
|
||||
Reference in New Issue
Block a user