initial public commit
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user