diff --git a/core/static/core/js/script.js b/core/static/core/js/script.js index 592164a9..adb15b06 100644 --- a/core/static/core/js/script.js +++ b/core/static/core/js/script.js @@ -123,37 +123,3 @@ function updateQueryString(key, value, action = History.REPLACE, url = null) { return ret; } - -// TODO : If one day a test workflow is made for JS in this project -// please test this function. A all cost. -/** - * Given a paginated endpoint, fetch all the items of this endpoint, - * performing multiple API calls if necessary. - * @param {string} url The paginated endpoint to fetch - * @return {Promise} - */ -// biome-ignore lint/correctness/noUnusedVariables: used in other scripts -async function fetchPaginated(url) { - const maxPerPage = 199; - const paginatedUrl = new URL(url, document.location.origin); - paginatedUrl.searchParams.set("page_size", maxPerPage.toString()); - paginatedUrl.searchParams.set("page", "1"); - - const firstPage = await (await fetch(paginatedUrl)).json(); - const results = firstPage.results; - - const nbPictures = firstPage.count; - const nbPages = Math.ceil(nbPictures / maxPerPage); - - if (nbPages > 1) { - const promises = []; - for (let i = 2; i <= nbPages; i++) { - paginatedUrl.searchParams.set("page", i.toString()); - promises.push( - fetch(paginatedUrl).then((res) => res.json().then((json) => json.results)), - ); - } - results.push(...(await Promise.all(promises)).flat()); - } - return results; -} diff --git a/core/static/webpack/easymde-index.js b/core/static/webpack/easymde-index.js index 3648657e..378c1303 100644 --- a/core/static/webpack/easymde-index.js +++ b/core/static/webpack/easymde-index.js @@ -2,25 +2,21 @@ import "codemirror/lib/codemirror.css"; import "easymde/src/css/easymde.css"; import easyMde from "easymde"; - -// This scripts dependens on Alpine but it should be loaded on every page +import { markdownRenderMarkdown } from "#openapi"; /** * Create a new easymde based textarea * @param {HTMLTextAreaElement} textarea to use - * @param {string} link to the markdown api **/ -window.easymdeFactory = (textarea, markdownApiUrl) => { +window.easymdeFactory = (textarea) => { const easymde = new easyMde({ element: textarea, spellChecker: false, autoDownloadFontAwesome: false, previewRender: Alpine.debounce(async (plainText, preview) => { - const res = await fetch(markdownApiUrl, { - method: "POST", - body: JSON.stringify({ text: plainText }), - }); - preview.innerHTML = await res.text(); + preview.innerHTML = ( + await markdownRenderMarkdown({ body: { text: plainText } }) + ).data; return null; }, 300), forceSync: true, // Avoid validation error on generic create view diff --git a/core/static/webpack/user/family-graph-index.js b/core/static/webpack/user/family-graph-index.js index 8c7a9773..c6eb7278 100644 --- a/core/static/webpack/user/family-graph-index.js +++ b/core/static/webpack/user/family-graph-index.js @@ -1,16 +1,26 @@ import cytoscape from "cytoscape"; import cxtmenu from "cytoscape-cxtmenu"; import klay from "cytoscape-klay"; +import { familyGetFamilyGraph } from "#openapi"; cytoscape.use(klay); cytoscape.use(cxtmenu); -async function getGraphData(url, godfathersDepth, godchildrenDepth) { - const data = await ( - await fetch( - `${url}?godfathers_depth=${godfathersDepth}&godchildren_depth=${godchildrenDepth}`, - ) - ).json(); +async function getGraphData(userId, godfathersDepth, godchildrenDepth) { + const data = ( + await familyGetFamilyGraph({ + path: { + // biome-ignore lint/style/useNamingConvention: api is snake_case + user_id: userId, + }, + query: { + // biome-ignore lint/style/useNamingConvention: api is snake_case + godfathers_depth: godfathersDepth, + // biome-ignore lint/style/useNamingConvention: api is snake_case + godchildren_depth: godchildrenDepth, + }, + }) + ).data; return [ ...data.users.map((user) => { return { data: user }; @@ -160,15 +170,14 @@ function createGraph(container, data, activeUserId) { /** * @typedef FamilyGraphConfig - * @param {string} apiUrl Base url for fetching the tree as a string - * @param {string} activeUser Id of the user to fetch the tree from - * @param {number} depthMin Minimum tree depth for godfathers and godchildren - * @param {number} depthMax Maximum tree depth for godfathers and godchildren + * @property {number} activeUser Id of the user to fetch the tree from + * @property {number} depthMin Minimum tree depth for godfathers and godchildren + * @property {number} depthMax Maximum tree depth for godfathers and godchildren **/ /** * Create a family graph of an user - * @param {FamilyGraphConfig} Configuration + * @param {FamilyGraphConfig} config **/ window.loadFamilyGraph = (config) => { document.addEventListener("alpine:init", () => { @@ -248,7 +257,7 @@ window.loadFamilyGraph = (config) => { async fetchGraphData() { this.graphData = await getGraphData( - config.apiUrl, + config.activeUser, this.godfathersDepth, this.godchildrenDepth, ); diff --git a/core/static/webpack/user/pictures-index.js b/core/static/webpack/user/pictures-index.js index d0be8d55..68f08d25 100644 --- a/core/static/webpack/user/pictures-index.js +++ b/core/static/webpack/user/pictures-index.js @@ -1,5 +1,7 @@ +import { paginated } from "#core:utils/api"; import { HttpReader, ZipWriter } from "@zip.js/zip.js"; import { showSaveFilePicker } from "native-file-system-adapter"; +import { picturesFetchPictures } from "#openapi"; /** * @typedef UserProfile @@ -28,12 +30,12 @@ import { showSaveFilePicker } from "native-file-system-adapter"; /** * @typedef PicturePageConfig - * @param {string} apiUrl Url of the api endpoint to fetch pictures from the user + * @property {number} userId Id of the user to get the pictures from **/ /** * Load user picture page with a nice download bar - * @param {PicturePageConfig} Configuration + * @param {PicturePageConfig} config **/ window.loadPicturePage = (config) => { document.addEventListener("alpine:init", () => { @@ -44,8 +46,10 @@ window.loadPicturePage = (config) => { albums: {}, async init() { - // biome-ignore lint/correctness/noUndeclaredVariables: imported from script.json - this.pictures = await fetchPaginated(config.apiUrl); + this.pictures = await paginated(picturesFetchPictures, { + // biome-ignore lint/style/useNamingConvention: api is in snake_case + query: { users_identified: [config.userId] }, + }); this.albums = this.pictures.reduce((acc, picture) => { if (!acc[picture.album]) { acc[picture.album] = []; diff --git a/core/static/webpack/utils/api.ts b/core/static/webpack/utils/api.ts new file mode 100644 index 00000000..a2c872c7 --- /dev/null +++ b/core/static/webpack/utils/api.ts @@ -0,0 +1,81 @@ +import type { Client, Options, RequestResult } from "@hey-api/client-fetch"; +import { client } from "#openapi"; + +interface PaginatedResponse { + count: number; + next: string | null; + previous: string | null; + results: T[]; +} + +interface PaginatedRequest { + query?: { + page?: number; + // biome-ignore lint/style/useNamingConvention: api is in snake_case + page_size?: number; + }; +} + +type PaginatedEndpoint = ( + options?: Options, +) => RequestResult, unknown, ThrowOnError>; + +// TODO : If one day a test workflow is made for JS in this project +// please test this function. A all cost. +export const paginated = async ( + endpoint: PaginatedEndpoint, + options?: PaginatedRequest, +) => { + const maxPerPage = 199; + options.query.page_size = maxPerPage; + options.query.page = 1; + + const firstPage = (await endpoint(options)).data; + const results = firstPage.results; + + const nbElements = firstPage.count; + const nbPages = Math.ceil(nbElements / maxPerPage); + + if (nbPages > 1) { + const promises: Promise[] = []; + for (let i = 2; i <= nbPages; i++) { + const nextPage = structuredClone(options); + nextPage.query.page = i; + promises.push(endpoint(nextPage).then((res) => res.data.results)); + } + results.push(...(await Promise.all(promises)).flat()); + } + return results; +}; + +interface Request { + client?: Client; +} + +interface InterceptorOptions { + url: string; +} + +type GenericEndpoint = ( + options?: Options, +) => RequestResult; + +/** + * Return the endpoint url of the endpoint + **/ +export const makeUrl = async (endpoint: GenericEndpoint) => { + let url = ""; + const interceptor = (_request: undefined, options: InterceptorOptions) => { + url = options.url; + throw new Error("We don't want to send the request"); + }; + + client.interceptors.request.use(interceptor); + try { + await endpoint({ client: client }); + } catch (_error) { + /* do nothing */ + } + client.interceptors.request.eject(interceptor); + return url; +}; diff --git a/core/templates/core/base.jinja b/core/templates/core/base.jinja index 7492276b..e36c183a 100644 --- a/core/templates/core/base.jinja +++ b/core/templates/core/base.jinja @@ -24,7 +24,7 @@ - + diff --git a/core/templates/core/user_godfathers_tree.jinja b/core/templates/core/user_godfathers_tree.jinja index 979c8ab4..91033b15 100644 --- a/core/templates/core/user_godfathers_tree.jinja +++ b/core/templates/core/user_godfathers_tree.jinja @@ -92,8 +92,7 @@ {% endblock script %} diff --git a/core/templates/core/widgets/markdown_textarea.jinja b/core/templates/core/widgets/markdown_textarea.jinja index 1a35056d..2412497d 100644 --- a/core/templates/core/widgets/markdown_textarea.jinja +++ b/core/templates/core/widgets/markdown_textarea.jinja @@ -7,9 +7,7 @@ diff --git a/core/views/forms.py b/core/views/forms.py index 76f34f7e..29de3ab4 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -39,7 +39,6 @@ from django.forms import ( TextInput, ) from django.templatetags.static import static -from django.urls import reverse from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ from phonenumber_field.widgets import RegionalPhoneNumberWidget @@ -76,7 +75,6 @@ class MarkdownInput(Textarea): "js": static("webpack/easymde-index.js"), "css": static("webpack/easymde-index.css"), } - context["markdown_api_url"] = reverse("api:markdown") return context diff --git a/docs/howto/js-import-paths.md b/docs/howto/js-import-paths.md new file mode 100644 index 00000000..1fd270d1 --- /dev/null +++ b/docs/howto/js-import-paths.md @@ -0,0 +1,39 @@ +Vous avez ajouté une application et vous voulez y mettre du javascript ? + +Vous voulez importer depuis cette nouvelle application dans votre script géré par webpack ? + +Eh bien il faut manuellement enregistrer dans node où les trouver et c'est très simple. + +D'abord, il faut ajouter dans node via `package.json`: + +```json +{ + // ... + "imports": { + // ... + "#mon_app:*": "./mon_app/static/webpack/*" + } + // ... +} +``` + +Ensuite, pour faire fonctionne l'auto-complétion, il faut configurer `tsconfig.json`: + +```json +{ + "compilerOptions": { + // ... + "paths": { + // ... + "#mon_app:*": ["./mon_app/static/webpack/*"] + } + } +} +``` + +Et c'est tout ! + +!!!note + + Il se peut qu'il soit nécessaire de redémarrer `./manage.py runserver` pour + que les changements prennent effet. \ No newline at end of file diff --git a/docs/howto/statics.md b/docs/howto/statics.md index 470e5e2a..2dbacb09 100644 --- a/docs/howto/statics.md +++ b/docs/howto/statics.md @@ -35,8 +35,9 @@ les fichiers sont à mettre dans un dossier `static/webpack` de l'application à Pour accéder au fichier, il faut utiliser `static` comme pour le reste mais en ajouter `webpack/` comme prefix. ```jinja - {# Exemple pour ajouter sith/core/webpack/alpine-index.js #} + {# Example pour ajouter sith/core/webpack/alpine-index.js #} + ``` !!!note @@ -45,6 +46,16 @@ Pour accéder au fichier, il faut utiliser `static` comme pour le reste mais en Les autres fichiers sont disponibles à l'import dans le JavaScript comme si ils étaient tous au même niveau. +### Les imports au sein des fichiers de webpack + +Pour importer au sein de webpack, il faut préfixer ses imports de `#app:`. + +Exemple: + +```js +import { paginated } from "#core:utils/api"; +``` + ## Comment ça fonctionne le post processing ? Le post processing est géré par le module `staticfiles`. Les fichiers sont diff --git a/mkdocs.yml b/mkdocs.yml index c2755dd3..f0cea1ad 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,6 +72,7 @@ nav: - Gérer les migrations: howto/migrations.md - Gérer les traductions: howto/translation.md - Gérer les statics: howto/statics.md + - Ajouter un chemin d'import javascript: howto/js-import-paths.md - Configurer pour la production: howto/prod.md - Ajouter un logo de promo: howto/logo.md - Ajouter une cotisation: howto/subscriptions.md diff --git a/openapi-ts.config.ts b/openapi-ts.config.ts new file mode 100644 index 00000000..d583ffee --- /dev/null +++ b/openapi-ts.config.ts @@ -0,0 +1,10 @@ +// biome-ignore lint/correctness/noNodejsModules: this only used at compile time +import { resolve } from "node:path"; +import { defineConfig } from "@hey-api/openapi-ts"; + +// biome-ignore lint/style/noDefaultExport: needed for openapi-ts +export default defineConfig({ + client: "@hey-api/client-fetch", + input: resolve(__dirname, "./staticfiles/generated/openapi/schema.json"), + output: resolve(__dirname, "./staticfiles/generated/openapi"), +}); diff --git a/package-lock.json b/package-lock.json index d2fa2a3e..a7127ed2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "GPL-3.0-only", "dependencies": { "@fortawesome/fontawesome-free": "^6.6.0", + "@hey-api/client-fetch": "^0.4.0", "@zip.js/zip.js": "^2.7.52", "alpinejs": "^3.14.1", "cytoscape": "^3.30.2", @@ -26,6 +27,7 @@ "@babel/core": "^7.25.2", "@babel/preset-env": "^7.25.4", "@biomejs/biome": "1.9.3", + "@hey-api/openapi-ts": "^0.53.8", "babel-loader": "^9.2.1", "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.0", @@ -33,6 +35,8 @@ "mini-css-extract-plugin": "^2.9.1", "source-map-loader": "^5.0.0", "terser-webpack-plugin": "^5.3.10", + "ts-loader": "^9.5.1", + "typescript": "^5.6.3", "webpack": "^5.94.0", "webpack-cli": "^5.1.4" } @@ -50,6 +54,23 @@ "node": ">=6.0.0" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.7.0", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.7.0.tgz", + "integrity": "sha512-pRrmXMCwnmrkS3MLgAIW5dXRzeTv6GLjkjb4HmxNnvAKXN1Nfzp4KmGADBQvlVUcqi+a5D+hfGDLLnd5NnYxog==", + "dev": true, + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, "node_modules/@babel/code-frame": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", @@ -1995,6 +2016,41 @@ "node": ">=6" } }, + "node_modules/@hey-api/client-fetch": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.4.0.tgz", + "integrity": "sha512-T8T3yCl2+AiVVDP6tvfnU/rXOkEHddMTOYCZXUVbydj7URVErh5BelIa8UWBkFYZBP2/mi2nViScNhe9eBolPw==" + }, + "node_modules/@hey-api/openapi-ts": { + "version": "0.53.8", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.53.8.tgz", + "integrity": "sha512-UbiaIq+JNgG00N/iWYk+LSivOBgWsfGxEHDleWEgQcQr3q7oZJTKL8oH87+KkFDDbUngm1g8lnKI/zLdu1aElQ==", + "dev": true, + "dependencies": { + "@apidevtools/json-schema-ref-parser": "11.7.0", + "c12": "2.0.1", + "commander": "12.1.0", + "handlebars": "4.7.8" + }, + "bin": { + "openapi-ts": "bin/index.cjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/@hey-api/openapi-ts/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2098,6 +2154,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2542,6 +2604,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, "node_modules/babel-loader": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz", @@ -2679,6 +2747,18 @@ "balanced-match": "^1.0.0" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.23.3", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", @@ -2717,6 +2797,34 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/c12": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/c12/-/c12-2.0.1.tgz", + "integrity": "sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==", + "dev": true, + "dependencies": { + "chokidar": "^4.0.1", + "confbox": "^0.1.7", + "defu": "^6.1.4", + "dotenv": "^16.4.5", + "giget": "^1.2.3", + "jiti": "^2.3.0", + "mlly": "^1.7.1", + "ohash": "^1.1.4", + "pathe": "^1.1.2", + "perfect-debounce": "^1.0.0", + "pkg-types": "^1.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, "node_modules/caniuse-api": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", @@ -2792,6 +2900,30 @@ "node": ">=8" } }, + "node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "dev": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -2816,6 +2948,15 @@ "node": ">=8" } }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "dev": true, + "dependencies": { + "consola": "^3.2.3" + } + }, "node_modules/clone-deep": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", @@ -2883,6 +3024,21 @@ "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", "dev": true }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true + }, + "node_modules/consola": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz", + "integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==", + "dev": true, + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3280,6 +3436,18 @@ } } }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "dev": true + }, + "node_modules/destr": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.3.tgz", + "integrity": "sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==", + "dev": true + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -3335,6 +3503,18 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -3485,6 +3665,29 @@ "node": ">=0.8.x" } }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/expose-loader": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-5.0.0.tgz", @@ -3551,6 +3754,18 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/find-cache-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", @@ -3689,6 +3904,36 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3707,6 +3952,37 @@ "node": ">=6.9.0" } }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/giget": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/giget/-/giget-1.2.3.tgz", + "integrity": "sha512-8EHPljDvs7qKykr6uw8b+lqLiUc/vUg+KVTI0uND4s63TdsZM2Xus3mflvF0DDG9SiM4RlCkFGL+7aAjRmV7KA==", + "dev": true, + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.2.3", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.3", + "nypm": "^0.3.8", + "ohash": "^1.1.3", + "pathe": "^1.1.2", + "tar": "^6.2.0" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/glob": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.0.tgz", @@ -3750,6 +4026,27 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3771,6 +4068,15 @@ "node": ">= 0.4" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -3846,6 +4152,15 @@ "node": ">=8" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-plain-object": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", @@ -3858,6 +4173,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3920,6 +4247,15 @@ "node": ">= 10.13.0" } }, + "node_modules/jiti": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.3.3.tgz", + "integrity": "sha512-EX4oNDwcXSivPrw2qKH2LB5PoFxEvgtv2JgwW0bU858HoLQ+kutSvjLMUqBd0PeJYEinLWhoI9Ol0eYMqj/wNQ==", + "dev": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/jquery": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", @@ -3944,6 +4280,18 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -4076,6 +4424,19 @@ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -4097,6 +4458,18 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mini-css-extract-plugin": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.1.tgz", @@ -4184,6 +4557,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -4192,6 +4574,61 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mlly": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.2.tgz", + "integrity": "sha512-tN3dvVHYVz4DhSXinXIk7u9syPYaJvio118uomkovAtWBT+RdbP6Lfh/5Lvo519YMmwBafwlh20IPTXIStscpA==", + "dev": true, + "dependencies": { + "acorn": "^8.12.1", + "pathe": "^1.1.2", + "pkg-types": "^1.2.0", + "ufo": "^1.5.4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4262,12 +4699,45 @@ "node": ">=10.5.0" } }, + "node_modules/node-fetch-native": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.4.tgz", + "integrity": "sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==", + "dev": true + }, "node_modules/node-releases": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", "dev": true }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -4280,6 +4750,47 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nypm": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.3.12.tgz", + "integrity": "sha512-D3pzNDWIvgA+7IORhD/IuWzEk4uXv6GsgOxiid4UU3h9oq5IqV1KtPDi63n4sZJ/xcWlr88c0QM2RgN5VbOhFA==", + "dev": true, + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.2.3", + "execa": "^8.0.1", + "pathe": "^1.1.2", + "pkg-types": "^1.2.0", + "ufo": "^1.5.4" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": "^14.16.0 || >=16.10.0" + } + }, + "node_modules/ohash": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.4.tgz", + "integrity": "sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==", + "dev": true + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -4359,6 +4870,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true + }, "node_modules/picocolors": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", @@ -4389,6 +4912,17 @@ "node": ">=8" } }, + "node_modules/pkg-types": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.2.1.tgz", + "integrity": "sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==", + "dev": true, + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.2", + "pathe": "^1.1.2" + } + }, "node_modules/postcss": { "version": "8.4.47", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", @@ -4924,6 +5458,29 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "dev": true, + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "dev": true, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -5297,6 +5854,18 @@ "node": ">=8" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/stylehacks": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.4.tgz", @@ -5383,6 +5952,38 @@ "node": ">=6" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, "node_modules/terser": { "version": "5.33.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.33.0.tgz", @@ -5444,11 +6045,84 @@ "node": ">=4" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-loader": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.1.tgz", + "integrity": "sha512-rNH3sK9kGZcH9dYzC7CewQm4NtxJTjSEVRJ2DyBZR7f8/wcta+iV44UPCXc5+nzDzivKtlzV6c9P4e+oFhDLYg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "typescript": "*", + "webpack": "^5.0.0" + } + }, + "node_modules/ts-loader/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/typo-js": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/typo-js/-/typo-js-1.2.4.tgz", "integrity": "sha512-Oy/k+tFle5NAA3J/yrrYGfvEnPVrDZ8s8/WCwjUE75k331QyKIsFss7byQ/PzBmXLY6h1moRnZbnaxWBe3I3CA==" }, + "node_modules/ufo": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz", + "integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==", + "dev": true + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", @@ -5705,6 +6379,12 @@ "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", "dev": true }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", diff --git a/package.json b/package.json index 780136a3..9701aa51 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,16 @@ "author": "", "license": "GPL-3.0-only", "sideEffects": [".css"], + "imports": { + "#openapi": "./staticfiles/generated/openapi/index.ts", + "#core:*": "./core/static/webpack/*", + "#pedagogy:*": "./pedagogy/static/webpack/*" + }, "devDependencies": { "@babel/core": "^7.25.2", "@babel/preset-env": "^7.25.4", "@biomejs/biome": "1.9.3", + "@hey-api/openapi-ts": "^0.53.8", "babel-loader": "^9.2.1", "css-loader": "^7.1.2", "css-minimizer-webpack-plugin": "^7.0.0", @@ -24,11 +30,14 @@ "mini-css-extract-plugin": "^2.9.1", "source-map-loader": "^5.0.0", "terser-webpack-plugin": "^5.3.10", + "ts-loader": "^9.5.1", + "typescript": "^5.6.3", "webpack": "^5.94.0", "webpack-cli": "^5.1.4" }, "dependencies": { "@fortawesome/fontawesome-free": "^6.6.0", + "@hey-api/client-fetch": "^0.4.0", "@zip.js/zip.js": "^2.7.52", "alpinejs": "^3.14.1", "cytoscape": "^3.30.2", diff --git a/pedagogy/static/webpack/pedagogy/guide-index.js b/pedagogy/static/webpack/pedagogy/guide-index.js new file mode 100644 index 00000000..6740b935 --- /dev/null +++ b/pedagogy/static/webpack/pedagogy/guide-index.js @@ -0,0 +1,120 @@ +import { uvFetchUvList } from "#openapi"; + +const pageDefault = 1; +const pageSizeDefault = 100; + +document.addEventListener("alpine:init", () => { + Alpine.data("uv_search", () => ({ + uvs: { + count: 0, + next: null, + previous: null, + results: [], + }, + loading: false, + page: pageDefault, + // biome-ignore lint/style/useNamingConvention: api is in snake_case + page_size: pageSizeDefault, + search: "", + department: [], + // biome-ignore lint/style/useNamingConvention: api is in snake_case + credit_type: [], + semester: [], + // biome-ignore lint/style/useNamingConvention: api is in snake_case + to_change: [], + pushstate: History.PUSH, + + update: undefined, + + initializeArgs() { + const url = new URLSearchParams(window.location.search); + this.pushstate = History.REPLACE; + + this.page = Number.parseInt(url.get("page")) || pageDefault; + this.page_size = Number.parseInt(url.get("page_size")) || pageSizeDefault; + this.search = url.get("search") || ""; + this.department = url.getAll("department"); + this.credit_type = url.getAll("credit_type"); + /* The semester is easier to use on the backend as an enum (spring/autumn/both/none) + and easier to use on the frontend as an array ([spring, autumn]). + Thus there is some conversion involved when both communicate together */ + this.semester = url.has("semester") ? url.get("semester").split("_AND_") : []; + + this.update(); + }, + + async init() { + this.update = Alpine.debounce(async () => { + /* Create the whole url before changing everything all at once */ + const first = this.to_change.shift(); + // biome-ignore lint/correctness/noUndeclaredVariables: defined in script.js + let url = updateQueryString(first.param, first.value, History.NONE); + for (const value of this.to_change) { + // biome-ignore lint/correctness/noUndeclaredVariables: defined in script.js + url = updateQueryString(value.param, value.value, History.NONE, url); + } + // biome-ignore lint/correctness/noUndeclaredVariables: defined in script.js + updateQueryString(first.param, first.value, this.pushstate, url); + await this.fetchData(); /* reload data on form change */ + this.to_change = []; + this.pushstate = History.PUSH; + }, 50); + + const searchParams = ["search", "department", "credit_type", "semester"]; + const paginationParams = ["page", "page_size"]; + + for (const param of searchParams) { + this.$watch(param, () => { + if (this.pushstate !== History.PUSH) { + /* This means that we are doing a mass param edit */ + return; + } + /* Reset pagination on search */ + this.page = pageDefault; + this.page_size = pageSizeDefault; + }); + } + for (const param of searchParams.concat(paginationParams)) { + this.$watch(param, (value) => { + this.to_change.push({ param: param, value: value }); + this.update(); + }); + } + window.addEventListener("popstate", () => { + this.initializeArgs(); + }); + this.initializeArgs(); + }, + + async fetchData() { + this.loading = true; + const args = { + // biome-ignore lint/style/useNamingConvention: api is in snake_case + page_size: this.page_size, + }; + for (const [param, value] of new URL( + window.location.href, + ).searchParams.entries()) { + // Deal with array type params + if (["credit_type", "department", "semester"].includes(param)) { + if (args[param] === undefined) { + args[param] = []; + } + args[param].push(value); + } else { + args[param] = value; + } + } + this.uvs = ( + await uvFetchUvList({ + query: args, + }) + ).data; + this.loading = false; + }, + + maxPage() { + return Math.ceil(this.uvs.count / this.page_size); + }, + })); +}); diff --git a/pedagogy/templates/pedagogy/guide.jinja b/pedagogy/templates/pedagogy/guide.jinja index 7155748f..be9cce86 100644 --- a/pedagogy/templates/pedagogy/guide.jinja +++ b/pedagogy/templates/pedagogy/guide.jinja @@ -9,6 +9,10 @@ {% endblock %} +{% block additional_js %} + +{% endblock %} + {% block head %} {{ super() }} @@ -113,105 +117,6 @@ - {{ paginate_alpine("page", "max_page()") }} + {{ paginate_alpine("page", "maxPage()") }} - {% endblock content %} diff --git a/sas/static/sas/js/viewer.js b/sas/static/sas/js/viewer.js deleted file mode 100644 index b72276fe..00000000 --- a/sas/static/sas/js/viewer.js +++ /dev/null @@ -1,268 +0,0 @@ -/** - * @typedef PictureIdentification - * @property {number} id The actual id of the identification - * @property {UserProfile} user The identified user - */ - -/** - * A container for a picture with the users identified on it - * able to prefetch its data. - */ -class PictureWithIdentifications { - identifications = null; - imageLoading = false; - identificationsLoading = false; - - /** - * @param {Picture} picture - */ - constructor(picture) { - Object.assign(this, picture); - } - - /** - * @param {Picture} picture - */ - static fromPicture(picture) { - return new PictureWithIdentifications(picture); - } - - /** - * If not already done, fetch the users identified on this picture and - * populate the identifications field - * @param {?Object=} options - * @return {Promise} - */ - async loadIdentifications(options) { - if (this.identificationsLoading) { - return; // The users are already being fetched. - } - if (!!this.identifications && !options?.forceReload) { - // The users are already fetched - // and the user does not want to force the reload - return; - } - this.identificationsLoading = true; - const url = `/api/sas/picture/${this.id}/identified`; - this.identifications = await (await fetch(url)).json(); - this.identificationsLoading = false; - } - - /** - * Preload the photo and the identifications - * @return {Promise} - */ - async preload() { - const img = new Image(); - img.src = this.compressed_url; - if (!img.complete) { - this.imageLoading = true; - img.addEventListener("load", () => { - this.imageLoading = false; - }); - } - await this.loadIdentifications(); - } -} - -document.addEventListener("alpine:init", () => { - Alpine.data("picture_viewer", () => ({ - /** - * All the pictures that can be displayed on this picture viewer - * @type PictureWithIdentifications[] - **/ - pictures: [], - /** - * The currently displayed picture - * Default dummy data are pre-loaded to avoid javascript error - * when loading the page at the beginning - * @type PictureWithIdentifications - **/ - currentPicture: { - // biome-ignore lint/style/useNamingConvention: json is snake_case - is_moderated: true, - id: null, - name: "", - // biome-ignore lint/style/useNamingConvention: json is snake_case - display_name: "", - // biome-ignore lint/style/useNamingConvention: json is snake_case - compressed_url: "", - // biome-ignore lint/style/useNamingConvention: json is snake_case - profile_url: "", - // biome-ignore lint/style/useNamingConvention: json is snake_case - full_size_url: "", - owner: "", - date: new Date(), - identifications: [], - }, - /** - * The picture which will be displayed next if the user press the "next" button - * @type ?PictureWithIdentifications - **/ - nextPicture: null, - /** - * The picture which will be displayed next if the user press the "previous" button - * @type ?PictureWithIdentifications - **/ - previousPicture: null, - /** - * The select2 component used to identify users - **/ - selector: undefined, - /** - * true if the page is in a loading state, else false - **/ - /** - * Error message when a moderation operation fails - * @type string - **/ - moderationError: "", - /** - * Method of pushing new url to the browser history - * Used by popstate event and always reset to it's default value when used - * @type History - **/ - pushstate: History.PUSH, - - async init() { - // biome-ignore lint/correctness/noUndeclaredVariables: Imported from script.js - this.pictures = (await fetchPaginated(pictureEndpoint)).map( - PictureWithIdentifications.fromPicture, - ); - // biome-ignore lint/correctness/noUndeclaredVariables: Imported from script.js - this.selector = sithSelect2({ - element: $(this.$refs.search), - // biome-ignore lint/correctness/noUndeclaredVariables: Imported from script.js - dataSource: remoteDataSource("/api/user/search", { - excluded: () => [ - ...(this.currentPicture.identifications || []).map((i) => i.user.id), - ], - resultConverter: (obj) => new Object({ ...obj, text: obj.display_name }), - }), - pictureGetter: (user) => user.profile_pict, - }); - // biome-ignore lint/correctness/noUndeclaredVariables: Imported from picture.jinja - this.currentPicture = this.pictures.find((i) => i.id === firstPictureId); - this.$watch("currentPicture", (current, previous) => { - if (current === previous) { - /* Avoid recursive updates */ - return; - } - this.updatePicture(); - }); - window.addEventListener("popstate", async (event) => { - if (!event.state || event.state.sasPictureId === undefined) { - return; - } - this.pushstate = History.REPLACE; - this.currentPicture = this.pictures.find( - (i) => i.id === Number.parseInt(event.state.sasPictureId), - ); - }); - this.pushstate = History.REPLACE; /* Avoid first url push */ - await this.updatePicture(); - }, - - /** - * Update the page. - * Called when the `currentPicture` property changes. - * - * The url is modified without reloading the page, - * and the previous picture, the next picture and - * the list of identified users are updated. - */ - async updatePicture() { - const updateArgs = [ - { sasPictureId: this.currentPicture.id }, - "", - `/sas/picture/${this.currentPicture.id}/`, - ]; - if (this.pushstate === History.REPLACE) { - window.history.replaceState(...updateArgs); - this.pushstate = History.PUSH; - } else { - window.history.pushState(...updateArgs); - } - - this.moderationError = ""; - const index = this.pictures.indexOf(this.currentPicture); - this.previousPicture = this.pictures[index - 1] || null; - this.nextPicture = this.pictures[index + 1] || null; - await this.currentPicture.loadIdentifications(); - this.$refs.mainPicture?.addEventListener("load", () => { - // once the current picture is loaded, - // start preloading the next and previous pictures - this.nextPicture?.preload(); - this.previousPicture?.preload(); - }); - }, - - async moderatePicture() { - const res = await fetch(`/api/sas/picture/${this.currentPicture.id}/moderate`, { - method: "PATCH", - }); - if (!res.ok) { - this.moderationError = `${gettext("Couldn't moderate picture")} : ${res.statusText}`; - return; - } - this.currentPicture.is_moderated = true; - this.currentPicture.askedForRemoval = false; - }, - - async deletePicture() { - const res = await fetch(`/api/sas/picture/${this.currentPicture.id}`, { - method: "DELETE", - }); - if (!res.ok) { - this.moderationError = `${gettext("Couldn't delete picture")} : ${res.statusText}`; - return; - } - this.pictures.splice(this.pictures.indexOf(this.currentPicture), 1); - if (this.pictures.length === 0) { - // The deleted picture was the only one in the list. - // As the album is now empty, go back to the parent page - // biome-ignore lint/correctness/noUndeclaredVariables: imported from picture.jinja - document.location.href = albumUrl; - } - this.currentPicture = this.nextPicture || this.previousPicture; - }, - - /** - * Send the identification request and update the list of identified users. - */ - async submitIdentification() { - const url = `/api/sas/picture/${this.currentPicture.id}/identified`; - await fetch(url, { - method: "PUT", - body: JSON.stringify(this.selector.val().map((i) => Number.parseInt(i))), - }); - // refresh the identified users list - await this.currentPicture.loadIdentifications({ forceReload: true }); - this.selector.empty().trigger("change"); - }, - - /** - * Check if an identification can be removed by the currently logged user - * @param {PictureIdentification} identification - * @return {boolean} - */ - canBeRemoved(identification) { - // biome-ignore lint/correctness/noUndeclaredVariables: imported from picture.jinja - return userIsSasAdmin || identification.user.id === userId; - }, - - /** - * Untag a user from the current picture - * @param {PictureIdentification} identification - */ - async removeIdentification(identification) { - const res = await fetch(`/api/sas/relation/${identification.id}`, { - method: "DELETE", - }); - if (res.ok && Array.isArray(this.currentPicture.identifications)) { - this.currentPicture.identifications = - this.currentPicture.identifications.filter((i) => i.id !== identification.id); - } - }, - })); -}); diff --git a/sas/static/webpack/sas/viewer-index.js b/sas/static/webpack/sas/viewer-index.js new file mode 100644 index 00000000..35f393e0 --- /dev/null +++ b/sas/static/webpack/sas/viewer-index.js @@ -0,0 +1,302 @@ +import { paginated } from "#core:utils/api"; +import { + picturesDeletePicture, + picturesFetchIdentifications, + picturesFetchPictures, + picturesIdentifyUsers, + picturesModeratePicture, + usersidentifiedDeleteRelation, +} from "#openapi"; + +/** + * @typedef PictureIdentification + * @property {number} id The actual id of the identification + * @property {UserProfile} user The identified user + */ + +/** + * A container for a picture with the users identified on it + * able to prefetch its data. + */ +class PictureWithIdentifications { + identifications = null; + imageLoading = false; + identificationsLoading = false; + + /** + * @param {Picture} picture + */ + constructor(picture) { + Object.assign(this, picture); + } + + /** + * @param {Picture} picture + */ + static fromPicture(picture) { + return new PictureWithIdentifications(picture); + } + + /** + * If not already done, fetch the users identified on this picture and + * populate the identifications field + * @param {?Object=} options + * @return {Promise} + */ + async loadIdentifications(options) { + if (this.identificationsLoading) { + return; // The users are already being fetched. + } + if (!!this.identifications && !options?.forceReload) { + // The users are already fetched + // and the user does not want to force the reload + return; + } + this.identificationsLoading = true; + this.identifications = ( + await picturesFetchIdentifications({ + // biome-ignore lint/style/useNamingConvention: api is in snake_case + path: { picture_id: this.id }, + }) + ).data; + this.identificationsLoading = false; + } + + /** + * Preload the photo and the identifications + * @return {Promise} + */ + async preload() { + const img = new Image(); + img.src = this.compressed_url; + if (!img.complete) { + this.imageLoading = true; + img.addEventListener("load", () => { + this.imageLoading = false; + }); + } + await this.loadIdentifications(); + } +} + +/** + * @typedef ViewerConfig + * @property {number} userId Id of the user to get the pictures from + * @property {number} albumId Id of the album to displlay + * @property {number} firstPictureId id of the first picture to load on the page + * @property {bool} userIsSasAdmin if the user is sas admin + **/ + +/** + * Load user picture page with a nice download bar + * @param {ViewerConfig} config + **/ +window.loadViewer = (config) => { + document.addEventListener("alpine:init", () => { + Alpine.data("picture_viewer", () => ({ + /** + * All the pictures that can be displayed on this picture viewer + * @type PictureWithIdentifications[] + **/ + pictures: [], + /** + * The currently displayed picture + * Default dummy data are pre-loaded to avoid javascript error + * when loading the page at the beginning + * @type PictureWithIdentifications + **/ + currentPicture: { + // biome-ignore lint/style/useNamingConvention: api is in snake_case + is_moderated: true, + id: null, + name: "", + // biome-ignore lint/style/useNamingConvention: api is in snake_case + display_name: "", + // biome-ignore lint/style/useNamingConvention: api is in snake_case + compressed_url: "", + // biome-ignore lint/style/useNamingConvention: api is in snake_case + profile_url: "", + // biome-ignore lint/style/useNamingConvention: api is in snake_case + full_size_url: "", + owner: "", + date: new Date(), + identifications: [], + }, + /** + * The picture which will be displayed next if the user press the "next" button + * @type ?PictureWithIdentifications + **/ + nextPicture: null, + /** + * The picture which will be displayed next if the user press the "previous" button + * @type ?PictureWithIdentifications + **/ + previousPicture: null, + /** + * The select2 component used to identify users + **/ + selector: undefined, + /** + * true if the page is in a loading state, else false + **/ + /** + * Error message when a moderation operation fails + * @type string + **/ + moderationError: "", + /** + * Method of pushing new url to the browser history + * Used by popstate event and always reset to it's default value when used + * @type History + **/ + pushstate: History.PUSH, + + async init() { + this.pictures = ( + await paginated(picturesFetchPictures, { + // biome-ignore lint/style/useNamingConvention: api is in snake_case + query: { album_id: config.albumId }, + }) + ).map(PictureWithIdentifications.fromPicture); + // biome-ignore lint/correctness/noUndeclaredVariables: Imported from sith-select2.js + this.selector = sithSelect2({ + element: $(this.$refs.search), + // biome-ignore lint/correctness/noUndeclaredVariables: Imported from sith-select2.js + dataSource: remoteDataSource("/api/user/search", { + excluded: () => [ + ...(this.currentPicture.identifications || []).map((i) => i.user.id), + ], + resultConverter: (obj) => new Object({ ...obj, text: obj.display_name }), + }), + pictureGetter: (user) => user.profile_pict, + }); + this.currentPicture = this.pictures.find((i) => i.id === config.firstPictureId); + this.$watch("currentPicture", (current, previous) => { + if (current === previous) { + /* Avoid recursive updates */ + return; + } + this.updatePicture(); + }); + window.addEventListener("popstate", async (event) => { + if (!event.state || event.state.sasPictureId === undefined) { + return; + } + this.pushstate = History.REPLACE; + this.currentPicture = this.pictures.find( + (i) => i.id === Number.parseInt(event.state.sasPictureId), + ); + }); + this.pushstate = History.REPLACE; /* Avoid first url push */ + await this.updatePicture(); + }, + + /** + * Update the page. + * Called when the `currentPicture` property changes. + * + * The url is modified without reloading the page, + * and the previous picture, the next picture and + * the list of identified users are updated. + */ + async updatePicture() { + const updateArgs = [ + { sasPictureId: this.currentPicture.id }, + "", + `/sas/picture/${this.currentPicture.id}/`, + ]; + if (this.pushstate === History.REPLACE) { + window.history.replaceState(...updateArgs); + this.pushstate = History.PUSH; + } else { + window.history.pushState(...updateArgs); + } + + this.moderationError = ""; + const index = this.pictures.indexOf(this.currentPicture); + this.previousPicture = this.pictures[index - 1] || null; + this.nextPicture = this.pictures[index + 1] || null; + await this.currentPicture.loadIdentifications(); + this.$refs.mainPicture?.addEventListener("load", () => { + // once the current picture is loaded, + // start preloading the next and previous pictures + this.nextPicture?.preload(); + this.previousPicture?.preload(); + }); + }, + + async moderatePicture() { + const res = await picturesModeratePicture({ + // biome-ignore lint/style/useNamingConvention: api is in snake_case + path: { picture_id: this.currentPicture.id }, + }); + if (res.error) { + this.moderationError = `${gettext("Couldn't moderate picture")} : ${res.statusText}`; + return; + } + this.currentPicture.is_moderated = true; + this.currentPicture.askedForRemoval = false; + }, + + async deletePicture() { + const res = await picturesDeletePicture({ + // biome-ignore lint/style/useNamingConvention: api is in snake_case + path: { picture_id: this.currentPicture.id }, + }); + if (res.error) { + this.moderationError = `${gettext("Couldn't delete picture")} : ${res.statusText}`; + return; + } + this.pictures.splice(this.pictures.indexOf(this.currentPicture), 1); + if (this.pictures.length === 0) { + // The deleted picture was the only one in the list. + // As the album is now empty, go back to the parent page + document.location.href = config.albumUrl; + } + this.currentPicture = this.nextPicture || this.previousPicture; + }, + + /** + * Send the identification request and update the list of identified users. + */ + async submitIdentification() { + await picturesIdentifyUsers({ + path: { + // biome-ignore lint/style/useNamingConvention: api is in snake_case + picture_id: this.currentPicture.id, + }, + body: this.selector.val().map((i) => Number.parseInt(i)), + }); + // refresh the identified users list + await this.currentPicture.loadIdentifications({ forceReload: true }); + this.selector.empty().trigger("change"); + }, + + /** + * Check if an identification can be removed by the currently logged user + * @param {PictureIdentification} identification + * @return {boolean} + */ + canBeRemoved(identification) { + return config.userIsSasAdmin || identification.user.id === config.userId; + }, + + /** + * Untag a user from the current picture + * @param {PictureIdentification} identification + */ + async removeIdentification(identification) { + const res = await usersidentifiedDeleteRelation({ + // biome-ignore lint/style/useNamingConvention: api is in snake_case + path: { relation_id: identification.id }, + }); + if (!res.error && Array.isArray(this.currentPicture.identifications)) { + this.currentPicture.identifications = + this.currentPicture.identifications.filter( + (i) => i.id !== identification.id, + ); + } + }, + })); + }); +}; diff --git a/sas/templates/sas/picture.jinja b/sas/templates/sas/picture.jinja index cf6b8475..046064eb 100644 --- a/sas/templates/sas/picture.jinja +++ b/sas/templates/sas/picture.jinja @@ -5,7 +5,7 @@ {%- endblock -%} {%- block additional_js -%} - + {%- endblock -%} {% block title %} @@ -171,10 +171,14 @@ {% block script %} {{ super() }} {% endblock %} diff --git a/staticfiles/apps.py b/staticfiles/apps.py index d3aba905..e652c18e 100644 --- a/staticfiles/apps.py +++ b/staticfiles/apps.py @@ -5,8 +5,10 @@ from django.contrib.staticfiles.apps import StaticFilesConfig GENERATED_ROOT = Path(__file__).parent.resolve() / "generated" IGNORE_PATTERNS_WEBPACK = ["webpack/*"] IGNORE_PATTERNS_SCSS = ["*.scss"] +IGNORE_PATTERNS_TYPESCRIPT = ["*.ts"] IGNORE_PATTERNS = [ *StaticFilesConfig.ignore_patterns, + *IGNORE_PATTERNS_TYPESCRIPT, *IGNORE_PATTERNS_WEBPACK, *IGNORE_PATTERNS_SCSS, ] diff --git a/staticfiles/management/commands/collectstatic.py b/staticfiles/management/commands/collectstatic.py index 9472a4ba..9bee7fbf 100644 --- a/staticfiles/management/commands/collectstatic.py +++ b/staticfiles/management/commands/collectstatic.py @@ -7,7 +7,7 @@ from django.contrib.staticfiles.management.commands.collectstatic import ( ) from staticfiles.apps import GENERATED_ROOT, IGNORE_PATTERNS_SCSS -from staticfiles.processors import Scss, Webpack +from staticfiles.processors import OpenApi, Scss, Webpack class Command(CollectStatic): @@ -50,6 +50,7 @@ class Command(CollectStatic): return Path(location) Scss.compile(self.collect_scss()) + OpenApi.compile() # This needs to be prior to webpack Webpack.compile() collected = super().collect() diff --git a/staticfiles/management/commands/runserver.py b/staticfiles/management/commands/runserver.py index a3255794..9b7f3a34 100644 --- a/staticfiles/management/commands/runserver.py +++ b/staticfiles/management/commands/runserver.py @@ -6,13 +6,15 @@ from django.contrib.staticfiles.management.commands.runserver import ( ) from django.utils.autoreload import DJANGO_AUTORELOAD_ENV -from staticfiles.processors import Webpack +from staticfiles.processors import OpenApi, Webpack class Command(Runserver): """Light wrapper around the statics runserver that integrates webpack auto bundling""" def run(self, **options): + # OpenApi generation needs to be before webpack + OpenApi.compile() # Only run webpack server when debug is enabled # Also protects from re-launching the server if django reloads it if os.environ.get(DJANGO_AUTORELOAD_ENV) is None and settings.DEBUG: diff --git a/staticfiles/processors.py b/staticfiles/processors.py index ec1e33f0..dd5b73c1 100644 --- a/staticfiles/processors.py +++ b/staticfiles/processors.py @@ -1,6 +1,7 @@ import logging import subprocess from dataclasses import dataclass +from hashlib import sha1 from pathlib import Path from typing import Iterable @@ -8,6 +9,7 @@ import rjsmin import sass from django.conf import settings +from sith.urls import api from staticfiles.apps import GENERATED_ROOT @@ -71,3 +73,38 @@ class JS: minified = rjsmin.jsmin(p.read_text()) p.write_text(minified) logging.getLogger("main").info(f"Minified {path}") + + +class OpenApi: + OPENAPI_DIR = GENERATED_ROOT / "openapi" + + @classmethod + def compile(cls): + """Compile a typescript client for the sith API. Only generates it if it changed""" + logging.getLogger("django").info("Compiling open api typescript client") + out = cls.OPENAPI_DIR / "schema.json" + cls.OPENAPI_DIR.mkdir(parents=True, exist_ok=True) + + old_hash = "" + if out.exists(): + with open(out, "rb") as f: + old_hash = sha1(f.read()).hexdigest() + + schema = api.get_openapi_schema() + # Remove hash from operationIds + # This is done for cache invalidation but this is too aggressive + for path in schema["paths"].values(): + for action, desc in path.items(): + path[action]["operationId"] = "_".join( + desc["operationId"].split("_")[:-1] + ) + schema = str(schema) + + if old_hash == sha1(schema.encode("utf-8")).hexdigest(): + logging.getLogger("django").info("✨ Api did not change, nothing to do ✨") + return + + with open(out, "w") as f: + _ = f.write(schema) + + subprocess.run(["npx", "openapi-ts"]).check_returncode() diff --git a/staticfiles/storage.py b/staticfiles/storage.py index ad7aa99c..3aaca470 100644 --- a/staticfiles/storage.py +++ b/staticfiles/storage.py @@ -12,7 +12,7 @@ from staticfiles.processors import JS, Scss class ManifestPostProcessingStorage(ManifestStaticFilesStorage): def url(self, name: str, *, force: bool = False) -> str: - """Get the URL for a file, convert .scss calls to .css ones""" + """Get the URL for a file, convert .scss calls to .css ones and .ts to .js""" # This name swap has to be done here # Otherwise, the manifest isn't aware of the file and can't work properly path = Path(name) @@ -27,6 +27,9 @@ class ManifestPostProcessingStorage(ManifestStaticFilesStorage): ) name = str(path.with_suffix(".css")) + elif path.suffix == ".ts": + name = str(path.with_suffix(".js")) + return super().url(name, force=force) def post_process( diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..6c70fc04 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "outDir": "./staticfiles/generated/webpack/", + "sourceMap": true, + "noImplicitAny": true, + "module": "es6", + "target": "es5", + "allowJs": true, + "moduleResolution": "node", + "paths": { + "#openapi": ["./staticfiles/generated/openapi/index.ts"], + "#core:*": ["./core/static/webpack/*"], + "#pedagogy:*": ["./pedagogy/static/webpack/*"] + } + } +} diff --git a/webpack.config.js b/webpack.config.js index aa09ec7c..ca2b7046 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -6,25 +6,33 @@ const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); const TerserPlugin = require("terser-webpack-plugin"); module.exports = { - entry: glob.sync("./!(static)/static/webpack/**/*?(-)index.js").reduce((obj, el) => { - // We include the path inside the webpack folder in the name - const relativePath = []; - const fullPath = path.parse(el); - for (const dir of fullPath.dir.split("/").reverse()) { - if (dir === "webpack") { - break; + entry: glob + .sync("./!(static)/static/webpack/**/*?(-)index.[j|t]s?(x)") + .reduce((obj, el) => { + // We include the path inside the webpack folder in the name + const relativePath = []; + const fullPath = path.parse(el); + for (const dir of fullPath.dir.split("/").reverse()) { + if (dir === "webpack") { + break; + } + relativePath.push(dir); } - relativePath.push(dir); - } - relativePath.push(fullPath.name); - obj[relativePath.join("/")] = `./${el}`; - return obj; - }, {}), + relativePath.push(fullPath.name); + obj[relativePath.join("/")] = `./${el}`; + return obj; + }, {}), + cache: { + type: "filesystem", // This reduces typescript compilation time like crazy when you restart the server + }, output: { filename: "[name].js", path: path.resolve(__dirname, "./staticfiles/generated/webpack"), clean: true, }, + resolve: { + extensions: [".tsx", ".ts", ".js"], + }, plugins: [new MiniCssExtractPlugin()], optimization: { minimizer: [ @@ -62,6 +70,7 @@ module.exports = { loader: "babel-loader", options: { presets: ["@babel/preset-env"], + cacheDirectory: true, }, }, }, @@ -70,6 +79,11 @@ module.exports = { enforce: "pre", use: ["source-map-loader"], }, + { + test: /\.tsx?$/, + use: "ts-loader", + exclude: /node_modules/, + }, { test: require.resolve("jquery"), loader: "expose-loader",