Jordancole21's picture
Deploy native Gradio SEO page and tool
901493d verified
Raw
History Blame Contribute Delete
8.33 kB
"""D&D Combat Tracker — stateful, deterministic Gradio app (no AI)."""
import tempfile
from pathlib import Path
import gradio as gr
from src import content, tool
from src.space_config import CONFIG
from src.shared.ctas import cta_html
from src.shared.page import standard_launch
from src.shared.seo import (
hero_html, section_html, faq_html, related_html,
)
COPY_JS = """
() => {
const el = document.querySelector('#lf-copy-src textarea');
if (!el || !el.value) return;
const flash = () => {
const btn = document.querySelector('#lf-copy-btn button') ||
document.querySelector('#lf-copy-btn');
if (btn) { const t = btn.textContent; btn.textContent = 'Copied!';
setTimeout(() => (btn.textContent = t), 1500); }
};
const fallback = () => {
el.focus({preventScroll: true}); el.select();
try { document.execCommand('copy'); } catch (e) {}
flash();
};
try {
navigator.clipboard.writeText(el.value).then(flash).catch(fallback);
} catch (e) { fallback(); }
}
"""
def _sync(state):
"""Everything that must refresh after any state change: both state stores,
the board HTML, the combatant dropdown, the copy source, and the download."""
md = tool.export_markdown(state)
path = Path(tempfile.mkdtemp()) / "dnd-combat-tracker.md"
path.write_text(md, encoding="utf-8")
return (
state, # gr.State
state, # gr.BrowserState
tool.render_board(state), # board HTML
gr.update(choices=tool.combatant_choices(state), # combatant dropdown
value=None),
md, # copy source
gr.update(value=str(path)), # download button
)
def on_app_load(saved):
state = tool.coerce_state(saved)
return _sync(state)
def on_add(state, name, side, initiative, max_hp, ac):
state = tool.add_combatant(state, name, side, initiative, max_hp, ac)
return (*_sync(state), "") # extra output clears the name box
def on_load_example(state):
return _sync(tool.load_example())
def on_clear_all(state):
return _sync(tool.clear_all())
def on_next(state):
return _sync(tool.next_turn(state))
def on_prev(state):
return _sync(tool.prev_turn(state))
def on_reset(state):
return _sync(tool.reset_encounter(state))
def on_damage(state, cid, amount):
if cid is None:
return _sync(state)
return _sync(tool.apply_hp(state, cid, -abs(int(amount or 0))))
def on_heal(state, cid, amount):
if cid is None:
return _sync(state)
return _sync(tool.apply_hp(state, cid, abs(int(amount or 0))))
def on_toggle_condition(state, cid, condition):
if cid is None or not condition:
return _sync(state)
return _sync(tool.toggle_condition(state, cid, condition))
def on_remove(state, cid):
if cid is None:
return _sync(state)
return _sync(tool.remove_combatant(state, cid))
with gr.Blocks(title=CONFIG.title) as demo:
gr.HTML(hero_html(CONFIG))
gr.HTML(cta_html(CONFIG, CONFIG.hero_cta))
state = gr.State(tool.new_state())
browser_state = gr.BrowserState(
default_value=tool.new_state(), storage_key="lf_combat_tracker_v1",
)
# ---- add-combatant form -------------------------------------------------
with gr.Group():
with gr.Row():
name_tb = gr.Textbox(label="Name", placeholder="e.g. Goblin 1 or Sera",
scale=3)
side_radio = gr.Radio(tool.SIDES, label="Side", value="PC", scale=2)
with gr.Row():
init_num = gr.Number(label="Initiative", value=10, precision=1)
hp_num = gr.Number(label="Max HP", value=10, precision=0, minimum=1)
ac_num = gr.Number(label="AC (optional)", value=None, precision=0)
with gr.Row():
add_btn = gr.Button("Add Combatant", variant="primary",
elem_classes=["lf-generate"])
example_btn = gr.Button("Load Example Encounter")
clear_btn = gr.Button("Clear All")
# ---- battle board -------------------------------------------------------
board = gr.HTML(tool.render_board(tool.new_state()))
# ---- turn controls ------------------------------------------------------
with gr.Row():
next_btn = gr.Button("Next Turn ▶", variant="primary",
elem_classes=["lf-generate"])
prev_btn = gr.Button("◀ Back")
reset_btn = gr.Button("Reset Encounter")
# ---- per-combatant actions ---------------------------------------------
with gr.Group():
with gr.Row():
combatant_dd = gr.Dropdown(choices=[], value=None, label="Combatant",
scale=3)
amount_num = gr.Number(label="Amount", value=1, precision=0,
minimum=0, scale=1)
damage_btn = gr.Button("Damage")
heal_btn = gr.Button("Heal")
with gr.Row():
condition_dd = gr.Dropdown(tool.CONDITIONS, value=None,
label="Condition", scale=3)
condition_btn = gr.Button("Toggle Condition")
remove_btn = gr.Button("Remove")
# ---- export -------------------------------------------------------------
# Kept in the DOM (offscreen) so the Copy button's JS can read it;
# visible=False would remove the textarea entirely in Gradio 6.
copy_src = gr.Textbox(value=tool.export_markdown(tool.new_state()),
elem_id="lf-copy-src", elem_classes=["lf-offscreen"],
container=False, show_label=False)
with gr.Row():
copy_btn = gr.Button("Copy Summary", elem_id="lf-copy-btn")
download_btn = gr.DownloadButton("Download .md")
gr.HTML(cta_html(CONFIG, CONFIG.post_tool_cta))
gr.HTML(section_html("Run a Full D&D Combat", content.HOW_TO_HTML))
gr.HTML(section_html("What This DnD Combat Tracker Handles",
content.HANDLES_HTML))
gr.HTML(section_html("Worked Example: A Goblin Ambush in 3 Rounds",
content.EXAMPLE_HTML))
gr.HTML(section_html("Tracker Rules & Tips", content.RULES_HTML))
gr.HTML(faq_html(content.FAQS))
gr.HTML(related_html(CONFIG))
gr.HTML(cta_html(CONFIG, CONFIG.footer_cta))
gr.HTML(content.DISCLAIMER_HTML)
# ---- wiring -------------------------------------------------------------
sync_outputs = [state, browser_state, board, combatant_dd, copy_src,
download_btn]
demo.load(on_app_load, [browser_state], sync_outputs,
api_name=False, queue=False)
add_btn.click(on_add, [state, name_tb, side_radio, init_num, hp_num, ac_num],
sync_outputs + [name_tb], api_name=False, queue=False)
name_tb.submit(on_add, [state, name_tb, side_radio, init_num, hp_num, ac_num],
sync_outputs + [name_tb], api_name=False, queue=False)
example_btn.click(on_load_example, [state], sync_outputs,
api_name=False, queue=False)
clear_btn.click(on_clear_all, [state], sync_outputs,
api_name=False, queue=False)
next_btn.click(on_next, [state], sync_outputs, api_name=False, queue=False)
prev_btn.click(on_prev, [state], sync_outputs, api_name=False, queue=False)
reset_btn.click(on_reset, [state], sync_outputs, api_name=False, queue=False)
damage_btn.click(on_damage, [state, combatant_dd, amount_num], sync_outputs,
api_name=False, queue=False)
heal_btn.click(on_heal, [state, combatant_dd, amount_num], sync_outputs,
api_name=False, queue=False)
condition_btn.click(on_toggle_condition, [state, combatant_dd, condition_dd],
sync_outputs, api_name=False, queue=False)
remove_btn.click(on_remove, [state, combatant_dd], sync_outputs,
api_name=False, queue=False)
copy_btn.click(None, None, None, js=COPY_JS, api_name=False, queue=False)
if __name__ == "__main__":
standard_launch(demo, CONFIG)