1import { html } from "./html";
2
3export interface Route {
4 path: string;
5 component: () => Promise<string> | string;
6 title?: string;
7 showInNavigation?: boolean;
8}
9
10export class Router {
11 private currentRoute: Route | null = null;
12 private loadedRoutes: Set<string> = new Set();
13 isLoading: boolean = false;
14 routes: Route[] = [];
15
16 constructor(private container: HTMLElement) {
17 window.addEventListener("popstate", () => this.handleRoute());
18 document.addEventListener("click", (e) => this.handleClick(e));
19 }
20
21 private async render() {
22 if (!this.currentRoute) return;
23
24 this.isLoading = true;
25 const preloaded = this.loadedRoutes.has(this.currentRoute.path);
26 if (!preloaded) {
27 this.container.style.opacity = "0.5";
28 this.container.style.pointerEvents = "none";
29 }
30
31 try {
32 const result = this.currentRoute.component();
33 const content = result instanceof Promise ? await result : result;
34 this.loadedRoutes.add(this.currentRoute.path);
35
36 if (!preloaded) await new Promise((resolve) => setTimeout(resolve, 150));
37 this.container.innerHTML = content;
38
39 if (!preloaded) {
40 this.container.style.opacity = "1";
41 this.container.style.pointerEvents = "auto";
42 }
43 } catch (error) {
44 console.error("failed to load page:", error);
45 this.container.innerHTML = this.getErrorContent();
46 this.container.style.opacity = "1";
47 this.container.style.pointerEvents = "auto";
48 } finally {
49 this.isLoading = false;
50 }
51 }
52
53 addRoute(route: Route) {
54 this.routes.push(route);
55 return this;
56 }
57
58 private handleClick(e: Event) {
59 const target = e.target as HTMLElement;
60 const link = target.closest("a[data-link]");
61
62 if (link) {
63 e.preventDefault();
64 const href = link.getAttribute("href");
65 if (href) {
66 this.navigate(href);
67 }
68 }
69 }
70
71 private handleRoute() {
72 const path = window.location.pathname;
73 const route =
74 this.routes.find((r) => r.path === path) ||
75 this.routes.find((r) => r.path === "*");
76
77 if (route) {
78 this.currentRoute = route;
79 this.render();
80 if (route.title) {
81 document.title = `${route.title} - willow!`;
82 }
83 this.updateNavigation(path);
84 }
85 }
86
87 updateNavigation = (currentPath: string) => {
88 const navLinks = document.querySelectorAll(
89 ".navigation-bar__nav a[data-link]",
90 );
91 navLinks.forEach((link) => {
92 const href = link.getAttribute("href");
93 if (href === currentPath) {
94 link.classList.add("active");
95 } else {
96 link.classList.remove("active");
97 }
98 });
99 };
100
101 navigate(path: string) {
102 window.history.pushState({}, "", path);
103 this.handleRoute();
104 }
105
106 private getErrorContent() {
107 return html`
108 <div class="error">
109 <h1>error</h1>
110 <p>failed to load page. please try again.</p>
111 <div class="error__actions">
112 <a href="/" class="button__pill" data-link>go home</a>
113 <button class="button__pill reload" onclick="location.reload()">
114 reload
115 </button>
116 </div>
117 </div>
118 `;
119 }
120
121 start() {
122 this.handleRoute();
123 }
124}