initial public commit
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
<script>
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
|
||||
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
|
||||
import { addInfoToast } from "@/stores/toasts";
|
||||
|
||||
let popupActive = true;
|
||||
|
||||
setTimeout(function () {
|
||||
addInfoToast("Hello world");
|
||||
}, 500);
|
||||
</script>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="">EXAMPLE</label>
|
||||
<ObjectSelect multiple searchable items={["test1", "test2"]} />
|
||||
</div>
|
||||
<hr />
|
||||
<div class="form-field">
|
||||
<label for="">EXAMPLE</label>
|
||||
<ObjectSelect searchable items={["test1", "test2"]} />
|
||||
</div>
|
||||
<hr />
|
||||
<div class="form-field disabled">
|
||||
<label for="">EXAMPLE</label>
|
||||
<ObjectSelect disabled searchable items={["test1", "test2"]} />
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div class="alert">
|
||||
<div class="icon">
|
||||
<i class="ri-information-line" />
|
||||
</div>
|
||||
<div class="content">Hello world!</div>
|
||||
<div class="close">
|
||||
<i class="ri-close-line" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<div class="icon">
|
||||
<i class="ri-information-line" />
|
||||
</div>
|
||||
<div class="content">Hello world!</div>
|
||||
<div class="close">
|
||||
<i class="ri-close-line" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-danger">
|
||||
<div class="icon">
|
||||
<i class="ri-information-line" />
|
||||
</div>
|
||||
<div class="content">Hello world!</div>
|
||||
<div class="close">
|
||||
<i class="ri-close-line" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<div class="icon">
|
||||
<i class="ri-error-warning-line" />
|
||||
</div>
|
||||
<div class="content">Hello world!</div>
|
||||
<div class="close">
|
||||
<i class="ri-close-line" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success">
|
||||
<div class="icon">
|
||||
<i class="ri-checkbox-circle-line" />
|
||||
</div>
|
||||
<div class="content">Hello world!</div>
|
||||
<div class="close">
|
||||
<i class="ri-close-line" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<h1>H1 title</h1>
|
||||
<p>Lorem Ipsum dolor sit amet...</p>
|
||||
<h2>H2 title</h2>
|
||||
<p>Lorem Ipsum dolor sit amet...</p>
|
||||
<h3>H3 title</h3>
|
||||
<p>Lorem Ipsum dolor sit amet...</p>
|
||||
<h4>H4 title</h4>
|
||||
<p>Lorem Ipsum dolor sit amet...</p>
|
||||
<h5>H5 title</h5>
|
||||
<p>Lorem Ipsum dolor sit amet...</p>
|
||||
<h6>H6 title</h6>
|
||||
<p>Lorem Ipsum dolor sit amet...</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="grid">
|
||||
<div class="col-6">COL1</div>
|
||||
<div class="col-6">COL2</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Lorem Ipsum is <a href="/">simply dummy</a> text of the printing and typesetting industry. Lorem Ipsum has
|
||||
been the industry's
|
||||
<strong>standard</strong> dummy text ever since the 1500s, when an unknown printer took a galley of type
|
||||
and <em>scrambled</em> it to make a type specimen book. It has survived not only five centuries, but also
|
||||
the leap into electronic typesetting, remaining<sup>1</sup> essentially<sub>2</sub> unchanged.
|
||||
</p>
|
||||
<p>
|
||||
It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and
|
||||
more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li><small>Option 1</small></li>
|
||||
<li>Option 2</li>
|
||||
<li>Option 3</li>
|
||||
</ul>
|
||||
|
||||
<ol>
|
||||
<li>Option 1</li>
|
||||
<li>Option 2</li>
|
||||
<li>Option 3</li>
|
||||
</ol>
|
||||
|
||||
<hr />
|
||||
|
||||
<span use:tooltip={"My tooltip"}>Lorem Ipsum</span>
|
||||
|
||||
<hr />
|
||||
|
||||
<button class="btn">Button default</button>
|
||||
<button class="btn btn-danger">Button danger</button>
|
||||
<button class="btn btn-warning">Button warning</button>
|
||||
<button class="btn btn-success">Button success</button>
|
||||
<button class="btn btn-info">Button info</button>
|
||||
<button class="btn btn-hint">Button hint</button>
|
||||
|
||||
<hr />
|
||||
|
||||
<button class="btn btn-secondary">Button default</button>
|
||||
<button class="btn btn-secondary btn-danger">Button danger</button>
|
||||
<button class="btn btn-secondary btn-warning">Button danger</button>
|
||||
<button class="btn btn-secondary btn-success">Button success</button>
|
||||
<button class="btn btn-secondary btn-info">Button info</button>
|
||||
<button class="btn btn-secondary btn-hint">Button hint</button>
|
||||
|
||||
<hr />
|
||||
|
||||
<button class="btn btn-outline">Button default</button>
|
||||
<button class="btn btn-outline btn-danger">Button danger</button>
|
||||
<button class="btn btn-outline btn-warning">Button danger</button>
|
||||
<button class="btn btn-outline btn-success">Button success</button>
|
||||
<button class="btn btn-outline btn-info">Button info</button>
|
||||
<button class="btn btn-outline btn-hint">Button hint</button>
|
||||
|
||||
<hr />
|
||||
|
||||
<button disabled class="btn">Button default</button>
|
||||
<button disabled class="btn btn-danger">Button danger</button>
|
||||
<button disabled class="btn btn-warning">Button warning</button>
|
||||
<button disabled class="btn btn-success">Button success</button>
|
||||
<button disabled class="btn btn-info">Button info</button>
|
||||
<button disabled class="btn btn-hint">Button hint</button>
|
||||
|
||||
<button disabled class="btn btn-secondary">Button default</button>
|
||||
<button disabled class="btn btn-secondary btn-danger">Button danger</button>
|
||||
<button disabled class="btn btn-secondary btn-warning">Button danger</button>
|
||||
<button disabled class="btn btn-secondary btn-success">Button success</button>
|
||||
<button disabled class="btn btn-secondary btn-info">Button info</button>
|
||||
<button disabled class="btn btn-secondary btn-hint">Button hint</button>
|
||||
<hr />
|
||||
|
||||
<button class="btn">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button default</span>
|
||||
</button>
|
||||
<button class="btn btn-danger">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button danger</span>
|
||||
</button>
|
||||
<button class="btn btn-warning">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button warning</span>
|
||||
</button>
|
||||
<button class="btn btn-success">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button success</span>
|
||||
</button>
|
||||
<button class="btn btn-hint">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button hint</span>
|
||||
</button>
|
||||
|
||||
<hr />
|
||||
|
||||
<button class="btn btn-sm">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button default</span>
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button danger</span>
|
||||
</button>
|
||||
<button class="btn btn-warning btn-sm">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button warning</span>
|
||||
</button>
|
||||
<button class="btn btn-success btn-sm">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button success</span>
|
||||
</button>
|
||||
<button class="btn btn-hint btn-sm">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button hint</span>
|
||||
</button>
|
||||
|
||||
<hr />
|
||||
|
||||
<button class="btn btn-lg">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button default</span>
|
||||
</button>
|
||||
<button class="btn btn-danger btn-lg">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button danger</span>
|
||||
</button>
|
||||
<button class="btn btn-warning btn-lg">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button warning</span>
|
||||
</button>
|
||||
<button class="btn btn-success btn-lg">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button success</span>
|
||||
</button>
|
||||
<button class="btn btn-hint btn-lg">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button hint</span>
|
||||
</button>
|
||||
|
||||
<hr />
|
||||
|
||||
<button class="btn btn-circle">
|
||||
<i class="ri-mail-line" />
|
||||
</button>
|
||||
<button class="btn btn-sm btn-circle">
|
||||
<i class="ri-mail-line" />
|
||||
</button>
|
||||
<button class="btn btn-lg btn-circle">
|
||||
<i class="ri-mail-line" />
|
||||
</button>
|
||||
|
||||
<button class="btn btn-secondary btn-circle">
|
||||
<i class="ri-mail-line" />
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm btn-circle">
|
||||
<i class="ri-mail-line" />
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-lg btn-circle">
|
||||
<i class="ri-mail-line" />
|
||||
</button>
|
||||
|
||||
<hr />
|
||||
|
||||
<button class="btn btn-loading">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button Loading</span>
|
||||
</button>
|
||||
<button class="btn btn-loading btn-primary btn-sm">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button Loading</span>
|
||||
</button>
|
||||
<button class="btn btn-loading btn-danger btn-lg">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button Loading</span>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-loading btn-circle">
|
||||
<i class="ri-mail-line" />
|
||||
</button>
|
||||
<button class="btn btn-loading btn-primary btn-sm btn-circle">
|
||||
<i class="ri-mail-line" />
|
||||
</button>
|
||||
<button class="btn btn-loading btn-danger btn-lg btn-circle">
|
||||
<i class="ri-mail-line" />
|
||||
</button>
|
||||
|
||||
<hr />
|
||||
|
||||
<button disabled class="btn btn-loading">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button Loading</span>
|
||||
</button>
|
||||
<button disabled class="btn btn-loading btn-primary btn-sm">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button Loading</span>
|
||||
</button>
|
||||
<button disabled class="btn btn-loading btn-danger btn-lg">
|
||||
<i class="ri-mail-line" />
|
||||
<span class="txt">Button Loading</span>
|
||||
</button>
|
||||
|
||||
<button disabled class="btn btn-loading btn-circle">
|
||||
<i class="ri-mail-line" />
|
||||
</button>
|
||||
<button disabled class="btn btn-loading btn-primary btn-sm btn-circle">
|
||||
<i class="ri-mail-line" />
|
||||
</button>
|
||||
<button disabled class="btn btn-loading btn-danger btn-lg btn-circle">
|
||||
<i class="ri-mail-line" />
|
||||
</button>
|
||||
|
||||
<hr />
|
||||
|
||||
<input type="text" />
|
||||
|
||||
<hr />
|
||||
<select>
|
||||
<option value="1" selected>Option 1</option>
|
||||
<option value="">Option 2</option>
|
||||
<option value="">Option 3</option>
|
||||
</select>
|
||||
<hr />
|
||||
|
||||
<textarea cols="30" rows="10" />
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-field required">
|
||||
<label for="field_1">Name</label>
|
||||
<input type="text" id="field_1" placeholder="Name 123" />
|
||||
</div>
|
||||
|
||||
<div class="form-field required">
|
||||
<label for="field_2">Description</label>
|
||||
<textarea id="field_2" />
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="field_3">Choose value</label>
|
||||
<select id="field_3">
|
||||
<option value="1" selected>Option 1</option>
|
||||
<option value="">Option 2</option>
|
||||
<option value="">Option 3</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-field">
|
||||
<input type="text" placeholder="Lorem ipsum dolor sit amet..." />
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<textarea />
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<select>
|
||||
<option value="1" selected>Option 1</option>
|
||||
<option value="">Option 2</option>
|
||||
<option value="">Option 3</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-field">
|
||||
<input type="checkbox" id="field_check" />
|
||||
<label for="field_check">
|
||||
I agree with the <a href="/">terms and conditions</a>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<input type="radio" name="radio_check" id="field_radio1" value="1" />
|
||||
<label for="field_radio1">Radio 1</label>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<input type="radio" name="radio_check" id="field_radio2" value="2" />
|
||||
<label for="field_radio2">Radio 2</label>
|
||||
</div>
|
||||
|
||||
<div class="form-field form-field-toggle">
|
||||
<input type="checkbox" id="field_toggle" />
|
||||
<label for="field_toggle">Toggle check</label>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-field error">
|
||||
<label for="field_error">Name + error</label>
|
||||
<input type="text" id="field_error" />
|
||||
<div class="help-block help-block-error">
|
||||
<p>Something went wrong</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="field_hint">Name + hint</label>
|
||||
<input type="text" id="field_hint" />
|
||||
<div class="help-block">
|
||||
<p>Lorem ipsum dolor <a href="/">sit</a> amet</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<input type="checkbox" id="field_check_hint" />
|
||||
<label for="field_check_hint">Checkbox hint</label>
|
||||
<div class="help-block">Lorem ipsum</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field has-error">
|
||||
<input type="checkbox" id="field_check_error" />
|
||||
<label for="field_check_error">Checkbox error</label>
|
||||
<div class="help-block help-error">Lorem ipsum</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-field disabled">
|
||||
<input type="checkbox" id="field_check" disabled />
|
||||
<label for="field_check">
|
||||
I agree with the <a href="/">terms and conditions</a>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<input type="radio" name="radio_check" id="field_radio1" value="1" disabled />
|
||||
<label for="field_radio1">Radio 1</label>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<input type="radio" name="radio_check" id="field_radio2" value="2" disabled />
|
||||
<label for="field_radio2">Radio 2</label>
|
||||
</div>
|
||||
|
||||
<div class="form-field form-field-toggle disabled">
|
||||
<input type="checkbox" id="field_toggle" disabled />
|
||||
<label for="field_toggle" />
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-field disabled">
|
||||
<label for="field_addon1">Name</label>
|
||||
<div class="form-field-addon">
|
||||
<i class="ri-mail-line" />
|
||||
</div>
|
||||
<input disabled type="text" id="field_addon1" />
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<div class="form-field-addon">
|
||||
<i class="ri-mail-line" />
|
||||
</div>
|
||||
<input type="text" />
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-field">
|
||||
<label for="field_group1">Name</label>
|
||||
<div class="form-field-addon">
|
||||
<div class="btn btn-circle btn-secondary">
|
||||
<i class="ri-mail-line" />
|
||||
</div>
|
||||
</div>
|
||||
<input type="text" id="field_group1" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="field_group2">Password</label>
|
||||
<div class="form-field-addon">
|
||||
<i class="ri-mail-line" />
|
||||
</div>
|
||||
<input type="password" id="field_group2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OverlayPanel bind:active={popupActive} popup={false}>
|
||||
<h4 slot="header">My title</h4>
|
||||
<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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script>
|
||||
import { replace } from "svelte-spa-router";
|
||||
|
||||
replace("/collections");
|
||||
</script>
|
||||
@@ -0,0 +1,258 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
import { Admin } from "pocketbase";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import { setErrors } from "@/stores/errors";
|
||||
import { confirm } from "@/stores/confirmation";
|
||||
import { addSuccessToast } from "@/stores/toasts";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import Toggler from "@/components/base/Toggler.svelte";
|
||||
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const formId = "admin_" + CommonHelper.randomString(5);
|
||||
|
||||
let panel;
|
||||
let admin = new Admin();
|
||||
let isSaving = false;
|
||||
let confirmClose = false; // prevent close recursion
|
||||
let avatar = 0;
|
||||
let email = "";
|
||||
let password = "";
|
||||
let passwordConfirm = "";
|
||||
let changePasswordToggle = false;
|
||||
|
||||
$: hasChanges =
|
||||
(admin.isNew && email != "") ||
|
||||
changePasswordToggle ||
|
||||
email !== admin.email ||
|
||||
avatar !== admin.avatar;
|
||||
|
||||
export function show(model) {
|
||||
load(model);
|
||||
|
||||
confirmClose = true;
|
||||
|
||||
return panel?.show();
|
||||
}
|
||||
|
||||
export function hide() {
|
||||
return panel?.hide();
|
||||
}
|
||||
|
||||
function load(model) {
|
||||
setErrors({}); // reset errors
|
||||
admin = model?.clone ? model.clone() : new Admin();
|
||||
reset(); // reset form
|
||||
}
|
||||
|
||||
function reset() {
|
||||
changePasswordToggle = false;
|
||||
email = admin?.email || "";
|
||||
avatar = admin?.avatar || 0;
|
||||
password = "";
|
||||
passwordConfirm = "";
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (isSaving || !hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
const data = { email, avatar };
|
||||
if (admin.isNew || changePasswordToggle) {
|
||||
data["password"] = password;
|
||||
data["passwordConfirm"] = passwordConfirm;
|
||||
}
|
||||
|
||||
let request;
|
||||
if (admin.isNew) {
|
||||
request = ApiClient.Admins.create(data);
|
||||
} else {
|
||||
request = ApiClient.Admins.update(admin.id, data);
|
||||
}
|
||||
|
||||
request
|
||||
.then(async (result) => {
|
||||
confirmClose = false;
|
||||
hide();
|
||||
addSuccessToast(admin.isNew ? "Successfully created admin." : "Successfully updated admin.");
|
||||
dispatch("save", result);
|
||||
|
||||
if (ApiClient.AuthStore.model?.id === result.id) {
|
||||
ApiClient.AuthStore.save(ApiClient.AuthStore.token, result);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
})
|
||||
.finally(() => {
|
||||
isSaving = false;
|
||||
});
|
||||
}
|
||||
|
||||
function deleteConfirm() {
|
||||
if (!admin?.id) {
|
||||
return; // nothing to delete
|
||||
}
|
||||
|
||||
confirm(`Do you really want to delete the selected admin?`, () => {
|
||||
return ApiClient.Admins.delete(admin.id)
|
||||
.then(() => {
|
||||
confirmClose = false;
|
||||
hide();
|
||||
addSuccessToast("Successfully deleted admin.");
|
||||
dispatch("delete", admin);
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<OverlayPanel
|
||||
bind:this={panel}
|
||||
popup
|
||||
class="admin-panel"
|
||||
beforeHide={() => {
|
||||
if (hasChanges && confirmClose) {
|
||||
confirm("You have unsaved changes. Do you really want to close the panel?", () => {
|
||||
confirmClose = false;
|
||||
hide();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
on:hide
|
||||
on:show
|
||||
>
|
||||
<svelte:fragment slot="header">
|
||||
<h4>
|
||||
{admin.isNew ? "New admin" : "Edit admin"}
|
||||
</h4>
|
||||
</svelte:fragment>
|
||||
|
||||
<form id={formId} class="grid" autocomplete="off" on:submit|preventDefault={save}>
|
||||
{#if !admin.isNew}
|
||||
<Field class="form-field disabled" name="id" let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class={CommonHelper.getFieldTypeIcon("primary")} />
|
||||
<span class="txt">ID</span>
|
||||
</label>
|
||||
<input type="text" id={uniqueId} value={admin.id} disabled />
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
<div class="content">
|
||||
<p class="section-title">Avatar</p>
|
||||
<div class="flex flex-gap-xs flex-wrap">
|
||||
{#each [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as index}
|
||||
<figure
|
||||
tabindex="0"
|
||||
class="link-fade thumb thumb-circle {index == avatar ? 'thumb-active' : 'thumb-sm'}"
|
||||
on:click={() => (avatar = index)}
|
||||
on:keydown={(e) => {
|
||||
if (e.code === "Enter" || e.code === "Space") {
|
||||
e.preventDefault();
|
||||
avatar = index;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="{import.meta.env.BASE_URL}images/avatars/avatar{index}.svg"
|
||||
alt="Avatar {index}"
|
||||
/>
|
||||
</figure>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field class="form-field required" name="email" let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class={CommonHelper.getFieldTypeIcon("email")} />
|
||||
<span class="txt">Email</span>
|
||||
</label>
|
||||
<input type="email" autocomplete="off" id={uniqueId} required bind:value={email} />
|
||||
</Field>
|
||||
|
||||
{#if !admin.isNew}
|
||||
<Field class="form-field form-field-toggle" let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} bind:checked={changePasswordToggle} />
|
||||
<label for={uniqueId}>Change password</label>
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
{#if admin.isNew || changePasswordToggle}
|
||||
<div class="col-12">
|
||||
<div class="grid" transition:slide={{ duration: 150 }}>
|
||||
<div class="col-sm-6">
|
||||
<Field class="form-field required" name="password" let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class="ri-lock-line" />
|
||||
<span class="txt">Password</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
id={uniqueId}
|
||||
required
|
||||
bind:value={password}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<Field class="form-field required" name="passwordConfirm" let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class="ri-lock-line" />
|
||||
<span class="txt">Password confirm</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
id={uniqueId}
|
||||
required
|
||||
bind:value={passwordConfirm}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
{#if !admin.isNew}
|
||||
<button type="button" class="btn btn-sm btn-circle btn-secondary">
|
||||
<!-- empty span for alignment -->
|
||||
<span />
|
||||
<i class="ri-more-line" />
|
||||
<Toggler class="dropdown dropdown-upside dropdown-left dropdown-nowrap">
|
||||
<button type="button" class="dropdown-item" on:click={() => deleteConfirm()}>
|
||||
<i class="ri-delete-bin-7-line" />
|
||||
<span class="txt">Delete</span>
|
||||
</button>
|
||||
</Toggler>
|
||||
</button>
|
||||
<div class="flex-fill" />
|
||||
{/if}
|
||||
|
||||
<button type="button" class="btn btn-secondary" disabled={isSaving} on:click={() => hide()}>
|
||||
<span class="txt">Cancel</span>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form={formId}
|
||||
class="btn btn-expanded"
|
||||
class:btn-loading={isSaving}
|
||||
disabled={!hasChanges || isSaving}
|
||||
>
|
||||
<span class="txt">{admin.isNew ? "Create" : "Save changes"}</span>
|
||||
</button>
|
||||
</svelte:fragment>
|
||||
</OverlayPanel>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script>
|
||||
import { link, replace } from "svelte-spa-router";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import { addSuccessToast } from "@/stores/toasts";
|
||||
import FullPage from "@/components/base/FullPage.svelte";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
export let params;
|
||||
|
||||
let newPassword = "";
|
||||
let newPasswordConfirm = "";
|
||||
let isLoading = false;
|
||||
|
||||
$: email = CommonHelper.getJWTPayload(params?.token).email || "";
|
||||
|
||||
async function submit() {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
await ApiClient.Admins.confirmPasswordReset(params?.token, newPassword, newPasswordConfirm);
|
||||
addSuccessToast("Successfully set a new admin password.");
|
||||
replace("/");
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<FullPage>
|
||||
<form class="m-b-base" on:submit|preventDefault={submit}>
|
||||
<div class="content txt-center m-b-sm">
|
||||
<h4 class="m-b-xs">
|
||||
Reset your admin password
|
||||
{#if email}
|
||||
for <strong class="txt-nowrap">{email}</strong>
|
||||
{/if}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<Field class="form-field required" name="password" let:uniqueId>
|
||||
<label for={uniqueId}>New password</label>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input type="password" id={uniqueId} required autofocus bind:value={newPassword} />
|
||||
</Field>
|
||||
|
||||
<Field class="form-field required" name="passwordConfirm" let:uniqueId>
|
||||
<label for={uniqueId}>New password confirm</label>
|
||||
<input type="password" id={uniqueId} required bind:value={newPasswordConfirm} />
|
||||
</Field>
|
||||
|
||||
<button type="submit" class="btn btn-lg btn-block" class:btn-loading={isLoading} disabled={isLoading}>
|
||||
<span class="txt">Set new password</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="content txt-center">
|
||||
<a href="/login" class="link-hint" use:link>Back to login</a>
|
||||
</div>
|
||||
</FullPage>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script>
|
||||
import { link } from "svelte-spa-router";
|
||||
import { replace } from "svelte-spa-router";
|
||||
import FullPage from "@/components/base/FullPage.svelte";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import { addErrorToast } from "@/stores/toasts";
|
||||
|
||||
const queryParams = CommonHelper.getQueryParams(window.location?.href);
|
||||
|
||||
let email = queryParams.demoEmail || "";
|
||||
let password = queryParams.demoPassword || "";
|
||||
let isLoading = false;
|
||||
|
||||
function login() {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
|
||||
return ApiClient.Admins.authViaEmail(email, password)
|
||||
.then(() => {
|
||||
replace("/");
|
||||
})
|
||||
.catch(() => {
|
||||
addErrorToast("Invalid login credentials.");
|
||||
})
|
||||
.finally(() => {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<FullPage>
|
||||
<form class="block" on:submit|preventDefault={login}>
|
||||
<div class="content txt-center m-b-base">
|
||||
<h4>Admin sign in</h4>
|
||||
</div>
|
||||
|
||||
<Field class="form-field required" name="email" let:uniqueId>
|
||||
<label for={uniqueId}>Email</label>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input type="email" id={uniqueId} bind:value={email} required autofocus />
|
||||
</Field>
|
||||
|
||||
<Field class="form-field required" name="password" let:uniqueId>
|
||||
<label for={uniqueId}>Password</label>
|
||||
<input type="password" id={uniqueId} bind:value={password} required />
|
||||
<div class="help-block">
|
||||
<a href="/request-password-reset" class="link-hint" use:link>Forgotten password?</a>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-lg btn-block btn-next"
|
||||
class:btn-disabled={isLoading}
|
||||
class:btn-loading={isLoading}
|
||||
>
|
||||
<span class="txt">Login</span>
|
||||
<i class="ri-arrow-right-line" />
|
||||
</button>
|
||||
</form>
|
||||
</FullPage>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script>
|
||||
import { link } from "svelte-spa-router";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import FullPage from "@/components/base/FullPage.svelte";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
let email = "";
|
||||
let isLoading = false;
|
||||
let success = false;
|
||||
|
||||
async function submit() {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
await ApiClient.Admins.requestPasswordReset(email);
|
||||
success = true;
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<FullPage>
|
||||
{#if success}
|
||||
<div class="alert alert-success">
|
||||
<div class="icon"><i class="ri-checkbox-circle-line" /></div>
|
||||
<div class="content">
|
||||
<p>Check <strong class="txt-nowrap">{email}</strong> for the recovery link.</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<form class="m-b-base" on:submit|preventDefault={submit}>
|
||||
<div class="content txt-center m-b-sm">
|
||||
<h4 class="m-b-xs">Forgotten admin password</h4>
|
||||
<p>Enter the email associated with your account and we’ll send you a recovery link:</p>
|
||||
</div>
|
||||
|
||||
<Field class="form-field required" name="email" let:uniqueId>
|
||||
<label for={uniqueId}>Email</label>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input type="email" id={uniqueId} required autofocus bind:value={email} />
|
||||
</Field>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-lg btn-block"
|
||||
class:btn-loading={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<i class="ri-mail-send-line" />
|
||||
<span class="txt">Send recovery link</span>
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="content txt-center">
|
||||
<a href="/login" class="link-hint" use:link>Back to login</a>
|
||||
</div>
|
||||
</FullPage>
|
||||
@@ -0,0 +1,198 @@
|
||||
<script>
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import { admin as loggedAdmin } from "@/stores/admin";
|
||||
import Searchbar from "@/components/base/Searchbar.svelte";
|
||||
import SortHeader from "@/components/base/SortHeader.svelte";
|
||||
import IdLabel from "@/components/base/IdLabel.svelte";
|
||||
import FormattedDate from "@/components/base/FormattedDate.svelte";
|
||||
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
|
||||
import AdminUpsertPanel from "@/components/admins/AdminUpsertPanel.svelte";
|
||||
|
||||
const queryParams = CommonHelper.getQueryParams(window.location?.href);
|
||||
|
||||
let adminUpsertPanel;
|
||||
let admins = [];
|
||||
let isLoading = false;
|
||||
let filter = queryParams.filter || "";
|
||||
let sort = queryParams.sort || "-created";
|
||||
|
||||
$: if (sort !== -1 && filter !== -1) {
|
||||
// keep listing params in sync
|
||||
CommonHelper.replaceClientQueryParams({
|
||||
filter: filter,
|
||||
sort: sort,
|
||||
});
|
||||
|
||||
loadAdmins();
|
||||
}
|
||||
|
||||
CommonHelper.setDocumentTitle("Admins");
|
||||
|
||||
export function loadAdmins() {
|
||||
isLoading = true;
|
||||
admins = []; // reset
|
||||
|
||||
return ApiClient.Admins.getFullList(100, {
|
||||
sort: sort || "-created",
|
||||
filter: filter,
|
||||
})
|
||||
.then((result) => {
|
||||
admins = result;
|
||||
isLoading = false;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err !== null) {
|
||||
isLoading = false;
|
||||
console.warn(err);
|
||||
clearList();
|
||||
ApiClient.errorResponseHandler(err, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clearList() {
|
||||
admins = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsSidebar />
|
||||
|
||||
<main class="page-wrapper">
|
||||
<header class="page-header">
|
||||
<nav class="breadcrumbs">
|
||||
<div class="breadcrumb-item">Settings</div>
|
||||
<div class="breadcrumb-item">Admins</div>
|
||||
</nav>
|
||||
|
||||
<div class="flex-fill" />
|
||||
|
||||
<button type="button" class="btn btn-expanded" on:click={() => adminUpsertPanel?.show()}>
|
||||
<i class="ri-add-line" />
|
||||
<span class="txt">New admin</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<Searchbar
|
||||
value={filter}
|
||||
placeholder={"Search filter, eg. email='test@example.com'"}
|
||||
extraAutocompleteKeys={["email"]}
|
||||
on:submit={(e) => (filter = e.detail)}
|
||||
/>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="table" class:table-loading={isLoading}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="min-width" />
|
||||
|
||||
<SortHeader class="col-type-text" name="id" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("primary")} />
|
||||
<span class="txt">id</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<SortHeader class="col-type-email col-field-email" name="email" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("email")} />
|
||||
<span class="txt">email</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<SortHeader class="col-type-date col-field-created" name="created" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("date")} />
|
||||
<span class="txt">created</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<SortHeader class="col-type-date col-field-updated" name="updated" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("date")} />
|
||||
<span class="txt">updated</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<th class="col-type-action min-width" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each admins as admin (admin.id)}
|
||||
<tr
|
||||
tabindex="0"
|
||||
class="row-handle"
|
||||
on:click={() => adminUpsertPanel?.show(admin)}
|
||||
on:keydown={(e) => {
|
||||
if (e.code === "Enter" || e.code === "Space") {
|
||||
e.preventDefault();
|
||||
adminUpsertPanel?.show(admin);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td class="min-width">
|
||||
<figure class="thumb thumb-sm thumb-circle">
|
||||
<img
|
||||
src="{import.meta.env.BASE_URL}images/avatars/avatar{admin.avatar ||
|
||||
0}.svg"
|
||||
alt="Admin avatar"
|
||||
/>
|
||||
</figure>
|
||||
</td>
|
||||
<td class="col-type-text col-field-id">
|
||||
<IdLabel id={admin.id} />
|
||||
{#if admin.id === $loggedAdmin.id}
|
||||
<span class="label label-warning m-l-5">You</span>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<td class="col-type-email col-field-email">
|
||||
<span class="txt txt-ellipsis" title={admin.email}>
|
||||
{admin.email}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="col-type-date col-field-created">
|
||||
<FormattedDate date={admin.created} />
|
||||
</td>
|
||||
<td class="col-type-date col-field-updated">
|
||||
<FormattedDate date={admin.updated} />
|
||||
</td>
|
||||
<td class="col-type-action min-width">
|
||||
<i class="ri-arrow-right-line" />
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#if isLoading}
|
||||
<tr>
|
||||
<td colspan="99" class="p-xs">
|
||||
<span class="skeleton-loader" />
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="99" class="txt-center txt-hint p-xs">
|
||||
<h6>No admins found.</h6>
|
||||
{#if filter?.length}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-hint btn-expanded m-t-sm"
|
||||
on:click={() => (filter = "")}
|
||||
>
|
||||
<span class="txt">Clear filters</span>
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if admins.length}
|
||||
<small class="block txt-hint txt-right m-t-sm">Showing {admins.length} of {admins.length}</small>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<AdminUpsertPanel bind:this={adminUpsertPanel} on:save={() => loadAdmins()} on:delete={() => loadAdmins()} />
|
||||
@@ -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}
|
||||
@@ -0,0 +1,83 @@
|
||||
<script>
|
||||
import { SchemaField } from "pocketbase";
|
||||
import FieldAccordion from "@/components/collections/FieldAccordion.svelte";
|
||||
|
||||
const reservedNames = ["id", "created", "updated"];
|
||||
|
||||
export let collection = {};
|
||||
|
||||
$: if (typeof collection?.schema === "undefined") {
|
||||
collection = collection || {};
|
||||
collection.schema = [];
|
||||
}
|
||||
|
||||
function removeField(fieldIndex) {
|
||||
if (collection.schema[fieldIndex]) {
|
||||
collection.schema.splice(fieldIndex, 1);
|
||||
collection.schema = collection.schema;
|
||||
}
|
||||
}
|
||||
|
||||
function newField() {
|
||||
const field = new SchemaField({
|
||||
name: getUniqueFieldName(),
|
||||
});
|
||||
|
||||
collection.schema.push(field);
|
||||
collection.schema = collection.schema;
|
||||
}
|
||||
|
||||
function getUniqueFieldName(base = "field") {
|
||||
let counter = "";
|
||||
|
||||
while (hasFieldWithName(base + counter)) {
|
||||
++counter;
|
||||
}
|
||||
|
||||
return base + counter;
|
||||
}
|
||||
|
||||
function hasFieldWithName(name) {
|
||||
return !!collection.schema.find((field) => field.name === name);
|
||||
}
|
||||
|
||||
function getSiblingsFieldNames(currentField) {
|
||||
let result = [];
|
||||
|
||||
for (let field of collection.schema) {
|
||||
if (field === currentField) {
|
||||
continue; // skip current
|
||||
}
|
||||
|
||||
result.push(field.name);
|
||||
|
||||
if (field.id && field.originalName !== "" && field.originalName !== field.name) {
|
||||
result.push(field.originalName);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="accordions">
|
||||
{#each collection.schema as field, i (i)}
|
||||
<FieldAccordion
|
||||
bind:field
|
||||
key={i}
|
||||
excludeNames={reservedNames.concat(getSiblingsFieldNames(field))}
|
||||
on:remove={() => removeField(i)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="clearfix m-t-xs" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-block {collection.schema?.length ? 'btn-secondary' : 'btn-success'}"
|
||||
on:click={newField}
|
||||
>
|
||||
<i class="ri-add-line" />
|
||||
<span class="txt">New field</span>
|
||||
</button>
|
||||
@@ -0,0 +1,188 @@
|
||||
<script>
|
||||
import { onMount, tick } from "svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
import { Collection } from "pocketbase";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
export let collection = new Collection();
|
||||
|
||||
let tempValues = {};
|
||||
let showFiltersInfo = false;
|
||||
let editorRefs = {};
|
||||
let ruleInputComponent;
|
||||
let isRuleComponentLoading = false;
|
||||
|
||||
// all supported collection rules in "collection_rule_prop: label" format
|
||||
const ruleProps = {
|
||||
listRule: "List Action",
|
||||
viewRule: "View Action",
|
||||
createRule: "Create Action",
|
||||
updateRule: "Update Action",
|
||||
deleteRule: "Delete Action",
|
||||
};
|
||||
|
||||
function isAdminOnly(propVal) {
|
||||
return propVal === null;
|
||||
}
|
||||
|
||||
async function loadEditorComponent() {
|
||||
isRuleComponentLoading = true;
|
||||
try {
|
||||
ruleInputComponent = (await import("@/components/base/FilterAutocompleteInput.svelte")).default;
|
||||
} catch (err) {
|
||||
console.warn(err);
|
||||
ruleInputComponent = null;
|
||||
}
|
||||
isRuleComponentLoading = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadEditorComponent();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="block m-b-base">
|
||||
<div class="flex">
|
||||
<p>
|
||||
All rules follow the
|
||||
<a href={import.meta.env.PB_RULES_SYNTAX_DOCS} target="_blank" rel="noopener">
|
||||
PocketBase filter syntax and operators
|
||||
</a>.
|
||||
</p>
|
||||
<span
|
||||
class="expand-handle txt-sm txt-bold txt-nowrap link-hint"
|
||||
on:click={() => (showFiltersInfo = !showFiltersInfo)}
|
||||
>
|
||||
{showFiltersInfo ? "Hide available fields" : "Show available fields"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if showFiltersInfo}
|
||||
<div transition:slide|local={{ duration: 150 }}>
|
||||
<div class="alert alert-warning m-0">
|
||||
<div class="content">
|
||||
<p class="m-b-0">The following record fields are available:</p>
|
||||
<div class="inline-flex flex-gap-5">
|
||||
<code>id</code>
|
||||
<code>created</code>
|
||||
<code>updated</code>
|
||||
{#each collection.schema as field}
|
||||
{#if field.type === "relation" || field.type === "user"}
|
||||
<code>{field.name}.*</code>
|
||||
{:else}
|
||||
<code>{field.name}</code>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<hr class="m-t-10 m-b-5" />
|
||||
|
||||
<p class="m-b-0">
|
||||
The request fields could be accessed with the special <em>@request</em> filter:
|
||||
</p>
|
||||
<div class="inline-flex flex-gap-5">
|
||||
<code>@request.method</code>
|
||||
<code>@request.query.*</code>
|
||||
<code>@request.data.*</code>
|
||||
<code>@request.user.*</code>
|
||||
</div>
|
||||
|
||||
<hr class="m-t-10 m-b-5" />
|
||||
|
||||
<p class="m-b-0">
|
||||
You could also add constraints and query other collections using the <em
|
||||
>@collection</em
|
||||
> filter:
|
||||
</p>
|
||||
<div class="inline-flex flex-gap-5">
|
||||
<code>@collection.ANY_COLLECTION_NAME.*</code>
|
||||
</div>
|
||||
|
||||
<hr class="m-t-10 m-b-5" />
|
||||
|
||||
<p>
|
||||
Example rule:
|
||||
<br />
|
||||
<code>@request.user.id!=null && created>"2022-01-01 00:00:00"</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isRuleComponentLoading}
|
||||
<div class="txt-center">
|
||||
<span class="loader" />
|
||||
</div>
|
||||
{:else}
|
||||
{#each Object.entries(ruleProps) as [prop, label] (prop)}
|
||||
<hr class="m-t-sm m-b-sm" />
|
||||
<div class="rule-block">
|
||||
{#if isAdminOnly(collection[prop])}
|
||||
<button
|
||||
type="button"
|
||||
class="rule-toggle-btn btn btn-circle btn-outline btn-success"
|
||||
use:tooltip={"Unlock and set custom rule"}
|
||||
on:click={async () => {
|
||||
collection[prop] = tempValues[prop] || "";
|
||||
await tick();
|
||||
editorRefs[prop]?.focus();
|
||||
}}
|
||||
>
|
||||
<i class="ri-lock-unlock-line" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="rule-toggle-btn btn btn-circle btn-outline"
|
||||
use:tooltip={"Lock and set to Admins only"}
|
||||
on:click={() => {
|
||||
tempValues[prop] = collection[prop];
|
||||
collection[prop] = null;
|
||||
}}
|
||||
>
|
||||
<i class="ri-lock-line" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<Field
|
||||
class="form-field rule-field m-0 {isAdminOnly(collection[prop]) ? 'disabled' : ''}"
|
||||
name={prop}
|
||||
let:uniqueId
|
||||
>
|
||||
<label for={uniqueId} on:click={() => editorRefs[prop]?.focus()}>
|
||||
{label} - {isAdminOnly(collection[prop]) ? "Admins only" : "Custom rule"}
|
||||
</label>
|
||||
|
||||
<svelte:component
|
||||
this={ruleInputComponent}
|
||||
bind:this={editorRefs[prop]}
|
||||
bind:value={collection[prop]}
|
||||
baseCollection={collection}
|
||||
disabled={isAdminOnly(collection[prop])}
|
||||
/>
|
||||
|
||||
<div class="help-block">
|
||||
{#if isAdminOnly(collection[prop])}
|
||||
Only admins will be able to access (unlock to change)
|
||||
{:else}
|
||||
Leave empty to grant everyone access
|
||||
{/if}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.rule-block {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--xsSpacing);
|
||||
}
|
||||
.rule-toggle-btn {
|
||||
margin-top: 15px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script>
|
||||
import { tick, createEventDispatcher } from "svelte";
|
||||
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let panel;
|
||||
let collection;
|
||||
|
||||
$: isCollectionRenamed = collection?.originalName != collection?.name;
|
||||
|
||||
$: renamedFields =
|
||||
collection?.schema.filter(
|
||||
(field) => field.id && !field.toDelete && field.originalName != field.name
|
||||
) || [];
|
||||
|
||||
$: deletedFields = collection?.schema.filter((field) => field.id && field.toDelete) || [];
|
||||
|
||||
export async function show(collectionToCheck) {
|
||||
collection = collectionToCheck;
|
||||
|
||||
await tick();
|
||||
|
||||
if (!isCollectionRenamed && !renamedFields.length && !deletedFields.length) {
|
||||
// no confirm required changes
|
||||
confirm();
|
||||
} else {
|
||||
panel?.show();
|
||||
}
|
||||
}
|
||||
|
||||
export function hide() {
|
||||
panel?.hide();
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
hide();
|
||||
dispatch("confirm");
|
||||
}
|
||||
</script>
|
||||
|
||||
<OverlayPanel bind:this={panel} class="confirm-changes-panel" popup on:hide on:show>
|
||||
<svelte:fragment slot="header">
|
||||
<h4>Confirm collection changes</h4>
|
||||
</svelte:fragment>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<div class="icon">
|
||||
<i class="ri-error-warning-line" />
|
||||
</div>
|
||||
<div class="content txt-bold">
|
||||
<p>
|
||||
If any of the following changes is part of another collection rule or filter, you'll have to
|
||||
update it manually!
|
||||
</p>
|
||||
{#if deletedFields.length}
|
||||
<p>All data associated with the removed fields will be permanently deleted!</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6>Changes:</h6>
|
||||
<ul class="changes-list">
|
||||
{#if isCollectionRenamed}
|
||||
<li>
|
||||
<div class="inline-flex">
|
||||
Renamed collection
|
||||
<strong class="txt-strikethrough txt-hint">{collection.originalName}</strong>
|
||||
<i class="ri-arrow-right-line txt-sm" />
|
||||
<strong class="txt"> {collection.name}</strong>
|
||||
</div>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
{#each renamedFields as field}
|
||||
<li>
|
||||
<div class="inline-flex">
|
||||
Renamed field
|
||||
<strong class="txt-strikethrough txt-hint">{field.originalName}</strong>
|
||||
<i class="ri-arrow-right-line txt-sm" />
|
||||
<strong class="txt"> {field.name}</strong>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
{#each deletedFields as field}
|
||||
<li class="txt-danger">
|
||||
Removed field <span class="txt-bold">{field.name}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<button autofocus type="button" class="btn btn-secondary" on:click={() => hide()}>
|
||||
<span class="txt">Cancel</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-expanded" on:click={() => confirm()}>
|
||||
<span class="txt">Confirm</span>
|
||||
</button>
|
||||
</svelte:fragment>
|
||||
</OverlayPanel>
|
||||
|
||||
<style>
|
||||
.changes-list {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,306 @@
|
||||
<script>
|
||||
import { Collection } from "pocketbase";
|
||||
import { createEventDispatcher, tick } from "svelte";
|
||||
import { scale } from "svelte/transition";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import { errors, setErrors } from "@/stores/errors";
|
||||
import { confirm } from "@/stores/confirmation";
|
||||
import { addSuccessToast } from "@/stores/toasts";
|
||||
import { addCollection, removeCollection, activeCollection } from "@/stores/collections";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import Toggler from "@/components/base/Toggler.svelte";
|
||||
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
|
||||
import CollectionFieldsTab from "@/components/collections/CollectionFieldsTab.svelte";
|
||||
import CollectionRulesTab from "@/components/collections/CollectionRulesTab.svelte";
|
||||
import CollectionUpdateConfirm from "@/components/collections/CollectionUpdateConfirm.svelte";
|
||||
|
||||
const TAB_FIELDS = "fields";
|
||||
const TAB_RULES = "api_rules";
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let collectionPanel;
|
||||
let confirmChangesPanel;
|
||||
|
||||
let original = null;
|
||||
let collection = new Collection();
|
||||
let isSaving = false;
|
||||
let confirmClose = false; // prevent close recursion
|
||||
let activeTab = TAB_FIELDS;
|
||||
let initialFormHash = calculateFormHash(collection);
|
||||
|
||||
$: schemaTabError =
|
||||
// extract the direct schema field error, otherwise - return a generic message
|
||||
typeof CommonHelper.getNestedVal($errors, "schema.message", null) === "string"
|
||||
? CommonHelper.getNestedVal($errors, "schema.message")
|
||||
: "Has errors";
|
||||
|
||||
$: isSystemUpdate = !collection.isNew && collection.system;
|
||||
|
||||
$: hasChanges = initialFormHash != calculateFormHash(collection);
|
||||
|
||||
$: canSave = collection.isNew || hasChanges;
|
||||
|
||||
export function changeTab(newTab) {
|
||||
activeTab = newTab;
|
||||
}
|
||||
|
||||
export function show(model) {
|
||||
load(model);
|
||||
|
||||
confirmClose = true;
|
||||
|
||||
changeTab(TAB_FIELDS);
|
||||
|
||||
return collectionPanel?.show();
|
||||
}
|
||||
|
||||
export function hide() {
|
||||
return collectionPanel?.hide();
|
||||
}
|
||||
|
||||
async function load(model) {
|
||||
setErrors({}); // reset errors
|
||||
if (typeof model !== "undefined") {
|
||||
original = model;
|
||||
collection = model?.clone();
|
||||
} else {
|
||||
original = null;
|
||||
collection = new Collection();
|
||||
}
|
||||
// normalize
|
||||
collection.schema = collection.schema || [];
|
||||
collection.originalName = collection.name || "";
|
||||
|
||||
await tick();
|
||||
|
||||
initialFormHash = calculateFormHash(collection);
|
||||
}
|
||||
|
||||
function saveWithConfirm() {
|
||||
if (collection.isNew) {
|
||||
return save();
|
||||
} else {
|
||||
confirmChangesPanel?.show(collection);
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
const data = exportFormData();
|
||||
|
||||
let request;
|
||||
if (collection.isNew) {
|
||||
request = ApiClient.Collections.create(data);
|
||||
} else {
|
||||
request = ApiClient.Collections.update(collection.id, data);
|
||||
}
|
||||
|
||||
request
|
||||
.then((result) => {
|
||||
confirmClose = false;
|
||||
hide();
|
||||
addSuccessToast(
|
||||
collection.isNew ? "Successfully created collection." : "Successfully updated collection."
|
||||
);
|
||||
addCollection(result);
|
||||
|
||||
if (collection.isNew) {
|
||||
$activeCollection = result;
|
||||
}
|
||||
|
||||
dispatch("save", result);
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
})
|
||||
.finally(() => {
|
||||
isSaving = false;
|
||||
});
|
||||
}
|
||||
|
||||
function exportFormData() {
|
||||
const data = collection.export();
|
||||
data.schema = data.schema.slice(0);
|
||||
|
||||
// remove deleted fields
|
||||
for (let i = data.schema.length - 1; i >= 0; i--) {
|
||||
const field = data.schema[i];
|
||||
if (field.toDelete) {
|
||||
data.schema.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function deleteConfirm() {
|
||||
if (!original?.id) {
|
||||
return; // nothing to delete
|
||||
}
|
||||
|
||||
confirm(`Do you really want to delete collection "${original?.name}" and all its records?`, () => {
|
||||
return ApiClient.Collections.delete(original?.id)
|
||||
.then(() => {
|
||||
hide();
|
||||
addSuccessToast(`Successfully deleted collection "${original?.name}".`);
|
||||
dispatch("delete", original);
|
||||
removeCollection(original);
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function calculateFormHash(m) {
|
||||
return JSON.stringify(m);
|
||||
}
|
||||
</script>
|
||||
|
||||
<OverlayPanel
|
||||
bind:this={collectionPanel}
|
||||
class="overlay-panel-lg colored-header collection-panel"
|
||||
beforeHide={() => {
|
||||
if (hasChanges && confirmClose) {
|
||||
confirm("You have unsaved changes. Do you really want to close the panel?", () => {
|
||||
confirmClose = false;
|
||||
hide();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
on:hide
|
||||
on:show
|
||||
>
|
||||
<svelte:fragment slot="header">
|
||||
<h4>
|
||||
{collection.isNew ? "New collection" : "Edit collection"}
|
||||
</h4>
|
||||
|
||||
{#if !collection.isNew && !collection.system}
|
||||
<div class="flex-fill" />
|
||||
<button type="button" class="btn btn-sm btn-circle btn-secondary flex-gap-0">
|
||||
<i class="ri-more-line" />
|
||||
<Toggler class="dropdown dropdown-right m-t-5">
|
||||
<button type="button" class="dropdown-item closable" on:click={() => deleteConfirm()}>
|
||||
<i class="ri-delete-bin-7-line" />
|
||||
<span class="txt">Delete</span>
|
||||
</button>
|
||||
</Toggler>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
class="block"
|
||||
on:submit|preventDefault={() => {
|
||||
canSave && saveWithConfirm();
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
class="form-field required m-b-0 {isSystemUpdate ? 'disabled' : ''}"
|
||||
name="name"
|
||||
let:uniqueId
|
||||
>
|
||||
<label for={uniqueId}>Name</label>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
id={uniqueId}
|
||||
required
|
||||
disabled={isSystemUpdate}
|
||||
spellcheck="false"
|
||||
autofocus={collection.isNew}
|
||||
placeholder={`eg. "posts"`}
|
||||
value={collection.name}
|
||||
on:input={(e) => {
|
||||
collection.name = CommonHelper.slugify(e.target.value);
|
||||
e.target.value = collection.name;
|
||||
}}
|
||||
/>
|
||||
{#if collection.system}
|
||||
<div class="help-block">System collection</div>
|
||||
{/if}
|
||||
</Field>
|
||||
|
||||
<input type="submit" class="hidden" tabindex="-1" />
|
||||
</form>
|
||||
|
||||
<div class="tabs-header stretched">
|
||||
<button
|
||||
type="button"
|
||||
class="tab-item"
|
||||
class:active={activeTab === TAB_FIELDS}
|
||||
on:click={() => changeTab(TAB_FIELDS)}
|
||||
>
|
||||
<span class="txt">Fields</span>
|
||||
{#if !CommonHelper.isEmpty($errors?.schema)}
|
||||
<i
|
||||
class="ri-error-warning-fill txt-danger"
|
||||
transition:scale|local={{ duration: 150, start: 0.7 }}
|
||||
use:tooltip={schemaTabError}
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="tab-item"
|
||||
class:active={activeTab === TAB_RULES}
|
||||
on:click={() => changeTab(TAB_RULES)}
|
||||
>
|
||||
<span class="txt">API Rules</span>
|
||||
{#if !CommonHelper.isEmpty($errors?.listRule) || !CommonHelper.isEmpty($errors?.viewRule) || !CommonHelper.isEmpty($errors?.createRule) || !CommonHelper.isEmpty($errors?.updateRule) || !CommonHelper.isEmpty($errors?.deleteRule)}
|
||||
<i
|
||||
class="ri-error-warning-fill txt-danger"
|
||||
transition:scale|local={{ duration: 150, start: 0.7 }}
|
||||
use:tooltip={"Has errors"}
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
<div class="tabs-content">
|
||||
<!-- avoid rerendering the fields tab -->
|
||||
<div class="tab-item" class:active={activeTab === TAB_FIELDS}>
|
||||
<CollectionFieldsTab bind:collection />
|
||||
</div>
|
||||
|
||||
{#if activeTab === TAB_RULES}
|
||||
<div class="tab-item active">
|
||||
<CollectionRulesTab bind:collection />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<button type="button" class="btn btn-secondary" disabled={isSaving} on:click={() => hide()}>
|
||||
<span class="txt">Cancel</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-expanded"
|
||||
class:btn-loading={isSaving}
|
||||
disabled={!canSave || isSaving}
|
||||
on:click={() => saveWithConfirm()}
|
||||
>
|
||||
<span class="txt">{collection.isNew ? "Create" : "Save changes"}</span>
|
||||
</button>
|
||||
</svelte:fragment>
|
||||
</OverlayPanel>
|
||||
|
||||
<CollectionUpdateConfirm bind:this={confirmChangesPanel} on:confirm={() => save()} />
|
||||
|
||||
<style>
|
||||
.tabs-content {
|
||||
z-index: 3; /* autocomplete dropdown overlay fix */
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script>
|
||||
import { collections, activeCollection } from "@/stores/collections";
|
||||
import CollectionUpsertPanel from "@/components/collections/CollectionUpsertPanel.svelte";
|
||||
|
||||
let collectionPanel;
|
||||
let searchTerm = "";
|
||||
|
||||
$: normalizedSearch = searchTerm.replace(/\s+/g, "").toLowerCase();
|
||||
|
||||
$: hasSearch = searchTerm !== "";
|
||||
|
||||
$: filteredCollections = $collections.filter((collection) => {
|
||||
return (
|
||||
collection.name != import.meta.env.PB_PROFILE_COLLECTION &&
|
||||
(collection.id == searchTerm ||
|
||||
collection.name.replace(/\s+/g, "").toLowerCase().includes(normalizedSearch))
|
||||
);
|
||||
});
|
||||
|
||||
function selectCollection(collection) {
|
||||
$activeCollection = collection;
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="page-sidebar collection-sidebar">
|
||||
<header class="sidebar-header">
|
||||
<div class="form-field search" class:active={hasSearch}>
|
||||
<div class="form-field-addon">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-secondary btn-circle btn-clear"
|
||||
class:hidden={!hasSearch}
|
||||
on:click={() => (searchTerm = "")}
|
||||
>
|
||||
<i class="ri-close-line" />
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" placeholder="Search collections..." bind:value={searchTerm} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<hr class="m-t-5 m-b-xs" />
|
||||
|
||||
<div class="sidebar-content">
|
||||
{#each filteredCollections as collection (collection.id)}
|
||||
<div
|
||||
tabindex="0"
|
||||
class="sidebar-list-item"
|
||||
class:active={$activeCollection?.id === collection.id}
|
||||
on:click={() => selectCollection(collection)}
|
||||
>
|
||||
{#if $activeCollection?.id === collection.id}
|
||||
<i class="ri-folder-open-line" />
|
||||
{:else}
|
||||
<i class="ri-folder-2-line" />
|
||||
{/if}
|
||||
<span class="txt">{collection.name}</span>
|
||||
</div>
|
||||
{:else}
|
||||
{#if normalizedSearch.length}
|
||||
<p class="txt-hint m-t-10 m-b-10 txt-center">No collections found.</p>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<footer class="sidebar-footer">
|
||||
<button type="button" class="btn btn-block btn-outline" on:click={() => collectionPanel?.show()}>
|
||||
<i class="ri-add-line" />
|
||||
<span class="txt">New collection</span>
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<CollectionUpsertPanel bind:this={collectionPanel} />
|
||||
@@ -0,0 +1,286 @@
|
||||
<script>
|
||||
import { createEventDispatcher, onMount } from "svelte";
|
||||
import { scale, fly } from "svelte/transition";
|
||||
import { SchemaField } from "pocketbase";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import { errors } from "@/stores/errors";
|
||||
import Accordion from "@/components/base/Accordion.svelte";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import FieldTypeSelect from "@/components/collections/schema/FieldTypeSelect.svelte";
|
||||
import TextOptions from "@/components/collections/schema/TextOptions.svelte";
|
||||
import NumberOptions from "@/components/collections/schema/NumberOptions.svelte";
|
||||
import BoolOptions from "@/components/collections/schema/BoolOptions.svelte";
|
||||
import EmailOptions from "@/components/collections/schema/EmailOptions.svelte";
|
||||
import UrlOptions from "@/components/collections/schema/UrlOptions.svelte";
|
||||
import DateOptions from "@/components/collections/schema/DateOptions.svelte";
|
||||
import SelectOptions from "@/components/collections/schema/SelectOptions.svelte";
|
||||
import JsonOptions from "@/components/collections/schema/JsonOptions.svelte";
|
||||
import FileOptions from "@/components/collections/schema/FileOptions.svelte";
|
||||
import RelationOptions from "@/components/collections/schema/RelationOptions.svelte";
|
||||
import UserOptions from "@/components/collections/schema/UserOptions.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let key = "0";
|
||||
export let field = new SchemaField();
|
||||
export let disabled = false;
|
||||
export let excludeNames = [];
|
||||
|
||||
let accordion;
|
||||
let initialType = field.type;
|
||||
|
||||
$: if (initialType != field.type) {
|
||||
initialType = field.type;
|
||||
// reset common options
|
||||
field.options = {};
|
||||
field.unique = false;
|
||||
}
|
||||
|
||||
$: if (excludeNames.length) {
|
||||
const normalizedName = normalizeFieldName(field.name);
|
||||
if (field.name !== normalizedName) {
|
||||
field.name = normalizedName;
|
||||
}
|
||||
}
|
||||
|
||||
$: canBeStored = !CommonHelper.isEmpty(field.name) && field.type;
|
||||
|
||||
$: if (!canBeStored) {
|
||||
accordion && expand();
|
||||
}
|
||||
|
||||
$: if (field.toDelete) {
|
||||
accordion && collapse();
|
||||
|
||||
// reset the name if it was previously deleted
|
||||
if (!field.name && field.originalName) {
|
||||
field.name = field.originalName;
|
||||
}
|
||||
}
|
||||
|
||||
$: if (!field.originalName && field.name) {
|
||||
field.originalName = field.name;
|
||||
}
|
||||
|
||||
$: if (typeof field.toDelete === "undefined") {
|
||||
field.toDelete = false; // normalize
|
||||
}
|
||||
|
||||
$: interactive = !disabled && !field.system && !field.toDelete && canBeStored;
|
||||
|
||||
$: hasErrors = !CommonHelper.isEmpty(CommonHelper.getNestedVal($errors, `schema.${key}`));
|
||||
|
||||
export function expand() {
|
||||
accordion?.expand();
|
||||
}
|
||||
|
||||
export function collapse() {
|
||||
accordion?.collapse();
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!field.id) {
|
||||
collapse();
|
||||
dispatch("remove");
|
||||
} else {
|
||||
field.toDelete = true;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFieldName(name) {
|
||||
name = CommonHelper.slugify(name);
|
||||
|
||||
let counter = "";
|
||||
while (excludeNames.includes(name + counter)) {
|
||||
++counter;
|
||||
}
|
||||
|
||||
return name + counter;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// auto expand new fields
|
||||
if (!field.id) {
|
||||
expand();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Accordion
|
||||
bind:this={accordion}
|
||||
on:expand
|
||||
on:collapse
|
||||
on:toggle
|
||||
single
|
||||
{interactive}
|
||||
class={disabled || field.toDelete || field.system ? "field-accordion disabled" : "field-accordion"}
|
||||
>
|
||||
<svelte:fragment slot="header" let:active={expanded}>
|
||||
<div class="inline-flex">
|
||||
<span class="icon field-type">
|
||||
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
|
||||
</span>
|
||||
<strong class="title field-name" class:txt-strikethrough={field.toDelete} title={field.name}>
|
||||
{field.name || "-"}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
{#if !field.toDelete}
|
||||
<div class="inline-flex">
|
||||
{#if field.system}
|
||||
<span class="label label-danger">System</span>
|
||||
{/if}
|
||||
{#if !field.id}
|
||||
<span class="label" class:label-warning={interactive && !field.toDelete}>New</span>
|
||||
{/if}
|
||||
{#if field.required}
|
||||
<span class="label label-success">Required</span>
|
||||
{/if}
|
||||
{#if field.unique}
|
||||
<span class="label label-success">Unique</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex-fill" />
|
||||
|
||||
{#if hasErrors}
|
||||
<i
|
||||
class="ri-error-warning-fill txt-danger"
|
||||
transition:scale={{ duration: 150, start: 0.7 }}
|
||||
use:tooltip={{ text: "Has errors", position: "left" }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if expanded && !field.toDelete}
|
||||
<div class="inline-flex flex-gap-sm flex-nowrap" in:fly={{ duration: 200, x: 20, opacity: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm fade p-l-0 p-r-0"
|
||||
on:click|stopPropagation={handleDelete}
|
||||
>
|
||||
<span class="txt">Remove</span>
|
||||
</button>
|
||||
|
||||
{#if interactive}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline btn-expanded-sm"
|
||||
on:click|stopPropagation={collapse}
|
||||
>
|
||||
<span class="txt">Done</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if field.toDelete}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger btn-secondary"
|
||||
on:click|stopPropagation={() => {
|
||||
field.toDelete = false;
|
||||
}}
|
||||
>
|
||||
<span class="txt">Restore</span>
|
||||
</button>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<form
|
||||
class="field-form"
|
||||
on:submit|preventDefault={() => {
|
||||
canBeStored && collapse();
|
||||
}}
|
||||
>
|
||||
<div class="grid">
|
||||
<div class="col-sm-6">
|
||||
<Field
|
||||
class="form-field required {field.id ? 'disabled' : ''}"
|
||||
name="schema.{key}.type"
|
||||
let:uniqueId
|
||||
>
|
||||
<label for={uniqueId}>Type</label>
|
||||
<FieldTypeSelect id={uniqueId} disabled={field.id} bind:value={field.type} />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<Field
|
||||
class="form-field required {field.id && field.system ? 'disabled' : ''}"
|
||||
name="schema.{key}.name"
|
||||
let:uniqueId
|
||||
>
|
||||
<label for={uniqueId}>Name</label>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
id={uniqueId}
|
||||
required
|
||||
disabled={field.id && field.system}
|
||||
spellcheck="false"
|
||||
autofocus={!field.id}
|
||||
value={field.name}
|
||||
on:input={(e) => {
|
||||
field.name = normalizeFieldName(e.target.value);
|
||||
e.target.value = field.name;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 hidden-empty">
|
||||
{#if field.type === "text"}
|
||||
<TextOptions {key} bind:options={field.options} />
|
||||
{:else if field.type === "number"}
|
||||
<NumberOptions {key} bind:options={field.options} />
|
||||
{:else if field.type === "bool"}
|
||||
<BoolOptions {key} bind:options={field.options} />
|
||||
{:else if field.type === "email"}
|
||||
<EmailOptions {key} bind:options={field.options} />
|
||||
{:else if field.type === "url"}
|
||||
<UrlOptions {key} bind:options={field.options} />
|
||||
{:else if field.type === "date"}
|
||||
<DateOptions {key} bind:options={field.options} />
|
||||
{:else if field.type === "select"}
|
||||
<SelectOptions {key} bind:options={field.options} />
|
||||
{:else if field.type === "json"}
|
||||
<JsonOptions {key} bind:options={field.options} />
|
||||
{:else if field.type === "file"}
|
||||
<FileOptions {key} bind:options={field.options} />
|
||||
{:else if field.type === "relation"}
|
||||
<RelationOptions {key} bind:options={field.options} />
|
||||
{:else if field.type === "user"}
|
||||
<UserOptions {key} bind:options={field.options} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="col-4">
|
||||
<Field class="form-field form-field-toggle m-0" name="requried" let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} bind:checked={field.required} />
|
||||
<label for={uniqueId}>Required</label>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="col-4">
|
||||
{#if field.type !== "file"}
|
||||
<Field class="form-field form-field-toggle m-0" name="unique" let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} bind:checked={field.unique} />
|
||||
<label for={uniqueId}>Unique</label>
|
||||
</Field>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="submit" class="hidden" tabindex="-1" />
|
||||
</form>
|
||||
</Accordion>
|
||||
|
||||
<style>
|
||||
.title.field-name {
|
||||
max-width: 130px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script>
|
||||
import { Collection } from "pocketbase";
|
||||
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
|
||||
import ListApiDocs from "@/components/collections/docs/ListApiDocs.svelte";
|
||||
import ViewApiDocs from "@/components/collections/docs/ViewApiDocs.svelte";
|
||||
import CreateApiDocs from "@/components/collections/docs/CreateApiDocs.svelte";
|
||||
import UpdateApiDocs from "@/components/collections/docs/UpdateApiDocs.svelte";
|
||||
import DeleteApiDocs from "@/components/collections/docs/DeleteApiDocs.svelte";
|
||||
import RealtimeApiDocs from "@/components/collections/docs/RealtimeApiDocs.svelte";
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: "list",
|
||||
label: "List",
|
||||
component: ListApiDocs,
|
||||
},
|
||||
{
|
||||
id: "view",
|
||||
label: "View",
|
||||
component: ViewApiDocs,
|
||||
},
|
||||
{
|
||||
id: "create",
|
||||
label: "Create",
|
||||
component: CreateApiDocs,
|
||||
},
|
||||
{
|
||||
id: "update",
|
||||
label: "Update",
|
||||
component: UpdateApiDocs,
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: "Delete",
|
||||
component: DeleteApiDocs,
|
||||
},
|
||||
{
|
||||
id: "realtime",
|
||||
label: "Realtime",
|
||||
component: RealtimeApiDocs,
|
||||
},
|
||||
];
|
||||
|
||||
let collectionPanel;
|
||||
let collection = new Collection();
|
||||
let activeTab = tabs[0].id;
|
||||
|
||||
export function show(model) {
|
||||
collection = model;
|
||||
|
||||
changeTab(tabs[0].id);
|
||||
|
||||
return collectionPanel?.show();
|
||||
}
|
||||
|
||||
export function hide() {
|
||||
return collectionPanel?.hide();
|
||||
}
|
||||
|
||||
export function changeTab(newTab) {
|
||||
activeTab = newTab;
|
||||
}
|
||||
|
||||
function changeTabViaKey(e, newTab) {
|
||||
if (e.code === "Enter" || e.code === "Space") {
|
||||
e.preventDefault();
|
||||
changeTab(newTab);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<OverlayPanel
|
||||
bind:this={collectionPanel}
|
||||
on:hide
|
||||
on:show
|
||||
class="overlay-panel-xl colored-header collection-panel"
|
||||
>
|
||||
<svelte:fragment slot="header">
|
||||
<h4><strong>{collection.name}</strong> records API</h4>
|
||||
|
||||
<div class="tabs-header stretched">
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button
|
||||
tabindex="0"
|
||||
class="tab-item"
|
||||
class:active={activeTab === tab.id}
|
||||
on:click={() => changeTab(tab.id)}
|
||||
on:keydown|self={(e) => changeTabViaKey(e, tab.id)}
|
||||
>
|
||||
<span class="txt">{tab.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
<div class="tabs-content">
|
||||
{#each tabs as tab (tab.id)}
|
||||
{#if activeTab === tab.id}
|
||||
<div class="tab-item active">
|
||||
<svelte:component this={tab.component} {collection} />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<button type="button" class="btn btn-secondary" on:click={() => hide()}>
|
||||
<span class="txt">Close</span>
|
||||
</button>
|
||||
</svelte:fragment>
|
||||
</OverlayPanel>
|
||||
@@ -0,0 +1,184 @@
|
||||
<script>
|
||||
import { Collection } from "pocketbase";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import CodeBlock from "@/components/base/CodeBlock.svelte";
|
||||
|
||||
export let collection = new Collection();
|
||||
|
||||
let responseTab = 200;
|
||||
let sdkTab = "JavaScript";
|
||||
let responses = [];
|
||||
let sdkExamples = [];
|
||||
|
||||
$: adminsOnly = collection?.createRule === null;
|
||||
|
||||
$: responses = [
|
||||
{
|
||||
code: 200,
|
||||
body: JSON.stringify(CommonHelper.dummyCollectionRecord(collection), null, 2),
|
||||
},
|
||||
{
|
||||
code: 400,
|
||||
body: `
|
||||
{
|
||||
"code": 400,
|
||||
"message": "Failed to create record.",
|
||||
"data": {
|
||||
"${collection?.schema?.[0]?.name}": {
|
||||
"code": "validation_required",
|
||||
"message": "Missing required value."
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
code: 403,
|
||||
body: `
|
||||
{
|
||||
"code": 403,
|
||||
"message": "You are not allowed to perform this request.",
|
||||
"data": {}
|
||||
}
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
$: sdkExamples = [
|
||||
{
|
||||
lang: "JavaScript",
|
||||
code: `
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const client = new PocketBase("${ApiClient.baseUrl}");
|
||||
|
||||
const data = { ... };
|
||||
|
||||
client.Records.create("${collection?.name}", data)
|
||||
.then(function (record) {
|
||||
// success...
|
||||
}).catch(function (error) {
|
||||
// error...
|
||||
});
|
||||
`,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="alert alert-success">
|
||||
<strong class="label label-primary">POST</strong>
|
||||
<div class="content">
|
||||
<p>
|
||||
/api/collections/<strong>{collection.name}</strong>/records
|
||||
</p>
|
||||
</div>
|
||||
{#if adminsOnly}
|
||||
<p class="txt-hint txt-sm txt-right">Requires <code>Authorization: Admin TOKEN</code> header</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="content m-b-base">
|
||||
<p>Create a new <strong>{collection.name}</strong> record.</p>
|
||||
<p>
|
||||
Body parameters could be sent as <code>application/json</code> or
|
||||
<code>multipart/form-data</code>.
|
||||
</p>
|
||||
<p>
|
||||
File upload is supported only via <code>multipart/form-data</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Client SDKs example</div>
|
||||
<div class="tabs m-b-lg">
|
||||
<div class="tabs-header compact left">
|
||||
{#each sdkExamples as example (example.lang)}
|
||||
<button
|
||||
class="tab-item"
|
||||
class:active={sdkTab === example.lang}
|
||||
on:click={() => (sdkTab = example.lang)}
|
||||
>
|
||||
{example.lang}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tabs-content">
|
||||
{#each sdkExamples as example (example.lang)}
|
||||
<div class="tab-item" class:active={sdkTab === example.lang}>
|
||||
<CodeBlock content={example.code} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Body Parameters</div>
|
||||
<table class="table-compact table-border m-b-lg">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Param</th>
|
||||
<th>Type</th>
|
||||
<th width="50%">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each collection?.schema as field (field.name)}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="inline-flex">
|
||||
{#if field.required}
|
||||
<span class="label label-success">Required</span>
|
||||
{:else}
|
||||
<span class="label label-warning">Optional</span>
|
||||
{/if}
|
||||
<span>{field.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="label">{CommonHelper.getFieldValueType(field)}</span>
|
||||
</td>
|
||||
<td>
|
||||
{#if field.type === "text"}
|
||||
Plain text value.
|
||||
{:else if field.type === "number"}
|
||||
Number value.
|
||||
{:else if field.type === "json"}
|
||||
JSON array or object.
|
||||
{:else if field.type === "email"}
|
||||
Email address.
|
||||
{:else if field.type === "url"}
|
||||
URL address.
|
||||
{:else if field.type === "file"}
|
||||
FormData object.<br />
|
||||
Set to <code>null</code> to delete already uploaded file(s).
|
||||
{:else if field.type === "relation"}
|
||||
Relation record {field.options?.maxSelect > 1 ? "ids" : "id"}.
|
||||
{:else if field.type === "user"}
|
||||
User {field.options?.maxSelect > 1 ? "ids" : "id"}.
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="section-title">Responses</div>
|
||||
<div class="tabs">
|
||||
<div class="tabs-header compact left">
|
||||
{#each responses as response (response.code)}
|
||||
<button
|
||||
class="tab-item"
|
||||
class:active={responseTab === response.code}
|
||||
on:click={() => (responseTab = response.code)}
|
||||
>
|
||||
{response.code}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tabs-content">
|
||||
{#each responses as response (response.code)}
|
||||
<div class="tab-item" class:active={responseTab === response.code}>
|
||||
<CodeBlock content={response.body} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script>
|
||||
import { Collection } from "pocketbase";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CodeBlock from "@/components/base/CodeBlock.svelte";
|
||||
|
||||
export let collection = new Collection();
|
||||
|
||||
let responseTab = 204;
|
||||
let sdkTab = "JavaScript";
|
||||
let responses = [];
|
||||
let sdkExamples = [];
|
||||
|
||||
$: adminsOnly = collection?.deleteRule === null;
|
||||
|
||||
$: if (collection?.id) {
|
||||
responses.push({
|
||||
code: 204,
|
||||
body: `
|
||||
null
|
||||
`,
|
||||
});
|
||||
|
||||
responses.push({
|
||||
code: 400,
|
||||
body: `
|
||||
{
|
||||
"code": 400,
|
||||
"message": "Failed to delete record. Make sure that the record is not part of a required relation reference.",
|
||||
"data": {}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
if (adminsOnly) {
|
||||
responses.push({
|
||||
code: 403,
|
||||
body: `
|
||||
{
|
||||
"code": 403,
|
||||
"message": "Only admins can access this action.",
|
||||
"data": {}
|
||||
}
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
responses.push({
|
||||
code: 404,
|
||||
body: `
|
||||
{
|
||||
"code": 404,
|
||||
"message": "The requested resource wasn't found.",
|
||||
"data": {}
|
||||
}
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
$: sdkExamples = [
|
||||
{
|
||||
lang: "JavaScript",
|
||||
code: `
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const client = new PocketBase("${ApiClient.baseUrl}");
|
||||
|
||||
client.Records.delete("${collection?.name}", "RECORD_ID")
|
||||
.then(function () {
|
||||
// success...
|
||||
}).catch(function (error) {
|
||||
// error...
|
||||
});
|
||||
`,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="alert alert-danger">
|
||||
<strong class="label label-primary">DELETE</strong>
|
||||
<div class="content">
|
||||
<p>
|
||||
/api/collections/<strong>{collection.name}</strong>/records/<strong>:id</strong>
|
||||
</p>
|
||||
</div>
|
||||
{#if adminsOnly}
|
||||
<p class="txt-hint txt-sm txt-right">Requires <code>Authorization: Admin TOKEN</code> header</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="content m-b-base">
|
||||
<p>Delete a single <strong>{collection.name}</strong> record.</p>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Client SDKs example</div>
|
||||
<div class="tabs m-b-lg">
|
||||
<div class="tabs-header compact left">
|
||||
{#each sdkExamples as example (example.lang)}
|
||||
<button
|
||||
class="tab-item"
|
||||
class:active={sdkTab === example.lang}
|
||||
on:click={() => (sdkTab = example.lang)}
|
||||
>
|
||||
{example.lang}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tabs-content">
|
||||
{#each sdkExamples as example (example.lang)}
|
||||
<div class="tab-item" class:active={sdkTab === example.lang}>
|
||||
<CodeBlock content={example.code} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Path parameters</div>
|
||||
<table class="table-compact table-border m-b-lg">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Param</th>
|
||||
<th>Type</th>
|
||||
<th width="60%">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>id</td>
|
||||
<td>
|
||||
<span class="label">String</span>
|
||||
</td>
|
||||
<td>ID of the record to delete.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="section-title">Responses</div>
|
||||
<div class="tabs">
|
||||
<div class="tabs-header compact left">
|
||||
{#each responses as response (response.code)}
|
||||
<button
|
||||
class="tab-item"
|
||||
class:active={responseTab === response.code}
|
||||
on:click={() => (responseTab = response.code)}
|
||||
>
|
||||
{response.code}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tabs-content">
|
||||
{#each responses as response (response.code)}
|
||||
<div class="tab-item" class:active={responseTab === response.code}>
|
||||
<CodeBlock content={response.body} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,75 @@
|
||||
<p>
|
||||
The syntax basically follows the format
|
||||
<code>
|
||||
<span class="txt-success">OPERAND</span>
|
||||
<span class="txt-danger">OPERATOR</span>
|
||||
<span class="txt-success">OPERAND</span></code
|
||||
>, where:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<code class="txt-success">OPERAND</code> - could be any of the above field literal, string (single or double
|
||||
quoted), number, null, true, false
|
||||
</li>
|
||||
<li>
|
||||
<code class="txt-danger">OPERATOR</code> - is one of:
|
||||
<br />
|
||||
<ul>
|
||||
<li>
|
||||
<code class="filter-op">{"="}</code>
|
||||
<span class="txt-hint">Equal</span>
|
||||
</li>
|
||||
<li>
|
||||
<code class="filter-op">{"!="}</code>
|
||||
<span class="txt-hint">NOT equal</span>
|
||||
</li>
|
||||
<li>
|
||||
<code class="filter-op">{">"}</code>
|
||||
<span class="txt-hint">Greater than</span>
|
||||
</li>
|
||||
<li>
|
||||
<code class="filter-op">{">="}</code>
|
||||
<span class="txt-hint">Greater than or equal</span>
|
||||
</li>
|
||||
<li>
|
||||
<code class="filter-op">{"<"}</code>
|
||||
<span class="txt-hint">Less than or equal</span>
|
||||
</li>
|
||||
<li>
|
||||
<code class="filter-op">{"<="}</code>
|
||||
<span class="txt-hint">Less than or equal</span>
|
||||
</li>
|
||||
<li>
|
||||
<code class="filter-op">{"~"}</code>
|
||||
<span class="txt-hint">
|
||||
Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for wildcard
|
||||
match)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<code class="filter-op">{"!~"}</code>
|
||||
<span class="txt-hint">
|
||||
NOT Like/Contains (if not specified auto wraps the right string OPERAND in a "%" for
|
||||
wildcard match)
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
To group and combine several expressions you could use brackets
|
||||
<code>(...)</code>, <code>&&</code> (AND) and <code>||</code> (OR) tokens.
|
||||
</p>
|
||||
|
||||
<style>
|
||||
.filter-op {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
margin-right: 5px;
|
||||
width: 30px;
|
||||
text-align: center;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,232 @@
|
||||
<script>
|
||||
import { Collection } from "pocketbase";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import CodeBlock from "@/components/base/CodeBlock.svelte";
|
||||
import FilterSyntax from "@/components/collections/docs/FilterSyntax.svelte";
|
||||
|
||||
export let collection = new Collection();
|
||||
|
||||
let responseTab = 200;
|
||||
let sdkTab = "JavaScript";
|
||||
let responses = [];
|
||||
let sdkExamples = [];
|
||||
|
||||
$: adminsOnly = collection?.listRule === null;
|
||||
|
||||
$: if (collection?.id) {
|
||||
responses.push({
|
||||
code: 200,
|
||||
body: JSON.stringify(
|
||||
{
|
||||
page: 1,
|
||||
perPage: 30,
|
||||
totalItems: 2,
|
||||
items: [
|
||||
CommonHelper.dummyCollectionRecord(collection),
|
||||
CommonHelper.dummyCollectionRecord(collection),
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
});
|
||||
|
||||
responses.push({
|
||||
code: 400,
|
||||
body: `
|
||||
{
|
||||
"code": 400,
|
||||
"message": "Something went wrong while processing your request. Invalid filter.",
|
||||
"data": {}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
if (adminsOnly) {
|
||||
responses.push({
|
||||
code: 403,
|
||||
body: `
|
||||
{
|
||||
"code": 403,
|
||||
"message": "Only admins can access this action.",
|
||||
"data": {}
|
||||
}
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
responses.push({
|
||||
code: 404,
|
||||
body: `
|
||||
{
|
||||
"code": 404,
|
||||
"message": "The requested resource wasn't found.",
|
||||
"data": {}
|
||||
}
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
$: sdkExamples = [
|
||||
{
|
||||
lang: "JavaScript",
|
||||
code: `
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const client = new PocketBase("${ApiClient.baseUrl}");
|
||||
|
||||
client.Records.getList("${collection?.name}", { page: 2 })
|
||||
.then(function (list) {
|
||||
// success...
|
||||
}).catch(function (error) {
|
||||
// error...
|
||||
});
|
||||
|
||||
// alternatively you can also fetch all records at once via getFullList:
|
||||
client.Records.getFullList("${collection?.name}", 200 /* batch size */);
|
||||
.then(function (records) {
|
||||
// success...
|
||||
}).catch(function (error) {
|
||||
// error...
|
||||
});
|
||||
`,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<strong class="label label-primary">GET</strong>
|
||||
<div class="content">
|
||||
<p>
|
||||
/api/collections/<strong>{collection.name}</strong>/records
|
||||
</p>
|
||||
</div>
|
||||
{#if adminsOnly}
|
||||
<p class="txt-hint txt-sm txt-right">Requires <code>Authorization: Admin TOKEN</code> header</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="content m-b-base">
|
||||
<p>Fetch a paginated <strong>{collection.name}</strong> records list.</p>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Client SDKs example</div>
|
||||
<div class="tabs m-b-lg">
|
||||
<div class="tabs-header compact left">
|
||||
{#each sdkExamples as example (example.lang)}
|
||||
<button
|
||||
class="tab-item"
|
||||
class:active={sdkTab === example.lang}
|
||||
on:click={() => (sdkTab = example.lang)}
|
||||
>
|
||||
{example.lang}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tabs-content">
|
||||
{#each sdkExamples as example (example.lang)}
|
||||
<div class="tab-item" class:active={sdkTab === example.lang}>
|
||||
<CodeBlock content={example.code} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Query parameters</div>
|
||||
<table class="table-compact table-border m-b-lg">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Param</th>
|
||||
<th>Type</th>
|
||||
<th width="60%">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>page</td>
|
||||
<td>
|
||||
<span class="label">Number</span>
|
||||
</td>
|
||||
<td>The page (aka. offset) of the paginated list (default to 1).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>perPage</td>
|
||||
<td>
|
||||
<span class="label">Number</span>
|
||||
</td>
|
||||
<td>Specify the max returned records per page (default to 30).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>sort</td>
|
||||
<td>
|
||||
<span class="label">String</span>
|
||||
</td>
|
||||
<td>
|
||||
Specify the records order attribute(s). <br />
|
||||
Add <code>-</code> / <code>+</code> (default) in front of the attribute for DESC / ASC order.
|
||||
Ex.:
|
||||
<CodeBlock
|
||||
content={`
|
||||
// DESC by created and ASC by id
|
||||
?sort=-created,id
|
||||
`}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>filter</td>
|
||||
<td>
|
||||
<span class="label">String</span>
|
||||
</td>
|
||||
<td>
|
||||
Filter the returned records. Ex.:
|
||||
<CodeBlock
|
||||
content={`
|
||||
?filter=(id='abc' && created>'2022-01-01')
|
||||
`}
|
||||
/>
|
||||
<FilterSyntax />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>expand</td>
|
||||
<td>
|
||||
<span class="label">String</span>
|
||||
</td>
|
||||
<td>
|
||||
Auto expand nested record relations. Ex.:
|
||||
<CodeBlock
|
||||
content={`
|
||||
?expand=rel1,rel2.subrel21.subrel22
|
||||
`}
|
||||
/>
|
||||
Supports up to 6-levels depth nested relations expansion. <br />
|
||||
The expanded relations will be appended to each individual record under the
|
||||
<code>@expand</code> property (eg. <code>{`"@expand": {"rel1": {...}, ...}`}</code>).
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="section-title">Responses</div>
|
||||
<div class="tabs">
|
||||
<div class="tabs-header compact left">
|
||||
{#each responses as response (response.code)}
|
||||
<div
|
||||
class="tab-item"
|
||||
class:active={responseTab === response.code}
|
||||
on:click={() => (responseTab = response.code)}
|
||||
>
|
||||
{response.code}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tabs-content">
|
||||
{#each responses as response (response.code)}
|
||||
<div class="tab-item" class:active={responseTab === response.code}>
|
||||
<CodeBlock content={response.body} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script>
|
||||
import { Collection } from "pocketbase";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import CodeBlock from "@/components/base/CodeBlock.svelte";
|
||||
|
||||
export let collection = new Collection();
|
||||
|
||||
let sdkTab = "JavaScript";
|
||||
let sdkExamples = [];
|
||||
|
||||
$: sdkExamples = [
|
||||
{
|
||||
lang: "JavaScript",
|
||||
code: `
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const client = new PocketBase("${ApiClient.baseUrl}");
|
||||
|
||||
// (Optionally) authenticate
|
||||
client.Users.authViaEmail("test@example.com", "123456");
|
||||
|
||||
// Subscribe to changes in any record from the collection
|
||||
client.Realtime.subscribe("${collection?.name}", function (e) {
|
||||
console.log(e.data);
|
||||
});
|
||||
|
||||
// Subscribe to changes in a single record
|
||||
client.Realtime.subscribe("${collection?.name}/RECORD_ID", function (e) {
|
||||
console.log(e.data);
|
||||
});
|
||||
|
||||
// Unsubscribe
|
||||
client.Realtime.unsubscribe() // remove all subscriptions
|
||||
client.Realtime.unsubscribe("${collection?.name}") // remove the collection subscription
|
||||
client.Realtime.unsubscribe("${collection?.name}/RECORD_ID") // remove the record subscription
|
||||
`,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="alert">
|
||||
<strong class="label label-primary">SSE</strong>
|
||||
<div class="content">
|
||||
<p>/api/realtime</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content m-b-base">
|
||||
<p>Subscribe to realtime changes via Server-Sent Events (SSE).</p>
|
||||
<p>
|
||||
Events are send for <strong>create</strong>, <strong>update</strong>
|
||||
and <strong>delete</strong> record operations (see "Event data format" section below).
|
||||
</p>
|
||||
<div class="alert alert-info m-t-10">
|
||||
<div class="icon">
|
||||
<i class="ri-information-line" />
|
||||
</div>
|
||||
<div class="contet">
|
||||
<p>
|
||||
<strong>You could subscribe to a single record or to an entire collection.</strong>
|
||||
</p>
|
||||
<p>
|
||||
When you subscribe to a <strong>single record</strong>, the collection's
|
||||
<strong>ViewRule</strong> will be used to determine whether the subscriber has access to receive
|
||||
the event message.
|
||||
</p>
|
||||
<p>
|
||||
When you subscribe to an <strong>entire collection</strong>, the collection's
|
||||
<strong>ListRule</strong> will be used to determine whether the subscriber has access to receive
|
||||
the event message.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Client SDKs example</div>
|
||||
<div class="tabs m-b-base">
|
||||
<div class="tabs-header compact left">
|
||||
{#each sdkExamples as example (example.lang)}
|
||||
<button
|
||||
class="tab-item"
|
||||
class:active={sdkTab === example.lang}
|
||||
on:click={() => (sdkTab = example.lang)}
|
||||
>
|
||||
{example.lang}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tabs-content">
|
||||
{#each sdkExamples as example (example.lang)}
|
||||
<div class="tab-item" class:active={sdkTab === example.lang}>
|
||||
<CodeBlock content={example.code} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Event data format</div>
|
||||
<CodeBlock
|
||||
content={JSON.stringify(
|
||||
{
|
||||
action: "create",
|
||||
record: CommonHelper.dummyCollectionRecord(collection),
|
||||
},
|
||||
null,
|
||||
2
|
||||
).replace('"action": "create"', '"action": "create" // create, update or delete')}
|
||||
/>
|
||||
@@ -0,0 +1,214 @@
|
||||
<script>
|
||||
import { Collection } from "pocketbase";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import CodeBlock from "@/components/base/CodeBlock.svelte";
|
||||
|
||||
export let collection = new Collection();
|
||||
|
||||
let responseTab = 200;
|
||||
let sdkTab = "JavaScript";
|
||||
let responses = [];
|
||||
let sdkExamples = [];
|
||||
|
||||
$: adminsOnly = collection?.updateRule === null;
|
||||
|
||||
$: responses = [
|
||||
{
|
||||
code: 200,
|
||||
body: JSON.stringify(CommonHelper.dummyCollectionRecord(collection), null, 2),
|
||||
},
|
||||
{
|
||||
code: 400,
|
||||
body: `
|
||||
{
|
||||
"code": 400,
|
||||
"message": "Failed to update record.",
|
||||
"data": {
|
||||
"${collection?.schema?.[0]?.name}": {
|
||||
"code": "validation_required",
|
||||
"message": "Missing required value."
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
code: 403,
|
||||
body: `
|
||||
{
|
||||
"code": 403,
|
||||
"message": "You are not allowed to perform this request.",
|
||||
"data": {}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
code: 404,
|
||||
body: `
|
||||
{
|
||||
"code": 404,
|
||||
"message": "The requested resource wasn't found.",
|
||||
"data": {}
|
||||
}
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
$: sdkExamples = [
|
||||
{
|
||||
lang: "JavaScript",
|
||||
code: `
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const client = new PocketBase("${ApiClient.baseUrl}");
|
||||
|
||||
const data = { ... };
|
||||
|
||||
client.Records.update("${collection?.name}", "RECORD_ID", data)
|
||||
.then(function (record) {
|
||||
// success...
|
||||
}).catch(function (error) {
|
||||
// error...
|
||||
});
|
||||
`,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<strong class="label label-primary">PATCH</strong>
|
||||
<div class="content">
|
||||
<p>
|
||||
/api/collections/<strong>{collection.name}</strong>/records/<strong>:id</strong>
|
||||
</p>
|
||||
</div>
|
||||
{#if adminsOnly}
|
||||
<p class="txt-hint txt-sm txt-right">Requires <code>Authorization: Admin TOKEN</code> header</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="content m-b-base">
|
||||
<p>Update a single <strong>{collection.name}</strong> record.</p>
|
||||
<p>
|
||||
Body parameters could be sent as <code>application/json</code> or
|
||||
<code>multipart/form-data</code>.
|
||||
</p>
|
||||
<p>
|
||||
File upload is supported only via <code>multipart/form-data</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Client SDKs example</div>
|
||||
<div class="tabs m-b-lg">
|
||||
<div class="tabs-header compact left">
|
||||
{#each sdkExamples as example (example.lang)}
|
||||
<button
|
||||
class="tab-item"
|
||||
class:active={sdkTab === example.lang}
|
||||
on:click={() => (sdkTab = example.lang)}
|
||||
>
|
||||
{example.lang}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tabs-content">
|
||||
{#each sdkExamples as example (example.lang)}
|
||||
<div class="tab-item" class:active={sdkTab === example.lang}>
|
||||
<CodeBlock content={example.code} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Path parameters</div>
|
||||
<table class="table-compact table-border m-b-lg">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Param</th>
|
||||
<th>Type</th>
|
||||
<th width="60%">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>id</td>
|
||||
<td>
|
||||
<span class="label">String</span>
|
||||
</td>
|
||||
<td>ID of the record to update.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="section-title">Body Parameters</div>
|
||||
<table class="table-compact table-border m-b-lg">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Param</th>
|
||||
<th width="60%">Type</th>
|
||||
<th width="50%">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each collection?.schema as field (field.name)}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="inline-flex">
|
||||
{#if field.required}
|
||||
<span class="label label-success">Required</span>
|
||||
{:else}
|
||||
<span class="label label-warning">Optional</span>
|
||||
{/if}
|
||||
<span>{field.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="label">{CommonHelper.getFieldValueType(field)}</span>
|
||||
</td>
|
||||
<td>
|
||||
{#if field.type === "text"}
|
||||
Plain text value.
|
||||
{:else if field.type === "number"}
|
||||
Number value.
|
||||
{:else if field.type === "json"}
|
||||
JSON array or object.
|
||||
{:else if field.type === "email"}
|
||||
Email address.
|
||||
{:else if field.type === "url"}
|
||||
URL address.
|
||||
{:else if field.type === "file"}
|
||||
FormData object.<br />
|
||||
Set to <code>null</code> to delete already uploaded file(s).
|
||||
{:else if field.type === "relation"}
|
||||
Relation record {field.options?.maxSelect > 1 ? "ids" : "id"}.
|
||||
{:else if field.type === "user"}
|
||||
User {field.options?.maxSelect > 1 ? "ids" : "id"}.
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="section-title">Responses</div>
|
||||
<div class="tabs">
|
||||
<div class="tabs-header compact left">
|
||||
{#each responses as response (response.code)}
|
||||
<button
|
||||
class="tab-item"
|
||||
class:active={responseTab === response.code}
|
||||
on:click={() => (responseTab = response.code)}
|
||||
>
|
||||
{response.code}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tabs-content">
|
||||
{#each responses as response (response.code)}
|
||||
<div class="tab-item" class:active={responseTab === response.code}>
|
||||
<CodeBlock content={response.body} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script>
|
||||
import { Collection } from "pocketbase";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import CodeBlock from "@/components/base/CodeBlock.svelte";
|
||||
|
||||
export let collection = new Collection();
|
||||
|
||||
let responseTab = 200;
|
||||
let sdkTab = "JavaScript";
|
||||
let responses = [];
|
||||
let sdkExamples = [];
|
||||
|
||||
$: adminsOnly = collection?.viewRule === null;
|
||||
|
||||
$: if (collection?.id) {
|
||||
responses.push({
|
||||
code: 200,
|
||||
body: JSON.stringify(CommonHelper.dummyCollectionRecord(collection), null, 2),
|
||||
});
|
||||
|
||||
if (adminsOnly) {
|
||||
responses.push({
|
||||
code: 403,
|
||||
body: `
|
||||
{
|
||||
"code": 403,
|
||||
"message": "Only admins can access this action.",
|
||||
"data": {}
|
||||
}
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
responses.push({
|
||||
code: 404,
|
||||
body: `
|
||||
{
|
||||
"code": 404,
|
||||
"message": "The requested resource wasn't found.",
|
||||
"data": {}
|
||||
}
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
$: sdkExamples = [
|
||||
{
|
||||
lang: "JavaScript",
|
||||
code: `
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const client = new PocketBase("${ApiClient.baseUrl}");
|
||||
|
||||
client.Records.getOne("${collection?.name}", "RECORD_ID")
|
||||
.then(function (record) {
|
||||
// success...
|
||||
}).catch(function (error) {
|
||||
// error...
|
||||
});
|
||||
`,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<strong class="label label-primary">GET</strong>
|
||||
<div class="content">
|
||||
<p>
|
||||
/api/collections/<strong>{collection.name}</strong>/records/<strong>:id</strong>
|
||||
</p>
|
||||
</div>
|
||||
{#if adminsOnly}
|
||||
<p class="txt-hint txt-sm txt-right">Requires <code>Authorization: Admin TOKEN</code> header</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="content m-b-base">
|
||||
<p>Fetch a single <strong>{collection.name}</strong> record.</p>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Client SDKs example</div>
|
||||
<div class="tabs m-b-lg">
|
||||
<div class="tabs-header compact left">
|
||||
{#each sdkExamples as example (example.lang)}
|
||||
<button
|
||||
class="tab-item"
|
||||
class:active={sdkTab === example.lang}
|
||||
on:click={() => (sdkTab = example.lang)}
|
||||
>
|
||||
{example.lang}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tabs-content">
|
||||
{#each sdkExamples as example (example.lang)}
|
||||
<div class="tab-item" class:active={sdkTab === example.lang}>
|
||||
<CodeBlock content={example.code} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Path Parameters</div>
|
||||
<table class="table-compact table-border m-b-lg">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Param</th>
|
||||
<th>Type</th>
|
||||
<th width="60%">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>id</td>
|
||||
<td>
|
||||
<span class="label">String</span>
|
||||
</td>
|
||||
<td>ID of the record to view.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="section-title">Query parameters</div>
|
||||
<table class="table-compact table-border m-b-lg">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Param</th>
|
||||
<th>Type</th>
|
||||
<th width="60%">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>expand</td>
|
||||
<td>
|
||||
<span class="label">String</span>
|
||||
</td>
|
||||
<td>
|
||||
Auto expand nested record relations. Ex.:
|
||||
<CodeBlock
|
||||
content={`
|
||||
?expand=rel1,rel2.subrel21.subrel22
|
||||
`}
|
||||
/>
|
||||
Supports up to 6-levels depth nested relations expansion. <br />
|
||||
The expanded relations will be appended to the record under the
|
||||
<code>@expand</code> property (eg. <code>{`"@expand": {"rel1": {...}, ...}`}</code>).
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="section-title">Responses</div>
|
||||
<div class="tabs">
|
||||
<div class="tabs-header compact left">
|
||||
{#each responses as response (response.code)}
|
||||
<button
|
||||
class="tab-item"
|
||||
class:active={responseTab === response.code}
|
||||
on:click={() => (responseTab = response.code)}
|
||||
>
|
||||
{response.code}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tabs-content">
|
||||
{#each responses as response (response.code)}
|
||||
<div class="tab-item" class:active={responseTab === response.code}>
|
||||
<CodeBlock content={response.body} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script>
|
||||
// svelte-ignore unused-export-let
|
||||
export let key = "";
|
||||
// svelte-ignore unused-export-let
|
||||
export let options = {};
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script>
|
||||
import Flatpickr from "svelte-flatpickr";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
export let key = "";
|
||||
export let options = {};
|
||||
</script>
|
||||
|
||||
<div class="grid">
|
||||
<div class="col-sm-6">
|
||||
<Field class="form-field" name="schema.{key}.options.min" let:uniqueId>
|
||||
<label for={uniqueId}>Min date (UTC)</label>
|
||||
<Flatpickr
|
||||
id={uniqueId}
|
||||
options={CommonHelper.defaultFlatpickrOptions()}
|
||||
value={options.min}
|
||||
bind:formattedValue={options.min}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6">
|
||||
<Field class="form-field" name="schema.{key}.options.max" let:uniqueId>
|
||||
<label for={uniqueId}>Max date (UTC)</label>
|
||||
<Flatpickr
|
||||
id={uniqueId}
|
||||
options={CommonHelper.defaultFlatpickrOptions()}
|
||||
value={options.max}
|
||||
bind:formattedValue={options.max}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script>
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import MultipleValueInput from "@/components/base/MultipleValueInput.svelte";
|
||||
|
||||
export let key = "";
|
||||
export let options = {};
|
||||
</script>
|
||||
|
||||
<div class="grid">
|
||||
<div class="col-sm-6">
|
||||
<Field class="form-field" name="schema.{key}.options.exceptDomains" let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<span class="txt">Except domains</span>
|
||||
<i
|
||||
class="ri-information-line link-hint"
|
||||
use:tooltip={{
|
||||
text: 'Domains that are NOT allowed as value. \n This field is disabled if "Only domains" is set.',
|
||||
position: "top",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<MultipleValueInput
|
||||
id={uniqueId}
|
||||
disabled={!CommonHelper.isEmpty(options.onlyDomains)}
|
||||
bind:value={options.exceptDomains}
|
||||
/>
|
||||
<div class="help-block">Use comma as separator.</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6">
|
||||
<Field class="form-field" name="schema.{key}.options.onlyDomains" let:uniqueId>
|
||||
<label for="{uniqueId}.options.onlyDomains">
|
||||
<span class="txt">Only domains</span>
|
||||
<i
|
||||
class="ri-information-line link-hint"
|
||||
use:tooltip={{
|
||||
text: 'Domains that are ONLY allowed as value. \n This field is disabled if "Except domains" is set.',
|
||||
position: "top",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<MultipleValueInput
|
||||
id="{uniqueId}.options.onlyDomains"
|
||||
disabled={!CommonHelper.isEmpty(options.exceptDomains)}
|
||||
bind:value={options.onlyDomains}
|
||||
/>
|
||||
<div class="help-block">Use comma as separator.</div>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script>
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
|
||||
|
||||
export let value = "text";
|
||||
|
||||
let classes = "";
|
||||
export { classes as class }; // export reserved keyword
|
||||
|
||||
const types = [
|
||||
{
|
||||
label: "Text",
|
||||
value: "text",
|
||||
icon: CommonHelper.getFieldTypeIcon("text"),
|
||||
},
|
||||
{
|
||||
label: "Number",
|
||||
value: "number",
|
||||
icon: CommonHelper.getFieldTypeIcon("number"),
|
||||
},
|
||||
{
|
||||
label: "Bool",
|
||||
value: "bool",
|
||||
icon: CommonHelper.getFieldTypeIcon("bool"),
|
||||
},
|
||||
{
|
||||
label: "Email",
|
||||
value: "email",
|
||||
icon: CommonHelper.getFieldTypeIcon("email"),
|
||||
},
|
||||
{
|
||||
label: "Url",
|
||||
value: "url",
|
||||
icon: CommonHelper.getFieldTypeIcon("url"),
|
||||
},
|
||||
{
|
||||
label: "DateTime",
|
||||
value: "date",
|
||||
icon: CommonHelper.getFieldTypeIcon("date"),
|
||||
},
|
||||
{
|
||||
label: "Multiple choices",
|
||||
value: "select",
|
||||
icon: CommonHelper.getFieldTypeIcon("select"),
|
||||
},
|
||||
{
|
||||
label: "JSON",
|
||||
value: "json",
|
||||
icon: CommonHelper.getFieldTypeIcon("json"),
|
||||
},
|
||||
{
|
||||
label: "File",
|
||||
value: "file",
|
||||
icon: CommonHelper.getFieldTypeIcon("file"),
|
||||
},
|
||||
{
|
||||
label: "Relation",
|
||||
value: "relation",
|
||||
icon: CommonHelper.getFieldTypeIcon("relation"),
|
||||
},
|
||||
{
|
||||
label: "User",
|
||||
value: "user",
|
||||
icon: CommonHelper.getFieldTypeIcon("user"),
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<ObjectSelect
|
||||
class="field-type-select {classes}"
|
||||
searchable
|
||||
items={types}
|
||||
bind:keyOfSelected={value}
|
||||
{...$$restProps}
|
||||
/>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script>
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import Toggler from "@/components/base/Toggler.svelte";
|
||||
import MultipleValueInput from "@/components/base/MultipleValueInput.svelte";
|
||||
|
||||
export let key = "";
|
||||
export let options = {};
|
||||
|
||||
$: if (CommonHelper.isEmpty(options)) {
|
||||
// load defaults
|
||||
options = {
|
||||
maxSelect: 1,
|
||||
maxSize: 5242880,
|
||||
thumbs: [],
|
||||
mimeTypes: [],
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid">
|
||||
<div class="col-sm-6">
|
||||
<Field class="form-field required" name="schema.{key}.options.maxSize" let:uniqueId>
|
||||
<label for={uniqueId}>Max file size (bytes)</label>
|
||||
<input type="number" id={uniqueId} step="1" min="0" bind:value={options.maxSize} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6">
|
||||
<Field class="form-field required" name="schema.{key}.options.maxSelect" let:uniqueId>
|
||||
<label for={uniqueId}>Max files</label>
|
||||
<input type="number" id={uniqueId} step="1" min="" required bind:value={options.maxSelect} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12">
|
||||
<Field class="form-field" name="schema.{key}.options.mimeTypes" let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<span class="txt">Mime types</span>
|
||||
<i
|
||||
class="ri-information-line link-hint"
|
||||
use:tooltip={{
|
||||
text: "Allow uploading files ONLY with the listed mime types. \n Leave empty for no restriction.",
|
||||
position: "top",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<MultipleValueInput
|
||||
id={uniqueId}
|
||||
placeholder="eg. image/png, application/pdf..."
|
||||
bind:value={options.mimeTypes}
|
||||
/>
|
||||
<div class="help-block">
|
||||
Use comma as separator.
|
||||
<span class="inline-flex">
|
||||
<span class="txt link-primary">Choose presets</span>
|
||||
<Toggler class="dropdown dropdown-sm dropdown-nowrap">
|
||||
<div
|
||||
tabindex="0"
|
||||
class="dropdown-item closable"
|
||||
on:click={() => {
|
||||
options.mimeTypes = [
|
||||
"image/jpg",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
"image/gif",
|
||||
];
|
||||
}}
|
||||
>
|
||||
<span class="txt">Images (jpg, png, svg, gif)</span>
|
||||
</div>
|
||||
<div
|
||||
tabindex="0"
|
||||
class="dropdown-item closable"
|
||||
on:click={() => {
|
||||
options.mimeTypes = [
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
];
|
||||
}}
|
||||
>
|
||||
<span class="txt">Documents (pdf, doc/docx, xls/xlsx)</span>
|
||||
</div>
|
||||
<div
|
||||
tabindex="0"
|
||||
class="dropdown-item closable"
|
||||
on:click={() => {
|
||||
options.mimeTypes = [
|
||||
"application/zip",
|
||||
"application/x-7z-compressed",
|
||||
"application/x-rar-compressed",
|
||||
];
|
||||
}}
|
||||
>
|
||||
<span class="txt">Archives (zip, 7zip, rar)</span>
|
||||
</div>
|
||||
</Toggler>
|
||||
</span>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12">
|
||||
<Field class="form-field" name="schema.{key}.options.thumbs" let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<span class="txt">Thumb sizes</span>
|
||||
<i
|
||||
class="ri-information-line link-hint"
|
||||
use:tooltip={{
|
||||
text: "List of thumb sizes for image files. The thumbs will be generated lazily on first access.",
|
||||
position: "top",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<MultipleValueInput id={uniqueId} placeholder="eg. 50x50, 480x720" bind:value={options.thumbs} />
|
||||
<div class="help-block">Use comma as separator.</div>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script>
|
||||
// svelte-ignore unused-export-let
|
||||
export const key = "";
|
||||
// svelte-ignore unused-export-let
|
||||
export const options = {};
|
||||
</script>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script>
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
export let key = "";
|
||||
export let options = {};
|
||||
</script>
|
||||
|
||||
<div class="grid">
|
||||
<div class="col-sm-6">
|
||||
<Field class="form-field" name="schema.{key}.options.min" let:uniqueId>
|
||||
<label for={uniqueId}>Min</label>
|
||||
<input type="number" id={uniqueId} bind:value={options.min} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6">
|
||||
<Field class="form-field" name="schema.{key}.options.max" let:uniqueId>
|
||||
<label for={uniqueId}>Max</label>
|
||||
<input type="number" id={uniqueId} min={options.min} bind:value={options.max} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script>
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
|
||||
|
||||
export let key = "";
|
||||
export let options = {};
|
||||
|
||||
const defaultOptions = [
|
||||
{ label: "False", value: false },
|
||||
{ label: "True", value: true },
|
||||
];
|
||||
|
||||
let isLoading = false;
|
||||
let collections = [];
|
||||
|
||||
// load defaults
|
||||
$: if (CommonHelper.isEmpty(options)) {
|
||||
options = {
|
||||
maxSelect: 1,
|
||||
collectionId: null,
|
||||
cascadeDelete: false,
|
||||
};
|
||||
}
|
||||
|
||||
loadCollections();
|
||||
|
||||
function loadCollections() {
|
||||
isLoading = true;
|
||||
|
||||
ApiClient.Collections.getFullList(200, { sort: "-created" })
|
||||
.then((items) => {
|
||||
collections = items;
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
})
|
||||
.finally(() => {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid">
|
||||
<div class="col-sm-9">
|
||||
<Field class="form-field required" name="schema.{key}.options.collectionId" let:uniqueId>
|
||||
<label for={uniqueId}>Collection</label>
|
||||
<ObjectSelect
|
||||
searchable={collections.length > 5}
|
||||
selectPlaceholder={isLoading ? "Loading..." : "Select collection"}
|
||||
noOptionsText="No collections found"
|
||||
selectionKey="id"
|
||||
items={collections}
|
||||
bind:keyOfSelected={options.collectionId}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<Field class="form-field required" name="schema.{key}.options.maxSelect" let:uniqueId>
|
||||
<label for={uniqueId}>Max select</label>
|
||||
<input type="number" id={uniqueId} step="1" min="1" required bind:value={options.maxSelect} />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<Field class="form-field" name="schema.{key}.options.cascadeDelete" let:uniqueId>
|
||||
<label for={uniqueId}>Delete record on relation delete</label>
|
||||
<ObjectSelect id={uniqueId} items={defaultOptions} bind:keyOfSelected={options.cascadeDelete} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script>
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import MultipleValueInput from "@/components/base/MultipleValueInput.svelte";
|
||||
|
||||
export let key = "";
|
||||
export let options = {};
|
||||
|
||||
$: if (CommonHelper.isEmpty(options)) {
|
||||
// load defaults
|
||||
options = {
|
||||
maxSelect: 1,
|
||||
values: [],
|
||||
};
|
||||
}
|
||||
|
||||
// leave the validation to the api
|
||||
// $: if (!CommonHelper.isEmpty(options.values) && options.maxSelect > options.values.length) {
|
||||
// options.maxSelect = options.values.length;
|
||||
// }
|
||||
</script>
|
||||
|
||||
<div class="grid">
|
||||
<div class="col-sm-9">
|
||||
<Field class="form-field required" name="schema.{key}.options.values" let:uniqueId>
|
||||
<label for={uniqueId}>Choices</label>
|
||||
<MultipleValueInput
|
||||
id={uniqueId}
|
||||
placeholder="eg. optionA, optionB"
|
||||
required
|
||||
bind:value={options.values}
|
||||
/>
|
||||
<div class="help-block">Use comma as separator.</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-3">
|
||||
<Field class="form-field required" name="schema.{key}.options.maxSelect" let:uniqueId>
|
||||
<label for={uniqueId}>Max select</label>
|
||||
<input type="number" id={uniqueId} step="1" min="1" required bind:value={options.maxSelect} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script>
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
export let key = "";
|
||||
export let options = {};
|
||||
</script>
|
||||
|
||||
<div class="grid">
|
||||
<div class="col-sm-6">
|
||||
<Field class="form-field" name="schema.{key}.options.min" let:uniqueId>
|
||||
<label for={uniqueId}>Min length</label>
|
||||
<input type="number" id={uniqueId} step="1" min="0" bind:value={options.min} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-6">
|
||||
<Field class="form-field" name="schema.{key}.options.max" let:uniqueId>
|
||||
<label for={uniqueId}>Max length</label>
|
||||
<input type="number" id={uniqueId} step="1" min={options.min || 0} bind:value={options.max} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12">
|
||||
<Field class="form-field" name="schema.{key}.options.pattern" let:uniqueId>
|
||||
<label for={uniqueId}>Regex pattern</label>
|
||||
<input type="text" id={uniqueId} bind:value={options.pattern} />
|
||||
<div class="help-block">Valid Go regular expression, eg. <code>^\w+$</code>.</div>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script>
|
||||
import EmailOptions from "./EmailOptions.svelte";
|
||||
|
||||
export let key = "";
|
||||
export let options = {};
|
||||
</script>
|
||||
|
||||
<!-- shares the same options with the email field -->
|
||||
<EmailOptions bind:key bind:options />
|
||||
@@ -0,0 +1,36 @@
|
||||
<script>
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
|
||||
|
||||
const defaultOptions = [
|
||||
{ label: "False", value: false },
|
||||
{ label: "True", value: true },
|
||||
];
|
||||
|
||||
export let key = "";
|
||||
export let options = {};
|
||||
|
||||
// load defaults
|
||||
$: if (CommonHelper.isEmpty(options)) {
|
||||
options = {
|
||||
maxSelect: 1,
|
||||
cascadeDelete: false,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid">
|
||||
<div class="col-sm-6">
|
||||
<Field class="form-field required" name="schema.{key}.options.maxSelect" let:uniqueId>
|
||||
<label for={uniqueId}>Max select</label>
|
||||
<input type="number" id={uniqueId} step="1" min="1" required bind:value={options.maxSelect} />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<Field class="form-field" name="schema.{key}.options.cascadeDelete" let:uniqueId>
|
||||
<label for={uniqueId}>Delete record on user delete</label>
|
||||
<ObjectSelect id={uniqueId} items={defaultOptions} bind:keyOfSelected={options.cascadeDelete} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script>
|
||||
import { Request } from "pocketbase";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import CodeBlock from "@/components/base/CodeBlock.svelte";
|
||||
import FormattedDate from "@/components/base/FormattedDate.svelte";
|
||||
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
|
||||
|
||||
let logPanel;
|
||||
let item = new Request();
|
||||
|
||||
export function show(model) {
|
||||
item = model;
|
||||
|
||||
return logPanel?.show();
|
||||
}
|
||||
|
||||
export function hide() {
|
||||
return logPanel?.hide();
|
||||
}
|
||||
</script>
|
||||
|
||||
<OverlayPanel bind:this={logPanel} class="overlay-panel-lg log-panel" on:hide on:show>
|
||||
<svelte:fragment slot="header">
|
||||
<h4>Request log</h4>
|
||||
</svelte:fragment>
|
||||
|
||||
<table class="table-compact table-border">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">ID</td>
|
||||
<td>{item.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">Status</td>
|
||||
<td>
|
||||
<span class="label" class:label-danger={item.status >= 400}>
|
||||
{item.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">Method</td>
|
||||
<td>{item.method?.toUpperCase()}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">Auth</td>
|
||||
<td>{item.auth}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">URL</td>
|
||||
<td>{item.url}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">Referer</td>
|
||||
<td>
|
||||
{#if item.referer}
|
||||
<a href={item.referer} target="_blank" rel="noopener noreferrer">
|
||||
{item.referer}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="txt-hint">N/A</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">IP</td>
|
||||
<td>{item.ip}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">UserAgent</td>
|
||||
<td>{item.userAgent}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">Meta</td>
|
||||
<td>
|
||||
{#if !CommonHelper.isEmpty(item.meta)}
|
||||
<CodeBlock content={JSON.stringify(item.meta, null, 2)} />
|
||||
{:else}
|
||||
<span class="txt-hint">N/A</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="min-width txt-hint txt-bold">Created</td>
|
||||
<td><FormattedDate date={item.created} /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<button type="button" class="btn btn-secondary" on:click={() => hide()}>
|
||||
<span class="txt">Close</span>
|
||||
</button>
|
||||
</svelte:fragment>
|
||||
</OverlayPanel>
|
||||
@@ -0,0 +1,180 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { scale } from "svelte/transition";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import {
|
||||
Chart,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LineController,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
Filler,
|
||||
Tooltip,
|
||||
} from "chart.js";
|
||||
import "chartjs-adapter-luxon";
|
||||
|
||||
export let filter = "";
|
||||
export let presets = "";
|
||||
|
||||
let chartCanvas;
|
||||
let chartInst;
|
||||
let chartData = [];
|
||||
let totalRequests = 0;
|
||||
let isLoading = false;
|
||||
|
||||
$: if (typeof filter !== "undefined" || typeof presets !== "undefined") {
|
||||
load();
|
||||
}
|
||||
|
||||
$: if (typeof chartData !== "undefined" && chartInst) {
|
||||
chartInst.data.datasets[0].data = chartData;
|
||||
chartInst.update();
|
||||
}
|
||||
|
||||
export async function load() {
|
||||
isLoading = true;
|
||||
|
||||
return ApiClient.Logs.getRequestsStats({
|
||||
filter: [presets, filter].filter(Boolean).join("&&"),
|
||||
})
|
||||
.then((result) => {
|
||||
resetData();
|
||||
for (let item of result) {
|
||||
chartData.push({
|
||||
x: CommonHelper.getDateTime(item.date).toLocal().toJSDate(),
|
||||
y: item.total,
|
||||
});
|
||||
totalRequests += item.total;
|
||||
}
|
||||
|
||||
// add current time marker to the chart
|
||||
chartData.push({
|
||||
x: new Date(),
|
||||
y: undefined,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err !== null) {
|
||||
resetData();
|
||||
console.warn(err);
|
||||
ApiClient.errorResponseHandler(err, false);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
function resetData() {
|
||||
totalRequests = 0;
|
||||
chartData = [];
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
Chart.register(LineElement, PointElement, LineController, LinearScale, TimeScale, Filler, Tooltip);
|
||||
|
||||
chartInst = new Chart(chartCanvas, {
|
||||
type: "line",
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: "Total requests",
|
||||
data: chartData,
|
||||
borderColor: "#ef4565",
|
||||
pointBackgroundColor: "#ef4565",
|
||||
backgroundColor: "rgb(239,69,101,0.05)",
|
||||
borderWidth: 2,
|
||||
pointBorderWidth: 0,
|
||||
fill: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
animation: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: "index",
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: "#edf0f3",
|
||||
borderColor: "#dee3e8",
|
||||
},
|
||||
ticks: {
|
||||
precision: 0,
|
||||
maxTicksLimit: 6,
|
||||
autoSkip: true,
|
||||
color: "#666f75",
|
||||
},
|
||||
},
|
||||
x: {
|
||||
type: "time",
|
||||
time: {
|
||||
unit: "hour",
|
||||
tooltipFormat: "DD h a",
|
||||
},
|
||||
grid: {
|
||||
borderColor: "#dee3e8",
|
||||
color: (c) => (c.tick.major ? "#edf0f3" : ""),
|
||||
},
|
||||
ticks: {
|
||||
maxTicksLimit: 15,
|
||||
autoSkip: true,
|
||||
maxRotation: 0,
|
||||
major: {
|
||||
enabled: true,
|
||||
},
|
||||
color: (c) => (c.tick.major ? "#16161a" : "#666f75"),
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => chartInst?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="chart-wrapper" class:loading={isLoading}>
|
||||
{#if isLoading}
|
||||
<div class="chart-loader loader" transition:scale={{ duration: 150 }} />
|
||||
{/if}
|
||||
<canvas bind:this={chartCanvas} class="chart-canvas" style="height: 250px; width: 100%;" />
|
||||
</div>
|
||||
|
||||
<div class="txt-hint m-t-xs txt-right">
|
||||
{#if isLoading}
|
||||
Loading...
|
||||
{:else}
|
||||
{totalRequests}
|
||||
{totalRequests === 1 ? "log" : "logs"}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chart-wrapper {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.chart-wrapper.loading .chart-canvas {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.chart-loader {
|
||||
position: absolute;
|
||||
z-index: 999;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,201 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import SortHeader from "@/components/base/SortHeader.svelte";
|
||||
import FormattedDate from "@/components/base/FormattedDate.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const labelMethodClass = {
|
||||
get: "label-info",
|
||||
post: "label-success",
|
||||
patch: "label-warning",
|
||||
delete: "label-danger",
|
||||
};
|
||||
|
||||
export let filter = "";
|
||||
export let presets = "";
|
||||
export let sort = "-rowid";
|
||||
|
||||
let items = [];
|
||||
let currentPage = 1;
|
||||
let totalItems = 0;
|
||||
let isLoading = false;
|
||||
|
||||
$: if (typeof sort !== "undefined" || typeof filter !== "undefined" || typeof presets !== "undefined") {
|
||||
clearList();
|
||||
load(1);
|
||||
}
|
||||
|
||||
$: canLoadMore = totalItems > items.length;
|
||||
|
||||
export async function load(page = 1) {
|
||||
isLoading = true;
|
||||
|
||||
return ApiClient.Logs.getRequestsList(page, 40, {
|
||||
sort: sort,
|
||||
filter: [presets, filter].filter(Boolean).join("&&"),
|
||||
})
|
||||
.then((result) => {
|
||||
if (page <= 1) {
|
||||
clearList();
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
items = items.concat(result.items);
|
||||
currentPage = result.page;
|
||||
totalItems = result.totalItems;
|
||||
|
||||
dispatch("load", items);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err !== null) {
|
||||
isLoading = false;
|
||||
console.warn(err);
|
||||
clearList();
|
||||
ApiClient.errorResponseHandler(err, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clearList() {
|
||||
items = [];
|
||||
currentPage = 1;
|
||||
totalItems = 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="table" class:table-loading={isLoading}>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortHeader disable class="col-field-method" name="method" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class="ri-global-line" />
|
||||
<span class="txt">method</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<SortHeader disable class="col-type-text col-field-url" name="url" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("url")} />
|
||||
<span class="txt">url</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<SortHeader disable class="col-type-text col-field-referer" name="referer" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("url")} />
|
||||
<span class="txt">referer</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<SortHeader disable class="col-type-number col-field-status" name="status" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("number")} />
|
||||
<span class="txt">status</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<SortHeader disable class="col-type-date col-field-created" name="created" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("date")} />
|
||||
<span class="txt">created</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
<th class="col-type-action min-width" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each items as item (item.id)}
|
||||
<tr
|
||||
tabindex="0"
|
||||
class="row-handle"
|
||||
on:click={() => dispatch("select", item)}
|
||||
on:keydown={(e) => {
|
||||
if (e.code === "Enter") {
|
||||
e.preventDefault();
|
||||
dispatch("select", item);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td class="col-type-text col-field-method min-width">
|
||||
<span class="label txt-uppercase {labelMethodClass[item.method.toLowerCase()]}">
|
||||
{item.method?.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="col-type-text col-field-url">
|
||||
<span class="txt txt-ellipsis" title={item.url}>
|
||||
{item.url}
|
||||
</span>
|
||||
{#if item.meta?.errorMessage || item.meta?.errorData}
|
||||
<i class="ri-error-warning-line txt-danger m-l-5 m-r-5" title="Error" />
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<td class="col-type-text col-field-referer">
|
||||
<span class="txt txt-ellipsis" class:txt-hint={!item.referer} title={item.referer}>
|
||||
{item.referer || "N/A"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="col-type-number col-field-status">
|
||||
<span class="label" class:label-danger={item.status >= 400}>
|
||||
{item.status}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="col-type-date col-field-created">
|
||||
<FormattedDate date={item.created} />
|
||||
</td>
|
||||
|
||||
<td class="col-type-action min-width">
|
||||
<i class="ri-arrow-right-line" />
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#if isLoading}
|
||||
<tr>
|
||||
<td colspan="99" class="p-xs">
|
||||
<span class="skeleton-loader" />
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="99" class="txt-center txt-hint p-xs">
|
||||
<h6>No logs found.</h6>
|
||||
{#if filter?.length}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-hint btn-expanded m-t-sm"
|
||||
on:click={() => (filter = "")}
|
||||
>
|
||||
<span class="txt">Clear filters</span>
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if items.length}
|
||||
<small class="block txt-hint txt-right m-t-sm">Showing {items.length} of {totalItems}</small>
|
||||
{/if}
|
||||
|
||||
{#if items.length && canLoadMore}
|
||||
<div class="block txt-center m-t-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-lg btn-secondary btn-expanded"
|
||||
class:btn-loading={isLoading}
|
||||
class:btn-disabled={isLoading}
|
||||
on:click={() => load(currentPage + 1)}
|
||||
>
|
||||
<span class="txt">Load more ({totalItems - items.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,75 @@
|
||||
<script>
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import Searchbar from "@/components/base/Searchbar.svelte";
|
||||
import LogsList from "@/components/logs/LogsList.svelte";
|
||||
import LogsChart from "@/components/logs/LogsChart.svelte";
|
||||
import LogViewPanel from "@/components/logs/LogViewPanel.svelte";
|
||||
|
||||
const ADMIN_LOGS_LOCAL_STORAGE_KEY = "includeAdminLogs";
|
||||
|
||||
let logPanel;
|
||||
let filter = "";
|
||||
let includeAdminLogs = window.localStorage?.getItem(ADMIN_LOGS_LOCAL_STORAGE_KEY) << 0;
|
||||
let refreshToken = 1;
|
||||
|
||||
$: presets = !includeAdminLogs ? 'auth!="admin"' : "";
|
||||
|
||||
$: if (typeof includeAdminLogs !== "undefined" && window.localStorage) {
|
||||
window.localStorage.setItem(ADMIN_LOGS_LOCAL_STORAGE_KEY, includeAdminLogs << 0);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
refreshToken++;
|
||||
}
|
||||
|
||||
CommonHelper.setDocumentTitle("Request logs");
|
||||
</script>
|
||||
|
||||
<main class="page-wrapper">
|
||||
<div class="page-header-wrapper m-b-0">
|
||||
<header class="page-header">
|
||||
<nav class="breadcrumbs">
|
||||
<div class="breadcrumb-item">Request logs</div>
|
||||
</nav>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-circle btn-secondary"
|
||||
use:tooltip={{ text: "Refresh", position: "right" }}
|
||||
on:click={refresh}
|
||||
>
|
||||
<i class="ri-refresh-line" />
|
||||
</button>
|
||||
|
||||
<div class="flex-fill" />
|
||||
|
||||
<div class="inline-flex">
|
||||
<Field class="form-field form-field-toggle m-0" let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} bind:checked={includeAdminLogs} />
|
||||
<label for={uniqueId}>Include requests by admins</label>
|
||||
</Field>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Searchbar
|
||||
value={filter}
|
||||
placeholder="Search logs, ex. status > 200"
|
||||
extraAutocompleteKeys={["method", "url", "ip", "referer", "status", "auth", "userAgent"]}
|
||||
on:submit={(e) => (filter = e.detail)}
|
||||
/>
|
||||
|
||||
<div class="clearfix m-b-xs" />
|
||||
|
||||
{#key refreshToken}
|
||||
<LogsChart bind:filter {presets} />
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
{#key refreshToken}
|
||||
<LogsList bind:filter {presets} on:select={(e) => logPanel?.show(e?.detail)} />
|
||||
{/key}
|
||||
</main>
|
||||
|
||||
<LogViewPanel bind:this={logPanel} />
|
||||
@@ -0,0 +1,130 @@
|
||||
<script>
|
||||
import {
|
||||
collections,
|
||||
activeCollection,
|
||||
isCollectionsLoading,
|
||||
loadCollections,
|
||||
} from "@/stores/collections";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import Searchbar from "@/components/base/Searchbar.svelte";
|
||||
import CollectionsSidebar from "@/components/collections/CollectionsSidebar.svelte";
|
||||
import CollectionUpsertPanel from "@/components/collections/CollectionUpsertPanel.svelte";
|
||||
import CollectionDocsPanel from "@/components/collections/docs/CollectionDocsPanel.svelte";
|
||||
import RecordUpsertPanel from "@/components/records/RecordUpsertPanel.svelte";
|
||||
import RecordsList from "@/components/records/RecordsList.svelte";
|
||||
|
||||
const queryParams = CommonHelper.getQueryParams(window.location?.href);
|
||||
|
||||
let collectionUpsertPanel;
|
||||
let collectionDocsPanel;
|
||||
let recordPanel;
|
||||
let recordsList;
|
||||
let filter = queryParams.filter || "";
|
||||
let sort = queryParams.sort || "-created";
|
||||
let selectedCollectionId = queryParams.collectionId;
|
||||
|
||||
$: viewableCollections = $collections.filter((c) => c.name != import.meta.env.PB_PROFILE_COLLECTION);
|
||||
|
||||
// reset filter and sort on collection change
|
||||
$: if ($activeCollection?.id && selectedCollectionId != $activeCollection.id) {
|
||||
selectedCollectionId = $activeCollection.id;
|
||||
sort = "-created";
|
||||
filter = "";
|
||||
}
|
||||
|
||||
// keep the url params in sync
|
||||
$: if (sort || filter || $activeCollection?.id) {
|
||||
CommonHelper.replaceClientQueryParams({
|
||||
collectionId: $activeCollection?.id,
|
||||
filter: filter,
|
||||
sort: sort,
|
||||
});
|
||||
}
|
||||
|
||||
CommonHelper.setDocumentTitle("Collections");
|
||||
|
||||
loadCollections(selectedCollectionId);
|
||||
</script>
|
||||
|
||||
{#if $isCollectionsLoading}
|
||||
<div class="placeholder-section m-b-base">
|
||||
<span class="loader loader-lg" />
|
||||
<h1>Loading collections...</h1>
|
||||
</div>
|
||||
{:else if !viewableCollections.length}
|
||||
<div class="placeholder-section m-b-base">
|
||||
<div class="icon">
|
||||
<i class="ri-database-2-line" />
|
||||
</div>
|
||||
<h1 class="m-b-10">Create your first collection to add records!</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-expanded-lg btn-lg"
|
||||
on:click={() => collectionUpsertPanel?.show()}
|
||||
>
|
||||
<i class="ri-add-line" />
|
||||
<span class="txt">Create new collection</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<CollectionsSidebar />
|
||||
|
||||
<main class="page-wrapper">
|
||||
<header class="page-header">
|
||||
<nav class="breadcrumbs">
|
||||
<div class="breadcrumb-item">Collections</div>
|
||||
<div class="breadcrumb-item">{$activeCollection.name}</div>
|
||||
</nav>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-circle"
|
||||
use:tooltip={{ text: "Edit collection", position: "right" }}
|
||||
on:click={() => collectionUpsertPanel?.show($activeCollection)}
|
||||
>
|
||||
<i class="ri-settings-4-line" />
|
||||
</button>
|
||||
|
||||
<div class="btns-group">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline"
|
||||
on:click={() => collectionDocsPanel?.show($activeCollection)}
|
||||
>
|
||||
<i class="ri-code-s-slash-line" />
|
||||
<span class="txt">API Preview</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-expanded" on:click={() => recordPanel?.show()}>
|
||||
<i class="ri-add-line" />
|
||||
<span class="txt">New record</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Searchbar
|
||||
value={filter}
|
||||
autocompleteCollection={$activeCollection}
|
||||
on:submit={(e) => (filter = e.detail)}
|
||||
/>
|
||||
|
||||
<RecordsList
|
||||
bind:this={recordsList}
|
||||
collection={$activeCollection}
|
||||
bind:filter
|
||||
bind:sort
|
||||
on:select={(e) => recordPanel?.show(e?.detail)}
|
||||
/>
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
<CollectionUpsertPanel bind:this={collectionUpsertPanel} />
|
||||
<CollectionDocsPanel bind:this={collectionDocsPanel} />
|
||||
|
||||
<RecordUpsertPanel
|
||||
bind:this={recordPanel}
|
||||
collection={$activeCollection}
|
||||
on:save={() => recordsList?.load()}
|
||||
on:delete={() => recordsList?.load()}
|
||||
/>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script>
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import IdLabel from "@/components/base/IdLabel.svelte";
|
||||
import FormattedDate from "@/components/base/FormattedDate.svelte";
|
||||
import RecordFilePreview from "@/components/records/RecordFilePreview.svelte";
|
||||
|
||||
export let record;
|
||||
export let field;
|
||||
</script>
|
||||
|
||||
<td class="col-type-{field.type} col-field-{field.name}">
|
||||
{#if CommonHelper.isEmpty(record[field.name])}
|
||||
<span class="txt-hint">N/A</span>
|
||||
{:else if field.type === "bool"}
|
||||
<span class="txt">{record[field.name] ? "True" : "False"}</span>
|
||||
{:else if field.type === "url"}
|
||||
<a
|
||||
class="txt-ellipsis"
|
||||
href={record[field.name]}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
use:tooltip={"Open in new tab"}
|
||||
on:click|stopPropagation
|
||||
>
|
||||
{record[field.name]}
|
||||
</a>
|
||||
{:else if field.type === "date"}
|
||||
<FormattedDate date={record[field.name]} />
|
||||
{:else if field.type === "json"}
|
||||
<span class="txt txt-ellipsis">{JSON.stringify(record[field.name])}</span>
|
||||
{:else if field.type === "select"}
|
||||
<div class="inline-flex">
|
||||
{#each CommonHelper.toArray(record[field.name]) as item}
|
||||
<span class="label">{item}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if field.type === "relation" || field.type === "user"}
|
||||
<div class="inline-flex">
|
||||
{#each CommonHelper.toArray(record[field.name]) as item}
|
||||
<IdLabel id={item} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if field.type === "file"}
|
||||
<div class="inline-flex">
|
||||
{#each CommonHelper.toArray(record[field.name]) as filename}
|
||||
<figure class="thumb thumb-sm" use:tooltip={filename}>
|
||||
<RecordFilePreview {record} {filename} />
|
||||
</figure>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="txt txt-ellipsis" title={record[field.name]}>
|
||||
{record[field.name]}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script>
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
|
||||
export let record;
|
||||
export let filename;
|
||||
|
||||
let previewUrl = "";
|
||||
|
||||
$: if (CommonHelper.hasImageExtension(filename)) {
|
||||
previewUrl = ApiClient.Records.getFileUrl(record, `${filename}?thumb=100x100`);
|
||||
}
|
||||
|
||||
function onError() {
|
||||
previewUrl = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if previewUrl}
|
||||
<img src={previewUrl} alt={filename} on:error={onError} />
|
||||
{:else}
|
||||
<i class="ri-file-line" />
|
||||
{/if}
|
||||
@@ -0,0 +1,123 @@
|
||||
<script>
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
|
||||
import RecordSelectOption from "./RecordSelectOption.svelte";
|
||||
|
||||
const uniqueId = "select_" + CommonHelper.randomString(5);
|
||||
|
||||
// original select props
|
||||
export let multiple = false;
|
||||
export let selected = multiple ? [] : undefined;
|
||||
export let keyOfSelected = multiple ? [] : undefined;
|
||||
export let selectPlaceholder = "- Select -";
|
||||
export let optionComponent = RecordSelectOption; // custom component to use for each dropdown option item
|
||||
|
||||
// custom props
|
||||
export let collectionId;
|
||||
|
||||
let list = [];
|
||||
let currentPage = 1;
|
||||
let totalItems = 0;
|
||||
let isLoadingList = false;
|
||||
let isLoadingSelected = false;
|
||||
|
||||
$: if (collectionId) {
|
||||
loadList();
|
||||
}
|
||||
|
||||
$: isLoading = isLoadingList || isLoadingSelected;
|
||||
|
||||
$: canLoadMore = totalItems > list.length;
|
||||
|
||||
loadSelected();
|
||||
|
||||
async function loadSelected() {
|
||||
const selectedIds = CommonHelper.toArray(keyOfSelected);
|
||||
|
||||
if (!collectionId || !selectedIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingSelected = true;
|
||||
|
||||
try {
|
||||
const filters = [];
|
||||
for (const id of selectedIds) {
|
||||
filters.push(`id="${id}"`);
|
||||
}
|
||||
|
||||
selected = await ApiClient.Records.getFullList(collectionId, 200, {
|
||||
sort: "-created",
|
||||
filter: filters.join("||"),
|
||||
$cancelKey: uniqueId + "loadSelected",
|
||||
});
|
||||
|
||||
// add the selected models to the list (if not already)
|
||||
list = CommonHelper.filterDuplicatesByKey(list.concat(selected));
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isLoadingSelected = false;
|
||||
}
|
||||
|
||||
async function loadList(reset = false) {
|
||||
if (!collectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingList = true;
|
||||
|
||||
try {
|
||||
const page = reset ? 1 : currentPage + 1;
|
||||
|
||||
const result = await ApiClient.Records.getList(collectionId, page, 200, {
|
||||
sort: "-created",
|
||||
$cancelKey: uniqueId + "loadList",
|
||||
});
|
||||
|
||||
if (reset) {
|
||||
list = [];
|
||||
}
|
||||
|
||||
list = CommonHelper.filterDuplicatesByKey(list.concat(result.items));
|
||||
currentPage = result.page;
|
||||
totalItems = result.totalItems;
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isLoadingList = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ObjectSelect
|
||||
selectPlaceholder={isLoading ? "Loading..." : selectPlaceholder}
|
||||
items={list}
|
||||
searchable={list.length > 5}
|
||||
selectionKey="id"
|
||||
labelComponent={optionComponent}
|
||||
{optionComponent}
|
||||
{multiple}
|
||||
bind:keyOfSelected
|
||||
bind:selected
|
||||
on:show
|
||||
on:hide
|
||||
class="records-select block-options"
|
||||
{...$$restProps}
|
||||
>
|
||||
<svelte:fragment slot="afterOptions">
|
||||
{#if canLoadMore}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-block btn-sm"
|
||||
class:btn-loading={isLoadingList}
|
||||
class:btn-disabled={isLoadingList}
|
||||
on:click|stopPropagation={() => loadList()}
|
||||
>
|
||||
<span class="txt">Load more</span>
|
||||
</button>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</ObjectSelect>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script>
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
|
||||
const excludedMetaProps = ["id", "created", "updated", "@collectionId", "@collectionName"];
|
||||
|
||||
export let item = {}; // model
|
||||
|
||||
$: meta = extractMeta(item);
|
||||
|
||||
function extractMeta(model) {
|
||||
model = model || {};
|
||||
|
||||
const props = [
|
||||
// prioritized common displayable props
|
||||
"name",
|
||||
"title",
|
||||
"label",
|
||||
"key",
|
||||
"email",
|
||||
"heading",
|
||||
"content",
|
||||
// fallback to the available props
|
||||
...Object.keys(model),
|
||||
];
|
||||
|
||||
for (const prop of props) {
|
||||
if (
|
||||
typeof model[prop] === "string" &&
|
||||
!CommonHelper.isEmpty(model[prop]) &&
|
||||
!excludedMetaProps.includes(prop)
|
||||
) {
|
||||
return prop + ": " + model[prop];
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<i
|
||||
class="ri-information-line link-hint"
|
||||
use:tooltip={{ text: JSON.stringify(item, null, 2), position: "left", class: "code" }}
|
||||
/>
|
||||
|
||||
<div class="content">
|
||||
<div class="block txt-ellipsis">{item.id}</div>
|
||||
{#if meta !== "" && meta !== item.id}
|
||||
<small class="block txt-hint txt-ellipsis">{meta}</small>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
flex-shrink: 1;
|
||||
flex-grow: 0;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,269 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { Record } from "pocketbase";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import { setErrors } from "@/stores/errors";
|
||||
import { confirm } from "@/stores/confirmation";
|
||||
import { addSuccessToast } from "@/stores/toasts";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import Toggler from "@/components/base/Toggler.svelte";
|
||||
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
|
||||
import TextField from "@/components/records/fields/TextField.svelte";
|
||||
import NumberField from "@/components/records/fields/NumberField.svelte";
|
||||
import BoolField from "@/components/records/fields/BoolField.svelte";
|
||||
import EmailField from "@/components/records/fields/EmailField.svelte";
|
||||
import UrlField from "@/components/records/fields/UrlField.svelte";
|
||||
import DateField from "@/components/records/fields/DateField.svelte";
|
||||
import SelectField from "@/components/records/fields/SelectField.svelte";
|
||||
import JsonField from "@/components/records/fields/JsonField.svelte";
|
||||
import FileField from "@/components/records/fields/FileField.svelte";
|
||||
import RelationField from "@/components/records/fields/RelationField.svelte";
|
||||
import UserField from "@/components/records/fields/UserField.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const formId = "record_" + CommonHelper.randomString(5);
|
||||
|
||||
export let collection;
|
||||
|
||||
let recordPanel;
|
||||
let original = null;
|
||||
let record = new Record();
|
||||
let isSaving = false;
|
||||
let confirmClose = false; // prevent close recursion
|
||||
let uploadedFilesMap = {}; // eg.: {"field1":[File1, File2], ...}
|
||||
let deletedFileIndexesMap = {}; // eg.: {"field1":[0, 1], ...}
|
||||
let initialFormHash = "";
|
||||
|
||||
$: hasFileChanges =
|
||||
CommonHelper.hasNonEmptyProps(uploadedFilesMap) ||
|
||||
CommonHelper.hasNonEmptyProps(deletedFileIndexesMap);
|
||||
|
||||
$: hasChanges = hasFileChanges || initialFormHash != calculateFormHash(record);
|
||||
|
||||
$: canSave = record.isNew || hasChanges;
|
||||
|
||||
$: isProfileCollection = collection?.name !== import.meta.env.PB_PROFILE_COLLECTION;
|
||||
|
||||
export function show(model) {
|
||||
load(model);
|
||||
|
||||
confirmClose = true;
|
||||
|
||||
return recordPanel?.show();
|
||||
}
|
||||
|
||||
export function hide() {
|
||||
return recordPanel?.hide();
|
||||
}
|
||||
|
||||
function load(model) {
|
||||
setErrors({}); // reset errors
|
||||
original = model || {};
|
||||
record = model?.clone ? model.clone() : new Record();
|
||||
uploadedFilesMap = {};
|
||||
deletedFileIndexesMap = {};
|
||||
initialFormHash = calculateFormHash(record);
|
||||
}
|
||||
|
||||
function calculateFormHash(m) {
|
||||
return JSON.stringify(m);
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (isSaving || !hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
const data = exportFormData();
|
||||
|
||||
let request;
|
||||
if (record.isNew) {
|
||||
request = ApiClient.Records.create(collection?.id, data);
|
||||
} else {
|
||||
request = ApiClient.Records.update(collection?.id, record.id, data);
|
||||
}
|
||||
|
||||
request
|
||||
.then(async (result) => {
|
||||
addSuccessToast(
|
||||
record.isNew ? "Successfully created record." : "Successfully updated record."
|
||||
);
|
||||
confirmClose = false;
|
||||
hide();
|
||||
dispatch("save", result);
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
})
|
||||
.finally(() => {
|
||||
isSaving = false;
|
||||
});
|
||||
}
|
||||
|
||||
function deleteConfirm() {
|
||||
if (!original?.id) {
|
||||
return; // nothing to delete
|
||||
}
|
||||
|
||||
confirm(`Do you really want to delete the selected record?`, () => {
|
||||
return ApiClient.Records.delete(original["@collectionId"], original.id)
|
||||
.then(() => {
|
||||
hide();
|
||||
addSuccessToast("Successfully deleted record.");
|
||||
dispatch("delete", original);
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function exportFormData() {
|
||||
const data = record?.export() || {};
|
||||
const formData = new FormData();
|
||||
|
||||
const schemaMap = {};
|
||||
for (const field of collection?.schema || []) {
|
||||
schemaMap[field.name] = field;
|
||||
}
|
||||
|
||||
// export base fields
|
||||
for (const key in data) {
|
||||
// skip non-schema fields
|
||||
if (!schemaMap[key]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// normalize nullable values
|
||||
if (typeof data[key] === "undefined") {
|
||||
data[key] = null;
|
||||
}
|
||||
|
||||
CommonHelper.addValueToFormData(formData, key, data[key]);
|
||||
}
|
||||
|
||||
// add uploaded files (if any)
|
||||
for (const key in uploadedFilesMap) {
|
||||
const files = CommonHelper.toArray(uploadedFilesMap[key]);
|
||||
for (const file of files) {
|
||||
formData.append(key, file);
|
||||
}
|
||||
}
|
||||
|
||||
// unset deleted files (if any)
|
||||
for (const key in deletedFileIndexesMap) {
|
||||
const indexes = CommonHelper.toArray(deletedFileIndexesMap[key]);
|
||||
for (const index of indexes) {
|
||||
formData.append(key + "." + index, "");
|
||||
}
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
||||
</script>
|
||||
|
||||
<OverlayPanel
|
||||
bind:this={recordPanel}
|
||||
class="overlay-panel-lg record-panel"
|
||||
beforeHide={() => {
|
||||
if (hasChanges && confirmClose) {
|
||||
confirm("You have unsaved changes. Do you really want to close the panel?", () => {
|
||||
confirmClose = false;
|
||||
hide();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
on:hide
|
||||
on:show
|
||||
>
|
||||
<svelte:fragment slot="header">
|
||||
<h4>
|
||||
{record.isNew ? "New" : "Edit"}
|
||||
{collection.name} record
|
||||
</h4>
|
||||
|
||||
{#if !record.isNew && isProfileCollection}
|
||||
<div class="flex-fill" />
|
||||
<button type="button" class="btn btn-sm btn-circle btn-secondary">
|
||||
<div class="content">
|
||||
<i class="ri-more-line" />
|
||||
<Toggler class="dropdown dropdown-right m-t-5">
|
||||
<div tabindex="0" class="dropdown-item closable" on:click={() => deleteConfirm()}>
|
||||
<i class="ri-delete-bin-7-line" />
|
||||
<span class="txt">Delete</span>
|
||||
</div>
|
||||
</Toggler>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<form id={formId} class="block" on:submit|preventDefault={save}>
|
||||
{#if !record.isNew}
|
||||
<Field class="form-field disabled" name="id" let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class={CommonHelper.getFieldTypeIcon("primary")} />
|
||||
<span class="txt">id</span>
|
||||
<span class="flex-fill" />
|
||||
</label>
|
||||
<input type="text" id={uniqueId} value={record.id} disabled />
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
{#each collection?.schema || [] as field (field.name)}
|
||||
{#if field.type === "text"}
|
||||
<TextField {field} bind:value={record[field.name]} />
|
||||
{:else if field.type === "number"}
|
||||
<NumberField {field} bind:value={record[field.name]} />
|
||||
{:else if field.type === "bool"}
|
||||
<BoolField {field} bind:value={record[field.name]} />
|
||||
{:else if field.type === "email"}
|
||||
<EmailField {field} bind:value={record[field.name]} />
|
||||
{:else if field.type === "url"}
|
||||
<UrlField {field} bind:value={record[field.name]} />
|
||||
{:else if field.type === "date"}
|
||||
<DateField {field} bind:value={record[field.name]} />
|
||||
{:else if field.type === "select"}
|
||||
<SelectField {field} bind:value={record[field.name]} />
|
||||
{:else if field.type === "json"}
|
||||
<JsonField {field} bind:value={record[field.name]} />
|
||||
{:else if field.type === "file"}
|
||||
<FileField
|
||||
{field}
|
||||
{record}
|
||||
bind:value={record[field.name]}
|
||||
bind:uploadedFiles={uploadedFilesMap[field.name]}
|
||||
bind:deletedFileIndexes={deletedFileIndexesMap[field.name]}
|
||||
/>
|
||||
{:else if field.type === "relation"}
|
||||
<RelationField {field} bind:value={record[field.name]} />
|
||||
{:else if field.type === "user"}
|
||||
<UserField {field} bind:value={record[field.name]} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="block txt-center txt-disabled">
|
||||
<h5>No custom fields to be set</h5>
|
||||
</div>
|
||||
{/each}
|
||||
</form>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<button type="button" class="btn btn-secondary" disabled={isSaving} on:click={() => hide()}>
|
||||
<span class="txt">Cancel</span>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form={formId}
|
||||
class="btn btn-expanded"
|
||||
class:btn-loading={isSaving}
|
||||
disabled={!canSave || isSaving}
|
||||
>
|
||||
<span class="txt">{record.isNew ? "Create" : "Save changes"}</span>
|
||||
</button>
|
||||
</svelte:fragment>
|
||||
</OverlayPanel>
|
||||
@@ -0,0 +1,312 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { fly } from "svelte/transition";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import { confirm } from "@/stores/confirmation";
|
||||
import { addSuccessToast } from "@/stores/toasts";
|
||||
import SortHeader from "@/components/base/SortHeader.svelte";
|
||||
import FormattedDate from "@/components/base/FormattedDate.svelte";
|
||||
import IdLabel from "@/components/base/IdLabel.svelte";
|
||||
import RecordFieldCell from "@/components/records/RecordFieldCell.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let collection;
|
||||
export let sort = "";
|
||||
export let filter = "";
|
||||
|
||||
let records = [];
|
||||
let currentPage = 1;
|
||||
let totalRecords = 0;
|
||||
let bulkSelected = {};
|
||||
let isLoading = true;
|
||||
let isDeleting = false;
|
||||
|
||||
$: if (collection && collection.id && sort !== -1 && filter !== -1) {
|
||||
clearList();
|
||||
load(1);
|
||||
}
|
||||
|
||||
$: canLoadMore = totalRecords > records.length;
|
||||
|
||||
$: fields = collection?.schema || [];
|
||||
|
||||
$: totalBulkSelected = Object.keys(bulkSelected).length;
|
||||
|
||||
$: areAllRecordsSelected = records.length && totalBulkSelected === records.length;
|
||||
|
||||
export async function load(page = 1) {
|
||||
if (!collection?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
|
||||
return ApiClient.Records.getList(collection.id, page, 50, {
|
||||
sort: sort,
|
||||
filter: filter,
|
||||
})
|
||||
.then((result) => {
|
||||
if (page <= 1) {
|
||||
clearList();
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
records = records.concat(result.items);
|
||||
currentPage = result.page;
|
||||
totalRecords = result.totalItems;
|
||||
|
||||
dispatch("load", records);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err !== null) {
|
||||
isLoading = false;
|
||||
console.warn(err);
|
||||
clearList();
|
||||
ApiClient.errorResponseHandler(err, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clearList() {
|
||||
records = [];
|
||||
currentPage = 1;
|
||||
totalRecords = 0;
|
||||
bulkSelected = {};
|
||||
}
|
||||
|
||||
function toggleSelectAllRecords() {
|
||||
if (areAllRecordsSelected) {
|
||||
deselectAllRecords();
|
||||
} else {
|
||||
selectAllRecords();
|
||||
}
|
||||
}
|
||||
|
||||
function deselectAllRecords() {
|
||||
bulkSelected = {};
|
||||
}
|
||||
|
||||
function selectAllRecords() {
|
||||
for (const record of records) {
|
||||
bulkSelected[record.id] = record;
|
||||
}
|
||||
bulkSelected = bulkSelected;
|
||||
}
|
||||
|
||||
function toggleSelectRecord(record) {
|
||||
if (!bulkSelected[record.id]) {
|
||||
bulkSelected[record.id] = record;
|
||||
} else {
|
||||
delete bulkSelected[record.id];
|
||||
}
|
||||
|
||||
bulkSelected = bulkSelected; // trigger reactivity
|
||||
}
|
||||
|
||||
function deleteSelectedConfirm() {
|
||||
const msg = `Do you really want to delete the selected ${
|
||||
totalBulkSelected === 1 ? "record" : "records"
|
||||
}?`;
|
||||
|
||||
confirm(msg, deleteSelected);
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
if (isDeleting || !totalBulkSelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
let promises = [];
|
||||
for (const recordId of Object.keys(bulkSelected)) {
|
||||
promises.push(ApiClient.Records.delete(collection?.id, recordId));
|
||||
}
|
||||
|
||||
isDeleting = true;
|
||||
|
||||
return Promise.all(promises)
|
||||
.then(() => {
|
||||
addSuccessToast(
|
||||
`Successfully deleted the selected ${totalBulkSelected === 1 ? "record" : "records"}.`
|
||||
);
|
||||
deselectAllRecords();
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
})
|
||||
.finally(() => {
|
||||
isDeleting = false;
|
||||
|
||||
// always reload because some of the records may not be deletable
|
||||
return load();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="table" class:table-loading={isLoading}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bulk-select-col min-width">
|
||||
<div class="form-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="checkbox_0"
|
||||
disabled={!records.length}
|
||||
checked={areAllRecordsSelected}
|
||||
on:change={() => toggleSelectAllRecords()}
|
||||
/>
|
||||
<label for="checkbox_0" />
|
||||
</div>
|
||||
</th>
|
||||
<SortHeader class="col-type-text col-field-id" name="id" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("primary")} />
|
||||
<span class="txt">id</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
{#each fields as field (field.name)}
|
||||
<SortHeader
|
||||
class="col-type-{field.type} col-field-{field.name}"
|
||||
name={field.name}
|
||||
bind:sort
|
||||
>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
|
||||
<span class="txt">{field.name}</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
{/each}
|
||||
<SortHeader class="col-type-date col-field-created" name="created" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("date")} />
|
||||
<span class="txt">created</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
<SortHeader class="col-type-date col-field-updated" name="updated" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("date")} />
|
||||
<span class="txt">updated</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
<th class="col-type-action min-width" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each records as record (record.id)}
|
||||
<tr
|
||||
tabindex="0"
|
||||
class="row-handle"
|
||||
on:click={() => dispatch("select", record)}
|
||||
on:keydown={(e) => {
|
||||
if (e.code === "Enter") {
|
||||
e.preventDefault();
|
||||
dispatch("select", record);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td class="bulk-select-col min-width">
|
||||
<div class="form-field" on:click|stopPropagation>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="checkbox_{record.id}"
|
||||
checked={bulkSelected[record.id]}
|
||||
on:change={() => toggleSelectRecord(record)}
|
||||
/>
|
||||
<label for="checkbox_{record.id}" />
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="col-type-text col-field-id">
|
||||
<IdLabel id={record.id} />
|
||||
</td>
|
||||
|
||||
{#each fields as field (field.name)}
|
||||
<RecordFieldCell {record} {field} />
|
||||
{/each}
|
||||
|
||||
<td class="col-type-date col-field-created">
|
||||
<FormattedDate date={record.created} />
|
||||
</td>
|
||||
|
||||
<td class="col-type-date col-field-updated">
|
||||
<FormattedDate date={record.updated} />
|
||||
</td>
|
||||
|
||||
<td class="col-type-action min-width">
|
||||
<i class="ri-arrow-right-line" />
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#if isLoading}
|
||||
<tr>
|
||||
<td colspan="99" class="p-xs">
|
||||
<span class="skeleton-loader" />
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="99" class="txt-center txt-hint p-xs">
|
||||
<h6>No records found.</h6>
|
||||
{#if filter?.length}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-hint btn-expanded m-t-sm"
|
||||
on:click={() => (filter = "")}
|
||||
>
|
||||
<span class="txt">Clear filters</span>
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if records.length}
|
||||
<small class="block txt-hint txt-right m-t-sm">Showing {records.length} of {totalRecords}</small>
|
||||
{/if}
|
||||
|
||||
{#if records.length && canLoadMore}
|
||||
<div class="block txt-center m-t-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-lg btn-secondary btn-expanded"
|
||||
class:btn-loading={isLoading}
|
||||
class:btn-disabled={isLoading}
|
||||
on:click={() => load(currentPage + 1)}
|
||||
>
|
||||
<span class="txt">Load more ({totalRecords - records.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if totalBulkSelected}
|
||||
<div class="bulkbar" transition:fly|local={{ duration: 150, y: 5 }}>
|
||||
<div class="txt">
|
||||
Selected <strong>{totalBulkSelected}</strong>
|
||||
{totalBulkSelected === 1 ? "record" : "records"}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-secondary btn-outline p-l-5 p-r-5"
|
||||
class:btn-disabled={isDeleting}
|
||||
on:click={() => deselectAllRecords()}
|
||||
>
|
||||
<span class="txt">Reset</span>
|
||||
</button>
|
||||
<div class="flex-fill" />
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-secondary btn-danger"
|
||||
class:btn-loading={isDeleting}
|
||||
class:btn-disabled={isDeleting}
|
||||
on:click={() => deleteSelectedConfirm()}
|
||||
>
|
||||
<span class="txt">Delete selected</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import { SchemaField } from "pocketbase";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
export let field = new SchemaField();
|
||||
export let value = false;
|
||||
</script>
|
||||
|
||||
<Field class="form-field form-field-toggle {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} bind:checked={value} />
|
||||
<label for={uniqueId}>{field.name}</label>
|
||||
</Field>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script>
|
||||
import { SchemaField } from "pocketbase";
|
||||
import Flatpickr from "svelte-flatpickr";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
export let field = new SchemaField();
|
||||
export let value = undefined;
|
||||
</script>
|
||||
|
||||
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
|
||||
<span class="txt">{field.name} (UTC)</span>
|
||||
</label>
|
||||
<Flatpickr
|
||||
id={uniqueId}
|
||||
options={CommonHelper.defaultFlatpickrOptions()}
|
||||
{value}
|
||||
bind:formattedValue={value}
|
||||
/>
|
||||
</Field>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
import { SchemaField } from "pocketbase";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
export let field = new SchemaField();
|
||||
export let value = undefined;
|
||||
</script>
|
||||
|
||||
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
|
||||
<span class="txt">{field.name}</span>
|
||||
</label>
|
||||
<input type="email" id={uniqueId} required={field.required} bind:value />
|
||||
</Field>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script>
|
||||
import { SchemaField } from "pocketbase";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import UploadedFilePreview from "@/components/base/UploadedFilePreview.svelte";
|
||||
import PreviewPopup from "@/components/base/PreviewPopup.svelte";
|
||||
import RecordFilePreview from "@/components/records/RecordFilePreview.svelte";
|
||||
|
||||
export let record;
|
||||
export let value = null;
|
||||
export let uploadedFiles = []; // Array<File> array
|
||||
export let deletedFileIndexes = []; // Array<int> array
|
||||
export let field = new SchemaField();
|
||||
|
||||
let fileInput;
|
||||
let previewPopup;
|
||||
let filesListElem;
|
||||
|
||||
// normalize uploadedFiles type
|
||||
$: if (!Array.isArray(uploadedFiles)) {
|
||||
uploadedFiles = CommonHelper.toArray(uploadedFiles);
|
||||
}
|
||||
|
||||
// normalize delited file indexes
|
||||
$: if (!Array.isArray(deletedFileIndexes)) {
|
||||
deletedFileIndexes = CommonHelper.toArray(deletedFileIndexes);
|
||||
}
|
||||
|
||||
$: isMultiple = field.options?.maxSelect > 1;
|
||||
|
||||
$: if (typeof value === "undefined" || value === null) {
|
||||
value = isMultiple ? [] : null;
|
||||
}
|
||||
|
||||
$: valueAsArray = CommonHelper.toArray(value);
|
||||
|
||||
$: maxReached =
|
||||
(valueAsArray.length || uploadedFiles.length) &&
|
||||
field.options?.maxSelect <= valueAsArray.length + uploadedFiles.length - deletedFileIndexes.length;
|
||||
|
||||
$: if (uploadedFiles !== -1 || deletedFileIndexes !== -1) {
|
||||
triggerListChange();
|
||||
}
|
||||
|
||||
function restoreExistingFile(valueIndex) {
|
||||
CommonHelper.removeByValue(deletedFileIndexes, valueIndex);
|
||||
deletedFileIndexes = deletedFileIndexes;
|
||||
}
|
||||
|
||||
function removeExistingFile(valueIndex) {
|
||||
CommonHelper.pushUnique(deletedFileIndexes, valueIndex);
|
||||
deletedFileIndexes = deletedFileIndexes;
|
||||
}
|
||||
|
||||
function removeNewFile(index) {
|
||||
if (!CommonHelper.isEmpty(uploadedFiles[index])) {
|
||||
uploadedFiles.splice(index, 1);
|
||||
}
|
||||
uploadedFiles = uploadedFiles;
|
||||
}
|
||||
|
||||
// emulate native change event
|
||||
function triggerListChange() {
|
||||
filesListElem?.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: { value, uploadedFiles, deletedFileIndexes },
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field class="form-field form-field-file {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
|
||||
<span class="txt">{field.name}</span>
|
||||
</label>
|
||||
|
||||
<div bind:this={filesListElem} class="files-list">
|
||||
{#each valueAsArray as filename, i (filename)}
|
||||
<div class="list-item">
|
||||
<figute
|
||||
class="thumb"
|
||||
class:fade={deletedFileIndexes.includes(i)}
|
||||
class:link-fade={CommonHelper.hasImageExtension(filename)}
|
||||
title={CommonHelper.hasImageExtension(filename) ? "Preview" : ""}
|
||||
on:click={() =>
|
||||
CommonHelper.hasImageExtension(filename)
|
||||
? previewPopup?.show(ApiClient.Records.getFileUrl(record, filename))
|
||||
: false}
|
||||
>
|
||||
<RecordFilePreview {record} {filename} />
|
||||
</figute>
|
||||
<a
|
||||
href={ApiClient.Records.getFileUrl(record, filename)}
|
||||
class="filename"
|
||||
class:txt-strikethrough={deletedFileIndexes.includes(i)}
|
||||
title={"Download " + filename}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
download
|
||||
>
|
||||
/.../{filename}
|
||||
</a>
|
||||
|
||||
{#if deletedFileIndexes.includes(i)}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger btn-secondary"
|
||||
on:click={() => restoreExistingFile(i)}
|
||||
>
|
||||
<span class="txt">Restore</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm btn-circle btn-remove txt-hint"
|
||||
use:tooltip={"Remove file"}
|
||||
on:click={() => removeExistingFile(i)}
|
||||
>
|
||||
<i class="ri-close-line" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#each uploadedFiles as file, i}
|
||||
<div class="list-item">
|
||||
<figute class="thumb">
|
||||
<UploadedFilePreview {file} />
|
||||
</figute>
|
||||
<div class="filename" title={file.name}>
|
||||
<small class="label label-success m-r-5">New</small>
|
||||
<span class="txt">{file.name}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm btn-circle btn-remove"
|
||||
use:tooltip={"Remove file"}
|
||||
on:click={() => removeNewFile(i)}
|
||||
>
|
||||
<i class="ri-close-line" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if !maxReached}
|
||||
<div class="list-item btn-list-item">
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
class="hidden"
|
||||
multiple={isMultiple}
|
||||
on:change={() => {
|
||||
for (let file of fileInput.files) {
|
||||
uploadedFiles.push(file);
|
||||
}
|
||||
uploadedFiles = uploadedFiles;
|
||||
fileInput.value = null; // reset
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm btn-block"
|
||||
on:click={() => fileInput?.click()}
|
||||
>
|
||||
<i class="ri-upload-cloud-line" />
|
||||
<span class="txt">Upload new file</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<PreviewPopup bind:this={previewPopup} />
|
||||
@@ -0,0 +1,22 @@
|
||||
<script>
|
||||
import { SchemaField } from "pocketbase";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
export let field = new SchemaField();
|
||||
export let value = undefined;
|
||||
|
||||
$: if (typeof value !== "undefined" && typeof value !== "string" && value !== null) {
|
||||
// the JSON field support both js primitives and encoded JSON string
|
||||
// so we are normalizing the value to only a string
|
||||
value = JSON.stringify(value, null, 2);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
|
||||
<span class="txt">{field.name}</span>
|
||||
</label>
|
||||
<textarea id={uniqueId} required={field.required} class="txt-mono txt-sm" bind:value />
|
||||
</Field>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script>
|
||||
import { SchemaField } from "pocketbase";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
export let field = new SchemaField();
|
||||
export let value = undefined;
|
||||
</script>
|
||||
|
||||
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
|
||||
<span class="txt">{field.name}</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id={uniqueId}
|
||||
required={field.required}
|
||||
min={field.options?.min}
|
||||
max={field.options?.max}
|
||||
bind:value
|
||||
/>
|
||||
</Field>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script>
|
||||
import { SchemaField } from "pocketbase";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import RecordSelect from "@/components/records/RecordSelect.svelte";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
export let field = new SchemaField();
|
||||
export let value = undefined;
|
||||
|
||||
$: isMultiple = field.options?.maxSelect > 1;
|
||||
|
||||
$: if (isMultiple && Array.isArray(value) && value.length > field.options.maxSelect) {
|
||||
value = value.slice(field.options.maxSelect - 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
|
||||
<span class="txt">{field.name}</span>
|
||||
</label>
|
||||
<RecordSelect
|
||||
toggle
|
||||
id={uniqueId}
|
||||
multiple={isMultiple}
|
||||
collectionId={field.options?.collectionId}
|
||||
bind:keyOfSelected={value}
|
||||
/>
|
||||
{#if field.options?.maxSelect > 1}
|
||||
<div class="help-block">Select up to {field.options.maxSelect} items.</div>
|
||||
{/if}
|
||||
</Field>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script>
|
||||
import { SchemaField } from "pocketbase";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import Select from "@/components/base/Select.svelte";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
export let field = new SchemaField();
|
||||
export let value = undefined;
|
||||
|
||||
$: isMultiple = field.options?.maxSelect > 1;
|
||||
|
||||
$: if (typeof value === "undefined") {
|
||||
value = isMultiple ? [] : null;
|
||||
}
|
||||
|
||||
$: if (isMultiple && Array.isArray(value) && value.length > field.options.maxSelect) {
|
||||
value = value.slice(value.length - field.options.maxSelect);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
|
||||
<span class="txt">{field.name}</span>
|
||||
</label>
|
||||
<Select
|
||||
id={uniqueId}
|
||||
toggle={!field.required || isMultiple}
|
||||
multiple={isMultiple}
|
||||
items={field.options?.values}
|
||||
searchable={field.options?.values > 5}
|
||||
bind:selected={value}
|
||||
/>
|
||||
{#if field.options?.maxSelect > 1}
|
||||
<div class="help-block">Select up to {field.options.maxSelect} items.</div>
|
||||
{/if}
|
||||
</Field>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script>
|
||||
import { SchemaField } from "pocketbase";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import AutoExpandTextarea from "@/components/base/AutoExpandTextarea.svelte";
|
||||
|
||||
export let field = new SchemaField();
|
||||
export let value = undefined;
|
||||
</script>
|
||||
|
||||
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
|
||||
<span class="txt">{field.name}</span>
|
||||
</label>
|
||||
<AutoExpandTextarea id={uniqueId} required={field.required} bind:value />
|
||||
</Field>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
import { SchemaField } from "pocketbase";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
export let field = new SchemaField();
|
||||
export let value = undefined;
|
||||
</script>
|
||||
|
||||
<Field class="form-field {field.required ? 'required' : ''}" name={field.name} let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
|
||||
<span class="txt">{field.name}</span>
|
||||
</label>
|
||||
<input type="url" id={uniqueId} required={field.required} bind:value />
|
||||
</Field>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script>
|
||||
import { SchemaField } from "pocketbase";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import UserSelect from "@/components/users/UserSelect.svelte";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
export let field = new SchemaField();
|
||||
export let value = undefined;
|
||||
|
||||
// to prevent accidental changes, disable editing system user field values from the UI
|
||||
$: isDisabled = !CommonHelper.isEmpty(value) && field.system;
|
||||
|
||||
$: isMultiple = field.options?.maxSelect > 1;
|
||||
|
||||
$: if (isMultiple && Array.isArray(value) && value.length > field.options.maxSelect) {
|
||||
value = value.slice(field.options.maxSelect - 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field
|
||||
class="form-field {field.required ? 'required' : ''} {isDisabled ? 'disabled' : ''}"
|
||||
name={field.name}
|
||||
let:uniqueId
|
||||
>
|
||||
<label for={uniqueId}>
|
||||
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
|
||||
<span class="txt">{field.name}</span>
|
||||
</label>
|
||||
<UserSelect toggle id={uniqueId} multiple={isMultiple} disabled={isDisabled} bind:keyOfSelected={value} />
|
||||
{#if field.options?.maxSelect > 1}
|
||||
<div class="help-block">Select up to {field.options.maxSelect} users.</div>
|
||||
{/if}
|
||||
</Field>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script>
|
||||
import { scale, slide } from "svelte/transition";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import { errors, removeError } from "@/stores/errors";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import Accordion from "@/components/base/Accordion.svelte";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import RedactedPasswordInput from "@/components/base/RedactedPasswordInput.svelte";
|
||||
|
||||
export let key;
|
||||
export let title;
|
||||
export let icon = "";
|
||||
export let config = {};
|
||||
export let showSelfHostedFields = false;
|
||||
|
||||
let accordion;
|
||||
|
||||
$: hasErrors = !CommonHelper.isEmpty(CommonHelper.getNestedVal($errors, key));
|
||||
|
||||
$: if (!config.enabled) {
|
||||
removeError(key);
|
||||
}
|
||||
|
||||
export function expand() {
|
||||
accordion?.expand();
|
||||
}
|
||||
|
||||
export function collapse() {
|
||||
accordion?.collapse();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Accordion bind:this={accordion} on:expand on:collapse on:toggle {...$$restProps}>
|
||||
<svelte:fragment slot="header">
|
||||
<div class="inline-flex">
|
||||
{#if icon}
|
||||
<i class={icon} />
|
||||
{/if}
|
||||
<span class="txt">{title}</span>
|
||||
</div>
|
||||
|
||||
{#if config.enabled}
|
||||
<span class="label label-success">Enabled</span>
|
||||
{:else}
|
||||
<span class="label label-hint">Disabled</span>
|
||||
{/if}
|
||||
|
||||
<div class="flex-fill" />
|
||||
|
||||
{#if hasErrors}
|
||||
<i
|
||||
class="ri-error-warning-fill txt-danger"
|
||||
transition:scale={{ duration: 150, start: 0.7 }}
|
||||
use:tooltip={{ text: "Has errors", position: "left" }}
|
||||
/>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<Field class="form-field form-field-toggle m-b-0" name="{key}.enabled" let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} bind:checked={config.enabled} />
|
||||
<label for={uniqueId}>Enable</label>
|
||||
</Field>
|
||||
|
||||
{#if config.enabled}
|
||||
<div class="grid" transition:slide|local={{ duration: 200 }}>
|
||||
<div class="col-12 spacing" />
|
||||
<div class="col-lg-6">
|
||||
<Field class="form-field required" name="{key}.clientId" let:uniqueId>
|
||||
<label for={uniqueId}>Client ID</label>
|
||||
<input type="text" id={uniqueId} bind:value={config.clientId} required />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<Field class="form-field required" name="{key}.clientSecret" let:uniqueId>
|
||||
<label for={uniqueId}>Client Secret</label>
|
||||
<RedactedPasswordInput bind:value={config.clientSecret} id={uniqueId} required />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-12">
|
||||
<Field class="form-field" name="{key}.allowRegistrations" let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} bind:checked={config.allowRegistrations} />
|
||||
<label for={uniqueId}>Allow registration for new users</label>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{#if showSelfHostedFields}
|
||||
<div class="col-lg-12">
|
||||
<div class="section-title">Optional endpoints (if you self host the OAUTH2 service)</div>
|
||||
<div class="grid">
|
||||
<div class="col-lg-4">
|
||||
<Field class="form-field" name="{key}.authUrl" let:uniqueId>
|
||||
<label for={uniqueId}>Custom Auth URL</label>
|
||||
<input type="url" id={uniqueId} bind:value={config.authUrl} />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<Field class="form-field" name="{key}.tokenUrl" let:uniqueId>
|
||||
<label for={uniqueId}>Custom Token URL</label>
|
||||
<input type="text" id={uniqueId} bind:value={config.tokenUrl} />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<Field class="form-field" name="{key}.userApiUrl" let:uniqueId>
|
||||
<label for={uniqueId}>Custom User API URL</label>
|
||||
<input type="text" id={uniqueId} bind:value={config.userApiUrl} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Accordion>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script>
|
||||
import { scale, slide } from "svelte/transition";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import { errors } from "@/stores/errors";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import Accordion from "@/components/base/Accordion.svelte";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import MultipleValueInput from "@/components/base/MultipleValueInput.svelte";
|
||||
|
||||
export let config = {}; // EmailAuthConfig
|
||||
|
||||
let accordion;
|
||||
|
||||
$: hasErrors = !CommonHelper.isEmpty($errors?.emailPassword);
|
||||
|
||||
export function expand() {
|
||||
accordion?.expand();
|
||||
}
|
||||
|
||||
export function collapse() {
|
||||
accordion?.collapse();
|
||||
}
|
||||
|
||||
export function collapseSiblings() {
|
||||
accordion?.collapseSiblings();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Accordion bind:this={accordion} on:expand on:collapse on:toggle {...$$restProps}>
|
||||
<svelte:fragment slot="header">
|
||||
<div class="inline-flex">
|
||||
<i class="ri-mail-lock-line" />
|
||||
<span class="txt">Email/Password</span>
|
||||
</div>
|
||||
|
||||
{#if config.enabled}
|
||||
<span class="label label-success">Enabled</span>
|
||||
{:else}
|
||||
<span class="label">Disabled</span>
|
||||
{/if}
|
||||
|
||||
<div class="flex-fill" />
|
||||
|
||||
{#if hasErrors}
|
||||
<i
|
||||
class="ri-error-warning-fill txt-danger"
|
||||
transition:scale={{ duration: 150, start: 0.7 }}
|
||||
use:tooltip={{ text: "Has errors", position: "left" }}
|
||||
/>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<Field class="form-field form-field-toggle m-b-0" name="emailPassword.enabled" let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} bind:checked={config.enabled} />
|
||||
<label for={uniqueId}>Enable</label>
|
||||
</Field>
|
||||
|
||||
{#if config.enabled}
|
||||
<div class="grid" transition:slide|local={{ duration: 150 }}>
|
||||
<div class="col-sm-12 m-t-sm">
|
||||
<Field class="form-field required" name="emailPassword.minPasswordLength" let:uniqueId>
|
||||
<label for={uniqueId}>Minimum password length</label>
|
||||
<input
|
||||
type="number"
|
||||
id={uniqueId}
|
||||
required
|
||||
min="5"
|
||||
max="200"
|
||||
bind:value={config.minPasswordLength}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<Field
|
||||
class="form-field {!CommonHelper.isEmpty(config.onlyDomains) ? 'disabled' : ''}"
|
||||
name="emailPassword.exceptDomains"
|
||||
let:uniqueId
|
||||
>
|
||||
<label for={uniqueId}>
|
||||
<span class="txt">Except domains</span>
|
||||
<i
|
||||
class="ri-information-line link-hint"
|
||||
use:tooltip={{
|
||||
text: 'Email domains that are NOT allowed to sign up. \n This field is disabled if "Only domains" is set.',
|
||||
position: "top",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<MultipleValueInput
|
||||
id={uniqueId}
|
||||
disabled={!CommonHelper.isEmpty(config.onlyDomains)}
|
||||
bind:value={config.exceptDomains}
|
||||
/>
|
||||
<div class="help-block">Use comma as separator.</div>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<Field
|
||||
class="form-field {!CommonHelper.isEmpty(config.exceptDomains) ? 'disabled' : ''}"
|
||||
name="emailPassword.onlyDomains"
|
||||
let:uniqueId
|
||||
>
|
||||
<label for="{uniqueId}.config.onlyDomains">
|
||||
<span class="txt">Only domains</span>
|
||||
<i
|
||||
class="ri-information-line link-hint"
|
||||
use:tooltip={{
|
||||
text: 'Email domains that are ONLY allowed to sign up. \n This field is disabled if "Except domains" is set.',
|
||||
position: "top",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<MultipleValueInput
|
||||
id="{uniqueId}.config.onlyDomains"
|
||||
disabled={!CommonHelper.isEmpty(config.exceptDomains)}
|
||||
bind:value={config.onlyDomains}
|
||||
/>
|
||||
<div class="help-block">Use comma as separator.</div>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Accordion>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script>
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import { addSuccessToast } from "@/stores/toasts";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
|
||||
|
||||
let formSettings = {};
|
||||
let isLoading = false;
|
||||
let isSaving = false;
|
||||
let initialHash = "";
|
||||
|
||||
$: hasChanges = initialHash != JSON.stringify(formSettings);
|
||||
|
||||
CommonHelper.setDocumentTitle("Application settings");
|
||||
|
||||
loadSettings();
|
||||
|
||||
async function loadSettings() {
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const settings = (await ApiClient.Settings.getAll()) || {};
|
||||
init(settings);
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving || !hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
try {
|
||||
const settings = await ApiClient.Settings.update(CommonHelper.filterRedactedProps(formSettings));
|
||||
init(settings);
|
||||
addSuccessToast("Successfully saved application settings.");
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
function init(settings = {}) {
|
||||
formSettings = {
|
||||
meta: settings?.meta || {},
|
||||
logs: settings?.logs || {},
|
||||
};
|
||||
initialHash = JSON.stringify(formSettings);
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsSidebar />
|
||||
|
||||
<main class="page-wrapper">
|
||||
<header class="page-header">
|
||||
<nav class="breadcrumbs">
|
||||
<div class="breadcrumb-item">Settings</div>
|
||||
<div class="breadcrumb-item">Application</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="wrapper">
|
||||
<form class="panel" autocomplete="off" on:submit|preventDefault={save}>
|
||||
{#if isLoading}
|
||||
<div class="loader" />
|
||||
{:else}
|
||||
<div class="grid">
|
||||
<div class="col-lg-6">
|
||||
<Field class="form-field required" name="meta.appName" let:uniqueId>
|
||||
<label for={uniqueId}>Application name</label>
|
||||
<input
|
||||
type="text"
|
||||
id={uniqueId}
|
||||
required
|
||||
bind:value={formSettings.meta.appName}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<Field class="form-field required" name="meta.appUrl" let:uniqueId>
|
||||
<label for={uniqueId}>Application url</label>
|
||||
<input type="text" id={uniqueId} required bind:value={formSettings.meta.appUrl} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field class="form-field required" name="logs.maxDays" let:uniqueId>
|
||||
<label for={uniqueId}>Logs max days retention</label>
|
||||
<input type="number" id={uniqueId} required bind:value={formSettings.logs.maxDays} />
|
||||
</Field>
|
||||
|
||||
<div class="col-lg-12 flex">
|
||||
<div class="flex-fill" />
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-expanded"
|
||||
class:btn-loading={isSaving}
|
||||
disabled={!hasChanges || isSaving}
|
||||
on:click={() => save()}
|
||||
>
|
||||
<span class="txt">Save changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script>
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import { setErrors } from "@/stores/errors";
|
||||
import { addSuccessToast } from "@/stores/toasts";
|
||||
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
|
||||
import EmailAuthAccordion from "@/components/settings/EmailAuthAccordion.svelte";
|
||||
import AuthProviderAccordion from "@/components/settings/AuthProviderAccordion.svelte";
|
||||
|
||||
let emailAuthAccordion;
|
||||
let authSettings = {};
|
||||
let isLoading = false;
|
||||
let isSaving = false;
|
||||
let initialHash = "";
|
||||
|
||||
$: hasChanges = initialHash != JSON.stringify(authSettings);
|
||||
|
||||
CommonHelper.setDocumentTitle("Auth providers");
|
||||
|
||||
loadSettings();
|
||||
|
||||
async function loadSettings() {
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const result = (await ApiClient.Settings.getAll()) || {};
|
||||
initSettings(result);
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving || !hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
try {
|
||||
const result = await ApiClient.Settings.update(CommonHelper.filterRedactedProps(authSettings));
|
||||
initSettings(result);
|
||||
setErrors({});
|
||||
emailAuthAccordion?.collapseSiblings();
|
||||
addSuccessToast("Successfully updated auth providers.");
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
function initSettings(data) {
|
||||
data = data || {};
|
||||
|
||||
authSettings = {};
|
||||
authSettings.emailAuth = Object.assign({ enabled: true }, data.emailAuth);
|
||||
|
||||
const providers = ["googleAuth", "facebookAuth", "githubAuth", "gitlabAuth"];
|
||||
for (const provider of providers) {
|
||||
authSettings[provider] = Object.assign(
|
||||
{ enabled: false, allowRegistrations: true },
|
||||
data[provider]
|
||||
);
|
||||
}
|
||||
|
||||
initialHash = JSON.stringify(authSettings);
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsSidebar />
|
||||
|
||||
<main class="page-wrapper">
|
||||
<header class="page-header">
|
||||
<nav class="breadcrumbs">
|
||||
<div class="breadcrumb-item">Settings</div>
|
||||
<div class="breadcrumb-item">Auth providers</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="wrapper">
|
||||
<form class="panel" autocomplete="off" on:submit|preventDefault={save}>
|
||||
<h6 class="m-b-base">Manage the allowed users sign-in/sign-up methods.</h6>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loader" />
|
||||
{:else}
|
||||
<div class="accordions">
|
||||
<EmailAuthAccordion
|
||||
bind:this={emailAuthAccordion}
|
||||
single
|
||||
bind:config={authSettings.emailAuth}
|
||||
/>
|
||||
<AuthProviderAccordion
|
||||
single
|
||||
key="googleAuth"
|
||||
title="Google"
|
||||
icon="ri-google-line"
|
||||
bind:config={authSettings.googleAuth}
|
||||
/>
|
||||
<AuthProviderAccordion
|
||||
single
|
||||
key="facebookAuth"
|
||||
title="Facebook"
|
||||
icon="ri-facebook-line"
|
||||
bind:config={authSettings.facebookAuth}
|
||||
/>
|
||||
<AuthProviderAccordion
|
||||
single
|
||||
key="githubAuth"
|
||||
title="GitHub"
|
||||
icon="ri-github-line"
|
||||
bind:config={authSettings.githubAuth}
|
||||
/>
|
||||
<AuthProviderAccordion
|
||||
single
|
||||
key="gitlabAuth"
|
||||
title="GitLab"
|
||||
icon="ri-gitlab-line"
|
||||
showSelfHostedFields
|
||||
bind:config={authSettings.gitlabAuth}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex m-t-base">
|
||||
<div class="flex-fill" />
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-expanded"
|
||||
class:btn-loading={isSaving}
|
||||
disabled={!hasChanges || isSaving}
|
||||
on:click={() => save()}
|
||||
>
|
||||
<span class="txt">Save changes</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
@@ -0,0 +1,242 @@
|
||||
<script>
|
||||
import { slide } from "svelte/transition";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import { addSuccessToast } from "@/stores/toasts";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
|
||||
import RedactedPasswordInput from "@/components/base/RedactedPasswordInput.svelte";
|
||||
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
|
||||
|
||||
const tlsOptions = [
|
||||
{ label: "Optional (StartTLS)", value: false },
|
||||
{ label: "Always", value: true },
|
||||
];
|
||||
|
||||
let formSettings = {};
|
||||
let isLoading = false;
|
||||
let isSaving = false;
|
||||
let initialHash = "";
|
||||
|
||||
$: hasChanges = initialHash != JSON.stringify(formSettings);
|
||||
|
||||
CommonHelper.setDocumentTitle("Mail settings");
|
||||
|
||||
loadSettings();
|
||||
|
||||
async function loadSettings() {
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const settings = (await ApiClient.Settings.getAll()) || {};
|
||||
init(settings);
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving || !hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
try {
|
||||
const settings = await ApiClient.Settings.update(CommonHelper.filterRedactedProps(formSettings));
|
||||
init(settings);
|
||||
addSuccessToast("Successfully saved mail settings.");
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
function init(settings = {}) {
|
||||
formSettings = {
|
||||
meta: settings?.meta || {},
|
||||
smtp: settings?.smtp || {},
|
||||
};
|
||||
initialHash = JSON.stringify(formSettings);
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsSidebar />
|
||||
|
||||
<main class="page-wrapper">
|
||||
<header class="page-header">
|
||||
<nav class="breadcrumbs">
|
||||
<div class="breadcrumb-item">Settings</div>
|
||||
<div class="breadcrumb-item">Mail settings</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="wrapper">
|
||||
<form class="panel" autocomplete="off" on:submit|preventDefault={() => save()}>
|
||||
<div class="content txt-xl m-b-base">
|
||||
<p>Configure common settings for sending emails.</p>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loader" />
|
||||
{:else}
|
||||
<div class="grid">
|
||||
<div class="col-lg-6">
|
||||
<Field class="form-field required" name="meta.senderName" let:uniqueId>
|
||||
<label for={uniqueId}>Sender name</label>
|
||||
<input
|
||||
type="text"
|
||||
id={uniqueId}
|
||||
required
|
||||
bind:value={formSettings.meta.senderName}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<Field class="form-field required" name="meta.senderAddress" let:uniqueId>
|
||||
<label for={uniqueId}>Sender address</label>
|
||||
<input
|
||||
type="email"
|
||||
id={uniqueId}
|
||||
required
|
||||
bind:value={formSettings.meta.senderAddress}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field class="form-field required" name="meta.userVerificationUrl" let:uniqueId>
|
||||
<label for={uniqueId}>User verification page url</label>
|
||||
<input
|
||||
type="text"
|
||||
id={uniqueId}
|
||||
required
|
||||
bind:value={formSettings.meta.userVerificationUrl}
|
||||
/>
|
||||
<div class="help-block">
|
||||
Used in the user verification email. Available placeholder parameters:
|
||||
<code>%APP_URL%</code>, <code>%TOKEN%</code>.
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field class="form-field required" name="meta.userResetPasswordUrl" let:uniqueId>
|
||||
<label for={uniqueId}>User reset password page url</label>
|
||||
<input
|
||||
type="text"
|
||||
id={uniqueId}
|
||||
required
|
||||
bind:value={formSettings.meta.userResetPasswordUrl}
|
||||
/>
|
||||
<div class="help-block">
|
||||
Used in the user password reset email. Available placeholder parameters:
|
||||
<code>%APP_URL%</code>, <code>%TOKEN%</code>.
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field class="form-field required" name="meta.userConfirmEmailChangeUrl" let:uniqueId>
|
||||
<label for={uniqueId}>User confirm email change page url</label>
|
||||
<input
|
||||
type="text"
|
||||
id={uniqueId}
|
||||
required
|
||||
bind:value={formSettings.meta.userConfirmEmailChangeUrl}
|
||||
/>
|
||||
<div class="help-block">
|
||||
Used in the user email change confirmation email. Available placeholder
|
||||
parameters:
|
||||
<code>%APP_URL%</code>, <code>%TOKEN%</code>.
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="content m-b-sm">
|
||||
<p>
|
||||
By default PocketBase uses the OS <code>sendmail</code> command for sending emails.
|
||||
<br />
|
||||
<strong class="txt-bold">
|
||||
For better emails deliverability it is recommended to enable the SMTP settings
|
||||
below.
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Field class="form-field form-field-toggle" let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} required bind:checked={formSettings.smtp.enabled} />
|
||||
<label for={uniqueId}>Use SMTP mail server</label>
|
||||
</Field>
|
||||
|
||||
{#if formSettings.smtp.enabled}
|
||||
<div class="grid" transition:slide|local={{ duration: 150 }}>
|
||||
<div class="col-lg-6">
|
||||
<Field class="form-field required" name="smtp.host" let:uniqueId>
|
||||
<label for={uniqueId}>SMTP server host</label>
|
||||
<input
|
||||
type="text"
|
||||
id={uniqueId}
|
||||
required
|
||||
bind:value={formSettings.smtp.host}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-3">
|
||||
<Field class="form-field required" name="smtp.port" let:uniqueId>
|
||||
<label for={uniqueId}>Port</label>
|
||||
<input
|
||||
type="number"
|
||||
id={uniqueId}
|
||||
required
|
||||
bind:value={formSettings.smtp.port}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-3">
|
||||
<Field class="form-field required" name="smtp.tls" let:uniqueId>
|
||||
<label for={uniqueId}>TLS Encryption</label>
|
||||
<ObjectSelect
|
||||
id={uniqueId}
|
||||
items={tlsOptions}
|
||||
bind:keyOfSelected={formSettings.smtp.tls}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<Field class="form-field" name="smtp.username" let:uniqueId>
|
||||
<label for={uniqueId}>Username</label>
|
||||
<input type="text" id={uniqueId} bind:value={formSettings.smtp.username} />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<Field class="form-field" name="smtp.password" let:uniqueId>
|
||||
<label for={uniqueId}>Password</label>
|
||||
<RedactedPasswordInput
|
||||
id={uniqueId}
|
||||
bind:value={formSettings.smtp.password}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<!-- margin helper -->
|
||||
<div class="col-lg-12" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex">
|
||||
<div class="flex-fill" />
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-expanded"
|
||||
class:btn-loading={isSaving}
|
||||
disabled={!hasChanges || isSaving}
|
||||
on:click={() => save()}
|
||||
>
|
||||
<span class="txt">Save changes</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script>
|
||||
import { slide } from "svelte/transition";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import { setErrors } from "@/stores/errors";
|
||||
import { addSuccessToast } from "@/stores/toasts";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import RedactedPasswordInput from "@/components/base/RedactedPasswordInput.svelte";
|
||||
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
|
||||
|
||||
let s3 = {};
|
||||
let isLoading = false;
|
||||
let isSaving = false;
|
||||
let initialHash = "";
|
||||
|
||||
$: hasChanges = initialHash != JSON.stringify(s3);
|
||||
|
||||
CommonHelper.setDocumentTitle("Files storage");
|
||||
|
||||
loadSettings();
|
||||
|
||||
async function loadSettings() {
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const settings = (await ApiClient.Settings.getAll()) || {};
|
||||
init(settings);
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving || !hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
try {
|
||||
const settings = await ApiClient.Settings.update(CommonHelper.filterRedactedProps({ s3 }));
|
||||
init(settings);
|
||||
setErrors({});
|
||||
addSuccessToast("Successfully saved Files storage settings.");
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
function init(settings = {}) {
|
||||
s3 = settings?.s3 || {};
|
||||
initialHash = JSON.stringify(s3);
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsSidebar />
|
||||
|
||||
<main class="page-wrapper">
|
||||
<header class="page-header">
|
||||
<nav class="breadcrumbs">
|
||||
<div class="breadcrumb-item">Settings</div>
|
||||
<div class="breadcrumb-item">Files storage</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="wrapper">
|
||||
<form class="panel" autocomplete="off" on:submit|preventDefault={save}>
|
||||
<div class="content txt-xl m-b-base">
|
||||
<p>By default PocketBase uses the local file system to store uploaded files.</p>
|
||||
<p>
|
||||
If you have limited disk space, you could optionally connect to a S3 compatible storage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loader" />
|
||||
{:else}
|
||||
<Field class="form-field form-field-toggle" let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} required bind:checked={s3.enabled} />
|
||||
<label for={uniqueId}>Use S3 storage</label>
|
||||
</Field>
|
||||
|
||||
{#if s3.enabled}
|
||||
<div class="grid" transition:slide|local={{ duration: 150 }}>
|
||||
<div class="col-lg-12">
|
||||
<Field class="form-field required" name="s3.endpoint" let:uniqueId>
|
||||
<label for={uniqueId}>Endpoint</label>
|
||||
<input type="text" id={uniqueId} required bind:value={s3.endpoint} />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<Field class="form-field required" name="s3.bucket" let:uniqueId>
|
||||
<label for={uniqueId}>Bucket</label>
|
||||
<input type="text" id={uniqueId} required bind:value={s3.bucket} />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<Field class="form-field required" name="s3.region" let:uniqueId>
|
||||
<label for={uniqueId}>Region</label>
|
||||
<input type="text" id={uniqueId} required bind:value={s3.region} />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<Field class="form-field required" name="s3.accessKey" let:uniqueId>
|
||||
<label for={uniqueId}>Access key</label>
|
||||
<input type="text" id={uniqueId} required bind:value={s3.accessKey} />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<Field class="form-field required" name="s3.secret" let:uniqueId>
|
||||
<label for={uniqueId}>Secret</label>
|
||||
<RedactedPasswordInput id={uniqueId} required bind:value={s3.secret} />
|
||||
</Field>
|
||||
</div>
|
||||
<!-- margin helper -->
|
||||
<div class="col-lg-12" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex">
|
||||
<div class="flex-fill" />
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-expanded"
|
||||
class:btn-loading={isSaving}
|
||||
disabled={!hasChanges || isSaving}
|
||||
on:click={() => save()}
|
||||
>
|
||||
<span class="txt">Save changes</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script>
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import { addSuccessToast } from "@/stores/toasts";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
|
||||
|
||||
const tokensList = [
|
||||
{ key: "userAuthToken", label: "Users auth token" },
|
||||
{ key: "userVerificationToken", label: "Users email verification token" },
|
||||
{ key: "userPasswordResetToken", label: "Users password reset token" },
|
||||
{ key: "userEmailChangeToken", label: "Users email change token" },
|
||||
{ key: "adminAuthToken", label: "Admins auth token" },
|
||||
{ key: "adminPasswordResetToken", label: "Admins password reset token" },
|
||||
];
|
||||
|
||||
let tokenSettings = {};
|
||||
let isLoading = false;
|
||||
let isSaving = false;
|
||||
let initialHash = "";
|
||||
|
||||
$: hasChanges = initialHash != JSON.stringify(tokenSettings);
|
||||
|
||||
CommonHelper.setDocumentTitle("Token options");
|
||||
|
||||
loadSettings();
|
||||
|
||||
async function loadSettings() {
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const result = (await ApiClient.Settings.getAll()) || {};
|
||||
initSettings(result);
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving || !hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
try {
|
||||
const result = await ApiClient.Settings.update(CommonHelper.filterRedactedProps(tokenSettings));
|
||||
initSettings(result);
|
||||
addSuccessToast("Successfully saved tokens options.");
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
function initSettings(data) {
|
||||
data = data || {};
|
||||
tokenSettings = {};
|
||||
|
||||
for (const listItem of tokensList) {
|
||||
tokenSettings[listItem.key] = {
|
||||
duration: data[listItem.key]?.duration || 0,
|
||||
};
|
||||
}
|
||||
|
||||
initialHash = JSON.stringify(tokenSettings);
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsSidebar />
|
||||
|
||||
<main class="page-wrapper">
|
||||
<header class="page-header">
|
||||
<nav class="breadcrumbs">
|
||||
<div class="breadcrumb-item">Settings</div>
|
||||
<div class="breadcrumb-item">Token options</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="wrapper">
|
||||
<form class="panel" autocomplete="off" on:submit|preventDefault={save}>
|
||||
<div class="content m-b-sm txt-xl">
|
||||
<p>Adjust common token options.</p>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loader" />
|
||||
{:else}
|
||||
{#each tokensList as token (token.key)}
|
||||
<Field class="form-field required" name="{token.key}.duration" let:uniqueId>
|
||||
<label for={uniqueId}>{token.label} duration (in seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
id={uniqueId}
|
||||
required
|
||||
bind:value={tokenSettings[token.key].duration}
|
||||
/>
|
||||
<div class="help-block">
|
||||
<span
|
||||
class="link-primary"
|
||||
class:txt-success={tokenSettings[token.key].secret}
|
||||
on:click={() => {
|
||||
// toggle
|
||||
if (tokenSettings[token.key].secret) {
|
||||
delete tokenSettings[token.key].secret;
|
||||
tokenSettings[token.key] = tokenSettings[token.key];
|
||||
} else {
|
||||
tokenSettings[token.key].secret = CommonHelper.randomString(50);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Invalidate all previosly issued tokens
|
||||
</span>
|
||||
</div>
|
||||
</Field>
|
||||
{/each}
|
||||
|
||||
<div class="flex">
|
||||
<div class="flex-fill" />
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-expanded"
|
||||
class:btn-loading={isSaving}
|
||||
disabled={!hasChanges || isSaving}
|
||||
on:click={() => save()}
|
||||
>
|
||||
<span class="txt">Save changes</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script>
|
||||
import { link } from "svelte-spa-router";
|
||||
import active from "svelte-spa-router/active";
|
||||
</script>
|
||||
|
||||
<aside class="page-sidebar settings-sidebar">
|
||||
<div class="sidebar-content">
|
||||
<div class="sidebar-title">System</div>
|
||||
<a href="/settings" class="sidebar-list-item" use:active={{ path: "/settings" }} use:link>
|
||||
<i class="ri-home-gear-line" />
|
||||
<span class="txt">Application</span>
|
||||
</a>
|
||||
<a
|
||||
href="/settings/mail"
|
||||
class="sidebar-list-item"
|
||||
use:active={{ path: "/settings/mail/?.*" }}
|
||||
use:link
|
||||
>
|
||||
<i class="ri-send-plane-2-line" />
|
||||
<span class="txt">Mail settings</span>
|
||||
</a>
|
||||
<a
|
||||
href="/settings/storage"
|
||||
class="sidebar-list-item"
|
||||
use:active={{ path: "/settings/storage/?.*" }}
|
||||
use:link
|
||||
>
|
||||
<i class="ri-archive-drawer-line" />
|
||||
<span class="txt">Files storage</span>
|
||||
</a>
|
||||
|
||||
<div class="sidebar-title">Authentication</div>
|
||||
<a
|
||||
href="/settings/auth-providers"
|
||||
class="sidebar-list-item"
|
||||
use:active={{ path: "/settings/auth-providers/?.*" }}
|
||||
use:link
|
||||
>
|
||||
<i class="ri-lock-password-line" />
|
||||
<span class="txt">Auth providers</span>
|
||||
</a>
|
||||
<a
|
||||
href="/settings/tokens"
|
||||
class="sidebar-list-item"
|
||||
use:active={{ path: "/settings/tokens/?.*" }}
|
||||
use:link
|
||||
>
|
||||
<i class="ri-key-line" />
|
||||
<span class="txt">Token options</span>
|
||||
</a>
|
||||
<a
|
||||
href="/settings/admins"
|
||||
class="sidebar-list-item"
|
||||
use:active={{ path: "/settings/admins/?.*" }}
|
||||
use:link
|
||||
>
|
||||
<i class="ri-shield-user-line" />
|
||||
<span class="txt">Admins</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script>
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import FullPage from "@/components/base/FullPage.svelte";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
export let params;
|
||||
|
||||
let password = "";
|
||||
let isLoading = false;
|
||||
let success = false;
|
||||
|
||||
$: newEmail = CommonHelper.getJWTPayload(params?.token).newEmail || "";
|
||||
|
||||
async function submit() {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
await ApiClient.Users.confirmEmailChange(params?.token, password);
|
||||
success = true;
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<FullPage nobranding>
|
||||
{#if success}
|
||||
<div class="alert alert-success">
|
||||
<div class="icon"><i class="ri-checkbox-circle-line" /></div>
|
||||
<div class="content txt-bold">
|
||||
<p>Email address changed</p>
|
||||
<p>You can now sign in with your new email address.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-secondary btn-block" on:click={() => window.close()}>
|
||||
Close
|
||||
</button>
|
||||
{:else}
|
||||
<form on:submit|preventDefault={submit}>
|
||||
<div class="content txt-center m-b-sm">
|
||||
<h4 class="m-b-xs">
|
||||
Type your password to confirm changing your email address
|
||||
{#if newEmail}
|
||||
to <strong class="txt-nowrap">{newEmail}</strong>
|
||||
{/if}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<Field class="form-field required" name="password" let:uniqueId>
|
||||
<label for={uniqueId}>Password</label>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input type="password" id={uniqueId} required autofocus bind:value={password} />
|
||||
</Field>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-lg btn-block"
|
||||
class:btn-loading={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span class="txt">Confirm new email</span>
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</FullPage>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script>
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import FullPage from "@/components/base/FullPage.svelte";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
export let params;
|
||||
|
||||
let newPassword = "";
|
||||
let newPasswordConfirm = "";
|
||||
let isLoading = false;
|
||||
let success = false;
|
||||
|
||||
$: email = CommonHelper.getJWTPayload(params?.token).email || "";
|
||||
|
||||
async function submit() {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
await ApiClient.Users.confirmPasswordReset(params?.token, newPassword, newPasswordConfirm);
|
||||
success = true;
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<FullPage nobranding>
|
||||
{#if success}
|
||||
<div class="alert alert-success">
|
||||
<div class="icon"><i class="ri-checkbox-circle-line" /></div>
|
||||
<div class="content txt-bold">
|
||||
<p>Password changed</p>
|
||||
<p>You can now sign in with your new password.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-secondary btn-block" on:click={() => window.close()}>
|
||||
Close
|
||||
</button>
|
||||
{:else}
|
||||
<form on:submit|preventDefault={submit}>
|
||||
<div class="content txt-center m-b-sm">
|
||||
<h4 class="m-b-xs">
|
||||
Reset your user password
|
||||
{#if email}
|
||||
for <strong>{email}</strong>
|
||||
{/if}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<Field class="form-field required" name="password" let:uniqueId>
|
||||
<label for={uniqueId}>New password</label>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input type="password" id={uniqueId} required autofocus bind:value={newPassword} />
|
||||
</Field>
|
||||
|
||||
<Field class="form-field required" name="passwordConfirm" let:uniqueId>
|
||||
<label for={uniqueId}>New password confirm</label>
|
||||
<input type="password" id={uniqueId} required bind:value={newPasswordConfirm} />
|
||||
</Field>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-lg btn-block"
|
||||
class:btn-loading={isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span class="txt">Set new password</span>
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</FullPage>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script>
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import FullPage from "@/components/base/FullPage.svelte";
|
||||
|
||||
export let params;
|
||||
|
||||
let success = false;
|
||||
let isLoading = false;
|
||||
|
||||
send();
|
||||
|
||||
async function send() {
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
await ApiClient.Users.confirmVerification(params?.token);
|
||||
success = true;
|
||||
} catch (err) {
|
||||
console.warn(err);
|
||||
success = false;
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<FullPage nobranding>
|
||||
{#if isLoading}
|
||||
<div class="txt-center">
|
||||
<div class="loader loader-lg">
|
||||
<em>Please wait...</em>
|
||||
</div>
|
||||
</div>
|
||||
{:else if success}
|
||||
<div class="alert alert-success">
|
||||
<div class="icon"><i class="ri-checkbox-circle-line" /></div>
|
||||
<div class="content txt-bold">
|
||||
<p>Successfully verified email address.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-secondary btn-block" on:click={() => window.close()}>
|
||||
Close
|
||||
</button>
|
||||
{:else}
|
||||
<div class="alert alert-danger">
|
||||
<div class="icon"><i class="ri-error-warning-line" /></div>
|
||||
<div class="content txt-bold">
|
||||
<p>Invalid or expired verification token.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-secondary btn-block" on:click={() => window.close()}>
|
||||
Close
|
||||
</button>
|
||||
{/if}
|
||||
</FullPage>
|
||||
@@ -0,0 +1,293 @@
|
||||
<script>
|
||||
import { Collection } from "pocketbase";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import Searchbar from "@/components/base/Searchbar.svelte";
|
||||
import SortHeader from "@/components/base/SortHeader.svelte";
|
||||
import IdLabel from "@/components/base/IdLabel.svelte";
|
||||
import FormattedDate from "@/components/base/FormattedDate.svelte";
|
||||
import UserUpsertPanel from "@/components/users/UserUpsertPanel.svelte";
|
||||
import CollectionUpsertPanel from "@/components/collections/CollectionUpsertPanel.svelte";
|
||||
import RecordUpsertPanel from "@/components/records/RecordUpsertPanel.svelte";
|
||||
import RecordFieldCell from "@/components/records/RecordFieldCell.svelte";
|
||||
|
||||
const queryParams = CommonHelper.getQueryParams(window.location?.href);
|
||||
const excludedProfileFields = ["id", "userId", "created", "updated"];
|
||||
|
||||
let userUpsertPanel;
|
||||
let collectionUpsertPanel;
|
||||
let recordUpsertPanel;
|
||||
let users = [];
|
||||
let currentPage = 1;
|
||||
let totalItems = 0;
|
||||
let isLoadingUsers = false;
|
||||
let filter = queryParams.filter || "";
|
||||
let sort = queryParams.sort || "-created";
|
||||
let profileCollection = new Collection();
|
||||
let isLoadingProfileCollection = false;
|
||||
|
||||
$: if (sort !== -1 && filter !== -1) {
|
||||
// keep query params
|
||||
CommonHelper.replaceClientQueryParams({
|
||||
filter: filter,
|
||||
sort: sort,
|
||||
});
|
||||
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
$: canLoadMore = totalItems > users.length;
|
||||
|
||||
$: profileFields = profileCollection?.schema?.filter(
|
||||
(field) => !excludedProfileFields.includes(field.name)
|
||||
);
|
||||
|
||||
CommonHelper.setDocumentTitle("Users");
|
||||
|
||||
loadProfilesCollection();
|
||||
|
||||
export async function loadUsers(page = 1) {
|
||||
isLoadingUsers = true;
|
||||
|
||||
return ApiClient.Users.getList(page, 50, {
|
||||
sort: sort || "-created",
|
||||
filter: filter,
|
||||
})
|
||||
.then((result) => {
|
||||
if (page <= 1) {
|
||||
clearList();
|
||||
}
|
||||
|
||||
isLoadingUsers = false;
|
||||
users = users.concat(result.items);
|
||||
currentPage = result.page;
|
||||
totalItems = result.totalItems;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err !== null) {
|
||||
isLoadingUsers = false;
|
||||
console.warn(err);
|
||||
clearList();
|
||||
ApiClient.errorResponseHandler(err, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clearList() {
|
||||
users = [];
|
||||
currentPage = 1;
|
||||
totalItems = 0;
|
||||
}
|
||||
|
||||
function setUserProfile(profile) {
|
||||
const user = users.find((u) => u.id === profile?.userId);
|
||||
if (user) {
|
||||
user.profile = profile;
|
||||
}
|
||||
users = users;
|
||||
}
|
||||
|
||||
async function loadProfilesCollection() {
|
||||
isLoadingProfileCollection = true;
|
||||
|
||||
try {
|
||||
profileCollection = await ApiClient.Collections.getOne(import.meta.env.PB_PROFILE_COLLECTION);
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isLoadingProfileCollection = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isLoadingProfileCollection}
|
||||
<div class="placeholder-section m-b-base">
|
||||
<span class="loader loader-lg" />
|
||||
<h1>Loading users...</h1>
|
||||
</div>
|
||||
{:else}
|
||||
<main class="page-wrapper">
|
||||
<header class="page-header">
|
||||
<nav class="breadcrumbs">
|
||||
<div class="breadcrumb-item">Users</div>
|
||||
</nav>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-circle"
|
||||
use:tooltip={{ text: "Edit profile collection", position: "right" }}
|
||||
on:click={() => collectionUpsertPanel?.show(profileCollection)}
|
||||
>
|
||||
<i class="ri-settings-4-line" />
|
||||
</button>
|
||||
|
||||
<div class="flex-fill" />
|
||||
|
||||
<button type="button" class="btn btn-expanded" on:click={() => userUpsertPanel?.show()}>
|
||||
<i class="ri-add-line" />
|
||||
<span class="txt">New user</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<Searchbar
|
||||
value={filter}
|
||||
placeholder={"Search filter, eg. verified=1"}
|
||||
extraAutocompleteKeys={["verified", "email"]}
|
||||
on:submit={(e) => (filter = e.detail)}
|
||||
/>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="table" class:table-loading={isLoadingUsers}>
|
||||
<thead>
|
||||
<tr>
|
||||
<SortHeader class="col-type-text col-field-id" name="id" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("primary")} />
|
||||
<span class="txt">id</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<SortHeader class="col-type-email col-field-email" name="email" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("email")} />
|
||||
<span class="txt">email</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
{#each profileFields as field (field.name)}
|
||||
<th class="col-type-{field.type} col-field-{field.name}" name={field.name}>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
|
||||
<span class="txt">profile.{field.name}</span>
|
||||
</div>
|
||||
</th>
|
||||
{/each}
|
||||
|
||||
<SortHeader class="col-type-date col-field-created" name="created" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("date")} />
|
||||
<span class="txt">created</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<SortHeader class="col-type-date col-field-updated" name="updated" bind:sort>
|
||||
<div class="col-header-content">
|
||||
<i class={CommonHelper.getFieldTypeIcon("date")} />
|
||||
<span class="txt">updated</span>
|
||||
</div>
|
||||
</SortHeader>
|
||||
|
||||
<th class="col-type-action min-width" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each users as user (user.id)}
|
||||
<tr>
|
||||
<td class="col-type-text col-field-id">
|
||||
<IdLabel id={user.id} />
|
||||
</td>
|
||||
|
||||
<td class="col-type-email col-field-email">
|
||||
<div class="inline-flex">
|
||||
<span class="txt" title={user.email}>
|
||||
{user.email}
|
||||
</span>
|
||||
<span
|
||||
class="label"
|
||||
class:label-success={user.verified}
|
||||
class:label-warning={!user.verified}
|
||||
>
|
||||
{user.verified ? "Verified" : "Unverified"}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{#each profileFields as field (field.name)}
|
||||
<RecordFieldCell {field} record={user.profile || {}} />
|
||||
{/each}
|
||||
|
||||
<td class="col-type-date col-field-created">
|
||||
<FormattedDate date={user.created} />
|
||||
</td>
|
||||
|
||||
<td class="col-type-date col-field-updated">
|
||||
<FormattedDate date={user.updated} />
|
||||
</td>
|
||||
|
||||
<td class="col-type-action min-width">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline"
|
||||
on:click|stopPropagation={() => userUpsertPanel?.show(user)}
|
||||
>
|
||||
<i class="ri-user-settings-line" />
|
||||
<span class="txt">Edit user</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm m-l-10"
|
||||
on:click|stopPropagation={() => recordUpsertPanel?.show(user.profile)}
|
||||
>
|
||||
<i class="ri-profile-line" />
|
||||
<span class="txt">Edit profile</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#if isLoadingUsers}
|
||||
<tr>
|
||||
<td colspan="99" class="p-xs">
|
||||
<span class="skeleton-loader" />
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="99" class="txt-center txt-hint p-xs">
|
||||
<h6>No users found.</h6>
|
||||
{#if filter?.length}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-hint btn-expanded m-t-sm"
|
||||
on:click={() => (filter = "")}
|
||||
>
|
||||
<span class="txt">Clear filters</span>
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if users.length}
|
||||
<small class="block txt-hint txt-right m-t-sm">Showing {users.length} of {totalItems}</small>
|
||||
{/if}
|
||||
|
||||
{#if users.length && canLoadMore}
|
||||
<div class="block txt-center m-t-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-lg btn-secondary btn-expanded"
|
||||
class:btn-loading={isLoadingUsers}
|
||||
class:btn-disabled={isLoadingUsers}
|
||||
on:click={() => loadUsers(currentPage + 1)}
|
||||
>
|
||||
<span class="txt">Load more ({totalItems - users.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
<UserUpsertPanel bind:this={userUpsertPanel} on:save={() => loadUsers()} on:delete={() => loadUsers()} />
|
||||
|
||||
<CollectionUpsertPanel bind:this={collectionUpsertPanel} on:save={(e) => (profileCollection = e.detail)} />
|
||||
|
||||
<RecordUpsertPanel
|
||||
bind:this={recordUpsertPanel}
|
||||
collection={profileCollection}
|
||||
on:save={(e) => setUserProfile(e.detail)}
|
||||
/>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script>
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
|
||||
import UserSelectOption from "./UserSelectOption.svelte";
|
||||
|
||||
const uniqueId = "select_" + CommonHelper.randomString(5);
|
||||
|
||||
// original select props
|
||||
export let multiple = false;
|
||||
export let selected = multiple ? [] : undefined;
|
||||
export let keyOfSelected = multiple ? [] : undefined;
|
||||
export let selectPlaceholder = "- Select -";
|
||||
export let optionComponent = UserSelectOption; // custom component to use for each dropdown option item
|
||||
|
||||
let list = [];
|
||||
let currentPage = 1;
|
||||
let totalItems = 0;
|
||||
let isLoadingList = false;
|
||||
let isLoadingSelected = false;
|
||||
|
||||
$: isLoading = isLoadingList || isLoadingSelected;
|
||||
|
||||
$: canLoadMore = totalItems > list.length;
|
||||
|
||||
loadList();
|
||||
|
||||
loadSelected();
|
||||
|
||||
async function loadSelected() {
|
||||
const selectedIds = CommonHelper.toArray(keyOfSelected);
|
||||
if (!selectedIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingSelected = true;
|
||||
|
||||
try {
|
||||
const filters = [];
|
||||
for (const id of selectedIds) {
|
||||
filters.push(`id="${id}"`);
|
||||
}
|
||||
|
||||
selected = await ApiClient.Users.getFullList(100, {
|
||||
sort: "-created",
|
||||
filter: filters.join("||"),
|
||||
$cancelKey: uniqueId + "loadSelected",
|
||||
});
|
||||
|
||||
// add the selected models to the list (if not already)
|
||||
list = CommonHelper.filterDuplicatesByKey(list.concat(selected));
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isLoadingSelected = false;
|
||||
}
|
||||
|
||||
async function loadList(reset = false) {
|
||||
isLoadingList = true;
|
||||
|
||||
try {
|
||||
const page = reset ? 1 : currentPage + 1;
|
||||
|
||||
const result = await ApiClient.Users.getList(page, 200, {
|
||||
sort: "-created",
|
||||
$cancelKey: uniqueId + "loadList",
|
||||
});
|
||||
|
||||
if (reset) {
|
||||
list = [];
|
||||
}
|
||||
|
||||
list = CommonHelper.filterDuplicatesByKey(list.concat(result.items));
|
||||
currentPage = result.page;
|
||||
totalItems = result.totalItems;
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
}
|
||||
|
||||
isLoadingList = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ObjectSelect
|
||||
selectPlaceholder={isLoading ? "Loading..." : selectPlaceholder}
|
||||
items={list}
|
||||
searchable={list.length > 5}
|
||||
selectionKey="id"
|
||||
labelComponent={UserSelectOption}
|
||||
{optionComponent}
|
||||
{multiple}
|
||||
bind:keyOfSelected
|
||||
bind:selected
|
||||
on:show
|
||||
on:hide
|
||||
class="users-select block-options"
|
||||
{...$$restProps}
|
||||
>
|
||||
<svelte:fragment slot="afterOptions">
|
||||
{#if canLoadMore}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-block btn-sm"
|
||||
class:btn-loading={isLoadingList}
|
||||
class:btn-disabled={isLoadingList}
|
||||
on:click|stopPropagation={() => loadList()}
|
||||
>
|
||||
<span class="txt">Load more</span>
|
||||
</button>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</ObjectSelect>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script>
|
||||
import tooltip from "@/actions/tooltip";
|
||||
|
||||
export let item = {}; // model
|
||||
</script>
|
||||
|
||||
<i
|
||||
class="ri-information-line link-hint"
|
||||
use:tooltip={{ text: JSON.stringify(item, null, 2), position: "left", class: "code" }}
|
||||
/>
|
||||
|
||||
<div class="content">
|
||||
<div class="block txt-ellipsis">{item.id}</div>
|
||||
<small class="block txt-hint txt-ellipsis">{item.email}</small>
|
||||
</div>
|
||||
@@ -0,0 +1,263 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
import { User } from "pocketbase";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import { setErrors } from "@/stores/errors";
|
||||
import { confirm } from "@/stores/confirmation";
|
||||
import { addSuccessToast } from "@/stores/toasts";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import Toggler from "@/components/base/Toggler.svelte";
|
||||
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const formId = "user_" + CommonHelper.randomString(5);
|
||||
|
||||
let panel;
|
||||
let user = new User();
|
||||
let isSaving = false;
|
||||
let confirmClose = false; // prevent close recursion
|
||||
let email = "";
|
||||
let password = "";
|
||||
let passwordConfirm = "";
|
||||
let changePasswordToggle = false;
|
||||
let verificationEmailToggle = true;
|
||||
|
||||
$: hasChanges = (user.isNew && email != "") || changePasswordToggle || email !== user.email;
|
||||
|
||||
export function show(model) {
|
||||
load(model);
|
||||
|
||||
confirmClose = true;
|
||||
|
||||
return panel?.show();
|
||||
}
|
||||
|
||||
export function hide() {
|
||||
return panel?.hide();
|
||||
}
|
||||
|
||||
function load(model) {
|
||||
setErrors({}); // reset errors
|
||||
user = model?.clone ? model.clone() : new User();
|
||||
reset(); // reset form
|
||||
}
|
||||
|
||||
function reset() {
|
||||
changePasswordToggle = false;
|
||||
verificationEmailToggle = true;
|
||||
email = user?.email || "";
|
||||
password = "";
|
||||
passwordConfirm = "";
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (isSaving || !hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
const data = { email: email };
|
||||
if (user.isNew || changePasswordToggle) {
|
||||
data["password"] = password;
|
||||
data["passwordConfirm"] = passwordConfirm;
|
||||
}
|
||||
|
||||
let request;
|
||||
if (user.isNew) {
|
||||
request = ApiClient.Users.create(data);
|
||||
} else {
|
||||
request = ApiClient.Users.update(user.id, data);
|
||||
}
|
||||
|
||||
request
|
||||
.then(async (result) => {
|
||||
if (verificationEmailToggle) {
|
||||
sendVerificationEmail(false);
|
||||
}
|
||||
|
||||
confirmClose = false;
|
||||
hide();
|
||||
addSuccessToast(user.isNew ? "Successfully created user." : "Successfully updated user.");
|
||||
dispatch("save", result);
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
})
|
||||
.finally(() => {
|
||||
isSaving = false;
|
||||
});
|
||||
}
|
||||
|
||||
function deleteConfirm() {
|
||||
if (!user?.id) {
|
||||
return; // nothing to delete
|
||||
}
|
||||
|
||||
confirm(`Do you really want to delete the selected user?`, () => {
|
||||
return ApiClient.Users.delete(user.id)
|
||||
.then(() => {
|
||||
confirmClose = false;
|
||||
hide();
|
||||
addSuccessToast("Successfully deleted user.");
|
||||
dispatch("delete", user);
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendVerificationEmail(notify = true) {
|
||||
return ApiClient.Users.requestVerification(user.isNew ? email : user.email)
|
||||
.then(() => {
|
||||
confirmClose = false;
|
||||
hide();
|
||||
if (notify) {
|
||||
addSuccessToast(`Successfully sent verification email to ${user.email}.`);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<OverlayPanel
|
||||
bind:this={panel}
|
||||
popup
|
||||
class="user-panel"
|
||||
beforeHide={() => {
|
||||
if (hasChanges && confirmClose) {
|
||||
confirm("You have unsaved changes. Do you really want to close the panel?", () => {
|
||||
confirmClose = false;
|
||||
hide();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
on:hide
|
||||
on:show
|
||||
>
|
||||
<svelte:fragment slot="header">
|
||||
<h4>
|
||||
{user.isNew ? "New user" : "Edit user"}
|
||||
</h4>
|
||||
</svelte:fragment>
|
||||
|
||||
<form id={formId} class="grid" autocomplete="off" on:submit|preventDefault={save}>
|
||||
{#if !user.isNew}
|
||||
<Field class="form-field disabled" name="id" let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class={CommonHelper.getFieldTypeIcon("primary")} />
|
||||
<span class="txt">ID</span>
|
||||
</label>
|
||||
<input type="text" id={uniqueId} value={user.id} disabled />
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
<Field class="form-field required" name="email" let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class={CommonHelper.getFieldTypeIcon("email")} />
|
||||
<span class="txt">Email</span>
|
||||
</label>
|
||||
{#if user.verified}
|
||||
<div class="form-field-addon txt-success" use:tooltip={"Verified"}>
|
||||
<i class="ri-shield-check-line" />
|
||||
</div>
|
||||
{/if}
|
||||
<input type="email" autocomplete="off" id={uniqueId} required bind:value={email} />
|
||||
</Field>
|
||||
|
||||
{#if !user.isNew}
|
||||
<Field class="form-field form-field-toggle" let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} bind:checked={changePasswordToggle} />
|
||||
<label for={uniqueId}>Change password</label>
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
{#if user.isNew || changePasswordToggle}
|
||||
<div class="col-12">
|
||||
<div class="grid" transition:slide={{ duration: 150 }}>
|
||||
<div class="col-sm-6">
|
||||
<Field class="form-field required" name="password" let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class="ri-lock-line" />
|
||||
<span class="txt">Password</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
id={uniqueId}
|
||||
required
|
||||
bind:value={password}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<Field class="form-field required" name="passwordConfirm" let:uniqueId>
|
||||
<label for={uniqueId}>
|
||||
<i class="ri-lock-line" />
|
||||
<span class="txt">Password confirm</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
id={uniqueId}
|
||||
required
|
||||
bind:value={passwordConfirm}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if user.isNew}
|
||||
<Field class="form-field form-field-toggle" let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} bind:checked={verificationEmailToggle} />
|
||||
<label for={uniqueId}>Send verification email</label>
|
||||
</Field>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
{#if !user.isNew}
|
||||
<button type="button" class="btn btn-sm btn-circle btn-secondary">
|
||||
<!-- empty span for alignment -->
|
||||
<span />
|
||||
<i class="ri-more-line" />
|
||||
<Toggler class="dropdown dropdown-upside dropdown-left dropdown-nowrap">
|
||||
{#if !user.verified}
|
||||
<button type="button" class="dropdown-item" on:click={() => sendVerificationEmail()}>
|
||||
<i class="ri-mail-check-line" />
|
||||
<span class="txt">Send verification email</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" class="dropdown-item" on:click={() => deleteConfirm()}>
|
||||
<i class="ri-delete-bin-7-line" />
|
||||
<span class="txt">Delete</span>
|
||||
</button>
|
||||
</Toggler>
|
||||
</button>
|
||||
<div class="flex-fill" />
|
||||
{/if}
|
||||
|
||||
<button type="button" class="btn btn-secondary" disabled={isSaving} on:click={() => hide()}>
|
||||
<span class="txt">Cancel</span>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form={formId}
|
||||
class="btn btn-expanded"
|
||||
class:btn-loading={isSaving}
|
||||
disabled={!hasChanges || isSaving}
|
||||
>
|
||||
<span class="txt">{user.isNew ? "Create" : "Save changes"}</span>
|
||||
</button>
|
||||
</svelte:fragment>
|
||||
</OverlayPanel>
|
||||
Reference in New Issue
Block a user