merge newui branch

This commit is contained in:
Gani Georgiev
2026-04-18 16:29:34 +03:00
parent 58f605e90c
commit 4c44044c0c
804 changed files with 58660 additions and 56663 deletions

View File

@@ -1,185 +0,0 @@
<script>
import "./scss/main.scss";
import tooltip from "@/actions/tooltip";
import Confirmation from "@/components/base/Confirmation.svelte";
import TinyMCE from "@/components/base/TinyMCE.svelte";
import Toasts from "@/components/base/Toasts.svelte";
import Toggler from "@/components/base/Toggler.svelte";
import { appName, hideControls, pageTitle } from "@/stores/app";
import { resetConfirmation } from "@/stores/confirmation";
import { setErrors } from "@/stores/errors";
import { superuser } from "@/stores/superuser";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import Router, { link, replace } from "svelte-spa-router";
import active from "svelte-spa-router/active";
import routes from "./routes";
let oldLocation = undefined;
let showAppSidebar = false;
let isTinyMCEPreloaded = false;
$: if ($superuser?.id) {
loadSettings();
}
function handleRouteLoading(e) {
if (e?.detail?.location === oldLocation) {
return; // not an actual change
}
showAppSidebar = !!e?.detail?.userData?.showAppSidebar;
oldLocation = e?.detail?.location;
// resets
$pageTitle = "";
setErrors({});
resetConfirmation();
}
function handleRouteFailure() {
replace("/");
}
async function loadSettings() {
if (!$superuser?.id) {
return;
}
try {
const settings = await ApiClient.settings.getAll({
$cancelKey: "initialAppSettings",
});
$appName = settings?.meta?.appName || "";
$hideControls = !!settings?.meta?.hideControls;
} catch (err) {
if (!err?.isAbort) {
console.warn("Failed to load app settings.", err);
}
}
}
function logout() {
ApiClient.logout();
}
</script>
<svelte:head>
<title>{CommonHelper.joinNonEmpty([$pageTitle, $appName, "PocketBase"], " - ", false)}</title>
{#if window.location.protocol == "https:"}
<link
rel="shortcut icon"
type="image/png"
href="{import.meta.env.BASE_URL}images/favicon/favicon_prod.png"
/>
{/if}
</svelte:head>
<div class="app-layout">
{#if $superuser?.id && showAppSidebar}
<aside class="app-sidebar">
<a href="/" class="logo logo-sm" use:link>
<img
src="{import.meta.env.BASE_URL}images/logo.svg"
alt="PocketBase logo"
width="40"
height="40"
/>
</a>
<nav class="main-menu">
<a
href="/collections"
class="menu-item"
aria-label="Collections"
use:link
use:active={{ path: "/collections/?.*", className: "current-route" }}
use:tooltip={{ text: "Collections", position: "right" }}
>
<i class="ri-database-2-line" />
</a>
<a
href="/logs"
class="menu-item"
aria-label="Logs"
use:link
use:active={{ path: "/logs/?.*", className: "current-route" }}
use:tooltip={{ text: "Logs", position: "right" }}
>
<i class="ri-line-chart-line" />
</a>
<a
href="/settings"
class="menu-item"
aria-label="Settings"
use:link
use:active={{ path: "/settings/?.*", className: "current-route" }}
use:tooltip={{ text: "Settings", position: "right" }}
>
<i class="ri-tools-line" />
</a>
</nav>
<div
tabindex="0"
role="button"
aria-label="Logged superuser menu"
class="thumb thumb-circle link-hint"
title={$superuser.email}
>
<span class="initials">{CommonHelper.getInitials($superuser.email)}</span>
<Toggler class="dropdown dropdown-nowrap dropdown-upside dropdown-left">
<div class="txt-ellipsis current-superuser" title={$superuser.email}>
{$superuser.email}
</div>
<hr />
<a
href="/collections?collection=_superusers"
class="dropdown-item closable"
role="menuitem"
use:link
>
<i class="ri-shield-user-line" aria-hidden="true" />
<span class="txt">Manage superusers</span>
</a>
<button type="button" class="dropdown-item closable" role="menuitem" on:click={logout}>
<i class="ri-logout-circle-line" aria-hidden="true" />
<span class="txt">Logout</span>
</button>
</Toggler>
</div>
</aside>
{/if}
<div class="app-body">
<Router {routes} on:routeLoading={handleRouteLoading} on:conditionsFailed={handleRouteFailure} />
<Toasts />
</div>
</div>
<Confirmation />
{#if showAppSidebar && !isTinyMCEPreloaded}
<div class="tinymce-preloader hidden">
<TinyMCE
conf={CommonHelper.defaultEditorOptions()}
on:init={() => {
isTinyMCEPreloaded = true;
}}
/>
</div>
{/if}
<style>
.current-superuser {
padding: 10px;
max-width: 200px;
color: var(--txtHintColor);
}
</style>

View File

@@ -1,64 +0,0 @@
// Simple Svelte scrollend detection action.
// ===================================================================
//
// ### Example usage
//
// Simple form (with default 100px threshold):
// ```html
// <div class="list" use:scrollend={() => { console.log("end reached") }}>
// ...
// </div>
// ```
//
// With custom threshold:
// ```html
// <div class="list" use:scrollend={{
// threshold: 10,
// callback: () => { console.log("end reached") }
// }}>
// ...
// </div>
// ```
// ===================================================================
function normalize(rawData) {
if (typeof rawData === "function") {
return {
threshold: 100,
callback: rawData,
}
}
return rawData || {};
}
export default function scrollend(node, options) {
options = normalize(options);
options?.callback && options.callback();
function onScroll(e) {
if (!options?.callback) {
return;
}
const offset = e.target.scrollHeight - e.target.clientHeight - e.target.scrollTop;
if (offset <= options.threshold) {
options.callback();
}
}
node.addEventListener("scroll", onScroll);
node.addEventListener("resize", onScroll);
return {
update(newOptions) {
options = normalize(newOptions);
},
destroy() {
node.removeEventListener("scroll", onScroll);
node.removeEventListener("resize", onScroll);
},
};
}

View File

@@ -1,187 +0,0 @@
// Simple Svelte tooltip action.
// ===================================================================
//
// ### Example usage
//
// Default (position bottom):
// ```html
// <span use:tooltip={"My tooltip"}>Lorem Ipsum</span>
// ```
//
// Custom options (valid positions: top, right, bottom, left, bottom-left, bottom-right, top-left, top-right):
// ```html
// <span use:tooltip={{text: "My tooltip", position: "top-left", class: "...", delay: 300, hideOnClick: false}}>Lorem Ipsum</span>
// ```
// ===================================================================
import CommonHelper from "@/utils/CommonHelper";
let showTimeoutId;
let tooltipContainer;
const defaultTooltipClass = "app-tooltip";
function normalize(rawData) {
if (typeof rawData == "string") {
return {
text: rawData,
position: "bottom",
hideOnClick: null, // auto
}
}
return rawData || {};
}
function getTooltip() {
tooltipContainer = tooltipContainer || document.querySelector("." + defaultTooltipClass);
if (!tooltipContainer) {
// create
tooltipContainer = document.createElement("div");
tooltipContainer.classList.add(defaultTooltipClass);
document.body.appendChild(tooltipContainer);
}
return tooltipContainer;
}
function refreshTooltip(node, data) {
let tooltip = getTooltip();
if (!tooltip.classList.contains("active") || !data?.text) {
hideTooltip();
return; // no need to update since it is not active or there is no text to display
}
// set tooltip content
tooltip.textContent = data.text;
// reset tooltip styling
tooltip.className = defaultTooltipClass + " active";
if (data.class) {
tooltip.classList.add(data.class);
}
if (data.position) {
tooltip.classList.add(data.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 nodeRect = node.getBoundingClientRect();
let top = 0;
let left = 0;
let tolerance = 5;
// calculate tooltip position position
if (data.position == "left") {
top = nodeRect.top + (nodeRect.height / 2) - (tooltipHeight / 2);
left = nodeRect.left - tooltipWidth - tolerance;
} else if (data.position == "right") {
top = nodeRect.top + (nodeRect.height / 2) - (tooltipHeight / 2);
left = nodeRect.right + tolerance;
} else if (data.position == "top") {
top = nodeRect.top - tooltipHeight - tolerance;
left = nodeRect.left + (nodeRect.width / 2) - (tooltipWidth / 2);
} else if (data.position == "top-left") {
top = nodeRect.top - tooltipHeight - tolerance;
left = nodeRect.left;
} else if (data.position == "top-right") {
top = nodeRect.top - tooltipHeight - tolerance;
left = nodeRect.right - tooltipWidth;
} else if (data.position == "bottom-left") {
top = nodeRect.top + nodeRect.height + tolerance;
left = nodeRect.left;
} else if (data.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;
// apply new tooltip position
tooltip.style.top = top + "px";
tooltip.style.left = left + "px";
}
function hideTooltip() {
clearTimeout(showTimeoutId);
getTooltip().classList.remove("active");
getTooltip().activeNode = undefined;
}
function showTooltip(node, data) {
getTooltip().activeNode = node;
clearTimeout(showTimeoutId);
showTimeoutId = setTimeout(() => {
getTooltip().classList.add("active");
refreshTooltip(node, data);
}, (!isNaN(data.delay) ? data.delay : 0));
}
export default function tooltip(node, tooltipData) {
let data = normalize(tooltipData);
function showEventHandler() {
showTooltip(node, data);
}
function hideEventHandler() {
hideTooltip();
}
node.addEventListener("mouseenter", showEventHandler);
node.addEventListener("mouseleave", hideEventHandler);
node.addEventListener("blur", hideEventHandler);
if (data.hideOnClick === true || (data.hideOnClick === null && CommonHelper.isFocusable(node))) {
node.addEventListener("click", hideEventHandler);
}
// trigger tooltip container creation (if not inserted already)
getTooltip();
return {
update(newTooltipData) {
data = normalize(newTooltipData);
if (getTooltip()?.activeNode?.contains(node)) {
refreshTooltip(node, data);
}
},
destroy() {
if (getTooltip()?.activeNode?.contains(node)) {
hideTooltip();
}
node.removeEventListener("mouseenter", showEventHandler);
node.removeEventListener("mouseleave", hideEventHandler);
node.removeEventListener("blur", hideEventHandler);
node.removeEventListener("click", hideEventHandler);
},
};
}

View File

@@ -0,0 +1,228 @@
window.app = window.app || {};
window.app.modals = window.app.modals || {};
window.app.modals.openApiPreview = function(collection, settings = {
onbeforeopen: null,
onafteropen: null,
onbeforeclose: null,
onafterclose: null,
}) {
const modal = apiPreviewModal(collection, settings);
if (!modal) {
return;
}
document.body.appendChild(modal);
app.modals.open(modal);
};
function apiPreviewModal(collection, settings) {
if (!collection) {
console.warn("[apiPreviewModal] missing required collection");
return;
}
let modal;
const data = store({
activeTab: "List/Search",
tabEl: null,
isLoading: false,
});
const docs = {
"List/Search": async (collection) => {
const { docsList } = await import("./docsList");
return data.tabEl = docsList(collection);
},
"View": async (collection) => {
const { docsView } = await import("./docsView");
return data.tabEl = docsView(collection);
},
};
if (collection.type != "view") {
docs["Create"] = async (collection) => {
const { docsCreate } = await import("./docsCreate");
return data.tabEl = docsCreate(collection);
};
docs["Update"] = async (collection) => {
const { docsUpdate } = await import("./docsUpdate");
return data.tabEl = docsUpdate(collection);
};
docs["Delete"] = async (collection) => {
const { docsDelete } = await import("./docsDelete");
return data.tabEl = docsDelete(collection);
};
docs["Realtime"] = async (collection) => {
const { docsRealtime } = await import("./docsRealtime");
return data.tabEl = docsRealtime(collection);
};
docs["Batch"] = async (collection) => {
const { docsBatch } = await import("./docsBatch");
return data.tabEl = docsBatch(collection);
};
}
if (collection.type == "auth") {
docs[""] = null; // hr
docs["List auth methods"] = async (collection) => {
const { docsListAuthMethods } = await import("./docsListAuthMethods");
return data.tabEl = docsListAuthMethods(collection);
};
docs["Auth with password"] = collection.passwordAuth?.enabled
? async (collection) => {
const { docsAuthWithPassword } = await import("./docsAuthWithPassword");
return data.tabEl = docsAuthWithPassword(collection);
}
: null;
if (collection.name != "_superusers") {
docs["Auth with OAuth2"] = collection.oauth2?.enabled
? async (collection) => {
const { docsAuthWithOAuth2 } = await import("./docsAuthWithOAuth2");
return data.tabEl = docsAuthWithOAuth2(collection);
}
: null;
}
docs["Auth with OTP"] = collection.otp?.enabled
? async (collection) => {
const { docsAuthWithOTP } = await import("./docsAuthWithOTP");
return data.tabEl = docsAuthWithOTP(collection);
}
: null;
docs["Auth refresh"] = async (collection) => {
const { docsAuthRefresh } = await import("./docsAuthRefresh");
return data.tabEl = docsAuthRefresh(collection);
};
if (collection.name != "_superusers") {
docs["Verification"] = async (collection) => {
const { docsVerification } = await import("./docsVerification");
return data.tabEl = docsVerification(collection);
};
}
docs["Password reset"] = async (collection) => {
const { docsPasswordReset } = await import("./docsPasswordReset");
return data.tabEl = docsPasswordReset(collection);
};
docs["Email change"] = async (collection) => {
const { docsEmailChange } = await import("./docsEmailChange");
return data.tabEl = docsEmailChange(collection);
};
}
const watchers = [
watch(() => data.activeTab, async () => {
data.isLoading = true;
await docs[data.activeTab]?.(collection);
data.isLoading = false;
}),
];
modal = t.div(
{
pbEvent: "apiPreviewModal",
className: "modal api-preview-modal",
onbeforeopen: (el) => {
return settings.onbeforeopen?.(el);
},
onafteropen: (el) => {
settings.onafteropen?.(el);
},
onbeforeclose: (el) => {
return settings.onbeforeclose?.(el);
},
onafterclose: (el) => {
settings.onafterclose?.(el);
watchers.forEach((w) => w?.unwatch());
el?.remove();
},
onmount: (el) => {
},
onunmount: (el) => {
watchers.forEach((w) => w?.unwatch());
},
},
t.div(
{ className: "modal-content" },
t.aside(
{ className: "api-preview-sidebar" },
t.nav(
{ className: "api-preview-nav" },
() => {
const items = [];
for (let title in docs) {
if (!title) {
items.push(t.hr());
continue;
}
const isDisabled = !docs[title];
items.push(
t.button(
{
type: "button",
className: () => `nav-item ${data.activeTab == title ? "active" : ""}`,
disabled: isDisabled,
ariaDescription: app.attrs.tooltip(
() => isDisabled ? "Not enabled for the collection" : "",
"left",
),
onclick: () => {
if (!isDisabled) {
data.activeTab = title;
}
},
},
title,
),
);
}
return items;
},
),
),
t.div(
{
className: () => `api-preview-content ${data.isLoading ? "faded" : ""}`,
// always scroll to the top of the new doc
scrollTop: () => data.activeTab ? 0 : null,
},
t.header(
{ className: "api-preview-content-header" },
t.h6(null, () => data.activeTab + ` (${collection.name})`),
t.button(
{
type: "button",
className: () =>
`btn sm circle transparent secondary m-l-auto preview-close-btn ${
data.isLoading ? "loading" : ""
}`,
title: "Close",
onclick: () => app.modals.close(modal),
},
t.i({ className: "ri-close-line", ariaHidden: true }),
),
),
() => data.tabEl,
),
),
);
return modal;
}

View File

@@ -0,0 +1,179 @@
import { expandInfo } from "./expandInfo";
import { fieldsInfo } from "./fieldsInfo";
export function docsAuthRefresh(collection) {
const baseURL = app.utils.getApiExampleURL();
const baseDummyRecord = {
collectionId: collection.id,
collectionName: collection.name,
};
const responses = [
{
title: 200,
value: JSON.stringify(
{
token: "...JWT...",
record: Object.assign(baseDummyRecord, app.utils.getDummyFieldsData(collection)),
},
null,
2,
),
},
{
title: 401,
value: `
{
"status": 401,
"message": "The request requires valid record authorization token to be set.",
"data": {}
}
`,
},
{
title: 403,
value: `
{
"status": 403,
"message": "The authorized record model is not allowed to perform this action.",
"data": {}
}
`,
},
{
title: 404,
value: `
{
"status": 404,
"message": "Missing auth record context.",
"data": {}
}
`,
},
];
return t.div(
{
pbEvent: "apiPreviewAuthRefresh",
className: "content",
},
// description
t.p(null, "Returns a new auth response (token and record data) for an already authenticated record."),
t.p(
null,
"This method is usually called by users on page/screen reload to ensure that the previously stored data in ",
t.code(null, "pb.authStore"),
" is still valid and up-to-date.",
),
app.components.codeBlockTabs({
className: "sdk-examples m-t-sm",
historyKey: "pbLastSDK",
tabs: [
{
title: "JS SDK",
language: "js",
value: `
import PocketBase from 'pocketbase';
const pb = new PocketBase('${baseURL}');
...
const authData = await pb.collection('${collection.name}').authRefresh();
// after the above you can also access the refreshed auth data from the authStore
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.record.id);
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_JS_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "JS SDK docs",
}),
),
},
{
title: "Dart SDK",
language: "dart",
value: `
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${baseURL}');
...
final authData = await pb.collection('${collection.name}').authRefresh();
// after the above you can also access the refreshed auth data from the authStore
print(pb.authStore.isValid);
print(pb.authStore.token);
print(pb.authStore.record.id);
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_DART_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Dart SDK docs",
}),
),
},
{
title: "curl",
language: "bash",
value: `
curl -X POST \\
-H 'Authorization:TOKEN' \\
'${baseURL}/api/collections/${collection.name}/auth-refresh'
`,
},
],
}),
// api
t.div({ className: "m-t-base" }, t.strong(null, "API details")),
t.div(
{ className: "alert success api-preview-alert" },
t.span({ className: "label method" }, "POST"),
t.span({ className: "path" }, `/api/collections/${collection.name}/auth-refresh`),
t.small({ className: "extra" }, "Requires", t.br(), "Authorization:TOKEN header"),
),
t.table(
{ className: "api-preview-table query-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "?query params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "expand"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, expandInfo()),
),
t.tr(
null,
t.td({ className: "min-width" }, "fields"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, fieldsInfo()),
),
),
),
// responses
t.div({ className: "m-t-base m-b-sm" }, t.strong(null, "Example responses")),
app.components.codeBlockTabs({
tabs: responses,
}),
);
}

View File

@@ -0,0 +1,261 @@
import { expandInfo } from "./expandInfo";
import { fieldsInfo } from "./fieldsInfo";
export function docsAuthWithOAuth2(collection) {
const baseURL = app.utils.getApiExampleURL();
const baseDummyRecord = {
collectionId: collection.id,
collectionName: collection.name,
};
const responses = [
{
title: 200,
value: JSON.stringify(
{
token: "...JWT...",
record: Object.assign(baseDummyRecord, app.utils.getDummyFieldsData(collection)),
meta: {
"id": "abc123",
"name": "John Doe",
"username": "john.doe",
"email": "test@example.com",
"avatarURL": "https://example.com/avatar.png",
"accessToken": "...",
"refreshToken": "...",
"expiry": "2022-01-01 10:00:00.123Z",
"isNew": false,
"rawUser": {},
},
},
null,
2,
),
},
{
title: 400,
value: `
{
"status": 400,
"message": "An error occurred while submitting the form.",
"data": {
"provider": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
];
return t.div(
{
pbEvent: "apiPreviewAuthWithOAuth2",
className: "content",
},
// description
t.p(null, "Authenticate with an OAuth2 provider and returns a new auth token and record data."),
t.p(
null,
"For more details please check the ",
t.a({
href: import.meta.env.PB_OAUTH2_DOCS,
target: "_blank",
rel: "noopener noreferrer",
textContent: "OAuth2 integration documentation",
}),
".",
),
app.components.codeBlockTabs({
className: "sdk-examples m-t-sm",
historyKey: "pbLastSDK",
tabs: [
{
title: "JS SDK",
language: "js",
value: `
import PocketBase from 'pocketbase';
const pb = new PocketBase('${baseURL}');
...
// OAuth2 authentication with a single realtime call.
//
// Make sure to register ${baseURL}/api/oauth2-redirect
// as redirect url in the OAuth2 app configuration.
const authData = await pb.collection('${collection.name}').authWithOAuth2({ provider: 'google' });
// OR authenticate with manual OAuth2 code exchange
// const authData = await pb.collection('${collection.name}').authWithOAuth2Code(...);
// after the above you can also access the auth data from the authStore
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.record.id);
// "logout"
pb.authStore.clear();
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_JS_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "JS SDK docs",
}),
),
},
{
title: "Dart SDK",
language: "dart",
value: `
import 'package:pocketbase/pocketbase.dart';
import 'package:url_launcher/url_launcher.dart';
final pb = PocketBase('${baseURL}');
...
// OAuth2 authentication with a single realtime call.
//
// Make sure to register ${baseURL}/api/oauth2-redirect
// as redirect url in the OAuth2 app configuration.
final authData = await pb.collection('${collection.name}').authWithOAuth2('google', (url) async {
await launchUrl(url);
});
// OR authenticate with manual OAuth2 code exchange
// final authData = await pb.collection('${collection.name}').authWithOAuth2Code(...);
// after the above you can also access the auth data from the authStore
print(pb.authStore.isValid);
print(pb.authStore.token);
print(pb.authStore.record.id);
// "logout"
pb.authStore.clear();
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_DART_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Dart SDK docs",
}),
),
},
{
title: "curl",
language: "bash",
value: `
# authenticate with manual OAuth2 code exchange
curl -X POST \\
-H 'Content-Type:application/json' \\
-d '{ "provider":"google", "code":"OAUTH2_CODE", "codeVerifier":"...", "redirectURL":"..." }' \\
'${baseURL}/api/collections/${collection.name}/auth-with-oauth2'
`,
},
],
}),
// api
t.div({ className: "m-t-base" }, t.strong(null, "API details")),
t.div(
{ className: "alert success api-preview-alert" },
t.span({ className: "label method" }, "POST"),
t.span({ className: "path" }, `/api/collections/${collection.name}/auth-with-password`),
),
t.table(
{ className: "api-preview-table body-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "Body params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "provider ", t.em(null, "(required)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, `The name of the OAuth2 client provider (eg. "google").`),
),
t.tr(
null,
t.td({ className: "min-width" }, "code ", t.em(null, "(required)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "The authorization code returned from the initial request."),
),
t.tr(
null,
t.td({ className: "min-width" }, "codeVerifier ", t.em(null, "(required)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "The code verifier sent with the initial request as part of the code_challenge."),
),
t.tr(
null,
t.td({ className: "min-width" }, "redirectURL ", t.em(null, "(required)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "The redirect url sent with the initial request."),
),
t.tr(
null,
t.td({ className: "min-width" }, "createData ", t.em(null, "(optional)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(
null,
t.p(null, "Optional data that will be used when creating the auth record on OAuth2 sign-up."),
t.p(
null,
"The created auth record must comply with the same requirements and validations in the regular create action.",
),
t.p(
null,
"The data can only be in JSON, aka. user uploaded files currently are not supported during OAuth2 sign-ups.",
),
),
),
),
),
t.table(
{ className: "api-preview-table query-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "?query params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "expand"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, expandInfo()),
),
t.tr(
null,
t.td({ className: "min-width" }, "fields"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, fieldsInfo()),
),
),
),
// responses
t.div({ className: "m-t-base m-b-sm" }, t.strong(null, "Example responses")),
app.components.codeBlockTabs({
tabs: responses,
}),
);
}

View File

@@ -0,0 +1,324 @@
import { expandInfo } from "./expandInfo";
import { fieldsInfo } from "./fieldsInfo";
export function docsAuthWithOTP(collection) {
const baseURL = app.utils.getApiExampleURL();
const actionTabs = [
{ title: "OTP request", content: otpRequest },
{ title: "OTP auth", content: otpAuth },
];
const data = store({
activeActionIndex: 0,
});
return t.div(
{
pbEvent: "apiPreviewAuthWithOTP",
className: "content",
},
// description
t.p(null, "Authenticate with an one-time/short-lived password (OTP)."),
t.p(
null,
"Note that when requesting an OTP we return an ",
t.code(null, "otpId"),
" even if a user with the provided email doesn't exist as a very basic enumeration protection.",
),
app.components.codeBlockTabs({
className: "sdk-examples m-t-sm",
historyKey: "pbLastSDK",
tabs: [
{
title: "JS SDK",
language: "js",
value: `
import PocketBase from 'pocketbase';
const pb = new PocketBase('${baseURL}');
...
// send OTP email to the provided auth record
const req = await pb.collection('${collection.name}').requestOTP('test@example.com');
// ... show a screen/popup to enter the password from the email ...
// authenticate with the requested OTP id and the email password
const authData = await pb.collection('${collection.name}').authWithOTP(
req.otpId,
"YOUR_OTP",
);
// after the above you can also access the auth data from the authStore
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.record.id);
// "logout"
pb.authStore.clear();
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_JS_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "JS SDK docs",
}),
),
},
{
title: "Dart SDK",
language: "dart",
value: `
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${baseURL}');
...
// send OTP email to the provided auth record
final req = await pb.collection('${collection.name}').requestOTP('test@example.com');
// ... show a screen/popup to enter the password from the email ...
// authenticate with the requested OTP id and the email password
final authData = await pb.collection('${collection.name}').authWithOTP(
req.otpId,
"YOUR_OTP",
);
// after the above you can also access the auth data from the authStore
print(pb.authStore.isValid);
print(pb.authStore.token);
print(pb.authStore.record.id);
// "logout"
pb.authStore.clear();
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_DART_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Dart SDK docs",
}),
),
},
{
title: "curl",
language: "bash",
value: `
# OTP request (sends email to the user if exists)
curl -X POST \\
-H 'Content-Type:application/json' \\
-d '{ "email":"..." }' \\
'${baseURL}/api/collections/${collection.name}/request-otp'
# OTP auth
curl -X POST \\
-H 'Content-Type:application/json' \\
-d '{ "otpId":"...", "password":"..." }' \\
'${baseURL}/api/collections/${collection.name}/auth-with-otp'
`,
},
],
}),
t.nav(
{ className: "btns m-t-base m-b-sm" },
() => {
return actionTabs.map((tab, i) => {
return t.button({
type: "button",
className: () => `btn sm expanded ${data.activeActionIndex == i ? "active" : "secondary"}`,
textContent: () => tab.title,
onclick: () => data.activeActionIndex = i,
});
});
},
),
() => actionTabs[data.activeActionIndex]?.content?.(collection),
);
}
function otpRequest(collection) {
const responses = [
{
title: 200,
value: `
{
"otpId": "njvv1b1lkdbpp3m"
}
`,
},
{
title: 400,
value: `
{
"status": 400,
"message": "An error occurred while validating the submitted data.",
"data": {
"email": {
"code": "validation_is_email",
"message": "Must be a valid email address."
}
}
}
`,
},
{
title: 429,
value: `
{
"status": 429,
"message": "You've send too many OTP requests, please try again later.",
"data": {}
}
`,
},
];
return [
// api
t.div(null, t.strong(null, "API details")),
t.div(
{ className: "alert success api-preview-alert" },
t.span({ className: "label method" }, "POST"),
t.span({ className: "path" }, `/api/collections/${collection.name}/request-otp`),
),
t.table(
{ className: "api-preview-table body-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "Body params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "email ", t.em(null, "(required)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "The auth record email address to send the OTP request (if exists)."),
),
),
),
// responses
t.div({ className: "m-t-base m-b-sm" }, t.strong(null, "Example responses")),
app.components.codeBlockTabs({
tabs: responses,
}),
];
}
function otpAuth(collection) {
const baseDummyRecord = {
collectionId: collection.id,
collectionName: collection.name,
};
const responses = [
{
title: 200,
value: JSON.stringify(
{
token: "...JWT...",
record: Object.assign(baseDummyRecord, app.utils.getDummyFieldsData(collection)),
},
null,
2,
),
},
{
title: 400,
value: `
{
"status": 400,
"message": "Failed to authenticate.",
"data": {
"otpId": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
];
return [
// api
t.div(null, t.strong(null, "API details")),
t.div(
{ className: "alert success api-preview-alert" },
t.span({ className: "label method" }, "POST"),
t.span({ className: "path" }, `/api/collections/${collection.name}/auth-with-otp`),
),
t.table(
{ className: "api-preview-table body-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "Body params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "otpId ", t.em(null, "(required)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "The id of the OTP request."),
),
t.tr(
null,
t.td({ className: "min-width" }, "password ", t.em(null, "(required)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "The one-time/short-lived password from the OTP request."),
),
),
),
t.table(
{ className: "api-preview-table query-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "?query params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "expand"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, expandInfo()),
),
t.tr(
null,
t.td({ className: "min-width" }, "fields"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, fieldsInfo()),
),
),
),
// responses
t.div({ className: "m-t-base m-b-sm" }, t.strong(null, "Example responses")),
app.components.codeBlockTabs({
tabs: responses,
}),
];
}

View File

@@ -0,0 +1,225 @@
import { expandInfo } from "./expandInfo";
import { fieldsInfo } from "./fieldsInfo";
export function docsAuthWithPassword(collection) {
const baseURL = app.utils.getApiExampleURL();
const identityFields = collection.passwordAuth?.identityFields || [];
const exampleIdentityLabel = identityFields.length == 0
? "NONE"
: "YOUR_" + identityFields.join("_OR_").toUpperCase();
const baseDummyRecord = {
collectionId: collection.id,
collectionName: collection.name,
};
const responses = [
{
title: 200,
value: JSON.stringify(
{
token: "...JWT...",
record: Object.assign(baseDummyRecord, app.utils.getDummyFieldsData(collection)),
},
null,
2,
),
},
{
title: 400,
value: `
{
"status": 400,
"message": "Failed to authenticate.",
"data": {
"identity": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
];
return t.div(
{
pbEvent: "apiPreviewAuthWithPassword",
className: "content",
},
// description
t.p(
null,
"Authenticate with combination of ",
t.strong(null, identityFields.join("/")),
" and ",
t.strong(null, "password"),
".",
),
app.components.codeBlockTabs({
className: "sdk-examples m-t-sm",
historyKey: "pbLastSDK",
tabs: [
{
title: "JS SDK",
language: "js",
value: `
import PocketBase from 'pocketbase';
const pb = new PocketBase('${baseURL}');
...
const authData = await pb.collection('${collection.name}').authWithPassword(
'${exampleIdentityLabel}',
'YOUR_PASSWORD',
);
// after the above you can also access the auth data from the authStore
console.log(pb.authStore.isValid);
console.log(pb.authStore.token);
console.log(pb.authStore.record.id);
// "logout"
pb.authStore.clear();
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_JS_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "JS SDK docs",
}),
),
},
{
title: "Dart SDK",
language: "dart",
value: `
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${baseURL}');
...
final authData = await pb.collection('${collection.name}').authWithPassword(
'${exampleIdentityLabel}',
'YOUR_PASSWORD',
);
// after the above you can also access the auth data from the authStore
print(pb.authStore.isValid);
print(pb.authStore.token);
print(pb.authStore.record.id);
// "logout"
pb.authStore.clear();
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_DART_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Dart SDK docs",
}),
),
},
{
title: "curl",
language: "bash",
value: `
curl -X POST \\
-H 'Content-Type:application/json' \\
-d '{ "identity":"${exampleIdentityLabel}", "password":"YOUR_PASSWORD" }' \\
'${baseURL}/api/collections/${collection.name}/auth-with-password'
`,
},
],
}),
// api
t.div({ className: "block m-t-base" }, t.strong(null, "API details")),
t.div(
{ className: "alert success api-preview-alert" },
t.span({ className: "label method" }, "POST"),
t.span({ className: "path" }, `/api/collections/${collection.name}/auth-with-password`),
),
t.table(
{ className: "api-preview-table body-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "Body params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "identity ", t.em(null, "(required)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(
null,
app.utils.sentenize(identityFields.join(" or "), false),
" of the record to authenticate.",
),
),
t.tr(
null,
t.td({ className: "min-width" }, "identityField ", t.em(null, "(optional)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(
null,
"In case of multiple identity fields, explicitly set the field name to use when searching for the auth record.",
t.br(),
"Leave it empty for auto detection.",
),
),
t.tr(
null,
t.td({ className: "min-width" }, "password ", t.em(null, "(required)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "The auth record password."),
),
),
),
t.table(
{ className: "api-preview-table query-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "?query params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "expand"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, expandInfo()),
),
t.tr(
null,
t.td({ className: "min-width" }, "fields"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, fieldsInfo()),
),
),
),
// responses
t.div({ className: "block m-t-base m-b-sm" }, t.strong(null, "Example responses")),
app.components.codeBlockTabs({
tabs: responses,
}),
);
}

View File

@@ -0,0 +1,263 @@
export function docsBatch(collection) {
const baseURL = app.utils.getApiExampleURL();
const baseDummyRecord = {
collectionId: collection.id,
collectionName: collection.name,
};
const responses = [
{
title: 200,
value: JSON.stringify(
[
{
status: 200,
body: Object.assign(baseDummyRecord, app.utils.getDummyFieldsData(collection)),
},
{
status: 200,
body: Object.assign(baseDummyRecord, app.utils.getDummyFieldsData(collection)),
},
],
null,
2,
),
},
{
title: 400,
value: `
{
"status": 400,
"message": "Batch transaction failed.",
"data": {
"requests": {
"1": {
"code": "batch_request_failed",
"message": "Batch request failed.",
"response": {
"status": 400,
"message": "Failed to create record.",
"data": {
"id": {
"code": "validation_min_text_constraint",
"message": "Must be at least 3 character(s).",
"params": { "min": 3 }
}
}
}
}
}
}
}
`,
},
{
title: 403,
value: `
{
"status": 403,
"message": "Batch requests are not allowed.",
"data": {}
}
`,
},
];
return t.div(
{ pbEvent: "apiPreviewBatch", className: "content" },
// description
t.p(null, `Batch and transactional create/update/upsert/delete of multiple records in a single request.`),
t.div(
{ className: "alert warning" },
t.p(
{ className: "txt-bold" },
"The batch Web API need to be explicitly enabled and configured from the ",
t.a({
href: "#/settings",
target: "_blank",
title: "Open in new tab",
textContent: "App settings",
}),
".",
),
t.p(
null,
"Because this endpoint process the requests in a single DB transaction it could degrade the performance of your application if not used with proper care and configuration (use smaller max processing and body size limits, avoid large file uploads over slow S3 networks and custom hooks that communicate with slow external APIs).",
),
),
app.components.codeBlockTabs({
className: "sdk-examples m-t-sm",
historyKey: "pbLastSDK",
tabs: [
{
title: "JS SDK",
language: "js",
value: `
import PocketBase from 'pocketbase';
const pb = new PocketBase('${baseURL}');
...
const batch = pb.createBatch();
batch.collection('${collection.name}').create({ ... });
batch.collection('${collection.name}').update('RECORD_ID', { ... });
batch.collection('${collection.name}').delete('RECORD_ID');
batch.collection('${collection.name}').upsert({ ... });
const result = await batch.send();
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_JS_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "JS SDK docs",
}),
),
},
{
title: "Dart SDK",
language: "dart",
value: `
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${baseURL}');
...
final batch = pb.createBatch();
batch.collection('${collection.name}').create(body: { ... });
batch.collection('${collection.name}').update('RECORD_ID', body: { ... });
batch.collection('${collection.name}').delete('RECORD_ID');
batch.collection('${collection.name}').upsert(body: { ... });
final result = await batch.send();
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_DART_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Dart SDK docs",
}),
),
},
{
title: "curl",
language: "bash",
value: `
curl -X POST \\
-H 'Authorization:TOKEN' \\
-H 'Content-Type:application/json' \\
-d '{ "requests": [...] }' \\
'${baseURL}/api/batch'
`,
},
],
}),
// api
t.div({ className: "block m-t-sm" }, t.strong(null, "API details")),
t.div(
{ className: "alert success api-preview-alert" },
t.span({ className: "label method" }, "POST"),
t.span({ className: "path" }, "/api/batch"),
),
t.p(
null,
"The request accepts only 1 required ",
t.code(null, "requests: Array<Request>"),
" parameter that defines the list of the batch requests to process.",
),
t.p(
null,
"When using the official SDKs the batch requests are transparently constructed by their service handler.",
),
t.p(null, "For the cases when you don't use the SDKs, the she supported batch request actions are:"),
t.ul(
null,
t.li(null, "record create - ", t.code(null, "POST /api/collections/{collection}/records")),
t.li(null, "record update - ", t.code(null, "PATCH /api/collections/{collection}/records")),
t.li(
null,
"record upsert - ",
t.code(null, "PUT /api/collections/{collection}/records"),
t.br(),
t.small({ className: "txt-hint" }, `(the body must have an "id" field)`),
),
t.li(null, "record delete - ", t.code(null, "DELETE /api/collections/{collection}/records/{id}")),
),
t.p(null, "Each batch ", t.em(null, "Request"), " element has the following properties:"),
t.ul(
null,
t.li(null, t.code(null, "url"), t.em(null, " (could include query parameters)")),
t.li(null, t.code(null, "method"), t.em(null, " (GET, POST, PUT, PATCH, DELETE)")),
t.li(
null,
t.code(null, "headers"),
t.br(),
t.em(
null,
"(custom per-request Authorization header is not supported at the moment, aka. all batch requests have the same auth state)",
),
),
t.li(
null,
t.code(null, "body"),
t.br(),
"When the batch request is send as ",
t.code(null, "multipart/form-data"),
", the regular batch action fields are expected to be submitted as serialized json under the ",
t.code(null, "@jsonPayload"),
" field and file keys need to follow the pattern ",
t.code(null, "requests.N.fileField"),
" or ",
t.code(null, "requests[N].fileField"),
".",
t.br(),
"Again this is handled transparently by the official SDKs, but for example if you prefer to manually construct a JS ",
t.code(null, "FormData"),
" body, then it could look something like:",
app.components.codeBlock({
className: "m-t-10",
value: `
const batchBody = new FormData();
batchBody.append("@jsonPayload", JSON.stringify({
requests: [
// create
{
url: "/api/collections/users/records?expand=someRelField",
method: "POST",
body: { someField: "test1" }
},
// update
{
url: "/api/collections/users/records/RECORD_ID",
method: "PATCH",
body: { someField: "test2" }
}
]
}))
// bind file to the first request
batchBody.append("requests.0.someFileField", new File(...))
// bind file to the second request
batchBody.append("requests.1.someFileField", new File(...))
`,
}),
),
),
// responses
t.div({ className: "block m-t-base m-b-sm" }, t.strong(null, "Example responses")),
app.components.codeBlockTabs({
tabs: responses,
}),
);
}

View File

@@ -0,0 +1,404 @@
import { expandInfo } from "./expandInfo";
import { fieldsInfo } from "./fieldsInfo";
import { filterSyntax } from "./filterSyntax";
export function docsCreate(collection) {
const baseURL = app.utils.getApiExampleURL();
const isSuperusersOnly = collection.createRule === null;
const isAuth = collection.type === "auth";
const excludedTableFields = isAuth ? ["password", "verified", "email", "emailVisibility"] : [];
const tableFields =
collection.fields?.filter((f) => !f.hidden && f.type != "autodate" && !excludedTableFields.includes(f.name))
|| [];
const baseDummyRecord = {
collectionId: collection.id,
collectionName: collection.name,
};
const responses = [
{
title: 200,
value: JSON.stringify(
Object.assign(baseDummyRecord, app.utils.getDummyFieldsData(collection)),
null,
2,
),
},
{
title: 400,
value: `
{
"status": 400,
"message": "Failed to create record.",
"data": {
"${isAuth ? "email" : tableFields.find((f) => !f.primaryKey)?.name || "someField"}": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
];
if (isSuperusersOnly) {
responses.push({
title: 403,
value: `
{
"status": 403,
"message": "Only superusers can perform this action.",
"data": {}
}
`,
});
}
return t.div(
{ pbEvent: "apiPreviewCreate", className: "content" },
// description
t.p(null, `Creates a new ${collection.name} record.`),
t.p(
null,
"Body parameters could be sent as ",
t.code(null, "application/json"),
" or ",
t.code(null, "multipart/form-data"),
".",
),
t.p(
null,
"File upload is supported only via ",
t.code(null, "multipart/form-data"),
". For more info and examples you could check the detailed ",
t.a({
href: import.meta.env.PB_FILE_UPLOAD_DOCS,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Files upload and handling docs",
}),
".",
),
app.components.codeBlockTabs({
className: "sdk-examples m-t-sm",
historyKey: "pbLastSDK",
tabs: [
{
title: "JS SDK",
language: "js",
// dprint-ignore
value: `
import PocketBase from 'pocketbase';
const pb = new PocketBase('${baseURL}');
...
// example create body
const body = ${replaceDummyPayloadPlaceholder(JSON.stringify(fullDummyPayload(collection), null, 2))};
const record = await pb.collection('${collection.name}').create(body);
`+ (isAuth ? `
// (optional) send an email verification request
await pb.collection('${collection?.name}').requestVerification('test@example.com');
` : ""),
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_JS_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "JS SDK docs",
}),
),
},
{
title: "Dart SDK",
language: "dart",
// dprint-ignore
value: `
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${baseURL}');
...
// example create body
final body = <String, dynamic>${JSON.stringify(primitivesDummyPayload(collection), null, 2)};
final record = await pb.collection('${collection.name}').create(body: body, files: []);
` + (isAuth ? `
// (optional) send an email verification request
await pb.collection('${collection?.name}').requestVerification('test@example.com');
` : ""),
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_DART_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Dart SDK docs",
}),
),
},
{
title: "curl",
language: "bash",
value: `
curl -X POST \\
-H 'Authorization:TOKEN' \\
-H 'Content-Type:application/json' \\
-d '{ ... }' \\
'${baseURL}/api/collections/${collection.name}/records/RECORD_ID'
`,
},
],
}),
// api
t.div({ className: "block m-t-base" }, t.strong(null, "API details")),
t.div(
{ className: "alert success api-preview-alert" },
t.span({ className: "label method" }, "POST"),
t.span({ className: "path" }, `/api/collections/${collection.name}/records`),
() => {
if (isSuperusersOnly) {
return t.small({ className: "extra" }, "Requires superuser Authorization:TOKEN header");
}
},
),
t.table(
{ className: "api-preview-table body-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "Body params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
() => {
if (!isAuth) {
return;
}
return [
t.tr(
null,
t.th(
{ colSpan: 99 },
"Auth specific fields",
),
),
t.tr(
null,
t.td(
{ className: "min-width" },
"email ",
() => {
if (collection.fields?.find((f) => f.name == "email")?.required) {
return t.em(null, "(required)");
}
return t.em(null, "(optional)");
},
),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "Auth record email address."),
),
t.tr(
null,
t.td(
{ className: "min-width" },
"emailVisibility ",
() => {
if (collection.fields?.find((f) => f.name == "emailVisibility")?.required) {
return t.em(null, "(required)");
}
return t.em(null, "(optional)");
},
),
t.td({ className: "min-width" }, t.span({ className: "label" }, "Boolean")),
t.td(
null,
"Whether to show/hide the auth record email when fetching the record data.",
t.br(),
"Superusers and the owner of the record always have access to the email address.",
),
),
t.tr(
null,
t.td(
{ className: "min-width" },
"password ",
t.em(null, "(required)"),
),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "Auth record password."),
),
t.tr(
null,
t.td(
{ className: "min-width" },
"passwordConfirm ",
t.em(null, "(required)"),
),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "Auth record password confirmation."),
),
t.tr(
null,
t.td(
{ className: "min-width" },
"verified ",
t.em(null, "(optional)"),
),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(
null,
t.p(null, "Indicates whether the auth record is verified or not."),
t.p(
null,
`This field can be set only by superusers or auth records with "Manage" access.`,
),
),
),
t.tr(
null,
t.th(
{ colSpan: 99 },
"Other fields",
),
),
];
},
() => {
return tableFields.map((f) => {
return t.tr(
null,
t.td(
{ className: "min-width" },
f.name,
t.em(null, f.required && !f.autogeneratePattern ? " (required)" : " (optional)"),
),
t.td(
{ className: "min-width" },
t.span(
{ className: "label" },
() => {
const dummyData = app.fieldTypes[f.type]?.dummyData(f, true);
const dummyDataType = typeof dummyData;
if (f.type == "file") return "File";
if (dummyDataType === "string") return "String";
if (dummyDataType == "number") return "Number";
if (dummyDataType == "bool") return "Boolean";
if (Array.isArray(dummyData)) return "Array";
if (app.utils.isObject(dummyData)) return "Object";
return "Mixed";
},
),
),
t.td(
null,
t.code(null, f.type),
" field type value.",
t.br(),
t.small(
{ className: "txt-hint" },
"For more details you could check the ",
t.a({
href: import.meta.env.PB_FIELDS_DOCS,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Fields docs",
}),
".",
),
),
);
});
},
),
),
t.table(
{ className: "api-preview-table query-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "?query params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "expand"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, expandInfo()),
),
t.tr(
null,
t.td({ className: "min-width" }, "fields"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, fieldsInfo()),
),
),
),
// responses
t.div({ className: "block m-t-base m-b-sm" }, t.strong(null, "Example responses")),
app.components.codeBlockTabs({
tabs: responses,
}),
);
}
export function replaceDummyPayloadPlaceholder(payloadStr) {
return payloadStr.replaceAll(`"[[`, "").replaceAll(`]]"`, "");
}
export function fullDummyPayload(collection, forUpdate = false) {
let payload = app.utils.getDummyFieldsData(collection, true);
delete payload.id;
if (collection.type == "auth") {
if (forUpdate) {
payload.oldPassword = "987654321";
delete payload.email;
}
payload.password = "123456789";
payload.passwordConfirm = "123456789";
delete payload.verified;
}
return payload;
}
export function primitivesDummyPayload(collection, forUpdate = false) {
const payload = fullDummyPayload(collection, forUpdate);
for (const prop in payload) {
const type = typeof payload[prop];
if (
// placeholder
payload[prop]?.startsWith?.("[[")
// not a primitive
|| (!["number", "string", "boolean"].includes(type) && !Array.isArray(payload[prop]))
) {
delete payload[prop];
}
}
return payload;
}

View File

@@ -0,0 +1,147 @@
export function docsDelete(collection) {
const baseURL = app.utils.getApiExampleURL();
const isSuperusersOnly = collection.deleteRule === null;
const responses = [
{
title: 204,
value: "null",
},
{
title: 400,
value: `
{
"status": 400,
"message": "Failed to delete record. Make sure that the record is not part of a required relation reference.",
"data": {}
}
`,
},
];
if (isSuperusersOnly) {
responses.push({
title: 403,
value: `
{
"status": 403,
"message": "Only superusers can access this action.",
"data": {}
}
`,
});
}
responses.push({
title: 404,
value: `
{
"status": 404,
"message": "The requested resource wasn't found.",
"data": {}
}
`,
});
return t.div(
{ pbEvent: "apiPreviewDelete", className: "content" },
// description
t.p(null, `Delete a single ${collection.name} record.`),
app.components.codeBlockTabs({
className: "sdk-examples m-t-sm",
historyKey: "pbLastSDK",
tabs: [
{
title: "JS SDK",
language: "js",
value: `
import PocketBase from 'pocketbase';
const pb = new PocketBase('${baseURL}');
...
await pb.collection('${collection.name}').delete('RECORD_ID');
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_JS_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "JS SDK docs",
}),
),
},
{
title: "Dart SDK",
language: "dart",
value: `
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${baseURL}');
...
await pb.collection('${collection.name}').delete('RECORD_ID');
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_DART_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Dart SDK docs",
}),
),
},
{
title: "curl",
language: "bash",
value: `
curl -X DELETE \\
-H 'Authorization:TOKEN' \\
'${baseURL}/api/collections/${collection.name}/records/RECORD_ID'
`,
},
],
}),
// api
t.div({ className: "block m-t-base" }, t.strong(null, "API details")),
t.div(
{ className: "alert danger api-preview-alert" },
t.span({ className: "label method" }, "DELETE"),
t.span({ className: "path" }, `/api/collections/${collection.name}/records/`, t.strong(null, ":id")),
() => {
if (isSuperusersOnly) {
return t.small({ className: "extra" }, "Requires superuser Authorization:TOKEN header");
}
},
),
t.table(
{ className: "api-preview-table path-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "Path params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "id"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "ID of the record to delete."),
),
),
),
// responses
t.div({ className: "block m-t-base m-b-sm" }, t.strong(null, "Example responses")),
app.components.codeBlockTabs({
tabs: responses,
}),
);
}

View File

@@ -0,0 +1,284 @@
export function docsEmailChange(collection) {
const baseURL = app.utils.getApiExampleURL();
const actionTabs = [
{ title: "Request email change", content: request },
{ title: "Confirm email change", content: confirm },
];
const data = store({
activeActionIndex: 0,
});
return t.div(
{
pbEvent: "apiPreviewEmailChange",
className: "content",
},
// description
t.p(null, `Sends ${collection.name} email change request.`),
t.p(
null,
"On successful email change all previously issued auth tokens for the specific record will be automatically invalidated.",
),
app.components.codeBlockTabs({
className: "sdk-examples m-t-sm",
historyKey: "pbLastSDK",
tabs: [
{
title: "JS SDK",
language: "js",
value: `
import PocketBase from 'pocketbase';
const pb = new PocketBase('${baseURL}');
...
await pb.collection('${collection.name}').authWithPassword(
'test@example.com',
'1234567890'
);
await pb.collection('${collection.name}').requestEmailChange('new@example.com');
// ---
// (optional) in your custom confirmation page:
// ---
// note: after this call all previously issued auth tokens are invalidated
await pb.collection('${collection.name}').confirmEmailChange(
'EMAIL_CHANGE_TOKEN',
'YOUR_PASSWORD',
);
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_JS_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "JS SDK docs",
}),
),
},
{
title: "Dart SDK",
language: "dart",
value: `
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${baseURL}');
...
await pb.collection('${collection.name}').authWithPassword(
'test@example.com',
'1234567890'
);
await pb.collection('${collection.name}').requestEmailChange('new@example.com');
// ---
// (optional) in your custom confirmation page:
// ---
// note: after this call all previously issued auth tokens are invalidated
await pb.collection('${collection.name}').confirmEmailChange(
'EMAIL_CHANGE_TOKEN',
'YOUR_PASSWORD',
);
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_DART_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Dart SDK docs",
}),
),
},
{
title: "curl",
language: "bash",
value: `
# Request email change
curl -X POST \\
-H 'Authorization:TOKEN' \\
-H 'Content-Type:application/json' \\
-d '{ "newEmail":"..." }' \\
'${baseURL}/api/collections/${collection.name}/request-email-change'
# Confirm email change
curl -X POST \\
-H 'Content-Type:application/json' \\
-d '{ "token":"...", "password":"" }' \\
'${baseURL}/api/collections/${collection.name}/confirm-email-change'
`,
},
],
}),
t.nav(
{ className: "btns m-t-base m-b-sm" },
() => {
return actionTabs.map((tab, i) => {
return t.button({
type: "button",
className: () => `btn sm expanded ${data.activeActionIndex == i ? "active" : "secondary"}`,
textContent: () => tab.title,
onclick: () => data.activeActionIndex = i,
});
});
},
),
() => actionTabs[data.activeActionIndex]?.content?.(collection),
);
}
function request(collection) {
const responses = [
{
title: 204,
value: "null",
},
{
title: 400,
value: `
{
"status": 400,
"message": "An error occurred while validating the submitted data.",
"data": {
"newEmail": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
{
title: 401,
value: `
{
"status": 401,
"message": "The request requires valid record authorization token to be set.",
"data": {}
}
`,
},
{
title: 403,
value: `
{
"status": 403,
"message": "The authorized record model is not allowed to perform this action.",
"data": {}
}
`,
},
];
return [
// api
t.div({ className: "block" }, t.strong(null, "API details")),
t.div(
{ className: "alert success api-preview-alert" },
t.span({ className: "label method" }, "POST"),
t.span({ className: "path" }, `/api/collections/${collection.name}/request-email-change`),
t.small({ className: "extra" }, "Requires", t.br(), "Authorization:TOKEN header"),
),
t.table(
{ className: "api-preview-table body-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "Body params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "newEmail ", t.em(null, "(required)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "The new email address to send the change email request."),
),
),
),
// responses
t.div({ className: "block m-t-base m-b-sm" }, t.strong(null, "Example responses")),
app.components.codeBlockTabs({
tabs: responses,
}),
];
}
function confirm(collection) {
const responses = [
{
title: 204,
value: "null",
},
{
title: 400,
value: `
{
"status": 400,
"message": "An error occurred while validating the submitted data.",
"data": {
"token": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
];
return [
// api
t.div({ className: "block" }, t.strong(null, "API details")),
t.div(
{ className: "alert success api-preview-alert" },
t.span({ className: "label method" }, "POST"),
t.span({ className: "path" }, `/api/collections/${collection.name}/confirm-email-change`),
),
t.table(
{ className: "api-preview-table body-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "Body params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "token ", t.em(null, "(required)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "The token from the change email request email."),
),
t.tr(
null,
t.td({ className: "min-width" }, "password ", t.em(null, "(required)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "The account password to confirm the email change."),
),
),
),
// responses
t.div({ className: "block m-t-base m-b-sm" }, t.strong(null, "Example responses")),
app.components.codeBlockTabs({
tabs: responses,
}),
];
}

View File

@@ -0,0 +1,277 @@
import { expandInfo } from "./expandInfo";
import { fieldsInfo } from "./fieldsInfo";
import { filterSyntax } from "./filterSyntax";
export function docsList(collection) {
const baseURL = app.utils.getApiExampleURL();
const isSuperusersOnly = collection.listRule === null;
const baseDummyRecord = {
collectionId: collection.id,
collectionName: collection.name,
};
const responses = [
{
title: 200,
value: JSON.stringify(
{
page: 1,
perPage: 30,
totalPages: 1,
totalItems: 2,
items: [
Object.assign(baseDummyRecord, app.utils.getDummyFieldsData(collection)),
Object.assign(baseDummyRecord, app.utils.getDummyFieldsData(collection)),
],
},
null,
2,
),
},
{
title: 400,
value: `
{
"status": 400,
"message": "Something went wrong while processing your request.",
"data": {}
}
`,
},
];
if (isSuperusersOnly) {
responses.push({
title: 403,
value: `
{
"status": 403,
"message": "Only superusers can access this action.",
"data": {}
}
`,
});
}
return t.div(
{ pbEvent: "apiPreviewList", className: "content" },
// description
t.p(null, `Fetch a paginated ${collection.name} records list, supporting sorting and filtering.`),
app.components.codeBlockTabs({
className: "sdk-examples m-t-sm",
historyKey: "pbLastSDK",
tabs: [
{
title: "JS SDK",
language: "js",
value: `
import PocketBase from 'pocketbase';
const pb = new PocketBase('${baseURL}');
...
// fetch a paginated records list
const resultList = await pb.collection('${collection.name}').getList(1, 50, {
filter: 'someField1 != someField2',
});
// you can also fetch all records at once via getFullList
const records = await pb.collection('${collection.name}').getFullList({
sort: '-someField',
});
// or fetch only the first record that matches the specified filter
const record = await pb.collection('${collection.name}').getFirstListItem(
'someField="test"',
{ expand: 'relField1,relField2.subRelField' },
);
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_JS_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "JS SDK docs",
}),
),
},
{
title: "Dart SDK",
language: "dart",
value: `
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${baseURL}');
...
// fetch a paginated records list
final resultList = await pb.collection('${collection.name}').getList(
page: 1,
perPage: 50,
filter: 'someField1 != someField2',
);
// you can also fetch all records at once via getFullList
final records = await pb.collection('${collection.name}').getFullList(
sort: '-someField',
);
// or fetch only the first record that matches the specified filter
final record = await pb.collection('${collection.name}').getFirstListItem(
'someField="test"',
expand: 'relField1,relField2.subRelField',
);
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_DART_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Dart SDK docs",
}),
),
},
{
title: "curl",
language: "bash",
value: `
curl \\
-H 'Authorization:TOKEN' \\
'${baseURL}/api/collections/${collection.name}/records?perPage=50'
`,
},
],
}),
// api
t.div({ className: "block m-t-base" }, t.strong(null, "API details")),
t.div(
{ className: "alert info api-preview-alert" },
t.span({ className: "label method" }, "GET"),
t.span({ className: "path" }, `/api/collections/${collection.name}/records`),
() => {
if (isSuperusersOnly) {
return t.small({ className: "extra" }, "Requires superuser Authorization:TOKEN header");
}
},
),
t.table(
{ className: "api-preview-table query-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "?query params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "page"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "Number")),
t.td(null, "The page (aka. offset) of the paginated list (default to 1)."),
),
t.tr(
null,
t.td({ className: "min-width" }, "perPage"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "Number")),
t.td(null, "Specify the max returned records per page (default to 30)."),
),
t.tr(
null,
t.td({ className: "min-width" }, "sort"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(
null,
t.p(
null,
"Specify the records order attribute(s).",
t.br(),
"Add -/+ (default) in front of the attribute for DESC / ASC order.",
),
t.p(
null,
"For example:",
app.components.codeBlock({
value: `// DESC by created and ASC by id\n?sort=-created,id`,
}),
),
t.p(
null,
"In addition to the collection non-hidden fields, the following special sort fields could be also used: ",
t.code(null, "@random"),
" ",
t.code({ hidden: () => collection.type == "view" }, "@rowid"),
".",
),
),
),
t.tr(
null,
t.td({ className: "min-width" }, "filter"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(
null,
t.p(null, "Filter the returned records. For example:"),
app.components.codeBlock({
value: `?filter=(id='abc' && created>'2022-01-01')`,
footnote: "All query params must be properly URL encoded (the SDKs do this automatically).",
}),
filterSyntax(),
),
),
t.tr(
null,
t.td({ className: "min-width" }, "expand"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, expandInfo()),
),
t.tr(
null,
t.td({ className: "min-width" }, "fields"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, fieldsInfo()),
),
t.tr(
null,
t.td({ className: "min-width" }, "skipTotal"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "Boolean")),
t.td(
null,
t.p(
null,
"If set to ",
t.code(null, "1/true"),
" the total counts query will be skipped and the response fields ",
t.code(null, "totalItems"),
" and ",
t.code(null, "totalPages"),
" will have -1 value.",
),
t.p(
null,
"This could drastically speed up the search queries when the total counters are not needed or cursor based pagination is used.",
" For optimization purposes, it is set by default in the ",
t.code(null, "getFirstListItem()"),
" and ",
t.code(null, "getFullList()"),
" SDKs methods.",
),
),
),
),
),
// responses
t.div({ className: "block m-t-base m-b-sm" }, t.strong(null, "Example responses")),
app.components.codeBlockTabs({
tabs: responses,
}),
);
}

View File

@@ -0,0 +1,144 @@
import { fieldsInfo } from "./fieldsInfo";
export function docsListAuthMethods(collection) {
const baseURL = app.utils.getApiExampleURL();
const data = store({
isLoading: false,
authMethods: [],
get responses() {
return [
{
title: 200,
value: data.isLoading ? "..." : JSON.stringify(data.authMethods, null, 2),
},
{
title: 404,
value: `
{
"status": 404,
"message": "Missing collection context.",
"data": {}
}
`,
},
];
},
});
async function listAuthMethods() {
data.isLoading = true;
try {
data.authMethods = await app.pb.collection(collection.name).listAuthMethods();
} catch (err) {
if (err.isAbort) {
app.pb.checkApiError(err);
}
}
data.isLoading = false;
}
return t.div(
{
pbEvent: "apiPreviewListAuthMethods",
className: "content",
onmount: () => {
listAuthMethods();
},
},
// description
t.p(null, `Returns a public list with all allowed ${collection.name} authentication methods.`),
app.components.codeBlockTabs({
className: "sdk-examples m-t-sm",
historyKey: "pbLastSDK",
tabs: [
{
title: "JS SDK",
language: "js",
value: `
import PocketBase from 'pocketbase';
const pb = new PocketBase('${baseURL}');
...
const result = await pb.collection('${collection.name}').listAuthMethods();
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_JS_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "JS SDK docs",
}),
),
},
{
title: "Dart SDK",
language: "dart",
value: `
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${baseURL}');
...
final result = await pb.collection('${collection.name}').listAuthMethods();
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_DART_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Dart SDK docs",
}),
),
},
{
title: "curl",
language: "bash",
value: `
curl '${baseURL}/api/collections/${collection.name}/auth-methods'
`,
},
],
}),
// api
t.div({ className: "block m-t-base" }, t.strong(null, "API details")),
t.div(
{ className: "alert info api-preview-alert" },
t.span({ className: "label method" }, "GET"),
t.span({ className: "path" }, `/api/collections/${collection.name}/auth-methods`),
),
t.table(
{ className: "api-preview-table query-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "?query params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "fields"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, fieldsInfo()),
),
),
),
// responses
t.div({ className: "block m-t-base m-b-sm" }, t.strong(null, "Example responses")),
app.components.codeBlockTabs({
tabs: () => data.responses,
}),
);
}

View File

@@ -0,0 +1,260 @@
export function docsPasswordReset(collection) {
const baseURL = app.utils.getApiExampleURL();
const actionTabs = [
{ title: "Request password reset", content: request },
{ title: "Confirm password reset", content: confirm },
];
const data = store({
activeActionIndex: 0,
});
return t.div(
{
pbEvent: "apiPreviewPasswordReset",
className: "content",
},
// description
t.p(null, `Sends ${collection.name} password reset email request.`),
t.p(
null,
"On successful password reset all previously issued auth tokens for the specific record will be automatically invalidated.",
),
app.components.codeBlockTabs({
className: "sdk-examples m-t-sm",
historyKey: "pbLastSDK",
tabs: [
{
title: "JS SDK",
language: "js",
value: `
import PocketBase from 'pocketbase';
const pb = new PocketBase('${baseURL}');
...
await pb.collection('${collection.name}').requestPasswordReset('test@example.com');
// ---
// (optional) in your custom confirmation page:
// ---
// note: after this call all previously issued auth tokens are invalidated
await pb.collection('${collection.name}').confirmPasswordReset(
'RESET_TOKEN',
'NEW_PASSWORD',
'NEW_PASSWORD_CONFIRM',
);
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_JS_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "JS SDK docs",
}),
),
},
{
title: "Dart SDK",
language: "dart",
value: `
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${baseURL}');
...
await pb.collection('${collection.name}').requestPasswordReset('test@example.com');
// ---
// (optional) in your custom confirmation page:
// ---
// note: after this call all previously issued auth tokens are invalidated
await pb.collection('${collection.name}').confirmPasswordReset(
'RESET_TOKEN',
'NEW_PASSWORD',
'NEW_PASSWORD_CONFIRM',
);
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_DART_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Dart SDK docs",
}),
),
},
{
title: "curl",
language: "bash",
value: `
# Request password reset
curl -X POST \\
-H 'Content-Type:application/json' \\
-d '{ "email":"..." }' \\
'${baseURL}/api/collections/${collection.name}/request-password-reset'
# Confirm password reset
curl -X POST \\
-H 'Content-Type:application/json' \\
-d '{ "token":"...", "password":"", "passwordConfirm":"" }' \\
'${baseURL}/api/collections/${collection.name}/confirm-password-reset'
`,
},
],
}),
t.nav(
{ className: "btns m-t-base m-b-sm" },
() => {
return actionTabs.map((tab, i) => {
return t.button({
type: "button",
className: () => `btn sm expanded ${data.activeActionIndex == i ? "active" : "secondary"}`,
textContent: () => tab.title,
onclick: () => data.activeActionIndex = i,
});
});
},
),
() => actionTabs[data.activeActionIndex]?.content?.(collection),
);
}
function request(collection) {
const responses = [
{
title: 204,
value: "null",
},
{
title: 400,
value: `
{
"status": 400,
"message": "An error occurred while validating the submitted data.",
"data": {
"email": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
];
return [
// api
t.div({ className: "block" }, t.strong(null, "API details")),
t.div(
{ className: "alert success api-preview-alert" },
t.span({ className: "label method" }, "POST"),
t.span({ className: "path" }, `/api/collections/${collection.name}/request-password-reset`),
),
t.table(
{ className: "api-preview-table body-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "Body params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "email ", t.em(null, "(required)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "The auth record email address to send the password reset request (if exists)."),
),
),
),
// responses
t.div({ className: "block m-t-base m-b-sm" }, t.strong(null, "Example responses")),
app.components.codeBlockTabs({
tabs: responses,
}),
];
}
function confirm(collection) {
const responses = [
{
title: 204,
value: "null",
},
{
title: 400,
value: `
{
"status": 400,
"message": "An error occurred while validating the submitted data.",
"data": {
"token": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
];
return [
// api
t.div({ className: "block" }, t.strong(null, "API details")),
t.div(
{ className: "alert success api-preview-alert" },
t.span({ className: "label method" }, "POST"),
t.span({ className: "path" }, `/api/collections/${collection.name}/confirm-password-reset`),
),
t.table(
{ className: "api-preview-table body-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "Body params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "token ", t.em(null, "(required)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "The token from the password reset request email."),
),
t.tr(
null,
t.td({ className: "min-width" }, "password ", t.em(null, "(required)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "The new password to set."),
),
t.tr(
null,
t.td({ className: "min-width" }, "passwordConfirm ", t.em(null, "(required)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "Confirmation of the new password."),
),
),
),
// responses
t.div({ className: "block m-t-base m-b-sm" }, t.strong(null, "Example responses")),
app.components.codeBlockTabs({
tabs: responses,
}),
];
}

View File

@@ -0,0 +1,190 @@
export function docsRealtime(collection) {
const baseURL = app.utils.getApiExampleURL();
const dummyRecord = Object.assign({
collectionId: collection.id,
collectionName: collection.name,
}, app.utils.getDummyFieldsData(collection));
return t.div(
{ pbEvent: "apiPreviewRealtime", className: "content" },
// description
t.p(null, `Subscribe to realtime changes via Server-Sent Events (SSE).`),
t.p(
null,
"Events are sent for ",
t.strong(null, "create"),
", ",
t.strong(null, "update"),
" and ",
t.strong(null, "delete"),
` record operations (see "Event data format" below).`,
),
t.div(
{ className: "alert info" },
t.p({ className: "txt-bold" }, "You could subscribe to a single record or to an entire collection."),
t.p(
null,
"When you subscribe to a ",
t.strong(null, "single record"),
", the collection's ",
t.strong(null, "View rule"),
" will be used to determine whether the subscriber is allowed to receive the event message.",
),
t.p(
null,
"When you subscribe to an ",
t.strong(null, "entire collection"),
", the collection's ",
t.strong(null, "List/Search rule"),
" will be used to determine whether the subscriber is allowed to receive the event message.",
),
),
app.components.codeBlockTabs({
className: "sdk-examples m-t-sm",
historyKey: "pbLastSDK",
tabs: [
{
title: "JS SDK",
language: "js",
value: `
import PocketBase from 'pocketbase';
const pb = new PocketBase('${baseURL}');
...
// (optionally) authenticate
await pb.collection('users').authWithPassword('test@example.com', '123456');
// subscribe to changes in any ${baseURL} record
pb.collection('${baseURL}').subscribe('*', function (e) {
console.log(e.action);
console.log(e.record);
}, { /* other options like: filter, expand, custom headers, etc. */ });
// subscribe to changes only in the specified record
pb.collection('${baseURL}').subscribe('RECORD_ID', function (e) {
console.log(e.action);
console.log(e.record);
}, { /* other options like: filter, expand, custom headers, etc. */ });
...
// unsubscribe - remove all 'RECORD_ID' subscriptions
pb.collection('${baseURL}').unsubscribe('RECORD_ID');
// unsubscribe - remove all '*' topic subscriptions
pb.collection('${baseURL}').unsubscribe('*');
// unsubscribe - remove all collection subscriptions
pb.collection('${baseURL}').unsubscribe();
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_JS_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "JS SDK docs",
}),
),
},
{
title: "Dart SDK",
language: "dart",
value: `
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${baseURL}');
...
// (optionally) authenticate
await pb.collection('users').authWithPassword('test@example.com', '123456');
// subscribe to changes in any ${baseURL} record
pb.collection('${baseURL}').subscribe('*', (e) {
print(e.action);
print(e.record);
}, /* other options like: filter, expand, custom headers, etc. */);
// subscribe to changes only in the specified record
pb.collection('${baseURL}').subscribe('RECORD_ID', (e) {
print(e.action);
print(e.record);
}, /* other options like: filter, expand, custom headers, etc. */);
...
// unsubscribe - remove all 'RECORD_ID' subscriptions
pb.collection('${baseURL}').unsubscribe('RECORD_ID');
// unsubscribe - remove all '*' topic subscriptions
pb.collection('${baseURL}').unsubscribe('*');
// unsubscribe - remove all collection subscriptions
pb.collection('${baseURL}').unsubscribe();
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_DART_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Dart SDK docs",
}),
),
},
{
title: "curl",
language: "bash",
value: `
# init an SSE connection and start listening for messages
# (the first message is always PB_CONNECT with the connection "clientId")
curl -N '${baseURL}/api/realtime'
# open a new terminal and submit the subscription topic(s)
# with the "clientId" from the initial PB_CONNECT message
curl -X POST \\
-H 'Authorization:TOKEN' \\
-H 'Content-Type:application/json' \\
-d '{ "clientId": "YOUR_CLIENT_ID", "subscriptions": ["${collection.name}/*"] }' \\
'${baseURL}/api/realtime'
# create/update/delete a record in the ${collection.name} collection and
# you should see the event message(s) in the first terminal
# (as long as your client satisfies the topic API rule)
`,
},
],
}),
// api
t.div({ className: "block m-t-base" }, t.strong(null, "API details")),
t.div(
{ className: "alert api-preview-alert" },
t.span({ className: "label method" }, "GET/POST"),
t.span({ className: "path" }, "/api/realtime"),
t.div(
{ className: "extra" },
t.a({
href: import.meta.env.PB_REALTIME_DOCS,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Realtime docs",
}),
),
),
t.div({ className: "block m-t-base m-b-sm" }, t.strong(null, "Event data format")),
app.components.codeBlock({
value: JSON.stringify(
{
"action": "create",
"record": dummyRecord,
},
null,
2,
).replace(`"action": "create",`, "\"action\": \"create\", // create, update or delete"),
}),
);
}

View File

@@ -0,0 +1,242 @@
import { fullDummyPayload, primitivesDummyPayload, replaceDummyPayloadPlaceholder } from "./docsCreate";
import { expandInfo } from "./expandInfo";
import { fieldsInfo } from "./fieldsInfo";
export function docsUpdate(collection) {
const baseURL = app.utils.getApiExampleURL();
const isSuperusersOnly = collection.updateRule === null;
const isAuth = collection.type === "auth";
const excludedTableFields = isAuth ? ["id", "password", "verified", "email", "emailVisibility"] : ["id"];
const tableFields =
collection.fields?.filter((f) => !f.hidden && f.type != "autodate" && !excludedTableFields.includes(f.name))
|| [];
const baseDummyRecord = {
collectionId: collection.id,
collectionName: collection.name,
};
const responses = [
{
title: 200,
value: JSON.stringify(
Object.assign(baseDummyRecord, app.utils.getDummyFieldsData(collection)),
null,
2,
),
},
{
title: 400,
value: `
{
"status": 400,
"message": "Failed to create record.",
"data": {
"${tableFields.find((f) => !f.primaryKey)?.name || "someField"}": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
];
if (isSuperusersOnly) {
responses.push({
title: 403,
value: `
{
"status": 403,
"message": "Only superusers can perform this action.",
"data": {}
}
`,
});
}
responses.push({
title: 404,
value: `
{
"status": 404,
"message": "The requested resource wasn't found.",
"data": {}
}
`,
});
return t.div(
{ pbEvent: "apiPreviewUpdate", className: "content" },
// description
t.p(null, `Updates an existing ${collection.name} record.`),
t.p(
null,
"Body parameters could be sent as ",
t.code(null, "application/json"),
" or ",
t.code(null, "multipart/form-data"),
".",
),
t.p(
null,
"File upload is supported only via ",
t.code(null, "multipart/form-data"),
". For more info and examples you could check the detailed ",
t.a({
href: import.meta.env.PB_FILE_UPLOAD_DOCS,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Files upload and handling docs",
}),
".",
),
t.p(
null,
t.em(
null,
"Note that in case of a password change all previously issued tokens for the current record will be automatically invalidated and if you want your user to remain signed in you need to reauthenticate manually after the update call.",
),
),
app.components.codeBlockTabs({
className: "sdk-examples m-t-sm",
historyKey: "pbLastSDK",
tabs: [
{
title: "JS SDK",
language: "js",
// dprint-ignore
value: `
import PocketBase from 'pocketbase';
const pb = new PocketBase('${baseURL}');
...
// example update body
const body = ${replaceDummyPayloadPlaceholder(JSON.stringify(fullDummyPayload(collection, true), null, 2))};
const record = await pb.collection('${collection.name}').update('RECORD_ID', body);
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_JS_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "JS SDK docs",
}),
),
},
{
title: "Dart SDK",
language: "dart",
// dprint-ignore
value: `
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${baseURL}');
...
// example update body
final body = <String, dynamic>${JSON.stringify(primitivesDummyPayload(collection, true), null, 2)};
final record = await pb.collection('${collection.name}').update(
'RECORD_ID',
body: body,
files: [],
);
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_DART_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Dart SDK docs",
}),
),
},
{
title: "curl",
language: "bash",
value: `
curl -X PATCH \\
-H 'Authorization:TOKEN' \\
-H 'Content-Type:application/json' \\
-d '{ ... }' \\
'${baseURL}/api/collections/${collection.name}/records/RECORD_ID'
`,
},
],
}),
// api
t.div({ className: "block m-t-base" }, t.strong(null, "API details")),
t.div(
{ className: "alert warning api-preview-alert" },
t.span({ className: "label method" }, "PATCH"),
t.span({ className: "path" }, `/api/collections/${collection.name}/records/`, t.strong(null, ":id")),
() => {
if (isSuperusersOnly) {
return t.small({ className: "extra" }, "Requires superuser Authorization:TOKEN header");
}
},
),
t.table(
{ className: "api-preview-table path-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "Path params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "id"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "ID of the record to update."),
),
),
),
t.table(
{ className: "api-preview-table query-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "?query params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "expand"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, expandInfo()),
),
t.tr(
null,
t.td({ className: "min-width" }, "fields"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, fieldsInfo()),
),
),
),
// responses
t.div({ className: "block m-t-base m-b-sm" }, t.strong(null, "Example responses")),
app.components.codeBlockTabs({
tabs: responses,
}),
);
}

View File

@@ -0,0 +1,234 @@
export function docsVerification(collection) {
const baseURL = app.utils.getApiExampleURL();
const actionTabs = [
{ title: "Request verification", content: request },
{ title: "Confirm verification", content: confirm },
];
const data = store({
activeActionIndex: 0,
});
return t.div(
{
pbEvent: "apiPreviewVerification",
className: "content",
},
// description
t.p(null, `Sends ${collection.name} account verification request.`),
app.components.codeBlockTabs({
className: "sdk-examples m-t-sm",
historyKey: "pbLastSDK",
tabs: [
{
title: "JS SDK",
language: "js",
value: `
import PocketBase from 'pocketbase';
const pb = new PocketBase('${baseURL}');
...
await pb.collection('${collection.name}').requestVerification('test@example.com');
// ---
// (optional) in your custom confirmation page:
// ---
await pb.collection('${collection.name}').confirmVerification('VERIFICATION_TOKEN');
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_JS_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "JS SDK docs",
}),
),
},
{
title: "Dart SDK",
language: "dart",
value: `
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${baseURL}');
...
await pb.collection('${collection.name}').requestVerification('test@example.com');
// ---
// (optional) in your custom confirmation page:
// ---
await pb.collection('${collection.name}').confirmVerification('VERIFICATION_TOKEN');
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_DART_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Dart SDK docs",
}),
),
},
{
title: "curl",
language: "bash",
value: `
# Request verification
curl -X POST \\
-H 'Content-Type:application/json' \\
-d '{ "email":"..." }' \\
'${baseURL}/api/collections/${collection.name}/request-verification'
# Confirm verification
curl -X POST \\
-H 'Content-Type:application/json' \\
-d '{ "token":"..." }' \\
'${baseURL}/api/collections/${collection.name}/confirm-verification'
`,
},
],
}),
t.nav(
{ className: "btns m-t-base m-b-sm" },
() => {
return actionTabs.map((tab, i) => {
return t.button({
type: "button",
className: () => `btn sm expanded ${data.activeActionIndex == i ? "active" : "secondary"}`,
textContent: () => tab.title,
onclick: () => data.activeActionIndex = i,
});
});
},
),
() => actionTabs[data.activeActionIndex]?.content?.(collection),
);
}
function request(collection) {
const responses = [
{
title: 204,
value: "null",
},
{
title: 400,
value: `
{
"status": 400,
"message": "An error occurred while validating the submitted data.",
"data": {
"email": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
];
return [
// api
t.div({ className: "block" }, t.strong(null, "API details")),
t.div(
{ className: "alert success api-preview-alert" },
t.span({ className: "label method" }, "POST"),
t.span({ className: "path" }, `/api/collections/${collection.name}/request-verification`),
),
t.table(
{ className: "api-preview-table body-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "Body params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "email ", t.em(null, "(required)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "The auth record email address to send the verification request (if exists)."),
),
),
),
// responses
t.div({ className: "block m-t-base m-b-sm" }, t.strong(null, "Example responses")),
app.components.codeBlockTabs({
tabs: responses,
}),
];
}
function confirm(collection) {
const responses = [
{
title: 204,
value: "null",
},
{
title: 400,
value: `
{
"status": 400,
"message": "An error occurred while validating the submitted data.",
"data": {
"token": {
"code": "validation_required",
"message": "Missing required value."
}
}
}
`,
},
];
return [
// api
t.div({ className: "block" }, t.strong(null, "API details")),
t.div(
{ className: "alert success api-preview-alert" },
t.span({ className: "label method" }, "POST"),
t.span({ className: "path" }, `/api/collections/${collection.name}/confirm-verification`),
),
t.table(
{ className: "api-preview-table body-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "Body params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "token ", t.em(null, "(required)")),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "The token from the verification request email."),
),
),
),
// responses
t.div({ className: "block m-t-base m-b-sm" }, t.strong(null, "Example responses")),
app.components.codeBlockTabs({
tabs: responses,
}),
];
}

View File

@@ -0,0 +1,180 @@
import { expandInfo } from "./expandInfo";
import { fieldsInfo } from "./fieldsInfo";
export function docsView(collection) {
const baseURL = app.utils.getApiExampleURL();
const isSuperusersOnly = collection.viewRule === null;
const baseDummyRecord = {
collectionId: collection.id,
collectionName: collection.name,
};
const responses = [
{
title: 200,
value: JSON.stringify(
Object.assign(baseDummyRecord, app.utils.getDummyFieldsData(collection)),
null,
2,
),
},
];
if (isSuperusersOnly) {
responses.push({
title: 403,
value: `
{
"status": 403,
"message": "Only superusers can access this action.",
"data": {}
}
`,
});
}
responses.push({
title: 404,
value: `
{
"status": 404,
"message": "The requested resource wasn't found.",
"data": {}
}
`,
});
return t.div(
{ pbEvent: "apiPreviewView", className: "content" },
// description
t.p(null, `Fetch a single ${collection.name} record.`),
app.components.codeBlockTabs({
className: "sdk-examples m-t-sm",
historyKey: "pbLastSDK",
tabs: [
{
title: "JS SDK",
language: "js",
value: `
import PocketBase from 'pocketbase';
const pb = new PocketBase('${baseURL}');
...
const record = await pb.collection('${collection.name}').getOne('RECORD_ID', {
expand: 'relField1,relField2.subRelField',
});
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_JS_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "JS SDK docs",
}),
),
},
{
title: "Dart SDK",
language: "dart",
value: `
import 'package:pocketbase/pocketbase.dart';
final pb = PocketBase('${baseURL}');
...
final record = await pb.collection('${collection.name}').getOne('RECORD_ID',
expand: 'relField1,relField2.subRelField',
);
`,
footnote: t.div(
{ className: "txt-right" },
t.a({
href: import.meta.env.PB_DART_SDK_URL,
target: "_blank",
rel: "noopener noreferrer",
textContent: "Dart SDK docs",
}),
),
},
{
title: "curl",
language: "bash",
value: `
curl \\
-H 'Authorization:TOKEN' \\
'${baseURL}/api/collections/${collection.name}/records/RECORD_ID'
`,
},
],
}),
// api
t.div({ className: "block m-t-base" }, t.strong(null, "API details")),
t.div(
{ className: "alert info api-preview-alert" },
t.span({ className: "label method" }, "GET"),
t.span({ className: "path" }, `/api/collections/${collection.name}/records/`, t.strong(null, ":id")),
() => {
if (isSuperusersOnly) {
return t.small({ className: "extra" }, "Requires superuser Authorization:TOKEN header");
}
},
),
t.table(
{ className: "api-preview-table path-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "Path params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "id"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, "ID of the record to view."),
),
),
),
t.table(
{ className: "api-preview-table query-params" },
t.thead(
null,
t.tr(
null,
t.th({ className: "min-width txt-primary" }, "?query params"),
t.th({ className: "min-width" }, "Type"),
t.th(null, "Description"),
),
),
t.tbody(
null,
t.tr(
null,
t.td({ className: "min-width" }, "expand"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, expandInfo()),
),
t.tr(
null,
t.td({ className: "min-width" }, "fields"),
t.td({ className: "min-width" }, t.span({ className: "label" }, "String")),
t.td(null, fieldsInfo()),
),
),
),
// responses
t.div({ className: "block m-t-base m-b-sm" }, t.strong(null, "Example responses")),
app.components.codeBlockTabs({
tabs: responses,
}),
);
}

View File

@@ -0,0 +1,25 @@
export function expandInfo() {
return t.div(
{ className: "api-expand-info" },
t.p(null, "Auto expand record relations. For example:"),
app.components.codeBlock({
value: `?expand=relField1,relField2.subRelField`,
}),
t.p(
null,
"Supports up to 6-levels depth nested relations expansion.",
t.br(),
"The expanded relations will be appended to each individual record under the ",
t.code(null, "expand"),
" property (eg. ",
t.code(null, `"expand": {"relField1": {...}, ...}`),
").",
),
t.p(
null,
"Only the relations to which the request user has permissions to ",
t.strong(null, "view"),
" will be expanded.",
),
);
}

View File

@@ -0,0 +1,25 @@
export function fieldsInfo() {
return t.div(
{ className: "api-fields-info" },
t.p(
null,
"Comma separated string of the fields to return in the JSON response (by default returns all fields). For example:",
),
app.components.codeBlock({
value:
`// return all root level fields and only\n// "relField.someField" from expand\n?fields=*,expand.relField.someField`,
}),
t.p(null, "Use ", t.code(null, "*"), " to target all keys from the specific depth level."),
t.p(null, "In addition, the following field modifiers are also supported:"),
t.ul(
null,
t.li(
null,
t.code(null, ":excerpt(maxLength, withEllipsis?)"),
t.br(),
"Returns a short plain text version of the field string value. Ex.: ",
t.code(null, "?fields=*,someTextField:excerpt(200,true)"),
),
),
);
}

View File

@@ -0,0 +1,165 @@
export function filterSyntax() {
const data = store({
show: false,
});
return t.div(
{ className: "filter-details m-t-10" },
t.button(
{
type: "button",
className: "btn secondary sm",
onclick: () => data.show = !data.show,
},
() => {
if (data.show) {
return [
t.span({ className: "txt" }, "Hide details"),
t.i({ className: "ri-arrow-up-s-line" }),
];
}
return [
t.span({ className: "txt" }, "Show details"),
t.i({ className: "ri-arrow-down-s-line" }),
];
},
),
app.components.slide(
() => data.show,
t.div(
{ className: "block p-t-5" },
t.p(
null,
"The filter syntax follows the format ",
t.code(
null,
t.span({ className: "txt-success" }, "OPERAND"),
t.span({ className: "txt-danger" }, " OPERATOR "),
t.span({ className: "txt-success" }, "OPERAND"),
),
", where:",
),
t.ul(
null,
t.li(
null,
t.span({ className: "txt-code txt-success" }, "OPERAND"),
" could be any of the above field literal, function, string (single or double quoted), number, null, true, false",
),
t.li(
null,
t.span({ className: "txt-code txt-danger" }, "OPERATOR"),
" is one of:",
t.ul(
null,
t.li(null, t.code({ className: "filter-op" }, "="), " Equal"),
t.li(null, t.code({ className: "filter-op" }, "!="), " Not equal"),
t.li(null, t.code({ className: "filter-op" }, ">"), " Greater than"),
t.li(null, t.code({ className: "filter-op" }, ">="), " Greater than or equal"),
t.li(null, t.code({ className: "filter-op" }, "<"), " Less than"),
t.li(null, t.code({ className: "filter-op" }, "<="), " Less than or equal"),
t.li(
null,
t.code({ className: "filter-op" }, "~"),
" Like/Contains",
t.div(
{ className: "txt-sm txt-hint" },
t.em(
null,
"(auto wraps the right string OPERAND in a \"%\" for wildcard match if not explicitly set)",
),
),
),
t.li(
null,
t.code({ className: "filter-op" }, "!~"),
" NOT Like/Contains",
t.div(
{ className: "txt-sm txt-hint" },
t.em(
null,
"(auto wraps the right string OPERAND in a \"%\" for wildcard match if not explicitly set)",
),
),
),
// any/at-least-one-of
t.li(
null,
t.code({ className: "filter-op" }, "?="),
t.span({ className: "txt-hint" }, " Any/At-least-one-of"),
" Equal",
),
t.li(
null,
t.code({ className: "filter-op" }, "?!="),
t.span({ className: "txt-hint" }, " Any/At-least-one-of"),
" Not equal",
),
t.li(
null,
t.code({ className: "filter-op" }, "?>"),
t.span({ className: "txt-hint" }, " Any/At-least-one-of"),
" Greater than",
),
t.li(
null,
t.code({ className: "filter-op" }, "?>="),
t.span({ className: "txt-hint" }, " Any/At-least-one-of"),
" Greater than or equal",
),
t.li(
null,
t.code({ className: "filter-op" }, "?<"),
t.span({ className: "txt-hint" }, " Any/At-least-one-of"),
" Less than",
),
t.li(
null,
t.code({ className: "filter-op" }, "?<="),
t.span({ className: "txt-hint" }, " Any/At-least-one-of"),
" Less than or equal",
),
t.li(
null,
t.code({ className: "filter-op" }, "?~"),
t.span({ className: "txt-hint" }, " Any/At-least-one-of"),
" Like/Contains",
t.div(
{ className: "txt-sm txt-hint" },
t.em(
null,
"(auto wraps the right string OPERAND in a \"%\" for wildcard match if not explicitly set)",
),
),
),
t.li(
null,
t.code({ className: "filter-op" }, "?!~"),
t.span({ className: "txt-hint" }, " Any/At-least-one-of"),
" NOT Like/Contains",
t.div(
{ className: "txt-sm txt-hint" },
t.em(
null,
"(auto wraps the right string OPERAND in a \"%\" for wildcard match if not explicitly set)",
),
),
),
),
t.p(
null,
"To group and combine several expressions you could use brackets ",
t.code(null, "(...)"),
", ",
t.code(null, "&&"),
", (AND) and ",
t.code(null, "||"),
" (OR) tokens.",
),
),
),
),
),
);
}

View File

@@ -0,0 +1,129 @@
import PocketBase, { getTokenPayload } from "pocketbase";
export function pageConfirmEmailChange(route) {
const token = route.params?.token || "";
const tokenPayload = getTokenPayload(token);
if (!tokenPayload.newEmail || !tokenPayload.collectionId) {
app.toasts.error("Invalid or expired email change token.");
window.location.hash = "#/";
return;
}
app.store.title = "Confirm email change";
const data = store({
password: "",
isSubmitting: false,
isSuccess: false,
showPassword: false,
});
async function submit() {
if (data.isSubmitting) {
return;
}
data.isSubmitting = true;
// init a custom client to avoid interfering with the superuser state
const client = new PocketBase(app.pb.baseURL);
try {
await client.collection(tokenPayload.collectionId).confirmEmailChange(token, data.password);
data.isSuccess = true;
} catch (err) {
app.checkApiError(err);
data.isSuccess = false;
}
data.isSubmitting = false;
}
return t.div(
{
pbEvent: "pageConfirmEmailChange",
className: "wrapper sm m-auto p-b-base",
},
t.header(
{ className: "txt-center m-b-base" },
t.img({ className: "main-logo", src: () => app.store.mainLogo, ariaHidden: true, alt: "App logo" }),
t.h5({ className: "m-t-10" }, () => app.store.title),
),
() => {
if (data.isSuccess) {
return t.div(
{
pbEvent: "confirmEmailChangeAlert",
className: "alert success txt-center",
},
t.p(null, "The email was successfully changed."),
t.p(null, "You can go back and sign in with your new email address."),
);
}
return t.form(
{
pbEvent: "confirmEmailChangeForm",
className: "grid confirm-email-change-form",
onsubmit: (e) => {
e.preventDefault();
submit();
},
},
t.div(
{ className: "col-12" },
t.div(
{ className: "content txt-center m-b-sm" },
"Type your password to confirm changing your email address to ",
t.strong(null, tokenPayload.newEmail),
":",
),
t.div(
{ className: "fields" },
t.div(
{ className: "field" },
t.label({ htmlFor: "password_confirm" }, "Password"),
t.input({
id: "password_confirm",
name: "password",
required: true,
autofocus: true,
type: () => (data.showPassword ? "text" : "password"),
value: () => data.password,
oninput: (e) => (data.password = e.target.value),
}),
),
t.div(
{ className: "field addon" },
t.button(
{
type: "button",
tabIndex: -1,
className: "btn sm transparent secondary circle tooltip-right",
ariaDescription: app.attrs.tooltip(() =>
data.showPassword ? "Hide password" : "Show password"
),
onclick: () => (data.showPassword = !data.showPassword),
},
t.i({
className: () => (data.showPassword ? "ri-eye-off-line" : "ri-eye-line"),
}),
),
),
),
),
t.div(
{ className: "col-12" },
t.button(
{
className: () => `btn lg block ${data.isSubmitting ? "loading" : ""}`,
disabled: () => data.isSubmitting,
},
t.span({ className: "txt" }, "Confirm new email"),
),
),
);
},
);
}

View File

@@ -0,0 +1,167 @@
import PocketBase, { getTokenPayload } from "pocketbase";
export function pageConfirmPasswordReset(route) {
const token = route.params?.token || "";
const tokenPayload = getTokenPayload(token);
if (!tokenPayload.email || !tokenPayload.collectionId) {
app.toasts.error("Invalid or expired password reset token.");
window.location.hash = "#/";
return;
}
app.store.title = "Confirm password reset";
const data = store({
newPassword: "",
newPasswordConfirm: "",
showNewPassword: false,
showNewPasswordConfirm: false,
isSubmitting: false,
isSuccess: false,
});
async function submit() {
if (data.isSubmitting) {
return;
}
data.isSubmitting = true;
// init a custom client to avoid interfering with the superuser state
const client = new PocketBase(app.pb.baseURL);
try {
await client
.collection(tokenPayload.collectionId)
.confirmPasswordReset(token, data.newPassword, data.newPasswordConfirm);
data.isSuccess = true;
} catch (err) {
app.checkApiError(err);
}
data.isSubmitting = false;
}
return t.div(
{
pbEvent: "pageConfirmPasswordReset",
className: "wrapper sm m-auto p-b-base",
},
t.header(
{ className: "txt-center m-b-base" },
t.img({ className: "main-logo", src: () => app.store.mainLogo, ariaHidden: true, alt: "App logo" }),
t.h5({ className: "m-t-10" }, () => app.store.title),
),
() => {
if (data.isSuccess) {
return t.div(
{ pbEvent: "confirmPasswordResetAlert", className: "alert success txt-center" },
t.p(null, "The password was successfully changed."),
t.p(null, "You can go back to sign in with your new password."),
);
}
return t.form(
{
pbEvent: "confirmPasswordResetForm",
className: "grid confirm-password-reset-form",
onsubmit: (e) => {
e.preventDefault();
submit();
},
},
t.div(
{ className: "col-12" },
t.div(
{ className: "content txt-center m-b-sm" },
"Type your new password for ",
t.strong(null, tokenPayload.email),
":",
),
t.div(
{ className: "fields" },
t.div(
{ className: "field" },
t.label({ htmlFor: "newPassword" }, "New password"),
t.input({
id: "newPassword",
name: "password",
required: true,
autofocus: true,
autocomplete: "new-password",
type: () => (data.showNewPassword ? "text" : "password"),
value: () => data.newPassword,
oninput: (e) => (data.newPassword = e.target.value),
}),
),
t.div(
{ className: "field addon" },
t.button(
{
type: "button",
tabIndex: -1,
className: "btn sm transparent secondary circle tooltip-right",
ariaDescription: app.attrs.tooltip(() =>
data.showNewPassword ? "Hide password" : "Show password"
),
onclick: () => (data.showNewPassword = !data.showNewPassword),
},
t.i({
className: () => (data.showNewPassword ? "ri-eye-off-line" : "ri-eye-line"),
}),
),
),
),
),
t.div(
{ className: "col-12" },
t.div(
{ className: "fields" },
t.div(
{ className: "field" },
t.label({ htmlFor: "newPasswordConfirm" }, "New password confirm"),
t.input({
id: "newPasswordConfirm",
name: "passwordConfirm",
required: true,
autocomplete: "new-password",
type: () => (data.showNewPasswordConfirm ? "text" : "password"),
value: () => data.newPasswordConfirm,
oninput: (e) => (data.newPasswordConfirm = e.target.value),
}),
),
t.div(
{ className: "field addon" },
t.button(
{
type: "button",
tabIndex: -1,
className: "btn sm transparent secondary circle tooltip-right",
ariaDescription: app.attrs.tooltip(() =>
data.showNewPasswordConfirm ? "Hide password" : "Show password"
),
onclick: () => (data.showNewPasswordConfirm = !data.showNewPasswordConfirm),
},
t.i({
className: () => (data.showNewPasswordConfirm ? "ri-eye-off-line" : "ri-eye-line"),
}),
),
),
),
),
t.div(
{ className: "col-12" },
t.button(
{
className: () => `btn lg block ${data.isSubmitting ? "loading" : ""}`,
disabled: () => data.isSubmitting,
},
t.span({ className: "txt" }, "Set new password"),
),
),
);
},
);
}

View File

@@ -0,0 +1,112 @@
import PocketBase, { getTokenPayload } from "pocketbase";
export function pageConfirmVerification(route) {
const token = route.params?.token || "";
const tokenPayload = getTokenPayload(token);
if (!tokenPayload.email || !tokenPayload.collectionId) {
app.toasts.error("Invalid or expired verification token.");
window.location.hash = "#/";
return;
}
app.store.title = "Confirm verification";
const data = store({
isConfirming: false,
isConfirmSuccess: false,
// ---
isResending: false,
isResendSuccess: false,
});
confirm();
async function confirm() {
if (data.isConfirming) {
return;
}
data.isConfirming = true;
// init a custom client to avoid interfering with the superuser state
const client = new PocketBase(app.pb.baseURL);
try {
await client.collection(tokenPayload.collectionId).confirmVerification(token);
data.isConfirmSuccess = true;
} catch (err) {
data.isConfirmSuccess = false;
}
data.isConfirming = false;
}
async function resend() {
if (data.isResending) {
return;
}
data.isResending = true;
// init a custom client to avoid interfering with the superuser state
const client = new PocketBase(import.meta.env.PB_BACKEND_URL);
try {
await client.collection(tokenPayload.collectionId).requestVerification(tokenPayload.email);
data.isResendSuccess = true;
} catch (err) {
app.checkApiError(err);
data.isResendSuccess = false;
}
data.isResending = false;
}
return t.div(
{
pbEvent: "pageConfirmVerification",
className: "wrapper sm m-auto p-b-base",
},
t.header(
{ className: "txt-center m-b-base" },
t.img({ className: "main-logo", src: () => app.store.mainLogo, ariaHidden: true, alt: "App logo" }),
t.h5({ className: "m-t-10" }, () => app.store.title),
),
() => {
if (data.isConfirming) {
return t.div({ className: "block txt-center" }, t.span({ className: "loader" }, "Please wait..."));
}
if (data.isConfirmSuccess) {
return t.div(
{ pbEvent: "confirmVerificationSuccessAlert", className: "alert success txt-center" },
t.p(null, "Successfully verified ", t.strong(null, tokenPayload.email), "."),
);
}
if (data.isResendSuccess) {
return t.div(
{ pbEvent: "confirmVerificationResendAlert", className: "alert success txt-center" },
t.p(null, "Please check your email for the new verification link."),
);
}
return [
t.div(
{ pbEvent: "confirmVerificationErrorAlert", className: "alert danger txt-center m-b-base" },
t.p(null, "Invalid or expired verification token."),
),
t.button(
{
type: "button",
className: () => `btn transparent lg block ${data.isResending ? "loading" : ""}`,
disabled: () => data.isResending,
onclick: () => resend(),
},
t.span({ className: "txt" }, "Resend"),
),
];
},
);
}

View File

@@ -0,0 +1,266 @@
import { getTokenPayload, isTokenExpired } from "pocketbase";
export function pageInstaller(route) {
const token = route.params?.token || "";
const tokenPayload = getTokenPayload(token);
if (tokenPayload.type != "auth" || isTokenExpired(token)) {
app.toasts.error("The installer token is invalid or has expired.");
window.location.hash = "#/";
return;
}
app.store.title = "Setup your PocketBase instance";
const data = store({
email: "",
password: "",
passwordConfirm: "",
showPassword: false,
showPasswordConfirm: false,
isSubmitting: false,
isUploading: false,
get isBusy() {
return data.isSubmitting || data.isUploading;
},
});
async function submit() {
if (data.isBusy) {
return;
}
data.isSubmitting = true;
try {
await app.pb.collection("_superusers").create(
{
email: data.email,
password: data.password,
passwordConfirm: data.passwordConfirm,
},
{
headers: { Authorization: token },
},
);
await app.pb.collection("_superusers").authWithPassword(data.email, data.password);
window.location.hash = "#/";
} catch (err) {
app.checkApiError(err);
}
data.isSubmitting = false;
}
const fileInputId = "backupFileInput";
function resetSelectedBackupFile() {
const input = document.getElementById(fileInputId);
if (input) {
input.value = "";
}
}
function uploadBackupConfirm(file) {
if (!file) {
return;
}
app.modals.confirm(
t.h6(
null,
`Note that we don't perform validations for the uploaded backup files. Proceed with caution and only if you trust the file source.\n\n`
+ `Do you really want to upload and initialize "${file.name}"?`,
),
() => {
uploadBackup(file);
},
() => {
resetSelectedBackupFile();
},
);
}
async function uploadBackup(file) {
if (!file || data.isBusy) {
return;
}
data.isUploading = true;
try {
await app.pb.backups.upload(
{ file: file },
{
headers: { Authorization: token },
},
);
await app.pb.backups.restore(file.name, {
headers: { Authorization: token },
});
app.toasts.info("Please wait while extracting the uploaded archive!");
// optimistic restore completion
await new Promise((r) => setTimeout(r, 3000));
window.location.href = "#/";
} catch (err) {
app.checkApiError(err);
}
resetSelectedBackupFile();
data.isUploading = false;
}
return t.div(
{
pbEvent: "pageInstaller",
className: "wrapper sm m-auto p-b-base",
},
t.header(
{ className: "txt-center m-b-base" },
t.img({ className: "main-logo", src: () => app.store.mainLogo, ariaHidden: true, alt: "App logo" }),
t.h5({ className: "m-t-10" }, () => app.store.title),
),
t.form(
{
pbEvent: "installerForm",
className: "grid installer-form",
onsubmit: (e) => {
e.preventDefault();
submit(data);
},
},
t.div({ className: "col-12 txt-center" }, "Create your first superuser account in order to continue:"),
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.label({ htmlFor: "superuser_email" }, "Email"),
t.input({
id: "superuser_email",
name: "email",
type: "email",
required: true,
autofocus: true,
autocomplete: "off",
disabled: () => data.isBusy,
value: () => data.email,
oninput: (e) => (data.email = e.target.value),
}),
),
),
t.div(
{ className: "col-12" },
t.div(
{ className: "fields" },
t.div(
{ className: "field" },
t.label({ htmlFor: "superuser_password" }, "Password"),
t.input({
id: "superuser_password",
name: "password",
min: 10,
required: true,
disabled: () => data.isBusy,
type: () => (data.showPassword ? "text" : "password"),
value: () => data.password,
oninput: (e) => (data.password = e.target.value),
}),
),
t.div(
{ className: "field addon" },
t.button(
{
type: "button",
tabIndex: -1,
className: "btn sm transparent secondary circle tooltip-right",
ariaDescription: app.attrs.tooltip(() =>
data.showPassword ? "Hide password" : "Show password"
),
onclick: () => (data.showPassword = !data.showPassword),
},
t.i({
className: () => (data.showPassword ? "ri-eye-off-line" : "ri-eye-line"),
}),
),
),
),
t.div({ className: "field-help" }, "Recommended at least 10 characters."),
),
t.div(
{ className: "col-12" },
t.div(
{ className: "fields" },
t.div(
{ className: "field" },
t.label({ htmlFor: "superuser_password_confirm" }, "Password confirm"),
t.input({
id: "superuser_password_confirm",
name: "passwordConfirm",
required: true,
disabled: () => data.isBusy,
type: () => (data.showPasswordConfirm ? "text" : "password"),
value: () => data.passwordConfirm,
oninput: (e) => (data.passwordConfirm = e.target.value),
}),
),
t.div(
{ className: "field addon" },
t.button(
{
type: "button",
tabIndex: -1,
className: "btn sm transparent secondary circle tooltip-right",
ariaDescription: app.attrs.tooltip(() =>
data.showPasswordConfirm ? "Hide password" : "Show password"
),
onclick: () => (data.showPasswordConfirm = !data.showPasswordConfirm),
},
t.i({
className: () => (data.showPasswordConfirm ? "ri-eye-off-line" : "ri-eye-line"),
}),
),
),
),
),
t.div(
{ className: "col-12" },
t.button(
{
className: () => `btn lg next block ${data.isSubmitting ? "loading" : ""}`,
disabled: () => data.isBusy,
},
t.span({ className: "txt" }, "Create superuser and login"),
t.i({ className: "ri-arrow-right-line" }),
),
),
),
t.hr(),
t.label(
{
htmlFor: fileInputId,
className: () =>
`btn secondary transparent lg block ${data.isBusy ? "disabled" : ""} ${
data.isUploading ? "loading" : ""
}`,
},
t.i({ className: "ri-upload-cloud-line" }),
t.span({ className: "txt" }, "Or initialize from backup"),
),
t.input({
id: fileInputId,
type: "file",
className: "hidden",
accept: ".zip",
onchange: (e) => {
uploadBackupConfirm(e.target?.files?.[0]);
},
}),
);
}

View File

@@ -0,0 +1,20 @@
export function pageOAuth2RedirectFailure(route) {
app.store.title = "OAuth2 auth failed";
window.close();
return t.div(
{ pbEvent: "pageOAuth2RedirectFailure", className: "page" },
t.div(
{ className: "page-content" },
t.header(
{ className: "txt-center p-base" },
t.h3({ className: "primary-heading m-b-sm" }, "Auth failed."),
t.h6(
{ className: "secondary-heading" },
"You can close this window and go back to the app to try again.",
),
),
),
);
}

View File

@@ -0,0 +1,17 @@
export function pageOAuth2RedirectSuccess(route) {
app.store.title = "OAuth2 auth completed";
window.close();
return t.div(
{ pbEvent: "pageOAuth2RedirectSuccess", className: "page" },
t.div(
{ className: "page-content" },
t.header(
{ className: "txt-center p-base" },
t.h3({ className: "primary-heading m-b-sm" }, "Auth completed."),
t.h6({ className: "secondary-heading" }, "You can close this window and go back to the app."),
),
),
);
}

View File

@@ -0,0 +1,92 @@
export function pageRequestSuperuserPasswordReset(route) {
app.store.title = "Forgotten superuser password";
const data = store({
email: "",
isSubmitting: false,
success: false,
});
async function submit() {
if (data.isSubmitting) {
return;
}
data.isSubmitting = true;
try {
await app.pb.collection("_superusers").requestPasswordReset(data.email);
data.success = true;
} catch (err) {
app.checkApiError(err);
}
data.isSubmitting = false;
}
return t.div(
{
pbEvent: "pageSuperuserPasswordReset",
className: "wrapper sm m-auto p-b-base",
},
t.header(
{ className: "txt-center m-b-base" },
t.img({ className: "main-logo", src: () => app.store.mainLogo, ariaHidden: true, alt: "App logo" }),
t.h5({ className: "m-t-10" }, () => app.store.title),
),
() => {
if (data.success) {
return t.div(
{ pbEvent: "superuserPasswordResetAlert", className: "alert success txt-center" },
t.p(null, "Check ", t.strong(null, data.email), " for the recovery link!"),
);
}
return t.form(
{
pbEvent: "superuserPasswordResetForm",
className: "grid request-password-reset-form",
onsubmit: (e) => {
e.preventDefault();
submit();
},
},
t.div(
{ className: "col-12" },
t.div(
{ className: "content txt-center m-b-sm" },
t.p(null, "Enter the email associated with your account and we'll send you a recovery link:"),
),
t.div(
{ className: "field" },
t.label({ htmlFor: "password_reset_email" }, "Email"),
t.input({
id: "password_reset_email",
name: "email",
type: "email",
required: true,
autofocus: true,
value: () => data.email,
oninput: (e) => (data.email = e.target.value),
}),
),
),
t.div(
{ className: "col-12" },
t.button(
{
className: () => `btn lg block ${data.isSubmitting ? "loading" : ""}`,
disabled: () => data.isSubmitting,
},
t.i({ className: "ri-mail-send-line" }),
t.span({ className: "txt" }, "Send recovery link"),
),
),
);
},
t.div(
{ className: "block m-t-sm txt-center" },
t.a({ href: "#/login", className: "link-hint" }, "Back to login"),
),
);
}

View File

@@ -0,0 +1,420 @@
export function pageSuperuserLogin(route) {
app.store.title = "Superuser login";
const data = store({
authMethods: {},
identity: route.query.demoEmail?.[0] || "",
password: route.query.demoPassword?.[0] || "",
showPassword: false,
otpId: "",
lastOTPId: "",
otpEmail: "",
otpPassword: "",
mfaId: "",
totalSteps: 1,
get currentStep() {
return 1 + !!data.mfaId + !!data.otpId;
},
isAuthMethodsLoading: true,
isPasswordAuthSubmitting: false,
isOTPRequestSubmitting: false,
isOTPAuthSubmitting: false,
});
async function loadAuthMethods() {
data.isAuthMethodsLoading = true;
try {
data.authMethods = await app.pb.collection("_superusers").listAuthMethods();
data.totalSteps = 1;
data.mfaId = "";
data.otpId = "";
if (data.authMethods.mfa?.enabled && data.authMethods.otp?.enabled) {
data.totalSteps += 2; // otp request + auth
}
} catch (err) {
app.checkApiError(err);
}
data.isAuthMethodsLoading = false;
}
loadAuthMethods();
return t.div(
{
pbEvent: "pageSuperuserLogin",
className: "wrapper sm m-auto p-b-base",
},
t.header(
{ className: "txt-center m-b-base" },
t.img({ className: "main-logo", src: () => app.store.mainLogo, ariaHidden: true, alt: "App logo" }),
t.h5(
{ className: "m-t-10" },
t.span(null, () => app.store.title),
() => {
if (data.totalSteps > 1) {
return t.span(null, () => ` (${data.currentStep}/${data.totalSteps})`);
}
},
),
),
() => {
if (data.isAuthMethodsLoading) {
return t.div({ className: "block txt-center" }, t.span({ className: "loader lg" }));
}
if (data.authMethods.password?.enabled && !data.mfaId) {
return authWithPasswordForm(data);
}
if (data.authMethods.otp?.enabled) {
if (!data.otpId) {
return requestOTPForm(data);
}
return authWithOTPForm(data);
}
},
);
}
// Auth with password
// -------------------------------------------------------------------
async function authWithPassword(data) {
if (data.isPasswordAuthSubmitting) {
return;
}
data.isPasswordAuthSubmitting = true;
try {
await app.pb.collection("_superusers").authWithPassword(data.identity, data.password);
app.toasts.removeAll();
app.store.errors = null;
app.utils.toRememberedPath();
} catch (err) {
if (err.status == 401) {
data.mfaId = err.response.mfaId;
// show the otp forms
if (
// if the identity field is just the email use it to directly send an otp request
data.authMethods?.password?.identityFields?.length == 1
&& data.authMethods.password.identityFields[0] == "email"
) {
// prefill and request
data.otpEmail = data.identity;
await requestOTP(data);
} else if (/^[^@\s]+@[^@\s]+$/.test(data.identity)) {
// only prefill
data.otpEmail = data.identity;
}
} else if (err.status != 400) {
app.checkApiError(err);
} else {
app.toasts.error("Invalid login credentials.");
}
}
data.isPasswordAuthSubmitting = false;
}
function authWithPasswordForm(data) {
return t.form(
{
pbEvent: "authWithPasswordForm",
className: "grid auth-with-password-form",
onsubmit: (e) => {
e.preventDefault();
authWithPassword(data);
},
},
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.label(
{ htmlFor: "login_identity" },
() => app.utils.sentenize(data.authMethods.password.identityFields.join(" or "), false),
),
t.input({
id: "login_identity",
name: "identity",
type: () => {
if (
data.authMethods.password.identityFields.length == 1
&& data.authMethods.password.identityFields[0] == "email"
) {
return "email";
}
return "text";
},
required: true,
autofocus: true,
value: () => data.identity,
oninput: (e) => (data.identity = e.target.value),
}),
),
),
t.div(
{ className: "col-12" },
t.div(
{ className: "fields" },
t.div(
{ className: "field" },
t.label({ htmlFor: "login_pass" }, "Password"),
t.input({
id: "login_pass",
name: "password",
required: true,
type: () => (data.showPassword ? "text" : "password"),
value: () => data.password,
oninput: (e) => (data.password = e.target.value),
}),
),
t.div(
{ className: "field addon" },
t.button(
{
type: "button",
tabIndex: -1,
className: "btn sm transparent secondary circle tooltip-right",
ariaDescription: app.attrs.tooltip(() =>
data.showPassword ? "Hide password" : "Show password"
),
onclick: () => (data.showPassword = !data.showPassword),
},
t.i({
className: () => (data.showPassword ? "ri-eye-off-line" : "ri-eye-line"),
}),
),
),
),
t.a(
{
href: "#/request-password-reset",
className: "link-hint m-t-5",
onclick: (e) => e.stopPropagation(),
},
t.small(null, "Forgotten password"),
),
),
t.div(
{ className: "col-12" },
t.button(
{
className: () => `btn lg block next ${data.isPasswordAuthSubmitting ? "loading" : ""}`,
disabled: () => data.isPasswordAuthSubmitting,
},
t.span({ className: "txt" }, () => (data.totalSteps > 1 ? "Next" : "Login")),
t.i({ className: "ri-arrow-right-line" }),
),
),
);
}
// Request OTP
// -------------------------------------------------------------------
async function requestOTP(data) {
if (data.isOTPRequestSubmitting) {
return;
}
data.isOTPRequestSubmitting = true;
try {
const result = await app.pb.collection("_superusers").requestOTP(data.otpEmail);
data.otpId = result.otpId;
data.lastOTPId = data.otpId;
app.toasts.removeAll();
app.store.errors = null;
} catch (err) {
// reset the form
if (err.status == 429) {
data.otpId = data.lastOTPId;
}
app.checkApiError(err);
}
data.isOTPRequestSubmitting = false;
}
function requestOTPForm(data) {
return t.form(
{
pbEvent: "requestOTPForm",
className: "grid request-otp-form",
onsubmit: (e) => {
e.preventDefault();
requestOTP(data);
},
},
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.label({ htmlFor: "otp_email" }, "Email"),
t.input({
id: "otp_email",
name: "email",
type: "email",
required: true,
autofocus: true,
value: () => data.otpEmail,
oninput: (e) => (data.otpEmail = e.target.value),
}),
),
),
t.div(
{ className: "col-12" },
t.button(
{
className: () => `btn lg block ${data.isOTPRequestSubmitting ? "loading" : ""}`,
disabled: () => data.isOTPRequestSubmitting,
},
t.i({ className: "ri-mail-send-line" }),
t.span({ className: "txt" }, "Send OTP"),
),
),
);
}
// Auth with OTP
// -------------------------------------------------------------------
async function authWithOTP(data) {
if (data.isOTPAuthSubmitting) {
return;
}
data.isOTPAuthSubmitting = true;
try {
await app.pb.collection("_superusers").authWithOTP(data.otpId || data.lastOTPId, data.otpPassword, {
mfaId: data.mfaId,
});
app.toasts.removeAll();
app.store.errors = null;
app.utils.toRememberedPath();
} catch (err) {
app.checkApiError(err);
}
data.isOTPAuthSubmitting = false;
}
function authWithOTPForm(data) {
return t.form(
{
pbEvent: "authWithOTPForm",
className: "grid auht-with-otp-form",
onsubmit: (e) => {
e.preventDefault();
authWithOTP(data);
},
},
() => {
if (data.otpEmail) {
return t.div(
{ className: "col-12" },
t.div(
{ className: "content txt-center" },
"Check your ",
t.strong(null, data.otpEmail),
" inbox and enter below the received One-time password (OTP).",
),
);
}
},
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.label({ htmlFor: "otp_id" }, "Id"),
t.input({
id: "otp_id",
name: "otpId",
type: "text",
required: true,
placeholder: () => data.lastOTPId,
value: () => data.otpId,
onchange: (e) => {
data.otpId = e.target.value || data.lastOTPId;
e.target.value = data.otpId;
},
}),
),
),
t.div(
{ className: "col-12" },
t.div(
{ className: "fields" },
t.div(
{ className: "field" },
t.label({ htmlFor: "otp_password" }, "One-time password"),
t.input({
id: "otp_password",
name: "password",
required: true,
type: () => (data.showPassword ? "text" : "password"),
value: () => data.otpPassword,
oninput: (e) => (data.otpPassword = e.target.value),
}),
),
t.div(
{ className: "field addon" },
t.button(
{
type: "button",
tabIndex: -1,
className: "btn sm transparent secondary circle tooltip-right",
ariaDescription: app.attrs.tooltip(() =>
data.showPassword ? "Hide password" : "Show password"
),
onclick: () => (data.showPassword = !data.showPassword),
},
t.i({
className: () => (data.showPassword ? "ri-eye-off-line" : "ri-eye-line"),
}),
),
),
),
),
t.div(
{ className: "col-12" },
t.button(
{
className: () => `btn lg block next ${data.isOTPAuthSubmitting ? "loading" : ""}`,
disabled: () => data.isOTPAuthSubmitting,
},
t.span({ className: "txt" }, "Login"),
t.i({ className: "ri-arrow-right-line" }),
),
t.div(
{ className: "block m-t-sm txt-center" },
t.button(
{
type: "button",
className: "link-hint txt-sm",
disabled: () => data.isOTPAuthSubmitting,
onclick: () => {
data.otpId = "";
data.otpPassword = "";
},
},
"Request another OTP",
),
),
),
);
}

View File

@@ -1,39 +0,0 @@
import CommonHelper from "@/utils/CommonHelper";
const maxKeys = 11000;
onmessage = (e) => {
if (!e.data.collections) {
return;
}
const result = {};
result.baseKeys = CommonHelper.getCollectionAutocompleteKeys(e.data.collections, e.data.baseCollection?.name);
result.baseKeys = limitArray(result.baseKeys.sort(keysSort), maxKeys);
if (!e.data.disableRequestKeys) {
result.requestKeys = CommonHelper.getRequestAutocompleteKeys(e.data.collections, e.data.baseCollection?.name);
result.requestKeys = limitArray(result.requestKeys.sort(keysSort), maxKeys);
}
if (!e.data.disableCollectionJoinKeys) {
result.collectionJoinKeys = CommonHelper.getCollectionJoinAutocompleteKeys(e.data.collections);
result.collectionJoinKeys = limitArray(result.collectionJoinKeys.sort(keysSort), maxKeys);
}
postMessage(result);
};
// sort shorter keys first
function keysSort(a, b) {
return a.length - b.length;
}
function limitArray(arr, max) {
if (arr.length > max) {
return arr.slice(0, max);
}
return arr;
}

148
ui/src/base/appHeader.js Normal file
View File

@@ -0,0 +1,148 @@
export function appHeader() {
return () => {
if (!app.store._ready || !app.store.showHeader || !app.store.superuser?.id) {
return;
}
return t.header(
{
pbEvent: "appHeader",
rid: "appHeader",
className: "app-header accent-surface",
onmount: async (el) => {
await new Promise((r) => setTimeout(r, 0));
el._scrollToActiveMenuItem = function() {
el?.querySelector(".app-main-nav .header-link.active")?.scrollIntoView();
};
el._scrollToActiveMenuItem();
window.addEventListener("hashchange", el._scrollToActiveMenuItem);
},
onunmount: (el) => {
window.removeEventListener("hashchange", el?._scrollToActiveMenuItem);
},
},
t.a(
{ href: "#/", className: "logo" },
t.img({ src: () => app.store.headerLogo, alt: "App logo" }),
),
t.nav(
{
pbEvent: "mainNav",
className: "app-main-nav",
},
() => {
return app.store.headerLinks.map((link) => {
const isLocal = link.href.startsWith("#/");
return t.a(
{
href: () => link.href,
target: () => !isLocal ? "_blank" : undefined,
rel: () => !isLocal ? "noopener noreferrer" : undefined,
className: (el) => {
const isActive = link.isActive?.(el) || app.utils.isActivePath(link.href);
return `header-link ${isActive ? "active" : ""}`;
},
},
() => {
if (link.icon) {
return t.i({ className: link.icon });
}
},
t.span({ className: "txt" }, () => link.label),
);
});
},
),
t.div({ className: "flex-fill app-header-separator" }),
colorSchemeButton(),
t.button(
{
className: "header-link logged-user txt-normal",
"html-popovertarget": "logged-user-dropdown",
},
t.span({ className: "superuser-name txt-ellipsis" }, () => app.store.superuser?.email),
t.i({ className: "ri-arrow-drop-down-line" }),
),
t.div(
{
pbEvent: "loggedUserDropdown",
id: "logged-user-dropdown",
className: "dropdown sm nowrap logged-user-dropdown",
popover: "auto",
},
t.a(
{
className: "dropdown-item dropdown-item-manage",
href: "#/collections?collection=_superusers",
onclick: (e) => {
e.target.closest(".dropdown").hidePopover();
},
},
t.i({ className: "ri-group-line", ariaHidden: true }),
t.span({ className: "txt" }, "Manage superusers"),
),
t.hr(),
t.button(
{
type: "button",
className: "dropdown-item txt-danger dropdown-item-logout",
onclick: () => app.pb.authStore.clear(),
},
t.i({ className: "ri-logout-circle-line", ariaHidden: true }),
t.span({ className: "txt" }, "Logout"),
),
),
);
};
}
function colorSchemeButton() {
const options = [
{ value: "light", icon: "ri-sun-line", label: "Light" },
{ value: "dark", icon: "ri-moon-line", label: "Dark" },
{ value: "", icon: "ri-subtract-line", label: "Auto" },
];
return [
t.button(
{
className: "header-link color-scheme-picker",
"html-popovertarget": "color-scheme-dropdown",
title: "Color scheme",
},
t.i({
className: () => app.store.activeColorScheme == "dark" ? "ri-moon-line" : "ri-sun-line",
ariaHidden: true,
}),
),
t.div(
{
pbEvent: "colorSchemeDropdown",
id: "color-scheme-dropdown",
className: "dropdown sm nowrap color-scheme-dropdown",
popover: "auto",
},
() => {
return options.map((opt) => {
return t.button(
{
type: "button",
className: () =>
`dropdown-item dropdown-item-light ${
app.store.userColorScheme == opt.value ? "active" : ""
}`,
onclick: (e) => {
e.target.closest(".dropdown").hidePopover();
app.store.userColorScheme = opt.value;
},
},
t.i({ className: opt.icon, ariaHidden: true }),
t.span({ className: "txt" }, opt.label),
);
});
},
),
];
}

View File

@@ -0,0 +1,14 @@
// auto open accordions if there is an invalid item
document.addEventListener(
"invalid",
(e) => {
const details = e.target.closest("details");
if (details && !details.open && !e.target.closest("summary")) {
details.open = true;
// revalidate and show the error message
e.target.reportValidity && e.target.reportValidity();
}
},
true,
);

78
ui/src/base/codeBlock.js Normal file
View File

@@ -0,0 +1,78 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Readonly code highlight component.
*
* @example
* ```js
* app.components.codeBlock({
* value: () => data.myCode,
* language: "html",
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.codeBlock = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
language: "js", // see Prism.languages
value: undefined,
footnote: undefined,
});
const watchers = app.utils.extendStore(props, propsArg);
return t.div(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => `code-wrapper ${props.className}`,
tabIndex: -1,
onmount: (el) => {
el.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key == "a" || e.key == "A")) {
e.preventDefault();
window.getSelection().selectAllChildren(el);
}
});
},
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
t.code({
className: "block",
innerHTML: () => highlight(props.value, props.language),
}),
t.div({ className: "footnote" }, (el) => {
if (typeof props.footnote == "function") {
return props.footnote(el);
}
return props.footnote;
}),
);
};
function highlight(content, language) {
content = typeof content == "string" ? content : "";
// @see https://prismjs.com/plugins/normalize-whitespace
content = Prism.plugins.NormalizeWhitespace.normalize(content, {
"remove-trailing": true,
"remove-indent": true,
"left-trim": true,
"right-trim": true,
});
return Prism.highlight(content, Prism.languages[language] || Prism.languages.js, language);
}

View File

@@ -0,0 +1,96 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Code highlighted tabs component.
*
* @example
* ```js
* app.components.codeBlockTabs({
* tabs: [
* {
* title: "Tab 1",
* language: "js",
* value: "console.log(123)","
* // other codeBlock props...
* },
* ...
* ],
* historyKey: "myTabs"
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.codeBlockTabs = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
activeTabIndex: 0,
historyKey: "",
tabs: [], // {title, ...codeBlockProps}
get activeTab() {
return props.tabs[props.activeTabIndex] || props.tabs[0];
},
});
const watchers = app.utils.extendStore(props, propsArg);
watchers.push(
watch(() => props.activeTabIndex, (newIndex, oldIndex) => {
if (oldIndex != undefined && props.historyKey) {
localStorage.setItem(props.historyKey, newIndex);
}
}),
);
return t.div(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden || !props.tabs.length,
inert: () => props.inert,
className: () => `code-block-tabs ${props.className}`,
onmount: () => {
if (props.historyKey) {
props.activeTabIndex = localStorage.getItem(props.historyKey) << 0;
}
},
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
t.header(
{ className: "tabs-header" },
() => {
return props.tabs.map((tab, i) => {
return t.button(
{
type: "button",
className: () => `tab-item ${props.activeTabIndex == i ? "active" : ""}`,
onclick: () => props.activeTabIndex = i,
},
(el) => {
if (typeof tab.title == "function") {
return tab.title(el);
}
return tab.title;
},
);
});
},
),
t.div(
{ className: "code-block-tabs-content" },
() => {
if (props.activeTab) {
return app.components.codeBlock(props.activeTab);
}
},
),
);
};

507
ui/src/base/codeEditor.js Normal file
View File

@@ -0,0 +1,507 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Basic code editor element with syntax highlight support.
* For static code visualization use `app.components.codeBlock({ ... })`.
*
* @example
* ```js
* app.components.codeEditor({
* language: "html",
* value: () => data.myCode, // data is some store() instance
* singleLine: true,
* placeholder: "Type your html here...",
* oninput: (val) => {
* data.myCode = val
* },
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.codeEditor = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
name: undefined,
className: "",
value: "",
language: "js", // see Prism.languages
placeholder: "",
disabled: false,
required: false,
singleLine: false,
// [
// "value",
// {value, label},
// ]
autocomplete: undefined, // Array<string|Object> | function(word): Array<string|Object>,
// ---
oninput: function(val, e) {},
onfocus: function(val, e) {},
onblur: function(val, e) {},
});
const extendWatchers = app.utils.extendStore(props, propsArg, "autocomplete");
let dropdown;
let visibilityObserver;
let isFieldVisible = true;
function openAutocompleteDropdown(items) {
closeAutocompleteDropdown();
dropdown = t.div(
{
className: "dropdown autocomplete code-editor-dropdown",
onmount: (el) => {
el._updatePosition = () => {
if (!isFieldVisible) {
closeAutocompleteDropdown();
} else {
updateDropdownPosition(dropdown);
}
};
el._closeOnEsc = (e) => {
if (e.key == "Escape") {
e.preventDefault();
closeAutocompleteDropdown();
}
};
window.addEventListener("scroll", el._updatePosition, true);
window.addEventListener("resize", el._updatePosition);
window.addEventListener("keydown", el._closeOnEsc);
el._updatePosition();
},
onunmount: (el) => {
if (el) {
window.removeEventListener("scroll", el._updatePosition, true);
window.removeEventListener("resize", el._updatePosition);
window.removeEventListener("keydown", el._closeOnEsc);
}
},
},
items,
);
document.body.appendChild(dropdown);
// track editor field visibility to hide the dropdown when
// not in the view port to avoid overflow issues
if (editorContent) {
visibilityObserver?.disconnect();
visibilityObserver = new IntersectionObserver(
([entry]) => {
isFieldVisible = entry.isIntersecting;
},
{
root: null,
threshold: 0.1,
},
);
visibilityObserver.observe(editorContent);
}
}
function closeAutocompleteDropdown() {
if (dropdown) {
dropdown.remove();
dropdown = null;
}
if (visibilityObserver) {
visibilityObserver.disconnect();
visibilityObserver = null;
}
isFieldVisible = true;
}
let isCtrlOrCmdKey = false;
let valueWatcher;
// note1: use contenteditable so that we can call getBoundingClientRect on the selected text
// note2: getSelection also doesn't seem to work in Firefox for textarea and inputs
const editorContent = t.div({
contentEditable: () => (props.disabled ? false : "plaintext-only"),
tabIndex: 0,
spellcheck: false,
autocorrect: false,
autocomplete: "off",
autocapitalize: "off",
role: "textbox",
className: "editor-content",
"html-data-placeholder": () => props.placeholder,
onmount: (el) => {
// auto change change textContent only if it props.value was
// changed externally to preserve the focus and caret position
valueWatcher?.unwatch();
valueWatcher = watch(
() => props.value,
(value) => {
if (value != editorContent.textContent) {
editorContent.textContent = value;
closeAutocompleteDropdown();
}
},
);
},
onunmount: (el) => {
valueWatcher?.unwatch();
closeAutocompleteDropdown();
},
onfocus: (e) => {
props.onfocus?.(props.value, e);
},
onblur: (e) => {
// not blurred because of dropdown click
if (dropdown && !dropdown.contains(e.relatedTarget)) {
closeAutocompleteDropdown();
}
props.onblur?.(props.value, e);
},
oninput: (e) => {
closeAutocompleteDropdown();
props.value = editorContent.textContent;
props.oninput?.(props.value, e);
editorContent.dispatchEvent(new CustomEvent("change", { detail: props.value }));
if (!props.value?.length) {
editorContent.textContent = ""; // ensure that no comments, br, etc. tags are left
return;
}
if (!editorContent?.isConnected) {
return;
}
const pos = getCaretPos(editorContent);
const match = getWord(props.value, pos);
if (
!match.word.length
// don't show suggestions in case the cursor is at the
// beginning of an already typed word
|| pos == match.start
) {
return;
}
let suggestions = [];
if (typeof props.autocomplete == "function") {
suggestions = props.autocomplete(match.word) || [];
} else if (!app.utils.isEmpty(props.autocomplete)) {
const wordLowercased = match.word.toLowerCase();
suggestions = props.autocomplete.filter((item) => {
if (typeof item == "object") {
item = item?.value;
}
item = item?.toLowerCase();
return item && item != wordLowercased && item.includes(wordLowercased);
});
}
if (!suggestions?.length) {
return;
}
openAutocompleteDropdown(() => {
return suggestions.map((suggestion, i) => {
return t.button({
type: "button",
className: `dropdown-item ${i == 0 ? "active" : ""}`,
textContent: suggestion.label || suggestion.value || suggestion,
onclick: (e) => {
e.preventDefault();
editorContent.focus();
const word = suggestion.value || suggestion;
// note: replacing the text doesn't preserve the native "undo" history
// (document.execCommand is being deprecated)
editorContent.textContent = editorContent.textContent.substring(0, match.start)
+ word
+ editorContent.textContent.substring(match.end + 1);
props.value = editorContent.textContent;
try {
window
.getSelection()
.setPosition(editorContent.childNodes[0], match.start + word.length);
} catch (err) {
console.warn("failed to set caret position", err);
}
closeAutocompleteDropdown();
},
});
});
});
},
onkeydown: (e) => {
isCtrlOrCmdKey = e.ctrlKey || e.metaKey;
// autocomplete nav
// -------------------------------------------------------
if ((e.key == "Enter" || e.key == "Tab") && dropdown?.isConnected) {
e.preventDefault();
dropdown.querySelector(".dropdown-item.active")?.click();
return;
}
if (e.key == "ArrowUp" && dropdown?.isConnected) {
e.preventDefault();
const currentActive = dropdown.querySelector(".dropdown-item.active");
if (currentActive?.previousElementSibling) {
currentActive.classList.remove("active");
currentActive.previousElementSibling.classList.add("active");
currentActive.previousElementSibling.scrollIntoView(false);
}
return;
}
if (e.key == "ArrowDown" && dropdown?.isConnected) {
e.preventDefault();
const currentActive = dropdown.querySelector(".dropdown-item.active");
if (currentActive?.nextElementSibling) {
currentActive.classList.remove("active");
currentActive.nextElementSibling.classList.add("active");
currentActive.nextElementSibling.scrollIntoView(false);
}
return;
}
// editor shortcuts
// -------------------------------------------------------
if (isCtrlOrCmdKey && e.key.toLowerCase() == "l") {
e.preventDefault();
selectLine(editorContent);
return;
}
if (isCtrlOrCmdKey && e.key.toLowerCase() == "d") {
e.preventDefault();
selectWord(editorContent);
return;
}
if (!props.singleLine && e.key == "Tab") {
e.preventDefault();
const selection = window.getSelection();
if (!selection) {
return;
}
// -1 tab level
if (e.shiftKey) {
selection.modify("extend", "backward", "character");
if (selection.toString()[0] == "\t") {
selection.deleteFromDocument();
props.value = editorContent.textContent;
} else {
// check ahead and restore
selection.modify("extend", "forward", "character");
if (selection.toString()[0] == "\t") {
selection.deleteFromDocument();
props.value = editorContent.textContent;
}
}
return;
}
// +1 tab level
const range = selection.getRangeAt(0);
if (range) {
range.deleteContents();
range.insertNode(document.createTextNode("\t"));
range.collapse();
props.value = editorContent.textContent;
}
return;
}
// simulate single-line enter press
if (props.singleLine && e.key == "Enter") {
e.preventDefault();
hiddenSubmit.click();
return;
}
},
onscroll: () => {
closeAutocompleteDropdown();
if (highlightOverlay) {
highlightOverlay.scrollLeft = editorContent.scrollLeft;
highlightOverlay.scrollTop = editorContent.scrollTop;
}
},
});
const highlightOverlay = t.div({
className: "highlight-overlay",
innerHTML: () => highlight(props.value, props.language),
onscroll: () => {
if (editorContent) {
editorContent.scrollLeft = highlightOverlay.scrollLeft;
editorContent.scrollTop = highlightOverlay.scrollTop;
}
},
});
const hiddenSubmit = t.button({
type: "submit",
className: "hidden",
});
return t.div(
{
rid: props.rid,
id: () => props.id,
inert: () => props.inert,
hidden: () => props.hidden,
"html-name": () => props.name,
"html-required": () => props.required || undefined,
// dprint-ignore
className: () => `input code-editor ${props.className} ${props.disabled ? "disabled" : ""} ${props.singleLine ? "single-line" : ""}`,
onclick: () => {
editorContent?.focus();
},
onunmount: () => {
extendWatchers?.forEach((w) => w?.unwatch());
},
},
t.div({ className: "code-editor-container" }, editorContent, highlightOverlay, hiddenSubmit),
);
};
const highlightThreshold = 500;
function highlight(content, language) {
content = typeof content == "string" ? content : "";
if (!content) {
return "";
}
if (
!Prism.languages[language]
// fallback to plain to avoid performance issues with large text blocks
|| content.length > highlightThreshold
) {
language = "plain";
}
return Prism.highlight(content, Prism.languages[language], language);
}
const wordCharRegex = new RegExp(/[\p{Alphabetic}\p{Number}_@:\."'{}]/, "u");
function getWord(value, caretPos) {
let start = caretPos;
for (let i = caretPos - 1; i >= 0; i--) {
if (!wordCharRegex.test(value[i])) {
break;
}
start = i;
}
let end = start;
for (let i = caretPos - 1; i < value.length; i++) {
if (!wordCharRegex.test(value[i])) {
break;
}
end = i;
}
return {
word: value.substring(start, end + 1),
start: start,
end: end,
};
}
function selectLine() {
const selection = window.getSelection();
selection?.modify("move", "forward", "lineboundary");
selection?.modify("extend", "backward", "lineboundary");
}
function selectWord() {
const selection = window.getSelection();
selection?.modify("move", "forward", "word");
selection?.modify("extend", "backward", "word");
}
function getCaretPos(editorContent) {
const selection = window.getSelection();
// new line adds a new text node which resets the selection counter
// so we have to add them as offset
let offset = 0;
for (let node of editorContent.childNodes) {
if (node == selection.focusNode) {
break;
} else {
offset += node.length;
}
}
return offset + selection.focusOffset;
}
function updateDropdownPosition(dropdown) {
const targetRect = window.getSelection()?.getRangeAt(0)?.getBoundingClientRect();
if (!targetRect || !dropdown) {
return false;
}
if (targetRect.top < 0) {
dropdown.classList.add("hidden");
return;
}
dropdown.classList.remove("hidden");
// reset
dropdown.style.left = "0px";
dropdown.style.top = "0px";
const dropdownHeight = dropdown.offsetHeight;
const dropdownWidth = dropdown.offsetWidth;
let left = targetRect.left - 5;
let top = targetRect.top + targetRect.height;
// show on top if it cannot fit below the parent
if (top + dropdownHeight > document.documentElement.clientHeight) {
top = Math.max(targetRect.top - dropdownHeight, 0);
}
// align from the right edge if overflow
if (left + dropdownWidth > document.documentElement.clientWidth) {
left = Math.max(document.documentElement.clientWidth - dropdownWidth, 0);
}
dropdown.style.left = left + "px";
dropdown.style.top = top + "px";
}

142
ui/src/base/colorPicker.js Normal file
View File

@@ -0,0 +1,142 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Color picker component (with predefined colors support).
*
* @example
* ```js
* app.components.colorPicker({
* value: () => data.color,
* predefinedColors: ["#ff0000", "#123456"],
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.colorPicker = function(propsArg = {}) {
const uniqueId = "picker_" + app.utils.randomString();
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
name: "",
required: false,
disabled: false,
value: "",
predefinedColors: [],
// ---
onchange: (newColor) => {},
onmount: (el) => {},
onunmount: (el) => {},
});
const watchers = app.utils.extendStore(props, propsArg);
const local = store({
inputValue: "#ffffff",
});
function handleOnchange(val) {
val = val?.toLowerCase() || "";
props.onchange?.(val);
}
let inputTimeoutId;
let input = t.input({
type: "color",
className: "color-picker-input",
id: () => props.id,
name: () => props.name,
required: () => props.required,
disabled: () => props.disabled,
value: () => {
local.inputValue = props.value || "#ffffff";
return props.value || undefined;
},
oninput: (e) => {
local.inputValue = e.target.value;
clearTimeout(inputTimeoutId);
inputTimeoutId = setTimeout(() => {
handleOnchange(e.target.value);
}, 50);
},
});
return t.div(
{
rid: props.rid,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => `color-picker ${props.className}`,
onmount: (el) => {
props.onmount?.(el);
},
onunmount: (el) => {
clearTimeout(inputTimeoutId);
props.onunmount?.(el);
watchers.forEach((w) => w?.unwatch());
},
},
t.div(
{ className: "color-picker-input-wrapper" },
input,
t.output({
className: "result",
// black or white text (https://developer.chrome.com/blog/css-relative-color-syntax)
// @todo replace with contrast-color once there is better support?
style: () => `color: lch(from ${local.inputValue || "#ffffff"} calc((49 - l) * infinity) 0 0);`,
textContent: () => local.inputValue,
}),
),
t.button(
{
hidden: () => !props.predefinedColors.length,
type: "button",
title: "Predefined colors",
className: "link-hint predefined-colors-btn",
"html-popovertarget": uniqueId + "predefined-colors-dropdown",
},
t.i({ className: "ri-arrow-down-s-line", roleHidden: true }),
),
t.div(
{
pbEvent: "predefinedColorsDropdown",
id: uniqueId + "predefined-colors-dropdown",
className: "dropdown predefined-colors-dropdown",
popover: "auto",
},
t.div(
{
className: "predefined-colors-list",
},
() => {
return props.predefinedColors?.map((color) => {
return t.button({
type: "button",
className: () => `color ${props.value == color ? "active" : ""}`,
style: `background:${color}`,
onclick: (e) => {
if (!input) {
return;
}
e.target.closest(".dropdown")?.hidePopover();
input.value = color || undefined;
input.dispatchEvent(new Event("input", { bubbles: true }));
},
});
});
},
),
),
);
};

157
ui/src/base/confirm.js Normal file
View File

@@ -0,0 +1,157 @@
window.app = window.app || {};
window.app.modals = window.app.modals || {};
/**
* Opens a confirmation dialog and executes `yesCallback` or `noCallback`
* depending on the user's choice.
*
* The callbacks can return a `Promise`` that will be awaited before
* closing the confirmation modal.
*
* @example
* ```js
* app.modals.confirm("Are you sure?", () => console.log("confirmed"))
* ```
*
* @param {string|Element} textOrElem The confirmation message.
* @param {function} [yesCallback]
* @param {function} [noCallback]
* @param {Object} [settings]
*/
window.app.modals.confirm = function(textOrElem, yesCallback, noCallback, settings = {
className: undefined,
yesButton: "",
noButton: "",
}) {
data.textOrElem = textOrElem;
data.yesCallback = yesCallback;
data.yesCallbackWaiting = false;
data.noCallback = noCallback;
data.noCallbackWaiting = false;
data.className = typeof settings.className == "string" ? settings.className : "sm";
data.yesButton = settings.yesButton || "Yes";
data.noButton = settings.noButton || "No";
if (!confirmElem.isConnected) {
document.body.appendChild(confirmElem);
}
window.app.modals.open(confirmElem);
};
const data = store({
className: "",
textOrElem: null,
// ---
yesButton: "",
yesCallback: null,
yesCallbackWaiting: false,
// ---
noButton: "",
noCallback: null,
noCallbackWaiting: false,
// ---
get isBusy() {
return data.yesCallbackWaiting || data.noCallbackWaiting;
},
});
const confirmElem = t.div(
{ className: () => `modal popup manual ${data.className || ""}` },
t.div(
{ className: "modal-content" },
(el) => {
if (typeof data.textOrElem === "string") {
return t.h6({ className: "block txt-center" }, data.textOrElem);
}
if (typeof data.textOrElem === "function") {
return data.textOrElem(el);
}
return data.textOrElem;
},
),
t.footer(
{ className: "modal-footer p-sm" },
t.div(
{ className: "grid sm" },
t.div(
{ className: "col-sm-6" },
t.button(
{
type: "button",
className: () => `btn lg block secondary ${data.noCallbackWaiting ? "loading" : ""}`,
disabled: () => data.isBusy,
onclick: async () => {
if (data.noCallback) {
data.noCallbackWaiting = true;
try {
const result = await data.noCallback();
if (result === false) {
return;
}
} catch (err) {
console.log("confirm noCallback error:", err);
} finally {
data.noCallbackWaiting = false;
}
}
window.app.modals.close(confirmElem);
},
},
() => {
if (typeof data.noButton === "string") {
return t.span({ className: "txt" }, data.noButton);
}
if (typeof data.noButton === "function") {
return data.noButton(el);
}
return data.noButton;
},
),
),
t.div(
{ className: "col-sm-6" },
t.button(
{
type: "button",
className: () => `btn lg block warning ${data.yesCallbackWaiting ? "loading" : ""}`,
disabled: () => data.isBusy,
onclick: async () => {
if (data.yesCallback) {
data.yesCallbackWaiting = true;
try {
const result = await data.yesCallback();
if (result === false) {
return;
}
} catch (err) {
console.log("confirm yesCallback error:", err);
} finally {
data.yesCallbackWaiting = false;
}
}
window.app.modals.close(confirmElem);
},
},
() => {
if (typeof data.yesButton === "string") {
return t.span({ className: "txt" }, data.yesButton);
}
if (typeof data.yesButton === "function") {
return data.yesButton(el);
}
return data.yesButton;
},
),
),
),
),
);

60
ui/src/base/copyButton.js Normal file
View File

@@ -0,0 +1,60 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
// @todo consider normalizing and changing args to props
/**
* Returns a "Copy" icon button for the specified value.
*
* @example
* ```js
* app.components.copyButton("test")
* ```
*
* @param {string|function} The value to copy.
* @param {Array} [children] Optional children to append after the icon.
* @return {Element}
*/
window.app.components.copyButton = function(textOrFunc, ...children) {
const data = store({
active: false,
});
let activeTimeoutId;
function copy() {
let value = textOrFunc;
if (typeof value == "function") {
value = textOrFunc();
}
app.utils.copyToClipboard(value);
data.active = true;
clearTimeout(activeTimeoutId);
activeTimeoutId = setTimeout(() => {
data.active = false;
}, 500);
}
return t.button(
{
tabIndex: -1,
type: "button",
className: () => `copy-to-clipboard ${data.active ? "active" : ""}`,
title: "Copy",
ariaDescription: app.attrs.tooltip(() => data.active ? "Copied" : null),
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
copy();
},
},
t.i({
hidden: children?.length,
className: () => `copy-icon ${data.active ? "ri-check-double-line" : "ri-file-copy-line"}`,
}),
...children,
);
};

36
ui/src/base/credits.js Normal file
View File

@@ -0,0 +1,36 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Returns a new element with common helper links, usually shown in the footer.
*
* @return {Element}
*/
window.app.components.credits = function() {
return t.div(
{ pbEvent: "credits", className: "credits" },
() => {
return app.store.creditLinks.map((link) => {
const isLocal = link.href.startsWith("#/");
return t.a(
{
href: () => link.href,
target: () => !isLocal ? "_blank" : undefined,
rel: () => !isLocal ? "noopener noreferrer" : undefined,
className: (el) => {
const isActive = link.isActive?.(el) || app.utils.isActivePath(link.href, false);
return `credit-item ${isActive ? "active" : ""}`;
},
},
() => {
if (link.icon) {
return t.i({ className: link.icon });
}
},
t.span({ className: "txt" }, () => link.label),
);
});
},
);
};

123
ui/src/base/dragline.js Normal file
View File

@@ -0,0 +1,123 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* A vertical dragline to allow width resizing an element.
*
* @example
* ```js
* return app.components.dragline({
* ondragstart: (e) => {
* el._startWidth = el.offsetWidth;
* },
* ondragging: (e, diffX, diffY) => {
* el.style.width = el._startWidth + diffX + "px";
* },
* });
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.dragline = function(propsArg = {}) {
let elem;
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
tolerance: 0,
ondragstart: function(e) {},
ondragstop: function(e) {},
ondragging: function(e, diffX, diffY) {},
});
const watchers = app.utils.extendStore(props, propsArg);
const data = store({
dragStarted: false,
});
let startX, startY, shiftX, shiftY;
function addDocumentEvents() {
document.addEventListener("touchmove", onMove);
document.addEventListener("mousemove", onMove);
document.addEventListener("touchend", onStop);
document.addEventListener("mouseup", onStop);
}
function removeDocumentEvents() {
document.removeEventListener("touchmove", onMove);
document.removeEventListener("mousemove", onMove);
document.removeEventListener("touchend", onStop);
document.removeEventListener("mouseup", onStop);
}
function dragInit(e) {
e.stopPropagation();
startX = e.clientX;
startY = e.clientY;
shiftX = e.clientX - elem.offsetLeft;
shiftY = e.clientY - elem.offsetTop;
addDocumentEvents();
}
function onStop(e) {
if (data.dragStarted) {
e.preventDefault();
data.dragStarted = false;
props.ondragstop?.(e);
}
removeDocumentEvents();
}
function onMove(e) {
let diffX = e.clientX - startX;
let diffY = e.clientY - startY;
let left = e.clientX - shiftX;
let top = e.clientY - shiftY;
if (
!data.dragStarted
&& Math.abs(left - elem.offsetLeft) < props.tolerance
&& Math.abs(top - elem.offsetTop) < props.tolerance
) {
return;
}
e.preventDefault();
if (!data.dragStarted) {
data.dragStarted = true;
props.ondragstart?.(e);
}
props.ondragging?.(e, diffX, diffY);
}
elem = t.div({
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => `dragline ${data.dragStarted ? "dragging" : ""} ${props.className}`,
onmousedown: (e) => {
if (e.button == 0) {
dragInit(e);
}
},
ontouchstart: dragInit,
onunmount: () => {
removeDocumentEvents();
watchers.forEach((w) => w?.unwatch());
},
});
return elem;
};

View File

@@ -0,0 +1,98 @@
const optionClass = "dropdown-item";
document.addEventListener(
"toggle",
(e) => {
if (e.newState != "open" || !e.target?.matches(".dropdown") || e.target.__keyboardNavRegistered) {
return;
}
e.target.__keyboardNavRegistered = true;
const dropdown = e.target;
function onKeydown(e) {
// remove keydown listener in case the element was removed while still open
if (!dropdown.isConnected) {
document.removeEventListener("keydown", onKeydown);
return;
}
let optElem;
if (document.activeElement && document.activeElement.classList.contains(optionClass)) {
optElem = document.activeElement;
} else {
optElem = dropdown.querySelector("." + optionClass + ":not([hidden]):not(.disabled)");
}
if (!optElem) {
return;
}
if (e.key == "ArrowUp") {
e.preventDefault();
const prevElem = firstActiveSibling(optElem, -1);
if (optElem == document.activeElement && prevElem?.classList?.contains(optionClass)) {
prevElem?.focus();
} else {
optElem.focus();
}
} else if (e.key == "ArrowDown") {
e.preventDefault();
const nextElem = firstActiveSibling(optElem, 1);
if (optElem == document.activeElement && nextElem?.classList?.contains(optionClass)) {
nextElem?.focus();
} else {
optElem.focus();
}
} else if (
// a-z
(e.keyCode >= 65 && e.keyCode <= 90)
// 0-9
|| (e.keyCode >= 48 && e.keyCode <= 57)
) {
// autofocus the only available input when start typing
// (e.g. for search)
const inputs = dropdown.querySelectorAll("input,textare,select");
if (inputs.length == 1) {
inputs[0].focus();
}
}
}
dropdown.addEventListener("toggle", (e) => {
if (e.newState == "open") {
updatePopovertargetsData(e.target.id, true);
document.addEventListener("keydown", onKeydown);
} else {
updatePopovertargetsData(e.target.id, false);
document.removeEventListener("keydown", onKeydown);
}
});
},
true,
);
function updatePopovertargetsData(popoverId, state = false) {
if (!popoverId) {
return;
}
document.querySelectorAll("[popovertarget='" + popoverId + "']")
?.forEach((el) => el.setAttribute("data-popover-state", state));
}
function firstActiveSibling(el, dir = -1) {
const sibling = dir < 0 ? el.previousElementSibling : el.nextElementSibling;
if (
sibling
&& (sibling.hidden || sibling.classList.contains("disabled") || !sibling.classList.contains(optionClass))
) {
return firstActiveSibling(sibling, dir);
}
return sibling;
}

467
ui/src/base/erd.js Normal file
View File

@@ -0,0 +1,467 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
const maxScale = 2;
const minScale = 0.4;
const areaPadding = 40;
/**
* Component for rendering ERD-like draggable chart based on the specified collections.
*
* @example
* ```js
* return app.components.erd({
* collections: () => [collection1, collection2, collection3],
* });
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.erd = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
collections: [],
height: 500, // number or CSS string
cols: 5,
marginX: 90,
marginY: 70,
scale: 0.8,
onscalechange: (newScale) => {},
});
const watchers = app.utils.extendStore(props, propsArg);
const data = store({
activeCollection: null,
positions: {},
viewX: 0,
viewY: 0,
panStartX: 0,
panStartY: 0,
isPanning: false,
isUpdating: true,
});
let erdEl;
let initialScale;
const uniqueId = "erd_" + app.utils.randomString();
watchers.push(watch(() => props.scale, (newScale) => {
if (newScale > maxScale) {
props.scale = maxScale;
}
if (newScale < minScale) {
props.scale = minScale;
}
if (!initialScale) {
initialScale = newScale;
}
props.onscalechange?.(newScale);
}));
watchers.push(watch(() => JSON.stringify(props.collections), (_, oldHash) => {
if (typeof oldHash == "undefined") {
return; // initial
}
updateTablePositions();
}));
async function updateTablePositions(withRelations = true) {
data.isUpdating = true;
await new Promise((r) => setTimeout(r, 0));
data.positions = {};
const colYOffsets = new Array(props.cols).fill(areaPadding);
erdEl.querySelectorAll(".erd-table")?.forEach((table, i) => {
const colIndex = i % props.cols;
data.positions[table.dataset.collectionId] = {
x: areaPadding + colIndex * (table.clientWidth + props.marginX),
y: colYOffsets[colIndex],
};
colYOffsets[colIndex] += table.clientHeight + props.marginY;
});
horizontalCenter();
if (withRelations) {
await updateRelations();
} else {
clearRelations();
}
data.isUpdating = false;
}
function clearRelations() {
backPathsGroup.innerHTML = "";
frontPathsGroup.innerHTML = "";
}
async function updateRelations() {
await new Promise((r) => setTimeout(r, 0));
clearRelations();
const paths = [];
for (const collection of props.collections) {
const fields = collection.fields || [];
for (const field of fields) {
if (field.type != "relation") {
continue;
}
const fromEl = erdEl?.querySelector(`[data-collection-id="${collection.id}"]`)
?.querySelector(`[data-field-name="${field.name}"]`);
const toEl = erdEl?.querySelector(`[data-collection-id="${field.collectionId}"]`)
?.querySelector(`[data-field-name="id"]`);
paths.push(createPath(fromEl, toEl, props.scale, collection.id, field.collectionId));
}
}
if (paths.length) {
backPathsGroup.append(...paths);
}
}
function horizontalCenter() {
const containerWidth = erdEl.clientWidth || 0;
const tableWidth = erdEl.querySelector(".erd-table")?.offsetWidth || 0;
const areaWidth = 2 * areaPadding + (props.cols * (tableWidth + props.marginX)) - props.marginX;
data.viewY = 0;
data.viewX = (containerWidth - areaWidth * props.scale) / 2;
}
function resetScale() {
props.scale = initialScale || 1;
horizontalCenter();
}
function isHighlighted(collection) {
if (!data.activeCollection) {
return false;
}
if (data.activeCollection.id == collection.id) {
return true;
}
const relFromField = data.activeCollection.fields?.find((f) =>
f.type == "relation" && f.collectionId == collection.id
);
if (relFromField) {
return true;
}
const relToField = collection.fields?.find((f) =>
f.type == "relation" && f.collectionId == data.activeCollection.id
);
return !!relToField;
}
// maintain 2 svg layers because z-index on paths can't escape the svg
//
// (for the path commands see https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/d#path_commands)
// ---
let svgBack = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgBack.classList.add("erd-paths", "back");
svgBack.innerHTML = `
<defs>
<marker id="${uniqueId}_arrow1" class="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto" fill="context-stroke">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
</defs>
<g class="paths-group" marker-end="url(#${uniqueId}_arrow1)"></g>
`;
let backPathsGroup = svgBack.querySelector(".paths-group");
let svgFront = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svgFront.classList.add("erd-paths", "front");
svgFront.innerHTML = `
<defs>
<marker id="${uniqueId}_arrow2" class="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto" fill="context-stroke">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
</defs>
<g class="paths-group" marker-end="url(#${uniqueId}_arrow2)"></g>
`;
let frontPathsGroup = svgFront.querySelector(".paths-group");
// ---
erdEl = t.div(
{
tabIndex: -1,
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () =>
// dprint-ignore
`erd ${props.className} ${data.isUpdating ? "updating" : ""} ${data.isPanning ? "panning" : ""} ${data.activeCollection ? "active" : ""}`,
onkeydown: (e) => {
if ((e.ctrlKey || e.metaKey) && e.key == "0") {
resetScale();
}
},
onmount: async (el) => {
// zoom
el.addEventListener("wheel", (e) => {
e.preventDefault();
const rect = el.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const layoutX = (mouseX - data.viewX) / props.scale;
const layoutY = (mouseY - data.viewY) / props.scale;
const newScale = Math.min(Math.max(-e.deltaY * 0.001 + props.scale, minScale), maxScale);
data.viewX = mouseX - layoutX * newScale;
data.viewY = mouseY - layoutY * newScale;
props.scale = newScale;
});
// pan
el.addEventListener("pointerdown", (e) => {
if (e.buttons != 1) {
return;
}
data.isPanning = true;
data.panStartX = e.clientX - data.viewX;
data.panStartY = e.clientY - data.viewY;
});
el._ondragging = function(e) {
if (!data.isPanning) {
return;
}
data.viewX = e.clientX - data.panStartX;
data.viewY = e.clientY - data.panStartY;
};
el._ondragstop = function() {
data.isPanning = false;
};
// note: attach mouse end events to window to allow panning when outside the element
window.addEventListener("pointermove", el._ondragging);
window.addEventListener("pointerup", el._ondragstop);
updateTablePositions();
},
onunmount: (el) => {
window.removeEventListener("pointermove", el._ondragging);
window.removeEventListener("pointerup", el._ondragstop);
watchers.forEach((w) => w?.unwatch());
},
},
t.div(
{
className: "erd-area",
style: () => `transform: translate(${data.viewX}px, ${data.viewY}px) scale(${props.scale});`,
},
svgBack,
t.div(
{ className: "erd-tables" },
() => {
return props.collections.map((collection) => {
return t.div(
{
// dpint-ignore
style: () =>
`left:${data.positions[collection.id]?.x || 0}px;top:${
data.positions[collection.id]?.y || 0
}px`,
className: () =>
`erd-table type-${collection.type} ${isHighlighted(collection) ? "active" : ""} ${
collection.system ? "system" : ""
}`,
"html-data-collection-id": () => collection.id,
"html-data-collection-name": () => collection.name,
onmouseenter: () => {
backPathsGroup.querySelectorAll(`[data-to="${collection.id}"]`)?.forEach(
(child) => {
child.classList.add("active-to");
frontPathsGroup.append(child);
},
);
backPathsGroup.querySelectorAll(`[data-from="${collection.id}"]`)?.forEach(
(child) => {
child.classList.add("active-from");
frontPathsGroup.append(child);
},
);
data.activeCollection = collection;
},
onmouseleave: () => {
for (const child of frontPathsGroup.children) {
child.classList.remove("active-from", "active-to");
}
backPathsGroup.append(...frontPathsGroup.children);
data.activeCollection = null;
},
},
t.div(
{ className: "erd-table-row header" },
() => collection.name,
),
() => {
return collection.fields?.map((field) => {
return t.div(
{
className: `erd-table-row type-${field.type} ${
field.primaryKey ? "primary-key" : ""
}`,
"html-data-field-id": () => field.id,
"html-data-field-name": () => field.name,
},
t.i({
title: () => field.type,
className: () =>
`field-icon ${
app.fieldTypes[field.type].icon || app.utils.fallbackFieldIcon
}`,
}),
t.span({ className: "field-name" }, () => field.name),
() => {
if (field.hidden) {
return t.span(
{ className: "label danger field-hidden-label" },
"Hidden",
);
}
},
() => {
if (typeof field.maxSelect != "undefined") {
return t.span(
{ className: "meta" },
field.maxSelect > 1 ? "multiple" : "single",
);
}
},
);
});
},
);
});
},
),
svgFront,
),
t.nav(
{
className: "erd-nav",
onmousedown: (e) => {
e.stopImmediatePropagation();
},
},
t.button(
{
type: "button",
className: "btn sm circle secondary",
title: "Zoom in",
onclick: () => {
props.scale += 0.05;
},
},
t.i({ className: "ri-add-line", ariaHidden: true }),
),
t.button(
{
type: "button",
className: "btn sm circle secondary",
title: "Zoom out",
onclick: () => {
props.scale -= 0.05;
},
},
t.i({ className: "ri-subtract-line", ariaHidden: true }),
),
),
);
return erdEl;
};
// creates a new svg path line in svgEl to visually connect el1 and el2.
function createPath(
el1,
el2,
scale = 1,
fromCollectionId = "",
toCollectionId = "",
extraSpacing = 2,
) {
if (!el1 || !el2) {
return;
}
const workspaceRect = el1.closest(".erd-area").getBoundingClientRect();
const r1 = el1.getBoundingClientRect();
const r2 = el2.getBoundingClientRect();
extraSpacing *= scale;
let y1 = (r1.top - workspaceRect.top) + r1.height / 2;
let y2 = (r2.top - workspaceRect.top) + r2.height / 2;
let x1, x2;
if (r1.left < r2.left) {
// left -> right
x1 = (r1.left - workspaceRect.left) + r1.width + extraSpacing;
x2 = (r2.left - workspaceRect.left) - extraSpacing;
} else if (r1.left > r2.left) {
// right <- left
x1 = (r1.left - workspaceRect.left) - extraSpacing;
x2 = (r2.left - workspaceRect.left) + r2.width + extraSpacing;
} else {
// within the same column
x1 = (r1.left - workspaceRect.left) - extraSpacing;
x2 = (r2.left - workspaceRect.left) - extraSpacing;
}
// rescale
x1 /= scale;
x2 /= scale;
y1 /= scale;
y2 /= scale;
// mid point for the line squirish/orthogonal break
let midX = x1 + (x2 - x1) / 2;
if (x1 == x2) {
midX -= 20; // offset slightly to prevent overlap when the 2 elements are in the same column
}
const d = `M ${x1} ${y1}
L ${midX} ${y1}
L ${midX} ${y2}
L ${x2} ${y2}`;
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("class", "relation-path");
path.setAttribute("data-from", fromCollectionId || "");
path.setAttribute("data-to", toCollectionId || "");
path.setAttribute("d", d);
return path;
}

View File

@@ -0,0 +1,439 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
export const toDeleteProp = "@toDelete";
window.app.components.fieldSettings = function(data, settingsArg = {}) {
const uniqueId = "base_" + app.utils.randomString();
const settings = store({
// options
showHidden: true,
showPresentable: true,
showDuplicate: true,
showRemove: true, // for system fields this is ignored
// slots
header: (el) => null,
content: (el) => null,
footer: (el) => null,
});
const watchers = app.utils.extendStore(settings, settingsArg);
function duplicateField() {
const clone = JSON.parse(JSON.stringify(data.field));
clone.id = "";
clone.system = false;
clone.name = getUniqueFieldName(data.collection.fields, clone.name + "_copy");
clone.__detailsOpen = true;
if (clone.primaryKey) {
clone.primaryKey = false;
}
if (clone[toDeleteProp]) {
delete clone[toDeleteProp];
}
data.collection.fields.splice(data.fieldIndex + 1, 0, clone);
}
function removeField() {
if (data.field.id) {
// existing fields are only marked as deleted
data.field[toDeleteProp] = true;
} else {
// new fields are immediately removed
data.collection.fields.splice(data.fieldIndex, 1);
}
}
return t.details(
{
// duplicate the class as raw attribute because the reactive state
// is applied AFTER mount (MutationObserver related quirk)
"html-class": "accordion record-field-settings",
className: () =>
`accordion record-field-settings field-type-${data.field.type} ${
data.field[toDeleteProp] ? "deleted" : ""
}`,
name: "collection_field",
onmount: (el) => {
if (data.field.__detailsOpen) {
delete data.field.__detailsOpen;
el.open = true;
}
// name normalizer
watchers.push(
watch(
() => data.field.name,
(newName, oldName) => {
newName = app.utils.slugify(newName);
data.field.name = newName;
if (typeof oldName == "undefined") {
return;
}
replaceIndexesColumn(data.collection, oldName, newName);
replaceIdentityFields(data.collection, oldName, newName);
},
),
);
// reset the name if it was previously deleted
watchers.push(
watch(
() => data.field[toDeleteProp],
(deleted) => {
if (deleted && data.originalField?.name && data.field.name != data.originalField.name) {
data.field.name = data.originalField.name;
}
},
),
);
// disable presentable
watchers.push(
watch(() => {
if (data.field.presentable && data.field.hidden) {
data.field.presentable = false;
app.toasts.info("The field cannot be presentable if hidden.");
}
}),
);
// special cases for some system fields
watchers.push(
watch(() => {
if (
(data.field.name == "id"
|| (data.collection.type == "auth"
&& ["password", "tokenKey"].includes(data.field.name)))
&& data.originalField
&& data.field.required != data.originalField.required
) {
data.field.required = data.originalField.required;
app.toasts.info(`The option cannot be changed for field "${data.field.name}".`);
}
}),
);
// prevent hidden prop change for special fields
watchers.push(
watch(() => {
if (
(data.field.name == "id"
|| (data.collection.type == "auth"
&& ["password", "tokenKey", "email"].includes(data.field.name)))
&& data.originalField
&& data.field.hidden != data.originalField.hidden
) {
data.field.hidden = data.originalField.hidden;
app.toasts.info(`The option cannot be changed for field "${data.field.name}".`);
}
}),
);
},
onunmount: (el) => {
watchers.forEach((w) => w?.unwatch());
},
},
t.summary(
{ tabIndex: -1, onfocusout: () => false, onclick: () => false, onkeyup: () => false },
t.span({ className: "sort-handle" }, t.i({ className: "ri-draggable" })),
t.header(
{
className: "header-fields",
inert: () => data.field[toDeleteProp],
onclick: (e) => {
e.stopPropagation();
e.preventDefault();
},
},
t.div(
{ className: "fields" },
t.label(
{
htmlFor: uniqueId + ".name",
className: () => `field addon ${data.field.system ? "txt-disabled" : ""}`,
},
t.i({
className: app.fieldTypes[data.field.type]?.icon || app.utils.fallbackFieldIcon,
ariaDescription: app.attrs.tooltip(() => {
if (data.field.system) {
return data.field.type + " (system)";
}
return data.field.type;
}),
}),
),
t.div(
{ className: "field prop-name" },
t.input({
type: "text",
id: uniqueId + ".name",
name: () => `fields.${data.fieldIndex}.name`,
required: true,
spellcheck: false,
placeholder: "Field name*",
className: "inline-error",
disabled: () => data.field[toDeleteProp] || data.field.system,
value: () => data.field.name || "",
oninput: (e) => {
if (e.isComposing) {
return;
}
data.field.name = e.target.value;
},
onmount: (nameInput) => {
nameInput.addEventListener("compositionend", (e) => {
data.field.name = e.target.value;
});
setTimeout(() => {
if (nameInput && data.field.__focus) {
nameInput.select();
delete data.field.__focus;
}
}, 0);
},
}),
t.div({ className: "field-labels" }, () => {
const labels = [];
if (data.field.required) {
labels.push(t.span({ className: "label success" }, "Required"));
}
if (data.field.hidden) {
labels.push(t.span({ className: "label danger" }, "Hidden"));
} else if (data.field.presentable) {
labels.push(t.span({ className: "label info" }, "Presentable"));
}
return labels;
}),
),
),
(el) => {
if (typeof settings.header == "function") {
return settings.header(el);
}
return settings.header;
},
),
t.button(
{
type: "button",
className: () => {
const hasError = !app.utils.isEmpty(
app.utils.getByPath(app.store.errors, `fields.${data.fieldIndex}`),
);
return `btn sm circle transparent secondary ${hasError ? "txt-danger" : ""}`;
},
title: "Field options",
hidden: () => data.field[toDeleteProp],
onclick: (e) => {
const details = e.target.closest("details");
if (details) {
details.open = !details.open;
}
},
},
t.i({ className: "ri-settings-3-line" }),
),
t.button(
{
type: "button",
className: "btn sm circle transparent warning",
hidden: () => !data.field[toDeleteProp],
onclick: () => delete data.field[toDeleteProp],
ariaDescription: app.attrs.tooltip("Restore"),
},
t.i({ className: "ri-restart-line" }),
),
),
(el) => {
if (typeof settings.content == "function") {
return settings.content(el);
}
return settings.content;
},
t.footer(
{ className: "record-field-settings-footer" },
(el) => {
if (typeof settings.footer == "function") {
return settings.footer(el);
}
return settings.footer;
},
() => {
if (!settings.showPresentable) {
return;
}
return t.div(
{ className: "field prop-presentable" },
t.input({
type: "checkbox",
id: uniqueId + ".presentable",
name: () => `fields.${data.fieldIndex}.presentable`,
className: "sm",
disabled: () => data.field.hidden,
checked: () => !!data.field.presentable,
onchange: (e) => (data.field.presentable = e.target.checked),
}),
t.label(
{ htmlFor: uniqueId + ".presentable" },
t.span({ className: "txt" }, "Presentable"),
t.i({
className: "ri-information-line link-hint",
ariaDescription: app.attrs.tooltip(
() => {
let msg =
"Whether the field should be preferred in the Superuser UI relation listings (default to auto).";
if (data.field.hidden) {
msg += "\nThe field cannot be presentable if hidden.";
}
return msg;
},
),
}),
),
);
},
() => {
if (!settings.showHidden) {
return;
}
return t.div(
{ className: "field prop-hidden" },
t.input({
type: "checkbox",
id: uniqueId + ".hidden",
className: "sm",
name: () => `fields.${data.fieldIndex}.hidden`,
checked: () => !!data.field.hidden,
onchange: (e) => (data.field.hidden = e.target.checked),
}),
t.label(
{ htmlFor: uniqueId + ".hidden" },
t.span({ className: "txt" }, "Hidden"),
t.i({
className: "ri-information-line link-hint",
ariaDescription: app.attrs.tooltip("Hide from the JSON API response and filters."),
}),
),
);
},
t.button(
{
hidden: () => !settings.showDuplicate && (!settings.showRemove || data.field.system),
type: "button",
className: "btn sm circle transparent secondary more-btn m-l-auto",
"html-popovertarget": uniqueId + "_options_dropdown",
},
t.i({ className: "ri-more-line", ariaHidden: true }),
),
t.div(
{
id: uniqueId + "_options_dropdown",
className: "dropdown sm field-options-dropdown",
popover: "auto",
},
() => {
if (!settings.showDuplicate) {
return;
}
return t.button({
type: "button",
className: "dropdown-item",
role: "menuitem",
textContent: "Duplicate",
onclick: (e) => {
duplicateField();
e.target.closest(".dropdown").hidePopover();
},
});
},
() => {
if (!settings.showRemove || data.field.system) {
return;
}
return t.button({
type: "button",
className: "dropdown-item",
role: "menuitem",
textContent: "Remove",
onclick: (e) => {
removeField();
e.target.closest(".dropdown").hidePopover();
e.target.closest("details").open = false;
},
});
},
),
),
);
};
function getUniqueFieldName(allFields, name = "field") {
let result = name;
let counter = 2;
let suffix = name.match(/\d+$/)?.[0] || ""; // extract numeric suffix
// name without the suffix
let base = suffix ? name.substring(0, name.length - suffix.length) : name;
while (!!allFields?.find((field) => field.name.toLowerCase() == result.toLowerCase())) {
result = base + ((suffix << 0) + counter);
counter++;
}
return result;
}
function replaceIdentityFields(collection, oldName, newName) {
if (
!newName
|| typeof oldName == "undefined"
|| oldName === newName
|| !collection?.passwordAuth?.identityFields?.length
) {
return;
}
let identityFields = collection.passwordAuth.identityFields;
for (let i = 0; i < identityFields.length; i++) {
if (identityFields[i] == oldName) {
identityFields[i] = newName;
}
}
}
function replaceIndexesColumn(collection, oldName, newName) {
if (
!newName
|| typeof oldName == "undefined"
|| oldName === newName
|| !collection?.indexes?.length
|| !collection?.fields?.length
) {
return;
}
// field with the old name exists so there is no need to rename index columns
if (!!collection.fields.find((f) => !f[toDeleteProp] && f.name == oldName)) {
return;
}
// update indexes on renamed fields
collection.indexes = collection.indexes.map((idx) => app.utils.replaceIndexColumn(idx, oldName, newName));
}

View File

@@ -0,0 +1,119 @@
window.app = window.app || {};
window.app.modals = window.app.modals || {};
/**
* Opens a new file preview popup.
*
* @example
* ```js
* app.modals.openFilePreview(url)
* ```
*
* @param {string|Promise|Function} urlOrFactory
*/
window.app.modals.openFilePreview = function(urlOrFactory) {
const modal = filePreviewModal(urlOrFactory);
document.body.appendChild(modal);
app.modals.open(modal);
};
function filePreviewModal(urlOrFactory) {
const data = store({
url: "",
get filename() {
const url = data.url;
const queryParamsIdx = url.indexOf("?");
return url.substring(url.lastIndexOf("/") + 1, queryParamsIdx > 0 ? queryParamsIdx : undefined);
},
get fileType() {
return app.utils.getFileType(data.filename);
},
});
async function resolveUrlOrFactory() {
let url = "";
try {
if (typeof urlOrFactory == "function") {
url = await urlOrFactory();
} else {
// string or Promise
url = await urlOrFactory;
}
} catch (err) {
if (!err.isAbort) {
console.warn("resolveUrlOrFactory file preview failure:", err);
}
}
data.url = url;
return url;
}
async function openInNewTab() {
// resolve again because it may have expired
let url = await resolveUrlOrFactory();
if (!url) {
return;
}
window.open(url, "_blank", "noreferrer,noopener");
}
return t.div(
{
pbEvent: "filePreviewModal",
className: () => `modal preview preview-${data.fileType}`,
onbeforeopen: () => {
resolveUrlOrFactory();
},
onafterclose: (el) => {
el.remove();
},
},
t.div({ className: "modal-content" }, () => {
if (!data.url) {
return t.span({ className: "loader" });
}
if (data.fileType == "image") {
return t.img({
src: () => data.url,
alt: () => `Preview ${data.filename}`,
});
}
return t.object(
{
data: data.url, // note: the reactive value doesn't trigger reload of the object
title: () => data.filename,
},
"Cannot preview the file.",
);
}),
t.footer(
{ className: "modal-footer" },
t.button(
{
type: "button",
className: "link-hint filename-link",
ariaDescription: app.attrs.tooltip("Open in new tab"),
onclick: () => openInNewTab(),
},
t.span({ className: "txt" }, () => data.filename),
),
t.button(
{
type: "button",
className: "btn transparent m-l-auto",
onclick: () => app.modals.close(),
},
t.span({ className: "txt" }, "Close"),
),
),
);
}

View File

@@ -0,0 +1,54 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone;
window.app.components.formattedDate = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
value: "",
short: false,
});
const watchers = app.utils.extendStore(props, propsArg);
return t.div(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
ariaDescription: app.attrs.tooltip(() => {
if (props.short && !!props.value) {
return app.utils.toLocalDatetime(props.value) + "\n" + tzName;
}
return null;
}),
"html-class": "formatted-date",
className: () => `formatted-date ${props.short ? "short" : "full"}`,
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
() => {
if (!props.value) {
return t.span({ className: "missing-value" });
}
if (props.short) {
const parts = props.value.split(" ");
return [
t.span({ className: "primary-date" }, parts[0]),
t.span({ className: "secondary-date" }, parts[1]),
];
}
return [
t.span({ className: "primary-date" }, app.utils.toLocalDatetime(props.value)),
t.span({ className: "secondary-date" }, props.value),
];
},
);
};

313
ui/src/base/leaflet.js Normal file
View File

@@ -0,0 +1,313 @@
import L from "leaflet";
import "leaflet/dist/leaflet.css";
// manually load the markers so that they can be embedded in the prod bundle
import markerIconRetinaUrl from "leaflet/dist/images/marker-icon-2x.png";
import markerIconUrl from "leaflet/dist/images/marker-icon.png";
import markerShadowUrl from "leaflet/dist/images/marker-shadow.png";
const defaultZoomLevel = 8;
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Leaflet component for showing and adjust a single geo point value.
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.leaflet = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
point: { lat: 0, lon: 0 },
onchange: function(point) {},
});
const watchers = app.utils.extendStore(props, propsArg);
let map;
let marker;
let panTimeoutId;
watchers.push(
watch(
() => {
if (props.point.lat > 90) {
props.point.lat = 90;
}
if (props.point.lat < -90) {
props.point.lat = -90;
}
if (props.point.lon > 180) {
props.point.lon = 180;
}
if (props.point.lon < -180) {
props.point.lon = -180;
}
},
() => {
panInside();
},
),
);
function panInside(debounce = 200) {
if (!map) {
return;
}
clearTimeout(panTimeoutId);
panTimeoutId = setTimeout(() => {
marker?.setLatLng([props.point.lat, props.point.lon]);
map?.panInside([props.point.lat, props.point.lon], { padding: [20, 40] });
}, debounce);
}
function initMap(mapEl) {
const latlon = [toFixedCoord(props.point.lat), toFixedCoord(props.point.lon)];
map = L.map(mapEl, { zoomControl: false }).setView(latlon, defaultZoomLevel);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a>",
}).addTo(map);
// reassign the default marker images with the loaded ones
// (https://leafletjs.com/reference.html#icon-default-option)
L.Icon.Default.prototype.options.iconUrl = markerIconUrl;
L.Icon.Default.prototype.options.iconRetinaUrl = markerIconRetinaUrl;
L.Icon.Default.prototype.options.shadowUrl = markerShadowUrl;
L.Icon.Default.imagePath = "";
marker = L.marker(latlon, {
draggable: true,
autoPan: true,
}).addTo(map);
marker.bindTooltip("drag or right click anywhere on the map to move");
marker.on("moveend", (e) => {
if (e.sourceTarget?._latlng) {
select(e.sourceTarget._latlng.lat, e.sourceTarget._latlng.lng, false);
}
});
map.on("contextmenu", (e) => {
select(e.latlng.lat, e.latlng.lng, false);
});
}
function destroyMap() {
clearTimeout(panTimeoutId);
marker?.remove();
map?.remove();
}
function select(lat, lon, centerMap = true) {
const point = {
lat: toFixedCoord(lat),
lon: toFixedCoord(lon),
};
if (props.onchange && props.onchange(point) === false) {
return;
}
props.point = point;
// center the map
if (centerMap) {
marker?.setLatLng([props.point.lat, props.point.lon]); // optimistic marker update
map?.panTo([props.point.lat, props.point.lon], { animate: false });
}
map.getContainer()?.dispatchEvent(new CustomEvent("change", { detail: point }));
resetSearch?.();
}
const [searchEl, resetSearch] = initSearch(select);
return t.div(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: "map-container",
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
searchEl,
t.div({
className: "map-box",
onmount: (el) => {
initMap(el);
},
onunmount: () => {
destroyMap();
},
}),
);
};
function toFixedCoord(coord) {
return +(+coord).toFixed(6) || 0;
}
function initSearch(selectFunc = null) {
const data = store({
searchTerm: "",
isSearching: false,
searchResults: [],
});
let searchTimeoutId;
let searchAbortController;
function reset() {
searchAbortController?.abort("reset");
clearTimeout(searchTimeoutId);
data.isSearching = false;
data.searchResults = [];
data.searchTerm = "";
}
// note: using debounce > 1s to minimize hitting the API rate limits
// (see also https://operations.osmfoundation.org/policies/nominatim/)
function search(debounce = 1100) {
clearTimeout(searchTimeoutId);
searchAbortController?.abort("search debounce");
data.isSearching = true;
data.searchResults = [];
if (!data.searchTerm) {
data.isSearching = false;
return;
}
searchTimeoutId = setTimeout(async () => {
try {
searchAbortController = new AbortController();
const response = await fetch(
"https://nominatim.openstreetmap.org/search.php?format=jsonv2&q="
+ encodeURIComponent(data.searchTerm),
{ signal: searchAbortController.signal },
);
if (response.status != 200) {
throw new Error("OpenStreetMap API error " + response.status);
}
const results = [];
const addresses = await response.json();
for (const item of addresses) {
results.push({
lat: item.lat,
lon: item.lon,
name: item.display_name,
});
}
data.searchResults = results;
} catch (err) {
console.warn("[address search failed]", err);
}
data.isSearching = false;
}, debounce);
}
const searchInput = t.div(
{ className: "fields" },
t.div(
{ className: "field" },
t.input({
type: "text",
placeholder: "Search address...",
value: () => data.searchTerm,
oninput: (e) => (data.searchTerm = e.target.value),
}),
),
t.div({ className: "field addon p-l-10 p-r-10" }, () => {
if (data.isSearching) {
return t.span({ className: "loader sm" });
}
if (data.searchTerm.length) {
return t.button(
{
className: "link-hint",
title: "Clear search",
onclick: () => reset(),
},
t.i({ className: "ri-close-line" }),
);
}
}),
);
const searchDropdown = t.div({ className: "dropdown", popover: "manual" }, () => {
return data.searchResults.map((item) => {
return t.button(
{
type: "button",
className: "dropdown-item",
title: "Select address coordinates",
onclick: () => selectFunc?.(item.lat, item.lon),
},
item.name,
);
});
});
const watchers = [];
return [
t.div(
{
className: "map-search",
onmount: () => {
watchers.push(
watch(
() => data.searchTerm,
(searchTerm) => {
search(searchTerm);
},
),
);
watchers.push(
watch(
() => data.searchResults,
(results) => {
if (results.length) {
searchDropdown.showPopover({ source: searchInput });
} else {
searchDropdown.hidePopover();
}
},
),
);
},
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
reset();
},
},
searchInput,
searchDropdown,
),
reset,
];
}

261
ui/src/base/modal.js Normal file
View File

@@ -0,0 +1,261 @@
window.app = window.app || {};
window.app.modals = window.app.modals || {};
const modalAttr = "data-modal-state";
const modalManualClass = "manual";
let oldActiveElem;
/**
* Initializes and opens `el` as modal.
*
* You can also make use of the following custom `el` function properties:
*
* - onbeforeopen(el) - triggered before the opening sequence (return `false` to stop)
* - onafteropen(el) - triggered after the opening sequence
* - onbeforeclose(el, forceClosed) - triggered before the closing sequence (return `false` to stop);
* (note that when force closing the result of this callback is ignored)
* - onafterclose(el, forceClosed) - triggered after the closing sequence
*
* @example
* ```js
* modal = t.div(
* {
* className: "modal popup sm",
* onbeforeclose: (el) => {
* if (!someImportantCheck) {
* return false
* }
* },
* },
* t.header({ className: "modal-header" },
* t.h5({ className: "m-auto" }, "Logs settings"),
* ),
* t.form(
* {
* id: "myForm",
* className: "modal-content",
* onsubmit: (e) => {
* e.preventDefault();
* // ...
* },
* },
* // ... content ...
* ),
* t.footer({ className: "modal-footer" },
* t.button(
* {
* type: "button",
* className: "btn transparent m-r-auto",
* onclick: () => app.modals.close(),
* },
* t.span({ className: "txt" }, "Close"),
* ),
* t.button(
* {
* "html-form": "myForm",
* type: "submit",
* className: "btn",
* },
* t.span({ className: "txt" }, "Save changes"),
* ),
* ),
* );
*
* app.modals.open(modal)
* ```
*
* @param {Element} el
*/
window.app.modals.open = async function(el) {
if (!el?.isConnected) {
console.error("modals.open requies an active DOM element", el);
return;
}
let beforeopen;
if (el.onbeforeopen) {
beforeopen = await el.onbeforeopen(el);
}
if (beforeopen === false) {
return;
}
// note: currently doesn't wait for the entrance animation but this may change in the future
if (el.onafteropen) {
let resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentRect.height > 0 && el?.onafteropen) {
el.onafteropen(el);
resizeObserver.disconnect();
resizeObserver = null; // observers uses weak but clear it explicitly nonetheless
}
}
});
resizeObserver.observe(el);
}
oldActiveElem = document.activeElement;
// init (if not already)
initModal(el);
// force close on history change
el._forceClose = () => app.modals.close(el, true);
window.addEventListener("popstate", el._forceClose);
const largestZIndex = Math.max(findLargestZIndexModal(el)?.style.zIndex << 0, 1000);
el.style.zIndex = largestZIndex + 1;
// note: use data attribute to avoid conflict with reactive className
el.setAttribute(modalAttr, "open");
};
/**
* Closes the specified modal (or the last/top open one if not explicitly set).
*
* Example:
* ```js
* app.modals.close()
* app.modals.close(myModal)
*
* // force close
* app.modals.close(null, true)
* app.modals.close(myModal, true)
* ```
*
* @param {Element} [el]
* @param {boolean} [forceClose]
*/
window.app.modals.close = async function(el = null, forceClose = false) {
el = el || findLargestZIndexModal();
if (!el) {
return;
}
window.removeEventListener("popstate", el._forceClose);
if (forceClose) {
el.onbeforeclose?.(el, true);
el.setAttribute(modalAttr, "close");
el.onafterclose?.(el, true);
} else {
if (
el.onbeforeclose
&& (await el.onbeforeclose(el, false)) === false
) {
return;
}
if (el.onafterclose) {
let resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentRect.height <= 0 && el?.onafterclose) {
el.onafterclose(el, false);
resizeObserver.disconnect();
resizeObserver = null; // observers uses weak ref but clear it explicitly nonetheless
}
}
});
resizeObserver.observe(el);
}
el.setAttribute(modalAttr, "close");
}
// restore original focus position without making the focus visible
if (oldActiveElem) {
oldActiveElem.focus?.();
setTimeout(() => {
oldActiveElem?.blur?.();
oldActiveElem = null;
}, 0);
}
};
function initModal(el) {
if (!el.getAttribute("tabindex")) {
el.setAttribute("tabindex", "-1");
}
// focus to capture key events and to change the tab navigation to the modal
// (execute after the element rendering task)
setTimeout(() => {
const autofocusEl = el?.querySelector("[autofocus]");
if (autofocusEl) {
autofocusEl.focus();
} else {
el?.focus();
}
}, 0);
// already initialized
if (el.getAttribute(modalAttr)) {
return;
}
el.setAttribute(modalAttr, "");
// dismiss handlers
el.addEventListener("keydown", (e) => {
if (
e.key != "Escape"
|| el.classList.contains(modalManualClass)
|| (e.target !== el && el.contains(e.target))
) {
return;
}
window.app.modals.close(el);
});
let startedInside = false;
const startedInsideFunc = (e) => {
startedInside = e.target !== el && el.contains(e.target);
};
el.addEventListener("mousedown", startedInsideFunc);
el.addEventListener("touchstart", startedInsideFunc);
let endedInside = false;
const endedInsideFunc = (e) => {
endedInside = e.target !== el && el.contains(e.target);
};
el.addEventListener("mouseup", endedInsideFunc);
el.addEventListener("touchend", endedInsideFunc);
el.addEventListener("click", (e) => {
if (
startedInside
|| endedInside
|| el.classList.contains(modalManualClass)
// e.g. in case a btn is clicked with the keyboard (Enter/Space/etc.)
|| (e.target !== el && el.contains(e.target))
) {
return;
}
window.app.modals.close(el);
});
}
function findLargestZIndexModal(excludeEl) {
const opened = document.querySelectorAll(`[${modalAttr}="open"]`);
let z = 0;
let max = 0;
let largest;
for (const m of opened) {
if (excludeEl && m == excludeEl) {
continue;
}
z = m.style.zIndex << 0;
if (z > max) {
max = z;
largest = m;
}
}
return largest;
}

170
ui/src/base/pageSidebar.js Normal file
View File

@@ -0,0 +1,170 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
const responsiveThreshold = 1000;
/**
* A generic page sidebar component with resizable edge.
*
* @example
* ```js
* app.components.pageSidebar({},
* t.nav({ className: "sidebar-content scrollable" },
* t.details({ className: "nav-group"},
* t.summary({ tabIndex: -1, onfocusout: () => false, onclick: () => false, onkeyup: () => false },
* "Group 1",
* ),
* t.a({ className: "nav-link", href: "..." }, "Link 1.1"),
* t.a({ className: "nav-link", href: "..." }, "Link 1.2"),
* ),
* t.details({ className: "nav-group"},
* t.summary({ tabIndex: -1, onfocusout: () => false, onclick: () => false, onkeyup: () => false },
* "Group 2",
* ),
* t.a({ className: "nav-link", href: "..." }, "Link 2.1"),
* t.a({ className: "nav-link", href: "..." }, "Link 2.2"),
* ),
* )
* ```
*
* @param {Object} [propsArg]
* @param {Array<Element|Function>} [children]
* @return {Element}
*/
window.app.components.pageSidebar = function(propsArg = {}, ...children) {
let sidebarElem;
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
widthHistoryKey: "pbPageSidebarWidth",
onmount: undefined,
onunmount: undefined,
});
const watchers = app.utils.extendStore(props, propsArg);
const data = store({
responsiveShow: false,
});
let responsiveBtn;
function responsivePageSidebar() {
if (!sidebarElem) {
return;
}
if (window.innerWidth > responsiveThreshold) {
data.responsiveShow = false;
sidebarElem.dataset.responsive = false;
responsiveBtn?.remove();
responsiveBtn = null;
return;
}
sidebarElem.dataset.responsive = true;
if (!responsiveBtn) {
responsiveBtn = t.button(
{
type: "button",
className: "btn transparent secondary responsive-sidebar-btn",
title: "Toggle sidebar",
onclick: (e) => {
e.stopPropagation();
data.responsiveShow = !data.responsiveShow;
},
},
t.i({ className: "ri-menu-2-line", ariaHidden: true }),
);
document.body.querySelector(".page-header .breadcrumbs").before(responsiveBtn);
}
}
function onOutsideClick(e) {
if (e.target.closest(".responsive-close")) {
data.responsiveShow = false;
return;
}
if (
e.target.closest(".page-sidebar")
|| e.target.closest(".app-header")
|| e.target.closest(".modal")
) {
return; // inside click -> do nothing
}
e.preventDefault();
e.stopImmediatePropagation();
data.responsiveShow = false;
return false;
}
watchers.push(
watch(() => data.responsiveShow, (visible) => {
if (visible) {
window.addEventListener("click", onOutsideClick, true);
} else {
window.removeEventListener("click", onOutsideClick, true);
}
}),
);
sidebarElem = t.aside(
{
pbEvent: "pageSidebar",
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => `page-sidebar ${props.className} ${data.responsiveShow ? "active" : ""}`,
onmount: (el) => {
responsivePageSidebar(el);
window.addEventListener("resize", responsivePageSidebar);
props.onmount?.(el);
},
onunmount: (el) => {
props.onunmount?.(el);
window.removeEventListener("click", onOutsideClick, true);
window.removeEventListener("resize", responsivePageSidebar);
responsiveBtn?.remove();
watchers.forEach((w) => w?.unwatch());
},
},
(el) => {
let sidebarWidth;
if (props.widthHistoryKey) {
sidebarWidth = localStorage.getItem(props.widthHistoryKey);
if (sidebarWidth) {
el.style.width = sidebarWidth;
}
}
return app.components.dragline({
ondragstart: (e) => {
el._startWidth = el.offsetWidth;
},
ondragging: (e, diffX, diffY) => {
sidebarWidth = el._startWidth + diffX + "px";
el.style.width = sidebarWidth;
if (props.widthHistoryKey) {
localStorage.setItem(props.widthHistoryKey, sidebarWidth);
}
},
});
},
...children,
);
return sidebarElem;
};

View File

@@ -0,0 +1,71 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Animated refresh button element.
*
* @example
* ```js
* app.components.refreshButton({
* onclick: () => { console.log("clicked...") },
* })
* ```
*
* @param {Object} propsArg
* @return {Element}
*/
window.app.components.refreshButton = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
tooltip: "Refresh",
className: "btn transparent secondary circle rotate-btn",
disabled: false,
onclick: function(e) {},
});
const watchers = app.utils.extendStore(props, propsArg);
let refreshTimeoutId;
const btn = t.button(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
type: "button",
ariaDescription: app.attrs.tooltip(() => props.tooltip),
disabled: () => props.disabled,
className: () => props.className,
onunmount: () => {
clearTimeout(refreshTimeoutId);
watchers.forEach((w) => w?.unwatch());
},
onclick: (e) => {
e.preventDefault();
if (props.onclick) {
props.onclick(e);
}
btn.classList.add("rotate");
btn.addEventListener("animationend", () => {
btn.classList.remove("rotate");
});
// fallback
clearTimeout(refreshTimeoutId);
refreshTimeoutId = setTimeout(() => {
clearTimeout(refreshTimeoutId);
btn.classList.remove("rotate");
}, 500);
},
},
t.i({ className: "ri-refresh-line" }),
);
return btn;
};

165
ui/src/base/ruleField.js Normal file
View File

@@ -0,0 +1,165 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* API rule input element.
*
* @example
* ```js
* app.components.ruleField({
* name: "listRule",
* autocomplete: (word) => {
* return app.utils.collectionAutocompleteKeys(someCollection, word);
* },
* value: () => someCollection.listRule,
* oninput: (newVal) => someCollection.listRule = newVal,
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.ruleField = function(propsArg = {}) {
const uniqueId = "rule_" + app.utils.randomString();
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
required: false,
disabled: false,
name: undefined,
label: undefined,
help: undefined,
value: null,
nullable: true,
placeholder: "Leave empty to grant everyone access...",
autocomplete: (word) => [],
oninput: (newVal) => {},
onmount: (el) => {},
onunmount: (el) => {},
// ---
get isLocked() {
return props.value == null;
},
});
const watchers = app.utils.extendStore(props, propsArg, "isLocked");
let ruleField;
let _prevValue = "";
function updateValue(newValue) {
props.value = newValue;
props.oninput?.(newValue);
ruleField?.dispatchEvent(new CustomEvent("change", { detail: newValue }));
}
function lock() {
if (props.value === null) {
return;
}
_prevValue = props.value;
updateValue(null);
}
function unlock() {
if (_prevValue != null) {
updateValue(_prevValue);
} else {
updateValue("");
}
setTimeout(() => {
document.getElementById(uniqueId)?.focus();
}, 0);
}
ruleField = t.div(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
"html-name": () => props.name, // used for the error reset
className: () =>
[
"field",
"rule-field",
props.required ? "required" : null,
props.value === null ? "locked" : null,
props.disabled ? "disabled" : null,
].filter(Boolean).join(" "),
onmount: (el) => {
props.onmount?.(el);
},
onunmount: (el) => {
props.onunmount?.(el);
watchers.forEach((w) => w?.unwatch());
},
},
t.label(
{ htmlFor: uniqueId },
(el) => {
if (!props.label) {
return t.span({ className: "txt" }, "Rule");
}
if (typeof props.label == "function") {
return props.label(el);
}
if (typeof props.label == "string") {
return t.span({ className: "txt" }, props.label);
}
return props.label;
},
t.span({ hidden: () => !props.isLocked, className: "txt superusers-label" }, "(Superusers only)"),
),
(el) => {
if (props.isLocked) {
return t.button(
{
type: "button",
className: "unlock-overlay",
disabled: () => props.disabled,
onclick: unlock,
},
t.span({ className: "txt" }, "Unlock and set custom rule"),
t.i({ className: "ri-lock-unlock-line", ariaHidden: true }),
);
}
return [
app.components.codeEditor({
id: uniqueId,
language: "pbrule",
required: () => props.required,
disabled: () => props.disabled,
value: () => props.value,
oninput: updateValue,
placeholder: () => props.placeholder,
autocomplete: props.autocomplete,
autocompleteContainer: el,
}),
t.button(
{
hidden: () => !props.nullable,
type: "button",
className: "superuser-toggle",
disabled: () => props.disabled,
onclick: lock,
},
t.i({ className: "ri-lock-line", ariaHidden: true }),
t.span({ className: "txt" }, "Set superusers only"),
),
];
},
);
return ruleField;
};

View File

@@ -0,0 +1,216 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Returns a wrapper form element with common S3 config fields.
*
* @example
* ```js
* app.components.s3ConfigFields({
* config: () => data.settings.storage,
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.s3ConfigFields = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
config: {}, // S3 config store (pass as a function in case the object is being replaced)
configKey: "s3", // used for the fields error matching
toggleLabel: "Use S3 storage",
testFilesystem: "storage",
before: null,
after: null,
});
const watchers = app.utils.extendStore(props, propsArg);
if (props.configKey.endsWith(".")) {
props.configKey = props.configKey.substring(0, props.configKey.length - 1);
}
const data = store({
originalHash: "",
originalConfig: null,
});
watchers.push(
watch(
() => props.config,
(c) => {
data.originalHash = JSON.stringify(c);
data.originalConfig = JSON.parse(data.originalHash);
},
),
);
return t.div(
{
pbEvent: "s3ConfigFields",
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => `block s3-fields s3-config-${props.configKey} ${props.className}`,
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
t.div(
{ className: "field" },
t.input({
id: () => `${props.configKey}.enabled`,
name: () => `${props.configKey}.enabled`,
type: "checkbox",
className: "switch",
checked: () => props.config.enabled,
onchange: (e) => props.config.enabled = e.target.checked,
}),
t.label({ htmlFor: () => `${props.configKey}.enabled` }, () => props.toggleLabel),
),
(el) => {
if (typeof props.before == "function") {
return props.before(el);
}
return props.before;
},
app.components.slide(
() => props.config.enabled,
t.div(
{ className: "grid m-t-base" },
t.div(
{ className: "col-lg-6" },
t.div(
{ className: "field" },
t.label({ htmlFor: () => `${props.configKey}.endpoint` }, "Endpoint"),
t.input({
id: () => `${props.configKey}.endpoint`,
name: () => `${props.configKey}.endpoint`,
type: "text",
required: () => props.config.enabled,
value: () => props.config.endpoint || "",
oninput: (e) => (props.config.endpoint = e.target.value),
}),
),
),
t.div(
{ className: "col-lg-3" },
t.div(
{ className: "field" },
t.label({ htmlFor: () => `${props.configKey}.bucket` }, "Bucket"),
t.input({
id: () => `${props.configKey}.bucket`,
name: () => `${props.configKey}.bucket`,
type: "text",
required: () => props.config.enabled,
value: () => props.config.bucket || "",
oninput: (e) => (props.config.bucket = e.target.value),
}),
),
),
t.div(
{ className: "col-lg-3" },
t.div(
{ className: "field" },
t.label({ htmlFor: () => `${props.configKey}.region` }, "Region"),
t.input({
id: () => `${props.configKey}.region`,
name: () => `${props.configKey}.region`,
type: "text",
required: () => props.config.enabled,
value: () => props.config.region || "",
oninput: (e) => (props.config.region = e.target.value),
}),
),
),
t.div(
{ className: "col-lg-6" },
t.div(
{ className: "field" },
t.label({ htmlFor: () => `${props.configKey}.accessKey` }, "Access key"),
t.input({
id: () => `${props.configKey}.accessKey`,
name: () => `${props.configKey}.accessKey`,
type: "text",
autocomplete: "off",
required: () => props.config.enabled,
value: () => props.config.accessKey || "",
oninput: (e) => (props.config.accessKey = e.target.value),
}),
),
),
t.div(
{ className: "col-lg-6" },
t.div(
{
className: () => `field ${props.config.enabled ? "" : "required"}`,
},
t.label({ htmlFor: () => `${props.configKey}.secret` }, "Secret"),
t.input({
id: () => `${props.configKey}.secret`,
name: () => `${props.configKey}.secret`,
type: "password",
autocomplete: "new-password",
value: () => props.config.secret || "",
oninput: (e) => (props.config.secret = e.target.value),
onkeyup: (e) => {
if (
e.key == "Backspace"
&& typeof props.config.secret === "undefined"
) {
props.config.secret = "";
}
},
placeholder: () => (typeof props.config.secret !== "undefined" ? "" : "* * * * * *"),
}),
),
),
t.div(
{ className: "col-lg-6", style: "min-height: 25px" },
t.div(
{ className: "field" },
t.input({
id: () => `${props.configKey}.forcePathStyle`,
name: () => `${props.configKey}.forcePathStyle`,
type: "checkbox",
checked: () => props.config.forcePathStyle || false,
onchange: (e) => (props.config.forcePathStyle = e.target.checked),
}),
t.label(
{ htmlFor: () => `${props.configKey}.forcePathStyle` },
t.span({ className: "txt" }, "Force path-style addressing"),
t.i({
className: "ri-information-line link-hint",
ariaDescription: app.attrs.tooltip(
`Forces the request to use path-style addressing, eg. "https://s3.amazonaws.com/BUCKET/KEY" instead of the default "https://BUCKET.s3.amazonaws.com/KEY".`,
),
}),
),
),
),
t.div({ className: "col-lg-6 txt-right" }, () => {
if (!props.config?.enabled || data.originalHash != JSON.stringify(props.config)) {
return;
}
return app.components.s3Test({
config: () => props.config,
testFilesystem: () => props.testFilesystem,
});
}),
),
),
(el) => {
if (typeof props.after == "function") {
return props.after(el);
}
return props.after;
},
);
};

125
ui/src/base/s3Test.js Normal file
View File

@@ -0,0 +1,125 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* A helper label element that performs live S3 connectivity tests.
*
* ```js
* app.components.s3Test({
* config: () => data.settings.backups.s3,
* testFilesystem: "backups",
* })
* ```
*
* @param {Object} propsArg
* @return {Element}
*/
window.app.components.s3Test = function(propsArg = {}) {
const testRequestKey = "s3_test_request";
const props = store({
rid: undefined,
config: null, // S3 config store
label: "Use S3 storage",
testFilesystem: "storage", // "storage" or "backups"
});
const watchers = app.utils.extendStore(props, propsArg);
const data = store({
isTesting: false,
testError: null,
get hasError() {
return !app.utils.isEmpty(data.testError);
},
});
let testDebounceId;
let testTimeoutId;
function testS3WithDebounce(timeout = 150) {
if (!props.config.enabled) {
clearTimeout(testDebounceId);
return;
}
data.isTesting = true;
clearTimeout(testDebounceId);
testDebounceId = setTimeout(() => {
testS3();
}, timeout);
}
async function testS3() {
data.isTesting = true;
if (!props.config.enabled || !props.testFilesystem) {
data.testError = null;
data.isTesting = false;
return; // nothing to test
}
// auto cancel the test request after 30sec
app.pb.cancelRequest(testRequestKey);
clearTimeout(testTimeoutId);
testTimeoutId = setTimeout(() => {
app.pb.cancelRequest(testRequestKey);
data.testError = new Error("S3 test connection timeout.");
data.isTesting = false;
}, 30000);
try {
await app.pb.props.testS3(props.testFilesystem, {
requestKey: testRequestKey,
});
data.testError = null;
data.isTesting = false;
} catch (err) {
if (!err?.isAbort) {
data.testError = err;
data.isTesting = false;
clearTimeout(testTimeoutId);
}
}
}
watchers.push(
watch(
() => props.testFilesystem && props.config,
() => testS3WithDebounce(),
),
);
return t.div(
{
pbEvent: "s3Test",
rid: props.rid,
hidden: () => !props.testFilesystem,
className: () => `label s3-test-label txt-nowrap ${data.hasError ? "warning" : "success"}`,
ariaDescription: app.attrs.tooltip(() => data.testError?.data?.message),
onunmount: () => {
clearTimeout(testTimeoutId);
clearTimeout(testDebounceId);
watchers.forEach((w) => w?.unwatch());
},
},
() => {
if (data.isTesting) {
return t.span({ className: "loader sm" });
}
if (data.hasError) {
return [
t.i({ className: "ri-error-warning-line txt-warning" }),
t.span({ className: "txt" }, "Failed to establish S3 connection"),
];
}
return [
t.i({ className: "ri-checkbox-circle-line txt-success" }),
t.span({ className: "txt" }, "S3 connected successfully"),
];
},
);
};

View File

@@ -0,0 +1,153 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Helper button that shows a dropdown with previous search attempts.
*
* @example
* ```js
* app.components.searchHistoryButton({
* historyKey: "anything", // localStorage history key
* value: () => data.search,
* onselect: (historyVal) => data.search = historyVal,
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.searchHistoryButton = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
value: undefined,
historyKey: "default",
max: 15,
openInNewTabParam: "filter",
btnClassName: "btn sm pill secondary transparent p-r-5",
onselect: function(val) {},
});
const watchers = app.utils.extendStore(props, propsArg);
const history = store({
items: app.utils.getLocalHistory(props.historyKey, []),
});
function addToHistory(val) {
removeFromHistory(val);
history.items.unshift(val);
}
function removeFromHistory(val) {
app.utils.removeByValue(history.items, val);
}
const uniqueId = "history_dropdown_" + app.utils.randomString();
watchers.push(
watch(
() => props.value,
(val) => {
if (val) {
addToHistory(val);
}
},
),
);
watchers.push(
watch(() => {
if (history.items.length > props.max) {
history.items = history.items.slice(0, props.max);
}
app.utils.saveLocalHistory(props.historyKey, history.items);
}),
);
const dropdown = t.div(
{
id: uniqueId,
className: "dropdown sm left nowrap history-searchbar-dropdown",
popover: "hint",
onclick: (e) => {
e.stopPropagation();
return false;
},
},
t.div({ className: "block p-5" }, t.small({ className: "txt-hint" }, "Search history")),
() => {
if (!history.items?.length) {
return t.div(
{ rid: "no-history", className: "block p-5" },
t.span(null, "Your recent searches will show up here."),
);
}
return history.items.slice(0, props.max).map((h) => {
return t.button(
{
type: "button",
className: "dropdown-item txt-code",
onclick: () => {
dropdown.hidePopover();
props.onselect?.(h);
addToHistory(h);
},
onauxclick: () => {
if (props.openInNewTabParam) {
addToHistory(h);
dropdown.hidePopover();
const url = app.utils.replaceHashQueryParams(
{
[props.openInNewTabParam]: h,
},
false,
);
window.open(url, "_blank");
}
},
},
t.span({ className: "txt-ellipsis", title: h, textContent: h }),
t.small(
{
role: "button",
className: "remove-btn link-hint m-l-auto p-l-5 p-r-5",
onauxclick: (e) => {
e.stopPropagation();
return false;
},
onclick: (e) => {
e.stopPropagation();
removeFromHistory(h);
return false;
},
},
t.i({ className: "ri-close-line" }),
),
);
});
},
);
return t.button(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
type: "button",
className: () => props.btnClassName,
"html-popovertarget": uniqueId,
onunmount: () => {
watchers?.forEach((w) => w?.unwatch());
},
},
t.i({ className: "ri-search-line" }),
t.i({ className: "ri-arrow-drop-down-line" }),
dropdown,
);
};

128
ui/src/base/searchbar.js Normal file
View File

@@ -0,0 +1,128 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Generic page searchbar element.
*
* @example
* ```js
* app.components.searchbar({
* value: () => data.search,
* onsubmit: (newValue) => data.search = newValue,
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.searchbar = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
value: "",
className: "",
placeholder: "Search...",
disabled: false,
historyKey: "",
autocomplete: undefined, // Array<string|Object> | function(word): Array<string|Object>,
onsubmit: (newValue) => {},
});
const watchers = app.utils.extendStore(props, propsArg, "autocomplete");
const local = store({
value: "",
});
function submit() {
props.value = local.value;
props.onsubmit?.(local.value);
}
function clear() {
local.value = "";
submit();
}
watchers.push(
// init and local sync changes
watch(
() => props.value,
(searchTerm) => {
local.value = searchTerm;
},
),
);
return t.form(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => `fields searchbar ${props.className}`,
onsubmit: (e) => {
e.preventDefault();
submit();
},
onunmount: (el) => {
watchers.forEach((w) => w?.unwatch());
},
},
() => {
if (!props.historyKey) {
return;
}
return t.div(
{ className: "field addon p-l-5" },
app.components.searchHistoryButton({
historyKey: () => props.historyKey,
value: () => props.value,
onselect: (val) => {
local.value = val;
submit();
},
}),
);
},
t.div(
{ className: "field" },
app.components.codeEditor({
singleLine: true,
language: "pbrule",
className: () => props.historyKey ? "p-l-5" : "p-l-20",
placeholder: () => props.placeholder,
disabled: () => props.disabled,
value: () => local.value,
oninput: (val) => (local.value = val),
autocomplete: props.autocomplete,
}),
),
() => {
if (props.value.length > 0 || local.value.length > 0) {
return t.div(
{ rid: "search-ctrls", className: "field addon p-r-5" },
t.button(
{
type: "submit",
className: "btn sm pill warning",
hidden: () => props.value == local.value,
},
"Search",
),
t.button(
{
type: "button",
className: "btn sm pill secondary transparent",
onclick: () => clear(),
},
"Clear",
),
);
}
},
);
};

331
ui/src/base/select.js Normal file
View File

@@ -0,0 +1,331 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Generic custom select element.
* If max > 1 the select is multiple, otherwise - single (default).
*
* Note that if label or selected are custom DOM elements they need to be
* wrapped in a function to allow recreation when toggling the select options.
*
* @example
* ```js
* app.components.select({
* options: [
* { value: "opt1", label: "Opt 1" },
* { value: "opt2", label: "Opt 2", selected: "Opt 2 selected label" },
* { value: "opt2", label: () => t.div(null, "Custom element") },
* ],
* value: () => data.selected,
* onchange: (opts) => data.selected = opts.map((opt) => opt.value),
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.select = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined, // used for associating with a label
name: undefined, // used for error matching
hidden: undefined,
inert: undefined,
className: "",
value: undefined,
options: [], // [{value, label?, selected?}, ...]
before: null,
after: null,
max: 1,
searchThreshold: 6,
required: false,
disabled: false,
placeholder: "- Select -",
noItemsFoundText: "No items found",
onchange: function(selectedOpts) {},
ondropdowntoggle: function(e) {},
});
const watchers = app.utils.extendStore(props, propsArg);
if (props.max <= 0) {
props.max = 1;
}
const internalData = store({
selected: [],
search: "",
get hasSearch() {
return internalData.search?.length > 0;
},
get allowRemove() {
return !props.disabled && (!props.required || props.max > 1);
},
});
function syncSelected() {
if (typeof props.value === "undefined") {
return; // nothing to sync
}
const optVals = app.utils.toArray(props.value, true);
const cappedOptVals = optVals.slice(0, props.max || 1);
if (optVals.length != cappedOptVals.length) {
console.warn(
`[select] the provided select values (${optVals.length}) are more than the allowed max selected options (${cappedOptVals.length}):`,
optVals,
);
props.value = props.max > 1 ? cappedOptVals : cappedOptVals[0];
}
internalData.selected = optVals
.map((value) => {
return props.options.find((opt) => opt.value === value);
})
.filter(Boolean);
}
watchers.push(
watch(
() => props.value,
() => syncSelected(),
),
);
async function toggle(opt) {
const idx = internalData.selected.findIndex((o) => o.value === opt.value);
if (idx >= 0) {
if (!internalData.allowRemove) {
dropdown?.hidePopover();
return; // no change
}
internalData.selected.splice(idx, 1);
} else {
// clear last redundant elements (leaving place for the new selected)
let toRemove = internalData.selected.length - props.max;
while (toRemove >= 0) {
internalData.selected.pop();
toRemove--;
}
internalData.selected.push(opt);
}
if (props.max <= 1) {
dropdown?.hidePopover();
}
if (props.onchange) {
await props.onchange(internalData.selected);
syncSelected(); // manually sync in case in the onchange handler the value didn't change
}
// trigger custom change event for clearing field errors
if (selectedContainer?.isConnected) {
selectedContainer.dispatchEvent(
new CustomEvent("change", {
detail: internalData.selected,
bubbles: true,
}),
);
}
}
function isSelected(opt) {
return internalData.selected.findIndex((o) => o.value === opt.value) >= 0;
}
const searchInput = t.input({
type: "text",
placeholder: "Search...",
value: () => internalData.search,
oninput: (e) => (internalData.search = e.target.value),
});
function clearSearch(focus = false) {
internalData.search = "";
if (focus) {
searchInput?.focus();
}
}
const noItemsFoundElem = t.div({ className: "txt-hint txt-center m-0 p-5", hidden: true }, props.noItemsFoundText);
async function toggleNoItemsFoundElem() {
if (!dropdown) {
return;
}
await new Promise((r) => setTimeout(r, 0));
if (dropdown.querySelector(".select-option:not([hidden])")) {
noItemsFoundElem.hidden = true;
} else {
noItemsFoundElem.hidden = false;
}
}
const dropdown = t.div(
{
tabIndex: -1,
popover: "auto",
className: "dropdown",
onbeforetoggle: (e) => {
if (e.newState == "closed") {
clearSearch();
}
return props.ondropdowntoggle?.(e);
},
},
t.div(
{
className: "fields dropdown-search",
hidden: () => props.options.length < props.searchThreshold,
},
t.div({ className: "field" }, searchInput),
t.div(
{
className: "field addon p-r-5",
hidden: () => !internalData.hasSearch,
},
t.button(
{
type: "button",
className: "btn sm secondary transparent circle",
onclick: () => clearSearch(true),
},
t.i({ className: "ri-close-line" }),
),
),
),
() => props.before?.__raw || props.before,
() => {
return props.options.map((opt) => {
return t.button(
{
type: "button",
className: () => `dropdown-item select-option ${isSelected(opt) ? "active" : ""}`,
onclick: () => {
toggle(opt);
return false;
},
},
opt.label || opt.value,
);
});
},
noItemsFoundElem,
() => props.after?.__raw || props.after,
);
const selectedContainer = t.button(
{
type: "button",
id: () => props.id,
name: () => props.name,
disabled: () => props.disabled,
className: () => `selected-container ${props.className}`,
popoverTargetElement: dropdown,
onclick: (e) => {
e.stopPropagation();
},
},
() => {
if (!internalData.selected.length) {
return t.span({ rid: "selected-placeholder", className: "placeholder" }, () => props.placeholder);
}
return internalData.selected.map((opt) => {
return t.div({ className: "selected-item" }, opt.selected || opt.label || opt.value, () => {
if (!internalData.allowRemove) {
return;
}
return t.i({
tabIndex: -1,
role: "button",
className: "ri-close-line link-hint btn-option-unset",
ariaDescription: app.attrs.tooltip("Unset", "left"),
onclick: () => {
toggle(opt);
return false;
},
});
});
});
},
);
watchers.push(
watch(
() => props.options,
() => {
toggleNoItemsFoundElem();
},
),
);
// search watcher
let searchDebounce;
watchers.push(
watch(
() => internalData.search,
() => {
const normalizedSearch = internalData.search.toLowerCase().replaceAll(" ", "");
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
const options = dropdown.querySelectorAll(".select-option");
if (!normalizedSearch.length) {
options.forEach((opt) => (opt.hidden = false));
} else {
options.forEach((opt) => {
const txt = opt.textContent.toLowerCase().replaceAll(" ", "");
if (!txt.includes(normalizedSearch)) {
opt.hidden = true;
} else {
opt.hidden = false;
}
});
}
toggleNoItemsFoundElem();
}, 100);
},
),
);
return t.div(
{
rid: props.rid,
hidden: () => props.hidden,
inert: () => props.inert,
onmount: (el) => {
el.addEventListener("focusout", function(e) {
if (!e.relatedTarget || !el.contains(e.relatedTarget)) {
dropdown?.hidePopover();
}
});
},
onunmount: () => {
clearTimeout(searchDebounce);
watchers.forEach((w) => w.unwatch());
},
className: () => {
return [
"input",
"select",
props.max > 1 ? "multiple" : "single",
props.disabled ? "disabled" : "",
props.required ? "required" : "",
].join(" ");
},
},
selectedContainer,
dropdown,
);
};

38
ui/src/base/slide.js Normal file
View File

@@ -0,0 +1,38 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Wraps the children elements in a collapsible container.
*
* @example
* ```js
* app.components.slide(
* () => data.showToggle,
* t.div(null, "child1..."),
* t.div(null, "child2..."),
* )
* ```
*
* @param {function} boolFunc Boolean function that indicates whether the container is visible or not.
* @param {Array<Element>} [children]
* @return {Element}
*/
window.app.components.slide = function(boolFunc, ...children) {
let initTimeoutId;
return t.div(
{
className: (el) => `block slide-block ${boolFunc?.(el) ? "" : "hidden"}`,
onmount: (el) => {
// add a ready attribute with slight delay to avoid @starting-style flickering
initTimeoutId = setTimeout(() => {
el?.setAttribute("data-slide", "1");
}, 200);
},
onunmount: () => {
clearTimeout(initTimeoutId);
},
},
...children,
);
};

188
ui/src/base/sortable.js Normal file
View File

@@ -0,0 +1,188 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Creates a reactive single level sortable container (nested sortables are not supported).
*
* @example
* ```js
* app.components.sortable({
* data: () => data.list,
* dataItem: (item) => t.strong(null, "ID:", () => item.id),
* })
* ```
*
* @param {Object>} [propsArg]
* @return {Element}
*/
window.app.components.sortable = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
data: [],
dataItem: function(item, i, parent) {
return t.span(null, "Item " + i);
},
onchange: function(sortedList, fromIndex, toIndex) {},
handle: "", // specific handle selector (if not set attached to the entire list item)
before: undefined,
after: undefined,
});
const watchers = app.utils.extendStore(props, propsArg);
function initSortEvents(listEl) {
function clearDragData() {
listEl.querySelectorAll(":scope > [data-dragstart=\"true\"]")?.forEach((item) => {
item.dataset.dragstart = false;
});
listEl.querySelectorAll(":scope > [data-dragover=\"true\"]")?.forEach((item) => {
item.dataset.dragover = false;
});
}
// drag
// ---
listEl.addEventListener("dragstart", (e) => {
if (props.handle && !e.target.closest(props.handle)) {
e.preventDefault();
return;
}
const child = closestChild(listEl, e.target);
if (child) {
child.dataset.dragstart = true;
}
});
listEl.addEventListener("dragenter", (e) => {
for (let child of listEl.children) {
if (child.dataset.dragover) {
child.dataset.dragover = false;
}
}
const to = closestChild(listEl, e.target);
if (to) {
to.dataset.dragover = true;
}
});
listEl.addEventListener("dragend", (e) => {
clearDragData();
});
// drop
// ---
// prevent default to allow drop
// (https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/drop_event)
listEl.addEventListener("dragover", (e) => {
e.preventDefault();
});
listEl.addEventListener("drop", (e) => {
if (!props.onchange) {
clearDragData();
return;
}
const from = listEl.querySelector(":scope > [data-dragstart=\"true\"]");
const to = closestChild(listEl, e.target);
clearDragData();
if (!from || !to || to == from) {
return;
}
const fromIndex = childIndex(from);
const toIndex = childIndex(to);
const clone = props.data.slice();
const deleted = clone.splice(fromIndex, 1);
clone.splice(toIndex, 0, deleted[0]);
props.onchange(clone, fromIndex, toIndex);
});
}
function childIndex(node) {
if (!node?.parentNode) {
return -1;
}
for (let i = 0; i < node.parentNode.children.length; i++) {
if (node.parentNode.children[i] == node) {
return i;
}
}
return -1;
}
function closestChild(parent, node) {
if (!node || !node.parentNode) {
return null;
}
if (node.parentNode == parent) {
return node;
}
return closestChild(parent, node.parentNode);
}
return t.div(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => props.className,
onmount: (listEl) => {
initSortEvents(listEl);
},
onunmount: (listEl) => {
watchers.forEach((w) => w?.unwatch());
},
},
(el) => {
if (typeof props.before == "function") {
return props.before(el);
}
return props.before;
},
(el) => {
const children = [];
for (let i = 0; i < props.data.length; i++) {
let child = props.dataItem(props.data[i], i, el);
if (!child) {
continue;
}
if (props.handle) {
const handle = child.querySelector(props.handle);
if (handle) {
handle.draggable = true;
}
} else {
child.draggable = true;
}
children.push(child);
}
return children;
},
(el) => {
if (typeof props.after == "function") {
return props.after(el);
}
return props.after;
},
);
};

542
ui/src/base/tinymce.js Normal file
View File

@@ -0,0 +1,542 @@
import cssVars from "@/css/vars.css?inline";
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Creates a new TinyMCE editor element.
*
* @example
* ```js
* const data = store({ value: "" })
*
* app.components.tinymce({
* value: () => data.value,
* onchange: (val) => data.value = val,
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.tinymce = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
name: undefined,
className: "",
value: "",
readonly: false,
disabled: false,
required: false,
convertURLs: false,
onchange: function(val) {},
onbeforeinit: function(opts) {},
onafterinit: function(editor) {},
});
const watchers = app.utils.extendStore(props, propsArg);
let editorRef;
let textarea;
let oldChange;
watchers.push(watch(() => props.value, setEditorContentValue));
watchers.push(watch(() => props.disabled || props.readonly, setDisabled));
watchers.push(watch(() => props.convertURLs, setConvertURLs));
watchers.push(watch(() => app.store.activeColorScheme, setEditorBodyColorScheme));
// generic error handling wrapper to prevent throws from tinymce API calls from crashing the UI
function catchError(fn) {
try {
fn();
} catch (err) {
console.warn("tinymce error:", err);
}
}
function setEditorContentValue() {
if (oldChange != props.value) {
catchError(() => {
editorRef?.setContent("" + (props.value || "")); // stringify and normalize
});
}
}
function setDisabled() {
catchError(() => {
// https://www.tiny.cloud/docs/tinymce/6/editor-important-options/#readonly
editorRef?.mode?.set(props.disabled || props.readonly ? "readonly" : "design");
});
}
function setConvertURLs() {
catchError(() => {
editorRef?.options?.set("convert_urls", !!props.convertURLs);
});
}
function setEditorBodyColorScheme() {
catchError(() => {
editorRef?.getBody()?.setAttribute("data-color-scheme", app.store.activeColorScheme);
});
}
let changeTimeoutId;
function triggerOnchangeWithDebounce(debounce = 150) {
clearTimeout(changeTimeoutId);
changeTimeoutId = setTimeout(triggerOnchange, debounce);
}
function triggerOnchange() {
if (!editorRef) {
return;
}
clearTimeout(changeTimeoutId);
let content;
catchError(() => {
content = editorRef.getContent();
});
if (content == oldChange) {
return; // no change
}
oldChange = content;
props.onchange?.(content);
// trigger custom change event for clearing field errors
textarea?.dispatchEvent(
new CustomEvent("change", {
detail: { editor: editorRef, content: content },
bubbles: true,
}),
);
}
function destroyEditor() {
if (!editorRef) {
return; // already removed or not initialized yet
}
clearTimeout(changeTimeoutId);
// workaround for https://github.com/tinymce/tinymce/issues/9377
editorRef.dom?.unbind(document);
catchError(() => {
window.tinymce?.remove(editorRef);
});
editorRef = null;
oldChange = null;
}
async function initEditor(el) {
await loadTinyMCE();
destroyEditor();
// removed while loading
if (!el.isConnected) {
return;
}
const opts = {
target: el,
content_style: cssVars,
branding: false,
promotion: false,
menubar: false,
resize: false,
min_height: 265,
height: 265,
max_height: 600,
sandbox_iframes: true,
convert_unsafe_embeds: true, // GHSA-5359
codesample_global_prismjs: true,
convert_urls: false,
relative_urls: false,
autoresize_bottom_margin: 30,
media_poster: false,
media_alt_source: false,
codesample_languages: [
{ text: "HTML/XML", value: "markup" },
{ text: "CSS", value: "css" },
{ text: "SQL", value: "sql" },
{ text: "JavaScript", value: "javascript" },
{ text: "Go", value: "go" },
{ text: "Dart", value: "dart" },
{ text: "Zig", value: "zig" },
{ text: "Rust", value: "rust" },
{ text: "Lua", value: "lua" },
{ text: "PHP", value: "php" },
{ text: "Ruby", value: "ruby" },
{ text: "Python", value: "python" },
{ text: "Java", value: "java" },
{ text: "C", value: "c" },
{ text: "C#", value: "csharp" },
{ text: "C++", value: "cpp" },
// other non-highlighted languages
{ text: "Markdown", value: "markdown" },
{ text: "Swift", value: "swift" },
{ text: "Kotlin", value: "kotlin" },
{ text: "Elixir", value: "elixir" },
{ text: "Scala", value: "scala" },
{ text: "Julia", value: "julia" },
{ text: "Haskell", value: "haskell" },
],
plugins: [
"autolink",
"autoresize",
"code",
"codesample",
"directionality",
"image",
"link",
"lists",
"media",
"table",
"wordcount",
],
toolbar:
"styles | alignleft aligncenter alignright | bold italic forecolor backcolor | bullist numlist | link table media_picker codesample | direction code",
paste_postprocess: (editor, args) => {
cleanupPastedNode(args.node);
},
// @see https://www.tiny.cloud/docs/tinymce/6/file-image-upload/#interactive-example
file_picker_types: "image",
file_picker_callback: (callback, value, meta) => {
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("accept", "image/*");
input.addEventListener("change", (e) => {
const file = e.target.files[0];
const reader = new FileReader();
reader.addEventListener("load", () => {
if (!tinymce) {
return;
}
// We need to register the blob in TinyMCEs image blob registry.
// In future TinyMCE version this part will be handled internally.
const id = "blobid" + new Date().getTime();
const blobCache = tinymce.activeEditor.editorUpload.blobCache;
const base64 = reader.result.split(",")[1];
const blobInfo = blobCache.create(id, file, base64);
blobCache.add(blobInfo);
// call the callback and populate the Title field with the file name
callback(blobInfo.blobUri(), { title: file.name });
});
reader.readAsDataURL(file);
});
input.click();
},
setup: (editor) => {
editorRef = editor;
editor.on("init", (e) => {
props.onafterinit?.(editorRef);
setConvertURLs();
setDisabled();
setEditorBodyColorScheme();
setEditorContentValue();
});
// propagate save shortcut to the parent
editor.on("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.code == "KeyS" && editor.formElement) {
e.preventDefault();
e.stopPropagation();
editor.formElement.dispatchEvent(new KeyboardEvent("keydown", e));
}
});
editor.on("input", (e) => {
triggerOnchangeWithDebounce();
});
editor.on("change", (e) => {
triggerOnchange();
});
registerDirectionButton(editor);
registerMediaButton(editor);
},
};
if (props.readonly) {
opts.statusbar = false;
opts.min_height = 30;
opts.height = 30;
opts.max_height = 500;
opts.autoresize_bottom_margin = 5;
opts.resize = false;
opts.toolbar = false;
opts.plugins = ["autoresize", "codesample", "directionality"];
}
if (props.onbeforeinit) {
props.onbeforeinit(opts);
}
window.tinymce.init(opts);
}
textarea = t.textarea({
name: () => props.name,
onmount: (el) => {
initEditor(el).catch((err) => {
console.warn("tinymce init error:", err);
});
},
onunmount: destroyEditor,
});
return t.div(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => `pb-tinymce ${props.className}`,
"html-required": () => props.required || undefined, // set on the parent because the textarea will be hidden
onunmount: (el) => {
clearTimeout(changeTimeoutId);
watchers.forEach((w) => w?.unwatch());
textarea = null;
},
},
textarea,
);
};
function registerDirectionButton(editor) {
const lastDirectionKey = "pbTinymceLastDirection";
// load last used text direction for blank editors
editor.on("init", () => {
const lastDirection = window.localStorage.getItem(lastDirectionKey);
if (!editor.isDirty() && editor.getContent() == "" && lastDirection == "rtl") {
editor.execCommand("mceDirectionRTL");
}
});
// text direction dropdown
editor.ui.registry.addMenuButton("direction", {
icon: "visualchars",
tooltip: "Direction",
fetch: (callback) => {
const items = [
{
type: "menuitem",
text: "LTR content",
icon: "ltr",
onAction: () => {
window?.localStorage?.setItem(lastDirectionKey, "ltr");
editor.execCommand("mceDirectionLTR");
},
},
{
type: "menuitem",
text: "RTL content",
icon: "rtl",
onAction: () => {
window?.localStorage?.setItem(lastDirectionKey, "rtl");
editor.execCommand("mceDirectionRTL");
},
},
];
callback(items);
},
});
}
function registerMediaButton(editor) {
editor.ui.registry.addMenuButton("media_picker", {
tooltip: "Insert media",
icon: "embed",
fetch: (callback) => {
const items = [
{
type: "menuitem",
text: "Inline image (Base64)",
onAction: () => {
editor.execCommand("mceImage");
},
},
{
type: "menuitem",
text: "Media from collection",
onAction: () => {
app.modals.openRecordFilePicker({
fileTypes: ["image", "audio", "video"],
onselect: (selected) => {
const url = app.pb.files.getURL(selected.record, selected.name, {
thumb: selected.thumb || undefined,
});
// just an extra precaution in case the editor fail for whatever reason to sanitize the inserted raw htmls
const escapedName = app.utils.encodeEntities(selected.name);
const escapedUrl = app.utils.encodeEntities(url);
if (app.utils.hasImageExtension(selected.name)) {
editor?.execCommand("InsertImage", false, url);
} else if (app.utils.hasAudioExtension(selected.name)) {
editor?.execCommand(
"InsertHTML",
false,
`<audio controls src="${escapedUrl}"></audio>`,
);
} else if (app.utils.hasVideoExtension(escapedName)) {
editor?.execCommand(
"InsertHTML",
false,
`
<video controls width="300">
<source src="${escapedUrl}" />
<p>Download: <a href="${escapedUrl}" download="${escapedName}">${escapedName}</a>.</p>
</video>
`,
);
}
},
});
},
},
{
type: "menuitem",
text: "Manual embed",
onAction: () => {
tinymce.activeEditor.execCommand("mceMedia");
},
},
];
callback(items);
},
});
}
const allowedPasteNodes = [
"DIV",
"P",
"A",
"EM",
"B",
"STRONG",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"TABLE",
"TR",
"TD",
"TH",
"TBODY",
"THEAD",
"TFOOT",
"BR",
"HR",
"Q",
"SUP",
"SUB",
"DEL",
"IMG",
"OL",
"UL",
"LI",
"CODE",
];
function cleanupPastedNode(node) {
if (!node) {
return; // nothing to cleanup
}
for (const child of node.children) {
cleanupPastedNode(child);
}
if (!allowedPasteNodes.includes(node.tagName)) {
unwrap(node);
} else {
node.removeAttribute("style");
node.removeAttribute("class");
}
}
function unwrap(node) {
let parent = node.parentNode;
// move children outside of the parent node
while (node.firstChild) {
parent.insertBefore(node.firstChild, node);
}
// remove the now empty parent element
parent.removeChild(node);
}
async function loadTinyMCE() {
// already loaded
if (typeof window.tinymce != "undefined") {
return;
}
const scriptId = "lazy-tinymce-js";
// in the process of being loaded
if (document.getElementById(scriptId)) {
return new Promise((resolve, reject) => {
function cleanup() {
document.removeEventListener("tinymceLoadSuccess", successHandler);
document.removeEventListener("tinymceLoadError", errorHandler);
}
const successHandler = function() {
cleanup();
resolve();
};
const errorHandler = function(e) {
cleanup();
reject(e?.details);
};
document.addEventListener("tinymceLoadSuccess", successHandler);
document.addEventListener("tinymceLoadError", errorHandler);
});
}
return new Promise((resolve, reject) => {
document.head.querySelector("#shablon-script").after(
t.script({
id: scriptId,
src: import.meta.env.BASE_URL + "libs/tinymce/tinymce.min.js",
onload: () => {
resolve();
},
onerror: (err) => {
console.warn("failed to load tinymce.min.js:", err);
reject(err);
},
}),
);
}).then(() => {
document.dispatchEvent(new CustomEvent("tinymceLoadSuccess"));
}).catch((err) => {
document.dispatchEvent(new CustomEvent("tinymceLoadError", { detail: err }));
});
}

158
ui/src/base/toast.js Normal file
View File

@@ -0,0 +1,158 @@
const toasts = new Map();
const toastsContainer = t.div({ className: "toasts-container" });
/**
* Removes a single notification by its reference (key or content).
*
* @param {string|Node} toastRef
* @param {boolean} [animate]
*/
function removeToast(toastRef, animate = true) {
const toast = toasts.get(toastRef);
if (!toast || !toast.isConnected) {
return;
}
toasts.delete(toastRef);
clearTimeout(toast._removeTimeout);
if (animate) {
toast.classList.add("removing");
setTimeout(() => {
toast.remove();
}, 300);
} else {
toast.remove();
}
}
/**
* Removes all registered notifications.
*
* @param {boolean} [animate]
*/
function removeAllToasts(animate = true) {
toasts.forEach((_, key) => {
window.app.toasts.remove(key, animate);
});
}
/**
* Adds "info" notification.
*
* @see {@link addToast}
*/
function infoToast(textOrElem, options = {}) {
options.type = "info";
options.duration = options.duration || 3000;
addToast(textOrElem, options);
}
/**
* Adds "success" notification.
*
* @see {@link addToast}
*/
function successToast(textOrElem, options = {}) {
options.type = "success";
options.duration = options.duration || 3000;
addToast(textOrElem, options);
}
/**
* Adds "error" notification.
*
* @see {@link addToast}
*/
function errorToast(textOrElem, options = {}) {
options.type = "error";
options.duration = options.duration || 3500;
addToast(textOrElem, options);
}
/**
* Creates and registers a new toast notification.
*
* @param {string|Node} textOrElem The content of the notification as plain text or DOM node.
* @param {object} [options] Toast options.
* @param {number} [options.duration] Duration time in ms the notifaction would be active.
* @param {string} [options.key] Optional identifier that could be used to manually remove the specific notification (default to `textOrElem`).
* @param {string} [options.type] The CSS class type of the notification.
*/
function addToast(textOrElem, options = {}) {
options = Object.assign({ duration: 3000, key: undefined, type: "info" }, options);
if (!toastsContainer.isConnected) {
document.body.appendChild(toastsContainer);
}
const toastRef = options.key || textOrElem;
if (toasts.has(toastRef)) {
removeToast(toastRef, false);
}
function initRemoveTimer(el) {
if (el?._removeTimeout) {
clearTimeout(el?._removeTimeout);
}
el._removeTimeout = setTimeout(() => {
removeToast(toastRef);
}, options.duration);
}
let newToast = t.div(
{
className: `toast ${options.type || ""}`,
onmount: (el) => {
initRemoveTimer(el);
},
onunmount: (el) => {
if (el?._removeTimeout) {
clearTimeout(el?._removeTimeout);
newToast = null;
}
},
onmouseover: () => {
clearTimeout(newToast?._removeTimeout);
},
onmouseout: () => {
initRemoveTimer(newToast);
},
},
t.div(
{ className: "toast-container" },
t.div({ className: "toast-icon" }),
t.div(
{ className: "toast-content" },
textOrElem,
t.button(
{
className: "m-l-auto btn circle sm transparent secondary toast-remove",
title: "Clear",
onclick: () => removeToast(toastRef),
},
t.i({ className: "ri-close-line" }),
),
),
),
);
toasts.set(toastRef, newToast);
toastsContainer.prepend(newToast);
}
// -------------------------------------------------------------------
window.app = window.app || {};
window.app.toasts = window.app.toasts || {};
window.app.toasts.info = infoToast;
window.app.toasts.error = errorToast;
window.app.toasts.success = successToast;
window.app.toasts.remove = removeToast;
window.app.toasts.removeAll = removeAllToasts;

153
ui/src/base/tooltip.js Normal file
View File

@@ -0,0 +1,153 @@
const tolerance = 5;
const tooltip = t.div({
popover: "manual",
className: "pb-tooltip",
});
document.body.appendChild(tooltip);
function updateTooltipPosition(node, position) {
let nodeRect = node.getBoundingClientRect();
tooltip.setAttribute("data-position", position);
// reset tooltip position
tooltip.style.top = "0px";
tooltip.style.left = "0px";
// note: doesn't use getBoundingClientRect() here because the
// tooltip could be animated/scaled/transformed and we need the real size
let tooltipHeight = tooltip.offsetHeight;
let tooltipWidth = tooltip.offsetWidth;
let top = 0;
let left = 0;
// calculate tooltip position based
if (position == "left") {
top = nodeRect.top + nodeRect.height / 2 - tooltipHeight / 2;
left = nodeRect.left - tooltipWidth - tolerance;
} else if (position == "right") {
top = nodeRect.top + nodeRect.height / 2 - tooltipHeight / 2;
left = nodeRect.right + tolerance;
} else if (position == "top") {
top = nodeRect.top - tooltipHeight - tolerance;
left = nodeRect.left + nodeRect.width / 2 - tooltipWidth / 2;
} else if (position == "top-left") {
top = nodeRect.top - tooltipHeight - tolerance;
left = nodeRect.left;
} else if (position == "top-right") {
top = nodeRect.top - tooltipHeight - tolerance;
left = nodeRect.right - tooltipWidth;
} else if (position == "bottom-left") {
top = nodeRect.top + nodeRect.height + tolerance;
left = nodeRect.left;
} else if (position == "bottom-right") {
top = nodeRect.top + nodeRect.height + tolerance;
left = nodeRect.right - tooltipWidth;
} else {
// bottom
top = nodeRect.top + nodeRect.height + tolerance;
left = nodeRect.left + nodeRect.width / 2 - tooltipWidth / 2;
}
// right edge boundary
if (left + tooltipWidth > document.documentElement.clientWidth) {
left = document.documentElement.clientWidth - tooltipWidth;
}
// left edge boundary
left = left >= 0 ? left : 0;
// bottom edge boundary
if (top + tooltipHeight > document.documentElement.clientHeight) {
top = document.documentElement.clientHeight - tooltipHeight;
}
// top edge boundary
top = top >= 0 ? top : 0;
tooltip.style.top = top + "px";
tooltip.style.left = left + "px";
}
function hideTooltip() {
tooltip.hidePopover();
}
function showTooltip(node, text, position) {
if (!node || !text) {
hideTooltip();
return;
}
tooltip.showPopover();
tooltip.textContent = text;
updateTooltipPosition(node, position);
}
document.body.addEventListener("mouseleave", () => {
hideTooltip();
});
function tooltipAction(textOrFunc, position = "top") {
return (el) => {
if (!el._tooltipText) {
el._tooltipText = store({
value: "",
});
let tooltipTextWatcher;
function showEventHandler() {
tooltipTextWatcher?.unwatch();
tooltipTextWatcher = watch(
() => el._tooltipText.value,
async (result) => {
showTooltip(el, result, position);
},
);
}
async function hideEventHandler() {
tooltipTextWatcher?.unwatch();
tooltipTextWatcher = null;
hideTooltip();
}
el.addEventListener("mouseenter", showEventHandler);
el.addEventListener("focusin", showEventHandler);
el.addEventListener("mouseleave", hideEventHandler);
el.addEventListener("focusout", hideEventHandler);
el.addEventListener("blur", hideEventHandler);
const originalOnunmount = el.onunmount;
el.onunmount = (el) => {
tooltipTextWatcher?.unwatch();
el._tooltipText = null;
el?.removeEventListener("mouseenter", showEventHandler);
el?.removeEventListener("focusin", showEventHandler);
el?.removeEventListener("mouseleave", hideEventHandler);
el?.removeEventListener("focusout", hideEventHandler);
el?.removeEventListener("blur", hideEventHandler);
originalOnunmount(el);
};
}
if (typeof textOrFunc == "function") {
el._tooltipText.value = textOrFunc();
} else {
el._tooltipText.value = textOrFunc;
}
return el._tooltipText.value;
};
}
window.app = window.app || {};
window.app.attrs = window.app.attrs || {};
window.app.attrs.tooltip = tooltipAction;

View File

@@ -0,0 +1,83 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Thumb preview element for an uploaded File.
* For non-image file the thumb is an icon representing the file type.
*
* @example
* ```js
* app.components.uploadedFileThumb({
* file: new File(...),
* })
* ```
*
* @param {Object} [propsArg]
* @return {Element}
*/
window.app.components.uploadedFileThumb = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
file: undefined, // File
imageWidth: 100, // image thumb width
imageHeight: 100, // image thumb height
extraClasses: "sm", // any .thumb related classes
});
const watchers = app.utils.extendStore(props, propsArg);
const data = store({
thumbSrc: undefined,
});
watchers.push(
watch(
() => [props.file, props.imageWidth, props.imageHeight],
() => {
if (app.utils.hasImageExtension(props.file?.name)) {
app.utils
.generateThumb(props.file, props.imageWidth, props.imageHeight)
.then((url) => {
data.thumbSrc = url;
})
.catch((err) => {
console.warn("unable to generate thumb:", err);
data.thumbSrc = undefined;
});
} else {
data.thumbSrc = undefined;
}
},
),
);
return t.div(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => `thumb ${props.extraClasses}`,
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
() => {
const fileType = app.utils.getFileType(props.file?.name);
if (fileType == "image" && data.thumbSrc) {
return t.img({
draggable: false,
loading: "lazy",
alt: () => "Thumb of " + props.file.name,
src: data.thumbSrc,
});
}
return t.i({ className: app.utils.fileTypeIcons[fileType] || "ri-file-line" });
},
);
};

View File

@@ -0,0 +1,104 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
window.app.components.addCollectionFieldButton = function(collection) {
const uniqueId = "new_field_" + app.utils.randomString();
function addNewField(fieldType) {
const field = {
id: "",
name: getUniqueFieldName(fieldType),
type: fieldType,
system: false,
hidden: false,
presentable: false,
required: false,
__focus: true, // see fieldSettings
};
collection.fields = collection.fields || [];
// if the collection has created/updated last fields,
// insert before the first autodate field, otherwise - append
const idx = collection.fields.findLastIndex((f) => f.type != "autodate");
if (field.type != "autodate" && idx >= 0) {
collection.fields.splice(idx + 1, 0, field);
} else {
collection.fields.push(field);
}
}
function getUniqueFieldName(baseName = "") {
let result = baseName;
let counter = 2;
let suffix = baseName.match(/\d+$/)?.[0] || ""; // extract numeric suffix
// name without the suffix
let base = suffix ? baseName.substring(0, baseName.length - suffix.length) : baseName;
while (hasFieldWithName(result)) {
result = base + ((suffix << 0) + counter);
counter++;
}
return result;
}
function hasFieldWithName(name) {
return !!collection.fields?.find((f) => f.name.toLowerCase() === name.toLowerCase());
}
return t.div(
{ className: "new-collection-field-btn-wrapper" },
t.button(
{
type: "button",
className: "btn block outline",
"html-popovertarget": uniqueId + "_dropdown",
},
t.i({ className: "ri-add-line" }),
t.span({ className: "txt" }, "New field"),
),
t.div(
{
id: uniqueId + "_dropdown",
className: "dropdown field-types-dropdown",
popover: "auto",
},
() => {
const options = [];
for (const type in app.fieldTypes) {
// for now skip password field types
if (type == "password") {
continue;
}
const def = app.fieldTypes[type];
if (!def.settings) {
continue;
}
options.push(
t.button(
{
type: "button",
className: "dropdown-item",
onclick: (e) => {
e.target.closest(".dropdown")?.hidePopover();
addNewField(type);
},
},
t.i({ className: def.icon || app.utils.fallbackFieldIcon }),
t.span({ className: "txt" }, def.label || type),
),
);
}
return options;
},
),
);
};

View File

@@ -0,0 +1,246 @@
window.app = window.app || {};
window.app.utils = window.app.utils || {};
const defaultOptions = {
maxKeys: 30,
requestKeys: true,
collectionJoinKeys: true,
};
/**
* Generates an array with the suitable autocomplete words for the targeted collection.
*
* @param {string|Object} targetCollection Collection model or identifier.
* @param {string} word The autocomplete triggered "word".
* @param {Object} [options]
* @param {number} [options.maxKeys] The max number of returned autocomplete keys (default to 30).
* @param {boolean} [options.requestKeys] Whether to include the `@request.*` keys (default to true).
* @param {boolean} [options.collectionJoinKeys] Whether to include the `@collection.*` keys (default to true).
* @return {Array}
*/
window.app.utils.collectionAutocompleteKeys = function(targetCollection, word, options = {}) {
if (!targetCollection || !word || !app.store.collections?.length) {
return [];
}
options = Object.assign({}, defaultOptions, options);
let result = collectionFieldsAutocomplete(word, app.store.collections, targetCollection).sort(keysSort);
if (options.requestKeys) {
const keys = requestFieldsAutocomplete(word, app.store.collections, targetCollection).sort(keysSort);
for (let k of keys) {
result.push(k);
}
}
if (options.collectionJoinKeys) {
const keys = collectionJoinAutocomplete(word, app.store.collections).sort(keysSort);
for (let k of keys) {
result.push(k);
}
}
if (result.length > options.maxKeys) {
return result.slice(0, options.maxKeys);
}
return result;
};
// sort shorter keys first
function keysSort(a, b) {
return a.length - b.length;
}
/**
* Generates recursively a list with all the autocomplete field keys
* for the collectionNameOrId collection.
*
* @param {string} word
* @param {Array} collections
* @param {string|object} collection
* @param {string} [prefix]
* @param {number} [level]
* @return {Array}
*/
function collectionFieldsAutocomplete(word, collections, collection, prefix = "", level = 0) {
if (!word || level >= 4) {
return [];
}
if (typeof collection == "string") {
collection = collections.find((c) => c.name == collection || c.id == collection);
}
if (!collection) {
return [];
}
word = word.toLowerCase();
const isAuth = collection.type == "auth";
const result = app.utils
.getAllCollectionIdentifiers(collection, prefix)
.filter((item) => item.toLowerCase().includes(word));
const fields = collection.fields || [];
for (const field of fields) {
if (field.type == "password" || (isAuth && field.name == "tokenKey")) {
continue;
}
const keys = [];
// special @request.body modifiers
if (prefix == "@request.body.") {
keys.push(prefix + field.name + ":changed");
keys.push(prefix + field.name + ":isset");
}
if (typeof app.fieldTypes[field.type]?.filterModifiers == "function") {
const modifiers = app.fieldTypes[field.type]?.filterModifiers(field) || [];
for (const m of modifiers) {
keys.push(prefix + field.name + ":" + m);
}
}
for (const key of keys) {
if (key.toLowerCase().includes(word)) {
result.push(key);
}
}
// add relation fields
if (field.type == "relation" && field.collectionId) {
const subKeys = collectionFieldsAutocomplete(
word,
collections,
field.collectionId,
prefix + field.name + ".",
level + 1,
);
for (const k of subKeys) {
result.push(k);
}
}
}
// add back relations
for (const ref of collections) {
const refFields = ref.fields || [];
for (const field of refFields) {
if (field.type != "relation" || field.collectionId != collection.id) {
continue;
}
const key = prefix + ref.name + "_via_" + field.name;
const subKeys = collectionFieldsAutocomplete(word, collections, ref, key + ".", level + 2); // +2 to reduce the recursive results
for (const k of subKeys) {
result.push(k);
}
}
}
return result;
}
/**
* Generates a list with all @request.* autocomplete field keys.
*
* @param {string} word
* @param {Array} collections
* @param {string|object} baseCollection (used for the `@request.body.*` fields)
* @return {Array}
*/
function requestFieldsAutocomplete(word, collections, baseCollection) {
if (!word) {
return [];
}
word = word.toLowerCase();
const result = [];
const common = [
"@request.context",
"@request.method",
"@request.query.",
"@request.body.",
"@request.headers.",
"@request.auth.collectionId",
"@request.auth.collectionName",
];
for (const w of common) {
if (!w.toLowerCase().includes(word)) {
continue;
}
result.push(w);
}
// load auth collection fields
const authCollections = collections.filter((collection) => collection.type === "auth");
for (const collection of authCollections) {
if (collection.system) {
continue; // skip system collections for now
}
const authKeys = collectionFieldsAutocomplete(word, collections, collection, "@request.auth.");
for (const k of authKeys) {
app.utils.pushUnique(result, k);
}
}
if (typeof baseCollection == "string") {
baseCollection = collections.find((c) => c.name == baseCollection || c.id == baseCollection);
}
if (!baseCollection) {
return result;
}
// load base collection fields into @request.body.*
const keys = collectionFieldsAutocomplete(word, collections, baseCollection, "@request.body.");
for (const key of keys) {
result.push(key);
}
return result;
}
/**
* Generates a list with all @collection.* autocomplete field keys.
*
* @param {string} word
* @param {Array} collections
* @return {Array}
*/
function collectionJoinAutocomplete(word, collections) {
const result = [];
let basePrefix = "@collection.";
// to avoid unnecessary loading all @collection.* keys match with the word first
let base, search;
if (basePrefix.length < word.length) {
base = word;
search = basePrefix;
} else {
base = basePrefix;
search = word;
}
if (!base.includes(search)) {
return result;
}
for (const collection of collections) {
if (collection.system) {
continue; // skip system collections for now
}
const keys = collectionFieldsAutocomplete(word, collections, collection, basePrefix + collection.name + ".");
for (const key of keys) {
result.push(key);
}
}
return result;
}

View File

@@ -0,0 +1,89 @@
import { emailTemplateAccordion } from "./emailTemplateAccordion";
import { mfaAccordion } from "./mfaAccordion";
import { oauth2Accordion } from "./oauth2Accordion";
import { otpAccordion } from "./otpAccordion";
import { passwordAuthAccordion } from "./passwordAuthAccordion";
import { tokenOptionsAccordion } from "./tokenOptionsAccordion";
export function collectionAuthOptionsTab(upsertData) {
const uniqueId = "options_" + app.utils.randomString();
return t.div(
{ className: "collection-tab-content collection-options-tab-content" },
t.div(
{ className: "grid" },
t.div(
{ className: "col-12" },
t.div(
{ className: "section-heading" },
t.strong(null, "Auth methods"),
t.div({ className: "flex-fill" }),
t.div(
{ className: "field" },
t.input({
id: uniqueId + ".authAlert",
name: "authAlert.enabled",
type: "checkbox",
className: "switch sm",
checked: () => !!upsertData.collection.authAlert?.enabled,
onchange: (e) => {
upsertData.collection.authAlert = upsertData.collection.authAlert || {};
upsertData.collection.authAlert.enabled = e.target.checked;
},
}),
t.label({ htmlFor: uniqueId + ".authAlert" }, "Send email alert for new logins"),
),
),
passwordAuthAccordion(upsertData.collection),
() => {
if (upsertData.originalCollection?.name == "_superusers") {
return;
}
return oauth2Accordion(upsertData.collection);
},
otpAccordion(upsertData.collection),
mfaAccordion(upsertData.collection),
),
t.div(
{ className: "col-12" },
t.div(
{ className: "section-heading" },
t.strong(null, "Mail templates"),
t.button({
tabIndex: -1,
type: "buttton",
className: "m-l-auto label handle txt-bold",
textContent: "Send test email",
onclick: () => app.modals.openMailTest(upsertData.collection?.name),
}),
),
emailTemplateAccordion(upsertData.collection, "verificationTemplate", {
title: "Default Verification email template",
placeholders: ["{APP_NAME}", "{APP_URL}", "{RECORD:*}", "{TOKEN}"],
}),
emailTemplateAccordion(upsertData.collection, "resetPasswordTemplate", {
title: "Default Password reset email template",
placeholders: ["{APP_NAME}", "{APP_URL}", "{RECORD:*}", "{TOKEN}"],
}),
emailTemplateAccordion(upsertData.collection, "confirmEmailChangeTemplate", {
title: "Default Confirm email change email template",
placeholders: ["{APP_NAME}", "{APP_URL}", "{RECORD:*}", "{TOKEN}"],
}),
emailTemplateAccordion(upsertData.collection, "otp.emailTemplate", {
title: "Default OTP email template",
placeholders: ["{APP_NAME}", "{APP_URL}", "{RECORD:*}", "{OTP}", "{OTP_ID}"],
}),
emailTemplateAccordion(upsertData.collection, "authAlert.emailTemplate", {
title: "Default Login alert email template",
placeholders: ["{APP_NAME}", "{APP_URL}", "{RECORD:*}", "{ALERT_INFO}"],
}),
),
t.div(
{ className: "col-12" },
t.div({ className: "section-heading" }, t.strong(null, "Other")),
tokenOptionsAccordion(upsertData.collection),
),
),
);
}

View File

@@ -0,0 +1,347 @@
import { toDeleteProp } from "@/base/fieldSettings";
window.app = window.app || {};
window.app.modals = window.app.modals || {};
window.app.modals.openCollectionChangesConfirmation = async function(
oldCollection,
newCollection,
yesCallback,
noCallback,
) {
const data = store({
isLoadingConflictingOIDCProviders: false,
conflictingOIDCProviders: [],
// ---
get isCollectionRenamed() {
return oldCollection?.name != newCollection?.name;
},
get isNewCollectionAuth() {
return newCollection?.type === "auth";
},
get isNewCollectionView() {
return newCollection?.type === "view";
},
get renamedFields() {
if (data.isNewCollectionView) {
return [];
}
return newCollection?.fields?.filter?.((f) => {
let oldField;
if (f.id && !f[toDeleteProp]) {
oldField = oldCollection.fields?.find?.((old) => old.id == f.id);
}
return oldField && oldField.name != f.name;
}) || [];
},
get deletedFields() {
if (data.isNewCollectionView) {
return [];
}
return newCollection?.fields?.filter?.((f) => {
return f.id && f[toDeleteProp];
}) || [];
},
get multipleToSingleFields() {
if (data.isNewCollectionView) {
return [];
}
return newCollection?.fields?.filter?.((newField) => {
const oldField = oldCollection?.fields?.find?.((f) => f.id == newField.id);
if (!oldField || typeof oldField.maxSelect == "undefined") {
return false;
}
// normalize
const oldMaxSelect = oldField.maxSelect || 1;
const newMaxSelect = newField.maxSelect || 1;
return oldMaxSelect > 1 && newMaxSelect == 1;
}) || [];
},
get changedRules() {
// for now enable only for "production"
if (window.location.protocol != "https:") {
return [];
}
const result = [];
const ruleProps = ["listRule", "viewRule"];
if (!data.isNewCollectionView) {
ruleProps.push("createRule", "updateRule", "deleteRule");
}
if (data.isNewCollectionAuth) {
ruleProps.push("manageRule", "authRule");
}
let oldRule, newRule;
for (let prop of ruleProps) {
oldRule = oldCollection?.[prop];
newRule = newCollection?.[prop];
if (oldRule === newRule) {
continue;
}
result.push({ prop, oldRule, newRule });
}
return result;
},
get needConfirmation() {
return !app.utils.isEmpty(oldCollection?.id) && (
data.isCollectionRenamed
|| data.renamedFields.length
|| data.deletedFields.length
|| data.multipleToSingleFields.length
|| data.changedRules.length
|| data.conflictingOIDCProviders.length
);
},
});
const knownOIDCProviders = ["oidc", "oidc2", "oidc3"];
async function detectConflictingOIDCProviders() {
if (app.utils.isEmpty(oldCollection?.id) || !data.isNewCollectionAuth) {
return;
}
data.isLoadingConflictingOIDCProviders = true;
try {
data.conflictingOIDCProviders = [];
for (const name of knownOIDCProviders) {
const oldProvider = oldCollection?.oauth2?.providers?.find?.((p) => p.name == name);
const newProvider = newCollection?.oauth2?.providers?.find?.((p) => p.name == name);
if (!oldProvider || !newProvider) {
continue;
}
const oldHost = new URL(oldProvider.authURL).host;
const newHost = new URL(newProvider.authURL).host;
if (oldHost == newHost) {
continue;
}
// check if there are existing externalAuths
const haveExternalAuths = await app.pb.collection("_externalAuths").getFirstListItem(
app.pb.filter("collectionRef={:collectionId} && provider={:provider}", {
collectionId: newCollection?.id,
provider: name,
}),
{
requestKey: null,
},
);
if (haveExternalAuths) {
data.conflictingOIDCProviders.push({ name, oldHost, newHost });
}
}
data.isLoadingConflictingOIDCProviders = false;
} catch (err) {
if (err.isAbort) {
data.isLoadingConflictingOIDCProviders = false;
app.checkApiError(err);
}
}
}
await detectConflictingOIDCProviders();
if (!data.needConfirmation) {
return yesCallback();
}
app.modals.confirm(
t.div(
{ className: "dangerous-collection-changes-list" },
t.h5({ className: "block txt-center m-b-base" }, "Do you really want to save the collection changes?"),
// general collection warning
() => {
if (!data.isCollectionRenamed && !data.deletedFields.length && !data.renamedFields.length) {
return;
}
return t.div(
{ className: "alert warning m-b-base" },
t.p(
null,
"If the collection participate in another collection rule, filter or view query, you'll have to update it manually!",
),
() => {
if (data.deletedFields.length) {
return t.p(
null,
"All data associated with the removed fields will be permanently deleted!",
);
}
},
);
},
// renamed collection
() => {
if (!data.isCollectionRenamed) {
return;
}
return t.ul(
{ className: "collection-changes-list changes-renamed-collection" },
t.li(
{ className: "list-item" },
"Renamed collection ",
t.strong({ className: "label warning" }, oldCollection?.name),
t.i({ className: "ri-arrow-right-line txt-sm" }),
t.strong({ className: "label success" }, newCollection?.name || "N/A"),
),
);
},
// renamed fields
() => {
if (!data.renamedFields.length) {
return;
}
return t.ul(
{ className: "collection-changes-list changes-renamed-fields" },
() => {
return data.renamedFields.map((newField) => {
const oldField = oldCollection?.fields?.find?.((f) => f.id == newField.id);
return t.li(
{ className: "list-item" },
"Renamed field ",
t.strong({ className: "label warning" }, oldField?.name),
t.i({ className: "ri-arrow-right-line txt-sm" }),
t.strong({ className: "label success" }, newField.name || "N/A"),
);
});
},
);
},
// deleted fields
() => {
if (!data.deletedFields.length) {
return;
}
return t.ul(
{ className: "collection-changes-list changes-deleted-fields" },
() => {
return data.deletedFields.map((field) => {
return t.li(
{ className: "list-item" },
"Deleted field ",
t.strong({ className: "label danger" }, field.name || "N/A"),
);
});
},
);
},
// multiple->single fields
() => {
if (!data.multipleToSingleFields.length) {
return;
}
return t.ul(
{ className: "collection-changes-list changes-multiple-to-single-fields" },
() => {
return data.multipleToSingleFields.map((field) => {
return t.li(
{ className: "list-item" },
"Multiple to single value conversion of field ",
t.strong({ className: "label warning" }, field.name || field.id),
t.em({ className: "txt-sm" }, " (will keep only the last array item)"),
);
});
},
);
},
// API rule changes
() => {
if (!data.changedRules.length) {
return;
}
return t.ul(
{ className: "collection-changes-list changes-api-rules" },
() => {
return data.changedRules.map((ruleChange) => {
return t.li(
{ className: "list-item" },
t.div(
{ className: "content" },
t.span({ className: "txt" }, "Changed API rule for "),
t.code(null, ruleChange.prop),
),
t.small({ className: "txt-bold" }, "Old:"),
t.div(
{ className: "rule-content old-rule" },
ruleChange.oldRule === null
? "null (superusers only)"
: (ruleChange.oldRule || "\"\""),
),
t.small({ className: "txt-bold" }, "New:"),
t.div(
{ className: "rule-content new-rule" },
ruleChange.newRule === null
? "null (superusers only)"
: (ruleChange.newRule || "\"\""),
),
);
});
},
);
},
// Conflicting OIDC changes
() => {
if (!data.conflictingOIDCProviders.length) {
return;
}
return t.ul(
{ className: "collection-changes-list changes-api-rules" },
() => {
return data.conflictingOIDCProviders.map((oidc) => {
return t.li(
{ className: "list-item" },
"Changed OIDC ",
oidc.name,
" host ",
t.strong({ className: "label warning" }, oidc.oldHost),
t.i({ className: "ri-arrow-right-line txt-sm" }),
t.strong({ className: "label success" }, oidc.newHost),
t.br(),
t.span(
{ className: "txt-hint" },
"If the old and new OIDC configuration is not for the same provider consider deleting",
" all old _externalAuths records associated to the current collection and provider,",
" otherwise it may result in account linking errors.",
),
" ",
t.a({
rel: "noopenener noreferrer",
target: "_blank",
href: () => {
return `#/collections?collection=_externalAuths&filter=collectionRef%3D%22${newCollection?.id}%22+%26%26+provider%3D%22${oidc.name}%22`;
},
textContent: "Review existing _externalAuths records",
}),
);
});
},
);
},
),
yesCallback,
noCallback,
{ className: "collection-changes-confirm-modal" },
);
};

View File

@@ -0,0 +1,82 @@
export function collectionFieldsTab(upsertData) {
return t.div(
{ className: "collection-tab-content collection-fields-tab-content" },
t.div(
{ className: "collection-fields-list" },
app.components.sortable({
handle: ".sort-handle",
data: () => (upsertData.collection?.fields || [])?.filter((f) => !!app.fieldTypes[f.type]?.settings),
dataItem: (field, _) => {
return app.fieldTypes[field.type].settings({
field: field,
get originalCollection() {
return upsertData.originalCollection;
},
get collection() {
return upsertData.collection;
},
get originalField() {
return upsertData.originalCollection?.fields?.find((f) => field.id && f.id == field.id);
},
get fieldIndex() {
return upsertData.collection.fields?.findIndex((f) =>
field.id ? f.id == field.id : f == field
);
},
});
},
onchange: (sortedList, fromIndex, toIndex) => {
upsertData.collection.fields = sortedList;
},
}),
),
() => app.components.addCollectionFieldButton(upsertData.collection),
// indexes
t.hr(),
t.p(
{ className: "txt-bold" },
"Unique constraints and indexes (",
() => upsertData.collection.indexes?.length,
")",
),
app.components.sortable({
className: "indexes-list",
data: () => upsertData.collection.indexes || [],
onchange: function(sortedList) {
upsertData.collection.indexes = sortedList;
},
dataItem: (index, i) => {
const parsed = app.utils.parseIndex(index);
return t.button(
{
type: "button",
className: () => {
const errMsg = app.store.errors?.indexes?.[i]?.message || "";
return `label handle ${errMsg ? "danger error" : "success"}`;
},
ariaDescription: app.attrs.tooltip(() => app.store.errors?.indexes?.[i]?.message || ""),
onclick: () => app.modals.openIndexUpsert(upsertData.collection, index),
},
() => {
if (parsed.unique) {
return t.strong(null, "Unique:");
}
},
t.span({ className: "txt" }, () => parsed.columns?.map((c) => c.name).join(", ")),
);
},
after: () => {
return t.button(
{
type: "button",
className: "label handle",
onclick: () => app.modals.openIndexUpsert(upsertData.collection),
},
t.i({ className: "ri-add-line" }),
t.span({ className: "txt" }, "New index"),
);
},
}),
);
}

View File

@@ -0,0 +1,274 @@
export function collectionRulesTab(upsertData) {
const local = store({
showRulesInfo: false,
showAuthRules: false,
});
const systemRuleTooltip = () =>
app.attrs.tooltip(
upsertData.originalCollection?.system ? "System collection rule cannot be changed." : null,
"top-left",
);
function autocomplete(word) {
return app.utils.collectionAutocompleteKeys(upsertData.collection, word);
}
return t.div(
{ className: "collection-tab-content collection-rules-tab-content" },
t.div(
{ className: "grid" },
t.div(
{ className: "col-12" },
t.div(
{ className: "flex txt-hint txt-sm" },
t.span(
{ className: "txt" },
"All rules follow the ",
t.a({
target: "_blank",
rel: "noopener noreferrer",
href: import.meta.env.PB_RULES_SYNTAX_DOCS,
textContent: "PocketBase filter syntax and operators",
}),
".",
),
t.strong({
tabIndex: -1,
className: "m-l-auto link-hint",
textContent: () => (local.showRulesInfo ? "Hide available fields" : "Show available fields"),
onclick: () => (local.showRulesInfo = !local.showRulesInfo),
}),
),
app.components.slide(
() => local.showRulesInfo,
t.div(
{ className: "alert warning m-t-sm" },
t.div(
{ className: "content" },
t.p(null, "The following record fields are available:"),
t.div({ className: "flex flex-wrap gap-5" }, () => {
const identifiers = app.utils.getAllCollectionIdentifiers(upsertData.collection);
return identifiers.map((f) => {
return t.code(null, f);
});
}),
t.hr({ className: "m-t-10 m-b-10" }),
t.p(
null,
"The request fields could be accessed with the special ",
t.strong(null, "@request"),
" fields:",
),
t.div(
{ className: "flex flex-wrap gap-5" },
t.code(null, "@request.headers.*"),
t.code(null, "@request.query.*"),
t.code(null, "@request.body.*"),
t.code(null, "@request.auth.*"),
),
t.hr({ className: "m-t-10 m-b-10" }),
t.p(
null,
"You could also add constraints and query other collections using the ",
t.strong(null, "@collection"),
" field:",
),
t.div(
{ className: "flex flex-wrap gap-5" },
t.code(null, "@collection.ANY_COLLECTION_NAME.*"),
),
t.hr({ className: "m-t-10 m-b-10" }),
t.p(null, "Example rule:"),
() => {
const dateField = upsertData.collection.fields?.find(
(f) => f.type == "date" || f.type == "autodate",
);
if (dateField) {
return t.code(
null,
`@request.auth.id != "" && ${dateField.name} > "2022-01-01 00:00:00.000Z"`,
);
}
return t.code(null, `@request.auth.id != ""`);
},
),
),
),
),
t.div(
{ className: "col-12", ariaDescription: systemRuleTooltip() },
app.components.ruleField({
label: "List/Search rule",
name: "listRule",
autocomplete: autocomplete,
disabled: () => upsertData.originalCollection?.system,
value: () => upsertData.collection.listRule,
oninput: (val) => (upsertData.collection.listRule = val),
}),
),
t.div(
{ className: "col-12", ariaDescription: systemRuleTooltip() },
app.components.ruleField({
label: "View rule",
name: "viewRule",
autocomplete: autocomplete,
disabled: () => upsertData.originalCollection?.system,
value: () => upsertData.collection.viewRule,
oninput: (val) => (upsertData.collection.viewRule = val),
}),
),
() => {
// view collections has only List and View API rules
if (upsertData.collection.type == "view") {
return;
}
return [
t.div(
{ className: "col-12", ariaDescription: systemRuleTooltip() },
app.components.ruleField({
label: [
t.span({ className: "txt", textContent: "Create rule" }),
t.i({
hidden: () => upsertData.collection.createRule == null,
className: "ri-information-line link-hint",
ariaDescription: app.attrs.tooltip(
"The main record fields hold the values that are going to be inserted in the database.",
),
}),
],
name: "createRule",
autocomplete: autocomplete,
disabled: () => upsertData.originalCollection?.system,
value: () => upsertData.collection.createRule,
oninput: (val) => (upsertData.collection.createRule = val),
}),
),
t.div(
{ className: "col-12", ariaDescription: systemRuleTooltip() },
app.components.ruleField({
label: [
t.span({ className: "txt", textContent: "Update rule" }),
t.i({
hidden: () => upsertData.collection.updateRule == null,
className: "ri-information-line link-hint",
ariaDescription: app.attrs.tooltip(
"The main record fields hold the old/existing record field values.\nTo target the newly submitted ones you can use @request.body.*.",
),
}),
],
name: "updateRule",
autocomplete: autocomplete,
disabled: () => upsertData.originalCollection?.system,
value: () => upsertData.collection.updateRule,
oninput: (val) => (upsertData.collection.updateRule = val),
}),
),
t.div(
{ className: "col-12", ariaDescription: systemRuleTooltip() },
app.components.ruleField({
label: "Delete rule",
name: "deleteRule",
autocomplete: autocomplete,
disabled: () => upsertData.originalCollection?.system,
value: () => upsertData.collection.deleteRule,
oninput: (val) => (upsertData.collection.deleteRule = val),
}),
),
];
},
),
// auth specific fields
() => {
if (upsertData.collection.type != "auth") {
return;
}
return [
t.hr({ className: "m-t-base m-b-base" }),
t.button(
{
type: "button",
onmount: () => {
local.showAuthRules = upsertData.collection.manageRule !== null
|| upsertData.collection.authRule !== "";
},
className: () => `btn secondary sm ${local.showAuthRules ? "" : "transparent"}`,
onclick: () => {
local.showAuthRules = !local.showAuthRules;
},
},
t.span({ className: "txt" }, "Additional auth collection rules"),
t.i({
className: () => (local.showAuthRules ? "ri-arrow-drop-up-line" : "ri-arrow-drop-down-line"),
}),
),
app.components.slide(
() => local.showAuthRules,
t.div(
{ className: "grid sm m-t-sm" },
t.div(
{ className: "col-12", ariaDescription: systemRuleTooltip() },
app.components.ruleField({
label: "Authentication rule",
name: "authRule",
placeholder: "",
autocomplete: autocomplete,
disabled: () => upsertData.originalCollection?.system,
value: () => upsertData.collection.authRule,
oninput: (val) => (upsertData.collection.authRule = val),
}),
t.div(
{ className: "field-help" },
t.p(
null,
"This rule is executed every time ",
t.strong(null, "before authentication"),
" allowing you to restrict who can authenticate.",
),
t.p(
null,
"For example, to allow only verified users you can set it to ",
t.code(null, "verified = true"),
".",
),
t.p(null, "Leave it empty to allow anyone with an account to authenticate."),
t.p(
null,
`To disable authentication entirely you can change it to "Set superusers only".`,
),
),
),
t.div(
{ className: "col-12", ariaDescription: systemRuleTooltip() },
app.components.ruleField({
label: "Manage rule",
name: "manageRule",
autocomplete: autocomplete,
disabled: () => upsertData.originalCollection?.system,
value: () => upsertData.collection.manageRule,
oninput: (val) => (upsertData.collection.manageRule = val),
}),
t.div(
{ className: "field-help" },
t.p(
null,
"This rule is executed in addition to the ",
t.strong(null, "create"),
" and ",
t.strong(null, "update"),
" API rules.",
),
t.p(
null,
"It enables superuser-like permissions to allow fully managing the auth record(s), eg. changing the password without requiring to enter the old one, directly updating the verified state or email, etc.",
),
),
),
),
),
];
},
);
}

View File

@@ -0,0 +1,913 @@
import { toDeleteProp } from "@/base/fieldSettings";
import { collectionAuthOptionsTab } from "./collectionAuthOptionsTab";
import { collectionFieldsTab } from "./collectionFieldsTab";
import { collectionRulesTab } from "./collectionRulesTab";
import { collectionViewQueryTab } from "./collectionViewQueryTab";
window.app = window.app || {};
window.app.modals = window.app.modals || {};
window.app.modals.openCollectionUpsert = function(collection = {}, modalSettings = {
// base modal events
onbeforeopen: null, // function(el) {},
onafteropen: null, // function(el) {},
onbeforeclose: null, // function(el) {},
onafterclose: null, // function(el) {},
// collection specific events
onsave: null, // function(collection, isNew) {},
ondelete: null, // function(collection) {},
onduplicate: null, // function(collection) {},
ontruncate: null, // function(collection) {},
}) {
app.store.errors = null; // reset
const modal = collectionUpsertModal(collection || {}, modalSettings || {});
document.body.appendChild(modal);
app.modals.open(modal);
};
window.app.collectionTypes = {
"base": {
"icon": "ri-folder-2-line",
"tabs": {
"Fields": collectionFieldsTab,
"API rules": collectionRulesTab,
},
},
"view": {
"icon": "ri-table-line",
"tabs": {
"Query": collectionViewQueryTab,
"API rules": collectionRulesTab,
},
},
"auth": {
"icon": "ri-group-line",
"tabs": {
"Fields": collectionFieldsTab,
"API rules": collectionRulesTab,
"Options": collectionAuthOptionsTab,
},
},
};
function collectionUpsertModal(rawCollection, modalSettings) {
let modal;
const uniqueId = "collection_upsert_" + app.utils.randomString();
const data = store({
isSaving: false,
originalCollection: {},
collection: {},
selectedTab: "",
get activeTab() {
if (!app.collectionTypes[data.collection.type]?.tabs) {
return data.selectedTab;
}
if (
!data.selectedTab
|| !app.collectionTypes[data.collection.type].tabs?.[data.selectedTab]
) {
return Object.keys(app.collectionTypes[data.collection.type].tabs)?.[0] || "";
}
return data.selectedTab;
},
get isNew() {
return app.utils.isEmpty(data.originalCollection?.id);
},
get collectionTypeOptions() {
return Object.keys(app.collectionTypes).map((type) => {
return {
value: type,
label: app.utils.sentenize(type, false) + " collection",
};
});
},
get collectionHash() {
Object.keys(data.collection).length;
return JSON.stringify(data.collection);
},
get originalCollectionHash() {
return JSON.stringify(data.originalCollection);
},
get hasChanges() {
return data.originalCollectionHash != data.collectionHash;
},
get canSave() {
return !data.isSaving && (data.isNew || data.hasChanges);
},
});
async function initCollection(collection) {
if (app.utils.isEmpty(collection)) {
collection = JSON.parse(JSON.stringify(app.store.collectionScaffolds.base)) || {
type: "base",
fields: [],
};
// add commonly used timestamp fields
collection.fields.push({
type: "autodate",
name: "created",
onCreate: true,
});
collection.fields.push({
type: "autodate",
name: "updated",
onCreate: true,
onUpdate: true,
});
}
data.originalCollection = JSON.parse(JSON.stringify(collection));
data.collection = JSON.parse(JSON.stringify(collection));
}
async function confirmSave(close = true) {
if (!data.canSave) {
return;
}
data.isSaving = true;
app.modals.openCollectionChangesConfirmation(
data.originalCollection,
data.collection,
() => save(close),
() => {
data.isSaving = false;
},
);
}
function exportPayload() {
const payload = JSON.parse(JSON.stringify(data.collection));
payload.fields = payload.fields || [];
// remove fields marked for deletion
for (let i = payload.fields.length - 1; i >= 0; i--) {
const field = payload.fields[i];
if (field[toDeleteProp]) {
payload.fields.splice(i, 1);
continue;
}
}
return payload;
}
async function save(close = true) {
data.isSaving = true;
try {
const payload = exportPayload();
const isNew = app.utils.isEmpty(data.originalCollection?.id);
let request;
if (isNew) {
request = app.pb.collections.create(payload);
} else {
request = app.pb.collections.update(data.originalCollection.id, payload);
}
const rawCollection = JSON.stringify(await request);
data.originalCollection = JSON.parse(rawCollection);
data.collection = JSON.parse(rawCollection);
app.store.addOrUpdateCollection(JSON.parse(rawCollection));
modalSettings?.onsave?.(JSON.parse(rawCollection), isNew);
data.isSaving = false;
app.toasts.success(
isNew
? `Successfully created collection "${data.collection.name}".`
: `Successfully updated collection "${data.collection.name}".`,
{ key: "collectionSave" },
);
// reset all errors
app.store.errors = null;
if (close) {
app.modals.close(modal, true);
}
} catch (err) {
if (!err?.isAbort) {
data.isSaving = false;
app.checkApiError(err, false);
app.toasts.error(err.message || "Failed to save collection.", { key: "collectionSave" });
}
}
}
function resetForm() {
data.collection = JSON.parse(JSON.stringify(data.originalCollection));
}
async function duplicate() {
const clone = data.originalCollection ? JSON.parse(JSON.stringify(data.originalCollection)) : {};
clone.id = "";
clone.system = false;
clone.name = clone.name + "_duplicate";
clone.created = "";
clone.updated = "";
// updated indexes ids
clone.indexes = clone.indexes?.map((idx) => {
return app.utils.replaceIndexFields(idx, (parsed) => {
return {
indexName: parsed.indexName + app.utils.randomString(3),
tableName: clone.name,
};
});
}) || [];
await modalSettings.onduplicate?.(clone);
return initCollection(clone);
}
async function changeTab(tabName) {
data.selectedTab = tabName;
// ui tick
await new Promise((r) => setTimeout(r, 0));
// refresh errors in case to retrigger validations
if (app.store.errors) {
app.store.errors = JSON.parse(JSON.stringify(app.store.errors));
}
}
modal = t.div(
{
pbEvent: "collectionUpsertModal",
"html-data-collectionId": () => data.originalCollection?.id,
"html-data-collectionName": () => data.originalCollection?.name,
className: "modal collection-upsert-modal",
inert: () => data.isSaving,
onkeydown: (e) => {
if ((e.ctrlKey || e.metaKey) && e.code == "KeyS") {
e.preventDefault();
// temp blur any active input to make sure that onchange/blur events are fired
const input = document.activeElement;
input?.blur();
confirmSave(false);
// restore previous active input
input?.focus();
}
},
onbeforeopen: () => {
initCollection(rawCollection);
return modalSettings.onbeforeopen?.(el);
},
onafteropen: (el) => {
modalSettings.onafteropen?.(el);
},
onbeforeclose: (el, forceClosed) => {
if (forceClosed) {
return modalSettings.onbeforeclose?.(el);
}
if (data.isSaving) {
return false;
}
if (!data.hasChanges) {
return modalSettings.onbeforeclose?.(el);
}
return new Promise((r) => {
app.modals.confirm(
"You have unsaved changes. Do you really want to discard them?",
() => r(modalSettings.onbeforeclose?.(el)),
() => r(false),
);
});
},
onafterclose: (el) => {
modalSettings.onafterclose?.(el);
el?.remove();
},
onmount: (el) => {
el._watchers?.forEach((w) => w?.unwatch());
el._watchers = [
watch(
() => data.collection.type,
(newType, oldType) => {
if (!oldType || newType == oldType || !app.store.collectionScaffolds[newType]) {
return;
}
// reset fields list errors on type change
app.utils.deleteByPath(app.store.errors, "fields");
// merge with the scaffold to ensure that the minimal props are set
const scaffold = JSON.parse(JSON.stringify(app.store.collectionScaffolds[newType]));
data.collection = Object.assign(
structuredClone(scaffold),
JSON.parse(JSON.stringify(data.collection)),
);
data.originalCollection = scaffold;
syncFieldsAndIndexesWithScaffold(data.collection);
},
),
// collection rename
watch(
() => data.collection.name,
(newName, oldName) => {
newName = app.utils.slugify(newName);
data.collection.name = newName;
if (typeof oldName == "undefined" || !newName || newName == oldName) {
return;
}
// update indexes with the latest collection name as table name
clearTimeout(el.__collectionRenameTimeoutId);
el.__collectionRenameTimeoutId = setTimeout(() => {
data.collection.indexes = data.collection.indexes?.map((idx) => {
return app.utils.replaceIndexFields(idx, { tableName: data.collection.name });
});
}, 150);
},
),
];
},
onunmount: (el) => {
clearTimeout(el?.__collectionRenameTimeoutId);
el?._watchers?.forEach((w) => w?.unwatch());
},
},
t.header(
{ className: "modal-header isolated" },
t.div(
{ className: "grid sm" },
t.div(
{ className: "col-12 flex" },
t.h6(
{ className: "modal-title" },
t.span(null, () => (data.isNew ? "Create " : "Edit ")),
t.strong(
{
hidden: () => data.isNew,
className: "txt-ellipsis collection-name",
},
() => data.originalCollection?.name,
),
t.span(null, " collection"),
),
t.div({ className: "flex-fill" }),
() => {
if (app.utils.isEmpty(data.originalCollection?.id)) {
return;
}
return [
t.button(
{
type: "button",
className: "btn sm circle transparent",
"html-popovertarget": uniqueId + "modal-header-dropdown",
},
t.i({ className: "ri-more-line" }),
),
t.div(
{
id: uniqueId + "modal-header-dropdown",
className: "dropdown nowrap modal-header-dropdown",
popover: "auto",
},
t.button(
{
type: "button",
className: "dropdown-item",
onclick: (e) => {
e.target.closest(".dropdown").hidePopover();
app.utils.copyToClipboard(
JSON.stringify(data.originalCollection, null, 2),
);
app.toasts.success("Collection copied to clipboard!");
},
},
t.i({ className: "ri-braces-line" }),
t.span({ className: "txt" }, "Copy JSON"),
),
t.button(
{
type: "button",
className: "dropdown-item",
onclick: (e) => {
e.target.closest(".dropdown").hidePopover();
if (data.hasChanges) {
app.modals.confirm(
"You have unsaved changes. Do you really want to discard them?",
duplicate,
null,
{ yesButton: "Yes, discard" },
);
} else {
duplicate();
}
},
},
t.i({ className: "ri-file-copy-line" }),
t.span({ className: "txt" }, "Duplicate"),
),
t.hr(),
() => {
if (data.collection.type == "view") {
return; // view don't have their own records
}
return truncateDropdownItem(data, modalSettings);
},
() => {
if (data.collection.system) {
return; // system collections cannot be deleted
}
return deleteDropdownItem(data, modalSettings);
},
),
];
},
),
t.div(
{ className: "col-12" },
t.div(
{ className: "fields" },
t.div(
{ className: "field" },
t.label({
htmlFor: uniqueId + "col_name",
textContent: () => {
return `Name${data.collection?.system ? " (system)" : ""}`;
},
}),
t.input({
id: uniqueId + "col_name",
type: "text",
name: "name",
required: true,
spellcheck: false,
placeholder: "e.g. posts",
autofocus: () => data.isNew,
disabled: () => !data.isNew && data.collection?.system,
value: () => data.collection.name || "",
onmount: (el) => {
el.addEventListener("compositionend", (e) => {
data.collection.name = e.target.value;
});
},
oninput: (e) => {
if (e.isComposing) {
return;
}
data.collection.name = e.target.value;
},
}),
),
t.div(
{ className: "field addon" },
t.button(
{
type: "button",
disabled: () => !data.isNew,
className: () =>
`btn sm collection-type-select ${data.isNew ? "outline" : "transparent"}`,
"html-popovertarget": uniqueId + "col_type_dropdown",
},
t.span(
{ className: "txt" },
"Type: ",
() => app.utils.sentenize(data.collection.type, false) || "N/A",
),
t.i({
hidden: () => !data.isNew,
className: "ri-arrow-drop-down-line m-l-auto",
}),
),
t.div(
{
id: uniqueId + "col_type_dropdown",
className: "dropdown nowrap collection-type-dropdown",
popover: "auto",
},
() => {
let options = [];
for (const opt of data.collectionTypeOptions) {
options.push(
t.button(
{
type: "button",
className: () =>
`dropdown-item ${
opt.value == data.collection.type ? "active" : ""
}`,
onclick: (e) => {
e.target.closest(".dropdown").hidePopover();
data.collection.type = opt.value;
},
},
t.i({
className: app.collectionTypes[opt.value]?.icon
|| app.utils.fallbackCollectionIcon,
}),
t.span({ className: "txt" }, opt.label || opt.value),
),
);
}
return options;
},
),
),
),
),
t.div(
{ className: "col-12" },
t.nav(
{ className: "tabs-header equal-width" },
() => {
const tabItems = [];
const tabs = app.collectionTypes[data.collection.type]?.tabs || {};
for (let tabName in tabs) {
tabItems.push(
t.button(
{
type: "button",
disabled: () => data.isSaving,
className: () => `tab-item ${data.activeTab == tabName ? "active" : ""}`,
onclick: () => changeTab(tabName),
},
t.span({ className: "txt" }, tabName),
),
);
}
return tabItems;
},
),
),
),
),
t.div(
{ className: "modal-content" },
() => app.collectionTypes[data.collection.type]?.tabs?.[data.activeTab]?.(data),
),
t.footer(
{ className: "modal-footer" },
t.button(
{
type: "button",
className: "btn transparent m-r-auto",
disabled: () => data.isSaving,
onclick: () => app.modals.close(modal),
},
t.span({ className: "txt" }, "Close"),
),
() => {
const rawErrors = JSON.stringify(app.store.errors);
if (rawErrors == "" || rawErrors == "null" || rawErrors == "{}" || rawErrors == "[]") {
return;
}
return t.i({
className: "ri-alert-line txt-danger",
ariaDescription: app.attrs.tooltip(() => "Raw error:\n" + rawErrors),
});
},
t.div(
{ className: "btns" },
t.button(
{
type: "button",
className: () => `btn expanded-lg ${data.isSaving ? "loading" : ""}`,
disabled: () => !data.canSave,
onclick: () => confirmSave(true),
},
t.span({ className: "txt" }, () => (data.isNew ? "Create" : "Save changes")),
),
t.button(
{
type: "button",
className: () => `btn p-5`,
disabled: () => !data.canSave,
"html-popovertarget": uniqueId + "save_options",
},
t.i({ className: "ri-arrow-up-s-line" }),
),
t.div(
{ id: uniqueId + "save_options", className: "dropdown nowrap", popover: "auto" },
t.button(
{
type: "button",
className: "dropdown-item",
onclick: (e) => {
e.target.closest(".dropdown").hidePopover();
confirmSave(false);
},
},
t.span({ className: "txt" }, "Save and continue"),
t.small({ className: "txt-hint" }, "(Ctrl+S)"),
),
t.hr(),
t.button(
{
type: "button",
className: "dropdown-item",
onclick: (e) => {
e.target.closest(".dropdown").hidePopover();
resetForm();
},
},
t.span({ className: "txt" }, "Reset form"),
),
),
),
),
);
return modal;
}
function syncFieldsAndIndexesWithScaffold(collection) {
const newScaffold = JSON.parse(JSON.stringify(app.store.collectionScaffolds[collection.type]));
// merge fields
// -----------------------------------------------------------
const oldFields = JSON.parse(JSON.stringify(collection.fields)) || [];
const nonSystemFields = oldFields.filter((f) => !f.system);
collection.fields = newScaffold.fields || [];
for (const oldField of oldFields) {
if (!oldField.system) {
continue;
}
const field = collection.fields.find((f) => f.name == oldField.name);
if (!field) {
continue;
}
// merge the default field with the existing one
Object.assign(field, oldField);
}
for (const field of nonSystemFields) {
collection.fields.push(field);
}
// merge indexes
// -----------------------------------------------------------
collection.indexes = collection.indexes || [];
if (collection.indexes.length) {
const scaffoldIndexes = newScaffold?.indexes || [];
indexesLoop: for (let i = collection.indexes.length - 1; i >= 0; i--) {
const parsed = app.utils.parseIndex(collection.indexes[i]);
const parsedName = parsed.indexName.toLowerCase();
// remove old scaffold indexes
for (const idx of scaffoldIndexes) {
const oldScaffoldName = app.utils.parseIndex(idx).indexName.toLowerCase();
if (parsedName == oldScaffoldName) {
collection.indexes.splice(i, 1);
continue indexesLoop;
}
}
// remove indexes to nonexisting fields
for (const column of parsed.columns) {
const hasFieldWithName = !!collection.fields.find(
(f) => f.name.toLowerCase() == column.name.toLowerCase(),
);
if (!hasFieldWithName) {
collection.indexes.splice(i, 1);
continue indexesLoop;
}
}
}
}
// merge new scaffold indexes
app.utils.mergeUnique(collection.indexes, newScaffold.indexes);
}
function truncateDropdownItem(data, modalSettings) {
const uniqueId = "truncate_" + app.utils.randomString();
const local = store({
isSubmitting: false,
nameConfirm: "",
});
async function truncateCollection() {
if (
local.isSubmitting
|| !data.originalCollection?.name
|| data.originalCollection.name != local.nameConfirm
) {
return false;
}
local.isSubmitting = true;
try {
await app.pb.collections.truncate(data.originalCollection.name);
modalSettings.ontruncate?.(JSON.parse(JSON.stringify(data.originalCollection)));
app.toasts.success(`Successfully truncated collection "${data.originalCollection.name}".`);
local.isSubmitting = false;
return true;
} catch (err) {
local.isSubmitting = false;
app.checkApiError(err);
}
return false;
}
return t.button(
{
type: "button",
className: "dropdown-item txt-danger",
disabled: () => local.isSubmitting,
onclick: (e) => {
e.target.closest(".dropdown").hidePopover();
app.modals.confirm(
t.div(
null,
t.h6(
{ className: "block txt-center" },
"Do you really want to delete all records of the collection?",
),
t.div(
{ className: "confirm-collection-label txt-bold m-t-sm m-b-sm" },
"Type the collection name ",
t.div(
{ className: "label" },
() => data.originalCollection.name,
app.components.copyButton(() => data.originalCollection?.name),
),
" to confirm:",
),
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".confirm_name" }, "Collection name"),
t.input({
id: uniqueId + ".confirm_name",
type: "text",
required: true,
pattern: () => RegExp.escape(data.originalCollection.name),
value: () => local.nameConfirm,
oninput: (e) => local.nameConfirm = e.target.value,
}),
),
),
async () => {
document.getElementById(uniqueId + ".confirm_name")?.reportValidity();
const truncated = await truncateCollection();
if (!truncated) {
return false;
}
app.modals.close(e.target.closest(".modal"));
},
() => {
local.nameConfirm = "";
},
);
},
},
t.i({ className: "ri-eraser-line" }),
t.span({ className: "txt" }, "Truncate"),
);
}
function deleteDropdownItem(data, modalSettings) {
const uniqueId = "delete_" + app.utils.randomString();
const local = store({
isSubmitting: false,
nameConfirm: "",
});
async function deleteCollection() {
if (
local.isSubmitting
|| !data.originalCollection?.name
|| data.originalCollection.name != local.nameConfirm
) {
return false;
}
local.isSubmitting = true;
try {
await app.pb.collections.delete(data.originalCollection.name);
modalSettings.ondelete?.(JSON.parse(JSON.stringify(data.originalCollection)));
app.utils.removeByKey(app.store.collections, "id", data.originalCollection.id);
app.toasts.success(`Successfully deleted collection "${data.originalCollection.name}".`);
local.isSubmitting = false;
return true;
} catch (err) {
local.isSubmitting = false;
app.checkApiError(err);
}
return false;
}
return t.button(
{
type: "button",
className: "dropdown-item txt-danger",
disabled: () => local.isSubmitting,
onclick: (e) => {
e.target.closest(".dropdown").hidePopover();
const collectionModal = e.target.closest(".modal");
app.modals.confirm(
t.div(
{ className: "block" },
t.h6(
{ className: "block txt-center" },
() => {
if (data.originalCollection.type == "view") {
return "Do you really want to delete the selected collection?";
}
return "Do you really want to delete the selected collection and all its records";
},
),
t.div(
{ className: "confirm-collection-label txt-bold m-t-sm m-b-sm" },
"Type the collection name ",
t.div(
{ className: "label" },
() => data.originalCollection.name,
app.components.copyButton(() => data.originalCollection?.name),
),
" to confirm:",
),
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".confirm_name" }, "Collection name"),
t.input({
id: uniqueId + ".confirm_name",
type: "text",
required: true,
pattern: () => RegExp.escape(data.originalCollection.name),
value: () => local.nameConfirm,
oninput: (e) => local.nameConfirm = e.target.value,
}),
),
),
async () => {
document.getElementById(uniqueId + ".confirm_name")?.reportValidity();
const deleted = await deleteCollection();
if (!deleted) {
return false;
}
app.modals.close(collectionModal);
},
() => {
local.nameConfirm = "";
},
);
},
},
t.i({ className: "ri-delete-bin-7-line" }),
t.span({ className: "txt" }, "Delete"),
);
}

View File

@@ -0,0 +1,192 @@
const TEST_REQUEST_KEY = "test_view_query";
export function collectionViewQueryTab(upsertData) {
const uniqueId = "query_" + app.utils.randomString();
// dprint-ignore
const autocomplete = [
"SELECT", "FROM", "WHERE", "LEFT JOIN", "INNER JOIN", "ON",
"GROUP BY", "HAVING", "ORDER BY", "LIMIT", "OFFSET", "AS",
"WITH", "NOT", "IN", "EXISTS", "LIKE", "CAST",
];
const local = store({
testRecords: [],
testError: "",
isTesting: false,
});
async function dryRunViewQuery(query) {
local.isTesting = true;
local.testRecords = [];
// reset form errors related to the query
if (app.store.errors?.viewQuery || app.store.errors?.fields) {
delete app.store.errors.viewQuery;
delete app.store.errors.fields;
}
if (!query) {
local.testError = "";
local.isTesting = false;
return;
}
try {
// @todo replace with SDK method
const result = await app.pb.send("/api/collections/meta/dry-run-view", {
method: "POST",
body: { "query": query },
requestKey: TEST_REQUEST_KEY,
});
if (upsertData.collection?.id) {
// replace the collection meta fields
local.testRecords = result.sample.map((r) => {
r.collectionId = upsertData.collection?.id;
r.collectionName = upsertData.collection?.name;
return r;
});
} else {
local.testRecords = result.sample;
}
local.testError = "";
local.isTesting = false;
} catch (err) {
if (!err.isAbort) {
local.testError = err.message || "Invalid query.";
local.isTesting = false;
}
}
}
let testDebounceId;
const watchers = [
watch(() => upsertData.collection?.viewQuery, (newQuery) => {
clearTimeout(testDebounceId);
testDebounceId = setTimeout(() => dryRunViewQuery(newQuery), 200);
}),
];
return t.div(
{
pbEvent: "collectionViewQueryTabContent",
className: "collection-tab-content collection-view-query-tab-content",
onunmount: () => {
clearTimeout(testDebounceId);
app.pb.cancelRequest(TEST_REQUEST_KEY);
watchers.forEach((w) => w?.unwatch());
},
},
t.div(
{ className: "grid" },
t.div(
{ className: "col-12" },
t.div(
{ className: "txt-right txt-sm m-b-10" },
t.button(
{
type: "button",
className: "txt-bold link-hint",
"html-popovertarget": uniqueId + "caveats_dropdown",
},
() => "Query caveats",
),
),
t.div(
{
id: uniqueId + "caveats_dropdown",
className: "dropdown sm query-caveats-dropdown",
popover: "auto",
},
t.ul(
null,
t.li(null, "Wildcard columns (*) are not supported."),
t.li(
null,
"The query must have a unique ",
t.code(null, "id"),
" column.",
t.br(),
"If your query doesn't have a suitable one, you can use the universal ",
t.code(null, "(ROW_NUMBER() OVER()) as id"),
".",
),
t.li(
null,
"Expressions must be aliased with a valid formatted field name, e.g. ",
t.code(null, "MAX(balance) as maxBalance"),
".",
),
t.li(
null,
"Combined/multi-spaced expressions must be wrapped in parenthesis, e.g. ",
t.code(null, "(MAX(balance) + 1) as maxBalance"),
".",
),
t.li(
null,
"UNION expressions are supported but the entire query must be wrapped in parenthesis.",
),
),
),
t.div(
{ className: "field" },
t.label(
{ htmlFor: uniqueId + ".viewQuery" },
t.span({ className: "txt" }, "Select query"),
t.span(
{
hidden: () => !local.testError,
className: "query-state",
ariaDescription: app.attrs.tooltip("Invalid query", "left"),
},
t.i({ className: "ri-error-warning-fill txt-danger" }),
),
t.span(
{
hidden: () => !!local.testError,
className: "query-state",
ariaDescription: app.attrs.tooltip("Valid query", "left"),
},
t.i({ className: "ri-checkbox-circle-fill txt-success" }),
),
),
app.components.codeEditor({
id: uniqueId + ".viewQuery",
name: "viewQuery",
language: "sql",
required: true,
autocomplete: autocomplete,
className: "inline-error",
value: () => upsertData.collection.viewQuery || "",
oninput: (newVal) => {
upsertData.collection.viewQuery = newVal;
},
}),
),
),
t.div(
{ className: "col-12" },
t.p({ className: "txt-sm txt-bold" }, "Sample output:"),
t.div(
{ className: "view-query-sample-wrapper" },
app.components.codeBlock({
language: () => local.testError ? "plain" : "js",
className: () => `view-query-sample ${local.testError ? "txt-danger" : ""}`,
value: () => {
if (local.testRecords?.length) {
return JSON.stringify(local.testRecords, null, 2);
}
return local.testError || "N/A";
},
}),
),
),
),
);
}

View File

@@ -0,0 +1,282 @@
window.app = window.app || {};
window.app.modals = window.app.modals || {};
window.app.modals.openCollectionsOverview = function(settings = {
onbeforeopen: null,
onafteropen: null,
onbeforeclose: null,
onafterclose: null,
}) {
const modal = collectionsOverviewModal(settings);
if (!modal) {
return;
}
document.body.appendChild(modal);
app.modals.open(modal);
};
function collectionsOverviewModal(settings = {}) {
const uniqueId = "overview_modal_" + app.utils.randomString();
const tabs = {
"Fields and relations": erd,
"Rules": rules,
};
const data = store({
showSystemCollections: false,
activeTab: Object.keys(tabs)[0],
get collections() {
if (data.showSystemCollections) {
return app.store.collections;
}
return app.store.collections.filter((c) => !c.system);
},
});
const modal = t.div(
{
pbEvent: "collectionsOverviewModal",
className: "modal popup collections-overview-modal",
onbeforeopen: (el) => {
return settings.onbeforeopen?.(el);
},
onafteropen: (el) => {
settings.onafteropen?.(el);
},
onbeforeclose: (el) => {
return settings.onbeforeclose?.(el);
},
onafterclose: (el) => {
settings.onafterclose?.(el);
el?.remove();
},
},
t.header(
{ className: "modal-header isolated" },
t.div(
{ className: "grid sm" },
t.div(
{ className: "col-12" },
t.div(
{ className: "flex" },
t.h6({ className: "modal-title" }, "Collections overview"),
t.div({ className: "flex-fill" }),
t.div(
{ className: "field" },
t.input({
id: uniqueId + ".showSystemCollections",
type: "checkbox",
className: "sm switch",
checked: () => data.showSystemCollections,
onchange: (e) => data.showSystemCollections = e.target.checked,
}),
t.label({ htmlFor: uniqueId + ".showSystemCollections" }, "System collections"),
),
t.button(
{
type: "button",
className: "btn sm secondary transparent circle modal-close-btn",
onclick: () => app.modals.close(modal),
},
t.i({ className: "ri-close-line" }),
),
),
),
t.div(
{ className: "col-12" },
t.div(
{ className: "tabs-header equal-width" },
() => {
const items = [];
for (let title in tabs) {
items.push(t.button({
type: "button",
className: () => `tab-item ${data.activeTab == title ? "active" : ""}`,
onclick: () => data.activeTab = title,
textContent: title,
}));
}
return items;
},
),
),
),
),
() => {
return tabs[data.activeTab]?.(data);
},
);
return modal;
}
function erd(data) {
return t.div(
{ className: "modal-content erd-tab" },
app.components.erd({
collections: () => {
let underscoreA, underscoreB;
function sortSystemUnderscoredLast(a, b) {
underscoreA = a.name.startsWith("_");
underscoreB = b.name.startsWith("_");
if (
(a.system && !b.system)
|| (underscoreA && !underscoreB)
) {
return 1;
}
if (
(!a.system && b.system)
|| (!underscoreA && underscoreB)
) {
return -1;
}
return 0;
}
return data.collections.slice().sort(sortSystemUnderscoredLast);
},
}),
);
}
function rules(data) {
const ruleOptions = [
{ value: "listRule", label: "List/Search rule" },
{ value: "viewRule", label: "View rule" },
{ value: "createRule", label: "Create rule", filter: (c) => c.type != "view" },
{ value: "updateRule", label: "Update rule", filter: (c) => c.type != "view" },
{ value: "deleteRule", label: "Delete rule", filter: (c) => c.type != "view" },
{ value: "authRule", label: "Auth rule", filter: (c) => c.type == "auth" },
{ value: "manageRule", label: "Manage rule", filter: (c) => c.type == "auth" },
{
value: "mfaRule",
label: "MFA rule",
emptyLabel: t.span({ className: "label info" }, "Enabled for everyone"),
rule: (c) => c.mfa?.rule,
filter: (c) => c.mfa?.enabled && c.type == "auth",
},
];
const local = store({
activeRuleOption: ruleOptions[0],
get activeCollections() {
if (!local.activeRuleOption.filter) {
return data.collections;
}
return data.collections.filter((c) => local.activeRuleOption.filter(c));
},
});
return t.div(
{ className: "modal-content rules-tab" },
t.table(
{ className: "rules-table" },
t.thead(
{ className: "sticky" },
t.tr(
null,
t.td(
{ colSpan: 99, className: "col-rule-btns" },
t.div(
{ className: "rule-btns" },
() => {
return ruleOptions.map((opt) => {
return t.button({
type: "button",
className: () =>
`btn sm ${
local.activeRuleOption?.value == opt.value
? "outline"
: "transparent secondary"
}`,
textContent: () => opt.label,
onclick: () => local.activeRuleOption = opt,
});
});
},
),
),
),
),
t.tbody(
null,
() => {
if (!local.activeCollections.length) {
return t.tr(
null,
t.td(
{ colSpan: 99, className: "txt-hint" },
t.p(null, "No collections with the selected rule."),
),
);
}
return local.activeCollections.map((collection) => {
return t.tr(
null,
t.th(
{ className: "min-width" },
t.div(
{ className: "inline-flex gap-10" },
t.i({
className: () =>
app.collectionTypes[collection.type]?.icon
|| app.utils.fallbackCollectionIcon,
}),
t.span({
className: "txt collection-name",
title: () => collection.name,
textContent: () => collection.name,
}),
),
),
() => {
let rule;
if (local.activeRuleOption.rule) {
rule = local.activeRuleOption.rule(collection);
} else {
rule = collection[local.activeRuleOption.value];
}
return t.td(
{ style: "vertical-align: top" },
() => {
if (rule === null) {
if (local.activeRuleOption.nullLabel) {
return local.activeRuleOption.nullLabel;
}
return t.span({ className: "label success" }, "Superusers only");
}
if (rule === "") {
if (local.activeRuleOption.emptyLabel) {
return local.activeRuleOption.emptyLabel;
}
return t.span({ className: "label info" }, "Public");
}
return app.components.codeBlock({
language: "pbrule",
value: rule,
});
},
);
},
);
});
},
),
),
);
}

View File

@@ -0,0 +1,258 @@
const PINNED_STORAGE_KEY = "pbPinnedCollections";
const compactThreshold = 12;
export function collectionsSidebar() {
const data = store({
search: "",
pinned: app.utils.getLocalHistory(PINNED_STORAGE_KEY, []),
get filteredCollections() {
if (!data.search.length) {
return app.store.collections;
}
const normalizedSearch = data.search.replaceAll(" ", "").toLowerCase();
return app.store.collections.filter((c) => {
return (c.name + c.id + c.type).toLowerCase().includes(normalizedSearch);
});
},
get systemCollections() {
return data.filteredCollections.filter((c) => c.system && !data.pinned.includes(c.id));
},
get regularCollections() {
return data.filteredCollections.filter((c) => !c.system && !data.pinned.includes(c.id));
},
get pinnedCollections() {
if (!data.pinned.length) {
return [];
}
return data.filteredCollections.filter((c) => data.pinned.includes(c.id));
},
});
function clearSearch() {
data.search = "";
}
const watchers = [];
return app.components.pageSidebar(
{
className: () => `collections-sidebar ${data.responsiveShow ? "active" : ""}`,
onmount: (el) => {
// init and persist pinned changes
watchers.push(watch(() => {
app.utils.saveLocalHistory(PINNED_STORAGE_KEY, JSON.stringify(data.pinned));
}));
// scroll to the active item
watchers.push(watch(
() => app.store.activeCollection?.id,
async () => {
await new Promise((r) => setTimeout(r, 0));
const activeNavItem = el?.querySelector(".nav-item.active");
const details = activeNavItem?.closest("details");
if (details) {
details.open = true;
activeNavItem?.scrollIntoView({ block: "nearest" });
}
},
));
},
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
t.div(
{ className: "sidebar-search" },
t.div(
{ className: "fields" },
t.div(
{ className: "field" },
t.input({
className: "p-r-5",
type: "text",
placeholder: "Search collections...",
value: () => data.search,
oninput: (e) => data.search = e.target.value,
}),
),
t.div(
{ className: "field addon p-l-0 p-r-5 gap-0" },
t.button(
{
hidden: () => !data.search.length,
type: "button",
className: "btn sm circle transparent secondary",
ariaDescription: app.attrs.tooltip("Clear", "left"),
onclick: clearSearch,
},
t.i({ className: "ri-close-line", ariaHidden: true }),
),
t.button(
{
hidden: () => app.store.isLoadingCollections,
type: "button",
className: "btn sm circle transparent secondary link-faded",
ariaDescription: app.attrs.tooltip("Collections overview", "left"),
onclick: () => app.modals.openCollectionsOverview(),
},
t.i({ className: "ri-organization-chart", ariaHidden: true }),
),
),
),
),
() => {
if (
!data.search.length
|| !!data.filteredCollections.length
|| app.store.isLoadingCollections
) {
return;
}
return t.div(
{ className: "block p-t-base txt-center txt-hint" },
t.p(null, "No collections found."),
t.button({
type: "button",
className: "btn sm secondary",
textContent: "Clear search",
onclick: () => clearSearch(),
}),
);
},
() => {
if (app.store.isLoadingCollections) {
return t.div({ className: "sidebar-content txt-center" }, t.span({ className: "loader sm" }));
}
return [
t.nav(
{
className: () =>
`sidebar-content collections-list scrollable ${
data.regularCollections.length + data.pinnedCollections >= compactThreshold
? "compact"
: ""
}`,
},
t.details(
{
hidden: () => !data.pinnedCollections.length,
className: () => `nav-group nav-group-pinned-collections`,
open: true,
},
t.summary(
{ tabIndex: -1, onfocusout: () => false, onclick: () => false, onkeyup: () => false },
"Pinned",
),
() => data.pinnedCollections.map((c) => collectionItem(c, data)),
),
t.details(
{
hidden: () => !data.regularCollections.length,
className: "nav-group nav-group-regular-collections",
open: true,
},
t.summary(
{ tabIndex: -1, onfocusout: () => false, onclick: () => false, onkeyup: () => false },
() => data.pinnedCollections.length ? "Others" : "Collections",
),
() => data.regularCollections.map((c) => collectionItem(c, data)),
),
t.details(
{
hidden: () => !data.systemCollections.length,
className: "nav-group nav-group-system-collections",
open: () => data.search.length,
},
t.summary(null, "System"),
() => data.systemCollections.map((c) => collectionItem(c, data)),
),
),
t.div(
{
hidden: () => data.search.length && !data.filteredCollections.length,
className: "sidebar-content new-collection",
},
t.button(
{
type: "button",
className: "btn outline block",
onclick: () => {
app.modals.openCollectionUpsert({}, {
onsave: (newCollection) => {
app.store.activeCollection = newCollection.id;
},
});
},
},
t.i({ className: "ri-add-line" }),
t.span({ textContent: "New collection" }),
),
),
];
},
);
}
function collectionItem(collection, data) {
return t.button(
{
"html-data-collection-id": () => collection.id,
type: "button",
className: () =>
`nav-item responsive-close ${collection.id == app.store.activeCollection?.id ? "active" : ""}`,
title: () => collection.name,
onclick: () => app.store.activeCollection = collection.name,
},
t.i({ className: () => app.collectionTypes[collection.type]?.icon || app.utils.fallbackCollectionIcon }),
t.span({ className: "txt" }, () => collection.name),
() => {
if (
collection.type != "auth"
|| !collection.oauth2?.enabled
|| collection.oauth2?.providers?.length > 0
) {
return;
}
return t.i({
ariaHidden: true,
className: "ri-alert-line txt-hint txt-sm",
ariaDescription: app.attrs.tooltip(
"OAuth2 auth is enabled but the collection doesn't have any registered providers",
),
});
},
() => {
const pinnedIndex = data.pinned.indexOf(collection.id);
return t.span(
{
tabIndex: -1,
role: "button",
className: "pin",
title: () => pinnedIndex >= 0 ? "Unpin" : "Pin",
onclick: (e) => {
e.preventDefault();
e.stopPropagation();
if (pinnedIndex >= 0) {
data.pinned.splice(pinnedIndex, 1);
} else {
data.pinned.push(collection.id);
}
},
},
t.i({
ariaHidden: false,
className: () => pinnedIndex >= 0 ? "ri-unpin-line" : "ri-pushpin-line",
}),
);
},
);
}

View File

@@ -0,0 +1,112 @@
export function emailTemplateAccordion(collection, key, propsArg = {}) {
const uniqueId = "emailTemplate" + app.utils.randomString();
const props = store({
title: "Email template",
placeholders: [],
});
const watchers = app.utils.extendStore(props, propsArg);
const data = store({
get config() {
let val = app.utils.getByPath(collection, key);
if (!val) {
val = { subject: "", body: "" };
app.utils.setByPath(collection, key, val);
}
return val;
},
get tokensList() {
return [];
},
});
const placeholdersList = () => {
if (!props.placeholders?.length) {
return;
}
return t.div(
{ className: "field-help" },
t.div({ className: "flex flex-wrap gap-5" }, t.span({ className: "txt" }, "Placeholders:"), () => {
return props.placeholders.map((p) => {
return t.span({ className: "label sm" }, app.components.copyButton(p, p));
});
}),
);
};
return t.details(
{
pbEvent: "emailTemplateAccordion",
className: "accordion email-template-accordion",
name: "email-template",
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
t.summary(
null,
t.i({ className: "ri-draft-line" }),
t.span({ className: "txt", textContent: () => props.title }),
() => {
if (!app.utils.getByPath(app.store.errors, key)) {
return;
}
return t.i({
className: "ri-error-warning-fill txt-danger m-l-auto",
ariaDescription: app.attrs.tooltip("Has errors", "left"),
});
},
),
t.div(
{ className: "grid" },
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.label({
htmlFor: uniqueId + ".subject",
textContent: "Subject",
}),
app.components.codeEditor({
id: uniqueId + ".subject",
name: key + ".subject",
required: true,
singleLine: true,
language: "text",
autocomplete: props.placeholders,
value: () => data.config.subject || "",
oninput: (val) => (data.config.subject = val),
}),
),
placeholdersList,
),
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.label({
htmlFor: uniqueId + ".body",
textContent: "Body (HTML)",
}),
app.components.codeEditor({
id: uniqueId + ".body",
name: key + ".body",
required: true,
language: "html",
className: "pre-wrap",
autocomplete: props.placeholders,
value: () => data.config.body || "",
oninput: (val) => (data.config.body = val),
}),
),
placeholdersList,
),
),
);
}

View File

@@ -0,0 +1,273 @@
window.app = window.app || {};
window.app.modals = window.app.modals || {};
window.app.modals.openIndexUpsert = function(
collection,
index = "",
settings = {
onsave: () => {},
ondelete: () => {},
},
) {
const modal = indexUpsertModal(collection, index, settings);
if (!modal) {
return;
}
document.body.appendChild(modal);
app.modals.open(modal);
};
function indexUpsertModal(collection, index = "", settings = {}) {
if (!collection) {
console.warn("[indexUpsertModal] missing required collection argument");
return;
}
let modal;
const uniqueId = app.utils.randomString();
const data = store({
originalIndex: "",
index: "",
get isNew() {
return data.originalIndex == "";
},
get indexParts() {
return app.utils.parseIndex(data.index);
},
get lowerCasedIndexColumnNames() {
return data.indexParts.columns.map((c) => c.name.toLowerCase());
},
get canSave() {
return data.lowerCasedIndexColumnNames.length > 0;
},
});
const presetColumns = collection?.fields?.filter((f) => !f["@toDelete"] && f.name != "id")?.map((f) => f.name)
|| [];
function loadIndex(index) {
data.originalIndex = index || "";
if (!index) {
const parsed = app.utils.parseIndex("");
parsed.tableName = collection?.name || "";
index = app.utils.buildIndex(parsed);
}
data.index = index;
}
function saveIndex() {
if (!collection || !data.canSave) {
console.warn("[saveIndex] no collection or invalid save state:", collection, data.canSave);
return;
}
collection.indexes = collection.indexes || [];
// search for existing
const pos = collection.indexes.findIndex((index) => index == data.originalIndex);
if (pos >= 0) {
// replace
collection.indexes[pos] = data.index;
app.utils.deleteByPath(app.store.errors, "indexes." + pos);
} else {
// push missing
collection.indexes.push(data.index);
}
if (typeof settings?.onsave == "function") {
settings.onsave({
collection: collection,
index: data.index,
oldIndex: data.originalIndex,
});
}
clearIndexError();
app.modals.close(modal);
}
function deleteIndex() {
if (!collection || !data.originalIndex) {
console.warn("[deleteIndex] no collection or index:", collection, data.originalIndex);
return;
}
const pos = collection.indexes?.findIndex((index) => index == data.originalIndex);
if (pos == -1) {
console.warn("[deleteIndex] missing index:", data.originalIndex);
return;
}
collection.indexes.splice(pos, 1);
app.utils.deleteByPath(app.store.errors, "indexes." + pos);
if (typeof settings?.ondelete == "function") {
settings.ondelete({
collection: collection,
position: pos,
index: data.originalIndex,
});
}
clearIndexError();
app.modals.close(modal);
}
function toggleColumn(column) {
const clone = JSON.parse(JSON.stringify(data.indexParts));
clone.tableName = collection?.name || "";
const colLowerCased = column.toLowerCase();
const i = clone.columns.findIndex((c) => c.name.toLowerCase() == colLowerCased);
if (i >= 0) {
clone.columns.splice(i, 1);
} else {
app.utils.pushUnique(clone.columns, { name: column });
}
data.index = app.utils.buildIndex(clone);
clearIndexError();
}
function clearIndexError() {
if (app.store.errors?.indexes) {
const pos = collection.indexes.findIndex((idx) => idx == data.originalIndex);
app.utils.deleteByPath(app.store.errors, "indexes." + pos);
}
}
modal = t.div(
{
className: "modal popup index-upsert-modal",
onbeforeopen: () => {
loadIndex(index);
},
onafteropen: () => {
// retrigger indexes error (if any)
if (app.store.errors?.indexes) {
app.store.errors.indexes = JSON.parse(JSON.stringify(app.store.errors.indexes));
}
},
onafterclose: (el) => {
el?.remove();
},
},
t.header(
{ className: "modal-header" },
t.h6(
{ className: "modal-title" },
t.span({ className: "txt" }, () => (data.isNew ? "Create index" : "Update index")),
),
),
t.div(
{ className: "modal-content" },
t.form(
{
id: uniqueId + "form",
className: "grid sm index-upsert-form",
onsubmit: (e) => {
e.preventDefault();
saveIndex();
},
},
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.input({
type: "checkbox",
className: "switch",
id: uniqueId + "checkbox_unique",
checked: () => data.indexParts.unique,
onchange: (e) => {
const newIndexParts = JSON.parse(JSON.stringify(data.indexParts));
newIndexParts.unique = e.target.checked;
newIndexParts.tableName = newIndexParts.tableName || collection?.name || "";
data.index = app.utils.buildIndex(newIndexParts);
},
}),
t.label({ htmlFor: uniqueId + "checkbox_unique" }, "Unique"),
),
),
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
app.components.codeEditor({
required: true,
className: "collection-index-input pre-wrap",
name: () => "indexes." + collection.indexes?.findIndex((idx) => idx == data.originalIndex),
placeholder: () => `e.g. CREATE INDEX idx_test on ${collection?.name || "X"} (created)`,
value: () => data.index,
oninput: (val) => (data.index = val),
}),
),
t.div(
{ hidden: () => !presetColumns.length, className: "field-help m-t-sm" },
t.div(
{ className: "flex flex-wrap gap-5" },
t.span({ className: "txt", textContent: "Presets:" }),
() => {
return presetColumns?.map((col) => {
const isSelected = data.lowerCasedIndexColumnNames.includes(col.toLowerCase());
return t.button({
type: "button",
textContent: col,
className: () => `label handle ${isSelected ? "success" : ""}`,
onclick: () => toggleColumn(col),
});
});
},
),
),
),
),
),
t.footer(
{ className: "modal-footer gap-base" },
t.button(
{
type: "button",
className: "btn transparent m-r-auto",
onclick: () => app.modals.close(modal),
},
t.span({ className: "txt" }, "Close"),
),
t.button(
{
hidden: () => data.isNew,
type: "button",
className: () => "btn sm circle transparent secondary",
ariaDescription: app.attrs.tooltip("Delete index", "left"),
onclick: () => {
app.modals.confirm(
"Do you really want to remove the selected index from the collection?",
deleteIndex,
);
},
},
t.i({ className: "ri-delete-bin-7-line" }),
),
t.button(
{
"type": "submit",
"html-form": uniqueId + "form",
"disabled": () => !data.canSave,
"className": () => "btn expanded",
},
t.span({ className: "txt" }, "Set index"),
),
),
);
return modal;
}

View File

@@ -0,0 +1,131 @@
export function mfaAccordion(collection) {
const uniqueId = "mfa_" + app.utils.randomString();
const data = store({
get config() {
if (!collection.mfa) {
collection.mfa = {
enabled: false,
duration: 900,
rule: "",
};
}
return collection.mfa;
},
});
return t.details(
{
pbEvent: "mfaAccordion",
name: "auth-methods",
className: "accordion mfa-accordion",
},
t.summary(
null,
t.i({ className: "ri-shield-check-line" }),
t.span({ className: "txt", textContent: "Multi-factor authentication (MFA)" }),
t.span({
className: () => `label m-l-auto ${data.config.enabled ? "success" : ""}`,
textContent: () => (data.config.enabled ? "Enabled" : "Disabled"),
}),
() => {
if (!app.store.errors?.mfa) {
return;
}
return t.i({
className: "ri-error-warning-fill txt-danger",
ariaDescription: app.attrs.tooltip("Has errors", "left"),
});
},
),
t.div(
{ className: "grid sm" },
t.div(
{ className: "col-sm-12" },
t.div(
{ className: "alert info" },
t.div(
{ className: "content" },
t.p(
null,
"Multi-factor authentication (MFA) requires the user to authenticate with any 2 different auth methods (otp, identity/password, oauth2) before issuing an auth token. ",
t.a({
href: import.meta.env.PB_MFA_DOCS,
className: "link-hint",
target: "_blank",
rel: "noopener noreferrer",
textContent: "Learn more.",
}),
),
),
),
),
t.div(
{ className: "col-sm-12" },
t.div(
{ className: "field" },
t.input({
type: "checkbox",
id: uniqueId + ".enabled",
name: "mfa.enabled",
className: "switch",
checked: () => data.config.enabled,
onchange: (e) => (data.config.enabled = e.target.checked),
}),
t.label({
htmlFor: uniqueId + ".enabled",
textContent: "Enable",
}),
),
),
t.div(
{ className: "col-sm-12" },
t.div(
{ className: "field" },
t.label({
htmlFor: uniqueId + ".duration",
textContent: "Max duration between 2 authentications (in seconds)",
}),
t.input({
type: "number",
id: uniqueId + ".duration",
name: "mfa.duration",
min: 1,
step: 1,
required: true,
value: () => data.config.duration || "",
oninput: (e) => (data.config.duration = parseInt(e.target.value, 10)),
}),
),
),
t.div(
{ className: "col-sm-12" },
app.components.ruleField({
label: "MFA rule",
id: uniqueId + ".rule",
name: "mfa.rule",
nullable: false,
placeholder: "Leave empty to require MFA for everyone",
autocomplete: (word) => {
return app.utils.collectionAutocompleteKeys(collection, word);
},
value: () => data.config.rule || "",
oninput: (newVal) => (data.config.rule = newVal),
}),
t.div(
{ className: "field-help" },
t.p(null, "This optional rule could be used to enable/disable MFA per account basis."),
t.p(
null,
"For example, to require MFA only for accounts with non-empty email you can set it to ",
t.code(null, "email != ''"),
".",
),
t.p(null, "Leave the rule empty to require MFA for everyone."),
),
),
),
);
}

View File

@@ -0,0 +1,236 @@
window.app = window.app || {};
window.app.oauth2 = window.app.oauth2 || {};
// note: data is the providerSettingsModal form store
window.app.oauth2.apple = function(providerInfo, namePrefix, data) {
const uniqueId = "apple_" + app.utils.randomString();
return t.div(
{ pbEvent: "oauth2AppleOptions", className: "oauth2-apple-options" },
t.button(
{
type: "button",
className: "btn sm secondary",
onclick: () => {
app.modals.openAppleSecretGenerator({
ongenerate: (secret) => {
data.config.clientSecret = secret;
},
});
},
},
t.i({ className: "ri-key-line" }),
t.span({ className: "txt" }, "Generate secret"),
),
);
};
window.app.modals = window.app.modals || {};
window.app.modals.openAppleSecretGenerator = function(modalSettings = {
onbeforeopen: null,
onafteropen: null,
onbeforeclose: null,
onafterclose: null,
ongenerate: null, // (secret) => {}
}) {
const modal = appleSecretGeneratorModal(modalSettings);
if (!modal) {
return;
}
document.body.appendChild(modal);
app.modals.open(modal);
};
function appleSecretGeneratorModal(modalSettings = {}) {
let modal;
const uniqueId = "secret_generator_" + app.utils.randomString();
const maxDuration = 15777000; // 6 months
const data = store({
clientId: "",
teamId: "",
keyId: "",
privateKey: "",
duration: maxDuration,
isSubmitting: false,
});
async function submit() {
data.isSubmitting = true;
try {
const result = await app.pb.settings.generateAppleClientSecret(
data.clientId,
data.teamId,
data.keyId,
data.privateKey.trim(),
data.duration,
);
data.isSubmitting = false;
app.toasts.success("Successfully generated client secret.");
modalSettings.ongenerate?.(result.secret);
app.modals.close(modal);
} catch (err) {
if (!err.isAbort) {
app.checkApiError(err);
data.isSubmitting = false;
}
}
}
modal = t.div(
{
pbEvent: "appleSecretGeneratorModal",
className: "modal popup apple-secret-generator-modal",
onbeforeopen: (el) => {
return modalSettings.onbeforeopen?.(el);
},
onafteropen: (el) => {
modalSettings.onafteropen?.(el);
},
onbeforeclose: (el) => {
return modalSettings.onbeforeclose?.(el);
},
onafterclose: (el) => {
modalSettings.onafterclose?.(el);
el?.remove();
},
},
t.header(
{ className: "modal-header" },
t.h5({ className: "m-auto" }, "Generate Apple client secret"),
),
t.form(
{
id: uniqueId + "_form",
className: "modal-content",
onsubmit: (e) => {
e.preventDefault();
submit();
},
},
t.div(
{ className: "grid" },
t.div(
{ className: "col-sm-6" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".clientId" }, "Client ID"),
t.input({
id: uniqueId + ".clientId",
name: "clientId",
type: "text",
required: true,
value: () => data.clientId || "",
oninput: (e) => data.clientId = e.target.value,
}),
),
),
t.div(
{ className: "col-sm-6" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".teamId" }, "Team ID"),
t.input({
id: uniqueId + ".teamId",
name: "teamId",
type: "text",
required: true,
value: () => data.teamId || "",
oninput: (e) => data.teamId = e.target.value,
}),
),
),
t.div(
{ className: "col-sm-6" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".keyId" }, "Key ID"),
t.input({
id: uniqueId + ".keyId",
name: "keyId",
type: "text",
required: true,
value: () => data.keyId || "",
oninput: (e) => data.keyId = e.target.value,
}),
),
),
t.div(
{ className: "col-sm-6" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".duration" }, "Duration (in seconds)"),
t.input({
id: uniqueId + ".duration",
name: "duration",
type: "number",
min: 0,
step: 1,
max: maxDuration,
required: true,
value: () => data.duration || 0,
oninput: (e) => data.duration = parseInt(e.target.value, 10),
}),
),
t.div(
{ className: "field-help" },
`Max ${maxDuration} seconds (~${(maxDuration / (60 * 60 * 24 * 30)) << 0} months).`,
),
),
t.div(
{ className: "col-sm-12" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".privateKey" }, "Private key"),
t.textarea({
id: uniqueId + ".privateKey",
name: "privateKey",
type: "text",
required: true,
rows: 8,
placeholder: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----",
value: () => data.privateKey || "",
oninput: (e) => data.privateKey = e.target.value,
}),
),
t.div(
{ className: "field-help" },
"The key is not stored on the server and it is used only for generating the signed JWT.",
),
),
),
),
t.footer(
{ className: "modal-footer" },
t.button(
{
type: "button",
className: "btn transparent m-r-auto",
onclick: () => app.modals.close(modal),
},
t.span({ className: "txt" }, "Close"),
),
t.button(
{
"html-form": uniqueId + "_form",
type: "submit",
className: "btn expanded",
},
t.i({ className: "ri-key-line" }),
t.span({ className: "txt" }, "Generate secret"),
),
),
);
return modal;
}

View File

@@ -0,0 +1,73 @@
window.app = window.app || {};
window.app.oauth2 = window.app.oauth2 || {};
// note: data is the providerSettingsModal form store
window.app.oauth2.lark = function(providerInfo, namePrefix, data) {
const uniqueId = "lark_" + app.utils.randomString();
const domainOptions = [
{ label: "Feishu (China)", value: "feishu.cn" },
{ label: "Lark (International)", value: "larksuite.com" },
];
const local = store({
domain: data.config.authURL?.includes(domainOptions[1].value)
? domainOptions[1].value
: domainOptions[0].value,
});
const watchers = [
watch(() => local.domain, (domain) => {
if (domain) {
data.config.authURL = `https://accounts.${domain}/open-apis/authen/v1/authorize`;
data.config.tokenURL = `https://open.${domain}/open-apis/authen/v2/oauth/token`;
data.config.userInfoURL = `https://open.${domain}/open-apis/authen/v1/user_info`;
}
}),
];
return t.div(
{
pbEvent: "oauth2LarkOptions",
className: "oauth2-lark-options",
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
t.div(
{ className: "grid" },
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".site" }, "Site"),
app.components.select({
options: domainOptions,
required: true,
value: () => local.domain || "",
onchange: (selectedOpts) => {
local.domain = selectedOpts?.[0]?.value;
},
}),
),
),
t.div(
{ className: "col-12" },
t.div(
{ className: "alert info" },
"Note that the Lark user's ",
t.strong(null, "Union ID"),
" will be used for the association with the PocketBase user (see ",
t.a({
href:
"https://open.feishu.cn/document/platform-overveiw/basic-concepts/user-identity-introduction/introduction#3f2d4b63",
target: "_blank",
rel: "noopener noreferrer",
textContent: "Different Types of Lark User IDs",
}),
").",
),
),
),
);
};

View File

@@ -0,0 +1,53 @@
window.app = window.app || {};
window.app.oauth2 = window.app.oauth2 || {};
// note: data is the providerSettingsModal form store
window.app.oauth2.microsoft = function(providerInfo, namePrefix, data) {
const uniqueId = "microsoft_" + app.utils.randomString();
return t.div(
{ pbEvent: "oauth2MicrosoftOptions", className: "oauth2-microsoft-options" },
t.p({ className: "txt-bold" }, "Azure AD endpoints"),
t.div(
{ className: "grid" },
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".authURL" }, "Auth URL"),
t.input({
id: uniqueId + ".authURL",
name: namePrefix + ".authURL",
type: "url",
required: true,
value: () => data.config.authURL || "",
oninput: (e) => data.config.authURL = e.target.value,
}),
),
t.div(
{ className: "field-help" },
"Ex. https://login.microsoftonline.com/YOUR_DIRECTORY_TENANT_ID/oauth2/v2.0/authorize",
),
),
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".tokenURL" }, "Token URL"),
t.input({
id: uniqueId + ".tokenURL",
name: namePrefix + ".tokenURL",
type: "url",
required: true,
value: () => data.config.tokenURL || "",
oninput: (e) => data.config.tokenURL = e.target.value,
}),
),
t.div(
{ className: "field-help" },
"Ex. https://login.microsoftonline.com/YOUR_DIRECTORY_TENANT_ID/oauth2/v2.0/token",
),
),
),
);
};

View File

@@ -0,0 +1,234 @@
window.app = window.app || {};
window.app.oauth2 = window.app.oauth2 || {};
// note: data is the providerSettingsModal form store
window.app.oauth2.oidc = function(providerInfo, namePrefix, data) {
const uniqueId = "oidc_" + app.utils.randomString();
const userInfoOptions = [
{ label: "User info URL", value: true },
{ label: "ID Token", value: false },
];
const local = store({
useUserInfoUrl: false,
});
const watchers = [];
return t.div(
{
pbEvent: "oauth2OIDCOptions",
className: "oauth2-oidc-options",
// init defaults
onmount: (el) => {
if (typeof data.config.displayName == "undefined") {
data.config.displayName = "OIDC";
}
if (typeof data.config.pkce == "undefined") {
data.config.pkce = true;
}
if (data.config.userInfoURL || !data.config.extra) {
local.useUserInfoUrl = true;
}
// unset the id_token or info url fields based on the toggle state
watchers.push(
watch(() => local.useUserInfoUrl, (useURL, oldUseURL) => {
if (useURL) {
// note: null because {} will just result in JSON unmarshal merge with the existing data
data.config.extra = null;
} else {
data.config.userInfoURL = "";
// note: fallback to empty object to distinguish from the null state since all id_token fields are optional
data.config.extra = data.config.extra || {};
}
}),
);
},
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
t.div(
{ className: "grid" },
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".displayName" }, "Display name"),
t.input({
id: uniqueId + ".displayName",
name: namePrefix + ".displayName",
type: "text",
required: true,
value: () => data.config.displayName || "",
oninput: (e) => data.config.displayName = e.target.value,
}),
),
),
t.div(
{ className: "col-12" },
t.p({ className: "txt-bold" }, "Endpoints"),
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".authURL" }, "Auth URL"),
t.input({
id: uniqueId + ".authURL",
name: namePrefix + ".authURL",
type: "url",
required: true,
value: () => data.config.authURL || "",
oninput: (e) => data.config.authURL = e.target.value,
}),
),
),
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".tokenURL" }, "Token URL"),
t.input({
id: uniqueId + ".tokenURL",
name: namePrefix + ".tokenURL",
type: "url",
required: true,
value: () => data.config.tokenURL || "",
oninput: (e) => data.config.tokenURL = e.target.value,
}),
),
),
// User info
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".userInfoSelect" }, "Fetch user info from"),
app.components.select({
id: uniqueId + ".userInfoSelect",
required: true,
options: userInfoOptions,
value: () => local.useUserInfoUrl,
onchange: (selectedOpts) => local.useUserInfoUrl = selectedOpts?.[0]?.value,
}),
),
t.div({ className: "oidc-userinfo-options m-t-10" }, () => {
if (local.useUserInfoUrl) {
return t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".userInfoURL" }, "User info URL"),
t.input({
id: uniqueId + ".userInfoURL",
name: namePrefix + ".userInfoURL",
type: "url",
required: true,
value: () => data.config.userInfoURL || "",
oninput: (e) => data.config.userInfoURL = e.target.value,
}),
);
}
return t.div(
{ className: "grid sm" },
t.div(
{ className: "col-12 txt-hint txt-sm" },
t.em(
null,
"Both fields are considered optional because the parsed ",
t.code(null, "id_token"),
" is a direct result of the TLS code->token exchange server response.",
),
),
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.label(
{ htmlFor: uniqueId + ".extra.jwksURL" },
t.span({ className: "txt" }, "JWKS verification URL"),
t.i({
ariaHidden: true,
className: "ri-information-line link-hint",
ariaDescription: app.attrs.tooltip(
"URL to the public token verification keys.",
),
}),
),
t.input({
id: uniqueId + ".extra.jwksURL",
name: namePrefix + ".extra.jwksURL",
type: "url",
value: () => data.config.extra?.jwksURL || "",
oninput: (e) => {
data.config.extra = data.config.extra || {};
data.config.extra.jwksURL = e.target.value;
},
}),
),
),
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.label(
{ htmlFor: uniqueId + ".extra.issuers" },
t.span({ className: "txt" }, "Issuers"),
t.i({
ariaHidden: true,
className: "ri-information-line link-hint",
ariaDescription: app.attrs.tooltip(
"Comma separated list of accepted values for the iss token claim validation.",
),
}),
),
t.input({
id: uniqueId + ".extra.issuers",
name: namePrefix + ".extra.issuers",
type: "text",
value: () => app.utils.joinNonEmpty(data.config.extra?.issuers),
oninput: (e) => {
const newValue = app.utils.splitNonEmpty(e.target.value, ",");
const newStr = app.utils.joinNonEmpty(newValue);
const oldStr = app.utils.joinNonEmpty(data.config.extra?.issuers);
// has an actual change
if (oldStr != newStr) {
data.config.extra = data.config.extra || {};
data.config.extra.issuers = newValue;
}
},
}),
),
),
);
}),
),
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.input({
id: uniqueId + ".pkce",
name: namePrefix + ".pkce",
type: "checkbox",
checked: () => data.config.pkce || false,
onchange: (e) => data.config.pkce = e.target.checked,
}),
t.label(
{ htmlFor: uniqueId + ".pkce" },
t.span({ className: "txt", textContent: "Support PKCE" }),
t.i({
className: "ri-information-line link-hint",
ariaHidden: true,
ariaDescription: app.attrs.tooltip(
"Usually it should be safe to be always enabled as most providers will just ignore the extra query parameters if they don't support PKCE.",
),
}),
),
),
),
),
);
};

View File

@@ -0,0 +1,115 @@
window.app = window.app || {};
window.app.components = window.app.components || {};
// note: data is the providerSettingsModal form store
window.app.components.oauth2EndpointFields = function(providerInfo, namePrefix, data, settingsArg = {}) {
const uniqueId = "endpoints_" + app.utils.randomString();
const settings = store({
required: true,
title: "Provider endpoints",
});
const watchers = app.utils.extendStore(settings, settingsArg);
return t.div(
{
pbEvent: "oauth2Endpoints",
className: "oauth2-endpoints",
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
},
},
t.p(
{ className: "txt-bold" },
(el) => {
if (typeof settings.title == "function") {
settings.title(el);
}
return settings.title;
},
),
t.div(
{ className: "grid" },
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".authURL" }, "Auth URL"),
t.input({
id: uniqueId + ".authURL",
name: namePrefix + ".authURL",
type: "url",
required: () => !!settings.required,
value: () => data.config.authURL || "",
oninput: (e) => data.config.authURL = e.target.value,
}),
),
),
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".tokenURL" }, "Token URL"),
t.input({
id: uniqueId + ".tokenURL",
name: namePrefix + ".tokenURL",
type: "url",
required: () => !!settings.required,
value: () => data.config.tokenURL || "",
oninput: (e) => data.config.tokenURL = e.target.value,
}),
),
),
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".userInfoURL" }, "User info URL"),
t.input({
id: uniqueId + ".userInfoURL",
name: namePrefix + ".userInfoURL",
type: "url",
required: () => !!settings.required,
value: () => data.config.userInfoURL || "",
oninput: (e) => data.config.userInfoURL = e.target.value,
}),
),
),
),
);
};
window.app.oauth2 = window.app.oauth2 || {};
window.app.oauth2.gitlab = function(providerInfo, namePrefix, data) {
return app.components.oauth2EndpointFields(
providerInfo,
namePrefix,
data,
{
required: false,
title: "Self-hosted endpoints (optional)",
},
);
};
window.app.oauth2.gitea = function(providerInfo, namePrefix, data) {
return app.components.oauth2EndpointFields(
providerInfo,
namePrefix,
data,
{
required: false,
title: "Self-hosted endpoints (optional)",
},
);
};
window.app.oauth2.mailcow = function(providerInfo, namePrefix, data) {
return app.components.oauth2EndpointFields(
providerInfo,
namePrefix,
data,
);
};

View File

@@ -0,0 +1,314 @@
const excludedFieldNames = ["id", "email", "emailVisibility", "verified", "tokenKey", "password"];
const allowedRegularTypes = ["text", "editor", "url", "email", "json"];
export function oauth2Accordion(collection) {
const uniqueId = "oauth2_" + app.utils.randomString();
const data = store({
get config() {
if (!collection.oauth2) {
collection.oauth2 = {
enabled: false,
mappedFields: {},
providers: [],
};
}
return collection.oauth2;
},
get regularFieldOptions() {
return collection.fields
?.filter((f) => {
return allowedRegularTypes.includes(f.type) && !excludedFieldNames.includes(f.name);
})
.map((f) => {
return { value: f.name };
});
},
get regularAndFileFieldOptions() {
return collection.fields
?.filter((f) => {
return (
(f.type == "file" || allowedRegularTypes.includes(f.type))
&& !excludedFieldNames.includes(f.name)
);
})
.map((f) => {
return { value: f.name };
});
},
showMapping: false,
});
function clearProviderErrors(index) {
app.utils.deleteByPath(app.store.errors, "oauth2.providers." + index);
}
return t.details(
{
pbEvent: "oauth2Accordion",
name: "auth-methods",
className: "accordion oauth2-accordion",
},
t.summary(
null,
t.i({ className: "ri-profile-line" }),
t.span({ className: "txt", textContent: "OAuth2" }),
t.span({
className: () => `label m-l-auto ${data.config.enabled ? "success" : ""}`,
textContent: () => (data.config.enabled ? "Enabled" : "Disabled"),
}),
() => {
if (app.utils.isEmpty(app.store.errors?.oauth2)) {
return;
}
return t.i({
className: "ri-error-warning-fill txt-danger",
ariaDescription: app.attrs.tooltip("Has errors", "left"),
});
},
),
t.div(
{ className: "grid sm" },
t.div(
{ className: "col-sm-12" },
t.div(
{ className: "field" },
t.input({
type: "checkbox",
id: uniqueId + ".enabled",
name: "oauth2.enabled",
className: "switch",
checked: () => data.config.enabled,
onchange: (e) => (data.config.enabled = e.target.checked),
}),
t.label({
htmlFor: uniqueId + ".enabled",
textContent: "Enable",
}),
),
),
() => {
return data.config.providers.map((providerConfig, configIndex) => {
const providerId = uniqueId + providerConfig.name;
const providerInfo = app.store.oauth2Providers?.find((p) => p.name == providerConfig.name) || {};
return t.div(
{ className: "col-sm-6" },
t.div(
{
className: () => {
let result = "provider-card";
if (!app.utils.isEmpty(app.store.errors?.oauth2?.providers?.[configIndex])) {
result += " error";
}
return result;
},
},
t.figure(
{ className: "provider-logo" },
() => {
if (providerInfo.logo) {
return t.img({
src: "data:image/svg+xml;base64," + btoa(providerInfo.logo),
alt: providerConfig.name + " logo",
});
}
return t.i({ className: app.utils.fallbackProviderIcon });
},
),
t.div(
{ className: "content" },
t.span(
{ className: "primary-txt" },
() => providerConfig.displayName || providerInfo.displayName || providerInfo.name,
),
t.span({ className: "secondary-txt" }, () => providerConfig.name || providerInfo.name),
),
t.div(
{ className: "actions" },
t.button(
{
"type": "button",
"className": "btn secondary transparent sm circle",
"html-popovertarget": providerId + "dropdown",
},
t.i({ className: "ri-more-2-line" }),
),
t.div(
{
id: providerId + "dropdown",
className: "dropdown sm",
popover: "auto",
},
t.button(
{
type: "button",
className: "dropdown-item",
onclick: (e) => {
e.target.closest(".dropdown").hidePopover();
app.modals.openProviderSettings(providerConfig, {
namePrefix: "oauth2.providers." + configIndex,
onsubmit: (providerInfo, providerConfig) => {
data.config.providers[configIndex] = providerConfig;
clearProviderErrors(configIndex);
},
});
},
},
t.span({ className: "txt" }, "Settings"),
),
t.hr(),
t.button(
{
type: "button",
className: "dropdown-item",
onclick: (e) => {
e.target.closest(".dropdown").hidePopover();
app.modals.confirm(
`Do you really want to remove provider "${
providerConfig.displayName || providerInfo.displayName
|| providerInfo.name
}"?`,
() => {
clearProviderErrors(configIndex);
data.config.providers.splice(configIndex, 1);
if (data.config.providers.length == 0) {
data.config.enabled = false;
}
},
);
},
},
t.span({ className: "txt" }, "Remove"),
),
),
),
),
);
});
},
t.div(
{ className: "col-sm-6" },
t.button(
{
type: "button",
className: "btn lg block secondary add-provider-btn",
onclick: () => {
app.modals.openProviderPicker({
exclude: data.config.providers.map((p) => p.name),
onselect: (providerInfo) => {
app.modals.openProviderSettings({ name: providerInfo.name }, {
onsubmit: (providerInfo, providerConfig) => {
if (data.config.providers.length == 0) {
data.config.enabled = true;
}
data.config.providers.push(providerConfig);
},
});
},
});
},
},
t.i({ className: "ri-add-line" }),
t.span({ className: "txt " }, "Add provider"),
),
),
t.div(
{ className: "col-sm-12" },
t.button(
{
type: "button",
className: () => `btn secondary sm ${data.showMapping ? "" : "transparent"}`,
onclick: () => (data.showMapping = !data.showMapping),
},
t.span({ className: "txt" }, "Optional users create fields mapping"),
t.i({
className: () => (data.showMapping ? "ri-arrow-drop-up-line" : "ri-arrow-drop-down-line"),
}),
),
app.components.slide(
() => data.showMapping,
t.div(
{ className: "grid sm m-t-sm" },
t.div(
{ className: "col-sm-6" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".mappedFields.name" }, "OAuth2 full name"),
app.components.select({
id: uniqueId + ".mappedFields.name",
name: "oauth2.mappedFields.name",
placeholder: "Select field",
options: () => data.regularFieldOptions,
value: () => collection.oauth2.mappedFields.name,
onchange: (selectedOpts) => {
collection.oauth2.mappedFields.name = selectedOpts?.[0]?.value || "";
},
}),
),
),
t.div(
{ className: "col-sm-6" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".mappedFields.avatarURL" }, "OAuth2 avatar"),
app.components.select({
id: uniqueId + ".mappedFields.avatarURL",
name: "oauth2.mappedFields.avatarURL",
placeholder: "Select field",
options: () => data.regularAndFileFieldOptions,
value: () => collection.oauth2.mappedFields.avatarURL,
onchange: (selectedOpts) => {
collection.oauth2.mappedFields.avatarURL = selectedOpts?.[0]?.value || "";
},
}),
),
),
t.div(
{ className: "col-sm-6" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".mappedFields.id" }, "OAuth2 id"),
app.components.select({
id: uniqueId + ".mappedFields.id",
name: "oauth2.mappedFields.id",
placeholder: "Select field",
options: () => data.regularFieldOptions,
value: () => collection.oauth2.mappedFields.id,
onchange: (selectedOpts) => {
collection.oauth2.mappedFields.id = selectedOpts?.[0]?.value || "";
},
}),
),
),
t.div(
{ className: "col-sm-6" },
t.div(
{ className: "field" },
t.label({ htmlFor: uniqueId + ".mappedFields.username" }, "OAuth2 username"),
app.components.select({
id: uniqueId + ".mappedFields.username",
name: "oauth2.mappedFields.username",
placeholder: "Select field",
options: () => data.regularFieldOptions,
value: () => collection.oauth2.mappedFields.username,
onchange: (selectedOpts) => {
collection.oauth2.mappedFields.username = selectedOpts?.[0]?.value || "";
},
}),
),
),
),
),
),
),
);
}

View File

@@ -0,0 +1,105 @@
export function otpAccordion(collection) {
const uniqueId = "otp_" + app.utils.randomString();
const data = store({
get config() {
if (!collection.otp) {
collection.otp = {
enabled: false,
duration: 300,
length: 8,
};
}
return collection.otp;
},
});
return t.details(
{
pbEvent: "otpAccordion",
name: "auth-methods",
className: "accordion otp-accordion",
},
t.summary(
null,
t.i({ className: "ri-time-line" }),
t.span({ className: "txt", textContent: "One-time password (OTP)" }),
t.span({
className: () => `label m-l-auto ${data.config.enabled ? "success" : ""}`,
textContent: () => (data.config.enabled ? "Enabled" : "Disabled"),
}),
() => {
if (!app.store.errors?.otp) {
return;
}
return t.i({
className: "ri-error-warning-fill txt-danger",
ariaDescription: app.attrs.tooltip("Has errors", "left"),
});
},
),
t.div(
{ className: "grid sm" },
t.div(
{ className: "col-sm-12" },
t.div(
{ className: "field" },
t.input({
type: "checkbox",
id: uniqueId + ".enabled",
name: "otp.enabled",
className: "switch",
checked: () => data.config.enabled,
onchange: (e) => (data.config.enabled = e.target.checked),
}),
t.label({
htmlFor: uniqueId + ".enabled",
textContent: "Enable",
}),
),
),
t.div(
{ className: "col-sm-6" },
t.div(
{ className: "field" },
t.label({
htmlFor: uniqueId + ".duration",
textContent: "Duration (in seconds)",
}),
t.input({
type: "number",
id: uniqueId + ".duration",
name: "otp.duration",
min: 1,
step: 1,
required: true,
value: () => data.config.duration || "",
oninput: (e) => (data.config.duration = parseInt(e.target.value, 10)),
}),
),
),
t.div(
{ className: "col-sm-6" },
t.div(
{ className: "field" },
t.label({
htmlFor: uniqueId + ".length",
textContent: "Generated password length",
}),
t.input({
type: "number",
id: uniqueId + ".length",
name: "otp.length",
min: 1,
step: 1,
required: true,
value: () => data.config.length || "",
oninput: (e) => (data.config.length = parseInt(e.target.value, 10)),
}),
),
),
),
);
}

View File

@@ -0,0 +1,331 @@
import { collectionsSidebar } from "./collectionsSidebar";
const SORT_QUERY_KEY = "sort";
const FILTER_QUERY_KEY = "filter";
const COLLECTION_QUERY_KEY = "collection";
const RECORD_QUERY_KEY = "record";
const LAST_ACTIVE_STORAGE_KEY = "pbLastActiveCollection";
export function pageCollections(route) {
app.store.activeCollection = route.query[COLLECTION_QUERY_KEY]?.[0]
|| window.localStorage.getItem(LAST_ACTIVE_STORAGE_KEY);
const pageData = store({
reset: null,
activeRecordIdOrModel: route.query[RECORD_QUERY_KEY]?.[0] || "",
sort: route.query[SORT_QUERY_KEY]?.[0] || "",
filter: route.query[FILTER_QUERY_KEY]?.[0] || "",
totalCount: 0,
isTotalCountLoading: false,
});
async function loadTotalCount() {
if (!app.store.activeCollection?.id) {
return;
}
pageData.isTotalCountLoading = true;
try {
const normalizedFilter = app.utils.normalizeSearchFilter(
pageData.filter,
app.store.activeCollection.fields.filter((f) => !f.hidden).map((f) => f.name),
);
const result = await app.pb.collection(app.store.activeCollection.name).getList(1, 1, {
filter: normalizedFilter,
fields: "id",
});
pageData.totalCount = result.totalItems;
} catch (err) {
if (!err.isAbort) {
pageData.totalCount = 0;
console.warn("failed to load total count:", err);
}
}
pageData.isTotalCountLoading = false;
}
function refreshRecordsList() {
pageData.reset = Date.now();
}
const watchers = [
watch(
() => (app.store.activeCollection?.name || "") + (app.store.activeCollection?.updated || ""),
(newVal, oldVal) => {
// skip unnecessery initial params replacement
if (!oldVal) {
return;
}
// reset filter and sort params on collection change
if (oldVal != newVal) {
pageData.filter = "";
pageData.sort = "";
}
app.store.title = app.store.activeCollection?.name || "Collections";
app.utils.replaceHashQueryParams({
[COLLECTION_QUERY_KEY]: app.store.activeCollection?.name,
[FILTER_QUERY_KEY]: pageData.filter || null,
[SORT_QUERY_KEY]: pageData.sort || null,
}, newVal != oldVal ? true : null);
if (app.store.activeCollection?.id) {
window.localStorage.setItem(LAST_ACTIVE_STORAGE_KEY, app.store.activeCollection.id);
} else {
window.localStorage.removeItem(LAST_ACTIVE_STORAGE_KEY);
}
},
),
watch(
() => [pageData.filter, pageData.sort],
(newVal, oldVal) => {
if (!oldVal) {
return;
}
app.utils.replaceHashQueryParams({
[FILTER_QUERY_KEY]: pageData.filter || null,
[SORT_QUERY_KEY]: pageData.sort || null,
});
},
),
watch(
() => (pageData.activeRecordIdOrModel || "") + (app.store.activeCollection?.id || ""),
(newVal, oldVal) => {
if (!pageData.activeRecordIdOrModel) {
app.utils.replaceHashQueryParams({
[RECORD_QUERY_KEY]: null,
});
return;
}
// no change or the collection model is still loading
if (newVal == oldVal || !app.store.activeCollection?.id) {
return;
}
const recordData = typeof pageData.activeRecordIdOrModel == "string"
? {
id: pageData.activeRecordIdOrModel,
collectionId: app.store.activeCollection?.id,
collectionName: app.store.activeCollection?.name,
}
: pageData.activeRecordIdOrModel;
app.utils.replaceHashQueryParams({
[RECORD_QUERY_KEY]: recordData.id || null,
});
// force close any previous modal
app.modals.close(null, true);
if (app.store.activeCollection?.type == "view") {
app.modals.openRecordPreview(recordData, {
onafterclose: () => {
pageData.activeRecordIdOrModel = "";
},
});
} else {
app.modals.openRecordUpsert(app.store.activeCollection, recordData, {
onafterclose: () => {
pageData.activeRecordIdOrModel = "";
},
});
}
},
),
watch(
() => [app.store.activeCollection?.id, pageData.filter, pageData.reset],
() => loadTotalCount(),
),
];
const documentEvents = {
"record:save": (e) => {
if (e.detail.collectionId != app.store.activeCollection?.id) {
return;
}
pageData.totalCount++;
},
"record:delete": (e) => {
if (
// check both because for delete we don't know which one was assigned to
e.detail.collectionId != app.store.activeCollection?.id
&& e.detail.collectionName != app.store.activeCollection?.name
) {
return;
}
pageData.totalCount--;
},
};
return t.div(
{
pbEvent: "pageCollections",
className: "page",
onmount: () => {
// refresh if necesser the cached collections in the background
if (!app.store.isLoadingCollections) {
app.store.silentlyReloadCollections();
}
for (let event in documentEvents) {
document.addEventListener(event, documentEvents[event]);
}
},
onunmount: () => {
watchers.forEach((w) => w?.unwatch());
for (let event in documentEvents) {
document.removeEventListener(event, documentEvents[event]);
}
},
},
() => collectionsSidebar(),
t.div(
{ className: "page-content full-height" },
t.header(
{ className: "page-header compact flex-nowrap" },
t.nav(
{ className: "breadcrumbs" },
t.div(null, "Collections"),
() => {
if (app.store.activeCollection?.name) {
return t.div({
title: app.store.activeCollection.name,
textContent: app.store.activeCollection.name,
});
}
},
),
t.div(
{
hidden: () => !app.store.activeCollection?.id,
pbEvent: "pageHeaderSecondaryBtns",
className: "page-header-secondary-btns",
},
t.button(
{
type: "button",
className: "btn circle transparent secondary tooltip-bottom btn-collection-settings",
ariaDescription: app.attrs.tooltip("Collection settings"),
onclick: () => {
app.modals.openCollectionUpsert(app.store.activeCollection, {
ontruncate: () => refreshRecordsList(),
onsave: (collection, isNew) => {
if (isNew) {
// e.g. in case of a duplicate or modal state reset
app.store.activeCollection = collection.id;
} else {
refreshRecordsList();
}
},
});
},
},
t.i({ className: "ri-settings-3-line" }),
),
app.components.refreshButton({
onclick: () => refreshRecordsList(),
}),
),
t.div(
{
hidden: () => !app.store.activeCollection?.id,
pbEvent: "pageHeaderPrimaryBtns",
className: "page-header-primary-btns",
},
t.button(
{
type: "button",
className: "btn outline api-preview-btn",
onclick: () => app.modals.openApiPreview(app.store.activeCollection),
},
t.i({ className: "ri-code-s-slash-line" }),
t.span({ className: "txt", textContent: "API preview" }),
),
() => {
if (app.store.activeCollection?.type == "view") {
return;
}
return t.button(
{
type: "button",
className: "btn new-record-btn",
onclick: () => app.modals.openRecordUpsert(app.store.activeCollection),
},
t.i({ className: "ri-add-line" }),
t.span({ className: "txt", textContent: "New Record" }),
);
},
),
),
// page loader
t.div(
{
hidden: () => !app.store.isLoadingCollections || app.store.activeCollection?.id,
className: "block txt-center p-base",
},
t.span({ className: "loader lg" }),
),
// no selected collection
t.div(
{
hidden: () => app.store.isLoadingCollections || app.store.activeCollection?.id,
className: "block txt-center p-base",
},
t.h6(
{ className: "txt" },
() => {
if (app.store.collections?.length) {
return "Select collection from the sidebar.";
}
return "No collections found.";
},
),
),
// records list
app.components.recordsSearchbar({
hidden: () => !app.store.activeCollection?.id,
collection: () => app.store.activeCollection,
value: () => pageData.filter,
onsubmit: (newFilter) => (pageData.filter = newFilter),
}),
app.components.recordsList({
className: "m-t-sm",
reset: () => pageData.reset,
hidden: () => !app.store.activeCollection?.id,
collection: () => app.store.activeCollection,
filter: () => pageData.filter,
sort: () => pageData.sort,
onselect: (record) => {
pageData.activeRecordIdOrModel = record;
},
onchange: (newFilter, newSort) => {
pageData.filter = newFilter;
pageData.sort = newSort;
},
}),
t.footer(
{ className: "page-footer" },
t.span(
{
className: () => `total-count ${pageData.isTotalCountLoading ? "faded" : ""}`,
},
"Total: ",
() => pageData.totalCount,
),
app.components.credits(),
),
),
);
}

View File

@@ -0,0 +1,112 @@
export function passwordAuthAccordion(collection) {
const uniqueId = "passwordAuth_" + app.utils.randomString();
const data = store({
get config() {
if (!collection.passwordAuth) {
collection.passwordAuth = {
enabled: true,
identityFields: ["email"],
};
}
return collection.passwordAuth;
},
get identityFieldOptions() {
// email is always available in auth collections
const options = [{ value: "email" }];
const fields = collection?.fields || [];
const indexes = collection?.indexes || [];
for (let index of indexes) {
const parsed = app.utils.parseIndex(index);
if (!parsed.unique || parsed.columns.length != 1 || parsed.columns[0].name == "email") {
continue;
}
const field = fields.find((f) => {
return !f.hidden && f.name.toLowerCase() == parsed.columns[0].name.toLowerCase();
});
if (field) {
options.push({ value: field.name });
}
}
return options;
},
});
return t.details(
{
pbEvent: "passwordAuthAccordion",
name: "auth-methods",
className: "accordion password-auth-accordion",
},
t.summary(
null,
t.i({ className: "ri-lock-password-line" }),
t.span({ className: "txt", textContent: "Identity/Password" }),
t.span({
className: () => `label m-l-auto ${data.config.enabled ? "success" : ""}`,
textContent: () => (data.config.enabled ? "Enabled" : "Disabled"),
}),
() => {
if (!app.store.errors?.passwordAuth) {
return;
}
return t.i({
className: "ri-error-warning-fill txt-danger",
ariaDescription: app.attrs.tooltip("Has errors", "left"),
});
},
),
t.div(
{ className: "grid sm" },
t.div(
{ className: "col-sm-12" },
t.div(
{ className: "field" },
t.input({
type: "checkbox",
id: uniqueId + ".enabled",
name: "passwordAuth.enabled",
className: "switch",
checked: () => data.config.enabled,
onchange: (e) => (data.config.enabled = e.target.checked),
}),
t.label({
htmlFor: uniqueId + ".enabled",
textContent: "Enable",
}),
),
),
t.div(
{ className: "col-sm-12" },
t.div(
{ className: "field" },
t.label({
htmlFor: uniqueId + ".identityFields",
textContent: "Identity fields",
}),
app.components.select({
id: uniqueId + ".identityFields",
name: "passwordAuth.identityFields",
max: 99,
required: true,
options: () => data.identityFieldOptions,
value: () => data.config.identityFields,
onchange: (selectedOpts) => {
data.config.identityFields = selectedOpts.map((opt) => opt.value);
},
}),
),
t.div(
{ className: "field-help" },
"Only non-hidden fields with UNIQUE index constraint can be selected.",
),
),
),
);
}

View File

@@ -0,0 +1,175 @@
window.app = window.app || {};
window.app.modals = window.app.modals || {};
window.app.modals.openProviderPicker = function(settings = {
exclude: [],
// ---
onbeforeopen: null,
onafteropen: null,
onbeforeclose: null,
onafterclose: null,
onselect: null, // (providerInfo) => {},
}) {
const modal = providerPickerModal(settings);
if (!modal) {
return;
}
document.body.appendChild(modal);
app.modals.open(modal);
};
function providerPickerModal(settings = {}) {
let modal;
const data = store({
searchTerm: "",
get filteredProviders() {
const search = data.searchTerm.trim().toLowerCase().replaceAll(" ", "");
return app.store.oauth2Providers.filter((p) => {
return (
!settings.exclude?.includes(p.name)
&& (p.name + p.displayName).toLowerCase().replaceAll(" ", "").includes(search)
);
});
},
});
function clearSearch() {
data.searchTerm = "";
}
modal = t.div(
{
pbEvent: "providerPickerModal",
className: "modal provider-picker-modal",
onbeforeopen: (el) => {
return settings.onbeforeopen?.(el);
},
onafteropen: (el) => {
settings.onafteropen?.(el);
},
onbeforeclose: (el) => {
return settings.onbeforeclose?.(el);
},
onafterclose: (el) => {
settings.onafterclose?.(el);
el?.remove();
},
},
t.header(
{ className: "modal-header" },
t.h6({ className: "modal-title" }, t.span({ className: "txt" }, "Select OAuth2 provider")),
),
t.div(
{ className: "modal-content" },
t.div(
{ className: "grid sm" },
// search
t.div(
{ className: "col-12" },
t.div(
{ className: "fields searchbar" },
t.div(
{ className: "field" },
t.input({
placeholder: "Search...",
className: "p-l-20",
value: () => data.searchTerm,
oninput: (e) => data.searchTerm = e.target.value,
}),
),
() => {
if (!data.searchTerm) {
return;
}
return t.div(
{ rid: "search-ctrls", className: "field addon p-r-5" },
t.button(
{
type: "button",
className: "btn sm pill secondary transparent",
onclick: () => clearSearch(),
},
"Clear",
),
);
},
),
),
// no providers
() => {
if (app.store.isLoadingOAuth2Providers || data.filteredProviders.length) {
return;
}
return t.div(
{ rid: "notfound", className: "block txt-center txt-hint" },
t.p(null, "No providers found."),
t.button({
type: "button",
className: "btn sm secondary",
textContent: "Clear search",
onclick: () => clearSearch(),
}),
);
},
// list
() => {
if (app.store.isLoadingOAuth2Providers) {
return t.div({ className: "col-12 txt-center" }, t.span({ className: "loader active" }));
}
return data.filteredProviders.map((provider) => {
return t.div(
{ className: "col-sm-6" },
t.button(
{
type: "button",
className: "provider-card handle",
onclick: () => {
app.modals.close(modal);
settings.onselect?.(provider);
},
},
t.figure(
{ className: "provider-logo" },
() => {
if (provider.logo) {
return t.img({
src: "data:image/svg+xml;base64," + btoa(provider.logo),
alt: provider.name + " logo",
});
}
return t.i({ className: app.utils.fallbackProviderIcon });
},
),
t.div(
{ className: "content" },
t.span({ className: "primadry-txt" }, provider.displayName || provider.name),
t.span({ className: "secondary-txt" }, provider.name),
),
),
);
});
},
),
),
t.footer(
{ className: "modal-footer gap-base" },
t.button(
{
type: "button",
className: "btn transparent m-r-auto",
onclick: () => app.modals.close(modal),
},
t.span({ className: "txt" }, "Close"),
),
),
);
return modal;
}

View File

@@ -0,0 +1,200 @@
window.app = window.app || {};
window.app.modals = window.app.modals || {};
window.app.modals.openProviderSettings = function(
config = {},
settings = {
namePrefix: "", // e.g. 'oauth2.providers.0'
// ---
onbeforeopen: null,
onafteropen: null,
onbeforeclose: null,
onafterclose: null,
onsubmit: null, // (providerInfo, newConfig) => {},
},
) {
const modal = providerSettingsModal(config, settings);
if (!modal) {
return;
}
document.body.appendChild(modal);
app.modals.open(modal);
};
function providerSettingsModal(providerConfig, settings) {
let modal;
const uniqueId = "provider_" + app.utils.randomString();
providerConfig = providerConfig || {};
const isNew = !providerConfig.clientId;
const initialHash = JSON.stringify(providerConfig);
const providerInfo = app.store.oauth2Providers?.find((p) => p.name == providerConfig.name);
if (!providerInfo) {
console.warn("missing provider for config", providerConfig);
return;
}
const data = store({
config: JSON.parse(initialHash),
get hasChanges() {
return initialHash != JSON.stringify(data.config);
},
onsubmit: (providerInfo, newConfig) => {},
});
function submit() {
if (!data.hasChanges) {
return;
}
settings.onsubmit?.(providerInfo, JSON.parse(JSON.stringify(data.config)));
app.modals.close(modal);
}
modal = t.div(
{
pbEvent: "providerSettingsModal",
className: "modal provider-settings-modal",
onbeforeopen: (el) => {
return settings.onbeforeopen?.(el);
},
onafteropen: (el) => {
settings.onafteropen?.(el);
// retriger errors (if any)
setTimeout(() => {
if (app.store.errors?.oauth2) {
app.store.errors.oauth2 = JSON.parse(JSON.stringify(app.store.errors.oauth2));
}
}, 0);
},
onbeforeclose: (el) => {
return settings.onbeforeclose?.(el);
},
onafterclose: (el) => {
settings.onafterclose?.(el);
el?.remove();
},
},
t.header(
{ className: "modal-header" },
t.figure(
{ className: "provider-logo" },
() => {
if (providerInfo.logo) {
return t.img({
src: "data:image/svg+xml;base64," + btoa(providerInfo.logo),
alt: providerInfo.name + " logo",
});
}
return t.i({ className: app.utils.fallbackProviderIcon });
},
),
t.h6(
{ className: "modal-title" },
providerConfig.displayName || providerInfo.displayName || providerInfo.name,
t.small({ className: "txt-hint" }, " (", providerConfig.name, ")"),
),
),
t.form(
{
pbEvent: "providerSettingsForm",
id: uniqueId + "form",
className: "modal-content",
onsubmit: (e) => {
e.preventDefault();
submit();
},
},
t.div(
{ className: "grid" },
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.label({
htmlFor: uniqueId + ".clientId",
textContent: "Client ID",
}),
t.input({
type: "text",
required: true,
id: uniqueId + ".clientId",
autocomplete: "off",
name: () => settings.namePrefix + ".clientId",
value: () => data.config.clientId || "",
oninput: (e) => (data.config.clientId = e.target.value.trim()),
}),
),
),
t.div(
{ className: "col-12" },
t.div(
{ className: "field" },
t.label({
htmlFor: uniqueId + ".clientSecret",
textContent: "Client secret",
}),
t.input({
type: "password",
id: uniqueId + ".clientSecret",
autocomplete: "new-password",
required: () => isNew || typeof data.config.clientSecret != "undefined",
name: () => settings.namePrefix + ".clientSecret",
value: () => data.config.clientSecret || "",
oninput: (e) => (data.config.clientSecret = e.target.value.trim()),
onkeyup: (e) => {
if (
e.key == "Backspace"
&& typeof data.config.clientSecret === "undefined"
) {
data.config.clientSecret = "";
}
},
placeholder:
() => (isNew || typeof data.config.clientSecret !== "undefined" ? "" : "* * * * * *"),
}),
),
),
// extra fields
() => {
if (typeof app.oauth2?.[providerInfo.name] == "function") {
return t.div(
{ className: "col-12" },
app.oauth2[providerInfo.name](providerInfo, settings.namePrefix, data),
);
}
},
),
),
t.footer(
{ className: "modal-footer" },
t.button(
{
type: "button",
className: "btn transparent m-r-auto",
onclick: () => app.modals.close(modal),
},
t.span({ className: "txt" }, "Close"),
),
t.button(
{
"html-form": uniqueId + "form",
type: "submit",
className: "btn",
disabled: () => !data.hasChanges,
},
t.span({ className: "txt" }, "Set provider config"),
),
),
);
return modal;
}

View File

@@ -0,0 +1,78 @@
export function tokenOptionsAccordion(collection) {
const uniqueId = "token_" + app.utils.randomString();
const data = store({
get tokensList() {
if (collection?.name === "_superusers") {
return [
{ key: "authToken", label: "Auth" },
{ key: "passwordResetToken", label: "Password reset" },
{ key: "fileToken", label: "Protected file" },
];
}
return [
{ key: "authToken", label: "Auth" },
{ key: "verificationToken", label: "Email verification" },
{ key: "passwordResetToken", label: "Password reset" },
{ key: "emailChangeToken", label: "Email change" },
{ key: "fileToken", label: "Protected file" },
];
},
});
return t.details(
{
pbEvent: "tokenOptionsAccordion",
name: "other",
className: "accordion token-options-accordion",
},
t.summary(
null,
t.i({ className: "ri-key-2-line" }),
t.span({ className: "txt", textContent: "Token options (invalidate, duration)" }),
),
t.div({ className: "grid sm" }, () => {
return data.tokensList.map((token) => {
const fieldId = uniqueId + token.key;
return t.div(
{ className: "col-sm-6" },
t.div(
{ className: "field token-field" },
t.label({
htmlFor: fieldId,
textContent: () => token.label + " duration (in seconds)",
}),
t.input({
id: fieldId,
type: "number",
min: 1,
step: 1,
required: true,
name: () => token.key + ".duration",
value: () => collection[token.key].duration,
oninput: (e) => (collection[token.key].duration = parseInt(e.target.value, 10)),
}),
),
t.div(
{ className: "field-help m-b-10" },
t.button({
type: "button",
className: () => `link-hint ${collection[token.key].secret ? "txt-success" : ""}`,
textContent: "Invalidate all previously issued tokens",
onclick: () => {
// toggle
if (collection[token.key].secret) {
delete collection[token.key].secret;
} else {
collection[token.key].secret = app.utils.randomSecret(50);
}
},
}),
),
);
});
}),
);
}

View File

@@ -1,14 +0,0 @@
<script>
import { replace } from "svelte-spa-router";
import ApiClient from "@/utils/ApiClient";
handler();
function handler() {
if (ApiClient.authStore.isValid) {
replace("/collections");
} else {
ApiClient.logout();
}
}
</script>

View File

@@ -1,114 +0,0 @@
<script>
import { createEventDispatcher, onMount } from "svelte";
import { slide } from "svelte/transition";
const dispatch = createEventDispatcher();
let accordionElem;
let expandTimeoutId;
let classes = "";
export { classes as class }; // export reserved keyword
export let draggable = false;
export let active = false;
export let interactive = true;
export let single = false; // ensures that only one accordion is expanded in its given parent container
let isDragOver = false;
$: if (active) {
clearTimeout(expandTimeoutId);
expandTimeoutId = setTimeout(() => {
if (accordionElem?.scrollIntoViewIfNeeded) {
accordionElem.scrollIntoViewIfNeeded();
} else if (accordionElem?.scrollIntoView) {
accordionElem.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, 200);
}
export function isExpanded() {
return !!active;
}
export function expand() {
collapseSiblings();
active = true;
dispatch("expand");
}
export function collapse() {
active = false;
clearTimeout(expandTimeoutId);
dispatch("collapse");
}
export function toggle() {
dispatch("toggle");
if (active) {
collapse();
} else {
expand();
}
}
export function collapseSiblings() {
if (single && accordionElem.closest(".accordions")) {
const handlers = accordionElem
.closest(".accordions")
.querySelectorAll(".accordion.active .accordion-header.interactive");
for (const handler of handlers) {
handler.click(); // @todo consider using store or other more reliable approach
}
}
}
onMount(() => {
return () => clearTimeout(expandTimeoutId);
});
</script>
<div bind:this={accordionElem} class="accordion {isDragOver ? 'drag-over' : ''} {classes}" class:active>
<button
type="button"
class="accordion-header"
{draggable}
class:interactive
aria-expanded={active}
on:click|preventDefault={() => interactive && toggle()}
on:drop|preventDefault={(e) => {
if (draggable) {
isDragOver = false;
collapseSiblings();
dispatch("drop", e);
}
}}
on:dragstart={(e) => draggable && dispatch("dragstart", e)}
on:dragenter={(e) => {
if (draggable) {
isDragOver = true;
dispatch("dragenter", e);
}
}}
on:dragleave={(e) => {
if (draggable) {
isDragOver = false;
dispatch("dragleave", e);
}
}}
on:dragover|preventDefault
>
<slot name="header" {active} />
</button>
{#if active}
<div class="accordion-content" transition:slide={{ delay: 10, duration: 150 }}>
<slot />
</div>
{/if}
</div>

View File

@@ -1,55 +0,0 @@
<script>
import { onMount } from "svelte";
export let value = "";
export let maxHeight = 200;
let inputElem;
let updateTimeoutId;
$: if (typeof value !== undefined) {
updateInputHeight();
}
function updateInputHeight() {
clearTimeout(updateTimeoutId);
updateTimeoutId = setTimeout(() => {
if (inputElem) {
inputElem.style.height = ""; // reset
inputElem.style.height = Math.min(inputElem.scrollHeight, maxHeight) + "px";
}
}, 0);
}
// Pressing "Enter" key should trigger parent form submission,
// aka. the same as any <input /> element.
//
// note: New line could be added using "Enter+Shift".
function handleKeydown(e) {
if (e?.code === "Enter" && !e?.shiftKey && !e?.isComposing) {
e.preventDefault();
// trigger parent form submission (if any)
const form = inputElem.closest("form");
form?.requestSubmit && form.requestSubmit();
}
}
onMount(() => {
updateInputHeight();
return () => clearTimeout(updateTimeoutId);
});
</script>
<textarea bind:this={inputElem} bind:value on:keydown={handleKeydown} {...$$restProps} />
<style>
textarea {
resize: none;
padding-top: 4px !important;
padding-bottom: 5px !important;
min-height: var(--inputHeight);
height: var(--inputHeight);
}
</style>

View File

@@ -1,24 +0,0 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
export let value = "";
export let options = []; // [{label: "Option 1", value: "opt1"}, {label: "Option 2", value: "opt2"}, ...]
const uniqueId = "list_" + CommonHelper.randomString(5);
</script>
<input
type={$$restProps.type || "text"}
list={uniqueId}
{value}
on:input={(e) => {
value = e.target.value;
}}
{...$$restProps}
/>
<datalist id={uniqueId}>
{#each options as opt}
<option value={opt.value}>{opt.label || ""}</option>
{/each}
</datalist>

View File

@@ -1,10 +0,0 @@
<script>
// example supported format: {label: "...", value: "...", icon: ""}
export let item = {};
</script>
{#if item.icon}
<i class="icon {item.icon}" />
{/if}
<span class="txt">{item.label || item.name || item.title || item.id || item.value}</span>

View File

@@ -1,49 +0,0 @@
<script>
export let content = "";
export let language = "javascript"; // javascript, html, dart, go, sql
let classes = "";
export { classes as class }; // export reserved keyword
let formattedContent = "";
$: if (typeof Prism !== "undefined" && content) {
formattedContent = highlight(content);
}
function highlight(code) {
code = typeof code === "string" ? code : "";
// @see https://prismjs.com/plugins/normalize-whitespace
code = Prism.plugins.NormalizeWhitespace.normalize(code, {
"remove-trailing": true,
"remove-indent": true,
"left-trim": true,
"right-trim": true,
});
return Prism.highlight(code, Prism.languages[language] || Prism.languages.javascript, language);
}
</script>
<div class="code-wrapper prism-light {classes}">
<code>{@html formattedContent}</code>
</div>
<style>
code {
display: block;
width: 100%;
padding: 10px 15px;
white-space: pre-wrap;
word-break: break-word;
}
.code-wrapper {
display: block;
width: 100%;
}
.prism-light code {
color: var(--txtPrimaryColor);
background: var(--baseAlt1Color);
}
</style>

View File

@@ -1,284 +0,0 @@
<script>
/**
* This component uses Codemirror editor under the hood and its a "little heavy".
* To allow manuall chunking it is recommended to load the component lazily!
*
* Example usage:
* ```
* <script>
* import { onMount } from "svelte";
*
* let editorComponent;
*
* onMount(async () => {
* try {
* editorComponent = (await import("@/components/base/CodeEditor.svelte")).default;
* } catch (err) {
* console.warn(err);
* }
* });
* <//script>
*
* ...
*
* <svelte:component
* this={editorComponent}
* bind:value={value}
* disabled={disabled}
* language="html"
* />
* ```
*/
import { onMount, createEventDispatcher } from "svelte";
// code mirror imports
// ---
import {
keymap,
highlightSpecialChars,
drawSelection,
dropCursor,
rectangularSelection,
highlightActiveLineGutter,
EditorView,
placeholder as placeholderExt,
} from "@codemirror/view";
import { EditorState, Compartment } from "@codemirror/state";
import { defaultHighlightStyle, syntaxHighlighting, bracketMatching } from "@codemirror/language";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { searchKeymap, highlightSelectionMatches } from "@codemirror/search";
import {
autocompletion,
completionKeymap,
closeBrackets,
closeBracketsKeymap,
} from "@codemirror/autocomplete";
import { html as htmlLang } from "@codemirror/lang-html";
import { json as jsonLang } from "@codemirror/lang-json";
import { sql, SQLDialect } from "@codemirror/lang-sql";
import { javascript as javascriptLang } from "@codemirror/lang-javascript";
// ---
import CommonHelper from "@/utils/CommonHelper";
import { collections } from "@/stores/collections";
const dispatch = createEventDispatcher();
export let id = "";
export let value = "";
export let minHeight = null;
export let maxHeight = null;
export let disabled = false;
export let placeholder = "";
export let language = "javascript";
export let singleLine = false;
let editor;
let container;
let langCompartment = new Compartment();
let editableCompartment = new Compartment();
let readOnlyCompartment = new Compartment();
let placeholderCompartment = new Compartment();
$: if (id) {
addLabelListeners();
}
$: if (editor && language) {
editor.dispatch({
effects: [langCompartment.reconfigure(getEditorLang())],
});
}
$: if (editor && typeof disabled !== "undefined") {
editor.dispatch({
effects: [
editableCompartment.reconfigure(EditorView.editable.of(!disabled)),
readOnlyCompartment.reconfigure(EditorState.readOnly.of(disabled)),
],
});
}
$: if (editor && value != editor.state.doc.toString()) {
editor.dispatch({
changes: {
from: 0,
to: editor.state.doc.length,
insert: value,
},
});
}
$: if (editor && typeof placeholder !== "undefined") {
editor.dispatch({
effects: [placeholderCompartment.reconfigure(placeholderExt(placeholder))],
});
}
// Focus the editor (if inited).
export function focus() {
editor?.focus();
}
// Emulate native change event for the editor container element.
function triggerNativeChange() {
container?.dispatchEvent(
new CustomEvent("change", {
detail: { value },
bubbles: true,
})
);
dispatch("change", value);
}
// Remove any attached label listeners.
function removeLabelListeners() {
if (!id) {
return;
}
const labels = document.querySelectorAll('[for="' + id + '"]');
for (let label of labels) {
label.removeEventListener("click", focus);
}
}
// Add `<label for="ID">...</label>` focus support.
function addLabelListeners() {
if (!id) {
return;
}
removeLabelListeners();
const labels = document.querySelectorAll('[for="' + id + '"]');
for (let label of labels) {
label.addEventListener("click", focus);
}
}
// Returns the current active editor language.
function getEditorLang() {
switch (language) {
case "html":
return htmlLang();
case "json":
return jsonLang();
case "sql-create-index":
return sql({
// lightweight sql dialect with mostly SELECT statements keywords
dialect: SQLDialect.define({
keywords:
"create unique index if not exists on collate asc desc where like isnull notnull " +
"date time datetime unixepoch strftime lower upper substr " +
"case when then iif if else json_extract json_each json_tree json_array_length json_valid ",
operatorChars: "*+-%<>!=&|/~",
identifierQuotes: '`"',
specialVar: "@:?$",
}),
upperCaseKeywords: true,
});
case "sql-select":
let schema = {};
for (let collection of $collections) {
schema[collection.name] = CommonHelper.getAllCollectionIdentifiers(collection);
}
return sql({
// lightweight sql dialect with mostly SELECT statements keywords
dialect: SQLDialect.define({
keywords:
"select distinct from where having group by order limit offset join left right inner with like not in match asc desc regexp isnull notnull glob " +
"count avg sum min max current random cast as int real text bool " +
"date time datetime unixepoch strftime coalesce lower upper substr " +
"case when then iif if else json_extract json_each json_tree json_array_length json_valid ",
operatorChars: "*+-%<>!=&|/~",
identifierQuotes: '`"',
specialVar: "@:?$",
}),
schema: schema,
upperCaseKeywords: true,
});
default:
return javascriptLang();
}
}
onMount(() => {
const submitShortcut = {
key: "Enter",
run: (_) => {
// trigger submit on enter for singleline input
if (singleLine) {
dispatch("submit", value);
}
},
};
addLabelListeners();
editor = new EditorView({
parent: container,
state: EditorState.create({
doc: value,
extensions: [
highlightActiveLineGutter(),
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
bracketMatching(),
closeBrackets(),
rectangularSelection(),
highlightSelectionMatches(),
keymap.of([
submitShortcut,
...closeBracketsKeymap,
...defaultKeymap,
searchKeymap.find((item) => item.key === "Mod-d"),
...historyKeymap,
...completionKeymap,
]),
EditorView.lineWrapping,
autocompletion({
icons: false,
}),
langCompartment.of(getEditorLang()),
placeholderCompartment.of(placeholderExt(placeholder)),
editableCompartment.of(EditorView.editable.of(true)),
readOnlyCompartment.of(EditorState.readOnly.of(false)),
EditorState.transactionFilter.of((tr) => {
if (singleLine && tr.newDoc.lines > 1) {
if (!tr.changes?.inserted?.filter((i) => !!i.text.find((t) => t))?.length) {
return []; // only empty lines
}
// it is ok to mutate the current transaction as we don't change the doc length
tr.newDoc.text = [tr.newDoc.text.join(" ")];
}
return tr;
}),
EditorView.updateListener.of((v) => {
if (!v.docChanged || disabled) {
return;
}
value = v.state.doc.toString();
triggerNativeChange();
}),
],
}),
});
return () => {
removeLabelListeners();
editor?.destroy();
};
});
</script>
<div
bind:this={container}
class="code-editor"
style:min-height={minHeight ? minHeight + "px" : null}
style:max-height={maxHeight ? maxHeight + "px" : "auto"}
/>

View File

@@ -1,66 +0,0 @@
<script>
import { tick } from "svelte";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import { confirmation, resetConfirmation } from "@/stores/confirmation";
let confirmationPopup;
let isConfirmationBusy = false;
let confirmed = false;
$: if ($confirmation?.text) {
confirmed = false;
confirmationPopup?.show();
}
</script>
<OverlayPanel
bind:this={confirmationPopup}
class="confirm-popup hide-content overlay-panel-sm"
overlayClose={!isConfirmationBusy}
escClose={!isConfirmationBusy}
btnClose={false}
popup
on:hide={async () => {
if (!confirmed && $confirmation?.noCallback) {
$confirmation.noCallback();
}
await tick();
confirmed = false;
resetConfirmation();
}}
>
<h4 class="block center txt-break" slot="header">{$confirmation?.text}</h4>
<svelte:fragment slot="footer">
<!-- svelte-ignore a11y-autofocus -->
<button
autofocus
type="button"
class="btn btn-transparent btn-expanded-sm"
disabled={isConfirmationBusy}
on:click={() => {
confirmed = false;
confirmationPopup?.hide();
}}
>
<span class="txt">No</span>
</button>
<button
type="button"
class="btn btn-danger btn-expanded"
class:btn-loading={isConfirmationBusy}
disabled={isConfirmationBusy}
on:click={async () => {
if ($confirmation?.yesCallback) {
isConfirmationBusy = true;
await Promise.resolve($confirmation.yesCallback());
isConfirmationBusy = false;
}
confirmed = true;
confirmationPopup?.hide();
}}
>
<span class="txt">Yes</span>
</button>
</svelte:fragment>
</OverlayPanel>

View File

@@ -1,45 +0,0 @@
<script>
import { onMount } from "svelte";
import CommonHelper from "@/utils/CommonHelper";
import tooltipAction from "@/actions/tooltip";
export let value = "";
export let tooltip = "Copy";
export let idleClasses = "ri-file-copy-line txt-sm link-hint";
export let successClasses = "ri-check-line txt-sm txt-success";
export let successDuration = 500; // ms
let copyTimeout;
function copy() {
if (CommonHelper.isEmpty(value)) {
return;
}
CommonHelper.copyToClipboard(value);
clearTimeout(copyTimeout);
copyTimeout = setTimeout(() => {
clearTimeout(copyTimeout);
copyTimeout = null;
}, successDuration);
}
onMount(() => {
return () => {
if (copyTimeout) {
clearTimeout(copyTimeout);
}
};
});
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<i
tabindex="-1"
role="button"
class={copyTimeout ? successClasses : idleClasses}
aria-label={"Copy to clipboard"}
use:tooltipAction={!copyTimeout ? tooltip : undefined}
on:click|stopPropagation={copy}
/>

View File

@@ -1,110 +0,0 @@
<script>
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
export let index;
export let list = [];
export let group = "default";
export let disabled = false;
export let dragHandleClass = ""; // by default the entire element
let dragging = false;
let dragover = false;
function onDrag(e, i) {
if (!e || disabled) {
return;
}
if (dragHandleClass && !e.target.classList.contains(dragHandleClass)) {
// not the drag handle
dragover = false;
dragging = false;
e.preventDefault();
return;
}
dragging = true;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.dropEffect = "move";
e.dataTransfer.setData(
"text/plain",
JSON.stringify({
index: i,
group: group,
}),
);
dispatch("drag", e);
}
function onDrop(e, target) {
dragover = false;
dragging = false;
if (!e || disabled) {
return;
}
e.dataTransfer.dropEffect = "move";
let dragData = {};
try {
dragData = JSON.parse(e.dataTransfer.getData("text/plain"));
} catch (_) {}
if (dragData.group != group) {
return; // different draggable group
}
const start = dragData.index << 0;
if (start < target) {
list.splice(target + 1, 0, list[start]);
list.splice(start, 1);
} else {
list.splice(target, 0, list[start]);
list.splice(start + 1, 1);
}
list = list;
dispatch("sort", {
oldIndex: start,
newIndex: target,
list: list,
});
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
draggable={!disabled}
class="draggable"
class:dragging
class:dragover
on:dragover|preventDefault={() => {
dragover = true;
}}
on:dragleave|preventDefault={() => {
dragover = false;
}}
on:dragend={() => {
dragover = false;
dragging = false;
}}
on:dragstart={(e) => onDrag(e, index)}
on:drop={(e) => onDrop(e, index)}
>
<slot {dragging} {dragover} />
</div>
<style>
.draggable {
user-select: text;
outline: 0;
min-width: 0;
}
</style>

View File

@@ -1,107 +0,0 @@
<script>
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
export let tolerance = 0;
let elem;
let startX = 0;
let startY = 0;
let shiftX = 0;
let shiftY = 0;
let dragStarted = false;
function dragInit(e) {
e.stopPropagation();
startX = e.clientX;
startY = e.clientY;
shiftX = e.clientX - elem.offsetLeft;
shiftY = e.clientY - elem.offsetTop;
document.addEventListener("touchmove", onMove);
document.addEventListener("mousemove", onMove);
document.addEventListener("touchend", onStop);
document.addEventListener("mouseup", onStop);
}
function onStop(e) {
if (dragStarted) {
e.preventDefault();
dragStarted = false;
elem.classList.remove("no-pointer-events");
dispatch("dragstop", { event: e, elem: elem });
}
document.removeEventListener("touchmove", onMove);
document.removeEventListener("mousemove", onMove);
document.removeEventListener("touchend", onStop);
document.removeEventListener("mouseup", onStop);
}
function onMove(e) {
let diffX = e.clientX - startX;
let diffY = e.clientY - startY;
let left = e.clientX - shiftX;
let top = e.clientY - shiftY;
if (
!dragStarted &&
Math.abs(left - elem.offsetLeft) < tolerance &&
Math.abs(top - elem.offsetTop) < tolerance
) {
return;
}
e.preventDefault();
if (!dragStarted) {
dragStarted = true;
elem.classList.add("no-pointer-events");
dispatch("dragstart", { event: e, elem: elem });
}
dispatch("dragging", { event: e, elem: elem, diffX: diffX, diffY: diffY });
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span
bind:this={elem}
class="dragline"
class:dragging={dragStarted}
on:mousedown={(e) => {
if (e.button == 0) {
dragInit(e);
}
}}
on:touchstart={dragInit}
/>
<style lang="scss">
.dragline {
position: relative;
z-index: 101;
left: 0;
top: 0;
height: 100%;
width: 5px;
padding: 0;
margin: 0 -3px 0 -1px;
background: none;
cursor: ew-resize;
box-sizing: content-box;
user-select: none;
transition: box-shadow var(--activeAnimationSpeed);
box-shadow: inset 1px 0 0 0 var(--baseAlt2Color);
&:hover,
&.dragging {
box-shadow: inset 3px 0 0 0 var(--baseAlt2Color);
}
}
</style>

View File

@@ -1,151 +0,0 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import Toggler from "@/components/base/Toggler.svelte";
import Draggable from "@/components/base/Draggable.svelte";
export let id = null;
export let items = [];
export let disabled = false;
export let emptyPlaceholder = "Add items";
export let newPlaceholder = "e.g. optionA";
let newInput;
let newInputVal = "";
$: formattedValue = items.join(" • ");
function remove(item) {
items = items || [];
CommonHelper.removeByValue(items, item);
if (!items.length) {
newInput?.focus();
}
}
function add(item) {
const val = item.trim();
if (!val.length) {
return;
}
items = items || [];
CommonHelper.pushUnique(items, val);
// reset input
newInputVal = "";
}
</script>
<div class="block">
<input
{id}
readonly
type="text"
class="formatted-value-input"
{disabled}
placeholder={emptyPlaceholder}
value={formattedValue}
title={formattedValue}
/>
{#if !disabled}
<Toggler
class="dropdown dropdown-block options-dropdown dropdown-left m-t-0 p-0"
on:hide={() => (newInputVal = "")}
>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="block"
on:dragover|stopPropagation
on:dragleave|stopPropagation
on:dragend|stopPropagation
on:dragstart|stopPropagation
on:drop|stopPropagation
>
{#each items as item, i (item)}
<Draggable bind:list={items} index={i} group={"options_" + id}>
<div class="dropdown-item plain">
<span class="txt">{item}</span>
<div class="flex-fill"></div>
<button
type="button"
class="btn btn-circle btn-transparent btn-hint btn-xs"
title="Remove"
on:click|stopPropagation={() => remove(item)}
>
<i class="ri-close-line" aria-hidden="true"></i>
</button>
</div>
</Draggable>
{/each}
<div class="new-item-form">
<div class="form-field form-field-sm m-0">
<div class="input-group">
<!-- svelte-ignore a11y-autofocus -->
<input
bind:this={newInput}
autofocus
type="text"
class="new-item-input"
placeholder={newPlaceholder}
bind:value={newInputVal}
on:keydown={(e) => {
if (e.code === "Enter") {
e.preventDefault();
add(e.target.value);
}
}}
/>
<div class="form-field-addon suffix">
<button
type="button"
class="btn btn-transparent btn-xs btn-circle new-item-btn"
title="Add new"
class:btn-disabled={!newInputVal.length}
on:click={() => add(newInputVal)}
>
<i class="ri-add-line" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</Toggler>
{/if}
</div>
<style lang="scss">
.formatted-value-input {
padding-left: 10px;
padding-right: 10px;
cursor: pointer;
color: var(--txtPrimaryColor);
}
.dropdown-item {
padding-top: 5px;
padding-bottom: 5px;
}
.new-item-form {
position: sticky;
z-index: 99;
bottom: 0;
padding: 10px;
background: var(--baseColor);
border-bottom-left-radius: var(--baseRadius);
border-bottom-right-radius: var(--baseRadius);
&:not(:first-child) {
margin-top: 5px;
border-top: 1px solid var(--baseAlt1Color);
}
}
.new-item-input {
padding-right: 40px;
padding-left: 10px;
}
.new-item-btn {
right: -5px;
}
</style>

View File

@@ -1,70 +0,0 @@
<script>
import { onMount } from "svelte";
import { slide, scale } from "svelte/transition";
import tooltip from "@/actions/tooltip";
import { errors, removeError } from "@/stores/errors";
import CommonHelper from "@/utils/CommonHelper";
const uniqueId = "field_" + CommonHelper.randomString(7);
const defaultError = "Invalid value";
export let name = "";
export let inlineError = false;
let classes = undefined;
export { classes as class }; // export reserved keyword
let container;
let fieldErrors = [];
$: {
fieldErrors = CommonHelper.toArray(CommonHelper.getNestedVal($errors, name));
}
export function changed() {
removeError(name);
}
onMount(() => {
container.addEventListener("input", changed);
container.addEventListener("change", changed);
return () => {
container.removeEventListener("input", changed);
container.removeEventListener("change", changed);
};
});
function getErrorMessage(err) {
if (typeof err === "object") {
return err?.message || err?.code || defaultError;
}
return err || defaultError;
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div bind:this={container} class={classes} class:error={fieldErrors.length} on:click>
<slot {uniqueId} />
{#if inlineError && fieldErrors.length}
<div class="form-field-addon inline-error-icon">
<i
class="ri-error-warning-fill txt-danger"
transition:scale={{ duration: 150, start: 0.7 }}
use:tooltip={{
position: "left",
text: fieldErrors.map(getErrorMessage).join("\n"),
}}
/>
</div>
{:else}
{#each fieldErrors as error}
<div class="help-block help-block-error" transition:slide={{ duration: 150 }}>
<pre>{getErrorMessage(error)}</pre>
</div>
{/each}
{/if}
</div>

Some files were not shown because too many files have changed in this diff Show More