diff --git a/.impeccable/surfaces/web-src-main-ts.md b/.impeccable/surfaces/web-src-main-ts.md new file mode 100644 index 0000000..5e82744 --- /dev/null +++ b/.impeccable/surfaces/web-src-main-ts.md @@ -0,0 +1,26 @@ +--- +version: 1 +slug: "web-src-main-ts" +primary_target: "web/src/main.ts" +related_targets: ["web/index.html","web/src/search.ts","web/src/style.css"] +--- + +# Surface: unified search (web/src/main.ts, §9.2) + +Mode: Operate. The operator finds or adds a title without knowing which of +the two they are doing. + +- Audience: the household operator; desktop mostly, phone sometimes. +- Task: one rail-mounted command input. Typing swaps the chain board for the + search deck; clearing or Esc returns to the board. "/" focuses from anywhere. +- Structure (pinned by DESIGN.md §9.2): IN LIBRARY group first, ON TMDB below, + engraved micro-label headings, machined rows, chips on the right. +- Add flow: inline panel under a TMDB row — root radio cards (audience name, + policy name, path), main pre-selected, one confirm. Never a separate page. +- Manual intake: magnet / .torrent input routes to a MANUAL GRAB panel showing + parsed display name and btih; grab wiring lands with the release buckets + (issue #31/#22), and the panel says so honestly. +- Constraints: 250 ms debounce, AbortController cancellation, token-gated CSS, + cyan for interaction only, dead-air rule (no idle animation). +- Unresolved: library rows are informational until the library view (#32) + gives them a destination; TMDB search is movie-only until series land (#39). diff --git a/.sqlx/query-fa84977e249cb6f3b95d5f3486346450066ac2997437175e30c60d6364701dcf.json b/.sqlx/query-fa84977e249cb6f3b95d5f3486346450066ac2997437175e30c60d6364701dcf.json new file mode 100644 index 0000000..b66fc84 --- /dev/null +++ b/.sqlx/query-fa84977e249cb6f3b95d5f3486346450066ac2997437175e30c60d6364701dcf.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "SELECT r.id AS \"id!: i64\", r.kind AS \"kind!: String\", r.audience AS \"audience!: String\", r.path AS \"path!: String\", p.id AS \"policy_id!: i64\", p.name AS \"policy_name!: String\" FROM roots r JOIN policies p ON p.id = r.policy_id ORDER BY r.id", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "kind!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "audience!: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "path!: String", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "policy_id!: i64", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "policy_name!: String", + "ordinal": 5, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "fa84977e249cb6f3b95d5f3486346450066ac2997437175e30c60d6364701dcf" +} diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index 062b2b1..f565f46 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -9,6 +9,7 @@ mod health; mod movies; mod owners; +mod roots; mod search; mod state; @@ -22,6 +23,7 @@ use utoipa_scalar::{Scalar, Servable}; pub use health::{Check, Health, HealthReport, Status}; pub use movies::{Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, Release, UpdateMovie}; pub use owners::{CreateOwner, Owner, UpdateOwner}; +pub use roots::Root; pub use search::{ClassifiedRelease, SearchResponse}; pub use state::{AppState, MovieCommand, Upstreams, DEFAULT_TMDB_URL}; @@ -45,7 +47,8 @@ pub const DOCS_PATH: &str = "/api/docs"; (name = "system", description = "Service health and metadata"), (name = "movies", description = "Movie library and actions"), (name = "owners", description = "Owner tags and filtered views (DESIGN.md §4.3)"), - (name = "search", description = "Unified title and release search") + (name = "search", description = "Unified title and release search"), + (name = "roots", description = "Root folders and their policies") ), )] struct ApiDoc; @@ -66,6 +69,7 @@ fn api_router() -> OpenApiRouter { .routes(routes!(owners::get, owners::update, owners::delete)) .routes(routes!(search::search)) .routes(routes!(search::releases)) + .routes(routes!(roots::list)) } /// The generated `OpenAPI` document. diff --git a/crates/arr-api/src/roots.rs b/crates/arr-api/src/roots.rs new file mode 100644 index 0000000..7f86586 --- /dev/null +++ b/crates/arr-api/src/roots.rs @@ -0,0 +1,79 @@ +use axum::extract::State; +use axum::Json; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::movies::{ApiError, ErrorBody}; +use crate::state::AppState; + +/// A root folder joined with its policy, enough for the add flow to pre-fill +/// root and policy (DESIGN.md §9.2) without a second request. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct Root { + pub id: i64, + pub kind: String, + pub audience: String, + pub path: String, + pub policy_id: i64, + pub policy_name: String, +} + +#[utoipa::path( + get, path = "/api/roots", tag = "roots", + responses( + (status = 200, body = [Root]), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn list(State(state): State) -> Result>, ApiError> { + let database = state.database().ok_or(ApiError::Unavailable)?; + let roots = sqlx::query_as!( + Root, + r#"SELECT r.id AS "id!: i64", r.kind AS "kind!: String", r.audience AS "audience!: String", r.path AS "path!: String", p.id AS "policy_id!: i64", p.name AS "policy_name!: String" FROM roots r JOIN policies p ON p.id = r.policy_id ORDER BY r.id"# + ) + .fetch_all(database.pool()) + .await?; + Ok(Json(roots)) +} + +#[cfg(test)] +mod tests { + use crate::state::Upstreams; + use crate::{router, AppState}; + + #[tokio::test] + async fn roots_carry_their_policy_name() { + let dir = tempfile::tempdir().expect("tempdir"); + let database = arr_db::Db::connect(dir.path().join("arr.db")) + .await + .expect("database"); + database.migrate().await.expect("migrate"); + let state = AppState::new(Upstreams::new( + "http://127.0.0.1:1".into(), + "http://127.0.0.1:1".into(), + )) + .expect("state") + .with_database(database); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let address = listener.local_addr().expect("address"); + let app = router(state); + tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); + + let roots: Vec = reqwest::get(format!("http://{address}/api/roots")) + .await + .expect("roots") + .json() + .await + .expect("json"); + assert_eq!(roots.len(), 2, "the two seeded movie roots: {roots:?}"); + assert_eq!(roots[0]["audience"], "main"); + assert_eq!(roots[0]["policy_name"], "Movies — main"); + assert_eq!(roots[1]["audience"], "kids"); + assert!(roots[1]["path"] + .as_str() + .is_some_and(|p| p.contains("kids"))); + } +} diff --git a/web/index.html b/web/index.html index ac7c7f0..dcd8d88 100644 --- a/web/index.html +++ b/web/index.html @@ -28,12 +28,31 @@ output in this environment). FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md. + SEARCH (§9.2, issue #30): one command input on the rail — the board's + command line. Typing swaps the chain for the search deck: IN LIBRARY + first, ON TMDB below, engraved group labels, machined rows. Enter on a + TMDB row expands an inline add panel with root and policy pre-filled; + a pasted magnet or .torrent routes to a manual-intake panel. There is + no separate add page, ever. Esc returns to the board; "/" focuses the + box from anywhere. -->

arr

+

probing chain…

v — @@ -41,6 +60,33 @@
+
+ + + + + + + +
+
diff --git a/web/src/main.ts b/web/src/main.ts index 7a664f5..458283d 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,4 +1,14 @@ import { type CheckStatus, type Probe, probeHealth } from "./health"; +import { + addMovie, + type LibraryMovie, + movieRoots, + parseManualInput, + type Root, + type SearchResponse, + searchTitles, + type TmdbMovie, +} from "./search"; import "./style.css"; const POLL_MS = 15_000; @@ -154,6 +164,458 @@ function main() { void probe(); }, POLL_MS); void probe(); + + searchMain(board); +} + +const DEBOUNCE_MS = 250; + +interface DeckRefs { + deck: HTMLElement; + status: HTMLElement; + groups: { + library: { section: HTMLElement; count: HTMLElement; rows: HTMLUListElement }; + tmdb: { section: HTMLElement; count: HTMLElement; rows: HTMLUListElement }; + }; + manualSection: HTMLElement; + manualIntake: HTMLElement; +} + +function searchMain(board: HTMLElement) { + const input = must("#search"); + const hint = must("#search-hint"); + const refs: DeckRefs = { + deck: must("#deck"), + status: must("#deck-status"), + groups: { + library: { + section: must("#group-library"), + count: must("#count-library"), + rows: must("#rows-library"), + }, + tmdb: { + section: must("#group-tmdb"), + count: must("#count-tmdb"), + rows: must("#rows-tmdb"), + }, + }, + manualSection: must("#group-manual"), + manualIntake: must("#manual-intake"), + }; + + // The audience chip on a library row needs id → root. movieRoots() caches + // success, so a failed boot fetch is retried whenever an add panel opens. + let roots: Root[] = []; + const fetchRoots = () => + movieRoots().then( + (fetched) => { + roots = fetched; + return fetched; + }, + () => roots, + ); + void fetchRoots(); + + let timer: number | undefined; + let controller: AbortController | null = null; + + function showBoard() { + controller?.abort(); + controller = null; + refs.deck.hidden = true; + board.hidden = false; + } + + function showDeck() { + board.hidden = true; + refs.deck.hidden = false; + } + + function setStatus(text: string | null, tone?: "fault") { + refs.status.hidden = text === null; + refs.status.textContent = text ?? ""; + if (tone) { + refs.status.dataset.tone = tone; + } else { + delete refs.status.dataset.tone; + } + } + + function clearGroups() { + for (const group of [refs.groups.library, refs.groups.tmdb]) { + group.section.hidden = true; + group.rows.replaceChildren(); + } + refs.manualSection.hidden = true; + refs.manualIntake.replaceChildren(); + } + + async function run(query: string) { + controller?.abort(); + controller = new AbortController(); + showDeck(); + setStatus("searching…"); + const outcome = await searchTitles(query, controller.signal); + if (outcome.kind === "aborted") { + return; + } + if (outcome.kind === "error") { + clearGroups(); + setStatus(`search failed — ${outcome.detail}`, "fault"); + return; + } + render(outcome.response); + } + + function render(response: SearchResponse) { + clearGroups(); + if ( + response.manual !== null && + (response.kind === "magnet" || response.kind === "torrent_url") + ) { + setStatus(null); + refs.manualSection.hidden = false; + renderManual(refs.manualIntake, response.kind, response.manual); + return; + } + + const inLibrary = new Set(response.library.map((movie) => movie.tmdb_id)); + if (response.library.length === 0 && response.tmdb.length === 0) { + setStatus("no matches in library or on tmdb"); + return; + } + setStatus(null); + + if (response.library.length > 0) { + refs.groups.library.section.hidden = false; + refs.groups.library.count.textContent = String(response.library.length); + for (const movie of response.library) { + refs.groups.library.rows.append(libraryRow(movie, roots)); + } + } + if (response.tmdb.length > 0) { + refs.groups.tmdb.section.hidden = false; + refs.groups.tmdb.count.textContent = String(response.tmdb.length); + for (const movie of response.tmdb) { + refs.groups.tmdb.rows.append(tmdbRow(movie, inLibrary.has(movie.tmdb_id), fetchRoots)); + } + } + } + + input.addEventListener("input", () => { + window.clearTimeout(timer); + const query = input.value.trim(); + if (query === "") { + showBoard(); + clearGroups(); + setStatus(null); + return; + } + timer = window.setTimeout(() => { + void run(query); + }, DEBOUNCE_MS); + }); + + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + window.clearTimeout(timer); + const query = input.value.trim(); + if (query !== "") { + void run(query); + } + } else if (event.key === "ArrowDown") { + const first = refs.deck.querySelector(".row-tmdb:not(:disabled)"); + if (first) { + event.preventDefault(); + first.focus(); + } + } else if (event.key === "Escape") { + input.value = ""; + showBoard(); + clearGroups(); + setStatus(null); + } + }); + + input.addEventListener("focus", () => { + hint.hidden = true; + }); + input.addEventListener("blur", () => { + hint.hidden = false; + }); + + window.addEventListener("keydown", (event) => { + if ( + event.key === "/" && + document.activeElement !== input && + !(document.activeElement instanceof HTMLInputElement) && + !(document.activeElement instanceof HTMLTextAreaElement) + ) { + event.preventDefault(); + input.focus(); + input.select(); + } else if (event.key === "Escape" && !refs.deck.hidden) { + input.value = ""; + showBoard(); + clearGroups(); + setStatus(null); + input.focus(); + } + }); + + refs.deck.addEventListener("keydown", (event) => { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") { + return; + } + const target = event.target; + if (!(target instanceof HTMLElement) || !target.classList.contains("row-tmdb")) { + return; + } + event.preventDefault(); + const rows = [...refs.deck.querySelectorAll(".row-tmdb:not(:disabled)")]; + const index = rows.indexOf(target); + if (event.key === "ArrowUp" && index === 0) { + input.focus(); + return; + } + rows[index + (event.key === "ArrowDown" ? 1 : -1)]?.focus(); + }); +} + +function chip(text: string, extra?: (chip: HTMLSpanElement) => void): HTMLSpanElement { + const span = document.createElement("span"); + span.className = "chip readout"; + span.textContent = text; + extra?.(span); + return span; +} + +function rowTitle(title: string, year: number | null): HTMLElement { + const wrap = document.createElement("span"); + wrap.className = "row-id"; + const name = document.createElement("span"); + name.className = "row-title"; + name.textContent = title; + const when = document.createElement("span"); + when.className = "row-year readout dim"; + when.textContent = year === null ? "—" : String(year); + wrap.append(name, when); + return wrap; +} + +function libraryRow(movie: LibraryMovie, roots: Root[]): HTMLLIElement { + const item = document.createElement("li"); + item.className = "row"; + const chips = document.createElement("span"); + chips.className = "row-chips"; + const root = roots.find((candidate) => candidate.id === movie.root_id); + chips.append(chip(root ? root.audience : `root ${movie.root_id}`)); + chips.append( + chip(movie.state, (span) => { + span.dataset.movieState = movie.state; + }), + ); + if (!movie.wanted) { + chips.append(chip("not wanted")); + } + if (movie.blocked) { + chips.append(chip("blocked")); + } + item.append(rowTitle(movie.title, movie.year), chips); + return item; +} + +function tmdbRow( + movie: TmdbMovie, + inLibrary: boolean, + fetchRoots: () => Promise, +): HTMLLIElement { + const item = document.createElement("li"); + + const row = document.createElement("button"); + row.type = "button"; + row.className = "row row-tmdb"; + const chips = document.createElement("span"); + chips.className = "row-chips"; + chips.append(chip(movie.original_language)); + if (inLibrary) { + chips.append( + chip("in library", (span) => { + span.dataset.verdict = "eligible"; + }), + ); + } else { + const add = document.createElement("span"); + add.className = "row-add readout"; + add.textContent = "add"; + chips.append(add); + } + row.append(rowTitle(movie.title, movie.year), chips); + if (movie.overview) { + const overview = document.createElement("span"); + overview.className = "row-overview"; + overview.textContent = movie.overview; + row.append(overview); + } + item.append(row); + + if (inLibrary) { + row.disabled = true; + return item; + } + + row.setAttribute("aria-expanded", "false"); + row.addEventListener("click", () => { + const open = item.querySelector(".add-panel"); + if (open) { + open.remove(); + row.setAttribute("aria-expanded", "false"); + row.focus(); + return; + } + void fetchRoots().then((available) => { + if (item.querySelector(".add-panel")) { + return; + } + const panel = addPanel(movie, available, row); + item.append(panel); + row.setAttribute("aria-expanded", "true"); + panel.querySelector(".control")?.focus(); + }); + }); + return item; +} + +/** The §9.2 add flow: inline, root and policy pre-filled, one confirm. */ +function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTMLElement { + const panel = document.createElement("div"); + panel.className = "add-panel"; + + let selected = roots.find((root) => root.audience === "main") ?? roots[0] ?? null; + + const options = document.createElement("div"); + options.className = "add-roots"; + options.setAttribute("role", "group"); + options.setAttribute("aria-label", "root folder"); + + const optionButtons: HTMLButtonElement[] = []; + for (const root of roots) { + const option = document.createElement("button"); + option.type = "button"; + option.className = "root-option"; + option.setAttribute("aria-pressed", String(root === selected)); + + const audience = document.createElement("span"); + audience.className = "root-audience"; + audience.textContent = root.audience; + const policy = document.createElement("span"); + policy.className = "root-policy readout dim"; + policy.textContent = root.policy_name; + const path = document.createElement("span"); + path.className = "root-path readout dim"; + path.textContent = root.path; + option.append(audience, policy, path); + + option.addEventListener("click", () => { + selected = root; + for (const other of optionButtons) { + other.setAttribute("aria-pressed", String(other === option)); + } + }); + optionButtons.push(option); + options.append(option); + } + + const confirm = document.createElement("button"); + confirm.type = "button"; + confirm.className = "control"; + confirm.textContent = "add to library"; + + const note = document.createElement("p"); + note.className = "add-note readout"; + note.setAttribute("role", "status"); + note.hidden = true; + + if (roots.length === 0) { + confirm.disabled = true; + note.hidden = false; + note.textContent = "roots unavailable — daemon down or database missing"; + note.dataset.tone = "fault"; + } + + confirm.addEventListener("click", () => { + const root = selected; + if (!root) { + return; + } + confirm.disabled = true; + note.hidden = false; + delete note.dataset.tone; + note.textContent = "adding…"; + void addMovie(movie, root.id).then((outcome) => { + if (outcome.kind === "error") { + confirm.disabled = false; + note.textContent = `add failed — ${outcome.detail}`; + note.dataset.tone = "fault"; + return; + } + const already = outcome.kind === "conflict"; + const done = document.createElement("p"); + done.className = "add-done readout"; + done.setAttribute("role", "status"); + done.tabIndex = -1; + done.textContent = already + ? "already in library" + : `added to ${root.audience} — wanted, search follows`; + panel.replaceChildren(done); + done.focus(); + row.disabled = true; + row.setAttribute("aria-expanded", "true"); + row.querySelector(".row-add")?.replaceWith( + chip("in library", (span) => { + span.dataset.verdict = "eligible"; + }), + ); + }); + }); + + const actions = document.createElement("div"); + actions.className = "add-actions"; + actions.append(confirm, note); + panel.append(options, actions); + return panel; +} + +function renderManual(intake: HTMLElement, kind: "magnet" | "torrent_url", raw: string) { + const parsed = parseManualInput(kind, raw); + intake.replaceChildren(); + + const head = document.createElement("header"); + head.className = "module-head"; + const name = document.createElement("h3"); + name.className = "module-name"; + name.textContent = kind === "magnet" ? "magnet" : "torrent"; + const status = document.createElement("span"); + status.className = "module-status readout"; + status.textContent = "recognized"; + head.append(name, status); + + const detail = document.createElement("p"); + detail.className = "module-detail readout"; + detail.textContent = parsed.name ?? parsed.raw; + + intake.append(head, detail); + if (parsed.infohash) { + const hash = document.createElement("p"); + hash.className = "module-detail readout dim"; + hash.textContent = `btih ${parsed.infohash}`; + intake.append(hash); + } + + const note = document.createElement("p"); + note.className = "module-detail readout dim"; + note.textContent = "manual grab lands with the release buckets — nothing sent yet"; + intake.append(note); } main(); diff --git a/web/src/search.ts b/web/src/search.ts new file mode 100644 index 0000000..82068d1 --- /dev/null +++ b/web/src/search.ts @@ -0,0 +1,146 @@ +// Hand-written mirror of arr-api's /api/search, /api/roots and POST +// /api/movies schemas — same reasoning as health.ts: the generated client +// (src/api/) is uncommitted, so CI's tsc cannot see it. + +export type SearchInputKind = "text" | "tmdb_id" | "imdb_id" | "magnet" | "torrent_url"; + +export interface LibraryMovie { + id: number; + tmdb_id: number; + title: string; + year: number | null; + original_language: string | null; + root_id: number; + wanted: boolean; + state: "missing" | "grabbed" | "imported"; + blocked: boolean; +} + +export interface TmdbMovie { + tmdb_id: number; + title: string; + original_title: string; + original_language: string; + year: number | null; + overview: string | null; +} + +export interface SearchResponse { + kind: SearchInputKind; + library: LibraryMovie[]; + tmdb: TmdbMovie[]; + manual: string | null; +} + +export interface Root { + id: number; + kind: string; + audience: string; + path: string; + policy_id: number; + policy_name: string; +} + +export type SearchOutcome = + | { kind: "results"; response: SearchResponse } + | { kind: "error"; detail: string } + | { kind: "aborted" }; + +export async function searchTitles(query: string, signal: AbortSignal): Promise { + try { + const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal }); + if (!response.ok) { + const detail = await errorDetail(response); + return { kind: "error", detail }; + } + return { kind: "results", response: (await response.json()) as SearchResponse }; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return { kind: "aborted" }; + } + return { kind: "error", detail: "daemon unreachable" }; + } +} + +let rootsCache: Root[] | null = null; + +/** The movie roots, fetched once. The add flow pre-fills from these. */ +export async function movieRoots(): Promise { + if (rootsCache) { + return rootsCache; + } + const response = await fetch("/api/roots"); + if (!response.ok) { + throw new Error(await errorDetail(response)); + } + const roots = (await response.json()) as Root[]; + rootsCache = roots.filter((root) => root.kind === "movie"); + return rootsCache; +} + +export type AddOutcome = + | { kind: "added"; movie: LibraryMovie } + | { kind: "conflict" } + | { kind: "error"; detail: string }; + +export async function addMovie(movie: TmdbMovie, rootId: number): Promise { + try { + const response = await fetch("/api/movies", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + tmdb_id: movie.tmdb_id, + title: movie.title, + year: movie.year, + original_language: movie.original_language, + root_id: rootId, + }), + }); + if (response.status === 409) { + return { kind: "conflict" }; + } + if (!response.ok) { + return { kind: "error", detail: await errorDetail(response) }; + } + return { kind: "added", movie: (await response.json()) as LibraryMovie }; + } catch { + return { kind: "error", detail: "daemon unreachable" }; + } +} + +async function errorDetail(response: Response): Promise { + try { + const body = (await response.json()) as { error?: string }; + return body.error ?? `http ${response.status}`; + } catch { + return `http ${response.status}`; + } +} + +export interface ManualIntake { + kind: "magnet" | "torrent_url"; + /** The display name a magnet advertises, when it carries one. */ + name: string | null; + /** The btih infohash, when the magnet carries one. */ + infohash: string | null; + raw: string; +} + +/** Best-effort read of a pasted magnet or .torrent URL, for display only. */ +export function parseManualInput(kind: "magnet" | "torrent_url", raw: string): ManualIntake { + if (kind === "torrent_url") { + return { kind, name: raw.split("/").pop() ?? null, infohash: null, raw }; + } + let name: string | null = null; + let infohash: string | null = null; + try { + const params = new URLSearchParams(raw.slice(raw.indexOf("?") + 1)); + name = params.get("dn"); + const urn = params.get("xt") ?? ""; + const match = /^urn:btih:([0-9a-zA-Z]+)$/.exec(urn); + infohash = match?.[1]?.toLowerCase() ?? null; + } catch { + // display-only parsing; a malformed magnet still shows raw + } + return { kind, name, infohash, raw }; +} diff --git a/web/src/style.css b/web/src/style.css index c916b47..a89308c 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -277,6 +277,11 @@ body { align-items: center; } +/* the deck swaps in over the board; flex would beat [hidden] otherwise */ +.board[hidden] { + display: none; +} + .chain { width: 100%; } @@ -467,6 +472,285 @@ body { outline-offset: 2px; } +/* ---- unified search (§9.2) ------------------------------------------- */ + +.rail-search { + position: relative; + flex: 1; + max-width: 30rem; + display: flex; + align-items: center; +} + +.search-input { + width: 100%; + min-height: 2.75rem; + padding: 0 var(--space-8) 0 var(--space-3); + color: var(--ink); + background: var(--ground); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: inset 0 1px var(--lowlight); + caret-color: var(--accent); + transition: border-color 150ms var(--ease-out); +} + +.search-input::placeholder { + color: var(--ink-faint); + opacity: 1; +} + +.search-input:hover { + border-color: var(--line-strong); +} + +.search-input:focus { + border-color: var(--accent); + outline: none; +} + +.search-hint { + position: absolute; + right: var(--space-3); + padding: 0 var(--space-1); + font-size: var(--text-2xs); + color: var(--ink-faint); + border: 1px solid var(--line); + border-radius: var(--radius); + pointer-events: none; +} + +/* ---- search deck ------------------------------------------------------ */ + +.deck { + width: 100%; + max-width: 68rem; + margin: 0 auto; + padding: var(--space-8) var(--space-6) var(--space-12); + flex: 1; +} + +.deck-status { + margin: 0 0 var(--space-4); + color: var(--ink-muted); +} + +.deck-status[data-tone="fault"] { + color: var(--signal-fault); +} + +.deck-group { + margin: 0 0 var(--space-8); +} + +.deck-head { + display: flex; + align-items: baseline; + gap: var(--space-3); + padding: 0 var(--space-1) var(--space-2); + border-bottom: 1px solid var(--line); +} + +/* the engraved micro-label is the heading itself, not a kicker */ +.deck-label { + margin: 0; + font-size: var(--text-2xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--ink-muted); +} + +.deck-count { + font-size: var(--text-xs); + color: var(--ink-faint); +} + +.deck-rows { + margin: 0; + padding: 0; + list-style: none; +} + +.row { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: var(--space-1) var(--space-3); + width: 100%; + padding: var(--space-2) var(--space-1); + border-bottom: 1px solid oklch(from var(--line) l c h / 45%); + text-align: left; +} + +.row-id { + display: inline-flex; + align-items: baseline; + gap: var(--space-2); + min-width: 0; +} + +/* titles are names: the display face, per the Readout Rule in design.json */ +.row-title { + font-family: var(--font-display); + font-size: var(--text-md); + font-weight: 600; + letter-spacing: 0.02em; + color: var(--ink); +} + +.row-year { + font-size: var(--text-xs); +} + +.row-chips { + display: inline-flex; + align-items: center; + gap: var(--space-1); + margin-left: auto; +} + +.chip { + display: inline-flex; + align-items: center; + gap: var(--space-1); + min-height: 1.375rem; + padding: 0 var(--space-2); + background: var(--panel-raised); + border: 1px solid var(--line); + border-radius: var(--radius); + font-family: var(--font-readout); + font-size: var(--text-xs); + color: var(--ink-muted); +} + +.chip[data-verdict="eligible"] { + color: var(--verdict-eligible); + border-color: oklch(from var(--verdict-eligible) l c h / 45%); +} + +.chip[data-movie-state="imported"] { + color: var(--ink); +} + +/* a TMDB row is the affordance: the whole row opens the add flow */ +.row-tmdb { + font: inherit; + background: none; + border: 0; + border-bottom: 1px solid oklch(from var(--line) l c h / 45%); + cursor: pointer; + transition: background 150ms var(--ease-out); +} + +.row-tmdb:hover:not(:disabled), +.row-tmdb:focus-visible { + background: var(--panel); +} + +.row-tmdb:disabled { + cursor: default; +} + +.row-add { + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent); +} + +.row-overview { + flex-basis: 100%; + overflow: hidden; + font-size: var(--text-xs); + color: var(--ink-faint); + white-space: nowrap; + text-overflow: ellipsis; +} + +/* ---- add flow (inline, root and policy pre-filled) -------------------- */ + +.add-panel { + margin: 0 0 var(--space-2); + padding: var(--space-3) var(--space-3) var(--space-3); + background: var(--panel); + border: 1px solid var(--line); + border-top: 0; + border-radius: 0 0 var(--radius) var(--radius); + box-shadow: inset 0 1px var(--highlight); +} + +.add-roots { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: var(--space-2); +} + +.root-option { + display: grid; + gap: var(--space-1); + padding: var(--space-2) var(--space-3); + font: inherit; + text-align: left; + color: var(--ink); + background: var(--panel-raised); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: inset 0 1px var(--highlight); + cursor: pointer; + transition: border-color 150ms var(--ease-out); +} + +.root-option:hover { + border-color: var(--line-strong); +} + +.root-option[aria-pressed="true"] { + border-color: var(--accent); +} + +.root-audience { + font-family: var(--font-display); + font-size: var(--text-md); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + line-height: 1; +} + +.root-policy, +.root-path { + font-size: var(--text-xs); +} + +.add-actions { + display: flex; + align-items: center; + gap: var(--space-3); + margin: var(--space-3) 0 0; +} + +.add-note { + margin: 0; + font-size: var(--text-xs); + color: var(--ink-muted); +} + +.add-note[data-tone="fault"] { + color: var(--signal-fault); +} + +.add-done { + margin: 0; + color: var(--verdict-eligible); +} + +/* ---- manual intake ---------------------------------------------------- */ + +.intake .module-detail { + overflow-wrap: anywhere; +} + /* ---- small screens --------------------------------------------------- */ @media (max-width: 46rem) { @@ -480,6 +764,21 @@ body { order: 3; } + .rail-search { + order: 4; + flex-basis: 100%; + max-width: none; + } + + .deck { + padding: var(--space-6) var(--space-4) var(--space-8); + } + + .row-chips { + margin-left: 0; + flex-basis: 100%; + } + .board { padding: var(--space-6) var(--space-4); }