Merge branch 'main' into feat-SER-267-console-notification-for-grids-ui

This commit is contained in:
Harsh Mahajan
2025-08-26 16:10:59 +05:30
committed by GitHub
616 changed files with 14169 additions and 8884 deletions
+7 -6
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@c0da899",
"@appwrite.io/pink-icons": "0.25.0",
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@e6b154b",
"@appwrite.io/pink-icons-svelte": "^2.0.0-RC.1",
"@appwrite.io/pink-legacy": "^1.0.3",
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@e6b154b",
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@4d9e4bf",
"@faker-js/faker": "^9.9.0",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^8.38.0",
"@stripe/stripe-js": "^3.5.0",
@@ -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.0"
}
+363 -267
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 [
+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(() => {
@@ -9,17 +9,22 @@
upgradeURL
} from '$lib/stores/billing';
import { currentPlan } from '$lib/stores/organization';
import { onMount } from 'svelte';
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
@@ -1,33 +1,30 @@
<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';
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 +36,7 @@
projects[0].teamId,
selectedProjects
);
showSelectProject = false;
invalidate(Dependencies.ORGANIZATION);
addNotification({
@@ -69,47 +67,97 @@
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}
<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>
{/if}
{/if}
<svelte:fragment slot="footer">
<Button.Button size="s" variant="secondary" on:click={() => (showSelectProject = false)}
@@ -118,3 +166,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;
@@ -7,7 +7,6 @@
import { LabelCard } from '..';
export let billingPlan: Tier;
export let anyOrgFree = false;
export let isNewOrg = false;
export let selfService = true;
@@ -22,12 +21,9 @@
<LabelCard
name="plan"
bind:group={billingPlan}
disabled={anyOrgFree || !selfService}
disabled={!selfService}
value={BillingPlan.FREE}
title={tierFree.name}
tooltipShow={anyOrgFree}
tooltipText="You are limited to 1 Free organization per account."
tooltipWidth="100%">
title={tierFree.name}>
<svelte:fragment slot="action">
{#if $organization?.billingPlan === BillingPlan.FREE && !isNewOrg}
<Badge variant="secondary" size="xs" content="Current plan" />
+38 -8
View File
@@ -22,7 +22,9 @@
import { base } from '$app/paths';
import { currentPlan, newOrgModal } 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';
type Organization = {
name: string;
@@ -63,15 +65,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 +101,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 +201,6 @@
$: selectedOrg = organizations.find((org) => org.isSelected);
$: projectsBottomSheet = createProjectsBottomSheet(selectedOrg);
$: organizationsBottomSheet = createOrganizationBottomSheet(selectedOrg);
$: correctPlanName =
@@ -198,6 +212,20 @@
: 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);
}
</script>
<svelte:window on:resize={onResize} />
@@ -380,7 +408,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}
+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';
+188 -37
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) {
@@ -35,16 +61,17 @@
const saveColumnPreferences = () => {
const shownColumns = $columns.filter((n) => n.hide === true).map((n) => n.id);
if (isCustomCollection) {
preferences.setCustomCollectionColumns(collectionId, shownColumns);
if (isCustomTable) {
onPreferencesUpdated?.();
preferences.setCustomTableColumns(page.params.table, shownColumns);
} else {
preferences.setColumns(shownColumns);
}
};
onMount(() => {
if (isCustomCollection) {
const shownColumns = preferences.getCustomCollectionColumns(collectionId);
if (isCustomTable) {
const shownColumns = preferences.getCustomTableColumns(page.params.table);
columns.update((n) =>
n.map((column) => {
@@ -53,7 +80,7 @@
})
);
} 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>
+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,7 +10,11 @@
export let height = 16;
$: ccImage = isValueOfStringEnum(CreditCard, brand)
? sdk.forConsole.avatars.getCreditCard(brand, width, height)
? sdk.forConsole.avatars.getCreditCard({
code: brand,
width,
height
})
: '';
</script>
+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>
+41 -38
View File
@@ -11,6 +11,7 @@
import { menuOpen } from '$lib/components/menu/store';
export let time: string | null = null;
export let canShowPopover: boolean = true;
export let placement: ComponentProps<Popover>['placement'] = 'bottom';
function timeDifference(dateString: string): string {
@@ -79,7 +80,7 @@
<Popover let:show let:hide {placement} portal>
<button
on:mouseenter={() => {
if (!$menuOpen) {
if (!$menuOpen && canShowPopover) {
setTimeout(show, 150);
}
}}
@@ -89,54 +90,56 @@
<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}
+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>
+65 -45
View File
@@ -19,6 +19,7 @@
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;
@@ -124,62 +125,74 @@
<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>
<Icon icon={IconFilterLine} slot="start" size={onlyIcon ? 'm' : 's'} />
{#if !onlyIcon}
Filters
{#if applied > 0}
<span class="inline-tag">
{applied}
</span>
{/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={() => (applied = 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={applied === 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>
@@ -238,3 +251,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(get(queries));
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');
+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
+1
View File
@@ -84,3 +84,4 @@ 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 SortButton, type SortDirection } from './sortButton.svelte';
+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
+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} />
+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;
}
+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 -8
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,
@@ -37,6 +37,7 @@
import { getSidebarState, 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';
@@ -71,7 +72,7 @@
}
}
$: state = $isTabletViewport ? 'closed' : getSidebarState();
$: state = $isTabletViewport ? 'closed' : getSidebarState($page);
const projectOptions = [
{ name: 'Auth', icon: IconUserGroup, slug: 'auth', category: 'build' },
@@ -89,7 +90,7 @@
];
const isSelected = (service: string): boolean => {
return page.route.id?.includes(service);
return $page.route.id?.includes(service);
};
</script>
@@ -101,7 +102,7 @@
<Sidebar.Base
{...$$props}
bind:state
on:resize={(event) => updateSidebarState(event.detail)}
on:resize={(event) => updateSidebarState($page, event.detail)}
resizable>
<div slot="top">
<div class="only-mobile-tablet top">
@@ -240,7 +241,9 @@
<span slot="tooltip">{projectOption.name}</span>
</Tooltip>
{/each}
{#if project}
{#if project && $isSmallViewport}
<Divider />
<div class="mobile-tablet-settings">
<Tooltip placement="right" disabled={state !== 'icons'}>
<a
@@ -354,7 +357,10 @@
</div>
{#if subNavigation}
<div class="sub-navigation" class:icons={state === 'icons'}>
<div
class="sub-navigation"
class:icons={state === 'icons'}
style:--banner-spacing={$bannerSpacing ? $bannerSpacing : undefined}>
<svelte:component this={subNavigation} />
</div>
{/if}
@@ -608,8 +614,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;
@@ -623,6 +630,17 @@
&.icons {
width: 266px;
transition: width 0.3s linear;
& :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" />
+16 -2
View File
@@ -18,6 +18,19 @@
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();
@@ -39,7 +52,7 @@
error = event.currentTarget.validationMessage;
};
$: if (value) {
$: if (value !== null && value !== undefined && !Number.isNaN(value)) {
error = null;
}
</script>
@@ -61,7 +74,8 @@
helper={error || helper}
state={error ? 'error' : 'default'}
on:invalid={handleInvalid}
on:input>
on:input={coerceToNumber}
on:change={coerceToNumber}>
<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>
+1 -2
View File
@@ -19,6 +19,5 @@ function isFlagEnabled(name: string) {
}
export const flags = {
showSites: isFlagEnabled('sites'),
showIndexLengths: isFlagEnabled('index-lengths')
showSites: isFlagEnabled('sites')
};
+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
});
}
}
+197
View File
@@ -0,0 +1,197 @@
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';
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');
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();
}
}
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 = intAttr.min ?? 0;
const max = intAttr.max ?? 10000;
return faker.number.int({ min, max });
}
case 'float': {
const floatAttr = column as Models.ColumnFloat;
const min = floatAttr.min ?? 0;
const max = floatAttr.max ?? 1000000;
const precision = 2;
return parseFloat(
faker.number.float({ min, max, fractionDigits: precision }).toFixed(precision)
);
}
case 'boolean': {
return faker.datatype.boolean();
}
case 'datetime': {
return faker.date.recent({ days: 365 }).toISOString();
}
case 'enum': {
const enumAttr = column as Models.ColumnEnum;
if (enumAttr.elements && enumAttr.elements.length > 0) {
return faker.helpers.arrayElement(enumAttr.elements);
}
return null;
}
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 });
}
/**
+1 -1
View File
@@ -26,5 +26,5 @@ export async function setHasOnboardingDismissed(projectId: string, account?: Acc
onboardingDismissed: onboardingDismissed
};
await sdk.forConsole.account.updatePrefs(newPrefs);
await sdk.forConsole.account.updatePrefs({ prefs: newPrefs });
}
+9 -2
View File
@@ -15,8 +15,15 @@ export function filterRegions(regions: Models.ConsoleRegion[]): RegionOption[] {
return regions
.filter((region) => region.$id !== 'default')
.sort((a, b) => {
if (a.disabled && !b.disabled) return 1;
if (!a.disabled && b.disabled) return -1;
// Check if regions are truly available (not disabled and available)
const aAvailable = !a.disabled && a.available;
const bAvailable = !b.disabled && b.available;
// Prioritize truly available regions
if (aAvailable && !bAvailable) return -1;
if (!aAvailable && bAvailable) return 1;
// Within the same availability group, sort alphabetically
return a.name.localeCompare(b.name);
})
.map((region) => ({
+13 -3
View File
@@ -1,10 +1,18 @@
import { get } from 'svelte/store';
import { user } from '$lib/stores/user';
import { sdk } from '$lib/stores/sdk';
import type { Page } from '@sveltejs/kit';
const userPreferences = () => get(user)?.prefs;
export function updateSidebarState(state: 'closed' | 'open' | 'icons') {
// for spreadsheet, closed with icons
const isInDatabasesRoute = (page: Page) => {
return page.route.id?.includes('databases/database-[database]');
};
export function updateSidebarState(page: Page, state: 'closed' | 'open' | 'icons') {
if (isInDatabasesRoute(page)) return;
if (state === 'open' || state === 'icons') {
const currentPrefs = userPreferences();
@@ -13,11 +21,13 @@ export function updateSidebarState(state: 'closed' | 'open' | 'icons') {
sidebarState: state
};
sdk.forConsole.account.updatePrefs(newPrefs);
sdk.forConsole.account.updatePrefs({ prefs: newPrefs });
}
}
export function getSidebarState(): 'open' | 'icons' {
export function getSidebarState(page: Page): 'open' | 'icons' {
if (isInDatabasesRoute(page)) return 'icons';
const currentPrefs = userPreferences();
if (currentPrefs && currentPrefs.sidebarState) {
if (currentPrefs.sidebarState === 'open' || currentPrefs.sidebarState === 'icons') {
+9
View File
@@ -60,3 +60,12 @@ export const hostnameRegex = String.raw`(\*)|(\*\.)?(?!-)[A-Za-z0-9\-]+([\-\.]{1
* Supports domains, localhost, wildcards, ip-addresses and Chrome extension IDs!
*/
export const extendedHostnameRegex = String.raw`(\*)|(\*\.)?((?!-)[A-Za-z0-9\-]+([\-\.]{1}[a-z0-9]+)*\.[A-Za-z]{2,18}|localhost|(\d{1,3}\.){3}\d{1,3}|[a-z0-9]{32})`;
export function hash(input: string | string[], delimiter: string = ','): string {
const str = Array.isArray(input) ? input.join(delimiter) : input;
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) & 0xffffffff;
}
return Math.abs(hash).toString(36);
}
+26
View File
@@ -0,0 +1,26 @@
import type { Models } from '@appwrite.io/console';
/**
* Build VCS repo URL from the template response model.
* Example (GitHub): https://github.com/appwrite/templates-for-sites
*/
export function getTemplateSourceUrl(
t: Models.TemplateSite | Models.TemplateFunction
): string | null {
const owner = t.providerOwner;
const repo = t.providerRepositoryId;
const provider = t.vcsProvider; // e.g., "github"
if (!owner || !repo || !provider) return null;
// Map provider → host (extend if needed)
const hostMap: Record<string, string> = {
github: 'github.com',
gitlab: 'gitlab.com',
bitbucket: 'bitbucket.org'
};
const host = hostMap[provider.toLowerCase()] ?? provider; // fallback
return `https://${host}/${owner}/${repo}`;
}
+10
View File
@@ -1,3 +1,4 @@
import type { ComponentType } from 'svelte';
import type { Writable } from 'svelte/store';
export type PinkColumn = {
@@ -11,6 +12,7 @@ export type PinkColumn = {
min: number;
}
| number;
minimumWidth?: number;
hide?: boolean;
};
@@ -45,6 +47,14 @@ export type Column = PinkColumn & {
exclude?: boolean;
elements?: string[] | { value: string | number; label: string }[];
encrypt?: boolean;
icon?: ComponentType;
isPrimary?: boolean;
isEditable?: boolean;
fixed?: boolean;
resizable?: boolean;
draggable?: boolean;
resizedWidth?: number;
isAction?: boolean;
};
export function isValueOfStringEnum<T extends Record<string, string>>(
+1 -1
View File
@@ -8,7 +8,7 @@ export const joinWaitlistSites = (user: Account) => {
joinWaitlistSites: true
};
sdk.forConsole.account.updatePrefs(newPrefs);
sdk.forConsole.account.updatePrefs({ prefs: newPrefs });
if (sessionStorage) {
sessionStorage.setItem('joinWaitlistSites', 'true');
Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

+71 -53
View File
@@ -7,70 +7,85 @@
import Button from '$lib/elements/forms/button.svelte';
import type { PinkColumn } from '$lib/helpers/types';
export let logs: Models.LogList;
export let offset = 0;
export let limit = 0;
export let offset = 0;
export let logs: Models.LogList;
export let insideSideSheet = false;
export let databasesScreen = false;
export let useCreateLinkForPagination = true;
const columns: PinkColumn[] = [
{ id: 'user' },
{ id: 'event' },
{ id: 'location' },
{ id: 'ip' },
{ id: 'date' }
{ id: 'user', ...(insideSideSheet ? { width: 140 } : {}) },
{ id: 'event', ...(insideSideSheet ? { width: 125 } : {}) },
{ id: 'location', ...(insideSideSheet ? { width: 100 } : {}) },
{ id: 'ip', ...(insideSideSheet ? { width: { min: 150 } } : {}) },
{ id: 'date', ...(insideSideSheet ? { width: { min: 200 } } : {}) }
];
</script>
<Container>
<Container
{databasesScreen}
{insideSideSheet}
expanded={databasesScreen && !insideSideSheet}
slotSpacing={databasesScreen && !insideSideSheet}>
{#if logs.total}
<Table.Root {columns} let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="user" {root}>User</Table.Header.Cell>
<Table.Header.Cell column="event" {root}>Event</Table.Header.Cell>
<Table.Header.Cell column="location" {root}>Location</Table.Header.Cell>
<Table.Header.Cell column="ip" {root}>IP</Table.Header.Cell>
<Table.Header.Cell column="date" {root}>Date</Table.Header.Cell>
</svelte:fragment>
{#each logs.logs as log}
<Table.Row.Base {root}>
<Table.Cell column="user" {root}>
<Layout.Stack direction="row" alignItems="center">
{#if log.userEmail}
{#if log.userName}
<AvatarInitials size="xs" name={log.userName} />
<Trim>{log.userName}</Trim>
<div>
<Table.Root {columns} let:root>
<svelte:fragment slot="header" let:root>
<Table.Header.Cell column="user" {root}>User</Table.Header.Cell>
<Table.Header.Cell column="event" {root}>Event</Table.Header.Cell>
<Table.Header.Cell column="location" {root}>Location</Table.Header.Cell>
<Table.Header.Cell column="ip" {root}>IP</Table.Header.Cell>
<Table.Header.Cell column="date" {root}>Date</Table.Header.Cell>
</svelte:fragment>
{#each logs.logs as log}
<Table.Row.Base {root}>
<Table.Cell column="user" {root}>
<Layout.Stack direction="row" alignItems="center">
{#if log.userEmail}
{#if log.userName}
<AvatarInitials size="xs" name={log.userName} />
<Trim>{log.userName}</Trim>
{:else}
<AvatarInitials size="xs" name={log.userEmail} />
<Trim>{log.userEmail}</Trim>
{/if}
{:else}
<AvatarInitials size="xs" name={log.userEmail} />
<Trim>{log.userEmail}</Trim>
<div class="avatar is-size-small">
<span class="icon-anonymous" aria-hidden="true"></span>
</div>
<span class="text u-trim">{log.userName ?? 'Anonymous'}</span>
{/if}
</Layout.Stack>
</Table.Cell>
<Table.Cell column="event" {root}>
{log.event}
</Table.Cell>
<Table.Cell column="location" {root}>
{#if log.countryCode !== '--'}
{log.countryName}
{:else}
<div class="avatar is-size-small">
<span class="icon-anonymous" aria-hidden="true"></span>
</div>
<span class="text u-trim">{log.userName ?? 'Anonymous'}</span>
Unknown
{/if}
</Layout.Stack>
</Table.Cell>
<Table.Cell column="event" {root}>
{log.event}
</Table.Cell>
<Table.Cell column="location" {root}>
{#if log.countryCode !== '--'}
{log.countryName}
{:else}
Unknown
{/if}
</Table.Cell>
<Table.Cell column="ip" {root}>
{log.ip}
</Table.Cell>
<Table.Cell column="date" {root}>
{toLocaleDateTime(log.time)}
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
</Table.Cell>
<Table.Cell column="ip" {root}>
{log.ip}
</Table.Cell>
<Table.Cell column="date" {root}>
{toLocaleDateTime(log.time)}
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
</div>
<PaginationWithLimit name="Logs" {limit} {offset} total={logs.total} />
<PaginationWithLimit
{limit}
{offset}
on:page
name="Logs"
total={logs.total}
useCreateLink={useCreateLinkForPagination} />
{:else}
<Card.Base padding="none">
<Empty
@@ -78,7 +93,10 @@
description="Need a hand? Learn more in our documentation."
type="secondary">
<svelte:fragment slot="actions">
<Button external secondary href="https://appwrite.io/docs">
<Button
external
secondary
href="https://appwrite.io/docs/products/databases/databases">
Documentation
</Button>
</svelte:fragment>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import { isSmallViewport } from '$lib/stores/viewport';
import { IconChevronLeft } from '@appwrite.io/pink-icons-svelte';
import { Button, Icon, Layout } from '@appwrite.io/pink-svelte';
import type { Snippet } from 'svelte';
let {
href = null,
collapsed = false,
children
}: {
href?: string | null;
collapsed?: boolean;
children: Snippet;
} = $props();
const buttonSize = $derived(collapsed ? 'xs' : 's');
const currentLineHeight = $derived(collapsed ? '130%' : '140%');
const currentLetterSpacing = $derived(collapsed ? '0' : '-0.144px');
const currentFontSize = $derived(collapsed ? 'var(--font-size-l)' : 'var(--font-size-xxxl)');
</script>
<Layout.Stack justifyContent="center" alignItems="center" direction="row" gap="xs" inline>
{#if href && !$isSmallViewport}
<span style:position="relative">
<Button.Anchor size={buttonSize} icon variant="text" {href} aria-label="page back">
<Icon icon={IconChevronLeft} />
</Button.Anchor>
</span>
{/if}
<h1
class="animated-title"
style:font-size={currentFontSize}
style:line-height={currentLineHeight}
style:letter-spacing={currentLetterSpacing}>
{@render children()}
</h1>
</Layout.Stack>
<style lang="scss">
.animated-title {
font-family: var(--font-family-brand), var(--sans-fallbacks);
font-weight: 400;
color: var(--fgcolor-neutral-primary);
text-align: start;
margin: 0;
transition:
font-size 300ms cubic-bezier(0.4, 0, 0.2, 1),
line-height 300ms cubic-bezier(0.4, 0, 0.2, 1),
letter-spacing 300ms cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
</style>
+85 -2
View File
@@ -1,6 +1,15 @@
<script lang="ts">
import { Layout } from '@appwrite.io/pink-svelte';
// TODO: needs better props
export let expanded = false;
export let slotSpacing = false;
export let overlapCover = false;
export let paddingInlineEnd = true;
export let insideSideSheet = false;
export let databasesScreen = false;
export let expandHeightButton = false;
export let size: 'small' | 'medium' | 'large' | 'xl' | 'xxl' | 'xxxl' = null;
$: style = size
@@ -8,8 +17,16 @@
: '';
</script>
<div style:container-type="inline-size" class:overlap-cover={overlapCover}>
<div class="console-container" {style}>
<div style:container-type="inline-size" class:overlap-cover={overlapCover} {...$$restProps}>
<div
{style}
class:expanded
class:slotSpacing
class:databasesScreen
class:insideSideSheet
class:expandHeightButton
class="console-container"
class:paddingInlineEnd={!paddingInlineEnd}>
<Layout.Stack gap="l">
<slot />
</Layout.Stack>
@@ -18,26 +35,92 @@
<style>
.overlap-cover {
z-index: 1;
margin-block-start: -3.5rem;
}
:global(.console-container) {
margin-block: var(--base-32);
&.insideSideSheet {
margin-block: var(--base-20);
}
&.expanded {
max-width: unset;
margin-inline: 2.75rem;
padding-inline-end: 0 !important;
margin-block: var(--base-8) !important;
@media (min-width: 1440px) {
margin-inline: 0 !important;
padding-inline: 53px !important;
}
&.expandHeightButton {
margin-inline-end: unset;
margin-inline-start: 2.75rem;
padding-inline-end: 0.5rem !important;
}
&:first-child {
margin-inline: 4rem;
&.expandHeightButton {
margin-inline: 0.5rem;
margin-inline-start: 4rem;
}
@media (max-width: 768px) {
margin-inline: 1rem;
&.expandHeightButton {
margin-inline-start: 1rem;
}
}
}
&.slotSpacing {
padding-block-start: var(--base-32) !important;
}
}
&.paddingInlineEnd {
@media (min-width: 1024px) {
padding-inline-end: 2.75rem !important;
}
}
&.databasesScreen {
@media (min-width: 1440px) {
min-width: 1070px;
/*max-width: 1144px;*/
}
@media (min-width: 1728px) {
min-width: 1070px;
padding-inline: 196px !important;
}
}
@media (min-width: 360px) {
margin-inline: 1rem;
}
@media (min-width: 1024px) {
margin-inline: auto;
max-width: calc(944px - 11rem);
}
@media (min-width: 1280px) {
margin-inline: auto;
max-width: 1000px;
}
@media (min-width: 1440px) {
margin-inline: auto;
max-width: 1144px;
}
@media (min-width: 1728px) {
margin-inline: auto;
max-width: 1200px;
+2
View File
@@ -45,6 +45,8 @@
users: 1,
executions: 1
};
// TODO: @itznotabug - check with @abnegate, what do we do here? this is billing!
const limitedServices = [
{ name: 'bandwidth', value: bandwidth },
{ name: 'documents', value: documents },
+77 -6
View File
@@ -1,33 +1,85 @@
<script lang="ts">
import { Layout } from '@appwrite.io/pink-svelte';
import { isTabletViewport } from '$lib/stores/viewport';
export let size: 'small' | 'medium' | 'large' | 'xl' = null;
export let blocksize = '152px';
export let expanded: boolean = false;
export let animate: boolean = false;
export let collapsed: boolean = false;
let isAnimating = false;
let animationTimeout: ReturnType<typeof setTimeout>;
$: style = size
? `--p-container-max-size: var(--container-max-size, var(--container-size-${size}))`
: '';
$: marginTop = expanded && $isTabletViewport ? '48px' : undefined;
$: animatedBlocksize = animate && collapsed ? '65px' : blocksize;
$: headerPaddingTop = animate && collapsed ? 'var(--base-16)' : 'var(--base-32)';
$: headerPaddingBottom = animate && collapsed ? 'var(--base-8)' : 'var(--base-16)';
$: if (animate) {
clearTimeout(animationTimeout);
isAnimating = true;
animationTimeout = setTimeout(() => {
isAnimating = false;
}, 300);
}
</script>
<div class="top-cover-console" style:block-size={blocksize}>
<div class="cover-container" {style}>
<div
class:animate
class:expanded
class:collapsed={animate && collapsed}
class="top-cover-console"
style:margin-top={marginTop}
style:block-size={animatedBlocksize}
style:padding-block-start={headerPaddingTop}
style:padding-block-end={headerPaddingBottom}>
<div
class="cover-container"
{style}
class:expanded
class:collapsed={animate && collapsed}
class:animating={isAnimating}>
<Layout.Stack direction="row" alignItems="baseline">
<slot name="header" />
</Layout.Stack>
<slot />
{#if $$slots.default}
<div class:expanded-slot={expanded}>
<slot />
</div>
{/if}
</div>
</div>
<style>
<style lang="scss">
.top-cover-console {
container-type: inline-size;
padding-block-start: var(--base-32);
padding-block-end: var(--base-16);
border-bottom: 1px solid var(--border-neutral, #2d2d31);
background: var(--bgcolor-neutral-primary, #1d1d21);
margin-left: -190px;
padding-left: 190px;
position: relative;
overflow: hidden;
&.animate {
transition:
block-size 300ms cubic-bezier(0.4, 0, 0.2, 1),
padding-block-start 300ms cubic-bezier(0.4, 0, 0.2, 1),
padding-block-end 300ms cubic-bezier(0.4, 0, 0.2, 1),
border-bottom-color 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
&.collapsed {
border-bottom-color: var(--border-neutral, #2d2d31);
}
}
.cover-container {
position: relative;
margin: 0 1rem;
@@ -39,21 +91,40 @@
@media (min-width: 360px) {
margin-inline: 1rem;
}
@media (min-width: 1024px) {
margin-inline: auto;
max-width: calc(944px - 11rem);
}
@media (min-width: 1280px) {
margin-inline: auto;
max-width: 1000px;
}
@media (min-width: 1440px) {
margin-inline: auto;
max-width: 1144px;
}
@media (min-width: 1728px) {
margin-inline: auto;
max-width: 1200px;
}
&.expanded {
margin-inline: 1rem !important;
}
&.animating {
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
}
.expanded-slot {
margin-inline: 3rem !important;
@media (max-width: 768px) {
margin-inline: 0 !important;
}
}
</style>
+2 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { isSmallViewport, isTabletViewport } from '$lib/stores/viewport';
import { isSmallViewport } from '$lib/stores/viewport';
import { IconChevronLeft } from '@appwrite.io/pink-icons-svelte';
import { Typography, Button, Icon, Layout } from '@appwrite.io/pink-svelte';
@@ -8,8 +8,7 @@
<Layout.Stack justifyContent="center" alignItems="center" direction="row" gap="xs" inline>
{#if href}
<span
style={$isTabletViewport ? 'position: relative' : 'position: absolute; left: -2.75rem'}>
<span style:position="relative">
<Button.Anchor size="s" icon variant="text" {href} aria-label="page back">
<Icon icon={IconChevronLeft} />
</Button.Anchor>
+3 -3
View File
@@ -13,14 +13,14 @@
view = $bindable(),
columns,
hideColumns = false,
isCustomCollection = false
isCustomTable = false
}: {
show?: boolean;
hideView?: boolean;
view?: View;
columns?: Writable<Column[]>;
hideColumns?: boolean;
isCustomCollection?: boolean;
isCustomTable?: boolean;
} = $props();
</script>
@@ -35,7 +35,7 @@
{#if !hideColumns && $columns?.length}
<Layout.Stack gap="xs">
<Typography.Text>Columns</Typography.Text>
<ColumnSelector {columns} {isCustomCollection}>
<ColumnSelector {columns} {isCustomTable}>
{#snippet children(toggle, selectedColumnsNumber)}
<Button.Button
size="s"
+63 -53
View File
@@ -11,19 +11,24 @@
Button,
Badge
} from '@appwrite.io/pink-svelte';
import { isSmallViewport, isTabletViewport } from '$lib/stores/viewport';
import { isSmallViewport } from '$lib/stores/viewport';
import { page } from '$app/state';
const currentYear = new Date().getFullYear();
const hideFooter = $derived.by(() => {
const endings = ['table-[table]', 'table-[table]/columns', 'table-[table]/indexes'];
return endings.some((end) => page.route.id?.endsWith(end));
});
</script>
<footer>
<footer class:hide={hideFooter}>
<Divider />
<Layout.Stack direction={$isSmallViewport ? 'column-reverse' : 'row'}>
<Layout.Stack
direction="row"
alignItems="center"
gap={$isSmallViewport ? 'm' : 'l'}
inline={$isTabletViewport}
justifyContent="flex-start">
<Typography.Caption variant="400">
ⓒ {currentYear} Appwrite. All rights reserved.
@@ -54,63 +59,37 @@
</Button.Anchor>
</Layout.Stack>
</Layout.Stack>
{#if isCloud && $isSmallViewport}
<div class="extra-margin">
<Layout.Stack direction="row" justifyContent={'flex-start'} alignItems="center">
{#if $version}
{#if isCloud}
<Icon size="s" icon={IconCloud} />
{/if}
<Link.Anchor
size="s"
variant="quiet"
href="https://github.com/appwrite/appwrite/releases"
aria-label="Appwrite releases on Github"
target="_blank"
rel="noreferrer">
Version {$version}
</Link.Anchor>
<span class="divider-wrapper">
<Divider vertical />
</span>
{/if}
<Badge
size="xs"
type="success"
variant="secondary"
content="Generally Available"
style="white-space: nowrap;" />
</Layout.Stack>
</div>
{/if}
<Layout.Stack
direction="row"
justifyContent={$isSmallViewport ? 'flex-start' : 'flex-end'}
alignItems="center">
{#if isCloud}
{#if !$isSmallViewport}
alignItems="center"
wrap={$isSmallViewport ? 'wrap' : 'normal'}>
{#if !$isSmallViewport}
{#if isCloud}
<Badge
size="xs"
type="success"
variant="secondary"
content="Generally Available"
style="white-space: nowrap;" />
<Icon size="s" icon={IconCloud} />
{#if $version}
<Link.Anchor
size="s"
variant="quiet"
href="https://github.com/appwrite/appwrite/releases"
aria-label="Appwrite releases on Github"
target="_blank"
rel="noreferrer">
Version {$version}
</Link.Anchor>
<span class="divider-wrapper">
<Divider vertical />
</span>
{/if}
{/if}
{#if $version}
<Link.Anchor
size="s"
variant="quiet"
href="https://github.com/appwrite/appwrite/releases"
aria-label="Appwrite releases on Github"
target="_blank"
rel="noreferrer"
style="white-space: nowrap;">
Version {$version}
</Link.Anchor>
<span class="divider-wrapper">
<Divider vertical />
</span>
{/if}
{/if}
@@ -157,6 +136,32 @@
Cookies
</Link.Anchor>
{/if}
{#if $isSmallViewport}
{#if $version}
<span class="divider-wrapper">
<Divider vertical />
</span>
<Link.Anchor
size="s"
variant="quiet"
href="https://github.com/appwrite/appwrite/releases"
aria-label="Appwrite releases on Github"
target="_blank"
rel="noreferrer"
style="white-space: nowrap;">
Version {$version}
</Link.Anchor>
{#if isCloud}
<Icon size="s" icon={IconCloud} />
<Badge
size="xs"
type="success"
variant="secondary"
content="Generally Available"
style="white-space: nowrap;" />
{/if}
{/if}
{/if}
</Layout.Stack>
</Layout.Stack>
</footer>
@@ -172,15 +177,20 @@
flex-direction: column;
gap: var(--gap-l);
margin-inline: 2rem;
&.hide {
display: none;
}
}
:global(main:has(.sub-navigation)) footer {
@media (min-width: 1024px) {
margin-inline-start: -1.5rem;
margin-inline-end: 2rem;
margin-inline-start: -1.5rem;
}
}
.extra-margin {
margin-block-start: var(--space-2, 4px);
:global(main:has(.wide-screen-wrapper)) footer {
margin-inline-start: 2rem !important;
}
</style>
+2 -8
View File
@@ -8,7 +8,7 @@
export let title: string;
export let columns: Writable<Column[]>;
export let view: View;
export let isCustomCollection = false;
export let isCustomTable = false;
export let hideView = false;
export let hideColumns = false;
export let allowNoColumns = false;
@@ -17,13 +17,7 @@
<header class="grid-header">
<Typography.Title size="m">{title}</Typography.Title>
<div class="u-flex u-gap-16 u-contents-mobile">
<ViewSelector
{view}
{columns}
{isCustomCollection}
{hideView}
{hideColumns}
{allowNoColumns} />
<ViewSelector {view} {columns} {isCustomTable} {hideView} {hideColumns} {allowNoColumns} />
<div class="grid-header-col-2">
<slot />
</div>
+11
View File
@@ -1,3 +1,9 @@
<script module lang="ts">
import { writable } from 'svelte/store';
export const bannerSpacing = writable<string | null>(null);
</script>
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { isTabletViewport } from '$lib/stores/viewport';
@@ -16,10 +22,15 @@
if (header) {
header.style.top = `${alertHeight}px`;
}
if (sidebar) {
sidebar.style.top = `${alertHeight + ($isTabletViewport ? 0 : header.getBoundingClientRect().height)}px`;
sidebar.style.height = `calc(100vh - (${alertHeight + ($isTabletViewport ? 0 : header.getBoundingClientRect().height)}px))`;
// for sidebar and sub-navigation!
bannerSpacing.set(`${alertHeight}px`);
}
if (contentSection) {
contentSection.style.paddingBlockStart = `${alertHeight}px`;
}
+1
View File
@@ -1,6 +1,7 @@
export { default as Container } from './container.svelte';
export { default as Cover } from './cover.svelte';
export { default as CoverTitle } from './coverTitle.svelte';
export { default as AnimatedTitle } from './animatedTitle.svelte';
export { default as Footer } from './footer.svelte';
export { default as Navigation } from './navigation.svelte';
export { default as Notifications } from './notifications.svelte';
+1 -1
View File
@@ -31,7 +31,7 @@
position: fixed;
top: calc(var(--main-header-height) + 12px);
right: 12px;
z-index: 1000;
z-index: 1001;
}
@media (min-width: 768px) {
+49 -13
View File
@@ -22,7 +22,6 @@
export let showFooter = true;
export let showSideNavigation = false;
export let selectedProject: Models.Project = null;
export let projects: Promise<Models.ProjectList> = undefined;
let yOnMenuOpen: number;
let showContentTransition = false;
@@ -91,7 +90,13 @@
alt: 'Logo Appwrite'
},
avatar: sdk.forConsole.avatars.getInitials($user?.name, 80, 80).toString(),
avatar: sdk.forConsole.avatars
.getInitials({
name: $user?.name,
width: 80,
height: 80
})
.toString(),
organizations: $organizationList.teams.map((org) => {
const billingPlan = org['billingPlan'];
@@ -104,8 +109,6 @@
};
}),
projects: projects,
currentProject: selectedProject
};
@@ -141,13 +144,18 @@
return undefined;
};
isNewWizardStatusOpen.subscribe((value) => (showHeader = !value));
</script>
<svelte:window on:resize={handleResize} />
<svelte:body use:style={$bodyStyle} />
{#if $activeHeaderAlert?.show && !$isNewWizardStatusOpen}
<svelte:component this={$activeHeaderAlert.component} />
{/if}
<main
class:has-alert={$activeHeaderAlert?.show}
class:is-open={$showSubNavigation}
@@ -158,15 +166,20 @@
{#if showHeader}
<Navbar {...navbarProps} bind:sideBarIsOpen={$isSidebarOpen} bind:showAccountMenu />
{/if}
<Sidebar
project={selectedProject}
progressCard={progressCard()}
avatar={navbarProps.avatar}
bind:subNavigation
bind:sideBarIsOpen={$isSidebarOpen}
bind:showAccountMenu
bind:state />
{#if !$isNewWizardStatusOpen}
<Sidebar
project={selectedProject}
progressCard={progressCard()}
avatar={navbarProps.avatar}
bind:subNavigation
bind:sideBarIsOpen={$isSidebarOpen}
bind:showAccountMenu
bind:state />
{/if}
<SideNavigation bind:subNavigation />
<div
class="content"
class:has-transition={showContentTransition}
@@ -174,7 +187,9 @@
class:no-sidebar={!showSideNavigation}>
<section class="main-content" data-test={showSideNavigation}>
{#if $page.data?.header}
<svelte:component this={$page.data.header} />
<div class="layout-header">
<svelte:component this={$page.data.header} />
</div>
{/if}
<slot />
{#if showFooter}
@@ -239,6 +254,27 @@
}
}
:global(main:has(.databases-spreadsheet)) {
/* avoids the unnecessary sheet slide animation */
.has-transition {
transition: none !important;
}
@media (min-width: 1024px) {
.main-content {
height: auto;
padding-left: 210px;
}
}
@media (max-width: 768px) {
.main-content:not(:has(.wide-screen-wrapper)) {
width: 100%;
position: fixed;
}
}
}
.overlay {
position: fixed;
width: 100vw;
+27 -5
View File
@@ -10,6 +10,7 @@
import type { Campaign } from '$lib/stores/campaigns';
import { Typography, Layout, Avatar } from '@appwrite.io/pink-svelte';
import { getCampaignImageUrl } from '$routes/(public)/card/helpers';
import { isSmallViewport } from '$lib/stores/viewport';
export const imgLight = LoginLight;
export const imgDark = LoginDark;
@@ -44,7 +45,7 @@
<main class="grid-1-1 is-full-page" id="main">
<section
class={`u-flex u-flex-vertical ${variation !== 'default' ? 'u-cross-center u-main-center u-height-100-percent u-width-full-line side-bg' : 'side-default'}`}
class={`u-flex u-flex-vertical ${variation !== 'default' ? 'u-cross-center u-main-center u-height-100-percent u-width-full-line side-bg' : !$isSmallViewport ? 'side-default' : ''}`}
style:--url={variation !== 'default'
? ''
: `url(${$app.themeInUse === 'dark' ? imgDark : imgLight})`}>
@@ -159,7 +160,6 @@
<div class="u-margin-block-start-24">
<slot />
</div>
<ul
class="inline-links is-center is-with-sep u-margin-block-start-32"
class:u-hide={!$$slots?.links}>
@@ -167,18 +167,18 @@
</ul>
</div>
<div
class="logo u-flex u-gap-16 u-margin-inline-auto is-only-mobile u-margin-block-start-32">
class="logo u-flex u-gap-16 u-margin-inline-auto is-only-mobile u-margin-block-start-32 mobile-logo">
<a href={base}>
{#if $app.themeInUse === 'dark'}
<img
src={AppwriteLogoDark}
width="93"
width="120"
class="u-block u-only-dark"
alt="Appwrite Logo" />
{:else}
<img
src={AppwriteLogoLight}
width="93"
width="120"
class="u-block u-only-light"
alt="Appwrite Logo" />
{/if}
@@ -359,4 +359,26 @@
padding-block-start: 5rem;
}
}
// mobile logo positioned at bottom in mobile view
.mobile-logo {
position: absolute;
bottom: 20px;
left: 50%;
transform: translate(-50%, -50%);
max-width: 100%;
margin-block-start: 0 !important;
}
@media #{devices.$break1} {
.auth-container {
margin-block-end: 5rem;
margin-block-start: 2rem;
}
.container {
min-height: 100%;
position: relative;
}
}
</style>
+5
View File
@@ -320,8 +320,10 @@ export function calculateTrialDay(org: Organization) {
export async function checkForProjectsLimit(org: Organization, orgProjectCount?: number) {
if (!isCloud) return;
if (!org) return;
const plan = await sdk.forConsole.billing.getOrganizationPlan(org.$id);
if (!plan) return;
if (plan.$id !== BillingPlan.FREE) return;
if (org.projects?.length > 0) return;
@@ -369,6 +371,8 @@ export async function checkForUsageLimit(org: Organization) {
return;
}
}
// TODO: @itznotabug - check with @abnegate, what do we do here? this is billing!
const { bandwidth, documents, executions, storage, users } = org?.billingLimits ?? {};
const resources = [
{ value: bandwidth, name: 'bandwidth' },
@@ -381,6 +385,7 @@ export async function checkForUsageLimit(org: Organization) {
const members = org.total;
const plan = get(currentPlan);
const membersOverflow =
// `plan` can be null on `onboarding/create-organization` route.
// nested null checks needed: GitHub Education plan have empty addons.
members > plan?.addons?.seats?.limit
? members - (plan?.addons?.seats?.limit || members)

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