@futo-org/backups-orchestrator-ui 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/backends/BackendItem.svelte +9 -2
- package/dist/components/backends/BackendsList.svelte +7 -3
- package/dist/components/backends/dialogs/OAuthDeviceFlowModal.svelte +7 -1
- package/dist/components/backends/dialogs/SelectBackendModal.svelte +12 -6
- package/dist/components/integrations/immich/ImmichManageBackupOverview.svelte +2 -2
- package/dist/components/integrations/immich/ImmichOnboardingRestoreFlow.svelte +44 -34
- package/dist/components/integrations/immich/ImmichOnboardingSetupFlow.svelte +72 -54
- package/dist/components/onboarding/OnboardingBootstrapError.svelte +49 -0
- package/dist/components/onboarding/OnboardingBootstrapError.svelte.d.ts +7 -0
- package/dist/components/onboarding/OnboardingGate.svelte +20 -17
- package/dist/components/onboarding/SampleOnboarding.svelte +22 -6
- package/dist/components/onboarding/stages/OnboardingStageBackupServices.svelte +12 -5
- package/dist/components/onboarding/stages/OnboardingStageTelemetry.svelte +60 -0
- package/dist/components/onboarding/stages/OnboardingStageTelemetry.svelte.d.ts +7 -0
- package/dist/fetch-client.d.ts +7 -0
- package/dist/fetch-client.js +12 -0
- package/dist/services/backend.service.d.ts +2 -2
- package/dist/services/backend.service.js +13 -15
- package/dist/services/onboarding.service.d.ts +2 -0
- package/dist/services/onboarding.service.js +9 -1
- package/package.json +2 -2
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
LocalRepositoryDto,
|
|
6
6
|
RepositoryBackendDto,
|
|
7
7
|
} from "../../fetch-client";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
getBackendActions,
|
|
10
|
+
useYuccaLogin,
|
|
11
|
+
} from "../../services/backend.service";
|
|
9
12
|
import { Badge, Icon, Text } from "@immich/ui";
|
|
10
13
|
import { mdiCloudOutline, mdiHarddisk, mdiShieldCheckOutline } from "@mdi/js";
|
|
11
14
|
import StackListItem from "../ui/StackListItem.svelte";
|
|
@@ -30,8 +33,12 @@
|
|
|
30
33
|
s3: "S3 Server",
|
|
31
34
|
};
|
|
32
35
|
|
|
36
|
+
const yuccaLogin = useYuccaLogin();
|
|
37
|
+
|
|
33
38
|
const { LoginAgain, Reconfigure } = $derived(
|
|
34
|
-
getBackendActions(repository, backend, repositoryBackend)
|
|
39
|
+
getBackendActions(repository, backend, repositoryBackend, () =>
|
|
40
|
+
yuccaLogin.mutate(undefined),
|
|
41
|
+
),
|
|
35
42
|
);
|
|
36
43
|
</script>
|
|
37
44
|
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
import { options } from "../../options";
|
|
4
4
|
import {
|
|
5
5
|
handleSetupLocalStorage,
|
|
6
|
-
handleYuccaLogin,
|
|
7
6
|
useBackendEventHandler,
|
|
8
7
|
useBackends,
|
|
8
|
+
useYuccaLogin,
|
|
9
9
|
} from "../../services/backend.service";
|
|
10
10
|
import { Button, HStack } from "@immich/ui";
|
|
11
11
|
import StackList from "../ui/StackList.svelte";
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
const { advanced } = options;
|
|
23
23
|
const query = useBackends();
|
|
24
|
+
const yuccaLogin = useYuccaLogin();
|
|
24
25
|
const { onBackendCreate } = useBackendEventHandler();
|
|
25
26
|
|
|
26
27
|
const repositoryBackends = $derived(
|
|
@@ -61,8 +62,11 @@
|
|
|
61
62
|
|
|
62
63
|
{#if $advanced}
|
|
63
64
|
<HStack>
|
|
64
|
-
<Button
|
|
65
|
-
|
|
65
|
+
<Button
|
|
66
|
+
size="small"
|
|
67
|
+
variant="outline"
|
|
68
|
+
loading={yuccaLogin.isPending}
|
|
69
|
+
onclick={() => yuccaLogin.mutate(undefined)}>Login with FUTO Backups</Button
|
|
66
70
|
>
|
|
67
71
|
<Button
|
|
68
72
|
size="small"
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
ModalFooter,
|
|
14
14
|
Stack,
|
|
15
15
|
Text,
|
|
16
|
+
toastManager,
|
|
16
17
|
VStack,
|
|
17
18
|
} from "@immich/ui";
|
|
18
19
|
import { mdiContentCopy } from "@mdi/js";
|
|
@@ -35,6 +36,11 @@
|
|
|
35
36
|
onClose();
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
function onDeviceFlowFailure() {
|
|
40
|
+
toastManager.danger("Failed to log into FUTO Backups");
|
|
41
|
+
onClose();
|
|
42
|
+
}
|
|
43
|
+
|
|
38
44
|
function onOpen() {
|
|
39
45
|
window.open(verificationUri, "_blank");
|
|
40
46
|
}
|
|
@@ -44,7 +50,7 @@
|
|
|
44
50
|
}
|
|
45
51
|
</script>
|
|
46
52
|
|
|
47
|
-
<OnEvents {onBackendCreate} />
|
|
53
|
+
<OnEvents {onBackendCreate} {onDeviceFlowFailure} />
|
|
48
54
|
|
|
49
55
|
<Modal title="Logging into FUTO Backups" icon={false} {onClose}>
|
|
50
56
|
<ModalBody>
|
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
import Suspense from "../../util/Suspense.svelte";
|
|
5
5
|
import {
|
|
6
6
|
handleSetupLocalStorage,
|
|
7
|
-
handleYuccaLogin,
|
|
8
7
|
useBackends,
|
|
8
|
+
useYuccaLogin,
|
|
9
9
|
} from "../../../services/backend.service";
|
|
10
10
|
import {
|
|
11
11
|
Button,
|
|
12
12
|
HStack,
|
|
13
13
|
Icon,
|
|
14
|
+
LoadingSpinner,
|
|
14
15
|
Modal,
|
|
15
16
|
ModalBody,
|
|
16
17
|
ModalFooter,
|
|
@@ -37,6 +38,7 @@
|
|
|
37
38
|
}: Props = $props();
|
|
38
39
|
|
|
39
40
|
const backends = useBackends();
|
|
41
|
+
const yuccaLogin = useYuccaLogin();
|
|
40
42
|
|
|
41
43
|
const onFutoBackups = () => {
|
|
42
44
|
const futoBackend = backends.data!.find(
|
|
@@ -46,7 +48,7 @@
|
|
|
46
48
|
if (futoBackend) {
|
|
47
49
|
onSelect(futoBackend.id);
|
|
48
50
|
} else {
|
|
49
|
-
|
|
51
|
+
yuccaLogin.mutate(onSelect);
|
|
50
52
|
}
|
|
51
53
|
};
|
|
52
54
|
|
|
@@ -63,7 +65,7 @@
|
|
|
63
65
|
|
|
64
66
|
<Suspense query={backends}>
|
|
65
67
|
<StackList>
|
|
66
|
-
<StackListItem {disabled} onclick={onFutoBackups}>
|
|
68
|
+
<StackListItem disabled={disabled || yuccaLogin.isPending} onclick={onFutoBackups}>
|
|
67
69
|
{#snippet icon()}
|
|
68
70
|
<Icon icon={mdiShieldCheck} size="36px" />
|
|
69
71
|
{/snippet}
|
|
@@ -74,10 +76,14 @@
|
|
|
74
76
|
</Stack>
|
|
75
77
|
|
|
76
78
|
{#snippet trailing()}
|
|
77
|
-
|
|
79
|
+
{#if yuccaLogin.isPending}
|
|
80
|
+
<LoadingSpinner />
|
|
81
|
+
{:else}
|
|
82
|
+
<Icon icon={mdiChevronRight} />
|
|
83
|
+
{/if}
|
|
78
84
|
{/snippet}
|
|
79
85
|
</StackListItem>
|
|
80
|
-
<StackListItem {disabled} onclick={onLocalBackups}>
|
|
86
|
+
<StackListItem disabled={disabled || yuccaLogin.isPending} onclick={onLocalBackups}>
|
|
81
87
|
{#snippet icon()}
|
|
82
88
|
<Icon icon={mdiHarddisk} size="36px" />
|
|
83
89
|
{/snippet}
|
|
@@ -95,7 +101,7 @@
|
|
|
95
101
|
{#each backends.data as backend}
|
|
96
102
|
{#if backend.type === "local"}
|
|
97
103
|
<StackListItem
|
|
98
|
-
{disabled}
|
|
104
|
+
disabled={disabled || yuccaLogin.isPending}
|
|
99
105
|
onclick={() => {
|
|
100
106
|
onSelect(backend.id);
|
|
101
107
|
}}
|
|
@@ -53,9 +53,9 @@
|
|
|
53
53
|
<Heading size="tiny">Your library</Heading>
|
|
54
54
|
<Text>
|
|
55
55
|
{#if repository.meter}
|
|
56
|
-
|
|
56
|
+
<FormatBytes bytes={repository.meter.sizeBytes} />
|
|
57
57
|
{:else}
|
|
58
|
-
<FormatBytes bytes={repository.metrics.sizeBytes} />
|
|
58
|
+
Estimated <FormatBytes bytes={repository.metrics.sizeBytes} />
|
|
59
59
|
{/if} ·
|
|
60
60
|
<span class="lowercase"
|
|
61
61
|
>{cronstrue.toString(schedule.cron, {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import OnboardingBootstrapError from "../../onboarding/OnboardingBootstrapError.svelte";
|
|
2
3
|
import OnboardingStageBackupServices from "../../onboarding/stages/OnboardingStageBackupServices.svelte";
|
|
3
4
|
import OnboardingStageKeyImport from "../../onboarding/stages/OnboardingStageKeyImport.svelte";
|
|
5
|
+
import OnboardingStageTelemetry from "../../onboarding/stages/OnboardingStageTelemetry.svelte";
|
|
4
6
|
import RestorePointFlow from "../../onboarding/restore-point-flow/RestorePointFlow.svelte";
|
|
5
7
|
import type { OnboardingStatusResponseDto } from "../../../fetch-client";
|
|
6
8
|
import {
|
|
@@ -19,6 +21,7 @@
|
|
|
19
21
|
|
|
20
22
|
let status: OnboardingStatusResponseDto | undefined = $state();
|
|
21
23
|
let stage:
|
|
24
|
+
| "telemetry"
|
|
22
25
|
| "key-import"
|
|
23
26
|
| "backup-service"
|
|
24
27
|
| "restore-point"
|
|
@@ -28,48 +31,55 @@
|
|
|
28
31
|
handleOnboardingStatus().then((data) => {
|
|
29
32
|
status = data;
|
|
30
33
|
|
|
31
|
-
if (data.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
if (data.status !== "ready") {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (data.hasTelemetry === "none") {
|
|
39
|
+
stage = "telemetry";
|
|
40
|
+
} else if (data.hasOnboardedKey) {
|
|
41
|
+
stage = data.hasBackend ? "restore-point" : "backup-service";
|
|
37
42
|
}
|
|
38
43
|
});
|
|
39
44
|
});
|
|
40
45
|
|
|
41
46
|
const onKeyImported = async () => {
|
|
42
47
|
await handleConfirmRecoveryKey();
|
|
43
|
-
|
|
44
|
-
if (status?.hasBackend) {
|
|
45
|
-
stage = "restore-point";
|
|
46
|
-
} else {
|
|
47
|
-
stage = "backup-service";
|
|
48
|
-
}
|
|
48
|
+
stage = status?.hasBackend ? "restore-point" : "backup-service";
|
|
49
49
|
};
|
|
50
50
|
</script>
|
|
51
51
|
|
|
52
|
-
{#if
|
|
53
|
-
{#if stage === "key-import"}
|
|
54
|
-
<OnboardingStageKeyImport onImported={onKeyImported} onCancel={onExit} />
|
|
55
|
-
{:else if stage === "backup-service"}
|
|
56
|
-
<OnboardingStageBackupServices
|
|
57
|
-
onNext={() => (stage = "restore-point")}
|
|
58
|
-
onCancel={onExit}
|
|
59
|
-
restore
|
|
60
|
-
/>
|
|
61
|
-
{:else if stage === "key-reimport"}
|
|
62
|
-
<OnboardingStageKeyImport
|
|
63
|
-
onImported={() => (stage = "restore-point")}
|
|
64
|
-
onCancel={() => (stage = "restore-point")}
|
|
65
|
-
/>
|
|
66
|
-
{:else if stage === "restore-point"}
|
|
67
|
-
<RestorePointFlow
|
|
68
|
-
onImportKey={() => (stage = "key-reimport")}
|
|
69
|
-
onCancel={onExit}
|
|
70
|
-
{onFinish}
|
|
71
|
-
/>
|
|
72
|
-
{/if}
|
|
73
|
-
{:else}
|
|
52
|
+
{#if status === undefined || status.status === "not-ready"}
|
|
74
53
|
<LoadingSpinner />
|
|
54
|
+
{:else if status.status === "error"}
|
|
55
|
+
<OnboardingBootstrapError error={status.error} onQuit={onExit} />
|
|
56
|
+
{:else if stage === "telemetry"}
|
|
57
|
+
<OnboardingStageTelemetry
|
|
58
|
+
onContinue={() =>
|
|
59
|
+
(stage = status?.hasOnboardedKey
|
|
60
|
+
? status.hasBackend
|
|
61
|
+
? "restore-point"
|
|
62
|
+
: "backup-service"
|
|
63
|
+
: "key-import")}
|
|
64
|
+
onCancel={onExit}
|
|
65
|
+
/>
|
|
66
|
+
{:else if stage === "key-import"}
|
|
67
|
+
<OnboardingStageKeyImport onImported={onKeyImported} onCancel={onExit} />
|
|
68
|
+
{:else if stage === "backup-service"}
|
|
69
|
+
<OnboardingStageBackupServices
|
|
70
|
+
onNext={() => (stage = "restore-point")}
|
|
71
|
+
onCancel={onExit}
|
|
72
|
+
restore
|
|
73
|
+
/>
|
|
74
|
+
{:else if stage === "key-reimport"}
|
|
75
|
+
<OnboardingStageKeyImport
|
|
76
|
+
onImported={() => (stage = "restore-point")}
|
|
77
|
+
onCancel={() => (stage = "restore-point")}
|
|
78
|
+
/>
|
|
79
|
+
{:else if stage === "restore-point"}
|
|
80
|
+
<RestorePointFlow
|
|
81
|
+
onImportKey={() => (stage = "key-reimport")}
|
|
82
|
+
onCancel={onExit}
|
|
83
|
+
{onFinish}
|
|
84
|
+
/>
|
|
75
85
|
{/if}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import OnboardingBootstrapError from "../../onboarding/OnboardingBootstrapError.svelte";
|
|
2
3
|
import OnboardingStageBackupServices from "../../onboarding/stages/OnboardingStageBackupServices.svelte";
|
|
3
4
|
import OnboardingStageKeyConfirm from "../../onboarding/stages/OnboardingStageKeyConfirm.svelte";
|
|
4
5
|
import OnboardingStageKeyImport from "../../onboarding/stages/OnboardingStageKeyImport.svelte";
|
|
5
6
|
import OnboardingStageKeyIntro from "../../onboarding/stages/OnboardingStageKeyIntro.svelte";
|
|
6
7
|
import OnboardingStageSaveKey from "../../onboarding/stages/OnboardingStageKeySave.svelte";
|
|
8
|
+
import OnboardingStageTelemetry from "../../onboarding/stages/OnboardingStageTelemetry.svelte";
|
|
7
9
|
import OnboardingStageWelcome from "../../onboarding/stages/OnboardingStageWelcome.svelte";
|
|
8
10
|
import type { OnboardingStatusResponseDto } from "../../../fetch-client";
|
|
9
11
|
import {
|
|
@@ -28,6 +30,7 @@
|
|
|
28
30
|
let status: OnboardingStatusResponseDto | undefined = $state();
|
|
29
31
|
let stage:
|
|
30
32
|
| `welcome`
|
|
33
|
+
| `telemetry`
|
|
31
34
|
| `key-${"intro" | "save" | "confirm" | "import"}`
|
|
32
35
|
| `backup-${"service" | "confirm" | "create"}`
|
|
33
36
|
| `finished` = $state("welcome");
|
|
@@ -36,12 +39,17 @@
|
|
|
36
39
|
handleOnboardingStatus().then(async (data) => {
|
|
37
40
|
status = data;
|
|
38
41
|
|
|
42
|
+
if (data.status !== "ready") {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
39
46
|
if (data.hasOnboardedKey) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
stage =
|
|
48
|
+
data.hasTelemetry === "none"
|
|
49
|
+
? "telemetry"
|
|
50
|
+
: data.hasBackup
|
|
51
|
+
? "finished"
|
|
52
|
+
: "backup-service";
|
|
45
53
|
} else {
|
|
46
54
|
const { recoveryKey } = await handleCurrentRecoveryKey();
|
|
47
55
|
code = recoveryKey;
|
|
@@ -60,54 +68,64 @@
|
|
|
60
68
|
};
|
|
61
69
|
</script>
|
|
62
70
|
|
|
63
|
-
{#if
|
|
64
|
-
{#if stage === "finished"}
|
|
65
|
-
{@render children()}
|
|
66
|
-
{:else if stage === "welcome"}
|
|
67
|
-
<OnboardingStageWelcome
|
|
68
|
-
onNext={() => (stage = "key-intro")}
|
|
69
|
-
onImportKey={() => (stage = "key-import")}
|
|
70
|
-
onCancel={onExit}
|
|
71
|
-
/>
|
|
72
|
-
{:else if stage === "key-import"}
|
|
73
|
-
<OnboardingStageKeyImport
|
|
74
|
-
onStart={() => (stage = "welcome")}
|
|
75
|
-
onImported={() => (stage = "key-confirm")}
|
|
76
|
-
onCancel={onExit}
|
|
77
|
-
/>
|
|
78
|
-
{:else if stage === "key-intro"}
|
|
79
|
-
<OnboardingStageKeyIntro
|
|
80
|
-
onNext={() => (stage = "key-save")}
|
|
81
|
-
onCancel={onExit}
|
|
82
|
-
/>
|
|
83
|
-
{:else if stage === "key-save"}
|
|
84
|
-
<OnboardingStageSaveKey
|
|
85
|
-
{code}
|
|
86
|
-
onNext={() => (stage = "key-confirm")}
|
|
87
|
-
onCancel={onExit}
|
|
88
|
-
/>
|
|
89
|
-
{:else if stage === "key-confirm"}
|
|
90
|
-
<OnboardingStageKeyConfirm
|
|
91
|
-
{code}
|
|
92
|
-
onBack={() => (stage = "key-save")}
|
|
93
|
-
onCancel={onExit}
|
|
94
|
-
{onConfirmKey}
|
|
95
|
-
/>
|
|
96
|
-
{:else if stage === "backup-service"}
|
|
97
|
-
<OnboardingStageBackupServices onNext={onSelectBackend} onCancel={onExit} />
|
|
98
|
-
{:else if stage === "backup-confirm"}
|
|
99
|
-
<ImmichConfirmDefaultBackup
|
|
100
|
-
onCustomize={() => (stage = "backup-create")}
|
|
101
|
-
onConfirm={() => (stage = "finished")}
|
|
102
|
-
onCancel={onExit}
|
|
103
|
-
/>
|
|
104
|
-
{:else if stage === "backup-create"}
|
|
105
|
-
<ImmichConfigureBackup
|
|
106
|
-
onFinish={() => (stage = "finished")}
|
|
107
|
-
onCancel={onExit}
|
|
108
|
-
{backendId}
|
|
109
|
-
/>
|
|
110
|
-
{/if}
|
|
111
|
-
{:else}
|
|
71
|
+
{#if status === undefined || status.status === "not-ready"}
|
|
112
72
|
<LoadingSpinner />
|
|
73
|
+
{:else if status.status === "error"}
|
|
74
|
+
<OnboardingBootstrapError error={status.error} onQuit={onExit} />
|
|
75
|
+
{:else if stage === "finished"}
|
|
76
|
+
{@render children()}
|
|
77
|
+
{:else if stage === "welcome"}
|
|
78
|
+
<OnboardingStageWelcome
|
|
79
|
+
onNext={() => (stage = status?.hasTelemetry === "none" ? "telemetry" : "key-intro")}
|
|
80
|
+
onImportKey={() => (stage = "key-import")}
|
|
81
|
+
onCancel={onExit}
|
|
82
|
+
/>
|
|
83
|
+
{:else if stage === "telemetry"}
|
|
84
|
+
<OnboardingStageTelemetry
|
|
85
|
+
onContinue={() =>
|
|
86
|
+
(stage = status?.hasOnboardedKey
|
|
87
|
+
? status.hasBackup
|
|
88
|
+
? "finished"
|
|
89
|
+
: "backup-service"
|
|
90
|
+
: "key-intro")}
|
|
91
|
+
onCancel={onExit}
|
|
92
|
+
/>
|
|
93
|
+
{:else if stage === "key-import"}
|
|
94
|
+
<OnboardingStageKeyImport
|
|
95
|
+
onStart={() => (stage = "welcome")}
|
|
96
|
+
onImported={() => (stage = "key-confirm")}
|
|
97
|
+
onCancel={onExit}
|
|
98
|
+
/>
|
|
99
|
+
{:else if stage === "key-intro"}
|
|
100
|
+
<OnboardingStageKeyIntro
|
|
101
|
+
onNext={() => (stage = "key-save")}
|
|
102
|
+
onCancel={onExit}
|
|
103
|
+
/>
|
|
104
|
+
{:else if stage === "key-save"}
|
|
105
|
+
<OnboardingStageSaveKey
|
|
106
|
+
{code}
|
|
107
|
+
onNext={() => (stage = "key-confirm")}
|
|
108
|
+
onCancel={onExit}
|
|
109
|
+
/>
|
|
110
|
+
{:else if stage === "key-confirm"}
|
|
111
|
+
<OnboardingStageKeyConfirm
|
|
112
|
+
{code}
|
|
113
|
+
onBack={() => (stage = "key-save")}
|
|
114
|
+
onCancel={onExit}
|
|
115
|
+
{onConfirmKey}
|
|
116
|
+
/>
|
|
117
|
+
{:else if stage === "backup-service"}
|
|
118
|
+
<OnboardingStageBackupServices onNext={onSelectBackend} onCancel={onExit} />
|
|
119
|
+
{:else if stage === "backup-confirm"}
|
|
120
|
+
<ImmichConfirmDefaultBackup
|
|
121
|
+
onCustomize={() => (stage = "backup-create")}
|
|
122
|
+
onConfirm={() => (stage = "finished")}
|
|
123
|
+
onCancel={onExit}
|
|
124
|
+
/>
|
|
125
|
+
{:else if stage === "backup-create"}
|
|
126
|
+
<ImmichConfigureBackup
|
|
127
|
+
onFinish={() => (stage = "finished")}
|
|
128
|
+
onCancel={onExit}
|
|
129
|
+
{backendId}
|
|
130
|
+
/>
|
|
113
131
|
{/if}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
HStack,
|
|
5
|
+
Modal,
|
|
6
|
+
ModalBody,
|
|
7
|
+
ModalFooter,
|
|
8
|
+
Stack,
|
|
9
|
+
Text,
|
|
10
|
+
} from "@immich/ui";
|
|
11
|
+
import { useReportError } from "../../services/onboarding.service";
|
|
12
|
+
|
|
13
|
+
type Props = {
|
|
14
|
+
error?: string;
|
|
15
|
+
onQuit: () => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const { error, onQuit }: Props = $props();
|
|
19
|
+
|
|
20
|
+
const mutation = useReportError();
|
|
21
|
+
|
|
22
|
+
const onReportAndQuit = () =>
|
|
23
|
+
mutation.mutate(undefined, { onSuccess: onQuit });
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<Modal size="small" title="Something went wrong" onClose={onQuit} icon={false}>
|
|
27
|
+
<ModalBody>
|
|
28
|
+
<Stack>
|
|
29
|
+
<Text>We ran into an error setting up backups...</Text>
|
|
30
|
+
{#if error}
|
|
31
|
+
<Text size="small" color="danger" class="font-mono whitespace-pre-wrap"
|
|
32
|
+
>{error}</Text
|
|
33
|
+
>
|
|
34
|
+
{/if}
|
|
35
|
+
</Stack>
|
|
36
|
+
</ModalBody>
|
|
37
|
+
<ModalFooter>
|
|
38
|
+
<HStack>
|
|
39
|
+
<Button
|
|
40
|
+
color="danger"
|
|
41
|
+
onclick={onReportAndQuit}
|
|
42
|
+
loading={mutation.isPending}>Report error and quit</Button
|
|
43
|
+
>
|
|
44
|
+
<Button variant="ghost" onclick={onQuit} disabled={mutation.isPending}
|
|
45
|
+
>Go back</Button
|
|
46
|
+
>
|
|
47
|
+
</HStack>
|
|
48
|
+
</ModalFooter>
|
|
49
|
+
</Modal>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { LoadingSpinner } from "@immich/ui";
|
|
3
3
|
import { onMount, type Snippet } from "svelte";
|
|
4
|
+
import OnboardingBootstrapError from "./OnboardingBootstrapError.svelte";
|
|
4
5
|
import SampleOnboarding from "./SampleOnboarding.svelte";
|
|
5
6
|
import type { OnboardingStatusResponseDto } from "../../fetch-client";
|
|
6
7
|
import { handleOnboardingStatus } from "../../services/onboarding.service";
|
|
@@ -13,14 +14,16 @@
|
|
|
13
14
|
|
|
14
15
|
const { onExit, onFinish, children }: Props = $props();
|
|
15
16
|
|
|
16
|
-
let
|
|
17
|
+
let onboarding: OnboardingStatusResponseDto | undefined = $state();
|
|
17
18
|
|
|
18
19
|
onMount(() => {
|
|
19
|
-
handleOnboardingStatus().then((data) => (
|
|
20
|
+
handleOnboardingStatus().then((data) => (onboarding = data));
|
|
20
21
|
});
|
|
21
22
|
|
|
22
23
|
function onSkip() {
|
|
23
|
-
|
|
24
|
+
onboarding = {
|
|
25
|
+
status: "ready",
|
|
26
|
+
hasTelemetry: "full",
|
|
24
27
|
hasBackend: true,
|
|
25
28
|
hasOnboardedKey: true,
|
|
26
29
|
hasBackup: true,
|
|
@@ -30,19 +33,19 @@
|
|
|
30
33
|
}
|
|
31
34
|
</script>
|
|
32
35
|
|
|
33
|
-
{#if
|
|
34
|
-
{#if !(status.hasBackend && status.hasOnboardedKey && (status.hasSkippedExtraConfig || (status.hasBackup && status.hasSchedule)))}
|
|
35
|
-
<SampleOnboarding
|
|
36
|
-
{status}
|
|
37
|
-
onFinish={() => (onFinish ? onFinish() : onSkip())}
|
|
38
|
-
onCancel={() => {
|
|
39
|
-
onSkip();
|
|
40
|
-
onExit();
|
|
41
|
-
}}
|
|
42
|
-
/>
|
|
43
|
-
{:else}
|
|
44
|
-
{@render children()}
|
|
45
|
-
{/if}
|
|
46
|
-
{:else}
|
|
36
|
+
{#if onboarding === undefined || onboarding.status === "not-ready"}
|
|
47
37
|
<LoadingSpinner />
|
|
38
|
+
{:else if onboarding.status === "error"}
|
|
39
|
+
<OnboardingBootstrapError error={onboarding.error} onQuit={onExit} />
|
|
40
|
+
{:else if onboarding.hasTelemetry === "none" || !(onboarding.hasBackend && onboarding.hasOnboardedKey && (onboarding.hasSkippedExtraConfig || (onboarding.hasBackup && onboarding.hasSchedule)))}
|
|
41
|
+
<SampleOnboarding
|
|
42
|
+
status={onboarding}
|
|
43
|
+
onFinish={() => (onFinish ? onFinish() : onSkip())}
|
|
44
|
+
onCancel={() => {
|
|
45
|
+
onSkip();
|
|
46
|
+
onExit();
|
|
47
|
+
}}
|
|
48
|
+
/>
|
|
49
|
+
{:else}
|
|
50
|
+
{@render children()}
|
|
48
51
|
{/if}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import ImportKey from "./stages/OnboardingStageKeyImport.svelte";
|
|
14
14
|
import KeyIntro from "./stages/OnboardingStageKeyIntro.svelte";
|
|
15
15
|
import SaveKey from "./stages/OnboardingStageKeySave.svelte";
|
|
16
|
+
import Telemetry from "./stages/OnboardingStageTelemetry.svelte";
|
|
16
17
|
import Welcome from "./stages/OnboardingStageWelcome.svelte";
|
|
17
18
|
|
|
18
19
|
type Props = {
|
|
@@ -28,17 +29,20 @@
|
|
|
28
29
|
// svelte-ignore state_referenced_locally
|
|
29
30
|
let stage:
|
|
30
31
|
| "welcome"
|
|
32
|
+
| "telemetry"
|
|
31
33
|
| `key-${"intro" | "save" | "confirm" | "import"}`
|
|
32
34
|
| "backup-service"
|
|
33
35
|
| "backup-create"
|
|
34
36
|
| "schedule-create" = $state(
|
|
35
37
|
!status.hasOnboardedKey
|
|
36
38
|
? "welcome"
|
|
37
|
-
:
|
|
38
|
-
? "
|
|
39
|
-
: !status.
|
|
40
|
-
? "backup-
|
|
41
|
-
:
|
|
39
|
+
: status.hasTelemetry === "none"
|
|
40
|
+
? "telemetry"
|
|
41
|
+
: !status.hasBackend
|
|
42
|
+
? "backup-service"
|
|
43
|
+
: !status.hasBackup
|
|
44
|
+
? "backup-create"
|
|
45
|
+
: "schedule-create",
|
|
42
46
|
);
|
|
43
47
|
|
|
44
48
|
onMount(() => {
|
|
@@ -65,10 +69,22 @@
|
|
|
65
69
|
|
|
66
70
|
{#if stage === "welcome"}
|
|
67
71
|
<Welcome
|
|
68
|
-
onNext={() => (stage = "key-intro")}
|
|
72
|
+
onNext={() => (stage = status.hasTelemetry === "none" ? "telemetry" : "key-intro")}
|
|
69
73
|
onImportKey={() => (stage = "key-import")}
|
|
70
74
|
{onCancel}
|
|
71
75
|
/>
|
|
76
|
+
{:else if stage === "telemetry"}
|
|
77
|
+
<Telemetry
|
|
78
|
+
onContinue={() =>
|
|
79
|
+
(stage = !status.hasOnboardedKey
|
|
80
|
+
? "key-intro"
|
|
81
|
+
: !status.hasBackend
|
|
82
|
+
? "backup-service"
|
|
83
|
+
: !status.hasBackup
|
|
84
|
+
? "backup-create"
|
|
85
|
+
: "schedule-create")}
|
|
86
|
+
{onCancel}
|
|
87
|
+
/>
|
|
72
88
|
{:else if stage === "key-intro"}
|
|
73
89
|
<KeyIntro onNext={() => (stage = "key-save")} {onCancel} />
|
|
74
90
|
{:else if stage === "key-save"}
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
import StackListItem from "../../ui/StackListItem.svelte";
|
|
4
4
|
import {
|
|
5
5
|
handleSetupLocalStorage,
|
|
6
|
-
|
|
6
|
+
useYuccaLogin,
|
|
7
7
|
} from "../../../services/backend.service";
|
|
8
8
|
import {
|
|
9
9
|
Button,
|
|
10
10
|
HStack,
|
|
11
11
|
Icon,
|
|
12
|
+
LoadingSpinner,
|
|
12
13
|
Modal,
|
|
13
14
|
ModalBody,
|
|
14
15
|
ModalFooter,
|
|
@@ -25,8 +26,10 @@
|
|
|
25
26
|
|
|
26
27
|
const { restore = false, onNext, onCancel }: Props = $props();
|
|
27
28
|
|
|
29
|
+
const yuccaLogin = useYuccaLogin();
|
|
30
|
+
|
|
28
31
|
function onFutoBackups() {
|
|
29
|
-
|
|
32
|
+
yuccaLogin.mutate(onNext);
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
function onLocalBackups() {
|
|
@@ -44,7 +47,7 @@
|
|
|
44
47
|
>
|
|
45
48
|
<ModalBody>
|
|
46
49
|
<StackList>
|
|
47
|
-
<StackListItem onclick={onFutoBackups}>
|
|
50
|
+
<StackListItem disabled={yuccaLogin.isPending} onclick={onFutoBackups}>
|
|
48
51
|
{#snippet icon()}
|
|
49
52
|
<Icon icon={mdiShieldCheck} size="36px" />
|
|
50
53
|
{/snippet}
|
|
@@ -55,10 +58,14 @@
|
|
|
55
58
|
</Stack>
|
|
56
59
|
|
|
57
60
|
{#snippet trailing()}
|
|
58
|
-
|
|
61
|
+
{#if yuccaLogin.isPending}
|
|
62
|
+
<LoadingSpinner />
|
|
63
|
+
{:else}
|
|
64
|
+
<Icon icon={mdiChevronRight} />
|
|
65
|
+
{/if}
|
|
59
66
|
{/snippet}
|
|
60
67
|
</StackListItem>
|
|
61
|
-
<StackListItem onclick={onLocalBackups}>
|
|
68
|
+
<StackListItem disabled={yuccaLogin.isPending} onclick={onLocalBackups}>
|
|
62
69
|
{#snippet icon()}
|
|
63
70
|
<Icon icon={mdiHarddisk} size="36px" />
|
|
64
71
|
{/snippet}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
HStack,
|
|
5
|
+
Icon,
|
|
6
|
+
Modal,
|
|
7
|
+
ModalBody,
|
|
8
|
+
ModalFooter,
|
|
9
|
+
Stack,
|
|
10
|
+
Text,
|
|
11
|
+
} from "@immich/ui";
|
|
12
|
+
import { useEnableTelemetry } from "../../../services/onboarding.service";
|
|
13
|
+
import { mdiChartBox, mdiEyeOff } from "@mdi/js";
|
|
14
|
+
|
|
15
|
+
type Props = {
|
|
16
|
+
onContinue: () => void;
|
|
17
|
+
onCancel: () => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const { onContinue, onCancel }: Props = $props();
|
|
21
|
+
|
|
22
|
+
const mutation = useEnableTelemetry();
|
|
23
|
+
|
|
24
|
+
const onConfirm = () => mutation.mutate(undefined, { onSuccess: onContinue });
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<Modal
|
|
28
|
+
size="small"
|
|
29
|
+
title="Telemetry required for closed beta"
|
|
30
|
+
onClose={onCancel}
|
|
31
|
+
>
|
|
32
|
+
<ModalBody>
|
|
33
|
+
<Stack>
|
|
34
|
+
<HStack>
|
|
35
|
+
<Icon icon={mdiChartBox} class="shrink-0 place-self-start mt-1" />
|
|
36
|
+
<Text>
|
|
37
|
+
We collect usage and diagnostic data to understand how FUTO Backups is
|
|
38
|
+
used and to find problems.</Text
|
|
39
|
+
>
|
|
40
|
+
</HStack>
|
|
41
|
+
<HStack>
|
|
42
|
+
<Icon icon={mdiEyeOff} class="shrink-0 place-self-start mt-1" />
|
|
43
|
+
<Text>
|
|
44
|
+
Your photos, files, and recovery key are never collected and never
|
|
45
|
+
leave your device unencrypted.</Text
|
|
46
|
+
>
|
|
47
|
+
</HStack>
|
|
48
|
+
</Stack>
|
|
49
|
+
</ModalBody>
|
|
50
|
+
<ModalFooter>
|
|
51
|
+
<HStack>
|
|
52
|
+
<Button onclick={onConfirm} loading={mutation.isPending}>Continue</Button>
|
|
53
|
+
<Button
|
|
54
|
+
variant="ghost"
|
|
55
|
+
onclick={onCancel}
|
|
56
|
+
disabled={mutation.isPending}>Cancel</Button
|
|
57
|
+
>
|
|
58
|
+
</HStack>
|
|
59
|
+
</ModalFooter>
|
|
60
|
+
</Modal>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
type Props = {
|
|
2
|
+
onContinue: () => void;
|
|
3
|
+
onCancel: () => void;
|
|
4
|
+
};
|
|
5
|
+
declare const OnboardingStageTelemetry: import("svelte").Component<Props, {}, "">;
|
|
6
|
+
type OnboardingStageTelemetry = ReturnType<typeof OnboardingStageTelemetry>;
|
|
7
|
+
export default OnboardingStageTelemetry;
|
package/dist/fetch-client.d.ts
CHANGED
|
@@ -82,7 +82,12 @@ export type ConfigureImmichIntegrationRequestDto = {
|
|
|
82
82
|
libraries: "all" | string[];
|
|
83
83
|
retentionPolicy?: (RetentionPolicyDto) | null;
|
|
84
84
|
};
|
|
85
|
+
export type BootstrapStatus = "not-ready" | "ready" | "error";
|
|
86
|
+
export type TelemetryLevel = "full" | "none";
|
|
85
87
|
export type OnboardingStatusResponseDto = {
|
|
88
|
+
status: BootstrapStatus;
|
|
89
|
+
error?: string;
|
|
90
|
+
hasTelemetry: TelemetryLevel;
|
|
86
91
|
hasOnboardedKey: boolean;
|
|
87
92
|
hasBackend: boolean;
|
|
88
93
|
hasBackup: boolean;
|
|
@@ -269,6 +274,8 @@ export declare function currentRecoveryKey(opts?: Oazapfts.RequestOpts): Promise
|
|
|
269
274
|
export declare function importRecoveryKey(importRecoveryKeyRequest: ImportRecoveryKeyRequest, opts?: Oazapfts.RequestOpts): Promise<never>;
|
|
270
275
|
export declare function confirmRecoveryKey(opts?: Oazapfts.RequestOpts): Promise<never>;
|
|
271
276
|
export declare function skipOnboardingExtraConfig(opts?: Oazapfts.RequestOpts): Promise<never>;
|
|
277
|
+
export declare function enableTelemetry(opts?: Oazapfts.RequestOpts): Promise<never>;
|
|
278
|
+
export declare function reportError(opts?: Oazapfts.RequestOpts): Promise<never>;
|
|
272
279
|
export declare function createRepository(repositoryCreateRequestDto: RepositoryCreateRequestDto, { backend }?: {
|
|
273
280
|
backend?: string;
|
|
274
281
|
}, opts?: Oazapfts.RequestOpts): Promise<RepositoryCreateResponseDto>;
|
package/dist/fetch-client.js
CHANGED
|
@@ -85,6 +85,18 @@ export function skipOnboardingExtraConfig(opts) {
|
|
|
85
85
|
method: "POST"
|
|
86
86
|
}));
|
|
87
87
|
}
|
|
88
|
+
export function enableTelemetry(opts) {
|
|
89
|
+
return oazapfts.ok(oazapfts.fetchText("/api/yucca/onboarding/telemetry", {
|
|
90
|
+
...opts,
|
|
91
|
+
method: "POST"
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
export function reportError(opts) {
|
|
95
|
+
return oazapfts.ok(oazapfts.fetchText("/api/yucca/onboarding/report-error", {
|
|
96
|
+
...opts,
|
|
97
|
+
method: "POST"
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
88
100
|
export function createRepository(repositoryCreateRequestDto, { backend } = {}, opts) {
|
|
89
101
|
return oazapfts.ok(oazapfts.fetchJson(`/api/yucca/repository${QS.query(QS.explode({
|
|
90
102
|
backend
|
|
@@ -10,14 +10,14 @@ export declare const useBackendEventHandler: () => {
|
|
|
10
10
|
backend: BackendDto;
|
|
11
11
|
}>): void;
|
|
12
12
|
};
|
|
13
|
-
export declare const
|
|
13
|
+
export declare const useYuccaLogin: () => import("@tanstack/svelte-query").CreateMutationResult<void, Error, ((backendId: string) => void) | undefined, unknown>;
|
|
14
14
|
export declare const handleSetupLocalStorage: (onCreate?: (backendId: string) => void) => void;
|
|
15
15
|
export declare const useCreateLocalBackend: () => import("@tanstack/svelte-query").CreateMutationResult<import("../fetch-client").BackendResponseDto, Error, CreateLocalBackendRequestDto, unknown>;
|
|
16
16
|
export declare const handleReconfigureRepositoryBackend: (repository: LocalRepositoryDto) => Promise<void>;
|
|
17
17
|
export declare const handleRemoveRepositoryBackend: (_backend: BackendDto, _repositoryBackend: RepositoryBackendDto) => void;
|
|
18
18
|
export declare const getBackendActions: (repository: LocalRepositoryDto | undefined, backend: BackendDto, repositoryBackend?: RepositoryBackendDto & {
|
|
19
19
|
primary?: boolean;
|
|
20
|
-
}) => {
|
|
20
|
+
}, onLogin?: () => void) => {
|
|
21
21
|
LoginAgain: ActionItem;
|
|
22
22
|
Reconfigure: ActionItem;
|
|
23
23
|
Remove: ActionItem;
|
|
@@ -29,20 +29,18 @@ export const useBackendEventHandler = () => {
|
|
|
29
29
|
},
|
|
30
30
|
};
|
|
31
31
|
};
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
window.open(response.verificationUri, '_blank');
|
|
40
|
-
}
|
|
41
|
-
catch (error) {
|
|
42
|
-
handleError(error, 'Failed to start login');
|
|
43
|
-
throw error;
|
|
44
|
-
}
|
|
32
|
+
const startYuccaLogin = async (onCreate) => {
|
|
33
|
+
const response = await oidcDeviceFlow();
|
|
34
|
+
void modalManager.show(OAuthDeviceFlow, {
|
|
35
|
+
...response,
|
|
36
|
+
onCreate,
|
|
37
|
+
});
|
|
38
|
+
window.open(response.verificationUri, '_blank');
|
|
45
39
|
};
|
|
40
|
+
export const useYuccaLogin = () => createMutation(() => ({
|
|
41
|
+
mutationFn: (onCreate) => startYuccaLogin(onCreate),
|
|
42
|
+
onError: (error) => handleError(error, 'Failed to start login'),
|
|
43
|
+
}), () => queryClient);
|
|
46
44
|
export const handleSetupLocalStorage = (onCreate) => {
|
|
47
45
|
void modalManager.show(CreateLocalBackend, { onCreate });
|
|
48
46
|
};
|
|
@@ -58,11 +56,11 @@ export const handleReconfigureRepositoryBackend = async (repository) => {
|
|
|
58
56
|
export const handleRemoveRepositoryBackend = (_backend, _repositoryBackend) => {
|
|
59
57
|
alert('TODO: implement me when multi-repository-backend support is added');
|
|
60
58
|
};
|
|
61
|
-
export const getBackendActions = (repository, backend, repositoryBackend) => {
|
|
59
|
+
export const getBackendActions = (repository, backend, repositoryBackend, onLogin) => {
|
|
62
60
|
const LoginAgain = {
|
|
63
61
|
icon: mdiLogin,
|
|
64
62
|
title: 'Login again',
|
|
65
|
-
onAction: () =>
|
|
63
|
+
onAction: () => onLogin?.(),
|
|
66
64
|
$if: () => backend.type === 'yucca' && !backend.isOnline,
|
|
67
65
|
};
|
|
68
66
|
const Reconfigure = {
|
|
@@ -9,3 +9,5 @@ export declare const handleCurrentRecoveryKey: () => Promise<sdk.CurrentRecovery
|
|
|
9
9
|
export declare const handleConfirmRecoveryKey: () => Promise<void>;
|
|
10
10
|
export declare const handleImportRecoveryKey: (dto: ImportRecoveryKeyRequest) => Promise<void>;
|
|
11
11
|
export declare const handleSkipOnboardingExtraConfig: () => Promise<void>;
|
|
12
|
+
export declare const useEnableTelemetry: () => import("@tanstack/svelte-query").CreateMutationResult<never, Error, void, unknown>;
|
|
13
|
+
export declare const useReportError: () => import("@tanstack/svelte-query").CreateMutationResult<never, Error, void, unknown>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { sdk } from '..';
|
|
2
2
|
import { queryClient } from '../query-client';
|
|
3
3
|
import { handleError } from '../utils/handle-error';
|
|
4
|
-
import { createQuery } from '@tanstack/svelte-query';
|
|
4
|
+
import { createMutation, createQuery } from '@tanstack/svelte-query';
|
|
5
5
|
export const recoveryKeyKeys = {
|
|
6
6
|
all: ['recovery-key'],
|
|
7
7
|
};
|
|
@@ -54,3 +54,11 @@ export const handleSkipOnboardingExtraConfig = async () => {
|
|
|
54
54
|
throw error;
|
|
55
55
|
}
|
|
56
56
|
};
|
|
57
|
+
export const useEnableTelemetry = () => createMutation(() => ({
|
|
58
|
+
mutationFn: () => sdk.enableTelemetry(),
|
|
59
|
+
onError: (error) => handleError(error, 'Failed to save preferences'),
|
|
60
|
+
}), () => queryClient);
|
|
61
|
+
export const useReportError = () => createMutation(() => ({
|
|
62
|
+
mutationFn: () => sdk.reportError(),
|
|
63
|
+
onError: (error) => handleError(error, 'Failed to report error'),
|
|
64
|
+
}), () => queryClient);
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@futo-org/backups-orchestrator-ui",
|
|
3
3
|
"repository": "https://github.com/immich-app/yucca",
|
|
4
4
|
"description": "Backups orchestrator (UI library)",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.5.0",
|
|
6
6
|
"license": "Source First License 1.1",
|
|
7
7
|
"files": [
|
|
8
8
|
"dist",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"lodash.debounce": "^4.0.8",
|
|
65
65
|
"luxon": "^3.7.2",
|
|
66
66
|
"socket.io-client": "^4.8.3",
|
|
67
|
-
"@futo-org/backups-api-client": "^0.
|
|
67
|
+
"@futo-org/backups-api-client": "^0.5.0"
|
|
68
68
|
},
|
|
69
69
|
"scripts": {
|
|
70
70
|
"dev": "vite dev --port 5174",
|