feat(web): add client-side routes for views
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:
+96
-10
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user