merge conflicts

This commit is contained in:
Atharva Deosthale
2025-11-18 17:57:20 +05:30
73 changed files with 2646 additions and 2695 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Copilot Setup Steps
# Automatically run the setup steps when they are changed to allow for easy validation, and
# allow manual testing through the repository's "Actions" tab
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
copilot-setup-steps:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v5
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
+104
View File
@@ -0,0 +1,104 @@
# Appwrite Console - Copilot Instructions
## Repository Overview
Appwrite Console is the web-based GUI for the Appwrite backend-as-a-service platform. Single-page application built with **Svelte 5 + SvelteKit 2**, **TypeScript** (not strict mode), **Vite 7**, tested with **Vitest + Playwright**. Package manager: **pnpm 10.15.1**, Node 20+. ~1500 files with extensive component-based architecture.
## Critical Build & Test Commands
### Setup (REQUIRED before any commands)
1. **Install pnpm**: `npm install -g corepack && corepack enable && corepack prepare pnpm@10.15.1 --activate`
2. **Create .env**: `cp .env.example .env` (configure `PUBLIC_APPWRITE_ENDPOINT` and `PUBLIC_CONSOLE_MODE`)
3. **Configure network access** (if using GitHub Actions or restricted environments):
- Ensure firewall/proxy allows access to: `pkg.pr.new`, `pkg.vc`, `registry.npmjs.org`
- These domains are required for dependencies: `@appwrite.io/console`, `@appwrite.io/pink-icons-svelte`, `@appwrite.io/pink-svelte`
- In GitHub Actions: Use `pnpm/action-setup@v4` which handles registry configuration
- If network errors persist, check proxy settings: `npm config get proxy` and `npm config get https-proxy`
4. **Install dependencies**: `pnpm install --frozen-lockfile` (if pkg.pr.new/pkg.vc fail due to network restrictions, installation may still succeed with cached versions)
### Development Commands
**Standard workflow**: `check` → `lint` → `test` → `build` (before committing)
- `pnpm run check` - TypeScript/Svelte validation (~30-60s)
- `pnpm run lint` - ESLint check (~10-20s)
- `pnpm run format` - Auto-fix Prettier formatting
- `pnpm run test` - Vitest unit tests with TZ=EST (~10-30s)
- `pnpm run build` - Production build via build.js (~60-120s)
- `pnpm dev` - Dev server on port 3000
- `pnpm run preview` - Preview build on port 4173
- `pnpm run e2e` - Playwright tests (needs `pnpm exec playwright install --with-deps chromium` first, ~120s+)
**CI Pipeline** (`.github/workflows/tests.yml`): audit → install → check → lint → test → build
## Project Structure
```
src/
├── lib/ # Reusable logic ($lib alias)
│ ├── components/ # Feature components (billing, domains, permissions, etc.)
│ ├── elements/ # Basic UI elements
│ ├── helpers/ # Utility functions (array, date, string, etc.)
│ ├── stores/ # Svelte stores for state
│ ├── sdk/ # Appwrite SDK wrappers
│ └── constants.ts, flags.ts, system.ts
├── routes/
│ ├── (console)/ # Auth-required routes
│ │ ├── organization-[organization]/
│ │ └── project-[region]-[project]/ # databases, functions, messaging, storage
│ └── (public)/ # Public routes (login, register, auth callbacks)
├── themes/ # Theme definitions ($themes alias)
└── app.html, hooks.{client,server}.ts, service-worker.ts
```
**SvelteKit conventions**: `+page.svelte` (component), `+page.ts` (data loader), `+layout.svelte` (wrapper), `+error.svelte` (errors). Groups like `(console)` organize routes without affecting URLs. Dynamic params: `[param]`.
## Key Configuration
**svelte.config.js**: Adapter = static SPA (fallback: index.html), base path `/console`, aliases: `$lib`, `$routes`, `$themes`
**vite.config.ts**: Dev port 3000, Vitest (client=jsdom, server=node), test files: `src/**/*.{test,spec}.{js,ts}`
**tsconfig.json**: Extends `.svelte-kit/tsconfig.json`, **NOT strict mode** (`strict: false`)
**eslint.config.js**: Flat config (ESLint 9+), many rules disabled (see TODOs)
**.prettierrc**: 4 spaces, single quotes, 100 char width, no trailing commas
## Testing
**Unit (Vitest)**: Tests in `src/lib/helpers/*.test.ts`, run with `TZ=EST` (timezone matters). Setup mocks SvelteKit (`$app/*`) in `vitest-setup-client.ts`.
**E2E (Playwright)**: Tests in `e2e/journeys/*.spec.ts`, needs build+preview on port 4173, retries 3x, timeout 120s, Chromium only.
## Common Pitfalls
1. **Blank page in dev**: Disable ad blockers if seeing "Failed to fetch dynamically imported module" (known SvelteKit issue)
2. **Network errors on install**:
- pkg.pr.new/pkg.vc deps may fail due to firewall/proxy restrictions
- Check access: `curl -I https://pkg.pr.new` and `curl -I https://pkg.vc`
- Configure proxy if needed: `npm config set proxy http://proxy:port` and `npm config set https-proxy http://proxy:port`
- GitHub Actions: Ensure runner has internet access; use `pnpm/action-setup@v4` action
- Local dev: Often safe to continue with cached versions if network fails
3. **OOM on build**: Set `NODE_OPTIONS=--max_old_space_size=8192` (like Dockerfile does)
4. **Test failures**: Always use `pnpm run test` (sets TZ=EST), not `vitest` directly
5. **TS errors not showing**: Run `pnpm run check` explicitly (dev server doesn't always surface them)
6. **Format vs lint conflicts**: Run `pnpm run format` before `pnpm run lint`
7. **E2E timeouts**: Wait 120s for preview server startup, tests auto-retry 3x
8. **Stale build**: Clear `.svelte-kit` if changes not reflected: `rm -rf .svelte-kit && pnpm run build`
## Code Conventions
- Imports: Use `$lib`, `$routes`, `$themes` aliases
- Components: PascalCase, in `src/lib/components/[feature]/`
- Helpers: Pure functions in `src/lib/helpers/`
- Types: Inline or `.d.ts`, not `.types.ts` files
- Comments: Minimal, use for TODOs or complex logic
- TypeScript: Not strict mode, `any` tolerated
## Workflow
1. Run Appwrite backend locally (see [docs](https://appwrite.io/docs/advanced/self-hosting))
2. Configure `.env` with backend endpoint
3. `pnpm install --frozen-lockfile`
4. `pnpm dev` (hot reload on port 3000)
5. Before commit: `pnpm run check && pnpm run format && pnpm run lint && pnpm run test && pnpm run build`
6. **Take screenshots**: For any UI changes, capture screenshots and include them in the PR description or comments before finalizing
**Trust these instructions** - only search if incomplete/incorrect. See CONTRIBUTING.md for PR conventions. Use `--frozen-lockfile` always. Docker builds: multi-stage, final image is nginx serving static files from `/console` path.
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
import { Copy } from '.';
import { Icon, Tag } from '@appwrite.io/pink-svelte';
import { IconDuplicate } from '@appwrite.io/pink-icons-svelte';
import { getProjectEndpoint } from '$lib/helpers/project';
</script>
<Copy value={getProjectEndpoint()} copyText="Copy endpoint">
<Tag size="xs" variant="code">
<Icon icon={IconDuplicate} size="s" slot="start" />
<span style:white-space="nowrap" style:overflow="hidden" style:word-break="break-all">
API endpoint
</span>
</Tag>
</Copy>
+10 -12
View File
@@ -125,20 +125,18 @@
onMount(() => {
// fast path: don't subscribe if org is on a free plan or is self-hosted.
if (isSelfHosted || (isCloud && $organization.billingPlan === BillingPlan.FREE)) return;
if (isSelfHosted || (isCloud && $organization?.billingPlan === BillingPlan.FREE)) return;
return realtime
.forProject(page.params.region, page.params.project)
.subscribe('console', (response) => {
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
return realtime.forProject(page.params.region, 'console', (response) => {
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
if (
response.events.includes('archives.*') ||
response.events.includes('restorations.*')
) {
updateOrAddItem(response.payload);
}
});
if (
response.events.includes('archives.*') ||
response.events.includes('restorations.*')
) {
updateOrAddItem(response.payload);
}
});
});
</script>
+1
View File
@@ -57,6 +57,7 @@
disabled={tooltipDisabled}
portal={tooltipPortal}
delay={tooltipDelay}
maxWidth="500px"
placement={tooltipPlacement}>
<span
data-private
+102 -46
View File
@@ -2,22 +2,29 @@
import { onMount } from 'svelte';
import { base } from '$app/paths';
import { page } from '$app/state';
import { sdk } from '$lib/stores/sdk';
import { Dependencies } from '$lib/constants';
import { realtime, sdk } from '$lib/stores/sdk';
import { goto, invalidate } from '$app/navigation';
import { getProjectId } from '$lib/helpers/project';
import { writable, type Writable } from 'svelte/store';
import { addNotification } from '$lib/stores/notifications';
import { Layout, Typography } from '@appwrite.io/pink-svelte';
import { Layout, Typography, Icon } from '@appwrite.io/pink-svelte';
import { IconExclamationCircle } from '@appwrite.io/pink-icons-svelte';
import { Modal, Code } from '$lib/components';
import { type Models, type Payload, Query } from '@appwrite.io/console';
// re-render the key for sheet UI.
import { hash } from '$lib/helpers/string';
import { spreadsheetRenderKey } from '$routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/store';
import { Link } from '$lib/elements';
type CsvImportError = {
[key: string]: number | string | null;
};
type ImportItem = {
status: string;
table?: string;
errors?: string[];
};
type ImportItemsMap = Map<string, ImportItem>;
@@ -28,7 +35,7 @@
* The structure is as follows -
* `{ migrationId: { status: status, table: table } }`
*/
const importItems: Writable<ImportItemsMap> = writable(new Map());
let importItems = $state<ImportItemsMap>(new Map());
async function showCompletionNotification(database: string, table: string, payload: Payload) {
const isSuccess = payload.status === 'completed';
@@ -37,13 +44,9 @@
if (!isSuccess && !isError) return;
let errorMessage = 'Import failed. Check your CSV for correct fields and required values.';
if (isError && Array.isArray(payload.errors)) {
try {
// the `errors` is a list of json encoded string.
errorMessage = JSON.parse(payload.errors[0]).message;
} catch {
// do nothing, fallback to default message.
}
const errors = getErrors(payload);
if (errors) {
errorMessage = extractErrorMessage(errors);
}
const type = isSuccess ? 'success' : 'error';
@@ -73,7 +76,7 @@
const resourceId = importData.resourceId ?? '';
const [databaseId, tableId] = resourceId.split(':') ?? [];
const current = $importItems.get(importData.$id);
const current = importItems.get(importData.$id);
let tableName = current?.table ?? null;
if (!tableName && tableId) {
@@ -91,30 +94,27 @@
}
if (tableId && tableName === null) {
importItems.update((items) => {
const next = new Map(items);
next.delete(importData.$id);
return next;
});
const next = new Map(importItems);
next.delete(importData.$id);
importItems = next;
return;
}
importItems.update((items) => {
const existing = items.get(importData.$id);
const existing = importItems.get(importData.$id);
const isDone = (s: string) => s === 'completed' || s === 'failed';
const isInProgress = (s: string) => ['pending', 'processing', 'uploading'].includes(s);
const isDone = (s: string) => s === 'completed' || s === 'failed';
const isInProgress = (s: string) => ['pending', 'processing', 'uploading'].includes(s);
const shouldSkip =
(existing && isDone(existing.status) && isInProgress(status)) ||
existing?.status === status;
const shouldSkip =
(existing && isDone(existing.status) && isInProgress(status)) ||
existing?.status === status;
if (shouldSkip) return items;
const next = new Map(items);
next.set(importData.$id, { status, table: tableName ?? undefined });
return next;
});
if (!shouldSkip) {
const next = new Map(importItems);
const errors = getErrors(importData);
next.set(importData.$id, { status, table: tableName ?? undefined, errors });
importItems = next;
}
if (status === 'completed' || status === 'failed') {
await showCompletionNotification(databaseId, tableId, importData);
@@ -122,10 +122,27 @@
}
function clear() {
importItems.update((items) => {
items.clear();
return items;
});
importItems = new Map();
}
function getErrors(importData: Payload | Models.Migration): string[] | undefined {
return Array.isArray(importData.errors) ? importData.errors : undefined;
}
function parseError(error: string): string | CsvImportError {
try {
return JSON.parse(error) as CsvImportError;
} catch {
return error;
}
}
function extractErrorMessage(errors: string[]): string {
try {
return JSON.parse(errors[0]).message;
} catch {
return 'Import failed. Check your CSV for correct fields and required values.';
}
}
function graphSize(status: string): number {
@@ -148,8 +165,9 @@
const name = collectionName ? `<b>${collectionName}</b>` : '';
switch (status) {
case 'completed':
return `CSV import completed${name ? ` to ${name}` : ''}`;
case 'failed':
return `Import to ${name} ${status}`;
return `CSV import failed${name ? ` to ${name}` : ''}`;
case 'processing':
return `Importing CSV file${name ? ` to ${name}` : ''}`;
default:
@@ -169,7 +187,7 @@
migrations.migrations.forEach(updateOrAddItem);
});
return sdk.forConsoleIn(page.params.region).realtime.subscribe('console', (response) => {
return realtime.forConsole(page.params.region, 'console', (response) => {
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
if (response.events.includes('migrations.*')) {
updateOrAddItem(response.payload as Payload);
@@ -177,8 +195,18 @@
});
});
$: isOpen = true;
$: showCsvImportBox = $importItems.size > 0;
let isOpen = $state(true);
let showCsvImportBox = $derived(importItems.size > 0);
let showDetails = $state(false);
let selectedErrors = $state<string[]>([]);
let parsedErrors = $state<Array<string | CsvImportError>>([]);
function openDetails(errors: string[] | undefined) {
selectedErrors = errors ?? [];
parsedErrors = selectedErrors.map(parseError);
showDetails = true;
}
</script>
{#if showCsvImportBox}
@@ -187,26 +215,23 @@
<header class="upload-box-header">
<h4 class="upload-box-title">
<Typography.Text variant="m-500">
Importing rows ({$importItems.size})
Importing rows ({importItems.size})
</Typography.Text>
</h4>
<button
class="upload-box-button"
class:is-open={isOpen}
aria-label="toggle upload box"
on:click={() => (isOpen = !isOpen)}>
onclick={() => (isOpen = !isOpen)}>
<span class="icon-cheveron-up" aria-hidden="true"></span>
</button>
<button
class="upload-box-button"
aria-label="close backup restore box"
on:click={clear}>
<button class="upload-box-button" aria-label="close CSV import box" onclick={clear}>
<span class="icon-x" aria-hidden="true"></span>
</button>
</header>
<div class="upload-box-content-list">
{#each [...$importItems.entries()] as [key, value] (key)}
{#each [...importItems.entries()] as [key, value] (key)}
<div class="upload-box-content" class:is-open={isOpen}>
<ul class="upload-box-list">
<li class="upload-box-item">
@@ -222,6 +247,25 @@
class:is-danger={value.status === 'failed'}
style="--graph-size:{graphSize(value.status)}%">
</div>
{#if value.status === 'failed'}
<Layout.Stack
direction="row"
gap="xs"
alignItems="center"
inline>
<Icon
icon={IconExclamationCircle}
color="--fgcolor-error"
size="s" />
<Typography.Text color="--fgcolor-error">
There was an import issue.
<Link
style="color: inherit"
onclick={() => openDetails(value.errors)}
>View details</Link>
</Typography.Text>
</Layout.Stack>
{/if}
</section>
</li>
</ul>
@@ -232,6 +276,18 @@
</Layout.Stack>
{/if}
<Modal title="Import error" bind:show={showDetails} hideFooter>
<Layout.Stack gap="m">
<Layout.Stack>
<Code
language="json"
code={JSON.stringify(parsedErrors, null, 2)}
withCopy
allowScroll />
</Layout.Stack>
</Layout.Stack>
</Modal>
<style lang="scss">
.upload-box {
display: flex;
@@ -252,7 +308,7 @@
}
.upload-box-content {
width: 304px;
width: 324px;
}
.upload-box-button {
+2 -1
View File
@@ -95,10 +95,11 @@
export let tooltipPortal = false;
export let tooltipDelay: number = 0;
export let tooltipPlacement: TooltipPlacement = undefined;
export let copyText: string | undefined = undefined;
</script>
{#key value}
<Copy {value} {event} {tooltipPortal} {tooltipDelay} {tooltipPlacement}>
<Copy {value} {event} {tooltipPortal} {tooltipDelay} {tooltipPlacement} {copyText}>
<Tag size="xs" variant="code">
<Icon icon={IconDuplicate} size="s" slot="start" />
<span
+3
View File
@@ -82,8 +82,11 @@ 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 ApiEndpoint } from './apiEndpoint.svelte';
export { default as RegionEndpoint } from './regionEndpoint.svelte';
export { default as ExpirationInput } from './expirationInput.svelte';
export { default as EstimatedCard } from './estimatedCard.svelte';
export { default as SortButton, type SortDirection } from './sortButton.svelte';
export { default as SendVerificationEmailModal } from './account/sendVerificationEmailModal.svelte';
export { default as MultiSelectionTable } from './multiSelectTable.svelte';
export * from './multiSelectTable.svelte';
+8 -9
View File
@@ -50,15 +50,14 @@
})();
onMount(() => {
return realtime
.forProject(page.params.region, page.params.project)
.subscribe<Models.Migration>(['console'], async (response) => {
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
if (response.events.includes('migrations.*')) {
if (response.payload.source === 'Backup') return;
migration = response.payload;
}
});
return realtime.forProject(page.params.region, ['console'], async (response) => {
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
if (response.events.includes('migrations.*')) {
const payload = response.payload as Models.Migration;
if (payload.source === 'Backup') return;
migration = payload;
}
});
});
</script>
+164
View File
@@ -0,0 +1,164 @@
<script lang="ts" module>
export type DeleteOperationState = Error | void;
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import {
Table,
Badge,
Typography,
FloatingActionBar,
type TableColumn,
type TableRootProps
} from '@appwrite.io/pink-svelte';
import Confirm from './confirm.svelte';
import { Button } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
let {
columns,
resource,
allowSelection = true,
confirmDeletion = true,
showSuccessNotification = true,
computeKey = 'multiSelectionTable',
header,
children,
onDelete,
onCancel,
deleteContent,
deleteContentNotice
}: {
resource: string;
allowSelection?: boolean;
confirmDeletion?: boolean;
computeKey?: string | number;
showSuccessNotification?: boolean;
columns: Array<TableColumn> | number;
header: Snippet<[root: TableRootProps]>;
children: Snippet<[root: TableRootProps]>;
deleteContent?: Snippet<[count: number]>;
deleteContentNotice?: Snippet;
onDelete?: (selectedRows: string[]) => Promise<DeleteOperationState> | DeleteOperationState;
onCancel?: () => Promise<void> | void;
} = $props();
let selectedRows: string[] = $state([]);
let disableModal: boolean = $state(false);
let onDeleteError: string | null = $state(null);
let showConfirmDeletion: boolean = $state(false);
function notifySuccess() {
if (!showSuccessNotification) return;
const count = selectedRows.length;
if (count === 0) return;
const label = `${resource}${count > 1 ? 's' : ''}`;
addNotification({
type: 'success',
message: `${count} ${label} deleted`
});
}
// this is kept very basic!
function getPluralResource() {
if (resource.endsWith('ty')) {
return `${resource}ies`;
}
return `${resource}s`;
}
</script>
{#key computeKey}
<Table.Root let:root {columns} {allowSelection} bind:selectedRows>
<svelte:fragment slot="header" let:root>
{@render header?.(root)}
</svelte:fragment>
{@render children(root)}
</Table.Root>
{#if allowSelection && selectedRows.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedRows.length.toString()} />
<span>
{selectedRows.length > 1 ? getPluralResource() : resource}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button
text
on:click={() => {
onCancel?.();
selectedRows = [];
}}>Cancel</Button>
<Button
secondary
on:click={async () => {
if (confirmDeletion) {
showConfirmDeletion = true;
} else {
const state = await onDelete?.(selectedRows);
if (state instanceof Error) {
// user should handle error on their own!
} else {
notifySuccess();
selectedRows = [];
}
}
}}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
{#if allowSelection && confirmDeletion}
<Confirm
submissionLoader
confirmDeletion
error={onDeleteError}
disabled={disableModal}
title="Delete {getPluralResource()}"
bind:open={showConfirmDeletion}
onSubmit={async () => {
disableModal = true;
onDeleteError = null;
const state = await onDelete?.(selectedRows);
if (state instanceof Error) {
disableModal = false;
onDeleteError = state.message || `Failed to delete ${resource}s`;
} else {
notifySuccess();
selectedRows = [];
disableModal = false;
showConfirmDeletion = false;
}
}}>
<Typography.Text>
{@const selectionCount = selectedRows.length}
{#if deleteContent}
<!-- some show extra info -->
{@render deleteContent(selectionCount)}
{:else}
Are you sure you want to delete <strong>{selectionCount}</strong>
{selectionCount > 1 ? getPluralResource() : resource}?
{/if}
</Typography.Text>
<Typography.Text variant="m-500">
{#if deleteContentNotice}
<!-- some show extra info -->
{@render deleteContentNotice()}
{:else}
This action is irreversible.
{/if}
</Typography.Text>
</Confirm>
{/if}
{/key}
@@ -113,26 +113,12 @@
}, []);
}
function sortRoles([a]: [string, Permission], [b]: [string, Permission]) {
if ((a === 'any') !== (b === 'any')) {
return a === 'any' ? -1 : 1;
}
if ((a === 'users') !== (b === 'users')) {
return a === 'users' ? -1 : 1;
}
if ((a === 'guests') !== (b === 'guests')) {
return a === 'guests' ? -1 : 1;
}
return a.localeCompare(b);
}
const columns: PinkColumn[] = [
{ 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: 'role', width: { min: 220 } },
{ id: 'create', width: { min: 64 }, hide: !withCreate },
{ id: 'read', width: { min: 64 } },
{ id: 'update', width: { min: 64 } },
{ id: 'delete', width: { min: 64 } },
{ id: 'action', width: 40 }
];
</script>
@@ -150,13 +136,14 @@
<Table.Header.Cell column="delete" {root}>Delete</Table.Header.Cell>
<Table.Header.Cell column="action" {root} />
</svelte:fragment>
{#each [...$groups].sort(sortRoles) as [role, permission] (role)}
{#each [...$groups] as [role, permission] (role)}
<Table.Row.Base {root}>
<Table.Cell column="role" {root}>
<Row {role} />
</Table.Cell>
<Table.Cell column="create" {root}>
<Selector.Checkbox
size="s"
checked={permission.create}
on:change={() => togglePermission(role, 'create')} />
</Table.Cell>
+10 -5
View File
@@ -140,6 +140,9 @@
{:else}
<Popover let:show let:hide {placement} portal>
<button
type="button"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
onmouseenter={() => {
if (!$menuOpen) {
setTimeout(show, 150);
@@ -159,7 +162,7 @@
{:then data}
{formatName(
data.name ?? data?.email ?? data?.phone ?? '-',
$isSmallViewport ? 5 : 7
$isSmallViewport ? 16 : 18
)}
{/await}
</Typography.Text>
@@ -200,7 +203,7 @@
<Layout.Stack alignItems="flex-start" gap="xxs">
<Layout.Stack style="padding-left: 0.25rem;">
<Typography.Text
size="s"
size="m"
color="--fgcolor-neutral-primary">
{data.customName}
</Typography.Text>
@@ -254,14 +257,14 @@
? `${base}/project-${page.params.region}-${page.params.project}/auth/user-${id}`
: `${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${id}`}>
<Typography.Text
size="s"
size="m"
color="--fgcolor-neutral-primary">
{formatName(
data.name ??
data?.email ??
data?.phone ??
'-',
$isSmallViewport ? 12 : 20
$isSmallViewport ? 18 : 24
)}
</Typography.Text>
</Link.Anchor>
@@ -276,10 +279,11 @@
{#if isUser && (data.email || data.phone)}
<Divider />
<Layout.Stack gap="xs" alignItems="flex-start">
<Layout.Stack gap="xxs" alignItems="flex-start">
{#if data.email}
<Typography.Text
size="xs"
variant="m-400"
color="--fgcolor-neutral-secondary">
Email: {data.email}
</Typography.Text>
@@ -287,6 +291,7 @@
{#if data.phone}
<Typography.Text
size="xs"
variant="m-400"
color="--fgcolor-neutral-secondary">
Phone: {data.phone}
</Typography.Text>
+1 -1
View File
@@ -141,7 +141,7 @@
</div>
{:else}
<Card.Base padding="none">
<Empty title="You have no teams. Create a team to see them here." type="secondary">
<Empty title="No teams yet. Create a team to see it here." type="secondary">
<Typography.Text slot="description">
Need a hand? Learn more in our <Link.Anchor
href="https://appwrite.io/docs/products/auth/teams"
+2 -2
View File
@@ -176,10 +176,10 @@
</div>
{:else}
<Card.Base padding="none">
<Empty title="You have no users. Create a user to see them here." type="secondary">
<Empty title="No users yet. Create a user to see it here." type="secondary">
<Typography.Text slot="description">
Need a hand? Learn more in our <Link.Anchor
href="https://appwrite.io/docs/products/auth/quick-start"
href="https://appwrite.io/docs/products/auth/users"
target="_blank"
rel="noopener noreferrer">
documentation</Link.Anchor
+24 -24
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import { Copy } from '.';
import { sdk } from '$lib/stores/sdk';
import { Layout, Tag } from '@appwrite.io/pink-svelte';
import { Icon, Tag } from '@appwrite.io/pink-svelte';
import { IconDuplicate } from '@appwrite.io/pink-icons-svelte';
import { Flag, type Models } from '@appwrite.io/console';
import { truncateText } from '$lib/components/id.svelte';
import { isValueOfStringEnum } from '$lib/helpers/types';
import { getProjectEndpoint } from '$lib/helpers/project';
@@ -21,28 +21,28 @@
</script>
{#if region}
<Copy value={getProjectEndpoint()} copyText="Copy endpoint">
<Tag size="xs" variant="default">
<Layout.Stack direction="row" gap="s" alignItems="center" inline>
{#if flagSrc}
<img
width={16}
height={12}
src={flagSrc}
alt={region?.name}
style:border-radius="2.5px" />
{/if}
<span
style:white-space="nowrap"
class="text u-line-height-1-5"
style:overflow="hidden"
style:word-break="break-all"
use:truncateText
style:font-family="unset">
{region?.name}
</span>
</Layout.Stack>
<Copy value={getProjectEndpoint()} copyText={`Copy endpoint (${region.name})`}>
<Tag size="xs" variant="code">
<Icon icon={IconDuplicate} size="s" slot="start" />
<span class="endpoint-label"> API endpoint </span>
{#if flagSrc}
<img class="region-flag" src={flagSrc} alt={region?.name} />
{/if}
</Tag>
</Copy>
{/if}
<style>
.endpoint-label {
white-space: nowrap;
overflow: hidden;
word-break: break-all;
}
.region-flag {
width: 16px;
height: 12px;
border-radius: 2.5px;
margin-inline-start: 6px;
}
</style>
+2 -15
View File
@@ -12,8 +12,7 @@
Button,
Layout,
Avatar,
Typography,
Badge
Typography
} from '@appwrite.io/pink-svelte';
import {
@@ -80,13 +79,7 @@
{ name: 'Functions', icon: IconLightningBolt, slug: 'functions', category: 'build' },
{ name: 'Messaging', icon: IconChatBubble, slug: 'messaging', category: 'build' },
{ name: 'Storage', icon: IconFolder, slug: 'storage', category: 'build' },
{
name: 'Sites',
icon: IconGlobeAlt,
slug: 'sites',
category: 'deploy',
badge: 'Early access'
}
{ name: 'Sites', icon: IconGlobeAlt, slug: 'sites', category: 'deploy' }
];
const isSelected = (service: string): boolean => {
@@ -239,12 +232,6 @@
class:has-text={state === 'open'}
class="link-text">
{projectOption.name}
{#if projectOption?.badge}
<Badge
variant="secondary"
content={projectOption.badge}
size="xs" />
{/if}
</span>
</a>
<span slot="tooltip">{projectOption.name}</span>
+11 -1
View File
@@ -29,6 +29,7 @@
{#if href}
<Link.Anchor
{...$$restProps}
on:click
on:mousedown
on:click={track}
@@ -42,7 +43,16 @@
<slot />
</Link.Anchor>
{:else}
<Link.Button on:click on:mousedown on:click={track} {type} {disabled} {variant} {size} {icon}>
<Link.Button
{...$$restProps}
on:click
on:mousedown
on:click={track}
{type}
{disabled}
{variant}
{size}
{icon}>
<slot />
</Link.Button>
{/if}
+4 -1
View File
@@ -488,6 +488,9 @@ export async function paymentExpired(org: Organization) {
const nots = get(notifications);
const expiredNotification = nots.some((n) => n.message === expiredMessage);
const expiringNotification = nots.some((n) => n.message === expiringMessage);
const cardExpiry = new Date(payment.expiryYear, payment.expiryMonth, 1);
const nextMonth = new Date(year, month + 1, 1);
const isExpiringNextMonth = cardExpiry.getTime() === nextMonth.getTime();
if (payment.expired && !expiredNotification) {
addNotification({
type: 'error',
@@ -503,7 +506,7 @@ export async function paymentExpired(org: Organization) {
}
]
});
} else if (!expiringNotification && payment.expiryYear <= year && payment.expiryMonth < month) {
} else if (!expiringNotification && !payment.expired && isExpiringNextMonth) {
addNotification({
type: 'warning',
isHtml: true,
+43 -11
View File
@@ -42,7 +42,6 @@ import {
SUBDOMAIN_TOR
} from '$lib/constants';
import { building } from '$app/environment';
import { getProjectId } from '$lib/helpers/project';
export function getApiEndpoint(region?: string): string {
if (building) return '';
@@ -141,12 +140,32 @@ const sdkForProject = {
};
export const realtime = {
forProject(region: string, _projectId: string) {
forProject(
region: string,
channels: string | string[],
callback: AppwriteRealtimeResponseEvent
) {
const endpoint = getApiEndpoint(region);
if (endpoint !== clientRealtime.config.endpoint) {
clientRealtime.setEndpoint(endpoint);
}
return clientRealtime;
// because uses a different client!
const realtime = new Realtime(clientRealtime);
return createRealtimeSubscription(realtime, channels, callback);
},
forConsole(
region: string,
channels: string | string[],
callback: AppwriteRealtimeResponseEvent
): () => void {
const realtimeInstance = region
? sdk.forConsoleIn(region).realtime
: sdk.forConsole.realtime;
return createRealtimeSubscription(realtimeInstance, channels, callback);
}
};
@@ -176,8 +195,8 @@ export const sdk = {
};
export enum RuleType {
DEPLOYMENT = 'deployment',
API = 'api',
DEPLOYMENT = 'deployment',
REDIRECT = 'redirect'
}
@@ -191,11 +210,24 @@ export enum RuleTrigger {
MANUAL = 'manual'
}
/**
* Some type imports are broken on the SDK, this works correctly for the time being!
*/
export type AppwriteRealtimeSubscription = Awaited<ReturnType<Realtime['subscribe']>>;
export const createAdminClient = () => {
return new Client().setEndpoint(getApiEndpoint()).setMode('admin').setProject(getProjectId());
export type RealtimeResponse = {
events: string[];
channels: string[];
timestamp: string;
payload: unknown;
};
export type AppwriteRealtimeResponseEvent = (response: RealtimeResponse) => void;
function createRealtimeSubscription(
realtimeInstance: Realtime,
channels: string | string[],
callback: AppwriteRealtimeResponseEvent
): () => void {
const channelsArray = Array.isArray(channels) ? channels : [channels];
const subscriptionPromise = realtimeInstance.subscribe(channelsArray, callback);
return () => {
subscriptionPromise.then((sub) => sub.close());
};
}
@@ -27,15 +27,13 @@
import CsvImportBox from '$lib/components/csvImportBox.svelte';
onMount(() => {
return realtime
.forProject(page.params.region, page.params.project)
.subscribe(['project', 'console'], (response) => {
if (response.events.includes('stats.connections')) {
for (const [projectId, value] of Object.entries(response.payload)) {
stats.add(projectId, [new Date(response.timestamp).toISOString(), value]);
}
return realtime.forProject(page.params.region, ['project', 'console'], (response) => {
if (response.events.includes('stats.connections')) {
for (const [projectId, value] of Object.entries(response.payload)) {
stats.add(projectId, [new Date(response.timestamp).toISOString(), value]);
}
});
}
});
});
$: $registerCommands([
@@ -1,4 +1,4 @@
<script lang="ts" context="module">
<script lang="ts" module>
export let showCreateUser = writable(false);
</script>
@@ -9,8 +9,10 @@
import {
AvatarInitials,
Copy,
type DeleteOperationState,
Empty,
EmptySearch,
MultiSelectionTable,
PaginationWithLimit,
SearchQuery
} from '$lib/components';
@@ -20,14 +22,7 @@
import type { Models } from '@appwrite.io/console';
import { writable } from 'svelte/store';
import Create from './createUser.svelte';
import {
Badge,
Icon,
Table,
Layout,
Typography,
FloatingActionBar
} from '@appwrite.io/pink-svelte';
import { Badge, Icon, Table, Layout, Typography } from '@appwrite.io/pink-svelte';
import { Tag } from '@appwrite.io/pink-svelte';
import { IconDuplicate, IconPlus } from '@appwrite.io/pink-icons-svelte';
import { canWriteUsers } from '$lib/stores/roles';
@@ -37,11 +32,11 @@
import { sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import Confirm from '$lib/components/confirm.svelte';
export let data;
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const columns = writable<Column[]>([
{ id: '$id', title: 'User ID', type: 'string', width: 200 },
@@ -59,44 +54,25 @@
}
]);
let selectedUsers: string[] = [];
let showDelete = false;
let deleting = false;
async function userCreated(event: CustomEvent<Models.User<Record<string, unknown>>>) {
await goto(
`${base}/project-${page.params.region}-${page.params.project}/auth/user-${event.detail.$id}`
);
}
async function handleDelete() {
showDelete = false;
deleting = true;
const promises = selectedUsers.map((userId) =>
sdk.forProject(page.params.region, page.params.project).users.delete(userId)
);
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((userId) => {
return sdk.forProject(page.params.region, page.params.project).users.delete({ userId });
});
try {
await Promise.all(promises);
trackEvent(Submit.UserDelete, {
total: selectedUsers.length
});
addNotification({
type: 'success',
message: `${selectedUsers.length} user${selectedUsers.length > 1 ? 's' : ''} deleted`
});
invalidate(Dependencies.USERS);
trackEvent(Submit.UserDelete, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.UserDelete);
return error;
} finally {
selectedUsers = [];
showDelete = false;
deleting = false;
await invalidate(Dependencies.USERS);
}
}
</script>
@@ -116,101 +92,111 @@
</Layout.Stack>
{#if data.users.total}
<Table.Root
<MultiSelectionTable
resource="user"
columns={$columns}
allowSelection={$canWriteUsers}
bind:selectedRows={selectedUsers}
let:root>
<svelte:fragment slot="header" let:root>
onDelete={handleDelete}
allowSelection={$canWriteUsers}>
{#snippet header(root)}
{#each $columns as { id, title } (id)}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each data.users.users as user}
<Table.Row.Link
href={`${base}/project-${page.params.region}-${page.params.project}/auth/user-${user.$id}`}
{root}
id={user.$id}>
{#each $columns as { id } (id)}
<Table.Cell column={id} {root}>
{#if id === '$id'}
<Copy value={user.$id} event="user">
<Tag size="xs" variant="code">
<Icon size="s" icon={IconDuplicate} slot="start" />
{user.$id}
</Tag>
</Copy>
{:else if id === 'name'}
<Layout.Stack direction="row" alignItems="center" gap="s">
{#if user.email || user.phone}
{#if user.name}
<AvatarInitials size="xs" name={user.name} />
{/snippet}
{#snippet children(root)}
{#each data.users.users as user}
<Table.Row.Link
href={`${base}/project-${page.params.region}-${page.params.project}/auth/user-${user.$id}`}
{root}
id={user.$id}>
{#each $columns as { id } (id)}
<Table.Cell column={id} {root}>
{#if id === '$id'}
<Copy value={user.$id} event="user">
<Tag size="xs" variant="code">
<Icon size="s" icon={IconDuplicate} slot="start" />
{user.$id}
</Tag>
</Copy>
{:else if id === 'name'}
<Layout.Stack direction="row" alignItems="center" gap="s">
{#if user.email || user.phone}
{#if user.name}
<AvatarInitials size="xs" name={user.name} />
<Typography.Text truncate>
{user.name}
</Typography.Text>
{:else}
<div class="avatar is-size-small">
<span class="icon-minus-sm" aria-hidden="true"
></span>
</div>
{/if}
{:else}
<div class="avatar is-size-small">
<span class="icon-anonymous" aria-hidden="true"
></span>
</div>
<Typography.Text truncate>
{user.name}
</Typography.Text>
{:else}
<div class="avatar is-size-small">
<span class="icon-minus-sm" aria-hidden="true"
></span>
</div>
{/if}
</Layout.Stack>
{:else if id === 'identifiers'}
<Typography.Text truncate>
{user.email && user.phone
? [user.email, user.phone].join(',')
: user.email || user.phone}
</Typography.Text>
{:else if id === 'status'}
{#if user.status}
{@const success =
user.emailVerification || user.phoneVerification}
<Badge
size="xs"
variant="secondary"
type={success ? 'success' : undefined}
content={user.emailVerification &&
user.phoneVerification
? 'Verified'
: user.emailVerification
? 'Verified email'
: user.phoneVerification
? 'Verified phone'
: 'Unverified'} />
{:else}
<div class="avatar is-size-small">
<span class="icon-anonymous" aria-hidden="true"></span>
</div>
<Typography.Text truncate>
{user.name}
</Typography.Text>
<Badge
size="xs"
variant="secondary"
type="error"
content="blocked" />
{/if}
{:else if id === 'labels'}
<Typography.Text truncate>
{user.labels.join(', ')}
</Typography.Text>
{:else if id === 'joined'}
<DualTimeView time={user.registration} />
{:else if id === 'lastActivity'}
{#if user.accessedAt}
<DualTimeView time={user.accessedAt} />
{:else}
never
{/if}
</Layout.Stack>
{:else if id === 'identifiers'}
<Typography.Text truncate>
{user.email && user.phone
? [user.email, user.phone].join(',')
: user.email || user.phone}
</Typography.Text>
{:else if id === 'status'}
{#if user.status}
{@const success =
user.emailVerification || user.phoneVerification}
<Badge
size="xs"
variant="secondary"
type={success ? 'success' : undefined}
content={user.emailVerification && user.phoneVerification
? 'Verified'
: user.emailVerification
? 'Verified email'
: user.phoneVerification
? 'Verified phone'
: 'Unverified'} />
{:else}
<Badge
size="xs"
variant="secondary"
type="error"
content="blocked" />
{user[id]}
{/if}
{:else if id === 'labels'}
<Typography.Text truncate>
{user.labels.join(', ')}
</Typography.Text>
{:else if id === 'joined'}
<DualTimeView time={user.registration} />
{:else if id === 'lastActivity'}
{#if user.accessedAt}
<DualTimeView time={user.accessedAt} />
{:else}
never
{/if}
{:else}
{user[id]}
{/if}
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
</Table.Root>
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
{/snippet}
{#snippet deleteContentNotice()}
This action is irreversible and will permanently remove the selected users and all
their data.
{/snippet}
</MultiSelectionTable>
<PaginationWithLimit
name="Users"
@@ -220,9 +206,10 @@
{:else if data.search}
<EmptySearch target="users" hidePagination>
<Button
href={`${base}/project-${page.params.region}-${page.params.project}/auth`}
size="s"
secondary>Clear Search</Button>
secondary
href={`${base}/project-${page.params.region}-${page.params.project}/auth`}
>Clear Search</Button>
</EmptySearch>
{:else}
<Empty
@@ -232,33 +219,6 @@
allowCreate={$canWriteUsers}
on:click={() => showCreateUser.set(true)} />
{/if}
{#if selectedUsers.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedUsers.length.toString()} />
<span>
{selectedUsers.length > 1 ? 'users' : 'user'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedUsers = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
</Container>
<Create bind:showCreate={$showCreateUser} on:created={userCreated} />
<Confirm title="Delete users" bind:open={showDelete} onSubmit={handleDelete} disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selectedUsers.length}</b>
{selectedUsers.length > 1 ? 'users' : 'user'}?
</Typography.Text>
<Typography.Text>
This action is irreversible and will permanently remove the selected users and all their
data.
</Typography.Text>
</Confirm>
@@ -1,4 +1,4 @@
<script lang="ts" context="module">
<script lang="ts" module>
export let showCreateTeam = writable(false);
</script>
@@ -10,7 +10,9 @@
EmptySearch,
AvatarInitials,
SearchQuery,
PaginationWithLimit
PaginationWithLimit,
type DeleteOperationState,
MultiSelectionTable
} from '$lib/components';
import Create from '../createTeam.svelte';
import { goto } from '$app/navigation';
@@ -19,31 +21,16 @@
import type { Models } from '@appwrite.io/console';
import { writable } from 'svelte/store';
import { canWriteTeams } from '$lib/stores/roles';
import {
Icon,
Layout,
Table,
FloatingActionBar,
Badge,
Typography
} from '@appwrite.io/pink-svelte';
import { Icon, Layout, Table } from '@appwrite.io/pink-svelte';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import Confirm from '$lib/components/confirm.svelte';
import type { PageProps } from './$types';
export let data;
const region = page.params.region;
const project = page.params.project;
let selectedTeams: string[] = [];
let showDelete = false;
let deleting = false;
let { data }: PageProps = $props();
const columns = writable([
{ id: 'name', title: 'Name', type: 'string', width: { min: 200, max: 300 } },
@@ -52,37 +39,24 @@
]);
const teamCreated = async (event: CustomEvent<Models.Team<Record<string, unknown>>>) => {
await goto(`${base}/project-${region}-${project}/auth/teams/team-${event.detail.$id}`);
await goto(
`${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${event.detail.$id}`
);
};
async function handleDelete() {
showDelete = false;
deleting = true;
const promises = selectedTeams.map((teamId) =>
sdk.forProject(page.params.region, page.params.project).teams.delete(teamId)
);
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((teamId) => {
return sdk.forProject(page.params.region, page.params.project).teams.delete({ teamId });
});
try {
await Promise.all(promises);
trackEvent(Submit.TeamDelete, {
total: selectedTeams.length
});
addNotification({
type: 'success',
message: `${selectedTeams.length} team${selectedTeams.length > 1 ? 's' : ''} deleted`
});
invalidate(Dependencies.TEAMS);
trackEvent(Submit.TeamDelete, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.TeamDelete);
return error;
} finally {
selectedTeams = [];
showDelete = false;
deleting = false;
await invalidate(Dependencies.TEAMS);
}
}
</script>
@@ -101,54 +75,47 @@
</Layout.Stack>
{#if data.teams.total}
<Table.Root
<MultiSelectionTable
resource="team"
columns={$columns}
allowSelection={$canWriteTeams}
bind:selectedRows={selectedTeams}
let:root>
<svelte:fragment slot="header" let:root>
onDelete={handleDelete}
allowSelection={$canWriteTeams}>
{#snippet header(root)}
{#each $columns as { id, title }}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each data.teams.teams as team (team.$id)}
<Table.Row.Link
{root}
href={`${base}/project-${region}-${project}/auth/teams/team-${team.$id}`}
id={team.$id}>
{#each $columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === 'name'}
<Layout.Stack direction="row" alignItems="center">
<AvatarInitials size="xs" name={team.name} />
<span class="u-trim">{team.name}</span>
</Layout.Stack>
{:else if column.id === 'members'}
{team.total} members
{:else if column.id === 'created'}
<DualTimeView time={team.$createdAt} />
{/if}
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
</Table.Root>
{/snippet}
{#if selectedTeams.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedTeams.length.toString()} />
<span>
{selectedTeams.length > 1 ? 'teams' : 'team'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedTeams = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
{#snippet children(root)}
{@const TableRowComponent = $canWriteTeams ? Table.Row.Link : Table.Row.Base}
{#each data.teams.teams as team (team.$id)}
{@const href = $canWriteTeams
? `${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${team.$id}`
: undefined}
<TableRowComponent {root} {href} id={team.$id}>
{#each $columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === 'name'}
<Layout.Stack direction="row" alignItems="center">
<AvatarInitials size="xs" name={team.name} />
<span class="u-trim">{team.name}</span>
</Layout.Stack>
{:else if column.id === 'members'}
{team.total} members
{:else if column.id === 'created'}
<DualTimeView time={team.$createdAt} />
{/if}
</Table.Cell>
{/each}
</TableRowComponent>
{/each}
{/snippet}
{#snippet deleteContentNotice()}
This action is irreversible and will permanently remove the selected teams and all
their memberships.
{/snippet}
</MultiSelectionTable>
<PaginationWithLimit
name="Teams"
@@ -157,7 +124,10 @@
total={data.teams.total} />
{:else if data.search}
<EmptySearch target="teams" search={data.search} hidePagination={data.teams.total === 0}>
<Button secondary size="s" href={`${base}/project-${region}-${project}/auth/teams`}>
<Button
size="s"
secondary
href={`${base}/project-${page.params.region}-${page.params.project}/auth/teams`}>
Clear Search
</Button>
</EmptySearch>
@@ -172,14 +142,3 @@
</Container>
<Create bind:showCreate={$showCreateTeam} on:created={teamCreated} />
<Confirm title="Delete teams" bind:open={showDelete} onSubmit={handleDelete} disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selectedTeams.length}</b>
{selectedTeams.length > 1 ? 'teams' : 'team'}?
</Typography.Text>
<Typography.Text>
This action is irreversible and will permanently remove the selected teams and all their
memberships.
</Typography.Text>
</Confirm>
@@ -1,77 +1,50 @@
<script lang="ts">
import { page } from '$app/state';
import { Empty, EmptySearch, AvatarInitials, PaginationWithLimit } from '$lib/components';
import {
Empty,
EmptySearch,
AvatarInitials,
PaginationWithLimit,
MultiSelectionTable,
type DeleteOperationState
} from '$lib/components';
import { Button } from '$lib/elements/forms';
import { Container } from '$lib/layout';
import type { Models } from '@appwrite.io/console';
import { invalidate } from '$app/navigation';
import { base } from '$app/paths';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import type { PageData } from './$types';
import type { PageProps } from './$types';
import CreateMember from '../createMembership.svelte';
import DeleteMembership from '../deleteMembership.svelte';
import { Dependencies } from '$lib/constants';
import { Click, trackEvent, Submit, trackError } from '$lib/actions/analytics';
import {
Table,
Layout,
Icon,
FloatingActionBar,
Badge,
Typography
} from '@appwrite.io/pink-svelte';
import { Table, Layout, Icon } from '@appwrite.io/pink-svelte';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
import Confirm from '$lib/components/confirm.svelte';
export let data: PageData;
const { data }: PageProps = $props();
let showCreate = false;
let showDelete = false;
let selectedMembership: Models.Membership;
let selectedMemberships: string[] = [];
let showBulkDelete = false;
let deleting = false;
let showCreate = $state(false);
let showDelete = $state(false);
let selectedMembership: Models.Membership | null = $state(null);
const region = page.params.region;
const project = page.params.project;
async function memberCreated() {
invalidate(Dependencies.MEMBERSHIPS);
}
async function handleBulkDelete() {
showBulkDelete = false;
deleting = true;
const promises = selectedMemberships.map((membershipId) =>
sdk.forProject(page.params.region, page.params.project).teams.deleteMembership({
async function handleBulkDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((membershipId) => {
return sdk.forProject(page.params.region, page.params.project).teams.deleteMembership({
teamId: page.params.team,
membershipId
})
);
});
});
try {
await Promise.all(promises);
trackEvent(Submit.MembershipUpdate, {
total: selectedMemberships.length
});
addNotification({
type: 'success',
message: `${selectedMemberships.length} membership${selectedMemberships.length > 1 ? 's' : ''} deleted`
});
invalidate(Dependencies.MEMBERSHIPS);
trackEvent(Submit.MembershipUpdate, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.MembershipUpdate);
return error;
} finally {
selectedMemberships = [];
showBulkDelete = false;
deleting = false;
await invalidate(Dependencies.MEMBERSHIPS);
}
}
</script>
@@ -85,71 +58,62 @@
</Layout.Stack>
{#if data.memberships.total}
<Table.Root
let:root
allowSelection
bind:selectedRows={selectedMemberships}
<MultiSelectionTable
resource="membership"
onDelete={handleBulkDelete}
columns={[
{ id: 'name' },
{ id: 'roles' },
{ id: 'joined' },
{ id: 'actions', width: 40 }
]}>
<svelte:fragment slot="header" let:root>
{#snippet header(root)}
<Table.Header.Cell column="name" {root}>Name</Table.Header.Cell>
<Table.Header.Cell column="roles" {root}>Roles</Table.Header.Cell>
<Table.Header.Cell column="joined" {root}>Joined</Table.Header.Cell>
<Table.Header.Cell column="actions" {root} />
</svelte:fragment>
{#each data.memberships.memberships as membership (membership.$id)}
{@const username = membership.userName ? membership.userName : '-'}
<Table.Row.Link
{root}
href={`${base}/project-${region}-${project}/auth/user-${membership.userId}`}
id={membership.$id}>
<Table.Cell column="name" {root}>
<Layout.Stack direction="row" alignItems="center">
<AvatarInitials size="xs" name={username} />
<span>{username}</span>
</Layout.Stack>
</Table.Cell>
<Table.Cell column="roles" {root}>
{membership.roles}
</Table.Cell>
<Table.Cell column="joined" {root}>
<DualTimeView time={membership.joined} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<button
class="button is-only-icon is-text"
aria-label="Delete item"
on:click|preventDefault={() => {
selectedMembership = membership;
showDelete = true;
trackEvent(Click.MembershipDeleteClick);
}}>
<span class="icon-trash" aria-hidden="true"></span>
</button>
</Table.Cell>
</Table.Row.Link>
{/each}
</Table.Root>
{/snippet}
{#if selectedMemberships.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedMemberships.length.toString()} />
<span>
{selectedMemberships.length > 1 ? 'memberships' : 'membership'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedMemberships = [])}>Cancel</Button>
<Button secondary on:click={() => (showBulkDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
{#snippet children(root)}
{#each data.memberships.memberships as membership (membership.$id)}
{@const username = membership.userName ? membership.userName : '-'}
<Table.Row.Link
{root}
href={`${base}/project-${page.params.region}-${page.params.project}/auth/user-${membership.userId}`}
id={membership.$id}>
<Table.Cell column="name" {root}>
<Layout.Stack direction="row" alignItems="center">
<AvatarInitials size="xs" name={username} />
<span>{username}</span>
</Layout.Stack>
</Table.Cell>
<Table.Cell column="roles" {root}>
{membership.roles}
</Table.Cell>
<Table.Cell column="joined" {root}>
<DualTimeView time={membership.joined} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<button
class="button is-only-icon is-text"
aria-label="Delete item"
onclick={(event) => {
event.preventDefault();
showDelete = true;
selectedMembership = membership;
trackEvent(Click.MembershipDeleteClick);
}}>
<span class="icon-trash" aria-hidden="true"></span>
</button>
</Table.Cell>
</Table.Row.Link>
{/each}
{/snippet}
{#snippet deleteContentNotice()}
This action is irreversible and will remove the selected members from this team.
{/snippet}
</MultiSelectionTable>
<PaginationWithLimit
name="Memberships"
@@ -179,22 +143,12 @@
{/if}
</Container>
<CreateMember teamId={page.params.team} bind:showCreate on:created={memberCreated} />
<DeleteMembership
{selectedMembership}
bind:showDelete
on:deleted={() => invalidate(Dependencies.MEMBERSHIPS)} />
<CreateMember
bind:showCreate
teamId={page.params.team}
on:created={() => invalidate(Dependencies.MEMBERSHIPS)} />
<Confirm
title="Delete memberships"
bind:open={showBulkDelete}
onSubmit={handleBulkDelete}
disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selectedMemberships.length}</b>
{selectedMemberships.length > 1 ? 'memberships' : 'membership'}?
</Typography.Text>
<Typography.Text>
This action is irreversible and will remove the selected members from this team.
</Typography.Text>
</Confirm>
<DeleteMembership
bind:showDelete
{selectedMembership}
on:deleted={() => invalidate(Dependencies.MEMBERSHIPS)} />
@@ -1,122 +1,88 @@
<script lang="ts">
import { Id } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import type { PageData } from './$types';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import type { Column } from '$lib/helpers/types';
import { oAuthProviders } from '$lib/stores/oauth-providers';
import { app } from '$lib/stores/app';
import { base } from '$app/paths';
import { Badge, FloatingActionBar, Table, Typography } from '@appwrite.io/pink-svelte';
import Confirm from '$lib/components/confirm.svelte';
import { Table } from '@appwrite.io/pink-svelte';
import { page } from '$app/state';
export let columns: Column[];
export let data: PageData;
let {
data,
columns
}: {
data: PageData;
columns: Column[];
} = $props();
let selectedIds: string[] = [];
let showDelete = false;
async function handleDelete() {
showDelete = false;
const promises = selectedIds.map((id) =>
sdk
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) => {
return sdk
.forProject(page.params.region, page.params.project)
.users.deleteIdentity({ identityId: id })
);
.users.deleteIdentity({ identityId: id });
});
try {
await Promise.all(promises);
trackEvent(Submit.UserIdentityDelete, {
total: selectedIds.length
});
addNotification({
type: 'success',
message: `${selectedIds.length} target${selectedIds.length > 1 ? 's' : ''} deleted`
});
invalidate(Dependencies.USER_IDENTITIES);
trackEvent(Submit.UserIdentityDelete, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.UserIdentityDelete);
return error;
} finally {
selectedIds = [];
showDelete = false;
await invalidate(Dependencies.USER_IDENTITIES);
}
}
</script>
<Table.Root {columns} allowSelection let:root bind:selectedRows={selectedIds}>
<svelte:fragment slot="header" let:root>
<MultiSelectionTable {columns} resource="identity" onDelete={handleDelete}>
{#snippet header(root)}
{#each columns as { id, title }}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each data.identities.identities as identity (identity.$id)}
<Table.Row.Base {root} id={identity.$id}>
{#each columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key columns}
<Id value={identity[column.id]}>
{identity[column.id]}
</Id>
{/key}
{:else if column.id === 'provider'}
{@const provider = oAuthProviders[identity[column.id]]}
<div class="u-inline-flex u-cross-center u-gap-8">
<div class="avatar is-size-small">
<img
style="--p-text-size: 1rem"
height="20"
width="20"
src={`${base}/icons/${$app.themeInUse}/color/${provider.icon}.svg`}
alt={provider.name} />
{/snippet}
{#snippet children(root)}
{#each data.identities.identities as identity (identity.$id)}
<Table.Row.Base {root} id={identity.$id}>
{#each columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key columns}
<Id value={identity[column.id]}>
{identity[column.id]}
</Id>
{/key}
{:else if column.id === 'provider'}
{@const provider = oAuthProviders[identity[column.id]]}
<div class="u-inline-flex u-cross-center u-gap-8">
<div class="avatar is-size-small">
<img
style="--p-text-size: 1rem"
height="20"
width="20"
src={`${base}/icons/${$app.themeInUse}/color/${provider.icon}.svg`}
alt={provider.name} />
</div>
{provider.name}
</div>
{provider.name}
</div>
{:else if column.type === 'datetime'}
{#if !identity[column.id]}
-
{:else if column.type === 'datetime'}
{#if !identity[column.id]}
-
{:else}
<DualTimeView time={identity[column.id]} />
{/if}
{:else}
<DualTimeView time={identity[column.id]} />
{identity[column.id]}
{/if}
{:else}
{identity[column.id]}
{/if}
</Table.Cell>
{/each}
</Table.Row.Base>
{/each}
</Table.Root>
{#if selectedIds.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedIds.length.toString()} />
<span>
{selectedIds.length > 1 ? 'identities' : 'identity'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedIds = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm title="Delete Identity" bind:open={showDelete} onSubmit={handleDelete}>
<Typography.Text>
Are you sure you want to delete <b>{selectedIds.length}</b>
{selectedIds.length > 1 ? 'identities' : 'identity'}?
</Typography.Text>
</Confirm>
</Table.Cell>
{/each}
</Table.Row.Base>
{/each}
{/snippet}
</MultiSelectionTable>
@@ -1,50 +1,36 @@
<script lang="ts">
import { page } from '$app/state';
import { base } from '$app/paths';
import { AvatarInitials } from '$lib/components';
import {
AvatarInitials,
type DeleteOperationState,
MultiSelectionTable
} from '$lib/components';
import { Button } from '$lib/elements/forms';
import { Container } from '$lib/layout';
import DeleteMembership from '../deleteMembership.svelte';
import type { Models } from '@appwrite.io/console';
import { trackEvent, Submit, trackError } from '$lib/actions/analytics';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import {
Table,
Layout,
Empty,
Card,
FloatingActionBar,
Badge,
Typography
} from '@appwrite.io/pink-svelte';
import { Table, Layout, Empty, Card } from '@appwrite.io/pink-svelte';
import { sdk } from '$lib/stores/sdk';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import Confirm from '$lib/components/confirm.svelte';
import type { PageProps } from './$types';
export let data;
const { data }: PageProps = $props();
let selectedMembership: Models.Membership;
let showDelete = false;
let selectedMemberships: string[] = [];
let showBulkDelete = false;
let deleting = false;
const region = page.params.region;
const project = page.params.project;
async function handleBulkDelete() {
showBulkDelete = false;
deleting = true;
let showDelete = $state(false);
let selectedMembership: Models.Membership | null = $state(null);
async function handleBulkDelete(selectedRows: string[]): Promise<DeleteOperationState> {
// Precompute a lookup map from membershipId to teamId for efficient access
const membershipIdToTeamId: Record<string, string> = {};
for (const m of data.memberships.memberships) {
membershipIdToTeamId[m.$id] = m.teamId;
for (const membership of data.memberships.memberships) {
membershipIdToTeamId[membership.$id] = membership.teamId;
}
const promises = selectedMemberships.map((membershipId) =>
const promises = selectedRows.map((membershipId) =>
sdk.forProject(page.params.region, page.params.project).teams.deleteMembership({
teamId: membershipIdToTeamId[membershipId] || '',
membershipId
@@ -53,78 +39,73 @@
try {
await Promise.all(promises);
trackEvent(Submit.MembershipUpdate, {
total: selectedMemberships.length
});
addNotification({
type: 'success',
message: `${selectedMemberships.length} membership${selectedMemberships.length > 1 ? 's' : ''} deleted`
});
invalidate(Dependencies.MEMBERSHIPS);
trackEvent(Submit.MembershipUpdate, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.MembershipUpdate);
return error;
} finally {
selectedMemberships = [];
showBulkDelete = false;
deleting = false;
await invalidate(Dependencies.MEMBERSHIPS);
}
}
</script>
<Container>
{#if data.memberships.total}
<Table.Root
let:root
allowSelection
bind:selectedRows={selectedMemberships}
<MultiSelectionTable
resource="membership"
onDelete={handleBulkDelete}
columns={[
{ id: 'name' },
{ id: 'roles' },
{ id: 'joined' },
{ id: 'actions', width: 40 }
]}>
<svelte:fragment slot="header" let:root>
{#snippet header(root)}
<Table.Header.Cell column="name" {root}>Name</Table.Header.Cell>
<Table.Header.Cell column="roles" {root}>Roles</Table.Header.Cell>
<Table.Header.Cell column="joined" {root}>Joined</Table.Header.Cell>
<Table.Header.Cell column="actions" {root} />
</svelte:fragment>
{#each data.memberships.memberships as membership}
<Table.Row.Link
{root}
href={`${base}/project-${region}-${project}/auth/teams/team-${membership.teamId}`}
id={membership.$id}>
<Table.Cell column="name" {root}>
<Layout.Stack direction="row" alignItems="center">
<AvatarInitials size="xs" name={membership.teamName} />
<span>{membership.teamName ? membership.teamName : 'n/a'}</span>
</Layout.Stack>
</Table.Cell>
<Table.Cell column="roles" {root}>
{membership.roles}
</Table.Cell>
<Table.Cell column="joined" {root}>
<DualTimeView time={membership.joined} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<button
class="button is-only-icon is-text"
aria-label="Delete item"
on:click|preventDefault={() => {
selectedMembership = membership;
showDelete = true;
trackEvent('click_delete_membership');
}}>
<span class="icon-trash" aria-hidden="true"></span>
</button>
</Table.Cell>
</Table.Row.Link>
{/each}
</Table.Root>
{/snippet}
{#snippet children(root)}
{#each data.memberships.memberships as membership}
<Table.Row.Link
{root}
href={`${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${membership.teamId}`}
id={membership.$id}>
<Table.Cell column="name" {root}>
<Layout.Stack direction="row" alignItems="center">
<AvatarInitials size="xs" name={membership.teamName} />
<span>{membership.teamName ? membership.teamName : 'n/a'}</span>
</Layout.Stack>
</Table.Cell>
<Table.Cell column="roles" {root}>
{membership.roles}
</Table.Cell>
<Table.Cell column="joined" {root}>
<DualTimeView time={membership.joined} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<button
class="button is-only-icon is-text"
aria-label="Delete item"
onclick={(event) => {
event.preventDefault();
selectedMembership = membership;
showDelete = true;
trackEvent('click_delete_membership');
}}>
<span class="icon-trash" aria-hidden="true"></span>
</button>
</Table.Cell>
</Table.Row.Link>
{/each}
{/snippet}
{#snippet deleteContentNotice()}
This action is irreversible and will remove the user from the selected teams.
{/snippet}
</MultiSelectionTable>
{:else}
<Card.Base padding="none">
<Empty
@@ -142,36 +123,6 @@
</Empty>
</Card.Base>
{/if}
{#if selectedMemberships.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedMemberships.length.toString()} />
<span>
{selectedMemberships.length > 1 ? 'memberships' : 'membership'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedMemberships = [])}>Cancel</Button>
<Button secondary on:click={() => (showBulkDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
</Container>
<DeleteMembership {selectedMembership} bind:showDelete />
<Confirm
title="Delete memberships"
bind:open={showBulkDelete}
onSubmit={handleBulkDelete}
disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selectedMemberships.length}</b>
{selectedMemberships.length > 1 ? 'memberships' : 'membership'}?
</Typography.Text>
<Typography.Text>
This action is irreversible and will remove the user from the selected teams.
</Typography.Text>
</Confirm>
@@ -1,6 +1,5 @@
<script lang="ts">
import { Id } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import type { PageData } from './$types';
import { columns } from './store';
import DualTimeView from '$lib/components/dualTimeView.svelte';
@@ -10,108 +9,74 @@
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import { MessagingProviderType } from '@appwrite.io/console';
import { Badge, FloatingActionBar, Table, Typography } from '@appwrite.io/pink-svelte';
import Confirm from '$lib/components/confirm.svelte';
import { Table } from '@appwrite.io/pink-svelte';
export let data: PageData;
const {
data
}: {
data: PageData;
} = $props();
let selectedIds: string[] = [];
let showDelete = false;
async function handleDelete() {
showDelete = false;
const promises = selectedIds.map((id) =>
sdk
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) => {
return sdk
.forProject(page.params.region, page.params.project)
.users.deleteTarget({ userId: page.params.user, targetId: id })
);
.users.deleteTarget({ userId: page.params.user, targetId: id });
});
try {
await Promise.all(promises);
trackEvent(Submit.UserTargetDelete, {
total: selectedIds.length
});
addNotification({
type: 'success',
message: `${selectedIds.length} target${selectedIds.length > 1 ? 's' : ''} deleted`
});
invalidate(Dependencies.USER_TARGETS);
trackEvent(Submit.UserTargetDelete, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.UserTargetDelete);
return error;
} finally {
selectedIds = [];
showDelete = false;
await invalidate(Dependencies.USER_TARGETS);
}
}
</script>
<Table.Root columns={$columns} allowSelection let:root bind:selectedRows={selectedIds}>
<svelte:fragment slot="header" let:root>
<MultiSelectionTable resource="target" columns={$columns} onDelete={handleDelete}>
{#snippet header(root)}
{#each $columns as { id, title }}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each data.targets.targets as target (target.$id)}
{@const provider = data.providersById[target.providerId]}
<Table.Row.Base {root} id={target.$id}>
{#each $columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key $columns}
<Id value={target[column.id]}>
{target[column.id]}
</Id>
{/key}
{:else if column.id === 'target'}
{#if target.providerType === MessagingProviderType.Push}
{target.name}
{/snippet}
{#snippet children(root)}
{#each data.targets.targets as target (target.$id)}
{@const provider = data.providersById[target.providerId]}
<Table.Row.Base {root} id={target.$id}>
{#each $columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key $columns}
<Id value={target[column.id]}>
{target[column.id]}
</Id>
{/key}
{:else if column.id === 'target'}
{#if target.providerType === MessagingProviderType.Push}
{target.name}
{:else}
{target.identifier}
{/if}
{:else if column.id === 'providerType'}
<ProviderType type={target.providerType} size="s" />
{:else if column.id === 'provider'}
{#if provider}
<Provider provider={provider.provider} />
{/if}
{:else if column.id === '$createdAt'}
<DualTimeView time={target[column.id]} />
{:else}
{target.identifier}
{target[column.id]}
{/if}
{:else if column.id === 'providerType'}
<ProviderType type={target.providerType} size="s" />
{:else if column.id === 'provider'}
{#if provider}
<Provider provider={provider.provider} />
{/if}
{:else if column.id === '$createdAt'}
<DualTimeView time={target[column.id]} />
{:else}
{target[column.id]}
{/if}
</Table.Cell>
{/each}
</Table.Row.Base>
{/each}
</Table.Root>
{#if selectedIds.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedIds.length.toString()} />
<span>
{selectedIds.length > 1 ? 'targets' : 'target'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedIds = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm title="Delete Target" bind:open={showDelete} onSubmit={handleDelete}>
<Typography.Text>
Are you sure you want to delete <b>{selectedIds.length}</b>
{selectedIds.length > 1 ? 'targets' : 'target'}?
</Typography.Text>
</Confirm>
</Table.Cell>
{/each}
</Table.Row.Base>
{/each}
{/snippet}
</MultiSelectionTable>
@@ -69,11 +69,11 @@
await sdk
.forProject(page.params.region, page.params.project)
.backups.createArchive(['databases'], data.database.$id);
await invalidate(Dependencies.BACKUPS);
addNotification({
type: 'success',
message: 'Database backup has started'
});
invalidate(Dependencies.BACKUPS);
trackEvent('click_manual_submit');
showFeedbackNotification();
} catch (error) {
@@ -86,7 +86,7 @@
}
};
const trackEvents = (policies) => {
const trackEvents = (policies: UserBackupPolicy[]) => {
policies.forEach((policy) => {
let actualDay = null;
const monthlyBackupFrequency = policy.monthlyBackupFrequency;
@@ -139,7 +139,6 @@
? `Backup policies have been created`
: `<b>${totalPolicies[0].label}</b> policy has been created`;
// TODO: html isn't yet supported on Toast.
addNotification({
isHtml: true,
type: 'success',
@@ -148,7 +147,7 @@
trackEvents(totalPolicies);
invalidate(Dependencies.BACKUPS);
await invalidate(Dependencies.BACKUPS);
showFeedbackNotification();
} catch (err) {
addNotification({
@@ -162,19 +161,14 @@
};
onMount(() => {
return realtime
.forProject(page.params.region, page.params.project)
.subscribe(['project', 'console'], (response) => {
// fast path return.
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
return realtime.forProject(page.params.region, ['project', 'console'], (response) => {
// fast path return.
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
if (
response.events.includes('archives.*') ||
response.events.includes('policies.*')
) {
invalidate(Dependencies.BACKUPS);
}
});
if (response.events.includes('archives.*') || response.events.includes('policies.*')) {
invalidate(Dependencies.BACKUPS);
}
});
});
</script>
@@ -1,5 +1,11 @@
<script lang="ts">
import { Card, Modal } from '$lib/components';
import {
Card,
Confirm,
type DeleteOperationState,
Modal,
MultiSelectionTable
} from '$lib/components';
import { Button, InputCheckbox, InputText } from '$lib/elements/forms';
import RestoreModal from './restoreModal.svelte';
import type { PageData } from './$types';
@@ -11,17 +17,14 @@
import { ID } from '@appwrite.io/console';
import { columns } from './store';
import { database } from '../store';
import type { BackupArchive } from '$lib/sdk/backups';
import type { BackupArchive, BackupPolicy } from '$lib/sdk/backups';
import { Click, trackEvent } from '$lib/actions/analytics';
import { copy } from '$lib/helpers/copy';
import { LabelCard } from '$lib/components/index.js';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { Dependencies } from '$lib/constants';
import {
ActionMenu,
Badge,
FloatingActionBar,
Icon,
Layout,
Popover,
@@ -40,24 +43,29 @@
} from '@appwrite.io/pink-icons-svelte';
import { capitalize } from '$lib/helpers/string';
import Ellipse from './components/Ellipse.svelte';
import Confirm from '$lib/components/confirm.svelte';
import { page } from '$app/state';
export let data: PageData;
const {
data
}: {
data: PageData;
} = $props();
let showDelete = false;
let selectedBackup: BackupArchive = null;
let showDelete = $state(false);
let selectedBackup: BackupArchive | null = $state(null);
let showDropdown = [];
let selectedBackups: string[] = [];
let showRestore = false;
let showCustomId = false;
let newDatabaseInfo: { name: string; id: string } = { name: null, id: null };
let showRestore = $state(false);
let showCustomId = $state(false);
let newDatabaseInfo: { name: string | null; id: string | null } = $state({
name: null,
id: null
});
let confirmSameDbRestore = false;
let selectedRestoreOption = 'new';
let restoreOptions = [
let confirmSameDbRestore = $state(false);
let selectedRestoreOption = $state('new');
const restoreOptions = [
{
id: 'new',
title: 'Restore in new database',
@@ -70,89 +78,21 @@
}
];
const deleteBackups = async () => {
if (!selectedBackups.length && selectedBackup) {
selectedBackups.push(selectedBackup.$id);
}
const message = `${selectedBackups.length} backup${selectedBackups.length > 1 ? 's have been' : ''} deleted`;
const promises = selectedBackups.map((archiveId) =>
sdk.forProject(page.params.region, page.params.project).backups.deleteArchive(archiveId)
const disableRestoreButton = $derived.by(() => {
return (
(selectedRestoreOption === 'new' &&
(!newDatabaseInfo.name || $database.$id === newDatabaseInfo.id)) ||
(selectedRestoreOption === 'same' && !confirmSameDbRestore)
);
});
try {
await Promise.all(promises);
addNotification({
message,
type: 'success'
});
invalidate(Dependencies.BACKUPS);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
} finally {
showDelete = false;
selectedBackup = null;
selectedBackups = [];
}
};
const restoreBackup = async () => {
if (selectedRestoreOption === 'same') {
newDatabaseInfo.id = $database.$id;
newDatabaseInfo.name = $database.name;
}
try {
await sdk
.forProject(page.params.region, page.params.project)
.backups.createRestoration(
selectedBackup.$id,
['databases'],
newDatabaseInfo.id ?? ID.unique(),
newDatabaseInfo.name
);
addNotification({
type: 'success',
message: 'Database restore initiated'
});
invalidate(Dependencies.BACKUPS);
trackEvent('backup_restore_submit', {
newDatabaseName: newDatabaseInfo.name
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
} finally {
showRestore = false;
}
};
const policyDetails = (policyId: string | null) =>
data.policies.policies.find((policy) => policy.$id === policyId);
const cleanBackupName = (backup: BackupArchive) =>
toLocaleDateTime(backup.$createdAt).replaceAll(',', '');
$: if (!showRestore && !showDelete) {
showCustomId = false;
selectedBackup = null;
confirmSameDbRestore = false;
selectedRestoreOption = 'new';
newDatabaseInfo = { name: null, id: null };
function getPolicyDetails(policyId: string | null): BackupPolicy | null {
return data.policies.policies.find((policy) => policy.$id === policyId);
}
$: disableButton =
(selectedRestoreOption === 'new' &&
(!newDatabaseInfo.name || $database.$id === newDatabaseInfo.id)) ||
(selectedRestoreOption === 'same' && !confirmSameDbRestore);
function getCleanBackupName(backup: BackupArchive): string {
return toLocaleDateTime(backup.$createdAt).replaceAll(',', '');
}
function getBackupStatus(backup: BackupArchive) {
switch (backup.status) {
@@ -169,142 +109,210 @@
return 'waiting';
}
}
async function deleteBackups(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((archiveId) => {
return sdk
.forProject(page.params.region, page.params.project)
.backups.deleteArchive(archiveId);
});
try {
await Promise.all(promises);
if (selectedBackup) {
addNotification({
type: 'success',
message: '1 backup deleted'
});
}
} catch (error) {
if (selectedBackup) {
addNotification({
type: 'error',
message: error.message
});
} else {
return error;
}
} finally {
if (selectedBackup) {
showDelete = false;
selectedBackup = null;
}
await invalidate(Dependencies.BACKUPS);
}
}
async function restoreBackup() {
if (selectedRestoreOption === 'same') {
newDatabaseInfo.id = $database.$id;
newDatabaseInfo.name = $database.name;
}
try {
await sdk
.forProject(page.params.region, page.params.project)
.backups.createRestoration(
selectedBackup.$id,
['databases'],
newDatabaseInfo.id ?? ID.unique(),
newDatabaseInfo.name
);
await invalidate(Dependencies.BACKUPS);
addNotification({
type: 'success',
message: 'Database restore initiated'
});
trackEvent('backup_restore_submit', { newDatabaseName: newDatabaseInfo.name });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
} finally {
showRestore = false;
}
}
$effect(() => {
if (!showRestore && !showDelete) {
showCustomId = false;
selectedBackup = null;
confirmSameDbRestore = false;
selectedRestoreOption = 'new';
newDatabaseInfo = { name: null, id: null };
}
});
</script>
<Table.Root let:root allowSelection columns={$columns} bind:selectedRows={selectedBackups}>
<svelte:fragment slot="header" let:root>
<MultiSelectionTable
resource="backup"
columns={$columns}
onDelete={deleteBackups}
computeKey={data.backups.archives.length}>
{#snippet header(root)}
{#each $columns as column}
<Table.Header.Cell column={column.id} {root}>{column.title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{/snippet}
{#each data.backups.archives as backup, index}
{@const policy = policyDetails(backup.policyId)}
{@const retainedUntil = new Date(
new Date(policy?.$createdAt).getTime() + policy?.retention * 24 * 60 * 60 * 1000
)}
{@const formattedRetainedUntil = `${retainedUntil.getDate()} ${retainedUntil.toLocaleString('en-US', { month: 'short' })}, ${retainedUntil.getFullYear()} ${retainedUntil.toLocaleTimeString('en-US', { hour12: false })}`}
<Table.Row.Base id={backup.$id} {root}>
<Table.Cell column="backups" {root}>
<DualTimeView time={backup.$createdAt}>
{cleanBackupName(backup)}
</DualTimeView>
</Table.Cell>
<Table.Cell column="size" {root}>
{#if backup.status === 'completed'}
{calculateSize(backup.size)}
{:else}
-
{/if}
</Table.Cell>
<Table.Cell column="status" {root}>
{@const backupStatus = getBackupStatus(backup)}
<Status status={backupStatus} label={capitalize(backupStatus)} />
<!--{#if backup.status === 'Failed'}-->
<!-- <span class="u-underline">Get support</span>-->
<!--{/if}-->
</Table.Cell>
<Table.Cell column="policy" {root}>
<div class="u-flex u-cross-baseline">
<Tooltip maxWidth="fit-content">
<span>
{policy?.name || 'Manual'}
</span>
<span slot="tooltip"
>{policy
? `Retained until: ${formattedRetainedUntil}`
: `Retained forever`}</span>
</Tooltip>
</div>
</Table.Cell>
<Table.Cell column="actions" {root}>
<div class="action-cell u-flex u-main-end u-width-full-line">
<Popover let:toggle padding="m" placement="bottom-end">
<Button extraCompact on:click={toggle}>
<Icon icon={IconDotsHorizontal} />
</Button>
<svelte:fragment slot="tooltip" let:toggle>
<ActionMenu.Root width="180px" noPadding>
{#if backup.status === 'completed'}
{#snippet children(root)}
{#each data.backups.archives as backup, index}
{@const policy = getPolicyDetails(backup.policyId)}
{@const retainedUntil = new Date(
new Date(policy?.$createdAt).getTime() + policy?.retention * 24 * 60 * 60 * 1000
)}
{@const formattedRetainedUntil = `${retainedUntil.getDate()} ${retainedUntil.toLocaleString('en-US', { month: 'short' })}, ${retainedUntil.getFullYear()} ${retainedUntil.toLocaleTimeString('en-US', { hour12: false })}`}
<Table.Row.Base id={backup.$id} {root}>
<Table.Cell column="backups" {root}>
<DualTimeView time={backup.$createdAt}>
{getCleanBackupName(backup)}
</DualTimeView>
</Table.Cell>
<Table.Cell column="size" {root}>
{#if backup.status === 'completed'}
{calculateSize(backup.size)}
{:else}
-
{/if}
</Table.Cell>
<Table.Cell column="status" {root}>
{@const backupStatus = getBackupStatus(backup)}
<Status status={backupStatus} label={capitalize(backupStatus)} />
<!--{#if backup.status === 'Failed'}-->
<!-- <span class="u-underline">Get support</span>-->
<!--{/if}-->
</Table.Cell>
<Table.Cell column="policy" {root}>
<div class="u-flex u-cross-baseline">
<Tooltip maxWidth="fit-content">
<span>
{policy?.name || 'Manual'}
</span>
<span slot="tooltip"
>{policy
? `Retained until: ${formattedRetainedUntil}`
: `Retained forever`}</span>
</Tooltip>
</div>
</Table.Cell>
<Table.Cell column="actions" {root}>
<div class="action-cell u-flex u-main-end u-width-full-line">
<Popover let:toggle padding="m" placement="bottom-end">
<Button extraCompact on:click={toggle}>
<Icon icon={IconDotsHorizontal} />
</Button>
<svelte:fragment slot="tooltip" let:toggle>
<ActionMenu.Root width="180px" noPadding>
{#if backup.status === 'completed'}
<ActionMenu.Item.Button
trailingIcon={IconRefresh}
on:click={(e) => {
toggle(e);
showRestore = true;
selectedBackup = backup;
showDropdown[index] = false;
trackEvent(Click.BackupRestoreClick);
}}>
Restore
</ActionMenu.Item.Button>
{/if}
<ActionMenu.Item.Button
trailingIcon={IconRefresh}
trailingIcon={IconDuplicate}
on:click={(e) => {
toggle(e);
showRestore = true;
copy(backup.$id);
showDropdown[index] = false;
trackEvent(Click.BackupCopyIdClick);
}}>
Copy ID
</ActionMenu.Item.Button>
<ActionMenu.Item.Button
status="danger"
trailingIcon={IconTrash}
on:click={(e) => {
toggle(e);
showDelete = true;
selectedBackup = backup;
showDropdown[index] = false;
trackEvent(Click.BackupRestoreClick);
trackEvent(Click.BackupDeleteClick);
}}>
Restore
Delete
</ActionMenu.Item.Button>
{/if}
<ActionMenu.Item.Button
trailingIcon={IconDuplicate}
on:click={(e) => {
toggle(e);
copy(backup.$id);
showDropdown[index] = false;
trackEvent(Click.BackupCopyIdClick);
}}>
Copy ID
</ActionMenu.Item.Button>
<ActionMenu.Item.Button
status="danger"
trailingIcon={IconTrash}
on:click={(e) => {
toggle(e);
showDelete = true;
selectedBackup = backup;
showDropdown[index] = false;
trackEvent(Click.BackupDeleteClick);
}}>
Delete
</ActionMenu.Item.Button>
</ActionMenu.Root>
</svelte:fragment>
</Popover>
</div>
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
{#if selectedBackups.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedBackups.length.toString()} />
<span>
{selectedBackups.length > 1 ? 'backups' : 'backup'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedBackups = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
</ActionMenu.Root>
</svelte:fragment>
</Popover>
</div>
</Table.Cell>
</Table.Row.Base>
{/each}
{/snippet}
</MultiSelectionTable>
<!-- this is for single backup delete -->
<Confirm
title="Delete {selectedBackups.length ? 'backups' : 'backup'}"
title="Delete backup"
bind:open={showDelete}
onSubmit={deleteBackups}>
onSubmit={async () => {
if (!selectedBackup) return;
await deleteBackups([selectedBackup.$id]);
}}>
<Typography.Text>
Are you sure you want to delete
{#if selectedBackups.length}
<b>{selectedBackups.length}</b> {selectedBackups.length > 1 ? 'backups' : 'backup'}?
{:else}
the <b>{cleanBackupName(selectedBackup)}</b> backup?
{/if}
<br />This action is irreversible.
Are you sure you want to delete the <b>{getCleanBackupName(selectedBackup)}</b> backup?
</Typography.Text>
<Typography.Text variant="m-500">This action is irreversible.</Typography.Text>
</Confirm>
<Modal title="Restore backup" bind:show={showRestore} onSubmit={restoreBackup}>
<Card radius="m" padding="s">
<Layout.Stack gap="xxs">
<Typography.Text variant="m-500">
{cleanBackupName(selectedBackup)}
{getCleanBackupName(selectedBackup)}
</Typography.Text>
<Typography.Caption variant="500">
@@ -374,6 +382,6 @@
<svelte:fragment slot="footer">
<Button text on:click={() => (showRestore = false)}>Cancel</Button>
<Button submit disabled={disableButton}>Restore</Button>
<Button submit disabled={disableRestoreButton}>Restore</Button>
</svelte:fragment>
</Modal>
@@ -20,7 +20,7 @@
<script lang="ts">
import { goto, invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { realtime, sdk } from '$lib/stores/sdk';
import { type RealtimeResponse, realtime, sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import {
table,
@@ -66,7 +66,6 @@
import IndexesSuggestions from '../(suggestions)/indexes.svelte';
import { showIndexesSuggestions, tableColumnSuggestions } from '../(suggestions)';
import type { RealtimeResponseEvent } from '@appwrite.io/console';
let editRow: EditRow;
let editRelatedRow: EditRelatedRow;
@@ -83,35 +82,33 @@
*/
let isWaterfallFromFaker = false;
let columnCreationHandler: ((response: RealtimeResponseEvent<unknown>) => void) | null = null;
let columnCreationHandler: ((response: RealtimeResponse) => void) | null = null;
onMount(() => {
expandTabs.set(preferences.getKey('tableHeaderExpanded', true));
return realtime
.forProject(page.params.region, page.params.project)
.subscribe(['project', 'console'], (response) => {
if (
response.events.includes('databases.*.tables.*.columns.*') ||
response.events.includes('databases.*.tables.*.indexes.*')
) {
if (isWaterfallFromFaker) {
columnCreationHandler?.(response);
}
// don't invalidate when -
// 1. from faker
// 2. ai columns creation
// 3. ai indexes creation
if (
!isWaterfallFromFaker &&
!$showIndexesSuggestions &&
!$tableColumnSuggestions.table
) {
invalidate(Dependencies.TABLE);
}
return realtime.forProject(page.params.region, ['project', 'console'], (response) => {
if (
response.events.includes('databases.*.tables.*.columns.*') ||
response.events.includes('databases.*.tables.*.indexes.*')
) {
if (isWaterfallFromFaker) {
columnCreationHandler?.(response);
}
});
// don't invalidate when -
// 1. from faker
// 2. ai columns creation
// 3. ai indexes creation
if (
!isWaterfallFromFaker &&
!$showIndexesSuggestions &&
!$tableColumnSuggestions.table
) {
invalidate(Dependencies.TABLE);
}
}
});
});
// TODO: use route ids instead of pathname
@@ -268,7 +265,7 @@
const availableColumns = new Set<string>();
const waitPromise = new Promise<void>((resolve) => (resolvePromise = resolve));
columnCreationHandler = (response) => {
columnCreationHandler = (response: RealtimeResponse) => {
const { events, payload } = response;
if (
@@ -400,6 +397,8 @@
$: if (!$showCreateColumnSheet.show) {
createMoreColumns = false;
}
$: currentRowId = $databaseRowSheetOptions.row?.$id ?? $databaseRowSheetOptions.rowId;
</script>
<svelte:head>
@@ -456,7 +455,6 @@
</SideSheet>
<SideSheet
closeOnBlur
title={$databaseRowSheetOptions.title}
bind:show={$databaseRowSheetOptions.show}
submit={{
@@ -467,13 +465,15 @@
topAction={{
mode: 'copy-tag',
text: 'Row URL',
show: !!($databaseRowSheetOptions.rowId ?? $databaseRowSheetOptions.row?.$id),
value: buildRowUrl($databaseRowSheetOptions.rowId ?? $databaseRowSheetOptions.row?.$id)
show: !!currentRowId,
value: buildRowUrl(currentRowId)
}}>
<EditRow
bind:this={editRow}
bind:row={$databaseRowSheetOptions.row}
bind:rowId={$databaseRowSheetOptions.rowId} />
{#key currentRowId}
<EditRow
bind:this={editRow}
bind:row={$databaseRowSheetOptions.row}
bind:rowId={$databaseRowSheetOptions.rowId} />
{/key}
</SideSheet>
<SideSheet
@@ -76,7 +76,7 @@
$effect(() => {
if (row) {
work = initWork();
focusFirstInput();
requestAnimationFrame(() => focusFirstInput());
} else {
work = null;
}
@@ -117,15 +117,20 @@
if (type === 'header') {
if (isSequence) {
return ['sort-asc', 'sort-desc'].includes(item.action ?? '');
return ['sort-asc', 'sort-desc'].includes(item?.action);
}
if (['delete', 'update', 'duplicate-header'].includes(item.action) && isSystemColumn) {
if (['delete', 'update', 'duplicate-header'].includes(item?.action) && isSystemColumn) {
return false;
}
// hide column-left and create-index for $id (first column, already indexed)
if (columnId === '$id' && ['column-left', 'create-index'].includes(item?.action)) {
return false;
}
// hide sort options for relationship columns
if (isRelationship(column) && ['sort-asc', 'sort-desc'].includes(item.action ?? '')) {
if (isRelationship(column) && ['sort-asc', 'sort-desc'].includes(item?.action)) {
return false;
}
}
@@ -53,6 +53,8 @@
Button,
Layout,
Link,
Popover,
Keyboard,
Badge,
FloatingActionBar,
Icon,
@@ -78,7 +80,8 @@
IconSwitchHorizontal,
IconText,
IconToggle,
IconViewList
IconViewList,
IconArrowExpand
} from '@appwrite.io/pink-icons-svelte';
import type { HeaderCellAction, RowCellAction } from './sheetOptions.svelte';
import SheetOptions from './sheetOptions.svelte';
@@ -120,7 +123,9 @@
: 24;
let selectedRows = [];
let showExpandIconForId: number | null = null;
let spreadsheetContainer: SpreadsheetContainer;
let previouslyFocusedElement: Element | null = null;
let currentPage = 1;
let jumpToPageReactive = 0;
@@ -170,8 +175,8 @@
{
id: '$id',
title: '$id',
width: getColumnWidth('$id', 225),
minimumWidth: 225,
width: getColumnWidth('$id', 250),
minimumWidth: 250,
draggable: false,
type: 'string',
icon: IconFingerPrint,
@@ -537,9 +542,14 @@
}
} else if (type === 'row') {
if (action === 'update') {
$databaseRowSheetOptions.show = true;
$databaseRowSheetOptions.row = row;
$databaseRowSheetOptions.title = 'Update row';
databaseRowSheetOptions.update((opts) => {
return {
...opts,
row,
show: true,
title: 'Update row'
};
});
}
if (action === 'duplicate-row') {
@@ -747,6 +757,13 @@
$: totalPages = Math.ceil($rows.total / SPREADSHEET_PAGE_LIMIT) || 1;
$: rowSelection = !$spreadsheetLoading && !$paginatedRowsLoading ? true : ('disabled' as const);
$: if (!$databaseRowSheetOptions.show && previouslyFocusedElement) {
requestAnimationFrame(() => {
(previouslyFocusedElement as HTMLElement)?.focus();
previouslyFocusedElement = null;
});
}
</script>
<SpreadsheetContainer bind:this={spreadsheetContainer}>
@@ -776,6 +793,14 @@
bottomActionTooltip={{
text: 'Create row',
placement: 'top-end'
}}
expandKbdShortcut="Cmd+Enter"
on:expandKbdShortcut={({ detail }) => {
const focusedRowId = detail.rowId;
const focusedRow = $rows.rows.find((row) => row.$id === focusedRowId);
previouslyFocusedElement = document.activeElement;
onSelectSheetOption('update', null, 'row', focusedRow);
}}>
<svelte:fragment slot="header" let:root>
{#each $tableColumns as column (column.id)}
@@ -867,56 +892,133 @@
valueWithoutHover={row.$sequence}>
{#each $tableColumns as { id: columnId, isEditable } (columnId)}
{@const rowColumn = $columns.find((col) => col.key === columnId)}
<Spreadsheet.Cell {root} {isEditable} column={columnId}>
{#if columnId === '$id'}
<Id value={row.$id} tooltipPortal tooltipDelay={200}
>{row.$id}</Id>
{:else if columnId === '$createdAt' || columnId === '$updatedAt'}
<DualTimeView
showDatetime
time={row[columnId]}
canShowPopover={canShowDatetimePopover} />
{:else if columnId === 'actions'}
<SheetOptions
type="row"
column={rowColumn}
onSelect={(option) =>
onSelectSheetOption(option, null, 'row', row)}
onVisibilityChanged={(visible) => {
canShowDatetimePopover = !visible;
}}>
{#snippet children(toggle)}
<Button.Button
icon
variant="extra-compact"
on:click={toggle}>
<Icon
icon={IconDotsHorizontal}
color="--fgcolor-neutral-primary" />
</Button.Button>
{/snippet}
</SheetOptions>
{:else if isRelationship(rowColumn)}
{@const args = getDisplayNamesForTable(row[columnId])}
{#if !isRelationshipToMany(rowColumn)}
{#if row[columnId]}
{@const displayValue = args
.map((arg) => row[columnId]?.[arg])
.filter(Boolean)
.join(' | ')}
{#if columnId === '$id'}
<button
on:mouseenter={() => {
showExpandIconForId = index;
}}
on:mouseleave={() => {
showExpandIconForId = null;
}}>
<Spreadsheet.Cell {root} {isEditable} column={columnId}>
<Layout.Stack
gap="none"
direction="row"
alignItems="center"
alignContent="center"
justifyContent="space-between">
<Id value={row.$id} tooltipPortal tooltipDelay={200}
>{row.$id}</Id>
{#if displayValue}
<Link.Button
variant="muted"
on:click={() => {
$databaseRelatedRowSheetOptions.tableId =
row[columnId]?.['$tableId'];
$databaseRelatedRowSheetOptions.rows =
row[columnId]?.['$id'];
$databaseRelatedRowSheetOptions.show = true;
}}>
{displayValue}
</Link.Button>
<Popover let:show let:hide portal padding="none">
{@const opacityValue =
showExpandIconForId === index ? '1' : '0'}
<button
on:mouseenter={show}
on:mouseleave={hide}
style:opacity={opacityValue}
style:transition="opacity 225ms ease-in-out">
<Button.Button
size="xs"
icon
variant="secondary"
on:click={() => {
hide();
previouslyFocusedElement =
document.activeElement;
onSelectSheetOption(
'update',
null,
'row',
row
);
}}>
<Icon icon={IconArrowExpand} size="s" />
</Button.Button>
</button>
<svelte:fragment slot="tooltip">
<Layout.Stack
inline
gap="xxs"
direction="row"
alignItems="center"
alignContent="center"
style="padding: var(--gap-XS, 6px) var(--gap-S, 8px);">
Expand row
<Layout.Stack
inline
gap="xxxs"
direction="row"
alignItems="center"
alignContent="center">
<Keyboard size="s" key="⌘" />
<Keyboard
key={'Enter'}
autoWidth
size="s" />
</Layout.Stack>
</Layout.Stack>
</svelte:fragment>
</Popover>
</Layout.Stack>
</Spreadsheet.Cell>
</button>
{:else}
<Spreadsheet.Cell {root} {isEditable} column={columnId}>
{#if columnId === '$createdAt' || columnId === '$updatedAt'}
<DualTimeView
showDatetime
time={row[columnId]}
canShowPopover={canShowDatetimePopover} />
{:else if columnId === 'actions'}
<SheetOptions
type="row"
column={rowColumn}
onSelect={(option) =>
onSelectSheetOption(option, null, 'row', row)}
onVisibilityChanged={(visible) => {
canShowDatetimePopover = !visible;
}}>
{#snippet children(toggle)}
<Button.Button
icon
variant="extra-compact"
on:click={toggle}>
<Icon
icon={IconDotsHorizontal}
color="--fgcolor-neutral-primary" />
</Button.Button>
{/snippet}
</SheetOptions>
{:else if isRelationship(rowColumn)}
{@const args = getDisplayNamesForTable(row[columnId])}
{#if !isRelationshipToMany(rowColumn)}
{#if row[columnId]}
{@const displayValue = args
.map((arg) => row[columnId]?.[arg])
.filter(Boolean)
.join(' | ')}
{#if displayValue}
<Link.Button
variant="muted"
on:click={() => {
$databaseRelatedRowSheetOptions.tableId =
row[columnId]?.['$tableId'];
$databaseRelatedRowSheetOptions.rows =
row[columnId]?.['$id'];
$databaseRelatedRowSheetOptions.show = true;
}}>
{displayValue}
</Link.Button>
{:else}
<Badge
variant="secondary"
content="NULL"
size="xs" />
{/if}
{:else}
<Badge
variant="secondary"
@@ -924,89 +1026,90 @@
size="xs" />
{/if}
{:else}
<Badge variant="secondary" content="NULL" size="xs" />
{@const itemsNum = row[columnId]?.length}
Items <Badge
content={itemsNum}
variant="secondary"
size="s" />
{/if}
{:else if isSpatialType(rowColumn) && row[columnId] !== null}
<Typography.Text truncate>
{JSON.stringify(row[columnId])}
</Typography.Text>
{:else}
{@const itemsNum = row[columnId]?.length}
Items <Badge
content={itemsNum}
variant="secondary"
size="s" />
{/if}
{:else if isSpatialType(rowColumn) && row[columnId] !== null}
<Typography.Text truncate>
{JSON.stringify(row[columnId])}
</Typography.Text>
{:else}
{@const value = row[columnId]}
{@const formatted = formatColumn(row[columnId])}
{@const isEmptyArray = formatted === 'Empty'}
{@const isDatetimeAttribute = rowColumn.type === 'datetime'}
{@const isEncryptedAttribute =
isString(rowColumn) && rowColumn.encrypt}
{#if isDatetimeAttribute}
<DualTimeView time={value}>
<span slot="title">Timestamp</span>
{toLocaleDateTime(value, true)}
</DualTimeView>
{:else if isEncryptedAttribute}
<button on:click={(e) => e.preventDefault()}>
<InteractiveText
copy={false}
variant="secret"
isVisible={false}
text={formatted} />
</button>
{:else if formatted.length > 20}
<Tooltip placement="bottom" portal disabled>
{@const value = row[columnId]}
{@const formatted = formatColumn(row[columnId])}
{@const isEmptyArray = formatted === 'Empty'}
{@const isDatetimeAttribute = rowColumn.type === 'datetime'}
{@const isEncryptedAttribute =
isString(rowColumn) && rowColumn.encrypt}
{#if isDatetimeAttribute}
<DualTimeView time={value}>
<span slot="title">Timestamp</span>
{toLocaleDateTime(value, true)}
</DualTimeView>
{:else if isEncryptedAttribute}
<button on:click={(e) => e.preventDefault()}>
<InteractiveText
copy={false}
variant="secret"
isVisible={false}
text={formatted} />
</button>
{:else if formatted.length > 20}
<Tooltip placement="bottom" portal>
<Typography.Text truncate>
{formatted}
</Typography.Text>
<span
slot="tooltip"
style:white-space="pre-wrap"
style:word-break="break-word">
{formatted}
</span>
</Tooltip>
{:else if formatted === 'null'}
<Badge variant="secondary" content="NULL" size="xs" />
{:else if isEmptyArray}
<Badge
variant="secondary"
content={formatted}
size="xs" />
{:else}
<Typography.Text truncate>
{formatted}
</Typography.Text>
<span
slot="tooltip"
style:white-space="pre-wrap"
style:word-break="break-word">
{formatted}
</span>
</Tooltip>
{:else if formatted === 'null'}
<Badge variant="secondary" content="NULL" size="xs" />
{:else if isEmptyArray}
<Badge variant="secondary" content={formatted} size="xs" />
{:else}
<Typography.Text truncate>
{formatted}
</Typography.Text>
{/if}
{/if}
{/if}
<svelte:fragment slot="cell-editor" let:close>
{@const isRelatedToMany = isRelationshipToMany(rowColumn)}
{@const hasItems = isRelatedToMany
? row[columnId]?.length
: false}
<svelte:fragment slot="cell-editor" let:close>
{@const isRelatedToMany = isRelationshipToMany(rowColumn)}
{@const hasItems = isRelatedToMany
? row[columnId]?.length
: false}
<EditRowCell
{row}
column={rowColumn}
onRowStructureUpdate={updateRowContents}
noInlineEdit={isRelatedToMany && hasItems}
onChange={(row) => paginatedRows.update(index, row)}
onRevert={(row) => paginatedRows.update(index, row)}
openSideSheet={() => {
close(); /* closes the editor */
<EditRowCell
{row}
column={rowColumn}
onRowStructureUpdate={updateRowContents}
noInlineEdit={isRelatedToMany && hasItems}
onChange={(row) => paginatedRows.update(index, row)}
onRevert={(row) => paginatedRows.update(index, row)}
openSideSheet={() => {
close(); /* closes the editor */
if (isRelationshipToMany(rowColumn)) {
openSideSheetForRelationsToMany(
row[columnId],
rowColumn
);
} else {
onSelectSheetOption('update', null, 'row', row);
}
}} />
</svelte:fragment>
</Spreadsheet.Cell>
if (isRelationshipToMany(rowColumn)) {
openSideSheetForRelationsToMany(
row[columnId],
rowColumn
);
} else {
onSelectSheetOption('update', null, 'row', row);
}
}} />
</svelte:fragment>
</Spreadsheet.Cell>
{/if}
{/each}
</Spreadsheet.Row.Base>
{/if}
@@ -1,110 +1,85 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Id } from '$lib/components';
import { Id, MultiSelectionTable, type DeleteOperationState } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { addNotification } from '$lib/stores/notifications';
import { canWriteTables } from '$lib/stores/roles';
import { sdk } from '$lib/stores/sdk';
import { Badge, FloatingActionBar, Table, Typography } from '@appwrite.io/pink-svelte';
import { Table } from '@appwrite.io/pink-svelte';
import type { PageData } from './$types';
import { tableViewColumns } from './store';
import Confirm from '$lib/components/confirm.svelte';
import { subNavigation } from '$lib/stores/database';
import type { Models } from '@appwrite.io/console';
export let data: PageData;
const databaseId = page.params.database;
let deleting = false;
let showDelete = false;
let selectedTables: string[] = [];
async function handleDelete() {
showDelete = false;
const {
data
}: {
data: PageData;
} = $props();
async function onDelete(selectedTables: string[]): Promise<DeleteOperationState> {
const promises = selectedTables.map((tableId) =>
sdk.forProject(page.params.region, page.params.project).tablesDB.deleteTable({
databaseId,
databaseId: page.params.database,
tableId
})
);
try {
await Promise.all(promises);
trackEvent(Submit.TableDelete);
addNotification({
type: 'success',
message: `${selectedTables.length} table${selectedTables.length > 1 ? 's' : ''} deleted`
});
} catch (error) {
trackError(error, Submit.TableDelete);
return error;
} finally {
await invalidate(Dependencies.TABLES);
subNavigation.update();
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.TableDelete);
} finally {
selectedTables = [];
showDelete = false;
}
}
function getTableHref(table: Models.Table) {
return resolve(
'/(console)/project-[region]-[project]/databases/database-[database]/table-[table]',
{
region: page.params.region,
project: page.params.project,
database: page.params.database,
table: table.$id
}
);
}
</script>
<Table.Root
<MultiSelectionTable
resource="table"
columns={$tableViewColumns}
allowSelection={$canWriteTables}
bind:selectedRows={selectedTables}
let:root>
<svelte:fragment slot="header" let:root>
{onDelete}>
{#snippet header(root)}
{#each $tableViewColumns as { id, title }}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each data.tables.tables as table (table.$id)}
<Table.Row.Link
{root}
id={table.$id}
href={`${base}/project-${page.params.region}-${page.params.project}/databases/database-${databaseId}/table-${table.$id}`}>
{#each $tableViewColumns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key $tableViewColumns}
<Id value={table.$id}>{table.$id}</Id>
{/key}
{:else if column.id === 'name'}
{table.name}
{:else}
<DualTimeView time={table[column.id]} />
{/if}
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
</Table.Root>
{/snippet}
{#if selectedTables.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedTables.length.toString()} />
<span>
{selectedTables.length > 1 ? 'tables' : 'table'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedTables = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm title="Delete tables" bind:open={showDelete} onSubmit={handleDelete} disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selectedTables.length}</b>
{selectedTables.length > 1 ? 'tables' : 'table'}?
</Typography.Text>
</Confirm>
{#snippet children(root)}
{#each data.tables.tables as table (table.$id)}
<Table.Row.Link {root} id={table.$id} href={getTableHref(table)}>
{#each $tableViewColumns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key $tableViewColumns}
<Id value={table.$id}>{table.$id}</Id>
{/key}
{:else if column.id === 'name'}
{table.name}
{:else}
<DualTimeView time={table[column.id]} />
{/if}
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
{/snippet}
</MultiSelectionTable>
@@ -185,27 +185,24 @@
on:invalid={handleInvalid}>
<Layout.Stack alignItems="center" gap="s">
<Layout.Stack alignItems="center" gap="s">
<Layout.Stack
alignItems="center"
justifyContent="center"
direction="row"
gap="s">
<Typography.Text variant="l-500">
<Layout.Stack alignItems="center" justifyContent="center" inline>
<Typography.Text variant="l-500" align="center" inline>
Drag and drop file here or click to upload
</Typography.Text>
<Tooltip>
<Layout.Stack
style="display: inline-flex; vertical-align: middle;"
inline
alignItems="center"
justifyContent="center"
inline>
<Icon icon={IconInfo} size="s" />
justifyContent="center">
<Tooltip>
<Icon icon={IconInfo} size="s" />
<svelte:fragment slot="tooltip"
>Only .tar.gz files allowed</svelte:fragment>
</Tooltip>
</Layout.Stack>
<svelte:fragment slot="tooltip"
>Only .tar.gz files allowed</svelte:fragment>
</Tooltip>
</Typography.Text>
</Layout.Stack>
{#if maxSize > 0}
<Typography.Caption variant="400"
<Typography.Caption variant="400" align="center"
>Max file size: {readableMaxSize.value +
readableMaxSize.unit}</Typography.Caption>
{/if}
@@ -41,8 +41,8 @@
variables.map((variable) => {
if (variable.value === '{apiEndpoint}') {
variable.value = getApiEndpoint();
variable.placeholder = getApiEndpoint();
variable.value = getApiEndpoint(page.params.region);
variable.placeholder = getApiEndpoint(page.params.region);
} else if (variable.value === '{projectId}') {
variable.value = page.params.project;
variable.placeholder = page.params.project;
@@ -21,30 +21,29 @@
onMount(() => {
let previousStatus = null;
return realtime
.forProject(page.params.region, page.params.project)
.subscribe<Models.Deployment>('console', (message) => {
if (
message.payload.status !== 'ready' &&
previousStatus === message.payload.status
) {
return;
}
previousStatus = message.payload.status;
if (message.events.includes('functions.*.deployments.*.create')) {
invalidate(Dependencies.DEPLOYMENTS);
return;
}
if (message.events.includes('functions.*.deployments.*.update')) {
invalidate(Dependencies.DEPLOYMENTS);
invalidate(Dependencies.FUNCTION);
return;
}
if (message.events.includes('functions.*.deployments.*.delete')) {
invalidate(Dependencies.DEPLOYMENTS);
return;
}
});
return realtime.forProject(page.params.region, 'console', (response) => {
const payload = response.payload as Models.Deployment;
if (payload.status !== 'ready' && previousStatus === payload.status) {
return;
}
previousStatus = payload.status;
if (response.events.includes('functions.*.deployments.*.create')) {
invalidate(Dependencies.DEPLOYMENTS);
return;
}
if (response.events.includes('functions.*.deployments.*.update')) {
invalidate(Dependencies.DEPLOYMENTS);
invalidate(Dependencies.FUNCTION);
return;
}
if (response.events.includes('functions.*.deployments.*.delete')) {
invalidate(Dependencies.DEPLOYMENTS);
return;
}
});
});
$: $registerCommands([
@@ -1,7 +1,7 @@
<script lang="ts">
import { Button } from '$lib/elements/forms';
import { Container } from '$lib/layout';
import { sdk } from '$lib/stores/sdk';
import { realtime } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { type Models } from '@appwrite.io/console';
import { page } from '$app/state';
@@ -44,24 +44,18 @@
let showRedeploy = false;
onMount(() => {
const unsubscribe = sdk.forConsole.client.subscribe<Models.Deployment>(
'console',
(message) => {
if (
message.events.includes(
`functions.${page.params.function}.deployments.${page.params.deployment}.update`
)
) {
if (message.payload.status === 'ready') {
invalidate(Dependencies.DEPLOYMENT);
}
return realtime.forProject(page.params.region, 'console', (response) => {
if (
response.events.includes(
`functions.${page.params.function}.deployments.${page.params.deployment}.update`
)
) {
const payload = response.payload as Models.Deployment;
if (payload.status === 'ready') {
invalidate(Dependencies.DEPLOYMENT);
}
}
);
return () => {
unsubscribe();
};
});
});
export function badgeTypeDeployment(status: string) {
@@ -1,18 +1,16 @@
<script>
import { invalidate } from '$app/navigation';
import { page } from '$app/state';
import { Dependencies } from '$lib/constants';
import { realtime } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { page } from '$app/state';
import { realtime } from '$lib/stores/sdk';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
onMount(() => {
return realtime
.forProject(page.params.region, page.params.project)
.subscribe('functions.*.executions', (response) => {
if (response.events.includes('functions.*.executions.*')) {
invalidate(Dependencies.EXECUTIONS);
}
});
return realtime.forProject(page.params.region, 'functions.*.executions', (response) => {
if (response.events.includes('functions.*.executions.*')) {
invalidate(Dependencies.EXECUTIONS);
}
});
});
</script>
@@ -4,7 +4,7 @@
import { Dependencies } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { Container, ResponsiveContainerHeader } from '$lib/layout';
import { sdk } from '$lib/stores/sdk';
import { realtime } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { project } from '$routes/(console)/project-[region]-[project]/store';
import { base } from '$app/paths';
@@ -12,11 +12,13 @@
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import Table from './table.svelte';
import { columns } from './store';
import type { PageProps } from './$types';
import { page } from '$app/state';
export let data;
let { data }: PageProps = $props();
onMount(() => {
return sdk.forConsole.realtime.subscribe('console', (response) => {
return realtime.forConsole(page.params.region, 'console', (response) => {
if (response.events.includes('functions.*.executions.*')) {
invalidate(Dependencies.EXECUTIONS);
}
@@ -1,17 +1,10 @@
<script lang="ts">
import { Confirm, Id } from '$lib/components';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import { toLocaleDateTime } from '$lib/helpers/date';
import type { Column } from '$lib/helpers/types';
import type { Models } from '@appwrite.io/console';
import { ExecutionStatus } from '@appwrite.io/console';
import {
Badge,
FloatingActionBar,
Status,
Table,
Tooltip,
Typography
} from '@appwrite.io/pink-svelte';
import { Badge, Status, Table, Tooltip, Typography } from '@appwrite.io/pink-svelte';
import Sheet from './sheet.svelte';
import { capitalize } from '$lib/helpers/string';
import { calculateTime } from '$lib/helpers/timeConversion';
@@ -22,146 +15,110 @@
import { sdk } from '$lib/stores/sdk';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { timer } from '$lib/actions/timer';
export let columns: Column[];
export let executions: Models.ExecutionList;
let {
columns,
executions
}: {
columns: Column[];
executions: Models.ExecutionList;
} = $props();
let open = false;
let selectedLogId: string = null;
let selectedRows = [];
let showBatchDeletion = false;
async function deleteExecutions() {
showBatchDeletion = false;
let open = $state(false);
let selectedLogId: string | null = $state(null);
async function deleteExecutions(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((executionId) =>
sdk.forProject(page.params.region, page.params.project).functions.deleteExecution({
functionId: page.params.function,
executionId
})
);
try {
await Promise.all(promises);
trackEvent(Submit.ExecutionDelete);
addNotification({
type: 'success',
message: `${selectedRows.length} execution${selectedRows.length > 1 ? 's' : ''} deleted`
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.ExecutionDelete);
return error;
} finally {
selectedRows = [];
showBatchDeletion = false;
invalidate(Dependencies.EXECUTIONS);
await invalidate(Dependencies.EXECUTIONS);
}
}
</script>
<Table.Root let:root {columns} allowSelection bind:selectedRows>
<svelte:fragment slot="header" let:root>
<MultiSelectionTable {columns} resource="execution" onDelete={deleteExecutions}>
{#snippet header(root)}
{#each columns as { id, title }}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each executions.executions as log (log.$id)}
<Table.Row.Button
{root}
id={log.$id}
on:click={(e) => {
e.stopPropagation();
open = true;
selectedLogId = log.$id;
}}>
{#each columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={log.$id}>{log.$id}</Id>
{/key}
{:else if column.id === 'deploymentId'}
<Id value={log.deploymentId}>{log.deploymentId}</Id>
{:else if column.id === '$createdAt'}
<DualTimeView time={log.$createdAt} />
{:else if column.id === 'requestPath'}
<Typography.Code size="m">
{log.requestPath}
</Typography.Code>
{:else if column.id === 'responseStatusCode'}
<Badge
variant="secondary"
type={getBadgeTypeFromStatusCode(log.responseStatusCode)}
content={log.responseStatusCode.toString()} />
{:else if column.id === 'requestMethod'}
<Typography.Code size="m">
{log.requestMethod}
</Typography.Code>
{:else if column.id === 'trigger'}
{capitalize(log.trigger)}
{:else if column.id === 'status'}
{@const status = log.status}
<Tooltip
disabled={!log?.scheduledAt || status !== ExecutionStatus.Scheduled}
maxWidth="400px">
<div>
<Status
status={logStatusConverter(status)}
label={capitalize(status)}>
</Status>
</div>
<span slot="tooltip">
{`Scheduled to execute on ${toLocaleDateTime(log.scheduledAt)}`}
</span>
</Tooltip>
{:else if column.id === 'duration'}
{#if ['processing', 'waiting'].includes(log.status)}
<span use:timer={{ start: log.$createdAt }}></span>
{:else}
{calculateTime(log.duration)}
{/snippet}
{#snippet children(root)}
{#each executions.executions as log (log.$id)}
<Table.Row.Button
{root}
id={log.$id}
on:click={(e) => {
e.stopPropagation();
open = true;
selectedLogId = log.$id;
}}>
{#each columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={log.$id}>{log.$id}</Id>
{/key}
{:else if column.id === 'deploymentId'}
<Id value={log.deploymentId}>{log.deploymentId}</Id>
{:else if column.id === '$createdAt'}
<DualTimeView time={log.$createdAt} />
{:else if column.id === 'requestPath'}
<Typography.Code size="m">
{log.requestPath}
</Typography.Code>
{:else if column.id === 'responseStatusCode'}
<Badge
variant="secondary"
type={getBadgeTypeFromStatusCode(log.responseStatusCode)}
content={log.responseStatusCode.toString()} />
{:else if column.id === 'requestMethod'}
<Typography.Code size="m">
{log.requestMethod}
</Typography.Code>
{:else if column.id === 'trigger'}
{capitalize(log.trigger)}
{:else if column.id === 'status'}
{@const status = log.status}
<Tooltip
disabled={!log?.scheduledAt || status !== ExecutionStatus.Scheduled}
maxWidth="400px">
<div>
<Status
status={logStatusConverter(status)}
label={capitalize(status)}>
</Status>
</div>
<span slot="tooltip">
{`Scheduled to execute on ${toLocaleDateTime(log.scheduledAt)}`}
</span>
</Tooltip>
{:else if column.id === 'duration'}
{#if ['processing', 'waiting'].includes(log.status)}
<span use:timer={{ start: log.$createdAt }}></span>
{:else}
{calculateTime(log.duration)}
{/if}
{/if}
{/if}
</Table.Cell>
{/each}
</Table.Row.Button>
{/each}
</Table.Root>
</Table.Cell>
{/each}
</Table.Row.Button>
{/each}
{/snippet}
</MultiSelectionTable>
<Sheet bind:open bind:selectedLogId logs={executions.executions} logging={$func.logging} />
{#if selectedRows.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedRows.length.toString()} />
<span>
{selectedRows.length > 1 ? 'executions' : 'execution'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedRows = [])}>Cancel</Button>
<Button secondary on:click={() => (showBatchDeletion = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm
title="Delete executions"
confirmDeletion
onSubmit={deleteExecutions}
bind:open={showBatchDeletion}>
<p>
Are you sure you want to delete <strong>{selectedRows.length}</strong>
{selectedRows.length > 1 ? 'executions' : 'execution'}?
</p>
<p class="u-bold">This action is irreversible.</p>
</Confirm>
@@ -1,5 +1,5 @@
<script lang="ts">
import { Confirm, Id } from '$lib/components';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import type { PageData } from './$types';
import { type Models } from '@appwrite.io/console';
import type { Column } from '$lib/helpers/types';
@@ -14,15 +14,7 @@
import { Dependencies } from '$lib/constants';
import Cancel from './(modals)/cancelDeploymentModal.svelte';
import { base } from '$app/paths';
import {
ActionMenu,
Badge,
FloatingActionBar,
Icon,
Status,
Table,
Tooltip
} from '@appwrite.io/pink-svelte';
import { ActionMenu, Icon, Status, Table, Tooltip } from '@appwrite.io/pink-svelte';
import { Click, Submit, trackError, trackEvent } from '$lib/actions/analytics';
import {
IconDotsHorizontal,
@@ -39,28 +31,26 @@
import DownloadActionMenuItem from './(components)/downloadActionMenuItem.svelte';
import { Menu } from '$lib/components/menu';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
export let columns: Column[];
export let data: PageData;
let {
data,
columns
}: {
data: PageData;
columns: Column[];
} = $props();
let showDelete = false;
let showActivate = false;
let showRedeploy = false;
let showCancel = false;
let selectedDeployment: Models.Deployment = null;
let showDelete = $state(false);
let showCancel = $state(false);
let showActivate = $state(false);
let showRedeploy = $state(false);
let selectedDeployment: Models.Deployment | null = $state(null);
function handleActivate() {
invalidate(Dependencies.DEPLOYMENTS);
}
let selectedRows = [];
let showBatchDeletion = false;
async function deleteDeployments() {
showBatchDeletion = false;
async function deleteDeployments(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((deploymentId) =>
sdk.forProject(page.params.region, page.params.project).functions.deleteDeployment({
functionId: page.params.function,
@@ -70,186 +60,160 @@
try {
await Promise.all(promises);
trackEvent(Submit.DeploymentDelete);
addNotification({
type: 'success',
message: `${selectedRows.length} deployment${selectedRows.length > 1 ? 's' : ''} deleted`
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.DeploymentDelete);
return error;
} finally {
selectedRows = [];
showBatchDeletion = false;
invalidate(Dependencies.DEPLOYMENTS);
await invalidate(Dependencies.DEPLOYMENTS);
}
}
</script>
<Table.Root
let:root
<MultiSelectionTable
allowSelection
bind:selectedRows
resource="deployment"
onDelete={deleteDeployments}
columns={[...columns, { id: 'actions', width: 40 }]}>
<svelte:fragment slot="header" let:root>
{#snippet header(root)}
{#each columns as { id, title }}
<Table.Header.Cell column={id} {root}>
{title}
</Table.Header.Cell>
{/each}
<Table.Header.Cell column="actions" {root} />
</svelte:fragment>
{#each data.deploymentList.deployments as deployment (deployment.$id)}
<Table.Row.Link
{root}
id={deployment.$id}
href={`${base}/project-${page.params.region}-${page.params.project}/functions/function-${page.params.function}/deployment-${deployment.$id}`}>
{#each columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={deployment.$id}>{deployment.$id}</Id>
{/key}
{:else if column.id === 'status'}
{@const status = deployment.status}
{/snippet}
{#if data?.activeDeployment?.$id === deployment?.$id}
<Status status="complete" label="Active" />
{:else}
<Status
status={deploymentStatusConverter(status)}
label={capitalize(status)} />
{/if}
{:else if column.id === 'type'}
<DeploymentSource {deployment} />
{:else if column.id === '$updatedAt'}
<DeploymentCreatedBy {deployment} />
{:else if column.id === 'buildDuration'}
{#if ['waiting'].includes(deployment.status)}
-
{:else if ['processing', 'building'].includes(deployment.status)}
<span use:timer={{ start: deployment.$createdAt }}></span>
{:else}
{formatTimeDetailed(deployment.buildDuration)}
{/if}
{:else if column.id === 'totalSize'}
{calculateSize(deployment.totalSize)}
{:else if column.id === 'sourceSize'}
{calculateSize(deployment.sourceSize)}
{:else if column.id === 'buildSize'}
{calculateSize(deployment.buildSize)}
{/if}
</Table.Cell>
{/each}
<Table.Cell column="actions" {root}>
<Menu>
<Button text icon size="s">
<Icon size="s" icon={IconDotsHorizontal} />
</Button>
{#snippet children(root)}
{#each data.deploymentList.deployments as deployment (deployment.$id)}
<Table.Row.Link
{root}
id={deployment.$id}
href={`${base}/project-${page.params.region}-${page.params.project}/functions/function-${page.params.function}/deployment-${deployment.$id}`}>
{#each columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={deployment.$id}>{deployment.$id}</Id>
{/key}
{:else if column.id === 'status'}
{@const status = deployment.status}
<svelte:fragment slot="menu" let:toggle>
<ActionMenu.Root>
<Tooltip disabled={deployment.sourceSize !== 0} placement={'bottom'}>
<div>
{#if data?.activeDeployment?.$id === deployment?.$id}
<Status status="complete" label="Active" />
{:else}
<Status
status={deploymentStatusConverter(status)}
label={capitalize(status)} />
{/if}
{:else if column.id === 'type'}
<DeploymentSource {deployment} />
{:else if column.id === '$updatedAt'}
<DeploymentCreatedBy {deployment} />
{:else if column.id === 'buildDuration'}
{#if ['waiting'].includes(deployment.status)}
-
{:else if ['processing', 'building'].includes(deployment.status)}
<span use:timer={{ start: deployment.$createdAt }}></span>
{:else}
{formatTimeDetailed(deployment.buildDuration)}
{/if}
{:else if column.id === 'totalSize'}
{calculateSize(deployment.totalSize)}
{:else if column.id === 'sourceSize'}
{calculateSize(deployment.sourceSize)}
{:else if column.id === 'buildSize'}
{calculateSize(deployment.buildSize)}
{/if}
</Table.Cell>
{/each}
<Table.Cell column="actions" {root}>
<Menu>
<Button text icon size="s">
<Icon size="s" icon={IconDotsHorizontal} />
</Button>
<svelte:fragment slot="menu" let:toggle>
<ActionMenu.Root>
<Tooltip
disabled={deployment.sourceSize !== 0}
placement={'bottom'}>
<div>
<ActionMenu.Item.Button
trailingIcon={IconRefresh}
disabled={deployment.sourceSize === 0}
on:click={() => {
selectedDeployment = deployment;
showRedeploy = true;
toggle();
trackEvent(Click.FunctionsRedeployClick);
}}
style="width: 100%">
Redeploy
</ActionMenu.Item.Button>
</div>
<div slot="tooltip">Source is empty</div>
</Tooltip>
{#if deployment.status === 'ready' && deployment.$id !== $func.deploymentId}
<ActionMenu.Item.Button
trailingIcon={IconRefresh}
disabled={deployment.sourceSize === 0}
trailingIcon={IconLightningBolt}
on:click={() => {
selectedDeployment = deployment;
showRedeploy = true;
showActivate = true;
toggle();
trackEvent(Click.FunctionsRedeployClick);
}}
style="width: 100%">
Redeploy
}}>
Activate
</ActionMenu.Item.Button>
</div>
<div slot="tooltip">Source is empty</div>
</Tooltip>
{#if deployment.status === 'ready' && deployment.$id !== $func.deploymentId}
<ActionMenu.Item.Button
trailingIcon={IconLightningBolt}
on:click={() => {
selectedDeployment = deployment;
showActivate = true;
toggle();
}}>
Activate
</ActionMenu.Item.Button>
{/if}
{/if}
<DownloadActionMenuItem {deployment} {toggle} />
<DownloadActionMenuItem {deployment} {toggle} />
{#if deployment.status === 'processing' || deployment.status === 'building' || deployment.status === 'waiting'}
<ActionMenu.Item.Button
trailingIcon={IconXCircle}
on:click={() => {
selectedDeployment = deployment;
toggle();
{#if deployment.status === 'processing' || deployment.status === 'building' || deployment.status === 'waiting'}
<ActionMenu.Item.Button
trailingIcon={IconXCircle}
on:click={() => {
selectedDeployment = deployment;
toggle();
showCancel = true;
trackEvent(Click.FunctionsDeploymentCancelClick);
}}>
Cancel
</ActionMenu.Item.Button>
{/if}
{#if deployment.status !== 'building' && deployment.status !== 'processing' && deployment.status !== 'waiting'}
<ActionMenu.Item.Button
trailingIcon={IconTrash}
status="danger"
on:click={() => {
selectedDeployment = deployment;
toggle();
showCancel = true;
trackEvent(Click.FunctionsDeploymentCancelClick);
}}>
Cancel
</ActionMenu.Item.Button>
{/if}
{#if deployment.status !== 'building' && deployment.status !== 'processing' && deployment.status !== 'waiting'}
<ActionMenu.Item.Button
trailingIcon={IconTrash}
status="danger"
on:click={() => {
selectedDeployment = deployment;
toggle();
showDelete = true;
trackEvent(Click.FunctionsDeploymentDeleteClick);
}}>
Delete
</ActionMenu.Item.Button>
{/if}
</ActionMenu.Root>
</svelte:fragment>
</Menu>
</Table.Cell>
</Table.Row.Link>
{/each}
</Table.Root>
showDelete = true;
trackEvent(Click.FunctionsDeploymentDeleteClick);
}}>
Delete
</ActionMenu.Item.Button>
{/if}
</ActionMenu.Root>
</svelte:fragment>
</Menu>
</Table.Cell>
</Table.Row.Link>
{/each}
{/snippet}
{#snippet deleteContent(count)}
<p>
Are you sure you want to delete <strong>{count}</strong>
{count > 1 ? 'deployments' : 'deployment'} from your function -
<strong>{page.data.function.name}</strong>?
</p>
{/snippet}
</MultiSelectionTable>
{#if selectedDeployment}
<Delete {selectedDeployment} bind:showDelete />
<Activate {selectedDeployment} bind:showActivate on:activated={handleActivate} />
<Cancel {selectedDeployment} bind:showCancel />
<RedeployModal {selectedDeployment} bind:show={showRedeploy} />
<Activate {selectedDeployment} bind:showActivate on:activated={handleActivate} />
{/if}
{#if selectedRows.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedRows.length.toString()} />
<span style="white-space: nowrap">
{selectedRows.length > 1 ? 'deployments' : 'deployment'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedRows = [])}>Cancel</Button>
<Button secondary on:click={() => (showBatchDeletion = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm
title="Delete deployments"
bind:open={showBatchDeletion}
confirmDeletion
onSubmit={deleteDeployments}>
<p>
Are you sure you want to delete <strong>{selectedRows.length}</strong>
{selectedRows.length > 1 ? 'deployments' : 'deployment'} from your function -
<strong>{$func.name}</strong>?
</p>
<p class="u-bold">This action is irreversible.</p>
</Confirm>
@@ -1,7 +1,15 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/state';
import { Empty, EmptyFilter, EmptySearch, Id, PaginationWithLimit } from '$lib/components';
import {
type DeleteOperationState,
Empty,
EmptyFilter,
EmptySearch,
Id,
MultiSelectionTable,
PaginationWithLimit
} from '$lib/components';
import { hasPageQueries } from '$lib/components/filters';
import { Button } from '$lib/elements/forms';
import DualTimeView from '$lib/components/dualTimeView.svelte';
@@ -16,28 +24,18 @@
import { invalidate } from '$app/navigation';
import { trackEvent, Submit, trackError } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import type { Column } from '$lib/helpers/types';
import { writable } from 'svelte/store';
import { canWriteMessages } from '$lib/stores/roles';
import {
Badge,
FloatingActionBar,
Layout,
Link,
Table,
Typography
} from '@appwrite.io/pink-svelte';
import { Confirm } from '$lib/components';
import { Layout, Link, Table } from '@appwrite.io/pink-svelte';
import { onDestroy, onMount } from 'svelte';
import { stopPolling, pollMessagesStatus } from './helper';
import type { PageProps } from './$types';
export let data;
let selected: string[] = [];
let showDelete = false;
let deleting = false;
let showFailed = false;
let errors: string[] = [];
const { data }: PageProps = $props();
let showFailed = $state(false);
let errors: string[] = $state([]);
const columns = writable<Column[]>([
{ id: '$id', title: 'Message ID', type: 'string', width: 200 },
@@ -101,35 +99,21 @@
}
]);
const region = page.params.region;
const project = page.params.project;
async function handleDelete() {
showDelete = false;
const promises = selected.map((id) =>
sdk.forProject(region, project).messaging.delete({ messageId: id })
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) =>
sdk
.forProject(page.params.region, page.params.project)
.messaging.delete({ messageId: id })
);
try {
await Promise.all(promises);
trackEvent(Submit.MessagingMessageDelete, {
total: selected.length
});
addNotification({
type: 'success',
message: `${selected.length} message${selected.length > 1 ? 's' : ''} deleted`
});
trackEvent(Submit.MessagingMessageDelete, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.MessagingMessageDelete);
return error;
} finally {
invalidate(Dependencies.MESSAGING_MESSAGES);
selected = [];
showDelete = false;
await invalidate(Dependencies.MESSAGING_MESSAGES);
}
}
@@ -147,10 +131,10 @@
<Container>
<ResponsiveContainerHeader
{columns}
bind:view={data.view}
hideView
hasFilters
hasSearch
hasFilters
bind:view={data.view}
analyticsSource="messaging_messages"
searchPlaceholder="Search by description, type, status, or ID">
{#if $canWriteMessages}
@@ -159,81 +143,68 @@
</ResponsiveContainerHeader>
{#if data.messages.total}
<Table.Root
<MultiSelectionTable
resource="message"
columns={$columns}
allowSelection={$canWriteMessages}
let:root
bind:selectedRows={selected}>
<svelte:fragment slot="header" let:root>
onDelete={handleDelete}
allowSelection={$canWriteMessages}>
{#snippet header(root)}
{#each $columns as { id, title }}
<Table.Cell column={id} {root}>{title}</Table.Cell>
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each data.messages.messages as message (message.$id)}
<Table.Row.Link
{root}
id={message.$id}
href={`${base}/project-${region}-${project}/messaging/message-${message.$id}`}>
{#each $columns as column (column.id)}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key $columns}
<Id value={message.$id}>{message.$id}</Id>
{/key}
{:else if column.id === 'message'}
{#if message.providerType === MessagingProviderType.Push}
{message.data.title}
{:else if message.providerType === MessagingProviderType.Sms}
{message.data.content}
{:else if message.providerType === MessagingProviderType.Email}
{message.data.subject}
{:else}
Invalid provider
{/if}
{:else if column.id === 'providerType'}
<ProviderType type={message.providerType} size="xs" />
{:else if column.id === 'status'}
<Layout.Stack direction="row" gap="s">
<MessageStatusPill status={message.status} />
{#if message.status === 'failed'}
<Link.Button
on:click={(e) => {
e.preventDefault();
errors = message.deliveryErrors;
showFailed = true;
}}>Details</Link.Button>
{/if}
</Layout.Stack>
{:else if column.type === 'datetime'}
{#if !message[column.id]}
-
{:else}
<DualTimeView time={message[column.id]} />
{/if}
{:else}
{message[column.id]}
{/if}
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
</Table.Root>
{/snippet}
{#if selected.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selected.length.toString()} />
<span>
{selected.length > 1 ? 'messages' : 'message'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selected = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
{#snippet children(root)}
{#each data.messages.messages as message (message.$id)}
<Table.Row.Link
{root}
id={message.$id}
href={`${base}/project-${page.params.region}-${page.params.project}/messaging/message-${message.$id}`}>
{#each $columns as column (column.id)}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key $columns}
<Id value={message.$id}>{message.$id}</Id>
{/key}
{:else if column.id === 'message'}
{#if message.providerType === MessagingProviderType.Push}
{message.data.title}
{:else if message.providerType === MessagingProviderType.Sms}
{message.data.content}
{:else if message.providerType === MessagingProviderType.Email}
{message.data.subject}
{:else}
Invalid provider
{/if}
{:else if column.id === 'providerType'}
<ProviderType type={message.providerType} size="xs" />
{:else if column.id === 'status'}
<Layout.Stack direction="row" gap="s">
<MessageStatusPill status={message.status} />
{#if message.status === 'failed'}
<Link.Button
on:click={(e) => {
e.preventDefault();
errors = message.deliveryErrors;
showFailed = true;
}}>Details</Link.Button>
{/if}
</Layout.Stack>
{:else if column.type === 'datetime'}
{#if !message[column.id]}
-
{:else}
<DualTimeView time={message[column.id]} />
{/if}
{:else}
{message[column.id]}
{/if}
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
{/snippet}
</MultiSelectionTable>
<PaginationWithLimit
name="Messages"
@@ -248,7 +219,9 @@
<Button external href="https://appwrite.io/docs/products/messaging/messages" text>
Documentation
</Button>
<Button secondary href={`${base}/project-${region}-${project}/messaging`}>
<Button
secondary
href={`${base}/project-${page.params.region}-${page.params.project}/messaging`}>
Clear search
</Button>
</div>
@@ -258,10 +231,10 @@
<svelte:fragment slot="actions">
<Button
external
href="https://appwrite.io/docs/products/messaging/messages"
text
ariaLabel="create message"
event="empty_documentation"
ariaLabel={`create message`}>
href="https://appwrite.io/docs/products/messaging/messages">
Documentation
</Button>
{#if $canWriteMessages}
@@ -277,10 +250,3 @@
</Container>
<FailedModal bind:show={showFailed} {errors} />
<Confirm title="Delete messages" bind:open={showDelete} onSubmit={handleDelete} disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selected.length}</b>
{selected.length > 1 ? 'messages' : 'message'}?
</Typography.Text>
</Confirm>
@@ -1,134 +1,89 @@
<script lang="ts">
import { base } from '$app/paths';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Id } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import type { PageData } from './$types';
import { columns } from './store';
import { project } from '$routes/(console)/project-[region]-[project]/store';
import Provider from '../provider.svelte';
import ProviderType from '../providerType.svelte';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import { canWriteProviders } from '$lib/stores/roles';
import { Badge, FloatingActionBar, Icon, Table, Typography } from '@appwrite.io/pink-svelte';
import { Badge, Icon, Table } from '@appwrite.io/pink-svelte';
import { IconCheckCircle } from '@appwrite.io/pink-icons-svelte';
import Confirm from '$lib/components/confirm.svelte';
import { page } from '$app/state';
export let data: PageData;
const {
data
}: {
data: PageData;
} = $props();
let selectedIds: string[] = [];
let showDelete = false;
let deleting = false;
async function handleDelete() {
showDelete = false;
const promises = selectedIds.map((id) =>
sdk
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) => {
return sdk
.forProject(page.params.region, page.params.project)
.messaging.deleteProvider({ providerId: id })
);
.messaging.deleteProvider({ providerId: id });
});
try {
await Promise.all(promises);
trackEvent(Submit.MessagingProviderDelete, {
total: selectedIds.length
});
addNotification({
type: 'success',
message: `${selectedIds.length} provider${
selectedIds.length > 1 ? 's' : ''
} deleted`
});
trackEvent(Submit.MessagingProviderDelete, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.MessagingProviderDelete);
return error;
} finally {
invalidate(Dependencies.MESSAGING_PROVIDERS);
selectedIds = [];
showDelete = false;
await invalidate(Dependencies.MESSAGING_PROVIDERS);
}
}
</script>
<Table.Root
<MultiSelectionTable
resource="provider"
columns={$columns}
allowSelection={$canWriteProviders}
let:root
bind:selectedRows={selectedIds}>
<svelte:fragment slot="header" let:root>
onDelete={handleDelete}
allowSelection={$canWriteProviders}>
{#snippet header(root)}
{#each $columns as { id, title }}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each data.providers.providers as provider (provider.$id)}
<svelte:component
this={$canWriteProviders ? Table.Row.Link : Table.Row.Base}
id={provider.$id}
href={$canWriteProviders
? `${base}/project-${$project.region}-${$project.$id}/messaging/providers/provider-${provider.$id}`
{/snippet}
{#snippet children(root)}
{@const TableRowComponent = $canWriteProviders ? Table.Row.Link : Table.Row.Base}
{#each data.providers.providers as provider (provider.$id)}
{@const href = $canWriteProviders
? `${base}/project-${page.params.region}-${page.params.project}/messaging/providers/provider-${provider.$id}`
: undefined}
{root}>
{#each $columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key $columns}
<Id value={provider.$id}>{provider.$id}</Id>
{/key}
{:else if column.id === 'provider'}
<Provider provider={provider.provider} />
{:else if column.id === 'type'}
<ProviderType type={provider.type} size="xs" />
{:else if column.id === 'enabled'}
<Badge
variant="secondary"
type={provider.enabled ? 'success' : undefined}
content={provider.enabled ? 'enabled' : 'disabled'}>
<svelte:fragment slot="start">
{#if provider.enabled}
<Icon icon={IconCheckCircle} size="s" />
{/if}
</svelte:fragment>
</Badge>
{:else}
{provider[column.id]}
{/if}
</Table.Cell>
{/each}
</svelte:component>
{/each}
</Table.Root>
{#if selectedIds.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedIds.length.toString()} />
<span>
{selectedIds.length > 1 ? 'providers' : 'provider'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedIds = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm
title="Delete providers"
bind:open={showDelete}
onSubmit={handleDelete}
disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selectedIds.length}</b>
{selectedIds.length > 1 ? 'providers' : 'provider'}?
</Typography.Text>
</Confirm>
<TableRowComponent {href} {root} id={provider.$id}>
{#each $columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key $columns}
<Id value={provider.$id}>{provider.$id}</Id>
{/key}
{:else if column.id === 'provider'}
<Provider provider={provider.provider} />
{:else if column.id === 'type'}
<ProviderType type={provider.type} size="xs" />
{:else if column.id === 'enabled'}
<Badge
variant="secondary"
type={provider.enabled ? 'success' : undefined}
content={provider.enabled ? 'enabled' : 'disabled'}>
<svelte:fragment slot="start">
{#if provider.enabled}
<Icon icon={IconCheckCircle} size="s" />
{/if}
</svelte:fragment>
</Badge>
{:else}
{provider[column.id]}
{/if}
</Table.Cell>
{/each}
</TableRowComponent>
{/each}
{/snippet}
</MultiSelectionTable>
@@ -1,9 +1,7 @@
<script lang="ts">
import { base } from '$app/paths';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Id } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import type { PageData } from './$types';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
@@ -12,99 +10,70 @@
import type { Column } from '$lib/helpers/types';
import { page } from '$app/state';
import { canWriteTopics } from '$lib/stores/roles';
import { Badge, FloatingActionBar, Table, Typography } from '@appwrite.io/pink-svelte';
import Confirm from '$lib/components/confirm.svelte';
import { Table } from '@appwrite.io/pink-svelte';
export let columns: Column[];
export let data: PageData;
let {
data,
columns
}: {
data: PageData;
columns: Column[];
} = $props();
let selectedIds: string[] = [];
let showDelete = false;
let deleting = false;
async function handleDelete() {
showDelete = false;
const promises = selectedIds.map((id) =>
sdk
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) => {
return sdk
.forProject(page.params.region, page.params.project)
.messaging.deleteTopic({ topicId: id })
);
.messaging.deleteTopic({ topicId: id });
});
try {
await Promise.all(promises);
trackEvent(Submit.MessagingTopicDelete, {
total: selectedIds.length
});
addNotification({
type: 'success',
message: `${selectedIds.length} topic${selectedIds.length > 1 ? 's' : ''} deleted`
});
trackEvent(Submit.MessagingTopicDelete, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.MessagingTopicDelete);
return error;
} finally {
invalidate(Dependencies.MESSAGING_TOPICS);
selectedIds = [];
showDelete = false;
await invalidate(Dependencies.MESSAGING_TOPICS);
}
}
</script>
<Table.Root {columns} allowSelection={$canWriteTopics} let:root bind:selectedRows={selectedIds}>
<svelte:fragment slot="header" let:root>
<MultiSelectionTable
{columns}
resource="topic"
onDelete={handleDelete}
allowSelection={$canWriteTopics}>
{#snippet header(root)}
{#each columns as { id, title }}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each data.topics.topics as topic (topic.$id)}
<Table.Row.Link
{root}
id={topic.$id}
href={`${base}/project-${page.params.region}-${page.params.project}/messaging/topics/topic-${topic.$id}`}>
{#each columns as column (column.id)}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={topic.$id}>{topic.$id}</Id>
{/key}
{:else if column.type === 'datetime'}
{#if topic[column.id]}
<DualTimeView time={topic[column.id]} />
{:else}-{/if}
{:else if column.id === 'total'}
{topic.emailTotal + topic.smsTotal + topic.pushTotal}
{:else}
{topic[column.id]}
{/if}
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
</Table.Root>
{/snippet}
{#if selectedIds.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedIds.length.toString()} />
<span>
{selectedIds.length > 1 ? 'topics' : 'topic'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedIds = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm title="Delete topics" bind:open={showDelete} onSubmit={handleDelete} disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selectedIds.length}</b>
{selectedIds.length > 1 ? 'topics' : 'topic'}?
</Typography.Text>
</Confirm>
{#snippet children(root)}
{#each data.topics.topics as topic (topic.$id)}
<Table.Row.Link
{root}
id={topic.$id}
href={`${base}/project-${page.params.region}-${page.params.project}/messaging/topics/topic-${topic.$id}`}>
{#each columns as column (column.id)}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={topic.$id}>{topic.$id}</Id>
{/key}
{:else if column.type === 'datetime'}
{#if topic[column.id]}
<DualTimeView time={topic[column.id]} />
{:else}-{/if}
{:else if column.id === 'total'}
{topic.emailTotal + topic.smsTotal + topic.pushTotal}
{:else}
{topic[column.id]}
{/if}
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
{/snippet}
</MultiSelectionTable>
@@ -2,10 +2,8 @@
import { invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Id } from '$lib/components';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import type { PageData } from './$types';
import ProviderType from '../../providerType.svelte';
import DualTimeView from '$lib/components/dualTimeView.svelte';
@@ -15,21 +13,26 @@
import { targetsById } from '../../store';
import { MessagingProviderType, type Models } from '@appwrite.io/console';
import type { Column } from '$lib/helpers/types';
import { Badge, FloatingActionBar, Table, Typography } from '@appwrite.io/pink-svelte';
import Confirm from '$lib/components/confirm.svelte';
import { Table } from '@appwrite.io/pink-svelte';
export let columns: Column[];
export let data: PageData;
let {
data,
columns
}: {
data: PageData;
columns: Column[];
} = $props();
let subscribers: Record<string, Models.Subscriber> = {};
let selectedIds: string[] = [];
let selected: Record<string, Models.Subscriber> = {};
let showDelete = false;
let deleting = false;
const subscribers = $derived.by(() => {
const record: Record<string, Models.Subscriber> = {};
for (const subscriber of data.subscribers.subscribers) {
record[subscriber.$id] = subscriber;
}
async function handleDelete() {
showDelete = false;
return record;
});
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
async function deleteSubscriber(subscriberId: string) {
await sdk
.forProject(page.params.region, page.params.project)
@@ -37,111 +40,68 @@
topicId: page.params.topic,
subscriberId
});
const { target } = subscribers[subscriberId];
const { [target.$id]: _, ...rest } = $targetsById;
$targetsById = rest;
}
const promises = selectedIds.map((id) => deleteSubscriber(id));
const promises = selectedRows.map((id) => deleteSubscriber(id));
try {
await Promise.all(promises);
trackEvent(Submit.MessagingTopicSubscriberDelete, {
total: selectedIds.length
});
addNotification({
type: 'success',
message: `${selectedIds.length} subscriber${
selectedIds.length > 1 ? 's' : ''
} deleted`
});
invalidate(Dependencies.MESSAGING_TOPIC_SUBSCRIBERS);
trackEvent(Submit.MessagingTopicSubscriberDelete, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.MessagingTopicSubscriberDelete);
return error;
} finally {
selectedIds = [];
showDelete = false;
await invalidate(Dependencies.MESSAGING_TOPIC_SUBSCRIBERS);
}
}
$: data.subscribers.subscribers.forEach((s) => {
subscribers[s.$id] = s;
});
$: selectedIds.forEach((id) => {
selected[id] = subscribers[id];
});
</script>
<Table.Root {columns} allowSelection let:root bind:selectedRows={selectedIds}>
<svelte:fragment slot="header" let:root>
<MultiSelectionTable {columns} resource="subscriber" onDelete={handleDelete}>
{#snippet header(root)}
{#each columns as { id, title }}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each data.subscribers.subscribers as subscriber (subscriber.$id)}
{@const target = subscriber.target}
<Table.Row.Link
{root}
id={subscriber.$id}
href={`${base}/project-${$project.region}-${$project.$id}/auth/user-${subscriber.target.userId}`}>
{#each columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={subscriber.$id}>
{subscriber.$id}
{/snippet}
{#snippet children(root)}
{#each data.subscribers.subscribers as subscriber (subscriber.$id)}
{@const target = subscriber.target}
<Table.Row.Link
{root}
id={subscriber.$id}
href={`${base}/project-${$project.region}-${$project.$id}/auth/user-${subscriber.target.userId}`}>
{#each columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={subscriber.$id}>
{subscriber.$id}
</Id>
{/key}
{:else if column.id === 'targetId'}
<Id value={subscriber[column.id]}>
{subscriber[column.id]}
</Id>
{/key}
{:else if column.id === 'targetId'}
<Id value={subscriber[column.id]}>
{subscriber[column.id]}
</Id>
{:else if column.id === 'target'}
{#if target.providerType === MessagingProviderType.Push}
{target.name}
{:else if column.id === 'target'}
{#if target.providerType === MessagingProviderType.Push}
{target.name}
{:else}
{target.identifier}
{/if}
{:else if column.id === 'type'}
<ProviderType type={subscriber.target.providerType} size="xs" />
{:else if column.id === '$createdAt'}
<DualTimeView time={subscriber[column.id]} />
{:else}
{target.identifier}
{subscriber[column.id]}
{/if}
{:else if column.id === 'type'}
<ProviderType type={subscriber.target.providerType} size="xs" />
{:else if column.id === '$createdAt'}
<DualTimeView time={subscriber[column.id]} />
{:else}
{subscriber[column.id]}
{/if}
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
</Table.Root>
{#if selectedIds.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedIds.length.toString()} />
<span>
{selectedIds.length > 1 ? 'subscribers' : 'subscriber'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedIds = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm
title="Delete subscribers"
bind:open={showDelete}
onSubmit={handleDelete}
disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selectedIds.length}</b>
{selectedIds.length > 1 ? 'subscribers' : 'subscriber'}?
</Typography.Text>
</Confirm>
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
{/snippet}
</MultiSelectionTable>
@@ -15,6 +15,7 @@
import { writable } from 'svelte/store';
import Scopes from '../api-keys/scopes.svelte';
import { page } from '$app/state';
import { copy } from '$lib/helpers/copy';
const projectId = page.params.project;
@@ -28,7 +29,7 @@
async function create() {
try {
const { $id } = await sdk.forConsole.projects.createKey({
const { $id, secret } = await sdk.forConsole.projects.createKey({
projectId,
name,
scopes,
@@ -45,7 +46,21 @@
);
addNotification({
message: `API key has been created`,
type: 'success'
type: 'success',
buttons: [
{
name: 'Copy API key',
method: async () => {
await copy(secret);
}
},
{
name: 'Copy endpoint',
method: async () => {
await copy(sdk.forConsole.client.config.endpoint);
}
}
]
});
} catch (error) {
addNotification({
@@ -59,7 +59,12 @@
}
</script>
<Confirm onSubmit={handleDelete} title={`Delete ${label} key`} bind:open={showDelete} bind:error>
<Confirm
bind:error
bind:open={showDelete}
submissionLoader
onSubmit={handleDelete}
title={`Delete ${label} key${keyIds.length > 1 ? 's' : ''}`}>
<p>
Are you sure you want to delete <b>{keyIds.length}</b>
{label} key{keyIds.length > 1 ? 's' : ''}?
@@ -2,27 +2,32 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { Empty } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { Empty, MultiSelectionTable } from '$lib/components';
import { canWriteKeys } from '$lib/stores/roles';
import type { Models } from '@appwrite.io/console';
import { diffDays } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { devKeyColumns, keyColumns, showDevKeysCreateModal } from '../store';
import { Badge, FloatingActionBar, Layout, Table } from '@appwrite.io/pink-svelte';
import { Badge, Layout, Table } from '@appwrite.io/pink-svelte';
import DeleteBatch from './deleteBatch.svelte';
import { capitalize } from '$lib/helpers/string';
import { getEffectiveScopes } from '../api-keys/scopes.svelte';
export let keyType: 'api' | 'dev' = 'api';
export let keys: Models.KeyList | Models.DevKeyList;
let {
keyType = 'api',
keys
}: {
keyType?: 'api' | 'dev';
keys: Models.KeyList | Models.DevKeyList;
} = $props();
let showDeleteModal = false;
let selectedRows: string[] = [];
let selectedKeys = $state([]);
let showDeleteModal = $state(false);
const isApiKey = keyType === 'api';
const label = isApiKey ? 'API' : 'dev';
const slug = isApiKey ? 'api-keys' : 'dev-keys';
const columns = isApiKey ? $keyColumns : $devKeyColumns;
function getApiKeyScopeCount(key: Models.Key | Models.DevKey) {
const apiKey = key as Models.Key;
@@ -53,55 +58,65 @@
else
return 'Dev keys allow bypassing rate limits and CORS errors in your development environment.';
}
const columns = isApiKey ? $keyColumns : $devKeyColumns;
</script>
{#if keys.total}
<Table.Root let:root {columns} bind:selectedRows allowSelection={$canWriteKeys}>
<svelte:fragment slot="header" let:root>
<MultiSelectionTable
{columns}
confirmDeletion={false}
allowSelection={$canWriteKeys}
showSuccessNotification={false}
resource={`${capitalize(label)} key`}
onDelete={(selectedRows) => {
showDeleteModal = true;
selectedKeys = selectedRows;
}}>
{#snippet header(root)}
{#each columns as column}
<Table.Header.Cell column={column.id} {root}>{column.title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each getKeys() as key (key.$id)}
<Table.Row.Link id={key.$id} href={`${slug}/${key.$id}`} {root}>
<Table.Cell {root}>
{key.name}
</Table.Cell>
<Table.Cell {root}>
{#if key.accessedAt}
<DualTimeView time={key.accessedAt} />
{:else}
never
{/if}
</Table.Cell>
<Table.Cell {root}>
{@const expiration = getExpiryDetails(key)}
<Layout.Stack gap="s" direction="row">
{#if key.expire}
<DualTimeView time={key.expire} />
{/snippet}
{#snippet children(root)}
{#each getKeys() as key (key.$id)}
<Table.Row.Link id={key.$id} href={`${slug}/${key.$id}`} {root}>
<Table.Cell {root}>
{key.name}
</Table.Cell>
<Table.Cell {root}>
{#if key.accessedAt}
<DualTimeView time={key.accessedAt} />
{:else}
never
{/if}
{#if expiration.status}
<Badge
size="s"
variant="secondary"
type={expiration.status}
content={expiration.message} />
{/if}
</Layout.Stack>
</Table.Cell>
{#if isApiKey}
<Table.Cell {root}>
{getApiKeyScopeCount(key)} Scopes
</Table.Cell>
{/if}
</Table.Row.Link>
{/each}
</Table.Root>
<Table.Cell {root}>
{@const expiration = getExpiryDetails(key)}
<Layout.Stack gap="s" direction="row">
{#if key.expire}
<DualTimeView time={key.expire} />
{:else}
never
{/if}
{#if expiration.status}
<Badge
size="s"
variant="secondary"
type={expiration.status}
content={expiration.message} />
{/if}
</Layout.Stack>
</Table.Cell>
{#if isApiKey}
<Table.Cell {root}>
{getApiKeyScopeCount(key)} Scopes
</Table.Cell>
{/if}
</Table.Row.Link>
{/each}
{/snippet}
</MultiSelectionTable>
{:else}
<Empty
single
@@ -109,9 +124,9 @@
href="https://appwrite.io/docs/advanced/platform/{slug}"
target="{label} key"
description={getDescription()}
on:click={() => {
on:click={async () => {
if (isApiKey) {
goto(
await goto(
`${base}/project-${page.params.region}-${page.params.project}/overview/${slug}/create`
);
} else {
@@ -120,21 +135,4 @@
}} />
{/if}
{#if selectedRows.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedRows.length.toString()} />
<span>
{capitalize(label)}
{selectedRows.length > 1 ? 'keys' : 'key'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedRows = [])}>Cancel</Button>
<Button secondary on:click={() => (showDeleteModal = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<DeleteBatch {keyType} bind:keyIds={selectedRows} bind:showDelete={showDeleteModal} />
<DeleteBatch {keyType} bind:keyIds={selectedKeys} bind:showDelete={showDeleteModal} />
@@ -3,6 +3,10 @@
import { page } from '$app/state';
import { Cover, CoverTitle } from '$lib/layout';
import { key } from './store';
import { RegionEndpoint, Copy } from '$lib/components';
import { Layout, Tag, Icon } from '@appwrite.io/pink-svelte';
import { IconDuplicate } from '@appwrite.io/pink-icons-svelte';
import { projectRegion } from '../../../store';
const projectId = page.params.project;
</script>
@@ -12,5 +16,26 @@
<CoverTitle href={`${base}/project-${page.params.region}-${projectId}/overview/api-keys`}>
{$key?.name}
</CoverTitle>
<Layout.Stack direction="row" inline>
{#if $key?.secret}
<Copy value={$key.secret} copyText="Copy API secret">
<Tag size="xs" variant="code">
<Icon icon={IconDuplicate} size="s" slot="start" />
<span class="api-secret-label"> API secret </span>
</Tag>
</Copy>
{/if}
{#if $projectRegion}
<RegionEndpoint region={$projectRegion} />
{/if}
</Layout.Stack>
</svelte:fragment>
</Cover>
<style>
.api-secret-label {
white-space: nowrap;
overflow: hidden;
word-break: break-all;
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -2,19 +2,24 @@
import { page } from '$app/state';
import { Id, RegionEndpoint } from '$lib/components';
import { Cover } from '$lib/layout';
import { project, projectRegion } from '../store';
import { projectRegion } from '../store';
import { hasOnboardingDismissed, setHasOnboardingDismissed } from '$lib/helpers/onboarding';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { resolve } from '$app/paths';
import { Layout, Button, Typography } from '@appwrite.io/pink-svelte';
import { user } from '$lib/stores/user';
import { isSmallViewport } from '$lib/stores/viewport';
import { trackEvent } from '$lib/actions/analytics';
function dismissOnboarding() {
setHasOnboardingDismissed($project.$id, $user);
setHasOnboardingDismissed(page.params.project, $user);
trackEvent('onboarding_hub_platform_dismiss');
goto(`${base}/project-${$project.region}-${$project.$id}/overview/platforms`);
goto(
resolve('/(console)/project-[region]-[project]/overview/platforms', {
region: page.params.region,
project: page.params.project
})
);
}
</script>
@@ -24,12 +29,15 @@
<Layout.Stack alignItems="baseline" direction={$isSmallViewport ? 'column' : 'row'}>
<Typography.Title color="--fgcolor-neutral-primary" size="xl" truncate>
<span class="project-title">
{$project?.name}
{page.data.project?.name}
</span>
</Typography.Title>
<Layout.Stack direction="row" inline>
<Id value={$project.$id}>{$project.$id}</Id>
<RegionEndpoint region={$projectRegion} />
<Id value={page.params.project} copyText="Copy project ID"
>{page.params.project}</Id>
{#if $projectRegion}
<RegionEndpoint region={$projectRegion} />
{/if}
</Layout.Stack>
</Layout.Stack>
</svelte:fragment>
@@ -53,7 +61,7 @@
>Follow a few quick steps to get started with Appwrite</Typography.Text>
</Layout.Stack>
<div class="dashboard-header-button">
{#if !hasOnboardingDismissed($project.$id, $user)}
{#if !hasOnboardingDismissed(page.params.project, $user)}
<Button.Button size="s" variant="secondary" on:click={dismissOnboarding}>
Dismiss this page
</Button.Button>
@@ -14,12 +14,7 @@
import { app } from '$lib/stores/app';
import AuthPreview from './assets/auth-preview.svg';
import AuthPreviewDark from './assets/auth-preview-dark.svg';
import {
IconArrowRight,
IconNodeJs,
IconPhp,
IconPython
} from '@appwrite.io/pink-icons-svelte';
import { IconArrowRight } from '@appwrite.io/pink-icons-svelte';
import DatabaseImgSource from './assets/database.png';
import DatabaseImgSourceDark from './assets/database-dark.png';
import DiscordImgSource from './assets/discord.png';
@@ -31,9 +26,10 @@
import PlatformAndroidImgSourceDark from './assets/platform-android-dark.svg';
import PlatformFlutterImgSource from './assets/platform-flutter.svg';
import PlatformFlutterImgSourceDark from './assets/platform-flutter-dark.svg';
import PlatformSdkImgSource from './assets/platform-sdk.jpg';
import PlatformSdkImgSourceDark from './assets/platform-sdk-dark.png';
import { base } from '$app/paths';
import { isSmallViewport } from '$lib/stores/viewport';
import { AvatarGroup } from '$lib/components';
import type { Models } from '@appwrite.io/console';
import { getPlatformInfo } from '$lib/helpers/platform';
import { Click, trackEvent } from '$lib/actions/analytics';
@@ -103,7 +99,9 @@
</Layout.Stack>
</div>
<Layout.Stack gap="l">
<Layout.Stack gap="l" direction="row">
<Layout.Stack
gap="l"
direction={$isSmallViewport ? 'column' : 'row'}>
<Card.Button
on:click={() => {
openPlatformWizard(0, platformMap.get('Web'));
@@ -341,17 +339,34 @@
</Layout.Stack>
</Card.Button>
</Layout.Stack>
<Layout.Stack direction="row" gap="xxs" alignItems="center">
<Typography.Text>Or connect</Typography.Text>
<Link.Button variant="muted" on:click={createKey}
>server side</Link.Button>
<div style:padding-inline-start="8px">
<AvatarGroup
icons={[IconPython, IconNodeJs, IconPhp]}
total={7}
size="s" />
</div>
</Layout.Stack>
<span class="with-separators eyebrow-heading-3">or</span>
<Card.Button on:click={createKey} padding="none">
<Layout.Stack gap="xl">
<div
class="card-top-image api-key-card-image"
style:background-image={`url('${
$app.themeInUse === 'dark'
? PlatformSdkImgSourceDark
: PlatformSdkImgSource
}')`}>
<Layout.Stack
direction="row"
alignItems="center"
justifyContent="space-between">
<Layout.Stack gap="xxs">
<Typography.Title size="s"
>Create API key</Typography.Title>
<Typography.Text
>Connect your server or backend to Appwrite</Typography.Text>
</Layout.Stack>
<div class="arrow-icon">
<Icon icon={IconArrowRight} size="s" />
</div>
</Layout.Stack>
</div>
</Layout.Stack>
</Card.Button>
</Layout.Stack>
</Layout.Stack></Step.Item>
<Step.Item state="next"
@@ -684,6 +699,24 @@
background-position: bottom;
background-repeat: no-repeat;
}
.api-key-card-image {
background-size: cover;
background-position: right center;
background-repeat: no-repeat;
margin: 0;
width: 100%;
height: 100%;
min-height: 160px;
border-radius: var(--border-radius-m);
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-start;
padding: var(--base-16, 16px);
@media (min-width: 1200px) {
min-height: 187px;
}
}
.full-height-card {
height: 100%;
}
@@ -17,7 +17,7 @@
import { Card } from '$lib/components';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { type AppwriteRealtimeSubscription, sdk } from '$lib/stores/sdk';
import { realtime, sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
import { fade } from 'svelte/transition';
@@ -108,8 +108,10 @@ const val APPWRITE_PUBLIC_ENDPOINT = "${sdk.forProject(page.params.region, page.
message: 'Platform created.'
});
invalidate(Dependencies.PROJECT);
invalidate(Dependencies.PLATFORMS);
await Promise.all([
invalidate(Dependencies.PROJECT),
invalidate(Dependencies.PLATFORMS)
]);
} catch (error) {
trackError(error, Submit.PlatformCreate);
addNotification({
@@ -126,20 +128,17 @@ const val APPWRITE_PUBLIC_ENDPOINT = "${sdk.forProject(page.params.region, page.
}
onMount(() => {
let subscription: AppwriteRealtimeSubscription;
sdk.forConsole.realtime
.subscribe('console', (response) => {
if (response.events.includes(`projects.${projectId}.ping`)) {
connectionSuccessful = true;
invalidate(Dependencies.ORGANIZATION);
invalidate(Dependencies.PROJECT);
subscription?.close();
}
})
.then((realtime) => (subscription = realtime));
const unsubscribe = realtime.forConsole(page.params.region, 'console', (response) => {
if (response.events.includes(`projects.${projectId}.ping`)) {
connectionSuccessful = true;
invalidate(Dependencies.ORGANIZATION);
invalidate(Dependencies.PROJECT);
unsubscribe();
}
});
return () => {
subscription?.close();
unsubscribe();
resetPlatformStore();
};
});
@@ -18,7 +18,7 @@
import { Card } from '$lib/components';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { type AppwriteRealtimeSubscription, sdk } from '$lib/stores/sdk';
import { realtime, sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
import { fade } from 'svelte/transition';
@@ -97,8 +97,10 @@ APPWRITE_PUBLIC_ENDPOINT: "${sdk.forProject(page.params.region, page.params.proj
message: 'Platform created.'
});
invalidate(Dependencies.PROJECT);
invalidate(Dependencies.PLATFORMS);
await Promise.all([
invalidate(Dependencies.PROJECT),
invalidate(Dependencies.PLATFORMS)
]);
} catch (error) {
trackError(error, Submit.PlatformCreate);
addNotification({
@@ -115,20 +117,17 @@ APPWRITE_PUBLIC_ENDPOINT: "${sdk.forProject(page.params.region, page.params.proj
}
onMount(() => {
let subscription: AppwriteRealtimeSubscription;
sdk.forConsole.realtime
.subscribe('console', (response) => {
if (response.events.includes(`projects.${projectId}.ping`)) {
connectionSuccessful = true;
invalidate(Dependencies.ORGANIZATION);
invalidate(Dependencies.PROJECT);
subscription?.close();
}
})
.then((realtime) => (subscription = realtime));
const unsubscribe = realtime.forConsole(page.params.region, 'console', (response) => {
if (response.events.includes(`projects.${projectId}.ping`)) {
connectionSuccessful = true;
invalidate(Dependencies.ORGANIZATION);
invalidate(Dependencies.PROJECT);
unsubscribe();
}
});
return () => {
subscription?.close();
unsubscribe();
resetPlatformStore();
};
});
@@ -18,7 +18,7 @@
import { Card } from '$lib/components';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { type AppwriteRealtimeSubscription, sdk } from '$lib/stores/sdk';
import { realtime, sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
import { fade } from 'svelte/transition';
@@ -161,8 +161,10 @@ client.ping();
message: 'Platform created.'
});
invalidate(Dependencies.PROJECT);
invalidate(Dependencies.PLATFORMS);
await Promise.all([
invalidate(Dependencies.PROJECT),
invalidate(Dependencies.PLATFORMS)
]);
} catch (error) {
trackError(error, Submit.PlatformCreate);
addNotification({
@@ -179,20 +181,17 @@ client.ping();
}
onMount(() => {
let subscription: AppwriteRealtimeSubscription;
sdk.forConsole.realtime
.subscribe('console', (response) => {
if (response.events.includes(`projects.${projectId}.ping`)) {
connectionSuccessful = true;
invalidate(Dependencies.ORGANIZATION);
invalidate(Dependencies.PROJECT);
subscription?.close();
}
})
.then((realtime) => (subscription = realtime));
const unsubscribe = realtime.forConsole(page.params.region, 'console', (response) => {
if (response.events.includes(`projects.${projectId}.ping`)) {
connectionSuccessful = true;
invalidate(Dependencies.ORGANIZATION);
invalidate(Dependencies.PROJECT);
unsubscribe();
}
});
return () => {
subscription?.close();
unsubscribe();
resetPlatformStore();
};
});
@@ -18,7 +18,7 @@
import { Card } from '$lib/components';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { type AppwriteRealtimeSubscription, sdk } from '$lib/stores/sdk';
import { realtime, sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
import { fade } from 'svelte/transition';
@@ -128,8 +128,10 @@ EXPO_PUBLIC_APPWRITE_ENDPOINT=${sdk.forProject(page.params.region, page.params.p
message: 'Platform created.'
});
invalidate(Dependencies.PROJECT);
invalidate(Dependencies.PLATFORMS);
await Promise.all([
invalidate(Dependencies.PROJECT),
invalidate(Dependencies.PLATFORMS)
]);
} catch (error) {
trackError(error, Submit.PlatformCreate);
addNotification({
@@ -146,20 +148,17 @@ EXPO_PUBLIC_APPWRITE_ENDPOINT=${sdk.forProject(page.params.region, page.params.p
}
onMount(() => {
let subscription: AppwriteRealtimeSubscription;
sdk.forConsole.realtime
.subscribe('console', (response) => {
if (response.events.includes(`projects.${projectId}.ping`)) {
connectionSuccessful = true;
invalidate(Dependencies.ORGANIZATION);
invalidate(Dependencies.PROJECT);
subscription?.close();
}
})
.then((realtime) => (subscription = realtime));
const unsubscribe = realtime.forConsole(page.params.region, 'console', (response) => {
if (response.events.includes(`projects.${projectId}.ping`)) {
connectionSuccessful = true;
invalidate(Dependencies.ORGANIZATION);
invalidate(Dependencies.PROJECT);
unsubscribe();
}
});
return () => {
subscription?.close();
unsubscribe();
resetPlatformStore();
};
});
@@ -28,7 +28,7 @@
} from '@appwrite.io/pink-icons-svelte';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { type AppwriteRealtimeSubscription, sdk } from '$lib/stores/sdk';
import { realtime, sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
import { fade } from 'svelte/transition';
@@ -241,8 +241,10 @@ APPWRITE_ENDPOINT = "${sdk.forProject(page.params.region, page.params.project).c
message: 'Platform created.'
});
invalidate(Dependencies.PROJECT);
invalidate(Dependencies.PLATFORMS);
await Promise.all([
invalidate(Dependencies.PROJECT),
invalidate(Dependencies.PLATFORMS)
]);
} catch (error) {
trackError(error, Submit.PlatformCreate);
addNotification({
@@ -259,21 +261,18 @@ APPWRITE_ENDPOINT = "${sdk.forProject(page.params.region, page.params.project).c
}
onMount(() => {
let subscription: AppwriteRealtimeSubscription;
sdk.forConsole.realtime
.subscribe('console', (response) => {
if (response.events.includes(`projects.${projectId}.ping`)) {
connectionSuccessful = true;
invalidate(Dependencies.ORGANIZATION);
invalidate(Dependencies.PROJECT);
subscription?.close();
}
})
.then((realtime) => (subscription = realtime));
const unsubscribe = realtime.forConsole(page.params.region, 'console', (response) => {
if (response.events.includes(`projects.${projectId}.ping`)) {
connectionSuccessful = true;
invalidate(Dependencies.ORGANIZATION);
invalidate(Dependencies.PROJECT);
unsubscribe();
}
});
return () => {
unsubscribe();
resetPlatformStore();
subscription?.close();
};
});
</script>
@@ -38,13 +38,11 @@
let migration: Models.Migration = null;
onMount(() => {
return realtime
.forProject(page.params.region, page.params.project)
.subscribe(['project', 'console'], (response) => {
if (response.events.includes('migrations.*')) {
invalidate(Dependencies.MIGRATIONS);
}
});
return realtime.forProject(page.params.region, ['project', 'console'], (response) => {
if (response.events.includes('migrations.*')) {
invalidate(Dependencies.MIGRATIONS);
}
});
});
$: $registerCommands([
@@ -25,7 +25,8 @@
import { onMount } from 'svelte';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import { realtime } from '$lib/stores/sdk';
import { page } from '$app/state';
export let data;
@@ -49,7 +50,7 @@
$updateCommandGroupRanks({ sites: 1000 });
onMount(() => {
return sdk.forConsole.realtime.subscribe('console', (response) => {
return realtime.forConsole(page.params.region, 'console', (response) => {
if (response.events.includes('sites.*')) {
invalidate(Dependencies.SITES);
}
@@ -7,42 +7,36 @@
import Aside from '../aside.svelte';
import Logs from '../../(components)/logs.svelte';
import { Copy, SvgIcon } from '$lib/components';
import { type AppwriteRealtimeSubscription, sdk } from '$lib/stores/sdk';
import { realtime } from '$lib/stores/sdk';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { getFrameworkIcon } from '$lib/stores/sites';
import type { Models } from '@appwrite.io/console';
let { data } = $props();
let deployment = $state(data.deployment);
onMount(() => {
let subscription: AppwriteRealtimeSubscription;
sdk.forConsoleIn(page.params.region)
.realtime.subscribe('console', async (response) => {
if (
response.events.includes(
`sites.${data.site.$id}.deployments.${data.deployment.$id}.update`
)
) {
deployment = response.payload;
if (response.payload.status === 'ready') {
const resolvedUrl = resolve(
'/(console)/project-[region]-[project]/sites/create-site/finish',
{
region: page.params.region,
project: page.params.project
}
);
await goto(`${resolvedUrl}?site=${data.site.$id}`);
}
return realtime.forConsole(page.params.region, 'console', async (response) => {
if (
response.events.includes(
`sites.${data.site.$id}.deployments.${data.deployment.$id}.update`
)
) {
deployment = response.payload as Models.Deployment;
if (deployment.status === 'ready') {
const resolvedUrl = resolve(
'/(console)/project-[region]-[project]/sites/create-site/finish',
{
region: page.params.region,
project: page.params.project
}
);
await goto(`${resolvedUrl}?site=${data.site.$id}`);
}
})
.then((realtime) => (subscription = realtime));
return () => {
subscription?.close();
};
}
});
});
</script>
@@ -174,24 +174,21 @@
on:invalid={handleInvalid}>
<Layout.Stack alignItems="center" gap="s">
<Layout.Stack alignItems="center" gap="s">
<Layout.Stack
alignItems="center"
justifyContent="center"
direction="row"
gap="s">
<Typography.Text variant="l-500">
Drag and drop file here or click to upload
</Typography.Text>
<Tooltip>
<Layout.Stack alignItems="center" justifyContent="center" inline>
<Typography.Text variant="l-500" align="center" inline>
Drag and drop file here or click to upload;
<Layout.Stack
style="display: inline-flex; vertical-align: middle;"
inline
alignItems="center"
justifyContent="center"
inline>
<Icon icon={IconInfo} size="s" />
justifyContent="center">
<Tooltip>
<Icon icon={IconInfo} size="s" />
<svelte:fragment slot="tooltip"
>Only .tar.gz files allowed</svelte:fragment>
</Tooltip>
</Layout.Stack>
<svelte:fragment slot="tooltip"
>Only .tar.gz files allowed</svelte:fragment>
</Tooltip>
</Typography.Text>
</Layout.Stack>
{#if maxSize > 0}
<Typography.Caption variant="400"
@@ -41,8 +41,8 @@
variables.map((variable) => {
if (variable.value === '{apiEndpoint}') {
variable.value = getApiEndpoint();
variable.placeholder = getApiEndpoint();
variable.value = getApiEndpoint(page.params.region);
variable.placeholder = getApiEndpoint(page.params.region);
} else if (variable.value === '{projectId}') {
variable.value = page.params.project;
variable.placeholder = page.params.project;
@@ -7,19 +7,21 @@
import { Button } from '$lib/elements/forms';
import InstantRollbackDomain from './instantRollbackModal.svelte';
import { app } from '$lib/stores/app';
import { sdk } from '$lib/stores/sdk';
import { realtime } from '$lib/stores/sdk';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { onMount } from 'svelte';
import { page } from '$app/state';
import { base } from '$app/paths';
import type { PageProps } from './$types';
import { regionalProtocol } from '$routes/(console)/project-[region]-[project]/store';
export let data;
let showRollback = false;
let { data }: PageProps = $props();
let showRollback = $state(false);
onMount(() => {
return sdk.forConsole.realtime.subscribe('console', (response) => {
return realtime.forConsole(page.params.region, 'console', (response) => {
if (response.events.includes(`sites.${page.params.site}.deployments.*`)) {
invalidate(Dependencies.SITE);
}
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { sdk } from '$lib/stores/sdk';
import { realtime } from '$lib/stores/sdk';
import { Dependencies } from '$lib/constants';
import { invalidate, goto } from '$app/navigation';
import { registerCommands } from '$lib/commandCenter';
@@ -13,11 +13,14 @@
onMount(() => {
let previousStatus: string = null;
return sdk.forConsole.realtime.subscribe<Models.Deployment>('console', (message) => {
if (message.payload.status !== 'ready' && previousStatus === message.payload.status) {
return realtime.forConsole(page.params.region, 'console', (message) => {
const payload = message.payload as Models.Deployment;
if (payload.status !== 'ready' && previousStatus === payload.status) {
return;
}
previousStatus = message.payload.status;
previousStatus = payload.status;
if (message.events.includes('sites.*.deployments.*.create')) {
invalidate(Dependencies.DEPLOYMENTS);
@@ -13,7 +13,7 @@
import DeploymentMetrics from './deploymentMetrics.svelte';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import { onMount } from 'svelte';
import { sdk } from '$lib/stores/sdk';
import { realtime, sdk } from '$lib/stores/sdk';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import CreateCliModal from './createCliModal.svelte';
@@ -32,11 +32,7 @@
let showAlert = true;
onMount(() => {
if (page.url.searchParams.has('createDeployment')) {
showConnectRepo = true;
}
return sdk.forConsole.realtime.subscribe('console', (response) => {
return realtime.forConsole(page.params.region, 'console', (response) => {
if (response.events.includes('sites.*.deployments.*')) {
invalidate(Dependencies.DEPLOYMENTS);
}
@@ -1,6 +1,6 @@
<script lang="ts">
import { Container } from '$lib/layout';
import { type AppwriteRealtimeSubscription, sdk } from '$lib/stores/sdk';
import { realtime } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import SiteCard from '../../../(components)/siteCard.svelte';
import Logs, { badgeTypeDeployment } from '../../../(components)/logs.svelte';
@@ -31,22 +31,15 @@
let showCancel = $state(false);
onMount(() => {
let subscription: AppwriteRealtimeSubscription;
sdk.forConsoleIn(page.params.region)
.realtime.subscribe('console', async (response) => {
if (
response.events.includes(
`sites.${page.params.site}.deployments.${page.params.deployment}.update`
)
) {
await invalidate(Dependencies.DEPLOYMENT);
}
})
.then((realtime) => (subscription = realtime));
return () => {
subscription?.close();
};
return realtime.forConsole(page.params.region, 'console', async (response) => {
if (
response.events.includes(
`sites.${page.params.site}.deployments.${page.params.deployment}.update`
)
) {
await invalidate(Dependencies.DEPLOYMENT);
}
});
});
</script>
@@ -1,5 +1,5 @@
<script lang="ts">
import { Confirm, Id } from '$lib/components';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import type { PageData } from './$types';
import { type Models } from '@appwrite.io/console';
import { formatTimeDetailed } from '$lib/helpers/timeConversion';
@@ -11,7 +11,7 @@
import RedeployModal from '../../redeployModal.svelte';
import Cancel from './cancelDeploymentModal.svelte';
import { base } from '$app/paths';
import { Badge, FloatingActionBar, Layout, Status, Table } from '@appwrite.io/pink-svelte';
import { Layout, Status, Table } from '@appwrite.io/pink-svelte';
import { columns } from './store';
import ActivateDeploymentModal from '../../activateDeploymentModal.svelte';
import { capitalize } from '$lib/helpers/string';
@@ -19,26 +19,23 @@
import { deploymentStatusConverter } from '$lib/stores/git';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { site } from '$routes/(console)/project-[region]-[project]/sites/site-[site]/store';
export let data: PageData;
const {
data
}: {
data: PageData;
} = $props();
let showDelete = false;
let showActivate = false;
let showRedeploy = false;
let showCancel = false;
let showDelete = $state(false);
let showActivate = $state(false);
let showRedeploy = $state(false);
let showCancel = $state(false);
let selectedRows = [];
let showBatchDeletion = false;
let selectedDeployment: Models.Deployment = null;
async function deleteDeployments() {
showBatchDeletion = false;
let selectedDeployment: Models.Deployment | null = $state(null);
async function deleteDeployments(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((deploymentId) =>
sdk.forProject(page.params.region, page.params.project).sites.deleteDeployment({
siteId: page.params.site,
@@ -48,18 +45,10 @@
try {
await Promise.all(promises);
trackEvent(Submit.DeploymentDelete);
addNotification({
type: 'success',
message: `${selectedRows.length} deployment${selectedRows.length > 1 ? 's' : ''} deleted`
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.DeploymentDelete);
return error;
} finally {
selectedRows = [];
await Promise.all([
invalidate(Dependencies.DEPLOYMENTS),
invalidate(Dependencies.SITE)
@@ -68,75 +57,83 @@
}
</script>
<Table.Root
let:root
allowSelection
bind:selectedRows
<MultiSelectionTable
resource="deployment"
onDelete={deleteDeployments}
columns={[...$columns, { id: 'actions', width: 40 }]}>
<svelte:fragment slot="header" let:root>
{#snippet header(root)}
{#each $columns as { id, title }}
<Table.Header.Cell column={id} {root}>
{title}
</Table.Header.Cell>
{/each}
<Table.Header.Cell column="actions" {root} />
</svelte:fragment>
{#each data.deploymentList.deployments as deployment (deployment.$id)}
<Table.Row.Link
{root}
id={deployment.$id}
href={`${base}/project-${page.params.region}-${page.params.project}/sites/site-${page.params.site}/deployments/deployment-${deployment.$id}`}>
{#each $columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={deployment.$id}>{deployment.$id}</Id>
{/key}
{:else if column.id === 'status'}
{@const status = deployment.status}
{#if data?.activeDeployment?.$id === deployment?.$id}
<Status status="complete" label="Active" />
{:else}
<Status
status={deploymentStatusConverter(status)}
label={capitalize(status)} />
{/snippet}
{#snippet children(root)}
{#each data.deploymentList.deployments as deployment (deployment.$id)}
<Table.Row.Link
{root}
id={deployment.$id}
href={`${base}/project-${page.params.region}-${page.params.project}/sites/site-${page.params.site}/deployments/deployment-${deployment.$id}`}>
{#each $columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={deployment.$id}>{deployment.$id}</Id>
{/key}
{:else if column.id === 'status'}
{@const status = deployment.status}
{#if data?.activeDeployment?.$id === deployment?.$id}
<Status status="complete" label="Active" />
{:else}
<Status
status={deploymentStatusConverter(status)}
label={capitalize(status)} />
{/if}
{:else if column.id === 'type'}
<DeploymentSource {deployment} />
{:else if column.id === '$updatedAt'}
<DeploymentCreatedBy {deployment} />
{:else if column.id === 'buildDuration'}
{#if ['waiting'].includes(deployment.status)}
-
{:else if ['processing', 'building'].includes(deployment.status)}
<span use:timer={{ start: deployment.$createdAt }}></span>
{:else}
{formatTimeDetailed(deployment.buildDuration)}
{/if}
{:else if column.id === 'totalSize'}
{calculateSize(deployment?.totalSize ?? 0)}
{:else if column.id === 'sourceSize'}
{calculateSize(deployment.sourceSize)}
{:else if column.id === 'buildSize'}
{calculateSize(deployment.buildSize)}
{/if}
{:else if column.id === 'type'}
<DeploymentSource {deployment} />
{:else if column.id === '$updatedAt'}
<DeploymentCreatedBy {deployment} />
{:else if column.id === 'buildDuration'}
{#if ['waiting'].includes(deployment.status)}
-
{:else if ['processing', 'building'].includes(deployment.status)}
<span use:timer={{ start: deployment.$createdAt }}></span>
{:else}
{formatTimeDetailed(deployment.buildDuration)}
{/if}
{:else if column.id === 'totalSize'}
{calculateSize(deployment?.totalSize ?? 0)}
{:else if column.id === 'sourceSize'}
{calculateSize(deployment.sourceSize)}
{:else if column.id === 'buildSize'}
{calculateSize(deployment.buildSize)}
{/if}
</Table.Cell>
{/each}
<Table.Cell column="actions" {root}>
<Layout.Stack alignItems="flex-end">
<DeploymentActionMenu
{deployment}
bind:selectedDeployment
bind:showRedeploy
bind:showActivate
bind:showDelete
bind:showCancel
activeDeployment={data.site.deploymentId} />
</Layout.Stack>
</Table.Cell>
{/each}
<Table.Cell column="actions" {root}>
<Layout.Stack alignItems="flex-end">
<DeploymentActionMenu
{deployment}
bind:selectedDeployment
bind:showRedeploy
bind:showActivate
bind:showDelete
bind:showCancel
activeDeployment={data.site.deploymentId} />
</Layout.Stack>
</Table.Cell>
</Table.Row.Link>
{/each}
</Table.Root>
</Table.Row.Link>
{/each}
{/snippet}
{#snippet deleteContent(count)}
Are you sure you want to delete <strong>{count}</strong>
{count > 1 ? 'deployments' : 'deployment'} from your site -
<strong>{page.data.site.name}</strong>?
{/snippet}
</MultiSelectionTable>
{#if selectedDeployment}
<Delete {selectedDeployment} activeDeployment={data.site.deploymentId} bind:showDelete />
@@ -154,33 +151,3 @@
selectedDeploymentId={selectedDeployment.$id}
bind:show={showActivate} />
{/if}
{#if selectedRows.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedRows.length.toString()} />
<span style="white-space: nowrap">
{selectedRows.length > 1 ? 'deployments' : 'deployment'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedRows = [])}>Cancel</Button>
<Button secondary on:click={() => (showBatchDeletion = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm
title="Delete deployments"
confirmDeletion
onSubmit={deleteDeployments}
bind:open={showBatchDeletion}>
<p>
Are you sure you want to delete <strong>{selectedRows.length}</strong>
{selectedRows.length > 1 ? 'deployments' : 'deployment'} from your site -
<strong>{$site.name}</strong>?
</p>
<p class="u-bold">This action is irreversible.</p>
</Confirm>
@@ -4,16 +4,17 @@
import { Dependencies } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { Container, ResponsiveContainerHeader } from '$lib/layout';
import { sdk } from '$lib/stores/sdk';
import { realtime } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import Table from './table.svelte';
import { Card, Empty } from '@appwrite.io/pink-svelte';
import { columns } from './store';
import { page } from '$app/state';
export let data;
onMount(() => {
return sdk.forConsole.realtime.subscribe('console', (response) => {
return realtime.forConsole(page.params.region, 'console', (response) => {
if (response.events.includes('sites.*.executions.*')) {
invalidate(Dependencies.EXECUTIONS);
}
@@ -1,137 +1,105 @@
<script lang="ts">
import { Confirm, Id } from '$lib/components';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import type { Column } from '$lib/helpers/types';
import type { Models } from '@appwrite.io/console';
import { Badge, FloatingActionBar, Table, Typography } from '@appwrite.io/pink-svelte';
import { Badge, Table, Typography } from '@appwrite.io/pink-svelte';
import Sheet from './sheet.svelte';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { sdk } from '$lib/stores/sdk';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { calculateTime } from '$lib/helpers/timeConversion';
import { getBadgeTypeFromStatusCode } from '$lib/helpers/httpStatus';
import { Button } from '$lib/elements/forms';
import { timer } from '$lib/actions/timer';
export let columns: Column[];
export let logs: Models.ExecutionList;
let {
columns,
logs
}: {
columns: Column[];
logs: Models.ExecutionList;
} = $props();
let openSheet = false;
let selectedLogId: string = null;
$: filteredColumns = columns.filter((c) => !c.exclude);
let selectedRows = [];
let showBatchDeletion = false;
async function deleteLogs() {
showBatchDeletion = false;
let openSheet = $state(false);
let selectedLogId = $state<string | null>(null);
const filteredColumns = $derived(columns.filter((c) => !c.exclude));
async function deleteLogs(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((logId) =>
sdk.forProject(page.params.region, page.params.project).sites.deleteLog({
siteId: page.params.site,
logId
})
);
try {
await Promise.all(promises);
trackEvent(Submit.LogDelete);
addNotification({
type: 'success',
message: `${selectedRows.length} log${selectedRows.length > 1 ? 's' : ''} deleted`
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.LogDelete);
return error;
} finally {
selectedRows = [];
invalidate(Dependencies.EXECUTIONS);
await invalidate(Dependencies.EXECUTIONS);
}
}
</script>
<Table.Root let:root allowSelection bind:selectedRows columns={filteredColumns}>
<svelte:fragment slot="header" let:root>
<MultiSelectionTable resource="log" columns={filteredColumns} onDelete={deleteLogs}>
{#snippet header(root)}
{#each filteredColumns as { id, title }}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each logs.executions as log (log.$id)}
<Table.Row.Button
{root}
id={log.$id}
on:click={(e) => {
e.stopPropagation();
openSheet = true;
selectedLogId = log.$id;
}}>
{#each filteredColumns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={log.$id}>{log.$id}</Id>
{/key}
{:else if column.id === 'deploymentId'}
<Id value={log.deploymentId}>{log.deploymentId}</Id>
{:else if column.id === 'requestMethod'}
<Typography.Code size="m">
{log.requestMethod}
</Typography.Code>
{:else if column.id === 'duration'}
{#if ['processing', 'waiting'].includes(log.status)}
<span use:timer={{ start: log.$createdAt }}></span>
{:else}
{calculateTime(log.duration)}
{/snippet}
{#snippet children(root)}
{#each logs.executions as log (log.$id)}
<Table.Row.Button
{root}
id={log.$id}
on:click={(e) => {
e.stopPropagation();
openSheet = true;
selectedLogId = log.$id;
}}>
{#each filteredColumns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={log.$id}>{log.$id}</Id>
{/key}
{:else if column.id === 'deploymentId'}
<Id value={log.deploymentId}>{log.deploymentId}</Id>
{:else if column.id === 'requestMethod'}
<Typography.Code size="m">
{log.requestMethod}
</Typography.Code>
{:else if column.id === 'duration'}
{#if ['processing', 'waiting'].includes(log.status)}
<span use:timer={{ start: log.$createdAt }}></span>
{:else}
{calculateTime(log.duration)}
{/if}
{:else if column.id === 'responseStatusCode'}
<div>
<Badge
variant="secondary"
type={getBadgeTypeFromStatusCode(log.responseStatusCode)}
content={log.responseStatusCode.toString()} />
</div>
{:else if column.id === 'requestPath'}
<Typography.Code size="m">
{log.requestPath}
</Typography.Code>
{:else if column.id === '$createdAt'}
<DualTimeView time={log.$createdAt} />
{/if}
{:else if column.id === 'responseStatusCode'}
<div>
<Badge
variant="secondary"
type={getBadgeTypeFromStatusCode(log.responseStatusCode)}
content={log.responseStatusCode.toString()} />
</div>
{:else if column.id === 'requestPath'}
<Typography.Code size="m">
{log.requestPath}
</Typography.Code>
{:else if column.id === '$createdAt'}
<DualTimeView time={log.$createdAt} />
{/if}
</Table.Cell>
{/each}
</Table.Row.Button>
{/each}
</Table.Root>
</Table.Cell>
{/each}
</Table.Row.Button>
{/each}
{/snippet}
</MultiSelectionTable>
<Sheet bind:open={openSheet} bind:selectedLogId logs={logs.executions} />
{#if selectedRows.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedRows.length.toString()} />
<span>
{selectedRows.length > 1 ? 'logs' : 'log'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedRows = [])}>Cancel</Button>
<Button secondary on:click={() => (showBatchDeletion = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm title="Delete logs" confirmDeletion onSubmit={deleteLogs} bind:open={showBatchDeletion}>
<p>
Are you sure you want to delete <strong>{selectedRows.length}</strong>
{selectedRows.length > 1 ? 'logs' : 'log'}?
</p>
<p class="u-bold">This action is irreversible.</p>
</Confirm>
@@ -3,28 +3,25 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Avatar, Empty, EmptySearch, PaginationWithLimit, SearchQuery } from '$lib/components';
import {
Avatar,
type DeleteOperationState,
Empty,
EmptySearch,
MultiSelectionTable,
PaginationWithLimit,
SearchQuery
} from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Badge } from '@appwrite.io/pink-svelte';
import { Button } from '$lib/elements/forms';
import { calculateSize } from '$lib/helpers/sizeConvertion';
import { Container } from '$lib/layout';
import { ImageFormat, type Models } from '@appwrite.io/console';
import { addNotification } from '$lib/stores/notifications';
import { uploader } from '$lib/stores/uploader';
import { sdk } from '$lib/stores/sdk.js';
import DeleteFile from './deleteFile.svelte';
import { bucket } from './store';
import Confirm from '$lib/components/confirm.svelte';
import {
Layout,
Table,
Icon,
Popover,
ActionMenu,
FloatingActionBar,
Typography
} from '@appwrite.io/pink-svelte';
import { Layout, Table, Icon, Popover, ActionMenu } from '@appwrite.io/pink-svelte';
import { onMount } from 'svelte';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import {
@@ -34,23 +31,20 @@
IconTrash
} from '@appwrite.io/pink-icons-svelte';
import { isSmallViewport } from '$lib/stores/viewport';
import type { PageProps } from './$types';
export let data;
const { data }: PageProps = $props();
let showDelete = false;
let selectedFile: Models.File = null;
let selectedFiles: string[] = [];
let showBulkDelete = false;
let deleting = false;
const bucketId = page.params.bucket;
let showDelete = $state(false);
let isUploading = $state(false);
let selectedFile: Models.File | null = $state(null);
function getPreview(fileId: string) {
return (
sdk
.forProject(page.params.region, page.params.project)
.storage.getFilePreview({
bucketId,
bucketId: page.params.bucket,
fileId,
height: 128,
width: 128,
@@ -71,42 +65,25 @@
showDelete = true;
}
async function handleBulkDelete() {
showBulkDelete = false;
deleting = true;
const promises = selectedFiles.map((fileId) =>
sdk.forProject(page.params.region, page.params.project).storage.deleteFile({
bucketId,
async function handleBulkDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((fileId) => {
return sdk.forProject(page.params.region, page.params.project).storage.deleteFile({
bucketId: page.params.bucket,
fileId
})
);
});
});
try {
await Promise.all(promises);
trackEvent(Submit.FileDelete, {
total: selectedFiles.length
});
addNotification({
type: 'success',
message: `${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''} deleted`
});
invalidate(Dependencies.FILES);
trackEvent(Submit.FileDelete, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.FileDelete);
return error;
} finally {
selectedFiles = [];
showBulkDelete = false;
deleting = false;
await invalidate(Dependencies.FILES);
}
}
let isUploading = false;
const beforeunload = (event: BeforeUnloadEvent) => {
// legacy browser **may** support showing a custom message.
const message = 'An upload is in progress. Are you sure you want to leave?';
@@ -147,10 +124,10 @@
</Layout.Stack>
{#if data.files.total}
<Table.Root
let:root
allowSelection
bind:selectedRows={selectedFiles}
<MultiSelectionTable
resource="file"
computeKey={data.files.total}
onDelete={handleBulkDelete}
columns={[
{ id: 'filename', width: $isSmallViewport ? 24 : undefined },
{ id: 'type', width: { min: 140 } },
@@ -158,93 +135,106 @@
{ id: 'created', width: { min: 120 } },
{ id: 'actions', width: 40 }
]}>
<svelte:fragment slot="header" let:root>
{#snippet header(root)}
<Table.Header.Cell column="filename" {root}>Filename</Table.Header.Cell>
<Table.Header.Cell column="type" {root}>Type</Table.Header.Cell>
<Table.Header.Cell column="size" {root}>Size</Table.Header.Cell>
<Table.Header.Cell column="created" {root}>Created</Table.Header.Cell>
<Table.Header.Cell column="actions" {root} />
</svelte:fragment>
{#each data.files.files as file}
{#if file.chunksTotal / file.chunksUploaded !== 1}
<Table.Row.Base {root} id={file.$id}>
<Table.Cell column="filename" {root}>
<Layout.Stack direction="row" alignItems="center">
<span class="avatar is-size-small is-color-empty"></span>
<span class="text u-trim">{file.name}</span>
<div>
<Badge variant="secondary" type="warning" content="Pending" />
{/snippet}
{#snippet children(root)}
{#each data.files.files as file}
{#if file.chunksTotal / file.chunksUploaded !== 1}
<Table.Row.Base {root} id={file.$id}>
<Table.Cell column="filename" {root}>
<Layout.Stack direction="row" alignItems="center">
<span class="avatar is-size-small is-color-empty"></span>
<span class="text u-trim">{file.name}</span>
<div>
<Badge
variant="secondary"
type="warning"
content="Pending" />
</div>
</Layout.Stack>
</Table.Cell>
<Table.Cell column="type" {root}>{file.mimeType}</Table.Cell>
<Table.Cell column="size" {root}>
{calculateSize(file.sizeOriginal)}
</Table.Cell>
<Table.Cell column="created" {root}>
<DualTimeView time={file.$createdAt} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<div class="u-flex u-main-center">
<button
class="button is-only-icon is-text"
aria-label="Delete item"
onclick={(event) => {
event.preventDefault();
deleteFile(file);
}}>
<span class="icon-trash" aria-hidden="true"></span>
</button>
</div>
</Layout.Stack>
</Table.Cell>
<Table.Cell column="type" {root}>{file.mimeType}</Table.Cell>
<Table.Cell column="size" {root}>
{calculateSize(file.sizeOriginal)}
</Table.Cell>
<Table.Cell column="created" {root}>
<DualTimeView time={file.$createdAt} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<div class="u-flex u-main-center">
<button
class="button is-only-icon is-text"
aria-label="Delete item"
on:click|preventDefault={() => deleteFile(file)}>
<span class="icon-trash" aria-hidden="true"></span>
</button>
</div>
</Table.Cell>
</Table.Row.Base>
{:else}
{@const href = `${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${bucketId}/file-${file.$id}`}
<Table.Row.Link {href} {root} id={file.$id}>
<Table.Cell column="filename" {root}>
<div class="u-flex u-gap-12 u-cross-center">
<Avatar size="xs" src={getPreview(file.$id)} alt={file.name} />
<span class="text u-trim">{file.name}</span>
</div>
</Table.Cell>
<Table.Cell column="type" {root}>{file.mimeType}</Table.Cell>
<Table.Cell column="size" {root}>
{calculateSize(file.sizeOriginal)}
</Table.Cell>
<Table.Cell column="created" {root}>
<DualTimeView time={file.$createdAt} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<Popover let:toggle placement="bottom-start" padding="none">
<Button
text
icon
ariaLabel="more options"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
toggle();
}}>
<Icon icon={IconDotsHorizontal} size="s" />
</Button>
<ActionMenu.Root slot="tooltip">
<ActionMenu.Item.Anchor {href} leadingIcon={IconPencil}>
Update
</ActionMenu.Item.Anchor>
<ActionMenu.Item.Button
leadingIcon={IconTrash}
</Table.Cell>
</Table.Row.Base>
{:else}
{@const href = `${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${page.params.bucket}/file-${file.$id}`}
<Table.Row.Link {href} {root} id={file.$id}>
<Table.Cell column="filename" {root}>
<div class="u-flex u-gap-12 u-cross-center">
<Avatar size="xs" src={getPreview(file.$id)} alt={file.name} />
<span class="text u-trim">{file.name}</span>
</div>
</Table.Cell>
<Table.Cell column="type" {root}>{file.mimeType}</Table.Cell>
<Table.Cell column="size" {root}>
{calculateSize(file.sizeOriginal)}
</Table.Cell>
<Table.Cell column="created" {root}>
<DualTimeView time={file.$createdAt} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<Popover let:toggle placement="bottom-start" padding="none">
<Button
text
icon
ariaLabel="more options"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
selectedFile = file;
showDelete = true;
toggle();
}}>
Delete
</ActionMenu.Item.Button>
</ActionMenu.Root>
</Popover>
</Table.Cell>
</Table.Row.Link>
{/if}
{/each}
</Table.Root>
<Icon icon={IconDotsHorizontal} size="s" />
</Button>
<ActionMenu.Root slot="tooltip">
<ActionMenu.Item.Anchor {href} leadingIcon={IconPencil}>
Update
</ActionMenu.Item.Anchor>
<ActionMenu.Item.Button
leadingIcon={IconTrash}
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
selectedFile = file;
showDelete = true;
}}>
Delete
</ActionMenu.Item.Button>
</ActionMenu.Root>
</Popover>
</Table.Cell>
</Table.Row.Link>
{/if}
{/each}
{/snippet}
{#snippet deleteContentNotice()}
This action is irreversible and will permanently remove the selected files.
{/snippet}
</MultiSelectionTable>
<PaginationWithLimit
name="Files"
@@ -256,7 +246,7 @@
<Button
secondary
size="s"
href={`${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${bucketId}`}>
href={`${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${page.params.bucket}`}>
Clear Search
</Button>
</EmptySearch>
@@ -268,39 +258,9 @@
allowCreate
on:click={() =>
goto(
`${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${bucketId}/create`
`${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${page.params.bucket}/create`
)} />
{/if}
{#if selectedFiles.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedFiles.length.toString()} />
<span>
{selectedFiles.length > 1 ? 'files' : 'file'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedFiles = [])}>Cancel</Button>
<Button secondary on:click={() => (showBulkDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
</Container>
<DeleteFile bind:showDelete file={selectedFile} on:deleted={fileDeleted} />
<Confirm
title="Delete files"
bind:open={showBulkDelete}
onSubmit={handleBulkDelete}
disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selectedFiles.length}</b>
{selectedFiles.length > 1 ? 'files' : 'file'} from <b>{$bucket?.name}</b>?
</Typography.Text>
<Typography.Text>
This action is irreversible and will permanently remove the selected files.
</Typography.Text>
</Confirm>
+1 -1
View File
@@ -1,3 +1,3 @@
<svg width="1800" height="1800" viewBox="0 0 1800 1800" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="24" height="24" viewBox="0 0 1800 1800" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1000.46 450C1174.77 450 1278.43 553.669 1278.43 691.282C1278.43 828.896 1174.77 932.563 1000.46 932.563H912.382L1350 1350H1040.82L707.794 1033.48C683.944 1011.47 672.936 985.781 672.935 963.765C672.935 932.572 694.959 905.049 737.161 893.122L908.712 847.244C973.85 829.812 1018.81 779.353 1018.81 713.298C1018.8 632.567 952.745 585.78 871.095 585.78H450V450H1000.46Z" fill="#FDFDFD"/>
</svg>

Before

Width:  |  Height:  |  Size: 506 B

After

Width:  |  Height:  |  Size: 502 B

+1 -1
View File
@@ -1,3 +1,3 @@
<svg width="1800" height="1800" viewBox="0 0 1800 1800" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="24" height="24" viewBox="0 0 1800 1800" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1000.46 450C1174.77 450 1278.43 553.669 1278.43 691.282C1278.43 828.896 1174.77 932.563 1000.46 932.563H912.382L1350 1350H1040.82L707.794 1033.48C683.944 1011.47 672.936 985.781 672.935 963.765C672.935 932.572 694.959 905.049 737.161 893.122L908.712 847.244C973.85 829.812 1018.81 779.353 1018.81 713.298C1018.8 632.567 952.745 585.78 871.095 585.78H450V450H1000.46Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 504 B

After

Width:  |  Height:  |  Size: 500 B