Remove select2 from vendored

* Make core/utils/select2.ts
* Convert viewer-index.js to typescript
This commit is contained in:
Antoine Bartuccio 2024-10-13 00:28:21 +02:00 committed by Bartuccio Antoine
parent 768e2867b5
commit a5d8c96bab
12 changed files with 442 additions and 357 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,6 @@
import type { Alpine as AlpineType } from "alpinejs";
declare global {
const Alpine: AlpineType;
const gettext: (text: string) => string;
}

View File

@ -136,52 +136,69 @@
* </script> * </script>
*/ */
/** import "select2";
* @typedef Select2Object import type {
* @property {number} id AjaxOptions,
* @property {string} text DataFormat,
*/ GroupedDataFormat,
LoadingData,
Options,
PlainObject,
} from "select2";
import "select2/dist/css/select2.css";
/** export interface Select2Object {
* @typedef Select2Options id: number;
* @property {Element} element text: string;
* @property {Object} dataSource }
* the data source, built with `localDataSource` or `remoteDataSource`
* @property {number[]} excluded A list of ids to exclude from search // biome-ignore lint/suspicious/noExplicitAny: You have to do it at some point
* @property {undefined | function(Object): string} pictureGetter export type RemoteResult = any;
* A callback to get the picture field from the API response export type AjaxResponse = AjaxOptions<DataFormat | GroupedDataFormat, RemoteResult>;
* @property {Object | undefined} overrides
* Any other select2 parameter to apply on the config interface DataSource {
*/ ajax?: AjaxResponse | undefined;
data?: RemoteResult | DataFormat[] | GroupedDataFormat[] | undefined;
}
interface Select2Options {
element: Element;
/** the data source, built with `localDataSource` or `remoteDataSource` */
dataSource: DataSource;
excluded?: number[];
/** A callback to get the picture field from the API response */
pictureGetter?: (element: LoadingData | DataFormat | GroupedDataFormat) => string;
/** Any other select2 parameter to apply on the config */
overrides?: Options;
}
/** /**
* @param {Select2Options} options * @param {Select2Options} options
*/ */
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts export function sithSelect2(options: Select2Options) {
function sithSelect2(options) { const elem: PlainObject = $(options.element);
const elem = $(options.element);
return elem.select2({ return elem.select2({
theme: elem[0].multiple ? "classic" : "default", theme: elem[0].multiple ? "classic" : "default",
minimumInputLength: 2, minimumInputLength: 2,
templateResult: selectItemBuilder(options.pictureGetter), templateResult: selectItemBuilder(options.pictureGetter),
...options.dataSource, ...options.dataSource,
...(options.overrides || {}), ...(options.overrides ?? {}),
}); });
} }
/** interface LocalSourceOptions {
* @typedef LocalSourceOptions excluded: () => number[];
* @property {undefined | function(): number[]} excluded }
* A callback to the ids to exclude from the search
*/
/** /**
* Build a data source for a Select2 from a local array * Build a data source for a Select2 from a local array
* @param {Select2Object[]} source The array containing the data * @param {Select2Object[]} source The array containing the data
* @param {RemoteSourceOptions} options * @param {RemoteSourceOptions} options
*/ */
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts export function localDataSource(
function localDataSource(source, options) { source: Select2Object[],
options: LocalSourceOptions,
): DataSource {
if (options.excluded) { if (options.excluded) {
const ids = options.excluded(); const ids = options.excluded();
return { data: source.filter((i) => !ids.includes(i.id)) }; return { data: source.filter((i) => !ids.includes(i.id)) };
@ -189,15 +206,14 @@ function localDataSource(source, options) {
return { data: source }; return { data: source };
} }
/** interface RemoteSourceOptions {
* @typedef RemoteSourceOptions /** A callback to the ids to exclude from the search */
* @property {undefined | function(): number[]} excluded excluded?: () => number[];
* A callback to the ids to exclude from the search /** A converter for a value coming from the remote api */
* @property {undefined | function(): Select2Object} resultConverter resultConverter?: ((obj: RemoteResult) => DataFormat | GroupedDataFormat) | undefined;
* A converter for a value coming from the remote api /** Any other select2 parameter to apply on the config */
* @property {undefined | Object} overrides overrides?: AjaxOptions;
* Any other select2 parameter to apply on the config }
*/
/** /**
* Build a data source for a Select2 from a remote url * Build a data source for a Select2 from a remote url
@ -205,10 +221,14 @@ function localDataSource(source, options) {
* @param {RemoteSourceOptions} options * @param {RemoteSourceOptions} options
*/ */
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts export function remoteDataSource(
function remoteDataSource(source, options) { source: string,
jQuery.ajaxSettings.traditional = true; options: RemoteSourceOptions,
const params = { ): DataSource {
$.ajaxSetup({
traditional: true,
});
const params: AjaxOptions = {
url: source, url: source,
dataType: "json", dataType: "json",
cache: true, cache: true,
@ -217,7 +237,7 @@ function remoteDataSource(source, options) {
return { return {
search: params.term, search: params.term,
exclude: [ exclude: [
...(this.val() || []).map((i) => Number.parseInt(i)), ...(this.val() || []).map((i: string) => Number.parseInt(i)),
...(options.excluded ? options.excluded() : []), ...(options.excluded ? options.excluded() : []),
], ],
}; };
@ -234,8 +254,7 @@ function remoteDataSource(source, options) {
return { ajax: params }; return { ajax: params };
} }
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts export function itemFormatter(user: { loading: boolean; text: string }) {
function itemFormatter(user) {
if (user.loading) { if (user.loading) {
return user.text; return user.text;
} }
@ -246,8 +265,8 @@ function itemFormatter(user) {
* @param {null | function(Object):string} pictureGetter * @param {null | function(Object):string} pictureGetter
* @return {function(string): jQuery|HTMLElement} * @return {function(string): jQuery|HTMLElement}
*/ */
function selectItemBuilder(pictureGetter) { export function selectItemBuilder(pictureGetter?: (item: RemoteResult) => string) {
return (item) => { return (item: RemoteResult) => {
const picture = typeof pictureGetter === "function" ? pictureGetter(item) : null; const picture = typeof pictureGetter === "function" ? pictureGetter(item) : null;
const imgHtml = picture const imgHtml = picture
? `<img ? `<img

View File

@ -12,7 +12,6 @@
<link rel="stylesheet" href="{{ static('core/header.scss') }}"> <link rel="stylesheet" href="{{ static('core/header.scss') }}">
<link rel="stylesheet" href="{{ static('core/navbar.scss') }}"> <link rel="stylesheet" href="{{ static('core/navbar.scss') }}">
<link rel="stylesheet" href="{{ static('core/pagination.scss') }}"> <link rel="stylesheet" href="{{ static('core/pagination.scss') }}">
<link rel="stylesheet" href="{{ static('vendored/select2/select2.min.css') }}">
{% block jquery_css %} {% block jquery_css %}
{# Thile file is quite heavy (around 250kb), so declaring it in a block allows easy removal #} {# Thile file is quite heavy (around 250kb), so declaring it in a block allows easy removal #}
@ -26,8 +25,6 @@
<script src="{{ static('webpack/jquery-index.js') }}"></script> <script src="{{ static('webpack/jquery-index.js') }}"></script>
<!-- Put here to always have access to those functions on django widgets --> <!-- Put here to always have access to those functions on django widgets -->
<script src="{{ static('core/js/script.js') }}"></script> <script src="{{ static('core/js/script.js') }}"></script>
<script defer src="{{ static('vendored/select2/select2.min.js') }}"></script>
<script defer src="{{ static('core/js/sith-select2.js') }}"></script>

41
package-lock.json generated
View File

@ -21,13 +21,17 @@
"jquery": "^3.7.1", "jquery": "^3.7.1",
"jquery-ui": "^1.14.0", "jquery-ui": "^1.14.0",
"jquery.shorten": "^1.0.0", "jquery.shorten": "^1.0.0",
"native-file-system-adapter": "^3.0.1" "native-file-system-adapter": "^3.0.1",
"select2": "^4.1.0-rc.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4", "@babel/preset-env": "^7.25.4",
"@biomejs/biome": "1.9.3", "@biomejs/biome": "1.9.3",
"@hey-api/openapi-ts": "^0.53.8", "@hey-api/openapi-ts": "^0.53.8",
"@types/alpinejs": "^3.13.10",
"@types/jquery": "^3.5.31",
"@types/select2": "^4.0.63",
"babel-loader": "^9.2.1", "babel-loader": "^9.2.1",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.0", "css-minimizer-webpack-plugin": "^7.0.0",
@ -2184,6 +2188,12 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/@types/alpinejs": {
"version": "3.13.10",
"resolved": "https://registry.npmjs.org/@types/alpinejs/-/alpinejs-3.13.10.tgz",
"integrity": "sha512-ah53tF6mWuuwerpDE7EHwbZErNDJQlsLISPqJhYj2RZ9nuTYbRknSkqebUd3igkhLIZKkPa7IiXjSn9qsU9O2w==",
"dev": true
},
"node_modules/@types/codemirror": { "node_modules/@types/codemirror": {
"version": "5.60.15", "version": "5.60.15",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.15.tgz", "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.15.tgz",
@ -2221,6 +2231,15 @@
"@types/istanbul-lib-report": "*" "@types/istanbul-lib-report": "*"
} }
}, },
"node_modules/@types/jquery": {
"version": "3.5.31",
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.31.tgz",
"integrity": "sha512-rf/iB+cPJ/YZfMwr+FVuQbm7IaWC4y3FVYfVDxRGqmUCFjjPII0HWaP0vTPJGp6m4o13AXySCcMbWfrWtBFAKw==",
"dev": true,
"dependencies": {
"@types/sizzle": "*"
}
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -2241,6 +2260,21 @@
"undici-types": "~6.19.2" "undici-types": "~6.19.2"
} }
}, },
"node_modules/@types/select2": {
"version": "4.0.63",
"resolved": "https://registry.npmjs.org/@types/select2/-/select2-4.0.63.tgz",
"integrity": "sha512-/DXUfPSj3iVTGlRYRYPCFKKSogAGP/j+Z0fIMXbBiBtmmZj0WH7vnfNuckafq9C43KnqPPQW2TI/Rj/vTSGnQQ==",
"dev": true,
"dependencies": {
"@types/jquery": "*"
}
},
"node_modules/@types/sizzle": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.8.tgz",
"integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==",
"dev": true
},
"node_modules/@types/tern": { "node_modules/@types/tern": {
"version": "0.23.9", "version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
@ -5655,6 +5689,11 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/select2": {
"version": "4.1.0-rc.0",
"resolved": "https://registry.npmjs.org/select2/-/select2-4.1.0-rc.0.tgz",
"integrity": "sha512-Hr9TdhyHCZUtwznEH2CBf7967mEM0idtJ5nMtjvk3Up5tPukOLXbHUNmh10oRfeNIhj+3GD3niu+g6sVK+gK0A=="
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.6.3", "version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",

View File

@ -23,6 +23,9 @@
"@babel/preset-env": "^7.25.4", "@babel/preset-env": "^7.25.4",
"@biomejs/biome": "1.9.3", "@biomejs/biome": "1.9.3",
"@hey-api/openapi-ts": "^0.53.8", "@hey-api/openapi-ts": "^0.53.8",
"@types/alpinejs": "^3.13.10",
"@types/jquery": "^3.5.31",
"@types/select2": "^4.0.63",
"babel-loader": "^9.2.1", "babel-loader": "^9.2.1",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.0", "css-minimizer-webpack-plugin": "^7.0.0",
@ -48,6 +51,7 @@
"jquery": "^3.7.1", "jquery": "^3.7.1",
"jquery-ui": "^1.14.0", "jquery-ui": "^1.14.0",
"jquery.shorten": "^1.0.0", "jquery.shorten": "^1.0.0",
"native-file-system-adapter": "^3.0.1" "native-file-system-adapter": "^3.0.1",
"select2": "^4.1.0-rc.0"
} }
} }

View File

@ -1,302 +0,0 @@
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
* @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,
);
}
},
}));
});
};

View File

@ -0,0 +1,324 @@
import { makeUrl, paginated } from "#core:utils/api";
import { History } from "#core:utils/history";
import {
type AjaxResponse,
type RemoteResult,
remoteDataSource,
sithSelect2,
} from "#core:utils/select2";
import {
type IdentifiedUserSchema,
type PictureSchema,
type PicturesFetchIdentificationsResponse,
type PicturesFetchPicturesData,
type UserProfileSchema,
picturesDeletePicture,
picturesFetchIdentifications,
picturesFetchPictures,
picturesIdentifyUsers,
picturesModeratePicture,
userSearchUsers,
usersidentifiedDeleteRelation,
} from "#openapi";
/**
* A container for a picture with the users identified on it
* able to prefetch its data.
*/
class PictureWithIdentifications {
identifications: PicturesFetchIdentificationsResponse | null = null;
imageLoading = false;
identificationsLoading = false;
id: number;
// biome-ignore lint/style/useNamingConvention: api is in snake_case
compressed_url: string;
constructor(picture: PictureSchema) {
Object.assign(this, picture);
}
static fromPicture(picture: PictureSchema) {
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 }) {
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();
}
}
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 displlay */
albumId: number;
/** id of the first picture to load on the page */
firstPictureId: number;
/** if the user is sas admin */
userIsSasAdmin: number;
}
/**
* Load user picture page with a nice download bar
**/
(window as unknown as { loadViewer: (config: ViewerConfig) => undefined }).loadViewer =
(config: ViewerConfig) => {
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 },
} as PicturesFetchPicturesData)
).map(PictureWithIdentifications.fromPicture);
this.selector = sithSelect2({
element: $(this.$refs.search) as unknown as HTMLElement,
dataSource: remoteDataSource(await makeUrl(userSearchUsers), {
excluded: () => [
...(this.currentPicture.identifications || []).map(
(i: IdentifiedUserSchema) => i.user.id,
),
],
resultConverter: (obj: AjaxResponse) => {
return { ...obj, text: (obj as UserProfileSchema).display_name };
},
}),
pictureGetter: (user: RemoteResult) => user.profile_pict,
});
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() {
const updateArgs = {
data: { sasPictureId: this.currentPicture.id },
unused: "",
url: `/sas/picture/${this.currentPicture.id}/`,
};
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 = 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.error as { detail: string }).detail}`;
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.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() {
await picturesIdentifyUsers({
path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
picture_id: this.currentPicture.id,
},
body: this.selector.val().map((i: string) => 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: IdentifiedUserSchema) {
return config.userIsSasAdmin || identification.user.id === config.userId;
},
/**
* Untag a user from the current picture
* @param {PictureIdentification} identification
*/
async removeIdentification(identification: IdentifiedUserSchema) {
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,
);
}
},
}));
});
};

View File

@ -2,10 +2,11 @@
{%- block additional_css -%} {%- block additional_css -%}
<link rel="stylesheet" href="{{ static('sas/css/picture.scss') }}"> <link rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
<link rel="stylesheet" href="{{ static('webpack/sas/viewer-index.css') }}" defer>
{%- endblock -%} {%- endblock -%}
{%- block additional_js -%} {%- block additional_js -%}
<script defer src="{{ static("webpack/sas/viewer-index.js") }}"></script> <script defer src="{{ static("webpack/sas/viewer-index.ts") }}"></script>
{%- endblock -%} {%- endblock -%}
{% block title %} {% block title %}

View File

@ -755,5 +755,4 @@ SITH_FRONT_DEP_VERSIONS = {
"https://github.com/vasturiano/three-spritetext": "1.6.5", "https://github.com/vasturiano/three-spritetext": "1.6.5",
"https://github.com/vasturiano/3d-force-graph/": "1.70.19", "https://github.com/vasturiano/3d-force-graph/": "1.70.19",
"https://github.com/vasturiano/d3-force-3d": "3.0.3", "https://github.com/vasturiano/d3-force-3d": "3.0.3",
"https://github.com/select2/select2/": "4.0.13",
} }

View File

@ -7,6 +7,7 @@
"target": "es5", "target": "es5",
"allowJs": true, "allowJs": true,
"moduleResolution": "node", "moduleResolution": "node",
"types": ["jquery", "alpinejs"],
"paths": { "paths": {
"#openapi": ["./staticfiles/generated/openapi/index.ts"], "#openapi": ["./staticfiles/generated/openapi/index.ts"],
"#core:*": ["./core/static/webpack/*"], "#core:*": ["./core/static/webpack/*"],