Merge branch 'master' into Alex/hell

This commit is contained in:
Maieul BOYER 2025-12-22 17:42:13 +01:00
commit be9f5ad33a
No known key found for this signature in database
19 changed files with 313 additions and 283 deletions

View file

@ -46,12 +46,11 @@
</main> </main>
<!-- Scripts --> <!-- Scripts -->
<script type="module" src="/src/carousel/"></script>
<script type="module" src="/src/pages/"></script> <script type="module" src="/src/pages/"></script>
<script type="module" src="/src/routing/"></script> <script type="module" src="/src/carousel"></script>
<script type="module" src="/src/toast/"></script> <script type="module" src="/src/routing"></script>
<script type="module" src="/src/auth/"></script> <script type="module" src="/src/toast"></script>
<script type="module" src="/src/chat/"></script> <script type="module" src="/src/auth"></script>
</body> </body>
</html> </html>

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,14 +1,27 @@
import { type State } from "ft_state";
interface CustomEventMap { interface CustomEventMap {
"ft:pageChange": CustomEvent<{}>; "ft:pageChange": CustomEvent<{}>;
} }
declare global { declare global {
interface Document { //adds definition to Document, but you can do the same with HTMLElement interface Document {
addEventListener<K extends keyof CustomEventMap>(type: K, //adds definition to Document, but you can do the same with HTMLElement
listener: (this: Document, ev: CustomEventMap[K]) => void): void; addEventListener<K extends keyof CustomEventMap>(
dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K]): void; type: K,
} listener: (this: Document, ev: CustomEventMap[K]) => void,
): void;
dispatchEvent<K extends keyof CustomEventMap>(
ev: CustomEventMap[K],
): void;
}
} }
declare global {
interface Window {
__state: State;
}
}
export { }; //keep that for TS compiler. export { }; //keep that for TS compiler.

4
frontend/src/@types/ft_state.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module 'ft_state' {
interface GlobalState {
};
};

View file

@ -1,6 +1,14 @@
import { ensureWindowState } from '@app/utils';
import { Configuration, OpenapiOtherApi } from './generated'; import { Configuration, OpenapiOtherApi } from './generated';
export * from './generated' export * from './generated'
ensureWindowState();
declare module 'ft_state' {
interface State {
client: OpenapiOtherApi;
}
}
const basePath = (() => { const basePath = (() => {
let u = new URL(location.href); let u = new URL(location.href);
@ -8,11 +16,9 @@ const basePath = (() => {
u.hash = ""; u.hash = "";
u.search = ""; u.search = "";
return u.toString().replace(/\/+$/, ''); return u.toString().replace(/\/+$/, '');
})(); })();
export const client = new OpenapiOtherApi(new Configuration({ basePath })); export const client = new OpenapiOtherApi(new Configuration({ basePath }));
export default client; export default client;
Object.assign(window as any, { apiClient: client }); window.__state.client ??= client;

View file

@ -1,9 +1,13 @@
import { showError } from "@app/toast"; import { showError } from "@app/toast";
import client from '@app/api'; import client from '@app/api';
import cookie from 'js-cookie'; import cookie from 'js-cookie';
import { ensureWindowState } from "@app/utils";
cookie.remove('pkce'); cookie.remove('pkce');
ensureWindowState();
window.__state.user ??= null;
export type User = { export type User = {
id: string; id: string;
guest: boolean; 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<User> | null { export function getUser(): Readonly<User> | null {
return currentUser; return window.__state.user;
} }
export function isLogged(): boolean { export function isLogged(): boolean {
return currentUser !== null; return window.__state.user !== null;
} }
export function setUser(newUser: User | null) { export function setUser(newUser: User | null) {
currentUser = newUser; window.__state.user = newUser;
} }
export async function updateUser(): Promise<Readonly<User> | null> { export async function updateUser(): Promise<Readonly<User> | null> {

View file

@ -1,2 +0,0 @@
import './chat.css';

View file

@ -1,3 +1,4 @@
import "./chat.css";
import { addRoute, setTitle, type RouteHandlerParams, type RouteHandlerReturn } from "@app/routing"; import { addRoute, setTitle, type RouteHandlerParams, type RouteHandlerReturn } from "@app/routing";
import { showError } from "@app/toast"; import { showError } from "@app/toast";
import authHtml from './chat.html?raw'; import authHtml from './chat.html?raw';

View file

@ -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 { showError, showSuccess } from "@app/toast";
import page from "./profile.html?raw"; import page from "./profile.html?raw";
import { updateUser } from "@app/auth"; import { updateUser } from "@app/auth";

257
frontend/src/routing.ts Normal file
View file

@ -0,0 +1,257 @@
import { updateUser } from "@app/auth";
import { ensureWindowState, escapeHTML } from "@app/utils";
ensureWindowState();
declare module "ft_state" {
interface State {
routes: Map<string, RouteHandlerData>;
title: string;
titleElem: HTMLDivElement;
_routingHandler: boolean;
}
}
window.__state.routes ??= new Map();
window.__state.title ??= "";
window.__state.titleElem ??= document.querySelector<HTMLDivElement>("#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<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 = 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<RouteHandlerSpecialArgs>,
) {
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<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 = 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<string> {
return `
<div> 404 - Not Found </div>
<hr />
<center> ${escapeHTML(url)} </center>
`;
}
Object.assign(window as any, { setTitle, addRoute, navigateTo });

View file

@ -1,225 +0,0 @@
import { updateUser } from '@app/auth';
import { route_404 } from './special_routes'
// ---- Router logic ----
export function navigateTo(url: string) {
if (url.startsWith('/') && !url.startsWith('/app'))
url = `/app${url}`;
history.pushState(null, "", 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 = 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);
}
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;
}
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), { getTitle, setTitle, getRoute, addRoute, navigateTo })

View file

@ -1,10 +0,0 @@
import { escapeHTML } from "@app/utils";
import { getRoute, type RouteHandlerParams } from "@app/routing";
export async function route_404(url: string, _args: RouteHandlerParams): Promise<string> {
return `
<div> 404 - Not Found </div>
<hr />
<center> ${escapeHTML(url)} </center>
`
}

View file

@ -1,24 +0,0 @@
<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">You are already logged in</h1>
<div id="returnToDiv" hidden>
<p class="text-center text-sm text-gray-500 mt-4">
We were asked to redirect you to somewhere when you logged in,
but you already are !
<br />
You can click the button below to go there
</p>
<button id="bReturnTo"
class="w-full bg-green-600 text-white font-medium py-2 rounded-xl hover:bg-gray-700 transition">
Get redirected
</button>
</div>
<p class="text-center text-sm text-gray-500 mt-4">Want to logout ? Click the big button bellow !</p>
<button id="bLogout"
class="w-full bg-gray-600 text-white font-medium py-2 rounded-xl hover:bg-gray-700 transition">
Logout
</button>
<p class="text-center text-sm text-gray-500 mt-4">Otherwise, here is a cute cat picture</p>
<img class="" id="cuteCatImage" hidden />
</div>
</div>

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -6,3 +6,8 @@ export function escapeHTML(str: string): string {
export function isNullish<T>(v: T | undefined | null): v is (null | undefined) { export function isNullish<T>(v: T | undefined | null): v is (null | undefined) {
return v === null || v === undefined; return v === null || v === undefined;
} }
// MAKE SURE YOU DO WANT TO CALL THIS FUNCTION
export function ensureWindowState() {
window.__state = window.__state ?? {};
}