Frontend¶
Django Cast ships a small JavaScript layer that powers interactive features: an audio player, an image gallery modal, and AJAX-based comments. Assets are built with Vite and integrated into Django templates via django-vite. Page-level interactivity (pagination, gallery navigation) uses HTMX.
Architecture Overview¶
The JavaScript source lives in javascript/src/ and is organized into three
independent entry points:
src/gallery/image-gallery-bs4.tsA custom element (
<image-gallery-bs4>) that provides Bootstrap-modal gallery navigation with keyboard support. Used by the bootstrap4 theme.src/audio/podlove-player.tsA custom element (
<podlove-player>) that lazy-loads the Podlove Web Player 5 for audio playback. Used by all themes.src/comments/ajaxcomments.tsAn IIFE script that intercepts comment form submissions and posts them via
fetch, with preview support, threaded replies, and error display. Loaded on post detail pages when comments are enabled.
The plain theme does not use the <image-gallery-bs4> web component for
galleries. Instead, it renders a pure HTMX-driven gallery modal (see
Gallery Modal Navigation).
Pagination¶
The blog index page comes with pagination support. You can set the
number of posts per page using the POST_LIST_PAGINATION setting.
If there are more than 3 pages, there will be a “…” in the pagination. If there are more than 10 pages, there will be two “…” in the pagination.
Web Components¶
<podlove-player>¶
A custom HTML element that wraps the Podlove Web Player 5. It handles lazy loading, dark mode detection, and an optional click-to-load facade.
Template Usage¶
The element is rendered by the shared template
cast/audio/audio.html:
<podlove-player
id="audio_{{ value.pk }}"
data-variant="xl"
data-url="{% url 'cast:api:audio_podlove_detail' pk=value.pk post_id=page.pk %}"
data-embed="{% static 'cast/js/web-player/embed.5.js' %}"
data-config="{% url 'cast:api:player_config' %}"
{% if podlove_load_mode == "facade" %}
data-load-mode="facade"
{% elif podlove_load_mode == "click" %}
data-load-mode="click"
{% endif %}
>
{% if podlove_load_mode == "facade" %}
<!-- server-rendered facade: cover art, title, play button -->
{% endif %}
</podlove-player>
Data Attributes¶
data-url(required)The API endpoint returning the Podlove episode JSON for this audio file.
data-configURL for the player configuration (theme colors, fonts). Defaults to
/api/audios/player_config/. You can customize the theme per template base directory using theCAST_PODLOVE_PLAYER_THEMESsetting.data-embedURL of the Podlove Web Player embed script. Defaults to the CDN version at
https://cdn.podlove.org/web-player/5.x/embed.js. Django Cast ships a local copy atcast/js/web-player/embed.5.js.data-templateOptional Podlove template name passed through to the player initialization.
data-load-modeControls how the player is initialized. Two values are supported:
"click"— displays a “Load player” button; the player loads only when clicked. Used on list pages to avoid loading multiple heavy player instances at once."facade"— the server renders a static preview (cover art, title, play button) inside the element. The player auto-initializes viaIntersectionObserverand is injected into the existing container. Used on post detail pages.
If omitted, the player auto-initializes with a plain placeholder.
Initialization Behavior¶
Auto-init (no data-load-mode, or data-load-mode="facade"):
The element waits for the page
loadevent.A shared
IntersectionObserver(with a 200 px root margin) watches the element.When the element enters the viewport, initialization is scheduled via
requestIdleCallback.The embed script is loaded (once, shared across all players on the page), then the Podlove player is created inside the element.
In facade mode, the server-rendered preview (cover art, title, decorative play
button and progress bar) is visible while the player loads. The JS skips
creating its own placeholder when it detects a .podlove-player-container
already present inside the element. The player <div> is appended into
the existing container; the facade markup remains in the DOM unless hidden
by CSS or overwritten by the player iframe.
Click-to-load (data-load-mode="click"):
A placeholder container with a “Load player” button is rendered immediately.
Clicking the button bypasses the page-load wait and IntersectionObserver steps — it directly schedules initialization via
requestIdleCallback, then loads the embed script and creates the player.On failure, an error message is shown and the button text changes to “Try again”.
Dark Mode¶
The player automatically detects dark mode by checking (in order):
data-bs-themeon<html>data-themeon<html>data-bs-themeon<body>data-themeon<body>prefers-color-scheme: darkmedia query
When dark mode is detected, ?color_scheme=dark is appended to the config
URL so the server can return appropriate theme tokens.
<image-gallery-bs4>¶
A custom HTML element that manages Bootstrap-modal image galleries with keyboard navigation. Used by the bootstrap4 theme.
Template Usage¶
The element wraps gallery thumbnails and an associated Bootstrap modal. Each
thumbnail is an <a> tag with a nested <picture> and <img>. The
<img> carries data attributes that define the modal-size image sources and
navigation links.
<image-gallery-bs4 id="gallery-{{ block.id }}">
<div class="cast-gallery-container">
<a class="cast-gallery-modal"
data-toggle="modal" data-target="#galleryModal-{{ block.id }}"
data-full="{{ image.modal.src.jpeg }}">
<picture>
<source srcset="{{ image.thumbnail.srcset.avif }}" type="image/avif"
sizes="{{ image.thumbnail.sizes }}"
data-modal-src="{{ image.modal.src.avif }}"
data-modal-srcset="{{ image.modal.srcset.avif }}"
data-modal-sizes="{{ image.modal.sizes }}">
<img id="img-{{ block.id }}-0"
class="cast-gallery-thumbnail"
alt="{{ image.default_alt_text }}"
src="{{ image.thumbnail.src.jpeg }}"
srcset="{{ image.thumbnail.srcset.jpeg }}"
sizes="{{ image.thumbnail.sizes }}"
width="{{ image.thumbnail.width }}"
height="{{ image.thumbnail.height }}"
data-modal-src="{{ image.modal.src.jpeg }}"
data-modal-srcset="{{ image.modal.srcset.jpeg }}"
data-modal-sizes="{{ image.modal.sizes }}"
data-modal-width="{{ image.modal.width }}"
data-modal-height="{{ image.modal.height }}"
data-prev="false"
data-next="img-{{ block.id }}-1"
loading="lazy" />
</picture>
</a>
<!-- more thumbnails -->
<div class="modal fade" id="galleryModal-{{ block.id }}"
tabindex="-1" role="dialog" aria-hidden="true">
<!-- Bootstrap modal with placeholder image -->
</div>
</div>
</image-gallery-bs4>
Data Attributes¶
On <source> (AVIF variant for the modal):
data-modal-src,data-modal-srcset,data-modal-sizesThe AVIF image sources to use in the modal
<source>element.
On <img> (JPEG fallback and navigation):
data-fullsrcThe JPEG URL for the modal
<img>src. Note: the current template setsdata-modal-srcon<img>instead; the JS readsdata-fullsrc, so the modalsrcfalls back to the placeholder whilesrcset(fromdata-modal-srcset) provides the actual image.data-modal-srcset,data-modal-sizesThe JPEG srcset and sizes for the modal
<img>.data-modal-width,data-modal-heightUsed to set the
aspect-ratioCSS property on the modal image, preventing layout shift while the full-size image loads.data-prev,data-nextThe
idof the previous/next thumbnail<img>element, or"false"if at the boundary. These drive the Prev/Next navigation.data-fullOn the parent
<a>tag. The original full-resolution image URL, used as thehrefon the modal image link.
Loading States¶
When navigating between images, a spinner overlay is shown while the new full-size image loads. A sequence counter ensures that only the most recent navigation request updates the display, preventing race conditions with slow-loading images.
Bootstrap Compatibility¶
The component supports three Bootstrap modal APIs, tried in order:
jQuery plugin (
$(modal).modal("show")) — legacy Bootstrap 4 with jQueryBootstrap JS global (
new bootstrap.Modal(el)) — Bootstrap 4.6.2 UMD buildCSS-only fallback — manually toggles
showclass anddisplaystyle when no Bootstrap JS is available
AJAX Comments¶
The comments script (ajaxcomments.ts) provides client-side comment posting
without full page reloads. It is built as a standalone IIFE and loaded via a
<script> tag on post detail pages when comments are enabled.
How It Works¶
On
DOMContentLoaded, the script finds allform.js-comments-formelements and wraps them in position-tracking containers.Form submissions are intercepted via a delegated
submitevent listener ondocument.The form data is sent via
fetchwithX-Requested-With: XMLHttpRequestto the form’sdata-ajax-actionoractionURL.On success, the new comment HTML is inserted into the DOM and the page scrolls to it.
On error, field-level error messages are displayed using Bootstrap
has-error/errorclasses.
Features¶
Preview: Clicking a button with
name="preview"shows a preview of the comment without posting it.Threaded replies: Clicking a
.comment-reply-linkmoves the form under the target comment and sets theparentfield.Cancel reply: Clicking
.comment-cancel-reply-linkresets the form back to its original position.Moderation message: If the comment requires moderation, a temporary message is shown for 4 seconds.
Error event: On network failure, a
cast:comments:errorcustom event is dispatched onwindow. If no listener callspreventDefault(), a fallbackalert()is shown.
Template Integration¶
Comments are loaded conditionally in post templates:
{% if comments_are_enabled %}
<link rel="stylesheet" href="{% static 'fluent_comments/css/ajaxcomments.css' %}" />
<script defer src="{% static 'fluent_comments/js/ajaxcomments.js' %}"></script>
{% endif %}
The script works on HTMX-navigated pages because it re-wraps forms on each submit, reply, and cancel interaction (not via HTMX lifecycle events). This means forms inserted after the initial page load are handled correctly.
Vite Build Setup¶
JavaScript assets are built with Vite. There are two separate build
configurations in the javascript/ directory.
Main Build (Gallery + Podlove Player)¶
Configuration file: javascript/vite.config.ts
Entry points:
src/gallery/image-gallery-bs4.ts → main-<hash>.js
src/audio/podlove-player.ts → podlovePlayer-<hash>.js
Output: javascript/dist/
Format: ES modules (default Vite/Rollup output)
Target: ES2015
npm run build writes to javascript/dist/. The just js-build-vite
command handles the full pipeline: it runs the Vite build, moves the manifest
file from the .vite/ subdirectory, and copies the output to
src/cast/static/cast/vite/ where Django’s static files system can serve it.
Templates include these assets using django-vite template tags:
{% load django_vite %}
{% vite_hmr_client app="cast" %}
{% vite_asset 'src/gallery/image-gallery-bs4.ts' app="cast" %}
{% vite_asset 'src/audio/podlove-player.ts' app="cast" %}
In development (DJANGO_VITE["cast"]["dev_mode"] = True), the template tags
point to the Vite dev server on port 5173. In production, they resolve asset
paths from the manifest file.
Build Commands¶
Run from the javascript/ directory:
npm run build # Main build (gallery + player)
npm run build:comments # Comments build
npm run build:all # Both builds
npm run dev # Vite dev server for HMR
Or from the project root using the justfile:
just js-build-vite # Main build + copy to static
just js-build-comments # Comments build
just js-build-all # Both builds
Note
just js-build-vite handles the full pipeline: builds with Vite, moves
the manifest file, and copies the output to
src/cast/static/cast/vite/.
Testing¶
JavaScript tests use Vitest with a jsdom environment:
cd javascript
npm test # Run tests once
npm run test:watch # Watch mode
npm run coverage # With coverage report
HTMX Integration¶
Django Cast uses HTMX for two main features: gallery modal navigation and pagination with view transitions.
CSRF Token Handling¶
The bootstrap4 base template sets a global HTMX CSRF header on the
<body> tag:
<body data-hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
This ensures all HTMX requests automatically include the CSRF token without additional configuration.
Note
The plain theme does not set this header because its HTMX interactions
(gallery modals, pagination) only use GET requests. If you add HTMX-driven
POST requests to a plain-theme template, add data-hx-headers to your
base template or individual elements.
Pagination with View Transitions¶
Pagination links use HTMX to swap page content without a full page reload.
The data-hx-swap attribute includes transition:true to enable CSS
view transitions:
<a data-hx-get="?page=2"
data-hx-target="#paging-area"
data-hx-swap="innerHTML show:window:top transition:true"
data-hx-sync="#paging-area:replace"
data-hx-push-url="true"
href="?page=2">
Key attributes:
data-hx-target="#paging-area"Swaps the content of the
#paging-areacontainer, which wraps the post list and pagination controls.data-hx-swap="innerHTML show:window:top transition:true"Replaces the inner HTML, scrolls to the top of the window, and uses CSS view transitions for a smooth visual effect.
data-hx-sync="#paging-area:replace"If a new pagination request arrives while one is in-flight, the old request is cancelled.
data-hx-push-url="true"Updates the browser URL so the Back button works as expected.
A small companion script (paging-view-transition-fix.js) listens for
htmx:beforeTransition events targeting the #paging-area element and
scrolls to the top of the page before the view transition snapshot is taken,
ensuring a clean transition. Other HTMX transitions are not affected.
Comments Build¶
Configuration file:
javascript/vite.comments.config.tsThe comments build outputs directly into the static files directory. It uses IIFE format (not ES modules) so it works as a simple
<script>tag without module loading. Minification is disabled so the output remains readable for debugging.