diff --git a/com/static/bundled/com/moderation-alert-index.ts b/com/static/bundled/com/moderation-alert-index.ts index 70dc871c..029ff87e 100644 --- a/com/static/bundled/com/moderation-alert-index.ts +++ b/com/static/bundled/com/moderation-alert-index.ts @@ -1,4 +1,3 @@ -import { exportToHtml } from "#core:utils/globals.ts"; import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi"; // This will be used in jinja templates, @@ -13,7 +12,8 @@ const AlertState = { // biome-ignore lint/style/useNamingConvention: this feels more like an enum DISPLAYED: 4, // When published at page generation }; -exportToHtml("AlertState", AlertState); +// biome-ignore lint/style/useNamingConvention: it's an enum, PascalCase is better +Object.assign(window, { AlertState }); document.addEventListener("alpine:init", () => { Alpine.data("moderationAlert", (newsId: number) => ({ diff --git a/com/templates/com/screen_slideshow.jinja b/com/templates/com/screen_slideshow.jinja index 4a928cc4..0cd7449c 100644 --- a/com/templates/com/screen_slideshow.jinja +++ b/com/templates/com/screen_slideshow.jinja @@ -4,7 +4,7 @@ {% trans %}Slideshow{% endtrans %} - + { - $notifications: NotificationPlugin; - } -} - -Alpine.plugin([sort, limitedChoices, notifications]); -// biome-ignore lint/style/useNamingConvention: it's how it's named -Object.assign(window, { Alpine }); - -window.addEventListener("DOMContentLoaded", () => { - Alpine.start(); -}); diff --git a/core/static/bundled/base-bundle-index.ts b/core/static/bundled/base-bundle-index.ts new file mode 100644 index 00000000..4dc085ad --- /dev/null +++ b/core/static/bundled/base-bundle-index.ts @@ -0,0 +1,64 @@ +/** + * File containing main functions and library re-exports + * that should be accessible throughout the whole website. + * + * The idea is to group all that shared code into a single bundle, + * for more efficient tree-shaking and gzip compression. + */ + +import sort from "@alpinejs/sort"; +import Alpine from "alpinejs"; +import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill"; +import htmx from "htmx.org"; +import { limitedChoices } from "#core:alpine/limited-choices"; +import { expireOldStorage } from "#core:core/localstorage"; +import { default as navbar } from "#core:core/navbar"; +import { + type NotificationPlugin, + notificationsPlugin as notifications, +} from "#core:utils/notifications"; + +/** + * Alpine + */ +declare module "alpinejs" { + interface Magics { + $notifications: NotificationPlugin; + } +} + +Alpine.plugin([sort, limitedChoices, notifications]); +// biome-ignore lint/style/useNamingConvention: it's how it's named +Object.assign(window, { Alpine }); + +window.addEventListener("DOMContentLoaded", () => { + Alpine.start(); +}); + +/** + * Polyfill for country flags (used for language choice) + */ +polyfillCountryFlagEmojis(); + +/** + * HTMX + */ +document.body.addEventListener("htmx:beforeRequest", (event: CustomEvent) => { + event.detail.target.ariaBusy = true; +}); + +document.body.addEventListener("htmx:beforeSwap", (event: CustomEvent) => { + event.detail.target.ariaBusy = null; +}); + +Object.assign(window, { htmx }); + +/** + * navbar + */ +navbar(); + +/** + * Script that clears the cache when the cache version changes + */ +expireOldStorage(); diff --git a/core/static/bundled/core/localstorage.ts b/core/static/bundled/core/localstorage.ts new file mode 100644 index 00000000..521b7ac2 --- /dev/null +++ b/core/static/bundled/core/localstorage.ts @@ -0,0 +1,72 @@ +/** + * For more detailed infos on how to use this file, + * check /docs/tutorial/front/localstorage.md, + * or https://ae-utbm.github.io/sith/tutorial/front/localstorage/ + */ + +// increment this number when a breaking change is made with localStorage +const CURRENT_LOCALSTORAGE_VERSION = 1; + +/** + * Remove keys that are no longer used from localStorage + */ +export function expireOldStorage() { + const version = Number.parseInt(localStorage.getItem("version") ?? "0", 10); + if (version === CURRENT_LOCALSTORAGE_VERSION) { + // The cache schema is up-to-date. Nothing to do. + return; + } + localStorage.removeItem("basket1"); + // remove all storage items which key is in the form + // `userXXXPictures` or `userXXXPicturesNumber` + Object.keys(localStorage) + .filter( + (key) => + key.startsWith("user") && + (key.endsWith("Pictures") || key.endsWith("PicturesNumber")), + ) + .forEach((key) => { + localStorage.removeItem(key); + }); + localStorage.setItem("version", CURRENT_LOCALSTORAGE_VERSION.toString()); +} + +interface VersionedStorageItem { + version?: number; + val: T | undefined; +} + +export const versionedLocalStorage = { + ...localStorage, + /** + * set this item in localStorage, alongside its version. + * + * Note: this expects an object, not a JSON string, because the parsing + * into JSON needs to be done inside the function. + */ + setItem(key: string, value: T, { version }: { version: number }) { + localStorage.setItem(key, JSON.stringify({ version: version, val: value })); + }, + /** + * Get the item linked with the given key and version from localStorage. + * + * Note: if the given key exists in localStorage but doesn't satisfy + * the given version, it will be cleared from cache. + * + * @return the object if found and with the good version, else null; + */ + getItem(key: string, { version }: { version: number }): T | null { + const stored = localStorage.getItem(key); + if (!stored) { + // this key doesn't exist, return null; + return null; + } + const obj: VersionedStorageItem = JSON.parse(stored); + if (obj.version !== version || obj.val === undefined) { + // The version is wrong, return null and remove this item from cache + localStorage.removeItem(key); + return null; + } + return obj.val; + }, +}; diff --git a/core/static/bundled/core/navbar-index.ts b/core/static/bundled/core/navbar.ts similarity index 84% rename from core/static/bundled/core/navbar-index.ts rename to core/static/bundled/core/navbar.ts index 18f7e3e3..9abc3aec 100644 --- a/core/static/bundled/core/navbar-index.ts +++ b/core/static/bundled/core/navbar.ts @@ -1,12 +1,10 @@ -import { exportToHtml } from "#core:utils/globals.ts"; - -exportToHtml("showMenu", () => { +function showMenu() { const navbar = document.getElementById("navbar-content"); const current = navbar.getAttribute("mobile-display"); navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden"); -}); +} -document.addEventListener("alpine:init", () => { +function navbarInit() { const menuItems = document.querySelectorAll(".navbar details[name='navbar'].menu"); const isDesktop = () => { return window.innerWidth >= 500; @@ -33,4 +31,9 @@ document.addEventListener("alpine:init", () => { } }); } -}); +} + +export default () => { + Object.assign(document, { showMenu }); + document.addEventListener("alpine:init", navbarInit); +}; diff --git a/core/static/bundled/country-flags-index.ts b/core/static/bundled/country-flags-index.ts deleted file mode 100644 index 1dc005c3..00000000 --- a/core/static/bundled/country-flags-index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill"; - -polyfillCountryFlagEmojis(); diff --git a/core/static/bundled/htmx-index.js b/core/static/bundled/htmx-index.js deleted file mode 100644 index 5880668d..00000000 --- a/core/static/bundled/htmx-index.js +++ /dev/null @@ -1,11 +0,0 @@ -import htmx from "htmx.org"; - -document.body.addEventListener("htmx:beforeRequest", (event) => { - event.detail.target.ariaBusy = true; -}); - -document.body.addEventListener("htmx:beforeSwap", (event) => { - event.detail.target.ariaBusy = null; -}); - -Object.assign(window, { htmx }); diff --git a/core/static/bundled/sentry-popup-index.ts b/core/static/bundled/sentry-popup-index.ts index ea16c092..d8b84197 100644 --- a/core/static/bundled/sentry-popup-index.ts +++ b/core/static/bundled/sentry-popup-index.ts @@ -1,6 +1,5 @@ // biome-ignore lint/performance/noNamespaceImport: this is the recommended way from the documentation import * as Sentry from "@sentry/browser"; -import { exportToHtml } from "#core:utils/globals.ts"; interface LoggedUser { name: string; @@ -13,7 +12,7 @@ interface SentryOptions { user?: LoggedUser; } -exportToHtml("loadSentryPopup", (options: SentryOptions) => { +const loadSentryPopup = (options: SentryOptions) => { Sentry.init({ dsn: options.dsn, }); @@ -21,4 +20,5 @@ exportToHtml("loadSentryPopup", (options: SentryOptions) => { eventId: options.eventId, ...(options.user ?? {}), }); -}); +}; +Object.assign(window, { loadSentryPopup }); diff --git a/core/static/bundled/utils/types.d.ts b/core/static/bundled/types/nested-key.d.ts similarity index 100% rename from core/static/bundled/utils/types.d.ts rename to core/static/bundled/types/nested-key.d.ts diff --git a/core/static/bundled/utils/csv.ts b/core/static/bundled/utils/csv.ts index d7356bfc..3172c525 100644 --- a/core/static/bundled/utils/csv.ts +++ b/core/static/bundled/utils/csv.ts @@ -1,4 +1,4 @@ -import type { NestedKeyOf } from "#core:utils/types.ts"; +import type { NestedKeyOf } from "#core:types/nested-key"; interface StringifyOptions { /** The columns to include in the resulting CSV. */ diff --git a/core/static/bundled/utils/globals.ts b/core/static/bundled/utils/globals.ts index b4f9a457..71c15cb0 100644 --- a/core/static/bundled/utils/globals.ts +++ b/core/static/bundled/utils/globals.ts @@ -5,17 +5,3 @@ declare global { const gettext: (text: string) => string; const interpolate: (fmt: string, args: string[] | T, isNamed?: boolean) => string; } - -/** - * Helper function to export typescript functions to regular html and jinja files - * Without it, you either have to use the any keyword and suppress warnings or do a - * very painful type conversion workaround which is only here to please the linter - * - * This is only useful if you're using typescript, this is equivalent to doing - * window.yourFunction = yourFunction - **/ -// biome-ignore lint/suspicious/noExplicitAny: Avoid strange tricks to export functions -export function exportToHtml(name: string, func: any) { - // biome-ignore lint/suspicious/noExplicitAny: Avoid strange tricks to export functions - (window as any)[name] = func; -} diff --git a/core/templates/core/base.jinja b/core/templates/core/base.jinja index 577546be..0d8a06fe 100644 --- a/core/templates/core/base.jinja +++ b/core/templates/core/base.jinja @@ -35,12 +35,9 @@ - + - - - - + {% block additional_css %}{% endblock %} {% block additional_js %}{% endblock %} diff --git a/counter/static/bundled/counter/product-list-index.ts b/counter/static/bundled/counter/product-list-index.ts index 30d26b9a..1486c766 100644 --- a/counter/static/bundled/counter/product-list-index.ts +++ b/counter/static/bundled/counter/product-list-index.ts @@ -1,9 +1,9 @@ import { showSaveFilePicker } from "native-file-system-adapter"; import type TomSelect from "tom-select"; +import type { NestedKeyOf } from "#core:types/nested-key"; import { paginated } from "#core:utils/api"; import { csv } from "#core:utils/csv"; import { getCurrentUrlParams, History, updateQueryString } from "#core:utils/history"; -import type { NestedKeyOf } from "#core:utils/types"; import { type ProductSchema, type ProductSearchProductsDetailedData, diff --git a/docs/tutorial/front/localstorage.md b/docs/tutorial/front/localstorage.md new file mode 100644 index 00000000..fac974ab --- /dev/null +++ b/docs/tutorial/front/localstorage.md @@ -0,0 +1,75 @@ +[Documentation du localStorage (mozilla)](https://developer.mozilla.org/fr/docs/Web/API/Window/localStorage) + +## Utilité et limitations + +Le `localStorage` est un cache géré directement par le navigateur. +Il permet de stocker des données directement chez le client. +Il s'agit donc d'un outil extrêmement puissant, qui permet d'éviter +beaucoup de requêtes au serveur, améliorant ainsi les temps de chargement. + +Cependant, il y a deux limitations majeures à prendre en compte : + +- le `localStorage` est entièrement géré par le client, + une fois le déploiement effectué, vous ne pouvez plus y toucher ; + vous devez donc être sûr de vous avant d'apporter des modifications + reposant sur le `localStorage`. +- la quantité de données stockable est limitée à 10Mo ; + une fois ce quota rempli, le navigateur lèvera une `QuotaExceededError`. + +## Invalidation du `localStorage` + +Pour résoudre le premier de ces deux problèmes, il y a un script permettant +d'annuler une partie du cache. +Ce dernier se trouve dans le fichier `core/static/bundled/core/cache.ts`. + +Vous devrez modifier ce fichier chaque fois qu'un élément du localStorage +cessera d'être utilisé. +Les modifications à apporter sont les suivantes : + +- incrémenter la version du cache +- ajouter une ligne permettant de retirer votre clef du cache + +```ts hl_lines="2 11" +// increment this number when a breaking change is made with localStorage +const CURRENT_CACHE_VERSION = 2; // <-- changez cette ligne + +export function cacheBuster() { + const version = Number.parseInt(localStorage.getItem("version") ?? "0", 10); + if (version === CURRENT_CACHE_VERSION) { + // The cache schema is up-to-date. Nothing to do. + return; + } + // ... + localStorage.removeItem(""); // <-- et rajoutez cette ligne + localStorage.setItem("version", CURRENT_CACHE_VERSION.toString()); +} +``` + +## Versionnage d'une clef + +Dans le cas où une paire clef-valeur du localStorage subit un changement +dans son schéma de données, utilisez `versionedLocalStorage` : + +```typescript +import { versionedLocalStorage } from "#core:core/localstorage"; + +const foo = () => { + let obj = versionedLocalStorage.getItem("", { version: 1 }); + if (obj === null) { + obj = fetchMyObject(); + versionedLocalStorage.setItem("", obj, { version: 1 }) + } + // Do something with obj... +} +``` + +!!!Warning + + Il existe une différence d'usage entre `localStorage` et `versionedLocalStorage` : + les valeurs données à `localStorage` doivent être des strings (généralement + obtenus avec `JSON.stringify`), tandis que `versionedLocalStorage` utilise + directement des objets JS. + + Cette différence résulte du fait que `versionedLocalStorage` doit légèrement + modifier les données pour y inclure la version, et gérer en interne + la conversion en JSON. \ No newline at end of file diff --git a/eboutic/static/bundled/eboutic/eboutic-index.ts b/eboutic/static/bundled/eboutic/eboutic-index.ts index 43080a58..18573158 100644 --- a/eboutic/static/bundled/eboutic/eboutic-index.ts +++ b/eboutic/static/bundled/eboutic/eboutic-index.ts @@ -1,4 +1,4 @@ -export {}; +import { versionedLocalStorage } from "#core:core/localstorage"; interface BasketItem { priceId: number; @@ -7,8 +7,8 @@ interface BasketItem { unitPrice: number; } -// increment the key number if the data schema of the cached basket changes -const BASKET_CACHE_KEY = "basket1"; +const BASKET_CACHE_KEY = "basket"; +const BASKET_CACHE_VERSION = 1; document.addEventListener("alpine:init", () => { Alpine.data("basket", (lastPurchaseTime?: number) => ({ @@ -34,18 +34,16 @@ document.addEventListener("alpine:init", () => { }, loadBasket(): BasketItem[] { - if (localStorage.getItem(BASKET_CACHE_KEY) === null) { - return []; - } - try { - return JSON.parse(localStorage.getItem(BASKET_CACHE_KEY)); - } catch (_err) { - return []; - } + const cached = versionedLocalStorage.getItem(BASKET_CACHE_KEY, { + version: BASKET_CACHE_VERSION, + }); + return cached ?? []; }, saveBasket() { - localStorage.setItem(BASKET_CACHE_KEY, JSON.stringify(this.basket)); + versionedLocalStorage.setItem(BASKET_CACHE_KEY, this.basket, { + version: BASKET_CACHE_VERSION, + }); localStorage.setItem("basketTimestamp", Date.now().toString()); }, diff --git a/mkdocs.yml b/mkdocs.yml index f537fa76..cd6df0de 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -66,6 +66,8 @@ nav: - Gestion des permissions: tutorial/perms.md - Gestion des groupes: tutorial/groups.md - Les fragments: tutorial/fragments.md + - Frontend: + - localStorage: tutorial/front/localstorage.md - API: - Développement: tutorial/api/dev.md - Connexion à l'API: tutorial/api/connect.md diff --git a/package-lock.json b/package-lock.json index 8dd3583c..8778d436 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "@biomejs/biome": "^2.4.15", "@hey-api/openapi-ts": "^0.94.5", "@types/alpinejs": "^3.13.11", + "@types/alpinejs__sort": "^3.13.0", "@types/cytoscape-cxtmenu": "^3.4.5", "@types/cytoscape-klay": "^3.1.5", "@types/js-cookie": "^3.0.6", @@ -2499,6 +2500,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/alpinejs__sort": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/@types/alpinejs__sort/-/alpinejs__sort-3.13.0.tgz", + "integrity": "sha512-iR9vEy6e3yXbYAK45/hpulzlt8SSKTsvYUl/t5nuWjtbJPoGxzxUUqOm3egp83Gqtf//TyJnDCI4OTebAKDRAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/alpinejs": "*" + } + }, "node_modules/@types/codemirror": { "version": "5.60.17", "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.17.tgz", diff --git a/package.json b/package.json index 16604f0b..52faf51b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@biomejs/biome": "^2.4.15", "@hey-api/openapi-ts": "^0.94.5", "@types/alpinejs": "^3.13.11", + "@types/alpinejs__sort": "^3.13.0", "@types/cytoscape-cxtmenu": "^3.4.5", "@types/cytoscape-klay": "^3.1.5", "@types/js-cookie": "^3.0.6", diff --git a/sas/static/bundled/sas/user/pictures-index.ts b/sas/static/bundled/sas/user/pictures-index.ts index abbdc2c8..99d6a7d7 100644 --- a/sas/static/bundled/sas/user/pictures-index.ts +++ b/sas/static/bundled/sas/user/pictures-index.ts @@ -22,35 +22,35 @@ document.addEventListener("alpine:init", () => { albums: [] as Album[], async fetchPictures(): Promise { - const localStorageKey = `user${config.userId}Pictures`; - const localStorageInvalidationKey = `user${config.userId}PicturesNumber`; - const lastCachedNumber = localStorage.getItem(localStorageInvalidationKey); + // Check the cache before hitting the API. + const storageKey = "userPictures"; + const cacheContent: { userId: number; pictures: PictureSchema[] }[] = JSON.parse( + sessionStorage.getItem(storageKey) || "[]", + ); + const userPictures = cacheContent.find((obj) => obj.userId === config.userId); if ( - lastCachedNumber !== null && - Number.parseInt(lastCachedNumber, 10) === config.nbPictures + userPictures !== undefined && + userPictures.pictures.length === config.nbPictures ) { - return JSON.parse(localStorage.getItem(localStorageKey)); + // The cached value is considered valid + // if it contains the right amount of pictures. + // This amount is known because it is given in the template. + return userPictures.pictures; } const pictures = await paginated(picturesFetchPictures, { // biome-ignore lint/style/useNamingConvention: from python api query: { users_identified: [config.userId] }, } as PicturesFetchPicturesData); + + cacheContent.push({ userId: config.userId, pictures: pictures }); try { - localStorage.setItem(localStorageInvalidationKey, config.nbPictures.toString()); - localStorage.setItem(localStorageKey, JSON.stringify(pictures)); + // cache only the pictures of the last 4 visited profiles + sessionStorage.setItem(storageKey, JSON.stringify(cacheContent.slice(-4))); } catch { - // an exception is raised if the localstorage is entirely filled - // so just delete all cached user pictures. + // an exception is raised if the storage is entirely filled. + // To be as safe as possible, delete the cached pictures. // A cache hit is not worth the page breaking. - Object.keys(localStorage) - .filter( - (key) => - key.startsWith("user") && - (key.endsWith("Pictures") || key.endsWith("PicturesNumber")), - ) - .forEach((key) => { - localStorage.removeItem(key); - }); + sessionStorage.removeItem(storageKey); } return pictures; },