feat(web): add client-side routes for views
ci / web (push) Successful in 27s
ci / rust (push) Successful in 53s
e2e / e2e (push) Successful in 1m1s

Deep-link, refresh and back/forward now land on the view the operator
was on instead of the board. Vanilla History API (pushState/popstate),
no router dependency: /library, /queues, /movies/{id}/releases and
/search?q= all restore on load.

Closes #102
This commit is contained in:
Miguel Palhas
2026-08-23 08:24:36 +01:00
parent 1ce9f11e62
commit e36c73ca75
3 changed files with 179 additions and 10 deletions
+96 -10
View File
@@ -35,9 +35,11 @@ import {
waiveAndGrab,
waiverOverride,
} from "./releases";
import { currentRoute, navigate, type Route } from "./router";
import {
addMovie,
allRoots,
fetchMovie,
type LibraryMovie,
movieRoots,
parseManualInput,
@@ -211,11 +213,50 @@ function main() {
const library = libraryMain(board, releases, views);
const queues = queuesMain(board, releases, views);
views.push(library, queues);
searchMain(board, releases, views);
const search = searchMain(board, releases, views);
refreshQueuesBadge = () => {
void queues.refreshBadge();
};
void queues.refreshBadge();
// popstate and the initial load both dispatch through here, so refresh
// and back/forward reach the same view a click would have.
async function applyRoute(route: Route) {
switch (route.kind) {
case "board":
search.showIdle();
break;
case "library":
library.open();
break;
case "queues":
queues.open();
break;
case "search":
search.restore(route.query);
break;
case "releases": {
const movie = await fetchMovie(route.movieId);
if (!movie) {
navigate({ kind: "library" }, { replace: true });
library.open();
return;
}
// no origin click to restore focus to on a deep link — the library
// rail button is the closest stand-in
library.open();
releases.open(movie, must<HTMLElement>("#nav-library"), must<HTMLElement>("#library"), {
kind: "library",
});
break;
}
}
}
window.addEventListener("popstate", () => {
void applyRoute(currentRoute());
});
void applyRoute(currentRoute());
}
interface HideableView {
@@ -235,7 +276,14 @@ interface DeckRefs {
manualIntake: HTMLElement;
}
function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableView[]) {
interface SearchView {
/** Restores the board with the search box empty — the "/" route. */
showIdle: () => void;
/** Restores the deck for `query` — the "/search?q=" route. */
restore: (query: string) => void;
}
function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableView[]): SearchView {
const input = must<HTMLInputElement>("#search");
const hint = must<HTMLElement>("#search-hint");
const refs: DeckRefs = {
@@ -354,7 +402,8 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV
for (const movie of response.library) {
refs.groups.library.rows.append(
libraryRow(movie, roots, (target, origin) => {
releases.open(target, origin, refs.deck);
navigate({ kind: "releases", movieId: target.id });
releases.open(target, origin, refs.deck, { kind: "search", query: input.value.trim() });
}),
);
}
@@ -372,12 +421,14 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV
window.clearTimeout(timer);
const query = input.value.trim();
if (query === "") {
navigate({ kind: "board" });
showBoard();
clearGroups();
setStatus(null);
return;
}
timer = window.setTimeout(() => {
navigate({ kind: "search", query }, { replace: true });
void run(query);
}, DEBOUNCE_MS);
});
@@ -387,6 +438,7 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV
window.clearTimeout(timer);
const query = input.value.trim();
if (query !== "") {
navigate({ kind: "search", query }, { replace: true });
void run(query);
}
} else if (event.key === "ArrowDown") {
@@ -397,6 +449,7 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV
}
} else if (event.key === "Escape") {
input.value = "";
navigate({ kind: "board" });
showBoard();
clearGroups();
setStatus(null);
@@ -422,6 +475,7 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV
input.select();
} else if (event.key === "Escape" && !refs.deck.hidden) {
input.value = "";
navigate({ kind: "board" });
showBoard();
clearGroups();
setStatus(null);
@@ -446,6 +500,20 @@ function searchMain(board: HTMLElement, releases: ReleasesView, views: HideableV
}
rows[index + (event.key === "ArrowDown" ? 1 : -1)]?.focus();
});
function showIdle() {
input.value = "";
showBoard();
clearGroups();
setStatus(null);
}
function restore(query: string) {
input.value = query;
void run(query);
}
return { showIdle, restore };
}
function chip(text: string, extra?: (chip: HTMLSpanElement) => void): HTMLSpanElement {
@@ -678,8 +746,13 @@ function addPanel(movie: TmdbMovie, roots: Root[], row: HTMLButtonElement): HTML
/* ---- release deck (§9.3, issue #31) ---------------------------------- */
interface ReleasesView {
/** Opens over `returnTo`, which back and Esc restore. */
open: (movie: LibraryMovie, origin: HTMLElement, returnTo: HTMLElement) => void;
/** Opens over `returnTo`, which back and Esc restore; `parentRoute` is where they navigate. */
open: (
movie: LibraryMovie,
origin: HTMLElement,
returnTo: HTMLElement,
parentRoute: Route,
) => void;
hide: () => void;
}
@@ -719,6 +792,7 @@ function releasesMain(): ReleasesView {
let current: LibraryMovie | null = null;
let origin: HTMLElement | null = null;
let returnTo: HTMLElement = deck;
let parentRoute: Route = { kind: "board" };
const actions: ReleaseActions = {
reload: () => load(),
notify: (text, tone) => setStatus(text, tone),
@@ -902,10 +976,11 @@ function releasesMain(): ReleasesView {
}, SWEEP_POLL_MS);
}
function open(movie: LibraryMovie, from: HTMLElement, container: HTMLElement) {
function open(movie: LibraryMovie, from: HTMLElement, container: HTMLElement, parent: Route) {
current = movie;
origin = from;
returnTo = container;
parentRoute = parent;
title.textContent = movie.title;
year.textContent = movie.year === null ? "—" : String(movie.year);
container.hidden = true;
@@ -925,6 +1000,7 @@ function releasesMain(): ReleasesView {
function close() {
const target = origin;
navigate(parentRoute);
hide();
returnTo.hidden = false;
target?.focus();
@@ -1122,6 +1198,7 @@ function releaseRow(
interface LibraryView {
hide: () => void;
open: () => void;
}
interface LibraryGroup {
@@ -1209,7 +1286,8 @@ function libraryMain(
renderGroup(groups.series, seriesShown, (series) => seriesRow(series, roots));
renderGroup(groups.movies, moviesShown, (movie) =>
libraryRow(movie, roots, (target, origin) => {
releases.open(target, origin, view);
navigate({ kind: "releases", movieId: target.id });
releases.open(target, origin, view, { kind: "library" });
}),
);
@@ -1269,8 +1347,10 @@ function libraryMain(
nav.addEventListener("click", () => {
if (view.hidden) {
navigate({ kind: "library" });
open();
} else {
navigate({ kind: "board" });
close();
}
});
@@ -1286,13 +1366,14 @@ function libraryMain(
(event) => {
if (event.key === "Escape" && !view.hidden) {
event.stopImmediatePropagation();
navigate({ kind: "board" });
close();
}
},
true,
);
return { hide };
return { hide, open };
}
/**
@@ -1332,6 +1413,7 @@ function seriesRow(series: LibrarySeries, roots: Root[]): HTMLLIElement {
interface QueuesView {
hide: () => void;
open: () => void;
/** Refetches both queues and repaints the rail badge. */
refreshBadge: () => Promise<void>;
}
@@ -1415,7 +1497,8 @@ function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableV
}
const openReleases = (movie: LibraryMovie, origin: HTMLElement) => {
releases.open(movie, origin, view);
navigate({ kind: "releases", movieId: movie.id });
releases.open(movie, origin, view, { kind: "queues" });
};
/** Drops an emptied item without a refetch: the override IS written. */
@@ -1496,8 +1579,10 @@ function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableV
nav.addEventListener("click", () => {
if (view.hidden) {
navigate({ kind: "queues" });
open();
} else {
navigate({ kind: "board" });
close();
}
});
@@ -1508,6 +1593,7 @@ function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableV
(event) => {
if (event.key === "Escape" && !view.hidden) {
event.stopImmediatePropagation();
navigate({ kind: "board" });
close();
}
},
@@ -1522,7 +1608,7 @@ function queuesMain(board: HTMLElement, releases: ReleasesView, views: HideableV
paintBadge(attentionTotal(outcome.queues));
}
return { hide, refreshBadge };
return { hide, open, refreshBadge };
}
/**
+70
View File
@@ -0,0 +1,70 @@
// Vanilla History API routing, no router dependency (see the routes issue). The URL
// is the source of truth: every route is derived from `location`, never from
// state passed to pushState, so a manually edited or copy-pasted URL works.
export type Route =
| { kind: "board" }
| { kind: "library" }
| { kind: "queues" }
| { kind: "search"; query: string }
| { kind: "releases"; movieId: number };
export function parseRoute(url: URL): Route {
const segments = url.pathname.split("/").filter(Boolean);
if (segments.length === 1 && segments[0] === "library") {
return { kind: "library" };
}
if (segments.length === 1 && segments[0] === "queues") {
return { kind: "queues" };
}
if (segments.length === 1 && segments[0] === "search") {
const query = url.searchParams.get("q") ?? "";
return query === "" ? { kind: "board" } : { kind: "search", query };
}
if (segments.length === 3 && segments[0] === "movies" && segments[2] === "releases") {
const movieId = Number(segments[1]);
if (Number.isInteger(movieId) && movieId > 0) {
return { kind: "releases", movieId };
}
}
return { kind: "board" };
}
export function routePath(route: Route): string {
switch (route.kind) {
case "board":
return "/";
case "library":
return "/library";
case "queues":
return "/queues";
case "search":
return `/search?q=${encodeURIComponent(route.query)}`;
case "releases":
return `/movies/${route.movieId}/releases`;
}
}
export function currentRoute(): Route {
return parseRoute(new URL(location.href));
}
/**
* Updates the URL to `route`. `replace: true` rewrites the current entry
* instead of pushing one — used for search-as-you-type, so every keystroke
* doesn't add a back-stack entry.
*/
export function navigate(route: Route, options: { replace?: boolean } = {}) {
const path = routePath(route);
if (location.pathname + location.search === path) {
if (options.replace) {
history.replaceState(null, "", path);
}
return;
}
if (options.replace) {
history.replaceState(null, "", path);
} else {
history.pushState(null, "", path);
}
}
+13
View File
@@ -64,6 +64,19 @@ export async function searchTitles(query: string, signal: AbortSignal): Promise<
}
}
/** A single movie by id — used to restore the release deck from a URL alone. */
export async function fetchMovie(movieId: number): Promise<LibraryMovie | null> {
try {
const response = await fetch(`/api/movies/${movieId}`);
if (!response.ok) {
return null;
}
return (await response.json()) as LibraryMovie;
} catch {
return null;
}
}
let rootsCache: Root[] | null = null;
/** Every root, fetched once. Audience chips resolve root ids through these. */