feat(frontend): added frontend

- Router: client side route handling with client side rendering
- Toast: rought Toast handling for better UX and messaging
- Auth: single point of truth for the Logged in user

This commit doesnt not include the openapi generated code
This commit is contained in:
Maieul BOYER 2025-11-10 17:00:21 +01:00 committed by Maix0
parent 0db41a440d
commit 08c910c193
28 changed files with 1994 additions and 0 deletions

2
frontend/.dockerignore Normal file
View file

@ -0,0 +1,2 @@
node_modules
dist

25
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
package-lock.json
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

17
frontend/Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM node:22-alpine AS pnpm_base
RUN npm install --global pnpm@10;
FROM pnpm_base AS builder
COPY . /src
WORKDIR /src
RUN pnpm install --frozen-lockfile && pnpm run build;
FROM node:22-alpine
COPY --from=builder /src/dist /dist
COPY ./run.sh /bin/run.sh
RUN chmod +x /bin/run.sh
CMD [ "/bin/run.sh" ]

51
frontend/index.html Normal file
View file

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ft Boules</title>
</head>
<body class="bg-gray-500 font-sans min-h-screen">
<!-- Header -->
<header
class="fixed top-0 left-0 right-0 h-14 bg-gray-800 text-white flex items-center justify-between px-4 shadow-md z-50">
<button id="menuBtn" class="flip-btn text-lg font-semibold focus:outline-none">
<span class="arrow front">&gt;</span>
<span class="arrow back">&lt;</span>
</button>
<div id="header-title" class="text-white text-lf text-semibold ps-4 shadow-md font-mono"></div>
<div class="text-white text-lf ps-4 font-mono"> </div>
</header>
<!-- Sidebar -->
<aside id="sidebar"
class="fixed top-14 left-0 w-64 h-full bg-gray-900 text-white transform -translate-x-full transition-transform duration-300 ease-in-out z-40">
<nav class="flex flex-col p-4 space-y-3">
<a href="/" class="hover:bg-gray-700 rounded-md px-3 py-2">🏠 Home</a>
<a href="/chat" class="hover:bg-gray-700 rounded-md px-3 py-2">👤 Chat</a>
<a href="/contact" class="hover:bg-gray-700 rounded-md px-3 py-2">⚙️ Settings</a>
<a href="/404" class="hover:bg-gray-700 rounded-md px-3 py-2">🚪 Logout</a>
</nav>
</aside>
<!-- Overlay -->
<div id="overlay"
class="fixed top-14 left-0 right-0 bottom-0 bg-black bg-opacity-40 opacity-0 pointer-events-none transition-opacity duration-300 z-30">
</div>
<!-- Main content -->
<main class="pt-16 px-6 pb-8 w-full h-full container" id="app">
</main>
<!-- Scripts -->
<script type="module" src="/src/carousel/"></script>
<script type="module" src="/src/pages/"></script>
<script type="module" src="/src/routing/"></script>
<script type="module" src="/src/toast/"></script>
<script type="module" src="/src/auth/"></script>
</body>
</html>

21
frontend/package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "~5.9.3",
"vite": "^7.1.10",
"vite-tsconfig-paths": "^5.1.4"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.16",
"openapi-fetch": "^0.15.0",
"tailwindcss": "^4.1.16"
}
}

1063
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
packages:
- .
onlyBuiltDependencies:
- esbuild

1
frontend/public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

9
frontend/run.sh Normal file
View file

@ -0,0 +1,9 @@
#!/bin/sh
set -x
set -e
rm -rf /volumes/static/app
mkdir -p /volumes/static/app
cp -r /dist/* /volumes/static/app/

17
frontend/src/api/index.ts Normal file
View file

@ -0,0 +1,17 @@
import { Configuration, OpenapiOtherApi } from './generated';
export * from './generated'
const basePath = (() => {
let u = new URL(location.href);
u.pathname = "";
u.hash = "";
u.search = "";
return u.toString().replace(/\/+$/, '');
})();
export const client = new OpenapiOtherApi(new Configuration({ basePath }));
export default client;

View file

@ -0,0 +1,51 @@
import { showError } from "@app/toast";
import client from '@app/api';
export type User = {
id: string;
guest: boolean;
name: string;
};
let currentUser: User | null = null;
export function getUser(): Readonly<User> | null {
return currentUser;
}
export function isLogged(): boolean {
return currentUser === null;
}
export function setUser(newUser: User | null) {
currentUser = newUser;
}
export async function updateUser(): Promise<Readonly<User> | null> {
try {
let res = await client.getUser({ user: 'me' });
if (res.kind === "success") {
setUser(res.payload);
return res.payload;
} else if (res.kind === "failure") {
// well no user :D
setUser(null);
return null;
} else if (res.kind === "notLoggedIn") {
setUser(null);
return null;
} else {
setUser(null);
showError(`unknown response: ${JSON.stringify(res)}`);
return null;
}
} catch (e) {
setUser(null);
showError(`failed to get user: ${e}`);
return null;
}
}
Object.assign(window as any, { getUser, setUser, updateUser, isLogged });

View file

@ -0,0 +1,104 @@
.flip-btn {
position: relative;
width: 60px;
height: 40px;
border: none;
background: #333;
color: white;
font-size: 1.5rem;
border-radius: 8px;
cursor: pointer;
perspective: 600px;
/* Enables 3D effect */
}
.arrow {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
transition: transform 0.5s;
backface-visibility: hidden;
/* Hide the back when rotated */
}
.front {
transform: translate(-50%, -50%) rotateY(0deg);
}
.back {
transform: translate(-50%, -50%) rotateY(180deg);
}
.flip-btn.flipped .front {
transform: translate(-50%, -50%) rotateY(180deg);
}
.flip-btn.flipped .back {
transform: translate(-50%, -50%) rotateY(360deg);
}
@import 'tailwindcss';
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
#app {
max-width: 1280px;
margin: 0 auto;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vanilla:hover {
filter: drop-shadow(0 0 2em #3178c6aa);
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View file

@ -0,0 +1,17 @@
import './carousel.css'
const menuBtn = document.querySelector<HTMLButtonElement>('#menuBtn')!;
const sidebar = document.querySelector('#sidebar')!;
const overlay = document.querySelector('#overlay')!;
menuBtn.addEventListener('click', () => {
sidebar.classList.toggle('-translate-x-full')
overlay.classList.toggle('opacity-0');
overlay.classList.toggle('pointer-events-none');
menuBtn.classList.toggle('flipped');
});
overlay.addEventListener('click', () => {
sidebar.classList.add('-translate-x-full');
overlay.classList.add('opacity-0', 'pointer-events-none');
});

View file

View file

@ -0,0 +1,12 @@
import { addRoute, setTitle } from "@app/routing";
import page from './about.html?raw'
async function route(_url: string, _args: { [k: string]: string }): Promise<string> {
setTitle('About us')
return page;
}
addRoute('/', route)

View file

@ -0,0 +1,5 @@
import { addRoute, type RouteHandlerParams } from "@app/routing";
addRoute('/chat', function (_url: string, _args: RouteHandlerParams) {
return "this is the chat page !"
})

View file

@ -0,0 +1,8 @@
import { setTitle, handleRoute } from '@app/routing';
import './root/root.ts'
import './chat/chat.ts'
import './login/login.ts'
// ---- Initial load ----
setTitle("");
handleRoute();

View file

@ -0,0 +1,41 @@
<div class="grid h-full place-items-center">
<div class="bg-white shadow-lg rounded-2xl p-8 w-full max-w-md">
<h1 class="text-2xl font-semibold text-center mb-6 text-gray-800">Welcome to <span>ft boules</span></h1>
<button id="bGuestLogin"
class="w-full bg-gray-600 text-white font-medium py-2 rounded-xl hover:bg-gray-700 transition">
Login as Guest
</button>
<form class="space-y-5 pt-3" id="login-form">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Username</label>
<input type="text" placeholder="Enter your username" name="login"
class="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input type="password" placeholder="Enter your password" name="password"
class="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
<button type="submit"
class="w-full bg-blue-600 text-white font-medium py-2 rounded-xl hover:bg-blue-700 transition">
Sign In
</button>
</form>
<p class="text-center text-sm text-gray-500 mt-4">
Dont have an account?
<a href="/signin" class="text-blue-600 hover:underline">Sign up</a>
</p>
<p class="text-center text-sm text-gray-500 mt-4">
You can also login with
</p>
<div id="otherLogin" class="pt-5 space-y-5 grid grid-cols-2 gap-4">
</div>
</div>
</div>

View file

@ -0,0 +1,152 @@
import { addRoute, setTitle, type RouteHandlerParams, type RouteHandlerReturn } from "@app/routing";
import { showError, showInfo, showSuccess } from "@app/toast";
import authHtml from './login.html?raw';
import client from '@app/api'
import { updateUser } from "@app/auth";
type Providers = {
name: string,
display_name: string,
icon_url?: string,
color?: { default: string, hover: string },
};
function handleLogin(_url: string, _args: RouteHandlerParams): RouteHandlerReturn {
setTitle('Login')
return {
html: authHtml, postInsert: async (app) => {
const fLogin = document.querySelector<HTMLFormElement>('form#login-form');
if (fLogin === null)
return showError('Error while rendering the page: no form found');
showSuccess('got the form !')
fLogin.addEventListener('submit', async function(e: SubmitEvent) {
e.preventDefault();
let form = e.target as (HTMLFormElement | null)
if (form === null)
return showError('Failed to send form...');
let formData = Object.fromEntries((new FormData(form)).entries());
if (!('login' in formData) || typeof formData['login'] !== 'string' || (formData['login'] as string).length === 0)
return showError('Please enter a Login');
if (!('password' in formData) || typeof formData['password'] !== 'string' || (formData['password'] as string).length === 0)
return showError('Please enter a Password');
try {
const res = await client.login({ loginRequest: { name: formData.login, password: formData.password } });
switch (res.kind) {
case 'success': {
document.cookie = `token=${res.payload.token}`;
let user = await updateUser();
if (user === null)
return showError('Failed to get user: no user ?');
setTitle(`Welcome ${user.guest ? '[GUEST] ' : ''}${user.name}`);
break;
}
case 'otpRequired': {
showInfo('Got ask OTP, not yet implemented');
break;
}
case 'failed': {
showError(`Failed to login: ${res.msg}`);
}
}
} catch (e) {
console.error("Login error:", e);
showError('Failed to login: Unknown error');
}
});
const bLoginAsGuest = document.querySelector<HTMLButtonElement>('#bGuestLogin');
bLoginAsGuest?.addEventListener('click', async () => {
try {
const res = await client.guestLogin();
switch (res.kind) {
case 'success': {
document.cookie = `token=${res.payload.token}`;
let user = await updateUser();
if (user === null)
return showError('Failed to get user: no user ?');
setTitle(`Welcome ${user.guest ? '[GUEST] ' : ''}${user.name}`);
break;
}
case 'failed': {
showError(`Failed to login: ${res.msg}`);
}
}
} catch (e) {
console.error("Login error:", e);
showError('Failed to login: Unknown error');
}
});
const dOtherLoginArea = document.querySelector<HTMLDivElement>('#otherLogin');
if (dOtherLoginArea) {
let styleSheetElement = document.createElement('style');
styleSheetElement.innerText = "";
// TODO: fetch all the providers from an API ?
const providers: Providers[] = [
{ name: 'discord', display_name: 'Discord', color: { default: 'bg-[#5865F2]', hover: '#FF65F2' } },
{ name: 'kanidm', display_name: 'Kanidm', color: { default: 'bg-red-500', hover: 'bg-red-700' } },
{ name: 'google', display_name: 'Google' },
]
let first = true;
for (const p of providers) {
let b = document.createElement('button');
if (first) b.classList.add('last:col-span-2');
first = false;
b.classList.add(...(
'w-full text-white font-medium py-2 rounded-xl transition'
.split(' ')
));
b.classList.add(`providerButton-${p.name}`)
const col = { default: p.color?.default ?? "bg-gray-600", hover: p.color?.hover ?? "bg-gray-700" };
for (const k of Object.keys(col)) {
let c = (col as { [k: string]: string })[k].trim();
if (c.startsWith('bg-')) {
c = c.replace(/^bg-/, '');
const customProp = c.match(/^\((.+)\)$/);
const customVal = c.match(/^\[(.+)\]$/);
if (customProp)
c = `var(${customProp[1]})`
else if (customVal)
c = customVal[1];
else if (c === 'inherit')
c = 'inherit';
else if (c === 'current')
c = 'currentColor';
else if (c === 'transparent')
c = 'transparent';
else
c = `var(--color-${c})`
}
(col as { [k: string]: string })[k] = c;
}
styleSheetElement.innerText += `.providerButton-${p.name} { background-color: ${col.default}; }\n`;
styleSheetElement.innerText += `.providerButton-${p.name}:hover { background-color: ${col.hover}; }\n`;
b.dataset.display_name = p.display_name;
b.dataset.name = p.name;
if (p.icon_url) b.dataset.icon = p.icon_url;
b.innerHTML = `
${p.icon_url ? `<img src="${p.icon_url}" alt="${p.display_name} Logo" />` : ''} <span class="">${p.display_name}</span>
`
b.addEventListener('click', () => {
location.href = `/api/auth/oauth2/${p.name}/login`;
})
dOtherLoginArea.insertAdjacentElement('afterbegin', b);
}
app?.appendChild(styleSheetElement);
}
}
};
}
addRoute('/login', handleLogin, { bypass_auth: true })

View file

@ -0,0 +1,15 @@
<div>
Welcome a to <span> The Site de Boule </span>
</div>
<div>
Welcome a to <span> The Site de Boule </span>
</div>
<div>
Welcome a to <span> The Site de Boule </span>
</div>
<div>
Welcome a to <span> The Site de Boule </span>
</div>
<div>
Welcome a to <span> The Site de Boule </span>
</div>

View file

@ -0,0 +1,14 @@
import { addRoute, setTitle, type RouteHandlerParams } from "@app/routing";
import page from './root.html?raw'
addRoute('/', (_: string) => {
setTitle('ft boules')
return page;
})
addRoute('/with_title/:title', (_: string, args: RouteHandlerParams) => {
setTitle(args.title)
console.log(`title should be '${args.title}'`);
return page;
})

View file

@ -0,0 +1,217 @@
import { route_404 } from './special_routes'
// ---- Router logic ----
function navigateTo(url: string) {
history.pushState(null, "", `${url.startsWith('/') ? '/app' : ""}${url}`);
handleRoute();
}
type AsyncFunctionMaker<F extends (...args: any[]) => any> =
(...args: Parameters<F>) => Promise<ReturnType<F>>;
export type RouteHandlerParams = { [k: string]: string };
export type SyncRouteHandlerPostInsertFn = (appNode?: HTMLElement) => void;
export type AsyncRouteHandlerPostInsertFn = AsyncFunctionMaker<SyncRouteHandlerPostInsertFn>;
export type RouteHandlerReturn = {
html: string,
postInsert?: SyncRouteHandlerPostInsertFn | AsyncRouteHandlerPostInsertFn,
};
export type SyncRouteHandler = (url: string, args: RouteHandlerParams) => RouteHandlerReturn | string;
export type AsyncRouteHandler = AsyncFunctionMaker<SyncRouteHandler>;
export type RouteHandler = string | SyncRouteHandler | AsyncRouteHandler;
export type Routes = Map<string, RouteHandlerData>
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<RouteHandlerSpecialArgs>) {
this.special_args = RouteHandlerData.SPECIAL_ARGS_DEFAULT;
Object.assign(this.special_args, special_args);
let parsed = RouteHandlerData.parseUrl(url);
this.handler = handler;
this.parts = parsed.parts;
this.url = parsed.parts.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;
}
function setupRoutes(): [
() => Routes,
(url: string, handler: RouteHandler, args?: Partial<RouteHandlerSpecialArgs>) => void
] {
const routes = new Map();
return [
() => routes,
(url: string, handler: RouteHandler | string, args?: Partial<RouteHandlerSpecialArgs>) => {
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<HTMLDivElement>('#header-title')!;
return [
() => title,
(new_title) => {
title = new_title;
titleElem.innerText = title;
}
]
}
export const [getRoute, addRoute] = setupRoutes();
export const [getTitle, setTitle] = setupTitle();
(window as any).getRoute = getRoute;
const executeRouteHandler = async (handler: RouteHandlerData, ...args: Parameters<SyncRouteHandler>): Promise<RouteHandlerReturn> => {
// 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<string> (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('<special:404>', 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 = getRoute();
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;
}
const app = document.getElementById('app')!;
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), { getTitle, setTitle, getRoute, addRoute, navigateTo })

View file

@ -0,0 +1,12 @@
import { escapeHTML } from "@app/utils";
import { getRoute, type RouteHandlerParams } from "@app/routing";
export async function route_404(url: string, _args: RouteHandlerParams): Promise<string> {
console.log(`asked about route '${url}: not found'`)
console.log(getRoute())
return `
<div> 404 - Not Found </div>
<hr />
<center> ${escapeHTML(url)} </center>
`
}

View file

@ -0,0 +1,79 @@
type ToastType = 'success' | 'error' | 'info' | 'warning';
interface Toast {
message: string;
type: ToastType;
el: HTMLElement;
}
class ToastManager {
private container: HTMLElement;
private toasts: Toast[] = [];
private static INSTANCE: ToastManager = new ToastManager();
public static instance(): ToastManager { return ToastManager.INSTANCE; }
private constructor() {
// Create container at bottom center
this.container = document.createElement('div');
this.container.className = `
fixed bottom-5 left-1/2 transform -translate-x-1/2
flex flex-col-reverse items-center gap-2 z-50
`;
document.body.appendChild(this.container);
}
public show(message: string, type: ToastType = 'info', duration = 5000) {
const el = document.createElement('div');
const color = {
success: 'bg-green-600',
error: 'bg-red-600',
info: 'bg-blue-600',
warning: 'bg-yellow-600 text-black'
}[type];
el.className = `
toast-item min-w-[200px] max-w-sm px-4 py-2 rounded-xl shadow-lg
text-white text-sm font-medium opacity-0 translate-y-4
transition-all duration-300 ease-out ${color}
`;
el.innerText = message;
this.container.prepend(el);
// Animate in
requestAnimationFrame(() => {
el.classList.remove('opacity-0', 'translate-y-4');
el.classList.add('opacity-100', 'translate-y-0');
});
let toast: Toast = { message, type, el };
this.toasts.push(toast)
setTimeout(() => this.remove(toast), duration);
}
private remove(toast: Toast) {
const el = toast.el;
if (toast) {
// Animate out
el.classList.add('opacity-0', 'translate-y-4');
setTimeout(() => el.remove(), 300);
}
this.toasts = this.toasts.filter(t => t !== toast);
}
}
// Export a singleton
export const toast = ToastManager.instance();
export default toast;
export function showError(message: string, duration: number = 5000) { toast.show(message, 'error', duration); }
export function showWarn(message: string, duration: number = 5000) { toast.show(message, 'warning', duration); }
export function showInfo(message: string, duration: number = 5000) { toast.show(message, 'info', duration); }
export function showSuccess(message: string, duration: number = 5000) { toast.show(message, 'success', duration); }
Object.assign((window as any), { toast, showError, showWarn, showInfo, showSuccess })

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

8
frontend/src/utils.ts Normal file
View file

@ -0,0 +1,8 @@
export function escapeHTML(str: string): string {
const p = document.createElement("p");
p.appendChild(document.createTextNode(str));
return p.innerHTML;
}
export function isNullish<T>(v: T | undefined | null): v is (null | undefined) {
return v === null || v === undefined;
}

29
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"paths": {
"@app/*": ["./src/*"]
}
},
"include": ["src"]
}

18
frontend/vite.config.js Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite'
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [
tailwindcss(),
tsconfigPaths(),
],
server: {
hmr: {
protocol: 'ws',
host: 'localhost',
port: '5137',
}
}
});