Remove select2 from vendored

* Make core/utils/select2.ts
* Convert viewer-index.js to typescript
This commit is contained in:
klmp200 authored and Bartuccio Antoine committed 2024-10-13 10:55:15 +02:00
1 parent 768e2867b5
commit a5d8c96bab
12 files changed
+442 -357

No files matched your search

-1
View File
@@ -1 +0,0 @@
.select2-container{box-sizing:border-box;display:inline-block;margin:0;position:relative;vertical-align:middle}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{background-color:transparent;border:none;font-size:1em}.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline;list-style:none;padding:0}.select2-container .select2-selection--multiple .select2-selection__clear{background-color:transparent;border:none;font-size:1em}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;margin-left:5px;padding:0;max-width:100%;resize:none;height:18px;vertical-align:bottom;font-family:sans-serif;overflow:hidden;word-break:keep-all}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:white;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;user-select:none;-webkit-user-select:none}.select2-results__option--selectable{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(50%) !important;clip-path:inset(50%) !important;height:1px !important;overflow:hidden !important;padding:0 !important;position:absolute !important;width:1px !important;white-space:nowrap !important}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;height:26px;margin-right:20px;padding-right:0px}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--default .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;padding-bottom:5px;padding-right:5px;position:relative}.select2-container--default .select2-selection--multiple.select2-selection--clearable{padding-right:25px}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;font-weight:bold;height:20px;margin-right:10px;marginLine truncated
-2
View File
@@ -1,2 +0,0 @@
/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */
!function(n){"function"==typeof define&&define.amd?define(["jquery"],n):"object"==typeof module&&module.exports?module.exports=function(e,t){return void 0===t&&(t="undefined"!=typeof window?require("jquery"):require("jquery")(e)),n(t),t}:n(jQuery)}(function(t){var e,n,s,p,r,o,h,f,g,m,y,v,i,a,_,s=((u=t&&t.fn&&t.fn.select2&&t.fn.select2.amd?t.fn.select2.amd:u)&&u.requirejs||(u?n=u:u={},g={},m={},y={},v={},i=Object.prototype.hasOwnProperty,a=[].slice,_=/\.js$/,h=function(e,t){var n,s,i=c(e),r=i[0],t=t[1];return e=i[1],r&&(n=x(r=l(r,t))),r?e=n&&n.normalize?n.normalize(e,(s=t,function(e){return l(e,s)})):l(e,t):(r=(i=c(e=l(e,t)))[0],e=i[1],r&&(n=x(r))),{f:r?r+"!"+e:e,n:e,pr:r,p:n}},f={require:function(e){return w(e)},exports:function(e){var t=g[e];return void 0!==t?t:g[e]={}},module:function(e){return{id:e,uri:"",exports:g[e],config:(t=e,function(){return y&&y.config&&y.config[t]||{}})};var t}},r=function(e,t,n,s){var i,r,o,a,l,c=[],u=typeof n,d=A(s=s||e);if("undefined"==u||"function"==u){for(t=!t.length&&n.length?["require","exports","module"]:t,a=0;a<t.length;a+=1)if("require"===(r=(o=h(t[a],d)).f))c[a]=f.require(e);else if("exports"===r)c[a]=f.exports(e),l=!0;else if("module"===r)i=c[a]=f.module(e);else if(b(g,r)||b(m,r)||b(v,r))c[a]=x(r);else{if(!o.p)throw new Error(e+" missing "+r);o.p.load(o.n,w(s,!0),function(t){return function(e){g[t]=e}}(r),{}),c[a]=g[r]}u=n?n.apply(g[e],c):void 0,e&&(i&&i.exports!==p&&i.exports!==g[e]?g[e]=i.exports:u===p&&l||(g[e]=u))}else e&&(g[e]=n)},e=n=o=function(e,t,n,s,i){if("string"==typeof e)return f[e]?f[e](t):x(h(e,A(t)).f);if(!e.splice){if((y=e).deps&&o(y.deps,y.callback),!t)return;t.splice?(e=t,t=n,n=null):e=p}return t=t||function(){},"function"==typeof n&&(n=s,s=i),s?r(p,e,t,n):setTimeout(function(){r(p,e,t,n)},4),o},o.config=function(e){return o(e)},e._defined=g,(s=function(e,t,n){if("string"!=typeof e)throw new Error("See almond README: incorrect module build, no module name");t.splice||(n=t,t=[]),b(g,e)||b(m,e)||(m[e]=[e,t,n])}).amd={jQuery:!0},u.requirejs=e,u.require=n,u.define=s),u.define("almond",function(){}),u.define("jquery",[],function(){var e=t||$;return null==e&&console&&console.error&&console.error("Select2: An instance of jQuery or a jQuery-compatible library was not found. Make sure that you are including jQuery before Select2 on your web page."),e}),u.define("select2/utils",["jquery"],function(r){var s={};function c(e){var t,n=e.prototype,s=[];for(t in n)"function"==typeof n[t]&&"constructor"!==t&&s.push(t);return s}s.Extend=function(e,t){var n,s={}.hasOwnProperty;function i(){this.constructor=e}for(n in t)s.call(t,n)&&(e[n]=t[n]);return i.prototype=t.prototype,e.prototype=new i,e.__super__=t.prototype,e},s.Decorate=function(s,i){var e=c(i),t=c(s);function r(){var e=Array.prototype.unshift,t=i.prototype.constructor.length,n=s.prototype.constructor;0<t&&(e.call(arguments,s.prototype.constructor),n=i.prototype.constructor),n.apply(this,arguments)}i.displayName=s.displayName,r.prototype=new function(){this.constructor=r};for(var n=0;n<t.length;n++){var o=t[n];r.prototype[o]=s.prototype[o]}for(var a=0;a<e.length;a++){var l=e[a];r.prototype[l]=function(e){var t=function(){};e in r.prototype&&(t=r.prototype[e]);var n=i.prototype[e];return function(){return Array.prototype.unshift.call(arguments,t),n.apply(this,arguments)}}(l)}return r};function e(){this.listeners={}}e.prototype.on=function(e,t){this.listeners=this.listeners||{},e in this.listeners?this.listeners[e].push(t):this.listeners[e]=[t]},e.prototype.trigger=function(e){var t=Array.prototype.slice,n=t.call(arguments,1);this.listeners=this.listeners||{},0===(n=null==n?[]:n).length&&n.push({}),(n[0]._type=e)in this.listeners&&this.invoke(this.listeners[e],t.call(arguments,1)),"*"in this.listeners&&this.invoke(this.listeners["*"],arguments)},e.prototype.invoke=function(e,t){for(var n=0,s=e.length;n<s;n++)e[n].apply(this,t)},s.Observable=e,s.generateChars=function(e){for(var t="",n=0;n<e;n++)t+=Math.floor(36*Math.random()).toString(36);return t},s.bind=function(e,t){return function(){e.apply(t,arguments)}},s._convertData=function(e){for(var t in e){var n=t.split("-"),s=e;if(1!==n.length){for(var i=0;i<n.length;i++){var r=n[i];(r=r.substring(0,1).toLowerCase()+r.substring(1))in s||(s[r]={}),i==n.length-1&&(s[r]=e[t]),s=s[r]}delete e[t]}}return e},s.hasScroll=function(e,t){var n=r(t),s=t.style.overflowX,i=t.style.overflowY;return(s!==i||"hidden"!==i&&"visible"!==i)&&("scroll"===s||"scroll"===i||(n.innerHeight()<t.scrollHeight||n.innerWidth()<t.scrollWidth))},s.escapeMarkup=function(e){var t={"\\":"&#92;","&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;","/":"&#47;"};return"string"!=typeof e?e:String(e).replace(/[&<>"'\/\\]/g,function(e){return t[e]})},s.__cache={};var n=0;return s.GetUniqueElementId=function(e){var t=e.getAttribute("data-select2-id");return null!=t||(t=e.id?"select2-data-"+e.id:"select2-data-"+(++n).toString()+"-"+s.generateChars(4),e.setAttribute("data-select2-id",t)),t},s.StoreData=Line truncated
+6
View File
@@ -0,0 +1,6 @@
import type { Alpine as AlpineType } from "alpinejs";
declare global {
const Alpine: AlpineType;
const gettext: (text: string) => string;
}
@@ -136,52 +136,69 @@
* </script>
*/
/**
* @typedef Select2Object
* @property {number} id
* @property {string} text
*/
import "select2";
import type {
AjaxOptions,
DataFormat,
GroupedDataFormat,
LoadingData,
Options,
PlainObject,
} from "select2";
import "select2/dist/css/select2.css";
/**
* @typedef Select2Options
* @property {Element} element
* @property {Object} dataSource
* the data source, built with `localDataSource` or `remoteDataSource`
* @property {number[]} excluded A list of ids to exclude from search
* @property {undefined | function(Object): string} pictureGetter
* A callback to get the picture field from the API response
* @property {Object | undefined} overrides
* Any other select2 parameter to apply on the config
*/
export interface Select2Object {
id: number;
text: string;
}
// biome-ignore lint/suspicious/noExplicitAny: You have to do it at some point
export type RemoteResult = any;
export type AjaxResponse = AjaxOptions<DataFormat | GroupedDataFormat, RemoteResult>;
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
*/
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function sithSelect2(options) {
const elem = $(options.element);
export function sithSelect2(options: Select2Options) {
const elem: PlainObject = $(options.element);
return elem.select2({
theme: elem[0].multiple ? "classic" : "default",
minimumInputLength: 2,
templateResult: selectItemBuilder(options.pictureGetter),
...options.dataSource,
...(options.overrides || {}),
...(options.overrides ?? {}),
});
}
/**
* @typedef LocalSourceOptions
* @property {undefined | function(): number[]} excluded
* A callback to the ids to exclude from the search
*/
interface LocalSourceOptions {
excluded: () => number[];
}
/**
* Build a data source for a Select2 from a local array
* @param {Select2Object[]} source The array containing the data
* @param {RemoteSourceOptions} options
*/
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function localDataSource(source, options) {
export function localDataSource(
source: Select2Object[],
options: LocalSourceOptions,
): DataSource {
if (options.excluded) {
const ids = options.excluded();
return { data: source.filter((i) => !ids.includes(i.id)) };
@@ -189,15 +206,14 @@ function localDataSource(source, options) {
return { data: source };
}
/**
* @typedef RemoteSourceOptions
* @property {undefined | function(): number[]} excluded
* A callback to the ids to exclude from the search
* @property {undefined | function(): Select2Object} resultConverter
* A converter for a value coming from the remote api
* @property {undefined | Object} overrides
* Any other select2 parameter to apply on the config
*/
interface RemoteSourceOptions {
/** A callback to the ids to exclude from the search */
excluded?: () => number[];
/** A converter for a value coming from the remote api */
resultConverter?: ((obj: RemoteResult) => DataFormat | GroupedDataFormat) | undefined;
/** Any other select2 parameter to apply on the config */
overrides?: AjaxOptions;
}
/**
* Build a data source for a Select2 from a remote url
@@ -205,10 +221,14 @@ function localDataSource(source, options) {
* @param {RemoteSourceOptions} options
*/
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function remoteDataSource(source, options) {
jQuery.ajaxSettings.traditional = true;
const params = {
export function remoteDataSource(
source: string,
options: RemoteSourceOptions,
): DataSource {
$.ajaxSetup({
traditional: true,
});
const params: AjaxOptions = {
url: source,
dataType: "json",
cache: true,
@@ -217,7 +237,7 @@ function remoteDataSource(source, options) {
return {
search: params.term,
exclude: [
...(this.val() || []).map((i) => Number.parseInt(i)),
...(this.val() || []).map((i: string) => Number.parseInt(i)),
...(options.excluded ? options.excluded() : []),
],
};
@@ -234,8 +254,7 @@ function remoteDataSource(source, options) {
return { ajax: params };
}
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function itemFormatter(user) {
export function itemFormatter(user: { loading: boolean; text: string }) {
if (user.loading) {
return user.text;
}
@@ -246,8 +265,8 @@ function itemFormatter(user) {
* @param {null | function(Object):string} pictureGetter
* @return {function(string): jQuery|HTMLElement}
*/
function selectItemBuilder(pictureGetter) {
return (item) => {
export function selectItemBuilder(pictureGetter?: (item: RemoteResult) => string) {
return (item: RemoteResult) => {
const picture = typeof pictureGetter === "function" ? pictureGetter(item) : null;
const imgHtml = picture
? `<img
-3
View File
@@ -12,7 +12,6 @@
<link rel="stylesheet" href="{{ static('core/header.scss') }}">
<link rel="stylesheet" href="{{ static('core/navbar.scss') }}">
<link rel="stylesheet" href="{{ static('core/pagination.scss') }}">
<link rel="stylesheet" href="{{ static('vendored/select2/select2.min.css') }}">
{% block jquery_css %}
{# 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>
<!-- Put here to always have access to those functions on django widgets -->
<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>
+40 -1
View File
@@ -21,13 +21,17 @@
"jquery": "^3.7.1",
"jquery-ui": "^1.14.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": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@biomejs/biome": "1.9.3",
"@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",
"css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.0",
@@ -2184,6 +2188,12 @@
"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": {
"version": "5.60.15",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.15.tgz",
@@ -2221,6 +2231,15 @@
"@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": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2241,6 +2260,21 @@
"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": {
"version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
@@ -5655,6 +5689,11 @@
"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": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+5 -1
View File
@@ -23,6 +23,9 @@
"@babel/preset-env": "^7.25.4",
"@biomejs/biome": "1.9.3",
"@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",
"css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.0",
@@ -48,6 +51,7 @@
"jquery": "^3.7.1",
"jquery-ui": "^1.14.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"
}
}
-302
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,
);
}
},
}));
});
};
+324
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,
);
}
},
}));
});
};
+2 -1
View File
@@ -2,10 +2,11 @@
{%- block additional_css -%}
<link rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
<link rel="stylesheet" href="{{ static('webpack/sas/viewer-index.css') }}" defer>
{%- endblock -%}
{%- block additional_js -%}
<script defer src="{{ static("webpack/sas/viewer-index.js") }}"></script>
<script defer src="{{ static("webpack/sas/viewer-index.ts") }}"></script>
{%- endblock -%}
{% block title %}
-1
View File
@@ -755,5 +755,4 @@ SITH_FRONT_DEP_VERSIONS = {
"https://github.com/vasturiano/three-spritetext": "1.6.5",
"https://github.com/vasturiano/3d-force-graph/": "1.70.19",
"https://github.com/vasturiano/d3-force-3d": "3.0.3",
"https://github.com/select2/select2/": "4.0.13",
}
+1
View File
@@ -7,6 +7,7 @@
"target": "es5",
"allowJs": true,
"moduleResolution": "node",
"types": ["jquery", "alpinejs"],
"paths": {
"#openapi": ["./staticfiles/generated/openapi/index.ts"],
"#core:*": ["./core/static/webpack/*"],