Merge branch 'main' into fix-SER-289-Improve-BackUp-ui-changes

This commit is contained in:
Harsh Mahajan
2025-09-05 15:05:10 +05:30
committed by GitHub
673 changed files with 17687 additions and 9835 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
PUBLIC_CONSOLE_MODE=self-hosted
PUBLIC_CONSOLE_FEATURE_FLAGS=sites,index-lengths
PUBLIC_CONSOLE_FEATURE_FLAGS=
PUBLIC_APPWRITE_MULTI_REGION=false
PUBLIC_APPWRITE_ENDPOINT=http://localhost/v1
PUBLIC_STRIPE_KEY=
+4 -4
View File
@@ -39,7 +39,7 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
build-args: |
"PUBLIC_CONSOLE_MODE=cloud"
"PUBLIC_CONSOLE_FEATURE_FLAGS=sites"
"PUBLIC_CONSOLE_FEATURE_FLAGS="
"PUBLIC_APPWRITE_MULTI_REGION=true"
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY }}"
@@ -79,7 +79,7 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
build-args: |
"PUBLIC_CONSOLE_MODE=cloud"
"PUBLIC_CONSOLE_FEATURE_FLAGS=sites,index-lengths"
"PUBLIC_CONSOLE_FEATURE_FLAGS="
"PUBLIC_APPWRITE_MULTI_REGION=true"
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}"
@@ -118,7 +118,7 @@ jobs:
build-args: |
"PUBLIC_CONSOLE_MODE=self-hosted"
"PUBLIC_APPWRITE_MULTI_REGION=false"
"PUBLIC_CONSOLE_FEATURE_FLAGS=sites,index-lengths"
"PUBLIC_CONSOLE_FEATURE_FLAGS="
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
publish-cloud-no-regions:
@@ -156,6 +156,6 @@ jobs:
build-args: |
"PUBLIC_CONSOLE_MODE=cloud"
"PUBLIC_APPWRITE_MULTI_REGION=false"
"PUBLIC_CONSOLE_FEATURE_FLAGS=sites,index-lengths"
"PUBLIC_CONSOLE_FEATURE_FLAGS="
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}"
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
+6 -6
View File
@@ -6,12 +6,6 @@ server {
root /usr/share/nginx/html;
# Security headers for all locations
add_header X-UA-Compatible "IE=Edge";
add_header X-Frame-Options SAMEORIGIN;
add_header X-XSS-Protection "1; mode=block;";
add_header X-Content-Type-Options nosniff;
# Only cache files in /console/_app/immutable/ for 1 year
location /console/_app/immutable/ {
try_files $uri =404;
@@ -35,6 +29,12 @@ server {
expires 0;
add_header Cache-Control "no-cache, no-store";
add_header Pragma "no-cache";
# Security headers
add_header X-UA-Compatible "IE=Edge";
add_header X-Frame-Options SAMEORIGIN;
add_header X-XSS-Protection "1; mode=block;";
add_header X-Content-Type-Options nosniff;
}
location / {
-1
View File
@@ -10,7 +10,6 @@ test('upgrade - free tier', async ({ page }) => {
await page.getByRole('link', { name: 'Upgrade', exact: true }).click();
await page.waitForURL(/\/organization-[^/]+\/change-plan/);
await page.locator('input[value="tier-1"]').click();
await page.getByRole('button', { name: 'add' }).first().click();
await enterCreditCard(page);
+9 -7
View File
@@ -7,12 +7,15 @@ type Metadata = {
};
export async function enterCreditCard(page: Page) {
const dialog = page.locator('.modal').filter({
hasText: 'add payment method'
});
await dialog.waitFor({
state: 'visible'
});
// click the `add` button inside correct view layer
await page
.locator('#no-payments-card-stack')
.getByRole('button', { name: 'add' })
.first()
.click();
const dialog = page.locator('.modal').filter({ hasText: 'add payment method' });
await dialog.waitFor({ state: 'visible' });
await page.getByPlaceholder('cardholder').fill('Test User');
const stripe = page.locator('[title="Secure payment input frame"]').nth(0).contentFrame();
await stripe.locator('id=Field-numberInput').fill('4242424242424242');
@@ -35,7 +38,6 @@ export async function createProProject(page: Page): Promise<Metadata> {
await page.getByRole('radio', { name: /^Pro\b/ }).check();
// `create organization` because there's already free created on start!
await page.getByRole('button', { name: 'create organization' }).click();
await page.getByRole('button', { name: 'add' }).first().click();
await enterCreditCard(page);
// skip members
await page.getByRole('button', { name: 'create organization' }).click();
+8 -7
View File
@@ -22,11 +22,12 @@
},
"dependencies": {
"@ai-sdk/svelte": "^1.1.24",
"@appwrite.io/console": "^1.10.0",
"@appwrite.io/console": "https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@6031134",
"@appwrite.io/pink-icons": "0.25.0",
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@d93a5e9",
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@2cf27e0",
"@appwrite.io/pink-legacy": "^1.0.3",
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@d93a5e9",
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@40fb564",
"@faker-js/faker": "^9.9.0",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^8.38.0",
"@stripe/stripe-js": "^3.5.0",
@@ -53,7 +54,7 @@
"@melt-ui/svelte": "^0.86.5",
"@playwright/test": "^1.51.1",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.20.2",
"@sveltejs/kit": "^2.36.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
@@ -83,8 +84,8 @@
"tslib": "^2.8.1",
"typescript": "^5.8.2",
"typescript-eslint": "^8.30.1",
"vite": "^6.2.3",
"vitest": "^3.0.0"
"vite": "^7.0.6",
"vitest": "^3.2.4"
},
"pnpm": {
"onlyBuiltDependencies": [
@@ -94,5 +95,5 @@
"svelte-preprocess"
]
},
"packageManager": "pnpm@10.13.1"
"packageManager": "pnpm@10.15.1"
}
+391 -283
View File
File diff suppressed because it is too large Load Diff
+17 -16
View File
@@ -148,9 +148,9 @@ export enum Click {
ConnectRepositoryClick = 'click_connect_repository',
CreditsRedeemClick = 'click_credits_redeem',
CloudSignupClick = 'click_cloud_signup',
DatabaseAttributeDelete = 'click_attribute_delete',
DatabaseColumnDelete = 'click_column_delete',
DatabaseIndexDelete = 'click_index_delete',
DatabaseCollectionDelete = 'click_collection_delete',
DatabaseTableDelete = 'click_table_delete',
DatabaseDatabaseDelete = 'click_database_delete',
DatabaseImportCsv = 'click_database_import_csv',
DomainCreateClick = 'click_domain_create',
@@ -265,6 +265,7 @@ export enum Submit {
AuthSessionAlertsUpdate = 'submit_auth_session_alerts_update',
AuthMembershipPrivacyUpdate = 'submit_auth_membership_privacy_update',
AuthMockNumbersUpdate = 'submit_auth_mock_numbers_update',
AuthInvalidateSesssion = 'submit_auth_invalidate_session',
SessionsLengthUpdate = 'submit_sessions_length_update',
SessionsLimitUpdate = 'submit_sessions_limit_update',
SessionDelete = 'submit_session_delete',
@@ -273,22 +274,22 @@ export enum Submit {
DatabaseDelete = 'submit_database_delete',
DatabaseUpdateName = 'submit_database_update_name',
DatabaseImportCsv = 'submit_database_import_csv',
AttributeCreate = 'submit_attribute_create',
AttributeUpdate = 'submit_attribute_update',
AttributeDelete = 'submit_attribute_delete',
DocumentCreate = 'submit_document_create',
DocumentDelete = 'submit_document_delete',
DocumentUpdate = 'submit_document_update',
DocumentUpdatePermissions = 'submit_document_update_permissions',
ColumnCreate = 'submit_column_create',
ColumnUpdate = 'submit_column_update',
ColumnDelete = 'submit_column_delete',
RowCreate = 'submit_row_create',
RowDelete = 'submit_row_delete',
RowUpdate = 'submit_row_update',
RowUpdatePermissions = 'submit_row_update_permissions',
IndexCreate = 'submit_index_create',
IndexDelete = 'submit_index_delete',
CollectionCreate = 'submit_collection_create',
CollectionDelete = 'submit_collection_delete',
CollectionUpdateName = 'submit_collection_update_name',
CollectionUpdatePermissions = 'submit_collection_update_permissions',
CollectionUpdateSecurity = 'submit_collection_update_security',
CollectionUpdateEnabled = 'submit_collection_update_enabled',
CollectionUpdateDisplayNames = 'submit_collection_update_display_names',
TableCreate = 'submit_row_create',
TableDelete = 'submit_row_delete',
TableUpdateName = 'submit_row_update_name',
TableUpdatePermissions = 'submit_row_update_permissions',
TableUpdateSecurity = 'submit_row_update_security',
TableUpdateEnabled = 'submit_row_update_enabled',
TableUpdateDisplayNames = 'submit_row_update_display_names',
FunctionCreate = 'submit_function_create',
FunctionDelete = 'submit_function_delete',
FunctionUpdateName = 'submit_function_update_name',
+3 -3
View File
@@ -28,10 +28,10 @@ const groups = [
'integrations',
'migrations',
'users',
'collections',
'attributes',
'tables',
'columns',
'indexes',
'documents',
'rows',
'teams',
'security',
'buckets',
+1 -1
View File
@@ -27,7 +27,7 @@
const examples = [
'How to add platform in the console?',
'How can I manage users, permissions, and access control in Appwrite?',
'How can I set up database collections and documents in Appwrite?',
'How can I set up database tables and rows in Appwrite?',
'How do I configure and manage server-side functions in Appwrite?',
'How to add custom domain in the console?'
];
@@ -1,16 +1,16 @@
<script lang="ts">
import { initCreateAttribute } from '$routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+layout.svelte';
import { attributeOptions } from '$routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/attributes/store';
import { initCreateColumn } from '$routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+layout.svelte';
import { columnOptions } from '$routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/store';
import Template from './template.svelte';
let search = '';
let options = attributeOptions.map((option) => {
let options = columnOptions.map((option) => {
return {
label: option.name,
icon: option.icon,
callback() {
initCreateAttribute(option.name);
initCreateColumn(option.name);
}
};
});
+8 -8
View File
@@ -37,16 +37,16 @@ export const DatabasesPanel: SubPanel = {
component: Databases
};
import Collections from './collections.svelte';
export const CollectionsPanel: SubPanel = {
name: 'Collections',
component: Collections
import Tables from './tables.svelte';
export const TablesPanel: SubPanel = {
name: 'Tables',
component: Tables
};
import CreateAttribute from './createAttribute.svelte';
export const CreateAttributePanel: SubPanel = {
name: 'Create Attribute',
component: CreateAttribute
import CreateColumn from './createColumn.svelte';
export const CreateColumnPanel: SubPanel = {
name: 'Create Column',
component: CreateColumn
};
import Users from './users.svelte';
@@ -1,9 +1,9 @@
<script lang="ts">
import { initSearcher } from '../commands';
import { collectionsSearcher } from '../searchers';
import { tablesSearcher } from '../searchers';
import Template from './template.svelte';
const { search, results } = initSearcher(collectionsSearcher);
const { search, results } = initSearcher(tablesSearcher);
</script>
<Template options={$results} bind:search={$search} />
+3 -1
View File
@@ -32,7 +32,9 @@ export const bucketSearcher = (async (query: string) => {
const region = page.params.region;
const { buckets } = await sdk
.forProject(page.params.region, page.params.project)
.storage.listBuckets([Query.orderDesc('$createdAt')]);
.storage.listBuckets({
queries: [Query.orderDesc('$createdAt')]
});
const filtered = buckets.filter((bucket) => bucket.name.includes(query));
@@ -1,29 +0,0 @@
import { goto } from '$app/navigation';
import { database } from '$routes/(console)/project-[region]-[project]/databases/database-[database]/store';
import { get } from 'svelte/store';
import type { Searcher } from '../commands';
import { sdk } from '$lib/stores/sdk';
import { base } from '$app/paths';
import { page } from '$app/state';
export const collectionsSearcher = (async (query: string) => {
const databaseId = get(database).$id;
const { collections } = await sdk
.forProject(page.params.region, page.params.project)
.databases.listCollections(databaseId);
return collections
.filter((col) => col.name.toLowerCase().includes(query.toLowerCase()))
.map(
(col) =>
({
group: 'collections',
label: col.name,
callback: () => {
goto(
`${base}/project-${page.params.region}-${page.params.project}/databases/database-${databaseId}/collection-${col.$id}`
);
}
}) as const
);
}) satisfies Searcher;
+1 -1
View File
@@ -8,7 +8,7 @@ import { page } from '$app/state';
export const dbSearcher = (async (query: string) => {
const { databases } = await sdk
.forProject(page.params.region, page.params.project)
.databases.list();
.tablesDB.list();
return databases
.filter((db) => db.name.toLowerCase().includes(query.toLowerCase()))
+5 -1
View File
@@ -15,7 +15,11 @@ export const fileSearcher = (async (query: string) => {
const { files } = await sdk
.forProject(page.params.region, page.params.project)
.storage.listFiles($bucket.$id, [Query.orderDesc('')], query || undefined);
.storage.listFiles({
bucketId: $bucket.$id,
queries: [Query.orderDesc('')],
search: query || undefined
});
return files.map((file) => ({
label: file.name,
+1 -1
View File
@@ -1,7 +1,7 @@
export * from './databases';
export * from './users';
export * from './organizations';
export * from './collections';
export * from './tables';
export * from './projects';
export * from './teams';
export * from './functions';
+1 -1
View File
@@ -35,7 +35,7 @@ const getIcon = (message: Models.Message) => {
export const messagesSearcher = (async (query: string) => {
const { messages } = await sdk
.forProject(page.params.region, page.params.project)
.messaging.listMessages([], query || undefined);
.messaging.listMessages({ search: query || undefined });
return messages
.filter((message) => getLabel(message).toLowerCase().includes(query.toLowerCase()))
+3 -4
View File
@@ -7,10 +7,9 @@ import type { Searcher } from '../commands';
import { base } from '$app/paths';
export const projectsSearcher = (async (query: string) => {
const { projects } = await sdk.forConsole.projects.list([
Query.equal('teamId', get(organization).$id),
Query.orderDesc('')
]);
const { projects } = await sdk.forConsole.projects.list({
queries: [Query.equal('teamId', get(organization).$id), Query.orderDesc('')]
});
return projects
.filter((project) => project.name.toLowerCase().includes(query.toLowerCase()))
+3 -1
View File
@@ -13,7 +13,9 @@ const getIcon = (provider: string) => {
export const providersSearcher = (async (query: string) => {
const { providers } = await sdk
.forProject(page.params.region, page.params.project)
.messaging.listProviders([], query || undefined);
.messaging.listProviders({
search: query || undefined
});
return providers
.filter((provider) => provider.name.toLowerCase().includes(query.toLowerCase()))
+27
View File
@@ -0,0 +1,27 @@
import { goto } from '$app/navigation';
import type { Searcher } from '../commands';
import { sdk } from '$lib/stores/sdk';
import { base } from '$app/paths';
import { page } from '$app/state';
export const tablesSearcher = (async (query: string) => {
const databaseId = page.params.database;
const { tables } = await sdk
.forProject(page.params.region, page.params.project)
.tablesDB.listTables({ databaseId });
return tables
.filter((table) => table.name.toLowerCase().includes(query.toLowerCase()))
.map(
(table) =>
({
group: 'tables',
label: table.name,
callback: () => {
goto(
`${base}/project-${page.params.region}-${page.params.project}/databases/database-${databaseId}/table-${table.$id}`
);
}
}) as const
);
}) satisfies Searcher;
+3 -3
View File
@@ -17,9 +17,9 @@ const getTeamCommand = (team: Models.Team<Models.Preferences>, region: string, p
}) satisfies Command;
export const teamSearcher = (async (query: string) => {
const { teams } = await sdk
.forProject(page.params.region, page.params.project)
.teams.list([], query);
const { teams } = await sdk.forProject(page.params.region, page.params.project).teams.list({
search: query
});
if (teams.length === 1) {
return [
+3 -1
View File
@@ -8,7 +8,9 @@ import { page } from '$app/state';
export const topicsSearcher = (async (query: string) => {
const { topics } = await sdk
.forProject(page.params.region, page.params.project)
.messaging.listTopics([], query || undefined);
.messaging.listTopics({
search: query || undefined
});
return topics
.filter((topic) => topic.name.toLowerCase().includes(query.toLowerCase()))
+3 -3
View File
@@ -18,9 +18,9 @@ const getUserCommand = (user: Models.User<Models.Preferences>, region: string, p
}) satisfies Command;
export const userSearcher = (async (query: string) => {
const { users } = await sdk
.forProject(page.params.region, page.params.project)
.users.list([], query || undefined);
const { users } = await sdk.forProject(page.params.region, page.params.project).users.list({
search: query || undefined
});
if (users.length === 1) {
return [
@@ -0,0 +1,63 @@
<script lang="ts">
import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { Button } from '$lib/elements/forms';
import { HeaderAlert } from '$lib/layout';
import { Typography } from '@appwrite.io/pink-svelte';
import { hideNotification, shouldShowNotification } from '$lib/helpers/notifications';
import { user } from '$lib/stores/user';
import { wizard } from '$lib/stores/wizard';
import { page } from '$app/state';
const { emailBannerClosed, onEmailBannerClose } = $props<{
emailBannerClosed: boolean;
onEmailBannerClose: (closed: boolean) => void;
}>();
const isOnOnboarding = $derived(page.url?.pathname?.includes('/console/onboarding'));
const hasUser = $derived(!!$user);
const needsEmailVerification = $derived(hasUser && !$user.emailVerification);
const shouldShowNotificationBanner = $derived.by(() =>
shouldShowNotification('email-verification-banner')
);
const wizardNotActive = $derived(!$wizard.show && !$wizard.cover);
const bannerNotClosed = $derived(!emailBannerClosed);
const notOnOnboarding = $derived(!isOnOnboarding);
const shouldShowEmailBanner = $derived(
hasUser &&
needsEmailVerification &&
shouldShowNotificationBanner &&
wizardNotActive &&
bannerNotClosed &&
notOnOnboarding
);
function navigateToAccount() {
goto(`${base}/account`);
}
function handleDismiss() {
onEmailBannerClose(true);
hideNotification('email-verification-banner', { coolOffPeriod: 24 * 365 * 100 });
}
</script>
{#if shouldShowEmailBanner}
<HeaderAlert
type="warning"
title="Your email address needs to be verified"
dismissible
on:dismiss={handleDismiss}>
<svelte:fragment>
To avoid losing access to your projects, make sure <Typography.Text
variant="m-500"
style="display:inline">{$user.email}</Typography.Text> is valid and up to date. Email
verification will be required soon.
</svelte:fragment>
<svelte:fragment slot="buttons">
<Button secondary size="s" on:click={navigateToAccount}>Update email address</Button>
</svelte:fragment>
</HeaderAlert>
{/if}
+384
View File
@@ -0,0 +1,384 @@
<script lang="ts">
import { Button, InputText } from '$lib/elements/forms';
import { DropList, GridItem1, CardContainer, Modal } from '$lib/components';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import {
Badge,
Icon,
Typography,
Tag,
Accordion,
ActionMenu,
Popover,
Layout,
Divider
} from '@appwrite.io/pink-svelte';
import {
IconAndroid,
IconApple,
IconCode,
IconFlutter,
IconReact,
IconUnity,
IconInfo,
IconDotsHorizontal,
IconInboxIn,
IconSwitchHorizontal,
IconTrash
} from '@appwrite.io/pink-icons-svelte';
import { getPlatformInfo } from '$lib/helpers/platform';
import { Status, type Models } from '@appwrite.io/console';
import type { ComponentType } from 'svelte';
import { BillingPlan } from '$lib/constants';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { isSmallViewport } from '$lib/stores/viewport';
import { isCloud } from '$lib/system';
import { regions as regionsStore } from '$lib/stores/organization';
import type { Organization } from '$lib/stores/organization';
import type { Plan } from '$lib/sdk/billing';
// props
interface Props {
projectsToArchive: Models.Project[];
organization: Organization;
currentPlan: Plan;
}
let { projectsToArchive, organization, currentPlan }: Props = $props();
// Track Read-only info droplist per archived project
let readOnlyInfoOpen = $state<Record<string, boolean>>({});
let showUnarchiveModal = $state(false);
let projectToUnarchive = $state<Models.Project | null>(null);
let showDeleteModal = $state(false);
let projectToDelete = $state<Models.Project | null>(null);
let deleteProjectName = $state<string | null>(null);
let deleteError = $state<string | null>(null);
function resetDeleteState() {
showDeleteModal = false;
projectToDelete = null;
deleteProjectName = null;
deleteError = null;
}
function filterPlatforms(platforms: { name: string; icon: string }[]) {
return platforms.filter(
(value, index, self) => index === self.findIndex((t) => t.name === value.name)
);
}
function getIconForPlatform(platform: string): ComponentType {
switch (platform) {
case 'code':
return IconCode;
case 'flutter':
return IconFlutter;
case 'apple':
return IconApple;
case 'android':
return IconAndroid;
case 'react-native':
return IconReact;
case 'unity':
return IconUnity;
default:
return IconCode;
}
}
// Check if unarchive should be disabled
function isUnarchiveDisabled(): boolean {
if (!organization || !currentPlan) return true;
if (organization.billingPlan === BillingPlan.FREE) {
const currentProjectCount = organization.projects?.length || 0;
const projectLimit = currentPlan.projects || 0;
return currentProjectCount >= projectLimit;
}
return false;
}
function handleMigrateProject(project: Models.Project) {
goto(`${base}/project-${project.region}-${project.$id}/settings/migrations`);
}
// Handle unarchive project action
async function handleUnarchiveProject(project: Models.Project) {
projectToUnarchive = project;
showUnarchiveModal = true;
}
function handleDeleteProject(project: Models.Project) {
projectToDelete = project;
deleteProjectName = null;
showDeleteModal = true;
}
// Confirm unarchive action
async function confirmUnarchive() {
if (!projectToUnarchive) return;
try {
if (!organization) {
addNotification({
type: 'error',
message: 'Organization not found'
});
return;
}
await sdk.forConsole.projects.updateStatus(projectToUnarchive.$id, Status.Active);
await invalidate(Dependencies.ORGANIZATION);
addNotification({
type: 'success',
message: `${projectToUnarchive.name} has been unarchived`
});
showUnarchiveModal = false;
projectToUnarchive = null;
} catch (error) {
const msg =
error && typeof error === 'object' && 'message' in error
? String((error as { message: string }).message)
: 'Failed to unarchive project';
addNotification({ type: 'error', message: msg });
}
}
function cancelUnarchive() {
showUnarchiveModal = false;
projectToUnarchive = null;
}
async function confirmDelete() {
if (!projectToDelete) return;
try {
await sdk.forConsoleIn(projectToDelete.region).projects.delete({
projectId: projectToDelete.$id
});
await invalidate(Dependencies.ORGANIZATION);
trackEvent(Submit.ProjectDelete);
addNotification({
type: 'success',
message: `${projectToDelete.name} has been deleted`
});
resetDeleteState();
} catch (error) {
deleteError = error.message;
trackError(error, Submit.ProjectDelete);
}
}
function findRegion(project: Models.Project) {
return $regionsStore.regions.find((region) => region.$id === project.region);
}
import { formatName as formatNameHelper } from '$lib/helpers/string';
function formatName(name: string, limit: number = 19) {
return formatNameHelper(name, limit, $isSmallViewport);
}
</script>
{#if projectsToArchive.length > 0}
<div class="archive-projects-margin-top">
<Accordion title="Archived projects" badge={`${projectsToArchive.length}`}>
<Typography.Text tag="p" size="s">
These projects have been archived and are read-only. You can view and migrate their
data.
</Typography.Text>
<div class="archive-projects-margin">
<CardContainer disableEmpty={true} total={projectsToArchive.length}>
{#each projectsToArchive as project}
{@const platforms = filterPlatforms(
project.platforms.map((platform) => getPlatformInfo(platform.type))
)}
{@const formatted = formatName(project.name)}
<GridItem1>
<svelte:fragment slot="eyebrow">
{project?.platforms?.length ? project?.platforms?.length : 'No'} apps
</svelte:fragment>
<svelte:fragment slot="title">{formatted}</svelte:fragment>
<svelte:fragment slot="status">
<div class="status-container">
<DropList
bind:show={readOnlyInfoOpen[project.$id]}
placement="bottom-start"
noArrow>
<Tag
size="s"
style="white-space: nowrap;"
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
readOnlyInfoOpen = {
...readOnlyInfoOpen,
[project.$id]: !readOnlyInfoOpen[project.$id]
};
}}>
<Icon icon={IconInfo} size="s" />
<span>Read only</span>
</Tag>
<svelte:fragment slot="list">
<li
class="drop-list-item u-width-250"
style="padding: var(--space-5, 12px) var(--space-6, 16px)">
<span class="u-block u-mb-8">
Archived projects are read-only. You can view
and migrate their data, but they no longer
accept edits or requests.
</span>
</li>
</svelte:fragment>
</DropList>
<Popover let:toggle padding="none" placement="bottom-end">
<Button
text
icon
size="s"
ariaLabel="more options"
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
toggle(e);
}}>
<Icon icon={IconDotsHorizontal} size="s" />
</Button>
<ActionMenu.Root slot="tooltip">
<ActionMenu.Item.Button
leadingIcon={IconInboxIn}
disabled={isUnarchiveDisabled()}
on:click={() => handleUnarchiveProject(project)}
>Unarchive project</ActionMenu.Item.Button>
<ActionMenu.Item.Button
leadingIcon={IconSwitchHorizontal}
on:click={() => handleMigrateProject(project)}
>Migrate project</ActionMenu.Item.Button>
<div class="action-menu-divider">
<Divider />
</div>
<ActionMenu.Item.Button
status="danger"
leadingIcon={IconTrash}
on:click={() => handleDeleteProject(project)}
>Delete project</ActionMenu.Item.Button>
</ActionMenu.Root>
</Popover>
</div>
</svelte:fragment>
{#each platforms.slice(0, 2) as platform}
{@const icon = getIconForPlatform(platform.icon)}
<Badge
variant="secondary"
content={platform.name}
style="width: max-content;">
<Icon {icon} size="s" slot="start" />
</Badge>
{/each}
{#if platforms.length > 3}
<Badge
variant="secondary"
content={`+${platforms.length - 2}`}
style="width: max-content;" />
{/if}
<svelte:fragment slot="icons">
{#if isCloud && $regionsStore?.regions}
{@const region = findRegion(project)}
<Typography.Text>{region?.name}</Typography.Text>
{/if}
</svelte:fragment>
</GridItem1>
{/each}
</CardContainer>
</div>
</Accordion>
</div>
{/if}
<!-- Unarchive Confirmation Modal -->
<Modal bind:show={showUnarchiveModal} title="Unarchive project" size="s">
<p>Are you sure you want to unarchive <strong>{projectToUnarchive?.name}</strong>?</p>
<p>This will move the project back to your active projects list.</p>
<svelte:fragment slot="footer">
<Layout.Stack direction="row" gap="s" justifyContent="flex-end">
<Button secondary on:click={cancelUnarchive}>Cancel</Button>
<Button on:click={confirmUnarchive}>Unarchive</Button>
</Layout.Stack>
</svelte:fragment>
</Modal>
<!-- Delete Confirmation Modal -->
<Modal
size="s"
bind:show={showDeleteModal}
title="Delete project"
onSubmit={confirmDelete}
bind:error={deleteError}>
<svelte:fragment slot="description">
The archived project <strong>{projectToDelete?.name}</strong> will be deleted along with all
of its metadata, stats, and other resources.
<b>This action is irreversible.</b>
</svelte:fragment>
<InputText
label={`Enter "${projectToDelete?.name}" to continue`}
placeholder="Enter name"
id="delete-project-name"
autofocus
required
bind:value={deleteProjectName} />
<svelte:fragment slot="footer">
<Button
text
on:click={() => {
resetDeleteState();
}}>Cancel</Button>
<Button
submissionLoader
submit
disabled={(deleteProjectName ?? '') !== projectToDelete?.name}>
Delete
</Button>
</svelte:fragment>
</Modal>
<style>
.archive-projects-margin-top {
margin-top: 36px;
}
.action-menu-divider {
margin-inline: -1rem;
padding-block-start: 0.25rem;
padding-block-end: 0.25rem;
}
.archive-projects-margin {
margin-top: 16px;
margin-bottom: 36px;
}
.status-container {
display: flex;
align-items: center;
gap: 8px;
}
</style>
+3 -1
View File
@@ -9,7 +9,9 @@
export let size: AvatarProps['size'] = 'm';
export let background: string | undefined = undefined;
$: src = sdk.forConsole.avatars.getInitials(name, 192, 192, background).toString();
$: src = sdk.forConsole.avatars
.getInitials({ name, width: 192, height: 192, background })
.toString();
</script>
<Avatar alt={name} {size} {src} />
+3 -2
View File
@@ -57,6 +57,7 @@
}
function updateOrAddItem(payload: Payload) {
// todo: @itznotabug - might need a change to $table?
const { $id, status, $collection, policyId } = payload;
if ($collection === 'archives' && policyId !== null) {
return;
@@ -117,9 +118,9 @@
}
function backupName(item: BackupArchive | BackupRestoration, key: string) {
const attribute = key === 'archives' ? '$createdAt' : 'startedAt';
const column = key === 'archives' ? '$createdAt' : 'startedAt';
return toLocaleDate(item[attribute]);
return toLocaleDate(item[column]);
}
onMount(() => {
@@ -20,9 +20,14 @@
plan. Consider upgrading to increase your resource usage.
</svelte:fragment>
<svelte:fragment slot="buttons">
<Button href={`${base}/organization-${$organization.$id}/usage`} text fullWidthMobile>
<span class="text">View usage</span>
</Button>
{#if !page.data.currentPlan?.usagePerProject}
<Button
href={`${base}/organization-${$organization.$id}/usage`}
text
fullWidthMobile>
<span class="text">View usage</span>
</Button>
{/if}
<Button
href={$upgradeURL}
on:click={() => {
@@ -3,31 +3,33 @@
import { Click } from '$lib/actions/analytics';
import { Button } from '$lib/elements/forms';
import { HeaderAlert } from '$lib/layout';
import {
billingProjectsLimitDate,
hideBillingHeaderRoutes,
upgradeURL
} from '$lib/stores/billing';
import { currentPlan } from '$lib/stores/organization';
import { onMount } from 'svelte';
import { hideBillingHeaderRoutes, upgradeURL } from '$lib/stores/billing';
import { currentPlan, organization } from '$lib/stores/organization';
import SelectProjectCloud from './selectProjectCloud.svelte';
import { toLocaleDate } from '$lib/helpers/date';
let showSelectProject: boolean = $state(false);
let selectedProjects: string[] = $state([]);
onMount(() => {
selectedProjects = page.data.organization?.projects || [];
let showSelectProject: boolean = $state(false);
const organizationId = $derived(page.data.organization?.$id);
$effect(() => {
if (organizationId) {
selectedProjects = page.data.organization?.projects || [];
}
});
</script>
<SelectProjectCloud bind:showSelectProject bind:selectedProjects />
<SelectProjectCloud bind:showSelectProject bind:selectedProjects {organizationId} />
{#if $currentPlan && $currentPlan.projects > 0 && !hideBillingHeaderRoutes.includes(page.url.pathname)}
<HeaderAlert
type="warning"
title="Action required: You have more than {$currentPlan.projects} projects.">
<svelte:fragment>
Choose which projects to keep before {toLocaleDate(billingProjectsLimitDate)} or upgrade
to Pro. Projects over the limit will be blocked after this date.
Choose which projects to keep before {toLocaleDate(
$organization.billingNextInvoiceDate
)} or upgrade to Pro. Projects over the limit will be blocked after this date.
</svelte:fragment>
<svelte:fragment slot="buttons">
<Button
@@ -1,33 +1,29 @@
<script lang="ts">
import { type Models } from '@appwrite.io/console';
import { Alert, Button, Table } from '@appwrite.io/pink-svelte';
import { type Models, Query } from '@appwrite.io/console';
import { Alert, Button, Skeleton, Table } from '@appwrite.io/pink-svelte';
import { Modal } from '$lib/components';
import { onMount } from 'svelte';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { billingProjectsLimitDate } from '$lib/stores/billing';
import { page } from '$app/state';
import { toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
import { currentPlan } from '$lib/stores/organization';
import { currentPlan, organization } from '$lib/stores/organization';
let {
showSelectProject = $bindable(false),
selectedProjects = $bindable([])
selectedProjects = $bindable([]),
organizationId
}: {
showSelectProject: boolean;
selectedProjects: string[];
organizationId: string;
} = $props();
let projects = $state<Array<Models.Project>>([]);
let error = $state<string | null>(null);
let loading = $state(false);
let projectsLoadingError = $state<string | null>(null);
onMount(() => {
const currentOrgId = page.data.organization?.$id;
projects =
page.data.currentOrgId === currentOrgId ? page.data.allProjects?.projects || [] : [];
});
let error = $state<string | null>(null);
let projects = $state<Array<Models.Project>>([]);
let projectsToArchive = $derived(
projects.filter((project) => !selectedProjects.includes(project.$id))
@@ -39,6 +35,7 @@
projects[0].teamId,
selectedProjects
);
showSelectProject = false;
invalidate(Dependencies.ORGANIZATION);
addNotification({
@@ -69,47 +66,100 @@
return result;
}
$effect(() => {
if (!showSelectProject) return;
const areProjectsLoaded = projects.length > 0;
const teamIdInLoadedProjects = areProjectsLoaded ? projects[0].teamId : null;
if (organizationId != teamIdInLoadedProjects) {
loading = true;
sdk.forConsole.projects
.list({
queries: [
Query.equal('teamId', organizationId),
Query.limit(1000) // Get all projects for organization
]
})
.then((loadedProjects) => (projects = loadedProjects.projects))
.catch((err) => (projectsLoadingError = err.message))
.finally(() => (loading = false));
}
});
</script>
<Modal bind:show={showSelectProject} title={'Manage projects'} onSubmit={updateSelected}>
<Modal bind:show={showSelectProject} title="Manage projects" onSubmit={updateSelected}>
<svelte:fragment slot="description">
Choose which {$currentPlan?.projects || 2} projects to keep. Projects over the limit will be
blocked after this date.
</svelte:fragment>
{#if error}
<Alert.Inline status="error" title="Error">{error}</Alert.Inline>
{/if}
<Table.Root
let:root
allowSelection
bind:selectedRows={selectedProjects}
columns={[{ id: 'name' }, { id: 'created' }]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="name" {root}>Project Name</Table.Header.Cell>
<Table.Header.Cell column="created" {root}>Created</Table.Header.Cell>
</svelte:fragment>
{#each projects as project}
<Table.Row.Base {root} id={project.$id}>
<Table.Cell column="name" {root}>{project.name}</Table.Cell>
<Table.Cell column="created" {root}
>{toLocaleDateTime(project.$createdAt)}</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
{#if selectedProjects.length > $currentPlan?.projects}
<div class="u-text-warning u-mb-4">
You can only select {$currentPlan?.projects} projects. Please deselect others to continue.
{#if loading}
<div class="skeleton-projects">
<Table.Root let:root allowSelection columns={[{ id: 'name' }, { id: 'created' }]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="name" {root}>Project Name</Table.Header.Cell>
<Table.Header.Cell column="created" {root}>Created</Table.Header.Cell>
</svelte:fragment>
{#each Array.from({ length: 5 }) as _}
<Table.Row.Base {root} select="disabled">
<Table.Cell column="name" {root}>
<Skeleton variant="line" height={20} width="80%" />
</Table.Cell>
<Table.Cell column="created" {root}>
<Skeleton variant="line" height={20} width="80%" />
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
</div>
{/if}
{#if selectedProjects.length === $currentPlan?.projects}
<Alert.Inline
status="warning"
title={`${projects.length - selectedProjects.length} projects will be archived on ${toLocaleDate(billingProjectsLimitDate)}`}>
<span>
{@html formatProjectsToArchive()}
will be archived.
</span>
</Alert.Inline>
{:else if projectsLoadingError}
<Alert.Inline status="error" title="Error">{projectsLoadingError}</Alert.Inline>
{:else}
{#if error}
<Alert.Inline status="error" title="Error">{error}</Alert.Inline>
{/if}
<Table.Root
let:root
allowSelection
bind:selectedRows={selectedProjects}
columns={[{ id: 'name' }, { id: 'created' }]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="name" {root}>Project Name</Table.Header.Cell>
<Table.Header.Cell column="created" {root}>Created</Table.Header.Cell>
</svelte:fragment>
{#each projects as project}
<Table.Row.Base {root} id={project.$id}>
<Table.Cell column="name" {root}>{project.name}</Table.Cell>
<Table.Cell column="created" {root}
>{toLocaleDateTime(project.$createdAt)}</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
{#if selectedProjects.length > $currentPlan?.projects}
<div class="u-text-warning u-mb-4">
You can only select {$currentPlan?.projects} projects. Please deselect others to continue.
</div>
{/if}
{#if selectedProjects.length === $currentPlan?.projects}
{@const difference = projects.length - selectedProjects.length}
{@const messagePrefix =
difference > 1 ? `${difference} projects` : `${difference} project`}
<Alert.Inline
status="warning"
title={`${messagePrefix} will be archived on ${toLocaleDate($organization.billingNextInvoiceDate)}`}>
<span>
{@html formatProjectsToArchive()}
will be archived.
</span>
</Alert.Inline>
{/if}
{/if}
<svelte:fragment slot="footer">
<Button.Button size="s" variant="secondary" on:click={() => (showSelectProject = false)}
@@ -118,3 +168,13 @@
>Save</Button.Button>
</svelte:fragment>
</Modal>
<style lang="scss">
/* disable the top select all selector button */
.skeleton-projects :global([role='rowheader'] button) {
opacity: 0.4;
cursor: default;
pointer-events: none;
background: var(--bgcolor-neutral-tertiary);
}
</style>
@@ -5,6 +5,7 @@
import { IconX } from '@appwrite.io/pink-icons-svelte';
import { isTabletViewport } from '$lib/stores/viewport';
import PinkBackground from '$lib/images/pink-background.svg';
import { bannerSpacing } from '$lib/layout/headerAlert.svelte';
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
export let variant: 'gradient' | 'image' = 'gradient';
@@ -23,12 +24,22 @@
const { header, sidebar, content } = queryLayoutElements();
const headerHeight = header?.getBoundingClientRect().height || 0;
const offset = alertHeight + (!$isTabletViewport && header ? headerHeight : 0);
if (header) header.style.top = `${alertHeight}px`;
if (header) {
header.style.top = `${alertHeight}px`;
}
if (sidebar) {
sidebar.style.top = `${offset}px`;
sidebar.style.height = `calc(100vh - ${offset}px)`;
// for sidebar and sub-navigation!
bannerSpacing.set(`${alertHeight}px`);
}
if (content) {
content.style.paddingBlockStart = `${alertHeight}px`;
}
if (content) content.style.paddingBlockStart = `${alertHeight}px`;
};
onMount(setNavigationHeight);
@@ -42,7 +42,7 @@
const card = await submitStripeCard(name, page?.params?.organization ?? null);
if (card && Object.hasOwn(card, 'id')) {
if ((card as PaymentMethod).card.country === 'US') {
if ((card as PaymentMethod).card?.country === 'US') {
paymentMethod = card as PaymentMethod;
showState = true;
return;
@@ -90,6 +90,7 @@
<Typography.Text>Everything in the Free plan, plus:</Typography.Text>
<ul class="un-order-list">
<li>Unlimited databases, buckets, functions</li>
<li>Unlimited seats</li>
<li>{plan.bandwidth}GB bandwidth</li>
<li>{plan.storage}GB storage</li>
<li>{formatNum(plan.executions)} executions</li>
+10 -4
View File
@@ -1,16 +1,22 @@
<script lang="ts">
import { calculateExcess, plansInfo, tierToPlan, type Tier } from '$lib/stores/billing';
import {
calculateExcess,
plansInfo,
tierToPlan,
getServiceLimit,
type Tier
} from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { toLocaleDate } from '$lib/helpers/date';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import { abbreviateNumber } from '$lib/helpers/numbers';
import { formatNum } from '$lib/helpers/string';
import { onMount } from 'svelte';
import type { Aggregation } from '$lib/sdk/billing';
import { sdk } from '$lib/stores/sdk';
import { BillingPlan } from '$lib/constants';
import { Alert, Icon, Table, Tooltip } from '@appwrite.io/pink-svelte';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
import type { AggregationTeam } from '$lib/sdk/billing';
export let tier: Tier;
@@ -22,7 +28,7 @@
executions?: number;
members?: number;
} = null;
let aggregation: Aggregation = null;
let aggregation: AggregationTeam = null;
let showExcess = false;
onMount(async () => {
@@ -64,7 +70,7 @@
{#if excess?.members}
<Table.Row.Base {root}>
<Table.Cell {root}>Organization members</Table.Cell>
<Table.Cell {root}>{plan.addons.seats.limit} members</Table.Cell>
<Table.Cell {root}>{getServiceLimit('members', tier)} members</Table.Cell>
<Table.Cell {root}>
<p class="u-color-text-danger u-flex u-cross-center u-gap-4">
<span class="icon-arrow-up"></span>
+31 -66
View File
@@ -1,81 +1,46 @@
<script lang="ts">
import { BASE_BILLING_PLANS, BillingPlan } from '$lib/constants';
import { BillingPlan } from '$lib/constants';
import { formatCurrency } from '$lib/helpers/numbers';
import { plansInfo, type Tier, tierFree, tierPro, tierScale } from '$lib/stores/billing';
import { currentPlan, organization } from '$lib/stores/organization';
import { Badge, Layout, Typography } from '@appwrite.io/pink-svelte';
import { LabelCard } from '..';
import type { Plan } from '$lib/sdk/billing';
import { page } from '$app/state';
export let billingPlan: Tier;
export let anyOrgFree = false;
export let billingPlan: BillingPlan;
export let isNewOrg = false;
export let selfService = true;
export let anyOrgFree = false;
$: freePlan = $plansInfo.get(BillingPlan.FREE);
$: proPlan = $plansInfo.get(BillingPlan.PRO);
$: scalePlan = $plansInfo.get(BillingPlan.SCALE);
$: isBasePlan = BASE_BILLING_PLANS.includes($currentPlan?.$id);
$: plans = Object.values(page.data.plans.plans) as Plan[];
$: currentPlanInList = plans.some((plan) => plan.$id === $currentPlan?.$id);
</script>
<Layout.Stack>
<LabelCard
name="plan"
bind:group={billingPlan}
disabled={anyOrgFree || !selfService}
value={BillingPlan.FREE}
title={tierFree.name}
tooltipShow={anyOrgFree}
tooltipText="You are limited to 1 Free organization per account."
tooltipWidth="100%">
<svelte:fragment slot="action">
{#if $organization?.billingPlan === BillingPlan.FREE && !isNewOrg}
<Badge variant="secondary" size="xs" content="Current plan" />
{/if}
</svelte:fragment>
<Typography.Caption variant="400">
{tierFree.description}
</Typography.Caption>
<Typography.Text>
{formatCurrency(freePlan?.price ?? 0)}
</Typography.Text>
</LabelCard>
<LabelCard
name="plan"
disabled={!selfService}
bind:group={billingPlan}
value={BillingPlan.PRO}
title={tierPro.name}>
<svelte:fragment slot="action">
{#if $organization?.billingPlan === BillingPlan.PRO && !isNewOrg}
<Badge variant="secondary" size="xs" content="Current plan" />
{/if}
</svelte:fragment>
<Typography.Caption variant="400">
{tierPro.description}
</Typography.Caption>
<Typography.Text>
{formatCurrency(proPlan?.price ?? 0)} per month + usage
</Typography.Text>
</LabelCard>
<LabelCard
name="plan"
bind:group={billingPlan}
value={BillingPlan.SCALE}
title={tierScale.name}>
<svelte:fragment slot="action">
{#if $organization?.billingPlan === BillingPlan.SCALE && !isNewOrg}
<Badge variant="secondary" size="xs" content="Current plan" />
{/if}
</svelte:fragment>
<Typography.Caption variant="400">
{tierScale.description}
</Typography.Caption>
<Typography.Text>
{formatCurrency(scalePlan?.price ?? 0)} per month + usage
</Typography.Text>
</LabelCard>
{#if $currentPlan && !isBasePlan}
{#each plans as plan}
<LabelCard
name="plan"
bind:group={billingPlan}
disabled={!selfService || (plan.$id === BillingPlan.FREE && anyOrgFree)}
tooltipShow={plan.$id === BillingPlan.FREE && anyOrgFree}
value={plan.$id}
title={plan.name}>
<svelte:fragment slot="action">
{#if $organization?.billingPlan === plan.$id && !isNewOrg}
<Badge variant="secondary" size="xs" content="Current plan" />
{/if}
</svelte:fragment>
<Typography.Caption variant="400">
{plan.desc}
</Typography.Caption>
<Typography.Text>
{@const isZeroPrice = (plan.price ?? 0) <= 0}
{@const price = formatCurrency(plan.price ?? 0)}
{isZeroPrice ? price : `${price} per month + usage`}
</Typography.Text>
</LabelCard>
{/each}
{#if $currentPlan && !currentPlanInList}
<LabelCard
name="plan"
bind:group={billingPlan}
@@ -78,7 +78,7 @@
</Layout.Stack>
{:else}
<Card.Base variant="secondary" radius="s" padding="xs">
<Layout.Stack direction="row">
<Layout.Stack direction="row" id="no-payments-card-stack">
<Layout.Stack direction="row" gap="xxs" alignItems="center">
<Icon
icon={IconExclamationCircle}
+4 -4
View File
@@ -4,7 +4,7 @@
import { toLocaleDate } from '$lib/helpers/date';
import { type Organization } from '$lib/stores/organization';
import { plansInfo } from '$lib/stores/billing';
import { abbreviateNumber, formatCurrency } from '$lib/helpers/numbers';
import { abbreviateNumber, formatCurrency, isWithinSafeRange } from '$lib/helpers/numbers';
import { BillingPlan } from '$lib/constants';
import { Table, Typography } from '@appwrite.io/pink-svelte';
@@ -21,9 +21,9 @@
// equal or above means unlimited!
const getCorrectSeatsCountValue = (count: number): string | number => {
// php int max is always larger than js
const exceedsSafeLimit = count >= Number.MAX_SAFE_INTEGER;
return exceedsSafeLimit ? 'Unlimited' : count || 0;
// Check for Infinity or very large numbers
const isUnlimited = count === Infinity || !isWithinSafeRange(count);
return isUnlimited ? 'Unlimited' : count || 0;
};
function getPlanLimit(key: string): number | false {
+55 -12
View File
@@ -20,9 +20,12 @@
import { isCloud } from '$lib/system';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { currentPlan, newOrgModal } from '$lib/stores/organization';
import { currentPlan, newOrgModal, organization } from '$lib/stores/organization';
import { Click, trackEvent } from '$lib/actions/analytics';
import type { Models } from '@appwrite.io/console';
import { type Models, Query } from '@appwrite.io/console';
import { sdk } from '$lib/stores/sdk';
import { page } from '$app/state';
import { BillingPlan } from '$lib/constants';
type Organization = {
name: string;
@@ -63,15 +66,15 @@
}
} = createMenu();
let isLoadingProjects = true;
let isLoadingProjects = false;
let loadedProjects: Models.ProjectList = { total: 0, projects: [] };
export let organizations: Organization[] = [];
export let currentProject: Models.Project | null = null;
export let projects: Promise<Models.ProjectList> = Promise.resolve(loadedProjects);
let projectsBottomSheetOpen = false;
let organisationBottomSheetOpen = false;
let projectsBottomSheet: Promise<SheetMenu | null> = null;
function createOrg() {
trackEvent(Click.OrganizationClickCreate, { source: 'breadcrumbs' });
@@ -99,13 +102,27 @@
}
};
async function createProjectsBottomSheet(organization: Organization): Promise<SheetMenu> {
async function createProjectsBottomSheet(
organization: Organization
): Promise<SheetMenu | null> {
if (!isOnProjects) return null;
isLoadingProjects = true;
// null on non-org/project path like `onboarding`.
loadedProjects = (await projects) ?? loadedProjects;
loadedProjects =
(await sdk.forConsole.projects.list({
queries: [
Query.equal('teamId', organizationId ?? page.data.currentOrgId),
Query.limit(5),
Query.orderDesc('$updatedAt')
]
})) ?? loadedProjects;
for (const project of loadedProjects.projects) {
project.region ??= 'default';
}
isLoadingProjects = false;
const createProjectItem = {
@@ -185,8 +202,6 @@
$: selectedOrg = organizations.find((org) => org.isSelected);
$: projectsBottomSheet = createProjectsBottomSheet(selectedOrg);
$: organizationsBottomSheet = createOrganizationBottomSheet(selectedOrg);
$: correctPlanName =
@@ -198,6 +213,24 @@
: selectedOrg?.tierName; // fallback
$: derivedKey = `${selectedOrg?.$id}-${currentProject?.$id}`;
$: organizationId = currentProject?.teamId;
$: isOnProjects = page.route.id.includes('project-[region]-[project]');
$: shouldReloadProjects = isLoadingProjects
? false
: currentProject && loadedProjects.projects.length
? loadedProjects.projects[0].teamId != currentProject.teamId
: !loadedProjects.projects.length;
$: if (shouldReloadProjects) {
projectsBottomSheet = createProjectsBottomSheet(selectedOrg);
}
let badgeType: 'success' | undefined;
$: badgeType =
$organization && $organization.billingPlan !== BillingPlan.FREE ? 'success' : undefined;
</script>
<svelte:window on:resize={onResize} />
@@ -212,10 +245,13 @@
use:melt={$triggerOrganizations}
aria-label="Open organizations tab">
<span class="orgName">{selectedOrg?.name ?? 'Organization'}</span>
<span class="not-mobile"
<span class="not-mobile" style="padding-inline-start: 2px"
>{#if correctPlanName}<Badge
size="xs"
variant="secondary"
content={correctPlanName} />{/if}</span>
type={badgeType}
content={correctPlanName} />
{/if}</span>
<Icon icon={IconChevronDown} size="s" color="--fgcolor-neutral-secondary" />
</button>
{:else}
@@ -229,7 +265,11 @@
<span class="orgName" class:noProjects={!currentProject}
>{selectedOrg?.name ?? 'Organization'}</span>
<span class="not-mobile"
><Badge variant="secondary" content={correctPlanName ?? ''} /></span>
><Badge
size="xs"
variant="secondary"
type={badgeType}
content={correctPlanName ?? ''} /></span>
<Icon icon={IconChevronDown} size="s" color="--fgcolor-neutral-secondary" />
</button>
{/if}
@@ -380,7 +420,9 @@
<BottomSheet.Menu bind:isOpen={organisationBottomSheetOpen} menu={organizationsBottomSheet} />
{#await projectsBottomSheet then menu}
<BottomSheet.Menu bind:isOpen={projectsBottomSheetOpen} {menu} />
{#if menu}
<BottomSheet.Menu bind:isOpen={projectsBottomSheetOpen} {menu} />
{/if}
{/await}
{/key}
@@ -446,6 +488,7 @@
border-radius: var(--border-radius-S, 8px);
background: var(--overlay-neutral-hover, rgba(25, 25, 28, 0.03));
}
.trigger {
display: inline-flex;
align-items: center;
+1 -1
View File
@@ -17,7 +17,7 @@
$: planLimit = getServiceLimit(serviceId) || Infinity;
$: limit = preferences.get(page.route)?.limit ?? CARD_LIMIT;
$: limit = preferences.getForRoute(page.route)?.limit ?? CARD_LIMIT;
$: gridItemStyle = $isSmallViewport
? undefined
+1 -1
View File
@@ -16,7 +16,7 @@
</Typography.Text>
{/if}
</Layout.Stack>
<div style:overflow="hidden">
<div style:overflow="visible">
<Layout.Stack {gap}>
<slot name="aside" />
</Layout.Stack>
+26 -18
View File
@@ -1,4 +1,4 @@
<script lang="ts" context="module">
<script lang="ts" module>
const langArr = ['js', 'html', 'dart', 'kotlin', 'json', 'sh', 'yml', 'swift'] as const;
export type Language = (typeof langArr)[number];
@@ -20,11 +20,31 @@
import 'prismjs/plugins/autoloader/prism-autoloader';
import 'prismjs/plugins/custom-class/prism-custom-class';
import 'prismjs/plugins/line-numbers/prism-line-numbers';
import { afterUpdate } from 'svelte';
import { Copy } from '.';
export let label: string = null;
export let labelIcon: 'code' | 'android' | 'flutter' | 'apple' = null;
let {
label = null,
labelIcon = null,
code,
language,
withLineNumbers = false,
withCopy = false,
noMargin = false,
noBoxPadding = false,
allowScroll = false,
class: classes = ''
}: {
label?: string;
labelIcon?: 'code' | 'android' | 'flutter' | 'apple';
code: string;
language: 'js' | 'html' | 'dart' | 'kotlin' | 'json' | 'sh' | 'yml' | 'swift';
withLineNumbers?: boolean;
withCopy?: boolean;
noMargin?: boolean;
noBoxPadding?: boolean;
allowScroll?: boolean;
class?: string;
} = $props();
function getIcon(iconName: string) {
switch (iconName) {
@@ -40,22 +60,10 @@
return null;
}
}
export let code: string;
export let language: 'js' | 'html' | 'dart' | 'kotlin' | 'json' | 'sh' | 'yml' | 'swift';
export let withLineNumbers = false;
export let withCopy = false;
export let noMargin = false;
export let noBoxPadding = false;
export let allowScroll = false;
let classes = '';
export { classes as class };
Prism.plugins.customClass.prefix('prism-');
afterUpdate(async () => {
Prism.highlightAll();
});
$effect(() => Prism.highlightAll());
</script>
<section
@@ -85,7 +93,7 @@
class:line-numbers={withLineNumbers}><code>{code}</code></pre>
</section>
<!-- svelte-ignore css-unused-selector -->
<!-- svelte-ignore css_unused_selector -->
<style lang="scss" global>
@import 'prismjs/themes/prism.css';
@import 'prismjs/plugins/line-numbers/prism-line-numbers.css';
+191 -40
View File
@@ -4,23 +4,49 @@
import { preferences } from '$lib/stores/preferences';
import { onMount, type Snippet } from 'svelte';
import type { Column } from '$lib/helpers/types';
import { ActionMenu, Layout, Popover, Selector } from '@appwrite.io/pink-svelte';
import {
ActionMenu,
Divider,
Layout,
Popover,
Selector,
Typography
} from '@appwrite.io/pink-svelte';
import { Button } from '$lib/elements/forms';
let {
columns,
isCustomCollection = false,
isCustomTable = false,
ui = 'legacy',
allowNoColumns = false,
children
showAnyway = false,
children,
onPreferencesUpdated = null
}: {
columns: Writable<Column[]>;
isCustomCollection?: boolean;
isCustomTable?: boolean;
allowNoColumns?: boolean;
ui?: 'legacy' | 'new';
showAnyway?: boolean;
children: Snippet<[toggle: () => void, selectedColumnsNumber: number]>;
onPreferencesUpdated?: () => void;
} = $props();
let search = $state('');
let filteredColumns = $derived(
$columns
.filter((column) => !column.isAction && column.id !== '$sequence')
.filter(
(col) =>
!col.exclude &&
(col.title?.toLowerCase().includes(search.toLowerCase()) ||
col.id?.toLowerCase().includes(search.toLowerCase()))
)
);
let maxHeight = $state('none');
let containerRef = $state<HTMLElement>(null);
const collectionId = $derived(page.params.collection);
const isNewStyle = ui === 'new';
const calcMaxHeight = () => {
if (containerRef) {
@@ -33,27 +59,28 @@
};
const saveColumnPreferences = () => {
const shownColumns = $columns.filter((n) => n.hide === true).map((n) => n.id);
const hiddenColumns = $columns.filter((n) => n.hide === true).map((n) => n.id);
if (isCustomCollection) {
preferences.setCustomCollectionColumns(collectionId, shownColumns);
if (isCustomTable) {
onPreferencesUpdated?.();
preferences.setCustomTableColumns(page.params.table, hiddenColumns);
} else {
preferences.setColumns(shownColumns);
preferences.setColumns(hiddenColumns);
}
};
onMount(() => {
if (isCustomCollection) {
const shownColumns = preferences.getCustomCollectionColumns(collectionId);
if (isCustomTable) {
const hiddenColumns = preferences.getCustomTableColumns(page.params.table);
columns.update((n) =>
n.map((column) => {
column.hide = shownColumns?.includes(column.id) ?? false;
column.hide = hiddenColumns?.includes(column.id) ?? false;
return column;
})
);
} else {
const prefs = preferences.get(page.route);
const prefs = preferences.getForRoute(page.route);
if (prefs?.columns) {
columns.update((n) =>
@@ -70,7 +97,7 @@
let selectedColumnsNumber = $derived(
$columns.reduce((acc, column) => {
if (column.hide) return acc;
if (column.hide || column.isAction) return acc;
return ++acc;
}, 0)
@@ -78,45 +105,169 @@
function toggleColumn(column: Column) {
columns.update((cols) =>
cols.map((col) => {
if (col.id === column.id) {
column.hide = !column.hide;
}
return col;
})
cols.map((col) => (col.id === column.id ? { ...col, hide: !col.hide } : col))
);
saveColumnPreferences();
}
function selectAll() {
columns.update((cols) =>
cols.map((col) =>
col.exclude
? col
: filteredColumns.some((fc) => fc.id === col.id)
? { ...col, hide: false }
: col
)
);
saveColumnPreferences();
}
function deselectAll() {
columns.update((cols) => {
const realColumns = cols.filter((col) => !col.exclude && !col.isAction);
const filtered = filteredColumns.filter((col) => !col.exclude && !col.isAction);
if (filtered.length === 0) return cols;
const visibleRealColumns = realColumns.filter((col) => !col.hide);
if (!allowNoColumns && visibleRealColumns.length <= 1) {
return cols;
}
const willHideCount = filtered.filter((col) => !col.hide).length;
if (!allowNoColumns && visibleRealColumns.length - willHideCount < 1) {
const [keep] = filtered;
return cols.map((col) => {
if (col.exclude || col.isAction) return col;
if (!filtered.some((fc) => fc.id === col.id)) return col;
if (col.id === keep.id) {
const stillVisible = cols
.filter((c) => !c.exclude && !c.isAction)
.filter((c) =>
c.id === keep.id
? true
: filtered.some((fc) => fc.id === c.id)
? false
: !c.hide
);
if (stillVisible.length === 1 && typeof col.width === 'number') {
return { ...col, hide: false, width: { min: col.width } };
}
return { ...col, hide: false };
}
return { ...col, hide: true };
});
}
return cols.map((col) =>
col.exclude || col.isAction
? col
: filtered.some((fc) => fc.id === col.id)
? { ...col, hide: true }
: col
);
});
saveColumnPreferences();
}
let visibleRealColumns = $derived(
$columns.filter((col) => !col.exclude && !col.isAction && !col.hide)
);
</script>
<svelte:window on:resize={calcMaxHeight} />
{#if $columns?.length}
<Popover let:toggle placement="bottom-end" padding="none">
{#if $columns?.length || showAnyway}
{@const showActions = $columns.length > 1}
{@const placement = isNewStyle ? 'bottom-start' : 'bottom-end'}
<Popover let:toggle {placement} padding="none">
{@render children(toggle, selectedColumnsNumber)}
<svelte:fragment slot="tooltip">
<div style:max-height={maxHeight} style:overflow="scroll" bind:this={containerRef}>
<div bind:this={containerRef} class="actions-menu-wrapper" style:max-height={maxHeight}>
<ActionMenu.Root>
{#each $columns as column}
{#if !column?.exclude}
<ActionMenu.Item.Button
on:click={() => toggleColumn(column)}
disabled={allowNoColumns
? false
: selectedColumnsNumber <= 1 && column.hide !== true}>
<Layout.Stack direction="row" gap="s">
<Selector.Checkbox
checked={!column.hide}
size="s"
on:click={() => toggleColumn(column)} />
{column.title}
{#if isNewStyle && showActions}
<Layout.Stack gap="xs">
<ActionMenu.Item.Input
id="columns"
placeholder="Search"
bind:value={search} />
{#if filteredColumns.length > 0}
<Layout.Stack
gap="s"
direction="row"
alignItems="center"
style="padding-block-end: 0.5rem">
<Button size="xs" icon extraCompact on:click={selectAll}
>Select all</Button>
<div style:height="1rem">
<Divider vertical />
</div>
<Button size="xs" icon extraCompact on:click={deselectAll}
>Deselect all</Button>
</Layout.Stack>
</ActionMenu.Item.Button>
{/if}
{/each}
{:else}
<div style:padding-inline="0.6rem" style:padding-block-end="0.5rem">
<Typography.Text>No results found</Typography.Text>
</div>
{/if}
</Layout.Stack>
{/if}
<Layout.Stack
gap="none"
direction="column"
class="filter-modal-actions-menu {showActions ? 'has-actions' : ''}">
{#each filteredColumns as column}
{#if !column?.exclude}
<ActionMenu.Item.Button
on:click={() => toggleColumn(column)}
disabled={allowNoColumns
? false
: visibleRealColumns.length <= 1 && !column.hide}>
<Layout.Stack direction="row" gap="s">
<Selector.Checkbox
size="s"
checked={!column.hide}
on:change={() => toggleColumn(column)} />
{column.title}
</Layout.Stack>
</ActionMenu.Item.Button>
{/if}
{/each}
</Layout.Stack>
</ActionMenu.Root>
</div>
</svelte:fragment>
</Popover>
{/if}
<!-- svelte-ignore css_unused_selector -->
<style lang="scss">
/* global because its on a tooltip slot, nested */
:global(.actions-menu-wrapper) {
overflow: scroll;
margin-bottom: -0.35rem;
& :global(.filter-modal-actions-menu) {
overflow: scroll;
max-height: 150px;
padding-bottom: unset;
&.has-actions {
margin-bottom: -0.5rem;
}
&::-webkit-scrollbar {
display: none;
}
}
}
</style>
+2 -1
View File
@@ -10,6 +10,7 @@
export let disabled: boolean = false;
export let submissionLoader = false;
export let confirmDeletion: boolean = false;
export let confirmDeletionLabel: string = 'I understand and confirm';
export let onSubmit: (e: SubmitEvent) => Promise<void> | void = function () {
return;
};
@@ -46,7 +47,7 @@
required
id={checkboxId}
bind:checked={confirm}
label="I understand and confirm" />
label={confirmDeletionLabel} />
{/if}
</Layout.Stack>
</Layout.Stack>
+3 -2
View File
@@ -1,4 +1,7 @@
<script lang="ts" context="module">
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
type Consent = {
key: string;
accepted: Record<string, boolean>;
@@ -19,8 +22,6 @@
import { createEventDispatcher, onMount } from 'svelte';
import { Modal } from '.';
import { Button } from '$lib/elements/forms';
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
const key = new Date('2023-11-07');
const dispatch = createEventDispatcher();
+41 -14
View File
@@ -1,17 +1,36 @@
<script lang="ts">
import { trackEvent } from '$lib/actions/analytics';
import { clickOnEnter } from '$lib/helpers/a11y';
import type { ComponentProps, Snippet } from 'svelte';
import { copy } from '$lib/helpers/copy';
import { addNotification } from '$lib/stores/notifications';
import { clickOnEnter } from '$lib/helpers/a11y';
import { Tooltip } from '@appwrite.io/pink-svelte';
import { trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
export let value: string;
export let event: string = null;
export let eventContext = 'click_id_tag';
export let tooltipDisabled = false;
export let copyText: string = 'Click to copy';
export type TooltipPlacement = NonNullable<ComponentProps<Tooltip>['placement']>;
let content = copyText;
let {
value,
event = null,
eventContext = 'click_id_tag',
tooltipDisabled = false,
tooltipPortal = false,
tooltipDelay = 0,
tooltipPlacement = undefined,
copyText = 'Click to copy',
children
}: {
value: string;
event?: string;
eventContext?: string;
tooltipDisabled?: boolean;
tooltipPortal?: boolean;
tooltipDelay?: number;
tooltipPlacement?: TooltipPlacement;
copyText?: string;
children?: Snippet;
} = $props();
let content = $state(copyText);
async function handleClick() {
const success = await copy(value);
@@ -34,17 +53,25 @@
//TODO: remove this component
</script>
<Tooltip disabled={tooltipDisabled}>
<Tooltip
disabled={tooltipDisabled}
portal={tooltipPortal}
delay={tooltipDelay}
placement={tooltipPlacement}>
<span
data-private
style:display="inline-flex"
role="button"
tabindex="0"
style:cursor="pointer"
on:click|preventDefault|stopPropagation={handleClick}
on:keyup={clickOnEnter}
on:mouseenter={() => setTimeout(() => (content = copyText))}>
<slot />
onclick={(event) => {
event.preventDefault();
event.stopPropagation();
handleClick();
}}
onkeyup={clickOnEnter}
onmouseenter={() => setTimeout(() => (content = copyText))}>
{@render children?.()}
</span>
<p slot="tooltip" let:showing>
{#if showing}
@@ -10,13 +10,16 @@
export let height = 16;
$: ccImage = isValueOfStringEnum(CreditCard, brand)
? sdk.forConsole.avatars.getCreditCard(brand, width, height)
? sdk.forConsole.avatars.getCreditCard({
code: brand,
width,
height
})
: '';
</script>
{#if ccImage}
<img alt={brand} src={ccImage} {width} {height} style:border-radius="2.5px" />
{:else}
<!-- fallback: unionpay image not in Avatars API -->
<Icon icon={IconCreditCard} color="--fgcolor-neutral-tertiary" />
{/if}
+69 -48
View File
@@ -11,9 +11,13 @@
import { Layout, Typography } from '@appwrite.io/pink-svelte';
import { type Models, type Payload, Query } from '@appwrite.io/console';
// re-render the key for sheet UI.
import { hash } from '$lib/helpers/string';
import { spreadsheetRenderKey } from '$routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/store';
type ImportItem = {
status: string;
collection?: string;
table?: string;
};
type ImportItemsMap = Map<string, ImportItem>;
@@ -22,15 +26,11 @@
* Keeps a track of the active and ongoing csv migrations.
*
* The structure is as follows -
* `{ migrationId: { status: status, collection: collection } }`
* `{ migrationId: { status: status, table: table } }`
*/
const importItems: Writable<ImportItemsMap> = writable(new Map());
async function showCompletionNotification(
database: string,
collection: string,
payload: Payload
) {
async function showCompletionNotification(database: string, table: string, payload: Payload) {
const isSuccess = payload.status === 'completed';
const isError = !isSuccess && !!payload.errors;
@@ -48,20 +48,21 @@
const type = isSuccess ? 'success' : 'error';
const message = isError ? errorMessage : 'CSV import finished successfully.';
const url = `${base}/project-${page.params.region}-${page.params.project}/databases/database-${database}/collection-${collection}`;
const url = `${base}/project-${page.params.region}-${page.params.project}/databases/database-${database}/table-${table}`;
addNotification({
type,
message,
isHtml: true,
buttons:
isSuccess && collection !== page.params.collection
? [{ name: 'View documents', method: () => goto(url) }]
isSuccess && table !== page.params.table
? [{ name: 'View rows', method: () => goto(url) }]
: undefined
});
if (isSuccess) {
await invalidate(Dependencies.DOCUMENTS);
await invalidate(Dependencies.ROWS);
$spreadsheetRenderKey = hash(Date.now().toString());
}
}
@@ -70,19 +71,22 @@
const status = importData.status;
const resourceId = importData.resourceId ?? '';
const [databaseId, collectionId] = resourceId.split(':') ?? [];
const [databaseId, tableId] = resourceId.split(':') ?? [];
const current = $importItems.get(importData.$id);
let collectionName = current?.collection ?? null;
let tableName = current?.table ?? null;
if (!collectionName && collectionId) {
if (!tableName && tableId) {
try {
const collection = await sdk
const table = await sdk
.forProject(page.params.region, page.params.project)
.databases.getCollection(databaseId, collectionId);
collectionName = collection.name;
.tablesDB.getTable({
databaseId,
tableId
});
tableName = table.name;
} catch {
collectionName = null;
tableName = null;
}
}
@@ -99,12 +103,12 @@
if (shouldSkip) return items;
const next = new Map(items);
next.set(importData.$id, { status, collection: collectionName ?? undefined });
next.set(importData.$id, { status, table: tableName ?? undefined });
return next;
});
if (status === 'completed' || status === 'failed') {
await showCompletionNotification(databaseId, collectionId, importData);
await showCompletionNotification(databaseId, tableId, importData);
}
}
@@ -146,10 +150,12 @@
onMount(() => {
sdk.forProject(page.params.region, page.params.project)
.migrations.list([
Query.equal('source', 'CSV'),
Query.equal('status', ['pending', 'processing'])
])
.migrations.list({
queries: [
Query.equal('source', 'CSV'),
Query.equal('status', ['pending', 'processing'])
]
})
.then((migrations) => {
migrations.migrations.forEach(updateOrAddItem);
});
@@ -172,7 +178,7 @@
<header class="upload-box-header">
<h4 class="upload-box-title">
<Typography.Text variant="m-500">
Importing documents ({$importItems.size})
Importing rows ({$importItems.size})
</Typography.Text>
</h4>
<button
@@ -190,39 +196,54 @@
</button>
</header>
{#each [...$importItems.entries()] as [key, value] (key)}
<div class="upload-box-content" class:is-open={isOpen}>
<ul class="upload-box-list">
<li class="upload-box-item">
<section class="progress-bar u-width-full-line">
<div
class="progress-bar-top-line u-flex u-gap-8 u-main-space-between">
<Typography.Text>
{@html text(value.status, value.collection)}
</Typography.Text>
</div>
<div
class="progress-bar-container"
class:is-danger={value.status === 'failed'}
style="--graph-size:{graphSize(value.status)}%">
</div>
</section>
</li>
</ul>
</div>
{/each}
<div class="upload-box-content-list">
{#each [...$importItems.entries()] as [key, value] (key)}
<div class="upload-box-content" class:is-open={isOpen}>
<ul class="upload-box-list">
<li class="upload-box-item">
<section class="progress-bar u-width-full-line">
<div
class="progress-bar-top-line u-flex u-gap-8 u-main-space-between">
<Typography.Text>
{@html text(value.status, value.table)}
</Typography.Text>
</div>
<div
class="progress-bar-container"
class:is-danger={value.status === 'failed'}
style="--graph-size:{graphSize(value.status)}%">
</div>
</section>
</li>
</ul>
</div>
{/each}
</div>
</section>
</Layout.Stack>
{/if}
<style lang="scss">
.upload-box {
display: flex;
max-height: 320px;
flex-direction: column;
}
.upload-box-header {
flex-shrink: 0;
}
.upload-box-title {
font-size: 11px;
}
.upload-box-content-list {
overflow-y: auto;
}
.upload-box-content {
min-width: 400px;
max-width: 100vw;
width: 304px;
}
.upload-box-button {
+31 -17
View File
@@ -1,26 +1,40 @@
<script lang="ts">
import { Click, trackEvent } from '$lib/actions/analytics';
import { InputId } from '$lib/elements/forms';
import { InputProjectId } from '$lib/elements/forms';
import Button from '$lib/elements/forms/button.svelte';
import { IconX } from '@appwrite.io/pink-icons-svelte';
import { Click, trackEvent } from '$lib/actions/analytics';
import { Card, Divider, Icon, Layout, Typography } from '@appwrite.io/pink-svelte';
export let show = false;
export let name: string;
export let id: string;
export let autofocus = true;
export let isProject = false;
$: if (!show) {
id = null;
}
$: if (!id?.length) {
id = null;
}
let {
show = $bindable(false),
name,
id = $bindable(null),
autofocus = true,
isProject = false,
required = true
}: {
show: boolean;
name: string;
id: string;
autofocus?: boolean;
isProject?: boolean;
required?: boolean;
} = $props();
$: if (show) {
trackEvent(Click.ShowCustomIdClick);
}
$effect(() => {
if (!show) {
id = null;
}
if (!id?.length) {
id = null;
}
if (show) {
trackEvent(Click.ShowCustomIdClick);
}
});
</script>
{#if show}
@@ -45,9 +59,9 @@
<Divider />
</span>
{#if isProject}
<InputProjectId bind:value={id} {autofocus} />
<InputProjectId on:input bind:value={id} {autofocus} />
{:else}
<InputId required bind:value={id} {autofocus} />
<InputId {required} on:input bind:value={id} {autofocus} />
{/if}
</Layout.Stack>
</Card.Base>
@@ -23,8 +23,8 @@
{/if}
</Layout.Stack>
<Typography.Text variant="m-400">
Add the following nameservers on your DNS provider. Note that DNS changes may take time to
propagate fully.
Add the following nameservers on your DNS provider. Note that changes may take up to 48
hours to propagate fully.
</Typography.Text>
</Layout.Stack>
+43 -39
View File
@@ -11,6 +11,8 @@
import { menuOpen } from '$lib/components/menu/store';
export let time: string | null = null;
export let showDatetime: boolean = false;
export let canShowPopover: boolean = true;
export let placement: ComponentProps<Popover>['placement'] = 'bottom';
function timeDifference(dateString: string): string {
@@ -79,64 +81,66 @@
<Popover let:show let:hide {placement} portal>
<button
on:mouseenter={() => {
if (!$menuOpen) {
if (!$menuOpen && canShowPopover) {
setTimeout(show, 150);
}
}}
on:mouseleave={() => hidePopover(hide)}>
<slot>{capitalize(timeFromNow(time))}</slot>
<slot>{showDatetime ? toLocaleDateTime(time) : capitalize(timeFromNow(time))}</slot>
</button>
<div
let:hide
let:showing
slot="tooltip"
role="tooltip"
style:padding="1rem"
style:margin="-1rem"
on:mouseenter={() => (isMouseOverTooltip = true)}
on:mouseleave={() => hidePopover(hide, false)}>
<Layout.Stack gap="s" alignContent="flex-start">
<!-- `Raw time` as per design -->
<Typography.Caption color="--fgcolor-neutral-tertiary" variant="400">
{#if $$slots.title}
<slot name="title" />
{:else}
{timeToString}
{/if}
</Typography.Caption>
{#if showing}
<Layout.Stack gap="s" alignContent="flex-start">
<!-- `Raw time` as per design -->
<Typography.Caption color="--fgcolor-neutral-tertiary" variant="400">
{#if $$slots.title}
<slot name="title" />
{:else}
{timeToString}
{/if}
</Typography.Caption>
<!-- `Absolute time` as per design -->
<Layout.Stack gap="xxs">
<Layout.Stack
direction="row"
alignItems="center"
alignContent="center"
justifyContent="space-between">
<InteractiveText
isVisible
variant="copy"
text={toLocaleDateTime(time, false, 'UTC')}
value={toISOString(time)} />
<!-- `Absolute time` as per design -->
<Layout.Stack gap="xxs">
<Layout.Stack
direction="row"
alignItems="center"
alignContent="center"
justifyContent="space-between">
<InteractiveText
isVisible
variant="copy"
text={toLocaleDateTime(time, false, 'UTC')}
value={toISOString(time)} />
<Badge variant="secondary" content="UTC" size="xs" />
</Layout.Stack>
<Badge variant="secondary" content="UTC" size="xs" />
</Layout.Stack>
<Layout.Stack
direction="row"
alignItems="center"
alignContent="center"
justifyContent="space-between">
<InteractiveText
isVisible
variant="copy"
text={toLocaleDateTime(time)}
value={toLocalDateTimeISO(time)} />
<Badge variant="secondary" content="Local" size="xs" />
<Layout.Stack
direction="row"
alignItems="center"
alignContent="center"
justifyContent="space-between">
<InteractiveText
isVisible
variant="copy"
text={toLocaleDateTime(time)}
value={toLocalDateTimeISO(time)} />
<Badge variant="secondary" content="Local" size="xs" />
</Layout.Stack>
</Layout.Stack>
</Layout.Stack>
</Layout.Stack>
{/if}
</div>
</Popover>
{:else}
</Popover>{:else}
<Typography.Text>{time === null || time === undefined ? 'null' : time}</Typography.Text>
{/if}
+10
View File
@@ -0,0 +1,10 @@
<script lang="ts">
import { Layout, Card } from '@appwrite.io/pink-svelte';
export let gap: 'none' | 'xxxs' | 'xxs' | 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl' | 'xxxl' = 'l';
</script>
<Card.Base>
<Layout.Stack {gap}>
<slot />
</Layout.Stack>
</Card.Base>
+24 -27
View File
@@ -25,10 +25,10 @@
...services.flatMap((s) => s.actions.map((a) => a.name)),
...services.flatMap((s) => s.resources.flatMap((r) => r.actions.map((a) => a.name)))
];
const attributeNames = [
...services.flatMap((s) => s.actions.flatMap((a) => a.attributes ?? [])),
const columnNames = [
...services.flatMap((s) => s.actions.flatMap((a) => a.columns ?? [])),
...services.flatMap((s) =>
s.resources.flatMap((r) => r.actions.flatMap((a) => a.attributes ?? []))
s.resources.flatMap((r) => r.actions.flatMap((a) => a.columns ?? []))
)
];
@@ -36,7 +36,7 @@
const isService = (s: string) => serviceNames.includes(s);
const isResource = (s: string) => resourceNames.includes(s);
const isAction = (s: string) => actionNames.includes(s);
const isAttribute = (s: string) => attributeNames.includes(s);
const isColumn = (s: string) => columnNames.includes(s);
const dispatch = createEventDispatcher();
@@ -68,7 +68,7 @@
case 'service': {
selected.resource = null;
selected.action = null;
selected.attribute = null;
selected.column = null;
break;
}
case 'resource': {
@@ -78,25 +78,25 @@
// We need to check if the selected action name is still
// on the available list, and if so, change the action to the
// appropriate one, as it may contain different attributes
// appropriate one, as it may contain different attributes/columns.
const availableAction = availableActions.find(
(a) => a.name === selected.action?.name
);
if (!availableAction) {
selected.action = null;
selected.attribute = null;
selected.column = null;
} else {
selected.action = availableAction;
if (!selected?.action?.attributes?.includes(selected.attribute)) {
selected.attribute = null;
if (!selected?.action?.columns?.includes(selected.column)) {
selected.column = null;
}
}
break;
}
case 'action': {
selected.attribute = null;
selected.column = null;
break;
}
}
@@ -112,16 +112,13 @@
if (isService(field)) {
selected.service = services.find((s) => s.name === field);
} else if (isResource(field)) {
const resource = selected.service?.resources.find((r) => r.name === field);
selected.resource = resource;
selected.resource = selected.service?.resources.find((r) => r.name === field);
} else if (isAction(field)) {
const action =
selected.action =
selected.resource?.actions.find((a) => a.name === field) ||
selected.service?.actions.find((a) => a.name === field);
selected.action = action;
} else if (isAttribute(field)) {
const attribute = selected.action?.attributes?.find((a) => a === field);
selected.attribute = attribute;
} else if (isColumn(field)) {
selected.column = selected.action?.columns?.find((a) => a === field);
}
}
}
@@ -142,7 +139,7 @@
const secondToLastField = at(fields, index - 2);
if (index === 0 || isService(currField)) return 'service';
if (isAttribute(currField) || isAction(prevField)) return 'attribute';
if (isColumn(currField) || isAction(prevField)) return 'attribute';
if (isAction(currField)) return 'action';
if (isResource(currField)) return 'resource';
if (isService(prevField) || isResource(prevField) || index === 1) {
@@ -181,7 +178,7 @@
service: null as EventService | null,
resource: null as EventResource | null,
action: null as EventAction | null,
attribute: null as string | null
column: null as string | null
};
let helper: string = null;
let customInput: string = null;
@@ -197,7 +194,7 @@
services: services,
resources: selected.service?.resources || [],
actions: (selected.resource ? selected.resource?.actions : selected.service?.actions) || [],
attributes: selected.action?.attributes || []
columns: selected.action?.columns || []
};
$: eventString = (function createEventString(): Array<{ value: string; description: string }> {
@@ -211,8 +208,8 @@
if (selected.service) {
fields.push(selected.service.name, '*');
}
if (selected.resource?.name === 'documents') {
fields.push('collections', '*');
if (selected.resource?.name === 'rows') {
fields.push('tables', '*');
}
if (selected.resource) {
fields.push(selected.resource.name, '*');
@@ -220,8 +217,8 @@
if (selected.action) {
fields.push(selected.action.name);
}
if (selected.attribute) {
fields.push(selected.attribute);
if (selected.column) {
fields.push(selected.column);
}
}
@@ -289,14 +286,14 @@
</Layout.Stack>
{/if}
{#if !empty(available.attributes)}
{#if !empty(available.columns)}
<Layout.Stack gap="s">
<Typography.Text variant="m-500">Choose an attribute (optional)</Typography.Text>
<Layout.Stack gap="s" wrap="wrap" direction="row">
{#each available.attributes as attribute}
{#each available.columns as attribute}
<Tag
disabled={showInput}
selected={selected.attribute === attribute}
selected={selected.column === attribute}
on:click={() => select('attribute', attribute)}>
{attribute}
</Tag>
+502 -354
View File
@@ -17,6 +17,7 @@
Card,
Divider,
Empty,
Icon,
Layout,
Modal,
Selector,
@@ -24,23 +25,28 @@
Table,
ToggleButton,
Tooltip,
Typography
Typography,
Upload
} from '@appwrite.io/pink-svelte';
import Form from '$lib/elements/forms/form.svelte';
import { isSmallViewport } from '$lib/stores/viewport';
import { IconViewGrid, IconViewList } from '@appwrite.io/pink-icons-svelte';
import { IconInfo, IconPlus, IconViewGrid, IconViewList } from '@appwrite.io/pink-icons-svelte';
import { showCreateBucket } from '$routes/(console)/project-[region]-[project]/storage/+page.svelte';
import { preferences } from '$lib/stores/preferences';
export let show: boolean;
export let mimeTypeQuery: string = 'image/';
export let allowedExtension: string = '*';
export let selectedBucket: string = null;
export let selectedFile: string = null;
export let onSelect: (e: Models.File) => void;
export let onSelect: (e: Models.File, localFile: boolean) => void;
export let gridImageDimensions: { imageHeight?: number; imageWidth?: number } = {
imageHeight: 148
};
export let showLocalFileBucket: boolean = false;
export let localFileBucketTitle = 'Upload local file';
let localFileBucketSelected = true;
let localFile: FileList = null;
let search = writable('');
let searchEnabled = false;
let fileSelector: HTMLInputElement;
@@ -48,11 +54,22 @@
let view: 'grid' | 'list' = 'list';
onMount(() => {
const lastSelectedBucket = preferences.getKey('lastSelectedBucket', null);
localFileBucketSelected = !lastSelectedBucket;
selectedBucket = currentBucket?.$id;
});
function onSubmit() {
onSelect(currentFile);
async function onSubmit() {
// localfile needs to be uploaded first
if (localFileBucketSelected) {
await uploadFile();
}
// Save preference based on selection
preferences.setKey('lastSelectedBucket', localFileBucketSelected ? null : selectedBucket);
onSelect(currentFile, localFileBucketSelected);
closeModal();
}
@@ -60,7 +77,12 @@
return (
sdk
.forProject(page.params.region, page.params.project)
.storage.getFilePreview(bucketId, fileId, size, size)
.storage.getFilePreview({
bucketId,
fileId,
width: size,
height: size
})
.toString() + '&mode=admin'
);
}
@@ -68,12 +90,24 @@
async function uploadFile() {
try {
uploading = true;
const file = await sdk
.forProject(page.params.region, page.params.project)
.storage.createFile(selectedBucket, ID.unique(), fileSelector.files[0], [
Permission.read(Role.any())
]);
search.set($search === null ? '' : null);
let file = null;
if (localFileBucketSelected) {
file = await sdk.forConsoleIn(page.params.region).storage.createFile({
bucketId: 'default',
fileId: ID.unique(),
file: localFile[0]
});
} else {
file = await sdk
.forProject(page.params.region, page.params.project)
.storage.createFile({
bucketId: selectedBucket,
fileId: ID.unique(),
file: fileSelector.files[0],
permissions: [Permission.read(Role.any())]
});
search.set($search === null ? '' : null);
}
selectFile(file);
} catch (e) {
console.error(e);
@@ -82,7 +116,17 @@
}
}
function selectLocalBucket() {
localFileBucketSelected = true;
selectBucket(null);
}
function selectBucket(bucket: Models.Bucket | null) {
if (bucket) {
localFileBucketSelected = false;
localFile = null;
}
search.set('');
currentBucket = bucket;
selectedBucket = bucket?.$id ?? null;
@@ -91,8 +135,10 @@
function selectFile(file: Models.File) {
currentFile = file;
selectedBucket = currentFile.bucketId;
selectedFile = currentFile.$id;
if (!localFileBucketSelected) {
selectedBucket = currentFile.bucketId;
selectedFile = currentFile.$id;
}
}
function resetFile() {
@@ -123,10 +169,24 @@
const response = await sdk
.forProject(page.params.region, page.params.project)
.storage.listBuckets();
const bucket = response.buckets[0] ?? null;
const lastSelectedBucket = preferences.getKey('lastSelectedBucket', null);
let preferredBucket = null;
if (lastSelectedBucket) {
preferredBucket = response.buckets.find((bucket) => bucket.$id === lastSelectedBucket);
}
const bucket = preferredBucket ?? response.buckets[0] ?? null;
if (bucket) {
currentBucket = bucket;
selectedBucket = bucket.$id;
if (preferredBucket) {
localFileBucketSelected = false;
}
} else {
// in case when the last bucket selected was deleted
localFileBucketSelected = true;
}
return response;
@@ -153,7 +213,11 @@
currentBucket &&
sdk
.forProject(page.params.region, page.params.project)
.storage.listFiles(currentBucket.$id, getProperQuery(), $search || undefined)
.storage.listFiles({
bucketId: currentBucket.$id,
queries: getProperQuery(),
search: $search || undefined
})
.then((response) => {
if ($search === '') {
searchEnabled = response.total > 0;
@@ -176,206 +240,268 @@
<Layout.Stack direction={$isSmallViewport ? 'column' : 'row'} height="50vh" gap="none">
<!-- min-width to avoid a layout-shift -->
<aside>
{#if !$isSmallViewport}
<Typography.Caption variant="500">Buckets</Typography.Caption>
{/if}
<Layout.Stack direction="column" gap="l">
{#if showLocalFileBucket}
<div class="action-button" class:active-item={localFileBucketSelected}>
<ActionMenu.Item.Button on:click={selectLocalBucket}>
Local file
</ActionMenu.Item.Button>
</div>
{/if}
<Layout.Stack direction="column" gap="s">
{#if !$isSmallViewport}
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary"
>Buckets</Typography.Caption>
{/if}
{#await buckets}
{#if $isSmallViewport}
<!-- disabled state -->
<div style:padding-block-start="1rem">
<InputSelect
required
disabled
id="bucket"
value={null}
options={[]}
label="Bucket"
placeholder="Loading buckets..." />
</div>
{/if}
{:then response}
{#if $isSmallViewport}
<div style:padding-block-start="1rem">
<InputSelect
required
id="bucket"
label="Bucket"
bind:value={selectedBucket}
placeholder="Select bucket"
on:change={(event) => {
const bucketId = event.detail;
const bucket = response.buckets.find(
(bucket) => bucket.$id === bucketId
);
selectBucket(bucket);
}}
options={response.buckets.map((bucket) => {
return {
value: bucket.$id,
label: `${bucket.name}`
};
})} />
</div>
{:else}
<div class="action-menu-holder">
<ActionMenu.Root width="180px">
{#each response.buckets as bucket}
{@const isSelected = bucket.$id === selectedBucket}
<div class="action-button" class:active-item={isSelected}>
<ActionMenu.Item.Button
on:click={() => selectBucket(bucket)}>
{bucket.name}
</ActionMenu.Item.Button>
</div>
{:else}
<ActionMenu.Item.Button
>No buckets found</ActionMenu.Item.Button>
{/each}
</ActionMenu.Root>
</div>
{/if}
{/await}
{#await buckets}
{#if $isSmallViewport}
<!-- disabled state -->
<div style:padding-block-start="1rem">
<InputSelect
required
disabled
id="bucket"
value={null}
options={[]}
label="Bucket"
placeholder="Loading buckets..." />
</div>
{/if}
{:then response}
{#if $isSmallViewport}
<div style:padding-block-start="1rem">
<InputSelect
required
id="bucket"
label="Bucket"
bind:value={selectedBucket}
placeholder="Select bucket"
on:change={(event) => {
const bucketId = event.detail;
const bucket = response.buckets.find(
(bucket) => bucket.$id === bucketId
);
selectBucket(bucket);
}}
options={response.buckets.map((bucket) => {
return {
value: bucket.$id,
label: `${bucket.name}`
};
})} />
</div>
{:else}
<div class="action-menu-holder">
<ActionMenu.Root width="180px">
{#each response.buckets as bucket}
{@const isSelected =
!localFileBucketSelected &&
bucket.$id === selectedBucket}
<div
class="action-button"
class:active-item={isSelected}>
<ActionMenu.Item.Button
on:click={() => selectBucket(bucket)}>
{bucket.name}
</ActionMenu.Item.Button>
</div>
{:else}
<Layout.Stack
gap="xs"
direction="row"
alignItems="center"
class="u-margin-inline-start-8">
<Typography.Text
align="center"
variant="m-400"
color="--fgcolor-neutral-tertiary"
>No buckets found</Typography.Text>
</Layout.Stack>
<Layout.Stack
alignItems="center"
direction="row"
style="gap: 3px; margin-block-start: 8px;">
<Icon icon={IconPlus} size="s" />
<Button
compact
on:click={async () => {
await goto(
`${base}/project-${page.params.region}-${page.params.project}/storage`
);
$showCreateBucket = true;
}}>
Create a bucket
</Button>
</Layout.Stack>
{/each}
</ActionMenu.Root>
</div>
{/if}
{/await}
</Layout.Stack>
</Layout.Stack>
</aside>
<div style:padding-inline-start="1rem" style:opacity={$isSmallViewport ? 0 : 1}>
<Divider vertical />
</div>
<div class="files-section">
<Layout.Stack gap="l">
{#if $isSmallViewport}
<Button
secondary
disabled={uploading}
on:click={() => fileSelector.click()}>
<input
type="file"
tabindex="-1"
class="u-hide"
accept={extension}
on:change={uploadFile}
bind:this={fileSelector} />
{#if uploading}
<div class="loader is-small"></div>
<span>Uploading</span>
{:else}
<span class="icon-upload" aria-hidden="true"></span>
<span>Upload</span>
{/if}
</Button>
{/if}
{#await buckets then response}
{#if response?.total}
{#if currentBucket}
<Layout.Stack>
{#if !$isSmallViewport}
{#key currentBucket?.$id}
<Layout.Stack direction="row" alignItems="center">
<Typography.Title size="s"
>{currentBucket?.name}</Typography.Title>
<Id value={currentBucket?.$id} event="bucket">
{currentBucket?.$id}
</Id>
{#if localFileBucketSelected}
<div class="files-section">
<Layout.Stack gap="l">
<Typography.Title size="s" color="--fgcolor-neutral-primary"
>{localFileBucketTitle}</Typography.Title>
<Upload.Dropzone bind:files={localFile} extensions={[allowedExtension]}>
<Layout.Stack alignItems="center" gap="s">
<Layout.Stack alignItems="center" gap="s">
<Layout.Stack
alignItems="center"
justifyContent="center"
direction="row"
gap="s">
<Typography.Text variant="l-500">
Drag and drop files here or click to upload
</Typography.Text>
<Tooltip>
<Layout.Stack
alignItems="center"
justifyContent="center"
inline>
<Icon icon={IconInfo} size="s" />
</Layout.Stack>
{/key}
{/if}
<Layout.Stack direction="row" alignItems="center">
<InputSearch
placeholder="Search files"
bind:value={$search}
disabled={!searchEnabled} />
<ToggleButton
bind:active={view}
buttons={[
{
id: 'list',
label: 'list view',
disabled: !searchEnabled,
icon: IconViewList
},
{
id: 'grid',
label: 'grid view',
disabled: !searchEnabled,
icon: IconViewGrid
}
]} />
{#if !$isSmallViewport}
<Button
secondary
disabled={uploading}
on:click={() => fileSelector.click()}>
<input
type="file"
tabindex="-1"
class="u-hide"
accept={extension}
on:change={uploadFile}
bind:this={fileSelector} />
{#if uploading}
<div class="loader is-small"></div>
<span>Uploading</span>
{:else}
<span class="icon-upload" aria-hidden="true"
></span>
<span>Upload</span>
{/if}
</Button>
{/if}
<svelte:fragment slot="tooltip"
>{allowedExtension} files are allowed</svelte:fragment>
</Tooltip>
</Layout.Stack>
<Typography.Caption variant="400"
>Max file size: 10MB</Typography.Caption>
</Layout.Stack>
</Layout.Stack>
</Upload.Dropzone>
{#if localFile}
<Upload.List
files={Array.from(localFile).map((f) => {
return {
...f,
name: f.name,
size: f.size,
extension: f.type,
removable: true
};
})}
on:remove={() => (localFile = null)} />
{/if}
</Layout.Stack>
</div>
{:else}
<div class="files-section">
<Layout.Stack gap="l">
{#if $isSmallViewport}
<Button
secondary
disabled={uploading}
on:click={() => fileSelector.click()}>
<input
type="file"
tabindex="-1"
class="u-hide"
accept={extension}
on:change={uploadFile}
bind:this={fileSelector} />
{#if uploading}
<div class="loader is-small"></div>
<span>Uploading</span>
{:else}
<span class="icon-upload" aria-hidden="true"></span>
<span>Upload</span>
{/if}
</Button>
{/if}
{#if files}
{#await files}
<Layout.Stack
justifyContent="center"
alignContent="center"
alignItems="center"
gap="xl"
height="100%">
<Spinner size="l" />
<span>Loading files...</span>
{#await buckets then response}
{#if response?.total}
{#if currentBucket}
<Layout.Stack>
{#if !$isSmallViewport}
{#key currentBucket?.$id}
<Layout.Stack direction="row" alignItems="center">
<Typography.Title size="s"
>{currentBucket?.name}</Typography.Title>
<Id value={currentBucket?.$id} event="bucket">
{currentBucket?.$id}
</Id>
</Layout.Stack>
{/key}
{/if}
<Layout.Stack direction="row" alignItems="center">
<InputSearch
placeholder="Search files"
bind:value={$search}
disabled={!searchEnabled} />
<ToggleButton
bind:active={view}
buttons={[
{
id: 'list',
label: 'list view',
disabled: !searchEnabled,
icon: IconViewList
},
{
id: 'grid',
label: 'grid view',
disabled: !searchEnabled,
icon: IconViewGrid
}
]} />
{#if !$isSmallViewport}
<Button
secondary
disabled={uploading}
on:click={() => fileSelector.click()}>
<input
type="file"
tabindex="-1"
class="u-hide"
accept={extension}
on:change={uploadFile}
bind:this={fileSelector} />
{#if uploading}
<div class="loader is-small"></div>
<span>Uploading</span>
{:else}
<span class="icon-upload" aria-hidden="true"
></span>
<span>Upload</span>
{/if}
</Button>
{/if}
</Layout.Stack>
{:then response}
{#if response?.files?.length}
{#if view === 'grid'}
{#if $isSmallViewport}
<Layout.Stack gap="l">
{#each response?.files as file}
<Card.Selector
radius="s"
name="files"
padding="xxs"
imageHeight={32}
imageWidth={32}
imageRadius="xs"
bind:group={selectedFile}
title={truncatedFilename(file, 14)}
value={file.$id}
src={getPreview(
currentBucket.$id,
file.$id,
360
)}
on:click={() => selectFile(file)} />
{/each}
</Layout.Stack>
{:else}
<Layout.Grid
columnsXXS={1}
columnsXS={2}
columnsS={3}
columns={4}>
{#each response?.files as file}
<div class="image-selector">
</Layout.Stack>
{#if files}
{#await files}
<Layout.Stack
justifyContent="center"
alignContent="center"
alignItems="center"
gap="xl"
height="100%">
<Spinner size="l" />
<span>Loading files...</span>
</Layout.Stack>
{:then response}
{#if response?.files?.length}
{#if view === 'grid'}
{#if $isSmallViewport}
<Layout.Stack gap="l">
{#each response?.files as file}
<Card.Selector
radius="s"
name="files"
padding="xxs"
imageWidth={gridImageDimensions.imageWidth}
imageHeight={gridImageDimensions.imageHeight}
imageHeight={32}
imageWidth={32}
imageRadius="xs"
bind:group={selectedFile}
title={truncatedFilename(
@@ -390,166 +516,188 @@
)}
on:click={() =>
selectFile(file)} />
</div>
{/each}
</Layout.Grid>
{/if}
{/if}
{#if view === 'list'}
<Table.Root
let:root
columns={[
{ id: 'filename', width: { min: 225 } },
{ id: 'id', width: { min: 200 } },
{ id: 'type', width: { min: 100 } },
{ id: 'size', width: { min: 120 } },
{ id: 'created', width: { min: 140 } }
]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="filename" {root}>
Filename
</Table.Header.Cell>
<Table.Header.Cell column="id" {root}>
ID
</Table.Header.Cell>
<Table.Header.Cell column="type" {root}>
Type
</Table.Header.Cell>
<Table.Header.Cell column="size" {root}>
Size
</Table.Header.Cell>
<Table.Header.Cell column="created" {root}>
Created
</Table.Header.Cell>
</svelte:fragment>
{#each response?.files as file (file.$id)}
<Table.Row.Button
{root}
on:click={() => selectFile(file)}>
<Table.Cell column="filename" {root}>
<div
class="u-inline-flex u-cross-center u-gap-12">
<Selector.Radio
size="s"
name="file"
{/each}
</Layout.Stack>
{:else}
<Layout.Grid
columnsXXS={1}
columnsXS={2}
columnsS={3}
columns={4}>
{#each response?.files as file}
<div class="image-selector">
<Card.Selector
radius="s"
name="files"
padding="xxs"
imageWidth={gridImageDimensions.imageWidth}
imageHeight={gridImageDimensions.imageHeight}
imageRadius="xs"
bind:group={selectedFile}
title={truncatedFilename(
file,
14
)}
value={file.$id}
bind:group={selectedFile} />
<div class="preview-block">
<img
alt={file.name}
src={getPreview(
currentBucket.$id,
file.$id
)} />
</div>
<Tooltip
disabled={file.name.length <
15}
maxWidth="fit-content">
<Typography.Text truncate>
{truncatedFilename(
file
)}
</Typography.Text>
<span slot="tooltip">
{file.name}
</span>
</Tooltip>
src={getPreview(
currentBucket.$id,
file.$id,
360
)}
on:click={() =>
selectFile(file)} />
</div>
</Table.Cell>
<Table.Cell column="id" {root}>
<Id value={file.$id}>{file.$id}</Id>
</Table.Cell>
<Table.Cell column="type" {root}>
{file.mimeType}
</Table.Cell>
<Table.Cell column="size" {root}>
{calculateSize(file.sizeOriginal)}
</Table.Cell>
<Table.Cell column="created" {root}>
<DualTimeView
time={file.$createdAt} />
</Table.Cell>
</Table.Row.Button>
{/each}
</Table.Root>
{/each}
</Layout.Grid>
{/if}
{/if}
{#if view === 'list'}
<Table.Root
let:root
columns={[
{ id: 'filename', width: { min: 225 } },
{ id: 'id', width: { min: 200 } },
{ id: 'type', width: { min: 100 } },
{ id: 'size', width: { min: 120 } },
{ id: 'created', width: { min: 140 } }
]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell
column="filename"
{root}>
Filename
</Table.Header.Cell>
<Table.Header.Cell column="id" {root}>
ID
</Table.Header.Cell>
<Table.Header.Cell column="type" {root}>
Type
</Table.Header.Cell>
<Table.Header.Cell column="size" {root}>
Size
</Table.Header.Cell>
<Table.Header.Cell
column="created"
{root}>
Created
</Table.Header.Cell>
</svelte:fragment>
{#each response?.files as file (file.$id)}
<Table.Row.Button
{root}
on:click={() => selectFile(file)}>
<Table.Cell
column="filename"
{root}>
<div
class="u-inline-flex u-cross-center u-gap-12">
<Selector.Radio
size="s"
name="file"
value={file.$id}
bind:group={
selectedFile
} />
<div class="preview-block">
<img
alt={file.name}
src={getPreview(
currentBucket.$id,
file.$id
)} />
</div>
<Tooltip
disabled={file.name
.length < 15}
maxWidth="fit-content">
<Typography.Text
truncate>
{truncatedFilename(
file
)}
</Typography.Text>
<span slot="tooltip">
{file.name}
</span>
</Tooltip>
</div>
</Table.Cell>
<Table.Cell column="id" {root}>
<Id value={file.$id}
>{file.$id}</Id>
</Table.Cell>
<Table.Cell column="type" {root}>
{file.mimeType}
</Table.Cell>
<Table.Cell column="size" {root}>
{calculateSize(
file.sizeOriginal
)}
</Table.Cell>
<Table.Cell column="created" {root}>
<DualTimeView
time={file.$createdAt} />
</Table.Cell>
</Table.Row.Button>
{/each}
</Table.Root>
{/if}
{:else if $search}
<EmptySearch
hidePages
hidePagination
bind:search={$search}
target="files">
<Button
secondary
on:click={() => ($search = '')}>
Clear search
</Button>
</EmptySearch>
{:else}
<Card.Base padding="none">
<Empty
title="No files found within this bucket."
description="Need a hand? Learn more in our documentation.">
<slot name="actions" slot="actions">
<Button
text
external
size="s"
event="empty_documentation"
href="https://appwrite.io/docs/products/storage/upload-download"
ariaLabel="create document"
>Documentation</Button>
<Button
secondary
disabled={uploading}
on:click={() =>
fileSelector.click()}
>Upload file
</Button>
</slot>
</Empty>
</Card.Base>
{/if}
{:else if $search}
<EmptySearch
hidePages
hidePagination
bind:search={$search}
target="files">
<Button secondary on:click={() => ($search = '')}>
Clear search
</Button>
</EmptySearch>
{:else}
<Card.Base padding="none">
<Empty
title="No files found within this bucket."
description="Need a hand? Learn more in our documentation.">
<slot name="actions" slot="actions">
<Button
text
external
size="s"
event="empty_documentation"
href="https://appwrite.io/docs/products/storage/upload-download"
ariaLabel="create document"
>Documentation</Button>
<Button
secondary
disabled={uploading}
on:click={() => fileSelector.click()}
>Upload file
</Button>
</slot>
</Empty>
</Card.Base>
{/if}
{/await}
{/await}
{/if}
{/if}
{/if}
{:else}
<Card.Base padding="none">
<Empty
title="No buckets found"
description="Need a hand? Learn more in our documentation.">
<slot name="actions" slot="actions">
<Button
text
external
size="s"
event="empty_documentation"
href="https://appwrite.io/docs/products/storage/buckets"
ariaLabel="create document">Documentation</Button>
<Button
secondary
on:click={async () => {
await goto(
`${base}/project-${page.params.region}-${page.params.project}/storage`
);
$showCreateBucket = true;
}}>
Create bucket
</Button>
</slot>
</Empty>
</Card.Base>
{/if}
{/await}
</Layout.Stack>
</div></Layout.Stack>
{/await}
</Layout.Stack>
</div>
{/if}
</Layout.Stack>
<svelte:fragment slot="footer">
<Layout.Stack direction="row" justifyContent="flex-end">
<Button text on:click={closeModal}>Cancel</Button>
<Button submit disabled={selectedBucket === null || selectedFile === null}
<Button
submit
disabled={(selectedBucket === null && localFileBucketSelected === false) ||
(selectedFile === null && localFile === null)}
>Select
</Button>
</Layout.Stack>
+79 -54
View File
@@ -14,11 +14,12 @@
ValidOperators
} from './store';
import { createEventDispatcher } from 'svelte';
import { Icon, Layout, Popover } from '@appwrite.io/pink-svelte';
import { Badge, Icon, Layout, Popover } from '@appwrite.io/pink-svelte';
import { IconFilter, IconFilterLine } from '@appwrite.io/pink-icons-svelte';
import { Click, Submit, trackEvent } from '$lib/actions/analytics';
export let query = '[]';
export let onlyIcon = false;
export let columns: Writable<Column[]>;
export let disabled = false;
export let fullWidthMobile = false;
@@ -43,12 +44,12 @@
let showFiltersDesktop = false;
let showFiltersMobile = false;
let applied = $tags.length;
$: filtersAppliedCount = $tags.length;
beforeNavigate(() => {
applied = $tags.length;
showFiltersDesktop = false;
showFiltersMobile = false;
filtersAppliedCount = $tags.length;
});
function clearAll() {
@@ -86,7 +87,7 @@
applied: number;
}>
) {
applied = e.detail.applied;
filtersAppliedCount = e.detail.applied;
if (singleCondition) {
showFiltersDesktop = false;
showFiltersMobile = false;
@@ -124,62 +125,81 @@
<Popover let:toggle placement="bottom-start">
<Button
secondary
icon={onlyIcon}
class={onlyIcon ? 'width-fix' : undefined}
on:click={(event) => {
toggle(event);
trackEvent(Click.FilterApplyClick, { source: analyticsSource });
}}
{disabled}>
<Icon icon={IconFilterLine} slot="start" size="s" />
Filters
{#if applied > 0}
<span class="inline-tag">
{applied}
</span>
{#if !onlyIcon}
<Icon icon={IconFilterLine} slot="start" size={onlyIcon ? 'm' : 's'} />
Filters
{#if filtersAppliedCount > 0}
<Badge size="xs" variant="secondary" content={filtersAppliedCount.toString()} />
{/if}
{:else}
<Icon
icon={IconFilterLine}
size={onlyIcon ? 'm' : 's'}
color="--fgcolor-neutral-tertiary" />
{#if filtersAppliedCount > 0}
<Badge content={filtersAppliedCount.toString()} size="xs" variant="secondary" />
{/if}
{/if}
</Button>
<svelte:fragment slot="tooltip">
<div style:width="420px">
<Layout.Stack gap="s">
{#if displayQuickFilters}
<slot name="quick" />
{:else}
<p>Apply filter rules to refine the table view</p>
<Content
bind:columnId={selectedColumn}
bind:operatorKey
bind:value
bind:arrayValues
{columns}
{singleCondition}
on:apply={afterApply}
on:clear={() => (applied = 0)} />
{/if}
<div
class="u-flex u-cross-center u-margin-block-start-16"
class:u-main-end={!quickFilters}
class:u-main-space-between={quickFilters}>
{#if quickFilters}
<Button
text
on:click={() => (displayQuickFilters = !displayQuickFilters)}
class="u-margin-block-end-auto">
{displayQuickFilters ? 'Advanced filters' : 'Quick filters'}
</Button>
<svelte:fragment slot="tooltip" let:showing>
{#if showing}
<div style:width="420px">
<Layout.Stack gap="s">
{#if displayQuickFilters}
<slot name="quick" />
{:else}
<p>Apply filter rules to refine the table view</p>
<!-- this needs to be portalled -->
<Content
bind:columnId={selectedColumn}
bind:operatorKey
bind:value
bind:arrayValues
{columns}
{singleCondition}
on:apply={afterApply}
on:clear={() => (filtersAppliedCount = 0)} />
{/if}
<div class="u-flex u-gap-8">
{#if singleCondition}
<Button size="s" text on:click={toggleDropdown}>Cancel</Button>
{:else}
<Button size="s" disabled={applied === 0} text on:click={clearAll}>
Clear all
<div
class="u-flex u-cross-center u-margin-block-start-16"
class:u-main-end={!quickFilters}
class:u-main-space-between={quickFilters}>
{#if quickFilters}
<Button
text
on:click={() => (displayQuickFilters = !displayQuickFilters)}
class="u-margin-block-end-auto">
{displayQuickFilters ? 'Advanced filters' : 'Quick filters'}
</Button>
{/if}
<Button size="s" on:click={apply} disabled={isButtonDisabled}
>Apply</Button>
<div class="u-flex u-gap-8">
{#if singleCondition}
<Button size="s" text on:click={toggleDropdown}>Cancel</Button>
{:else}
<Button
size="s"
disabled={filtersAppliedCount === 0}
text
on:click={clearAll}>
Clear all
</Button>
{/if}
<Button size="s" on:click={apply} disabled={isButtonDisabled}
>Apply</Button>
</div>
</div>
</div>
</Layout.Stack>
</div>
</Layout.Stack>
</div>
{/if}
</svelte:fragment>
</Popover>
</div>
@@ -189,10 +209,8 @@
<Button size="s" secondary on:click={toggleMobileModal} {fullWidthMobile}>
<Icon icon={IconFilter} slot="start" size="s" />
Filters
{#if applied > 0}
<span class="inline-tag">
{applied}
</span>
{#if filtersAppliedCount > 0}
<Badge content={filtersAppliedCount.toString()} size="xs" variant="secondary" />
{/if}
</Button>
</slot>
@@ -210,7 +228,7 @@
bind:arrayValues
{singleCondition}
on:apply={afterApply}
on:clear={() => (applied = 0)} />
on:clear={() => (filtersAppliedCount = 0)} />
{/if}
<svelte:fragment slot="footer">
<div
@@ -238,3 +256,10 @@
</svelte:fragment>
</Modal>
</div>
<style>
:global(.width-fix) {
width: 32px;
height: 32px;
}
</style>
+20 -14
View File
@@ -1,10 +1,10 @@
import { goto } from '$app/navigation';
import { derived, get, writable } from 'svelte/store';
import { page } from '$app/stores';
import deepEqual from 'deep-equal';
import type { Column, ColumnType } from '$lib/helpers/types';
import { Query } from '@appwrite.io/console';
import { toLocaleDateTime } from '$lib/helpers/date';
import { derived, get, writable } from 'svelte/store';
import type { Column, ColumnType } from '$lib/helpers/types';
export type TagValue = {
tag: string;
@@ -12,8 +12,8 @@ export type TagValue = {
};
export type Operator = {
toTag: (attribute: string, input?: string | number | string[], type?: string) => TagValue;
toQuery: (attribute: string, input?: string | number | string[]) => string;
toTag: (column: string, input?: string | number | string[], type?: string) => TagValue;
toQuery: (column: string, input?: string | number | string[]) => string;
types: ColumnType[];
hideInput?: boolean;
};
@@ -59,9 +59,15 @@ function initQueries(initialValue = new Map<TagValue, string>()) {
}
function apply() {
const queryParam = mapToQueryParams(get(queries));
const usableQueries = get(queries);
const currentLocation = window.location.pathname;
goto(`${currentLocation}?query=${queryParam}`, { noScroll: true });
if (usableQueries.size) {
const queryParam = mapToQueryParams(usableQueries);
goto(`${currentLocation}?query=${queryParam}`, { noScroll: true });
} else {
goto(currentLocation, { noScroll: true });
}
}
return {
@@ -134,7 +140,7 @@ export enum ValidTypes {
const operatorsDefault = new Map<
ValidOperators,
{
query: (attr: string, input: string | number | string[]) => string;
query: (col: string, input: string | number | string[]) => string;
types: ColumnType[];
hideInput?: boolean;
}
@@ -249,8 +255,8 @@ export function generateDefaultOperators() {
operatorsDefault.forEach((operator, operatorName) => {
operators[operatorName] = {
toQuery: operator.query,
toTag: (attribute, input = null, type = null) => {
return generateTag(attribute, operatorName, input, type);
toTag: (column, input = null, type = null) => {
return generateTag(column, operatorName, input, type);
},
types: operator.types,
hideInput: operator.hideInput
@@ -259,24 +265,24 @@ export function generateDefaultOperators() {
return operators;
}
export function generateTag(attribute: string, operatorName: string, input = null, type = null) {
export function generateTag(column: string, operatorName: string, input = null, type = null) {
if (input === null) {
return {
value: '',
tag: `**${attribute}** ${operatorName}`
tag: `**${column}** ${operatorName}`
};
} else if (Array.isArray(input) && input.length > 2) {
return {
value: input,
tag: `**${attribute}** ${operatorName} **${formatArray(input)}** `
tag: `**${column}** ${operatorName} **${formatArray(input)}** `
};
} else if (type === ValidTypes.Datetime) {
return {
value: input,
tag: `**${attribute}** ${operatorName} **${toLocaleDateTime(input.toString())}**`
tag: `**${column}** ${operatorName} **${toLocaleDateTime(input.toString())}**`
};
} else {
return { value: input, tag: `**${attribute}** ${operatorName} **${input}**` };
return { value: input, tag: `**${column}** ${operatorName} **${input}**` };
}
}
@@ -56,7 +56,11 @@
if (repositoryBehaviour === 'new') {
const repo = await sdk
.forProject(page.params.region, page.params.project)
.vcs.createRepository($installation.$id, repositoryName, repositoryPrivate);
.vcs.createRepository({
installationId: $installation.$id,
name: repositoryName,
xprivate: repositoryPrivate
});
repository.set(repo);
selectedRepository = repo.id;
}
@@ -18,13 +18,19 @@
async function loadBranches() {
const repo = await sdk
.forProject(page.params.region, page.params.project)
.vcs.getRepository(installationId, repositoryId);
.vcs.getRepository({
installationId,
providerRepositoryId: repositoryId
});
branch = repo.defaultBranch ?? 'main';
const { branches } = await sdk
.forProject(page.params.region, page.params.project)
.vcs.listRepositoryBranches(installationId, repositoryId);
.vcs.listRepositoryBranches({
installationId,
providerRepositoryId: repositoryId
});
return sortBranches(branches);
}
+8 -8
View File
@@ -107,21 +107,21 @@
$repositories.repositories = (
(await sdk
.forProject(page.params.region, page.params.project)
.vcs.listRepositories(
.vcs.listRepositories({
installationId,
VCSDetectionType.Runtime,
search || undefined
)) as unknown as Models.ProviderRepositoryRuntimeList
type: VCSDetectionType.Runtime,
search: search || undefined
})) as unknown as Models.ProviderRepositoryRuntimeList
).runtimeProviderRepositories; //TODO: remove forced cast after backend fixes
} else {
$repositories.repositories = (
(await sdk
.forProject(page.params.region, page.params.project)
.vcs.listRepositories(
.vcs.listRepositories({
installationId,
VCSDetectionType.Framework,
search || undefined
)) as unknown as Models.ProviderRepositoryFrameworkList
type: VCSDetectionType.Framework,
search: search || undefined
})) as unknown as Models.ProviderRepositoryFrameworkList
).frameworkProviderRepositories;
}
$repositories.search = search;
+21 -8
View File
@@ -43,7 +43,12 @@
try {
const content = await sdk
.forProject(page.params.region, page.params.project)
.vcs.getRepositoryContents($installation.$id, $repository.id, currentPath, branch);
.vcs.getRepositoryContents({
installationId: $installation.$id,
providerRepositoryId: $repository.id,
providerRootDirectory: currentPath,
providerReference: branch
});
directories[0].fileCount = content.contents?.length ?? 0;
directories[0].children = content.contents
.filter((e) => e.isDirectory)
@@ -83,7 +88,12 @@
try {
const content = await sdk
.forProject(page.params.region, page.params.project)
.vcs.getRepositoryContents($installation.$id, $repository.id, path, branch);
.vcs.getRepositoryContents({
installationId: $installation.$id,
providerRepositoryId: $repository.id,
providerRootDirectory: path,
providerReference: branch
});
const fileCount = content.contents?.length ?? 0;
const contentDirectories = content.contents.filter((e) => e.isDirectory);
@@ -101,12 +111,15 @@
}));
const runtime = await sdk
.forProject(page.params.region, page.params.project)
.vcs.createRepositoryDetection(
$installation.$id,
$repository.id,
product === 'sites' ? VCSDetectionType.Framework : VCSDetectionType.Runtime,
path
);
.vcs.createRepositoryDetection({
installationId: $installation.$id,
providerRepositoryId: $repository.id,
type:
product === 'sites'
? VCSDetectionType.Framework
: VCSDetectionType.Runtime,
providerRootDirectory: path
});
if (product === 'sites') {
currentDir.children.forEach((dir) => {
dir.thumbnailUrl = $iconPath(runtime.framework, 'color');
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { Card, Layout, Typography } from '@appwrite.io/pink-svelte';
export let href: string;
export let href: string = null;
</script>
<Card.Link class="card" {href}>
+6 -1
View File
@@ -45,13 +45,18 @@
import { Icon, Tag } from '@appwrite.io/pink-svelte';
import { Copy } from '.';
import { IconDuplicate } from '@appwrite.io/pink-icons-svelte';
import type { TooltipPlacement } from '$lib/components/copy.svelte';
export let value: string;
export let event: string = null;
export let tooltipPortal = false;
export let tooltipDelay: number = 0;
export let tooltipPlacement: TooltipPlacement = undefined;
</script>
{#key value}
<Copy {value} {event}>
<Copy {value} {event} {tooltipPortal} {tooltipDelay} {tooltipPlacement}>
<Tag size="xs" variant="code">
<Icon icon={IconDuplicate} size="s" slot="start" />
<span
+3
View File
@@ -84,3 +84,6 @@ export { default as UsageCard } from './usageCard.svelte';
export { default as ViewToggle } from './viewToggle.svelte';
export { default as RegionEndpoint } from './regionEndpoint.svelte';
export { default as ExpirationInput } from './expirationInput.svelte';
export { default as EstimatedCard } from './estimatedCard.svelte';
export { default as EmailVerificationBanner } from './alerts/emailVerificationBanner.svelte';
export { default as SortButton, type SortDirection } from './sortButton.svelte';
+1 -1
View File
@@ -45,7 +45,7 @@
</script>
<Tooltip maxWidth={tooltipWidth} disabled={!tooltipText || !tooltipShow}>
<div style:cursor={disabled ? 'pointer' : ''}>
<div style:cursor={disabled ? 'pointer' : ''} style:z-index="1">
<Card.Selector
{name}
{src}
+1 -1
View File
@@ -21,7 +21,7 @@
const url = new URL(pageStore.url);
const previousLimit = Number(url.searchParams.get('limit'));
url.searchParams.set('limit', limit.toString());
preferences.setLimit(limit);
await preferences.setLimit(limit);
if (url.searchParams.has('page')) {
const page = Number(url.searchParams.get('page'));
+10 -7
View File
@@ -4,11 +4,14 @@
export async function verify(challenge: Models.MfaChallenge, code: string) {
try {
if (challenge === null) {
challenge = await sdk.forConsole.account.createMfaChallenge(
AuthenticationFactor.Totp
);
challenge = await sdk.forConsole.account.createMFAChallenge({
factor: AuthenticationFactor.Totp
});
}
await sdk.forConsole.account.updateMfaChallenge(challenge.$id, code);
await sdk.forConsole.account.updateMFAChallenge({
challengeId: challenge.$id,
otp: code
});
await invalidate(Dependencies.ACCOUNT);
trackEvent(Submit.AccountLogin, { mfa_used: true });
} catch (error) {
@@ -46,7 +49,7 @@
disabled = true;
challengeType = factor;
try {
challenge = await sdk.forConsole.account.createMfaChallenge(factor);
challenge = await sdk.forConsole.account.createMFAChallenge({ factor });
} catch (error) {
addNotification({
type: 'error',
@@ -63,9 +66,9 @@
);
if (enabledNonRecoveryFactors.length === 1) {
if (factors.phone) {
createChallenge(AuthenticationFactor.Phone);
await createChallenge(AuthenticationFactor.Phone);
} else if (factors.email) {
createChallenge(AuthenticationFactor.Email);
await createChallenge(AuthenticationFactor.Email);
}
}
});
+3 -11
View File
@@ -1,15 +1,6 @@
<script lang="ts" context="module">
import type { HTMLAttributes } from 'svelte/elements';
export type NavbarProject = {
name: string;
$id: string;
region: string;
isSelected: boolean;
platformCount: number;
pingCount: number;
};
export type BaseNavbarProps = HTMLAttributes<HTMLHeadElement> & {
logo: {
src: string;
@@ -106,7 +97,6 @@
export let sideBarIsOpen: $$Props['sideBarIsOpen'] = false;
export let showAccountMenu = false;
export let currentProject: Models.Project = undefined;
export let projects: Promise<Models.ProjectList> = undefined;
let activeTheme = $app.theme;
let shouldAnimateThemeToggle = false;
@@ -138,7 +128,9 @@
class="only-desktop">
<img src={logo.src} alt={logo.alt} />
</a>
<Breadcrumbs {organizations} {projects} {currentProject} />
<Breadcrumbs {organizations} {currentProject} />
{#if page.route?.id?.includes('/project-[region]-[project]') && currentProject && currentProject.pingCount === 0}
<div class="only-desktop" style:margin-inline-start="-16px">
<Button.Anchor
@@ -0,0 +1,354 @@
<script lang="ts">
import { Button } from '$lib/elements/forms';
import { getServiceLimit, plansInfo } from '$lib/stores/billing';
import { BillingPlan } from '$lib/constants';
import { Click, trackEvent } from '$lib/actions/analytics';
import { Badge, Icon, Layout, Table, Typography, Tooltip } from '@appwrite.io/pink-svelte';
import { IconArrowUp, IconInfo } from '@appwrite.io/pink-icons-svelte';
import { formatNumberWithCommas } from '$lib/helpers/numbers';
import { Modal } from '$lib/components';
import { Alert } from '@appwrite.io/pink-svelte';
import { addNotification } from '$lib/stores/notifications';
import { toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
import { organization, type Organization } from '$lib/stores/organization';
import type { Models } from '@appwrite.io/console';
// Props
type Props = {
organization: Organization;
projects?: Models.Project[];
members?: Models.Membership[];
storageUsage?: number;
};
const { projects = [], members = [], storageUsage = 0 }: Props = $props();
let showSelectProject = $state(false);
let selectedProjects = $state<string[]>([]);
let error = $state<string | null>(null);
let showSelectionReminder = $state(false);
// Derived state using runes
let freePlanLimits = $derived({
projects: $plansInfo?.get(BillingPlan.FREE)?.projects,
members: getServiceLimit('members', BillingPlan.FREE),
storage: getServiceLimit('storage', BillingPlan.FREE)
});
// When preparing to downgrade to Free, enforce Free plan limit locally (2)
let allowedProjectsToKeep = $derived(freePlanLimits.projects);
let currentUsage = $derived({
projects: projects?.length || 0,
members: members?.length || 0,
storage: storageUsage || 0
});
let storageUsageGB = $derived(storageUsage / (1024 * 1024 * 1024));
let isLimitExceeded = $derived({
projects: currentUsage.projects > freePlanLimits.projects,
members: currentUsage.members > freePlanLimits.members,
storage: storageUsageGB > freePlanLimits.storage
});
let excessUsage = $derived({
projects: Math.max(0, currentUsage.projects),
members: Math.max(0, currentUsage.members - freePlanLimits.members),
storage: Math.max(0, storageUsageGB - freePlanLimits.storage)
});
// projects that would be archived with the current selection
let projectsToArchive = $derived(
projects.filter((project) => !selectedProjects.includes(project.$id))
);
function formatProjectsToArchive(): string {
let result = '';
projectsToArchive.forEach((project, index) => {
const isLast = index === projectsToArchive.length - 1;
const isSecondLast = index === projectsToArchive.length - 2;
result += `${index === 0 ? '' : ' '}${project.name}`;
if (!isLast) {
if (isSecondLast) result += ' and';
else result += ',';
}
});
return result;
}
function formatNumber(num: number): string {
return formatNumberWithCommas(num);
}
function handleManageProjects() {
showSelectProject = true;
showSelectionReminder = false;
trackEvent(Click.OrganizationClickUpgrade, { source: 'usage_limits_manage_projects' });
}
// Expose validation for parent to call before submitting downgrade
export function validateOrAlert(): boolean {
const filteredSelection = selectedProjects.filter((id) =>
projects.some((p) => p.$id === id)
);
const isValid = filteredSelection.length === allowedProjectsToKeep;
showSelectionReminder = !isValid && isLimitExceeded.projects;
return isValid;
}
export function getSelectedProjects(): string[] {
return selectedProjects.filter((id) => projects.some((p) => p.$id === id));
}
function updateSelected() {
error = null;
const filteredSelection = selectedProjects.filter((id) =>
projects.some((p) => p.$id === id)
);
if (filteredSelection.length !== allowedProjectsToKeep) {
error = `You must select exactly ${allowedProjectsToKeep} projects to keep.`;
return;
}
// Keep selection locally; parent flow will apply after plan change
showSelectProject = false;
showSelectionReminder = false;
addNotification({ type: 'success', message: `Projects selected for archiving` });
}
</script>
<Layout.Stack gap="l">
{#if showSelectionReminder}
<Alert.Inline status="warning" title="Choose projects to keep">
The Free plan lets you keep {allowedProjectsToKeep} projects. Select them before continuing.
<Layout.Stack
direction="row"
justifyContent="flex-start"
gap="xs"
style="position: relative; z-index: 10; pointer-events: auto;">
<Button compact on:click={handleManageProjects}>Manage projects</Button>
</Layout.Stack>
</Alert.Inline>
{/if}
<div class="responsive-table">
<Table.Root
columns={[
{ id: 'resource', width: { min: 215 } },
{ id: 'freeLimit', width: { min: 100 } },
{ id: 'excessUsage', width: { min: 120 } },
{ id: 'manage', width: { min: 110 } }
]}
let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="resource" {root}>Resource</Table.Header.Cell>
<Table.Header.Cell column="freeLimit" {root}>Free limit</Table.Header.Cell>
<Table.Header.Cell column="excessUsage" {root}>
<Layout.Stack direction="row" alignItems="center" gap="xs">
<Typography.Text>Excess usage</Typography.Text>
<Tooltip placement="bottom" portal>
<Icon icon={IconInfo} size="s" />
<span slot="tooltip">Usage beyond the Free plan limits.</span>
</Tooltip>
</Layout.Stack>
</Table.Header.Cell>
<Table.Header.Cell column="manage" {root}></Table.Header.Cell>
</svelte:fragment>
<!-- Projects Row -->
<Table.Row.Base {root}>
<Table.Cell column="resource" {root}>
<Layout.Stack direction="row" alignItems="center" gap="xs">
<Typography.Text>Projects</Typography.Text>
{#if isLimitExceeded.projects}
<Badge
size="xs"
content="Action required"
variant="secondary"
type="warning" />
{/if}
</Layout.Stack>
</Table.Cell>
<Table.Cell column="freeLimit" {root}>
<Typography.Text
>{formatNumber(allowedProjectsToKeep)} projects</Typography.Text>
</Table.Cell>
<Table.Cell column="excessUsage" {root}>
{#if isLimitExceeded.projects}
<Layout.Stack direction="row" alignItems="center" gap="xs">
<Icon icon={IconArrowUp} size="s" color="--fgcolor-error" />
<Typography.Text color="--fgcolor-error">
{formatNumber(excessUsage.projects)} projects
</Typography.Text>
</Layout.Stack>
{:else}
<Typography.Text color="--fgcolor-neutral-secondary">
{formatNumber(currentUsage.projects)} / {formatNumber(
allowedProjectsToKeep
)}
</Typography.Text>
{/if}
</Table.Cell>
<Table.Cell column="manage" {root}>
{#if isLimitExceeded.projects}
<Layout.Stack direction="row" justifyContent="flex-end">
<Button size="xs" secondary on:click={handleManageProjects}
>Manage projects</Button>
</Layout.Stack>
{/if}
</Table.Cell>
</Table.Row.Base>
<!-- Organization Members Row -->
<Table.Row.Base {root}>
<Table.Cell column="resource" {root}>
<Typography.Text>Organization members</Typography.Text>
</Table.Cell>
<Table.Cell column="freeLimit" {root}>
<Typography.Text>{formatNumber(freePlanLimits.members)} member</Typography.Text>
</Table.Cell>
<Table.Cell column="excessUsage" {root}>
{#if isLimitExceeded.members}
<Layout.Stack direction="row" alignItems="center" gap="xs">
<Icon icon={IconArrowUp} size="s" color="--fgcolor-error" />
<Typography.Text color="--fgcolor-error">
{formatNumber(excessUsage.members)} members
</Typography.Text>
</Layout.Stack>
{:else}
<Typography.Text color="--fgcolor-neutral-secondary">
{formatNumber(currentUsage.members)} / {formatNumber(
freePlanLimits.members
)}
</Typography.Text>
{/if}
</Table.Cell>
<Table.Cell column="manage" {root}></Table.Cell>
</Table.Row.Base>
<!-- Storage Row -->
<Table.Row.Base {root}>
<Table.Cell column="resource" {root}>
<Typography.Text>Storage</Typography.Text>
</Table.Cell>
<Table.Cell column="freeLimit" {root}>
<Typography.Text>{freePlanLimits.storage} GB</Typography.Text>
</Table.Cell>
<Table.Cell column="excessUsage" {root}>
{#if isLimitExceeded.storage}
<Layout.Stack direction="row" alignItems="center" gap="xs">
<Icon icon={IconArrowUp} size="s" color="--fgcolor-error" />
<Typography.Text color="--fgcolor-error">
{excessUsage.storage.toFixed(2)} GB
</Typography.Text>
</Layout.Stack>
{:else}
<Typography.Text color="--fgcolor-neutral-secondary">
{storageUsageGB.toFixed(2)} / {freePlanLimits.storage} GB
</Typography.Text>
{/if}
</Table.Cell>
<Table.Cell column="manage" {root}></Table.Cell>
</Table.Row.Base>
</Table.Root>
</div>
</Layout.Stack>
{#if showSelectProject}
<Modal bind:show={showSelectProject} title="Manage projects" onSubmit={updateSelected}>
<svelte:fragment slot="description">
Choose which {freePlanLimits.projects} projects to keep. Projects over the limit will be
blocked after your billing cycle ends on {toLocaleDate(
$organization.billingNextInvoiceDate
)}.
</svelte:fragment>
{#if error}
<Alert.Inline status="error" title="Error">{error}</Alert.Inline>
{/if}
<div>
<Table.Root
let:root
allowSelection
bind:selectedRows={selectedProjects}
columns={[{ id: 'name' }, { id: 'created' }]}>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="name" {root}>Project Name</Table.Header.Cell>
<Table.Header.Cell column="created" {root}>Created</Table.Header.Cell>
</svelte:fragment>
{#each projects as project}
<Table.Row.Base {root} id={project.$id}>
<Table.Cell column="name" {root}
><Typography.Text truncate>{project.name}</Typography.Text></Table.Cell>
<Table.Cell column="created" {root}>
{toLocaleDateTime(project.$createdAt)}
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
</div>
{#if selectedProjects.length === allowedProjectsToKeep}
{@const difference = projects.length - selectedProjects.length}
{@const messagePrefix =
difference > 1 ? `${difference} projects` : `${difference} project`}
<Alert.Inline
status="warning"
title={`${messagePrefix} will be archived on ${toLocaleDate($organization.billingNextInvoiceDate)}`}>
{formatProjectsToArchive()} will be archived
</Alert.Inline>
{/if}
<svelte:fragment slot="footer">
<Button secondary on:click={() => (showSelectProject = false)}>Cancel</Button>
<Button submit disabled={selectedProjects.length !== allowedProjectsToKeep}
>Save</Button>
</svelte:fragment>
</Modal>
{/if}
<style>
/* Responsive table container */
.responsive-table {
overflow-x: auto;
width: 100%;
min-width: 0;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
position: relative;
z-index: 2;
}
/* Small viewport optimizations */
@media (max-width: 640px) {
.responsive-table {
margin-inline: -1rem;
padding-inline: 1rem;
z-index: 5;
}
.responsive-table :global(td),
.responsive-table :global(th) {
padding: 0.5rem 0.25rem;
font-size: 0.875rem;
}
.responsive-table :global(td:nth-child(1) .badge) {
white-space: nowrap;
flex-shrink: 0;
}
.responsive-table :global(td:nth-child(1) .layout-stack) {
flex-wrap: nowrap;
gap: 6px !important;
}
.responsive-table :global(th:nth-child(4)),
.responsive-table :global(td:nth-child(4)) {
width: 96px;
min-width: 96px;
}
}
</style>
+6 -1
View File
@@ -5,6 +5,7 @@
export let sum: number;
export let limit: number;
export let offset: number;
export let useCreateLink = true;
$: currentPage = Math.floor(offset / limit + 1);
@@ -18,6 +19,10 @@
return url.toString();
}
$: paginationProps = useCreateLink
? ({ type: 'link', createLink: getLink } as const)
: ({ type: 'button', createLink: undefined as never } as const);
</script>
<Pagination page={currentPage} total={sum} {limit} createLink={getLink} type="link" />
<Pagination on:page {limit} total={sum} page={currentPage} {...paginationProps} />
@@ -66,6 +66,7 @@
{:else}
<Layout.Stack direction="row" inline>
<Button.Button
type="button"
size="s"
variant="compact"
on:click={prev}
@@ -75,6 +76,7 @@
</Button.Button>
<Button.Button
type="button"
size="s"
variant="compact"
on:click={next}
+23 -7
View File
@@ -3,13 +3,29 @@
import Pagination from './pagination.svelte';
import { Layout } from '@appwrite.io/pink-svelte';
export let limit: number;
export let offset: number;
export let total: number;
export let name: string;
let {
limit,
offset,
total,
name,
useCreateLink = true
}: {
limit: number;
offset: number;
total: number;
name: string;
useCreateLink?: boolean;
} = $props();
const showLimit = $derived(!!useCreateLink);
const direction = $derived(showLimit ? 'row' : 'column');
const alignItems = $derived(showLimit ? 'center' : 'flex-end');
</script>
<Layout.Stack direction="row" alignItems="center" wrap="wrap" justifyContent="space-between">
<Limit {limit} sum={total} {name} />
<Pagination {limit} {offset} sum={total} />
<Layout.Stack wrap="wrap" {direction} {alignItems} justifyContent="space-between">
{#if showLimit}
<Limit {limit} sum={total} {name} />
{/if}
<Pagination on:page {limit} {offset} sum={total} {useCreateLink} />
</Layout.Stack>
+1 -1
View File
@@ -30,7 +30,7 @@
$: disabled = !value || $groups.has(value);
</script>
<Modal title="Label" bind:show on:close={reset} onSubmit={create}>
<Modal title="Label" bind:show on:close={reset} onSubmit={create} size="s">
<svelte:fragment slot="description">
Labels allow you to grant access to users with the specified label.
</svelte:fragment>
+2 -2
View File
@@ -29,13 +29,13 @@
if (role === 'user') {
const user = await sdk
.forProject(page.params.region, page.params.project)
.users.get(id);
.users.get({ userId: id });
return user;
}
if (role === 'team') {
const team = await sdk
.forProject(page.params.region, page.params.project)
.teams.get(id);
.teams.get({ teamId: id });
return team;
}
}
+4 -3
View File
@@ -45,9 +45,10 @@
async function request() {
if (!show) return;
isLoading = true;
results = await sdk
.forProject(page.params.region, page.params.project)
.teams.list([Query.limit(5), Query.offset(offset)], search || undefined);
results = await sdk.forProject(page.params.region, page.params.project).teams.list({
queries: [Query.limit(5), Query.offset(offset)],
search: search || undefined
});
isLoading = false;
}
+4 -3
View File
@@ -48,9 +48,10 @@
async function request() {
if (!show) return;
isLoading = true;
results = await sdk
.forProject(page.params.region, page.params.project)
.users.list([Query.limit(5), Query.offset(offset)], search || undefined);
results = await sdk.forProject(page.params.region, page.params.project).users.list({
queries: [Query.limit(5), Query.offset(offset)],
search: search || undefined
});
isLoading = false;
}
@@ -66,6 +66,7 @@
flex-direction: row;
gap: 2px;
margin-top: 1rem;
overflow: hidden;
}
&__content {
+6 -1
View File
@@ -11,7 +11,12 @@
$: flagSrc =
region && isValueOfStringEnum(Flag, region.flag)
? sdk.forConsole.avatars.getFlag(region.flag, 30, 20, 100)
? sdk.forConsole.avatars.getFlag({
code: region.flag,
width: 30,
height: 20,
quality: 100
})
: '';
</script>
+26 -10
View File
@@ -1,19 +1,29 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { trackEvent } from '$lib/actions/analytics';
import { Icon, Input } from '@appwrite.io/pink-svelte';
import { IconSearch, IconX } from '@appwrite.io/pink-icons-svelte';
import { debounce as createDebounce } from '$lib/helpers/debounce.js';
export let placeholder = '';
export let debounce = 250;
export let required = false;
export let disabled = false;
export let autofocus = false;
interface Props {
placeholder?: string;
debounce?: number;
required?: boolean;
disabled?: boolean;
autofocus?: boolean;
}
let inputValue = page.url.searchParams.get('search') ?? '';
let {
placeholder = '',
debounce = 250,
required = false,
disabled = false,
autofocus = false
}: Props = $props();
let inputValue = $state(page.url.searchParams.get('search') ?? '');
const runSearch = createDebounce((value: string) => {
const trimmed = value.trim();
@@ -36,15 +46,21 @@
goto(url, { keepFocus: true });
}, debounce);
$: runSearch(inputValue);
function clearInput() {
export function clearInput() {
inputValue = '';
}
onDestroy(() => {
runSearch.cancel?.();
});
$effect(() => {
const urlSearch = page.url.searchParams.get('search') ?? '';
if (urlSearch !== inputValue) {
runSearch(inputValue);
}
});
</script>
<div style:max-width="360px" style:width="100%">
+51 -14
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { page } from '$app/state';
import { page } from '$app/stores';
import { app } from '$lib/stores/app';
import { fade } from 'svelte/transition';
import {
Icon,
Sidebar,
@@ -34,12 +34,14 @@
import { showSupportModal } from '$routes/(console)/wizard/support/store';
import MobileSupportModal from '$routes/(console)/wizard/support/mobileSupportModal.svelte';
import MobileFeedbackModal from '$routes/(console)/wizard/feedback/mobileFeedbackModal.svelte';
import { getSidebarState, updateSidebarState } from '$lib/helpers/sidebar';
import { getSidebarState, isInDatabasesRoute, updateSidebarState } from '$lib/helpers/sidebar';
import { isTabletViewport, isSmallViewport } from '$lib/stores/viewport';
import { Click, trackEvent } from '$lib/actions/analytics';
import { bannerSpacing } from '$lib/layout/headerAlert.svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { Models } from '@appwrite.io/console';
import { noWidthTransition } from '$lib/stores/sidebar';
type $$Props = HTMLAttributes<HTMLElement> & {
state?: 'closed' | 'open' | 'icons';
@@ -71,8 +73,6 @@
}
}
$: state = $isTabletViewport ? 'closed' : getSidebarState();
const projectOptions = [
{ name: 'Auth', icon: IconUserGroup, slug: 'auth', category: 'build' },
{ name: 'Databases', icon: IconDatabase, slug: 'databases', category: 'build' },
@@ -89,12 +89,21 @@
];
const isSelected = (service: string): boolean => {
return page.route.id?.includes(service);
return $page.route.id?.includes(service);
};
$: state = $isTabletViewport
? 'closed'
: // example: manual resize
isInDatabasesRoute($page.route)
? 'icons'
: getSidebarState();
</script>
<div
class="sidebar"
class:only-mobile-tablet={!project}
class:no-transitions={$noWidthTransition}
style:--overlay-on-neutral={$app.themeInUse === 'dark'
? 'var(--neutral-750)'
: 'var(--neutral-100)'}>
@@ -240,7 +249,7 @@
<span slot="tooltip">{projectOption.name}</span>
</Tooltip>
{/each}
{#if project}
{#if project && $isSmallViewport}
<div class="mobile-tablet-settings">
<Tooltip placement="right" disabled={state !== 'icons'}>
<a
@@ -353,13 +362,24 @@
</Sidebar.Base>
</div>
{#if subNavigation}
<div class="sub-navigation" class:icons={state === 'icons'}>
<svelte:component this={subNavigation} />
</div>
{/if}
<div style:--banner-spacing={$bannerSpacing ? $bannerSpacing : undefined}>
{#if subNavigation}
<div
class="sub-navigation"
class:icons={state === 'icons'}
class:no-transitions={$noWidthTransition}>
<svelte:component this={subNavigation} />
</div>
{/if}
</div>
<style lang="scss">
.sidebar {
&.no-transitions :global(nav) {
transition: none !important;
}
}
.middle-container {
flex: 1;
overflow-y: visible;
@@ -571,6 +591,7 @@
@media (min-width: 1024px) {
width: 166px;
}
.info {
position: absolute;
display: flex;
@@ -608,8 +629,9 @@
.sub-navigation {
margin-top: 48px;
@media (min-width: 1024px) {
margin-top: 0;
margin-top: var(--banner-spacing, 0px);
width: 400px;
height: calc(100vh - 48px);
display: flex;
@@ -617,12 +639,27 @@
background-color: var(--bgcolor-neutral-primary, #fff);
z-index: 14;
position: fixed;
top: 48px;
top: var(--banner-spacing, 48px);
transition: width 0.2s linear;
&.icons {
width: 266px;
transition: width 0.3s linear;
&.no-transitions {
transition: none !important;
}
& :global(nav) {
margin-top: var(--banner-spacing);
}
}
}
/* when in small or tablet viewport and there's a banner */
@media (max-width: 1023px) {
& :global(header) {
margin-top: var(--banner-spacing);
}
}
}
+99
View File
@@ -0,0 +1,99 @@
<script module lang="ts">
export type SortDirection = 'asc' | 'desc' | 'default';
</script>
<script lang="ts">
import { Query } from '@appwrite.io/console';
import { Button } from '$lib/elements/forms';
import { Icon } from '@appwrite.io/pink-svelte';
import { IconChevronDown, IconChevronUp, IconSelector } from '@appwrite.io/pink-icons-svelte';
import type { Writable } from 'svelte/store';
import type { SortState } from '$routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/store';
let {
column,
state,
onSort = () => {},
disabled = false
}: {
column: string;
disabled?: boolean;
onSort?: (query: string | null) => void | Promise<void>;
state?: Writable<SortState>;
} = $props();
function sort() {
const isCurrent = $state.column === column;
const nextDir: SortDirection = !isCurrent
? 'asc'
: $state.direction === 'asc'
? 'desc'
: $state.direction === 'desc'
? 'default'
: 'asc';
state?.set({
direction: nextDir,
column: nextDir === 'default' ? null : column
});
if (nextDir === 'default') {
onSort?.(null);
} else if (nextDir === 'asc') {
onSort?.(Query.orderAsc(column));
} else {
onSort?.(Query.orderDesc(column));
}
}
const direction = $derived(
$state?.column === column && $state?.direction !== 'default' ? $state?.direction : ''
);
</script>
<Button extraCompact on:click={sort} class="hoverable-compact {direction}" {disabled}>
<Icon
size="s"
icon={$state?.column !== column || $state?.direction === 'default'
? IconSelector
: $state?.direction === 'asc'
? IconChevronUp
: IconChevronDown}
color="--fgcolor-neutral-weak" />
</Button>
<style lang="scss">
:global(.hoverable-compact) {
width: 20px;
height: 20px;
--p-button-padding-inline: var(--space-0);
&:hover {
background-color: var(--bgcolor-neutral-secondary);
border: var(--border-width-s) solid var(--bgcolor-neutral-secondary);
& :global(i) {
--icon-fill: var(--fgcolor-neutral-secondary) !important;
}
}
&:active {
background-color: var(--bgcolor-neutral-tertiary);
border: var(--border-width-s) solid var(--bgcolor-neutral-tertiary);
}
&:disabled {
& :global(i) {
--icon-fill: var(--fgcolor-neutral-secondary) !important;
}
}
}
:global(.hoverable-compact.asc),
:global(.hoverable-compact.desc) {
background-color: var(--bgcolor-neutral-secondary) !important;
& :global(i) {
--icon-fill: var(--fgcolor-neutral-secondary) !important;
}
}
</style>
+66 -9
View File
@@ -6,23 +6,73 @@
import { IconViewBoards } from '@appwrite.io/pink-icons-svelte';
import ViewToggle from './viewToggle.svelte';
import ColumnSelector from './columnSelector.svelte';
import { isSmallViewport } from '$lib/stores/viewport';
import { preferences } from '$lib/stores/preferences';
import { page } from '$app/state';
import { hash } from '$lib/helpers/string';
export let columns: Writable<Column[]>;
export let view: View;
export let isCustomCollection = false;
export let hideView = false;
export let hideColumns = false;
export let allowNoColumns = false;
interface Props {
onlyIcon?: boolean;
ui?: 'legacy' | 'new';
columns: Writable<Column[]>;
view: View;
isCustomTable?: boolean;
hideView?: boolean;
hideColumns?: boolean;
allowNoColumns?: boolean;
showAnyway?: boolean;
}
let {
onlyIcon = false,
ui = 'legacy',
columns,
view = $bindable(),
isCustomTable = false,
hideView = false,
hideColumns = false,
allowNoColumns = false,
showAnyway = false
}: Props = $props();
let showCountBadge = $state(false);
const preferenceKey = $derived(getPreferenceKey());
function getPreferenceKey(): string {
const tableId = page.params.table;
const organizationId = page.data.organization?.$id ?? page.data.project?.teamId;
return hash([organizationId, tableId].filter(Boolean).join('#'));
}
function updateBadgeState() {
if (!preferences.getKey(preferenceKey, false)) {
showCountBadge = true;
preferences.setKey(preferenceKey, true);
}
}
$effect(() => {
showCountBadge = !onlyIcon || !!preferences.getKey(preferenceKey, false);
});
</script>
{#if !hideColumns && view === View.Table}
<ColumnSelector {columns} {isCustomCollection} {allowNoColumns}>
<ColumnSelector
{ui}
{columns}
{showAnyway}
{isCustomTable}
{allowNoColumns}
onPreferencesUpdated={updateBadgeState}>
{#snippet children(toggle, selectedColumnsNumber)}
<Button.Button
size="s"
icon={onlyIcon}
onclick={toggle}
variant="secondary"
badge={selectedColumnsNumber.toString()}
on:click={toggle}>
disabled={!$columns.length && showAnyway}
class={onlyIcon && !$isSmallViewport ? 'width-fix' : undefined}
badge={showCountBadge ? selectedColumnsNumber.toString() : undefined}>
<Icon slot="start" icon={IconViewBoards} />
</Button.Button>
{/snippet}
@@ -32,3 +82,10 @@
{#if !hideView}
<ViewToggle bind:view />
{/if}
<style>
:global(.width-fix) {
width: 32px;
height: 32px;
}
</style>
+54 -9
View File
@@ -1,4 +1,5 @@
export const PAGE_LIMIT = 12; // default page limit
export const SPREADSHEET_PAGE_LIMIT = 50; // default sheet page limit
export const CARD_LIMIT = 6; // default card limit
export const INTERVAL = 5 * 60000; // default interval to check for feedback
export const NEW_DEV_PRO_UPGRADE_COUPON = 'appw50';
@@ -6,10 +7,12 @@ export const NEW_DEV_PRO_UPGRADE_COUPON = 'appw50';
export const REGION_FRA = 'fra';
export const REGION_SYD = 'syd';
export const REGION_NYC = 'nyc';
export const REGION_SFO = 'sfo';
export const SUBDOMAIN_FRA = 'fra.';
export const SUBDOMAIN_SYD = 'syd.';
export const SUBDOMAIN_NYC = 'nyc.';
export const SUBDOMAIN_SFO = 'sfo.';
export enum Dependencies {
FACTORS = 'dependency:factors',
@@ -38,9 +41,9 @@ export enum Dependencies {
MEMBERSHIPS = 'dependency:memberships',
DATABASE = 'dependency:database',
DATABASES = 'dependency:databases',
COLLECTION = 'dependency:collection',
DOCUMENT = 'dependency:document',
DOCUMENTS = 'dependency:documents',
TABLE = 'dependency:table',
ROW = 'dependency:row',
ROWS = 'dependency:rows',
BUCKET = 'dependency:bucket',
FILE = 'dependency:file',
FILE_TOKENS = 'dependency:file_tokens',
@@ -64,7 +67,7 @@ export enum Dependencies {
WEBHOOK = 'dependency:webhook',
WEBHOOKS = 'dependency:webhooks',
MIGRATIONS = 'dependency:migrations',
COLLECTIONS = 'dependency:collections',
TABLES = 'dependency:tables',
BACKUPS = 'dependency:backups',
RUNTIMES = 'dependency:runtimes',
CONSOLE_VARIABLES = 'dependency:console_variables',
@@ -92,6 +95,8 @@ export const defaultScopes: string[] = [
'teams.write',
'documents.read',
'documents.write',
'rows.read',
'rows.write',
'files.read',
'files.write',
'projects.read',
@@ -111,6 +116,8 @@ export const defaultScopes: string[] = [
'databases.write',
'collections.read',
'collections.write',
'tables.read',
'tables.write',
'buckets.read',
'buckets.write',
'functions.read',
@@ -141,6 +148,8 @@ export const defaultScopes: string[] = [
export const defaultRoles: string[] = ['owner'];
// these are kept for backwards compatibility with keys and events that already exists.
// for the new ones, we use the new terminology.
export const scopes: {
scope: string;
description: string;
@@ -201,6 +210,18 @@ export const scopes: {
category: 'Database',
icon: 'database'
},
{
scope: 'tables.read',
description: "Access to read your project's database tables",
category: 'Database',
icon: 'database'
},
{
scope: 'tables.write',
description: "Access to create, update, and delete your project's database tables",
category: 'Database',
icon: 'database'
},
{
scope: 'attributes.read',
description: "Access to read your project's database collection's attributes",
@@ -214,6 +235,18 @@ export const scopes: {
category: 'Database',
icon: 'database'
},
{
scope: 'columns.read',
description: "Access to read your project's database table's columns",
category: 'Database',
icon: 'database'
},
{
scope: 'columns.write',
description: "Access to create, update, and delete your project's database table's columns",
category: 'Database',
icon: 'database'
},
{
scope: 'indexes.read',
description: "Access to read your project's database collection's indexes",
@@ -239,6 +272,18 @@ export const scopes: {
category: 'Database',
icon: 'database'
},
{
scope: 'rows.read',
description: "Access to read your project's database rows",
category: 'Database',
icon: 'database'
},
{
scope: 'rows.write',
description: "Access to create, update, and delete your project's database rows",
category: 'Database',
icon: 'database'
},
{
scope: 'files.read',
description: "Access to read your project's storage files and preview images",
@@ -430,7 +475,7 @@ export type EventResource = {
export type EventAction = {
name: string;
attributes?: string[];
columns?: string[];
};
export const eventServices: Array<EventService> = [
@@ -448,11 +493,11 @@ export const eventServices: Array<EventService> = [
name: 'databases',
resources: [
{
name: 'collections',
name: 'tables',
actions: [{ name: 'create' }, { name: 'update' }, { name: 'delete' }]
},
{
name: 'documents',
name: 'rows',
actions: [{ name: 'create' }, { name: 'update' }, { name: 'delete' }]
}
],
@@ -479,7 +524,7 @@ export const eventServices: Array<EventService> = [
name: 'memberships',
actions: [
{ name: 'create' },
{ name: 'update', attributes: ['status'] },
{ name: 'update', columns: ['status'] },
{ name: 'delete' }
]
}
@@ -495,7 +540,7 @@ export const eventServices: Array<EventService> = [
],
actions: [
{ name: 'create' },
{ name: 'update', attributes: ['email', 'name', 'password', 'status', 'prefs'] },
{ name: 'update', columns: ['email', 'name', 'password', 'status', 'prefs'] },
{ name: 'delete' }
]
},
+6 -1
View File
@@ -14,7 +14,12 @@
export function getFlag(country: string, width: number, height: number, quality: number) {
if (!isValueOfStringEnum(Flag, country)) return '';
return sdk.forConsole.avatars
.getFlag(country, width * 2, height * 2, quality)
.getFlag({
code: country,
width: width * 2,
height: height * 2,
quality
})
?.toString()
?.replace('&project=console', '&mode=admin');
}
+315
View File
@@ -0,0 +1,315 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { ID } from '@appwrite.io/console';
import { debounce } from '$lib/helpers/debounce';
import { Badge } from '@appwrite.io/pink-svelte';
let {
min = 1,
max = 100,
maxAllowed = max,
value = $bindable(min),
breakPoints = [],
autoBreakpoints = true,
breakpointCount = 7,
snapToBreakpoints = true,
snapThreshold = 3,
disabled = false,
id = ID.unique(),
onchange
}: {
min?: number;
max?: number;
maxAllowed?: number;
value?: number;
breakPoints?: number[];
autoBreakpoints?: boolean;
breakpointCount?: number;
snapToBreakpoints?: boolean;
snapThreshold?: number;
disabled?: boolean;
id?: string;
onchange?: (value: number) => void;
} = $props();
let isDragging = $state(false);
let trackRef = $state<HTMLDivElement>();
const range = $derived(max - min);
const clampedValue = $derived(Math.min(Math.max(value, min), maxAllowed));
const relativeValue = $derived(clampedValue - min);
const percentage = $derived((relativeValue / max) * 100);
const maxAllowedPercentage = $derived(((maxAllowed - min) / range) * 100);
const calculatedBreakpoints = $derived(() => {
if (!autoBreakpoints) return breakPoints;
const points = [];
if (breakpointCount === 1) {
points.push(min);
} else {
points.push(min);
if (breakpointCount > 2) {
const step = range / (breakpointCount - 1);
for (let i = 1; i < breakpointCount - 1; i++) {
const rawValue = min + i * step;
let roundedValue: number;
if (range <= 10) {
roundedValue = Math.round(rawValue);
} else if (range <= 100) {
roundedValue = Math.round(rawValue / 5) * 5;
} else if (range <= 1000) {
roundedValue = Math.round(rawValue / 10) * 10;
} else {
roundedValue = Math.round(rawValue / 25) * 25;
}
points.push(Math.max(min + 1, Math.min(max - 1, roundedValue)));
}
}
points.push(max);
}
return points;
});
const exactBreakpoints = $derived(() => {
if (!autoBreakpoints) return breakPoints;
return calculatedBreakpoints();
});
const debouncedOnChange = debounce((newValue: number) => {
onchange?.(Math.round(newValue));
}, 150);
function findClosestBreakpoint(value: number): number | null {
if (!snapToBreakpoints || calculatedBreakpoints().length === 0) return null;
let closest = null;
let closestDistance = Infinity;
for (const breakpoint of calculatedBreakpoints()) {
const distance = Math.abs(value - breakpoint);
const distancePercentage = (distance / range) * 100;
if (distancePercentage <= snapThreshold && distance < closestDistance) {
closest = breakpoint;
closestDistance = distance;
}
}
return closest;
}
function handleMouseDown(event: MouseEvent) {
if (disabled) return;
isDragging = true;
updateValue(event);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
event.preventDefault();
}
function handleMouseMove(event: MouseEvent) {
if (!isDragging) return;
updateValue(event);
}
function handleMouseUp() {
isDragging = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}
function handleTrackClick(event: MouseEvent) {
if (disabled || isDragging) return;
updateValue(event);
}
function updateValue(event: MouseEvent) {
if (!trackRef) return;
const rect = trackRef.getBoundingClientRect();
const clickX = event.clientX - rect.left;
const newPercentage = Math.max(0, Math.min(100, (clickX / rect.width) * 100));
let newValue = min + (newPercentage / 100) * (max - min);
const snapPoint = findClosestBreakpoint(newValue);
if (snapPoint !== null) {
newValue = snapPoint;
}
const clampedNewValue = Math.max(min, Math.min(newValue, maxAllowed));
const roundedValue = Math.round(clampedNewValue);
if (roundedValue !== value) {
value = roundedValue;
debouncedOnChange(value);
}
}
onDestroy(() => {
if (isDragging) {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}
});
</script>
<div class="seekbar">
<div class="track" role="presentation" bind:this={trackRef} onclick={handleTrackClick}>
{#if maxAllowed < max}
<div
class="disabled-area"
style:left="{maxAllowedPercentage}%"
style:width="{100 - maxAllowedPercentage}%">
</div>
{/if}
<div class="progress" style:--progress-width="{percentage}%"></div>
<div class="thumb-container" style:left="{percentage}%">
<div class="badge">
<Badge variant="secondary" size="xs" content={Math.ceil(clampedValue).toString()} />
</div>
<div
{id}
role="slider"
class="thumb"
tabindex={disabled ? -1 : 0}
aria-valuemin="0"
aria-valuemax={maxAllowed}
aria-valuenow={Math.ceil(clampedValue)}
onmousedown={handleMouseDown}
onmouseenter={() => !disabled}
onmouseleave={() => !isDragging}>
</div>
</div>
</div>
{#if exactBreakpoints().length > 0}
<div class="breakpoints">
{#each exactBreakpoints() as breakPoint, index (index)}
{@const breakPercentage = ((breakPoint - min) / range) * 100}
<div
class="breakpoint"
class:is-major={index === 0 ||
index === exactBreakpoints().length - 1 ||
index === Math.floor(exactBreakpoints().length / 2)}
style:left="{breakPercentage}%">
</div>
{/each}
</div>
{/if}
</div>
<style lang="scss">
:root {
--seekbar-height: 4px;
--seekbar-thumb-size: 16px;
--seekbar-thumb-radius: 50%;
--seekbar-track-radius: 13px;
--seekbar-breakpoint-height-small: 6px;
--seekbar-breakpoint-height-medium: 6px;
--seekbar-breakpoint-height-major: 12px;
--seekbar-thumb-color: var(--bgcolor-neutral-primary);
--seekbar-track-color: var(--bgcolor-neutral-tertiary);
--seekbar-progress-color: var(--bgcolor-neutral-invert);
--seekbar-thumb-border-color: var(--fgcolor-accent-neutral);
--seekbar-breakpoint-color: var(--bgcolor-neutral-invert-weaker);
// experimental, disabled seekbar progress color
--seekbar-disabled-area-color: var(--overlay-neutral-pressed);
}
.seekbar {
width: 100%;
position: relative;
padding: 1.25rem 0 0.75rem 0;
}
.breakpoints {
position: absolute;
top: calc(1.25rem + var(--seekbar-height) + 8px);
left: 0;
right: 0;
height: 12px;
pointer-events: none;
}
.breakpoint {
position: absolute;
top: 0;
transform: translateX(-50%);
width: 1px;
height: var(--seekbar-breakpoint-height-small);
background-color: var(--seekbar-breakpoint-color);
&.is-major {
height: var(--seekbar-breakpoint-height-major);
}
}
.track {
position: relative;
height: var(--seekbar-height);
background-color: var(--seekbar-track-color);
border-radius: var(--seekbar-track-radius);
cursor: pointer;
}
.disabled-area {
top: 0;
height: 100%;
position: absolute;
border-top-right-radius: var(--seekbar-track-radius);
border-bottom-right-radius: var(--seekbar-track-radius);
background-color: var(--seekbar-disabled-area-color);
}
.progress {
top: 0;
left: 0;
height: 100%;
position: absolute;
width: var(--progress-width);
background-color: var(--seekbar-progress-color);
border-radius: var(--seekbar-track-radius);
}
.thumb-container {
top: 50%;
position: absolute;
transform: translateX(-50%) translateY(-50%);
}
.badge {
left: 50%;
position: absolute;
bottom: calc(100% + 8px);
transform: translateX(-50%);
:global(.badge) {
color: var(--fgcolor-on-invert) !important;
background: var(--bgcolor-neutral-invert) !important;
}
}
.thumb {
width: var(--seekbar-thumb-size);
height: var(--seekbar-thumb-size);
border-radius: var(--seekbar-thumb-radius);
background-color: var(--seekbar-thumb-color);
border: 1px solid var(--seekbar-thumb-border-color);
cursor: grab;
}
</style>
+1
View File
@@ -30,3 +30,4 @@ export { default as InputTime } from './inputTime.svelte';
export { default as InputDigits } from './inputDigits.svelte';
export { default as InputSelectCheckbox } from './inputSelectCheckbox.svelte';
export { default as InputFilePicker } from './inputFilePicker.svelte';
export { default as Seekbar } from './Seekbar.svelte';
+1
View File
@@ -31,6 +31,7 @@
maxlength={36}
autocomplete="off"
bind:value
on:input
on:invalid={handleInvalid}
state={error ? 'error' : 'default'}
helper="Allowed characters: alphanumeric, non-leading hyphen, underscore, period" />
+1 -16
View File
@@ -18,19 +18,6 @@
let error: string;
function coerceToNumber(event: Event & { currentTarget: EventTarget & HTMLInputElement }) {
const raw = event.currentTarget?.value ?? '';
if (raw === '') {
value = nullable ? null : (undefined as unknown as number);
return;
}
const parsed = Number(raw);
if (Number.isFinite(parsed)) {
value = parsed;
}
}
const handleInvalid = (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
event.preventDefault();
@@ -73,9 +60,7 @@
autofocus={autofocus || undefined}
helper={error || helper}
state={error ? 'error' : 'default'}
on:invalid={handleInvalid}
on:input={coerceToNumber}
on:change={coerceToNumber}>
on:invalid={handleInvalid}>
<svelte:fragment slot="info">
<slot name="info" slot="info" />
</svelte:fragment>
@@ -30,6 +30,7 @@
maxlength={36}
autocomplete="off"
bind:value
on:input
on:invalid={handleInvalid}
state={error ? 'error' : 'default'}
helper="Allowed characters: lowercase alphanumeric and non-leading hyphen" />
@@ -6,6 +6,7 @@
export let label: string | undefined = undefined;
export let value: string | number | boolean | null;
export let helper: string | undefined = undefined;
export let autofocus: boolean | undefined = undefined;
export let optionalText: string | number | boolean | null | undefined = undefined;
export let placeholder = '';
export let required = false;
@@ -54,6 +55,7 @@
{optionalText}
{placeholder}
{disabled}
{autofocus}
helper={error ?? helper}
{required}
state={error ? 'error' : 'default'}
@@ -51,4 +51,5 @@
on:input
bind:value>
<slot name="info" slot="info" />
<slot name="end" slot="end" />
</Input.Textarea>
+2 -4
View File
@@ -5,6 +5,7 @@ import type { Organization } from './stores/organization';
// Parse feature flags from env as a string array (exact match only)
const flagsRaw = (env.PUBLIC_CONSOLE_FEATURE_FLAGS ?? '').split(',');
// @ts-expect-error: unused method!
function isFlagEnabled(name: string) {
// loose generic to allow safe access while retaining type safety
return <T extends { account?: Account; organization?: Organization }>(data: T) => {
@@ -18,7 +19,4 @@ function isFlagEnabled(name: string) {
};
}
export const flags = {
showSites: isFlagEnabled('sites'),
showIndexLengths: isFlagEnabled('index-lengths')
};
export const flags = {};
+8
View File
@@ -102,3 +102,11 @@ export function total(set: Array<number>): number {
if (!set) return 0;
return set.reduce((prev, curr) => prev + curr, 0);
}
export function chunks<T>(array: T[], size: number = 10): T[][] {
const output: T[][] = [];
for (let i = 0; i < array.length; i += size) {
output.push(array.slice(i, i + size));
}
return output;
}
+138 -138
View File
@@ -4,188 +4,188 @@ import type { Models } from '@appwrite.io/console';
export async function createRecord(record: Partial<Models.DnsRecord>, domainId: string) {
switch (record.type) {
case 'A':
return await sdk.forConsole.domains.createRecordA(
return await sdk.forConsole.domains.createRecordA({
domainId,
record.name,
record.value,
record.ttl,
record?.comment || undefined
);
name: record.name!,
value: record.value!,
ttl: record.ttl!,
comment: record?.comment || undefined
});
case 'AAAA':
return await sdk.forConsole.domains.createRecordAAAA(
return await sdk.forConsole.domains.createRecordAAAA({
domainId,
record.name,
record.value,
record.ttl,
record?.comment || undefined
);
name: record.name!,
value: record.value!,
ttl: record.ttl!,
comment: record?.comment || undefined
});
case 'CNAME':
return await sdk.forConsole.domains.createRecordCNAME(
return await sdk.forConsole.domains.createRecordCNAME({
domainId,
record.name,
record.value,
record.ttl,
record?.comment || undefined
);
name: record.name!,
value: record.value!,
ttl: record.ttl!,
comment: record?.comment || undefined
});
case 'MX':
return await sdk.forConsole.domains.createRecordMX(
return await sdk.forConsole.domains.createRecordMX({
domainId,
record.name,
record.value,
record.ttl,
record.priority || 10,
record?.comment || undefined
);
name: record.name!,
value: record.value!,
ttl: record.ttl!,
priority: record.priority || 10,
comment: record?.comment || undefined
});
case 'TXT':
return await sdk.forConsole.domains.createRecordTXT(
return await sdk.forConsole.domains.createRecordTXT({
domainId,
record.name,
record.ttl,
record.value,
record?.comment || undefined
);
name: record.name!,
ttl: record.ttl!,
value: record.value!,
comment: record?.comment || undefined
});
case 'NS':
return await sdk.forConsole.domains.createRecordNS(
return await sdk.forConsole.domains.createRecordNS({
domainId,
record.name,
record.value,
record.ttl,
record?.comment || undefined
);
name: record.name!,
value: record.value!,
ttl: record.ttl!,
comment: record?.comment || undefined
});
case 'SRV':
return await sdk.forConsole.domains.createRecordSRV(
return await sdk.forConsole.domains.createRecordSRV({
domainId,
record.name,
record.value,
record.ttl,
record.priority,
record.weight,
record.port,
record?.comment || undefined
);
name: record.name!,
value: record.value!,
ttl: record.ttl!,
priority: record.priority!,
weight: record.weight!,
port: record.port!,
comment: record?.comment || undefined
});
case 'CAA':
return await sdk.forConsole.domains.createRecordCAA(
return await sdk.forConsole.domains.createRecordCAA({
domainId,
record.name,
record.value,
record.ttl,
record?.comment || undefined
);
name: record.name!,
value: record.value!,
ttl: record.ttl!,
comment: record?.comment || undefined
});
case 'HTTPS':
return await sdk.forConsole.domains.createRecordHTTPS(
return await sdk.forConsole.domains.createRecordHTTPS({
domainId,
record.name,
record.value,
record.ttl,
record?.comment || undefined
);
name: record.name!,
value: record.value!,
ttl: record.ttl!,
comment: record?.comment || undefined
});
case 'ALIAS':
return await sdk.forConsole.domains.createRecordAlias(
return await sdk.forConsole.domains.createRecordAlias({
domainId,
record.name,
record.value,
record.ttl,
record?.comment || undefined
);
name: record.name!,
value: record.value!,
ttl: record.ttl!,
comment: record?.comment || undefined
});
}
}
export async function updateRecord(record: Partial<Models.DnsRecord>, domainId: string) {
switch (record.type) {
case 'A':
return await sdk.forConsole.domains.updateRecordA(
return await sdk.forConsole.domains.updateRecordA({
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.comment
);
recordId: record.$id!,
name: record.name!,
value: record.value!,
ttl: record.ttl!,
comment: record.comment || undefined
});
case 'AAAA':
return await sdk.forConsole.domains.updateRecordAAAA(
return await sdk.forConsole.domains.updateRecordAAAA({
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.comment
);
recordId: record.$id!,
name: record.name!,
value: record.value!,
ttl: record.ttl!,
comment: record.comment || undefined
});
case 'CNAME':
return await sdk.forConsole.domains.updateRecordCNAME(
return await sdk.forConsole.domains.updateRecordCNAME({
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.comment
);
recordId: record.$id!,
name: record.name!,
value: record.value!,
ttl: record.ttl!,
comment: record.comment || undefined
});
case 'MX':
return await sdk.forConsole.domains.updateRecordMX(
return await sdk.forConsole.domains.updateRecordMX({
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.priority,
record.comment
);
recordId: record.$id!,
name: record.name!,
value: record.value!,
ttl: record.ttl!,
priority: record.priority!,
comment: record.comment || undefined
});
case 'TXT':
return await sdk.forConsole.domains.updateRecordTXT(
return await sdk.forConsole.domains.updateRecordTXT({
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.comment
);
recordId: record.$id!,
name: record.name!,
value: record.value!,
ttl: record.ttl!,
comment: record.comment || undefined
});
case 'NS':
return await sdk.forConsole.domains.updateRecordNS(
return await sdk.forConsole.domains.updateRecordNS({
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.comment
);
recordId: record.$id!,
name: record.name!,
value: record.value!,
ttl: record.ttl!,
comment: record.comment || undefined
});
case 'SRV':
return await sdk.forConsole.domains.updateRecordSRV(
return await sdk.forConsole.domains.updateRecordSRV({
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.priority,
record.weight,
record.port,
record?.comment || undefined
);
recordId: record.$id!,
name: record.name!,
value: record.value!,
ttl: record.ttl!,
priority: record.priority!,
weight: record.weight!,
port: record.port!,
comment: record.comment || undefined
});
case 'CAA':
return await sdk.forConsole.domains.updateRecordCAA(
return await sdk.forConsole.domains.updateRecordCAA({
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.comment
);
recordId: record.$id!,
name: record.name!,
value: record.value!,
ttl: record.ttl!,
comment: record.comment || undefined
});
case 'HTTPS':
return await sdk.forConsole.domains.updateRecordHTTPS(
return await sdk.forConsole.domains.updateRecordHTTPS({
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.comment
);
recordId: record.$id!,
name: record.name!,
value: record.value!,
ttl: record.ttl!,
comment: record.comment || undefined
});
case 'ALIAS':
return await sdk.forConsole.domains.updateRecordAlias(
return await sdk.forConsole.domains.updateRecordAlias({
domainId,
record.$id,
record.name,
record.value,
record.ttl,
record.comment
);
recordId: record.$id!,
name: record.name!,
value: record.value!,
ttl: record.ttl!,
comment: record.comment || undefined
});
}
}
+199
View File
@@ -0,0 +1,199 @@
import { faker } from '@faker-js/faker';
import type { Columns } from '$routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/store';
import { ID, type Models } from '@appwrite.io/console';
import { sdk } from '$lib/stores/sdk';
import { isWithinSafeRange } from '$lib/helpers/numbers';
export async function generateColumns(
project: Models.Project,
databaseId: string,
tableId: string
): Promise<Columns[]> {
const client = sdk.forProject(project.region, project.$id);
return await Promise.all([
client.tablesDB.createStringColumn({
databaseId,
tableId,
key: 'name',
size: 255,
required: false
}),
client.tablesDB.createEmailColumn({ databaseId, tableId, key: 'email', required: false }),
client.tablesDB.createIntegerColumn({
databaseId,
tableId,
key: 'age',
required: false,
min: 18,
max: 80
}),
client.tablesDB.createStringColumn({
databaseId,
tableId,
key: 'city',
size: 100,
required: false
}),
client.tablesDB.createStringColumn({
databaseId,
tableId,
key: 'description',
size: 1000,
required: false
}),
client.tablesDB.createBooleanColumn({ databaseId, tableId, key: 'active', required: false })
]);
}
export function generateFakeRecords(
columns: Columns[],
count: number
): {
ids: string[];
rows: Models.Row[];
} {
if (count <= 0) return { ids: [], rows: [] };
const filteredColumns = columns.filter(
(col) => col.type !== 'relationship' && col.status === 'available'
);
const ids: string[] = [];
const rows: Models.Row[] = [];
for (let i = 0; i < count; i++) {
const id = ID.unique();
ids.push(id);
let row: Record<string, string | number | boolean | Array<string | number | boolean>> = {
$id: id
};
if (filteredColumns.length === 0) {
row = {
$id: id,
name: faker.person.fullName(),
email: faker.internet.email(),
age: faker.number.int({ min: 18, max: 80 }),
city: faker.location.city(),
description: faker.lorem.sentence(),
active: faker.datatype.boolean()
};
} else {
for (const column of filteredColumns) {
row[column.key] = generateValueForColumn(column);
}
}
rows.push(row as unknown as Models.Row);
}
return { ids, rows };
}
function generateStringValue(key: string, maxLength: number): string {
const lowerKey = key.toLowerCase();
const generators: Record<string, () => string> = {
name: () => faker.person.fullName(),
email: () => faker.internet.email(),
title: () => faker.lorem.words({ min: 1, max: 3 }),
description: () => faker.lorem.paragraph(),
content: () => faker.lorem.paragraph(),
address: () => faker.location.streetAddress(),
city: () => faker.location.city(),
country: () => faker.location.country(),
company: () => faker.company.name()
};
const matchingKey = Object.keys(generators).find((pattern) => lowerKey.includes(pattern));
const text = matchingKey ? generators[matchingKey]() : faker.lorem.words({ min: 1, max: 5 });
return text.length <= maxLength ? text : text.substring(0, maxLength);
}
function generateValueForColumn(
column: Columns
): string | number | boolean | null | Array<string | number | boolean> {
if (column.array) {
const arraySize = faker.number.int({ min: 1, max: 5 });
const items: Array<string | number | boolean> = [];
for (let i = 0; i < arraySize; i++) {
const itemAttribute = { ...column, array: false };
const item = generateSingleValue(itemAttribute);
if (item !== null) {
items.push(item);
}
}
return items;
}
return generateSingleValue(column);
}
function generateSingleValue(column: Columns): string | number | boolean | null {
switch (column.type) {
case 'string': {
if ('format' in column && column.format) {
switch (column.format) {
case 'email': {
return faker.internet.email();
}
case 'ip': {
return faker.internet.ip();
}
case 'url': {
return faker.internet.url();
}
case 'enum': {
const enumAttr = column as Models.ColumnEnum;
if (enumAttr.elements?.length > 0) {
return faker.helpers.arrayElement(enumAttr.elements);
}
return null;
}
}
return '';
} else {
const stringAttr = column as Models.ColumnString;
const maxLength = Math.min(stringAttr.size ?? 255, 1000);
return generateStringValue(column.key, maxLength);
}
}
case 'integer': {
const intAttr = column as Models.ColumnInteger;
const min = !isWithinSafeRange(intAttr.min) ? 0 : intAttr.min;
const max = !isWithinSafeRange(intAttr.max) ? 100 : intAttr.max;
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 precision = 4;
return faker.number.float({ min, max, fractionDigits: precision });
}
case 'boolean': {
return faker.datatype.boolean();
}
case 'datetime': {
return faker.date.recent({ days: 365 }).toISOString();
}
default: {
return faker.lorem.word();
}
}
}
+3 -1
View File
@@ -4,5 +4,7 @@ import { sdk } from '$lib/stores/sdk';
export function getFlagUrl(countryCode: string) {
if (!isValueOfStringEnum(Flag, countryCode)) return '';
return sdk.forConsole.avatars.getFlag(countryCode, 22, 15, 100)?.toString();
return sdk.forConsole.avatars
.getFlag({ code: countryCode, width: 22, height: 15, quality: 100 })
?.toString();
}
+5 -3
View File
@@ -10,7 +10,9 @@ export function getPage(url: URL): number {
}
export function getLimit(url: URL, route: Page['route'], fallback: number): number {
return Number(url.searchParams.get('limit') ?? preferences.get(route).limit ?? fallback);
return Number(
url.searchParams.get('limit') ?? preferences.getForRoute(route).limit ?? fallback
);
}
export enum View {
@@ -19,7 +21,7 @@ export enum View {
}
export function getView(url: URL, route: Page['route'], fallback: View, overwrite?: View): View {
const view = url.searchParams.get('view') ?? preferences.get(route).view;
const view = url.searchParams.get('view') ?? preferences.getForRoute(route).view;
return view === View.Grid
? View.Grid
@@ -29,7 +31,7 @@ export function getView(url: URL, route: Page['route'], fallback: View, overwrit
}
export function getColumns(route: Page['route'], fallback: string[]): string[] {
return preferences.get(route).columns ?? fallback;
return preferences.getForRoute(route).columns ?? fallback;
}
export function getSearch(url: URL): string | undefined {
+1 -1
View File
@@ -6,7 +6,7 @@ import { base } from '$app/paths';
import { uploader } from '$lib/stores/uploader';
export async function logout(redirect: boolean = true) {
await sdk.forConsole.account.deleteSession('current');
await sdk.forConsole.account.deleteSession({ sessionId: 'current' });
await invalidate(Dependencies.ACCOUNT);
uploader.reset();
trackEvent(Submit.AccountLogout);
+1 -1
View File
@@ -35,7 +35,7 @@ function updateNotificationPrefs(parsedPrefs: Record<string, NotificationPrefIte
notificationPrefs: parsedPrefs
};
sdk.forConsole.account.updatePrefs(newPrefs);
sdk.forConsole.account.updatePrefs({ prefs: newPrefs });
}
/**
+4
View File
@@ -32,3 +32,7 @@ export function formatCurrency(number: number, locale = 'en-US', currency = 'USD
});
return formatter.format(number);
}
export function isWithinSafeRange(val: number) {
return Math.abs(val) < Number.MAX_SAFE_INTEGER;
}

Some files were not shown because too many files have changed in this diff Show More