replies timeline only, appview-less bluesky client
at main 4.3 kB view raw
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}