feat(web): one row primitive and a mobile shell

Every list now uses the row primitive with a fixed action column, so
actions never wrap. Rows use icons, headers use words. Status slots,
skeletons and a fixed hero grid stop the page jumping. Below 40rem a
bottom tab bar holds the four destinations. Tooltip-only meaning moves
to tap-to-explain.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-10-24 17:28:22 +01:00
co-authored by Claude Opus 5.5
parent ce6d38d044
commit 52b11fda93
4 changed files with 1689 additions and 1840 deletions
+297 -332
View File
@@ -69,72 +69,97 @@
/settings, its lamps still driven by the health poll, and the master
lamp stays on the rail so the one-glance read survives the move. Where
earlier notes here say "returns to the board", read "the library".
REVISION 2 (mobile first): the phone is the base layout. One sticky
one-line rail, and below 40rem a bottom tab bar carrying the four
destinations — library, search, queues, settings — which move into the
rail from 40rem. Every list is one row primitive (lead | body | actions)
whose action column never wraps; icons in rows, words in headers and
panels; every slot reserves its final size before data arrives. Detail
pages open with a back control and the title, then one action bar.
-->
<header class="rail">
<header class="rail" id="rail">
<div class="rail-inner">
<div class="rail-id">
<span class="lamp" id="master-lamp" data-state="probing" aria-hidden="true"></span>
<a class="wordmark" id="wordmark" href="/">arr</a>
</div>
<button
type="button"
class="control control-icon"
id="nav-queues"
aria-label="queues needing attention"
title="queues needing attention"
hidden
>
<span class="nav-count readout" id="queues-count" hidden></span>
</button>
<div class="rail-search">
<nav class="rail-nav" aria-label="destinations">
<a class="nav-link" href="/" data-dest="library">library</a>
<a class="nav-link" href="/queues" data-dest="queues"
>queues<span class="nav-count readout" data-role="queues-count" hidden></span
></a>
<a class="nav-link" href="/settings" data-dest="settings">settings</a>
</nav>
<div class="rail-search" id="rail-search">
<span class="rail-search-glyph" data-glyph="search" aria-hidden="true"></span>
<input
id="search"
class="search-input readout"
type="text"
spellcheck="false"
autocomplete="off"
placeholder="search · title / tmdb id / tt… / magnet"
enterkeyhint="search"
placeholder="title, tmdb id, tt…, magnet"
aria-label="unified search — titles, TMDB or IMDb ids, magnets and .torrent links"
/>
<span class="search-hint readout" id="search-hint" aria-hidden="true">/</span>
</div>
<div class="rail-meta">
<span class="readout dim" id="version" data-testid="version">v —</span>
<button
type="button"
class="control control-icon"
id="nav-settings"
aria-label="settings"
title="settings"
aria-pressed="false"
class="control control-icon rail-search-close"
id="search-close"
aria-label="close search"
data-glyph="close"
></button>
</div>
<button
type="button"
class="control control-icon rail-search-open"
id="search-open"
aria-label="search"
data-glyph="search"
></button>
<span class="readout dim rail-version" id="version" data-testid="version">v —</span>
</div>
</header>
<main class="deck" id="deck" hidden>
<p class="deck-status readout" id="deck-status" role="status" hidden></p>
<nav class="tabbar" id="tabbar" aria-label="destinations">
<a class="tab" href="/" data-dest="library"
><span data-glyph="library" aria-hidden="true"></span><span class="tab-label">library</span></a
>
<a class="tab" href="/search" data-dest="search"
><span data-glyph="search" aria-hidden="true"></span><span class="tab-label">search</span></a
>
<a class="tab" href="/queues" data-dest="queues"
><span data-glyph="warning" aria-hidden="true"></span><span class="tab-label">queues</span
><span class="tab-badge readout" data-role="queues-count" hidden></span
></a>
<a class="tab" href="/settings" data-dest="settings"
><span data-glyph="cog" aria-hidden="true"></span><span class="tab-label">settings</span></a
>
</nav>
<div class="deck-filter" id="deck-filter" role="toolbar" aria-label="show only one kind" hidden>
<button type="button" class="control control-filter" data-kind="all" aria-pressed="true">
<main class="deck" id="deck" hidden aria-label="search results">
<header class="page-bar">
<h2 class="page-title">search</h2>
<p class="head-status readout" id="deck-status" role="status" data-empty="true"></p>
</header>
<div class="segmented deck-filter" id="deck-filter" role="toolbar" aria-label="show only one kind" hidden>
<button type="button" class="seg" data-kind="all" aria-pressed="true">
all<span class="filter-count" data-role="count"></span>
</button>
<button type="button" class="control control-filter" data-kind="movie" aria-pressed="false">
<button type="button" class="seg" data-kind="movie" aria-pressed="false">
movies<span class="filter-count" data-role="count"></span>
</button>
<button
type="button"
class="control control-filter"
data-kind="series"
aria-pressed="false"
>
<button type="button" class="seg" data-kind="series" aria-pressed="false">
series<span class="filter-count" data-role="count"></span>
</button>
</div>
<section class="deck-group" id="group-library" hidden aria-labelledby="label-library">
<header class="deck-head">
<h2 class="deck-label" id="label-library">in library</h2>
<h3 class="deck-label" id="label-library">in library</h3>
<span class="deck-count readout" id="count-library"></span>
</header>
<ul class="deck-rows" id="rows-library"></ul>
@@ -142,7 +167,7 @@
<section class="deck-group" id="group-tmdb" hidden aria-labelledby="label-tmdb">
<header class="deck-head">
<h2 class="deck-label" id="label-tmdb">on tmdb</h2>
<h3 class="deck-label" id="label-tmdb">on tmdb</h3>
<span class="deck-count readout" id="count-tmdb"></span>
</header>
<ul class="deck-rows" id="rows-tmdb"></ul>
@@ -150,22 +175,22 @@
<section class="deck-group" id="group-manual" hidden aria-labelledby="label-manual">
<header class="deck-head">
<h2 class="deck-label" id="label-manual">manual grab</h2>
<h3 class="deck-label" id="label-manual">manual grab</h3>
</header>
<article class="module intake" id="manual-intake"></article>
</section>
</main>
<main class="deck" id="library" hidden aria-label="library">
<header class="deck-head library-bar">
<h2 class="deck-label">library</h2>
<header class="page-bar">
<h2 class="page-title">library</h2>
<span class="deck-count readout" id="library-summary"></span>
<div class="view-toggle">
<button type="button" class="view-btn" id="library-view-grid" aria-pressed="true">grid</button>
<button type="button" class="view-btn" id="library-view-list" aria-pressed="false">list</button>
</div>
<p class="head-status readout" id="library-status" role="status" data-empty="true"></p>
<fieldset class="segmented" aria-label="layout">
<button type="button" class="seg seg-icon" id="library-view-grid" aria-pressed="true" aria-label="poster grid" data-glyph="grid"></button>
<button type="button" class="seg seg-icon" id="library-view-list" aria-pressed="false" aria-label="list" data-glyph="list"></button>
</fieldset>
</header>
<p class="deck-status readout" id="library-status" role="status" hidden></p>
<div id="library-libraries"></div>
@@ -187,22 +212,24 @@
</main>
<main class="deck" id="queues" hidden aria-label="attention queues">
<header class="deck-head library-bar">
<h2 class="deck-label">attention</h2>
<header class="page-bar">
<h2 class="page-title">queues</h2>
<span class="deck-count readout" id="queues-summary"></span>
<p class="head-status readout" id="queues-status" role="status" data-empty="true"></p>
</header>
<p class="deck-status readout" id="queues-status" role="status" hidden></p>
<section class="deck-group" id="queue-no-pt" hidden aria-labelledby="label-no-pt">
<header class="deck-head">
<h3 class="deck-label" id="label-no-pt">no pt source</h3>
<span class="deck-count readout" id="count-no-pt"></span>
</header>
<p class="queue-note readout dim">
kids titles with no qualifying pt release — parked here, sometimes for months, by
design. allow english writes a movie's override and its search proceeds; series wait
for the detail view.
</p>
<div class="group-note" data-explain-group>
<p>Kids titles with no Portuguese release yet.</p>
<p class="group-note-more" hidden>
They can sit here for months, by design. Allow English writes the movie's override
and its search goes ahead. A series waits for its own page.
</p>
</div>
<ul class="deck-rows" id="rows-no-pt"></ul>
</section>
@@ -211,10 +238,12 @@
<h3 class="deck-label" id="label-decision">needs decision</h3>
<span class="deck-count readout" id="count-decision"></span>
</header>
<p class="queue-note readout dim">
hard-failed twice on different releases — open the movie's page and decide by hand;
series entries are display-only for now.
</p>
<div class="group-note" data-explain-group>
<p>Titles where two different releases failed.</p>
<p class="group-note-more" hidden>
Open the movie and pick a release by hand. Series entries are display-only for now.
</p>
</div>
<ul class="deck-rows" id="rows-decision"></ul>
</section>
@@ -223,11 +252,13 @@
<h3 class="deck-label" id="label-subtitles">missing subtitles</h3>
<span class="deck-count readout" id="count-subtitles"></span>
</header>
<p class="queue-note readout dim">
a wanted language with no subtitle, and why — no candidate, a translation or provider
failure, the daily budget, or a sync `alass` flagged. open the title to search,
translate or retry by hand.
</p>
<div class="group-note" data-explain-group>
<p>A wanted subtitle language is missing.</p>
<p class="group-note-more" hidden>
Each chip says why: no candidate, a translation or provider failure, the daily budget,
or a timing sync that looked wrong. Open the title to search, translate or retry.
</p>
</div>
<ul class="deck-rows" id="rows-subtitles"></ul>
</section>
</main>
@@ -248,77 +279,67 @@
deck section in view.
-->
<main class="deck" id="movie" hidden aria-label="movie detail">
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
<section class="module movie-hero" id="movie-hero" aria-label="title metadata">
<div class="movie-body">
<img class="movie-poster" id="movie-poster" alt="" hidden />
<div class="movie-info">
<div class="movie-idline">
<h2 class="movie-title" id="movie-title">—</h2>
<span class="movie-year readout dim" id="movie-year"></span>
</div>
<span class="row-chips movie-chips" id="movie-chips"></span>
<p class="movie-rating readout" id="movie-rating" hidden></p>
<p class="movie-meta readout dim" id="movie-meta" hidden></p>
<p class="movie-tagline" id="movie-tagline" hidden></p>
<p class="movie-overview" id="movie-overview" hidden></p>
<div class="movie-actions" id="movie-actions"></div>
<main class="deck detail" id="movie" hidden aria-label="movie detail">
<section class="hero" id="movie-hero" aria-label="title metadata">
<header class="page-head">
<button type="button" class="control control-icon" id="movie-back" aria-label="back" data-glyph="back"></button>
<h2 class="page-title" id="movie-title" tabindex="-1">&nbsp;</h2>
<span class="page-year readout" id="movie-year"></span>
</header>
<div class="hero-body">
<div class="hero-poster"><img class="movie-poster" id="movie-poster" alt="" /></div>
<div class="hero-info">
<span class="hero-chips" id="movie-chips"></span>
<p class="hero-rating readout" id="movie-rating"></p>
<p class="hero-meta readout" id="movie-meta"></p>
<p class="hero-tagline" id="movie-tagline"></p>
<p class="hero-overview" id="movie-overview"></p>
</div>
</div>
<p class="r-note hero-note readout" id="movie-note" role="status" hidden></p>
</section>
<section class="deck-group" id="movie-controls" aria-label="library controls">
<header class="deck-head">
<h3 class="deck-label">library</h3>
</header>
<div class="movie-controls">
<button type="button" class="control control-quiet" id="movie-wanted" aria-pressed="false">
wanted
</button>
<button
type="button"
class="control control-quiet"
id="movie-blocked"
aria-pressed="false"
>
no targeted search
</button>
<label class="movie-root-field">
<span class="field-label readout dim" id="movie-root-label">root</span>
<select class="form-input movie-root" id="movie-root" aria-labelledby="movie-root-label">
</select>
</label>
<span class="movie-controls-space"></span>
<button type="button" class="control" id="movie-sweep">search indexers</button>
<button
type="button"
class="control control-quiet control-icon"
data-kind="delete"
id="movie-remove"
aria-expanded="false"
aria-controls="remove-panel"
aria-label="remove from the library"
>
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<path d="M2.8 4.3h10.4" />
<path d="M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2" />
<path d="M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5" />
<path d="M6.6 7v4.2" />
<path d="M9.4 7v4.2" />
</svg>
</button>
</div>
<p class="control-note readout dim" id="movie-blocked-note" hidden>
targeted search is off for this title. RSS still matches it, so a release can still arrive.
<div class="actionbar">
<button type="button" class="control" id="movie-sweep">search indexers</button>
<span class="actionbar-links" id="movie-actions"></span>
<button
type="button"
class="control control-icon"
id="movie-more"
aria-label="library settings for this title"
aria-expanded="false"
aria-controls="movie-more-panel"
data-glyph="more"
></button>
</div>
<div class="more-panel" id="movie-more-panel" hidden>
<div class="more-links" id="movie-links"></div>
<div class="more-toggles" id="movie-toggles"></div>
<p class="control-note readout" id="movie-blocked-note" hidden>
Targeted search is off. RSS still matches this title, so a release can still arrive.
</p>
<label class="field more-field">
<span class="field-label" id="movie-root-label">root</span>
<select class="form-input" id="movie-root" aria-labelledby="movie-root-label"></select>
</label>
<button
type="button"
class="control control-quiet"
data-kind="delete"
id="movie-remove"
aria-expanded="false"
aria-controls="remove-panel"
>
remove from library
</button>
<div class="remove-panel" id="remove-panel" hidden></div>
</section>
</div>
<p class="deck-status readout" id="movie-status" role="status" data-empty="true"></p>
<section class="deck-group" id="movie-files" hidden aria-labelledby="label-disk">
<section class="deck-group" id="movie-files" aria-labelledby="label-disk">
<header class="deck-head">
<h3 class="deck-label" id="label-disk">on disk</h3>
<h3 class="section-title" id="label-disk">on disk</h3>
<span class="deck-count readout" id="count-disk"></span>
</header>
<ul class="deck-rows" id="rows-disk"></ul>
@@ -326,185 +347,122 @@
<section class="deck-group" id="movie-releases" aria-labelledby="label-releases">
<header class="deck-head">
<h3 class="deck-label" id="label-releases">releases</h3>
<h3 class="section-title" id="label-releases">releases</h3>
</header>
<p class="deck-status readout" id="releases-status" role="status" hidden></p>
<section class="deck-group" id="bucket-eligible" hidden aria-labelledby="label-eligible">
<header class="deck-head">
<h3 class="deck-label" id="label-eligible">eligible</h3>
<span class="deck-count readout" id="count-eligible"></span>
</header>
<div class="colhead" aria-hidden="true">
<span class="cw cw-score">score</span>
<span class="cw cw-res">res</span>
<span class="cw cw-src">source</span>
<span class="cw cw-hdr">hdr</span>
<span class="cw cw-aud">audio</span>
<span class="cw cw-size">size</span>
<span class="cw cw-seed">seed</span>
</div>
<ul class="deck-rows" id="rows-eligible"></ul>
</section>
<section class="deck-group" id="bucket-waived" hidden aria-labelledby="label-waived">
<header class="deck-head bucket-head">
<h3 class="deck-label" id="label-waived">below policy</h3>
<button
type="button"
class="bucket-toggle readout"
id="toggle-waived"
aria-expanded="false"
aria-controls="wrap-waived"
></button>
</header>
<div id="wrap-waived" hidden>
<div class="colhead" aria-hidden="true">
<span class="cw cw-score">score</span>
<span class="cw cw-res">res</span>
<span class="cw cw-src">source</span>
<span class="cw cw-hdr">hdr</span>
<span class="cw cw-aud">audio</span>
<span class="cw cw-size">size</span>
<span class="cw cw-seed">seed</span>
</div>
<ul class="deck-rows" id="rows-waived"></ul>
</div>
</section>
<section class="deck-group" id="bucket-rejected" hidden aria-labelledby="label-rejected">
<header class="deck-head bucket-head">
<h3 class="deck-label" id="label-rejected">rejected</h3>
<button
type="button"
class="bucket-toggle readout"
id="toggle-rejected"
aria-expanded="false"
aria-controls="wrap-rejected"
></button>
</header>
<div id="wrap-rejected" hidden>
<div class="colhead" aria-hidden="true">
<span class="cw cw-score">score</span>
<span class="cw cw-res">res</span>
<span class="cw cw-src">source</span>
<span class="cw cw-hdr">hdr</span>
<span class="cw cw-aud">audio</span>
<span class="cw cw-size">size</span>
<span class="cw cw-seed">seed</span>
</div>
<ul class="deck-rows" id="rows-rejected"></ul>
</div>
</section>
<p class="deck-status readout" id="releases-status" role="status" data-empty="true"></p>
<div class="buckets" id="movie-buckets"></div>
</section>
</main>
<main class="deck" id="settings" hidden aria-label="settings">
<header class="deck-head library-bar">
<h2 class="deck-label">settings</h2>
<header class="page-bar">
<h2 class="page-title">settings</h2>
<span class="deck-count readout" id="settings-summary"></span>
<p class="head-status readout" id="settings-status" role="status" data-empty="true"></p>
</header>
<p class="deck-status readout" id="settings-status" role="status" hidden></p>
<section class="deck-group" id="group-chain" aria-labelledby="label-chain">
<header class="deck-head">
<h3 class="deck-label" id="label-chain">signal chain</h3>
<h3 class="section-title" id="label-chain">signal chain</h3>
</header>
<section class="chain" aria-label="signal chain">
<article class="module area-tmdb" data-check="tmdb">
<header class="module-head">
<span class="lamp" data-state="probing" aria-hidden="true"></span>
<h2 class="module-name">tmdb</h2>
<span class="module-status readout" data-role="status">probing</span>
</header>
<p class="module-role">metadata in</p>
<p class="module-detail readout" data-role="detail">titles, languages, release dates</p>
</article>
<section class="chain-wrap" aria-label="signal chain">
<div class="chain">
<article class="module area-tmdb" data-check="tmdb">
<header class="module-head">
<span class="lamp" data-state="probing" aria-hidden="true"></span>
<h4 class="module-name">tmdb</h4>
<span class="module-status readout" data-role="status">probing</span>
</header>
<p class="module-role">metadata in</p>
<p class="module-detail readout" data-role="detail">titles, languages, release dates</p>
</article>
<div class="bus area-t1" aria-hidden="true">
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="tmdb" aria-hidden="true">
<line x1="0" y1="4" x2="32" y2="4" />
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
</svg>
<div class="bus area-t1" aria-hidden="true">
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="tmdb" aria-hidden="true">
<line x1="0" y1="4" x2="32" y2="4" />
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
</svg>
</div>
<article class="module area-prowlarr" data-check="prowlarr">
<header class="module-head">
<span class="lamp" data-state="probing" aria-hidden="true"></span>
<h4 class="module-name">prowlarr</h4>
<span class="module-status readout" data-role="status">probing</span>
</header>
<p class="module-role">indexers in</p>
<p class="module-detail readout" data-role="detail">torznab search and rss</p>
</article>
<div class="bus area-t2" aria-hidden="true">
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="prowlarr" aria-hidden="true">
<line x1="0" y1="4" x2="32" y2="4" />
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
</svg>
</div>
<article class="module module-master area-master" data-check="master">
<header class="module-head">
<span class="lamp lamp-big" data-state="probing" aria-hidden="true"></span>
<h4 class="module-name">arr</h4>
<span class="module-status readout" data-role="status">probing</span>
</header>
<p class="module-role">reconcile core</p>
<p class="module-detail readout" data-role="detail">decide · find · fetch · import</p>
</article>
<div class="bus area-out" aria-hidden="true">
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="master" aria-hidden="true">
<line x1="0" y1="4" x2="32" y2="4" />
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
</svg>
</div>
<article class="module area-qbittorrent" data-check="qbit">
<header class="module-head">
<span class="lamp" data-state="probing" aria-hidden="true"></span>
<h4 class="module-name">qbittorrent</h4>
<span class="module-status readout" data-role="status">probing</span>
</header>
<p class="module-role">torrents out</p>
<p class="module-detail readout" data-role="detail">webui · grab and seed</p>
</article>
</div>
<article class="module area-prowlarr" data-check="prowlarr">
<header class="module-head">
<span class="lamp" data-state="probing" aria-hidden="true"></span>
<h2 class="module-name">prowlarr</h2>
<span class="module-status readout" data-role="status">probing</span>
</header>
<p class="module-role">indexers in</p>
<p class="module-detail readout" data-role="detail">torznab search and rss</p>
</article>
<div class="bus area-t2" aria-hidden="true">
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="prowlarr" aria-hidden="true">
<line x1="0" y1="4" x2="32" y2="4" />
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
</svg>
</div>
<article class="module module-master area-master" data-check="master">
<header class="module-head">
<span class="lamp lamp-big" data-state="probing" aria-hidden="true"></span>
<h2 class="module-name">arr</h2>
<span class="module-status readout" data-role="status">probing</span>
</header>
<p class="module-role">reconcile core</p>
<p class="module-detail readout" data-role="detail">decide · find · fetch · import</p>
</article>
<div class="bus area-out" aria-hidden="true">
<svg viewBox="0 0 40 8" preserveAspectRatio="none" class="bus-trace" data-trace="master" aria-hidden="true">
<line x1="0" y1="4" x2="32" y2="4" />
<path d="M32 0.5 39 4 32 7.5 Z" class="bus-arrow" />
</svg>
</div>
<article class="module area-qbittorrent" data-check="qbit">
<header class="module-head">
<span class="lamp" data-state="probing" aria-hidden="true"></span>
<h2 class="module-name">qbittorrent</h2>
<span class="module-status readout" data-role="status">probing</span>
</header>
<p class="module-role">torrents out</p>
<p class="module-detail readout" data-role="detail">webui · grab and seed</p>
</article>
</section>
<section class="chain-lane" id="chain-subs" aria-label="subtitle lane" hidden></section>
</section>
<section class="deck-group" id="group-roots" hidden aria-labelledby="label-roots">
<header class="deck-head bucket-head">
<h3 class="deck-label" id="label-roots">roots</h3>
<button type="button" class="control control-quiet readout" id="root-add">add root</button>
<header class="deck-head">
<h3 class="section-title" id="label-roots">roots</h3>
<button type="button" class="control control-quiet head-action" id="root-add">add root</button>
</header>
<ul class="deck-rows" id="rows-roots"></ul>
</section>
<section class="deck-group" id="group-policies" hidden aria-labelledby="label-policies">
<header class="deck-head bucket-head">
<h3 class="deck-label" id="label-policies">policies</h3>
<button type="button" class="control control-quiet readout" id="policy-add">
add policy
</button>
<header class="deck-head">
<h3 class="section-title" id="label-policies">policies</h3>
<button type="button" class="control control-quiet head-action" id="policy-add">add policy</button>
</header>
<p class="queue-note readout dim">
a policy edit rescores every title's releases immediately — open any title to check it.
</p>
<div class="group-note">
<p>Editing a policy rescores every title's releases straight away.</p>
</div>
<ul class="deck-rows" id="rows-policies"></ul>
</section>
<section class="deck-group" id="group-subtitles" hidden aria-labelledby="label-settings-subtitles">
<header class="deck-head">
<h3 class="deck-label" id="label-settings-subtitles">subtitles</h3>
<h3 class="section-title" id="label-settings-subtitles">subtitles</h3>
</header>
<p class="queue-note readout dim">
provider credentials, translator keys and the remote-command template
live in config/env (§10) and are never shown here.
</p>
<div class="group-note" data-explain-group>
<p>Provider logins and translator keys are not shown here.</p>
<p class="group-note-more" hidden>
They live in the daemon's config and environment (§10), along with the remote-command
template.
</p>
</div>
<div id="subtitles-body"></div>
</section>
</main>
@@ -521,78 +479,86 @@
something: an unaired episode offers no search button at all.
-->
<main class="deck releases" id="series" hidden aria-label="series detail">
<p class="deck-status readout" id="series-status" role="status" hidden></p>
<section class="module movie-hero" id="series-hero" aria-label="title metadata">
<div class="movie-body">
<img class="movie-poster" id="series-poster" alt="" hidden />
<div class="movie-info">
<div class="movie-idline">
<h2 class="movie-title" id="series-title">—</h2>
<span class="movie-year readout dim" id="series-year"></span>
</div>
<main class="deck detail" id="series" hidden aria-label="series detail">
<section class="hero" id="series-hero" aria-label="title metadata">
<header class="page-head">
<button type="button" class="control control-icon" id="series-back" aria-label="back" data-glyph="back"></button>
<h2 class="page-title" id="series-title" tabindex="-1">&nbsp;</h2>
<span class="page-year readout" id="series-year"></span>
</header>
<div class="hero-body">
<div class="hero-poster"><img class="movie-poster" id="series-poster" alt="" /></div>
<div class="hero-info">
<!-- audience, episode counts and §4.2 status: operational state,
outranking the artwork, folded into this one header (issue 150) -->
<span class="row-chips movie-chips" id="series-chips"></span>
<p class="movie-rating readout" id="series-rating" hidden></p>
<p class="movie-meta readout dim" id="series-meta" hidden></p>
<p class="movie-tagline" id="series-tagline" hidden></p>
<p class="movie-overview" id="series-overview" hidden></p>
<div class="movie-actions" id="series-actions"></div>
<span class="hero-chips" id="series-chips"></span>
<p class="hero-rating readout" id="series-rating"></p>
<p class="hero-meta readout" id="series-meta"></p>
<p class="hero-tagline" id="series-tagline"></p>
<p class="hero-overview" id="series-overview"></p>
</div>
</div>
<p class="r-note hero-note readout" id="series-note" role="status" hidden></p>
</section>
<section class="deck-group" id="series-library" aria-label="library controls">
<header class="deck-head">
<h3 class="deck-label">library</h3>
</header>
<div class="movie-controls">
<label class="movie-root-field">
<span class="field-label readout dim" id="series-root-label">root</span>
<select
class="form-input movie-root"
id="series-root"
data-focus-id="series-root"
aria-labelledby="series-root-label"
></select>
</label>
<span class="movie-controls-space"></span>
<button
type="button"
class="control control-quiet control-icon"
data-kind="delete"
id="series-remove"
aria-expanded="false"
aria-controls="series-remove-panel"
aria-label="remove from the library"
>
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<path d="M2.8 4.3h10.4" />
<path d="M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2" />
<path d="M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5" />
<path d="M6.6 7v4.2" />
<path d="M9.4 7v4.2" />
</svg>
</button>
</div>
<div class="actionbar">
<span class="actionbar-links" id="series-actions"></span>
<button
type="button"
class="control control-icon"
id="series-more"
aria-label="library settings for this title"
aria-expanded="false"
aria-controls="series-more-panel"
data-glyph="more"
></button>
</div>
<div class="more-panel" id="series-more-panel" hidden>
<div class="more-links" id="series-links"></div>
<label class="field more-field">
<span class="field-label" id="series-root-label">root</span>
<select
class="form-input"
id="series-root"
data-focus-id="series-root"
aria-labelledby="series-root-label"
></select>
</label>
<button
type="button"
class="control control-quiet"
data-kind="delete"
id="series-remove"
aria-expanded="false"
aria-controls="series-remove-panel"
>
remove from library
</button>
<div class="remove-panel" id="series-remove-panel" hidden></div>
</section>
</div>
<p class="deck-status readout" id="series-status" role="status" data-empty="true"></p>
<ul class="deck-rows seasons" id="rows-seasons"></ul>
<section class="deck-group" aria-labelledby="label-seasons">
<header class="deck-head">
<h3 class="section-title" id="label-seasons">seasons</h3>
</header>
<ul class="deck-rows seasons" id="rows-seasons"></ul>
</section>
</main>
<main class="deck releases" id="tv-releases" hidden aria-label="season and episode releases">
<header class="releases-head">
<button type="button" class="control" id="tv-releases-back">back</button>
<div class="releases-id">
<h2 class="releases-title" id="tv-releases-title">—</h2>
<span class="releases-year readout dim" id="tv-releases-sub"></span>
<main class="deck detail" id="tv-releases" hidden aria-label="season and episode releases">
<header class="page-head">
<button type="button" class="control control-icon" id="tv-releases-back" aria-label="back" data-glyph="back"></button>
<div class="page-id">
<h2 class="page-title" id="tv-releases-title">&nbsp;</h2>
<span class="page-sub readout" id="tv-releases-sub"></span>
</div>
<button type="button" class="control" id="tv-releases-sweep">re-search</button>
</header>
<p class="deck-status readout" id="tv-releases-status" role="status" hidden></p>
<div class="actionbar">
<button type="button" class="control" id="tv-releases-sweep">search indexers</button>
</div>
<p class="deck-status readout" id="tv-releases-status" role="status" data-empty="true"></p>
<!-- issue 227: a pack that downloaded in full and was condemned at
import (§5.7). It outlives the status line because it is a fact
about the season, not about the request in flight. -->
@@ -602,12 +568,11 @@
<p class="notice-line" id="tv-failure-what"></p>
<p class="notice-line" id="tv-failure-next"></p>
<p class="notice-line dim">
nothing was imported, and the torrent was left where it is: nothing is
deleted early to satisfy the library (§7.3).
Nothing was imported, and the torrent was left where it is: nothing is deleted early to
satisfy the library (§7.3).
</p>
</section>
<div id="tv-buckets"></div>
<div class="buckets" id="tv-buckets"></div>
</main>
</body>
</html>
+1290 -1390
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -115,4 +115,24 @@ export function navigate(route: Route, options: { replace?: boolean } = {}) {
} else {
history.pushState(null, "", path);
}
window.dispatchEvent(new Event(ROUTE_EVENT));
}
/** Fired after every `navigate` and on popstate: the shell repaints its active tab. */
export const ROUTE_EVENT = "arr:route";
/** Which of the four destinations a route belongs to. */
export type Destination = "library" | "search" | "queues" | "settings";
export function destinationOf(route: Route): Destination {
switch (route.kind) {
case "queues":
return "queues";
case "settings":
return "settings";
case "search":
return "search";
default:
return "library";
}
}
+82 -118
View File
@@ -6,6 +6,7 @@ import { armedDeleteIcon, icon } from "./icons";
import { navigate } from "./router";
import type { Root } from "./search";
import { forgetSubtitleOptions } from "./subtitles";
import { control, type RowDom, row, setSlot, chip as uiChip } from "./ui";
/** One policy row: the JSON columns as the API returns them. */
export interface PolicyDoc {
@@ -166,13 +167,7 @@ function el<K extends keyof HTMLElementTagNameMap>(
}
function chip(text: string): HTMLSpanElement {
return el("span", "chip readout", text);
}
function rowTitle(title: string): HTMLElement {
const wrap = el("span", "row-id");
wrap.append(el("span", "row-title", title));
return wrap;
return uiChip(text);
}
function field(labelText: string, control: HTMLElement): HTMLDivElement {
@@ -226,10 +221,9 @@ function listValue(input: HTMLInputElement): string[] {
/** Icon-only edit toggle for one row; the label names the row it opens. */
function editIcon(label: string): HTMLButtonElement {
const button = el("button", "control control-quiet control-icon");
const button = el("button", "control control-icon");
button.type = "button";
button.setAttribute("aria-label", label);
button.title = label;
button.setAttribute("aria-expanded", "false");
button.append(icon("pencil"));
return button;
@@ -243,8 +237,6 @@ interface SettingsView {
export function settingsMain(views: { hide: () => void }[], goHome: () => void): SettingsView {
const view = must<HTMLElement>("#settings");
const deck = must<HTMLElement>("#deck");
const nav = must<HTMLButtonElement>("#nav-settings");
nav.append(icon("cog"));
const summary = must<HTMLElement>("#settings-summary");
const status = must<HTMLElement>("#settings-status");
const rootsSection = must<HTMLElement>("#group-roots");
@@ -261,13 +253,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
let sequence = 0;
function setStatus(text: string | null, tone?: "fault") {
status.hidden = text === null;
status.textContent = text ?? "";
if (tone) {
status.dataset.tone = tone;
} else {
delete status.dataset.tone;
}
setSlot(status, text, tone);
}
function clearGroups() {
@@ -327,54 +313,65 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
/* -- roots -------------------------------------------------------------- */
/** The row's line: identity, chips right, the icon action pair, feedback. */
function rowLine(
item: HTMLLIElement,
/**
* One settings row: identity, chips, then the edit and delete pair in the
* row's fixed action column. The edit panel opens in the row's tray.
*/
function settingsRow(
title: string,
chips: HTMLSpanElement,
del: HTMLButtonElement,
edit: HTMLButtonElement,
): HTMLParagraphElement {
const actions = el("div", "settings-row-actions");
actions.append(edit, del);
const note = el("p", "add-note readout");
note.setAttribute("role", "status");
note.hidden = true;
item.append(rowTitle(title), chips, actions, note);
return note;
}
function rootRow(root: Root): HTMLLIElement {
const item = el("li", "queue-item settings-row");
const chips = el("span", "row-chips");
chips.append(chip(root.kind), chip(root.audience), chip(root.policy_name));
const del = armedDeleteIcon(`delete root ${root.path}`, () => {
void deleteRow("roots", root.id).then((detail) => {
if (detail !== null) {
note.hidden = false;
note.dataset.tone = "fault";
note.textContent = detail;
return;
}
void load();
});
chips: HTMLSpanElement[],
label: string,
remove: () => Promise<string | null>,
panel: () => HTMLElement,
): HTMLLIElement {
let say: RowDom["say"] = () => {};
const del = armedDeleteIcon(
`delete ${label}`,
() => {
void remove().then((detail) => {
if (detail !== null) {
del.disabled = false;
say(detail, "fault");
return;
}
void load();
});
},
{ explain: (text) => say(text, "warn") },
);
const edit = editIcon(`edit ${label}`);
const parts = row({
kind: "setting",
title,
meta: chips,
actions: [
{ node: edit, width: "icon" },
{ node: del, width: "icon" },
],
});
const edit = editIcon(`edit root ${root.path}`);
say = parts.say;
parts.body.querySelector(".r-title")?.classList.add("readout");
edit.addEventListener("click", () => {
const existing = item.querySelector(".edit-panel");
const existing = parts.tray.querySelector(".edit-panel");
if (existing) {
existing.remove();
edit.setAttribute("aria-expanded", "false");
return;
}
item.append(rootPanel(root));
parts.tray.append(panel());
edit.setAttribute("aria-expanded", "true");
});
return parts.item;
}
const note = rowLine(item, root.path, chips, del, edit);
return item;
function rootRow(root: Root): HTMLLIElement {
return settingsRow(
root.path,
[chip(root.kind), chip(root.audience), chip(root.policy_name)],
`root ${root.path}`,
() => deleteRow("roots", root.id),
() => rootPanel(root),
);
}
function rootPanel(root: Root | null): HTMLElement {
@@ -407,8 +404,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
note.setAttribute("role", "status");
note.hidden = true;
const save = el("button", "control readout", root === null ? "create root" : "save root");
save.type = "button";
const save = control(root === null ? "create root" : "save root");
save.addEventListener("click", () => {
save.disabled = true;
note.hidden = false;
@@ -432,8 +428,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
});
});
const cancel = el("button", "control control-quiet readout", "cancel");
cancel.type = "button";
const cancel = control("cancel", { quiet: true });
cancel.addEventListener("click", () => {
panel.remove();
});
@@ -455,39 +450,18 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
}
function policyRow(policy: PolicyDoc): HTMLLIElement {
const item = el("li", "queue-item settings-row");
const chips = el("span", "row-chips");
chips.append(chip(policy.resolution_pref.join(" › ") || "no resolutions"));
chips.append(chip(requiredAudioLabel(policy)));
const bandCount = Object.keys(policy.size_bands).length;
chips.append(chip(`${bandCount} band${bandCount === 1 ? "" : "s"}`));
const del = armedDeleteIcon(`delete policy ${policy.name}`, () => {
void deleteRow("policies", policy.id).then((detail) => {
if (detail !== null) {
note.hidden = false;
note.dataset.tone = "fault";
note.textContent = detail;
return;
}
void load();
});
});
const edit = editIcon(`edit policy ${policy.name}`);
edit.addEventListener("click", () => {
const existing = item.querySelector(".edit-panel");
if (existing) {
existing.remove();
edit.setAttribute("aria-expanded", "false");
return;
}
item.append(policyPanel(policy));
edit.setAttribute("aria-expanded", "true");
});
const note = rowLine(item, policy.name, chips, del, edit);
return item;
return settingsRow(
policy.name,
[
chip(policy.resolution_pref.join(" › ") || "no resolutions"),
chip(requiredAudioLabel(policy)),
chip(`${bandCount} band${bandCount === 1 ? "" : "s"}`),
],
`policy ${policy.name}`,
() => deleteRow("policies", policy.id),
() => policyPanel(policy),
);
}
/**
@@ -635,8 +609,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
scoreInputs[key] = input;
}
const save = el("button", "control readout", policy === null ? "create policy" : "save policy");
save.type = "button";
const save = control(policy === null ? "create policy" : "save policy");
save.addEventListener("click", () => {
const bad = panel.querySelector<HTMLElement>("[data-invalid]");
if (bad !== null) {
@@ -704,8 +677,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
});
});
const cancel = el("button", "control control-quiet readout", "cancel");
cancel.type = "button";
const cancel = control("cancel", { quiet: true });
cancel.addEventListener("click", () => {
panel.remove();
});
@@ -730,11 +702,10 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
* ordinal selector would quietly disable the wrong control.
*/
function moveButton(direction: "up" | "down", label: string): HTMLButtonElement {
const button = el("button", "control control-quiet control-icon provider-move");
const button = el("button", "control control-icon provider-move");
button.type = "button";
button.dataset.move = direction;
button.setAttribute("aria-label", label);
button.title = label;
button.append(icon(direction === "up" ? "chevronUp" : "chevronDown"));
return button;
}
@@ -750,23 +721,31 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
enabled: boolean,
budget: number,
): { item: HTMLLIElement; enabledInput: HTMLInputElement; budgetInput: HTMLInputElement } {
const item = el("li", "provider-row");
item.dataset.provider = provider;
const enabledInput = document.createElement("input");
enabledInput.type = "checkbox";
enabledInput.className = "provider-toggle";
enabledInput.checked = enabled;
enabledInput.setAttribute("aria-label", `enable ${provider}`);
const name = el("span", "row-title provider-name", provider);
const budgetInput = numberControl(budget);
budgetInput.classList.add("provider-budget");
budgetInput.setAttribute("aria-label", `${provider} daily budget`);
const up = moveButton("up", `move ${provider} up`);
const down = moveButton("down", `move ${provider} down`);
const toggleWrap = el("label", "provider-check");
toggleWrap.append(enabledInput);
const { item } = row({
kind: "provider",
lead: toggleWrap,
title: provider,
actions: [
{ node: budgetInput, width: "word" },
{ node: up, width: "icon" },
{ node: down, width: "icon" },
],
});
item.dataset.provider = provider;
up.addEventListener("click", () => {
const previous = item.previousElementSibling;
@@ -783,7 +762,6 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
}
});
item.append(enabledInput, name, budgetInput, up, down);
return { item, enabledInput, budgetInput };
}
@@ -885,8 +863,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
note.setAttribute("role", "status");
note.hidden = true;
const save = el("button", "control readout", "save subtitles");
save.type = "button";
const save = control("save subtitles");
save.addEventListener("click", () => {
const providerOrder = [...list.children].map(
(item) => (item as HTMLElement).dataset.provider ?? "",
@@ -956,32 +933,19 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
}
deck.hidden = true;
view.hidden = false;
nav.setAttribute("aria-pressed", "true");
void load();
}
function hide() {
view.hidden = true;
nav.setAttribute("aria-pressed", "false");
sequence += 1;
}
function close() {
hide();
goHome();
nav.focus();
}
nav.addEventListener("click", () => {
if (view.hidden) {
navigate({ kind: "settings" });
open();
} else {
navigate({ kind: "library" });
close();
}
});
must<HTMLButtonElement>("#root-add").addEventListener("click", () => {
rootsSection.append(rootPanel(null));
});