diff --git a/.env.example b/.env.example index 9ba4011c0..30973b2c1 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,6 @@ PUBLIC_CONSOLE_FEATURE_FLAGS= PUBLIC_APPWRITE_MULTI_REGION=false PUBLIC_APPWRITE_ENDPOINT=http://localhost/v1 PUBLIC_STRIPE_KEY= -PUBLIC_GROWTH_ENDPOINT= \ No newline at end of file +PUBLIC_GROWTH_ENDPOINT= +PUBLIC_CONSOLE_EMAIL_VERIFICATION=false +PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=true \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8cfa0c03f..9fe42390d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,6 +41,8 @@ jobs: "PUBLIC_CONSOLE_MODE=cloud" "PUBLIC_CONSOLE_FEATURE_FLAGS=" "PUBLIC_APPWRITE_MULTI_REGION=true" + "PUBLIC_CONSOLE_EMAIL_VERIFICATION=true" + "PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false" "PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}" "PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY }}" "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" @@ -81,6 +83,8 @@ jobs: "PUBLIC_CONSOLE_MODE=cloud" "PUBLIC_CONSOLE_FEATURE_FLAGS=" "PUBLIC_APPWRITE_MULTI_REGION=true" + "PUBLIC_CONSOLE_EMAIL_VERIFICATION=false" + "PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false" "PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}" "PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}" publish-self-hosted: @@ -118,6 +122,8 @@ jobs: build-args: | "PUBLIC_CONSOLE_MODE=self-hosted" "PUBLIC_APPWRITE_MULTI_REGION=false" + "PUBLIC_CONSOLE_EMAIL_VERIFICATION=false" + "PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=true" "PUBLIC_CONSOLE_FEATURE_FLAGS=" "PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}" @@ -156,6 +162,8 @@ jobs: build-args: | "PUBLIC_CONSOLE_MODE=cloud" "PUBLIC_APPWRITE_MULTI_REGION=false" + "PUBLIC_CONSOLE_EMAIL_VERIFICATION=true" + "PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false" "PUBLIC_CONSOLE_FEATURE_FLAGS=" "PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}" "PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}" diff --git a/Dockerfile b/Dockerfile index e172247b6..6bdc2d78a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,8 @@ ADD ./static /app/static ARG PUBLIC_CONSOLE_MODE ARG PUBLIC_CONSOLE_FEATURE_FLAGS ARG PUBLIC_APPWRITE_MULTI_REGION +ARG PUBLIC_CONSOLE_EMAIL_VERIFICATION +ARG PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS ARG PUBLIC_APPWRITE_ENDPOINT ARG PUBLIC_GROWTH_ENDPOINT ARG PUBLIC_STRIPE_KEY @@ -34,6 +36,8 @@ ENV PUBLIC_GROWTH_ENDPOINT=$PUBLIC_GROWTH_ENDPOINT ENV PUBLIC_CONSOLE_MODE=$PUBLIC_CONSOLE_MODE ENV PUBLIC_CONSOLE_FEATURE_FLAGS=$PUBLIC_CONSOLE_FEATURE_FLAGS ENV PUBLIC_APPWRITE_MULTI_REGION=$PUBLIC_APPWRITE_MULTI_REGION +ENV PUBLIC_CONSOLE_EMAIL_VERIFICATION=$PUBLIC_CONSOLE_EMAIL_VERIFICATION +ENV PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=$PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS ENV PUBLIC_STRIPE_KEY=$PUBLIC_STRIPE_KEY ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN ENV SENTRY_RELEASE=$SENTRY_RELEASE diff --git a/build.js b/build.js index affdf3ddd..df5933adc 100644 --- a/build.js +++ b/build.js @@ -28,6 +28,8 @@ async function main() { logEnv('MULTI REGION', env?.PUBLIC_APPWRITE_MULTI_REGION); logEnv('APPWRITE ENDPOINT', env?.PUBLIC_APPWRITE_ENDPOINT, 'relative'); logEnv('GROWTH ENDPOINT', env?.PUBLIC_GROWTH_ENDPOINT); + logEnv('CONSOLE EMAIL VERIFICATION', env?.PUBLIC_CONSOLE_EMAIL_VERIFICATION); + logEnv('CONSOLE MOCK AI SUGGESTIONS', env?.PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS); log(); logDelimiter(); await build(); diff --git a/package.json b/package.json index 69a51845e..496818696 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,11 @@ }, "dependencies": { "@ai-sdk/svelte": "^1.1.24", - "@appwrite.io/console": "https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@7747562", + "@appwrite.io/console": "https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@636ed39", "@appwrite.io/pink-icons": "0.25.0", - "@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@f2198f1", + "@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@8f82877", "@appwrite.io/pink-legacy": "^1.0.3", - "@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@f2198f1", + "@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8f82877", "@faker-js/faker": "^9.9.0", "@popperjs/core": "^2.11.8", "@sentry/sveltekit": "^8.38.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 826d1cce5..827befa27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,20 +12,20 @@ importers: specifier: ^1.1.24 version: 1.1.24(svelte@5.25.3)(zod@3.24.3) '@appwrite.io/console': - specifier: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@7747562 - version: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@7747562 + specifier: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@636ed39 + version: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@636ed39 '@appwrite.io/pink-icons': specifier: 0.25.0 version: 0.25.0 '@appwrite.io/pink-icons-svelte': - specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@f2198f1 - version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@f2198f1(svelte@5.25.3) + specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@8f82877 + version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@8f82877(svelte@5.25.3) '@appwrite.io/pink-legacy': specifier: ^1.0.3 version: 1.0.3 '@appwrite.io/pink-svelte': - specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@f2198f1 - version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@f2198f1(svelte@5.25.3) + specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8f82877 + version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8f82877(svelte@5.25.3) '@faker-js/faker': specifier: ^9.9.0 version: 9.9.0 @@ -260,8 +260,8 @@ packages: '@analytics/type-utils@0.6.2': resolution: {integrity: sha512-TD+xbmsBLyYy/IxFimW/YL/9L2IEnM7/EoV9Aeh56U64Ify8o27HJcKjo38XY9Tcn0uOq1AX3thkKgvtWvwFQg==} - '@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@7747562': - resolution: {tarball: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@7747562} + '@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@636ed39': + resolution: {tarball: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@636ed39} version: 1.10.0 '@appwrite.io/pink-icons-svelte@2.0.0-RC.1': @@ -269,8 +269,8 @@ packages: peerDependencies: svelte: ^4.0.0 - '@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@f2198f1': - resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@f2198f1} + '@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@8f82877': + resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@8f82877} version: 2.0.0-RC.1 peerDependencies: svelte: ^4.0.0 @@ -284,8 +284,8 @@ packages: '@appwrite.io/pink-legacy@1.0.3': resolution: {integrity: sha512-GGde5fmPhs+s6/3aFeMPc/kKADG/gTFkYQSy6oBN8pK0y0XNCLrZZgBv+EBbdhwdtqVEWXa0X85Mv9w7jcIlwQ==} - '@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@f2198f1': - resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@f2198f1} + '@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8f82877': + resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8f82877} version: 2.0.0-RC.2 peerDependencies: svelte: ^4.0.0 @@ -3703,13 +3703,13 @@ snapshots: '@analytics/type-utils@0.6.2': {} - '@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@7747562': {} + '@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@636ed39': {} '@appwrite.io/pink-icons-svelte@2.0.0-RC.1(svelte@5.25.3)': dependencies: svelte: 5.25.3 - '@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@f2198f1(svelte@5.25.3)': + '@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@8f82877(svelte@5.25.3)': dependencies: svelte: 5.25.3 @@ -3722,7 +3722,7 @@ snapshots: '@appwrite.io/pink-icons': 1.0.0 the-new-css-reset: 1.11.3 - '@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@f2198f1(svelte@5.25.3)': + '@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8f82877(svelte@5.25.3)': dependencies: '@appwrite.io/pink-icons-svelte': 2.0.0-RC.1(svelte@5.25.3) '@floating-ui/dom': 1.6.13 diff --git a/src/lib/actions/analytics.ts b/src/lib/actions/analytics.ts index 1f4722965..b511e59c8 100644 --- a/src/lib/actions/analytics.ts +++ b/src/lib/actions/analytics.ts @@ -274,9 +274,12 @@ export enum Submit { DatabaseDelete = 'submit_database_delete', DatabaseUpdateName = 'submit_database_update_name', DatabaseImportCsv = 'submit_database_import_csv', + ColumnCreate = 'submit_column_create', ColumnUpdate = 'submit_column_update', ColumnDelete = 'submit_column_delete', + ColumnSuggestions = 'submit_column_suggestions', + RowCreate = 'submit_row_create', RowDelete = 'submit_row_delete', RowUpdate = 'submit_row_update', diff --git a/src/lib/actions/timer.ts b/src/lib/actions/timer.ts index 1f6bff7e1..07d589a8b 100644 --- a/src/lib/actions/timer.ts +++ b/src/lib/actions/timer.ts @@ -21,13 +21,16 @@ export const timer: Action = (node, props) => { } function step() { - const diffInSeconds = Math.floor((new Date().getTime() - startDate.getTime()) / 1000); - const minutes = Math.floor(diffInSeconds / 60); + const elapsedSeconds = Math.max( + 0, + Math.floor((new Date().getTime() - startDate.getTime()) / 1000) + ); + const minutes = Math.floor(elapsedSeconds / 60); if (minutes > 0) { - const seconds = diffInSeconds % 60; + const seconds = elapsedSeconds % 60; node.textContent = `${minutes}m ${seconds}s`; } else { - node.textContent = calculateTime(diffInSeconds); + node.textContent = calculateTime(elapsedSeconds); } frame = window.requestAnimationFrame(step); } diff --git a/src/lib/components/alerts/emailVerificationBanner.svelte b/src/lib/components/alerts/emailVerificationBanner.svelte index 9af46d418..72e2bd016 100644 --- a/src/lib/components/alerts/emailVerificationBanner.svelte +++ b/src/lib/components/alerts/emailVerificationBanner.svelte @@ -4,16 +4,21 @@ import { Typography } from '@appwrite.io/pink-svelte'; import { user } from '$lib/stores/user'; import SendVerificationEmailModal from '../account/sendVerificationEmailModal.svelte'; - import { page } from '$app/stores'; + import { page } from '$app/state'; import { wizard, isNewWizardStatusOpen } from '$lib/stores/wizard'; - import { isCloud } from '$lib/system'; + import { isCloud, VARS } from '$lib/system'; const hasUser = $derived(!!$user); const needsEmailVerification = $derived(hasUser && !$user.emailVerification); - const notOnOnboarding = $derived(!$page.route.id.includes('/onboarding')); + const notOnOnboarding = $derived(!page.route.id.includes('/onboarding')); const notOnWizard = $derived(!$wizard.show && !$isNewWizardStatusOpen); const shouldShowEmailBanner = $derived( - isCloud && hasUser && needsEmailVerification && notOnOnboarding && notOnWizard + VARS.EMAIL_VERIFICATION && + isCloud && + hasUser && + needsEmailVerification && + notOnOnboarding && + notOnWizard ); let showSendVerification = $state(false); diff --git a/src/lib/components/billing/planComparisonBox.svelte b/src/lib/components/billing/planComparisonBox.svelte index c15e48317..4379cc6f8 100644 --- a/src/lib/components/billing/planComparisonBox.svelte +++ b/src/lib/components/billing/planComparisonBox.svelte @@ -1,7 +1,7 @@ - (selectedTab = BillingPlan.FREE)}> - {tierFree.name} - - (selectedTab = BillingPlan.PRO)}> - {tierPro.name} - - (selectedTab = BillingPlan.SCALE)}> - {tierScale.name} - + {#each visibleTiers as tier} + (selectedTab = tier)}> + {tierToPlan(tier).name} + + {/each} {plan.name} plan diff --git a/src/lib/components/csvImportBox.svelte b/src/lib/components/csvImportBox.svelte index 8ff2838ab..ec7a2c9a3 100644 --- a/src/lib/components/csvImportBox.svelte +++ b/src/lib/components/csvImportBox.svelte @@ -62,7 +62,7 @@ if (isSuccess) { await invalidate(Dependencies.ROWS); - $spreadsheetRenderKey = hash(Date.now().toString()); + spreadsheetRenderKey.set(hash(Date.now().toString())); } } @@ -90,6 +90,15 @@ } } + if (tableId && tableName === null) { + importItems.update((items) => { + const next = new Map(items); + next.delete(importData.$id); + return next; + }); + return; + } + importItems.update((items) => { const existing = items.get(importData.$id); diff --git a/src/lib/components/customId.svelte b/src/lib/components/customId.svelte index a46307c81..66a56c2d8 100644 --- a/src/lib/components/customId.svelte +++ b/src/lib/components/customId.svelte @@ -27,10 +27,12 @@ id = null; } - if (!id?.length) { + if (id !== null && !id.length) { id = null; } + }); + $effect(() => { if (show) { trackEvent(Click.ShowCustomIdClick); } diff --git a/src/lib/components/filters/quickFilters.ts b/src/lib/components/filters/quickFilters.ts index dce8e823d..1ed3194a7 100644 --- a/src/lib/components/filters/quickFilters.ts +++ b/src/lib/components/filters/quickFilters.ts @@ -47,8 +47,16 @@ export function addFilterAndApply( addSizeFilter(value, colId, columns); } else if (colId === 'statusCode') { addStatusCodeFilter(value, colId, columns); - } else if (colId === '$createdAt' || colId === '$updatedAt' || colId === 'buildDuration') { + } else if ( + colId === '$createdAt' || + colId === '$updatedAt' || + colId === 'buildDuration' || + colId === 'scheduledAt' || + colId === 'deliveredAt' + ) { addDateFilter(value, colId, columns); + } else if (colId === 'duration') { + addDurationFilter(value, colId, columns); } else { addFilter(columns, colId, operator, value, arrayValues); } @@ -75,3 +83,7 @@ export function addDateFilter(value: string, colId: string, columns: Column[]) { export function addSizeFilter(value: string, colId: string, columns: Column[]) { addFilter(columns, colId, ValidOperators.GreaterThanOrEqual, value); } + +export function addDurationFilter(value: string, colId: string, columns: Column[]) { + addFilter(columns, colId, ValidOperators.GreaterThan, value); +} diff --git a/src/lib/components/filters/setFilters.ts b/src/lib/components/filters/setFilters.ts index b7e0f18c3..e6f07083c 100644 --- a/src/lib/components/filters/setFilters.ts +++ b/src/lib/components/filters/setFilters.ts @@ -20,7 +20,12 @@ export function setFilters(localTags: TagValue[], filterCols: FilterData[], $col setSizeFilter(filter, $columns); } else if (id?.includes('statuscode')) { setStatusCodeFilter(filter, $columns); - } else if (id === '$createdat' || id === '$updatedat') { + } else if ( + id === '$createdat' || + id === '$updatedat' || + id === 'scheduledat' || + id === 'deliveredat' + ) { setDateFilter(filter, $columns); } else { setFilterData(filter); @@ -61,20 +66,12 @@ export function setTimeFilter(filter: FilterData, columns: Column[]) { const col = columns.find((c) => c.id === filter.id); const timeTag = get(tags).find((tag) => tag.tag.includes(`**${filter.title}**`)); if (timeTag) { - const now = new Date(); - - const diff = now.getTime() - new Date(timeTag.value as string).getTime(); const ranges = col.elements as { value: string; label: string }[]; - const dateRange = ranges.reduce((prev, curr) => { - if (parseInt(curr.value) < diff && curr.value > prev.value) { - return curr; - } - return prev; - }); - if (dateRange) { + const timeRange = ranges.find((range) => range.value === timeTag.value); + if (timeRange) { const newTag = { - tag: `**${filter.title}** is **${dateRange.label}**`, - value: timeTag.value + tag: `**${filter.title}** is **${timeRange.label}**`, + value: timeRange.value }; cleanOldTags(filter?.title); diff --git a/src/lib/components/id.svelte b/src/lib/components/id.svelte index c756ee8f6..314e8fac2 100644 --- a/src/lib/components/id.svelte +++ b/src/lib/components/id.svelte @@ -1,41 +1,83 @@ @@ -51,20 +52,20 @@ {disabled} {readonly} {required} - {value} + bind:value {step} {type} helper={error} {leadingIcon} - on:change={onChange} autocomplete={autocomplete ? 'on' : 'off'}> - {#if nullable} - - {/if} + + {#if nullable} + + {/if} + diff --git a/src/lib/elements/forms/inputId.svelte b/src/lib/elements/forms/inputId.svelte index 909aaf094..9946c924a 100644 --- a/src/lib/elements/forms/inputId.svelte +++ b/src/lib/elements/forms/inputId.svelte @@ -12,11 +12,7 @@ const handleInvalid = (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => { event.preventDefault(); - - if (event.currentTarget.validity.patternMismatch) { - error = true; - return; - } + error = true; // show on any error }; $: if (value) { diff --git a/src/lib/elements/forms/inputProjectId.svelte b/src/lib/elements/forms/inputProjectId.svelte index c9628485c..f9b479ce5 100644 --- a/src/lib/elements/forms/inputProjectId.svelte +++ b/src/lib/elements/forms/inputProjectId.svelte @@ -12,11 +12,7 @@ const handleInvalid = (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => { event.preventDefault(); - - if (event.currentTarget.validity.patternMismatch) { - error = true; - return; - } + error = true; // show on any error }; $: if (value) { diff --git a/src/lib/helpers/faker.ts b/src/lib/helpers/faker.ts index bfbc266d0..c652eb6c3 100644 --- a/src/lib/helpers/faker.ts +++ b/src/lib/helpers/faker.ts @@ -198,15 +198,21 @@ function generateSingleValue( case 'integer': { const intAttr = column as Models.ColumnInteger; - const min = !isWithinSafeRange(intAttr.min) ? 0 : intAttr.min; - const max = !isWithinSafeRange(intAttr.max) ? 100 : intAttr.max; + const min = isWithinSafeRange(intAttr.min) ? intAttr.min : 0; + const fallbackMax = Math.max(min + 100, 100); + const max = isWithinSafeRange(intAttr.max) + ? intAttr.max + : Math.min(fallbackMax, Number.MAX_SAFE_INTEGER); return faker.number.int({ min, max }); } case 'double': { const floatAttr = column as Models.ColumnFloat; - const min = !isWithinSafeRange(floatAttr.min) ? 0 : floatAttr.min; - const max = !isWithinSafeRange(floatAttr.max) ? 100 : floatAttr.max; + const min = isWithinSafeRange(floatAttr.min) ? floatAttr.min : 0; + const fallbackMax = Math.max(min + 100, 100); + const max = isWithinSafeRange(floatAttr.max) + ? floatAttr.max + : Math.min(fallbackMax, Number.MAX_SAFE_INTEGER); const precision = 4; return faker.number.float({ min, max, fractionDigits: precision }); diff --git a/src/lib/images/promos/spatial-columns-api-dark.png b/src/lib/images/promos/spatial-columns-api-dark.png index 232763d19..29e52e259 100644 Binary files a/src/lib/images/promos/spatial-columns-api-dark.png and b/src/lib/images/promos/spatial-columns-api-dark.png differ diff --git a/src/lib/images/promos/spatial-columns-api-light.png b/src/lib/images/promos/spatial-columns-api-light.png index f62f20d4d..345bd1650 100644 Binary files a/src/lib/images/promos/spatial-columns-api-light.png and b/src/lib/images/promos/spatial-columns-api-light.png differ diff --git a/src/lib/layout/createProject.svelte b/src/lib/layout/createProject.svelte index 92899ae59..c24e9234a 100644 --- a/src/lib/layout/createProject.svelte +++ b/src/lib/layout/createProject.svelte @@ -15,10 +15,10 @@ import { formatCurrency } from '$lib/helpers/numbers'; let { - projectName = $bindable(''), - id = $bindable(''), + projectName = $bindable(), + id = $bindable(), regions = [], - region = $bindable(''), + region = $bindable(), showTitle = true, billingPlan = undefined, projects = undefined, diff --git a/src/lib/layout/footer.svelte b/src/lib/layout/footer.svelte index 2e4f9819c..54e8ec053 100644 --- a/src/lib/layout/footer.svelte +++ b/src/lib/layout/footer.svelte @@ -181,6 +181,11 @@ &.hide { display: none; } + + & :global(i) { + // because the `IconCloud` shows above floating action bars. + position: unset; + } } :global(main:has(.sub-navigation)) footer { diff --git a/src/lib/layout/notifications.svelte b/src/lib/layout/notifications.svelte index ef072472b..b6d6ffeff 100644 --- a/src/lib/layout/notifications.svelte +++ b/src/lib/layout/notifications.svelte @@ -1,22 +1,26 @@ {#if $notifications}
{#each $notifications as notification (notification.id)} - + { return { label: button.name, - onClick: button.method + onClick: button.method, + isHtml: button.isHtml }; })} on:dismiss={() => dismissNotification(notification.id)} /> @@ -28,16 +32,16 @@ diff --git a/src/lib/stores/notifications.ts b/src/lib/stores/notifications.ts index 97f6cfb36..c250820f7 100644 --- a/src/lib/stores/notifications.ts +++ b/src/lib/stores/notifications.ts @@ -1,13 +1,14 @@ import { writable } from 'svelte/store'; +import type { ComponentType } from 'svelte'; export type Notification = { id: number; - type: 'success' | 'error' | 'info' | 'warning'; + type?: 'success' | 'error' | 'info' | 'warning'; dismissible?: boolean; timeout?: number; message: string; title?: string; - icon?: string; + icon?: ComponentType; buttons?: Buttons[]; isHtml?: boolean; }; @@ -15,32 +16,52 @@ export type Notification = { export type Buttons = { method: () => void | Promise; name: string; + isHtml?: boolean; }; let counter = 0; +const activeTimeouts = new Map(); + export const notifications = writable([]); export const dismissNotification = (id: number) => { + const timeoutId = activeTimeouts.get(id); + + if (timeoutId) { + clearTimeout(timeoutId); + activeTimeouts.delete(id); + } + notifications.update((all) => all.filter((t) => t.id !== id)); }; export const dismissAllNotifications = () => { + activeTimeouts.forEach((timeoutId) => clearTimeout(timeoutId)); + activeTimeouts.clear(); + notifications.set([]); }; -export const addNotification = (notification: Omit) => { - const defaults = { +export const addNotification = (notification: Omit): number => { + const defaults: Notification = { id: counter++, type: 'info', dismissible: true, - timeout: 6000 + timeout: 6000, + ...notification }; - const n = { ...defaults, ...notification }; - notifications.update((all) => { - return [n, ...all.slice(0, 4)]; - }); + notifications.update((all) => [defaults, ...all.slice(0, 4)]); - if (n.timeout) setTimeout(() => dismissNotification(n.id), n.timeout); + if (defaults.timeout) { + const timeoutId = setTimeout(() => { + activeTimeouts.delete(defaults.id); + dismissNotification(defaults.id); + }, defaults.timeout); + + activeTimeouts.set(defaults.id, timeoutId); + } + + return defaults.id; }; diff --git a/src/lib/stores/preferences.ts b/src/lib/stores/preferences.ts index fdcb89f7e..fc6a864e2 100644 --- a/src/lib/stores/preferences.ts +++ b/src/lib/stores/preferences.ts @@ -66,17 +66,20 @@ function safePrefsKey(widthPreferences: TeamPreferences['widths'], from: string, } } -function safePrefsKeyForOrder( - orderPreferences: TeamPreferences['order'], - from: string, - to: string -) { - if (orderPreferences.includes(from)) { - const index = orderPreferences.indexOf(from); +function safePrefsKeyForOrder(order: TeamPreferences['order'], from: string, to: string) { + if (order.includes(from)) { + const index = order.indexOf(from); if (index !== -1) { - orderPreferences[index] = to; + order[index] = to; } } + + // move to first since its primary! + const toIndex = order.indexOf(to); + if (toIndex !== -1 && toIndex !== 0) { + order.splice(toIndex, 1); + order.unshift(to); + } } // rare cases where the value was an array, probably due to PHP backend. @@ -221,6 +224,8 @@ function createPreferences() { delete teamPreferences?.displayNames?.[tableId]; delete teamPreferences?.columnOrder?.[tableId]; delete teamPreferences?.columnWidths?.[tableId]; + delete teamPreferences?.columnWidths?.[tableId + '#columns']; + delete teamPreferences?.columnWidths?.[tableId + '#indexes']; const removeTablePreferences = sdk.forConsole.teams.updatePrefs({ teamId: orgId, diff --git a/src/lib/stores/sdk.ts b/src/lib/stores/sdk.ts index 20ec5b83c..2ae897822 100644 --- a/src/lib/stores/sdk.ts +++ b/src/lib/stores/sdk.ts @@ -31,10 +31,12 @@ import { REGION_NYC, REGION_SYD, REGION_SFO, + REGION_SGP, SUBDOMAIN_FRA, SUBDOMAIN_NYC, SUBDOMAIN_SFO, - SUBDOMAIN_SYD + SUBDOMAIN_SYD, + SUBDOMAIN_SGP } from '$lib/constants'; import { building } from '$app/environment'; import { getProjectId } from '$lib/helpers/project'; @@ -63,6 +65,8 @@ const getSubdomain = (region?: string) => { return SUBDOMAIN_NYC; case REGION_SFO: return SUBDOMAIN_SFO; + case REGION_SGP: + return SUBDOMAIN_SGP; default: return ''; } @@ -99,8 +103,8 @@ const clientProject = new Client(); const clientRealtime = new Client(); if (!building) { + scopedConsoleClient.setProject('console'); clientConsole.setEndpoint(endpoint).setProject('console'); - scopedConsoleClient.setMode(endpoint).setProject('console'); clientRealtime.setEndpoint(endpoint).setProject('console'); clientProject.setEndpoint(endpoint).setMode('admin'); @@ -126,7 +130,8 @@ const sdkForProject = { proxy: new Proxy(clientProject), migrations: new Migrations(clientProject), sites: new Sites(clientProject), - tablesDB: new TablesDB(clientProject) + tablesDB: new TablesDB(clientProject), + console: new Console(clientProject) // for suggestions API }; export const realtime = { diff --git a/src/lib/system.ts b/src/lib/system.ts index 0e3408872..453260992 100644 --- a/src/lib/system.ts +++ b/src/lib/system.ts @@ -10,7 +10,9 @@ export const VARS = { CONSOLE_MODE: (env.PUBLIC_CONSOLE_MODE as Mode) ?? undefined, APPWRITE_ENDPOINT: env.PUBLIC_APPWRITE_ENDPOINT ?? undefined, GROWTH_ENDPOINT: env.PUBLIC_GROWTH_ENDPOINT ?? undefined, - PUBLIC_STRIPE_KEY: env.PUBLIC_STRIPE_KEY ?? undefined + PUBLIC_STRIPE_KEY: env.PUBLIC_STRIPE_KEY ?? undefined, + EMAIL_VERIFICATION: env.PUBLIC_CONSOLE_EMAIL_VERIFICATION === 'true', + MOCK_AI_SUGGESTIONS: (env.PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS ?? 'true') === 'true' }; export const ENV = { diff --git a/src/routes/(console)/account/payments/addressModal.svelte b/src/routes/(console)/account/payments/addressModal.svelte index 9bdcc2784..5b058dbfb 100644 --- a/src/routes/(console)/account/payments/addressModal.svelte +++ b/src/routes/(console)/account/payments/addressModal.svelte @@ -89,25 +89,34 @@ id="address" label="Street address" placeholder="Enter street address" + autocomplete required /> - + diff --git a/src/routes/(console)/onboarding/create-project/+page.svelte b/src/routes/(console)/onboarding/create-project/+page.svelte index 9c85c6326..12dcb1a76 100644 --- a/src/routes/(console)/onboarding/create-project/+page.svelte +++ b/src/routes/(console)/onboarding/create-project/+page.svelte @@ -15,10 +15,13 @@ import { user } from '$lib/stores/user'; let isLoading = false; - let id: string = ID.unique(); let startAnimation = false; - let projectName = 'Appwrite project'; - let region = Region.Fra; + + let projectId = ID.unique(); + let projectRegion = Region.Fra; + let projectName = 'Appwrite Project'; + + const projectIdForLog = projectId; export let data; @@ -39,17 +42,17 @@ try { const teamId = data.organization.$id; const project = await sdk.forConsole.projects.create({ - projectId: id ?? ID.unique(), + projectId: projectId ?? ID.unique(), name: projectName, teamId, - region: isCloud ? region : undefined + region: isCloud ? projectRegion : undefined }); markOnboardingComplete(); trackEvent(Submit.ProjectCreate, { - customId: !!id, - teamId + teamId, + customId: projectId !== projectIdForLog }); startAnimation = true; @@ -95,11 +98,11 @@ alt="Appwrite Logo" /> + bind:id={projectId} + bind:region={projectRegion} + regions={$regionsStore?.regions}> {#snippet submit()} checkPricingRefAndRedirect(page.url.searchParams)); + function dismissFreePlanAlert() { + freePlanAlertDismissed = true; + const notificationId = `freePlanAlert_${data.organization.$id}`; + hideNotification(notificationId, { coolOffPeriod: 24 }); + + trackEvent(Click.OrganizationClickUpgrade, { + from: 'button', + source: 'free_plan_info_alert_dismiss' + }); + } + + onMount(async () => { + checkPricingRefAndRedirect(page.url.searchParams); + const notificationId = `freePlanAlert_${data.organization.$id}`; + const shouldShow = shouldShowNotification(notificationId); + freePlanAlertDismissed = !shouldShow; + }); function findRegion(project: Models.Project) { return $regionsStore.regions.find((region) => region.$id === project.region); @@ -129,13 +151,27 @@ {#if $canWriteProjects} - + {#if projectCreationDisabled && reachedProjectLimit} + +
+ +
+ + You have reached your limit of {projectsLimit} projects. + +
+ {:else} + + {/if} {/if}
@@ -162,6 +198,28 @@ {/if} + {#if isCloud && data.organization.billingPlan === BillingPlan.FREE && projectsToArchive.length === 0 && !freePlanAlertDismissed} + + Your Free plan includes up to 2 projects and limited resources. Upgrade to unlock + more capacity and features. + + + + + {/if} + {#if activeProjects.length > 0} - {toLocaleDate(invoice.dueAt)} + + + {@const isDanger = status === 'overdue' || diff --git a/src/routes/(console)/organization-[organization]/createProjectCloud.svelte b/src/routes/(console)/organization-[organization]/createProjectCloud.svelte index c467abdae..2b740800e 100644 --- a/src/routes/(console)/organization-[organization]/createProjectCloud.svelte +++ b/src/routes/(console)/organization-[organization]/createProjectCloud.svelte @@ -3,7 +3,7 @@ import { onDestroy } from 'svelte'; import { goto, invalidate } from '$app/navigation'; import { Submit, trackError, trackEvent } from '$lib/actions/analytics'; - import { ID, type Models, Region as ConsoleRegion, Region } from '@appwrite.io/console'; + import { ID, type Models, Region } from '@appwrite.io/console'; import { base } from '$app/paths'; import CreateProject from '$lib/layout/createProject.svelte'; import { Modal } from '$lib/components'; @@ -16,10 +16,12 @@ export let showCreateProjectCloud: boolean; export let regions: Array = []; - let id: string = null; let error: string = null; - let name: string = 'New project'; - let region: ConsoleRegion = Region.Fra; + let projectId = ID.unique(); + let projectRegion = Region.Fra; + let projectName = 'New project'; + + let projectIdForLog = projectId; let showSubmissionLoader = false; @@ -30,14 +32,18 @@ try { project = await sdk.forConsole.projects.create({ - projectId: id ?? ID.unique(), - name, + projectId: projectId ?? ID.unique(), + name: projectName, teamId, - region + region: projectRegion }); await goto(`${base}/project-${project.region}-${project.$id}`); - trackEvent(Submit.ProjectCreate, { customId: !!id, teamId, region: region }); + trackEvent(Submit.ProjectCreate, { + teamId, + region: projectRegion, + customId: projectId !== projectIdForLog + }); } catch (e) { error = e.message; trackError(e, Submit.ProjectCreate); @@ -52,11 +58,13 @@ } onDestroy(() => { - id = null; - name = null; error = null; - region = Region.Fra; + projectId = ID.unique(); + projectName = 'New project'; + projectRegion = Region.Fra; showCreateProjectCloud = false; + + projectIdForLog = projectId; }); @@ -67,12 +75,12 @@ onSubmit={create} bind:error> + bind:projectName + bind:id={projectId} + bind:region={projectRegion} /> - {/if} -
- + + {#if $canWriteDatabases} + + {/if} + {#if data.databases.total} {#if data.view === 'grid'} 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 new file mode 100644 index 000000000..09289f76f --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/empty.svelte @@ -0,0 +1,1259 @@ + + + + +
0} + class:thinking={$tableColumnSuggestions.thinking} + class="databases-spreadsheet spreadsheet-container-outer" + style:--overlay-icon-color="#fd366e99" + style:--non-overlay-icon-color="--fgcolor-neutral-weak"> +
+ +
+ + + {}}> + + {#each spreadsheetColumns as column, index (index)} + {#if column.isAction} + + + + + + {:else} + {@const columnObj = getColumn(column.id)} + {@const columnIcon = basicColumnOptions.find( + (col) => col.type === columnObj?.type + )?.icon} + {@const columnIconColor = !columnObj?.type + ? '--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} + + + +
+ { + if ( + isColumnInteractable && + !$isTabletViewport + ) { + toggle(event); + } + }}> + {#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} + + {/if} + + +
+ {/if} +
+
+ {/snippet} + + {#snippet tooltipChildren()} + {#if columnObj} + {@const selectedOption = getColumnOption( + columnObj.type, + columnObj.format + )} + {@const ColumnComponent = selectedOption?.component} + + + + + { + const newOption = columnOptions.find( + (opt) => opt.name === e.detail + ); + if (newOption) { + updateColumn(column.id, { + type: newOption.type, + format: newOption.format || null + }); + } + }} + options={basicColumnOptions.map((col) => { + return { + label: col.name, + value: col.name, + leadingIcon: col.icon + }; + })} /> + + + {#if ColumnComponent} + + {/if} + + {/if} + {/snippet} +
+ {/if} + {/each} +
+
+
+ +
+
+ + {#if $tableColumnSuggestions.thinking} +
+ + + + + + Thinking of column suggestions + + + + + resetSuggestionsStore()} + >Cancel + + + +
+ {:else if customColumns.some((col) => !col.isPlaceholder) && showFloatingBar} +
+ + + + {#if creatingColumns} + + {/if} + + + {creatingColumns + ? 'Creating columns' + : $isSmallViewport + ? 'Review and edit suggested columns' + : 'Review and edit suggested columns before applying'} + + + + + + + { + customColumns = []; + resetSuggestionsStore(); + }} + style="opacity: {creatingColumns ? '0' : '1'}" + >Dismiss + + Apply + + + + +
+ {/if} +
+ + 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 new file mode 100644 index 000000000..1d51db168 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/icon/ai.svelte @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/icon/aiNotification.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/icon/aiNotification.svelte new file mode 100644 index 000000000..1fe702004 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/icon/aiNotification.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/index.ts b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/index.ts new file mode 100644 index 000000000..6a0f6f7d8 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/index.ts @@ -0,0 +1,3 @@ +export * from './store'; +export { default as Empty } from './empty.svelte'; +export { default as Input } from './input.svelte'; 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 new file mode 100644 index 000000000..2f33a1af6 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/indexes.svelte @@ -0,0 +1,567 @@ + + +{#if !$isSmallViewport} + { + await applySuggestedIndexes(); + }}> + + {#await loadIndexSuggestions()} + + + {#each Array(3) as _, index} + {@const firstItem = index === 0} + + {@render fieldSkeleton({ label: 'Column', showLabel: firstItem })} + {@render fieldSkeleton({ label: 'Type', showLabel: firstItem })} + {@render fieldSkeleton({ label: 'Order', showLabel: firstItem })} + + +
+ +
+
+ {/each} +
+ {@render addIndexButton()} +
+ {:then suggestedIndexes} + + + {#each suggestedIndexes as index, count} + {@render indexEditForm({ index, count, isDesktop: true })} + {/each} + + + {@render addIndexButton()} + + {/await} +
+ + + + + + + + + + +
+{/if} + +{#if $isSmallViewport} + await applySuggestedIndexes() + }} + cancel={{ + disabled: loadingSuggestions || creatingIndexes, + onClick: () => { + if (indexes.length > 0 && !creatingIndexes) { + confirmDismiss = true; + } else { + $showIndexesSuggestions = false; + } + } + }}> + {#if modalError} + + {/if} + + {#await loadIndexSuggestions()} + + {#each Array(3) as _} + {@render fieldSkeleton({ + label: undefined, + showLabel: false, + isDesktop: false + })} + {/each} + + {:then suggestedIndexes} + + {#each suggestedIndexes as index, count} + + {@render indexEditForm({ index, count, isDesktop: false })} + + {/each} + + {@render addIndexButton()} + + {/await} + +{/if} + +{#snippet fieldSkeleton({ label, showLabel, isDesktop = true })} + + {#if showLabel} + {label} + {/if} + + +{/snippet} + +{#snippet indexEditForm({ index, count, isDesktop = true })} + {@const firstItem = count === 0} + {#if isDesktop} + + syncIndexState(event, index)} + options={columnOptions} + placeholder="Select column" + required + disabled={creatingIndexes} /> + + + + + + {@render removeIndexButton({ count, isDesktop: true })} + + {:else} + + + + syncIndexState(event, index)} + options={columnOptions} + placeholder="Select column" + required /> + + + + + + + {@render removeIndexButton({ count, isDesktop: false })} + + {/if} +{/snippet} + +{#snippet addIndexButton()} + {#if indexes.length < MAX_INDEXES} + + + + {/if} +{/snippet} + +{#snippet removeIndexButton({ count, isDesktop = true })} + {#if isDesktop} +
+ +
+ {:else if indexes.length > 1} + + + + {/if} +{/snippet} + + + Are you sure you want to dismiss these suggested indexes? This action cannot be undone. + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/input.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/input.svelte new file mode 100644 index 000000000..31272972c --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/input.svelte @@ -0,0 +1,81 @@ + + + + + + + + + {title} + + + {subtitle} + + + + {#if featureActive} +
+ +
+ {/if} +
+ + {#if !featureActive} + + + + {/if} + + + {#if $tableColumnSuggestions.enabled && featureActive} +
+ +
+ {/if} +
+
+ + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/options.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/options.svelte new file mode 100644 index 000000000..9f6ca1691 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/options.svelte @@ -0,0 +1,61 @@ + + + + {onShowStateChanged?.(showing || showSheet)} + + {#if toggleOnTapClick && $isSmallViewport} + + {:else} + + {/if} + +
+ {@render tooltipChildren(toggle)} +
+
+ +{#if $isSmallViewport} + { + showSheet = false; + } + }}> + {@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 new file mode 100644 index 000000000..f1792e391 --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/store.ts @@ -0,0 +1,151 @@ +import { writable } from 'svelte/store'; +import { IndexType } from '@appwrite.io/console'; +import { columnOptions } from '../table-[table]/columns/store'; + +export type TableColumnSuggestions = { + enabled: boolean; + thinking: boolean; + context?: string | undefined; + + /* for safety when in tables page */ + table?: { + id: string; + name: string; + }; +}; + +export type SuggestedColumnSchema = { + key: string; + type: string; + required: boolean; + default?: string | number | boolean | number[] | number[][] | number[][][] | null; + size?: number; + min?: number; + max?: number; + format?: string | null; +}; + +export enum IndexOrder { + ASC = 'ASC', + DESC = 'DESC', + NONE = null +} + +export type SuggestedIndexSchema = { + key: string; + type: IndexType; + orders: IndexOrder; + columns: string[]; + lengths?: number[] | undefined; +}; + +export const tableColumnSuggestions = writable({ + enabled: false, + context: null, + thinking: false, + table: null +}); + +export const showIndexesSuggestions = writable(false); + +export const mockSuggestions: { total: number; columns: ColumnInput[] } = { + total: 7, + columns: [ + { + name: 'title', + type: 'string', + size: 255, + format: null, + required: true, + formatOptions: null + }, + { + name: 'authorName', + type: 'string', + size: 128, + format: null, + required: true, + formatOptions: null + }, + { + name: 'publishedYear', + type: 'integer', + size: null, + format: null, + required: true, + formatOptions: { + min: 1500, + max: null + } + }, + { + name: 'genre', + type: 'string', + size: 64, + format: null, + required: false, + formatOptions: null, + default: null + }, + { + name: 'isbn', + type: 'string', + size: 13, + required: false, + formatOptions: null, + default: null + }, + { + name: 'language', + type: 'string', + size: 32, + format: null, + required: false, + formatOptions: null, + default: null + }, + { + name: 'pageCount', + type: 'integer', + required: false, + min: 1, + max: 10000, + default: null + } + ] +}; +export type ColumnInput = { + name: string; + type: string; + required?: boolean; + default?: string | number | boolean | number[] | number[][] | number[][][] | null; + size?: number; + min?: number; + max?: number; + format?: string; + formatOptions?: { + min?: number; + max?: number; + }; +}; + +export function mapSuggestedColumns(columns: T[]): SuggestedColumnSchema[] { + return columns.map((col) => ({ + key: col.name, + type: col.type, + required: col.required ?? false, + default: col.default ?? null, + size: col.type === 'string' ? (col.size ?? undefined) : undefined, + min: + col.type === 'integer' || col.type === 'double' + ? (col.min ?? col.formatOptions?.min ?? undefined) + : undefined, + max: + col.type === 'integer' || col.type === 'double' + ? (col.max ?? col.formatOptions?.max ?? undefined) + : undefined, + format: col.format ?? null + })); +} + +export const basicColumnOptions = columnOptions.slice(0, -1); 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 c4b66e957..fe77a776b 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,7 +10,6 @@ } from '$lib/commandCenter'; import { tablesSearcher } from '$lib/commandCenter/searchers'; import { Dependencies } from '$lib/constants'; - import type { Models } from '@appwrite.io/console'; import CreateTable from './createTable.svelte'; import { showCreateTable } from './store'; import { TablesPanel } from '$lib/commandCenter/panels'; @@ -24,14 +23,6 @@ const project = page.params.project; const databaseId = page.params.database; - async function handleCreate(event: CustomEvent) { - $showCreateTable = false; - await invalidate(Dependencies.DATABASE); - await goto( - `${base}/project-${page.params.region}-${project}/databases/database-${databaseId}/table-${event.detail.$id}` - ); - } - $: $registerCommands([ { label: 'Create table', @@ -147,9 +138,19 @@ - Database - Appwrite + + {#key page.url.pathname} + Database - Appwrite + {/key} - + { + 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]/backups/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/+page.svelte index 9fb56506a..6c6a8de18 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 @@ -23,6 +23,7 @@ import { trackEvent } from '$lib/actions/analytics'; import { Layout, Typography } from '@appwrite.io/pink-svelte'; import { page } from '$app/state'; + import IconQuestionMarkCircle from './components/questionIcon.svelte'; let policyCreateError: string; let totalPolicies: UserBackupPolicy[] = []; @@ -40,7 +41,7 @@ if (parsedCounter === showOnCount || !counter) { addNotification({ type: 'info', - icon: 'question-mark-circle', + icon: IconQuestionMarkCircle, message: 'How was your experience with our new Backups feature? Give us your feedback and help us improve!', timeout: 15000, diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/components/questionIcon.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/components/questionIcon.svelte new file mode 100644 index 000000000..98e32e2bd --- /dev/null +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/components/questionIcon.svelte @@ -0,0 +1,6 @@ + + + diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/createTable.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/createTable.svelte index 4e42ee442..78f692344 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/createTable.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/createTable.svelte @@ -6,25 +6,43 @@ import { Button, InputText } from '$lib/elements/forms'; import { addNotification } from '$lib/stores/notifications'; import { sdk } from '$lib/stores/sdk'; - import { ID } from '@appwrite.io/console'; - import { createEventDispatcher } from 'svelte'; + import { ID, type Models } from '@appwrite.io/console'; import { subNavigation } from '$lib/stores/database'; + import { Input as SuggestionsInput, tableColumnSuggestions } from './(suggestions)/index'; let { - showCreate = $bindable(false) + showCreate = $bindable(false), + onTableCreated }: { showCreate: boolean; + onTableCreated: (table: Models.Table) => void | Promise; } = $props(); const databaseId = page.params.database; - const dispatch = createEventDispatcher(); + const isOnTablesPage = $derived(page.route?.id.endsWith('table-[table]')); let name = $state(''); let id: string = $state(null); let touchedId = $state(false); let error: string = $state(null); - const create = async () => { + let creatingTable = $state(false); + + function enableThinkingModeForSuggestions(table: Models.Table) { + if ($tableColumnSuggestions.enabled) { + // if enabled, trigger thinking mode! + tableColumnSuggestions.update((store) => ({ + ...store, + thinking: true, + table: { + id: table.$id, + name: table.name + } + })); + } + } + + async function createTable() { error = null; try { const table = await sdk @@ -35,23 +53,32 @@ name }); - showCreate = false; - subNavigation.update(); + updateAndCleanup(); + + await onTableCreated(table); - dispatch('created', table); - addNotification({ - type: 'success', - message: `${name} has been created` - }); name = id = null; - trackEvent(Submit.TableCreate, { - customId: !!id - }); + showCreate = false; + creatingTable = false; + + // don't wait for UI to mount! + enableThinkingModeForSuggestions(table); } catch (e) { error = e.message; trackError(e, Submit.TableCreate); } - }; + } + + function updateAndCleanup() { + subNavigation.update(); + + addNotification({ + type: 'success', + message: `${name} has been created` + }); + + trackEvent(Submit.TableCreate, { customId: !!id }); + } function toIdFormat(str: string): string { return str @@ -76,9 +103,18 @@ touchedId = false; } }); + + $effect(() => { + if (showCreate && isOnTablesPage && $tableColumnSuggestions.table) { + tableColumnSuggestions.update((store) => ({ + ...store, + table: null + })); + } + }); - + + + - - + + 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 47a70368a..be59aa9ab 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 @@ -8,7 +8,7 @@ import { addNotification } from '$lib/stores/notifications'; import { sdk } from '$lib/stores/sdk'; import { database } from './store'; - import { toLocaleDate } from '$lib/helpers/date'; + import DualTimeView from '$lib/components/dualTimeView.svelte'; import { type Models, Query } from '@appwrite.io/console'; import { Spinner, Table } from '@appwrite.io/pink-svelte'; @@ -126,7 +126,9 @@ {#each tableItems as table} {table.name} - {toLocaleDate(table.updatedAt)} + + + {/each} 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 864a133cc..8bbeee520 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,9 +1,11 @@ -
+
@@ -110,8 +124,19 @@ {#if footer} {@render footer?.()} {/if} - + + + - {/if} - - + + {#if $canWriteBuckets} + + {/if} + + {#if data.buckets.total} {#if data.view === 'grid'} 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 2d8524fc4..198daff57 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 @@ -33,6 +33,7 @@ IconPlus, IconTrash } from '@appwrite.io/pink-icons-svelte'; + import { isSmallViewport } from '$lib/stores/viewport'; export let data; @@ -60,7 +61,7 @@ async function fileDeleted(event: CustomEvent) { showDelete = false; - uploader.removeFile(event.detail); + await uploader.removeFile(event.detail); await invalidate(Dependencies.FILES); } @@ -150,7 +151,7 @@ allowSelection bind:selectedRows={selectedFiles} columns={[ - { id: 'filename' }, + { id: 'filename', width: $isSmallViewport ? 24 : undefined }, { id: 'type', width: { min: 140 } }, { id: 'size', width: { min: 100 } }, { id: 'created', width: { min: 120 } }, diff --git a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/create/+page.svelte b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/create/+page.svelte index 3bb100c47..824281105 100644 --- a/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/create/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/storage/bucket-[bucket]/create/+page.svelte @@ -92,11 +92,11 @@ + confirmExit + title="Create file" + bind:showExitModal + href={`${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${page.params.bucket}/`}>
@@ -115,6 +115,7 @@ >. {/if} + Drag and drop files here or click to upload + {#if files} { diff --git a/src/routes/(console)/wizard/support/store.test.ts b/src/routes/(console)/wizard/support/store.test.ts new file mode 100644 index 000000000..a8b836983 --- /dev/null +++ b/src/routes/(console)/wizard/support/store.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { isSupportOnline } from './store'; + +describe('isSupportOnline', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + // Helper function to create a mock date + const mockDate = (hour: number, day: number) => { + const mockDate = new Date(); + vi.spyOn(mockDate, 'getUTCDay').mockReturnValue(day); + vi.spyOn(mockDate, 'getUTCHours').mockReturnValue(hour); + vi.spyOn(global, 'Date').mockImplementation(() => mockDate); + return mockDate; + }; + + describe('weekday support hours (Monday-Friday)', () => { + it('should be online at 16:00 UTC (Monday)', () => { + mockDate(16, 1); // Monday, 16:00 UTC + expect(isSupportOnline()).toBe(true); + }); + + it('should be online at 20:00 UTC (Tuesday)', () => { + mockDate(20, 2); // Tuesday, 20:00 UTC + expect(isSupportOnline()).toBe(true); + }); + + it('should be online at 23:59 UTC (Wednesday)', () => { + mockDate(23, 3); // Wednesday, 23:59 UTC + expect(isSupportOnline()).toBe(true); + }); + + it('should be online at 23:00 UTC (Thursday)', () => { + mockDate(23, 4); // Thursday, 23:00 UTC + expect(isSupportOnline()).toBe(true); + }); + + it('should be online at 22:00 UTC (Friday)', () => { + mockDate(22, 5); // Friday, 22:00 UTC + expect(isSupportOnline()).toBe(true); + }); + }); + + describe('outside support hours on weekdays', () => { + it('should be offline at 15:59 UTC (Monday)', () => { + mockDate(15, 1); // Monday, 15:59 UTC + expect(isSupportOnline()).toBe(false); + }); + + it('should be offline at 00:00 UTC (Tuesday)', () => { + mockDate(0, 2); // Tuesday, 00:00 UTC + expect(isSupportOnline()).toBe(false); + }); + + it('should be offline at 10:00 UTC (Wednesday)', () => { + mockDate(10, 3); // Wednesday, 10:00 UTC + expect(isSupportOnline()).toBe(false); + }); + + it('should be offline at 14:00 UTC (Friday)', () => { + mockDate(14, 5); // Friday, 14:00 UTC + expect(isSupportOnline()).toBe(false); + }); + }); + + describe('weekends', () => { + it('should be offline on Saturday regardless of hour', () => { + mockDate(18, 6); // Saturday, 18:00 UTC (in support hours for weekdays) + expect(isSupportOnline()).toBe(false); + }); + + it('should be offline on Sunday regardless of hour', () => { + mockDate(0, 0); // Sunday, 00:00 UTC + expect(isSupportOnline()).toBe(false); + }); + + it('should be offline on Saturday at midnight', () => { + mockDate(0, 6); // Saturday, 00:00 UTC + expect(isSupportOnline()).toBe(false); + }); + + it('should be offline on Sunday at 16:00', () => { + mockDate(16, 0); // Sunday, 16:00 UTC + expect(isSupportOnline()).toBe(false); + }); + }); + + describe('edge cases', () => { + it('should handle boundary at 15:59 (just before support hours)', () => { + mockDate(15, 1); // Monday, 15:59 UTC + expect(isSupportOnline()).toBe(false); + }); + + it('should handle boundary at 16:00 (start of support hours)', () => { + mockDate(16, 1); // Monday, 16:00 UTC + expect(isSupportOnline()).toBe(true); + }); + + it('should handle late night hours on Friday', () => { + mockDate(23, 5); // Friday, 23:00 UTC + expect(isSupportOnline()).toBe(true); + }); + }); +}); diff --git a/src/routes/(console)/wizard/support/store.ts b/src/routes/(console)/wizard/support/store.ts index 535579f79..06f671b6a 100644 --- a/src/routes/(console)/wizard/support/store.ts +++ b/src/routes/(console)/wizard/support/store.ts @@ -20,15 +20,18 @@ export function isSupportOnline() { const day = currentDate.getUTCDay(); const hour = currentDate.getUTCHours(); - if (day === 1 || day === 6) { + // Support is offline on weekends (Sunday = 0, Saturday = 6) + if (day === 0 || day === 6) { return false; } - if (hour < 14) { - return false; + // Support hours are 16:00 UTC to 23:59 UTC on weekdays + // This roughly covers 09:00 PT to 17:00 PT (depending on PST/PDT) + if (hour >= 16) { + return true; } - return true; + return false; } export const showSupportModal = writable(false); diff --git a/src/routes/(public)/(guest)/register/+page.svelte b/src/routes/(public)/(guest)/register/+page.svelte index a1ca85c02..aafbbd2b6 100644 --- a/src/routes/(public)/(guest)/register/+page.svelte +++ b/src/routes/(public)/(guest)/register/+page.svelte @@ -130,6 +130,7 @@ placeholder="Your name" autofocus required + autocomplete bind:value={name} />