1/* eslint-disable svelte/no-navigation-without-resolve */
2import { pushState, replaceState } from '$app/navigation';
3import { SvelteMap } from 'svelte/reactivity';
4
5export const routes = [
6 { path: '/', order: 0 },
7 { path: '/following', order: 1 },
8 { path: '/notifications', order: 2 },
9 { path: '/settings/:tab', order: 3 },
10 { path: '/profile/:actor', order: 4 }
11] as const;
12
13export type RouteConfig = (typeof routes)[number];
14export type RoutePath = RouteConfig['path'];
15
16type ExtractParams<Path extends string> =
17 // eslint-disable-next-line @typescript-eslint/no-unused-vars
18 Path extends `${infer Start}/:${infer Param}/${infer Rest}`
19 ? { [K in Param | keyof ExtractParams<`/${Rest}`>]: string }
20 : // eslint-disable-next-line @typescript-eslint/no-unused-vars
21 Path extends `${infer Start}/:${infer Param}`
22 ? { [K in Param]: string }
23 : Record<string, never>;
24
25export type Route<K extends RoutePath = RoutePath> = {
26 [T in K]: {
27 params: ExtractParams<T>;
28 path: T;
29 order: number;
30 url: string;
31 };
32}[K];
33
34type RouteNode = {
35 children: Map<string, RouteNode>;
36 paramName?: string;
37 paramChild?: RouteNode;
38 config?: RouteConfig;
39};
40
41const fallbackRoute: Route<'/'> = {
42 params: {},
43 path: '/',
44 order: 0,
45 url: '/'
46};
47
48export class Router {
49 current = $state<Route>(fallbackRoute);
50
51 direction = $state<'left' | 'right' | 'none'>('none');
52 scrollPositions = new SvelteMap<string, number>();
53 // eslint-disable-next-line svelte/prefer-svelte-reactivity
54 private root: RouteNode = { children: new Map() };
55
56 constructor() {
57 for (const route of routes) this.addRoute(route);
58 }
59
60 private addRoute(config: RouteConfig) {
61 const segments = config.path.split('/').filter(Boolean);
62 let node = this.root;
63
64 for (const segment of segments) {
65 if (segment.startsWith(':')) {
66 const paramName = segment.slice(1);
67 // eslint-disable-next-line svelte/prefer-svelte-reactivity
68 if (!node.paramChild) node.paramChild = { children: new Map(), paramName };
69 node = node.paramChild;
70 } else {
71 // eslint-disable-next-line svelte/prefer-svelte-reactivity
72 if (!node.children.has(segment)) node.children.set(segment, { children: new Map() });
73 node = node.children.get(segment)!;
74 }
75 }
76 node.config = config;
77 }
78
79 init() {
80 if (typeof window === 'undefined') return;
81 // initialize state
82 this._updateState(window.location.pathname);
83 // update state on browser navigation
84 window.addEventListener('popstate', () => this._updateState(window.location.pathname));
85 }
86
87 match(urlPath: string): Route | undefined {
88 const segments = urlPath.split('/').filter(Boolean);
89 const params: Record<string, string> = {};
90
91 let node = this.root;
92
93 for (const segment of segments) {
94 if (node.children.has(segment)) {
95 node = node.children.get(segment)!;
96 } else if (node.paramChild) {
97 node = node.paramChild;
98 if (node.paramName) params[node.paramName] = decodeURIComponent(segment);
99 } else {
100 return undefined;
101 }
102 }
103
104 if (node.config)
105 return {
106 params: params as unknown,
107 path: node.config.path,
108 order: node.config.order,
109 url: urlPath
110 } as Route<typeof node.config.path>;
111
112 return undefined;
113 }
114
115 updateDirection(newOrder: number, oldOrder: number) {
116 if (newOrder === oldOrder) this.direction = 'none';
117 else if (newOrder > oldOrder) this.direction = 'right';
118 else this.direction = 'left';
119 }
120
121 private _updateState(url: string) {
122 const target = this.match(url);
123 if (!target) return;
124
125 // save scroll position
126 if (typeof window !== 'undefined') this.scrollPositions.set(this.current.url, window.scrollY);
127
128 this.updateDirection(target.order, this.current.order);
129 this.current = target;
130
131 if (typeof window !== 'undefined') {
132 setTimeout(() => {
133 const savedScroll = this.scrollPositions.get(target.url) ?? 0;
134 window.scrollTo({ top: savedScroll, behavior: 'auto' });
135 }, 0);
136 }
137 }
138
139 navigate(url: string, { replace = false } = {}) {
140 if (typeof window === 'undefined') return;
141 if (this.current.url === url) return;
142
143 if (replace) replaceState(url, {});
144 else pushState(url, {});
145
146 this._updateState(url);
147 }
148
149 replace(url: string) {
150 this.navigate(url, { replace: true });
151 }
152
153 back() {
154 if (typeof window !== 'undefined') history.back();
155 }
156}