Go for a more generic js bundling architecture

* Don't tie the output name to webpack itself
* Don't call js bundling webpack in python code
* Make the doc more generic about js bundling
This commit is contained in:
2024-11-18 15:36:05 +01:00
committed by Bartuccio Antoine
parent 3db1f592e2
commit 7b41051d0d
56 changed files with 73 additions and 73 deletions

View File

@ -0,0 +1,7 @@
import Alpine from "alpinejs";
window.Alpine = Alpine;
window.addEventListener("DOMContentLoaded", () => {
Alpine.start();
});

View File

@ -0,0 +1,183 @@
import { inheritHtmlElement } from "#core:utils/web-components";
import TomSelect from "tom-select";
import type {
RecursivePartial,
TomLoadCallback,
TomOption,
TomSettings,
} from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
static observedAttributes = [
"delay",
"placeholder",
"max",
"min-characters-for-search",
];
public widget: TomSelect;
protected minCharNumberForSearch = 0;
protected delay: number | null = null;
protected placeholder = "";
protected max: number | null = null;
protected attributeChangedCallback(
name: string,
_oldValue?: string,
newValue?: string,
) {
switch (name) {
case "delay": {
this.delay = Number.parseInt(newValue) ?? null;
break;
}
case "placeholder": {
this.placeholder = newValue ?? "";
break;
}
case "max": {
this.max = Number.parseInt(newValue) ?? null;
break;
}
case "min-characters-for-search": {
this.minCharNumberForSearch = Number.parseInt(newValue) ?? 0;
break;
}
default: {
return;
}
}
}
connectedCallback() {
super.connectedCallback();
this.widget = new TomSelect(this.node, this.tomSelectSettings());
this.attachBehaviors();
}
protected shouldLoad(query: string) {
return query.length >= this.minCharNumberForSearch; // Avoid launching search with less than setup number of characters
}
protected tomSelectSettings(): RecursivePartial<TomSettings> {
return {
plugins: {
// biome-ignore lint/style/useNamingConvention: this is required by the api
remove_button: {
title: gettext("Remove"),
},
},
persist: false,
maxItems: this.node.multiple ? this.max : 1,
closeAfterSelect: true,
loadThrottle: this.delay,
placeholder: this.placeholder,
shouldLoad: (query: string) => this.shouldLoad(query), // wraps the method to avoid shadowing `this` by the one from tom-select
render: {
option: (item: TomOption, sanitize: typeof escape_html) => {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.text)}</span>
</div>`;
},
item: (item: TomOption, sanitize: typeof escape_html) => {
return `<span>${sanitize(item.text)}</span>`;
},
// biome-ignore lint/style/useNamingConvention: that's how it's defined
not_loading: (data: TomOption, _sanitize: typeof escape_html) => {
return `<div class="no-results">${interpolate(gettext("You need to type %(number)s more characters"), { number: this.minCharNumberForSearch - data.input.length }, true)}</div>`;
},
// biome-ignore lint/style/useNamingConvention: that's how it's defined
no_results: (_data: TomOption, _sanitize: typeof escape_html) => {
return `<div class="no-results">${gettext("No results found")}</div>`;
},
},
};
}
protected attachBehaviors() {
/* Called once the widget has been initialized */
}
}
export abstract class AjaxSelect extends AutoCompleteSelectBase {
protected filter?: (items: TomOption[]) => TomOption[] = null;
protected minCharNumberForSearch = 2;
protected abstract valueField: string;
protected abstract labelField: string;
protected abstract searchField: string[];
protected abstract renderOption(
item: TomOption,
sanitize: typeof escape_html,
): string;
protected abstract renderItem(item: TomOption, sanitize: typeof escape_html): string;
protected abstract search(query: string): Promise<TomOption[]>;
private initialValues: TomOption[] = [];
public setFilter(filter?: (items: TomOption[]) => TomOption[]) {
this.filter = filter;
}
protected shouldLoad(query: string) {
const resp = super.shouldLoad(query);
/* Force order sync with backend if no client side filtering is set */
if (!resp && this.searchField.length === 0) {
this.widget.clearOptions();
}
return resp;
}
protected async loadFunction(query: string, callback: TomLoadCallback) {
/* Force order sync with backend if no client side filtering is set */
if (this.searchField.length === 0) {
this.widget.clearOptions();
}
const resp = await this.search(query);
if (this.filter) {
callback(this.filter(resp), []);
} else {
callback(resp, []);
}
}
protected tomSelectSettings(): RecursivePartial<TomSettings> {
return {
...super.tomSelectSettings(),
hideSelected: true,
diacritics: true,
duplicates: false,
valueField: this.valueField,
labelField: this.labelField,
searchField: this.searchField,
load: (query: string, callback: TomLoadCallback) =>
this.loadFunction(query, callback), // wraps the method to avoid shadowing `this` by the one from tom-select
render: {
...super.tomSelectSettings().render,
option: this.renderOption,
item: this.renderItem,
},
};
}
connectedCallback() {
/* Capture initial values before they get moved to the inner node and overridden by tom-select */
const initial = this.querySelector("slot[name='initial']")?.textContent;
this.initialValues = initial ? JSON.parse(initial) : [];
super.connectedCallback();
}
protected attachBehaviors() {
super.attachBehaviors();
// Gather selected options, they must be added with slots like `<slot>json</slot>`
for (const value of this.initialValues) {
this.widget.addOption(value, false);
this.widget.addItem(value[this.valueField]);
}
}
}

View File

@ -0,0 +1,100 @@
import "tom-select/dist/css/tom-select.default.css";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import {
type GroupSchema,
type SithFileSchema,
type UserProfileSchema,
groupSearchGroup,
sithfileSearchFiles,
userSearchUsers,
} from "#openapi";
import {
AjaxSelect,
AutoCompleteSelectBase,
} from "#core:core/components/ajax-select-base";
@registerComponent("autocomplete-select")
export class AutoCompleteSelect extends AutoCompleteSelectBase {}
@registerComponent("user-ajax-select")
export class UserAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "display_name";
protected searchField: string[] = []; // Disable local search filter and rely on tested backend
protected async search(query: string): Promise<TomOption[]> {
const resp = await userSearchUsers({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: UserProfileSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<img
src="${sanitize(item.profile_pict)}"
alt="${sanitize(item.display_name)}"
onerror="this.src = '/static/core/img/unknown.jpg'"
/>
<span class="select-item-text">${sanitize(item.display_name)}</span>
</div>`;
}
protected renderItem(item: UserProfileSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.display_name)}</span>`;
}
}
@registerComponent("group-ajax-select")
export class GroupsAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await groupSearchGroup({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: GroupSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.name)}</span>
</div>`;
}
protected renderItem(item: GroupSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.name)}</span>`;
}
}
@registerComponent("sith-file-ajax-select")
export class SithFileAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "path";
protected searchField = ["path", "name"];
protected async search(query: string): Promise<TomOption[]> {
const resp = await sithfileSearchFiles({ query: { search: query } });
if (resp.data) {
return resp.data.results;
}
return [];
}
protected renderOption(item: SithFileSchema, sanitize: typeof escape_html) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.path)}</span>
</div>`;
}
protected renderItem(item: SithFileSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.path)}</span>`;
}
}

View File

@ -0,0 +1,198 @@
// biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde
import "codemirror/lib/codemirror.css";
import "easymde/src/css/easymde.css";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
// biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE
import type CodeMirror from "codemirror";
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace
import EasyMDE from "easymde";
import { markdownRenderMarkdown } from "#openapi";
const loadEasyMde = (textarea: HTMLTextAreaElement) => {
new EasyMDE({
element: textarea,
spellChecker: false,
autoDownloadFontAwesome: false,
previewRender: (plainText: string, preview: MarkdownInput) => {
/* This is wrapped this way to allow time for Alpine to be loaded on the page */
return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
const func = async (
plainText: string,
preview: MarkdownInput,
): Promise<null> => {
preview.innerHTML = (
await markdownRenderMarkdown({ body: { text: plainText } })
).data as string;
return null;
};
func(plainText, preview);
return null;
}, 300)(plainText, preview);
},
forceSync: true, // Avoid validation error on generic create view
toolbar: [
{
name: "heading-smaller",
action: EasyMDE.toggleHeadingSmaller,
className: "fa fa-header",
title: gettext("Heading"),
},
{
name: "italic",
action: EasyMDE.toggleItalic,
className: "fa fa-italic",
title: gettext("Italic"),
},
{
name: "bold",
action: EasyMDE.toggleBold,
className: "fa fa-bold",
title: gettext("Bold"),
},
{
name: "strikethrough",
action: EasyMDE.toggleStrikethrough,
className: "fa fa-strikethrough",
title: gettext("Strikethrough"),
},
{
name: "underline",
action: function customFunction(editor: { codemirror: CodeMirror.Editor }) {
const cm = editor.codemirror;
cm.replaceSelection(`__${cm.getSelection()}__`);
},
className: "fa fa-underline",
title: gettext("Underline"),
},
{
name: "superscript",
action: function customFunction(editor: { codemirror: CodeMirror.Editor }) {
const cm = editor.codemirror;
cm.replaceSelection(`^${cm.getSelection()}^`);
},
className: "fa fa-superscript",
title: gettext("Superscript"),
},
{
name: "subscript",
action: function customFunction(editor: { codemirror: CodeMirror.Editor }) {
const cm = editor.codemirror;
cm.replaceSelection(`~${cm.getSelection()}~`);
},
className: "fa fa-subscript",
title: gettext("Subscript"),
},
{
name: "code",
action: EasyMDE.toggleCodeBlock,
className: "fa fa-code",
title: gettext("Code"),
},
"|",
{
name: "quote",
action: EasyMDE.toggleBlockquote,
className: "fa fa-quote-left",
title: gettext("Quote"),
},
{
name: "unordered-list",
action: EasyMDE.toggleUnorderedList,
className: "fa fa-list-ul",
title: gettext("Unordered list"),
},
{
name: "ordered-list",
action: EasyMDE.toggleOrderedList,
className: "fa fa-list-ol",
title: gettext("Ordered list"),
},
"|",
{
name: "link",
action: EasyMDE.drawLink,
className: "fa fa-link",
title: gettext("Insert link"),
},
{
name: "image",
action: EasyMDE.drawImage,
className: "fa-regular fa-image",
title: gettext("Insert image"),
},
{
name: "table",
action: EasyMDE.drawTable,
className: "fa fa-table",
title: gettext("Insert table"),
},
"|",
{
name: "clean-block",
action: EasyMDE.cleanBlock,
className: "fa fa-eraser fa-clean-block",
title: gettext("Clean block"),
},
"|",
{
name: "preview",
action: EasyMDE.togglePreview,
className: "fa fa-eye no-disable",
title: gettext("Toggle preview"),
},
{
name: "side-by-side",
action: EasyMDE.toggleSideBySide,
className: "fa fa-columns no-disable no-mobile",
title: gettext("Toggle side by side"),
},
{
name: "fullscreen",
action: EasyMDE.toggleFullScreen,
className: "fa fa-expand no-mobile",
title: gettext("Toggle fullscreen"),
},
"|",
{
name: "guide",
action: "/page/Aide_sur_la_syntaxe",
className: "fa fa-question-circle",
title: gettext("Markdown guide"),
},
],
});
const submits: HTMLInputElement[] = Array.from(
textarea.closest("form").querySelectorAll('input[type="submit"]'),
);
const parentDiv = textarea.parentElement.parentElement;
function checkMarkdownInput(event: Event) {
// an attribute is null if it does not exist, else a string
const required = textarea.getAttribute("required") != null;
const length = textarea.value.trim().length;
if (required && length === 0) {
parentDiv.style.boxShadow = "red 0px 0px 1.5px 1px";
event.preventDefault();
} else {
parentDiv.style.boxShadow = "";
}
}
function onSubmitClick(e: Event) {
checkMarkdownInput(e);
}
for (const submit of submits) {
submit.addEventListener("click", onSubmitClick);
}
};
@registerComponent("markdown-input")
class MarkdownInput extends inheritHtmlElement("textarea") {
connectedCallback() {
super.connectedCallback();
loadEasyMde(this.node);
}
}

View File

@ -0,0 +1,33 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
/**
* Web component used to import css files only once
* If called multiple times or the file was already imported, it does nothing
**/
@registerComponent("link-once")
export class LinkOnce extends inheritHtmlElement("link") {
connectedCallback() {
super.connectedCallback(false);
// We get href from node.attributes instead of node.href to avoid getting the domain part
const href = this.node.attributes.getNamedItem("href").nodeValue;
if (document.querySelectorAll(`link[href='${href}']`).length === 0) {
this.appendChild(this.node);
}
}
}
/**
* Web component used to import javascript files only once
* If called multiple times or the file was already imported, it does nothing
**/
@registerComponent("script-once")
export class ScriptOnce extends inheritHtmlElement("script") {
connectedCallback() {
super.connectedCallback(false);
// We get src from node.attributes instead of node.src to avoid getting the domain part
const src = this.node.attributes.getNamedItem("src").nodeValue;
if (document.querySelectorAll(`script[src='${src}']`).length === 0) {
this.appendChild(this.node);
}
}
}

View File

@ -0,0 +1,42 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
@registerComponent("nfc-input")
export class NfcInput extends inheritHtmlElement("input") {
connectedCallback() {
super.connectedCallback();
/* Disable feature if browser is not supported or if not HTTPS */
// biome-ignore lint/correctness/noUndeclaredVariables: browser API
if (typeof NDEFReader === "undefined") {
return;
}
const button = document.createElement("button");
const logo = document.createElement("i");
logo.classList.add("fa-brands", "fa-nfc-symbol");
button.setAttribute("type", "button"); // Prevent form submission on click
button.appendChild(logo);
button.addEventListener("click", async () => {
// biome-ignore lint/correctness/noUndeclaredVariables: browser API
const ndef = new NDEFReader();
this.setAttribute("scan", "active");
await ndef.scan();
ndef.addEventListener("readingerror", () => {
this.removeAttribute("scan");
window.alert(gettext("Unsupported NFC card"));
});
// biome-ignore lint/correctness/noUndeclaredVariables: browser API
ndef.addEventListener("reading", (event: NDEFReadingEvent) => {
this.removeAttribute("scan");
this.node.value = event.serialNumber.replace(/:/g, "").toUpperCase();
/* Auto submit form, we need another button to not trigger our previously defined click event */
const submit = document.createElement("button");
this.node.appendChild(submit);
submit.click();
submit.remove();
});
});
this.appendChild(button);
}
}

View File

@ -0,0 +1 @@
require("@fortawesome/fontawesome-free/css/all.css");

View File

@ -0,0 +1 @@
window.htmx = require("htmx.org");

25
core/static/bundled/jquery-index.js vendored Normal file
View File

@ -0,0 +1,25 @@
import $ from "jquery";
import "jquery.shorten/src/jquery.shorten.min.js";
// We ship jquery-ui with jquery because when standalone with webpack
// JQuery is also included in the jquery-ui package. We do gain space by doing this
// We require jquery-ui components manually and not in a loop
// Otherwise it increases the output files by a x2 factor !
require("jquery-ui/ui/widgets/accordion.js");
require("jquery-ui/ui/widgets/autocomplete.js");
require("jquery-ui/ui/widgets/button.js");
require("jquery-ui/ui/widgets/dialog.js");
require("jquery-ui/ui/widgets/tabs.js");
require("jquery-ui/themes/base/all.css");
/**
* Simple wrapper to solve shorten not being able on legacy pages
* @param {string} selector to be passed to jQuery
* @param {Object} options object to pass to the shorten function
**/
function shorten(selector, options) {
$(selector).shorten(options);
}
window.shorten = shorten;

View File

@ -0,0 +1,24 @@
import { exportToHtml } from "#core:utils/globals";
// biome-ignore lint/style/noNamespaceImport: this is the recommended way from the documentation
import * as Sentry from "@sentry/browser";
interface LoggedUser {
name: string;
email: string;
}
interface SentryOptions {
dsn: string;
eventId: string;
user?: LoggedUser;
}
exportToHtml("loadSentryPopup", (options: SentryOptions) => {
Sentry.init({
dsn: options.dsn,
});
Sentry.showReportDialog({
eventId: options.eventId,
...(options.user ?? {}),
});
});

106
core/static/bundled/types/web-nfc.d.ts vendored Normal file
View File

@ -0,0 +1,106 @@
// Type definitions for Web NFC
// Project: https://github.com/w3c/web-nfc
// Definitions by: Takefumi Yoshii <https://github.com/takefumi-yoshii>
// TypeScript Version: 3.9
// This type definitions referenced to WebIDL.
// https://w3c.github.io/web-nfc/#actual-idl-index
// This has been modified to not trigger biome linting
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
interface Window {
// biome-ignore lint/style/useNamingConvention: this is the official API name
NDEFMessage: NDEFMessage;
}
// biome-ignore lint/style/useNamingConvention: this is the official API name
declare class NDEFMessage {
constructor(messageInit: NDEFMessageInit);
records: readonly NDEFRecord[];
}
// biome-ignore lint/style/useNamingConvention: this is the official API name
declare interface NDEFMessageInit {
records: NDEFRecordInit[];
}
// biome-ignore lint/style/useNamingConvention: this is the official API name
declare type NDEFRecordDataSource = string | BufferSource | NDEFMessageInit;
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
interface Window {
// biome-ignore lint/style/useNamingConvention: this is the official API name
NDEFRecord: NDEFRecord;
}
// biome-ignore lint/style/useNamingConvention: this is the official API name
declare class NDEFRecord {
constructor(recordInit: NDEFRecordInit);
readonly recordType: string;
readonly mediaType?: string;
readonly id?: string;
readonly data?: DataView;
readonly encoding?: string;
readonly lang?: string;
toRecords?: () => NDEFRecord[];
}
// biome-ignore lint/style/useNamingConvention: this is the official API name
declare interface NDEFRecordInit {
recordType: string;
mediaType?: string;
id?: string;
encoding?: string;
lang?: string;
data?: NDEFRecordDataSource;
}
// biome-ignore lint/style/useNamingConvention: this is the official API name
declare type NDEFMessageSource = string | BufferSource | NDEFMessageInit;
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
interface Window {
// biome-ignore lint/style/useNamingConvention: this is the official API name
NDEFReader: NDEFReader;
}
// biome-ignore lint/style/useNamingConvention: this is the official API name
declare class NDEFReader extends EventTarget {
constructor();
// biome-ignore lint/suspicious/noExplicitAny: who am I to doubt the w3c definitions ?
onreading: (this: this, event: NDEFReadingEvent) => any;
// biome-ignore lint/suspicious/noExplicitAny: who am I to doubt the w3c definitions ?
onreadingerror: (this: this, error: Event) => any;
scan: (options?: NDEFScanOptions) => Promise<void>;
write: (message: NDEFMessageSource, options?: NDEFWriteOptions) => Promise<void>;
makeReadOnly: (options?: NDEFMakeReadOnlyOptions) => Promise<void>;
}
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
interface Window {
// biome-ignore lint/style/useNamingConvention: this is the official API name
NDEFReadingEvent: NDEFReadingEvent;
}
// biome-ignore lint/style/useNamingConvention: this is the official API name
declare class NDEFReadingEvent extends Event {
constructor(type: string, readingEventInitDict: NDEFReadingEventInit);
serialNumber: string;
message: NDEFMessage;
}
// biome-ignore lint/style/useNamingConvention: this is the official API name
interface NDEFReadingEventInit extends EventInit {
serialNumber?: string;
message: NDEFMessageInit;
}
// biome-ignore lint/style/useNamingConvention: this is the official API name
interface NDEFWriteOptions {
overwrite?: boolean;
signal?: AbortSignal;
}
// biome-ignore lint/style/useNamingConvention: this is the official API name
interface NDEFMakeReadOnlyOptions {
signal?: AbortSignal;
}
// biome-ignore lint/style/useNamingConvention: this is the official API name
interface NDEFScanOptions {
signal: AbortSignal;
}

View File

@ -0,0 +1,274 @@
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import cytoscape from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu";
import klay from "cytoscape-klay";
import { familyGetFamilyGraph } from "#openapi";
cytoscape.use(klay);
cytoscape.use(cxtmenu);
async function getGraphData(userId, godfathersDepth, godchildrenDepth) {
const data = (
await familyGetFamilyGraph({
path: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
user_id: userId,
},
query: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
godfathers_depth: godfathersDepth,
// biome-ignore lint/style/useNamingConvention: api is snake_case
godchildren_depth: godchildrenDepth,
},
})
).data;
return [
...data.users.map((user) => {
return { data: user };
}),
...data.relationships.map((rel) => {
return {
data: { source: rel.godfather, target: rel.godchild },
};
}),
];
}
function createGraph(container, data, activeUserId) {
const cy = cytoscape({
boxSelectionEnabled: false,
autounselectify: true,
container,
elements: data,
minZoom: 0.5,
style: [
// the stylesheet for the graph
{
selector: "node",
style: {
label: "data(display_name)",
"background-image": "data(profile_pict)",
width: "100%",
height: "100%",
"background-fit": "cover",
"background-repeat": "no-repeat",
shape: "ellipse",
},
},
{
selector: "edge",
style: {
width: 5,
"line-color": "#ccc",
"target-arrow-color": "#ccc",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
},
},
{
selector: ".traversed",
style: {
"border-width": "5px",
"border-style": "solid",
"border-color": "red",
"target-arrow-color": "red",
"line-color": "red",
},
},
{
selector: ".not-traversed",
style: {
"line-opacity": "0.5",
"background-opacity": "0.5",
"background-image-opacity": "0.5",
},
},
],
layout: {
name: "klay",
nodeDimensionsIncludeLabels: true,
fit: true,
klay: {
addUnnecessaryBendpoints: true,
direction: "DOWN",
nodePlacement: "INTERACTIVE",
layoutHierarchy: true,
},
},
});
const activeUser = cy.getElementById(activeUserId).style("shape", "rectangle");
/* Reset graph */
const resetGraph = () => {
cy.elements((element) => {
if (element.hasClass("traversed")) {
element.removeClass("traversed");
}
if (element.hasClass("not-traversed")) {
element.removeClass("not-traversed");
}
});
};
const onNodeTap = (el) => {
resetGraph();
/* Create path on graph if selected isn't the targeted user */
if (el === activeUser) {
return;
}
cy.elements((element) => {
element.addClass("not-traversed");
});
for (const traversed of cy.elements().aStar({
root: el,
goal: activeUser,
}).path) {
traversed.removeClass("not-traversed");
traversed.addClass("traversed");
}
};
cy.on("tap", "node", (tapped) => {
onNodeTap(tapped.target);
});
cy.zoomingEnabled(false);
/* Add context menu */
cy.cxtmenu({
selector: "node",
commands: [
{
content: '<i class="fa fa-external-link fa-2x"></i>',
select: (el) => {
window.open(el.data().profile_url, "_blank").focus();
},
},
{
content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
select: (el) => {
onNodeTap(el);
},
},
{
content: '<i class="fa fa-eraser fa-2x"></i>',
select: (_) => {
resetGraph();
},
},
],
});
return cy;
}
/**
* @typedef FamilyGraphConfig
* @property {number} activeUser Id of the user to fetch the tree from
* @property {number} depthMin Minimum tree depth for godfathers and godchildren
* @property {number} depthMax Maximum tree depth for godfathers and godchildren
**/
/**
* Create a family graph of an user
* @param {FamilyGraphConfig} config
**/
window.loadFamilyGraph = (config) => {
document.addEventListener("alpine:init", () => {
const defaultDepth = 2;
function getInitialDepth(prop) {
const value = Number.parseInt(initialUrlParams.get(prop));
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
return defaultDepth;
}
return value;
}
Alpine.data("graph", () => ({
loading: false,
godfathersDepth: getInitialDepth("godfathersDepth"),
godchildrenDepth: getInitialDepth("godchildrenDepth"),
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined,
graphData: {},
async init() {
const delayedFetch = Alpine.debounce(async () => {
await this.fetchGraphData();
}, 100);
for (const param of ["godfathersDepth", "godchildrenDepth"]) {
this.$watch(param, async (value) => {
if (value < config.depthMin || value > config.depthMax) {
return;
}
updateQueryString(param, value, History.Replace);
await delayedFetch();
});
}
this.$watch("reverse", async (value) => {
updateQueryString("reverse", value, History.Replace);
await this.reverseGraph();
});
this.$watch("graphData", async () => {
this.generateGraph();
if (this.reverse) {
await this.reverseGraph();
}
});
await this.fetchGraphData();
},
screenshot() {
const link = document.createElement("a");
link.href = this.graph.jpg();
link.download = interpolate(
gettext("family_tree.%(extension)s"),
{ extension: "jpg" },
true,
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
reset() {
this.reverse = false;
this.godfathersDepth = defaultDepth;
this.godchildrenDepth = defaultDepth;
},
async reverseGraph() {
this.graph.elements((el) => {
el.position({ x: -el.position().x, y: -el.position().y });
});
this.graph.center(this.graph.elements());
},
async fetchGraphData() {
this.graphData = await getGraphData(
config.activeUser,
this.godfathersDepth,
this.godchildrenDepth,
);
},
generateGraph() {
this.loading = true;
this.graph = createGraph(
$(this.$refs.graph),
this.graphData,
config.activeUser,
);
this.loading = false;
},
}));
});
};

View File

@ -0,0 +1,101 @@
import { paginated } from "#core:utils/api";
import { HttpReader, ZipWriter } from "@zip.js/zip.js";
import { showSaveFilePicker } from "native-file-system-adapter";
import { picturesFetchPictures } from "#openapi";
/**
* @typedef UserProfile
* @property {number} id
* @property {string} first_name
* @property {string} last_name
* @property {string} nick_name
* @property {string} display_name
* @property {string} profile_url
* @property {string} profile_pict
*/
/**
* @typedef Picture
* @property {number} id
* @property {string} name
* @property {number} size
* @property {string} date
* @property {UserProfile} owner
* @property {string} full_size_url
* @property {string} compressed_url
* @property {string} thumb_url
* @property {string} album
* @property {boolean} is_moderated
* @property {boolean} asked_for_removal
*/
/**
* @typedef PicturePageConfig
* @property {number} userId Id of the user to get the pictures from
**/
/**
* Load user picture page with a nice download bar
* @param {PicturePageConfig} config
**/
window.loadPicturePage = (config) => {
document.addEventListener("alpine:init", () => {
Alpine.data("user_pictures", () => ({
isDownloading: false,
loading: true,
pictures: [],
albums: {},
async init() {
this.pictures = await paginated(picturesFetchPictures, {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
query: { users_identified: [config.userId] },
});
this.albums = this.pictures.reduce((acc, picture) => {
if (!acc[picture.album]) {
acc[picture.album] = [];
}
acc[picture.album].push(picture);
return acc;
}, {});
this.loading = false;
},
async downloadZip() {
this.isDownloading = true;
const bar = this.$refs.progress;
bar.value = 0;
bar.max = this.pictures.length;
const incrementProgressBar = () => {
bar.value++;
};
const fileHandle = await showSaveFilePicker({
_preferPolyfill: false,
suggestedName: interpolate(
gettext("pictures.%(extension)s"),
{ extension: "zip" },
true,
),
types: {},
excludeAcceptAllOption: false,
});
const zipWriter = new ZipWriter(await fileHandle.createWritable());
await Promise.all(
this.pictures.map((p) => {
const imgName = `${p.album}/IMG_${p.date.replaceAll(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
level: 9,
lastModDate: new Date(p.date),
onstart: incrementProgressBar,
});
}),
);
await zipWriter.close();
this.isDownloading = false;
},
}));
});
};

View File

@ -0,0 +1,83 @@
import type { Client, Options, RequestResult } from "@hey-api/client-fetch";
import { client } from "#openapi";
export interface PaginatedResponse<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
export interface PaginatedRequest {
query?: {
page?: number;
// biome-ignore lint/style/useNamingConvention: api is in snake_case
page_size?: number;
};
}
type PaginatedEndpoint<T> = <ThrowOnError extends boolean = false>(
options?: Options<PaginatedRequest, ThrowOnError>,
) => RequestResult<PaginatedResponse<T>, unknown, ThrowOnError>;
// TODO : If one day a test workflow is made for JS in this project
// please test this function. A all cost.
export const paginated = async <T>(
endpoint: PaginatedEndpoint<T>,
options?: PaginatedRequest,
) => {
const maxPerPage = 199;
const queryParams = options ?? {};
queryParams.query = queryParams.query ?? {};
queryParams.query.page_size = maxPerPage;
queryParams.query.page = 1;
const firstPage = (await endpoint(queryParams)).data;
const results = firstPage.results;
const nbElements = firstPage.count;
const nbPages = Math.ceil(nbElements / maxPerPage);
if (nbPages > 1) {
const promises: Promise<T[]>[] = [];
for (let i = 2; i <= nbPages; i++) {
const nextPage = structuredClone(queryParams);
nextPage.query.page = i;
promises.push(endpoint(nextPage).then((res) => res.data.results));
}
results.push(...(await Promise.all(promises)).flat());
}
return results;
};
interface Request {
client?: Client;
}
interface InterceptorOptions {
url: string;
}
type GenericEndpoint = <ThrowOnError extends boolean = false>(
options?: Options<Request, ThrowOnError>,
) => RequestResult<unknown, unknown, ThrowOnError>;
/**
* Return the endpoint url of the endpoint
**/
export const makeUrl = async (endpoint: GenericEndpoint) => {
let url = "";
const interceptor = (_request: undefined, options: InterceptorOptions) => {
url = options.url;
throw new Error("We don't want to send the request");
};
client.interceptors.request.use(interceptor);
try {
await endpoint({ client: client });
} catch (_error) {
/* do nothing */
}
client.interceptors.request.eject(interceptor);
return url;
};

View File

@ -0,0 +1,21 @@
import type { Alpine as AlpineType } from "alpinejs";
declare global {
const Alpine: AlpineType;
const gettext: (text: string) => string;
const interpolate: <T>(fmt: string, args: string[] | T, isNamed?: boolean) => string;
}
/**
* Helper function to export typescript functions to regular html and jinja files
* Without it, you either have to use the any keyword and suppress warnings or do a
* very painful type conversion workaround which is only here to please the linter
*
* This is only useful if you're using typescript, this is equivalent to doing
* window.yourFunction = yourFunction
**/
// biome-ignore lint/suspicious/noExplicitAny: Avoid strange tricks to export functions
export function exportToHtml(name: string, func: any) {
// biome-ignore lint/suspicious/noExplicitAny: Avoid strange tricks to export functions
(window as any)[name] = func;
}

View File

@ -0,0 +1,40 @@
export enum History {
None = 0,
Push = 1,
Replace = 2,
}
export const initialUrlParams = new URLSearchParams(window.location.search);
export const getCurrentUrlParams = () => {
return new URLSearchParams(window.location.search);
};
export function updateQueryString(
key: string,
value?: string | string[],
action?: History,
url?: string,
) {
const historyAction = action ?? History.Replace;
const ret = new URL(url ?? window.location.href);
if (value === undefined || value === null || value === "") {
// If the value is null, undefined or empty => delete it
ret.searchParams.delete(key);
} else if (Array.isArray(value)) {
ret.searchParams.delete(key);
for (const v of value) {
ret.searchParams.append(key, v);
}
} else {
ret.searchParams.set(key, value);
}
if (historyAction === History.Push) {
window.history.pushState(null, "", ret.toString());
} else if (historyAction === History.Replace) {
window.history.replaceState(null, "", ret.toString());
}
return ret;
}

View File

@ -0,0 +1,56 @@
/**
* Class decorator to register components easily
* It's a wrapper around window.customElements.define
* What's nice about it is that you don't separate the component registration
* and the class definition
**/
export function registerComponent(name: string, options?: ElementDefinitionOptions) {
return (component: CustomElementConstructor) => {
window.customElements.define(name, component, options);
};
}
/**
* Safari doesn't support inheriting from HTML tags on web components
* The technique is to:
* create a new web component
* create the desired type inside
* pass all attributes to the child component
* store is at as `node` inside the parent
*
* Since we can't use the generic type to instantiate the node, we create a generator function
*
* ```js
* class MyClass extends inheritHtmlElement("select") {
* // do whatever
* }
* ```
**/
export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagName: K) {
return class Inherited extends HTMLElement {
protected node: HTMLElementTagNameMap[K];
connectedCallback(autoAddNode?: boolean) {
this.node = document.createElement(tagName);
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
for (const attr of this.attributes) {
if (attr.name in this.node) {
attributes.push(attr);
}
}
for (const attr of attributes) {
this.removeAttributeNode(attr);
this.node.setAttributeNode(attr);
}
this.node.innerHTML = this.innerHTML;
this.innerHTML = "";
// Automatically add node to DOM if autoAddNode is true or unspecified
if (autoAddNode === undefined || autoAddNode) {
this.appendChild(this.node);
}
}
};
}