Merge branch 'feat-pink-v2' into 'devkeys-pink2'.

This commit is contained in:
Darshan
2025-05-09 09:29:04 +05:30
25 changed files with 717 additions and 218 deletions
+1 -1
View File
@@ -27,7 +27,7 @@
"@appwrite.io/pink-icons": "0.25.0",
"@appwrite.io/pink-icons-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@bd21ff7f",
"@appwrite.io/pink-legacy": "^1.0.3",
"@appwrite.io/pink-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@504c653",
"@appwrite.io/pink-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@de27ca4",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^8.38.0",
"@stripe/stripe-js": "^3.5.0",
+12 -12
View File
@@ -27,8 +27,8 @@ importers:
specifier: ^1.0.3
version: 1.0.3
'@appwrite.io/pink-svelte':
specifier: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@504c653
version: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@504c653(react-dom@18.3.1(react@18.3.1))(svelte@5.25.3)
specifier: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@de27ca4
version: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@de27ca4(react-dom@18.3.1(react@18.3.1))(svelte@5.25.3)
'@popperjs/core':
specifier: ^2.11.8
version: 2.11.8
@@ -258,14 +258,14 @@ packages:
resolution: {tarball: https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@f76aff1}
version: 1.2.1
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@504c653542de13ba8b4815805806f301df92790a':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@504c653542de13ba8b4815805806f301df92790a}
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@bd21ff7f':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@bd21ff7f}
version: 1.0.0-next.7
peerDependencies:
svelte: ^4.0.0
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@bd21ff7f':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@bd21ff7f}
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@de27ca407599e51b378814b421fb63625cb98c10':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@de27ca407599e51b378814b421fb63625cb98c10}
version: 1.0.0-next.7
peerDependencies:
svelte: ^4.0.0
@@ -279,8 +279,8 @@ packages:
'@appwrite.io/pink-legacy@1.0.3':
resolution: {integrity: sha512-GGde5fmPhs+s6/3aFeMPc/kKADG/gTFkYQSy6oBN8pK0y0XNCLrZZgBv+EBbdhwdtqVEWXa0X85Mv9w7jcIlwQ==}
'@appwrite.io/pink-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@504c653':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@504c653}
'@appwrite.io/pink-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@de27ca4':
resolution: {tarball: https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@de27ca4}
version: 1.0.0-next.85
peerDependencies:
react-dom: ^18.0.0
@@ -3627,11 +3627,11 @@ snapshots:
'@appwrite.io/console@https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@f76aff1': {}
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@504c653542de13ba8b4815805806f301df92790a(svelte@5.25.3)':
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@bd21ff7f(svelte@5.25.3)':
dependencies:
svelte: 5.25.3
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@bd21ff7f(svelte@5.25.3)':
'@appwrite.io/pink-icons-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@de27ca407599e51b378814b421fb63625cb98c10(svelte@5.25.3)':
dependencies:
svelte: 5.25.3
@@ -3644,9 +3644,9 @@ snapshots:
'@appwrite.io/pink-icons': 1.0.0
the-new-css-reset: 1.11.3
'@appwrite.io/pink-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@504c653(react-dom@18.3.1(react@18.3.1))(svelte@5.25.3)':
'@appwrite.io/pink-svelte@https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@de27ca4(react-dom@18.3.1(react@18.3.1))(svelte@5.25.3)':
dependencies:
'@appwrite.io/pink-icons-svelte': https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@504c653542de13ba8b4815805806f301df92790a(svelte@5.25.3)
'@appwrite.io/pink-icons-svelte': https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@de27ca407599e51b378814b421fb63625cb98c10(svelte@5.25.3)
'@floating-ui/dom': 1.6.13
'@melt-ui/pp': 0.3.2(@melt-ui/svelte@0.86.6(svelte@5.25.3))(svelte@5.25.3)
'@melt-ui/svelte': 0.86.6(svelte@5.25.3)
+3
View File
@@ -353,6 +353,9 @@ export enum Submit {
FileCreate = 'submit_file_create',
FileDelete = 'submit_file_delete',
FileUpdatePermissions = 'submit_file_update_permissions',
FileTokenCreate = 'submit_file_token',
FileTokenDelete = 'submit_file_delete',
FileTokenUpdate = 'submit_file_update_expiry',
BudgetCapUpdate = 'submit_budget_cap_update',
BudgetAlertsUpdate = 'submit_budget_alert_conditions_update',
CreditRedeem = 'submit_credit_redeem',
+1
View File
@@ -82,3 +82,4 @@ export { default as BottomSheet } from './bottom-sheet/index';
export { default as Confirm } from './confirm.svelte';
export { default as UsageCard } from './usageCard.svelte';
export { default as ViewToggle } from './viewToggle.svelte';
export { default as ExpirationInput } from './expirationInput.svelte';
+34 -12
View File
@@ -12,6 +12,7 @@
export let showTeam: boolean;
export let showLabel: boolean;
export let showCustom: boolean;
export let hideOnClick: boolean = false;
export let groups: Writable<Map<string, Permission>>;
const dispatch = createEventDispatcher();
@@ -19,31 +20,52 @@
<Popover let:toggle padding="none" placement="bottom-start">
<slot {toggle} />
<svelte:fragment slot="tooltip">
<svelte:fragment slot="tooltip" let:hide>
<ActionMenu.Root>
<ActionMenu.Item.Button
disabled={$groups.has('any')}
on:click={() => dispatch('create', ['any'])}>
on:click={(e) => {
if (hideOnClick) hide(e);
dispatch('create', ['any']);
}}>
Any
</ActionMenu.Item.Button>
<ActionMenu.Item.Button
disabled={$groups.has('guests')}
on:click={() => dispatch('create', ['guests'])}>
on:click={(e) => {
if (hideOnClick) hide(e);
dispatch('create', ['guests']);
}}>
All guests
</ActionMenu.Item.Button>
<ActionMenu.Item.Button
disabled={$groups.has('users')}
on:click={() => dispatch('create', ['users'])}>
on:click={(e) => {
if (hideOnClick) hide(e);
dispatch('create', ['users']);
}}>
All users
</ActionMenu.Item.Button>
<ActionMenu.Item.Button on:click={() => (showUser = true)}
>Select users</ActionMenu.Item.Button>
<ActionMenu.Item.Button on:click={() => (showTeam = true)}
>Select teams</ActionMenu.Item.Button>
<ActionMenu.Item.Button on:click={() => (showLabel = true)}
>Label</ActionMenu.Item.Button>
<ActionMenu.Item.Button on:click={() => (showCustom = true)}
>Custom permission</ActionMenu.Item.Button>
<ActionMenu.Item.Button
on:click={(e) => {
showUser = true;
if (hideOnClick) hide(e);
}}>Select users</ActionMenu.Item.Button>
<ActionMenu.Item.Button
on:click={(e) => {
showTeam = true;
if (hideOnClick) hide(e);
}}>Select teams</ActionMenu.Item.Button>
<ActionMenu.Item.Button
on:click={(e) => {
showLabel = true;
if (hideOnClick) hide(e);
}}>Label</ActionMenu.Item.Button>
<ActionMenu.Item.Button
on:click={(e) => {
showCustom = true;
if (hideOnClick) hide(e);
}}>Custom permission</ActionMenu.Item.Button>
</ActionMenu.Root>
</svelte:fragment>
</Popover>
@@ -21,6 +21,7 @@
import type { PinkColumn } from '$lib/helpers/types';
export let withCreate = false;
export let hideOnClick = false;
export let permissions: string[] = [];
let showUser = false;
@@ -127,11 +128,11 @@
}
const columns: PinkColumn[] = [
{ id: 'role', width: { min: 100 } },
{ id: 'create', width: { min: 100 }, hide: !withCreate },
{ id: 'read', width: { min: 100 } },
{ id: 'update', width: { min: 100 } },
{ id: 'delete', width: { min: 100 } },
{ id: 'role', width: { min: 80 } },
{ id: 'create', width: { min: 80 }, hide: !withCreate },
{ id: 'read', width: { min: 80 } },
{ id: 'update', width: { min: 80 } },
{ id: 'delete', width: { min: 80 } },
{ id: 'action', width: 40 }
];
</script>
@@ -195,6 +196,7 @@
bind:showTeam
bind:showUser
{groups}
{hideOnClick}
on:create={create}
let:toggle>
<Button secondary on:click={toggle}>
@@ -213,6 +215,7 @@
bind:showTeam
bind:showUser
{groups}
{hideOnClick}
on:create={create}
let:toggle>
<Button compact icon on:click={toggle}>
+1
View File
@@ -34,6 +34,7 @@ export enum Dependencies {
DOCUMENTS = 'dependency:documents',
BUCKET = 'dependency:bucket',
FILE = 'dependency:file',
FILE_TOKENS = 'dependency:file_tokens',
FILES = 'dependency:files',
FUNCTION = 'dependency:function',
FUNCTION_DOMAINS = 'dependency:function_domains',
+39 -36
View File
@@ -1,12 +1,10 @@
<script lang="ts">
import { Input } from '@appwrite.io/pink-svelte';
import { onMount } from 'svelte';
import { Input, Layout, Selector } from '@appwrite.io/pink-svelte';
export let label: string = undefined;
export let id: string;
export let name: string = id;
export let helper: string = undefined;
export let value = '';
export let placeholder = '';
export let label: string;
export let value: string;
export let required = false;
export let nullable = false;
export let disabled = false;
@@ -16,18 +14,23 @@
export let step: number | 'any' = 0.001;
let error: string;
let element: HTMLInputElement;
function handleInvalid(event: Event) {
event.preventDefault();
const inputNode = event.currentTarget as HTMLInputElement;
if (inputNode.validity.valueMissing) {
error = 'This field is required';
return;
onMount(() => {
if (element && autofocus) {
element.focus();
}
});
error = inputNode.validationMessage;
let prevValue = '';
function handleNullChange(e: CustomEvent<boolean>) {
const isNull = e.detail;
if (isNull) {
prevValue = value;
value = null;
} else {
value = prevValue;
}
}
$: if (value) {
@@ -35,24 +38,24 @@
}
</script>
<Input.DateTime
{id}
{name}
{placeholder}
{disabled}
{required}
{label}
{step}
{nullable}
{readonly}
autofocus={autofocus || undefined}
autocomplete={autocomplete ? 'on' : 'off'}
helper={error || helper}
state={error ? 'error' : 'default'}
on:invalid={handleInvalid}
on:input
bind:value>
<slot name="start" slot="start" />
<slot name="info" slot="info" />
<slot name="end" slot="end" />
</Input.DateTime>
<Layout.Stack gap="s" direction="row">
<Input.DateTime
{id}
{label}
{disabled}
{readonly}
{required}
{value}
{step}
helper={error}
autocomplete={autocomplete ? 'on' : 'off'}>
{#if nullable}
<Selector.Checkbox
size="s"
slot="end"
label="NULL"
checked={value === null}
on:change={handleNullChange} />
{/if}
</Input.DateTime>
</Layout.Stack>
+2 -2
View File
@@ -4,8 +4,8 @@
export let id: string;
export let label: string | undefined = undefined;
export let helper: string | undefined = undefined;
export let value: string | number | boolean | null;
export let helper: string | undefined = undefined;
export let optionalText: string | number | boolean | null | undefined = undefined;
export let placeholder = '';
export let required = false;
@@ -55,8 +55,8 @@
{placeholder}
{disabled}
{isSearchable}
{required}
helper={error ?? helper}
{required}
state={error ? 'error' : 'default'}
on:invalid={handleInvalid}
on:input
+8 -2
View File
@@ -15,13 +15,17 @@ if (browser) {
dayjs.extend(relativeTime);
}
export const toLocaleDate = (datetime: string) => {
export const toLocaleDate = (datetime: string, format: 'dd mm yyyy' | 'default' = 'default') => {
const date = new Date(datetime);
if (isNaN(date.getTime())) {
return 'n/a';
}
if (format === 'dd mm yyyy') {
return `${date.getDate().toString().padStart(2, '0')} ${date.toLocaleString('en', { month: 'short' })} ${date.getFullYear()}`;
}
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
@@ -33,6 +37,7 @@ export const toLocaleDate = (datetime: string) => {
export const toLocaleDateTime = (
datetime: string | number,
is12HourFormat: boolean = false,
timeZone: string | undefined = undefined
) => {
const date = new Date(datetime);
@@ -48,7 +53,8 @@ export const toLocaleDateTime = (
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hourCycle: 'h23'
hourCycle: is12HourFormat ? 'h12' : 'h23',
...(is12HourFormat && { hour12: true })
};
return date.toLocaleDateString('en', options);
+3 -1
View File
@@ -20,7 +20,8 @@ import {
Teams,
Users,
Vcs,
Sites
Sites,
Tokens
} from '@appwrite.io/console';
import { Billing } from '../sdk/billing';
import { Backups } from '../sdk/backups';
@@ -56,6 +57,7 @@ const sdkForProject = {
project: new Project(clientProject),
projectApi: new ProjectApi(clientProject),
storage: new Storage(clientProject),
tokens: new Tokens(clientProject),
teams: new Teams(clientProject),
users: new Users(clientProject),
vcs: new Vcs(clientProject),
@@ -10,11 +10,26 @@
import { base } from '$app/paths';
import { project } from '../../store';
import { wizard } from '$lib/stores/wizard';
import { provider, providerParams } from './wizard/store';
import { ID, type Models } from '@appwrite.io/console';
import { provider, providerParams, providerType } from './wizard/store';
import { ID, MessagingProviderType, type Models } from '@appwrite.io/console';
import { Providers } from '../provider.svelte';
import { Button, Form } from '$lib/elements/forms';
import { Fieldset, Layout } from '@appwrite.io/pink-svelte';
import {
Fieldset,
Layout,
Typography,
ActionList,
Accordion,
Card
} from '@appwrite.io/pink-svelte';
import { providers } from '$routes/(console)/project-[project]/messaging/providers/store';
import { getProviderText } from '$routes/(console)/project-[project]/messaging/helper';
import { newMemberModal } from '$lib/stores/organization';
import CreateMember from '$routes/(console)/organization-[organization]/createMember.svelte';
import { IconBookOpen, IconInfo, IconUserGroup } from '@appwrite.io/pink-icons-svelte';
import { getProviderDisplayNameAndIcon } from '$routes/(console)/project-[project]/messaging/provider.svelte';
let open = false;
async function create() {
try {
@@ -167,7 +182,7 @@
});
</script>
<Wizard title="Create provider">
<Wizard title="Create provider" columnSize="l">
<Form onSubmit={create} isModal={false}>
<Layout.Stack gap="xxl">
<Fieldset legend="Provider">
@@ -181,4 +196,47 @@
</Layout.Stack>
</Layout.Stack>
</Form>
<svelte:fragment slot="aside">
<Card.Base padding="s">
<Layout.Stack gap="s">
<Typography.Text variant="m-500">Need a hand?</Typography.Text>
<ActionList.Root>
{#if providers[$providerType].providers[$provider].needAHand}
{@const needAHand = providers[$providerType].providers[$provider].needAHand}
<ActionList.Item.Accordion
hasDivider
title={`How to enable ${getProviderText($provider)} ${
$providerType == MessagingProviderType.Push ||
$providerType == MessagingProviderType.Sms
? `${getProviderDisplayNameAndIcon($provider).displayName} notifications`
: getProviderText($provider)
}?`}
icon={IconInfo}>
<Layout.Stack>
{#each needAHand as p}
<p>
{@html p}
</p>
{/each}
</Layout.Stack>
</ActionList.Item.Accordion>
{/if}
<ActionList.Item.Anchor
hasDivider
href={`https://appwrite.io/docs/products/messaging/${$provider}`}
title="Read the guide in the docs"
icon={IconBookOpen} />
<ActionList.Item.Button
on:click={() => {
$newMemberModal = true;
}}
title="Invite a team member"
icon={IconUserGroup} />
</ActionList.Root>
</Layout.Stack>
</Card.Base>
</svelte:fragment>
{#if $newMemberModal}
<CreateMember bind:showCreate={$newMemberModal} />
{/if}
</Wizard>
@@ -4,8 +4,7 @@
import { providers } from '../store';
import { providerType, provider, providerParams } from './store';
import SettingsFormList from '../settingsFormList.svelte';
import { newMemberModal } from '$lib/stores/organization';
import CreateMember from '$routes/(console)/organization-[organization]/createMember.svelte';
import { MessagingProviderType } from '@appwrite.io/console';
import {
Collapsible,
@@ -17,7 +16,6 @@
let files: Record<string, FileList> = {};
$: inputs = providers[$providerType].providers[$provider].configure;
let open = false;
$: {
files;
@@ -65,7 +63,6 @@
</script>
<WizardStep {beforeSubmit}>
<svelte:fragment slot="title">Settings</svelte:fragment>
<svelte:fragment slot="subtitle">
Set up the credentials below to enable {providers[$providerType].providers[$provider].title}
for sending
@@ -73,122 +70,4 @@
</svelte:fragment>
<SettingsFormList bind:files {inputs} bind:params={$providerParams[$provider]} />
<div class="need-a-hand u-flex-vertical u-gap-8">
<p class="body-text-2 u-bold u-margin-block-start-48">Need a hand?</p>
<div>
{#if providers[$providerType].providers[$provider].needAHand}
{@const needAHand = providers[$providerType].providers[$provider].needAHand}
<div
class="how-to-enable u-padding-inline-8 u-padding-0"
style={!open ? '' : 'background-color: hsl(var(--p-bg-color-hover));'}>
<Collapsible>
<CollapsibleItem withIndentation bind:open>
<svelte:fragment slot="beforetitle">
<div class="u-flex u-cross-center u-gap-16">
<div class="avatar is-size-small">
<span
class="icon-info"
style:--p-text-size="1.25rem"
aria-hidden="true"></span>
</div>
<span class="title body-text-2 u-small" class:u-bold={open}
>How to enable {getProviderText($provider)}
{#if $providerType == MessagingProviderType.Push}notifications{:else}{getProviderText(
$provider
)}{/if} service?</span>
</div>
</svelte:fragment>
<div class="u-flex-vertical u-gap-16">
{#each needAHand as p}
<p>
{@html p}
</p>
{/each}
</div>
</CollapsibleItem>
</Collapsible>
</div>
<div class="u-margin-block-start-4 u-sep-block-end"></div>
{/if}
<ClickableList>
<ClickableListItem
href={`https://appwrite.io/docs/products/messaging/${$provider}`}
external>
<div class="u-flex u-cross-center u-main-space-between">
<div class="u-flex u-cross-center u-gap-16">
<div class="avatar is-size-small">
<span
class="icon-book-open"
style:--p-text-size="1.25rem"
aria-hidden="true"></span>
</div>
<p>Read the full guide in the documentation</p>
</div>
<span class="icon-arrow-sm-right u-font-size-20" aria-hidden="true"></span>
</div>
</ClickableListItem>
<ClickableListItem on:click={() => ($newMemberModal = true)}>
<div class="u-flex u-cross-center u-main-space-between">
<div class="u-flex u-cross-center u-gap-16">
<div class="avatar is-size-small">
<span
class="icon-user-group"
style:--p-text-size="1.25rem"
aria-hidden="true"></span>
</div>
<p>Invite a team member to complete this step</p>
</div>
<span class="icon-arrow-sm-right u-font-size-20" aria-hidden="true"></span>
</div>
</ClickableListItem>
</ClickableList>
</div>
</div>
</WizardStep>
{#if $newMemberModal}
<CreateMember bind:showCreate={$newMemberModal} />
{/if}
<style lang="scss">
.need-a-hand {
--p-bg-color-hover: var(--color-neutral-5);
.how-to-enable {
border-radius: var(--border-radius-small);
&:hover,
&:focus {
background-color: hsl(var(--p-bg-color-hover));
.title {
font-weight: 600;
}
}
}
:global(.clickable-list) {
--border: var(--color-neutral-5);
--p-clickable-button-bg-color-hover: var(--border);
:global(.theme-dark) & {
--border: var(--color-neutral-85);
}
}
:global(.clickable-list-button) {
padding-inline: 0.5rem;
}
:global(.theme-dark) & {
--p-bg-color-hover: var(--color-neutral-85);
}
}
:global(.clickable-list-button):hover p,
:global(.clickable-list-button):focus p {
font-weight: 600;
}
</style>
@@ -62,9 +62,15 @@
];
export let value: string | null = null;
<<<<<<<< HEAD:src/routes/(console)/project-[project]/overview/(components)/expirationInput.svelte
export let keyType: 'api' | 'dev' = 'api';
const options = keyType === 'api' ? defaultOptions : limitedOptions;
========
export let dateSelectorLabel: string | undefined = undefined;
export let selectorLabel: string | undefined = 'Expiration Date';
export let resourceType: string | 'key' | 'token' | undefined = 'key';
>>>>>>>> feat-pink-v2:src/lib/components/expirationInput.svelte
function initExpirationSelect() {
if (value === null || !isValidDate(value)) {
@@ -85,6 +91,10 @@
}
let expirationSelect = initExpirationSelect();
let expirationCustom: string | null = value ?? null;
$: helper =
expirationSelect !== 'custom' && expirationSelect !== null
? `Your ${resourceType} will expire in ${toLocaleDate(value)}`
: null;
$: {
if (!isSameDay(new Date(expirationSelect), new Date(value))) {
@@ -98,6 +108,7 @@
: undefined;
</script>
<<<<<<<< HEAD:src/routes/(console)/project-[project]/overview/(components)/expirationInput.svelte
<InputSelect
required
{helper}
@@ -106,6 +117,10 @@
label="Expiration date"
bind:value={expirationSelect} />
========
<InputSelect bind:value={expirationSelect} {options} id="preset" label={selectorLabel} {helper}>
</InputSelect>
>>>>>>>> feat-pink-v2:src/lib/components/expirationInput.svelte
{#if expirationSelect === 'custom'}
<InputDateTime required id="expire" label="" bind:value={expirationCustom} />
<InputDateTime required id="expire" label={dateSelectorLabel} bind:value={expirationCustom} />
{/if}
@@ -25,6 +25,7 @@
try {
await sdk.forConsole.projects.update($project.$id, name);
await invalidate(Dependencies.PROJECT);
await invalidate(Dependencies.ORGANIZATION);
addNotification({
type: 'success',
message: 'Project name has been updated'
@@ -7,11 +7,13 @@ import { error } from '@sveltejs/kit';
export const load: LayoutLoad = async ({ params, depends }) => {
depends(Dependencies.FILE);
depends(Dependencies.FILE_TOKENS);
try {
return {
header: Header,
breadcrumbs: Breadcrumbs,
tokens: await sdk.forProject.tokens.list(params.bucket, params.file),
file: await sdk.forProject.storage.getFile(params.bucket, params.file)
};
} catch (e) {
@@ -1,8 +1,8 @@
<script lang="ts">
import { CardGrid, BoxAvatar, CopyInput } from '$lib/components';
import { CardGrid, BoxAvatar, CopyInput, Empty } from '$lib/components';
import { Container } from '$lib/layout';
import { Button } from '$lib/elements/forms';
import { file } from './store';
import { columns, file, tokens } from './store';
import { toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
@@ -15,27 +15,53 @@
import { Dependencies } from '$lib/constants';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { bucket } from '../store';
import { Alert, Typography } from '@appwrite.io/pink-svelte';
import {
ActionMenu,
Alert,
Button as PinkButton,
InteractiveText,
Layout,
Icon,
Popover,
Table,
Typography
} from '@appwrite.io/pink-svelte';
import {
IconChevronRight,
IconDotsHorizontal,
IconDuplicate,
IconKey,
IconPencil,
IconPlus,
IconTrash
} from '@appwrite.io/pink-icons-svelte';
import FileTokensCopyUrl from './fileTokensCopyUrl.svelte';
import ManageFileTokenModal, { cleanFormattedDate } from './manageFileToken.svelte';
import { type Models, Permission, Role } from '@appwrite.io/console';
import { isSmallViewport } from '$lib/stores/viewport';
import { Menu } from '$lib/components/menu';
let showFileAlert = true;
onMount(async () => {
filePermissions = $file?.$permissions;
});
let showDelete = false;
let filePermissions = $file?.$permissions;
let arePermsDisabled = true;
let filePermissions = $file?.$permissions;
let showManageToken = false;
let tokenDeleteMode = false;
let showCopyUrlModal = false;
let tokenPermissionsMode = false;
let selectedFileToken: Models.ResourceToken | null = null;
const getPreview = (fileId: string) =>
sdk.forProject.storage.getFilePreview($file.bucketId, fileId, 640, 300).toString() +
'&mode=admin';
const getView = (fileId: string) =>
sdk.forProject.storage.getFileView($file.bucketId, fileId).toString() + '&mode=admin';
$: if (filePermissions) {
if (symmetricDifference(filePermissions, $file.$permissions).length) {
arePermsDisabled = false;
} else arePermsDisabled = true;
arePermsDisabled = !symmetricDifference(filePermissions, $file.$permissions).length;
}
function downloadFile() {
@@ -66,8 +92,98 @@
type: 'error'
});
trackError(error, Submit.FileUpdatePermissions);
} finally {
selectedFileToken = null;
}
}
async function deleteFileToken(token: Models.ResourceToken) {
try {
await sdk.forProject.tokens.delete(token.$id);
await invalidate(Dependencies.FILE_TOKENS);
addNotification({
message: 'File token deleted',
type: 'success'
});
trackEvent(Submit.FileTokenDelete);
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.FileTokenDelete);
} finally {
selectedFileToken = null;
}
}
async function createFileToken(expiry: string) {
try {
await sdk.forProject.tokens.createFileToken($file.bucketId, $file.$id, expiry, [
Permission.read(Role.any())
]);
await invalidate(Dependencies.FILE_TOKENS);
addNotification({
message: 'File token created',
type: 'success'
});
trackEvent(Submit.FileTokenCreate);
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.FileTokenCreate);
} finally {
selectedFileToken = null;
}
}
async function updateFileToken(fileToken: Models.ResourceToken) {
try {
await sdk.forProject.tokens.update(
selectedFileToken.$id,
fileToken.expire ? fileToken.expire : null,
fileToken.$permissions
);
await invalidate(Dependencies.FILE_TOKENS);
addNotification({
message: 'File token updated',
type: 'success'
});
trackEvent(Submit.FileTokenUpdate);
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.FileTokenUpdate);
} finally {
selectedFileToken = null;
}
}
function getPermissionGroups(permissions: string[]): string {
const map = {
read: 'Read',
update: 'Update',
delete: 'Delete'
};
const groups = permissions.reduce((set, perm) => {
const match = perm.match(/^(\w+)\(/);
const key = match?.[1];
if (key && map[key]) set.add(map[key]);
return set;
}, new Set<string>());
return [...groups].join(', ');
}
onMount(async () => {
filePermissions = $file?.$permissions;
});
</script>
<Container>
@@ -116,6 +232,120 @@
</svelte:fragment>
</CardGrid>
<div class="tokens-section">
<CardGrid hideOverflow>
<svelte:fragment slot="title">File tokens</svelte:fragment>
Use tokens to provide public access to the file.
<svelte:fragment slot="aside">
{#if $tokens.total}
<Layout.Stack
justifyContent="flex-end"
direction={$isSmallViewport ? 'column' : 'row'}>
<Button secondary on:click={() => (showManageToken = true)}>
<Icon size="s" icon={IconPlus} />
<span class="text">Create token</span>
</Button>
</Layout.Stack>
<Table.Root columns={$columns} let:root>
<svelte:fragment slot="header" let:root>
{#each $columns as { id, title } (id)}
<Table.Header.Cell column={id} {root}
>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each $tokens.tokens as token}
<Table.Row.Base {root}>
<Table.Cell column="created" {root}
>{toLocaleDate(token.$createdAt)}</Table.Cell>
<Table.Cell column="expiry" {root}
>{token.expire
? cleanFormattedDate(token.expire)
: 'never'}</Table.Cell>
<Table.Cell column="last_accessed" {root}
>{token.accessedAt
? cleanFormattedDate(token.accessedAt, true)
: 'never'}</Table.Cell>
<Table.Cell column="permissions" {root}>
{#if token.$permissions.length}
<Typography.Text truncate slot="tooltip">
{getPermissionGroups(token.$permissions)}
</Typography.Text>
{:else}
none
{/if}
</Table.Cell>
<Table.Cell column="actions" {root}>
<Layout.Stack alignItems="flex-end">
<Menu>
<PinkButton.Button icon variant="ghost">
<Icon size="s" icon={IconDotsHorizontal} />
</PinkButton.Button>
<svelte:fragment slot="menu" let:toggle>
<ActionMenu.Root>
<ActionMenu.Root noPadding>
<ActionMenu.Item.Button
on:click={(e) => {
toggle(e);
showCopyUrlModal = true;
selectedFileToken = token;
}}
leadingIcon={IconDuplicate}>
Copy URL
</ActionMenu.Item.Button>
</ActionMenu.Root>
<ActionMenu.Item.Button
leadingIcon={IconPencil}
on:click={(e) => {
toggle(e);
showManageToken = true;
selectedFileToken = token;
}}>
Edit expiry
</ActionMenu.Item.Button>
<ActionMenu.Item.Button
leadingIcon={IconKey}
on:click={(e) => {
toggle(e);
showManageToken = true;
tokenPermissionsMode = true;
selectedFileToken = token;
}}>
Edit Permissions
</ActionMenu.Item.Button>
<ActionMenu.Item.Button
status="danger"
leadingIcon={IconTrash}
on:click={async (e) => {
toggle(e);
tokenDeleteMode = true;
showManageToken = true;
selectedFileToken = token;
}}>
Delete
</ActionMenu.Item.Button>
</ActionMenu.Root>
</svelte:fragment>
</Menu>
</Layout.Stack>
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
{:else}
<Empty on:click={() => (showManageToken = true)}
>Create new file token</Empty>
{/if}
</svelte:fragment>
</CardGrid>
</div>
<CardGrid>
<svelte:fragment slot="title">Permissions</svelte:fragment>
Assign read or write permissions at the bucket level or file level. If bucket level permissions
@@ -135,8 +365,8 @@
{:else}
<Alert.Inline status="info" title="File security is disabled">
<Typography.Text>
If you want to assign document permissions. Go to Bucket settings and
enable file security. Otherwise, only Bucket permissions will be used.
If you want to assign file permissions. Go to Bucket settings and enable
file security. Otherwise, only Bucket permissions will be used.
</Typography.Text>
</Alert.Inline>
{/if}
@@ -175,3 +405,20 @@
</Container>
<Delete bind:showDelete />
<ManageFileTokenModal
bind:show={showManageToken}
bind:isDelete={tokenDeleteMode}
bind:fileToken={selectedFileToken}
bind:isUpdatePermissions={tokenPermissionsMode}
on:created={({ detail: expiry }) => createFileToken(expiry)}
on:updated={({ detail: token }) => updateFileToken(token)}
on:deleted={async () => await deleteFileToken(selectedFileToken)} />
<FileTokensCopyUrl file={$file} bind:token={selectedFileToken} bind:show={showCopyUrlModal} />
<style>
:global(.tokens-section div) {
overflow: auto;
}
</style>
@@ -13,12 +13,13 @@
let error: string;
const deleteFile = async () => {
try {
const fileName = $file.name;
await sdk.forProject.storage.deleteFile($file.bucketId, $file.$id);
showDelete = false;
await goto(`${base}/project-${page.params.project}/storage/bucket-${$file.bucketId}`);
addNotification({
type: 'success',
message: `${$file.name} has been deleted`
message: `${fileName} has been deleted`
});
trackEvent(Submit.FileDelete);
} catch (error) {
@@ -0,0 +1,141 @@
<script lang="ts">
import { cachedJwts } from './store';
import { sdk } from '$lib/stores/sdk';
import type { Models } from '@appwrite.io/console';
import { Modal, Card, CopyInput } from '$lib/components';
import { Button, InputSelect, InputText } from '$lib/elements/forms';
import {
Alert,
Code,
Icon,
Image,
Input,
Layout,
Tabs,
Tooltip,
Typography
} from '@appwrite.io/pink-svelte';
export let show = false;
export let file: Models.File;
export let token: Models.ResourceToken;
let error = null;
let codeSnippets = {};
let selectedTab = 'Preview';
async function getJwtForToken(): Promise<string> {
// Return cached, if it's valid based on the token's expiration.
if ($cachedJwts.has(token.$id)) {
const cachedJwt = $cachedJwts.get(token.$id);
const isExpired = !!token.expire && new Date(token.expire) < new Date();
if (!isExpired) return cachedJwt;
}
const { jwt } = await sdk.forProject.tokens.getJWT(token.$id);
// update the cached object!
cachedJwts.update((map) => {
map.set(token.$id, jwt);
return map;
});
return jwt;
}
function copyPreviewWithToken(
token: string,
method: 'preview' | 'view' | 'download' = 'preview'
) {
error = false;
try {
let url: string;
if (method === 'view') {
url = sdk.forProject.storage.getFileView(file.bucketId, file.$id);
} else if (method === 'download') {
url = sdk.forProject.storage.getFileDownload(file.bucketId, file.$id);
} else {
url = sdk.forProject.storage.getFilePreview(file.bucketId, file.$id);
}
url = `${url}&token=${token}`;
return url;
} catch (ignore) {
error = true;
}
}
async function setCodeSnippets() {
const jwt = await getJwtForToken();
return {
Preview: `${copyPreviewWithToken(jwt)}`,
View: `${copyPreviewWithToken(jwt, 'view')}`,
Download: `${copyPreviewWithToken(jwt, 'download')}`
};
}
function getMoreInfo(): string {
let message = '';
switch (selectedTab.toLowerCase()) {
case 'preview':
message = 'Apply transformations or filters. Good for thumbnails or previews.';
break;
case 'view':
message =
'Open the file in the browser (if supported). Works well for images and documents.';
break;
case 'download':
message = 'Download the file directly. Use when users need a local copy.';
break;
default:
message = '';
}
return message;
}
$: if (show) {
setCodeSnippets().then((snippets) => (codeSnippets = snippets));
}
</script>
<Modal title="Copy File URL" bind:show hideFooter>
<span slot="description"> Use the token-based URL below to access this file securely. </span>
{#if error}
<Alert.Inline title="Error" status="error">
Something went wrong, could not generate the URL.
</Alert.Inline>
{:else}
{@const tokenUrl = codeSnippets[selectedTab]}
<Layout.Stack gap="xl">
<Tabs.Root let:root stretch>
{#each ['Preview', 'View', 'Download'] as cat}
<Tabs.Item.Button
{root}
on:click={() => (selectedTab = cat)}
active={selectedTab === cat}>
{cat}
</Tabs.Item.Button>
{/each}
</Tabs.Root>
<Typography.Text variant="m-400">
{getMoreInfo()}
</Typography.Text>
{#if !token.expire}
<Alert.Inline title="No expiration date" status="warning">
This token doesn't expire. Be cautious when sharing links.
</Alert.Inline>
{/if}
<CopyInput value={tokenUrl} />
</Layout.Stack>
{/if}
<svelte:fragment slot="footer">
<Button secondary on:click={() => (show = false)}>Close</Button>
</svelte:fragment>
</Modal>
@@ -0,0 +1,101 @@
<script context="module" lang="ts">
import { toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
export function cleanFormattedDate(date: string, time: boolean = false): string {
return time
? toLocaleDateTime(date, true)
: toLocaleDate(date, 'dd mm yyyy').replaceAll(', ', ' ');
}
</script>
<script lang="ts">
import { Button } from '$lib/elements/forms';
import { createEventDispatcher } from 'svelte';
import { Link } from '@appwrite.io/pink-svelte';
import { type Models } from '@appwrite.io/console';
import { Permissions } from '$lib/components/permissions';
import { Confirm, ExpirationInput, Modal } from '$lib/components';
export let show = false;
export let isDelete = false;
export let isUpdatePermissions = false;
export let fileToken: Models.ResourceToken | null = null;
let expire = null;
const dispatch = createEventDispatcher();
function close() {
show = false;
expire = null;
isDelete = false;
fileToken = null;
}
function handleFileToken() {
if (isDelete) {
dispatch('deleted');
} else {
dispatch(
fileToken ? 'updated' : 'created',
fileToken ? { ...fileToken, expire } : expire
);
}
close();
}
$: if (!show) {
close();
}
</script>
{#if isDelete}
<Confirm bind:open={show} title="Delete token" onSubmit={handleFileToken}>
{@const formattedDate = cleanFormattedDate(fileToken.$createdAt)}
<p data-private>
Are you sure you want to delete the file token created on <b>{formattedDate}</b>?
</p>
<p data-private>
{#if fileToken.accessedAt}
{@const formattedDate = cleanFormattedDate(fileToken.accessedAt, true)}
This token was last accessed on <b>{formattedDate}</b>
{:else}
This token has never been accessed.
{/if}
</p>
</Confirm>
{:else}
<Modal
bind:show
size="s"
onSubmit={handleFileToken}
title={`${fileToken ? 'Edit' : 'Create'} file token`}>
<!-- TODO: docs link needed-->
<svelte:fragment slot="description">
{#if fileToken}
{@const formattedDate = cleanFormattedDate(fileToken.$createdAt)}
Edit the expiry of the file token created on <b>{formattedDate}</b>
{:else}
Create a file token to grant access to a file.
<Link.Anchor href="https://appwrite.com/docs/">Learn more</Link.Anchor>.
{/if}
</svelte:fragment>
{#if isUpdatePermissions}
<Permissions bind:permissions={fileToken.$permissions} hideOnClick />
{:else}
<ExpirationInput
bind:value={expire}
resourceType="token"
selectorLabel="Expiry"
dateSelectorLabel="Expiry date" />
{/if}
<svelte:fragment slot="footer">
<Button secondary on:click={close}>Cancel</Button>
<Button submit>{fileToken ? 'Update' : 'Create'}</Button>
</svelte:fragment>
</Modal>
{/if}
@@ -1,5 +1,21 @@
import { derived } from 'svelte/store';
import { derived, writable } from 'svelte/store';
import { page } from '$app/stores';
import type { Models } from '@appwrite.io/console';
import type { Column } from '$lib/helpers/types';
export const file = derived(page, ($page) => $page.data.file as Models.File);
export const tokens = derived(page, ($page) => $page.data.tokens as Models.ResourceTokenList);
/**
* Caches JWTs in memory by `token.$id` to avoid regenerating them on each URL copy.
*/
export const cachedJwts = writable<Map<string, string>>(new Map<string, string>());
export const columns = writable<Column[]>([
{ id: 'created', title: 'Created', type: 'datetime', width: 200 },
{ id: 'expiry', title: 'Expiration', type: 'datetime', width: 200 },
{ id: 'last_accessed', title: 'Last accessed', type: 'datetime', width: 200 },
{ id: 'permissions', title: 'Permissions', type: 'string', width: 200 },
{ id: 'actions', title: '', type: 'string', width: 60 }
]);
@@ -113,15 +113,12 @@
let isExtensionsDisabled = true;
$: if (permissions) {
if (symmetricDifference(permissions, data.bucket.$permissions).length) {
$arePermsDisabled = false;
} else $arePermsDisabled = true;
$arePermsDisabled = !symmetricDifference(permissions, data.bucket.$permissions).length;
}
$: if (extensions) {
if (JSON.stringify(extensions) !== JSON.stringify(data.bucket.allowedFileExtensions)) {
isExtensionsDisabled = false;
} else isExtensionsDisabled = true;
isExtensionsDisabled =
JSON.stringify(extensions) === JSON.stringify(data.bucket.allowedFileExtensions);
}
function toggleBucket() {