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/user/pictures-index.js b/core/static/webpack/user/pictures-index.js index acdb9042..c5c4c357 100644 --- a/core/static/webpack/user/pictures-index.js +++ b/core/static/webpack/user/pictures-index.js @@ -37,7 +37,7 @@ import { picturesFetchPictures } from "#openapi"; * Load user picture page with a nice download bar * @param {PicturePageConfig} Configuration **/ -window.window.loadPicturePage = (config) => { +window.loadPicturePage = (config) => { document.addEventListener("alpine:init", () => { Alpine.data("user_pictures", () => ({ isDownloading: false, diff --git a/core/static/webpack/utils/api.ts b/core/static/webpack/utils/api.ts index 7951556b..e81be5d4 100644 --- a/core/static/webpack/utils/api.ts +++ b/core/static/webpack/utils/api.ts @@ -19,6 +19,8 @@ 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, 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..6d8ae622 --- /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 + * @param {number} userId Id of the user to get the pictures from + * @param {number} albumId Id of the album to displlay + * @param {number} firstPictureId id of the first picture to load on the page + * @param {bool} userIsSasAdmin if the user is sas admin + **/ + +/** + * Load user picture page with a nice download bar + * @param {ViewerConfig} Configuration + **/ +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 %}