{#if $page.data?.header}
diff --git a/src/lib/profiles/css/base.css b/src/lib/profiles/css/base.css new file mode 100644 index 000000000..ae0218c27 --- /dev/null +++ b/src/lib/profiles/css/base.css @@ -0,0 +1,6 @@ +.responsive-table { + overflow: hidden; + width: 100%; + scrollbar-width: thin; + position: relative; +} diff --git a/src/lib/sdk/backups.ts b/src/lib/sdk/backups.ts deleted file mode 100644 index 43cb6eb8b..000000000 --- a/src/lib/sdk/backups.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { AppwriteException, Client, type Payload } from '@appwrite.io/console'; - -export type BackupPolicyList = { - total: number; - policies: BackupPolicy[]; -}; - -export type BackupArchiveList = { - total: number; - archives: BackupArchive[]; -}; - -export type BackupRestorationList = { - total: number; - restorations: BackupRestoration[]; -}; - -export type BackupPolicy = { - $id: string; - name: string; - $createdAt: string; - $updatedAt: string; - services: string[]; - resources: string[]; - resourceId?: string; - resourceType?: string; - retention: number; - schedule: string; - enabled: boolean; -}; - -export type BackupArchive = { - $id: string; - $createdAt: string; - $updatedAt: string; - policyId: string; - size: number; - status: string; - startedAt: string; - migrationId: string; - services: string[]; - resources: string[]; - resourceId?: string; - resourceType?: string; -}; - -export type BackupRestoration = { - $id: string; - $createdAt: string; - $updatedAt: string; - archiveId: string; - policyId: string; - status: string; - startedAt: string; - migrationId: string; - services: string[]; - resources: string[]; - options: string; -}; - -export class Backups { - client: Client; - - constructor(client: Client) { - this.client = client; - } - - async createArchive(services: string[], resourceId?: string): Promise { - if (typeof services === 'undefined') { - throw new AppwriteException('Missing required parameter: "services"'); - } - const apiPath = '/backups/archives'; - const payload: Payload = {}; - if (typeof services !== 'undefined') { - payload['services'] = services; - } - if (typeof resourceId !== 'undefined') { - payload['resourceId'] = resourceId; - } - const uri = new URL(this.client.config.endpoint + apiPath); - - const apiHeaders: { [header: string]: string } = { - 'content-type': 'application/json' - }; - - return await this.client.call('post', uri, apiHeaders, payload); - } - - async deleteArchive(archiveId: string): Promise { - if (typeof archiveId === 'undefined') { - throw new AppwriteException('Missing required parameter: "archiveId"'); - } - const apiPath = '/backups/archives/{archiveId}'.replace('{archiveId}', archiveId); - const payload: Payload = {}; - const uri = new URL(this.client.config.endpoint + apiPath); - - const apiHeaders: { [header: string]: string } = { - 'content-type': 'application/json' - }; - - return await this.client.call('delete', uri, apiHeaders, payload); - } - - async listArchives(queries?: string[]): Promise { - const apiPath = '/backups/archives'; - const payload: Payload = {}; - if (typeof queries !== 'undefined') { - payload['queries'] = queries; - } - const uri = new URL(this.client.config.endpoint + apiPath); - - const apiHeaders: { [header: string]: string } = { - 'content-type': 'application/json' - }; - - return await this.client.call('get', uri, apiHeaders, payload); - } - - async getArchive(archiveId: string): Promise { - if (typeof archiveId === 'undefined') { - throw new AppwriteException('Missing required parameter: "archiveId"'); - } - const apiPath = '/backups/archives/{archiveId}'.replace('{archiveId}', archiveId); - const payload: Payload = {}; - const uri = new URL(this.client.config.endpoint + apiPath); - - const apiHeaders: { [header: string]: string } = { - 'content-type': 'application/json' - }; - - return await this.client.call('get', uri, apiHeaders, payload); - } - - async listPolicies(queries?: string[]): Promise { - const apiPath = '/backups/policies'; - const payload: Payload = {}; - if (typeof queries !== 'undefined') { - payload['queries'] = queries; - } - const uri = new URL(this.client.config.endpoint + apiPath); - - const apiHeaders: { [header: string]: string } = { - 'content-type': 'application/json' - }; - - return await this.client.call('get', uri, apiHeaders, payload); - } - - async createPolicy( - policyId: string, - services: string[], - retention: number, - schedule: string, - name?: string, - resourceId?: string, - enabled?: boolean - ): Promise { - if (typeof policyId === 'undefined') { - throw new AppwriteException('Missing required parameter: "policyId"'); - } - if (typeof services === 'undefined') { - throw new AppwriteException('Missing required parameter: "services"'); - } - if (typeof retention === 'undefined') { - throw new AppwriteException('Missing required parameter: "retention"'); - } - if (typeof schedule === 'undefined') { - throw new AppwriteException('Missing required parameter: "schedule"'); - } - const apiPath = '/backups/policies'; - const payload: Payload = {}; - if (typeof policyId !== 'undefined') { - payload['policyId'] = policyId; - } - if (typeof name !== 'undefined') { - payload['name'] = name; - } - if (typeof services !== 'undefined') { - payload['services'] = services; - } - if (typeof resourceId !== 'undefined') { - payload['resourceId'] = resourceId; - } - if (typeof enabled !== 'undefined') { - payload['enabled'] = enabled; - } - if (typeof retention !== 'undefined') { - payload['retention'] = retention; - } - if (typeof schedule !== 'undefined') { - payload['schedule'] = schedule; - } - const uri = new URL(this.client.config.endpoint + apiPath); - - const apiHeaders: { [header: string]: string } = { - 'content-type': 'application/json' - }; - - return await this.client.call('post', uri, apiHeaders, payload); - } - - async getPolicy(policyId: string): Promise { - if (typeof policyId === 'undefined') { - throw new AppwriteException('Missing required parameter: "policyId"'); - } - const apiPath = '/backups/policies/{policyId}'.replace('{policyId}', policyId); - const payload: Payload = {}; - const uri = new URL(this.client.config.endpoint + apiPath); - - const apiHeaders: { [header: string]: string } = { - 'content-type': 'application/json' - }; - - return await this.client.call('get', uri, apiHeaders, payload); - } - - async updatePolicy( - policyId: string, - name?: string, - retention?: number, - schedule?: string, - enabled?: boolean - ): Promise { - if (typeof policyId === 'undefined') { - throw new AppwriteException('Missing required parameter: "policyId"'); - } - const apiPath = '/backups/policies/{policyId}'.replace('{policyId}', policyId); - const payload: Payload = {}; - if (typeof name !== 'undefined') { - payload['name'] = name; - } - if (typeof retention !== 'undefined') { - payload['retention'] = retention; - } - if (typeof schedule !== 'undefined') { - payload['schedule'] = schedule; - } - if (typeof enabled !== 'undefined') { - payload['enabled'] = enabled; - } - const uri = new URL(this.client.config.endpoint + apiPath); - - const apiHeaders: { [header: string]: string } = { - 'content-type': 'application/json' - }; - - return await this.client.call('patch', uri, apiHeaders, payload); - } - - async deletePolicy(policyId: string): Promise { - if (typeof policyId === 'undefined') { - throw new AppwriteException('Missing required parameter: "policyId"'); - } - const apiPath = '/backups/policies/{policyId}'.replace('{policyId}', policyId); - const payload: Payload = {}; - const uri = new URL(this.client.config.endpoint + apiPath); - - const apiHeaders: { [header: string]: string } = { - 'content-type': 'application/json' - }; - - return await this.client.call('delete', uri, apiHeaders, payload); - } - - async createRestoration( - archiveId: string, - services: string[], - newResourceId?: string, - newResourceName?: string - ): Promise { - if (typeof archiveId === 'undefined') { - throw new AppwriteException('Missing required parameter: "archiveId"'); - } - if (typeof services === 'undefined') { - throw new AppwriteException('Missing required parameter: "services"'); - } - const apiPath = '/backups/restoration'; - const payload: Payload = {}; - if (typeof archiveId !== 'undefined') { - payload['archiveId'] = archiveId; - } - if (typeof services !== 'undefined') { - payload['services'] = services; - } - if (typeof newResourceId !== 'undefined') { - payload['newResourceId'] = newResourceId; - } - if (typeof newResourceName !== 'undefined') { - payload['newResourceName'] = newResourceName; - } - const uri = new URL(this.client.config.endpoint + apiPath); - - const apiHeaders: { [header: string]: string } = { - 'content-type': 'application/json' - }; - - return await this.client.call('post', uri, apiHeaders, payload); - } - - async listRestorations(queries?: string[]): Promise { - const apiPath = '/backups/restorations'; - const payload: Payload = {}; - if (typeof queries !== 'undefined') { - payload['queries'] = queries; - } - const uri = new URL(this.client.config.endpoint + apiPath); - - const apiHeaders: { [header: string]: string } = { - 'content-type': 'application/json' - }; - - return await this.client.call('get', uri, apiHeaders, payload); - } - - async getRestoration(restorationId: string): Promise { - if (typeof restorationId === 'undefined') { - throw new AppwriteException('Missing required parameter: "restorationId"'); - } - const apiPath = '/backups/restorations/{restorationId}'.replace( - '{restorationId}', - restorationId - ); - const payload: Payload = {}; - const uri = new URL(this.client.config.endpoint + apiPath); - - const apiHeaders: { [header: string]: string } = { - 'content-type': 'application/json' - }; - - return await this.client.call('get', uri, apiHeaders, payload); - } -} diff --git a/src/lib/sdk/billing.ts b/src/lib/sdk/billing.ts index 0361f5910..994506e72 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', @@ -1287,26 +1300,6 @@ export class Billing { ); } - async setupPaymentMandate( - organizationId: string, - paymentMethodId: string - ): Promise { - const path = `/account/payment-methods/${paymentMethodId}/setup`; - const params = { - organizationId, - paymentMethodId - }; - const uri = new URL(this.client.config.endpoint + path); - return await this.client.call( - 'patch', - uri, - { - 'content-type': 'application/json' - }, - params - ); - } - async listAddresses(queries: string[] = []): Promise { const path = `/account/billing-addresses`; const params = { @@ -1411,10 +1404,10 @@ export class Billing { ); } - async listRegions(teamId: string): Promise { + async listRegions(organizationId: string): Promise { const path = `/console/regions`; const params = { - teamId + organizationId }; const uri = new URL(this.client.config.endpoint + path); return await this.client.call( diff --git a/src/lib/stores/billing.ts b/src/lib/stores/billing.ts index d23fa3346..df2d29178 100644 --- a/src/lib/stores/billing.ts +++ b/src/lib/stores/billing.ts @@ -8,7 +8,7 @@ import MarkedForDeletion from '$lib/components/billing/alerts/markedForDeletion. import MissingPaymentMethod from '$lib/components/billing/alerts/missingPaymentMethod.svelte'; import newDevUpgradePro from '$lib/components/billing/alerts/newDevUpgradePro.svelte'; import PaymentAuthRequired from '$lib/components/billing/alerts/paymentAuthRequired.svelte'; -import PaymentMandate from '$lib/components/billing/alerts/paymentMandate.svelte'; + import { BillingPlan, NEW_DEV_PRO_UPGRADE_COUPON } from '$lib/constants'; import { cachedStore } from '$lib/helpers/cache'; import { type Size, sizeToBytes } from '$lib/helpers/sizeConvertion'; @@ -18,13 +18,12 @@ import type { Invoice, InvoiceList, PaymentList, - PaymentMethodData, Plan, PlansMap } from '$lib/sdk/billing'; import { isCloud } from '$lib/system'; import { activeHeaderAlert, orgMissingPaymentMethod } from '$routes/(console)/store'; -import { AppwriteException, Query } from '@appwrite.io/console'; +import { AppwriteException, Query, Platform } from '@appwrite.io/console'; import { derived, get, writable } from 'svelte/store'; import { headerAlert } from './headerAlert'; import { addNotification, notifications } from './notifications'; @@ -100,7 +99,7 @@ export function tierToPlan(tier: Tier) { case BillingPlan.ENTERPRISE: return tierEnterprise; default: - return tierFree; + return tierCustom; } } @@ -488,6 +487,9 @@ export async function paymentExpired(org: Organization) { const nots = get(notifications); const expiredNotification = nots.some((n) => n.message === expiredMessage); const expiringNotification = nots.some((n) => n.message === expiringMessage); + const cardExpiry = new Date(payment.expiryYear, payment.expiryMonth, 1); + const nextMonth = new Date(year, month + 1, 1); + const isExpiringNextMonth = cardExpiry.getTime() === nextMonth.getTime(); if (payment.expired && !expiredNotification) { addNotification({ type: 'error', @@ -503,7 +505,7 @@ export async function paymentExpired(org: Organization) { } ] }); - } else if (!expiringNotification && payment.expiryYear <= year && payment.expiryMonth < month) { + } else if (!expiringNotification && !payment.expired && isExpiringNextMonth) { addNotification({ type: 'warning', isHtml: true, @@ -532,29 +534,12 @@ export function checkForMarkedForDeletion(org: Organization) { } } -export const paymentMissingMandate = writable(null); - -export async function checkForMandate(org: Organization) { - const paymentId = org.paymentMethodId ?? org.backupPaymentMethodId; - if (!paymentId) return; - const paymentMethod = await sdk.forConsole.billing.getPaymentMethod(paymentId); - if (paymentMethod?.mandateId === null && paymentMethod?.country.toLowerCase() === 'in') { - headerAlert.add({ - id: 'paymentMandate', - component: PaymentMandate, - show: true, - importance: 8 - }); - activeHeaderAlert.set(headerAlert.get()); - paymentMissingMandate.set(paymentMethod); - } -} - 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', Platform.Appwrite) ]); if (orgs?.total) { orgMissingPaymentMethod.set(orgs.teams[0]); diff --git a/src/lib/stores/bottom-alerts.ts b/src/lib/stores/bottom-alerts.ts index 634dee01f..0e80a6dfa 100644 --- a/src/lib/stores/bottom-alerts.ts +++ b/src/lib/stores/bottom-alerts.ts @@ -1,10 +1,14 @@ import { writable } from 'svelte/store'; -import type { NotificationCoolOffOptions } from '$lib/helpers/notifications'; -import type { Organization } from '$lib/stores/organization'; +import type { Component } from 'svelte'; import type { Models } from '@appwrite.io/console'; +import type { Organization } from '$lib/stores/organization'; +import type { NotificationCoolOffOptions } from '$lib/helpers/notifications'; export type BottomModalAlertAction = { text: string; + color?: Record<'light' | 'dark', string> | string; + background?: Record<'light' | 'dark', string> | string; + backgroundHover?: Record<'light' | 'dark', string> | string; hideOnClick?: boolean; link: (ctx: { organization: Organization; project: Models.Project }) => string; external?: boolean; @@ -32,7 +36,10 @@ export type BottomModalAlertItem = { title: string; message: string; - src: Record<'dark' | 'light', string>; + // use either of these! + src?: Record<'dark' | 'light', string>; + backgroundComponent?: Component; + cta: BottomModalAlertAction; learnMore?: BottomModalAlertAction; plan: 'free' | 'pro' | 'scale' /*| 'enterprise'*/; @@ -43,6 +50,12 @@ export type BottomModalAlertItem = { closed?: () => void; scope: 'organization' | 'project' | 'everywhere'; notificationHideOptions?: NotificationCoolOffOptions; + + /** + * if true, + * uses same title, message on mobile floating window. + */ + sameContentOnMobileLayout?: boolean; }; type BottomModalAlertState = { diff --git a/src/lib/stores/database.ts b/src/lib/stores/database.ts index 4c93859ce..648708bd0 100644 --- a/src/lib/stores/database.ts +++ b/src/lib/stores/database.ts @@ -27,9 +27,9 @@ export async function checkForDatabaseBackupPolicies( if (isCloud && backupsEnabled) { try { - const policies = await sdk - .forProject(region, projectId) - .backups.listPolicies([Query.limit(1), Query.equal('resourceId', database.$id)]); + const policies = await sdk.forProject(region, projectId).backups.listPolicies({ + queries: [Query.limit(1), Query.equal('resourceId', database.$id)] + }); total = policies.total; } catch (e) { diff --git a/src/lib/stores/oauth-providers.ts b/src/lib/stores/oauth-providers.ts index 47d426e9f..22df4b17b 100644 --- a/src/lib/stores/oauth-providers.ts +++ b/src/lib/stores/oauth-providers.ts @@ -13,6 +13,7 @@ export type Provider = { name: string; icon: string; docs?: string; + internal?: true; component: Component; }; @@ -26,7 +27,7 @@ export const oAuthProviders: Record = { apple: { name: 'Apple', icon: 'apple', - docs: 'https://developer.apple.com/', + docs: 'https://developer.apple.com/sign-in-with-apple/', component: Apple }, auth0: { @@ -113,6 +114,13 @@ export const oAuthProviders: Record = { docs: 'https://developer.github.com', component: Main }, + githubImagine: { + name: 'GitHub', + icon: 'github', + docs: 'https://developer.github.com', + component: Main, + internal: true + }, gitlab: { name: 'GitLab', icon: 'gitlab', @@ -125,6 +133,13 @@ export const oAuthProviders: Record = { docs: 'https://support.google.com/googleapi/answer/6158849', component: Google }, + googleImagine: { + name: 'Google', + icon: 'google', + docs: 'https://support.google.com/googleapi/answer/6158849', + component: Google, + internal: true + }, linkedin: { name: 'LinkedIn', icon: 'linkedin', diff --git a/src/lib/stores/organization.ts b/src/lib/stores/organization.ts index 79f218d8f..b3b874cab 100644 --- a/src/lib/stores/organization.ts +++ b/src/lib/stores/organization.ts @@ -1,8 +1,8 @@ import { page } from '$app/stores'; -import { derived, writable } from 'svelte/store'; -import type { Models } from '@appwrite.io/console'; import type { Tier } from './billing'; import type { Plan } from '$lib/sdk/billing'; +import { derived, writable } from 'svelte/store'; +import { type Models, Platform } from '@appwrite.io/console'; export type OrganizationError = { status: number; @@ -16,6 +16,8 @@ export type OrganizationError = { export type Organization = Models.Team> & { billingBudget: number; billingPlan: Tier; + billingPlanId: Tier /* unused for now! */; + billingPlanDetails: Plan /* unused for now! */; budgetAlerts: number[]; paymentMethodId: string; backupPaymentMethodId: string; @@ -35,6 +37,7 @@ export type Organization = Models.Team> & { status: string; remarks: string; projects: string[]; + platform: Platform; }; export type OrganizationList = { diff --git a/src/lib/stores/sdk.ts b/src/lib/stores/sdk.ts index 2ae897822..d7e0cc7b9 100644 --- a/src/lib/stores/sdk.ts +++ b/src/lib/stores/sdk.ts @@ -3,6 +3,7 @@ import { Account, Assistant, Avatars, + Backups, Client, Console, Functions, @@ -21,10 +22,11 @@ import { Sites, Tokens, TablesDB, - Domains + Domains, + Realtime, + Organizations } from '@appwrite.io/console'; import { Billing } from '../sdk/billing'; -import { Backups } from '../sdk/backups'; import { Sources } from '$lib/sdk/sources'; import { REGION_FRA, @@ -32,14 +34,15 @@ import { REGION_SYD, REGION_SFO, REGION_SGP, + REGION_TOR, SUBDOMAIN_FRA, SUBDOMAIN_NYC, SUBDOMAIN_SFO, SUBDOMAIN_SYD, - SUBDOMAIN_SGP + SUBDOMAIN_SGP, + SUBDOMAIN_TOR } from '$lib/constants'; import { building } from '$app/environment'; -import { getProjectId } from '$lib/helpers/project'; export function getApiEndpoint(region?: string): string { if (building) return ''; @@ -67,6 +70,8 @@ const getSubdomain = (region?: string) => { return SUBDOMAIN_SFO; case REGION_SGP: return SUBDOMAIN_SGP; + case REGION_TOR: + return SUBDOMAIN_TOR; default: return ''; } @@ -90,7 +95,9 @@ function createConsoleSdk(client: Client) { sources: new Sources(client), sites: new Sites(client), domains: new Domains(client), - storage: new Storage(client) + storage: new Storage(client), + realtime: new Realtime(client), + organizations: new Organizations(client) }; } @@ -106,7 +113,6 @@ if (!building) { scopedConsoleClient.setProject('console'); clientConsole.setEndpoint(endpoint).setProject('console'); - clientRealtime.setEndpoint(endpoint).setProject('console'); clientProject.setEndpoint(endpoint).setMode('admin'); clientRealtime.setEndpoint(endpoint).setProject('console'); } @@ -135,12 +141,32 @@ const sdkForProject = { }; export const realtime = { - forProject(region: string, _projectId: string) { + forProject( + region: string, + channels: string | string[], + callback: AppwriteRealtimeResponseEvent + ) { const endpoint = getApiEndpoint(region); if (endpoint !== clientRealtime.config.endpoint) { clientRealtime.setEndpoint(endpoint); } - return clientRealtime; + + // because uses a different client! + const realtime = new Realtime(clientRealtime); + + return createRealtimeSubscription(realtime, channels, callback); + }, + + forConsole( + region: string, + channels: string | string[], + callback: AppwriteRealtimeResponseEvent + ): () => void { + const realtimeInstance = region + ? sdk.forConsoleIn(region).realtime + : sdk.forConsole.realtime; + + return createRealtimeSubscription(realtimeInstance, channels, callback); } }; @@ -170,8 +196,8 @@ export const sdk = { }; export enum RuleType { - DEPLOYMENT = 'deployment', API = 'api', + DEPLOYMENT = 'deployment', REDIRECT = 'redirect' } @@ -185,6 +211,24 @@ export enum RuleTrigger { MANUAL = 'manual' } -export const createAdminClient = () => { - return new Client().setEndpoint(getApiEndpoint()).setMode('admin').setProject(getProjectId()); +export type RealtimeResponse = { + events: string[]; + channels: string[]; + timestamp: string; + payload: unknown; }; + +export type AppwriteRealtimeResponseEvent = (response: RealtimeResponse) => void; + +function createRealtimeSubscription( + realtimeInstance: Realtime, + channels: string | string[], + callback: AppwriteRealtimeResponseEvent +): () => void { + const channelsArray = Array.isArray(channels) ? channels : [channels]; + const subscriptionPromise = realtimeInstance.subscribe(channelsArray, callback); + + return () => { + subscriptionPromise.then((sub) => sub.close()); + }; +} diff --git a/src/lib/stores/sites.ts b/src/lib/stores/sites.ts index fcd321572..29d811332 100644 --- a/src/lib/stores/sites.ts +++ b/src/lib/stores/sites.ts @@ -26,6 +26,8 @@ export function getFrameworkIcon(framework: string) { return 'vite'; case framework.toLocaleLowerCase().includes('lynx'): return 'lynx'; + case framework.toLocaleLowerCase().includes('tanstack'): + return 'tanstack'; case framework.toLocaleLowerCase().includes('other'): return 'empty'; diff --git a/src/lib/stores/stripe.ts b/src/lib/stores/stripe.ts index 04c990081..9f4bc727b 100644 --- a/src/lib/stores/stripe.ts +++ b/src/lib/stores/stripe.ts @@ -26,6 +26,10 @@ export const isStripeInitialized = writable(false); export async function initializeStripe(node: HTMLElement) { if (!get(stripe)) return; + + // cleanup any existing state + await unmountPaymentElement(); + isStripeInitialized.set(true); const methods = await sdk.forConsole.billing.listPaymentMethods(); @@ -54,10 +58,20 @@ export async function initializeStripe(node: HTMLElement) { export async function unmountPaymentElement() { isStripeInitialized.set(false); - paymentElement?.unmount(); + + if (paymentElement) { + try { + paymentElement.unmount(); + paymentElement.destroy(); + } catch (e) { + console.debug('Payment element cleanup:', e.message); + } + } + + elements = null; clientSecret = null; paymentMethod = null; - elements = null; + paymentElement = null; } export async function submitStripeCard(name: string, organizationId?: string) { @@ -102,13 +116,32 @@ export async function submitStripeCard(name: string, organizationId?: string) { } if (setupIntent && setupIntent.status === 'succeeded') { - if ((setupIntent.payment_method as PaymentMethod).card?.country === 'US') { + const pm = setupIntent.payment_method as PaymentMethod | string | undefined; + // If Stripe returned an expanded PaymentMethod object, check the card country. + // If it returned a string id (common), `typeof pm === 'string'` and we skip this. + if (typeof pm !== 'string' && pm?.card?.country === 'US') { // need to get state - return setupIntent.payment_method as PaymentMethod; + return pm as PaymentMethod; } + + // The backend expects a provider method ID (string). Extract the id + // whether Stripe returned the id string or an expanded object. + let providerId: string | undefined; + if (typeof pm === 'string') { + providerId = pm; + } else { + providerId = (pm as PaymentMethod)?.id; + } + + if (!providerId) { + const e = new Error('Unable to verify payment method.'); + trackError(e, Submit.PaymentMethodCreate); + throw e; + } + const method = await sdk.forConsole.billing.setPaymentMethod( paymentMethod.$id, - (setupIntent.payment_method as PaymentMethod).id, + providerId, name ); paymentElement.destroy(); diff --git a/src/lib/stores/uploader.ts b/src/lib/stores/uploader.ts index ef848232c..8a04eae8a 100644 --- a/src/lib/stores/uploader.ts +++ b/src/lib/stores/uploader.ts @@ -1,4 +1,4 @@ -import { Client, ID, type Models, Sites, Storage } from '@appwrite.io/console'; +import { Client, Functions, ID, type Models, Sites, Storage } from '@appwrite.io/console'; import { writable } from 'svelte/store'; import { getApiEndpoint } from '$lib/stores/sdk'; import { page } from '$app/state'; @@ -32,6 +32,13 @@ const temporarySites = (region: string, projectId: string) => { return new Sites(clientProject); }; +const temporaryFunctions = (region: string, projectId: string) => { + const clientProject = new Client().setMode('admin'); + const endpoint = getApiEndpoint(region); + clientProject.setEndpoint(endpoint).setProject(projectId); + return new Functions(clientProject); +}; + const createUploader = () => { const { subscribe, set, update } = writable({ isOpen: false, @@ -110,7 +117,19 @@ const createUploader = () => { newFile.status = 'success'; updateFile(newFile.$id, newFile); }, - uploadSiteDeployment: async (siteId: string, code: File) => { + uploadSiteDeployment: async ({ + siteId, + code, + buildCommand, + installCommand, + outputDirectory + }: { + siteId: string; + code: File; + buildCommand?: string; + installCommand?: string; + outputDirectory?: string; + }) => { const newDeployment: UploaderFile = { $id: '', resourceId: siteId, @@ -132,6 +151,49 @@ const createUploader = () => { siteId, code, activate: true, + buildCommand, + installCommand, + outputDirectory, + onProgress: (progress) => { + newDeployment.$id = progress.$id; + newDeployment.progress = progress.progress; + newDeployment.status = progress.progress === 100 ? 'success' : 'pending'; + updateFile(progress.$id, newDeployment); + } + }); + newDeployment.$id = uploadedFile.$id; + newDeployment.progress = 100; + newDeployment.status = 'success'; + updateFile(newDeployment.$id, newDeployment); + }, + uploadFunctionDeployment: async ({ + functionId, + code + }: { + functionId: string; + code: File; + }) => { + const newDeployment: UploaderFile = { + $id: '', + resourceId: functionId, + name: code.name, + size: code.size, + progress: 0, + status: 'pending' + }; + update((n) => { + n.isOpen = true; + n.isCollapsed = false; + n.files.unshift(newDeployment); + return n; + }); + const uploadedFile = await temporaryFunctions( + page.params.region, + page.params.project + ).createDeployment({ + functionId, + code, + activate: true, onProgress: (progress) => { newDeployment.$id = progress.$id; newDeployment.progress = progress.progress; 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)/(migration-wizard)/wizard.svelte b/src/routes/(console)/(migration-wizard)/wizard.svelte index ac11668f1..ad17e49e0 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte @@ -57,6 +57,7 @@ let newProjName = ''; let projectType: 'existing' | 'new' = 'existing'; + let newlyCreatedProject: Models.Project | null = null; async function getProjects(orgId: string | null) { if (!orgId) { @@ -125,8 +126,9 @@ }); onExit(); await invalidate(Dependencies.PROJECTS); + const targetProject = newlyCreatedProject ?? currentSelectedProject; await goto( - `${base}/project-${currentSelectedProject.region}-${currentSelectedProject.$id}/settings/migrations` + `${base}/project-${targetProject.region ?? 'default'}-${targetProject.$id}/settings/migrations` ); } catch (error) { addNotification({ @@ -257,6 +259,7 @@ } else { const project = await createNewProject(); if (project !== null) { + newlyCreatedProject = project; projectSdkInstance = sdk.forProject( project.region, project.$id diff --git a/src/routes/(console)/+layout.svelte b/src/routes/(console)/+layout.svelte index d3a77a8c4..ce28dd7c2 100644 --- a/src/routes/(console)/+layout.svelte +++ b/src/routes/(console)/+layout.svelte @@ -15,7 +15,6 @@ import { calculateTrialDay, checkForEnterpriseTrial, - checkForMandate, checkForMarkedForDeletion, checkForMissingPaymentMethod, checkForNewDevUpgradePro, @@ -308,7 +307,6 @@ if (org?.billingPlan !== BillingPlan.FREE) { await paymentExpired(org); await checkPaymentAuthorizationRequired(org); - await checkForMandate(org); if ($plansInfo.get(org.billingPlan)?.trialDays) { calculateTrialDay(org); @@ -334,8 +332,6 @@ { sdk.forConsole.account.getPrefs(), isCloud ? sdk.forConsole.billing.getPlansInfo() : null, fetch(`${endpoint}/health/version`, { - headers: { 'X-Appwrite-Project': project } + headers: { 'X-Appwrite-Project': project as string } }).then((response) => response.json() as { version?: string }), sdk.forConsole.console.variables() ]); @@ -35,7 +35,11 @@ export const load: LayoutLoad = async ({ depends, parent }) => { try { projectsCount = ( await sdk.forConsole.projects.list({ - queries: [Query.equal('teamId', currentOrgId), Query.limit(1)] + queries: [ + Query.equal('teamId', currentOrgId), + Query.limit(1), + Query.select(['$id']) + ] }) ).total; } catch (e) { diff --git a/src/routes/(console)/account/identities.svelte b/src/routes/(console)/account/identities.svelte index 924d22621..c326fdc1a 100644 --- a/src/routes/(console)/account/identities.svelte +++ b/src/routes/(console)/account/identities.svelte @@ -5,7 +5,6 @@ import { addNotification } from '$lib/stores/notifications'; import { sdk } from '$lib/stores/sdk'; import { identities } from './store'; - import { toLocaleDateTime } from '$lib/helpers/date'; import { invalidate } from '$app/navigation'; import { Dependencies } from '$lib/constants'; import { oAuthProviders } from '$lib/stores/oauth-providers'; @@ -82,7 +81,13 @@ - {toLocaleDateTime(identity.providerAccessTokenExpiry)} + {#if identity.providerAccessTokenExpiry} + + {:else} + - + {/if} 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)/bottomAlerts.ts b/src/routes/(console)/bottomAlerts.ts index 864daa6d3..1d0d86045 100644 --- a/src/routes/(console)/bottomAlerts.ts +++ b/src/routes/(console)/bottomAlerts.ts @@ -1,36 +1,58 @@ import { isCloud } from '$lib/system'; import { isSameDay } from '$lib/helpers/date'; -import { type BottomModalAlertItem, showBottomModalAlert } from '$lib/stores/bottom-alerts'; -import TransactionsApiDark from '$lib/images/promos/transactions-api-dark.png'; -import TransactionsApiLight from '$lib/images/promos/transactions-api-light.png'; +import Imagine from '$lib/components/promos/imagine.svelte'; +import { + type BottomModalAlertItem, + setMobileSingleAlertLayout, + showBottomModalAlert +} from '$lib/stores/bottom-alerts'; + +const SHOW_IMAGINE_PROMO = true; const listOfPromotions: BottomModalAlertItem[] = []; -if (isCloud) { - const transactionsApiPromo: BottomModalAlertItem = { - id: 'modal:transactions_api_announcement', - src: { - dark: TransactionsApiDark, - light: TransactionsApiLight - }, - title: 'Announcing Transactions API', - message: 'Ensure data consistency across tables with atomic, all-or-nothing commits.', - plan: 'free', +if (isCloud && SHOW_IMAGINE_PROMO) { + const imaginePromo: BottomModalAlertItem = { + id: 'modal:imagine.dev', + backgroundComponent: Imagine, + title: 'Introducing Imagine', + message: 'The most complete AI builder to date', importance: 8, - scope: 'project', + scope: 'everywhere', + plan: 'free', cta: { - text: 'Read announcement', - link: () => 'https://appwrite.io/blog/post/announcing-transactions-api', + text: 'Try it now', + color: { + light: '#FFFFFF', + dark: '#000000' + }, + background: { + light: '#000000', + dark: '#FFFFFF' + }, + backgroundHover: { + light: '#333333', + dark: '#CCCCCC' + }, + link: () => 'https://imagine.dev', external: true, hideOnClick: true }, show: true }; - listOfPromotions.push(transactionsApiPromo); + + listOfPromotions.push(imaginePromo); } export function addBottomModalAlerts() { listOfPromotions.forEach((promotion) => showBottomModalAlert(promotion)); + + // only for imagine! + if (listOfPromotions.length > 0) { + const imaginePromo = listOfPromotions[0]; + const { cta, title, message } = imaginePromo; + setMobileSingleAlertLayout({ enabled: true, cta, title, message }); + } } // use this for time based promo handling diff --git a/src/routes/(console)/create-organization/+page.svelte b/src/routes/(console)/create-organization/+page.svelte index de2afad0f..13d296fac 100644 --- a/src/routes/(console)/create-organization/+page.svelte +++ b/src/routes/(console)/create-organization/+page.svelte @@ -1,6 +1,6 @@ - + {#if $canWriteProjects} {#if projectCreationDisabled && reachedProjectLimit} @@ -221,13 +242,13 @@ {/if} - {#if activeProjects.length > 0} + {#if data.projects.total > 0} - {#each activeProjects as project} + {#each data.projects.projects as project} {@const platforms = filterPlatforms( project.platforms.map((platform) => getPlatformInfo(platform.type)) )} @@ -309,13 +330,16 @@ name="Projects" limit={data.limit} offset={data.offset} - total={data.projects.total} /> + total={activeTotalOverall} /> + currentPlan={$currentPlan} + archivedTotalOverall={data.archivedTotalOverall} + archivedOffset={data.archivedOffset} + limit={data.limit} /> diff --git a/src/routes/(console)/organization-[organization]/+page.ts b/src/routes/(console)/organization-[organization]/+page.ts index bdbb21114..fc4256b03 100644 --- a/src/routes/(console)/organization-[organization]/+page.ts +++ b/src/routes/(console)/organization-[organization]/+page.ts @@ -1,4 +1,5 @@ import { Query } from '@appwrite.io/console'; +import { isCloud } from '$lib/system'; import { sdk } from '$lib/stores/sdk'; import { getLimit, getPage, getSearch, pageToOffset } from '$lib/helpers/load'; import { CARD_LIMIT, Dependencies } from '$lib/constants'; @@ -19,25 +20,76 @@ export const load: PageLoad = async ({ params, url, route, depends, parent }) => const offset = pageToOffset(page, limit); const search = getSearch(url); - const projects = await sdk.forConsole.projects.list({ - queries: [ - Query.offset(offset), - Query.equal('teamId', params.organization), - Query.limit(limit), - Query.orderDesc('') - ], - search: search || undefined - }); + const archivedPageRaw = parseInt(url.searchParams.get('archivedPage') || '1', 10); + const archivedPage = + Number.isFinite(archivedPageRaw) && archivedPageRaw > 0 ? archivedPageRaw : 1; + const archivedOffset = pageToOffset(archivedPage, limit); + + const searchQueries = search + ? [Query.or([Query.search('search', search), Query.contains('labels', search)])] + : []; + const commonQueries = [Query.equal('teamId', params.organization)]; + const activeQueries = isCloud + ? [Query.or([Query.equal('status', 'active'), Query.isNull('status')])] + : []; + + const [activeProjects, archivedProjects, activeTotal, archivedTotal] = await Promise.all([ + sdk.forConsole.projects.list({ + queries: [ + Query.offset(offset), + Query.limit(limit), + Query.orderDesc(''), + ...commonQueries, + ...searchQueries, + ...activeQueries + ] + }), + isCloud + ? sdk.forConsole.projects.list({ + queries: [ + Query.offset(archivedOffset), + Query.limit(limit), + Query.orderDesc(''), + ...commonQueries, + ...searchQueries, + Query.equal('status', 'archived') + ] + }) + : Promise.resolve({ projects: [], total: 0 }), + sdk.forConsole.projects.list({ + queries: [...commonQueries, ...activeQueries, ...searchQueries] + }), + isCloud + ? sdk.forConsole.projects.list({ + queries: [...commonQueries, ...searchQueries, Query.equal('status', 'archived')] + }) + : Promise.resolve({ projects: [], total: 0 }) + ]); // set `default` if no region! - for (const project of projects.projects) { + for (const project of activeProjects.projects) { project.region ??= 'default'; } + if (isCloud) { + for (const project of archivedProjects.projects) { + project.region ??= 'default'; + } + } return { offset, limit, - projects, + projects: { + ...activeProjects, + projects: activeProjects.projects, + total: activeTotal.total + }, + activeProjectsPage: activeProjects.projects, + archivedProjectsPage: archivedProjects.projects, + activeTotalOverall: activeTotal.total, + archivedTotalOverall: archivedTotal.total, + archivedOffset, + archivedPage, search }; }; diff --git a/src/routes/(console)/organization-[organization]/billing/+page.svelte b/src/routes/(console)/organization-[organization]/billing/+page.svelte index e74989250..51d7fe353 100644 --- a/src/routes/(console)/organization-[organization]/billing/+page.svelte +++ b/src/routes/(console)/organization-[organization]/billing/+page.svelte @@ -9,7 +9,6 @@ import PaymentHistory from './paymentHistory.svelte'; import TaxId from './taxId.svelte'; import { failedInvoice, tierToPlan, upgradeURL, useNewPricingModal } from '$lib/stores/billing'; - import type { PaymentMethodData } from '$lib/sdk/billing'; import { onMount } from 'svelte'; import { page } from '$app/state'; import { confirmPayment } from '$lib/stores/stripe'; @@ -21,20 +20,15 @@ import { Alert } from '@appwrite.io/pink-svelte'; import { goto, invalidate } from '$app/navigation'; import { Dependencies } from '$lib/constants'; - import { base } from '$app/paths'; import type { PageData } from './$types'; + import { resolve } from '$app/paths'; export let data: PageData; - let organization = data.organization; - // why are these reactive? - $: defaultPaymentMethod = data?.paymentMethods?.paymentMethods?.find( - (method: PaymentMethodData) => method.$id === organization?.paymentMethodId - ); - - $: backupPaymentMethod = data?.paymentMethods?.paymentMethods?.find( - (method: PaymentMethodData) => method.$id === organization?.backupPaymentMethodId - ); + $: organization = data.organization; + $: baseUrl = resolve('/(console)/organization-[organization]/billing', { + organization: organization.$id + }); onMount(async () => { if (page.url.searchParams.has('type')) { @@ -56,7 +50,7 @@ organization.$id, invoice.clientSecret, organization.paymentMethodId, - `${base}/organization-${organization.$id}/billing?type=validate-invoice&invoice=${invoice.$id}` + `${baseUrl}?type=validate-invoice&invoice=${invoice.$id}` ); } @@ -113,7 +107,7 @@ {/if} {/if} - {#if defaultPaymentMethod?.failed && !backupPaymentMethod} + {#if data.primaryPaymentMethod?.failed && !data.backupPaymentMethod} @@ -133,7 +127,9 @@ availableCredit={data?.availableCredit} currentPlan={data?.currentPlan} nextPlan={data?.nextPlan} - currentAggregation={data?.billingAggregation} /> + currentAggregation={data?.billingAggregation} + limit={data?.limit} + offset={data?.offset} /> {:else} {/if} - + + + { +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 +22,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 +39,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 @@ -73,6 +83,7 @@ export const load: PageLoad = async ({ parent, depends }) => { // make number const credits = availableCredit ? availableCredit.available : null; + const { backup, primary } = getOrganizationPaymentMethods(organization, paymentMethods); return { paymentMethods, @@ -84,6 +95,37 @@ 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) + ), + + backupPaymentMethod: backup, + primaryPaymentMethod: primary }; }; + +function getOrganizationPaymentMethods( + organization: Organization, + paymentMethods: PaymentList +): { + backup: PaymentMethodData | null; + primary: PaymentMethodData | null; +} { + let backup: PaymentMethodData | null = null; + let primary: PaymentMethodData | null = null; + + for (const paymentMethod of paymentMethods.paymentMethods) { + if (paymentMethod.$id === organization.paymentMethodId) { + primary = paymentMethod; + } else if (paymentMethod.$id === organization.backupPaymentMethodId) { + backup = paymentMethod; + } + + if (primary && backup) break; + } + + return { primary, backup }; +} 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/deleteOrgPayment.svelte b/src/routes/(console)/organization-[organization]/billing/deleteOrgPayment.svelte index 8f6625179..1de90ac4a 100644 --- a/src/routes/(console)/organization-[organization]/billing/deleteOrgPayment.svelte +++ b/src/routes/(console)/organization-[organization]/billing/deleteOrgPayment.svelte @@ -25,7 +25,7 @@ message: `The payment method has been removed from ${$organization.name}` }); trackEvent(Submit.OrganizationPaymentDelete); - invalidate(Dependencies.ORGANIZATION); + await invalidate(Dependencies.PAYMENT_METHODS); showDelete = false; } catch (e) { error = e.message; @@ -34,7 +34,7 @@ showDelete = false; } } - async function removeBackuptMethod() { + async function removeBackupMethod() { if ($organization?.billingPlan !== BillingPlan.FREE && !hasOtherMethod) return; showDelete = false; @@ -45,7 +45,7 @@ message: `The payment method has been removed from ${$organization.name}` }); trackEvent(Submit.OrganizationBackupPaymentDelete); - invalidate(Dependencies.ORGANIZATION); + await invalidate(Dependencies.PAYMENT_METHODS); showDelete = false; } catch (e) { error = e.message; @@ -65,10 +65,10 @@ {:else} + title="Remove payment method" + onSubmit={isBackup ? removeBackupMethod : removeDefaultMethod}> Are you sure you want to remove the payment method from {$organization?.name}? diff --git a/src/routes/(console)/organization-[organization]/billing/paymentHistory.svelte b/src/routes/(console)/organization-[organization]/billing/paymentHistory.svelte index d8085642a..480e14f09 100644 --- a/src/routes/(console)/organization-[organization]/billing/paymentHistory.svelte +++ b/src/routes/(console)/organization-[organization]/billing/paymentHistory.svelte @@ -28,6 +28,7 @@ IconExternalLink, IconRefresh } from '@appwrite.io/pink-icons-svelte'; + import { addNotification } from '$lib/stores/notifications'; let limit = $state(5); let offset = $state(0); @@ -40,29 +41,24 @@ const endpoint = getApiEndpoint(); const hasPaymentError = $derived(invoiceList?.invoices.some((invoice) => invoice?.lastError)); - /** - * Special case handling for the first page! - * - * As per Damodar - `there is some logic to **hide current cycle invoice** in the endpoint`. - * - * Due to this, the first page always loads `limit - 1` invoices which is inconsistent! - * Therefore, we load `limit + 1` to counter that so the returned invoices are consistent. - */ - onMount(() => request(true)); + onMount(loadInvoices); - async function request(patchQuery: boolean = false) { + async function loadInvoices() { isLoadingInvoices = true; - invoiceList = await sdk.forConsole.billing.listInvoices(page.params.organization, [ - Query.orderDesc('$createdAt'), - - // first page extra must have an extra limit! - Query.limit(patchQuery ? limit + 1 : limit), - - // so an invoice isn't repeated on 2nd page! - Query.offset(patchQuery ? offset : offset + 1) - ]); - - isLoadingInvoices = false; + try { + invoiceList = await sdk.forConsole.billing.listInvoices(page.params.organization, [ + Query.orderDesc('$createdAt'), + Query.limit(limit), + Query.offset(offset) + ]); + } catch (error) { + addNotification({ + type: 'error', + message: error.message + }); + } finally { + isLoadingInvoices = false; + } } function retryPayment(invoice: Invoice) { @@ -73,7 +69,7 @@ $effect(() => { if (page.url.searchParams.get('type') === 'validate-invoice') { window.history.replaceState({}, '', page.url.pathname); - request(); + loadInvoices(); } }); @@ -85,7 +81,7 @@ ]); - + Payment history Transaction history for this organization. Download invoices for more details about your payments. @@ -197,7 +193,7 @@ hidePages bind:offset total={invoiceList.total} - on:change={() => request()} /> + on:change={loadInvoices} /> {/if} {:else} diff --git a/src/routes/(console)/organization-[organization]/billing/paymentMethods.svelte b/src/routes/(console)/organization-[organization]/billing/paymentMethods.svelte index 457e85e77..445c65d10 100644 --- a/src/routes/(console)/organization-[organization]/billing/paymentMethods.svelte +++ b/src/routes/(console)/organization-[organization]/billing/paymentMethods.svelte @@ -34,16 +34,17 @@ IconTrash } from '@appwrite.io/pink-icons-svelte'; - export let organization: Organization; export let methods: PaymentList; + export let organization: Organization; + + export let backupMethod: PaymentMethodData; + export let primaryMethod: PaymentMethodData; let showPayment = false; let showEdit = false; let showDelete = false; let showReplace = false; let isSelectedBackup = false; - let backupPaymentMethod: PaymentMethodData; - let defaultPaymentMethod: PaymentMethodData; async function addPaymentMethod(paymentMethodId: string) { try { @@ -56,7 +57,7 @@ message: `A new payment method has been added to ${organization.name}` }); trackEvent(Submit.OrganizationPaymentUpdate); - invalidate(Dependencies.ORGANIZATION); + await invalidate(Dependencies.PAYMENT_METHODS); } catch (error) { addNotification({ type: 'error', @@ -76,7 +77,7 @@ type: 'success', message: `A new payment method has been added to ${organization.name}` }); - invalidate(Dependencies.ORGANIZATION); + await invalidate(Dependencies.PAYMENT_METHODS); } catch (error) { addNotification({ type: 'error', @@ -86,30 +87,18 @@ } } - $: if (organization?.backupPaymentMethodId) { - sdk.forConsole.billing - .getOrganizationPaymentMethod(organization.$id, organization.backupPaymentMethodId) - .then((res) => (backupPaymentMethod = res)); - } - - $: if (organization?.paymentMethodId) { - sdk.forConsole.billing - .getOrganizationPaymentMethod(organization.$id, organization.paymentMethodId) - .then((res) => (defaultPaymentMethod = res)); - } - $: if (!showReplace) { isSelectedBackup = false; } $: hasPaymentError = - defaultPaymentMethod?.lastError || - defaultPaymentMethod?.expired || - backupPaymentMethod?.lastError || - backupPaymentMethod?.expired; + primaryMethod?.lastError || + primaryMethod?.expired || + backupMethod?.lastError || + backupMethod?.expired; - + Payment methods View or update your organization payment methods here. @@ -117,7 +106,7 @@ - + - {#if defaultPaymentMethod?.userId === $user?.$id} + {#if primaryMethod?.userId === $user?.$id} { @@ -171,14 +160,14 @@ {#if organization?.backupPaymentMethodId} - + - {#if backupPaymentMethod?.userId === $user?.$id} + {#if backupMethod?.userId === $user?.$id} { @@ -308,18 +297,18 @@ {#if showPayment && isCloud && hasStripePublicKey} { + onCardSubmit={(card) => { if (isSelectedBackup) { - addBackupPaymentMethod(e.detail.$id); + addBackupPaymentMethod(card.$id); } else { - addPaymentMethod(e.detail.$id); + addPaymentMethod(card.$id); } }} /> {/if} {#if showEdit && isCloud && hasStripePublicKey} + bind:show={showEdit} + selectedPaymentMethod={isSelectedBackup ? backupMethod : primaryMethod} /> {/if} {#if isCloud && hasStripePublicKey} 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/replaceCard.svelte b/src/routes/(console)/organization-[organization]/billing/replaceCard.svelte index a0d5de92d..920f7b474 100644 --- a/src/routes/(console)/organization-[organization]/billing/replaceCard.svelte +++ b/src/routes/(console)/organization-[organization]/billing/replaceCard.svelte @@ -13,17 +13,29 @@ import { PaymentBoxes } from '$lib/components/billing'; import type { PaymentMethod } from '@stripe/stripe-js'; - export let organization: Organization; - export let show = false; - export let isBackup = false; - export let methods: PaymentList; + let { + show = $bindable(false), + isBackup = false, + methods, + organization + }: { + show?: boolean; + isBackup?: boolean; + methods: PaymentList; + organization: Organization; + } = $props(); - let name: string; - let error: string; - let selectedPaymentMethodId: string; - let showState: boolean = false; - let state: string = ''; - let paymentMethod: PaymentMethod | null = null; + let name: string | null = $state(null); + let error: string | null = $state(null); + let showState: boolean = $state(false); + let countryState: string | null = $state(null); + let paymentMethod: PaymentMethod | null = $state(null); + let selectedPaymentMethodId: string | null = $state(null); + + const filteredMethods = $derived(methods?.paymentMethods.filter((method) => !!method?.last4)); + const submitEvent = $derived( + isBackup ? Submit.OrganizationBackupPaymentUpdate : Submit.OrganizationPaymentUpdate + ); onMount(async () => { if (!organization.paymentMethodId && !organization.backupPaymentMethodId) { @@ -45,12 +57,12 @@ async function handleSubmit() { try { if (selectedPaymentMethodId === '$new') { - if (showState && !state) { + if (showState && !countryState) { throw Error('Please select a state'); } let method: PaymentMethodData; if (showState) { - method = await setPaymentMethod(paymentMethod.id, name, state); + method = await setPaymentMethod(paymentMethod.id, name, countryState); } else { const card = await submitStripeCard(name, organization.$id); if (card && Object.hasOwn(card, 'id')) { @@ -69,21 +81,17 @@ ? await addBackupPaymentMethod(selectedPaymentMethodId) : await addPaymentMethod(selectedPaymentMethodId); + await invalidate(Dependencies.PAYMENT_METHODS); + addNotification({ type: 'success', message: `Your ${isBackup ? 'backup' : 'default'} payment method has been updated` }); - invalidate(Dependencies.ORGANIZATION); - trackEvent( - isBackup ? Submit.OrganizationBackupPaymentDelete : Submit.OrganizationPaymentDelete - ); + trackEvent(submitEvent); show = false; - } catch (e) { - error = e.message; - trackError( - e, - isBackup ? Submit.OrganizationBackupPaymentDelete : Submit.OrganizationPaymentDelete - ); + } catch (err) { + error = err.message; + trackError(err, submitEvent); } } @@ -93,8 +101,8 @@ organization.$id, paymentMethodId ); - } catch (e) { - error = e.message; + } catch (err) { + error = err.message; } } @@ -104,23 +112,21 @@ organization.$id, paymentMethodId ); - } catch (e) { - error = e.message; + } catch (err) { + error = err.message; } } - - $: filteredMethods = methods?.paymentMethods.filter((method) => !!method?.last4);

Replace the existing payment method for your organization.

-

Your payment of {formatCurrency(invoice.grossAmount)} due on {toLocaleDate( invoice.dueAt 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..d0228e2cc 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, @@ -107,10 +106,13 @@ } try { - allProjects = await sdk.forConsole.projects.list([ - Query.equal('teamId', data.organization.$id), - Query.limit(1000) - ]); + allProjects = await sdk.forConsole.projects.list({ + queries: [ + Query.equal('teamId', data.organization.$id), + Query.limit(1000), + Query.select(['$id', 'name']) + ] + }); } catch { allProjects = { projects: [] }; } @@ -140,30 +142,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 +159,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 +239,7 @@ data.organization.$id, selectedPlan, paymentMethodId, - null, + undefined, selectedCoupon?.code, newCollaborators, billingBudget, @@ -342,7 +327,10 @@ {/if} - + {#if isDowngrade && selectedPlan === BillingPlan.FREE && data.hasFreeOrgs} + size="xs" /> {/if} {:else if column.id === 'registrar'} diff --git a/src/routes/(console)/organization-[organization]/domains/add-domain/+page.svelte b/src/routes/(console)/organization-[organization]/domains/add-domain/+page.svelte index c6953c2e3..a1066a09a 100644 --- a/src/routes/(console)/organization-[organization]/domains/add-domain/+page.svelte +++ b/src/routes/(console)/organization-[organization]/domains/add-domain/+page.svelte @@ -1,6 +1,7 @@ - + {#each $columns as { id, title } (id)} diff --git a/src/routes/(console)/organization-[organization]/domains/recordsCard.svelte b/src/routes/(console)/organization-[organization]/domains/recordsCard.svelte index 6d3d8b41a..edfec5903 100644 --- a/src/routes/(console)/organization-[organization]/domains/recordsCard.svelte +++ b/src/routes/(console)/organization-[organization]/domains/recordsCard.svelte @@ -39,7 +39,7 @@ if (verified) { addNotification({ type: 'success', - message: 'Domain verified' + message: 'Domain verified successfully' }); await goto(routeBase); @@ -76,7 +76,7 @@ {#if verified === false} {:else if verified === true} @@ -84,8 +84,8 @@ {/if} - Add the following nameservers on your DNS provider. Note that changes may take up to - 48 hours to propagate fully. + Add the following nameservers on your DNS provider. Note that DNS changes may take + up to 48 hours to propagate fully. diff --git a/src/routes/(console)/organization-[organization]/domains/retryDomainModal.svelte b/src/routes/(console)/organization-[organization]/domains/retryDomainModal.svelte index de237930a..5dab1876e 100644 --- a/src/routes/(console)/organization-[organization]/domains/retryDomainModal.svelte +++ b/src/routes/(console)/organization-[organization]/domains/retryDomainModal.svelte @@ -32,17 +32,20 @@ const domain = await sdk.forConsole.domains.updateNameservers({ domainId: selectedDomain.$id }); - if (domain.nameservers.toLowerCase() === 'appwrite') { - show = false; - addNotification({ - type: 'success', - message: `${selectedDomain.domain} has been verified` - }); - } else { - error = - 'Domain verification failed. Please check your domain settings or try again later'; - } + await Promise.all([invalidate(Dependencies.DOMAIN), invalidate(Dependencies.DOMAINS)]); + + const verified = domain?.nameservers.toLowerCase() === 'appwrite'; + if (!verified) { + throw new Error( + 'Domain verification failed. Please check your domain settings or try again later' + ); + } + show = false; + addNotification({ + type: 'success', + message: 'Domain verified successfully' + }); trackEvent(Submit.DomainUpdateVerification); } catch (e) { error = e.message; @@ -64,8 +67,8 @@ >{selectedDomain.domain} - Add the following nameservers on your DNS provider. Note that changes may take up to 48 - hours to propagate fully. + Add the following nameservers on your DNS provider. Note that DNS changes may take up to + 48 hours to propagate fully. diff --git a/src/routes/(console)/organization-[organization]/header.svelte b/src/routes/(console)/organization-[organization]/header.svelte index 38ec2a743..191131ae4 100644 --- a/src/routes/(console)/organization-[organization]/header.svelte +++ b/src/routes/(console)/organization-[organization]/header.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/(console)/project-[region]-[project]/auth/+page.svelte b/src/routes/(console)/project-[region]-[project]/auth/+page.svelte index 80f6ed5cc..e8b9c7a29 100644 --- a/src/routes/(console)/project-[region]-[project]/auth/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/auth/+page.svelte @@ -1,4 +1,4 @@ - @@ -9,8 +9,11 @@ import { AvatarInitials, Copy, + type DeleteOperation, + type DeleteOperationState, Empty, EmptySearch, + MultiSelectionTable, PaginationWithLimit, SearchQuery } from '$lib/components'; @@ -20,14 +23,7 @@ import type { Models } from '@appwrite.io/console'; import { writable } from 'svelte/store'; import Create from './createUser.svelte'; - import { - Badge, - Icon, - Table, - Layout, - Typography, - FloatingActionBar - } from '@appwrite.io/pink-svelte'; + import { Badge, Icon, Table, Layout, Typography } from '@appwrite.io/pink-svelte'; import { Tag } from '@appwrite.io/pink-svelte'; import { IconDuplicate, IconPlus } from '@appwrite.io/pink-icons-svelte'; import { canWriteUsers } from '$lib/stores/roles'; @@ -37,11 +33,11 @@ import { sdk } from '$lib/stores/sdk'; import { Submit, trackError, trackEvent } from '$lib/actions/analytics'; import { Dependencies } from '$lib/constants'; - import { addNotification } from '$lib/stores/notifications'; import { invalidate } from '$app/navigation'; - import Confirm from '$lib/components/confirm.svelte'; - export let data; + import type { PageProps } from './$types'; + + let { data }: PageProps = $props(); const columns = writable([ { id: '$id', title: 'User ID', type: 'string', width: 200 }, @@ -59,45 +55,28 @@ } ]); - let selectedUsers: string[] = []; - let showDelete = false; - let deleting = false; - async function userCreated(event: CustomEvent>>) { await goto( `${base}/project-${page.params.region}-${page.params.project}/auth/user-${event.detail.$id}` ); } - async function handleDelete() { - showDelete = false; - deleting = true; - - const promises = selectedUsers.map((userId) => - sdk.forProject(page.params.region, page.params.project).users.delete(userId) + async function handleDelete(batchDelete: DeleteOperation): Promise { + const result = await batchDelete((userId) => + sdk.forProject(page.params.region, page.params.project).users.delete({ userId }) ); try { - await Promise.all(promises); - trackEvent(Submit.UserDelete, { - total: selectedUsers.length - }); - addNotification({ - type: 'success', - message: `${selectedUsers.length} user${selectedUsers.length > 1 ? 's' : ''} deleted` - }); - invalidate(Dependencies.USERS); - } catch (error) { - addNotification({ - type: 'error', - message: error.message - }); - trackError(error, Submit.UserDelete); + if (result.error) { + trackError(result.error, Submit.UserDelete); + } else { + trackEvent(Submit.UserDelete, { total: result.deleted.length }); + } } finally { - selectedUsers = []; - showDelete = false; - deleting = false; + await invalidate(Dependencies.USERS); } + + return result; } @@ -116,101 +95,111 @@ {#if data.users.total} - - + onDelete={handleDelete} + allowSelection={$canWriteUsers}> + {#snippet header(root)} {#each $columns as { id, title } (id)} {title} {/each} - - {#each data.users.users as user} - - {#each $columns as { id } (id)} - - {#if id === '$id'} - - - - {user.$id} - - - {:else if id === 'name'} - - {#if user.email || user.phone} - {#if user.name} - + {/snippet} + + {#snippet children(root)} + {#each data.users.users as user} + + {#each $columns as { id } (id)} + + {#if id === '$id'} + + + + {user.$id} + + + {:else if id === 'name'} + + {#if user.email || user.phone} + {#if user.name} + + + {user.name} + + {:else} +

+ +
+ {/if} + {:else} +
+ +
{user.name} - {:else} -
- -
{/if} + + {:else if id === 'identifiers'} + + {user.email && user.phone + ? [user.email, user.phone].join(',') + : user.email || user.phone} + + {:else if id === 'status'} + {#if user.status} + {@const success = + user.emailVerification || user.phoneVerification} + {:else} -
- -
- - {user.name} - + + {/if} + {:else if id === 'labels'} + + {user.labels.join(', ')} + + {:else if id === 'joined'} + + {:else if id === 'lastActivity'} + {#if user.accessedAt} + + {:else} + never {/if} - - {:else if id === 'identifiers'} - - {user.email && user.phone - ? [user.email, user.phone].join(',') - : user.email || user.phone} - - {:else if id === 'status'} - {#if user.status} - {@const success = - user.emailVerification || user.phoneVerification} - {:else} - + {user[id]} {/if} - {:else if id === 'labels'} - - {user.labels.join(', ')} - - {:else if id === 'joined'} - - {:else if id === 'lastActivity'} - {#if user.accessedAt} - - {:else} - never - {/if} - {:else} - {user[id]} - {/if} -
- {/each} - - {/each} -
+ + {/each} + + {/each} + {/snippet} + + {#snippet deleteContentNotice()} + This action is irreversible and will permanently remove the selected users and all + their data. + {/snippet} + + secondary + href={`${base}/project-${page.params.region}-${page.params.project}/auth`} + >Clear Search {:else} showCreateUser.set(true)} /> {/if} - - {#if selectedUsers.length > 0} - - - - - {selectedUsers.length > 1 ? 'users' : 'user'} - selected - - - - - - - - {/if} - - - - Are you sure you want to delete {selectedUsers.length} - {selectedUsers.length > 1 ? 'users' : 'user'}? - - - This action is irreversible and will permanently remove the selected users and all their - data. - - diff --git a/src/routes/(console)/project-[region]-[project]/auth/security/+page.svelte b/src/routes/(console)/project-[region]-[project]/auth/security/+page.svelte index 48718cba3..385cfe1f5 100644 --- a/src/routes/(console)/project-[region]-[project]/auth/security/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/auth/security/+page.svelte @@ -1,26 +1,23 @@ - - - - - + + diff --git a/src/routes/(console)/project-[region]-[project]/auth/security/passwordPolicies.svelte b/src/routes/(console)/project-[region]-[project]/auth/security/passwordPolicies.svelte new file mode 100644 index 000000000..22898b060 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/auth/security/passwordPolicies.svelte @@ -0,0 +1,159 @@ + + +
+ + Password policies + + + + + + Enabling this option prevents users from reusing recent passwords by + comparing the new password with their password history. + + {#if passwordHistoryEnabled} + + {/if} + + + + + + + + Enabling this option prevents users from setting insecure passwords by + comparing the user's password with the 10k most commonly used passwords. + + + + + + + + Do not allow passwords that contain any part of the user's personal data. + This includes the user's name, email, or phone. + + + + + + + + + +
diff --git a/src/routes/(console)/project-[region]-[project]/auth/security/sessionSecurity.svelte b/src/routes/(console)/project-[region]-[project]/auth/security/sessionSecurity.svelte new file mode 100644 index 000000000..6babbbecc --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/auth/security/sessionSecurity.svelte @@ -0,0 +1,92 @@ + + +
+ + Session security + + + + + Enabling this option will send an email to the users when a new session is + created. + + + + + + + + Enabling this option will clear all existing sessions when the user changes + their password. + + + + + + + + + +
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/security/updatePasswordDictionary.svelte b/src/routes/(console)/project-[region]-[project]/auth/security/updatePasswordDictionary.svelte deleted file mode 100644 index 0e921ab04..000000000 --- a/src/routes/(console)/project-[region]-[project]/auth/security/updatePasswordDictionary.svelte +++ /dev/null @@ -1,61 +0,0 @@ - - -
- - Password dictionary - - - - Enabling this option prevent users from setting insecure passwords by comparing the - user's password with the 10k most commonly used passwords. - - - - - - - -
diff --git a/src/routes/(console)/project-[region]-[project]/auth/security/updatePasswordHistory.svelte b/src/routes/(console)/project-[region]-[project]/auth/security/updatePasswordHistory.svelte deleted file mode 100644 index 0b4218487..000000000 --- a/src/routes/(console)/project-[region]-[project]/auth/security/updatePasswordHistory.svelte +++ /dev/null @@ -1,80 +0,0 @@ - - -
- - Password history - Set the maximum number of passwords saved per user. - - - - Enabling this option prevents users from reusing recent passwords by comparing the - new password with their password history. - - - - - - - - -
diff --git a/src/routes/(console)/project-[region]-[project]/auth/security/updatePersonalDataCheck.svelte b/src/routes/(console)/project-[region]-[project]/auth/security/updatePersonalDataCheck.svelte deleted file mode 100644 index 71f87c24d..000000000 --- a/src/routes/(console)/project-[region]-[project]/auth/security/updatePersonalDataCheck.svelte +++ /dev/null @@ -1,56 +0,0 @@ - - -
- - Personal data - - - - Do not allow passwords that contain any part of the user's personal data. This - includes the user's name, email, or phone. - - - - - - -
diff --git a/src/routes/(console)/project-[region]-[project]/auth/security/updateSessionAlerts.svelte b/src/routes/(console)/project-[region]-[project]/auth/security/updateSessionAlerts.svelte deleted file mode 100644 index ba8af9ec9..000000000 --- a/src/routes/(console)/project-[region]-[project]/auth/security/updateSessionAlerts.svelte +++ /dev/null @@ -1,54 +0,0 @@ - - -
- - Session alerts - - - - Enabling this option will send an email to the users when a new session is created. - - - - - - -
diff --git a/src/routes/(console)/project-[region]-[project]/auth/security/updateSessionInvalidation.svelte b/src/routes/(console)/project-[region]-[project]/auth/security/updateSessionInvalidation.svelte deleted file mode 100644 index c2c449e3e..000000000 --- a/src/routes/(console)/project-[region]-[project]/auth/security/updateSessionInvalidation.svelte +++ /dev/null @@ -1,56 +0,0 @@ - - -
- - Invalidate sessions - - - - Enabling this option will clear all existing sessions when the user changes their - password. - - - - - - - -
diff --git a/src/routes/(console)/project-[region]-[project]/auth/settings/+page.svelte b/src/routes/(console)/project-[region]-[project]/auth/settings/+page.svelte index 5dbfdb576..28a3896a1 100644 --- a/src/routes/(console)/project-[region]-[project]/auth/settings/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/auth/settings/+page.svelte @@ -77,7 +77,7 @@ .filter((p) => p.name !== 'Mock') .sort( (a, b) => (a.enabled === b.enabled ? 0 : a.enabled ? -1 : 1) ) as provider} {@const oAuthProvider = oAuthProviders[provider.key]} - {#if oAuthProvider} + {#if oAuthProvider && !oAuthProvider.internal} { diff --git a/src/routes/(console)/project-[region]-[project]/auth/teams/+page.svelte b/src/routes/(console)/project-[region]-[project]/auth/teams/+page.svelte index 3a64c2500..8a58aec9a 100644 --- a/src/routes/(console)/project-[region]-[project]/auth/teams/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/auth/teams/+page.svelte @@ -1,4 +1,4 @@ - @@ -10,7 +10,10 @@ EmptySearch, AvatarInitials, SearchQuery, - PaginationWithLimit + PaginationWithLimit, + type DeleteOperationState, + type DeleteOperation, + MultiSelectionTable } from '$lib/components'; import Create from '../createTeam.svelte'; import { goto } from '$app/navigation'; @@ -19,31 +22,16 @@ import type { Models } from '@appwrite.io/console'; import { writable } from 'svelte/store'; import { canWriteTeams } from '$lib/stores/roles'; - import { - Icon, - Layout, - Table, - FloatingActionBar, - Badge, - Typography - } from '@appwrite.io/pink-svelte'; + import { Icon, Layout, Table } from '@appwrite.io/pink-svelte'; import { IconPlus } from '@appwrite.io/pink-icons-svelte'; import DualTimeView from '$lib/components/dualTimeView.svelte'; import { sdk } from '$lib/stores/sdk'; import { Submit, trackError, trackEvent } from '$lib/actions/analytics'; import { Dependencies } from '$lib/constants'; - import { addNotification } from '$lib/stores/notifications'; import { invalidate } from '$app/navigation'; - import Confirm from '$lib/components/confirm.svelte'; + import type { PageProps } from './$types'; - export let data; - - const region = page.params.region; - const project = page.params.project; - - let selectedTeams: string[] = []; - let showDelete = false; - let deleting = false; + let { data }: PageProps = $props(); const columns = writable([ { id: 'name', title: 'Name', type: 'string', width: { min: 200, max: 300 } }, @@ -52,38 +40,27 @@ ]); const teamCreated = async (event: CustomEvent>>) => { - await goto(`${base}/project-${region}-${project}/auth/teams/team-${event.detail.$id}`); + await goto( + `${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${event.detail.$id}` + ); }; - async function handleDelete() { - showDelete = false; - deleting = true; - - const promises = selectedTeams.map((teamId) => - sdk.forProject(page.params.region, page.params.project).teams.delete(teamId) + async function handleDelete(batchDelete: DeleteOperation): Promise { + const result = await batchDelete((teamId) => + sdk.forProject(page.params.region, page.params.project).teams.delete({ teamId }) ); try { - await Promise.all(promises); - trackEvent(Submit.TeamDelete, { - total: selectedTeams.length - }); - addNotification({ - type: 'success', - message: `${selectedTeams.length} team${selectedTeams.length > 1 ? 's' : ''} deleted` - }); - invalidate(Dependencies.TEAMS); - } catch (error) { - addNotification({ - type: 'error', - message: error.message - }); - trackError(error, Submit.TeamDelete); + if (result.error) { + trackError(result.error, Submit.TeamDelete); + } else { + trackEvent(Submit.TeamDelete, { total: result.deleted.length }); + } } finally { - selectedTeams = []; - showDelete = false; - deleting = false; + await invalidate(Dependencies.TEAMS); } + + return result; } @@ -101,54 +78,47 @@ {#if data.teams.total} - - + onDelete={handleDelete} + allowSelection={$canWriteTeams}> + {#snippet header(root)} {#each $columns as { id, title }} {title} {/each} - - {#each data.teams.teams as team (team.$id)} - - {#each $columns as column} - - {#if column.id === 'name'} - - - {team.name} - - {:else if column.id === 'members'} - {team.total} members - {:else if column.id === 'created'} - - {/if} - - {/each} - - {/each} - + {/snippet} - {#if selectedTeams.length > 0} - - - - - {selectedTeams.length > 1 ? 'teams' : 'team'} - selected - - - - - - - - {/if} + {#snippet children(root)} + {@const TableRowComponent = $canWriteTeams ? Table.Row.Link : Table.Row.Base} + {#each data.teams.teams as team (team.$id)} + {@const href = $canWriteTeams + ? `${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${team.$id}` + : undefined} + + {#each $columns as column} + + {#if column.id === 'name'} + + + {team.name} + + {:else if column.id === 'members'} + {team.total} members + {:else if column.id === 'created'} + + {/if} + + {/each} + + {/each} + {/snippet} + + {#snippet deleteContentNotice()} + This action is irreversible and will permanently remove the selected teams and all + their memberships. + {/snippet} + {:else if data.search} - @@ -172,14 +145,3 @@ - - - - Are you sure you want to delete {selectedTeams.length} - {selectedTeams.length > 1 ? 'teams' : 'team'}? - - - This action is irreversible and will permanently remove the selected teams and all their - memberships. - - diff --git a/src/routes/(console)/project-[region]-[project]/auth/teams/team-[team]/members/+page.svelte b/src/routes/(console)/project-[region]-[project]/auth/teams/team-[team]/members/+page.svelte index 629c5bdda..d9368560f 100644 --- a/src/routes/(console)/project-[region]-[project]/auth/teams/team-[team]/members/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/auth/teams/team-[team]/members/+page.svelte @@ -1,51 +1,37 @@ @@ -85,71 +61,62 @@ {#if data.memberships.total} - - + {#snippet header(root)} Name Roles Joined - - {#each data.memberships.memberships as membership (membership.$id)} - {@const username = membership.userName ? membership.userName : '-'} - - - - - {username} - - - - {membership.roles} - - - - - - - - - {/each} - + {/snippet} - {#if selectedMemberships.length > 0} - - - - - {selectedMemberships.length > 1 ? 'memberships' : 'membership'} - selected - - - - - - - - {/if} + {#snippet children(root)} + {#each data.memberships.memberships as membership (membership.$id)} + {@const username = membership.userName ? membership.userName : '-'} + + + + + {username} + + + + {membership.roles} + + + + + + + + + {/each} + {/snippet} + + {#snippet deleteContentNotice()} + This action is irreversible and will remove the selected members from this team. + {/snippet} + - - invalidate(Dependencies.MEMBERSHIPS)} /> + invalidate(Dependencies.MEMBERSHIPS)} /> - - - Are you sure you want to delete {selectedMemberships.length} - {selectedMemberships.length > 1 ? 'memberships' : 'membership'}? - - - This action is irreversible and will remove the selected members from this team. - - + invalidate(Dependencies.MEMBERSHIPS)} /> 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]/auth/user-[user]/identities/table.svelte b/src/routes/(console)/project-[region]-[project]/auth/user-[user]/identities/table.svelte index 13e55efa8..e1f053b95 100644 --- a/src/routes/(console)/project-[region]-[project]/auth/user-[user]/identities/table.svelte +++ b/src/routes/(console)/project-[region]-[project]/auth/user-[user]/identities/table.svelte @@ -1,122 +1,95 @@ - - + + {#snippet header(root)} {#each columns as { id, title }} {title} {/each} - - {#each data.identities.identities as identity (identity.$id)} - - {#each columns as column} - - {#if column.id === '$id'} - {#key columns} - - {identity[column.id]} - - {/key} - {:else if column.id === 'provider'} - {@const provider = oAuthProviders[identity[column.id]]} -
-
- {provider.name} + {/snippet} + + {#snippet children(root)} + {#each data.identities.identities as identity (identity.$id)} + + {#each columns as column} + + {#if column.id === '$id'} + {#key columns} + + {identity[column.id]} + + {/key} + {:else if column.id === 'provider'} + {@const provider = oAuthProviders[identity[column.id]]} +
+
+ {provider.name} +
+ {provider.name}
- {provider.name} -
- {:else if column.type === 'datetime'} - {#if !identity[column.id]} - - + {:else if column.type === 'datetime'} + {#if !identity[column.id]} + - + {:else} + + {/if} {:else} - + {identity[column.id]} {/if} - {:else} - {identity[column.id]} - {/if} - - {/each} - - {/each} - - -{#if selectedIds.length > 0} - - - - - {selectedIds.length > 1 ? 'identities' : 'identity'} - selected - - - - - - - -{/if} - - - - Are you sure you want to delete {selectedIds.length} - {selectedIds.length > 1 ? 'identities' : 'identity'}? - - + + {/each} + + {/each} + {/snippet} + diff --git a/src/routes/(console)/project-[region]-[project]/auth/user-[user]/memberships/+page.svelte b/src/routes/(console)/project-[region]-[project]/auth/user-[user]/memberships/+page.svelte index 0deaed8da..6f2b29355 100644 --- a/src/routes/(console)/project-[region]-[project]/auth/user-[user]/memberships/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/auth/user-[user]/memberships/+page.svelte @@ -1,50 +1,37 @@ {#if data.memberships.total} - - + {#snippet header(root)} Name Roles Joined - - {#each data.memberships.memberships as membership} - - - - - {membership.teamName ? membership.teamName : 'n/a'} - - - - {membership.roles} - - - - - - - - - {/each} - + {/snippet} + + {#snippet children(root)} + {#each data.memberships.memberships as membership} + + + + + {membership.teamName ? membership.teamName : 'n/a'} + + + + {membership.roles} + + + + + + + + + {/each} + {/snippet} + + {#snippet deleteContentNotice()} + This action is irreversible and will remove the user from the selected teams. + {/snippet} + {:else} {/if} - - {#if selectedMemberships.length > 0} - - - - - {selectedMemberships.length > 1 ? 'memberships' : 'membership'} - selected - - - - - - - - {/if} - - - - Are you sure you want to delete {selectedMemberships.length} - {selectedMemberships.length > 1 ? 'memberships' : 'membership'}? - - - This action is irreversible and will remove the user from the selected teams. - - diff --git a/src/routes/(console)/project-[region]-[project]/auth/user-[user]/sessions/+page.svelte b/src/routes/(console)/project-[region]-[project]/auth/user-[user]/sessions/+page.svelte index b7def9138..77feda0fb 100644 --- a/src/routes/(console)/project-[region]-[project]/auth/user-[user]/sessions/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/auth/user-[user]/sessions/+page.svelte @@ -1,6 +1,13 @@ - - + + {#snippet header(root)} {#each $columns as { id, title }} {title} {/each} - - {#each data.targets.targets as target (target.$id)} - {@const provider = data.providersById[target.providerId]} - - {#each $columns as column} - - {#if column.id === '$id'} - {#key $columns} - - {target[column.id]} - - {/key} - {:else if column.id === 'target'} - {#if target.providerType === MessagingProviderType.Push} - {target.name} + {/snippet} + + {#snippet children(root)} + {#each data.targets.targets as target (target.$id)} + {@const provider = data.providersById[target.providerId]} + + {#each $columns as column} + + {#if column.id === '$id'} + {#key $columns} + + {target[column.id]} + + {/key} + {:else if column.id === 'target'} + {#if target.providerType === MessagingProviderType.Push} + {target.name} + {:else} + {target.identifier} + {/if} + {:else if column.id === 'providerType'} + + {:else if column.id === 'provider'} + {#if provider} + + {/if} + {:else if column.id === '$createdAt'} + {:else} - {target.identifier} + {target[column.id]} {/if} - {:else if column.id === 'providerType'} - - {:else if column.id === 'provider'} - {#if provider} - - {/if} - {:else if column.id === '$createdAt'} - - {:else} - {target[column.id]} - {/if} - - {/each} - - {/each} - - -{#if selectedIds.length > 0} - - - - - {selectedIds.length > 1 ? 'targets' : 'target'} - selected - - - - - - - -{/if} - - - - Are you sure you want to delete {selectedIds.length} - {selectedIds.length > 1 ? 'targets' : 'target'}? - - + + {/each} + + {/each} + {/snippet} + diff --git a/src/routes/(console)/project-[region]-[project]/databases/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/+page.svelte index e95c01cca..adba24b05 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/+page.svelte @@ -14,9 +14,11 @@ import Table from './table.svelte'; import { registerCommands } from '$lib/commandCenter'; import { canWriteDatabases } from '$lib/stores/roles'; - import { Icon } from '@appwrite.io/pink-svelte'; + import { Icon, Tooltip } from '@appwrite.io/pink-svelte'; import { IconPlus } from '@appwrite.io/pink-icons-svelte'; import EmptySearch from '$lib/components/emptySearch.svelte'; + import { isServiceLimited } from '$lib/stores/billing'; + import { organization } from '$lib/stores/organization'; export let data: PageData; @@ -30,6 +32,8 @@ ); } + $: isLimited = isServiceLimited('databases', $organization?.billingPlan, data.databases.total); + $: $registerCommands([ { label: 'Create database', @@ -37,7 +41,7 @@ showCreate = true; }, keys: ['c'], - disabled: showCreate || !$canWriteDatabases, + disabled: showCreate || !$canWriteDatabases || isLimited, icon: IconPlus, group: 'databases', rank: 10 @@ -52,10 +56,20 @@ bind:view={data.view} searchPlaceholder="Search by name or ID"> {#if $canWriteDatabases} - + +
+ +
+ + You have reached the maximum number of databases for your plan. + +
{/if} @@ -63,7 +77,11 @@ {#if data.view === 'grid'} {:else} - +
{/if} , policies: Record; + let lastBackups: Record = {}; + let policies: Record = {}; if (isCloud && backupsEnabled) { [policies, lastBackups] = await Promise.all([ @@ -86,19 +86,21 @@ async function fetchDatabasesAndBackups( async function fetchPolicies(databases: Models.DatabaseList, params: RouteParams) { if (isSelfHosted) return {}; - const databasePolicies: Record = {}; + const databasePolicies: Record = {}; await Promise.all( databases.databases.map(async (database) => { try { const { policies } = await sdk .forProject(params.region, params.project) - .backups.listPolicies([ - // TODO: are all needed!? - // Query.limit(3), - Query.equal('resourceType', 'database'), - Query.equal('resourceId', database.$id) - ]); + .backups.listPolicies({ + queries: [ + // TODO: are all needed!? + // Query.limit(3), + Query.equal('resourceType', 'database'), + Query.equal('resourceId', database.$id) + ] + }); if (policies.length > 0) { databasePolicies[database.$id] = policies; @@ -122,12 +124,14 @@ async function fetchLastBackups(databases: Models.DatabaseList, params: RoutePar try { const { archives } = await sdk .forProject(params.region, params.project) - .backups.listArchives([ - Query.limit(1), - Query.orderDesc('$createdAt'), - Query.equal('resourceType', 'database'), - Query.equal('resourceId', database.$id) - ]); + .backups.listArchives({ + queries: [ + Query.limit(1), + Query.orderDesc('$createdAt'), + Query.equal('resourceType', 'database'), + Query.equal('resourceId', database.$id) + ] + }); if (archives.length > 0) { lastBackups[database.$id] = timeFromNow(archives[0].$createdAt); diff --git a/src/routes/(console)/project-[region]-[project]/databases/create.svelte b/src/routes/(console)/project-[region]-[project]/databases/create.svelte index 4c4842282..3fc1736dd 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'; @@ -62,16 +61,14 @@ const totalPoliciesPromise = totalPolicies.map((policy) => { cronExpression(policy); - return sdk - .forProject(page.params.region, page.params.project) - .backups.createPolicy( - ID.unique(), - ['databases'], - policy.retained, - policy.schedule, - policy.label, - resourceId - ); + return sdk.forProject(page.params.region, page.params.project).backups.createPolicy({ + policyId: ID.unique(), + services: ['databases'], + retention: policy.retained, + schedule: policy.schedule, + name: policy.label, + resourceId + }); }); await Promise.all(totalPoliciesPromise); @@ -132,7 +129,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]/(observer)/columnObserver.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(observer)/columnObserver.ts new file mode 100644 index 000000000..28ced29eb --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(observer)/columnObserver.ts @@ -0,0 +1,65 @@ +import type { Columns } from '../table-[table]/store'; +import type { RealtimeResponse } from '$lib/stores/sdk'; + +export function setupColumnObserver() { + let expectedCount = 0; + let resolvePromise: () => void; + let timeout: ReturnType; + let isActive = true; + + const availableColumns = new Set(); + const waitPromise = new Promise((resolve) => (resolvePromise = resolve)); + + const columnCreationHandler = (response: RealtimeResponse) => { + if (!isActive) return; + + const { events, payload } = response; + + if ( + events.includes('databases.*.tables.*.columns.*.create') || + events.includes('databases.*.tables.*.columns.*.update') + ) { + const asColumn = payload as Columns; + const columnId = asColumn.key; + const status = asColumn.status; + + if (status === 'available') { + availableColumns.add(columnId); + + if (expectedCount > 0 && availableColumns.size >= expectedCount) { + clearTimeout(timeout); + cleanup(); + resolvePromise(); + } + } + } + }; + + const cleanup = () => { + isActive = false; + if (timeout) clearTimeout(timeout); + }; + + // return function to start waiting! + const startWaiting = (count: number) => { + expectedCount = count; + + timeout = setTimeout(() => { + cleanup(); + resolvePromise(); + }, 10000); + + if (availableColumns.size >= expectedCount) { + clearTimeout(timeout); + cleanup(); + resolvePromise(); + } + }; + + return { + cleanup, + waitPromise, + startWaiting, + columnCreationHandler + }; +} 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..00347261f 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,16 +8,20 @@ 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'; + import { sdk, realtime, type RealtimeResponse } from '$lib/stores/sdk'; import { page } from '$app/state'; + import { setupColumnObserver } from '../(observer)/columnObserver'; import { type ColumnInput, mapSuggestedColumns, @@ -38,8 +42,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 +106,44 @@ 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); + let columnCreationHandler: ((response: RealtimeResponse) => void) | null = 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 +166,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 +187,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 +217,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 +308,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 +351,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 +362,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 +492,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 +523,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 +569,7 @@ icon: IconFingerPrint, ...baseColProps }, + ...userColumnsWithWidth, ...finalCustomColumns, { id: 'actions', @@ -353,16 +583,27 @@ }; 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(() => { + columnsOrder.set(preferences.getColumnOrder(tableId)); + columnsWidth.set(preferences.getColumnWidths(tableId)); - onMount(async () => { if (spreadsheetContainer) { resizeObserver = new ResizeObserver(recalcAll); resizeObserver.observe(spreadsheetContainer); } requestAnimationFrame(recalcAll); - await suggestColumns(); + suggestColumns(); + + return realtime.forProject(page.params.region, ['project', 'console'], (response) => { + if (response.events.includes('databases.*.tables.*.columns.*')) { + columnCreationHandler?.(response); + } + }); }); function resetSuggestionsStore(fullReset: boolean = true) { @@ -374,20 +615,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 +716,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 +740,9 @@ hScroller.scrollLeft = currentScrollLeft; } }); + + // reset selection! + resetSelectedColumn(); } function updateColumn(columnId: string, updates: Partial) { @@ -515,6 +766,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,9 +961,29 @@ 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 { + startWaiting, + waitPromise, + columnCreationHandler: handler + } = setupColumnObserver(); + + columnCreationHandler = handler; + const results = []; for (const column of customColumns) { @@ -552,7 +991,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; @@ -642,6 +1082,9 @@ results.push(columnResult); } + startWaiting(customColumns.length); + await waitPromise; + await invalidate(Dependencies.TABLE); addNotification({ @@ -650,6 +1093,8 @@ timeout: NOTIFICATION_AND_MOCK_DELAY }); + resetSuggestionsStore(true); + // show index notification! showIndexSuggestionsNotification(); @@ -661,16 +1106,17 @@ message: error.message }); creatingColumns = false; + } finally { + columnCreationHandler = null; } } - 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 +1127,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 +1295,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 +1366,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 +1593,12 @@ data-collapsed-tabs={!$expandTabs}>
+
+
+ {#if $tableColumnSuggestions.thinking}
@@ -942,13 +1624,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 +1712,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/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/+page.svelte index 6c6a8de18..e64490541 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/+page.svelte @@ -66,14 +66,15 @@ const createManualBackup = async () => { try { - await sdk - .forProject(page.params.region, page.params.project) - .backups.createArchive(['databases'], data.database.$id); + await sdk.forProject(page.params.region, page.params.project).backups.createArchive({ + services: ['databases'], + resourceId: data.database.$id + }); + await invalidate(Dependencies.BACKUPS); addNotification({ type: 'success', message: 'Database backup has started' }); - invalidate(Dependencies.BACKUPS); trackEvent('click_manual_submit'); showFeedbackNotification(); } catch (error) { @@ -86,7 +87,7 @@ } }; - const trackEvents = (policies) => { + const trackEvents = (policies: UserBackupPolicy[]) => { policies.forEach((policy) => { let actualDay = null; const monthlyBackupFrequency = policy.monthlyBackupFrequency; @@ -119,16 +120,14 @@ const totalPoliciesPromise = totalPolicies.map((policy) => { cronExpression(policy); - return sdk - .forProject(page.params.region, page.params.project) - .backups.createPolicy( - ID.unique(), - ['databases'], - policy.retained, - policy.schedule, - policy.label, - data.database.$id - ); + return sdk.forProject(page.params.region, page.params.project).backups.createPolicy({ + policyId: ID.unique(), + services: ['databases'], + retention: policy.retained, + schedule: policy.schedule, + name: policy.label, + resourceId: data.database.$id + }); }); try { @@ -139,7 +138,6 @@ ? `Backup policies have been created` : `${totalPolicies[0].label} policy has been created`; - // TODO: html isn't yet supported on Toast. addNotification({ isHtml: true, type: 'success', @@ -148,7 +146,7 @@ trackEvents(totalPolicies); - invalidate(Dependencies.BACKUPS); + await invalidate(Dependencies.BACKUPS); showFeedbackNotification(); } catch (err) { addNotification({ @@ -162,19 +160,14 @@ }; onMount(() => { - return realtime - .forProject(page.params.region, page.params.project) - .subscribe(['project', 'console'], (response) => { - // fast path return. - if (!response.channels.includes(`projects.${getProjectId()}`)) return; + return realtime.forProject(page.params.region, ['project', 'console'], (response) => { + // fast path return. + if (!response.channels.includes(`projects.${getProjectId()}`)) return; - if ( - response.events.includes('archives.*') || - response.events.includes('policies.*') - ) { - invalidate(Dependencies.BACKUPS); - } - }); + if (response.events.includes('archives.*') || response.events.includes('policies.*')) { + invalidate(Dependencies.BACKUPS); + } + }); }); diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/+page.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/+page.ts index 52d9e65c9..60d9bfb21 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/+page.ts @@ -2,8 +2,8 @@ import { getLimit, getPage, getView, pageToOffset, View } from '$lib/helpers/loa import { Dependencies, PAGE_LIMIT } from '$lib/constants'; import { sdk } from '$lib/stores/sdk'; import { Query } from '@appwrite.io/console'; -import type { BackupArchive, BackupArchiveList, BackupPolicyList } from '$lib/sdk/backups'; import { isCloud } from '$lib/system'; +import { type Models } from '@appwrite.io/console'; export const load = async ({ params, url, route, depends, parent }) => { depends(Dependencies.BACKUPS); @@ -13,8 +13,8 @@ export const load = async ({ params, url, route, depends, parent }) => { const view = getView(url, route, View.Grid); const offset = pageToOffset(page, limit); - let backups: BackupArchiveList = { total: 0, archives: [] }; - let policies: BackupPolicyList = { total: 0, policies: [] }; + let backups: Models.BackupArchiveList = { total: 0, archives: [] }; + let policies: Models.BackupPolicyList = { total: 0, policies: [] }; // already loaded by parent. const { currentPlan } = await parent(); @@ -23,23 +23,23 @@ export const load = async ({ params, url, route, depends, parent }) => { if (isCloud && backupsEnabled) { try { [backups, policies] = await Promise.all([ - sdk - .forProject(params.region, params.project) - .backups.listArchives([ + sdk.forProject(params.region, params.project).backups.listArchives({ + queries: [ Query.limit(limit), Query.offset(offset), Query.orderDesc('$createdAt'), Query.equal('resourceType', 'database'), Query.equal('resourceId', params.database) - ]), + ] + }), - sdk - .forProject(params.region, params.project) - .backups.listPolicies([ + sdk.forProject(params.region, params.project).backups.listPolicies({ + queries: [ Query.orderDesc('$createdAt'), Query.equal('resourceType', 'database'), Query.equal('resourceId', params.database) - ]) + ] + }) ]); } catch (e) { // ignore @@ -59,17 +59,17 @@ export const load = async ({ params, url, route, depends, parent }) => { }; }; -const groupArchivesByPolicy = (archives: BackupArchive[]) => { +const groupArchivesByPolicy = (archives: Models.BackupArchive[]) => { return archives.reduce((acc, archive) => { if (!acc.has(archive.policyId)) { acc.set(archive.policyId, []); } acc.get(archive.policyId)!.push(archive); return acc; - }, new Map()); + }, new Map()); }; -const getLatestBackupForPolicies = (policyIdMap: Map) => { +const getLatestBackupForPolicies = (policyIdMap: Map) => { const latestBackups = new Map(); for (const [policyId, archives] of policyIdMap) { const latestBackup = archives.sort( 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]/backups/policy.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/policy.svelte index e4ccd8b91..f73fc68a9 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/policy.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/policy.svelte @@ -11,7 +11,6 @@ import { toLocaleDateTime } from '$lib/helpers/date'; import EmptyDark from '$lib/images/backups/backups-dark.png'; import EmptyLight from '$lib/images/backups/backups-light.png'; - import type { BackupPolicy, BackupPolicyList } from '$lib/sdk/backups'; import { backupFrequencies } from '$lib/helpers/backups'; import { Click, trackEvent } from '$lib/actions/analytics'; import { @@ -27,21 +26,22 @@ import { Confirm } from '$lib/components/index.js'; import Ellipse from './components/Ellipse.svelte'; import { page } from '$app/state'; + import { type Models } from '@appwrite.io/console'; let showDelete = false; - let selectedPolicy: BackupPolicy = null; + let selectedPolicy: Models.BackupPolicy = null; let showEveryPolicy = false; export let showCreatePolicy = false; - export let policies: BackupPolicyList; + export let policies: Models.BackupPolicyList; export let lastBackupDates: Record; async function deletePolicy() { try { - await sdk - .forProject(page.params.region, page.params.project) - .backups.deletePolicy(selectedPolicy.$id); + await sdk.forProject(page.params.region, page.params.project).backups.deletePolicy({ + policyId: selectedPolicy.$id + }); addNotification({ type: 'success', message: 'Backup policy has been deleted' diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/table.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/table.svelte index 689afe74f..5cd807592 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/table.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/table.svelte @@ -1,5 +1,12 @@ - - + + {#snippet header(root)} {#each $columns as column} {column.title} {/each} - + {/snippet} - {#each data.backups.archives as backup, index} - {@const policy = policyDetails(backup.policyId)} - {@const retainedUntil = new Date( - new Date(policy?.$createdAt).getTime() + policy?.retention * 24 * 60 * 60 * 1000 - )} - {@const formattedRetainedUntil = `${retainedUntil.getDate()} ${retainedUntil.toLocaleString('en-US', { month: 'short' })}, ${retainedUntil.getFullYear()} ${retainedUntil.toLocaleTimeString('en-US', { hour12: false })}`} - - - - {cleanBackupName(backup)} - - - - {#if backup.status === 'completed'} - {calculateSize(backup.size)} - {:else} - - - {/if} - - - {@const backupStatus = getBackupStatus(backup)} - - - - - - -
- - - {policy?.name || 'Manual'} - - {policy - ? `Retained until: ${formattedRetainedUntil}` - : `Retained forever`} - -
-
- -
- - - - - {#if backup.status === 'completed'} + {#snippet children(root)} + {#each data.backups.archives as backup, index} + {@const policy = getPolicyDetails(backup.policyId)} + {@const retainedUntil = new Date( + new Date(policy?.$createdAt).getTime() + policy?.retention * 24 * 60 * 60 * 1000 + )} + {@const formattedRetainedUntil = `${retainedUntil.getDate()} ${retainedUntil.toLocaleString('en-US', { month: 'short' })}, ${retainedUntil.getFullYear()} ${retainedUntil.toLocaleTimeString('en-US', { hour12: false })}`} + + + + {getCleanBackupName(backup)} + + + + {#if backup.status === 'completed'} + {calculateSize(backup.size)} + {:else} + - + {/if} + + + {@const backupStatus = getBackupStatus(backup)} + + + + + + +
+ + + {policy?.name || 'Manual'} + + {policy + ? `Retained until: ${formattedRetainedUntil}` + : `Retained forever`} + +
+
+ +
+ + + + + {#if backup.status === 'completed'} + { + toggle(e); + showRestore = true; + selectedBackup = backup; + showDropdown[index] = false; + trackEvent(Click.BackupRestoreClick); + }}> + Restore + + {/if} { toggle(e); - showRestore = true; + copy(backup.$id); + showDropdown[index] = false; + trackEvent(Click.BackupCopyIdClick); + }}> + Copy ID + + { + toggle(e); + showDelete = true; selectedBackup = backup; showDropdown[index] = false; - trackEvent(Click.BackupRestoreClick); + trackEvent(Click.BackupDeleteClick); }}> - Restore + Delete - {/if} - { - toggle(e); - copy(backup.$id); - showDropdown[index] = false; - trackEvent(Click.BackupCopyIdClick); - }}> - Copy ID - - { - toggle(e); - showDelete = true; - selectedBackup = backup; - showDropdown[index] = false; - trackEvent(Click.BackupDeleteClick); - }}> - Delete - - - - -
-
-
- {/each} - - -{#if selectedBackups.length > 0} - - - - - {selectedBackups.length > 1 ? 'backups' : 'backup'} - selected - - - - - - - -{/if} +
+
+
+
+
+
+ {/each} + {/snippet} + + + onSubmit={async () => { + if (!selectedBackup) return; + await deleteSingleBackup(selectedBackup.$id); + }}> - Are you sure you want to delete - {#if selectedBackups.length} - {selectedBackups.length} {selectedBackups.length > 1 ? 'backups' : 'backup'}? - {:else} - the {cleanBackupName(selectedBackup)} backup? - {/if} -
This action is irreversible. + Are you sure you want to delete the {getCleanBackupName(selectedBackup)} backup?
+ + This action is irreversible.
- {cleanBackupName(selectedBackup)} + {getCleanBackupName(selectedBackup)} @@ -374,6 +389,6 @@ - + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/subNavigation.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/subNavigation.svelte index 4b5354ffd..31bb9d04a 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/subNavigation.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/subNavigation.svelte @@ -13,7 +13,8 @@ Layout, Link, Typography, - Divider + Divider, + Skeleton } from '@appwrite.io/pink-svelte'; import { IconChevronDown, @@ -37,6 +38,7 @@ const databaseId = $derived(page.params.database); let openBottomSheet = $state(false); + let loading = $state(true); let tables = $state({ total: 0, @@ -62,10 +64,14 @@ const tableContentPadding = $derived($bannerSpacing ? '210px' : '140px'); async function loadTables() { - tables = await sdk.forProject(region, project).tablesDB.listTables({ - databaseId: databaseId, - queries: [Query.orderDesc(''), Query.limit(100)] - }); + try { + tables = await sdk.forProject(region, project).tablesDB.listTables({ + databaseId: databaseId, + queries: [Query.orderDesc(''), Query.limit(100)] + }); + } finally { + loading = false; + } } onMount(() => { @@ -94,7 +100,20 @@ {data.database?.name}
- {#if tables?.total} + {#if loading} +
    + {#each Array(2) as _} + +
  • +
    + +
    +
  • +
    + {/each} +
+ {:else if tables?.total}
    {#each sortedTables as table, index} {@const href = `${base}/project-${region}-${project}/databases/database-${databaseId}/table-${table.$id}`} @@ -390,6 +409,8 @@ left: 1.25rem; position: absolute; padding-block-end: 1rem; + z-index: 1; + background: var(--bgcolor-neutral-primary); } .action-menu-divider { 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 19aa513a5..7a73c20c9 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 @@ -20,7 +20,7 @@ @@ -397,24 +436,78 @@ await editRow?.update() }} topAction={{ mode: 'copy-tag', text: 'Row URL', - show: !!($databaseRowSheetOptions.rowId ?? $databaseRowSheetOptions.row?.$id), - value: buildRowUrl($databaseRowSheetOptions.rowId ?? $databaseRowSheetOptions.row?.$id) + show: !!currentRowId, + value: buildRowUrl(currentRowId) }}> - + {#snippet topEndActions()} + {@const rows = $databaseRowSheetOptions.rows ?? []} + {@const currentIndex = $databaseRowSheetOptions.rowIndex ?? -1} + {@const isFirstRow = currentIndex <= 0} + {@const isLastRow = currentIndex >= rows.length - 1} + + {#if !$isTabletViewport} + {@const shouldFocusPrev = !$databaseRowSheetOptions.autoFocus && !isFirstRow} + {@const shouldFocusNext = + !$databaseRowSheetOptions.autoFocus && isFirstRow && !isLastRow} + +
    + +
    + +
    + +
    + {/if} + {/snippet} + + {#key currentRowId} + + {/key}
    await editRelatedRow?.update() }}> + tableId={$databaseRelatedRowSheetOptions.tableId} + bind:disabledState={editRelatedRowDisabled} /> editRowPermissions?.updatePermissions() }}> - + @@ -482,4 +579,13 @@ + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+page.svelte index df77376fa..5483e87a3 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+page.svelte @@ -6,7 +6,7 @@ import { Container } from '$lib/layout'; import { preferences } from '$lib/stores/preferences'; import { canWriteTables, canWriteRows } from '$lib/stores/roles'; - import { Icon, Layout, Divider, Tooltip } from '@appwrite.io/pink-svelte'; + import { Icon, Layout, Divider, Tooltip, Typography, Link } from '@appwrite.io/pink-svelte'; import type { PageData } from './$types'; import { table, @@ -26,16 +26,32 @@ import { addNotification } from '$lib/stores/notifications'; import { Click, Submit, trackError, trackEvent } from '$lib/actions/analytics'; import { isSmallViewport } from '$lib/stores/viewport'; - import { IconChevronDown, IconChevronUp, IconPlus } from '@appwrite.io/pink-icons-svelte'; + import { + IconBookOpen, + IconChevronDown, + IconChevronUp, + IconPlus, + IconViewBoards, + IconRefresh + } from '@appwrite.io/pink-icons-svelte'; import type { Models } from '@appwrite.io/console'; import EmptySheet from './layout/emptySheet.svelte'; import CreateRow from './rows/create.svelte'; import { onDestroy } from 'svelte'; import { isCloud } from '$lib/system'; - import { Empty as SuggestionsEmptySheet, tableColumnSuggestions } from '../(suggestions)'; + import { invalidate } from '$app/navigation'; + import { Dependencies } from '$lib/constants'; + import { + Empty as SuggestionsEmptySheet, + tableColumnSuggestions, + showColumnsSuggestionsModal + } from '../(suggestions)'; + import EmptySheetCards from './layout/emptySheetCards.svelte'; + import IconAI from '../(suggestions)/icon/aiForButton.svelte'; export let data: PageData; + let isRefreshing = false; let showImportCSV = false; // todo: might need a type fix here. @@ -83,13 +99,15 @@ $tableColumnSuggestions.table && $tableColumnSuggestions.table.id === page.params.table; + $: disableButton = canShowSuggestionsSheet; + async function onSelect(file: Models.File, localFile = false) { $isCsvImportInProgress = true; try { await sdk .forProject(page.params.region, page.params.project) - .migrations.createCsvMigration({ + .migrations.createCSVImport({ bucketId: file.bucketId, fileId: file.$id, resourceId: `${page.params.database}:${page.params.table}`, @@ -130,7 +148,8 @@ columns={tableColumns} hideView showAnyway - isCustomTable /> + isCustomTable + {disableButton} />
Columns @@ -141,49 +160,84 @@ onlyIcon query={data.query} columns={filterColumns} - disabled={!(hasColumns && hasValidColumns)} + disabled={!(hasColumns && hasValidColumns) || disableButton} analyticsSource="database_tables" /> Filters - - - {#if !$isSmallViewport} + + + {#if !$isSmallViewport} + - - {/if} + + + + + + Refresh + + {/if} + {#if $isSmallViewport} + {/snippet} + {:else} { + customColumns={createTableColumns($table.columns, selected)}> + {#snippet actions()} + { $showRowCreateSheet.show = true; - } - }, - random: { - onClick: () => { + }} /> + + { $randomDataModalState.show = true; - } - } - }} /> + }} /> + {/snippet} + {/if} {:else if isCloud && canShowSuggestionsSheet} - + {:else} - { + + {#snippet subtitle()} + {#if !isCloud} + + + Need a hand? Learn more in the + + docs. + + + {/if} + {/snippet} + + {#snippet actions()} + {#if isCloud} + + { + $showColumnsSuggestionsModal = true; + }} /> + {/if} + + { $showCreateColumnSheet.show = true; - } - }, - random: { - onClick: () => { + }} /> + + { $randomDataModalState.show = true; - } - } - }} /> + }} /> + + {#if isCloud} + + + {/if} + {/snippet} + {/if}
{/key} @@ -282,4 +380,17 @@ width: 32px !important; height: 32px !important; } + + :global(.rotating) { + animation: rotate 1s linear infinite; + } + + @keyframes rotate { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/+page.svelte index 5ee690f49..08d9192f4 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/+page.svelte @@ -23,6 +23,7 @@ type ColumnsWidth, indexes, isCsvImportInProgress, + isWaterfallFromFaker, reorderItems, showCreateIndexSheet } from '../store'; @@ -55,6 +56,9 @@ import { page } from '$app/state'; import { debounce } from '$lib/helpers/debounce'; import type { PageData } from './$types'; + import { realtime } from '$lib/stores/sdk'; + import { invalidate } from '$app/navigation'; + import { Dependencies } from '$lib/constants'; const { data @@ -140,6 +144,16 @@ onMount(() => { columnsOrder = preferences.getColumnOrder(tableId); columnsWidth = preferences.getColumnWidths(tableId + '#columns'); + + return realtime.forProject(page.params.region, ['project', 'console'], async (response) => { + if ( + response.events.includes('databases.*.tables.*.columns.*.delete') || + (response.events.includes('databases.*.tables.*.columns.*.update') && + !$isWaterfallFromFaker) + ) { + await invalidate(Dependencies.TABLE); + } + }); }); function getColumnStatusBadge(status: string): ComponentProps['type'] { @@ -368,8 +382,9 @@ {column.key}{column.array ? '[]' : undefined} {/if} + {#if isString(column) && column.encrypt} - + Encrypted
{/if} - - + {#if column.status !== 'available'} import { InputSelect } from '$lib/elements/forms'; + import { createConservative } from '$lib/helpers/stores'; + import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte'; + export let editing = false; + export let disabled = false; export let data: Partial = { required: false, array: false, default: null }; - import { createConservative } from '$lib/helpers/stores'; - import { Selector } from '@appwrite.io/pink-svelte'; - let savedDefault = data.default; function handleDefaultState(hideDefault: boolean) { @@ -67,6 +68,7 @@ array: false, ...data }); + $: listen(data); $: handleDefaultState($required || $array); @@ -76,24 +78,16 @@ id="default" label="Default value" placeholder="Select a value" - disabled={data.required || data.array} + disabled={data.required || data.array || disabled} options={[ { label: 'NULL', value: null }, { label: 'True', value: true }, { label: 'False', value: false } ]} bind:value={data.default} /> - - + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/datetime.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/datetime.svelte index 827d77798..f037cf46f 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/datetime.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/datetime.svelte @@ -42,13 +42,13 @@ + + +
+ +
+ + + Required cannot be selected because array columns may contain more than one value. + +
+ + +
+ +
+ + + {#if editing} + Array cannot be selected to avoid data incompatibility. + {:else} + Array cannot be selected because required columns must be populated in all rows with a + single value. + {/if} + +
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/string.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/string.svelte index cc631ae82..84ef6f706 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/string.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/string.svelte @@ -44,6 +44,7 @@ import { currentPlan } from '$lib/stores/organization'; import { createConservative } from '$lib/helpers/stores'; import { ActionMenu, Selector } from '@appwrite.io/pink-svelte'; + import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte'; import { InputNumber, InputText, InputTextarea } from '$lib/elements/forms'; import { Popover, Layout, Tag, Typography, Link } from '@appwrite.io/pink-svelte'; @@ -55,6 +56,8 @@ }; export let editing = false; + export let disabled = false; + export let autoIncreaseSize = false; let savedDefault = data.default; @@ -82,12 +85,17 @@ // Check plan on cloud, always allow on self-hosted $: supportsStringEncryption = isCloud ? $currentPlan?.databasesAllowEncrypt : true; + + $: if (autoIncreaseSize && data.encrypt && data.size < 150) { + data.size = 150; + } - - - +
+ class:cursor-not-allowed={editing || disabled} + class:disabled-checkbox={!supportsStringEncryption || editing || disabled}> + disabled={!supportsStringEncryption || editing || disabled} /> + + + + Expand row + + + + + + + + + + + + {:else} + + {#if columnId === '$createdAt' || columnId === '$updatedAt'} + + {:else if columnId === 'actions'} + { + $databaseRowSheetOptions.autoFocus = true; + onSelectSheetOption(option, null, 'row', row); + }} + onVisibilityChanged={(visible) => { + canShowDatetimePopover = !visible; + }}> + {#snippet children(toggle)} + + + + {/snippet} + + {:else if isRelationship(rowColumn)} + {@const args = getDisplayNamesForTable(row[columnId])} + {#if !isRelationshipToMany(rowColumn)} + {#if row[columnId]} + {@const displayValue = args + .map((arg) => row[columnId]?.[arg]) + .filter(Boolean) + .join(' | ')} + + {#if displayValue} + { + $databaseRelatedRowSheetOptions.tableId = + row[columnId]?.['$tableId']; + $databaseRelatedRowSheetOptions.rows = + row[columnId]?.['$id']; + $databaseRelatedRowSheetOptions.show = true; + }}> + {displayValue} + + {:else} + + {/if} {:else} {/if} {:else} - + {@const itemsNum = row[columnId]?.length} + Items {/if} + {:else if isSpatialType(rowColumn) && row[columnId] !== null} + + {JSON.stringify(row[columnId])} + {:else} - {@const itemsNum = row[columnId]?.length} - Items - {/if} - {:else if isSpatialType(rowColumn) && row[columnId] !== null} - - {JSON.stringify(row[columnId])} - - {:else} - {@const value = row[columnId]} - {@const formatted = formatColumn(row[columnId])} - {@const isEmptyArray = formatted === 'Empty'} - {@const isDatetimeAttribute = rowColumn.type === 'datetime'} - {@const isEncryptedAttribute = - isString(rowColumn) && rowColumn.encrypt} - {#if isDatetimeAttribute} - - Timestamp - {toLocaleDateTime(value, true)} - - {:else if isEncryptedAttribute} - - {:else if formatted.length > 20} - + {@const value = row[columnId]} + {@const formatted = formatColumn(row[columnId])} + {@const isEmptyArray = formatted === 'Empty'} + {@const isDatetimeAttribute = rowColumn.type === 'datetime'} + {@const isEncryptedAttribute = + isString(rowColumn) && rowColumn.encrypt} + {#if isDatetimeAttribute} + + Timestamp + {toLocaleDateTime(value, true)} + + {:else if isEncryptedAttribute} + + {:else if formatted.length > 20} + + + {formatted} + + + {formatted} + + + {:else if formatted === 'null'} + + {:else if isEmptyArray} + + {:else} {formatted} - - {formatted} - - - {:else if formatted === 'null'} - - {:else if isEmptyArray} - - {:else} - - {formatted} - + {/if} {/if} - {/if} - - {@const isRelatedToMany = isRelationshipToMany(rowColumn)} - {@const hasItems = isRelatedToMany - ? row[columnId]?.length - : false} + + {@const isRelatedToMany = isRelationshipToMany(rowColumn)} + {@const hasItems = isRelatedToMany + ? row[columnId]?.length + : false} - paginatedRows.update(index, row)} - onRevert={(row) => paginatedRows.update(index, row)} - openSideSheet={() => { - close(); /* closes the editor */ + { + const success = await updateRowContents(row); + if (success) { + // database update succeeded! + paginatedRows.update(index, row); + } + return success; + }} + noInlineEdit={isRelatedToMany && hasItems} + onChange={(row) => paginatedRows.update(index, row)} + onRevert={(row) => paginatedRows.update(index, row)} + openSideSheet={() => { + close(); /* closes the editor */ - if (isRelationshipToMany(rowColumn)) { - openSideSheetForRelationsToMany( - row[columnId], - rowColumn - ); - } else { - onSelectSheetOption('update', null, 'row', row); - } - }} /> - - + if (isRelationshipToMany(rowColumn)) { + openSideSheetForRelationsToMany( + row[columnId], + rowColumn + ); + } else { + $databaseRowSheetOptions.autoFocus = true; + onSelectSheetOption('update', null, 'row', row); + } + }} /> + + + {/if} {/each} {/if} @@ -1042,7 +1166,8 @@ gap="xs" direction="row" alignItems="center" - alignContent="center"> + alignContent="center" + class="footer-input-select-wrapper"> Page $page.data.table as Table); export const columns = derived(page, ($page) => $page.data.table.columns as Columns[]); export const indexes = derived(page, ($page) => $page.data.table.indexes as Models.ColumnIndex[]); +/** + * adding a lot of fake data will trigger the realtime below + * and will keep invalidating the `Dependencies.TABLE` making a lot of API noise! + */ +export const isWaterfallFromFaker = writable(false); + export const tableColumns = writable([]); export const isCsvImportInProgress = writable(false); @@ -64,12 +70,18 @@ export const databaseRowSheetOptions = writable< DatabaseSheetOptions & { row: Models.Row; rowId?: string; + rows: Models.Row[]; + rowIndex?: number; + autoFocus?: boolean; } >({ title: null, show: false, row: null, - rowId: null // for loading from a given id + rowId: null, // for loading from a given id + rows: [], + rowIndex: -1, + autoFocus: true }); export const databaseRelatedRowSheetOptions = writable< @@ -181,7 +193,9 @@ export const expandTabs = writable(null); export const spreadsheetRenderKey = writable('initial'); export const paginatedRowsLoading = writable(false); -export const paginatedRows = createSparsePagedDataStore(SPREADSHEET_PAGE_LIMIT); +export const paginatedRows = createSparsePagedDataStore( + SPREADSHEET_PAGE_LIMIT +); export const PROHIBITED_ROW_KEYS = [ '$id', diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table.svelte index 28e5c2362..6a5c73352 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table.svelte @@ -1,110 +1,93 @@ - - + {onDelete}> + {#snippet header(root)} {#each $tableViewColumns as { id, title }} {title} {/each} - - {#each data.tables.tables as table (table.$id)} - - {#each $tableViewColumns as column} - - {#if column.id === '$id'} - {#key $tableViewColumns} - {table.$id} - {/key} - {:else if column.id === 'name'} - {table.name} - {:else} - - {/if} - - {/each} - - {/each} - + {/snippet} -{#if selectedTables.length > 0} - - - - - {selectedTables.length > 1 ? 'tables' : 'table'} - selected - - - - - - - -{/if} - - - - Are you sure you want to delete {selectedTables.length} - {selectedTables.length > 1 ? 'tables' : 'table'}? - - + {#snippet children(root)} + {#each data.tables.tables as table (table.$id)} + + {#each $tableViewColumns as column} + + {#if column.id === '$id'} + {#key $tableViewColumns} + {table.$id} + {/key} + {:else if column.id === 'name'} + {table.name} + {:else} + + {/if} + + {/each} + + {/each} + {/snippet} + diff --git a/src/routes/(console)/project-[region]-[project]/databases/table.svelte b/src/routes/(console)/project-[region]-[project]/databases/table.svelte index 10ec6f307..0764bf9ea 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/table.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/table.svelte @@ -2,14 +2,23 @@ import { base } from '$app/paths'; import { page } from '$app/state'; import { Id } from '$lib/components'; - import { toLocaleDateTime } from '$lib/helpers/date'; + import DualTimeView from '$lib/components/dualTimeView.svelte'; import { columns } from './store'; import { IconExclamation } from '@appwrite.io/pink-icons-svelte'; import { Layout, Tooltip, Table, Icon } from '@appwrite.io/pink-svelte'; - import type { BackupPolicy } from '$lib/sdk/backups'; + import { type Models } from '@appwrite.io/console'; - export let data; - const tables = data.tables; + let { + tables, + policies, + databases, + lastBackups + }: { + tables: Record; + databases: Models.DatabaseList; + lastBackups: Record; + policies: Record; + } = $props(); function getPolicyDescription(cron: string): string { const [minute, hour, dayOfMonth, , dayOfWeek] = cron.split(' '); @@ -19,6 +28,10 @@ if (minute !== '*' && hour === '*') return 'Hourly'; if (hour !== '*') return 'Daily'; } + + function getPoliciesDescription(policies: Models.BackupPolicy[] | null): string { + return policies?.map((policy) => getPolicyDescription(policy.schedule)).join(', ') ?? ''; + } @@ -27,7 +40,7 @@ {title} {/each} - {#each data.databases.databases as database (database.$id)} + {#each databases.databases as database (database.$id)} {@const tableId = tables[database?.$id] ?? null} {@const tableHref = tableId ? `/table-${tableId}` : ''} @@ -45,18 +58,16 @@ {:else if column.id === 'name'} {database.name} {:else if column.id === 'backup'} - {@const policies = data.policies?.[database.$id] ?? null} - {@const lastBackup = data.lastBackups?.[database.$id] ?? null} - {@const description = policies - ?.map((policy: BackupPolicy) => getPolicyDescription(policy.schedule)) - .join(', ')} + {@const backupPolicies = policies?.[database.$id] ?? null} + {@const lastBackup = lastBackups?.[database.$id] ?? null} + {@const description = getPoliciesDescription(backupPolicies)} - {#if !policies} + {#if !backupPolicies} + {:else if column.type === 'datetime'} + {:else} - {toLocaleDateTime(database[column.id])} + {database[column.id]} {/if} {/each} diff --git a/src/routes/(console)/project-[region]-[project]/functions/+layout.ts b/src/routes/(console)/project-[region]-[project]/functions/+layout.ts index 1afe03dd3..b22ab7609 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/+layout.ts +++ b/src/routes/(console)/project-[region]-[project]/functions/+layout.ts @@ -3,6 +3,7 @@ import Header from './header.svelte'; import { sdk } from '$lib/stores/sdk'; import { Query } from '@appwrite.io/console'; import { Dependencies } from '$lib/constants'; +import { isCloud } from '$lib/system'; import type { LayoutLoad } from './$types'; export const load: LayoutLoad = async ({ depends, params }) => { @@ -13,7 +14,9 @@ export const load: LayoutLoad = async ({ depends, params }) => { sdk .forProject(params.region, params.project) .vcs.listInstallations({ queries: [Query.limit(100)] }), - sdk.forProject(params.region, params.project).functions.listSpecifications() + isCloud + ? sdk.forProject(params.region, params.project).functions.listSpecifications() + : Promise.resolve({ specifications: [], total: 0 }) ]); return { diff --git a/src/routes/(console)/project-[region]-[project]/functions/create-function/deploy/+page.svelte b/src/routes/(console)/project-[region]-[project]/functions/create-function/deploy/+page.svelte index 6aaa49c59..04c757d76 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/create-function/deploy/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/create-function/deploy/+page.svelte @@ -11,7 +11,7 @@ import { Fieldset, Layout, Icon, Input, Tag } from '@appwrite.io/pink-svelte'; import { IconGithub, IconPencil } from '@appwrite.io/pink-icons-svelte'; import { onMount } from 'svelte'; - import { ID, Runtime } from '@appwrite.io/console'; + import { ID, Runtime, TemplateReferenceType } from '@appwrite.io/console'; import { CustomId } from '$lib/components'; import { getIconFromRuntime } from '$lib/stores/runtimes'; import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store'; @@ -134,7 +134,8 @@ repository: data.repository.name, owner: data.repository.owner, rootDirectory: rootDir || '.', - version: latestTag ?? '1.0.0', + type: TemplateReferenceType.Tag, + reference: latestTag ?? '1.0.0', activate: true }); diff --git a/src/routes/(console)/project-[region]-[project]/functions/create-function/manual/+page.svelte b/src/routes/(console)/project-[region]-[project]/functions/create-function/manual/+page.svelte index 40c38c541..1a2b1ab93 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/create-function/manual/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/create-function/manual/+page.svelte @@ -1,6 +1,6 @@ -
+
@@ -122,11 +125,11 @@ - {#if deployment.status === 'failed'} + {#if effectiveStatus === 'failed'} {@render titleSnippet('Status')} - + {:else} diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(components)/downloadActionMenuItem.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(components)/downloadActionMenuItem.svelte index b91ee6d6f..85d45bb27 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(components)/downloadActionMenuItem.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(components)/downloadActionMenuItem.svelte @@ -2,7 +2,7 @@ import { page } from '$app/state'; import { SubMenu } from '$lib/components/menu'; import { type Models } from '@appwrite.io/console'; - import { IconDownload } from '@appwrite.io/pink-icons-svelte'; + import { IconDownload, IconChevronRight } from '@appwrite.io/pink-icons-svelte'; import { ActionMenu } from '@appwrite.io/pink-svelte'; import { getOutputDownload, getSourceDownload } from '../store'; @@ -13,7 +13,8 @@ {#if deployment?.status === 'ready' || deployment?.status === 'failed' || deployment?.status === 'building'} - Download + Download diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(modals)/createCli.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(modals)/createCli.svelte index ae5cdc4c3..70124e38e 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(modals)/createCli.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(modals)/createCli.svelte @@ -54,26 +54,26 @@ function setCodeSnippets() { return { Unix: { - code: `appwrite client --projectId="${page.params.project}" && \\ -appwrite functions createDeployment \\ - --functionId=${functionId} \\ + code: `appwrite client --project-id="${page.params.project}" && \\ +appwrite functions create-deployment \\ + --function-id=${functionId} \\ --code="." \\ --activate=true`, language: 'bash' }, CMD: { - code: `appwrite client --projectId="${page.params.project}" && ^ -appwrite functions createDeployment ^ - --functionId=${functionId} ^ + code: `appwrite client --project-id="${page.params.project}" && ^ +appwrite functions create-deployment ^ + --function-id=${functionId} ^ --code="." ^ --activate`, language: 'CMD' }, PowerShell: { - code: `appwrite client --projectId="${page.params.project}" && , -appwrite functions createDeployment , - --functionId=${functionId} , + code: `appwrite client --project-id="${page.params.project}" && , +appwrite functions create-deployment , + --function-id=${functionId} , --code="." , --activate`, language: 'PowerShell' diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(modals)/createGit.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(modals)/createGit.svelte index a65ae194f..24a05f760 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(modals)/createGit.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(modals)/createGit.svelte @@ -9,7 +9,7 @@ import { addNotification } from '$lib/stores/notifications'; import { sdk } from '$lib/stores/sdk'; import { installation, repository, sortBranches } from '$lib/stores/vcs'; - import { Runtime, VCSDeploymentType, type Models } from '@appwrite.io/console'; + import { Runtime, VCSReferenceType, type Models } from '@appwrite.io/console'; import { IconGithub } from '@appwrite.io/pink-icons-svelte'; import { Icon, Input, Layout, Skeleton, Typography } from '@appwrite.io/pink-svelte'; import { func } from '../store'; @@ -98,7 +98,7 @@ .forProject(page.params.region, page.params.project) .functions.createVcsDeployment({ functionId: $func.$id, - type: VCSDeploymentType.Commit, + type: VCSReferenceType.Commit, reference: commit, activate }); @@ -107,7 +107,7 @@ .forProject(page.params.region, page.params.project) .functions.createVcsDeployment({ functionId: $func.$id, - type: VCSDeploymentType.Branch, + type: VCSReferenceType.Branch, reference: branch, activate }); diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(modals)/createManual.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(modals)/createManual.svelte index 03e7f1dcf..acb5ece7a 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(modals)/createManual.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(modals)/createManual.svelte @@ -6,15 +6,14 @@ import { Button } from '$lib/elements/forms'; import { InvalidFileType, removeFile } from '$lib/helpers/files'; import { addNotification } from '$lib/stores/notifications'; - import { sdk } from '$lib/stores/sdk'; import { IconInfo } from '@appwrite.io/pink-icons-svelte'; import { Icon, Layout, Tooltip, Typography, Upload } from '@appwrite.io/pink-svelte'; import { func } from '../store'; - import { page } from '$app/state'; import { consoleVariables } from '$routes/(console)/store'; import { currentPlan } from '$lib/stores/organization'; import { isCloud } from '$lib/system'; import { humanFileSize } from '$lib/helpers/sizeConvertion'; + import { uploader } from '$lib/stores/uploader'; export let show = false; @@ -30,17 +29,16 @@ async function create() { try { - await sdk - .forProject(page.params.region, page.params.project) - .functions.createDeployment({ - functionId: $func.$id, - code: files[0], - activate: true - }); - await invalidate(Dependencies.DEPLOYMENTS); - files = undefined; + uploader.uploadFunctionDeployment({ + functionId: $func.$id, + code: files[0] + }); show = false; - trackEvent(Submit.DeploymentCreate); + files = undefined; + await invalidate(Dependencies.DEPLOYMENTS); + trackEvent(Submit.DeploymentCreate, { + type: 'function' + }); addNotification({ type: 'success', message: 'Deployment created successfully' diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(modals)/deleteModal.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(modals)/deleteModal.svelte index a8a5d819e..e913d705b 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(modals)/deleteModal.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(modals)/deleteModal.svelte @@ -1,5 +1,6 @@ - - - Are you sure you want to delete this deployment? - diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/deployment-[deployment]/+page.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/deployment-[deployment]/+page.svelte index 83247cdb2..140524354 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/deployment-[deployment]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/deployment-[deployment]/+page.svelte @@ -1,7 +1,7 @@ + + diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/+page.ts b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/+page.ts index 4145bf828..5fdc16ceb 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/+page.ts @@ -29,6 +29,7 @@ export const load: PageLoad = async ({ depends, params, url, route, parent }) => Query.limit(limit), Query.offset(offset), Query.orderDesc(''), + Query.orderDesc('$updatedAt'), ...parsedQueries.values() ], search: search || undefined diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/add-domain/+page.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/add-domain/+page.svelte index f913e4c91..6a9d3183a 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/add-domain/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/add-domain/+page.svelte @@ -48,11 +48,11 @@ async function addDomain() { const apexDomain = getApexDomain(domainName); - let domain = data.domains?.domains.find((d: Models.Domain) => d.domain === apexDomain); + const domain = data.domainsList.domains.find((d: Models.Domain) => d.domain === apexDomain); if (apexDomain && !domain && isCloud) { try { - domain = await sdk.forConsole.domains.create({ + await sdk.forConsole.domains.create({ teamId: $project.teamId, domain: apexDomain }); @@ -97,14 +97,18 @@ functionId: page.params.function }); } - if (rule?.status === 'verified') { + + await invalidate(Dependencies.FUNCTION_DOMAINS); + + const verified = rule?.status !== 'created'; + if (verified) { + addNotification({ + type: 'success', + message: 'Domain verified successfully' + }); await goto(routeBase); - await invalidate(Dependencies.FUNCTION_DOMAINS); } else { - await goto( - `${routeBase}/add-domain/verify-${domainName}?rule=${rule.$id}&domain=${domain.$id}` - ); - await invalidate(Dependencies.FUNCTION_DOMAINS); + await goto(`${routeBase}/add-domain/verify-${domainName}?rule=${rule.$id}`); } } catch (error) { addNotification({ diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/add-domain/+page.ts b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/add-domain/+page.ts index 9ee872047..a1f08feed 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/add-domain/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/add-domain/+page.ts @@ -1,5 +1,5 @@ import { sdk } from '$lib/stores/sdk'; -import { Query } from '@appwrite.io/console'; +import { Query, type Models } from '@appwrite.io/console'; import { RuleTrigger, RuleType } from '$lib/stores/sdk'; import { Dependencies } from '$lib/constants'; import { isCloud } from '$lib/system'; @@ -8,7 +8,7 @@ export const load = async ({ parent, depends, params }) => { const { function: func, organization } = await parent(); depends(Dependencies.DOMAINS, Dependencies.FUNCTION_DOMAINS); - const [rules, installations, domains] = await Promise.all([ + const [rules, installations, domainsList] = await Promise.all([ sdk.forProject(params.region, params.project).proxy.listRules({ queries: [ Query.equal('type', RuleType.DEPLOYMENT), @@ -18,13 +18,13 @@ export const load = async ({ parent, depends, params }) => { sdk.forProject(params.region, params.project).vcs.listInstallations(), isCloud ? sdk.forConsole.domains.list({ queries: [Query.equal('teamId', organization.$id)] }) - : Promise.resolve(null) + : Promise.resolve({ total: 0, domains: [] }) ]); return { func, rules, - domains, + domainsList, installations, branches: func?.installationId && func?.providerRepositoryId diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/add-domain/verify-[domain]/+page.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/add-domain/verify-[domain]/+page.svelte index 4db9c86d7..07f3c49d4 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/add-domain/verify-[domain]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/add-domain/verify-[domain]/+page.svelte @@ -11,7 +11,6 @@ } from '@appwrite.io/pink-svelte'; import { Button, Form } from '$lib/elements/forms'; import { sdk } from '$lib/stores/sdk'; - import { organization } from '$lib/stores/organization'; import { addNotification } from '$lib/stores/notifications'; import { goto, invalidate } from '$app/navigation'; import { Dependencies } from '$lib/constants'; @@ -20,66 +19,71 @@ import Wizard from '$lib/layout/wizard.svelte'; import { base } from '$app/paths'; import { writable } from 'svelte/store'; - import { isASubdomain } from '$lib/helpers/tlds'; import NameserverTable from '$lib/components/domains/nameserverTable.svelte'; import RecordTable from '$lib/components/domains/recordTable.svelte'; import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store'; + import { getApexDomain } from '$lib/helpers/tlds.js'; let { data } = $props(); const ruleId = page.url.searchParams.get('rule'); - const domainId = page.url.searchParams.get('domain'); - const isSubDomain = $derived.by(() => isASubdomain(page.params.domain)); - let selectedTab = $state<'cname' | 'nameserver' | 'a' | 'aaaa'>('nameserver'); - $effect(() => { - if ($regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME && isSubDomain) { - selectedTab = 'cname'; - } else if (!isCloud && $regionalConsoleVariables._APP_DOMAIN_TARGET_A) { - selectedTab = 'a'; - } else if (!isCloud && $regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA) { - selectedTab = 'aaaa'; - } else { - selectedTab = 'nameserver'; - } - }); - let verified: boolean | undefined = $state(undefined); + const showCNAMETab = $derived( + Boolean($regionalConsoleVariables._APP_DOMAIN_FUNCTIONS) && + $regionalConsoleVariables._APP_DOMAIN_FUNCTIONS !== 'localhost' + ); + const showATab = $derived( + !isCloud && + Boolean($regionalConsoleVariables._APP_DOMAIN_TARGET_A) && + $regionalConsoleVariables._APP_DOMAIN_TARGET_A !== '127.0.0.1' + ); + const showAAAATab = $derived( + !isCloud && + Boolean($regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA) && + $regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA !== '::1' + ); + const showNSTab = isCloud; + let proxyRule = $derived(data.proxyRule); + let selectedTab = $state<'cname' | 'nameserver' | 'a' | 'aaaa'>(getDefaultTab()); let routeBase = `${base}/project-${page.params.region}-${page.params.project}/functions/function-${page.params.function}/domains`; - let isSubmitting = $state(writable(false)); + let verified: boolean | undefined = $state(undefined); + const isSubmitting = writable(false); + + function getDefaultTab() { + return showCNAMETab ? 'cname' : showATab ? 'a' : showAAAATab ? 'aaaa' : 'nameserver'; + } async function verify() { - const isNewDomain = - data.domainsList.domains.find((rule) => rule.domain === page.params.domain) === - undefined; - try { - if (selectedTab !== 'nameserver') { - const ruleData = await sdk - .forProject(page.params.region, page.params.project) - .proxy.updateRuleVerification({ ruleId }); - verified = ruleData.status === 'verified'; - } else if (isNewDomain && isCloud) { - const domainData = await sdk.forConsole.domains.create({ - teamId: $organization.$id, - domain: page.params.domain - }); - verified = domainData.nameservers.toLowerCase() === 'appwrite'; - } else if (!isNewDomain && isCloud) { - const domain = await sdk.forConsole.domains.updateNameservers({ domainId }); - verified = domain.nameservers.toLowerCase() === 'appwrite'; - if (!verified) - throw new Error( - 'Domain verification failed. Please check your domain settings or try again later' - ); - } + verified = undefined; + try { + const apexDomain = getApexDomain(proxyRule.domain); + const domain = data.domainsList.domains.find((d) => d.domain === apexDomain); + + if (isCloud && domain) { + await sdk.forConsole.domains.updateNameservers({ + domainId: domain.$id + }); + } + } catch (error) { + // Ignore error + } + + try { + proxyRule = await sdk + .forProject(page.params.region, page.params.project) + .proxy.updateRuleVerification({ ruleId }); + + await Promise.all([ + invalidate(Dependencies.DOMAINS), + invalidate(Dependencies.FUNCTION_DOMAINS) + ]); + await goto(routeBase); addNotification({ type: 'success', - message: 'Domain added successfully' + message: 'Domain verified successfully' }); - await goto(routeBase); - await invalidate(Dependencies.DOMAINS); - await invalidate(Dependencies.FUNCTION_DOMAINS); } catch (error) { verified = false; isSubmitting.set(false); @@ -96,12 +100,12 @@ .forProject(page.params.region, page.params.project) .proxy.deleteRule({ ruleId }); } - await goto(`${routeBase}/add-domain?domain=${page.params.domain}`); + await goto(`${routeBase}/add-domain?domain=${proxyRule.domain}`); } -
+ - {page.params.domain} + {proxyRule.domain} @@ -124,7 +128,7 @@
- {#if isSubDomain && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME && $regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME !== 'localhost'} + {#if showCNAMETab} (selectedTab = 'cname')} @@ -132,7 +136,7 @@ CNAME {/if} - {#if isCloud} + {#if showNSTab} (selectedTab = 'nameserver')} @@ -140,7 +144,7 @@ Nameservers {/if} - {#if !isCloud && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_A && $regionalConsoleVariables._APP_DOMAIN_TARGET_A !== '127.0.0.1'} + {#if showATab} (selectedTab = 'a')} @@ -148,7 +152,7 @@ A {/if} - {#if !isCloud && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA && $regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA !== '::1'} + {#if showAAAATab} (selectedTab = 'aaaa')} @@ -160,9 +164,20 @@
{#if selectedTab === 'nameserver'} - + {:else} - + (selectedTab = 'nameserver')} + onNavigateToA={() => (selectedTab = 'a')} + onNavigateToAAAA={() => (selectedTab = 'aaaa')} /> {/if} diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/add-domain/verify-[domain]/+page.ts b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/add-domain/verify-[domain]/+page.ts index b89c93330..253859ee4 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/add-domain/verify-[domain]/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/add-domain/verify-[domain]/+page.ts @@ -3,18 +3,27 @@ import { isCloud } from '$lib/system'; import { Dependencies } from '$lib/constants.js'; import { type Models, Query } from '@appwrite.io/console'; -export const load = async ({ depends, parent }) => { - const { organization } = await parent(); - depends(Dependencies.DOMAINS); +export const load = async ({ depends, parent, params, url }) => { + const { function: func, organization } = await parent(); + depends(Dependencies.FUNCTION_DOMAINS); - let domainsList: Models.DomainsList; - if (isCloud) { - domainsList = await sdk.forConsole.domains.list({ - queries: [Query.equal('teamId', organization.$id)] - }); + const ruleId = url.searchParams.get('rule'); + if (!ruleId) { + throw new Error('Rule ID is required'); } + const [proxyRule, domainsList] = await Promise.all([ + sdk.forProject(params.region, params.project).proxy.getRule({ ruleId }), + isCloud + ? sdk.forConsole.domains.list({ + queries: [Query.equal('teamId', organization.$id)] + }) + : Promise.resolve({ total: 0, domains: [] }) + ]); + return { + function: func, + proxyRule, domainsList }; }; diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/recordsCard.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/recordsCard.svelte index 07a2da2c4..55e9b5a95 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/recordsCard.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/recordsCard.svelte @@ -25,8 +25,8 @@ - Add the following nameservers on your DNS provider. Note that changes may take - up to 48 hours to propagate fully. + Add the following nameservers on your DNS provider. Note that DNS changes may + take up to 48 hours to propagate fully.
diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/retryDomainModal.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/retryDomainModal.svelte index 8ccd491e2..bc90344d6 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/retryDomainModal.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/retryDomainModal.svelte @@ -6,33 +6,82 @@ import { invalidate } from '$app/navigation'; import { Submit, trackEvent, trackError } from '$lib/actions/analytics'; import { Dependencies } from '$lib/constants'; - import RecordsCard from './recordsCard.svelte'; import type { Models } from '@appwrite.io/console'; import { page } from '$app/state'; + import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store'; + import { isCloud } from '$lib/system'; + import { Divider, Tabs } from '@appwrite.io/pink-svelte'; + import NameserverTable from '$lib/components/domains/nameserverTable.svelte'; + import RecordTable from '$lib/components/domains/recordTable.svelte'; + import { getApexDomain } from '$lib/helpers/tlds'; let { show = $bindable(false), - selectedProxyRule + selectedProxyRule, + domainsList }: { show: boolean; selectedProxyRule: Models.ProxyRule; + domainsList?: Models.DomainsList; } = $props(); + const showCNAMETab = $derived( + Boolean($regionalConsoleVariables._APP_DOMAIN_FUNCTIONS) && + $regionalConsoleVariables._APP_DOMAIN_FUNCTIONS !== 'localhost' + ); + const showATab = $derived( + !isCloud && + Boolean($regionalConsoleVariables._APP_DOMAIN_TARGET_A) && + $regionalConsoleVariables._APP_DOMAIN_TARGET_A !== '127.0.0.1' + ); + const showAAAATab = $derived( + !isCloud && + Boolean($regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA) && + $regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA !== '::1' + ); + const showNSTab = isCloud; + + let selectedTab = $state<'cname' | 'nameserver' | 'a' | 'aaaa'>(getDefaultTab()); let error = $state(null); + let verified: boolean | undefined = $state(undefined); + + function getDefaultTab() { + return showCNAMETab ? 'cname' : showATab ? 'a' : showAAAATab ? 'aaaa' : 'nameserver'; + } + async function retryProxyRule() { + error = null; + verified = undefined; + try { - await sdk + const apexDomain = getApexDomain(selectedProxyRule.domain); + const domain = domainsList?.domains.find((d) => d.domain === apexDomain); + if (isCloud && domain) { + await sdk.forConsole.domains.updateNameservers({ + domainId: domain.$id + }); + } + } catch { + // Ignore error + } + + try { + selectedProxyRule = await sdk .forProject(page.params.region, page.params.project) .proxy.updateRuleVerification({ ruleId: selectedProxyRule.$id }); + await invalidate(Dependencies.FUNCTION_DOMAINS); show = false; addNotification({ type: 'success', - message: `${selectedProxyRule.domain} has been verified` + message: 'Domain verified successfully' }); trackEvent(Submit.DomainUpdateVerification); } catch (e) { - error = e.message; + verified = false; + error = + e.message ?? + 'Domain verification failed. Please check your domain settings or try again later'; trackError(e, Submit.DomainUpdateVerification); } } @@ -45,8 +94,58 @@ - {#if selectedProxyRule} - +
+ + {#if showCNAMETab} + (selectedTab = 'cname')} + active={selectedTab === 'cname'}> + CNAME + + {/if} + {#if showNSTab} + (selectedTab = 'nameserver')} + active={selectedTab === 'nameserver'}> + Nameservers + + {/if} + {#if showATab} + (selectedTab = 'a')} + active={selectedTab === 'a'}> + A + + {/if} + {#if showAAAATab} + (selectedTab = 'aaaa')} + active={selectedTab === 'aaaa'}> + AAAA + + {/if} + + +
+ {#if selectedTab === 'nameserver'} + + {:else} + (selectedTab = 'nameserver')} + onNavigateToA={() => (selectedTab = 'a')} + onNavigateToAAAA={() => (selectedTab = 'aaaa')} /> {/if} diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/store.ts b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/store.ts index 902fb3e3e..a3ec2fdb9 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/store.ts +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/store.ts @@ -7,13 +7,20 @@ export const columns = writable([ title: 'Domain', type: 'string', format: 'string', - width: { min: 200 } + width: { min: 600 } }, { id: 'target', title: 'Target', type: 'string', - width: { min: 120, max: 400 } + width: { min: 160, max: 400 } + }, + + { + id: 'updated', + title: '', + type: 'string', + width: { min: 160, max: 180 } } ]); diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/table.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/table.svelte index 80f8663d4..6f1693571 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/table.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/table.svelte @@ -3,10 +3,16 @@ import { Link } from '$lib/elements'; import { Button } from '$lib/elements/forms'; import type { Models } from '@appwrite.io/console'; - import { IconDotsHorizontal, IconRefresh, IconTrash } from '@appwrite.io/pink-icons-svelte'; + import { + IconDotsHorizontal, + IconRefresh, + IconTerminal, + IconTrash + } from '@appwrite.io/pink-icons-svelte'; import { ActionMenu, Badge, + Divider, Icon, Layout, Popover, @@ -18,6 +24,8 @@ import { columns } from './store'; import { regionalProtocol } from '$routes/(console)/project-[region]-[project]/store'; import DnsRecordsAction from '$lib/components/domains/dnsRecordsAction.svelte'; + import ViewLogsModal from '$lib/components/domains/viewLogsModal.svelte'; + import { timeFromNowShort } from '$lib/helpers/date'; let { proxyRules, @@ -29,6 +37,7 @@ let showDelete = $state(false); let showRetry = $state(false); + let showLogs = $state(false); let selectedProxyRule: Models.ProxyRule = $state(null); const proxyTarget = (proxy: Models.ProxyRule) => { @@ -38,6 +47,28 @@ ? 'Deployed from ' + proxy.deploymentVcsProviderBranch : 'Active deployment'; }; + + function updatedLabel(proxyRule: Models.ProxyRule): string { + if (proxyRule.status === 'verified') { + return ''; + } + + const timeStr = timeFromNowShort(proxyRule.$updatedAt); + if (timeStr === 'n/a') { + return ''; + } + + const prefix = + proxyRule.status === 'created' + ? 'Checked' + : proxyRule.status === 'verifying' + ? 'Updated' + : proxyRule.status === 'unverified' + ? 'Failed' + : ''; + + return prefix + ' ' + timeStr; + } @@ -49,7 +80,11 @@ {/each} - {#each proxyRules.rules as proxyRule} + {#each proxyRules.rules as proxyRule (proxyRule.$id)} + {@const isRetryable = proxyRule.status === 'created' || proxyRule.status === 'unverified'} + {@const isLogsViewable = + proxyRule.logs?.length > 0 && + (proxyRule.status === 'verifying' || proxyRule.status === 'unverified')} {#each $columns as column} @@ -57,24 +92,63 @@ {proxyRule.domain} - {#if proxyRule.status === 'verifying'} - - {:else if proxyRule.status !== 'verified'} - - {/if} + + {#if proxyRule.status !== 'verified'} + + {/if} + {#if isRetryable} + { + e.preventDefault(); + selectedProxyRule = proxyRule; + showRetry = true; + }}> + Retry + + {/if} + {#if isLogsViewable} + { + e.preventDefault(); + selectedProxyRule = proxyRule; + showLogs = true; + }}> + View logs + + {/if} + {:else if column.id === 'target'} {proxyTarget(proxyRule)} + {:else if column.id === 'updated' && proxyRule.status !== 'verified'} + + + {updatedLabel(proxyRule)} + + {/if} {/each} @@ -93,7 +167,18 @@ - {#if proxyRule.status !== 'verified' && proxyRule.status !== 'verifying'} + {#if isLogsViewable} + { + selectedProxyRule = proxyRule; + showLogs = true; + toggle(e); + }}> + View logs + + {/if} + {#if isRetryable} { @@ -105,6 +190,11 @@ {/if} + {#if isLogsViewable} +
+ +
+ {/if} + {/if} + +{#if showLogs} + +{/if} + + diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/executions/+layout.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/executions/+layout.svelte index d7af62449..478caf264 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/executions/+layout.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/executions/+layout.svelte @@ -1,18 +1,16 @@ diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/executions/+page.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/executions/+page.svelte index b3f43680e..55a275d5b 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/executions/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/executions/+page.svelte @@ -4,7 +4,7 @@ import { Dependencies } from '$lib/constants'; import { Button } from '$lib/elements/forms'; import { Container, ResponsiveContainerHeader } from '$lib/layout'; - import { sdk } from '$lib/stores/sdk'; + import { realtime } from '$lib/stores/sdk'; import { onMount } from 'svelte'; import { project } from '$routes/(console)/project-[region]-[project]/store'; import { base } from '$app/paths'; @@ -12,11 +12,13 @@ import { IconPlus } from '@appwrite.io/pink-icons-svelte'; import Table from './table.svelte'; import { columns } from './store'; + import type { PageProps } from './$types'; + import { page } from '$app/state'; - export let data; + let { data }: PageProps = $props(); onMount(() => { - return sdk.forConsole.client.subscribe('console', (response) => { + return realtime.forConsole(page.params.region, 'console', (response) => { if (response.events.includes('functions.*.executions.*')) { invalidate(Dependencies.EXECUTIONS); } diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/executions/sheet.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/executions/sheet.svelte index 48a313d48..bde1b909f 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/executions/sheet.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/executions/sheet.svelte @@ -1,6 +1,7 @@ - - + + {#snippet header(root)} {#each columns as { id, title }} {title} {/each} - - {#each executions.executions as log (log.$id)} - { - e.stopPropagation(); - open = true; - selectedLogId = log.$id; - }}> - {#each columns as column} - - {#if column.id === '$id'} - {#key column.id} - {log.$id} - {/key} - {:else if column.id === 'deploymentId'} - {log.deploymentId} - {:else if column.id === '$createdAt'} - - {:else if column.id === 'requestPath'} - - {log.requestPath} - - {:else if column.id === 'responseStatusCode'} - - {:else if column.id === 'requestMethod'} - - {log.requestMethod} - - {:else if column.id === 'trigger'} - {capitalize(log.trigger)} - {:else if column.id === 'status'} - {@const status = log.status} - -
- - -
- - {`Scheduled to execute on ${toLocaleDateTime(log.scheduledAt)}`} - -
- {:else if column.id === 'duration'} - {#if ['processing', 'waiting'].includes(log.status)} - - {:else} - {calculateTime(log.duration)} + {/snippet} + + {#snippet children(root)} + {#each executions.executions as log (log.$id)} + {@const effectiveStatus = getEffectiveExecutionStatus(log, $func)} + { + e.stopPropagation(); + open = true; + selectedLogId = log.$id; + }}> + {#each columns as column} + + {#if column.id === '$id'} + {#key column.id} + {log.$id} + {/key} + {:else if column.id === 'deploymentId'} + {log.deploymentId} + {:else if column.id === '$createdAt'} + + {:else if column.id === 'requestPath'} + + {log.requestPath} + + {:else if column.id === 'responseStatusCode'} + + {:else if column.id === 'requestMethod'} + + {log.requestMethod} + + {:else if column.id === 'trigger'} + {capitalize(log.trigger)} + {:else if column.id === 'status'} + +
+ + +
+ + {`Scheduled to execute on ${toLocaleDateTime(log.scheduledAt)}`} + +
+ {:else if column.id === 'duration'} + {#if ['processing', 'waiting'].includes(log.status)} + + {:else} + {calculateTime(log.duration)} + {/if} {/if} - {/if} -
- {/each} -
- {/each} -
+ + {/each} + + {/each} + {/snippet} + - -{#if selectedRows.length > 0} - - - - - {selectedRows.length > 1 ? 'executions' : 'execution'} - selected - - - - - - - -{/if} - - -

- Are you sure you want to delete {selectedRows.length} - {selectedRows.length > 1 ? 'executions' : 'execution'}? -

- -

This action is irreversible.

-
diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/settings/updateResourceLimits.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/settings/updateResourceLimits.svelte index c3199dcf5..30dcd26ff 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/settings/updateResourceLimits.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/settings/updateResourceLimits.svelte @@ -65,7 +65,7 @@ } } - const options = specs.specifications.map((spec) => ({ + const options = (specs?.specifications ?? []).map((spec) => ({ label: `${spec.cpus} CPU, ${spec.memory} MB RAM`, value: spec.slug, disabled: !spec.enabled diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/store.ts b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/store.ts index 2ee963047..42bf293ce 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/store.ts +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/store.ts @@ -17,10 +17,15 @@ export const proxyRuleList = derived( export const repositories: Writable<{ search: string; installationId: string; - repositories: Models.ProviderRepository[]; + total: number; + repositories: + | Models.ProviderRepository[] + | Models.ProviderRepositoryFramework[] + | Models.ProviderRepositoryRuntime[]; }> = writable({ search: '', installationId: '', + total: 0, repositories: [] }); diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/table.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/table.svelte index eecafbd5a..a70bf52d5 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/table.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/table.svelte @@ -1,5 +1,10 @@ - - + {#snippet header(root)} {#each columns as { id, title }} {title} {/each} - - {#each data.deploymentList.deployments as deployment (deployment.$id)} - - {#each columns as column} - - {#if column.id === '$id'} - {#key column.id} - {deployment.$id} - {/key} - {:else if column.id === 'status'} - {@const status = deployment.status} - - {#if data?.activeDeployment?.$id === deployment?.$id} - - {:else} - + {/snippet} + {#snippet children(root)} + {#each data.deploymentList.deployments as deployment (deployment.$id)} + {@const effectiveStatus = getEffectiveBuildStatus( + deployment, + $regionalConsoleVariables + )} + + {#each columns as column} + + {#if column.id === '$id'} + {#key column.id} + {deployment.$id} + {/key} + {:else if column.id === 'status'} + {#if data?.activeDeployment?.$id === deployment?.$id} + + {:else} + + {/if} + {:else if column.id === 'type'} + + {:else if column.id === '$updatedAt'} + + {:else if column.id === 'buildDuration'} + {#if ['waiting'].includes(effectiveStatus)} + - + {:else if ['processing', 'building'].includes(effectiveStatus)} + + {:else} + {formatTimeDetailed(deployment.buildDuration)} + {/if} + {:else if column.id === 'totalSize'} + {calculateSize(deployment.totalSize)} + {:else if column.id === 'sourceSize'} + {calculateSize(deployment.sourceSize)} + {:else if column.id === 'buildSize'} + {calculateSize(deployment.buildSize)} {/if} - {:else if column.id === 'type'} - - {:else if column.id === '$updatedAt'} - - {:else if column.id === 'buildDuration'} - {#if ['waiting'].includes(deployment.status)} - - - {:else if ['processing', 'building'].includes(deployment.status)} - - {:else} - {formatTimeDetailed(deployment.buildDuration)} - {/if} - {:else if column.id === 'totalSize'} - {calculateSize(deployment.totalSize)} - {:else if column.id === 'sourceSize'} - {calculateSize(deployment.sourceSize)} - {:else if column.id === 'buildSize'} - {calculateSize(deployment.buildSize)} - {/if} - - {/each} - - - + + {/each} + + + - - - -
+ + + +
+ { + selectedDeployment = deployment; + showRedeploy = true; + toggle(); + trackEvent(Click.FunctionsRedeployClick); + }} + style="width: 100%"> + Redeploy + +
+
Source is empty
+
+ {#if deployment.status === 'ready' && deployment.$id !== $func.deploymentId} { selectedDeployment = deployment; - showRedeploy = true; + showActivate = true; toggle(); - trackEvent(Click.FunctionsRedeployClick); - }} - style="width: 100%"> - Redeploy + }}> + Activate -
-
Source is empty
-
- {#if deployment.status === 'ready' && deployment.$id !== $func.deploymentId} - { - selectedDeployment = deployment; - showActivate = true; - toggle(); - }}> - Activate - - {/if} + {/if} - + - {#if deployment.status === 'processing' || deployment.status === 'building' || deployment.status === 'waiting'} - { - selectedDeployment = deployment; - toggle(); + {#if effectiveStatus === 'processing' || effectiveStatus === 'building' || effectiveStatus === 'waiting'} + { + selectedDeployment = deployment; + toggle(); - showCancel = true; - trackEvent(Click.FunctionsDeploymentCancelClick); - }}> - Cancel - - {/if} - {#if deployment.status !== 'building' && deployment.status !== 'processing' && deployment.status !== 'waiting'} - { - selectedDeployment = deployment; - toggle(); + showCancel = true; + trackEvent(Click.FunctionsDeploymentCancelClick); + }}> + Cancel + + {/if} + {#if effectiveStatus !== 'building' && effectiveStatus !== 'processing' && effectiveStatus !== 'waiting'} + { + selectedDeployment = deployment; + toggle(); - showDelete = true; - trackEvent(Click.FunctionsDeploymentDeleteClick); - }}> - Delete - - {/if} -
-
-
-
- - {/each} - + showDelete = true; + trackEvent(Click.FunctionsDeploymentDeleteClick); + }}> + Delete + + {/if} + + +
+
+
+ {/each} + {/snippet} + + {#snippet deleteContent(count)} +

+ Are you sure you want to delete {count} + {count > 1 ? 'deployments' : 'deployment'} from your function - + {page.data.function.name}? +

+ {/snippet} + {#if selectedDeployment} - + {/if} - -{#if selectedRows.length > 0} - - - - - {selectedRows.length > 1 ? 'deployments' : 'deployment'} - selected - - - - - - - -{/if} - - -

- Are you sure you want to delete {selectedRows.length} - {selectedRows.length > 1 ? 'deployments' : 'deployment'} from your function - - {$func.name}? -

- -

This action is irreversible.

-
diff --git a/src/routes/(console)/project-[region]-[project]/messaging/+page.svelte b/src/routes/(console)/project-[region]-[project]/messaging/+page.svelte index 6f009719b..464237e79 100644 --- a/src/routes/(console)/project-[region]-[project]/messaging/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/messaging/+page.svelte @@ -1,7 +1,16 @@ + + diff --git a/src/routes/(console)/project-[region]-[project]/messaging/providers/create.svelte b/src/routes/(console)/project-[region]-[project]/messaging/providers/create.svelte index e273aa462..8d2121695 100644 --- a/src/routes/(console)/project-[region]-[project]/messaging/providers/create.svelte +++ b/src/routes/(console)/project-[region]-[project]/messaging/providers/create.svelte @@ -118,6 +118,20 @@ enabled: $providerParams[$provider].enabled }); break; + case Providers.Resend: + response = await sdk + .forProject(page.params.region, page.params.project) + .messaging.createResendProvider({ + providerId, + name: $providerParams[$provider].name, + apiKey: $providerParams[$provider].apiKey, + fromName: $providerParams[$provider].fromName || undefined, + fromEmail: $providerParams[$provider].fromEmail, + replyToName: $providerParams[$provider].replyToName || undefined, + replyToEmail: $providerParams[$provider].replyToEmail || undefined, + enabled: $providerParams[$provider].enabled + }); + break; case Providers.SMTP: response = await sdk .forProject(page.params.region, page.params.project) diff --git a/src/routes/(console)/project-[region]-[project]/messaging/providers/provider-[provider]/+page.svelte b/src/routes/(console)/project-[region]-[project]/messaging/providers/provider-[provider]/+page.svelte index 4a6c74331..857bbb4f8 100644 --- a/src/routes/(console)/project-[region]-[project]/messaging/providers/provider-[provider]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/messaging/providers/provider-[provider]/+page.svelte @@ -99,6 +99,18 @@ replyToName: $providerData.options['replyToName'] }; break; + case Providers.Resend: + params = { + providerId: $providerData.$id, + name: $providerData.name, + enabled: $providerData.enabled, + apiKey: $providerData.credentials['apiKey'], + fromEmail: $providerData.options['fromEmail'], + fromName: $providerData.options['fromName'], + replyToEmail: $providerData.options['replyToEmail'], + replyToName: $providerData.options['replyToName'] + }; + break; case Providers.SMTP: params = { providerId: $providerData.$id, diff --git a/src/routes/(console)/project-[region]-[project]/messaging/providers/provider-[provider]/updateSettings.svelte b/src/routes/(console)/project-[region]-[project]/messaging/providers/provider-[provider]/updateSettings.svelte index 5d70650eb..dfaeb132d 100644 --- a/src/routes/(console)/project-[region]-[project]/messaging/providers/provider-[provider]/updateSettings.svelte +++ b/src/routes/(console)/project-[region]-[project]/messaging/providers/provider-[provider]/updateSettings.svelte @@ -158,6 +158,18 @@ replyToEmail: formValues['replyToEmail'] || undefined }); break; + case Providers.Resend: + response = await sdk + .forProject(page.params.region, page.params.project) + .messaging.updateResendProvider({ + providerId, + apiKey: formValues['apiKey'], + fromName: formValues['fromName'] || undefined, + fromEmail: formValues['fromEmail'], + replyToName: formValues['replyToName'] || undefined, + replyToEmail: formValues['replyToEmail'] || undefined + }); + break; case Providers.SMTP: response = await sdk .forProject(page.params.region, page.params.project) diff --git a/src/routes/(console)/project-[region]-[project]/messaging/providers/settingsFormInput.svelte b/src/routes/(console)/project-[region]-[project]/messaging/providers/settingsFormInput.svelte index afa62b44f..4ec9350a0 100644 --- a/src/routes/(console)/project-[region]-[project]/messaging/providers/settingsFormInput.svelte +++ b/src/routes/(console)/project-[region]-[project]/messaging/providers/settingsFormInput.svelte @@ -19,6 +19,7 @@ VonageProviderParams, MailgunProviderParams, SendgridProviderParams, + ResendProviderParams, SMTPProviderParams, FCMProviderParams, APNSProviderParams @@ -39,6 +40,7 @@ | VonageProviderParams | MailgunProviderParams | SendgridProviderParams + | ResendProviderParams | SMTPProviderParams | FCMProviderParams | APNSProviderParams diff --git a/src/routes/(console)/project-[region]-[project]/messaging/providers/settingsFormList.svelte b/src/routes/(console)/project-[region]-[project]/messaging/providers/settingsFormList.svelte index 88f6a4bab..010519b4c 100644 --- a/src/routes/(console)/project-[region]-[project]/messaging/providers/settingsFormList.svelte +++ b/src/routes/(console)/project-[region]-[project]/messaging/providers/settingsFormList.svelte @@ -9,6 +9,7 @@ VonageProviderParams, MailgunProviderParams, SendgridProviderParams, + ResendProviderParams, SMTPProviderParams, FCMProviderParams, APNSProviderParams @@ -24,6 +25,7 @@ | VonageProviderParams | MailgunProviderParams | SendgridProviderParams + | ResendProviderParams | SMTPProviderParams | FCMProviderParams | APNSProviderParams diff --git a/src/routes/(console)/project-[region]-[project]/messaging/providers/store.ts b/src/routes/(console)/project-[region]-[project]/messaging/providers/store.ts index 985d93215..4e186ece4 100644 --- a/src/routes/(console)/project-[region]-[project]/messaging/providers/store.ts +++ b/src/routes/(console)/project-[region]-[project]/messaging/providers/store.ts @@ -10,6 +10,7 @@ import { IconFirebase, IconMailgun, IconMsg91, + IconResend, IconSendgrid, IconTelesign, IconTextMagic, @@ -18,11 +19,11 @@ import { } from './components'; export const columns = writable([ - { id: '$id', title: 'Provider ID', type: 'string' }, - { id: 'name', title: 'Name', type: 'string' }, - { id: 'provider', title: 'Provider', type: 'string' }, - { id: 'type', title: 'Type', type: 'string' }, - { id: 'enabled', title: 'Status', type: 'boolean' } + { id: '$id', title: 'Provider ID', type: 'string', width: 200 }, + { id: 'name', title: 'Name', type: 'string', width: { min: 120 } }, + { id: 'provider', title: 'Provider', type: 'string', width: { min: 120 } }, + { id: 'type', title: 'Type', type: 'string', width: { min: 120 } }, + { id: 'enabled', title: 'Status', type: 'boolean', width: { min: 120 } } ]); export type ProviderInput = { @@ -318,6 +319,55 @@ export const providers: ProvidersMap = { ] ] }, + [Providers.Resend]: { + imageIcon: IconResend, + title: 'Resend', + description: '', + configure: [ + { + label: 'API key', + name: 'apiKey', + type: 'password', + placeholder: 'Enter API key', + popover: [ + 'How to get the API key?', + 'Create an account in Resend.', + 'Head to API Keys -> Create API Key.' + ] + }, + [ + { + label: 'Sender email', + name: 'fromEmail', + type: 'email', + placeholder: 'Enter email' + }, + { + label: 'Sender name', + name: 'fromName', + type: 'text', + optional: true, + placeholder: 'Enter name' + } + ], + [ + { + label: 'Reply-to email', + name: 'replyToEmail', + type: 'email', + optional: true, + placeholder: 'Enter email' + }, + { + label: 'Reply-to name', + name: 'replyToName', + type: 'text', + optional: true, + placeholder: 'Enter name' + } + ] + ] + }, [Providers.SMTP]: { classIcon: IconMail, title: 'SMTP', @@ -655,6 +705,14 @@ export type SendgridProviderParams = ProviderParams & { apiKey: string; }; +export type ResendProviderParams = ProviderParams & { + fromEmail: string; + fromName: string; + replyToEmail: string; + replyToName: string; + apiKey: string; +}; + export type SMTPProviderParams = ProviderParams & { fromEmail: string; fromName: string; diff --git a/src/routes/(console)/project-[region]-[project]/messaging/providers/table.svelte b/src/routes/(console)/project-[region]-[project]/messaging/providers/table.svelte index b65093f6e..f7ed22281 100644 --- a/src/routes/(console)/project-[region]-[project]/messaging/providers/table.svelte +++ b/src/routes/(console)/project-[region]-[project]/messaging/providers/table.svelte @@ -1,134 +1,96 @@ - - + onDelete={handleDelete} + allowSelection={$canWriteProviders}> + {#snippet header(root)} {#each $columns as { id, title }} {title} {/each} - - {#each data.providers.providers as provider (provider.$id)} - - {#each $columns as column} - - {#if column.id === '$id'} - {#key $columns} - {provider.$id} - {/key} - {:else if column.id === 'provider'} - - {:else if column.id === 'type'} - - {:else if column.id === 'enabled'} - - - {#if provider.enabled} - - {/if} - - - {:else} - {provider[column.id]} - {/if} - - {/each} - - {/each} - - -{#if selectedIds.length > 0} - - - - - {selectedIds.length > 1 ? 'providers' : 'provider'} - selected - - - - - - - -{/if} - - - - Are you sure you want to delete {selectedIds.length} - {selectedIds.length > 1 ? 'providers' : 'provider'}? - - + + {#each $columns as column} + + {#if column.id === '$id'} + {#key $columns} + {provider.$id} + {/key} + {:else if column.id === 'provider'} + + {:else if column.id === 'type'} + + {:else if column.id === 'enabled'} + + + {#if provider.enabled} + + {/if} + + + {:else} + {provider[column.id]} + {/if} + + {/each} + + {/each} + {/snippet} + diff --git a/src/routes/(console)/project-[region]-[project]/messaging/providers/wizard/provider.svelte b/src/routes/(console)/project-[region]-[project]/messaging/providers/wizard/provider.svelte index 313b6604f..34e1de162 100644 --- a/src/routes/(console)/project-[region]-[project]/messaging/providers/wizard/provider.svelte +++ b/src/routes/(console)/project-[region]-[project]/messaging/providers/wizard/provider.svelte @@ -99,6 +99,18 @@ replyToName: '' }; break; + case Providers.Resend: + $providerParams[$provider] = { + providerId: id, + name: name, + enabled: true, + apiKey: '', + fromEmail: '', + fromName: '', + replyToEmail: '', + replyToName: '' + }; + break; case Providers.SMTP: $providerParams[$provider] = { providerId: id, diff --git a/src/routes/(console)/project-[region]-[project]/messaging/providers/wizard/store.ts b/src/routes/(console)/project-[region]-[project]/messaging/providers/wizard/store.ts index 41741b3ce..5ed2a7c1f 100644 --- a/src/routes/(console)/project-[region]-[project]/messaging/providers/wizard/store.ts +++ b/src/routes/(console)/project-[region]-[project]/messaging/providers/wizard/store.ts @@ -6,6 +6,7 @@ import type { FCMProviderParams, MailgunProviderParams, Msg91ProviderParams, + ResendProviderParams, SMTPProviderParams, SendgridProviderParams, TelesignProviderParams, @@ -24,6 +25,7 @@ export const providerParams = writable<{ vonage: Partial; mailgun: Partial; sendgrid: Partial; + resend: Partial; smtp: Partial; fcm: Partial; apns: Partial; @@ -35,6 +37,7 @@ export const providerParams = writable<{ vonage: null, mailgun: null, sendgrid: null, + resend: null, smtp: null, fcm: null, apns: null diff --git a/src/routes/(console)/project-[region]-[project]/messaging/topics/table.svelte b/src/routes/(console)/project-[region]-[project]/messaging/topics/table.svelte index b4d653169..709529b0f 100644 --- a/src/routes/(console)/project-[region]-[project]/messaging/topics/table.svelte +++ b/src/routes/(console)/project-[region]-[project]/messaging/topics/table.svelte @@ -1,9 +1,12 @@ - - + + {#snippet header(root)} {#each columns as { id, title }} {title} {/each} - - {#each data.topics.topics as topic (topic.$id)} - - {#each columns as column (column.id)} - - {#if column.id === '$id'} - {#key column.id} - {topic.$id} - {/key} - {:else if column.type === 'datetime'} - {#if topic[column.id]} - - {:else}-{/if} - {:else if column.id === 'total'} - {topic.emailTotal + topic.smsTotal + topic.pushTotal} - {:else} - {topic[column.id]} - {/if} - - {/each} - - {/each} - + {/snippet} -{#if selectedIds.length > 0} - - - - - {selectedIds.length > 1 ? 'topics' : 'topic'} - selected - - - - - - - -{/if} - - - - Are you sure you want to delete {selectedIds.length} - {selectedIds.length > 1 ? 'topics' : 'topic'}? - - + {#snippet children(root)} + {#each data.topics.topics as topic (topic.$id)} + + {#each columns as column (column.id)} + + {#if column.id === '$id'} + {#key column.id} + {topic.$id} + {/key} + {:else if column.type === 'datetime'} + {#if topic[column.id]} + + {:else}-{/if} + {:else if column.id === 'total'} + {topic.emailTotal + topic.smsTotal + topic.pushTotal} + {:else} + {topic[column.id]} + {/if} + + {/each} + + {/each} + {/snippet} + diff --git a/src/routes/(console)/project-[region]-[project]/messaging/topics/topic-[topic]/table.svelte b/src/routes/(console)/project-[region]-[project]/messaging/topics/topic-[topic]/table.svelte index a42fcfd07..113d9d76e 100644 --- a/src/routes/(console)/project-[region]-[project]/messaging/topics/topic-[topic]/table.svelte +++ b/src/routes/(console)/project-[region]-[project]/messaging/topics/topic-[topic]/table.svelte @@ -2,10 +2,13 @@ import { invalidate } from '$app/navigation'; import { base } from '$app/paths'; import { Submit, trackError, trackEvent } from '$lib/actions/analytics'; - import { Id } from '$lib/components'; + import { + type DeleteOperationState, + type DeleteOperation, + Id, + MultiSelectionTable + } from '$lib/components'; import { Dependencies } from '$lib/constants'; - import { Button } from '$lib/elements/forms'; - import { addNotification } from '$lib/stores/notifications'; import type { PageData } from './$types'; import ProviderType from '../../providerType.svelte'; import DualTimeView from '$lib/components/dualTimeView.svelte'; @@ -15,133 +18,95 @@ import { targetsById } from '../../store'; import { MessagingProviderType, type Models } from '@appwrite.io/console'; import type { Column } from '$lib/helpers/types'; - import { Badge, FloatingActionBar, Table, Typography } from '@appwrite.io/pink-svelte'; - import Confirm from '$lib/components/confirm.svelte'; + import { Table } from '@appwrite.io/pink-svelte'; - export let columns: Column[]; - export let data: PageData; + let { + data, + columns + }: { + data: PageData; + columns: Column[]; + } = $props(); - let subscribers: Record = {}; - let selectedIds: string[] = []; - let selected: Record = {}; - let showDelete = false; - let deleting = false; + const subscribers = $derived.by(() => { + const record: Record = {}; + for (const subscriber of data.subscribers.subscribers) { + record[subscriber.$id] = subscriber; + } - async function handleDelete() { - showDelete = false; + return record; + }); - async function deleteSubscriber(subscriberId: string) { + async function handleDelete(batchDelete: DeleteOperation): Promise { + const result = await batchDelete(async (subscriberId) => { await sdk .forProject(page.params.region, page.params.project) .messaging.deleteSubscriber({ topicId: page.params.topic, subscriberId }); + const { target } = subscribers[subscriberId]; const { [target.$id]: _, ...rest } = $targetsById; $targetsById = rest; - } - - const promises = selectedIds.map((id) => deleteSubscriber(id)); + }); try { - await Promise.all(promises); - trackEvent(Submit.MessagingTopicSubscriberDelete, { - total: selectedIds.length - }); - addNotification({ - type: 'success', - message: `${selectedIds.length} subscriber${ - selectedIds.length > 1 ? 's' : '' - } deleted` - }); - invalidate(Dependencies.MESSAGING_TOPIC_SUBSCRIBERS); - } catch (error) { - addNotification({ - type: 'error', - message: error.message - }); - trackError(error, Submit.MessagingTopicSubscriberDelete); + if (result.error) { + trackError(result.error, Submit.MessagingTopicSubscriberDelete); + } else { + trackEvent(Submit.MessagingTopicSubscriberDelete, { total: result.deleted.length }); + } } finally { - selectedIds = []; - showDelete = false; + await invalidate(Dependencies.MESSAGING_TOPIC_SUBSCRIBERS); } - } - $: data.subscribers.subscribers.forEach((s) => { - subscribers[s.$id] = s; - }); - $: selectedIds.forEach((id) => { - selected[id] = subscribers[id]; - }); + return result; + } - - + + {#snippet header(root)} {#each columns as { id, title }} {title} {/each} - - {#each data.subscribers.subscribers as subscriber (subscriber.$id)} - {@const target = subscriber.target} - - {#each columns as column} - - {#if column.id === '$id'} - {#key column.id} - - {subscriber.$id} + {/snippet} + + {#snippet children(root)} + {#each data.subscribers.subscribers as subscriber (subscriber.$id)} + {@const target = subscriber.target} + + {#each columns as column} + + {#if column.id === '$id'} + {#key column.id} + + {subscriber.$id} + + {/key} + {:else if column.id === 'targetId'} + + {subscriber[column.id]} - {/key} - {:else if column.id === 'targetId'} - - {subscriber[column.id]} - - {:else if column.id === 'target'} - {#if target.providerType === MessagingProviderType.Push} - {target.name} + {:else if column.id === 'target'} + {#if target.providerType === MessagingProviderType.Push} + {target.name} + {:else} + {target.identifier} + {/if} + {:else if column.id === 'type'} + + {:else if column.id === '$createdAt'} + {:else} - {target.identifier} + {subscriber[column.id]} {/if} - {:else if column.id === 'type'} - - {:else if column.id === '$createdAt'} - - {:else} - {subscriber[column.id]} - {/if} - - {/each} - - {/each} - - -{#if selectedIds.length > 0} - - - - - {selectedIds.length > 1 ? 'subscribers' : 'subscriber'} - selected - - - - - - - -{/if} - - - - Are you sure you want to delete {selectedIds.length} - {selectedIds.length > 1 ? 'subscribers' : 'subscriber'}? - - +
+ {/each} +
+ {/each} + {/snippet} + 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 0df14759c..4c9bb8150 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/(components)/create.svelte +++ b/src/routes/(console)/project-[region]-[project]/overview/(components)/create.svelte @@ -15,6 +15,7 @@ import { writable } from 'svelte/store'; import Scopes from '../api-keys/scopes.svelte'; import { page } from '$app/state'; + import { copy } from '$lib/helpers/copy'; const projectId = page.params.project; @@ -23,12 +24,12 @@ let isSubmitting = writable(false); let scopes: string[] = []; - let name = '', - expire = ''; + let name = ''; + let expire: string | null = null; async function create() { try { - const { $id } = await sdk.forConsole.projects.createKey({ + const { $id, secret } = await sdk.forConsole.projects.createKey({ projectId, name, scopes, @@ -45,7 +46,21 @@ ); addNotification({ message: `API key has been created`, - type: 'success' + type: 'success', + buttons: [ + { + name: 'Copy API key', + method: async () => { + await copy(secret); + } + }, + { + name: 'Copy endpoint', + method: async () => { + await copy(sdk.forConsole.client.config.endpoint); + } + } + ] }); } catch (error) { addNotification({ diff --git a/src/routes/(console)/project-[region]-[project]/overview/(components)/deleteBatch.svelte b/src/routes/(console)/project-[region]-[project]/overview/(components)/deleteBatch.svelte index ff694c3cd..3a742d94c 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/(components)/deleteBatch.svelte +++ b/src/routes/(console)/project-[region]-[project]/overview/(components)/deleteBatch.svelte @@ -59,7 +59,12 @@ } - + 1 ? 's' : ''}`}>

Are you sure you want to delete {keyIds.length} {label} key{keyIds.length > 1 ? 's' : ''}? diff --git a/src/routes/(console)/project-[region]-[project]/overview/(components)/table.svelte b/src/routes/(console)/project-[region]-[project]/overview/(components)/table.svelte index 28bf1a389..535be9bd6 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/(components)/table.svelte +++ b/src/routes/(console)/project-[region]-[project]/overview/(components)/table.svelte @@ -2,27 +2,32 @@ import { base } from '$app/paths'; import { page } from '$app/state'; import { goto } from '$app/navigation'; - import { Empty } from '$lib/components'; - import { Button } from '$lib/elements/forms'; + import { Empty, MultiSelectionTable } from '$lib/components'; import { canWriteKeys } from '$lib/stores/roles'; import type { Models } from '@appwrite.io/console'; import { diffDays } from '$lib/helpers/date'; import DualTimeView from '$lib/components/dualTimeView.svelte'; import { devKeyColumns, keyColumns, showDevKeysCreateModal } from '../store'; - import { Badge, FloatingActionBar, Layout, Table } from '@appwrite.io/pink-svelte'; + import { Badge, Layout, Table } from '@appwrite.io/pink-svelte'; import DeleteBatch from './deleteBatch.svelte'; import { capitalize } from '$lib/helpers/string'; import { getEffectiveScopes } from '../api-keys/scopes.svelte'; - export let keyType: 'api' | 'dev' = 'api'; - export let keys: Models.KeyList | Models.DevKeyList; + let { + keyType = 'api', + keys + }: { + keyType?: 'api' | 'dev'; + keys: Models.KeyList | Models.DevKeyList; + } = $props(); - let showDeleteModal = false; - let selectedRows: string[] = []; + let selectedKeys = $state([]); + let showDeleteModal = $state(false); const isApiKey = keyType === 'api'; const label = isApiKey ? 'API' : 'dev'; const slug = isApiKey ? 'api-keys' : 'dev-keys'; + const columns = isApiKey ? $keyColumns : $devKeyColumns; function getApiKeyScopeCount(key: Models.Key | Models.DevKey) { const apiKey = key as Models.Key; @@ -53,55 +58,65 @@ else return 'Dev keys allow bypassing rate limits and CORS errors in your development environment.'; } - - const columns = isApiKey ? $keyColumns : $devKeyColumns; {#if keys.total} - - + { + showDeleteModal = true; + selectedKeys = selectedRows; + }}> + {#snippet header(root)} {#each columns as column} {column.title} {/each} - - {#each getKeys() as key (key.$id)} - - - {key.name} - - - {#if key.accessedAt} - - {:else} - never - {/if} - - - {@const expiration = getExpiryDetails(key)} - - {#if key.expire} - + {/snippet} + + {#snippet children(root)} + {#each getKeys() as key (key.$id)} + + + {key.name} + + + {#if key.accessedAt} + {:else} never {/if} - - {#if expiration.status} - - {/if} - - - {#if isApiKey} - - {getApiKeyScopeCount(key)} Scopes - {/if} - - {/each} - + + {@const expiration = getExpiryDetails(key)} + + {#if key.expire} + + {:else} + never + {/if} + + {#if expiration.status} + + {/if} + + + {#if isApiKey} + + {getApiKeyScopeCount(key)} Scopes + + {/if} + + {/each} + {/snippet} + {:else} { + on:click={async () => { if (isApiKey) { - goto( + await goto( `${base}/project-${page.params.region}-${page.params.project}/overview/${slug}/create` ); } else { @@ -120,21 +135,4 @@ }} /> {/if} -{#if selectedRows.length > 0} - - - - - {capitalize(label)} - {selectedRows.length > 1 ? 'keys' : 'key'} - selected - - - - - - - -{/if} - - + diff --git a/src/routes/(console)/project-[region]-[project]/overview/api-keys/[key]/header.svelte b/src/routes/(console)/project-[region]-[project]/overview/api-keys/[key]/header.svelte index 0b4b527d6..984700097 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/api-keys/[key]/header.svelte +++ b/src/routes/(console)/project-[region]-[project]/overview/api-keys/[key]/header.svelte @@ -3,6 +3,10 @@ import { page } from '$app/state'; import { Cover, CoverTitle } from '$lib/layout'; import { key } from './store'; + import { RegionEndpoint, Copy } from '$lib/components'; + import { Layout, Tag, Icon } from '@appwrite.io/pink-svelte'; + import { IconDuplicate } from '@appwrite.io/pink-icons-svelte'; + import { projectRegion } from '../../../store'; const projectId = page.params.project; @@ -12,5 +16,26 @@ {$key?.name} + + {#if $key?.secret} + + + + API secret + + + {/if} + {#if $projectRegion} + + {/if} + + + diff --git a/src/routes/(console)/project-[region]-[project]/overview/api-keys/scopes.svelte b/src/routes/(console)/project-[region]-[project]/overview/api-keys/scopes.svelte index 877db1b18..48485cc31 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/api-keys/scopes.svelte +++ b/src/routes/(console)/project-[region]-[project]/overview/api-keys/scopes.svelte @@ -26,17 +26,16 @@ + +Cursor diff --git a/src/routes/(console)/project-[region]-[project]/overview/header.svelte b/src/routes/(console)/project-[region]-[project]/overview/header.svelte index 8a412281c..ff2107397 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/header.svelte +++ b/src/routes/(console)/project-[region]-[project]/overview/header.svelte @@ -2,19 +2,24 @@ import { page } from '$app/state'; import { Id, RegionEndpoint } from '$lib/components'; import { Cover } from '$lib/layout'; - import { project, projectRegion } from '../store'; + import { projectRegion } from '../store'; import { hasOnboardingDismissed, setHasOnboardingDismissed } from '$lib/helpers/onboarding'; import { goto } from '$app/navigation'; - import { base } from '$app/paths'; + import { resolve } from '$app/paths'; import { Layout, Button, Typography } from '@appwrite.io/pink-svelte'; import { user } from '$lib/stores/user'; import { isSmallViewport } from '$lib/stores/viewport'; import { trackEvent } from '$lib/actions/analytics'; function dismissOnboarding() { - setHasOnboardingDismissed($project.$id, $user); + setHasOnboardingDismissed(page.params.project, $user); trackEvent('onboarding_hub_platform_dismiss'); - goto(`${base}/project-${$project.region}-${$project.$id}/overview/platforms`); + goto( + resolve('/(console)/project-[region]-[project]/overview/platforms', { + region: page.params.region, + project: page.params.project + }) + ); } @@ -24,12 +29,15 @@ - {$project?.name} + {page.data.project?.name} - {$project.$id} - + {page.params.project} + {#if $projectRegion} + + {/if} @@ -53,7 +61,7 @@ >Follow a few quick steps to get started with Appwrite

- {#if !hasOnboardingDismissed($project.$id, $user)} + {#if !hasOnboardingDismissed(page.params.project, $user)} Dismiss this page diff --git a/src/routes/(console)/project-[region]-[project]/overview/onboard.svelte b/src/routes/(console)/project-[region]-[project]/overview/onboard.svelte index 5b98b45d1..8650aa498 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/onboard.svelte +++ b/src/routes/(console)/project-[region]-[project]/overview/onboard.svelte @@ -14,12 +14,7 @@ import { app } from '$lib/stores/app'; import AuthPreview from './assets/auth-preview.svg'; import AuthPreviewDark from './assets/auth-preview-dark.svg'; - import { - IconArrowRight, - IconNodeJs, - IconPhp, - IconPython - } from '@appwrite.io/pink-icons-svelte'; + import { IconArrowRight } from '@appwrite.io/pink-icons-svelte'; import DatabaseImgSource from './assets/database.png'; import DatabaseImgSourceDark from './assets/database-dark.png'; import DiscordImgSource from './assets/discord.png'; @@ -31,49 +26,55 @@ import PlatformAndroidImgSourceDark from './assets/platform-android-dark.svg'; import PlatformFlutterImgSource from './assets/platform-flutter.svg'; import PlatformFlutterImgSourceDark from './assets/platform-flutter-dark.svg'; - import { base } from '$app/paths'; + import PlatformSdkImgSource from './assets/platform-sdk.jpg'; + import PlatformSdkImgSourceDark from './assets/platform-sdk-dark.png'; + import { resolve } from '$app/paths'; import { isSmallViewport } from '$lib/stores/viewport'; - import { AvatarGroup } from '$lib/components'; import type { Models } from '@appwrite.io/console'; import { getPlatformInfo } from '$lib/helpers/platform'; import { Click, trackEvent } from '$lib/actions/analytics'; import { goto } from '$app/navigation'; import { page } from '$app/state'; - export let pingCount = 0; - export let platforms: Models.Platform[] = []; + let { + pingCount = 0, + platforms = [] + }: { + pingCount: number; + platforms: Array; + } = $props(); + + const platformMap = $derived.by(() => { + const map = new Map(); + platforms.forEach((platform) => { + const platformInfo = getPlatformInfo(platform.type); + map.set(platformInfo.name, platform); + }); + + return map; + }); + + const projectRoute = $derived.by(() => { + return resolve('/(console)/project-[region]-[project]', { + region: page.params.region, + project: page.params.project + }); + }); function createKey() { - trackEvent(Click.KeyCreateClick, { - source: 'onboarding' - }); - goto( - `${base}/project-${page.params.region}-${page.params.project}/overview/api-keys/create`, - { - replaceState: true - } - ); + trackEvent(Click.KeyCreateClick, { source: 'onboarding' }); + + goto(`${projectRoute}/overview/api-keys/create`, { replaceState: true }); } function openPlatformWizard(type: number, platform?: Models.Platform) { if (platform) { - continuePlatform(type, platform.name, platform.key, platform.type); + continuePlatform(type, platform.name, platform.type); } else { trackEvent(Click.PlatformCreateClick, { source: 'onboarding' }); addPlatform(type); } } - - let platformMap = new Map(); - - $: { - let updatedMap = new Map(); - platforms.forEach((platform) => { - const platformInfo = getPlatformInfo(platform.type); - updatedMap.set(platformInfo.name, platform); - }); - platformMap = updatedMap; - }
@@ -103,7 +104,9 @@
- + { openPlatformWizard(0, platformMap.get('Web')); @@ -341,17 +344,34 @@ - - Or connect - server side -
- -
-
+ or + + + +
+ + + Create API key + Connect your server or backend to Appwrite + +
+ +
+
+
+
+
{ trackEvent(Click.OnboardingSetupDatabaseClick); - goto( - `${base}/project-${page.params.region}-${page.params.project}/databases` - ); + goto(`${projectRoute}/databases`); }} padding="s" > { trackEvent( Click.OnboardingAuthEmailPasswordClick @@ -502,7 +520,7 @@ { trackEvent( Click.OnboardingAuthOauth2Click @@ -510,7 +528,7 @@ }}>OAuth 2 { trackEvent( Click.OnboardingAuthAllMethodsClick @@ -684,6 +702,24 @@ background-position: bottom; background-repeat: no-repeat; } + .api-key-card-image { + background-size: cover; + background-position: right center; + background-repeat: no-repeat; + margin: 0; + width: 100%; + height: 100%; + min-height: 160px; + border-radius: var(--border-radius-m); + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: flex-start; + padding: var(--base-16, 16px); + @media (min-width: 1200px) { + min-height: 187px; + } + } .full-height-card { height: 100%; } diff --git a/src/routes/(console)/project-[region]-[project]/overview/platforms/+page.svelte b/src/routes/(console)/project-[region]-[project]/overview/platforms/+page.svelte index 84b73e194..de3cbcb2f 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/platforms/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/overview/platforms/+page.svelte @@ -1,4 +1,5 @@ - -{#if data.platforms.platforms.length} - - - Name - Platform type - Identifier - Last updated - - {#each data.platforms.platforms as platform} - - - {platform.name} - - - - - {PlatformTypes[platform.type]} - - - - {#if platform.type.includes('web') || platform.type === 'web'} - {platform.hostname || '—'} - {:else} - {platform.key || platform.hostname || '—'} - {/if} - - - {#if platform.$updatedAt} - - {:else} - never - {/if} - - - {/each} - +{#if data.platforms.total} + + {#snippet header(root)} + {#each $columns as column} + + {column.title} + + {/each} + {/snippet} + + {#snippet children(root)} + {#each data.platforms.platforms as platform} + + + {platform.name} + + + + + {PlatformTypes[platform.type]} + + + + {#if platform.type.includes('web') || platform.type === 'web'} + {platform.hostname || '—'} + {:else} + {platform.key || platform.hostname || '—'} + {/if} + + + {#if platform.$updatedAt} + + {:else} + never + {/if} + + + {/each} + {/snippet} + {:else} { - depends(Dependencies.PLATFORMS); +export const load: PageLoad = async ({ parent }) => { + const { project } = await parent(); return { - platforms: await sdk.forConsole.projects.listPlatforms({ projectId: params.project }) + platforms: { + platforms: project.platforms, + total: project.platforms.length + } }; }; diff --git a/src/routes/(console)/project-[region]-[project]/overview/platforms/[platform]/delete.svelte b/src/routes/(console)/project-[region]-[project]/overview/platforms/[platform]/delete.svelte index 0b14c84c7..163cb4ed9 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/platforms/[platform]/delete.svelte +++ b/src/routes/(console)/project-[region]-[project]/overview/platforms/[platform]/delete.svelte @@ -17,7 +17,7 @@ projectId: $project.$id, platformId: $platform.$id }); - await invalidate(Dependencies.PLATFORMS); + await invalidate(Dependencies.PROJECT); showDelete = false; addNotification({ type: 'success', diff --git a/src/routes/(console)/project-[region]-[project]/overview/platforms/components/TanStackFrameworkIcon.svelte b/src/routes/(console)/project-[region]-[project]/overview/platforms/components/TanStackFrameworkIcon.svelte new file mode 100644 index 000000000..035736e89 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/overview/platforms/components/TanStackFrameworkIcon.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/routes/(console)/project-[region]-[project]/overview/platforms/components/index.ts b/src/routes/(console)/project-[region]-[project]/overview/platforms/components/index.ts index 32e9fc210..ae805a26e 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/platforms/components/index.ts +++ b/src/routes/(console)/project-[region]-[project]/overview/platforms/components/index.ts @@ -4,6 +4,7 @@ export { default as JavascriptFrameworkIcon } from './JavascriptFrameworkIcon.sv export { default as NextjsFrameworkIcon } from './NextjsFrameworkIcon.svelte'; export { default as NoFrameworkIcon } from './NoFrameworkIcon.svelte'; export { default as NuxtFrameworkIcon } from './NuxtFrameworkIcon.svelte'; +export { default as TanStackFrameworkIcon } from './TanStackFrameworkIcon.svelte'; export { default as ReactFrameworkIcon } from './ReactFrameworkIcon.svelte'; export { default as SvelteFrameworkIcon } from './SvelteFrameworkIcon.svelte'; export { default as VueFrameworkIcon } from './VueFrameworkIcon.svelte'; 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 fb5cf62fd..87f37b791 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 { 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; @@ -63,8 +134,7 @@ const val APPWRITE_PUBLIC_ENDPOINT = "${sdk.forProject(page.params.region, page. message: 'Platform created.' }); - invalidate(Dependencies.PROJECT); - invalidate(Dependencies.PLATFORMS); + await invalidate(Dependencies.PROJECT); } catch (error) { trackError(error, Submit.PlatformCreate); addNotification({ @@ -81,7 +151,8 @@ const val APPWRITE_PUBLIC_ENDPOINT = "${sdk.forProject(page.params.region, page. } onMount(() => { - const unsubscribe = sdk.forConsole.client.subscribe('console', (response) => { + fetchAndroidSdkVersion(); + const unsubscribe = realtime.forConsole(page.params.region, 'console', (response) => { if (response.events.includes(`projects.${projectId}.ping`)) { connectionSuccessful = true; invalidate(Dependencies.ORGANIZATION); @@ -169,6 +240,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 0ee698c84..6148712f0 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/platforms/createApple.svelte +++ b/src/routes/(console)/project-[region]-[project]/overview/platforms/createApple.svelte @@ -18,7 +18,7 @@ import { Card } from '$lib/components'; import { page } from '$app/state'; import { onMount } from 'svelte'; - import { sdk } from '$lib/stores/sdk'; + import { realtime, sdk } from '$lib/stores/sdk'; import { Submit, trackError, trackEvent } from '$lib/actions/analytics'; import { addNotification } from '$lib/stores/notifications'; import { fade } from 'svelte/transition'; @@ -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, @@ -72,8 +97,7 @@ APPWRITE_PUBLIC_ENDPOINT: "${sdk.forProject(page.params.region, page.params.proj message: 'Platform created.' }); - invalidate(Dependencies.PROJECT); - invalidate(Dependencies.PLATFORMS); + await invalidate(Dependencies.PROJECT); } catch (error) { trackError(error, Submit.PlatformCreate); addNotification({ @@ -90,7 +114,7 @@ APPWRITE_PUBLIC_ENDPOINT: "${sdk.forProject(page.params.region, page.params.proj } onMount(() => { - const unsubscribe = sdk.forConsole.client.subscribe('console', (response) => { + const unsubscribe = realtime.forConsole(page.params.region, 'console', (response) => { if (response.events.includes(`projects.${projectId}.ping`)) { connectionSuccessful = true; invalidate(Dependencies.ORGANIZATION); @@ -197,6 +221,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 312924948..b18cbaf78 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 { 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; @@ -138,8 +187,7 @@ message: 'Platform created.' }); - invalidate(Dependencies.PROJECT); - invalidate(Dependencies.PLATFORMS); + await invalidate(Dependencies.PROJECT); } catch (error) { trackError(error, Submit.PlatformCreate); addNotification({ @@ -156,7 +204,8 @@ } onMount(() => { - const unsubscribe = sdk.forConsole.client.subscribe('console', (response) => { + fetchFlutterSdkVersion(); + const unsubscribe = realtime.forConsole(page.params.region, 'console', (response) => { if (response.events.includes(`projects.${projectId}.ping`)) { connectionSuccessful = true; invalidate(Dependencies.ORGANIZATION); @@ -279,6 +328,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 aeba8a381..7a475290b 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/platforms/createReactNative.svelte +++ b/src/routes/(console)/project-[region]-[project]/overview/platforms/createReactNative.svelte @@ -18,7 +18,7 @@ import { Card } from '$lib/components'; import { page } from '$app/state'; import { onMount } from 'svelte'; - import { sdk } from '$lib/stores/sdk'; + import { realtime, sdk } from '$lib/stores/sdk'; import { Submit, trackError, trackEvent } from '$lib/actions/analytics'; import { addNotification } from '$lib/stores/notifications'; import { fade } from 'svelte/transition'; @@ -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 @@ -99,8 +128,7 @@ EXPO_PUBLIC_APPWRITE_ENDPOINT=${sdk.forProject(page.params.region, page.params.p message: 'Platform created.' }); - invalidate(Dependencies.PROJECT); - invalidate(Dependencies.PLATFORMS); + await invalidate(Dependencies.PROJECT); } catch (error) { trackError(error, Submit.PlatformCreate); addNotification({ @@ -117,7 +145,7 @@ EXPO_PUBLIC_APPWRITE_ENDPOINT=${sdk.forProject(page.params.region, page.params.p } onMount(() => { - const unsubscribe = sdk.forConsole.client.subscribe('console', (response) => { + const unsubscribe = realtime.forConsole(page.params.region, 'console', (response) => { if (response.events.includes(`projects.${projectId}.ping`)) { connectionSuccessful = true; invalidate(Dependencies.ORGANIZATION); @@ -223,6 +251,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 bebe610d7..6dc27dbee 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/platforms/createWeb.svelte +++ b/src/routes/(console)/project-[region]-[project]/overview/platforms/createWeb.svelte @@ -20,6 +20,7 @@ IconSvelte, IconReact, IconNuxt, + IconTanstack, IconInfo, IconExternalLink, IconAngular, @@ -27,7 +28,7 @@ } from '@appwrite.io/pink-icons-svelte'; import { page } from '$app/state'; import { onMount } from 'svelte'; - import { sdk } from '$lib/stores/sdk'; + import { realtime, sdk } from '$lib/stores/sdk'; import { Submit, trackError, trackEvent } from '$lib/actions/analytics'; import { addNotification } from '$lib/stores/notifications'; import { fade } from 'svelte/transition'; @@ -38,6 +39,7 @@ ReactFrameworkIcon, SvelteFrameworkIcon, NuxtFrameworkIcon, + TanStackFrameworkIcon, NextjsFrameworkIcon, VueFrameworkIcon, NoFrameworkIcon, @@ -46,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(); @@ -129,6 +137,15 @@ ${prefix}APPWRITE_ENDPOINT = "${sdk.forProject(page.params.region, page.params.p appwriteProjectName: '${$project.name}' };` }, + { + key: 'tanstack-start', + label: 'TanStack Start', + icon: TanStackFrameworkIcon, + smallIcon: IconTanstack, + portNumber: 3000, + runCommand: 'npm run dev', + updateConfigCode: updateConfigCode('VITE_') + }, { key: 'js', label: 'JavaScript', @@ -146,8 +163,61 @@ ${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() { - hostnameError = hostname !== '' ? !new RegExp(extendedHostnameRegex).test(hostname) : null; + const hostnameRegex = new RegExp(extendedHostnameRegex); + const finalHostname = hostname?.trim() || 'localhost'; + hostnameError = !hostnameRegex.test(finalHostname); if (hostnameError) { return; @@ -160,7 +230,7 @@ ${prefix}APPWRITE_ENDPOINT = "${sdk.forProject(page.params.region, page.params.p type: PlatformType.Web, name: `${selectedFramework.label} app`, key: key, - hostname: hostname === '' ? undefined : hostname + hostname: finalHostname }); isPlatformCreated = true; @@ -173,8 +243,7 @@ ${prefix}APPWRITE_ENDPOINT = "${sdk.forProject(page.params.region, page.params.p message: 'Platform created.' }); - invalidate(Dependencies.PROJECT); - invalidate(Dependencies.PLATFORMS); + await invalidate(Dependencies.PROJECT); } catch (error) { trackError(error, Submit.PlatformCreate); addNotification({ @@ -191,7 +260,7 @@ ${prefix}APPWRITE_ENDPOINT = "${sdk.forProject(page.params.region, page.params.p } onMount(() => { - const unsubscribe = sdk.forConsole.client.subscribe('console', (response) => { + const unsubscribe = realtime.forConsole(page.params.region, 'console', (response) => { if (response.events.includes(`projects.${projectId}.ping`)) { connectionSuccessful = true; invalidate(Dependencies.ORGANIZATION); @@ -257,7 +326,8 @@ ${prefix}APPWRITE_ENDPOINT = "${sdk.forProject(page.params.region, page.params.p protocol or port number required. -
+ +
@@ -285,6 +355,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..bc031f73d 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/platforms/store.ts +++ b/src/routes/(console)/project-[region]-[project]/overview/platforms/store.ts @@ -1,5 +1,14 @@ import type { ComponentType } from 'svelte'; import { PlatformType } from '@appwrite.io/console'; +import { writable } from 'svelte/store'; +import type { Column } from '$lib/helpers/types'; + +export const columns = writable([ + { id: 'name', title: 'Name', type: 'string', width: { min: 120 } }, + { id: 'type', title: 'Platform type', type: 'string', width: { min: 120 } }, + { id: 'identifier', title: 'Identifier', type: 'string', width: { min: 120 } }, + { id: '$updatedAt', title: 'Last updated', type: 'string', width: { min: 120 } } +]); export type PlatformProps = { key?: string; @@ -17,6 +26,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]/overview/platforms/wizard/store.ts b/src/routes/(console)/project-[region]-[project]/overview/platforms/wizard/store.ts index e15dcfbf2..24f28def6 100644 --- a/src/routes/(console)/project-[region]-[project]/overview/platforms/wizard/store.ts +++ b/src/routes/(console)/project-[region]-[project]/overview/platforms/wizard/store.ts @@ -6,7 +6,6 @@ function createPlatformStore() { $id: null, name: null, hostname: null, - key: null, store: null, type: null }); @@ -20,7 +19,6 @@ function createPlatformStore() { $id: null, name: null, hostname: null, - key: null, store: null, type: null }); diff --git a/src/routes/(console)/project-[region]-[project]/settings/+page.svelte b/src/routes/(console)/project-[region]-[project]/settings/+page.svelte index edf09b92f..8c427cc23 100644 --- a/src/routes/(console)/project-[region]-[project]/settings/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/settings/+page.svelte @@ -15,6 +15,7 @@ import ChangeOrganization from './changeOrganization.svelte'; import UpdateVariables from '../updateVariables.svelte'; import { page } from '$app/state'; + import UpdateLabels from './updateLabels.svelte'; export let data; @@ -84,8 +85,9 @@ {#if $project} - {#if $canWriteProjects} + + + import { onMount } from 'svelte'; + import { page } from '$app/state'; + import { realtime, RuleType } from '$lib/stores/sdk'; + import { Dependencies } from '$lib/constants'; + import { invalidate } from '$app/navigation'; + import { type Models } from '@appwrite.io/console'; + + onMount(() => { + return realtime.forProject(page.params.region, ['console', 'project'], (response) => { + if (response.events.includes('rules.*.update')) { + const proxyRule = response.payload as Models.ProxyRule; + if (proxyRule.type === RuleType.API) { + invalidate(Dependencies.DOMAINS); + } + } + }); + }); + + + diff --git a/src/routes/(console)/project-[region]-[project]/settings/domains/+page.ts b/src/routes/(console)/project-[region]-[project]/settings/domains/+page.ts index 39495486c..bf7570e23 100644 --- a/src/routes/(console)/project-[region]-[project]/settings/domains/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/settings/domains/+page.ts @@ -21,7 +21,11 @@ export const load: PageLoad = async ({ depends, url, route, params, parent }) => const { organization } = await parent(); const rules = await sdk.forProject(params.region, params.project).proxy.listRules({ - queries: [Query.equal('type', RuleType.API), Query.equal('trigger', RuleTrigger.MANUAL)], + queries: [ + Query.equal('type', RuleType.API), + Query.equal('trigger', RuleTrigger.MANUAL), + Query.orderDesc('$updatedAt') + ], search: search || undefined }); diff --git a/src/routes/(console)/project-[region]-[project]/settings/domains/add-domain/+page.svelte b/src/routes/(console)/project-[region]-[project]/settings/domains/add-domain/+page.svelte index 90d5a2159..eb2b2a214 100644 --- a/src/routes/(console)/project-[region]-[project]/settings/domains/add-domain/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/settings/domains/add-domain/+page.svelte @@ -30,11 +30,11 @@ async function addDomain() { const apexDomain = getApexDomain(domainName); - let domain = data.domains?.domains.find((d: Models.Domain) => d.domain === apexDomain); + const domain = data.domainsList.domains.find((d: Models.Domain) => d.domain === apexDomain); if (apexDomain && !domain && isCloud) { try { - domain = await sdk.forConsole.domains.create({ + await sdk.forConsole.domains.create({ teamId: $project.teamId, domain: apexDomain }); @@ -55,22 +55,18 @@ const rule = await sdk .forProject(page.params.region, page.params.project) .proxy.createAPIRule({ domain: domainName.toLocaleLowerCase() }); - if (rule?.status === 'verified') { + + await invalidate(Dependencies.DOMAINS); + + const verified = rule?.status !== 'created'; + if (verified) { + addNotification({ + type: 'success', + message: 'Domain verified successfully' + }); await goto(routeBase); - await invalidate(Dependencies.DOMAINS); } else { - let redirect = `${routeBase}/add-domain/verify-${domainName}?rule=${rule.$id}`; - - if (isCloud) { - /** - * Domains are only on cloud! - * Self-hosted instances have rules. - */ - redirect += `&domain=${domain.$id}`; - } - - await goto(redirect); - await invalidate(Dependencies.DOMAINS); + await goto(`${routeBase}/add-domain/verify-${domainName}?rule=${rule.$id}`); } } catch (error) { addNotification({ diff --git a/src/routes/(console)/project-[region]-[project]/settings/domains/add-domain/+page.ts b/src/routes/(console)/project-[region]-[project]/settings/domains/add-domain/+page.ts index a62c7f29c..2520b9970 100644 --- a/src/routes/(console)/project-[region]-[project]/settings/domains/add-domain/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/settings/domains/add-domain/+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 { RuleTrigger, RuleType } from '$lib/stores/sdk'; import { Dependencies } from '$lib/constants.js'; @@ -8,17 +8,17 @@ export const load = async ({ depends, params, parent }) => { const { organization } = await parent(); depends(Dependencies.DOMAINS); - const [rules, domains] = await Promise.all([ + const [rules, domainsList] = await Promise.all([ sdk.forProject(params.region, params.project).proxy.listRules({ queries: [Query.equal('type', RuleType.API), Query.equal('trigger', RuleTrigger.MANUAL)] }), isCloud ? sdk.forConsole.domains.list({ queries: [Query.equal('teamId', organization.$id)] }) - : Promise.resolve(null) + : Promise.resolve({ total: 0, domains: [] }) ]); return { rules, - domains + domainsList }; }; diff --git a/src/routes/(console)/project-[region]-[project]/settings/domains/add-domain/verify-[domain]/+page.svelte b/src/routes/(console)/project-[region]-[project]/settings/domains/add-domain/verify-[domain]/+page.svelte index a6c23790f..93f725eb0 100644 --- a/src/routes/(console)/project-[region]-[project]/settings/domains/add-domain/verify-[domain]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/settings/domains/add-domain/verify-[domain]/+page.svelte @@ -11,7 +11,6 @@ } from '@appwrite.io/pink-svelte'; import { Button, Form } from '$lib/elements/forms'; import { sdk } from '$lib/stores/sdk'; - import { organization } from '$lib/stores/organization'; import { addNotification } from '$lib/stores/notifications'; import { goto, invalidate } from '$app/navigation'; import { Dependencies } from '$lib/constants'; @@ -20,68 +19,67 @@ import Wizard from '$lib/layout/wizard.svelte'; import { base } from '$app/paths'; import { writable } from 'svelte/store'; - import { isASubdomain } from '$lib/helpers/tlds'; import NameserverTable from '$lib/components/domains/nameserverTable.svelte'; import RecordTable from '$lib/components/domains/recordTable.svelte'; import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store'; + import { getApexDomain } from '$lib/helpers/tlds.js'; let { data } = $props(); const ruleId = page.url.searchParams.get('rule'); - const domainId = page.url.searchParams.get('domain'); - const isSubDomain = $derived.by(() => isASubdomain(page.params.domain)); - let selectedTab = $state<'cname' | 'nameserver' | 'a' | 'aaaa'>('nameserver'); - - $effect(() => { - if ($regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME && isSubDomain) { - selectedTab = 'cname'; - } else if (!isCloud && $regionalConsoleVariables._APP_DOMAIN_TARGET_A) { - selectedTab = 'a'; - } else if (!isCloud && $regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA) { - selectedTab = 'aaaa'; - } else { - selectedTab = 'nameserver'; - } - }); - let verified = $state(false); + const showCNAMETab = $derived( + Boolean($regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME) && + $regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME !== 'localhost' + ); + const showATab = $derived( + !isCloud && + Boolean($regionalConsoleVariables._APP_DOMAIN_TARGET_A) && + $regionalConsoleVariables._APP_DOMAIN_TARGET_A !== '127.0.0.1' + ); + const showAAAATab = $derived( + !isCloud && + Boolean($regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA) && + $regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA !== '::1' + ); + const showNSTab = isCloud; + let proxyRule = $derived(data.proxyRule); + let selectedTab = $state<'cname' | 'nameserver' | 'a' | 'aaaa'>(getDefaultTab()); const routeBase = `${base}/project-${page.params.region}-${page.params.project}/settings/domains`; + let verified: boolean | undefined = $state(undefined); const isSubmitting = writable(false); - async function verify() { - const isNewDomain = - data.domainsList.domains.find((rule) => rule.domain === page.params.domain) === - undefined; - try { - if (selectedTab !== 'nameserver') { - const ruleData = await sdk - .forProject(page.params.region, page.params.project) - .proxy.updateRuleVerification({ ruleId }); - verified = ruleData.status === 'verified'; - } else if (isNewDomain && isCloud) { - const domainData = await sdk.forConsole.domains.create({ - teamId: $organization.$id, - domain: page.params.domain - }); - verified = domainData.nameservers.toLowerCase() === 'appwrite'; - } else if (!isNewDomain && isCloud) { - const domain = await sdk.forConsole.domains.updateNameservers({ - domainId - }); - verified = domain.nameservers.toLowerCase() === 'appwrite'; - if (!verified) - throw new Error( - 'Domain verification failed. Please check your domain settings or try again later' - ); - } + function getDefaultTab() { + return showCNAMETab ? 'cname' : showATab ? 'a' : showAAAATab ? 'aaaa' : 'nameserver'; + } + async function verify() { + verified = undefined; + + try { + const apexDomain = getApexDomain(proxyRule.domain); + const domain = data.domainsList.domains.find((d) => d.domain === apexDomain); + if (isCloud && domain) { + await sdk.forConsole.domains.updateNameservers({ + domainId: domain.$id + }); + } + } catch (error) { + // Ignore error + } + + try { + proxyRule = await sdk + .forProject(page.params.region, page.params.project) + .proxy.updateRuleVerification({ ruleId }); + + await invalidate(Dependencies.DOMAINS); + await goto(routeBase); addNotification({ type: 'success', - message: 'Domain added successfully' + message: 'Domain verified successfully' }); - await goto(routeBase); - await invalidate(Dependencies.DOMAINS); } catch (error) { verified = false; isSubmitting.set(false); @@ -98,7 +96,7 @@ .forProject(page.params.region, page.params.project) .proxy.deleteRule({ ruleId }); } - await goto(`${routeBase}/add-domain?domain=${page.params.domain}`); + await goto(`${routeBase}/add-domain?domain=${proxyRule.domain}`); } @@ -115,7 +113,7 @@ - {page.params.domain} + {proxyRule.domain} @@ -126,7 +124,7 @@
- {#if isSubDomain && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME && $regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME !== 'localhost'} + {#if showCNAMETab} (selectedTab = 'cname')} @@ -134,7 +132,7 @@ CNAME {/if} - {#if isCloud} + {#if showNSTab} (selectedTab = 'nameserver')} @@ -142,7 +140,7 @@ Nameservers {/if} - {#if !isCloud && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_A && $regionalConsoleVariables._APP_DOMAIN_TARGET_A !== '127.0.0.1'} + {#if showATab} (selectedTab = 'a')} @@ -150,7 +148,7 @@ A {/if} - {#if !isCloud && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA && $regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA !== '::1'} + {#if showAAAATab} (selectedTab = 'aaaa')} @@ -162,9 +160,20 @@
{#if selectedTab === 'nameserver'} - + {:else} - + (selectedTab = 'nameserver')} + onNavigateToA={() => (selectedTab = 'a')} + onNavigateToAAAA={() => (selectedTab = 'aaaa')} /> {/if} diff --git a/src/routes/(console)/project-[region]-[project]/settings/domains/add-domain/verify-[domain]/+page.ts b/src/routes/(console)/project-[region]-[project]/settings/domains/add-domain/verify-[domain]/+page.ts index b89c93330..d4a8587b7 100644 --- a/src/routes/(console)/project-[region]-[project]/settings/domains/add-domain/verify-[domain]/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/settings/domains/add-domain/verify-[domain]/+page.ts @@ -3,18 +3,27 @@ import { isCloud } from '$lib/system'; import { Dependencies } from '$lib/constants.js'; import { type Models, Query } from '@appwrite.io/console'; -export const load = async ({ depends, parent }) => { - const { organization } = await parent(); +export const load = async ({ depends, parent, params, url }) => { + const { project, organization } = await parent(); depends(Dependencies.DOMAINS); - let domainsList: Models.DomainsList; - if (isCloud) { - domainsList = await sdk.forConsole.domains.list({ - queries: [Query.equal('teamId', organization.$id)] - }); + const ruleId = url.searchParams.get('rule'); + if (!ruleId) { + throw new Error('Rule ID is required'); } + const [proxyRule, domainsList] = await Promise.all([ + sdk.forProject(params.region, params.project).proxy.getRule({ ruleId }), + isCloud + ? sdk.forConsole.domains.list({ + queries: [Query.equal('teamId', organization.$id)] + }) + : Promise.resolve({ total: 0, domains: [] }) + ]); + return { - domainsList + project, + domainsList, + proxyRule }; }; diff --git a/src/routes/(console)/project-[region]-[project]/settings/domains/retryDomainModal.svelte b/src/routes/(console)/project-[region]-[project]/settings/domains/retryDomainModal.svelte index 844a2c94b..1098b8d3f 100644 --- a/src/routes/(console)/project-[region]-[project]/settings/domains/retryDomainModal.svelte +++ b/src/routes/(console)/project-[region]-[project]/settings/domains/retryDomainModal.svelte @@ -7,32 +7,81 @@ import { Submit, trackEvent, trackError } from '$lib/actions/analytics'; import { Dependencies } from '$lib/constants'; import type { Models } from '@appwrite.io/console'; - import CnameTable from '$lib/components/domains/cnameTable.svelte'; import { page } from '$app/state'; + import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store'; + import { isCloud } from '$lib/system'; + import { Divider, Tabs } from '@appwrite.io/pink-svelte'; + import NameserverTable from '$lib/components/domains/nameserverTable.svelte'; + import RecordTable from '$lib/components/domains/recordTable.svelte'; + import { getApexDomain } from '$lib/helpers/tlds'; let { show = $bindable(), - selectedDomain + selectedProxyRule, + domainsList }: { show: boolean; - selectedDomain: Models.ProxyRule; + selectedProxyRule: Models.ProxyRule; + domainsList?: Models.DomainsList; } = $props(); + const showCNAMETab = $derived( + Boolean($regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME) && + $regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME !== 'localhost' + ); + const showATab = $derived( + !isCloud && + Boolean($regionalConsoleVariables._APP_DOMAIN_TARGET_A) && + $regionalConsoleVariables._APP_DOMAIN_TARGET_A !== '127.0.0.1' + ); + const showAAAATab = $derived( + !isCloud && + Boolean($regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA) && + $regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA !== '::1' + ); + const showNSTab = isCloud; + + let selectedTab = $state<'cname' | 'nameserver' | 'a' | 'aaaa'>(getDefaultTab()); let error = $state(null); + let verified: boolean | undefined = $state(undefined); + + function getDefaultTab() { + return showCNAMETab ? 'cname' : showATab ? 'a' : showAAAATab ? 'aaaa' : 'nameserver'; + } + async function retryDomain() { + error = null; + verified = undefined; + try { - await sdk + const apexDomain = getApexDomain(selectedProxyRule.domain); + const domain = domainsList?.domains.find((d) => d.domain === apexDomain); + if (isCloud && domain) { + await sdk.forConsole.domains.updateNameservers({ + domainId: domain.$id + }); + } + } catch { + // Ignore error + } + + try { + selectedProxyRule = await sdk .forProject(page.params.region, page.params.project) - .proxy.updateRuleVerification({ ruleId: selectedDomain.$id }); + .proxy.updateRuleVerification({ ruleId: selectedProxyRule.$id }); + await invalidate(Dependencies.DOMAINS); show = false; addNotification({ type: 'success', - message: `${selectedDomain.domain} has been verified` + message: 'Domain verified successfully' }); trackEvent(Submit.DomainUpdateVerification); } catch (e) { - error = e.message; + verified = false; + error = + e.message ?? + 'Domain verification failed. Please check your domain settings or try again later'; trackError(e, Submit.DomainUpdateVerification); } } @@ -45,10 +94,58 @@ - {#if selectedDomain} - +
+ + {#if showCNAMETab} + (selectedTab = 'cname')} + active={selectedTab === 'cname'}> + CNAME + + {/if} + {#if showNSTab} + (selectedTab = 'nameserver')} + active={selectedTab === 'nameserver'}> + Nameservers + + {/if} + {#if showATab} + (selectedTab = 'a')} + active={selectedTab === 'a'}> + A + + {/if} + {#if showAAAATab} + (selectedTab = 'aaaa')} + active={selectedTab === 'aaaa'}> + AAAA + + {/if} + + +
+ {#if selectedTab === 'nameserver'} + + {:else} + (selectedTab = 'nameserver')} + onNavigateToA={() => (selectedTab = 'a')} + onNavigateToAAAA={() => (selectedTab = 'aaaa')} /> {/if} diff --git a/src/routes/(console)/project-[region]-[project]/settings/domains/table.svelte b/src/routes/(console)/project-[region]-[project]/settings/domains/table.svelte index 1558e8e3f..1412ddb75 100644 --- a/src/routes/(console)/project-[region]-[project]/settings/domains/table.svelte +++ b/src/routes/(console)/project-[region]-[project]/settings/domains/table.svelte @@ -3,10 +3,16 @@ import { Link } from '$lib/elements'; import { Button } from '$lib/elements/forms'; import type { Models } from '@appwrite.io/console'; - import { IconDotsHorizontal, IconRefresh, IconTrash } from '@appwrite.io/pink-icons-svelte'; + import { + IconDotsHorizontal, + IconRefresh, + IconTerminal, + IconTrash + } from '@appwrite.io/pink-icons-svelte'; import { ActionMenu, Badge, + Divider, Icon, Layout, Popover, @@ -17,6 +23,8 @@ import RetryDomainModal from './retryDomainModal.svelte'; import { regionalProtocol } from '../../store'; import DnsRecordsAction from '$lib/components/domains/dnsRecordsAction.svelte'; + import ViewLogsModal from '$lib/components/domains/viewLogsModal.svelte'; + import { timeFromNowShort } from '$lib/helpers/date'; let { domains, @@ -28,7 +36,8 @@ let showDelete = $state(false); let showRetry = $state(false); - let selectedDomain: Models.ProxyRule = $state(null); + let showLogs = $state(false); + let selectedProxyRule: Models.ProxyRule = $state(null); const columns = [ { @@ -36,9 +45,37 @@ title: 'Domain', type: 'string', format: 'string', - width: { min: 200, max: 550 } + width: { min: 600 } + }, + { + id: 'updated', + title: '', + type: 'string', + width: { min: 160, max: 180 } } ]; + + function updatedLabel(proxyRule: Models.ProxyRule): string { + if (proxyRule.status === 'verified') { + return ''; + } + + const timeStr = timeFromNowShort(proxyRule.$updatedAt); + if (timeStr === 'n/a') { + return ''; + } + + const prefix = + proxyRule.status === 'created' + ? 'Checked' + : proxyRule.status === 'verifying' + ? 'Updated' + : proxyRule.status === 'unverified' + ? 'Failed' + : ''; + + return prefix + ' ' + timeStr; + } @@ -50,7 +87,11 @@ {/each} - {#each domains.rules as domain} + {#each domains.rules as proxyRule (proxyRule.$id)} + {@const isRetryable = proxyRule.status === 'created' || proxyRule.status === 'unverified'} + {@const isLogsViewable = + proxyRule.logs?.length > 0 && + (proxyRule.status === 'verifying' || proxyRule.status === 'unverified')} {#each columns as column} @@ -58,21 +99,60 @@ + variant="quiet-muted" + href={`${$regionalProtocol}${proxyRule.domain}`}> - {domain.domain} + {proxyRule.domain} - {#if domain.status === 'verifying'} - - {:else if domain.status !== 'verified'} - - {/if} + + {#if proxyRule.status !== 'verified'} + + {/if} + {#if isRetryable} + { + e.preventDefault(); + selectedProxyRule = proxyRule; + showRetry = true; + }}> + Retry + + {/if} + {#if isLogsViewable} + { + e.preventDefault(); + selectedProxyRule = proxyRule; + showLogs = true; + }}> + View logs + + {/if} + + + {:else if column.id === 'updated' && proxyRule.status !== 'verified'} + + + {updatedLabel(proxyRule)} + {/if} @@ -92,23 +172,39 @@ - {#if domain.status !== 'verified' && domain.status !== 'verifiying'} + {#if isLogsViewable} + { + selectedProxyRule = proxyRule; + showLogs = true; + toggle(e); + }}> + View logs + + {/if} + {#if isRetryable} { - selectedDomain = domain; + selectedProxyRule = proxyRule; showRetry = true; toggle(e); }}> Retry {/if} - + + {#if isLogsViewable} +
+ +
+ {/if} { - selectedDomain = domain; + selectedProxyRule = proxyRule; showDelete = true; toggle(e); trackEvent(Click.DomainDeleteClick, { @@ -127,9 +223,19 @@ {#if showDelete} - + {/if} {#if showRetry} - + {/if} + +{#if showLogs} + +{/if} + + diff --git a/src/routes/(console)/project-[region]-[project]/settings/migrations/+page.svelte b/src/routes/(console)/project-[region]-[project]/settings/migrations/+page.svelte index b6f8083bc..cc82cb18c 100644 --- a/src/routes/(console)/project-[region]-[project]/settings/migrations/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/settings/migrations/+page.svelte @@ -38,13 +38,11 @@ let migration: Models.Migration = null; onMount(() => { - return realtime - .forProject(page.params.region, page.params.project) - .subscribe(['project', 'console'], (response) => { - if (response.events.includes('migrations.*')) { - invalidate(Dependencies.MIGRATIONS); - } - }); + return realtime.forProject(page.params.region, ['project', 'console'], (response) => { + if (response.events.includes('migrations.*')) { + invalidate(Dependencies.MIGRATIONS); + } + }); }); $: $registerCommands([ diff --git a/src/routes/(console)/project-[region]-[project]/settings/updateLabels.svelte b/src/routes/(console)/project-[region]-[project]/settings/updateLabels.svelte new file mode 100644 index 000000000..21d324ec4 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/settings/updateLabels.svelte @@ -0,0 +1,102 @@ + + + + + Labels + Categorize and manage your projects for easy searching based on specific criteria by assigning + them customizable labels. + + + {#key labels.length} + + {/key} + + {#each suggestedLabels as suggestedLabel} + { + if (!labels.includes(suggestedLabel)) { + labels = [...labels, suggestedLabel]; + } else { + labels = labels.filter((e) => e !== suggestedLabel); + } + }}> + + {suggestedLabel} + + {/each} + + {error ? error : 'Only alphanumeric characters are allowed'} + + + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/settings/webhooks/+page.svelte b/src/routes/(console)/project-[region]-[project]/settings/webhooks/+page.svelte index 4ee496dec..e7ec5e8d1 100644 --- a/src/routes/(console)/project-[region]-[project]/settings/webhooks/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/settings/webhooks/+page.svelte @@ -2,7 +2,7 @@ import { base } from '$app/paths'; import { page } from '$app/state'; import { Empty, Id } from '$lib/components'; - import { toLocaleDateTime } from '$lib/helpers/date'; + import DualTimeView from '$lib/components/dualTimeView.svelte'; import { Container } from '$lib/layout'; import { Button } from '$lib/elements/forms'; import type { PageData } from './$types'; @@ -58,7 +58,11 @@ {:else if column.id === 'events'} {webhook.events.length} {:else if column.type === 'datetime'} - {webhook[column.id] ? toLocaleDateTime(webhook[column.id]) : '-'} + {#if webhook[column.id]} + + {:else} + - + {/if} {:else if column.id === 'enabled'} + {@const effectiveStatus = getEffectiveBuildStatus( + deployment, + $regionalConsoleVariables + )} {#if !inCard}
@@ -70,7 +76,7 @@
Source is empty
{/if} - {#if deployment?.status === 'ready' && deployment?.$id !== activeDeployment} + {#if effectiveStatus === 'ready' && deployment?.$id !== activeDeployment} { @@ -82,7 +88,7 @@ Activate {/if} - {#if deployment?.status === 'ready' || deployment?.status === 'failed' || deployment?.status === 'building'} + {#if effectiveStatus === 'ready' || effectiveStatus === 'failed' || effectiveStatus === 'building'} @@ -112,7 +118,7 @@ {/if} - {#if deployment?.status === 'processing' || deployment?.status === 'building' || deployment.status === 'waiting'} + {#if effectiveStatus === 'processing' || effectiveStatus === 'building' || effectiveStatus === 'waiting'} {/if} - {#if deployment.status !== 'building' && deployment.status !== 'processing' && deployment?.status !== 'waiting'} + {#if ['ready', 'failed'].includes(deployment.status)} import { capitalize } from '$lib/helpers/string'; import { app } from '$lib/stores/app'; + import { getEffectiveBuildStatus } from '$lib/helpers/buildTimeout'; + import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store'; import type { Models } from '@appwrite.io/console'; import { Badge, Card, Layout, Logs, Spinner, Typography } from '@appwrite.io/pink-svelte'; import LogsTimer from './logsTimer.svelte'; @@ -38,15 +41,17 @@ emptyCopy?: string; } = $props(); + let effectiveStatus = $derived(getEffectiveBuildStatus(deployment, $regionalConsoleVariables)); + function setCopy() { - if (deployment.status === 'failed') { + if (effectiveStatus === 'failed') { return 'Your deployment has failed.'; - } else if (deployment.status === 'building') { + } else if (effectiveStatus === 'building') { //Do not remove empty space before the string it's an invisible character return 'Preparing for build ... \n'; - } else if (deployment.status === 'waiting') { + } else if (effectiveStatus === 'waiting') { return 'Preparing for build ... \n'; - } else if (deployment.status === 'processing') { + } else if (effectiveStatus === 'processing') { return 'Preparing for build ... \n'; } else { return emptyCopy; @@ -62,16 +67,16 @@ Deployment logs + type={badgeTypeDeployment(effectiveStatus)} /> - + {/if} - {#if ['waiting', 'processing'].includes(deployment.status) || (deployment.status === 'building' && !deployment?.buildLogs?.length)} + {#if ['waiting', 'processing'].includes(effectiveStatus) || (effectiveStatus === 'building' && !deployment?.buildLogs?.length)} Waiting for build to start... diff --git a/src/routes/(console)/project-[region]-[project]/sites/(components)/logsTimer.svelte b/src/routes/(console)/project-[region]-[project]/sites/(components)/logsTimer.svelte index 204ce91ec..79a00f541 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/(components)/logsTimer.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/(components)/logsTimer.svelte @@ -1,16 +1,19 @@ - {#if ['processing', 'building'].includes(status)} + {#if ['processing', 'building', 'finalizing'].includes(effectiveStatus)}

diff --git a/src/routes/(console)/project-[region]-[project]/sites/(components)/siteCard.svelte b/src/routes/(console)/project-[region]-[project]/sites/(components)/siteCard.svelte index 95562c0ad..eb392282a 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/(components)/siteCard.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/(components)/siteCard.svelte @@ -23,15 +23,38 @@ import { isCloud } from '$lib/system'; import { sdk } from '$lib/stores/sdk'; import { capitalize } from '$lib/helpers/string'; + import { getEffectiveBuildStatus } from '$lib/helpers/buildTimeout'; + import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store'; + import { regionalProtocol } from '$routes/(console)/project-[region]-[project]/store'; + import type { Snippet } from 'svelte'; - export let deployment: Models.Deployment; - export let proxyRuleList: Models.ProxyRuleList; - export let hideQRCode = false; - export let variant: 'primary' | 'secondary' = 'primary'; + let { + deployment, + proxyRuleList, + hideQRCode = false, + variant = 'primary', + footer + }: { + deployment: Models.Deployment; + proxyRuleList: Models.ProxyRuleList; + hideQRCode?: boolean; + variant?: 'primary' | 'secondary'; + footer?: Snippet; + } = $props(); - let show = false; + let effectiveStatus = $derived(getEffectiveBuildStatus(deployment, $regionalConsoleVariables)); + let show = $state(false); - $: totalSize = humanFileSize(deployment?.totalSize ?? 0); + const totalSize = $derived(humanFileSize(deployment?.totalSize ?? 0)); + + const sortedDomains = $derived( + proxyRuleList?.rules?.slice()?.sort((a, b) => { + if (a?.trigger === 'manual' && b?.trigger !== 'manual') return -1; + if (a?.trigger !== 'manual' && b?.trigger === 'manual') return 1; + return 0; + }) + ); + const primaryDomain = $derived(sortedDomains?.[0]?.domain); function getScreenshot(theme: string, deployment: Models.Deployment) { if (theme === 'dark') { @@ -51,7 +74,7 @@ fileId, width: 1024, height: 576, - output: ImageFormat.Webp + output: ImageFormat.Avif }); } @@ -59,13 +82,25 @@
- + {#if primaryDomain} + + + + {:else} + + {/if} @@ -81,15 +116,15 @@ - {#if deployment.status === 'failed'} + {#if effectiveStatus === 'failed'} Status + status={effectiveStatus} + label={capitalize(effectiveStatus)} /> {:else} @@ -178,13 +213,13 @@
- {#if $$slots.footer} + {#if footer} - + {@render footer?.()} {/if}
diff --git a/src/routes/(console)/project-[region]-[project]/sites/+page.svelte b/src/routes/(console)/project-[region]-[project]/sites/+page.svelte index 17022e7d7..3c4ee175a 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/+page.svelte @@ -25,7 +25,8 @@ import { onMount } from 'svelte'; import { invalidate } from '$app/navigation'; import { Dependencies } from '$lib/constants'; - import { sdk } from '$lib/stores/sdk'; + import { realtime } from '$lib/stores/sdk'; + import { page } from '$app/state'; export let data; @@ -49,7 +50,7 @@ $updateCommandGroupRanks({ sites: 1000 }); onMount(() => { - return sdk.forConsole.client.subscribe('console', (response) => { + return realtime.forConsole(page.params.region, 'console', (response) => { if (response.events.includes('sites.*')) { invalidate(Dependencies.SITES); } 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..4264c0229 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,13 @@ 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, + TemplateReferenceType + } 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 +178,8 @@ repository: data.repository.name, owner: data.repository.owner, rootDirectory: rootDir || '.', - version: latestTag ?? '1.0.0', + type: TemplateReferenceType.Tag, + reference: latestTag ?? '1.0.0', activate: true }); diff --git a/src/routes/(console)/project-[region]-[project]/sites/create-site/deploying/+page.svelte b/src/routes/(console)/project-[region]-[project]/sites/create-site/deploying/+page.svelte index 08f4f1047..b41104e09 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/create-site/deploying/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/create-site/deploying/+page.svelte @@ -1,43 +1,87 @@ - {#if ['processing', 'building'].includes(data.deployment.status)} + {#if ['processing', 'building', 'finalizing'].includes(effectiveStatus)} Deployment will continue in the background diff --git a/src/routes/(console)/project-[region]-[project]/sites/create-site/finish/+page.svelte b/src/routes/(console)/project-[region]-[project]/sites/create-site/finish/+page.svelte index 23e7a2d51..fe788db10 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/create-site/finish/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/create-site/finish/+page.svelte @@ -1,5 +1,5 @@ - +
@@ -83,9 +92,9 @@ deployment={data.deployment} proxyRuleList={data.proxyRuleList} hideQRCode> - + {#snippet footer()} - + {/snippet}
@@ -127,7 +136,7 @@ source: 'sites_create_finish' }); }} - href={`${base}/project-${page.params.region}-${page.params.project}/sites/site-${data.site.$id}/domains`}> + href={`${siteRedirectHref}/domains`}> - +
diff --git a/src/routes/(console)/project-[region]-[project]/sites/create-site/manual/+page.svelte b/src/routes/(console)/project-[region]-[project]/sites/create-site/manual/+page.svelte index 281b68314..5a4536527 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/create-site/manual/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/create-site/manual/+page.svelte @@ -1,6 +1,6 @@ diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/+page.svelte b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/+page.svelte index 97ea0bf4c..9a3c98817 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/+page.svelte @@ -7,19 +7,21 @@ import { Button } from '$lib/elements/forms'; import InstantRollbackDomain from './instantRollbackModal.svelte'; import { app } from '$lib/stores/app'; - import { sdk } from '$lib/stores/sdk'; + import { realtime } from '$lib/stores/sdk'; import { invalidate } from '$app/navigation'; import { Dependencies } from '$lib/constants'; import { onMount } from 'svelte'; import { page } from '$app/state'; import { base } from '$app/paths'; + import type { PageProps } from './$types'; import { regionalProtocol } from '$routes/(console)/project-[region]-[project]/store'; - export let data; - let showRollback = false; + let { data }: PageProps = $props(); + + let showRollback = $state(false); onMount(() => { - return sdk.forConsole.client.subscribe('console', (response) => { + return realtime.forConsole(page.params.region, 'console', (response) => { if (response.events.includes(`sites.${page.params.site}.deployments.*`)) { invalidate(Dependencies.SITE); } @@ -31,7 +33,7 @@ {#if data?.deployment && data.deployment.status === 'ready'} - + {#snippet footer()} {#if data.proxyRuleList.total} - - - -{/if} - - -

- Are you sure you want to delete {selectedRows.length} - {selectedRows.length > 1 ? 'deployments' : 'deployment'} from your site - - {$site.name}? -

- -

This action is irreversible.

-
diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/deploymentsOverview.svelte b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/deploymentsOverview.svelte index aef59781f..f9f2f8c0e 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/deploymentsOverview.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/deploymentsOverview.svelte @@ -11,6 +11,8 @@ import ActivateDeploymentModal from '../activateDeploymentModal.svelte'; import CancelDeploymentModal from './deployments/cancelDeploymentModal.svelte'; import { capitalize } from '$lib/helpers/string'; + import { getEffectiveBuildStatus } from '$lib/helpers/buildTimeout'; + import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store'; import DeleteDeploymentModal from './deployments/deleteDeploymentModal.svelte'; import DeploymentActionMenu from '../(components)/deploymentActionMenu.svelte'; import { deploymentStatusConverter } from '$lib/stores/git'; @@ -64,6 +66,12 @@
{#each deploymentList?.deployments as deployment (deployment.$id)} + {@const effectiveStatus = getEffectiveBuildStatus( + deployment, + $regionalConsoleVariables + )} + {@const displayStatus = + effectiveStatus === 'finalizing' ? 'ready' : effectiveStatus} @@ -71,13 +79,12 @@ {deployment.$id} - {@const status = deployment.status} {#if activeDeployment?.$id === deployment?.$id} {:else} + status={deploymentStatusConverter(displayStatus)} + label={capitalize(displayStatus)} /> {/if} diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/+layout.svelte b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/+layout.svelte new file mode 100644 index 000000000..c06e806c7 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/+layout.svelte @@ -0,0 +1,24 @@ + + + diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/+page.ts b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/+page.ts index a942cc3c0..25c021aa6 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/+page.ts @@ -28,6 +28,7 @@ export const load = async ({ params, depends, url, route, parent }) => { Query.limit(limit), Query.offset(offset), Query.orderDesc(''), + Query.orderDesc('$updatedAt'), ...parsedQueries.values() ], search: search || undefined diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/add-domain/+page.svelte b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/add-domain/+page.svelte index f0b6df34a..90f8c679d 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/add-domain/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/add-domain/+page.svelte @@ -57,13 +57,12 @@ async function addDomain() { const apexDomain = getApexDomain(domainName); - let domain = data.domains?.domains.find((d: Models.Domain) => d.domain === apexDomain); - const isSiteDomain = domainName.endsWith($regionalConsoleVariables._APP_DOMAIN_SITES); + const domain = data.domainsList.domains.find((d) => d.domain === apexDomain); if (isCloud && apexDomain && !domain && !isSiteDomain) { try { - domain = await sdk.forConsole.domains.create({ + await sdk.forConsole.domains.create({ teamId: $project.teamId, domain: apexDomain }); @@ -108,12 +107,18 @@ siteId: page.params.site }); } - if (rule?.status === 'verified') { + + await invalidate(Dependencies.SITES_DOMAINS); + + const verified = rule?.status !== 'created'; + if (verified) { + addNotification({ + type: 'success', + message: 'Domain verified successfully' + }); await goto(routeBase); - await invalidate(Dependencies.SITES_DOMAINS); } else { await goto(`${routeBase}/add-domain/verify-${domainName}?rule=${rule.$id}`); - await invalidate(Dependencies.SITES_DOMAINS); } } catch (error) { addNotification({ diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/add-domain/+page.ts b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/add-domain/+page.ts index f54e98097..dfff015a1 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/add-domain/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/add-domain/+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 { RuleTrigger, RuleType } from '$lib/stores/sdk'; import { Dependencies } from '$lib/constants.js'; @@ -8,7 +8,7 @@ export const load = async ({ parent, depends, params }) => { const { site, organization } = await parent(); depends(Dependencies.DOMAINS, Dependencies.SITES_DOMAINS); - const [rules, installations, domains] = await Promise.all([ + const [rules, installations, domainsList] = await Promise.all([ sdk.forProject(params.region, params.project).proxy.listRules({ queries: [ Query.equal('type', RuleType.DEPLOYMENT), @@ -20,13 +20,13 @@ export const load = async ({ parent, depends, params }) => { ? sdk.forConsole.domains.list({ queries: [Query.equal('teamId', organization.$id)] }) - : Promise.resolve(null) + : Promise.resolve({ total: 0, domains: [] }) ]); return { site, rules, - domains, + domainsList, installations, branches: site?.installationId && site?.providerRepositoryId diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/add-domain/verify-[domain]/+page.svelte b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/add-domain/verify-[domain]/+page.svelte index 8422d783a..83eb4f33d 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/add-domain/verify-[domain]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/add-domain/verify-[domain]/+page.svelte @@ -11,7 +11,6 @@ } from '@appwrite.io/pink-svelte'; import { Button, Form } from '$lib/elements/forms'; import { sdk } from '$lib/stores/sdk'; - import { organization } from '$lib/stores/organization'; import { addNotification } from '$lib/stores/notifications'; import { goto, invalidate } from '$app/navigation'; import { Dependencies } from '$lib/constants'; @@ -20,65 +19,70 @@ import Wizard from '$lib/layout/wizard.svelte'; import { base } from '$app/paths'; import { writable } from 'svelte/store'; - import { isASubdomain } from '$lib/helpers/tlds'; import RecordTable from '$lib/components/domains/recordTable.svelte'; import NameserverTable from '$lib/components/domains/nameserverTable.svelte'; import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store'; + import { getApexDomain } from '$lib/helpers/tlds.js'; let { data } = $props(); const ruleId = page.url.searchParams.get('rule'); - const isSubDomain = $derived.by(() => isASubdomain(page.params.domain)); - let selectedTab = $state<'cname' | 'nameserver' | 'a' | 'aaaa'>('nameserver'); - - $effect(() => { - if ($regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME && isSubDomain) { - selectedTab = 'cname'; - } else if (!isCloud && $regionalConsoleVariables._APP_DOMAIN_TARGET_A) { - selectedTab = 'a'; - } else if (!isCloud && $regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA) { - selectedTab = 'aaaa'; - } else { - selectedTab = 'nameserver'; - } - }); - - let verified = $state(false); + const showCNAMETab = $derived( + Boolean($regionalConsoleVariables._APP_DOMAIN_SITES) && + $regionalConsoleVariables._APP_DOMAIN_SITES !== 'localhost' + ); + const showATab = $derived( + !isCloud && + Boolean($regionalConsoleVariables._APP_DOMAIN_TARGET_A) && + $regionalConsoleVariables._APP_DOMAIN_TARGET_A !== '127.0.0.1' + ); + const showAAAATab = $derived( + !isCloud && + Boolean($regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA) && + $regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA !== '::1' + ); + const showNSTab = isCloud; + let proxyRule = $derived(data.proxyRule); + let selectedTab = $state<'cname' | 'nameserver' | 'a' | 'aaaa'>(getDefaultTab()); let routeBase = `${base}/project-${page.params.region}-${page.params.project}/sites/site-${page.params.site}/domains`; - let isSubmitting = $state(writable(false)); + let verified: boolean | undefined = $state(undefined); + const isSubmitting = writable(false); + + function getDefaultTab() { + return showCNAMETab ? 'cname' : showATab ? 'a' : showAAAATab ? 'aaaa' : 'nameserver'; + } async function verify() { - const isNewDomain = - data.domainsList.domains.findIndex((rule) => rule.domain === page.params.domain) === -1; - try { - if (selectedTab !== 'nameserver') { - const ruleData = await sdk - .forProject(page.params.region, page.params.project) - .proxy.updateRuleVerification({ ruleId }); - verified = ruleData.status === 'verified'; - throw new Error( - 'Domain verification failed. Please check your domain settings or try again later' - ); - } else if (isNewDomain && isCloud) { - const domainData = await sdk.forConsole.domains.create({ - teamId: $organization.$id, - domain: page.params.domain - }); - verified = domainData.nameservers.toLowerCase() === 'appwrite'; - throw new Error( - 'Domain verification failed. Please check your domain settings or try again later' - ); - } + verified = undefined; + try { + const apexDomain = getApexDomain(proxyRule.domain); + const domain = data.domainsList.domains.find((d) => d.domain === apexDomain); + if (isCloud && domain) { + await sdk.forConsole.domains.updateNameservers({ + domainId: domain.$id + }); + } + } catch (error) { + // Ignore error + } + + try { + proxyRule = await sdk + .forProject(page.params.region, page.params.project) + .proxy.updateRuleVerification({ ruleId }); + + await Promise.all([ + invalidate(Dependencies.DOMAINS), + invalidate(Dependencies.SITES_DOMAINS) + ]); + await goto(routeBase); addNotification({ type: 'success', - message: 'Domain added successfully' + message: 'Domain verified successfully' }); - await goto(routeBase); - await invalidate(Dependencies.DOMAINS); - await invalidate(Dependencies.SITES_DOMAINS); } catch (error) { verified = false; isSubmitting.set(false); @@ -95,12 +99,12 @@ .forProject(page.params.region, page.params.project) .proxy.deleteRule({ ruleId }); } - await goto(`${routeBase}/add-domain?domain=${page.params.domain}`); + await goto(`${routeBase}/add-domain?domain=${proxyRule.domain}`); } -
+ - {page.params.domain} + {proxyRule.domain} @@ -123,7 +127,7 @@
- {#if isSubDomain && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME && $regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME !== 'localhost'} + {#if showCNAMETab} (selectedTab = 'cname')} @@ -131,7 +135,7 @@ CNAME {/if} - {#if isCloud} + {#if showNSTab} (selectedTab = 'nameserver')} @@ -139,7 +143,7 @@ Nameservers {/if} - {#if !isCloud && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_A && $regionalConsoleVariables._APP_DOMAIN_TARGET_A !== '127.0.0.1'} + {#if showATab} (selectedTab = 'a')} @@ -147,7 +151,7 @@ A {/if} - {#if !isCloud && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA && $regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA !== '::1'} + {#if showAAAATab} (selectedTab = 'aaaa')} @@ -159,13 +163,20 @@
{#if selectedTab === 'nameserver'} - + {:else} + domain={proxyRule.domain} + ruleStatus={proxyRule.status} + onNavigateToNameservers={() => (selectedTab = 'nameserver')} + onNavigateToA={() => (selectedTab = 'a')} + onNavigateToAAAA={() => (selectedTab = 'aaaa')} /> {/if} diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/add-domain/verify-[domain]/+page.ts b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/add-domain/verify-[domain]/+page.ts index 570e1b27a..4657b835a 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/add-domain/verify-[domain]/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/add-domain/verify-[domain]/+page.ts @@ -3,19 +3,27 @@ import { isCloud } from '$lib/system'; import { Dependencies } from '$lib/constants.js'; import { type Models, Query } from '@appwrite.io/console'; -export const load = async ({ parent, depends }) => { +export const load = async ({ parent, depends, params, url }) => { const { site, organization } = await parent(); depends(Dependencies.SITES_DOMAINS); - let domainsList: Models.DomainsList; - if (isCloud) { - domainsList = await sdk.forConsole.domains.list({ - queries: [Query.equal('teamId', organization.$id)] - }); + const ruleId = url.searchParams.get('rule'); + if (!ruleId) { + throw new Error('Rule ID is required'); } + const [proxyRule, domainsList] = await Promise.all([ + sdk.forProject(params.region, params.project).proxy.getRule({ ruleId }), + isCloud + ? sdk.forConsole.domains.list({ + queries: [Query.equal('teamId', organization.$id)] + }) + : Promise.resolve({ total: 0, domains: [] }) + ]); + return { site, + proxyRule, domainsList }; }; diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/retryDomainModal.svelte b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/retryDomainModal.svelte index 949d8079c..56fa02b6d 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/retryDomainModal.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/retryDomainModal.svelte @@ -10,54 +10,75 @@ import { Divider, Tabs } from '@appwrite.io/pink-svelte'; import { isCloud } from '$lib/system'; import { page } from '$app/state'; - import { isASubdomain } from '$lib/helpers/tlds'; import NameserverTable from '$lib/components/domains/nameserverTable.svelte'; import RecordTable from '$lib/components/domains/recordTable.svelte'; import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store'; + import { getApexDomain } from '$lib/helpers/tlds'; let { show = $bindable(false), - selectedProxyRule + selectedProxyRule, + domainsList }: { show: boolean; selectedProxyRule: Models.ProxyRule; + domainsList?: Models.DomainsList; } = $props(); - const isSubDomain = $derived.by(() => isASubdomain(selectedProxyRule?.domain)); - - let selectedTab = $state<'cname' | 'nameserver' | 'a' | 'aaaa'>('nameserver'); - - $effect(() => { - if ($regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME && isSubDomain) { - selectedTab = 'cname'; - } else if (!isCloud && $regionalConsoleVariables._APP_DOMAIN_TARGET_A) { - selectedTab = 'a'; - } else if (!isCloud && $regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA) { - selectedTab = 'aaaa'; - } else { - selectedTab = 'nameserver'; - } - }); + const showCNAMETab = $derived( + Boolean($regionalConsoleVariables._APP_DOMAIN_SITES) && + $regionalConsoleVariables._APP_DOMAIN_SITES !== 'localhost' + ); + const showATab = $derived( + !isCloud && + Boolean($regionalConsoleVariables._APP_DOMAIN_TARGET_A) && + $regionalConsoleVariables._APP_DOMAIN_TARGET_A !== '127.0.0.1' + ); + const showAAAATab = $derived( + !isCloud && + Boolean($regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA) && + $regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA !== '::1' + ); + const showNSTab = isCloud; + let selectedTab = $state<'cname' | 'nameserver' | 'a' | 'aaaa'>(getDefaultTab()); let error = $state(null); - let verified = $state(false); + let verified: boolean | undefined = $state(undefined); + + function getDefaultTab() { + return showCNAMETab ? 'cname' : showATab ? 'a' : showAAAATab ? 'aaaa' : 'nameserver'; + } async function retryDomain() { + error = null; + verified = undefined; + try { - const domain = await sdk + const apexDomain = getApexDomain(selectedProxyRule.domain); + const domain = domainsList?.domains.find((d) => d.domain === apexDomain); + if (isCloud && domain) { + await sdk.forConsole.domains.updateNameservers({ + domainId: domain.$id + }); + } + } catch { + // Ignore error + } + + try { + selectedProxyRule = await sdk .forProject(page.params.region, page.params.project) .proxy.updateRuleVerification({ ruleId: selectedProxyRule.$id }); - show = false; - verified = domain.status === 'verified'; await invalidate(Dependencies.SITES_DOMAINS); - + show = false; addNotification({ type: 'success', - message: `${selectedProxyRule.domain} has been verified` + message: 'Domain verified successfully' }); trackEvent(Submit.DomainUpdateVerification); } catch (e) { + verified = false; error = e.message ?? 'Domain verification failed. Please check your domain settings or try again later'; @@ -75,7 +96,7 @@
- {#if isSubDomain && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME && $regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME !== 'localhost'} + {#if showCNAMETab} (selectedTab = 'cname')} @@ -83,7 +104,7 @@ CNAME {/if} - {#if isCloud} + {#if showNSTab} (selectedTab = 'nameserver')} @@ -91,7 +112,7 @@ Nameservers {/if} - {#if !isCloud && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_A && $regionalConsoleVariables._APP_DOMAIN_TARGET_A !== '127.0.0.1'} + {#if showATab} (selectedTab = 'a')} @@ -99,7 +120,7 @@ A {/if} - {#if !isCloud && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA && $regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA !== '::1'} + {#if showAAAATab} (selectedTab = 'aaaa')} @@ -111,13 +132,20 @@
{#if selectedTab === 'nameserver'} - + {:else} + domain={selectedProxyRule.domain} + ruleStatus={selectedProxyRule.status} + onNavigateToNameservers={() => (selectedTab = 'nameserver')} + onNavigateToA={() => (selectedTab = 'a')} + onNavigateToAAAA={() => (selectedTab = 'aaaa')} /> {/if} diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/store.ts b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/store.ts index 902fb3e3e..a3ec2fdb9 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/store.ts +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/store.ts @@ -7,13 +7,20 @@ export const columns = writable([ title: 'Domain', type: 'string', format: 'string', - width: { min: 200 } + width: { min: 600 } }, { id: 'target', title: 'Target', type: 'string', - width: { min: 120, max: 400 } + width: { min: 160, max: 400 } + }, + + { + id: 'updated', + title: '', + type: 'string', + width: { min: 160, max: 180 } } ]); diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/table.svelte b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/table.svelte index b5b755480..b1a1b4447 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/table.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/table.svelte @@ -21,10 +21,11 @@ } from '@appwrite.io/pink-svelte'; import DeleteDomainModal from './deleteDomainModal.svelte'; import RetryDomainModal from './retryDomainModal.svelte'; - import ViewLogsModal from './viewLogsModal.svelte'; import { columns } from './store'; import { regionalProtocol } from '$routes/(console)/project-[region]-[project]/store'; import DnsRecordsAction from '$lib/components/domains/dnsRecordsAction.svelte'; + import ViewLogsModal from '$lib/components/domains/viewLogsModal.svelte'; + import { timeFromNowShort } from '$lib/helpers/date'; let { proxyRules, @@ -46,6 +47,28 @@ ? 'Deployed from ' + proxy.deploymentVcsProviderBranch : 'Active deployment'; }; + + function updatedLabel(proxyRule: Models.ProxyRule): string { + if (proxyRule.status === 'verified') { + return ''; + } + + const timeStr = timeFromNowShort(proxyRule.$updatedAt); + if (timeStr === 'n/a') { + return ''; + } + + const prefix = + proxyRule.status === 'created' + ? 'Checked' + : proxyRule.status === 'verifying' + ? 'Updated' + : proxyRule.status === 'unverified' + ? 'Failed' + : ''; + + return prefix + ' ' + timeStr; + } @@ -57,7 +80,11 @@ {/each} - {#each proxyRules.rules as rule} + {#each proxyRules.rules as proxyRule (proxyRule.$id)} + {@const isRetryable = proxyRule.status === 'created' || proxyRule.status === 'unverified'} + {@const isLogsViewable = + proxyRule.logs?.length > 0 && + (proxyRule.status === 'verifying' || proxyRule.status === 'unverified')} {#each $columns as column} @@ -65,25 +92,63 @@ + variant="quiet-muted" + href={`${$regionalProtocol}${proxyRule.domain}`}> - {rule.domain} + {proxyRule.domain} - - {#if rule.status === 'verifying'} - - {:else if rule.status !== 'verified'} - - {/if} + + {#if proxyRule.status !== 'verified'} + + {/if} + {#if isRetryable} + { + e.preventDefault(); + selectedProxyRule = proxyRule; + showRetry = true; + }}> + Retry + + {/if} + {#if isLogsViewable} + { + e.preventDefault(); + selectedProxyRule = proxyRule; + showLogs = true; + }}> + View logs + + {/if} + {:else if column.id === 'target'} - {proxyTarget(rule)} + {proxyTarget(proxyRule)} + {:else if column.id === 'updated' && proxyRule.status !== 'verified'} + + + {updatedLabel(proxyRule)} + + {/if} {/each} @@ -102,30 +167,30 @@ - {#if rule.logs && (rule.status === 'unverified' || rule.status === 'verifying')} + {#if isLogsViewable} { - selectedProxyRule = rule; + selectedProxyRule = proxyRule; showLogs = true; toggle(e); }}> View logs {/if} - {#if rule.status !== 'verified' && rule.status !== 'verifying'} + {#if isRetryable} { - selectedProxyRule = rule; + selectedProxyRule = proxyRule; showRetry = true; toggle(e); }}> Retry {/if} - - {#if rule.logs && (rule.status === 'unverified' || rule.status === 'verifying')} + + {#if isLogsViewable}
@@ -134,7 +199,7 @@ status="danger" leadingIcon={IconTrash} on:click={(e) => { - selectedProxyRule = rule; + selectedProxyRule = proxyRule; showDelete = true; toggle(e); trackEvent(Click.DomainDeleteClick, { @@ -157,7 +222,7 @@ {/if} {#if showRetry} - + {/if} {#if showLogs} @@ -167,7 +232,5 @@ diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/logs/+page.svelte b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/logs/+page.svelte index c7a97e15d..3ad6b4645 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/logs/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/logs/+page.svelte @@ -4,16 +4,17 @@ import { Dependencies } from '$lib/constants'; import { Button } from '$lib/elements/forms'; import { Container, ResponsiveContainerHeader } from '$lib/layout'; - import { sdk } from '$lib/stores/sdk'; + import { realtime } from '$lib/stores/sdk'; import { onMount } from 'svelte'; import Table from './table.svelte'; import { Card, Empty } from '@appwrite.io/pink-svelte'; import { columns } from './store'; + import { page } from '$app/state'; export let data; onMount(() => { - return sdk.forConsole.client.subscribe('console', (response) => { + return realtime.forConsole(page.params.region, 'console', (response) => { if (response.events.includes('sites.*.executions.*')) { invalidate(Dependencies.EXECUTIONS); } diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/logs/table.svelte b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/logs/table.svelte index 9d74a095d..de2504715 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/logs/table.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/logs/table.svelte @@ -1,137 +1,112 @@ - - + + {#snippet header(root)} {#each filteredColumns as { id, title }} {title} {/each} - - {#each logs.executions as log (log.$id)} - { - e.stopPropagation(); - openSheet = true; - selectedLogId = log.$id; - }}> - {#each filteredColumns as column} - - {#if column.id === '$id'} - {#key column.id} - {log.$id} - {/key} - {:else if column.id === 'deploymentId'} - {log.deploymentId} - {:else if column.id === 'requestMethod'} - - {log.requestMethod} - - {:else if column.id === 'duration'} - {#if ['processing', 'waiting'].includes(log.status)} - - {:else} - {calculateTime(log.duration)} + {/snippet} + + {#snippet children(root)} + {#each logs.executions as log (log.$id)} + { + e.stopPropagation(); + openSheet = true; + selectedLogId = log.$id; + }}> + {#each filteredColumns as column} + + {#if column.id === '$id'} + {#key column.id} + {log.$id} + {/key} + {:else if column.id === 'deploymentId'} + {log.deploymentId} + {:else if column.id === 'requestMethod'} + + {log.requestMethod} + + {:else if column.id === 'duration'} + {#if ['processing', 'waiting'].includes(log.status)} + + {:else} + {calculateTime(log.duration)} + {/if} + {:else if column.id === 'responseStatusCode'} +
+ +
+ {:else if column.id === 'requestPath'} + + {log.requestPath} + + {:else if column.id === '$createdAt'} + {/if} - {:else if column.id === 'responseStatusCode'} -
- -
- {:else if column.id === 'requestPath'} - - {log.requestPath} - - {:else if column.id === '$createdAt'} - - {/if} -
- {/each} -
- {/each} -
+ + {/each} + + {/each} + {/snippet} + - -{#if selectedRows.length > 0} - - - - - {selectedRows.length > 1 ? 'logs' : 'log'} - selected - - - - - - - -{/if} - - -

- Are you sure you want to delete {selectedRows.length} - {selectedRows.length > 1 ? 'logs' : 'log'}? -

- -

This action is irreversible.

-
diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/+page.ts b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/+page.ts index 1d856b016..d71de3091 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/+page.ts @@ -1,5 +1,6 @@ import { sdk } from '$lib/stores/sdk'; import { Dependencies } from '$lib/constants'; +import { isCloud } from '$lib/system'; export const load = async ({ params, depends, parent }) => { depends(Dependencies.VARIABLES); @@ -14,7 +15,9 @@ export const load = async ({ params, depends, parent }) => { .sites.listVariables({ siteId: params.site }), sdk.forProject(params.region, params.project).sites.listFrameworks(), sdk.forProject(params.region, params.project).vcs.listInstallations(), - sdk.forProject(params.region, params.project).sites.listSpecifications() + isCloud + ? sdk.forProject(params.region, params.project).sites.listSpecifications() + : Promise.resolve({ specifications: [], total: 0 }) ]); // Conflicting variables first diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/store.ts b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/store.ts index e66ee97b3..59ff9cb35 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/store.ts +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/store.ts @@ -53,15 +53,28 @@ export const adapterDataList = [ url: 'https://nuxt.com/docs/getting-started/deployment#static-hosting' } }, + { + framework: 'tanstack-start', + ssr: { + desc: 'Ensure $ includes $ plugin.', + code: ['vite.config.js', 'tanstackStart()'], + url: 'https://tanstack.com/start/latest/docs/framework/react/guide/hosting' + }, + static: { + desc: 'Set $ to $ in $.', + code: ['prerender', 'enabled', 'vite.config.js'], + url: 'https://tanstack.com/start/latest/docs/framework/react/guide/static-prerendering' + } + }, { framework: 'nextjs', ssr: { - desc: "Ensure you don't set $ in $ file.", - code: ['output', 'next.config.js'], + desc: 'Set $ in $ file.', + code: ["output: 'standalone'", 'next.config.js'], url: 'https://nextjs.org/docs/pages/building-your-application/deploying' }, static: { - desc: 'Set $ in $ file', + desc: 'Set $ in $ file.', code: ["output: 'export'", 'next.config.js'], url: 'https://nextjs.org/docs/pages/building-your-application/deploying/static-exports' } @@ -69,12 +82,12 @@ export const adapterDataList = [ { framework: 'analog', ssr: { - desc: 'Set $ in $ plugin in $', + desc: 'Set $ in $ plugin in $.', code: ['ssr: true', 'analog', 'vite.config.ts'], url: 'https://analogjs.org/docs/features/server/server-side-rendering' }, static: { - desc: 'Set $ in $ plugin in $', + desc: 'Set $ in $ plugin in $.', code: ['static: true', 'analog', 'vite.config.ts'], url: 'https://analogjs.org/docs/features/server/static-site-generation' } diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/updateBuildSettings.svelte b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/updateBuildSettings.svelte index 1718ebca7..0f4cbeef3 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/updateBuildSettings.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/updateBuildSettings.svelte @@ -36,21 +36,34 @@ frameworks.find((framework) => framework.key === site.framework) ); let showFallback = $derived(adapter === Adapter.Static); - let hasChanges = $derived( + + let isUntouched = $derived( installCommand === site?.installCommand && buildCommand === site?.buildCommand && outputDirectory === site?.outputDirectory && selectedFramework?.key === site?.framework && - fallback === (site?.fallbackFile || undefined) && - adapter === site?.adapter + (fallback ?? '') === (site?.fallbackFile ?? '') && + (adapter ?? '') === (site?.adapter ?? '') ); let frameworkAdapterData = $derived( selectedFramework.adapters.find((a) => a.key === adapter) ?? selectedFramework.adapters[0] ); + $effect(() => { + if (adapter) { + const data = selectedFramework.adapters.find((a) => a.key === adapter); + if (data) { + installCommand = data.installCommand; + buildCommand = data.buildCommand; + outputDirectory = data.outputDirectory; + fallback = data.fallbackFile; + } + } + }); + $effect(() => { if (selectedFramework?.key !== site.framework) { - //Update adapter + // Update adapter const singleAdapter = selectedFramework?.adapters?.length <= 1; if (singleAdapter) { const hasSSR = selectedFramework?.adapters?.some((a) => a?.key === Adapter.Ssr); @@ -65,11 +78,13 @@ } //Update values - const data = selectedFramework.adapters.find((a) => a.key === adapter); + const data = + selectedFramework.adapters.find((a) => a.key === adapter) ?? + selectedFramework.adapters[0]; installCommand = data.installCommand; buildCommand = data.buildCommand; outputDirectory = data.outputDirectory; - adapter = selectedFramework.adapters[0].key as Adapter; + adapter = data.key as Adapter; fallback = data.fallbackFile; } else { adapter = site.adapter as Adapter; @@ -157,11 +172,11 @@ const data = selectedFramework.adapters.find((a) => a.key === adapter); if (type === 'installCommand') { - installCommand = site?.installCommand ?? data.installCommand; + installCommand = data.installCommand; } else if (type === 'buildCommand') { - buildCommand = site?.buildCommand ?? data.buildCommand; + buildCommand = data.buildCommand; } else if (type === 'outputDirectory') { - outputDirectory = site?.outputDirectory ?? data.outputDirectory; + outputDirectory = data.outputDirectory; } } @@ -216,7 +231,8 @@ {adapterData.ssr.desc} {/if} {#if adapterData?.ssr?.url} - Learn more + Learn more {/if} Learn more + Learn more {/if} @@ -258,7 +278,11 @@ placeholder={frameworkAdapterData?.installCommand || 'Enter install command'} /> -
@@ -269,7 +293,11 @@ bind:value={buildCommand} placeholder={frameworkAdapterData?.buildCommand || 'Enter build command'} /> -
@@ -280,7 +308,12 @@ bind:value={outputDirectory} placeholder={frameworkAdapterData?.outputDirectory || 'Enter output directory'} /> -
@@ -305,7 +338,7 @@ - + diff --git a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/updateResourceLimits.svelte b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/updateResourceLimits.svelte index 40fb7a83f..bc4dcea09 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/updateResourceLimits.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/settings/updateResourceLimits.svelte @@ -58,7 +58,7 @@ } } - const options = specs.specifications.map((spec) => ({ + const options = (specs?.specifications ?? []).map((spec) => ({ label: `${spec.cpus} CPU, ${spec.memory} MB RAM`, value: spec.slug, disabled: !spec.enabled diff --git a/src/routes/(console)/project-[region]-[project]/sites/table.svelte b/src/routes/(console)/project-[region]-[project]/sites/table.svelte index 680c82d38..3f15ba97d 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/table.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/table.svelte @@ -1,7 +1,7 @@ @@ -365,6 +379,28 @@ +
+ + Image transformations + + + + + + + + + +
@@ -381,7 +417,7 @@ placeholder="Select or type user labels" bind:tags={extensions} /> {/key} - + {#each suggestedExtensions as ext} {#if isCloud} {@const size = humanFileSize(sizeToBytes(service, 'MB', 1000))} - - The {currentPlan.name} plan has a maximum upload file size limit of {Math.floor( - parseInt(size.value) - )}{size.unit}. - {#if $organization?.billingPlan === BillingPlan.FREE} - Upgrade to allow files of a larger size. - {/if} - - {#if $organization?.billingPlan === BillingPlan.FREE} + {#if $organization?.billingPlan === BillingPlan.FREE} + + The {currentPlan.name} plan has a maximum upload file size limit of {Math.floor( + parseInt(size.value) + )}{size.unit}. Upgrade to allow files of a larger size. +
- {/if} -
-
+
+
+ {:else} + + The {currentPlan.name} plan has a maximum upload file size limit of {Math.floor( + parseInt(size.value) + )}{size.unit}. + + {/if} {/if} + {:else} - {toLocaleDateTime(bucket[column.id])} + {bucket[column.id]} {/if} {/each} diff --git a/src/routes/(console)/supportWizard.svelte b/src/routes/(console)/supportWizard.svelte index 6c6aa7045..7fe3fc710 100644 --- a/src/routes/(console)/supportWizard.svelte +++ b/src/routes/(console)/supportWizard.svelte @@ -1,11 +1,26 @@ +{#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 +247,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()} +
+
+ + + Drag and drop a file here or click to upload + Max file size: 5MB + + + {#if files} + { + return { + ...f, + name: f.name, + size: f.size, + extension: f.type, + removable: true + }; + })} + on:remove={(e) => (files = removeFile(e.detail, files))} /> + {/if} - { 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)/(guest)/login/+page.svelte b/src/routes/(public)/(guest)/login/+page.svelte index e9f8f25b4..529fcf685 100644 --- a/src/routes/(public)/(guest)/login/+page.svelte +++ b/src/routes/(public)/(guest)/login/+page.svelte @@ -94,6 +94,15 @@
+ {#if isCloud} +
+ +
+ or + {/if} - {#if isCloud} - or - - {/if}
diff --git a/src/routes/(public)/(guest)/register/+page.svelte b/src/routes/(public)/(guest)/register/+page.svelte index aafbbd2b6..ec7608b13 100644 --- a/src/routes/(public)/(guest)/register/+page.svelte +++ b/src/routes/(public)/(guest)/register/+page.svelte @@ -106,9 +106,17 @@ } function onGithubLogin() { + let successUrl = window.location.origin; + + if (page.url.searchParams.has('code')) { + successUrl += `?code=${page.url.searchParams.get('code')}`; + } else if (page.url.searchParams.has('campaign')) { + successUrl += `?campaign=${page.url.searchParams.get('campaign')}`; + } + sdk.forConsole.account.createOAuth2Session({ provider: OAuthProvider.Github, - success: window.location.origin, + success: successUrl, failure: window.location.origin, scopes: ['read:user', 'user:email'] }); @@ -124,6 +132,16 @@
+ {#if isCloud} +
+ +
+ or + {/if} + . - - {#if isCloud} - or - - {/if}
diff --git a/src/routes/(public)/(guest)/register/invite/[slug]/+page.svelte b/src/routes/(public)/(guest)/register/invite/[slug]/+page.svelte index 898d16358..fdd6c247a 100644 --- a/src/routes/(public)/(guest)/register/invite/[slug]/+page.svelte +++ b/src/routes/(public)/(guest)/register/invite/[slug]/+page.svelte @@ -57,7 +57,7 @@ const res = await fetch(`${endpoint}/account/invite`, { method: 'POST', headers: { - 'X-Appwrite-Project': project, + 'X-Appwrite-Project': project as string, 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/src/routes/(public)/functions/deploy/+page.svelte b/src/routes/(public)/functions/deploy/+page.svelte index 4b353d17b..0399cf3a3 100644 --- a/src/routes/(public)/functions/deploy/+page.svelte +++ b/src/routes/(public)/functions/deploy/+page.svelte @@ -43,7 +43,11 @@ loadingProjects = true; projects = await sdk.forConsole.projects.list({ - queries: [Query.equal('teamId', selectedOrg), Query.orderDesc('')] + queries: [ + Query.equal('teamId', selectedOrg), + Query.orderDesc(''), + Query.select(['$id', 'name']) + ] }); selectedProject = projects?.total ? projects.projects[0].$id : null; @@ -215,9 +219,9 @@ : undefined} disabled={loadingProjects} options={[ - ...(projects?.projects?.map((p) => ({ - label: p.name, - value: p.$id + ...(projects?.projects?.map((project) => ({ + label: project.name, + value: project.$id })) ?? []), { label: 'Create project', diff --git a/src/routes/(public)/functions/deploy/+page.ts b/src/routes/(public)/functions/deploy/+page.ts index 978894e78..d91d084ff 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, Platform } 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', 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', Platform.Appwrite) + ]); } else { organizations = await sdk.forConsole.teams.list(); } diff --git a/src/routes/(public)/sites/deploy/+page.svelte b/src/routes/(public)/sites/deploy/+page.svelte index ef6000fbb..0d258793a 100644 --- a/src/routes/(public)/sites/deploy/+page.svelte +++ b/src/routes/(public)/sites/deploy/+page.svelte @@ -48,7 +48,11 @@ async function fetchProjects() { loadingProjects = true; projects = await sdk.forConsole.projects.list({ - queries: [Query.equal('teamId', selectedOrg), Query.orderDesc('')] + queries: [ + Query.equal('teamId', selectedOrg), + Query.orderDesc(''), + Query.select(['$id', 'name']) + ] }); selectedProject = projects?.total ? projects.projects[0].$id : null; @@ -87,7 +91,7 @@ loadingProjects = false; } } else { - const project = projects.projects.find((p) => p.$id === selectedProject); + const project = projects.projects.find((project) => project.$id === selectedProject); if (!project) { addNotification({ type: 'error', message: 'Selected project not found' }); return; @@ -371,9 +375,9 @@ : undefined} disabled={loadingProjects} options={[ - ...(projects?.projects?.map((p) => ({ - label: p.name, - value: p.$id + ...(projects?.projects?.map((project) => ({ + label: project.name, + value: project.$id })) ?? []), { label: 'Create project', diff --git a/src/routes/(public)/sites/deploy/+page.ts b/src/routes/(public)/sites/deploy/+page.ts index 991de1bf5..ca57d36e3 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, Platform } 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', 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', Platform.Appwrite) + ]); } else { organizations = await sdk.forConsole.teams.list(); } diff --git a/src/routes/(public)/template-[template]/+page.svelte b/src/routes/(public)/template-[template]/+page.svelte index b82f6787c..600914e15 100644 --- a/src/routes/(public)/template-[template]/+page.svelte +++ b/src/routes/(public)/template-[template]/+page.svelte @@ -55,7 +55,11 @@ async function fetchProjects() { projects = await sdk.forConsole.projects.list({ - queries: [Query.equal('teamId', selectedOrg), Query.orderDesc('')] + queries: [ + Query.equal('teamId', selectedOrg), + Query.orderDesc(''), + Query.select(['$id', 'name', 'region']) + ] }); selectedProject = projects?.total ? projects.projects[0].$id : null; } @@ -180,9 +184,9 @@ label="Project" required options={[ - ...projects.projects.map((p) => ({ - label: p.name, - value: p.$id + ...projects.projects.map((project) => ({ + label: project.name, + value: project.$id })), { label: 'Create project', diff --git a/src/routes/(public)/template-[template]/+page.ts b/src/routes/(public)/template-[template]/+page.ts index 80f486c7f..e2dac3adf 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, Platform } 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,11 @@ 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', Platform.Appwrite) + ]) + : undefined; } else { organizations = account?.$id ? await sdk.forConsole.teams.list() : undefined; } @@ -49,7 +53,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..c1d724892 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -17,6 +17,7 @@ import { ThemeDark, ThemeLight, ThemeDarkCloud, ThemeLightCloud } from '../themes'; import { isSmallViewport, updateViewport } from '$lib/stores/viewport'; import { feedback } from '$lib/stores/feedback'; + import '$lib/profiles/css/base.css'; function resolveTheme(theme: AppStore['themeInUse']) { switch (theme) { @@ -289,6 +290,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..bcd3e307f 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, Platform } 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', Platform.Appwrite) + ]) }; } diff --git a/static/icons/dark/color/resend.svg b/static/icons/dark/color/resend.svg new file mode 100644 index 000000000..0ded97051 --- /dev/null +++ b/static/icons/dark/color/resend.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/dark/color/tanstack.svg b/static/icons/dark/color/tanstack.svg new file mode 100644 index 000000000..5823a237b --- /dev/null +++ b/static/icons/dark/color/tanstack.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/light/color/resend.svg b/static/icons/light/color/resend.svg new file mode 100644 index 000000000..ad9911369 --- /dev/null +++ b/static/icons/light/color/resend.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/light/color/tanstack.svg b/static/icons/light/color/tanstack.svg new file mode 100644 index 000000000..76011176f --- /dev/null +++ b/static/icons/light/color/tanstack.svg @@ -0,0 +1,3 @@ + + +