|
| 1 | +<script lang="ts"> |
| 2 | + import { Button } from '$lib/elements/forms'; |
| 3 | + import { DropList, GridItem1, CardContainer } from '$lib/components'; |
| 4 | + import { |
| 5 | + Badge, |
| 6 | + Icon, |
| 7 | + Typography, |
| 8 | + Tag, |
| 9 | + Accordion, |
| 10 | + ActionMenu, |
| 11 | + Popover, |
| 12 | + Layout |
| 13 | + } from '@appwrite.io/pink-svelte'; |
| 14 | + import { |
| 15 | + IconAndroid, |
| 16 | + IconApple, |
| 17 | + IconCode, |
| 18 | + IconFlutter, |
| 19 | + IconReact, |
| 20 | + IconUnity, |
| 21 | + IconInfo, |
| 22 | + IconDotsHorizontal, |
| 23 | + IconInboxIn, |
| 24 | + IconSwitchHorizontal |
| 25 | + } from '@appwrite.io/pink-icons-svelte'; |
| 26 | + import { getPlatformInfo } from '$lib/helpers/platform'; |
| 27 | + import { Status, type Models } from '@appwrite.io/console'; |
| 28 | + import type { ComponentType } from 'svelte'; |
| 29 | + import { BillingPlan } from '$lib/constants'; |
| 30 | + import { goto } from '$app/navigation'; |
| 31 | + import { base } from '$app/paths'; |
| 32 | + import { sdk } from '$lib/stores/sdk'; |
| 33 | + import { addNotification } from '$lib/stores/notifications'; |
| 34 | + import { invalidate } from '$app/navigation'; |
| 35 | + import { Dependencies } from '$lib/constants'; |
| 36 | + import { Modal } from '$lib/components'; |
| 37 | + import { isSmallViewport } from '$lib/stores/viewport'; |
| 38 | + import { isCloud } from '$lib/system'; |
| 39 | + import { regions as regionsStore } from '$lib/stores/organization'; |
| 40 | + import type { Organization } from '$lib/stores/organization'; |
| 41 | + import type { Plan } from '$lib/sdk/billing'; |
| 42 | +
|
| 43 | + // props |
| 44 | + interface Props { |
| 45 | + projectsToArchive: Models.Project[]; |
| 46 | + organization: Organization; |
| 47 | + currentPlan: Plan; |
| 48 | + } |
| 49 | +
|
| 50 | + let { projectsToArchive, organization, currentPlan }: Props = $props(); |
| 51 | +
|
| 52 | + // Track Read-only info droplist per archived project |
| 53 | + let readOnlyInfoOpen = $state<Record<string, boolean>>({}); |
| 54 | + let showUnarchiveModal = $state(false); |
| 55 | + let projectToUnarchive = $state<Models.Project | null>(null); |
| 56 | +
|
| 57 | + function filterPlatforms(platforms: { name: string; icon: string }[]) { |
| 58 | + return platforms.filter( |
| 59 | + (value, index, self) => index === self.findIndex((t) => t.name === value.name) |
| 60 | + ); |
| 61 | + } |
| 62 | +
|
| 63 | + function getIconForPlatform(platform: string): ComponentType { |
| 64 | + switch (platform) { |
| 65 | + case 'code': |
| 66 | + return IconCode; |
| 67 | + case 'flutter': |
| 68 | + return IconFlutter; |
| 69 | + case 'apple': |
| 70 | + return IconApple; |
| 71 | + case 'android': |
| 72 | + return IconAndroid; |
| 73 | + case 'react-native': |
| 74 | + return IconReact; |
| 75 | + case 'unity': |
| 76 | + return IconUnity; |
| 77 | + default: |
| 78 | + return IconCode; |
| 79 | + } |
| 80 | + } |
| 81 | +
|
| 82 | + // Check if unarchive should be disabled |
| 83 | + function isUnarchiveDisabled(): boolean { |
| 84 | + if (!organization || !currentPlan) return true; |
| 85 | +
|
| 86 | + if (organization.billingPlan === BillingPlan.FREE) { |
| 87 | + const currentProjectCount = organization.projects?.length || 0; |
| 88 | + const projectLimit = currentPlan.projects || 0; |
| 89 | +
|
| 90 | + return currentProjectCount >= projectLimit; |
| 91 | + } |
| 92 | +
|
| 93 | + return false; |
| 94 | + } |
| 95 | +
|
| 96 | + function handleMigrateProject(project: Models.Project) { |
| 97 | + goto(`${base}/project-${project.region}-${project.$id}/settings/migrations`); |
| 98 | + } |
| 99 | +
|
| 100 | + // Handle unarchive project action |
| 101 | + async function handleUnarchiveProject(project: Models.Project) { |
| 102 | + projectToUnarchive = project; |
| 103 | + showUnarchiveModal = true; |
| 104 | + } |
| 105 | +
|
| 106 | + // Confirm unarchive action |
| 107 | + async function confirmUnarchive() { |
| 108 | + if (!projectToUnarchive) return; |
| 109 | +
|
| 110 | + try { |
| 111 | + if (!organization) { |
| 112 | + addNotification({ |
| 113 | + type: 'error', |
| 114 | + message: 'Organization not found' |
| 115 | + }); |
| 116 | + return; |
| 117 | + } |
| 118 | +
|
| 119 | + await sdk.forConsole.projects.updateStatus(projectToUnarchive.$id, Status.Active); |
| 120 | +
|
| 121 | + await invalidate(Dependencies.ORGANIZATION); |
| 122 | +
|
| 123 | + addNotification({ |
| 124 | + type: 'success', |
| 125 | + message: `${projectToUnarchive.name} has been unarchived` |
| 126 | + }); |
| 127 | +
|
| 128 | + showUnarchiveModal = false; |
| 129 | + projectToUnarchive = null; |
| 130 | + } catch (error) { |
| 131 | + const msg = |
| 132 | + error && typeof error === 'object' && 'message' in error |
| 133 | + ? String((error as { message: string }).message) |
| 134 | + : 'Failed to unarchive project'; |
| 135 | + addNotification({ type: 'error', message: msg }); |
| 136 | + } |
| 137 | + } |
| 138 | +
|
| 139 | + function cancelUnarchive() { |
| 140 | + showUnarchiveModal = false; |
| 141 | + projectToUnarchive = null; |
| 142 | + } |
| 143 | +
|
| 144 | + function findRegion(project: Models.Project) { |
| 145 | + return $regionsStore.regions.find((region) => region.$id === project.region); |
| 146 | + } |
| 147 | +
|
| 148 | + import { formatName as formatNameHelper } from '$lib/helpers/string'; |
| 149 | + function formatName(name: string, limit: number = 19) { |
| 150 | + return formatNameHelper(name, limit, $isSmallViewport); |
| 151 | + } |
| 152 | +</script> |
| 153 | + |
| 154 | +{#if projectsToArchive.length > 0} |
| 155 | + <div class="archive-projects-margin-top"> |
| 156 | + <Accordion title="Archived projects" badge={`${projectsToArchive.length}`}> |
| 157 | + <Typography.Text tag="p" size="s"> |
| 158 | + These projects have been archived and are read-only. You can view and migrate their |
| 159 | + data. |
| 160 | + </Typography.Text> |
| 161 | + |
| 162 | + <div class="archive-projects-margin"> |
| 163 | + <CardContainer disableEmpty={true} total={projectsToArchive.length}> |
| 164 | + {#each projectsToArchive as project} |
| 165 | + {@const platforms = filterPlatforms( |
| 166 | + project.platforms.map((platform) => getPlatformInfo(platform.type)) |
| 167 | + )} |
| 168 | + {@const formatted = formatName(project.name)} |
| 169 | + <GridItem1> |
| 170 | + <svelte:fragment slot="eyebrow"> |
| 171 | + {project?.platforms?.length ? project?.platforms?.length : 'No'} apps |
| 172 | + </svelte:fragment> |
| 173 | + <svelte:fragment slot="title">{formatted}</svelte:fragment> |
| 174 | + <svelte:fragment slot="status"> |
| 175 | + <div class="status-container"> |
| 176 | + <DropList |
| 177 | + bind:show={readOnlyInfoOpen[project.$id]} |
| 178 | + placement="bottom-start" |
| 179 | + noArrow> |
| 180 | + <Tag |
| 181 | + size="s" |
| 182 | + style="white-space: nowrap;" |
| 183 | + on:click={(e) => { |
| 184 | + e.preventDefault(); |
| 185 | + e.stopPropagation(); |
| 186 | + readOnlyInfoOpen = { |
| 187 | + ...readOnlyInfoOpen, |
| 188 | + [project.$id]: !readOnlyInfoOpen[project.$id] |
| 189 | + }; |
| 190 | + }}> |
| 191 | + <Icon icon={IconInfo} size="s" /> |
| 192 | + <span>Read only</span> |
| 193 | + </Tag> |
| 194 | + <svelte:fragment slot="list"> |
| 195 | + <li |
| 196 | + class="drop-list-item u-width-250" |
| 197 | + style="padding: var(--space-5, 12px) var(--space-6, 16px)"> |
| 198 | + <span class="u-block u-mb-8"> |
| 199 | + Archived projects are read-only. You can view |
| 200 | + and migrate their data, but they no longer |
| 201 | + accept edits or requests. |
| 202 | + </span> |
| 203 | + </li> |
| 204 | + </svelte:fragment> |
| 205 | + </DropList> |
| 206 | + <Popover let:toggle padding="none" placement="bottom-end"> |
| 207 | + <Button |
| 208 | + text |
| 209 | + icon |
| 210 | + size="s" |
| 211 | + ariaLabel="more options" |
| 212 | + on:click={(e) => { |
| 213 | + e.preventDefault(); |
| 214 | + e.stopPropagation(); |
| 215 | + toggle(e); |
| 216 | + }}> |
| 217 | + <Icon icon={IconDotsHorizontal} size="s" /> |
| 218 | + </Button> |
| 219 | + <ActionMenu.Root slot="tooltip"> |
| 220 | + <ActionMenu.Item.Button |
| 221 | + leadingIcon={IconInboxIn} |
| 222 | + disabled={isUnarchiveDisabled()} |
| 223 | + on:click={() => handleUnarchiveProject(project)} |
| 224 | + >Unarchive project</ActionMenu.Item.Button> |
| 225 | + <ActionMenu.Item.Button |
| 226 | + leadingIcon={IconSwitchHorizontal} |
| 227 | + on:click={() => handleMigrateProject(project)} |
| 228 | + >Migrate project</ActionMenu.Item.Button> |
| 229 | + </ActionMenu.Root> |
| 230 | + </Popover> |
| 231 | + </div> |
| 232 | + </svelte:fragment> |
| 233 | + |
| 234 | + {#each platforms.slice(0, 2) as platform} |
| 235 | + {@const icon = getIconForPlatform(platform.icon)} |
| 236 | + <Badge |
| 237 | + variant="secondary" |
| 238 | + content={platform.name} |
| 239 | + style="width: max-content;"> |
| 240 | + <Icon {icon} size="s" slot="start" /> |
| 241 | + </Badge> |
| 242 | + {/each} |
| 243 | + |
| 244 | + {#if platforms.length > 3} |
| 245 | + <Badge |
| 246 | + variant="secondary" |
| 247 | + content={`+${platforms.length - 2}`} |
| 248 | + style="width: max-content;" /> |
| 249 | + {/if} |
| 250 | + |
| 251 | + <svelte:fragment slot="icons"> |
| 252 | + {#if isCloud && $regionsStore?.regions} |
| 253 | + {@const region = findRegion(project)} |
| 254 | + <Typography.Text>{region?.name}</Typography.Text> |
| 255 | + {/if} |
| 256 | + </svelte:fragment> |
| 257 | + </GridItem1> |
| 258 | + {/each} |
| 259 | + </CardContainer> |
| 260 | + </div> |
| 261 | + </Accordion> |
| 262 | + </div> |
| 263 | +{/if} |
| 264 | + |
| 265 | +<!-- Unarchive Confirmation Modal --> |
| 266 | +<Modal bind:show={showUnarchiveModal} title="Unarchive project" size="s"> |
| 267 | + <p>Are you sure you want to unarchive <strong>{projectToUnarchive?.name}</strong>?</p> |
| 268 | + <p>This will move the project back to your active projects list.</p> |
| 269 | + |
| 270 | + <svelte:fragment slot="footer"> |
| 271 | + <Layout.Stack direction="row" gap="s" justifyContent="flex-end"> |
| 272 | + <Button secondary on:click={cancelUnarchive}>Cancel</Button> |
| 273 | + <Button on:click={confirmUnarchive}>Unarchive</Button> |
| 274 | + </Layout.Stack> |
| 275 | + </svelte:fragment> |
| 276 | +</Modal> |
| 277 | + |
| 278 | +<style> |
| 279 | + .archive-projects-margin-top { |
| 280 | + margin-top: 36px; |
| 281 | + } |
| 282 | +
|
| 283 | + .archive-projects-margin { |
| 284 | + margin-top: 16px; |
| 285 | + margin-bottom: 36px; |
| 286 | + } |
| 287 | + .status-container { |
| 288 | + display: flex; |
| 289 | + align-items: center; |
| 290 | + gap: 8px; |
| 291 | + } |
| 292 | +</style> |
0 commit comments