mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
merge conflicts
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
disabled={tooltipDisabled}
|
||||
portal={tooltipPortal}
|
||||
delay={tooltipDelay}
|
||||
maxWidth="500px"
|
||||
placement={tooltipPlacement}>
|
||||
<span
|
||||
data-private
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
+75
-121
@@ -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)} />
|
||||
|
||||
+57
-91
@@ -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>
|
||||
|
||||
+62
-111
@@ -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>
|
||||
|
||||
+52
-87
@@ -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>
|
||||
|
||||
+10
-16
@@ -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>
|
||||
|
||||
|
||||
+216
-208
@@ -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>
|
||||
|
||||
+34
-34
@@ -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
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@
|
||||
$effect(() => {
|
||||
if (row) {
|
||||
work = initWork();
|
||||
focusFirstInput();
|
||||
requestAnimationFrame(() => focusFirstInput());
|
||||
} else {
|
||||
work = null;
|
||||
}
|
||||
|
||||
+8
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+232
-129
@@ -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}
|
||||
|
||||
+52
-77
@@ -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>
|
||||
|
||||
+12
-15
@@ -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}
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+23
-24
@@ -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([
|
||||
|
||||
+11
-17
@@ -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) {
|
||||
|
||||
+9
-11
@@ -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>
|
||||
|
||||
|
||||
+5
-3
@@ -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);
|
||||
}
|
||||
|
||||
+81
-124
@@ -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>
|
||||
|
||||
+140
-176
@@ -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>
|
||||
|
||||
+61
-101
@@ -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>
|
||||
|
||||
+17
-2
@@ -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({
|
||||
|
||||
+6
-1
@@ -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' : ''}?
|
||||
|
||||
+64
-66
@@ -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%;
|
||||
}
|
||||
|
||||
+14
-15
@@ -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();
|
||||
};
|
||||
});
|
||||
|
||||
+14
-15
@@ -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();
|
||||
};
|
||||
});
|
||||
|
||||
+14
-15
@@ -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();
|
||||
};
|
||||
});
|
||||
|
||||
+14
-15
@@ -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();
|
||||
};
|
||||
});
|
||||
|
||||
+14
-15
@@ -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);
|
||||
}
|
||||
|
||||
+20
-26
@@ -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>
|
||||
|
||||
|
||||
+12
-15
@@ -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"
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
|
||||
+7
-4
@@ -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);
|
||||
|
||||
|
||||
+2
-6
@@ -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);
|
||||
}
|
||||
|
||||
+10
-17
@@ -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>
|
||||
|
||||
|
||||
+82
-115
@@ -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);
|
||||
}
|
||||
|
||||
+67
-99
@@ -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>
|
||||
|
||||
+121
-161
@@ -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,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,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 |
Reference in New Issue
Block a user