merge newui branch

This commit is contained in:
Gani Georgiev
2026-04-18 16:29:34 +03:00
parent 58f605e90c
commit 4c44044c0c
804 changed files with 58660 additions and 56663 deletions
+148
View File
@@ -0,0 +1,148 @@
export function appHeader() {
return () => {
if (!app.store._ready || !app.store.showHeader || !app.store.superuser?.id) {
return;
}
return t.header(
{
pbEvent: "appHeader",
rid: "appHeader",
className: "app-header accent-surface",
onmount: async (el) => {
await new Promise((r) => setTimeout(r, 0));
el._scrollToActiveMenuItem = function() {
el?.querySelector(".app-main-nav .header-link.active")?.scrollIntoView();
};
el._scrollToActiveMenuItem();
window.addEventListener("hashchange", el._scrollToActiveMenuItem);
},
onunmount: (el) => {
window.removeEventListener("hashchange", el?._scrollToActiveMenuItem);
},
},
t.a(
{ href: "#/", className: "logo" },
t.img({ src: () => app.store.headerLogo, alt: "App logo" }),
),
t.nav(
{
pbEvent: "mainNav",
className: "app-main-nav",
},
() => {
return app.store.headerLinks.map((link) => {
const isLocal = link.href.startsWith("#/");
return t.a(
{
href: () => link.href,
target: () => !isLocal ? "_blank" : undefined,
rel: () => !isLocal ? "noopener noreferrer" : undefined,
className: (el) => {
const isActive = link.isActive?.(el) || app.utils.isActivePath(link.href);
return `header-link ${isActive ? "active" : ""}`;
},
},
() => {
if (link.icon) {
return t.i({ className: link.icon });
}
},
t.span({ className: "txt" }, () => link.label),
);
});
},
),
t.div({ className: "flex-fill app-header-separator" }),
colorSchemeButton(),
t.button(
{
className: "header-link logged-user txt-normal",
"html-popovertarget": "logged-user-dropdown",
},
t.span({ className: "superuser-name txt-ellipsis" }, () => app.store.superuser?.email),
t.i({ className: "ri-arrow-drop-down-line" }),
),
t.div(
{
pbEvent: "loggedUserDropdown",
id: "logged-user-dropdown",
className: "dropdown sm nowrap logged-user-dropdown",
popover: "auto",
},
t.a(
{
className: "dropdown-item dropdown-item-manage",
href: "#/collections?collection=_superusers",
onclick: (e) => {
e.target.closest(".dropdown").hidePopover();
},
},
t.i({ className: "ri-group-line", ariaHidden: true }),
t.span({ className: "txt" }, "Manage superusers"),
),
t.hr(),
t.button(
{
type: "button",
className: "dropdown-item txt-danger dropdown-item-logout",
onclick: () => app.pb.authStore.clear(),
},
t.i({ className: "ri-logout-circle-line", ariaHidden: true }),
t.span({ className: "txt" }, "Logout"),
),
),
);
};
}
function colorSchemeButton() {
const options = [
{ value: "light", icon: "ri-sun-line", label: "Light" },
{ value: "dark", icon: "ri-moon-line", label: "Dark" },
{ value: "", icon: "ri-subtract-line", label: "Auto" },
];
return [
t.button(
{
className: "header-link color-scheme-picker",
"html-popovertarget": "color-scheme-dropdown",
title: "Color scheme",
},
t.i({
className: () => app.store.activeColorScheme == "dark" ? "ri-moon-line" : "ri-sun-line",
ariaHidden: true,
}),
),
t.div(
{
pbEvent: "colorSchemeDropdown",
id: "color-scheme-dropdown",
className: "dropdown sm nowrap color-scheme-dropdown",
popover: "auto",
},
() => {
return options.map((opt) => {
return t.button(
{
type: "button",
className: () =>
`dropdown-item dropdown-item-light ${
app.store.userColorScheme == opt.value ? "active" : ""
}`,
onclick: (e) => {
e.target.closest(".dropdown").hidePopover();
app.store.userColorScheme = opt.value;
},
},
t.i({ className: opt.icon, ariaHidden: true }),
t.span({ className: "txt" }, opt.label),
);
});
},
),
];
}
+14
View File
@@ -0,0 +1,14 @@
// auto open accordions if there is an invalid item
document.addEventListener(
"invalid",
(e) => {
const details = e.target.closest("details");
if (details && !details.open && !e.target.closest("summary")) {
details.open = true;
// revalidate and show the error message
e.target.reportValidity && e.target.reportValidity();
}
},
true,
);
+78
View File
@@ -0,0 +1,78 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Readonly code highlight component.
*
* @example
* ```js
* app.components.codeBlock({
* value: () => data.myCode,
* language: "html",
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.codeBlock = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
language: "js", // see Prism.languages
value: undefined,
footnote: undefined,
});
const watchers = app.utils.extendStore(props, propsArg);
return t.div(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => `code-wrapper ${props.className}`,
tabIndex: -1,
onmount: (el) => {
el.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key == "a" || e.key == "A")) {
e.preventDefault();
window.getSelection().selectAllChildren(el);
}
});
},
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
t.code({
className: "block",
innerHTML: () => highlight(props.value, props.language),
}),
t.div({ className: "footnote" }, (el) => {
if (typeof props.footnote == "function") {
return props.footnote(el);
}
return props.footnote;
}),
);
};
function highlight(content, language) {
content = typeof content == "string" ? content : "";
// @see https://prismjs.com/plugins/normalize-whitespace
content = Prism.plugins.NormalizeWhitespace.normalize(content, {
"remove-trailing": true,
"remove-indent": true,
"left-trim": true,
"right-trim": true,
});
return Prism.highlight(content, Prism.languages[language] || Prism.languages.js, language);
}
+96
View File
@@ -0,0 +1,96 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Code highlighted tabs component.
*
* @example
* ```js
* app.components.codeBlockTabs({
* tabs: [
* {
* title: "Tab 1",
* language: "js",
* value: "console.log(123)","
* // other codeBlock props...
* },
* ...
* ],
* historyKey: "myTabs"
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.codeBlockTabs = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
activeTabIndex: 0,
historyKey: "",
tabs: [], // {title, ...codeBlockProps}
get activeTab() {
return props.tabs[props.activeTabIndex] || props.tabs[0];
},
});
const watchers = app.utils.extendStore(props, propsArg);
watchers.push(
watch(() => props.activeTabIndex, (newIndex, oldIndex) => {
if (oldIndex != undefined && props.historyKey) {
localStorage.setItem(props.historyKey, newIndex);
}
}),
);
return t.div(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden || !props.tabs.length,
inert: () => props.inert,
className: () => `code-block-tabs ${props.className}`,
onmount: () => {
if (props.historyKey) {
props.activeTabIndex = localStorage.getItem(props.historyKey) << 0;
}
},
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
t.header(
{ className: "tabs-header" },
() => {
return props.tabs.map((tab, i) => {
return t.button(
{
type: "button",
className: () => `tab-item ${props.activeTabIndex == i ? "active" : ""}`,
onclick: () => props.activeTabIndex = i,
},
(el) => {
if (typeof tab.title == "function") {
return tab.title(el);
}
return tab.title;
},
);
});
},
),
t.div(
{ className: "code-block-tabs-content" },
() => {
if (props.activeTab) {
return app.components.codeBlock(props.activeTab);
}
},
),
);
};
+507
View File
@@ -0,0 +1,507 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Basic code editor element with syntax highlight support.
* For static code visualization use `app.components.codeBlock({ ... })`.
*
* @example
* ```js
* app.components.codeEditor({
* language: "html",
* value: () => data.myCode, // data is some store() instance
* singleLine: true,
* placeholder: "Type your html here...",
* oninput: (val) => {
* data.myCode = val
* },
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.codeEditor = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
name: undefined,
className: "",
value: "",
language: "js", // see Prism.languages
placeholder: "",
disabled: false,
required: false,
singleLine: false,
// [
// "value",
// {value, label},
// ]
autocomplete: undefined, // Array<string|Object> | function(word): Array<string|Object>,
// ---
oninput: function(val, e) {},
onfocus: function(val, e) {},
onblur: function(val, e) {},
});
const extendWatchers = app.utils.extendStore(props, propsArg, "autocomplete");
let dropdown;
let visibilityObserver;
let isFieldVisible = true;
function openAutocompleteDropdown(items) {
closeAutocompleteDropdown();
dropdown = t.div(
{
className: "dropdown autocomplete code-editor-dropdown",
onmount: (el) => {
el._updatePosition = () => {
if (!isFieldVisible) {
closeAutocompleteDropdown();
} else {
updateDropdownPosition(dropdown);
}
};
el._closeOnEsc = (e) => {
if (e.key == "Escape") {
e.preventDefault();
closeAutocompleteDropdown();
}
};
window.addEventListener("scroll", el._updatePosition, true);
window.addEventListener("resize", el._updatePosition);
window.addEventListener("keydown", el._closeOnEsc);
el._updatePosition();
},
onunmount: (el) => {
if (el) {
window.removeEventListener("scroll", el._updatePosition, true);
window.removeEventListener("resize", el._updatePosition);
window.removeEventListener("keydown", el._closeOnEsc);
}
},
},
items,
);
document.body.appendChild(dropdown);
// track editor field visibility to hide the dropdown when
// not in the view port to avoid overflow issues
if (editorContent) {
visibilityObserver?.disconnect();
visibilityObserver = new IntersectionObserver(
([entry]) => {
isFieldVisible = entry.isIntersecting;
},
{
root: null,
threshold: 0.1,
},
);
visibilityObserver.observe(editorContent);
}
}
function closeAutocompleteDropdown() {
if (dropdown) {
dropdown.remove();
dropdown = null;
}
if (visibilityObserver) {
visibilityObserver.disconnect();
visibilityObserver = null;
}
isFieldVisible = true;
}
let isCtrlOrCmdKey = false;
let valueWatcher;
// note1: use contenteditable so that we can call getBoundingClientRect on the selected text
// note2: getSelection also doesn't seem to work in Firefox for textarea and inputs
const editorContent = t.div({
contentEditable: () => (props.disabled ? false : "plaintext-only"),
tabIndex: 0,
spellcheck: false,
autocorrect: false,
autocomplete: "off",
autocapitalize: "off",
role: "textbox",
className: "editor-content",
"html-data-placeholder": () => props.placeholder,
onmount: (el) => {
// auto change change textContent only if it props.value was
// changed externally to preserve the focus and caret position
valueWatcher?.unwatch();
valueWatcher = watch(
() => props.value,
(value) => {
if (value != editorContent.textContent) {
editorContent.textContent = value;
closeAutocompleteDropdown();
}
},
);
},
onunmount: (el) => {
valueWatcher?.unwatch();
closeAutocompleteDropdown();
},
onfocus: (e) => {
props.onfocus?.(props.value, e);
},
onblur: (e) => {
// not blurred because of dropdown click
if (dropdown && !dropdown.contains(e.relatedTarget)) {
closeAutocompleteDropdown();
}
props.onblur?.(props.value, e);
},
oninput: (e) => {
closeAutocompleteDropdown();
props.value = editorContent.textContent;
props.oninput?.(props.value, e);
editorContent.dispatchEvent(new CustomEvent("change", { detail: props.value }));
if (!props.value?.length) {
editorContent.textContent = ""; // ensure that no comments, br, etc. tags are left
return;
}
if (!editorContent?.isConnected) {
return;
}
const pos = getCaretPos(editorContent);
const match = getWord(props.value, pos);
if (
!match.word.length
// don't show suggestions in case the cursor is at the
// beginning of an already typed word
|| pos == match.start
) {
return;
}
let suggestions = [];
if (typeof props.autocomplete == "function") {
suggestions = props.autocomplete(match.word) || [];
} else if (!app.utils.isEmpty(props.autocomplete)) {
const wordLowercased = match.word.toLowerCase();
suggestions = props.autocomplete.filter((item) => {
if (typeof item == "object") {
item = item?.value;
}
item = item?.toLowerCase();
return item && item != wordLowercased && item.includes(wordLowercased);
});
}
if (!suggestions?.length) {
return;
}
openAutocompleteDropdown(() => {
return suggestions.map((suggestion, i) => {
return t.button({
type: "button",
className: `dropdown-item ${i == 0 ? "active" : ""}`,
textContent: suggestion.label || suggestion.value || suggestion,
onclick: (e) => {
e.preventDefault();
editorContent.focus();
const word = suggestion.value || suggestion;
// note: replacing the text doesn't preserve the native "undo" history
// (document.execCommand is being deprecated)
editorContent.textContent = editorContent.textContent.substring(0, match.start)
+ word
+ editorContent.textContent.substring(match.end + 1);
props.value = editorContent.textContent;
try {
window
.getSelection()
.setPosition(editorContent.childNodes[0], match.start + word.length);
} catch (err) {
console.warn("failed to set caret position", err);
}
closeAutocompleteDropdown();
},
});
});
});
},
onkeydown: (e) => {
isCtrlOrCmdKey = e.ctrlKey || e.metaKey;
// autocomplete nav
// -------------------------------------------------------
if ((e.key == "Enter" || e.key == "Tab") && dropdown?.isConnected) {
e.preventDefault();
dropdown.querySelector(".dropdown-item.active")?.click();
return;
}
if (e.key == "ArrowUp" && dropdown?.isConnected) {
e.preventDefault();
const currentActive = dropdown.querySelector(".dropdown-item.active");
if (currentActive?.previousElementSibling) {
currentActive.classList.remove("active");
currentActive.previousElementSibling.classList.add("active");
currentActive.previousElementSibling.scrollIntoView(false);
}
return;
}
if (e.key == "ArrowDown" && dropdown?.isConnected) {
e.preventDefault();
const currentActive = dropdown.querySelector(".dropdown-item.active");
if (currentActive?.nextElementSibling) {
currentActive.classList.remove("active");
currentActive.nextElementSibling.classList.add("active");
currentActive.nextElementSibling.scrollIntoView(false);
}
return;
}
// editor shortcuts
// -------------------------------------------------------
if (isCtrlOrCmdKey && e.key.toLowerCase() == "l") {
e.preventDefault();
selectLine(editorContent);
return;
}
if (isCtrlOrCmdKey && e.key.toLowerCase() == "d") {
e.preventDefault();
selectWord(editorContent);
return;
}
if (!props.singleLine && e.key == "Tab") {
e.preventDefault();
const selection = window.getSelection();
if (!selection) {
return;
}
// -1 tab level
if (e.shiftKey) {
selection.modify("extend", "backward", "character");
if (selection.toString()[0] == "\t") {
selection.deleteFromDocument();
props.value = editorContent.textContent;
} else {
// check ahead and restore
selection.modify("extend", "forward", "character");
if (selection.toString()[0] == "\t") {
selection.deleteFromDocument();
props.value = editorContent.textContent;
}
}
return;
}
// +1 tab level
const range = selection.getRangeAt(0);
if (range) {
range.deleteContents();
range.insertNode(document.createTextNode("\t"));
range.collapse();
props.value = editorContent.textContent;
}
return;
}
// simulate single-line enter press
if (props.singleLine && e.key == "Enter") {
e.preventDefault();
hiddenSubmit.click();
return;
}
},
onscroll: () => {
closeAutocompleteDropdown();
if (highlightOverlay) {
highlightOverlay.scrollLeft = editorContent.scrollLeft;
highlightOverlay.scrollTop = editorContent.scrollTop;
}
},
});
const highlightOverlay = t.div({
className: "highlight-overlay",
innerHTML: () => highlight(props.value, props.language),
onscroll: () => {
if (editorContent) {
editorContent.scrollLeft = highlightOverlay.scrollLeft;
editorContent.scrollTop = highlightOverlay.scrollTop;
}
},
});
const hiddenSubmit = t.button({
type: "submit",
className: "hidden",
});
return t.div(
{
rid: props.rid,
id: () => props.id,
inert: () => props.inert,
hidden: () => props.hidden,
"html-name": () => props.name,
"html-required": () => props.required || undefined,
// dprint-ignore
className: () => `input code-editor ${props.className} ${props.disabled ? "disabled" : ""} ${props.singleLine ? "single-line" : ""}`,
onclick: () => {
editorContent?.focus();
},
onunmount: () => {
extendWatchers?.forEach((w) => w?.unwatch());
},
},
t.div({ className: "code-editor-container" }, editorContent, highlightOverlay, hiddenSubmit),
);
};
const highlightThreshold = 500;
function highlight(content, language) {
content = typeof content == "string" ? content : "";
if (!content) {
return "";
}
if (
!Prism.languages[language]
// fallback to plain to avoid performance issues with large text blocks
|| content.length > highlightThreshold
) {
language = "plain";
}
return Prism.highlight(content, Prism.languages[language], language);
}
const wordCharRegex = new RegExp(/[\p{Alphabetic}\p{Number}_@:\."'{}]/, "u");
function getWord(value, caretPos) {
let start = caretPos;
for (let i = caretPos - 1; i >= 0; i--) {
if (!wordCharRegex.test(value[i])) {
break;
}
start = i;
}
let end = start;
for (let i = caretPos - 1; i < value.length; i++) {
if (!wordCharRegex.test(value[i])) {
break;
}
end = i;
}
return {
word: value.substring(start, end + 1),
start: start,
end: end,
};
}
function selectLine() {
const selection = window.getSelection();
selection?.modify("move", "forward", "lineboundary");
selection?.modify("extend", "backward", "lineboundary");
}
function selectWord() {
const selection = window.getSelection();
selection?.modify("move", "forward", "word");
selection?.modify("extend", "backward", "word");
}
function getCaretPos(editorContent) {
const selection = window.getSelection();
// new line adds a new text node which resets the selection counter
// so we have to add them as offset
let offset = 0;
for (let node of editorContent.childNodes) {
if (node == selection.focusNode) {
break;
} else {
offset += node.length;
}
}
return offset + selection.focusOffset;
}
function updateDropdownPosition(dropdown) {
const targetRect = window.getSelection()?.getRangeAt(0)?.getBoundingClientRect();
if (!targetRect || !dropdown) {
return false;
}
if (targetRect.top < 0) {
dropdown.classList.add("hidden");
return;
}
dropdown.classList.remove("hidden");
// reset
dropdown.style.left = "0px";
dropdown.style.top = "0px";
const dropdownHeight = dropdown.offsetHeight;
const dropdownWidth = dropdown.offsetWidth;
let left = targetRect.left - 5;
let top = targetRect.top + targetRect.height;
// show on top if it cannot fit below the parent
if (top + dropdownHeight > document.documentElement.clientHeight) {
top = Math.max(targetRect.top - dropdownHeight, 0);
}
// align from the right edge if overflow
if (left + dropdownWidth > document.documentElement.clientWidth) {
left = Math.max(document.documentElement.clientWidth - dropdownWidth, 0);
}
dropdown.style.left = left + "px";
dropdown.style.top = top + "px";
}
+142
View File
@@ -0,0 +1,142 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Color picker component (with predefined colors support).
*
* @example
* ```js
* app.components.colorPicker({
* value: () => data.color,
* predefinedColors: ["#ff0000", "#123456"],
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.colorPicker = function(propsArg = {}) {
const uniqueId = "picker_" + app.utils.randomString();
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
name: "",
required: false,
disabled: false,
value: "",
predefinedColors: [],
// ---
onchange: (newColor) => {},
onmount: (el) => {},
onunmount: (el) => {},
});
const watchers = app.utils.extendStore(props, propsArg);
const local = store({
inputValue: "#ffffff",
});
function handleOnchange(val) {
val = val?.toLowerCase() || "";
props.onchange?.(val);
}
let inputTimeoutId;
let input = t.input({
type: "color",
className: "color-picker-input",
id: () => props.id,
name: () => props.name,
required: () => props.required,
disabled: () => props.disabled,
value: () => {
local.inputValue = props.value || "#ffffff";
return props.value || undefined;
},
oninput: (e) => {
local.inputValue = e.target.value;
clearTimeout(inputTimeoutId);
inputTimeoutId = setTimeout(() => {
handleOnchange(e.target.value);
}, 50);
},
});
return t.div(
{
rid: props.rid,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => `color-picker ${props.className}`,
onmount: (el) => {
props.onmount?.(el);
},
onunmount: (el) => {
clearTimeout(inputTimeoutId);
props.onunmount?.(el);
watchers.forEach((w) => w?.unwatch());
},
},
t.div(
{ className: "color-picker-input-wrapper" },
input,
t.output({
className: "result",
// black or white text (https://developer.chrome.com/blog/css-relative-color-syntax)
// @todo replace with contrast-color once there is better support?
style: () => `color: lch(from ${local.inputValue || "#ffffff"} calc((49 - l) * infinity) 0 0);`,
textContent: () => local.inputValue,
}),
),
t.button(
{
hidden: () => !props.predefinedColors.length,
type: "button",
title: "Predefined colors",
className: "link-hint predefined-colors-btn",
"html-popovertarget": uniqueId + "predefined-colors-dropdown",
},
t.i({ className: "ri-arrow-down-s-line", roleHidden: true }),
),
t.div(
{
pbEvent: "predefinedColorsDropdown",
id: uniqueId + "predefined-colors-dropdown",
className: "dropdown predefined-colors-dropdown",
popover: "auto",
},
t.div(
{
className: "predefined-colors-list",
},
() => {
return props.predefinedColors?.map((color) => {
return t.button({
type: "button",
className: () => `color ${props.value == color ? "active" : ""}`,
style: `background:${color}`,
onclick: (e) => {
if (!input) {
return;
}
e.target.closest(".dropdown")?.hidePopover();
input.value = color || undefined;
input.dispatchEvent(new Event("input", { bubbles: true }));
},
});
});
},
),
),
);
};
+157
View File
@@ -0,0 +1,157 @@
window.app = window.app || {};
window.app.modals = window.app.modals || {};
/**
* Opens a confirmation dialog and executes `yesCallback` or `noCallback`
* depending on the user's choice.
*
* The callbacks can return a `Promise`` that will be awaited before
* closing the confirmation modal.
*
* @example
* ```js
* app.modals.confirm("Are you sure?", () => console.log("confirmed"))
* ```
*
* @param {string|Element} textOrElem The confirmation message.
* @param {function} [yesCallback]
* @param {function} [noCallback]
* @param {Object} [settings]
*/
window.app.modals.confirm = function(textOrElem, yesCallback, noCallback, settings = {
className: undefined,
yesButton: "",
noButton: "",
}) {
data.textOrElem = textOrElem;
data.yesCallback = yesCallback;
data.yesCallbackWaiting = false;
data.noCallback = noCallback;
data.noCallbackWaiting = false;
data.className = typeof settings.className == "string" ? settings.className : "sm";
data.yesButton = settings.yesButton || "Yes";
data.noButton = settings.noButton || "No";
if (!confirmElem.isConnected) {
document.body.appendChild(confirmElem);
}
window.app.modals.open(confirmElem);
};
const data = store({
className: "",
textOrElem: null,
// ---
yesButton: "",
yesCallback: null,
yesCallbackWaiting: false,
// ---
noButton: "",
noCallback: null,
noCallbackWaiting: false,
// ---
get isBusy() {
return data.yesCallbackWaiting || data.noCallbackWaiting;
},
});
const confirmElem = t.div(
{ className: () => `modal popup manual ${data.className || ""}` },
t.div(
{ className: "modal-content" },
(el) => {
if (typeof data.textOrElem === "string") {
return t.h6({ className: "block txt-center" }, data.textOrElem);
}
if (typeof data.textOrElem === "function") {
return data.textOrElem(el);
}
return data.textOrElem;
},
),
t.footer(
{ className: "modal-footer p-sm" },
t.div(
{ className: "grid sm" },
t.div(
{ className: "col-sm-6" },
t.button(
{
type: "button",
className: () => `btn lg block secondary ${data.noCallbackWaiting ? "loading" : ""}`,
disabled: () => data.isBusy,
onclick: async () => {
if (data.noCallback) {
data.noCallbackWaiting = true;
try {
const result = await data.noCallback();
if (result === false) {
return;
}
} catch (err) {
console.log("confirm noCallback error:", err);
} finally {
data.noCallbackWaiting = false;
}
}
window.app.modals.close(confirmElem);
},
},
() => {
if (typeof data.noButton === "string") {
return t.span({ className: "txt" }, data.noButton);
}
if (typeof data.noButton === "function") {
return data.noButton(el);
}
return data.noButton;
},
),
),
t.div(
{ className: "col-sm-6" },
t.button(
{
type: "button",
className: () => `btn lg block warning ${data.yesCallbackWaiting ? "loading" : ""}`,
disabled: () => data.isBusy,
onclick: async () => {
if (data.yesCallback) {
data.yesCallbackWaiting = true;
try {
const result = await data.yesCallback();
if (result === false) {
return;
}
} catch (err) {
console.log("confirm yesCallback error:", err);
} finally {
data.yesCallbackWaiting = false;
}
}
window.app.modals.close(confirmElem);
},
},
() => {
if (typeof data.yesButton === "string") {
return t.span({ className: "txt" }, data.yesButton);
}
if (typeof data.yesButton === "function") {
return data.yesButton(el);
}
return data.yesButton;
},
),
),
),
),
);
+60
View File
@@ -0,0 +1,60 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
// @todo consider normalizing and changing args to props
/**
* Returns a "Copy" icon button for the specified value.
*
* @example
* ```js
* app.components.copyButton("test")
* ```
*
* @param {string|function} The value to copy.
* @param {Array} [children] Optional children to append after the icon.
* @return {Element}
*/
window.app.components.copyButton = function(textOrFunc, ...children) {
const data = store({
active: false,
});
let activeTimeoutId;
function copy() {
let value = textOrFunc;
if (typeof value == "function") {
value = textOrFunc();
}
app.utils.copyToClipboard(value);
data.active = true;
clearTimeout(activeTimeoutId);
activeTimeoutId = setTimeout(() => {
data.active = false;
}, 500);
}
return t.button(
{
tabIndex: -1,
type: "button",
className: () => `copy-to-clipboard ${data.active ? "active" : ""}`,
title: "Copy",
ariaDescription: app.attrs.tooltip(() => data.active ? "Copied" : null),
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
copy();
},
},
t.i({
hidden: children?.length,
className: () => `copy-icon ${data.active ? "ri-check-double-line" : "ri-file-copy-line"}`,
}),
...children,
);
};
+36
View File
@@ -0,0 +1,36 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Returns a new element with common helper links, usually shown in the footer.
*
* @return {Element}
*/
window.app.components.credits = function() {
return t.div(
{ pbEvent: "credits", className: "credits" },
() => {
return app.store.creditLinks.map((link) => {
const isLocal = link.href.startsWith("#/");
return t.a(
{
href: () => link.href,
target: () => !isLocal ? "_blank" : undefined,
rel: () => !isLocal ? "noopener noreferrer" : undefined,
className: (el) => {
const isActive = link.isActive?.(el) || app.utils.isActivePath(link.href, false);
return `credit-item ${isActive ? "active" : ""}`;
},
},
() => {
if (link.icon) {
return t.i({ className: link.icon });
}
},
t.span({ className: "txt" }, () => link.label),
);
});
},
);
};
+123
View File
@@ -0,0 +1,123 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* A vertical dragline to allow width resizing an element.
*
* @example
* ```js
* return app.components.dragline({
* ondragstart: (e) => {
* el._startWidth = el.offsetWidth;
* },
* ondragging: (e, diffX, diffY) => {
* el.style.width = el._startWidth + diffX + "px";
* },
* });
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.dragline = function(propsArg = {}) {
let elem;
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
tolerance: 0,
ondragstart: function(e) {},
ondragstop: function(e) {},
ondragging: function(e, diffX, diffY) {},
});
const watchers = app.utils.extendStore(props, propsArg);
const data = store({
dragStarted: false,
});
let startX, startY, shiftX, shiftY;
function addDocumentEvents() {
document.addEventListener("touchmove", onMove);
document.addEventListener("mousemove", onMove);
document.addEventListener("touchend", onStop);
document.addEventListener("mouseup", onStop);
}
function removeDocumentEvents() {
document.removeEventListener("touchmove", onMove);
document.removeEventListener("mousemove", onMove);
document.removeEventListener("touchend", onStop);
document.removeEventListener("mouseup", onStop);
}
function dragInit(e) {
e.stopPropagation();
startX = e.clientX;
startY = e.clientY;
shiftX = e.clientX - elem.offsetLeft;
shiftY = e.clientY - elem.offsetTop;
addDocumentEvents();
}
function onStop(e) {
if (data.dragStarted) {
e.preventDefault();
data.dragStarted = false;
props.ondragstop?.(e);
}
removeDocumentEvents();
}
function onMove(e) {
let diffX = e.clientX - startX;
let diffY = e.clientY - startY;
let left = e.clientX - shiftX;
let top = e.clientY - shiftY;
if (
!data.dragStarted
&& Math.abs(left - elem.offsetLeft) < props.tolerance
&& Math.abs(top - elem.offsetTop) < props.tolerance
) {
return;
}
e.preventDefault();
if (!data.dragStarted) {
data.dragStarted = true;
props.ondragstart?.(e);
}
props.ondragging?.(e, diffX, diffY);
}
elem = t.div({
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => `dragline ${data.dragStarted ? "dragging" : ""} ${props.className}`,
onmousedown: (e) => {
if (e.button == 0) {
dragInit(e);
}
},
ontouchstart: dragInit,
onunmount: () => {
removeDocumentEvents();
watchers.forEach((w) => w?.unwatch());
},
});
return elem;
};
+98
View File
@@ -0,0 +1,98 @@
const optionClass = "dropdown-item";
document.addEventListener(
"toggle",
(e) => {
if (e.newState != "open" || !e.target?.matches(".dropdown") || e.target.__keyboardNavRegistered) {
return;
}
e.target.__keyboardNavRegistered = true;
const dropdown = e.target;
function onKeydown(e) {
// remove keydown listener in case the element was removed while still open
if (!dropdown.isConnected) {
document.removeEventListener("keydown", onKeydown);
return;
}
let optElem;
if (document.activeElement && document.activeElement.classList.contains(optionClass)) {
optElem = document.activeElement;
} else {
optElem = dropdown.querySelector("." + optionClass + ":not([hidden]):not(.disabled)");
}
if (!optElem) {
return;
}
if (e.key == "ArrowUp") {
e.preventDefault();
const prevElem = firstActiveSibling(optElem, -1);
if (optElem == document.activeElement && prevElem?.classList?.contains(optionClass)) {
prevElem?.focus();
} else {
optElem.focus();
}
} else if (e.key == "ArrowDown") {
e.preventDefault();
const nextElem = firstActiveSibling(optElem, 1);
if (optElem == document.activeElement && nextElem?.classList?.contains(optionClass)) {
nextElem?.focus();
} else {
optElem.focus();
}
} else if (
// a-z
(e.keyCode >= 65 && e.keyCode <= 90)
// 0-9
|| (e.keyCode >= 48 && e.keyCode <= 57)
) {
// autofocus the only available input when start typing
// (e.g. for search)
const inputs = dropdown.querySelectorAll("input,textare,select");
if (inputs.length == 1) {
inputs[0].focus();
}
}
}
dropdown.addEventListener("toggle", (e) => {
if (e.newState == "open") {
updatePopovertargetsData(e.target.id, true);
document.addEventListener("keydown", onKeydown);
} else {
updatePopovertargetsData(e.target.id, false);
document.removeEventListener("keydown", onKeydown);
}
});
},
true,
);
function updatePopovertargetsData(popoverId, state = false) {
if (!popoverId) {
return;
}
document.querySelectorAll("[popovertarget='" + popoverId + "']")
?.forEach((el) => el.setAttribute("data-popover-state", state));
}
function firstActiveSibling(el, dir = -1) {
const sibling = dir < 0 ? el.previousElementSibling : el.nextElementSibling;
if (
sibling
&& (sibling.hidden || sibling.classList.contains("disabled") || !sibling.classList.contains(optionClass))
) {
return firstActiveSibling(sibling, dir);
}
return sibling;
}
+467
View File
@@ -0,0 +1,467 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
const maxScale = 2;
const minScale = 0.4;
const areaPadding = 40;
/**
* Component for rendering ERD-like draggable chart based on the specified collections.
*
* @example
* ```js
* return app.components.erd({
* collections: () => [collection1, collection2, collection3],
* });
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.erd = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
collections: [],
height: 500, // number or CSS string
cols: 5,
marginX: 90,
marginY: 70,
scale: 0.8,
onscalechange: (newScale) => {},
});
const watchers = app.utils.extendStore(props, propsArg);
const data = store({
activeCollection: null,
positions: {},
viewX: 0,
viewY: 0,
panStartX: 0,
panStartY: 0,
isPanning: false,
isUpdating: true,
});
let erdEl;
let initialScale;
const uniqueId = "erd_" + app.utils.randomString();
watchers.push(watch(() => props.scale, (newScale) => {
if (newScale > maxScale) {
props.scale = maxScale;
}
if (newScale < minScale) {
props.scale = minScale;
}
if (!initialScale) {
initialScale = newScale;
}
props.onscalechange?.(newScale);
}));
watchers.push(watch(() => JSON.stringify(props.collections), (_, oldHash) => {
if (typeof oldHash == "undefined") {
return; // initial
}
updateTablePositions();
}));
async function updateTablePositions(withRelations = true) {
data.isUpdating = true;
await new Promise((r) => setTimeout(r, 0));
data.positions = {};
const colYOffsets = new Array(props.cols).fill(areaPadding);
erdEl.querySelectorAll(".erd-table")?.forEach((table, i) => {
const colIndex = i % props.cols;
data.positions[table.dataset.collectionId] = {
x: areaPadding + colIndex * (table.clientWidth + props.marginX),
y: colYOffsets[colIndex],
};
colYOffsets[colIndex] += table.clientHeight + props.marginY;
});
horizontalCenter();
if (withRelations) {
await updateRelations();
} else {
clearRelations();
}
data.isUpdating = false;
}
function clearRelations() {
backPathsGroup.innerHTML = "";
frontPathsGroup.innerHTML = "";
}
async function updateRelations() {
await new Promise((r) => setTimeout(r, 0));
clearRelations();
const paths = [];
for (const collection of props.collections) {
const fields = collection.fields || [];
for (const field of fields) {
if (field.type != "relation") {
continue;
}
const fromEl = erdEl?.querySelector(`[data-collection-id="${collection.id}"]`)
?.querySelector(`[data-field-name="${field.name}"]`);
const toEl = erdEl?.querySelector(`[data-collection-id="${field.collectionId}"]`)
?.querySelector(`[data-field-name="id"]`);
paths.push(createPath(fromEl, toEl, props.scale, collection.id, field.collectionId));
}
}
if (paths.length) {
backPathsGroup.append(...paths);
}
}
function horizontalCenter() {
const containerWidth = erdEl.clientWidth || 0;
const tableWidth = erdEl.querySelector(".erd-table")?.offsetWidth || 0;
const areaWidth = 2 * areaPadding + (props.cols * (tableWidth + props.marginX)) - props.marginX;
data.viewY = 0;
data.viewX = (containerWidth - areaWidth * props.scale) / 2;
}
function resetScale() {
props.scale = initialScale || 1;
horizontalCenter();
}
function isHighlighted(collection) {
if (!data.activeCollection) {
return false;
}
if (data.activeCollection.id == collection.id) {
return true;
}
const relFromField = data.activeCollection.fields?.find((f) =>
f.type == "relation" && f.collectionId == collection.id
);
if (relFromField) {
return true;
}
const relToField = collection.fields?.find((f) =>
f.type == "relation" && f.collectionId == data.activeCollection.id
);
return !!relToField;
}
// maintain 2 svg layers because z-index on paths can't escape the svg
//
// (for the path commands see https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/d#path_commands)
// ---
let svgBack = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgBack.classList.add("erd-paths", "back");
svgBack.innerHTML = `
<defs>
<marker id="${uniqueId}_arrow1" class="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto" fill="context-stroke">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
</defs>
<g class="paths-group" marker-end="url(#${uniqueId}_arrow1)"></g>
`;
let backPathsGroup = svgBack.querySelector(".paths-group");
let svgFront = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgFront.classList.add("erd-paths", "front");
svgFront.innerHTML = `
<defs>
<marker id="${uniqueId}_arrow2" class="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto" fill="context-stroke">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
</defs>
<g class="paths-group" marker-end="url(#${uniqueId}_arrow2)"></g>
`;
let frontPathsGroup = svgFront.querySelector(".paths-group");
// ---
erdEl = t.div(
{
tabIndex: -1,
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () =>
// dprint-ignore
`erd ${props.className} ${data.isUpdating ? "updating" : ""} ${data.isPanning ? "panning" : ""} ${data.activeCollection ? "active" : ""}`,
onkeydown: (e) => {
if ((e.ctrlKey || e.metaKey) && e.key == "0") {
resetScale();
}
},
onmount: async (el) => {
// zoom
el.addEventListener("wheel", (e) => {
e.preventDefault();
const rect = el.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const layoutX = (mouseX - data.viewX) / props.scale;
const layoutY = (mouseY - data.viewY) / props.scale;
const newScale = Math.min(Math.max(-e.deltaY * 0.001 + props.scale, minScale), maxScale);
data.viewX = mouseX - layoutX * newScale;
data.viewY = mouseY - layoutY * newScale;
props.scale = newScale;
});
// pan
el.addEventListener("pointerdown", (e) => {
if (e.buttons != 1) {
return;
}
data.isPanning = true;
data.panStartX = e.clientX - data.viewX;
data.panStartY = e.clientY - data.viewY;
});
el._ondragging = function(e) {
if (!data.isPanning) {
return;
}
data.viewX = e.clientX - data.panStartX;
data.viewY = e.clientY - data.panStartY;
};
el._ondragstop = function() {
data.isPanning = false;
};
// note: attach mouse end events to window to allow panning when outside the element
window.addEventListener("pointermove", el._ondragging);
window.addEventListener("pointerup", el._ondragstop);
updateTablePositions();
},
onunmount: (el) => {
window.removeEventListener("pointermove", el._ondragging);
window.removeEventListener("pointerup", el._ondragstop);
watchers.forEach((w) => w?.unwatch());
},
},
t.div(
{
className: "erd-area",
style: () => `transform: translate(${data.viewX}px, ${data.viewY}px) scale(${props.scale});`,
},
svgBack,
t.div(
{ className: "erd-tables" },
() => {
return props.collections.map((collection) => {
return t.div(
{
// dpint-ignore
style: () =>
`left:${data.positions[collection.id]?.x || 0}px;top:${
data.positions[collection.id]?.y || 0
}px`,
className: () =>
`erd-table type-${collection.type} ${isHighlighted(collection) ? "active" : ""} ${
collection.system ? "system" : ""
}`,
"html-data-collection-id": () => collection.id,
"html-data-collection-name": () => collection.name,
onmouseenter: () => {
backPathsGroup.querySelectorAll(`[data-to="${collection.id}"]`)?.forEach(
(child) => {
child.classList.add("active-to");
frontPathsGroup.append(child);
},
);
backPathsGroup.querySelectorAll(`[data-from="${collection.id}"]`)?.forEach(
(child) => {
child.classList.add("active-from");
frontPathsGroup.append(child);
},
);
data.activeCollection = collection;
},
onmouseleave: () => {
for (const child of frontPathsGroup.children) {
child.classList.remove("active-from", "active-to");
}
backPathsGroup.append(...frontPathsGroup.children);
data.activeCollection = null;
},
},
t.div(
{ className: "erd-table-row header" },
() => collection.name,
),
() => {
return collection.fields?.map((field) => {
return t.div(
{
className: `erd-table-row type-${field.type} ${
field.primaryKey ? "primary-key" : ""
}`,
"html-data-field-id": () => field.id,
"html-data-field-name": () => field.name,
},
t.i({
title: () => field.type,
className: () =>
`field-icon ${
app.fieldTypes[field.type].icon || app.utils.fallbackFieldIcon
}`,
}),
t.span({ className: "field-name" }, () => field.name),
() => {
if (field.hidden) {
return t.span(
{ className: "label danger field-hidden-label" },
"Hidden",
);
}
},
() => {
if (typeof field.maxSelect != "undefined") {
return t.span(
{ className: "meta" },
field.maxSelect > 1 ? "multiple" : "single",
);
}
},
);
});
},
);
});
},
),
svgFront,
),
t.nav(
{
className: "erd-nav",
onmousedown: (e) => {
e.stopImmediatePropagation();
},
},
t.button(
{
type: "button",
className: "btn sm circle secondary",
title: "Zoom in",
onclick: () => {
props.scale += 0.05;
},
},
t.i({ className: "ri-add-line", ariaHidden: true }),
),
t.button(
{
type: "button",
className: "btn sm circle secondary",
title: "Zoom out",
onclick: () => {
props.scale -= 0.05;
},
},
t.i({ className: "ri-subtract-line", ariaHidden: true }),
),
),
);
return erdEl;
};
// creates a new svg path line in svgEl to visually connect el1 and el2.
function createPath(
el1,
el2,
scale = 1,
fromCollectionId = "",
toCollectionId = "",
extraSpacing = 2,
) {
if (!el1 || !el2) {
return;
}
const workspaceRect = el1.closest(".erd-area").getBoundingClientRect();
const r1 = el1.getBoundingClientRect();
const r2 = el2.getBoundingClientRect();
extraSpacing *= scale;
let y1 = (r1.top - workspaceRect.top) + r1.height / 2;
let y2 = (r2.top - workspaceRect.top) + r2.height / 2;
let x1, x2;
if (r1.left < r2.left) {
// left -> right
x1 = (r1.left - workspaceRect.left) + r1.width + extraSpacing;
x2 = (r2.left - workspaceRect.left) - extraSpacing;
} else if (r1.left > r2.left) {
// right <- left
x1 = (r1.left - workspaceRect.left) - extraSpacing;
x2 = (r2.left - workspaceRect.left) + r2.width + extraSpacing;
} else {
// within the same column
x1 = (r1.left - workspaceRect.left) - extraSpacing;
x2 = (r2.left - workspaceRect.left) - extraSpacing;
}
// rescale
x1 /= scale;
x2 /= scale;
y1 /= scale;
y2 /= scale;
// mid point for the line squirish/orthogonal break
let midX = x1 + (x2 - x1) / 2;
if (x1 == x2) {
midX -= 20; // offset slightly to prevent overlap when the 2 elements are in the same column
}
const d = `M ${x1} ${y1}
L ${midX} ${y1}
L ${midX} ${y2}
L ${x2} ${y2}`;
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("class", "relation-path");
path.setAttribute("data-from", fromCollectionId || "");
path.setAttribute("data-to", toCollectionId || "");
path.setAttribute("d", d);
return path;
}
+439
View File
@@ -0,0 +1,439 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
export const toDeleteProp = "@toDelete";
window.app.components.fieldSettings = function(data, settingsArg = {}) {
const uniqueId = "base_" + app.utils.randomString();
const settings = store({
// options
showHidden: true,
showPresentable: true,
showDuplicate: true,
showRemove: true, // for system fields this is ignored
// slots
header: (el) => null,
content: (el) => null,
footer: (el) => null,
});
const watchers = app.utils.extendStore(settings, settingsArg);
function duplicateField() {
const clone = JSON.parse(JSON.stringify(data.field));
clone.id = "";
clone.system = false;
clone.name = getUniqueFieldName(data.collection.fields, clone.name + "_copy");
clone.__detailsOpen = true;
if (clone.primaryKey) {
clone.primaryKey = false;
}
if (clone[toDeleteProp]) {
delete clone[toDeleteProp];
}
data.collection.fields.splice(data.fieldIndex + 1, 0, clone);
}
function removeField() {
if (data.field.id) {
// existing fields are only marked as deleted
data.field[toDeleteProp] = true;
} else {
// new fields are immediately removed
data.collection.fields.splice(data.fieldIndex, 1);
}
}
return t.details(
{
// duplicate the class as raw attribute because the reactive state
// is applied AFTER mount (MutationObserver related quirk)
"html-class": "accordion record-field-settings",
className: () =>
`accordion record-field-settings field-type-${data.field.type} ${
data.field[toDeleteProp] ? "deleted" : ""
}`,
name: "collection_field",
onmount: (el) => {
if (data.field.__detailsOpen) {
delete data.field.__detailsOpen;
el.open = true;
}
// name normalizer
watchers.push(
watch(
() => data.field.name,
(newName, oldName) => {
newName = app.utils.slugify(newName);
data.field.name = newName;
if (typeof oldName == "undefined") {
return;
}
replaceIndexesColumn(data.collection, oldName, newName);
replaceIdentityFields(data.collection, oldName, newName);
},
),
);
// reset the name if it was previously deleted
watchers.push(
watch(
() => data.field[toDeleteProp],
(deleted) => {
if (deleted && data.originalField?.name && data.field.name != data.originalField.name) {
data.field.name = data.originalField.name;
}
},
),
);
// disable presentable
watchers.push(
watch(() => {
if (data.field.presentable && data.field.hidden) {
data.field.presentable = false;
app.toasts.info("The field cannot be presentable if hidden.");
}
}),
);
// special cases for some system fields
watchers.push(
watch(() => {
if (
(data.field.name == "id"
|| (data.collection.type == "auth"
&& ["password", "tokenKey"].includes(data.field.name)))
&& data.originalField
&& data.field.required != data.originalField.required
) {
data.field.required = data.originalField.required;
app.toasts.info(`The option cannot be changed for field "${data.field.name}".`);
}
}),
);
// prevent hidden prop change for special fields
watchers.push(
watch(() => {
if (
(data.field.name == "id"
|| (data.collection.type == "auth"
&& ["password", "tokenKey", "email"].includes(data.field.name)))
&& data.originalField
&& data.field.hidden != data.originalField.hidden
) {
data.field.hidden = data.originalField.hidden;
app.toasts.info(`The option cannot be changed for field "${data.field.name}".`);
}
}),
);
},
onunmount: (el) => {
watchers.forEach((w) => w?.unwatch());
},
},
t.summary(
{ tabIndex: -1, onfocusout: () => false, onclick: () => false, onkeyup: () => false },
t.span({ className: "sort-handle" }, t.i({ className: "ri-draggable" })),
t.header(
{
className: "header-fields",
inert: () => data.field[toDeleteProp],
onclick: (e) => {
e.stopPropagation();
e.preventDefault();
},
},
t.div(
{ className: "fields" },
t.label(
{
htmlFor: uniqueId + ".name",
className: () => `field addon ${data.field.system ? "txt-disabled" : ""}`,
},
t.i({
className: app.fieldTypes[data.field.type]?.icon || app.utils.fallbackFieldIcon,
ariaDescription: app.attrs.tooltip(() => {
if (data.field.system) {
return data.field.type + " (system)";
}
return data.field.type;
}),
}),
),
t.div(
{ className: "field prop-name" },
t.input({
type: "text",
id: uniqueId + ".name",
name: () => `fields.${data.fieldIndex}.name`,
required: true,
spellcheck: false,
placeholder: "Field name*",
className: "inline-error",
disabled: () => data.field[toDeleteProp] || data.field.system,
value: () => data.field.name || "",
oninput: (e) => {
if (e.isComposing) {
return;
}
data.field.name = e.target.value;
},
onmount: (nameInput) => {
nameInput.addEventListener("compositionend", (e) => {
data.field.name = e.target.value;
});
setTimeout(() => {
if (nameInput && data.field.__focus) {
nameInput.select();
delete data.field.__focus;
}
}, 0);
},
}),
t.div({ className: "field-labels" }, () => {
const labels = [];
if (data.field.required) {
labels.push(t.span({ className: "label success" }, "Required"));
}
if (data.field.hidden) {
labels.push(t.span({ className: "label danger" }, "Hidden"));
} else if (data.field.presentable) {
labels.push(t.span({ className: "label info" }, "Presentable"));
}
return labels;
}),
),
),
(el) => {
if (typeof settings.header == "function") {
return settings.header(el);
}
return settings.header;
},
),
t.button(
{
type: "button",
className: () => {
const hasError = !app.utils.isEmpty(
app.utils.getByPath(app.store.errors, `fields.${data.fieldIndex}`),
);
return `btn sm circle transparent secondary ${hasError ? "txt-danger" : ""}`;
},
title: "Field options",
hidden: () => data.field[toDeleteProp],
onclick: (e) => {
const details = e.target.closest("details");
if (details) {
details.open = !details.open;
}
},
},
t.i({ className: "ri-settings-3-line" }),
),
t.button(
{
type: "button",
className: "btn sm circle transparent warning",
hidden: () => !data.field[toDeleteProp],
onclick: () => delete data.field[toDeleteProp],
ariaDescription: app.attrs.tooltip("Restore"),
},
t.i({ className: "ri-restart-line" }),
),
),
(el) => {
if (typeof settings.content == "function") {
return settings.content(el);
}
return settings.content;
},
t.footer(
{ className: "record-field-settings-footer" },
(el) => {
if (typeof settings.footer == "function") {
return settings.footer(el);
}
return settings.footer;
},
() => {
if (!settings.showPresentable) {
return;
}
return t.div(
{ className: "field prop-presentable" },
t.input({
type: "checkbox",
id: uniqueId + ".presentable",
name: () => `fields.${data.fieldIndex}.presentable`,
className: "sm",
disabled: () => data.field.hidden,
checked: () => !!data.field.presentable,
onchange: (e) => (data.field.presentable = e.target.checked),
}),
t.label(
{ htmlFor: uniqueId + ".presentable" },
t.span({ className: "txt" }, "Presentable"),
t.i({
className: "ri-information-line link-hint",
ariaDescription: app.attrs.tooltip(
() => {
let msg =
"Whether the field should be preferred in the Superuser UI relation listings (default to auto).";
if (data.field.hidden) {
msg += "\nThe field cannot be presentable if hidden.";
}
return msg;
},
),
}),
),
);
},
() => {
if (!settings.showHidden) {
return;
}
return t.div(
{ className: "field prop-hidden" },
t.input({
type: "checkbox",
id: uniqueId + ".hidden",
className: "sm",
name: () => `fields.${data.fieldIndex}.hidden`,
checked: () => !!data.field.hidden,
onchange: (e) => (data.field.hidden = e.target.checked),
}),
t.label(
{ htmlFor: uniqueId + ".hidden" },
t.span({ className: "txt" }, "Hidden"),
t.i({
className: "ri-information-line link-hint",
ariaDescription: app.attrs.tooltip("Hide from the JSON API response and filters."),
}),
),
);
},
t.button(
{
hidden: () => !settings.showDuplicate && (!settings.showRemove || data.field.system),
type: "button",
className: "btn sm circle transparent secondary more-btn m-l-auto",
"html-popovertarget": uniqueId + "_options_dropdown",
},
t.i({ className: "ri-more-line", ariaHidden: true }),
),
t.div(
{
id: uniqueId + "_options_dropdown",
className: "dropdown sm field-options-dropdown",
popover: "auto",
},
() => {
if (!settings.showDuplicate) {
return;
}
return t.button({
type: "button",
className: "dropdown-item",
role: "menuitem",
textContent: "Duplicate",
onclick: (e) => {
duplicateField();
e.target.closest(".dropdown").hidePopover();
},
});
},
() => {
if (!settings.showRemove || data.field.system) {
return;
}
return t.button({
type: "button",
className: "dropdown-item",
role: "menuitem",
textContent: "Remove",
onclick: (e) => {
removeField();
e.target.closest(".dropdown").hidePopover();
e.target.closest("details").open = false;
},
});
},
),
),
);
};
function getUniqueFieldName(allFields, name = "field") {
let result = name;
let counter = 2;
let suffix = name.match(/\d+$/)?.[0] || ""; // extract numeric suffix
// name without the suffix
let base = suffix ? name.substring(0, name.length - suffix.length) : name;
while (!!allFields?.find((field) => field.name.toLowerCase() == result.toLowerCase())) {
result = base + ((suffix << 0) + counter);
counter++;
}
return result;
}
function replaceIdentityFields(collection, oldName, newName) {
if (
!newName
|| typeof oldName == "undefined"
|| oldName === newName
|| !collection?.passwordAuth?.identityFields?.length
) {
return;
}
let identityFields = collection.passwordAuth.identityFields;
for (let i = 0; i < identityFields.length; i++) {
if (identityFields[i] == oldName) {
identityFields[i] = newName;
}
}
}
function replaceIndexesColumn(collection, oldName, newName) {
if (
!newName
|| typeof oldName == "undefined"
|| oldName === newName
|| !collection?.indexes?.length
|| !collection?.fields?.length
) {
return;
}
// field with the old name exists so there is no need to rename index columns
if (!!collection.fields.find((f) => !f[toDeleteProp] && f.name == oldName)) {
return;
}
// update indexes on renamed fields
collection.indexes = collection.indexes.map((idx) => app.utils.replaceIndexColumn(idx, oldName, newName));
}
+119
View File
@@ -0,0 +1,119 @@
window.app = window.app || {};
window.app.modals = window.app.modals || {};
/**
* Opens a new file preview popup.
*
* @example
* ```js
* app.modals.openFilePreview(url)
* ```
*
* @param {string|Promise|Function} urlOrFactory
*/
window.app.modals.openFilePreview = function(urlOrFactory) {
const modal = filePreviewModal(urlOrFactory);
document.body.appendChild(modal);
app.modals.open(modal);
};
function filePreviewModal(urlOrFactory) {
const data = store({
url: "",
get filename() {
const url = data.url;
const queryParamsIdx = url.indexOf("?");
return url.substring(url.lastIndexOf("/") + 1, queryParamsIdx > 0 ? queryParamsIdx : undefined);
},
get fileType() {
return app.utils.getFileType(data.filename);
},
});
async function resolveUrlOrFactory() {
let url = "";
try {
if (typeof urlOrFactory == "function") {
url = await urlOrFactory();
} else {
// string or Promise
url = await urlOrFactory;
}
} catch (err) {
if (!err.isAbort) {
console.warn("resolveUrlOrFactory file preview failure:", err);
}
}
data.url = url;
return url;
}
async function openInNewTab() {
// resolve again because it may have expired
let url = await resolveUrlOrFactory();
if (!url) {
return;
}
window.open(url, "_blank", "noreferrer,noopener");
}
return t.div(
{
pbEvent: "filePreviewModal",
className: () => `modal preview preview-${data.fileType}`,
onbeforeopen: () => {
resolveUrlOrFactory();
},
onafterclose: (el) => {
el.remove();
},
},
t.div({ className: "modal-content" }, () => {
if (!data.url) {
return t.span({ className: "loader" });
}
if (data.fileType == "image") {
return t.img({
src: () => data.url,
alt: () => `Preview ${data.filename}`,
});
}
return t.object(
{
data: data.url, // note: the reactive value doesn't trigger reload of the object
title: () => data.filename,
},
"Cannot preview the file.",
);
}),
t.footer(
{ className: "modal-footer" },
t.button(
{
type: "button",
className: "link-hint filename-link",
ariaDescription: app.attrs.tooltip("Open in new tab"),
onclick: () => openInNewTab(),
},
t.span({ className: "txt" }, () => data.filename),
),
t.button(
{
type: "button",
className: "btn transparent m-l-auto",
onclick: () => app.modals.close(),
},
t.span({ className: "txt" }, "Close"),
),
),
);
}
+54
View File
@@ -0,0 +1,54 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone;
window.app.components.formattedDate = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
value: "",
short: false,
});
const watchers = app.utils.extendStore(props, propsArg);
return t.div(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
ariaDescription: app.attrs.tooltip(() => {
if (props.short && !!props.value) {
return app.utils.toLocalDatetime(props.value) + "\n" + tzName;
}
return null;
}),
"html-class": "formatted-date",
className: () => `formatted-date ${props.short ? "short" : "full"}`,
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
() => {
if (!props.value) {
return t.span({ className: "missing-value" });
}
if (props.short) {
const parts = props.value.split(" ");
return [
t.span({ className: "primary-date" }, parts[0]),
t.span({ className: "secondary-date" }, parts[1]),
];
}
return [
t.span({ className: "primary-date" }, app.utils.toLocalDatetime(props.value)),
t.span({ className: "secondary-date" }, props.value),
];
},
);
};
+313
View File
@@ -0,0 +1,313 @@
import L from "leaflet";
import "leaflet/dist/leaflet.css";
// manually load the markers so that they can be embedded in the prod bundle
import markerIconRetinaUrl from "leaflet/dist/images/marker-icon-2x.png";
import markerIconUrl from "leaflet/dist/images/marker-icon.png";
import markerShadowUrl from "leaflet/dist/images/marker-shadow.png";
const defaultZoomLevel = 8;
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Leaflet component for showing and adjust a single geo point value.
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.leaflet = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
point: { lat: 0, lon: 0 },
onchange: function(point) {},
});
const watchers = app.utils.extendStore(props, propsArg);
let map;
let marker;
let panTimeoutId;
watchers.push(
watch(
() => {
if (props.point.lat > 90) {
props.point.lat = 90;
}
if (props.point.lat < -90) {
props.point.lat = -90;
}
if (props.point.lon > 180) {
props.point.lon = 180;
}
if (props.point.lon < -180) {
props.point.lon = -180;
}
},
() => {
panInside();
},
),
);
function panInside(debounce = 200) {
if (!map) {
return;
}
clearTimeout(panTimeoutId);
panTimeoutId = setTimeout(() => {
marker?.setLatLng([props.point.lat, props.point.lon]);
map?.panInside([props.point.lat, props.point.lon], { padding: [20, 40] });
}, debounce);
}
function initMap(mapEl) {
const latlon = [toFixedCoord(props.point.lat), toFixedCoord(props.point.lon)];
map = L.map(mapEl, { zoomControl: false }).setView(latlon, defaultZoomLevel);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a>",
}).addTo(map);
// reassign the default marker images with the loaded ones
// (https://leafletjs.com/reference.html#icon-default-option)
L.Icon.Default.prototype.options.iconUrl = markerIconUrl;
L.Icon.Default.prototype.options.iconRetinaUrl = markerIconRetinaUrl;
L.Icon.Default.prototype.options.shadowUrl = markerShadowUrl;
L.Icon.Default.imagePath = "";
marker = L.marker(latlon, {
draggable: true,
autoPan: true,
}).addTo(map);
marker.bindTooltip("drag or right click anywhere on the map to move");
marker.on("moveend", (e) => {
if (e.sourceTarget?._latlng) {
select(e.sourceTarget._latlng.lat, e.sourceTarget._latlng.lng, false);
}
});
map.on("contextmenu", (e) => {
select(e.latlng.lat, e.latlng.lng, false);
});
}
function destroyMap() {
clearTimeout(panTimeoutId);
marker?.remove();
map?.remove();
}
function select(lat, lon, centerMap = true) {
const point = {
lat: toFixedCoord(lat),
lon: toFixedCoord(lon),
};
if (props.onchange && props.onchange(point) === false) {
return;
}
props.point = point;
// center the map
if (centerMap) {
marker?.setLatLng([props.point.lat, props.point.lon]); // optimistic marker update
map?.panTo([props.point.lat, props.point.lon], { animate: false });
}
map.getContainer()?.dispatchEvent(new CustomEvent("change", { detail: point }));
resetSearch?.();
}
const [searchEl, resetSearch] = initSearch(select);
return t.div(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: "map-container",
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
searchEl,
t.div({
className: "map-box",
onmount: (el) => {
initMap(el);
},
onunmount: () => {
destroyMap();
},
}),
);
};
function toFixedCoord(coord) {
return +(+coord).toFixed(6) || 0;
}
function initSearch(selectFunc = null) {
const data = store({
searchTerm: "",
isSearching: false,
searchResults: [],
});
let searchTimeoutId;
let searchAbortController;
function reset() {
searchAbortController?.abort("reset");
clearTimeout(searchTimeoutId);
data.isSearching = false;
data.searchResults = [];
data.searchTerm = "";
}
// note: using debounce > 1s to minimize hitting the API rate limits
// (see also https://operations.osmfoundation.org/policies/nominatim/)
function search(debounce = 1100) {
clearTimeout(searchTimeoutId);
searchAbortController?.abort("search debounce");
data.isSearching = true;
data.searchResults = [];
if (!data.searchTerm) {
data.isSearching = false;
return;
}
searchTimeoutId = setTimeout(async () => {
try {
searchAbortController = new AbortController();
const response = await fetch(
"https://nominatim.openstreetmap.org/search.php?format=jsonv2&q="
+ encodeURIComponent(data.searchTerm),
{ signal: searchAbortController.signal },
);
if (response.status != 200) {
throw new Error("OpenStreetMap API error " + response.status);
}
const results = [];
const addresses = await response.json();
for (const item of addresses) {
results.push({
lat: item.lat,
lon: item.lon,
name: item.display_name,
});
}
data.searchResults = results;
} catch (err) {
console.warn("[address search failed]", err);
}
data.isSearching = false;
}, debounce);
}
const searchInput = t.div(
{ className: "fields" },
t.div(
{ className: "field" },
t.input({
type: "text",
placeholder: "Search address...",
value: () => data.searchTerm,
oninput: (e) => (data.searchTerm = e.target.value),
}),
),
t.div({ className: "field addon p-l-10 p-r-10" }, () => {
if (data.isSearching) {
return t.span({ className: "loader sm" });
}
if (data.searchTerm.length) {
return t.button(
{
className: "link-hint",
title: "Clear search",
onclick: () => reset(),
},
t.i({ className: "ri-close-line" }),
);
}
}),
);
const searchDropdown = t.div({ className: "dropdown", popover: "manual" }, () => {
return data.searchResults.map((item) => {
return t.button(
{
type: "button",
className: "dropdown-item",
title: "Select address coordinates",
onclick: () => selectFunc?.(item.lat, item.lon),
},
item.name,
);
});
});
const watchers = [];
return [
t.div(
{
className: "map-search",
onmount: () => {
watchers.push(
watch(
() => data.searchTerm,
(searchTerm) => {
search(searchTerm);
},
),
);
watchers.push(
watch(
() => data.searchResults,
(results) => {
if (results.length) {
searchDropdown.showPopover({ source: searchInput });
} else {
searchDropdown.hidePopover();
}
},
),
);
},
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
reset();
},
},
searchInput,
searchDropdown,
),
reset,
];
}
+261
View File
@@ -0,0 +1,261 @@
window.app = window.app || {};
window.app.modals = window.app.modals || {};
const modalAttr = "data-modal-state";
const modalManualClass = "manual";
let oldActiveElem;
/**
* Initializes and opens `el` as modal.
*
* You can also make use of the following custom `el` function properties:
*
* - onbeforeopen(el) - triggered before the opening sequence (return `false` to stop)
* - onafteropen(el) - triggered after the opening sequence
* - onbeforeclose(el, forceClosed) - triggered before the closing sequence (return `false` to stop);
* (note that when force closing the result of this callback is ignored)
* - onafterclose(el, forceClosed) - triggered after the closing sequence
*
* @example
* ```js
* modal = t.div(
* {
* className: "modal popup sm",
* onbeforeclose: (el) => {
* if (!someImportantCheck) {
* return false
* }
* },
* },
* t.header({ className: "modal-header" },
* t.h5({ className: "m-auto" }, "Logs settings"),
* ),
* t.form(
* {
* id: "myForm",
* className: "modal-content",
* onsubmit: (e) => {
* e.preventDefault();
* // ...
* },
* },
* // ... content ...
* ),
* t.footer({ className: "modal-footer" },
* t.button(
* {
* type: "button",
* className: "btn transparent m-r-auto",
* onclick: () => app.modals.close(),
* },
* t.span({ className: "txt" }, "Close"),
* ),
* t.button(
* {
* "html-form": "myForm",
* type: "submit",
* className: "btn",
* },
* t.span({ className: "txt" }, "Save changes"),
* ),
* ),
* );
*
* app.modals.open(modal)
* ```
*
* @param {Element} el
*/
window.app.modals.open = async function(el) {
if (!el?.isConnected) {
console.error("modals.open requies an active DOM element", el);
return;
}
let beforeopen;
if (el.onbeforeopen) {
beforeopen = await el.onbeforeopen(el);
}
if (beforeopen === false) {
return;
}
// note: currently doesn't wait for the entrance animation but this may change in the future
if (el.onafteropen) {
let resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentRect.height > 0 && el?.onafteropen) {
el.onafteropen(el);
resizeObserver.disconnect();
resizeObserver = null; // observers uses weak but clear it explicitly nonetheless
}
}
});
resizeObserver.observe(el);
}
oldActiveElem = document.activeElement;
// init (if not already)
initModal(el);
// force close on history change
el._forceClose = () => app.modals.close(el, true);
window.addEventListener("popstate", el._forceClose);
const largestZIndex = Math.max(findLargestZIndexModal(el)?.style.zIndex << 0, 1000);
el.style.zIndex = largestZIndex + 1;
// note: use data attribute to avoid conflict with reactive className
el.setAttribute(modalAttr, "open");
};
/**
* Closes the specified modal (or the last/top open one if not explicitly set).
*
* Example:
* ```js
* app.modals.close()
* app.modals.close(myModal)
*
* // force close
* app.modals.close(null, true)
* app.modals.close(myModal, true)
* ```
*
* @param {Element} [el]
* @param {boolean} [forceClose]
*/
window.app.modals.close = async function(el = null, forceClose = false) {
el = el || findLargestZIndexModal();
if (!el) {
return;
}
window.removeEventListener("popstate", el._forceClose);
if (forceClose) {
el.onbeforeclose?.(el, true);
el.setAttribute(modalAttr, "close");
el.onafterclose?.(el, true);
} else {
if (
el.onbeforeclose
&& (await el.onbeforeclose(el, false)) === false
) {
return;
}
if (el.onafterclose) {
let resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentRect.height <= 0 && el?.onafterclose) {
el.onafterclose(el, false);
resizeObserver.disconnect();
resizeObserver = null; // observers uses weak ref but clear it explicitly nonetheless
}
}
});
resizeObserver.observe(el);
}
el.setAttribute(modalAttr, "close");
}
// restore original focus position without making the focus visible
if (oldActiveElem) {
oldActiveElem.focus?.();
setTimeout(() => {
oldActiveElem?.blur?.();
oldActiveElem = null;
}, 0);
}
};
function initModal(el) {
if (!el.getAttribute("tabindex")) {
el.setAttribute("tabindex", "-1");
}
// focus to capture key events and to change the tab navigation to the modal
// (execute after the element rendering task)
setTimeout(() => {
const autofocusEl = el?.querySelector("[autofocus]");
if (autofocusEl) {
autofocusEl.focus();
} else {
el?.focus();
}
}, 0);
// already initialized
if (el.getAttribute(modalAttr)) {
return;
}
el.setAttribute(modalAttr, "");
// dismiss handlers
el.addEventListener("keydown", (e) => {
if (
e.key != "Escape"
|| el.classList.contains(modalManualClass)
|| (e.target !== el && el.contains(e.target))
) {
return;
}
window.app.modals.close(el);
});
let startedInside = false;
const startedInsideFunc = (e) => {
startedInside = e.target !== el && el.contains(e.target);
};
el.addEventListener("mousedown", startedInsideFunc);
el.addEventListener("touchstart", startedInsideFunc);
let endedInside = false;
const endedInsideFunc = (e) => {
endedInside = e.target !== el && el.contains(e.target);
};
el.addEventListener("mouseup", endedInsideFunc);
el.addEventListener("touchend", endedInsideFunc);
el.addEventListener("click", (e) => {
if (
startedInside
|| endedInside
|| el.classList.contains(modalManualClass)
// e.g. in case a btn is clicked with the keyboard (Enter/Space/etc.)
|| (e.target !== el && el.contains(e.target))
) {
return;
}
window.app.modals.close(el);
});
}
function findLargestZIndexModal(excludeEl) {
const opened = document.querySelectorAll(`[${modalAttr}="open"]`);
let z = 0;
let max = 0;
let largest;
for (const m of opened) {
if (excludeEl && m == excludeEl) {
continue;
}
z = m.style.zIndex << 0;
if (z > max) {
max = z;
largest = m;
}
}
return largest;
}
+170
View File
@@ -0,0 +1,170 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
const responsiveThreshold = 1000;
/**
* A generic page sidebar component with resizable edge.
*
* @example
* ```js
* app.components.pageSidebar({},
* t.nav({ className: "sidebar-content scrollable" },
* t.details({ className: "nav-group"},
* t.summary({ tabIndex: -1, onfocusout: () => false, onclick: () => false, onkeyup: () => false },
* "Group 1",
* ),
* t.a({ className: "nav-link", href: "..." }, "Link 1.1"),
* t.a({ className: "nav-link", href: "..." }, "Link 1.2"),
* ),
* t.details({ className: "nav-group"},
* t.summary({ tabIndex: -1, onfocusout: () => false, onclick: () => false, onkeyup: () => false },
* "Group 2",
* ),
* t.a({ className: "nav-link", href: "..." }, "Link 2.1"),
* t.a({ className: "nav-link", href: "..." }, "Link 2.2"),
* ),
* )
* ```
*
* @param {Object} [propsArg]
* @param {Array<Element|Function>} [children]
* @return {Element}
*/
window.app.components.pageSidebar = function(propsArg = {}, ...children) {
let sidebarElem;
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
widthHistoryKey: "pbPageSidebarWidth",
onmount: undefined,
onunmount: undefined,
});
const watchers = app.utils.extendStore(props, propsArg);
const data = store({
responsiveShow: false,
});
let responsiveBtn;
function responsivePageSidebar() {
if (!sidebarElem) {
return;
}
if (window.innerWidth > responsiveThreshold) {
data.responsiveShow = false;
sidebarElem.dataset.responsive = false;
responsiveBtn?.remove();
responsiveBtn = null;
return;
}
sidebarElem.dataset.responsive = true;
if (!responsiveBtn) {
responsiveBtn = t.button(
{
type: "button",
className: "btn transparent secondary responsive-sidebar-btn",
title: "Toggle sidebar",
onclick: (e) => {
e.stopPropagation();
data.responsiveShow = !data.responsiveShow;
},
},
t.i({ className: "ri-menu-2-line", ariaHidden: true }),
);
document.body.querySelector(".page-header .breadcrumbs").before(responsiveBtn);
}
}
function onOutsideClick(e) {
if (e.target.closest(".responsive-close")) {
data.responsiveShow = false;
return;
}
if (
e.target.closest(".page-sidebar")
|| e.target.closest(".app-header")
|| e.target.closest(".modal")
) {
return; // inside click -> do nothing
}
e.preventDefault();
e.stopImmediatePropagation();
data.responsiveShow = false;
return false;
}
watchers.push(
watch(() => data.responsiveShow, (visible) => {
if (visible) {
window.addEventListener("click", onOutsideClick, true);
} else {
window.removeEventListener("click", onOutsideClick, true);
}
}),
);
sidebarElem = t.aside(
{
pbEvent: "pageSidebar",
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => `page-sidebar ${props.className} ${data.responsiveShow ? "active" : ""}`,
onmount: (el) => {
responsivePageSidebar(el);
window.addEventListener("resize", responsivePageSidebar);
props.onmount?.(el);
},
onunmount: (el) => {
props.onunmount?.(el);
window.removeEventListener("click", onOutsideClick, true);
window.removeEventListener("resize", responsivePageSidebar);
responsiveBtn?.remove();
watchers.forEach((w) => w?.unwatch());
},
},
(el) => {
let sidebarWidth;
if (props.widthHistoryKey) {
sidebarWidth = localStorage.getItem(props.widthHistoryKey);
if (sidebarWidth) {
el.style.width = sidebarWidth;
}
}
return app.components.dragline({
ondragstart: (e) => {
el._startWidth = el.offsetWidth;
},
ondragging: (e, diffX, diffY) => {
sidebarWidth = el._startWidth + diffX + "px";
el.style.width = sidebarWidth;
if (props.widthHistoryKey) {
localStorage.setItem(props.widthHistoryKey, sidebarWidth);
}
},
});
},
...children,
);
return sidebarElem;
};
+71
View File
@@ -0,0 +1,71 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Animated refresh button element.
*
* @example
* ```js
* app.components.refreshButton({
* onclick: () => { console.log("clicked...") },
* })
* ```
*
* @param {Object} propsArg
* @return {Element}
*/
window.app.components.refreshButton = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
tooltip: "Refresh",
className: "btn transparent secondary circle rotate-btn",
disabled: false,
onclick: function(e) {},
});
const watchers = app.utils.extendStore(props, propsArg);
let refreshTimeoutId;
const btn = t.button(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
type: "button",
ariaDescription: app.attrs.tooltip(() => props.tooltip),
disabled: () => props.disabled,
className: () => props.className,
onunmount: () => {
clearTimeout(refreshTimeoutId);
watchers.forEach((w) => w?.unwatch());
},
onclick: (e) => {
e.preventDefault();
if (props.onclick) {
props.onclick(e);
}
btn.classList.add("rotate");
btn.addEventListener("animationend", () => {
btn.classList.remove("rotate");
});
// fallback
clearTimeout(refreshTimeoutId);
refreshTimeoutId = setTimeout(() => {
clearTimeout(refreshTimeoutId);
btn.classList.remove("rotate");
}, 500);
},
},
t.i({ className: "ri-refresh-line" }),
);
return btn;
};
+165
View File
@@ -0,0 +1,165 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* API rule input element.
*
* @example
* ```js
* app.components.ruleField({
* name: "listRule",
* autocomplete: (word) => {
* return app.utils.collectionAutocompleteKeys(someCollection, word);
* },
* value: () => someCollection.listRule,
* oninput: (newVal) => someCollection.listRule = newVal,
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.ruleField = function(propsArg = {}) {
const uniqueId = "rule_" + app.utils.randomString();
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
required: false,
disabled: false,
name: undefined,
label: undefined,
help: undefined,
value: null,
nullable: true,
placeholder: "Leave empty to grant everyone access...",
autocomplete: (word) => [],
oninput: (newVal) => {},
onmount: (el) => {},
onunmount: (el) => {},
// ---
get isLocked() {
return props.value == null;
},
});
const watchers = app.utils.extendStore(props, propsArg, "isLocked");
let ruleField;
let _prevValue = "";
function updateValue(newValue) {
props.value = newValue;
props.oninput?.(newValue);
ruleField?.dispatchEvent(new CustomEvent("change", { detail: newValue }));
}
function lock() {
if (props.value === null) {
return;
}
_prevValue = props.value;
updateValue(null);
}
function unlock() {
if (_prevValue != null) {
updateValue(_prevValue);
} else {
updateValue("");
}
setTimeout(() => {
document.getElementById(uniqueId)?.focus();
}, 0);
}
ruleField = t.div(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
"html-name": () => props.name, // used for the error reset
className: () =>
[
"field",
"rule-field",
props.required ? "required" : null,
props.value === null ? "locked" : null,
props.disabled ? "disabled" : null,
].filter(Boolean).join(" "),
onmount: (el) => {
props.onmount?.(el);
},
onunmount: (el) => {
props.onunmount?.(el);
watchers.forEach((w) => w?.unwatch());
},
},
t.label(
{ htmlFor: uniqueId },
(el) => {
if (!props.label) {
return t.span({ className: "txt" }, "Rule");
}
if (typeof props.label == "function") {
return props.label(el);
}
if (typeof props.label == "string") {
return t.span({ className: "txt" }, props.label);
}
return props.label;
},
t.span({ hidden: () => !props.isLocked, className: "txt superusers-label" }, "(Superusers only)"),
),
(el) => {
if (props.isLocked) {
return t.button(
{
type: "button",
className: "unlock-overlay",
disabled: () => props.disabled,
onclick: unlock,
},
t.span({ className: "txt" }, "Unlock and set custom rule"),
t.i({ className: "ri-lock-unlock-line", ariaHidden: true }),
);
}
return [
app.components.codeEditor({
id: uniqueId,
language: "pbrule",
required: () => props.required,
disabled: () => props.disabled,
value: () => props.value,
oninput: updateValue,
placeholder: () => props.placeholder,
autocomplete: props.autocomplete,
autocompleteContainer: el,
}),
t.button(
{
hidden: () => !props.nullable,
type: "button",
className: "superuser-toggle",
disabled: () => props.disabled,
onclick: lock,
},
t.i({ className: "ri-lock-line", ariaHidden: true }),
t.span({ className: "txt" }, "Set superusers only"),
),
];
},
);
return ruleField;
};
+216
View File
@@ -0,0 +1,216 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Returns a wrapper form element with common S3 config fields.
*
* @example
* ```js
* app.components.s3ConfigFields({
* config: () => data.settings.storage,
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.s3ConfigFields = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
config: {}, // S3 config store (pass as a function in case the object is being replaced)
configKey: "s3", // used for the fields error matching
toggleLabel: "Use S3 storage",
testFilesystem: "storage",
before: null,
after: null,
});
const watchers = app.utils.extendStore(props, propsArg);
if (props.configKey.endsWith(".")) {
props.configKey = props.configKey.substring(0, props.configKey.length - 1);
}
const data = store({
originalHash: "",
originalConfig: null,
});
watchers.push(
watch(
() => props.config,
(c) => {
data.originalHash = JSON.stringify(c);
data.originalConfig = JSON.parse(data.originalHash);
},
),
);
return t.div(
{
pbEvent: "s3ConfigFields",
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => `block s3-fields s3-config-${props.configKey} ${props.className}`,
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
t.div(
{ className: "field" },
t.input({
id: () => `${props.configKey}.enabled`,
name: () => `${props.configKey}.enabled`,
type: "checkbox",
className: "switch",
checked: () => props.config.enabled,
onchange: (e) => props.config.enabled = e.target.checked,
}),
t.label({ htmlFor: () => `${props.configKey}.enabled` }, () => props.toggleLabel),
),
(el) => {
if (typeof props.before == "function") {
return props.before(el);
}
return props.before;
},
app.components.slide(
() => props.config.enabled,
t.div(
{ className: "grid m-t-base" },
t.div(
{ className: "col-lg-6" },
t.div(
{ className: "field" },
t.label({ htmlFor: () => `${props.configKey}.endpoint` }, "Endpoint"),
t.input({
id: () => `${props.configKey}.endpoint`,
name: () => `${props.configKey}.endpoint`,
type: "text",
required: () => props.config.enabled,
value: () => props.config.endpoint || "",
oninput: (e) => (props.config.endpoint = e.target.value),
}),
),
),
t.div(
{ className: "col-lg-3" },
t.div(
{ className: "field" },
t.label({ htmlFor: () => `${props.configKey}.bucket` }, "Bucket"),
t.input({
id: () => `${props.configKey}.bucket`,
name: () => `${props.configKey}.bucket`,
type: "text",
required: () => props.config.enabled,
value: () => props.config.bucket || "",
oninput: (e) => (props.config.bucket = e.target.value),
}),
),
),
t.div(
{ className: "col-lg-3" },
t.div(
{ className: "field" },
t.label({ htmlFor: () => `${props.configKey}.region` }, "Region"),
t.input({
id: () => `${props.configKey}.region`,
name: () => `${props.configKey}.region`,
type: "text",
required: () => props.config.enabled,
value: () => props.config.region || "",
oninput: (e) => (props.config.region = e.target.value),
}),
),
),
t.div(
{ className: "col-lg-6" },
t.div(
{ className: "field" },
t.label({ htmlFor: () => `${props.configKey}.accessKey` }, "Access key"),
t.input({
id: () => `${props.configKey}.accessKey`,
name: () => `${props.configKey}.accessKey`,
type: "text",
autocomplete: "off",
required: () => props.config.enabled,
value: () => props.config.accessKey || "",
oninput: (e) => (props.config.accessKey = e.target.value),
}),
),
),
t.div(
{ className: "col-lg-6" },
t.div(
{
className: () => `field ${props.config.enabled ? "" : "required"}`,
},
t.label({ htmlFor: () => `${props.configKey}.secret` }, "Secret"),
t.input({
id: () => `${props.configKey}.secret`,
name: () => `${props.configKey}.secret`,
type: "password",
autocomplete: "new-password",
value: () => props.config.secret || "",
oninput: (e) => (props.config.secret = e.target.value),
onkeyup: (e) => {
if (
e.key == "Backspace"
&& typeof props.config.secret === "undefined"
) {
props.config.secret = "";
}
},
placeholder: () => (typeof props.config.secret !== "undefined" ? "" : "* * * * * *"),
}),
),
),
t.div(
{ className: "col-lg-6", style: "min-height: 25px" },
t.div(
{ className: "field" },
t.input({
id: () => `${props.configKey}.forcePathStyle`,
name: () => `${props.configKey}.forcePathStyle`,
type: "checkbox",
checked: () => props.config.forcePathStyle || false,
onchange: (e) => (props.config.forcePathStyle = e.target.checked),
}),
t.label(
{ htmlFor: () => `${props.configKey}.forcePathStyle` },
t.span({ className: "txt" }, "Force path-style addressing"),
t.i({
className: "ri-information-line link-hint",
ariaDescription: app.attrs.tooltip(
`Forces the request to use path-style addressing, eg. "https://s3.amazonaws.com/BUCKET/KEY" instead of the default "https://BUCKET.s3.amazonaws.com/KEY".`,
),
}),
),
),
),
t.div({ className: "col-lg-6 txt-right" }, () => {
if (!props.config?.enabled || data.originalHash != JSON.stringify(props.config)) {
return;
}
return app.components.s3Test({
config: () => props.config,
testFilesystem: () => props.testFilesystem,
});
}),
),
),
(el) => {
if (typeof props.after == "function") {
return props.after(el);
}
return props.after;
},
);
};
+125
View File
@@ -0,0 +1,125 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* A helper label element that performs live S3 connectivity tests.
*
* ```js
* app.components.s3Test({
* config: () => data.settings.backups.s3,
* testFilesystem: "backups",
* })
* ```
*
* @param {Object} propsArg
* @return {Element}
*/
window.app.components.s3Test = function(propsArg = {}) {
const testRequestKey = "s3_test_request";
const props = store({
rid: undefined,
config: null, // S3 config store
label: "Use S3 storage",
testFilesystem: "storage", // "storage" or "backups"
});
const watchers = app.utils.extendStore(props, propsArg);
const data = store({
isTesting: false,
testError: null,
get hasError() {
return !app.utils.isEmpty(data.testError);
},
});
let testDebounceId;
let testTimeoutId;
function testS3WithDebounce(timeout = 150) {
if (!props.config.enabled) {
clearTimeout(testDebounceId);
return;
}
data.isTesting = true;
clearTimeout(testDebounceId);
testDebounceId = setTimeout(() => {
testS3();
}, timeout);
}
async function testS3() {
data.isTesting = true;
if (!props.config.enabled || !props.testFilesystem) {
data.testError = null;
data.isTesting = false;
return; // nothing to test
}
// auto cancel the test request after 30sec
app.pb.cancelRequest(testRequestKey);
clearTimeout(testTimeoutId);
testTimeoutId = setTimeout(() => {
app.pb.cancelRequest(testRequestKey);
data.testError = new Error("S3 test connection timeout.");
data.isTesting = false;
}, 30000);
try {
await app.pb.props.testS3(props.testFilesystem, {
requestKey: testRequestKey,
});
data.testError = null;
data.isTesting = false;
} catch (err) {
if (!err?.isAbort) {
data.testError = err;
data.isTesting = false;
clearTimeout(testTimeoutId);
}
}
}
watchers.push(
watch(
() => props.testFilesystem && props.config,
() => testS3WithDebounce(),
),
);
return t.div(
{
pbEvent: "s3Test",
rid: props.rid,
hidden: () => !props.testFilesystem,
className: () => `label s3-test-label txt-nowrap ${data.hasError ? "warning" : "success"}`,
ariaDescription: app.attrs.tooltip(() => data.testError?.data?.message),
onunmount: () => {
clearTimeout(testTimeoutId);
clearTimeout(testDebounceId);
watchers.forEach((w) => w?.unwatch());
},
},
() => {
if (data.isTesting) {
return t.span({ className: "loader sm" });
}
if (data.hasError) {
return [
t.i({ className: "ri-error-warning-line txt-warning" }),
t.span({ className: "txt" }, "Failed to establish S3 connection"),
];
}
return [
t.i({ className: "ri-checkbox-circle-line txt-success" }),
t.span({ className: "txt" }, "S3 connected successfully"),
];
},
);
};
+153
View File
@@ -0,0 +1,153 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Helper button that shows a dropdown with previous search attempts.
*
* @example
* ```js
* app.components.searchHistoryButton({
* historyKey: "anything", // localStorage history key
* value: () => data.search,
* onselect: (historyVal) => data.search = historyVal,
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.searchHistoryButton = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
value: undefined,
historyKey: "default",
max: 15,
openInNewTabParam: "filter",
btnClassName: "btn sm pill secondary transparent p-r-5",
onselect: function(val) {},
});
const watchers = app.utils.extendStore(props, propsArg);
const history = store({
items: app.utils.getLocalHistory(props.historyKey, []),
});
function addToHistory(val) {
removeFromHistory(val);
history.items.unshift(val);
}
function removeFromHistory(val) {
app.utils.removeByValue(history.items, val);
}
const uniqueId = "history_dropdown_" + app.utils.randomString();
watchers.push(
watch(
() => props.value,
(val) => {
if (val) {
addToHistory(val);
}
},
),
);
watchers.push(
watch(() => {
if (history.items.length > props.max) {
history.items = history.items.slice(0, props.max);
}
app.utils.saveLocalHistory(props.historyKey, history.items);
}),
);
const dropdown = t.div(
{
id: uniqueId,
className: "dropdown sm left nowrap history-searchbar-dropdown",
popover: "hint",
onclick: (e) => {
e.stopPropagation();
return false;
},
},
t.div({ className: "block p-5" }, t.small({ className: "txt-hint" }, "Search history")),
() => {
if (!history.items?.length) {
return t.div(
{ rid: "no-history", className: "block p-5" },
t.span(null, "Your recent searches will show up here."),
);
}
return history.items.slice(0, props.max).map((h) => {
return t.button(
{
type: "button",
className: "dropdown-item txt-code",
onclick: () => {
dropdown.hidePopover();
props.onselect?.(h);
addToHistory(h);
},
onauxclick: () => {
if (props.openInNewTabParam) {
addToHistory(h);
dropdown.hidePopover();
const url = app.utils.replaceHashQueryParams(
{
[props.openInNewTabParam]: h,
},
false,
);
window.open(url, "_blank");
}
},
},
t.span({ className: "txt-ellipsis", title: h, textContent: h }),
t.small(
{
role: "button",
className: "remove-btn link-hint m-l-auto p-l-5 p-r-5",
onauxclick: (e) => {
e.stopPropagation();
return false;
},
onclick: (e) => {
e.stopPropagation();
removeFromHistory(h);
return false;
},
},
t.i({ className: "ri-close-line" }),
),
);
});
},
);
return t.button(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
type: "button",
className: () => props.btnClassName,
"html-popovertarget": uniqueId,
onunmount: () => {
watchers?.forEach((w) => w?.unwatch());
},
},
t.i({ className: "ri-search-line" }),
t.i({ className: "ri-arrow-drop-down-line" }),
dropdown,
);
};
+128
View File
@@ -0,0 +1,128 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Generic page searchbar element.
*
* @example
* ```js
* app.components.searchbar({
* value: () => data.search,
* onsubmit: (newValue) => data.search = newValue,
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.searchbar = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
value: "",
className: "",
placeholder: "Search...",
disabled: false,
historyKey: "",
autocomplete: undefined, // Array<string|Object> | function(word): Array<string|Object>,
onsubmit: (newValue) => {},
});
const watchers = app.utils.extendStore(props, propsArg, "autocomplete");
const local = store({
value: "",
});
function submit() {
props.value = local.value;
props.onsubmit?.(local.value);
}
function clear() {
local.value = "";
submit();
}
watchers.push(
// init and local sync changes
watch(
() => props.value,
(searchTerm) => {
local.value = searchTerm;
},
),
);
return t.form(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => `fields searchbar ${props.className}`,
onsubmit: (e) => {
e.preventDefault();
submit();
},
onunmount: (el) => {
watchers.forEach((w) => w?.unwatch());
},
},
() => {
if (!props.historyKey) {
return;
}
return t.div(
{ className: "field addon p-l-5" },
app.components.searchHistoryButton({
historyKey: () => props.historyKey,
value: () => props.value,
onselect: (val) => {
local.value = val;
submit();
},
}),
);
},
t.div(
{ className: "field" },
app.components.codeEditor({
singleLine: true,
language: "pbrule",
className: () => props.historyKey ? "p-l-5" : "p-l-20",
placeholder: () => props.placeholder,
disabled: () => props.disabled,
value: () => local.value,
oninput: (val) => (local.value = val),
autocomplete: props.autocomplete,
}),
),
() => {
if (props.value.length > 0 || local.value.length > 0) {
return t.div(
{ rid: "search-ctrls", className: "field addon p-r-5" },
t.button(
{
type: "submit",
className: "btn sm pill warning",
hidden: () => props.value == local.value,
},
"Search",
),
t.button(
{
type: "button",
className: "btn sm pill secondary transparent",
onclick: () => clear(),
},
"Clear",
),
);
}
},
);
};
+331
View File
@@ -0,0 +1,331 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Generic custom select element.
* If max > 1 the select is multiple, otherwise - single (default).
*
* Note that if label or selected are custom DOM elements they need to be
* wrapped in a function to allow recreation when toggling the select options.
*
* @example
* ```js
* app.components.select({
* options: [
* { value: "opt1", label: "Opt 1" },
* { value: "opt2", label: "Opt 2", selected: "Opt 2 selected label" },
* { value: "opt2", label: () => t.div(null, "Custom element") },
* ],
* value: () => data.selected,
* onchange: (opts) => data.selected = opts.map((opt) => opt.value),
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.select = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined, // used for associating with a label
name: undefined, // used for error matching
hidden: undefined,
inert: undefined,
className: "",
value: undefined,
options: [], // [{value, label?, selected?}, ...]
before: null,
after: null,
max: 1,
searchThreshold: 6,
required: false,
disabled: false,
placeholder: "- Select -",
noItemsFoundText: "No items found",
onchange: function(selectedOpts) {},
ondropdowntoggle: function(e) {},
});
const watchers = app.utils.extendStore(props, propsArg);
if (props.max <= 0) {
props.max = 1;
}
const internalData = store({
selected: [],
search: "",
get hasSearch() {
return internalData.search?.length > 0;
},
get allowRemove() {
return !props.disabled && (!props.required || props.max > 1);
},
});
function syncSelected() {
if (typeof props.value === "undefined") {
return; // nothing to sync
}
const optVals = app.utils.toArray(props.value, true);
const cappedOptVals = optVals.slice(0, props.max || 1);
if (optVals.length != cappedOptVals.length) {
console.warn(
`[select] the provided select values (${optVals.length}) are more than the allowed max selected options (${cappedOptVals.length}):`,
optVals,
);
props.value = props.max > 1 ? cappedOptVals : cappedOptVals[0];
}
internalData.selected = optVals
.map((value) => {
return props.options.find((opt) => opt.value === value);
})
.filter(Boolean);
}
watchers.push(
watch(
() => props.value,
() => syncSelected(),
),
);
async function toggle(opt) {
const idx = internalData.selected.findIndex((o) => o.value === opt.value);
if (idx >= 0) {
if (!internalData.allowRemove) {
dropdown?.hidePopover();
return; // no change
}
internalData.selected.splice(idx, 1);
} else {
// clear last redundant elements (leaving place for the new selected)
let toRemove = internalData.selected.length - props.max;
while (toRemove >= 0) {
internalData.selected.pop();
toRemove--;
}
internalData.selected.push(opt);
}
if (props.max <= 1) {
dropdown?.hidePopover();
}
if (props.onchange) {
await props.onchange(internalData.selected);
syncSelected(); // manually sync in case in the onchange handler the value didn't change
}
// trigger custom change event for clearing field errors
if (selectedContainer?.isConnected) {
selectedContainer.dispatchEvent(
new CustomEvent("change", {
detail: internalData.selected,
bubbles: true,
}),
);
}
}
function isSelected(opt) {
return internalData.selected.findIndex((o) => o.value === opt.value) >= 0;
}
const searchInput = t.input({
type: "text",
placeholder: "Search...",
value: () => internalData.search,
oninput: (e) => (internalData.search = e.target.value),
});
function clearSearch(focus = false) {
internalData.search = "";
if (focus) {
searchInput?.focus();
}
}
const noItemsFoundElem = t.div({ className: "txt-hint txt-center m-0 p-5", hidden: true }, props.noItemsFoundText);
async function toggleNoItemsFoundElem() {
if (!dropdown) {
return;
}
await new Promise((r) => setTimeout(r, 0));
if (dropdown.querySelector(".select-option:not([hidden])")) {
noItemsFoundElem.hidden = true;
} else {
noItemsFoundElem.hidden = false;
}
}
const dropdown = t.div(
{
tabIndex: -1,
popover: "auto",
className: "dropdown",
onbeforetoggle: (e) => {
if (e.newState == "closed") {
clearSearch();
}
return props.ondropdowntoggle?.(e);
},
},
t.div(
{
className: "fields dropdown-search",
hidden: () => props.options.length < props.searchThreshold,
},
t.div({ className: "field" }, searchInput),
t.div(
{
className: "field addon p-r-5",
hidden: () => !internalData.hasSearch,
},
t.button(
{
type: "button",
className: "btn sm secondary transparent circle",
onclick: () => clearSearch(true),
},
t.i({ className: "ri-close-line" }),
),
),
),
() => props.before?.__raw || props.before,
() => {
return props.options.map((opt) => {
return t.button(
{
type: "button",
className: () => `dropdown-item select-option ${isSelected(opt) ? "active" : ""}`,
onclick: () => {
toggle(opt);
return false;
},
},
opt.label || opt.value,
);
});
},
noItemsFoundElem,
() => props.after?.__raw || props.after,
);
const selectedContainer = t.button(
{
type: "button",
id: () => props.id,
name: () => props.name,
disabled: () => props.disabled,
className: () => `selected-container ${props.className}`,
popoverTargetElement: dropdown,
onclick: (e) => {
e.stopPropagation();
},
},
() => {
if (!internalData.selected.length) {
return t.span({ rid: "selected-placeholder", className: "placeholder" }, () => props.placeholder);
}
return internalData.selected.map((opt) => {
return t.div({ className: "selected-item" }, opt.selected || opt.label || opt.value, () => {
if (!internalData.allowRemove) {
return;
}
return t.i({
tabIndex: -1,
role: "button",
className: "ri-close-line link-hint btn-option-unset",
ariaDescription: app.attrs.tooltip("Unset", "left"),
onclick: () => {
toggle(opt);
return false;
},
});
});
});
},
);
watchers.push(
watch(
() => props.options,
() => {
toggleNoItemsFoundElem();
},
),
);
// search watcher
let searchDebounce;
watchers.push(
watch(
() => internalData.search,
() => {
const normalizedSearch = internalData.search.toLowerCase().replaceAll(" ", "");
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
const options = dropdown.querySelectorAll(".select-option");
if (!normalizedSearch.length) {
options.forEach((opt) => (opt.hidden = false));
} else {
options.forEach((opt) => {
const txt = opt.textContent.toLowerCase().replaceAll(" ", "");
if (!txt.includes(normalizedSearch)) {
opt.hidden = true;
} else {
opt.hidden = false;
}
});
}
toggleNoItemsFoundElem();
}, 100);
},
),
);
return t.div(
{
rid: props.rid,
hidden: () => props.hidden,
inert: () => props.inert,
onmount: (el) => {
el.addEventListener("focusout", function(e) {
if (!e.relatedTarget || !el.contains(e.relatedTarget)) {
dropdown?.hidePopover();
}
});
},
onunmount: () => {
clearTimeout(searchDebounce);
watchers.forEach((w) => w.unwatch());
},
className: () => {
return [
"input",
"select",
props.max > 1 ? "multiple" : "single",
props.disabled ? "disabled" : "",
props.required ? "required" : "",
].join(" ");
},
},
selectedContainer,
dropdown,
);
};
+38
View File
@@ -0,0 +1,38 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Wraps the children elements in a collapsible container.
*
* @example
* ```js
* app.components.slide(
* () => data.showToggle,
* t.div(null, "child1..."),
* t.div(null, "child2..."),
* )
* ```
*
* @param {function} boolFunc Boolean function that indicates whether the container is visible or not.
* @param {Array<Element>} [children]
* @return {Element}
*/
window.app.components.slide = function(boolFunc, ...children) {
let initTimeoutId;
return t.div(
{
className: (el) => `block slide-block ${boolFunc?.(el) ? "" : "hidden"}`,
onmount: (el) => {
// add a ready attribute with slight delay to avoid @starting-style flickering
initTimeoutId = setTimeout(() => {
el?.setAttribute("data-slide", "1");
}, 200);
},
onunmount: () => {
clearTimeout(initTimeoutId);
},
},
...children,
);
};
+188
View File
@@ -0,0 +1,188 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Creates a reactive single level sortable container (nested sortables are not supported).
*
* @example
* ```js
* app.components.sortable({
* data: () => data.list,
* dataItem: (item) => t.strong(null, "ID:", () => item.id),
* })
* ```
*
* @param {Object>} [propsArg]
* @return {Element}
*/
window.app.components.sortable = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
data: [],
dataItem: function(item, i, parent) {
return t.span(null, "Item " + i);
},
onchange: function(sortedList, fromIndex, toIndex) {},
handle: "", // specific handle selector (if not set attached to the entire list item)
before: undefined,
after: undefined,
});
const watchers = app.utils.extendStore(props, propsArg);
function initSortEvents(listEl) {
function clearDragData() {
listEl.querySelectorAll(":scope > [data-dragstart=\"true\"]")?.forEach((item) => {
item.dataset.dragstart = false;
});
listEl.querySelectorAll(":scope > [data-dragover=\"true\"]")?.forEach((item) => {
item.dataset.dragover = false;
});
}
// drag
// ---
listEl.addEventListener("dragstart", (e) => {
if (props.handle && !e.target.closest(props.handle)) {
e.preventDefault();
return;
}
const child = closestChild(listEl, e.target);
if (child) {
child.dataset.dragstart = true;
}
});
listEl.addEventListener("dragenter", (e) => {
for (let child of listEl.children) {
if (child.dataset.dragover) {
child.dataset.dragover = false;
}
}
const to = closestChild(listEl, e.target);
if (to) {
to.dataset.dragover = true;
}
});
listEl.addEventListener("dragend", (e) => {
clearDragData();
});
// drop
// ---
// prevent default to allow drop
// (https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/drop_event)
listEl.addEventListener("dragover", (e) => {
e.preventDefault();
});
listEl.addEventListener("drop", (e) => {
if (!props.onchange) {
clearDragData();
return;
}
const from = listEl.querySelector(":scope > [data-dragstart=\"true\"]");
const to = closestChild(listEl, e.target);
clearDragData();
if (!from || !to || to == from) {
return;
}
const fromIndex = childIndex(from);
const toIndex = childIndex(to);
const clone = props.data.slice();
const deleted = clone.splice(fromIndex, 1);
clone.splice(toIndex, 0, deleted[0]);
props.onchange(clone, fromIndex, toIndex);
});
}
function childIndex(node) {
if (!node?.parentNode) {
return -1;
}
for (let i = 0; i < node.parentNode.children.length; i++) {
if (node.parentNode.children[i] == node) {
return i;
}
}
return -1;
}
function closestChild(parent, node) {
if (!node || !node.parentNode) {
return null;
}
if (node.parentNode == parent) {
return node;
}
return closestChild(parent, node.parentNode);
}
return t.div(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => props.className,
onmount: (listEl) => {
initSortEvents(listEl);
},
onunmount: (listEl) => {
watchers.forEach((w) => w?.unwatch());
},
},
(el) => {
if (typeof props.before == "function") {
return props.before(el);
}
return props.before;
},
(el) => {
const children = [];
for (let i = 0; i < props.data.length; i++) {
let child = props.dataItem(props.data[i], i, el);
if (!child) {
continue;
}
if (props.handle) {
const handle = child.querySelector(props.handle);
if (handle) {
handle.draggable = true;
}
} else {
child.draggable = true;
}
children.push(child);
}
return children;
},
(el) => {
if (typeof props.after == "function") {
return props.after(el);
}
return props.after;
},
);
};
+542
View File
@@ -0,0 +1,542 @@
import cssVars from "@/css/vars.css?inline";
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Creates a new TinyMCE editor element.
*
* @example
* ```js
* const data = store({ value: "" })
*
* app.components.tinymce({
* value: () => data.value,
* onchange: (val) => data.value = val,
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.tinymce = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
name: undefined,
className: "",
value: "",
readonly: false,
disabled: false,
required: false,
convertURLs: false,
onchange: function(val) {},
onbeforeinit: function(opts) {},
onafterinit: function(editor) {},
});
const watchers = app.utils.extendStore(props, propsArg);
let editorRef;
let textarea;
let oldChange;
watchers.push(watch(() => props.value, setEditorContentValue));
watchers.push(watch(() => props.disabled || props.readonly, setDisabled));
watchers.push(watch(() => props.convertURLs, setConvertURLs));
watchers.push(watch(() => app.store.activeColorScheme, setEditorBodyColorScheme));
// generic error handling wrapper to prevent throws from tinymce API calls from crashing the UI
function catchError(fn) {
try {
fn();
} catch (err) {
console.warn("tinymce error:", err);
}
}
function setEditorContentValue() {
if (oldChange != props.value) {
catchError(() => {
editorRef?.setContent("" + (props.value || "")); // stringify and normalize
});
}
}
function setDisabled() {
catchError(() => {
// https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#readonly
editorRef?.mode?.set(props.disabled || props.readonly ? "readonly" : "design");
});
}
function setConvertURLs() {
catchError(() => {
editorRef?.options?.set("convert_urls", !!props.convertURLs);
});
}
function setEditorBodyColorScheme() {
catchError(() => {
editorRef?.getBody()?.setAttribute("data-color-scheme", app.store.activeColorScheme);
});
}
let changeTimeoutId;
function triggerOnchangeWithDebounce(debounce = 150) {
clearTimeout(changeTimeoutId);
changeTimeoutId = setTimeout(triggerOnchange, debounce);
}
function triggerOnchange() {
if (!editorRef) {
return;
}
clearTimeout(changeTimeoutId);
let content;
catchError(() => {
content = editorRef.getContent();
});
if (content == oldChange) {
return; // no change
}
oldChange = content;
props.onchange?.(content);
// trigger custom change event for clearing field errors
textarea?.dispatchEvent(
new CustomEvent("change", {
detail: { editor: editorRef, content: content },
bubbles: true,
}),
);
}
function destroyEditor() {
if (!editorRef) {
return; // already removed or not initialized yet
}
clearTimeout(changeTimeoutId);
// workaround for https://github.com/tinymce/tinymce/issues/9377
editorRef.dom?.unbind(document);
catchError(() => {
window.tinymce?.remove(editorRef);
});
editorRef = null;
oldChange = null;
}
async function initEditor(el) {
await loadTinyMCE();
destroyEditor();
// removed while loading
if (!el.isConnected) {
return;
}
const opts = {
target: el,
content_style: cssVars,
branding: false,
promotion: false,
menubar: false,
resize: false,
min_height: 265,
height: 265,
max_height: 600,
sandbox_iframes: true,
convert_unsafe_embeds: true, // GHSA-5359
codesample_global_prismjs: true,
convert_urls: false,
relative_urls: false,
autoresize_bottom_margin: 30,
media_poster: false,
media_alt_source: false,
codesample_languages: [
{ text: "HTML/XML", value: "markup" },
{ text: "CSS", value: "css" },
{ text: "SQL", value: "sql" },
{ text: "JavaScript", value: "javascript" },
{ text: "Go", value: "go" },
{ text: "Dart", value: "dart" },
{ text: "Zig", value: "zig" },
{ text: "Rust", value: "rust" },
{ text: "Lua", value: "lua" },
{ text: "PHP", value: "php" },
{ text: "Ruby", value: "ruby" },
{ text: "Python", value: "python" },
{ text: "Java", value: "java" },
{ text: "C", value: "c" },
{ text: "C#", value: "csharp" },
{ text: "C++", value: "cpp" },
// other non-highlighted languages
{ text: "Markdown", value: "markdown" },
{ text: "Swift", value: "swift" },
{ text: "Kotlin", value: "kotlin" },
{ text: "Elixir", value: "elixir" },
{ text: "Scala", value: "scala" },
{ text: "Julia", value: "julia" },
{ text: "Haskell", value: "haskell" },
],
plugins: [
"autolink",
"autoresize",
"code",
"codesample",
"directionality",
"image",
"link",
"lists",
"media",
"table",
"wordcount",
],
toolbar:
"styles | alignleft aligncenter alignright | bold italic forecolor backcolor | bullist numlist | link table media_picker codesample | direction code",
paste_postprocess: (editor, args) => {
cleanupPastedNode(args.node);
},
// @see https://www.tiny.cloud/docs/tinymce/6/file-image-upload/#interactive-example
file_picker_types: "image",
file_picker_callback: (callback, value, meta) => {
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("accept", "image/*");
input.addEventListener("change", (e) => {
const file = e.target.files[0];
const reader = new FileReader();
reader.addEventListener("load", () => {
if (!tinymce) {
return;
}
// We need to register the blob in TinyMCEs image blob registry.
// In future TinyMCE version this part will be handled internally.
const id = "blobid" + new Date().getTime();
const blobCache = tinymce.activeEditor.editorUpload.blobCache;
const base64 = reader.result.split(",")[1];
const blobInfo = blobCache.create(id, file, base64);
blobCache.add(blobInfo);
// call the callback and populate the Title field with the file name
callback(blobInfo.blobUri(), { title: file.name });
});
reader.readAsDataURL(file);
});
input.click();
},
setup: (editor) => {
editorRef = editor;
editor.on("init", (e) => {
props.onafterinit?.(editorRef);
setConvertURLs();
setDisabled();
setEditorBodyColorScheme();
setEditorContentValue();
});
// propagate save shortcut to the parent
editor.on("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.code == "KeyS" && editor.formElement) {
e.preventDefault();
e.stopPropagation();
editor.formElement.dispatchEvent(new KeyboardEvent("keydown", e));
}
});
editor.on("input", (e) => {
triggerOnchangeWithDebounce();
});
editor.on("change", (e) => {
triggerOnchange();
});
registerDirectionButton(editor);
registerMediaButton(editor);
},
};
if (props.readonly) {
opts.statusbar = false;
opts.min_height = 30;
opts.height = 30;
opts.max_height = 500;
opts.autoresize_bottom_margin = 5;
opts.resize = false;
opts.toolbar = false;
opts.plugins = ["autoresize", "codesample", "directionality"];
}
if (props.onbeforeinit) {
props.onbeforeinit(opts);
}
window.tinymce.init(opts);
}
textarea = t.textarea({
name: () => props.name,
onmount: (el) => {
initEditor(el).catch((err) => {
console.warn("tinymce init error:", err);
});
},
onunmount: destroyEditor,
});
return t.div(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => `pb-tinymce ${props.className}`,
"html-required": () => props.required || undefined, // set on the parent because the textarea will be hidden
onunmount: (el) => {
clearTimeout(changeTimeoutId);
watchers.forEach((w) => w?.unwatch());
textarea = null;
},
},
textarea,
);
};
function registerDirectionButton(editor) {
const lastDirectionKey = "pbTinymceLastDirection";
// load last used text direction for blank editors
editor.on("init", () => {
const lastDirection = window.localStorage.getItem(lastDirectionKey);
if (!editor.isDirty() && editor.getContent() == "" && lastDirection == "rtl") {
editor.execCommand("mceDirectionRTL");
}
});
// text direction dropdown
editor.ui.registry.addMenuButton("direction", {
icon: "visualchars",
tooltip: "Direction",
fetch: (callback) => {
const items = [
{
type: "menuitem",
text: "LTR content",
icon: "ltr",
onAction: () => {
window?.localStorage?.setItem(lastDirectionKey, "ltr");
editor.execCommand("mceDirectionLTR");
},
},
{
type: "menuitem",
text: "RTL content",
icon: "rtl",
onAction: () => {
window?.localStorage?.setItem(lastDirectionKey, "rtl");
editor.execCommand("mceDirectionRTL");
},
},
];
callback(items);
},
});
}
function registerMediaButton(editor) {
editor.ui.registry.addMenuButton("media_picker", {
tooltip: "Insert media",
icon: "embed",
fetch: (callback) => {
const items = [
{
type: "menuitem",
text: "Inline image (Base64)",
onAction: () => {
editor.execCommand("mceImage");
},
},
{
type: "menuitem",
text: "Media from collection",
onAction: () => {
app.modals.openRecordFilePicker({
fileTypes: ["image", "audio", "video"],
onselect: (selected) => {
const url = app.pb.files.getURL(selected.record, selected.name, {
thumb: selected.thumb || undefined,
});
// just an extra precaution in case the editor fail for whatever reason to sanitize the inserted raw htmls
const escapedName = app.utils.encodeEntities(selected.name);
const escapedUrl = app.utils.encodeEntities(url);
if (app.utils.hasImageExtension(selected.name)) {
editor?.execCommand("InsertImage", false, url);
} else if (app.utils.hasAudioExtension(selected.name)) {
editor?.execCommand(
"InsertHTML",
false,
`<audio controls src="${escapedUrl}"></audio>`,
);
} else if (app.utils.hasVideoExtension(escapedName)) {
editor?.execCommand(
"InsertHTML",
false,
`
<video controls width="300">
<source src="${escapedUrl}" />
<p>Download: <a href="${escapedUrl}" download="${escapedName}">${escapedName}</a>.</p>
</video>
`,
);
}
},
});
},
},
{
type: "menuitem",
text: "Manual embed",
onAction: () => {
tinymce.activeEditor.execCommand("mceMedia");
},
},
];
callback(items);
},
});
}
const allowedPasteNodes = [
"DIV",
"P",
"A",
"EM",
"B",
"STRONG",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"TABLE",
"TR",
"TD",
"TH",
"TBODY",
"THEAD",
"TFOOT",
"BR",
"HR",
"Q",
"SUP",
"SUB",
"DEL",
"IMG",
"OL",
"UL",
"LI",
"CODE",
];
function cleanupPastedNode(node) {
if (!node) {
return; // nothing to cleanup
}
for (const child of node.children) {
cleanupPastedNode(child);
}
if (!allowedPasteNodes.includes(node.tagName)) {
unwrap(node);
} else {
node.removeAttribute("style");
node.removeAttribute("class");
}
}
function unwrap(node) {
let parent = node.parentNode;
// move children outside of the parent node
while (node.firstChild) {
parent.insertBefore(node.firstChild, node);
}
// remove the now empty parent element
parent.removeChild(node);
}
async function loadTinyMCE() {
// already loaded
if (typeof window.tinymce != "undefined") {
return;
}
const scriptId = "lazy-tinymce-js";
// in the process of being loaded
if (document.getElementById(scriptId)) {
return new Promise((resolve, reject) => {
function cleanup() {
document.removeEventListener("tinymceLoadSuccess", successHandler);
document.removeEventListener("tinymceLoadError", errorHandler);
}
const successHandler = function() {
cleanup();
resolve();
};
const errorHandler = function(e) {
cleanup();
reject(e?.details);
};
document.addEventListener("tinymceLoadSuccess", successHandler);
document.addEventListener("tinymceLoadError", errorHandler);
});
}
return new Promise((resolve, reject) => {
document.head.querySelector("#shablon-script").after(
t.script({
id: scriptId,
src: import.meta.env.BASE_URL + "libs/tinymce/tinymce.min.js",
onload: () => {
resolve();
},
onerror: (err) => {
console.warn("failed to load tinymce.min.js:", err);
reject(err);
},
}),
);
}).then(() => {
document.dispatchEvent(new CustomEvent("tinymceLoadSuccess"));
}).catch((err) => {
document.dispatchEvent(new CustomEvent("tinymceLoadError", { detail: err }));
});
}
+158
View File
@@ -0,0 +1,158 @@
const toasts = new Map();
const toastsContainer = t.div({ className: "toasts-container" });
/**
* Removes a single notification by its reference (key or content).
*
* @param {string|Node} toastRef
* @param {boolean} [animate]
*/
function removeToast(toastRef, animate = true) {
const toast = toasts.get(toastRef);
if (!toast || !toast.isConnected) {
return;
}
toasts.delete(toastRef);
clearTimeout(toast._removeTimeout);
if (animate) {
toast.classList.add("removing");
setTimeout(() => {
toast.remove();
}, 300);
} else {
toast.remove();
}
}
/**
* Removes all registered notifications.
*
* @param {boolean} [animate]
*/
function removeAllToasts(animate = true) {
toasts.forEach((_, key) => {
window.app.toasts.remove(key, animate);
});
}
/**
* Adds "info" notification.
*
* @see {@link addToast}
*/
function infoToast(textOrElem, options = {}) {
options.type = "info";
options.duration = options.duration || 3000;
addToast(textOrElem, options);
}
/**
* Adds "success" notification.
*
* @see {@link addToast}
*/
function successToast(textOrElem, options = {}) {
options.type = "success";
options.duration = options.duration || 3000;
addToast(textOrElem, options);
}
/**
* Adds "error" notification.
*
* @see {@link addToast}
*/
function errorToast(textOrElem, options = {}) {
options.type = "error";
options.duration = options.duration || 3500;
addToast(textOrElem, options);
}
/**
* Creates and registers a new toast notification.
*
* @param {string|Node} textOrElem The content of the notification as plain text or DOM node.
* @param {object} [options] Toast options.
* @param {number} [options.duration] Duration time in ms the notifaction would be active.
* @param {string} [options.key] Optional identifier that could be used to manually remove the specific notification (default to `textOrElem`).
* @param {string} [options.type] The CSS class type of the notification.
*/
function addToast(textOrElem, options = {}) {
options = Object.assign({ duration: 3000, key: undefined, type: "info" }, options);
if (!toastsContainer.isConnected) {
document.body.appendChild(toastsContainer);
}
const toastRef = options.key || textOrElem;
if (toasts.has(toastRef)) {
removeToast(toastRef, false);
}
function initRemoveTimer(el) {
if (el?._removeTimeout) {
clearTimeout(el?._removeTimeout);
}
el._removeTimeout = setTimeout(() => {
removeToast(toastRef);
}, options.duration);
}
let newToast = t.div(
{
className: `toast ${options.type || ""}`,
onmount: (el) => {
initRemoveTimer(el);
},
onunmount: (el) => {
if (el?._removeTimeout) {
clearTimeout(el?._removeTimeout);
newToast = null;
}
},
onmouseover: () => {
clearTimeout(newToast?._removeTimeout);
},
onmouseout: () => {
initRemoveTimer(newToast);
},
},
t.div(
{ className: "toast-container" },
t.div({ className: "toast-icon" }),
t.div(
{ className: "toast-content" },
textOrElem,
t.button(
{
className: "m-l-auto btn circle sm transparent secondary toast-remove",
title: "Clear",
onclick: () => removeToast(toastRef),
},
t.i({ className: "ri-close-line" }),
),
),
),
);
toasts.set(toastRef, newToast);
toastsContainer.prepend(newToast);
}
// -------------------------------------------------------------------
window.app = window.app || {};
window.app.toasts = window.app.toasts || {};
window.app.toasts.info = infoToast;
window.app.toasts.error = errorToast;
window.app.toasts.success = successToast;
window.app.toasts.remove = removeToast;
window.app.toasts.removeAll = removeAllToasts;
+153
View File
@@ -0,0 +1,153 @@
const tolerance = 5;
const tooltip = t.div({
popover: "manual",
className: "pb-tooltip",
});
document.body.appendChild(tooltip);
function updateTooltipPosition(node, position) {
let nodeRect = node.getBoundingClientRect();
tooltip.setAttribute("data-position", position);
// reset tooltip position
tooltip.style.top = "0px";
tooltip.style.left = "0px";
// note: doesn't use getBoundingClientRect() here because the
// tooltip could be animated/scaled/transformed and we need the real size
let tooltipHeight = tooltip.offsetHeight;
let tooltipWidth = tooltip.offsetWidth;
let top = 0;
let left = 0;
// calculate tooltip position based
if (position == "left") {
top = nodeRect.top + nodeRect.height / 2 - tooltipHeight / 2;
left = nodeRect.left - tooltipWidth - tolerance;
} else if (position == "right") {
top = nodeRect.top + nodeRect.height / 2 - tooltipHeight / 2;
left = nodeRect.right + tolerance;
} else if (position == "top") {
top = nodeRect.top - tooltipHeight - tolerance;
left = nodeRect.left + nodeRect.width / 2 - tooltipWidth / 2;
} else if (position == "top-left") {
top = nodeRect.top - tooltipHeight - tolerance;
left = nodeRect.left;
} else if (position == "top-right") {
top = nodeRect.top - tooltipHeight - tolerance;
left = nodeRect.right - tooltipWidth;
} else if (position == "bottom-left") {
top = nodeRect.top + nodeRect.height + tolerance;
left = nodeRect.left;
} else if (position == "bottom-right") {
top = nodeRect.top + nodeRect.height + tolerance;
left = nodeRect.right - tooltipWidth;
} else {
// bottom
top = nodeRect.top + nodeRect.height + tolerance;
left = nodeRect.left + nodeRect.width / 2 - tooltipWidth / 2;
}
// right edge boundary
if (left + tooltipWidth > document.documentElement.clientWidth) {
left = document.documentElement.clientWidth - tooltipWidth;
}
// left edge boundary
left = left >= 0 ? left : 0;
// bottom edge boundary
if (top + tooltipHeight > document.documentElement.clientHeight) {
top = document.documentElement.clientHeight - tooltipHeight;
}
// top edge boundary
top = top >= 0 ? top : 0;
tooltip.style.top = top + "px";
tooltip.style.left = left + "px";
}
function hideTooltip() {
tooltip.hidePopover();
}
function showTooltip(node, text, position) {
if (!node || !text) {
hideTooltip();
return;
}
tooltip.showPopover();
tooltip.textContent = text;
updateTooltipPosition(node, position);
}
document.body.addEventListener("mouseleave", () => {
hideTooltip();
});
function tooltipAction(textOrFunc, position = "top") {
return (el) => {
if (!el._tooltipText) {
el._tooltipText = store({
value: "",
});
let tooltipTextWatcher;
function showEventHandler() {
tooltipTextWatcher?.unwatch();
tooltipTextWatcher = watch(
() => el._tooltipText.value,
async (result) => {
showTooltip(el, result, position);
},
);
}
async function hideEventHandler() {
tooltipTextWatcher?.unwatch();
tooltipTextWatcher = null;
hideTooltip();
}
el.addEventListener("mouseenter", showEventHandler);
el.addEventListener("focusin", showEventHandler);
el.addEventListener("mouseleave", hideEventHandler);
el.addEventListener("focusout", hideEventHandler);
el.addEventListener("blur", hideEventHandler);
const originalOnunmount = el.onunmount;
el.onunmount = (el) => {
tooltipTextWatcher?.unwatch();
el._tooltipText = null;
el?.removeEventListener("mouseenter", showEventHandler);
el?.removeEventListener("focusin", showEventHandler);
el?.removeEventListener("mouseleave", hideEventHandler);
el?.removeEventListener("focusout", hideEventHandler);
el?.removeEventListener("blur", hideEventHandler);
originalOnunmount(el);
};
}
if (typeof textOrFunc == "function") {
el._tooltipText.value = textOrFunc();
} else {
el._tooltipText.value = textOrFunc;
}
return el._tooltipText.value;
};
}
window.app = window.app || {};
window.app.attrs = window.app.attrs || {};
window.app.attrs.tooltip = tooltipAction;
+83
View File
@@ -0,0 +1,83 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Thumb preview element for an uploaded File.
* For non-image file the thumb is an icon representing the file type.
*
* @example
* ```js
* app.components.uploadedFileThumb({
* file: new File(...),
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.uploadedFileThumb = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
file: undefined, // File
imageWidth: 100, // image thumb width
imageHeight: 100, // image thumb height
extraClasses: "sm", // any .thumb related classes
});
const watchers = app.utils.extendStore(props, propsArg);
const data = store({
thumbSrc: undefined,
});
watchers.push(
watch(
() => [props.file, props.imageWidth, props.imageHeight],
() => {
if (app.utils.hasImageExtension(props.file?.name)) {
app.utils
.generateThumb(props.file, props.imageWidth, props.imageHeight)
.then((url) => {
data.thumbSrc = url;
})
.catch((err) => {
console.warn("unable to generate thumb:", err);
data.thumbSrc = undefined;
});
} else {
data.thumbSrc = undefined;
}
},
),
);
return t.div(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => `thumb ${props.extraClasses}`,
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
() => {
const fileType = app.utils.getFileType(props.file?.name);
if (fileType == "image" && data.thumbSrc) {
return t.img({
draggable: false,
loading: "lazy",
alt: () => "Thumb of " + props.file.name,
src: data.thumbSrc,
});
}
return t.i({ className: app.utils.fileTypeIcons[fileType] || "ri-file-line" });
},
);
};