Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Gluetun integration with API key set, and incorrect endpoints.** The Gluetun client authenticated using `Authorization: Bearer <key>`, but Gluetun expects it as `X-API-Key`, so it would return a 401 error when an API key is required. Additionally, referring to the older [Control Server Docs](https://github.com/qdm12/gluetun-wiki/blob/7025b1c0e4427d4477e47d4bbd2ef3f1b5c4da71/setup/advanced/control-server.md#openvpn-and-wireguard), WireGuard doesn't get its own endpoint, so the `/v1/wireguard/status` and `/v1/wireguard/portforwarded` calls were incorrect. The client now sends `X-API-Key` and uses the unified `/v1/vpn/status` and `/v1/portforward` endpoints, with the legacy `/v1/openvpn/` paths as a fallback. With that, the protocol selector in the settings becomes redundant and was removed.
- **Radarr/Sonarr sidebar entry unusable with 4+ instances** ([#44](https://github.com/Shoshuo/Prismarr/issues/44)). The dropdown toggle shown for 4 or more instances of the same service used the native Bootstrap `data-bs-toggle`, but nothing in the app initializes Bootstrap's dropdown JS anymore — every other dropdown had already moved to a shared click-delegate. The triangle rendered but never opened, making every instance beyond the first three unreachable. The toggle now uses the same delegate as the rest of the app, and its label shows the active instance's name instead of staying stuck on the generic service name.
- **A torrent client page could show another client's downloads mid-session.** Turbo Drive re-executes the incoming page's `<script>` with a fresh closure while the outgoing page's — and its live 3-second poll timer — kept running; since the qBittorrent, Deluge and Transmission pages render the same element IDs, the orphaned poller kept overwriting the page you'd navigated to with its own client's torrents. The poll timer now lives on a single shared handle so the incoming page can kill the outgoing one's, and these three pages always do a full reload in and out instead of a Turbo visit.
- **Dead space in the External Services settings cards.** The service-card grid on the admin settings "External services" tab allocated every CSS-grid row track to its tallest card, so a short card left an empty gap beside/below a tall neighbour and an odd last card left the whole second column empty. The grid is now CSS multi-column packing (`columns: 2 320px`), which flows cards into balanced stacks with no row tracks — cards pack tightly by height. Responsive behaviour is unchanged (drops to one column under ~660px); the multi-instance Radarr/Sonarr cards still span both columns via `column-span: all`.

### Performance
- **Service-health pings are now cached across requests.** `HealthService::statusFor()`'s 10 s memo lived in a per-object array, which the classic (non-worker) FrankenPHP throws away with each request — so every topbar/dashboard health poll, from every open tab, re-pinged all configured services with sequential blocking calls. The verdicts are now shared through `cache.app` (same 10 s TTL): the whole install performs at most one probe sweep per window, and `invalidate()` (admin "Test connection" / settings save) rotates a generation token so stale verdicts can't outlive a reconfiguration.
Expand All @@ -34,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Download queue widget starts collapsed on Radarr/Sonarr.** The films/series queue card now loads collapsed by default instead of open; the existing toggle (click the header) still expands it, and the 2s auto-refresh no longer forces it back open.
- **Plex items open the app-global quick-look modal.** Clicking a title on the Plex Activity page or the dashboard's Plex widget now opens the same rich detail modal used everywhere else (poster, synopsis, ratings, watchlist button, Manage/Discover deep-links) instead of the bespoke Tautulli metadata pop-up. The click resolves the item's TMDb id server-side (`/tautulli/api/quicklook/{ratingKey}`, one `get_metadata` call — episodes/seasons resolve to their show, with a grandparent hop for older Tautulli payloads that lack show-level guids); only the numeric TMDb id reaches the browser, keeping raw Plex guids inside the existing allow-list sanitization. Items with no TMDb match (music, home videos) fall back to the legacy Plex metadata modal, so every click keeps working.
- **Dashboard service-health chips show latency.** `HealthService::statusFor()` returns a status word plus a round-trip reading (cached 10 s like the existing bool path, which now delegates to it); the dashboard chips render five states — up / slow / very_slow / down / degraded — with a coloured dot, so a reachable-but-slow service is visibly distinct from a healthy one. `isHealthy()` keeps its old contract for every existing caller.
- **Library filters collapse into a drawer on mobile.** On the Films, Series and Discover pages the filter controls used to sit in an always-visible toolbar that ate a large slice of the screen above the poster grid on phones. Below 992px the Films/Series filter card is now a Bootstrap offcanvas drawer opened by a `Filtres (n)` button (n = active-filter count) — search, status, and the dropdowns move into the drawer, and an **Apply** button runs a single navigation so changing filters no longer reloads the page (and closes the drawer) on every tap; at ≥992px the toolbar renders inline exactly as before (same live auto-submit, unchanged desktop). Discover's existing Filters panel becomes a slide-in drawer at all widths, reusing its Apply/Reset/count logic. Same responsive offcanvas markup drives both states, so there is one source of truth per page.
## [1.1.1] - 2026-06-10

### Fixed
Expand Down
28 changes: 17 additions & 11 deletions symfony/templates/admin/settings.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -123,21 +123,27 @@
.service-card + .service-card { margin-top: 1rem; }
.service-card:focus-within { border-color: rgba(var(--tblr-primary-rgb),.55); }

/* Two-column layout for the service cards so the list doesn't grow into a
very tall single column. Collapses to one column under ~700px; the grid
gap replaces the stacked margin, and align-items:start keeps a short card
from stretching to match a tall neighbour. */
/* Two-column layout for the service cards. CSS multi-column (not grid) so
cards pack tightly by height: grid allocates every row track to its
tallest card, leaving dead space beside/below a short card and an empty
column when the card count is odd. Columns
flow cards into balanced stacks with no row tracks, so no gaps.
`columns: 2 320px` = at most 2 columns, each ≥320px → drops to one column
under ~660px, matching the old auto-fit responsive behaviour. */
.service-grid-2col {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
align-items: start;
columns: 2 320px;
column-gap: 1rem;
}
.service-grid-2col .service-card {
margin-top: 0;
margin-bottom: 1rem;
/* Keep a card whole — never split one across a column break. */
break-inside: avoid;
}
.service-grid-2col .service-card { margin-top: 0; }
/* Multi-instance cards (Radarr/Sonarr) hold a 6-column table that can't fit
a half-width column, so they span the full row; only the simple URL+key
a half-width column, so they span both columns; only the simple URL+key
cards share the two columns. */
.service-grid-2col .service-card--wide { grid-column: 1 / -1; }
.service-grid-2col .service-card--wide { column-span: all; }
/* The 6-column instance table can't shrink to phone width, so let it scroll
horizontally inside the card instead of overflowing the layout. */
.instance-list { overflow-x: auto; }
Expand Down
23 changes: 23 additions & 0 deletions symfony/templates/base.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -251,11 +251,34 @@
}
});

// Offcanvas cleanup shared by the two Turbo hooks below. Same problem
// class as modals, one twist: the mobile filter drawers navigate while
// still open (Apply submits the form), so without scrubbing, Turbo
// snapshots the page WITH .offcanvas.show + a live backdrop — and a
// Back restore resurrects a dimmed, scroll-locked page whose backdrop
// has no bound Offcanvas instance.
function scrubOffcanvas(root) {
root.querySelectorAll('.offcanvas-backdrop').forEach(function(b) { b.remove(); });
root.querySelectorAll('.offcanvas.show, .offcanvas-lg.show').forEach(function(o) {
o.classList.remove('show', 'showing', 'hiding');
o.style.removeProperty('visibility');
});
}

// Sanitize the page before Turbo snapshots it, so a Back restore
// renders the drawer closed instead of stuck open.
document.addEventListener('turbo:before-cache', function() {
scrubOffcanvas(document);
document.body.style.removeProperty('overflow');
document.body.style.removeProperty('padding-right');
});

// Full cleanup on each Turbo navigation
document.addEventListener('turbo:before-render', function() {
document.querySelectorAll('.modal-backdrop').forEach(function(b) { b.remove(); });
document.querySelectorAll('.modal.show').forEach(function(m) { m.classList.remove('show'); m.style.display = 'none'; });
document.querySelectorAll('.tooltip, .popover').forEach(function(t) { t.remove(); });
scrubOffcanvas(document);
document.body.classList.remove('modal-open');
document.body.style.removeProperty('overflow');
document.body.style.removeProperty('padding-right');
Expand Down
66 changes: 41 additions & 25 deletions symfony/templates/decouverte/index.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -487,38 +487,44 @@
</div>
</div>

{# Filter bar (hidden by default) #}
<div id="tmdb-filters" class="card mb-3" style="display:none;">
<div class="card-body py-2">
<div class="row g-2 align-items-end">
<div class="col-auto">
{# Filter drawer (offcanvas at all widths) #}
<div id="tmdb-filters" class="offcanvas offcanvas-start" tabindex="-1" aria-labelledby="tmdb-filters-label">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="tmdb-filters-label">{{ 'decouverte.filters.drawer_title'|trans }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" data-bs-target="#tmdb-filters" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<div class="d-flex flex-column gap-3">
<div>
<label class="form-label small mb-1">{{ 'decouverte.filters.type'|trans }}</label>
<div class="btn-group" role="group">
<div class="btn-group d-flex" role="group">
<input type="radio" class="btn-check" name="filter-type" id="filter-type-movie" value="movie" checked>
<label class="btn btn-outline-primary btn-sm" for="filter-type-movie">{{ 'decouverte.filters.movies'|trans }}</label>
<input type="radio" class="btn-check" name="filter-type" id="filter-type-tv" value="tv">
<label class="btn btn-outline-primary btn-sm" for="filter-type-tv">{{ 'decouverte.filters.series'|trans }}</label>
</div>
</div>
<div class="col-md-3">
<div>
<label class="form-label small mb-1">{{ 'decouverte.filters.genre'|trans }}</label>
<select class="form-select form-select-sm" id="filter-genre">
<option value="">{{ 'decouverte.filters.all'|trans }}</option>
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-1">{{ 'decouverte.filters.year_from'|trans }}</label>
<input type="number" class="form-control form-control-sm" id="filter-year-min" placeholder="1970" min="1900" max="2030" style="width:90px;">
</div>
<div class="col-auto">
<label class="form-label small mb-1">{{ 'decouverte.filters.year_to'|trans }}</label>
<input type="number" class="form-control form-control-sm" id="filter-year-max" placeholder="2030" min="1900" max="2030" style="width:90px;">
<div class="d-flex gap-2">
<div class="flex-fill">
<label class="form-label small mb-1">{{ 'decouverte.filters.year_from'|trans }}</label>
<input type="number" class="form-control form-control-sm" id="filter-year-min" placeholder="1970" min="1900" max="2030">
</div>
<div class="flex-fill">
<label class="form-label small mb-1">{{ 'decouverte.filters.year_to'|trans }}</label>
<input type="number" class="form-control form-control-sm" id="filter-year-max" placeholder="2030" min="1900" max="2030">
</div>
</div>
<div class="col-auto" style="min-width:140px;">
<div>
<label class="form-label small mb-1">{{ 'decouverte.filters.vote_min'|trans({value: '__VAL__'})|replace({'__VAL__': '<span id="filter-vote-val" class="fw-medium">0</span>'})|raw }}</label>
<input type="range" class="form-range" id="filter-vote-min" min="0" max="10" step="0.5" value="0">
</div>
<div class="col-md-2">
<div>
<label class="form-label small mb-1">{{ 'decouverte.filters.sort'|trans }}</label>
<select class="form-select form-select-sm" id="filter-sort">
<option value="popularity.desc">{{ 'decouverte.filters.sort_options.popularity'|trans }}</option>
Expand All @@ -528,11 +534,9 @@
<option value="revenue.desc">{{ 'decouverte.filters.sort_options.revenue'|trans }}</option>
</select>
</div>
<div class="col-auto">
<button type="button" class="btn btn-primary btn-sm" id="filter-apply">{{ 'decouverte.filters.apply'|trans }}</button>
</div>
<div class="col-auto">
<div class="d-flex gap-2 justify-content-end mt-2 pt-2 border-top">
<button type="button" class="btn btn-ghost-secondary btn-sm" id="filter-reset">{{ 'decouverte.filters.reset'|trans }}</button>
<button type="button" class="btn btn-primary btn-sm" id="filter-apply">{{ 'decouverte.filters.apply'|trans }}</button>
</div>
</div>
</div>
Expand Down Expand Up @@ -1919,11 +1923,19 @@
var filterReset = document.getElementById('filter-reset');
var genresCache = { movie: null, tv: null };

// This build ships Bootstrap under window.tabler (no window.bootstrap global),
// so resolve the Offcanvas class defensively rather than assuming `bootstrap`.
function offcanvasFor(el) {
var C = (window.bootstrap && window.bootstrap.Offcanvas)
|| (window.tabler && (window.tabler.Offcanvas
|| (window.tabler.bootstrap && window.tabler.bootstrap.Offcanvas)));
return C ? C.getOrCreateInstance(el) : null;
}

filtersToggle.addEventListener('click', function() {
var show = filtersPanel.style.display === 'none';
filtersPanel.style.display = show ? '' : 'none';
filtersToggle.classList.toggle('active', show);
if (show && !genresCache.movie) loadGenres('movie');
var oc = offcanvasFor(filtersPanel);
if (oc) oc.toggle();
if (!genresCache.movie) loadGenres('movie');
});

function currentType() {
Expand Down Expand Up @@ -2017,7 +2029,11 @@
searchGrid.innerHTML = '<div class="alert alert-danger">' + {{ 'decouverte.search.search_error'|trans|json_encode|raw }} + '</div>';
});
}
filterApply.addEventListener('click', applyFilters);
filterApply.addEventListener('click', function() {
applyFilters();
var oc = offcanvasFor(filtersPanel);
if (oc) oc.hide();
});
filterReset.addEventListener('click', function() {
filterGenre.value = '';
filterYearMin.value = '';
Expand Down
Loading