mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
feat(yucca sdk): improve onboarding & path selection (#521)
This commit is contained in:
@@ -3,7 +3,7 @@ import { createEventSource } from 'eventsource-client';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { dirname, join, resolve, sep } from 'node:path';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { waitForLog } from 'src/victoria-logs';
|
||||
|
||||
@@ -115,11 +115,11 @@ describe('Filesystem', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to the home directory when no path is provided', async () => {
|
||||
it('defaults to the filesystem root when no path is provided', async () => {
|
||||
await expect(sdk.getFileListing()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
path: homedir(),
|
||||
parent: dirname(homedir()),
|
||||
path: sep,
|
||||
parent: sep,
|
||||
items: expect.any(Array),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { dirname, resolve, sep } from 'node:path';
|
||||
import { FilesystemListingRequestDto, FilesystemListingResponseDto } from '../dto/filesystem.dto';
|
||||
import { StorageRepository } from '../repositories/storage.repository';
|
||||
|
||||
@@ -9,7 +8,7 @@ export class FilesystemService {
|
||||
constructor(private readonly storage: StorageRepository) {}
|
||||
|
||||
async getFileListing(dto: FilesystemListingRequestDto): Promise<FilesystemListingResponseDto> {
|
||||
const path = dto.path ?? homedir();
|
||||
const path = dto.path ?? sep;
|
||||
const files = await this.storage.readdir(path);
|
||||
|
||||
return {
|
||||
|
||||
+3
-5
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import BackendsList from "$lib/components/backends/BackendsList.svelte";
|
||||
import PathListField from "$lib/components/ui/PathListField.svelte";
|
||||
import { type LocalRepositoryDto } from "$lib/fetch-client";
|
||||
import { options } from "$lib/options";
|
||||
import {
|
||||
useRemoveRepository,
|
||||
useUpdateRepository,
|
||||
} from "$lib/services/repository.service";
|
||||
import PathListField from "$lib/components/ui/PathListField.svelte";
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
@@ -89,10 +89,8 @@
|
||||
</PathListField>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
color="danger"
|
||||
loading={removeMutation.isPending}
|
||||
onclick={onRemove}>Remove Repository</Button
|
||||
<Button color="danger" loading={removeMutation.isPending} onclick={onRemove}
|
||||
>Remove Repository from This Device</Button
|
||||
>
|
||||
|
||||
{#if advanced}
|
||||
|
||||
+16
-9
@@ -6,7 +6,7 @@
|
||||
import OnboardingStepConnectAccount from "$lib/components/onboarding/steps/OnboardingStep2ConnectAccount.svelte";
|
||||
import OnboardingStepSaveRecoveryKey from "$lib/components/onboarding/steps/OnboardingStep3SaveRecoveryKey.svelte";
|
||||
import OnboardingStepFirstBackup from "$lib/components/onboarding/steps/OnboardingStep4FirstBackup.svelte";
|
||||
import type { OnboardingStatusResponseDto } from "$lib/fetch-client";
|
||||
import { type OnboardingStatusResponseDto } from "$lib/fetch-client";
|
||||
import {
|
||||
handleSetupLocalStorage,
|
||||
handleStartYuccaLogin,
|
||||
@@ -78,11 +78,11 @@
|
||||
const onCancel = () => (stage = "idle");
|
||||
|
||||
const afterTelemetry = () =>
|
||||
(stage = status?.hasOnboardedKey && status.hasBackup
|
||||
? "finished"
|
||||
: "connect");
|
||||
(stage =
|
||||
status?.hasOnboardedKey && status.hasBackup ? "finished" : "connect");
|
||||
|
||||
const onBackendReady = () => (stage = status?.hasOnboardedKey ? "backup" : "key");
|
||||
const onBackendReady = () =>
|
||||
(stage = status?.hasOnboardedKey ? "backup" : "key");
|
||||
|
||||
const onConnect = () => {
|
||||
storageLocation = "FUTO Backups";
|
||||
@@ -105,6 +105,16 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onImportedKey = async (key: string) => {
|
||||
try {
|
||||
await handleConfirmRecoveryKey();
|
||||
code = key;
|
||||
stage = "connect";
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
|
||||
const onStartBackup = () =>
|
||||
defaults.mutate(undefined, { onSuccess: () => (stage = "finished") });
|
||||
</script>
|
||||
@@ -142,10 +152,7 @@
|
||||
{:else if stage === "key-import"}
|
||||
<OnboardingStageKeyImport
|
||||
onStart={() => (stage = "intro")}
|
||||
onImported={(key) => {
|
||||
code = key;
|
||||
stage = "connect";
|
||||
}}
|
||||
onImported={onImportedKey}
|
||||
{onCancel}
|
||||
/>
|
||||
{:else if stage === "key"}
|
||||
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { OnboardingStatusResponseDto } from "$lib/fetch-client";
|
||||
import { handleOnboardingStatus } from "$lib/services/onboarding.service";
|
||||
import { LoadingSpinner } from "@immich/ui";
|
||||
import { onMount, type Snippet } from "svelte";
|
||||
import OnboardingBootstrapError from "./OnboardingBootstrapError.svelte";
|
||||
import SampleOnboarding from "./SampleOnboarding.svelte";
|
||||
import type { OnboardingStatusResponseDto } from "$lib/fetch-client";
|
||||
import { handleOnboardingStatus } from "$lib/services/onboarding.service";
|
||||
|
||||
type Props = {
|
||||
onExit: () => void;
|
||||
@@ -37,7 +37,7 @@
|
||||
<LoadingSpinner />
|
||||
{:else if onboarding.status === "error"}
|
||||
<OnboardingBootstrapError error={onboarding.error} onQuit={onExit} />
|
||||
{:else if onboarding.hasTelemetry === "none" || !(onboarding.hasBackend && onboarding.hasOnboardedKey && (onboarding.hasSkippedExtraConfig || (onboarding.hasBackup && onboarding.hasSchedule)))}
|
||||
{:else if onboarding.hasTelemetry === "none" || !(onboarding.hasBackend && onboarding.hasOnboardedKey)}
|
||||
<SampleOnboarding
|
||||
status={onboarding}
|
||||
onFinish={() => (onFinish ? onFinish() : onSkip())}
|
||||
|
||||
+19
-40
@@ -7,12 +7,9 @@
|
||||
import {
|
||||
handleConfirmRecoveryKey,
|
||||
handleCurrentRecoveryKey,
|
||||
handleSkipOnboardingExtraConfig,
|
||||
} from "$lib/services/onboarding.service";
|
||||
import { Modal, ModalBody } from "@immich/ui";
|
||||
import { onMount } from "svelte";
|
||||
import CreateFirstBackup from "./stages/SampleCreateFirstBackup.svelte";
|
||||
import CreateFirstSchedule from "./stages/SampleCreateFirstSchedule.svelte";
|
||||
import ImportKey from "./stages/OnboardingStageKeyImport.svelte";
|
||||
import Telemetry from "./stages/OnboardingStageTelemetry.svelte";
|
||||
import StepFinishSetup from "./steps/OnboardingStep1FinishSetup.svelte";
|
||||
@@ -31,23 +28,12 @@
|
||||
let confirming = $state(false);
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let stage:
|
||||
| "intro"
|
||||
| "telemetry"
|
||||
| "key"
|
||||
| "key-import"
|
||||
| "connect"
|
||||
| "backup-create"
|
||||
| "schedule-create" = $state(
|
||||
let stage: "intro" | "telemetry" | "key" | "key-import" | "connect" = $state(
|
||||
!status.hasOnboardedKey
|
||||
? "intro"
|
||||
: status.hasTelemetry === "none"
|
||||
? "telemetry"
|
||||
: !status.hasBackend
|
||||
? "connect"
|
||||
: !status.hasBackup
|
||||
? "backup-create"
|
||||
: "schedule-create",
|
||||
: "connect",
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
@@ -56,11 +42,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
const onSkip = () => {
|
||||
void handleSkipOnboardingExtraConfig();
|
||||
onFinish();
|
||||
};
|
||||
|
||||
const onConfirmKey = async () => {
|
||||
confirming = true;
|
||||
|
||||
@@ -77,7 +58,15 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onBackendReady = () => (stage = "backup-create");
|
||||
const onTelemetryConfirmed = () => {
|
||||
if (!status.hasOnboardedKey) {
|
||||
stage = "key";
|
||||
} else if (status.hasBackend) {
|
||||
onFinish();
|
||||
} else {
|
||||
stage = "connect";
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if stage === "intro"}
|
||||
@@ -91,21 +80,15 @@
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
{:else if stage === "telemetry"}
|
||||
<Telemetry
|
||||
onContinue={() =>
|
||||
(stage = !status.hasOnboardedKey
|
||||
? "key"
|
||||
: !status.hasBackend
|
||||
? "connect"
|
||||
: !status.hasBackup
|
||||
? "backup-create"
|
||||
: "schedule-create")}
|
||||
{onCancel}
|
||||
/>
|
||||
<Telemetry onContinue={onTelemetryConfirmed} {onCancel} />
|
||||
{:else if stage === "key"}
|
||||
<Modal size="small" title="FUTO Backups" onClose={onCancel}>
|
||||
<ModalBody>
|
||||
<StepSaveRecoveryKey {code} onContinue={onConfirmKey} loading={confirming} />
|
||||
<StepSaveRecoveryKey
|
||||
{code}
|
||||
onContinue={onConfirmKey}
|
||||
loading={confirming}
|
||||
/>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
{:else if stage === "key-import"}
|
||||
@@ -121,13 +104,9 @@
|
||||
<Modal size="small" title="FUTO Backups" onClose={onCancel}>
|
||||
<ModalBody>
|
||||
<StepConnectAccount
|
||||
onConnect={() => handleStartYuccaLogin(onBackendReady)}
|
||||
onLocalStorage={() => handleSetupLocalStorage(onBackendReady)}
|
||||
onConnect={() => handleStartYuccaLogin(onFinish)}
|
||||
onLocalStorage={() => handleSetupLocalStorage(onFinish)}
|
||||
/>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
{:else if stage === "backup-create"}
|
||||
<CreateFirstBackup onNext={() => (stage = "schedule-create")} {onSkip} />
|
||||
{:else if stage === "schedule-create"}
|
||||
<CreateFirstSchedule {onFinish} {onSkip} />
|
||||
{/if}
|
||||
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox, Field, FormModal, Input, Stack } from "@immich/ui";
|
||||
import { useCreateRepository } from "$lib/services/repository.service";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
|
||||
type Props = {
|
||||
onNext: () => void;
|
||||
onSkip: () => void;
|
||||
};
|
||||
|
||||
let { onNext, onSkip }: Props = $props();
|
||||
|
||||
let name = $state("");
|
||||
let worm = $state(false);
|
||||
let paths = new SvelteSet([]);
|
||||
|
||||
const mutation = useCreateRepository();
|
||||
|
||||
const onSubmit = () =>
|
||||
mutation.mutate(
|
||||
{ name, worm, paths: [...paths] },
|
||||
{ onSuccess: () => onSkip() },
|
||||
);
|
||||
</script>
|
||||
|
||||
<FormModal
|
||||
title="Create Your First Backup"
|
||||
disabled={name.length === 0 || mutation.isPending}
|
||||
onClose={onSkip}
|
||||
{onSubmit}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Field label="Name" description="A memorable name for this backup">
|
||||
<Input bind:value={name} />
|
||||
</Field>
|
||||
<Field
|
||||
label="Write once (WORM)"
|
||||
description="Prevent anything being deleted"
|
||||
>
|
||||
<Checkbox bind:checked={worm} />
|
||||
</Field>
|
||||
</Stack>
|
||||
</FormModal>
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Field, FormModal, Input, Stack } from "@immich/ui";
|
||||
import validate from "cron-validate";
|
||||
import { useCreateSchedule } from "$lib/services/schedule.service";
|
||||
import { useRepositories } from "$lib/services/repository.service";
|
||||
|
||||
type Props = {
|
||||
onFinish: () => void;
|
||||
onSkip: () => void;
|
||||
};
|
||||
|
||||
const { onFinish, onSkip }: Props = $props();
|
||||
|
||||
const query = useRepositories();
|
||||
const repositories = $derived(
|
||||
(query.data ?? []).filter((repository) => repository.configuration),
|
||||
);
|
||||
|
||||
let name = $state("");
|
||||
let cron = $state("*/15 * * * *");
|
||||
|
||||
const mutation = useCreateSchedule();
|
||||
|
||||
const onSubmit = () =>
|
||||
mutation.mutate(
|
||||
{
|
||||
name,
|
||||
cron,
|
||||
repositories: repositories.map((repository) => repository.id),
|
||||
},
|
||||
{ onSuccess: () => onFinish() },
|
||||
);
|
||||
</script>
|
||||
|
||||
<FormModal
|
||||
title="Create First Schedule"
|
||||
disabled={name.length === 0 || validate(cron).isError() || mutation.isPending}
|
||||
onClose={onSkip}
|
||||
{onSubmit}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Field label="Name" description="Give this schedule a name">
|
||||
<Input bind:value={name} />
|
||||
</Field>
|
||||
<Field label="Schedule" description="Uses cron syntax">
|
||||
<Input bind:value={cron} />
|
||||
</Field>
|
||||
</Stack>
|
||||
</FormModal>
|
||||
@@ -16,6 +16,7 @@
|
||||
} from "@immich/ui";
|
||||
import {
|
||||
mdiArrowUp,
|
||||
mdiCheck,
|
||||
mdiClose,
|
||||
mdiFileOutline,
|
||||
mdiFolderOutline,
|
||||
@@ -149,7 +150,12 @@
|
||||
Browse
|
||||
{/snippet}
|
||||
|
||||
<HStack gap={2} class="items-center px-4 py-3 bg-subtle">
|
||||
<HStack
|
||||
gap={2}
|
||||
class="items-center px-4 py-3 {selected.has(listing.path)
|
||||
? 'bg-primary/10'
|
||||
: 'bg-subtle'}"
|
||||
>
|
||||
<Icon icon={mdiFolderOutline} color="primary" />
|
||||
<Text class="grow truncate" title={listing.path}>
|
||||
{listing.path}
|
||||
@@ -167,7 +173,13 @@
|
||||
|
||||
{#each sortedItems as item (item.path)}
|
||||
{#if item.isDirectory || !foldersOnly}
|
||||
<HStack gap={2} class="items-center px-2 py-1">
|
||||
{@const isSelected = selected.has(item.path)}
|
||||
<HStack
|
||||
gap={2}
|
||||
class="items-center px-2 py-1 {isSelected
|
||||
? 'bg-primary/10'
|
||||
: ''}"
|
||||
>
|
||||
{#if item.isDirectory}
|
||||
<ListButton
|
||||
leadingIcon={mdiFolderOutline}
|
||||
@@ -189,11 +201,16 @@
|
||||
</HStack>
|
||||
{/if}
|
||||
<IconButton
|
||||
icon={mdiPlus}
|
||||
aria-label={single ? "Select" : "Add"}
|
||||
icon={isSelected ? mdiCheck : mdiPlus}
|
||||
aria-label={isSelected
|
||||
? "Selected"
|
||||
: single
|
||||
? "Select"
|
||||
: "Add"}
|
||||
size="tiny"
|
||||
variant="ghost"
|
||||
disabled={!single && selected.has(item.path)}
|
||||
color={isSelected ? "primary" : undefined}
|
||||
disabled={!single && isSelected}
|
||||
onclick={() => add(item.path)}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
Reference in New Issue
Block a user