feat: add S3-compatible storage provider (MinIO, Ceph, R2, etc.)

Adds a new 'S3-Compatible Storage' provider that works with any
S3-API-compatible object storage service, including MinIO, Ceph,
Cloudflare R2, Backblaze B2, and others.

Changes:
- New provider class: classes/providers/storage/s3-compatible-provider.php
  - Provider key: s3compatible
  - Reads user-configured endpoint URL from settings
  - Uses path-style URL access (required by most S3-compatible services)
  - Supports credentials via AS3CF_S3COMPAT_ACCESS_KEY_ID /
    AS3CF_S3COMPAT_SECRET_ACCESS_KEY wp-config.php constants
  - Disables AWS-specific features (Block Public Access, Object Ownership)
- New provider SVG icons (s3compatible.svg, -link.svg, -round.svg)
- Registered provider in main plugin class with endpoint setting support
- Updated StorageProviderSubPage to show endpoint URL input for S3-compatible
- Built pro settings bundle with rollup (Svelte 4.2.19)
- Added package.json and updated rollup.config.mjs for pro-only builds
This commit is contained in:
2026-03-03 12:30:18 +01:00
commit 3248cbb029
2086 changed files with 359427 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
<script>
export let provider;
</script>
<p>{@html provider.define_access_keys_desc}</p>
<pre>{provider.define_access_keys_example}</pre>

View File

@@ -0,0 +1,41 @@
<script>
import {strings} from "../js/stores";
export let provider;
export let accessKeyId = "";
export let secretAccessKey = "";
export let disabled = false;
let accessKeyIdName = "access-key-id";
let accessKeyIdLabel = $strings.access_key_id;
let secretAccessKeyName = "secret-access-key";
let secretAccessKeyLabel = $strings.secret_access_key;
</script>
<p>{@html provider.enter_access_keys_desc}</p>
<label class="input-label" for={accessKeyIdName}>{accessKeyIdLabel}</label>
<input
type="text"
id={accessKeyIdName}
name={accessKeyIdName}
bind:value={accessKeyId}
minlength="20"
size="20"
{disabled}
class:disabled
>
<label class="input-label" for={secretAccessKeyName}>{secretAccessKeyLabel}</label>
<input
type="text"
id={secretAccessKeyName}
name={secretAccessKeyName}
bind:value={secretAccessKey}
autocomplete="off"
minlength="40"
size="40"
{disabled}
class:disabled
>

View File

@@ -0,0 +1,12 @@
<script>
import {strings} from "../js/stores";
import Page from "./Page.svelte";
import AssetsUpgrade from "./AssetsUpgrade.svelte";
export let name = "assets";
</script>
<Page {name} on:routeEvent>
<h2 class="page-title">{$strings.assets_title}</h2>
<AssetsUpgrade/>
</Page>

View File

@@ -0,0 +1,33 @@
<script>
import {strings, urls} from "../js/stores";
import Upsell from "./Upsell.svelte";
let benefits = [
{
icon: $urls.assets + 'img/icon/fonts.svg',
alt: 'js icon',
text: $strings.assets_uppsell_benefits.js,
},
{
icon: $urls.assets + 'img/icon/css.svg',
alt: 'css icon',
text: $strings.assets_uppsell_benefits.css,
},
{
icon: $urls.assets + 'img/icon/fonts.svg',
alt: 'fonts icon',
text: $strings.assets_uppsell_benefits.fonts,
},
];
</script>
<Upsell benefits={benefits}>
<div slot="heading">{$strings.assets_upsell_heading}</div>
<div slot="description">{@html $strings.assets_upsell_description}</div>
<a slot="call-to-action" href={$urls.upsell_discount_assets} class="button btn-lg btn-primary">
<img src={$urls.assets + "img/icon/stars.svg"} alt="stars icon" style="margin-right: 5px;">
{$strings.assets_upsell_cta}
</a>
</Upsell>

View File

@@ -0,0 +1,57 @@
<script>
import {createEventDispatcher} from "svelte";
import {strings} from "../js/stores";
import Button from "./Button.svelte";
const dispatch = createEventDispatcher();
// Back button is optional but shown by default.
export let backText = $strings.back;
export let backDisabled = false;
export let backTitle = "";
export let backVisible = false;
// Skip button is optional, only shown if explicitly made visible.
export let skipText = $strings.skip;
export let skipDisabled = false;
export let skipTitle = "";
export let skipVisible = false;
// Next button required.
export let nextText = $strings.next;
export let nextDisabled = false;
export let nextTitle = "";
</script>
<div class="btn-row">
{#if backVisible}
<Button
large
on:click="{() => dispatch('back')}"
disabled={backDisabled}
title={backTitle}
>
{backText}
</Button>
{/if}
{#if skipVisible}
<Button
large
outline
on:click="{() => dispatch('skip')}"
disabled={skipDisabled}
title={skipTitle}
>
{skipText}
</Button>
{/if}
<Button
large
primary
on:click="{() => dispatch('next')}"
disabled={nextDisabled}
title={nextTitle}
>
{nextText}
</Button>
</div>

View File

@@ -0,0 +1,427 @@
<script>
import {
createEventDispatcher,
getContext,
hasContext,
onMount
} from "svelte";
import {writable} from "svelte/store";
import {slide} from "svelte/transition";
import {
api,
settings,
defined_settings,
strings,
storage_provider,
urls,
current_settings,
needs_refresh,
revalidatingSettings,
state
} from "../js/stores";
import {scrollIntoView} from "../js/scrollIntoView";
import {
scrollNotificationsIntoView
} from "../js/scrollNotificationsIntoView";
import {needsRefresh} from "../js/needsRefresh";
import SubPage from "./SubPage.svelte";
import Panel from "./Panel.svelte";
import PanelRow from "./PanelRow.svelte";
import TabButton from "./TabButton.svelte";
import BackNextButtonsRow from "./BackNextButtonsRow.svelte";
import RadioButton from "./RadioButton.svelte";
import Loading from "./Loading.svelte";
import DefinedInWPConfig from "./DefinedInWPConfig.svelte";
const dispatch = createEventDispatcher();
// Parent page may want to be locked.
let settingsLocked = writable( false );
if ( hasContext( "settingsLocked" ) ) {
settingsLocked = getContext( "settingsLocked" );
}
// Keep track of where we were at prior to any changes made here.
let initialSettings = $current_settings;
if ( hasContext( "initialSettings" ) ) {
initialSettings = getContext( "initialSettings" );
}
// As this page does not directly alter the settings store until done,
// we need to keep track of any changes made elsewhere and prompt
// the user to refresh the page.
let saving = false;
const previousSettings = { ...$current_settings };
const previousDefines = { ...$defined_settings };
$: {
$needs_refresh = $needs_refresh || needsRefresh( saving, previousSettings, $current_settings, previousDefines, $defined_settings );
}
let bucketSource = "existing";
let enterOrSelectExisting = "enter";
// If $defined_settings.bucket set, must use it, and disable change.
let newBucket = $settings.bucket;
$: defined = $defined_settings.includes( "bucket" );
$: disabled = defined || $needs_refresh || $settingsLocked;
// If $defined_settings.region set, must use it, and disable change.
let newRegion = $settings.region;
$: newRegionDefined = $defined_settings.includes( "region" );
$: newRegionDisabled = newRegionDefined || $needs_refresh || $settingsLocked;
/**
* Handles clicking the Existing radio button.
*/
function handleExisting() {
if ( disabled ) {
return;
}
bucketSource = "existing";
}
/**
* Handles clicking the New radio button.
*/
function handleNew() {
if ( disabled ) {
return;
}
bucketSource = "new";
}
/**
* Calls the API to get a list of existing buckets for the currently selected storage provider and region (if applicable).
*
* @param {string} region
*
* @return {Promise<*[]>}
*/
async function getBuckets( region ) {
let params = {};
if ( $storage_provider.region_required ) {
params = { region: region };
}
let data = await api.get( "buckets", params );
if ( data.hasOwnProperty( "buckets" ) ) {
if ( data.buckets.filter( ( bucket ) => bucket.Name === newBucket ).length === 0 ) {
newBucket = "";
}
return data.buckets;
}
newBucket = "";
return [];
}
/**
* Calls the API to create a new bucket with the currently entered name and selected region.
*
* @return {Promise<boolean>}
*/
async function createBucket() {
let data = await api.post( "buckets", {
bucket: newBucket,
region: newRegion
} )
if ( data.hasOwnProperty( "saved" ) ) {
return data.saved;
}
return false;
}
/**
* Potentially returns a reason that the provided bucket name is invalid.
*
* @param {string} bucket
* @param {string} source Either "existing" or "new".
* @param {string} existingType Either "enter" or "select".
*
* @return {string}
*/
function getInvalidBucketNameMessage( bucket, source, existingType ) {
// If there's an invalid region defined, don't even bother looking at bucket name.
if ( newRegionDefined && (newRegion.length === 0 || !$storage_provider.regions.hasOwnProperty( newRegion )) ) {
return $strings.defined_region_invalid;
}
const bucketNamePattern = source === "new" ? /[^a-z0-9.\-]/ : /[^a-zA-Z0-9.\-_]/;
let message = "";
if ( bucket.trim().length < 1 ) {
if ( source === "existing" && existingType === "select" ) {
message = $strings.no_bucket_selected;
} else {
message = $strings.create_bucket_name_missing;
}
} else if ( true === bucketNamePattern.test( bucket ) ) {
message = source === "new" ? $strings.create_bucket_invalid_chars : $strings.select_bucket_invalid_chars;
} else if ( bucket.length < 3 ) {
message = $strings.create_bucket_name_short;
} else if ( bucket.length > 63 ) {
message = $strings.create_bucket_name_long;
}
return message;
}
$: invalidBucketNameMessage = getInvalidBucketNameMessage( newBucket, bucketSource, enterOrSelectExisting );
/**
* Returns text to be used on Next button.
*
* @param {string} source Either "existing" or "new".
* @param {string} existingType Either "enter" or "select".
*
* @return {string}
*/
function getNextText( source, existingType ) {
if ( source === "existing" && existingType === "enter" ) {
return $strings.save_enter_bucket;
}
if ( source === "existing" && existingType === "select" ) {
return $strings.save_select_bucket;
}
if ( source === "new" ) {
return $strings.save_new_bucket;
}
return $strings.next;
}
$: nextText = getNextText( bucketSource, enterOrSelectExisting );
/**
* Handles a Next button click.
*
* @return {Promise<void>}
*/
async function handleNext() {
if ( bucketSource === "new" && false === await createBucket() ) {
scrollNotificationsIntoView();
return;
}
saving = true;
state.pausePeriodicFetch();
$settings.bucket = newBucket;
$settings.region = newRegion;
const result = await settings.save();
// If something went wrong, don't move onto next step.
if ( result.hasOwnProperty( "saved" ) && !result.saved ) {
settings.reset();
saving = false;
await state.resumePeriodicFetch();
scrollNotificationsIntoView();
return;
}
$revalidatingSettings = true;
const statePromise = state.resumePeriodicFetch();
result.bucketSource = bucketSource;
result.initialSettings = initialSettings;
dispatch( "routeEvent", {
event: "settings.save",
data: result,
default: "/"
} );
// Just make sure periodic state fetch promise is done with,
// even though we don't really care about it.
await statePromise;
$revalidatingSettings = false;
}
onMount( () => {
// Default to first region in storage provider if not defined and not set or not valid.
if ( !newRegionDefined && (newRegion.length === 0 || !$storage_provider.regions.hasOwnProperty( newRegion )) ) {
newRegion = Object.keys( $storage_provider.regions )[ 0 ];
}
} );
</script>
<SubPage name="bucket-settings" route="/storage/bucket">
<Panel heading={$strings.bucket_source_title} multi {defined}>
<PanelRow class="body flex-row tab-buttons">
<TabButton
active={bucketSource === "existing"}
{disabled}
text={$strings.use_existing_bucket}
on:click={handleExisting}
/>
<TabButton
active={bucketSource === "new"}
{disabled}
text={$strings.create_new_bucket}
on:click={handleNew}
/>
</PanelRow>
</Panel>
{#if bucketSource === "existing"}
<Panel heading={$strings.existing_bucket_title} storageProvider={$storage_provider} multi {defined}>
<PanelRow class="body flex-column">
<div class="flex-row align-center row radio-btns">
<RadioButton bind:selected={enterOrSelectExisting} value="enter" list {disabled}>{$strings.enter_bucket}</RadioButton>
<RadioButton bind:selected={enterOrSelectExisting} value="select" list {disabled}>{$strings.select_bucket}</RadioButton>
</div>
{#if enterOrSelectExisting === "enter"}
<div class="flex-row align-center row">
<div class="new-bucket-details flex-column">
<label class="input-label" for="bucket-name">{$strings.bucket_name}</label>
<input
type="text"
id="bucket-name"
class="bucket-name"
name="bucket"
minlength="3"
placeholder={$strings.enter_bucket_name_placeholder}
bind:value={newBucket}
class:disabled
{disabled}
>
</div>
{#if $storage_provider.region_required}
<div class="region flex-column">
<label class="input-label" for="region">
{$strings.region}&nbsp;<DefinedInWPConfig defined={newRegionDefined}/>
</label>
<select name="region" id="region" bind:value={newRegion} disabled={newRegionDisabled} class:disabled={newRegionDisabled}>
{#each Object.entries( $storage_provider.regions ) as [regionKey, regionName], index}
<option
value={regionKey}
selected={regionKey === newRegion}
>
{regionName}
</option>
{/each}
</select>
</div>
{/if}
</div>
{/if}
{#if enterOrSelectExisting === "select"}
{#if $storage_provider.region_required}
<label class="input-label" for="list-region">
{$strings.region}&nbsp;<DefinedInWPConfig defined={newRegionDefined}/>
</label>
<select name="region" id="list-region" bind:value={newRegion} disabled={newRegionDisabled} class:disabled={newRegionDisabled}>
{#each Object.entries( $storage_provider.regions ) as [regionKey, regionName], index}
<option
value={regionKey}
selected={regionKey === newRegion}
>
{regionName}
</option>
{/each}
</select>
{/if}
{#await getBuckets( newRegion )}
<Loading/>
{:then buckets}
<ul class="bucket-list">
{#if buckets.length}
{#each buckets as bucket}
<!-- TODO: Fix a11y. -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<li
class="row"
class:active={newBucket === bucket.Name}
on:click={() => newBucket = bucket.Name}
use:scrollIntoView={newBucket === bucket.Name}
data-bucket-name={bucket.Name}
>
<img class="icon bucket" src="{$urls.assets + 'img/icon/bucket.svg'}" alt={$strings.bucket_icon}>
<p>{bucket.Name}</p>
{#if newBucket === bucket.Name}
<img class="icon status" src="{$urls.assets + 'img/icon/licence-checked.svg'}" type="image/svg+xml" alt={$strings.selected_desc}>
{/if}
</li>
{/each}
{:else}
<li class="row nothing-found">
<p>{$strings.nothing_found}</p>
</li>
{/if}
</ul>
{/await}
{/if}
{#if invalidBucketNameMessage}
<p class="input-error" transition:slide>{invalidBucketNameMessage}</p>
{/if}
</PanelRow>
</Panel>
{/if}
{#if bucketSource === "new"}
<Panel heading={$strings.new_bucket_title} storageProvider={$storage_provider} multi {defined}>
<PanelRow class="body flex-column">
<div class="flex-row align-center row">
<div class="new-bucket-details flex-column">
<label class="input-label" for="new-bucket-name">{$strings.bucket_name}</label>
<input
type="text"
id="new-bucket-name"
class="bucket-name"
name="bucket"
minlength="3"
placeholder={$strings.enter_bucket_name_placeholder}
bind:value={newBucket}
class:disabled
{disabled}
>
</div>
<div class="region flex-column">
<label class="input-label" for="new-region">
{$strings.region}&nbsp;<DefinedInWPConfig defined={newRegionDefined}/>
</label>
<select name="region" id="new-region" bind:value={newRegion} disabled={newRegionDisabled} class:disabled={newRegionDisabled}>
{#each Object.entries( $storage_provider.regions ) as [regionKey, regionName], index}
<option
value={regionKey}
selected={regionKey === newRegion}
>
{regionName}
</option>
{/each}
</select>
</div>
</div>
{#if invalidBucketNameMessage}
<p class="input-error" transition:slide>{invalidBucketNameMessage}</p>
{/if}
</PanelRow>
</Panel>
{/if}
<BackNextButtonsRow
on:next={handleNext}
{nextText}
nextDisabled={invalidBucketNameMessage || $needs_refresh || $settingsLocked}
nextTitle={invalidBucketNameMessage}
/>
</SubPage>

View File

@@ -0,0 +1,76 @@
<script>
import {createEventDispatcher} from "svelte";
import {urls} from "../js/stores";
const classes = $$props.class ? $$props.class : "";
const dispatch = createEventDispatcher();
export let ref = {};
// Button sizes, medium is the default.
export let extraSmall = false;
export let small = false;
export let large = false;
export let medium = !extraSmall && !small && !large;
// Button styles, outline is the default.
export let primary = false;
export let expandable = false;
export let refresh = false;
export let outline = !primary && !expandable && !refresh;
// Is the button disabled? Defaults to false.
export let disabled = false;
// Is the button in an expanded state? Defaults to false.
export let expanded = false;
// Is the button in a refreshing state? Defaults to false.
export let refreshing = false;
// A button can have a title, most useful to give a reason when disabled.
export let title = "";
/**
* Catch escape key and emit a custom cancel event.
*
* @param {KeyboardEvent} event
*/
function handleKeyup( event ) {
if ( event.key === "Escape" ) {
event.preventDefault();
dispatch( "cancel" );
}
}
function refreshIcon( refreshing ) {
return $urls.assets + 'img/icon/' + (refreshing ? 'refresh-disabled.svg' : 'refresh.svg');
}
</script>
<button
on:click|preventDefault
class:btn-xs={extraSmall}
class:btn-sm={small}
class:btn-md={medium}
class:btn-lg={large}
class:btn-primary={primary}
class:btn-outline={outline}
class:btn-expandable={expandable}
class:btn-disabled={disabled}
class:btn-expanded={expanded}
class:btn-refresh={refresh}
class:btn-refreshing={refreshing}
class={classes}
{title}
disabled={disabled || refreshing}
bind:this={ref}
on:focusout
on:keyup={handleKeyup}
>
{#if refresh}
<img class="icon refresh" class:refreshing src="{refreshIcon(refreshing)}" alt={title}/>
{/if}
<slot/>
</button>

View File

@@ -0,0 +1,66 @@
<script>
import Button from "./Button.svelte";
import {
api,
revalidatingSettings,
settings_validation,
state,
strings
} from "../js/stores";
import {delayMin} from "../js/delay";
export let section = "";
$: refreshing = $revalidatingSettings;
let datetime = new Date( $settings_validation[ section ].timestamp * 1000 ).toString();
/**
* Calls the API to revalidate settings.
*/
async function revalidate() {
let start = Date.now();
let params = {
revalidateSettings: true,
section: section,
};
refreshing = true;
let json = await api.get( "state", params );
await delayMin( start, 1000 );
state.updateState( json );
refreshing = false;
}
</script>
<div class="check-again">
{#if !refreshing}
<Button refresh {refreshing} title={$strings.check_again_desc} on:click={revalidate}>
{$strings.check_again_title}
</Button>
{:else}
<Button refresh {refreshing} title={$strings.check_again_desc}>
{$strings.check_again_active}
</Button>
{/if}
<span class="last-update" title="{datetime}">
{$settings_validation[ section ].last_update}
</span>
</div>
<style>
div.check-again {
display: flex;
flex-direction: column;
align-items: flex-end;
white-space: nowrap;
min-width: 6rem;
padding-left: 0.5rem;
color: var(--as3cf-color-gray-700);
}
#as3cf-settings .check-again .last-update {
padding-right: 2px;
margin-top: 2px;
}
</style>

View File

@@ -0,0 +1,12 @@
<script>
export let name = "";
export let checked = false;
export let disabled = false;
</script>
<div class="checkbox" class:locked={disabled} class:disabled>
<label class="toggle-label" for={name}>
<input type="checkbox" id={name} bind:checked={checked} {disabled}/>
<slot/>
</label>
</div>

View File

@@ -0,0 +1,9 @@
<script>
import {strings} from "../js/stores";
export let defined = false;
</script>
{#if defined}
<p class="wp-config">{$strings.defined_in_wp_config}</p>
{/if}

View File

@@ -0,0 +1,199 @@
<script>
import {createEventDispatcher, setContext} from "svelte";
import {
strings,
settings,
storage_provider,
delivery_providers,
delivery_provider,
defined_settings,
settingsLocked,
current_settings,
needs_refresh,
revalidatingSettings,
state
} from "../js/stores";
import {
scrollNotificationsIntoView
} from "../js/scrollNotificationsIntoView";
import {needsRefresh} from "../js/needsRefresh";
import Page from "./Page.svelte";
import Notifications from "./Notifications.svelte";
import Panel from "./Panel.svelte";
import PanelRow from "./PanelRow.svelte";
import TabButton from "./TabButton.svelte";
import BackNextButtonsRow from "./BackNextButtonsRow.svelte";
import HelpButton from "./HelpButton.svelte";
const dispatch = createEventDispatcher();
export let name = "delivery-provider";
export let params = {}; // Required for regex routes.
const _params = params; // Stops compiler warning about unused params export;
// Let all child components know if settings are currently locked.
setContext( "settingsLocked", settingsLocked );
// As this page does not directly alter the settings store until done,
// we need to keep track of any changes made elsewhere and prompt
// the user to refresh the page.
let saving = false;
const previousSettings = { ...$current_settings };
const previousDefines = { ...$defined_settings };
$: {
$needs_refresh = $needs_refresh || needsRefresh( saving, previousSettings, $current_settings, previousDefines, $defined_settings );
}
// Start with a copy of the current delivery provider.
let deliveryProvider = { ...$delivery_provider };
$: defined = $defined_settings.includes( "delivery-provider" );
$: disabled = defined || $settingsLocked;
let serviceName = $settings[ "delivery-provider-service-name" ];
$: serviceNameDefined = $defined_settings.includes( "delivery-provider-service-name" );
$: serviceNameDisabled = serviceNameDefined || $settingsLocked;
/**
* Returns an array of delivery providers that can be used with the currently configured storage provider.
*
* @return {array}
*/
function supportedDeliveryProviders() {
return Object.values( $delivery_providers ).filter(
( provider ) => provider.supported_storage_providers.length === 0 || provider.supported_storage_providers.includes( $storage_provider.provider_key_name )
);
}
/**
* Determines whether the Next button should be disabled or not and returns a suitable reason.
*
* @param {Object} provider
* @param {string} providerName
* @param {boolean} settingsLocked
* @param {boolean} needsRefresh
*
* @return {string}
*/
function getNextDisabledMessage( provider, providerName, settingsLocked, needsRefresh ) {
let message = "";
if ( settingsLocked || needsRefresh ) {
message = $strings.settings_locked;
} else if ( provider.provider_service_name_override_allowed && providerName.trim().length < 1 ) {
message = $strings.no_delivery_provider_name;
} else if ( provider.provider_service_name_override_allowed && providerName.trim().length < 4 ) {
message = $strings.delivery_provider_name_short;
} else if ( deliveryProvider.provider_key_name === $delivery_provider.provider_key_name && providerName === $settings[ "delivery-provider-service-name" ] ) {
message = $strings.nothing_to_save;
}
return message;
}
$: nextDisabledMessage = getNextDisabledMessage( deliveryProvider, serviceName, $settingsLocked, $needs_refresh );
/**
* Handles choosing a different delivery provider.
*
* @param {Object} provider
*/
function handleChooseProvider( provider ) {
if ( disabled ) {
return;
}
deliveryProvider = provider;
}
/**
* Handles a Next button click.
*
* @return {Promise<void>}
*/
async function handleNext() {
saving = true;
state.pausePeriodicFetch();
$settings[ "delivery-provider" ] = deliveryProvider.provider_key_name;
$settings[ "delivery-provider-service-name" ] = serviceName;
const result = await settings.save();
// If something went wrong, don't move onto next step.
if ( result.hasOwnProperty( "saved" ) && !result.saved ) {
settings.reset();
saving = false;
await state.resumePeriodicFetch();
scrollNotificationsIntoView();
return;
}
$revalidatingSettings = true;
const statePromise = state.resumePeriodicFetch();
dispatch( "routeEvent", {
event: "settings.save",
data: result,
default: "/media/delivery"
} );
// Just make sure periodic state fetch promise is done with,
// even though we don't really care about it.
await statePromise;
$revalidatingSettings = false;
}
</script>
<Page {name} subpage on:routeEvent>
<Notifications tab={name} tabParent="media"/>
<h2 class="page-title">{$strings.delivery_title}</h2>
<div class="delivery-provider-settings-page wrapper">
<Panel heading={$strings.select_delivery_provider_title} defined={defined} multi>
<PanelRow class="body flex-column delivery-provider-buttons">
{#each supportedDeliveryProviders() as provider}
<div class="row">
<TabButton
active={provider.provider_key_name === deliveryProvider.provider_key_name}
{disabled}
icon={provider.icon}
text={provider.default_provider_service_name}
on:click={() => handleChooseProvider( provider )}
/>
<p class="speed">{@html provider.edge_server_support_desc}</p>
<p class="private-media">{@html provider.signed_urls_support_desc}</p>
<HelpButton url={provider.provider_service_quick_start_url} desc={$strings.view_quick_start_guide}/>
</div>
{/each}
</PanelRow>
</Panel>
{#if deliveryProvider.provider_service_name_override_allowed}
<Panel heading={$strings.enter_other_cdn_name_title} defined={serviceNameDefined} multi>
<PanelRow class="body flex-column">
<input
type="text"
class="cdn-name"
id="cdn-name"
name="cdn-name"
minlength="4"
placeholder={$strings.enter_other_cdn_name_placeholder}
bind:value={serviceName}
disabled={serviceNameDisabled}
>
</PanelRow>
</Panel>
{/if}
<BackNextButtonsRow
on:next={handleNext}
nextText={$strings.save_delivery_provider}
nextDisabled={nextDisabledMessage}
nextTitle={nextDisabledMessage}
/>
</div>
</Page>

View File

@@ -0,0 +1,72 @@
<script>
import {hasContext, getContext} from "svelte";
import {writable} from "svelte/store";
import {push} from "svelte-spa-router";
import {
delivery_provider,
settings,
storage_provider,
strings,
urls
} from "../js/stores";
import PanelRow from "./PanelRow.svelte";
import Button from "./Button.svelte";
// Parent page may want to be locked.
let settingsLocked = writable( false );
if ( hasContext( "settingsLocked" ) ) {
settingsLocked = getContext( "settingsLocked" );
}
$: providerType = $settings[ 'delivery-provider' ] === 'storage' ? 'storage' : 'delivery';
$: providerKey = providerType === 'storage' ? $storage_provider.provider_key_name : $delivery_provider.provider_key_name;
</script>
<PanelRow header gradient class="delivery {providerType} {providerKey}">
<img src="{$delivery_provider.icon}" alt={$delivery_provider.provider_service_name}/>
<div class="provider-details">
<h3>{$delivery_provider.provider_service_name}</h3>
<p class="console-details">
<a href={$urls.delivery_provider_console_url} class="console" target="_blank" title={$strings.view_provider_console}>{$delivery_provider.console_title}</a>
</p>
</div>
<Button outline on:click={() => push('/delivery/provider')} title={$strings.edit_delivery_provider} disabled={$settingsLocked}>{$strings.edit}</Button>
</PanelRow>
<style>
:global(#as3cf-settings.wpome div.panel.settings .header) img {
width: var(--as3cf-settings-ctrl-width);
height: var(--as3cf-settings-ctrl-width);
}
.provider-details {
display: flex;
flex-direction: column;
flex: auto;
margin-left: var(--as3cf-settings-option-indent);
z-index: 1;
}
:global(#as3cf-settings.wpome div.panel) .provider-details h3 {
margin-left: 0;
margin-bottom: 0.5rem;
}
:global(#as3cf-settings.wpome div.panel) .console-details {
display: flex;
align-items: center;
font-size: 0.75rem;
}
.console-details .console {
flex: 0 1 min-content;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
:global(#as3cf-settings.wpome div.panel) .console-details a[target="_blank"].console:after {
margin-right: 0;
}
</style>

View File

@@ -0,0 +1,99 @@
<script>
import {delivery_provider, settings, strings} from "../js/stores";
import Panel from "./Panel.svelte";
import DeliverySettingsHeadingRow
from "./DeliverySettingsHeadingRow.svelte";
import SettingsValidationStatusRow from "./SettingsValidationStatusRow.svelte";
import SettingsPanelOption from "./SettingsPanelOption.svelte";
/**
* Potentially returns a reason that the provided domain name is invalid.
*
* @param {string} domain
*
* @return {string}
*/
function domainValidator( domain ) {
const domainPattern = /[^a-z0-9.-]/;
let message = "";
if ( domain.trim().length === 0 ) {
message = $strings.domain_blank;
} else if ( true === domainPattern.test( domain ) ) {
message = $strings.domain_invalid_content;
} else if ( domain.length < 3 ) {
message = $strings.domain_too_short;
}
return message;
}
</script>
<Panel name="settings" heading={$strings.delivery_settings_title} helpKey="delivery-provider">
<DeliverySettingsHeadingRow/>
<SettingsValidationStatusRow section="delivery"/>
<SettingsPanelOption
heading={$strings.rewrite_media_urls}
description={$delivery_provider.rewrite_media_urls_desc}
toggleName="serve-from-s3"
bind:toggle={$settings["serve-from-s3"]}
/>
{#if $delivery_provider.delivery_domain_allowed}
<SettingsPanelOption
heading={$strings.delivery_domain}
description={$delivery_provider.delivery_domain_desc}
toggleName="enable-delivery-domain"
bind:toggle={$settings["enable-delivery-domain"]}
textName="delivery-domain"
bind:text={$settings["delivery-domain"]}
validator={domainValidator}
/>
{#if $delivery_provider.use_signed_urls_key_file_allowed && $settings[ "enable-delivery-domain" ]}
<SettingsPanelOption
heading={$delivery_provider.signed_urls_option_name}
description={$delivery_provider.signed_urls_option_description}
toggleName="enable-signed-urls"
bind:toggle={$settings["enable-signed-urls"]}
>
<!-- Currently only CloudFront needs a key file for signing -->
{#if $settings[ "enable-signed-urls" ]}
<SettingsPanelOption
heading={$delivery_provider.signed_urls_key_id_name}
description={$delivery_provider.signed_urls_key_id_description}
textName="signed-urls-key-id"
bind:text={$settings["signed-urls-key-id"]}
nested={true}
first={true}
/>
<SettingsPanelOption
heading={$delivery_provider.signed_urls_key_file_path_name}
description={$delivery_provider.signed_urls_key_file_path_description}
textName="signed-urls-key-file-path"
bind:text={$settings["signed-urls-key-file-path"]}
placeholder={$delivery_provider.signed_urls_key_file_path_placeholder}
nested={true}
/>
<SettingsPanelOption
heading={$delivery_provider.signed_urls_object_prefix_name}
description={$delivery_provider.signed_urls_object_prefix_description}
textName="signed-urls-object-prefix"
bind:text={$settings["signed-urls-object-prefix"]}
placeholder="private/"
nested={true}
/>
{/if}
</SettingsPanelOption>
{/if}
{/if}
<SettingsPanelOption
heading={$strings.force_https}
description={$strings.force_https_desc}
toggleName="force-https"
bind:toggle={$settings["force-https"]}
/>
</Panel>

View File

@@ -0,0 +1,8 @@
<script>
import SubPage from "./SubPage.svelte";
import DeliverySettingsPanel from "./DeliverySettingsPanel.svelte";
</script>
<SubPage name="delivery-settings" route="/media/delivery">
<DeliverySettingsPanel/>
</SubPage>

View File

@@ -0,0 +1,75 @@
<script>
import {createEventDispatcher, onDestroy} from "svelte";
import {slide} from "svelte/transition";
import {
revalidatingSettings,
settings_changed,
settings,
strings,
state,
validationErrors
} from "../js/stores";
import {
scrollNotificationsIntoView
} from "../js/scrollNotificationsIntoView";
import Button from "./Button.svelte";
const dispatch = createEventDispatcher();
export let settingsStore = settings;
export let settingsChangedStore = settings_changed;
let saving = false;
$: disabled = saving || $validationErrors.size > 0;
// On init, start with no validation errors.
validationErrors.set( new Map() );
/**
* Handles a Cancel button click.
*/
function handleCancel() {
settingsStore.reset();
}
/**
* Handles a Save button click.
*
* @return {Promise<void>}
*/
async function handleSave() {
saving = true;
state.pausePeriodicFetch();
const result = await settingsStore.save();
$revalidatingSettings = true;
const statePromise = state.resumePeriodicFetch();
// The save happened, whether anything changed or not.
if ( result.hasOwnProperty( "saved" ) && result.hasOwnProperty( "changed_settings" ) ) {
dispatch( "routeEvent", { event: "settings.save", data: result } );
}
// After save make sure notifications are eyeballed.
scrollNotificationsIntoView();
saving = false;
// Just make sure periodic state fetch promise is done with,
// even though we don't really care about it.
await statePromise;
$revalidatingSettings = false;
}
// On navigation away from a component showing the footer,
// make sure settings are reset.
onDestroy( () => handleCancel() );
</script>
{#if $settingsChangedStore}
<div class="fixed-cta-block" transition:slide>
<div class="buttons">
<Button outline on:click={handleCancel}>{$strings.cancel_button}</Button>
<Button primary on:click={handleSave} {disabled}>{$strings.save_changes}</Button>
</div>
</div>
{/if}

View File

@@ -0,0 +1,15 @@
<script>
import {config, urls} from "../js/stores";
$: header_img_url = $urls.assets + "img/brand/ome-medallion.svg";
</script>
<div class="header">
<div class="header-wrapper">
<img class="medallion" src={header_img_url} alt="{$config.title} logo">
<h1>{$config.title}</h1>
<div class="licence">
<slot/>
</div>
</div>
</div>

View File

@@ -0,0 +1,17 @@
<script>
import {strings, urls, docs} from "../js/stores";
export let key = "";
export let url = key && $docs.hasOwnProperty( key ) && $docs[ key ].hasOwnProperty( "url" ) ? $docs[ key ].url : "";
export let desc = "";
// If desc supplied, use it, otherwise try and get via docs store or fall back to default help description.
let alt = desc.length ? desc : (key && $docs.hasOwnProperty( key ) && $docs[ key ].hasOwnProperty( "desc" ) ? $docs[ key ].desc : $strings.help_desc);
let title = alt;
</script>
{#if url}
<a href={url} {title} class="help" target="_blank" data-setting-key={key}>
<img class="icon help" src="{$urls.assets + 'img/icon/help.svg'}" {alt}/>
</a>
{/if}

View File

@@ -0,0 +1,7 @@
<script>
export let provider;
</script>
<p>{@html provider.define_key_file_desc}</p>
<pre>{provider.define_key_file_example}</pre>

View File

@@ -0,0 +1,15 @@
<script>
import {strings} from "../js/stores";
export let provider;
export let value = "";
export let disabled = false;
let name = "key-file";
let label = $strings.key_file;
</script>
<p>{@html provider.enter_key_file_desc}</p>
<label class="input-label" for={name}>{label}</label>
<textarea id={name} name={name} bind:value {disabled} class:disabled rows="10"></textarea>

View File

@@ -0,0 +1,5 @@
<script>
import {strings} from "../js/stores";
</script>
<p>{$strings.loading}</p>

View File

@@ -0,0 +1,71 @@
<script>
import {getContext, hasContext, onMount, setContext} from "svelte";
import {
is_plugin_setup,
settingsLocked,
strings,
settings_validation
} from "../js/stores";
import Page from "./Page.svelte";
import Notifications from "./Notifications.svelte";
import SubNav from "./SubNav.svelte";
import SubPages from "./SubPages.svelte";
import MediaSettings from "./MediaSettings.svelte";
import UrlPreview from "./UrlPreview.svelte";
import Footer from "./Footer.svelte";
export let name = "media";
export let params = {}; // Required for regex routes.
const _params = params; // Stops compiler warning for params;
let sidebar = null;
let render = false;
if ( hasContext( 'sidebar' ) ) {
sidebar = getContext( 'sidebar' );
}
// Let all child components know if settings are currently locked.
setContext( "settingsLocked", settingsLocked );
// We have a weird subnav here as both routes could be shown at same time.
// So they are grouped, and CSS decides which is shown when width stops both from being shown.
// The active route will determine the SubPage that is given the active class.
const routes = {
'*': MediaSettings,
}
$: items = [
{
route: "/",
title: () => $strings.storage_settings_title,
noticeIcon: $settings_validation[ "storage" ].type
},
{
route: "/media/delivery",
title: () => $strings.delivery_settings_title,
noticeIcon: $settings_validation[ "delivery" ].type
}
];
onMount( () => {
if ( $is_plugin_setup ) {
render = true;
}
} )
</script>
<Page {name} on:routeEvent>
{#if render}
<Notifications tab={name}/>
<SubNav {name} {items} subpage/>
<SubPages {name} {routes}/>
<UrlPreview/>
{/if}
</Page>
{#if sidebar && render}
<svelte:component this={sidebar}/>
{/if}
<Footer on:routeEvent/>

View File

@@ -0,0 +1,10 @@
<script>
import StorageSettingsSubPage from "./StorageSettingsSubPage.svelte";
import DeliverySettingsSubPage from "./DeliverySettingsSubPage.svelte";
export let params = {}; // Required for regex routes.
const _params = params; // Stops compiler warning about unused params export;
</script>
<StorageSettingsSubPage/>
<DeliverySettingsSubPage/>

20
ui/components/Nav.svelte Normal file
View File

@@ -0,0 +1,20 @@
<script>
import {pages} from "../js/routes";
import NavItem from "./NavItem.svelte";
import OffloadStatus from "./OffloadStatus.svelte";
</script>
<div class="nav">
<div class="items">
<ul class="nav">
{#each $pages as tab (tab.position)}
{#if tab.nav && tab.title}
<NavItem {tab}/>
{/if}
{/each}
</ul>
<slot>
<OffloadStatus/>
</slot>
</div>
</div>

View File

@@ -0,0 +1,23 @@
<script>
import {link} from "svelte-spa-router";
import active from "svelte-spa-router/active";
export let tab;
let focus = false;
let hover = false;
</script>
<li class="nav-item" use:active={tab.routeMatcher ? tab.routeMatcher : tab.route} class:focus class:hover>
<a
href={tab.route}
title={tab.title()}
use:link
on:focusin={() => focus = true}
on:focusout={() => focus = false}
on:mouseenter={() => hover = true}
on:mouseleave={() => hover = false}
>
{tab.title()}
</a>
</li>

View File

@@ -0,0 +1,131 @@
<script>
import {notifications, strings, urls} from "../js/stores";
import Button from "./Button.svelte";
const classes = $$props.class ? $$props.class : "";
export let notification = {};
export let unique_id = notification.id ? notification.id : "";
export let inline = notification.inline ? notification.inline : false;
export let wordpress = notification.wordpress ? notification.wordpress : false;
export let success = notification.type === "success";
export let warning = notification.type === "warning";
export let error = notification.type === "error";
let info = false;
// It's possible to set type purely by component property,
// but we need notification.type to be correct too.
if ( success ) {
notification.type = "success";
} else if ( warning ) {
notification.type = "warning";
} else if ( error ) {
notification.type = "error";
} else {
info = true;
notification.type = "info";
}
export let heading = notification.hasOwnProperty( "heading" ) && notification.heading.trim().length ? notification.heading.trim() : "";
export let dismissible = notification.dismissible ? notification.dismissible : false;
export let icon = notification.icon ? notification.icon : false;
export let plainHeading = notification.plainHeading ? notification.plainHeading : false;
export let extra = notification.extra ? notification.extra : "";
export let links = notification.links ? notification.links : [];
export let expandable = false;
export let expanded = false;
/**
* Returns the icon URL for the notification.
*
* @param {string|boolean} icon
* @param {string} notificationType
*
* @return {string}
*/
function getIconURL( icon, notificationType ) {
if ( icon ) {
return $urls.assets + "img/icon/" + icon;
}
return $urls.assets + "img/icon/notification-" + notificationType + ".svg";
}
$: iconURL = getIconURL( icon, notification.type );
// We need to change various properties and alignments if text is multiline.
let iconHeight = 0;
let bodyHeight = 0;
$: multiline = (iconHeight && bodyHeight) && bodyHeight > iconHeight;
/**
* Builds a links row from an array of HTML links.
*
* @param {array} links
*
* @return {string}
*/
function getLinksHTML( links ) {
if ( links.length ) {
return links.join( " " );
}
return "";
}
$: linksHTML = getLinksHTML( links );
</script>
<div
class="notification {classes}"
class:inline
class:wordpress
class:success
class:warning
class:error
class:info
class:multiline
class:expandable
class:expanded
>
<div class="content">
{#if iconURL}
<div class="icon type" bind:clientHeight={iconHeight}>
<img class="icon type" src={iconURL} alt="{notification.type} icon"/>
</div>
{/if}
<div class="body" bind:clientHeight={bodyHeight}>
{#if heading || dismissible || expandable}
<div class="heading">
{#if heading}
{#if plainHeading}
<p>{@html heading}</p>
{:else}
<h3>{@html heading}</h3>
{/if}
{/if}
{#if dismissible && expandable}
<button class="dismiss" on:click|preventDefault={notifications.dismiss(unique_id)}>{$strings.dismiss_all}</button>
<Button expandable {expanded} on:click={() => expanded = !expanded} title={expanded ? $strings.hide_details : $strings.show_details}></Button>
{:else if expandable}
<Button expandable {expanded} on:click={() => expanded = !expanded} title={expanded ? $strings.hide_details : $strings.show_details}></Button>
{:else if dismissible}
<button class="icon close" title={$strings["dismiss_notice"]} on:click|preventDefault={() => notifications.dismiss(unique_id)}></button>
{/if}
</div>
{/if}
<slot/>
{#if extra}
<p>{@html extra}</p>
{/if}
{#if linksHTML}
<p class="links">{@html linksHTML}</p>
{/if}
</div>
</div>
<slot name="details"/>
</div>

View File

@@ -0,0 +1,34 @@
<script>
import {notifications} from "../js/stores";
import Notification from "./Notification.svelte";
export let component = Notification;
export let tab = "";
export let tabParent = "";
/**
* Render the notification or not?
*/
function renderNotification( notification ) {
let not_dismissed = !notification.dismissed;
let valid_parent_tab = notification.only_show_on_tab === tab && notification.hide_on_parent !== true;
let valid_sub_tab = notification.only_show_on_tab === tabParent;
let show_on_all_tabs = !notification.only_show_on_tab;
return not_dismissed && (valid_parent_tab || valid_sub_tab || show_on_all_tabs);
}
</script>
{#if $notifications.length && Object.values( $notifications ).filter( notification => renderNotification( notification ) ).length}
<div id="notifications" class="notifications wrapper">
{#each $notifications as notification (notification.render_key)}
{#if renderNotification( notification )}
<svelte:component this={component} notification={notification}>
{#if notification.message}
<p>{@html notification.message}</p>
{/if}
</svelte:component>
{/if}
{/each}
</div>
{/if}

View File

@@ -0,0 +1,120 @@
<script>
import {counts, strings, urls} from "../js/stores";
import {numToString} from "../js/numToString";
import ProgressBar from "../components/ProgressBar.svelte";
import OffloadStatusFlyout from "./OffloadStatusFlyout.svelte";
// Controls whether flyout is visible or not.
export let expanded = false;
export let flyoutButton = {};
export let hasFocus = false;
/**
* Returns the numeric percentage progress for total offloaded media.
*
* @param {number} total
* @param {number} offloaded
*
* @return {number}
*/
function getPercentComplete( total, offloaded ) {
if ( total < 1 || offloaded < 1 ) {
return 0;
}
const percent = Math.floor( Math.abs( offloaded / total * 100 ) );
if ( percent > 100 ) {
return 100;
}
return percent;
}
$: percentComplete = getPercentComplete( $counts.total, $counts.offloaded );
$: complete = percentComplete >= 100;
/**
* Returns a formatted title string reflecting the current status.
*
* @param {number} percent
* @param {number} total
* @param {number} offloaded
* @param {string} description
*
* @return {string}
*/
function getTitle( percent, total, offloaded, description ) {
return percent + "% (" + numToString( offloaded ) + "/" + numToString( total ) + ") " + description;
}
$: title = getTitle( percentComplete, $counts.total, $counts.offloaded, $strings.offloaded );
/**
* Handles a click to toggle the flyout.
*/
function handleClick() {
expanded = !expanded;
flyoutButton.focus();
// We've handled the click.
return true;
}
/**
* Keep track of when a child control gets mouse focus.
*/
function handleMouseEnter() {
hasFocus = true;
}
/**
* Keep track of when a child control loses mouse focus.
*/
function handleMouseLeave() {
hasFocus = false;
}
</script>
<!-- TODO: Fix a11y. -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="nav-status-wrapper" class:complete>
<!-- TODO: Fix a11y. -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="nav-status"
{title}
on:click|preventDefault={handleClick}
on:mouseenter={handleMouseEnter}
on:mouseleave={handleMouseLeave}
>
{#if complete}
<img
class="icon type"
src={$urls.assets + "img/icon/licence-checked.svg"}
alt="{title}"
{title}
/>
{/if}
<p
class="status-text"
{title}
>
<strong>{percentComplete}%</strong>
<span> {@html $strings.offloaded}</span>
</p>
<ProgressBar
{percentComplete}
{title}
/>
</div>
<slot name="flyout">
<OffloadStatusFlyout bind:expanded bind:hasFocus bind:buttonRef={flyoutButton}/>
</slot>
</div>
<style>
.nav-status-wrapper {
position: relative;
}
</style>

View File

@@ -0,0 +1,191 @@
<script>
import {
counts,
offloadRemainingUpsell,
summaryCounts,
strings,
urls,
api,
state
} from "../js/stores";
import {numToString} from "../js/numToString";
import {delayMin} from "../js/delay";
import Button from "./Button.svelte";
import Panel from "./Panel.svelte";
import PanelRow from "./PanelRow.svelte";
export let expanded = false;
export let buttonRef = {};
export let panelRef = {};
export let hasFocus = false;
export let refreshing = false;
/**
* Keep track of when a child control gets mouse focus.
*/
function handleMouseEnter() {
hasFocus = true;
}
/**
* Keep track of when a child control loses mouse focus.
*/
function handleMouseLeave() {
hasFocus = false;
}
/**
* When the panel is clicked, select the first focusable element
* so that clicking outside the panel triggers a lost focus event.
*/
function handlePanelClick() {
hasFocus = true;
const firstFocusable = panelRef.querySelector( "a:not([tabindex='-1']),button:not([tabindex='-1'])" );
if ( firstFocusable ) {
firstFocusable.focus();
}
}
/**
* When either the button or panel completely lose focus, close the flyout.
*
* @param {FocusEvent} event
*
* @return {boolean}
*/
function handleFocusOut( event ) {
if ( !expanded ) {
return false;
}
// Mouse click and OffloadStatus control/children no longer have mouse focus.
if ( event.relatedTarget === null && !hasFocus ) {
expanded = false;
}
// Keyboard focus change and new focused control isn't within OffloadStatus/Flyout.
if (
event.relatedTarget !== null &&
event.relatedTarget !== buttonRef &&
!panelRef.contains( event.relatedTarget )
) {
expanded = false;
}
}
/**
* Handle cancel event from panel and button.
*/
function handleCancel() {
buttonRef.focus();
expanded = false;
}
/**
* Manually refresh the media counts.
*
* @return {Promise<void>}
*/
async function handleRefresh() {
let start = Date.now();
refreshing = true;
let params = {
refreshMediaCounts: true
};
let json = await api.get( "state", params );
await delayMin( start, 1000 );
state.updateState( json );
refreshing = false;
buttonRef.focus();
}
</script>
<Button
expandable
{expanded}
on:click={() => expanded = !expanded}
title={expanded ? $strings.hide_details : $strings.show_details}
bind:ref={buttonRef}
on:focusout={handleFocusOut}
on:cancel={handleCancel}
/>
{#if expanded}
<Panel
multi
flyout
refresh
{refreshing}
heading={$strings.offload_status_title}
refreshDesc={$strings.refresh_media_counts_desc}
bind:ref={panelRef}
on:focusout={handleFocusOut}
on:mouseenter={handleMouseEnter}
on:mouseleave={handleMouseLeave}
on:mousedown={handleMouseEnter}
on:click={handlePanelClick}
on:cancel={handleCancel}
on:refresh={handleRefresh}
>
<PanelRow class="summary">
<table>
<thead>
<tr>
<th>{$strings.summary_type_title}</th>
<th class="numeric">{$strings.summary_offloaded_title}</th>
<th class="numeric">{$strings.summary_not_offloaded_title}</th>
</tr>
</thead>
<tbody>
{#each $summaryCounts as summary (summary.type)}
<tr>
<td>{summary.name}</td>
{#if summary.offloaded_url}
<td class="numeric">
<a href="{summary.offloaded_url}">{numToString( summary.offloaded )}</a>
</td>
{:else}
<td class="numeric">{numToString( summary.offloaded )}</td>
{/if}
{#if summary.not_offloaded_url}
<td class="numeric">
<a href="{summary.not_offloaded_url}">{numToString( summary.not_offloaded )}</a>
</td>
{:else}
<td class="numeric">{numToString( summary.not_offloaded )}</td>
{/if}
</tr>
{/each}
</tbody>
{#if $summaryCounts.length > 1}
<tfoot>
<tr>
<td>{$strings.summary_total_row_title}</td>
<td class="numeric">{numToString( $counts.offloaded )}</td>
<td class="numeric">{numToString( $counts.not_offloaded )}</td>
</tr>
</tfoot>
{/if}
</table>
</PanelRow>
<slot name="footer">
<PanelRow footer class="upsell">
{#if $offloadRemainingUpsell}
<p>{@html $offloadRemainingUpsell}</p>
{/if}
<a href={$urls.upsell_discount} class="button btn-sm btn-primary licence" target="_blank">
<img src={$urls.assets + "img/icon/stars.svg"} alt="stars icon" style="margin-right: 5px;">
{$strings.offload_remaining_upsell_cta}
</a>
</PanelRow>
</slot>
</Panel>
{/if}

33
ui/components/Page.svelte Normal file
View File

@@ -0,0 +1,33 @@
<script>
import {onMount, createEventDispatcher, setContext} from "svelte";
import {location} from "svelte-spa-router";
import {current_settings} from "../js/stores";
export let name = "";
// In some scenarios a Page should have some SubPage behaviours.
export let subpage = false;
export let initialSettings = $current_settings;
const dispatch = createEventDispatcher();
// When a page is created, store a copy of the initial settings
// so they can be compared with any changes later.
setContext( "initialSettings", initialSettings );
// Tell the route event handlers about the initial settings too.
onMount( () => {
dispatch( "routeEvent", {
event: "page.initial.settings",
data: {
settings: initialSettings,
location: $location
}
} );
} );
</script>
<div class="page-wrapper {name}" class:subpage>
<slot/>
</div>

View File

@@ -0,0 +1,38 @@
<script>
import Router from "svelte-spa-router";
import {push} from "svelte-spa-router";
import {pages, routes} from "../js/routes";
import Nav from "./Nav.svelte";
// These components can be overridden.
export let nav = Nav;
const classes = $$props.class ? $$props.class : "";
/**
* Handles events published by the router.
*
* This handler gives pages a chance to put their hand up and
* provide a new route to be navigated to in response
* to some event.
* e.g. settings saved resulting in a question being asked.
*
* @param {Object} event
*/
function handleRouteEvent( event ) {
const route = pages.handleRouteEvent( event.detail );
if ( route ) {
push( route );
}
}
</script>
<svelte:component this={nav}/>
<div class="wpome-wrapper {classes}">
<Router routes={$routes} on:routeEvent={handleRouteEvent}/>
<slot>
<!-- EXTRA CONTENT GOES HERE -->
</slot>
</div>

139
ui/components/Panel.svelte Normal file
View File

@@ -0,0 +1,139 @@
<script>
import {createEventDispatcher, getContext, hasContext} from "svelte";
import {writable} from "svelte/store";
import {fade} from "svelte/transition";
import {link} from "svelte-spa-router";
import {defined_settings, strings} from "../js/stores";
import PanelContainer from "./PanelContainer.svelte";
import PanelRow from "./PanelRow.svelte";
import DefinedInWPConfig from "./DefinedInWPConfig.svelte";
import ToggleSwitch from "./ToggleSwitch.svelte";
import HelpButton from "./HelpButton.svelte";
import Button from "./Button.svelte";
const classes = $$props.class ? $$props.class : "";
const dispatch = createEventDispatcher();
export let ref = {};
export let name = "";
export let heading = "";
export let defined = false;
export let multi = false;
export let flyout = false;
export let toggleName = "";
export let toggle = false;
export let refresh = false;
export let refreshText = $strings.refresh_title;
export let refreshDesc = refreshText;
export let refreshing = false;
export let helpKey = "";
export let helpURL = "";
export let helpDesc = $strings.help_desc;
// We can display storage provider info on the right-hand side of the panel's header.
// In the future, if anything else needs to be displayed in the same position we
// should create a named slot or assignable component. CSS changes would be required.
export let storageProvider = null;
// Parent page may want to be locked.
let settingsLocked = writable( false );
if ( hasContext( "settingsLocked" ) ) {
settingsLocked = getContext( "settingsLocked" );
}
$: locked = $settingsLocked;
$: toggleDisabled = $defined_settings.includes( toggleName ) || locked;
/**
* If appropriate, clicking the header toggles to toggle switch.
*/
function headingClickHandler() {
if ( toggleName && !toggleDisabled ) {
toggle = !toggle;
}
}
/**
* Catch escape key and emit a custom cancel event.
*
* @param {KeyboardEvent} event
*/
function handleKeyup( event ) {
if ( event.key === "Escape" ) {
event.preventDefault();
dispatch( "cancel" );
}
}
</script>
<!-- TODO: Fix a11y. -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="panel {name}"
class:multi
class:flyout
class:locked
transition:fade={{duration: flyout ? 200 : 0}}
bind:this={ref}
on:focusout
on:mouseenter
on:mouseleave
on:mousedown
on:click
on:keyup={handleKeyup}
>
{#if !multi && heading}
<div class="heading">
<h2>{heading}</h2>
{#if helpURL}
<HelpButton url={helpURL} desc={helpDesc}/>
{:else if helpKey}
<HelpButton key={helpKey} desc={helpDesc}/>
{/if}
<DefinedInWPConfig {defined}/>
</div>
{/if}
<PanelContainer class={classes}>
{#if multi && heading}
<PanelRow header>
{#if toggleName}
<ToggleSwitch name={toggleName} bind:checked={toggle} disabled={toggleDisabled}>
{heading}
</ToggleSwitch>
<!-- TODO: Fix a11y. -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<h3 on:click={headingClickHandler} class="toggler" class:toggleDisabled>{heading}</h3>
{:else}
<h3>{heading}</h3>
{/if}
<DefinedInWPConfig {defined}/>
{#if refresh}
<Button refresh {refreshing} title={refreshDesc} on:click={() => dispatch("refresh")}>{@html refreshText}</Button>
{/if}
{#if storageProvider}
<div class="provider">
<a href="/storage/provider" use:link class="link">
<img src={storageProvider.link_icon} alt={storageProvider.icon_desc}>
{storageProvider.provider_service_name}
</a>
</div>
{/if}
{#if helpURL}
<HelpButton url={helpURL} desc={helpDesc}/>
{:else if helpKey}
<HelpButton key={helpKey} desc={helpDesc}/>
{/if}
</PanelRow>
{/if}
<slot/>
</PanelContainer>
</div>
<style>
.toggler:not(.toggleDisabled) {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,7 @@
<script>
const classes = $$props.class ? $$props.class : "";
</script>
<div class="panel-container {classes}">
<slot/>
</div>

View File

@@ -0,0 +1,30 @@
<script>
const classes = $$props.class ? $$props.class : "";
export let header = false;
export let footer = false;
export let gradient = false;
</script>
<div class="panel-row {classes}" class:header class:footer>
{#if gradient}
<div class="gradient"></div>
{/if}
<slot/>
</div>
<style>
.panel-row {
position: relative;
}
.header .gradient {
position: absolute;
width: 144px;
left: 0;
top: 0;
bottom: 0;
transform: matrix(-1, 0, 0, 1, 0, 0);
border-top-right-radius: 5px;
}
</style>

View File

@@ -0,0 +1,52 @@
<script>
import {cubicOut} from "svelte/easing";
import {tweened} from "svelte/motion";
export let percentComplete = 0;
export let starting = false;
export let running = false;
export let paused = false;
export let title = "";
let progressTweened = tweened( 0, {
duration: 400,
easing: cubicOut
} );
/**
* Utility function for reactively getting the progress.
*
* @param {number} percent
*
* @return {number|*}
*/
function getProgress( percent ) {
if ( percent < 1 ) {
return 0;
}
if ( percent >= 100 ) {
return 100;
}
return percent;
}
$: progressTweened.set( getProgress( percentComplete ) );
$: complete = percentComplete >= 100;
</script>
<!-- TODO: Fix a11y. -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="progress-bar"
class:stripe={running && ! paused}
class:animate={starting}
{title}
on:click|preventDefault
on:mouseenter
on:mouseleave
>
<span class="indicator animate" class:complete class:running style="width: {$progressTweened}%"></span>
</div>

View File

@@ -0,0 +1,20 @@
<script>
export let list = false;
export let disabled = false;
export let name = "options";
export let value = "";
export let selected = "";
export let desc = "";
</script>
<div class="radio-btn" class:list class:disabled>
<label>
<input type="radio" {name} bind:group={selected} {value} {disabled}>
<slot/>
</label>
</div>
{#if selected === value && desc}
<p class="radio-desc">
{@html desc}
</p>
{/if}

View File

@@ -0,0 +1,282 @@
<script>
import {createEventDispatcher, getContext, hasContext} from "svelte";
import {writable} from "svelte/store";
import {slide} from "svelte/transition";
import {
api,
settings,
strings,
current_settings,
storage_provider,
delivery_provider,
needs_refresh,
revalidatingSettings,
state,
defined_settings
} from "../js/stores";
import {
scrollNotificationsIntoView
} from "../js/scrollNotificationsIntoView";
import {needsRefresh} from "../js/needsRefresh";
import SubPage from "./SubPage.svelte";
import Panel from "./Panel.svelte";
import PanelRow from "./PanelRow.svelte";
import BackNextButtonsRow from "./BackNextButtonsRow.svelte";
import Checkbox from "./Checkbox.svelte";
const dispatch = createEventDispatcher();
// Parent page may want to be locked.
let settingsLocked = writable( false );
if ( hasContext( "settingsLocked" ) ) {
settingsLocked = getContext( "settingsLocked" );
}
// As this page does not directly alter the settings store until done,
// we need to keep track of any changes made elsewhere and prompt
// the user to refresh the page.
let saving = false;
const previousSettings = { ...$current_settings };
const previousDefines = { ...$defined_settings };
$: {
$needs_refresh = $needs_refresh || needsRefresh( saving, previousSettings, $current_settings, previousDefines, $defined_settings );
}
let blockPublicAccess = $settings[ "block-public-access" ];
let bapaSetupConfirmed = false;
let objectOwnershipEnforced = $settings[ "object-ownership-enforced" ];
let ooeSetupConfirmed = false;
// During initial setup we show a slightly different page
// if ACLs disabled but unsupported by Delivery Provider.
let initialSetup = false;
if ( hasContext( "initialSetup" ) ) {
initialSetup = getContext( "initialSetup" );
}
// If provider has changed, then still treat as initial setup.
if (
!initialSetup &&
hasContext( "initialSettings" ) &&
getContext( "initialSettings" ).provider !== $current_settings.provider
) {
initialSetup = true;
}
/**
* Calls API to update the properties of the current bucket.
*
* @return {Promise<boolean|*>}
*/
async function updateBucketProperties() {
let data = await api.put( "buckets", {
bucket: $settings.bucket,
blockPublicAccess: blockPublicAccess,
objectOwnershipEnforced: objectOwnershipEnforced
} );
if ( data.hasOwnProperty( "saved" ) ) {
return data.saved;
}
return false;
}
/**
* Returns text to be displayed on Next button.
*
* @param {boolean} bapaCurrent
* @param {boolean} bapaNew
* @param {boolean} ooeCurrent
* @param {boolean} ooeNew
* @param {boolean} needsRefresh
* @param {boolean} settingsLocked
*
* @return {string}
*/
function getNextText( bapaCurrent, bapaNew, ooeCurrent, ooeNew, needsRefresh, settingsLocked ) {
if ( needsRefresh || settingsLocked ) {
return $strings.settings_locked;
}
if ( bapaCurrent !== bapaNew || ooeCurrent !== ooeNew ) {
return $strings.update_bucket_security;
}
return $strings.keep_bucket_security;
}
$: nextText = getNextText(
$current_settings[ "block-public-access" ],
blockPublicAccess,
$current_settings[ "object-ownership-enforced" ],
objectOwnershipEnforced,
$needs_refresh,
$settingsLocked
);
/**
* Determines whether the Next button should be disabled or not.
*
* If the delivery provider supports the security setting, then do not enable it until setup confirmed.
*
* All other scenarios result in safe results or warned against repercussions that are being explicitly ignored.
*
* @param {boolean} currentValue
* @param {boolean} newValue
* @param {boolean} supported
* @param {boolean} setupConfirmed
* @param {boolean} needsRefresh
* @param {boolean} settingsLocked
*
* @returns {boolean}
*/
function getNextDisabled( currentValue, newValue, supported, setupConfirmed, needsRefresh, settingsLocked ) {
return needsRefresh || settingsLocked || (!currentValue && newValue && supported && !setupConfirmed);
}
$: nextDisabled =
getNextDisabled(
$current_settings[ "block-public-access" ],
blockPublicAccess,
$delivery_provider.block_public_access_supported,
bapaSetupConfirmed,
$needs_refresh,
$settingsLocked
) ||
getNextDisabled(
$current_settings[ "object-ownership-enforced" ],
objectOwnershipEnforced,
$delivery_provider.object_ownership_supported,
ooeSetupConfirmed,
$needs_refresh,
$settingsLocked
);
/**
* Handles a Next button click.
*
* @return {Promise<void>}
*/
async function handleNext() {
if (
blockPublicAccess === $current_settings[ "block-public-access" ] &&
objectOwnershipEnforced === $current_settings[ "object-ownership-enforced" ]
) {
dispatch( "routeEvent", { event: "next", default: "/" } );
return;
}
saving = true;
state.pausePeriodicFetch();
const result = await updateBucketProperties();
// Regardless of whether update succeeded or not, make sure settings are up-to-date.
await settings.fetch();
if ( false === result ) {
saving = false;
await state.resumePeriodicFetch();
scrollNotificationsIntoView();
return;
}
$revalidatingSettings = true;
const statePromise = state.resumePeriodicFetch();
// Block All Public Access changed.
dispatch( "routeEvent", {
event: "bucket-security",
data: {
blockPublicAccess: $settings[ "block-public-access" ],
objectOwnershipEnforced: $settings[ "object-ownership-enforced" ]
},
default: "/"
} );
// Just make sure periodic state fetch promise is done with,
// even though we don't really care about it.
await statePromise;
$revalidatingSettings = false;
}
</script>
<SubPage name="bapa-settings" route="/storage/security">
<Panel
class="toggle-header"
heading={$strings.block_public_access_title}
toggleName="block-public-access"
bind:toggle={blockPublicAccess}
helpKey="block-public-access"
multi
>
<PanelRow class="body flex-column">
{#if initialSetup && $current_settings[ "block-public-access" ] && !$delivery_provider.block_public_access_supported}
<p>{@html $strings.block_public_access_enabled_setup_sub}</p>
<p>{@html $delivery_provider.block_public_access_enabled_unsupported_setup_desc} {@html $storage_provider.block_public_access_enabled_unsupported_setup_desc}</p>
{:else if $current_settings[ "block-public-access" ] && $delivery_provider.block_public_access_supported}
<p>{@html $strings.block_public_access_enabled_sub}</p>
<p>{@html $delivery_provider.block_public_access_enabled_supported_desc} {@html $storage_provider.block_public_access_enabled_supported_desc}</p>
{:else if $current_settings[ "block-public-access" ] && !$delivery_provider.block_public_access_supported}
<p>{@html $strings.block_public_access_enabled_sub}</p>
<p>{@html $delivery_provider.block_public_access_enabled_unsupported_desc} {@html $storage_provider.block_public_access_enabled_unsupported_desc}</p>
{:else if !$current_settings[ "block-public-access" ] && $delivery_provider.block_public_access_supported}
<p>{@html $strings.block_public_access_disabled_sub}</p>
<p>{@html $delivery_provider.block_public_access_disabled_supported_desc} {@html $storage_provider.block_public_access_disabled_supported_desc}</p>
{:else}
<p>{@html $strings.block_public_access_disabled_sub}</p>
<p>{@html $delivery_provider.block_public_access_disabled_unsupported_desc} {@html $storage_provider.block_public_access_disabled_unsupported_desc}</p>
{/if}
</PanelRow>
{#if !$current_settings[ "block-public-access" ] && blockPublicAccess && $delivery_provider.block_public_access_supported}
<div transition:slide>
<PanelRow class="body flex-column toggle-reveal" footer>
<Checkbox name="confirm-setup-bapa-oai" bind:checked={bapaSetupConfirmed} disabled={$needs_refresh || $settingsLocked}>{@html $delivery_provider.block_public_access_confirm_setup_prompt}</Checkbox>
</PanelRow>
</div>
{/if}
</Panel>
<Panel
class="toggle-header"
heading={$strings.object_ownership_title}
toggleName="object-ownership-enforced"
bind:toggle={objectOwnershipEnforced}
helpKey="object-ownership-enforced"
multi
>
<PanelRow class="body flex-column">
{#if initialSetup && $current_settings[ "object-ownership-enforced" ] && !$delivery_provider.object_ownership_supported}
<p>{@html $strings.object_ownership_enforced_setup_sub}</p>
<p>{@html $delivery_provider.object_ownership_enforced_unsupported_setup_desc} {@html $storage_provider.object_ownership_enforced_unsupported_setup_desc}</p>
{:else if $current_settings[ "object-ownership-enforced" ] && $delivery_provider.object_ownership_supported}
<p>{@html $strings.object_ownership_enforced_sub}</p>
<p>{@html $delivery_provider.object_ownership_enforced_supported_desc} {@html $storage_provider.object_ownership_enforced_supported_desc}</p>
{:else if $current_settings[ "object-ownership-enforced" ] && !$delivery_provider.object_ownership_supported}
<p>{@html $strings.object_ownership_enforced_sub}</p>
<p>{@html $delivery_provider.object_ownership_enforced_unsupported_desc} {@html $storage_provider.object_ownership_enforced_unsupported_desc}</p>
{:else if !$current_settings[ "object-ownership-enforced" ] && $delivery_provider.object_ownership_supported}
<p>{@html $strings.object_ownership_not_enforced_sub}</p>
<p>{@html $delivery_provider.object_ownership_not_enforced_supported_desc} {@html $storage_provider.object_ownership_not_enforced_supported_desc}</p>
{:else}
<p>{@html $strings.object_ownership_not_enforced_sub}</p>
<p>{@html $delivery_provider.object_ownership_not_enforced_unsupported_desc} {@html $storage_provider.object_ownership_not_enforced_unsupported_desc}</p>
{/if}
</PanelRow>
{#if !$current_settings[ "object-ownership-enforced" ] && objectOwnershipEnforced && $delivery_provider.object_ownership_supported}
<div transition:slide>
<PanelRow class="body flex-column toggle-reveal">
<Checkbox name="confirm-setup-ooe-oai" bind:checked={ooeSetupConfirmed} disabled={$needs_refresh || $settingsLocked}>{@html $delivery_provider.object_ownership_confirm_setup_prompt}</Checkbox>
</PanelRow>
</div>
{/if}
</Panel>
<BackNextButtonsRow on:next={handleNext} {nextText} {nextDisabled}/>
</SubPage>

View File

@@ -0,0 +1,54 @@
<script>
import {settings_notifications} from "../js/stores";
import Notification from "./Notification.svelte";
export let settingKey;
/**
* Compares two notification objects to sort them into a preferred order.
*
* Order should be errors, then warnings and finally anything else alphabetically by type.
* As these (inline) notifications are typically displayed under a setting,
* this ensures the most important notifications are nearest the control.
*
* @param {Object} a
* @param {Object} b
*
* @return {number}
*/
function compareNotificationTypes( a, b ) {
// Sort errors to the top.
if ( a.type === "error" && b.type !== "error" ) {
return -1;
}
if ( b.type === "error" && a.type !== "error" ) {
return 1;
}
// Next sort warnings.
if ( a.type === "warning" && b.type !== "warning" ) {
return -1;
}
if ( b.type === "warning" && a.type !== "warning" ) {
return 1;
}
// Anything else, just sort by type for stability.
if ( a.type < b.type ) {
return -1;
}
if ( b.type < a.type ) {
return 1;
}
return 0;
}
</script>
{#if $settings_notifications.has( settingKey )}
{#each [...$settings_notifications.get( settingKey ).values()].sort( compareNotificationTypes ) as notification (notification)}
<Notification {notification}>
<p>{@html notification.message}</p>
</Notification>
{/each}
{/if}

View File

@@ -0,0 +1,37 @@
<script>
import {onMount} from "svelte";
import {config, notifications, settings, state} from "../js/stores";
import Header from "./Header.svelte";
// These components can be overridden.
export let header = Header;
export let footer = null;
// We need a disassociated copy of the initial settings to work with.
settings.set( { ...$config.settings } );
// We might have some initial notifications to display too.
if ( $config.notifications.length ) {
for ( const notification of $config.notifications ) {
notifications.add( notification );
}
}
onMount( () => {
// Periodically check the state.
state.startPeriodicFetch();
// Be a good citizen and clean up the timer when exiting our settings.
return () => state.stopPeriodicFetch();
} );
</script>
{#if header}
<svelte:component this={header}/>
{/if}
<slot>
<!-- CONTENT GOES HERE -->
</slot>
{#if footer}
<svelte:component this={footer}/>
{/if}

View File

@@ -0,0 +1,152 @@
<script>
import {getContext, hasContext} from "svelte";
import {writable} from "svelte/store";
import {slide} from "svelte/transition";
import {defined_settings, validationErrors} from "../js/stores";
import PanelRow from "./PanelRow.svelte";
import ToggleSwitch from "./ToggleSwitch.svelte";
import DefinedInWPConfig from "./DefinedInWPConfig.svelte";
import SettingNotifications from "./SettingNotifications.svelte";
export let heading = "";
export let description = "";
export let placeholder = "";
export let nested = false;
export let first = false; // of nested items
// Toggle and Text may both be used at same time.
export let toggleName = "";
export let toggle = false;
export let textName = "";
export let text = "";
export let alwaysShowText = false;
export let definedSettings = defined_settings;
/**
* Optional validator function.
*
* @param {string} textValue
*
* @return {string} Empty if no error
*/
export let validator = ( textValue ) => "";
// Parent page may want to be locked.
let settingsLocked = writable( false );
let textDirty = false;
if ( hasContext( "settingsLocked" ) ) {
settingsLocked = getContext( "settingsLocked" );
}
$: locked = $settingsLocked;
$: toggleDisabled = $definedSettings.includes( toggleName ) || locked;
$: textDisabled = $definedSettings.includes( textName ) || locked;
$: input = ((toggleName && toggle) || !toggleName || alwaysShowText) && textName;
$: headingName = input ? textName + "-heading" : toggleName;
/**
* Validate the text if validator function supplied.
*
* @param {string} text
* @param {bool} toggle
*
* @return {string}
*/
function validateText( text, toggle ) {
let message = "";
if ( validator !== undefined && toggle && !textDisabled ) {
message = validator( text );
}
validationErrors.update( _validationErrors => {
if ( _validationErrors.has( textName ) && message === "" ) {
_validationErrors.delete( textName );
} else if ( message !== "" ) {
_validationErrors.set( textName, message );
}
return _validationErrors;
} );
return message;
}
function onTextInput() {
textDirty = true;
}
$: validationError = validateText( text, toggle );
/**
* If appropriate, clicking the header toggles to toggle switch.
*/
function headingClickHandler() {
if ( toggleName && !toggleDisabled ) {
toggle = !toggle;
}
}
</script>
<div class="setting" class:nested class:first>
<PanelRow class="option">
{#if toggleName}
<ToggleSwitch name={toggleName} bind:checked={toggle} disabled={toggleDisabled}>
{heading}
</ToggleSwitch>
<!-- TODO: Fix a11y. -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<h4 id={headingName} on:click={headingClickHandler} class="toggler" class:toggleDisabled>{heading}</h4>
{:else}
<h4 id={headingName}>{heading}</h4>
{/if}
<DefinedInWPConfig defined={$definedSettings.includes( toggleName ) || (input && $definedSettings.includes( textName ))}/>
</PanelRow>
<PanelRow class="desc">
<p>{@html description}</p>
</PanelRow>
{#if input}
<PanelRow class="input">
<input
type="text"
id={textName}
name={textName}
bind:value={text}
on:input={onTextInput}
minlength="1"
size="10"
{placeholder}
disabled={textDisabled}
class:disabled={textDisabled}
aria-labelledby={headingName}
>
<label for={textName}>
{heading}
</label>
</PanelRow>
{#if validationError && textDirty}
<p class="input-error" transition:slide>{validationError}</p>
{/if}
{/if}
{#if toggleName}
<SettingNotifications settingKey={toggleName}/>
{/if}
{#if textName}
<SettingNotifications settingKey={textName}/>
{/if}
<slot/>
</div>
<style>
.toggler:not(.toggleDisabled) {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,38 @@
<script>
import {
settings_validation,
urls
} from "../js/stores";
import CheckAgain from "./CheckAgain.svelte";
export let section = "";
$: success = $settings_validation[ section ].type === "success";
$: warning = $settings_validation[ section ].type === "warning";
$: error = $settings_validation[ section ].type === "error";
$: info = $settings_validation[ section ].type === "info";
$: type = $settings_validation[ section ].type;
$: message = '<p>' + $settings_validation[ section ].message + '</p>';
$: iconURL = $urls.assets + "img/icon/notification-" + $settings_validation[ section ].type + ".svg";
</script>
<div
class="notification in-panel multiline {section}"
class:success
class:warning
class:error
class:info
>
<div class="content in-panel">
<div class="icon type in-panel">
<img class="icon type" src={iconURL} alt="{type} icon"/>
</div>
<div class="body">
{@html message}
</div>
<CheckAgain section={section}/>
</div>
</div>

View File

@@ -0,0 +1,52 @@
<script>
import {afterUpdate, setContext} from "svelte";
import {location, push} from "svelte-spa-router";
import {
current_settings,
settingsLocked,
needs_access_keys
} from "../js/stores";
import Page from "./Page.svelte";
import Notifications from "./Notifications.svelte";
import SubNav from "./SubNav.svelte";
import SubPages from "./SubPages.svelte";
import {pages} from "../js/routes";
export let name = "storage";
export let params = {}; // Required for regex routes.
const _params = params; // Stops compiler warning about unused params export;
// During initial setup some storage sub pages behave differently.
// Not having a bucket defined is akin to initial setup, but changing provider in sub page may also flip the switch.
if ( $current_settings.bucket ) {
setContext( "initialSetup", false );
} else {
setContext( "initialSetup", true );
}
// Let all child components know if settings are currently locked.
setContext( "settingsLocked", settingsLocked );
const prefix = "/storage";
let items = pages.withPrefix( prefix );
let routes = pages.routes( prefix );
afterUpdate( () => {
items = pages.withPrefix( prefix );
routes = pages.routes( prefix );
// Ensure only Storage Provider subpage can be visited if credentials not set.
if ( $needs_access_keys && $location.startsWith( "/storage/" ) && $location !== "/storage/provider" ) {
push( "/storage/provider" );
}
} );
</script>
<Page {name} subpage on:routeEvent>
<Notifications tab="media" tabParent="media"/>
<SubNav {name} {items} progress/>
<SubPages {name} {prefix} {routes} on:routeEvent/>
</Page>

View File

@@ -0,0 +1,315 @@
<script>
import {createEventDispatcher, getContext, hasContext} from "svelte";
import {writable} from "svelte/store";
import {
settings,
defined_settings,
strings,
storage_providers,
storage_provider,
counts,
current_settings,
needs_refresh,
revalidatingSettings,
state
} from "../js/stores";
import {
scrollNotificationsIntoView
} from "../js/scrollNotificationsIntoView";
import {needsRefresh} from "../js/needsRefresh";
import SubPage from "./SubPage.svelte";
import Panel from "./Panel.svelte";
import PanelRow from "./PanelRow.svelte";
import TabButton from "./TabButton.svelte";
import RadioButton from "./RadioButton.svelte";
import AccessKeysDefine from "./AccessKeysDefine.svelte";
import BackNextButtonsRow from "./BackNextButtonsRow.svelte";
import KeyFileDefine from "./KeyFileDefine.svelte";
import UseServerRolesDefine from "./UseServerRolesDefine.svelte";
import AccessKeysEntry from "./AccessKeysEntry.svelte";
import KeyFileEntry from "./KeyFileEntry.svelte";
import Notification from "./Notification.svelte";
export let params = {}; // Required for regex routes.
const _params = params; // Stops compiler warning about unused params export;
const dispatch = createEventDispatcher();
// Parent page may want to be locked.
let settingsLocked = writable( false );
if ( hasContext( "settingsLocked" ) ) {
settingsLocked = getContext( "settingsLocked" );
}
// Need to be careful about throwing unneeded warnings.
let initialSettings = $current_settings;
if ( hasContext( "initialSettings" ) ) {
initialSettings = getContext( "initialSettings" );
}
// As this page does not directly alter the settings store until done,
// we need to keep track of any changes made elsewhere and prompt
// the user to refresh the page.
let saving = false;
const previousSettings = { ...$current_settings };
const previousDefines = { ...$defined_settings };
$: {
$needs_refresh = $needs_refresh || needsRefresh( saving, previousSettings, $current_settings, previousDefines, $defined_settings );
}
/*
* 1. Select Storage Provider
*/
let storageProvider = { ...$storage_provider };
$: defined = $defined_settings.includes( "provider" );
$: disabled = defined || $needs_refresh || $settingsLocked;
/**
* Handles picking different storage provider.
*
* @param {Object} provider
*/
function handleChooseProvider( provider ) {
if ( disabled ) {
return;
}
storageProvider = provider;
// Now make sure authMethod is valid for chosen storage provider.
authMethod = getAuthMethod( storageProvider, authMethod );
}
$: changedWithOffloaded = initialSettings.provider !== storageProvider.provider_key_name && $counts.offloaded > 0;
/*
* 2. Select Authentication method
*/
let endpointUrl = $settings[ "endpoint" ] || "";
let accessKeyId = $settings[ "access-key-id" ];
let secretAccessKey = $settings[ "secret-access-key" ];
let keyFile = $settings[ "key-file" ] ? JSON.stringify( $settings[ "key-file" ] ) : "";
/**
* For the given current storage provider, determine the authentication method or fallback to currently selected.
* It's possible that the storage provider can be freely changed but the
* authentication method is defined (fixed) differently for each, or freely changeable too.
* The order of evaluation in this function is important and mirrors the server side evaluation order.
*
* @param {provider} provider
* @param {string} current auth method, one of "define", "server-role" or "database" if set.
*
* @return {string}
*/
function getAuthMethod( provider, current = "" ) {
if ( provider.use_access_keys_allowed && provider.used_access_keys_constants.length ) {
return "define";
}
if ( provider.use_key_file_allowed && provider.used_key_file_path_constants.length ) {
return "define";
}
if ( provider.use_server_roles_allowed && provider.used_server_roles_constants.length ) {
return "server-role";
}
if ( current === "server-role" && !provider.use_server_roles_allowed ) {
return "define";
}
if ( current.length === 0 ) {
if ( provider.use_access_keys_allowed && (accessKeyId || secretAccessKey) ) {
return "database";
}
if ( provider.use_key_file_allowed && keyFile ) {
return "database";
}
if ( provider.use_server_roles_allowed && $settings[ "use-server-roles" ] ) {
return "server-role";
}
// Default to most secure option.
return "define";
}
return current;
}
let authMethod = getAuthMethod( storageProvider );
// If auth method is not allowed to be database, then either define or server-role is being forced, likely by a define.
$: authDefined = "database" !== getAuthMethod( storageProvider, "database" );
$: authDisabled = authDefined || $needs_refresh || $settingsLocked;
/*
* 3. Save Authentication Credentials
*/
/**
* Returns a title string to be used for the credentials panel as appropriate for the auth method.
*
* @param {string} method
* @return {*}
*/
function getCredentialsTitle( method ) {
return $strings.auth_method_title[ method ];
}
$: saveCredentialsTitle = getCredentialsTitle( authMethod );
/*
* Do Something!
*/
/**
* Handles a Next button click.
*
* @return {Promise<void>}
*/
async function handleNext() {
saving = true;
state.pausePeriodicFetch();
$settings.provider = storageProvider.provider_key_name;
$settings[ "endpoint" ] = storageProvider.provider_key_name === "s3compatible" ? endpointUrl : "";
$settings[ "access-key-id" ] = accessKeyId;
$settings[ "secret-access-key" ] = secretAccessKey;
$settings[ "use-server-roles" ] = authMethod === "server-role";
$settings[ "key-file" ] = keyFile;
const result = await settings.save();
// If something went wrong, don't move onto next step.
if ( !result.hasOwnProperty( "saved" ) || !result.saved ) {
settings.reset();
saving = false;
await state.resumePeriodicFetch();
scrollNotificationsIntoView();
return;
}
$revalidatingSettings = true;
const statePromise = state.resumePeriodicFetch();
dispatch( "routeEvent", { event: "settings.save", data: result } );
// Just make sure periodic state fetch promise is done with,
// even though we don't really care about it.
await statePromise;
$revalidatingSettings = false;
}
</script>
<SubPage name="storage-provider-settings" route="/storage/provider">
{#if changedWithOffloaded}
<Notification inline warning heading={storageProvider.media_already_offloaded_warning.heading}>
<p>{@html storageProvider.media_already_offloaded_warning.message}</p>
</Notification>
{/if}
<Panel heading={$strings.select_storage_provider_title} defined={defined} multi>
<PanelRow class="body flex-row tab-buttons">
{#each Object.values( $storage_providers ) as provider}
<TabButton
active={provider.provider_key_name === storageProvider.provider_key_name}
{disabled}
icon={provider.icon}
iconDesc={provider.icon_desc}
text={provider.provider_service_name}
on:click={() => handleChooseProvider( provider )}
/>
{/each}
<Notification class="notice-qsg">
<p>{@html storageProvider.get_access_keys_help}</p>
</Notification>
</PanelRow>
</Panel>
{#if storageProvider.provider_key_name === "s3compatible"}
<Panel heading={$strings.s3compat_endpoint_title} multi>
<PanelRow class="body flex-column">
<label class="input-label" for="s3compat-endpoint">{$strings.s3compat_endpoint_label}</label>
<input
type="url"
id="s3compat-endpoint"
name="endpoint"
placeholder={$strings.s3compat_endpoint_placeholder}
bind:value={endpointUrl}
class:disabled
{disabled}
>
<p class="desc">{$strings.s3compat_endpoint_desc}</p>
</PanelRow>
</Panel>
{/if}
<Panel heading={$strings.select_auth_method_title} defined={authDefined} multi>
<PanelRow class="body flex-column">
<!-- define -->
{#if storageProvider.use_access_keys_allowed}
<RadioButton bind:selected={authMethod} disabled={authDisabled} value="define" desc={storageProvider.defined_auth_desc}>
{$strings.define_access_keys}
</RadioButton>
{:else if storageProvider.use_key_file_allowed}
<RadioButton bind:selected={authMethod} disabled={authDisabled} value="define" desc={storageProvider.defined_auth_desc}>
{$strings.define_key_file_path}
</RadioButton>
{/if}
<!-- server-role -->
{#if storageProvider.use_server_roles_allowed}
<RadioButton bind:selected={authMethod} disabled={authDisabled} value="server-role" desc={storageProvider.defined_auth_desc}>
{storageProvider.use_server_roles_title}
</RadioButton>
{/if}
<!-- database -->
{#if storageProvider.use_access_keys_allowed}
<RadioButton bind:selected={authMethod} disabled={authDisabled} value="database">
{$strings.store_access_keys_in_db}
</RadioButton>
{:else if storageProvider.use_key_file_allowed}
<RadioButton bind:selected={authMethod} disabled={authDisabled} value="database">
{$strings.store_key_file_in_db}
</RadioButton>
{/if}
</PanelRow>
</Panel>
{#if !authDefined}
<Panel heading={saveCredentialsTitle} multi>
<PanelRow class="body flex-column access-keys">
{#if authMethod === "define" && storageProvider.use_access_keys_allowed}
<AccessKeysDefine provider={storageProvider}/>
{:else if authMethod === "define" && storageProvider.use_key_file_allowed}
<KeyFileDefine provider={storageProvider}/>
{:else if authMethod === "server-role" && storageProvider.use_server_roles_allowed}
<UseServerRolesDefine provider={storageProvider}/>
{:else if authMethod === "database" && storageProvider.use_access_keys_allowed}
<AccessKeysEntry
provider={storageProvider}
bind:accessKeyId
bind:secretAccessKey
disabled={authDisabled}
/>
{:else if authMethod === "database" && storageProvider.use_key_file_allowed}
<KeyFileEntry provider={storageProvider} bind:value={keyFile}/>
{/if}
</PanelRow>
</Panel>
{/if}
<BackNextButtonsRow on:next={handleNext} nextDisabled={$needs_refresh || $settingsLocked} nextText={$strings.save_and_continue}/>
</SubPage>

View File

@@ -0,0 +1,76 @@
<script>
import {getContext, hasContext} from "svelte";
import {writable} from "svelte/store";
import {push} from "svelte-spa-router";
import {
region_name,
settings,
storage_provider,
strings,
urls
} from "../js/stores";
import PanelRow from "./PanelRow.svelte";
import Button from "./Button.svelte";
// Parent page may want to be locked.
let settingsLocked = writable( false );
if ( hasContext( "settingsLocked" ) ) {
settingsLocked = getContext( "settingsLocked" );
}
</script>
<PanelRow header gradient class="storage {$storage_provider.provider_key_name}">
<img src="{$storage_provider.icon}" alt={$storage_provider.provider_service_name}/>
<div class="provider-details">
<h3>{$storage_provider.provider_service_name}</h3>
<p class="console-details">
<a href={$urls.storage_provider_console_url} class="console" target="_blank" title={$strings.view_provider_console}>{$settings.bucket}</a>
<span class="region" title={$settings.region}>{$region_name}</span>
</p>
</div>
<Button outline on:click={() => push('/storage/provider')} title={$strings.edit_storage_provider} disabled={$settingsLocked}>{$strings.edit}</Button>
</PanelRow>
<style>
:global(#as3cf-settings.wpome div.panel.settings .header) img {
width: var(--as3cf-settings-ctrl-width);
height: var(--as3cf-settings-ctrl-width);
}
.provider-details {
display: flex;
flex-direction: column;
flex: auto;
margin-left: var(--as3cf-settings-option-indent);
z-index: 1;
}
:global(#as3cf-settings.wpome div.panel) .provider-details h3 {
margin-left: 0;
margin-bottom: 0.5rem;
}
:global(#as3cf-settings.wpome div.panel) .console-details {
display: flex;
align-items: center;
font-size: 0.75rem;
}
.console-details .console {
flex: 0 1 min-content;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
:global(#as3cf-settings.wpome div.panel) .console-details a[target="_blank"].console:after {
margin-right: 0;
}
:global(#as3cf-settings.wpome div.panel) .console-details .region {
flex: 1 0 auto;
color: var(--as3cf-color-gray-500);
margin: 0 0.5rem;
}
</style>

View File

@@ -0,0 +1,48 @@
<script>
import {settings, strings} from "../js/stores";
import Panel from "./Panel.svelte";
import StorageSettingsHeadingRow from "./StorageSettingsHeadingRow.svelte";
import SettingsValidationStatusRow
from "./SettingsValidationStatusRow.svelte";
import SettingsPanelOption from "./SettingsPanelOption.svelte";
</script>
<Panel name="settings" heading={$strings.storage_settings_title} helpKey="storage-provider">
<StorageSettingsHeadingRow/>
<SettingsValidationStatusRow section="storage"/>
<SettingsPanelOption
heading={$strings.copy_files_to_bucket}
description={$strings.copy_files_to_bucket_desc}
toggleName="copy-to-s3"
bind:toggle={$settings["copy-to-s3"]}
/>
<SettingsPanelOption
heading={$strings.remove_local_file}
description={$strings.remove_local_file_desc}
toggleName="remove-local-file"
bind:toggle={$settings["remove-local-file"]}
>
</SettingsPanelOption>
<SettingsPanelOption
heading={$strings.path}
description={$strings.path_desc}
toggleName="enable-object-prefix"
bind:toggle={$settings["enable-object-prefix"]}
textName="object-prefix"
bind:text={$settings["object-prefix"]}
/>
<SettingsPanelOption
heading={$strings.year_month}
description={$strings.year_month_desc}
toggleName="use-yearmonth-folders"
bind:toggle={$settings["use-yearmonth-folders"]}
>
</SettingsPanelOption>
<SettingsPanelOption
heading={$strings.object_versioning}
description={$strings.object_versioning_desc}
toggleName="object-versioning"
bind:toggle={$settings["object-versioning"]}
>
</SettingsPanelOption>
</Panel>

View File

@@ -0,0 +1,8 @@
<script>
import SubPage from "./SubPage.svelte";
import StorageSettingsPanel from "./StorageSettingsPanel.svelte";
</script>
<SubPage name="storage-settings">
<StorageSettingsPanel/>
</SubPage>

View File

@@ -0,0 +1,25 @@
<script>
import {urls} from "../js/stores";
import SubNavItem from "./SubNavItem.svelte";
export let name = "media";
export let items = [];
export let subpage = false;
export let progress = false;
$: displayItems = items.filter( ( page ) => page.title && (!page.hasOwnProperty( "enabled" ) || page.enabled() === true) );
</script>
{#if displayItems}
<ul class="subnav {name}" class:subpage class:progress>
{#each displayItems as page, index}
<SubNavItem {page}/>
<!-- Show a progress indicator after all but the last item. -->
{#if progress && index < (displayItems.length - 1)}
<li class="step-arrow">
<img src="{$urls.assets + 'img/icon/subnav-arrow.svg'}" alt="">
</li>
{/if}
{/each}
</ul>
{/if}

View File

@@ -0,0 +1,46 @@
<script>
import {link, location} from "svelte-spa-router";
import {urls} from "../js/stores";
export let page;
let focus = false;
let hover = false;
$: showIcon = typeof page.noticeIcon === "string" && (["warning", "error"].includes( page.noticeIcon ));
$: iconUrl = showIcon ? $urls.assets + "img/icon/tab-notifier-" + page.noticeIcon + ".svg" : "";
</script>
<li class="subnav-item" class:active={$location === page.route} class:focus class:hover class:has-icon={showIcon}>
<a
href={page.route}
title={page.title()}
use:link
on:focusin={() => focus = true}
on:focusout={() => focus = false}
on:mouseenter={() => hover = true}
on:mouseleave={() => hover = false}
>
{page.title()}
{#if showIcon}
<div class="notice-icon-wrapper notice-icon-{page.noticeIcon}">
<img class="notice-icon" src="{iconUrl}" alt="{page.noticeIcon}">
</div>
{/if}
</a>
</li>
<style>
.notice-icon-wrapper {
display: inline-block;
min-width: 1.1875rem;
}
.notice-icon {
margin-left: 2px;
margin-top: -1.5px;
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,10 @@
<script>
import active from "svelte-spa-router/active";
export let name = "";
export let route = "/";
</script>
<div class="{name}" use:active={route}>
<slot/>
</div>

View File

@@ -0,0 +1,16 @@
<script>
import Router from "svelte-spa-router";
export let name = "sub";
export let prefix = "";
export let routes = {};
</script>
{#if routes}
<div class="{name}-page wrapper">
<Router {routes} {prefix} on:routeEvent/>
<slot>
<!-- EXTRA CONTENT GOES HERE -->
</slot>
</div>
{/if}

View File

@@ -0,0 +1,50 @@
<script>
import {onMount} from "svelte";
import {api, config, diagnostics, strings, urls} from "../js/stores";
import Page from "./Page.svelte";
import Notifications from "./Notifications.svelte";
export let name = "support";
export let title = $strings.support_tab_title;
onMount( async () => {
const json = await api.get( "diagnostics", {} );
if ( json.hasOwnProperty( "diagnostics" ) ) {
$config.diagnostics = json.diagnostics;
}
} );
</script>
<Page {name} on:routeEvent>
<Notifications tab={name}/>
{#if title}
<h2 class="page-title">{title}</h2>
{/if}
<div class="support-page wrapper">
<slot name="header"/>
<div class="columns">
<div class="support-form">
<slot name="content">
<div class="lite-support">
<p>{@html $strings.no_support}</p>
<p>{@html $strings.community_support}</p>
<p>{@html $strings.upgrade_for_support}</p>
<p>{@html $strings.report_a_bug}</p>
</div>
</slot>
<div class="diagnostic-info">
<hr>
<h2 class="page-title">{$strings.diagnostic_info_title}</h2>
<pre>{$diagnostics}</pre>
<a href={$urls.download_diagnostics} class="button btn-md btn-outline">{$strings.download_diagnostics}</a>
</div>
</div>
<slot name="footer"/>
</div>
</div>
</Page>

View File

@@ -0,0 +1,37 @@
<script>
import {strings, urls} from "../js/stores";
export let active = false;
export let disabled = false;
export let icon = "";
export let iconDesc = "";
export let text = ""
export let url = $urls.settings;
</script>
<a
href={url}
class="button-tab"
class:active
class:btn-disabled={disabled}
{disabled}
on:click|preventDefault
>
{#if icon}
<img
src={icon}
type="image/svg+xml"
alt={iconDesc}
>
{/if}
{#if text}
<p>{text}</p>
{/if}
{#if active}
<img
class="checkmark"
src="{$urls.assets + 'img/icon/licence-checked.svg'}"
type="image/svg+xml" alt={$strings.selected_desc}
>
{/if}
</a>

View File

@@ -0,0 +1,17 @@
<script>
export let name = "";
export let checked = false;
export let disabled = false;
</script>
<div class="toggle-switch" class:locked={disabled}>
<input
type="checkbox"
id={name}
bind:checked={checked}
{disabled}
/>
<label class="toggle-label" for={name}>
<slot/>
</label>
</div>

View File

@@ -0,0 +1,14 @@
<script>
import {strings} from "../js/stores";
import Page from "./Page.svelte";
import Notifications from "./Notifications.svelte";
import ToolsUpgrade from "./ToolsUpgrade.svelte";
export let name = "tools";
</script>
<Page {name} on:routeEvent>
<Notifications tab={name}/>
<h2 class="page-title">{$strings.tools_title}</h2>
<ToolsUpgrade/>
</Page>

View File

@@ -0,0 +1,38 @@
<script>
import {strings, urls} from "../js/stores";
import Upsell from "./Upsell.svelte";
let benefits = [
{
icon: $urls.assets + 'img/icon/offload-remaining.svg',
alt: 'offload icon',
text: $strings.tools_uppsell_benefits.offload,
},
{
icon: $urls.assets + 'img/icon/download.svg',
alt: 'download icon',
text: $strings.tools_uppsell_benefits.download,
},
{
icon: $urls.assets + 'img/icon/remove-from-bucket.svg',
alt: 'remove from bucket icon',
text: $strings.tools_uppsell_benefits.remove_bucket,
},
{
icon: $urls.assets + 'img/icon/remove-from-server.svg',
alt: 'remove from server icon',
text: $strings.tools_uppsell_benefits.remove_server,
},
];
</script>
<Upsell benefits={benefits}>
<div slot="heading">{$strings.tools_upsell_heading}</div>
<div slot="description">{@html $strings.tools_upsell_description}</div>
<a slot="call-to-action" href={$urls.upsell_discount_tools} class="button btn-lg btn-primary">
<img src={$urls.assets + "img/icon/stars.svg"} alt="stars icon" style="margin-right: 5px;">
{$strings.tools_upsell_cta}
</a>
</Upsell>

View File

@@ -0,0 +1,78 @@
<script>
import Panel from "../components/Panel.svelte";
export let benefits;
</script>
<Panel name="upsell" class="upsell-panel">
<div class="branding"></div>
<div class="content">
<div class="heading">
<slot name="heading"></slot>
</div>
<div class="description">
<slot name="description"></slot>
</div>
<div class="benefits">
{#each benefits as benefit}
<li>
<img src="{benefit.icon}" alt="{benefit.alt}">
<span>{benefit.text}</span>
</li>
{/each}
</div>
<div class="call-to-action">
<slot name="call-to-action"></slot>
<div class="note">
<slot name="call-to-action-note"></slot>
</div>
</div>
</div>
</Panel>
<style>
.content {
padding: 1.875rem 2.25rem 1.5rem 2.25rem;
display: flex;
flex-direction: column;
}
.heading {
margin-top: 1rem;
font-weight: 700;
font-size: 1.125rem;
line-height: 140%;
}
.description {
margin-top: 1rem;
color: rgba(56, 54, 55, 0.7);
}
.benefits {
margin-top: 1.7rem;
color: rgba(56, 54, 55, 0.7);
}
.benefits li {
display: flex;
align-items: center;
}
.benefits img {
height: 40px;
margin-left: -5px;
margin-right: 10px;
}
.call-to-action {
margin-top: 0.7rem;
}
.call-to-action .note {
text-align: center;
}
</style>

View File

@@ -0,0 +1,59 @@
<script>
import {scale} from "svelte/transition";
import {api, settings, settings_changed, strings, urls} from "../js/stores";
import Panel from "./Panel.svelte";
import PanelRow from "./PanelRow.svelte";
let parts = $urls.url_parts;
/**
* When settings have changed, show their preview URL, otherwise show saved settings version.
*
* Note: This function **assigns** to the `example` and `parts` variables to defeat the reactive demons!
*
* @param {Object} urls
* @param {boolean} settingsChanged
* @param {Object} settings
*
* @returns boolean
*/
async function temporaryUrl( urls, settingsChanged, settings ) {
if ( settingsChanged ) {
const response = await api.post( "url-preview", {
"settings": settings
} );
// Use temporary URLs if available.
if ( response.hasOwnProperty( "url_parts" ) ) {
parts = response.url_parts;
return true;
}
}
// Reset back to saved URLs.
parts = urls.url_parts;
return false;
}
$: isTemporaryUrl = temporaryUrl( $urls, $settings_changed, $settings );
</script>
{#if parts.length > 0}
<Panel name="url-preview" heading={$strings.url_preview_title}>
<PanelRow class="desc">
<p>{$strings.url_preview_desc}</p>
</PanelRow>
<PanelRow class="body flex-row">
<dl>
{#each parts as part (part.title)}
<div data-key={part.key} transition:scale>
<dt>{part.title}</dt>
<dd>{part.example}</dd>
</div>
{/each}
</dl>
</PanelRow>
</Panel>
{/if}

View File

@@ -0,0 +1,7 @@
<script>
export let provider;
</script>
<p>{@html provider.use_server_roles_desc}</p>
<pre>{provider.use_server_roles_example}</pre>

8
ui/js/autofocus.js Normal file
View File

@@ -0,0 +1,8 @@
/**
* A simple action that focuses the supplied HTML node.
*
* @param {Object} node
*/
export function autofocus( node ) {
node.focus();
}

144
ui/js/defaultPages.js Normal file
View File

@@ -0,0 +1,144 @@
import {get} from "svelte/store";
import {location} from "svelte-spa-router";
import {
strings,
storage_provider,
is_plugin_setup_with_credentials,
is_plugin_setup,
needs_access_keys,
delivery_provider
} from "./stores";
// Components used for default pages.
import MediaPage from "../components/MediaPage.svelte";
import StoragePage from "../components/StoragePage.svelte";
import StorageProviderSubPage
from "../components/StorageProviderSubPage.svelte";
import BucketSettingsSubPage from "../components/BucketSettingsSubPage.svelte";
import SecuritySubPage from "../components/SecuritySubPage.svelte";
import DeliveryPage from "../components/DeliveryPage.svelte";
// Default pages, having a title means inclusion in main tabs.
// NOTE: get() only resolves after initialization, hence arrow functions for getting titles.
export const defaultPages = [
{
position: 0,
name: "media-library",
title: () => get( strings ).media_tab_title,
nav: true,
route: "/",
routeMatcher: /^\/(media\/.*)*$/,
component: MediaPage,
default: true
},
{
position: 200,
name: "storage",
route: "/storage/*",
component: StoragePage
},
{
position: 210,
name: "storage-provider",
title: () => get( strings ).storage_provider_tab_title,
subNav: true,
route: "/storage/provider",
component: StorageProviderSubPage,
default: true,
events: {
"page.initial.settings": ( data ) => {
// We need Storage Provider credentials for some pages to be useful.
if ( data.hasOwnProperty( "location" ) && get( needs_access_keys ) && !get( is_plugin_setup ) ) {
for ( const prefix of ["/storage", "/media", "/delivery"] ) {
if ( data.location.startsWith( prefix ) ) {
return true;
}
}
return data.location === "/";
}
return false;
}
}
},
{
position: 220,
name: "bucket",
title: () => get( strings ).bucket_tab_title,
subNav: true,
route: "/storage/bucket",
component: BucketSettingsSubPage,
enabled: () => {
return !get( needs_access_keys );
},
events: {
"page.initial.settings": ( data ) => {
// We need a bucket and region to have been verified before some pages are useful.
if ( data.hasOwnProperty( "location" ) && !get( needs_access_keys ) && !get( is_plugin_setup ) ) {
for ( const prefix of ["/storage", "/media", "/delivery"] ) {
if ( data.location.startsWith( prefix ) ) {
return true;
}
}
return data.location === "/";
}
return false;
},
"settings.save": ( data ) => {
// If currently in /storage/provider route, bucket is always next, assuming storage provider set up correctly.
return get( location ) === "/storage/provider" && !get( needs_access_keys );
}
}
},
{
position: 230,
name: "security",
title: () => get( strings ).security_tab_title,
subNav: true,
route: "/storage/security",
component: SecuritySubPage,
enabled: () => {
return get( is_plugin_setup_with_credentials ) && !get( storage_provider ).requires_acls;
},
events: {
"settings.save": ( data ) => {
// If currently in /storage/bucket route,
// and storage provider does not require ACLs,
// and bucket wasn't just created during initial set up
// with delivery provider compatible access control,
// then security is next.
if (
get( location ) === "/storage/bucket" &&
get( is_plugin_setup_with_credentials ) &&
!get( storage_provider ).requires_acls &&
(
!data.hasOwnProperty( "bucketSource" ) || // unexpected data issue
data.bucketSource !== "new" || // bucket not created
!data.hasOwnProperty( "initialSettings" ) || // unexpected data issue
!data.initialSettings.hasOwnProperty( "bucket" ) || // unexpected data issue
data.initialSettings.bucket.length > 0 || // bucket previously set
!data.hasOwnProperty( "settings" ) || // unexpected data issue
!data.settings.hasOwnProperty( "use-bucket-acls" ) || // unexpected data issue
(
!data.settings[ "use-bucket-acls" ] && // bucket not using ACLs ...
get( delivery_provider ).requires_acls // ... but delivery provider needs ACLs
)
)
) {
return true;
}
return false;
}
}
},
{
position: 300,
name: "delivery",
route: "/delivery/*",
component: DeliveryPage
},
];

12
ui/js/delay.js Normal file
View File

@@ -0,0 +1,12 @@
/**
* Return a promise that resolves after a minimum amount of time has elapsed.
*
* @param {number} start Timestamp of when the action started.
* @param {number} minTime Minimum amount of time to delay in milliseconds.
*
* @return {Promise}
*/
export function delayMin( start, minTime ) {
let elapsed = Date.now() - start;
return new Promise( ( resolve ) => setTimeout( resolve, minTime - elapsed ) );
}

8
ui/js/getLocale.js Normal file
View File

@@ -0,0 +1,8 @@
/**
* Get the user's current locale string.
*
* @return {string}
*/
export function getLocale() {
return (navigator.languages && navigator.languages.length) ? navigator.languages[ 0 ] : navigator.language;
}

24
ui/js/needsRefresh.js Normal file
View File

@@ -0,0 +1,24 @@
import {objectsDiffer} from "./objectsDiffer";
/**
* Determines whether a page should be refreshed due to changes to settings.
*
* @param {boolean} saving
* @param {object} previousSettings
* @param {object} currentSettings
* @param {object} previousDefines
* @param {object} currentDefines
*
* @returns {boolean}
*/
export function needsRefresh( saving, previousSettings, currentSettings, previousDefines, currentDefines ) {
if ( saving ) {
return false;
}
if ( objectsDiffer( [previousSettings, currentSettings] ) ) {
return true;
}
return objectsDiffer( [previousDefines, currentDefines] );
}

23
ui/js/numToString.js Normal file
View File

@@ -0,0 +1,23 @@
import {getLocale} from "./getLocale";
/**
* Get number formatted for user's current locale.
*
* @param {number} num
*
* @return {string}
*/
export function numToString( num ) {
return Intl.NumberFormat( getLocale() ).format( num );
}
/**
* Get number formatted with short representation for user's current locale.
*
* @param {number} num
*
* @return {string}
*/
export function numToShortString( num ) {
return Intl.NumberFormat( getLocale(), { notation: "compact" } ).format( num );
}

41
ui/js/objectsDiffer.js Normal file
View File

@@ -0,0 +1,41 @@
/**
* Does the current object have different keys or values compared to the previous version?
*
* @param {object} previous
* @param {object} current
*
* @returns {boolean}
*/
export function objectsDiffer( [previous, current] ) {
if ( !previous || !current ) {
return false;
}
// Any difference in keys?
const prevKeys = Object.keys( previous );
const currKeys = Object.keys( current );
if ( prevKeys.length !== currKeys.length ) {
return true;
}
// Symmetrical diff to find extra keys in either object.
if (
prevKeys.filter( x => !currKeys.includes( x ) )
.concat(
currKeys.filter( x => !prevKeys.includes( x ) )
)
.length > 0
) {
return true;
}
// Any difference in values?
for ( const key in previous ) {
if ( JSON.stringify( current[ key ] ) !== JSON.stringify( previous[ key ] ) ) {
return true;
}
}
return false;
}

117
ui/js/routes.js Normal file
View File

@@ -0,0 +1,117 @@
import {derived, writable, get} from "svelte/store";
import {wrap} from "svelte-spa-router/wrap";
/**
* Creates store of default pages.
*
* Having a title means inclusion in main tabs.
*
* @return {Object}
*/
function createPages() {
// NOTE: get() only resolves after initialization, hence arrow functions for getting titles.
const { subscribe, set, update } = writable( [] );
return {
subscribe,
set,
add( page ) {
update( $pages => {
return [...$pages, page]
.sort( ( a, b ) => {
return a.position - b.position;
} );
} );
},
withPrefix( prefix = null ) {
return get( this ).filter( ( page ) => {
return (prefix && page.route.startsWith( prefix )) || !prefix;
} );
},
routes( prefix = null ) {
let defaultComponent = null;
let defaultUserData = null;
const routes = new Map();
// If a page can be enabled/disabled, check whether it is enabled before displaying.
const conditions = [
( detail ) => {
if (
detail.hasOwnProperty( "userData" ) &&
detail.userData.hasOwnProperty( "page" ) &&
detail.userData.page.hasOwnProperty( "enabled" )
) {
return detail.userData.page.enabled();
}
return true;
}
];
for ( const page of this.withPrefix( prefix ) ) {
const userData = { page: page };
let route = page.route;
if ( prefix && route !== prefix + "/*" ) {
route = route.replace( prefix, "" );
}
routes.set( route, wrap( {
component: page.component,
userData: userData,
conditions: conditions
} ) );
if ( !defaultComponent && page.default ) {
defaultComponent = page.component;
defaultUserData = userData;
}
}
if ( defaultComponent ) {
routes.set( "*", wrap( {
component: defaultComponent,
userData: defaultUserData,
conditions: conditions
} ) );
}
return routes;
},
handleRouteEvent( detail ) {
if ( detail.hasOwnProperty( "event" ) ) {
if ( !detail.hasOwnProperty( "data" ) ) {
detail.data = {};
}
// Find the first page that wants to handle the event
// , but also let other pages see the event
// so they can set any initial state etc.
let route = false;
for ( const page of get( this ).values() ) {
if ( page.events && page.events[ detail.event ] && page.events[ detail.event ]( detail.data ) && !route ) {
route = page.route;
}
}
if ( route ) {
return route;
}
}
if ( detail.hasOwnProperty( "default" ) ) {
return detail.default;
}
return false;
}
};
}
export const pages = createPages();
// Convenience readable store of all routes.
export const routes = derived( pages, () => {
return pages.routes();
} );

11
ui/js/scrollIntoView.js Normal file
View File

@@ -0,0 +1,11 @@
/**
* A simple action to scroll the element into view if active.
*
* @param {Object} node
* @param {boolean} active
*/
export function scrollIntoView( node, active ) {
if ( active ) {
node.scrollIntoView( { behavior: "smooth", block: "center", inline: "nearest" } );
}
}

View File

@@ -0,0 +1,10 @@
/**
* Scrolls the notifications into view.
*/
export function scrollNotificationsIntoView() {
const element = document.getElementById( "notifications" );
if ( element ) {
element.scrollIntoView( { behavior: "smooth", block: "start" } );
}
}

View File

@@ -0,0 +1,51 @@
export const settingsNotifications = {
/**
* Process local and server settings to return a new Map of inline notifications.
*
* @param {Map} notifications
* @param {Object} settings
* @param {Object} current_settings
* @param {Object} strings
*
* @return {Map<string, Map<string, Object>>} keyed by setting name, containing map of notification objects keyed by id.
*/
process: ( notifications, settings, current_settings, strings ) => {
// remove-local-file
if ( settings.hasOwnProperty( "remove-local-file" ) && settings[ "remove-local-file" ] ) {
let entries = notifications.has( "remove-local-file" ) ? notifications.get( "remove-local-file" ) : new Map();
if ( settings.hasOwnProperty( "serve-from-s3" ) && !settings[ "serve-from-s3" ] ) {
if ( !entries.has( "lost-files-notice" ) ) {
entries.set( "lost-files-notice", {
inline: true,
type: "error",
heading: strings.lost_files_notice_heading,
message: strings.lost_files_notice_message
} );
}
} else {
entries.delete( "lost-files-notice" );
}
// Show inline warning about potential compatibility issues
// when turning on setting for the first time.
if (
!entries.has( "remove-local-file-notice" ) &&
current_settings.hasOwnProperty( "remove-local-file" ) &&
!current_settings[ "remove-local-file" ]
) {
entries.set( "remove-local-file-notice", {
inline: true,
type: "warning",
message: strings.remove_local_file_message
} );
}
notifications.set( "remove-local-file", entries );
} else {
notifications.delete( "remove-local-file" );
}
return notifications;
}
};

594
ui/js/stores.js Normal file
View File

@@ -0,0 +1,594 @@
import {derived, writable, get, readable} from "svelte/store";
import {objectsDiffer} from "./objectsDiffer";
// Initial config store.
export const config = writable( {} );
// Whether settings are locked due to background activity such as upgrade.
export const settingsLocked = writable( false );
// Convenience readable store of server's settings, derived from config.
export const current_settings = derived( config, $config => $config.settings );
// Convenience readable store of defined settings keys, derived from config.
export const defined_settings = derived( config, $config => $config.defined_settings );
// Convenience readable store of translated strings, derived from config.
export const strings = derived( config, $config => $config.strings );
// Convenience readable store for nonce, derived from config.
export const nonce = derived( config, $config => $config.nonce );
// Convenience readable store of urls, derived from config.
export const urls = derived( config, $config => $config.urls );
// Convenience readable store of docs, derived from config.
export const docs = derived( config, $config => $config.docs );
// Convenience readable store of api endpoints, derived from config.
export const endpoints = derived( config, $config => $config.endpoints );
// Convenience readable store of diagnostics, derived from config.
export const diagnostics = derived( config, $config => $config.diagnostics );
// Convenience readable store of counts, derived from config.
export const counts = derived( config, $config => $config.counts );
// Convenience readable store of summary counts, derived from config.
export const summaryCounts = derived( config, $config => $config.summary_counts );
// Convenience readable store of offload remaining upsell, derived from config.
export const offloadRemainingUpsell = derived( config, $config => $config.offload_remaining_upsell );
// Convenience readable store of upgrades, derived from config.
export const upgrades = derived( config, $config => $config.upgrades );
// Convenience readable store of whether plugin is set up, derived from config.
export const is_plugin_setup = derived( config, $config => $config.is_plugin_setup );
// Convenience readable store of whether plugin is set up, including with credentials, derived from config.
export const is_plugin_setup_with_credentials = derived( config, $config => $config.is_plugin_setup_with_credentials );
// Convenience readable store of whether storage provider needs access credentials, derived from config.
export const needs_access_keys = derived( config, $config => $config.needs_access_keys );
// Convenience readable store of whether bucket is writable, derived from config.
export const bucket_writable = derived( config, $config => $config.bucket_writable );
// Convenience readable store of settings validation results, derived from config.
export const settings_validation = derived( config, $config => $config.settings_validation );
// Store of inline errors and warnings to be shown next to settings.
// Format is a map using settings key for keys, values are an array of objects that can be used to instantiate a notification.
export const settings_notifications = writable( new Map() );
// Store of validation errors for settings.
// Format is a map using settings key for keys, values are strings containing validation error.
export const validationErrors = writable( new Map() );
// Whether settings validations are being run.
export const revalidatingSettings = writable( false );
// Does the app need a page refresh to resolve conflicts?
export const needs_refresh = writable( false );
// Various stores may call the API, and the api object uses some stores.
// To avoid cyclic dependencies, we therefore co-locate the api object with the stores.
// We also need to add its functions much later so that JSHint does not complain about using the stores too early.
export const api = {};
/**
* Creates store of settings.
*
* @return {Object}
*/
function createSettings() {
const { subscribe, set, update } = writable( [] );
return {
subscribe,
set,
async save() {
const json = await api.put( "settings", get( this ) );
if ( json.hasOwnProperty( "saved" ) && true === json.saved ) {
// Sync settings with what the server has.
this.updateSettings( json );
return json;
}
return { 'saved': false };
},
reset() {
set( { ...get( current_settings ) } );
},
async fetch() {
const json = await api.get( "settings", {} );
this.updateSettings( json );
},
updateSettings( json ) {
if (
json.hasOwnProperty( "defined_settings" ) &&
json.hasOwnProperty( "settings" ) &&
json.hasOwnProperty( "storage_providers" ) &&
json.hasOwnProperty( "delivery_providers" ) &&
json.hasOwnProperty( "is_plugin_setup" ) &&
json.hasOwnProperty( "is_plugin_setup_with_credentials" ) &&
json.hasOwnProperty( "needs_access_keys" ) &&
json.hasOwnProperty( "bucket_writable" ) &&
json.hasOwnProperty( "urls" )
) {
// Update our understanding of what the server's settings are.
config.update( $config => {
return {
...$config,
defined_settings: json.defined_settings,
settings: json.settings,
storage_providers: json.storage_providers,
delivery_providers: json.delivery_providers,
is_plugin_setup: json.is_plugin_setup,
is_plugin_setup_with_credentials: json.is_plugin_setup_with_credentials,
needs_access_keys: json.needs_access_keys,
bucket_writable: json.bucket_writable,
urls: json.urls
};
} );
// Update our local working copy of the settings.
update( $settings => {
return { ...json.settings };
} );
}
}
};
}
export const settings = createSettings();
// Have the settings been changed from current server side settings?
export const settings_changed = derived( [settings, current_settings], objectsDiffer );
// Convenience readable store of default storage provider, derived from config.
export const defaultStorageProvider = derived( config, $config => $config.default_storage_provider );
// Convenience readable store of available storage providers.
export const storage_providers = derived( [config, urls], ( [$config, $urls] ) => {
for ( const key in $config.storage_providers ) {
$config.storage_providers[ key ].icon = $urls.assets + "img/icon/provider/storage/" + $config.storage_providers[ key ].provider_key_name + ".svg";
$config.storage_providers[ key ].link_icon = $urls.assets + "img/icon/provider/storage/" + $config.storage_providers[ key ].provider_key_name + "-link.svg";
$config.storage_providers[ key ].round_icon = $urls.assets + "img/icon/provider/storage/" + $config.storage_providers[ key ].provider_key_name + "-round.svg";
}
return $config.storage_providers;
} );
// Convenience readable store of storage provider's details.
export const storage_provider = derived( [settings, storage_providers], ( [$settings, $storage_providers] ) => {
if ( $settings.hasOwnProperty( "provider" ) && $storage_providers.hasOwnProperty( $settings.provider ) ) {
return $storage_providers[ $settings.provider ];
} else {
return [];
}
} );
// Convenience readable store of default delivery provider, derived from config.
export const defaultDeliveryProvider = derived( config, $config => $config.default_delivery_provider );
// Convenience readable store of available delivery providers.
export const delivery_providers = derived( [config, urls, storage_provider], ( [$config, $urls, $storage_provider] ) => {
for ( const key in $config.delivery_providers ) {
if ( "storage" === key ) {
$config.delivery_providers[ key ].icon = $storage_provider.icon;
$config.delivery_providers[ key ].round_icon = $storage_provider.round_icon;
$config.delivery_providers[ key ].provider_service_quick_start_url = $storage_provider.provider_service_quick_start_url;
} else {
$config.delivery_providers[ key ].icon = $urls.assets + "img/icon/provider/delivery/" + $config.delivery_providers[ key ].provider_key_name + ".svg";
$config.delivery_providers[ key ].round_icon = $urls.assets + "img/icon/provider/delivery/" + $config.delivery_providers[ key ].provider_key_name + "-round.svg";
}
}
return $config.delivery_providers;
} );
// Convenience readable store of delivery provider's details.
export const delivery_provider = derived( [settings, delivery_providers, urls], ( [$settings, $delivery_providers, $urls] ) => {
if ( $settings.hasOwnProperty( "delivery-provider" ) && $delivery_providers.hasOwnProperty( $settings[ "delivery-provider" ] ) ) {
return $delivery_providers[ $settings[ "delivery-provider" ] ];
} else {
return [];
}
} );
// Full name for current region.
export const region_name = derived( [settings, storage_provider, strings], ( [$settings, $storage_provider, $strings] ) => {
if ( $settings.region && $storage_provider.regions && $storage_provider.regions.hasOwnProperty( $settings.region ) ) {
return $storage_provider.regions[ $settings.region ];
} else if ( $settings.region && $storage_provider.regions ) {
// Region set but not available in list of regions.
return $strings.unknown;
} else if ( $storage_provider.default_region && $storage_provider.regions && $storage_provider.regions.hasOwnProperty( $storage_provider.default_region ) ) {
// Region not set but default available.
return $storage_provider.regions[ $storage_provider.default_region ];
} else {
// Possibly no default region or regions available.
return $strings.unknown;
}
} );
// Convenience readable store of whether Block All Public Access is enabled.
export const bapa = derived( [settings, storage_provider], ( [$settings, $storage_provider] ) => {
return $storage_provider.block_public_access_supported && $settings.hasOwnProperty( "block-public-access" ) && $settings[ "block-public-access" ];
} );
// Convenience readable store of whether Object Ownership is enforced.
export const ooe = derived( [settings, storage_provider], ( [$settings, $storage_provider] ) => {
return $storage_provider.object_ownership_supported && $settings.hasOwnProperty( "object-ownership-enforced" ) && $settings[ "object-ownership-enforced" ];
} );
/**
* Creates a store of notifications.
*
* Example object in the array:
* {
* id: "error-message",
* type: "error", // error | warning | success | primary (default)
* dismissible: true,
* flash: true, // Optional, means notification is context specific and will not persist on server, defaults to true.
* inline: false, // Optional, unlikely to be true, included here for completeness.
* only_show_on_tab: "media-library", // Optional, blank/missing means on all tabs.
* heading: "Global Error: Something has gone terribly pear shaped.", // Optional.
* message: "We're so sorry, but unfortunately we're going to have to delete the year 2020.", // Optional.
* icon: "notification-error.svg", // Optional icon file name to be shown in front of heading.
* plainHeading: false, // Optional boolean as to whether a <p> tag should be used instead of <h3> for heading content.
* extra: "", // Optional extra content to be shown in paragraph below message.
* links: [], // Optional list of links to be shown at bottom of notice.
* },
*
* @return {Object}
*/
function createNotifications() {
const { subscribe, set, update } = writable( [] );
return {
set,
subscribe,
add( notification ) {
// There's a slight difference between our notification's formatting and what WP uses.
if ( notification.hasOwnProperty( "type" ) && notification.type === "updated" ) {
notification.type = "success";
}
if ( notification.hasOwnProperty( "type" ) && notification.type === "notice-warning" ) {
notification.type = "warning";
}
if ( notification.hasOwnProperty( "type" ) && notification.type === "notice-info" ) {
notification.type = "info";
}
if (
notification.hasOwnProperty( "message" ) &&
(!notification.hasOwnProperty( "heading" ) || notification.heading.trim().length === 0)
) {
notification.heading = notification.message;
notification.plainHeading = true;
delete notification.message;
}
if ( !notification.hasOwnProperty( "flash" ) ) {
notification.flash = true;
}
// We need some sort of id for indexing and to ensure rendering is efficient.
if ( !notification.hasOwnProperty( "id" ) ) {
// Notifications are useless without at least a heading or message, so we can be sure at least one exists.
const idHeading = notification.hasOwnProperty( "heading" ) ? notification.heading.trim() : "dynamic-heading";
const idMessage = notification.hasOwnProperty( "message" ) ? notification.message.trim() : "dynamic-message";
notification.id = btoa( idHeading + idMessage );
}
// So that rendering is efficient, but updates displayed notifications that re-use keys,
// we create a render_key based on id and created_at as created_at is churned on re-use.
const createdAt = notification.hasOwnProperty( "created_at" ) ? notification.created_at : 0;
notification.render_key = notification.id + "-" + createdAt;
update( $notifications => {
// Maybe update a notification if id already exists.
let index = -1;
if ( notification.hasOwnProperty( "id" ) ) {
index = $notifications.findIndex( _notification => _notification.id === notification.id );
}
if ( index >= 0 ) {
// If the id exists but has been dismissed, add the replacement notification to the end of the array
// if given notification is newer, otherwise skip it entirely.
if ( $notifications[ index ].hasOwnProperty( "dismissed" ) ) {
if ( $notifications[ index ].dismissed < notification.created_at ) {
$notifications.push( notification );
$notifications.splice( index, 1 );
}
} else {
// Update existing.
$notifications.splice( index, 1, notification );
}
} else {
// Add new.
$notifications.push( notification );
}
return $notifications.sort( this.sortCompare );
} );
},
sortCompare( a, b ) {
// Sort by created_at in case an existing notification was updated.
if ( a.created_at < b.created_at ) {
return -1;
}
if ( a.created_at > b.created_at ) {
return 1;
}
return 0;
},
async dismiss( id ) {
update( $notifications => {
const index = $notifications.findIndex( notification => notification.id === id );
// If the notification still exists, set a "dismissed" tombstone with the created_at value.
// The cleanup will delete any notifications that have been dismissed and no longer exist
// in the list of notifications retrieved from the server.
// The created_at value ensures that if a notification is retrieved from the server that
// has the same id but later created_at, then it can be added, otherwise it is skipped.
if ( index >= 0 ) {
if ( $notifications[ index ].hasOwnProperty( "created_at" ) ) {
$notifications[ index ].dismissed = $notifications[ index ].created_at;
} else {
// Notification likely did not come from server, maybe a local "flash" notification.
$notifications.splice( index, 1 );
}
}
return $notifications;
} );
// Tell server to dismiss notification, still ok to try if flash notification, makes sure it is definitely removed.
await api.delete( "notifications", { id: id, all_tabs: true } );
},
/**
* Delete removes a notification from the UI without telling the server.
*/
delete( id ) {
update( $notifications => {
const index = $notifications.findIndex( notification => notification.id === id );
if ( index >= 0 ) {
$notifications.splice( index, 1 );
}
return $notifications;
} );
},
cleanup( latest ) {
update( $notifications => {
for ( const [index, notification] of $notifications.entries() ) {
// Only clean up dismissed or server created notices that no longer exist.
if ( notification.hasOwnProperty( "dismissed" ) || notification.hasOwnProperty( "created_at" ) ) {
const latestIndex = latest.findIndex( _notification => _notification.id === notification.id );
// If server doesn't know about the notification anymore, remove it.
if ( latestIndex < 0 ) {
$notifications.splice( index, 1 );
}
}
}
return $notifications;
} );
}
};
}
export const notifications = createNotifications();
// Controller for periodic fetch of state info.
let stateFetchInterval;
let stateFetchIntervalStarted = false;
let stateFetchIntervalPaused = false;
// Store of functions to call before an update of state processes the result into config.
export const preStateUpdateCallbacks = writable( [] );
// Store of functions to call after an update of state processes the result into config.
export const postStateUpdateCallbacks = writable( [] );
/**
* Store of functions to call when state info is updated, and actual API access methods.
*
* Functions are called after the returned state info has been used to update the config store.
* Therefore, functions should only be added to the store if extra processing is required.
* The functions should be asynchronous as they are part of the reactive chain and called with await.
*
* @return {Object}
*/
function createState() {
const { subscribe, set, update } = writable( [] );
return {
subscribe,
set,
update,
async fetch() {
const json = await api.get( "state", {} );
// Abort controller is still a bit hit or miss, so we'll go old skool.
if ( stateFetchIntervalStarted && !stateFetchIntervalPaused ) {
this.updateState( json );
}
},
updateState( json ) {
for ( const callable of get( preStateUpdateCallbacks ) ) {
callable( json );
}
const dirty = get( settings_changed );
const previous_settings = { ...get( current_settings ) }; // cloned
config.update( $config => {
return { ...$config, ...json };
} );
// If the settings weren't changed before, they shouldn't be now.
if ( !dirty && get( settings_changed ) ) {
settings.reset();
}
// If settings are in middle of being changed when changes come in
// from server, reset to server version.
if ( dirty && objectsDiffer( [previous_settings, get( current_settings )] ) ) {
needs_refresh.update( $needs_refresh => true );
settings.reset();
}
for ( const callable of get( postStateUpdateCallbacks ) ) {
callable( json );
}
},
async startPeriodicFetch() {
stateFetchIntervalStarted = true;
stateFetchIntervalPaused = false;
await this.fetch();
stateFetchInterval = setInterval( async () => {
await this.fetch();
}, 5000 );
},
stopPeriodicFetch() {
stateFetchIntervalStarted = false;
stateFetchIntervalPaused = false;
clearInterval( stateFetchInterval );
},
pausePeriodicFetch() {
if ( stateFetchIntervalStarted ) {
stateFetchIntervalPaused = true;
clearInterval( stateFetchInterval );
}
},
async resumePeriodicFetch() {
stateFetchIntervalPaused = false;
if ( stateFetchIntervalStarted ) {
await this.startPeriodicFetch();
}
}
};
}
export const state = createState();
// API functions added here to avoid JSHint errors.
api.headers = () => {
return {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-WP-Nonce': get( nonce )
};
};
api.url = ( endpoint ) => {
return get( urls ).api + get( endpoints )[ endpoint ];
};
api.get = async ( endpoint, params ) => {
let url = new URL( api.url( endpoint ) );
const searchParams = new URLSearchParams( params );
searchParams.forEach( function( value, name ) {
url.searchParams.set( name, value );
} );
const response = await fetch( url.toString(), {
method: 'GET',
headers: api.headers()
} );
return response.json().then( json => {
json = api.check_response( json );
return json;
} );
};
api.post = async ( endpoint, body ) => {
const response = await fetch( api.url( endpoint ), {
method: 'POST',
headers: api.headers(),
body: JSON.stringify( body )
} );
return response.json().then( json => {
json = api.check_response( json );
return json;
} );
};
api.put = async ( endpoint, body ) => {
const response = await fetch( api.url( endpoint ), {
method: 'PUT',
headers: api.headers(),
body: JSON.stringify( body )
} );
return response.json().then( json => {
json = api.check_response( json );
return json;
} );
};
api.delete = async ( endpoint, body ) => {
const response = await fetch( api.url( endpoint ), {
method: 'DELETE',
headers: api.headers(),
body: JSON.stringify( body )
} );
return response.json().then( json => {
json = api.check_response( json );
return json;
} );
};
api.check_errors = ( json ) => {
if ( json.code && json.message ) {
notifications.add( {
id: json.code,
type: 'error',
dismissible: true,
heading: get( strings ).api_error_notice_heading,
message: json.message
} );
// Just in case resultant json is expanded into a store.
delete json.code;
delete json.message;
}
return json;
};
api.check_notifications = ( json ) => {
const _notifications = json.hasOwnProperty( "notifications" ) ? json.notifications : [];
if ( _notifications ) {
for ( const notification of _notifications ) {
notifications.add( notification );
}
}
notifications.cleanup( _notifications );
// Just in case resultant json is expanded into a store.
delete json.notifications;
return json;
};
api.check_response = ( json ) => {
json = api.check_notifications( json );
json = api.check_errors( json );
return json;
};

33
ui/pro/AssetsPage.svelte Normal file
View File

@@ -0,0 +1,33 @@
<script>
import {
assetsSettings,
assetsSettingsChanged,
assetsSettingsLocked,
currentAssetsSettings,
enableAssets
} from "./stores";
import Page from "../components/Page.svelte";
import Notifications from "../components/Notifications.svelte";
import AssetsSettings from "./AssetsSettings.svelte";
import AssetsUpgrade from "./AssetsUpgrade.svelte";
import Footer from "../components/Footer.svelte";
import {setContext} from "svelte";
export let name = "assets";
// Let all child components know if settings are currently locked.
setContext( "settingsLocked", assetsSettingsLocked );
</script>
<Page {name} on:routeEvent initialSettings={currentAssetsSettings}>
<Notifications tab={name}/>
<div class="assets-page wrapper">
{#if $enableAssets}
<AssetsSettings/>
{:else}
<AssetsUpgrade/>
{/if}
</div>
</Page>
<Footer settingsStore={assetsSettings} settingsChangedStore={assetsSettingsChanged} on:routeEvent/>

View File

@@ -0,0 +1,67 @@
<script>
import {strings, urls} from "../js/stores";
import {assetsSettings, assetsDefinedSettings} from "./stores";
import AssetsSettingsHeaderRow from "./AssetsSettingsHeaderRow.svelte";
import Panel from "../components/Panel.svelte";
import SettingsPanelOption from "../components/SettingsPanelOption.svelte";
import SettingsValidationStatusRow
from "../components/SettingsValidationStatusRow.svelte";
/**
* Potentially returns a reason that the provided domain name is invalid.
*
* @param {string} domain
*
* @return {string}
*/
function validator( domain ) {
const domainPattern = /[^a-z0-9.-]/;
let message = "";
if ( domain.trim().length === 0 ) {
message = $strings.domain_blank;
} else if ( true === domainPattern.test( domain ) ) {
message = $strings.domain_invalid_content;
} else if ( domain.length < 3 ) {
message = $strings.domain_too_short;
} else if ( domain === $urls.home_domain ) {
message = $strings.assets_domain_same_as_site;
}
return message;
}
</script>
<Panel name="settings" class="assets-panel" heading={$strings.assets_title} helpKey="assets-pull">
<AssetsSettingsHeaderRow/>
<SettingsValidationStatusRow section="assets"/>
<SettingsPanelOption
heading={$strings.assets_rewrite_urls}
description={$strings.assets_rewrite_urls_desc}
placeholder="assets.example.com"
toggleName="rewrite-urls"
bind:toggle={$assetsSettings["rewrite-urls"]}
textName="domain"
bind:text={$assetsSettings["domain"]}
definedSettings={assetsDefinedSettings}
{validator}
>
</SettingsPanelOption>
<SettingsPanelOption
heading={$strings.assets_force_https}
description={$strings.assets_force_https_desc}
toggleName="force-https"
bind:toggle={$assetsSettings["force-https"]}
definedSettings={assetsDefinedSettings}
/>
</Panel>
<!--
<div class="btn-row">
<div class="notice">
<img class="icon notice-icon assets-wizard" src="{$urls.assets + 'img/icon/assets-wizard.svg'}" alt="Launch the Assets Setup Wizard"/><a href={$urls.settings} class="link">Launch the Assets Setup Wizard</a>
</div>
</div>
-->

View File

@@ -0,0 +1,42 @@
<script>
import {strings, urls} from "../js/stores";
import PanelRow from "../components/PanelRow.svelte";
</script>
<PanelRow header class="assets">
<img src="{$urls.assets + 'img/icon/assets.svg'}" alt="foo"/>
<div class="assets-details">
<h3>{$strings.assets_panel_header}</h3>
<p class="console-details">
{$strings.assets_panel_header_details}
</p>
</div>
</PanelRow>
<style>
:global(#as3cf-settings.wpome div.panel.settings .header) img {
width: var(--as3cf-settings-ctrl-width);
height: var(--as3cf-settings-ctrl-width);
}
.assets-details {
display: flex;
flex-direction: column;
flex: auto;
margin-left: var(--as3cf-settings-option-indent);
z-index: 1;
}
:global(#as3cf-settings.wpome div.panel) .assets-details h3 {
margin-left: 0;
margin-bottom: 0.5rem;
}
:global(#as3cf-settings.wpome div.panel) .console-details {
display: flex;
align-items: center;
color: var(--as3cf-color-gray-600);
font-size: 0.75rem;
}
</style>

View File

@@ -0,0 +1,37 @@
<script>
import {strings, urls} from "../js/stores";
import Upsell from "../components/Upsell.svelte";
let benefits = [
{
icon: $urls.assets + 'img/icon/fonts.svg',
alt: 'js icon',
text: $strings.assets_uppsell_benefits.js,
},
{
icon: $urls.assets + 'img/icon/css.svg',
alt: 'css icon',
text: $strings.assets_uppsell_benefits.css,
},
{
icon: $urls.assets + 'img/icon/fonts.svg',
alt: 'fonts icon',
text: $strings.assets_uppsell_benefits.fonts,
},
];
</script>
<Upsell benefits={benefits}>
<div slot="heading">{$strings.assets_upsell_heading}</div>
<div slot="description">{@html $strings.assets_upsell_description}</div>
<a slot="call-to-action" href={$urls.upsell_discount_assets} class="button btn-lg btn-primary">
<img src={$urls.assets + "img/icon/stars.svg"} alt="stars icon" style="margin-right: 5px;">
{$strings.assets_upsell_cta}
</a>
<div slot="call-to-action-note">
{@html $strings.assets_upsell_cta_note}
</div>
</Upsell>

View File

@@ -0,0 +1,62 @@
<script>
import {createEventDispatcher, getContext, hasContext} from "svelte";
import {writable} from "svelte/store";
import {pop} from "svelte-spa-router";
import {strings} from "../js/stores";
import {tools} from "./stores";
import SubPage from "../components/SubPage.svelte";
import Panel from "../components/Panel.svelte";
import PanelRow from "../components/PanelRow.svelte";
import BackNextButtonsRow from "../components/BackNextButtonsRow.svelte";
const tool = $tools.copy_buckets;
const dispatch = createEventDispatcher();
// Parent page may want to be locked.
let settingsLocked = writable( false );
if ( hasContext( "settingsLocked" ) ) {
settingsLocked = getContext( "settingsLocked" );
}
/**
* Handles a Skip button click.
*
* @return {Promise<void>}
*/
async function handleSkip() {
dispatch( "routeEvent", { event: "next", default: "/" } );
}
/**
* Handles a Next button click.
*
* @return {Promise<void>}
*/
async function handleNext() {
await tools.start( tool );
dispatch( "routeEvent", { event: "next", default: "/" } );
}
</script>
<SubPage name="copy-buckets" route="/storage/copy-buckets">
<Panel
heading={tool.title}
helpURL={tool.doc_url}
helpDesc={tool.doc_desc}
multi
>
<PanelRow class="body flex-column">
<p>{@html tool.prompt}</p>
</PanelRow>
</Panel>
<BackNextButtonsRow
on:skip={handleSkip}
on:next={handleNext}
skipText={$strings.no}
nextText={$strings.yes}
skipVisible={true}
nextDisabled={$settingsLocked}
/>
</SubPage>

View File

@@ -0,0 +1,13 @@
<script>
import {strings} from "../js/stores";
import {documentation} from "./stores";
</script>
{#if $documentation.length}
<div class="documentation">
<h3>{$strings.documentation_title}</h3>
{#each $documentation as item}
<a href={item.url} class="link" target="_blank">{item.title}</a>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,62 @@
<script>
import {createEventDispatcher, setContext} from "svelte";
import {settingsLocked} from "../js/stores";
import {tools} from "./stores";
import Page from "../components/Page.svelte";
import Notifications from "../components/Notifications.svelte";
import ToolNotification from "./ToolNotification.svelte";
import BackNextButtonsRow from "../components/BackNextButtonsRow.svelte";
import Panel from "../components/Panel.svelte";
import PanelRow from "../components/PanelRow.svelte";
export let name = "downloader";
// Let all child components know if settings are currently locked.
setContext( "settingsLocked", settingsLocked );
const dispatch = createEventDispatcher();
const tool = $tools.downloader;
/**
* Handles a Skip button click.
*
* @return {Promise<void>}
*/
async function handleSkip() {
dispatch( "routeEvent", { event: "next", default: "/" } );
}
/**
* Handles a Next button click.
*
* @return {Promise<void>}
*/
async function handleNext() {
await tools.start( tool );
dispatch( "routeEvent", { event: "next", default: "/" } );
}
</script>
<Page {name} subpage on:routeEvent>
<Notifications tab="media" component={ToolNotification}/>
<Panel
heading={tool.name}
helpURL={tool.doc_url}
helpDesc={tool.doc_desc}
multi
>
<PanelRow class="body flex-column">
<p>{@html tool.prompt}</p>
</PanelRow>
</Panel>
<BackNextButtonsRow
on:skip={handleSkip}
on:next={handleNext}
nextText={tool.button}
skipVisible={true}
nextDisabled={$settingsLocked}
/>
</Page>

26
ui/pro/Header.svelte Normal file
View File

@@ -0,0 +1,26 @@
<script>
import {push} from "svelte-spa-router";
import {strings, urls} from "../js/stores";
import {licence} from "./stores";
import Header from "../components/Header.svelte";
import Button from "../components/Button.svelte";
</script>
<Header>
{#if $licence.is_set}
{#if $licence.is_valid}
<div class="licence-type">
<img src={$urls.assets + "img/icon/licence-checked.svg"} alt={$strings.licence_checked}/>
<a href={$urls.licenses} class="licence" target="_blank">{$licence.plan_plus_licence}</a>
</div>
<p>{@html $licence.customer}</p>
{:else}
<div class="licence-type">
<img src={$urls.assets + "img/icon/error.svg"} alt={$strings.licence_error}/>
<a href={$urls.licenses} class="licence" target="_blank">{$licence.status_description}</a>
</div>
{/if}
{:else}
<Button large primary on:click={() => push("/license")}>{$strings.activate_licence}</Button>
{/if}
</Header>

103
ui/pro/LicencePage.svelte Normal file
View File

@@ -0,0 +1,103 @@
<script>
import {createEventDispatcher} from "svelte";
import {api, config, settings, strings} from "../js/stores";
import {autofocus} from "../js/autofocus";
import {licence} from "./stores";
import Page from "../components/Page.svelte";
import Notifications from "../components/Notifications.svelte";
import Button from "../components/Button.svelte";
import DefinedInWPConfig from "../components/DefinedInWPConfig.svelte";
const dispatch = createEventDispatcher();
export let name = "licence";
let value = "";
/**
* Handles an "Activate License" button click.
*
* @param {Object} event
*
* @return {Promise<void>}
*/
async function handleActivateLicence( event ) {
const result = await api.post( "licences", { licence: value } );
await updateLicenceInfo( result )
}
/**
* Handles a "Remove License" button click.
*
* @param {Object} event
*
* @return {Promise<void>}
*/
async function handleRemoveLicence( event ) {
value = "";
const result = await api.delete( "licences" );
await updateLicenceInfo( result )
}
/**
* Update licence store with results of API call.
*
* @param {Object} response
*
* @return {Promise<void>}
*/
async function updateLicenceInfo( response ) {
if ( response.hasOwnProperty( "licences" ) ) {
config.update( currentConfig => {
return {
...currentConfig,
licences: response.licences
};
} );
}
// Regardless of what just happened, make sure our settings are in sync (includes reference to license).
await settings.fetch();
}
</script>
<Page {name} on:routeEvent>
<Notifications tab={name}/>
<h2 class="page-title">{$strings.licence_title}</h2>
<div class="licence-page wrapper" class:defined={$licence.is_set && $licence.is_defined}>
{#if $licence.is_set}
<label for="licence-key" class="screen-reader-text">{$strings.licence_title}</label>
<input
id="licence-key"
type="text"
class="licence-field disabled"
name="licence"
value={$licence.masked_licence}
disabled
>
{#if $licence.is_defined}
<DefinedInWPConfig defined/>
{:else}
<Button large outline on:click={handleRemoveLicence}>{$strings.remove_licence}</Button>
{/if}
{:else}
<label for="enter-licence-key" class="screen-reader-text">{$strings.enter_licence_key}</label>
<input
id="enter-licence-key"
type="text"
class="licence-field"
name="licence"
minlength="4"
placeholder={$strings.enter_licence_key}
bind:value
use:autofocus
>
<Button large primary on:click={handleActivateLicence} disabled={value.length === 0}>
{$strings.activate_licence}
</Button>
{/if}
</div>
</Page>

View File

@@ -0,0 +1,92 @@
<script>
import {createEventDispatcher, setContext} from "svelte";
import {settingsLocked} from "../js/stores";
import {tools} from "./stores";
import Page from "../components/Page.svelte";
import Notifications from "../components/Notifications.svelte";
import ToolNotification from "./ToolNotification.svelte";
import BackNextButtonsRow from "../components/BackNextButtonsRow.svelte";
import Panel from "../components/Panel.svelte";
import PanelRow from "../components/PanelRow.svelte";
export let name = "move-objects";
// Let all child components know if settings are currently locked.
setContext( "settingsLocked", settingsLocked );
const dispatch = createEventDispatcher();
const moveObjectsTool = $tools.move_objects;
const movePublicObjectsTool = $tools.move_public_objects;
const movePrivateObjectsTool = $tools.move_private_objects;
let movePublicObjects = false;
let movePrivateObjects = true;
$: nextDisabled = $settingsLocked || (!movePublicObjects && !movePrivateObjects);
/**
* Handles a Skip button click.
*
* @return {Promise<void>}
*/
async function handleSkip() {
dispatch( "routeEvent", { event: "next", default: "/" } );
}
/**
* Handles a Next button click.
*
* @return {Promise<void>}
*/
async function handleNext() {
let tool = moveObjectsTool;
if ( !movePublicObjects || !movePrivateObjects ) {
tool = movePublicObjects ? movePublicObjectsTool : movePrivateObjectsTool;
}
await tools.start( tool );
dispatch( "routeEvent", { event: "next", default: "/" } );
}
</script>
<Page {name} subpage on:routeEvent>
<Notifications tab="media" component={ToolNotification}/>
<Panel
class="toggle-header"
heading={movePublicObjectsTool.name}
toggleName="move-public-objects"
bind:toggle={movePublicObjects}
helpURL={movePublicObjectsTool.doc_url}
helpDesc={movePublicObjectsTool.doc_desc}
multi
>
<PanelRow class="body flex-column">
<p>{@html movePublicObjectsTool.prompt}</p>
</PanelRow>
</Panel>
<Panel
class="toggle-header"
heading={movePrivateObjectsTool.name}
toggleName="move-private-objects"
bind:toggle={movePrivateObjects}
helpURL={movePrivateObjectsTool.doc_url}
helpDesc={movePrivateObjectsTool.doc_desc}
multi
>
<PanelRow class="body flex-column">
<p>{@html movePrivateObjectsTool.prompt}</p>
</PanelRow>
</Panel>
<BackNextButtonsRow
on:skip={handleSkip}
on:next={handleNext}
nextText={moveObjectsTool.button}
skipVisible={true}
{nextDisabled}
/>
</Page>

View File

@@ -0,0 +1,62 @@
<script>
import {createEventDispatcher, setContext} from "svelte";
import {settingsLocked} from "../js/stores";
import {tools} from "./stores";
import Page from "../components/Page.svelte";
import Notifications from "../components/Notifications.svelte";
import ToolNotification from "./ToolNotification.svelte";
import BackNextButtonsRow from "../components/BackNextButtonsRow.svelte";
import Panel from "../components/Panel.svelte";
import PanelRow from "../components/PanelRow.svelte";
export let name = "move-private-objects";
// Let all child components know if settings are currently locked.
setContext( "settingsLocked", settingsLocked );
const dispatch = createEventDispatcher();
const tool = $tools.move_private_objects;
/**
* Handles a Skip button click.
*
* @return {Promise<void>}
*/
async function handleSkip() {
dispatch( "routeEvent", { event: "next", default: "/" } );
}
/**
* Handles a Next button click.
*
* @return {Promise<void>}
*/
async function handleNext() {
await tools.start( tool );
dispatch( "routeEvent", { event: "next", default: "/" } );
}
</script>
<Page {name} subpage on:routeEvent>
<Notifications tab="media" component={ToolNotification}/>
<Panel
heading={tool.name}
helpURL={tool.doc_url}
helpDesc={tool.doc_desc}
multi
>
<PanelRow class="body flex-column">
<p>{@html tool.prompt}</p>
</PanelRow>
</Panel>
<BackNextButtonsRow
on:skip={handleSkip}
on:next={handleNext}
nextText={tool.button}
skipVisible={true}
nextDisabled={$settingsLocked}
/>
</Page>

View File

@@ -0,0 +1,62 @@
<script>
import {createEventDispatcher, setContext} from "svelte";
import {settingsLocked} from "../js/stores";
import {tools} from "./stores";
import Page from "../components/Page.svelte";
import Notifications from "../components/Notifications.svelte";
import ToolNotification from "./ToolNotification.svelte";
import BackNextButtonsRow from "../components/BackNextButtonsRow.svelte";
import Panel from "../components/Panel.svelte";
import PanelRow from "../components/PanelRow.svelte";
export let name = "move-public-objects";
// Let all child components know if settings are currently locked.
setContext( "settingsLocked", settingsLocked );
const dispatch = createEventDispatcher();
const tool = $tools.move_public_objects;
/**
* Handles a Skip button click.
*
* @return {Promise<void>}
*/
async function handleSkip() {
dispatch( "routeEvent", { event: "next", default: "/" } );
}
/**
* Handles a Next button click.
*
* @return {Promise<void>}
*/
async function handleNext() {
await tools.start( tool );
dispatch( "routeEvent", { event: "next", default: "/" } );
}
</script>
<Page {name} subpage on:routeEvent>
<Notifications tab="media" component={ToolNotification}/>
<Panel
heading={tool.name}
helpURL={tool.doc_url}
helpDesc={tool.doc_desc}
multi
>
<PanelRow class="body flex-column">
<p>{@html tool.prompt}</p>
</PanelRow>
</Panel>
<BackNextButtonsRow
on:skip={handleSkip}
on:next={handleNext}
nextText={tool.button}
skipVisible={true}
nextDisabled={$settingsLocked}
/>
</Page>

109
ui/pro/Nav.svelte Normal file
View File

@@ -0,0 +1,109 @@
<script>
import {link} from "svelte-spa-router";
import {bucket_writable, counts, strings, urls} from "../js/stores";
import {licence, offloadRemainingWithCount, running, tools} from "./stores";
import Nav from "../components/Nav.svelte";
import OffloadStatus from "../components/OffloadStatus.svelte";
import ToolRunningStatus from "./ToolRunningStatus.svelte";
import OffloadStatusFlyout from "../components/OffloadStatusFlyout.svelte";
import PanelRow from "../components/PanelRow.svelte";
import Button from "../components/Button.svelte";
let flyoutButton;
let expanded = false;
let hasFocus = false;
/**
* Get a message describing why the offload remaining button is disabled, if it is.
*
* @param {Object} licence
* @param {Object} counts
*
* @return {string}
*/
function getOffloadRemainingDisabledMessage( licence, counts ) {
if ( !licence.is_set ) {
return $strings.no_licence;
}
if ( counts.total < 1 ) {
return $strings.no_media;
}
if ( counts.not_offloaded < 1 ) {
return $strings.all_media_offloaded;
}
if (
licence.limit_info.counts_toward_limit &&
licence.limit_info.total > 0 &&
licence.limit_info.limit > 0 &&
licence.limit_info.total >= licence.limit_info.limit
) {
if ( licence.limit_info.total > licence.limit_info.limit ) {
return $strings.licence_limit_exceeded;
}
return $strings.licence_limit_reached;
}
if ( ! $bucket_writable ) {
return $strings.disabled_tool_bucket_access;
}
return "";
}
$: offloadRemainingDisabledMessage = getOffloadRemainingDisabledMessage( $licence, $counts );
/**
* Close the flyout panel and kick off the offloader.
*
* The panel is closed so that it does not pop back open without focus on completion.
*/
function startOffload() {
expanded = false;
tools.start( $tools.uploader );
}
</script>
<Nav>
{#if !!$running}
<ToolRunningStatus/>
{:else}
<OffloadStatus bind:flyoutButton bind:expanded bind:hasFocus>
<svelte:fragment slot="flyout">
<OffloadStatusFlyout bind:expanded bind:hasFocus bind:buttonRef={flyoutButton}>
<svelte:fragment slot="footer">
<PanelRow footer class="offload-remaining">
<Button
primary
disabled={offloadRemainingDisabledMessage}
title={offloadRemainingDisabledMessage}
on:click={startOffload}
>
{$offloadRemainingWithCount}
</Button>
</PanelRow>
<PanelRow footer class="licence">
<div class="details">
<p class="title">{$strings.plan_usage_title}</p>
<p>{$licence.plan_usage}</p>
</div>
{#if !$licence.is_set}
<a href="/license" use:link>
{$strings.activate_licence}
</a>
{:else if $licence.limit_info.limit !== 0}
<a href={$urls.licenses} target="_blank" class="upgrade">
{$strings.upgrade_plan_cta}
</a>
{/if}
</PanelRow>
</svelte:fragment>
</OffloadStatusFlyout>
</svelte:fragment>
</OffloadStatus>
{/if}
</Nav>

33
ui/pro/NoTools.svelte Normal file
View File

@@ -0,0 +1,33 @@
<script>
import {strings, urls} from "../js/stores";
import Upsell from "../components/Upsell.svelte";
let benefits = [
{
icon: $urls.assets + "img/icon/offload-remaining.svg",
alt: "offload icon",
text: $strings.tools_uppsell_benefits.offload,
},
{
icon: $urls.assets + "img/icon/download.svg",
alt: "download icon",
text: $strings.tools_uppsell_benefits.download,
},
{
icon: $urls.assets + "img/icon/remove-from-bucket.svg",
alt: "remove from bucket icon",
text: $strings.tools_uppsell_benefits.remove_bucket,
},
{
icon: $urls.assets + "img/icon/remove-from-server.svg",
alt: "remove from server icon",
text: $strings.tools_uppsell_benefits.remove_server,
},
];
</script>
<Upsell {benefits}>
<div slot="heading">{$strings.no_tools_header}</div>
<div slot="description">{@html $strings.no_tools_description}</div>
</Upsell>

View File

@@ -0,0 +1,62 @@
<script>
import {createEventDispatcher, setContext} from "svelte";
import {settingsLocked} from "../js/stores";
import {tools} from "./stores";
import Page from "../components/Page.svelte";
import Notifications from "../components/Notifications.svelte";
import ToolNotification from "./ToolNotification.svelte";
import BackNextButtonsRow from "../components/BackNextButtonsRow.svelte";
import Panel from "../components/Panel.svelte";
import PanelRow from "../components/PanelRow.svelte";
export let name = "remove-local-files";
// Let all child components know if settings are currently locked.
setContext( "settingsLocked", settingsLocked );
const dispatch = createEventDispatcher();
const tool = $tools.remove_local_files;
/**
* Handles a Skip button click.
*
* @return {Promise<void>}
*/
async function handleSkip() {
dispatch( "routeEvent", { event: "next", default: "/" } );
}
/**
* Handles a Next button click.
*
* @return {Promise<void>}
*/
async function handleNext() {
await tools.start( tool );
dispatch( "routeEvent", { event: "next", default: "/" } );
}
</script>
<Page {name} subpage on:routeEvent>
<Notifications tab="media" component={ToolNotification}/>
<Panel
heading={tool.name}
helpURL={tool.doc_url}
helpDesc={tool.doc_desc}
multi
>
<PanelRow class="body flex-column">
<p>{@html tool.prompt}</p>
</PanelRow>
</Panel>
<BackNextButtonsRow
on:skip={handleSkip}
on:next={handleNext}
nextText={tool.button}
skipVisible={true}
nextDisabled={$settingsLocked}
/>
</Page>

201
ui/pro/Settings.svelte Normal file
View File

@@ -0,0 +1,201 @@
<script>
import {onMount} from "svelte";
import {
strings,
config,
defaultStorageProvider,
settingsLocked,
notifications,
current_settings,
needs_access_keys,
needs_refresh,
counts,
settings_notifications,
settings,
settings_changed,
preStateUpdateCallbacks,
postStateUpdateCallbacks
} from "../js/stores";
import {
licence,
running,
tools,
toolsLocked,
assetsNeedsRefresh,
assetsSettingsLocked,
assetsSettings,
assetsSettingsChanged
} from "./stores";
import {pages} from "../js/routes";
import {defaultPages} from "../js/defaultPages";
import {addPages} from "./pages";
import {settingsNotifications} from "../js/settingsNotifications";
import {toolSettingsNotifications} from "./toolSettingsNotifications";
import Settings from "../components/Settings.svelte";
import Header from "./Header.svelte";
import Nav from "./Nav.svelte";
import Pages from "../components/Pages.svelte";
export let init = {};
// During initialization set config store to passed in values to avoid undefined values in components during mount.
// This saves having to do a lot of checking of values before use.
config.set( init );
pages.set( defaultPages );
// We need a disassociated copy of the initial tools info to start with.
tools.updateTools( { tools: { ...$config.tools } } );
// We need a disassociated copy of the initial assets settings to work with.
assetsSettings.set( { ...$config.assets_settings } );
// Add Pro specific pages.
addPages( $tools );
/**
* Handles state update event's changes to config.
*
* @param {Object} config
*
* @return {Promise<void>}
*/
async function handleStateUpdate( config ) {
let _settingsLocked = false;
let _toolsLocked = false;
let _assetsSettingsLocked = false;
// All settings need to be locked?
if ( config.upgrades.is_upgrading ) {
_settingsLocked = true;
_toolsLocked = true;
_assetsSettingsLocked = true;
const notification = {
id: "as3cf-all-settings-locked",
type: "warning",
dismissible: false,
heading: config.upgrades.locked_notifications[ config.upgrades.running_upgrade ],
icon: "notification-locked.svg",
plainHeading: true
};
notifications.add( notification );
if ( $settings_changed ) {
settings.reset();
}
if ( $assetsSettingsChanged ) {
assetsSettings.reset();
}
} else {
notifications.delete( "as3cf-all-settings-locked" );
}
// Media settings need to be locked?
if ( $needs_refresh ) {
_settingsLocked = true;
_toolsLocked = true;
const notification = {
id: "as3cf-media-settings-locked",
type: "warning",
dismissible: false,
only_show_on_tab: "media",
heading: $strings.needs_refresh,
icon: "notification-locked.svg",
plainHeading: true
};
notifications.add( notification );
} else if ( $running ) {
_settingsLocked = true;
const tool = $tools[ $running ];
const notification = {
id: "as3cf-media-settings-locked",
type: "warning",
dismissible: false,
only_show_on_tab: "media",
heading: tool.locked_notification,
icon: "notification-locked.svg",
plainHeading: true
};
notifications.add( notification );
if ( $settings_changed ) {
settings.reset();
}
} else {
notifications.delete( "as3cf-media-settings-locked" );
}
// Assets settings need to be locked?
if ( $assetsNeedsRefresh ) {
_assetsSettingsLocked = true;
const notification = {
id: "as3cf-assets-settings-locked",
type: "warning",
dismissible: false,
only_show_on_tab: "assets",
heading: $strings.needs_refresh,
icon: "notification-locked.svg",
plainHeading: true
};
notifications.add( notification );
} else {
notifications.delete( "as3cf-assets-settings-locked" );
}
$settingsLocked = _settingsLocked;
$toolsLocked = _toolsLocked;
$assetsSettingsLocked = _assetsSettingsLocked;
// Show a persistent error notice if bucket can't be accessed.
if ( $needs_access_keys && ($settings.provider !== $defaultStorageProvider || $settings.bucket.length !== 0) ) {
const notification = {
id: "as3cf-needs-access-keys",
type: "error",
dismissible: false,
only_show_on_tab: "media",
hide_on_parent: true,
heading: $strings.needs_access_keys,
plainHeading: true
};
notifications.add( notification );
} else {
notifications.delete( "as3cf-needs-access-keys" );
}
}
// Catch changes to running tool as soon as possible.
$: if ( $running ) {
handleStateUpdate( $config );
}
// Catch changes to needing access credentials as soon as possible.
$: if ( $needs_access_keys ) {
handleStateUpdate( $config );
}
onMount( () => {
// Make sure state dependent data is up-to-date.
handleStateUpdate( $config );
// When state info is fetched we need some extra processing of the data.
preStateUpdateCallbacks.update( _callables => {
return [..._callables, assetsSettings.updateSettings];
} );
postStateUpdateCallbacks.update( _callables => {
return [..._callables, tools.updateTools, handleStateUpdate];
} );
} );
// Make sure all inline notifications are in place.
$: settings_notifications.update( ( notices ) => settingsNotifications.process( notices, $settings, $current_settings, $strings ) );
$: settings_notifications.update( ( notices ) => toolSettingsNotifications.process( notices, $settings, $current_settings, $strings, $counts, $licence ) );
</script>
<Settings header={Header}>
<Pages nav={Nav}/>
</Settings>

139
ui/pro/SupportForm.svelte Normal file
View File

@@ -0,0 +1,139 @@
<script>
import {diagnostics, notifications, strings} from "../js/stores";
import {licence} from "./stores";
import Button from "../components/Button.svelte";
let email = "";
let subject = "";
let message = "";
let includeDiagnostics = true;
/**
* Potentially returns a reason that the Submit button is disabled.
*
* @param {string} email
* @param {string} subject
* @param {string} message
*
* @return {string}
*/
function getDisabledReason( email, subject, message ) {
let reason = "";
if ( !email || !subject || !message ) {
reason = "Email, Subject and Message required.";
}
return reason;
}
$: disabledReason = getDisabledReason( email, subject, message );
/**
* Handles a Submit button click.
*
* @param {Object} event
*
* @return {Promise<void>}
*/
async function submitSupportRequest( event ) {
const formData = new FormData();
formData.append( "email", email );
formData.append( "subject", subject );
formData.append( "message", message );
if ( includeDiagnostics ) {
formData.append( "local-diagnostic", "1" );
formData.append( "local-diagnostic-content", $diagnostics );
}
let response;
try {
response = await fetch(
$licence.support_url,
{
method: "POST",
body: formData
}
);
} catch ( error ) {
const notice = $strings.send_email_post_error + error.message;
notifications.add( {
id: "support-send-email-response",
type: "error",
dismissible: true,
only_show_on_tab: "support",
message: notice
} );
return;
}
const json = await response.json();
if ( json.hasOwnProperty( "errors" ) ) {
for ( const [key, value] of Object.entries( json.errors ) ) {
const notice = $strings.send_email_api_error + value;
notifications.add( {
id: "support-send-email-response",
type: "error",
dismissible: true,
only_show_on_tab: "support",
message: notice
} );
}
return;
}
if ( json.hasOwnProperty( "success" ) && json.success === 1 ) {
notifications.add( {
id: "support-send-email-response",
type: "success",
dismissible: true,
only_show_on_tab: "support",
message: $strings.send_email_success
} );
email = "";
subject = "";
message = "";
includeDiagnostics = true;
return;
}
notifications.add( {
id: "support-send-email-response",
type: "error",
dismissible: true,
only_show_on_tab: "support",
message: $strings.send_email_unexpected_error
} );
}
</script>
<label for="email" class="input-label">From</label>
<select name="email" id="email" bind:value={email}>
{#each $licence.support_email_addresses as supportEmail}
<option value={supportEmail}>{supportEmail}</option>
{/each}
<option value="">{$strings.select_email}</option>
</select>
<p class="note">{@html $strings.email_note}</p>
<input type="text" id="subject" name="subject" bind:value={subject} minlength="4" placeholder={$strings.email_subject_placeholder}>
<textarea id="message" name="message" bind:value={message} rows="8" placeholder={$strings.email_message_placeholder}></textarea>
<div class="actions">
<div class="checkbox">
<label for="include-diagnostics">
<input type="checkbox" id="include-diagnostics" name="include-diagnostics" bind:checked={includeDiagnostics}>{$strings.attach_diagnostics}
</label>
</div>
<Button primary on:click={submitSupportRequest} disabled={disabledReason} title={disabledReason}>{$strings.send_email}</Button>
</div>
<p class="note first">{$strings.having_trouble}</p>
<p class="note">{@html $strings.email_instead}</p>

76
ui/pro/SupportPage.svelte Normal file
View File

@@ -0,0 +1,76 @@
<script>
import {link} from "svelte-spa-router";
import {strings} from "../js/stores";
import {licence} from "./stores";
import SupportPage from "../components/SupportPage.svelte";
import DocumentationSidebar from "./DocumentationSidebar.svelte";
import SupportForm from "./SupportForm.svelte";
import Notification from "../components/Notification.svelte";
export let name = "support";
/**
* Potentially returns an error message detailing a problem with the currently set license key.
*
* @param {Object} licence
*
* @return {string}
*/
function getLicenceError( licence ) {
// If there are any errors, just return the first (there's usually only 1 anyway).
if ( licence.hasOwnProperty( "errors" ) && Object.values( licence.errors ).length > 0 ) {
return Object.values( licence.errors )[ 0 ];
}
return "";
}
$: licenceError = getLicenceError( $licence );
</script>
{#if $licence.is_set}
{#if $licence.is_valid && licenceError.length === 0}
<SupportPage {name} title={$strings.email_support_title} on:routeEvent>
<p class="licence-type" slot="header">{@html $licence.your_active_licence}</p>
<svelte:fragment slot="content">
<SupportForm/>
</svelte:fragment>
<svelte:fragment slot="footer">
<DocumentationSidebar/>
</svelte:fragment>
</SupportPage>
{:else}
<SupportPage {name} title={$strings.email_support_title} on:routeEvent>
<svelte:fragment slot="content">
<Notification warning inline>
<p>
{@html licenceError}
</p>
</Notification>
</svelte:fragment>
<svelte:fragment slot="footer">
<DocumentationSidebar/>
</svelte:fragment>
</SupportPage>
{/if}
{:else}
<SupportPage {name} title={$strings.email_support_title} on:routeEvent>
<svelte:fragment slot="content">
<Notification warning inline>
<p>
{$strings.licence_not_entered}
<a href="/license" use:link>
{$strings.please_enter_licence}
</a>
</p>
<p>{$strings.once_licence_entered}</p>
</Notification>
</svelte:fragment>
<svelte:fragment slot="footer">
<DocumentationSidebar/>
</svelte:fragment>
</SupportPage>
{/if}

View File

@@ -0,0 +1,76 @@
<script>
import {fade, slide} from "svelte/transition";
import {api, strings} from "../js/stores";
import Notification from "../components/Notification.svelte";
export let notification;
let expanded = true;
/**
* Handles Dismiss All Errors for item click.
*
* @param {string} tool_key
* @param {Object} item
*
* @return {Promise<void>}
*/
async function dismissAll( tool_key, item ) {
await api.delete( "tools", {
id: tool_key,
blog_id: item.blog_id,
source_type: item.source_type,
source_id: item.source_id
} );
}
/**
* Handles Dismiss Individual Error for item click.
*
* @param {string} tool_key
* @param {Object} item
* @param {number} index
*
* @return {Promise<void>}
*/
async function dismissError( tool_key, item, index ) {
await api.delete( "tools", {
id: tool_key,
blog_id: item.blog_id,
source_type: item.source_type,
source_id: item.source_id,
errors: index
} );
}
</script>
{#if notification.hasOwnProperty( "class" ) && notification.class === "tool-error" && notification.hasOwnProperty( "errors" )}
<Notification notification={notification} expandable bind:expanded>
<svelte:fragment slot="details">
{#if expanded}
<div class="details" transition:slide>
{#each notification.errors.details as item, index}
<div class="item" transition:fade>
<div class="summary">
<div class="title">
{(index + 1) + ". " + item.source_type_name}
<a href={item.edit_url.url}>#{item.source_id}</a>
</div>
<button class="dismiss" on:click|preventDefault={() => dismissAll(notification.errors.tool_key, item)}>{$strings.dismiss}</button>
</div>
<ul class="detail">
{#each item.messages as message, index}
<li>{@html message}</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
</svelte:fragment>
</Notification>
{:else}
<Notification notification={notification}>
<slot/>
</Notification>
{/if}

178
ui/pro/ToolPanel.svelte Normal file
View File

@@ -0,0 +1,178 @@
<script>
import {bucket_writable, strings, urls} from "../js/stores";
import {running, tools, toolsLocked} from "./stores";
import Panel from "../components/Panel.svelte";
import PanelRow from "../components/PanelRow.svelte";
import Button from "../components/Button.svelte";
import ProgressBar from "../components/ProgressBar.svelte";
import ToolRunningButtons from "./ToolRunningButtons.svelte";
import {numToString} from "../js/numToString";
export let tool = {};
// Total processed related variables.
$: showTotal = !!tool.hasOwnProperty( "total_progress" );
$: initial = !!(showTotal && tool.total_progress < 1);
$: partialComplete = !!(showTotal && tool.total_progress > 0 && tool.total_progress < 100);
$: complete = !!(showTotal && !initial && !partialComplete);
// In progress related variables.
$: isRunning = !!($running && $running === tool.id);
$: starting = !!(isRunning && tool.progress < 1 && !tool.is_paused);
// Buttons should be disabled if another tool is running, current tool is in process of pausing or cancelling, or all tools locked.
$: disabled = ($running && $running !== tool.id) || (tool.is_processing && tool.is_paused) || tool.is_cancelled || $toolsLocked;
$: disabled_bucket_access = tool.requires_bucket_access && !$bucket_writable;
/**
* Returns the numeric percentage progress for the running job.
*
* @param {Object} tool
* @param {boolean} isRunning
* @param {boolean} showTotal
*
* @return {number}
*/
function getPercentComplete( tool, isRunning, showTotal ) {
if ( isRunning ) {
return tool.progress;
} else if ( showTotal ) {
return tool.total_progress;
}
return 0;
}
$: percentComplete = getPercentComplete( tool, isRunning, showTotal );
/**
* Returns state dependent icon for tool.
*
* @param {Object} tool
* @param {boolean} isRunning
* @return {string}
*/
function getIcon( tool, isRunning ) {
const icon = tools.icon( tool, isRunning, false );
return $urls.assets + "img/icon/" + icon;
}
$: icon = getIcon( tool, isRunning );
/**
* Potentially returns a map of tools that are related to the current tool.
*
* Map is keyed by tool's id (key), values are tool objects.
*
* @param {Object} tool
*
* @return {Map<string, object>}
*/
function getRelatedTools( tool ) {
let related = new Map();
if ( tool.hasOwnProperty( "related_tools" ) && tool.related_tools.length > 0 ) {
tool.related_tools.forEach( ( key ) => {
if ( $tools.hasOwnProperty( key ) ) {
related.set( key, $tools[ key ] );
}
} )
}
return related;
}
$: relatedTools = getRelatedTools( tool );
/**
* Starts a tool's job.
*
* @param {Object} tool
*/
function handleStartTool( tool ) {
tools.start( tool );
}
/**
* Handles a Start button click.
*/
function handleStart() {
handleStartTool( tool );
}
</script>
<Panel multi class="tools-panel">
<PanelRow header>
<img src={icon} type="image/svg+xml" alt={tool.title}>
{#if showTotal}
{#if initial}
<h3>{@html tool.title}</h3>
{:else if partialComplete}
<h3>{@html tool.title_partial_complete}</h3>
{:else}
<h3>{@html tool.title_complete}</h3>
{/if}
{:else}
<h3>{@html tool.title}</h3>
{/if}
<div class="buttons-right">
{#if isRunning}
<ToolRunningButtons {tool} {disabled}/>
{:else}
{#if complete}
<!-- 🎉 -->
{:else if disabled_bucket_access}
<Button primary disabled={true} title={$strings.disabled_tool_bucket_access}>{@html partialComplete ? tool.button_partial_complete : tool.button}</Button>
{:else if initial}
<Button primary {disabled} title={disabled ? $strings.disabled_tool_button : ""} on:click={handleStart}>{@html tool.button}</Button>
{:else if partialComplete}
<Button primary {disabled} title={disabled ? $strings.disabled_tool_button : ""} on:click={handleStart}>{@html tool.button_partial_complete}</Button>
{:else}
<Button primary {disabled} title={disabled ? $strings.disabled_tool_button : ""} on:click={handleStart}>{@html tool.button}</Button>
{/if}
{/if}
</div>
</PanelRow>
{#if complete || partialComplete || isRunning}
<PanelRow class="body flex-row show-progress">
<div class="status">
{#if isRunning}
<h4>
<strong>{getPercentComplete( tool, isRunning, showTotal )}%</strong> ({numToString( tool.queue.processed )}/{numToString( tool.queue.total )})
{@html tool.status_description ? " " + tool.status_description : " " + tool.busy_description}
</h4>
{:else }
<h4>{@html tool.progress_description}</h4>
{/if}
<slot name="status-right" {isRunning}/>
</div>
<ProgressBar
{percentComplete}
{starting}
running={isRunning}
paused={tool.is_paused}
title={! isRunning && showTotal ? "(" + numToString(tool.total_processed) + "/" + numToString(tool.total_items) + ")" : ""}
/>
</PanelRow>
{/if}
{#if !complete && !partialComplete && !isRunning}
<PanelRow class="body flex-row">
<p class="desc">{@html tool.more_info}</p>
</PanelRow>
{#if !disabled && relatedTools.size > 0 }
<PanelRow class="body flex-column" footer>
{#each [...relatedTools] as [key, relatedTool] }
<p>
<a
href={$urls.settings}
on:click|preventDefault={() => handleStartTool(relatedTool)}
title={relatedTool.more_info}
>
{relatedTool.title}
</a>
</p>
{/each}
</PanelRow>
{/if}
{/if}
</Panel>

View File

@@ -0,0 +1,34 @@
<script>
import {tools} from "./stores";
import {strings} from "../js/stores";
import Button from "../components/Button.svelte";
export let tool = {};
export let disabled = false;
export let small = false;
/**
* Handles a Pause or Resume button click.
*/
function handlePauseResume() {
tools.pauseResume( tool );
}
/**
* Handles a Cancel button click.
*/
function handleCancel() {
tools.cancel( tool );
}
</script>
{#if tool}
<Button outline {small} {disabled} class="pause" on:click={handlePauseResume}>
{#if tool.is_paused}
{$strings.resume_button}
{:else}
{$strings.pause_button}
{/if}
</Button>
<Button outline {small} {disabled} on:click={handleCancel}>{$strings.cancel_button}</Button>
{/if}

View File

@@ -0,0 +1,94 @@
<script>
import {push} from "svelte-spa-router";
import {urls} from "../js/stores";
import {running, tools, toolsLocked} from "./stores";
import {numToShortString, numToString} from "../js/numToString";
import ProgressBar from "../components/ProgressBar.svelte";
import ToolRunningButtons from "./ToolRunningButtons.svelte";
/**
* Returns the currently running tool's details.
*
* @param {Object} tools
* @param {string} running
*
* @return {unknown}
*/
function runningTool( tools, running ) {
return Object.values( tools ).find( ( tool ) => tool.id === running );
}
/**
* Get status description for tool.
*
* @param {Object} tool
* @param {boolean} isRunning
*
* @return {string}
*/
function toolStatus( tool, isRunning ) {
if ( !isRunning ) {
return "";
}
if ( tool.short_status_description ) {
return tool.short_status_description;
}
return tool.busy_description;
}
$: isRunning = !!$running;
$: tool = runningTool( $tools, $running );
$: icon = tools.icon( tool, isRunning, true );
// Buttons should be disabled if another tool is running, current tool is in process of pausing or cancelling, or all tools locked.
$: disabled = isRunning && (($running && $running !== tool.id) || (tool.is_processing && tool.is_paused) || tool.is_cancelled || $toolsLocked);
$: starting = !!(isRunning && tool.progress < 1 && !tool.is_paused);
$: status = isRunning ? "(" + numToShortString( tool.queue.processed ) + "/" + numToShortString( tool.queue.total ) + ") " + toolStatus( tool, isRunning ) : "";
$: title = isRunning ? tool.name + ": " + tool.progress + "% (" + numToString( tool.queue.processed ) + "/" + numToString( tool.queue.total ) + ")" : "";
/**
* Returns the numeric percentage progress for the running job.
*
* @param {Object} tool
* @param {boolean} isRunning
*
* @return {number}
*/
function getPercentComplete( tool, isRunning ) {
if ( isRunning ) {
return tool.progress;
}
return 0;
}
$: percentComplete = getPercentComplete( tool, isRunning );
</script>
{#if tool}
<div class="nav-status-wrapper tool-running">
<!-- TODO: Fix a11y. -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="nav-status" {title} on:click={() => push("/tools")}>
<p class="status-text" {title}>
<strong>{tool.progress}%</strong>
<span> {@html status}</span>
</p>
<ProgressBar
{percentComplete}
{starting}
running={isRunning}
paused={tool.is_paused}
{title}
/>
<div class="animation-running" {title}>
<img src="{$urls.assets + 'img/icon/' + icon}" alt="{tool.status_description}"/>
</div>
</div>
<ToolRunningButtons {tool} {disabled} small/>
</div>
{/if}

29
ui/pro/ToolsPage.svelte Normal file
View File

@@ -0,0 +1,29 @@
<script>
import {setContext} from "svelte";
import {strings} from "../js/stores";
import {tools, toolsLocked} from "./stores";
import Page from "../components/Page.svelte";
import Notifications from "../components/Notifications.svelte";
import ToolNotification from "./ToolNotification.svelte";
import ToolPanel from "./ToolPanel.svelte";
import NoTools from "./NoTools.svelte";
export let name = "tools";
// Let all child components know if tools are currently locked.
// All panels etc respond to settingsLocked, so we fake it here as we're not in a settings context.
setContext( "settingsLocked", toolsLocked );
</script>
<Page {name} on:routeEvent>
<Notifications tab={name} component={ToolNotification}/>
<h2 class="page-title">{$strings.tools_title}</h2>
<div class="tools-page wrapper">
{#each Object.values( $tools ).filter( ( tool ) => tool.render ) as tool (tool.id)}
<ToolPanel {tool}/>
{:else}
<NoTools/>
{/each}
</div>
</Page>

View File

@@ -0,0 +1,62 @@
<script>
import {createEventDispatcher, getContext, hasContext} from "svelte";
import {writable} from "svelte/store";
import {pop} from "svelte-spa-router";
import {strings} from "../js/stores";
import {tools} from "./stores";
import SubPage from "../components/SubPage.svelte";
import Panel from "../components/Panel.svelte";
import PanelRow from "../components/PanelRow.svelte";
import BackNextButtonsRow from "../components/BackNextButtonsRow.svelte";
const tool = $tools.update_acls;
const dispatch = createEventDispatcher();
// Parent page may want to be locked.
let settingsLocked = writable( false );
if ( hasContext( "settingsLocked" ) ) {
settingsLocked = getContext( "settingsLocked" );
}
/**
* Handles a Skip button click.
*
* @return {Promise<void>}
*/
async function handleSkip() {
dispatch( "routeEvent", { event: "next", default: "/" } );
}
/**
* Handles a Next button click.
*
* @return {Promise<void>}
*/
async function handleNext() {
await tools.start( tool );
dispatch( "routeEvent", { event: "next", default: "/" } );
}
</script>
<SubPage name="update-acls" route="/storage/update-acls">
<Panel
heading={tool.title}
helpURL={tool.doc_url}
helpDesc={tool.doc_desc}
multi
>
<PanelRow class="body flex-column">
<p>{@html tool.prompt}</p>
</PanelRow>
</Panel>
<BackNextButtonsRow
on:skip={handleSkip}
on:next={handleNext}
skipText={$strings.no}
nextText={$strings.yes}
skipVisible={true}
nextDisabled={$settingsLocked}
/>
</SubPage>

View File

@@ -0,0 +1,62 @@
<script>
import {createEventDispatcher, setContext} from "svelte";
import {settingsLocked} from "../js/stores";
import {tools} from "./stores";
import Page from "../components/Page.svelte";
import Notifications from "../components/Notifications.svelte";
import ToolNotification from "./ToolNotification.svelte";
import BackNextButtonsRow from "../components/BackNextButtonsRow.svelte";
import Panel from "../components/Panel.svelte";
import PanelRow from "../components/PanelRow.svelte";
export let name = "uploader";
// Let all child components know if settings are currently locked.
setContext( "settingsLocked", settingsLocked );
const dispatch = createEventDispatcher();
const tool = $tools.uploader;
/**
* Handles a Skip button click.
*
* @return {Promise<void>}
*/
async function handleSkip() {
dispatch( "routeEvent", { event: "next", default: "/" } );
}
/**
* Handles a Next button click.
*
* @return {Promise<void>}
*/
async function handleNext() {
await tools.start( tool );
dispatch( "routeEvent", { event: "next", default: "/" } );
}
</script>
<Page {name} subpage on:routeEvent>
<Notifications tab="media" component={ToolNotification}/>
<Panel
heading={tool.name}
helpURL={tool.doc_url}
helpDesc={tool.doc_desc}
multi
>
<PanelRow class="body flex-column">
<p>{@html tool.prompt}</p>
</PanelRow>
</Panel>
<BackNextButtonsRow
on:skip={handleSkip}
on:next={handleNext}
nextText={tool.button}
skipVisible={true}
nextDisabled={$settingsLocked}
/>
</Page>

615
ui/pro/pages.js Normal file
View File

@@ -0,0 +1,615 @@
import {get} from "svelte/store";
import {location} from "svelte-spa-router";
import {
bapa,
ooe,
counts,
current_settings,
storage_provider,
strings
} from "../js/stores";
import {pages} from "../js/routes";
import {licence} from "./stores";
import AssetsPage from "./AssetsPage.svelte";
import ToolsPage from "./ToolsPage.svelte";
import LicencePage from "./LicencePage.svelte";
import SupportPage from "./SupportPage.svelte";
import UpdateObjectACLsPromptSubPage
from "./UpdateObjectACLsPromptSubPage.svelte";
import CopyBucketsPromptSubPage from "./CopyBucketsPromptSubPage.svelte";
import MoveObjectsPromptPage from "./MoveObjectsPromptPage.svelte";
import MovePublicObjectsPromptPage from "./MovePublicObjectsPromptPage.svelte";
import MovePrivateObjectsPromptPage
from "./MovePrivateObjectsPromptPage.svelte";
import RemoveLocalFilesPromptPage from "./RemoveLocalFilesPromptPage.svelte";
import UploaderPromptPage from "./UploaderPromptPage.svelte";
import DownloaderPromptPage from "./DownloaderPromptPage.svelte";
export function addPages( enabledTools ) {
pages.add(
{
position: 10,
name: "assets",
title: () => get( strings ).assets_tab_title,
nav: true,
route: "/assets",
component: AssetsPage
}
);
pages.add(
{
position: 20,
name: "tools",
title: () => get( strings ).tools_tab_title,
nav: true,
route: "/tools",
component: ToolsPage
}
);
pages.add(
{
position: 90,
name: "licence",
title: () => get( strings ).licence_tab_title,
nav: true,
route: "/license",
component: LicencePage
}
);
pages.add(
{
position: 100,
name: "support",
title: () => get( strings ).support_tab_title,
nav: true,
route: "/support",
component: SupportPage
}
);
// Update ACLs tool prompt.
if ( enabledTools.hasOwnProperty( "update_acls" ) ) {
const updateACLs = {
position: 240,
name: "update-acls",
title: () => enabledTools.update_acls.name,
subNav: true,
route: "/storage/update-acls",
component: UpdateObjectACLsPromptSubPage,
enabled: () => {
// Nothing to update?
if (
!get( counts ).hasOwnProperty( "offloaded" ) ||
get( counts ).offloaded < 1 ||
!get( current_settings ).hasOwnProperty( "bucket" ) ) {
return false;
}
// Current Storage Provider never allows ACLs to be disabled.
if ( get( storage_provider ).requires_acls ) {
return false;
}
// If either Block All Public Access or Object Ownership turned on,
// we should not update ACLs.
if ( get( bapa ) || get( ooe ) ) {
return false;
}
// Update ACLs if BAPA just turned off.
if (
get( storage_provider ).block_public_access_supported &&
get( current_settings ).hasOwnProperty( "block-public-access" ) &&
updateACLs.blockPublicAccess !== get( current_settings )[ "block-public-access" ]
) {
return true;
}
// Update ACLs if OOE just turned off.
if (
get( storage_provider ).object_ownership_supported &&
get( current_settings ).hasOwnProperty( "object-ownership-enforced" ) &&
updateACLs.objectOwnershipEnforced !== get( current_settings )[ "object-ownership-enforced" ]
) {
return true;
}
return false;
},
isNextRoute: ( data ) => {
if (
!get( licence ).hasOwnProperty( "is_valid" ) ||
!get( licence ).is_valid ||
!updateACLs.enabled()
) {
return false;
}
// If currently in /storage/security route, then update-acls is next.
return get( location ) === "/storage/security";
},
blockPublicAccess: get( current_settings ).hasOwnProperty( "bucket" ) && get( current_settings ).hasOwnProperty( "block-public-access" ) ? get( current_settings )[ "block-public-access" ] : false,
objectOwnershipEnforced: get( current_settings ).hasOwnProperty( "bucket" ) && get( current_settings ).hasOwnProperty( "object-ownership-enforced" ) ? get( current_settings )[ "object-ownership-enforced" ] : false,
setInitialProperties: ( data ) => {
if ( data.hasOwnProperty( "settings" ) && data.settings.hasOwnProperty( "bucket" ) ) {
if ( data.settings.hasOwnProperty( "block-public-access" ) ) {
updateACLs.blockPublicAccess = data.settings[ "block-public-access" ];
} else {
updateACLs.blockPublicAccess = false;
}
if ( data.settings.hasOwnProperty( "object-ownership-enforced" ) ) {
updateACLs.objectOwnershipEnforced = data.settings[ "object-ownership-enforced" ];
} else {
updateACLs.objectOwnershipEnforced = false;
}
}
return false;
},
events: {
"next": ( data ) => updateACLs.isNextRoute( data ),
"bucket-security": ( data ) => updateACLs.isNextRoute( data ),
"settings.save": ( data ) => updateACLs.setInitialProperties( data ),
"page.initial.settings": ( data ) => updateACLs.setInitialProperties( data )
}
};
pages.add( updateACLs );
}
// Copy Files tool prompt.
if ( enabledTools.hasOwnProperty( "copy_buckets" ) ) {
const copyBuckets = {
position: 250,
name: "copy-buckets",
title: () => enabledTools.copy_buckets.name,
subNav: true,
route: "/storage/copy-buckets",
component: CopyBucketsPromptSubPage,
enabled: () => {
return get( counts ).offloaded > 0 && get( current_settings ).hasOwnProperty( "bucket" ) && copyBuckets.bucket !== get( current_settings ).bucket;
},
isNextRoute: ( data ) => {
if (
!get( licence ).hasOwnProperty( "is_valid" ) ||
!get( licence ).is_valid ||
!copyBuckets.enabled()
) {
return false;
}
// If currently in any of the below routes, then copy-buckets is next if not gazumped.
return get( location ) === "/storage/bucket" || get( location ) === "/storage/security" || get( location ) === "/storage/update-acls";
},
bucket: get( current_settings ).hasOwnProperty( "bucket" ) ? get( current_settings ).bucket : "",
setInitialBucket: ( data ) => {
if ( data.hasOwnProperty( "settings" ) && data.settings.hasOwnProperty( "bucket" ) ) {
copyBuckets.bucket = data.settings.bucket;
}
return false;
},
events: {
"next": ( data ) => copyBuckets.isNextRoute( data ),
"settings.save": ( data ) => copyBuckets.isNextRoute( data ),
"bucket-security": ( data ) => copyBuckets.isNextRoute( data ),
"page.initial.settings": ( data ) => copyBuckets.setInitialBucket( data )
}
};
pages.add( copyBuckets );
}
// Move Public/Private Objects tool prompt.
if (
enabledTools.hasOwnProperty( "move_objects" ) &&
enabledTools.hasOwnProperty( "move_public_objects" ) &&
enabledTools.hasOwnProperty( "move_private_objects" )
) {
const moveObjects = {
position: 400,
name: "move-objects",
title: () => enabledTools.move_objects.name,
route: "/prompt/move-objects",
component: MoveObjectsPromptPage,
publicPathChanged: ( data ) => {
if ( data.hasOwnProperty( "changed_settings" ) ) {
// Year/Month disabled - never show prompt.
if (
data.changed_settings.includes( "use-yearmonth-folders" ) &&
get( current_settings ).hasOwnProperty( "use-yearmonth-folders" ) &&
!get( current_settings )[ "use-yearmonth-folders" ]
) {
return false;
}
// Object Versioning disabled - never show prompt.
if (
data.changed_settings.includes( "object-versioning" ) &&
get( current_settings ).hasOwnProperty( "object-versioning" ) &&
!get( current_settings )[ "object-versioning" ]
) {
return false;
}
// Path enabled/disabled.
if (
data.changed_settings.includes( "enable-object-prefix" ) &&
get( current_settings ).hasOwnProperty( "enable-object-prefix" )
) {
return true;
}
// Path changed while enabled.
if (
data.changed_settings.includes( "object-prefix" ) &&
get( current_settings ).hasOwnProperty( "enable-object-prefix" ) &&
get( current_settings )[ "enable-object-prefix" ]
) {
return true;
}
// Year/Month enabled.
if (
data.changed_settings.includes( "use-yearmonth-folders" ) &&
get( current_settings ).hasOwnProperty( "use-yearmonth-folders" ) &&
get( current_settings )[ "use-yearmonth-folders" ]
) {
return true;
}
// Object Versioning enabled.
if (
data.changed_settings.includes( "object-versioning" ) &&
get( current_settings ).hasOwnProperty( "object-versioning" ) &&
get( current_settings )[ "object-versioning" ]
) {
return true;
}
}
return false;
},
privatePathChanged: ( data ) => {
if ( data.hasOwnProperty( "changed_settings" ) ) {
// Signed URLs enabled/disabled.
if (
data.changed_settings.includes( "enable-signed-urls" ) &&
get( current_settings ).hasOwnProperty( "enable-signed-urls" )
) {
return true;
}
// Signed URLs prefix changed while enabled.
if (
data.changed_settings.includes( "signed-urls-object-prefix" ) &&
get( current_settings ).hasOwnProperty( "enable-signed-urls" ) &&
get( current_settings )[ "enable-signed-urls" ]
) {
return true;
}
}
return false;
},
isNextRoute: ( data ) => {
// Anything to work with?
if (
!get( licence ).hasOwnProperty( "is_valid" ) ||
!get( licence ).is_valid ||
!get( current_settings ).hasOwnProperty( "bucket" ) ||
!get( counts ).hasOwnProperty( "offloaded" ) ||
get( counts ).offloaded < 1
) {
return false;
}
return moveObjects.publicPathChanged( data ) && moveObjects.privatePathChanged( data );
},
events: {
"settings.save": ( data ) => moveObjects.isNextRoute( data )
}
};
pages.add( moveObjects );
const movePublicObjects = {
position: 410,
name: "move-public-objects",
title: () => enabledTools.move_public_objects.name,
route: "/prompt/move-public-objects",
component: MovePublicObjectsPromptPage,
isNextRoute: ( data ) => {
// Anything to work with?
if (
!get( licence ).hasOwnProperty( "is_valid" ) ||
!get( licence ).is_valid ||
!get( current_settings ).hasOwnProperty( "bucket" ) ||
!get( counts ).hasOwnProperty( "offloaded" ) ||
get( counts ).offloaded < 1
) {
return false;
}
return moveObjects.publicPathChanged( data );
},
events: {
"settings.save": ( data ) => movePublicObjects.isNextRoute( data )
}
};
pages.add( movePublicObjects );
const movePrivateObjects = {
position: 420,
name: "move-private-objects",
title: () => enabledTools.move_private_objects.name,
route: "/prompt/move-private-objects",
component: MovePrivateObjectsPromptPage,
isNextRoute: ( data ) => {
// Anything to work with?
if (
!get( licence ).hasOwnProperty( "is_valid" ) ||
!get( licence ).is_valid ||
!get( current_settings ).hasOwnProperty( "bucket" ) ||
!get( counts ).hasOwnProperty( "offloaded" ) ||
get( counts ).offloaded < 1
) {
return false;
}
return moveObjects.privatePathChanged( data );
},
events: {
"settings.save": ( data ) => movePrivateObjects.isNextRoute( data )
}
};
pages.add( movePrivateObjects );
}
// Remove Local Files tool prompt.
if ( enabledTools.hasOwnProperty( "remove_local_files" ) ) {
const removeLocalFiles = {
position: 430,
name: "remove-local-files",
title: () => enabledTools.remove_local_files.name,
route: "/prompt/remove-local-files",
component: RemoveLocalFilesPromptPage,
onPreviousPage: () => {
const previousPages = pages.withPrefix( "/prompt/" ).filter( ( page ) => page.position < removeLocalFiles.position );
for ( const previousPage of previousPages ) {
if ( get( location ) === previousPage.route ) {
return true;
}
}
return false;
},
removeLocalFile: get( current_settings ).hasOwnProperty( "remove-local-file" ) ? get( current_settings )[ "remove-local-file" ] : false,
setInitialRemoveLocalFile: ( data ) => {
if (
get( location ) !== removeLocalFiles.route &&
!removeLocalFiles.onPreviousPage() &&
data.hasOwnProperty( "settings" ) &&
data.settings.hasOwnProperty( "remove-local-file" )
) {
removeLocalFiles.removeLocalFile = data.settings[ "remove-local-file" ];
}
return false;
},
isNextRoute: ( data ) => {
// Anything to work with?
if (
!get( licence ).hasOwnProperty( "is_valid" ) ||
!get( licence ).is_valid ||
!get( current_settings ).hasOwnProperty( "bucket" ) ||
!get( counts ).hasOwnProperty( "offloaded" ) ||
get( counts ).offloaded < 1
) {
return false;
}
if ( data.hasOwnProperty( "changed_settings" ) ) {
// Remove Local Files turned on.
if (
data.changed_settings.includes( "remove-local-file" ) &&
get( current_settings ).hasOwnProperty( "remove-local-file" ) &&
removeLocalFiles.removeLocalFile !== get( current_settings )[ "remove-local-file" ] &&
get( current_settings )[ "remove-local-file" ]
) {
return true;
}
}
// Setting changed and event from previous prompt page.
if (
removeLocalFiles.onPreviousPage() &&
get( current_settings ).hasOwnProperty( "remove-local-file" ) &&
removeLocalFiles.removeLocalFile !== get( current_settings )[ "remove-local-file" ] &&
get( current_settings )[ "remove-local-file" ]
) {
return true;
}
// We're not interested in showing prompt, just ensure local state is up to date.
// NOTE: This handles syncing the local state when moving on from this prompt too.
if ( get( current_settings ).hasOwnProperty( "remove-local-file" ) ) {
removeLocalFiles.removeLocalFile = get( current_settings )[ "remove-local-file" ];
}
return false;
},
events: {
"next": ( data ) => removeLocalFiles.isNextRoute( data ),
"settings.save": ( data ) => removeLocalFiles.isNextRoute( data ),
"page.initial.settings": ( data ) => removeLocalFiles.setInitialRemoveLocalFile( data )
}
};
pages.add( removeLocalFiles );
}
// Uploader tool prompt.
if ( enabledTools.hasOwnProperty( "uploader" ) ) {
const uploader = {
position: 440,
name: "uploader",
title: () => enabledTools.uploader.name,
route: "/prompt/uploader",
component: UploaderPromptPage,
onPreviousPage: () => {
const previousPages = pages.withPrefix( "/prompt/" ).filter( ( page ) => page.position < uploader.position );
for ( const previousPage of previousPages ) {
if ( get( location ) === previousPage.route ) {
return true;
}
}
return false;
},
copyToProvider: get( current_settings ).hasOwnProperty( "copy-to-s3" ) ? get( current_settings )[ "copy-to-s3" ] : false,
setInitialCopyToProvider: ( data ) => {
if (
get( location ) !== uploader.route &&
!uploader.onPreviousPage() &&
data.hasOwnProperty( "settings" ) &&
data.settings.hasOwnProperty( "copy-to-s3" )
) {
uploader.copyToProvider = data.settings[ "copy-to-s3" ];
}
return false;
},
isNextRoute: ( data ) => {
// Anything to work with?
if (
!get( licence ).hasOwnProperty( "is_valid" ) ||
!get( licence ).is_valid ||
!get( current_settings ).hasOwnProperty( "bucket" ) ||
!get( counts ).hasOwnProperty( "not_offloaded" ) ||
get( counts ).not_offloaded < 1
) {
return false;
}
if ( data.hasOwnProperty( "changed_settings" ) ) {
// Copy to Provider turned on.
if (
data.changed_settings.includes( "copy-to-s3" ) &&
get( current_settings ).hasOwnProperty( "copy-to-s3" ) &&
uploader.copyToProvider !== get( current_settings )[ "copy-to-s3" ] &&
get( current_settings )[ "copy-to-s3" ]
) {
return true;
}
}
// Setting changed and event from previous prompt page.
if (
uploader.onPreviousPage() &&
get( current_settings ).hasOwnProperty( "copy-to-s3" ) &&
uploader.copyToProvider !== get( current_settings )[ "copy-to-s3" ] &&
get( current_settings )[ "copy-to-s3" ]
) {
return true;
}
// We're not interested in showing prompt, just ensure local state is up to date.
// NOTE: This handles syncing the local state when moving on from this prompt too.
if ( get( current_settings ).hasOwnProperty( "copy-to-s3" ) ) {
uploader.copyToProvider = get( current_settings )[ "copy-to-s3" ];
}
return false;
},
events: {
"next": ( data ) => uploader.isNextRoute( data ),
"settings.save": ( data ) => uploader.isNextRoute( data ),
"page.initial.settings": ( data ) => uploader.setInitialCopyToProvider( data )
}
};
pages.add( uploader );
}
// Downloader tool prompt when Remove Local Files turned off.
if ( enabledTools.hasOwnProperty( "downloader" ) ) {
const downloader = {
position: 450,
name: "downloader",
title: () => enabledTools.downloader.name,
route: "/prompt/downloader",
component: DownloaderPromptPage,
onPreviousPage: () => {
const previousPages = pages.withPrefix( "/prompt/" ).filter( ( page ) => page.position < downloader.position );
for ( const previousPage of previousPages ) {
if ( get( location ) === previousPage.route ) {
return true;
}
}
return false;
},
removeLocalFile: get( current_settings ).hasOwnProperty( "remove-local-file" ) ? get( current_settings )[ "remove-local-file" ] : false,
setInitialRemoveLocalFile: ( data ) => {
if (
get( location ) !== downloader.route &&
!downloader.onPreviousPage() &&
data.hasOwnProperty( "settings" ) &&
data.settings.hasOwnProperty( "remove-local-file" )
) {
downloader.removeLocalFile = data.settings[ "remove-local-file" ];
}
return false;
},
isNextRoute: ( data ) => {
// Anything to work with?
if (
!get( licence ).hasOwnProperty( "is_valid" ) ||
!get( licence ).is_valid ||
!get( current_settings ).hasOwnProperty( "bucket" ) ||
!get( counts ).hasOwnProperty( "offloaded" ) ||
get( counts ).offloaded < 1
) {
return false;
}
if ( data.hasOwnProperty( "changed_settings" ) ) {
// Remove Local Files turned off.
if (
data.changed_settings.includes( "remove-local-file" ) &&
get( current_settings ).hasOwnProperty( "remove-local-file" ) &&
downloader.removeLocalFile !== get( current_settings )[ "remove-local-file" ] &&
!get( current_settings )[ "remove-local-file" ]
) {
return true;
}
}
// Setting changed and event from previous prompt page.
if (
downloader.onPreviousPage() &&
get( current_settings ).hasOwnProperty( "remove-local-file" ) &&
downloader.removeLocalFile !== get( current_settings )[ "remove-local-file" ] &&
!get( current_settings )[ "remove-local-file" ]
) {
return true;
}
// We're not interested in showing prompt, just ensure local state is up to date.
// NOTE: This handles syncing the local state when moving on from this prompt too.
if ( get( current_settings ).hasOwnProperty( "remove-local-file" ) ) {
downloader.removeLocalFile = get( current_settings )[ "remove-local-file" ];
}
return false;
},
events: {
"next": ( data ) => downloader.isNextRoute( data ),
"settings.save": ( data ) => downloader.isNextRoute( data ),
"page.initial.settings": ( data ) => downloader.setInitialRemoveLocalFile( data )
}
};
pages.add( downloader );
}
}

251
ui/pro/stores.js Normal file
View File

@@ -0,0 +1,251 @@
import {derived, get, writable} from "svelte/store";
import {api, config, state} from "../js/stores";
import {objectsDiffer} from "../js/objectsDiffer";
// Convenience readable store of licence, derived from config.
// We currently have one licence applied to a plugin install.
export const licence = derived( config, $config => $config.hasOwnProperty( "licences" ) ? $config.licences.at( 0 ) : [] );
// Convenience readable store of offload remaining with count message, derived from config.
export const offloadRemainingWithCount = derived( config, $config => $config.offload_remaining_with_count );
// Convenience readable store of documentation, derived from config.
export const documentation = derived( config, $config => $config.documentation );
/*
* Tools.
*/
// Whether tools are locked due to background activity such as upgrade.
export const toolsLocked = writable( false );
// Keeps track of the currently running background tool.
export const running = writable( "" );
const toolIcons = {
"add-metadata": "offload",
"reverse-add-metadata": "analyzerepair",
"verify-add-metadata": "analyzerepair",
"copy-buckets": "move",
"download-and-remover": "remove",
"downloader": "download",
"elementor-analyze-and-repair": "analyzerepair",
"move-objects": "move",
"move-private-objects": "move",
"move-public-objects": "move",
"remove-local-files": "clean",
"update-acls": "analyzerepair",
"uploader": "offload",
"woocommerce-product-urls": "analyzerepair",
};
/**
* Creates store of tools info and API access methods.
*
* @return {Object}
*/
function createTools() {
const { subscribe, set, update } = writable( {} );
return {
subscribe,
set,
async action( tool, action ) {
state.pausePeriodicFetch();
// Set the status text to the default busy description
// until the API returns a calculated status description.
tool.status_description = tool.busy_description;
tool.short_status_description = tool.busy_description;
// Ensure all subscribers know the tool status is changing.
update( _tools => {
_tools[ tool.id ] = tool;
return _tools;
} );
let result = {};
const json = await api.put( "tools", {
id: tool.id,
action: action
} );
if ( json.hasOwnProperty( "ok" ) ) {
result = json;
}
await state.resumePeriodicFetch();
return result;
},
async start( tool ) {
// Ensure all subscribers know that a tool is running.
running.update( _running => tool.id );
tool.is_queued = true;
return await this.action( tool, "start" );
},
async cancel( tool ) {
tool.is_cancelled = true;
return await this.action( tool, "cancel" );
},
async pauseResume( tool ) {
tool.is_paused = !tool.is_paused;
return await this.action( tool, "pause_resume" );
},
updateTools( json ) {
if ( json.hasOwnProperty( "tools" ) ) {
// Update our understanding of what the server's tools status is.
update( _tools => {
return { ...json.tools };
} );
// Update our understanding of the currently running tool.
const runningTool = Object.values( json.tools ).find( ( tool ) => tool.is_processing || tool.is_queued || tool.is_paused || tool.is_cancelled );
if ( runningTool ) {
running.update( _running => runningTool.id );
} else {
running.update( _running => "" );
}
}
},
icon( tool, isRunning, animated ) {
let icon = "tool-generic";
let type = "default";
if ( isRunning ) {
if ( tool.is_paused ) {
type = "paused";
} else if ( animated ) {
type = "running-animated";
} else {
type = "active";
}
}
if ( tool && tool.hasOwnProperty( "slug" ) && toolIcons.hasOwnProperty( tool.slug ) ) {
icon = "tool-" + toolIcons[ tool.slug ];
}
if ( ["active", "default", "paused", "running-animated"].includes( type ) ) {
icon = icon + "-" + type + ".svg";
} else {
icon = icon + "-default.svg";
}
return icon;
}
};
}
export const tools = createTools();
/*
* Assets.
*/
// Does the app need a page refresh to resolve conflicts?
export const assetsNeedsRefresh = writable( false );
// Whether assets settings are locked due to background activity such as upgrade.
export const assetsSettingsLocked = writable( false );
// Convenience readable store of server's assets settings, derived from config.
export const currentAssetsSettings = derived( config, $config => $config.assets_settings );
// Convenience readable store of defined assets settings keys, derived from config.
export const assetsDefinedSettings = derived( config, $config => $config.assets_defined_settings );
// Convenience readable store of assets domain check info, derived from config.
export const assetsDomainCheck = derived( config, $config => $config.assets_domain_check );
// Convenience readable store indicating whether Assets functionality may be used.
export const enableAssets = derived( [licence, config], ( [$licence, $config] ) => {
if (
$licence.hasOwnProperty( "is_set" ) &&
$licence.is_set &&
$licence.hasOwnProperty( "is_valid" ) &&
$licence.is_valid &&
$config.hasOwnProperty( "assets_settings" )
) {
return true;
}
return false;
} );
/**
* Creates store of assets settings.
*
* @return {Object}
*/
function createAssetsSettings() {
const { subscribe, set, update } = writable( [] );
return {
subscribe,
set,
async save() {
const json = await api.put( "assets-settings", get( this ) );
if ( json.hasOwnProperty( "saved" ) && true === json.saved ) {
// Sync settings with what the server has.
this.updateSettings( json );
return json;
}
return {};
},
reset() {
set( { ...get( currentAssetsSettings ) } );
},
async fetch() {
const json = await api.get( "assets-settings", {} );
this.updateSettings( json );
},
updateSettings( json ) {
if (
json.hasOwnProperty( "assets_defined_settings" ) &&
json.hasOwnProperty( "assets_settings" )
) {
const dirty = get( assetsSettingsChanged );
const previousSettings = { ...get( currentAssetsSettings ) }; // cloned
// Update our understanding of what the server's settings are.
config.update( _config => {
return {
..._config,
assets_defined_settings: json.assets_defined_settings,
assets_settings: json.assets_settings,
};
} );
// No need to check for changes from state if we've just saved these settings.
if ( json.hasOwnProperty( "saved" ) && true === json.saved ) {
return;
}
// If the settings weren't changed before, they shouldn't be now.
if ( !dirty && get( assetsSettingsChanged ) ) {
assetsSettings.reset();
}
// If settings are in middle of being changed when changes come in
// from server, reset to server version.
if ( dirty && objectsDiffer( [previousSettings, get( currentAssetsSettings )] ) ) {
assetsNeedsRefresh.update( _needsRefresh => true );
assetsSettings.reset();
}
}
}
};
}
export const assetsSettings = createAssetsSettings();
// Have the assets settings been changed from current server side settings?
export const assetsSettingsChanged = derived( [assetsSettings, currentAssetsSettings], objectsDiffer );

View File

@@ -0,0 +1,67 @@
export const toolSettingsNotifications = {
/**
* Process local and server settings to return a new Map of inline notifications.
*
* @param {Map} notifications
* @param {Object} settings
* @param {Object} current_settings
* @param {Object} strings
* @param {Object} counts
* @param {Object} licence
*
* @return {Map<string, Map<string, Object>>} keyed by setting name, containing map of notification objects keyed by id.
*/
process: ( notifications, settings, current_settings, strings, counts, licence ) => {
// use-yearmonth-folders
let entries = notifications.has( "use-yearmonth-folders" ) ? notifications.get( "use-yearmonth-folders" ) : new Map();
if (
current_settings.hasOwnProperty( "use-yearmonth-folders" ) &&
current_settings[ "use-yearmonth-folders" ] &&
settings.hasOwnProperty( "use-yearmonth-folders" ) &&
!settings[ "use-yearmonth-folders" ] &&
counts.hasOwnProperty( "offloaded" ) &&
counts.offloaded > 0 &&
licence.hasOwnProperty( "is_valid" ) &&
licence.is_valid
) {
if ( !entries.has( "no-move-objects-year-month-notice" ) ) {
entries.set( "no-move-objects-year-month-notice", {
inline: true,
type: "warning",
message: strings.no_move_objects_year_month_notice
} );
}
} else if ( entries.has( "no-move-objects-year-month-notice" ) ) {
entries.delete( "no-move-objects-year-month-notice" );
}
notifications.set( "use-yearmonth-folders", entries );
// object-versioning
entries = notifications.has( "object-versioning" ) ? notifications.get( "object-versioning" ) : new Map();
if (
current_settings.hasOwnProperty( "object-versioning" ) &&
current_settings[ "object-versioning" ] &&
settings.hasOwnProperty( "object-versioning" ) &&
!settings[ "object-versioning" ] &&
counts.hasOwnProperty( "offloaded" ) &&
counts.offloaded > 0 &&
licence.hasOwnProperty( "is_valid" ) &&
licence.is_valid
) {
if ( !entries.has( "no-move-objects-object-versioning-notice" ) ) {
entries.set( "no-move-objects-object-versioning-notice", {
inline: true,
type: "warning",
message: strings.no_move_objects_object_versioning_notice
} );
}
} else if ( entries.has( "no-move-objects-object-versioning-notice" ) ) {
entries.delete( "no-move-objects-object-versioning-notice" );
}
notifications.set( "object-versioning", entries );
return notifications;
}
};