mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
Prior to this, the projects page had two problems: 1. After submitting a downgrade request but still on a Pro plan before the end of the billing period, projects showed as archived 2. After downgraded, archived projects showed as read only and gave options to migrate data even though access to the archived projects were blocked. This PR clarifies the behavior for archived projects by: - showing projects as pending archive if downgraded but still on Pro - removing the read only tag for downgraded orgs - preventing opening projects for downgraded orgs since the user has no access - making the unarchive and migrate buttons disabled for downgraded orgs
359 lines
13 KiB
Svelte
359 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { Button, InputText } from '$lib/elements/forms';
|
|
import { GridItem1, CardContainer, Modal } from '$lib/components';
|
|
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
|
|
import {
|
|
Badge,
|
|
Icon,
|
|
Typography,
|
|
Accordion,
|
|
ActionMenu,
|
|
Popover,
|
|
Layout,
|
|
Divider
|
|
} from '@appwrite.io/pink-svelte';
|
|
import {
|
|
IconAndroid,
|
|
IconApple,
|
|
IconCode,
|
|
IconFlutter,
|
|
IconReact,
|
|
IconUnity,
|
|
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 { 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();
|
|
|
|
// Check if current plan order is less than Pro (order < 1 means FREE plan)
|
|
let isPlanBelowPro = $derived(currentPlan?.order < 1);
|
|
|
|
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 (isPlanBelowPro) {
|
|
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={isPlanBelowPro ? 'Archived projects' : 'Pending archive'}
|
|
badge={`${projectsToArchive.length}`}>
|
|
<Typography.Text tag="p" size="s">
|
|
{#if isPlanBelowPro}
|
|
These projects are archived and require a plan upgrade to restore access.
|
|
{:else}
|
|
These projects will be archived at the end of your billing cycle.
|
|
{/if}
|
|
</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">
|
|
<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}
|
|
disabled={isUnarchiveDisabled()}
|
|
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>
|