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
@@ -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>