OIDC Integration: Supports Authentik officially, other solutions currently untested. Social login not implemented

This commit is contained in:
boris committed 2026-03-30 13:38:18 +01:00
1 parent dfea7e869b
commit 68bbb53976
28 files changed
+2365 -751

No files matched your search

+354 -23
View File
@@ -9,11 +9,49 @@ window.addEventListener("load", (e) => {
warnWithoutSaving();
inlines();
tabNavigation();
scrollSidebarNav();
});
/*************************************************************
* Switch theme
*************************************************************/
function theme(defaultTheme = "auto") {
return {
adminTheme: Alpine.$persist(defaultTheme).as('adminTheme'),
switchTheme(theme) {
this.adminTheme = theme;
},
themeBindings: {
['x-bind:class']() {
if (this.adminTheme === 'dark' || (this.adminTheme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
return 'dark';
}
if (this.adminTheme === 'light') {
return 'light';
}
return '';
},
['x-on:keydown.window'](event) {
if ((event.metaKey || event.ctrlKey) && event.key === "e") {
event.preventDefault();
if (this.adminTheme == "light") {
this.adminTheme = "dark";
} else {
this.adminTheme = "light";
}
}
}
}
}
}
/*************************************************************
* Scroll sidebar to active item
*************************************************************/
@@ -110,18 +148,36 @@ function tabNavigation() {
/*************************************************************
* Alpine.sort.js callback after sorting
*************************************************************/
const sortRecords = (e) => {
const orderingField = e.from.dataset.orderingField;
function sortRecords(e) {
e.from
.querySelectorAll(`.form-group.original`)
.forEach(function (row, index) {
input = row.querySelector(
`input[name$=-${e.from.dataset.orderingField}]`
);
input.value = index;
});
const weightInputs = Array.from(
e.from.querySelectorAll(
`.has_original input[name$=-${orderingField}], td.field-${orderingField} input[name$=-${orderingField}]`
e.from
.querySelectorAll(
`td.field-${e.from.dataset.orderingField} input[name$=-${e.from.dataset.orderingField}]`
)
);
.forEach(function (input, index) {
input.value = index;
});
}
weightInputs.forEach((input, index) => {
input.value = index;
});
/*************************************************************
* Alpine.sort.js callback before moving records
*************************************************************/
const moveRecords = (e) => {
if (
(e.related.classList.contains("template") ||
e.related.classList.contains("form-actions")) &&
e.willInsertAfter
) {
return false;
}
};
/*************************************************************
@@ -434,7 +490,7 @@ const watchClassChanges = (selector, callback) => {
/*************************************************************
* Calendar & clock
*************************************************************/
const dateTimeShortcutsOverlay = () => {
function dateTimeShortcutsOverlay() {
const observer = new MutationObserver((mutations) => {
for (const mutationRecord of mutations) {
const display = mutationRecord.target.style.display;
@@ -448,27 +504,32 @@ const dateTimeShortcutsOverlay = () => {
}
});
const targets = document.querySelectorAll(".calendarbox, .clockbox");
const findCalendars = () => {
for (const target of document.querySelectorAll(".calendarbox, .clockbox")) {
observer.observe(target, {
attributes: true,
attributeFilter: ["style"],
});
}
};
for (const target of targets) {
observer.observe(target, {
attributes: true,
attributeFilter: ["style"],
});
}
new MutationObserver(findCalendars).observe(document.body, {
childList: true,
subtree: true,
});
};
/*************************************************************
* File upload path
*************************************************************/
const fileInputUpdatePath = () => {
const checkInputChanged = () => {
function fileInputUpdatePath() {
function checkInputChanged() {
for (const input of document.querySelectorAll("input[type=file]")) {
if (input.hasChangeListener) {
continue;
}
input.addEventListener("change", (e) => {
input.addEventListener("change", function (e) {
const parts = e.target.value.split("\\");
const placeholder =
input.parentNode.parentNode.parentNode.querySelector(
@@ -479,9 +540,9 @@ const fileInputUpdatePath = () => {
input.hasChangeListener = true;
}
};
}
new MutationObserver(() => {
new MutationObserver(function () {
checkInputChanged();
}).observe(document.body, {
childList: true,
@@ -489,7 +550,7 @@ const fileInputUpdatePath = () => {
});
checkInputChanged();
};
}
/*************************************************************
* Chart
@@ -719,6 +780,276 @@ const renderCharts = () => {
});
};
/*************************************************************
* Inlines
*************************************************************/
function inlines() {
/**
* Replacing: https://github.com/django/django/blob/main/django/contrib/admin/static/admin/js/inlines.js
*
* Expected HTML structure:
*
* <div class="formset-wrapper">
* ... management form ...
* <div class="formset">
* <div class="form-group">
* <div class="form-row">inputs</div>
* <div class="form-nested">nested inlines if exists</div>
* </div>
*
* ... another form group ...
*
* <div class="form-actions">
* <div class="add-row">Add record button</div>
* </div>
* </div>
* </div>
*/
document.querySelectorAll(".add-row").forEach(function (el) {
el.addEventListener("click", addInlineTemplateHandler);
});
document.querySelectorAll(".delete-template").forEach(function (el) {
el.addEventListener("click", deleteInlineTemplateHandler);
});
}
function deleteInlineTemplateHandler(e) {
const formset = e.target.closest(".formset");
const options = JSON.parse(formset.dataset.inlineFormset).options;
const formsetWrapper = e.target.closest(".formset-wrapper");
const minNumForms = formsetWrapper.querySelector(
"input[type=hidden][name$='MIN_NUM_FORMS']"
);
const totalForms = formsetWrapper.querySelector(
"input[type=hidden][name$='TOTAL_FORMS']"
);
const maxNumForms = formsetWrapper.querySelector(
"input[type=hidden][name$='MAX_NUM_FORMS']"
);
const rowToDelete = e.target.closest(".form-group");
if (parseInt(totalForms.value) == parseInt(minNumForms.value)) {
return;
}
let reindex = false;
formset
.querySelectorAll(":scope > .form-group:not(.empty-form)")
.forEach((row, index) => {
if (rowToDelete.isSameNode(row)) {
reindex = true;
}
if (!rowToDelete.isSameNode(row) && reindex) {
const content = row.querySelector(":scope > .form-row");
content.innerHTML = content.innerHTML.replaceAll(
`${options.prefix}-${index}`,
`${options.prefix}-${index - 1}`
);
// Reindex nested formset
const nestedFormset = row.querySelector(":scope .form-nested");
if (nestedFormset) {
nestedFormset.innerHTML = nestedFormset.innerHTML.replaceAll(
`${options.prefix}-${index}`,
`${options.prefix}-${index - 1}`
);
}
}
});
const rowToDeleteParent = rowToDelete.parentElement;
rowToDelete.remove();
updateStackedCounter(rowToDeleteParent);
// Update total forms count
totalForms.value = parseInt(
formset.querySelectorAll(":scope > .form-group:not(.empty-form)").length,
10
);
// Apply delete event
formset.querySelectorAll(".delete-template").forEach(function (el) {
el.removeEventListener("click", deleteInlineTemplateHandler);
el.addEventListener("click", deleteInlineTemplateHandler);
});
// Show add button
if (parseInt(totalForms.value, 10) < parseInt(maxNumForms.value, 10)) {
formset.querySelector(".add-row").classList.remove("hidden");
}
applyLastClass(formset);
checkFormsetEmpty(formset);
document.dispatchEvent(
new CustomEvent("formset:removed", {
detail: {
formsetName: options.prefix,
},
})
);
}
function addInlineTemplateHandler(e) {
e.preventDefault();
const formset = e.target.closest(".formset");
const formsetWrapper = e.target.closest(".formset-wrapper");
const totalForms = formsetWrapper.querySelector(
"input[type=hidden][name$='TOTAL_FORMS']"
);
const maxNumForms = formsetWrapper.querySelector(
"input[type=hidden][name$='MAX_NUM_FORMS']"
);
const template = formset.querySelector(":scope >.empty-form");
const index = formset.querySelectorAll(":scope > .form-group").length - 1;
const options = JSON.parse(formset.dataset.inlineFormset).options;
const row = template.cloneNode(true);
row.classList.remove("empty-form");
if (parseInt(totalForms.value, 10) >= parseInt(maxNumForms.value, 10)) {
return;
}
// Insert new row BEFORE template row which is always last in formset
formset.insertBefore(row, template);
applyLastClass(formset);
checkFormsetEmpty(formset);
// Stacked inlines are having a counter in title
updateStackedCounter(row.parentElement);
// Replace __prefix__ in RECENTLY ADDED row
const formRow = row.querySelector(":scope > .form-row");
formRow.innerHTML = formRow.innerHTML.replaceAll(/__prefix__/g, index);
// Update hidden TOTAL_FORMS input value
totalForms.value = parseInt(totalForms.value, 10) + 1;
// Hide add row button if it is not possible to add more rows
if (totalForms.value === maxNumForms.value) {
e.target.parentElement.classList.add("hidden");
}
// Call special `formsetGroup:added` event to reinitialize inlines
row.dispatchEvent(
new CustomEvent("formsetGroup:added", {
bubbles: true,
detail: {
formsetName: options.prefix,
row: row,
index: index,
},
})
);
}
document.addEventListener("formsetGroup:added", function (e) {
if (!e.detail.row) {
return;
}
e.detail.row.innerHTML = e.detail.row.innerHTML.replaceAll(
`${e.detail.formsetName}-__prefix__`,
`${e.detail.formsetName}-${e.detail.index}`
);
if (typeof DateTimeShortcuts !== "undefined") {
document.querySelectorAll(".datetimeshortcuts").forEach(function (el) {
el.remove();
});
DateTimeShortcuts.init();
dateTimeShortcutsOverlay();
}
e.detail.row.querySelector(":scope > .form-row").dispatchEvent(
new CustomEvent("formset:added", {
bubbles: true,
detail: { formsetName: e.detail.formsetName },
})
);
e.detail.row
.querySelectorAll(".nested-formset > .form-group:not(.empty-form)")
.forEach(function (formGroup) {
formGroup.querySelector(".form-row").dispatchEvent(
new CustomEvent("formset:added", {
bubbles: true,
detail: { formsetName: e.detail.formsetName },
})
);
});
e.detail.row.querySelectorAll(".add-row").forEach(function (el) {
el.addEventListener("click", addInlineTemplateHandler);
});
e.detail.row.querySelectorAll(".delete-template").forEach(function (el) {
el.addEventListener("click", deleteInlineTemplateHandler);
});
});
function checkFormsetEmpty(formset) {
const formGroups = formset.querySelectorAll(
":scope > .form-group:not(.empty-form)"
);
if (formGroups.length === 0) {
formset.classList.add("empty");
} else {
formset.classList.remove("empty");
}
}
function applyLastClass(formset) {
formset.querySelectorAll(":scope > .form-group").forEach(function (row) {
if (row.classList.contains("last")) {
row.classList.remove("last");
}
if (
row.nextElementSibling.classList.contains("empty-form") ||
row.nextElementSibling.classList.contains("form-actions")
) {
row.classList.add("last");
}
});
}
function updateStackedCounter(rowsParent) {
rowsParent
.querySelectorAll(":scope > .form-group:not(.empty-form) > .form-row")
.forEach(function (row, index) {
const counter = row.querySelector(".stacked-counter");
if (counter) {
counter.textContent = index + 1;
}
});
}
/*************************************************************
* Reinitialize inlines after pagination AJAX request
*************************************************************/
document.addEventListener("htmx:afterSettle", function (e) {
e.target.querySelectorAll(".form-row").forEach(function (formRow) {
formRow.dispatchEvent(
new CustomEvent("formset:added", {
bubbles: true,
detail: {
formsetName: JSON.parse(e.target.dataset.inlineFormset).options
.prefix,
},
})
);
});
});
function getCurrentTab() {
const fragment = window.location.hash?.replace('#', '');