From 55fc1bad1f98ad4c504650e5f6c9b0dabeda23fb Mon Sep 17 00:00:00 2001 From: Maieul BOYER Date: Mon, 22 Dec 2025 13:55:34 +0100 Subject: [PATCH] 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)}
- ` -}