initial public commit

This commit is contained in:
Gani Georgiev
2022-07-07 00:19:05 +03:00
commit 3d07f0211d
484 changed files with 92412 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
<script>
import { onMount, createEventDispatcher } 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 active = false;
export let interactive = true;
export let single = false; // ensures that only one accordion is expanded in its given parent container
$: if (active) {
clearTimeout(expandTimeoutId);
expandTimeoutId = setTimeout(() => {
if (accordionElem?.scrollIntoView) {
accordionElem.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, 250);
}
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.parentElement) {
const handlers = accordionElem.parentElement.querySelectorAll(
".accordion.active .accordion-header.interactive"
);
for (const handler of handlers) {
handler.click(); // @todo consider using store or other more reliable approach
}
}
}
function keyToggle(e) {
if (!interactive) {
return;
}
if (e.code === "Enter" || e.code === "Space") {
e.preventDefault();
toggle();
}
}
onMount(() => {
return () => clearTimeout(expandTimeoutId);
});
</script>
<div
bind:this={accordionElem}
tabindex={interactive ? 0 : -1}
class="accordion {classes}"
class:active
on:keydown|self={keyToggle}
>
<header
class="accordion-header"
class:interactive
on:click|preventDefault={() => interactive && toggle()}
>
<slot name="header" {active} />
</header>
{#if active}
<div class="accordion-content" transition:slide|local={{ duration: 150 }}>
<slot />
</div>
{/if}
</div>
@@ -0,0 +1,47 @@
<script>
export let value = "";
export let maxHeight = 200;
let inputElem;
let updateTimeoutId;
$: if (inputElem && typeof value !== undefined) {
updateInputHeight();
}
function updateInputHeight() {
clearTimeout(updateTimeoutId);
updateTimeoutId = setTimeout(() => {
if (inputElem) {
inputElem.style.height = ""; // reset
inputElem.style.height = Math.min(inputElem.scrollHeight + 2, 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.preventDefault();
// trigger parent form submission (if any)
const form = inputElem.closest("form");
form?.requestSubmit && form.requestSubmit();
}
}
</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>
@@ -0,0 +1,10 @@
<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>
+50
View File
@@ -0,0 +1,50 @@
<script>
import Prism from "prismjs";
import "prismjs/plugins/normalize-whitespace/prism-normalize-whitespace.js";
import "@/scss/prism_light.scss";
export let content = "";
export let language = "javascript"; // javascript, html
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">
<code>{@html formattedContent}</code>
</div>
<style>
code {
display: block;
width: 100%;
padding: var(--xsSpacing);
white-space: pre-wrap;
word-break: break-word;
}
.code-wrapper {
display: block;
width: 100%;
}
.prism-light code {
color: var(--txtPrimaryColor);
background: var(--baseAlt1Color);
}
</style>
@@ -0,0 +1,64 @@
<script>
import { tick } from "svelte";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import { confirmation, resetConfirmation } from "@/stores/confirmation";
let confirmationPopup;
let isConfirmationBusy = false;
$: if ($confirmation?.text) {
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 ($confirmation?.noCallback) {
$confirmation.noCallback();
}
await tick();
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-secondary btn-expanded-sm"
disabled={isConfirmationBusy}
on:click={() => {
if ($confirmation?.noCallback) {
$confirmation.noCallback();
}
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;
}
confirmationPopup?.hide();
}}
>
<span class="txt">Yes</span>
</button>
</svelte:fragment>
</OverlayPanel>
+46
View File
@@ -0,0 +1,46 @@
<script>
import { onMount } from "svelte";
import { errors, removeError } from "@/stores/errors";
import CommonHelper from "@/utils/CommonHelper";
const uniqueId = "field_" + CommonHelper.randomString(7);
const defaultError = "Invalid value";
export let name = "";
let classes = undefined;
export { classes as class }; // export reserved keyword
let container;
let fieldErrors = [];
$: {
fieldErrors = CommonHelper.toArray(CommonHelper.getNestedVal($errors, name));
}
function handleChange() {
removeError(name);
}
onMount(() => {
container.addEventListener("change", handleChange);
return () => {
container.removeEventListener("change", handleChange);
};
});
</script>
<div bind:this={container} class={classes} class:error={fieldErrors.length} on:click>
<slot {uniqueId} />
{#each fieldErrors as error}
<div class="help-block help-block-error">
{#if typeof error === "object"}
{error?.message || error?.code || defaultError}
{:else}
{error || defaultError}
{/if}
</div>
{/each}
</div>
@@ -0,0 +1,376 @@
<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 inputComponent;
*
* onMount(async () => {
* try {
* inputComponent = (await import("@/components/base/FilterAutocompleteInput.svelte")).default;
* } catch (err) {
* console.warn(err);
* }
* });
* <//script>
*
* ...
*
* <svelte:component
* this={inputComponent}
* bind:value={value}
* baseCollection={baseCollection}
* disabled={disabled}
* />
* ```
*/
import { onMount, createEventDispatcher } from "svelte";
import CommonHelper from "@/utils/CommonHelper";
import { collections } from "@/stores/collections";
import { Collection } from "pocketbase";
// 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,
StreamLanguage,
} 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 { simpleMode } from "@codemirror/legacy-modes/mode/simple-mode";
// ---
const dispatch = createEventDispatcher();
export let value = "";
export let disabled = false;
export let placeholder = "";
export let baseCollection = new Collection();
export let singleLine = false;
export let extraAutocompleteKeys = []; // eg. ["test1", "test2"]
export let disableRequestKeys = false;
export let disableIndirectCollectionsKeys = false;
let editor;
let container;
let langCompartment = new Compartment();
let editableCompartment = new Compartment();
let readOnlyCompartment = new Compartment();
let placeholderCompartment = new Compartment();
$: mergedCollections = mergeWithBaseCollection($collections);
$: if (editor && baseCollection?.schema) {
editor.dispatch({
effects: [langCompartment.reconfigure(ruleLang())],
});
}
$: 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();
}
// Replace the base collection in the provided list.
function mergeWithBaseCollection(collections) {
let copy = collections.slice();
CommonHelper.pushOrReplaceByKey(copy, baseCollection, "id");
return copy;
}
// Emulate native change event for the editor container element.
function triggerNativeChange() {
container?.dispatchEvent(
new CustomEvent("change", {
detail: { value },
bubbles: true,
})
);
}
// Returns list with all collection field keys recursively.
function getCollectionFieldKeys(nameOrId, prefix = "", level = 0) {
let collection = mergedCollections.find((item) => item.name == nameOrId || item.id == nameOrId);
if (!collection || level >= 4) {
return [];
}
let result = [
// base model fields
prefix + "id",
prefix + "created",
prefix + "updated",
];
for (const field of collection.schema) {
const key = prefix + field.name;
if (field.type === "relation" && field.options.collectionId) {
const subKeys = getCollectionFieldKeys(field.options.collectionId, key + ".", level + 1);
if (subKeys.length) {
result = result.concat(subKeys);
} else {
result.push(key);
}
} else {
result.push(key);
}
}
return result;
}
// Returns an array with all the supported keys.
function getAllKeys(includeRequestKeys = true, includeIndirectCollectionsKeys = true) {
let result = [].concat(extraAutocompleteKeys);
// add base keys
const baseKeys = getCollectionFieldKeys(baseCollection.name);
for (const key of baseKeys) {
result.push(key);
}
// add base request keys
if (includeRequestKeys) {
result.push("@request.method");
result.push("@request.query.");
result.push("@request.data.");
result.push("@request.user.id");
result.push("@request.user.email");
result.push("@request.user.verified");
result.push("@request.user.created");
result.push("@request.user.updated");
}
// add @collections and @request.user.profile keys
if (includeRequestKeys || includeIndirectCollectionsKeys) {
for (const collection of mergedCollections) {
let prefix = "";
if (collection.name === import.meta.env.PB_PROFILE_COLLECTION) {
if (!includeRequestKeys) {
continue;
}
prefix = "@request.user.profile.";
} else {
if (!includeIndirectCollectionsKeys) {
continue;
}
prefix = "@collection." + collection.name + ".";
}
const keys = getCollectionFieldKeys(collection.name, prefix);
for (const key of keys) {
result.push(key);
}
}
}
// sort longer keys first because the highlighter will highlight
// the first match and stops until an operator is found
result.sort(function (a, b) {
return b.length - a.length;
});
return result;
}
// Returns object with all the completions matching the context.
function completions(context) {
let word = context.matchBefore(/[\@\w\.]*/);
if (word.from == word.to && !context.explicit) {
return null;
}
let options = [{ label: "null" }, { label: "false" }, { label: "true" }];
if (!disableIndirectCollectionsKeys) {
options.push({ label: "@collection.*", apply: "@collection." });
}
const skipFields = [
"@request.user.profile.id",
"@request.user.profile.userId",
"@request.user.profile.created",
"@request.user.profile.updated",
];
const keys = getAllKeys(!disableRequestKeys, !disableRequestKeys && word.text.startsWith("@c"));
for (const key of keys) {
if (skipFields.includes(key)) {
continue;
}
options.push({
label: key.endsWith(".") ? key + "*" : key,
apply: key,
});
}
return {
from: word.from,
options: options,
};
}
// Returns all field keys as keyword patterns to highlight.
function keywords() {
const result = [];
const keys = getAllKeys(!disableRequestKeys, !disableIndirectCollectionsKeys);
for (const key of keys) {
let pattern;
if (key.endsWith(".")) {
pattern = CommonHelper.escapeRegExp(key) + "\\w+[\\w.]*";
} else {
pattern = CommonHelper.escapeRegExp(key);
}
result.push({ regex: pattern, token: "keyword" });
}
return result;
}
// Creates a new language mode.
// @see https://codemirror.net/5/demo/simplemode.html
function ruleLang() {
return StreamLanguage.define(
simpleMode({
start: [
// base literals
{
regex: /true|false|null/,
token: "atom",
},
// double quoted string
{ regex: /"(?:[^\\]|\\.)*?(?:"|$)/, token: "string" },
// single quoted string
{ regex: /'(?:[^\\]|\\.)*?(?:'|$)/, token: "string" },
// numbers
{
regex: /0x[a-f\d]+|[-+]?(?:\.\d+|\d+\.?\d*)(?:e[-+]?\d+)?/i,
token: "number",
},
// operators
{
regex: /\&\&|\|\||\=|\!\=|\~|\!\~|\>|\<|\>\=|\<\=/,
token: "operator",
},
// indent and dedent properties guide autoindentation
{ regex: /[\{\[\(]/, indent: true },
{ regex: /[\}\]\)]/, dedent: true },
].concat(keywords()),
})
);
}
onMount(() => {
const submitShortcut = {
key: "Enter",
run: (_) => {
// trigger submit on enter for singleline input
if (singleLine) {
dispatch("submit", value);
}
},
};
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,
...historyKeymap,
...completionKeymap,
]),
EditorView.lineWrapping,
autocompletion({
override: [completions],
icons: false,
}),
placeholderCompartment.of(placeholderExt(placeholder)),
editableCompartment.of(EditorView.editable.of(true)),
readOnlyCompartment.of(EditorState.readOnly.of(false)),
langCompartment.of(ruleLang()),
EditorState.transactionFilter.of((tr) => {
return singleLine && tr.newDoc.lines > 1 ? [] : tr;
}),
EditorView.updateListener.of((v) => {
if (!v.docChanged || disabled) {
return;
}
value = v.state.doc.toString();
triggerNativeChange();
}),
],
}),
});
return () => editor?.destroy();
});
</script>
<div bind:this={container} class="code-editor" />
@@ -0,0 +1,14 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
export let date = "";
</script>
{#if date}
<span class="txt" use:tooltip={CommonHelper.formatToLocalDate(date) + " Local"}>
{CommonHelper.formatToUTCDate(date)} UTC
</span>
{:else}
<span class="txt txt-hint">N/A</span>
{/if}
+40
View File
@@ -0,0 +1,40 @@
<script>
export let nobranding = false;
</script>
<div class="page-wrapper full-page-panel">
<div class="flex-fill" />
<div class="wrapper wrapper-sm m-b-xl">
{#if !nobranding}
<div class="block txt-center m-b-lg">
<figure class="logo">
<img
src="{import.meta.env.BASE_URL}images/logo.svg"
alt="PocketBase logo"
width="40"
height="40"
/>
<span class="txt">Pocket<strong>Base</strong></span>
</figure>
</div>
<div class="clearfix" />
{/if}
<slot />
</div>
<div class="flex-fill" />
</div>
<style>
.full-page-panel {
display: flex;
flex-direction: column;
align-items: center;
background: var(--baseColor);
}
.full-page-panel .wrapper {
animation: slideIn 200ms;
}
</style>
+15
View File
@@ -0,0 +1,15 @@
<script>
export let id = "";
let shortId = id;
$: if (typeof id === "string" && id.length > 27) {
shortId = id.substring(0, 5) + "..." + id.substring(id.length - 10);
}
</script>
{#if id}
<span class="label txt-base txt-mono" title={id}>{shortId}</span>
{:else}
<span class="txt txt-hint">N/A</span>
{/if}
@@ -0,0 +1,17 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
export let value = [];
export let separator = ",";
$: valueStr = (value || []).join(",");
</script>
<input
type={$$restProps.type || "text"}
value={valueStr}
on:input={(e) => {
value = CommonHelper.splitNonEmpty(e.target.value, separator);
}}
{...$$restProps}
/>
@@ -0,0 +1,71 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import Select from "@/components/base/Select.svelte";
import BaseSelectOption from "@/components/base/BaseSelectOption.svelte";
// original select props
export let items = []; // for groups support wrap in `[{group: 'My group', items: [...]}]`
export let multiple = false;
export let selected = multiple ? [] : undefined;
export let labelComponent = BaseSelectOption; // custom component to use for each selected option label
export let optionComponent = BaseSelectOption; // custom component to use for each dropdown option item
// custom props
export let selectionKey = "value";
export let keyOfSelected = multiple ? [] : undefined;
$: if (items) {
handleKeyOfSelectedChange(keyOfSelected);
}
$: handleSelectedChange(selected);
function handleKeyOfSelectedChange(newKeyOfSelected) {
newKeyOfSelected = CommonHelper.toArray(newKeyOfSelected, true);
let newSelected = [];
let allItems = getFlattenItems();
for (let item of allItems) {
if (CommonHelper.inArray(newKeyOfSelected, item[selectionKey])) {
newSelected.push(item);
}
}
if (newKeyOfSelected.length && !newSelected.length) {
return; // options are still loading...
}
selected = multiple ? newSelected : newSelected[0];
}
async function handleSelectedChange(newSelected) {
let extractedKeys = CommonHelper.toArray(newSelected, true).map((item) => item[selectionKey]);
if (!items.length) {
return; // options are still loading...
}
keyOfSelected = multiple ? extractedKeys : extractedKeys[0];
}
function getFlattenItems() {
if (!CommonHelper.isObjectArrayWithKeys(items, ["group", "items"])) {
return items; // already flatten
}
// extract items from groups
let result = [];
for (const group of items) {
result = result.concat(group.items);
}
return result;
}
</script>
<Select bind:selected {items} {multiple} {labelComponent} {optionComponent} on:show on:hide {...$$restProps}>
<svelte:fragment slot="afterOptions">
<slot name="afterOptions" />
</svelte:fragment>
</Select>
+237
View File
@@ -0,0 +1,237 @@
<script context="module">
let holder;
function getHolder() {
holder = holder || document.querySelector(".overlays");
if (!holder) {
// create
holder = document.createElement("div");
holder.classList.add("overlays");
document.body.appendChild(holder);
}
return holder;
}
</script>
<script>
/**
* Example usage:
* ```html
* <OverlayPanel bind:active={popupActive} popup={false}>
* <h5 slot="header">My title</h5>
* <p>Lorem ipsum dolor sit amet...</p>
* <svelte:fragment slot="footer">
* <button class="btn btn-secondary">Cancel</button>
* <button class="btn btn-expanded">Save</button>
* </svelte:fragment>
* </OverlayPanel>
* ```
*/
import { onMount, createEventDispatcher, tick } from "svelte";
import { fade, fly } from "svelte/transition";
import CommonHelper from "@/utils/CommonHelper";
let classes = "";
export { classes as class }; // export reserved keyword
export let active = false;
export let popup = false;
export let overlayClose = true;
export let btnClose = true;
export let escClose = true;
export let beforeOpen = undefined; // function callback called before open; if return false - no open
export let beforeHide = undefined; // function callback called before hide; if return false - no close
const dispatch = createEventDispatcher();
let wrapper;
let contentPanel;
let oldFocusedElem;
let transitionSpeed = 150;
let contentScrollThrottle;
let contentScrollClass = "";
$: onActiveChange(active);
$: handleContentScroll(contentPanel, true);
$: if (wrapper) {
zIndexUpdate();
}
export function show() {
if (typeof beforeOpen === "function" && beforeOpen() === false) {
return;
}
active = true;
}
export function hide() {
if (typeof beforeHide === "function" && beforeHide() === false) {
return;
}
active = false;
}
export function isActive() {
return active;
}
async function onActiveChange(state) {
if (state) {
oldFocusedElem = document.activeElement;
wrapper?.focus();
dispatch("show");
} else {
clearTimeout(contentScrollThrottle);
oldFocusedElem?.focus();
dispatch("hide");
}
await tick();
zIndexUpdate();
}
function zIndexUpdate() {
if (!wrapper) {
return;
}
if (active) {
wrapper.style.zIndex = highestZIndex();
} else {
wrapper.style = "";
}
}
function highestZIndex() {
return 1000 + getHolder().querySelectorAll(".overlay-panel-container.active").length;
}
function handleEscPress(e) {
if (
active &&
escClose &&
e.code == "Escape" &&
!CommonHelper.isInput(e.target) &&
wrapper &&
// it is the top most popup
wrapper.style.zIndex == highestZIndex()
) {
e.preventDefault();
hide();
}
}
function handleResize(e) {
if (active) {
handleContentScroll(contentPanel);
}
}
function handleContentScroll(panel, reset) {
if (reset) {
contentScrollClass = "";
}
if (!panel) {
return;
}
if (!contentScrollThrottle) {
contentScrollThrottle = setTimeout(() => {
clearTimeout(contentScrollThrottle);
contentScrollThrottle = null;
if (!panel) {
return; // deleted during timeout
}
let heightDiff = panel.scrollHeight - panel.offsetHeight;
if (heightDiff > 0) {
contentScrollClass = "scrollable";
} else {
contentScrollClass = "";
return; // no scroll
}
if (panel.scrollTop == 0) {
contentScrollClass += " scroll-top-reached";
} else if (panel.scrollTop + panel.offsetHeight == panel.scrollHeight) {
contentScrollClass += " scroll-bottom-reached";
}
}, 100);
}
}
onMount(() => {
// move outside of its current parent
getHolder().appendChild(wrapper);
return () => {
clearTimeout(contentScrollThrottle);
// ensures that no artifacts remains
// (currently there is a bug with svelte transition)
wrapper?.classList?.add("hidden");
};
});
</script>
<svelte:window on:resize={handleResize} on:keydown={handleEscPress} />
<div class="overlay-panel-wrapper" bind:this={wrapper}>
{#if active}
<div class="overlay-panel-container" class:padded={popup} class:active>
<div
class="overlay"
on:click|preventDefault={() => (overlayClose ? hide() : true)}
transition:fade={{ duration: transitionSpeed, opacity: 0 }}
/>
<div
class="overlay-panel {classes} {contentScrollClass}"
class:popup
in:fly={popup ? { duration: transitionSpeed, y: -10 } : { duration: transitionSpeed, x: 50 }}
out:fly={popup ? { duration: transitionSpeed, y: 10 } : { duration: transitionSpeed, x: 50 }}
>
<div class="overlay-panel-section panel-header">
{#if btnClose && !popup}
<div class="overlay-close" on:click|preventDefault={hide}>
<i class="ri-close-line" />
</div>
{/if}
<slot name="header" />
{#if btnClose && popup}
<button
type="button"
class="btn btn-sm btn-circle btn-secondary btn-close m-l-auto"
on:click|preventDefault={hide}
>
<i class="ri-close-line txt-lg" />
</button>
{/if}
</div>
<div
bind:this={contentPanel}
class="overlay-panel-section panel-content"
on:scroll={(e) => handleContentScroll(e.target)}
>
<slot />
</div>
<div class="overlay-panel-section panel-footer">
<slot name="footer" />
</div>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,37 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
let panel;
let url = "";
export function show(newUrl) {
if (newUrl === "") {
return;
}
CommonHelper.checkImageUrl(newUrl)
.then(() => {
url = newUrl;
panel?.show();
})
.catch(() => {
console.warn("Invalid image preview url: ", newUrl);
hide();
});
}
export function hide() {
return panel?.hide();
}
</script>
<OverlayPanel bind:this={panel} class="image-preview" popup on:show on:hide>
<img src={url} alt="Preview" />
<svelte:fragment slot="footer">
<a href={url} class="link-hint txt-ellipsis">/../{url.substring(url.lastIndexOf("/") + 1)}</a>
<div class="flex-fill" />
<button type="button" class="btn btn-secondary" on:click={hide}>Close</button>
</svelte:fragment>
</OverlayPanel>
@@ -0,0 +1,38 @@
<script>
import { tick } from "svelte";
import tooltip from "@/actions/tooltip";
export let value = "";
export let mask = "******";
let inputElem;
let locked = false;
$: if (value === mask) {
locked = true;
}
async function unlock() {
value = "";
locked = false;
await tick();
inputElem?.focus();
}
</script>
{#if locked}
<div class="form-field-addon">
<button
type="button"
class="btn btn-secondary btn-circle"
use:tooltip={{ position: "left", text: "Set new value" }}
on:click={() => unlock()}
>
<i class="ri-key-line" />
</button>
</div>
<input readonly type="text" placeholder={mask} {...$$restProps} />
{:else}
<input bind:this={inputElem} bind:value type="password" autocomplete="new-password" {...$$restProps} />
{/if}
+108
View File
@@ -0,0 +1,108 @@
<script>
import { createEventDispatcher, onMount } from "svelte";
import { fly } from "svelte/transition";
import { Collection } from "pocketbase";
import CommonHelper from "@/utils/CommonHelper";
const dispatch = createEventDispatcher();
const uniqueId = "search_" + CommonHelper.randomString(7);
export let value = "";
export let placeholder = 'Search filter, ex. created > "2022-01-01"...';
// autocomplete filter component fields
export let autocompleteCollection = new Collection();
export let extraAutocompleteKeys = [];
let filterComponent;
let isFilterComponentLoading = false;
let searchInput;
let tempValue = "";
$: if (typeof value === "string") {
tempValue = value;
}
function clear(focusInput = true) {
tempValue = "";
if (focusInput) {
searchInput?.focus();
}
dispatch("clear");
}
function submit() {
value = tempValue;
dispatch("submit", value);
}
async function loadFilterComponent() {
if (filterComponent || isFilterComponentLoading) {
return; // already loaded or in the process
}
isFilterComponentLoading = true;
filterComponent = (await import("@/components/base/FilterAutocompleteInput.svelte")).default;
isFilterComponentLoading = false;
}
onMount(() => {
loadFilterComponent();
});
</script>
<div class="searchbar-wrapper" on:click|stopPropagation>
<form class="searchbar" on:submit|preventDefault={submit}>
<label for={uniqueId} class="m-l-10 txt-xl">
<i class="ri-search-line" />
</label>
{#if filterComponent && !isFilterComponentLoading}
<svelte:component
this={filterComponent}
singleLine
disableRequestKeys
disableIndirectCollectionsKeys
{extraAutocompleteKeys}
baseCollection={autocompleteCollection}
placeholder={value || placeholder}
bind:value={tempValue}
on:submit={submit}
/>
{:else}
<input
bind:this={searchInput}
type="text"
id={uniqueId}
placeholder={value || placeholder}
bind:value={tempValue}
/>
{/if}
{#if value.length || tempValue.length}
{#if tempValue !== value}
<button
type="submit"
class="btn btn-expanded btn-sm btn-warning"
transition:fly={{ duration: 150, x: 5 }}
>
<span class="txt">Search</span>
</button>
{/if}
<button
type="button"
class="btn btn-secondary btn-sm btn-hint p-l-xs p-r-xs m-l-10"
transition:fly={{ duration: 150, x: 5 }}
on:click={() => {
clear(false);
submit();
}}
>
<span class="txt">Clear</span>
</button>
{/if}
</form>
</div>
+315
View File
@@ -0,0 +1,315 @@
<script>
import { onMount } from "svelte";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import Toggler from "@/components/base/Toggler.svelte";
const baseGroup = "_base_"; // reserved items group name
export let id = "";
export let noOptionsText = "No options found";
export let selectPlaceholder = "- Select -";
export let searchPlaceholder = "Search...";
export let items = []; // for groups support wrap in `[{group: 'My group', items: [...]}]`
export let multiple = false;
export let disabled = false;
export let selected = multiple ? [] : undefined;
export let toggle = false; // toggle option on click
export let labelComponent = undefined; // custom component to use for each selected option label
export let labelComponentProps = {}; // props to pass to the custom option component
export let optionComponent = undefined; // custom component to use for each dropdown option item
export let optionComponentProps = {}; // props to pass to the custom option component
export let searchable = false; // whether to show the dropdown options search input
export let searchFunc = undefined; // custom search option filter: `function(item, searchTerm):boolean`
let classes = "";
export { classes as class }; // export reserved keyword
let toggler;
let searchTerm = "";
let container = undefined;
let labelDiv = undefined;
$: groupedItems = CommonHelper.isObjectArrayWithKeys(items, ["group"])
? items
: [{ group: baseGroup, items: items }];
$: if (items) {
ensureSelectedExist();
resetSearch();
}
$: filteredGroups = filterGroups(groupedItems, searchTerm);
$: isSelected = function (item) {
let normalized = CommonHelper.toArray(selected);
return CommonHelper.inArray(normalized, item);
};
// Selection handlers
// ---------------------------------------------------------------
export function deselectItem(item) {
if (CommonHelper.isEmpty(selected)) {
return; // nothing to deselect
}
let normalized = CommonHelper.toArray(selected);
if (CommonHelper.inArray(normalized, item)) {
CommonHelper.removeByValue(normalized, item);
selected = normalized;
}
// emulate native change event
container?.dispatchEvent(new CustomEvent("change", { detail: selected, bubbles: true }));
}
export function selectItem(item) {
if (multiple) {
let normalized = CommonHelper.toArray(selected);
if (!CommonHelper.inArray(normalized, item)) {
selected = [...normalized, item];
}
} else {
selected = item;
}
// emulate native change event
container?.dispatchEvent(new CustomEvent("change", { detail: selected, bubbles: true }));
}
export function toggleItem(item) {
return isSelected(item) ? deselectItem(item) : selectItem(item);
}
export function reset() {
selected = multiple ? [] : undefined;
}
export function showDropdown() {
toggler?.show && toggler?.show();
}
export function hideDropdown() {
toggler?.hide && toggler?.hide();
}
function ensureSelectedExist() {
if (CommonHelper.isEmpty(selected) || CommonHelper.isEmpty(groupedItems)) {
return; // nothing to check
}
let selectedArray = CommonHelper.toArray(selected);
let unselectedArray = [];
// find missing
for (const selectedItem of selectedArray) {
let exist = false;
for (const group of groupedItems) {
if (CommonHelper.inArray(group.items, selectedItem)) {
exist = true;
break;
}
}
if (!exist) {
unselectedArray.push(selectedItem);
}
}
// trigger reactivity
if (unselectedArray.length) {
for (const item of unselectedArray) {
CommonHelper.removeByValue(selectedArray, item);
}
selected = multiple ? selectedArray : selectedArray[0];
}
}
// Search handlers
// ---------------------------------------------------------------
function defaultSearchFunc(item, search) {
let normalizedSearch = ("" + search).replace(/\s+/g, "").toLowerCase();
let normalizedItem = item;
try {
if (typeof item === "object" && item !== null) {
normalizedItem = JSON.stringify(item);
}
} catch (e) {}
return ("" + normalizedItem).replace(/\s+/g, "").toLowerCase().includes(normalizedSearch);
}
function resetSearch() {
searchTerm = "";
}
function filterGroups(groups, search) {
const result = [];
const filterFunc = searchFunc || defaultSearchFunc;
for (const group of groups) {
let groupItems;
if (typeof search === "string" && search.length) {
groupItems = group.items?.filter((item) => filterFunc(item, search)) || [];
} else {
groupItems = group.items || [];
}
if (groupItems.length) {
result.push({ group: group.group, items: groupItems });
}
}
return result;
}
// Option actions
// ---------------------------------------------------------------
function handleOptionSelect(e, item) {
e.preventDefault();
if (toggle && multiple) {
toggleItem(item);
} else {
selectItem(item);
}
}
function handleOptionKeypress(e, item) {
if (e.code === "Enter" || e.code === "Space") {
handleOptionSelect(e, item);
}
}
function onDropdownShow() {
resetSearch();
// ensure that the first selected option is visible
setTimeout(() => {
const selected = container?.querySelector(".dropdown-item.option.selected");
if (selected) {
selected.focus();
selected.scrollIntoView({ block: "nearest" });
}
}, 0);
}
// Label(s) activation
// ---------------------------------------------------------------
function onLabelClick(e) {
e.stopPropagation();
!disabled && toggler?.toggle();
}
onMount(() => {
const labels = document.querySelectorAll(`label[for="${id}"]`);
for (const label of labels) {
label.addEventListener("click", onLabelClick);
}
return () => {
for (const label of labels) {
label.removeEventListener("click", onLabelClick);
}
};
});
</script>
<div class="select {classes}" class:multiple class:disabled bind:this={container}>
<div tabindex={disabled ? "-1" : "0"} class="selected-container" class:disabled bind:this={labelDiv}>
{#each CommonHelper.toArray(selected) as item}
<div class="option">
{#if labelComponent}
<svelte:component this={labelComponent} {item} {...labelComponentProps} />
{:else}<span class="txt">{item}</span>{/if}
{#if multiple || toggle}
<span
class="clear"
use:tooltip={"Clear"}
on:click|preventDefault|stopPropagation={() => deselectItem(item)}
>
<i class="ri-close-line" />
</span>
{/if}
</div>
{:else}
<div class="txt-placeholder">{selectPlaceholder}</div>
{/each}
</div>
{#if !disabled}
<Toggler
class="dropdown dropdown-block options-dropdown dropdown-left"
trigger={labelDiv}
on:show={onDropdownShow}
on:hide
bind:this={toggler}
>
{#if searchable}
<div class="form-field form-field-sm options-search">
<label class="input-group">
<div class="addon p-r-0">
<i class="ri-search-line" />
</div>
<!-- svelte-ignore a11y-autofocus -->
<input
autofocus
type="text"
placeholder={searchPlaceholder}
bind:value={searchTerm}
/>
{#if searchTerm.length}
<div class="addon suffix p-r-5">
<button
type="button"
class="btn btn-sm btn-circle btn-secondary clear"
on:click|preventDefault|stopPropagation={resetSearch}
>
<i class="ri-close-line" />
</button>
</div>
{/if}
</label>
</div>
{/if}
<slot name="beforeOptions" />
<div class="options-list">
{#each filteredGroups as group}
{#if group.group != baseGroup}
<div class="dropdown-item separator">{group.group}</div>
{/if}
{#each group.items as item}
<div
tabindex="0"
class="dropdown-item option closable"
class:selected={isSelected(item)}
on:click={(e) => handleOptionSelect(e, item)}
on:keydown={(e) => handleOptionKeypress(e, item)}
>
{#if optionComponent}
<svelte:component this={optionComponent} {item} {...optionComponentProps} />
{:else}{item}{/if}
</div>
{/each}
{:else}
{#if noOptionsText}
<div class="txt-missing">{noOptionsText}</div>
{/if}
{/each}
</div>
<slot name="afterOptions" />
</Toggler>
{/if}
</div>
+38
View File
@@ -0,0 +1,38 @@
<script>
let classes = "";
export { classes as class }; // export reserved keyword
export let name;
export let sort = "";
export let disable = false;
function toggleSort() {
if (disable) {
return;
}
if ("-" + name === sort) {
sort = "+" + name;
} else {
sort = "-" + name;
}
}
</script>
<th
tabindex="0"
class="col-sort {classes}"
class:col-sort-disabled={disable}
class:sort-active={sort === "-" + name || sort === "+" + name}
class:sort-desc={sort === "-" + name}
class:sort-asc={sort === "+" + name}
on:click={() => toggleSort()}
on:keydown={(e) => {
if (e.code === "Enter" || e.code === "Space") {
e.preventDefault();
toggleSort();
}
}}
>
<slot />
</th>
+35
View File
@@ -0,0 +1,35 @@
<script>
import { fade } from "svelte/transition";
import { flip } from "svelte/animate";
import { toasts, removeToast } from "@/stores/toasts";
</script>
<div class="toasts-wrapper">
{#each $toasts as toast (toast.message)}
<div
class="alert txt-break"
class:alert-info={toast.type == "info"}
class:alert-success={toast.type == "success"}
class:alert-danger={toast.type == "error"}
class:alert-warning={toast.type == "warning"}
transition:fade={{ duration: 150 }}
animate:flip={{ duration: 150 }}
>
<div class="icon">
{#if toast.type === "info"}
<i class="ri-information-line" />
{:else if toast.type === "success"}
<i class="ri-checkbox-circle-line" />
{:else}
<i class="ri-alert-line" />
{/if}
</div>
<div class="content">{toast.message}</div>
<div class="close" on:click|preventDefault={() => removeToast(toast)}>
<i class="ri-close-line" />
</div>
</div>
{/each}
</div>
+112
View File
@@ -0,0 +1,112 @@
<script>
import { onMount, createEventDispatcher } from "svelte";
import { fly } from "svelte/transition";
export let trigger = undefined;
export let active = false;
export let escClose = true;
export let closableClass = "closable";
let classes = "";
export { classes as class }; // export reserved keyword
let container;
const dispatch = createEventDispatcher();
$: if (active) {
trigger?.classList?.add("active");
dispatch("show");
} else {
trigger?.classList?.remove("active");
dispatch("hide");
}
export function hide() {
active = false;
}
export function show() {
active = true;
}
export function toggle() {
if (active) {
hide();
} else {
show();
}
}
function isClosable(elem) {
return (
!container ||
elem.classList.contains(closableClass) ||
// is the trigger itself (or a direct child)
(trigger?.contains(elem) && !container.contains(elem)) ||
// is closable toggler child
(container.contains(elem) && elem.closest && elem.closest("." + closableClass))
);
}
function handleClickToggle(e) {
if (!active || isClosable(e.target)) {
e.preventDefault();
toggle();
}
}
function handleKeydownToggle(e) {
if (
(e.code === "Enter" || e.code === "Space") && // enter or spacebar
(!active || isClosable(e.target))
) {
e.preventDefault();
e.stopPropagation();
toggle();
}
}
function handleOutsideClick(e) {
if (active && !container?.contains(e.target) && !trigger?.contains(e.target)) {
hide();
}
}
function handleEscPress(e) {
if (active && escClose && e.code == "Escape") {
e.preventDefault();
hide();
}
}
function handleFocusChange(e) {
return handleOutsideClick(e);
}
onMount(() => {
trigger = trigger || container.parentNode;
trigger.addEventListener("click", handleClickToggle);
trigger.addEventListener("keydown", handleKeydownToggle);
return () => {
trigger.removeEventListener("click", handleClickToggle);
trigger.removeEventListener("keydown", handleKeydownToggle);
};
});
</script>
<svelte:window on:click={handleOutsideClick} on:keydown={handleEscPress} on:focusin={handleFocusChange} />
<div bind:this={container} class="toggler-container">
{#if active}
<div
class={classes}
class:active
in:fly|local={{ duration: 150, y: -5 }}
out:fly|local={{ duration: 150, y: 2 }}
>
<slot />
</div>
{/if}
</div>
@@ -0,0 +1,32 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
export let file; // File() instance
export let size = 50; // preview thumb size (if file is image)
$: if (typeof file !== "undefined") {
loadPreviewUrl();
}
$: previewUrl = "";
function loadPreviewUrl() {
previewUrl = "";
if (CommonHelper.hasImageExtension(file?.name)) {
CommonHelper.generateThumb(file, size, size)
.then((url) => {
previewUrl = url;
})
.catch((err) => {
console.warn("Unable to generate thumb: ", err);
});
}
}
</script>
{#if previewUrl}
<img src={previewUrl} width={size} height={size} alt={file.name} />
{:else}
<i class="ri-file-line" alt={file.name} />
{/if}