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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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">▸</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 →</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
Reference in New Issue
Block a user