refactor: settings accordion reactivity (#28281)

This commit is contained in:
Daniel Dietzler
2026-05-07 21:00:23 +02:00
committed by GitHub
parent 52b00b0bad
commit 2039c129f2
8 changed files with 178 additions and 202 deletions
+2 -1
View File
@@ -11,5 +11,6 @@
"@types/oidc-provider": "^9.0.0",
"oidc-provider": "^9.0.0",
"tsx": "^4.20.6"
}
},
"packageManager": "pnpm@10.33.1"
}
@@ -1,10 +1,8 @@
<script lang="ts">
import { accordionManager } from '$lib/managers/accordion-manager.svelte';
import { Icon } from '@immich/ui';
import { onDestroy, onMount, type Snippet } from 'svelte';
import { onDestroy, type Snippet } from 'svelte';
import { slide } from 'svelte/transition';
import { getAccordionState } from './SettingAccordionState.svelte';
const accordionState = getAccordionState();
interface Props {
title: string;
@@ -21,7 +19,7 @@
title,
subtitle = '',
key,
isOpen = $bindable($accordionState.has(key)),
isOpen = $bindable(false),
autoScrollTo = false,
icon = '',
subtitleSnippet,
@@ -30,9 +28,15 @@
let accordionElement: HTMLDivElement | undefined = $state();
const setIsOpen = (isOpen: boolean) => {
$effect(() => {
isOpen = accordionManager.isOpen(key);
});
const toggleOpen = () => {
if (isOpen) {
$accordionState = $accordionState.add(key);
accordionManager.close(key);
} else {
accordionManager.open(key);
if (autoScrollTo) {
setTimeout(() => {
@@ -42,24 +46,11 @@
});
}, 200);
}
} else {
$accordionState.delete(key);
// eslint-disable-next-line no-self-assign
$accordionState = $accordionState;
}
};
onDestroy(() => {
setIsOpen(false);
});
const onclick = () => {
isOpen = !isOpen;
setIsOpen(isOpen);
};
onMount(() => {
setIsOpen(isOpen);
accordionManager.close(key);
});
</script>
@@ -72,7 +63,7 @@
<button
type="button"
aria-expanded={isOpen}
{onclick}
onclick={toggleOpen}
class="flex w-full place-items-center justify-between text-start"
>
<div>
@@ -1,43 +0,0 @@
<script lang="ts" module>
export type AccordionState = Set<string>;
const { get: getAccordionState, set: setAccordionState } = createContext<Writable<AccordionState>>();
export { getAccordionState };
</script>
<script lang="ts">
import { writable, type Writable } from 'svelte/store';
import { createContext } from '$lib/utils/context';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import type { Snippet } from 'svelte';
import { handlePromiseError } from '$lib/utils';
import { SvelteURLSearchParams } from 'svelte/reactivity';
const getParamValues = (param: string) => {
return new Set((page.url.searchParams.get(param) || '').split(' ').filter((x) => x !== ''));
};
interface Props {
queryParam: string;
state?: Writable<AccordionState>;
children?: Snippet;
}
let { queryParam, state = writable(getParamValues(queryParam)), children }: Props = $props();
setAccordionState(state);
const searchParams = new SvelteURLSearchParams(page.url.searchParams);
$effect(() => {
if ($state.size > 0) {
searchParams.set(queryParam, [...$state].join(' '));
} else {
searchParams.delete(queryParam);
}
handlePromiseError(goto(`?${searchParams.toString()}`, { replaceState: true, noScroll: true, keepFocus: true }));
});
</script>
{@render children?.()}
@@ -0,0 +1,46 @@
import { SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { QueryParameter } from '$lib/constants';
import { handlePromiseError } from '$lib/utils';
class AccordionManager {
// needs to be derived since `page.url.searchParams` isn't actually initialized by the time this class gets instantiated.
#searchParams = $derived(new SvelteURLSearchParams(page.url.searchParams));
#state = $derived(
new SvelteSet(
this.#searchParams
.get(QueryParameter.IS_OPEN)
?.split(' ')
.filter((x) => x !== ''),
),
);
isOpen(key: string) {
return this.#state.has(key);
}
#refreshSearchParams() {
if (this.#state.size === 0) {
this.#searchParams.delete(QueryParameter.IS_OPEN);
} else {
this.#searchParams.set(QueryParameter.IS_OPEN, [...this.#state].join(' '));
}
handlePromiseError(
goto(`?${this.#searchParams.toString()}`, { replaceState: true, noScroll: true, keepFocus: true }),
);
}
open(key: string) {
this.#state.add(key);
this.#refreshSearchParams();
}
close(key: string) {
this.#state.delete(key);
this.#refreshSearchParams();
}
}
export const accordionManager = new AccordionManager();
-8
View File
@@ -1,8 +0,0 @@
import { getContext, setContext } from 'svelte';
export function createContext<T>(key: string | symbol = Symbol()) {
return {
get: () => getContext<T>(key),
set: (context: T) => setContext<T>(key, context),
};
}
@@ -26,7 +26,6 @@
mdiTwoFactorAuthentication,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import SettingAccordionState from '$lib/components/shared-components/settings/SettingAccordionState.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
import AppSettings from './AppSettings.svelte';
import ChangePasswordSettings from './ChangePasswordSettings.svelte';
@@ -48,116 +47,114 @@
$page.url.searchParams.get(QueryParameter.OPEN_SETTING) === OpenQueryParam.OAUTH;
</script>
<SettingAccordionState queryParam={QueryParameter.IS_OPEN}>
<SettingAccordion
icon={mdiCogOutline}
key="app-settings"
title={$t('app_settings')}
subtitle={$t('manage_the_app_settings')}
>
<AppSettings />
</SettingAccordion>
<SettingAccordion icon={mdiAccountOutline} key="account" title={$t('account')} subtitle={$t('manage_your_account')}>
<UserProfileSettings />
</SettingAccordion>
<SettingAccordion
icon={mdiServerOutline}
key="user-usage-info"
title={$t('user_usage_stats')}
subtitle={$t('user_usage_stats_description')}
>
<UserUsageStatistic />
</SettingAccordion>
<SettingAccordion icon={mdiApi} key="api-keys" title={$t('api_keys')} subtitle={$t('manage_your_api_keys')}>
<UserApiKeyList bind:keys />
</SettingAccordion>
<SettingAccordion
icon={mdiDevices}
key="authorized-devices"
title={$t('authorized_devices')}
subtitle={$t('manage_your_devices')}
>
<DeviceList bind:devices={sessions} />
</SettingAccordion>
<SettingAccordion
icon={mdiDownload}
key="download-settings"
title={$t('download_settings')}
subtitle={$t('download_settings_description')}
>
<DownloadSettings />
</SettingAccordion>
<SettingAccordion
icon={mdiFeatureSearchOutline}
key="feature"
title={$t('features')}
subtitle={$t('features_setting_description')}
>
<FeatureSettings />
</SettingAccordion>
<SettingAccordion
icon={mdiBellOutline}
key={OpenQueryParam.NOTIFICATIONS}
title={$t('notifications')}
subtitle={$t('notifications_setting_description')}
>
<NotificationsSettings />
</SettingAccordion>
{#if featureFlagsManager.value.oauth}
<SettingAccordion
icon={mdiCogOutline}
key="app-settings"
title={$t('app_settings')}
subtitle={$t('manage_the_app_settings')}
icon={mdiTwoFactorAuthentication}
key={OpenQueryParam.OAUTH}
title={$t('oauth')}
subtitle={$t('manage_your_oauth_connection')}
isOpen={oauthOpen || undefined}
>
<AppSettings />
<OauthSettings />
</SettingAccordion>
{/if}
<SettingAccordion icon={mdiAccountOutline} key="account" title={$t('account')} subtitle={$t('manage_your_account')}>
<UserProfileSettings />
</SettingAccordion>
<SettingAccordion
icon={mdiFormTextboxPassword}
key="password"
title={$t('password')}
subtitle={$t('change_your_password')}
>
<ChangePasswordSettings />
</SettingAccordion>
<SettingAccordion
icon={mdiServerOutline}
key="user-usage-info"
title={$t('user_usage_stats')}
subtitle={$t('user_usage_stats_description')}
>
<UserUsageStatistic />
</SettingAccordion>
<SettingAccordion
icon={mdiAccountGroupOutline}
key="partner-sharing"
title={$t('partner_sharing')}
subtitle={$t('manage_sharing_with_partners')}
>
<PartnerSettings />
</SettingAccordion>
<SettingAccordion icon={mdiApi} key="api-keys" title={$t('api_keys')} subtitle={$t('manage_your_api_keys')}>
<UserApiKeyList bind:keys />
</SettingAccordion>
<SettingAccordion
icon={mdiLockSmart}
key="user-pin-code-settings"
title={$t('user_pin_code_settings')}
subtitle={$t('user_pin_code_settings_description')}
autoScrollTo={true}
>
<ChangePinCodeSettings />
</SettingAccordion>
<SettingAccordion
icon={mdiDevices}
key="authorized-devices"
title={$t('authorized_devices')}
subtitle={$t('manage_your_devices')}
>
<DeviceList bind:devices={sessions} />
</SettingAccordion>
<SettingAccordion
icon={mdiDownload}
key="download-settings"
title={$t('download_settings')}
subtitle={$t('download_settings_description')}
>
<DownloadSettings />
</SettingAccordion>
<SettingAccordion
icon={mdiFeatureSearchOutline}
key="feature"
title={$t('features')}
subtitle={$t('features_setting_description')}
>
<FeatureSettings />
</SettingAccordion>
<SettingAccordion
icon={mdiBellOutline}
key={OpenQueryParam.NOTIFICATIONS}
title={$t('notifications')}
subtitle={$t('notifications_setting_description')}
>
<NotificationsSettings />
</SettingAccordion>
{#if featureFlagsManager.value.oauth}
<SettingAccordion
icon={mdiTwoFactorAuthentication}
key={OpenQueryParam.OAUTH}
title={$t('oauth')}
subtitle={$t('manage_your_oauth_connection')}
isOpen={oauthOpen || undefined}
>
<OauthSettings />
</SettingAccordion>
{/if}
<SettingAccordion
icon={mdiFormTextboxPassword}
key="password"
title={$t('password')}
subtitle={$t('change_your_password')}
>
<ChangePasswordSettings />
</SettingAccordion>
<SettingAccordion
icon={mdiAccountGroupOutline}
key="partner-sharing"
title={$t('partner_sharing')}
subtitle={$t('manage_sharing_with_partners')}
>
<PartnerSettings />
</SettingAccordion>
<SettingAccordion
icon={mdiLockSmart}
key="user-pin-code-settings"
title={$t('user_pin_code_settings')}
subtitle={$t('user_pin_code_settings_description')}
autoScrollTo={true}
>
<ChangePinCodeSettings />
</SettingAccordion>
<SettingAccordion
icon={mdiKeyOutline}
key={OpenQueryParam.PURCHASE_SETTINGS}
title={$t('user_purchase_settings')}
subtitle={$t('user_purchase_settings_description')}
autoScrollTo={true}
>
<UserPurchaseSettings />
</SettingAccordion>
</SettingAccordionState>
<SettingAccordion
icon={mdiKeyOutline}
key={OpenQueryParam.PURCHASE_SETTINGS}
title={$t('user_purchase_settings')}
subtitle={$t('user_purchase_settings_description')}
autoScrollTo={true}
>
<UserPurchaseSettings />
</SettingAccordion>
+8 -12
View File
@@ -1,9 +1,7 @@
<script lang="ts">
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import MaintenanceBackupsList from '$lib/components/maintenance/MaintenanceBackupsList.svelte';
import SettingAccordionState from '$lib/components/shared-components/settings/SettingAccordionState.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
import { QueryParameter } from '$lib/constants';
import { getMaintenanceAdminActions } from '$lib/services/maintenance.service';
import { mdiRefresh } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -20,16 +18,14 @@
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[StartMaintenance]}>
<section id="setting-content" class="flex place-content-center sm:mx-4">
<section class="w-full pb-28 sm:w-5/6 md:w-212.5">
<SettingAccordionState queryParam={QueryParameter.IS_OPEN}>
<SettingAccordion
title={$t('admin.maintenance_restore_database_backup')}
subtitle={$t('admin.maintenance_restore_database_backup_description')}
icon={mdiRefresh}
key="backups"
>
<MaintenanceBackupsList backups={data.backups} expectedVersion={data.expectedVersion} />
</SettingAccordion>
</SettingAccordionState>
<SettingAccordion
title={$t('admin.maintenance_restore_database_backup')}
subtitle={$t('admin.maintenance_restore_database_backup_description')}
icon={mdiRefresh}
key="backups"
>
<MaintenanceBackupsList backups={data.backups} expectedVersion={data.expectedVersion} />
</SettingAccordion>
</section>
</section>
</AdminPageLayout>
@@ -18,9 +18,7 @@
import TrashSettings from './TrashSettings.svelte';
import UserSettings from './UserSettings.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import SettingAccordionState from '$lib/components/shared-components/settings/SettingAccordionState.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
import { QueryParameter } from '$lib/constants';
import SearchBar from '$lib/elements/SearchBar.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
@@ -215,12 +213,10 @@
<div>
<SearchBar placeholder={$t('search_settings')} bind:name={searchQuery} showLoadingSpinner={false} />
</div>
<SettingAccordionState queryParam={QueryParameter.IS_OPEN}>
{#each filteredSettings as { component: Component, title, subtitle, key, icon } (key)}
<SettingAccordion {title} {subtitle} {key} {icon}>
<Component />
</SettingAccordion>
{/each}
</SettingAccordionState>
{#each filteredSettings as { component: Component, title, subtitle, key, icon } (key)}
<SettingAccordion {title} {subtitle} {key} {icon}>
<Component />
</SettingAccordion>
{/each}
</Container>
</AdminPageLayout>