initial public commit

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