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;
},