added backup apis and tests
This commit is contained in:
@@ -82,14 +82,15 @@
|
||||
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);
|
||||
}
|
||||
|
||||
dispatch("save", result);
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
isSaving = false;
|
||||
@@ -111,7 +112,7 @@
|
||||
dispatch("delete", admin);
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
addSuccessToast("Successfully set a new admin password.");
|
||||
replace("/");
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
await ApiClient.admins.requestPasswordReset(email);
|
||||
success = true;
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
isLoading = false;
|
||||
console.warn(err);
|
||||
clearList();
|
||||
ApiClient.errorResponseHandler(err, false);
|
||||
ApiClient.error(err, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
dispatch("submit");
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
let tooltipData = { text: "Refresh", position: "right" };
|
||||
export { tooltipData as tooltip };
|
||||
|
||||
let classes = "";
|
||||
export { classes as class };
|
||||
|
||||
let refreshTimeoutId = null;
|
||||
|
||||
function refresh() {
|
||||
@@ -31,7 +34,7 @@
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Refresh"
|
||||
class="btn btn-transparent btn-circle"
|
||||
class="btn btn-transparent btn-circle {classes}"
|
||||
class:refreshing={refreshTimeoutId}
|
||||
use:tooltip={tooltipData}
|
||||
on:click={refresh}
|
||||
|
||||
@@ -157,6 +157,9 @@
|
||||
function handleOptionKeypress(e, item) {
|
||||
if (e.code === "Enter" || e.code === "Space") {
|
||||
handleOptionSelect(e, item);
|
||||
if (closable) {
|
||||
hideDropdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
initialFormHash = calculateFormHash(collection);
|
||||
}
|
||||
|
||||
function saveWithConfirm() {
|
||||
function saveConfirm() {
|
||||
if (collection.$isNew) {
|
||||
save();
|
||||
} else {
|
||||
@@ -159,7 +159,7 @@
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
isSaving = false;
|
||||
@@ -196,7 +196,7 @@
|
||||
removeCollection(original);
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -304,7 +304,7 @@
|
||||
<form
|
||||
class="block"
|
||||
on:submit|preventDefault={() => {
|
||||
canSave && saveWithConfirm();
|
||||
canSave && saveConfirm();
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
@@ -453,7 +453,7 @@
|
||||
class="btn btn-expanded"
|
||||
class:btn-loading={isSaving}
|
||||
disabled={!canSave || isSaving}
|
||||
on:click={() => saveWithConfirm()}
|
||||
on:click={() => saveConfirm()}
|
||||
>
|
||||
<span class="txt">{collection.$isNew ? "Create" : "Save changes"}</span>
|
||||
</button>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
if (!err?.isAbort) {
|
||||
resetData();
|
||||
console.warn(err);
|
||||
ApiClient.errorResponseHandler(err, false);
|
||||
ApiClient.error(err, false);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
isLoading = false;
|
||||
console.warn(err);
|
||||
clearList();
|
||||
ApiClient.errorResponseHandler(err, false);
|
||||
ApiClient.error(err, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
try {
|
||||
externalAuths = await ApiClient.collection(record.collectionId).listExternalAuths(record.id);
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
@@ -53,7 +53,7 @@
|
||||
loadExternalAuths(); // reload list
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
await client.collection(payload.collectionId).confirmEmailChange(params?.token, password);
|
||||
success = true;
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
.confirmPasswordReset(params?.token, newPassword, newPasswordConfirm);
|
||||
success = true;
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
|
||||
@@ -206,7 +206,7 @@
|
||||
dispatch("save", result);
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
isSaving = false;
|
||||
@@ -227,7 +227,7 @@
|
||||
dispatch("delete", original);
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -299,7 +299,7 @@
|
||||
addSuccessToast(`Successfully sent verification email to ${original.email}.`);
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -316,7 +316,7 @@
|
||||
addSuccessToast(`Successfully sent password reset email to ${original.email}.`);
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@
|
||||
isLoading = false;
|
||||
console.warn(err);
|
||||
clearList();
|
||||
ApiClient.errorResponseHandler(err, false);
|
||||
ApiClient.error(err, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -234,7 +234,7 @@
|
||||
deselectAllRecords();
|
||||
})
|
||||
.catch((err) => {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
isDeleting = false;
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
list = CommonHelper.filterDuplicatesByKey(selected.concat(list));
|
||||
}
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isLoadingSelected = false;
|
||||
@@ -144,7 +144,7 @@
|
||||
currentPage = result.page;
|
||||
totalItems = result.totalItems;
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isLoadingList = false;
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
|
||||
list = list;
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
hide();
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isSubmitting = false;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<script>
|
||||
import { createEventDispatcher, onDestroy } from "svelte";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import { setErrors } from "@/stores/errors";
|
||||
import { addInfoToast, addSuccessToast } from "@/stores/toasts";
|
||||
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const formId = "backup_create_" + CommonHelper.randomString(5);
|
||||
|
||||
let panel;
|
||||
let name = "";
|
||||
let isSubmitting = false;
|
||||
let submitTimeoutId;
|
||||
|
||||
export function show(newName) {
|
||||
setErrors({});
|
||||
isSubmitting = false;
|
||||
name = newName || "";
|
||||
panel?.show();
|
||||
}
|
||||
|
||||
export function hide() {
|
||||
return panel?.hide();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
clearTimeout(submitTimeoutId);
|
||||
submitTimeoutId = setTimeout(() => {
|
||||
hide();
|
||||
}, 1500);
|
||||
|
||||
try {
|
||||
await ApiClient.backups.create(name, { $cancelKey: formId });
|
||||
|
||||
isSubmitting = false;
|
||||
|
||||
hide();
|
||||
dispatch("submit");
|
||||
addSuccessToast("Successfully generated new backup.");
|
||||
} catch (err) {
|
||||
if (!err.isAbort) {
|
||||
ApiClient.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(submitTimeoutId);
|
||||
isSubmitting = false;
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(submitTimeoutId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<OverlayPanel
|
||||
bind:this={panel}
|
||||
class="backup-create-panel"
|
||||
beforeOpen={() => {
|
||||
if (isSubmitting) {
|
||||
addInfoToast("A backup has already been started, please wait.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
beforeHide={() => {
|
||||
if (isSubmitting) {
|
||||
addInfoToast(
|
||||
"The backup was started but may take a while to complete. You can come back later.",
|
||||
4500
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
popup
|
||||
on:show
|
||||
on:hide
|
||||
>
|
||||
<svelte:fragment slot="header">
|
||||
<h4 class="center txt-break">Initialize new backup</h4>
|
||||
</svelte:fragment>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<div class="icon">
|
||||
<i class="ri-information-line" />
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>
|
||||
Please note that during the backup other concurrent write requrests may fail since the
|
||||
database will be temporary "locked" (this usually happens only during the ZIP generation).
|
||||
</p>
|
||||
<p class="txt-bold">
|
||||
If you are using S3 storage for the collections file upload, you'll have to backup them
|
||||
separately since they are not locally stored and will not be included in the final backup!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id={formId} autocomplete="off" on:submit|preventDefault={submit}>
|
||||
<Field class="form-field m-0" name="name" let:uniqueId>
|
||||
<label for={uniqueId}>Backup name</label>
|
||||
<input
|
||||
type="text"
|
||||
id={uniqueId}
|
||||
placeholder={"Leave empty to autogenerate"}
|
||||
pattern="^[a-z0-9_-]+\.zip$"
|
||||
bind:value={name}
|
||||
/>
|
||||
<em class="help-block">Must be in the format [a-z0-9_-].zip</em>
|
||||
</Field>
|
||||
</form>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<button type="button" class="btn btn-transparent" on:click={hide} disabled={isSubmitting}>
|
||||
<span class="txt">Cancel</span>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form={formId}
|
||||
class="btn btn-expanded"
|
||||
class:btn-loading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span class="txt">Start backup</span>
|
||||
</button>
|
||||
</svelte:fragment>
|
||||
</OverlayPanel>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script>
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import { setErrors } from "@/stores/errors";
|
||||
import { addErrorToast } from "@/stores/toasts";
|
||||
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import CopyIcon from "@/components/base/CopyIcon.svelte";
|
||||
|
||||
const formId = "backup_restore_" + CommonHelper.randomString(5);
|
||||
|
||||
let panel;
|
||||
let name = "";
|
||||
let nameConfirm = "";
|
||||
let isSubmitting = false;
|
||||
|
||||
$: canSubmit = nameConfirm != "" && name == nameConfirm;
|
||||
|
||||
export function show(backupName) {
|
||||
setErrors({});
|
||||
nameConfirm = "";
|
||||
name = backupName;
|
||||
isSubmitting = false;
|
||||
panel?.show();
|
||||
}
|
||||
|
||||
export function hide() {
|
||||
return panel?.hide();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
await ApiClient.backups.restore(name);
|
||||
|
||||
// slight delay just in case the application is still restarting
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
if (!err?.isAbort) {
|
||||
isSubmitting = false;
|
||||
addErrorToast(err.response?.message || err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<OverlayPanel
|
||||
bind:this={panel}
|
||||
class="backup-restore-panel"
|
||||
overlayClose={!isSubmitting}
|
||||
escClose={!isSubmitting}
|
||||
beforeHide={() => !isSubmitting}
|
||||
popup
|
||||
on:show
|
||||
on:hide
|
||||
>
|
||||
<svelte:fragment slot="header">
|
||||
<h4 class="center txt-break">Restore <strong>{name}</strong></h4>
|
||||
</svelte:fragment>
|
||||
|
||||
<div class="alert alert-danger">
|
||||
<div class="icon">
|
||||
<i class="ri-alert-line" />
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Please proceed with caution.</p>
|
||||
<p>
|
||||
The restore operation will replace your existing <code>pb_data</code> with the one from the backup
|
||||
and will restart the application process!
|
||||
</p>
|
||||
<p class="txt-bold">
|
||||
Backup restore is still experimental and currently works only on UNIX based systems.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content m-b-sm">
|
||||
Type the backup name
|
||||
<div class="label">
|
||||
<span class="txt">{name}</span>
|
||||
<CopyIcon value={name} />
|
||||
</div>
|
||||
to confirm:
|
||||
</div>
|
||||
|
||||
<form id={formId} autocomplete="off" on:submit|preventDefault={submit}>
|
||||
<Field class="form-field required m-0" name="name" let:uniqueId>
|
||||
<label for={uniqueId}>Backup name</label>
|
||||
<input type="text" id={uniqueId} required bind:value={nameConfirm} />
|
||||
</Field>
|
||||
</form>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<button type="button" class="btn btn-transparent" on:click={hide} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form={formId}
|
||||
class="btn btn-expanded"
|
||||
class:btn-loading={isSubmitting}
|
||||
disabled={!canSubmit || isSubmitting}
|
||||
>
|
||||
<span class="txt">Restore backup</span>
|
||||
</button>
|
||||
</svelte:fragment>
|
||||
</OverlayPanel>
|
||||
@@ -0,0 +1,224 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import { confirm } from "@/stores/confirmation";
|
||||
import { addSuccessToast } from "@/stores/toasts";
|
||||
import BackupCreatePanel from "@/components/settings/BackupCreatePanel.svelte";
|
||||
import BackupRestorePanel from "@/components/settings/BackupRestorePanel.svelte";
|
||||
|
||||
let createPanel;
|
||||
let restorePanel;
|
||||
let backups = [];
|
||||
let isLoading = false;
|
||||
let isDownloading = {};
|
||||
let isDeleting = {};
|
||||
let canBackup = true;
|
||||
|
||||
loadBackups();
|
||||
loadCanBackup();
|
||||
|
||||
export async function loadBackups() {
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
backups = await ApiClient.backups.getFullList();
|
||||
|
||||
// sort backups DESC by their modified date
|
||||
backups.sort((a, b) => {
|
||||
if (a.modified < b.modified) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a.modified > b.modified) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
isLoading = false;
|
||||
} catch (err) {
|
||||
if (!err.isAbort) {
|
||||
ApiClient.error(err);
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function download(name) {
|
||||
if (isDownloading[name]) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDownloading[name] = true;
|
||||
|
||||
try {
|
||||
const token = await ApiClient.getAdminFileToken();
|
||||
const url = ApiClient.backups.getDownloadUrl(token, name);
|
||||
CommonHelper.download(url);
|
||||
} catch (err) {
|
||||
if (!err.isAbort) {
|
||||
ApiClient.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
delete isDownloading[name];
|
||||
isDownloading = isDownloading;
|
||||
}
|
||||
|
||||
function deleteConfirm(name) {
|
||||
confirm(`Do you really want to delete ${name}?`, () => deleteBackup(name));
|
||||
}
|
||||
|
||||
async function deleteBackup(name) {
|
||||
if (isDeleting[name]) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting[name] = true;
|
||||
|
||||
try {
|
||||
await ApiClient.backups.delete(name);
|
||||
CommonHelper.removeByKey(backups, "name", name);
|
||||
loadBackups();
|
||||
addSuccessToast(`Successfully deleted ${name}.`);
|
||||
} catch (err) {
|
||||
if (!err.isAbort) {
|
||||
ApiClient.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
delete isDeleting[name];
|
||||
isDeleting = isDeleting;
|
||||
}
|
||||
|
||||
async function loadCanBackup() {
|
||||
try {
|
||||
const health = await ApiClient.health.check({ $autoCancel: false });
|
||||
const oldCanBackup = canBackup;
|
||||
canBackup = health?.data?.canBackup || false;
|
||||
|
||||
// reload backups list
|
||||
if (oldCanBackup != canBackup && canBackup) {
|
||||
loadBackups();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let canBackupIntervalId = setInterval(() => {
|
||||
loadCanBackup();
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
clearInterval(canBackupIntervalId);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="list list-compact">
|
||||
<div class="list-content">
|
||||
{#if isLoading}
|
||||
{#each Array(backups.length || 1) as i}
|
||||
<div class="list-item list-item-loader">
|
||||
<span class="skeleton-loader" />
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each backups as backup (backup.key)}
|
||||
<div class="list-item" transition:slide|local={{ duration: 150 }}>
|
||||
<i class="ri-folder-zip-line" />
|
||||
<div class="content">
|
||||
<span class="name backup-name">{backup.key}</span>
|
||||
<span class="size txt-hint txt-nowrap">
|
||||
({CommonHelper.formattedFileSize(backup.size)})
|
||||
</span>
|
||||
</div>
|
||||
<div class="actions nonintrusive">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-hint btn-transparent"
|
||||
class:btn-loading={isDownloading[backup.key]}
|
||||
disabled={isDeleting[backup.key] || isDownloading[backup.key]}
|
||||
aria-label="Download"
|
||||
use:tooltip={"Download"}
|
||||
on:click|preventDefault={() => download(backup.key)}
|
||||
>
|
||||
<i class="ri-download-line" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-hint btn-transparent"
|
||||
disabled={isDeleting[backup.key]}
|
||||
aria-label="Restore"
|
||||
use:tooltip={"Restore"}
|
||||
on:click|preventDefault={() => restorePanel.show(backup.key)}
|
||||
>
|
||||
<i class="ri-restart-line" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-hint btn-transparent"
|
||||
class:btn-loading={isDeleting[backup.key]}
|
||||
disabled={isDeleting[backup.key]}
|
||||
aria-label="Delete"
|
||||
use:tooltip={"Delete"}
|
||||
on:click|preventDefault={() => deleteConfirm(backup.key)}
|
||||
>
|
||||
<i class="ri-delete-bin-7-line" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="list-item list-item-placeholder">
|
||||
<span class="txt">No backups yet.</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="list-item list-item-btn">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-block btn-transparent"
|
||||
disabled={isLoading || !canBackup}
|
||||
on:click={() => createPanel?.show()}
|
||||
>
|
||||
{#if canBackup}
|
||||
<i class="ri-play-circle-line" />
|
||||
<span class="txt">Initialize new backup</span>
|
||||
{:else}
|
||||
<span class="loader loader-sm" />
|
||||
<span class="txt">Backup/restore operation is in process</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackupCreatePanel
|
||||
bind:this={createPanel}
|
||||
on:submit={() => {
|
||||
loadBackups();
|
||||
}}
|
||||
/>
|
||||
|
||||
<BackupRestorePanel bind:this={restorePanel} />
|
||||
|
||||
<style lang="scss">
|
||||
.list-content {
|
||||
overflow: auto;
|
||||
max-height: 342px;
|
||||
.list-item {
|
||||
min-height: 49px;
|
||||
}
|
||||
}
|
||||
.backup-name {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -72,7 +72,7 @@
|
||||
hide();
|
||||
} catch (err) {
|
||||
isSubmitting = false;
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
clearTimeout(testTimeoutId);
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function submitWithConfirm() {
|
||||
function submitConfirm() {
|
||||
// find deleted fields
|
||||
const deletedFieldNames = [];
|
||||
if (deleteMissing) {
|
||||
@@ -109,7 +109,7 @@
|
||||
addSuccessToast("Successfully imported collections configuration.");
|
||||
dispatch("submit");
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isImporting = false;
|
||||
@@ -137,13 +137,14 @@
|
||||
{/each}
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<button type="button" class="btn btn-transparent" on:click={hide} disabled={isImporting}>Close</button>
|
||||
<button type="button" class="btn btn-transparent" on:click={hide} disabled={isImporting}>Close</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-expanded"
|
||||
class:btn-loading={isImporting}
|
||||
disabled={isImporting}
|
||||
on:click={() => submitWithConfirm()}
|
||||
on:click={() => submitConfirm()}
|
||||
>
|
||||
<span class="txt">Confirm and import</span>
|
||||
</button>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
const settings = (await ApiClient.settings.getAll()) || {};
|
||||
init(settings);
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
@@ -47,7 +47,7 @@
|
||||
init(settings);
|
||||
addSuccessToast("Successfully saved application settings.");
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isSaving = false;
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
const result = (await ApiClient.settings.getAll()) || {};
|
||||
initSettings(result);
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
<script>
|
||||
import { slide } from "svelte/transition";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
import { pageTitle } from "@/stores/app";
|
||||
import { removeError } from "@/stores/errors";
|
||||
import { addSuccessToast } from "@/stores/toasts";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import PageWrapper from "@/components/base/PageWrapper.svelte";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import Toggler from "@/components/base/Toggler.svelte";
|
||||
import RefreshButton from "@/components/base/RefreshButton.svelte";
|
||||
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
|
||||
import BackupsList from "@/components/settings/BackupsList.svelte";
|
||||
import S3Fields from "@/components/settings/S3Fields.svelte";
|
||||
|
||||
$pageTitle = "Backups";
|
||||
|
||||
let backupsListComponent;
|
||||
let originalFormSettings = {};
|
||||
let formSettings = {};
|
||||
let isLoading = false;
|
||||
let isSaving = false;
|
||||
let initialHash = "";
|
||||
let enableAutoBackups = false;
|
||||
let showBackupsSettings = false;
|
||||
let isTesting = false;
|
||||
let testError = null;
|
||||
|
||||
$: initialHash = JSON.stringify(originalFormSettings);
|
||||
|
||||
$: hasChanges = initialHash != JSON.stringify(formSettings);
|
||||
|
||||
$: if (!enableAutoBackups && formSettings?.backups?.cron) {
|
||||
removeError("backups.cron");
|
||||
formSettings.backups.cron = "";
|
||||
}
|
||||
|
||||
loadSettings();
|
||||
|
||||
async function loadSettings() {
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const settings = (await ApiClient.settings.getAll()) || {};
|
||||
init(settings);
|
||||
} catch (err) {
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isSaving || !hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
|
||||
try {
|
||||
const settings = await ApiClient.settings.update(CommonHelper.filterRedactedProps(formSettings));
|
||||
|
||||
await refreshList();
|
||||
|
||||
init(settings);
|
||||
|
||||
addSuccessToast("Successfully saved application settings.");
|
||||
} catch (err) {
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
function init(settings = {}) {
|
||||
formSettings = {
|
||||
backups: settings?.backups || {},
|
||||
};
|
||||
|
||||
enableAutoBackups = formSettings.backups.cron != "";
|
||||
|
||||
originalFormSettings = JSON.parse(JSON.stringify(formSettings));
|
||||
}
|
||||
|
||||
function reset() {
|
||||
formSettings = JSON.parse(JSON.stringify(originalFormSettings || { backups: {} }));
|
||||
enableAutoBackups = formSettings.backups.cron != "";
|
||||
}
|
||||
|
||||
async function refreshList() {
|
||||
await backupsListComponent?.loadBackups();
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsSidebar />
|
||||
|
||||
<PageWrapper>
|
||||
<header class="page-header">
|
||||
<nav class="breadcrumbs">
|
||||
<div class="breadcrumb-item">Settings</div>
|
||||
<div class="breadcrumb-item">{$pageTitle}</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="wrapper">
|
||||
<div class="panel" autocomplete="off" on:submit|preventDefault={save}>
|
||||
<div class="flex m-b-sm flex-gap-5">
|
||||
<span class="txt-xl">Backup and restore your PocketBase data</span>
|
||||
<RefreshButton
|
||||
class="btn-sm"
|
||||
tooltip={"Reload backups list"}
|
||||
on:refresh={() => refreshList()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BackupsList bind:this={backupsListComponent} />
|
||||
|
||||
<hr />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
class:btn-loading={isLoading}
|
||||
disabled={isLoading}
|
||||
on:click={() => (showBackupsSettings = !showBackupsSettings)}
|
||||
>
|
||||
<span class="txt">Backups options</span>
|
||||
{#if showBackupsSettings}
|
||||
<i class="ri-arrow-up-s-line" />
|
||||
{:else}
|
||||
<i class="ri-arrow-down-s-line" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if showBackupsSettings && !isLoading}
|
||||
<form
|
||||
class="block"
|
||||
autocomplete="off"
|
||||
on:submit|preventDefault={save}
|
||||
transition:slide|local={{ duration: 150 }}
|
||||
>
|
||||
<Field class="form-field form-field-toggle m-t-base m-b-0" let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} required bind:checked={enableAutoBackups} />
|
||||
<label for={uniqueId}>Enable auto backups</label>
|
||||
</Field>
|
||||
|
||||
{#if enableAutoBackups}
|
||||
<div class="block" transition:slide|local={{ duration: 150 }}>
|
||||
<div class="grid p-t-base p-b-sm">
|
||||
<div class="col-lg-6">
|
||||
<Field class="form-field required" name="backups.cron" let:uniqueId>
|
||||
<label for={uniqueId}>Cron expression</label>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
id={uniqueId}
|
||||
class="txt-lg txt-mono"
|
||||
placeholder="* * * * *"
|
||||
autofocus={!originalFormSettings?.backups?.cron}
|
||||
bind:value={formSettings.backups.cron}
|
||||
/>
|
||||
<div class="form-field-addon">
|
||||
<button type="button" class="btn btn-sm btn-outline p-r-0">
|
||||
<span class="txt">Presets</span>
|
||||
<i class="ri-arrow-drop-down-fill" />
|
||||
<Toggler class="dropdown dropdown-nowrap dropdown-right">
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item closable"
|
||||
on:click={() => {
|
||||
formSettings.backups.cron = "0 0 * * *";
|
||||
}}
|
||||
>
|
||||
<span class="txt">Every day at 00:00h</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item closable"
|
||||
on:click={() => {
|
||||
formSettings.backups.cron = "0 0 * * 0";
|
||||
}}
|
||||
>
|
||||
<span class="txt">Every sunday at 00:00h</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item closable"
|
||||
on:click={() => {
|
||||
formSettings.backups.cron = "0 0 * * 1,3";
|
||||
}}
|
||||
>
|
||||
<span class="txt">Every Mon and Wed at 00:00h</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item closable"
|
||||
on:click={() => {
|
||||
formSettings.backups.cron = "0 0 1 * *";
|
||||
}}
|
||||
>
|
||||
<span class="txt">
|
||||
Every first day of the month at 00:00h
|
||||
</span>
|
||||
</button>
|
||||
</Toggler>
|
||||
</button>
|
||||
</div>
|
||||
<div class="help-block">
|
||||
Only numeric list, steps or ranges are supported.
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<Field
|
||||
class="form-field required"
|
||||
name="backups.cronMaxKeep"
|
||||
let:uniqueId
|
||||
>
|
||||
<label for={uniqueId}>Max @auto backups to keep</label>
|
||||
<input
|
||||
type="number"
|
||||
id={uniqueId}
|
||||
min="1"
|
||||
bind:value={formSettings.backups.cronMaxKeep}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="clearfix m-b-base" />
|
||||
|
||||
<S3Fields
|
||||
toggleLabel="Store backups in S3 storage"
|
||||
testFilesystem="backups"
|
||||
configKey="backups.s3"
|
||||
originalConfig={originalFormSettings.backups?.s3}
|
||||
bind:config={formSettings.backups.s3}
|
||||
bind:isTesting
|
||||
bind:testError
|
||||
/>
|
||||
|
||||
<div class="flex">
|
||||
<div class="flex-fill" />
|
||||
|
||||
{#if formSettings.backups?.s3?.enabled && !hasChanges && !isSaving}
|
||||
{#if isTesting}
|
||||
<span class="loader loader-sm" />
|
||||
{:else if testError}
|
||||
<div
|
||||
class="label label-sm label-warning entrance-right"
|
||||
use:tooltip={testError.data?.message}
|
||||
>
|
||||
<i class="ri-error-warning-line txt-warning" />
|
||||
<span class="txt">Failed to establish S3 connection</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="label label-sm label-success entrance-right">
|
||||
<i class="ri-checkbox-circle-line txt-success" />
|
||||
<span class="txt">S3 connected successfully</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if hasChanges}
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-hint btn-transparent"
|
||||
disabled={!hasChanges || isSaving}
|
||||
on:click={() => reset()}
|
||||
>
|
||||
<span class="txt">Reset</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<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>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
@@ -33,7 +33,7 @@
|
||||
delete collection.updated;
|
||||
}
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isLoadingCollections = false;
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
delete collection.updated;
|
||||
}
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isLoadingOldCollections = false;
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
const settings = (await ApiClient.settings.getAll()) || {};
|
||||
init(settings);
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
@@ -64,7 +64,7 @@
|
||||
setErrors({});
|
||||
addSuccessToast("Successfully saved mail settings.");
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isSaving = false;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import CommonHelper from "@/utils/CommonHelper";
|
||||
@@ -8,9 +7,8 @@
|
||||
import { removeAllToasts, addWarningToast, addSuccessToast } from "@/stores/toasts";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import PageWrapper from "@/components/base/PageWrapper.svelte";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import RedactedPasswordInput from "@/components/base/RedactedPasswordInput.svelte";
|
||||
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
|
||||
import S3Fields from "@/components/settings/S3Fields.svelte";
|
||||
|
||||
$pageTitle = "Files storage";
|
||||
|
||||
@@ -21,8 +19,7 @@
|
||||
let isLoading = false;
|
||||
let isSaving = false;
|
||||
let isTesting = false;
|
||||
let testS3Error = null;
|
||||
let testS3TimeoutId = null;
|
||||
let testError = null;
|
||||
|
||||
$: initialHash = JSON.stringify(originalFormSettings);
|
||||
|
||||
@@ -37,7 +34,7 @@
|
||||
const settings = (await ApiClient.settings.getAll()) || {};
|
||||
init(settings);
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
@@ -59,13 +56,13 @@
|
||||
|
||||
removeAllToasts();
|
||||
|
||||
if (testS3Error) {
|
||||
if (testError) {
|
||||
addWarningToast("Successfully saved but failed to establish S3 connection.");
|
||||
} else {
|
||||
addSuccessToast("Successfully saved files storage settings.");
|
||||
}
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isSaving = false;
|
||||
@@ -75,49 +72,13 @@
|
||||
formSettings = {
|
||||
s3: settings?.s3 || {},
|
||||
};
|
||||
originalFormSettings = JSON.parse(JSON.stringify(formSettings));
|
||||
|
||||
await testS3();
|
||||
originalFormSettings = JSON.parse(JSON.stringify(formSettings));
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
formSettings = JSON.parse(JSON.stringify(originalFormSettings || {}));
|
||||
|
||||
await testS3();
|
||||
}
|
||||
|
||||
async function testS3() {
|
||||
testS3Error = null;
|
||||
|
||||
if (!formSettings.s3.enabled) {
|
||||
return; // nothing to test
|
||||
}
|
||||
|
||||
// auto cancel the test request after 30sec
|
||||
ApiClient.cancelRequest(testRequestKey);
|
||||
clearTimeout(testS3TimeoutId);
|
||||
testS3TimeoutId = setTimeout(() => {
|
||||
ApiClient.cancelRequest(testRequestKey);
|
||||
addErrorToast("S3 test connection timeout.");
|
||||
}, 30000);
|
||||
|
||||
isTesting = true;
|
||||
|
||||
try {
|
||||
await ApiClient.settings.testS3({ $cancelKey: testRequestKey });
|
||||
} catch (err) {
|
||||
testS3Error = err;
|
||||
}
|
||||
|
||||
isTesting = false;
|
||||
clearTimeout(testS3TimeoutId);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
return () => {
|
||||
clearTimeout(testS3TimeoutId);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<SettingsSidebar />
|
||||
@@ -142,129 +103,57 @@
|
||||
{#if isLoading}
|
||||
<div class="loader" />
|
||||
{:else}
|
||||
<Field class="form-field form-field-toggle" let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} required bind:checked={formSettings.s3.enabled} />
|
||||
<label for={uniqueId}>Use S3 storage</label>
|
||||
</Field>
|
||||
|
||||
{#if originalFormSettings.s3?.enabled != formSettings.s3.enabled}
|
||||
<div transition:slide|local={{ duration: 150 }}>
|
||||
<div class="alert alert-warning m-0">
|
||||
<div class="icon">
|
||||
<i class="ri-error-warning-line" />
|
||||
</div>
|
||||
<div class="content">
|
||||
If you have existing uploaded files, you'll have to migrate them manually from
|
||||
the
|
||||
<strong>
|
||||
{originalFormSettings.s3?.enabled ? "S3 storage" : "local file system"}
|
||||
</strong>
|
||||
to the
|
||||
<strong>{formSettings.s3.enabled ? "S3 storage" : "local file system"}</strong
|
||||
>.
|
||||
<br />
|
||||
There are numerous command line tools that can help you, such as:
|
||||
<a
|
||||
href="https://github.com/rclone/rclone"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="txt-bold"
|
||||
>
|
||||
rclone
|
||||
</a>,
|
||||
<a
|
||||
href="https://github.com/peak/s5cmd"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="txt-bold"
|
||||
>
|
||||
s5cmd
|
||||
</a>, etc.
|
||||
<S3Fields
|
||||
toggleLabel="Use S3 storage"
|
||||
originalConfig={originalFormSettings.s3}
|
||||
bind:config={formSettings.s3}
|
||||
bind:isTesting
|
||||
bind:testError
|
||||
>
|
||||
{#if originalFormSettings.s3?.enabled != formSettings.s3.enabled}
|
||||
<div transition:slide|local={{ duration: 150 }}>
|
||||
<div class="alert alert-warning m-0">
|
||||
<div class="icon">
|
||||
<i class="ri-error-warning-line" />
|
||||
</div>
|
||||
<div class="content">
|
||||
If you have existing uploaded files, you'll have to migrate them manually
|
||||
from the
|
||||
<strong>
|
||||
{originalFormSettings.s3?.enabled
|
||||
? "S3 storage"
|
||||
: "local file system"}
|
||||
</strong>
|
||||
to the
|
||||
<strong
|
||||
>{formSettings.s3.enabled
|
||||
? "S3 storage"
|
||||
: "local file system"}</strong
|
||||
>.
|
||||
<br />
|
||||
There are numerous command line tools that can help you, such as:
|
||||
<a
|
||||
href="https://github.com/rclone/rclone"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="txt-bold"
|
||||
>
|
||||
rclone
|
||||
</a>,
|
||||
<a
|
||||
href="https://github.com/peak/s5cmd"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="txt-bold"
|
||||
>
|
||||
s5cmd
|
||||
</a>, etc.
|
||||
</div>
|
||||
</div>
|
||||
<div class="clearfix m-t-base" />
|
||||
</div>
|
||||
<div class="clearfix m-t-base" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if formSettings.s3.enabled}
|
||||
<div class="grid" transition:slide|local={{ duration: 150 }}>
|
||||
<div class="col-lg-6">
|
||||
<Field class="form-field required" name="s3.endpoint" let:uniqueId>
|
||||
<label for={uniqueId}>Endpoint</label>
|
||||
<input
|
||||
type="text"
|
||||
id={uniqueId}
|
||||
required
|
||||
bind:value={formSettings.s3.endpoint}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-3">
|
||||
<Field class="form-field required" name="s3.bucket" let:uniqueId>
|
||||
<label for={uniqueId}>Bucket</label>
|
||||
<input
|
||||
type="text"
|
||||
id={uniqueId}
|
||||
required
|
||||
bind:value={formSettings.s3.bucket}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-3">
|
||||
<Field class="form-field required" name="s3.region" let:uniqueId>
|
||||
<label for={uniqueId}>Region</label>
|
||||
<input
|
||||
type="text"
|
||||
id={uniqueId}
|
||||
required
|
||||
bind:value={formSettings.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={formSettings.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={formSettings.s3.secret}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-12">
|
||||
<Field class="form-field" name="s3.forcePathStyle" let:uniqueId>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={uniqueId}
|
||||
bind:checked={formSettings.s3.forcePathStyle}
|
||||
/>
|
||||
<label for={uniqueId}>
|
||||
<span class="txt">Force path-style addressing</span>
|
||||
<i
|
||||
class="ri-information-line link-hint"
|
||||
use:tooltip={{
|
||||
text: 'Forces the request to use path-style addressing, eg. "https://s3.amazonaws.com/BUCKET/KEY" instead of the default "https://BUCKET.s3.amazonaws.com/KEY".',
|
||||
position: "top",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
</div>
|
||||
<!-- margin helper -->
|
||||
<div class="col-lg-12" />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</S3Fields>
|
||||
|
||||
<div class="flex">
|
||||
<div class="flex-fill" />
|
||||
@@ -272,10 +161,10 @@
|
||||
{#if formSettings.s3?.enabled && !hasChanges && !isSaving}
|
||||
{#if isTesting}
|
||||
<span class="loader loader-sm" />
|
||||
{:else if testS3Error}
|
||||
{:else if testError}
|
||||
<div
|
||||
class="label label-sm label-warning entrance-right"
|
||||
use:tooltip={testS3Error.data?.message}
|
||||
use:tooltip={testError.data?.message}
|
||||
>
|
||||
<i class="ri-error-warning-line txt-warning" />
|
||||
<span class="txt">Failed to establish S3 connection</span>
|
||||
@@ -295,7 +184,7 @@
|
||||
disabled={isSaving}
|
||||
on:click={() => reset()}
|
||||
>
|
||||
<span class="txt">Cancel</span>
|
||||
<span class="txt">Reset</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
const result = (await ApiClient.settings.getAll()) || {};
|
||||
initSettings(result);
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
@@ -59,7 +59,7 @@
|
||||
initSettings(result);
|
||||
addSuccessToast("Successfully saved tokens options.");
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isSaving = false;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
import ApiClient from "@/utils/ApiClient";
|
||||
import tooltip from "@/actions/tooltip";
|
||||
import { removeError } from "@/stores/errors";
|
||||
import Field from "@/components/base/Field.svelte";
|
||||
import RedactedPasswordInput from "@/components/base/RedactedPasswordInput.svelte";
|
||||
|
||||
const testRequestKey = "s3_test_request";
|
||||
|
||||
export let originalConfig = {};
|
||||
export let config = {};
|
||||
export let configKey = "s3";
|
||||
export let toggleLabel = "Enable S3";
|
||||
export let testFilesystem = "storage"; // storage or backups
|
||||
export let testError = null;
|
||||
export let isTesting = false;
|
||||
|
||||
let testTimeoutId = null;
|
||||
let testDebounceId = null;
|
||||
|
||||
$: if (originalConfig?.enabled) {
|
||||
testConnectionWithDebounce(100);
|
||||
}
|
||||
|
||||
// clear s3 errors on disable
|
||||
$: if (!config.enabled) {
|
||||
removeError(configKey);
|
||||
}
|
||||
|
||||
function testConnectionWithDebounce(timeout) {
|
||||
isTesting = true;
|
||||
clearTimeout(testDebounceId);
|
||||
testDebounceId = setTimeout(() => {
|
||||
testConnection();
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
testError = null;
|
||||
|
||||
if (!config.enabled) {
|
||||
isTesting = false;
|
||||
return testError; // nothing to test
|
||||
}
|
||||
|
||||
// auto cancel the test request after 30sec
|
||||
ApiClient.cancelRequest(testRequestKey);
|
||||
clearTimeout(testTimeoutId);
|
||||
testTimeoutId = setTimeout(() => {
|
||||
ApiClient.cancelRequest(testRequestKey);
|
||||
testError = new Error("S3 test connection timeout.");
|
||||
isTesting = false;
|
||||
}, 30000);
|
||||
|
||||
isTesting = true;
|
||||
|
||||
let err;
|
||||
|
||||
try {
|
||||
await ApiClient.settings.testS3(testFilesystem, {
|
||||
$cancelKey: testRequestKey,
|
||||
});
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
|
||||
if (!err?.isAbort) {
|
||||
testError = err;
|
||||
isTesting = false;
|
||||
clearTimeout(testTimeoutId);
|
||||
}
|
||||
|
||||
return testError;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
return () => {
|
||||
clearTimeout(testTimeoutId);
|
||||
clearTimeout(testDebounceId);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<Field class="form-field form-field-toggle" let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} required bind:checked={config.enabled} />
|
||||
<label for={uniqueId}>{toggleLabel}</label>
|
||||
</Field>
|
||||
|
||||
<slot {isTesting} {testError} enabled={config.enabled} />
|
||||
|
||||
{#if config.enabled}
|
||||
<div class="grid" transition:slide|local={{ duration: 150 }}>
|
||||
<div class="col-lg-6">
|
||||
<Field class="form-field required" name="{configKey}.endpoint" let:uniqueId>
|
||||
<label for={uniqueId}>Endpoint</label>
|
||||
<input type="text" id={uniqueId} required bind:value={config.endpoint} />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-3">
|
||||
<Field class="form-field required" name="{configKey}.bucket" let:uniqueId>
|
||||
<label for={uniqueId}>Bucket</label>
|
||||
<input type="text" id={uniqueId} required bind:value={config.bucket} />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-3">
|
||||
<Field class="form-field required" name="{configKey}.region" let:uniqueId>
|
||||
<label for={uniqueId}>Region</label>
|
||||
<input type="text" id={uniqueId} required bind:value={config.region} />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<Field class="form-field required" name="{configKey}.accessKey" let:uniqueId>
|
||||
<label for={uniqueId}>Access key</label>
|
||||
<input type="text" id={uniqueId} required bind:value={config.accessKey} />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<Field class="form-field required" name="{configKey}.secret" let:uniqueId>
|
||||
<label for={uniqueId}>Secret</label>
|
||||
<RedactedPasswordInput id={uniqueId} required bind:value={config.secret} />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="col-lg-12">
|
||||
<Field class="form-field" name="{configKey}.forcePathStyle" let:uniqueId>
|
||||
<input type="checkbox" id={uniqueId} bind:checked={config.forcePathStyle} />
|
||||
<label for={uniqueId}>
|
||||
<span class="txt">Force path-style addressing</span>
|
||||
<i
|
||||
class="ri-information-line link-hint"
|
||||
use:tooltip={{
|
||||
text: 'Forces the request to use path-style addressing, eg. "https://s3.amazonaws.com/BUCKET/KEY" instead of the default "https://BUCKET.s3.amazonaws.com/KEY".',
|
||||
position: "top",
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
</div>
|
||||
<!-- margin helper -->
|
||||
<div class="col-lg-12" />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -28,6 +28,15 @@
|
||||
<i class="ri-archive-drawer-line" />
|
||||
<span class="txt">Files storage</span>
|
||||
</a>
|
||||
<a
|
||||
href="/settings/backups"
|
||||
class="sidebar-list-item"
|
||||
use:active={{ path: "/settings/backups/?.*" }}
|
||||
use:link
|
||||
>
|
||||
<i class="ri-archive-line" />
|
||||
<span class="txt">Backups</span>
|
||||
</a>
|
||||
|
||||
<div class="sidebar-title">
|
||||
<span class="txt">Sync</span>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
panel?.hide();
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isSubmitting = false;
|
||||
|
||||
@@ -13,6 +13,7 @@ import PageAuthProviders from "@/components/settings/PageAuthProviders.svelt
|
||||
import PageTokenOptions from "@/components/settings/PageTokenOptions.svelte";
|
||||
import PageExportCollections from "@/components/settings/PageExportCollections.svelte";
|
||||
import PageImportCollections from "@/components/settings/PageImportCollections.svelte";
|
||||
import PageBackups from "@/components/settings/PageBackups.svelte";
|
||||
|
||||
const baseConditions = [
|
||||
async (details) => {
|
||||
@@ -105,6 +106,12 @@ const routes = {
|
||||
userData: { showAppSidebar: true },
|
||||
}),
|
||||
|
||||
"/settings/backups": wrap({
|
||||
component: PageBackups,
|
||||
conditions: baseConditions.concat([(_) => ApiClient.authStore.isValid]),
|
||||
userData: { showAppSidebar: true },
|
||||
}),
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Records email confirmation actions
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
+10
-5
@@ -113,11 +113,16 @@
|
||||
@include shadowize();
|
||||
}
|
||||
}
|
||||
body:not(.overlay-active) {
|
||||
.app-sidebar ~ .app-body .toasts-wrapper {
|
||||
left: var(--appSidebarWidth);
|
||||
|
||||
@media screen and (min-width: 980px) {
|
||||
body:not(.overlay-active):has(.app-sidebar) {
|
||||
.toasts-wrapper {
|
||||
left: var(--appSidebarWidth);
|
||||
}
|
||||
}
|
||||
.app-sidebar ~ .app-body .page-sidebar ~ .toasts-wrapper {
|
||||
left: calc(var(--appSidebarWidth) + var(--pageSidebarWidth));
|
||||
body:not(.overlay-active):has(.page-sidebar) {
|
||||
.toasts-wrapper {
|
||||
left: calc(var(--appSidebarWidth) + var(--pageSidebarWidth));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-4
@@ -511,9 +511,10 @@ a,
|
||||
.thumb {
|
||||
--thumbSize: 40px;
|
||||
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
vertical-align: top;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
@@ -772,7 +773,7 @@ a.thumb:not(.thumb-active) {
|
||||
align-items: center;
|
||||
margin: -1px -5px -1px 0;
|
||||
&.nonintrusive {
|
||||
@include hide();
|
||||
opacity: 0;
|
||||
transform: translateX(5px);
|
||||
transition: transform var(--baseAnimationSpeed),
|
||||
opacity var(--baseAnimationSpeed),
|
||||
@@ -780,9 +781,12 @@ a.thumb:not(.thumb-active) {
|
||||
}
|
||||
}
|
||||
&:hover,
|
||||
&:focus-visible,
|
||||
&:focus-within,
|
||||
&:active {
|
||||
background: var(--bodyColor);
|
||||
.actions.nonintrusive {
|
||||
@include show();
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@@ -807,13 +811,28 @@ a.thumb:not(.thumb-active) {
|
||||
}
|
||||
}
|
||||
|
||||
.list-item-placeholder {
|
||||
color: var(--txtHintColor);
|
||||
}
|
||||
|
||||
.list-item-btn {
|
||||
padding: 5px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.list-item-placeholder,
|
||||
.list-item-btn {
|
||||
&:hover,
|
||||
&:focus-visible,
|
||||
&:focus-within,
|
||||
&:active {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.list-compact {
|
||||
.list-item {
|
||||
gap: 10px;
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ button {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
vertical-align: top;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
outline: 0;
|
||||
@@ -238,6 +239,8 @@ button {
|
||||
}
|
||||
}
|
||||
&.btn-xs {
|
||||
padding-left: 7px;
|
||||
padding-right: 7px;
|
||||
min-width: var(--xsBtnHeight);
|
||||
min-height: var(--xsBtnHeight);
|
||||
}
|
||||
@@ -1108,6 +1111,11 @@ select {
|
||||
transition: background var(--baseAnimationSpeed);
|
||||
.list-item {
|
||||
border-top: 1px solid var(--baseAlt2Color);
|
||||
&:hover,
|
||||
&:focus-visible,
|
||||
&:active {
|
||||
background: none;
|
||||
}
|
||||
&.selected {
|
||||
background: var(--baseAlt2Color);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
--warningColor: #ff944d;
|
||||
--warningAltColor: #ffd4b8;
|
||||
|
||||
--overlayColor: rgba(53, 71, 104, 0.25);
|
||||
--overlayColor: rgba(53, 71, 104, 0.28);
|
||||
--tooltipColor: rgba(0, 0, 0, 0.85);
|
||||
--shadowColor: rgba(0, 0, 0, 0.06);
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ export async function loadCollections(activeId = null) {
|
||||
|
||||
refreshProtectedFilesCollectionsCache();
|
||||
} catch (err) {
|
||||
ApiClient.errorResponseHandler(err);
|
||||
ApiClient.error(err);
|
||||
}
|
||||
|
||||
isCollectionsLoading.set(false);
|
||||
|
||||
@@ -31,7 +31,7 @@ PocketBase.prototype.logout = function(redirect = true) {
|
||||
* @param {Boolean} notify Whether to add a toast notification.
|
||||
* @param {String} defaultMsg Default toast notification message if the error doesn't have one.
|
||||
*/
|
||||
PocketBase.prototype.errorResponseHandler = function(err, notify = true, defaultMsg = "") {
|
||||
PocketBase.prototype.error = function(err, notify = true, defaultMsg = "") {
|
||||
if (!err || !(err instanceof Error) || err.isAbort) {
|
||||
return;
|
||||
}
|
||||
@@ -88,7 +88,7 @@ PocketBase.prototype.getAdminFileToken = async function(collectionId = "") {
|
||||
let token = localStorage.getItem(adminFileTokenKey) || "";
|
||||
|
||||
// request a new token only if the previous one is missing or will expire soon
|
||||
if (!token || isTokenExpired(token, 15)) {
|
||||
if (!token || isTokenExpired(token, 10)) {
|
||||
// remove previously stored token (if any)
|
||||
token && localStorage.removeItem(adminFileTokenKey);
|
||||
|
||||
|
||||
@@ -285,7 +285,7 @@ export default class CommonHelper {
|
||||
const result = JSON.parse(JSON.stringify(obj || {}));
|
||||
|
||||
for (let prop in result) {
|
||||
if (typeof result[prop] === 'object' && result[prop] !== null) {
|
||||
if (typeof result[prop] === "object" && result[prop] !== null) {
|
||||
result[prop] = CommonHelper.filterRedactedProps(result[prop], mask)
|
||||
} else if (result[prop] === mask) {
|
||||
delete result[prop];
|
||||
@@ -313,7 +313,7 @@ export default class CommonHelper {
|
||||
*/
|
||||
static getNestedVal(data, path, defaultVal = null, delimiter = ".") {
|
||||
let result = data || {};
|
||||
let parts = (path || '').split(delimiter);
|
||||
let parts = (path || "").split(delimiter);
|
||||
|
||||
for (const part of parts) {
|
||||
if (
|
||||
@@ -343,7 +343,7 @@ export default class CommonHelper {
|
||||
* @param {String} delimiter
|
||||
*/
|
||||
static setByPath(data, path, newValue, delimiter = ".") {
|
||||
if (data === null || typeof data !== 'object') {
|
||||
if (data === null || typeof data !== "object") {
|
||||
console.warn("setByPath: data not an object or array.");
|
||||
return
|
||||
}
|
||||
@@ -382,7 +382,7 @@ export default class CommonHelper {
|
||||
*/
|
||||
static deleteByPath(data, path, delimiter = ".") {
|
||||
let result = data || {};
|
||||
let parts = (path || '').split(delimiter);
|
||||
let parts = (path || "").split(delimiter);
|
||||
let lastPart = parts.pop();
|
||||
|
||||
for (const part of parts) {
|
||||
@@ -533,7 +533,7 @@ export default class CommonHelper {
|
||||
for (let key in obj) {
|
||||
let value = obj[key];
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (typeof value === "string") {
|
||||
value = CommonHelper.truncate(value, 150, true);
|
||||
}
|
||||
|
||||
@@ -551,47 +551,47 @@ export default class CommonHelper {
|
||||
* @param {Array} [preserved]
|
||||
* @return {String}
|
||||
*/
|
||||
static slugify(str, delimiter = '_', preserved = ['.', '=', '-']) {
|
||||
if (str === '') {
|
||||
return '';
|
||||
static slugify(str, delimiter = "_", preserved = [".", "=", "-"]) {
|
||||
if (str === "") {
|
||||
return "";
|
||||
}
|
||||
|
||||
// special characters
|
||||
const specialCharsMap = {
|
||||
'a': /а|à|á|å|â/gi,
|
||||
'b': /б/gi,
|
||||
'c': /ц|ç/gi,
|
||||
'd': /д/gi,
|
||||
'e': /е|è|é|ê|ẽ|ë/gi,
|
||||
'f': /ф/gi,
|
||||
'g': /г/gi,
|
||||
'h': /х/gi,
|
||||
'i': /й|и|ì|í|î/gi,
|
||||
'j': /ж/gi,
|
||||
'k': /к/gi,
|
||||
'l': /л/gi,
|
||||
'm': /м/gi,
|
||||
'n': /н|ñ/gi,
|
||||
'o': /о|ò|ó|ô|ø/gi,
|
||||
'p': /п/gi,
|
||||
'q': /я/gi,
|
||||
'r': /р/gi,
|
||||
's': /с/gi,
|
||||
't': /т/gi,
|
||||
'u': /ю|ù|ú|ů|û/gi,
|
||||
'v': /в/gi,
|
||||
'w': /в/gi,
|
||||
'x': /ь/gi,
|
||||
'y': /ъ/gi,
|
||||
'z': /з/gi,
|
||||
'ae': /ä|æ/gi,
|
||||
'oe': /ö/gi,
|
||||
'ue': /ü/gi,
|
||||
'Ae': /Ä/gi,
|
||||
'Ue': /Ü/gi,
|
||||
'Oe': /Ö/gi,
|
||||
'ss': /ß/gi,
|
||||
'and': /&/gi
|
||||
"a": /а|à|á|å|â/gi,
|
||||
"b": /б/gi,
|
||||
"c": /ц|ç/gi,
|
||||
"d": /д/gi,
|
||||
"e": /е|è|é|ê|ẽ|ë/gi,
|
||||
"f": /ф/gi,
|
||||
"g": /г/gi,
|
||||
"h": /х/gi,
|
||||
"i": /й|и|ì|í|î/gi,
|
||||
"j": /ж/gi,
|
||||
"k": /к/gi,
|
||||
"l": /л/gi,
|
||||
"m": /м/gi,
|
||||
"n": /н|ñ/gi,
|
||||
"o": /о|ò|ó|ô|ø/gi,
|
||||
"p": /п/gi,
|
||||
"q": /я/gi,
|
||||
"r": /р/gi,
|
||||
"s": /с/gi,
|
||||
"t": /т/gi,
|
||||
"u": /ю|ù|ú|ů|û/gi,
|
||||
"v": /в/gi,
|
||||
"w": /в/gi,
|
||||
"x": /ь/gi,
|
||||
"y": /ъ/gi,
|
||||
"z": /з/gi,
|
||||
"ae": /ä|æ/gi,
|
||||
"oe": /ö/gi,
|
||||
"ue": /ü/gi,
|
||||
"Ae": /Ä/gi,
|
||||
"Ue": /Ü/gi,
|
||||
"Oe": /Ö/gi,
|
||||
"ss": /ß/gi,
|
||||
"and": /&/gi
|
||||
};
|
||||
|
||||
// replace special characters
|
||||
@@ -600,8 +600,8 @@ export default class CommonHelper {
|
||||
}
|
||||
|
||||
return str
|
||||
.replace(new RegExp('[' + preserved.join('') + ']', 'g'), ' ') // replace preserved characters with spaces
|
||||
.replace(/[^\w\ ]/gi, '') // replaces all non-alphanumeric with empty string
|
||||
.replace(new RegExp('[' + preserved.join("") + ']', 'g'), ' ') // replace preserved characters with spaces
|
||||
.replace(/[^\w\ ]/gi, "") // replaces all non-alphanumeric with empty string
|
||||
.replace(/\s+/g, delimiter); // collapse whitespaces and replace with `delimiter`
|
||||
}
|
||||
|
||||
@@ -666,7 +666,7 @@ export default class CommonHelper {
|
||||
* @return {String}
|
||||
*/
|
||||
static getInitials(str) {
|
||||
str = (str || '').split('@')[0].trim();
|
||||
str = (str || "").split("@")[0].trim();
|
||||
|
||||
if (str.length <= 2) {
|
||||
return str.toUpperCase();
|
||||
@@ -681,6 +681,18 @@ export default class CommonHelper {
|
||||
return str[0].toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human readable file size string from size in bytes.
|
||||
*
|
||||
* @param {Number} size s
|
||||
* @return {String}
|
||||
*/
|
||||
static formattedFileSize(size) {
|
||||
const i = size ? Math.floor(Math.log(size) / Math.log(1024)) : 0;
|
||||
|
||||
return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "KB", "MB", "GB", "TB"][i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a DateTime instance from a date object/string.
|
||||
*
|
||||
@@ -688,7 +700,7 @@ export default class CommonHelper {
|
||||
* @return {DateTime}
|
||||
*/
|
||||
static getDateTime(date) {
|
||||
if (typeof date === 'string') {
|
||||
if (typeof date === "string") {
|
||||
const formats = {
|
||||
19: "yyyy-MM-dd HH:mm:ss",
|
||||
23: "yyyy-MM-dd HH:mm:ss.SSS",
|
||||
@@ -696,7 +708,7 @@ export default class CommonHelper {
|
||||
24: "yyyy-MM-dd HH:mm:ss.SSS'Z'",
|
||||
}
|
||||
const format = formats[date.length] || formats[19];
|
||||
return DateTime.fromFormat(date, format, { zone: 'UTC' });
|
||||
return DateTime.fromFormat(date, format, { zone: "UTC" });
|
||||
}
|
||||
|
||||
return DateTime.fromJSDate(date);
|
||||
@@ -709,7 +721,7 @@ export default class CommonHelper {
|
||||
* @param {String} [format] The result format (see https://moment.github.io/luxon/#/parsing?id=table-of-tokens)
|
||||
* @return {String}
|
||||
*/
|
||||
static formatToUTCDate(date, format = 'yyyy-MM-dd HH:mm:ss') {
|
||||
static formatToUTCDate(date, format = "yyyy-MM-dd HH:mm:ss") {
|
||||
return CommonHelper.getDateTime(date).toUTC().toFormat(format);
|
||||
}
|
||||
|
||||
@@ -720,7 +732,7 @@ export default class CommonHelper {
|
||||
* @param {String} [format] The result format (see https://moment.github.io/luxon/#/parsing?id=table-of-tokens)
|
||||
* @return {String}
|
||||
*/
|
||||
static formatToLocalDate(date, format = 'yyyy-MM-dd HH:mm:ss') {
|
||||
static formatToLocalDate(date, format = "yyyy-MM-dd HH:mm:ss") {
|
||||
return CommonHelper.getDateTime(date).toLocal().toFormat(format);
|
||||
}
|
||||
|
||||
@@ -742,20 +754,32 @@ export default class CommonHelper {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the browser to start downloading the specified url.
|
||||
*
|
||||
* @param {String} url The url of the file to download.
|
||||
* @param {String} name The result file name.
|
||||
*/
|
||||
static download(url, name) {
|
||||
const tempLink = document.createElement("a");
|
||||
tempLink.setAttribute("href", url);
|
||||
tempLink.setAttribute("download", name);
|
||||
tempLink.click();
|
||||
tempLink.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a json file created from the provide object.
|
||||
*
|
||||
* @param {mixed} obj The JS object to download.
|
||||
* @param {String} name The result file name.
|
||||
* @param {String} name The result file name.
|
||||
*/
|
||||
static downloadJson(obj, name) {
|
||||
const encodedObj = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(obj, null, 2));
|
||||
|
||||
const tempLink = document.createElement('a');
|
||||
tempLink.setAttribute("href", encodedObj);
|
||||
tempLink.setAttribute("download", name + ".json");
|
||||
tempLink.click();
|
||||
tempLink.remove();
|
||||
name = name.endsWith(".json") ? name : (name + ".json");
|
||||
|
||||
download(encodedObj, name)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -765,7 +789,7 @@ export default class CommonHelper {
|
||||
* @return {Object}
|
||||
*/
|
||||
static getJWTPayload(jwt) {
|
||||
const raw = (jwt || '').split(".")[1] || '';
|
||||
const raw = (jwt || "").split(".")[1] || "";
|
||||
if (raw === "") {
|
||||
return {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user