Merge branch 'main' of https://github.com/appwrite/console into mock-phone-numbers

This commit is contained in:
Christy Jacob
2024-06-16 20:55:46 +00:00
564 changed files with 20556 additions and 5974 deletions
+35
View File
@@ -0,0 +1,35 @@
name: E2E
on:
pull_request_target:
branches: ['**']
paths-ignore:
- '**/*.md'
- 'static/**/*'
env:
VITE_STRIPE_PUBLIC_KEY: ${{ vars.VITE_STRIPE_PUBLIC_KEY }}
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: E2E Tests
run: npm run e2e
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 7
+23
View File
@@ -0,0 +1,23 @@
name: Mark stale issues
on:
schedule:
- cron: '0 0 * * *' # Midnight Runtime
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue has been labeled as a 'question', indicating that it requires additional information from the requestor. It has been inactive for 7 days. If no further activity occurs, this issue will be closed in 14 days."
stale-issue-label: 'stale'
days-before-stale: 7
days-before-close: 14
remove-stale-when-updated: true
close-issue-message: 'This issue has been closed due to inactivity. If you still require assistance, please provide the requested information.'
close-issue-reason: 'not_planned'
operations-per-run: 100
only-labels: 'question'
+5 -10
View File
@@ -1,13 +1,8 @@
name: Tests
on:
push:
branches: [main]
paths-ignore:
- '**/*.md'
- 'static/**/*'
pull_request:
branches: [main]
branches: ['**']
paths-ignore:
- '**/*.md'
- 'static/**/*'
@@ -23,9 +18,9 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18
# - name: Audit dependencies
# run: npm audit --audit-level low
node-version: 20
- name: Audit dependencies
run: npm audit --audit-level critical
- name: Install dependencies
run: npm ci
- name: Svelte Diagnostics
@@ -33,6 +28,6 @@ jobs:
- name: Linter
run: npm run lint
- name: Unit Tests
run: npm test
run: npm run test
- name: Build Console
run: npm run build
+3 -1
View File
@@ -8,6 +8,8 @@ node_modules
.env
.env.*
!.env.example
test-results/
playwright-report/
.DS_STORE
.cache
@@ -145,4 +147,4 @@ dist
.stylelintcache
# SvelteKit build / generate output
.svelte-kit
.svelte-kit
+1250 -1245
View File
File diff suppressed because it is too large Load Diff
+13 -14
View File
@@ -12,38 +12,37 @@
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test": "vitest run",
"test:ui": "vitest --ui",
"test:watch": "vitest watch",
"e2e": "playwright test tests/e2e"
"test": "TZ=EST vitest run",
"test:ui": "TZ=EST vitest --ui",
"test:watch": "TZ=EST vitest watch",
"e2e": "playwright test tests/e2e",
"e2e:ui": "playwright test tests/e2e --ui"
},
"dependencies": {
"@appwrite.io/console": "^0.5.0",
"@appwrite.io/pink": "0.3.0",
"@appwrite.io/pink-icons": "0.3.0",
"@appwrite.io/console": "^0.6.2",
"@appwrite.io/pink": "0.23.0",
"@appwrite.io/pink-icons": "0.23.0",
"@popperjs/core": "^2.11.8",
"@sentry/svelte": "^7.66.0",
"@sentry/tracing": "^7.66.0",
"@stripe/stripe-js": "^2.2.0",
"@stripe/stripe-js": "^3.4.0",
"ai": "^2.2.11",
"analytics": "^0.8.9",
"dayjs": "^1.11.9",
"deep-equal": "^2.2.2",
"dotenv": "^16.3.1",
"echarts": "^5.4.3",
"logrocket": "^5.0.1",
"nanoid": "^4.0.2",
"plausible-tracker": "^0.3.8",
"pretty-bytes": "^6.1.1",
"prismjs": "^1.29.0",
"svelte-confetti": "^1.3.0",
"tippy.js": "^6.3.7",
"web-vitals": "^3.4.0"
"tippy.js": "^6.3.7"
},
"devDependencies": {
"@melt-ui/pp": "^0.1.4",
"@melt-ui/svelte": "^0.61.2",
"@playwright/test": "^1.37.1",
"@playwright/test": "^1.44.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.3.4",
"@sveltejs/vite-plugin-svelte": "^3.0.1",
@@ -53,8 +52,8 @@
"@testing-library/user-event": "^14.4.3",
"@types/deep-equal": "^1.0.1",
"@types/prismjs": "^1.26.0",
"@typescript-eslint/eslint-plugin": "^6.5.0",
"@typescript-eslint/parser": "^6.5.0",
"@typescript-eslint/eslint-plugin": "^7.7.0",
"@typescript-eslint/parser": "^7.7.0",
"@vitest/ui": "^1.2.1",
"eslint": "^8.48.0",
"eslint-config-prettier": "^9.0.0",
+8
View File
@@ -1,7 +1,15 @@
import { type PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
timeout: 120000,
reportSlowTests: null,
reporter: [['html', { open: 'never' }]],
webServer: {
timeout: 120000,
env: {
VITE_APPWRITE_ENDPOINT: 'http://console-tests.appwrite.org/v1',
VITE_CONSOLE_MODE: 'cloud'
},
command: 'npm run build && npm run preview',
port: 4173
}
+3
View File
@@ -3,7 +3,10 @@
<head>
<meta charset="utf-8" />
<meta name="description" content="" />
<link rel="icon" type="image/svg+xml" href="/logos/appwrite-icon.svg" />
<link rel="mask-icon" type="image/png" href="/logos/appwrite-icon.png" />
<link
rel="preload"
href="/fonts/inter/inter-v8-latin-600.woff2"
+1 -3
View File
@@ -1,4 +1,2 @@
/// <reference types="@sveltejs/kit" />
interface Window {
VERCEL_ANALYTICS_ID: string | false;
}
interface Window {}
+14
View File
@@ -0,0 +1,14 @@
import { AppwriteException } from '@appwrite.io/console';
import type { HandleClientError } from '@sveltejs/kit';
export const handleError: HandleClientError = async ({ error, message, status }) => {
if (error instanceof AppwriteException) {
status = error.code === 0 ? undefined : error.code;
message = error.message;
}
return {
message,
status
};
};
+26 -1
View File
@@ -151,6 +151,13 @@ export enum Submit {
AccountDelete = 'submit_account_delete',
AccountDeleteSession = 'submit_account_delete_session',
AccountDeleteAllSessions = 'submit_account_delete_all_sessions',
AccountUpdateMfa = 'submit_account_update_mfa',
AccountAuthenticatorCreate = 'submit_account_authenticator_create',
AccountAuthenticatorUpdate = 'submit_account_authenticator_update',
AccountAuthenticatorDelete = 'submit_account_authenticator_delete',
AccountRecoveryCodesCreate = 'submit_account_recovery_codes_create',
AccountRecoveryCodesUpdate = 'submit_account_recovery_codes_update',
AccountDeleteIdentity = 'submit_account_delete_identity',
UserCreate = 'submit_user_create',
UserDelete = 'submit_user_delete',
UserUpdateEmail = 'submit_user_update_email',
@@ -162,6 +169,10 @@ export enum Submit {
UserUpdateStatus = 'submit_user_update_status',
UserUpdateVerificationEmail = 'submit_user_update_verification_email',
UserUpdateVerificationPhone = 'submit_user_update_verification_phone',
UserIdentityDelete = 'submit_user_identity_delete',
UserTargetCreate = 'submit_user_target_create',
UserTargetDelete = 'submit_user_target_delete',
UserAuthenticatorDelete = 'submit_user_authenticator_delete',
OrganizationCreate = 'submit_organization_create',
OrganizationDelete = 'submit_organization_delete',
OrganizationUpdateName = 'submit_organization_update_name',
@@ -244,6 +255,7 @@ export enum Submit {
WebhookUpdateUrl = 'submit_webhook_update_url',
WebhookUpdateEvents = 'submit_webhook_update_events',
WebhookUpdateName = 'submit_webhook_update_name',
WebhookUpdateEnabled = 'submit_webhook_update_enabled',
WebhookUpdateSecurity = 'submit_webhook_update_security',
BucketCreate = 'submit_bucket_create',
BucketDelete = 'submit_bucket_delete',
@@ -265,6 +277,7 @@ export enum Submit {
PaymentMethodUpdate = 'submit_payment_method_update',
PaymentMethodDelete = 'submit_payment_method_delete',
RetryPayment = 'submit_retry_payment',
VerifyPayment = 'submit_verify_payment',
BillingAddressCreate = 'submit_billing_address_create',
BillingAddressUpdate = 'submit_billing_address_update',
BillingAddressDelete = 'submit_billing_address_delete',
@@ -290,5 +303,17 @@ export enum Submit {
SmsResetTemplate = 'submit_sms_reset_template',
SmsUpdateInviteTemplate = 'submit_sms_update_invite_template',
SmsUpdateLoginTemplate = 'submit_sms_update_login_template',
SmsUpdateVerificationTemplate = 'submit_sms_update_verification_template'
SmsUpdateVerificationTemplate = 'submit_sms_update_verification_template',
MessagingProviderCreate = 'submit_messaging_provider_create',
MessagingProviderDelete = 'submit_messaging_provider_delete',
MessagingProviderUpdate = 'submit_messaging_provider_update',
MessagingMessageCreate = 'submit_messaging_message_create',
MessagingMessageUpdate = 'submit_messaging_message_update',
MessagingMessageDelete = 'submit_messaging_message_delete',
MessagingTopicCreate = 'submit_messaging_topic_create',
MessagingTopicDelete = 'submit_messaging_topic_delete',
MessagingTopicUpdateName = 'submit_messaging_topic_update_name',
MessagingTopicUpdatePermissions = 'submit_messaging_topic_update_permissions',
MessagingTopicSubscriberAdd = 'submit_messaging_topic_subscriber_add',
MessagingTopicSubscriberDelete = 'submit_messaging_topic_subscriber_delete'
}
+26 -4
View File
@@ -5,6 +5,7 @@ import { onMount } from 'svelte';
import { derived, writable } from 'svelte/store';
import { nanoid } from 'nanoid/non-secure';
import { trackEvent } from '$lib/actions/analytics';
import { omit } from '$lib/helpers/omit';
const groups = [
'ungrouped',
@@ -17,6 +18,10 @@ const groups = [
'platforms',
'databases',
'functions',
'messaging',
'messages',
'providers',
'topics',
'storage',
'domains',
'webhooks',
@@ -44,6 +49,7 @@ type BaseCommand = {
forceEnable?: boolean;
group?: CommandGroup;
icon?: string;
image?: string;
rank?: number;
nested?: boolean;
keepOpen?: boolean;
@@ -69,7 +75,23 @@ export const disabledMap = writable<Map<string, boolean>>(new Map());
// Derived stores
export const commands = derived(commandMap, ($commandMap) => {
return Array.from($commandMap.values()).flat();
const res: Command[] = [];
const keys = new Set<string>();
const allCommands = Array.from($commandMap.values()).flat().toReversed();
for (const command of allCommands) {
if (isKeyedCommand(command) && !command.disabled) {
const keysString = command.keys.join('+');
if (keys.has(keysString)) {
res.push(omit(command, 'keys'));
continue;
}
keys.add(keysString);
}
res.push(command);
}
return res;
});
const commandsEnabled = derived(disabledMap, ($disabledMap) => {
@@ -109,9 +131,9 @@ function hasDisputing(command: KeyedCommand, allCommands: Command[]) {
}
export const commandCenterKeyDownHandler = derived(
[commandMap, commandsEnabled, wizard],
([$commandMap, enabled, $wizard]) => {
const commandsArr = Array.from($commandMap.values()).flat();
[commands, commandsEnabled, wizard],
([$commands, enabled, $wizard]) => {
const commandsArr = $commands;
let recentKeyCodes: number[] = [];
let validCommands: KeyedCommand[] = [];
@@ -0,0 +1,73 @@
<script lang="ts">
import { providers } from '$routes/console/project-[project]/messaging/providers/store';
import {
messageParams,
providerType,
targetsById
} from '$routes/console/project-[project]/messaging/wizard/store';
import { MessagingProviderType } from '@appwrite.io/console';
import Template from './template.svelte';
import { wizard } from '$lib/stores/wizard';
import Create from '$routes/console/project-[project]/messaging/create.svelte';
import { topicsById } from '$routes/console/project-[project]/messaging/store';
let search = '';
let options = Object.entries(providers).map(([type, option]) => {
return {
label: option.name,
icon: option.icon,
callback() {
if (
type !== MessagingProviderType.Email &&
type !== MessagingProviderType.Sms &&
type !== MessagingProviderType.Push
)
return;
$providerType = type;
$topicsById = {};
$targetsById = {};
const common = {
topics: [],
users: [],
targets: []
};
switch (type) {
case MessagingProviderType.Email:
$messageParams[$providerType] = {
...common,
subject: '',
content: ''
};
break;
case MessagingProviderType.Sms:
$messageParams[$providerType] = {
...common,
content: ''
};
break;
case MessagingProviderType.Push:
$messageParams[$providerType] = {
...common,
title: '',
body: '',
data: [['', '']]
};
break;
}
wizard.start(Create);
}
};
});
$: filteredOptions = options.filter((option) => {
return option.label.toLowerCase().includes(search.toLowerCase());
});
</script>
<Template options={filteredOptions} bind:search>
<div class="u-flex u-cross-center u-gap-8" slot="option" let:option>
<i class="icon-{option.icon}" />
<span>{option.label}</span>
</div>
</Template>
+6
View File
@@ -78,3 +78,9 @@ export const FilesPanel: SubPanel = {
name: 'Files',
component: Files
};
import CreateMessage from './createMessage.svelte';
export const CreateMessagePanel: SubPanel = {
name: 'Create Message',
component: CreateMessage
};
+9 -1
View File
@@ -2,6 +2,8 @@
This is the root command panel. It precedes all other command panels.
-->
<script lang="ts">
import { base } from '$app/paths';
import { app } from '$lib/stores/app';
import { debounce } from '$lib/helpers/debounce';
import { isMac } from '$lib/helpers/platform';
import { commands, searchers, type Command, isKeyedCommand } from '../commands';
@@ -54,7 +56,13 @@
<Template options={results} bind:search searchPlaceholder="Search for commands or content...">
<div slot="option" class="u-flex u-main-space-between content" let:option={command}>
<div class="u-flex u-gap-8 u-cross-center">
<i class="icon-{command.icon ?? 'arrow-sm-right'}" />
{#if command.image}
<img
src={`${base}/icons/${$app.themeInUse}/color/${command.image}.svg`}
alt={command.label} />
{:else}
<i class="icon-{command.icon ?? 'arrow-sm-right'}" />
{/if}
<span>
{command.label}
</span>
@@ -0,0 +1,52 @@
import { goto } from '$app/navigation';
import { project } from '$routes/console/project-[project]/store';
import { get } from 'svelte/store';
import { type Searcher } from '../commands';
import { sdk } from '$lib/stores/sdk';
import { MessagingProviderType } from '@appwrite.io/console';
const getLabel = (message) => {
switch (message.providerType) {
case MessagingProviderType.Push:
return message.data.title;
case MessagingProviderType.Sms:
return message.data.content;
case MessagingProviderType.Email:
return message.data.subject;
default:
return 'null';
}
};
const getIcon = (message) => {
switch (message.providerType) {
case MessagingProviderType.Push:
return 'device-mobile';
case MessagingProviderType.Sms:
return 'annotation';
case MessagingProviderType.Email:
return 'mail';
default:
return 'send';
}
};
export const messagesSearcher = (async (query: string) => {
const { messages } = await sdk.forProject.messaging.listMessages([], query || undefined);
const projectId = get(project).$id;
return messages
.filter((message) => getLabel(message).toLowerCase().includes(query.toLowerCase()))
.map(
(message) =>
({
group: 'messages',
label: getLabel(message),
callback: () => {
goto(`/console/project-${projectId}/messaging/message-${message.$id}`);
},
icon: getIcon(message)
}) as const
);
}) satisfies Searcher;
@@ -0,0 +1,33 @@
import { goto } from '$app/navigation';
import { project } from '$routes/console/project-[project]/store';
import { get } from 'svelte/store';
import type { Searcher } from '../commands';
import { sdk } from '$lib/stores/sdk';
import { getProviderDisplayNameAndIcon } from '$routes/console/project-[project]/messaging/provider.svelte';
const getIcon = (provider: string) => {
const { icon } = getProviderDisplayNameAndIcon(provider);
return icon;
};
export const providersSearcher = (async (query: string) => {
const { providers } = await sdk.forProject.messaging.listProviders([], query || undefined);
const projectId = get(project).$id;
return providers
.filter((provider) => provider.name.toLowerCase().includes(query.toLowerCase()))
.map(
(provider) =>
({
group: 'providers',
label: provider.name,
callback: () => {
goto(
`/console/project-${projectId}/messaging/providers/provider-${provider.$id}`
);
},
image: getIcon(provider.provider)
}) as const
);
}) satisfies Searcher;
+25
View File
@@ -0,0 +1,25 @@
import { goto } from '$app/navigation';
import { project } from '$routes/console/project-[project]/store';
import { get } from 'svelte/store';
import type { Searcher } from '../commands';
import { sdk } from '$lib/stores/sdk';
export const topicsSearcher = (async (query: string) => {
const { topics } = await sdk.forProject.messaging.listTopics([], query || undefined);
const projectId = get(project).$id;
return topics
.filter((topic) => topic.name.toLowerCase().includes(query.toLowerCase()))
.map(
(topic) =>
({
group: 'topics',
label: topic.name,
callback: () => {
goto(`/console/project-${projectId}/messaging/topics/topic-${topic.$id}`);
},
icon: 'send'
}) as const
);
}) satisfies Searcher;
+10 -9
View File
@@ -1,11 +1,13 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { Buttons } from '../stores/notifications';
import { createEventDispatcher, type ComponentProps } from 'svelte';
import { Button } from '$lib/elements/forms';
export let dismissible = false;
export let type: 'info' | 'success' | 'warning' | 'error' | 'default' = 'info';
export let buttons: Buttons[] = [];
export let buttons: (ComponentProps<Button> & {
slot: string;
onClick?: (e: MouseEvent) => void;
})[] = [];
export let isAction = false;
export let isStandalone = false;
let classes = '';
@@ -27,7 +29,6 @@
<button
type="button"
class="button is-text is-only-icon"
style="--button-size:1.5rem;"
aria-label="close alert box"
on:click={() => dispatch('dismiss')}>
<span class="icon-x" aria-hidden="true" />
@@ -54,8 +55,8 @@
<div class="alert-buttons u-flex">
<slot name="buttons">
{#each buttons as button}
<Button text on:click={button.method}>
<span class="text">{button.name}</span>
<Button text {...button} on:click={button.onClick}>
<span class="text">{button.slot}</span>
</Button>
{/each}
</slot>
@@ -67,9 +68,9 @@
<div class="alert-buttons u-flex u-gap-16 u-cross-child-center">
<slot name="buttons">
{#each buttons as button}
<button type="button" class="button is-text" on:click={button.method}>
<span class="text">{button.name}</span>
</button>
<Button text {...button} on:click={button.onClick}>
+ <span class="text">{button.slot}</span>
</Button>
{/each}
</slot>
</div>
@@ -0,0 +1,43 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/stores';
import { trackEvent } from '$lib/actions/analytics';
import { BillingPlan } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { HeaderAlert } from '$lib/layout';
import { hideBillingHeaderRoutes, readOnly, tierToPlan, upgradeURL } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
</script>
{#if $organization?.$id && $organization?.billingPlan === BillingPlan.FREE && $readOnly && !hideBillingHeaderRoutes.includes($page.url.pathname)}
<HeaderAlert
type="error"
title={`${$organization.name} usage has reached the ${tierToPlan($organization.billingPlan).name} plan limit`}>
<svelte:fragment>
Usage for the <b>{$organization.name}</b> organization has reached the limits of the {tierToPlan(
$organization.billingPlan
).name}
plan. Consider upgrading to increase your resource usage.
</svelte:fragment>
<svelte:fragment slot="buttons">
<Button
href={`${base}/console/organization-${$organization.$id}/usage`}
text
fullWidthMobile>
<span class="text">View usage</span>
</Button>
<Button
href={$upgradeURL}
on:click={() => {
trackEvent('click_organization_upgrade', {
from: 'button',
source: 'limit_reached_banner'
});
}}
secondary
fullWidthMobile>
<span class="text">Upgrade plan</span>
</Button>
</svelte:fragment>
</HeaderAlert>
{/if}
@@ -1,10 +1,11 @@
<script lang="ts">
import { page } from '$app/stores';
import { HeaderAlert } from '$lib/layout';
import { hideBillingHeaderRoutes } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
</script>
{#if $organization?.markedForDeletion && !$page.url.pathname.includes('/console/account')}
{#if $organization?.markedForDeletion && !hideBillingHeaderRoutes.includes($page.url.pathname)}
<HeaderAlert title="Organization flagged for deletion">
<svelte:fragment>
All existing projects in the {$organization.name} organization have been paused. This organization
@@ -0,0 +1,26 @@
<script lang="ts">
import { page } from '$app/stores';
import { BillingPlan } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { HeaderAlert } from '$lib/layout';
import { hideBillingHeaderRoutes } from '$lib/stores/billing';
import { orgMissingPaymentMethod } from '$routes/console/store';
</script>
{#if ($orgMissingPaymentMethod.billingPlan === BillingPlan.PRO || $orgMissingPaymentMethod.billingPlan === BillingPlan.SCALE) && !$orgMissingPaymentMethod.paymentMethodId && !$orgMissingPaymentMethod.backupPaymentMethodId && !hideBillingHeaderRoutes.includes($page.url.pathname)}
<HeaderAlert
type="error"
title={`Payment method required for ${$orgMissingPaymentMethod.name}`}>
<svelte:fragment>
Add a payment method to {$orgMissingPaymentMethod.name} to avoid service interruptions to
your projects.
</svelte:fragment>
<svelte:fragment slot="buttons">
<Button
secondary
href={`/console/organization-${$orgMissingPaymentMethod.$id}/billing`}>
Add payment method
</Button>
</svelte:fragment>
</HeaderAlert>
{/if}
@@ -2,14 +2,14 @@
import { page } from '$app/stores';
import { Button } from '$lib/elements/forms';
import { HeaderAlert } from '$lib/layout';
import { actionRequiredInvoices } from '$lib/stores/billing';
import { actionRequiredInvoices, hideBillingHeaderRoutes } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { VARS } from '$lib/system';
const endpoint = VARS.APPWRITE_ENDPOINT ?? `${$page.url.origin}/v1`;
</script>
{#if $actionRequiredInvoices && $actionRequiredInvoices?.invoices?.length && !$page.url.pathname.includes('/console/account')}
{#if $actionRequiredInvoices && $actionRequiredInvoices?.invoices?.length && !hideBillingHeaderRoutes.includes($page.url.pathname)}
<HeaderAlert title="Authorization required" type="error">
Please authorize your upcoming payment for {$organization.name}. Your bank requires this
security measure to proceed with payment.
@@ -0,0 +1,26 @@
<script lang="ts">
import { page } from '$app/stores';
import { Button } from '$lib/elements/forms';
import { HeaderAlert } from '$lib/layout';
import { hideBillingHeaderRoutes, paymentMissingMandate } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { confirmSetup } from '$lib/stores/stripe';
async function verifyPaymentMethod() {
const method = await sdk.forConsole.billing.setupPaymentMandate(
$organization.$id,
$paymentMissingMandate.$id
);
await confirmSetup(method.clientSecret, method.$id);
}
</script>
{#if $paymentMissingMandate && $paymentMissingMandate.country === 'in' && $paymentMissingMandate.mandateId === null && !hideBillingHeaderRoutes.includes($page.url.pathname)}
<HeaderAlert title="Authorization required" type="info">
The payment method for {$organization.name} needs to be verified.
<svelte:fragment slot="buttons">
<Button secondary on:click={verifyPaymentMethod}>Verify payment method</Button>
</svelte:fragment>
</HeaderAlert>
{/if}
@@ -4,10 +4,10 @@
import { Button } from '$lib/elements/forms';
import { diffDays, toLocaleDate } from '$lib/helpers/date';
import { HeaderAlert } from '$lib/layout';
import { failedInvoice } from '$lib/stores/billing';
import { failedInvoice, hideBillingHeaderRoutes } from '$lib/stores/billing';
</script>
{#if $failedInvoice && !$page.url.pathname.includes('/console/account')}
{#if $failedInvoice && !hideBillingHeaderRoutes.includes($page.url.pathname)}
{@const daysPassed = diffDays(new Date($failedInvoice.dueAt), new Date())}
<HeaderAlert title="Your projects are at risk">
<svelte:fragment>
@@ -0,0 +1,52 @@
<script lang="ts">
import { tooltip } from '$lib/actions/tooltip';
import { formatCurrency } from '$lib/helpers/numbers';
import type { Coupon } from '$lib/sdk/billing';
export let couponData: Partial<Coupon> = {
code: null,
status: null,
credits: null
};
export let fixedCoupon = false;
</script>
{#if couponData?.credits}
<span class="u-flex u-main-space-between">
<div class="u-flex u-cross-center u-gap-4">
<p class="text">
<span class="icon-tag u-color-text-success" aria-hidden="true" />
{#if couponData.credits >= 100}
{couponData?.code?.toUpperCase()}
{:else}
<span use:tooltip={{ content: couponData?.code?.toUpperCase() }}>
Credits applied
</span>
{/if}
</p>
{#if !fixedCoupon}
<button
type="button"
class="button is-text is-only-icon"
style="--button-size:1.5rem;"
aria-label="Close"
title="Close"
on:click={() =>
(couponData = {
code: null,
status: null,
credits: null
})}>
<span class="icon-x" aria-hidden="true" />
</button>
{/if}
</div>
{#if couponData.credits >= 100}
<p class="inline-tag" use:tooltip={{ content: formatCurrency(couponData.credits) }}>
Credits applied
</p>
{:else}
<span class="u-color-text-success">-{formatCurrency(couponData.credits)}</span>
{/if}
</span>
{/if}
@@ -0,0 +1,35 @@
<script lang="ts">
import { trackEvent } from '$lib/actions/analytics';
import { BillingPlan } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { tierToPlan, upgradeURL } from '$lib/stores/billing';
import { Card } from '..';
export let service: string;
export let eventSource: string;
</script>
<Card>
<slot>
<div class="u-flex u-flex-vertical u-main-center u-cross-center u-gap-8">
<h6 class="body-text-1 u-bold u-trim-1">Upgrade to add {service}</h6>
<p class="text u-text-center">
Upgrade to a {tierToPlan(BillingPlan.PRO).name} plan to add {service} to your organization
</p>
<Button
class="u-margin-block-start-16"
secondary
fullWidthMobile
href={$upgradeURL}
on:click={() => {
trackEvent('click_organization_upgrade', {
from: 'button',
source: eventSource
});
}}>
Upgrade
</Button>
</div>
</slot>
</Card>
@@ -0,0 +1,84 @@
<script lang="ts">
import { FormList, InputChoice, InputNumber } from '$lib/elements/forms';
import { toLocaleDate } from '$lib/helpers/date';
import { formatCurrency } from '$lib/helpers/numbers';
import type { Coupon } from '$lib/sdk/billing';
import { plansInfo, type Tier } from '$lib/stores/billing';
import { CreditsApplied } from '.';
export let billingPlan: Tier;
export let collaborators: string[];
export let couponData: Partial<Coupon>;
export let billingBudget: number;
export let fixedCoupon = false; // If true, the coupon cannot be removed
const today = new Date();
const billingPayDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
let budgetEnabled = false;
$: currentPlan = $plansInfo.get(billingPlan);
$: extraSeatsCost = (collaborators?.length ?? 0) * (currentPlan?.addons?.member?.price ?? 0);
$: grossCost = currentPlan.price + extraSeatsCost;
$: estimatedTotal =
couponData?.status === 'active'
? grossCost - couponData.credits >= 0
? grossCost - couponData.credits
: 0
: grossCost;
$: trialEndDate = new Date(
billingPayDate.getTime() + currentPlan.trialDays * 24 * 60 * 60 * 1000
);
</script>
<section
class="card u-margin-block-start-32 u-flex u-flex-vertical u-gap-8"
style:--p-card-padding="1.5rem"
style:--p-card-border-radius="var(--border-radius-small)">
<span class="u-flex u-main-space-between">
<p class="text">{currentPlan.name} plan</p>
<p class="text">{formatCurrency(currentPlan.price)}</p>
</span>
<span class="u-flex u-main-space-between">
<p class="text">Additional seats ({collaborators?.length})</p>
<p class="text">
{formatCurrency(extraSeatsCost)}
</p>
</span>
{#if couponData?.status === 'active'}
<CreditsApplied bind:couponData {fixedCoupon} />
{/if}
<div class="u-sep-block-start" />
<span class="u-flex u-main-space-between">
<p class="text">Estimated total</p>
<p class="text">
{formatCurrency(estimatedTotal)}
</p>
</span>
<p class="text u-margin-block-start-16">
Your payment method will be charged this amount plus usage fees every 30 days {!currentPlan.trialDays
? `starting ${toLocaleDate(billingPayDate.toString())}`
: ` after your trial period ends on ${toLocaleDate(trialEndDate.toString())}`}.
</p>
<FormList class="u-margin-block-start-24">
<InputChoice
type="switchbox"
id="budget"
label="Enable budget cap"
tooltip="If enabled, you will be notified when your spending reaches 75% of the set cap. Update cap alerts in your organization settings."
fullWidth
bind:value={budgetEnabled}>
{#if budgetEnabled}
<div class="u-margin-block-start-16">
<InputNumber
id="budget"
label="Budget cap (USD)"
placeholder="0"
min={0}
bind:value={billingBudget} />
</div>
{/if}
</InputChoice>
</FormList>
</section>
+6
View File
@@ -1,2 +1,8 @@
export { default as PaymentBoxes } from './paymentBoxes.svelte';
export { default as CouponInput } from './couponInput.svelte';
export { default as SelectPaymentMethod } from './selectPaymentMethod.svelte';
export { default as UsageRates } from './usageRates.svelte';
export { default as EstimatedTotalBox } from './estimatedTotalBox.svelte';
export { default as PlanComparisonBox } from './planComparisonBox.svelte';
export { default as EmptyCardCloud } from './emptyCardCloud.svelte';
export { default as CreditsApplied } from './creditsApplied.svelte';
@@ -46,6 +46,11 @@
$: if (element) {
observer.observe(element, { childList: true });
}
//Set setAsDefault as false when group changes
$: if (group || group === null) {
setAsDefault = false;
}
</script>
<RadioBoxes
@@ -6,6 +6,7 @@
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { page } from '$app/stores';
export let show = false;
@@ -20,7 +21,7 @@
async function handleSubmit() {
try {
const card = await submitStripeCard(name);
const card = await submitStripeCard(name, $page?.params?.organization ?? null);
invalidate(Dependencies.PAYMENT_METHODS);
dispatch('submit', card);
show = false;
@@ -67,8 +68,14 @@
}
</script>
<FakeModal bind:show title="Add payment method" bind:error onSubmit={handleSubmit}>
<FakeModal
bind:show
title="Add payment method"
bind:error
onSubmit={handleSubmit}
headerDivider={false}>
<FormList gap={16}>
<slot />
<InputText
id="name"
label="Cardholder name"
@@ -85,10 +92,11 @@
<!-- Stripe will create form elements here -->
</div>
</div>
<slot name="end"></slot>
</FormList>
<svelte:fragment slot="footer">
<Button secondary on:click={() => (show = false)}>Cancel</Button>
<Button submit disabled={!name}>Save</Button>
<Button submit disabled={!name}>Add</Button>
</svelte:fragment>
</FakeModal>
@@ -0,0 +1,50 @@
<script lang="ts">
import { formatNum } from '$lib/helpers/string';
import { plansInfo } from '$lib/stores/billing';
import { Box, SecondaryTabs, SecondaryTabsItem } from '..';
let selectedTab: 'tier-0' | 'tier-1' = 'tier-0';
$: plan = $plansInfo.get(selectedTab);
</script>
<Box>
<SecondaryTabs stretch>
<SecondaryTabsItem
disabled={selectedTab === 'tier-0'}
on:click={() => (selectedTab = 'tier-0')}>
Free
</SecondaryTabsItem>
<SecondaryTabsItem
disabled={selectedTab === 'tier-1'}
on:click={() => (selectedTab = 'tier-1')}>
Pro
</SecondaryTabsItem>
</SecondaryTabs>
<div class="u-margin-block-start-24">
{#if selectedTab === 'tier-0'}
<h3 class="u-bold body-text-1">{plan.name} plan</h3>
<ul class="un-order-list u-margin-block-start-8">
<li>
Limited to {plan.databases} Database, {plan.buckets} Buckets, {plan.functions} Functions
per project
</li>
<li>Limited to 1 organization member</li>
<li>{plan.bandwidth}GB bandwidth</li>
<li>{plan.storage}GB storage</li>
<li>{formatNum(plan.executions)} executions</li>
</ul>
{:else if selectedTab === 'tier-1'}
<h3 class="u-bold body-text-1">{plan.name} plan</h3>
<ul class="un-order-list u-margin-block-start-8">
<li>Everything in the Free plan, plus:</li>
<li>Unlimited databases, buckets, functions</li>
<li>{plan.bandwidth}GB bandwidth</li>
<li>{plan.storage}GB storage</li>
<li>{formatNum(plan.executions)} executions</li>
<li>Email support</li>
</ul>
{/if}
</div>
</Box>
@@ -0,0 +1,138 @@
<script lang="ts">
import {
TableBody,
TableCell,
TableCellHead,
TableCellText,
TableHeader,
TableRow,
TableScroll
} from '$lib/elements/table';
import { Alert } from '$lib/components';
import { calculateExcess, plansInfo, tierToPlan, type Tier } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { toLocaleDate } from '$lib/helpers/date';
import { Button } from '$lib/elements/forms';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import { abbreviateNumber } from '$lib/helpers/numbers';
import { formatNum } from '$lib/helpers/string';
import { onMount } from 'svelte';
import type { OrganizationUsage } from '$lib/sdk/billing';
import { sdk } from '$lib/stores/sdk';
import { BillingPlan } from '$lib/constants';
export let tier: Tier;
const plan = $plansInfo?.get(tier);
let excess: {
bandwidth?: number;
storage?: number;
users?: number;
executions?: number;
members?: number;
} = null;
let usage: OrganizationUsage = null;
let showExcess = false;
onMount(async () => {
usage = await sdk.forConsole.billing.listUsage(
$organization.$id,
$organization.billingCurrentInvoiceDate,
new Date().toISOString()
);
excess = calculateExcess(usage, plan, $organization);
showExcess = Object.values(excess).some((value) => value > 0);
});
</script>
{#if showExcess}
<Alert type="error" {...$$restProps}>
<svelte:fragment slot="title">
Your {tierToPlan($organization.billingPlan).name} plan subscription will end on {toLocaleDate(
$organization.billingNextInvoiceDate
)}
</svelte:fragment>
Following payment of your final invoice, your organization will switch to the {tierToPlan(
BillingPlan.FREE
).name} plan. {#if excess?.members > 0}All team members except the owner will be removed on
that date.{/if} Service disruptions may occur unless resource usage is reduced.
<!-- Any executions, bandwidth, or messaging usage will be reset at that time. -->
<svelte:fragment slot="buttons">
<Button
text
external
href="https://appwrite.io/docs/advanced/platform/free#reaching-resource-limits">
Learn more
</Button>
</svelte:fragment>
</Alert>
<TableScroll dense noMargin class="u-margin-block-start-16">
<TableHeader>
<TableCellHead>Resource</TableCellHead>
<TableCellHead>Free limit</TableCellHead>
<TableCellHead>Excess usage</TableCellHead>
</TableHeader>
<TableBody>
{#if excess?.members}
<TableRow>
<TableCellText title="members">Organization members</TableCellText>
<TableCellText title="limit">{plan.members} members</TableCellText>
<TableCell title="excess">
<p class="u-color-text-danger u-flex u-cross-center u-gap-4">
<span class="icon-arrow-up" />
{excess?.members} members
</p>
</TableCell>
</TableRow>
{/if}
{#if excess?.storage}
<TableRow>
<TableCellText title="storage">Storage</TableCellText>
<TableCellText title="limit">{plan.storage} GB</TableCellText>
<TableCell title="excess">
<p class="u-color-text-danger">
<span class="icon-arrow-up" />
{humanFileSize(excess?.storage)}
</p>
</TableCell>
</TableRow>
{/if}
{#if excess?.executions}
<TableRow>
<TableCellText title="executions">Function executions</TableCellText>
<TableCellText title="limit">
{abbreviateNumber(plan.executions)} executions
</TableCellText>
<TableCell title="excess">
<p class="u-color-text-danger">
<span class="icon-arrow-up" />
<span
title={excess?.executions
? excess.executions.toString()
: 'executions'}>
{formatNum(excess?.executions)} executions
</span>
</p>
</TableCell>
</TableRow>
{/if}
{#if excess?.users}
<TableRow>
<TableCellText title="users">Users</TableCellText>
<TableCellText title="limit">
{abbreviateNumber(plan.users)} users
</TableCellText>
<TableCell title="excess">
<p class="u-color-text-danger">
<span class="icon-arrow-up" />
<span title={excess?.users ? excess.users.toString() : 'users'}>
{formatNum(excess?.users)} users
</span>
</p>
</TableCell>
</TableRow>
{/if}
</TableBody>
</TableScroll>
{/if}
@@ -0,0 +1,127 @@
<script lang="ts">
import { Button, InputChoice, InputSelectSearch, InputText } from '$lib/elements/forms';
import type { PaymentList, PaymentMethodData } from '$lib/sdk/billing';
import { sdk } from '$lib/stores/sdk';
import { hasStripePublicKey, isCloud } from '$lib/system';
import { onMount } from 'svelte';
import { Alert, Card, CreditCardBrandImage } from '..';
import PaymentModal from './paymentModal.svelte';
import { capitalize } from '$lib/helpers/string';
export let methods: PaymentList;
export let value: string;
export let taxId = '';
let showTaxId = false;
let showPaymentModal = false;
async function cardSaved(event: CustomEvent<PaymentMethodData>) {
value = event.detail.$id;
methods = await sdk.forConsole.billing.listPaymentMethods();
}
onMount(() => {
if (methods?.total && methods.paymentMethods.some((method) => !!method?.last4)) {
value = methods.paymentMethods[0].$id;
}
});
$: filteredMethods = methods?.paymentMethods?.filter((method) => !!method?.last4);
$: selectedPaymentMethod = methods?.paymentMethods?.find((method) => method.$id === value);
</script>
{#if filteredMethods?.length}
{#if selectedPaymentMethod?.country === 'in'}
<Alert type="warning">
<svelte:fragment slot="title">Indian credit or debit card-holders</svelte:fragment>
To comply with RBI regulations in India, Appwrite will ask for verification to charge up
to $150 USD on your payment method. We will never charge more than the cost of your plan
and the resources you use, or your budget cap limit. For higher usage limits, please contact
us.
</Alert>
{/if}
<InputSelectSearch
id="method"
required
label="Payment method"
placeholder="Select payment method"
bind:value
options={filteredMethods.map((method) => {
return {
value: method.$id,
label: `${capitalize(method.brand)} ending in ${method.last4}`,
data: [method.brand]
};
})}
interactiveOutput
let:option={o}>
<svelte:fragment slot="output" let:option={o}>
<output class="input-text u-cursor-pointer">
<span class="u-flex u-gap-16 u-flex-vertical">
<span class="u-flex u-gap-16">
<span class="u-flex u-cross-center u-gap-8" style="padding-inline:0.25rem">
<span>{o.label}</span>
<CreditCardBrandImage brand={o.data?.toString()} />
</span>
</span>
</span>
</output>
</svelte:fragment>
<span class="u-flex u-gap-16 u-flex-vertical">
<span class="u-flex u-gap-16">
<span class="u-flex u-cross-center u-gap-8" style="padding-inline:0.25rem">
<span>{o.label}</span>
<CreditCardBrandImage brand={o.data?.toString()} />
</span>
</span>
</span>
<svelte:fragment slot="listEnd">
<Button text on:click={() => (showPaymentModal = true)}>
<span class="icon-plus"></span>
<span class="text">Add new payment method</span>
</Button>
</svelte:fragment>
</InputSelectSearch>
{:else}
<Card
isDashed
style="--p-card-padding:0.75rem; --p-card-bg-color: transparent; --p-card-border-radius: 0.5rem"
isTile>
<div class="u-flex u-main-space-between u-cross-center">
<p class="u-flex u-gap-8 u-cross-center">
<span
class="icon-exclamation u-color-text-warning"
style:font-size="var(--icon-size-medium)"></span>
<span class="text">A payment method is required</span>
</p>
<Button secondary on:click={() => (showPaymentModal = true)}>
<span class="icon-plus"></span> <span class="text">Add</span>
</Button>
</div>
</Card>
{/if}
{#if showPaymentModal && isCloud && hasStripePublicKey}
<PaymentModal bind:show={showPaymentModal} on:submit={cardSaved}>
<svelte:fragment slot="end">
<InputChoice
type="checkbox"
id="taxIdCheck"
label="I'm purchasing as a business"
fullWidth
bind:value={showTaxId}>
{#if showTaxId}
<div class="u-margin-block-start-8">
<InputText
id="taxId"
label="Tax ID"
placeholder="Tax ID"
bind:value={taxId} />
</div>
{/if}
</InputChoice>
</svelte:fragment>
</PaymentModal>
{/if}
@@ -10,18 +10,17 @@
TableRow
} from '$lib/elements/table';
import { toLocaleDate } from '$lib/helpers/date';
import { organization } from '$lib/stores/organization';
import { createOrganization } from './store';
import { plansInfo, type Tier } from '$lib/stores/billing';
import { organization, type Organization } from '$lib/stores/organization';
import { plansInfo } from '$lib/stores/billing';
import { abbreviateNumber, formatCurrency } from '$lib/helpers/numbers';
import { BillingPlan } from '$lib/constants';
export let show = false;
export let tier: Tier;
export let org: Organization;
$: plan = $plansInfo?.get(tier);
$: plan = $plansInfo?.get(org.billingPlan);
$: nextDate = $createOrganization?.name
$: nextDate = org?.name
? new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1).toString()
: $organization?.billingNextInvoiceDate;
@@ -50,20 +49,19 @@
}
];
$: isFree = tier === BillingPlan.STARTER;
$: isFree = org.billingPlan === BillingPlan.FREE;
</script>
<Modal bind:show size="big" headerDivider={false} title="Usage rates">
{#if isFree}
Usage on the Starter plan is limited for the following resources. Next billing period: {toLocaleDate(
nextDate
)}.
{:else if tier === BillingPlan.PRO}
Usage on the {$plansInfo?.get(BillingPlan.FREE).name} plan is limited for the following resources.
Next billing period: {toLocaleDate(nextDate)}.
{:else if org.billingPlan === BillingPlan.PRO}
<p>
Usage on the Pro plan will be charged at the end of each billing period at the following
rates. Next billing period: {toLocaleDate(nextDate)}.
</p>
{:else if tier === BillingPlan.SCALE}
{:else if org.billingPlan === BillingPlan.SCALE}
<p>
Usage on the Scale plan will be charged at the end of each billing period at the
following rates. Next billing period: {toLocaleDate(nextDate)}.
@@ -0,0 +1,57 @@
<script lang="ts">
import { Modal } from '$lib/components';
import { Button, FormList, InputText } from '$lib/elements/forms';
import type { Coupon } from '$lib/sdk/billing';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let show = false;
let error: string = null;
let coupon: string = '';
export let couponData: Partial<Coupon> = {
code: null,
status: null,
credits: null
};
async function addCoupon() {
try {
const response = await sdk.forConsole.billing.getCoupon(coupon);
couponData = response;
dispatch('validation', couponData);
coupon = null;
show = false;
addNotification({
type: 'success',
message: 'Credits applied successfully'
});
} catch (e) {
error = e.message;
}
}
$: if (coupon) {
error = null;
}
</script>
<Modal
bind:show
title="Add credits"
headerDivider={false}
onSubmit={addCoupon}
size="big"
bind:error>
Credits will be applied automatically to your next invoice.
<FormList>
<InputText placeholder="Promo code" id="code" label="Add promo code" bind:value={coupon} />
</FormList>
<svelte:fragment slot="footer">
<Button text on:click={() => (show = false)}>Cancel</Button>
<Button submit>Add</Button>
</svelte:fragment>
</Modal>
+5
View File
@@ -5,6 +5,8 @@
isTile?: boolean;
isDashed?: boolean;
danger?: boolean;
style?: string;
class?: string;
};
type ButtonProps = {
@@ -26,6 +28,7 @@
export let href: string = null;
let classes = '';
export { classes as class };
export let style: string = '';
function getElement() {
switch (true) {
@@ -46,6 +49,8 @@
class:is-border-dashed={isDashed}
class:is-danger={danger}
class:is-allowed-focus={href}
{...$$restProps}
{style}
on:click
on:keyup={clickOnEnter}
role={href || isButton ? 'button' : 'generic'}
+1 -1
View File
@@ -20,7 +20,7 @@
<ul
class="grid-box common-section u-margin-block-start-32"
style={`--grid-gap:1.5rem; --grid-item-size:${total > 3 ? '22rem' : '25rem'};`}
style={`--grid-gap:1.5rem; --grid-item-size-small-screens: 18rem; --grid-item-size:${total > 3 ? '22rem' : '25rem'};`}
data-private>
<slot />
+1 -1
View File
@@ -6,7 +6,7 @@
export let hideFooter = false;
</script>
<Card {danger}>
<Card {danger} class={danger ? 'u-padding-inline-start-32' : ''}>
<div class="common-section grid-1-2" class:hideOverflow>
<div class="grid-1-2-col-1 u-flex u-flex-vertical u-gap-16">
<slot />
+2 -5
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { Button } from '$lib/elements/forms';
import { wizard } from '$lib/stores/wizard';
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
import { upgradeURL } from '$lib/stores/billing';
export let service: string;
</script>
@@ -9,8 +8,6 @@
<article class="card u-grid u-cross-center u-width-full-line">
<div class="u-flex u-flex-vertical u-gap-24 u-main-center u-cross-center">
<p class="text u-text-center">Upgrade your plan to add more {service}</p>
<Button secondary on:click={() => wizard.start(ChangeOrganizationTierCloud)}>
Change plan
</Button>
<Button secondary href={$upgradeURL}>Change plan</Button>
</div>
</article>
+34 -13
View File
@@ -1,18 +1,39 @@
<script lang="ts">
export let href: string;
export let href: string = null;
export let external: boolean = false;
</script>
<li class="clickable-list-item">
<a {href} class="clickable-list-button" on:click>
{#if $$slots.default}
<h5 class="clickable-list-title u-trim-1">
<slot />
</h5>
{/if}
{#if $$slots.desc}
<div class="clickable-list-desc">
<p class="text u-margin-block-start-8"><slot name="desc" /></p>
</div>
{/if}
</a>
{#if href}
<a
{href}
class="clickable-list-button"
target={external ? '_blank' : ''}
rel={external ? 'noopener noreferrer' : ''}
on:click>
{#if $$slots.default}
<h5 class="clickable-list-title u-trim-1">
<slot />
</h5>
{/if}
{#if $$slots.desc}
<div class="clickable-list-desc">
<p class="text u-margin-block-start-8"><slot name="desc" /></p>
</div>
{/if}
</a>
{:else}
<button class="clickable-list-button u-width-full-line" on:click>
{#if $$slots.default}
<h5 class="clickable-list-title u-trim-1">
<slot />
</h5>
{/if}
{#if $$slots.desc}
<div class="clickable-list-desc">
<p class="text u-margin-block-start-8"><slot name="desc" /></p>
</div>
{/if}
</button>
{/if}
</li>
+5 -1
View File
@@ -1,3 +1,7 @@
<ul class="collapsible">
<script lang="ts">
export let fullWidth = false;
</script>
<ul class="collapsible" class:u-width-full-line={fullWidth}>
<slot />
</ul>
+66 -25
View File
@@ -3,33 +3,74 @@
export let withIndentation = false;
export let open = false;
export let disabled = false;
export let noContent = false;
export let isInfo = false;
export let gap = 16;
</script>
<li class="collapsible-item">
<details class="collapsible-wrapper" {open}>
<!-- svelte-ignore a11y-no-redundant-roles -->
<summary
class="collapsible-button u-position-relative"
on:keyup={clickOnEnter}
on:click
role="button"
tabindex="0">
<slot name="beforetitle" />
<div>
<span class="text"><slot name="title" /></span>
{#if $$slots.subtitle}
<span class="collapsible-button-optional"><slot name="subtitle" /></span>
{/if}
<li class="collapsible-item" class:is-info={isInfo}>
{#if noContent}
<div class="collapsible-wrapper">
<div class={`collapsible-button u-gap-${gap}`}>
<slot />
</div>
<div class="icon">
<span class="icon-cheveron-down" aria-hidden="true" />
</div>
</summary>
<div
class="collapsible-content"
class:u-margin-block-start-8={withIndentation}
class:u-padding-inline-32={withIndentation}>
<slot />
</div>
</details>
{:else}
<details class="collapsible-wrapper" class:is-disabled={disabled} bind:open>
<!-- svelte-ignore a11y-no-redundant-roles -->
<summary
class={`collapsible-button u-position-relative u-gap-${gap}`}
on:keyup={clickOnEnter}
on:click
role="button"
tabindex="0">
<slot name="beforetitle" />
<div>
<span class="text" class:u-color-text-disabled={disabled}>
<slot name="title" /></span>
{#if $$slots.subtitle}
<span
class="collapsible-button-optional"
class:u-color-text-disabled={disabled}><slot name="subtitle" /></span>
{/if}
</div>
<slot name="end">
<div class="icon">
<span
class="icon-cheveron-down u-font-size-20"
class:u-color-text-disabled={disabled}
aria-hidden="true" />
</div>
</slot>
</summary>
<div
class="collapsible-content"
class:u-margin-block-start-8={withIndentation}
class:u-padding-inline-32={withIndentation}>
<slot />
</div>
</details>
{/if}
</li>
<style lang="scss">
// TODO: remove once pink is updated
.collapsible-item {
.collapsible-wrapper.is-disabled {
&[open] .icon-cheveron-down {
rotate: unset;
}
cursor: not-allowed;
* {
cursor: not-allowed;
}
.collapsible {
&-content {
display: none !important;
}
}
}
}
</style>
+43 -31
View File
@@ -1,46 +1,58 @@
<script lang="ts">
import { tooltip } from '$lib/actions/tooltip';
import { copy } from '$lib/helpers/copy';
import { addNotification } from '$lib/stores/notifications';
import { Label } from '$lib/elements/forms';
import type { SvelteComponent } from 'svelte';
import { Copy, Drop } from '.';
export let value: string;
export let label: string = null;
export let optionalText: string | undefined = undefined;
export let showLabel = false;
export let labelTooltip: string = null;
export let popover: typeof SvelteComponent<unknown> = null;
export let popoverProps: Record<string, unknown> = {};
export let appendTo: Parameters<typeof tooltip>['1']['appendTo'] = undefined;
let content = 'Click to copy';
const handleCopy = async () => {
const success = await copy(value);
if (success) {
content = 'Copied';
} else {
addNotification({
message: 'Unable to copy to clipboard',
type: 'error'
});
}
};
let show = false;
</script>
<div>
<label class:u-hide={!showLabel} class="label" for={label}>{label}</label>
{#if label}
<Label tooltip={labelTooltip} {optionalText} hide={!showLabel} for={label}>
{label}{#if popover}
<Drop isPopover bind:show display="inline-block">
<!-- TODO: make unclicked icon greyed out and hover and clicked filled -->
&nbsp;<button
type="button"
on:click={() => (show = !show)}
class="tooltip"
aria-label="input tooltip">
<span
class="icon-info"
aria-hidden="true"
style:font-size="var(--icon-size-small)" />
</button>
<svelte:fragment slot="list">
<div
class="dropped card u-max-width-250"
style:--p-card-padding=".75rem"
style:--card-border-radius="var(--border-radius-small)"
style:box-shadow="var(--shadow-large)">
<svelte:component this={popover} {...popoverProps} />
</div>
</svelte:fragment>
</Drop>
{/if}
</Label>
{/if}
<div class="input-text-wrapper" style="--amount-of-buttons:1">
<input {value} id={label} type="text" class="input-text" readonly />
<input id={label} type="text" {value} readonly />
<div class="options-list">
<button
type="button"
class="options-list-button"
aria-label="Click to copy."
on:click={handleCopy}
on:mouseenter={() => setTimeout(() => (content = 'Click to copy'))}
use:tooltip={{
content,
hideOnClick: false
}}>
<span class="icon-duplicate" aria-hidden="true" />
</button>
{#key appendTo}
<Copy {value} {appendTo}>
<span class="icon-duplicate" aria-hidden="true" />
</Copy>
{/key}
</div>
</div>
</div>
@@ -1,12 +1,14 @@
<script lang="ts">
import { isValueOfStringEnum } from '$lib/helpers/types';
import { sdk } from '$lib/stores/sdk';
import { CreditCard } from '@appwrite.io/console';
export let brand: string;
export let width = 23;
export let height = 16;
function getCreditCardImage(brand: string, width = 46, height = 32) {
if (!brand) return '';
if (!isValueOfStringEnum(CreditCard, brand)) return '';
return sdk.forConsole.avatars.getCreditCard(brand, width, height).toString();
}
</script>
+5
View File
@@ -33,4 +33,9 @@
<svelte:fragment slot="title">This payment method has expired</svelte:fragment>
</Alert>
{/if}
{#if paymentMethod?.lastError}
<Alert type="error" class="u-margin-block-start-16 u-width-full-line">
{paymentMethod.lastError}
</Alert>
{/if}
</div>
+55 -25
View File
@@ -12,7 +12,11 @@
export let childStart = false;
export let noStyle = false;
export let fullWidth = false;
export let wrapperFullWidth = false;
export let fixed = false;
export let display = 'block';
export let arrowSize = 10;
export let isPopover = false;
const dispatch = createEventDispatcher<{
blur: undefined;
@@ -31,13 +35,14 @@
{
name: 'arrow',
options: {
element: arrow
element: arrow,
padding: arrowSize * 1.75
}
},
{
name: 'offset',
options: {
offset: [0, noArrow ? 0 : 6]
offset: [noArrow ? 0 : -arrowSize, noArrow ? 0 : arrowSize / 1.5]
}
},
{
@@ -100,16 +105,27 @@
<svelte:window on:click={onBlur} on:keydown={onKeyDown} />
<div class:drop-wrapper={!noStyle} class:u-cross-child-start={childStart} bind:this={element}>
<div
class="u-min-width-0"
class:drop-wrapper={!noStyle}
class:u-cross-child-start={childStart}
class:u-width-full-line={wrapperFullWidth}
bind:this={element}
style:display>
<slot />
</div>
<div
class="drop-tooltip"
class:u-width-full-line={fullWidth}
bind:this={tooltip}
class:u-width-full-line={fullWidth}
style:--arrow-size={`${arrowSize}px`}
style:z-index="10">
<div class="drop-arrow" class:u-hide={!show || (show && noArrow)} bind:this={arrow} />
<div
class="drop-arrow"
class:is-popover={isPopover}
class:u-hide={!show || (show && noArrow)}
bind:this={arrow} />
{#if show}
<slot name="list" />
{/if}
@@ -117,36 +133,50 @@
<!-- svelte-ignore css-unused-selector -->
<style global lang="scss">
.drop-tooltip[data-popper-placement^='top'] > .drop-arrow {
bottom: -4px;
}
.drop-tooltip[data-popper-placement^='bottom'] > .drop-arrow {
top: -4px;
}
.drop-tooltip[data-popper-placement^='left'] > .drop-arrow {
right: -4px;
}
.drop-tooltip[data-popper-placement^='right'] > .drop-arrow {
left: -4px;
.drop-arrow.is-popover {
--drop-arrow-pop-over-bg-color: var(--color-neutral-90);
body.theme-light & {
--drop-arrow-pop-over-bg-color: var(--color-neutral-0);
}
}
.drop-arrow,
.drop-arrow::before {
position: absolute;
width: 8px;
height: 8px;
z-index: -1;
width: var(--arrow-size);
height: var(--arrow-size);
z-index: 1;
--drop-arrow-border: 1px solid hsl(var(--color-neutral-85));
--drop-arrow-bg-color: hsl(var(--drop-arrow-pop-over-bg-color, var(--color-neutral-105)));
body.theme-light & {
--drop-arrow-border: 1px solid hsl(var(--color-neutral-10));
--drop-arrow-bg-color: hsl(var(--drop-arrow-pop-over-bg-color, var(--color-neutral-0)));
}
}
.drop-arrow::before {
content: '';
transform: rotate(45deg);
background: hsl(var(--color-neutral-85));
background: var(--drop-arrow-bg-color);
}
body.theme-light & {
background: hsl(var(--color-neutral-10));
.drop-tooltip[data-popper-placement^='top'] > .drop-arrow {
bottom: calc(var(--arrow-size) / -2);
&::before {
border-bottom: var(--drop-arrow-border);
border-right: var(--drop-arrow-border);
border-bottom-right-radius: 25%;
}
}
.drop-tooltip[data-popper-placement^='bottom'] > .drop-arrow {
top: calc(var(--arrow-size) / -2);
&::before {
border-top: var(--drop-arrow-border);
border-left: var(--drop-arrow-border);
border-top-left-radius: 25%;
}
}
</style>
+13 -1
View File
@@ -11,10 +11,21 @@
export let noStyle = false;
export let width: string = null;
export let fullWidth = false;
export let wrapperFullWidth = false;
export let position: 'relative' | 'static' = 'relative';
export let noMaxWidthList = false;
</script>
<Drop bind:show {placement} {childStart} {noArrow} {noStyle} {fullWidth} {fixed} on:blur>
<Drop
bind:show
{placement}
{childStart}
{noArrow}
{noStyle}
{fullWidth}
{wrapperFullWidth}
{fixed}
on:blur>
<slot />
<svelte:fragment slot="list">
<div
@@ -25,6 +36,7 @@
}`}>
{#if $$slots.list}
<section
class:u-max-width-none={noMaxWidthList}
class:u-overflow-y-auto={scrollable}
class:u-max-height-200={scrollable}
class="drop-section">
+1 -1
View File
@@ -24,7 +24,7 @@
{#if single}
<article class="card u-grid u-cross-center u-width-full-line common-section">
<div
class="u-flex u-flex-vertical u-cross-center u-gap-24 u-width-full-line u-overflow-hidden">
class="u-flex u-flex-vertical u-cross-center u-gap-24 u-width-full-line u-overflow-hidden u-padding-block-8">
{#if !noMedia}
<button
type="button"
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import { Button } from '$lib/elements/forms';
import { EmptySearch } from '.';
import { queries } from './filters';
export let resource;
</script>
<EmptySearch hidePages>
<div class="common-section">
<div class="u-text-center common-section">
<b class="body-text-2 u-bold">Sorry, we couldn't find any {resource}.</b>
<p>There are no {resource} that match your filters.</p>
</div>
<div class="u-flex common-section u-main-center">
<Button
secondary
on:click={() => {
queries.clearAll();
queries.apply();
}}>
Clear filters
</Button>
</div>
</div>
</EmptySearch>
+2 -2
View File
@@ -249,7 +249,7 @@
<slot />
<div>
<p class="u-text">Choose a service</p>
<div class="u-flex u-gap-8 u-margin-block-start-8">
<div class="u-flex u-gap-8 u-margin-block-start-8 u-flex-wrap">
{#each available.services as service}
<Pill
disabled={showInput}
@@ -332,7 +332,7 @@
</div>
{:else}
<div class="input-text-wrapper" style="--amount-of-buttons:2" bind:this={copyParent}>
<!--
<!--
This object syntax avoids TS erroring because 'type' isn't a valid HTMLDivElement attribute
(we need to set it to 'text' to add styling)
-->
+544
View File
@@ -0,0 +1,544 @@
<script lang="ts">
import { Id, ModalWrapper, Trim } from '.';
import { Button, Form } from '$lib/elements/forms';
import { sdk } from '$lib/stores/sdk';
import { ID, Query, Permission, Role } from '@appwrite.io/console';
import type { Models } from '@appwrite.io/console';
import { calculateSize } from '$lib/helpers/sizeConvertion';
import { toLocaleDate } from '$lib/helpers/date';
import {
Table,
TableBody,
TableRowButton,
TableHeader,
TableCell,
TableCellText,
TableCellHead
} from '$lib/elements/table';
import InputSearch from '$lib/elements/forms/inputSearch.svelte';
import InputSelect from '$lib/elements/forms/inputSelect.svelte';
import FormList from '$lib/elements/forms/formList.svelte';
import { writable } from 'svelte/store';
import { onMount } from 'svelte';
import Heading from './heading.svelte';
import { clickOnEnter } from '$lib/helpers/a11y';
import Empty from './empty.svelte';
import { base } from '$app/paths';
import { page } from '$app/stores';
export let show: boolean;
export let mimeTypeQuery: string = 'image/';
export let selectedBucket: string = null;
export let selectedFile: string = null;
export let onSelect: (e: Models.File) => void;
let search = writable('');
let searchEnabled = false;
let fileSelector: HTMLInputElement;
let uploading = false;
let view: 'grid' | 'list' = 'list';
onMount(() => {
selectedBucket = currentBucket?.$id;
});
function submitForm() {
onSelect(currentFile);
closeModal();
}
function getPreview(bucketId: string, fileId: string, size: number = 64) {
return (
sdk.forProject.storage.getFilePreview(bucketId, fileId, size, size).toString() +
'&mode=admin'
);
}
async function uploadFile() {
try {
uploading = true;
const file = await sdk.forProject.storage.createFile(
selectedBucket,
ID.unique(),
fileSelector.files[0],
[Permission.read(Role.any())]
);
search.set($search === null ? '' : null);
selectFile(file);
} catch (e) {
console.error(e);
} finally {
uploading = false;
}
}
function selectBucket(bucket: Models.Bucket | null) {
currentBucket = bucket;
selectedBucket = bucket?.$id ?? null;
resetFile();
}
function selectFile(file: Models.File) {
currentFile = file;
selectedBucket = currentFile.bucketId;
selectedFile = currentFile.$id;
}
function resetFile() {
selectedFile = null;
}
function resetBucket() {
selectedBucket = null;
}
function closeModal() {
show = false;
resetFile();
resetBucket();
}
function handleVisibilityChange() {
if (!document.hidden) {
buckets = loadBuckets();
}
}
let currentBucket: Models.Bucket = null;
let currentFile: Models.File = null;
let buckets: Promise<Models.BucketList> = loadBuckets();
async function loadBuckets() {
const response = await sdk.forProject.storage.listBuckets();
const bucket = response.buckets[0] ?? null;
if (bucket) {
currentBucket = bucket;
selectedBucket = bucket.$id;
}
return response;
}
$: files =
currentBucket &&
sdk.forProject.storage
.listFiles(
currentBucket.$id,
[Query.startsWith('mimeType', mimeTypeQuery), Query.orderDesc('$createdAt')],
$search || undefined
)
.then((response) => {
if ($search === '') {
searchEnabled = response.total > 0;
}
return response;
});
$: if ($search) {
resetFile();
}
</script>
<svelte:document on:visibilitychange={handleVisibilityChange} />
<ModalWrapper bind:show size="huge">
<Form isModal onSubmit={submitForm} class="u-stretch">
<header class="modal-header u-margin-block-end-0">
<div class="u-flex u-main-space-between u-cross-center u-gap-16">
<h4 class="modal-title heading-level-5">Select file</h4>
<button
type="button"
on:click={closeModal}
class="button is-text is-small is-only-icon"
aria-label="Close modal">
<span class="icon-x" aria-hidden="true"></span>
</button>
</div>
</header>
<div
class="modal-content u-stretch u-flex-vertical u-padding-0 u-margin-block-0 u-overflow-visible">
<div class="u-flex u-min-height-0 u-stretch">
<aside
class="drop-section u-width-200 u-padding-16
u-flex-vertical u-gap-8
u-flex-shrink-0 u-margin-inline-0 u-overflow-y-auto is-not-mobile">
<h6
class="eyebrow-heading-3"
style:--heading-text-color="var(--color-neutral-50)">
Buckets
</h6>
<ul class="drop-list">
{#await buckets}
<div class="u-flex u-main-center">
<div class="loader" />
</div>
{:then response}
{#each response.buckets as bucket}
{@const isSelected = bucket.$id === selectedBucket}
<li class="drop-list-item">
<button
type="button"
class="drop-button"
class:is-selected={isSelected}
on:click={() => selectBucket(bucket)}>
<span>{bucket.name}</span>
</button>
</li>
{:else}
<li class="drop-list-item">
<span class="drop-button">No buckets found</span>
</li>
{/each}
{/await}
</ul>
</aside>
<article
style:padding-inline="calc(var(--p-modal-padding))"
class="modal-content-main u-flex-vertical u-gap-24 u-sep-inline-start u-flex-basis-1000 u-padding-block-24 u-overflow-y-auto">
<div class="is-only-mobile">
{#await buckets}
loading
{:then response}
{#if currentBucket?.$id}
<FormList>
<InputSelect
wrapperTag="div"
label="Buckets"
options={response.buckets.map((n) => ({
value: n.$id,
label: n.name
}))}
bind:value={currentBucket.$id}
id="buckets" />
</FormList>
{/if}
{/await}
</div>
{#await buckets}
<div class="u-flex-vertical u-stretch u-position-relative u-main-center">
<div
class="u-position-absolute u-width-full-line u-flex u-flex-vertical u-main-center u-cross-center u-gap-16 u-margin-block-start-32"
style="inset-inline-start: 0;">
<div class="loader" />
<p class="text">Loading files...</p>
</div>
</div>
{:then response}
{#if response?.total}
{#if currentBucket}
<header class="u-flex-vertical u-gap-32">
<div class="u-flex u-gap-16">
<h5 class="heading-level-6 u-trim u-min-width-0">
{currentBucket?.name}
</h5>
<Id value={currentBucket?.$id} event="bucket">
{currentBucket?.$id}
</Id>
</div>
<div
class="u-flex u-main-space-between u-gap-16 u-flex-vertical-mobile">
<InputSearch
placeholder="Search files"
bind:value={$search}
disabled={!searchEnabled}
style="min-inline-size: 17.5rem; block-size: 100%" />
<div class="u-flex u-gap-16">
<div class="toggle-button">
<ul class="toggle-button-list">
<li class="toggle-button-item">
<button
on:click={() => (view = 'list')}
disabled={!searchEnabled}
type="button"
class="toggle-button-element"
class:is-selected={view === 'list'}
aria-label="List View">
<span
class="icon-view-list"
aria-hidden="true" />
</button>
</li>
<li class="toggle-button-item">
<button
on:click={() => (view = 'grid')}
disabled={!searchEnabled}
type="button"
class="toggle-button-element"
class:is-selected={view === 'grid'}
aria-label="Grid View">
<span
class="icon-view-grid"
aria-hidden="true" />
</button>
</li>
</ul>
</div>
<Button
secondary
class="is-full-width-in-stack-mobile u-height-100-percent"
disabled={uploading}
on:click={() => fileSelector.click()}>
<input
tabindex="-1"
type="file"
accept="image/*"
class="u-hide"
on:change={uploadFile}
bind:this={fileSelector} />
{#if uploading}
<div class="loader is-small"></div>
<span>Uploading</span>
{:else}
<span class="icon-upload" aria-hidden="true"
></span>
<span>Upload</span>
{/if}
</Button>
</div>
</div>
</header>
{#if files}
{#await files}
<div
class="u-flex-vertical u-stretch u-position-relative u-main-center">
<div
class="u-position-absolute u-width-full-line u-flex u-flex-vertical u-main-center u-cross-center u-gap-16 u-margin-block-start-32"
style="inset-inline-start: 0;">
<div class="loader" />
<p class="text">Loading files...</p>
</div>
</div>
{:then response}
<div class="u-flex-vertical u-stretch">
{#if response?.files?.length}
{#if view === 'grid'}
<ul
class="grid-box"
style="--grid-gap:40px; --grid-item-size:120px; --grid-item-size-small-screens:100px;">
{#each response?.files as file}
<li>
<div
class="u-flex-vertical u-gap-8">
<div
role="button"
style:background-size="cover"
style:background-image={`url(${getPreview(
currentBucket.$id,
file.$id,
360
)})`}
on:click={() =>
selectFile(file)}
on:keyup={clickOnEnter}
tabindex="0"
style:aspect-ratio="1/1"
style:display="flex"
style:align-items="flex-end"
style:flex-direction="row-reverse"
style:box-shadow="none"
class="card u-height-100-percent u-gap-16"
style="--card-padding:0.5rem;--card-padding-mobile:0.5rem; --card-border-radius:var(--border-radius-medium);">
<input
class="u-position-absolute is-small u-margin-block-start-2"
type="radio"
name="file"
value={file.$id}
style:pointer-events="none"
checked={selectedFile ===
file.$id} />
</div>
<span class="u-text-center"
><Trim alternativeTrim
>{file.name}</Trim
></span>
</div>
</li>
{/each}
</ul>
{/if}
{#if view === 'list'}
<Table noMargin noStyles transparent dense>
<TableHeader>
<TableCellHead
><span
class="u-margin-inline-start-8"
>Filename</span
></TableCellHead>
<TableCellHead width={140} onlyDesktop>
ID
</TableCellHead>
<TableCellHead width={100} onlyDesktop>
Type
</TableCellHead>
<TableCellHead width={100} onlyDesktop>
Size
</TableCellHead>
<TableCellHead width={120} onlyDesktop>
Created
</TableCellHead>
</TableHeader>
<TableBody>
{#each response?.files as file}
<TableRowButton
on:click={() =>
selectFile(file)}>
<TableCell title="Filename">
<div
class="u-inline-flex u-cross-center u-gap-12">
<input
type="radio"
class="is-small u-margin-inline-start-8"
name="file"
value={file.$id}
style:pointer-events="none"
checked={selectedFile ===
file.$id} />
<span class="image">
<img
class="avatar"
style:border-radius="var(--border-radius-xsmall)"
width="28"
height="28"
src={getPreview(
currentBucket.$id,
file.$id
)}
alt={file.name} />
</span>
<Trim alternativeTrim>
{file.name}
</Trim>
</div>
</TableCell>
<TableCellText
title="ID"
onlyDesktop>
<Id value={file.$id}
>{file.$id}</Id>
</TableCellText>
<TableCellText
title="Type"
onlyDesktop>
{file.mimeType}
</TableCellText>
<TableCellText
title="Size"
onlyDesktop>
{calculateSize(
file.sizeOriginal
)}
</TableCellText>
<TableCellText
title="Created"
onlyDesktop>
{toLocaleDate(
file.$createdAt
)}
</TableCellText>
</TableRowButton>
{/each}
</TableBody>
</Table>
{/if}
{:else if $search}
<article
style:--card-bg-color="transparent"
style:--shadow-small="none"
style:--color-border="var(--color-neutral-15)"
class="card u-grid u-cross-center u-width-full-line common-section is-border-dashed">
<div
class="u-flex u-flex-vertical u-cross-center u-gap-24 u-overflow-hidden">
<div class="common-section">
<div
class="u-text-center common-section">
<b class="body-text-2 u-bold"
>Sorry we couldn't find "{$search}"</b>
<p>
There are no files that match
your search.
</p>
</div>
<div
class="u-flex u-gap-16 common-section u-main-center">
<Button
secondary
on:click={() => ($search = '')}
>Clear search</Button>
</div>
</div>
</div>
</article>
{:else}
<Empty
single
noMedia
--card-bg-color="transparent"
--shadow-small="none"
--color-border="var(--color-neutral-15)">
<div class="common-section">
<div class="u-text-center common-section">
<Heading
size="7"
tag="h2"
trimmed={false}>
No files found within this bucket.
</Heading>
<p class="text u-line-height-1-5">
Need a hand? Learn more in our <a
class="link"
href="https://appwrite.io/docs/products/storage"
target="_blank"
rel="noopener noreferrer">
documentation</a
>.
</p>
</div>
</div>
</Empty>
{/if}
</div>
{/await}
{/if}
{/if}
{:else}
<Empty
single
noMedia
--card-bg-color="transparent"
--shadow-small="none"
--color-border="var(--color-neutral-15)">
<div class="u-text-center u-flex-vertical u-cross-center u-gap-24">
<Heading size="7" tag="h2" trimmed={false}>
No buckets found
</Heading>
<Button
secondary
external
href={`${base}/console/project-${$page.params.project}/storage`}>
Create bucket
</Button>
</div>
</Empty>
{/if}
{/await}
</article>
</div>
</div>
<div class="modal-footer u-margin-block-start-0">
<div class="u-flex u-main-end u-gap-16">
<Button text on:click={closeModal}>Cancel</Button>
<Button submit disabled={selectedBucket === null || selectedFile === null}
>Select</Button>
</div>
</div>
</Form>
</ModalWrapper>
<style lang="scss">
input[type='radio']:where(:indeterminate) {
--p-bg-color: var(--p-bg-color-default);
--p-border-color: var(--p-border-color-default);
}
</style>
+146 -49
View File
@@ -1,22 +1,30 @@
<script lang="ts">
import { Button, InputNumber } from '$lib/elements/forms';
import InputSelect from '$lib/elements/forms/inputSelect.svelte';
import InputText from '$lib/elements/forms/inputText.svelte';
import {
Button,
InputNumber,
InputSelect,
InputText,
InputTags,
FormList,
InputSelectCheckbox
} from '$lib/elements/forms';
import { Query } from '@appwrite.io/console';
import { createEventDispatcher } from 'svelte';
import { tags, type Operator, queries } from './store';
import { createEventDispatcher, onMount } from 'svelte';
import { tags, type Operator, queries, type TagValue } from './store';
import type { Column } from '$lib/helpers/types';
import type { Writable } from 'svelte/store';
import { tooltip } from '$lib/actions/tooltip';
export let columns: Writable<Column[]>;
export let columnId: string | null = null;
const dispatch = createEventDispatcher<{
clear: void;
apply: { applied: number };
}>();
let columnId: string | null = null;
$: column = $columns.find((c) => c.id === columnId) as Column;
let arrayValues: string[] = [];
dispatch('apply', { applied: $tags.length });
@@ -36,7 +44,7 @@
toTag: (attribute, input) => `**${attribute}** greater than **${input}**`,
types: ['integer', 'double', 'datetime']
},
'greater than or equal to': {
'greater than or equal': {
toQuery: (attr, input) => Query.greaterThanEqual(attr, Number(input)),
toTag: (attribute, input) => `**${attribute}** greater than or equal to **${input}**`,
types: ['integer', 'double', 'datetime']
@@ -46,7 +54,7 @@
toTag: (attribute, input) => `**${attribute}** less than **${input}**`,
types: ['integer', 'double', 'datetime']
},
'less than or equal to': {
'less than or equal': {
toQuery: Query.lessThanEqual,
toTag: (attribute, input) => `**${attribute}** less than or equal to **${input}**`,
types: ['integer', 'double', 'datetime']
@@ -56,22 +64,36 @@
toTag: (attribute, input) => `**${attribute}** equal to **${input}**`,
types: ['string', 'integer', 'double', 'boolean']
},
'not equal to': {
'not equal': {
toQuery: Query.notEqual,
toTag: (attribute, input) => `**${attribute}** not equal to **${input}**`,
types: ['string', 'integer', 'double', 'boolean']
},
'not null': {
'is not null': {
toQuery: Query.isNotNull,
toTag: (attribute) => `**${attribute}** is not null`,
types: ['string', 'integer', 'double', 'boolean', 'datetime', 'relationship'],
hideInput: true
},
null: {
'is null': {
toQuery: Query.isNull,
toTag: (attribute) => `**${attribute}** is null`,
types: ['string', 'integer', 'double', 'boolean', 'datetime', 'relationship'],
hideInput: true
},
contains: {
toQuery: Query.contains,
toTag: (attribute, input) => {
if (Array.isArray(input) && input.length > 2) {
return {
value: input,
tag: `**${attribute}** contains **${formatArray(input)}** `
};
} else {
return `**${attribute}** contains **${input}**`;
}
},
types: ['string', 'integer', 'double', 'boolean', 'datetime', 'enum']
}
};
@@ -93,23 +115,48 @@
/* eslint @typescript-eslint/no-explicit-any: 'off' */
let value: any = null;
$: {
columnId;
value = null;
}
onMount(() => {
value = column?.array ? [] : null;
});
// This Map is keyed by tags, and has a query as the value
function addFilter() {
if (!column || !operator) return;
queries.addFilter({ column, operator, value: value ?? '' });
value = null;
if (column.array) {
queries.addFilter({ column, operator, value: arrayValues });
columnId = null;
arrayValues = [];
} else {
queries.addFilter({ column, operator, value: value ?? '' });
columnId = null;
value = null;
}
}
function tagFormat(node: HTMLElement) {
node.innerHTML = node.innerHTML.replace(/\*\*(.*?)\*\*/g, '<b>$1</b>');
}
function formatArray(array: string[]) {
if (!array?.length) return;
if (array.length > 2) {
return `${array[0]} or ${array.length - 1} others`;
} else {
return array.join(' or ');
}
}
function isTypeTagValue(obj: any): obj is TagValue {
if (typeof obj === 'string') return false;
return (
obj &&
typeof obj.tag === 'string' &&
(typeof obj.value === 'string' ||
typeof obj.value === 'number' ||
Array.isArray(obj.value))
);
}
$: isDisabled = !operator;
</script>
@@ -118,10 +165,12 @@
<ul class="selects u-flex u-gap-8 u-margin-block-start-16">
<InputSelect
id="column"
options={$columns.map((c) => ({
label: c.title,
value: c.id
}))}
options={$columns
.filter((c) => c.filter !== false)
.map((c) => ({
label: c.title,
value: c.id
}))}
placeholder="Select column"
bind:value={columnId} />
<InputSelect
@@ -132,23 +181,55 @@
bind:value={operatorKey} />
</ul>
{#if column && operator && !operator?.hideInput}
<div class="u-margin-block-start-8">
{#if column.type === 'integer' || column.type === 'double'}
<InputNumber id="value" bind:value placeholder="Enter value" />
{:else if column.type === 'boolean'}
<InputSelect
id="value"
placeholder="Select a value"
required={true}
options={[
{ label: 'True', value: true },
{ label: 'False', value: false }
].filter(Boolean)}
bind:value />
{:else}
<InputText id="value" bind:value placeholder="Enter value" />
{/if}
</div>
{#if column?.array}
<FormList class="u-margin-block-start-8">
{#if column.format === 'enum'}
<InputSelectCheckbox
name="value"
bind:tags={arrayValues}
placeholder="Select value"
options={column?.elements?.map((value) => ({
label: value,
value,
checked: arrayValues.includes(value)
}))}>
</InputSelectCheckbox>
{:else}
<InputTags
label="values"
showLabel={false}
id="value"
bind:tags={arrayValues}
placeholder="Enter values" />
{/if}
</FormList>
{:else}
<ul class="u-margin-block-start-8">
{#if column.format === 'enum'}
<InputSelect
id="value"
bind:value
placeholder="Select value"
options={column?.elements?.map((value) => ({ label: value, value }))}
label="Value"
showLabel={false} />
{:else if column.type === 'integer' || column.type === 'double'}
<InputNumber id="value" bind:value placeholder="Enter value" />
{:else if column.type === 'boolean'}
<InputSelect
id="value"
placeholder="Select a value"
required={true}
options={[
{ label: 'True', value: true },
{ label: 'False', value: false }
].filter(Boolean)}
bind:value />
{:else}
<InputText id="value" bind:value placeholder="Enter value" />
{/if}
</ul>
{/if}
{/if}
<Button text disabled={isDisabled} class="u-margin-block-start-4" submit>
<i class="icon-plus" />
@@ -158,16 +239,32 @@
<ul class="u-flex u-flex-wrap u-cross-center u-gap-8 u-margin-block-start-16 tags">
{#each $tags as tag (tag)}
<button
class="tag"
on:click={() => {
queries.removeFilter(tag);
}}>
<span class="text" use:tagFormat>
{tag}
</span>
<i class="icon-x" />
</button>
{#if isTypeTagValue(tag)}
<button
use:tooltip={{
content: tag?.value?.toString()
}}
class="tag"
on:click={() => {
queries.removeFilter(tag);
}}>
<span class="text" use:tagFormat>
{tag.tag}
</span>
<i class="icon-x" />
</button>
{:else}
<button
class="tag"
on:click={() => {
queries.removeFilter(tag);
}}>
<span class="text" use:tagFormat>
{tag}
</span>
<i class="icon-x" />
</button>
{/if}
{/each}
</ul>
</div>
+17 -4
View File
@@ -9,10 +9,13 @@
export let query = '[]';
export let columns: Writable<Column[]>;
export let disabled = false;
export let fullWidthMobile = false;
const parsedQueries = queryParamToMap(query);
queries.set(parsedQueries);
let selectedColumn: string | null = null;
// We need to separate them so we don't trigger Drop's handlers
let showFiltersDesktop = false;
let showFiltersMobile = false;
@@ -24,11 +27,20 @@
showFiltersDesktop = false;
showFiltersMobile = false;
});
function clearAll() {
selectedColumn = null;
queries.clearAll();
}
$: if (!showFiltersDesktop && !showFiltersMobile) {
selectedColumn = null;
}
</script>
<div class="is-not-mobile">
<Drop bind:show={showFiltersDesktop} noArrow>
<Button secondary on:click={() => (showFiltersDesktop = !showFiltersDesktop)}>
<Button secondary on:click={() => (showFiltersDesktop = !showFiltersDesktop)} {disabled}>
<i class="icon-filter u-opacity-50" />
Filters
{#if applied > 0}
@@ -41,12 +53,13 @@
<div class="dropped card">
<p>Apply filter rules to refine the table view</p>
<Content
bind:columnId={selectedColumn}
{columns}
on:apply={(e) => (applied = e.detail.applied)}
on:clear={() => (applied = 0)} />
<hr />
<div class="u-flex u-margin-block-start-16 u-main-end u-gap-8">
<Button text on:click={queries.clearAll}>Clear all</Button>
<Button text on:click={clearAll}>Clear all</Button>
<Button on:click={queries.apply} disabled={!$queriesAreDirty}>Apply</Button>
</div>
</div>
@@ -55,7 +68,7 @@
</div>
<div class="is-only-mobile">
<Button secondary on:click={() => (showFiltersMobile = !showFiltersMobile)}>
<Button secondary on:click={() => (showFiltersMobile = !showFiltersMobile)} {fullWidthMobile}>
<i class="icon-filter u-opacity-50" />
Filters
{#if applied > 0}
@@ -75,7 +88,7 @@
on:apply={(e) => (applied = e.detail.applied)}
on:clear={() => (applied = 0)} />
<svelte:fragment slot="footer">
<Button text on:click={queries.clearAll}>Clear all</Button>
<Button text on:click={clearAll}>Clear all</Button>
<Button on:click={queries.apply} disabled={!$queriesAreDirty}>Apply</Button
></svelte:fragment>
</Modal>
+2 -1
View File
@@ -1 +1,2 @@
export { default as filters } from './filters.svelte';
export { default as Filters } from './filters.svelte';
export { hasPageQueries, queryParamToMap, queries } from '$lib/components/filters/store';
+11 -6
View File
@@ -5,14 +5,19 @@ import { page } from '$app/stores';
import deepEqual from 'deep-equal';
import type { Column, ColumnType } from '$lib/helpers/types';
export type TagValue = {
tag: string;
value: string | number | string[];
};
export type Operator = {
toTag: (attribute: string, input?: string | number) => string;
toQuery: (attribute: string, input?: string | number) => string;
toTag: (attribute: string, input?: string | number | string[]) => string | TagValue;
toQuery: (attribute: string, input?: string | number | string[]) => string;
types: ColumnType[];
hideInput?: boolean;
};
export function mapToQueryParams(map: Map<string, string>) {
export function mapToQueryParams(map: Map<string | TagValue, string>) {
return encodeURIComponent(JSON.stringify(Array.from(map.entries())));
}
@@ -22,13 +27,13 @@ export function queryParamToMap(queryParam: string) {
return new Map(queries);
}
function initQueries(initialValue = new Map<string, string>()) {
function initQueries(initialValue = new Map<string | TagValue, string>()) {
const queries = writable(initialValue);
type AddFilterArgs = {
operator: Operator;
column: Column;
value: string | number;
value: string | number | string[];
};
function addFilter({ column, operator, value }: AddFilterArgs) {
@@ -38,7 +43,7 @@ function initQueries(initialValue = new Map<string, string>()) {
});
}
function removeFilter(tag: string) {
function removeFilter(tag: string | TagValue) {
queries.update((map) => {
map.delete(tag);
return map;
+7 -1
View File
@@ -8,8 +8,14 @@
export let id: string = null;
let classes = '';
export { classes as class };
export let style = '';
</script>
<svelte:element this={tag} class={`heading-level-${size} ${classes}`} class:u-trim-1={trimmed} {id}>
<svelte:element
this={tag}
class={`heading-level-${size} u-min-width-0 ${classes}`}
class:u-trim-1={trimmed}
{style}
{id}>
<slot />
</svelte:element>
+1 -1
View File
@@ -47,7 +47,7 @@
}
</script>
<Copy {value} {event}>
<Copy {value} {event} appendTo="parent">
<div
class="interactive-text-output is-buttons-on-top"
class:u-text-center={centered}
+24
View File
@@ -0,0 +1,24 @@
<script lang="ts">
import { app } from '$lib/stores/app';
export let darkSrc: string;
export let lightSrc: string;
export let alt: string;
</script>
<a
href={$app.themeInUse === 'dark' ? darkSrc : lightSrc}
class="file-preview is-with-image"
style="inline-size: 100%; block-size: 100%;"
target="_blank"
rel="noopener noreferrer"
aria-label="open file in new window">
<div class="file-preview-image">
<img src={$app.themeInUse === 'dark' ? darkSrc : lightSrc} {alt} />
</div>
<div class="file-preview-content">
<div class="avatar">
<span class="icon-external-link" aria-hidden="true" />
</div>
</div>
</a>
+3
View File
@@ -15,6 +15,7 @@ export { default as UploadBox } from './uploadBox.svelte';
export { default as List } from './list.svelte';
export { default as ListItem } from './listItem.svelte';
export { default as Empty } from './empty.svelte';
export { default as EmptyFilter } from './emptyFilter.svelte';
export { default as EmptySearch } from './emptySearch.svelte';
export { default as Drop } from './drop.svelte';
export { default as DropList } from './dropList.svelte';
@@ -71,3 +72,5 @@ export { default as FakeModal } from './fakeModal.svelte';
export { default as RadioBoxes } from './radioBoxes.svelte';
export { default as ModalWrapper } from './modalWrapper.svelte';
export { default as ModalSideCol } from './modalSideCol.svelte';
export { default as ImagePreview } from './imagePreview.svelte';
export { default as MfaChallengeFormList } from './mfaChallengeFormList.svelte';
+11
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { tooltip } from '$lib/actions/tooltip';
import { app } from '$lib/stores/app';
import { base } from '$app/paths';
export let name: string;
export let group: string;
@@ -7,6 +9,7 @@
export let disabled = false;
export let padding = 1;
export let icon: string = null;
export let imageIcon: string = null;
export let fullHeight = true;
export let borderRadius: 'xsmall' | 'small' | 'medium' | 'large' = 'small';
export let backgroundColor: string = null;
@@ -57,6 +60,14 @@
{#if icon}
<span class={`icon-${icon} u-margin-inline-start-auto`} aria-hidden="true" />
{/if}
{#if imageIcon}
<img
class="u-margin-inline-start-auto"
style:max-inline-size="1.25rem"
style:max-block-size="1.25rem"
src={`${base}/icons/${$app.themeInUse}/color/${imageIcon}.svg`}
alt={imageIcon} />
{/if}
{/if}
</div>
</label>
@@ -0,0 +1,134 @@
<script context="module" lang="ts">
export async function verify(challenge: Models.MfaChallenge, code: string) {
try {
if (challenge == null) {
challenge = await sdk.forConsole.account.createMfaChallenge(
AuthenticationFactor.Totp
);
}
await sdk.forConsole.account.updateMfaChallenge(challenge.$id, code);
await invalidate(Dependencies.ACCOUNT);
trackEvent(Submit.AccountCreate);
} catch (error) {
trackError(error, Submit.AccountCreate);
throw error;
}
}
</script>
<script lang="ts">
import { onMount } from 'svelte';
import { invalidate } from '$app/navigation';
import { Button, FormItem, FormList, InputDigits, InputText } from '$lib/elements/forms';
import { sdk } from '$lib/stores/sdk';
import { Dependencies } from '$lib/constants';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { AuthenticationFactor, type Models } from '@appwrite.io/console';
export let factors: Models.MfaFactors & { recoveryCode: boolean };
/** If true, the form will be submitted automatically when the code is entered. */
export let autoSubmit: boolean = true;
export let showVerifyButton: boolean = true;
export let disabled: boolean = false;
export let challenge: Models.MfaChallenge;
export let code: string;
let challengeType: AuthenticationFactor;
const enabledFactors = Object.entries(factors).filter(([_, enabled]) => enabled);
async function createChallenge(factor: AuthenticationFactor) {
disabled = true;
challengeType = factor;
challenge = await sdk.forConsole.account.createMfaChallenge(factor);
disabled = false;
}
onMount(async () => {
const enabledNonRecoveryFactors = enabledFactors.filter(
([factor, _]) => factor != 'recoveryCode'
);
if (enabledNonRecoveryFactors.length == 1) {
if (factors.phone) {
createChallenge(AuthenticationFactor.Phone);
} else if (factors.email) {
createChallenge(AuthenticationFactor.Email);
}
}
});
</script>
<FormList>
{#if challengeType == AuthenticationFactor.Recoverycode}
<p>
Enter below one of the recovery codes you received when enabling MFA for this account.
</p>
<InputText id="recovery-code" bind:value={code} required autofocus />
{:else}
{#if factors.totp && (challengeType == AuthenticationFactor.Totp || challengeType == null)}
<p>Enter below a 6-digit one-time code generated by your authentication app.</p>
{:else if challengeType == AuthenticationFactor.Email}
<p>A 6-digit verification code was sent to your email, enter it below.</p>
{:else if challengeType == AuthenticationFactor.Phone}
<p>A 6-digit verification code was sent to your phone, enter it below.</p>
{/if}
<InputDigits bind:value={code} required autofocus {autoSubmit} />
{/if}
{#if showVerifyButton}
<FormItem>
<Button fullWidth submit {disabled}>Verify</Button>
</FormItem>
{/if}
{#if enabledFactors.length > 1}
<span class="with-separators eyebrow-heading-3">or</span>
<div class="u-flex-vertical u-gap-8">
{#if factors.totp && challengeType != null && challengeType != AuthenticationFactor.Totp}
<FormItem>
<Button
secondary
fullWidth
{disabled}
on:click={() => createChallenge(AuthenticationFactor.Totp)}>
<span class="icon-device-mobile u-font-size-20" aria-hidden="true" />
<span class="text">Authenticator app</span>
</Button>
</FormItem>
{/if}
{#if factors.email && challengeType != AuthenticationFactor.Email}
<FormItem>
<Button
secondary
fullWidth
{disabled}
on:click={() => createChallenge(AuthenticationFactor.Email)}>
<span class="icon-mail u-font-size-20" aria-hidden="true" />
<span class="text">Email verification</span>
</Button>
</FormItem>
{/if}
{#if factors.phone && challengeType != AuthenticationFactor.Phone}
<FormItem>
<Button
secondary
fullWidth
{disabled}
on:click={() => createChallenge(AuthenticationFactor.Phone)}>
<span class="icon-chat-alt u-font-size-20" aria-hidden="true" />
<span class="text">Phone verification</span>
</Button>
</FormItem>
{/if}
{#if factors.recoveryCode && challengeType != AuthenticationFactor.Recoverycode}
<FormItem>
<Button
text
fullWidth
{disabled}
on:click={() => createChallenge(AuthenticationFactor.Recoverycode)}>
<span class="text">Use recovery code</span>
</Button>
</FormItem>
{/if}
</div>
{/if}
</FormList>
+8 -6
View File
@@ -5,7 +5,7 @@
import { disableCommands } from '$lib/commandCenter';
export let show = false;
export let size: 'small' | 'big' = null;
export let size: 'small' | 'big' | 'huge' = null;
export let icon: string = null;
export let state: 'success' | 'warning' | 'error' | 'info' = null;
export let error: string = null;
@@ -64,11 +64,13 @@
</button>
{/if}
</div>
<p class="u-margin-block-start-4">
<slot name="description">
{description}
</slot>
</p>
{#if description.length > 0}
<p class="u-margin-block-start-4">
<slot name="description">
{description}
</slot>
</p>
{/if}
</header>
<div class="modal-content">
<div class="modal-content-spacer u-flex-vertical u-gap-24 u-width-full-line">
+20 -3
View File
@@ -4,7 +4,7 @@
import { disableCommands } from '$lib/commandCenter';
export let show = false;
export let size: 'small' | 'big' = null;
export let size: 'small' | 'big' | 'huge' = null;
export let closable = true;
export let headerDivider = true;
export let style = '';
@@ -70,13 +70,30 @@
<dialog
class="modal"
class:u-hide={!show}
class:is-small={size === 'small'}
class:is-big={size === 'big'}
class:is-huge={size === 'huge'}
class:is-separate-header={headerDivider}
{style}
bind:this={dialog}
on:cancel|preventDefault>
on:cancel|preventDefault
{style}>
{#if show}
<slot close={closeModal} />
{/if}
</dialog>
<style lang="scss">
@import '@appwrite.io/pink/src/abstract/variables/_devices.scss';
.modal.is-huge {
block-size: 100%;
min-block-size: 80vh;
@media #{$break1}, #{$break2} {
min-inline-size: 100%;
min-block-size: 100%;
border-radius: 0;
}
}
</style>
+8 -5
View File
@@ -3,6 +3,7 @@
export let items = [];
export let limit = 5;
export let hideFooter = false;
let offset = 0;
@@ -10,9 +11,11 @@
$: paginatedItems = items.slice(offset, offset + limit);
</script>
<slot {paginatedItems} />
<slot {paginatedItems} {limit} />
<div class="u-flex u-margin-block-start-32 u-main-space-between">
<p class="text">Total results: {total}</p>
<PaginationInline {limit} bind:offset sum={total} hidePages />
</div>
{#if !hideFooter}
<div class="u-flex u-margin-block-start-32 u-main-space-between">
<p class="text">Total results: {total}</p>
<PaginationInline {limit} bind:offset sum={total} hidePages />
</div>
{/if}
+2 -2
View File
@@ -118,8 +118,8 @@
<!-- svelte-ignore css-unused-selector -->
<style lang="scss" global>
.tippy-user .tippy-box {
--p-drop-bg-color: var(--color-neutral-500);
--p-drop-border-color: var(--color-neutral-200);
--p-drop-bg-color: var(--color-neutral-105);
--p-drop-border-color: var(--color-neutral-85);
body.theme-light & {
--p-drop-bg-color: var(--color-neutral-0);
+12 -10
View File
@@ -6,6 +6,8 @@
import { AvatarInitials, EmptySearch, Modal, PaginationInline } from '..';
import type { Writable } from 'svelte/store';
import type { Permission } from './permissions.svelte';
import Table from '$lib/elements/table/table.svelte';
import { TableBody, TableCell, TableRow } from '$lib/elements/table';
export let show: boolean;
export let groups: Writable<Map<string, Permission>>;
@@ -77,13 +79,13 @@
bind:value={search} />
{#if results?.teams?.length}
<div class="table-wrapper">
<table class="table is-table-layout-auto is-remove-outer-styles">
<tbody class="table-tbody">
<Table noStyles isAutoLayout tag="table">
<TableBody>
{#each results.teams as team (team.$id)}
{@const role = `team:${team.$id}`}
{@const exists = $groups.has(role)}
<tr class="table-row">
<td class="table-col" data-title="Enabled" style="--p-col-width:40">
<TableRow>
<TableCell title="Enabled" width={40}>
<input
id={team.$id}
type="checkbox"
@@ -92,8 +94,8 @@
checked={exists || selected.has(role)}
disabled={exists}
on:change={(event) => onSelection(event, role)} />
</td>
<td class="table-col" data-title="Team">
</TableCell>
<TableCell title="Team">
<label class="u-flex u-cross-center u-gap-8" for={team.$id}>
<AvatarInitials size={32} name={team.name} />
<div class="u-line-height-1-5">
@@ -101,11 +103,11 @@
<div class="u-x-small">{team.$id}</div>
</div>
</label>
</td>
</tr>
</TableCell>
</TableRow>
{/each}
</tbody>
</table>
</TableBody>
</Table>
</div>
<div class="u-flex u-margin-block-start-32 u-main-space-between">
<p class="text">Total results: {results?.total}</p>
+2 -1
View File
@@ -12,6 +12,7 @@
export let disabled = false;
export let autofocus = false;
export let isWithEndButton = true;
export let fullWidth = false;
let element: HTMLInputElement;
let timer: ReturnType<typeof setTimeout>;
@@ -58,7 +59,7 @@
</script>
<div class="u-flex u-gap-12 common-section u-main-space-between">
<div class="u-flex-basis-50-percent">
<div class={fullWidth ? 'u-width-full-line' : 'u-flex-basis-50-percent'}>
<div class="input-text-wrapper" class:is-with-end-button={isWithEndButton}>
<input
{placeholder}
+2 -1
View File
@@ -1,9 +1,10 @@
<script lang="ts">
export let large = false;
export let stretch = false;
let classes: string = undefined;
export { classes as class };
</script>
<ul class="secondary-tabs {classes}" class:is-large={large}>
<ul class="secondary-tabs {classes}" class:is-large={large} class:is-stretch={stretch}>
<slot />
</ul>
+9 -23
View File
@@ -1,55 +1,40 @@
<script lang="ts">
import { Button } from '$lib/elements/forms';
import SupportOfflineLight from '$lib/images/support/support-offline-light.png';
import SupportOfflineDark from '$lib/images/support/support-offline-dark.png';
import SupportOnlineLight from '$lib/images/support/support-online-light.png';
import SupportOnlineDark from '$lib/images/support/support-online-dark.png';
import { app } from '$lib/stores/app';
import { wizard } from '$lib/stores/wizard';
import SupportWizard from '../../routes/console/supportWizard.svelte';
import { isSupportOnline, showSupportModal } from '../../routes/console/wizard/support/store';
import { showSupportModal } from '../../routes/console/wizard/support/store';
import { isCloud } from '$lib/system';
import { organization } from '$lib/stores/organization';
import { BillingPlan } from '$lib/constants';
import ChangeOrganizationTierCloud from '$routes/console/changeOrganizationTierCloud.svelte';
import { trackEvent } from '$lib/actions/analytics';
import { localeTimezoneName, utcHourToLocaleHour } from '$lib/helpers/date';
import { upgradeURL } from '$lib/stores/billing';
export let show = false;
$: isPaid =
$organization?.billingPlan === BillingPlan.PRO ||
$organization?.billingPlan === BillingPlan.SCALE;
$: supportTimings = `${utcHourToLocaleHour('09:00')} - ${utcHourToLocaleHour('17:00')} ${localeTimezoneName()}`;
</script>
{#if isCloud}
<section class="drop-section u-grid u-gap-24 u-padding-24">
<div class="u-flex u-main-center">
{#if isSupportOnline()}
{#if $app.themeInUse === 'light'}
<img src={SupportOnlineLight} alt="Support" height="87" width="230" />
{:else}
<img src={SupportOnlineDark} alt="Support" height="87" width="230" />
{/if}
{:else if $app.themeInUse === 'light'}
<img src={SupportOfflineLight} alt="Support" height="87" width="230" />
{:else}
<img src={SupportOfflineDark} alt="Support" height="87" width="230" />
{/if}
</div>
<div>
<h4 class="eyebrow-heading-3">Premium support</h4>
{#if isPaid}
<p class="u-line-height-1-5 u-margin-block-start-8">
Get personalized support from the Appwrite team from <b>09:00 - 17:00 UTC</b>
Get personalized support from the Appwrite team from <b>{supportTimings}</b>
</p>
{/if}
</div>
{#if $organization?.billingPlan === BillingPlan.STARTER}
{#if $organization?.billingPlan === BillingPlan.FREE}
<Button
fullWidth
external
href={$upgradeURL}
on:click={() => {
wizard.start(ChangeOrganizationTierCloud);
trackEvent('click_organization_upgrade', {
from: 'button',
source: 'support_menu'
@@ -78,6 +63,7 @@
<div class="u-margin-block-start-8 u-width-full-line">
{#key $app.themeInUse}
<iframe
style="color-scheme: none"
title="Appwrite Status"
src={`https://status.appwrite.online/badge?theme=${
$app.themeInUse === 'dark' ? 'dark' : 'light'
+9 -12
View File
@@ -16,16 +16,13 @@
<svelte:window on:resize={throttle(onResize, 250)} />
<span class={`text ${alternativeTrim ? 'u-trim-1' : 'u-trim'}`} bind:this={container}>
{#if showTooltip}
<span
use:tooltip={{
content: container.innerText,
maxWidth: '30rem'
}}>
<slot />
</span>
{:else}
<span><slot /></span>
{/if}
<span
class={`text ${alternativeTrim ? 'u-trim-1' : 'u-trim'}`}
bind:this={container}
use:tooltip={{
disabled: !showTooltip,
content: container?.innerText ?? undefined,
maxWidth: '30rem'
}}>
<span><slot /></span>
</span>
+15 -8
View File
@@ -16,6 +16,7 @@
export let hideColumns = false;
export let allowNoColumns = false;
export let showColsTextMobile = false;
export let fullWidthMobile = false;
let showSelectColumns = false;
@@ -31,12 +32,15 @@
} else {
const prefs = preferences.get($page.route);
columns.set(
$columns.map((column) => {
column.show = prefs.columns?.includes(column.id) ?? true;
return column;
})
);
// Override the shown columns only if a preference was set
if (prefs?.columns) {
columns.set(
$columns.map((column) => {
column.show = prefs.columns?.includes(column.id) ?? true;
return column;
})
);
}
}
columns.subscribe((ctx) => {
@@ -71,8 +75,11 @@
<div class="grid-header-col-4">
{#if !hideColumns && view === View.Table}
{#if $columns?.length}
<DropList bind:show={showSelectColumns} scrollable>
<Button secondary on:click={() => (showSelectColumns = !showSelectColumns)}>
<DropList bind:show={showSelectColumns} scrollable wrapperFullWidth={fullWidthMobile}>
<Button
secondary
on:click={() => (showSelectColumns = !showSelectColumns)}
{fullWidthMobile}>
<span
class="icon-view-boards u-opacity-50"
aria-hidden="true"
+124 -2
View File
@@ -3,6 +3,8 @@ export const CARD_LIMIT = 6; // default card limit
export const INTERVAL = 5 * 60000; // default interval to check for feedback
export enum Dependencies {
FACTORS = 'dependency:factors',
IDENTITIES = 'dependency:identities',
CREDIT = 'dependency:credit',
INVOICES = 'dependency:invoices',
ADDRESS = 'dependency:address',
@@ -17,6 +19,8 @@ export enum Dependencies {
ACCOUNT_SESSIONS = 'dependency:account_sessions',
USER = 'dependency:user',
USERS = 'dependency:users',
USER_IDENTITIES = 'dependency:user_identities',
USER_TARGETS = 'dependency:user_targets',
SESSIONS = 'dependency:sessions',
TEAM = 'dependency:team',
TEAMS = 'dependency:teams',
@@ -47,7 +51,14 @@ export enum Dependencies {
MIGRATIONS = 'dependency:migrations',
COLLECTIONS = 'dependency:collections',
RUNTIMES = 'dependency:runtimes',
CONSOLE_VARIABLES = 'dependency:console_variables'
CONSOLE_VARIABLES = 'dependency:console_variables',
MESSAGING_PROVIDERS = 'dependency:messaging_providers',
MESSAGING_PROVIDER = 'dependency:messaging_provider',
MESSAGING_MESSAGES = 'dependency:messaging_messages',
MESSAGING_MESSAGE = 'dependency:messaging_message',
MESSAGING_TOPICS = 'dependency:messaging_topics',
MESSAGING_TOPIC = 'dependency:messaging_topic',
MESSAGING_TOPIC_SUBSCRIBERS = 'dependency:messaging_topic_subscribers'
}
export const scopes: {
@@ -55,6 +66,11 @@ export const scopes: {
description: string;
category: string;
}[] = [
{
scope: 'sessions.write',
description: "Access to create, update and delete your project's sessions",
category: 'Auth'
},
{
scope: 'users.read',
description: "Access to read your project's users",
@@ -168,6 +184,57 @@ export const scopes: {
description: "Access to execute your project's functions",
category: 'Functions'
},
{
scope: 'targets.read',
description: "Access to read your project's messaging targets",
category: 'Messaging'
},
{
scope: 'targets.write',
description: "Access to create, update, and delete your project's messaging targets",
category: 'Messaging'
},
{
scope: 'providers.read',
description: "Access to read your project's messaging providers",
category: 'Messaging'
},
{
scope: 'providers.write',
description: "Access to create, update, and delete your project's messaging providers",
category: 'Messaging'
},
{
scope: 'messages.read',
description: "Access to read your project's messages",
category: 'Messaging'
},
{
scope: 'messages.write',
description: "Access to create, update, and delete your project's messages",
category: 'Messaging'
},
{
scope: 'topics.read',
description: "Access to read your project's messaging topics",
category: 'Messaging'
},
{
scope: 'topics.write',
description: "Access to create, update, and delete your project's messaging topics",
category: 'Messaging'
},
{
scope: 'subscribers.read',
description: "Access to read your project's messaging topic subscribers",
category: 'Messaging'
},
{
scope: 'subscribers.write',
description:
"Access to create, update, and delete your project's messaging topic subscribers",
category: 'Messaging'
},
{
scope: 'locale.read',
description: "Access to access your project's Locale service",
@@ -276,11 +343,66 @@ export const eventServices: Array<EventService> = [
{ name: 'update', attributes: ['email', 'name', 'password', 'status', 'prefs'] },
{ name: 'delete' }
]
},
{
name: 'providers',
resources: [],
actions: [{ name: 'create' }, { name: 'update' }, { name: 'delete' }]
},
{
name: 'topics',
resources: [
{
name: 'subscribers',
actions: [{ name: 'create' }, { name: 'delete' }]
}
],
actions: [{ name: 'create' }, { name: 'update' }, { name: 'delete' }]
},
{
name: 'messages',
resources: [],
actions: [{ name: 'create' }, { name: 'update' }, { name: 'delete' }]
}
];
export enum BillingPlan {
STARTER = 'tier-0',
FREE = 'tier-0',
PRO = 'tier-1',
SCALE = 'tier-2'
}
export const feedbackDowngradeOptions = [
{
value: 'availableFeatures',
label: "The available features don't meet my needs"
},
{
value: 'traction',
label: "My project isn't getting traction"
},
{
value: 'bugs',
label: 'I experienced bugs or unexpected outages while using the console'
},
{
value: 'starter',
label: 'The Free plan is enough for my projects'
},
{
value: 'budget',
label: "I don't have the budget"
},
{
value: 'tryOut',
label: 'I just wanted to try it out'
},
{
value: 'alternative',
label: 'I found an alternative/competitor to meet my needs'
},
{
value: 'other',
label: 'Other'
}
];
+3
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { isValueOfStringEnum } from '$lib/helpers/types';
import { sdk } from '$lib/stores/sdk';
import { Flag } from '@appwrite.io/console';
export let flag: string;
export let name: string = flag;
export let width = 40;
@@ -9,6 +11,7 @@
export { classes as class };
export function getFlag(country: string, width: number, height: number, quality: number) {
if (!isValueOfStringEnum(Flag, country)) return '';
let flag = sdk.forProject.avatars
.getFlag(country, width * 2, height * 2, quality)
?.toString();
+2
View File
@@ -15,6 +15,7 @@
export let disabled = false;
export let external = false;
export let href: string = null;
export let download: string = undefined;
export let fullWidth = false;
export let fullWidthMobile = false;
export let ariaLabel: string = null;
@@ -63,6 +64,7 @@
on:click
on:click={track}
{href}
{download}
target={external ? '_blank' : ''}
rel={external ? 'noopener noreferrer' : ''}
class={resolvedClasses}
+11 -1
View File
@@ -17,10 +17,19 @@
let classes = '';
export { classes as class };
const { isSubmitting } = setContext<FormContext>('form', {
let form: HTMLFormElement;
export let { isSubmitting } = setContext<FormContext>('form', {
isSubmitting: writable(false)
});
export function checkValidity() {
return form.checkValidity();
}
export function triggerSubmit() {
form.requestSubmit();
}
async function submit(e: SubmitEvent) {
isSubmitting.set(true);
await onSubmit(e);
@@ -29,6 +38,7 @@
</script>
<form
bind:this={form}
class={classes}
class:form={!noStyle}
class:common-section={!noMargin}
+4
View File
@@ -29,3 +29,7 @@ export { default as Label } from './label.svelte';
export { default as InputProjectId } from './inputProjectId.svelte';
export { default as InputDate } from './inputDate.svelte';
export { default as InputDateRange } from './inputDateRange.svelte';
export { default as InputTime } from './inputTime.svelte';
export { default as InputDigits } from './inputDigits.svelte';
export { default as InputSelectCheckbox } from './inputSelectCheckbox.svelte';
export { default as InputFilePicker } from './inputFilePicker.svelte';
+25 -27
View File
@@ -1,32 +1,27 @@
<script lang="ts">
import { FormItem, Helper, Label } from '.';
import { FormItem, Helper } from '.';
import type { FormItemTag } from './formItem.svelte';
interface $$Props extends Partial<HTMLLabelElement> {
id: string;
label?: string;
optionalText?: string;
tooltip?: string;
showLabel?: boolean;
checked?: boolean;
required?: boolean;
disabled?: boolean;
element?: HTMLInputElement | undefined;
indeterminate?: boolean;
wrapperTag?: FormItemTag;
size?: 'small' | 'medium';
}
export let id: string;
export let label: string | undefined = undefined;
export let optionalText: string | undefined = undefined;
export let tooltip: string = null;
export let showLabel = true;
export let checked = false;
export let required = false;
export let disabled = false;
export let element: HTMLInputElement | undefined = undefined;
export let wrapperTag: FormItemTag = 'li';
export let size: $$Props['size'] = 'medium';
let error: string;
const handleInvalid = (event: Event) => {
@@ -44,25 +39,28 @@
</script>
<FormItem tag={wrapperTag}>
{#if label}
<Label {required} {tooltip} {optionalText} hide={!showLabel} for={id}>
{label}
</Label>
{/if}
<div class="input-text-wrapper">
<input
{id}
{disabled}
{required}
{...$$restProps}
type="checkbox"
bind:this={element}
bind:checked
on:invalid={handleInvalid}
on:click
on:change />
</div>
<label class="choice-item" for={id}>
<div class="input-text-wrapper">
<input
{id}
{disabled}
{required}
{...$$restProps}
class:is-small={size === 'small'}
type="checkbox"
bind:this={element}
bind:checked
on:invalid={handleInvalid}
on:click
on:change />
</div>
<div class="choice-item-content">
{#if label}
<div class="choice-item-title">{label}</div>
{/if}
<slot name="description" />
</div>
</label>
{#if error}
<Helper type="warning">{error}</Helper>
{/if}
+7 -6
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { tooltip as tooltipAction } from '$lib/actions/tooltip';
import { FormItem, Helper } from '.';
export let type: 'checkbox' | 'switchbox' = 'checkbox';
@@ -39,6 +40,7 @@
aria-checked={value}
bind:this={element}
bind:checked={value}
on:change
on:invalid={handleInvalid} />
<div class="choice-item-content" class:u-width-full-line={fullWidth}>
@@ -50,16 +52,15 @@
</h6>
{/if}
{#if tooltip}
<button type="button" class="tooltip" aria-label="variables info">
<button
type="button"
class="tooltip"
aria-label="variables info"
use:tooltipAction={{ content: tooltip }}>
<span
class="icon-info"
aria-hidden="true"
style="font-size: var(--icon-size-small)" />
<span class="tooltip-popup" role="tooltip">
<p class="text">
{tooltip}
</p>
</span>
</button>
{/if}
</div>
+8
View File
@@ -10,6 +10,8 @@
export let value = '';
export let required = false;
export let nullable = false;
export let min: string | number | undefined = undefined;
export let max: string | number | undefined = undefined;
export let disabled = false;
export let readonly = false;
export let autofocus = false;
@@ -65,12 +67,18 @@
{readonly}
{required}
step=".001"
{min}
{max}
autocomplete={autocomplete ? 'on' : 'off'}
type="date"
style={disabled ? '' : 'cursor: pointer;'}
class="input-text"
bind:value
bind:this={element}
on:invalid={handleInvalid}
on:click={function () {
this.showPicker();
}}
style:--amount-of-buttons={isNullable ? 2.75 : 1}
style:--button-size={isNullable ? '2rem' : '1rem'} />
{#if isNullable}
+97
View File
@@ -0,0 +1,97 @@
<script lang="ts">
import { onMount } from 'svelte';
import { FormItem } from '.';
import { createPinInput, melt } from '@melt-ui/svelte';
export let length: number = 6;
export let value: string = '';
export let required = false;
export let disabled = false;
export let readonly = false;
export let autofocus = false;
export let fullWidth = false;
export let autoSubmit = true;
let element: HTMLOListElement;
let autoSubmitted = false;
const {
elements: { root, input }
} = createPinInput({
placeholder: '',
defaultValue: value.split(''),
onValueChange: ({ next }) => {
value = next.join('');
if (value.length < length) {
autoSubmitted = false;
}
if (element && autoSubmit && value.length === length && !autoSubmitted) {
autoSubmitted = true;
const firstInputElement = element.querySelector('input');
firstInputElement?.form.requestSubmit();
}
return next;
}
});
onMount(() => {
const interval = setInterval(() => {
const input = element.querySelector('input');
if (element) {
if (input && autofocus) {
input.focus();
}
clearInterval(interval);
}
}, 10);
});
</script>
<FormItem {fullWidth}>
<ol class="u-flex u-main-center" use:melt={$root} bind:this={element}>
{#each Array.from({ length }) as _}
<li>
<input
type="number"
class="verification-code-input u-bold u-remove-input-number-buttons"
inputmode="numeric"
maxlength="1"
style:border-radius="var(--border-radius-small)"
use:melt={$input()}
{required}
{readonly}
{disabled} />
</li>
{/each}
</ol>
</FormItem>
<style lang="scss">
@import '@appwrite.io/pink/src/abstract/variables/_devices.scss';
/* Default (including mobile) */
ol {
gap: 0.5rem;
input {
--p-input-size: 2.5rem;
font-size: 1rem;
}
}
/* for smaller screens */
@media #{$break2open} {
ol {
gap: 1rem;
input {
--p-input-size: 3.75rem;
font-size: 2rem;
}
}
}
</style>
+32 -4
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteComponent, onMount } from 'svelte';
import { FormItem, Helper, Label } from '.';
import { Drop } from '$lib/components';
export let label: string;
export let showLabel = true;
@@ -13,12 +14,16 @@
export let autofocus = false;
export let autocomplete = false;
export let maxlength: number = null;
export let popover: typeof SvelteComponent<unknown> = null;
export let popoverProps: Record<string, unknown> = {};
export let fullWidth = false;
// https://www.geeksforgeeks.org/how-to-validate-a-domain-name-using-regular-expression/
const pattern = String.raw`^(?!-)[A-Za-z0-9-]+([\-\.]{1}[a-z0-9]+)*\.[A-Za-z]{2,18}$`;
const pattern = String.raw`(?!-)[A-Za-z0-9\-]+([\-\.]{1}[a-z0-9]+)*\.[A-Za-z]{2,18}`;
let element: HTMLInputElement;
let error: string;
let show = false;
onMount(() => {
if (element && autofocus) {
@@ -46,9 +51,32 @@
}
</script>
<FormItem>
<FormItem {fullWidth}>
<Label {required} hide={!showLabel} for={id}>
{label}
{label}{#if popover}
<Drop isPopover bind:show display="inline-block">
<!-- TODO: make unclicked icon greyed out and hover and clicked filled -->
&nbsp;<button
type="button"
on:click={() => (show = !show)}
class="tooltip"
aria-label="input tooltip">
<span
class="icon-info"
aria-hidden="true"
style="font-size: var(--icon-size-small)" />
</button>
<svelte:fragment slot="list">
<div
class="dropped card u-max-width-250"
style:--card-border-radius="var(--border-radius-small)"
style:--p-card-padding=".75rem"
style:box-shadow="var(--shadow-large)">
<svelte:component this={popover} {...popoverProps} />
</div>
</svelte:fragment>
</Drop>
{/if}
</Label>
<div class="input-text-wrapper">
+31 -3
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteComponent, onMount } from 'svelte';
import { FormItem, Helper, Label } from '.';
import NullCheckbox from './nullCheckbox.svelte';
import { Drop } from '$lib/components';
export let label: string;
export let optionalText: string | undefined = undefined;
@@ -16,9 +17,13 @@
export let autofocus = false;
export let autocomplete = false;
export let tooltip: string = null;
export let popover: typeof SvelteComponent<unknown> = null;
export let popoverProps: Record<string, unknown> = {};
export let fullWidth = false;
let element: HTMLInputElement;
let error: string;
let show = false;
onMount(() => {
if (element && autofocus) {
@@ -55,9 +60,32 @@
}
</script>
<FormItem>
<FormItem {fullWidth}>
<Label {required} {optionalText} {tooltip} hide={!showLabel} for={id}>
{label}
{label}{#if popover}
<Drop isPopover bind:show display="inline-block">
<!-- TODO: make unclicked icon greyed out and hover and clicked filled -->
&nbsp;<button
type="button"
on:click={() => (show = !show)}
class="tooltip"
aria-label="input tooltip">
<span
class="icon-info"
aria-hidden="true"
style="font-size: var(--icon-size-small)" />
</button>
<svelte:fragment slot="list">
<div
class="dropped card u-max-width-250"
style:--card-border-radius="var(--border-radius-small)"
style:--p-card-padding=".75rem"
style:box-shadow="var(--shadow-large)">
<svelte:component this={popover} {...popoverProps} />
</div>
</svelte:fragment>
</Drop>
{/if}
</Label>
<div
+52 -14
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { Trim } from '$lib/components';
import { Drop, Trim } from '$lib/components';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import { onMount } from 'svelte';
import { SvelteComponent, onMount } from 'svelte';
import { Helper, Label } from '.';
export let label: string = null;
@@ -13,24 +13,16 @@
export let optionalText: string = null;
export let tooltip: string = null;
export let error: string = null;
export let popover: typeof SvelteComponent<unknown> = null;
export let popoverProps: Record<string, unknown> = {};
let input: HTMLInputElement;
let hovering = false;
let show = false;
function setFiles(value: FileList) {
if (!value) return;
const hasInvalidExt = Array.from(value).some((file) => {
const fileExtension = file.name.split('.').pop();
return allowedFileExtensions?.length
? !allowedFileExtensions.includes(fileExtension)
: false;
});
if (hasInvalidExt) {
error = 'Invalid file extension';
return;
}
files = value;
input.files = value;
}
@@ -39,11 +31,22 @@
setFiles(new DataTransfer().files);
}
function isFileExtensionAllowed(fileExtension: string) {
if (allowedFileExtensions.length && !allowedFileExtensions.includes(fileExtension)) {
return false;
}
return true;
}
function dropHandler(ev: DragEvent) {
ev.dataTransfer.dropEffect = 'move';
hovering = false;
if (!ev.dataTransfer.items) return;
for (let i = 0; i < ev.dataTransfer.items.length; i++) {
const fileExtension = ev.dataTransfer.items[i].getAsFile().name.split('.')[1];
if (!isFileExtensionAllowed(fileExtension)) {
error = 'Invalid file extension';
return;
}
if (ev.dataTransfer.items[i].kind === 'file') {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(ev.dataTransfer.items[i].getAsFile());
@@ -78,6 +81,18 @@
const handleChange = (event: Event) => {
const target = event.currentTarget as HTMLInputElement;
const isValidFiles = Array.from(target.files).every((file) => {
const fileExtension = file.name.split('.').pop();
return isFileExtensionAllowed(fileExtension);
});
if (!isValidFiles) {
error = 'Invalid file extension';
target.value = '';
return;
}
setFiles(target.files);
};
</script>
@@ -94,7 +109,30 @@
<div>
{#if label}
<Label {required} {optionalText} {tooltip} hide={!label}>
{label}
{label}{#if popover}
<Drop isPopover bind:show display="inline-block">
<!-- TODO: make unclicked icon greyed out and hover and clicked filled -->
&nbsp;<button
type="button"
on:click={() => (show = !show)}
class="tooltip"
aria-label="input tooltip">
<span
class="icon-info"
aria-hidden="true"
style="font-size: var(--icon-size-small)" />
</button>
<svelte:fragment slot="list">
<div
class="dropped card u-max-width-250"
style:--card-border-radius="var(--border-radius-small)"
style:--p-card-padding=".75rem"
style:box-shadow="var(--shadow-large)">
<svelte:component this={popover} {...popoverProps} />
</div>
</svelte:fragment>
</Drop>
{/if}
</Label>
{/if}
<div
@@ -0,0 +1,104 @@
<script lang="ts">
import { Drop, Trim } from '$lib/components';
import FilePicker from '$lib/components/filePicker.svelte';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import type { Models } from '@appwrite.io/console';
import { Label } from '.';
export let label: string = null;
export let value: Models.File = null;
export let disabled = false;
export let optionalText: string = null;
export let tooltip: string = null;
export let isPopoverDefined = true;
let show = false;
function onSelect(file: Models.File) {
value = file;
}
</script>
<div>
{#if label}
<Label {optionalText} {tooltip} hide={!label}>
{label}{#if $$slots.popover && isPopoverDefined}
<Drop bind:show display="inline-block">
<!-- TODO: make unclicked icon greyed out and hover and clicked filled -->
&nbsp;<button
type="button"
on:click={() => (show = !show)}
class="tooltip"
aria-label="input tooltip">
<span
class="icon-info"
aria-hidden="true"
style="font-size: var(--icon-size-small)" />
</button>
<svelte:fragment slot="list">
<div
class="dropped card u-max-width-250"
style="--p-card-padding: .75rem; box-shadow:var(--shadow-large);">
<slot name="popover" />
</div>
</svelte:fragment>
</Drop>
{/if}
</Label>
{/if}
<div
role="region"
class="box is-no-shadow u-padding-24 is-border-dashed"
style:--box-border-radius="var(--border-radius-xsmall)">
<div class="upload-file-box">
<div class="upload-file-box-image">
<span class="icon-upload" aria-hidden="true" />
</div>
<div class="u-min-width-0 u-text-center">
<h5 class="upload-file-box-title heading-level-7 u-inline">
<span class="is-only-desktop">Select a file to upload</span>
<span class="is-not-desktop">Select a file to upload</span>
</h5>
</div>
<div class="u-flex u-main-center u-cross-center u-gap-16 u-flex-vertical-mobile">
Max file size: 1MB
<button
class="button is-secondary is-full-width-mobile"
type="button"
{disabled}
on:click={() => (show = true)}>
<span class="text">Browse</span>
</button>
</div>
{#if value}
{@const fileSize = humanFileSize(value.sizeOriginal)}
<ul class="upload-file-box-list u-min-width-0">
<li class="u-flex u-cross-center u-min-width-0">
<span class="icon-document" aria-hidden="true" />
<span class="upload-file-box-name u-min-width-0">
<Trim alternativeTrim>{value.name}</Trim>
</span>
<span
class="upload-file-box-size u-margin-inline-start-4 u-margin-inline-end-16">
{fileSize.value + fileSize.unit}
</span>
<button
on:click={() => (value = null)}
type="button"
class="button is-text is-only-icon u-margin-inline-start-auto"
aria-label="remove file"
style="--button-size:1.5rem;">
<span class="icon-x" aria-hidden="true" />
</button>
</li>
</ul>
{/if}
</div>
</div>
</div>
{#if show}
<FilePicker selectedFile={value?.$id} selectedBucket={value?.bucketId} bind:show {onSelect} />
{/if}
+1 -1
View File
@@ -86,7 +86,7 @@
{readonly}
{step}
type="number"
class="input-text"
class="input-text u-remove-input-number-buttons"
bind:value
bind:this={element}
on:invalid={handleInvalid}
+31 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteComponent, onMount } from 'svelte';
import { FormItem, Helper, Label } from '.';
import { Drop } from '$lib/components';
export let id: string;
export let label: string;
@@ -15,10 +16,14 @@
export let showPasswordButton = false;
export let minlength = 8;
export let maxlength: number = null;
export let popover: typeof SvelteComponent<unknown> = null;
export let popoverProps: Record<string, unknown> = {};
export let fullWidth = false;
let element: HTMLInputElement;
let error: string;
let showInPlainText = false;
let showPopover = false;
onMount(() => {
if (element && autofocus) {
@@ -46,9 +51,32 @@
}
</script>
<FormItem>
<FormItem {fullWidth}>
<Label {required} hide={!showLabel} for={id}>
{label}
{label}{#if popover}
<Drop isPopover bind:show={showPopover} display="inline-block">
<!-- TODO: make unclicked icon greyed out and hover and clicked filled -->
&nbsp;<button
type="button"
on:click={() => (showPopover = !showPopover)}
class="tooltip"
aria-label="input tooltip">
<span
class="icon-info"
aria-hidden="true"
style="font-size: var(--icon-size-small)" />
</button>
<svelte:fragment slot="list">
<div
class="dropped card u-max-width-250"
style:--card-border-radius="var(--border-radius-small)"
style:--p-card-padding=".75rem"
style:box-shadow="var(--shadow-large)">
<svelte:component this={popover} {...popoverProps} />
</div>
</svelte:fragment>
</Drop>
{/if}
</Label>
<div class="input-text-wrapper" style={showPasswordButton ? '--amount-of-buttons: 1' : ''}>
+31 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteComponent, onMount } from 'svelte';
import { FormItem, Helper, Label } from '.';
import { Drop } from '$lib/components';
export let label: string;
export let showLabel = true;
@@ -13,11 +14,15 @@
export let autofocus = false;
export let autocomplete = false;
export let maxlength: number = null;
export let popover: typeof SvelteComponent<unknown> = null;
export let popoverProps: Record<string, unknown> = {};
export let fullWidth = false;
const pattern = String.raw`^\+?[1-9]\d{1,14}$`;
let element: HTMLInputElement;
let error: string;
let show = false;
onMount(() => {
if (element && autofocus) {
@@ -45,9 +50,32 @@
}
</script>
<FormItem>
<FormItem {fullWidth}>
<Label {required} hide={!showLabel} for={id}>
{label}
{label}{#if popover}
<Drop isPopover bind:show display="inline-block">
<!-- TODO: make unclicked icon greyed out and hover and clicked filled -->
&nbsp;<button
type="button"
on:click={() => (show = !show)}
class="tooltip"
aria-label="input tooltip">
<span
class="icon-info"
aria-hidden="true"
style="font-size: var(--icon-size-small)" />
</button>
<svelte:fragment slot="list">
<div
class="dropped card u-max-width-250"
style:--card-border-radius="var(--border-radius-small)"
style:--p-card-padding=".75rem"
style:box-shadow="var(--shadow-large)">
<svelte:component this={popover} {...popoverProps} />
</div>
</svelte:fragment>
</Drop>
{/if}
</Label>
<div class="input-text-wrapper">
<input
+18
View File
@@ -47,6 +47,24 @@
<slot />
{/if}
</Label>
<!-- <label class="choice-item" for={id}>
<input
{id}
{name}
{disabled}
{required}
{value}
type="radio"
bind:group
bind:this={element}
on:invalid={handleInvalid} />
<div
class="choice-item-content u-cross-child-center"
class:u-width-full-line={fullWidth}>
<div class="choice-item-title">{label}</div>
<slot name="description" />
</div>
</label> -->
</div>
{#if error}
<Helper type="warning">{error}</Helper>
@@ -9,6 +9,7 @@
export let disabled = false;
export let autofocus = false;
export let isWithEndButton = true;
export let style: string = '';
const dispatch = createEventDispatcher();
let element: HTMLInputElement;
@@ -53,6 +54,7 @@
{placeholder}
{disabled}
{required}
{style}
type="search"
class="input-text"
bind:this={element}
+2 -2
View File
@@ -6,14 +6,14 @@
export let label: string | undefined = undefined;
export let optionalText: string | undefined = undefined;
export let showLabel = true;
export let value: string | number | boolean;
export let value: string | number | boolean | null;
export let placeholder = '';
export let required = false;
export let hideRequired = false;
export let disabled = false;
export let wrapperTag: FormItemTag = 'li';
export let options: {
value: string | boolean | number;
value: string | boolean | number | null;
label: string;
}[];
export let isMultiple = false;
@@ -0,0 +1,88 @@
<script lang="ts">
import { DropList } from '$lib/components';
import { SelectSearchCheckbox } from '..';
type Option = {
value: string;
label: string;
checked: boolean;
};
export let name: string;
export let tags: string[] = [];
export let placeholder: string;
export let options: Option[] = [];
let search = '';
let input: HTMLInputElement;
let show = false;
function addValue(option: Option) {
let tag = option.value.trim();
if (tag.length === 0 || tags.includes(tag)) return;
tags = [...tags, tag];
option.checked = true;
clearSearch();
}
function removeValue(option: Option) {
tags = tags.filter((tag) => tag !== option.value);
option.checked = false;
}
function clearSearch() {
search = '';
input.value = '';
}
$: filteredOptions = search
? options.filter((option) => option.label.toLowerCase().includes(search.toLowerCase()))
: options;
//TODO: Debounce search and open the droplist when the user starts typing
</script>
<DropList bind:show noStyle noArrow scrollable placement="bottom-end" fixed noMaxWidthList>
<button
class="tags-input u-position-relative u-cursor-pointer"
type="button"
on:click={() => (show = !show)}>
<div class="tags">
<ul class="tags-list">
{#each tags as tag}
<li class="tags-item">
<div class="input-tag">
<span class="tag-text">{tag}</span>
</div>
</li>
{/each}
</ul>
</div>
<input
class="tags-input-text u-cursor-text"
{placeholder}
bind:value={search}
bind:this={input} />
<span
class:icon-cheveron-up={show}
class:icon-cheveron-down={!show}
class="u-position-absolute u-inset-block-start-4 u-inset-inline-end-12"
aria-hidden="true"></span>
</button>
<svelte:fragment slot="list">
{#each filteredOptions as option (option.value + option.checked)}
<SelectSearchCheckbox
on:click={() => (option?.checked ? removeValue(option) : addValue(option))}
bind:value={option.checked}>
{option.label}
</SelectSearchCheckbox>
{:else}
<li class="drop-list-item">
<span class="text">There are no {name} that match your search</span>
</li>
{/each}
</svelte:fragment>
</DropList>
@@ -8,7 +8,7 @@
type Option = $$Generic<{
value: string | boolean | number;
label: string;
data?: string[];
data?: unknown[];
}>;
type OptionArray = Option[];
@@ -193,6 +193,17 @@
</li>
{/each}
</svelte:fragment>
<svelte:fragment slot="other">
{#if $$slots.listEnd}
<section class="drop-section">
<ul class="drop-list">
<li class="drop-list-item">
<slot name="listEnd" />
</li>
</ul>
</section>
{/if}
</svelte:fragment>
</DropList>
</li>
@@ -4,7 +4,6 @@
export let label: string;
export let id: string;
export let value = false;
export let required = false;
export let disabled = false;
let element: HTMLInputElement;
@@ -13,10 +12,6 @@
const handleInvalid = (event: Event) => {
event.preventDefault();
if (element.validity.valueMissing) {
error = 'This field is required';
return;
}
error = element.validationMessage;
};
@@ -31,7 +26,6 @@
<input
{id}
{disabled}
{required}
type="checkbox"
class="switch"
role="switch"
+13 -2
View File
@@ -13,6 +13,8 @@
export let readonly = false;
export let required = false;
export let tooltip: string = null;
export let validityRegex: RegExp = null;
export let validityMessage: string = null;
let value = '';
let element: HTMLInputElement;
@@ -29,11 +31,16 @@
/**
* Allow form submit and tab input switch
*/
if (value === '' && ['Enter', 'Tab'].includes(e.key)) {
if (value === '' && ['Enter', 'Tab', ','].includes(e.key)) {
return;
}
if (['Enter', 'Tab', ' '].includes(e.key)) {
if (['Enter', 'Tab', ' ', ','].includes(e.key)) {
e.preventDefault();
if (validityRegex && !validityRegex.test(value)) {
error = validityMessage ? validityMessage : 'Invalid value';
return;
}
addValue();
}
if (['Backspace', 'Delete'].includes(e.key)) {
@@ -44,6 +51,10 @@
};
const addValue = () => {
if (validityRegex && !validityRegex.test(value) && !!value) {
error = validityMessage ? validityMessage : 'Invalid value';
return;
}
let tag = value.trim();
if (tag.length === 0 || tags.includes(tag)) return;
+29 -2
View File
@@ -1,8 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteComponent, onMount } from 'svelte';
import { FormItem, FormItemPart, Helper, Label } from '.';
import NullCheckbox from './nullCheckbox.svelte';
import TextCounter from './textCounter.svelte';
import { Drop } from '$lib/components';
export let label: string = undefined;
export let optionalText: string | undefined = undefined;
@@ -22,9 +23,12 @@
export let maxlength: number = null;
export let tooltip: string = null;
export let isMultiple = false;
export let popover: typeof SvelteComponent<unknown> = null;
export let popoverProps: Record<string, unknown> = {};
let element: HTMLInputElement;
let error: string;
let show = false;
onMount(() => {
if (element && autofocus) {
@@ -74,7 +78,30 @@
<svelte:component this={wrapper} {fullWidth}>
{#if label}
<Label {required} {hideRequired} {tooltip} {optionalText} hide={!showLabel} for={id}>
{label}
{label}{#if popover}
<Drop isPopover bind:show display="inline-block">
<!-- TODO: make unclicked icon greyed out and hover and clicked filled -->
&nbsp;<button
type="button"
on:click={() => (show = !show)}
class="tooltip"
aria-label="input tooltip">
<span
class="icon-info"
aria-hidden="true"
style:font-size="var(--icon-size-small)" />
</button>
<svelte:fragment slot="list">
<div
class="dropped card u-max-width-250"
style:--p-card-padding=".75rem"
style:--card-border-radius="var(--border-radius-small)"
style:box-shadow="var(--shadow-large)">
<svelte:component this={popover} {...popoverProps} />
</div>
</svelte:fragment>
</Drop>
{/if}
</Label>
{/if}
+71
View File
@@ -0,0 +1,71 @@
<script lang="ts">
import { onMount } from 'svelte';
import { FormItem, Helper, Label } from '.';
export let label: string;
export let showLabel = true;
export let optionalText: string | undefined = undefined;
export let id: string;
export let value = '';
export let required = false;
export let min: string | number | undefined = undefined;
export let max: string | number | undefined = undefined;
export let disabled = false;
export let readonly = false;
export let autofocus = false;
export let autocomplete = false;
let element: HTMLInputElement;
let error: string;
onMount(() => {
if (element && autofocus) {
element.focus();
}
});
function handleInvalid(event: Event) {
event.preventDefault();
if (element.validity.valueMissing) {
error = 'This field is required';
return;
}
error = element.validationMessage;
}
$: if (value) {
error = null;
}
</script>
<FormItem>
<Label {required} {optionalText} hide={!showLabel} for={id}>
{label}
</Label>
<div class="input-text-wrapper" style="--amount-of-buttons:1; --button-size: 1rem">
<input
{id}
{disabled}
{readonly}
{required}
{min}
{max}
step="60"
autocomplete={autocomplete ? 'on' : 'off'}
type="time"
class="input-text"
style={disabled ? '' : 'cursor: pointer;'}
bind:value
bind:this={element}
on:invalid={handleInvalid}
on:click={function () {
this.showPicker();
}} />
</div>
{#if error}
<Helper type="warning">{error}</Helper>
{/if}
</FormItem>
+9 -6
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { tooltip as tooltipAction } from '$lib/actions/tooltip';
interface $$Props extends Partial<HTMLLabelElement> {
required?: boolean;
hideRequired?: boolean;
@@ -6,6 +8,7 @@
hide?: boolean;
tooltip?: string;
for?: string;
class?: string;
}
export let required: $$Props['required'] = false;
@@ -28,12 +31,12 @@
{/if}
{#if tooltip}
<button type="button" on:click|preventDefault class="tooltip" aria-label="input tooltip">
<button
type="button"
on:click|preventDefault
class="tooltip"
aria-label="input tooltip"
use:tooltipAction={{ content: tooltip }}>
<span class="icon-info" aria-hidden="true" style="font-size: var(--icon-size-small)" />
<span class="tooltip-popup" role="tooltip">
<p class="text">
{tooltip}
</p>
</span>
</button>
{/if}
+1
View File
@@ -1,3 +1,4 @@
export { default as Pill } from './pill.svelte';
export { default as SelectSearchItem } from './selectSearchItem.svelte';
export { default as Flag } from './flag.svelte';
export { default as SelectSearchCheckbox } from './selectSearchCheckbox.svelte';
@@ -0,0 +1,14 @@
<script lang="ts">
// export let data: string[] = [];
export let value = false;
</script>
<li class="drop-list-item">
<button
class="drop-button u-flex u-cross-center u-gap-12"
on:click|preventDefault
type="button">
<input type="checkbox" class="is-small" bind:checked={value} />
<slot />
</button>
</li>

Some files were not shown because too many files have changed in this diff Show More