mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
Merge branch 'main' into fix-SER-289-Improve-BackUp-ui-changes
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
PUBLIC_CONSOLE_MODE=self-hosted
|
||||
PUBLIC_CONSOLE_FEATURE_FLAGS=sites,index-lengths
|
||||
PUBLIC_CONSOLE_FEATURE_FLAGS=
|
||||
PUBLIC_APPWRITE_MULTI_REGION=false
|
||||
PUBLIC_APPWRITE_ENDPOINT=http://localhost/v1
|
||||
PUBLIC_STRIPE_KEY=
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
"PUBLIC_CONSOLE_MODE=cloud"
|
||||
"PUBLIC_CONSOLE_FEATURE_FLAGS=sites"
|
||||
"PUBLIC_CONSOLE_FEATURE_FLAGS="
|
||||
"PUBLIC_APPWRITE_MULTI_REGION=true"
|
||||
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
|
||||
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY }}"
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
"PUBLIC_CONSOLE_MODE=cloud"
|
||||
"PUBLIC_CONSOLE_FEATURE_FLAGS=sites,index-lengths"
|
||||
"PUBLIC_CONSOLE_FEATURE_FLAGS="
|
||||
"PUBLIC_APPWRITE_MULTI_REGION=true"
|
||||
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
|
||||
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}"
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
build-args: |
|
||||
"PUBLIC_CONSOLE_MODE=self-hosted"
|
||||
"PUBLIC_APPWRITE_MULTI_REGION=false"
|
||||
"PUBLIC_CONSOLE_FEATURE_FLAGS=sites,index-lengths"
|
||||
"PUBLIC_CONSOLE_FEATURE_FLAGS="
|
||||
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
|
||||
|
||||
publish-cloud-no-regions:
|
||||
@@ -156,6 +156,6 @@ jobs:
|
||||
build-args: |
|
||||
"PUBLIC_CONSOLE_MODE=cloud"
|
||||
"PUBLIC_APPWRITE_MULTI_REGION=false"
|
||||
"PUBLIC_CONSOLE_FEATURE_FLAGS=sites,index-lengths"
|
||||
"PUBLIC_CONSOLE_FEATURE_FLAGS="
|
||||
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}"
|
||||
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
|
||||
|
||||
+6
-6
@@ -6,12 +6,6 @@ server {
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# Security headers for all locations
|
||||
add_header X-UA-Compatible "IE=Edge";
|
||||
add_header X-Frame-Options SAMEORIGIN;
|
||||
add_header X-XSS-Protection "1; mode=block;";
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
# Only cache files in /console/_app/immutable/ for 1 year
|
||||
location /console/_app/immutable/ {
|
||||
try_files $uri =404;
|
||||
@@ -35,6 +29,12 @@ server {
|
||||
expires 0;
|
||||
add_header Cache-Control "no-cache, no-store";
|
||||
add_header Pragma "no-cache";
|
||||
|
||||
# Security headers
|
||||
add_header X-UA-Compatible "IE=Edge";
|
||||
add_header X-Frame-Options SAMEORIGIN;
|
||||
add_header X-XSS-Protection "1; mode=block;";
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
||||
@@ -10,7 +10,6 @@ test('upgrade - free tier', async ({ page }) => {
|
||||
await page.getByRole('link', { name: 'Upgrade', exact: true }).click();
|
||||
await page.waitForURL(/\/organization-[^/]+\/change-plan/);
|
||||
await page.locator('input[value="tier-1"]').click();
|
||||
await page.getByRole('button', { name: 'add' }).first().click();
|
||||
|
||||
await enterCreditCard(page);
|
||||
|
||||
|
||||
@@ -7,12 +7,15 @@ type Metadata = {
|
||||
};
|
||||
|
||||
export async function enterCreditCard(page: Page) {
|
||||
const dialog = page.locator('.modal').filter({
|
||||
hasText: 'add payment method'
|
||||
});
|
||||
await dialog.waitFor({
|
||||
state: 'visible'
|
||||
});
|
||||
// click the `add` button inside correct view layer
|
||||
await page
|
||||
.locator('#no-payments-card-stack')
|
||||
.getByRole('button', { name: 'add' })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const dialog = page.locator('.modal').filter({ hasText: 'add payment method' });
|
||||
await dialog.waitFor({ state: 'visible' });
|
||||
await page.getByPlaceholder('cardholder').fill('Test User');
|
||||
const stripe = page.locator('[title="Secure payment input frame"]').nth(0).contentFrame();
|
||||
await stripe.locator('id=Field-numberInput').fill('4242424242424242');
|
||||
@@ -35,7 +38,6 @@ export async function createProProject(page: Page): Promise<Metadata> {
|
||||
await page.getByRole('radio', { name: /^Pro\b/ }).check();
|
||||
// `create organization` because there's already free created on start!
|
||||
await page.getByRole('button', { name: 'create organization' }).click();
|
||||
await page.getByRole('button', { name: 'add' }).first().click();
|
||||
await enterCreditCard(page);
|
||||
// skip members
|
||||
await page.getByRole('button', { name: 'create organization' }).click();
|
||||
|
||||
+8
-7
@@ -22,11 +22,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/svelte": "^1.1.24",
|
||||
"@appwrite.io/console": "^1.10.0",
|
||||
"@appwrite.io/console": "https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@6031134",
|
||||
"@appwrite.io/pink-icons": "0.25.0",
|
||||
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@d93a5e9",
|
||||
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@2cf27e0",
|
||||
"@appwrite.io/pink-legacy": "^1.0.3",
|
||||
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@d93a5e9",
|
||||
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@40fb564",
|
||||
"@faker-js/faker": "^9.9.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@sentry/sveltekit": "^8.38.0",
|
||||
"@stripe/stripe-js": "^3.5.0",
|
||||
@@ -53,7 +54,7 @@
|
||||
"@melt-ui/svelte": "^0.86.5",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.20.2",
|
||||
"@sveltejs/kit": "^2.36.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
@@ -83,8 +84,8 @@
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.2.3",
|
||||
"vitest": "^3.0.0"
|
||||
"vite": "^7.0.6",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
@@ -94,5 +95,5 @@
|
||||
"svelte-preprocess"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.13.1"
|
||||
"packageManager": "pnpm@10.15.1"
|
||||
}
|
||||
|
||||
Generated
+391
-283
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
|
||||
@@ -28,10 +28,10 @@ const groups = [
|
||||
'integrations',
|
||||
'migrations',
|
||||
'users',
|
||||
'collections',
|
||||
'attributes',
|
||||
'tables',
|
||||
'columns',
|
||||
'indexes',
|
||||
'documents',
|
||||
'rows',
|
||||
'teams',
|
||||
'security',
|
||||
'buckets',
|
||||
|
||||
@@ -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?'
|
||||
];
|
||||
|
||||
+4
-4
@@ -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);
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
+2
-2
@@ -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} />
|
||||
@@ -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;
|
||||
@@ -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()))
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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;
|
||||
@@ -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 [
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -18,9 +18,9 @@ const getUserCommand = (user: Models.User<Models.Preferences>, region: string, p
|
||||
}) satisfies Command;
|
||||
|
||||
export const userSearcher = (async (query: string) => {
|
||||
const { users } = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.users.list([], query || undefined);
|
||||
const { users } = await sdk.forProject(page.params.region, page.params.project).users.list({
|
||||
search: query || undefined
|
||||
});
|
||||
|
||||
if (users.length === 1) {
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { HeaderAlert } from '$lib/layout';
|
||||
import { Typography } from '@appwrite.io/pink-svelte';
|
||||
import { hideNotification, shouldShowNotification } from '$lib/helpers/notifications';
|
||||
import { user } from '$lib/stores/user';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import { page } from '$app/state';
|
||||
|
||||
const { emailBannerClosed, onEmailBannerClose } = $props<{
|
||||
emailBannerClosed: boolean;
|
||||
onEmailBannerClose: (closed: boolean) => void;
|
||||
}>();
|
||||
|
||||
const isOnOnboarding = $derived(page.url?.pathname?.includes('/console/onboarding'));
|
||||
|
||||
const hasUser = $derived(!!$user);
|
||||
const needsEmailVerification = $derived(hasUser && !$user.emailVerification);
|
||||
const shouldShowNotificationBanner = $derived.by(() =>
|
||||
shouldShowNotification('email-verification-banner')
|
||||
);
|
||||
const wizardNotActive = $derived(!$wizard.show && !$wizard.cover);
|
||||
const bannerNotClosed = $derived(!emailBannerClosed);
|
||||
const notOnOnboarding = $derived(!isOnOnboarding);
|
||||
|
||||
const shouldShowEmailBanner = $derived(
|
||||
hasUser &&
|
||||
needsEmailVerification &&
|
||||
shouldShowNotificationBanner &&
|
||||
wizardNotActive &&
|
||||
bannerNotClosed &&
|
||||
notOnOnboarding
|
||||
);
|
||||
|
||||
function navigateToAccount() {
|
||||
goto(`${base}/account`);
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
onEmailBannerClose(true);
|
||||
hideNotification('email-verification-banner', { coolOffPeriod: 24 * 365 * 100 });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if shouldShowEmailBanner}
|
||||
<HeaderAlert
|
||||
type="warning"
|
||||
title="Your email address needs to be verified"
|
||||
dismissible
|
||||
on:dismiss={handleDismiss}>
|
||||
<svelte:fragment>
|
||||
To avoid losing access to your projects, make sure <Typography.Text
|
||||
variant="m-500"
|
||||
style="display:inline">{$user.email}</Typography.Text> is valid and up to date. Email
|
||||
verification will be required soon.
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="buttons">
|
||||
<Button secondary size="s" on:click={navigateToAccount}>Update email address</Button>
|
||||
</svelte:fragment>
|
||||
</HeaderAlert>
|
||||
{/if}
|
||||
@@ -0,0 +1,384 @@
|
||||
<script lang="ts">
|
||||
import { Button, InputText } from '$lib/elements/forms';
|
||||
import { DropList, GridItem1, CardContainer, Modal } from '$lib/components';
|
||||
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
|
||||
import {
|
||||
Badge,
|
||||
Icon,
|
||||
Typography,
|
||||
Tag,
|
||||
Accordion,
|
||||
ActionMenu,
|
||||
Popover,
|
||||
Layout,
|
||||
Divider
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import {
|
||||
IconAndroid,
|
||||
IconApple,
|
||||
IconCode,
|
||||
IconFlutter,
|
||||
IconReact,
|
||||
IconUnity,
|
||||
IconInfo,
|
||||
IconDotsHorizontal,
|
||||
IconInboxIn,
|
||||
IconSwitchHorizontal,
|
||||
IconTrash
|
||||
} from '@appwrite.io/pink-icons-svelte';
|
||||
import { getPlatformInfo } from '$lib/helpers/platform';
|
||||
import { Status, type Models } from '@appwrite.io/console';
|
||||
import type { ComponentType } from 'svelte';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
|
||||
import { isSmallViewport } from '$lib/stores/viewport';
|
||||
import { isCloud } from '$lib/system';
|
||||
import { regions as regionsStore } from '$lib/stores/organization';
|
||||
import type { Organization } from '$lib/stores/organization';
|
||||
import type { Plan } from '$lib/sdk/billing';
|
||||
|
||||
// props
|
||||
interface Props {
|
||||
projectsToArchive: Models.Project[];
|
||||
organization: Organization;
|
||||
currentPlan: Plan;
|
||||
}
|
||||
|
||||
let { projectsToArchive, organization, currentPlan }: Props = $props();
|
||||
|
||||
// Track Read-only info droplist per archived project
|
||||
let readOnlyInfoOpen = $state<Record<string, boolean>>({});
|
||||
let showUnarchiveModal = $state(false);
|
||||
let projectToUnarchive = $state<Models.Project | null>(null);
|
||||
let showDeleteModal = $state(false);
|
||||
let projectToDelete = $state<Models.Project | null>(null);
|
||||
let deleteProjectName = $state<string | null>(null);
|
||||
let deleteError = $state<string | null>(null);
|
||||
|
||||
function resetDeleteState() {
|
||||
showDeleteModal = false;
|
||||
projectToDelete = null;
|
||||
deleteProjectName = null;
|
||||
deleteError = null;
|
||||
}
|
||||
|
||||
function filterPlatforms(platforms: { name: string; icon: string }[]) {
|
||||
return platforms.filter(
|
||||
(value, index, self) => index === self.findIndex((t) => t.name === value.name)
|
||||
);
|
||||
}
|
||||
|
||||
function getIconForPlatform(platform: string): ComponentType {
|
||||
switch (platform) {
|
||||
case 'code':
|
||||
return IconCode;
|
||||
case 'flutter':
|
||||
return IconFlutter;
|
||||
case 'apple':
|
||||
return IconApple;
|
||||
case 'android':
|
||||
return IconAndroid;
|
||||
case 'react-native':
|
||||
return IconReact;
|
||||
case 'unity':
|
||||
return IconUnity;
|
||||
default:
|
||||
return IconCode;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if unarchive should be disabled
|
||||
function isUnarchiveDisabled(): boolean {
|
||||
if (!organization || !currentPlan) return true;
|
||||
|
||||
if (organization.billingPlan === BillingPlan.FREE) {
|
||||
const currentProjectCount = organization.projects?.length || 0;
|
||||
const projectLimit = currentPlan.projects || 0;
|
||||
|
||||
return currentProjectCount >= projectLimit;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleMigrateProject(project: Models.Project) {
|
||||
goto(`${base}/project-${project.region}-${project.$id}/settings/migrations`);
|
||||
}
|
||||
|
||||
// Handle unarchive project action
|
||||
async function handleUnarchiveProject(project: Models.Project) {
|
||||
projectToUnarchive = project;
|
||||
showUnarchiveModal = true;
|
||||
}
|
||||
|
||||
function handleDeleteProject(project: Models.Project) {
|
||||
projectToDelete = project;
|
||||
deleteProjectName = null;
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
// Confirm unarchive action
|
||||
async function confirmUnarchive() {
|
||||
if (!projectToUnarchive) return;
|
||||
|
||||
try {
|
||||
if (!organization) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: 'Organization not found'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await sdk.forConsole.projects.updateStatus(projectToUnarchive.$id, Status.Active);
|
||||
|
||||
await invalidate(Dependencies.ORGANIZATION);
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `${projectToUnarchive.name} has been unarchived`
|
||||
});
|
||||
|
||||
showUnarchiveModal = false;
|
||||
projectToUnarchive = null;
|
||||
} catch (error) {
|
||||
const msg =
|
||||
error && typeof error === 'object' && 'message' in error
|
||||
? String((error as { message: string }).message)
|
||||
: 'Failed to unarchive project';
|
||||
addNotification({ type: 'error', message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
function cancelUnarchive() {
|
||||
showUnarchiveModal = false;
|
||||
projectToUnarchive = null;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!projectToDelete) return;
|
||||
|
||||
try {
|
||||
await sdk.forConsoleIn(projectToDelete.region).projects.delete({
|
||||
projectId: projectToDelete.$id
|
||||
});
|
||||
|
||||
await invalidate(Dependencies.ORGANIZATION);
|
||||
|
||||
trackEvent(Submit.ProjectDelete);
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `${projectToDelete.name} has been deleted`
|
||||
});
|
||||
|
||||
resetDeleteState();
|
||||
} catch (error) {
|
||||
deleteError = error.message;
|
||||
trackError(error, Submit.ProjectDelete);
|
||||
}
|
||||
}
|
||||
|
||||
function findRegion(project: Models.Project) {
|
||||
return $regionsStore.regions.find((region) => region.$id === project.region);
|
||||
}
|
||||
|
||||
import { formatName as formatNameHelper } from '$lib/helpers/string';
|
||||
function formatName(name: string, limit: number = 19) {
|
||||
return formatNameHelper(name, limit, $isSmallViewport);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if projectsToArchive.length > 0}
|
||||
<div class="archive-projects-margin-top">
|
||||
<Accordion title="Archived projects" badge={`${projectsToArchive.length}`}>
|
||||
<Typography.Text tag="p" size="s">
|
||||
These projects have been archived and are read-only. You can view and migrate their
|
||||
data.
|
||||
</Typography.Text>
|
||||
|
||||
<div class="archive-projects-margin">
|
||||
<CardContainer disableEmpty={true} total={projectsToArchive.length}>
|
||||
{#each projectsToArchive as project}
|
||||
{@const platforms = filterPlatforms(
|
||||
project.platforms.map((platform) => getPlatformInfo(platform.type))
|
||||
)}
|
||||
{@const formatted = formatName(project.name)}
|
||||
<GridItem1>
|
||||
<svelte:fragment slot="eyebrow">
|
||||
{project?.platforms?.length ? project?.platforms?.length : 'No'} apps
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="title">{formatted}</svelte:fragment>
|
||||
<svelte:fragment slot="status">
|
||||
<div class="status-container">
|
||||
<DropList
|
||||
bind:show={readOnlyInfoOpen[project.$id]}
|
||||
placement="bottom-start"
|
||||
noArrow>
|
||||
<Tag
|
||||
size="s"
|
||||
style="white-space: nowrap;"
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
readOnlyInfoOpen = {
|
||||
...readOnlyInfoOpen,
|
||||
[project.$id]: !readOnlyInfoOpen[project.$id]
|
||||
};
|
||||
}}>
|
||||
<Icon icon={IconInfo} size="s" />
|
||||
<span>Read only</span>
|
||||
</Tag>
|
||||
<svelte:fragment slot="list">
|
||||
<li
|
||||
class="drop-list-item u-width-250"
|
||||
style="padding: var(--space-5, 12px) var(--space-6, 16px)">
|
||||
<span class="u-block u-mb-8">
|
||||
Archived projects are read-only. You can view
|
||||
and migrate their data, but they no longer
|
||||
accept edits or requests.
|
||||
</span>
|
||||
</li>
|
||||
</svelte:fragment>
|
||||
</DropList>
|
||||
<Popover let:toggle padding="none" placement="bottom-end">
|
||||
<Button
|
||||
text
|
||||
icon
|
||||
size="s"
|
||||
ariaLabel="more options"
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggle(e);
|
||||
}}>
|
||||
<Icon icon={IconDotsHorizontal} size="s" />
|
||||
</Button>
|
||||
<ActionMenu.Root slot="tooltip">
|
||||
<ActionMenu.Item.Button
|
||||
leadingIcon={IconInboxIn}
|
||||
disabled={isUnarchiveDisabled()}
|
||||
on:click={() => handleUnarchiveProject(project)}
|
||||
>Unarchive project</ActionMenu.Item.Button>
|
||||
<ActionMenu.Item.Button
|
||||
leadingIcon={IconSwitchHorizontal}
|
||||
on:click={() => handleMigrateProject(project)}
|
||||
>Migrate project</ActionMenu.Item.Button>
|
||||
<div class="action-menu-divider">
|
||||
<Divider />
|
||||
</div>
|
||||
<ActionMenu.Item.Button
|
||||
status="danger"
|
||||
leadingIcon={IconTrash}
|
||||
on:click={() => handleDeleteProject(project)}
|
||||
>Delete project</ActionMenu.Item.Button>
|
||||
</ActionMenu.Root>
|
||||
</Popover>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
{#each platforms.slice(0, 2) as platform}
|
||||
{@const icon = getIconForPlatform(platform.icon)}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
content={platform.name}
|
||||
style="width: max-content;">
|
||||
<Icon {icon} size="s" slot="start" />
|
||||
</Badge>
|
||||
{/each}
|
||||
|
||||
{#if platforms.length > 3}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
content={`+${platforms.length - 2}`}
|
||||
style="width: max-content;" />
|
||||
{/if}
|
||||
|
||||
<svelte:fragment slot="icons">
|
||||
{#if isCloud && $regionsStore?.regions}
|
||||
{@const region = findRegion(project)}
|
||||
<Typography.Text>{region?.name}</Typography.Text>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</GridItem1>
|
||||
{/each}
|
||||
</CardContainer>
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Unarchive Confirmation Modal -->
|
||||
<Modal bind:show={showUnarchiveModal} title="Unarchive project" size="s">
|
||||
<p>Are you sure you want to unarchive <strong>{projectToUnarchive?.name}</strong>?</p>
|
||||
<p>This will move the project back to your active projects list.</p>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Layout.Stack direction="row" gap="s" justifyContent="flex-end">
|
||||
<Button secondary on:click={cancelUnarchive}>Cancel</Button>
|
||||
<Button on:click={confirmUnarchive}>Unarchive</Button>
|
||||
</Layout.Stack>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<Modal
|
||||
size="s"
|
||||
bind:show={showDeleteModal}
|
||||
title="Delete project"
|
||||
onSubmit={confirmDelete}
|
||||
bind:error={deleteError}>
|
||||
<svelte:fragment slot="description">
|
||||
The archived project <strong>{projectToDelete?.name}</strong> will be deleted along with all
|
||||
of its metadata, stats, and other resources.
|
||||
<b>This action is irreversible.</b>
|
||||
</svelte:fragment>
|
||||
|
||||
<InputText
|
||||
label={`Enter "${projectToDelete?.name}" to continue`}
|
||||
placeholder="Enter name"
|
||||
id="delete-project-name"
|
||||
autofocus
|
||||
required
|
||||
bind:value={deleteProjectName} />
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Button
|
||||
text
|
||||
on:click={() => {
|
||||
resetDeleteState();
|
||||
}}>Cancel</Button>
|
||||
<Button
|
||||
submissionLoader
|
||||
submit
|
||||
disabled={(deleteProjectName ?? '') !== projectToDelete?.name}>
|
||||
Delete
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.archive-projects-margin-top {
|
||||
margin-top: 36px;
|
||||
}
|
||||
.action-menu-divider {
|
||||
margin-inline: -1rem;
|
||||
padding-block-start: 0.25rem;
|
||||
padding-block-end: 0.25rem;
|
||||
}
|
||||
|
||||
.archive-projects-margin {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.status-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -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} />
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
}
|
||||
|
||||
function updateOrAddItem(payload: Payload) {
|
||||
// todo: @itznotabug - might need a change to $table?
|
||||
const { $id, status, $collection, policyId } = payload;
|
||||
if ($collection === 'archives' && policyId !== null) {
|
||||
return;
|
||||
@@ -117,9 +118,9 @@
|
||||
}
|
||||
|
||||
function backupName(item: BackupArchive | BackupRestoration, key: string) {
|
||||
const attribute = key === 'archives' ? '$createdAt' : 'startedAt';
|
||||
const column = key === 'archives' ? '$createdAt' : 'startedAt';
|
||||
|
||||
return toLocaleDate(item[attribute]);
|
||||
return toLocaleDate(item[column]);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
||||
@@ -20,9 +20,14 @@
|
||||
plan. Consider upgrading to increase your resource usage.
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="buttons">
|
||||
<Button href={`${base}/organization-${$organization.$id}/usage`} text fullWidthMobile>
|
||||
<span class="text">View usage</span>
|
||||
</Button>
|
||||
{#if !page.data.currentPlan?.usagePerProject}
|
||||
<Button
|
||||
href={`${base}/organization-${$organization.$id}/usage`}
|
||||
text
|
||||
fullWidthMobile>
|
||||
<span class="text">View usage</span>
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
href={$upgradeURL}
|
||||
on:click={() => {
|
||||
|
||||
@@ -3,31 +3,33 @@
|
||||
import { Click } from '$lib/actions/analytics';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { HeaderAlert } from '$lib/layout';
|
||||
import {
|
||||
billingProjectsLimitDate,
|
||||
hideBillingHeaderRoutes,
|
||||
upgradeURL
|
||||
} from '$lib/stores/billing';
|
||||
import { currentPlan } from '$lib/stores/organization';
|
||||
import { onMount } from 'svelte';
|
||||
import { hideBillingHeaderRoutes, upgradeURL } from '$lib/stores/billing';
|
||||
import { currentPlan, organization } from '$lib/stores/organization';
|
||||
import SelectProjectCloud from './selectProjectCloud.svelte';
|
||||
import { toLocaleDate } from '$lib/helpers/date';
|
||||
let showSelectProject: boolean = $state(false);
|
||||
|
||||
let selectedProjects: string[] = $state([]);
|
||||
onMount(() => {
|
||||
selectedProjects = page.data.organization?.projects || [];
|
||||
let showSelectProject: boolean = $state(false);
|
||||
|
||||
const organizationId = $derived(page.data.organization?.$id);
|
||||
|
||||
$effect(() => {
|
||||
if (organizationId) {
|
||||
selectedProjects = page.data.organization?.projects || [];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<SelectProjectCloud bind:showSelectProject bind:selectedProjects />
|
||||
<SelectProjectCloud bind:showSelectProject bind:selectedProjects {organizationId} />
|
||||
|
||||
{#if $currentPlan && $currentPlan.projects > 0 && !hideBillingHeaderRoutes.includes(page.url.pathname)}
|
||||
<HeaderAlert
|
||||
type="warning"
|
||||
title="Action required: You have more than {$currentPlan.projects} projects.">
|
||||
<svelte:fragment>
|
||||
Choose which projects to keep before {toLocaleDate(billingProjectsLimitDate)} or upgrade
|
||||
to Pro. Projects over the limit will be blocked after this date.
|
||||
Choose which projects to keep before {toLocaleDate(
|
||||
$organization.billingNextInvoiceDate
|
||||
)} or upgrade to Pro. Projects over the limit will be blocked after this date.
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="buttons">
|
||||
<Button
|
||||
|
||||
@@ -1,33 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { type Models } from '@appwrite.io/console';
|
||||
import { Alert, Button, Table } from '@appwrite.io/pink-svelte';
|
||||
import { type Models, Query } from '@appwrite.io/console';
|
||||
import { Alert, Button, Skeleton, Table } from '@appwrite.io/pink-svelte';
|
||||
import { Modal } from '$lib/components';
|
||||
import { onMount } from 'svelte';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { billingProjectsLimitDate } from '$lib/stores/billing';
|
||||
import { page } from '$app/state';
|
||||
import { toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
|
||||
import { currentPlan } from '$lib/stores/organization';
|
||||
import { currentPlan, organization } from '$lib/stores/organization';
|
||||
|
||||
let {
|
||||
showSelectProject = $bindable(false),
|
||||
selectedProjects = $bindable([])
|
||||
selectedProjects = $bindable([]),
|
||||
organizationId
|
||||
}: {
|
||||
showSelectProject: boolean;
|
||||
selectedProjects: string[];
|
||||
organizationId: string;
|
||||
} = $props();
|
||||
|
||||
let projects = $state<Array<Models.Project>>([]);
|
||||
let error = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
let projectsLoadingError = $state<string | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
const currentOrgId = page.data.organization?.$id;
|
||||
projects =
|
||||
page.data.currentOrgId === currentOrgId ? page.data.allProjects?.projects || [] : [];
|
||||
});
|
||||
let error = $state<string | null>(null);
|
||||
let projects = $state<Array<Models.Project>>([]);
|
||||
|
||||
let projectsToArchive = $derived(
|
||||
projects.filter((project) => !selectedProjects.includes(project.$id))
|
||||
@@ -39,6 +35,7 @@
|
||||
projects[0].teamId,
|
||||
selectedProjects
|
||||
);
|
||||
|
||||
showSelectProject = false;
|
||||
invalidate(Dependencies.ORGANIZATION);
|
||||
addNotification({
|
||||
@@ -69,47 +66,100 @@
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!showSelectProject) return;
|
||||
|
||||
const areProjectsLoaded = projects.length > 0;
|
||||
const teamIdInLoadedProjects = areProjectsLoaded ? projects[0].teamId : null;
|
||||
|
||||
if (organizationId != teamIdInLoadedProjects) {
|
||||
loading = true;
|
||||
|
||||
sdk.forConsole.projects
|
||||
.list({
|
||||
queries: [
|
||||
Query.equal('teamId', organizationId),
|
||||
Query.limit(1000) // Get all projects for organization
|
||||
]
|
||||
})
|
||||
.then((loadedProjects) => (projects = loadedProjects.projects))
|
||||
.catch((err) => (projectsLoadingError = err.message))
|
||||
.finally(() => (loading = false));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal bind:show={showSelectProject} title={'Manage projects'} onSubmit={updateSelected}>
|
||||
<Modal bind:show={showSelectProject} title="Manage projects" onSubmit={updateSelected}>
|
||||
<svelte:fragment slot="description">
|
||||
Choose which {$currentPlan?.projects || 2} projects to keep. Projects over the limit will be
|
||||
blocked after this date.
|
||||
</svelte:fragment>
|
||||
{#if error}
|
||||
<Alert.Inline status="error" title="Error">{error}</Alert.Inline>
|
||||
{/if}
|
||||
<Table.Root
|
||||
let:root
|
||||
allowSelection
|
||||
bind:selectedRows={selectedProjects}
|
||||
columns={[{ id: 'name' }, { id: 'created' }]}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="name" {root}>Project Name</Table.Header.Cell>
|
||||
<Table.Header.Cell column="created" {root}>Created</Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
{#each projects as project}
|
||||
<Table.Row.Base {root} id={project.$id}>
|
||||
<Table.Cell column="name" {root}>{project.name}</Table.Cell>
|
||||
<Table.Cell column="created" {root}
|
||||
>{toLocaleDateTime(project.$createdAt)}</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
{#if selectedProjects.length > $currentPlan?.projects}
|
||||
<div class="u-text-warning u-mb-4">
|
||||
You can only select {$currentPlan?.projects} projects. Please deselect others to continue.
|
||||
|
||||
{#if loading}
|
||||
<div class="skeleton-projects">
|
||||
<Table.Root let:root allowSelection columns={[{ id: 'name' }, { id: 'created' }]}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="name" {root}>Project Name</Table.Header.Cell>
|
||||
<Table.Header.Cell column="created" {root}>Created</Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
|
||||
{#each Array.from({ length: 5 }) as _}
|
||||
<Table.Row.Base {root} select="disabled">
|
||||
<Table.Cell column="name" {root}>
|
||||
<Skeleton variant="line" height={20} width="80%" />
|
||||
</Table.Cell>
|
||||
<Table.Cell column="created" {root}>
|
||||
<Skeleton variant="line" height={20} width="80%" />
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedProjects.length === $currentPlan?.projects}
|
||||
<Alert.Inline
|
||||
status="warning"
|
||||
title={`${projects.length - selectedProjects.length} projects will be archived on ${toLocaleDate(billingProjectsLimitDate)}`}>
|
||||
<span>
|
||||
{@html formatProjectsToArchive()}
|
||||
will be archived.
|
||||
</span>
|
||||
</Alert.Inline>
|
||||
{:else if projectsLoadingError}
|
||||
<Alert.Inline status="error" title="Error">{projectsLoadingError}</Alert.Inline>
|
||||
{:else}
|
||||
{#if error}
|
||||
<Alert.Inline status="error" title="Error">{error}</Alert.Inline>
|
||||
{/if}
|
||||
|
||||
<Table.Root
|
||||
let:root
|
||||
allowSelection
|
||||
bind:selectedRows={selectedProjects}
|
||||
columns={[{ id: 'name' }, { id: 'created' }]}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="name" {root}>Project Name</Table.Header.Cell>
|
||||
<Table.Header.Cell column="created" {root}>Created</Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
{#each projects as project}
|
||||
<Table.Row.Base {root} id={project.$id}>
|
||||
<Table.Cell column="name" {root}>{project.name}</Table.Cell>
|
||||
<Table.Cell column="created" {root}
|
||||
>{toLocaleDateTime(project.$createdAt)}</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
|
||||
{#if selectedProjects.length > $currentPlan?.projects}
|
||||
<div class="u-text-warning u-mb-4">
|
||||
You can only select {$currentPlan?.projects} projects. Please deselect others to continue.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedProjects.length === $currentPlan?.projects}
|
||||
{@const difference = projects.length - selectedProjects.length}
|
||||
{@const messagePrefix =
|
||||
difference > 1 ? `${difference} projects` : `${difference} project`}
|
||||
<Alert.Inline
|
||||
status="warning"
|
||||
title={`${messagePrefix} will be archived on ${toLocaleDate($organization.billingNextInvoiceDate)}`}>
|
||||
<span>
|
||||
{@html formatProjectsToArchive()}
|
||||
will be archived.
|
||||
</span>
|
||||
</Alert.Inline>
|
||||
{/if}
|
||||
{/if}
|
||||
<svelte:fragment slot="footer">
|
||||
<Button.Button size="s" variant="secondary" on:click={() => (showSelectProject = false)}
|
||||
@@ -118,3 +168,13 @@
|
||||
>Save</Button.Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
/* disable the top select all selector button */
|
||||
.skeleton-projects :global([role='rowheader'] button) {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
background: var(--bgcolor-neutral-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { IconX } from '@appwrite.io/pink-icons-svelte';
|
||||
import { isTabletViewport } from '$lib/stores/viewport';
|
||||
import PinkBackground from '$lib/images/pink-background.svg';
|
||||
import { bannerSpacing } from '$lib/layout/headerAlert.svelte';
|
||||
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
|
||||
|
||||
export let variant: 'gradient' | 'image' = 'gradient';
|
||||
@@ -23,12 +24,22 @@
|
||||
const { header, sidebar, content } = queryLayoutElements();
|
||||
const headerHeight = header?.getBoundingClientRect().height || 0;
|
||||
const offset = alertHeight + (!$isTabletViewport && header ? headerHeight : 0);
|
||||
if (header) header.style.top = `${alertHeight}px`;
|
||||
|
||||
if (header) {
|
||||
header.style.top = `${alertHeight}px`;
|
||||
}
|
||||
|
||||
if (sidebar) {
|
||||
sidebar.style.top = `${offset}px`;
|
||||
sidebar.style.height = `calc(100vh - ${offset}px)`;
|
||||
|
||||
// for sidebar and sub-navigation!
|
||||
bannerSpacing.set(`${alertHeight}px`);
|
||||
}
|
||||
|
||||
if (content) {
|
||||
content.style.paddingBlockStart = `${alertHeight}px`;
|
||||
}
|
||||
if (content) content.style.paddingBlockStart = `${alertHeight}px`;
|
||||
};
|
||||
|
||||
onMount(setNavigationHeight);
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
const card = await submitStripeCard(name, page?.params?.organization ?? null);
|
||||
if (card && Object.hasOwn(card, 'id')) {
|
||||
if ((card as PaymentMethod).card.country === 'US') {
|
||||
if ((card as PaymentMethod).card?.country === 'US') {
|
||||
paymentMethod = card as PaymentMethod;
|
||||
showState = true;
|
||||
return;
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
<Typography.Text>Everything in the Free plan, plus:</Typography.Text>
|
||||
<ul class="un-order-list">
|
||||
<li>Unlimited databases, buckets, functions</li>
|
||||
<li>Unlimited seats</li>
|
||||
<li>{plan.bandwidth}GB bandwidth</li>
|
||||
<li>{plan.storage}GB storage</li>
|
||||
<li>{formatNum(plan.executions)} executions</li>
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { calculateExcess, plansInfo, tierToPlan, type Tier } from '$lib/stores/billing';
|
||||
import {
|
||||
calculateExcess,
|
||||
plansInfo,
|
||||
tierToPlan,
|
||||
getServiceLimit,
|
||||
type Tier
|
||||
} from '$lib/stores/billing';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { toLocaleDate } from '$lib/helpers/date';
|
||||
import { humanFileSize } from '$lib/helpers/sizeConvertion';
|
||||
import { abbreviateNumber } from '$lib/helpers/numbers';
|
||||
import { formatNum } from '$lib/helpers/string';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Aggregation } from '$lib/sdk/billing';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { Alert, Icon, Table, Tooltip } from '@appwrite.io/pink-svelte';
|
||||
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
|
||||
import type { AggregationTeam } from '$lib/sdk/billing';
|
||||
|
||||
export let tier: Tier;
|
||||
|
||||
@@ -22,7 +28,7 @@
|
||||
executions?: number;
|
||||
members?: number;
|
||||
} = null;
|
||||
let aggregation: Aggregation = null;
|
||||
let aggregation: AggregationTeam = null;
|
||||
let showExcess = false;
|
||||
|
||||
onMount(async () => {
|
||||
@@ -64,7 +70,7 @@
|
||||
{#if excess?.members}
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell {root}>Organization members</Table.Cell>
|
||||
<Table.Cell {root}>{plan.addons.seats.limit} members</Table.Cell>
|
||||
<Table.Cell {root}>{getServiceLimit('members', tier)} members</Table.Cell>
|
||||
<Table.Cell {root}>
|
||||
<p class="u-color-text-danger u-flex u-cross-center u-gap-4">
|
||||
<span class="icon-arrow-up"></span>
|
||||
|
||||
@@ -1,81 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { BASE_BILLING_PLANS, BillingPlan } from '$lib/constants';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { formatCurrency } from '$lib/helpers/numbers';
|
||||
import { plansInfo, type Tier, tierFree, tierPro, tierScale } from '$lib/stores/billing';
|
||||
import { currentPlan, organization } from '$lib/stores/organization';
|
||||
import { Badge, Layout, Typography } from '@appwrite.io/pink-svelte';
|
||||
import { LabelCard } from '..';
|
||||
import type { Plan } from '$lib/sdk/billing';
|
||||
import { page } from '$app/state';
|
||||
|
||||
export let billingPlan: Tier;
|
||||
export let anyOrgFree = false;
|
||||
export let billingPlan: BillingPlan;
|
||||
export let isNewOrg = false;
|
||||
export let selfService = true;
|
||||
export let anyOrgFree = false;
|
||||
|
||||
$: freePlan = $plansInfo.get(BillingPlan.FREE);
|
||||
$: proPlan = $plansInfo.get(BillingPlan.PRO);
|
||||
$: scalePlan = $plansInfo.get(BillingPlan.SCALE);
|
||||
|
||||
$: isBasePlan = BASE_BILLING_PLANS.includes($currentPlan?.$id);
|
||||
$: plans = Object.values(page.data.plans.plans) as Plan[];
|
||||
$: currentPlanInList = plans.some((plan) => plan.$id === $currentPlan?.$id);
|
||||
</script>
|
||||
|
||||
<Layout.Stack>
|
||||
<LabelCard
|
||||
name="plan"
|
||||
bind:group={billingPlan}
|
||||
disabled={anyOrgFree || !selfService}
|
||||
value={BillingPlan.FREE}
|
||||
title={tierFree.name}
|
||||
tooltipShow={anyOrgFree}
|
||||
tooltipText="You are limited to 1 Free organization per account."
|
||||
tooltipWidth="100%">
|
||||
<svelte:fragment slot="action">
|
||||
{#if $organization?.billingPlan === BillingPlan.FREE && !isNewOrg}
|
||||
<Badge variant="secondary" size="xs" content="Current plan" />
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
<Typography.Caption variant="400">
|
||||
{tierFree.description}
|
||||
</Typography.Caption>
|
||||
<Typography.Text>
|
||||
{formatCurrency(freePlan?.price ?? 0)}
|
||||
</Typography.Text>
|
||||
</LabelCard>
|
||||
<LabelCard
|
||||
name="plan"
|
||||
disabled={!selfService}
|
||||
bind:group={billingPlan}
|
||||
value={BillingPlan.PRO}
|
||||
title={tierPro.name}>
|
||||
<svelte:fragment slot="action">
|
||||
{#if $organization?.billingPlan === BillingPlan.PRO && !isNewOrg}
|
||||
<Badge variant="secondary" size="xs" content="Current plan" />
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
<Typography.Caption variant="400">
|
||||
{tierPro.description}
|
||||
</Typography.Caption>
|
||||
<Typography.Text>
|
||||
{formatCurrency(proPlan?.price ?? 0)} per month + usage
|
||||
</Typography.Text>
|
||||
</LabelCard>
|
||||
<LabelCard
|
||||
name="plan"
|
||||
bind:group={billingPlan}
|
||||
value={BillingPlan.SCALE}
|
||||
title={tierScale.name}>
|
||||
<svelte:fragment slot="action">
|
||||
{#if $organization?.billingPlan === BillingPlan.SCALE && !isNewOrg}
|
||||
<Badge variant="secondary" size="xs" content="Current plan" />
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
<Typography.Caption variant="400">
|
||||
{tierScale.description}
|
||||
</Typography.Caption>
|
||||
<Typography.Text>
|
||||
{formatCurrency(scalePlan?.price ?? 0)} per month + usage
|
||||
</Typography.Text>
|
||||
</LabelCard>
|
||||
{#if $currentPlan && !isBasePlan}
|
||||
{#each plans as plan}
|
||||
<LabelCard
|
||||
name="plan"
|
||||
bind:group={billingPlan}
|
||||
disabled={!selfService || (plan.$id === BillingPlan.FREE && anyOrgFree)}
|
||||
tooltipShow={plan.$id === BillingPlan.FREE && anyOrgFree}
|
||||
value={plan.$id}
|
||||
title={plan.name}>
|
||||
<svelte:fragment slot="action">
|
||||
{#if $organization?.billingPlan === plan.$id && !isNewOrg}
|
||||
<Badge variant="secondary" size="xs" content="Current plan" />
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
<Typography.Caption variant="400">
|
||||
{plan.desc}
|
||||
</Typography.Caption>
|
||||
<Typography.Text>
|
||||
{@const isZeroPrice = (plan.price ?? 0) <= 0}
|
||||
{@const price = formatCurrency(plan.price ?? 0)}
|
||||
{isZeroPrice ? price : `${price} per month + usage`}
|
||||
</Typography.Text>
|
||||
</LabelCard>
|
||||
{/each}
|
||||
{#if $currentPlan && !currentPlanInList}
|
||||
<LabelCard
|
||||
name="plan"
|
||||
bind:group={billingPlan}
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
</Layout.Stack>
|
||||
{:else}
|
||||
<Card.Base variant="secondary" radius="s" padding="xs">
|
||||
<Layout.Stack direction="row">
|
||||
<Layout.Stack direction="row" id="no-payments-card-stack">
|
||||
<Layout.Stack direction="row" gap="xxs" alignItems="center">
|
||||
<Icon
|
||||
icon={IconExclamationCircle}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { toLocaleDate } from '$lib/helpers/date';
|
||||
import { type Organization } from '$lib/stores/organization';
|
||||
import { plansInfo } from '$lib/stores/billing';
|
||||
import { abbreviateNumber, formatCurrency } from '$lib/helpers/numbers';
|
||||
import { abbreviateNumber, formatCurrency, isWithinSafeRange } from '$lib/helpers/numbers';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { Table, Typography } from '@appwrite.io/pink-svelte';
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
|
||||
// equal or above means unlimited!
|
||||
const getCorrectSeatsCountValue = (count: number): string | number => {
|
||||
// php int max is always larger than js
|
||||
const exceedsSafeLimit = count >= Number.MAX_SAFE_INTEGER;
|
||||
return exceedsSafeLimit ? 'Unlimited' : count || 0;
|
||||
// Check for Infinity or very large numbers
|
||||
const isUnlimited = count === Infinity || !isWithinSafeRange(count);
|
||||
return isUnlimited ? 'Unlimited' : count || 0;
|
||||
};
|
||||
|
||||
function getPlanLimit(key: string): number | false {
|
||||
|
||||
@@ -20,9 +20,12 @@
|
||||
import { isCloud } from '$lib/system';
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { currentPlan, newOrgModal } from '$lib/stores/organization';
|
||||
import { currentPlan, newOrgModal, organization } from '$lib/stores/organization';
|
||||
import { Click, trackEvent } from '$lib/actions/analytics';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { type Models, Query } from '@appwrite.io/console';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { page } from '$app/state';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
|
||||
type Organization = {
|
||||
name: string;
|
||||
@@ -63,15 +66,15 @@
|
||||
}
|
||||
} = createMenu();
|
||||
|
||||
let isLoadingProjects = true;
|
||||
let isLoadingProjects = false;
|
||||
let loadedProjects: Models.ProjectList = { total: 0, projects: [] };
|
||||
|
||||
export let organizations: Organization[] = [];
|
||||
export let currentProject: Models.Project | null = null;
|
||||
export let projects: Promise<Models.ProjectList> = Promise.resolve(loadedProjects);
|
||||
|
||||
let projectsBottomSheetOpen = false;
|
||||
let organisationBottomSheetOpen = false;
|
||||
let projectsBottomSheet: Promise<SheetMenu | null> = null;
|
||||
|
||||
function createOrg() {
|
||||
trackEvent(Click.OrganizationClickCreate, { source: 'breadcrumbs' });
|
||||
@@ -99,13 +102,27 @@
|
||||
}
|
||||
};
|
||||
|
||||
async function createProjectsBottomSheet(organization: Organization): Promise<SheetMenu> {
|
||||
async function createProjectsBottomSheet(
|
||||
organization: Organization
|
||||
): Promise<SheetMenu | null> {
|
||||
if (!isOnProjects) return null;
|
||||
|
||||
isLoadingProjects = true;
|
||||
|
||||
// null on non-org/project path like `onboarding`.
|
||||
loadedProjects = (await projects) ?? loadedProjects;
|
||||
loadedProjects =
|
||||
(await sdk.forConsole.projects.list({
|
||||
queries: [
|
||||
Query.equal('teamId', organizationId ?? page.data.currentOrgId),
|
||||
Query.limit(5),
|
||||
Query.orderDesc('$updatedAt')
|
||||
]
|
||||
})) ?? loadedProjects;
|
||||
|
||||
for (const project of loadedProjects.projects) {
|
||||
project.region ??= 'default';
|
||||
}
|
||||
|
||||
isLoadingProjects = false;
|
||||
|
||||
const createProjectItem = {
|
||||
@@ -185,8 +202,6 @@
|
||||
|
||||
$: selectedOrg = organizations.find((org) => org.isSelected);
|
||||
|
||||
$: projectsBottomSheet = createProjectsBottomSheet(selectedOrg);
|
||||
|
||||
$: organizationsBottomSheet = createOrganizationBottomSheet(selectedOrg);
|
||||
|
||||
$: correctPlanName =
|
||||
@@ -198,6 +213,24 @@
|
||||
: selectedOrg?.tierName; // fallback
|
||||
|
||||
$: derivedKey = `${selectedOrg?.$id}-${currentProject?.$id}`;
|
||||
|
||||
$: organizationId = currentProject?.teamId;
|
||||
|
||||
$: isOnProjects = page.route.id.includes('project-[region]-[project]');
|
||||
|
||||
$: shouldReloadProjects = isLoadingProjects
|
||||
? false
|
||||
: currentProject && loadedProjects.projects.length
|
||||
? loadedProjects.projects[0].teamId != currentProject.teamId
|
||||
: !loadedProjects.projects.length;
|
||||
|
||||
$: if (shouldReloadProjects) {
|
||||
projectsBottomSheet = createProjectsBottomSheet(selectedOrg);
|
||||
}
|
||||
|
||||
let badgeType: 'success' | undefined;
|
||||
$: badgeType =
|
||||
$organization && $organization.billingPlan !== BillingPlan.FREE ? 'success' : undefined;
|
||||
</script>
|
||||
|
||||
<svelte:window on:resize={onResize} />
|
||||
@@ -212,10 +245,13 @@
|
||||
use:melt={$triggerOrganizations}
|
||||
aria-label="Open organizations tab">
|
||||
<span class="orgName">{selectedOrg?.name ?? 'Organization'}</span>
|
||||
<span class="not-mobile"
|
||||
<span class="not-mobile" style="padding-inline-start: 2px"
|
||||
>{#if correctPlanName}<Badge
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
content={correctPlanName} />{/if}</span>
|
||||
type={badgeType}
|
||||
content={correctPlanName} />
|
||||
{/if}</span>
|
||||
<Icon icon={IconChevronDown} size="s" color="--fgcolor-neutral-secondary" />
|
||||
</button>
|
||||
{:else}
|
||||
@@ -229,7 +265,11 @@
|
||||
<span class="orgName" class:noProjects={!currentProject}
|
||||
>{selectedOrg?.name ?? 'Organization'}</span>
|
||||
<span class="not-mobile"
|
||||
><Badge variant="secondary" content={correctPlanName ?? ''} /></span>
|
||||
><Badge
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
type={badgeType}
|
||||
content={correctPlanName ?? ''} /></span>
|
||||
<Icon icon={IconChevronDown} size="s" color="--fgcolor-neutral-secondary" />
|
||||
</button>
|
||||
{/if}
|
||||
@@ -380,7 +420,9 @@
|
||||
<BottomSheet.Menu bind:isOpen={organisationBottomSheetOpen} menu={organizationsBottomSheet} />
|
||||
|
||||
{#await projectsBottomSheet then menu}
|
||||
<BottomSheet.Menu bind:isOpen={projectsBottomSheetOpen} {menu} />
|
||||
{#if menu}
|
||||
<BottomSheet.Menu bind:isOpen={projectsBottomSheetOpen} {menu} />
|
||||
{/if}
|
||||
{/await}
|
||||
{/key}
|
||||
|
||||
@@ -446,6 +488,7 @@
|
||||
border-radius: var(--border-radius-S, 8px);
|
||||
background: var(--overlay-neutral-hover, rgba(25, 25, 28, 0.03));
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -4,23 +4,49 @@
|
||||
import { preferences } from '$lib/stores/preferences';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import type { Column } from '$lib/helpers/types';
|
||||
import { ActionMenu, Layout, Popover, Selector } from '@appwrite.io/pink-svelte';
|
||||
import {
|
||||
ActionMenu,
|
||||
Divider,
|
||||
Layout,
|
||||
Popover,
|
||||
Selector,
|
||||
Typography
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
|
||||
let {
|
||||
columns,
|
||||
isCustomCollection = false,
|
||||
isCustomTable = false,
|
||||
ui = 'legacy',
|
||||
allowNoColumns = false,
|
||||
children
|
||||
showAnyway = false,
|
||||
children,
|
||||
onPreferencesUpdated = null
|
||||
}: {
|
||||
columns: Writable<Column[]>;
|
||||
isCustomCollection?: boolean;
|
||||
isCustomTable?: boolean;
|
||||
allowNoColumns?: boolean;
|
||||
ui?: 'legacy' | 'new';
|
||||
showAnyway?: boolean;
|
||||
children: Snippet<[toggle: () => void, selectedColumnsNumber: number]>;
|
||||
onPreferencesUpdated?: () => void;
|
||||
} = $props();
|
||||
|
||||
let search = $state('');
|
||||
let filteredColumns = $derived(
|
||||
$columns
|
||||
.filter((column) => !column.isAction && column.id !== '$sequence')
|
||||
.filter(
|
||||
(col) =>
|
||||
!col.exclude &&
|
||||
(col.title?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
col.id?.toLowerCase().includes(search.toLowerCase()))
|
||||
)
|
||||
);
|
||||
|
||||
let maxHeight = $state('none');
|
||||
let containerRef = $state<HTMLElement>(null);
|
||||
const collectionId = $derived(page.params.collection);
|
||||
const isNewStyle = ui === 'new';
|
||||
|
||||
const calcMaxHeight = () => {
|
||||
if (containerRef) {
|
||||
@@ -33,27 +59,28 @@
|
||||
};
|
||||
|
||||
const saveColumnPreferences = () => {
|
||||
const shownColumns = $columns.filter((n) => n.hide === true).map((n) => n.id);
|
||||
const hiddenColumns = $columns.filter((n) => n.hide === true).map((n) => n.id);
|
||||
|
||||
if (isCustomCollection) {
|
||||
preferences.setCustomCollectionColumns(collectionId, shownColumns);
|
||||
if (isCustomTable) {
|
||||
onPreferencesUpdated?.();
|
||||
preferences.setCustomTableColumns(page.params.table, hiddenColumns);
|
||||
} else {
|
||||
preferences.setColumns(shownColumns);
|
||||
preferences.setColumns(hiddenColumns);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (isCustomCollection) {
|
||||
const shownColumns = preferences.getCustomCollectionColumns(collectionId);
|
||||
if (isCustomTable) {
|
||||
const hiddenColumns = preferences.getCustomTableColumns(page.params.table);
|
||||
|
||||
columns.update((n) =>
|
||||
n.map((column) => {
|
||||
column.hide = shownColumns?.includes(column.id) ?? false;
|
||||
column.hide = hiddenColumns?.includes(column.id) ?? false;
|
||||
return column;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
const prefs = preferences.get(page.route);
|
||||
const prefs = preferences.getForRoute(page.route);
|
||||
|
||||
if (prefs?.columns) {
|
||||
columns.update((n) =>
|
||||
@@ -70,7 +97,7 @@
|
||||
|
||||
let selectedColumnsNumber = $derived(
|
||||
$columns.reduce((acc, column) => {
|
||||
if (column.hide) return acc;
|
||||
if (column.hide || column.isAction) return acc;
|
||||
|
||||
return ++acc;
|
||||
}, 0)
|
||||
@@ -78,45 +105,169 @@
|
||||
|
||||
function toggleColumn(column: Column) {
|
||||
columns.update((cols) =>
|
||||
cols.map((col) => {
|
||||
if (col.id === column.id) {
|
||||
column.hide = !column.hide;
|
||||
}
|
||||
return col;
|
||||
})
|
||||
cols.map((col) => (col.id === column.id ? { ...col, hide: !col.hide } : col))
|
||||
);
|
||||
|
||||
saveColumnPreferences();
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
columns.update((cols) =>
|
||||
cols.map((col) =>
|
||||
col.exclude
|
||||
? col
|
||||
: filteredColumns.some((fc) => fc.id === col.id)
|
||||
? { ...col, hide: false }
|
||||
: col
|
||||
)
|
||||
);
|
||||
saveColumnPreferences();
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
columns.update((cols) => {
|
||||
const realColumns = cols.filter((col) => !col.exclude && !col.isAction);
|
||||
const filtered = filteredColumns.filter((col) => !col.exclude && !col.isAction);
|
||||
|
||||
if (filtered.length === 0) return cols;
|
||||
|
||||
const visibleRealColumns = realColumns.filter((col) => !col.hide);
|
||||
|
||||
if (!allowNoColumns && visibleRealColumns.length <= 1) {
|
||||
return cols;
|
||||
}
|
||||
|
||||
const willHideCount = filtered.filter((col) => !col.hide).length;
|
||||
|
||||
if (!allowNoColumns && visibleRealColumns.length - willHideCount < 1) {
|
||||
const [keep] = filtered;
|
||||
return cols.map((col) => {
|
||||
if (col.exclude || col.isAction) return col;
|
||||
if (!filtered.some((fc) => fc.id === col.id)) return col;
|
||||
|
||||
if (col.id === keep.id) {
|
||||
const stillVisible = cols
|
||||
.filter((c) => !c.exclude && !c.isAction)
|
||||
.filter((c) =>
|
||||
c.id === keep.id
|
||||
? true
|
||||
: filtered.some((fc) => fc.id === c.id)
|
||||
? false
|
||||
: !c.hide
|
||||
);
|
||||
if (stillVisible.length === 1 && typeof col.width === 'number') {
|
||||
return { ...col, hide: false, width: { min: col.width } };
|
||||
}
|
||||
return { ...col, hide: false };
|
||||
}
|
||||
|
||||
return { ...col, hide: true };
|
||||
});
|
||||
}
|
||||
|
||||
return cols.map((col) =>
|
||||
col.exclude || col.isAction
|
||||
? col
|
||||
: filtered.some((fc) => fc.id === col.id)
|
||||
? { ...col, hide: true }
|
||||
: col
|
||||
);
|
||||
});
|
||||
saveColumnPreferences();
|
||||
}
|
||||
|
||||
let visibleRealColumns = $derived(
|
||||
$columns.filter((col) => !col.exclude && !col.isAction && !col.hide)
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:window on:resize={calcMaxHeight} />
|
||||
|
||||
{#if $columns?.length}
|
||||
<Popover let:toggle placement="bottom-end" padding="none">
|
||||
{#if $columns?.length || showAnyway}
|
||||
{@const showActions = $columns.length > 1}
|
||||
{@const placement = isNewStyle ? 'bottom-start' : 'bottom-end'}
|
||||
<Popover let:toggle {placement} padding="none">
|
||||
{@render children(toggle, selectedColumnsNumber)}
|
||||
<svelte:fragment slot="tooltip">
|
||||
<div style:max-height={maxHeight} style:overflow="scroll" bind:this={containerRef}>
|
||||
<div bind:this={containerRef} class="actions-menu-wrapper" style:max-height={maxHeight}>
|
||||
<ActionMenu.Root>
|
||||
{#each $columns as column}
|
||||
{#if !column?.exclude}
|
||||
<ActionMenu.Item.Button
|
||||
on:click={() => toggleColumn(column)}
|
||||
disabled={allowNoColumns
|
||||
? false
|
||||
: selectedColumnsNumber <= 1 && column.hide !== true}>
|
||||
<Layout.Stack direction="row" gap="s">
|
||||
<Selector.Checkbox
|
||||
checked={!column.hide}
|
||||
size="s"
|
||||
on:click={() => toggleColumn(column)} />
|
||||
{column.title}
|
||||
{#if isNewStyle && showActions}
|
||||
<Layout.Stack gap="xs">
|
||||
<ActionMenu.Item.Input
|
||||
id="columns"
|
||||
placeholder="Search"
|
||||
bind:value={search} />
|
||||
|
||||
{#if filteredColumns.length > 0}
|
||||
<Layout.Stack
|
||||
gap="s"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
style="padding-block-end: 0.5rem">
|
||||
<Button size="xs" icon extraCompact on:click={selectAll}
|
||||
>Select all</Button>
|
||||
|
||||
<div style:height="1rem">
|
||||
<Divider vertical />
|
||||
</div>
|
||||
|
||||
<Button size="xs" icon extraCompact on:click={deselectAll}
|
||||
>Deselect all</Button>
|
||||
</Layout.Stack>
|
||||
</ActionMenu.Item.Button>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<div style:padding-inline="0.6rem" style:padding-block-end="0.5rem">
|
||||
<Typography.Text>No results found</Typography.Text>
|
||||
</div>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
|
||||
<Layout.Stack
|
||||
gap="none"
|
||||
direction="column"
|
||||
class="filter-modal-actions-menu {showActions ? 'has-actions' : ''}">
|
||||
{#each filteredColumns as column}
|
||||
{#if !column?.exclude}
|
||||
<ActionMenu.Item.Button
|
||||
on:click={() => toggleColumn(column)}
|
||||
disabled={allowNoColumns
|
||||
? false
|
||||
: visibleRealColumns.length <= 1 && !column.hide}>
|
||||
<Layout.Stack direction="row" gap="s">
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
checked={!column.hide}
|
||||
on:change={() => toggleColumn(column)} />
|
||||
{column.title}
|
||||
</Layout.Stack>
|
||||
</ActionMenu.Item.Button>
|
||||
{/if}
|
||||
{/each}
|
||||
</Layout.Stack>
|
||||
</ActionMenu.Root>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Popover>
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore css_unused_selector -->
|
||||
<style lang="scss">
|
||||
/* global because its on a tooltip slot, nested */
|
||||
:global(.actions-menu-wrapper) {
|
||||
overflow: scroll;
|
||||
margin-bottom: -0.35rem;
|
||||
|
||||
& :global(.filter-modal-actions-menu) {
|
||||
overflow: scroll;
|
||||
max-height: 150px;
|
||||
padding-bottom: unset;
|
||||
|
||||
&.has-actions {
|
||||
margin-bottom: -0.5rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
export let disabled: boolean = false;
|
||||
export let submissionLoader = false;
|
||||
export let confirmDeletion: boolean = false;
|
||||
export let confirmDeletionLabel: string = 'I understand and confirm';
|
||||
export let onSubmit: (e: SubmitEvent) => Promise<void> | void = function () {
|
||||
return;
|
||||
};
|
||||
@@ -46,7 +47,7 @@
|
||||
required
|
||||
id={checkboxId}
|
||||
bind:checked={confirm}
|
||||
label="I understand and confirm" />
|
||||
label={confirmDeletionLabel} />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { trackEvent } from '$lib/actions/analytics';
|
||||
import { clickOnEnter } from '$lib/helpers/a11y';
|
||||
import type { ComponentProps, Snippet } from 'svelte';
|
||||
import { copy } from '$lib/helpers/copy';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { clickOnEnter } from '$lib/helpers/a11y';
|
||||
import { Tooltip } from '@appwrite.io/pink-svelte';
|
||||
import { trackEvent } from '$lib/actions/analytics';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
|
||||
export let value: string;
|
||||
export let event: string = null;
|
||||
export let eventContext = 'click_id_tag';
|
||||
export let tooltipDisabled = false;
|
||||
export let copyText: string = 'Click to copy';
|
||||
export type TooltipPlacement = NonNullable<ComponentProps<Tooltip>['placement']>;
|
||||
|
||||
let content = copyText;
|
||||
let {
|
||||
value,
|
||||
event = null,
|
||||
eventContext = 'click_id_tag',
|
||||
tooltipDisabled = false,
|
||||
tooltipPortal = false,
|
||||
tooltipDelay = 0,
|
||||
tooltipPlacement = undefined,
|
||||
copyText = 'Click to copy',
|
||||
children
|
||||
}: {
|
||||
value: string;
|
||||
event?: string;
|
||||
eventContext?: string;
|
||||
tooltipDisabled?: boolean;
|
||||
tooltipPortal?: boolean;
|
||||
tooltipDelay?: number;
|
||||
tooltipPlacement?: TooltipPlacement;
|
||||
copyText?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
let content = $state(copyText);
|
||||
|
||||
async function handleClick() {
|
||||
const success = await copy(value);
|
||||
@@ -34,17 +53,25 @@
|
||||
//TODO: remove this component
|
||||
</script>
|
||||
|
||||
<Tooltip disabled={tooltipDisabled}>
|
||||
<Tooltip
|
||||
disabled={tooltipDisabled}
|
||||
portal={tooltipPortal}
|
||||
delay={tooltipDelay}
|
||||
placement={tooltipPlacement}>
|
||||
<span
|
||||
data-private
|
||||
style:display="inline-flex"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
style:cursor="pointer"
|
||||
on:click|preventDefault|stopPropagation={handleClick}
|
||||
on:keyup={clickOnEnter}
|
||||
on:mouseenter={() => setTimeout(() => (content = copyText))}>
|
||||
<slot />
|
||||
onclick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleClick();
|
||||
}}
|
||||
onkeyup={clickOnEnter}
|
||||
onmouseenter={() => setTimeout(() => (content = copyText))}>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
<p slot="tooltip" let:showing>
|
||||
{#if showing}
|
||||
|
||||
@@ -10,13 +10,16 @@
|
||||
export let height = 16;
|
||||
|
||||
$: ccImage = isValueOfStringEnum(CreditCard, brand)
|
||||
? sdk.forConsole.avatars.getCreditCard(brand, width, height)
|
||||
? sdk.forConsole.avatars.getCreditCard({
|
||||
code: brand,
|
||||
width,
|
||||
height
|
||||
})
|
||||
: '';
|
||||
</script>
|
||||
|
||||
{#if ccImage}
|
||||
<img alt={brand} src={ccImage} {width} {height} style:border-radius="2.5px" />
|
||||
{:else}
|
||||
<!-- fallback: unionpay image not in Avatars API -->
|
||||
<Icon icon={IconCreditCard} color="--fgcolor-neutral-tertiary" />
|
||||
{/if}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { Click, trackEvent } from '$lib/actions/analytics';
|
||||
import { InputId } from '$lib/elements/forms';
|
||||
import { InputProjectId } from '$lib/elements/forms';
|
||||
import Button from '$lib/elements/forms/button.svelte';
|
||||
import { IconX } from '@appwrite.io/pink-icons-svelte';
|
||||
import { Click, trackEvent } from '$lib/actions/analytics';
|
||||
import { Card, Divider, Icon, Layout, Typography } from '@appwrite.io/pink-svelte';
|
||||
export let show = false;
|
||||
export let name: string;
|
||||
export let id: string;
|
||||
export let autofocus = true;
|
||||
export let isProject = false;
|
||||
$: if (!show) {
|
||||
id = null;
|
||||
}
|
||||
|
||||
$: if (!id?.length) {
|
||||
id = null;
|
||||
}
|
||||
let {
|
||||
show = $bindable(false),
|
||||
name,
|
||||
id = $bindable(null),
|
||||
autofocus = true,
|
||||
isProject = false,
|
||||
required = true
|
||||
}: {
|
||||
show: boolean;
|
||||
name: string;
|
||||
id: string;
|
||||
autofocus?: boolean;
|
||||
isProject?: boolean;
|
||||
required?: boolean;
|
||||
} = $props();
|
||||
|
||||
$: if (show) {
|
||||
trackEvent(Click.ShowCustomIdClick);
|
||||
}
|
||||
$effect(() => {
|
||||
if (!show) {
|
||||
id = null;
|
||||
}
|
||||
|
||||
if (!id?.length) {
|
||||
id = null;
|
||||
}
|
||||
|
||||
if (show) {
|
||||
trackEvent(Click.ShowCustomIdClick);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
@@ -45,9 +59,9 @@
|
||||
<Divider />
|
||||
</span>
|
||||
{#if isProject}
|
||||
<InputProjectId bind:value={id} {autofocus} />
|
||||
<InputProjectId on:input bind:value={id} {autofocus} />
|
||||
{:else}
|
||||
<InputId required bind:value={id} {autofocus} />
|
||||
<InputId {required} on:input bind:value={id} {autofocus} />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Card.Base>
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
<Typography.Text variant="m-400">
|
||||
Add the following nameservers on your DNS provider. Note that DNS changes may take time to
|
||||
propagate fully.
|
||||
Add the following nameservers on your DNS provider. Note that changes may take up to 48
|
||||
hours to propagate fully.
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
import { menuOpen } from '$lib/components/menu/store';
|
||||
|
||||
export let time: string | null = null;
|
||||
export let showDatetime: boolean = false;
|
||||
export let canShowPopover: boolean = true;
|
||||
export let placement: ComponentProps<Popover>['placement'] = 'bottom';
|
||||
|
||||
function timeDifference(dateString: string): string {
|
||||
@@ -79,64 +81,66 @@
|
||||
<Popover let:show let:hide {placement} portal>
|
||||
<button
|
||||
on:mouseenter={() => {
|
||||
if (!$menuOpen) {
|
||||
if (!$menuOpen && canShowPopover) {
|
||||
setTimeout(show, 150);
|
||||
}
|
||||
}}
|
||||
on:mouseleave={() => hidePopover(hide)}>
|
||||
<slot>{capitalize(timeFromNow(time))}</slot>
|
||||
<slot>{showDatetime ? toLocaleDateTime(time) : capitalize(timeFromNow(time))}</slot>
|
||||
</button>
|
||||
|
||||
<div
|
||||
let:hide
|
||||
let:showing
|
||||
slot="tooltip"
|
||||
role="tooltip"
|
||||
style:padding="1rem"
|
||||
style:margin="-1rem"
|
||||
on:mouseenter={() => (isMouseOverTooltip = true)}
|
||||
on:mouseleave={() => hidePopover(hide, false)}>
|
||||
<Layout.Stack gap="s" alignContent="flex-start">
|
||||
<!-- `Raw time` as per design -->
|
||||
<Typography.Caption color="--fgcolor-neutral-tertiary" variant="400">
|
||||
{#if $$slots.title}
|
||||
<slot name="title" />
|
||||
{:else}
|
||||
{timeToString}
|
||||
{/if}
|
||||
</Typography.Caption>
|
||||
{#if showing}
|
||||
<Layout.Stack gap="s" alignContent="flex-start">
|
||||
<!-- `Raw time` as per design -->
|
||||
<Typography.Caption color="--fgcolor-neutral-tertiary" variant="400">
|
||||
{#if $$slots.title}
|
||||
<slot name="title" />
|
||||
{:else}
|
||||
{timeToString}
|
||||
{/if}
|
||||
</Typography.Caption>
|
||||
|
||||
<!-- `Absolute time` as per design -->
|
||||
<Layout.Stack gap="xxs">
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
alignContent="center"
|
||||
justifyContent="space-between">
|
||||
<InteractiveText
|
||||
isVisible
|
||||
variant="copy"
|
||||
text={toLocaleDateTime(time, false, 'UTC')}
|
||||
value={toISOString(time)} />
|
||||
<!-- `Absolute time` as per design -->
|
||||
<Layout.Stack gap="xxs">
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
alignContent="center"
|
||||
justifyContent="space-between">
|
||||
<InteractiveText
|
||||
isVisible
|
||||
variant="copy"
|
||||
text={toLocaleDateTime(time, false, 'UTC')}
|
||||
value={toISOString(time)} />
|
||||
|
||||
<Badge variant="secondary" content="UTC" size="xs" />
|
||||
</Layout.Stack>
|
||||
<Badge variant="secondary" content="UTC" size="xs" />
|
||||
</Layout.Stack>
|
||||
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
alignContent="center"
|
||||
justifyContent="space-between">
|
||||
<InteractiveText
|
||||
isVisible
|
||||
variant="copy"
|
||||
text={toLocaleDateTime(time)}
|
||||
value={toLocalDateTimeISO(time)} />
|
||||
<Badge variant="secondary" content="Local" size="xs" />
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
alignContent="center"
|
||||
justifyContent="space-between">
|
||||
<InteractiveText
|
||||
isVisible
|
||||
variant="copy"
|
||||
text={toLocaleDateTime(time)}
|
||||
value={toLocalDateTimeISO(time)} />
|
||||
<Badge variant="secondary" content="Local" size="xs" />
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover>
|
||||
{:else}
|
||||
</Popover>{:else}
|
||||
<Typography.Text>{time === null || time === undefined ? 'null' : time}</Typography.Text>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Layout, Card } from '@appwrite.io/pink-svelte';
|
||||
export let gap: 'none' | 'xxxs' | 'xxs' | 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl' | 'xxxl' = 'l';
|
||||
</script>
|
||||
|
||||
<Card.Base>
|
||||
<Layout.Stack {gap}>
|
||||
<slot />
|
||||
</Layout.Stack>
|
||||
</Card.Base>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,11 +14,12 @@
|
||||
ValidOperators
|
||||
} from './store';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { Icon, Layout, Popover } from '@appwrite.io/pink-svelte';
|
||||
import { Badge, Icon, Layout, Popover } from '@appwrite.io/pink-svelte';
|
||||
import { IconFilter, IconFilterLine } from '@appwrite.io/pink-icons-svelte';
|
||||
import { Click, Submit, trackEvent } from '$lib/actions/analytics';
|
||||
|
||||
export let query = '[]';
|
||||
export let onlyIcon = false;
|
||||
export let columns: Writable<Column[]>;
|
||||
export let disabled = false;
|
||||
export let fullWidthMobile = false;
|
||||
@@ -43,12 +44,12 @@
|
||||
let showFiltersDesktop = false;
|
||||
let showFiltersMobile = false;
|
||||
|
||||
let applied = $tags.length;
|
||||
$: filtersAppliedCount = $tags.length;
|
||||
|
||||
beforeNavigate(() => {
|
||||
applied = $tags.length;
|
||||
showFiltersDesktop = false;
|
||||
showFiltersMobile = false;
|
||||
filtersAppliedCount = $tags.length;
|
||||
});
|
||||
|
||||
function clearAll() {
|
||||
@@ -86,7 +87,7 @@
|
||||
applied: number;
|
||||
}>
|
||||
) {
|
||||
applied = e.detail.applied;
|
||||
filtersAppliedCount = e.detail.applied;
|
||||
if (singleCondition) {
|
||||
showFiltersDesktop = false;
|
||||
showFiltersMobile = false;
|
||||
@@ -124,62 +125,81 @@
|
||||
<Popover let:toggle placement="bottom-start">
|
||||
<Button
|
||||
secondary
|
||||
icon={onlyIcon}
|
||||
class={onlyIcon ? 'width-fix' : undefined}
|
||||
on:click={(event) => {
|
||||
toggle(event);
|
||||
trackEvent(Click.FilterApplyClick, { source: analyticsSource });
|
||||
}}
|
||||
{disabled}>
|
||||
<Icon icon={IconFilterLine} slot="start" size="s" />
|
||||
Filters
|
||||
{#if applied > 0}
|
||||
<span class="inline-tag">
|
||||
{applied}
|
||||
</span>
|
||||
{#if !onlyIcon}
|
||||
<Icon icon={IconFilterLine} slot="start" size={onlyIcon ? 'm' : 's'} />
|
||||
Filters
|
||||
{#if filtersAppliedCount > 0}
|
||||
<Badge size="xs" variant="secondary" content={filtersAppliedCount.toString()} />
|
||||
{/if}
|
||||
{:else}
|
||||
<Icon
|
||||
icon={IconFilterLine}
|
||||
size={onlyIcon ? 'm' : 's'}
|
||||
color="--fgcolor-neutral-tertiary" />
|
||||
|
||||
{#if filtersAppliedCount > 0}
|
||||
<Badge content={filtersAppliedCount.toString()} size="xs" variant="secondary" />
|
||||
{/if}
|
||||
{/if}
|
||||
</Button>
|
||||
<svelte:fragment slot="tooltip">
|
||||
<div style:width="420px">
|
||||
<Layout.Stack gap="s">
|
||||
{#if displayQuickFilters}
|
||||
<slot name="quick" />
|
||||
{:else}
|
||||
<p>Apply filter rules to refine the table view</p>
|
||||
<Content
|
||||
bind:columnId={selectedColumn}
|
||||
bind:operatorKey
|
||||
bind:value
|
||||
bind:arrayValues
|
||||
{columns}
|
||||
{singleCondition}
|
||||
on:apply={afterApply}
|
||||
on:clear={() => (applied = 0)} />
|
||||
{/if}
|
||||
<div
|
||||
class="u-flex u-cross-center u-margin-block-start-16"
|
||||
class:u-main-end={!quickFilters}
|
||||
class:u-main-space-between={quickFilters}>
|
||||
{#if quickFilters}
|
||||
<Button
|
||||
text
|
||||
on:click={() => (displayQuickFilters = !displayQuickFilters)}
|
||||
class="u-margin-block-end-auto">
|
||||
{displayQuickFilters ? 'Advanced filters' : 'Quick filters'}
|
||||
</Button>
|
||||
|
||||
<svelte:fragment slot="tooltip" let:showing>
|
||||
{#if showing}
|
||||
<div style:width="420px">
|
||||
<Layout.Stack gap="s">
|
||||
{#if displayQuickFilters}
|
||||
<slot name="quick" />
|
||||
{:else}
|
||||
<p>Apply filter rules to refine the table view</p>
|
||||
<!-- this needs to be portalled -->
|
||||
<Content
|
||||
bind:columnId={selectedColumn}
|
||||
bind:operatorKey
|
||||
bind:value
|
||||
bind:arrayValues
|
||||
{columns}
|
||||
{singleCondition}
|
||||
on:apply={afterApply}
|
||||
on:clear={() => (filtersAppliedCount = 0)} />
|
||||
{/if}
|
||||
<div class="u-flex u-gap-8">
|
||||
{#if singleCondition}
|
||||
<Button size="s" text on:click={toggleDropdown}>Cancel</Button>
|
||||
{:else}
|
||||
<Button size="s" disabled={applied === 0} text on:click={clearAll}>
|
||||
Clear all
|
||||
<div
|
||||
class="u-flex u-cross-center u-margin-block-start-16"
|
||||
class:u-main-end={!quickFilters}
|
||||
class:u-main-space-between={quickFilters}>
|
||||
{#if quickFilters}
|
||||
<Button
|
||||
text
|
||||
on:click={() => (displayQuickFilters = !displayQuickFilters)}
|
||||
class="u-margin-block-end-auto">
|
||||
{displayQuickFilters ? 'Advanced filters' : 'Quick filters'}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button size="s" on:click={apply} disabled={isButtonDisabled}
|
||||
>Apply</Button>
|
||||
<div class="u-flex u-gap-8">
|
||||
{#if singleCondition}
|
||||
<Button size="s" text on:click={toggleDropdown}>Cancel</Button>
|
||||
{:else}
|
||||
<Button
|
||||
size="s"
|
||||
disabled={filtersAppliedCount === 0}
|
||||
text
|
||||
on:click={clearAll}>
|
||||
Clear all
|
||||
</Button>
|
||||
{/if}
|
||||
<Button size="s" on:click={apply} disabled={isButtonDisabled}
|
||||
>Apply</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout.Stack>
|
||||
</div>
|
||||
</Layout.Stack>
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -189,10 +209,8 @@
|
||||
<Button size="s" secondary on:click={toggleMobileModal} {fullWidthMobile}>
|
||||
<Icon icon={IconFilter} slot="start" size="s" />
|
||||
Filters
|
||||
{#if applied > 0}
|
||||
<span class="inline-tag">
|
||||
{applied}
|
||||
</span>
|
||||
{#if filtersAppliedCount > 0}
|
||||
<Badge content={filtersAppliedCount.toString()} size="xs" variant="secondary" />
|
||||
{/if}
|
||||
</Button>
|
||||
</slot>
|
||||
@@ -210,7 +228,7 @@
|
||||
bind:arrayValues
|
||||
{singleCondition}
|
||||
on:apply={afterApply}
|
||||
on:clear={() => (applied = 0)} />
|
||||
on:clear={() => (filtersAppliedCount = 0)} />
|
||||
{/if}
|
||||
<svelte:fragment slot="footer">
|
||||
<div
|
||||
@@ -238,3 +256,10 @@
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.width-fix) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
import { page } from '$app/stores';
|
||||
import deepEqual from 'deep-equal';
|
||||
import type { Column, ColumnType } from '$lib/helpers/types';
|
||||
import { Query } from '@appwrite.io/console';
|
||||
import { toLocaleDateTime } from '$lib/helpers/date';
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
import type { Column, ColumnType } from '$lib/helpers/types';
|
||||
|
||||
export type TagValue = {
|
||||
tag: string;
|
||||
@@ -12,8 +12,8 @@ export type TagValue = {
|
||||
};
|
||||
|
||||
export type Operator = {
|
||||
toTag: (attribute: string, input?: string | number | string[], type?: string) => TagValue;
|
||||
toQuery: (attribute: string, input?: string | number | string[]) => string;
|
||||
toTag: (column: string, input?: string | number | string[], type?: string) => TagValue;
|
||||
toQuery: (column: string, input?: string | number | string[]) => string;
|
||||
types: ColumnType[];
|
||||
hideInput?: boolean;
|
||||
};
|
||||
@@ -59,9 +59,15 @@ function initQueries(initialValue = new Map<TagValue, string>()) {
|
||||
}
|
||||
|
||||
function apply() {
|
||||
const queryParam = mapToQueryParams(get(queries));
|
||||
const usableQueries = get(queries);
|
||||
const currentLocation = window.location.pathname;
|
||||
goto(`${currentLocation}?query=${queryParam}`, { noScroll: true });
|
||||
|
||||
if (usableQueries.size) {
|
||||
const queryParam = mapToQueryParams(usableQueries);
|
||||
goto(`${currentLocation}?query=${queryParam}`, { noScroll: true });
|
||||
} else {
|
||||
goto(currentLocation, { noScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -134,7 +140,7 @@ export enum ValidTypes {
|
||||
const operatorsDefault = new Map<
|
||||
ValidOperators,
|
||||
{
|
||||
query: (attr: string, input: string | number | string[]) => string;
|
||||
query: (col: string, input: string | number | string[]) => string;
|
||||
types: ColumnType[];
|
||||
hideInput?: boolean;
|
||||
}
|
||||
@@ -249,8 +255,8 @@ export function generateDefaultOperators() {
|
||||
operatorsDefault.forEach((operator, operatorName) => {
|
||||
operators[operatorName] = {
|
||||
toQuery: operator.query,
|
||||
toTag: (attribute, input = null, type = null) => {
|
||||
return generateTag(attribute, operatorName, input, type);
|
||||
toTag: (column, input = null, type = null) => {
|
||||
return generateTag(column, operatorName, input, type);
|
||||
},
|
||||
types: operator.types,
|
||||
hideInput: operator.hideInput
|
||||
@@ -259,24 +265,24 @@ export function generateDefaultOperators() {
|
||||
return operators;
|
||||
}
|
||||
|
||||
export function generateTag(attribute: string, operatorName: string, input = null, type = null) {
|
||||
export function generateTag(column: string, operatorName: string, input = null, type = null) {
|
||||
if (input === null) {
|
||||
return {
|
||||
value: '',
|
||||
tag: `**${attribute}** ${operatorName}`
|
||||
tag: `**${column}** ${operatorName}`
|
||||
};
|
||||
} else if (Array.isArray(input) && input.length > 2) {
|
||||
return {
|
||||
value: input,
|
||||
tag: `**${attribute}** ${operatorName} **${formatArray(input)}** `
|
||||
tag: `**${column}** ${operatorName} **${formatArray(input)}** `
|
||||
};
|
||||
} else if (type === ValidTypes.Datetime) {
|
||||
return {
|
||||
value: input,
|
||||
tag: `**${attribute}** ${operatorName} **${toLocaleDateTime(input.toString())}**`
|
||||
tag: `**${column}** ${operatorName} **${toLocaleDateTime(input.toString())}**`
|
||||
};
|
||||
} else {
|
||||
return { value: input, tag: `**${attribute}** ${operatorName} **${input}**` };
|
||||
return { value: input, tag: `**${column}** ${operatorName} **${input}**` };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,11 @@
|
||||
if (repositoryBehaviour === 'new') {
|
||||
const repo = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.vcs.createRepository($installation.$id, repositoryName, repositoryPrivate);
|
||||
.vcs.createRepository({
|
||||
installationId: $installation.$id,
|
||||
name: repositoryName,
|
||||
xprivate: repositoryPrivate
|
||||
});
|
||||
repository.set(repo);
|
||||
selectedRepository = repo.id;
|
||||
}
|
||||
|
||||
@@ -18,13 +18,19 @@
|
||||
async function loadBranches() {
|
||||
const repo = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.vcs.getRepository(installationId, repositoryId);
|
||||
.vcs.getRepository({
|
||||
installationId,
|
||||
providerRepositoryId: repositoryId
|
||||
});
|
||||
|
||||
branch = repo.defaultBranch ?? 'main';
|
||||
|
||||
const { branches } = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.vcs.listRepositoryBranches(installationId, repositoryId);
|
||||
.vcs.listRepositoryBranches({
|
||||
installationId,
|
||||
providerRepositoryId: repositoryId
|
||||
});
|
||||
|
||||
return sortBranches(branches);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -43,7 +43,12 @@
|
||||
try {
|
||||
const content = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.vcs.getRepositoryContents($installation.$id, $repository.id, currentPath, branch);
|
||||
.vcs.getRepositoryContents({
|
||||
installationId: $installation.$id,
|
||||
providerRepositoryId: $repository.id,
|
||||
providerRootDirectory: currentPath,
|
||||
providerReference: branch
|
||||
});
|
||||
directories[0].fileCount = content.contents?.length ?? 0;
|
||||
directories[0].children = content.contents
|
||||
.filter((e) => e.isDirectory)
|
||||
@@ -83,7 +88,12 @@
|
||||
try {
|
||||
const content = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.vcs.getRepositoryContents($installation.$id, $repository.id, path, branch);
|
||||
.vcs.getRepositoryContents({
|
||||
installationId: $installation.$id,
|
||||
providerRepositoryId: $repository.id,
|
||||
providerRootDirectory: path,
|
||||
providerReference: branch
|
||||
});
|
||||
|
||||
const fileCount = content.contents?.length ?? 0;
|
||||
const contentDirectories = content.contents.filter((e) => e.isDirectory);
|
||||
@@ -101,12 +111,15 @@
|
||||
}));
|
||||
const runtime = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.vcs.createRepositoryDetection(
|
||||
$installation.$id,
|
||||
$repository.id,
|
||||
product === 'sites' ? VCSDetectionType.Framework : VCSDetectionType.Runtime,
|
||||
path
|
||||
);
|
||||
.vcs.createRepositoryDetection({
|
||||
installationId: $installation.$id,
|
||||
providerRepositoryId: $repository.id,
|
||||
type:
|
||||
product === 'sites'
|
||||
? VCSDetectionType.Framework
|
||||
: VCSDetectionType.Runtime,
|
||||
providerRootDirectory: path
|
||||
});
|
||||
if (product === 'sites') {
|
||||
currentDir.children.forEach((dir) => {
|
||||
dir.thumbnailUrl = $iconPath(runtime.framework, 'color');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Card, Layout, Typography } from '@appwrite.io/pink-svelte';
|
||||
export let href: string;
|
||||
export let href: string = null;
|
||||
</script>
|
||||
|
||||
<Card.Link class="card" {href}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -84,3 +84,6 @@ export { default as UsageCard } from './usageCard.svelte';
|
||||
export { default as ViewToggle } from './viewToggle.svelte';
|
||||
export { default as RegionEndpoint } from './regionEndpoint.svelte';
|
||||
export { default as ExpirationInput } from './expirationInput.svelte';
|
||||
export { default as EstimatedCard } from './estimatedCard.svelte';
|
||||
export { default as EmailVerificationBanner } from './alerts/emailVerificationBanner.svelte';
|
||||
export { default as SortButton, type SortDirection } from './sortButton.svelte';
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</script>
|
||||
|
||||
<Tooltip maxWidth={tooltipWidth} disabled={!tooltipText || !tooltipShow}>
|
||||
<div style:cursor={disabled ? 'pointer' : ''}>
|
||||
<div style:cursor={disabled ? 'pointer' : ''} style:z-index="1">
|
||||
<Card.Selector
|
||||
{name}
|
||||
{src}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
const url = new URL(pageStore.url);
|
||||
const previousLimit = Number(url.searchParams.get('limit'));
|
||||
url.searchParams.set('limit', limit.toString());
|
||||
preferences.setLimit(limit);
|
||||
await preferences.setLimit(limit);
|
||||
|
||||
if (url.searchParams.has('page')) {
|
||||
const page = Number(url.searchParams.get('page'));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
<script lang="ts" context="module">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
export type NavbarProject = {
|
||||
name: string;
|
||||
$id: string;
|
||||
region: string;
|
||||
isSelected: boolean;
|
||||
platformCount: number;
|
||||
pingCount: number;
|
||||
};
|
||||
|
||||
export type BaseNavbarProps = HTMLAttributes<HTMLHeadElement> & {
|
||||
logo: {
|
||||
src: string;
|
||||
@@ -106,7 +97,6 @@
|
||||
export let sideBarIsOpen: $$Props['sideBarIsOpen'] = false;
|
||||
export let showAccountMenu = false;
|
||||
export let currentProject: Models.Project = undefined;
|
||||
export let projects: Promise<Models.ProjectList> = undefined;
|
||||
|
||||
let activeTheme = $app.theme;
|
||||
let shouldAnimateThemeToggle = false;
|
||||
@@ -138,7 +128,9 @@
|
||||
class="only-desktop">
|
||||
<img src={logo.src} alt={logo.alt} />
|
||||
</a>
|
||||
<Breadcrumbs {organizations} {projects} {currentProject} />
|
||||
|
||||
<Breadcrumbs {organizations} {currentProject} />
|
||||
|
||||
{#if page.route?.id?.includes('/project-[region]-[project]') && currentProject && currentProject.pingCount === 0}
|
||||
<div class="only-desktop" style:margin-inline-start="-16px">
|
||||
<Button.Anchor
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { getServiceLimit, plansInfo } from '$lib/stores/billing';
|
||||
import { BillingPlan } from '$lib/constants';
|
||||
import { Click, trackEvent } from '$lib/actions/analytics';
|
||||
import { Badge, Icon, Layout, Table, Typography, Tooltip } from '@appwrite.io/pink-svelte';
|
||||
import { IconArrowUp, IconInfo } from '@appwrite.io/pink-icons-svelte';
|
||||
|
||||
import { formatNumberWithCommas } from '$lib/helpers/numbers';
|
||||
import { Modal } from '$lib/components';
|
||||
import { Alert } from '@appwrite.io/pink-svelte';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
|
||||
import { organization, type Organization } from '$lib/stores/organization';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
|
||||
// Props
|
||||
type Props = {
|
||||
organization: Organization;
|
||||
projects?: Models.Project[];
|
||||
members?: Models.Membership[];
|
||||
storageUsage?: number;
|
||||
};
|
||||
|
||||
const { projects = [], members = [], storageUsage = 0 }: Props = $props();
|
||||
|
||||
let showSelectProject = $state(false);
|
||||
let selectedProjects = $state<string[]>([]);
|
||||
let error = $state<string | null>(null);
|
||||
let showSelectionReminder = $state(false);
|
||||
|
||||
// Derived state using runes
|
||||
let freePlanLimits = $derived({
|
||||
projects: $plansInfo?.get(BillingPlan.FREE)?.projects,
|
||||
members: getServiceLimit('members', BillingPlan.FREE),
|
||||
storage: getServiceLimit('storage', BillingPlan.FREE)
|
||||
});
|
||||
|
||||
// When preparing to downgrade to Free, enforce Free plan limit locally (2)
|
||||
let allowedProjectsToKeep = $derived(freePlanLimits.projects);
|
||||
|
||||
let currentUsage = $derived({
|
||||
projects: projects?.length || 0,
|
||||
members: members?.length || 0,
|
||||
storage: storageUsage || 0
|
||||
});
|
||||
|
||||
let storageUsageGB = $derived(storageUsage / (1024 * 1024 * 1024));
|
||||
|
||||
let isLimitExceeded = $derived({
|
||||
projects: currentUsage.projects > freePlanLimits.projects,
|
||||
members: currentUsage.members > freePlanLimits.members,
|
||||
storage: storageUsageGB > freePlanLimits.storage
|
||||
});
|
||||
|
||||
let excessUsage = $derived({
|
||||
projects: Math.max(0, currentUsage.projects),
|
||||
members: Math.max(0, currentUsage.members - freePlanLimits.members),
|
||||
storage: Math.max(0, storageUsageGB - freePlanLimits.storage)
|
||||
});
|
||||
|
||||
// projects that would be archived with the current selection
|
||||
let projectsToArchive = $derived(
|
||||
projects.filter((project) => !selectedProjects.includes(project.$id))
|
||||
);
|
||||
|
||||
function formatProjectsToArchive(): string {
|
||||
let result = '';
|
||||
projectsToArchive.forEach((project, index) => {
|
||||
const isLast = index === projectsToArchive.length - 1;
|
||||
const isSecondLast = index === projectsToArchive.length - 2;
|
||||
|
||||
result += `${index === 0 ? '' : ' '}${project.name}`;
|
||||
|
||||
if (!isLast) {
|
||||
if (isSecondLast) result += ' and';
|
||||
else result += ',';
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return formatNumberWithCommas(num);
|
||||
}
|
||||
|
||||
function handleManageProjects() {
|
||||
showSelectProject = true;
|
||||
showSelectionReminder = false;
|
||||
trackEvent(Click.OrganizationClickUpgrade, { source: 'usage_limits_manage_projects' });
|
||||
}
|
||||
|
||||
// Expose validation for parent to call before submitting downgrade
|
||||
export function validateOrAlert(): boolean {
|
||||
const filteredSelection = selectedProjects.filter((id) =>
|
||||
projects.some((p) => p.$id === id)
|
||||
);
|
||||
const isValid = filteredSelection.length === allowedProjectsToKeep;
|
||||
showSelectionReminder = !isValid && isLimitExceeded.projects;
|
||||
return isValid;
|
||||
}
|
||||
|
||||
export function getSelectedProjects(): string[] {
|
||||
return selectedProjects.filter((id) => projects.some((p) => p.$id === id));
|
||||
}
|
||||
|
||||
function updateSelected() {
|
||||
error = null;
|
||||
const filteredSelection = selectedProjects.filter((id) =>
|
||||
projects.some((p) => p.$id === id)
|
||||
);
|
||||
if (filteredSelection.length !== allowedProjectsToKeep) {
|
||||
error = `You must select exactly ${allowedProjectsToKeep} projects to keep.`;
|
||||
return;
|
||||
}
|
||||
// Keep selection locally; parent flow will apply after plan change
|
||||
showSelectProject = false;
|
||||
showSelectionReminder = false;
|
||||
addNotification({ type: 'success', message: `Projects selected for archiving` });
|
||||
}
|
||||
</script>
|
||||
|
||||
<Layout.Stack gap="l">
|
||||
{#if showSelectionReminder}
|
||||
<Alert.Inline status="warning" title="Choose projects to keep">
|
||||
The Free plan lets you keep {allowedProjectsToKeep} projects. Select them before continuing.
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
justifyContent="flex-start"
|
||||
gap="xs"
|
||||
style="position: relative; z-index: 10; pointer-events: auto;">
|
||||
<Button compact on:click={handleManageProjects}>Manage projects</Button>
|
||||
</Layout.Stack>
|
||||
</Alert.Inline>
|
||||
{/if}
|
||||
|
||||
<div class="responsive-table">
|
||||
<Table.Root
|
||||
columns={[
|
||||
{ id: 'resource', width: { min: 215 } },
|
||||
{ id: 'freeLimit', width: { min: 100 } },
|
||||
{ id: 'excessUsage', width: { min: 120 } },
|
||||
{ id: 'manage', width: { min: 110 } }
|
||||
]}
|
||||
let:root>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="resource" {root}>Resource</Table.Header.Cell>
|
||||
<Table.Header.Cell column="freeLimit" {root}>Free limit</Table.Header.Cell>
|
||||
<Table.Header.Cell column="excessUsage" {root}>
|
||||
<Layout.Stack direction="row" alignItems="center" gap="xs">
|
||||
<Typography.Text>Excess usage</Typography.Text>
|
||||
<Tooltip placement="bottom" portal>
|
||||
<Icon icon={IconInfo} size="s" />
|
||||
<span slot="tooltip">Usage beyond the Free plan limits.</span>
|
||||
</Tooltip>
|
||||
</Layout.Stack>
|
||||
</Table.Header.Cell>
|
||||
<Table.Header.Cell column="manage" {root}></Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
|
||||
<!-- Projects Row -->
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell column="resource" {root}>
|
||||
<Layout.Stack direction="row" alignItems="center" gap="xs">
|
||||
<Typography.Text>Projects</Typography.Text>
|
||||
{#if isLimitExceeded.projects}
|
||||
<Badge
|
||||
size="xs"
|
||||
content="Action required"
|
||||
variant="secondary"
|
||||
type="warning" />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="freeLimit" {root}>
|
||||
<Typography.Text
|
||||
>{formatNumber(allowedProjectsToKeep)} projects</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="excessUsage" {root}>
|
||||
{#if isLimitExceeded.projects}
|
||||
<Layout.Stack direction="row" alignItems="center" gap="xs">
|
||||
<Icon icon={IconArrowUp} size="s" color="--fgcolor-error" />
|
||||
<Typography.Text color="--fgcolor-error">
|
||||
{formatNumber(excessUsage.projects)} projects
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{:else}
|
||||
<Typography.Text color="--fgcolor-neutral-secondary">
|
||||
{formatNumber(currentUsage.projects)} / {formatNumber(
|
||||
allowedProjectsToKeep
|
||||
)}
|
||||
</Typography.Text>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="manage" {root}>
|
||||
{#if isLimitExceeded.projects}
|
||||
<Layout.Stack direction="row" justifyContent="flex-end">
|
||||
<Button size="xs" secondary on:click={handleManageProjects}
|
||||
>Manage projects</Button>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
|
||||
<!-- Organization Members Row -->
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell column="resource" {root}>
|
||||
<Typography.Text>Organization members</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="freeLimit" {root}>
|
||||
<Typography.Text>{formatNumber(freePlanLimits.members)} member</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="excessUsage" {root}>
|
||||
{#if isLimitExceeded.members}
|
||||
<Layout.Stack direction="row" alignItems="center" gap="xs">
|
||||
<Icon icon={IconArrowUp} size="s" color="--fgcolor-error" />
|
||||
<Typography.Text color="--fgcolor-error">
|
||||
{formatNumber(excessUsage.members)} members
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{:else}
|
||||
<Typography.Text color="--fgcolor-neutral-secondary">
|
||||
{formatNumber(currentUsage.members)} / {formatNumber(
|
||||
freePlanLimits.members
|
||||
)}
|
||||
</Typography.Text>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="manage" {root}></Table.Cell>
|
||||
</Table.Row.Base>
|
||||
|
||||
<!-- Storage Row -->
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell column="resource" {root}>
|
||||
<Typography.Text>Storage</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="freeLimit" {root}>
|
||||
<Typography.Text>{freePlanLimits.storage} GB</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="excessUsage" {root}>
|
||||
{#if isLimitExceeded.storage}
|
||||
<Layout.Stack direction="row" alignItems="center" gap="xs">
|
||||
<Icon icon={IconArrowUp} size="s" color="--fgcolor-error" />
|
||||
<Typography.Text color="--fgcolor-error">
|
||||
{excessUsage.storage.toFixed(2)} GB
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{:else}
|
||||
<Typography.Text color="--fgcolor-neutral-secondary">
|
||||
{storageUsageGB.toFixed(2)} / {freePlanLimits.storage} GB
|
||||
</Typography.Text>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="manage" {root}></Table.Cell>
|
||||
</Table.Row.Base>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</Layout.Stack>
|
||||
|
||||
{#if showSelectProject}
|
||||
<Modal bind:show={showSelectProject} title="Manage projects" onSubmit={updateSelected}>
|
||||
<svelte:fragment slot="description">
|
||||
Choose which {freePlanLimits.projects} projects to keep. Projects over the limit will be
|
||||
blocked after your billing cycle ends on {toLocaleDate(
|
||||
$organization.billingNextInvoiceDate
|
||||
)}.
|
||||
</svelte:fragment>
|
||||
|
||||
{#if error}
|
||||
<Alert.Inline status="error" title="Error">{error}</Alert.Inline>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<Table.Root
|
||||
let:root
|
||||
allowSelection
|
||||
bind:selectedRows={selectedProjects}
|
||||
columns={[{ id: 'name' }, { id: 'created' }]}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="name" {root}>Project Name</Table.Header.Cell>
|
||||
<Table.Header.Cell column="created" {root}>Created</Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
{#each projects as project}
|
||||
<Table.Row.Base {root} id={project.$id}>
|
||||
<Table.Cell column="name" {root}
|
||||
><Typography.Text truncate>{project.name}</Typography.Text></Table.Cell>
|
||||
<Table.Cell column="created" {root}>
|
||||
{toLocaleDateTime(project.$createdAt)}
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
</div>
|
||||
{#if selectedProjects.length === allowedProjectsToKeep}
|
||||
{@const difference = projects.length - selectedProjects.length}
|
||||
{@const messagePrefix =
|
||||
difference > 1 ? `${difference} projects` : `${difference} project`}
|
||||
<Alert.Inline
|
||||
status="warning"
|
||||
title={`${messagePrefix} will be archived on ${toLocaleDate($organization.billingNextInvoiceDate)}`}>
|
||||
{formatProjectsToArchive()} will be archived
|
||||
</Alert.Inline>
|
||||
{/if}
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Button secondary on:click={() => (showSelectProject = false)}>Cancel</Button>
|
||||
<Button submit disabled={selectedProjects.length !== allowedProjectsToKeep}
|
||||
>Save</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Responsive table container */
|
||||
.responsive-table {
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Small viewport optimizations */
|
||||
@media (max-width: 640px) {
|
||||
.responsive-table {
|
||||
margin-inline: -1rem;
|
||||
padding-inline: 1rem;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.responsive-table :global(td),
|
||||
.responsive-table :global(th) {
|
||||
padding: 0.5rem 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.responsive-table :global(td:nth-child(1) .badge) {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.responsive-table :global(td:nth-child(1) .layout-stack) {
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px !important;
|
||||
}
|
||||
|
||||
.responsive-table :global(th:nth-child(4)),
|
||||
.responsive-table :global(td:nth-child(4)) {
|
||||
width: 96px;
|
||||
min-width: 96px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@
|
||||
export let sum: number;
|
||||
export let limit: number;
|
||||
export let offset: number;
|
||||
export let useCreateLink = true;
|
||||
|
||||
$: currentPage = Math.floor(offset / limit + 1);
|
||||
|
||||
@@ -18,6 +19,10 @@
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
$: paginationProps = useCreateLink
|
||||
? ({ type: 'link', createLink: getLink } as const)
|
||||
: ({ type: 'button', createLink: undefined as never } as const);
|
||||
</script>
|
||||
|
||||
<Pagination page={currentPage} total={sum} {limit} createLink={getLink} type="link" />
|
||||
<Pagination on:page {limit} total={sum} page={currentPage} {...paginationProps} />
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
{:else}
|
||||
<Layout.Stack direction="row" inline>
|
||||
<Button.Button
|
||||
type="button"
|
||||
size="s"
|
||||
variant="compact"
|
||||
on:click={prev}
|
||||
@@ -75,6 +76,7 @@
|
||||
</Button.Button>
|
||||
|
||||
<Button.Button
|
||||
type="button"
|
||||
size="s"
|
||||
variant="compact"
|
||||
on:click={next}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,9 +48,10 @@
|
||||
async function request() {
|
||||
if (!show) return;
|
||||
isLoading = true;
|
||||
results = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.users.list([Query.limit(5), Query.offset(offset)], search || undefined);
|
||||
results = await sdk.forProject(page.params.region, page.params.project).users.list({
|
||||
queries: [Query.limit(5), Query.offset(offset)],
|
||||
search: search || undefined
|
||||
});
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
flex-direction: row;
|
||||
gap: 2px;
|
||||
margin-top: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__content {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { trackEvent } from '$lib/actions/analytics';
|
||||
import { Icon, Input } from '@appwrite.io/pink-svelte';
|
||||
import { IconSearch, IconX } from '@appwrite.io/pink-icons-svelte';
|
||||
import { debounce as createDebounce } from '$lib/helpers/debounce.js';
|
||||
|
||||
export let placeholder = '';
|
||||
export let debounce = 250;
|
||||
export let required = false;
|
||||
export let disabled = false;
|
||||
export let autofocus = false;
|
||||
interface Props {
|
||||
placeholder?: string;
|
||||
debounce?: number;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
autofocus?: boolean;
|
||||
}
|
||||
|
||||
let inputValue = page.url.searchParams.get('search') ?? '';
|
||||
let {
|
||||
placeholder = '',
|
||||
debounce = 250,
|
||||
required = false,
|
||||
disabled = false,
|
||||
autofocus = false
|
||||
}: Props = $props();
|
||||
|
||||
let inputValue = $state(page.url.searchParams.get('search') ?? '');
|
||||
|
||||
const runSearch = createDebounce((value: string) => {
|
||||
const trimmed = value.trim();
|
||||
@@ -36,15 +46,21 @@
|
||||
goto(url, { keepFocus: true });
|
||||
}, debounce);
|
||||
|
||||
$: runSearch(inputValue);
|
||||
|
||||
function clearInput() {
|
||||
export function clearInput() {
|
||||
inputValue = '';
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
runSearch.cancel?.();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const urlSearch = page.url.searchParams.get('search') ?? '';
|
||||
|
||||
if (urlSearch !== inputValue) {
|
||||
runSearch(inputValue);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div style:max-width="360px" style:width="100%">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import { page } from '$app/state';
|
||||
import { page } from '$app/stores';
|
||||
import { app } from '$lib/stores/app';
|
||||
import { fade } from 'svelte/transition';
|
||||
import {
|
||||
Icon,
|
||||
Sidebar,
|
||||
@@ -34,12 +34,14 @@
|
||||
import { showSupportModal } from '$routes/(console)/wizard/support/store';
|
||||
import MobileSupportModal from '$routes/(console)/wizard/support/mobileSupportModal.svelte';
|
||||
import MobileFeedbackModal from '$routes/(console)/wizard/feedback/mobileFeedbackModal.svelte';
|
||||
import { getSidebarState, updateSidebarState } from '$lib/helpers/sidebar';
|
||||
import { getSidebarState, isInDatabasesRoute, updateSidebarState } from '$lib/helpers/sidebar';
|
||||
import { isTabletViewport, isSmallViewport } from '$lib/stores/viewport';
|
||||
import { Click, trackEvent } from '$lib/actions/analytics';
|
||||
import { bannerSpacing } from '$lib/layout/headerAlert.svelte';
|
||||
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { noWidthTransition } from '$lib/stores/sidebar';
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLElement> & {
|
||||
state?: 'closed' | 'open' | 'icons';
|
||||
@@ -71,8 +73,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: state = $isTabletViewport ? 'closed' : getSidebarState();
|
||||
|
||||
const projectOptions = [
|
||||
{ name: 'Auth', icon: IconUserGroup, slug: 'auth', category: 'build' },
|
||||
{ name: 'Databases', icon: IconDatabase, slug: 'databases', category: 'build' },
|
||||
@@ -89,12 +89,21 @@
|
||||
];
|
||||
|
||||
const isSelected = (service: string): boolean => {
|
||||
return page.route.id?.includes(service);
|
||||
return $page.route.id?.includes(service);
|
||||
};
|
||||
|
||||
$: state = $isTabletViewport
|
||||
? 'closed'
|
||||
: // example: manual resize
|
||||
isInDatabasesRoute($page.route)
|
||||
? 'icons'
|
||||
: getSidebarState();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="sidebar"
|
||||
class:only-mobile-tablet={!project}
|
||||
class:no-transitions={$noWidthTransition}
|
||||
style:--overlay-on-neutral={$app.themeInUse === 'dark'
|
||||
? 'var(--neutral-750)'
|
||||
: 'var(--neutral-100)'}>
|
||||
@@ -240,7 +249,7 @@
|
||||
<span slot="tooltip">{projectOption.name}</span>
|
||||
</Tooltip>
|
||||
{/each}
|
||||
{#if project}
|
||||
{#if project && $isSmallViewport}
|
||||
<div class="mobile-tablet-settings">
|
||||
<Tooltip placement="right" disabled={state !== 'icons'}>
|
||||
<a
|
||||
@@ -353,13 +362,24 @@
|
||||
</Sidebar.Base>
|
||||
</div>
|
||||
|
||||
{#if subNavigation}
|
||||
<div class="sub-navigation" class:icons={state === 'icons'}>
|
||||
<svelte:component this={subNavigation} />
|
||||
</div>
|
||||
{/if}
|
||||
<div style:--banner-spacing={$bannerSpacing ? $bannerSpacing : undefined}>
|
||||
{#if subNavigation}
|
||||
<div
|
||||
class="sub-navigation"
|
||||
class:icons={state === 'icons'}
|
||||
class:no-transitions={$noWidthTransition}>
|
||||
<svelte:component this={subNavigation} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.sidebar {
|
||||
&.no-transitions :global(nav) {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.middle-container {
|
||||
flex: 1;
|
||||
overflow-y: visible;
|
||||
@@ -571,6 +591,7 @@
|
||||
@media (min-width: 1024px) {
|
||||
width: 166px;
|
||||
}
|
||||
|
||||
.info {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
@@ -608,8 +629,9 @@
|
||||
|
||||
.sub-navigation {
|
||||
margin-top: 48px;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
margin-top: 0;
|
||||
margin-top: var(--banner-spacing, 0px);
|
||||
width: 400px;
|
||||
height: calc(100vh - 48px);
|
||||
display: flex;
|
||||
@@ -617,12 +639,27 @@
|
||||
background-color: var(--bgcolor-neutral-primary, #fff);
|
||||
z-index: 14;
|
||||
position: fixed;
|
||||
top: 48px;
|
||||
top: var(--banner-spacing, 48px);
|
||||
transition: width 0.2s linear;
|
||||
|
||||
&.icons {
|
||||
width: 266px;
|
||||
transition: width 0.3s linear;
|
||||
|
||||
&.no-transitions {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
& :global(nav) {
|
||||
margin-top: var(--banner-spacing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* when in small or tablet viewport and there's a banner */
|
||||
@media (max-width: 1023px) {
|
||||
& :global(header) {
|
||||
margin-top: var(--banner-spacing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -18,19 +18,6 @@
|
||||
|
||||
let error: string;
|
||||
|
||||
function coerceToNumber(event: Event & { currentTarget: EventTarget & HTMLInputElement }) {
|
||||
const raw = event.currentTarget?.value ?? '';
|
||||
if (raw === '') {
|
||||
value = nullable ? null : (undefined as unknown as number);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed)) {
|
||||
value = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
const handleInvalid = (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -73,9 +60,7 @@
|
||||
autofocus={autofocus || undefined}
|
||||
helper={error || helper}
|
||||
state={error ? 'error' : 'default'}
|
||||
on:invalid={handleInvalid}
|
||||
on:input={coerceToNumber}
|
||||
on:change={coerceToNumber}>
|
||||
on:invalid={handleInvalid}>
|
||||
<svelte:fragment slot="info">
|
||||
<slot name="info" slot="info" />
|
||||
</svelte:fragment>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
maxlength={36}
|
||||
autocomplete="off"
|
||||
bind:value
|
||||
on:input
|
||||
on:invalid={handleInvalid}
|
||||
state={error ? 'error' : 'default'}
|
||||
helper="Allowed characters: lowercase alphanumeric and non-leading hyphen" />
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
export let label: string | undefined = undefined;
|
||||
export let value: string | number | boolean | null;
|
||||
export let helper: string | undefined = undefined;
|
||||
export let autofocus: boolean | undefined = undefined;
|
||||
export let optionalText: string | number | boolean | null | undefined = undefined;
|
||||
export let placeholder = '';
|
||||
export let required = false;
|
||||
@@ -54,6 +55,7 @@
|
||||
{optionalText}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
{autofocus}
|
||||
helper={error ?? helper}
|
||||
{required}
|
||||
state={error ? 'error' : 'default'}
|
||||
|
||||
@@ -51,4 +51,5 @@
|
||||
on:input
|
||||
bind:value>
|
||||
<slot name="info" slot="info" />
|
||||
<slot name="end" slot="end" />
|
||||
</Input.Textarea>
|
||||
|
||||
+2
-4
@@ -5,6 +5,7 @@ import type { Organization } from './stores/organization';
|
||||
// Parse feature flags from env as a string array (exact match only)
|
||||
const flagsRaw = (env.PUBLIC_CONSOLE_FEATURE_FLAGS ?? '').split(',');
|
||||
|
||||
// @ts-expect-error: unused method!
|
||||
function isFlagEnabled(name: string) {
|
||||
// loose generic to allow safe access while retaining type safety
|
||||
return <T extends { account?: Account; organization?: Organization }>(data: T) => {
|
||||
@@ -18,7 +19,4 @@ function isFlagEnabled(name: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export const flags = {
|
||||
showSites: isFlagEnabled('sites'),
|
||||
showIndexLengths: isFlagEnabled('index-lengths')
|
||||
};
|
||||
export const flags = {};
|
||||
|
||||
@@ -102,3 +102,11 @@ export function total(set: Array<number>): number {
|
||||
if (!set) return 0;
|
||||
return set.reduce((prev, curr) => prev + curr, 0);
|
||||
}
|
||||
|
||||
export function chunks<T>(array: T[], size: number = 10): T[][] {
|
||||
const output: T[][] = [];
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
output.push(array.slice(i, i + size));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
+138
-138
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Columns } from '$routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/store';
|
||||
import { ID, type Models } from '@appwrite.io/console';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { isWithinSafeRange } from '$lib/helpers/numbers';
|
||||
|
||||
export async function generateColumns(
|
||||
project: Models.Project,
|
||||
databaseId: string,
|
||||
tableId: string
|
||||
): Promise<Columns[]> {
|
||||
const client = sdk.forProject(project.region, project.$id);
|
||||
|
||||
return await Promise.all([
|
||||
client.tablesDB.createStringColumn({
|
||||
databaseId,
|
||||
tableId,
|
||||
key: 'name',
|
||||
size: 255,
|
||||
required: false
|
||||
}),
|
||||
client.tablesDB.createEmailColumn({ databaseId, tableId, key: 'email', required: false }),
|
||||
client.tablesDB.createIntegerColumn({
|
||||
databaseId,
|
||||
tableId,
|
||||
key: 'age',
|
||||
required: false,
|
||||
min: 18,
|
||||
max: 80
|
||||
}),
|
||||
client.tablesDB.createStringColumn({
|
||||
databaseId,
|
||||
tableId,
|
||||
key: 'city',
|
||||
size: 100,
|
||||
required: false
|
||||
}),
|
||||
client.tablesDB.createStringColumn({
|
||||
databaseId,
|
||||
tableId,
|
||||
key: 'description',
|
||||
size: 1000,
|
||||
required: false
|
||||
}),
|
||||
client.tablesDB.createBooleanColumn({ databaseId, tableId, key: 'active', required: false })
|
||||
]);
|
||||
}
|
||||
|
||||
export function generateFakeRecords(
|
||||
columns: Columns[],
|
||||
count: number
|
||||
): {
|
||||
ids: string[];
|
||||
rows: Models.Row[];
|
||||
} {
|
||||
if (count <= 0) return { ids: [], rows: [] };
|
||||
|
||||
const filteredColumns = columns.filter(
|
||||
(col) => col.type !== 'relationship' && col.status === 'available'
|
||||
);
|
||||
|
||||
const ids: string[] = [];
|
||||
const rows: Models.Row[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = ID.unique();
|
||||
ids.push(id);
|
||||
|
||||
let row: Record<string, string | number | boolean | Array<string | number | boolean>> = {
|
||||
$id: id
|
||||
};
|
||||
|
||||
if (filteredColumns.length === 0) {
|
||||
row = {
|
||||
$id: id,
|
||||
name: faker.person.fullName(),
|
||||
email: faker.internet.email(),
|
||||
age: faker.number.int({ min: 18, max: 80 }),
|
||||
city: faker.location.city(),
|
||||
description: faker.lorem.sentence(),
|
||||
active: faker.datatype.boolean()
|
||||
};
|
||||
} else {
|
||||
for (const column of filteredColumns) {
|
||||
row[column.key] = generateValueForColumn(column);
|
||||
}
|
||||
}
|
||||
|
||||
rows.push(row as unknown as Models.Row);
|
||||
}
|
||||
|
||||
return { ids, rows };
|
||||
}
|
||||
|
||||
function generateStringValue(key: string, maxLength: number): string {
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
const generators: Record<string, () => string> = {
|
||||
name: () => faker.person.fullName(),
|
||||
email: () => faker.internet.email(),
|
||||
title: () => faker.lorem.words({ min: 1, max: 3 }),
|
||||
description: () => faker.lorem.paragraph(),
|
||||
content: () => faker.lorem.paragraph(),
|
||||
address: () => faker.location.streetAddress(),
|
||||
city: () => faker.location.city(),
|
||||
country: () => faker.location.country(),
|
||||
company: () => faker.company.name()
|
||||
};
|
||||
|
||||
const matchingKey = Object.keys(generators).find((pattern) => lowerKey.includes(pattern));
|
||||
|
||||
const text = matchingKey ? generators[matchingKey]() : faker.lorem.words({ min: 1, max: 5 });
|
||||
|
||||
return text.length <= maxLength ? text : text.substring(0, maxLength);
|
||||
}
|
||||
|
||||
function generateValueForColumn(
|
||||
column: Columns
|
||||
): string | number | boolean | null | Array<string | number | boolean> {
|
||||
if (column.array) {
|
||||
const arraySize = faker.number.int({ min: 1, max: 5 });
|
||||
const items: Array<string | number | boolean> = [];
|
||||
|
||||
for (let i = 0; i < arraySize; i++) {
|
||||
const itemAttribute = { ...column, array: false };
|
||||
const item = generateSingleValue(itemAttribute);
|
||||
if (item !== null) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
return generateSingleValue(column);
|
||||
}
|
||||
|
||||
function generateSingleValue(column: Columns): string | number | boolean | null {
|
||||
switch (column.type) {
|
||||
case 'string': {
|
||||
if ('format' in column && column.format) {
|
||||
switch (column.format) {
|
||||
case 'email': {
|
||||
return faker.internet.email();
|
||||
}
|
||||
|
||||
case 'ip': {
|
||||
return faker.internet.ip();
|
||||
}
|
||||
|
||||
case 'url': {
|
||||
return faker.internet.url();
|
||||
}
|
||||
|
||||
case 'enum': {
|
||||
const enumAttr = column as Models.ColumnEnum;
|
||||
if (enumAttr.elements?.length > 0) {
|
||||
return faker.helpers.arrayElement(enumAttr.elements);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
} else {
|
||||
const stringAttr = column as Models.ColumnString;
|
||||
const maxLength = Math.min(stringAttr.size ?? 255, 1000);
|
||||
return generateStringValue(column.key, maxLength);
|
||||
}
|
||||
}
|
||||
|
||||
case 'integer': {
|
||||
const intAttr = column as Models.ColumnInteger;
|
||||
const min = !isWithinSafeRange(intAttr.min) ? 0 : intAttr.min;
|
||||
const max = !isWithinSafeRange(intAttr.max) ? 100 : intAttr.max;
|
||||
return faker.number.int({ min, max });
|
||||
}
|
||||
|
||||
case 'double': {
|
||||
const floatAttr = column as Models.ColumnFloat;
|
||||
const min = !isWithinSafeRange(floatAttr.min) ? 0 : floatAttr.min;
|
||||
const max = !isWithinSafeRange(floatAttr.max) ? 100 : floatAttr.max;
|
||||
const precision = 4;
|
||||
|
||||
return faker.number.float({ min, max, fractionDigits: precision });
|
||||
}
|
||||
|
||||
case 'boolean': {
|
||||
return faker.datatype.boolean();
|
||||
}
|
||||
|
||||
case 'datetime': {
|
||||
return faker.date.recent({ days: 365 }).toISOString();
|
||||
}
|
||||
|
||||
default: {
|
||||
return faker.lorem.word();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -35,7 +35,7 @@ function updateNotificationPrefs(parsedPrefs: Record<string, NotificationPrefIte
|
||||
notificationPrefs: parsedPrefs
|
||||
};
|
||||
|
||||
sdk.forConsole.account.updatePrefs(newPrefs);
|
||||
sdk.forConsole.account.updatePrefs({ prefs: newPrefs });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,3 +32,7 @@ export function formatCurrency(number: number, locale = 'en-US', currency = 'USD
|
||||
});
|
||||
return formatter.format(number);
|
||||
}
|
||||
|
||||
export function isWithinSafeRange(val: number) {
|
||||
return Math.abs(val) < Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user