{#if $page.data?.header}
diff --git a/src/lib/sdk/billing.ts b/src/lib/sdk/billing.ts index 0361f59101..11b5ba5198 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', @@ -1411,10 +1424,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 d23fa33469..695ef874c7 100644 --- a/src/lib/stores/billing.ts +++ b/src/lib/stores/billing.ts @@ -24,7 +24,7 @@ import type { } 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 +100,7 @@ export function tierToPlan(tier: Tier) { case BillingPlan.ENTERPRISE: return tierEnterprise; default: - return tierFree; + return tierCustom; } } @@ -488,6 +488,9 @@ export async function paymentExpired(org: Organization) { const nots = get(notifications); const expiredNotification = nots.some((n) => n.message === expiredMessage); const expiringNotification = nots.some((n) => n.message === expiringMessage); + const cardExpiry = new Date(payment.expiryYear, payment.expiryMonth, 1); + const nextMonth = new Date(year, month + 1, 1); + const isExpiringNextMonth = cardExpiry.getTime() === nextMonth.getTime(); if (payment.expired && !expiredNotification) { addNotification({ type: 'error', @@ -503,7 +506,7 @@ export async function paymentExpired(org: Organization) { } ] }); - } else if (!expiringNotification && payment.expiryYear <= year && payment.expiryMonth < month) { + } else if (!expiringNotification && !payment.expired && isExpiringNextMonth) { addNotification({ type: 'warning', isHtml: true, @@ -554,7 +557,8 @@ export async function checkForMissingPaymentMethod() { const orgs = await sdk.forConsole.billing.listOrganization([ Query.notEqual('billingPlan', BillingPlan.FREE), Query.isNull('paymentMethodId'), - Query.isNull('backupPaymentMethodId') + Query.isNull('backupPaymentMethodId'), + Query.equal('platform', 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 634dee01fe..0e80a6dfa8 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/navigation.ts b/src/lib/stores/navigation.ts new file mode 100644 index 0000000000..00d165a87e --- /dev/null +++ b/src/lib/stores/navigation.ts @@ -0,0 +1,36 @@ +import { resolve } from '$app/paths'; +import { goto } from '$app/navigation'; +import type { Pathname, RouteId, RouteParams } from '$app/types'; + +// taken directly from svelte's source! +type ResolveArgs = T extends RouteId + ? RouteParams extends Record + ? [route: T] + : [route: T, params: RouteParams] + : [route: T]; + +export function withPath(base: string, ...parts: string[]) { + // remove slashes at the end if any + const normalizedBase = base.replace(/\/+$/, ''); + + // remove slashes at the start of each part if any + const normalizedParts = parts.map((part) => part.replace(/^\/+/, '')); + + // join em with slashes + return [normalizedBase, ...normalizedParts].join('/'); +} + +export function resolveRoute(route: T, params?: Record) { + // type cast is necessary here! + const resolveArgs = params ? ([route, params] as [T, RouteParams]) : [route]; + + return resolve(...(resolveArgs as ResolveArgs)); +} + +export function navigate( + route: T, + params?: Record +): Promise { + // type cast is necessary here! + return goto(resolveRoute(route, params)); +} diff --git a/src/lib/stores/oauth-providers.ts b/src/lib/stores/oauth-providers.ts index 47d426e9f9..44a1777f4e 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; }; @@ -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 79f218d8ff..b3b874cab4 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 2ae897822e..95ce1901a1 100644 --- a/src/lib/stores/sdk.ts +++ b/src/lib/stores/sdk.ts @@ -21,7 +21,10 @@ import { Sites, Tokens, TablesDB, - Domains + Domains, + DocumentsDB, + Realtime, + Organizations } from '@appwrite.io/console'; import { Billing } from '../sdk/billing'; import { Backups } from '../sdk/backups'; @@ -32,14 +35,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 +71,8 @@ const getSubdomain = (region?: string) => { return SUBDOMAIN_SFO; case REGION_SGP: return SUBDOMAIN_SGP; + case REGION_TOR: + return SUBDOMAIN_TOR; default: return ''; } @@ -90,7 +96,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 +114,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'); } @@ -131,16 +138,37 @@ const sdkForProject = { migrations: new Migrations(clientProject), sites: new Sites(clientProject), tablesDB: new TablesDB(clientProject), + documentsDB: new DocumentsDB(clientProject), console: new Console(clientProject) // for suggestions API }; 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 +198,8 @@ export const sdk = { }; export enum RuleType { - DEPLOYMENT = 'deployment', API = 'api', + DEPLOYMENT = 'deployment', REDIRECT = 'redirect' } @@ -185,6 +213,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 fcd3215720..29d8113323 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 04c9900812..9f4bc727bc 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/routes/(authenticated)/git/+layout.svelte b/src/routes/(authenticated)/git/+layout.svelte index 24921622c5..6f5a1d6c6d 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 23c1b34d58..c8cebcfc40 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 ac11668f15..ad17e49e0f 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 d3a77a8c4e..109bdabb3a 100644 --- a/src/routes/(console)/+layout.svelte +++ b/src/routes/(console)/+layout.svelte @@ -334,7 +334,6 @@ { 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/organizations/+page.svelte b/src/routes/(console)/account/organizations/+page.svelte index a08500bb7d..2d16680439 100644 --- a/src/routes/(console)/account/organizations/+page.svelte +++ b/src/routes/(console)/account/organizations/+page.svelte @@ -13,10 +13,10 @@ import { sdk } from '$lib/stores/sdk'; import type { PageData } from './$types'; import { isCloud } from '$lib/system'; - import { Badge } from '@appwrite.io/pink-svelte'; + import { Badge, Skeleton } from '@appwrite.io/pink-svelte'; import type { Models } from '@appwrite.io/console'; import type { Organization } from '$lib/stores/organization'; - import { daysLeftInTrial, plansInfo, tierToPlan } from '$lib/stores/billing'; + import { daysLeftInTrial, plansInfo, tierToPlan, type Tier } from '$lib/stores/billing'; import { toLocaleDate } from '$lib/helpers/date'; import { BillingPlan } from '$lib/constants'; import { goto } from '$app/navigation'; @@ -36,6 +36,27 @@ return memberships.memberships.map((team) => team.userName || team.userEmail); } + async function getPlanName(billingPlan: string | undefined): Promise { + if (!billingPlan) return 'Unknown'; + + // For known plans, use tierToPlan + const tierData = tierToPlan(billingPlan as Tier); + + // If it's not a custom plan or we got a non-custom result, return the name + if (tierData.name !== 'Custom') { + return tierData.name; + } + + // For custom plans, fetch from API + try { + const plan = await sdk.forConsole.billing.getPlan(billingPlan); + return plan.name; + } catch (error) { + // Fallback to 'Custom' if fetch fails + return 'Custom'; + } + } + function isOrganizationOnTrial(organization: Organization): boolean { if (!organization?.billingTrialStartDate) return false; if ($daysLeftInTrial <= 0) return false; @@ -92,6 +113,9 @@ {#each data.organizations.teams as organization} {@const avatarList = getMemberships(organization.$id)} {@const payingOrg = isPayingOrganization(organization)} + {@const planName = isCloudOrg(organization) + ? getPlanName(organization.billingPlan) + : null} @@ -104,16 +128,19 @@ {#if isCloudOrg(organization)} {#if isNonPayingOrganization(organization)} - - - - - You are limited to 1 free organization per account - - + {#if planName} + {#await planName} + + {:then name} + + + + + You are limited to 1 free organization per account + + + {/await} + {/if} {/if} {#if isOrganizationOnTrial(organization)} @@ -132,16 +159,20 @@ {/if} {#if payingOrg} - + {#await planName} + + {:then name} + + {/await} {/if} {/if} {#await avatarList} - + {:then avatars} {/await} diff --git a/src/routes/(console)/account/organizations/+page.ts b/src/routes/(console)/account/organizations/+page.ts index 6d8ea5689c..c669a9fe1f 100644 --- a/src/routes/(console)/account/organizations/+page.ts +++ b/src/routes/(console)/account/organizations/+page.ts @@ -1,4 +1,4 @@ -import { Query } from '@appwrite.io/console'; +import { Query, Platform } from '@appwrite.io/console'; import { sdk } from '$lib/stores/sdk'; import { getLimit, getPage, pageToOffset } from '$lib/helpers/load'; import { CARD_LIMIT } from '$lib/constants'; @@ -10,7 +10,12 @@ export const load: PageLoad = async ({ url, route }) => { const limit = getLimit(url, route, CARD_LIMIT); const offset = pageToOffset(page, limit); - const queries = [Query.offset(offset), Query.limit(limit), Query.orderDesc('')]; + const queries = [ + Query.offset(offset), + Query.limit(limit), + Query.orderDesc(''), + ...(isCloud ? [Query.equal('platform', Platform.Appwrite)] : []) + ]; const organizations = !isCloud ? await sdk.forConsole.teams.list({ queries }) diff --git a/src/routes/(console)/account/payments/paymentMethods.svelte b/src/routes/(console)/account/payments/paymentMethods.svelte index 3c2b83ef5c..8e53a8d3da 100644 --- a/src/routes/(console)/account/payments/paymentMethods.svelte +++ b/src/routes/(console)/account/payments/paymentMethods.svelte @@ -51,7 +51,7 @@ $: hasPaymentError = filteredMethods.some((method) => method?.lastError || method?.expired); - + Payment methods View or update your payment methods. These can be applied to any organizations you have created. @@ -59,7 +59,7 @@ '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 de2afad0fe..13d296facd 100644 --- a/src/routes/(console)/create-organization/+page.svelte +++ b/src/routes/(console)/create-organization/+page.svelte @@ -1,6 +1,6 @@ {/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)) )} diff --git a/src/routes/(console)/organization-[organization]/+page.ts b/src/routes/(console)/organization-[organization]/+page.ts index bdbb211143..dfd1d75fa5 100644 --- a/src/routes/(console)/organization-[organization]/+page.ts +++ b/src/routes/(console)/organization-[organization]/+page.ts @@ -5,6 +5,7 @@ import { CARD_LIMIT, Dependencies } from '$lib/constants'; import type { PageLoad } from './$types'; import { redirect } from '@sveltejs/kit'; import { base } from '$app/paths'; +import { isCloud } from '$lib/system'; export const load: PageLoad = async ({ params, url, route, depends, parent }) => { const { scopes } = await parent(); @@ -24,7 +25,8 @@ export const load: PageLoad = async ({ params, url, route, depends, parent }) => Query.offset(offset), Query.equal('teamId', params.organization), Query.limit(limit), - Query.orderDesc('') + Query.orderDesc(''), + Query.select(['$id', 'name', 'platforms', 'region', ...(isCloud ? ['status'] : [])]) ], search: search || undefined }); diff --git a/src/routes/(console)/organization-[organization]/billing/+page.svelte b/src/routes/(console)/organization-[organization]/billing/+page.svelte index e749892505..99729b8e9a 100644 --- a/src/routes/(console)/organization-[organization]/billing/+page.svelte +++ b/src/routes/(console)/organization-[organization]/billing/+page.svelte @@ -25,7 +25,9 @@ import type { PageData } from './$types'; export let data: PageData; - let organization = data.organization; + + // Reactive statement to update organization when data changes + $: organization = data.organization; // why are these reactive? $: defaultPaymentMethod = data?.paymentMethods?.paymentMethods?.find( @@ -133,7 +135,9 @@ availableCredit={data?.availableCredit} currentPlan={data?.currentPlan} nextPlan={data?.nextPlan} - currentAggregation={data?.billingAggregation} /> + currentAggregation={data?.billingAggregation} + limit={data?.limit} + offset={data?.offset} /> {:else} { +import { getLimit, getPage, pageToOffset } from '$lib/helpers/load'; + +export const load: PageLoad = async ({ parent, depends, url, route }) => { const { organization, scopes, currentPlan, countryList, locale } = await parent(); if (!scopes.includes('billing.read')) { @@ -19,6 +21,8 @@ export const load: PageLoad = async ({ parent, depends }) => { depends(Dependencies.CREDIT); depends(Dependencies.INVOICES); depends(Dependencies.ADDRESS); + //aggregation reloads on page param changes + depends(Dependencies.BILLING_AGGREGATION); const billingAddressId = (organization as Organization)?.billingAddressId; const billingAddressPromise: Promise
= billingAddressId @@ -34,9 +38,14 @@ export const load: PageLoad = async ({ parent, depends }) => { */ let billingAggregation = null; try { + const currentPage = getPage(url) || 1; + const limit = getLimit(url, route, DEFAULT_BILLING_PROJECTS_LIMIT); + const offset = pageToOffset(currentPage, limit); billingAggregation = await sdk.forConsole.billing.getAggregation( organization.$id, - (organization as Organization)?.billingAggregationId + (organization as Organization)?.billingAggregationId, + limit, + offset ); } catch (e) { // ignore error @@ -84,6 +93,11 @@ export const load: PageLoad = async ({ parent, depends }) => { areCreditsSupported, countryList, locale, - nextPlan: billingPlanDowngrade + nextPlan: billingPlanDowngrade, + limit: getLimit(url, route, DEFAULT_BILLING_PROJECTS_LIMIT), + offset: pageToOffset( + getPage(url) || 1, + getLimit(url, route, DEFAULT_BILLING_PROJECTS_LIMIT) + ) }; }; diff --git a/src/routes/(console)/organization-[organization]/billing/billingAddress.svelte b/src/routes/(console)/organization-[organization]/billing/billingAddress.svelte index f4422850ce..238d0de7fe 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/paymentHistory.svelte b/src/routes/(console)/organization-[organization]/billing/paymentHistory.svelte index d8085642a4..552288765e 100644 --- a/src/routes/(console)/organization-[organization]/billing/paymentHistory.svelte +++ b/src/routes/(console)/organization-[organization]/billing/paymentHistory.svelte @@ -85,7 +85,7 @@ ]); - + Payment history Transaction history for this organization. Download invoices for more details about your payments. diff --git a/src/routes/(console)/organization-[organization]/billing/paymentMethods.svelte b/src/routes/(console)/organization-[organization]/billing/paymentMethods.svelte index 457e85e779..124518f1e3 100644 --- a/src/routes/(console)/organization-[organization]/billing/paymentMethods.svelte +++ b/src/routes/(console)/organization-[organization]/billing/paymentMethods.svelte @@ -109,7 +109,7 @@ backupPaymentMethod?.expired; - + Payment methods View or update your organization payment methods here. @@ -117,7 +117,7 @@ { + onCardSubmit={(card) => { if (isSelectedBackup) { - addBackupPaymentMethod(e.detail.$id); + addBackupPaymentMethod(card.$id); } else { - addPaymentMethod(e.detail.$id); + addPaymentMethod(card.$id); } }} /> {/if} diff --git a/src/routes/(console)/organization-[organization]/billing/planSummary.svelte b/src/routes/(console)/organization-[organization]/billing/planSummary.svelte index 6a4a43f01c..fb3df8a608 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 - - {#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'} -
+ {#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 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 row.children} - {#each row.children as child (child.id)} - - {/each} -
- {/each} - {/if} - -
- {/each} - {#if availableCredit > 0} - - - - - - Credits - - - + + +
+
+ {#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} + {#if availableCredit > 0} + + + + + + Credits + + + + + + + + + -{formatCurrency(creditsApplied)} + + + + {/if} + + + + Total -
- + + - -{formatCurrency(creditsApplied)} - -
- {/if} - - - - - Total - - - - - - - - - {formatCurrency(totalAmount)} - - - -
-
- - -
- {#if $organization?.billingPlan === BillingPlan.FREE || $organization?.billingPlan === BillingPlan.GITHUB_EDUCATION} -
- {#if !currentPlan?.usagePerProject} - - {/if} - -
- {:else} -
- {#if $organization?.billingPlanDowngrade !== null} - - {:else} + + + + {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 18fdd81ccb..083aff4445 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 4d5fa05776..5ef49d0261 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 a0d5de92da..ea4cc7d267 100644 --- a/src/routes/(console)/organization-[organization]/billing/replaceCard.svelte +++ b/src/routes/(console)/organization-[organization]/billing/replaceCard.svelte @@ -19,7 +19,7 @@ export let methods: PaymentList; let name: string; - let error: string; + let error: string = null; let selectedPaymentMethodId: string; let showState: boolean = false; let state: string = ''; diff --git a/src/routes/(console)/organization-[organization]/billing/retryPaymentModal.svelte b/src/routes/(console)/organization-[organization]/billing/retryPaymentModal.svelte index 7e39f10ee6..65e217d2c6 100644 --- a/src/routes/(console)/organization-[organization]/billing/retryPaymentModal.svelte +++ b/src/routes/(console)/organization-[organization]/billing/retryPaymentModal.svelte @@ -55,7 +55,7 @@ async function handleSubmit() { isButtonDisabled = true; try { - if (paymentMethodId === null) { + if (paymentMethodId === null || paymentMethodId === '$new') { try { if (showState && !state) { throw Error('Please select a state'); @@ -65,13 +65,17 @@ method = await setPaymentMethod(paymentMethod.id, name, state); } else { const card = await submitStripeCard(name, $organization.$id); - if (card && Object.hasOwn(card, 'id')) { + // When Stripe returns an expanded PaymentMethod for US cards, we need state. + if (Object.hasOwn(card, 'id') && (card as PaymentMethod)?.card) { if ((card as PaymentMethod).card?.country === 'US') { paymentMethod = card as PaymentMethod; showState = true; return; } - } else if (card && Object.hasOwn(card, '$id')) { + } + + // Otherwise, we expect an Appwrite PaymentMethodData with `$id`. + if (Object.hasOwn(card, '$id')) { method = card as PaymentMethodData; } } @@ -143,7 +147,6 @@ onSubmit={handleSubmit} size="big" title="Retry payment"> -

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 30d9b1c3cb..a8f05930a9 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 153e85c5a5..d0228e2cc8 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} { depends(Dependencies.DOMAINS); const organizations = !isCloud ? await sdk.forConsole.teams.list() - : await sdk.forConsole.billing.listOrganization(); + : await sdk.forConsole.billing.listOrganization([ + Query.equal('platform', Platform.Appwrite) + ]); const { domain } = await parent(); return { diff --git a/src/routes/(console)/organization-[organization]/header.svelte b/src/routes/(console)/organization-[organization]/header.svelte index 38ec2a743e..191131ae49 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 80f6ed5cc9..3912746505 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,10 @@ import { AvatarInitials, Copy, + type DeleteOperationState, Empty, EmptySearch, + MultiSelectionTable, PaginationWithLimit, SearchQuery } from '$lib/components'; @@ -20,14 +22,7 @@ import type { Models } from '@appwrite.io/console'; import { writable } from 'svelte/store'; import Create from './createUser.svelte'; - import { - Badge, - Icon, - Table, - Layout, - Typography, - FloatingActionBar - } from '@appwrite.io/pink-svelte'; + import { Badge, Icon, Table, Layout, Typography } from '@appwrite.io/pink-svelte'; import { Tag } from '@appwrite.io/pink-svelte'; import { IconDuplicate, IconPlus } from '@appwrite.io/pink-icons-svelte'; import { canWriteUsers } from '$lib/stores/roles'; @@ -37,11 +32,11 @@ import { sdk } from '$lib/stores/sdk'; import { Submit, trackError, trackEvent } from '$lib/actions/analytics'; import { Dependencies } from '$lib/constants'; - import { addNotification } from '$lib/stores/notifications'; import { invalidate } from '$app/navigation'; - import Confirm from '$lib/components/confirm.svelte'; - export let data; + import type { PageProps } from './$types'; + + let { data }: PageProps = $props(); const columns = writable([ { id: '$id', title: 'User ID', type: 'string', width: 200 }, @@ -59,44 +54,25 @@ } ]); - let selectedUsers: string[] = []; - let showDelete = false; - let deleting = false; - async function userCreated(event: CustomEvent>>) { await goto( `${base}/project-${page.params.region}-${page.params.project}/auth/user-${event.detail.$id}` ); } - async function handleDelete() { - showDelete = false; - deleting = true; - - const promises = selectedUsers.map((userId) => - sdk.forProject(page.params.region, page.params.project).users.delete(userId) - ); + async function handleDelete(selectedRows: string[]): Promise { + const promises = selectedRows.map((userId) => { + return sdk.forProject(page.params.region, page.params.project).users.delete({ userId }); + }); try { await Promise.all(promises); - trackEvent(Submit.UserDelete, { - total: selectedUsers.length - }); - addNotification({ - type: 'success', - message: `${selectedUsers.length} user${selectedUsers.length > 1 ? 's' : ''} deleted` - }); - invalidate(Dependencies.USERS); + trackEvent(Submit.UserDelete, { total: selectedRows.length }); } catch (error) { - addNotification({ - type: 'error', - message: error.message - }); trackError(error, Submit.UserDelete); + return error; } finally { - selectedUsers = []; - showDelete = false; - deleting = false; + await invalidate(Dependencies.USERS); } } @@ -116,101 +92,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} - - - {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} + {/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} - - {/if} - {:else if id === 'labels'} - - {user.labels.join(', ')} - - {:else if id === 'joined'} - - {:else if id === 'lastActivity'} - {#if user.accessedAt} - {:else} - never + {user[id]} {/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/updateMockNumbers.svelte b/src/routes/(console)/project-[region]-[project]/auth/security/updateMockNumbers.svelte index e8a82bab90..515fb2228b 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/updateSessionInvalidation.svelte b/src/routes/(console)/project-[region]-[project]/auth/security/updateSessionInvalidation.svelte index c2c449e3eb..ab79a35c29 100644 --- a/src/routes/(console)/project-[region]-[project]/auth/security/updateSessionInvalidation.svelte +++ b/src/routes/(console)/project-[region]-[project]/auth/security/updateSessionInvalidation.svelte @@ -22,13 +22,13 @@ type: 'success', message: 'Updated session invalidation check.' }); - trackEvent(Submit.AuthInvalidateSesssion); + trackEvent(Submit.AuthInvalidateSession); } catch (error) { addNotification({ type: 'error', message: error.message }); - trackError(error, Submit.AuthInvalidateSesssion); + trackError(error, Submit.AuthInvalidateSession); } } 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 5dbfdb5767..28a3896a12 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 3a64c2500d..18b2eb2d9e 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,9 @@ EmptySearch, AvatarInitials, SearchQuery, - PaginationWithLimit + PaginationWithLimit, + type DeleteOperationState, + MultiSelectionTable } from '$lib/components'; import Create from '../createTeam.svelte'; import { goto } from '$app/navigation'; @@ -19,31 +21,16 @@ import type { Models } from '@appwrite.io/console'; import { writable } from 'svelte/store'; import { canWriteTeams } from '$lib/stores/roles'; - import { - Icon, - Layout, - Table, - FloatingActionBar, - Badge, - Typography - } from '@appwrite.io/pink-svelte'; + import { Icon, Layout, Table } from '@appwrite.io/pink-svelte'; import { IconPlus } from '@appwrite.io/pink-icons-svelte'; import DualTimeView from '$lib/components/dualTimeView.svelte'; import { sdk } from '$lib/stores/sdk'; import { Submit, trackError, trackEvent } from '$lib/actions/analytics'; import { Dependencies } from '$lib/constants'; - import { addNotification } from '$lib/stores/notifications'; import { invalidate } from '$app/navigation'; - import Confirm from '$lib/components/confirm.svelte'; - - export let data; - - const region = page.params.region; - const project = page.params.project; + import type { PageProps } from './$types'; - let selectedTeams: string[] = []; - let showDelete = false; - let deleting = false; + let { data }: PageProps = $props(); const columns = writable([ { id: 'name', title: 'Name', type: 'string', width: { min: 200, max: 300 } }, @@ -52,37 +39,24 @@ ]); const teamCreated = async (event: CustomEvent>>) => { - await goto(`${base}/project-${region}-${project}/auth/teams/team-${event.detail.$id}`); + await goto( + `${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${event.detail.$id}` + ); }; - async function handleDelete() { - showDelete = false; - deleting = true; - - const promises = selectedTeams.map((teamId) => - sdk.forProject(page.params.region, page.params.project).teams.delete(teamId) - ); + async function handleDelete(selectedRows: string[]): Promise { + const promises = selectedRows.map((teamId) => { + return sdk.forProject(page.params.region, page.params.project).teams.delete({ teamId }); + }); try { await Promise.all(promises); - trackEvent(Submit.TeamDelete, { - total: selectedTeams.length - }); - addNotification({ - type: 'success', - message: `${selectedTeams.length} team${selectedTeams.length > 1 ? 's' : ''} deleted` - }); - invalidate(Dependencies.TEAMS); + trackEvent(Submit.TeamDelete, { total: selectedRows.length }); } catch (error) { - addNotification({ - type: 'error', - message: error.message - }); trackError(error, Submit.TeamDelete); + return error; } finally { - selectedTeams = []; - showDelete = false; - deleting = false; + await invalidate(Dependencies.TEAMS); } } @@ -101,54 +75,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} + + {#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} - {#if selectedTeams.length > 0} - - - - - {selectedTeams.length > 1 ? 'teams' : 'team'} - selected - - - - - - - - {/if} + {#snippet deleteContentNotice()} + This action is irreversible and will permanently remove the selected teams and all + their memberships. + {/snippet} + {:else if data.search} - @@ -172,14 +142,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 629c5bdda3..98999a3059 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,77 +1,50 @@ @@ -85,71 +58,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. - - 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 8885f086ea..6651bce516 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 13e55efa8e..5d9e956afc 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,88 @@ - - + + {#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 0deaed8daf..0c0b5525be 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,36 @@ {#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]/targets/table.svelte b/src/routes/(console)/project-[region]-[project]/auth/user-[user]/targets/table.svelte index 09aa636193..e71859a430 100644 --- a/src/routes/(console)/project-[region]-[project]/auth/user-[user]/targets/table.svelte +++ b/src/routes/(console)/project-[region]-[project]/auth/user-[user]/targets/table.svelte @@ -1,6 +1,5 @@ - - + + {#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} - {/if} - {:else if column.id === 'providerType'} - - {:else if column.id === 'provider'} - {#if provider} - + {target[column.id]} {/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 e95c01ccae..6044cf62f6 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/+page.svelte @@ -1,48 +1,53 @@ @@ -74,7 +79,7 @@ {:else if data.search} diff --git a/src/routes/(console)/project-[region]-[project]/databases/+page.ts b/src/routes/(console)/project-[region]-[project]/databases/+page.ts index fcb828ff80..2618891ff2 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/+page.ts @@ -8,6 +8,7 @@ import type { BackupPolicy } from '$lib/sdk/backups'; import { isSelfHosted } from '$lib/system'; import { isCloud } from '$lib/system'; import type { Plan } from '$lib/sdk/billing'; +import { useDatabasesSdk } from '$database/(entity)'; export const load: PageLoad = async ({ url, route, depends, params, parent }) => { depends(Dependencies.DATABASES); @@ -21,7 +22,7 @@ export const load: PageLoad = async ({ url, route, depends, params, parent }) => // already loaded by parent. const { currentPlan } = await parent(); - const { databases, tables, policies, lastBackups } = await fetchDatabasesAndBackups( + const { databases, entities, policies, lastBackups } = await fetchDatabasesAndBackups( limit, offset, params, @@ -34,7 +35,7 @@ export const load: PageLoad = async ({ url, route, depends, params, parent }) => limit, view, search, - tables, + entities, policies, databases, lastBackups @@ -50,24 +51,24 @@ async function fetchDatabasesAndBackups( ) { const backupsEnabled = currentPlan?.backupsEnabled ?? true; - const projectSDK = sdk.forProject(params.region, params.project); - - const databases = await projectSDK.tablesDB.list({ + const databasesSdk = useDatabasesSdk(params.region, params.project); + const databases = await databasesSdk.list({ queries: [Query.limit(limit), Query.offset(offset), Query.orderDesc('$createdAt')], search: search || undefined }); - const tables: Record = {}; + const entities: Record = {}; await Promise.all( // TODO: backend should allow `Query.select` for perf! - databases.databases.map(async ({ $id }) => { - const res = await projectSDK.tablesDB.listTables({ + databases.databases.map(async ({ $id, type }) => { + const res = await databasesSdk.listEntities({ databaseId: $id, + databaseType: type, queries: [Query.limit(1), Query.orderDesc('')] }); - tables[$id] = res.tables?.[0]?.$id ?? null; + entities[$id] = res.entities?.[0]?.$id ?? null; }) ); @@ -80,7 +81,7 @@ async function fetchDatabasesAndBackups( ]); } - return { databases, tables, policies, lastBackups }; + return { databases, entities, policies, lastBackups }; } async function fetchPolicies(databases: Models.DatabaseList, params: RouteParams) { diff --git a/src/routes/(console)/project-[region]-[project]/databases/breadcrumbs.svelte b/src/routes/(console)/project-[region]-[project]/databases/breadcrumbs.svelte index 056141e765..4202af6079 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/breadcrumbs.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/breadcrumbs.svelte @@ -1,24 +1,30 @@ diff --git a/src/routes/(console)/project-[region]-[project]/databases/create.svelte b/src/routes/(console)/project-[region]-[project]/databases/create.svelte index 4c48422827..f00b68e592 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/create.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/create.svelte @@ -7,8 +7,7 @@ import { ID } from '@appwrite.io/console'; import { createEventDispatcher } from 'svelte'; import { isCloud } from '$lib/system'; - import { BillingPlan } from '$lib/constants'; - import { organization } from '$lib/stores/organization'; + import { currentPlan } from '$lib/stores/organization'; import { upgradeURL } from '$lib/stores/billing'; import CreatePolicy from './database-[database]/backups/createPolicy.svelte'; import { cronExpression, type UserBackupPolicy } from '$lib/helpers/backups'; @@ -132,7 +131,7 @@ {#if isCloud} - {#if $organization?.billingPlan === BillingPlan.FREE} + {#if !$currentPlan?.backupsEnabled} Upgrade your plan to ensure your data stays safe and backed up. diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/analytics.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/analytics.ts new file mode 100644 index 0000000000..1999b2505f --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/analytics.ts @@ -0,0 +1,51 @@ +import type { Page } from '@sveltejs/kit'; + +import { useTerminology } from './terminology'; +import { Submit, Click } from '$lib/actions/analytics'; +import type { AnalyticsResult, TerminologyResult, TerminologyShape } from './types'; + +export function useAnalytics(pageOrTerms: Page | TerminologyResult): AnalyticsResult { + // source is in `TerminologyResult`. + const terminology = 'source' in pageOrTerms ? pageOrTerms : useTerminology(pageOrTerms); + + const createSubmitHandler = (termType: keyof TerminologyShape) => { + return (action: TAction) => { + const term = terminology.source[termType]; + if (!term) { + throw new Error(`No ${termType} terminology found`); + } + const enumKey = `${term.title.singular}${action}`; + return Submit[enumKey as keyof typeof Submit]; + }; + }; + + const createClickHandler = (termType: keyof TerminologyShape) => { + return (action: TAction) => { + const term = terminology.source[termType]; + if (!term) { + throw new Error(`No ${termType} terminology found`); + } + const enumKey = `Database${term.title.singular}${action}`; + return Click[enumKey as keyof typeof Click]; + }; + }; + + const result: AnalyticsResult = { submit: {}, click: {} }; + + if (terminology.entity) { + result.click.entity = createClickHandler('entity'); + result.submit.entity = createSubmitHandler('entity'); + } + + if (terminology.field) { + result.click.field = createClickHandler('field'); + result.submit.field = createSubmitHandler('field'); + } + + if (terminology.record) { + result.click.record = createClickHandler('record'); + result.submit.record = createSubmitHandler('record'); + } + + return result; +} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/dependencies.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/dependencies.ts new file mode 100644 index 0000000000..b8467dde1a --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/dependencies.ts @@ -0,0 +1,21 @@ +import type { Page } from '@sveltejs/kit'; + +import { useTerminology } from './terminology'; +import { Dependencies } from '$lib/constants'; +import type { DependenciesResult, Term, TerminologyResult } from './types'; + +export function useDependencies(pageOrTerms: Page | TerminologyResult): DependenciesResult { + // source is in `TerminologyResult`. + const terminology = 'source' in pageOrTerms ? pageOrTerms : useTerminology(pageOrTerms); + + const getDependencies = (term: { title: Term }) => ({ + singular: Dependencies[term.title.singular.toUpperCase() as keyof typeof Dependencies], + plural: Dependencies[term.title.plural.toUpperCase() as keyof typeof Dependencies] + }); + + return { + entity: terminology.entity ? getDependencies(terminology.entity) : undefined, + field: terminology.field ? getDependencies(terminology.field) : undefined, + record: terminology.record ? getDependencies(terminology.record) : undefined + }; +} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/index.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/index.ts new file mode 100644 index 0000000000..b930cd9b8e --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/index.ts @@ -0,0 +1,6 @@ +export * from './sdk'; +export * from './init'; +export * from './types'; +export * from './analytics'; +export * from './terminology'; +export * from './dependencies'; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/init.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/init.ts new file mode 100644 index 0000000000..739e074344 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/init.ts @@ -0,0 +1,39 @@ +import type { Page } from '@sveltejs/kit'; +import { getContext, setContext } from 'svelte'; +import { + type AnalyticsResult, + type DatabaseSdkResult, + type DependenciesResult, + type TerminologyResult, + useAnalytics, + useDependencies, + useTerminology, + useDatabasesSdk +} from '$database/(entity)'; + +const TERMINOLOGIES_KEY = Symbol('terminologies'); + +export type Terminologies = { + analytics: AnalyticsResult; + terminology: TerminologyResult; + dependencies: DependenciesResult; + databaseSdk: DatabaseSdkResult; +}; + +export function getTerminologies(): Terminologies { + return getContext(TERMINOLOGIES_KEY); +} + +export function setTerminologies(page: Page) { + setContext(TERMINOLOGIES_KEY, buildTerminologies(page)); +} + +function buildTerminologies(page: Page) { + const terminology = useTerminology(page); + return { + terminology, + analytics: useAnalytics(terminology), + dependencies: useDependencies(terminology), + databasesSdk: useDatabasesSdk(page, terminology) + }; +} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/sdk.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/sdk.ts new file mode 100644 index 0000000000..54220872ac --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/sdk.ts @@ -0,0 +1,119 @@ +import { sdk } from '$lib/stores/sdk'; +import type { Page } from '@sveltejs/kit'; +import type { TerminologyResult } from './types'; +import { type DatabaseType, type Entity, type EntityList, toSupportiveEntity } from './terminology'; +import type { Models } from '@appwrite.io/console'; + +export type DatabaseSdkResult = { + list: (params: { queries?: string[]; search?: string }) => Promise; + getEntity: (params: { + databaseId: string; + entityId: string; + databaseType?: DatabaseType; + }) => Promise; + listEntities: (params: { + databaseId: string; + queries?: string[]; + search?: string; + databaseType?: DatabaseType; + }) => Promise; + delete: (params: { databaseId: string; databaseType?: DatabaseType }) => Promise<{}>; +}; + +export function useDatabasesSdk( + regionOrPage: string | Page, + projectOrTerminology: string | TerminologyResult, + databaseType?: DatabaseType /* nullable for use at top `databases` level */ +): DatabaseSdkResult { + let region: string; + let project: string; + let type: DatabaseType; + + if (typeof regionOrPage === 'object' && typeof projectOrTerminology === 'object') { + type = projectOrTerminology.type; + region = regionOrPage?.params?.region || ''; + project = regionOrPage?.params?.project || ''; + } else { + type = databaseType!; + region = regionOrPage as string; + project = projectOrTerminology as string; + } + + const baseSdk = sdk.forProject(region, project); + + return { + async list(params): Promise { + const results = await Promise.all([ + baseSdk.tablesDB.list(params) + + // not available just yet! + // baseSdk.documentsDB.list(params), + ]); + + return results.reduce( + (acc, curr) => ({ + total: acc.total + curr.total, + databases: [...acc.databases, ...curr.databases] + }), + { total: 0, databases: [] as Models.Database[] } + ); + }, + + async listEntities(params) { + switch (type ?? params.databaseType) { + case 'legacy': /* databases api */ + case 'tablesdb': { + const { total, tables } = await baseSdk.tablesDB.listTables(params); + return { total, entities: tables.map(toSupportiveEntity) }; + } + case 'documentsdb': { + const { total, collections } = + await baseSdk.documentsDB.listCollections(params); + return { total, entities: collections.map(toSupportiveEntity) }; + } + case 'vectordb': + throw new Error(`Database type not supported yet`); + default: + throw new Error(`Unknown database type`); + } + }, + + async getEntity(params) { + switch (type ?? params.databaseType) { + case 'legacy': /* databases api */ + case 'tablesdb': { + const table = await baseSdk.tablesDB.getTable({ + databaseId: params.databaseId, + tableId: params.entityId + }); + return toSupportiveEntity(table); + } + case 'documentsdb': { + const table = await baseSdk.documentsDB.getCollection({ + databaseId: params.databaseId, + collectionId: params.entityId + }); + return toSupportiveEntity(table); + } + case 'vectordb': + throw new Error(`Database type not supported yet`); + default: + throw new Error(`Unknown database type`); + } + }, + + async delete(params) { + switch (type ?? params.databaseType) { + case 'legacy': /* databases api */ + case 'tablesdb': + return await baseSdk.tablesDB.delete(params); + case 'documentsdb': + return await baseSdk.documentsDB.delete(params); + case 'vectordb': + throw new Error(`Database type not supported yet`); + default: + throw new Error(`Unknown database type`); + } + } + }; +} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/terminology.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/terminology.ts new file mode 100644 index 0000000000..6fff779c4e --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/terminology.ts @@ -0,0 +1,127 @@ +import type { Page } from '@sveltejs/kit'; + +import { capitalize, plural } from '$lib/helpers/string'; +import { AppwriteException, type Models } from '@appwrite.io/console'; +import type { Attributes, Columns, Table } from '$database/table-[table]/store'; +import type { Term, TerminologyResult, TerminologyShape } from '$database/(entity)/helpers/types'; + +export type DatabaseType = 'legacy' | 'tablesdb' | 'documentsdb' | 'vectordb'; + +export type Entity = Partial & { + indexes?: Index[]; + fields?: (Attributes | Columns)[]; + recordSecurity?: Models.Collection['documentSecurity'] | Models.Table['rowSecurity']; +}; + +export type Field = Partial | Partial; + +export type Index = Partial & { + fields: Models.Index['attributes'] | Models.ColumnIndex['columns']; +}; + +export type EntityList = { + total: number; + entities: Entity[]; +}; + +export const baseTerminology = { + /** + * this is no longer used on console so + * we don't really show old terminology for older databases and, + * therefore we use the new routes, terms and sdk for these databases. + */ + legacy: { + entity: 'table', + field: 'column', + record: 'row' + }, + tablesdb: { + entity: 'table', + field: 'column', + record: 'row' + }, + documentsdb: { + entity: 'collection', + field: 'attribute', + record: 'document' + }, + vectordb: {} +} as const; + +const createTerm = (singular: string, pluralForm: string): Term => { + return { singular, plural: pluralForm }; +}; + +// transforms a base into lower/title variants +const createTermVariants = (baseTerm: string) => ({ + lower: createTerm(baseTerm, plural(baseTerm)), + title: createTerm(capitalize(baseTerm), plural(capitalize(baseTerm))) +}); + +// transforms terminology for a database type +const transformDatabaseTerms = (terms: Partial) => + Object.fromEntries( + Object.entries(terms).map(([key, term]) => [ + key, + term ? createTermVariants(term) : undefined + ]) + ); + +// build the terminology data +const terminologyData = Object.fromEntries( + Object.entries(baseTerminology).map(([dbType, terms]) => [ + dbType, + transformDatabaseTerms(terms) + ]) +); + +const toIndex = (index: Models.Index | Models.ColumnIndex): Index => ({ + ...index, + fields: (index as Models.Index).attributes ?? (index as Models.ColumnIndex).columns ?? [] +}); + +/** + * Transforms a raw `Collection` / `Table` model to normalized `Entity`. + */ +export function toSupportiveEntity(raw: Models.Collection | Models.Table): Entity { + const isTable = 'columns' in raw; + const indexes = raw.indexes?.map(toIndex) ?? []; + + const fields = isTable ? raw.columns : raw.attributes; + const recordSecurity = isTable ? raw.rowSecurity : raw.documentSecurity; + + return { + ...raw, + fields, + recordSecurity, + indexes + } as Entity; +} + +export function toRelationalField(raw: Field): Columns { + return raw as Columns; +} + +/** + * @internal + * Use `getTerminologies()` instead when in `database-[database]` routes where context is available. + */ +export function useTerminology(pageOrType: Page | DatabaseType): TerminologyResult { + const type = + typeof pageOrType === 'object' + ? (pageOrType.data?.database?.type as DatabaseType) + : pageOrType; + if (!type) { + // strict check because this should always be available! + throw new AppwriteException('Database type is required', 500); + } + + const dbTerminologies = terminologyData[type] || {}; + return { + type, + source: dbTerminologies, + field: dbTerminologies.field, + record: dbTerminologies.record, + entity: dbTerminologies.entity + }; +} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/types.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/types.ts new file mode 100644 index 0000000000..8cdb4b73eb --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/types.ts @@ -0,0 +1,67 @@ +import { Dependencies } from '$lib/constants'; +import { Click, Submit } from '$lib/actions/analytics'; +import type { baseTerminology, DatabaseType } from './terminology'; + +export type TerminologyShape = { + entity: string; + field?: string; + record?: string; +}; + +export type Term = { singular: string; plural: string }; + +export type TerminologyResult = { + type: DatabaseType; + source: { + entity?: { lower: Term; title: Term }; + field?: { lower: Term; title: Term }; + record?: { lower: Term; title: Term }; + }; + entity: { lower: Term; title: Term }; + field?: { lower: Term; title: Term }; + record?: { lower: Term; title: Term }; +}; + +// for derived analytics! +type ExtractActionsForPrefix = { + [K in keyof typeof Submit]: K extends `${TPrefix}${infer Action}` ? Action : never; +}[keyof typeof Submit]; + +type ExtractClickActionsForPrefix = { + [K in keyof typeof Click]: K extends `Database${TPrefix}${infer Action}` ? Action : never; +}[keyof typeof Click]; + +type TermValuesForKey = { + [K in keyof typeof baseTerminology]: TKey extends keyof (typeof baseTerminology)[K] + ? (typeof baseTerminology)[K][TKey] + : never; +}[keyof typeof baseTerminology]; + +type SubmitActionsFor = ExtractActionsForPrefix< + Capitalize> +>; + +type ClickActionsFor = ExtractClickActionsForPrefix< + Capitalize> +>; + +export type AnalyticsResult = { + submit: { + entity?: (action: SubmitActionsFor<'entity'>) => Submit; + field?: (action: SubmitActionsFor<'field'>) => Submit; + record?: (action: SubmitActionsFor<'record'>) => Submit; + }; + click: { + entity?: (action: ClickActionsFor<'entity'>) => Click; + field?: (action: ClickActionsFor<'field'>) => Click; + record?: (action: ClickActionsFor<'record'>) => Click; + }; +}; + +// for derived dependencies! +export type DependenciesResult = { + [K in keyof Omit]: { + singular: Dependencies; + plural: Dependencies; + }; +}; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/index.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/index.ts new file mode 100644 index 0000000000..8437673d36 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/index.ts @@ -0,0 +1,12 @@ +export * from './helpers'; +export * from './views/field'; +export * from './views/indexes'; +export * from './views/layouts'; +export * from './views/settings'; + +export { default as Header } from './views/header.svelte'; +export { default as Breadcrumbs } from './views/breadcrumbs.svelte'; + +export { default as Usage } from './views/usage/view.svelte'; +export { default as CreateEntity } from './views/create.svelte'; +export { default as FailedModal } from './views/failedModal.svelte'; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/breadcrumbs.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/breadcrumbs.svelte new file mode 100644 index 0000000000..285052d6d0 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/breadcrumbs.svelte @@ -0,0 +1,43 @@ + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/createTable.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/create.svelte similarity index 50% rename from src/routes/(console)/project-[region]-[project]/databases/database-[database]/createTable.svelte rename to src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/create.svelte index 78f6923444..c8830a36e9 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/createTable.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/create.svelte @@ -1,71 +1,78 @@ - + { if (!touchedId) { touchedId = true; } }} /> - + {#if useSuggestions} + + {/if} - - + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/failedModal.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/failedModal.svelte new file mode 100644 index 0000000000..44914f7bab --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/failedModal.svelte @@ -0,0 +1,25 @@ + + + + {error} + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/field/csvDisabled.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/field/csvDisabled.svelte new file mode 100644 index 0000000000..9a37954a8c --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/field/csvDisabled.svelte @@ -0,0 +1,16 @@ + + + +
{@render children()}
+ +
This action is disabled during import.
+
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/field/index.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/field/index.ts new file mode 100644 index 0000000000..dfe3a26079 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/field/index.ts @@ -0,0 +1 @@ +export { default as CsvDisabled } from './csvDisabled.svelte'; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/header.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/header.svelte new file mode 100644 index 0000000000..c30dfece89 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/header.svelte @@ -0,0 +1,128 @@ + + +
+ + + + + {entity?.name} + + + {#key entity?.$id} + + {entity?.$id} + + {/key} + + + +
+ + {#each tabs as tab} + + {tab.title} + + {/each} + +
+
+
+ + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/createIndex.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/indexes/create.svelte similarity index 59% rename from src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/createIndex.svelte rename to src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/indexes/create.svelte index 88af9c2571..3a10a8d96a 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/createIndex.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/indexes/create.svelte @@ -1,5 +1,14 @@ + + @@ -188,10 +222,12 @@ bind:value={key} autofocus /> - + - {#each columnList as column, index} + {@const fieldType = terminology.field.title.singular} + {@const fieldTypeLower = terminology.field.lower.singular} + {#each fieldList as field, index} {@const direction = $isSmallViewport ? 'column' : 'row'} + id={`field-${index}`} + label={index === 0 ? fieldType : undefined} + placeholder="Select {fieldType}" + bind:value={field.value} /> {#if selectedType === IndexType.Key} @@ -233,7 +269,7 @@ id={`length-${index}`} label={index === 0 ? 'Length' : undefined} placeholder="Enter length" - bind:value={column.length} /> + bind:value={field.length} /> {/if} {#if $isSmallViewport} @@ -241,9 +277,9 @@ @@ -254,9 +290,9 @@ icon size="s" secondary - disabled={columnList.length <= 1} + disabled={fieldList.length <= 1} on:click={() => { - columnList = remove(columnList, index); + fieldList = remove(fieldList, index); }}> @@ -265,9 +301,9 @@ {/each}
-
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/deleteIndex.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/indexes/delete.svelte similarity index 53% rename from src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/deleteIndex.svelte rename to src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/indexes/delete.svelte index 5916d398aa..480e6fc175 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/deleteIndex.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/indexes/delete.svelte @@ -1,53 +1,53 @@ + -{#if selectedIndex?.columns?.length} - {#each selectedIndex.columns as column, i} +{#if selectedIndex?.fields?.length} + {#each selectedIndex.fields as field, i} + import { Container } from '$lib/layout'; + import Delete from './delete.svelte'; + import { Button } from '$lib/elements/forms'; + import Overview from './overview.svelte'; + import CreateIndex from './create.svelte'; + import FailedModal from '../failedModal.svelte'; + import { canWriteTables } from '$lib/stores/roles'; + import { + ActionMenu, + Badge, + Divider, + FloatingActionBar, + Icon, + Layout, + Link, + Popover, + Spreadsheet, + Typography + } from '@appwrite.io/pink-svelte'; + import { + IconDotsHorizontal, + IconEye, + IconPlus, + IconTrash + } from '@appwrite.io/pink-icons-svelte'; + import { type ComponentProps, onMount, type Snippet } from 'svelte'; + import { Click, trackEvent } from '$lib/actions/analytics'; + import { isSmallViewport } from '$lib/stores/viewport'; + import { + type Entity, + type CreateIndexesCallbackType, + SpreadsheetContainer, + SideSheet, + type Index, + getTerminologies + } from '$database/(entity)'; + import { preferences } from '$lib/stores/preferences'; + import { debounce } from '$lib/helpers/debounce'; + import { page } from '$app/state'; + import { realtime } from '$lib/stores/sdk'; + import type { ColumnsWidth } from '$database/table-[table]/store'; + import { invalidate } from '$app/navigation'; + + let { + entity, + onCreateIndex, + onDeleteIndexes, + emptyIndexesSheetView, + emptyEntitiesSheetView + }: { + entity: Entity; + onCreateIndex: (index: CreateIndexesCallbackType) => Promise; + onDeleteIndexes: (indexKeys: string[]) => Promise; + emptyIndexesSheetView: Snippet<[() => void]>; + emptyEntitiesSheetView?: Snippet; + } = $props(); + + let showCreateIndex = $state(false); + let selectedIndex: Index | null = $state(null); + + let createIndex: CreateIndex; + let selectedIndexes = $state([]); + + let error = $state(''); + let showFailed = $state(false); + let showDelete = $state(false); + let showOverview = $state(false); + + let columnsWidth: ColumnsWidth | null = $state(null); + + const organizationId = $derived(page.data.organization?.$id ?? page.data.project?.teamId); + + const spreadsheetColumns = $derived([ + { + id: 'key', + width: getColumnWidth('key', $isSmallViewport ? 250 : 200), + minimumWidth: $isSmallViewport ? 250 : 200, + resizable: true + }, + { + id: 'type', + width: getColumnWidth('type', 120), + minimumWidth: 120, + resizable: true + }, + { + id: 'columns', + width: getColumnWidth('columns', 200), + minimumWidth: 200, + resizable: true + }, + // { id: 'orders' }, // design doesn't have orders atm + { + id: 'lengths', + width: getColumnWidth('lengths', 180), + minimumWidth: 180, + resizable: true + }, + { + id: 'actions', + width: 40, + isAction: true, + resizable: false + } + ]); + + const emptyCellsLimit = $derived($isSmallViewport ? 14 : 17); + const emptyCellsCount = $derived( + entity.indexes.length >= emptyCellsLimit ? 0 : emptyCellsLimit - entity.indexes.length + ); + + const { dependencies, terminology } = getTerminologies(); + + onMount(() => { + columnsWidth = preferences.getColumnWidths(entity.$id + '#indexes'); + + // example: databases.*.tables.*.indexes.* + // example: documentsdb.*.collections.indexes.* + // this is needed because `documentsdb` doesn't use `database` prefix don't exist + const derivedEventsForIndex = `${terminology.type}.*.${terminology.entity.lower.plural}.*.indexes.*`; + + return realtime.forProject(page.params.region, ['project', 'console'], (response) => { + if (response.events.includes(derivedEventsForIndex)) { + invalidate(dependencies.entity.singular); + } + }); + }); + + function getEntityStatusBadge(status: string): ComponentProps['type'] { + switch (status) { + case 'processing': + return 'warning'; + case 'deleting': + case 'stuck': + case 'failed': + return 'error'; + default: + return undefined; + } + } + + function getColumnWidth(columnId: string, defaultWidth: number): number { + const savedWidth = columnsWidth?.[columnId]; + if (!savedWidth) return defaultWidth; + + return savedWidth.resized; + } + + function saveColumnsWidth({ columnId, newWidth }: { columnId: string; newWidth: number }) { + const existing = columnsWidth?.[columnId]; + const fixed = existing + ? typeof existing?.fixed === 'number' + ? existing.fixed + : existing?.fixed?.min + : newWidth; + + columnsWidth = { + ...(columnsWidth ?? {}), + [columnId]: { + fixed, + resized: Math.ceil(newWidth) + } + }; + + saveColumnWidthsToPreferences({ columnId, newWidth, fixedWidth: fixed }); + } + + const saveColumnWidthsToPreferences = debounce( + (column: { columnId: string; newWidth: number; fixedWidth: number }) => { + if (!organizationId) return; + + preferences.saveColumnWidths(organizationId, entity.$id + '#indexes', { + [column.columnId]: { + fixed: column.fixedWidth, + resized: Math.ceil(column.newWidth) + } + }); + }, + 1000 + ); + + + + + {#if $canWriteTables} + + {/if} + + + +
+ {#if entity.fields?.length} + {#if entity.indexes.length} + + (showCreateIndex = true)} + on:columnsResize={(resize) => saveColumnsWidth(resize.detail)}> + + Key + Type + Columns + + Lengths + + + + {#each entity.indexes as index (index.key)} + + + + {index.key} + {#if index.status !== 'available'} + + {#if index.error} + { + e.preventDefault(); + error = index.error; + showFailed = true; + }}>Details + {/if} + {/if} + + + {index.type} + + {index.fields.join(', ')} + + + + {index.lengths} + + + + + + { + toggle(); + selectedIndex = index; + showOverview = true; + }}>Overview + +
+ +
+ + { + toggle(); + showDelete = true; + selectedIndex = index; + trackEvent(Click.DatabaseIndexDelete); + }}>Delete +
+
+
+
+ {/each} + + + + + {@const length = entity.indexes.length} + {length} + {length === 1 ? 'index' : 'indexes'} + + + +
+
+ {:else} + {@render emptyIndexesSheetView(() => (showCreateIndex = true))} + {/if} + {:else} + {@render emptyEntitiesSheetView?.()} + {/if} + + {#if selectedIndexes.length > 0} +
+ + +
+ + + + {selectedIndexes.length > 1 ? 'indexes' : 'index'} + selected + + +
+
+ + + + +
+
+ {/if} +
+ + await createIndex.create() + }}> + + + +{#if selectedIndex} + +{:else if selectedIndexes && selectedIndexes.length} + +{/if} + + + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/empty.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/empty.svelte new file mode 100644 index 0000000000..640bb39351 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/empty.svelte @@ -0,0 +1,482 @@ + + +
0} + class:no-custom-columns={customColumns.length <= 0} + class="databases-spreadsheet spreadsheet-container-outer"> + + { + /* @ignore: only for showing the `+` button on footer */ + }}> + + {#each spreadsheetColumns as column (column.id)} + {#if column.isAction} + + { + if (mode === 'rows') { + onOpenCreateColumn?.(); + } + }}> + + + + {:else} + + {#if column.isAction} + + + + {:else if column.id === 'actions' || column.id === 'empty'} + {column.title} + {:else} + + {column.title} + + + + {/if} + + {/if} + {/each} + + + + {#if $spreadsheetLoading} + + + + {/if} + + + + + {#if !$spreadsheetLoading} +
0} + data-collapsed-tabs={!$expandTabs} + style:--overlay-left={overlayLeftOffset} + style:--overlay-top={overlayTopOffset} + style:--dynamic-overlay-height={dynamicOverlayHeight}> +
+ + + {title ?? `You have no ${mode} yet`} + + {@render subtitle?.()} + + + {#if showActions && actions} + {@const inline = mode === 'rows-filtered'} +
+ + {#if inline} + {@render actions?.()} + {:else} + + {@render actions?.()} + + {/if} + +
+ {/if} +
+
+
+ {/if} +
+ + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/emptySheetCards.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/emptySheetCards.svelte new file mode 100644 index 0000000000..f38edeb62f --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/emptySheetCards.svelte @@ -0,0 +1,48 @@ + + + onClick?.()}> + + {#if icon} + + {/if} + + + + {title} + + {#if subtitle} + + {subtitle} + + {/if} + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/index.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/index.ts new file mode 100644 index 0000000000..36dbcca040 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/index.ts @@ -0,0 +1,4 @@ +export { default as EmptySheet } from './empty.svelte'; +export { default as SideSheet } from './sidesheet.svelte'; +export { default as EmptySheetCards } from './emptySheetCards.svelte'; +export { default as SpreadsheetContainer } from './spreadsheet.svelte'; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/sidesheet.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/sidesheet.svelte similarity index 94% rename from src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/sidesheet.svelte rename to src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/sidesheet.svelte index c4d8041b2e..c0b525c612 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/sidesheet.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/sidesheet.svelte @@ -18,6 +18,7 @@ footer = null, titleBadge = null, topAction = null, + topEndActions = null, ...restProps }: { show: boolean; @@ -48,16 +49,17 @@ } | undefined; children?: Snippet; - footer?: Snippet | null; + footer?: Snippet; + topEndActions?: Snippet; } & HTMLAttributes = $props(); let form: Form; let submitting = $state(writable(false)); let copyText = $state(undefined); - beforeNavigate(() => { - show = false; - }); + + // hide on a nav trigger! + beforeNavigate(() => (show = false));
@@ -88,6 +90,12 @@ {/if} {/if} + + {#if topEndActions} + + {@render topEndActions()} + + {/if}
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/spreadsheet.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/spreadsheet.svelte similarity index 98% rename from src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/spreadsheet.svelte rename to src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/spreadsheet.svelte index 185f2aec78..30483eb680 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/spreadsheet.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/layouts/spreadsheet.svelte @@ -1,6 +1,6 @@ + + + Delete {type} + The {type} will be permanently deleted, including all the {records} within it. This action is irreversible. + + + +
{entity.name}
+
+

Last updated: {toLocaleDateTime(entity.$updatedAt)}

+
+
+ + + + +
+ +{#if show} + + + Are you sure you want to delete {entity.name}? + + +{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/settings/index.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/settings/index.ts new file mode 100644 index 0000000000..24fe556518 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/settings/index.ts @@ -0,0 +1,5 @@ +export { default as DangerZone } from './danger.svelte'; +export { default as UpdateName } from './name.svelte'; +export { default as UpdatePermissions } from './permissions.svelte'; +export { default as UpdateSecurity } from './security.svelte'; +export { default as UpdateStatus } from './status.svelte'; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/settings/name.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/settings/name.svelte new file mode 100644 index 0000000000..ac96aee5df --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/settings/name.svelte @@ -0,0 +1,60 @@ + + +
+ + Name + + + + + + + + +
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/settings/permissions.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/settings/permissions.svelte new file mode 100644 index 0000000000..90534375c8 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/settings/permissions.svelte @@ -0,0 +1,67 @@ + + + + Permissions + Choose who can access your {type} and {records}. + Learn more + . + + {#if entityPermissions} + + {/if} + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/settings/security.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/settings/security.svelte new file mode 100644 index 0000000000..112aa6deb3 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/settings/security.svelte @@ -0,0 +1,66 @@ + + + + {title} security + + + +

+ When {recordLower} security is enabled, users will be able to access {recordsLower} + for which they have been granted + either {recordLower} or {entityLower} permissions. +

+

+ If {recordLower} security is disabled, users can access {recordsLower} + only if they have {entityLower} permissions. {title} permissions will be ignored. +

+
+ + + +
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/settings/status.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/settings/status.svelte new file mode 100644 index 0000000000..44cae7b7d7 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/settings/status.svelte @@ -0,0 +1,60 @@ + + + + {entity.name} + +
    + +
+
+

Created: {toLocaleDateTime(entity.$createdAt)}

+

Last updated: {toLocaleDateTime(entity.$updatedAt)}

+
+
+ + + + +
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/usage/view.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/usage/view.svelte new file mode 100644 index 0000000000..c47c8da008 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/usage/view.svelte @@ -0,0 +1,47 @@ + + +
+ + + +
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 0000000000..28ced29eb4 --- /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 0000000000..69cc26bcbb --- /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 09289f76f7..b7b90e70c6 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 SpreadsheetContainer from '../table-[table]/layout/spreadsheet.svelte'; + import { SortButton } from '$lib/components'; + import { expandTabs, columnsOrder, columnsWidth, reorderItems } from '../table-[table]/store'; + import { preferences } from '$lib/stores/preferences'; + import { SpreadsheetContainer } from '$database/(entity)'; 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} - - - {#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} - - - -
+ + + {column.title} + + + + + + {:else} + { + if (triggerColumnId === column.id) { + triggerColumnId = null; + return true; + } + + 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); + } + }}> + + - { - if ( - isColumnInteractable && - !$isTabletViewport - ) { - toggle(event); - } + style:--animation-delay={`${isColumnInteractable ? (index - 1) * 100 : 0}ms`} + title={column.title}> + {column.title} + + + {@render changeColumnTypePopover({ + id: column.id, + columnObj, + iconColor: columnIconColor, + icon: column.icon, + isColumnInteractable, + index + })} + + + + {#if !$isTabletViewport} +
{ + isInlineEditing = true; + showHeadTooltip = false; + resetSelectedColumn(); + handlePreviousColumnsBorder(column.id); + }} + onfocusout={() => { + showHeadTooltip = true; + isInlineEditing = false; + handlePreviousColumnsBorder( + column.id, + false + ); }}> - {#if !columnObj?.isPlaceholder} - - {/if} - -
- -
- - - {#each basicColumnOptions as option} - { - toggle(); - updateColumn(column.id, { - type: option.type, - format: - option.format || null - }); - }}> - - - {option.name} - - - {/each} - - -
- - - - - {#if !$isTabletViewport} -
+ + + {#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} + + - - {#if columnIcon} - - {/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 - }; - })} /> - + pattern="^[A-Za-z0-9][A-Za-z0-9._\-]*$" /> - {#if ColumnComponent} - - {/if} - - {/if} - {/snippet} - + { + 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 1d51db1688..761f3a51cd 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 0000000000..df6de05161 --- /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 2f33a1af66..836ea3b9c1 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,12 +8,12 @@ mockSuggestions, type SuggestedIndexSchema } from './store'; - import { Modal, Confirm } from '$lib/components'; - import SideSheet from '../table-[table]/layout/sidesheet.svelte'; + import { Modal } from '$lib/components'; + import { type Entity, SideSheet } from '$database/(entity)'; import { isSmallViewport } from '$lib/stores/viewport'; - import { IndexType, type Models } from '@appwrite.io/console'; + import { IndexType } from '@appwrite.io/console'; import { capitalize } from '$lib/helpers/string'; - import { type Columns, table } from '../table-[table]/store'; + import { type Columns } from '../table-[table]/store'; import { isRelationship } from '../table-[table]/rows/store'; import { VARS } from '$lib/system'; import { sleep } from '$lib/helpers/promises'; @@ -26,22 +26,24 @@ import { type ComponentType, onDestroy, onMount } from 'svelte'; import { columnOptions as baseColumnOptions } from '../table-[table]/columns/store'; + const { + table + }: { + table: Entity; + } = $props(); + const MAX_INDEXES = 5; let modalError = $state(null); let creatingIndexes = $state(false); let loadingSuggestions = $state(false); let indexes = $state([]); - let confirmDismiss = $state(false); let columnOptions: Array<{ value: string; label: string; leadingIcon?: ComponentType; }> = $state(); - const tableId = page.params.table; - const databaseId = page.params.database; - function makeColumnOptions() { if (VARS.MOCK_AI_SUGGESTIONS) { columnOptions = mockSuggestions.columns.map((column) => ({ @@ -50,8 +52,8 @@ leadingIcon: baseColumnOptions.find((option) => option.type === column.type)?.icon })); } else { - columnOptions = $table.columns - .filter((column) => !isRelationship(column)) + columnOptions = table.fields + .filter((column: Columns) => !isRelationship(column)) .map((column) => ({ value: column.key, label: column.key, @@ -71,7 +73,7 @@ indexes = mockSuggestions.columns.slice(0, 3).map((column, index) => ({ key: column.name, type: IndexType.Key, - columns: [column.name], + fields: [column.name], orders: index === 2 ? IndexOrder.DESC : IndexOrder.ASC, lengths: [] })); @@ -80,8 +82,8 @@ const suggestions = await sdk .forProject(page.params.region, page.params.project) .console.suggestIndexes({ - databaseId, - tableId: $table.$id + databaseId: table.databaseId, + tableId: table.$id }); indexes = suggestions.indexes.map((index) => { @@ -89,7 +91,7 @@ key: index.columns[0], type: index.type as IndexType, orders: (index.orders?.[0] as IndexOrder) || IndexOrder.ASC, - columns: index.columns, + fields: index.columns, lengths: index.lengths ?? [] }; }); @@ -117,7 +119,7 @@ key: '', type: IndexType.Key, orders: IndexOrder.ASC, - columns: [], + fields: [], lengths: null }); } @@ -130,9 +132,9 @@ function syncIndexState(event: CustomEvent, index: SuggestedIndexSchema) { const selected = event.detail; index.key = selected; - index.columns = selected ? [selected] : []; + index.fields = selected ? [selected] : []; if (index.lengths) { - index.lengths = index.lengths.slice(0, index.columns.length); + index.lengths = index.lengths.slice(0, index.fields.length); } } @@ -147,8 +149,8 @@ } function generateUniqueIndexKey(index: SuggestedIndexSchema, usedKeys: Set): string { - const existingKeys = $table.indexes.map((idx) => idx.key); - let suggestedKey = `${index.key || index.columns[0]}_${index.type.toLowerCase()}`; + const existingKeys = table.indexes.map((idx) => idx.key); + let suggestedKey = `${index.key || index.fields[0]}_${index.type.toLowerCase()}`; let uniqueKey = suggestedKey; let counter = 1; @@ -161,25 +163,20 @@ return uniqueKey; } - function prepareIndexForCreation(index: SuggestedIndexSchema, columnMap: Map) { + function prepareIndexForCreation(index: SuggestedIndexSchema, columnMap: Map) { // prepare orders array - const orders = index.orders !== null ? index.columns.map(() => String(index.orders)) : []; + const orders = index.orders !== null ? index.fields.map(() => String(index.orders)) : []; // prepare lengths array let lengths: (number | null)[]; if (index.type === IndexType.Key) { // only validate if it's a key index - lengths = index.columns.map((columnKey, i) => { - const column = columnMap.get(columnKey); - if (column?.type === 'string') { - const stringColumn = column as Models.ColumnString; + lengths = index.fields.map((columnKey, i) => { + const maxSize = columnMap.get(columnKey); + if (maxSize) { const requestedLength = index.lengths?.[i]; - if ( - requestedLength && - stringColumn.size && - requestedLength > stringColumn.size - ) { - return stringColumn.size; + if (requestedLength && requestedLength > maxSize) { + return maxSize; } return requestedLength || null; } @@ -187,7 +184,7 @@ }); } else { // non-key indexes, lengths are null! - lengths = Array(index.columns.length).fill(null); + lengths = Array(index.fields.length).fill(null); } return { orders, lengths }; @@ -195,7 +192,6 @@ function dismissIndexes() { indexes = []; - confirmDismiss = false; $showIndexesSuggestions = false; } @@ -215,7 +211,7 @@ creatingIndexes = true; for (const [i, index] of indexes.entries()) { - if (!index.key || !index.type || !index.columns || index.columns.length === 0) { + if (!index.key || !index.type || !index.fields || index.fields.length === 0) { modalError = `Index ${i + 1}: Selected column or type invalid`; creatingIndexes = false; return true; // keep sheet open! @@ -224,7 +220,12 @@ let successCount = 0; const usedKeys = new Set(); - const columnMap = new Map($table.columns.map((col) => [col.key, col])); + const columnMap: Map = new Map( + table.fields + .filter((field) => field.type === 'string' && 'size' in field) + .map((field) => [field.key, field['size']]) + ); + const sdkClient = sdk.forProject(page.params.region, page.params.project); for (const [_, index] of indexes.entries()) { @@ -236,11 +237,11 @@ const uniqueIndexKey = generateUniqueIndexKey(index, usedKeys); await sdkClient.tablesDB.createIndex({ - databaseId, - tableId, + databaseId: table.databaseId, + tableId: table.$id, key: uniqueIndexKey, type: index.type, - columns: index.columns, + columns: index.fields, lengths, ...(orders.length ? { orders } : {}) }); @@ -354,13 +355,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 f1792e3911..0e8daa0362 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 { @@ -35,7 +40,7 @@ export type SuggestedIndexSchema = { key: string; type: IndexType; orders: IndexOrder; - columns: string[]; + fields: string[]; lengths?: number[] | undefined; }; @@ -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]/+layout.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/+layout.svelte index fe77a776bd..b11e83f6ef 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/+layout.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/+layout.svelte @@ -10,8 +10,7 @@ } from '$lib/commandCenter'; import { tablesSearcher } from '$lib/commandCenter/searchers'; import { Dependencies } from '$lib/constants'; - import CreateTable from './createTable.svelte'; - import { showCreateTable } from './store'; + import { showCreateEntity } from './store'; import { TablesPanel } from '$lib/commandCenter/panels'; import { canWriteTables, canWriteDatabases } from '$lib/stores/roles'; import { showCreateBackup, showCreatePolicy } from './backups/store'; @@ -19,6 +18,8 @@ import { currentPlan } from '$lib/stores/organization'; import { isCloud } from '$lib/system'; import { noWidthTransition } from '$lib/stores/sidebar'; + import { CreateEntity, setTerminologies } from '$database/(entity)'; + import { sdk } from '$lib/stores/sdk'; const project = page.params.project; const databaseId = page.params.database; @@ -27,7 +28,7 @@ { label: 'Create table', callback() { - $showCreateTable = true; + $showCreateEntity = true; if (!page.url.pathname.endsWith(databaseId)) { goto( `${base}/project-${page.params.region}-${project}/databases/database-${databaseId}` @@ -135,6 +136,23 @@ $: $updateCommandGroupRanks({ tables: 10 }); $noWidthTransition = true; + + async function createEntity(tableId: string, name: string) { + const table = await sdk + .forProject(page.params.region, page.params.project) + .tablesDB.createTable({ + databaseId, + tableId, + name + }); + + await invalidate(Dependencies.DATABASE); + await goto( + `${base}/project-${page.params.region}-${project}/databases/database-${databaseId}/table-${table.$id}` + ); + } + + $: setTerminologies(page); @@ -146,11 +164,4 @@ - { - await invalidate(Dependencies.DATABASE); - await goto( - `${base}/project-${page.params.region}-${project}/databases/database-${databaseId}/table-${table.$id}` - ); - }} /> + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/+layout.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/+layout.ts index ac97110f0e..d469b86b39 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/+layout.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/+layout.ts @@ -12,10 +12,13 @@ export const load: LayoutLoad = async ({ params, depends }) => { databaseId: params.database }); + // only for tests + // database.type = 'documentsdb'; + return { + database, header: Header, breadcrumbs: Breadcrumbs, - subNavigation: SubNavigation, - database + subNavigation: SubNavigation }; }; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/+page.svelte index 8234805e3a..90066c9f96 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/+page.svelte @@ -2,20 +2,38 @@ import { EmptySearch, PaginationWithLimit, SearchQuery, ViewSelector } from '$lib/components'; import { Button } from '$lib/elements/forms'; import { Container } from '$lib/layout'; - import { showCreateTable, tableViewColumns } from './store'; + import { showCreateEntity, tableViewColumns } from './store'; import Table from './table.svelte'; import Grid from './grid.svelte'; - import type { PageData } from './$types'; + import type { PageProps } from './$types'; import { Card, Empty, Icon, Layout } from '@appwrite.io/pink-svelte'; - import { base } from '$app/paths'; import { app } from '$lib/stores/app'; import { canWriteTables } from '$lib/stores/roles'; import { IconPlus } from '@appwrite.io/pink-icons-svelte'; import { page } from '$app/state'; + import { resolveRoute } from '$lib/stores/navigation'; + import { getTerminologies } from '$database/(entity)'; + import { withPath } from '$lib/stores/navigation.js'; - export let data: PageData; + const { data }: PageProps = $props(); - const databaseId = page.params.database; + const { terminology } = getTerminologies(); + const entityTitle = terminology.entity.title; + const entityLower = terminology.entity.lower; + + /** + * init update because `getContext` + * doesn't work on typescript context! + */ + tableViewColumns.update((columns) => { + /* $id */ + columns[0].title = `${entityTitle.singular} ID`; + return columns; + }); + + function getImageRoute(type: 'light' | 'dark'): string { + return withPath(resolveRoute('/'), `/images/empty-database-${type}.svg`); + } @@ -29,11 +47,11 @@ ui="new" view={data.view} columns={tableViewColumns} - hideColumns={!data.tables.total} - hideView={!data.tables.total} /> + hideColumns={!data.entities.total} + hideView={!data.entities.total} /> {#if $canWriteTables} - @@ -41,51 +59,48 @@ - {#if data.tables.total} + {#if data.entities.total} {#if data.view === 'grid'} - + {:else} - +
{/if} + total={data.entities.total} + name={entityTitle.plural} /> {:else if data.search} - + + href={resolveRoute( + '/(console)/project-[region]-[project]/databases/database-[database]', + page.params + )}>Clear Search {:else} + src={getImageRoute($app.themeInUse)} + title="Create your first {entityLower.singular}"> - Create, organize, and query structured data with Tables. + Create, organize, and query structured data with {entityTitle.plural}. + + ariaLabel="create {entityLower.singular}">Documentation {#if $canWriteTables} - {/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/+page.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/+page.ts index 475ed13fee..731d7a06fe 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/+page.ts @@ -1,10 +1,11 @@ import { Query } from '@appwrite.io/console'; -import { sdk } from '$lib/stores/sdk'; import { getLimit, getPage, getSearch, getView, pageToOffset, View } from '$lib/helpers/load'; import type { PageLoad } from './$types'; import { CARD_LIMIT, Dependencies } from '$lib/constants'; +import { type DatabaseType, useDatabasesSdk } from '$database/(entity)'; -export const load: PageLoad = async ({ params, url, route, depends }) => { +export const load: PageLoad = async ({ params, url, route, depends, parent }) => { + const { database } = await parent(); depends(Dependencies.TABLES); const page = getPage(url); @@ -13,10 +14,13 @@ export const load: PageLoad = async ({ params, url, route, depends }) => { const view = getView(url, route, View.Grid); const offset = pageToOffset(page, limit); - const tables = await sdk.forProject(params.region, params.project).tablesDB.listTables({ - databaseId: params.database, - queries: [Query.limit(limit), Query.offset(offset), Query.orderDesc('')], - search: search || undefined + const databaseType = database.type as DatabaseType; + + const databasesSdk = useDatabasesSdk(params.region, params.project, databaseType); + const entities = await databasesSdk.listEntities({ + databaseId: database.$id, + search: search || undefined, + queries: [Query.limit(limit), Query.offset(offset), Query.orderDesc('')] }); return { @@ -24,6 +28,6 @@ export const load: PageLoad = async ({ params, url, route, depends }) => { limit, search, view, - tables + entities }; }; 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 6c6a8de18e..0adc04a15f 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 @@ -5,7 +5,7 @@ import BackupPolicy from './policy.svelte'; import LockedCard from './locked.svelte'; import Table from './table.svelte'; - import type { PageData } from './$types'; + import type { PageProps } from './$types'; import CreatePolicy from './createPolicy.svelte'; import { Button } from '$lib/elements/forms'; import { addNotification, dismissAllNotifications } from '$lib/stores/notifications'; @@ -25,11 +25,12 @@ import { page } from '$app/state'; import IconQuestionMarkCircle from './components/questionIcon.svelte'; - let policyCreateError: string; - let totalPolicies: UserBackupPolicy[] = []; - let isDisabled = isSelfHosted || (isCloud && !$currentPlan.backupsEnabled); + const { data }: PageProps = $props(); - export let data: PageData; + let policyCreateError: string | null = $state(null); + let totalPolicies: UserBackupPolicy[] = $state([]); + + const isDisabled = $derived(isSelfHosted || (isCloud && !$currentPlan.backupsEnabled)); const showFeedbackNotification = () => { let counter = localStorage.getItem('createBackupsCounter'); @@ -69,11 +70,11 @@ await sdk .forProject(page.params.region, page.params.project) .backups.createArchive(['databases'], data.database.$id); + await invalidate(Dependencies.BACKUPS); addNotification({ type: 'success', message: 'Database backup has started' }); - invalidate(Dependencies.BACKUPS); trackEvent('click_manual_submit'); showFeedbackNotification(); } catch (error) { @@ -86,7 +87,7 @@ } }; - const trackEvents = (policies) => { + const trackEvents = (policies: UserBackupPolicy[]) => { policies.forEach((policy) => { let actualDay = null; const monthlyBackupFrequency = policy.monthlyBackupFrequency; @@ -139,7 +140,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 +148,7 @@ trackEvents(totalPolicies); - invalidate(Dependencies.BACKUPS); + await invalidate(Dependencies.BACKUPS); showFeedbackNotification(); } catch (err) { addNotification({ @@ -162,19 +162,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; - - if ( - response.events.includes('archives.*') || - response.events.includes('policies.*') - ) { - invalidate(Dependencies.BACKUPS); - } - }); + 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); + } + }); }); 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 d340d0f75b..1ee5a6e2f1 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/table.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/table.svelte index 689afe74f4..4123b50c8b 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,11 @@ - - + + {#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 deleteBackups([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)} @@ -358,7 +368,7 @@ autofocus={false} name="Database" bind:show={showCustomId} - databaseId={$database.$id} + databaseId={database.$id} bind:id={newDatabaseInfo.id} /> {/if} @@ -368,12 +378,12 @@ size="s" id="delete_policy" bind:checked={confirmSameDbRestore} - label="Overwrite '{$database.name}' with the selected backup version"> + label="Overwrite '{database.name}' with the selected backup version"> {/if} - + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/breadcrumbs.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/breadcrumbs.svelte index e1f0459d41..2da3c51b03 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/breadcrumbs.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/breadcrumbs.svelte @@ -1,28 +1,37 @@ diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/delete.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/delete.svelte index be59aa9ab8..2ab3fa80da 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/delete.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/delete.svelte @@ -1,34 +1,38 @@

- {#if tableItems.length > 0} - The following tables and all data associated with {$database.name}, will be + {#if entityItems.length > 0} + The following tables and all data associated with {database.name}, will be permanently deleted. {:else} - Are you sure you want to delete {$database.name}? + Are you sure you want to delete {database.name}? {/if}

@@ -114,16 +128,16 @@
{:else if error}

- Are you sure you want to delete {$database.name}? + Are you sure you want to delete {database.name}?

- {:else if tableItems.length > 0} + {:else if entityItems.length > 0}
Table Last Updated - {#each tableItems as table} + {#each entityItems as table} {table.name} @@ -133,9 +147,9 @@ {/each} - {#if tableItems.length < tables.total} + {#if entityItems.length < entities.total}
- @@ -143,11 +157,11 @@
{/if}
- {:else if tableItems.length > 25} + {:else if entityItems.length > 25} @@ -100,18 +110,20 @@ Delete database - The database will be permanently deleted, including all tables within it. This action is - irreversible. + The database will be permanently deleted, including all {terminology.entity.lower + .plural} within it. This action is irreversible. -
{$database.name}
+
{database.name}
- {#await loadTableCount()} + {#await loadEntityCount()} {:then count} - {count} {count === 1 ? 'Table' : 'Tables'} + {@const entity = terminology.entity.title} + {count} + {count === 1 ? entity.singular : entity.plural} {/await}
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/store.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/store.ts index 36e755d77e..d5e75fb54e 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/store.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/store.ts @@ -1,11 +1,10 @@ -import { page } from '$app/stores'; +import { writable } from 'svelte/store'; import type { Column } from '$lib/helpers/types'; -import type { Models } from '@appwrite.io/console'; -import { derived, writable } from 'svelte/store'; import { IconChartBar, IconCloudUpload, IconCog } from '@appwrite.io/pink-icons-svelte'; +import { resolveRoute, withPath } from '$lib/stores/navigation'; +import type { Page } from '@sveltejs/kit'; -export const database = derived(page, ($page) => $page.data.database as Models.Database); -export const showCreateTable = writable(false); +export const showCreateEntity = writable(false); export const tableViewColumns = writable([ { id: '$id', title: 'Table ID', type: 'string', width: 200 }, @@ -36,3 +35,13 @@ export const databaseSubNavigationItems = [ { title: 'Usage', href: 'usage', icon: IconChartBar }, { title: 'Settings', href: 'settings', icon: IconCog } ]; + +export function buildEntityRoute(page: Page, entityType: string, entityId: string): string { + return withPath( + resolveRoute( + '/(console)/project-[region]-[project]/databases/database-[database]', + page.params + ), + `/${entityType}-${entityId}` + ); +} 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 4b5354ffd6..6e9694f648 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 @@ -1,10 +1,9 @@ - {$table?.name ?? 'Table'} - Appwrite + {table?.name ?? 'Table'} - Appwrite @@ -397,24 +430,79 @@ 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} /> + externalFieldKey={$showCreateIndexSheet.column} + onCreateIndex={async (index) => { + await sdk.forProject(page.params.region, page.params.project).tablesDB.createIndex({ + databaseId: page.params.database, + tableId: page.params.table, + key: index.key, + type: index.type, + columns: index.fields, + lengths: index.lengths, + orders: index.orders + }); + + await invalidate(Dependencies.TABLE); + }} /> editRowPermissions?.updatePermissions() }}> - + @@ -482,4 +589,13 @@
- + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.ts index 2a34aa22f1..a51bf2e506 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.ts @@ -1,18 +1,22 @@ -import { sdk } from '$lib/stores/sdk'; -import { Dependencies } from '$lib/constants'; -import type { LayoutLoad } from './$types'; -import Breadcrumbs from './breadcrumbs.svelte'; import Header from './header.svelte'; +import type { LayoutLoad } from './$types'; +import { Dependencies } from '$lib/constants'; +import { Breadcrumbs, useDatabasesSdk } from '$database/(entity)'; -export const load: LayoutLoad = async ({ params, depends }) => { +export const load: LayoutLoad = async ({ params, depends, parent }) => { + const { database } = await parent(); depends(Dependencies.TABLE); + const databasesSdk = useDatabasesSdk(params.region, params.project, database.type); + + const table = await databasesSdk.getEntity({ + databaseId: params.database, + entityId: params.table + }); + return { + table, header: Header, - breadcrumbs: Breadcrumbs, - table: await sdk.forProject(params.region, params.project).tablesDB.getTable({ - databaseId: params.database, - tableId: params.table - }) + breadcrumbs: Breadcrumbs }; }; 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 df77376faa..36f3db3bba 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,15 +6,13 @@ 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, tableColumns, isCsvImportInProgress, showRowCreateSheet, showCreateColumnSheet, - type Columns, randomDataModalState, expandTabs } from './store'; @@ -26,31 +24,52 @@ 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 { columnOptions } from './columns/store'; + import { EmptySheet, EmptySheetCards, type Field } from '$database/(entity)'; + import { invalidate } from '$app/navigation'; + import { Dependencies } from '$lib/constants'; + import { + Empty as SuggestionsEmptySheet, + tableColumnSuggestions, + showColumnsSuggestionsModal + } from '../(suggestions)'; + import IconAI from '../(suggestions)/icon/aiForButton.svelte'; export let data: PageData; + $: table = data.table; + + let isRefreshing = false; let showImportCSV = false; // todo: might need a type fix here. const filterColumns = writable([]); - function createTableColumns(columns: Columns[], selected: string[] = []): Column[] { - return columns.map((column) => ({ - id: column.key, - title: column.key, - type: column.type as ColumnType, - hide: !!selected?.includes(column.key), - array: column?.array, - format: 'format' in column && column?.format === 'enum' ? column.format : null, - elements: 'elements' in column ? column.elements : null - })); + function createTableColumns(fields: Field[], selected: string[] = []): Column[] { + return fields.map((field) => { + return { + id: field.key, + title: field.key, + type: field.type as ColumnType, + hide: !!selected?.includes(field.key), + array: field?.array, + format: 'format' in field && field?.format === 'enum' ? field.format : null, + elements: 'elements' in field ? field.elements : null, + icon: columnOptions.find((option) => option.type === field.type)?.icon + }; + }); } function createFilterableColumns(columns: Column[], selected: string[] = []): Column[] { @@ -68,14 +87,14 @@ $: selected = preferences.getCustomTableColumns(page.params.table); - $: if ($table.columns) { - const freshColumns = createTableColumns($table.columns, selected); + $: if (table.fields) { + const freshColumns = createTableColumns(table.fields, selected); tableColumns.set(freshColumns); filterColumns.set(createFilterableColumns(freshColumns, selected)); } - $: hasColumns = !!$table.columns.length; - $: hasValidColumns = $table?.columns?.some((col) => col.status === 'available'); + $: hasColumns = !!table.fields.length; + $: hasValidColumns = table?.fields?.some((field: Field) => field.status === 'available'); $: canShowSuggestionsSheet = // enabled, has table details // and it matches current table @@ -83,13 +102,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 +151,8 @@ columns={tableColumns} hideView showAnyway - isCustomTable /> + isCustomTable + {disableButton} />
Columns @@ -141,49 +163,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.fields, 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} @@ -273,7 +374,7 @@ {/if} @@ -282,4 +383,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]/+page.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+page.ts index 31d33e0299..0f954c47d4 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+page.ts @@ -1,11 +1,12 @@ import { Dependencies, SPREADSHEET_PAGE_LIMIT } from '$lib/constants'; import { getLimit, getPage, getQuery, getView, pageToOffset, View } from '$lib/helpers/load'; import { sdk } from '$lib/stores/sdk'; -import { type Models, Query } from '@appwrite.io/console'; +import { Query } from '@appwrite.io/console'; import type { PageLoad } from './$types'; import { queries, queryParamToMap } from '$lib/components/filters'; import { buildWildcardColumnsQuery } from './rows/store'; import type { TagValue } from '$lib/components/filters/store'; +import type { Entity } from '$database/(entity)'; export const load: PageLoad = async ({ params, depends, url, route, parent }) => { const { table } = await parent(); @@ -57,7 +58,7 @@ function buildGridQueries( limit: number, offset: number, parsedQueries: Map, - table: Models.Table + table: Entity ) { const hasOrderQuery = Array.from(parsedQueries.values()).some( (q) => q.includes('orderAsc') || q.includes('orderDesc') diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/activity/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/activity/+page.svelte index 1161bb56d8..849d38953b 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/activity/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/activity/+page.svelte @@ -2,7 +2,11 @@ import { Activity } from '$lib/layout'; import type { PageData } from './$types'; - export let data: PageData; + const { + data + }: { + data: PageData; + } = $props();
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/breadcrumbs.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/breadcrumbs.svelte deleted file mode 100644 index 0b27f2fd07..0000000000 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/breadcrumbs.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - - 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 adc70ad97f..ab6eb7e417 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 @@ -16,13 +16,13 @@ Typography } from '@appwrite.io/pink-svelte'; import { isRelationship, isSpatialType, isString } from '../rows/store'; - import FailedModal from '../failedModal.svelte'; import { columns, type Columns, type ColumnsWidth, indexes, isCsvImportInProgress, + isWaterfallFromFaker, reorderItems, showCreateIndexSheet } from '../store'; @@ -40,20 +40,22 @@ IconTrash, IconViewList, IconLockClosed, - IconFingerPrint + IconFingerPrint, + IconMail } from '@appwrite.io/pink-icons-svelte'; import { type ComponentProps, onDestroy, onMount } from 'svelte'; import { Click, trackEvent } from '$lib/actions/analytics'; - import CsvDisabled from '../csvDisabled.svelte'; import { isSmallViewport } from '$lib/stores/viewport'; - import SideSheet from '../layout/sidesheet.svelte'; - import SpreadsheetContainer from '../layout/spreadsheet.svelte'; + import { SideSheet, SpreadsheetContainer, FailedModal, CsvDisabled } from '$database/(entity)'; import { showCreateColumnSheet } from '../store'; import { type Models } from '@appwrite.io/console'; import { preferences } from '$lib/stores/preferences'; 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 @@ -125,7 +127,7 @@ const columnFormatIcon = { ip: IconLocationMarker, url: IconLink, - email: IconLink, + email: IconMail, enum: IconViewList }; @@ -139,6 +141,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'] { @@ -248,6 +260,12 @@ minimumWidth: 300, resizable: true }, + { + id: 'type', + width: 150, + minimumWidth: 150, + resizable: false + }, { id: 'indexed', width: getColumnWidth('indexed', 150), @@ -299,8 +317,8 @@ saveColumnsWidth(resize.detail)}> Column name + Type Indexed Default value @@ -315,6 +334,7 @@ {#each updatedColumnsForSheet as column, index (column.key)} + {@const isId = column.key === '$id'} {@const option = columnOptions.find((option) => option.type === column.type)} {@const isSelectable = column['system'] || column.type === 'relationship' ? 'disabled' : true} @@ -359,8 +379,9 @@ {column.key}{column.array ? '[]' : undefined} {/if} + {#if isString(column) && column.encrypt} - + Encrypted
{/if} - - + {#if column.status !== 'available'} + + {@const columnType = column['format'] ? column['format'] : column.type} + {columnType.toLowerCase()} + - {@const isActuallyIndexed = $indexes.some((index) => - index.columns.includes(column.key) - )} + + {@const isActuallyIndexed = + isId || $indexes.some((index) => index.columns.includes(column.key))} - {@const checked = isActuallyIndexed || !!columnIndexMap[column.key]} + + {@const checked = isId || isActuallyIndexed || !!columnIndexMap[column.key]} - {@const _default = - column?.default !== null && column?.default !== undefined - ? column?.default - : null} + {@const _default = column.required + ? '-' + : column?.default !== null && column?.default !== undefined + ? column?.default + : null} {#if _default === null} + {:else if isSpatialType(column)} + {JSON.stringify(_default)} {:else} - {isSpatialType(column) ? JSON.stringify(_default) : _default} + {_default} {/if} @@ -452,8 +475,7 @@ - {:else if column.key !== '$sequence'} - + {:else if !isId} - - diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/header.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/header.svelte index 2e52e82f96..fd5d1e1f0c 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/header.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/header.svelte @@ -1,23 +1,19 @@ -
- - - - - {$table?.name} - - - {#key $table?.$id} - {$table?.$id} - {/key} - - - -
- - {#each tabs as tab} - - {tab.title} - - {/each} - -
-
-
- - +{#if table} +
+{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/+page.svelte index bce6d08c1b..28dd10b930 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/+page.svelte @@ -1,366 +1,150 @@ - - - {#if $canWriteTables} - - {/if} - - - -
- {#if data.table?.columns?.length} - {#if data.table.indexes.length} - - (showCreateIndex = true)} - on:columnsResize={(resize) => saveColumnsWidth(resize.detail)}> - - Key - Type - Columns - - - Lengths - - - - {#each data.table.indexes as index} - - - - {index.key} - {#if index.status !== 'available'} - - {#if index.error} - { - e.preventDefault(); - error = index.error; - showFailed = true; - }}>Details - {/if} - {/if} - - - {index.type} - - {index.columns.join(', ')} - - - - - - {index.lengths} - - - - - - { - toggle(); - selectedIndex = index; - showOverview = true; - }}>Overview - -
- -
- - { - toggle(); - showDelete = true; - selectedIndex = index; - trackEvent(Click.DatabaseIndexDelete); - }}>Delete -
-
-
-
- {/each} - - - - - {@const length = data.table.indexes.length} - {length} - {length === 1 ? 'index' : 'indexes'} - - - -
-
- {:else} - (showCreateIndex = true), - disabled: !$table?.columns?.length - } - }} /> - {/if} - {:else} - { - $showCreateColumnSheet.show = true; - } - } - }} /> - {/if} - - {#if selectedIndexes.length > 0} -
- - -
- - - - {selectedIndexes.length > 1 ? 'indexes' : 'index'} - selected - - -
-
- - - - -
-
- {/if} -
- - await createIndex.create() - }}> - - - -{#if selectedIndex} - -{:else if selectedIndexes && selectedIndexes.length} - -{/if} - - - - - - - - + + {#snippet emptyIndexesSheetView(toggle)} + + {#snippet subtitle()} + {#if isCloud} + + Need a hand? Learn more in the + + docs. + + + {/if} + {/snippet} + + {#snippet actions()} + {#if isCloud} + { + showIndexesSuggestions.update(() => true); + }} /> + {/if} + + + + {#if !isCloud} + + {/if} + {/snippet} + + {/snippet} + + {#snippet emptyEntitiesSheetView()} + + {#snippet subtitle()} + {#if isCloud} + + Need a hand? Learn more in the + + docs. + + + {/if} + {/snippet} + + {#snippet actions()} + {#if isCloud} + { + $showColumnsSuggestionsModal = true; + }} /> + + { + $showCreateColumnSheet.show = true; + }} /> + {:else} + { + $showCreateColumnSheet.show = true; + }} /> + + + {/if} + {/snippet} + + {/snippet} + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/select.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/select.svelte deleted file mode 100644 index e356097a5e..0000000000 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/indexes/select.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - - -
- - -
-{#if error} - {error} -{/if} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/emptySheet.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/emptySheet.svelte deleted file mode 100644 index 8141cf0a7a..0000000000 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/emptySheet.svelte +++ /dev/null @@ -1,389 +0,0 @@ - - -
- - { - /* @ignore: only for showing the `+` button on footer */ - }}> - - {#each spreadsheetColumns as column (column.id)} - {@const columnActionsById = column.id === 'actions'} - -
{ - if (columnActionsById && mode === 'rows') { - $showCreateColumnSheet.show = true; - $showCreateColumnSheet.title = 'Create column'; - $showCreateColumnSheet.columns = $tableColumns; - $showCreateColumnSheet.columnsOrder = $columnsOrder; - } - }}> - - {#if column.isAction} - - - - {:else if column.id === 'actions' || column.id === 'empty'} - {column.title} - {:else} - - {column.title} - - - - {/if} - -
- {/each} -
- - - {#if $spreadsheetLoading} - - - - {/if} - -
-
- - {#if !$spreadsheetLoading} -
-
- - {title ?? `You have no ${mode} yet`} - - {#if showActions} - - {#if mode !== 'rows-filtered'} - - - {actions?.primary?.text ?? `Create ${mode}`} - - - {#if mode === 'rows'} - - {actions?.random?.text ?? `Generate sample data`} - - {/if} - {:else} - - {actions?.primary?.text} - - {/if} - - {/if} - -
-
- {/if} -
- - diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/columns/types/relationship.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/columns/types/relationship.svelte index f790579c56..58bfca45c6 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/columns/types/relationship.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/columns/types/relationship.svelte @@ -124,7 +124,12 @@ } function updateRelatedList() { + /** + * don't be alarmed here. + * reassigning to trigger reactivity! + */ relatedList = relatedList; + value = relatedList; } diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/create.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/create.svelte index 8ac677c606..62eb13dd6f 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/create.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/create.svelte @@ -10,7 +10,7 @@ import { type Columns, spreadsheetRenderKey } from '../store'; import { ID, type Models } from '@appwrite.io/console'; import { Alert, Layout, Typography, Selector } from '@appwrite.io/pink-svelte'; - import SideSheet from '../layout/sidesheet.svelte'; + import { type Entity, SideSheet, toRelationalField } from '$database/(entity)'; import { invalidate } from '$app/navigation'; import { Dependencies } from '$lib/constants'; import { tick } from 'svelte'; @@ -29,8 +29,8 @@ showSheet = $bindable(false), existingData = $bindable(null) }: { + table: Entity; showSheet: boolean; - table: Models.Table; existingData: Models.Row | null; } = $props(); @@ -45,15 +45,17 @@ } function computeInitialCreateRow(): CreateRow { - const availableColumns = table.columns.filter((a) => a.status === 'available'); + const availableColumns = table.fields + .map(toRelationalField) + .filter((column: Columns) => column.status === 'available'); return { id: null, row: existingData ? existingData : availableColumns.reduce( - (acc, attr) => { - acc[attr.key] = attr.array ? [] : null; + (acc, field) => { + acc[field.key] = field.array ? [] : null; return acc; }, {} as Record @@ -189,7 +191,7 @@ Choose which permission scopes to grant your application. It is best practice to allow only the permissions you need to meet your project goals. - {#if table.rowSecurity} + {#if table.recordSecurity} Row security is enabled Users will be able to access this row if they have been granted diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/edit.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/edit.svelte index 9baf796974..418b8a6c14 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/edit.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/edit.svelte @@ -8,21 +8,32 @@ import type { Models } from '@appwrite.io/console'; import { Dependencies } from '$lib/constants'; import { invalidate } from '$app/navigation'; - import { table, type Columns, PROHIBITED_ROW_KEYS } from '../store'; + import { type Columns, PROHIBITED_ROW_KEYS } from '../store'; import ColumnItem from './columns/columnItem.svelte'; - import { buildWildcardColumnsQuery, isRelationship, isRelationshipToMany } from './store'; + import { + buildWildcardColumnsQuery, + isRelationship, + isRelationshipToMany, + isSpatialType + } from './store'; import { Layout, Skeleton } from '@appwrite.io/pink-svelte'; import { deepClone } from '$lib/helpers/object'; - - const tableId = page.params.table; - const databaseId = page.params.database; + import { type Entity, toRelationalField } from '$database/(entity)'; + import deepEqual from 'deep-equal'; + import { onMount } from 'svelte'; let { + table, row = $bindable(), - rowId = $bindable(null) + rowId = $bindable(null), + autoFocus = true, + disabled = $bindable(true) }: { + table: Entity; row?: Models.Row | null; rowId?: string | null; + autoFocus?: boolean; + disabled?: boolean; } = $props(); let loading = $state(false); @@ -30,6 +41,11 @@ let work = $state | null>(null); let columnFormWrapper = $state(null); + onMount(() => { + /* silences the not read error warning */ + disabled; + }); + function initWork() { const filteredKeys = Object.keys(row).filter((key) => { return !PROHIBITED_ROW_KEYS.includes(key); @@ -49,10 +65,10 @@ try { row = await sdk.forProject(page.params.region, page.params.project).tablesDB.getRow({ - databaseId, - tableId, + databaseId: table.databaseId, + tableId: table.$id, rowId, - queries: buildWildcardColumnsQuery($table) + queries: buildWildcardColumnsQuery(table) }); } catch (error) { addNotification({ @@ -76,7 +92,9 @@ $effect(() => { if (row) { work = initWork(); - focusFirstInput(); + if (autoFocus) { + requestAnimationFrame(() => focusFirstInput()); + } } else { work = null; } @@ -90,6 +108,10 @@ const workColumn = $work?.[column.key]; const currentColumn = $doc?.[column.key]; + if (isSpatialType(column)) { + return deepEqual(workColumn, currentColumn); + } + if (column.array) { return !symmetricDifference(Array.from(workColumn), Array.from(currentColumn)).length; } @@ -121,8 +143,8 @@ try { await sdk.forProject(page.params.region, page.params.project).tablesDB.updateRow({ - databaseId, - tableId, + databaseId: table.databaseId, + tableId: table.$id, rowId: row.$id, data: $work, permissions: $work.$permissions @@ -143,11 +165,14 @@ } } - export function isDisabled(): boolean { - if (!work || !row || !$table?.columns?.length) return true; + $effect(() => { + if (!work || !row || !table?.fields?.length) { + disabled = true; + return; + } - return $table.columns.every((column) => compareColumns(column, $work, row)); - } + disabled = table.fields.every((column: Columns) => compareColumns(column, $work, row)); + }); function focusFirstInput() { const firstInput = columnFormWrapper?.querySelector( @@ -166,16 +191,16 @@
-{:else if $table.columns?.length && work} +{:else if table.fields?.length && work}
- {#each $table.columns as column} + {#each table.fields as column} {@const label = column.key} {/each} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/editPermissions.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/editPermissions.svelte index eb3f0d6537..d3f58dc7a9 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/editPermissions.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/editPermissions.svelte @@ -1,5 +1,4 @@ {#if loading}
-{:else if relatedTable?.columns?.length && fetchedRows.length} +{:else if relatedTable?.fields?.length && fetchedRows.length} {@const twoWayKeys = new Set( - relatedTable.columns + relatedTable.fields .filter((column: Models.ColumnRelationship) => column.twoWay) .map((c) => c.key) )} - {@const columnsToRender = relatedTable.columns.filter((c) => !twoWayKeys.has(c.key))} + {@const columnsToRender = relatedTable.fields.filter( + (field: Field) => !twoWayKeys.has(field.key) + )}
{#if fetchedRows.length === 1} @@ -360,10 +381,10 @@ {#each columnsToRender as column} {@const label = column.key} {/each} @@ -381,10 +402,10 @@ {#each columnsToRender as column} {@const label = column.key} {/each} diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/store.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/store.ts index 835262ef73..a007c01fc4 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/store.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/rows/store.ts @@ -1,13 +1,14 @@ import { page } from '$app/state'; import type { Column } from '$lib/helpers/types'; -import type { Columns } from '../store'; +import type { Attributes, Columns } from '../store'; import { type Models, Query } from '@appwrite.io/console'; +import type { Entity, Field } from '$database/(entity)'; -export function isRelationshipToMany(col: Columns) { - if (!col) return false; - if (!isRelationship(col)) return false; +export function isRelationshipToMany(field: Field) { + if (!field) return false; + if (!isRelationship(field)) return false; - const column = col as Models.ColumnRelationship; + const column = field as Models.ColumnRelationship | Models.AttributeRelationship; if (!column?.relationType) return false; if (column?.side === 'child') { @@ -17,34 +18,42 @@ export function isRelationshipToMany(col: Columns) { } } -export function isRelationship(column: Columns): column is Models.ColumnRelationship { - if (!column) return false; - return column?.type === 'relationship'; +export function isRelationship( + field: Field +): field is Models.ColumnRelationship | Models.AttributeRelationship { + if (!field) return false; + return field?.type === 'relationship'; } -export function isString(column: Columns): column is Models.ColumnString { - if (!column) return false; - return column?.type === 'string'; +export function isString(field: Field): field is Models.ColumnString | Models.AttributeString { + if (!field) return false; + return field?.type === 'string'; } export function isSpatialType( - column: Columns | Column -): column is Models.ColumnPoint | Models.ColumnLine | Models.ColumnPolygon { - if (!column) return false; + field: Columns | Attributes | Column +): field is + | Models.ColumnPoint + | Models.ColumnLine + | Models.ColumnPolygon + | Models.AttributePoint + | Models.AttributeLine + | Models.AttributePolygon { + if (!field) return false; const spatialTypes = ['point', 'linestring', 'polygon']; - return spatialTypes.includes(column.type.toLowerCase()); + return spatialTypes.includes(field.type.toLowerCase()); } /** - * Returns select queries for all main and related fields in a table. + * Returns select queries for all main and related fields in an `Entity`. */ -export function buildWildcardColumnsQuery(table: Models.Table | null = null): string[] { +export function buildWildcardColumnsQuery(entity: Entity | null = null): string[] { return [ - ...(table?.columns - ?.filter((col) => col.status === 'available' && isRelationship(col)) - ?.map((col) => Query.select([`${col.key}.*`])) ?? []), + ...(entity?.fields + ?.filter((field) => field.status === 'available' && isRelationship(field)) + ?.map((field) => Query.select([`${field.key}.*`])) ?? []), Query.select(['*']) ]; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/+page.svelte index 20abb285b4..bca5e5fde1 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/+page.svelte @@ -1,23 +1,66 @@
- {#if $table} - - - - - - - {/if} + updateTable({ enabled })} /> + + updateTable({ name })} /> + + + + updateTable({ permissions })} /> + + updateTable({ rowSecurity })} /> + +
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/dangerZone.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/dangerZone.svelte deleted file mode 100644 index fa75976966..0000000000 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/dangerZone.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - - - Delete table - The table will be permanently deleted, including all the rows within it. This action is irreversible. - - - -
{$table.name}
-
-

Last updated: {toLocaleDateTime($table.$updatedAt)}

-
-
- - - - -
- - diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/deleteTable.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/deleteTable.svelte deleted file mode 100644 index 697dd79622..0000000000 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/deleteTable.svelte +++ /dev/null @@ -1,61 +0,0 @@ - - - - - Are you sure you want to delete {$table.name}? - - diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/updateName.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/updateName.svelte deleted file mode 100644 index 45ad32ed0d..0000000000 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/updateName.svelte +++ /dev/null @@ -1,68 +0,0 @@ - - -
- - Name - - - - - - - - - diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/updatePermissions.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/updatePermissions.svelte deleted file mode 100644 index 8041cb2c2f..0000000000 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/updatePermissions.svelte +++ /dev/null @@ -1,72 +0,0 @@ - - - - Permissions - Choose who can access your tables and rows. - Learn more - . - - {#if tablePermissions} - - {/if} - - - - - diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/updateSecurity.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/updateSecurity.svelte deleted file mode 100644 index 1b92ad0596..0000000000 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/updateSecurity.svelte +++ /dev/null @@ -1,66 +0,0 @@ - - - - Row security - - - -

- When row security is enabled, users will be able to access rows for which they have been - granted either row or table permissions. -

-

- If row security is disabled, users can access rows only if they have table permissions. Row permissions will be ignored. -

-
- - - -
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/updateStatus.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/updateStatus.svelte deleted file mode 100644 index 8d91f266e7..0000000000 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/settings/updateStatus.svelte +++ /dev/null @@ -1,66 +0,0 @@ - - - - {$table.name} - -
    - -
-
-

Created: {toLocaleDateTime($table.$createdAt)}

-

Last updated: {toLocaleDateTime($table.$updatedAt)}

-
-
- - - - -
diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/sheetOptions.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/sheetOptions.svelte index 761178f792..3fdf50d14a 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/sheetOptions.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/sheetOptions.svelte @@ -117,15 +117,20 @@ if (type === 'header') { if (isSequence) { - return ['sort-asc', 'sort-desc'].includes(item.action ?? ''); + return ['sort-asc', 'sort-desc'].includes(item?.action); } - if (['delete', 'update', 'duplicate-header'].includes(item.action) && isSystemColumn) { + if (['delete', 'update', 'duplicate-header'].includes(item?.action) && isSystemColumn) { + return false; + } + + // hide column-left and create-index for $id (first column, already indexed) + if (columnId === '$id' && ['column-left', 'create-index'].includes(item?.action)) { return false; } // hide sort options for relationship columns - if (isRelationship(column) && ['sort-asc', 'sort-desc'].includes(item.action ?? '')) { + if (isRelationship(column) && ['sort-asc', 'sort-desc'].includes(item?.action)) { return false; } } diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/spreadsheet.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/spreadsheet.svelte index 324a1e0876..f87e360150 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/spreadsheet.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/spreadsheet.svelte @@ -1,7 +1,7 @@ @@ -776,6 +802,15 @@ bottomActionTooltip={{ text: 'Create row', placement: 'top-end' + }} + expandKbdShortcut="Cmd+Enter" + on:expandKbdShortcut={({ detail }) => { + const focusedRowId = detail.rowId; + const focusedRow = $paginatedRows.items.find((row) => row.$id === focusedRowId); + + previouslyFocusedElement = document.activeElement; + $databaseRowSheetOptions.autoFocus = false; + onSelectSheetOption('update', null, 'row', focusedRow); }}> {#each $tableColumns as column (column.id)} @@ -863,60 +898,141 @@ id={row?.$id} virtualItem={item} select={rowSelection} + hoverEffect showSelectOnHover valueWithoutHover={row.$sequence}> - {#each $tableColumns as { id: columnId, isEditable } (columnId)} + {#each $tableColumns as { id: columnId, isEditable, hide } (columnId)} {@const rowColumn = $columns.find((col) => col.key === columnId)} - - {#if columnId === '$id'} - {row.$id} - {:else if columnId === '$createdAt' || columnId === '$updatedAt'} - - {:else if columnId === 'actions'} - - 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} - + {#if columnId === '$id' && !hide} + + + + + 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} - - 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); - } - }} /> - - + + + {@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 */ + + if (isRelationshipToMany(rowColumn)) { + openSideSheetForRelationsToMany( + row[columnId], + rowColumn + ); + } else { + $databaseRowSheetOptions.autoFocus = true; + onSelectSheetOption('update', null, 'row', row); + } + }} /> + + + {/if} {/each} {/if} @@ -1042,7 +1160,8 @@ gap="xs" direction="row" alignItems="center" - alignContent="center"> + alignContent="center" + class="footer-input-select-wrapper"> Page {@const isSingle = selectedRowForDelete !== null}

{#if isSingle} - Are you sure you want to delete this row from {$table.name}? + Are you sure you want to delete this row from {table.name}? {:else} Are you sure you want to delete {selectedRows.length} - {selectedRows.length > 1 ? 'rows' : 'row'} from {$table.name}? + {selectedRows.length > 1 ? 'rows' : 'row'} from {table.name}? {/if}

@@ -1185,6 +1304,11 @@ transform: translateX(-50%); } + :global(.footer-input-select-wrapper button.input) { + height: 30px; + background-color: var(--bgcolor-neutral-primary); + } + // very weird because the library already has this! :global(.virtual-row:has([data-editing-mode='true'])) { z-index: 1 !important; diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/store.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/store.ts index f5679dfb69..07f7e53451 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/store.ts +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/store.ts @@ -20,14 +20,37 @@ export type Columns = | Models.ColumnPolygon | (Models.ColumnRelationship & { default?: never }); -type Table = Omit & { +export type Attributes = + | Models.AttributeBoolean + | Models.AttributeEmail + | Models.AttributeEnum + | Models.AttributeFloat + | Models.AttributeInteger + | Models.AttributeIp + | Models.AttributeString + | Models.AttributeUrl + | Models.AttributePoint + | Models.AttributeLine + | Models.AttributePolygon + | (Models.AttributeRelationship & { default?: never }); + +export type Collection = Omit & { + attributes: Array; +}; + +export type Table = Omit & { columns: Array; }; -export const table = derived(page, ($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 +87,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< diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/usage/[[period]]/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/usage/[[period]]/+page.svelte index 4476c9a17b..a8d8fdb0b0 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/usage/[[period]]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/usage/[[period]]/+page.svelte @@ -1,24 +1,11 @@ -
- - - -
+ 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 28e5c2362b..4efab14a2e 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,79 @@ - - + {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} - - -{#if selectedTables.length > 0} - - - - - {selectedTables.length > 1 ? 'tables' : 'table'} - selected - - - - - - - -{/if} + {/snippet} - - - Are you sure you want to delete {selectedTables.length} - {selectedTables.length > 1 ? 'tables' : 'table'}? - - + {#snippet children(root)} + {#each data.entities.entities as entity (entity.$id)} + + {#each $tableViewColumns as column} + + {#if column.id === '$id'} + {#key $tableViewColumns} + {entity.$id} + {/key} + {:else if column.id === 'name'} + {entity.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 10ec6f307a..6054d6f735 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/table.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/table.svelte @@ -1,15 +1,21 @@ @@ -27,13 +46,12 @@ {title} {/each}
+ + {@const entities = data.entities} {#each data.databases.databases as database (database.$id)} - {@const tableId = tables[database?.$id] ?? null} - {@const tableHref = tableId ? `/table-${tableId}` : ''} - + {@const entityId = entities[database?.$id] ?? null} + {#each $columns as column} {#if column.id === '$id'} diff --git a/src/routes/(console)/project-[region]-[project]/functions/+layout.ts b/src/routes/(console)/project-[region]-[project]/functions/+layout.ts index 1afe03dd33..b22ab7609f 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 6aaa49c599..04c757d768 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 40c38c541e..e538374122 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 @@ -38,7 +38,7 @@ return { value, label, leadingHtml }; }); - const specificationOptions = data.specificationsList.specifications.map((size) => ({ + const specificationOptions = (data.specificationsList?.specifications ?? []).map((size) => ({ label: `${size.cpus} CPU, ${size.memory} MB RAM` + (!size.enabled ? ` (Upgrade to use this)` : ''), @@ -58,7 +58,7 @@ let roles: string[] = []; let variables: Partial[] = []; let files: FileList; - let specification = specificationOptions[0].value; + let specification = specificationOptions[0]?.value || ''; async function create() { try { @@ -185,27 +185,24 @@ on:invalid={handleInvalid}> - - + + Drag and drop file here or click to upload - - - + justifyContent="center"> + + + Only .tar.gz files allowed + - Only .tar.gz files allowed - + {#if maxSize > 0} - Max file size: {readableMaxSize.value + readableMaxSize.unit} {/if} diff --git a/src/routes/(console)/project-[region]-[project]/functions/create-function/repository-[repository]/+page.svelte b/src/routes/(console)/project-[region]-[project]/functions/create-function/repository-[repository]/+page.svelte index 2cc5e7b28d..9a06f70dd6 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/create-function/repository-[repository]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/create-function/repository-[repository]/+page.svelte @@ -10,7 +10,7 @@ import { installation, repository } from '$lib/stores/vcs'; import { Layout } from '@appwrite.io/pink-svelte'; import { writable } from 'svelte/store'; - import { ID, Runtime, VCSDeploymentType, VCSDetectionType } from '@appwrite.io/console'; + import { ID, Runtime, VCSReferenceType, VCSDetectionType } from '@appwrite.io/console'; import type { Models } from '@appwrite.io/console'; import { onMount } from 'svelte'; import Details from '../(components)/details.svelte'; @@ -25,7 +25,7 @@ export let data; - const specificationOptions = data.specificationsList.specifications.map((size) => ({ + const specificationOptions = (data.specificationsList?.specifications ?? []).map((size) => ({ label: `${size.cpus} CPU, ${size.memory} MB RAM` + (!size.enabled ? ` (Upgrade to use this)` : ''), @@ -55,7 +55,7 @@ let rootDir = './'; let variables: Partial[] = []; let silentMode = false; - let specification = specificationOptions[0].value; + let specification = specificationOptions[0]?.value || ''; let detectingRuntime = true; @@ -132,7 +132,7 @@ .forProject(page.params.region, page.params.project) .functions.createVcsDeployment({ functionId: func.$id, - type: VCSDeploymentType.Branch, + type: VCSReferenceType.Branch, reference: branch, activate: true }); diff --git a/src/routes/(console)/project-[region]-[project]/functions/create-function/template-[template]/+page.svelte b/src/routes/(console)/project-[region]-[project]/functions/create-function/template-[template]/+page.svelte index 2b082238a2..98c8bf7ea4 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/create-function/template-[template]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/create-function/template-[template]/+page.svelte @@ -15,7 +15,7 @@ import { writable } from 'svelte/store'; import ProductionBranch from '$lib/components/git/productionBranchFieldset.svelte'; import Configuration from './configuration.svelte'; - import { ID, Runtime, type Models } from '@appwrite.io/console'; + import { ID, Runtime, TemplateReferenceType, type Models } from '@appwrite.io/console'; import { ConnectBehaviour, NewRepository, @@ -34,7 +34,7 @@ export let data; - const specificationOptions = data.specificationsList.specifications.map((size) => ({ + const specificationOptions = (data.specificationsList?.specifications ?? []).map((size) => ({ label: `${size.cpus} CPU, ${size.memory} MB RAM` + (!size.enabled ? ` (Upgrade to use this)` : ''), @@ -65,7 +65,7 @@ let selectedScopes: string[] = []; let execute = true; let variables: Partial[] = []; - let specification = specificationOptions[0].value; + let specification = specificationOptions[0]?.value || ''; onMount(async () => { if (!$installation?.$id) { @@ -179,7 +179,8 @@ repository: data.template.providerRepositoryId || undefined, owner: data.template.providerOwner || undefined, rootDirectory: rt?.providerRootDirectory || undefined, - version: data.template.providerVersion || undefined, + type: TemplateReferenceType.Tag, + reference: data.template.providerVersion || undefined, activate: true }); diff --git a/src/routes/(console)/project-[region]-[project]/functions/create-function/template-[template]/configuration.svelte b/src/routes/(console)/project-[region]-[project]/functions/create-function/template-[template]/configuration.svelte index 34f815e6e3..cd21e5838d 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/create-function/template-[template]/configuration.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/create-function/template-[template]/configuration.svelte @@ -41,8 +41,8 @@ variables.map((variable) => { if (variable.value === '{apiEndpoint}') { - variable.value = getApiEndpoint(); - variable.placeholder = getApiEndpoint(); + variable.value = getApiEndpoint(page.params.region); + variable.placeholder = getApiEndpoint(page.params.region); } else if (variable.value === '{projectId}') { variable.value = page.params.project; variable.placeholder = page.params.project; diff --git a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(components)/deploymentCard.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(components)/deploymentCard.svelte index 1add28d37b..2376107203 100644 --- a/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(components)/deploymentCard.svelte +++ b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(components)/deploymentCard.svelte @@ -15,6 +15,8 @@ import { DeploymentSource, DeploymentCreatedBy, DeploymentDomains } from '$lib/components/git'; import { func } from '../store'; import { capitalize } from '$lib/helpers/string'; + import { getEffectiveBuildStatus } from '$lib/helpers/buildTimeout'; + import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store'; import { isCloud } from '$lib/system'; import { IconInfo } from '@appwrite.io/pink-icons-svelte'; import Link from '$lib/elements/link.svelte'; @@ -36,6 +38,9 @@ footer?: Snippet; } = $props(); + let effectiveStatus = $derived( + getEffectiveBuildStatus(deployment.status, deployment.$createdAt, $regionalConsoleVariables) + ); let totalSize = $derived(humanFileSize(deployment?.totalSize ?? 0)); @@ -122,11 +127,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 b91ee6d6f0..85d45bb275 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 ae5cdc4c3d..70124e38ef 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 a65ae194fe..24a05f760f 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)/deleteModal.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/(modals)/deleteModal.svelte index a8a5d819e6..e913d705b7 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 83247cdb2e..9a9b47b2e2 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/add-domain/+page.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/add-domain/+page.svelte index f913e4c91a..89f2bb4c2f 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,7 +48,7 @@ async function addDomain() { const apexDomain = getApexDomain(domainName); - let domain = data.domains?.domains.find((d: Models.Domain) => d.domain === apexDomain); + let domain = data.domainsList.domains.find((d: Models.Domain) => d.domain === apexDomain); if (apexDomain && !domain && isCloud) { try { @@ -57,15 +57,7 @@ domain: apexDomain }); } catch (error) { - // apex might already be added on organization level, skip. - const alreadyAdded = error?.type === 'domain_already_exists'; - if (!alreadyAdded) { - addNotification({ - type: 'error', - message: error.message - }); - return; - } + // Apex domain creation error needs to be silent. } } @@ -101,9 +93,7 @@ await goto(routeBase); await invalidate(Dependencies.FUNCTION_DOMAINS); } else { - await goto( - `${routeBase}/add-domain/verify-${domainName}?rule=${rule.$id}&domain=${domain.$id}` - ); + await goto(`${routeBase}/add-domain/verify-${domainName}?rule=${rule.$id}`); await invalidate(Dependencies.FUNCTION_DOMAINS); } } catch (error) { 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 9ee8720470..a1f08feed6 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 4db9c86d72..41d1b541ea 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 @@ -20,7 +20,6 @@ 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'; @@ -28,29 +27,35 @@ 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 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) === + data.domainsList.domains.find((rule) => rule.domain === data.proxyRule.domain) === undefined; try { if (selectedTab !== 'nameserver') { @@ -58,25 +63,32 @@ .forProject(page.params.region, page.params.project) .proxy.updateRuleVerification({ ruleId }); verified = ruleData.status === 'verified'; + + // This means domain verification using DNS records hasn't succeeded and the rule is still in initial state. + if (ruleData.status === 'created') { + 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 + domain: data.proxyRule.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' - ); } - addNotification({ - type: 'success', - message: 'Domain added successfully' - }); + if (verified) { + addNotification({ + type: 'success', + message: 'Domain added successfully' + }); + } else { + addNotification({ + type: 'info', + message: 'Verification in progress' + }); + } await goto(routeBase); await invalidate(Dependencies.DOMAINS); await invalidate(Dependencies.FUNCTION_DOMAINS); @@ -96,12 +108,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=${data.proxyRule.domain}`); } -
+ - {page.params.domain} + {data.proxyRule.domain} @@ -124,7 +136,7 @@
- {#if isSubDomain && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME && $regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME !== 'localhost'} + {#if showCNAMETab} (selectedTab = 'cname')} @@ -132,7 +144,7 @@ CNAME {/if} - {#if isCloud} + {#if showNSTab} (selectedTab = 'nameserver')} @@ -140,7 +152,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 +160,7 @@ A {/if} - {#if !isCloud && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA && $regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA !== '::1'} + {#if showAAAATab} (selectedTab = 'aaaa')} @@ -160,9 +172,17 @@
{#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 b89c933306..253859ee43 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/retryDomainModal.svelte b/src/routes/(console)/project-[region]-[project]/functions/function-[function]/domains/retryDomainModal.svelte index 8ccd491e2b..391321a9b3 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,9 +6,13 @@ 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'; let { show = $bindable(false), @@ -18,21 +22,64 @@ selectedProxyRule: Models.ProxyRule; } = $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 = $state(false); + + function getDefaultTab() { + return showCNAMETab ? 'cname' : showATab ? 'a' : showAAAATab ? 'aaaa' : 'nameserver'; + } + async function retryProxyRule() { try { - await sdk + error = null; + const proxyRule = await sdk .forProject(page.params.region, page.params.project) .proxy.updateRuleVerification({ ruleId: selectedProxyRule.$id }); + + verified = proxyRule.status === 'verified'; await invalidate(Dependencies.FUNCTION_DOMAINS); + + // This means domain verification using DNS records hasn't succeeded and the rule is still in initial state. + if (proxyRule.status === 'created') { + throw new Error( + 'Domain verification failed. Please check your domain settings or try again later' + ); + } + + if (verified) { + addNotification({ + type: 'success', + message: `${selectedProxyRule.domain} has been verified` + }); + } else { + addNotification({ + type: 'info', + message: 'Verification in progress' + }); + } show = false; - addNotification({ - type: 'success', - message: `${selectedProxyRule.domain} has been verified` - }); trackEvent(Submit.DomainUpdateVerification); } catch (e) { - error = e.message; + error = + e.message ?? + 'Domain verification failed. Please check your domain settings or try again later'; trackError(e, Submit.DomainUpdateVerification); } } @@ -45,8 +92,55 @@ - {#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 902fb3e3e8..f741c03ee0 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,7 +7,7 @@ export const columns = writable([ title: 'Domain', type: 'string', format: 'string', - width: { min: 200 } + width: { min: 300 } }, { 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 80f8663d44..98e29e5ba8 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,7 @@ 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'; let { proxyRules, @@ -29,6 +36,7 @@ let showDelete = $state(false); let showRetry = $state(false); + let showLogs = $state(false); let selectedProxyRule: Models.ProxyRule = $state(null); const proxyTarget = (proxy: Models.ProxyRule) => { @@ -49,7 +57,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,21 +69,51 @@ {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)} @@ -93,7 +135,18 @@ - {#if proxyRule.status !== 'verified' && proxyRule.status !== 'verifying'} + {#if isLogsViewable} + { + selectedProxyRule = proxyRule; + showLogs = true; + toggle(e); + }}> + View logs + + {/if} + {#if isRetryable} { @@ -105,6 +158,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 d7af62449f..478caf2647 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 b3f43680e7..55a275d5bb 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 48a313d489..7cddff0bb2 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)} + { + 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)} + {/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 c3199dcf57..30dcd26ff7 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 2ee9630477..42bf293ced 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 eecafbd5a3..7b46acc096 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,5 @@ - - + {#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} - - {/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)} + {/snippet} + {#snippet children(root)} + {#each data.deploymentList.deployments as deployment (deployment.$id)} + {@const effectiveStatus = getEffectiveBuildStatus( + deployment.status, + deployment.$createdAt, + $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 === '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 6f009719b4..b096f25618 100644 --- a/src/routes/(console)/project-[region]-[project]/messaging/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/messaging/+page.svelte @@ -1,7 +1,15 @@ + + 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 e8a711d863..8fd01fdfdc 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 4a6c743315..857bbb4f88 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 5d70650ebd..dfaeb132d1 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 afa62b44ff..4ec9350a01 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 88f6a4bab1..010519b4cd 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 985d93215e..4e186ece43 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 b65093f6e5..a54178b3db 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,89 @@ - - + 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} - + {/snippet} -{#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'}? - - + {#snippet children(root)} + {@const TableRowComponent = $canWriteProviders ? Table.Row.Link : Table.Row.Base} + {#each data.providers.providers as provider (provider.$id)} + {@const href = $canWriteProviders + ? `${base}/project-${page.params.region}-${page.params.project}/messaging/providers/provider-${provider.$id}` + : undefined} + + {#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 313b6604ff..34e1de1622 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 41741b3ceb..5ed2a7c1f9 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 b4d653169f..f5525754e7 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,7 @@ - - + + {#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 a42fcfd075..c9a64e9afb 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,8 @@ import { invalidate } from '$app/navigation'; import { base } from '$app/paths'; import { Submit, trackError, trackEvent } from '$lib/actions/analytics'; - import { Id } from '$lib/components'; + import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components'; import { Dependencies } from '$lib/constants'; - import { Button } from '$lib/elements/forms'; - import { addNotification } from '$lib/stores/notifications'; import type { PageData } from './$types'; import ProviderType from '../../providerType.svelte'; import DualTimeView from '$lib/components/dualTimeView.svelte'; @@ -15,21 +13,26 @@ import { targetsById } from '../../store'; import { MessagingProviderType, type Models } from '@appwrite.io/console'; import type { Column } from '$lib/helpers/types'; - import { Badge, FloatingActionBar, Table, Typography } from '@appwrite.io/pink-svelte'; - import Confirm from '$lib/components/confirm.svelte'; + import { Table } from '@appwrite.io/pink-svelte'; - export let columns: Column[]; - export let data: PageData; + let { + data, + columns + }: { + data: PageData; + columns: Column[]; + } = $props(); - let subscribers: Record = {}; - 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 handleDelete(selectedRows: string[]): Promise { async function deleteSubscriber(subscriberId: string) { await sdk .forProject(page.params.region, page.params.project) @@ -37,111 +40,68 @@ topicId: page.params.topic, subscriberId }); + const { target } = subscribers[subscriberId]; const { [target.$id]: _, ...rest } = $targetsById; $targetsById = rest; } - const promises = selectedIds.map((id) => deleteSubscriber(id)); + const promises = selectedRows.map((id) => deleteSubscriber(id)); try { await Promise.all(promises); - trackEvent(Submit.MessagingTopicSubscriberDelete, { - total: selectedIds.length - }); - addNotification({ - type: 'success', - message: `${selectedIds.length} subscriber${ - selectedIds.length > 1 ? 's' : '' - } deleted` - }); - invalidate(Dependencies.MESSAGING_TOPIC_SUBSCRIBERS); + trackEvent(Submit.MessagingTopicSubscriberDelete, { total: selectedRows.length }); } catch (error) { - addNotification({ - type: 'error', - message: error.message - }); trackError(error, Submit.MessagingTopicSubscriberDelete); + return error; } finally { - selectedIds = []; - showDelete = false; + await invalidate(Dependencies.MESSAGING_TOPIC_SUBSCRIBERS); } } - - $: data.subscribers.subscribers.forEach((s) => { - subscribers[s.$id] = s; - }); - $: selectedIds.forEach((id) => { - selected[id] = subscribers[id]; - }); - - + + {#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 0df14759c5..4c9bb81508 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 ff694c3cd1..3a742d94cc 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 28bf1a3894..9cfb56a832 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 + {@const expiration = getExpiryDetails(key)} + + {#if key.expire} + + {:else} + never + {/if} + + {#if expiration.status} + + {/if} + - {/if} - - {/each} - + {#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 0b4b527d63..9847000976 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 877db1b189..48485cc319 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 8a412281c1..ff21073979 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 5b98b45d18..42216d01aa 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,56 @@ 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'; + import { Platform } from './platforms/+page.svelte'; - export let pingCount = 0; - export let platforms: Models.Platform[] = []; + let { + pingCount = 0, + platforms = [] + }: { + pingCount: number; + platforms: Array; + } = $props(); - function createKey() { - trackEvent(Click.KeyCreateClick, { - source: 'onboarding' + const platformMap = $derived.by(() => { + const map = new Map(); + platforms.forEach((platform) => { + const platformInfo = getPlatformInfo(platform.type); + map.set(platformInfo.name, platform); }); - goto( - `${base}/project-${page.params.region}-${page.params.project}/overview/api-keys/create`, - { - replaceState: true - } - ); + + 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(`${projectRoute}/overview/api-keys/create`, { replaceState: true }); } - function openPlatformWizard(type: number, platform?: Models.Platform) { + function openPlatformWizard(type: Platform, 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,10 +105,15 @@
- + { - openPlatformWizard(0, platformMap.get('Web')); + openPlatformWizard( + Platform.Web, + platformMap.get('Web') + ); }} padding="s" > { - openPlatformWizard(4, platformMap.get('React Native')); + openPlatformWizard( + Platform.ReactNative, + platformMap.get('React Native') + ); }} padding="s" > { - openPlatformWizard(3, platformMap.get('Apple')); + openPlatformWizard( + Platform.Apple, + platformMap.get('Apple') + ); }} padding="s"> { - openPlatformWizard(2, platformMap.get('Android')); + openPlatformWizard( + Platform.Android, + platformMap.get('Android') + ); }} padding="s"> { - openPlatformWizard(1, platformMap.get('Flutter')); + openPlatformWizard( + Platform.Flutter, + platformMap.get('Flutter') + ); }} padding="s"> - - 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 +536,7 @@ { trackEvent( Click.OnboardingAuthOauth2Click @@ -510,7 +544,7 @@ }}>OAuth 2 { trackEvent( Click.OnboardingAuthAllMethodsClick @@ -684,6 +718,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 84b73e194c..22d0bce778 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 0b14c84c78..163cb4ed92 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 0000000000..035736e898 --- /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 32e9fc210c..ae805a26e5 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 fb5cf62fdf..87f37b7914 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 0ee698c845..6148712f02 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 3129249486..b18cbaf787 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 aeba8a3817..7a475290b2 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 bebe610d7f..6dc27dbeef 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 0000000000..08c28c9ac7 --- /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 d0b7956439..bc031f73de 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 e15dcfbf27..24f28def67 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/domains/+layout.svelte b/src/routes/(console)/project-[region]-[project]/settings/domains/+layout.svelte new file mode 100644 index 0000000000..cd60fcb5d3 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/settings/domains/+layout.svelte @@ -0,0 +1,21 @@ + + + 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 90d5a21595..2f4250d19f 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,7 +30,7 @@ async function addDomain() { const apexDomain = getApexDomain(domainName); - let domain = data.domains?.domains.find((d: Models.Domain) => d.domain === apexDomain); + let domain = data.domainsList.domains.find((d: Models.Domain) => d.domain === apexDomain); if (apexDomain && !domain && isCloud) { try { @@ -39,15 +39,7 @@ domain: apexDomain }); } catch (error) { - // apex might already be added on organization level, skip. - const alreadyAdded = error?.type === 'domain_already_exists'; - if (!alreadyAdded) { - addNotification({ - type: 'error', - message: error.message - }); - return; - } + // Apex domain creation error needs to be silent. } } @@ -59,17 +51,7 @@ 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 goto(`${routeBase}/add-domain/verify-${domainName}?rule=${rule.$id}`); await invalidate(Dependencies.DOMAINS); } } catch (error) { 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 a62c7f29c6..2520b9970a 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 a6c23790f5..f0312de382 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 @@ -20,7 +20,6 @@ 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'; @@ -28,30 +27,36 @@ 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'); + 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; - $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); + 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); + 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) === + data.domainsList.domains.find((rule) => rule.domain === data.proxyRule.domain) === undefined; try { if (selectedTab !== 'nameserver') { @@ -59,27 +64,32 @@ .forProject(page.params.region, page.params.project) .proxy.updateRuleVerification({ ruleId }); verified = ruleData.status === 'verified'; + + // This means domain verification using DNS records hasn't succeeded and the rule is still in initial state. + if (ruleData.status === 'created') { + 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 + domain: data.proxyRule.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' - ); } - addNotification({ - type: 'success', - message: 'Domain added successfully' - }); + if (verified) { + addNotification({ + type: 'success', + message: 'Domain added successfully' + }); + } else { + addNotification({ + type: 'info', + message: 'Verification in progress' + }); + } await goto(routeBase); await invalidate(Dependencies.DOMAINS); } catch (error) { @@ -98,7 +108,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=${data.proxyRule.domain}`); } @@ -115,7 +125,7 @@ - {page.params.domain} + {data.proxyRule.domain} @@ -126,7 +136,7 @@
- {#if isSubDomain && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME && $regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME !== 'localhost'} + {#if showCNAMETab} (selectedTab = 'cname')} @@ -134,7 +144,7 @@ CNAME {/if} - {#if isCloud} + {#if showNSTab} (selectedTab = 'nameserver')} @@ -142,7 +152,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 +160,7 @@ A {/if} - {#if !isCloud && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA && $regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA !== '::1'} + {#if showAAAATab} (selectedTab = 'aaaa')} @@ -162,9 +172,17 @@
{#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 b89c933306..d4a8587b7a 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 844a2c94be..bd77890a83 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,79 @@ 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'; let { show = $bindable(), - selectedDomain + selectedProxyRule }: { show: boolean; - selectedDomain: Models.ProxyRule; + selectedProxyRule: Models.ProxyRule; } = $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 = $state(false); + + function getDefaultTab() { + return showCNAMETab ? 'cname' : showATab ? 'a' : showAAAATab ? 'aaaa' : 'nameserver'; + } + async function retryDomain() { try { - await sdk + error = null; + const proxyRule = await sdk .forProject(page.params.region, page.params.project) - .proxy.updateRuleVerification({ ruleId: selectedDomain.$id }); + .proxy.updateRuleVerification({ ruleId: selectedProxyRule.$id }); + + verified = proxyRule.status === 'verified'; await invalidate(Dependencies.DOMAINS); + + // This means domain verification using DNS records hasn't succeeded and the rule is still in initial state. + if (proxyRule.status === 'created') { + throw new Error( + 'Domain verification failed. Please check your domain settings or try again later' + ); + } + + if (verified) { + addNotification({ + type: 'success', + message: `${selectedProxyRule.domain} has been verified` + }); + } else { + addNotification({ + type: 'info', + message: 'Verification in progress' + }); + } show = false; - addNotification({ - type: 'success', - message: `${selectedDomain.domain} has been verified` - }); trackEvent(Submit.DomainUpdateVerification); } catch (e) { - error = e.message; + error = + e.message ?? + 'Domain verification failed. Please check your domain settings or try again later'; trackError(e, Submit.DomainUpdateVerification); } } @@ -45,10 +92,55 @@ - {#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 1558e8e3f7..594988a7ff 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,7 @@ 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'; let { domains, @@ -28,7 +35,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,7 +44,7 @@ title: 'Domain', type: 'string', format: 'string', - width: { min: 200, max: 550 } + width: { min: 300, max: 550 } } ]; @@ -50,7 +58,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 +70,51 @@ + 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} + {/if} @@ -92,23 +134,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 +185,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 b6f8083bc7..cc82cb18cb 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]/sites/(components)/deploymentActionMenu.svelte b/src/routes/(console)/project-[region]-[project]/sites/(components)/deploymentActionMenu.svelte index b3d988be73..231611a161 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/(components)/deploymentActionMenu.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/(components)/deploymentActionMenu.svelte @@ -15,6 +15,8 @@ IconXCircle } from '@appwrite.io/pink-icons-svelte'; import { ActionMenu, Icon, Tooltip } from '@appwrite.io/pink-svelte'; + import { getEffectiveBuildStatus } from '$lib/helpers/buildTimeout'; + import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store'; export let selectedDeployment: Models.Deployment; export let deployment: Models.Deployment; @@ -51,6 +53,11 @@ + {@const effectiveStatus = getEffectiveBuildStatus( + deployment.status, + deployment.$createdAt, + $regionalConsoleVariables + )} {#if !inCard}
@@ -70,7 +77,7 @@
Source is empty
{/if} - {#if deployment?.status === 'ready' && deployment?.$id !== activeDeployment} + {#if effectiveStatus === 'ready' && deployment?.$id !== activeDeployment} { @@ -82,7 +89,7 @@ Activate {/if} - {#if deployment?.status === 'ready' || deployment?.status === 'failed' || deployment?.status === 'building'} + {#if effectiveStatus === 'ready' || effectiveStatus === 'failed' || effectiveStatus === 'building'} @@ -112,7 +119,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 +40,19 @@ emptyCopy?: string; } = $props(); + let effectiveStatus = $derived( + getEffectiveBuildStatus(deployment.status, deployment.$createdAt, $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 +68,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 204ce91ec1..47bc28da3f 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,21 @@ - {#if ['processing', 'building'].includes(status)} + {#if ['processing', 'building'].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 95562c0add..78ad644f49 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,40 @@ 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.status, deployment.$createdAt, $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 +76,7 @@ fileId, width: 1024, height: 576, - output: ImageFormat.Webp + output: ImageFormat.Avif }); } @@ -59,13 +84,25 @@
- + {#if primaryDomain} + + + + {:else} + + {/if} @@ -81,15 +118,15 @@ - {#if deployment.status === 'failed'} + {#if effectiveStatus === 'failed'} Status + status={effectiveStatus} + label={capitalize(effectiveStatus)} /> {:else} @@ -178,13 +215,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 17022e7d74..3c4ee175a9 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 feb15624cd..4264c02291 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 08f4f1047d..3cb1005435 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,42 +1,42 @@ 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 23e7a2d515..fe788db107 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 281b683143..8cecb4acc0 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 @@ -174,24 +174,21 @@ on:invalid={handleInvalid}> - - - Drag and drop file here or click to upload - - + + + Drag and drop file here or click to upload; - + justifyContent="center"> + + + Only .tar.gz files allowed + - Only .tar.gz files allowed - + {#if maxSize > 0} - + diff --git a/src/routes/(console)/project-[region]-[project]/sites/create-site/repositories/repository-[repository]/+page.svelte b/src/routes/(console)/project-[region]-[project]/sites/create-site/repositories/repository-[repository]/+page.svelte index 7657aa9a59..8171be094a 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/create-site/repositories/repository-[repository]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/create-site/repositories/repository-[repository]/+page.svelte @@ -19,7 +19,7 @@ BuildRuntime, Framework, ID, - VCSDeploymentType, + VCSReferenceType, VCSDetectionType } from '@appwrite.io/console'; import type { Models } from '@appwrite.io/console'; @@ -131,7 +131,7 @@ .forProject(page.params.region, page.params.project) .sites.createVcsDeployment({ siteId: site.$id, - type: VCSDeploymentType.Branch, + type: VCSReferenceType.Branch, reference: branch, activate: true }); diff --git a/src/routes/(console)/project-[region]-[project]/sites/create-site/templates/+page.ts b/src/routes/(console)/project-[region]-[project]/sites/create-site/templates/+page.ts index c4a2f694eb..5f876a9d8f 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/create-site/templates/+page.ts +++ b/src/routes/(console)/project-[region]-[project]/sites/create-site/templates/+page.ts @@ -32,6 +32,7 @@ export const load = async ({ url, params }) => { 'React', 'Vue.js', 'Svelte', + 'TanStack Start', 'Lynx', 'Angular', 'Analog', diff --git a/src/routes/(console)/project-[region]-[project]/sites/create-site/templates/template-[template]/+page.svelte b/src/routes/(console)/project-[region]-[project]/sites/create-site/templates/template-[template]/+page.svelte index 1dbbd143e3..0e57e223a6 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/create-site/templates/template-[template]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/create-site/templates/template-[template]/+page.svelte @@ -24,7 +24,14 @@ import Details from '../../details.svelte'; import Configuration from './configuration.svelte'; import Aside from '../../aside.svelte'; - import { Adapter, BuildRuntime, Framework, ID, type Models } from '@appwrite.io/console'; + import { + Adapter, + BuildRuntime, + Framework, + ID, + TemplateReferenceType, + type Models + } from '@appwrite.io/console'; import { ConnectBehaviour, NewRepository, @@ -159,7 +166,8 @@ repository: data.template.providerRepositoryId, owner: data.template.providerOwner, rootDirectory: framework.providerRootDirectory, - version: data.template.providerVersion, + type: TemplateReferenceType.Tag, + reference: data.template.providerVersion, activate: true }); diff --git a/src/routes/(console)/project-[region]-[project]/sites/create-site/templates/template-[template]/configuration.svelte b/src/routes/(console)/project-[region]-[project]/sites/create-site/templates/template-[template]/configuration.svelte index 188476e979..2d38d8d999 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/create-site/templates/template-[template]/configuration.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/create-site/templates/template-[template]/configuration.svelte @@ -41,8 +41,8 @@ variables.map((variable) => { if (variable.value === '{apiEndpoint}') { - variable.value = getApiEndpoint(); - variable.placeholder = getApiEndpoint(); + variable.value = getApiEndpoint(page.params.region); + variable.placeholder = getApiEndpoint(page.params.region); } else if (variable.value === '{projectId}') { variable.value = page.params.project; variable.placeholder = page.params.project; diff --git a/src/routes/(console)/project-[region]-[project]/sites/grid.svelte b/src/routes/(console)/project-[region]-[project]/sites/grid.svelte index 46b68f7cc7..99029389ea 100644 --- a/src/routes/(console)/project-[region]-[project]/sites/grid.svelte +++ b/src/routes/(console)/project-[region]-[project]/sites/grid.svelte @@ -38,7 +38,7 @@ fileId, width: 1024, height: 576, - output: ImageFormat.Webp + output: ImageFormat.Avif }); } 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 97ea0bf4cf..9a3c988170 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 aef59781fc..1816d005cb 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'; @@ -72,12 +74,17 @@ {@const status = deployment.status} + {@const effectiveStatus = getEffectiveBuildStatus( + status, + deployment.$createdAt, + $regionalConsoleVariables + )} {#if activeDeployment?.$id === deployment?.$id} {:else} + status={deploymentStatusConverter(effectiveStatus)} + label={capitalize(effectiveStatus)} /> {/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 0000000000..c06e806c77 --- /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/add-domain/+page.svelte b/src/routes/(console)/project-[region]-[project]/sites/site-[site]/domains/add-domain/+page.svelte index f0b6df34a3..b4288a1f90 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,9 +57,8 @@ 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); + let domain = data.domainsList.domains.find((d) => d.domain === apexDomain); if (isCloud && apexDomain && !domain && !isSiteDomain) { try { @@ -68,15 +67,7 @@ domain: apexDomain }); } catch (error) { - // apex might already be added on organization level, skip. - const alreadyAdded = error?.type === 'domain_already_exists'; - if (!alreadyAdded) { - addNotification({ - type: 'error', - message: error.message - }); - return; - } + // Apex domain creation error needs to be silent. } } 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 f54e980975..dfff015a1a 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 8422d783a0..80fba1842c 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 @@ -20,7 +20,6 @@ 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'; @@ -28,54 +27,68 @@ 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 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; + data.domainsList.domains.findIndex((rule) => rule.domain === data.proxyRule.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' - ); + + // This means domain verification using DNS records hasn't succeeded and the rule is still in initial state. + if (ruleData.status === 'created') { + 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 + domain: data.proxyRule.domain }); verified = domainData.nameservers.toLowerCase() === 'appwrite'; - throw new Error( - 'Domain verification failed. Please check your domain settings or try again later' - ); } - addNotification({ - type: 'success', - message: 'Domain added successfully' - }); + if (verified) { + addNotification({ + type: 'success', + message: 'Domain added successfully' + }); + } else { + addNotification({ + type: 'info', + message: 'Verification in progress' + }); + } await goto(routeBase); await invalidate(Dependencies.DOMAINS); await invalidate(Dependencies.SITES_DOMAINS); @@ -95,12 +108,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=${data.proxyRule.domain}`); } - + - {page.params.domain} + {data.proxyRule.domain} @@ -123,7 +136,7 @@
- {#if isSubDomain && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME && $regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME !== 'localhost'} + {#if showCNAMETab} (selectedTab = 'cname')} @@ -131,7 +144,7 @@ CNAME {/if} - {#if isCloud} + {#if showNSTab} (selectedTab = 'nameserver')} @@ -139,7 +152,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 +160,7 @@ A {/if} - {#if !isCloud && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA && $regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA !== '::1'} + {#if showAAAATab} (selectedTab = 'aaaa')} @@ -159,13 +172,17 @@
{#if selectedTab === 'nameserver'} - + {:else} + domain={data.proxyRule.domain} + ruleStatus={data.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 570e1b27a2..4657b835ad 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 949d8079c9..6e36ac2ec4 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,7 +10,6 @@ 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'; @@ -23,39 +22,59 @@ selectedProxyRule: Models.ProxyRule; } = $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); + function getDefaultTab() { + return showCNAMETab ? 'cname' : showATab ? 'a' : showAAAATab ? 'aaaa' : 'nameserver'; + } + async function retryDomain() { try { - const domain = await sdk + error = null; + const proxyRule = await sdk .forProject(page.params.region, page.params.project) .proxy.updateRuleVerification({ ruleId: selectedProxyRule.$id }); - show = false; - verified = domain.status === 'verified'; + verified = proxyRule.status === 'verified'; await invalidate(Dependencies.SITES_DOMAINS); - addNotification({ - type: 'success', - message: `${selectedProxyRule.domain} has been verified` - }); + // This means domain verification using DNS records hasn't succeeded and the rule is still in initial state. + if (proxyRule.status === 'created') { + throw new Error( + 'Domain verification failed. Please check your domain settings or try again later' + ); + } + + if (verified) { + addNotification({ + type: 'success', + message: `${selectedProxyRule.domain} has been verified` + }); + } else { + addNotification({ + type: 'info', + message: 'Verification in progress' + }); + } + show = false; trackEvent(Submit.DomainUpdateVerification); } catch (e) { error = @@ -75,7 +94,7 @@
- {#if isSubDomain && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME && $regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME !== 'localhost'} + {#if showCNAMETab} (selectedTab = 'cname')} @@ -83,7 +102,7 @@ CNAME {/if} - {#if isCloud} + {#if showNSTab} (selectedTab = 'nameserver')} @@ -91,7 +110,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 +118,7 @@ A {/if} - {#if !isCloud && !!$regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA && $regionalConsoleVariables._APP_DOMAIN_TARGET_AAAA !== '::1'} + {#if showAAAATab} (selectedTab = 'aaaa')} @@ -117,7 +136,11 @@ {verified} service="sites" variant={selectedTab} - domain={selectedProxyRule.domain} /> + 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 902fb3e3e8..f741c03ee0 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,7 +7,7 @@ export const columns = writable([ title: 'Domain', type: 'string', format: 'string', - width: { min: 200 } + width: { min: 300 } }, { 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 b5b755480d..b895285175 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,10 @@ } 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'; let { proxyRules, @@ -57,7 +57,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 +69,54 @@ + 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)} {/if} {/each} @@ -102,30 +135,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 +167,7 @@ status="danger" leadingIcon={IconTrash} on:click={(e) => { - selectedProxyRule = rule; + selectedProxyRule = proxyRule; showDelete = true; toggle(e); trackEvent(Click.DomainDeleteClick, { @@ -167,7 +200,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 c7a97e15d2..3ad6b4645b 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 9d74a095da..da4890035b 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,105 @@ - - + + {#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 1d856b016e..d71de3091a 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 e66ee97b34..59ff9cb358 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 1718ebca7d..0f4cbeef36 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 40fb7a83fb..bc4dcea093 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]/storage/+page.svelte b/src/routes/(console)/project-[region]-[project]/storage/+page.svelte index b9c29bc764..71ec0578c9 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/+page.svelte @@ -53,7 +53,7 @@ {/if} diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/+page.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/+page.svelte index 198daff577..8caf3c05a5 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/+page.svelte @@ -3,28 +3,25 @@ import { base } from '$app/paths'; import { page } from '$app/state'; import { Submit, trackError, trackEvent } from '$lib/actions/analytics'; - import { Avatar, Empty, EmptySearch, PaginationWithLimit, SearchQuery } from '$lib/components'; + import { + Avatar, + type DeleteOperationState, + Empty, + EmptySearch, + MultiSelectionTable, + PaginationWithLimit, + SearchQuery + } from '$lib/components'; import { Dependencies } from '$lib/constants'; import { Badge } from '@appwrite.io/pink-svelte'; import { Button } from '$lib/elements/forms'; import { calculateSize } from '$lib/helpers/sizeConvertion'; import { Container } from '$lib/layout'; - import type { Models } from '@appwrite.io/console'; - import { addNotification } from '$lib/stores/notifications'; + import { ImageFormat, type Models } from '@appwrite.io/console'; import { uploader } from '$lib/stores/uploader'; import { sdk } from '$lib/stores/sdk.js'; import DeleteFile from './deleteFile.svelte'; - import { bucket } from './store'; - import Confirm from '$lib/components/confirm.svelte'; - import { - Layout, - Table, - Icon, - Popover, - ActionMenu, - FloatingActionBar, - Typography - } from '@appwrite.io/pink-svelte'; + import { Layout, Table, Icon, Popover, ActionMenu } from '@appwrite.io/pink-svelte'; import { onMount } from 'svelte'; import DualTimeView from '$lib/components/dualTimeView.svelte'; import { @@ -34,26 +31,24 @@ IconTrash } from '@appwrite.io/pink-icons-svelte'; import { isSmallViewport } from '$lib/stores/viewport'; + import type { PageProps } from './$types'; - export let data; - - let showDelete = false; - let selectedFile: Models.File = null; - let selectedFiles: string[] = []; - let showBulkDelete = false; - let deleting = false; + const { data }: PageProps = $props(); - const bucketId = page.params.bucket; + let showDelete = $state(false); + let isUploading = $state(false); + let selectedFile: Models.File | null = $state(null); function getPreview(fileId: string) { return ( sdk .forProject(page.params.region, page.params.project) .storage.getFilePreview({ - bucketId, + bucketId: page.params.bucket, fileId, height: 128, - width: 128 + width: 128, + output: ImageFormat.Avif }) .toString() + '&mode=admin' ); @@ -70,42 +65,25 @@ showDelete = true; } - async function handleBulkDelete() { - showBulkDelete = false; - deleting = true; - - const promises = selectedFiles.map((fileId) => - sdk.forProject(page.params.region, page.params.project).storage.deleteFile({ - bucketId, + async function handleBulkDelete(selectedRows: string[]): Promise { + const promises = selectedRows.map((fileId) => { + return sdk.forProject(page.params.region, page.params.project).storage.deleteFile({ + bucketId: page.params.bucket, fileId - }) - ); + }); + }); try { await Promise.all(promises); - trackEvent(Submit.FileDelete, { - total: selectedFiles.length - }); - addNotification({ - type: 'success', - message: `${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''} deleted` - }); - invalidate(Dependencies.FILES); + trackEvent(Submit.FileDelete, { total: selectedRows.length }); } catch (error) { - addNotification({ - type: 'error', - message: error.message - }); trackError(error, Submit.FileDelete); + return error; } finally { - selectedFiles = []; - showBulkDelete = false; - deleting = false; + await invalidate(Dependencies.FILES); } } - let isUploading = false; - const beforeunload = (event: BeforeUnloadEvent) => { // legacy browser **may** support showing a custom message. const message = 'An upload is in progress. Are you sure you want to leave?'; @@ -146,10 +124,10 @@ {#if data.files.total} - - + {#snippet header(root)} Filename Type Size Created - - {#each data.files.files as file} - {#if file.chunksTotal / file.chunksUploaded !== 1} - - - - - {file.name} -
- + {/snippet} + + {#snippet children(root)} + {#each data.files.files as file} + {#if file.chunksTotal / file.chunksUploaded !== 1} + + + + + {file.name} +
+ +
+
+
+ {file.mimeType} + + {calculateSize(file.sizeOriginal)} + + + + + +
+ +
+
+
+ {:else} + {@const href = `${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${page.params.bucket}/file-${file.$id}`} + + +
+ + {file.name}
- -
- {file.mimeType} - - {calculateSize(file.sizeOriginal)} - - - - - -
- -
-
- - {:else} - {@const href = `${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${bucketId}/file-${file.$id}`} - - -
- - {file.name} -
-
- {file.mimeType} - - {calculateSize(file.sizeOriginal)} - - - - - - - - - - Update - - + {file.mimeType} + + {calculateSize(file.sizeOriginal)} + + + + + + + + + + Update + + { + e.stopPropagation(); + e.preventDefault(); + selectedFile = file; + showDelete = true; + }}> + Delete + + + + +
+ {/if} + {/each} + {/snippet} + + {#snippet deleteContentNotice()} + This action is irreversible and will permanently remove the selected files. + {/snippet} + + href={`${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${page.params.bucket}`}> Clear Search @@ -267,39 +258,9 @@ allowCreate on:click={() => goto( - `${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${bucketId}/create` + `${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${page.params.bucket}/create` )} /> {/if} - - {#if selectedFiles.length > 0} - - - - - {selectedFiles.length > 1 ? 'files' : 'file'} - selected - - - - - - - - {/if} - - - - Are you sure you want to delete {selectedFiles.length} - {selectedFiles.length > 1 ? 'files' : 'file'} from {$bucket?.name}? - - - This action is irreversible and will permanently remove the selected files. - - diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/file-[file]/+page.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/file-[file]/+page.svelte index 04f5219a41..a940dc8f6f 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/file-[file]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/file-[file]/+page.svelte @@ -35,7 +35,7 @@ } from '@appwrite.io/pink-icons-svelte'; import FileTokensCopyUrl from './fileTokensCopyUrl.svelte'; import ManageFileTokenModal, { cleanFormattedDate } from './manageFileToken.svelte'; - import { type Models } from '@appwrite.io/console'; + import { ImageFormat, type Models } from '@appwrite.io/console'; import { isSmallViewport } from '$lib/stores/viewport'; import { Menu } from '$lib/components/menu'; @@ -57,7 +57,8 @@ bucketId: $file.bucketId, fileId, width: 640, - height: 300 + height: 300, + output: ImageFormat.Avif }) .toString() + '&mode=admin'; const getView = (fileId: string) => diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/settings/+page.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/settings/+page.svelte index fa736724f2..1606d0195e 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/settings/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/settings/+page.svelte @@ -32,7 +32,8 @@ allowedFileExtensions: values.allowedFileExtensions, compression: values.compression, encryption: values.encryption, - antivirus: values.antivirus + antivirus: values.antivirus, + transformations: values.transformations }); await invalidate(Dependencies.BUCKET); @@ -101,7 +102,8 @@ $permissions: permissions, encryption, antivirus, - compression + compression, + transformations } = data.bucket; const compressionOptions = [ @@ -214,6 +216,18 @@ } ); } + + function updateTransformations() { + updateBucket( + data.bucket, + { + transformations + }, + { + trackEventName: Submit.BucketUpdateTransformations + } + ); + } @@ -317,7 +331,7 @@
Security settings - Enable or disable security services for the bucket including Ecryption + Enable or disable security services for the bucket including Encryption and Antivirus scanning. +
+ + 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} + import { Wizard } from '$lib/layout'; - import { Icon, Layout, Tag, Typography, Button, Card } from '@appwrite.io/pink-svelte'; + import { + Icon, + Input, + Layout, + Popover, + Tag, + Typography, + Card, + Upload + } from '@appwrite.io/pink-svelte'; import { supportData, isSupportOnline } from './wizard/support/store'; - import { onMount } from 'svelte'; + import { onMount, onDestroy } from 'svelte'; import { sdk } from '$lib/stores/sdk'; - import { Form, InputSelect, InputText, InputTextarea } from '$lib/elements/forms/index.js'; - + import { + Form, + InputSelect, + InputText, + InputTextarea, + Button + } from '$lib/elements/forms/index.js'; + import { Query } from '@appwrite.io/console'; import { Submit, trackError, trackEvent } from '$lib/actions/analytics'; import { localeTimezoneName, @@ -18,48 +33,125 @@ import { user } from '$lib/stores/user'; import { wizard } from '$lib/stores/wizard'; import { VARS } from '$lib/system'; - import { onDestroy } from 'svelte'; - import { IconCheckCircle, IconXCircle } from '@appwrite.io/pink-icons-svelte'; + import { IconCheckCircle, IconXCircle, IconInfo } from '@appwrite.io/pink-icons-svelte'; + import { removeFile } from '$lib/helpers/files'; + + let projectOptions = $state>([]); + let files = $state(null); + + // Category options with display names + const categories = [ + { value: 'general', label: 'General' }, + { value: 'billing', label: 'Billing' }, + { value: 'technical', label: 'Technical' } + ]; + + // Topic options based on category + const topicsByCategory = { + general: [ + 'Security', + 'Compliance', + 'Performance', + 'Account', + 'Project', + 'Regions', + 'Other' + ], + billing: ['Invoice', 'Plans', 'Payment methods', 'Downgrade', 'Refund', 'Usage', 'Other'], + technical: [ + 'Auth', + 'Databases', + 'Storage', + 'Functions', + 'Realtime', + 'Messaging', + 'Migrations', + 'Webhooks', + 'SDKs', + 'Console', + 'Backups', + 'Blocked project', + 'Domains', + 'Outage', + 'Platforms', + 'Sites', + 'Other' + ] + }; - let projectOptions: Array<{ value: string; label: string }>; + // Severity options + const severityOptions = [ + { value: 'critical', label: 'Critical' }, + { value: 'high', label: 'High' }, + { value: 'medium', label: 'Medium' }, + { value: 'low', label: 'Low' }, + { value: 'question', label: 'Question' } + ]; onMount(async () => { - const projectList = await sdk.forConsole.projects.list(); + // Filter projects by organization ID using server-side queries + const projectList = await sdk.forConsole.projects.list({ + queries: $organization?.$id + ? [Query.equal('teamId', $organization.$id), Query.select(['$id', 'name'])] + : [] + }); projectOptions = projectList.projects.map((project) => ({ value: project.$id, label: project.name })); }); + // Cleanup on component destroy onDestroy(() => { $supportData = { message: null, subject: null, - category: 'general', + category: 'technical', + topic: undefined, + severity: 'question', file: null }; }); + // Update topic options when category changes + const topicOptions = $derived( + ($supportData.category ? topicsByCategory[$supportData.category] || [] : []).map( + (topic) => ({ + value: topic.toLowerCase().trim().replace(/\s+/g, '-'), + label: topic + }) + ) + ); + async function handleSubmit() { + // Create category-topic tag + const categoryTopicTag = $supportData.topic + ? `${$supportData.category}-${$supportData.topic}`.toLowerCase() + : $supportData.category.toLowerCase(); + + const formData = new FormData(); + formData.append('email', $user.email); + formData.append('subject', $supportData.subject); + formData.append('firstName', ($user?.name || 'Unknown').slice(0, 40)); + formData.append('message', $supportData.message); + formData.append('tags[]', categoryTopicTag); + formData.append( + 'customFields', + JSON.stringify([ + { id: '41612', value: $supportData.category }, + { id: '48492', value: $organization?.$id ?? '' }, + { id: '48491', value: $supportData?.project ?? '' }, + { id: '56023', value: $supportData?.severity ?? '' }, + { id: '56024', value: $organization?.billingPlan ?? '' } + ]) + ); + if (files && files.length > 0) { + formData.append('attachment', files[0]); + } + const response = await fetch(`${VARS.GROWTH_ENDPOINT}/support`, { method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - email: $user.email, - subject: $supportData.subject, - firstName: ($user?.name || 'Unknown').slice(0, 40), - message: $supportData.message, - tags: ['cloud'], - customFields: [ - { id: '41612', value: $supportData.category }, - { id: '48493', value: $user?.name ?? '' }, - { id: '48492', value: $organization?.$id ?? '' }, - { id: '48491', value: $supportData?.project ?? '' }, - { id: '48490', value: $user?.$id ?? '' } - ] - }) + body: formData }); trackEvent(Submit.SupportTicket); if (response.status !== 200) { @@ -84,7 +176,9 @@ $supportData = { message: null, subject: null, - category: 'general', + category: 'technical', + topic: undefined, + severity: undefined, file: null, project: null }; @@ -92,6 +186,13 @@ $wizard.finalAction = handleSubmit; + function handleInvalid(_e: CustomEvent) { + addNotification({ + type: 'error', + message: 'Invalid file' + }); + } + const workTimings = { start: '16:00', end: '00:00', @@ -99,10 +200,43 @@ endDay: 'Friday' as WeekDay }; - $: supportTimings = `${utcHourToLocaleHour(workTimings.start)} - ${utcHourToLocaleHour(workTimings.end)} ${localeTimezoneName()}`; - $: supportWeekDays = `${utcWeekDayToLocaleWeekDay(workTimings.startDay, workTimings.start)} - ${utcWeekDayToLocaleWeekDay(workTimings.endDay, workTimings.end)}`; + const supportTimings = $derived( + `${utcHourToLocaleHour(workTimings.start)} - ${utcHourToLocaleHour(workTimings.end)} ${localeTimezoneName()}` + ); + const supportWeekDays = $derived( + `${utcWeekDayToLocaleWeekDay(workTimings.startDay, workTimings.start)} - ${utcWeekDayToLocaleWeekDay(workTimings.endDay, workTimings.end)}` + ); +{#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 06f671b6ad..56d0e76b29 100644 --- a/src/routes/(console)/wizard/support/store.ts +++ b/src/routes/(console)/wizard/support/store.ts @@ -4,6 +4,8 @@ export type SupportData = { message: string; subject: string; category: string; + topic?: string; + severity?: string; file?: File | null; project?: string; }; @@ -11,7 +13,8 @@ export type SupportData = { export const supportData = writable({ message: '', subject: '', - category: 'general', + category: 'technical', + severity: 'question', file: null }); diff --git a/src/routes/(public)/functions/deploy/+page.svelte b/src/routes/(public)/functions/deploy/+page.svelte index 4b353d17ba..0399cf3a3e 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 978894e780..d91d084ff8 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 ef6000fbb6..0d258793ac 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 991de1bf50..ca57d36e34 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 b82f6787cc..600914e154 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 80f486c7f1..e2dac3adfa 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 7a536b25eb..571542d06f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -289,6 +289,11 @@ } } + /* Fix when no vertical scrollbar is present, some environments reserve a gutter by default */ + html { + scrollbar-gutter: auto !important; + } + /* TODO: remove this block once Pink V2 is incorporated */ input[type='radio'], input[type='checkbox']:not([class='switch']), diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 5ee94b6502..63f986c8ee 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -6,9 +6,9 @@ import { redirect } from '@sveltejs/kit'; import { Dependencies } from '$lib/constants'; import type { LayoutLoad } from './$types'; import { redirectTo } from './store'; -import { base, resolve } from '$app/paths'; +import { 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) + ]) }; } @@ -52,11 +54,11 @@ export const load: LayoutLoad = async ({ depends, url, route }) => { } if (error.type === 'user_more_factors_required') { - if (url.pathname === `${base}/mfa`) + if (url.pathname === resolve('/mfa')) return { mfaRequired: true }; - redirect(303, withParams(`${base}/mfa`, url.searchParams)); + redirect(303, withParams(resolve('/mfa'), url.searchParams)); } if (!isPublicRoute) { @@ -64,7 +66,7 @@ export const load: LayoutLoad = async ({ depends, url, route }) => { checkPricingRefAndRedirect(url.searchParams, true); } - redirect(303, withParams(`${base}/login`, url.searchParams)); + redirect(303, withParams(resolve('/login'), url.searchParams)); } }; diff --git a/static/icons/dark/color/resend.svg b/static/icons/dark/color/resend.svg new file mode 100644 index 0000000000..0ded97051d --- /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 0000000000..5823a237b5 --- /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 0000000000..ad99113691 --- /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 0000000000..76011176f5 --- /dev/null +++ b/static/icons/light/color/tanstack.svg @@ -0,0 +1,3 @@ + + + diff --git a/svelte.config.js b/svelte.config.js index 18b6687901..831de20405 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -19,7 +19,9 @@ const config = { kit: { alias: { $routes: './src/routes', - $themes: './src/themes' + $themes: './src/themes', + $database: + './src/routes/(console)/project-[region]-[project]/databases/database-[database]' }, adapter: adapter({ fallback: 'index.html',