This commit was merged in pull request #75.
This commit is contained in:
@@ -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).
|
||||||
+50
@@ -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"
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
mod health;
|
mod health;
|
||||||
mod movies;
|
mod movies;
|
||||||
mod owners;
|
mod owners;
|
||||||
|
mod roots;
|
||||||
mod search;
|
mod search;
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ use utoipa_scalar::{Scalar, Servable};
|
|||||||
pub use health::{Check, Health, HealthReport, Status};
|
pub use health::{Check, Health, HealthReport, Status};
|
||||||
pub use movies::{Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, Release, UpdateMovie};
|
pub use movies::{Accepted, AttentionQueues, CreateMovie, ErrorBody, Movie, Release, UpdateMovie};
|
||||||
pub use owners::{CreateOwner, Owner, UpdateOwner};
|
pub use owners::{CreateOwner, Owner, UpdateOwner};
|
||||||
|
pub use roots::Root;
|
||||||
pub use search::{ClassifiedRelease, SearchResponse};
|
pub use search::{ClassifiedRelease, SearchResponse};
|
||||||
pub use state::{AppState, MovieCommand, Upstreams, DEFAULT_TMDB_URL};
|
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 = "system", description = "Service health and metadata"),
|
||||||
(name = "movies", description = "Movie library and actions"),
|
(name = "movies", description = "Movie library and actions"),
|
||||||
(name = "owners", description = "Owner tags and filtered views (DESIGN.md §4.3)"),
|
(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;
|
struct ApiDoc;
|
||||||
@@ -66,6 +69,7 @@ fn api_router() -> OpenApiRouter<AppState> {
|
|||||||
.routes(routes!(owners::get, owners::update, owners::delete))
|
.routes(routes!(owners::get, owners::update, owners::delete))
|
||||||
.routes(routes!(search::search))
|
.routes(routes!(search::search))
|
||||||
.routes(routes!(search::releases))
|
.routes(routes!(search::releases))
|
||||||
|
.routes(routes!(roots::list))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The generated `OpenAPI` document.
|
/// The generated `OpenAPI` document.
|
||||||
|
|||||||
@@ -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<AppState>) -> Result<Json<Vec<Root>>, 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<serde_json::Value> = 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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,12 +28,31 @@
|
|||||||
output in this environment).
|
output in this environment).
|
||||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the
|
FINISH: unreviewed and undocumented is unfinished; this build ends with the
|
||||||
finish review, the verdict, and DESIGN.md.
|
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.
|
||||||
-->
|
-->
|
||||||
<header class="rail">
|
<header class="rail">
|
||||||
<div class="rail-id">
|
<div class="rail-id">
|
||||||
<span class="lamp" id="master-lamp" data-state="probing" aria-hidden="true"></span>
|
<span class="lamp" id="master-lamp" data-state="probing" aria-hidden="true"></span>
|
||||||
<h1 class="wordmark">arr</h1>
|
<h1 class="wordmark">arr</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rail-search">
|
||||||
|
<input
|
||||||
|
id="search"
|
||||||
|
class="search-input readout"
|
||||||
|
type="text"
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="search · 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>
|
||||||
<p class="rail-verdict readout" id="master-verdict" role="status">probing chain…</p>
|
<p class="rail-verdict readout" id="master-verdict" role="status">probing chain…</p>
|
||||||
<div class="rail-meta">
|
<div class="rail-meta">
|
||||||
<span class="readout dim" id="version" data-testid="version">v —</span>
|
<span class="readout dim" id="version" data-testid="version">v —</span>
|
||||||
@@ -41,6 +60,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<main class="deck" id="deck" hidden>
|
||||||
|
<p class="deck-status readout" id="deck-status" role="status" hidden></p>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<span class="deck-count readout" id="count-library"></span>
|
||||||
|
</header>
|
||||||
|
<ul class="deck-rows" id="rows-library"></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<span class="deck-count readout" id="count-tmdb"></span>
|
||||||
|
</header>
|
||||||
|
<ul class="deck-rows" id="rows-tmdb"></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</header>
|
||||||
|
<article class="module intake" id="manual-intake"></article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
<main class="board">
|
<main class="board">
|
||||||
<section class="chain" aria-label="signal chain">
|
<section class="chain" aria-label="signal chain">
|
||||||
<article class="module area-tmdb" data-check="tmdb">
|
<article class="module area-tmdb" data-check="tmdb">
|
||||||
|
|||||||
+462
@@ -1,4 +1,14 @@
|
|||||||
import { type CheckStatus, type Probe, probeHealth } from "./health";
|
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";
|
import "./style.css";
|
||||||
|
|
||||||
const POLL_MS = 15_000;
|
const POLL_MS = 15_000;
|
||||||
@@ -154,6 +164,458 @@ function main() {
|
|||||||
void probe();
|
void probe();
|
||||||
}, POLL_MS);
|
}, POLL_MS);
|
||||||
void probe();
|
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<HTMLInputElement>("#search");
|
||||||
|
const hint = must<HTMLElement>("#search-hint");
|
||||||
|
const refs: DeckRefs = {
|
||||||
|
deck: must<HTMLElement>("#deck"),
|
||||||
|
status: must<HTMLElement>("#deck-status"),
|
||||||
|
groups: {
|
||||||
|
library: {
|
||||||
|
section: must<HTMLElement>("#group-library"),
|
||||||
|
count: must<HTMLElement>("#count-library"),
|
||||||
|
rows: must<HTMLUListElement>("#rows-library"),
|
||||||
|
},
|
||||||
|
tmdb: {
|
||||||
|
section: must<HTMLElement>("#group-tmdb"),
|
||||||
|
count: must<HTMLElement>("#count-tmdb"),
|
||||||
|
rows: must<HTMLUListElement>("#rows-tmdb"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manualSection: must<HTMLElement>("#group-manual"),
|
||||||
|
manualIntake: must<HTMLElement>("#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<HTMLElement>(".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<HTMLElement>(".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<Root[]>,
|
||||||
|
): 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<HTMLElement>(".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();
|
main();
|
||||||
|
|||||||
@@ -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<SearchOutcome> {
|
||||||
|
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<Root[]> {
|
||||||
|
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<AddOutcome> {
|
||||||
|
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<string> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -277,6 +277,11 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* the deck swaps in over the board; flex would beat [hidden] otherwise */
|
||||||
|
.board[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.chain {
|
.chain {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -467,6 +472,285 @@ body {
|
|||||||
outline-offset: 2px;
|
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 --------------------------------------------------- */
|
/* ---- small screens --------------------------------------------------- */
|
||||||
|
|
||||||
@media (max-width: 46rem) {
|
@media (max-width: 46rem) {
|
||||||
@@ -480,6 +764,21 @@ body {
|
|||||||
order: 3;
|
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 {
|
.board {
|
||||||
padding: var(--space-6) var(--space-4);
|
padding: var(--space-6) var(--space-4);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user