diff --git a/camp/character/templates/character/_character_summary.html b/camp/character/templates/character/_character_summary.html new file mode 100644 index 00000000..79f87b8d --- /dev/null +++ b/camp/character/templates/character/_character_summary.html @@ -0,0 +1,269 @@ +{% load character_sheet %} +{% load markdown %} + +
+ +{% comment %} OOB messages so alerts update during HTMX swaps {% endcomment %} +{% if request.htmx %}{% include "snippets/messages.html" %}{% endif %} + +{% if issues|length == 1 %} + + +{% elif issues %} + +
+
+

+ +

+
+
+
    + {% for issue in issues %} + {% if issue.feature_id %} +
  • + + {{issue.reason | markdown}} + +
  • + {% else %} +
  • {{issue.reason | markdown}}
  • + {% endif %} + {% endfor %} +
+
+
+
+
+ + +{% endif %} + + +
+{% for group in feature_groups %} +
+
+

+ {{ group.name }} + {% if group.has_available %} + + {% endif %} +

+ {% if group.explain_list %} +
    + {% for text in group.explain_list %} +
  • {{ text }}
  • + {% endfor %} +
+ {% endif %} +
+ {% for f in group.taken %} + + {% endfor %} +
+
+
+{% endfor %} +
+ + + +{% for group in feature_groups %} +{% if group.has_available %} + +{% endif %} +{% endfor %} + +
diff --git a/camp/character/templates/character/_feature_form.html b/camp/character/templates/character/_feature_form.html new file mode 100644 index 00000000..b5d4810e --- /dev/null +++ b/camp/character/templates/character/_feature_form.html @@ -0,0 +1,436 @@ +{% load crispy_forms_tags %} +{% load character_sheet %} +{% load markdown %} + + + + + diff --git a/camp/character/templates/character/character_base.html b/camp/character/templates/character/character_base.html index 86e44d71..1260931c 100644 --- a/camp/character/templates/character/character_base.html +++ b/camp/character/templates/character/character_base.html @@ -7,19 +7,19 @@ {% endblock %} {% block body_title %} -
+
{{ character }} -
+
{% endblock %} {% block precontent %} -
+

{% if character.campaign %} Campaign: {{character.campaign}} {% else %} @@ -82,6 +82,13 @@

Player: {{character.owner.username}}
  • LP: {% get 'lp' %}
  • Spikes: {% get 'spikes' %}
  • {% block extra_header_bar %}{% endblock %} +
  • + {% if request.resolver_match.url_name == 'character-detail-beta' %} + Switch to classic + {% else %} + Try responsive editor (beta) + {% endif %} +
  • diff --git a/camp/character/templates/character/character_detail_beta.html b/camp/character/templates/character/character_detail_beta.html new file mode 100644 index 00000000..367eeb6c --- /dev/null +++ b/camp/character/templates/character/character_detail_beta.html @@ -0,0 +1,226 @@ +{% extends "character/character_base.html" %} +{% load rules %} +{% load character_sheet %} +{% load markdown %} + +{% block extra_header_bar %} +
  • + +
  • + {% endblock %} + +{% block content %} + +
    + +
    + {% include "character/_character_summary.html" %} +
    + + +{% if not character.campaign %} + + +{% endif %} + + + + + + + +{% if undo %} + + + +{% endif %} + + + + + + + + +
    + +{% endblock %} diff --git a/camp/character/templates/character/feature_form.html b/camp/character/templates/character/feature_form.html index 4886956b..1aeff8c2 100644 --- a/camp/character/templates/character/feature_form.html +++ b/camp/character/templates/character/feature_form.html @@ -75,7 +75,7 @@

    Details

    {% for explanation in explain_ranks %} -

    {{ explanation | markdown }}

    +

    {{ explanation|feature_markdown:feature_base }}

    {% endfor %}
    {% endif %} diff --git a/camp/character/urls.py b/camp/character/urls.py index 0fea6007..f36661db 100644 --- a/camp/character/urls.py +++ b/camp/character/urls.py @@ -22,6 +22,12 @@ def to_url(self, value): path("", views.CharacterListView.as_view(), name="character-list"), path("new/", views.CreateCharacterView.as_view(), name="character-add"), path("/", views.CharacterView.as_view(), name="character-detail"), + path( + "/beta/", + views.CharacterViewBeta.as_view(), + name="character-detail-beta", + ), + path("/summary/", views.character_summary_view, name="character-summary"), path("/delete/", views.delete_character, name="character-delete"), path("/set/", views.set_attr, name="character-set-attr"), path("/name/", views.set_name, name="character-set-name"), diff --git a/camp/character/views.py b/camp/character/views.py index acd666f5..50118c67 100644 --- a/camp/character/views.py +++ b/camp/character/views.py @@ -15,6 +15,7 @@ from django.db import IntegrityError from django.db import transaction from django.http import Http404 +from django.http import HttpResponse from django.shortcuts import get_object_or_404 from django.shortcuts import redirect from django.shortcuts import render @@ -78,6 +79,10 @@ def get_context_data(self, **kwargs) -> dict: return context +class CharacterViewBeta(CharacterView): + template_name = "character/character_detail_beta.html" + + class CreateCharacterView(AutoPermissionRequiredMixin, CreateView): model = Character form_class = forms.NewCharacterForm @@ -127,6 +132,10 @@ def delete_character(request, pk): messages.warning(request, "Character discarded.") else: messages.warning(request, "Character was already discarded.") + if getattr(request, "htmx", False): + response = redirect("home") + response["HX-Redirect"] = response.url + return response return redirect("home") @@ -172,6 +181,11 @@ def set_attr(request, pk): sheet.save() else: messages.error(request, "Error validating character: %s" % d.reason) + if getattr(request, "htmx", False): + response = HttpResponse(status=204) + response["HX-Trigger"] = "refresh-character" + response["HX-Trigger-Target"] = "body" + return response return redirect("character-detail", pk=pk) @@ -194,6 +208,11 @@ def apply_view(request, pk): messages.success(request, result.reason or "Change applied.") else: messages.error(request, result.reason or "Could not apply change.") + if getattr(request, "htmx", False): + response = HttpResponse(status=204) + response["HX-Trigger"] = "refresh-character" + response["HX-Trigger-Target"] = "body" + return response return HttpResponseClientRefresh() @@ -212,6 +231,11 @@ def set_name(request, pk): messages.success(request, f"Name changed to {new_name}") else: messages.error(request, "Character name can't be empty.") + if getattr(request, "htmx", False): + response = HttpResponse(status=204) + response["HX-Trigger"] = "refresh-character" + response["HX-Trigger-Target"] = "body" + return response return redirect(character) @@ -248,13 +272,64 @@ def feature_view(request, pk, feature_id): pf = None if can_inc or can_dec: - if request.POST and "purchase" in request.POST or "remove" in request.POST: + if request.POST and ("purchase" in request.POST or "remove" in request.POST): success, pf = _try_apply_purchase( sheet, feature_id, feature_controller, controller, request ) - if success: - # If we purchased a feature and it has a choice that can be made, - # stay on the feature page. Otherwise, return to the character page. + if success and getattr(request, "htmx", False): + # Rebuild controller state and return partial with trigger + try: + feature_controller = controller.feature_controller(feature_id) + except Exception: + # If the feature no longer exists (e.g., removed), try parent; otherwise fall back to character page via empty context + if feature_controller.internal and feature_controller.parent: + feature_controller = feature_controller.parent + issues = feature_controller.issues() or [] + if ( + feature_controller.value > 0 + and feature_controller.supports_child_purchases + ): + subfeatures = feature_controller.subfeatures + subfeatures_available = _features( + controller, + feature_controller.subfeatures_available, + hide_internal=False, + ) + else: + subfeatures = [] + subfeatures_available = [] + for sf in subfeatures: + if sf_issues := sf.issues(): + issues.extend(sf_issues) + choices = feature_controller.choices + context = { + "character": character, + "controller": controller, + "feature": feature_controller, + "subfeatures": subfeatures, + "subfeatures_available": subfeatures_available, + "explain_ranks": feature_controller.explain, + "feature_base": reverse("character-detail", args=[character.id]) + + "f/", + "choices": ( + {k: forms.ChoiceForm(c) for (k, c) in choices.items()} + if choices + else {} + ), + "purchase_form": forms.FeatureForm(feature_controller), + "issues": issues, + } + # Recheck increment permission on fresh controller + can_inc = feature_controller.can_increase() + if not can_inc: + context["no_purchase_reason"] = can_inc.reason + context["trigger_refresh"] = True + response = render(request, "character/_feature_form.html", context) + response["HX-Trigger"] = "refresh-character" + response["HX-Trigger-Target"] = "body" + return response + elif success: + # Existing non-HTMX behavior stay = False if feature_controller.has_available_choices: messages.info(request, "Choices are available for this feature.") @@ -302,6 +377,52 @@ def feature_view(request, pk, feature_id): messages.success(request, result.reason) else: messages.error(request, result.reason or "Could not apply choice.") + if getattr(request, "htmx", False): + # Re-render partial with updated state and trigger a summary refresh + feature_controller = controller.feature_controller(feature_id) + issues = feature_controller.issues() or [] + if ( + feature_controller.value > 0 + and feature_controller.supports_child_purchases + ): + subfeatures = feature_controller.subfeatures + subfeatures_available = _features( + controller, + feature_controller.subfeatures_available, + hide_internal=False, + ) + else: + subfeatures = [] + subfeatures_available = [] + for sf in subfeatures: + if sf_issues := sf.issues(): + issues.extend(sf_issues) + choices = feature_controller.choices + context = { + "character": character, + "controller": controller, + "feature": feature_controller, + "subfeatures": subfeatures, + "subfeatures_available": subfeatures_available, + "explain_ranks": feature_controller.explain, + "feature_base": reverse("character-detail", args=[character.id]) + "f/", + "choices": ( + {k: forms.ChoiceForm(c) for (k, c) in choices.items()} + if choices + else {} + ), + "purchase_form": pf, + "issues": issues, + } + # Recheck increment permission on fresh controller + can_inc = feature_controller.can_increase() + if not can_inc: + context["no_purchase_reason"] = can_inc.reason + context["trigger_refresh"] = True + response = render(request, "character/_feature_form.html", context) + response["HX-Trigger"] = "refresh-character" + response["HX-Trigger-Target"] = "body" + return response return redirect("character-feature-view", pk=pk, feature_id=feature_id) if feature_controller.value > 0 and feature_controller.supports_child_purchases: @@ -326,6 +447,7 @@ def feature_view(request, pk, feature_id): "subfeatures": subfeatures, "subfeatures_available": subfeatures_available, "explain_ranks": feature_controller.explain, + "feature_base": reverse("character-detail", args=[character.id]) + "f/", "choices": ( {k: forms.ChoiceForm(c) for (k, c) in choices.items()} if choices else {} ), @@ -334,9 +456,32 @@ def feature_view(request, pk, feature_id): } if not can_inc: context["no_purchase_reason"] = can_inc.reason + # Render full page or partial depending on HTMX/param + if getattr(request, "htmx", False) or request.GET.get("partial"): + return render(request, "character/_feature_form.html", context) return render(request, "character/feature_form.html", context) +@permission_required( + "character.view_character", fn=objectgetter(Character), raise_exception=True +) +def character_summary_view(request, pk): + character = get_object_or_404(Character, id=pk) + sheet: Sheet = cast(Sheet, character.primary_sheet) + controller: CharacterController = sheet.controller + taken_features = controller.list_features(taken=True, available=False) + available_features = controller.list_features(available=True, taken=False) + context = { + "character": character, + "controller": controller, + "feature_groups": _features( + controller, chain(taken_features, available_features) + ), + "issues": controller.issues(), + } + return render(request, "character/_character_summary.html", context) + + def _try_apply_purchase( sheet: Sheet, feature_id: str, @@ -412,8 +557,18 @@ def undo_view(request, pk): else: description = "the last action" messages.success(request, f"Undid {description}") + if getattr(request, "htmx", False): + response = HttpResponse(status=204) + response["HX-Trigger"] = "refresh-character" + response["HX-Trigger-Target"] = "body" + return response return redirect("character-detail", pk=pk) messages.error(request, "Invalid undo request.") + if getattr(request, "htmx", False): + response = HttpResponse(status=204) + response["HX-Trigger"] = "refresh-character" + response["HX-Trigger-Target"] = "body" + return response return redirect("character-detail", pk=pk) @@ -443,6 +598,10 @@ def copy_view(request, pk): controller.model = new_model new_sheet.controller = controller new_sheet.save() + if getattr(request, "htmx", False): + response = redirect(new_character) + response["HX-Redirect"] = response.url + return response return redirect(new_character) diff --git a/camp/game/templatetags/markdown.py b/camp/game/templatetags/markdown.py index 9f405d2e..ae1aff1e 100644 --- a/camp/game/templatetags/markdown.py +++ b/camp/game/templatetags/markdown.py @@ -32,3 +32,25 @@ def markdown(value, arg=None): return nh3.clean( _MD.convert(value), set_tag_attribute_values=attr_values, attributes=_ATTRIBUTES ) + + +@register.filter(name="feature_markdown") +@mark_safe +@stringfilter +def feature_markdown(value: str, base: str | None = None): + """Render markdown intended for feature explanations. + + If a base is provided, rewrite any hrefs that start with "../" to be + rooted at the provided base. This makes links generated by the rules + engine work in both classic and reactive views. + """ + # Render and sanitize like the standard markdown filter + html = nh3.clean(_MD.convert(value), attributes=_ATTRIBUTES) + if not base: + return html + # Ensure base ends with a slash + if not base.endswith("/"): + base = base + "/" + # Rewrite relative links pointing to parent path + # This is a simple and safe textual replacement post-sanitization + return html.replace('href="../', f'href="{base}') diff --git a/static/css/base.css b/static/css/base.css index c43779d7..35e0a764 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -21,3 +21,10 @@ .skill-tooltip .tooltip-content strong { color: #3498db; } + +/* Feature modal content: ensure long content scrolls instead of cutting off */ +.modal-dialog-scrollable .modal-body { + overflow-y: auto; +} + +/* Character busy state overlay (disabled to avoid modal interaction issues) */ diff --git a/static/js/base.js b/static/js/base.js index e69de29b..81e59ba2 100644 --- a/static/js/base.js +++ b/static/js/base.js @@ -0,0 +1,15 @@ +document.addEventListener('DOMContentLoaded', function () { + // Feature modal spinner visibility + document.body.addEventListener('htmx:configRequest', function () { + const indicator = document.querySelector('#featureModalSpinner'); + if (indicator) { + indicator.style.display = ''; + } + }); + document.body.addEventListener('htmx:afterOnLoad', function () { + const indicator = document.querySelector('#featureModalSpinner'); + if (indicator) { + indicator.style.display = 'none'; + } + }); +}); diff --git a/templates/base.html b/templates/base.html index 2ed1ffb2..f25165c5 100644 --- a/templates/base.html +++ b/templates/base.html @@ -112,17 +112,16 @@ - {% if messages %}
    - {% for message in messages %} - - {% endfor %} - + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %}
    - {% endif %} @@ -153,9 +152,62 @@

    {% block body_title %}{% endblock %}