mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-11-04 02:53:06 +00:00 
			
		
		
		
	Apply all biomejs fixes
This commit is contained in:
		@@ -20,7 +20,10 @@
 | 
			
		||||
  "linter": {
 | 
			
		||||
    "enabled": true,
 | 
			
		||||
    "rules": {
 | 
			
		||||
      "recommended": true
 | 
			
		||||
    }
 | 
			
		||||
      "all": true
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "javascript": {
 | 
			
		||||
    "globals": ["Alpine", "$", "jQuery", "gettext", "interpolate"]
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
$(document).ready(() => {
 | 
			
		||||
  $("#poster_list #view").click((e) => {
 | 
			
		||||
  $("#poster_list #view").click(() => {
 | 
			
		||||
    $("#view").removeClass("active");
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,11 @@
 | 
			
		||||
$(document).ready(() => {
 | 
			
		||||
  transition_time = 1000;
 | 
			
		||||
  const transitionTime = 1000;
 | 
			
		||||
 | 
			
		||||
  i = 0;
 | 
			
		||||
  max = $("#slideshow .slide").length;
 | 
			
		||||
 | 
			
		||||
  next_trigger = 0;
 | 
			
		||||
  let i = 0;
 | 
			
		||||
  const max = $("#slideshow .slide").length;
 | 
			
		||||
 | 
			
		||||
  function enterFullscreen() {
 | 
			
		||||
    element = document.getElementById("slideshow");
 | 
			
		||||
    const element = document.getElementById("slideshow");
 | 
			
		||||
    $(element).addClass("fullscreen");
 | 
			
		||||
    if (element.requestFullscreen) {
 | 
			
		||||
      element.requestFullscreen();
 | 
			
		||||
@@ -21,7 +19,7 @@ $(document).ready(() => {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function exitFullscreen() {
 | 
			
		||||
    element = document.getElementById("slideshow");
 | 
			
		||||
    const element = document.getElementById("slideshow");
 | 
			
		||||
    $(element).removeClass("fullscreen");
 | 
			
		||||
    if (document.exitFullscreen) {
 | 
			
		||||
      document.exitFullscreen();
 | 
			
		||||
@@ -34,61 +32,59 @@ $(document).ready(() => {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function init_progress_bar() {
 | 
			
		||||
  function initProgressBar() {
 | 
			
		||||
    $("#slideshow #progress_bar").css("transition", "none");
 | 
			
		||||
    $("#slideshow #progress_bar").removeClass("progress");
 | 
			
		||||
    $("#slideshow #progress_bar").addClass("init");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function start_progress_bar(display_time) {
 | 
			
		||||
  function startProgressBar(displayTime) {
 | 
			
		||||
    $("#slideshow #progress_bar").removeClass("init");
 | 
			
		||||
    $("#slideshow #progress_bar").addClass("progress");
 | 
			
		||||
    $("#slideshow #progress_bar").css("transition", `width ${display_time}s linear`);
 | 
			
		||||
    $("#slideshow #progress_bar").css("transition", `width ${displayTime}s linear`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function next() {
 | 
			
		||||
    init_progress_bar();
 | 
			
		||||
    slide = $($("#slideshow .slide").get(i % max));
 | 
			
		||||
    initProgressBar();
 | 
			
		||||
    const slide = $($("#slideshow .slide").get(i % max));
 | 
			
		||||
    slide.removeClass("center");
 | 
			
		||||
    slide.addClass("left");
 | 
			
		||||
 | 
			
		||||
    next_slide = $($("#slideshow .slide").get((i + 1) % max));
 | 
			
		||||
    next_slide.removeClass("right");
 | 
			
		||||
    next_slide.addClass("center");
 | 
			
		||||
    display_time = next_slide.attr("display_time") || 2;
 | 
			
		||||
    const nextSlide = $($("#slideshow .slide").get((i + 1) % max));
 | 
			
		||||
    nextSlide.removeClass("right");
 | 
			
		||||
    nextSlide.addClass("center");
 | 
			
		||||
    const displayTime = nextSlide.attr("display_time") || 2;
 | 
			
		||||
 | 
			
		||||
    $("#slideshow .bullet").removeClass("active");
 | 
			
		||||
    bullet = $("#slideshow .bullet")[(i + 1) % max];
 | 
			
		||||
    const bullet = $("#slideshow .bullet")[(i + 1) % max];
 | 
			
		||||
    $(bullet).addClass("active");
 | 
			
		||||
 | 
			
		||||
    i = (i + 1) % max;
 | 
			
		||||
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      others_left = $("#slideshow .slide.left");
 | 
			
		||||
      others_left.removeClass("left");
 | 
			
		||||
      others_left.addClass("right");
 | 
			
		||||
      const othersLeft = $("#slideshow .slide.left");
 | 
			
		||||
      othersLeft.removeClass("left");
 | 
			
		||||
      othersLeft.addClass("right");
 | 
			
		||||
 | 
			
		||||
      start_progress_bar(display_time);
 | 
			
		||||
      next_trigger = setTimeout(next, display_time * 1000);
 | 
			
		||||
    }, transition_time);
 | 
			
		||||
      startProgressBar(displayTime);
 | 
			
		||||
      setTimeout(next, displayTime * 1000);
 | 
			
		||||
    }, transitionTime);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  display_time = $("#slideshow .center").attr("display_time");
 | 
			
		||||
  init_progress_bar();
 | 
			
		||||
  const displayTime = $("#slideshow .center").attr("display_time");
 | 
			
		||||
  initProgressBar();
 | 
			
		||||
  setTimeout(() => {
 | 
			
		||||
    if (max > 1) {
 | 
			
		||||
      start_progress_bar(display_time);
 | 
			
		||||
      setTimeout(next, display_time * 1000);
 | 
			
		||||
      startProgressBar(displayTime);
 | 
			
		||||
      setTimeout(next, displayTime * 1000);
 | 
			
		||||
    }
 | 
			
		||||
  }, 10);
 | 
			
		||||
 | 
			
		||||
  $("#slideshow").click((e) => {
 | 
			
		||||
    if (!$("#slideshow").hasClass("fullscreen")) {
 | 
			
		||||
      console.log("Entering fullscreen ...");
 | 
			
		||||
      enterFullscreen();
 | 
			
		||||
    } else {
 | 
			
		||||
      console.log("Exiting fullscreen ...");
 | 
			
		||||
  $("#slideshow").click(() => {
 | 
			
		||||
    if ($("#slideshow").hasClass("fullscreen")) {
 | 
			
		||||
      exitFullscreen();
 | 
			
		||||
    } else {
 | 
			
		||||
      enterFullscreen();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@@ -96,7 +92,6 @@ $(document).ready(() => {
 | 
			
		||||
    if (e.keyCode === 27) {
 | 
			
		||||
      // escape key maps to keycode `27`
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      console.log("Exiting fullscreen ...");
 | 
			
		||||
      exitFullscreen();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 
 | 
			
		||||
@@ -27,7 +27,7 @@
 | 
			
		||||
 | 
			
		||||
    <div id="posters">
 | 
			
		||||
 | 
			
		||||
      {% if poster_list.count() == 0 %}
 | 
			
		||||
      {% if poster_list.count() == 0 %}
 | 
			
		||||
        <div id="no-posters">{% trans %}No posters{% endtrans %}</div>
 | 
			
		||||
      {% else %}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -13,13 +13,11 @@ $(() => {
 | 
			
		||||
        bottom: "5%",
 | 
			
		||||
      });
 | 
			
		||||
      target.css("height", "300px");
 | 
			
		||||
      console.log(target);
 | 
			
		||||
    },
 | 
			
		||||
    buttons: [
 | 
			
		||||
      {
 | 
			
		||||
        text: "Choose",
 | 
			
		||||
        click: function () {
 | 
			
		||||
          console.log($("#file_id"));
 | 
			
		||||
          $(`input[name=${$(this).attr("name")}]`).attr(
 | 
			
		||||
            "value",
 | 
			
		||||
            $("#file_id").attr("value"),
 | 
			
		||||
@@ -34,7 +32,6 @@ $(() => {
 | 
			
		||||
    .button()
 | 
			
		||||
    .on("click", function () {
 | 
			
		||||
      const popup = popups.filter(`[name=${$(this).attr("name")}]`);
 | 
			
		||||
      console.log(popup);
 | 
			
		||||
      popup.html(
 | 
			
		||||
        '<iframe src="/file/popup" width="100%" height="95%"></iframe><div id="file_id" value="null" />',
 | 
			
		||||
      );
 | 
			
		||||
@@ -45,6 +42,7 @@ $(() => {
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
 | 
			
		||||
function createQuickNotif(msg) {
 | 
			
		||||
  const el = document.createElement("li");
 | 
			
		||||
  el.textContent = msg;
 | 
			
		||||
@@ -52,6 +50,7 @@ function createQuickNotif(msg) {
 | 
			
		||||
  document.getElementById("quick_notif").appendChild(el);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
 | 
			
		||||
function deleteQuickNotifs() {
 | 
			
		||||
  const el = document.getElementById("quick_notif");
 | 
			
		||||
  while (el.firstChild) {
 | 
			
		||||
@@ -59,7 +58,8 @@ function deleteQuickNotifs() {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function display_notif() {
 | 
			
		||||
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
 | 
			
		||||
function displayNotif() {
 | 
			
		||||
  $("#header_notif").toggle().parent().toggleClass("white");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -69,10 +69,13 @@ function display_notif() {
 | 
			
		||||
// Sadly, getting the cookie is not possible with CSRF_COOKIE_HTTPONLY or CSRF_USE_SESSIONS is True
 | 
			
		||||
// So, the true workaround is to get the token from the dom
 | 
			
		||||
// https://docs.djangoproject.com/en/2.0/ref/csrf/#acquiring-the-token-if-csrf-use-sessions-is-true
 | 
			
		||||
// biome-ignore lint/style/useNamingConvention: can't find it used anywhere but I will not play with the devil
 | 
			
		||||
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
 | 
			
		||||
function getCSRFToken() {
 | 
			
		||||
  return $("[name=csrfmiddlewaretoken]").val();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
 | 
			
		||||
const initialUrlParams = new URLSearchParams(window.location.search);
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
@@ -80,8 +83,11 @@ const initialUrlParams = new URLSearchParams(window.location.search);
 | 
			
		||||
 * @enum {number}
 | 
			
		||||
 */
 | 
			
		||||
const History = {
 | 
			
		||||
  // biome-ignore lint/style/useNamingConvention: this feels more like an enum
 | 
			
		||||
  NONE: 0,
 | 
			
		||||
  // biome-ignore lint/style/useNamingConvention: this feels more like an enum
 | 
			
		||||
  PUSH: 1,
 | 
			
		||||
  // biome-ignore lint/style/useNamingConvention: this feels more like an enum
 | 
			
		||||
  REPLACE: 2,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -91,7 +97,8 @@ const History = {
 | 
			
		||||
 * @param {History} action
 | 
			
		||||
 * @param {URL | null} url
 | 
			
		||||
 */
 | 
			
		||||
function update_query_string(key, value, action = History.REPLACE, url = null) {
 | 
			
		||||
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
 | 
			
		||||
function updateQueryString(key, value, action = History.REPLACE, url = null) {
 | 
			
		||||
  let ret = url;
 | 
			
		||||
  if (!ret) {
 | 
			
		||||
    ret = new URL(window.location.href);
 | 
			
		||||
@@ -125,24 +132,25 @@ function update_query_string(key, value, action = History.REPLACE, url = null) {
 | 
			
		||||
 * @param {string} url The paginated endpoint to fetch
 | 
			
		||||
 * @return {Promise<Object[]>}
 | 
			
		||||
 */
 | 
			
		||||
async function fetch_paginated(url) {
 | 
			
		||||
  const max_per_page = 199;
 | 
			
		||||
  const paginated_url = new URL(url, document.location.origin);
 | 
			
		||||
  paginated_url.searchParams.set("page_size", max_per_page.toString());
 | 
			
		||||
  paginated_url.searchParams.set("page", "1");
 | 
			
		||||
// 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 first_page = await (await fetch(paginated_url)).json();
 | 
			
		||||
  const results = first_page.results;
 | 
			
		||||
  const firstPage = await (await fetch(paginatedUrl)).json();
 | 
			
		||||
  const results = firstPage.results;
 | 
			
		||||
 | 
			
		||||
  const nb_pictures = first_page.count;
 | 
			
		||||
  const nb_pages = Math.ceil(nb_pictures / max_per_page);
 | 
			
		||||
  const nbPictures = firstPage.count;
 | 
			
		||||
  const nbPages = Math.ceil(nbPictures / maxPerPage);
 | 
			
		||||
 | 
			
		||||
  if (nb_pages > 1) {
 | 
			
		||||
  if (nbPages > 1) {
 | 
			
		||||
    const promises = [];
 | 
			
		||||
    for (let i = 2; i <= nb_pages; i++) {
 | 
			
		||||
      paginated_url.searchParams.set("page", i.toString());
 | 
			
		||||
    for (let i = 2; i <= nbPages; i++) {
 | 
			
		||||
      paginatedUrl.searchParams.set("page", i.toString());
 | 
			
		||||
      promises.push(
 | 
			
		||||
        fetch(paginated_url).then((res) => res.json().then((json) => json.results)),
 | 
			
		||||
        fetch(paginatedUrl).then((res) => res.json().then((json) => json.results)),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    results.push(...(await Promise.all(promises)).flat());
 | 
			
		||||
 
 | 
			
		||||
@@ -15,7 +15,7 @@
 | 
			
		||||
 * ];
 | 
			
		||||
 * document.addEventListener("DOMContentLoaded", () => sithSelect2({
 | 
			
		||||
 *   element: document.getElementById("select2-input"),
 | 
			
		||||
 *   data_source: local_data_source(data)
 | 
			
		||||
 *   dataSource: localDataSource(data)
 | 
			
		||||
 * }));
 | 
			
		||||
 * ```
 | 
			
		||||
 *
 | 
			
		||||
@@ -29,7 +29,7 @@
 | 
			
		||||
 * ];
 | 
			
		||||
 * document.addEventListener("DOMContentLoaded", () => sithSelect2({
 | 
			
		||||
 *   element: document.getElementById("select2-input"),
 | 
			
		||||
 *   data_source: local_data_source(data, {
 | 
			
		||||
 *   dataSource: localDataSource(data, {
 | 
			
		||||
 *     excluded: () => data.filter((i) => i.text === "to exclude").map((i) => parseInt(i))
 | 
			
		||||
 *   })
 | 
			
		||||
 * }));
 | 
			
		||||
@@ -38,15 +38,15 @@
 | 
			
		||||
 * # Remote data source
 | 
			
		||||
 *
 | 
			
		||||
 * Select2 with remote data sources are similar to those with local
 | 
			
		||||
 * data, but with some more parameters, like `result_converter`,
 | 
			
		||||
 * data, but with some more parameters, like `resultConverter`,
 | 
			
		||||
 * which takes a callback that must return a `Select2Object`.
 | 
			
		||||
 *
 | 
			
		||||
 * ```js
 | 
			
		||||
 * document.addEventListener("DOMContentLoaded", () => sithSelect2({
 | 
			
		||||
 *   element: document.getElementById("select2-input"),
 | 
			
		||||
 *   data_source: remote_data_source("/api/user/search", {
 | 
			
		||||
 *   dataSource: remoteDataSource("/api/user/search", {
 | 
			
		||||
 *     excluded: () => [1, 2],  // exclude users 1 and 2 from the search
 | 
			
		||||
 *     result_converter: (user) => Object({id: user.id, text: user.first_name})
 | 
			
		||||
 *     resultConverter: (user) => Object({id: user.id, text: user.firstName})
 | 
			
		||||
 *   })
 | 
			
		||||
 * }));
 | 
			
		||||
 * ```
 | 
			
		||||
@@ -62,8 +62,8 @@
 | 
			
		||||
 * ```js
 | 
			
		||||
 * document.addEventListener("DOMContentLoaded", () => sithSelect2({
 | 
			
		||||
 *   element: document.getElementById("select2-input"),
 | 
			
		||||
 *   data_source: remote_data_source("/api/user/search", {
 | 
			
		||||
 *     result_converter: (user) => Object({id: user.id, text: user.first_name}),
 | 
			
		||||
 *   dataSource: remoteDataSource("/api/user/search", {
 | 
			
		||||
 *     resultConverter: (user) => Object({id: user.id, text: user.firstName}),
 | 
			
		||||
 *     overrides: {
 | 
			
		||||
 *      delay: 500
 | 
			
		||||
 *     }
 | 
			
		||||
@@ -85,15 +85,15 @@
 | 
			
		||||
 *
 | 
			
		||||
 * Sometimes, you would like to display an image besides
 | 
			
		||||
 * the text on the select items.
 | 
			
		||||
 * In this case, fill the `picture_getter` option :
 | 
			
		||||
 * In this case, fill the `pictureGetter` option :
 | 
			
		||||
 *
 | 
			
		||||
 * ```js
 | 
			
		||||
 * document.addEventListener("DOMContentLoaded", () => sithSelect2({
 | 
			
		||||
 *   element: document.getElementById("select2-input"),
 | 
			
		||||
 *   data_source: remote_data_source("/api/user/search", {
 | 
			
		||||
 *     result_converter: (user) => Object({id: user.id, text: user.first_name})
 | 
			
		||||
 *   dataSource: remoteDataSource("/api/user/search", {
 | 
			
		||||
 *     resultConverter: (user) => Object({id: user.id, text: user.firstName})
 | 
			
		||||
 *   })
 | 
			
		||||
 *   picture_getter: (user) => user.profile_pict,
 | 
			
		||||
 *   pictureGetter: (user) => user.profilePict,
 | 
			
		||||
 * }));
 | 
			
		||||
 * ```
 | 
			
		||||
 *
 | 
			
		||||
@@ -105,8 +105,8 @@
 | 
			
		||||
 * <body>
 | 
			
		||||
 *   <div x-data="select2_test">
 | 
			
		||||
 *     <select x-ref="search" x-ref="select"></select>
 | 
			
		||||
 *     <p x-text="current_selection.id"></p>
 | 
			
		||||
 *     <p x-text="current_selection.text"></p>
 | 
			
		||||
 *     <p x-text="currentSelection.id"></p>
 | 
			
		||||
 *     <p x-text="currentSelection.text"></p>
 | 
			
		||||
 *   </div>
 | 
			
		||||
 * </body>
 | 
			
		||||
 *
 | 
			
		||||
@@ -114,20 +114,20 @@
 | 
			
		||||
 * document.addEventListener("alpine:init", () => {
 | 
			
		||||
 *   Alpine.data("select2_test", () => ({
 | 
			
		||||
 *     selector: undefined,
 | 
			
		||||
 *     current_select: {id: "", text: ""},
 | 
			
		||||
 *     currentSelect: {id: "", text: ""},
 | 
			
		||||
 *
 | 
			
		||||
 *     init() {
 | 
			
		||||
 *       this.selector = sithSelect2({
 | 
			
		||||
 *         element: $(this.$refs.select),
 | 
			
		||||
 *         data_source: local_data_source(
 | 
			
		||||
 *         dataSource: localDataSource(
 | 
			
		||||
 *           [{id: 1, text: "foo"}, {id: 2, text: "bar"}]
 | 
			
		||||
 *         ),
 | 
			
		||||
 *       });
 | 
			
		||||
 *       this.selector.on("select2:select", (event) => {
 | 
			
		||||
 *         // select2 => Alpine signals here
 | 
			
		||||
 *         this.current_select = this.selector.select2("data")
 | 
			
		||||
 *         this.currentSelect = this.selector.select2("data")
 | 
			
		||||
 *       });
 | 
			
		||||
 *       this.$watch("current_selected" (value) => {
 | 
			
		||||
 *       this.$watch("currentSelected" (value) => {
 | 
			
		||||
 *          // Alpine => select2 signals here
 | 
			
		||||
 *       });
 | 
			
		||||
 *     },
 | 
			
		||||
@@ -145,10 +145,10 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @typedef Select2Options
 | 
			
		||||
 * @property {Element} element
 | 
			
		||||
 * @property {Object} data_source
 | 
			
		||||
 *      the data source, built with `local_data_source` or `remote_data_source`
 | 
			
		||||
 * @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} picture_getter
 | 
			
		||||
 * @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
 | 
			
		||||
@@ -157,13 +157,14 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @param {Select2Options} options
 | 
			
		||||
 */
 | 
			
		||||
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
 | 
			
		||||
function sithSelect2(options) {
 | 
			
		||||
  const elem = $(options.element);
 | 
			
		||||
  return elem.select2({
 | 
			
		||||
    theme: elem[0].multiple ? "classic" : "default",
 | 
			
		||||
    minimumInputLength: 2,
 | 
			
		||||
    templateResult: select_item_builder(options.picture_getter),
 | 
			
		||||
    ...options.data_source,
 | 
			
		||||
    templateResult: selectItemBuilder(options.pictureGetter),
 | 
			
		||||
    ...options.dataSource,
 | 
			
		||||
    ...(options.overrides || {}),
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
@@ -179,7 +180,8 @@ function sithSelect2(options) {
 | 
			
		||||
 * @param {Select2Object[]} source The array containing the data
 | 
			
		||||
 * @param {RemoteSourceOptions} options
 | 
			
		||||
 */
 | 
			
		||||
function local_data_source(source, options) {
 | 
			
		||||
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
 | 
			
		||||
function localDataSource(source, options) {
 | 
			
		||||
  if (options.excluded) {
 | 
			
		||||
    const ids = options.excluded();
 | 
			
		||||
    return { data: source.filter((i) => !ids.includes(i.id)) };
 | 
			
		||||
@@ -191,7 +193,7 @@ function local_data_source(source, options) {
 | 
			
		||||
 * @typedef RemoteSourceOptions
 | 
			
		||||
 * @property {undefined | function(): number[]} excluded
 | 
			
		||||
 *     A callback to the ids to exclude from the search
 | 
			
		||||
 * @property {undefined | function(): Select2Object} result_converter
 | 
			
		||||
 * @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
 | 
			
		||||
@@ -202,7 +204,9 @@ function local_data_source(source, options) {
 | 
			
		||||
 * @param {string} source The url of the endpoint
 | 
			
		||||
 * @param {RemoteSourceOptions} options
 | 
			
		||||
 */
 | 
			
		||||
function remote_data_source(source, options) {
 | 
			
		||||
 | 
			
		||||
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
 | 
			
		||||
function remoteDataSource(source, options) {
 | 
			
		||||
  jQuery.ajaxSettings.traditional = true;
 | 
			
		||||
  const params = {
 | 
			
		||||
    url: source,
 | 
			
		||||
@@ -219,9 +223,9 @@ function remote_data_source(source, options) {
 | 
			
		||||
      };
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
  if (options.result_converter) {
 | 
			
		||||
  if (options.resultConverter) {
 | 
			
		||||
    params.processResults = (data) => ({
 | 
			
		||||
      results: data.results.map(options.result_converter),
 | 
			
		||||
      results: data.results.map(options.resultConverter),
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
  if (options.overrides) {
 | 
			
		||||
@@ -230,7 +234,8 @@ function remote_data_source(source, options) {
 | 
			
		||||
  return { ajax: params };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function item_formatter(user) {
 | 
			
		||||
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
 | 
			
		||||
function itemFormatter(user) {
 | 
			
		||||
  if (user.loading) {
 | 
			
		||||
    return user.text;
 | 
			
		||||
  }
 | 
			
		||||
@@ -238,22 +243,22 @@ function item_formatter(user) {
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Build a function to display the results
 | 
			
		||||
 * @param {null | function(Object):string} picture_getter
 | 
			
		||||
 * @param {null | function(Object):string} pictureGetter
 | 
			
		||||
 * @return {function(string): jQuery|HTMLElement}
 | 
			
		||||
 */
 | 
			
		||||
function select_item_builder(picture_getter) {
 | 
			
		||||
function selectItemBuilder(pictureGetter) {
 | 
			
		||||
  return (item) => {
 | 
			
		||||
    const picture = typeof picture_getter === "function" ? picture_getter(item) : null;
 | 
			
		||||
    const img_html = picture
 | 
			
		||||
    const picture = typeof pictureGetter === "function" ? pictureGetter(item) : null;
 | 
			
		||||
    const imgHtml = picture
 | 
			
		||||
      ? `<img 
 | 
			
		||||
          src="${picture_getter(item)}" 
 | 
			
		||||
          src="${pictureGetter(item)}" 
 | 
			
		||||
          alt="${item.text}" 
 | 
			
		||||
          onerror="this.src = '/static/core/img/unknown.jpg'" 
 | 
			
		||||
        />`
 | 
			
		||||
      : "";
 | 
			
		||||
 | 
			
		||||
    return $(`<div class="select-item">
 | 
			
		||||
        ${img_html}
 | 
			
		||||
        ${imgHtml}
 | 
			
		||||
         <span class="select-item-text">${item.text}</span>
 | 
			
		||||
         </div>`);
 | 
			
		||||
  };
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
async function get_graph_data(url, godfathers_depth, godchildren_depth) {
 | 
			
		||||
async function getGraphData(url, godfathersDepth, godchildrenDepth) {
 | 
			
		||||
  const data = await (
 | 
			
		||||
    await fetch(
 | 
			
		||||
      `${url}?godfathers_depth=${godfathers_depth}&godchildren_depth=${godchildren_depth}`,
 | 
			
		||||
      `${url}?godfathers_depth=${godfathersDepth}&godchildren_depth=${godchildrenDepth}`,
 | 
			
		||||
    )
 | 
			
		||||
  ).json();
 | 
			
		||||
  return [
 | 
			
		||||
@@ -16,7 +16,8 @@ async function get_graph_data(url, godfathers_depth, godchildren_depth) {
 | 
			
		||||
  ];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function create_graph(container, data, active_user_id) {
 | 
			
		||||
function createGraph(container, data, activeUserId) {
 | 
			
		||||
  // biome-ignore lint/correctness/noUndeclaredVariables: imported by user_godphaters_tree.jinja
 | 
			
		||||
  const cy = cytoscape({
 | 
			
		||||
    boxSelectionEnabled: false,
 | 
			
		||||
    autounselectify: true,
 | 
			
		||||
@@ -83,9 +84,9 @@ function create_graph(container, data, active_user_id) {
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
  const active_user = cy.getElementById(active_user_id).style("shape", "rectangle");
 | 
			
		||||
  const activeUser = cy.getElementById(activeUserId).style("shape", "rectangle");
 | 
			
		||||
  /* Reset graph */
 | 
			
		||||
  const reset_graph = () => {
 | 
			
		||||
  const resetGraph = () => {
 | 
			
		||||
    cy.elements((element) => {
 | 
			
		||||
      if (element.hasClass("traversed")) {
 | 
			
		||||
        element.removeClass("traversed");
 | 
			
		||||
@@ -96,10 +97,10 @@ function create_graph(container, data, active_user_id) {
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const on_node_tap = (el) => {
 | 
			
		||||
    reset_graph();
 | 
			
		||||
  const onNodeTap = (el) => {
 | 
			
		||||
    resetGraph();
 | 
			
		||||
    /* Create path on graph if selected isn't the targeted user */
 | 
			
		||||
    if (el === active_user) {
 | 
			
		||||
    if (el === activeUser) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    cy.elements((element) => {
 | 
			
		||||
@@ -108,7 +109,7 @@ function create_graph(container, data, active_user_id) {
 | 
			
		||||
 | 
			
		||||
    for (const traversed of cy.elements().aStar({
 | 
			
		||||
      root: el,
 | 
			
		||||
      goal: active_user,
 | 
			
		||||
      goal: activeUser,
 | 
			
		||||
    }).path) {
 | 
			
		||||
      traversed.removeClass("not-traversed");
 | 
			
		||||
      traversed.addClass("traversed");
 | 
			
		||||
@@ -116,14 +117,13 @@ function create_graph(container, data, active_user_id) {
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  cy.on("tap", "node", (tapped) => {
 | 
			
		||||
    on_node_tap(tapped.target);
 | 
			
		||||
    onNodeTap(tapped.target);
 | 
			
		||||
  });
 | 
			
		||||
  cy.zoomingEnabled(false);
 | 
			
		||||
 | 
			
		||||
  /* Add context menu */
 | 
			
		||||
  if (cy.cxtmenu === undefined) {
 | 
			
		||||
    console.error("ctxmenu isn't loaded, context menu won't be available on graphs");
 | 
			
		||||
    return cy;
 | 
			
		||||
    throw new Error("ctxmenu isn't loaded, context menu won't be available on graphs");
 | 
			
		||||
  }
 | 
			
		||||
  cy.cxtmenu({
 | 
			
		||||
    selector: "node",
 | 
			
		||||
@@ -139,14 +139,14 @@ function create_graph(container, data, active_user_id) {
 | 
			
		||||
      {
 | 
			
		||||
        content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
 | 
			
		||||
        select: (el) => {
 | 
			
		||||
          on_node_tap(el);
 | 
			
		||||
          onNodeTap(el);
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
      {
 | 
			
		||||
        content: '<i class="fa fa-eraser fa-2x"></i>',
 | 
			
		||||
        select: (el) => {
 | 
			
		||||
          reset_graph();
 | 
			
		||||
        select: (_) => {
 | 
			
		||||
          resetGraph();
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
@@ -155,73 +155,81 @@ function create_graph(container, data, active_user_id) {
 | 
			
		||||
  return cy;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* global api_url, active_user, depth_min, depth_max */
 | 
			
		||||
document.addEventListener("alpine:init", () => {
 | 
			
		||||
  /*
 | 
			
		||||
    This needs some constants to be set before the document has been loaded
 | 
			
		||||
 | 
			
		||||
    api_url:     base url for fetching the tree as a string
 | 
			
		||||
    active_user: id of the user to fetch the tree from
 | 
			
		||||
    depth_min:   minimum tree depth for godfathers and godchildren as an int
 | 
			
		||||
    depth_max:   maximum tree depth for godfathers and godchildren as an int
 | 
			
		||||
    apiUrl:     base url for fetching the tree as a string
 | 
			
		||||
    activeUser: id of the user to fetch the tree from
 | 
			
		||||
    depthMin:   minimum tree depth for godfathers and godchildren as an int
 | 
			
		||||
    depthMax:   maximum tree depth for godfathers and godchildren as an int
 | 
			
		||||
  */
 | 
			
		||||
  const default_depth = 2;
 | 
			
		||||
  const defaultDepth = 2;
 | 
			
		||||
 | 
			
		||||
  if (
 | 
			
		||||
    typeof api_url === "undefined" ||
 | 
			
		||||
    typeof active_user === "undefined" ||
 | 
			
		||||
    typeof depth_min === "undefined" ||
 | 
			
		||||
    typeof depth_max === "undefined"
 | 
			
		||||
    // biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
 | 
			
		||||
    typeof apiUrl === "undefined" ||
 | 
			
		||||
    // biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
 | 
			
		||||
    typeof activeUser === "undefined" ||
 | 
			
		||||
    // biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
 | 
			
		||||
    typeof depthMin === "undefined" ||
 | 
			
		||||
    // biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
 | 
			
		||||
    typeof depthMax === "undefined"
 | 
			
		||||
  ) {
 | 
			
		||||
    console.error(
 | 
			
		||||
    throw new Error(
 | 
			
		||||
      "Some constants are not set before using the family_graph script, please look at the documentation",
 | 
			
		||||
    );
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function get_initial_depth(prop) {
 | 
			
		||||
  function getInitialDepth(prop) {
 | 
			
		||||
    // biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
 | 
			
		||||
    const value = Number.parseInt(initialUrlParams.get(prop));
 | 
			
		||||
    if (Number.isNaN(value) || value < depth_min || value > depth_max) {
 | 
			
		||||
      return default_depth;
 | 
			
		||||
    // biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
 | 
			
		||||
    if (Number.isNaN(value) || value < depthMin || value > depthMax) {
 | 
			
		||||
      return defaultDepth;
 | 
			
		||||
    }
 | 
			
		||||
    return value;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Alpine.data("graph", () => ({
 | 
			
		||||
    loading: false,
 | 
			
		||||
    godfathers_depth: get_initial_depth("godfathers_depth"),
 | 
			
		||||
    godchildren_depth: get_initial_depth("godchildren_depth"),
 | 
			
		||||
    godfathersDepth: getInitialDepth("godfathersDepth"),
 | 
			
		||||
    godchildrenDepth: getInitialDepth("godchildrenDepth"),
 | 
			
		||||
    // biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
 | 
			
		||||
    reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
 | 
			
		||||
    graph: undefined,
 | 
			
		||||
    graph_data: {},
 | 
			
		||||
    graphData: {},
 | 
			
		||||
 | 
			
		||||
    async init() {
 | 
			
		||||
      const delayed_fetch = Alpine.debounce(async () => {
 | 
			
		||||
        this.fetch_graph_data();
 | 
			
		||||
      const delayedFetch = Alpine.debounce(async () => {
 | 
			
		||||
        await this.fetchGraphData();
 | 
			
		||||
      }, 100);
 | 
			
		||||
      for (const param of ["godfathers_depth", "godchildren_depth"]) {
 | 
			
		||||
      for (const param of ["godfathersDepth", "godchildrenDepth"]) {
 | 
			
		||||
        this.$watch(param, async (value) => {
 | 
			
		||||
          if (value < depth_min || value > depth_max) {
 | 
			
		||||
          // biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
 | 
			
		||||
          if (value < depthMin || value > depthMax) {
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
          update_query_string(param, value, History.REPLACE);
 | 
			
		||||
          delayed_fetch();
 | 
			
		||||
          // biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
 | 
			
		||||
          updateQueryString(param, value, History.REPLACE);
 | 
			
		||||
          await delayedFetch();
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
      this.$watch("reverse", async (value) => {
 | 
			
		||||
        update_query_string("reverse", value, History.REPLACE);
 | 
			
		||||
        this.reverse_graph();
 | 
			
		||||
        // biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
 | 
			
		||||
        updateQueryString("reverse", value, History.REPLACE);
 | 
			
		||||
        await this.reverseGraph();
 | 
			
		||||
      });
 | 
			
		||||
      this.$watch("graph_data", async () => {
 | 
			
		||||
        await this.generate_graph();
 | 
			
		||||
      this.$watch("graphData", async () => {
 | 
			
		||||
        await this.generateGraph();
 | 
			
		||||
        if (this.reverse) {
 | 
			
		||||
          await this.reverse_graph();
 | 
			
		||||
          await this.reverseGraph();
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
      this.fetch_graph_data();
 | 
			
		||||
      await this.fetchGraphData();
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async screenshot() {
 | 
			
		||||
    screenshot() {
 | 
			
		||||
      const link = document.createElement("a");
 | 
			
		||||
      link.href = this.graph.jpg();
 | 
			
		||||
      link.download = interpolate(
 | 
			
		||||
@@ -234,30 +242,32 @@ document.addEventListener("alpine:init", () => {
 | 
			
		||||
      document.body.removeChild(link);
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async reset() {
 | 
			
		||||
    reset() {
 | 
			
		||||
      this.reverse = false;
 | 
			
		||||
      this.godfathers_depth = default_depth;
 | 
			
		||||
      this.godchildren_depth = default_depth;
 | 
			
		||||
      this.godfathersDepth = defaultDepth;
 | 
			
		||||
      this.godchildrenDepth = defaultDepth;
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async reverse_graph() {
 | 
			
		||||
    async reverseGraph() {
 | 
			
		||||
      this.graph.elements((el) => {
 | 
			
		||||
        el.position({ x: -el.position().x, y: -el.position().y });
 | 
			
		||||
      });
 | 
			
		||||
      this.graph.center(this.graph.elements());
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async fetch_graph_data() {
 | 
			
		||||
      this.graph_data = await get_graph_data(
 | 
			
		||||
        api_url,
 | 
			
		||||
        this.godfathers_depth,
 | 
			
		||||
        this.godchildren_depth,
 | 
			
		||||
    async fetchGraphData() {
 | 
			
		||||
      this.graphData = await getGraphData(
 | 
			
		||||
        // biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
 | 
			
		||||
        apiUrl,
 | 
			
		||||
        this.godfathersDepth,
 | 
			
		||||
        this.godchildrenDepth,
 | 
			
		||||
      );
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async generate_graph() {
 | 
			
		||||
    async generateGraph() {
 | 
			
		||||
      this.loading = true;
 | 
			
		||||
      this.graph = create_graph($(this.$refs.graph), this.graph_data, active_user);
 | 
			
		||||
      // biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
 | 
			
		||||
      this.graph = await createGraph($(this.$refs.graph), this.graphData, activeUser);
 | 
			
		||||
      this.loading = false;
 | 
			
		||||
    },
 | 
			
		||||
  }));
 | 
			
		||||
 
 | 
			
		||||
@@ -1,23 +1,24 @@
 | 
			
		||||
function alpine_webcam_builder(default_picture, delete_url, can_delete_picture) {
 | 
			
		||||
// biome-ignore lint/correctness/noUnusedVariables: used in user_edit.jinja
 | 
			
		||||
function alpineWebcamBuilder(defaultPicture, deleteUrl, canDeletePicture) {
 | 
			
		||||
  return () => ({
 | 
			
		||||
    can_edit_picture: false,
 | 
			
		||||
    canEditPicture: false,
 | 
			
		||||
 | 
			
		||||
    loading: false,
 | 
			
		||||
    is_camera_enabled: false,
 | 
			
		||||
    is_camera_error: false,
 | 
			
		||||
    isCameraEnabled: false,
 | 
			
		||||
    isCameraError: false,
 | 
			
		||||
    picture: null,
 | 
			
		||||
    video: null,
 | 
			
		||||
    picture_form: null,
 | 
			
		||||
    pictureForm: null,
 | 
			
		||||
 | 
			
		||||
    init() {
 | 
			
		||||
      this.video = this.$refs.video;
 | 
			
		||||
      this.picture_form = this.$refs.form.getElementsByTagName("input");
 | 
			
		||||
      if (this.picture_form.length > 0) {
 | 
			
		||||
        this.picture_form = this.picture_form[0];
 | 
			
		||||
        this.can_edit_picture = true;
 | 
			
		||||
      this.pictureForm = this.$refs.form.getElementsByTagName("input");
 | 
			
		||||
      if (this.pictureForm.length > 0) {
 | 
			
		||||
        this.pictureForm = this.pictureForm[0];
 | 
			
		||||
        this.canEditPicture = true;
 | 
			
		||||
 | 
			
		||||
        // Link the displayed element to the form input
 | 
			
		||||
        this.picture_form.onchange = (event) => {
 | 
			
		||||
        this.pictureForm.onchange = (event) => {
 | 
			
		||||
          const files = event.srcElement.files;
 | 
			
		||||
          if (files.length > 0) {
 | 
			
		||||
            this.picture = (window.URL || window.webkitURL).createObjectURL(
 | 
			
		||||
@@ -30,45 +31,45 @@ function alpine_webcam_builder(default_picture, delete_url, can_delete_picture)
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    get_picture() {
 | 
			
		||||
      return this.picture || default_picture;
 | 
			
		||||
    getPicture() {
 | 
			
		||||
      return this.picture || defaultPicture;
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    delete_picture() {
 | 
			
		||||
    deletePicture() {
 | 
			
		||||
      // Only remove currently displayed picture
 | 
			
		||||
      if (this.picture) {
 | 
			
		||||
        const list = new DataTransfer();
 | 
			
		||||
        this.picture_form.files = list.files;
 | 
			
		||||
        this.picture_form.dispatchEvent(new Event("change"));
 | 
			
		||||
        this.pictureForm.files = list.files;
 | 
			
		||||
        this.pictureForm.dispatchEvent(new Event("change"));
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      if (!can_delete_picture) {
 | 
			
		||||
      if (!canDeletePicture) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      // Remove user picture if correct rights are available
 | 
			
		||||
      window.open(delete_url, "_self");
 | 
			
		||||
      window.open(deleteUrl, "_self");
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    enable_camera() {
 | 
			
		||||
    enableCamera() {
 | 
			
		||||
      this.picture = null;
 | 
			
		||||
      this.loading = true;
 | 
			
		||||
      this.is_camera_error = false;
 | 
			
		||||
      this.isCameraError = false;
 | 
			
		||||
      navigator.mediaDevices
 | 
			
		||||
        .getUserMedia({ video: true, audio: false })
 | 
			
		||||
        .then((stream) => {
 | 
			
		||||
          this.loading = false;
 | 
			
		||||
          this.is_camera_enabled = true;
 | 
			
		||||
          this.isCameraEnabled = true;
 | 
			
		||||
          this.video.srcObject = stream;
 | 
			
		||||
          this.video.play();
 | 
			
		||||
        })
 | 
			
		||||
        .catch((err) => {
 | 
			
		||||
          this.is_camera_error = true;
 | 
			
		||||
          this.isCameraError = true;
 | 
			
		||||
          this.loading = false;
 | 
			
		||||
          throw err;
 | 
			
		||||
        });
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    take_picture() {
 | 
			
		||||
    takePicture() {
 | 
			
		||||
      const canvas = document.createElement("canvas");
 | 
			
		||||
      const context = canvas.getContext("2d");
 | 
			
		||||
 | 
			
		||||
@@ -94,14 +95,14 @@ function alpine_webcam_builder(default_picture, delete_url, can_delete_picture)
 | 
			
		||||
 | 
			
		||||
        const list = new DataTransfer();
 | 
			
		||||
        list.items.add(file);
 | 
			
		||||
        this.picture_form.files = list.files;
 | 
			
		||||
        this.pictureForm.files = list.files;
 | 
			
		||||
 | 
			
		||||
        // No change event is triggered, we trigger it manually #}
 | 
			
		||||
        this.picture_form.dispatchEvent(new Event("change"));
 | 
			
		||||
        this.pictureForm.dispatchEvent(new Event("change"));
 | 
			
		||||
      }, "image/webp");
 | 
			
		||||
 | 
			
		||||
      canvas.remove();
 | 
			
		||||
      this.is_camera_enabled = false;
 | 
			
		||||
      this.isCameraEnabled = false;
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,6 @@ import Alpine from "alpinejs";
 | 
			
		||||
 | 
			
		||||
window.Alpine = Alpine;
 | 
			
		||||
 | 
			
		||||
window.addEventListener("DOMContentLoaded", (event) => {
 | 
			
		||||
window.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
  Alpine.start();
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
// biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde
 | 
			
		||||
import "codemirror/lib/codemirror.css";
 | 
			
		||||
import "easymde/src/css/easymde.css";
 | 
			
		||||
import EasyMDE from "easymde";
 | 
			
		||||
import easyMde from "easymde";
 | 
			
		||||
 | 
			
		||||
// This scripts dependens on Alpine but it should be loaded on every page
 | 
			
		||||
 | 
			
		||||
@@ -9,13 +10,13 @@ import EasyMDE from "easymde";
 | 
			
		||||
 * @param {HTMLTextAreaElement} textarea to use
 | 
			
		||||
 * @param {string} link to the markdown api
 | 
			
		||||
 **/
 | 
			
		||||
function easymdeFactory(textarea, markdownApiURL) {
 | 
			
		||||
  const easymde = new EasyMDE({
 | 
			
		||||
function easymdeFactory(textarea, markdownApiUrl) {
 | 
			
		||||
  const easymde = new easyMde({
 | 
			
		||||
    element: textarea,
 | 
			
		||||
    spellChecker: false,
 | 
			
		||||
    autoDownloadFontAwesome: false,
 | 
			
		||||
    previewRender: Alpine.debounce(async (plainText, preview) => {
 | 
			
		||||
      const res = await fetch(markdownApiURL, {
 | 
			
		||||
      const res = await fetch(markdownApiUrl, {
 | 
			
		||||
        method: "POST",
 | 
			
		||||
        body: JSON.stringify({ text: plainText }),
 | 
			
		||||
      });
 | 
			
		||||
@@ -26,25 +27,25 @@ function easymdeFactory(textarea, markdownApiURL) {
 | 
			
		||||
    toolbar: [
 | 
			
		||||
      {
 | 
			
		||||
        name: "heading-smaller",
 | 
			
		||||
        action: EasyMDE.toggleHeadingSmaller,
 | 
			
		||||
        action: easyMde.toggleHeadingSmaller,
 | 
			
		||||
        className: "fa fa-header",
 | 
			
		||||
        title: gettext("Heading"),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: "italic",
 | 
			
		||||
        action: EasyMDE.toggleItalic,
 | 
			
		||||
        action: easyMde.toggleItalic,
 | 
			
		||||
        className: "fa fa-italic",
 | 
			
		||||
        title: gettext("Italic"),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: "bold",
 | 
			
		||||
        action: EasyMDE.toggleBold,
 | 
			
		||||
        action: easyMde.toggleBold,
 | 
			
		||||
        className: "fa fa-bold",
 | 
			
		||||
        title: gettext("Bold"),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: "strikethrough",
 | 
			
		||||
        action: EasyMDE.toggleStrikethrough,
 | 
			
		||||
        action: easyMde.toggleStrikethrough,
 | 
			
		||||
        className: "fa fa-strikethrough",
 | 
			
		||||
        title: gettext("Strikethrough"),
 | 
			
		||||
      },
 | 
			
		||||
@@ -77,71 +78,71 @@ function easymdeFactory(textarea, markdownApiURL) {
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: "code",
 | 
			
		||||
        action: EasyMDE.toggleCodeBlock,
 | 
			
		||||
        action: easyMde.toggleCodeBlock,
 | 
			
		||||
        className: "fa fa-code",
 | 
			
		||||
        title: gettext("Code"),
 | 
			
		||||
      },
 | 
			
		||||
      "|",
 | 
			
		||||
      {
 | 
			
		||||
        name: "quote",
 | 
			
		||||
        action: EasyMDE.toggleBlockquote,
 | 
			
		||||
        action: easyMde.toggleBlockquote,
 | 
			
		||||
        className: "fa fa-quote-left",
 | 
			
		||||
        title: gettext("Quote"),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: "unordered-list",
 | 
			
		||||
        action: EasyMDE.toggleUnorderedList,
 | 
			
		||||
        action: easyMde.toggleUnorderedList,
 | 
			
		||||
        className: "fa fa-list-ul",
 | 
			
		||||
        title: gettext("Unordered list"),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: "ordered-list",
 | 
			
		||||
        action: EasyMDE.toggleOrderedList,
 | 
			
		||||
        action: easyMde.toggleOrderedList,
 | 
			
		||||
        className: "fa fa-list-ol",
 | 
			
		||||
        title: gettext("Ordered list"),
 | 
			
		||||
      },
 | 
			
		||||
      "|",
 | 
			
		||||
      {
 | 
			
		||||
        name: "link",
 | 
			
		||||
        action: EasyMDE.drawLink,
 | 
			
		||||
        action: easyMde.drawLink,
 | 
			
		||||
        className: "fa fa-link",
 | 
			
		||||
        title: gettext("Insert link"),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: "image",
 | 
			
		||||
        action: EasyMDE.drawImage,
 | 
			
		||||
        action: easyMde.drawImage,
 | 
			
		||||
        className: "fa-regular fa-image",
 | 
			
		||||
        title: gettext("Insert image"),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: "table",
 | 
			
		||||
        action: EasyMDE.drawTable,
 | 
			
		||||
        action: easyMde.drawTable,
 | 
			
		||||
        className: "fa fa-table",
 | 
			
		||||
        title: gettext("Insert table"),
 | 
			
		||||
      },
 | 
			
		||||
      "|",
 | 
			
		||||
      {
 | 
			
		||||
        name: "clean-block",
 | 
			
		||||
        action: EasyMDE.cleanBlock,
 | 
			
		||||
        action: easyMde.cleanBlock,
 | 
			
		||||
        className: "fa fa-eraser fa-clean-block",
 | 
			
		||||
        title: gettext("Clean block"),
 | 
			
		||||
      },
 | 
			
		||||
      "|",
 | 
			
		||||
      {
 | 
			
		||||
        name: "preview",
 | 
			
		||||
        action: EasyMDE.togglePreview,
 | 
			
		||||
        action: easyMde.togglePreview,
 | 
			
		||||
        className: "fa fa-eye no-disable",
 | 
			
		||||
        title: gettext("Toggle preview"),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: "side-by-side",
 | 
			
		||||
        action: EasyMDE.toggleSideBySide,
 | 
			
		||||
        action: easyMde.toggleSideBySide,
 | 
			
		||||
        className: "fa fa-columns no-disable no-mobile",
 | 
			
		||||
        title: gettext("Toggle side by side"),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: "fullscreen",
 | 
			
		||||
        action: EasyMDE.toggleFullScreen,
 | 
			
		||||
        action: easyMde.toggleFullScreen,
 | 
			
		||||
        className: "fa fa-expand no-mobile",
 | 
			
		||||
        title: gettext("Toggle fullscreen"),
 | 
			
		||||
      },
 | 
			
		||||
@@ -159,7 +160,7 @@ function easymdeFactory(textarea, markdownApiURL) {
 | 
			
		||||
  const parentDiv = textarea.parentElement;
 | 
			
		||||
  let submitPressed = false;
 | 
			
		||||
 | 
			
		||||
  function checkMarkdownInput(e) {
 | 
			
		||||
  function checkMarkdownInput() {
 | 
			
		||||
    // an attribute is null if it does not exist, else a string
 | 
			
		||||
    const required = textarea.getAttribute("required") != null;
 | 
			
		||||
    const length = textarea.value.trim().length;
 | 
			
		||||
 
 | 
			
		||||
@@ -112,7 +112,7 @@
 | 
			
		||||
                  ></a>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="notification">
 | 
			
		||||
                  <a href="#" onclick="display_notif()">
 | 
			
		||||
                  <a href="#" onclick="displayNotif()">
 | 
			
		||||
                    <i class="fa-regular fa-bell"></i>
 | 
			
		||||
                    {% set notification_count = user.notifications.filter(viewed=False).count() %}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -15,36 +15,36 @@
 | 
			
		||||
{% macro profile_picture(field_name) %}
 | 
			
		||||
  {% set this_picture = form.instance[field_name] %}
 | 
			
		||||
  <div class="profile-picture" x-data="camera_{{ field_name }}" >
 | 
			
		||||
    <div class="profile-picture-display" :aria-busy="loading" :class="{ 'camera-error': is_camera_error }">
 | 
			
		||||
    <div class="profile-picture-display" :aria-busy="loading" :class="{ 'camera-error': isCameraError }">
 | 
			
		||||
      <img
 | 
			
		||||
        x-show="!is_camera_enabled && !is_camera_error"
 | 
			
		||||
        :src="get_picture()"
 | 
			
		||||
        x-show="!isCameraEnabled && !isCameraError"
 | 
			
		||||
        :src="getPicture()"
 | 
			
		||||
        alt="{%- trans -%}Profile{%- endtrans -%}" title="{%- trans -%}Profile{%- endtrans -%}"
 | 
			
		||||
        loading="lazy"
 | 
			
		||||
      />
 | 
			
		||||
      <video
 | 
			
		||||
        x-show="is_camera_enabled"
 | 
			
		||||
        x-show="isCameraEnabled"
 | 
			
		||||
        x-ref="video"
 | 
			
		||||
      ></video>
 | 
			
		||||
      <i
 | 
			
		||||
        x-show="is_camera_error"
 | 
			
		||||
        x-show="isCameraError"
 | 
			
		||||
        x-cloak
 | 
			
		||||
        class="fa fa-eye-slash"
 | 
			
		||||
      ></i>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="profile-picture-buttons" x-show="can_edit_picture">
 | 
			
		||||
    <div class="profile-picture-buttons" x-show="canEditPicture">
 | 
			
		||||
      <button
 | 
			
		||||
        x-show="can_edit_picture && !is_camera_enabled"
 | 
			
		||||
        x-show="canEditPicture && !isCameraEnabled"
 | 
			
		||||
        class="btn btn-blue"
 | 
			
		||||
        @click.prevent="enable_camera()"
 | 
			
		||||
        @click.prevent="enableCamera()"
 | 
			
		||||
      >
 | 
			
		||||
        <i class="fa fa-camera"></i>
 | 
			
		||||
        {% trans %}Enable camera{% endtrans %}
 | 
			
		||||
      </button>
 | 
			
		||||
      <button
 | 
			
		||||
        x-show="is_camera_enabled"
 | 
			
		||||
        x-show="isCameraEnabled"
 | 
			
		||||
        class="btn btn-blue"
 | 
			
		||||
        @click.prevent="take_picture()"
 | 
			
		||||
        @click.prevent="takePicture()"
 | 
			
		||||
      >
 | 
			
		||||
        <i class="fa fa-camera"></i>
 | 
			
		||||
        {% trans %}Take a picture{% endtrans %}
 | 
			
		||||
@@ -54,7 +54,7 @@
 | 
			
		||||
      {%- if form[field_name] -%}
 | 
			
		||||
        <div>
 | 
			
		||||
          {{ form[field_name] }}
 | 
			
		||||
          <button class="btn btn-red" @click.prevent="delete_picture()"
 | 
			
		||||
          <button class="btn btn-red" @click.prevent="deletePicture()"
 | 
			
		||||
                  {%- if not (this_picture and this_picture.is_owned_by(user)) -%}
 | 
			
		||||
                    :disabled="!picture"
 | 
			
		||||
                  {%- endif -%}
 | 
			
		||||
@@ -86,7 +86,7 @@
 | 
			
		||||
    document.addEventListener("alpine:init", () => {
 | 
			
		||||
      Alpine.data(
 | 
			
		||||
        "camera_{{ field_name }}",
 | 
			
		||||
        alpine_webcam_builder(
 | 
			
		||||
        alpineWebcamBuilder(
 | 
			
		||||
          {{ default_picture }},
 | 
			
		||||
          {{ delete_url }},
 | 
			
		||||
          {{ (this_picture and this_picture.is_owned_by(user))|tojson }}
 | 
			
		||||
 
 | 
			
		||||
@@ -30,21 +30,21 @@
 | 
			
		||||
          </label>
 | 
			
		||||
          <span class="depth-choice">
 | 
			
		||||
            <button
 | 
			
		||||
              @click="godfathers_depth--"
 | 
			
		||||
              :disabled="godfathers_depth <= {{ depth_min }}"
 | 
			
		||||
              @click="godfathersDepth--"
 | 
			
		||||
              :disabled="godfathersDepth <= {{ depth_min }}"
 | 
			
		||||
            ><i class="fa fa-minus"></i></button>
 | 
			
		||||
            <input
 | 
			
		||||
              x-model="godfathers_depth"
 | 
			
		||||
              x-model="godfathersDepth"
 | 
			
		||||
              x-ref="godfather_depth_input"
 | 
			
		||||
              type="number"
 | 
			
		||||
              name="godfathers_depth"
 | 
			
		||||
              name="godfathersDepth"
 | 
			
		||||
              id="godfather-depth-input"
 | 
			
		||||
              min="{{ depth_min }}"
 | 
			
		||||
              max="{{ depth_max }}"
 | 
			
		||||
            />
 | 
			
		||||
            <button
 | 
			
		||||
              @click="godfathers_depth++"
 | 
			
		||||
              :disabled="godfathers_depth >= {{ depth_max }}"
 | 
			
		||||
              @click="godfathersDepth++"
 | 
			
		||||
              :disabled="godfathersDepth >= {{ depth_max }}"
 | 
			
		||||
            ><i class="fa fa-plus"
 | 
			
		||||
              ></i></button>
 | 
			
		||||
          </span>
 | 
			
		||||
@@ -56,22 +56,22 @@
 | 
			
		||||
          </label>
 | 
			
		||||
          <span class="depth-choice">
 | 
			
		||||
            <button
 | 
			
		||||
              @click="godchildren_depth--"
 | 
			
		||||
              :disabled="godchildren_depth <= {{ depth_min }}"
 | 
			
		||||
              @click="godchildrenDepth--"
 | 
			
		||||
              :disabled="godchildrenDepth <= {{ depth_min }}"
 | 
			
		||||
            ><i
 | 
			
		||||
              class="fa fa-minus"
 | 
			
		||||
            ></i></button>
 | 
			
		||||
            <input
 | 
			
		||||
              x-model="godchildren_depth"
 | 
			
		||||
              x-model="godchildrenDepth"
 | 
			
		||||
              type="number"
 | 
			
		||||
              name="godchildren_depth"
 | 
			
		||||
              name="godchildrenDepth"
 | 
			
		||||
              id="godchild-depth-input"
 | 
			
		||||
              min="{{ depth_min }}"
 | 
			
		||||
              max="{{ depth_max }}"
 | 
			
		||||
            />
 | 
			
		||||
            <button
 | 
			
		||||
              @click="godchildren_depth++"
 | 
			
		||||
              :disabled="godchildren_depth >= {{ depth_max }}"
 | 
			
		||||
              @click="godchildrenDepth++"
 | 
			
		||||
              :disabled="godchildrenDepth >= {{ depth_max }}"
 | 
			
		||||
            ><i class="fa fa-plus"
 | 
			
		||||
              ></i></button>
 | 
			
		||||
          </span>
 | 
			
		||||
@@ -96,10 +96,10 @@
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <script>
 | 
			
		||||
    const api_url = "{{ api_url }}";
 | 
			
		||||
    const active_user = "{{ object.id }}"
 | 
			
		||||
    const depth_min = {{ depth_min }};
 | 
			
		||||
    const depth_max = {{ depth_max }};
 | 
			
		||||
    const apiUrl = "{{ api_url }}";
 | 
			
		||||
    const activeUser = "{{ object.id }}"
 | 
			
		||||
    const depthMin = {{ depth_min }};
 | 
			
		||||
    const depthMax = {{ depth_max }};
 | 
			
		||||
  </script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -21,14 +21,14 @@
 | 
			
		||||
    {% if user.id == object.id %}
 | 
			
		||||
      <div x-show="pictures.length > 0" x-cloak>
 | 
			
		||||
        <button
 | 
			
		||||
          :disabled="is_downloading"
 | 
			
		||||
          :disabled="isDownloading"
 | 
			
		||||
          class="btn btn-blue"
 | 
			
		||||
          @click="download_zip()"
 | 
			
		||||
          @click="downloadZip()"
 | 
			
		||||
        >
 | 
			
		||||
          <i class="fa fa-download"></i>
 | 
			
		||||
          {% trans %}Download all my pictures{% endtrans %}
 | 
			
		||||
        </button>
 | 
			
		||||
        <progress x-ref="progress" x-show="is_downloading"></progress>
 | 
			
		||||
        <progress x-ref="progress" x-show="isDownloading"></progress>
 | 
			
		||||
      </div>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
 | 
			
		||||
@@ -92,13 +92,13 @@
 | 
			
		||||
 | 
			
		||||
    document.addEventListener("alpine:init", () => {
 | 
			
		||||
      Alpine.data("user_pictures", () => ({
 | 
			
		||||
        is_downloading: false,
 | 
			
		||||
        isDownloading: false,
 | 
			
		||||
        loading: true,
 | 
			
		||||
        pictures: [],
 | 
			
		||||
        albums: {},
 | 
			
		||||
 | 
			
		||||
        async init() {
 | 
			
		||||
          this.pictures = await fetch_paginated("{{ url("api:pictures") }}" + "?users_identified={{ object.id }}");
 | 
			
		||||
          this.pictures = await fetchPaginated("{{ url("api:pictures") }}" + "?users_identified={{ object.id }}");
 | 
			
		||||
          this.albums = this.pictures.reduce((acc, picture) => {
 | 
			
		||||
            if (!acc[picture.album]){
 | 
			
		||||
              acc[picture.album] = [];
 | 
			
		||||
@@ -109,8 +109,8 @@
 | 
			
		||||
          this.loading = false;
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        async download_zip(){
 | 
			
		||||
          this.is_downloading = true;
 | 
			
		||||
        async downloadZip(){
 | 
			
		||||
          this.isDownloading = true;
 | 
			
		||||
          const bar = this.$refs.progress;
 | 
			
		||||
          bar.value = 0;
 | 
			
		||||
          bar.max = this.pictures.length;
 | 
			
		||||
@@ -124,16 +124,16 @@
 | 
			
		||||
          const zipWriter = new zip.ZipWriter(await fileHandle.createWritable());
 | 
			
		||||
 | 
			
		||||
          await Promise.all(this.pictures.map(p => {
 | 
			
		||||
            const img_name = p.album + "/IMG_" + p.date.replaceAll(/[:\-]/g, "_") + p.name.slice(p.name.lastIndexOf("."));
 | 
			
		||||
            const imgName = p.album + "/IMG_" + p.date.replaceAll(/[:\-]/g, "_") + p.name.slice(p.name.lastIndexOf("."));
 | 
			
		||||
            return zipWriter.add(
 | 
			
		||||
              img_name,
 | 
			
		||||
              imgName,
 | 
			
		||||
              new zip.HttpReader(p.full_size_url),
 | 
			
		||||
              {level: 9, lastModDate: new Date(p.date), onstart: () => bar.value += 1}
 | 
			
		||||
            );
 | 
			
		||||
          }));
 | 
			
		||||
 | 
			
		||||
          await zipWriter.close();
 | 
			
		||||
          this.is_downloading = false;
 | 
			
		||||
          this.isDownloading = false;
 | 
			
		||||
        }
 | 
			
		||||
      }))
 | 
			
		||||
    });
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,10 @@
 | 
			
		||||
document.addEventListener("alpine:init", () => {
 | 
			
		||||
  Alpine.data("counter", () => ({
 | 
			
		||||
    basket,
 | 
			
		||||
    // biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja
 | 
			
		||||
    basket: sessionBasket,
 | 
			
		||||
    errors: [],
 | 
			
		||||
 | 
			
		||||
    sum_basket() {
 | 
			
		||||
    sumBasket() {
 | 
			
		||||
      if (!this.basket || Object.keys(this.basket).length === 0) {
 | 
			
		||||
        return 0;
 | 
			
		||||
      }
 | 
			
		||||
@@ -14,23 +15,26 @@ document.addEventListener("alpine:init", () => {
 | 
			
		||||
      return total / 100;
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async handle_code(event) {
 | 
			
		||||
    async handleCode(event) {
 | 
			
		||||
      const code = $(event.target).find("#code_field").val().toUpperCase();
 | 
			
		||||
      if (["FIN", "ANN"].includes(code)) {
 | 
			
		||||
        $(event.target).submit();
 | 
			
		||||
      } else {
 | 
			
		||||
        await this.handle_action(event);
 | 
			
		||||
        await this.handleAction(event);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async handle_action(event) {
 | 
			
		||||
    async handleAction(event) {
 | 
			
		||||
      const payload = $(event.target).serialize();
 | 
			
		||||
      const request = new Request(click_api_url, {
 | 
			
		||||
      // biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja
 | 
			
		||||
      const request = new Request(clickApiUrl, {
 | 
			
		||||
        method: "POST",
 | 
			
		||||
        body: payload,
 | 
			
		||||
        headers: {
 | 
			
		||||
          // biome-ignore lint/style/useNamingConvention: this goes into http headers
 | 
			
		||||
          Accept: "application/json",
 | 
			
		||||
          "X-CSRFToken": csrf_token,
 | 
			
		||||
          // biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja
 | 
			
		||||
          "X-CSRFToken": csrfToken,
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
      const response = await fetch(request);
 | 
			
		||||
@@ -44,25 +48,27 @@ document.addEventListener("alpine:init", () => {
 | 
			
		||||
 | 
			
		||||
$(() => {
 | 
			
		||||
  /* Autocompletion in the code field */
 | 
			
		||||
  const code_field = $("#code_field");
 | 
			
		||||
  const codeField = $("#code_field");
 | 
			
		||||
 | 
			
		||||
  let quantity = "";
 | 
			
		||||
  code_field.autocomplete({
 | 
			
		||||
  codeField.autocomplete({
 | 
			
		||||
    select: (event, ui) => {
 | 
			
		||||
      event.preventDefault();
 | 
			
		||||
      code_field.val(quantity + ui.item.value);
 | 
			
		||||
      codeField.val(quantity + ui.item.value);
 | 
			
		||||
    },
 | 
			
		||||
    focus: (event, ui) => {
 | 
			
		||||
      event.preventDefault();
 | 
			
		||||
      code_field.val(quantity + ui.item.value);
 | 
			
		||||
      codeField.val(quantity + ui.item.value);
 | 
			
		||||
    },
 | 
			
		||||
    source: (request, response) => {
 | 
			
		||||
      // biome-ignore lint/performance/useTopLevelRegex: performance impact is minimal
 | 
			
		||||
      const res = /^(\d+x)?(.*)/i.exec(request.term);
 | 
			
		||||
      quantity = res[1] || "";
 | 
			
		||||
      const search = res[2];
 | 
			
		||||
      const matcher = new RegExp($.ui.autocomplete.escapeRegex(search), "i");
 | 
			
		||||
      response(
 | 
			
		||||
        $.grep(products_autocomplete, (value) => {
 | 
			
		||||
        // biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja
 | 
			
		||||
        $.grep(productsAutocomplete, (value) => {
 | 
			
		||||
          return matcher.test(value.tags);
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
@@ -76,5 +82,5 @@ $(() => {
 | 
			
		||||
  });
 | 
			
		||||
  $("#products").tabs();
 | 
			
		||||
 | 
			
		||||
  code_field.focus();
 | 
			
		||||
  codeField.focus();
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -59,7 +59,7 @@
 | 
			
		||||
 | 
			
		||||
                {# Formulaire pour rechercher un produit en tapant son code dans une barre de recherche #}
 | 
			
		||||
        <form method="post" action=""
 | 
			
		||||
              class="code_form" @submit.prevent="handle_code">
 | 
			
		||||
              class="code_form" @submit.prevent="handleCode">
 | 
			
		||||
          {% csrf_token %}
 | 
			
		||||
          <input type="hidden" name="action" value="code">
 | 
			
		||||
          <label for="code_field"></label>
 | 
			
		||||
@@ -77,7 +77,7 @@
 | 
			
		||||
          <template x-for="[id, item] in Object.entries(basket)" :key="id">
 | 
			
		||||
            <div>
 | 
			
		||||
              <form method="post" action="" class="inline del_product_form"
 | 
			
		||||
                    @submit.prevent="handle_action">
 | 
			
		||||
                    @submit.prevent="handleAction">
 | 
			
		||||
                {% csrf_token %}
 | 
			
		||||
                <input type="hidden" name="action" value="del_product">
 | 
			
		||||
                <input type="hidden" name="product_id" :value="id">
 | 
			
		||||
@@ -87,7 +87,7 @@
 | 
			
		||||
              <span x-text="item['qty'] + item['bonus_qty']"></span>
 | 
			
		||||
 | 
			
		||||
              <form method="post" action="" class="inline add_product_form"
 | 
			
		||||
                    @submit.prevent="handle_action">
 | 
			
		||||
                    @submit.prevent="handleAction">
 | 
			
		||||
                {% csrf_token %}
 | 
			
		||||
                <input type="hidden" name="action" value="add_product">
 | 
			
		||||
                <input type="hidden" name="product_id" :value="id">
 | 
			
		||||
@@ -104,7 +104,7 @@
 | 
			
		||||
        </ul>
 | 
			
		||||
        <p>
 | 
			
		||||
          <strong>Total: </strong>
 | 
			
		||||
          <strong x-text="sum_basket().toLocaleString(undefined, { minimumFractionDigits: 2 })"></strong>
 | 
			
		||||
          <strong x-text="sumBasket().toLocaleString(undefined, { minimumFractionDigits: 2 })"></strong>
 | 
			
		||||
          <strong> €</strong>
 | 
			
		||||
        </p>
 | 
			
		||||
 | 
			
		||||
@@ -147,7 +147,7 @@
 | 
			
		||||
          {% for p in categories[category] -%}
 | 
			
		||||
            <form method="post"
 | 
			
		||||
                  action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}"
 | 
			
		||||
                  class="form_button add_product_form" @submit.prevent="handle_action">
 | 
			
		||||
                  class="form_button add_product_form" @submit.prevent="handleAction">
 | 
			
		||||
              {% csrf_token %}
 | 
			
		||||
              <input type="hidden" name="action" value="add_product">
 | 
			
		||||
              <input type="hidden" name="product_id" value="{{ p.id }}">
 | 
			
		||||
@@ -171,9 +171,9 @@
 | 
			
		||||
{% block script %}
 | 
			
		||||
  {{ super() }}
 | 
			
		||||
  <script>
 | 
			
		||||
    const csrf_token = "{{ csrf_token }}";
 | 
			
		||||
    const click_api_url = "{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}";
 | 
			
		||||
    const basket = {{ request.session["basket"]|tojson }};
 | 
			
		||||
    const csrfToken = "{{ csrf_token }}";
 | 
			
		||||
    const clickApiUrl = "{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}";
 | 
			
		||||
    const sessionBasket = {{ request.session["basket"]|tojson }};
 | 
			
		||||
    const products = {
 | 
			
		||||
      {%- for p in products -%}
 | 
			
		||||
        {{ p.id }}: {
 | 
			
		||||
@@ -183,7 +183,7 @@
 | 
			
		||||
        },
 | 
			
		||||
      {%- endfor -%}
 | 
			
		||||
    };
 | 
			
		||||
    const products_autocomplete = [
 | 
			
		||||
    const productsAutocomplete = [
 | 
			
		||||
      {% for p in products -%}
 | 
			
		||||
        {
 | 
			
		||||
          value: "{{ p.code }}",
 | 
			
		||||
 
 | 
			
		||||
@@ -14,6 +14,7 @@ const BASKET_ITEMS_COOKIE_NAME = "basket_items";
 | 
			
		||||
 * @returns {string|null|undefined} the value of the cookie or null if it does not exist, undefined if not found
 | 
			
		||||
 */
 | 
			
		||||
function getCookie(name) {
 | 
			
		||||
  // biome-ignore lint/style/useBlockStatements: <explanation>
 | 
			
		||||
  if (!document.cookie || document.cookie.length === 0) return null;
 | 
			
		||||
 | 
			
		||||
  const found = document.cookie
 | 
			
		||||
@@ -28,7 +29,7 @@ function getCookie(name) {
 | 
			
		||||
 * Fetch the basket items from the associated cookie
 | 
			
		||||
 * @returns {BasketItem[]|[]} the items in the basket
 | 
			
		||||
 */
 | 
			
		||||
function get_starting_items() {
 | 
			
		||||
function getStartingItems() {
 | 
			
		||||
  const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME);
 | 
			
		||||
  if (!cookie) {
 | 
			
		||||
    return [];
 | 
			
		||||
@@ -45,13 +46,13 @@ function get_starting_items() {
 | 
			
		||||
 | 
			
		||||
document.addEventListener("alpine:init", () => {
 | 
			
		||||
  Alpine.data("basket", () => ({
 | 
			
		||||
    items: get_starting_items(),
 | 
			
		||||
    items: getStartingItems(),
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the total price of the basket
 | 
			
		||||
     * @returns {number} The total price of the basket
 | 
			
		||||
     */
 | 
			
		||||
    get_total() {
 | 
			
		||||
    getTotal() {
 | 
			
		||||
      return this.items.reduce((acc, item) => acc + item.quantity * item.unit_price, 0);
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
@@ -61,38 +62,40 @@ document.addEventListener("alpine:init", () => {
 | 
			
		||||
     */
 | 
			
		||||
    add(item) {
 | 
			
		||||
      item.quantity++;
 | 
			
		||||
      this.set_cookies();
 | 
			
		||||
      this.setCookies();
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove 1 to the quantity of an item in the basket
 | 
			
		||||
     * @param {BasketItem} item_id
 | 
			
		||||
     */
 | 
			
		||||
    remove(item_id) {
 | 
			
		||||
      const index = this.items.findIndex((e) => e.id === item_id);
 | 
			
		||||
    remove(itemId) {
 | 
			
		||||
      const index = this.items.findIndex((e) => e.id === itemId);
 | 
			
		||||
 | 
			
		||||
      if (index < 0) return;
 | 
			
		||||
      if (index < 0) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      this.items[index].quantity -= 1;
 | 
			
		||||
 | 
			
		||||
      if (this.items[index].quantity === 0) {
 | 
			
		||||
        this.items = this.items.filter((e) => e.id !== this.items[index].id);
 | 
			
		||||
      }
 | 
			
		||||
      this.set_cookies();
 | 
			
		||||
      this.setCookies();
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Remove all the items from the basket & cleans the catalog CSS classes
 | 
			
		||||
     */
 | 
			
		||||
    clear_basket() {
 | 
			
		||||
    clearBasket() {
 | 
			
		||||
      this.items = [];
 | 
			
		||||
      this.set_cookies();
 | 
			
		||||
      this.setCookies();
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Set the cookie in the browser with the basket items
 | 
			
		||||
     * ! the cookie survives an hour
 | 
			
		||||
     */
 | 
			
		||||
    set_cookies() {
 | 
			
		||||
    setCookies() {
 | 
			
		||||
      if (this.items.length === 0) {
 | 
			
		||||
        document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=;Max-Age=0`;
 | 
			
		||||
      } else {
 | 
			
		||||
@@ -107,18 +110,19 @@ document.addEventListener("alpine:init", () => {
 | 
			
		||||
     * @param {number} price The unit price of the product
 | 
			
		||||
     * @returns {BasketItem} The created item
 | 
			
		||||
     */
 | 
			
		||||
    create_item(id, name, price) {
 | 
			
		||||
      const new_item = {
 | 
			
		||||
    createItem(id, name, price) {
 | 
			
		||||
      const newItem = {
 | 
			
		||||
        id,
 | 
			
		||||
        name,
 | 
			
		||||
        quantity: 0,
 | 
			
		||||
        // biome-ignore lint/style/useNamingConvention: used by django backend
 | 
			
		||||
        unit_price: price,
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      this.items.push(new_item);
 | 
			
		||||
      this.add(new_item);
 | 
			
		||||
      this.items.push(newItem);
 | 
			
		||||
      this.add(newItem);
 | 
			
		||||
 | 
			
		||||
      return new_item;
 | 
			
		||||
      return newItem;
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@@ -128,15 +132,15 @@ document.addEventListener("alpine:init", () => {
 | 
			
		||||
     * @param {string} name The name of the product
 | 
			
		||||
     * @param {number} price The unit price of the product
 | 
			
		||||
     */
 | 
			
		||||
    add_from_catalog(id, name, price) {
 | 
			
		||||
    addFromCatalog(id, name, price) {
 | 
			
		||||
      let item = this.items.find((e) => e.id === id);
 | 
			
		||||
 | 
			
		||||
      // if the item is not in the basket, we create it
 | 
			
		||||
      // else we add + 1 to it
 | 
			
		||||
      if (!item) {
 | 
			
		||||
        item = this.create_item(id, name, price);
 | 
			
		||||
      } else {
 | 
			
		||||
      if (item) {
 | 
			
		||||
        this.add(item);
 | 
			
		||||
      } else {
 | 
			
		||||
        item = this.createItem(id, name, price);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
  }));
 | 
			
		||||
 
 | 
			
		||||
@@ -3,18 +3,23 @@
 | 
			
		||||
 * @enum {number}
 | 
			
		||||
 */
 | 
			
		||||
const BillingInfoReqState = {
 | 
			
		||||
  // biome-ignore lint/style/useNamingConvention: this feels more like an enum
 | 
			
		||||
  SUCCESS: 1,
 | 
			
		||||
  // biome-ignore lint/style/useNamingConvention: this feels more like an enum
 | 
			
		||||
  FAILURE: 2,
 | 
			
		||||
  // biome-ignore lint/style/useNamingConvention: this feels more like an enum
 | 
			
		||||
  SENDING: 3,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
document.addEventListener("alpine:init", () => {
 | 
			
		||||
  Alpine.store("billing_inputs", {
 | 
			
		||||
    data: et_data,
 | 
			
		||||
    // biome-ignore lint/correctness/noUndeclaredVariables: defined in eboutic_makecommand.jinja
 | 
			
		||||
    data: etData,
 | 
			
		||||
 | 
			
		||||
    async fill() {
 | 
			
		||||
      document.getElementById("bank-submit-button").disabled = true;
 | 
			
		||||
      const res = await fetch(et_data_url);
 | 
			
		||||
      // biome-ignore lint/correctness/noUndeclaredVariables: defined in eboutic_makecommand.jinja
 | 
			
		||||
      const res = await fetch(etDataUrl);
 | 
			
		||||
      if (res.ok) {
 | 
			
		||||
        this.data = await res.json();
 | 
			
		||||
        document.getElementById("bank-submit-button").disabled = false;
 | 
			
		||||
@@ -24,10 +29,10 @@ document.addEventListener("alpine:init", () => {
 | 
			
		||||
 | 
			
		||||
  Alpine.data("billing_infos", () => ({
 | 
			
		||||
    /** @type {BillingInfoReqState | null} */
 | 
			
		||||
    req_state: null,
 | 
			
		||||
    reqState: null,
 | 
			
		||||
 | 
			
		||||
    async send_form() {
 | 
			
		||||
      this.req_state = BillingInfoReqState.SENDING;
 | 
			
		||||
    async sendForm() {
 | 
			
		||||
      this.reqState = BillingInfoReqState.SENDING;
 | 
			
		||||
      const form = document.getElementById("billing_info_form");
 | 
			
		||||
      document.getElementById("bank-submit-button").disabled = true;
 | 
			
		||||
      const payload = Object.fromEntries(
 | 
			
		||||
@@ -35,11 +40,12 @@ document.addEventListener("alpine:init", () => {
 | 
			
		||||
          .filter((elem) => elem.type !== "submit" && elem.value)
 | 
			
		||||
          .map((elem) => [elem.name, elem.value]),
 | 
			
		||||
      );
 | 
			
		||||
      const res = await fetch(billing_info_url, {
 | 
			
		||||
      // biome-ignore lint/correctness/noUndeclaredVariables: defined in eboutic_makecommand.jinja
 | 
			
		||||
      const res = await fetch(billingInfoUrl, {
 | 
			
		||||
        method: "PUT",
 | 
			
		||||
        body: JSON.stringify(payload),
 | 
			
		||||
      });
 | 
			
		||||
      this.req_state = res.ok
 | 
			
		||||
      this.reqState = res.ok
 | 
			
		||||
        ? BillingInfoReqState.SUCCESS
 | 
			
		||||
        : BillingInfoReqState.FAILURE;
 | 
			
		||||
      if (res.status === 422) {
 | 
			
		||||
@@ -56,22 +62,24 @@ document.addEventListener("alpine:init", () => {
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    get_alert_color() {
 | 
			
		||||
      if (this.req_state === BillingInfoReqState.SUCCESS) {
 | 
			
		||||
    getAlertColor() {
 | 
			
		||||
      if (this.reqState === BillingInfoReqState.SUCCESS) {
 | 
			
		||||
        return "green";
 | 
			
		||||
      }
 | 
			
		||||
      if (this.req_state === BillingInfoReqState.FAILURE) {
 | 
			
		||||
      if (this.reqState === BillingInfoReqState.FAILURE) {
 | 
			
		||||
        return "red";
 | 
			
		||||
      }
 | 
			
		||||
      return "";
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    get_alert_message() {
 | 
			
		||||
      if (this.req_state === BillingInfoReqState.SUCCESS) {
 | 
			
		||||
        return billing_info_success_message;
 | 
			
		||||
    getAlertMessage() {
 | 
			
		||||
      if (this.reqState === BillingInfoReqState.SUCCESS) {
 | 
			
		||||
        // biome-ignore lint/correctness/noUndeclaredVariables: defined in eboutic_makecommand.jinja
 | 
			
		||||
        return billingInfoSuccessMessage;
 | 
			
		||||
      }
 | 
			
		||||
      if (this.req_state === BillingInfoReqState.FAILURE) {
 | 
			
		||||
        return billing_info_failure_message;
 | 
			
		||||
      if (this.reqState === BillingInfoReqState.FAILURE) {
 | 
			
		||||
        // biome-ignore lint/correctness/noUndeclaredVariables: defined in eboutic_makecommand.jinja
 | 
			
		||||
        return billingInfoFailureMessage;
 | 
			
		||||
      }
 | 
			
		||||
      return "";
 | 
			
		||||
    },
 | 
			
		||||
 
 | 
			
		||||
@@ -56,11 +56,11 @@
 | 
			
		||||
                {# Total price #}
 | 
			
		||||
        <li style="margin-top: 20px">
 | 
			
		||||
          <span class="item-name"><strong>{% trans %}Basket amount: {% endtrans %}</strong></span>
 | 
			
		||||
          <span x-text="get_total().toFixed(2) + ' €'" class="item-price"></span>
 | 
			
		||||
          <span x-text="getTotal().toFixed(2) + ' €'" class="item-price"></span>
 | 
			
		||||
        </li>
 | 
			
		||||
      </ul>
 | 
			
		||||
      <div class="catalog-buttons">
 | 
			
		||||
        <button @click="clear_basket()" class="btn btn-grey">
 | 
			
		||||
        <button @click="clearBasket()" class="btn btn-grey">
 | 
			
		||||
          <i class="fa fa-trash"></i>
 | 
			
		||||
          {% trans %}Clear{% endtrans %}
 | 
			
		||||
        </button>
 | 
			
		||||
@@ -106,7 +106,7 @@
 | 
			
		||||
                    id="{{ p.id }}"
 | 
			
		||||
                    class="product-button"
 | 
			
		||||
                    :class="{selected: items.some((i) => i.id === {{ p.id }})}"
 | 
			
		||||
                    @click='add_from_catalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
 | 
			
		||||
                    @click='addFromCatalog({{ p.id }}, {{ p.name|tojson }}, {{ p.selling_price }})'
 | 
			
		||||
                  >
 | 
			
		||||
                    {% if p.icon %}
 | 
			
		||||
                      <img class="product-image" src="{{ p.icon.url }}"
 | 
			
		||||
 
 | 
			
		||||
@@ -56,7 +56,7 @@
 | 
			
		||||
          <div
 | 
			
		||||
            class="collapse"
 | 
			
		||||
            :class="{'shadow': collapsed}"
 | 
			
		||||
            x-data="{collapsed: !billing_info_exist}"
 | 
			
		||||
            x-data="{collapsed: !billingInfoExist}"
 | 
			
		||||
            x-cloak
 | 
			
		||||
          >
 | 
			
		||||
            <div class="collapse-header clickable" @click="collapsed = !collapsed">
 | 
			
		||||
@@ -73,27 +73,27 @@
 | 
			
		||||
              x-data="billing_infos"
 | 
			
		||||
              x-show="collapsed"
 | 
			
		||||
              x-transition.scale.origin.top
 | 
			
		||||
              @submit.prevent="await send_form()"
 | 
			
		||||
              @submit.prevent="await sendForm()"
 | 
			
		||||
            >
 | 
			
		||||
              {% csrf_token %}
 | 
			
		||||
              {{ billing_form }}
 | 
			
		||||
              <br>
 | 
			
		||||
              <br>
 | 
			
		||||
              <div
 | 
			
		||||
                x-show="[BillingInfoReqState.SUCCESS, BillingInfoReqState.FAILURE].includes(req_state)"
 | 
			
		||||
                x-show="[BillingInfoReqState.SUCCESS, BillingInfoReqState.FAILURE].includes(reqState)"
 | 
			
		||||
                class="alert"
 | 
			
		||||
                :class="'alert-' + get_alert_color()"
 | 
			
		||||
                :class="'alert-' + getAlertColor()"
 | 
			
		||||
                x-transition
 | 
			
		||||
              >
 | 
			
		||||
                <div class="alert-main" x-text="get_alert_message()"></div>
 | 
			
		||||
                <div class="clickable" @click="req_state = null">
 | 
			
		||||
                <div class="alert-main" x-text="getAlertMessage()"></div>
 | 
			
		||||
                <div class="clickable" @click="reqState = null">
 | 
			
		||||
                  <i class="fa fa-close"></i>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
              <input
 | 
			
		||||
                type="submit" class="btn btn-blue clickable"
 | 
			
		||||
                value="{% trans %}Validate{% endtrans %}"
 | 
			
		||||
                :disabled="req_state === BillingInfoReqState.SENDING"
 | 
			
		||||
                :disabled="reqState === BillingInfoReqState.SENDING"
 | 
			
		||||
              >
 | 
			
		||||
            </form>
 | 
			
		||||
          </div>
 | 
			
		||||
@@ -141,16 +141,16 @@
 | 
			
		||||
 | 
			
		||||
{% block script %}
 | 
			
		||||
  <script>
 | 
			
		||||
    const billing_info_url = '{{ url("api:put_billing_info", user_id=request.user.id) }}';
 | 
			
		||||
    const et_data_url = '{{ url("api:etransaction_data") }}';
 | 
			
		||||
    const billing_info_exist = {{ "true" if billing_infos else "false" }};
 | 
			
		||||
    const billing_info_success_message = "{% trans %}Billing info registration success{% endtrans %}";
 | 
			
		||||
    const billing_info_failure_message = "{% trans %}Billing info registration failure{% endtrans %}";
 | 
			
		||||
    const billingInfoUrl = '{{ url("api:put_billing_info", user_id=request.user.id) }}';
 | 
			
		||||
    const etDataUrl = '{{ url("api:etransaction_data") }}';
 | 
			
		||||
    const billingInfoExist = {{ "true" if billing_infos else "false" }};
 | 
			
		||||
    const billingInfoSuccessMessage = "{% trans %}Billing info registration success{% endtrans %}";
 | 
			
		||||
    const billingInfoFailureMessage = "{% trans %}Billing info registration failure{% endtrans %}";
 | 
			
		||||
 | 
			
		||||
    {% if billing_infos %}
 | 
			
		||||
      const et_data = {{ billing_infos|safe }}
 | 
			
		||||
      const etData = {{ billing_infos|safe }}
 | 
			
		||||
    {% else %}
 | 
			
		||||
      const et_data = {}
 | 
			
		||||
      const etData = {}
 | 
			
		||||
    {% endif %}
 | 
			
		||||
  </script>
 | 
			
		||||
  {{ super() }}
 | 
			
		||||
 
 | 
			
		||||
@@ -125,14 +125,14 @@
 | 
			
		||||
    then fetch the corresponding data from the API.
 | 
			
		||||
    This data will then be displayed on the result part of the page.
 | 
			
		||||
    #}
 | 
			
		||||
    const page_default = 1;
 | 
			
		||||
    const page_size_default = 100;
 | 
			
		||||
    const pageDefault = 1;
 | 
			
		||||
    const pageSizeDefault = 100;
 | 
			
		||||
    document.addEventListener("alpine:init", () => {
 | 
			
		||||
      Alpine.data("uv_search", () => ({
 | 
			
		||||
        uvs: [],
 | 
			
		||||
        loading: false,
 | 
			
		||||
        page: page_default,
 | 
			
		||||
        page_size: page_size_default,
 | 
			
		||||
        page: pageDefault,
 | 
			
		||||
        pageSize: pageSizeDefault,
 | 
			
		||||
        search: "",
 | 
			
		||||
        department: [],
 | 
			
		||||
        credit_type: [],
 | 
			
		||||
@@ -142,12 +142,12 @@
 | 
			
		||||
 | 
			
		||||
        update: undefined,
 | 
			
		||||
 | 
			
		||||
        async initialize_args() {
 | 
			
		||||
        async initializeArgs() {
 | 
			
		||||
          let url = new URLSearchParams(window.location.search);
 | 
			
		||||
          this.pushstate = History.REPLACE;
 | 
			
		||||
 | 
			
		||||
          this.page = parseInt(url.get("page")) || page_default;;
 | 
			
		||||
          this.page_size = parseInt(url.get("page_size")) || page_size_default;
 | 
			
		||||
          this.page = parseInt(url.get("page")) || pageDefault;;
 | 
			
		||||
          this.pageSize = parseInt(url.get("pageSize")) || pageSizeDefault;
 | 
			
		||||
          this.search = url.get("search") || "";
 | 
			
		||||
          this.department = url.getAll("department");
 | 
			
		||||
          this.credit_type = url.getAll("credit_type");
 | 
			
		||||
@@ -164,18 +164,18 @@
 | 
			
		||||
          this.update = Alpine.debounce(async () => {
 | 
			
		||||
            {# Create the whole url before changing everything all at once #}
 | 
			
		||||
            let first = this.to_change.shift();
 | 
			
		||||
            let url = update_query_string(first.param, first.value, History.NONE);
 | 
			
		||||
            let url = updateQueryString(first.param, first.value, History.NONE);
 | 
			
		||||
            this.to_change.forEach((value) => {
 | 
			
		||||
              url = update_query_string(value.param, value.value, History.NONE, url);
 | 
			
		||||
              url = updateQueryString(value.param, value.value, History.NONE, url);
 | 
			
		||||
            })
 | 
			
		||||
            update_query_string(first.param, first.value, this.pushstate, url);
 | 
			
		||||
            await this.fetch_data();  {# reload data on form change #}
 | 
			
		||||
            updateQueryString(first.param, first.value, this.pushstate, url);
 | 
			
		||||
            await this.fetchData();  {# reload data on form change #}
 | 
			
		||||
            this.to_change = [];
 | 
			
		||||
            this.pushstate = History.PUSH;
 | 
			
		||||
          }, 50);
 | 
			
		||||
 | 
			
		||||
          let search_params = ["search", "department", "credit_type", "semester"];
 | 
			
		||||
          let pagination_params = ["page", "page_size"];
 | 
			
		||||
          let pagination_params = ["page", "pageSize"];
 | 
			
		||||
 | 
			
		||||
          search_params.forEach((param) => {
 | 
			
		||||
            this.$watch(param, async (value) => {
 | 
			
		||||
@@ -184,8 +184,8 @@
 | 
			
		||||
                return;
 | 
			
		||||
              }
 | 
			
		||||
              {# Reset pagination on search #}
 | 
			
		||||
              this.page = page_default;
 | 
			
		||||
              this.page_size = page_size_default;
 | 
			
		||||
              this.page = pageDefault;
 | 
			
		||||
              this.pageSize = pageSizeDefault;
 | 
			
		||||
            });
 | 
			
		||||
          });
 | 
			
		||||
          search_params.concat(pagination_params).forEach((param) => {
 | 
			
		||||
@@ -195,13 +195,13 @@
 | 
			
		||||
            });
 | 
			
		||||
          });
 | 
			
		||||
          window.addEventListener("popstate", async (event) => {
 | 
			
		||||
            await this.initialize_args();
 | 
			
		||||
            await this.initializeArgs();
 | 
			
		||||
          });
 | 
			
		||||
          await this.initialize_args();
 | 
			
		||||
          await this.initializeArgs();
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        async fetch_data() {
 | 
			
		||||
        async fetchData() {
 | 
			
		||||
          this.loading = true;
 | 
			
		||||
          const url = "{{ url("api:fetch_uvs") }}" + window.location.search;
 | 
			
		||||
          this.uvs = await (await fetch(url)).json();
 | 
			
		||||
@@ -209,7 +209,7 @@
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        max_page() {
 | 
			
		||||
          return Math.ceil(this.uvs.count / this.page_size);
 | 
			
		||||
          return Math.ceil(this.uvs.count / this.pageSize);
 | 
			
		||||
        }
 | 
			
		||||
      }))
 | 
			
		||||
    })
 | 
			
		||||
 
 | 
			
		||||
@@ -10,8 +10,8 @@
 | 
			
		||||
 */
 | 
			
		||||
class PictureWithIdentifications {
 | 
			
		||||
  identifications = null;
 | 
			
		||||
  image_loading = false;
 | 
			
		||||
  identifications_loading = false;
 | 
			
		||||
  imageLoading = false;
 | 
			
		||||
  identificationsLoading = false;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * @param {Picture} picture
 | 
			
		||||
@@ -23,7 +23,7 @@ class PictureWithIdentifications {
 | 
			
		||||
  /**
 | 
			
		||||
   * @param {Picture} picture
 | 
			
		||||
   */
 | 
			
		||||
  static from_picture(picture) {
 | 
			
		||||
  static fromPicture(picture) {
 | 
			
		||||
    return new PictureWithIdentifications(picture);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -33,19 +33,19 @@ class PictureWithIdentifications {
 | 
			
		||||
   * @param {?Object=} options
 | 
			
		||||
   * @return {Promise<void>}
 | 
			
		||||
   */
 | 
			
		||||
  async load_identifications(options) {
 | 
			
		||||
    if (this.identifications_loading) {
 | 
			
		||||
  async loadIdentifications(options) {
 | 
			
		||||
    if (this.identificationsLoading) {
 | 
			
		||||
      return; // The users are already being fetched.
 | 
			
		||||
    }
 | 
			
		||||
    if (!!this.identifications && !options?.force_reload) {
 | 
			
		||||
    if (!!this.identifications && !options?.forceReload) {
 | 
			
		||||
      // The users are already fetched
 | 
			
		||||
      // and the user does not want to force the reload
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    this.identifications_loading = true;
 | 
			
		||||
    this.identificationsLoading = true;
 | 
			
		||||
    const url = `/api/sas/picture/${this.id}/identified`;
 | 
			
		||||
    this.identifications = await (await fetch(url)).json();
 | 
			
		||||
    this.identifications_loading = false;
 | 
			
		||||
    this.identificationsLoading = false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
@@ -56,12 +56,12 @@ class PictureWithIdentifications {
 | 
			
		||||
    const img = new Image();
 | 
			
		||||
    img.src = this.compressed_url;
 | 
			
		||||
    if (!img.complete) {
 | 
			
		||||
      this.image_loading = true;
 | 
			
		||||
      this.imageLoading = true;
 | 
			
		||||
      img.addEventListener("load", () => {
 | 
			
		||||
        this.image_loading = false;
 | 
			
		||||
        this.imageLoading = false;
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    await this.load_identifications();
 | 
			
		||||
    await this.loadIdentifications();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -78,13 +78,18 @@ document.addEventListener("alpine:init", () => {
 | 
			
		||||
     * when loading the page at the beginning
 | 
			
		||||
     * @type PictureWithIdentifications
 | 
			
		||||
     **/
 | 
			
		||||
    current_picture: {
 | 
			
		||||
    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(),
 | 
			
		||||
@@ -94,12 +99,12 @@ document.addEventListener("alpine:init", () => {
 | 
			
		||||
     * The picture which will be displayed next if the user press the "next" button
 | 
			
		||||
     * @type ?PictureWithIdentifications
 | 
			
		||||
     **/
 | 
			
		||||
    next_picture: null,
 | 
			
		||||
    nextPicture: null,
 | 
			
		||||
    /**
 | 
			
		||||
     * The picture which will be displayed next if the user press the "previous" button
 | 
			
		||||
     * @type ?PictureWithIdentifications
 | 
			
		||||
     **/
 | 
			
		||||
    previous_picture: null,
 | 
			
		||||
    previousPicture: null,
 | 
			
		||||
    /**
 | 
			
		||||
     * The select2 component used to identify users
 | 
			
		||||
     **/
 | 
			
		||||
@@ -111,7 +116,7 @@ document.addEventListener("alpine:init", () => {
 | 
			
		||||
     * Error message when a moderation operation fails
 | 
			
		||||
     * @type string
 | 
			
		||||
     **/
 | 
			
		||||
    moderation_error: "",
 | 
			
		||||
    moderationError: "",
 | 
			
		||||
    /**
 | 
			
		||||
     * Method of pushing new url to the browser history
 | 
			
		||||
     * Used by popstate event and always reset to it's default value when used
 | 
			
		||||
@@ -120,114 +125,119 @@ document.addEventListener("alpine:init", () => {
 | 
			
		||||
    pushstate: History.PUSH,
 | 
			
		||||
 | 
			
		||||
    async init() {
 | 
			
		||||
      this.pictures = (await fetch_paginated(picture_endpoint)).map(
 | 
			
		||||
        PictureWithIdentifications.from_picture,
 | 
			
		||||
      // 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),
 | 
			
		||||
        data_source: remote_data_source("/api/user/search", {
 | 
			
		||||
        // biome-ignore lint/correctness/noUndeclaredVariables: Imported from script.js
 | 
			
		||||
        dataSource: remoteDataSource("/api/user/search", {
 | 
			
		||||
          excluded: () => [
 | 
			
		||||
            ...(this.current_picture.identifications || []).map((i) => i.user.id),
 | 
			
		||||
            ...(this.currentPicture.identifications || []).map((i) => i.user.id),
 | 
			
		||||
          ],
 | 
			
		||||
          result_converter: (obj) => Object({ ...obj, text: obj.display_name }),
 | 
			
		||||
          resultConverter: (obj) => new Object({ ...obj, text: obj.display_name }),
 | 
			
		||||
        }),
 | 
			
		||||
        picture_getter: (user) => user.profile_pict,
 | 
			
		||||
        pictureGetter: (user) => user.profile_pict,
 | 
			
		||||
      });
 | 
			
		||||
      this.current_picture = this.pictures.find((i) => i.id === first_picture_id);
 | 
			
		||||
      this.$watch("current_picture", (current, previous) => {
 | 
			
		||||
      // 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.update_picture();
 | 
			
		||||
        this.updatePicture();
 | 
			
		||||
      });
 | 
			
		||||
      window.addEventListener("popstate", async (event) => {
 | 
			
		||||
        if (!event.state || event.state.sas_picture_id === undefined) {
 | 
			
		||||
        if (!event.state || event.state.sasPictureId === undefined) {
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        this.pushstate = History.REPLACE;
 | 
			
		||||
        this.current_picture = this.pictures.find(
 | 
			
		||||
          (i) => i.id === Number.parseInt(event.state.sas_picture_id),
 | 
			
		||||
        this.currentPicture = this.pictures.find(
 | 
			
		||||
          (i) => i.id === Number.parseInt(event.state.sasPictureId),
 | 
			
		||||
        );
 | 
			
		||||
      });
 | 
			
		||||
      this.pushstate = History.REPLACE; /* Avoid first url push */
 | 
			
		||||
      await this.update_picture();
 | 
			
		||||
      await this.updatePicture();
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the page.
 | 
			
		||||
     * Called when the `current_picture` property changes.
 | 
			
		||||
     * 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 update_picture() {
 | 
			
		||||
      const update_args = [
 | 
			
		||||
        { sas_picture_id: this.current_picture.id },
 | 
			
		||||
    async updatePicture() {
 | 
			
		||||
      const updateArgs = [
 | 
			
		||||
        { sasPictureId: this.currentPicture.id },
 | 
			
		||||
        "",
 | 
			
		||||
        `/sas/picture/${this.current_picture.id}/`,
 | 
			
		||||
        `/sas/picture/${this.currentPicture.id}/`,
 | 
			
		||||
      ];
 | 
			
		||||
      if (this.pushstate === History.REPLACE) {
 | 
			
		||||
        window.history.replaceState(...update_args);
 | 
			
		||||
        window.history.replaceState(...updateArgs);
 | 
			
		||||
        this.pushstate = History.PUSH;
 | 
			
		||||
      } else {
 | 
			
		||||
        window.history.pushState(...update_args);
 | 
			
		||||
        window.history.pushState(...updateArgs);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      this.moderation_error = "";
 | 
			
		||||
      const index = this.pictures.indexOf(this.current_picture);
 | 
			
		||||
      this.previous_picture = this.pictures[index - 1] || null;
 | 
			
		||||
      this.next_picture = this.pictures[index + 1] || null;
 | 
			
		||||
      await this.current_picture.load_identifications();
 | 
			
		||||
      this.$refs.main_picture?.addEventListener("load", () => {
 | 
			
		||||
      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.next_picture?.preload();
 | 
			
		||||
        this.previous_picture?.preload();
 | 
			
		||||
        this.nextPicture?.preload();
 | 
			
		||||
        this.previousPicture?.preload();
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async moderate_picture() {
 | 
			
		||||
      const res = await fetch(`/api/sas/picture/${this.current_picture.id}/moderate`, {
 | 
			
		||||
    async moderatePicture() {
 | 
			
		||||
      const res = await fetch(`/api/sas/picture/${this.currentPicture.id}/moderate`, {
 | 
			
		||||
        method: "PATCH",
 | 
			
		||||
      });
 | 
			
		||||
      if (!res.ok) {
 | 
			
		||||
        this.moderation_error = `${gettext("Couldn't moderate picture")} : ${res.statusText}`;
 | 
			
		||||
        this.moderationError = `${gettext("Couldn't moderate picture")} : ${res.statusText}`;
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      this.current_picture.is_moderated = true;
 | 
			
		||||
      this.current_picture.asked_for_removal = false;
 | 
			
		||||
      this.currentPicture.is_moderated = true;
 | 
			
		||||
      this.currentPicture.askedForRemoval = false;
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async delete_picture() {
 | 
			
		||||
      const res = await fetch(`/api/sas/picture/${this.current_picture.id}`, {
 | 
			
		||||
    async deletePicture() {
 | 
			
		||||
      const res = await fetch(`/api/sas/picture/${this.currentPicture.id}`, {
 | 
			
		||||
        method: "DELETE",
 | 
			
		||||
      });
 | 
			
		||||
      if (!res.ok) {
 | 
			
		||||
        this.moderation_error = `${gettext("Couldn't delete picture")} : ${res.statusText}`;
 | 
			
		||||
        this.moderationError = `${gettext("Couldn't delete picture")} : ${res.statusText}`;
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      this.pictures.splice(this.pictures.indexOf(this.current_picture), 1);
 | 
			
		||||
      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 = album_url;
 | 
			
		||||
        // biome-ignore lint/correctness/noUndeclaredVariables: imported from picture.jinja
 | 
			
		||||
        document.location.href = albumUrl;
 | 
			
		||||
      }
 | 
			
		||||
      this.current_picture = this.next_picture || this.previous_picture;
 | 
			
		||||
      this.currentPicture = this.nextPicture || this.previousPicture;
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Send the identification request and update the list of identified users.
 | 
			
		||||
     */
 | 
			
		||||
    async submit_identification() {
 | 
			
		||||
      const url = `/api/sas/picture/${this.current_picture.id}/identified`;
 | 
			
		||||
    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.current_picture.load_identifications({ force_reload: true });
 | 
			
		||||
      await this.currentPicture.loadIdentifications({ forceReload: true });
 | 
			
		||||
      this.selector.empty().trigger("change");
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
@@ -236,23 +246,22 @@ document.addEventListener("alpine:init", () => {
 | 
			
		||||
     * @param {PictureIdentification} identification
 | 
			
		||||
     * @return {boolean}
 | 
			
		||||
     */
 | 
			
		||||
    can_be_removed(identification) {
 | 
			
		||||
      return user_is_sas_admin || identification.user.id === user_id;
 | 
			
		||||
    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 remove_identification(identification) {
 | 
			
		||||
    async removeIdentification(identification) {
 | 
			
		||||
      const res = await fetch(`/api/sas/relation/${identification.id}`, {
 | 
			
		||||
        method: "DELETE",
 | 
			
		||||
      });
 | 
			
		||||
      if (res.ok && Array.isArray(this.current_picture.identifications)) {
 | 
			
		||||
        this.current_picture.identifications =
 | 
			
		||||
          this.current_picture.identifications.filter(
 | 
			
		||||
            (i) => i.id !== identification.id,
 | 
			
		||||
          );
 | 
			
		||||
      if (res.ok && Array.isArray(this.currentPicture.identifications)) {
 | 
			
		||||
        this.currentPicture.identifications =
 | 
			
		||||
          this.currentPicture.identifications.filter((i) => i.id !== identification.id);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
  }));
 | 
			
		||||
 
 | 
			
		||||
@@ -83,7 +83,7 @@
 | 
			
		||||
        </a>
 | 
			
		||||
      </template>
 | 
			
		||||
    </div>
 | 
			
		||||
    {{  paginate_alpine("page", "nb_pages()") }}
 | 
			
		||||
    {{  paginate_alpine("page", "nbPages()") }}
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  {% if is_sas_admin %}
 | 
			
		||||
@@ -116,14 +116,14 @@
 | 
			
		||||
        loading: false,
 | 
			
		||||
 | 
			
		||||
        async init() {
 | 
			
		||||
          await this.fetch_pictures();
 | 
			
		||||
          await this.fetchPictures();
 | 
			
		||||
          this.$watch("page", () => {
 | 
			
		||||
            update_query_string("page",
 | 
			
		||||
            updateQueryString("page",
 | 
			
		||||
              this.page === 1 ? null : this.page,
 | 
			
		||||
              this.pushstate
 | 
			
		||||
            );
 | 
			
		||||
            this.pushstate = History.PUSH;
 | 
			
		||||
            this.fetch_pictures();
 | 
			
		||||
            this.fetchPictures();
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          window.addEventListener("popstate", () => {
 | 
			
		||||
@@ -134,7 +134,7 @@
 | 
			
		||||
          });
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        async fetch_pictures() {
 | 
			
		||||
        async fetchPictures() {
 | 
			
		||||
          this.loading=true;
 | 
			
		||||
          const url = "{{ url("api:pictures") }}"
 | 
			
		||||
          +"?album_id={{ album.id }}"
 | 
			
		||||
@@ -144,7 +144,7 @@
 | 
			
		||||
          this.loading = false;
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        nb_pages() {
 | 
			
		||||
        nbPages() {
 | 
			
		||||
          return Math.ceil(this.pictures.count / {{ settings.SITH_SAS_IMAGES_PER_PAGE }});
 | 
			
		||||
        }
 | 
			
		||||
      }))
 | 
			
		||||
 
 | 
			
		||||
@@ -17,21 +17,21 @@
 | 
			
		||||
{% block content %}
 | 
			
		||||
  <main x-data="picture_viewer">
 | 
			
		||||
    <code>
 | 
			
		||||
      <a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album) }} <span x-text="current_picture.name"></span>
 | 
			
		||||
      <a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album) }} <span x-text="currentPicture.name"></span>
 | 
			
		||||
    </code>
 | 
			
		||||
 | 
			
		||||
    <br>
 | 
			
		||||
 | 
			
		||||
    <div class="title">
 | 
			
		||||
      <h3 x-text="current_picture.name"></h3>
 | 
			
		||||
      <h4 x-text="`${pictures.indexOf(current_picture) + 1 } / ${pictures.length}`"></h4>
 | 
			
		||||
      <h3 x-text="currentPicture.name"></h3>
 | 
			
		||||
      <h4 x-text="`${pictures.indexOf(currentPicture) + 1 } / ${pictures.length}`"></h4>
 | 
			
		||||
    </div>
 | 
			
		||||
    <br>
 | 
			
		||||
 | 
			
		||||
    <template x-if="!current_picture.is_moderated">
 | 
			
		||||
    <template x-if="!currentPicture.is_moderated">
 | 
			
		||||
      <div class="alert alert-red">
 | 
			
		||||
        <div class="alert-main">
 | 
			
		||||
          <template x-if="current_picture.asked_for_removal">
 | 
			
		||||
          <template x-if="currentPicture.askedForRemoval">
 | 
			
		||||
            <span class="important">{% trans %}Asked for removal{% endtrans %}</span>
 | 
			
		||||
          </template>
 | 
			
		||||
          <p>
 | 
			
		||||
@@ -43,14 +43,14 @@
 | 
			
		||||
        </div>
 | 
			
		||||
        <div>
 | 
			
		||||
          <div>
 | 
			
		||||
            <button class="btn btn-blue" @click="moderate_picture()">
 | 
			
		||||
            <button class="btn btn-blue" @click="moderatePicture()">
 | 
			
		||||
              {% trans %}Moderate{% endtrans %}
 | 
			
		||||
            </button>
 | 
			
		||||
            <button class="btn btn-red" @click.prevent="delete_picture()">
 | 
			
		||||
            <button class="btn btn-red" @click.prevent="deletePicture()">
 | 
			
		||||
              {% trans %}Delete{% endtrans %}
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
          <p x-show="!!moderation_error" x-text="moderation_error"></p>
 | 
			
		||||
          <p x-show="!!moderationError" x-text="moderationError"></p>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </template>
 | 
			
		||||
@@ -58,12 +58,12 @@
 | 
			
		||||
    <div class="container" id="pict">
 | 
			
		||||
      <div class="main">
 | 
			
		||||
 | 
			
		||||
        <div class="photo" :aria-busy="current_picture.image_loading">
 | 
			
		||||
        <div class="photo" :aria-busy="currentPicture.imageLoading">
 | 
			
		||||
          <img
 | 
			
		||||
            :src="current_picture.compressed_url"
 | 
			
		||||
            :alt="current_picture.name"
 | 
			
		||||
            :src="currentPicture.compressed_url"
 | 
			
		||||
            :alt="currentPicture.name"
 | 
			
		||||
            id="main-picture"
 | 
			
		||||
            x-ref="main_picture"
 | 
			
		||||
            x-ref="mainPicture"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
@@ -76,13 +76,13 @@
 | 
			
		||||
                <span
 | 
			
		||||
                  x-text="Intl.DateTimeFormat(
 | 
			
		||||
                          '{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
 | 
			
		||||
                          ).format(new Date(current_picture.date))"
 | 
			
		||||
                          ).format(new Date(currentPicture.date))"
 | 
			
		||||
                >
 | 
			
		||||
                </span>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div>
 | 
			
		||||
                <span>{% trans %}Owner: {% endtrans %}</span>
 | 
			
		||||
                <a :href="current_picture.owner.profile_url" x-text="current_picture.owner.display_name"></a>
 | 
			
		||||
                <a :href="currentPicture.owner.profile_url" x-text="currentPicture.owner.display_name"></a>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
@@ -91,14 +91,14 @@
 | 
			
		||||
            <h5>{% trans %}Tools{% endtrans %}</h5>
 | 
			
		||||
            <div>
 | 
			
		||||
              <div>
 | 
			
		||||
                <a class="text" :href="current_picture.full_size_url">
 | 
			
		||||
                <a class="text" :href="currentPicture.full_size_url">
 | 
			
		||||
                  {% trans %}HD version{% endtrans %}
 | 
			
		||||
                </a>
 | 
			
		||||
                <br>
 | 
			
		||||
                <a class="text danger" href="?ask_removal">{% trans %}Ask for removal{% endtrans %}</a>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div class="buttons">
 | 
			
		||||
                <a class="button" :href="`/sas/picture/${current_picture.id}/edit/`"><i class="fa-regular fa-pen-to-square edit-action"></i></a>
 | 
			
		||||
                <a class="button" :href="`/sas/picture/${currentPicture.id}/edit/`"><i class="fa-regular fa-pen-to-square edit-action"></i></a>
 | 
			
		||||
                <a class="button" href="?rotate_left"><i class="fa-solid fa-rotate-left"></i></a>
 | 
			
		||||
                <a class="button" href="?rotate_right"><i class="fa-solid fa-rotate-right"></i></a>
 | 
			
		||||
              </div>
 | 
			
		||||
@@ -110,23 +110,23 @@
 | 
			
		||||
      <div class="subsection">
 | 
			
		||||
        <div class="navigation">
 | 
			
		||||
          <div id="prev" class="clickable">
 | 
			
		||||
            <template x-if="previous_picture">
 | 
			
		||||
            <template x-if="previousPicture">
 | 
			
		||||
              <div
 | 
			
		||||
                @keyup.left.window="current_picture = previous_picture"
 | 
			
		||||
                @click="current_picture = previous_picture"
 | 
			
		||||
                @keyup.left.window="currentPicture = previousPicture"
 | 
			
		||||
                @click="currentPicture = previousPicture"
 | 
			
		||||
              >
 | 
			
		||||
                <img :src="previous_picture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
 | 
			
		||||
                <img :src="previousPicture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
 | 
			
		||||
                <div class="overlay">←</div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </template>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div id="next" class="clickable">
 | 
			
		||||
            <template x-if="next_picture">
 | 
			
		||||
            <template x-if="nextPicture">
 | 
			
		||||
              <div
 | 
			
		||||
                @keyup.right.window="current_picture = next_picture"
 | 
			
		||||
                @click="current_picture = next_picture"
 | 
			
		||||
                @keyup.right.window="currentPicture = nextPicture"
 | 
			
		||||
                @click="currentPicture = nextPicture"
 | 
			
		||||
              >
 | 
			
		||||
                <img :src="next_picture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
 | 
			
		||||
                <img :src="nextPicture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
 | 
			
		||||
                <div class="overlay">→</div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </template>
 | 
			
		||||
@@ -136,14 +136,14 @@
 | 
			
		||||
        <div class="tags">
 | 
			
		||||
          <h5>{% trans %}People{% endtrans %}</h5>
 | 
			
		||||
          {% if user.was_subscribed %}
 | 
			
		||||
            <form @submit.prevent="submit_identification" x-show="!!selector">
 | 
			
		||||
            <form @submit.prevent="submitIdentification" x-show="!!selector">
 | 
			
		||||
              <select x-ref="search" multiple="multiple"></select>
 | 
			
		||||
              <input type="submit" value="{% trans %}Go{% endtrans %}"/>
 | 
			
		||||
            </form>
 | 
			
		||||
          {% endif %}
 | 
			
		||||
          <ul>
 | 
			
		||||
            <template
 | 
			
		||||
              x-for="identification in (current_picture.identifications || [])"
 | 
			
		||||
              x-for="identification in (currentPicture.identifications || [])"
 | 
			
		||||
              :key="identification.id"
 | 
			
		||||
            >
 | 
			
		||||
              <li>
 | 
			
		||||
@@ -151,12 +151,12 @@
 | 
			
		||||
                  <img class="profile-pic" :src="identification.user.profile_pict" alt="image de profil"/>
 | 
			
		||||
                  <span x-text="identification.user.display_name"></span>
 | 
			
		||||
                </a>
 | 
			
		||||
                <template x-if="can_be_removed(identification)">
 | 
			
		||||
                  <a class="delete clickable" @click="remove_identification(identification)"><i class="fa fa-times fa-xl delete-action"></i></a>
 | 
			
		||||
                <template x-if="canBeRemoved(identification)">
 | 
			
		||||
                  <a class="delete clickable" @click="removeIdentification(identification)"><i class="fa fa-times fa-xl delete-action"></i></a>
 | 
			
		||||
                </template>
 | 
			
		||||
              </li>
 | 
			
		||||
            </template>
 | 
			
		||||
            <template x-if="current_picture.identifications_loading">
 | 
			
		||||
            <template x-if="currentPicture.identificationsLoading">
 | 
			
		||||
                {# shadow element that exists only to put the loading wheel below
 | 
			
		||||
                the list of identified people #}
 | 
			
		||||
              <li class="loader" aria-busy="true"></li>
 | 
			
		||||
@@ -171,10 +171,10 @@
 | 
			
		||||
{% block script %}
 | 
			
		||||
  {{ super() }}
 | 
			
		||||
  <script>
 | 
			
		||||
    const picture_endpoint = "{{ url("api:pictures") + "?album_id=" + album.id|string }}";
 | 
			
		||||
    const album_url = "{{ album.get_absolute_url() }}";
 | 
			
		||||
    const first_picture_id = {{ picture.id }};  {# id of the first picture to show after page load #}
 | 
			
		||||
    const user_id = {{ user.id }};
 | 
			
		||||
    const user_is_sas_admin = {{ (user.is_root or user.is_in_group(pk = settings.SITH_GROUP_SAS_ADMIN_ID))|tojson }}
 | 
			
		||||
    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 }}
 | 
			
		||||
  </script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
const glob = require("glob");
 | 
			
		||||
// biome-ignore lint/correctness/noNodejsModules: this is backend side
 | 
			
		||||
const path = require("node:path");
 | 
			
		||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
 | 
			
		||||
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
 | 
			
		||||
@@ -26,6 +27,7 @@ module.exports = {
 | 
			
		||||
        terserOptions: {
 | 
			
		||||
          mangle: true,
 | 
			
		||||
          compress: {
 | 
			
		||||
            // biome-ignore lint/style/useNamingConvention: this is how the underlying library wants it
 | 
			
		||||
            drop_console: true,
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user