7ef24088a5
Implements the §9.7 shell rulings (issue #173): "/" renders the library and the board route is gone — every former board target (Esc, cleared search, detail parent routes, the not-found fallback) lands on the library. The signal chain moves into /settings as its first section, lamps still driven by the health poll; the master lamp stays on the rail. The wordmark is now a real anchor that navigates home. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
119 lines
3.8 KiB
TypeScript
119 lines
3.8 KiB
TypeScript
// 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: "library" }
|
|
| { kind: "queues" }
|
|
| { kind: "settings" }
|
|
| { kind: "search"; query: string }
|
|
| { kind: "movie"; movieId: number }
|
|
| { kind: "releases"; movieId: number }
|
|
| { kind: "series"; seriesId: number }
|
|
| { kind: "seasonReleases"; seriesId: number; seasonNumber: number }
|
|
| { kind: "episodeReleases"; episodeId: number };
|
|
|
|
export function parseRoute(url: URL): Route {
|
|
const segments = url.pathname.split("/").filter(Boolean);
|
|
if (segments.length === 1 && segments[0] === "queues") {
|
|
return { kind: "queues" };
|
|
}
|
|
if (segments.length === 1 && segments[0] === "settings") {
|
|
return { kind: "settings" };
|
|
}
|
|
if (segments.length === 1 && segments[0] === "search") {
|
|
const query = url.searchParams.get("q") ?? "";
|
|
return query === "" ? { kind: "library" } : { kind: "search", query };
|
|
}
|
|
if (segments.length === 2 && segments[0] === "movies") {
|
|
const movieId = Number(segments[1]);
|
|
if (Number.isInteger(movieId) && movieId > 0) {
|
|
return { kind: "movie", movieId };
|
|
}
|
|
}
|
|
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 };
|
|
}
|
|
}
|
|
if (segments.length === 2 && segments[0] === "series") {
|
|
const seriesId = Number(segments[1]);
|
|
if (Number.isInteger(seriesId) && seriesId > 0) {
|
|
return { kind: "series", seriesId };
|
|
}
|
|
}
|
|
if (
|
|
segments.length === 5 &&
|
|
segments[0] === "series" &&
|
|
segments[2] === "seasons" &&
|
|
segments[4] === "releases"
|
|
) {
|
|
const seriesId = Number(segments[1]);
|
|
const seasonNumber = Number(segments[3]);
|
|
if (
|
|
Number.isInteger(seriesId) &&
|
|
seriesId > 0 &&
|
|
Number.isInteger(seasonNumber) &&
|
|
seasonNumber >= 0
|
|
) {
|
|
return { kind: "seasonReleases", seriesId, seasonNumber };
|
|
}
|
|
}
|
|
if (segments.length === 3 && segments[0] === "episodes" && segments[2] === "releases") {
|
|
const episodeId = Number(segments[1]);
|
|
if (Number.isInteger(episodeId) && episodeId > 0) {
|
|
return { kind: "episodeReleases", episodeId };
|
|
}
|
|
}
|
|
// §9.7: the library is the homepage — "/" and every unrecognised path land here
|
|
return { kind: "library" };
|
|
}
|
|
|
|
export function routePath(route: Route): string {
|
|
switch (route.kind) {
|
|
case "library":
|
|
return "/";
|
|
case "queues":
|
|
return "/queues";
|
|
case "settings":
|
|
return "/settings";
|
|
case "search":
|
|
return `/search?q=${encodeURIComponent(route.query)}`;
|
|
case "movie":
|
|
return `/movies/${route.movieId}`;
|
|
case "releases":
|
|
return `/movies/${route.movieId}/releases`;
|
|
case "series":
|
|
return `/series/${route.seriesId}`;
|
|
case "seasonReleases":
|
|
return `/series/${route.seriesId}/seasons/${route.seasonNumber}/releases`;
|
|
case "episodeReleases":
|
|
return `/episodes/${route.episodeId}/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);
|
|
}
|
|
}
|