feat(web): improved user onboarding (#18782)

* wip

* added user metadata key

* wip

* restructure onboarding system and add initial locale

* update language card and fix translation updating

* remove prints

* new card formattings

* fix cursed unmount effect

* add OAuth route onboarding

* remove required admin auth for onboarding

* delete the hotwire button

* update open-api files

* delete import

* fix failing oauth onboarding fields

* fix e2e test

* fix web e2e test

* add onboarding to user registration e2e test

* remove todo

this was a holdover during dev and didn't get deleted

* fix server small tests

* use onDestroy to save settings rather than a bind:this

* change to false for isOnboarded

* fix other auth small test

* provide type annotation in user factory metadata field

* remove onboardingCompelted from UserDto

* move translations to onboarding steps array and mark as derived so they update

* break language selector out into its own component as per @danieldietzler suggestion

* remove hello header on card

* fix flixkering on server privacy card

* label/id fixes

* openapi

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Brandon Wees 2025-06-02 16:09:13 -05:00 committed by GitHub
parent e7d7886f44
commit 74438f5bd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 961 additions and 235 deletions

View file

@ -1,15 +1,32 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { Button } from '@immich/ui';
import { mdiArrowLeft, mdiArrowRight, mdiCheck } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
title?: string | undefined;
icon?: string | undefined;
children?: Snippet;
previousTitle?: string | undefined;
nextTitle?: string | undefined;
onNext?: () => void;
onPrevious?: () => void;
onLeave?: () => void;
}
let { title = undefined, icon = undefined, children }: Props = $props();
let {
title = undefined,
icon = undefined,
children,
previousTitle,
nextTitle,
onLeave,
onNext,
onPrevious,
}: Props = $props();
</script>
<div
@ -30,4 +47,37 @@
</div>
{/if}
{@render children?.()}
<div class="flex pt-4">
{#if previousTitle}
<div class="w-full flex place-content-start">
<Button
shape="round"
leadingIcon={mdiArrowLeft}
class="flex gap-2 place-content-center"
onclick={() => {
onLeave?.();
onPrevious?.();
}}
>
<p>{previousTitle}</p>
</Button>
</div>
{/if}
<div class="flex w-full place-content-end">
<Button
shape="round"
trailingIcon={nextTitle ? mdiArrowRight : mdiCheck}
onclick={() => {
onLeave?.();
onNext?.();
}}
>
<span class="flex place-content-center place-items-center gap-2">
{nextTitle ?? $t('done')}
</span>
</Button>
</div>
</div>
</div>

View file

@ -1,28 +1,21 @@
<script lang="ts">
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
import { user } from '$lib/stores/user.store';
import { Button } from '@immich/ui';
import { mdiArrowRight } from '@mdi/js';
import { t } from 'svelte-i18n';
import OnboardingCard from './onboarding-card.svelte';
import { OnboardingRole } from '$lib/models/onboarding-role';
import { serverConfig } from '$lib/stores/server-config.store';
interface Props {
onDone: () => void;
}
let { onDone }: Props = $props();
let userRole = $derived($user.isAdmin && !$serverConfig.isOnboarded ? OnboardingRole.SERVER : OnboardingRole.USER);
</script>
<OnboardingCard>
<ImmichLogo noText class="h-[50px]" />
<p class="font-medium text-6xl my-6 text-immich-primary dark:text-immich-dark-primary">
<div class="gap-4">
<ImmichLogo noText class="h-[100px] mb-2" />
<p class="font-medium mb-6 text-6xl text-immich-primary dark:text-immich-dark-primary">
{$t('onboarding_welcome_user', { values: { user: $user.name } })}
</p>
<p class="text-3xl pb-6 font-light">{$t('onboarding_welcome_description')}</p>
<div class="w-full flex place-content-end">
<Button shape="round" trailingIcon={mdiArrowRight} class="flex gap-2 place-content-center" onclick={onDone}>
<p>{$t('theme')}</p>
</Button>
</div>
</OnboardingCard>
<p class="text-3xl pb-6 font-light">
{userRole == OnboardingRole.SERVER
? $t('onboarding_server_welcome_description')
: $t('onboarding_user_welcome_description')}
</p>
</div>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import SettingsLanguageSelector from '$lib/components/shared-components/settings/settings-language-selector.svelte';
import { t } from 'svelte-i18n';
</script>
<div class="flex flex-col gap-4">
<p>
{$t('onboarding_locale_description')}
</p>
<SettingsLanguageSelector />
</div>

View file

@ -1,74 +0,0 @@
<script lang="ts">
import AdminSettings from '$lib/components/admin-page/settings/admin-settings.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { user } from '$lib/stores/user.store';
import { getConfig, type SystemConfigDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { mdiArrowLeft, mdiArrowRight, mdiIncognito } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import OnboardingCard from './onboarding-card.svelte';
interface Props {
onDone: () => void;
onPrevious: () => void;
}
let { onDone, onPrevious }: Props = $props();
let config: SystemConfigDto | null = $state(null);
let adminSettingsComponent = $state<ReturnType<typeof AdminSettings>>();
onMount(async () => {
config = await getConfig();
});
</script>
<OnboardingCard title={$t('privacy')} icon={mdiIncognito}>
<p>
{$t('onboarding_privacy_description')}
</p>
{#if config && $user}
<AdminSettings bind:config bind:this={adminSettingsComponent}>
{#if config}
<SettingSwitch
title={$t('admin.map_settings')}
subtitle={$t('admin.map_implications')}
bind:checked={config.map.enabled}
/>
<SettingSwitch
title={$t('admin.version_check_settings')}
subtitle={$t('admin.version_check_implications')}
bind:checked={config.newVersionCheck.enabled}
/>
<div class="flex pt-4">
<div class="w-full flex place-content-start">
<Button
shape="round"
leadingIcon={mdiArrowLeft}
class="flex gap-2 place-content-center"
onclick={() => onPrevious()}
>
<p>{$t('theme')}</p>
</Button>
</div>
<div class="flex w-full place-content-end">
<Button
shape="round"
trailingIcon={mdiArrowRight}
onclick={() => {
adminSettingsComponent?.handleSave({ map: config?.map, newVersionCheck: config?.newVersionCheck });
onDone();
}}
>
<span class="flex place-content-center place-items-center gap-2">
{$t('admin.storage_template_settings')}
</span>
</Button>
</div>
</div>
{/if}
</AdminSettings>
{/if}
</OnboardingCard>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { systemConfig } from '$lib/stores/server-config.store';
import { updateConfig } from '@immich/sdk';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
onDestroy(async () => {
const cfg = get(systemConfig);
await updateConfig({
systemConfigDto: cfg,
});
});
</script>
<div class="flex flex-col gap-4">
<p>
{$t('onboarding_privacy_description')}
</p>
{#if $systemConfig}
<SettingSwitch
title={$t('admin.map_settings')}
subtitle={$t('admin.map_implications')}
bind:checked={$systemConfig.map.enabled}
/>
<SettingSwitch
title={$t('admin.version_check_settings')}
subtitle={$t('admin.version_check_implications')}
bind:checked={$systemConfig.newVersionCheck.enabled}
/>
{/if}
</div>

View file

@ -5,18 +5,7 @@
import { featureFlags } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store';
import { getConfig, type SystemConfigDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { mdiArrowLeft, mdiCheck, mdiHarddisk } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import OnboardingCard from './onboarding-card.svelte';
interface Props {
onDone: () => void;
onPrevious: () => void;
}
let { onDone, onPrevious }: Props = $props();
let config: SystemConfigDto | undefined = $state();
let adminSettingsComponent = $state<ReturnType<typeof AdminSettings>>();
@ -24,9 +13,13 @@
onMount(async () => {
config = await getConfig();
});
export const save = async () => {
await adminSettingsComponent?.handleSave({ storageTemplate: config?.storageTemplate });
};
</script>
<OnboardingCard title={$t('admin.storage_template_settings')} icon={mdiHarddisk}>
<div class="flex flex-col">
<p>
<FormatMessage key="admin.storage_template_onboarding_description">
{#snippet children({ message })}
@ -48,36 +41,9 @@
onSave={(config) => adminSettingsComponent?.handleSave(config)}
onReset={(options) => adminSettingsComponent?.handleReset(options)}
duration={0}
>
<div class="flex pt-4">
<div class="w-full flex place-content-start">
<Button
shape="round"
leadingIcon={mdiArrowLeft}
class="flex gap-2 place-content-center"
onclick={() => onPrevious()}
>
<p>{$t('privacy')}</p>
</Button>
</div>
<div class="flex w-full place-content-end">
<Button
shape="round"
trailingIcon={mdiCheck}
onclick={() => {
adminSettingsComponent?.handleSave({ storageTemplate: config?.storageTemplate });
onDone();
}}
>
<span class="flex place-content-center place-items-center gap-2">
{$t('done')}
</span>
</Button>
</div>
</div>
</StorageTemplateSettings>
/>
{/if}
{/snippet}
</AdminSettings>
{/if}
</OnboardingCard>
</div>

View file

@ -3,27 +3,16 @@
import Icon from '$lib/components/elements/icon.svelte';
import { Theme } from '$lib/constants';
import { themeManager } from '$lib/managers/theme-manager.svelte';
import { Button } from '@immich/ui';
import { mdiArrowRight, mdiThemeLightDark } from '@mdi/js';
import { t } from 'svelte-i18n';
import OnboardingCard from './onboarding-card.svelte';
interface Props {
onDone: () => void;
}
let { onDone }: Props = $props();
</script>
<OnboardingCard icon={mdiThemeLightDark} title={$t('color_theme')}>
<div>
<p class="pb-6 font-light">{$t('onboarding_theme_description')}</p>
</div>
<div class="flex flex-col gap-4">
<p>{$t('onboarding_theme_description')}</p>
<div class="flex gap-4 mb-6">
<div class="flex gap-4">
<button
type="button"
class="w-1/2 aspect-square bg-light dark:bg-dark rounded-3xl transition-all shadow-sm hover:shadow-xl border-[3px] border-immich-dark-primary/80 border-immich-primary dark:border dark:border-transparent"
class="w-1/2 aspect-square bg-light dark:bg-dark rounded-3xl transition-all shadow-sm hover:shadow-xl border-[3px] border-immich-primary dark:border dark:border-transparent"
onclick={() => themeManager.setTheme(Theme.LIGHT)}
>
<div
@ -35,7 +24,7 @@
</button>
<button
type="button"
class="dark w-1/2 aspect-square bg-light rounded-3xl dark:border-[3px] dark:border-immich-dark-primary/80 dark:border-immich-dark-primary border border-transparent"
class="dark w-1/2 aspect-square bg-light rounded-3xl dark:border-[3px] dark:border-immich-dark-primary border border-transparent"
onclick={() => themeManager.setTheme(Theme.DARK)}
>
<div
@ -46,17 +35,4 @@
</div>
</button>
</div>
<div class="flex">
<div class="w-full flex place-content-end">
<Button
trailingIcon={mdiArrowRight}
shape="round"
class="flex gap-2 place-content-center"
onclick={() => onDone()}
>
<p>{$t('privacy')}</p>
</Button>
</div>
</div>
</OnboardingCard>
</div>

View file

@ -0,0 +1,32 @@
<script lang="ts">
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { preferences } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { updateMyPreferences } from '@immich/sdk';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
let gCastEnabled = $state($preferences?.cast?.gCastEnabled ?? false);
onDestroy(async () => {
try {
const data = await updateMyPreferences({
userPreferencesUpdateDto: {
cast: { gCastEnabled },
},
});
$preferences = { ...data };
} catch (error) {
handleError(error, $t('errors.unable_to_update_settings'));
}
});
</script>
<div class="flex flex-col gap-4">
<p>
{$t('onboarding_privacy_description')}
</p>
<SettingSwitch title={$t('gcast_enabled')} subtitle={$t('gcast_enabled_description')} bind:checked={gCastEnabled} />
</div>