diff --git a/.github/workflows/dockerize-profiles.yml b/.github/workflows/dockerize-profiles.yml new file mode 100644 index 000000000..aafa95f32 --- /dev/null +++ b/.github/workflows/dockerize-profiles.yml @@ -0,0 +1,49 @@ +name: Dockerize Profiles + +on: + push: + branches: [feat-profiles] + pull_request: + types: [opened, synchronize, reopened] + branches: [feat-profiles] + workflow_dispatch: + +jobs: + dockerize-profiles: + runs-on: ubuntu-latest + + steps: + - name: Checkout the repo + uses: actions/checkout@v2 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: appwrite/console-profiles + tags: | + type=ref,event=branch,prefix=branch- + type=ref,event=pr + type=sha,prefix=sha- + type=raw,value=gh-${{ github.run_id}} + flavor: | + latest=false + + - name: Build and push Docker image + id: push + uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 862b7ef45..73a4883e6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -12,16 +12,31 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Use Node.js uses: actions/setup-node@v3 with: node-version: 20 + - name: Install pnpm uses: pnpm/action-setup@v4 + - name: Install dependencies run: pnpm install --frozen-lockfile + + - name: Cache Playwright browsers + uses: actions/cache@v4 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-playwright- + - name: Install Playwright Browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' run: pnpm exec playwright install --with-deps chromium + - name: E2E Tests run: pnpm run e2e - uses: actions/upload-artifact@v4 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6bd4d86ce..1674ff728 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,21 +15,29 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Use Node.js uses: actions/setup-node@v3 with: node-version: 20 + - name: Install pnpm uses: pnpm/action-setup@v4 + - name: Audit dependencies run: pnpm audit --audit-level high + - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Svelte Diagnostics run: pnpm run check + - name: Linter run: pnpm run lint + - name: Unit Tests run: pnpm run test + - name: Build Console run: pnpm run build diff --git a/AGENTS.md b/AGENTS.md index 82bcb13c7..38c3f89e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,4 +101,23 @@ src/ 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 +## Required Pre-Completion Checklist + +**CRITICAL**: Before finishing any work or marking a task complete, agents MUST run the following commands in order and ensure all pass: + +1. **`pnpm run format`** - Auto-fix all formatting issues +2. **`pnpm run check`** - Verify TypeScript/Svelte types (must show 0 errors, 0 warnings) +3. **`pnpm run lint`** - Check code style (ignore pre-existing issues in files you didn't modify) +4. **`pnpm run test`** - Run all unit tests (all tests must pass) +5. **`pnpm run build`** - Ensure production build succeeds + +If any command fails: + +- **Format/Lint**: Run `pnpm run format` to auto-fix, then re-check +- **Type errors**: Fix all TypeScript errors in files you modified +- **Test failures**: Fix failing tests or ensure failures are unrelated to your changes +- **Build failures**: Debug and resolve build issues before proceeding + +**Never skip these checks** - they are mandatory quality gates before any work is considered complete. + **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. diff --git a/package.json b/package.json index 2db84e49e..1c916e181 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,11 @@ }, "dependencies": { "@ai-sdk/svelte": "^1.1.24", - "@appwrite.io/console": "https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@2752", + "@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@acef319", "@appwrite.io/pink-icons": "0.25.0", - "@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@4472521", + "@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@6916470", "@appwrite.io/pink-legacy": "^1.0.3", - "@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@4472521", + "@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@33845eb", "@faker-js/faker": "^9.9.0", "@popperjs/core": "^2.11.8", "@sentry/sveltekit": "^8.38.0", @@ -95,5 +95,5 @@ "svelte-preprocess" ] }, - "packageManager": "pnpm@10.20.0" + "packageManager": "pnpm@10.18.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a05369a8a..1724be838 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,20 +12,20 @@ importers: specifier: ^1.1.24 version: 1.1.24(svelte@5.25.3)(zod@3.24.3) '@appwrite.io/console': - specifier: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@2752 - version: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@2752 + specifier: https://pkg.vc/-/@appwrite/@appwrite.io/console@acef319 + version: https://pkg.vc/-/@appwrite/@appwrite.io/console@acef319 '@appwrite.io/pink-icons': specifier: 0.25.0 version: 0.25.0 '@appwrite.io/pink-icons-svelte': - specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@4472521 - version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@4472521(svelte@5.25.3) + specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@6916470 + version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@6916470(svelte@5.25.3) '@appwrite.io/pink-legacy': specifier: ^1.0.3 version: 1.0.3 '@appwrite.io/pink-svelte': - specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@4472521 - version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@4472521(svelte@5.25.3) + specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@33845eb + version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@33845eb(svelte@5.25.3) '@faker-js/faker': specifier: ^9.9.0 version: 9.9.0 @@ -260,8 +260,8 @@ packages: '@analytics/type-utils@0.6.2': resolution: {integrity: sha512-TD+xbmsBLyYy/IxFimW/YL/9L2IEnM7/EoV9Aeh56U64Ify8o27HJcKjo38XY9Tcn0uOq1AX3thkKgvtWvwFQg==} - '@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@2752': - resolution: {tarball: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@2752} + '@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@acef319': + resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/console@acef319} version: 1.10.0 '@appwrite.io/pink-icons-svelte@2.0.0-RC.1': @@ -269,8 +269,8 @@ packages: peerDependencies: svelte: ^4.0.0 - '@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@4472521': - resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@4472521} + '@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@6916470': + resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@6916470} version: 2.0.0-RC.1 peerDependencies: svelte: ^4.0.0 @@ -284,8 +284,8 @@ packages: '@appwrite.io/pink-legacy@1.0.3': resolution: {integrity: sha512-GGde5fmPhs+s6/3aFeMPc/kKADG/gTFkYQSy6oBN8pK0y0XNCLrZZgBv+EBbdhwdtqVEWXa0X85Mv9w7jcIlwQ==} - '@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@4472521': - resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@4472521} + '@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@33845eb': + resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@33845eb} version: 2.0.0-RC.2 peerDependencies: svelte: ^4.0.0 @@ -3703,13 +3703,13 @@ snapshots: '@analytics/type-utils@0.6.2': {} - '@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@2752': {} + '@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@acef319': {} '@appwrite.io/pink-icons-svelte@2.0.0-RC.1(svelte@5.25.3)': dependencies: svelte: 5.25.3 - '@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@4472521(svelte@5.25.3)': + '@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@6916470(svelte@5.25.3)': dependencies: svelte: 5.25.3 @@ -3722,7 +3722,7 @@ snapshots: '@appwrite.io/pink-icons': 1.0.0 the-new-css-reset: 1.11.3 - '@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@4472521(svelte@5.25.3)': + '@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@33845eb(svelte@5.25.3)': dependencies: '@appwrite.io/pink-icons-svelte': 2.0.0-RC.1(svelte@5.25.3) '@floating-ui/dom': 1.6.13 diff --git a/src/lib/actions/analytics.ts b/src/lib/actions/analytics.ts index b511e59c8..ba1368a5c 100644 --- a/src/lib/actions/analytics.ts +++ b/src/lib/actions/analytics.ts @@ -153,6 +153,7 @@ export enum Click { DatabaseTableDelete = 'click_table_delete', DatabaseDatabaseDelete = 'click_database_delete', DatabaseImportCsv = 'click_database_import_csv', + DatabaseExportCsv = 'click_database_export_csv', DomainCreateClick = 'click_domain_create', DomainDeleteClick = 'click_domain_delete', DomainRetryDomainVerificationClick = 'click_domain_retry_domain_verification', @@ -195,7 +196,10 @@ export enum Click { VariablesCreateClick = 'click_variable_create', VariablesUpdateClick = 'click_variable_update', VariablesImportClick = 'click_variable_import', - WebsiteOpenClick = 'click_open_website' + WebsiteOpenClick = 'click_open_website', + CopyPromptStarterKitClick = 'click_copy_prompt_starter_kit', + OpenInCursorClick = 'click_open_in_cursor', + OpenInLovableClick = 'click_open_in_lovable' } export enum Submit { @@ -274,6 +278,7 @@ export enum Submit { DatabaseDelete = 'submit_database_delete', DatabaseUpdateName = 'submit_database_update_name', DatabaseImportCsv = 'submit_database_import_csv', + DatabaseExportCsv = 'submit_database_export_csv', ColumnCreate = 'submit_column_create', ColumnUpdate = 'submit_column_update', @@ -357,6 +362,7 @@ export enum Submit { BucketUpdateSize = 'submit_bucket_update_size', BucketUpdateCompression = 'submit_bucket_update_compression', BucketUpdateExtensions = 'submit_bucket_update_extensions', + BucketUpdateTransformations = 'submit_bucket_update_transformations', FileCreate = 'submit_file_create', FileDelete = 'submit_file_delete', FileUpdatePermissions = 'submit_file_update_permissions', diff --git a/src/lib/commandCenter/commandCenter.svelte b/src/lib/commandCenter/commandCenter.svelte index c82a5c035..91bf5dfe9 100644 --- a/src/lib/commandCenter/commandCenter.svelte +++ b/src/lib/commandCenter/commandCenter.svelte @@ -30,7 +30,12 @@ import { getContext, setContext } from 'svelte'; import { get, writable, type Readable } from 'svelte/store'; import { fade } from 'svelte/transition'; - import { commandCenterKeyDownHandler, disableCommands, registerCommands } from './commands'; + import { + commandCenterKeyDownHandler, + disableCommands, + isTargetInputLike, + registerCommands + } from './commands'; import { RootPanel } from './panels'; import { addSubPanel, clearSubPanels, subPanels } from './subPanels'; import { addNotification } from '$lib/stores/notifications'; @@ -95,13 +100,9 @@ keys = []; }, 1000); - function isInputEvent(event: KeyboardEvent) { - return ['INPUT', 'TEXTAREA', 'SELECT'].includes((event.target as HTMLElement).tagName); - } - const handleKeydown = (e: KeyboardEvent) => { if (!$subPanels.length) { - if (isInputEvent(e)) return; + if (isTargetInputLike(e.target)) return; keys = [...keys, e.key].slice(-10); resetKeys(); } diff --git a/src/lib/commandCenter/commands.ts b/src/lib/commandCenter/commands.ts index fa8427d30..e4f383718 100644 --- a/src/lib/commandCenter/commands.ts +++ b/src/lib/commandCenter/commands.ts @@ -100,8 +100,11 @@ const commandsEnabled = derived(disabledMap, ($disabledMap) => { return Array.from($disabledMap.values()).every((disabled) => !disabled); }); -function isInputEvent(event: KeyboardEvent) { - return ['INPUT', 'TEXTAREA', 'SELECT'].includes((event.target as HTMLElement).tagName); +export function isTargetInputLike(element: EventTarget | null) { + if (!(element instanceof HTMLElement)) return false; + return !!element.closest( + 'input,textarea,select,[contenteditable],[role="combobox"],[role="textbox"],[role="searchbox"],[data-command-center-ignore]' + ); } function getCommandRank(command: KeyedCommand) { @@ -204,7 +207,12 @@ export const commandCenterKeyDownHandler = derived( for (const command of commandsArr) { if (!isKeyedCommand(command)) continue; if (!command.forceEnable) { - if (command.disabled || !enabled || isInputEvent(event) || $wizard.show) { + if ( + command.disabled || + !enabled || + isTargetInputLike(event.target) || + $wizard.show + ) { continue; } } diff --git a/src/lib/commandCenter/searchers/organizations.ts b/src/lib/commandCenter/searchers/organizations.ts index 1d959e992..c7e202bbb 100644 --- a/src/lib/commandCenter/searchers/organizations.ts +++ b/src/lib/commandCenter/searchers/organizations.ts @@ -3,11 +3,12 @@ import { base } from '$app/paths'; import { sdk } from '$lib/stores/sdk'; import type { Searcher } from '../commands'; import { isCloud } from '$lib/system'; +import { Query } from '@appwrite.io/console'; export const orgSearcher = (async (query: string) => { const { teams } = !isCloud ? await sdk.forConsole.teams.list() - : await sdk.forConsole.billing.listOrganization(); + : await sdk.forConsole.billing.listOrganization([Query.equal('platform', 'appwrite')]); return teams .filter((organization) => organization.name.toLowerCase().includes(query.toLowerCase())) diff --git a/src/lib/components/archiveProject.svelte b/src/lib/components/archiveProject.svelte index c7896c991..8a9797ca0 100644 --- a/src/lib/components/archiveProject.svelte +++ b/src/lib/components/archiveProject.svelte @@ -1,12 +1,11 @@ {#if href} - + diff --git a/src/lib/components/csvExportBox.svelte b/src/lib/components/csvExportBox.svelte new file mode 100644 index 000000000..dfe79d2c8 --- /dev/null +++ b/src/lib/components/csvExportBox.svelte @@ -0,0 +1,281 @@ + + +{#if showCsvExportBox} + +
+
+

+ + Exporting rows ({exportItems.size}) + +

+ + +
+ +
+ {#each [...exportItems.entries()] as [key, value] (key)} +
+
    +
  • +
    +
    + + {@html text(value.status, value.table)} + + {#if value.status === 'failed' && value.errors && value.errors.length > 0} + + {/if} +
    +
    +
    +
    +
  • +
+
+ {/each} +
+
+
+{/if} + + + {#if selectedErrors.length > 0} + { + try { + return JSON.parse(err); + } catch { + return err; + } + }), + null, + 2 + )} + lang="json" + hideHeader /> + {/if} + + + diff --git a/src/lib/components/emptyCardImageCloud.svelte b/src/lib/components/emptyCardImageCloud.svelte index b6c29c2e6..0f4c23450 100644 --- a/src/lib/components/emptyCardImageCloud.svelte +++ b/src/lib/components/emptyCardImageCloud.svelte @@ -1,13 +1,20 @@ - - + {#if $$slots?.image}
diff --git a/src/lib/components/expirationInput.svelte b/src/lib/components/expirationInput.svelte index 343d2444e..9782dfae5 100644 --- a/src/lib/components/expirationInput.svelte +++ b/src/lib/components/expirationInput.svelte @@ -6,8 +6,8 @@ @@ -38,6 +40,7 @@ placeholder="Enter value" step={0.0001} value={values[index]} + {disabled} on:change={(e) => onChangePoint(index, Number.parseFloat(`${e.detail}`))} /> {/each} {/if} @@ -45,7 +48,7 @@ diff --git a/src/lib/elements/forms/inputPolygon.svelte b/src/lib/elements/forms/inputPolygon.svelte index c2bcbb4db..766dfa7d1 100644 --- a/src/lib/elements/forms/inputPolygon.svelte +++ b/src/lib/elements/forms/inputPolygon.svelte @@ -17,6 +17,7 @@ coordIndex: number, newValue: number ) => void; + disabled?: boolean; }; let { @@ -26,7 +27,8 @@ onAddPoint, onAddLine, onDeletePoint, - onChangePoint + onChangePoint, + disabled }: Props = $props(); @@ -34,6 +36,7 @@ {#each values as value, index} onAddPoint(index)} {nullable} diff --git a/src/lib/elements/forms/inputSelect.svelte b/src/lib/elements/forms/inputSelect.svelte index 57791bdd7..de977bfc1 100644 --- a/src/lib/elements/forms/inputSelect.svelte +++ b/src/lib/elements/forms/inputSelect.svelte @@ -56,6 +56,7 @@ helper={error ?? helper} {required} state={error ? 'error' : 'default'} + data-command-center-ignore on:invalid={handleInvalid} on:input on:change diff --git a/src/lib/helpers/templateSource.ts b/src/lib/helpers/templateSource.ts index d24b67e77..4c8c6c0e0 100644 --- a/src/lib/helpers/templateSource.ts +++ b/src/lib/helpers/templateSource.ts @@ -1,8 +1,15 @@ import type { Models } from '@appwrite.io/console'; /** - * Build VCS repo URL from the template response model. - * Example (GitHub): https://github.com/appwrite/templates-for-sites + * build VCS repo URL from the template response model. + * supports GitHub, GitLab, and Bitbucket. + * + * important: We use 'master' as the branch name because GitHub (and other providers) + * redirect 'master' to the repository's default branch, regardless of whether + * its actually named 'main', 'master', or something else. This ensures the + * link works across all repositories without needing to know their default branch. + * + * Example (GitHub): https://github.com/appwrite/templates-for-sites/tree/master/sveltekit/starter */ export function getTemplateSourceUrl( t: Models.TemplateSite | Models.TemplateFunction @@ -20,7 +27,41 @@ export function getTemplateSourceUrl( bitbucket: 'bitbucket.org' }; - const host = hostMap[provider.toLowerCase()] ?? provider; // fallback + const host = hostMap[provider.toLowerCase()]; + if (!host) return null; - return `https://${host}/${owner}/${repo}`; + let folderPath: string | undefined; + if ( + 'providerRootDirectory' in t && + t.providerRootDirectory && + typeof t.providerRootDirectory === 'string' + ) { + folderPath = t.providerRootDirectory; + } else if ( + 'frameworks' in t && + t.frameworks?.length > 0 && + t.frameworks[0]?.providerRootDirectory && + typeof t.frameworks[0].providerRootDirectory === 'string' + ) { + folderPath = t.frameworks[0].providerRootDirectory; + } + + let url = `https://${host}/${owner}/${repo}`; + + if (folderPath) { + const normalizedPath = folderPath.replace(/^\/+|\/+$/g, ''); + if (normalizedPath) { + const providerLower = provider.toLowerCase(); + // Use 'master' as branch name - GitHub/GitLab/Bitbucket redirect it to default branch + if (providerLower === 'github') { + url = `${url}/tree/master/${normalizedPath}`; + } else if (providerLower === 'gitlab') { + url = `${url}/-/tree/master/${normalizedPath}`; + } else if (providerLower === 'bitbucket') { + url = `${url}/src/master/${normalizedPath}`; + } + } + } + + return url; } diff --git a/src/lib/sdk/billing.ts b/src/lib/sdk/billing.ts index 0361f5910..f728ca5e8 100644 --- a/src/lib/sdk/billing.ts +++ b/src/lib/sdk/billing.ts @@ -490,7 +490,7 @@ export class Billing { name: string, billingPlan: string, paymentMethodId: string, - billingAddressId: string = null, + billingAddressId: string = undefined, couponId: string = null, invites: Array = [], budget: number = undefined, @@ -628,6 +628,7 @@ export class Billing { budget, taxId }; + const uri = new URL(this.client.config.endpoint + path); return await this.client.call( 'patch', @@ -934,12 +935,24 @@ export class Billing { ); } - async getAggregation(organizationId: string, aggregationId: string): Promise { + async getAggregation( + organizationId: string, + aggregationId: string, + limit?: number, + offset?: number + ): Promise { const path = `/organizations/${organizationId}/aggregations/${aggregationId}`; - const params = { + const params: { + organizationId: string; + aggregationId: string; + limit?: number; + offset?: number; + } = { organizationId, aggregationId }; + if (typeof limit === 'number') params.limit = limit; + if (typeof offset === 'number') params.offset = offset; const uri = new URL(this.client.config.endpoint + path); return await this.client.call( 'get', diff --git a/src/lib/stores/billing.ts b/src/lib/stores/billing.ts index 46134dde3..e2016b8c0 100644 --- a/src/lib/stores/billing.ts +++ b/src/lib/stores/billing.ts @@ -100,7 +100,7 @@ export function tierToPlan(tier: Tier) { case BillingPlan.ENTERPRISE: return tierEnterprise; default: - return tierFree; + return tierCustom; } } @@ -557,7 +557,8 @@ export async function checkForMissingPaymentMethod() { const orgs = await sdk.forConsole.billing.listOrganization([ Query.notEqual('billingPlan', BillingPlan.FREE), Query.isNull('paymentMethodId'), - Query.isNull('backupPaymentMethodId') + Query.isNull('backupPaymentMethodId'), + Query.equal('platform', 'appwrite') ]); if (orgs?.total) { orgMissingPaymentMethod.set(orgs.teams[0]); diff --git a/src/lib/stores/sdk.ts b/src/lib/stores/sdk.ts index daeb7ffba..05f9f54db 100644 --- a/src/lib/stores/sdk.ts +++ b/src/lib/stores/sdk.ts @@ -22,7 +22,8 @@ import { Tokens, TablesDB, Domains, - Realtime + Realtime, + Organizations } from '@appwrite.io/console'; import { Billing } from '../sdk/billing'; import { Backups } from '../sdk/backups'; @@ -95,7 +96,8 @@ function createConsoleSdk(client: Client) { sites: new Sites(client), domains: new Domains(client), storage: new Storage(client), - realtime: new Realtime(client) + realtime: new Realtime(client), + organizations: new Organizations(client) }; } diff --git a/src/routes/(authenticated)/git/+layout.svelte b/src/routes/(authenticated)/git/+layout.svelte index 24921622c..6f5a1d6c6 100644 --- a/src/routes/(authenticated)/git/+layout.svelte +++ b/src/routes/(authenticated)/git/+layout.svelte @@ -1,10 +1,51 @@ - - - + +
+ +
+
+ POWERED BY + {#if $app.themeInUse === 'dark'} + Appwrite Logo + {:else} + Appwrite Logo + {/if} +
+
+ + diff --git a/src/routes/(authenticated)/git/authorize-contributor/+page.svelte b/src/routes/(authenticated)/git/authorize-contributor/+page.svelte index 23c1b34d5..c8cebcfc4 100644 --- a/src/routes/(authenticated)/git/authorize-contributor/+page.svelte +++ b/src/routes/(authenticated)/git/authorize-contributor/+page.svelte @@ -1,30 +1,21 @@ -
-
-
-

Authorize External Deployment

- The deployment for pull request #{providerPullRequestId} is awaiting approval. When authorized, deployments - will be started. - - -
- -
- - {#if error} -

{error}

- {/if} - - {#if success} -

{success}

- {/if} -
- -
-
+ + {#if success} + + {:else if error} + + {/if} + + The deployment for pull request #{data.providerPullRequestId} + is awaiting approval. When authorized, deployments will be started. + + + diff --git a/src/routes/(console)/account/organizations/+page.svelte b/src/routes/(console)/account/organizations/+page.svelte index a08500bb7..2d1668043 100644 --- a/src/routes/(console)/account/organizations/+page.svelte +++ b/src/routes/(console)/account/organizations/+page.svelte @@ -13,10 +13,10 @@ import { sdk } from '$lib/stores/sdk'; import type { PageData } from './$types'; import { isCloud } from '$lib/system'; - import { Badge } from '@appwrite.io/pink-svelte'; + import { Badge, Skeleton } from '@appwrite.io/pink-svelte'; import type { Models } from '@appwrite.io/console'; import type { Organization } from '$lib/stores/organization'; - import { daysLeftInTrial, plansInfo, tierToPlan } from '$lib/stores/billing'; + import { daysLeftInTrial, plansInfo, tierToPlan, type Tier } from '$lib/stores/billing'; import { toLocaleDate } from '$lib/helpers/date'; import { BillingPlan } from '$lib/constants'; import { goto } from '$app/navigation'; @@ -36,6 +36,27 @@ return memberships.memberships.map((team) => team.userName || team.userEmail); } + async function getPlanName(billingPlan: string | undefined): Promise { + if (!billingPlan) return 'Unknown'; + + // For known plans, use tierToPlan + const tierData = tierToPlan(billingPlan as Tier); + + // If it's not a custom plan or we got a non-custom result, return the name + if (tierData.name !== 'Custom') { + return tierData.name; + } + + // For custom plans, fetch from API + try { + const plan = await sdk.forConsole.billing.getPlan(billingPlan); + return plan.name; + } catch (error) { + // Fallback to 'Custom' if fetch fails + return 'Custom'; + } + } + function isOrganizationOnTrial(organization: Organization): boolean { if (!organization?.billingTrialStartDate) return false; if ($daysLeftInTrial <= 0) return false; @@ -92,6 +113,9 @@ {#each data.organizations.teams as organization} {@const avatarList = getMemberships(organization.$id)} {@const payingOrg = isPayingOrganization(organization)} + {@const planName = isCloudOrg(organization) + ? getPlanName(organization.billingPlan) + : null} @@ -104,16 +128,19 @@ {#if isCloudOrg(organization)} {#if isNonPayingOrganization(organization)} - - + {#if planName} + {#await planName} + + {:then name} + + - - You are limited to 1 free organization per account - - + + You are limited to 1 free organization per account + + + {/await} + {/if} {/if} {#if isOrganizationOnTrial(organization)} @@ -132,16 +159,20 @@ {/if} {#if payingOrg} - + {#await planName} + + {:then name} + + {/await} {/if} {/if} {#await avatarList} - + {:then avatars} {/await} diff --git a/src/routes/(console)/account/organizations/+page.ts b/src/routes/(console)/account/organizations/+page.ts index 6d8ea5689..220e70a5d 100644 --- a/src/routes/(console)/account/organizations/+page.ts +++ b/src/routes/(console)/account/organizations/+page.ts @@ -10,7 +10,12 @@ export const load: PageLoad = async ({ url, route }) => { const limit = getLimit(url, route, CARD_LIMIT); const offset = pageToOffset(page, limit); - const queries = [Query.offset(offset), Query.limit(limit), Query.orderDesc('')]; + const queries = [ + Query.offset(offset), + Query.limit(limit), + Query.orderDesc(''), + Query.equal('platform', 'appwrite') + ]; const organizations = !isCloud ? await sdk.forConsole.teams.list({ queries }) diff --git a/src/routes/(console)/apply-credit/+page.svelte b/src/routes/(console)/apply-credit/+page.svelte index 61c770347..db9dbc038 100644 --- a/src/routes/(console)/apply-credit/+page.svelte +++ b/src/routes/(console)/apply-credit/+page.svelte @@ -134,7 +134,7 @@ name, billingPlan, paymentMethodId, - null, + undefined, couponData.code ? couponData.code : null, collaborators, billingBudget, @@ -148,7 +148,7 @@ selectedOrg.$id, billingPlan, paymentMethodId, - null, + undefined, couponData.code ? couponData.code : null, collaborators ); diff --git a/src/routes/(console)/create-organization/+page.svelte b/src/routes/(console)/create-organization/+page.svelte index de2afad0f..621536f25 100644 --- a/src/routes/(console)/create-organization/+page.svelte +++ b/src/routes/(console)/create-organization/+page.svelte @@ -112,7 +112,6 @@ ID.unique(), name, BillingPlan.FREE, - null, null ); } else { @@ -121,7 +120,7 @@ name, selectedPlan, paymentMethodId, - null, + undefined, selectedCoupon?.code, collaborators, billingBudget, diff --git a/src/routes/(console)/onboarding/create-organization/+page.svelte b/src/routes/(console)/onboarding/create-organization/+page.svelte index 3b563fd02..6ed323c7a 100644 --- a/src/routes/(console)/onboarding/create-organization/+page.svelte +++ b/src/routes/(console)/onboarding/create-organization/+page.svelte @@ -25,7 +25,6 @@ ID.unique(), organizationName, BillingPlan.FREE, - null, null ); diff --git a/src/routes/(console)/onboarding/create-project/+page.ts b/src/routes/(console)/onboarding/create-project/+page.ts index 6413f6e74..1dc717add 100644 --- a/src/routes/(console)/onboarding/create-project/+page.ts +++ b/src/routes/(console)/onboarding/create-project/+page.ts @@ -29,7 +29,6 @@ export const load: PageLoad = async ({ parent }) => { ID.unique(), 'Personal projects', BillingPlan.FREE, - null, null ); trackEvent(Submit.OrganizationCreate, { diff --git a/src/routes/(console)/organization-[organization]/+page.svelte b/src/routes/(console)/organization-[organization]/+page.svelte index 947d507d1..7e20073bc 100644 --- a/src/routes/(console)/organization-[organization]/+page.svelte +++ b/src/routes/(console)/organization-[organization]/+page.svelte @@ -224,7 +224,7 @@ {#if activeProjects.length > 0} {#each activeProjects as project} @@ -309,7 +309,7 @@ name="Projects" limit={data.limit} offset={data.offset} - total={data.projects.total} /> + total={activeProjects.length} /> + currentAggregation={data?.billingAggregation} + limit={data?.limit} + offset={data?.offset} /> {:else} { +import { getLimit, getPage, pageToOffset } from '$lib/helpers/load'; + +export const load: PageLoad = async ({ parent, depends, url, route }) => { const { organization, scopes, currentPlan, countryList, locale } = await parent(); if (!scopes.includes('billing.read')) { @@ -19,6 +21,8 @@ export const load: PageLoad = async ({ parent, depends }) => { depends(Dependencies.CREDIT); depends(Dependencies.INVOICES); depends(Dependencies.ADDRESS); + //aggregation reloads on page param changes + depends(Dependencies.BILLING_AGGREGATION); const billingAddressId = (organization as Organization)?.billingAddressId; const billingAddressPromise: Promise
= billingAddressId @@ -34,9 +38,14 @@ export const load: PageLoad = async ({ parent, depends }) => { */ let billingAggregation = null; try { + const currentPage = getPage(url) || 1; + const limit = getLimit(url, route, DEFAULT_BILLING_PROJECTS_LIMIT); + const offset = pageToOffset(currentPage, limit); billingAggregation = await sdk.forConsole.billing.getAggregation( organization.$id, - (organization as Organization)?.billingAggregationId + (organization as Organization)?.billingAggregationId, + limit, + offset ); } catch (e) { // ignore error @@ -84,6 +93,11 @@ export const load: PageLoad = async ({ parent, depends }) => { areCreditsSupported, countryList, locale, - nextPlan: billingPlanDowngrade + nextPlan: billingPlanDowngrade, + limit: getLimit(url, route, DEFAULT_BILLING_PROJECTS_LIMIT), + offset: pageToOffset( + getPage(url) || 1, + getLimit(url, route, DEFAULT_BILLING_PROJECTS_LIMIT) + ) }; }; diff --git a/src/routes/(console)/organization-[organization]/billing/billingAddress.svelte b/src/routes/(console)/organization-[organization]/billing/billingAddress.svelte index f4422850c..238d0de7f 100644 --- a/src/routes/(console)/organization-[organization]/billing/billingAddress.svelte +++ b/src/routes/(console)/organization-[organization]/billing/billingAddress.svelte @@ -150,7 +150,7 @@ bind:selectedAddress={billingAddress} /> {/if} {#if showReplace} - + {/if} {#if showRemove} diff --git a/src/routes/(console)/organization-[organization]/billing/planSummary.svelte b/src/routes/(console)/organization-[organization]/billing/planSummary.svelte index 6a4a43f01..fb3df8a60 100644 --- a/src/routes/(console)/organization-[organization]/billing/planSummary.svelte +++ b/src/routes/(console)/organization-[organization]/billing/planSummary.svelte @@ -1,20 +1,21 @@ {#if $organization} - - {currentPlan.name} plan + {#key aggregationKey} + + {currentPlan.name} plan - {#if totalAmount > 0} - - Next payment of {formatCurrency(totalAmount)} - will occur on - {toLocaleDate($organization?.billingNextInvoiceDate)}. - - {/if} - -
- - Current billing cycle ({new Date( - $organization?.billingCurrentInvoiceDate - ).toLocaleDateString('en', { day: 'numeric', month: 'short' })}-{new Date( - $organization?.billingNextInvoiceDate - ).toLocaleDateString('en', { day: 'numeric', month: 'short' })}) - - - Estimate, subject to change based on usage. - -
- -
- - {#each billingData as row} - - {#each columns as col} - - {#if col.id === 'item'} -
+ {#if totalAmount > 0} + + Next payment of {formatCurrency(totalAmount)} + will occur on + {toLocaleDate($organization?.billingNextInvoiceDate)}. + + {/if} + +
+ + Current billing cycle ({new Date( + $organization?.billingCurrentInvoiceDate + ).toLocaleDateString('en', { day: 'numeric', month: 'short' })}-{new Date( + $organization?.billingNextInvoiceDate + ).toLocaleDateString('en', { day: 'numeric', month: 'short' })}) + + + Estimate, subject to change based on usage. + +
+ +
+ + {#each billingData as row} + + {#each columns as col} + + {#if col.id === 'item'} +
+ {#if row.badge} + + + {row.cells?.[col.id] ?? ''} + + + + {:else} + + {row.cells?.[col.id] ?? ''} + + {/if} +
+ {:else} {row.cells?.[col.id] ?? ''} -
- {:else} - - {row.cells?.[col.id] ?? ''} - - {/if} - - {/each} + {/if} + + {/each} - - {#if row.children} - {#each row.children as child (child.id)} - - {/each} -
- {/each} - {/if} - -
- {/each} - {#if availableCredit > 0} - - - - - - Credits - - - - - - - - - -{formatCurrency(creditsApplied)} - - - - {/if} - - - - - Total - - - - - - - - - {formatCurrency(totalAmount)} - - - -
-
- - -
- {#if $organization?.billingPlan === BillingPlan.FREE || $organization?.billingPlan === BillingPlan.GITHUB_EDUCATION} -
- {#if !currentPlan?.usagePerProject} - + + +
+
+ {#if child.progressData && child.progressData.length > 0 && child.maxValue} + + {/if} +
+
+ {#if child.cells?.usage?.includes(' / ')} + {@const usageParts = ( + child.cells?.usage ?? '' + ).split(' / ')} + + {usageParts[0]} + + + {' / '} + + + {usageParts[1]} + + {:else} + + {child.cells?.usage ?? ''} + + {/if} +
+
+
+ + + {child.cells?.price ?? ''} + + + + {/each} + {/if} + + + {/each} + {#if totalProjects > projectsLimit && hasProjectBreakdown} + + +
+ +
+
+ + +
{/if} - -
- {:else} -
- {#if $organization?.billingPlanDowngrade !== null} - - {:else} + {#if availableCredit > 0} + + + + + + Credits + + + + + + + + + -{formatCurrency(creditsApplied)} + + + + {/if} + + + + + Total + + + + + + + + + {formatCurrency(totalAmount)} + + + + +
+ + +
+ {#if $organization?.billingPlan === BillingPlan.FREE || $organization?.billingPlan === BillingPlan.GITHUB_EDUCATION} + + {#if !currentPlan?.usagePerProject} + + {/if} - {/if} - {#if !currentPlan?.usagePerProject} - - {/if} -
- {/if} -
-
+ + {:else} + + {#if $organization?.billingPlanDowngrade !== null} + + {:else} + + {/if} + {#if !currentPlan?.usagePerProject} + + {/if} + + {/if} +
+ + {/key} {/if} @@ -668,50 +766,7 @@ flex-shrink: 0; } - /* mobile table wrapper for horizontal scroll */ - .table-wrapper.is-mobile { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - margin: 0 -1rem; - padding: 0 1rem; - } - - /* reset mobile overrides - use desktop layout in scrollable container */ - .table-wrapper.is-mobile :global(.child-row) { - grid-template-columns: var(--original-grid-template) !important; - min-width: 600px; /* ensure minimum width for proper layout */ - } - - .table-wrapper.is-mobile :global(.usage-cell-content) { - flex-direction: row !important; - align-items: center !important; - gap: 0.75rem !important; - padding-left: 1rem !important; - min-height: 2rem !important; - } - - .table-wrapper.is-mobile :global(.usage-progress-section) { - width: 200px !important; - flex-shrink: 0 !important; - } - - .table-wrapper.is-mobile :global(.usage-progress-section .progressbar__container) { - width: 200px !important; - max-width: 200px !important; - } - @media (max-width: 768px) { - .actions-mobile { - justify-content: flex-start !important; - gap: 8px !important; - } - - .actions-mobile :global(a), - .actions-mobile :global(button) { - padding: 6px 12px !important; - font-size: 14px !important; - border-radius: 8px !important; - } .billing-cycle-header { flex-direction: column; gap: 8px; @@ -733,4 +788,11 @@ background: unset !important; } } + + /* reducingh size of paginator */ + .pagination-left { + display: inline-block; + transform: scale(0.95); + transform-origin: left center; + } diff --git a/src/routes/(console)/organization-[organization]/billing/planSummaryOld.svelte b/src/routes/(console)/organization-[organization]/billing/planSummaryOld.svelte index 18fdd81cc..083aff444 100644 --- a/src/routes/(console)/organization-[organization]/billing/planSummaryOld.svelte +++ b/src/routes/(console)/organization-[organization]/billing/planSummaryOld.svelte @@ -199,7 +199,7 @@ disabled={$organization?.markedForDeletion} href={$upgradeURL} on:click={() => - trackEvent('click_organization_plan_update', { + trackEvent(Click.OrganizationClickUpgrade, { from: 'button', source: 'billing_tab' })}> diff --git a/src/routes/(console)/organization-[organization]/billing/replaceAddress.svelte b/src/routes/(console)/organization-[organization]/billing/replaceAddress.svelte index 4d5fa0577..5ef49d026 100644 --- a/src/routes/(console)/organization-[organization]/billing/replaceAddress.svelte +++ b/src/routes/(console)/organization-[organization]/billing/replaceAddress.svelte @@ -11,9 +11,11 @@ import { Submit, trackError, trackEvent } from '$lib/actions/analytics'; import { base } from '$app/paths'; import { Alert, Badge, Card, Layout, Skeleton } from '@appwrite.io/pink-svelte'; - import { page } from '$app/state'; + import type { Models } from '@appwrite.io/console'; export let show = false; + export let locale: Models.Locale; + export let countryList: Models.CountryList; let loading = true; let addresses: AddressesList; let selectedAddress: string; @@ -44,13 +46,9 @@ : null : null; - const locale = await sdk.forProject(page.params.region, page.params.project).locale.get(); if (locale?.countryCode) { country = locale.countryCode; } - const countryList = await sdk - .forProject(page.params.region, page.params.project) - .locale.listCountries(); options = countryList.countries.map((country) => { return { value: country.code, diff --git a/src/routes/(console)/organization-[organization]/billing/store.ts b/src/routes/(console)/organization-[organization]/billing/store.ts index 30d9b1c3c..a8f05930a 100644 --- a/src/routes/(console)/organization-[organization]/billing/store.ts +++ b/src/routes/(console)/organization-[organization]/billing/store.ts @@ -1,7 +1,7 @@ import { page } from '$app/stores'; -import type { WizardStepsType } from '$lib/layout/wizardWithSteps.svelte'; -import type { AggregationList, Invoice } from '$lib/sdk/billing'; import { derived, writable } from 'svelte/store'; +import type { WizardStepsType } from '$lib/layout/wizardWithSteps.svelte'; +import type { AggregationList, Invoice, InvoiceUsage } from '$lib/sdk/billing'; export const aggregationList = derived( page, @@ -16,3 +16,31 @@ export const addCreditWizardStore = writable<{ coupon: string; paymentMethodId: export const selectedInvoice = writable(null); export const showRetryModal = writable(false); + +export type RowFactoryOptions = { + id: string; + label: string; + resource?: InvoiceUsage; + planLimit?: number | null; + includeProgress?: boolean; + formatValue?: (value: number | null | undefined) => string; + usageFormatter?: (options: { + value: number; + planLimit?: number | null; + resource?: InvoiceUsage; + formatValue: (value: number | null | undefined) => string; + hasLimit: boolean; + }) => string; + priceFormatter?: (options: { amount: number; resource?: InvoiceUsage }) => string; + progressFactory?: (options: { + value: number; + planLimit?: number | null; + resource?: InvoiceUsage; + hasLimit: boolean; + }) => Array<{ size: number; color: string; tooltip?: { title: string; label: string } }>; + maxFactory?: (options: { + planLimit?: number | null; + hasLimit: boolean; + resource?: InvoiceUsage; + }) => number | null; +}; diff --git a/src/routes/(console)/organization-[organization]/change-plan/+page.svelte b/src/routes/(console)/organization-[organization]/change-plan/+page.svelte index 153e85c5a..ac5d47b75 100644 --- a/src/routes/(console)/organization-[organization]/change-plan/+page.svelte +++ b/src/routes/(console)/organization-[organization]/change-plan/+page.svelte @@ -16,7 +16,6 @@ import { sdk } from '$lib/stores/sdk'; import { confirmPayment } from '$lib/stores/stripe'; import { user } from '$lib/stores/user'; - import { VARS } from '$lib/system'; import { IconPlus } from '@appwrite.io/pink-icons-svelte'; import { Alert, @@ -140,30 +139,14 @@ } async function trackDowngradeFeedback() { - const paidInvoices = await sdk.forConsole.billing.listInvoices(data.organization.$id, [ - Query.equal('status', 'succeeded'), - Query.greaterThan('grossAmount', 0) - ]); - - await fetch(`${VARS.GROWTH_ENDPOINT}/feedback/billing`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - from: tierToPlan(data.organization.billingPlan).name, - to: tierToPlan(selectedPlan).name, - email: data.account.email, - reason: feedbackDowngradeOptions.find( - (option) => option.value === feedbackDowngradeReason - )?.label, - orgId: data.organization.$id, - userId: data.account.$id, - orgAge: data.organization.$createdAt, - userAge: data.account.$createdAt, - paidInvoices: paidInvoices.total, - message: feedbackMessage ?? '' - }) + await sdk.forConsole.organizations.createDowngradeFeedback({ + organizationId: data.organization.$id, + reason: feedbackDowngradeOptions.find( + (option) => option.value === feedbackDowngradeReason + )?.label, + message: feedbackMessage ?? '', + fromPlanId: data.organization.billingPlan, + toPlanId: selectedPlan }); } @@ -173,8 +156,7 @@ await sdk.forConsole.billing.updatePlan( data.organization.$id, selectedPlan, - paymentMethodId, - null + paymentMethodId ); // 2) If the target plan has a project limit, apply selected projects now @@ -254,7 +236,7 @@ data.organization.$id, selectedPlan, paymentMethodId, - null, + undefined, selectedCoupon?.code, newCollaborators, billingBudget, diff --git a/src/routes/(console)/organization-[organization]/domains/domain-[domain]/settings/+page.ts b/src/routes/(console)/organization-[organization]/domains/domain-[domain]/settings/+page.ts index af454f6d5..8040bcca7 100644 --- a/src/routes/(console)/organization-[organization]/domains/domain-[domain]/settings/+page.ts +++ b/src/routes/(console)/organization-[organization]/domains/domain-[domain]/settings/+page.ts @@ -1,13 +1,14 @@ import { Dependencies } from '$lib/constants'; import { sdk } from '$lib/stores/sdk'; import { isCloud } from '$lib/system'; +import { Query } from '@appwrite.io/console'; export const load = async ({ parent, depends }) => { depends(Dependencies.DOMAINS); const organizations = !isCloud ? await sdk.forConsole.teams.list() - : await sdk.forConsole.billing.listOrganization(); + : await sdk.forConsole.billing.listOrganization([Query.equal('platform', 'appwrite')]); const { domain } = await parent(); return { diff --git a/src/routes/(console)/project-[region]-[project]/+layout.svelte b/src/routes/(console)/project-[region]-[project]/+layout.svelte index 426821259..f9dd0ae1d 100644 --- a/src/routes/(console)/project-[region]-[project]/+layout.svelte +++ b/src/routes/(console)/project-[region]-[project]/+layout.svelte @@ -1,5 +1,5 @@ diff --git a/src/routes/(console)/project-[region]-[project]/auth/security/updateMockNumbers.svelte b/src/routes/(console)/project-[region]-[project]/auth/security/updateMockNumbers.svelte index e8a82bab9..515fb2228 100644 --- a/src/routes/(console)/project-[region]-[project]/auth/security/updateMockNumbers.svelte +++ b/src/routes/(console)/project-[region]-[project]/auth/security/updateMockNumbers.svelte @@ -95,7 +95,7 @@ Learn more {#if isComponentDisabled} - +
{#if $app.themeInUse === 'dark'} diff --git a/src/routes/(console)/project-[region]-[project]/auth/templates/emailSignature.svelte b/src/routes/(console)/project-[region]-[project]/auth/templates/emailSignature.svelte index 8885f086e..6651bce51 100644 --- a/src/routes/(console)/project-[region]-[project]/auth/templates/emailSignature.svelte +++ b/src/routes/(console)/project-[region]-[project]/auth/templates/emailSignature.svelte @@ -15,7 +15,7 @@ Enable or disable Appwrite branding in your email template signature. - +
{#if $app.themeInUse === 'dark'} diff --git a/src/routes/(console)/project-[region]-[project]/databases/create.svelte b/src/routes/(console)/project-[region]-[project]/databases/create.svelte index 4c4842282..f00b68e59 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/create.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/create.svelte @@ -7,8 +7,7 @@ import { ID } from '@appwrite.io/console'; import { createEventDispatcher } from 'svelte'; import { isCloud } from '$lib/system'; - import { BillingPlan } from '$lib/constants'; - import { organization } from '$lib/stores/organization'; + import { currentPlan } from '$lib/stores/organization'; import { upgradeURL } from '$lib/stores/billing'; import CreatePolicy from './database-[database]/backups/createPolicy.svelte'; import { cronExpression, type UserBackupPolicy } from '$lib/helpers/backups'; @@ -132,7 +131,7 @@ {#if isCloud} - {#if $organization?.billingPlan === BillingPlan.FREE} + {#if !$currentPlan?.backupsEnabled} Upgrade your plan to ensure your data stays safe and backed up. diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/columns.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/columns.svelte new file mode 100644 index 000000000..69cc26bcb --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/columns.svelte @@ -0,0 +1,65 @@ + + + + + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/empty.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/empty.svelte index 09289f76f..22839e48a 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/empty.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/empty.svelte @@ -8,12 +8,15 @@ Spreadsheet, Typography, FloatingActionBar, - Popover + Popover, + Badge } from '@appwrite.io/pink-svelte'; - import { IconFingerPrint, IconPlus } from '@appwrite.io/pink-icons-svelte'; + import { IconFingerPrint, IconPlus, IconText } from '@appwrite.io/pink-icons-svelte'; import { isSmallViewport, isTabletViewport } from '$lib/stores/viewport'; import type { Column } from '$lib/helpers/types'; - import { expandTabs } from '../table-[table]/store'; + import { SortButton } from '$lib/components'; + import { expandTabs, columnsOrder, columnsWidth, reorderItems } from '../table-[table]/store'; + import { preferences } from '$lib/stores/preferences'; import SpreadsheetContainer from '../table-[table]/layout/spreadsheet.svelte'; import { onDestroy, onMount, tick } from 'svelte'; import { sdk } from '$lib/stores/sdk'; @@ -38,8 +41,62 @@ import Options from './options.svelte'; import { InputSelect, InputText } from '$lib/elements/forms'; import { isCloud, VARS } from '$lib/system'; + import { fade } from 'svelte/transition'; import IconAINotification from './icon/aiNotification.svelte'; + import type { Models } from '@appwrite.io/console'; + + let { + userColumns = [], + userDataRows = [] + }: { + userColumns?: Column[]; + userDataRows?: Models.Row[]; + } = $props(); + + const tableId = page.params.table; + const minimumUserColumnWidth = 168; + + function getUserColumnWidth( + columnId: string, + defaultWidth: number | { min: number } + ): number | { min: number; max?: number } { + const savedWidth = $columnsWidth?.[columnId]; + if (!savedWidth) return defaultWidth; + return savedWidth.resized; + } + + // apply order & width to user columns + const staticUserColumns = $derived.by(() => { + if (!userColumns.length) return []; + + // apply widths to columns + const columnsWithWidths = userColumns.map((column) => { + const defaultWidth = + typeof column.width === 'object' && 'min' in column.width + ? column.width + : typeof column.width === 'number' + ? column.width + : minimumUserColumnWidth; + + return { + ...column, + width: getUserColumnWidth(column.id, defaultWidth), + custom: false, + resizable: false, + draggable: false + }; + }); + + // apply ordering if preferences exist + if ($columnsOrder && $columnsOrder.length > 0) { + return reorderItems(columnsWithWidths, $columnsOrder); + } + + return columnsWithWidths.filter( + (column) => !['$id', '$createdAt', '$updatedAt', 'actions'].includes(column.id) + ); + }); let resizeObserver: ResizeObserver; let spreadsheetContainer: HTMLElement; @@ -48,21 +105,43 @@ let headerElement: HTMLElement | null = null; let rangeOverlayEl: HTMLDivElement | null = null; let fadeBottomOverlayEl: HTMLDivElement | null = null; + let snowFadeBottomOverlayEl: HTMLDivElement | null = null; - let customColumns = $state< - (SuggestedColumnSchema & { elements?: []; isPlaceholder?: boolean })[] - >(Array.from({ length: 7 }, (_, index) => createPlaceholderColumn(index))); + let customColumns = $state( + Array.from({ length: 7 }, (_, index) => createPlaceholderColumn(index)) + ); let showFloatingBar = $state(true); let hasTransitioned = $state(false); let scrollAnimationFrame: number | null = null; let creatingColumns = $state(false); - const baseColProps = { draggable: false, resizable: false }; + let selectedColumnId = $state(null); + let previousColumnId = $state(null); + let selectedColumnName = $state(null); + + let showHeadTooltip = $state(true); + let isInlineEditing = $state(false); + // let tooltipTopPosition = $state(50); + let triggerColumnId = $state(null); + let hoveredColumnId = $state(null); + + // for deleting a column + undo + let undoTimer: ReturnType | null = $state(null); + let columnBeingDeleted: (SuggestedColumnSchema & { deletedIndex?: number }) | null = + $state(null); + + const baseColProps = { + custom: false, + draggable: false, + resizable: false + }; const NOTIFICATION_AND_MOCK_DELAY = 1250; + const COLUMN_DELETION_UNDO_TIMER_LIMIT = 10000; // 10 seconds const getColumnWidth = (columnKey: string) => Math.max(180, columnKey.length * 8 + 60); + const safeNumericValue = (value: number | undefined) => value !== undefined && isWithinSafeRange(value) ? value : undefined; @@ -85,7 +164,7 @@ const updateOverlayHeight = () => { if (!spreadsheetContainer) return; if (!headerElement || !headerElement.isConnected) { - headerElement = spreadsheetContainer.querySelector('[role="rowheader"]'); + headerElement = spreadsheetContainer?.querySelector('[role="rowheader"]'); } if (!headerElement) return; @@ -106,7 +185,7 @@ const updateOverlayBounds = () => { if (!spreadsheetContainer) return; if (!headerElement || !headerElement.isConnected) { - headerElement = spreadsheetContainer.querySelector('[role="rowheader"]'); + headerElement = spreadsheetContainer?.querySelector('[role="rowheader"]'); } if (!headerElement) return; @@ -136,18 +215,45 @@ const hasRealColumns = customColumns.some((col) => !col.isPlaceholder); if (!hasRealColumns) { - // For placeholders or no columns, position overlay to cover custom columns area - const idCell = getById('$id'); + // for placeholders or no columns, + // position overlay to cover custom columns area + let startCell = getById('$id'); + + if (staticUserColumns.length > 0) { + const lastUserColumn = staticUserColumns[staticUserColumns.length - 1]; + let lastUserCell = getById(lastUserColumn.id); + + // if not found with data-header="true", try without it + if (!lastUserCell) { + lastUserCell = headerElement!.querySelector( + `[role="cell"][data-column-id="${lastUserColumn.id}"]` + ); + } + + if (lastUserCell) { + startCell = lastUserCell; + } + } + const actionsCell = headerElement!.querySelector( '[role="cell"][data-column-id="actions"]' ); - if (idCell && actionsCell) { - const idRect = idCell.getBoundingClientRect(); + if (startCell && actionsCell) { + const startRect = startCell.getBoundingClientRect(); const actionsRect = actionsCell.getBoundingClientRect(); - const left = Math.round(idRect.right - containerRect.left); + let left = Math.round(startRect.right - containerRect.left); const actionsLeft = actionsRect.left - containerRect.left; + // ensure overlay doesn't go over select + const selectionRect = spreadsheetContainer + .querySelector('[data-select="true"]') + ?.getBoundingClientRect(); + if (selectionRect) { + const selectionRight = Math.round(selectionRect.right - containerRect.left); + left = Math.max(left, selectionRight); + } + const width = actionsLeft - left; spreadsheetContainer.style.setProperty('--group-left', `${left - 2}px`); @@ -200,15 +306,35 @@ .querySelector('[data-select="true"]') ?.getBoundingClientRect(); - // Start overlay after selection column if it exists, otherwise after $id + // determine starting point for overlay let startLeft = idRect.right; if (selectionRect && selectionRect.right > idRect.right) { startLeft = selectionRect.right; } + // if userColumns exist, + // start overlay **after** the last userColumn + if (staticUserColumns.length > 0) { + const lastUserColumn = staticUserColumns[staticUserColumns.length - 1]; + const lastUserCell = getById(lastUserColumn.id); + + if (lastUserCell) { + const lastUserRect = lastUserCell.getBoundingClientRect(); + startLeft = lastUserRect.right; + } + } + + if (selectionRect) { + startLeft = Math.max(startLeft, selectionRect.right); + } + const left = Math.round(startLeft - containerRect.left); - // get the actions column and use its left border as the boundary + // use the last visible custom column's right edge as the overlay boundary + const endRect = endCell.getBoundingClientRect(); + const endRight = Math.round(endRect.right - containerRect.left); + + // also get the actions column to ensure we don't exceed it const actionsCell = headerElement!.querySelector( '[role="cell"][data-column-id="actions"]' ); @@ -223,7 +349,9 @@ const actionsRect = actionsCell.getBoundingClientRect(); const actionsLeft = actionsRect.left - containerRect.left; - const width = actionsLeft - left; + // ensure overlay doesn't exceed bounds + const right = Math.min(endRight, actionsLeft); + const width = right - left; // Apply overlay positioning spreadsheetContainer.style.setProperty('--group-left', `${left - 2}px`); @@ -232,40 +360,126 @@ // only for mobile, we can remove if not needed! const scrollToFirstCustomColumn = () => { - if (!$isSmallViewport) return; + if (!staticUserColumns.length && !$isSmallViewport) return; if (!headerElement || !headerElement.isConnected) { - headerElement = spreadsheetContainer.querySelector('[role="rowheader"]'); + headerElement = spreadsheetContainer?.querySelector('[role="rowheader"]'); } if (!headerElement) return; - const firstCustomColumnCell = headerElement.querySelector( - `[role="cell"][data-header="true"][data-column-id="${customColumns[0]?.key}"]` - ); - const directAccessScroller = hScroller ?? findHorizontalScroller(headerElement) ?? // internal spreadsheet root main container! spreadsheetContainer.querySelector('.spreadsheet-container'); - if (firstCustomColumnCell && directAccessScroller) { - const cellRect = firstCustomColumnCell.getBoundingClientRect(); + if (!directAccessScroller) return; + + let targetCell: HTMLElement | null = null; + + if (staticUserColumns.length > 0 && !$isSmallViewport) { + const lastUserColumn = staticUserColumns[staticUserColumns.length - 1]; + targetCell = headerElement.querySelector( + `[role="cell"][data-header="true"][data-column-id="${lastUserColumn.id}"]` + ); + } else { + targetCell = headerElement.querySelector( + `[role="cell"][data-header="true"][data-column-id="${customColumns[0]?.key}"]` + ); + } + + if (targetCell) { + const cellRect = targetCell.getBoundingClientRect(); const scrollerRect = directAccessScroller.getBoundingClientRect(); const scrollLeft = directAccessScroller.scrollLeft + cellRect.left - scrollerRect.left - 40; directAccessScroller.scrollTo({ left: Math.max(0, scrollLeft), - behavior: 'smooth' + behavior: 'instant' }); } }; + function updateColumnHighlight() { + const activeColumnId = selectedColumnId || hoveredColumnId; + if (!spreadsheetContainer || !activeColumnId) return; + + const headerCell = spreadsheetContainer.querySelector( + `[role="rowheader"] [role="cell"][data-column-id="${activeColumnId}"]` + ); + + if (!headerCell) return; + + // calculate position similar to columns-range-overlay logic + if (!headerElement || !headerElement.isConnected) { + headerElement = spreadsheetContainer.querySelector('[role="rowheader"]'); + } + + if (!headerElement) return; + + const containerRect = spreadsheetContainer.getBoundingClientRect(); + const cellRect = headerCell.getBoundingClientRect(); + + const left = Math.round(cellRect.left - containerRect.left); + const width = cellRect.width; + + const isHovered = !selectedColumnId && hoveredColumnId; + const isFirstColumn = activeColumnId === customColumns[0]?.key; + const isLastColumn = activeColumnId === customColumns[customColumns.length - 1]?.key; + + let leftAdjustment = -2; + let widthAdjustment = 2; + if (isHovered && (isFirstColumn || isLastColumn)) { + leftAdjustment = 0; + } + + // get actions boundary to prevent hover overlay over it + const actionsCell = headerElement.querySelector( + '[role="cell"][data-column-id="actions"]' + ); + + let finalWidth = width + widthAdjustment; + + if (isHovered && actionsCell) { + const actionsRect = actionsCell.getBoundingClientRect(); + const actionsLeft = actionsRect.left - containerRect.left; + const overlayRight = left + leftAdjustment + finalWidth; + + const borderWidth = 2; + if (overlayRight + borderWidth > actionsLeft) { + finalWidth = actionsLeft - (left + leftAdjustment) - borderWidth; + } + } + + spreadsheetContainer.style.setProperty('--highlight-left', `${left + leftAdjustment}px`); + spreadsheetContainer.style.setProperty('--highlight-width', `${finalWidth}px`); + + if (isHovered) { + const tooltipElement = + spreadsheetContainer.querySelector('.custom-tooltip'); + const tooltipWidth = tooltipElement ? tooltipElement.offsetWidth : 200; + const defaultOffset = 325; + const smallerOffset = 225; + const viewportWidth = window.innerWidth; + + // check how much space is available to the right of the column + const columnRightEdge = left + leftAdjustment + finalWidth; + const availableSpace = viewportWidth - columnRightEdge; + + // use smaller offset if there isn't enough space for default offset + tooltip + const shouldUseSmallerOffset = availableSpace < defaultOffset + tooltipWidth; + const tooltipOffset = shouldUseSmallerOffset ? smallerOffset : defaultOffset; + + spreadsheetContainer.style.setProperty('--tooltip-offset', `${tooltipOffset}px`); + } + } + const recalcAll = () => { updateOverlayHeight(); updateOverlayBounds(); + updateColumnHighlight(); }; /** @@ -276,6 +490,16 @@ scrollAnimationFrame = requestAnimationFrame(() => { recalcAll(); + + // check if selected column is still visible after scroll + if (selectedColumnId && !isColumnVisible(selectedColumnId)) { + resetSelectedColumn(); + } + + if (hoveredColumnId && !isColumnVisible(hoveredColumnId)) { + hoveredColumnId = null; + } + scrollAnimationFrame = null; }); }; @@ -297,38 +521,41 @@ width: { min: getColumnWidth(col.key) }, icon: columnOption?.icon, draggable: false, - resizable: false + resizable: false, + custom: true }; }); }); - const getRowColumns = (): Column[] => { - const minColumnWidth = 180; + const getRowColumns = (): (Column & { custom: boolean })[] => { + const minColumnWidth = 250; const fixedWidths = { id: minColumnWidth, actions: 40, selection: 40 }; - // calculate base widths and total - const columnsWithBase = customSuggestedColumns.map((col) => ({ - ...col, - baseWidth: Math.max(minColumnWidth, getColumnWidth(col.id)) - })); + const equalWidthColumns = [...staticUserColumns, ...customSuggestedColumns]; - const totalUsed = + const totalBaseWidth = fixedWidths.id + fixedWidths.actions + fixedWidths.selection + - columnsWithBase.reduce((sum, col) => sum + col.baseWidth, 0); + equalWidthColumns.length * minColumnWidth; - // distribute excess space equally across custom columns const viewportWidth = spreadsheetContainer?.clientWidth || - (typeof window !== 'undefined' ? window.innerWidth : totalUsed); + (typeof window !== 'undefined' ? window.innerWidth : totalBaseWidth); + const excessSpace = Math.max(0, viewportWidth - totalBaseWidth); const extraPerColumn = - Math.max(0, viewportWidth - totalUsed) / (columnsWithBase.length || 1); + equalWidthColumns.length > 0 ? excessSpace / equalWidthColumns.length : 0; + const distributedWidth = minColumnWidth + extraPerColumn; - const finalCustomColumns = columnsWithBase.map((col) => ({ + const userColumnsWithWidth = staticUserColumns.map((col) => ({ ...col, - width: { min: col.baseWidth + extraPerColumn } + width: distributedWidth + })); + + const finalCustomColumns = customSuggestedColumns.map((col) => ({ + ...col, + width: { min: distributedWidth } })); return [ @@ -340,6 +567,7 @@ icon: IconFingerPrint, ...baseColProps }, + ...userColumnsWithWidth, ...finalCustomColumns, { id: 'actions', @@ -353,9 +581,14 @@ }; const spreadsheetColumns = $derived(getRowColumns()); - const emptyCells = $derived(($isSmallViewport ? 14 : 17) + (!$expandTabs ? 2 : 0)); + const emptyCells = $derived( + ($isSmallViewport ? 14 : 17) + (!$expandTabs ? 2 : 0) - userDataRows.length + ); onMount(async () => { + columnsOrder.set(preferences.getColumnOrder(tableId)); + columnsWidth.set(preferences.getColumnWidths(tableId)); + if (spreadsheetContainer) { resizeObserver = new ResizeObserver(recalcAll); resizeObserver.observe(spreadsheetContainer); @@ -374,20 +607,22 @@ // these are referenced in // `table-[table]/+page.svelte` $tableColumnSuggestions.table = null; + $tableColumnSuggestions.force = false; $tableColumnSuggestions.enabled = false; } $tableColumnSuggestions.context = null; $tableColumnSuggestions.thinking = false; + + // reset selection! + resetSelectedColumn(); } async function suggestColumns() { $tableColumnSuggestions.thinking = true; - if ($isSmallViewport) { - await tick(); - scrollToFirstCustomColumn(); - } + await tick(); + scrollToFirstCustomColumn(); let suggestedColumns: { total: number; @@ -473,17 +708,22 @@ } } - function onPopoverShowStateChanged(value: boolean) { - showFloatingBar = !value; + async function updateOverlaysForMobile(value: boolean) { if ($isSmallViewport) { setTimeout(() => { - [rangeOverlayEl, fadeBottomOverlayEl].forEach((el) => { + [rangeOverlayEl, fadeBottomOverlayEl, snowFadeBottomOverlayEl].forEach((el) => { if (el) { el.style.opacity = value ? '0' : '1'; } }); }, 0); } + } + + function onPopoverShowStateChanged(value: boolean) { + showFloatingBar = !value; + showHeadTooltip = !value; + updateOverlaysForMobile(value); const currentScrollLeft = hScroller?.scrollLeft || 0; @@ -492,6 +732,9 @@ hScroller.scrollLeft = currentScrollLeft; } }); + + // reset selection! + resetSelectedColumn(); } function updateColumn(columnId: string, updates: Partial) { @@ -515,6 +758,174 @@ return !['$id', '$createdAt', '$updatedAt', 'actions'].includes(id); } + function resetSelectedColumn() { + selectedColumnId = null; + previousColumnId = null; + /*selectedColumnName = null;*/ + } + + // small decor, hides previous cell's right border visibility! + function handlePreviousColumnsBorder(columnId: string, hide: boolean = true) { + const allHeaders = Array.from( + spreadsheetContainer.querySelectorAll( + '[role="rowheader"] [role="cell"][data-column-id]' + ) + ); + + const selectedIndex = allHeaders.findIndex( + (cell) => cell.getAttribute('data-column-id') === columnId + ); + + if (selectedIndex > 0) { + const prevColumnId = allHeaders[selectedIndex - 1].getAttribute('data-column-id'); + if (prevColumnId) { + const previousCells = spreadsheetContainer.querySelectorAll( + `[role="rowheader"] [role="cell"][data-column-id="${prevColumnId}"]` + ); + + previousCells.forEach((cell) => { + if (hide) { + cell.classList.add('hide-border'); + } else { + cell.classList.remove('hide-border'); + } + }); + } + } + } + + function isColumnVisible(columnId: string) { + if (!spreadsheetContainer || !hScroller) return true; + + const columnCell = spreadsheetContainer.querySelector( + `[role="rowheader"] [role="cell"][data-column-id="${columnId}"]` + ); + + if (!columnCell) return false; + + const cellRect = columnCell.getBoundingClientRect(); + const scrollerRect = hScroller.getBoundingClientRect(); + + // stickies have 40px width + const STICKY_COLUMN_WIDTH = 40; + + // calculate available viewport bounds (excluding both 40px sticky columns) + const leftBound = scrollerRect.left + STICKY_COLUMN_WIDTH; // Selection column (40px) + const rightBound = scrollerRect.right - STICKY_COLUMN_WIDTH; // Actions column (40px) + + const safetyMargin = 2; + return ( + cellRect.left >= leftBound - safetyMargin && cellRect.right <= rightBound + safetyMargin + ); + } + + function scrollColumnIntoView(columnId: string) { + if (!spreadsheetContainer || !hScroller) return false; + + const columnCell = spreadsheetContainer.querySelector( + `[role="rowheader"] [role="cell"][data-column-id="${columnId}"]` + ); + + if (!columnCell) return false; + + const cellRect = columnCell.getBoundingClientRect(); + const scrollerRect = hScroller.getBoundingClientRect(); + + // calculate scroll needed to center the column in view + const scrollLeft = + hScroller.scrollLeft + + cellRect.left - + scrollerRect.left - + (scrollerRect.width - cellRect.width) / 2; + + hScroller.scrollTo({ + left: Math.max(0, scrollLeft), + behavior: 'smooth' + }); + + return true; + } + + function deleteColumn(columnId: string) { + if (!columnId) return; + + let columnIndex = -1; + let columnSchema: SuggestedColumnSchema = null; + + for (let index = 0; index < customColumns.length; index++) { + if (customColumns[index].key === columnId) { + columnIndex = index; + columnSchema = customColumns[index]; + break; + } + } + + if (columnIndex === -1 || !columnSchema) { + return; + } + + // remove the column + customColumns.splice(columnIndex, 1); + + // store column with its index for undo + columnBeingDeleted = { ...columnSchema, deletedIndex: columnIndex }; + + // clear any existing timer + if (undoTimer) { + clearTimeout(undoTimer); + } + + // start 10-second undo timer + undoTimer = setTimeout(() => { + undoTimer = null; + selectedColumnId = null; + columnBeingDeleted = null; + selectedColumnName = null; + }, COLUMN_DELETION_UNDO_TIMER_LIMIT); + + // reset selection! + resetSelectedColumn(); + + // see overlay is visible after deletion on mobile! + setTimeout(() => updateOverlaysForMobile(false), 150); + + // recalculate view after deletion + requestAnimationFrame(() => recalcAll()); + } + + function undoDelete() { + if (!columnBeingDeleted) return; + + const { deletedIndex, ...columnData } = columnBeingDeleted; + + // restore column at its original index + if (deletedIndex !== undefined && deletedIndex >= 0) { + customColumns.splice(deletedIndex, 0, columnData); + } else { + // fallback: add at the end if index is missing + customColumns.push(columnData); + } + + // clear undo state + columnBeingDeleted = null; + + // clear timer + if (undoTimer) { + clearTimeout(undoTimer); + undoTimer = null; + } + + // recalculate view after restore + requestAnimationFrame(() => { + recalcAll(); + + tick().then(() => { + selectedColumnId = columnData.key; + selectedColumnName = columnData.key; + }); + }); + } + function showIndexSuggestionsNotification() { // safeguard anyways! if (!isCloud) return; @@ -542,8 +953,20 @@ async function createColumns() { creatingColumns = true; + selectedColumnId = null; + const client = sdk.forProject(page.params.region, page.params.project); + const isAnyEmpty = customColumns.some((col) => !col.key); + if (isAnyEmpty) { + creatingColumns = false; + addNotification({ + type: 'warning', + message: 'Some columns have invalid keys' + }); + return; + } + try { const results = []; @@ -552,7 +975,8 @@ databaseId: page.params.database, tableId: page.params.table, key: column.key, - required: column.required || false + required: column.required || false, + encrypt: 'encrypt' in column ? column.encrypt : undefined }; let columnResult: Columns; @@ -650,6 +1074,8 @@ timeout: NOTIFICATION_AND_MOCK_DELAY }); + resetSuggestionsStore(true); + // show index notification! showIndexSuggestionsNotification(); @@ -664,13 +1090,12 @@ } } - function createPlaceholderColumn( - index: number - ): SuggestedColumnSchema & { elements?: []; isPlaceholder?: boolean } { + function createPlaceholderColumn(index: number): SuggestedColumnSchema { return { key: `column${index + 1}`, type: 'string', required: false, + array: false, default: null, format: null, size: undefined, @@ -681,6 +1106,159 @@ }; } + // scroll to view if needed and select! + function selectColumnWithId(column: Column) { + if (creatingColumns) return; + + const columnId = column.id; + selectedColumnName = column.title; + if (!isColumnVisible(columnId)) { + scrollColumnIntoView(columnId); + setTimeout(() => (selectedColumnId = columnId), 300); + } else { + selectedColumnId = columnId; + } + + columnBeingDeleted = null; + } + + /*function fadeSlide(_: Node, { y = 8, duration = 200 } = {}) { + return { + duration, + css: (time: number) => ` + opacity: ${time}; + transform: translateY(${(1 - time) * y}px); + ` + }; + }*/ + + function columnHoverMouseTracker(event: MouseEvent) { + if (hoveredColumnId && event.target instanceof Element) { + const hoveredButton = event.target.closest('[data-column-hover]'); + const currentColumnId = hoveredButton?.getAttribute('data-column-hover'); + + if (currentColumnId !== hoveredColumnId) { + hoveredColumnId = null; + } + } + } + + $effect(() => { + if (!spreadsheetContainer) return; + + // remove existing hide-border classes + const hiddenCells = spreadsheetContainer.querySelectorAll('[role="cell"].hide-border'); + hiddenCells.forEach((cell) => cell.classList.remove('hide-border')); + + if (!selectedColumnId) return; + + setTimeout(() => { + // hide borders for selected column and previous column + const selectedCells = spreadsheetContainer.querySelectorAll( + `[role="cell"][data-column-id="${selectedColumnId}"]` + ); + + selectedCells.forEach((cell) => cell.classList.add('hide-border')); + + // find and hide previous column's borders (which create the left edge of selected column) + const allHeaders = Array.from( + spreadsheetContainer.querySelectorAll( + '[role="rowheader"] [role="cell"][data-column-id]' + ) + ); + const selectedIndex = allHeaders.findIndex( + (cell) => cell.getAttribute('data-column-id') === selectedColumnId + ); + + if (selectedIndex > 0) { + const prevColumnId = allHeaders[selectedIndex - 1].getAttribute('data-column-id'); + if (prevColumnId) { + const previousCells = spreadsheetContainer.querySelectorAll( + `[role="cell"][data-column-id="${prevColumnId}"]` + ); + previousCells.forEach((cell) => cell.classList.add('hide-border')); + } + } + }, 300); + + // update position + updateColumnHighlight(); + + // track for next selection - + // but only if we had a `real` previous selection + if (previousColumnId !== null) { + previousColumnId = selectedColumnId; + } else { + // fresh after a deselect + // set it for future switches + setTimeout(() => (previousColumnId = selectedColumnId), 25); + } + }); + + // mark suggested column cells so CSS can target them specifically + $effect(() => { + if (!spreadsheetContainer) return; + + // get all custom column IDs + const suggestedColumnIds = customColumns.map((col) => col.key); + const firstSuggestedColumnId = suggestedColumnIds[0]; + + const columnBeforeOverlay = + staticUserColumns.length > 0 + ? staticUserColumns[staticUserColumns.length - 1].id + : '$id'; + + const allCells = spreadsheetContainer.querySelectorAll('[role="cell"][data-column-id]'); + allCells.forEach((cell) => { + const columnId = cell.getAttribute('data-column-id'); + if (columnId && suggestedColumnIds.includes(columnId)) { + cell.setAttribute('data-suggested-column', 'true'); + if (columnId === firstSuggestedColumnId) { + cell.setAttribute('data-first-suggested-column', 'true'); + } else { + cell.removeAttribute('data-first-suggested-column'); + } + } else { + cell.removeAttribute('data-suggested-column'); + cell.removeAttribute('data-first-suggested-column'); + } + + if (columnId === columnBeforeOverlay) { + cell.setAttribute('data-column-before-overlay', 'true'); + } else { + cell.removeAttribute('data-column-before-overlay'); + } + }); + }); + + $effect(() => { + if (!spreadsheetContainer) return; + + const allCells = spreadsheetContainer.querySelectorAll('[role="cell"]'); + allCells.forEach((cell) => { + const resizer = cell.querySelector('.column-resizer-disabled') as HTMLDivElement; + if (resizer) resizer.style.display = ''; + }); + + if (!hoveredColumnId) return; + + // auto-scroll if hovered column is out of bounds + /*if (!isColumnVisible(hoveredColumnId)) { + scrollColumnIntoView(hoveredColumnId); + }*/ + + const hoveredCells = spreadsheetContainer.querySelectorAll( + `[role="cell"][data-column-id="${hoveredColumnId}"]` + ); + + hoveredCells.forEach((cell) => { + const resizer = cell.querySelector('.column-resizer-disabled') as HTMLDivElement; + if (resizer) resizer.style.display = 'none'; + }); + + updateColumnHighlight(); + }); + onDestroy(() => { resizeObserver?.disconnect(); hScroller?.removeEventListener('scroll', recalcAllThrottled); @@ -696,12 +1274,14 @@
0} class:thinking={$tableColumnSuggestions.thinking} class="databases-spreadsheet spreadsheet-container-outer" style:--overlay-icon-color="#fd366e99" - style:--non-overlay-icon-color="--fgcolor-neutral-weak"> + style:--non-overlay-icon-color="--fgcolor-neutral-weak" + onmousemove={columnHoverMouseTracker}>
+ + + {#if selectedColumnId || hoveredColumnId} + {@const activeColumnId = selectedColumnId || hoveredColumnId} + {@const isHovered = !selectedColumnId && hoveredColumnId} + {@const isFirstColumn = activeColumnId === customColumns[0]?.key} + {@const isLastColumn = activeColumnId === customColumns[customColumns.length - 1]?.key} +
+
+ + + {/if}
{}}> + bottomActionClick={() => {}} + let:root> {#each spreadsheetColumns as column, index (index)} {#if column.isAction} - + @@ -736,178 +1345,224 @@ ? '--non-overlay-icon-color' : '--overlay-icon-color'} {@const isColumnInteractable = - isCustomColumn(column.id) && !columnObj.isPlaceholder} + isCustomColumn(column.id) && columnObj && !columnObj.isPlaceholder} + {@const userColumn = column.id === '$id' || !column.custom} - - {#snippet children(toggle)} - { - // tablet viewport check because context-menu - // can be triggered on long hold clicks as well! - if (isColumnInteractable && !$isTabletViewport) { - toggle(event); - } - }}> - - - {column.title} - + {#if userColumn} + + + + {column.title} + - -
- { - if ( - isColumnInteractable && - !$isTabletViewport - ) { - toggle(event); - } - }}> - {#if !columnObj?.isPlaceholder} - - {/if} - -
+ +
+
+ {:else} + { + if (triggerColumnId === column.id) { + triggerColumnId = null; + return true; + } -
- - - {#each basicColumnOptions as option} - { - toggle(); - updateColumn(column.id, { - type: option.type, - format: - option.format || null - }); - }}> - - - {option.name} - - - {/each} - - -
- -
- - - {#if !$isTabletViewport} -
- - - {#if columnIcon} - - {/if} - - -
- {/if} -
-
- {/snippet} - - {#snippet tooltipChildren()} - {#if columnObj} - {@const selectedOption = getColumnOption( - columnObj.type, - columnObj.format - )} - {@const ColumnComponent = selectedOption?.component} - + return false; + }}> + {#snippet children(toggle)} + { + // tablet viewport check because context-menu + // can be triggered on long hold clicks as well! + if (isColumnInteractable && !$isTabletViewport) { + toggle(event); + } + }}> - + direction="row" + alignItems="center" + alignContent="center" + justifyContent="space-between"> + + {column.title} + - { - const newOption = columnOptions.find( - (opt) => opt.name === e.detail - ); - if (newOption) { - updateColumn(column.id, { - type: newOption.type, - format: newOption.format || null - }); - } - }} - options={basicColumnOptions.map((col) => { - return { - label: col.name, - value: col.name, - leadingIcon: col.icon - }; - })} /> + {@render changeColumnTypePopover({ + id: column.id, + columnObj, + iconColor: columnIconColor, + icon: column.icon, + isColumnInteractable, + index + })} - {#if ColumnComponent} - - {/if} - - {/if} - {/snippet} -
+ + {#if !$isTabletViewport} +
{ + isInlineEditing = true; + showHeadTooltip = false; + resetSelectedColumn(); + handlePreviousColumnsBorder(column.id); + }} + onfocusout={() => { + showHeadTooltip = true; + isInlineEditing = false; + handlePreviousColumnsBorder( + column.id, + false + ); + }}> + + + {#if columnIcon} + {@render changeColumnTypePopover({ + id: column.id, + columnObj, + iconColor: columnIconColor, + icon: column.icon, + isColumnInteractable, + index + })} + {/if} + + +
+ {/if} +
+ + {/snippet} + + {#snippet tooltipChildren()} + {#if columnObj} + {@const selectedOption = getColumnOption( + columnObj.type, + columnObj.format + )} + {@const ColumnComponent = selectedOption?.component} + + + + + { + const newOption = columnOptions.find( + (opt) => opt.name === e.detail + ); + if (newOption) { + updateColumn(column.id, { + type: newOption.type, + format: newOption.format || null + }); + } + }} + options={basicColumnOptions.map((col) => { + return { + label: col.name, + value: col.name, + leadingIcon: col.icon + }; + })} /> + + + {#if ColumnComponent} + + {/if} + + {/if} + {/snippet} + + {#snippet mobileFooterChildren(toggle)} + { + toggle(event); + deleteColumn(column.id); + }} + style="position: absolute; left: 1rem;" + >Delete + + {/snippet} + + {/if} {/if} {/each}
+ + {#each userDataRows as row} + + {#each spreadsheetColumns as column} + {@const columnObj = getColumn(column.id)} + {@const interactable = + isCustomColumn(column.id) && columnObj && !columnObj.isPlaceholder} + + {@render rowCellInteractiveButton({ + interactable, + column, + row + })} + + {/each} + + {/each} + + {#each Array.from({ length: emptyCells }) as _} + + {#each spreadsheetColumns as column} + {@const columnObj = getColumn(column.id)} + {@const interactable = + isCustomColumn(column.id) && columnObj && !columnObj.isPlaceholder} + + {@render rowCellInteractiveButton({ + interactable, + column + })} + + {/each} + + {/each}
@@ -917,6 +1572,12 @@ data-collapsed-tabs={!$expandTabs}>
+
+
+ {#if $tableColumnSuggestions.thinking}
@@ -942,13 +1603,85 @@
{:else if customColumns.some((col) => !col.isPlaceholder) && showFloatingBar} + + {@const isUndoDeleteMode = columnBeingDeleted && columnBeingDeleted?.key !== null} + {@const columnName = isUndoDeleteMode ? columnBeingDeleted?.key : selectedColumnName} + {@const hasSelection = selectedColumnId !== null || isUndoDeleteMode} + + {#if !creatingColumns} +
+ + + + + + + {#if isUndoDeleteMode} + was deleted. You can undo this action. + {:else} + is selected + {/if} + + + + + + + {#if !isUndoDeleteMode} + (selectedColumnId = null)}> + Cancel + + + + + {/if} + !col.isPlaceholder).length <= 1} + on:click={() => { + if (isUndoDeleteMode) { + undoDelete(); + } else { + deleteColumn(selectedColumnId); + } + }}> + {#if isUndoDeleteMode} + Undo + {:else} + Delete + {/if} + + + + +
+ {/if} + +
+ class:creating-columns={creatingColumns} + class:has-selection={hasSelection}> - + {#if creatingColumns} {/if} @@ -958,49 +1691,212 @@ color="--fgcolor-neutral-secondary" style="white-space: nowrap"> {creatingColumns - ? 'Creating columns' + ? 'Creating columns...' : $isSmallViewport - ? 'Review and edit suggested columns' - : 'Review and edit suggested columns before applying'} + ? 'Click headers or cells to edit columns' + : 'Click headers or cells to edit columns before applying'} - - { - customColumns = []; - resetSuggestionsStore(); - }} - style="opacity: {creatingColumns ? '0' : '1'}" - >Dismiss - - Apply - - + {#if !creatingColumns} + + { + customColumns = []; + resetSuggestionsStore(); + }} + style="opacity: {creatingColumns ? '0' : '1'}" + >Dismiss + + Apply + + + {/if}
{/if}
+ + +{#snippet rowCellInteractiveButton({ interactable, column, row = null })} + +{/snippet} + +{#snippet changeColumnTypePopover({ id, columnObj, iconColor, icon, isColumnInteractable, index })} + +
+ { + if (isColumnInteractable && !$isTabletViewport) { + toggle(event); + resetSelectedColumn(); + } + }}> + {#if !columnObj?.isPlaceholder} + + {/if} + +
+ +
+ + + {#each basicColumnOptions as option} + { + toggle(); + updateColumn(id, { + type: option.type, + format: option.format || null + }); + }}> + + + {option.name} + + + {/each} + + +
+
+{/snippet} + +{#snippet edgeGradients(side: 'left' | 'right')} + + {@const gradientConfigs = [ + { pos: '20%', color: 'var(--border-pink)', spread: '25%', delay: '0s' }, + { pos: '50%', color: 'var(--border-orange)', spread: '15%', delay: '1s' }, + { pos: '80%', color: 'var(--border-pink)', spread: '25%', delay: '2s' }, + { pos: '35%', color: 'var(--border-pink)', spread: '40%', delay: '0.5s' }, + { pos: '65%', color: 'var(--border-orange)', spread: '40%', delay: '1.5s' } + ]} + {@const xPosition = side === 'left' ? '0%' : '100%'} + +
+ {#each gradientConfigs as grad} +
+
+ {/each} +
+{/snippet} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/icon/ai.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/icon/ai.svelte index 1d51db168..761f3a51c 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/icon/ai.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/icon/ai.svelte @@ -71,12 +71,15 @@ border: 1.25px solid rgba(253, 54, 110, 0.12); padding: 5px 0; + min-width: 40px; width: 40px !important; height: 40px !important; - } - :global(.ai-icon-holder.notification) { - width: 36px !important; - height: 32px !important; + & svg { + width: 30px; + height: 30px; + flex-shrink: 0; + aspect-ratio: 1/1; + } } diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/icon/aiForButton.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/icon/aiForButton.svelte new file mode 100644 index 000000000..df6de0516 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/icon/aiForButton.svelte @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/indexes.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/indexes.svelte index 2f33a1af6..85158b884 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/indexes.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/indexes.svelte @@ -8,7 +8,7 @@ mockSuggestions, type SuggestedIndexSchema } from './store'; - import { Modal, Confirm } from '$lib/components'; + import { Modal } from '$lib/components'; import SideSheet from '../table-[table]/layout/sidesheet.svelte'; import { isSmallViewport } from '$lib/stores/viewport'; import { IndexType, type Models } from '@appwrite.io/console'; @@ -32,7 +32,6 @@ let creatingIndexes = $state(false); let loadingSuggestions = $state(false); let indexes = $state([]); - let confirmDismiss = $state(false); let columnOptions: Array<{ value: string; label: string; @@ -195,7 +194,6 @@ function dismissIndexes() { indexes = []; - confirmDismiss = false; $showIndexesSuggestions = false; } @@ -354,13 +352,7 @@ text size="s" disabled={loadingSuggestions || creatingIndexes} - on:click={() => { - if (indexes.length > 0 && !creatingIndexes) { - confirmDismiss = true; - } else { - $showIndexesSuggestions = false; - } - }}>Cancel + on:click={() => dismissIndexes()}>Cancel {:else} - +
+ + + + + {headerTooltipText} + + +
{/if}
@@ -56,6 +80,10 @@ showSheet = false; } }}> + {#snippet footer()} + {@render mobileFooterChildren?.(() => (showSheet = false))} + {/snippet} + {@render tooltipChildren(() => (showSheet = false))} {/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/store.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/store.ts index f1792e391..c27e09eae 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/store.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/store.ts @@ -3,6 +3,7 @@ import { IndexType } from '@appwrite.io/console'; import { columnOptions } from '../table-[table]/columns/store'; export type TableColumnSuggestions = { + force: boolean; enabled: boolean; thinking: boolean; context?: string | undefined; @@ -18,11 +19,15 @@ export type SuggestedColumnSchema = { key: string; type: string; required: boolean; + array?: boolean; default?: string | number | boolean | number[] | number[][] | number[][][] | null; size?: number; min?: number; max?: number; format?: string | null; + encrypt?: boolean | null; + elements?: string[]; + isPlaceholder?: boolean; }; export enum IndexOrder { @@ -43,11 +48,14 @@ export const tableColumnSuggestions = writable({ enabled: false, context: null, thinking: false, - table: null + table: null, + force: false }); export const showIndexesSuggestions = writable(false); +export const showColumnsSuggestionsModal = writable(false); + export const mockSuggestions: { total: number; columns: ColumnInput[] } = { total: 7, columns: [ @@ -68,7 +76,7 @@ export const mockSuggestions: { total: number; columns: ColumnInput[] } = { formatOptions: null }, { - name: 'publishedYear', + name: 'year', type: 'integer', size: null, format: null, @@ -79,7 +87,7 @@ export const mockSuggestions: { total: number; columns: ColumnInput[] } = { } }, { - name: 'genre', + name: 'category', type: 'string', size: 64, format: null, @@ -88,7 +96,7 @@ export const mockSuggestions: { total: number; columns: ColumnInput[] } = { default: null }, { - name: 'isbn', + name: 'code', type: 'string', size: 13, required: false, @@ -96,7 +104,7 @@ export const mockSuggestions: { total: number; columns: ColumnInput[] } = { default: null }, { - name: 'language', + name: 'spokenLanguage', type: 'string', size: 32, format: null, @@ -105,7 +113,7 @@ export const mockSuggestions: { total: number; columns: ColumnInput[] } = { default: null }, { - name: 'pageCount', + name: 'count', type: 'integer', required: false, min: 1, @@ -123,9 +131,11 @@ export type ColumnInput = { min?: number; max?: number; format?: string; + elements?: string[]; formatOptions?: { min?: number; max?: number; + elements?: string[]; }; }; @@ -134,6 +144,7 @@ export function mapSuggestedColumns(columns: T[]): Sugges key: col.name, type: col.type, required: col.required ?? false, + array: false, default: col.default ?? null, size: col.type === 'string' ? (col.size ?? undefined) : undefined, min: @@ -144,7 +155,11 @@ export function mapSuggestedColumns(columns: T[]): Sugges col.type === 'integer' || col.type === 'double' ? (col.max ?? col.formatOptions?.max ?? undefined) : undefined, - format: col.format ?? null + format: col.format ?? null, + elements: + col.format === 'enum' + ? (col.elements ?? col.formatOptions?.elements ?? undefined) + : undefined })); } diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/containerHeader.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/containerHeader.svelte index d340d0f75..1ee5a6e2f 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/containerHeader.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/containerHeader.svelte @@ -5,8 +5,6 @@ import { Badge, Icon, Layout, Tag, Typography } from '@appwrite.io/pink-svelte'; import { goto } from '$app/navigation'; import { upgradeURL } from '$lib/stores/billing'; - import { BillingPlan } from '$lib/constants'; - import { organization } from '$lib/stores/organization'; export let isFlex = true; export let title: string; @@ -50,7 +48,7 @@ paddingBlock="var(--space-5, 12px)" paddingInline="var(--space-6, 16px)" resetListPadding> - {#if $organization?.billingPlan === BillingPlan.PRO} + {#if maxPolicies === 1} all.map((policy) => { policy.id = ID.unique(); @@ -176,7 +175,7 @@
- {#if $organization.billingPlan === BillingPlan.SCALE} + {#if $currentPlan?.backupPolicies > 1} {#if title || subtitle}
{#if title} @@ -195,7 +194,7 @@ {/if} - {#if $organization.billingPlan === BillingPlan.PRO} + {#if $currentPlan?.backupPolicies === 1} {@const dailyPolicy = $presetPolicies[1]} {#if isFromBackupsTab} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.svelte index 07f9bd781..bc4f7f186 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.svelte @@ -74,6 +74,8 @@ import { isTabletViewport } from '$lib/stores/viewport'; import IndexesSuggestions from '../(suggestions)/indexes.svelte'; + import ColumnsSuggestions from '../(suggestions)/columns.svelte'; + import { showColumnsSuggestionsModal } from '../(suggestions)'; let editRow: EditRow; let editRelatedRow: EditRelatedRow; @@ -608,6 +610,8 @@ + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/export/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/export/+page.svelte new file mode 100644 index 000000000..deaaeb57b --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/export/+page.svelte @@ -0,0 +1,248 @@ + + + +
+ +
+ + + + + + + + + + + {#each visibleColumns as column (column.key)} +
+ +
+ {/each} +
+ + {#if hasMoreColumns} +
+ +
+ {/if} +
+
+ +
+ + + + + + + Define how to separate values in the exported file. + + + + + + + + +
+ +
+ + {#if localTags.length > 0} + + { + removeLocalFilter(e.detail); + }} /> + + {/if} +
+
+
+
+
+ + + + + + +
+ + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/+page.svelte index 134a18b30..9f2313ae1 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/+page.svelte @@ -23,6 +23,7 @@ Typography } from '@appwrite.io/pink-svelte'; import { + IconBookOpen, IconDotsHorizontal, IconEye, IconPlus, @@ -37,6 +38,10 @@ import { showCreateColumnSheet } from '../store'; import { isSmallViewport } from '$lib/stores/viewport'; import { page } from '$app/state'; + import { showIndexesSuggestions, showColumnsSuggestionsModal } from '../../(suggestions)'; + import IconAI from '../../(suggestions)/icon/aiForButton.svelte'; + import EmptySheetCards from '../layout/emptySheetCards.svelte'; + import { isCloud } from '$lib/system'; import { realtime } from '$lib/stores/sdk'; import { invalidate } from '$app/navigation'; import { Dependencies } from '$lib/constants'; @@ -66,14 +71,14 @@ const spreadsheetColumns = $derived([ { id: 'key', - width: getColumnWidth('key', $isSmallViewport ? 250 : 200), - minimumWidth: $isSmallViewport ? 250 : 200, + width: getColumnWidth('key', 250), + minimumWidth: 250, resizable: true }, { id: 'type', - width: getColumnWidth('type', 120), - minimumWidth: 120, + width: getColumnWidth('type', 200), + minimumWidth: 200, resizable: true }, { @@ -296,27 +301,100 @@ {:else} - (showCreateIndex = true), - disabled: !$table?.columns?.length - } - }} /> + + {#snippet subtitle()} + {#if isCloud} + + Need a hand? Learn more in the + + docs. + + + {/if} + {/snippet} + + {#snippet actions()} + {#if isCloud} + { + showIndexesSuggestions.update(() => true); + }} /> + {/if} + + { + showCreateIndex = true; + }} /> + + {#if !isCloud} + + {/if} + {/snippet} + {/if} {:else} - { - $showCreateColumnSheet.show = true; - } - } - }} /> + + {#snippet subtitle()} + {#if isCloud} + + Need a hand? Learn more in the + + docs. + + + {/if} + {/snippet} + + {#snippet actions()} + {#if isCloud} + { + $showColumnsSuggestionsModal = true; + }} /> + + { + $showCreateColumnSheet.show = true; + }} /> + {:else} + { + $showCreateColumnSheet.show = true; + }} /> + + + {/if} + {/snippet} + {/if} {#if selectedIndexes.length > 0} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/createIndex.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/createIndex.svelte index 88af9c257..f706ab739 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/createIndex.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/createIndex.svelte @@ -71,7 +71,8 @@ // spatial type selected -> reset column list to single empty column // and the column already is not spatial type $effect(() => { - if (selectedType === IndexType.Spatial && !columnList.at(0).value) { + const firstColumn = $table.columns.find((col) => col.key === columnList.at(0)?.value); + if (selectedType === IndexType.Spatial && firstColumn && !isSpatialType(firstColumn)) { columnList = [{ value: '', order: null, length: null }]; } }); diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/emptySheet.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/emptySheet.svelte index 8141cf0a7..c77dd08d8 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/emptySheet.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/emptySheet.svelte @@ -19,43 +19,57 @@ expandTabs } from '../store'; import SpreadsheetContainer from './spreadsheet.svelte'; - import { onDestroy, onMount } from 'svelte'; + import { onDestroy, onMount, type Snippet } from 'svelte'; import { debounce } from '$lib/helpers/debounce'; import { columnOptions } from '../columns/store'; type Mode = 'rows' | 'rows-filtered' | 'indexes'; - interface Action { - text?: string; - disabled?: boolean; - onClick?: () => void; - } - const { mode, - showActions = true, customColumns = [], title, - actions + subtitle, + actions, + showActions } = $props<{ mode: Mode; - showActions?: boolean; customColumns?: Column[]; title?: string; - actions?: { - primary?: Action; - random?: Action; - }; + subtitle?: Snippet; + actions?: Snippet; + showActions?: boolean; }>(); let spreadsheetContainer: HTMLElement; let headerElement: HTMLElement | null = null; let resizeObserver: ResizeObserver; + let overlayOffsetHandler: ResizeObserver; + + let overlayLeftOffset = $state('0px'); + let overlayTopOffset = $state('auto'); let dynamicOverlayHeight = $state('60.5vh'); const baseColProps = { draggable: false, resizable: false }; + const updateOverlayLeftOffset = () => { + if (spreadsheetContainer) { + const containerRect = spreadsheetContainer.getBoundingClientRect(); + overlayLeftOffset = `${containerRect.left}px`; + } + + // calculate vertical top position + if (!headerElement || !headerElement.isConnected) { + headerElement = spreadsheetContainer?.querySelector('[role="rowheader"]'); + } + + if (headerElement) { + const headerRect = headerElement.getBoundingClientRect(); + overlayTopOffset = `${headerRect.bottom}px`; + } + }; + const updateOverlayHeight = () => { if (!spreadsheetContainer) return; @@ -82,6 +96,9 @@ if (spreadsheetContainer) { resizeObserver = new ResizeObserver(debouncedUpdateOverlayHeight); resizeObserver.observe(spreadsheetContainer); + + overlayOffsetHandler = new ResizeObserver(updateOverlayLeftOffset); + overlayOffsetHandler.observe(spreadsheetContainer); } }); @@ -89,74 +106,141 @@ if (resizeObserver) { resizeObserver.disconnect(); } + + if (overlayOffsetHandler) { + overlayOffsetHandler.disconnect(); + } }); const getCustomColumns = (): Column[] => customColumns.map((col: Column) => ({ ...col, - width: 180, hide: false, icon: columnOptions.find((colOpt) => colOpt.type === col?.type)?.icon, ...baseColProps })); - const getRowColumns = (): Column[] => [ - { - id: '$id', - title: '$id', - type: 'string', - width: 180, - icon: IconFingerPrint, - ...baseColProps - }, - ...getCustomColumns(), - { - id: '$createdAt', - title: '$createdAt', - type: 'datetime', - width: 180, - icon: IconCalendar, - ...baseColProps - }, - { - id: '$updatedAt', - title: '$updatedAt', - type: 'datetime', - width: 180, - icon: IconCalendar, - ...baseColProps - }, - { - id: 'actions', - title: '', - type: 'string', - icon: IconPlus, - width: customColumns.length ? 555 : 832, - ...baseColProps - }, - { - id: 'empty', - title: '', - type: 'string', - ...baseColProps - } - ]; + const getRowColumns = (): Column[] => { + const minColumnWidth = 180; + const fixedWidths = { id: 180, actions: 40 }; + const hasCustomColumns = customColumns.length > 0; - const getIndexesColumns = (): Column[] => - [ + const customColumnsData = getCustomColumns(); + + // Calculate column widths based on whether we have custom columns + let columnWidths = { + id: fixedWidths.id, + createdAt: fixedWidths.id, + updatedAt: fixedWidths.id, + custom: minColumnWidth, + actions: hasCustomColumns ? fixedWidths.actions : 1387 + }; + + if (hasCustomColumns) { + const equalWidthColumns = [ + ...customColumnsData, + { id: '$createdAt' }, + { id: '$updatedAt' } + ]; + + const totalBaseWidth = + fixedWidths.id + fixedWidths.actions + equalWidthColumns.length * minColumnWidth; + + const viewportWidth = + spreadsheetContainer?.clientWidth || + (typeof window !== 'undefined' ? window.innerWidth : totalBaseWidth); + + const excessSpace = Math.max(0, viewportWidth - totalBaseWidth); + const extraPerColumn = + equalWidthColumns.length > 0 ? excessSpace / equalWidthColumns.length : 0; + const distributedWidth = minColumnWidth + extraPerColumn; + + columnWidths.createdAt = distributedWidth; + columnWidths.updatedAt = distributedWidth; + columnWidths.custom = distributedWidth; + } + + const columns: Column[] = [ + { + id: '$id', + title: '$id', + type: 'string', + width: columnWidths.id, + icon: IconFingerPrint, + ...baseColProps + } + ]; + + if (hasCustomColumns) { + columns.push( + ...customColumnsData.map((col) => ({ + ...col, + width: columnWidths.custom + })) + ); + } + + columns.push( + { + id: '$createdAt', + title: '$createdAt', + type: 'datetime', + width: columnWidths.createdAt, + icon: IconCalendar, + ...baseColProps + }, + { + id: '$updatedAt', + title: '$updatedAt', + type: 'datetime', + width: columnWidths.updatedAt, + icon: IconCalendar, + ...baseColProps + }, + { + id: 'actions', + title: '', + type: 'string', + icon: IconPlus, + isAction: hasCustomColumns, + width: columnWidths.actions, + ...baseColProps + } + ); + + if (!hasCustomColumns) { + columns.push({ + id: 'empty', + title: '', + type: 'string', + ...baseColProps + }); + } + + return columns; + }; + + const getIndexesColumns = (): Column[] => { + const columns = [ { id: 'key', title: 'Key', icon: null, isPrimary: false }, { id: 'type', title: 'Type', icon: null, isPrimary: false }, - { id: 'columns', title: 'Columns', icon: null, isPrimary: false }, - { + { id: 'columns', title: 'Columns', icon: null, isPrimary: false } + ] as Column[]; + + if (!$isSmallViewport) { + columns.push({ id: 'empty', title: '', width: 40, isAction: true, isPrimary: false - } - ] as Column[]; + } as Column); + } - const spreadsheetColumns = $derived(mode === 'rows' ? getRowColumns() : getIndexesColumns()); + return columns; + }; + + const spreadsheetColumns = $derived(mode === 'indexes' ? getIndexesColumns() : getRowColumns()); const emptyCells = $derived( ($isSmallViewport ? 14 : $isTabletViewport ? 17 : 24) + (!$expandTabs ? 2 : 0) @@ -164,9 +248,11 @@
+ bind:this={spreadsheetContainer} + class:custom-columns={customColumns.length > 0} + class:no-custom-columns={customColumns.length <= 0} + class="databases-spreadsheet spreadsheet-container-outer"> {#each spreadsheetColumns as column (column.id)} - {@const columnActionsById = column.id === 'actions'} - -
{ - if (columnActionsById && mode === 'rows') { - $showCreateColumnSheet.show = true; - $showCreateColumnSheet.title = 'Create column'; - $showCreateColumnSheet.columns = $tableColumns; - $showCreateColumnSheet.columnsOrder = $columnsOrder; - } - }}> + {#if column.isAction} + + { + if (mode === 'rows') { + $showCreateColumnSheet.show = true; + $showCreateColumnSheet.title = 'Create column'; + $showCreateColumnSheet.columns = $tableColumns; + $showCreateColumnSheet.columnsOrder = $columnsOrder; + } + }}> + + + + {:else} {/if} -
+ {/if} {/each}
@@ -236,48 +325,36 @@ {#if !$spreadsheetLoading}
0} data-collapsed-tabs={!$expandTabs} + style:--overlay-left={overlayLeftOffset} + style:--overlay-top={overlayTopOffset} style:--dynamic-overlay-height={dynamicOverlayHeight}>
- - {title ?? `You have no ${mode} yet`} + + + {title ?? `You have no ${mode} yet`} - {#if showActions} - - {#if mode !== 'rows-filtered'} - - - {actions?.primary?.text ?? `Create ${mode}`} - + {@render subtitle?.()} + - {#if mode === 'rows'} - - {actions?.random?.text ?? `Generate sample data`} - + {#if showActions && actions} + {@const inline = mode === 'rows-filtered'} +
+ + {#if inline} + {@render actions?.()} + {:else} + + {@render actions?.()} + {/if} - {:else} - - {actions?.primary?.text} - - {/if} - + +
{/if}
@@ -291,13 +368,59 @@ position: fixed; overflow: hidden; + & :global(.spreadsheet-container) { + overflow-x: auto; + overflow-y: auto; + } + + & :global([data-select='true']) { + opacity: 0.85; + pointer-events: none; + } + + &.custom-columns { + width: unset; + } + + &.no-custom-columns { + @media (max-width: 768px) { + & :global(.spreadsheet-wrapper) { + opacity: 0; + } + + & > .spreadsheet-fade-bottom { + top: var(--top-actions-spacing) !important; + background: var(--bgcolor-neutral-primary) !important; + } + } + } + + &:not(.custom-columns) :global(.spreadsheet-container) { + overflow-x: hidden; + overflow-y: hidden; + } + + /* alternative selector for header selection */ + & :global(.sticky-header [data-select='true']) { + opacity: 1; + pointer-events: none; + } + &[data-mode='rows'] { + --top-actions-spacing: 50%; + & :global([role='rowheader'] :nth-last-child(2) [role='presentation']) { display: none; } } &[data-mode='indexes'] { + --top-actions-spacing: 40%; + + & :global([role='cell']:last-child [role='presentation']) { + display: none; + } + & :global([role='rowheader'] [role='cell']:nth-last-child(1)) { pointer-events: none; @@ -306,22 +429,14 @@ } } } - - & :global(.spreadsheet-container) { - overflow-x: hidden; - overflow-y: hidden; - } - - & :global([data-select='true']) { - opacity: 0.85; - pointer-events: none; - } } .spreadsheet-fade-bottom { + right: 0; bottom: 0; - width: 100%; position: fixed; + top: var(--overlay-top, auto); + left: var(--overlay-left, 0px); background: linear-gradient( 180deg, rgba(255, 255, 255, 0) 0%, @@ -330,17 +445,21 @@ ); z-index: 20; display: flex; + align-items: center; justify-content: center; transition: none !important; - height: var(--dynamic-overlay-height, 70.5vh); - - @media (max-width: 1024px) { - height: var(--dynamic-overlay-height, 63.35vh); + &.custom-columns { + pointer-events: none; } + } - @media (min-width: 1024px) { - height: var(--dynamic-overlay-height, 70.35vh); + .controlled-width { + width: 100%; + + @media (min-width: 1440px) { + width: 538px; + max-width: 538px; } } @@ -354,36 +473,19 @@ } .empty-actions { - left: 50%; - bottom: 35%; - position: fixed; - - @media (max-width: 768px) and (max-height: 768px) { - left: unset; - bottom: 12.5% !important; - } - - @media (max-width: 768px) and (max-height: 1024px) { - left: unset; - bottom: 15% !important; - } - - @media (max-width: 1024px) and (max-height: 1024px) { - left: unset; - bottom: 15%; - } + margin-bottom: 10%; + pointer-events: auto; @media (max-width: 1024px) { - left: unset; - bottom: 30%; + // experiment + margin-bottom: 15%; } + } - @media (min-width: 1280px) { - bottom: 37.5%; - } - - @media (min-width: 1440px) { - bottom: 40%; + @media (max-width: 768px) { + // global but controlled properly! + :global(main:has(.no-custom-columns) .console-container) { + opacity: 0; } } diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/emptySheetCards.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/emptySheetCards.svelte new file mode 100644 index 000000000..f38edeb62 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/emptySheetCards.svelte @@ -0,0 +1,48 @@ + + + onClick?.()}> + + {#if icon} + + {/if} + + + + {title} + + {#if subtitle} + + {subtitle} + + {/if} + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/spreadsheet.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/spreadsheet.svelte index 5e3428bd4..ff67ac227 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/spreadsheet.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/spreadsheet.svelte @@ -898,6 +898,7 @@ id={row?.$id} virtualItem={item} select={rowSelection} + hoverEffect showSelectOnHover valueWithoutHover={row.$sequence}> {#each $tableColumns as { id: columnId, isEditable } (columnId)} @@ -1159,7 +1160,8 @@ gap="xs" direction="row" alignItems="center" - alignContent="center"> + alignContent="center" + class="footer-input-select-wrapper"> Page const parsedQueries = queryParamToMap(query || '[]'); queries.set(parsedQueries); + let activeDeployment: Models.Deployment | null = null; + if (data.function.deploymentId) { + try { + activeDeployment = await sdk + .forProject(params.region, params.project) + .functions.getDeployment({ + functionId: params.function, + deploymentId: data.function.deploymentId + }); + } catch (error) { + // active deployment with the requested ID could not be found + activeDeployment = null; + } + } + return { offset, limit, query, installations: data.installations, - activeDeployment: data.function.deploymentId - ? await sdk.forProject(params.region, params.project).functions.getDeployment({ - functionId: params.function, - deploymentId: data.function.deploymentId - }) - : null, + activeDeployment, deploymentList: await sdk .forProject(params.region, params.project) .functions.listDeployments({ diff --git a/src/routes/(console)/project-[region]-[project]/overview/(components)/create.svelte b/src/routes/(console)/project-[region]-[project]/overview/(components)/create.svelte index 84e6a7809..4c9bb8150 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/(components)/create.svelte +++ b/src/routes/(console)/project-[region]-[project]/overview/(components)/create.svelte @@ -24,8 +24,8 @@ let isSubmitting = writable(false); let scopes: string[] = []; - let name = '', - expire = ''; + let name = ''; + let expire: string | null = null; async function create() { try { diff --git a/src/routes/(console)/project-[region]-[project]/overview/components/CursorIconLarge.svelte b/src/routes/(console)/project-[region]-[project]/overview/components/CursorIconLarge.svelte new file mode 100644 index 000000000..80ef26af0 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/overview/components/CursorIconLarge.svelte @@ -0,0 +1,7 @@ + + +Cursor diff --git a/src/routes/(console)/project-[region]-[project]/overview/platforms/createAndroid.svelte b/src/routes/(console)/project-[region]-[project]/overview/platforms/createAndroid.svelte index 9ad7963e1..3abd55aa9 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/platforms/createAndroid.svelte +++ b/src/routes/(console)/project-[region]-[project]/overview/platforms/createAndroid.svelte @@ -17,7 +17,7 @@ import { Card } from '$lib/components'; import { page } from '$app/state'; import { onMount } from 'svelte'; - import { realtime, sdk } from '$lib/stores/sdk'; + import { getApiEndpoint, 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'; @@ -26,6 +26,7 @@ import { PlatformType } from '@appwrite.io/console'; import { project } from '../../store'; import { getCorrectTitle, type PlatformProps } from './store'; + import LlmBanner from './llmBanner.svelte'; let { isConnectPlatform = false }: PlatformProps = $props(); @@ -35,6 +36,60 @@ let isPlatformCreated = $state(isConnectPlatform); const projectId = page.params.project; + const VERSIONS_ENDPOINT = (() => { + const endpoint = getApiEndpoint(page.params.region); + const url = new URL('/versions', endpoint); + return url.toString(); + })(); + let androidSdkVersion = $state('11.3.0'); + + function buildAndroidInstructions(version: string) { + return ` +Confirm you're working inside the correct Android project before editing anything: +- Navigate into the directory that contains the real Android app module (look for gradlew, settings.gradle, and the app-level build.gradle(.kts)). +- If Cursor opens in a parent folder (like your home directory) or you see multiple Android projects, ask which one to modify before making changes. +- Update the app-level build.gradle.kts by default, but be ready to edit a Groovy build.gradle if the project hasn't migrated to Kotlin DSL yet. + +Prefer Version Catalogs when adding the Appwrite SDK: +1. If ./gradle/libs.versions.toml exists, add or reuse an Appwrite entry: +\`\`\`toml +[libraries] +appwrite = { module = "io.appwrite:sdk-for-android", version = "${version}" } +\`\`\` +2. Reference it inside the module's dependencies block: +\`\`\`kotlin +dependencies { + implementation(libs.appwrite) +} +\`\`\` +Only when the project lacks ./gradle/libs.versions.toml should you hardcode the dependency: +\`\`\`kotlin +implementation("io.appwrite:sdk-for-android:${version}") +\`\`\` +Legacy Groovy scripts should use: +\`\`\`groovy +implementation "io.appwrite:sdk-for-android:${version}" +\`\`\` + +Before introducing any new files, search the project (app/src, libs/, shared modules, etc.) for existing Appwrite client helpers (look for \`Client(\`, \`AppwriteClient\`, or \`.setEndpoint\`). If a client already exists, update its configuration instead of creating a duplicate. + +Ensure the Appwrite client is initialized with the application context and current project info: +\`\`\`kotlin +val client = Client(applicationContext) + .setEndpoint("${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}") + .setProject("${projectId}") + +val account = Account(client) +\`\`\` + +From the app's entry point (e.g., Application class or the first launched Activity), automatically invoke a helper that pings Appwrite so the user can verify connectivity and will be reflected on the Appwrite console: +\`\`\`kotlin +client.ping() +\`\`\` +`; + } + + const alreadyExistsInstructions = $derived(buildAndroidInstructions(androidSdkVersion)); const gitCloneCode = '\ngit clone https://github.com/appwrite/starter-for-android\ncd starter-for-android\n'; @@ -43,6 +98,22 @@ const val APPWRITE_PROJECT_NAME = "${$project.name}" const val APPWRITE_PUBLIC_ENDPOINT = "${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}"`; + async function fetchAndroidSdkVersion() { + try { + const response = await fetch(VERSIONS_ENDPOINT); + if (!response.ok) { + throw new Error(`Failed to fetch versions: ${response.status}`); + } + const data = await response.json(); + const latestVersion = data?.['client-android']; + if (typeof latestVersion === 'string' && latestVersion.trim()) { + androidSdkVersion = latestVersion.trim(); + } + } catch (error) { + console.error('Unable to fetch latest Android SDK version', error); + } + } + async function createAndroidPlatform() { try { isCreatingPlatform = true; @@ -83,6 +154,7 @@ const val APPWRITE_PUBLIC_ENDPOINT = "${sdk.forProject(page.params.region, page. } onMount(() => { + fetchAndroidSdkVersion(); const unsubscribe = realtime.forConsole(page.params.region, 'console', (response) => { if (response.events.includes(`projects.${projectId}.ping`)) { connectionSuccessful = true; @@ -171,6 +243,12 @@ const val APPWRITE_PUBLIC_ENDPOINT = "${sdk.forProject(page.params.region, page. {#if isPlatformCreated}
+ + 1. If you're starting a new project, you can clone our starter kit from GitHub using the terminal, VSCode or Android Studio. diff --git a/src/routes/(console)/project-[region]-[project]/overview/platforms/createApple.svelte b/src/routes/(console)/project-[region]-[project]/overview/platforms/createApple.svelte index 7c1ae8ee6..2a156b76d 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/platforms/createApple.svelte +++ b/src/routes/(console)/project-[region]-[project]/overview/platforms/createApple.svelte @@ -28,6 +28,7 @@ import { app } from '$lib/stores/app'; import { project } from '../../store'; import { getCorrectTitle, type PlatformProps } from './store'; + import LlmBanner from './llmBanner.svelte'; let { isConnectPlatform = false, platform = PlatformType.Appleios }: PlatformProps = $props(); @@ -38,6 +39,30 @@ const projectId = page.params.project; + const alreadyExistsInstructions = ` +Install the Appwrite iOS SDK using the following package URL: + +\`\`\` +https://github.com/appwrite/sdk-for-apple +\`\`\` + +From a suitable lib directory, export the Appwrite client as a global variable: + +\`\`\` +let client = Client() + .setEndpoint("${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}") + .setProject("${projectId}") + +let account = Account(client) +\`\`\` + +On the homepage of the app, create a button that says "Send a ping" and when clicked, it should call the following function: + +\`\`\` +client.ping() +\`\`\` +`; + const gitCloneCode = '\ngit clone https://github.com/appwrite/starter-for-ios\ncd starter-for-ios\n'; @@ -45,7 +70,7 @@ APPWRITE_PROJECT_NAME: "${$project.name}" APPWRITE_PUBLIC_ENDPOINT: "${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}"`; - let platforms: { [key: string]: PlatformType } = { + const platforms: { [key: string]: PlatformType } = { iOS: PlatformType.Appleios, macOS: PlatformType.Applemacos, watchOS: PlatformType.Applewatchos, @@ -199,6 +224,12 @@ APPWRITE_PUBLIC_ENDPOINT: "${sdk.forProject(page.params.region, page.params.proj {#if isPlatformCreated}
+ + 1. If you're starting a new project, you can clone our starter kit from GitHub using the terminal or XCode. diff --git a/src/routes/(console)/project-[region]-[project]/overview/platforms/createFlutter.svelte b/src/routes/(console)/project-[region]-[project]/overview/platforms/createFlutter.svelte index 65f7b732c..6c40f4489 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/platforms/createFlutter.svelte +++ b/src/routes/(console)/project-[region]-[project]/overview/platforms/createFlutter.svelte @@ -18,7 +18,7 @@ import { Card } from '$lib/components'; import { page } from '$app/state'; import { onMount } from 'svelte'; - import { realtime, sdk } from '$lib/stores/sdk'; + import { getApiEndpoint, 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'; @@ -27,6 +27,7 @@ import { PlatformType } from '@appwrite.io/console'; import { project } from '../../store'; import { getCorrectTitle, type PlatformProps } from './store'; + import LlmBanner from './llmBanner.svelte'; let { isConnectPlatform = false, platform = PlatformType.Flutterandroid }: PlatformProps = $props(); @@ -37,6 +38,38 @@ let isPlatformCreated = $state(isConnectPlatform); const projectId = page.params.project; + const VERSIONS_ENDPOINT = (() => { + const endpoint = getApiEndpoint(page.params.region); + const url = new URL('/versions', endpoint); + return url.toString(); + })(); + let flutterSdkVersion = $state('20.3.0'); + + function buildFlutterInstructions(version: string) { + return ` +Install the Appwrite Flutter SDK using the following command: + +\`\`\` +flutter pub add appwrite:${version} +\`\`\` + +From a suitable lib directory, export the Appwrite client as a global variable, hardcode the project details too: + +\`\`\` +final Client client = Client() + .setProject("${projectId}") + .setEndpoint("${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}"); +\`\`\` + +On the homepage of the app, create a button that says "Send a ping" and when clicked, it should call the following function: + +\`\`\` +client.ping(); +\`\`\` + `; + } + + const alreadyExistsInstructions = $derived(buildFlutterInstructions(flutterSdkVersion)); const gitCloneCode = '\ngit clone https://github.com/appwrite/starter-for-flutter\ncd starter-for-flutter\n'; @@ -111,6 +144,22 @@ [PlatformType.Flutterwindows]: 'Package name' }; + async function fetchFlutterSdkVersion() { + try { + const response = await fetch(VERSIONS_ENDPOINT); + if (!response.ok) { + throw new Error(`Failed to fetch versions: ${response.status}`); + } + const data = await response.json(); + const latestVersion = data?.['client-flutter']; + if (typeof latestVersion === 'string' && latestVersion.trim()) { + flutterSdkVersion = latestVersion.trim(); + } + } catch (error) { + console.error('Unable to fetch latest Flutter SDK version', error); + } + } + async function createFlutterPlatform() { try { isCreatingPlatform = true; @@ -158,6 +207,7 @@ } onMount(() => { + fetchFlutterSdkVersion(); const unsubscribe = realtime.forConsole(page.params.region, 'console', (response) => { if (response.events.includes(`projects.${projectId}.ping`)) { connectionSuccessful = true; @@ -281,6 +331,11 @@ {#if isPlatformCreated}
+ 1. If you're starting a new project, you can clone our starter kit from GitHub using the terminal, VSCode or Android Studio. diff --git a/src/routes/(console)/project-[region]-[project]/overview/platforms/createReactNative.svelte b/src/routes/(console)/project-[region]-[project]/overview/platforms/createReactNative.svelte index f8c8f17c0..0a55a52a9 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/platforms/createReactNative.svelte +++ b/src/routes/(console)/project-[region]-[project]/overview/platforms/createReactNative.svelte @@ -27,6 +27,7 @@ import { PlatformType } from '@appwrite.io/console'; import { project } from '../../store'; import { getCorrectTitle, type PlatformProps } from './store'; + import LlmBanner from './llmBanner.svelte'; let { isConnectPlatform = false, platform = PlatformType.Reactnativeandroid }: PlatformProps = $props(); @@ -38,6 +39,28 @@ const projectId = page.params.project; + const alreadyExistsInstructions = ` +Install the Appwrite React Native SDK using the following command, respect user's package manager of choice and use the one being used in the codebase: + +\`\`\` +npx expo install react-native-appwrite react-native-url-polyfill +\`\`\` + +From a suitable lib directory, export the Appwrite client as a global variable, hardcode the project details too: + +\`\`\` +const client = new Client() + .setProject("${projectId}") + .setEndpoint("${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}"); +\`\`\` + +From the entrypoint of the app, make it so that the following function is automatically called which will ping the Appwrite backend server to verify the setup. Let the user know about this function being added + +\`\`\` +client.ping(); +\`\`\` + `; + const gitCloneCode = '\ngit clone https://github.com/appwrite/starter-for-react-native\ncd starter-for-react-native\n'; @@ -45,6 +68,12 @@ EXPO_PUBLIC_APPWRITE_PROJECT_NAME="${$project.name}" EXPO_PUBLIC_APPWRITE_ENDPOINT=${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}`; + const promptConfigCode = ` + const client = new Client() + .setProject("${projectId}") + .setEndpoint("${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}") + `; + let platforms: { [key: string]: PlatformType } = { Android: PlatformType.Reactnativeandroid, iOS: PlatformType.Reactnativeios @@ -225,6 +254,12 @@ EXPO_PUBLIC_APPWRITE_ENDPOINT=${sdk.forProject(page.params.region, page.params.p {#if isPlatformCreated}
+ + 1. If you're starting a new project, you can clone our starter kit from GitHub using the terminal or VSCode. diff --git a/src/routes/(console)/project-[region]-[project]/overview/platforms/createWeb.svelte b/src/routes/(console)/project-[region]-[project]/overview/platforms/createWeb.svelte index e4a98a6a9..4a708d32b 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/platforms/createWeb.svelte +++ b/src/routes/(console)/project-[region]-[project]/overview/platforms/createWeb.svelte @@ -48,7 +48,13 @@ } from './components/index'; import { extendedHostnameRegex } from '$lib/helpers/string'; import { project } from '../../store'; - import { type PlatformProps, type FrameworkType, getCorrectTitle } from './store'; + import { + type PlatformProps, + type FrameworkType, + type LLMPromptConfig, + getCorrectTitle + } from './store'; + import LlmBanner from './llmBanner.svelte'; let { key, isConnectPlatform = false, platform = PlatformType.Web }: PlatformProps = $props(); @@ -157,6 +163,57 @@ ${prefix}APPWRITE_ENDPOINT = "${sdk.forProject(page.params.region, page.params.p selectedFramework ? selectedFramework.icon : NoFrameworkIcon ); + const llmConfig: LLMPromptConfig = $derived({ + alreadyExistsInstructions: ` +Install the Appwrite web SDK using the following command. Respect the user's package manager of choice. Do not use NPM if the user uses Bun for example. + +\`\`\`bash +npm install appwrite +\`\`\` + +Create a new \`appwrite.js\` (or equivalent, respecting the framework and language, don't create a JS file if TS is being used in the project) file in a suitable lib directory and have the following code: + +\`\`\`js +import { Client, Account, Databases } from "appwrite"; + +const client = new Client() + .setEndpoint("${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}") + .setProject("${projectId}"); + +const account = new Account(client); +const databases = new Databases(client); + +export { client, account, databases }; +\`\`\` + +When the app is opened, make it so that the following function is automatically called which will ping the Appwrite backend server to verify the setup. Let the user know about this function being added + +\`\`\`js +client.ping(); +\`\`\` +`, + title: `Copy prompt: starter kit for Appwrite in ${selectedFramework?.label || 'Web'}`, + cloneCommand: `git clone https://github.com/appwrite/starter-for-${selectedFramework?.key}\ncd starter-for-${selectedFramework?.key}`, + configFile: + selectedFramework?.key === 'angular' + ? 'src/environments/environment.ts' + : 'appwrite.js', + configCode: + // selectedFramework?.key === 'angular' + // ? `APPWRITE_PROJECT_ID=${projectId}\nAPPWRITE_PROJECT_NAME=${$project.name}\nAPPWRITE_ENDPOINT=${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}` + // : ` + // const client = new Client() + // .setEndpoint("${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}") + // .setProject("${projectId}"); + // `, + `APPWRITE_PROJECT_ID = "${projectId}" +APPWRITE_PROJECT_NAME = "${$project.name}" +APPWRITE_ENDPOINT = "${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}"`, + configLanguage: selectedFramework?.key === 'angular' ? 'ts' : 'dotenv', + runInstructions: `Install project dependencies using \`npm install\`, then run the app using \`${selectedFramework?.runCommand}\`. Demo app runs on http://localhost:${selectedFramework?.portNumber}. Click the \`Send a ping\` button to verify the setup.`, + using: 'the terminal or VSCode' + }); + async function createWebPlatform() { const hostnameRegex = new RegExp(extendedHostnameRegex); const finalHostname = hostname?.trim() || 'localhost'; @@ -301,6 +358,8 @@ ${prefix}APPWRITE_ENDPOINT = "${sdk.forProject(page.params.region, page.params.p {#if isPlatformCreated && !isChangingFramework}
+ + 1. If you're starting a new project, you can clone our starter kit from GitHub using the terminal or VSCode. diff --git a/src/routes/(console)/project-[region]-[project]/overview/platforms/llmBanner.svelte b/src/routes/(console)/project-[region]-[project]/overview/platforms/llmBanner.svelte new file mode 100644 index 000000000..08c28c9ac --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/overview/platforms/llmBanner.svelte @@ -0,0 +1,188 @@ + + +{#if showAlert} + (showAlert = false)}> + + + + + + + Copy the prompt or open it directly in an AI tool like Cursor or Lovable to get + step-by-step instructions, starter code, and SDK commands for your project. + + + + + + + {#each validOpeners as openerId} + {@const o = openersConfig[openerId]} + {#if o} + { + window.open( + o.href(prompt), + '_blank', + 'noopener,noreferrer' + ); + toggle(e); + }}> + + + {#if o.icon} + + {:else if o.imgSrc} + {o.alt} + {/if} + + + {o.label} + + {o.description} + + + + + {/if} + {/each} + + + + + {#if validOpeners.length} + + {/if} + + + + +{/if} diff --git a/src/routes/(console)/project-[region]-[project]/overview/platforms/store.ts b/src/routes/(console)/project-[region]-[project]/overview/platforms/store.ts index d0b795643..f5241c806 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/platforms/store.ts +++ b/src/routes/(console)/project-[region]-[project]/overview/platforms/store.ts @@ -17,6 +17,119 @@ export type FrameworkType = { updateConfigCode: string; }; +export type LLMPromptConfig = { + title: string; + alreadyExistsInstructions: string; + cloneCommand: string; + configFile: string; + configCode: string; + configLanguage: string; + runInstructions: string; + using: string; +}; + export function getCorrectTitle(isConnectPlatform: boolean, platform: string) { return isConnectPlatform ? `Connect your ${platform} app` : `Add ${platform} platform`; } + +export function generatePromptFromConfig(config: LLMPromptConfig): string { + return ` +Goal: Setting up Appwrite SDK in the project depending on if a project already exists or not. + +Following are the project details: + +\`\`\` +${config.configCode} +\`\`\` + +Follow the steps depending on if a project already exists on user's working directory or not: + +## If a project already exists: +${config.alreadyExistsInstructions} + +## If a project does not exist: + +1. Clone the starter kit using ${config.using || 'the terminal'}. Make sure to clone in the current working directory so that the cloned files are directly available in the working directory. + +\`\`\`bash +${config.cloneCommand} . +\`\`\` + +2. Replace all occurrences of the environment variables described in the project details section with their corresponding values. This effectively hardcodes the project details wherever those environment variables are used. Use grep (or an equivalent search) to find and update all occurrences. +3. ${config.runInstructions}`; +} + +type PlatformConfig = { + name: string; + title: string; + repoName: string; + configFile: string; + configLanguage: string; + runInstructions: string; + using: string; +}; + +const platformConfigs: Record = { + android: { + name: 'Kotlin', + title: 'Copy prompt: starter kit for Appwrite in Kotlin', + repoName: 'starter-for-android', + configFile: 'constants/AppwriteConfig.kt', + configLanguage: 'kotlin', + runInstructions: + 'Run the app on a connected device or emulator, then click the `Send a ping` button to verify the setup.', + using: 'the terminal, VSCode or Android Studio' + }, + apple: { + name: 'Apple platforms', + title: 'Copy prompt: starter kit for Appwrite for Apple platforms', + repoName: 'starter-for-ios', + configFile: 'Sources/Config.plist', + configLanguage: 'plaintext', + runInstructions: + 'Run the app on a connected device or simulator, then click the `Send a ping` button to verify the setup.', + using: 'the terminal or XCode' + }, + flutter: { + name: 'Flutter', + title: 'Copy prompt: starter kit for Appwrite in Flutter', + repoName: 'starter-for-flutter', + configFile: 'lib/config/environment.dart', + configLanguage: 'dart', + runInstructions: + 'Run the app on a connected device or simulator using `flutter run -d [device_name]`, then click the `Send a ping` button to verify the setup. Ask the user if the AI agent should run the command to run the app for them. Provide the full command while you ask for permission.', + using: 'the terminal' + }, + reactnative: { + name: 'React Native', + title: 'Copy prompt: starter kit for Appwrite in React Native', + repoName: 'starter-for-react-native', + configFile: 'index.ts', + configLanguage: 'typescript', + runInstructions: + 'After replacing and hardcoding project details, run the app on a connected device or simulator using `npm install` followed by `npm run ios` or `npm run android`, then click the `Send a ping` button to verify the setup. Ask the user if the AI agent should run the command to run the app for them. Provide the full command while you ask for permission.', + using: 'the terminal or VSCode' + } +}; + +export function buildPlatformConfig( + platformKey: string, + configCode: string, + alreadyExistsInstructions: string +): LLMPromptConfig { + const config = platformConfigs[platformKey]; + if (!config) { + throw new Error(`Unknown platform: ${platformKey}`); + } + + return { + title: config.title, + alreadyExistsInstructions: alreadyExistsInstructions, + cloneCommand: `git clone https://github.com/appwrite/${config.repoName}\ncd ${config.repoName}`, + configFile: config.configFile, + configCode: configCode, + configLanguage: config.configLanguage, + runInstructions: config.runInstructions, + using: config.using + }; +} diff --git a/src/routes/(console)/project-[region]-[project]/sites/create-site/deploy/+page.svelte b/src/routes/(console)/project-[region]-[project]/sites/create-site/deploy/+page.svelte index feb15624c..5dc5b86c3 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/create-site/deploy/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/create-site/deploy/+page.svelte @@ -12,7 +12,7 @@ import { IconGithub, IconPencil } from '@appwrite.io/pink-icons-svelte'; import { onMount } from 'svelte'; import Domain from '../domain.svelte'; - import { Adapter, BuildRuntime, Framework, ID } from '@appwrite.io/console'; + import { Adapter, BuildRuntime, Framework, ID, Type } from '@appwrite.io/console'; import { CustomId } from '$lib/components'; import { getFrameworkIcon } from '$lib/stores/sites'; import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store'; @@ -172,7 +172,8 @@ repository: data.repository.name, owner: data.repository.owner, rootDirectory: rootDir || '.', - version: latestTag ?? '1.0.0', + type: Type.Tag, + reference: latestTag ?? '1.0.0', activate: true }); diff --git a/src/routes/(console)/project-[region]-[project]/sites/create-site/templates/template-[template]/+page.svelte b/src/routes/(console)/project-[region]-[project]/sites/create-site/templates/template-[template]/+page.svelte index 1dbbd143e..992806014 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/create-site/templates/template-[template]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/create-site/templates/template-[template]/+page.svelte @@ -24,7 +24,7 @@ import Details from '../../details.svelte'; import Configuration from './configuration.svelte'; import Aside from '../../aside.svelte'; - import { Adapter, BuildRuntime, Framework, ID, type Models } from '@appwrite.io/console'; + import { Adapter, BuildRuntime, Framework, ID, Type, type Models } from '@appwrite.io/console'; import { ConnectBehaviour, NewRepository, @@ -159,7 +159,8 @@ repository: data.template.providerRepositoryId, owner: data.template.providerOwner, rootDirectory: framework.providerRootDirectory, - version: data.template.providerVersion, + type: Type.Tag, + reference: data.template.providerVersion, activate: true }); diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/+page.ts b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/+page.ts index 536e5eb09..d139f00b7 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/+page.ts @@ -1,6 +1,6 @@ import { sdk } from '$lib/stores/sdk'; import { Dependencies } from '$lib/constants'; -import { Query } from '@appwrite.io/console'; +import { Query, type Models } from '@appwrite.io/console'; import { RuleType } from '$lib/stores/sdk'; import { DeploymentResourceType } from '$lib/stores/sdk'; @@ -58,11 +58,18 @@ export const load = async ({ params, depends, parent }) => { }) ]); - const deployment = deploymentList?.total - ? await sdk - .forProject(params.region, params.project) - .sites.getDeployment({ siteId: params.site, deploymentId: site.deploymentId }) - : null; + let deployment: Models.Deployment | null = null; + if (deploymentList?.total && site.deploymentId) { + try { + deployment = await sdk + .forProject(params.region, params.project) + .sites.getDeployment({ siteId: params.site, deploymentId: site.deploymentId }); + } catch (error) { + // active deployment with the requested ID could not be found + deployment = null; + } + } + return { site, deploymentList, diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/deployments/+page.ts b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/deployments/+page.ts index 8ddb969d5..ac8fdc679 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/deployments/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/deployments/+page.ts @@ -1,4 +1,4 @@ -import { Query } from '@appwrite.io/console'; +import { Query, type Models } from '@appwrite.io/console'; import { sdk } from '$lib/stores/sdk'; import { getLimit, getPage, getQuery, pageToOffset } from '$lib/helpers/load'; import { Dependencies, PAGE_LIMIT } from '$lib/constants'; @@ -45,17 +45,24 @@ export const load = async ({ params, depends, url, route, parent }) => { sdk.forProject(params.region, params.project).vcs.listInstallations() ]); + let activeDeployment: Models.Deployment | null = null; + if (site.deploymentId && deploymentList?.total) { + try { + activeDeployment = await sdk + .forProject(params.region, params.project) + .sites.getDeployment({ siteId: params.site, deploymentId: site.deploymentId }); + } catch (error) { + // active deployment with the requested ID could not be found + activeDeployment = null; + } + } + return { offset, limit, query, deploymentList, - activeDeployment: - site.deploymentId && deploymentList?.total - ? await sdk - .forProject(params.region, params.project) - .sites.getDeployment({ siteId: params.site, deploymentId: site.deploymentId }) - : null, + activeDeployment, installations }; }; diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/settings/+page.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/settings/+page.svelte index fa736724f..6c7ca6d64 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/settings/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/settings/+page.svelte @@ -32,7 +32,8 @@ allowedFileExtensions: values.allowedFileExtensions, compression: values.compression, encryption: values.encryption, - antivirus: values.antivirus + antivirus: values.antivirus, + transformations: values.transformations }); await invalidate(Dependencies.BUCKET); @@ -101,7 +102,8 @@ $permissions: permissions, encryption, antivirus, - compression + compression, + transformations } = data.bucket; const compressionOptions = [ @@ -214,6 +216,18 @@ } ); } + + function updateTransformations() { + updateBucket( + data.bucket, + { + transformations + }, + { + trackEventName: Submit.BucketUpdateTransformations + } + ); + } @@ -365,6 +379,28 @@ +
+ + Image transformations + + + + + + + + +
+
diff --git a/src/routes/(console)/supportWizard.svelte b/src/routes/(console)/supportWizard.svelte index ac39f7e89..c53c0c4a3 100644 --- a/src/routes/(console)/supportWizard.svelte +++ b/src/routes/(console)/supportWizard.svelte @@ -1,11 +1,17 @@ +{#snippet severityPopover()} + + +
+ + + Critical: System is down or a critical component is non-functional, causing + a complete stoppage of work or significant business impact. + + + High: Major functionality is impaired, but a workaround is available, or a + critical component is significantly degraded. + + + Medium: Minor functionality is impaired without significant business impact. + + + Low: Issue has minor impact on business operations; workaround is not necessary. + + + Question: Requests for information, general guidance, or feature requests. + + +
+
+{/snippet} + @@ -113,24 +223,48 @@ Choose a topic + >Choose a category - {#each ['general', 'billing', 'technical'] as category} + {#each categories as category} { - $supportData.category = category; + if ($supportData.category !== category.value) { + $supportData.topic = undefined; + } + $supportData.category = category.value; }} - selected={$supportData.category === category}>{category} + selected={$supportData.category === category.value} + >{category.label} {/each} - 0} + {#key $supportData.category} + + {/key} + {/if} + + +
+ {@render severityPopover()} +
+
- { wizard.hide(); - }}>Cancel - Submit + }}>Cancel + diff --git a/src/routes/(console)/wizard/support/store.ts b/src/routes/(console)/wizard/support/store.ts index 06f671b6a..56d0e76b2 100644 --- a/src/routes/(console)/wizard/support/store.ts +++ b/src/routes/(console)/wizard/support/store.ts @@ -4,6 +4,8 @@ export type SupportData = { message: string; subject: string; category: string; + topic?: string; + severity?: string; file?: File | null; project?: string; }; @@ -11,7 +13,8 @@ export type SupportData = { export const supportData = writable({ message: '', subject: '', - category: 'general', + category: 'technical', + severity: 'question', file: null }); diff --git a/src/routes/(public)/functions/deploy/+page.ts b/src/routes/(public)/functions/deploy/+page.ts index 978894e78..669756550 100644 --- a/src/routes/(public)/functions/deploy/+page.ts +++ b/src/routes/(public)/functions/deploy/+page.ts @@ -3,7 +3,7 @@ import { redirect } from '@sveltejs/kit'; import { base } from '$app/paths'; import { isCloud } from '$lib/system'; import { BillingPlan } from '$lib/constants'; -import { ID, type Models } from '@appwrite.io/console'; +import { ID, type Models, Query } from '@appwrite.io/console'; import type { OrganizationList } from '$lib/stores/organization'; import { redirectTo } from '$routes/store'; import type { PageLoad } from './$types'; @@ -66,7 +66,9 @@ export const load: PageLoad = async ({ parent, url }) => { // Get organizations let organizations: Models.TeamList> | OrganizationList | undefined; if (isCloud) { - organizations = await sdk.forConsole.billing.listOrganization(); + organizations = await sdk.forConsole.billing.listOrganization([ + Query.equal('platform', 'appwrite') + ]); } else { organizations = await sdk.forConsole.teams.list(); } @@ -78,7 +80,6 @@ export const load: PageLoad = async ({ parent, url }) => { ID.unique(), 'Personal Projects', BillingPlan.FREE, - null, null ); } else { @@ -89,7 +90,9 @@ export const load: PageLoad = async ({ parent, url }) => { } if (isCloud) { - organizations = await sdk.forConsole.billing.listOrganization(); + organizations = await sdk.forConsole.billing.listOrganization([ + Query.equal('platform', 'appwrite') + ]); } else { organizations = await sdk.forConsole.teams.list(); } diff --git a/src/routes/(public)/sites/deploy/+page.ts b/src/routes/(public)/sites/deploy/+page.ts index 991de1bf5..666c809e9 100644 --- a/src/routes/(public)/sites/deploy/+page.ts +++ b/src/routes/(public)/sites/deploy/+page.ts @@ -3,7 +3,7 @@ import { redirect, error } from '@sveltejs/kit'; import { base } from '$app/paths'; import { isCloud } from '$lib/system'; import { BillingPlan } from '$lib/constants'; -import { ID, type Models } from '@appwrite.io/console'; +import { ID, type Models, Query } from '@appwrite.io/console'; import type { OrganizationList } from '$lib/stores/organization'; import { redirectTo } from '$routes/store'; import type { PageLoad } from './$types'; @@ -83,7 +83,9 @@ export const load: PageLoad = async ({ parent, url }) => { let organizations: Models.TeamList> | OrganizationList | undefined; if (isCloud) { - organizations = await sdk.forConsole.billing.listOrganization(); + organizations = await sdk.forConsole.billing.listOrganization([ + Query.equal('platform', 'appwrite') + ]); } else { organizations = await sdk.forConsole.teams.list(); } @@ -106,7 +108,9 @@ export const load: PageLoad = async ({ parent, url }) => { // Refetch organizations after creation if (isCloud) { - organizations = await sdk.forConsole.billing.listOrganization(); + organizations = await sdk.forConsole.billing.listOrganization([ + Query.equal('platform', 'appwrite') + ]); } else { organizations = await sdk.forConsole.teams.list(); } diff --git a/src/routes/(public)/template-[template]/+page.ts b/src/routes/(public)/template-[template]/+page.ts index 80f486c7f..2c4c6a5bb 100644 --- a/src/routes/(public)/template-[template]/+page.ts +++ b/src/routes/(public)/template-[template]/+page.ts @@ -1,6 +1,6 @@ import { BillingPlan } from '$lib/constants.js'; import { sdk } from '$lib/stores/sdk.js'; -import { ID, type Models } from '@appwrite.io/console'; +import { ID, type Models, Query } from '@appwrite.io/console'; import { isCloud } from '$lib/system.js'; import { error, redirect } from '@sveltejs/kit'; import type { OrganizationList } from '$lib/stores/organization.js'; @@ -39,7 +39,9 @@ export const load = async ({ parent, url, params }) => { let organizations: Models.TeamList> | OrganizationList | undefined; if (isCloud) { - organizations = account?.$id ? await sdk.forConsole.billing.listOrganization() : undefined; + organizations = account?.$id + ? await sdk.forConsole.billing.listOrganization([Query.equal('platform', 'appwrite')]) + : undefined; } else { organizations = account?.$id ? await sdk.forConsole.teams.list() : undefined; } @@ -49,7 +51,6 @@ export const load = async ({ parent, url, params }) => { ID.unique(), 'Personal project', BillingPlan.FREE, - null, null ); } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 7a536b25e..571542d06 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -289,6 +289,11 @@ } } + /* Fix when no vertical scrollbar is present, some environments reserve a gutter by default */ + html { + scrollbar-gutter: auto !important; + } + /* TODO: remove this block once Pink V2 is incorporated */ input[type='radio'], input[type='checkbox']:not([class='switch']), diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 5ee94b650..b8cc5ce03 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -8,7 +8,7 @@ import type { LayoutLoad } from './$types'; import { redirectTo } from './store'; import { base, resolve } from '$app/paths'; import type { Account } from '$lib/stores/user'; -import type { AppwriteException } from '@appwrite.io/console'; +import { type AppwriteException, Query } from '@appwrite.io/console'; import { isCloud, VARS } from '$lib/system'; import { checkPricingRefAndRedirect } from '$lib/helpers/pricingRedirect'; @@ -42,7 +42,9 @@ export const load: LayoutLoad = async ({ depends, url, route }) => { account: account, organizations: !isCloud ? await sdk.forConsole.teams.list() - : await sdk.forConsole.billing.listOrganization() + : await sdk.forConsole.billing.listOrganization([ + Query.equal('platform', 'appwrite') + ]) }; }