Merge pull request #858 from ae-utbm/jsstandard

Add biome to format js files
This commit is contained in:
thomas girod 2024-10-08 23:45:20 +02:00 committed by GitHub
commit b969513d94
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 10820 additions and 6681 deletions

View File

@ -8,6 +8,11 @@ repos:
args: ["--fix", "--silent"] args: ["--fix", "--silent"]
# Run the formatter. # Run the formatter.
- id: ruff-format - id: ruff-format
- repo: https://github.com/biomejs/pre-commit
rev: "v0.1.0" # Use the sha / tag you want to point at
hooks:
- id: biome-check
additional_dependencies: ["@biomejs/biome@1.9.3"]
- repo: https://github.com/rtts/djhtml - repo: https://github.com/rtts/djhtml
rev: 3.0.6 rev: 3.0.6
hooks: hooks:

View File

@ -5,6 +5,7 @@
[![CI status](https://github.com/ae-utbm/sith/actions/workflows/ci.yml/badge.svg)](#) [![CI status](https://github.com/ae-utbm/sith/actions/workflows/ci.yml/badge.svg)](#)
[![Docs status](https://github.com/ae-utbm/sith/actions/workflows/deploy_docs.yml/badge.svg)](https://ae-utbm.github.io/sith) [![Docs status](https://github.com/ae-utbm/sith/actions/workflows/deploy_docs.yml/badge.svg)](https://ae-utbm.github.io/sith)
[![Built with Material for MkDocs](https://img.shields.io/badge/Material_for_MkDocs-526CFE?style=default&logo=MaterialForMkDocs&logoColor=white)](https://squidfunk.github.io/mkdocs-material/) [![Built with Material for MkDocs](https://img.shields.io/badge/Material_for_MkDocs-526CFE?style=default&logo=MaterialForMkDocs&logoColor=white)](https://squidfunk.github.io/mkdocs-material/)
[![Checked with Biome](https://img.shields.io/badge/Checked_with-Biome-60a5fa?style=flat&logo=biome)](https://biomejs.dev)
[![discord](https://img.shields.io/discord/971448179075731476?label=discord&logo=discord&style=default)](https://discord.gg/xk9wfpsufm) [![discord](https://img.shields.io/discord/971448179075731476?label=discord&logo=discord&style=default)](https://discord.gg/xk9wfpsufm)
### This is the source code of the UTBM's student association available at [https://ae.utbm.fr/](https://ae.utbm.fr/). ### This is the source code of the UTBM's student association available at [https://ae.utbm.fr/](https://ae.utbm.fr/).

View File

@ -12,4 +12,4 @@
} }
] ]
] ]
} }

29
biome.json Normal file
View File

@ -0,0 +1,29 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"ignore": ["core/static/vendored", "*.min.*", "staticfiles/generated"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"lineWidth": 88
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"all": true
}
},
"javascript": {
"globals": ["Alpine", "$", "jQuery", "gettext", "interpolate"]
}
}

View File

@ -1,24 +1,23 @@
$(document).ready(function(){ $(document).ready(() => {
$("#poster_list #view").click(() => {
$("#view").removeClass("active");
});
$("#poster_list #view").click(function(e){ $("#poster_list .poster .image").click((e) => {
$("#view").removeClass("active"); let el = $(e.target);
}); if (el.hasClass("image")) {
el = el.find("img");
}
$("#poster_list #view #placeholder").html(el.clone());
$("#poster_list .poster .image").click(function(e){ $("#view").addClass("active");
});
el = $(e.target);
if(el.hasClass("image"))
el = el.find("img")
$("#poster_list #view #placeholder").html(el.clone());
$("#view").addClass("active");
});
$(document).keyup(function(e) {
if (e.keyCode == 27) { // escape key maps to keycode `27`
e.preventDefault();
$("#view").removeClass("active");
}
});
$(document).keyup((e) => {
if (e.keyCode === 27) {
// escape key maps to keycode `27`
e.preventDefault();
$("#view").removeClass("active");
}
});
}); });

View File

@ -1,118 +1,98 @@
$(document).ready(function(){ $(document).ready(() => {
const transitionTime = 1000;
transition_time = 1000; let i = 0;
const max = $("#slideshow .slide").length;
i = 0; function enterFullscreen() {
max = $("#slideshow .slide").length; const element = document.getElementById("slideshow");
$(element).addClass("fullscreen");
next_trigger = 0 if (element.requestFullscreen) {
element.requestFullscreen();
function enterFullscreen() { } else if (element.mozRequestFullScreen) {
element = document.getElementById("slideshow"); element.mozRequestFullScreen();
$(element).addClass("fullscreen"); } else if (element.webkitRequestFullscreen) {
if(element.requestFullscreen) { element.webkitRequestFullscreen();
element.requestFullscreen(); } else if (element.msRequestFullscreen) {
} else if(element.mozRequestFullScreen) { element.msRequestFullscreen();
element.mozRequestFullScreen();
} else if(element.webkitRequestFullscreen) {
element.webkitRequestFullscreen();
} else if(element.msRequestFullscreen) {
element.msRequestFullscreen();
}
} }
}
function exitFullscreen() { function exitFullscreen() {
element = document.getElementById("slideshow"); const element = document.getElementById("slideshow");
$(element).removeClass("fullscreen"); $(element).removeClass("fullscreen");
if (document.exitFullscreen) { if (document.exitFullscreen) {
document.exitFullscreen(); document.exitFullscreen();
} else if (document.webkitExitFullscreen) { } else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen(); document.webkitExitFullscreen();
} else if (document.mozCancelFullScreen) { } else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen(); document.mozCancelFullScreen();
} else if (document.msExitFullscreen) { } else if (document.msExitFullscreen) {
document.msExitFullscreen(); document.msExitFullscreen();
}
} }
}
function init_progress_bar() function initProgressBar() {
{ $("#slideshow #progress_bar").css("transition", "none");
$("#slideshow #progress_bar").removeClass("progress");
$("#slideshow #progress_bar").addClass("init");
}
$("#slideshow #progress_bar").css("transition", "none"); function startProgressBar(displayTime) {
$("#slideshow #progress_bar").removeClass("progress"); $("#slideshow #progress_bar").removeClass("init");
$("#slideshow #progress_bar").addClass("init"); $("#slideshow #progress_bar").addClass("progress");
$("#slideshow #progress_bar").css("transition", `width ${displayTime}s linear`);
}
function next() {
initProgressBar();
const slide = $($("#slideshow .slide").get(i % max));
slide.removeClass("center");
slide.addClass("left");
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");
const bullet = $("#slideshow .bullet")[(i + 1) % max];
$(bullet).addClass("active");
i = (i + 1) % max;
setTimeout(() => {
const othersLeft = $("#slideshow .slide.left");
othersLeft.removeClass("left");
othersLeft.addClass("right");
startProgressBar(displayTime);
setTimeout(next, displayTime * 1000);
}, transitionTime);
}
const displayTime = $("#slideshow .center").attr("display_time");
initProgressBar();
setTimeout(() => {
if (max > 1) {
startProgressBar(displayTime);
setTimeout(next, displayTime * 1000);
} }
}, 10);
function start_progress_bar(display_time) $("#slideshow").click(() => {
{ if ($("#slideshow").hasClass("fullscreen")) {
exitFullscreen();
$("#slideshow #progress_bar").removeClass("init"); } else {
$("#slideshow #progress_bar").addClass("progress"); enterFullscreen();
$("#slideshow #progress_bar").css("transition", "width " + display_time + "s linear")
} }
});
function next() $(document).keyup((e) => {
{ if (e.keyCode === 27) {
// escape key maps to keycode `27`
init_progress_bar(); e.preventDefault();
slide = $($("#slideshow .slide").get(i % max)); exitFullscreen();
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;
$("#slideshow .bullet").removeClass("active");
bullet = $("#slideshow .bullet")[(i + 1) % max];
$(bullet).addClass("active");
i = (i + 1) % max;
setTimeout(function(){
others_left = $("#slideshow .slide.left");
others_left.removeClass("left");
others_left.addClass("right");
start_progress_bar(display_time);
next_trigger = setTimeout(next, display_time * 1000);
}, transition_time);
} }
});
display_time = $("#slideshow .center").attr("display_time");
init_progress_bar();
setTimeout(function(){
if(max > 1){
start_progress_bar(display_time);
setTimeout(next, display_time * 1000);
}
}, 10);
$("#slideshow").click(function(e){
if(!$("#slideshow").hasClass("fullscreen"))
{
console.log("Entering fullscreen ...");
enterFullscreen();
}else{
console.log("Exiting fullscreen ...");
exitFullscreen();
}
});
$(document).keyup(function(e) {
if (e.keyCode == 27) { // escape key maps to keycode `27`
e.preventDefault();
console.log("Exiting fullscreen ...");
exitFullscreen();
}
});
}); });

View File

@ -27,7 +27,7 @@
<div id="posters"> <div id="posters">
{% if poster_list.count() == 0 %} {% if poster_list.count() == 0 %}
<div id="no-posters">{% trans %}No posters{% endtrans %}</div> <div id="no-posters">{% trans %}No posters{% endtrans %}</div>
{% else %} {% else %}

View File

@ -1 +1,14 @@
[{"pk": 1, "fields": {"permissions": [1, 2, 3, 7, 8, 9, 4, 5, 6, 10, 11, 12, 19, 20, 21, 22, 25, 23, 24, 16, 17, 18, 13, 14, 15]}, "model": "core.group"}, {"pk": 2, "fields": {"permissions": [25]}, "model": "core.group"}, {"pk": 3, "fields": {"permissions": []}, "model": "core.group"}] [
{
"pk": 1,
"fields": {
"permissions": [
1, 2, 3, 7, 8, 9, 4, 5, 6, 10, 11, 12, 19, 20, 21, 22, 25, 23, 24, 16, 17, 18,
13, 14, 15
]
},
"model": "core.group"
},
{ "pk": 2, "fields": { "permissions": [25] }, "model": "core.group" },
{ "pk": 3, "fields": { "permissions": [] }, "model": "core.group" }
]

View File

@ -1 +1,74 @@
[{"pk": 1, "model": "core.page", "fields": {"full_name": "guy2", "owner_group": 1, "parent": null, "edit_groups": [], "name": "guy2", "view_groups": []}}, {"pk": 2, "model": "core.page", "fields": {"full_name": "guy2/bibou", "owner_group": 1, "parent": 1, "edit_group": [], "name": "bibou", "view_group": []}}, {"pk": 3, "model": "core.page", "fields": {"full_name": "guy2/bibou/troll", "owner_group": 1, "parent": 2, "edit_group": [], "name": "troll", "view_group": []}}, {"pk": 4, "model": "core.page", "fields": {"full_name": "guy", "owner_group": 1, "parent": null, "edit_group": [1], "name": "guy", "view_group": [1]}}, {"pk": 5, "model": "core.page", "fields": {"full_name": "bibou", "owner_group": 3, "parent": null, "edit_group": [1], "name": "bibou", "view_group": []}}, {"pk": 6, "model": "core.page", "fields": {"full_name": "guy2/guy", "owner_group": 1, "parent": 1, "edit_group": [], "name": "guy", "view_group": []}}] [
{
"pk": 1,
"model": "core.page",
"fields": {
"full_name": "guy2",
"owner_group": 1,
"parent": null,
"edit_groups": [],
"name": "guy2",
"view_groups": []
}
},
{
"pk": 2,
"model": "core.page",
"fields": {
"full_name": "guy2/bibou",
"owner_group": 1,
"parent": 1,
"edit_group": [],
"name": "bibou",
"view_group": []
}
},
{
"pk": 3,
"model": "core.page",
"fields": {
"full_name": "guy2/bibou/troll",
"owner_group": 1,
"parent": 2,
"edit_group": [],
"name": "troll",
"view_group": []
}
},
{
"pk": 4,
"model": "core.page",
"fields": {
"full_name": "guy",
"owner_group": 1,
"parent": null,
"edit_group": [1],
"name": "guy",
"view_group": [1]
}
},
{
"pk": 5,
"model": "core.page",
"fields": {
"full_name": "bibou",
"owner_group": 3,
"parent": null,
"edit_group": [1],
"name": "bibou",
"view_group": []
}
},
{
"pk": 6,
"model": "core.page",
"fields": {
"full_name": "guy2/guy",
"owner_group": 1,
"parent": 1,
"edit_group": [],
"name": "guy",
"view_group": []
}
}
]

View File

@ -1 +1,42 @@
[{"model": "core.user", "pk": 1, "fields": {"first_name": "Ro", "date_joined": "2015-11-19T16:05:51.764Z", "groups": [], "password": "pbkdf2_sha256$20000$MDukCN5X8Bof$rYdhppKiusj+W/1Rxpy0yuYsEyWocESEjtRsopkOc5c=", "last_name": "Ot", "nick_name": "", "username": "root", "user_permissions": [], "email": "bibou@git.an", "last_login": "2015-11-26T16:28:36.464Z", "date_of_birth": "1969-12-31T23:00:00Z", "is_superuser": true, "is_active": true, "is_staff": true}}, {"model": "core.user", "pk": 2, "fields": {"first_name": "Skia", "date_joined": "2015-11-19T16:06:29.556Z", "groups": [3], "password": "pbkdf2_sha256$20000$UK9a29p5bBEh$Jzv7xs0W9njJZiXfIdYXDydim/3YHs6awKwDmN7gSAc=", "last_name": "Kia", "nick_name": "", "username": "skia", "user_permissions": [], "email": "plop@libskia.so", "last_login": "2015-11-26T16:37:01.671Z", "date_of_birth": "1969-12-31T23:00:00Z", "is_superuser": false, "is_active": true, "is_staff": false}}] [
{
"model": "core.user",
"pk": 1,
"fields": {
"first_name": "Ro",
"date_joined": "2015-11-19T16:05:51.764Z",
"groups": [],
"password": "pbkdf2_sha256$20000$MDukCN5X8Bof$rYdhppKiusj+W/1Rxpy0yuYsEyWocESEjtRsopkOc5c=",
"last_name": "Ot",
"nick_name": "",
"username": "root",
"user_permissions": [],
"email": "bibou@git.an",
"last_login": "2015-11-26T16:28:36.464Z",
"date_of_birth": "1969-12-31T23:00:00Z",
"is_superuser": true,
"is_active": true,
"is_staff": true
}
},
{
"model": "core.user",
"pk": 2,
"fields": {
"first_name": "Skia",
"date_joined": "2015-11-19T16:06:29.556Z",
"groups": [3],
"password": "pbkdf2_sha256$20000$UK9a29p5bBEh$Jzv7xs0W9njJZiXfIdYXDydim/3YHs6awKwDmN7gSAc=",
"last_name": "Kia",
"nick_name": "",
"username": "skia",
"user_permissions": [],
"email": "plop@libskia.so",
"last_login": "2015-11-26T16:37:01.671Z",
"date_of_birth": "1969-12-31T23:00:00Z",
"is_superuser": false,
"is_active": true,
"is_staff": false
}
}
]

View File

@ -1,45 +1,124 @@
/*--------------------------------RESET--------------------------------*/ /*--------------------------------RESET--------------------------------*/
/*--------------------------------RESET--------------------------------*/ /*--------------------------------RESET--------------------------------*/
html, body, div, span, applet, object, iframe, html,
h1, h2, h3, h4, h5, h6, p, blockquote, pre, body,
a, abbr, acronym, address, big, cite, code, div,
del, dfn, em, img, ins, kbd, q, s, samp, span,
small, strike, sub, sup, tt, var, applet,
b, u, i, center, object,
dl, dt, dd, ol, ul, li, iframe,
fieldset, form, label, legend, h1,
table, caption, tbody, tfoot, thead, tr, th, td, h2,
article, aside, canvas, details, embed, h3,
figure, figcaption, footer, header, hgroup, h4,
menu, nav, output, ruby, section, summary, h5,
time, mark, audio, video { h6,
margin: 0; p,
padding: 0; blockquote,
border: 0; pre,
font-size: 100%; a,
font: inherit; abbr,
vertical-align: baseline; acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
vertical-align: baseline;
} }
/* HTML5 display-role reset for older browsers */ /* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure, article,
footer, header, hgroup, menu, nav, section { aside,
display: block; details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
display: block;
} }
body { body {
line-height: 1; line-height: 1;
} }
ol, ul { ol,
/* list-style: none;*/ ul {
/* list-style: none;*/
} }
blockquote, q { blockquote,
quotes: none; q {
quotes: none;
} }
blockquote:before, blockquote:after, blockquote:before,
q:before, q:after { blockquote:after,
content: ''; q:before,
content: none; q:after {
content: "";
content: none;
} }
table { table {
border-collapse: collapse; border-collapse: collapse;
border-spacing: 0; border-spacing: 0;
} }

View File

@ -1,59 +1,66 @@
$( function() { $(() => {
buttons = $(".choose_file_button"); // const buttons = $('.choose_file_button')
popups = $(".choose_file_widget"); const popups = $(".choose_file_widget");
popups.dialog({ popups.dialog({
autoOpen: false, autoOpen: false,
modal: true, modal: true,
width: "90%", width: "90%",
create: function (event) { create: (event) => {
target = $(event.target); const target = $(event.target);
target.parent().css({ target.parent().css({
'position': 'fixed', position: "fixed",
'top': '5%', top: "5%",
'bottom': '5%', bottom: "5%",
}); });
target.css("height", "300px"); target.css("height", "300px");
console.log(target); },
buttons: [
{
text: "Choose",
click: function () {
$(`input[name=${$(this).attr("name")}]`).attr(
"value",
$("#file_id").attr("value"),
);
$(this).dialog("close");
}, },
buttons: [ disabled: true,
{ },
text: "Choose", ],
click: function() { });
console.log($("#file_id")); $(".choose_file_button")
$("input[name="+$(this).attr('name')+"]").attr('value', $("#file_id").attr('value')); .button()
$( this ).dialog( "close" ); .on("click", function () {
}, const popup = popups.filter(`[name=${$(this).attr("name")}]`);
disabled: true, popup.html(
} '<iframe src="/file/popup" width="100%" height="95%"></iframe><div id="file_id" value="null" />',
], );
popup.dialog({ title: $(this).text() }).dialog("open");
}); });
$( ".choose_file_button" ).button().on( "click", function() { $("#quick_notif li").click(function () {
popup = popups.filter("[name="+$(this).attr('name')+"]"); $(this).hide();
console.log(popup); });
popup.html('<iframe src="/file/popup" width="100%" height="95%"></iframe><div id="file_id" value="null" />');
popup.dialog({title: $(this).text()}).dialog( "open" );
});
$("#quick_notif li").click(function () {
$(this).hide();
})
}); });
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function createQuickNotif(msg) { function createQuickNotif(msg) {
const el = document.createElement('li') const el = document.createElement("li");
el.textContent = msg el.textContent = msg;
el.addEventListener('click', () => el.parentNode.removeChild(el)) el.addEventListener("click", () => el.parentNode.removeChild(el));
document.getElementById('quick_notif').appendChild(el) document.getElementById("quick_notif").appendChild(el);
} }
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function deleteQuickNotifs() { function deleteQuickNotifs() {
const el = document.getElementById('quick_notif') const el = document.getElementById("quick_notif");
while (el.firstChild) { while (el.firstChild) {
el.removeChild(el.firstChild) el.removeChild(el.firstChild);
} }
} }
function display_notif() { // biome-ignore lint/correctness/noUnusedVariables: used in other scripts
$('#header_notif').toggle().parent().toggleClass("white"); function displayNotif() {
$("#header_notif").toggle().parent().toggleClass("white");
} }
// You can't get the csrf token from the template in a widget // You can't get the csrf token from the template in a widget
@ -62,11 +69,13 @@ function display_notif() {
// Sadly, getting the cookie is not possible with CSRF_COOKIE_HTTPONLY or CSRF_USE_SESSIONS is True // 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 // 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 // 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() { function getCSRFToken() {
return $("[name=csrfmiddlewaretoken]").val(); return $("[name=csrfmiddlewaretoken]").val();
} }
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
const initialUrlParams = new URLSearchParams(window.location.search); const initialUrlParams = new URLSearchParams(window.location.search);
/** /**
@ -74,9 +83,12 @@ const initialUrlParams = new URLSearchParams(window.location.search);
* @enum {number} * @enum {number}
*/ */
const History = { const History = {
NONE: 0, // biome-ignore lint/style/useNamingConvention: this feels more like an enum
PUSH: 1, NONE: 0,
REPLACE: 2, // 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,
}; };
/** /**
@ -85,27 +97,31 @@ const History = {
* @param {History} action * @param {History} action
* @param {URL | null} url * @param {URL | null} url
*/ */
function update_query_string(key, value, action = History.REPLACE, url = null) { // biome-ignore lint/correctness/noUnusedVariables: used in other scripts
if (!url){ function updateQueryString(key, value, action = History.REPLACE, url = null) {
url = new URL(window.location.href); let ret = url;
} if (!ret) {
if (value === undefined || value === null || value === "") { ret = new URL(window.location.href);
// If the value is null, undefined or empty => delete it }
url.searchParams.delete(key) if (value === undefined || value === null || value === "") {
} else if (Array.isArray(value)) { // If the value is null, undefined or empty => delete it
url.searchParams.delete(key) ret.searchParams.delete(key);
value.forEach((v) => url.searchParams.append(key, v)) } else if (Array.isArray(value)) {
} else { ret.searchParams.delete(key);
url.searchParams.set(key, value); for (const v of value) {
ret.searchParams.append(key, v);
} }
} else {
ret.searchParams.set(key, value);
}
if (action === History.PUSH) { if (action === History.PUSH) {
history.pushState(null, "", url.toString()); window.history.pushState(null, "", ret.toString());
} else if (action === History.REPLACE) { } else if (action === History.REPLACE) {
history.replaceState(null, "", url.toString()); window.history.replaceState(null, "", ret.toString());
} }
return url; return ret;
} }
// TODO : If one day a test workflow is made for JS in this project // TODO : If one day a test workflow is made for JS in this project
@ -116,27 +132,28 @@ function update_query_string(key, value, action = History.REPLACE, url = null) {
* @param {string} url The paginated endpoint to fetch * @param {string} url The paginated endpoint to fetch
* @return {Promise<Object[]>} * @return {Promise<Object[]>}
*/ */
async function fetch_paginated(url) { // biome-ignore lint/correctness/noUnusedVariables: used in other scripts
const max_per_page = 199; async function fetchPaginated(url) {
const paginated_url = new URL(url, document.location.origin); const maxPerPage = 199;
paginated_url.searchParams.set("page_size", max_per_page.toString()); const paginatedUrl = new URL(url, document.location.origin);
paginated_url.searchParams.set("page", "1"); paginatedUrl.searchParams.set("page_size", maxPerPage.toString());
paginatedUrl.searchParams.set("page", "1");
let first_page = (await ( await fetch(paginated_url)).json()); const firstPage = await (await fetch(paginatedUrl)).json();
let results = first_page.results; const results = firstPage.results;
const nb_pictures = first_page.count const nbPictures = firstPage.count;
const nb_pages = Math.ceil(nb_pictures / max_per_page); const nbPages = Math.ceil(nbPictures / maxPerPage);
if (nb_pages > 1) { if (nbPages > 1) {
let promises = []; const promises = [];
for (let i = 2; i <= nb_pages; i++) { for (let i = 2; i <= nbPages; i++) {
paginated_url.searchParams.set("page", i.toString()); paginatedUrl.searchParams.set("page", i.toString());
promises.push( 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()) results.push(...(await Promise.all(promises)).flat());
} }
return results; return results;
} }

View File

@ -19,4 +19,106 @@
// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION // LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
!function(e){e.fn.shorten=function(s){"use strict";var t={showChars:100,minHideChars:10,ellipsesText:"...",moreText:"more",lessText:"less",onLess:function(){},onMore:function(){},errMsg:null,force:!1};return s&&e.extend(t,s),(!e(this).data("jquery.shorten")||!!t.force)&&(e(this).data("jquery.shorten",!0),e(document).off("click",".morelink"),e(document).on({click:function(){var s=e(this);return s.hasClass("less")?(s.removeClass("less"),s.html(t.moreText),s.parent().prev().animate({},function(){s.parent().prev().prev().show()}).hide("fast",function(){t.onLess()})):(s.addClass("less"),s.html(t.lessText),s.parent().prev().animate({},function(){s.parent().prev().prev().hide()}).show("fast",function(){t.onMore()})),!1}},".morelink"),this.each(function(){var s=e(this),n=s.html();if(s.text().length>t.showChars+t.minHideChars){var r=n.substr(0,t.showChars);if(r.indexOf("<")>=0){for(var a=!1,o="",i=0,l=[],h=null,c=0,f=0;f<=t.showChars;c++)if("<"!=n[c]||a||(a=!0,"/"==(h=n.substring(c+1,n.indexOf(">",c)))[0]?h!="/"+l[0]?t.errMsg="ERROR en HTML: the top of the stack should be the tag that closes":l.shift():"br"!=h.toLowerCase()&&l.unshift(h)),a&&">"==n[c]&&(a=!1),a)o+=n.charAt(c);else if(f++,i<=t.showChars)o+=n.charAt(c),i++;else if(l.length>0){for(j=0;j<l.length;j++)o+="</"+l[j]+">";break}r=e("<div/>").html(o+'<span class="ellip">'+t.ellipsesText+"</span>").html()}else r+=t.ellipsesText;var p='<div class="shortcontent">'+r+'</div><div class="allcontent">'+n+'</div><span><a href="javascript://nop/" class="morelink">'+t.moreText+"</a></span>";s.html(p),s.find(".allcontent").hide(),e(".shortcontent p:last",s).css("margin-bottom",0)}}))}}(jQuery); !(function (e) {
e.fn.shorten = function (s) {
"use strict";
var t = {
showChars: 100,
minHideChars: 10,
ellipsesText: "...",
moreText: "more",
lessText: "less",
onLess: function () {},
onMore: function () {},
errMsg: null,
force: !1,
};
return (
s && e.extend(t, s),
(!e(this).data("jquery.shorten") || !!t.force) &&
(e(this).data("jquery.shorten", !0),
e(document).off("click", ".morelink"),
e(document).on(
{
click: function () {
var s = e(this);
return (
s.hasClass("less")
? (s.removeClass("less"),
s.html(t.moreText),
s
.parent()
.prev()
.animate({}, function () {
s.parent().prev().prev().show();
})
.hide("fast", function () {
t.onLess();
}))
: (s.addClass("less"),
s.html(t.lessText),
s
.parent()
.prev()
.animate({}, function () {
s.parent().prev().prev().hide();
})
.show("fast", function () {
t.onMore();
})),
!1
);
},
},
".morelink",
),
this.each(function () {
var s = e(this),
n = s.html();
if (s.text().length > t.showChars + t.minHideChars) {
var r = n.substr(0, t.showChars);
if (r.indexOf("<") >= 0) {
for (
var a = !1, o = "", i = 0, l = [], h = null, c = 0, f = 0;
f <= t.showChars;
c++
)
if (
("<" != n[c] ||
a ||
((a = !0),
"/" == (h = n.substring(c + 1, n.indexOf(">", c)))[0]
? h != "/" + l[0]
? (t.errMsg =
"ERROR en HTML: the top of the stack should be the tag that closes")
: l.shift()
: "br" != h.toLowerCase() && l.unshift(h)),
a && ">" == n[c] && (a = !1),
a)
)
o += n.charAt(c);
else if ((f++, i <= t.showChars)) (o += n.charAt(c)), i++;
else if (l.length > 0) {
for (j = 0; j < l.length; j++) o += "</" + l[j] + ">";
break;
}
r = e("<div/>")
.html(o + '<span class="ellip">' + t.ellipsesText + "</span>")
.html();
} else r += t.ellipsesText;
var p =
'<div class="shortcontent">' +
r +
'</div><div class="allcontent">' +
n +
'</div><span><a href="javascript://nop/" class="morelink">' +
t.moreText +
"</a></span>";
s.html(p),
s.find(".allcontent").hide(),
e(".shortcontent p:last", s).css("margin-bottom", 0);
}
}))
);
};
})(jQuery);

View File

@ -15,7 +15,7 @@
* ]; * ];
* document.addEventListener("DOMContentLoaded", () => sithSelect2({ * document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"), * element: document.getElementById("select2-input"),
* data_source: local_data_source(data) * dataSource: localDataSource(data)
* })); * }));
* ``` * ```
* *
@ -29,7 +29,7 @@
* ]; * ];
* document.addEventListener("DOMContentLoaded", () => sithSelect2({ * document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"), * 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)) * excluded: () => data.filter((i) => i.text === "to exclude").map((i) => parseInt(i))
* }) * })
* })); * }));
@ -38,15 +38,15 @@
* # Remote data source * # Remote data source
* *
* Select2 with remote data sources are similar to those with local * 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`. * which takes a callback that must return a `Select2Object`.
* *
* ```js * ```js
* document.addEventListener("DOMContentLoaded", () => sithSelect2({ * document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"), * 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 * 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 * ```js
* document.addEventListener("DOMContentLoaded", () => sithSelect2({ * document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"), * element: document.getElementById("select2-input"),
* data_source: remote_data_source("/api/user/search", { * dataSource: remoteDataSource("/api/user/search", {
* result_converter: (user) => Object({id: user.id, text: user.first_name}), * resultConverter: (user) => Object({id: user.id, text: user.firstName}),
* overrides: { * overrides: {
* delay: 500 * delay: 500
* } * }
@ -85,15 +85,15 @@
* *
* Sometimes, you would like to display an image besides * Sometimes, you would like to display an image besides
* the text on the select items. * the text on the select items.
* In this case, fill the `picture_getter` option : * In this case, fill the `pictureGetter` option :
* *
* ```js * ```js
* document.addEventListener("DOMContentLoaded", () => sithSelect2({ * document.addEventListener("DOMContentLoaded", () => sithSelect2({
* element: document.getElementById("select2-input"), * element: document.getElementById("select2-input"),
* data_source: remote_data_source("/api/user/search", { * dataSource: remoteDataSource("/api/user/search", {
* result_converter: (user) => Object({id: user.id, text: user.first_name}) * resultConverter: (user) => Object({id: user.id, text: user.firstName})
* }) * })
* picture_getter: (user) => user.profile_pict, * pictureGetter: (user) => user.profilePict,
* })); * }));
* ``` * ```
* *
@ -105,8 +105,8 @@
* <body> * <body>
* <div x-data="select2_test"> * <div x-data="select2_test">
* <select x-ref="search" x-ref="select"></select> * <select x-ref="search" x-ref="select"></select>
* <p x-text="current_selection.id"></p> * <p x-text="currentSelection.id"></p>
* <p x-text="current_selection.text"></p> * <p x-text="currentSelection.text"></p>
* </div> * </div>
* </body> * </body>
* *
@ -114,20 +114,20 @@
* document.addEventListener("alpine:init", () => { * document.addEventListener("alpine:init", () => {
* Alpine.data("select2_test", () => ({ * Alpine.data("select2_test", () => ({
* selector: undefined, * selector: undefined,
* current_select: {id: "", text: ""}, * currentSelect: {id: "", text: ""},
* *
* init() { * init() {
* this.selector = sithSelect2({ * this.selector = sithSelect2({
* element: $(this.$refs.select), * element: $(this.$refs.select),
* data_source: local_data_source( * dataSource: localDataSource(
* [{id: 1, text: "foo"}, {id: 2, text: "bar"}] * [{id: 1, text: "foo"}, {id: 2, text: "bar"}]
* ), * ),
* }); * });
* this.selector.on("select2:select", (event) => { * this.selector.on("select2:select", (event) => {
* // select2 => Alpine signals here * // 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 * // Alpine => select2 signals here
* }); * });
* }, * },
@ -145,10 +145,10 @@
/** /**
* @typedef Select2Options * @typedef Select2Options
* @property {Element} element * @property {Element} element
* @property {Object} data_source * @property {Object} dataSource
* the data source, built with `local_data_source` or `remote_data_source` * the data source, built with `localDataSource` or `remoteDataSource`
* @property {number[]} excluded A list of ids to exclude from search * @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 * A callback to get the picture field from the API response
* @property {Object | undefined} overrides * @property {Object | undefined} overrides
* Any other select2 parameter to apply on the config * Any other select2 parameter to apply on the config
@ -157,13 +157,14 @@
/** /**
* @param {Select2Options} options * @param {Select2Options} options
*/ */
// biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function sithSelect2(options) { function sithSelect2(options) {
const elem = $(options.element); const elem = $(options.element);
return elem.select2({ return elem.select2({
theme: elem[0].multiple ? "classic" : "default", theme: elem[0].multiple ? "classic" : "default",
minimumInputLength: 2, minimumInputLength: 2,
templateResult: select_item_builder(options.picture_getter), templateResult: selectItemBuilder(options.pictureGetter),
...options.data_source, ...options.dataSource,
...(options.overrides || {}), ...(options.overrides || {}),
}); });
} }
@ -179,8 +180,9 @@ function sithSelect2(options) {
* @param {Select2Object[]} source The array containing the data * @param {Select2Object[]} source The array containing the data
* @param {RemoteSourceOptions} options * @param {RemoteSourceOptions} options
*/ */
function local_data_source(source, options) { // biome-ignore lint/correctness/noUnusedVariables: used in other scripts
if (!!options.excluded) { function localDataSource(source, options) {
if (options.excluded) {
const ids = options.excluded(); const ids = options.excluded();
return { data: source.filter((i) => !ids.includes(i.id)) }; return { data: source.filter((i) => !ids.includes(i.id)) };
} }
@ -191,7 +193,7 @@ function local_data_source(source, options) {
* @typedef RemoteSourceOptions * @typedef RemoteSourceOptions
* @property {undefined | function(): number[]} excluded * @property {undefined | function(): number[]} excluded
* A callback to the ids to exclude from the search * 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 * A converter for a value coming from the remote api
* @property {undefined | Object} overrides * @property {undefined | Object} overrides
* Any other select2 parameter to apply on the config * Any other select2 parameter to apply on the config
@ -202,9 +204,11 @@ function local_data_source(source, options) {
* @param {string} source The url of the endpoint * @param {string} source The url of the endpoint
* @param {RemoteSourceOptions} options * @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; jQuery.ajaxSettings.traditional = true;
let params = { const params = {
url: source, url: source,
dataType: "json", dataType: "json",
cache: true, cache: true,
@ -213,24 +217,25 @@ function remote_data_source(source, options) {
return { return {
search: params.term, search: params.term,
exclude: [ exclude: [
...(this.val() || []).map((i) => parseInt(i)), ...(this.val() || []).map((i) => Number.parseInt(i)),
...(options.excluded ? options.excluded() : []), ...(options.excluded ? options.excluded() : []),
], ],
}; };
}, },
}; };
if (!!options.result_converter) { if (options.resultConverter) {
params["processResults"] = function (data) { params.processResults = (data) => ({
return { results: data.results.map(options.result_converter) }; results: data.results.map(options.resultConverter),
}; });
} }
if (!!options.overrides) { if (options.overrides) {
Object.assign(params, options.overrides); Object.assign(params, options.overrides);
} }
return { ajax: params }; return { ajax: params };
} }
function item_formatter(user) { // biome-ignore lint/correctness/noUnusedVariables: used in other scripts
function itemFormatter(user) {
if (user.loading) { if (user.loading) {
return user.text; return user.text;
} }
@ -238,23 +243,22 @@ function item_formatter(user) {
/** /**
* Build a function to display the results * 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} * @return {function(string): jQuery|HTMLElement}
*/ */
function select_item_builder(picture_getter) { function selectItemBuilder(pictureGetter) {
return (item) => { return (item) => {
const picture = const picture = typeof pictureGetter === "function" ? pictureGetter(item) : null;
typeof picture_getter === "function" ? picture_getter(item) : null; const imgHtml = picture
const img_html = picture
? `<img ? `<img
src="${picture_getter(item)}" src="${pictureGetter(item)}"
alt="${item.text}" alt="${item.text}"
onerror="this.src = '/static/core/img/unknown.jpg'" onerror="this.src = '/static/core/img/unknown.jpg'"
/>` />`
: ""; : "";
return $(`<div class="select-item"> return $(`<div class="select-item">
${img_html} ${imgHtml}
<span class="select-item-text">${item.text}</span> <span class="select-item-text">${item.text}</span>
</div>`); </div>`);
}; };

View File

@ -1,7 +1,7 @@
async function get_graph_data(url, godfathers_depth, godchildren_depth) { async function getGraphData(url, godfathersDepth, godchildrenDepth) {
let data = await ( const data = await (
await fetch( await fetch(
`${url}?godfathers_depth=${godfathers_depth}&godchildren_depth=${godchildren_depth}`, `${url}?godfathers_depth=${godfathersDepth}&godchildren_depth=${godchildrenDepth}`,
) )
).json(); ).json();
return [ return [
@ -16,12 +16,13 @@ async function get_graph_data(url, godfathers_depth, godchildren_depth) {
]; ];
} }
function create_graph(container, data, active_user_id) { function createGraph(container, data, activeUserId) {
let cy = cytoscape({ // biome-ignore lint/correctness/noUndeclaredVariables: imported by user_godphaters_tree.jinja
const cy = cytoscape({
boxSelectionEnabled: false, boxSelectionEnabled: false,
autounselectify: true, autounselectify: true,
container: container, container,
elements: data, elements: data,
minZoom: 0.5, minZoom: 0.5,
@ -83,11 +84,9 @@ function create_graph(container, data, active_user_id) {
}, },
}, },
}); });
let active_user = cy const activeUser = cy.getElementById(activeUserId).style("shape", "rectangle");
.getElementById(active_user_id)
.style("shape", "rectangle");
/* Reset graph */ /* Reset graph */
let reset_graph = () => { const resetGraph = () => {
cy.elements((element) => { cy.elements((element) => {
if (element.hasClass("traversed")) { if (element.hasClass("traversed")) {
element.removeClass("traversed"); element.removeClass("traversed");
@ -98,38 +97,33 @@ function create_graph(container, data, active_user_id) {
}); });
}; };
let on_node_tap = (el) => { const onNodeTap = (el) => {
reset_graph(); resetGraph();
/* Create path on graph if selected isn't the targeted user */ /* Create path on graph if selected isn't the targeted user */
if (el === active_user) { if (el === activeUser) {
return; return;
} }
cy.elements((element) => { cy.elements((element) => {
element.addClass("not-traversed"); element.addClass("not-traversed");
}); });
cy.elements() for (const traversed of cy.elements().aStar({
.aStar({ root: el,
root: el, goal: activeUser,
goal: active_user, }).path) {
}) traversed.removeClass("not-traversed");
.path.forEach((el) => { traversed.addClass("traversed");
el.removeClass("not-traversed"); }
el.addClass("traversed");
});
}; };
cy.on("tap", "node", (tapped) => { cy.on("tap", "node", (tapped) => {
on_node_tap(tapped.target); onNodeTap(tapped.target);
}); });
cy.zoomingEnabled(false); cy.zoomingEnabled(false);
/* Add context menu */ /* Add context menu */
if (cy.cxtmenu === undefined) { if (cy.cxtmenu === undefined) {
console.error( throw new Error("ctxmenu isn't loaded, context menu won't be available on graphs");
"ctxmenu isn't loaded, context menu won't be available on graphs",
);
return cy;
} }
cy.cxtmenu({ cy.cxtmenu({
selector: "node", selector: "node",
@ -137,22 +131,22 @@ function create_graph(container, data, active_user_id) {
commands: [ commands: [
{ {
content: '<i class="fa fa-external-link fa-2x"></i>', content: '<i class="fa fa-external-link fa-2x"></i>',
select: function (el) { select: (el) => {
window.open(el.data().profile_url, "_blank").focus(); window.open(el.data().profile_url, "_blank").focus();
}, },
}, },
{ {
content: '<span class="fa fa-mouse-pointer fa-2x"></span>', content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
select: function (el) { select: (el) => {
on_node_tap(el); onNodeTap(el);
}, },
}, },
{ {
content: '<i class="fa fa-eraser fa-2x"></i>', content: '<i class="fa fa-eraser fa-2x"></i>',
select: function (el) { select: (_) => {
reset_graph(); resetGraph();
}, },
}, },
], ],
@ -165,68 +159,77 @@ document.addEventListener("alpine:init", () => {
/* /*
This needs some constants to be set before the document has been loaded This needs some constants to be set before the document has been loaded
api_url: base url for fetching the tree as a string apiUrl: base url for fetching the tree as a string
active_user: id of the user to fetch the tree from activeUser: id of the user to fetch the tree from
depth_min: minimum tree depth for godfathers and godchildren as an int depthMin: minimum tree depth for godfathers and godchildren as an int
depth_max: maximum 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 ( if (
typeof api_url === "undefined" || // biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
typeof active_user === "undefined" || typeof apiUrl === "undefined" ||
typeof depth_min === "undefined" || // biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
typeof depth_max === "undefined" 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", "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) {
let value = parseInt(initialUrlParams.get(prop)); // biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
if (isNaN(value) || value < depth_min || value > depth_max) { const value = Number.parseInt(initialUrlParams.get(prop));
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; return value;
} }
Alpine.data("graph", () => ({ Alpine.data("graph", () => ({
loading: false, loading: false,
godfathers_depth: get_initial_depth("godfathers_depth"), godfathersDepth: getInitialDepth("godfathersDepth"),
godchildren_depth: get_initial_depth("godchildren_depth"), godchildrenDepth: getInitialDepth("godchildrenDepth"),
// biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true", reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined, graph: undefined,
graph_data: {}, graphData: {},
async init() { async init() {
let delayed_fetch = Alpine.debounce(async () => { const delayedFetch = Alpine.debounce(async () => {
this.fetch_graph_data(); await this.fetchGraphData();
}, 100); }, 100);
["godfathers_depth", "godchildren_depth"].forEach((param) => { for (const param of ["godfathersDepth", "godchildrenDepth"]) {
this.$watch(param, async (value) => { 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; return;
} }
update_query_string(param, value, History.REPLACE); // biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
delayed_fetch(); updateQueryString(param, value, History.REPLACE);
await delayedFetch();
}); });
}); }
this.$watch("reverse", async (value) => { this.$watch("reverse", async (value) => {
update_query_string("reverse", value, History.REPLACE); // biome-ignore lint/correctness/noUndeclaredVariables: defined by script.js
this.reverse_graph(); updateQueryString("reverse", value, History.REPLACE);
await this.reverseGraph();
}); });
this.$watch("graph_data", async () => { this.$watch("graphData", async () => {
await this.generate_graph(); await this.generateGraph();
if (this.reverse) { 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"); const link = document.createElement("a");
link.href = this.graph.jpg(); link.href = this.graph.jpg();
link.download = interpolate( link.download = interpolate(
@ -239,34 +242,32 @@ document.addEventListener("alpine:init", () => {
document.body.removeChild(link); document.body.removeChild(link);
}, },
async reset() { reset() {
this.reverse = false; this.reverse = false;
this.godfathers_depth = default_depth; this.godfathersDepth = defaultDepth;
this.godchildren_depth = default_depth; this.godchildrenDepth = defaultDepth;
}, },
async reverse_graph() { async reverseGraph() {
this.graph.elements((el) => { this.graph.elements((el) => {
el.position(new Object({ x: -el.position().x, y: -el.position().y })); el.position({ x: -el.position().x, y: -el.position().y });
}); });
this.graph.center(this.graph.elements()); this.graph.center(this.graph.elements());
}, },
async fetch_graph_data() { async fetchGraphData() {
this.graph_data = await get_graph_data( this.graphData = await getGraphData(
api_url, // biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
this.godfathers_depth, apiUrl,
this.godchildren_depth, this.godfathersDepth,
this.godchildrenDepth,
); );
}, },
async generate_graph() { async generateGraph() {
this.loading = true; this.loading = true;
this.graph = create_graph( // biome-ignore lint/correctness/noUndeclaredVariables: defined by user_godfathers_tree.jinja
$(this.$refs.graph), this.graph = await createGraph($(this.$refs.graph), this.graphData, activeUser);
this.graph_data,
active_user,
);
this.loading = false; this.loading = false;
}, },
})); }));

View File

@ -1,28 +1,25 @@
function alpine_webcam_builder( // biome-ignore lint/correctness/noUnusedVariables: used in user_edit.jinja
default_picture, function alpineWebcamBuilder(defaultPicture, deleteUrl, canDeletePicture) {
delete_url,
can_delete_picture,
) {
return () => ({ return () => ({
can_edit_picture: false, canEditPicture: false,
loading: false, loading: false,
is_camera_enabled: false, isCameraEnabled: false,
is_camera_error: false, isCameraError: false,
picture: null, picture: null,
video: null, video: null,
picture_form: null, pictureForm: null,
init() { init() {
this.video = this.$refs.video; this.video = this.$refs.video;
this.picture_form = this.$refs.form.getElementsByTagName("input"); this.pictureForm = this.$refs.form.getElementsByTagName("input");
if (this.picture_form.length > 0) { if (this.pictureForm.length > 0) {
this.picture_form = this.picture_form[0]; this.pictureForm = this.pictureForm[0];
this.can_edit_picture = true; this.canEditPicture = true;
// Link the displayed element to the form input // Link the displayed element to the form input
this.picture_form.onchange = (event) => { this.pictureForm.onchange = (event) => {
let files = event.srcElement.files; const files = event.srcElement.files;
if (files.length > 0) { if (files.length > 0) {
this.picture = (window.URL || window.webkitURL).createObjectURL( this.picture = (window.URL || window.webkitURL).createObjectURL(
event.srcElement.files[0], event.srcElement.files[0],
@ -34,77 +31,78 @@ function alpine_webcam_builder(
} }
}, },
get_picture() { getPicture() {
return this.picture || default_picture; return this.picture || defaultPicture;
}, },
delete_picture() { deletePicture() {
// Only remove currently displayed picture // Only remove currently displayed picture
if (!!this.picture) { if (this.picture) {
let list = new DataTransfer(); const list = new DataTransfer();
this.picture_form.files = list.files; this.pictureForm.files = list.files;
this.picture_form.dispatchEvent(new Event("change")); this.pictureForm.dispatchEvent(new Event("change"));
return; return;
} }
if (!can_delete_picture) { if (!canDeletePicture) {
return; return;
} }
// Remove user picture if correct rights are available // Remove user picture if correct rights are available
window.open(delete_url, "_self"); window.open(deleteUrl, "_self");
}, },
enable_camera() { enableCamera() {
this.picture = null; this.picture = null;
this.loading = true; this.loading = true;
this.is_camera_error = false; this.isCameraError = false;
navigator.mediaDevices navigator.mediaDevices
.getUserMedia({ video: true, audio: false }) .getUserMedia({ video: true, audio: false })
.then((stream) => { .then((stream) => {
this.loading = false; this.loading = false;
this.is_camera_enabled = true; this.isCameraEnabled = true;
this.video.srcObject = stream; this.video.srcObject = stream;
this.video.play(); this.video.play();
}) })
.catch((err) => { .catch((err) => {
this.is_camera_error = true; this.isCameraError = true;
this.loading = false; this.loading = false;
throw err;
}); });
}, },
take_picture() { takePicture() {
let canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const context = canvas.getContext("2d"); const context = canvas.getContext("2d");
/* Create the image */ /* Create the image */
let settings = this.video.srcObject.getTracks()[0].getSettings(); const settings = this.video.srcObject.getTracks()[0].getSettings();
canvas.width = settings.width; canvas.width = settings.width;
canvas.height = settings.height; canvas.height = settings.height;
context.drawImage(this.video, 0, 0, canvas.width, canvas.height); context.drawImage(this.video, 0, 0, canvas.width, canvas.height);
/* Stop camera */ /* Stop camera */
this.video.pause(); this.video.pause();
this.video.srcObject.getTracks().forEach((track) => { for (const track of this.video.srcObject.getTracks()) {
if (track.readyState === "live") { if (track.readyState === "live") {
track.stop(); track.stop();
} }
}); }
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
const filename = interpolate(gettext("captured.%s"), ["webp"]); const filename = interpolate(gettext("captured.%s"), ["webp"]);
let file = new File([blob], filename, { const file = new File([blob], filename, {
type: "image/webp", type: "image/webp",
}); });
let list = new DataTransfer(); const list = new DataTransfer();
list.items.add(file); list.items.add(file);
this.picture_form.files = list.files; this.pictureForm.files = list.files;
// No change event is triggered, we trigger it manually #} // No change event is triggered, we trigger it manually #}
this.picture_form.dispatchEvent(new Event("change")); this.pictureForm.dispatchEvent(new Event("change"));
}, "image/webp"); }, "image/webp");
canvas.remove(); canvas.remove();
this.is_camera_enabled = false; this.isCameraEnabled = false;
}, },
}); });
} }

View File

@ -2,6 +2,6 @@ import Alpine from "alpinejs";
window.Alpine = Alpine; window.Alpine = Alpine;
addEventListener("DOMContentLoaded", (event) => { window.addEventListener("DOMContentLoaded", () => {
Alpine.start(); Alpine.start();
}); });

View File

@ -1,6 +1,7 @@
// biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde
import "codemirror/lib/codemirror.css"; import "codemirror/lib/codemirror.css";
import "easymde/src/css/easymde.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 // 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 {HTMLTextAreaElement} textarea to use
* @param {string} link to the markdown api * @param {string} link to the markdown api
**/ **/
function easymde_factory (textarea, markdown_api_url) { function easymdeFactory(textarea, markdownApiUrl) {
const easymde = new EasyMDE({ const easymde = new easyMde({
element: textarea, element: textarea,
spellChecker: false, spellChecker: false,
autoDownloadFontAwesome: false, autoDownloadFontAwesome: false,
previewRender: Alpine.debounce(async (plainText, preview) => { previewRender: Alpine.debounce(async (plainText, preview) => {
const res = await fetch(markdown_api_url, { const res = await fetch(markdownApiUrl, {
method: "POST", method: "POST",
body: JSON.stringify({ text: plainText }), body: JSON.stringify({ text: plainText }),
}); });
@ -26,33 +27,33 @@ function easymde_factory (textarea, markdown_api_url) {
toolbar: [ toolbar: [
{ {
name: "heading-smaller", name: "heading-smaller",
action: EasyMDE.toggleHeadingSmaller, action: easyMde.toggleHeadingSmaller,
className: "fa fa-header", className: "fa fa-header",
title: gettext("Heading"), title: gettext("Heading"),
}, },
{ {
name: "italic", name: "italic",
action: EasyMDE.toggleItalic, action: easyMde.toggleItalic,
className: "fa fa-italic", className: "fa fa-italic",
title: gettext("Italic"), title: gettext("Italic"),
}, },
{ {
name: "bold", name: "bold",
action: EasyMDE.toggleBold, action: easyMde.toggleBold,
className: "fa fa-bold", className: "fa fa-bold",
title: gettext("Bold"), title: gettext("Bold"),
}, },
{ {
name: "strikethrough", name: "strikethrough",
action: EasyMDE.toggleStrikethrough, action: easyMde.toggleStrikethrough,
className: "fa fa-strikethrough", className: "fa fa-strikethrough",
title: gettext("Strikethrough"), title: gettext("Strikethrough"),
}, },
{ {
name: "underline", name: "underline",
action: function customFunction(editor) { action: function customFunction(editor) {
let cm = editor.codemirror; const cm = editor.codemirror;
cm.replaceSelection("__" + cm.getSelection() + "__"); cm.replaceSelection(`__${cm.getSelection()}__`);
}, },
className: "fa fa-underline", className: "fa fa-underline",
title: gettext("Underline"), title: gettext("Underline"),
@ -60,8 +61,8 @@ function easymde_factory (textarea, markdown_api_url) {
{ {
name: "superscript", name: "superscript",
action: function customFunction(editor) { action: function customFunction(editor) {
let cm = editor.codemirror; const cm = editor.codemirror;
cm.replaceSelection("^" + cm.getSelection() + "^"); cm.replaceSelection(`^${cm.getSelection()}^`);
}, },
className: "fa fa-superscript", className: "fa fa-superscript",
title: gettext("Superscript"), title: gettext("Superscript"),
@ -69,80 +70,80 @@ function easymde_factory (textarea, markdown_api_url) {
{ {
name: "subscript", name: "subscript",
action: function customFunction(editor) { action: function customFunction(editor) {
let cm = editor.codemirror; const cm = editor.codemirror;
cm.replaceSelection("~" + cm.getSelection() + "~"); cm.replaceSelection(`~${cm.getSelection()}~`);
}, },
className: "fa fa-subscript", className: "fa fa-subscript",
title: gettext("Subscript"), title: gettext("Subscript"),
}, },
{ {
name: "code", name: "code",
action: EasyMDE.toggleCodeBlock, action: easyMde.toggleCodeBlock,
className: "fa fa-code", className: "fa fa-code",
title: gettext("Code"), title: gettext("Code"),
}, },
"|", "|",
{ {
name: "quote", name: "quote",
action: EasyMDE.toggleBlockquote, action: easyMde.toggleBlockquote,
className: "fa fa-quote-left", className: "fa fa-quote-left",
title: gettext("Quote"), title: gettext("Quote"),
}, },
{ {
name: "unordered-list", name: "unordered-list",
action: EasyMDE.toggleUnorderedList, action: easyMde.toggleUnorderedList,
className: "fa fa-list-ul", className: "fa fa-list-ul",
title: gettext("Unordered list"), title: gettext("Unordered list"),
}, },
{ {
name: "ordered-list", name: "ordered-list",
action: EasyMDE.toggleOrderedList, action: easyMde.toggleOrderedList,
className: "fa fa-list-ol", className: "fa fa-list-ol",
title: gettext("Ordered list"), title: gettext("Ordered list"),
}, },
"|", "|",
{ {
name: "link", name: "link",
action: EasyMDE.drawLink, action: easyMde.drawLink,
className: "fa fa-link", className: "fa fa-link",
title: gettext("Insert link"), title: gettext("Insert link"),
}, },
{ {
name: "image", name: "image",
action: EasyMDE.drawImage, action: easyMde.drawImage,
className: "fa-regular fa-image", className: "fa-regular fa-image",
title: gettext("Insert image"), title: gettext("Insert image"),
}, },
{ {
name: "table", name: "table",
action: EasyMDE.drawTable, action: easyMde.drawTable,
className: "fa fa-table", className: "fa fa-table",
title: gettext("Insert table"), title: gettext("Insert table"),
}, },
"|", "|",
{ {
name: "clean-block", name: "clean-block",
action: EasyMDE.cleanBlock, action: easyMde.cleanBlock,
className: "fa fa-eraser fa-clean-block", className: "fa fa-eraser fa-clean-block",
title: gettext("Clean block"), title: gettext("Clean block"),
}, },
"|", "|",
{ {
name: "preview", name: "preview",
action: EasyMDE.togglePreview, action: easyMde.togglePreview,
className: "fa fa-eye no-disable", className: "fa fa-eye no-disable",
title: gettext("Toggle preview"), title: gettext("Toggle preview"),
}, },
{ {
name: "side-by-side", name: "side-by-side",
action: EasyMDE.toggleSideBySide, action: easyMde.toggleSideBySide,
className: "fa fa-columns no-disable no-mobile", className: "fa fa-columns no-disable no-mobile",
title: gettext("Toggle side by side"), title: gettext("Toggle side by side"),
}, },
{ {
name: "fullscreen", name: "fullscreen",
action: EasyMDE.toggleFullScreen, action: easyMde.toggleFullScreen,
className: "fa fa-arrows-alt no-disable no-mobile", className: "fa fa-expand no-mobile",
title: gettext("Toggle fullscreen"), title: gettext("Toggle fullscreen"),
}, },
"|", "|",
@ -155,18 +156,16 @@ function easymde_factory (textarea, markdown_api_url) {
], ],
}); });
const submits = textarea const submits = textarea.closest("form").querySelectorAll('input[type="submit"]');
.closest("form")
.querySelectorAll('input[type="submit"]');
const parentDiv = textarea.parentElement; const parentDiv = textarea.parentElement;
let submitPressed = false; let submitPressed = false;
function checkMarkdownInput(e) { function checkMarkdownInput() {
// an attribute is null if it does not exist, else a string // an attribute is null if it does not exist, else a string
const required = textarea.getAttribute("required") != null; const required = textarea.getAttribute("required") != null;
const length = textarea.value.trim().length; const length = textarea.value.trim().length;
if (required && length == 0) { if (required && length === 0) {
parentDiv.style.boxShadow = "red 0px 0px 1.5px 1px"; parentDiv.style.boxShadow = "red 0px 0px 1.5px 1px";
} else { } else {
parentDiv.style.boxShadow = ""; parentDiv.style.boxShadow = "";
@ -181,9 +180,9 @@ function easymde_factory (textarea, markdown_api_url) {
checkMarkdownInput(e); checkMarkdownInput(e);
} }
submits.forEach((submit) => { for (const submit of submits) {
submit.addEventListener("click", onSubmitClick); submit.addEventListener("click", onSubmitClick);
}); }
}; }
window.easymde_factory = easymde_factory; window.easymdeFactory = easymdeFactory;

View File

@ -18,7 +18,7 @@ require("jquery-ui/themes/base/all.css");
* @param {string} selector to be passed to jQuery * @param {string} selector to be passed to jQuery
* @param {Object} options object to pass to the shorten function * @param {Object} options object to pass to the shorten function
**/ **/
export function shorten(selector, options) { function shorten(selector, options) {
$(selector).shorten(options); $(selector).shorten(options);
} }

View File

@ -112,7 +112,7 @@
></a> ></a>
</div> </div>
<div class="notification"> <div class="notification">
<a href="#" onclick="display_notif()"> <a href="#" onclick="displayNotif()">
<i class="fa-regular fa-bell"></i> <i class="fa-regular fa-bell"></i>
{% set notification_count = user.notifications.filter(viewed=False).count() %} {% set notification_count = user.notifications.filter(viewed=False).count() %}

View File

@ -15,36 +15,36 @@
{% macro profile_picture(field_name) %} {% macro profile_picture(field_name) %}
{% set this_picture = form.instance[field_name] %} {% set this_picture = form.instance[field_name] %}
<div class="profile-picture" x-data="camera_{{ 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 <img
x-show="!is_camera_enabled && !is_camera_error" x-show="!isCameraEnabled && !isCameraError"
:src="get_picture()" :src="getPicture()"
alt="{%- trans -%}Profile{%- endtrans -%}" title="{%- trans -%}Profile{%- endtrans -%}" alt="{%- trans -%}Profile{%- endtrans -%}" title="{%- trans -%}Profile{%- endtrans -%}"
loading="lazy" loading="lazy"
/> />
<video <video
x-show="is_camera_enabled" x-show="isCameraEnabled"
x-ref="video" x-ref="video"
></video> ></video>
<i <i
x-show="is_camera_error" x-show="isCameraError"
x-cloak x-cloak
class="fa fa-eye-slash" class="fa fa-eye-slash"
></i> ></i>
</div> </div>
<div class="profile-picture-buttons" x-show="can_edit_picture"> <div class="profile-picture-buttons" x-show="canEditPicture">
<button <button
x-show="can_edit_picture && !is_camera_enabled" x-show="canEditPicture && !isCameraEnabled"
class="btn btn-blue" class="btn btn-blue"
@click.prevent="enable_camera()" @click.prevent="enableCamera()"
> >
<i class="fa fa-camera"></i> <i class="fa fa-camera"></i>
{% trans %}Enable camera{% endtrans %} {% trans %}Enable camera{% endtrans %}
</button> </button>
<button <button
x-show="is_camera_enabled" x-show="isCameraEnabled"
class="btn btn-blue" class="btn btn-blue"
@click.prevent="take_picture()" @click.prevent="takePicture()"
> >
<i class="fa fa-camera"></i> <i class="fa fa-camera"></i>
{% trans %}Take a picture{% endtrans %} {% trans %}Take a picture{% endtrans %}
@ -54,7 +54,7 @@
{%- if form[field_name] -%} {%- if form[field_name] -%}
<div> <div>
{{ form[field_name] }} {{ 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)) -%} {%- if not (this_picture and this_picture.is_owned_by(user)) -%}
:disabled="!picture" :disabled="!picture"
{%- endif -%} {%- endif -%}
@ -86,7 +86,7 @@
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data( Alpine.data(
"camera_{{ field_name }}", "camera_{{ field_name }}",
alpine_webcam_builder( alpineWebcamBuilder(
{{ default_picture }}, {{ default_picture }},
{{ delete_url }}, {{ delete_url }},
{{ (this_picture and this_picture.is_owned_by(user))|tojson }} {{ (this_picture and this_picture.is_owned_by(user))|tojson }}

View File

@ -30,21 +30,21 @@
</label> </label>
<span class="depth-choice"> <span class="depth-choice">
<button <button
@click="godfathers_depth--" @click="godfathersDepth--"
:disabled="godfathers_depth <= {{ depth_min }}" :disabled="godfathersDepth <= {{ depth_min }}"
><i class="fa fa-minus"></i></button> ><i class="fa fa-minus"></i></button>
<input <input
x-model="godfathers_depth" x-model="godfathersDepth"
x-ref="godfather_depth_input" x-ref="godfather_depth_input"
type="number" type="number"
name="godfathers_depth" name="godfathersDepth"
id="godfather-depth-input" id="godfather-depth-input"
min="{{ depth_min }}" min="{{ depth_min }}"
max="{{ depth_max }}" max="{{ depth_max }}"
/> />
<button <button
@click="godfathers_depth++" @click="godfathersDepth++"
:disabled="godfathers_depth >= {{ depth_max }}" :disabled="godfathersDepth >= {{ depth_max }}"
><i class="fa fa-plus" ><i class="fa fa-plus"
></i></button> ></i></button>
</span> </span>
@ -56,22 +56,22 @@
</label> </label>
<span class="depth-choice"> <span class="depth-choice">
<button <button
@click="godchildren_depth--" @click="godchildrenDepth--"
:disabled="godchildren_depth <= {{ depth_min }}" :disabled="godchildrenDepth <= {{ depth_min }}"
><i ><i
class="fa fa-minus" class="fa fa-minus"
></i></button> ></i></button>
<input <input
x-model="godchildren_depth" x-model="godchildrenDepth"
type="number" type="number"
name="godchildren_depth" name="godchildrenDepth"
id="godchild-depth-input" id="godchild-depth-input"
min="{{ depth_min }}" min="{{ depth_min }}"
max="{{ depth_max }}" max="{{ depth_max }}"
/> />
<button <button
@click="godchildren_depth++" @click="godchildrenDepth++"
:disabled="godchildren_depth >= {{ depth_max }}" :disabled="godchildrenDepth >= {{ depth_max }}"
><i class="fa fa-plus" ><i class="fa fa-plus"
></i></button> ></i></button>
</span> </span>
@ -96,10 +96,10 @@
</div> </div>
<script> <script>
const api_url = "{{ api_url }}"; const apiUrl = "{{ api_url }}";
const active_user = "{{ object.id }}" const activeUser = "{{ object.id }}"
const depth_min = {{ depth_min }}; const depthMin = {{ depth_min }};
const depth_max = {{ depth_max }}; const depthMax = {{ depth_max }};
</script> </script>
{% endblock %} {% endblock %}

View File

@ -21,14 +21,14 @@
{% if user.id == object.id %} {% if user.id == object.id %}
<div x-show="pictures.length > 0" x-cloak> <div x-show="pictures.length > 0" x-cloak>
<button <button
:disabled="is_downloading" :disabled="isDownloading"
class="btn btn-blue" class="btn btn-blue"
@click="download_zip()" @click="downloadZip()"
> >
<i class="fa fa-download"></i> <i class="fa fa-download"></i>
{% trans %}Download all my pictures{% endtrans %} {% trans %}Download all my pictures{% endtrans %}
</button> </button>
<progress x-ref="progress" x-show="is_downloading"></progress> <progress x-ref="progress" x-show="isDownloading"></progress>
</div> </div>
{% endif %} {% endif %}
@ -92,13 +92,13 @@
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("user_pictures", () => ({ Alpine.data("user_pictures", () => ({
is_downloading: false, isDownloading: false,
loading: true, loading: true,
pictures: [], pictures: [],
albums: {}, albums: {},
async init() { 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) => { this.albums = this.pictures.reduce((acc, picture) => {
if (!acc[picture.album]){ if (!acc[picture.album]){
acc[picture.album] = []; acc[picture.album] = [];
@ -109,8 +109,8 @@
this.loading = false; this.loading = false;
}, },
async download_zip(){ async downloadZip(){
this.is_downloading = true; this.isDownloading = true;
const bar = this.$refs.progress; const bar = this.$refs.progress;
bar.value = 0; bar.value = 0;
bar.max = this.pictures.length; bar.max = this.pictures.length;
@ -124,16 +124,16 @@
const zipWriter = new zip.ZipWriter(await fileHandle.createWritable()); const zipWriter = new zip.ZipWriter(await fileHandle.createWritable());
await Promise.all(this.pictures.map(p => { 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( return zipWriter.add(
img_name, imgName,
new zip.HttpReader(p.full_size_url), new zip.HttpReader(p.full_size_url),
{level: 9, lastModDate: new Date(p.date), onstart: () => bar.value += 1} {level: 9, lastModDate: new Date(p.date), onstart: () => bar.value += 1}
); );
})); }));
await zipWriter.close(); await zipWriter.close();
this.is_downloading = false; this.isDownloading = false;
} }
})) }))
}); });

View File

@ -6,7 +6,7 @@
<link rel="stylesheet" type="text/css" href="{{ statics.css }}" defer> <link rel="stylesheet" type="text/css" href="{{ statics.css }}" defer>
<script type="text/javascript"> <script type="text/javascript">
addEventListener("DOMContentLoaded", (event) => { addEventListener("DOMContentLoaded", (event) => {
easymde_factory( easymdeFactory(
document.getElementById("{{ widget.attrs.id }}"), document.getElementById("{{ widget.attrs.id }}"),
"{{ markdown_api_url }}", "{{ markdown_api_url }}",
); );

View File

@ -1,77 +1,86 @@
document.addEventListener('alpine:init', () => { document.addEventListener("alpine:init", () => {
Alpine.data('counter', () => ({ Alpine.data("counter", () => ({
basket: basket, // biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja
errors: [], basket: sessionBasket,
errors: [],
sum_basket() { sumBasket() {
if (!this.basket || Object.keys(this.basket).length === 0) { if (!this.basket || Object.keys(this.basket).length === 0) {
return 0; return 0;
} }
const total = Object.values(this.basket) const total = Object.values(this.basket).reduce(
.reduce((acc, cur) => acc + cur["qty"] * cur["price"], 0); (acc, cur) => acc + cur.qty * cur.price,
return total / 100; 0,
);
return total / 100;
},
async handleCode(event) {
const code = $(event.target).find("#code_field").val().toUpperCase();
if (["FIN", "ANN"].includes(code)) {
$(event.target).submit();
} else {
await this.handleAction(event);
}
},
async handleAction(event) {
const payload = $(event.target).serialize();
// 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",
// biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja
"X-CSRFToken": csrfToken,
}, },
});
const response = await fetch(request);
const json = await response.json();
this.basket = json.basket;
this.errors = json.errors;
$("form.code_form #code_field").val("").focus();
},
}));
});
async handle_code(event) { $(() => {
const code = $(event.target).find("#code_field").val().toUpperCase(); /* Autocompletion in the code field */
if(["FIN", "ANN"].includes(code)) { const codeField = $("#code_field");
$(event.target).submit();
} else {
await this.handle_action(event);
}
},
async handle_action(event) { let quantity = "";
const payload = $(event.target).serialize(); codeField.autocomplete({
let request = new Request(click_api_url, { select: (event, ui) => {
method: "POST", event.preventDefault();
body: payload, codeField.val(quantity + ui.item.value);
headers: { },
'Accept': 'application/json', focus: (event, ui) => {
'X-CSRFToken': csrf_token, event.preventDefault();
} codeField.val(quantity + ui.item.value);
}) },
const response = await fetch(request); source: (request, response) => {
const json = await response.json(); // biome-ignore lint/performance/useTopLevelRegex: performance impact is minimal
this.basket = json["basket"] const res = /^(\d+x)?(.*)/i.exec(request.term);
this.errors = json["errors"] quantity = res[1] || "";
$('form.code_form #code_field').val("").focus(); const search = res[2];
} const matcher = new RegExp($.ui.autocomplete.escapeRegex(search), "i");
})) response(
}) // biome-ignore lint/correctness/noUndeclaredVariables: defined in counter_click.jinja
$.grep(productsAutocomplete, (value) => {
return matcher.test(value.tags);
}),
);
},
});
$(function () { /* Accordion UI between basket and refills */
/* Autocompletion in the code field */ $("#click_form").accordion({
const code_field = $("#code_field"); heightStyle: "content",
activate: () => $(".focus").focus(),
});
$("#products").tabs();
let quantity = ""; codeField.focus();
code_field.autocomplete({ });
select: function (event, ui) {
event.preventDefault();
code_field.val(quantity + ui.item.value);
},
focus: function (event, ui) {
event.preventDefault();
code_field.val(quantity + ui.item.value);
},
source: function (request, response) {
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, function(value) {
value = value.tags;
return matcher.test( value );
}));
},
});
/* Accordion UI between basket and refills */
$("#click_form").accordion({
heightStyle: "content",
activate: () => $(".focus").focus(),
});
$("#products").tabs();
code_field.focus();
});

View File

@ -59,7 +59,7 @@
{# Formulaire pour rechercher un produit en tapant son code dans une barre de recherche #} {# Formulaire pour rechercher un produit en tapant son code dans une barre de recherche #}
<form method="post" action="" <form method="post" action=""
class="code_form" @submit.prevent="handle_code"> class="code_form" @submit.prevent="handleCode">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="code"> <input type="hidden" name="action" value="code">
<label for="code_field"></label> <label for="code_field"></label>
@ -77,7 +77,7 @@
<template x-for="[id, item] in Object.entries(basket)" :key="id"> <template x-for="[id, item] in Object.entries(basket)" :key="id">
<div> <div>
<form method="post" action="" class="inline del_product_form" <form method="post" action="" class="inline del_product_form"
@submit.prevent="handle_action"> @submit.prevent="handleAction">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="del_product"> <input type="hidden" name="action" value="del_product">
<input type="hidden" name="product_id" :value="id"> <input type="hidden" name="product_id" :value="id">
@ -87,7 +87,7 @@
<span x-text="item['qty'] + item['bonus_qty']"></span> <span x-text="item['qty'] + item['bonus_qty']"></span>
<form method="post" action="" class="inline add_product_form" <form method="post" action="" class="inline add_product_form"
@submit.prevent="handle_action"> @submit.prevent="handleAction">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="add_product"> <input type="hidden" name="action" value="add_product">
<input type="hidden" name="product_id" :value="id"> <input type="hidden" name="product_id" :value="id">
@ -104,7 +104,7 @@
</ul> </ul>
<p> <p>
<strong>Total: </strong> <strong>Total: </strong>
<strong x-text="sum_basket().toLocaleString(undefined, { minimumFractionDigits: 2 })"></strong> <strong x-text="sumBasket().toLocaleString(undefined, { minimumFractionDigits: 2 })"></strong>
<strong> €</strong> <strong> €</strong>
</p> </p>
@ -147,7 +147,7 @@
{% for p in categories[category] -%} {% for p in categories[category] -%}
<form method="post" <form method="post"
action="{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}" 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 %} {% csrf_token %}
<input type="hidden" name="action" value="add_product"> <input type="hidden" name="action" value="add_product">
<input type="hidden" name="product_id" value="{{ p.id }}"> <input type="hidden" name="product_id" value="{{ p.id }}">
@ -171,9 +171,9 @@
{% block script %} {% block script %}
{{ super() }} {{ super() }}
<script> <script>
const csrf_token = "{{ csrf_token }}"; const csrfToken = "{{ csrf_token }}";
const click_api_url = "{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}"; const clickApiUrl = "{{ url('counter:click', counter_id=counter.id, user_id=customer.user.id) }}";
const basket = {{ request.session["basket"]|tojson }}; const sessionBasket = {{ request.session["basket"]|tojson }};
const products = { const products = {
{%- for p in products -%} {%- for p in products -%}
{{ p.id }}: { {{ p.id }}: {
@ -183,7 +183,7 @@
}, },
{%- endfor -%} {%- endfor -%}
}; };
const products_autocomplete = [ const productsAutocomplete = [
{% for p in products -%} {% for p in products -%}
{ {
value: "{{ p.code }}", value: "{{ p.code }}",

View File

@ -152,16 +152,24 @@ En ce qui concerne les templates Jinja
et les fichiers SCSS, la norme de formatage et les fichiers SCSS, la norme de formatage
est celle par défaut de `djHTML`. est celle par défaut de `djHTML`.
Pour Javascript, vous pouvez utiliser Pour Javascript, nous utilisons [biome](https://biomejs.dev/).
Prettier, avec sa configuration par défaut, C'est à la fois un formateur et un linter avec très peu de configuration,
qui est plutôt bonne, un peu comme ruff.
mais nous n'avons pas de norme établie pour le projet.
!!!note "Le javascript dans les templates jinja"
Biome n'est pas capable de lire dans les fichiers jinja,
c'est sa principale limitation.
Il est donc recommandé d'éviter de mettre trop de Javascript
directement dans jinja mais de préférer des fichiers dédiés.
### Qualité du code ### Qualité du code
Pour s'assurer de la qualité du code, Ruff est également utilisé. Pour s'assurer de la qualité du code, Ruff et Biome sont également utilisés.
Tout comme pour le format, Ruff doit tourner avant chaque commit. Tout comme pour le format, Ruff et Biome doivent tourner avant chaque commit.
!!!note "to edit or not to edit" !!!note "to edit or not to edit"
@ -182,6 +190,14 @@ Tout comme pour le format, Ruff doit tourner avant chaque commit.
ruff check --fix ruff check --fix
``` ```
Biome se comporte d'une manière très similaire
```bash
npx @biomejs/biome check # Liste toutes les erreurs et leurs catégories
npx @biomejs/biome check --write # Applique tous les fix considérés safe et formate le code
```
## Documentation ## Documentation
La documentation est écrite en markdown, avec les fonctionnalités La documentation est écrite en markdown, avec les fonctionnalités

View File

@ -354,16 +354,27 @@ Bien installé, il peut effectuer ce travail
à chaque sauvegarde d'un fichier dans son éditeur, à chaque sauvegarde d'un fichier dans son éditeur,
ce qui est très agréable pour travailler. ce qui est très agréable pour travailler.
### Biome
[Site officiel](https://biomejs.dev/)
Puisque Ruff ne fonctionne malheureusement que pour le Python,
nous utilisons Biome pour le javascript.
Biome est également capable d'analyser et formater les fichiers json et css.
Tout comme Ruff, Biome fait office de formateur et de linter.
### DjHTML ### DjHTML
[Site officiel](https://github.com/rtts/djhtml) [Site officiel](https://github.com/rtts/djhtml)
Ruff permet de formater les fichiers Python, Ruff permet de formater les fichiers Python et Biome les fichiers js,
mais il ne formatte pas les templates et les feuilles de style. mais ils ne formattent pas les templates et les feuilles de style.
Pour ça, il faut un autre outil, aisément intégrable Pour ça, il faut un autre outil, aisément intégrable
dans la CI : `djHTML`. dans la CI : `djHTML`.
En utilisant conjointement Ruff et djHTML, En utilisant conjointement Ruff, Biome et djHTML,
on arrive donc à la fois à formater les fichiers on arrive donc à la fois à formater les fichiers
Python et les fichiers relatifs au frontend. Python et les fichiers relatifs au frontend.

View File

@ -1,3 +1,3 @@
.mermaid { .mermaid {
text-align: center; text-align: center;
} }

View File

@ -99,52 +99,116 @@ votre éditeur pour que Ruff fasse son travail automatiquement à chaque éditio
Nous tenterons de vous faire ici un résumé pour deux éditeurs de textes populaires Nous tenterons de vous faire ici un résumé pour deux éditeurs de textes populaires
que sont VsCode et Sublime Text. que sont VsCode et Sublime Text.
### VsCode === "VsCode"
Installez l'extension Ruff pour VsCode. Installez l'extension Ruff pour VsCode.
Ensuite, ajoutez ceci dans votre configuration : Ensuite, ajoutez ceci dans votre configuration :
```json ```json
{ {
"[python]": { "[python]": {
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.defaultFormatter": "charliermarsh.ruff" "editor.defaultFormatter": "charliermarsh.ruff"
}
} }
} ```
```
### Sublime Text === "Sublime Text"
Vous devez installer ce plugin : https://packagecontrol.io/packages/LSP-ruff. Vous devez installer le plugin [LSP-ruff](https://packagecontrol.io/packages/LSP-ruff).
Suivez ensuite les instructions données dans la description du plugin. Suivez ensuite les instructions données dans la description du plugin.
Dans la configuration de votre projet, ajoutez ceci: Dans la configuration de votre projet, ajoutez ceci:
```json ```json
{ {
"settings": { "settings": {
"lsp_format_on_save": true, "lsp_format_on_save": true, // Commun à ruff et biome
"LSP": { "LSP": {
"LSP-ruff": { "LSP-ruff": {
"enabled": true, "enabled": true,
}
} }
} }
} }
} ```
Si vous utilisez le plugin [anaconda](http://damnwidget.github.io/anaconda/),
pensez à modifier les paramètres du linter pep8
pour éviter de recevoir des warnings dans le formatage
de ruff comme ceci :
```json
{
"pep8_ignore": [
"E203",
"E266",
"E501",
"W503"
]
}
```
## Configurer Biome pour son éditeur
!!!note
Biome est inclus dans les dépendances du projet.
Si vous avez réussi à terminer l'installation, vous n'avez donc pas de configuration
supplémentaire à effectuer.
Pour utiliser Biome, placez-vous à la racine du projet et lancer la commande suivante:
```bash
npx @biomejs/biome check # Pour checker le code avec le linter et le formater
npx @biomejs/biome check --write # Pour appliquer les changemnts
``` ```
Si vous utilisez le plugin [anaconda](http://damnwidget.github.io/anaconda/), Biome va alors faire son travail sur l'ensemble du projet puis vous dire
pensez à modifier les paramètres du linter pep8 si des documents ont été reformatés (si vous avez fait `npx @biomejs/biome format --write`)
pour éviter de recevoir des warnings dans le formatage ou bien s'il y a des erreurs à réparer (si vous avez faire `npx @biomejs/biome lint`) ou les deux (si vous avez fait `npx @biomejs/biome check --write`).
de ruff comme ceci :
```json Appeler Biome en ligne de commandes avant de pousser votre code sur Github
{ est une technique qui marche très bien.
"pep8_ignore": [ Cependant, vous risquez de souvent l'oublier.
"E203", Or, lorsque le code ne respecte pas les Biomes de qualité,
"E266", la pipeline bloque les PR sur les branches protégées.
"E501",
"W503" Pour éviter de vous faire régulièrement avoir, vous pouvez configurer
] votre éditeur pour que Biome fasse son travail automatiquement à chaque édition d'un fichier.
} Nous tenterons de vous faire ici un résumé pour deux éditeurs de textes populaires
``` que sont VsCode et Sublime Text.
=== "VsCode"
Biome est fourni par le plugin [Biome](https://marketplace.visualstudio.com/items?itemName=biomejs.biome).
Ensuite, ajoutez ceci dans votre configuration :
```json
{
"editor.defaultFormatter": "<other formatter>",
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
```
=== "Sublime Text"
Tout comme pour ruff, il suffit d'installer un plugin lsp [LSP-biome](https://packagecontrol.io/packages/LSP-biome).
Et enfin, dans la configuration de votre projet, ajouter les lignes suivantes :
```json
{
"settings": {
"lsp_format_on_save": true, // Commun à ruff et biome
"LSP": {
"LSP-biome": {
"enabled": true,
}
}
}
}
```

View File

@ -1,241 +1,245 @@
#eboutic { #eboutic {
display: flex; display: flex;
flex-direction: row-reverse; flex-direction: row-reverse;
align-items: flex-start; align-items: flex-start;
column-gap: 20px; column-gap: 20px;
margin: 0 20px 20px; margin: 0 20px 20px;
} }
#eboutic-title { #eboutic-title {
margin-left: 20px; margin-left: 20px;
} }
#eboutic h3 { #eboutic h3 {
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
} }
#basket { #basket {
min-width: 300px; min-width: 300px;
border-radius: 8px; border-radius: 8px;
box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 4px 8px 3px; box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 4px 8px 3px;
padding: 10px; padding: 10px;
} }
#basket h3 { #basket h3 {
margin-top: 0; margin-top: 0;
} }
@media screen and (max-width: 765px) { @media screen and (max-width: 765px) {
#eboutic { #eboutic {
flex-direction: column-reverse; flex-direction: column-reverse;
align-items: center; align-items: center;
margin: 10px; margin: 10px;
row-gap: 20px; row-gap: 20px;
} }
#eboutic-title { #eboutic-title {
margin-bottom: 20px; margin-bottom: 20px;
margin-top: 4px; margin-top: 4px;
} }
#basket { #basket {
width: -webkit-fill-available; width: -webkit-fill-available;
} }
} }
#eboutic .item-list { #eboutic .item-list {
margin-left: 0; margin-left: 0;
list-style: none; list-style: none;
} }
#eboutic .item-list li { #eboutic .item-list li {
display: flex; display: flex;
align-items: center; align-items: center;
margin-bottom: 10px margin-bottom: 10px;
} }
#eboutic .item-row { #eboutic .item-row {
gap: 10px; gap: 10px;
} }
#eboutic .item-name { #eboutic .item-name {
word-break: break-word; word-break: break-word;
width: 100%; width: 100%;
line-height: 100%; line-height: 100%;
} }
#eboutic .fa-plus, #eboutic .fa-plus,
#eboutic .fa-minus { #eboutic .fa-minus {
cursor: pointer; cursor: pointer;
background-color: #354a5f; background-color: #354a5f;
color: white; color: white;
border-radius: 50%; border-radius: 50%;
padding: 5px; padding: 5px;
font-size: 10px; font-size: 10px;
line-height: 10px; line-height: 10px;
width: 10px; width: 10px;
text-align: center; text-align: center;
} }
#eboutic .item-quantity { #eboutic .item-quantity {
min-width: 65px; min-width: 65px;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
display: flex; display: flex;
gap: 5px; gap: 5px;
} }
#eboutic .item-price { #eboutic .item-price {
min-width: 65px; min-width: 65px;
text-align: right; text-align: right;
} }
/* CSS du catalogue */ /* CSS du catalogue */
#eboutic #catalog { #eboutic #catalog {
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
flex-direction: column; flex-direction: column;
row-gap: 30px; row-gap: 30px;
} }
#eboutic .category-header { #eboutic .category-header {
margin-bottom: 15px; margin-bottom: 15px;
} }
#eboutic .product-group { #eboutic .product-group {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
column-gap: 15px; column-gap: 15px;
row-gap: 15px; row-gap: 15px;
} }
#eboutic .product-button { #eboutic .product-button {
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
min-height: 180px; min-height: 180px;
height: fit-content; height: fit-content;
width: 150px; width: 150px;
padding: 15px; padding: 15px;
overflow: hidden; overflow: hidden;
box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 4px 8px 3px; box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 4px 8px 3px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
row-gap: 5px; row-gap: 5px;
justify-content: flex-start; justify-content: flex-start;
} }
#eboutic .product-button.selected { #eboutic .product-button.selected {
animation: bg-in-out 1s ease; animation: bg-in-out 1s ease;
background-color: rgb(216, 236, 255); background-color: rgb(216, 236, 255);
} }
#eboutic .product-button.selected::after { #eboutic .product-button.selected::after {
content: "🛒"; content: "🛒";
position: absolute; position: absolute;
top: 5px; top: 5px;
right: 5px; right: 5px;
padding: 5px; padding: 5px;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 12px 2px rgb(0 0 0 / 14%); box-shadow: 0 0 12px 2px rgb(0 0 0 / 14%);
background-color: white; background-color: white;
width: 20px; width: 20px;
height: 20px; height: 20px;
font-size: 16px; font-size: 16px;
line-height: 20px; line-height: 20px;
} }
#eboutic .product-button:active { #eboutic .product-button:active {
box-shadow: none; box-shadow: none;
} }
#eboutic .product-image { #eboutic .product-image {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 70px; min-height: 70px;
max-height: 70px; max-height: 70px;
object-fit: contain; object-fit: contain;
border-radius: 4px; border-radius: 4px;
line-height: 70px; line-height: 70px;
margin-bottom: 15px; margin-bottom: 15px;
} }
#eboutic i.product-image { #eboutic i.product-image {
background-color: rgba(173, 173, 173, 0.2); background-color: rgba(173, 173, 173, 0.2);
} }
#eboutic .product-description h4 { #eboutic .product-description h4 {
font-size: .75em; font-size: .75em;
word-break: break-word; word-break: break-word;
margin: 0 0 5px 0; margin: 0 0 5px 0;
} }
#eboutic .product-button p { #eboutic .product-button p {
font-size: 13px; font-size: 13px;
margin: 0; margin: 0;
} }
#eboutic .catalog-buttons { #eboutic .catalog-buttons {
display: flex; display: flex;
justify-content: center; justify-content: center;
column-gap: 30px; column-gap: 30px;
margin: 30px 0 0; margin: 30px 0 0;
} }
#eboutic input { #eboutic input {
all: unset; all: unset;
} }
#eboutic .catalog-buttons button { #eboutic .catalog-buttons button {
min-width: 60px; min-width: 60px;
} }
#eboutic .catalog-buttons form { #eboutic .catalog-buttons form {
margin: 0; margin: 0;
} }
@media screen and (max-width: 765px) { @media screen and (max-width: 765px) {
#eboutic #catalog { #eboutic #catalog {
row-gap: 15px; row-gap: 15px;
width: 100%; width: 100%;
} }
#eboutic section { #eboutic section {
text-align: center; text-align: center;
} }
#eboutic .product-group { #eboutic .product-group {
justify-content: space-around; justify-content: space-around;
flex-direction: column; flex-direction: column;
} }
#eboutic .product-group .product-button { #eboutic .product-group .product-button {
min-height: 100px; min-height: 100px;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 10px; gap: 10px;
} }
#eboutic .product-group .product-description { #eboutic .product-group .product-description {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
width: 100%; width: 100%;
} }
#eboutic .product-description h4 { #eboutic .product-description h4 {
text-align: left; text-align: left;
max-width: 90%; max-width: 90%;
} }
#eboutic .product-image { #eboutic .product-image {
margin-bottom: 0; margin-bottom: 0;
max-width: 70px; max-width: 70px;
} }
} }
@keyframes bg-in-out { @keyframes bg-in-out {
0% { background-color: white; } 0% {
100% { background-color: rgb(216, 236, 255); } background-color: white;
} }
100% {
background-color: rgb(216, 236, 255);
}
}

View File

@ -14,131 +14,134 @@ 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 * @returns {string|null|undefined} the value of the cookie or null if it does not exist, undefined if not found
*/ */
function getCookie(name) { function getCookie(name) {
if (!document.cookie || document.cookie.length === 0) return null; // biome-ignore lint/style/useBlockStatements: <explanation>
if (!document.cookie || document.cookie.length === 0) return null;
let found = document.cookie const found = document.cookie
.split(';') .split(";")
.map(c => c.trim()) .map((c) => c.trim())
.find(c => c.startsWith(name + '=')); .find((c) => c.startsWith(`${name}=`));
return found === undefined ? undefined : decodeURIComponent(found.split('=')[1]); return found === undefined ? undefined : decodeURIComponent(found.split("=")[1]);
} }
/** /**
* Fetch the basket items from the associated cookie * Fetch the basket items from the associated cookie
* @returns {BasketItem[]|[]} the items in the basket * @returns {BasketItem[]|[]} the items in the basket
*/ */
function get_starting_items() { function getStartingItems() {
const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME); const cookie = getCookie(BASKET_ITEMS_COOKIE_NAME);
if (!cookie) { if (!cookie) {
return [] return [];
} }
// Django cookie backend converts `,` to `\054` // Django cookie backend converts `,` to `\054`
let parsed = JSON.parse(cookie.replace(/\\054/g, ',')); let parsed = JSON.parse(cookie.replace(/\\054/g, ","));
if (typeof parsed === "string") { if (typeof parsed === "string") {
// In some conditions, a second parsing is needed // In some conditions, a second parsing is needed
parsed = JSON.parse(parsed); parsed = JSON.parse(parsed);
} }
const res = Array.isArray(parsed) ? parsed : []; const res = Array.isArray(parsed) ? parsed : [];
return res.filter((i) => !!document.getElementById(i.id)) return res.filter((i) => !!document.getElementById(i.id));
} }
document.addEventListener('alpine:init', () => { document.addEventListener("alpine:init", () => {
Alpine.data('basket', () => ({ Alpine.data("basket", () => ({
items: get_starting_items(), items: getStartingItems(),
/** /**
* Get the total price of the basket * Get the total price of the basket
* @returns {number} The total price of the basket * @returns {number} The total price of the basket
*/ */
get_total() { getTotal() {
return this.items return this.items.reduce((acc, item) => acc + item.quantity * item.unit_price, 0);
.reduce((acc, item) => acc + item["quantity"] * item["unit_price"], 0); },
},
/** /**
* Add 1 to the quantity of an item in the basket * Add 1 to the quantity of an item in the basket
* @param {BasketItem} item * @param {BasketItem} item
*/ */
add(item) { add(item) {
item.quantity++; item.quantity++;
this.set_cookies(); this.setCookies();
}, },
/** /**
* Remove 1 to the quantity of an item in the basket * Remove 1 to the quantity of an item in the basket
* @param {BasketItem} item_id * @param {BasketItem} item_id
*/ */
remove(item_id) { remove(itemId) {
const index = this.items.findIndex(e => e.id === item_id); const index = this.items.findIndex((e) => e.id === itemId);
if (index < 0) return; if (index < 0) {
this.items[index].quantity -= 1; return;
}
this.items[index].quantity -= 1;
if (this.items[index].quantity === 0) { if (this.items[index].quantity === 0) {
this.items = this.items.filter((e) => e.id !== this.items[index].id); 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 * Remove all the items from the basket & cleans the catalog CSS classes
*/ */
clear_basket() { clearBasket() {
this.items = []; this.items = [];
this.set_cookies(); this.setCookies();
}, },
/** /**
* Set the cookie in the browser with the basket items * Set the cookie in the browser with the basket items
* ! the cookie survives an hour * ! the cookie survives an hour
*/ */
set_cookies() { setCookies() {
if (this.items.length === 0) { if (this.items.length === 0) {
document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=;Max-Age=0`; document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=;Max-Age=0`;
} else { } else {
document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(this.items))};Max-Age=3600`; document.cookie = `${BASKET_ITEMS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(this.items))};Max-Age=3600`;
} }
}, },
/** /**
* Create an item in the basket if it was not already in * Create an item in the basket if it was not already in
* @param {number} id The id of the product to add * @param {number} id The id of the product to add
* @param {string} name The name of the product * @param {string} name The name of the product
* @param {number} price The unit price of the product * @param {number} price The unit price of the product
* @returns {BasketItem} The created item * @returns {BasketItem} The created item
*/ */
create_item(id, name, price) { createItem(id, name, price) {
let new_item = { const newItem = {
id: id, id,
name: name, name,
quantity: 0, quantity: 0,
unit_price: price // biome-ignore lint/style/useNamingConvention: used by django backend
}; unit_price: price,
};
this.items.push(new_item); this.items.push(newItem);
this.add(new_item); this.add(newItem);
return new_item; return newItem;
}, },
/** /**
* Add an item to the basket. * Add an item to the basket.
* This is called when the user click on a button in the catalog * This is called when the user click on a button in the catalog
* @param {number} id The id of the product to add * @param {number} id The id of the product to add
* @param {string} name The name of the product * @param {string} name The name of the product
* @param {number} price The unit price 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) let item = this.items.find((e) => e.id === id);
// if the item is not in the basket, we create it // if the item is not in the basket, we create it
// else we add + 1 to it // else we add + 1 to it
if (!item) { if (item) {
item = this.create_item(id, name, price); this.add(item);
} else { } else {
this.add(item); item = this.createItem(id, name, price);
} }
}, },
})) }));
}) });

View File

@ -3,77 +3,85 @@
* @enum {number} * @enum {number}
*/ */
const BillingInfoReqState = { const BillingInfoReqState = {
SUCCESS: 1, // biome-ignore lint/style/useNamingConvention: this feels more like an enum
FAILURE: 2, SUCCESS: 1,
SENDING: 3, // 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", () => { document.addEventListener("alpine:init", () => {
Alpine.store("billing_inputs", { Alpine.store("billing_inputs", {
data: et_data, // biome-ignore lint/correctness/noUndeclaredVariables: defined in eboutic_makecommand.jinja
data: etData,
async fill() { async fill() {
document.getElementById("bank-submit-button").disabled = true; document.getElementById("bank-submit-button").disabled = true;
const res = await fetch(et_data_url); // biome-ignore lint/correctness/noUndeclaredVariables: defined in eboutic_makecommand.jinja
if (res.ok) { const res = await fetch(etDataUrl);
this.data = await res.json(); if (res.ok) {
document.getElementById("bank-submit-button").disabled = false; this.data = await res.json();
} document.getElementById("bank-submit-button").disabled = false;
}, }
}); },
});
Alpine.data("billing_infos", () => ({ Alpine.data("billing_infos", () => ({
/** @type {BillingInfoReqState | null} */ /** @type {BillingInfoReqState | null} */
req_state: null, reqState: null,
async send_form() { async sendForm() {
this.req_state = BillingInfoReqState.SENDING; this.reqState = BillingInfoReqState.SENDING;
const form = document.getElementById("billing_info_form"); const form = document.getElementById("billing_info_form");
document.getElementById("bank-submit-button").disabled = true; document.getElementById("bank-submit-button").disabled = true;
let payload = Object.fromEntries( const payload = Object.fromEntries(
Array.from(form.querySelectorAll("input, select")) Array.from(form.querySelectorAll("input, select"))
.filter((elem) => elem.type !== "submit" && elem.value) .filter((elem) => elem.type !== "submit" && elem.value)
.map((elem) => [elem.name, 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
method: "PUT", const res = await fetch(billingInfoUrl, {
body: JSON.stringify(payload), method: "PUT",
}); body: JSON.stringify(payload),
this.req_state = res.ok });
? BillingInfoReqState.SUCCESS this.reqState = res.ok
: BillingInfoReqState.FAILURE; ? BillingInfoReqState.SUCCESS
if (res.status === 422) { : BillingInfoReqState.FAILURE;
const errors = (await res.json())["detail"].map((err) => err["loc"]).flat(); if (res.status === 422) {
Array.from(form.querySelectorAll("input")) const errors = (await res.json()).detail.flatMap((err) => err.loc);
.filter((elem) => errors.includes(elem.name)) for (const elem of Array.from(form.querySelectorAll("input")).filter((elem) =>
.forEach((elem) => { errors.includes(elem.name),
elem.setCustomValidity(gettext("Incorrect value")); )) {
elem.reportValidity(); elem.setCustomValidity(gettext("Incorrect value"));
elem.oninput = () => elem.setCustomValidity(""); elem.reportValidity();
}); elem.oninput = () => elem.setCustomValidity("");
} else if (res.ok) { }
Alpine.store("billing_inputs").fill(); } else if (res.ok) {
} Alpine.store("billing_inputs").fill();
}, }
},
get_alert_color() { getAlertColor() {
if (this.req_state === BillingInfoReqState.SUCCESS) { if (this.reqState === BillingInfoReqState.SUCCESS) {
return "green"; return "green";
} }
if (this.req_state === BillingInfoReqState.FAILURE) { if (this.reqState === BillingInfoReqState.FAILURE) {
return "red"; return "red";
} }
return ""; return "";
}, },
get_alert_message() { getAlertMessage() {
if (this.req_state === BillingInfoReqState.SUCCESS) { if (this.reqState === BillingInfoReqState.SUCCESS) {
return billing_info_success_message; // 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 ""; return billingInfoFailureMessage;
}, }
})); return "";
},
}));
}); });

View File

@ -56,11 +56,11 @@
{# Total price #} {# Total price #}
<li style="margin-top: 20px"> <li style="margin-top: 20px">
<span class="item-name"><strong>{% trans %}Basket amount: {% endtrans %}</strong></span> <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> </li>
</ul> </ul>
<div class="catalog-buttons"> <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> <i class="fa fa-trash"></i>
{% trans %}Clear{% endtrans %} {% trans %}Clear{% endtrans %}
</button> </button>
@ -106,7 +106,7 @@
id="{{ p.id }}" id="{{ p.id }}"
class="product-button" class="product-button"
:class="{selected: items.some((i) => i.id === {{ p.id }})}" :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 %} {% if p.icon %}
<img class="product-image" src="{{ p.icon.url }}" <img class="product-image" src="{{ p.icon.url }}"

View File

@ -56,7 +56,7 @@
<div <div
class="collapse" class="collapse"
:class="{'shadow': collapsed}" :class="{'shadow': collapsed}"
x-data="{collapsed: !billing_info_exist}" x-data="{collapsed: !billingInfoExist}"
x-cloak x-cloak
> >
<div class="collapse-header clickable" @click="collapsed = !collapsed"> <div class="collapse-header clickable" @click="collapsed = !collapsed">
@ -73,27 +73,27 @@
x-data="billing_infos" x-data="billing_infos"
x-show="collapsed" x-show="collapsed"
x-transition.scale.origin.top x-transition.scale.origin.top
@submit.prevent="await send_form()" @submit.prevent="await sendForm()"
> >
{% csrf_token %} {% csrf_token %}
{{ billing_form }} {{ billing_form }}
<br> <br>
<br> <br>
<div <div
x-show="[BillingInfoReqState.SUCCESS, BillingInfoReqState.FAILURE].includes(req_state)" x-show="[BillingInfoReqState.SUCCESS, BillingInfoReqState.FAILURE].includes(reqState)"
class="alert" class="alert"
:class="'alert-' + get_alert_color()" :class="'alert-' + getAlertColor()"
x-transition x-transition
> >
<div class="alert-main" x-text="get_alert_message()"></div> <div class="alert-main" x-text="getAlertMessage()"></div>
<div class="clickable" @click="req_state = null"> <div class="clickable" @click="reqState = null">
<i class="fa fa-close"></i> <i class="fa fa-close"></i>
</div> </div>
</div> </div>
<input <input
type="submit" class="btn btn-blue clickable" type="submit" class="btn btn-blue clickable"
value="{% trans %}Validate{% endtrans %}" value="{% trans %}Validate{% endtrans %}"
:disabled="req_state === BillingInfoReqState.SENDING" :disabled="reqState === BillingInfoReqState.SENDING"
> >
</form> </form>
</div> </div>
@ -141,16 +141,16 @@
{% block script %} {% block script %}
<script> <script>
const billing_info_url = '{{ url("api:put_billing_info", user_id=request.user.id) }}'; const billingInfoUrl = '{{ url("api:put_billing_info", user_id=request.user.id) }}';
const et_data_url = '{{ url("api:etransaction_data") }}'; const etDataUrl = '{{ url("api:etransaction_data") }}';
const billing_info_exist = {{ "true" if billing_infos else "false" }}; const billingInfoExist = {{ "true" if billing_infos else "false" }};
const billing_info_success_message = "{% trans %}Billing info registration success{% endtrans %}"; const billingInfoSuccessMessage = "{% trans %}Billing info registration success{% endtrans %}";
const billing_info_failure_message = "{% trans %}Billing info registration failure{% endtrans %}"; const billingInfoFailureMessage = "{% trans %}Billing info registration failure{% endtrans %}";
{% if billing_infos %} {% if billing_infos %}
const et_data = {{ billing_infos|safe }} const etData = {{ billing_infos|safe }}
{% else %} {% else %}
const et_data = {} const etData = {}
{% endif %} {% endif %}
</script> </script>
{{ super() }} {{ super() }}

File diff suppressed because one or more lines are too long

11224
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,39 +1,39 @@
{ {
"name": "sith", "name": "sith",
"version": "3", "version": "3",
"description": "Le web Sith de l'AE", "description": "Le web Sith de l'AE",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"compile": "webpack --mode production", "compile": "webpack --mode production",
"compile-dev": "webpack --mode development", "compile-dev": "webpack --mode development",
"serve": "webpack --mode development --watch" "serve": "webpack --mode development --watch",
}, "check": "biome check --write"
"keywords": [], },
"author": "", "keywords": [],
"license": "GPL-3.0-only", "author": "",
"sideEffects": [ "license": "GPL-3.0-only",
".css" "sideEffects": [".css"],
], "devDependencies": {
"devDependencies": { "@babel/core": "^7.25.2",
"@babel/core": "^7.25.2", "@babel/preset-env": "^7.25.4",
"@babel/preset-env": "^7.25.4", "@biomejs/biome": "1.9.3",
"babel-loader": "^9.2.1", "babel-loader": "^9.2.1",
"expose-loader": "^5.0.0", "css-loader": "^7.1.2",
"mini-css-extract-plugin": "^2.9.1", "css-minimizer-webpack-plugin": "^7.0.0",
"source-map-loader": "^5.0.0", "expose-loader": "^5.0.0",
"terser-webpack-plugin": "^5.3.10", "mini-css-extract-plugin": "^2.9.1",
"webpack": "^5.94.0", "source-map-loader": "^5.0.0",
"webpack-cli": "^5.1.4", "terser-webpack-plugin": "^5.3.10",
"css-loader": "^7.1.2", "webpack": "^5.94.0",
"css-minimizer-webpack-plugin": "^7.0.0" "webpack-cli": "^5.1.4"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^6.6.0",
"alpinejs": "^3.14.1", "alpinejs": "^3.14.1",
"easymde": "^2.18.0", "easymde": "^2.18.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"jquery-ui": "^1.14.0", "jquery-ui": "^1.14.0",
"jquery.shorten": "^1.0.0" "jquery.shorten": "^1.0.0"
} }
} }

View File

@ -125,14 +125,14 @@
then fetch the corresponding data from the API. then fetch the corresponding data from the API.
This data will then be displayed on the result part of the page. This data will then be displayed on the result part of the page.
#} #}
const page_default = 1; const pageDefault = 1;
const page_size_default = 100; const pageSizeDefault = 100;
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("uv_search", () => ({ Alpine.data("uv_search", () => ({
uvs: [], uvs: [],
loading: false, loading: false,
page: page_default, page: pageDefault,
page_size: page_size_default, pageSize: pageSizeDefault,
search: "", search: "",
department: [], department: [],
credit_type: [], credit_type: [],
@ -142,12 +142,12 @@
update: undefined, update: undefined,
async initialize_args() { async initializeArgs() {
let url = new URLSearchParams(window.location.search); let url = new URLSearchParams(window.location.search);
this.pushstate = History.REPLACE; this.pushstate = History.REPLACE;
this.page = parseInt(url.get("page")) || page_default;; this.page = parseInt(url.get("page")) || pageDefault;;
this.page_size = parseInt(url.get("page_size")) || page_size_default; this.pageSize = parseInt(url.get("pageSize")) || pageSizeDefault;
this.search = url.get("search") || ""; this.search = url.get("search") || "";
this.department = url.getAll("department"); this.department = url.getAll("department");
this.credit_type = url.getAll("credit_type"); this.credit_type = url.getAll("credit_type");
@ -164,18 +164,18 @@
this.update = Alpine.debounce(async () => { this.update = Alpine.debounce(async () => {
{# Create the whole url before changing everything all at once #} {# Create the whole url before changing everything all at once #}
let first = this.to_change.shift(); 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) => { 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); updateQueryString(first.param, first.value, this.pushstate, url);
await this.fetch_data(); {# reload data on form change #} await this.fetchData(); {# reload data on form change #}
this.to_change = []; this.to_change = [];
this.pushstate = History.PUSH; this.pushstate = History.PUSH;
}, 50); }, 50);
let search_params = ["search", "department", "credit_type", "semester"]; let search_params = ["search", "department", "credit_type", "semester"];
let pagination_params = ["page", "page_size"]; let pagination_params = ["page", "pageSize"];
search_params.forEach((param) => { search_params.forEach((param) => {
this.$watch(param, async (value) => { this.$watch(param, async (value) => {
@ -184,8 +184,8 @@
return; return;
} }
{# Reset pagination on search #} {# Reset pagination on search #}
this.page = page_default; this.page = pageDefault;
this.page_size = page_size_default; this.pageSize = pageSizeDefault;
}); });
}); });
search_params.concat(pagination_params).forEach((param) => { search_params.concat(pagination_params).forEach((param) => {
@ -195,13 +195,13 @@
}); });
}); });
window.addEventListener("popstate", async (event) => { 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; this.loading = true;
const url = "{{ url("api:fetch_uvs") }}" + window.location.search; const url = "{{ url("api:fetch_uvs") }}" + window.location.search;
this.uvs = await (await fetch(url)).json(); this.uvs = await (await fetch(url)).json();
@ -209,7 +209,7 @@
}, },
max_page() { max_page() {
return Math.ceil(this.uvs.count / this.page_size); return Math.ceil(this.uvs.count / this.pageSize);
} }
})) }))
}) })

View File

@ -10,8 +10,8 @@
*/ */
class PictureWithIdentifications { class PictureWithIdentifications {
identifications = null; identifications = null;
image_loading = false; imageLoading = false;
identifications_loading = false; identificationsLoading = false;
/** /**
* @param {Picture} picture * @param {Picture} picture
@ -19,10 +19,11 @@ class PictureWithIdentifications {
constructor(picture) { constructor(picture) {
Object.assign(this, picture); Object.assign(this, picture);
} }
/** /**
* @param {Picture} picture * @param {Picture} picture
*/ */
static from_picture(picture) { static fromPicture(picture) {
return new PictureWithIdentifications(picture); return new PictureWithIdentifications(picture);
} }
@ -32,19 +33,19 @@ class PictureWithIdentifications {
* @param {?Object=} options * @param {?Object=} options
* @return {Promise<void>} * @return {Promise<void>}
*/ */
async load_identifications(options) { async loadIdentifications(options) {
if (this.identifications_loading) { if (this.identificationsLoading) {
return; // The users are already being fetched. return; // The users are already being fetched.
} }
if (!!this.identifications && !options?.force_reload) { if (!!this.identifications && !options?.forceReload) {
// The users are already fetched // The users are already fetched
// and the user does not want to force the reload // and the user does not want to force the reload
return; return;
} }
this.identifications_loading = true; this.identificationsLoading = true;
const url = `/api/sas/picture/${this.id}/identified`; const url = `/api/sas/picture/${this.id}/identified`;
this.identifications = await (await fetch(url)).json(); this.identifications = await (await fetch(url)).json();
this.identifications_loading = false; this.identificationsLoading = false;
} }
/** /**
@ -55,12 +56,12 @@ class PictureWithIdentifications {
const img = new Image(); const img = new Image();
img.src = this.compressed_url; img.src = this.compressed_url;
if (!img.complete) { if (!img.complete) {
this.image_loading = true; this.imageLoading = true;
img.addEventListener("load", () => { img.addEventListener("load", () => {
this.image_loading = false; this.imageLoading = false;
}); });
} }
await this.load_identifications(); await this.loadIdentifications();
} }
} }
@ -77,13 +78,18 @@ document.addEventListener("alpine:init", () => {
* when loading the page at the beginning * when loading the page at the beginning
* @type PictureWithIdentifications * @type PictureWithIdentifications
**/ **/
current_picture: { currentPicture: {
// biome-ignore lint/style/useNamingConvention: json is snake_case
is_moderated: true, is_moderated: true,
id: null, id: null,
name: "", name: "",
// biome-ignore lint/style/useNamingConvention: json is snake_case
display_name: "", display_name: "",
// biome-ignore lint/style/useNamingConvention: json is snake_case
compressed_url: "", compressed_url: "",
// biome-ignore lint/style/useNamingConvention: json is snake_case
profile_url: "", profile_url: "",
// biome-ignore lint/style/useNamingConvention: json is snake_case
full_size_url: "", full_size_url: "",
owner: "", owner: "",
date: new Date(), date: new Date(),
@ -93,12 +99,12 @@ document.addEventListener("alpine:init", () => {
* The picture which will be displayed next if the user press the "next" button * The picture which will be displayed next if the user press the "next" button
* @type ?PictureWithIdentifications * @type ?PictureWithIdentifications
**/ **/
next_picture: null, nextPicture: null,
/** /**
* The picture which will be displayed next if the user press the "previous" button * The picture which will be displayed next if the user press the "previous" button
* @type ?PictureWithIdentifications * @type ?PictureWithIdentifications
**/ **/
previous_picture: null, previousPicture: null,
/** /**
* The select2 component used to identify users * The select2 component used to identify users
**/ **/
@ -110,7 +116,7 @@ document.addEventListener("alpine:init", () => {
* Error message when a moderation operation fails * Error message when a moderation operation fails
* @type string * @type string
**/ **/
moderation_error: "", moderationError: "",
/** /**
* Method of pushing new url to the browser history * Method of pushing new url to the browser history
* Used by popstate event and always reset to it's default value when used * Used by popstate event and always reset to it's default value when used
@ -119,121 +125,119 @@ document.addEventListener("alpine:init", () => {
pushstate: History.PUSH, pushstate: History.PUSH,
async init() { async init() {
this.pictures = (await fetch_paginated(picture_endpoint)).map( // biome-ignore lint/correctness/noUndeclaredVariables: Imported from script.js
PictureWithIdentifications.from_picture, this.pictures = (await fetchPaginated(pictureEndpoint)).map(
PictureWithIdentifications.fromPicture,
); );
// biome-ignore lint/correctness/noUndeclaredVariables: Imported from script.js
this.selector = sithSelect2({ this.selector = sithSelect2({
element: $(this.$refs.search), 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: () => [ excluded: () => [
...(this.current_picture.identifications || []).map( ...(this.currentPicture.identifications || []).map((i) => i.user.id),
(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( // biome-ignore lint/correctness/noUndeclaredVariables: Imported from picture.jinja
(i) => i.id === first_picture_id, this.currentPicture = this.pictures.find((i) => i.id === firstPictureId);
); this.$watch("currentPicture", (current, previous) => {
this.$watch("current_picture", (current, previous) => { if (current === previous) {
if (current === previous){ /* Avoid recursive updates */ /* Avoid recursive updates */
return; return;
} }
this.update_picture(); this.updatePicture();
}); });
window.addEventListener("popstate", async (event) => { window.addEventListener("popstate", async (event) => {
if (!event.state || event.state.sas_picture_id === undefined) { if (!event.state || event.state.sasPictureId === undefined) {
return; return;
} }
this.pushstate = History.REPLACE; this.pushstate = History.REPLACE;
this.current_picture = this.pictures.find( this.currentPicture = this.pictures.find(
(i) => i.id === parseInt(event.state.sas_picture_id), (i) => i.id === Number.parseInt(event.state.sasPictureId),
); );
}); });
this.pushstate = History.REPLACE; /* Avoid first url push */ this.pushstate = History.REPLACE; /* Avoid first url push */
await this.update_picture(); await this.updatePicture();
}, },
/** /**
* Update the page. * Update the page.
* Called when the `current_picture` property changes. * Called when the `currentPicture` property changes.
* *
* The url is modified without reloading the page, * The url is modified without reloading the page,
* and the previous picture, the next picture and * and the previous picture, the next picture and
* the list of identified users are updated. * the list of identified users are updated.
*/ */
async update_picture() { async updatePicture() {
const update_args = [ const updateArgs = [
{ sas_picture_id: this.current_picture.id }, { sasPictureId: this.currentPicture.id },
"", "",
`/sas/picture/${this.current_picture.id}/`, `/sas/picture/${this.currentPicture.id}/`,
]; ];
if (this.pushstate === History.REPLACE) { if (this.pushstate === History.REPLACE) {
window.history.replaceState(...update_args); window.history.replaceState(...updateArgs);
this.pushstate = History.PUSH; this.pushstate = History.PUSH;
} else { } else {
window.history.pushState(...update_args); window.history.pushState(...updateArgs);
} }
this.moderation_error = ""; this.moderationError = "";
const index = this.pictures.indexOf(this.current_picture); const index = this.pictures.indexOf(this.currentPicture);
this.previous_picture = this.pictures[index - 1] || null; this.previousPicture = this.pictures[index - 1] || null;
this.next_picture = this.pictures[index + 1] || null; this.nextPicture = this.pictures[index + 1] || null;
await this.current_picture.load_identifications(); await this.currentPicture.loadIdentifications();
this.$refs.main_picture?.addEventListener("load", () => { this.$refs.mainPicture?.addEventListener("load", () => {
// once the current picture is loaded, // once the current picture is loaded,
// start preloading the next and previous pictures // start preloading the next and previous pictures
this.next_picture?.preload(); this.nextPicture?.preload();
this.previous_picture?.preload(); this.previousPicture?.preload();
}); });
}, },
async moderate_picture() { async moderatePicture() {
const res = await fetch( const res = await fetch(`/api/sas/picture/${this.currentPicture.id}/moderate`, {
`/api/sas/picture/${this.current_picture.id}/moderate`, method: "PATCH",
{ });
method: "PATCH",
},
);
if (!res.ok) { if (!res.ok) {
this.moderation_error = `${gettext("Couldn't moderate picture")} : ${res.statusText}`; this.moderationError = `${gettext("Couldn't moderate picture")} : ${res.statusText}`;
return; return;
} }
this.current_picture.is_moderated = true; this.currentPicture.is_moderated = true;
this.current_picture.asked_for_removal = false; this.currentPicture.askedForRemoval = false;
}, },
async delete_picture() { async deletePicture() {
const res = await fetch(`/api/sas/picture/${this.current_picture.id}`, { const res = await fetch(`/api/sas/picture/${this.currentPicture.id}`, {
method: "DELETE", method: "DELETE",
}); });
if (!res.ok) { if (!res.ok) {
this.moderation_error = this.moderationError = `${gettext("Couldn't delete picture")} : ${res.statusText}`;
gettext("Couldn't delete picture") + " : " + res.statusText;
return; 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) { if (this.pictures.length === 0) {
// The deleted picture was the only one in the list. // The deleted picture was the only one in the list.
// As the album is now empty, go back to the parent page // 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. * Send the identification request and update the list of identified users.
*/ */
async submit_identification() { async submitIdentification() {
const url = `/api/sas/picture/${this.current_picture.id}/identified`; const url = `/api/sas/picture/${this.currentPicture.id}/identified`;
await fetch(url, { await fetch(url, {
method: "PUT", method: "PUT",
body: JSON.stringify(this.selector.val().map((i) => parseInt(i))), body: JSON.stringify(this.selector.val().map((i) => Number.parseInt(i))),
}); });
// refresh the identified users list // 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"); this.selector.empty().trigger("change");
}, },
@ -242,23 +246,22 @@ document.addEventListener("alpine:init", () => {
* @param {PictureIdentification} identification * @param {PictureIdentification} identification
* @return {boolean} * @return {boolean}
*/ */
can_be_removed(identification) { canBeRemoved(identification) {
return user_is_sas_admin || identification.user.id === user_id; // biome-ignore lint/correctness/noUndeclaredVariables: imported from picture.jinja
return userIsSasAdmin || identification.user.id === userId;
}, },
/** /**
* Untag a user from the current picture * Untag a user from the current picture
* @param {PictureIdentification} identification * @param {PictureIdentification} identification
*/ */
async remove_identification(identification) { async removeIdentification(identification) {
const res = await fetch(`/api/sas/relation/${identification.id}`, { const res = await fetch(`/api/sas/relation/${identification.id}`, {
method: "DELETE", method: "DELETE",
}); });
if (res.ok && Array.isArray(this.current_picture.identifications)) { if (res.ok && Array.isArray(this.currentPicture.identifications)) {
this.current_picture.identifications = this.currentPicture.identifications =
this.current_picture.identifications.filter( this.currentPicture.identifications.filter((i) => i.id !== identification.id);
(i) => i.id !== identification.id,
);
} }
}, },
})); }));

View File

@ -83,7 +83,7 @@
</a> </a>
</template> </template>
</div> </div>
{{ paginate_alpine("page", "nb_pages()") }} {{ paginate_alpine("page", "nbPages()") }}
</div> </div>
{% if is_sas_admin %} {% if is_sas_admin %}
@ -116,14 +116,14 @@
loading: false, loading: false,
async init() { async init() {
await this.fetch_pictures(); await this.fetchPictures();
this.$watch("page", () => { this.$watch("page", () => {
update_query_string("page", updateQueryString("page",
this.page === 1 ? null : this.page, this.page === 1 ? null : this.page,
this.pushstate this.pushstate
); );
this.pushstate = History.PUSH; this.pushstate = History.PUSH;
this.fetch_pictures(); this.fetchPictures();
}); });
window.addEventListener("popstate", () => { window.addEventListener("popstate", () => {
@ -134,7 +134,7 @@
}); });
}, },
async fetch_pictures() { async fetchPictures() {
this.loading=true; this.loading=true;
const url = "{{ url("api:pictures") }}" const url = "{{ url("api:pictures") }}"
+"?album_id={{ album.id }}" +"?album_id={{ album.id }}"
@ -144,7 +144,7 @@
this.loading = false; this.loading = false;
}, },
nb_pages() { nbPages() {
return Math.ceil(this.pictures.count / {{ settings.SITH_SAS_IMAGES_PER_PAGE }}); return Math.ceil(this.pictures.count / {{ settings.SITH_SAS_IMAGES_PER_PAGE }});
} }
})) }))

View File

@ -17,21 +17,21 @@
{% block content %} {% block content %}
<main x-data="picture_viewer"> <main x-data="picture_viewer">
<code> <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> </code>
<br> <br>
<div class="title"> <div class="title">
<h3 x-text="current_picture.name"></h3> <h3 x-text="currentPicture.name"></h3>
<h4 x-text="`${pictures.indexOf(current_picture) + 1 } / ${pictures.length}`"></h4> <h4 x-text="`${pictures.indexOf(currentPicture) + 1 } / ${pictures.length}`"></h4>
</div> </div>
<br> <br>
<template x-if="!current_picture.is_moderated"> <template x-if="!currentPicture.is_moderated">
<div class="alert alert-red"> <div class="alert alert-red">
<div class="alert-main"> <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> <span class="important">{% trans %}Asked for removal{% endtrans %}</span>
</template> </template>
<p> <p>
@ -43,14 +43,14 @@
</div> </div>
<div> <div>
<div> <div>
<button class="btn btn-blue" @click="moderate_picture()"> <button class="btn btn-blue" @click="moderatePicture()">
{% trans %}Moderate{% endtrans %} {% trans %}Moderate{% endtrans %}
</button> </button>
<button class="btn btn-red" @click.prevent="delete_picture()"> <button class="btn btn-red" @click.prevent="deletePicture()">
{% trans %}Delete{% endtrans %} {% trans %}Delete{% endtrans %}
</button> </button>
</div> </div>
<p x-show="!!moderation_error" x-text="moderation_error"></p> <p x-show="!!moderationError" x-text="moderationError"></p>
</div> </div>
</div> </div>
</template> </template>
@ -58,12 +58,12 @@
<div class="container" id="pict"> <div class="container" id="pict">
<div class="main"> <div class="main">
<div class="photo" :aria-busy="current_picture.image_loading"> <div class="photo" :aria-busy="currentPicture.imageLoading">
<img <img
:src="current_picture.compressed_url" :src="currentPicture.compressed_url"
:alt="current_picture.name" :alt="currentPicture.name"
id="main-picture" id="main-picture"
x-ref="main_picture" x-ref="mainPicture"
/> />
</div> </div>
@ -76,13 +76,13 @@
<span <span
x-text="Intl.DateTimeFormat( x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'} '{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
).format(new Date(current_picture.date))" ).format(new Date(currentPicture.date))"
> >
</span> </span>
</div> </div>
<div> <div>
<span>{% trans %}Owner: {% endtrans %}</span> <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> </div>
</div> </div>
@ -91,14 +91,14 @@
<h5>{% trans %}Tools{% endtrans %}</h5> <h5>{% trans %}Tools{% endtrans %}</h5>
<div> <div>
<div> <div>
<a class="text" :href="current_picture.full_size_url"> <a class="text" :href="currentPicture.full_size_url">
{% trans %}HD version{% endtrans %} {% trans %}HD version{% endtrans %}
</a> </a>
<br> <br>
<a class="text danger" href="?ask_removal">{% trans %}Ask for removal{% endtrans %}</a> <a class="text danger" href="?ask_removal">{% trans %}Ask for removal{% endtrans %}</a>
</div> </div>
<div class="buttons"> <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_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> <a class="button" href="?rotate_right"><i class="fa-solid fa-rotate-right"></i></a>
</div> </div>
@ -110,23 +110,23 @@
<div class="subsection"> <div class="subsection">
<div class="navigation"> <div class="navigation">
<div id="prev" class="clickable"> <div id="prev" class="clickable">
<template x-if="previous_picture"> <template x-if="previousPicture">
<div <div
@keyup.left.window="current_picture = previous_picture" @keyup.left.window="currentPicture = previousPicture"
@click="current_picture = previous_picture" @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 class="overlay">←</div>
</div> </div>
</template> </template>
</div> </div>
<div id="next" class="clickable"> <div id="next" class="clickable">
<template x-if="next_picture"> <template x-if="nextPicture">
<div <div
@keyup.right.window="current_picture = next_picture" @keyup.right.window="currentPicture = nextPicture"
@click="current_picture = next_picture" @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 class="overlay">→</div>
</div> </div>
</template> </template>
@ -136,14 +136,14 @@
<div class="tags"> <div class="tags">
<h5>{% trans %}People{% endtrans %}</h5> <h5>{% trans %}People{% endtrans %}</h5>
{% if user.was_subscribed %} {% 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> <select x-ref="search" multiple="multiple"></select>
<input type="submit" value="{% trans %}Go{% endtrans %}"/> <input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form> </form>
{% endif %} {% endif %}
<ul> <ul>
<template <template
x-for="identification in (current_picture.identifications || [])" x-for="identification in (currentPicture.identifications || [])"
:key="identification.id" :key="identification.id"
> >
<li> <li>
@ -151,12 +151,12 @@
<img class="profile-pic" :src="identification.user.profile_pict" alt="image de profil"/> <img class="profile-pic" :src="identification.user.profile_pict" alt="image de profil"/>
<span x-text="identification.user.display_name"></span> <span x-text="identification.user.display_name"></span>
</a> </a>
<template x-if="can_be_removed(identification)"> <template x-if="canBeRemoved(identification)">
<a class="delete clickable" @click="remove_identification(identification)"><i class="fa fa-times fa-xl delete-action"></i></a> <a class="delete clickable" @click="removeIdentification(identification)"><i class="fa fa-times fa-xl delete-action"></i></a>
</template> </template>
</li> </li>
</template> </template>
<template x-if="current_picture.identifications_loading"> <template x-if="currentPicture.identificationsLoading">
{# shadow element that exists only to put the loading wheel below {# shadow element that exists only to put the loading wheel below
the list of identified people #} the list of identified people #}
<li class="loader" aria-busy="true"></li> <li class="loader" aria-busy="true"></li>
@ -171,10 +171,10 @@
{% block script %} {% block script %}
{{ super() }} {{ super() }}
<script> <script>
const picture_endpoint = "{{ url("api:pictures") + "?album_id=" + album.id|string }}"; const pictureEndpoint = "{{ url("api:pictures") + "?album_id=" + album.id|string }}";
const album_url = "{{ album.get_absolute_url() }}"; const albumUrl = "{{ album.get_absolute_url() }}";
const first_picture_id = {{ picture.id }}; {# id of the first picture to show after page load #} const firstPictureId = {{ picture.id }}; {# id of the first picture to show after page load #}
const user_id = {{ user.id }}; const userId = {{ user.id }};
const user_is_sas_admin = {{ (user.is_root or user.is_in_group(pk = settings.SITH_GROUP_SAS_ADMIN_ID))|tojson }} const userIsSasAdmin = {{ (user.is_root or user.is_in_group(pk = settings.SITH_GROUP_SAS_ADMIN_ID))|tojson }}
</script> </script>
{% endblock %} {% endblock %}

View File

@ -1,23 +1,21 @@
const glob = require('glob'); const glob = require("glob");
const path = require('path'); // biome-ignore lint/correctness/noNodejsModules: this is backend side
const webpack = require("webpack"); const path = require("node:path");
const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const TerserPlugin = require("terser-webpack-plugin"); const TerserPlugin = require("terser-webpack-plugin");
module.exports = { module.exports = {
entry: glob.sync('./!(static)/static/webpack/**?(-)index.js').reduce((obj, el) => { entry: glob.sync("./!(static)/static/webpack/**?(-)index.js").reduce((obj, el) => {
obj[path.parse(el).name] = './' + el; obj[path.parse(el).name] = `./${el}`;
return obj; return obj;
}, {}), }, {}),
output: { output: {
filename: '[name].js', filename: "[name].js",
path: path.resolve(__dirname, './staticfiles/generated/webpack'), path: path.resolve(__dirname, "./staticfiles/generated/webpack"),
clean: true clean: true,
}, },
plugins: [ plugins: [new MiniCssExtractPlugin()],
new MiniCssExtractPlugin(),
],
optimization: { optimization: {
minimizer: [ minimizer: [
"...", "...",
@ -29,9 +27,10 @@ module.exports = {
terserOptions: { terserOptions: {
mangle: true, mangle: true,
compress: { compress: {
// biome-ignore lint/style/useNamingConvention: this is how the underlying library wants it
drop_console: true, drop_console: true,
}, },
} },
}), }),
], ],
}, },
@ -40,14 +39,11 @@ module.exports = {
{ {
test: /\.css$/, test: /\.css$/,
sideEffects: true, sideEffects: true,
use: [ use: [MiniCssExtractPlugin.loader, "css-loader"],
MiniCssExtractPlugin.loader,
"css-loader",
],
}, },
{ {
test: /\.(jpe?g|png|gif|woff|woff2|eot|ttf|otf)$/i, test: /\.(jpe?g|png|gif)$/i,
type: 'asset/resource' type: "asset/resource",
}, },
{ {
test: /\.m?js$/, test: /\.m?js$/,
@ -55,9 +51,9 @@ module.exports = {
use: { use: {
loader: "babel-loader", loader: "babel-loader",
options: { options: {
presets: ['@babel/preset-env'] presets: ["@babel/preset-env"],
} },
} },
}, },
{ {
test: /\.js$/, test: /\.js$/,
@ -70,17 +66,17 @@ module.exports = {
options: { options: {
exposes: [ exposes: [
{ {
globalName: ['$'], globalName: ["$"],
override: true override: true,
}, },
{ {
globalName: ['jQuery'], globalName: ["jQuery"],
override: true override: true,
}, },
{ {
globalName: ['window.jQuery'], globalName: ["window.jQuery"],
override: true override: true,
} },
], ],
}, },
}, },