merge newui branch
This commit is contained in:
@@ -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),
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 }));
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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"),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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: "© <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,
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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"),
|
||||
];
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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 }));
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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" });
|
||||
},
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user