mirror of https://github.com/immich-app/immich.git
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>pull/18879/head
parent
e7d7886f44
commit
74438f5bd8
@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class OnboardingDto {
|
||||||
|
/// Returns a new [OnboardingDto] instance.
|
||||||
|
OnboardingDto({
|
||||||
|
required this.isOnboarded,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool isOnboarded;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is OnboardingDto &&
|
||||||
|
other.isOnboarded == isOnboarded;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(isOnboarded.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'OnboardingDto[isOnboarded=$isOnboarded]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'isOnboarded'] = this.isOnboarded;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [OnboardingDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static OnboardingDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "OnboardingDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return OnboardingDto(
|
||||||
|
isOnboarded: mapValueOfType<bool>(json, r'isOnboarded')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<OnboardingDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <OnboardingDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = OnboardingDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, OnboardingDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, OnboardingDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = OnboardingDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of OnboardingDto-objects as value to a dart map
|
||||||
|
static Map<String, List<OnboardingDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<OnboardingDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = OnboardingDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'isOnboarded',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class OnboardingResponseDto {
|
||||||
|
/// Returns a new [OnboardingResponseDto] instance.
|
||||||
|
OnboardingResponseDto({
|
||||||
|
required this.isOnboarded,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool isOnboarded;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is OnboardingResponseDto &&
|
||||||
|
other.isOnboarded == isOnboarded;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(isOnboarded.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'OnboardingResponseDto[isOnboarded=$isOnboarded]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'isOnboarded'] = this.isOnboarded;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [OnboardingResponseDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static OnboardingResponseDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "OnboardingResponseDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return OnboardingResponseDto(
|
||||||
|
isOnboarded: mapValueOfType<bool>(json, r'isOnboarded')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<OnboardingResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <OnboardingResponseDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = OnboardingResponseDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, OnboardingResponseDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, OnboardingResponseDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = OnboardingResponseDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of OnboardingResponseDto-objects as value to a dart map
|
||||||
|
static Map<String, List<OnboardingResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<OnboardingResponseDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = OnboardingResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'isOnboarded',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { IsBoolean, IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export class OnboardingDto {
|
||||||
|
@IsBoolean()
|
||||||
|
@IsNotEmpty()
|
||||||
|
isOnboarded!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OnboardingResponseDto extends OnboardingDto {}
|
||||||
@ -1,28 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
|
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { Button } from '@immich/ui';
|
|
||||||
import { mdiArrowRight } from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
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 {
|
let userRole = $derived($user.isAdmin && !$serverConfig.isOnboarded ? OnboardingRole.SERVER : OnboardingRole.USER);
|
||||||
onDone: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { onDone }: Props = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<OnboardingCard>
|
<div class="gap-4">
|
||||||
<ImmichLogo noText class="h-[50px]" />
|
<ImmichLogo noText class="h-[100px] mb-2" />
|
||||||
<p class="font-medium text-6xl my-6 text-immich-primary dark:text-immich-dark-primary">
|
<p class="font-medium mb-6 text-6xl text-immich-primary dark:text-immich-dark-primary">
|
||||||
{$t('onboarding_welcome_user', { values: { user: $user.name } })}
|
{$t('onboarding_welcome_user', { values: { user: $user.name } })}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-3xl pb-6 font-light">{$t('onboarding_welcome_description')}</p>
|
<p class="text-3xl pb-6 font-light">
|
||||||
|
{userRole == OnboardingRole.SERVER
|
||||||
<div class="w-full flex place-content-end">
|
? $t('onboarding_server_welcome_description')
|
||||||
<Button shape="round" trailingIcon={mdiArrowRight} class="flex gap-2 place-content-center" onclick={onDone}>
|
: $t('onboarding_user_welcome_description')}
|
||||||
<p>{$t('theme')}</p>
|
</p>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
|
||||||
</OnboardingCard>
|
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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>
|
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import Combobox from '$lib/components/shared-components/combobox.svelte';
|
||||||
|
import { defaultLang, langs } from '$lib/constants';
|
||||||
|
import { lang } from '$lib/stores/preferences.store';
|
||||||
|
import { getClosestAvailableLocale, langCodes } from '$lib/utils/i18n';
|
||||||
|
import { locale as i18nLocale, t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
showSettingDescription?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { showSettingDescription = false }: Props = $props();
|
||||||
|
|
||||||
|
const langOptions = langs
|
||||||
|
.map((lang) => ({ label: lang.name, value: lang.code }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.label.startsWith('Development')) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultLangOption = { label: defaultLang.name, value: defaultLang.code };
|
||||||
|
|
||||||
|
const handleLanguageChange = async (newLang: string | undefined) => {
|
||||||
|
if (newLang) {
|
||||||
|
$lang = newLang;
|
||||||
|
await i18nLocale.set(newLang);
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let closestLanguage = $derived(getClosestAvailableLocale([$lang], langCodes));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={showSettingDescription ? 'grid grid-cols-2' : ''}>
|
||||||
|
{#if showSettingDescription}
|
||||||
|
<div>
|
||||||
|
<div class="flex h-[26px] place-items-center gap-1">
|
||||||
|
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for={$t('language')}>
|
||||||
|
{$t('language')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm dark:text-immich-dark-fg">{$t('language_setting_description')}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Combobox
|
||||||
|
label={$t('language')}
|
||||||
|
hideLabel={true}
|
||||||
|
selectedOption={langOptions.find(({ value }) => value === closestLanguage) || defaultLangOption}
|
||||||
|
placeholder={$t('language')}
|
||||||
|
onSelect={(event) => handleLanguageChange(event?.value)}
|
||||||
|
options={langOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
export enum OnboardingRole {
|
||||||
|
SERVER = 'server',
|
||||||
|
USER = 'user',
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue