From 5615049a1534333604bc8b7b56a9047506515449 Mon Sep 17 00:00:00 2001 From: Maieul BOYER Date: Mon, 22 Dec 2025 12:19:32 +0100 Subject: [PATCH 1/2] clean up some stuff, moved around other --- frontend/index.html | 5 +- frontend/public/vite.svg | 1 - frontend/src/@types/dom.d.ts | 25 ++++++--- frontend/src/@types/ft_state.d.ts | 4 ++ frontend/src/api/index.ts | 12 +++-- frontend/src/{auth/index.ts => auth.ts} | 16 ++++-- frontend/src/chat/index.ts | 2 - frontend/src/{ => pages}/chat/chat.css | 2 +- frontend/src/pages/chat/chat.ts | 1 + frontend/src/pages/profile/profile.ts | 2 +- frontend/src/routing/index.ts | 62 +++++++++-------------- frontend/src/routing/special_routes.ts | 2 +- frontend/src/signin/alreadyLoggedin.html | 24 --------- frontend/src/{toast/index.ts => toast.ts} | 0 frontend/src/typescript.svg | 1 - frontend/src/utils.ts | 5 ++ 16 files changed, 80 insertions(+), 84 deletions(-) delete mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/@types/ft_state.d.ts rename frontend/src/{auth/index.ts => auth.ts} (79%) delete mode 100644 frontend/src/chat/index.ts rename frontend/src/{ => pages}/chat/chat.css (99%) delete mode 100644 frontend/src/signin/alreadyLoggedin.html rename frontend/src/{toast/index.ts => toast.ts} (100%) delete mode 100644 frontend/src/typescript.svg diff --git a/frontend/index.html b/frontend/index.html index 97b7829..2654fa3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -49,9 +49,8 @@ - - - + + diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/@types/dom.d.ts b/frontend/src/@types/dom.d.ts index 1c35dd9..49c1fcf 100644 --- a/frontend/src/@types/dom.d.ts +++ b/frontend/src/@types/dom.d.ts @@ -1,14 +1,27 @@ +import { type State } from "ft_state"; + interface CustomEventMap { - "ft:pageChange": CustomEvent<{}>; + "ft:pageChange": CustomEvent<{}>; } declare global { - interface Document { //adds definition to Document, but you can do the same with HTMLElement - addEventListener(type: K, - listener: (this: Document, ev: CustomEventMap[K]) => void): void; - dispatchEvent(ev: CustomEventMap[K]): void; - } + interface Document { + //adds definition to Document, but you can do the same with HTMLElement + addEventListener( + type: K, + listener: (this: Document, ev: CustomEventMap[K]) => void, + ): void; + dispatchEvent( + ev: CustomEventMap[K], + ): void; + } } +declare global { + interface Window { + __state: State; + } +} export { }; //keep that for TS compiler. + diff --git a/frontend/src/@types/ft_state.d.ts b/frontend/src/@types/ft_state.d.ts new file mode 100644 index 0000000..d79360a --- /dev/null +++ b/frontend/src/@types/ft_state.d.ts @@ -0,0 +1,4 @@ +declare module 'ft_state' { + interface GlobalState { + }; +}; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 6f09284..581a0e2 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,6 +1,14 @@ +import { ensureWindowState } from '@app/utils'; import { Configuration, OpenapiOtherApi } from './generated'; export * from './generated' +ensureWindowState(); + +declare module 'ft_state' { + interface State { + client: OpenapiOtherApi; + } +} const basePath = (() => { let u = new URL(location.href); @@ -8,11 +16,9 @@ const basePath = (() => { u.hash = ""; u.search = ""; return u.toString().replace(/\/+$/, ''); - })(); export const client = new OpenapiOtherApi(new Configuration({ basePath })); export default client; -Object.assign(window as any, { apiClient: client }); - +window.__state.client ??= client; diff --git a/frontend/src/auth/index.ts b/frontend/src/auth.ts similarity index 79% rename from frontend/src/auth/index.ts rename to frontend/src/auth.ts index 008e94d..8fdedb6 100644 --- a/frontend/src/auth/index.ts +++ b/frontend/src/auth.ts @@ -1,9 +1,13 @@ import { showError } from "@app/toast"; import client from '@app/api'; import cookie from 'js-cookie'; +import { ensureWindowState } from "@app/utils"; cookie.remove('pkce'); +ensureWindowState(); +window.__state.user ??= null; + export type User = { id: string; guest: boolean; @@ -15,18 +19,22 @@ export type User = { } }; -let currentUser: User | null = null; +declare module 'ft_state' { + interface State { + user: User | null, + } +}; export function getUser(): Readonly | null { - return currentUser; + return window.__state.user; } export function isLogged(): boolean { - return currentUser !== null; + return window.__state.user !== null; } export function setUser(newUser: User | null) { - currentUser = newUser; + window.__state.user = newUser; } export async function updateUser(): Promise | null> { diff --git a/frontend/src/chat/index.ts b/frontend/src/chat/index.ts deleted file mode 100644 index 0ff997d..0000000 --- a/frontend/src/chat/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import './chat.css'; - \ No newline at end of file diff --git a/frontend/src/chat/chat.css b/frontend/src/pages/chat/chat.css similarity index 99% rename from frontend/src/chat/chat.css rename to frontend/src/pages/chat/chat.css index 29f0274..d71cb55 100644 --- a/frontend/src/chat/chat.css +++ b/frontend/src/pages/chat/chat.css @@ -282,4 +282,4 @@ div-private { .hidden{ display: none; -} \ No newline at end of file +} diff --git a/frontend/src/pages/chat/chat.ts b/frontend/src/pages/chat/chat.ts index 2880b4e..07f4537 100644 --- a/frontend/src/pages/chat/chat.ts +++ b/frontend/src/pages/chat/chat.ts @@ -1,3 +1,4 @@ +import "./chat.css"; import { addRoute, setTitle, type RouteHandlerParams, type RouteHandlerReturn } from "@app/routing"; import { showError } from "@app/toast"; import authHtml from './chat.html?raw'; diff --git a/frontend/src/pages/profile/profile.ts b/frontend/src/pages/profile/profile.ts index 7241eeb..b6f573f 100644 --- a/frontend/src/pages/profile/profile.ts +++ b/frontend/src/pages/profile/profile.ts @@ -1,4 +1,4 @@ -import { addRoute, getRoute, handleRoute, navigateTo, setTitle } from "@app/routing"; +import { addRoute, handleRoute, navigateTo, setTitle } from "@app/routing"; import { showError, showSuccess } from "@app/toast"; import page from "./profile.html?raw"; import { updateUser } from "@app/auth"; diff --git a/frontend/src/routing/index.ts b/frontend/src/routing/index.ts index 02cd433..677e3fb 100644 --- a/frontend/src/routing/index.ts +++ b/frontend/src/routing/index.ts @@ -1,6 +1,20 @@ import { updateUser } from '@app/auth'; import { route_404 } from './special_routes' +import { ensureWindowState } from '@app/utils'; +ensureWindowState(); + +declare module 'ft_state' { + interface State { + routes: Map, + title: string, + titleElem: HTMLDivElement, + } +}; + +window.__state.routes ??= new Map(); +window.__state.title ??= ""; +window.__state.titleElem ??= document.querySelector('#header-title')!; // ---- Router logic ---- export function navigateTo(url: string) { @@ -101,44 +115,18 @@ function urlToParts(url: string): string[] { return parts.filter(p => p.length !== 0); } -function setupRoutes(): [ - () => Routes, - (url: string, handler: RouteHandler, args?: Partial) => void -] { - const routes = new Map(); - - return [ - () => routes, - (url: string, handler: RouteHandler | string, args?: Partial) => { - let d = new RouteHandlerData(url, handler, args ?? {}); - if (routes.has(d.url)) - throw `Tried to insert route ${url}, but it already exists`; - routes.set(d.url, d); - } - ]; -} - -function setupTitle(): [ - () => string, - (title: string) => void, -] { - let title = ""; - let titleElem = document.querySelector('#header-title')!; - return [ - () => title, - (new_title) => { - title = new_title; - titleElem.innerText = title; - } - ] +export function addRoute(url: string, handler: RouteHandler | string, args?: Partial) { + let d = new RouteHandlerData(url, handler, args ?? {}); + if (window.__state.routes.has(d.url)) + throw `Tried to insert route ${url}, but it already exists`; + window.__state.routes.set(d.url, d); } -export const [getRoute, addRoute] = setupRoutes(); - -export const [getTitle, setTitle] = setupTitle(); - -(window as any).getRoute = getRoute; +export function setTitle(title: string) { + window.__state.title = title; + window.__state.titleElem.innerText = window.__state.title; +} const executeRouteHandler = async (handler: RouteHandlerData, ...args: Parameters): Promise => { // handler may be a raw string literal, if yes => return it directly @@ -169,7 +157,7 @@ function parts_match(route_parts: (string | null)[], parts: string[]): boolean { } export async function handleRoute() { - let routes = getRoute(); + let routes = window.__state.routes; let parts = urlToParts(window.location.pathname); let routes_all = routes.entries(); @@ -222,4 +210,4 @@ document.addEventListener('click', e => { // ---- Handle browser navigation (back/forward) ---- window.addEventListener('popstate', handleRoute); -Object.assign((window as any), { getTitle, setTitle, getRoute, addRoute, navigateTo }) +Object.assign((window as any), { setTitle, addRoute, navigateTo }) diff --git a/frontend/src/routing/special_routes.ts b/frontend/src/routing/special_routes.ts index 5b7b7f6..dbda9d1 100644 --- a/frontend/src/routing/special_routes.ts +++ b/frontend/src/routing/special_routes.ts @@ -1,5 +1,5 @@ import { escapeHTML } from "@app/utils"; -import { getRoute, type RouteHandlerParams } from "@app/routing"; +import { type RouteHandlerParams } from "@app/routing"; export async function route_404(url: string, _args: RouteHandlerParams): Promise { return ` diff --git a/frontend/src/signin/alreadyLoggedin.html b/frontend/src/signin/alreadyLoggedin.html deleted file mode 100644 index 5d8b1dc..0000000 --- a/frontend/src/signin/alreadyLoggedin.html +++ /dev/null @@ -1,24 +0,0 @@ -
-
-

You are already logged in

- -

Want to logout ? Click the big button bellow !

- -

Otherwise, here is a cute cat picture

- -
-
diff --git a/frontend/src/toast/index.ts b/frontend/src/toast.ts similarity index 100% rename from frontend/src/toast/index.ts rename to frontend/src/toast.ts diff --git a/frontend/src/typescript.svg b/frontend/src/typescript.svg deleted file mode 100644 index d91c910..0000000 --- a/frontend/src/typescript.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index d35bb00..80a3888 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -6,3 +6,8 @@ export function escapeHTML(str: string): string { export function isNullish(v: T | undefined | null): v is (null | undefined) { return v === null || v === undefined; } + +// MAKE SURE YOU DO WANT TO CALL THIS FUNCTION +export function ensureWindowState() { + window.__state = window.__state ?? {}; +} From 55fc1bad1f98ad4c504650e5f6c9b0dabeda23fb Mon Sep 17 00:00:00 2001 From: Maieul BOYER Date: Mon, 22 Dec 2025 13:55:34 +0100 Subject: [PATCH 2/2] moved around some more files --- frontend/index.html | 4 +- frontend/src/{carousel => }/carousel.css | 0 .../src/{carousel/index.ts => carousel.ts} | 0 frontend/src/routing.ts | 257 ++++++++++++++++++ frontend/src/routing/index.ts | 213 --------------- frontend/src/routing/special_routes.ts | 10 - 6 files changed, 259 insertions(+), 225 deletions(-) rename frontend/src/{carousel => }/carousel.css (100%) rename frontend/src/{carousel/index.ts => carousel.ts} (100%) create mode 100644 frontend/src/routing.ts delete mode 100644 frontend/src/routing/index.ts delete mode 100644 frontend/src/routing/special_routes.ts diff --git a/frontend/index.html b/frontend/index.html index 2654fa3..675d9f9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -46,9 +46,9 @@ - - + + diff --git a/frontend/src/carousel/carousel.css b/frontend/src/carousel.css similarity index 100% rename from frontend/src/carousel/carousel.css rename to frontend/src/carousel.css diff --git a/frontend/src/carousel/index.ts b/frontend/src/carousel.ts similarity index 100% rename from frontend/src/carousel/index.ts rename to frontend/src/carousel.ts diff --git a/frontend/src/routing.ts b/frontend/src/routing.ts new file mode 100644 index 0000000..94cd460 --- /dev/null +++ b/frontend/src/routing.ts @@ -0,0 +1,257 @@ +import { updateUser } from "@app/auth"; +import { ensureWindowState, escapeHTML } from "@app/utils"; + +ensureWindowState(); + +declare module "ft_state" { + interface State { + routes: Map; + title: string; + titleElem: HTMLDivElement; + _routingHandler: boolean; + } +} + +window.__state.routes ??= new Map(); +window.__state.title ??= ""; +window.__state.titleElem ??= document.querySelector("#header-title")!; +window.__state._routingHandler ??= false; + +// ---- Router logic ---- +export function navigateTo(url: string) { + if (url.startsWith("/") && !url.startsWith("/app")) url = `/app${url}`; + history.pushState(null, "", url); + handleRoute(); +} + +type AsyncFunctionMaker any> = ( + ...args: Parameters +) => Promise>; + +export type RouteHandlerParams = { [k: string]: string }; + +export type SyncRouteHandlerPostInsertFn = (appNode?: HTMLElement) => void; +export type AsyncRouteHandlerPostInsertFn = + AsyncFunctionMaker; +export type RouteHandlerReturn = { + html: string; + postInsert?: SyncRouteHandlerPostInsertFn | AsyncRouteHandlerPostInsertFn; +}; + +export type SyncRouteHandler = ( + url: string, + args: RouteHandlerParams, +) => RouteHandlerReturn | string; +export type AsyncRouteHandler = AsyncFunctionMaker; +export type RouteHandler = string | SyncRouteHandler | AsyncRouteHandler; +export type Routes = Map; +export type RouteHandlerSpecialArgs = { + bypass_auth: boolean; +}; + +export class RouteHandlerData { + public readonly handler: RouteHandler; + public readonly url: string; + public readonly parts: (string | null)[]; + public readonly args: (string | null)[]; + public readonly orignal_url: string; + public readonly special_args: RouteHandlerSpecialArgs; + + public static SPECIAL_ARGS_DEFAULT: RouteHandlerSpecialArgs = { + bypass_auth: false, + }; + + constructor( + url: string, + handler: RouteHandler, + special_args: Partial, + ) { + this.special_args = Object.assign( + {}, + RouteHandlerData.SPECIAL_ARGS_DEFAULT, + ); + Object.assign(this.special_args, special_args); + + let parsed = RouteHandlerData.parseUrl(url); + this.handler = handler; + this.parts = parsed.parts.filter((p) => p?.length !== 0); + this.url = parsed.parts + .filter((p) => p?.length !== 0) + .map((v, i) => v ?? `:${i}`) + .reduce((p, c) => `${p}/${c}`, ""); + this.args = parsed.args; + this.orignal_url = parsed.original; + } + + private static parseUrl(url: string): { + parts: (string | null)[]; + original: string; + args: (string | null)[]; + } { + const deduped = url.replace(RegExp("/+"), "/"); + const trimed = deduped.replace(RegExp("(^/)|(/$)"), ""); + + let parts = trimed.split("/"); + let s = parts.map((part, idx) => { + // then this is a parameter ! + if (part.startsWith(":")) { + let param_name = part.substring(1); // remove the : + // verifiy that the parameter name only contains character, underscores and numbers (not in fist char tho) + if (!param_name.match("^[a-zA-Z_][a-zA-Z_0-9]*$")) + throw `route parameter ${idx} for url '${url}' contains illegal character`; + return { idx, param_name, part: null }; + } else { + return { idx, param_name: null, part }; + } + }); + { + let dup = new Set(); + for (const { param_name } of s) { + if (param_name === null) continue; + if (dup.has(param_name)) + throw `route paramater '${param_name}' is a duplicate in route ${url}`; + dup.add(param_name); + } + } + let out_args = s.map((p) => p.param_name); + let out_parts = s.map((p) => p.part); + + return { + parts: out_parts, + args: out_args, + original: url, + }; + } +} + +function urlToParts(url: string): string[] { + const deduped = url.replace(RegExp("/+"), "/"); + const trimed = deduped.replace(RegExp("(^/)|(/$)"), ""); + + let parts = trimed.split("/"); + if (parts.at(0) === "app") parts.shift(); + return parts.filter((p) => p.length !== 0); +} + +export function addRoute( + url: string, + handler: RouteHandler | string, + args?: Partial, +) { + let d = new RouteHandlerData(url, handler, args ?? {}); + if (window.__state.routes.has(d.url)) + throw `Tried to insert route ${url}, but it already exists`; + window.__state.routes.set(d.url, d); +} + +export function setTitle(title: string) { + window.__state.title = title; + window.__state.titleElem.innerText = window.__state.title; +} + +const executeRouteHandler = async ( + handler: RouteHandlerData, + ...args: Parameters +): Promise => { + // handler may be a raw string literal, if yes => return it directly + if (typeof handler.handler === "string") return { html: handler.handler }; + + // now we know handler is a function. what does it return ? we don't know + // the two choices are either a string, or a Promise (needing an await to get the string) + const result = handler.handler(...args); + + // if `result` is a promise, awaits it, otherwise do nothing + let ret = result instanceof Promise ? await result : result; + + // if ret is a string, then no postInsert function exists => return a well formed object + if (typeof ret === "string") return { html: ret }; + return ret; +}; + +const route_404_handler = new RouteHandlerData("", route_404, { + bypass_auth: true, +}); + +function parts_match(route_parts: (string | null)[], parts: string[]): boolean { + if (route_parts.length !== parts.length) return false; + + let zipped = route_parts.map((v, i) => [v ?? parts[i], parts[i]]); + return zipped.every(([lhs, rhs]) => lhs == rhs); +} + +export async function handleRoute() { + let routes = window.__state.routes; + + let parts = urlToParts(window.location.pathname); + let routes_all = routes.entries(); + + let route_handler: RouteHandlerData = route_404_handler; + let args: RouteHandlerParams = {}; + for (const [_, route_data] of routes_all) { + if (!parts_match(route_data.parts, parts)) continue; + + args = {}; + route_data.args.forEach((v, i) => { + if (v === null) return; + args[v] = decodeURIComponent(parts[i]); + }); + route_handler = route_data; + break; + } + + let user = await updateUser(); + if (user === null && !route_handler.special_args.bypass_auth) + return navigateTo( + `/login?returnTo=${encodeURIComponent(window.location.pathname)}`, + ); + const app = document.getElementById("app")!; + document.dispatchEvent( + new CustomEvent("ft:pageChange" as any, {} as any) as any, + ); + let ret = await executeRouteHandler( + route_handler, + window.location.pathname, + args, + ); + app.innerHTML = ret.html; + if (ret.postInsert) { + let r = ret.postInsert(app); + if (r instanceof Promise) await r; + } +} + +if (!window.__state._routingHandler) { + // ---- Intercept link clicks ---- + document.addEventListener("click", (e) => { + const target = e?.target; + if (!target) return; + if (!(target instanceof Element)) return; + let link = target.closest("a[href]") as HTMLAnchorElement; + if (!link) return; + + const url = new URL(link.href); + const sameOrigin = url.origin === window.location.origin; + + if (sameOrigin) { + e.preventDefault(); + navigateTo(url.pathname); + } + }); + + // ---- Handle browser navigation (back/forward) ---- + window.addEventListener("popstate", handleRoute); + window.__state._routingHandler = true; +} + +async function route_404( + url: string, + _args: RouteHandlerParams, +): Promise { + return ` +
404 - Not Found
+
+
${escapeHTML(url)}
+ `; +} + +Object.assign(window as any, { setTitle, addRoute, navigateTo }); diff --git a/frontend/src/routing/index.ts b/frontend/src/routing/index.ts deleted file mode 100644 index 677e3fb..0000000 --- a/frontend/src/routing/index.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { updateUser } from '@app/auth'; -import { route_404 } from './special_routes' -import { ensureWindowState } from '@app/utils'; - -ensureWindowState(); - -declare module 'ft_state' { - interface State { - routes: Map, - title: string, - titleElem: HTMLDivElement, - } -}; - -window.__state.routes ??= new Map(); -window.__state.title ??= ""; -window.__state.titleElem ??= document.querySelector('#header-title')!; - -// ---- Router logic ---- -export function navigateTo(url: string) { - if (url.startsWith('/') && !url.startsWith('/app')) - url = `/app${url}`; - history.pushState(null, "", url); - handleRoute(); -} - -type AsyncFunctionMaker any> = - (...args: Parameters) => Promise>; - -export type RouteHandlerParams = { [k: string]: string }; - - -export type SyncRouteHandlerPostInsertFn = (appNode?: HTMLElement) => void; -export type AsyncRouteHandlerPostInsertFn = AsyncFunctionMaker; -export type RouteHandlerReturn = { - html: string, - postInsert?: SyncRouteHandlerPostInsertFn | AsyncRouteHandlerPostInsertFn, -}; - -export type SyncRouteHandler = (url: string, args: RouteHandlerParams) => RouteHandlerReturn | string; -export type AsyncRouteHandler = AsyncFunctionMaker; -export type RouteHandler = string | SyncRouteHandler | AsyncRouteHandler; -export type Routes = Map -export type RouteHandlerSpecialArgs = { - bypass_auth: boolean, -}; - -export class RouteHandlerData { - public readonly handler: RouteHandler; - public readonly url: string; - public readonly parts: (string | null)[]; - public readonly args: (string | null)[]; - public readonly orignal_url: string; - public readonly special_args: RouteHandlerSpecialArgs; - - public static SPECIAL_ARGS_DEFAULT: RouteHandlerSpecialArgs = { - bypass_auth: false, - } - - constructor(url: string, handler: RouteHandler, special_args: Partial) { - this.special_args = Object.assign({}, RouteHandlerData.SPECIAL_ARGS_DEFAULT); - Object.assign(this.special_args, special_args); - - let parsed = RouteHandlerData.parseUrl(url); - this.handler = handler; - this.parts = parsed.parts.filter(p => p?.length !== 0); - this.url = parsed.parts.filter(p => p?.length !== 0).map((v, i) => v ?? `:${i}`).reduce((p, c) => `${p}/${c}`, ''); - this.args = parsed.args; - this.orignal_url = parsed.original; - } - - private static parseUrl(url: string): { parts: (string | null)[], original: string, args: (string | null)[] } { - const deduped = url.replace(RegExp('/+'), '/'); - const trimed = deduped.replace(RegExp('(^/)|(/$)'), ''); - - let parts = trimed.split('/'); - let s = parts.map((part, idx) => { - // then this is a parameter ! - if (part.startsWith(':')) { - let param_name = part.substring(1) // remove the : - // verifiy that the parameter name only contains character, underscores and numbers (not in fist char tho) - if (!param_name.match('^[a-zA-Z_][a-zA-Z_0-9]*$')) - throw `route parameter ${idx} for url '${url}' contains illegal character`; - return { idx, param_name, part: null } - } - else { - return { idx, param_name: null, part }; - } - }) - { - let dup = new Set(); - for (const { param_name } of s) { - if (param_name === null) continue; - if (dup.has(param_name)) - throw `route paramater '${param_name}' is a duplicate in route ${url}`; - dup.add(param_name); - } - } - let out_args = s.map(p => p.param_name); - let out_parts = s.map(p => p.part); - - return { - parts: out_parts, args: out_args, original: url, - } - } -} - -function urlToParts(url: string): string[] { - const deduped = url.replace(RegExp('/+'), '/'); - const trimed = deduped.replace(RegExp('(^/)|(/$)'), ''); - - let parts = trimed.split('/'); - if (parts.at(0) === 'app') - parts.shift(); - return parts.filter(p => p.length !== 0); -} - -export function addRoute(url: string, handler: RouteHandler | string, args?: Partial) { - let d = new RouteHandlerData(url, handler, args ?? {}); - if (window.__state.routes.has(d.url)) - throw `Tried to insert route ${url}, but it already exists`; - window.__state.routes.set(d.url, d); -} - - -export function setTitle(title: string) { - window.__state.title = title; - window.__state.titleElem.innerText = window.__state.title; -} - -const executeRouteHandler = async (handler: RouteHandlerData, ...args: Parameters): Promise => { - // handler may be a raw string literal, if yes => return it directly - if (typeof handler.handler === 'string') - return { html: handler.handler }; - - // now we know handler is a function. what does it return ? we don't know - // the two choices are either a string, or a Promise (needing an await to get the string) - const result = handler.handler(...args); - - - // if `result` is a promise, awaits it, otherwise do nothing - let ret = result instanceof Promise ? (await result) : result; - - // if ret is a string, then no postInsert function exists => return a well formed object - if (typeof ret === 'string') - return { html: ret }; - return ret; -} - -const route_404_handler = new RouteHandlerData('', route_404, { bypass_auth: true }); - -function parts_match(route_parts: (string | null)[], parts: string[]): boolean { - if (route_parts.length !== parts.length) return false; - - let zipped = route_parts.map((v, i) => [v ?? parts[i], parts[i]]); - return zipped.every(([lhs, rhs]) => lhs == rhs) -} - -export async function handleRoute() { - let routes = window.__state.routes; - - let parts = urlToParts(window.location.pathname); - let routes_all = routes.entries(); - - let route_handler: RouteHandlerData = route_404_handler; - let args: RouteHandlerParams = {}; - for (const [_, route_data] of routes_all) { - if (!parts_match(route_data.parts, parts)) continue; - - args = {}; - route_data.args.forEach((v, i) => { - if (v === null) return; - args[v] = decodeURIComponent(parts[i]); - }) - route_handler = route_data; - break; - } - - let user = await updateUser(); - if (user === null && !route_handler.special_args.bypass_auth) - return navigateTo(`/login?returnTo=${encodeURIComponent(window.location.pathname)}`) - const app = document.getElementById('app')!; - document.dispatchEvent(new CustomEvent('ft:pageChange' as any, {} as any) as any); - let ret = await executeRouteHandler(route_handler, window.location.pathname, args) - app.innerHTML = ret.html; - if (ret.postInsert) { - let r = ret.postInsert(app); - if (r instanceof Promise) await r; - } -} - - -// ---- Intercept link clicks ---- -document.addEventListener('click', e => { - const target = e?.target; - if (!target) return; - if (!(target instanceof Element)) return; - let link = target.closest('a[href]') as HTMLAnchorElement; - if (!link) return; - - const url = new URL(link.href); - const sameOrigin = url.origin === window.location.origin; - - if (sameOrigin) { - e.preventDefault(); - navigateTo(url.pathname); - } -}); - -// ---- Handle browser navigation (back/forward) ---- -window.addEventListener('popstate', handleRoute); - -Object.assign((window as any), { setTitle, addRoute, navigateTo }) diff --git a/frontend/src/routing/special_routes.ts b/frontend/src/routing/special_routes.ts deleted file mode 100644 index dbda9d1..0000000 --- a/frontend/src/routing/special_routes.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { escapeHTML } from "@app/utils"; -import { type RouteHandlerParams } from "@app/routing"; - -export async function route_404(url: string, _args: RouteHandlerParams): Promise { - return ` -
404 - Not Found
-
-
${escapeHTML(url)}
- ` -}