mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-25 18:25:12 +02:00
509 lines
13 KiB
TypeScript
509 lines
13 KiB
TypeScript
import CompanyPage from '$lib/pages/CollectionPages/CompanyPage.svelte';
|
|
import ListPage from '$lib/pages/CollectionPages/ListPage.svelte';
|
|
import NetworkPage from '$lib/pages/CollectionPages/NetworkPage.svelte';
|
|
import { linkedListToArray } from '$lib/utils';
|
|
import { Selectable } from '$lib/selectable';
|
|
import {
|
|
getContext as getSvelteContext,
|
|
setContext as setSvelteContext,
|
|
SvelteComponentTyped,
|
|
type ComponentType
|
|
} from 'svelte';
|
|
import { writable, type Readable, type Writable } from 'svelte/store';
|
|
import LibraryPage from '../../pages/LibraryPage/LibraryPage.svelte';
|
|
import ManagePage from '../../pages/ManagePage/ManagePage.svelte';
|
|
import MoviesHomePage from '../../pages/MoviesHomePage.svelte';
|
|
import PageNotFound from '../../pages/PageNotFound.svelte';
|
|
import PersonPage from '../../pages/PersonPage.svelte';
|
|
import SearchPage from '../../pages/SearchPage.svelte';
|
|
import SeriesHomePage from '../../pages/SeriesHomePage.svelte';
|
|
import EpisodePage from '../../pages/TitlePages/EpisodePage.svelte';
|
|
import MoviePage from '../../pages/TitlePages/MoviePage/MoviePage.svelte';
|
|
import SeriesPage from '../../pages/TitlePages/SeriesPage/SeriesPage.svelte';
|
|
import UiComponents from '../../pages/UIComponents.svelte';
|
|
import UsersPage from '../../pages/UsersPage.svelte';
|
|
|
|
export type CreateCompStackPage<TProps extends Record<string, unknown> = Record<string, unknown>> =
|
|
{
|
|
component: ComponentType<SvelteComponentTyped<TProps>>;
|
|
props: TProps;
|
|
|
|
id?: symbol;
|
|
group?: symbol | 'top';
|
|
|
|
preventScroll?: boolean;
|
|
trapFocus?: boolean;
|
|
sidebar?: boolean;
|
|
background?: boolean;
|
|
};
|
|
|
|
export interface CompStackPage extends CreateCompStackPage {
|
|
context: Record<string, unknown>;
|
|
pages: CompStackPage[];
|
|
|
|
hasContext: (key: string) => boolean;
|
|
getContext: <T = unknown>(key: string) => T;
|
|
setContext: <T = unknown>(key: string, value: T) => void;
|
|
getOrCreateContext: <T = unknown>(key: string, createValue: () => T) => T;
|
|
/** Handles initialization of context when mounting */
|
|
handleMount: () => void;
|
|
close: () => void;
|
|
push: <TProps extends Record<string, unknown>>(opts: CreateCompStackPage<TProps>) => void;
|
|
}
|
|
|
|
export interface CompStackForPageContext extends Readonly<CompStackPage> {
|
|
readonly root: Readable<Selectable | undefined>;
|
|
readonly hasFocusWithin: Readable<boolean>;
|
|
}
|
|
|
|
class RouteGroup {
|
|
pages: CompStackPage[] = [];
|
|
groups: RouteGroup[];
|
|
handleUpdate: () => void;
|
|
|
|
constructor(opts: {
|
|
groups: RouteGroup[];
|
|
route: Route;
|
|
props: Record<string, unknown>;
|
|
handleUpdate: () => void;
|
|
}) {
|
|
this.groups = opts.groups;
|
|
this.handleUpdate = opts.handleUpdate;
|
|
this.createRoutePage(opts.route, opts.props);
|
|
}
|
|
|
|
private hasContext(context: Record<string, unknown>) {
|
|
return (key: string): boolean => key in context;
|
|
}
|
|
|
|
private getContext(context: Record<string, unknown>) {
|
|
return <T = unknown>(key: string): T => context[key] as T;
|
|
}
|
|
|
|
private setContext(context: Record<string, unknown>) {
|
|
return <T = unknown>(key: string, value: T): void => {
|
|
context[key] = value;
|
|
};
|
|
}
|
|
|
|
private getOrCreateContext(context: Record<string, unknown>) {
|
|
return <T = unknown>(key: string, createValue: () => T): T => {
|
|
if (!(key in context)) {
|
|
context[key] = createValue();
|
|
}
|
|
return context[key] as T;
|
|
};
|
|
}
|
|
|
|
private close(id: symbol) {
|
|
const index = this.pages.findIndex((p) => p.id === id);
|
|
if (index !== -1) {
|
|
this.pages.splice(index, 1);
|
|
}
|
|
this.handleUpdate();
|
|
}
|
|
|
|
push(opts: CreateCompStackPage) {
|
|
const previousPage = this.pages.length > 0 ? this.pages[this.pages.length - 1] : undefined;
|
|
const previousContext = previousPage?.context ?? {};
|
|
const context = {};
|
|
|
|
const id = opts.id ?? Symbol();
|
|
|
|
const page: CompStackPage = {
|
|
id,
|
|
group: (opts.group === 'top' ? previousPage?.group : opts.group) ?? Symbol(),
|
|
component: opts.component,
|
|
props: opts.props,
|
|
preventScroll: opts.preventScroll ?? false,
|
|
trapFocus: opts.trapFocus ?? true,
|
|
sidebar: opts.sidebar ?? false,
|
|
context,
|
|
pages: this.pages,
|
|
hasContext: this.hasContext(context),
|
|
getContext: this.getContext(context),
|
|
setContext: this.setContext(context),
|
|
getOrCreateContext: this.getOrCreateContext(context),
|
|
handleMount: () => this.initializeContext(context, previousContext),
|
|
close: () => this.close(id),
|
|
push: (newOpts) => this.push(newOpts)
|
|
};
|
|
this.pages.push(page);
|
|
this.handleUpdate();
|
|
}
|
|
|
|
private createRoutePage(route: Route, props: Record<string, unknown>) {
|
|
const previousGroup = this.groups[this.groups.length - 1];
|
|
const previousContext = previousGroup?.pages[previousGroup.pages.length - 1]?.context ?? {};
|
|
|
|
const context: Record<string, unknown> = {};
|
|
const page: CompStackPage = {
|
|
id: Symbol(),
|
|
group: Symbol(),
|
|
component: route.component,
|
|
props,
|
|
preventScroll: false,
|
|
trapFocus: true,
|
|
sidebar: route.sidebar ?? true,
|
|
background: route.background ?? true,
|
|
context,
|
|
pages: this.pages,
|
|
hasContext: this.hasContext(context),
|
|
getContext: this.getContext(context),
|
|
setContext: this.setContext(context),
|
|
getOrCreateContext: this.getOrCreateContext(context),
|
|
handleMount: () => this.initializeContext(context, previousContext),
|
|
close: () => history.back(),
|
|
push: (opts) => this.push(opts)
|
|
};
|
|
this.pages.push(page);
|
|
// Don't update visibleStack yet, update history first
|
|
}
|
|
|
|
initializeContext(context: Record<string, unknown>, previousContext: Record<string, unknown>) {
|
|
if (context.didInitialize) return;
|
|
context.didInitialize = true;
|
|
|
|
for (const key in previousContext) {
|
|
if (!(key in context)) {
|
|
context[key] = previousContext[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
handleUnmount() {
|
|
this.pages.splice(1);
|
|
}
|
|
}
|
|
|
|
interface Route {
|
|
path: string;
|
|
component: ComponentType;
|
|
default?: boolean;
|
|
// When root is navigated to, stack is cleared
|
|
root?: boolean;
|
|
// Parent route, that is always rendered under this route, possibly sharing props that are a subset of the child's props.
|
|
// Child's props are also passed to these.
|
|
parent?: Route;
|
|
sidebar?: boolean;
|
|
background?: boolean;
|
|
}
|
|
|
|
// Defines what part of the stack is visible (not always the last x pages)
|
|
type HistoryState = {
|
|
// Id is used to identify if the history state belongs to the same stack instance, to deduce if stack has to be rebuilt
|
|
id: string;
|
|
end: number;
|
|
};
|
|
|
|
export type StackRouterStore = ReturnType<typeof useStackRouter>;
|
|
|
|
function useStackRouter({
|
|
routes,
|
|
notFound,
|
|
maxDepth
|
|
}: {
|
|
routes: Route[];
|
|
notFound: Route;
|
|
maxDepth?: number;
|
|
}) {
|
|
const groups: RouteGroup[] = [];
|
|
let historyState: HistoryState = {
|
|
id: Math.random().toString(36).slice(2),
|
|
end: 0
|
|
};
|
|
// Only updates when historyState changes
|
|
const visibleStack: Writable<Array<CompStackPage>> = writable([]);
|
|
|
|
initializeFromUrl();
|
|
|
|
function updateVisibleStack() {
|
|
const start = Math.max(0, historyState.end - (maxDepth ?? Infinity) + 1);
|
|
const newVisibleStack = groups
|
|
.slice(start, historyState.end + 1)
|
|
.map((g) => g.pages)
|
|
.flat();
|
|
|
|
// Unmount hidden
|
|
for (let i = 0; i < start; i++) {
|
|
groups[i]?.handleUnmount();
|
|
}
|
|
|
|
visibleStack.set(newVisibleStack);
|
|
}
|
|
|
|
function routeStringToRoutes(routeString: string): {
|
|
top: Route;
|
|
routes: Array<Route>;
|
|
params: Record<string, string>;
|
|
} {
|
|
let matchedRoute: Route = notFound;
|
|
let matchedParams: Record<string, string> = {};
|
|
|
|
for (const route of routes) {
|
|
const targetParts = routeString.split('/');
|
|
const routeParts = route.path.split('/');
|
|
const params: Record<string, string> = {};
|
|
|
|
if (targetParts.length !== routeParts.length) continue;
|
|
let i = 0;
|
|
while (i < targetParts.length) {
|
|
if (routeParts[i]?.startsWith(':')) {
|
|
const paramName = routeParts[i]?.slice(1);
|
|
if (paramName) {
|
|
// @ts-ignore
|
|
params[paramName] = targetParts[i];
|
|
}
|
|
} else if (routeParts[i] !== targetParts[i]) break;
|
|
i++;
|
|
}
|
|
if (i === targetParts.length) {
|
|
matchedRoute = route;
|
|
matchedParams = params;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const allRoutes = linkedListToArray(matchedRoute, (r) => r.parent).reverse();
|
|
|
|
return {
|
|
top: matchedRoute,
|
|
routes: allRoutes,
|
|
params: matchedParams
|
|
};
|
|
}
|
|
|
|
function initializeFromUrl() {
|
|
const initialUrl = window.location.pathname;
|
|
const { routes, params } = routeStringToRoutes(initialUrl);
|
|
|
|
groups.splice(0);
|
|
for (const route of routes) {
|
|
groups.push(
|
|
new RouteGroup({ groups, route, props: params, handleUpdate: updateVisibleStack })
|
|
);
|
|
}
|
|
|
|
historyState = {
|
|
id: Math.random().toString(36).slice(2),
|
|
end: groups.length - 1
|
|
};
|
|
|
|
history.replaceState(historyState, '', initialUrl);
|
|
updateVisibleStack();
|
|
}
|
|
|
|
const navigate = (
|
|
routeString: string,
|
|
options: { replaceStack?: boolean; refresh?: boolean } = {}
|
|
) => {
|
|
if (options.refresh) {
|
|
location.assign(routeString);
|
|
return;
|
|
}
|
|
|
|
const { top: topRoute, params } = routeStringToRoutes(routeString);
|
|
|
|
const replaceStack = options.replaceStack ?? topRoute.root ?? false;
|
|
|
|
const group = new RouteGroup({
|
|
groups,
|
|
route: topRoute,
|
|
props: params,
|
|
handleUpdate: updateVisibleStack
|
|
});
|
|
|
|
if (replaceStack) {
|
|
groups.splice(0);
|
|
groups.push(group);
|
|
historyState = {
|
|
id: Math.random().toString(36).slice(2),
|
|
end: 0
|
|
};
|
|
history.pushState(historyState, '', routeString);
|
|
} else {
|
|
groups.splice(historyState.end + 1, Infinity, group); // Remove any forward history
|
|
|
|
historyState.end = historyState.end + 1;
|
|
history.pushState(historyState, '', routeString);
|
|
}
|
|
|
|
updateVisibleStack();
|
|
};
|
|
|
|
/** Handle browser history navigation, https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event */
|
|
const handlePopState = (e: PopStateEvent) => {
|
|
const newHistory: HistoryState = e.state;
|
|
|
|
if (historyState.id === newHistory.id) {
|
|
historyState = newHistory;
|
|
updateVisibleStack();
|
|
} else {
|
|
// Rebuild stack
|
|
initializeFromUrl();
|
|
}
|
|
};
|
|
|
|
const push = <TProps extends Record<string, unknown> = Record<string, unknown>>(
|
|
opts: CreateCompStackPage<TProps>
|
|
) => {
|
|
const currentGroup = groups[historyState.end];
|
|
if (!currentGroup) {
|
|
throw new Error('No group to push to');
|
|
}
|
|
currentGroup.push(opts);
|
|
updateVisibleStack();
|
|
};
|
|
|
|
const back = () => {
|
|
if (historyState.end === 0) return;
|
|
|
|
history.back();
|
|
};
|
|
|
|
return {
|
|
subscribe: visibleStack.subscribe,
|
|
navigate,
|
|
push,
|
|
back,
|
|
handlePopState
|
|
};
|
|
}
|
|
|
|
const usersRoute: Route = {
|
|
path: '/users',
|
|
root: true,
|
|
component: UsersPage,
|
|
sidebar: false
|
|
};
|
|
|
|
const seriesHomeRoute: Route = {
|
|
path: '/series',
|
|
default: true,
|
|
root: true,
|
|
component: SeriesHomePage
|
|
};
|
|
|
|
const seriesRoute: Route = {
|
|
path: '/series/:id',
|
|
component: SeriesPage,
|
|
parent: seriesHomeRoute
|
|
};
|
|
|
|
const episodeRoute: Route = {
|
|
path: '/series/:id/season/:season/episode/:episode',
|
|
component: EpisodePage,
|
|
parent: seriesRoute
|
|
};
|
|
|
|
const networkRoute: Route = {
|
|
path: '/network/:network',
|
|
component: NetworkPage,
|
|
parent: seriesHomeRoute
|
|
};
|
|
|
|
const moviesHomeRoute: Route = {
|
|
path: '/movies',
|
|
component: MoviesHomePage,
|
|
root: true
|
|
};
|
|
|
|
const movieRoute: Route = {
|
|
path: '/movie/:id',
|
|
component: MoviePage,
|
|
parent: moviesHomeRoute
|
|
};
|
|
|
|
const collectionRoute: Route = {
|
|
path: '/collection/:collection',
|
|
component: ListPage,
|
|
parent: moviesHomeRoute
|
|
};
|
|
|
|
const companyRoute: Route = {
|
|
path: '/company/:company',
|
|
component: CompanyPage,
|
|
parent: moviesHomeRoute
|
|
};
|
|
|
|
const personRoute: Route = {
|
|
path: '/person/:id',
|
|
component: PersonPage
|
|
};
|
|
|
|
const libraryRoute: Route = {
|
|
path: '/library',
|
|
component: LibraryPage,
|
|
root: true
|
|
};
|
|
|
|
const searchRoute: Route = {
|
|
path: '/search',
|
|
component: SearchPage,
|
|
root: true
|
|
};
|
|
|
|
const manageRoute: Route = {
|
|
path: '/manage',
|
|
component: ManagePage,
|
|
root: true
|
|
};
|
|
|
|
const uiComponentsRoute: Route = {
|
|
path: '/ui-components',
|
|
component: UiComponents,
|
|
root: true
|
|
};
|
|
|
|
const notFoundRoute: Route = {
|
|
path: '/404',
|
|
component: PageNotFound,
|
|
root: true
|
|
};
|
|
|
|
export const stackRouter = useStackRouter({
|
|
routes: [
|
|
usersRoute,
|
|
seriesHomeRoute,
|
|
seriesRoute,
|
|
episodeRoute,
|
|
networkRoute,
|
|
moviesHomeRoute,
|
|
movieRoute,
|
|
collectionRoute,
|
|
companyRoute,
|
|
personRoute,
|
|
libraryRoute,
|
|
searchRoute,
|
|
manageRoute,
|
|
uiComponentsRoute
|
|
],
|
|
notFound: notFoundRoute
|
|
// maxDepth: 3
|
|
});
|
|
|
|
export const STACK_ROUTER_CONTEXT = 'stack-router-context';
|
|
|
|
export function _setComponentStack(stack: CompStackForPageContext) {
|
|
setSvelteContext<CompStackForPageContext>(STACK_ROUTER_CONTEXT, stack);
|
|
return stack;
|
|
}
|
|
|
|
export function useComponentStack() {
|
|
return getSvelteContext<CompStackForPageContext>(STACK_ROUTER_CONTEXT);
|
|
}
|
|
|
|
export function hasContext(key: string): boolean {
|
|
return useComponentStack().hasContext(key);
|
|
}
|
|
|
|
export function getContext<T = unknown>(key: string): T {
|
|
return useComponentStack().getContext(key);
|
|
}
|
|
|
|
export function setContext<T = unknown>(key: string, value: T): void {
|
|
return useComponentStack().setContext(key, value);
|
|
}
|
|
|
|
export const navigate = stackRouter.navigate;
|