Remove fetchPaginated and migrate viewer.js to viewer-index.js in webpack

This commit is contained in:
Antoine Bartuccio 2024-10-09 21:46:56 +02:00
parent 9199f91151
commit 46e58bb49e
6 changed files with 315 additions and 309 deletions

View File

@ -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<Object[]>}
*/
// 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;
}

View File

@ -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,

View File

@ -19,6 +19,8 @@ type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>(
options?: Options<PaginatedRequest, ThrowOnError>,
) => RequestResult<PaginatedResponse<T>, 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 <T>(
endpoint: PaginatedEndpoint<T>,
options?: PaginatedRequest,

View File

@ -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<void>}
*/
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<void>}
*/
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);
}
},
}));
});

View File

@ -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<void>}
*/
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<void>}
*/
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,
);
}
},
}));
});
};

View File

@ -5,7 +5,7 @@
{%- endblock -%}
{%- block additional_js -%}
<script defer src="{{ static("sas/js/viewer.js") }}"></script>
<script defer src="{{ static("webpack/sas/viewer-index.js") }}"></script>
{%- endblock -%}
{% block title %}
@ -171,10 +171,14 @@
{% block script %}
{{ super() }}
<script>
const pictureEndpoint = "{{ url("api:pictures") + "?album_id=" + album.id|string }}";
const albumUrl = "{{ album.get_absolute_url() }}";
const firstPictureId = {{ picture.id }}; {# id of the first picture to show after page load #}
const userId = {{ user.id }};
const userIsSasAdmin = {{ (user.is_root or user.is_in_group(pk = settings.SITH_GROUP_SAS_ADMIN_ID))|tojson }}
window.addEventListener("DOMContentLoaded", () => {
loadViewer({
albumId: {{ album.id }} ,
albumUrl: "{{ album.get_absolute_url() }}",
firstPictureId: {{ picture.id }}, {# id of the first picture to show after page load #}
userId: {{ user.id }},
userIsSasAdmin: {{ (user.is_root or user.is_in_group(pk = settings.SITH_GROUP_SAS_ADMIN_ID))|tojson }}
});
})
</script>
{% endblock %}