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>