mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-10-30 16:43:55 +00:00 
			
		
		
		
	Go for a more generic js bundling architecture
* Don't tie the output name to webpack itself * Don't call js bundling webpack in python code * Make the doc more generic about js bundling
This commit is contained in:
		
							
								
								
									
										59
									
								
								sas/static/bundled/sas/album-index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								sas/static/bundled/sas/album-index.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | ||||
| import { History, initialUrlParams, updateQueryString } from "#core:utils/history"; | ||||
| import { picturesFetchPictures } from "#openapi"; | ||||
|  | ||||
| /** | ||||
|  * @typedef AlbumConfig | ||||
|  * @property {number} albumId id of the album to visualize | ||||
|  * @property {number} maxPageSize maximum number of elements to show on a page | ||||
|  **/ | ||||
|  | ||||
| /** | ||||
|  * Create a family graph of an user | ||||
|  * @param {AlbumConfig} config | ||||
|  **/ | ||||
| window.loadAlbum = (config) => { | ||||
|   document.addEventListener("alpine:init", () => { | ||||
|     Alpine.data("pictures", () => ({ | ||||
|       pictures: {}, | ||||
|       page: Number.parseInt(initialUrlParams.get("page")) || 1, | ||||
|       pushstate: History.Push /* Used to avoid pushing a state on a back action */, | ||||
|       loading: false, | ||||
|  | ||||
|       async init() { | ||||
|         await this.fetchPictures(); | ||||
|         this.$watch("page", () => { | ||||
|           updateQueryString("page", this.page === 1 ? null : this.page, this.pushstate); | ||||
|           this.pushstate = History.Push; | ||||
|           this.fetchPictures(); | ||||
|         }); | ||||
|  | ||||
|         window.addEventListener("popstate", () => { | ||||
|           this.pushstate = History.Replace; | ||||
|           this.page = | ||||
|             Number.parseInt(new URLSearchParams(window.location.search).get("page")) || | ||||
|             1; | ||||
|         }); | ||||
|       }, | ||||
|  | ||||
|       async fetchPictures() { | ||||
|         this.loading = true; | ||||
|         this.pictures = ( | ||||
|           await picturesFetchPictures({ | ||||
|             query: { | ||||
|               // biome-ignore lint/style/useNamingConvention: API is in snake_case | ||||
|               album_id: config.albumId, | ||||
|               page: this.page, | ||||
|               // biome-ignore lint/style/useNamingConvention: API is in snake_case | ||||
|               page_size: config.maxPageSize, | ||||
|             }, | ||||
|           }) | ||||
|         ).data; | ||||
|         this.loading = false; | ||||
|       }, | ||||
|  | ||||
|       nbPages() { | ||||
|         return Math.ceil(this.pictures.count / config.maxPageSize); | ||||
|       }, | ||||
|     })); | ||||
|   }); | ||||
| }; | ||||
							
								
								
									
										30
									
								
								sas/static/bundled/sas/components/ajax-select-index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								sas/static/bundled/sas/components/ajax-select-index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| import { AjaxSelect } from "#core:core/components/ajax-select-base"; | ||||
| import { registerComponent } from "#core:utils/web-components"; | ||||
| import type { TomOption } from "tom-select/dist/types/types"; | ||||
| import type { escape_html } from "tom-select/dist/types/utils"; | ||||
| import { type AlbumSchema, albumSearchAlbum } from "#openapi"; | ||||
|  | ||||
| @registerComponent("album-ajax-select") | ||||
| export class AlbumAjaxSelect extends AjaxSelect { | ||||
|   protected valueField = "id"; | ||||
|   protected labelField = "path"; | ||||
|   protected searchField = ["path", "name"]; | ||||
|  | ||||
|   protected async search(query: string): Promise<TomOption[]> { | ||||
|     const resp = await albumSearchAlbum({ query: { search: query } }); | ||||
|     if (resp.data) { | ||||
|       return resp.data.results; | ||||
|     } | ||||
|     return []; | ||||
|   } | ||||
|  | ||||
|   protected renderOption(item: AlbumSchema, sanitize: typeof escape_html) { | ||||
|     return `<div class="select-item"> | ||||
|             <span class="select-item-text">${sanitize(item.path)}</span> | ||||
|           </div>`; | ||||
|   } | ||||
|  | ||||
|   protected renderItem(item: AlbumSchema, sanitize: typeof escape_html) { | ||||
|     return `<span>${sanitize(item.path)}</span>`; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										340
									
								
								sas/static/bundled/sas/viewer-index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										340
									
								
								sas/static/bundled/sas/viewer-index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,340 @@ | ||||
| import { paginated } from "#core:utils/api"; | ||||
| import { exportToHtml } from "#core:utils/globals"; | ||||
| import { History } from "#core:utils/history"; | ||||
| import type TomSelect from "tom-select"; | ||||
| import { | ||||
|   type IdentifiedUserSchema, | ||||
|   type PictureSchema, | ||||
|   type PicturesFetchIdentificationsResponse, | ||||
|   type PicturesFetchModerationRequestsResponse, | ||||
|   type PicturesFetchPicturesData, | ||||
|   type UserProfileSchema, | ||||
|   picturesDeletePicture, | ||||
|   picturesFetchIdentifications, | ||||
|   picturesFetchModerationRequests, | ||||
|   picturesFetchPictures, | ||||
|   picturesIdentifyUsers, | ||||
|   picturesModeratePicture, | ||||
|   usersidentifiedDeleteRelation, | ||||
| } from "#openapi"; | ||||
|  | ||||
| /** | ||||
|  * A container for a picture with the users identified on it | ||||
|  * able to prefetch its data. | ||||
|  */ | ||||
| class PictureWithIdentifications { | ||||
|   identifications: PicturesFetchIdentificationsResponse = null; | ||||
|   imageLoading = false; | ||||
|   identificationsLoading = false; | ||||
|   moderationLoading = false; | ||||
|   id: number; | ||||
|   // biome-ignore lint/style/useNamingConvention: api is in snake_case | ||||
|   compressed_url: string; | ||||
|   moderationRequests: PicturesFetchModerationRequestsResponse = null; | ||||
|  | ||||
|   constructor(picture: PictureSchema) { | ||||
|     Object.assign(this, picture); | ||||
|   } | ||||
|  | ||||
|   static fromPicture(picture: PictureSchema): PictureWithIdentifications { | ||||
|     return new PictureWithIdentifications(picture); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * If not already done, fetch the users identified on this picture and | ||||
|    * populate the identifications field | ||||
|    */ | ||||
|   async loadIdentifications(options?: { forceReload: boolean }): Promise<void> { | ||||
|     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; | ||||
|   } | ||||
|  | ||||
|   async loadModeration(options?: { forceReload: boolean }): Promise<void> { | ||||
|     if (this.moderationLoading) { | ||||
|       return; // The moderation requests are already being fetched. | ||||
|     } | ||||
|     if (!!this.moderationRequests && !options?.forceReload) { | ||||
|       // The moderation requests are already fetched | ||||
|       // and the user does not want to force the reload | ||||
|       return; | ||||
|     } | ||||
|     this.moderationLoading = true; | ||||
|     this.moderationRequests = ( | ||||
|       await picturesFetchModerationRequests({ | ||||
|         // biome-ignore lint/style/useNamingConvention: api is in snake_case | ||||
|         path: { picture_id: this.id }, | ||||
|       }) | ||||
|     ).data; | ||||
|     this.moderationLoading = false; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Preload the photo and the identifications | ||||
|    */ | ||||
|   async preload(): Promise<void> { | ||||
|     const img = new Image(); | ||||
|     img.src = this.compressed_url; | ||||
|     if (!img.complete) { | ||||
|       this.imageLoading = true; | ||||
|       img.addEventListener("load", () => { | ||||
|         this.imageLoading = false; | ||||
|       }); | ||||
|     } | ||||
|     await this.loadIdentifications(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| interface ViewerConfig { | ||||
|   /** Id of the user to get the pictures from */ | ||||
|   userId: number; | ||||
|   /** Url of the current album */ | ||||
|   albumUrl: string; | ||||
|   /** Id of the album to display */ | ||||
|   albumId: number; | ||||
|   /** id of the first picture to load on the page */ | ||||
|   firstPictureId: number; | ||||
|   /** if the user is sas admin */ | ||||
|   userIsSasAdmin: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Load user picture page with a nice download bar | ||||
|  **/ | ||||
| exportToHtml("loadViewer", (config: ViewerConfig) => { | ||||
|   document.addEventListener("alpine:init", () => { | ||||
|     Alpine.data("picture_viewer", () => ({ | ||||
|       /** | ||||
|        * All the pictures that can be displayed on this picture viewer | ||||
|        **/ | ||||
|       pictures: [] as PictureWithIdentifications[], | ||||
|       /** | ||||
|        * 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 | ||||
|        **/ | ||||
|       nextPicture: null as PictureWithIdentifications, | ||||
|       /** | ||||
|        * The picture which will be displayed next if the user press the "previous" button | ||||
|        **/ | ||||
|       previousPicture: null as PictureWithIdentifications, | ||||
|       /** | ||||
|        * 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 | ||||
|        **/ | ||||
|       moderationError: "", | ||||
|       /** | ||||
|        * Method of pushing new url to the browser history | ||||
|        * Used by popstate event and always reset to it's default value when used | ||||
|        **/ | ||||
|       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 }, | ||||
|           } as PicturesFetchPicturesData) | ||||
|         ).map(PictureWithIdentifications.fromPicture); | ||||
|         this.selector = this.$refs.search; | ||||
|         this.selector.setFilter((users: UserProfileSchema[]) => { | ||||
|           const resp: UserProfileSchema[] = []; | ||||
|           const ids = [ | ||||
|             ...(this.currentPicture.identifications || []).map( | ||||
|               (i: IdentifiedUserSchema) => i.user.id, | ||||
|             ), | ||||
|           ]; | ||||
|           for (const user of users) { | ||||
|             if (!ids.includes(user.id)) { | ||||
|               resp.push(user); | ||||
|             } | ||||
|           } | ||||
|           return resp; | ||||
|         }); | ||||
|         this.currentPicture = this.pictures.find( | ||||
|           (i: PictureSchema) => i.id === config.firstPictureId, | ||||
|         ); | ||||
|         this.$watch( | ||||
|           "currentPicture", | ||||
|           (current: PictureSchema, previous: PictureSchema) => { | ||||
|             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: PictureSchema) => 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(): Promise<void> { | ||||
|         const updateArgs = { | ||||
|           data: { sasPictureId: this.currentPicture.id }, | ||||
|           unused: "", | ||||
|           url: this.currentPicture.sas_url, | ||||
|         }; | ||||
|         if (this.pushstate === History.Replace) { | ||||
|           window.history.replaceState( | ||||
|             updateArgs.data, | ||||
|             updateArgs.unused, | ||||
|             updateArgs.url, | ||||
|           ); | ||||
|           this.pushstate = History.Push; | ||||
|         } else { | ||||
|           window.history.pushState(updateArgs.data, updateArgs.unused, updateArgs.url); | ||||
|         } | ||||
|  | ||||
|         this.moderationError = ""; | ||||
|         const index: number = this.pictures.indexOf(this.currentPicture); | ||||
|         this.previousPicture = this.pictures[index - 1] || null; | ||||
|         this.nextPicture = this.pictures[index + 1] || null; | ||||
|         this.$refs.mainPicture?.addEventListener("load", () => { | ||||
|           // once the current picture is loaded, | ||||
|           // start preloading the next and previous pictures | ||||
|           this.nextPicture?.preload(); | ||||
|           this.previousPicture?.preload(); | ||||
|         }); | ||||
|         if (this.currentPicture.asked_for_removal && config.userIsSasAdmin) { | ||||
|           await Promise.all([ | ||||
|             this.currentPicture.loadIdentifications(), | ||||
|             this.currentPicture.loadModeration(), | ||||
|           ]); | ||||
|         } else { | ||||
|           await this.currentPicture.loadIdentifications(); | ||||
|         } | ||||
|       }, | ||||
|  | ||||
|       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.error as { detail: string }).detail}`; | ||||
|           return; | ||||
|         } | ||||
|         this.currentPicture.is_moderated = true; | ||||
|         this.currentPicture.asked_for_removal = 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.error as { detail: string }).detail}`; | ||||
|           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(): Promise<void> { | ||||
|         const widget: TomSelect = this.selector.widget; | ||||
|         await picturesIdentifyUsers({ | ||||
|           path: { | ||||
|             // biome-ignore lint/style/useNamingConvention: api is in snake_case | ||||
|             picture_id: this.currentPicture.id, | ||||
|           }, | ||||
|           body: widget.items.map((i: string) => Number.parseInt(i)), | ||||
|         }); | ||||
|         // refresh the identified users list | ||||
|         await this.currentPicture.loadIdentifications({ forceReload: true }); | ||||
|  | ||||
|         // Clear selection and cache of retrieved user so they can be filtered again | ||||
|         widget.clear(false); | ||||
|         widget.clearOptions(); | ||||
|       }, | ||||
|  | ||||
|       /** | ||||
|        * Check if an identification can be removed by the currently logged user | ||||
|        */ | ||||
|       canBeRemoved(identification: IdentifiedUserSchema): boolean { | ||||
|         return config.userIsSasAdmin || identification.user.id === config.userId; | ||||
|       }, | ||||
|  | ||||
|       /** | ||||
|        * Untag a user from the current picture | ||||
|        */ | ||||
|       async removeIdentification(identification: IdentifiedUserSchema): Promise<void> { | ||||
|         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: IdentifiedUserSchema) => i.id !== identification.id, | ||||
|             ); | ||||
|         } | ||||
|       }, | ||||
|     })); | ||||
|   }); | ||||
| }); | ||||
		Reference in New Issue
	
	Block a user