Tracker: unfold a combatant's stat block inline

- extract the 5e stat-block markup into enemy/_statblock.html.twig
  (sb.block(enemy) macro); the enemy detail page now uses it too
- each tracker combatant gets a "stat block" toggle that expands the
  block in place (Bootstrap collapse), so you don't have to leave the
  encounter to check AC / attacks
- open blocks are remembered in localStorage and re-opened after each
  damage/heal reload, and the caret rotates when expanded

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-09-06 19:29:52 +02:00
co-authored by Claude Sonnet 5
parent cbe4b0a0e1
commit 2ed169fc78
5 changed files with 137 additions and 59 deletions
+40
View File
@@ -105,3 +105,43 @@ document.addEventListener('DOMContentLoaded', () => initDataTables());
document.addEventListener('turbo:load', () => initDataTables());
// Restore the plain <table> before Turbo snapshots the page for its cache.
document.addEventListener('turbo:before-cache', destroyDataTables);
// --- Remember unfolded stat blocks on the tracker -----------------------
// Every damage/heal is a POST + redirect, so without this a stat block the
// DM has open snaps shut on each hit. Keyed by the collapse element id.
const SB_KEY = 'dmtools.statblocks';
function readOpenSet() {
try {
return new Set(JSON.parse(localStorage.getItem(SB_KEY) || '[]'));
} catch (e) {
return new Set();
}
}
function restoreStatblocks() {
const open = readOpenSet();
document.querySelectorAll('.statblock-collapse').forEach((el) => {
const on = open.has(el.id);
el.classList.toggle('show', on);
document
.querySelectorAll(`[data-bs-toggle="collapse"][data-bs-target="#${el.id}"]`)
.forEach((btn) => btn.setAttribute('aria-expanded', on ? 'true' : 'false'));
});
}
function rememberStatblock(el, isOpen) {
if (!el.classList.contains('statblock-collapse')) return;
const open = readOpenSet();
isOpen ? open.add(el.id) : open.delete(el.id);
try {
localStorage.setItem(SB_KEY, JSON.stringify([...open]));
} catch (e) {
/* private mode - ignore */
}
}
document.addEventListener('shown.bs.collapse', (e) => rememberStatblock(e.target, true));
document.addEventListener('hidden.bs.collapse', (e) => rememberStatblock(e.target, false));
document.addEventListener('DOMContentLoaded', restoreStatblocks);
document.addEventListener('turbo:load', restoreStatblocks);
+12
View File
@@ -112,3 +112,15 @@ table.dataTable {
max-width: 100%;
height: auto;
}
/* --- Tracker: unfoldable stat blocks --------------------------------- */
[data-bs-toggle="collapse"] .sb-caret {
display: inline-block;
transition: transform 0.15s ease;
}
[data-bs-toggle="collapse"][aria-expanded="true"] .sb-caret {
transform: rotate(90deg);
}
.statblock-collapse h3 {
margin-top: 0.75rem;
}
+66
View File
@@ -0,0 +1,66 @@
{#
# Shared 5e stat-block rendering. Used on the enemy detail page and, folded
# up, inside each tracker combatant.
# {% import 'enemy/_statblock.html.twig' as sb %}
# {{ sb.block(enemy) }}
#}
{% macro mod(n) %}{{ n >= 0 ? '+' ~ n : n }}{% endmacro %}
{% macro _stat(label, score, m) %}
<div class="col text-center">
<div class="text-uppercase small text-body-secondary">{{ label }}</div>
<div class="fw-semibold">{{ score }} ({{ m }})</div>
</div>
{% endmacro %}
{% macro _line(label, value) %}
{% if value %}
<div><span class="fw-semibold">{{ label }}.</span> {{ value }}</div>
{% endif %}
{% endmacro %}
{% macro _section(title, body) %}
{% if body %}
<h3 class="h6 border-bottom pb-1 mt-3">{{ title }}</h3>
<div>{{ body|nl2br }}</div>
{% endif %}
{% endmacro %}
{% macro block(e) %}
{% import _self as sb %}
{{ sb._line('Armor Class', e.armorClass ~ (e.armorClassNote ? ' (' ~ e.armorClassNote ~ ')' : '')) }}
{{ sb._line('Hit Points', e.hitPoints ~ (e.hitDice ? ' (' ~ e.hitDice ~ ')' : '')) }}
{{ sb._line('Speed', e.speed) }}
<div class="row row-cols-6 my-3 g-1">
{{ sb._stat('Str', e.strength, sb.mod(e.abilityModifier(e.strength))) }}
{{ sb._stat('Dex', e.dexterity, sb.mod(e.abilityModifier(e.dexterity))) }}
{{ sb._stat('Con', e.constitution, sb.mod(e.abilityModifier(e.constitution))) }}
{{ sb._stat('Int', e.intelligence, sb.mod(e.abilityModifier(e.intelligence))) }}
{{ sb._stat('Wis', e.wisdom, sb.mod(e.abilityModifier(e.wisdom))) }}
{{ sb._stat('Cha', e.charisma, sb.mod(e.abilityModifier(e.charisma))) }}
</div>
{{ sb._line('Saving Throws', e.savingThrows) }}
{{ sb._line('Skills', e.skills) }}
{{ sb._line('Damage Vulnerabilities', e.damageVulnerabilities) }}
{{ sb._line('Damage Resistances', e.damageResistances) }}
{{ sb._line('Damage Immunities', e.damageImmunities) }}
{{ sb._line('Condition Immunities', e.conditionImmunities) }}
{{ sb._line('Senses', e.senses) }}
{{ sb._line('Languages', e.languages ?? '—') }}
{{ sb._line('Challenge', e.challengeRating ? e.challengeRating ~ (e.experiencePoints ? ' (' ~ e.experiencePoints ~ ' XP)' : '') : null) }}
{{ sb._line('Proficiency Bonus', e.proficiencyBonus ? sb.mod(e.proficiencyBonus) : null) }}
{{ sb._section('Traits', e.traits) }}
{{ sb._section('Actions', e.actions) }}
{{ sb._section('Bonus Actions', e.bonusActions) }}
{{ sb._section('Reactions', e.reactions) }}
{{ sb._section('Legendary Actions', e.legendaryActions) }}
{{ sb._section('Description', e.description) }}
{% if e.source %}
<p class="text-body-secondary small mt-3 mb-0">Source: {{ e.source }}</p>
{% endif %}
{% endmacro %}
+2 -57
View File
@@ -2,29 +2,7 @@
{% block title %}{{ enemy.name }} · Enemies · dmtools{% endblock %}
{% macro mod(n) %}{{ n >= 0 ? '+' ~ n : n }}{% endmacro %}
{% macro stat(label, score, m) %}
<div class="col text-center">
<div class="text-uppercase small text-body-secondary">{{ label }}</div>
<div class="fw-semibold">{{ score }} ({{ m }})</div>
</div>
{% endmacro %}
{% macro line(label, value) %}
{% if value %}
<div><span class="fw-semibold">{{ label }}.</span> {{ value }}</div>
{% endif %}
{% endmacro %}
{% macro section(title, body) %}
{% if body %}
<h2 class="h6 border-bottom pb-1 mt-3">{{ title }}</h2>
<div>{{ body|nl2br }}</div>
{% endif %}
{% endmacro %}
{% import _self as sb %}
{% import 'enemy/_statblock.html.twig' as sb %}
{% block body %}
<div class="d-flex justify-content-between align-items-start mb-3 gap-3">
@@ -40,40 +18,7 @@
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-body">
{{ sb.line('Armor Class', enemy.armorClass ~ (enemy.armorClassNote ? ' (' ~ enemy.armorClassNote ~ ')' : '')) }}
{{ sb.line('Hit Points', enemy.hitPoints ~ (enemy.hitDice ? ' (' ~ enemy.hitDice ~ ')' : '')) }}
{{ sb.line('Speed', enemy.speed) }}
<div class="row row-cols-6 my-3 g-1">
{{ sb.stat('Str', enemy.strength, sb.mod(enemy.abilityModifier(enemy.strength))) }}
{{ sb.stat('Dex', enemy.dexterity, sb.mod(enemy.abilityModifier(enemy.dexterity))) }}
{{ sb.stat('Con', enemy.constitution, sb.mod(enemy.abilityModifier(enemy.constitution))) }}
{{ sb.stat('Int', enemy.intelligence, sb.mod(enemy.abilityModifier(enemy.intelligence))) }}
{{ sb.stat('Wis', enemy.wisdom, sb.mod(enemy.abilityModifier(enemy.wisdom))) }}
{{ sb.stat('Cha', enemy.charisma, sb.mod(enemy.abilityModifier(enemy.charisma))) }}
</div>
{{ sb.line('Saving Throws', enemy.savingThrows) }}
{{ sb.line('Skills', enemy.skills) }}
{{ sb.line('Damage Vulnerabilities', enemy.damageVulnerabilities) }}
{{ sb.line('Damage Resistances', enemy.damageResistances) }}
{{ sb.line('Damage Immunities', enemy.damageImmunities) }}
{{ sb.line('Condition Immunities', enemy.conditionImmunities) }}
{{ sb.line('Senses', enemy.senses) }}
{{ sb.line('Languages', enemy.languages ?? '—') }}
{{ sb.line('Challenge', enemy.challengeRating ? enemy.challengeRating ~ (enemy.experiencePoints ? ' (' ~ enemy.experiencePoints ~ ' XP)' : '') : null) }}
{{ sb.line('Proficiency Bonus', enemy.proficiencyBonus ? sb.mod(enemy.proficiencyBonus) : null) }}
{{ sb.section('Traits', enemy.traits) }}
{{ sb.section('Actions', enemy.actions) }}
{{ sb.section('Bonus Actions', enemy.bonusActions) }}
{{ sb.section('Reactions', enemy.reactions) }}
{{ sb.section('Legendary Actions', enemy.legendaryActions) }}
{{ sb.section('Description', enemy.description) }}
{% if enemy.source %}
<p class="text-body-secondary small mt-3 mb-0">Source: {{ enemy.source }}</p>
{% endif %}
{{ sb.block(enemy) }}
</div>
</div>
</div>
+17 -2
View File
@@ -2,6 +2,8 @@
{% block title %}{{ encounter.name }} · Tracker · dmtools{% endblock %}
{% import 'enemy/_statblock.html.twig' as sb %}
{% block body %}
<div class="d-flex justify-content-between align-items-start mb-3 gap-3 flex-wrap">
<div>
@@ -52,8 +54,12 @@
{% if c.isDown %}<span class="badge text-bg-secondary ms-1">Down</span>{% endif %}
</div>
{% if c.enemy %}
<a href="{{ path('app_enemies_show', {id: c.enemy.id}) }}"
class="small text-decoration-none text-body-secondary">stat block</a>
<button type="button"
class="btn btn-link btn-sm p-0 text-decoration-none text-body-secondary"
data-bs-toggle="collapse" data-bs-target="#sb-{{ c.id }}"
aria-expanded="false" aria-controls="sb-{{ c.id }}">
<span class="sb-caret">&#9656;</span> stat block
</button>
{% endif %}
</div>
@@ -85,6 +91,15 @@
</div>
</div>
</div>
{% if c.enemy %}
<div class="collapse statblock-collapse mt-2 pt-2 border-top" id="sb-{{ c.id }}">
<p class="text-body-secondary fst-italic small mb-2">{{ c.enemy.metaLine }}</p>
<div class="small">{{ sb.block(c.enemy) }}</div>
<a href="{{ path('app_enemies_show', {id: c.enemy.id}) }}"
class="small text-decoration-none">Open full stat block &rarr;</a>
</div>
{% endif %}
</div>
</div>
{% endfor %}