Merge branch 'main' into feat/hero_view_transitions

This commit is contained in:
Alex
2026-05-10 22:36:28 -05:00
committed by GitHub
178 changed files with 17012 additions and 2087 deletions
-1
View File
@@ -1 +0,0 @@
24.15.0
+7 -1
View File
@@ -42,11 +42,17 @@ run = "pnpm run check:svelte"
[tasks.check]
run = { tasks = [":check-typescript", ":check-svelte"] }
[tasks.checklist]
[tasks.ci-unit]
depends = ["//:sdk:install", "//:sdk:build"]
run = [
{ task = ":install" },
{ task = ":format" },
{ task = ":check" },
{ task = ":test --run" },
]
[tasks.checklist]
run = [
{ task = ":ci-unit" },
{ task = ":lint" },
]
+1 -4
View File
@@ -14,7 +14,7 @@
"check:watch": "pnpm run check:svelte --watch",
"check:code": "pnpm run format && pnpm run lint && pnpm run check:svelte && pnpm run check:typescript",
"check:all": "pnpm run check:code && pnpm run test:cov",
"lint": "eslint . --max-warnings 0 --concurrency 4",
"lint": "eslint . --max-warnings 0 --concurrency 6",
"lint:fix": "pnpm run lint --fix",
"format": "prettier --cache --check .",
"format:fix": "prettier --cache --write --list-different .",
@@ -111,8 +111,5 @@
"typescript-eslint": "^8.45.0",
"vite": "^8.0.0",
"vitest": "^4.0.0"
},
"volta": {
"node": "24.15.0"
}
}
@@ -51,7 +51,7 @@ describe('AssetViewer', () => {
vi.restoreAllMocks();
});
it('updates the top bar favorite action after pressing favorite', async () => {
it.skip('updates the top bar favorite action after pressing favorite', async () => {
const ownerId = 'owner-id';
const user = userAdminFactory.build({ id: ownerId });
const asset = assetFactory.build({ ownerId, isFavorite: false, isTrashed: false });
@@ -1,6 +1,7 @@
<script lang="ts">
import { browser } from '$app/environment';
import { focusTrap } from '$lib/actions/focus-trap';
import { shortcuts } from '$lib/actions/shortcut';
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import NextAssetAction from '$lib/components/asset-viewer/actions/NextAssetAction.svelte';
import PreviousAssetAction from '$lib/components/asset-viewer/actions/PreviousAssetAction.svelte';
@@ -289,6 +290,22 @@
void navigation.finally(() => (navigating = false));
};
const navigateStack = (direction: 'previous' | 'next') => {
if (!stack || !withStacked || assetViewerManager.isShowEditor) {
return;
}
const assets = stack.assets;
const currentIndex = assets.findIndex(({ id }) => id === asset.id);
if (currentIndex === -1) {
return;
}
const nextIndex = direction === 'previous' ? currentIndex - 1 : currentIndex + 1;
if (nextIndex < 0 || nextIndex >= assets.length) {
return;
}
cursor.current = assets[nextIndex];
};
/**
* Slide show mode
*/
@@ -503,6 +520,10 @@
class="fixed inset-s-0 top-0 z-10 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
data-navigating={navigating || undefined}
use:focusTrap
use:shortcuts={[
{ shortcut: { key: 'ArrowUp' }, onShortcut: () => navigateStack('previous') },
{ shortcut: { key: 'ArrowDown' }, onShortcut: () => navigateStack('next') },
]}
bind:this={assetViewerHtmlElement}
>
<!-- Top navigation bar -->
@@ -4,7 +4,7 @@
import { assetViewerFadeDuration } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { autoPlayVideo, loopVideo as loopVideoPreference } from '$lib/stores/preferences.store';
import { autoPlayVideo, lang, loopVideo as loopVideoPreference } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
import { Icon, LoadingSpinner } from '@immich/ui';
@@ -166,6 +166,7 @@
<!-- dir=ltr based on https://github.com/videojs/video.js/issues/949 -->
<media-controller
dir="ltr"
lang={$lang}
nohotkeys
class="dark h-full max-w-full"
style:aspect-ratio={aspectRatio}
@@ -196,14 +197,14 @@
></video>
{#if extendedControls}
<media-settings-menu hidden anchor="auto" class="w-3xs rounded-xl border border-light-300 shadow-sm">
<media-settings-menu hidden anchor="auto" class="min-w-3xs rounded-xl border border-light-300 shadow-sm">
<Icon slot="checked-indicator" icon={mdiCheck} class="m-2" />
<media-settings-menu-item class="mx-1 rounded-lg p-1 ps-2">
{$t('playback_speed')}
{$t('media_chrome.playback_rate')}
<Icon slot="suffix" icon={mdiChevronRight} class="m-2" />
<media-playback-rate-menu slot="submenu" hidden rates="0.5 1 1.5 2">
<Icon slot="back-icon" icon={mdiChevronLeft} class="m-2" />
<span slot="title">{$t('playback_speed')}</span>
<span slot="title">{$t('media_chrome.playback_rate')}</span>
</media-playback-rate-menu>
</media-settings-menu-item>
</media-settings-menu>
@@ -30,7 +30,7 @@
trailing,
}: Props = $props();
let appBarBorder = $state('bg-light border border-transparent');
let appBarBorder = $state('border border-subtle');
const onScroll = () => {
if (window.scrollY > 80) {
@@ -40,7 +40,7 @@
appBarBorder = 'border border-gray-600';
}
} else {
appBarBorder = 'bg-light border border-transparent';
appBarBorder = 'border border-subtle';
}
};
@@ -66,9 +66,9 @@
!multiRow && 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]',
'justify-between lg:grid-cols-[25%_50%_25%]',
appBarBorder,
'm-2 place-items-center rounded-lg p-2 transition-all max-md:p-0',
'm-2 place-items-center rounded-full p-2 transition-all max-md:p-0',
tailwindClasses,
forceDark ? 'bg-immich-dark-gray! text-white' : 'bg-subtle dark:bg-immich-dark-gray',
forceDark ? 'bg-immich-dark-gray! text-white' : 'bg-light-50 dark:bg-immich-dark-gray',
]}
>
<div class="flex place-items-center justify-self-start sm:gap-6 dark:text-immich-dark-fg {forceDark ? 'dark' : ''}">
@@ -88,6 +88,10 @@
case 'description': {
return { description: term };
}
case 'fullPath': {
const normalizedTerm = term.trim();
return normalizedTerm ? { originalPath: normalizedTerm } : {};
}
case 'ocr': {
return { ocr: term };
}
@@ -198,6 +202,7 @@
case 'smart':
case 'metadata':
case 'description':
case 'fullPath':
case 'ocr': {
currentSearchType = searchType;
return searchType;
@@ -220,6 +225,9 @@
case 'description': {
return $t('description');
}
case 'fullPath': {
return $t('full_path_or_folder');
}
case 'ocr': {
return $t('ocr');
}
@@ -237,6 +245,7 @@
{ value: 'smart', label: () => $t('context') },
{ value: 'metadata', label: () => $t('filename') },
{ value: 'description', label: () => $t('description') },
{ value: 'fullPath', label: () => $t('full_path_or_folder') },
{ value: 'ocr', label: () => $t('ocr') },
] as const;
</script>
@@ -6,7 +6,7 @@
interface Props {
query: string | undefined;
queryType?: 'smart' | 'metadata' | 'description' | 'ocr';
queryType?: 'smart' | 'metadata' | 'description' | 'fullPath' | 'ocr';
}
let { query = $bindable(), queryType = $bindable('smart') }: Props = $props();
@@ -33,6 +33,13 @@
bind:group={queryType}
value="description"
/>
<RadioButton
name="query-type"
id="full-path-radio"
label={$t('full_path_or_folder')}
bind:group={queryType}
value="fullPath"
/>
{#if featureFlagsManager.value.ocr}
<RadioButton name="query-type" id="ocr-radio" label={$t('ocr')} bind:group={queryType} value="ocr" />
{/if}
@@ -51,6 +58,10 @@
<Field label={$t('search_by_description')}>
<Input type="text" placeholder={$t('search_by_description_example')} bind:value={query} />
</Field>
{:else if queryType === 'fullPath'}
<Field label={$t('search_by_full_path')}>
<Input type="text" placeholder={$t('search_by_full_path_example')} bind:value={query} />
</Field>
{:else if queryType === 'ocr'}
<Field label={$t('search_by_ocr')}>
<Input type="text" placeholder={$t('search_by_ocr_example')} bind:value={query} />
@@ -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?.()}
+8 -1
View File
@@ -87,10 +87,17 @@ export enum QueryType {
SMART = 'smart',
METADATA = 'metadata',
DESCRIPTION = 'description',
FULL_PATH = 'fullPath',
OCR = 'ocr',
}
export const validQueryTypes = new Set([QueryType.SMART, QueryType.METADATA, QueryType.DESCRIPTION, QueryType.OCR]);
export const validQueryTypes = new Set([
QueryType.SMART,
QueryType.METADATA,
QueryType.DESCRIPTION,
QueryType.FULL_PATH,
QueryType.OCR,
]);
export const locales = [
{ code: 'af-ZA', name: 'Afrikaans (South Africa)' },
@@ -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();
+23 -18
View File
@@ -24,10 +24,11 @@
type Props = {
album: AlbumResponseDto;
readOnly?: boolean;
onClose: () => void;
};
let { album, onClose }: Props = $props();
let { album, readOnly = false, onClose }: Props = $props();
const handleRoleSelect = async (user: UserResponseDto, role: AlbumUserRole | 'none') => {
if (role === 'none') {
@@ -72,14 +73,14 @@
onAlbumUpdate={(newAlbum) => (album = newAlbum)}
/>
<Modal title={$t('options')} {onClose} size="small">
<Modal title={readOnly ? $t('album') : $t('options')} {onClose} size="small">
<ModalBody>
<Stack gap={6}>
<div>
<Text size="medium" fontWeight="semi-bold">{$t('settings')}</Text>
<div class="mt-2 grid gap-y-3 ps-2">
{#if album.order}
<Field label={$t('display_order')}>
<Field label={$t('display_order')} disabled={readOnly}>
<Select
value={album.order}
options={[
@@ -90,7 +91,7 @@
/>
</Field>
{/if}
<Field label={$t('comments_and_likes')} description={$t('let_others_respond')}>
<Field label={$t('comments_and_likes')} description={$t('let_others_respond')} disabled={readOnly}>
<Switch
checked={album.isActivityEnabled}
onCheckedChange={(checked) => handleUpdateAlbum(album, { isActivityEnabled: checked })}
@@ -102,7 +103,9 @@
<div>
<HStack fullWidth class="mb-2 justify-between">
<Text size="medium" fontWeight="semi-bold">{$t('people')}</Text>
<HeaderActionButton action={AddUsers} />
{#if !readOnly}
<HeaderActionButton action={AddUsers} />
{/if}
</HStack>
<div class="ps-2">
{#each album.albumUsers as { user, role } (user.id)}
@@ -113,7 +116,7 @@
</div>
<Text size="small">{user.name}</Text>
</div>
<Field class="w-32" disabled={role === AlbumUserRole.Owner}>
<Field class="w-32" disabled={readOnly || role === AlbumUserRole.Owner}>
<Select
value={role}
options={[
@@ -129,20 +132,22 @@
{/each}
</div>
</div>
<div class="mb-4">
<HStack class="mb-2 justify-between">
<Text size="medium" fontWeight="semi-bold">{$t('shared_links')}</Text>
<HeaderActionButton action={CreateSharedLink} />
</HStack>
{#if !readOnly}
<div class="mb-4">
<HStack class="mb-2 justify-between">
<Text size="medium" fontWeight="semi-bold">{$t('shared_links')}</Text>
<HeaderActionButton action={CreateSharedLink} />
</HStack>
<div class="ps-2">
<Stack gap={4}>
{#each sharedLinks as sharedLink (sharedLink.id)}
<AlbumSharedLink {album} {sharedLink} />
{/each}
</Stack>
<div class="ps-2">
<Stack gap={4}>
{#each sharedLinks as sharedLink (sharedLink.id)}
<AlbumSharedLink {album} {sharedLink} />
{/each}
</Stack>
</div>
</div>
</div>
{/if}
</Stack>
</ModalBody>
</Modal>
+2 -5
View File
@@ -28,11 +28,8 @@
let { onClose }: Props = $props();
onMount(async () => {
// TODO the server should *really* just return all albums (paginated ideally)
const ownedAlbums = await getAllAlbums({ shared: false });
ownedAlbums.push.apply(ownedAlbums, await getAllAlbums({ shared: true }));
albums = ownedAlbums;
recentAlbums = albums.sort((a, b) => (new Date(a.updatedAt) > new Date(b.updatedAt) ? -1 : 1)).slice(0, 3);
albums = await getAllAlbums({});
recentAlbums = [...albums].sort((a, b) => (new Date(a.updatedAt) > new Date(b.updatedAt) ? -1 : 1)).slice(0, 3);
loading = false;
});
@@ -54,6 +54,10 @@
query = searchQuery.originalFileName;
}
if ('originalPath' in searchQuery && searchQuery.originalPath) {
query = searchQuery.originalPath;
}
return {
query,
ocr: searchQuery.ocr,
@@ -133,6 +137,7 @@
ocr: filter.queryType === 'ocr' ? query : undefined,
originalFileName: filter.queryType === 'metadata' ? query : undefined,
description: filter.queryType === 'description' ? query : undefined,
originalPath: filter.queryType === 'fullPath' ? filter.query.trim() || undefined : undefined,
country: filter.location.country,
state: filter.location.state,
city: filter.location.city,
+1 -1
View File
@@ -80,7 +80,7 @@ export type SearchLocationFilter = {
export type SearchFilter = {
query: string;
ocr?: string;
queryType: 'smart' | 'metadata' | 'description' | 'ocr';
queryType: 'smart' | 'metadata' | 'description' | 'fullPath' | 'ocr';
personIds: SvelteSet<string>;
tagIds: SvelteSet<string> | null;
location: SearchLocationFilter;
-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),
};
}
+2 -2
View File
@@ -5,8 +5,8 @@ import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const sharedAlbums = await getAllAlbums({ shared: true });
const albums = await getAllAlbums({});
const sharedAlbums = await getAllAlbums({ isShared: true });
const albums = await getAllAlbums({ isOwned: true });
const $t = await getFormatter();
return {
@@ -37,7 +37,6 @@
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { Route } from '$lib/route';
import {
getAlbumActions,
@@ -66,6 +65,7 @@
mdiArrowLeft,
mdiCogOutline,
mdiDeleteOutline,
mdiDotsHorizontal,
mdiDotsVertical,
mdiDownload,
mdiImageOutline,
@@ -373,38 +373,41 @@
<!-- ALBUM SHARING -->
{#if album.albumUsers.length > 1 || (album.hasSharedLink && isOwned)}
<div class="my-3 flex gap-x-1">
<!-- link -->
{#if album.hasSharedLink && isOwned}
<IconButton
aria-label={$t('create_link_to_share')}
color="secondary"
size="medium"
shape="round"
icon={mdiLink}
onclick={() => modalManager.show(SharedLinkCreateModal, { albumId: album.id })}
/>
{/if}
<!-- users with write access (collaborators) -->
{#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor || role === AlbumUserRole.Owner) as { user } (user.id)}
<button type="button" onclick={() => modalManager.show(AlbumOptionsModal, { album })}>
<button
class="flex gap-x-1"
type="button"
onclick={() => modalManager.show(AlbumOptionsModal, { album, readOnly: !isOwned })}
>
<!-- owner & users with write access (collaborators) -->
{#each album.albumUsers.filter(({ role }) => role === AlbumUserRole.Editor || role === AlbumUserRole.Owner) as { user } (user.id)}
<UserAvatar {user} size="md" />
</button>
{/each}
{/each}
<!-- display ellipsis if there are readonly users too -->
{#if albumHasViewers}
<IconButton
shape="round"
aria-label={$t('view_all_users')}
color="secondary"
size="medium"
icon={mdiDotsVertical}
onclick={() => modalManager.show(AlbumOptionsModal, { album })}
/>
<!-- display ellipsis if there are readonly users too -->
{#if albumHasViewers}
<IconButton
shape="round"
aria-label={$t('view_all_users')}
color="secondary"
size="medium"
icon={mdiDotsHorizontal}
/>
{/if}
{#if album.hasSharedLink && isOwned}
<IconButton
aria-label={$t('shared_link_manage_links')}
color="secondary"
size="medium"
shape="round"
icon={mdiLink}
/>
{/if}
</button>
{#if isOwned}
<ActionButton action={Share} />
{/if}
<ActionButton action={Share} />
</div>
{/if}
<AlbumDescription
+1 -23
View File
@@ -3,7 +3,6 @@
import { page } from '$app/stores';
import { scrollMemory } from '$lib/actions/scroll-memory';
import { shortcut } from '$lib/actions/shortcut';
import ManagePeopleVisibility from './ManagePeopleVisibility.svelte';
import PeopleCard from './PeopleCard.svelte';
import PeopleInfiniteScroll from './PeopleInfiniteScroll.svelte';
import SearchPeople from '$lib/components/faces-page/PeopleSearch.svelte';
@@ -22,8 +21,6 @@
import { mdiAccountOff, mdiEyeOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import type { PageData } from './$types';
interface Props {
@@ -32,7 +29,6 @@
let { data }: Props = $props();
let selectHidden = $state(false);
let searchName = $state('');
let newName = $state('');
let currentPage = $state(1);
@@ -331,7 +327,7 @@
</div>
<Button
leadingIcon={mdiEyeOutline}
onclick={() => (selectHidden = !selectHidden)}
onclick={() => goto('/people/manage')}
size="small"
variant="ghost"
color="secondary">{$t('show_and_hide_people')}</Button
@@ -377,21 +373,3 @@
</div>
{/if}
</UserPageLayout>
{#if selectHidden}
<dialog
transition:fly={{ y: innerHeight, duration: 150, easing: quintOut, opacity: 0 }}
class="fixed inset-0 size-full max-h-none max-w-none bg-light"
aria-labelledby="manage-visibility-title"
{@attach (dialog) => dialog.showModal()}
>
<ManagePeopleVisibility
{people}
totalPeopleCount={data.people.total}
titleId="manage-visibility-title"
onClose={() => (selectHidden = false)}
onUpdate={(updatedPeople) => (people = updatedPeople.slice())}
{loadNextPage}
/>
</dialog>
{/if}
@@ -1,20 +0,0 @@
<script lang="ts">
import type { PersonResponseDto } from '@immich/sdk';
import { TooltipProvider } from '@immich/ui';
import ManagePeopleVisibility from './ManagePeopleVisibility.svelte';
interface Props {
people: PersonResponseDto[];
totalPeopleCount: number;
titleId?: string | undefined;
onClose: () => void;
onUpdate: (people: PersonResponseDto[]) => void;
loadNextPage: () => void;
}
let props: Props = $props();
</script>
<TooltipProvider>
<ManagePeopleVisibility {...props} />
</TooltipProvider>
@@ -62,6 +62,8 @@
let { data }: Props = $props();
let numberOfAssets = $derived(data.statistics.assets);
let person = $derived(data.person);
let thumbnailData = $derived(getPeopleThumbnailUrl(person));
let timelineManager = $state<TimelineManager>() as TimelineManager;
const options = $derived({ visibility: AssetVisibility.Timeline, personId: data.person.id });
@@ -74,7 +76,7 @@
let potentialMergePeople: PersonResponseDto[] = $state([]);
let isSuggestionSelectedByUser = $state(false);
let personName = '';
let personName = $derived(person.name);
let suggestedPeople: PersonResponseDto[] = $state([]);
/**
@@ -187,7 +189,6 @@
isEditingName = false;
if (person.id !== person2.id) {
potentialMergePeople = [];
personName = person.name;
personMerge1 = person;
personMerge2 = person2;
isSuggestionSelectedByUser = true;
@@ -276,10 +277,6 @@
await updateAssetCount();
};
let person = $derived(data.person);
let thumbnailData = $derived(getPeopleThumbnailUrl(person));
const handleSetVisibility = (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
assetMultiSelectManager.clear();
@@ -1,28 +1,28 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
import PeopleInfiniteScroll from './PeopleInfiniteScroll.svelte';
import PeopleInfiniteScroll from '../PeopleInfiniteScroll.svelte';
import { goto } from '$app/navigation';
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import { ToggleVisibility } from '$lib/constants';
import { locale } from '$lib/stores/preferences.store';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { updatePeople, type PersonResponseDto } from '@immich/sdk';
import { getAllPeople, updatePeople, type PersonResponseDto } from '@immich/sdk';
import { Button, IconButton, toastManager } from '@immich/ui';
import { mdiClose, mdiEye, mdiEyeOff, mdiEyeSettings, mdiRestart } from '@mdi/js';
import { t } from 'svelte-i18n';
import { SvelteMap } from 'svelte/reactivity';
import type { PageData } from './$types';
interface Props {
people: PersonResponseDto[];
totalPeopleCount: number;
titleId?: string | undefined;
onClose: () => void;
onUpdate: (people: PersonResponseDto[]) => void;
loadNextPage: () => void;
data: PageData;
}
let { people, totalPeopleCount, titleId = undefined, onClose, onUpdate, loadNextPage }: Props = $props();
const { data }: Props = $props();
let people = $derived(data.people.people);
const totalPeopleCount = $derived(data.people.total);
let nextPage = $state(data.people.hasNextPage ? 2 : null);
let toggleVisibility = $state(ToggleVisibility.SHOW_ALL);
let showLoadingSpinner = $state(false);
const overrides = new SvelteMap<string, boolean>();
@@ -78,8 +78,7 @@
}
overrides.clear();
onUpdate(people);
onClose();
await goto('/people');
} catch (error) {
handleError(error, $t('errors.unable_to_change_visibility', { values: { count: changed.length } }));
} finally {
@@ -95,6 +94,19 @@
overrides.set(person.id, isHidden);
};
const loadNextPage = async () => {
if (!nextPage) {
return;
}
try {
const { people: newPeople, hasNextPage } = await getAllPeople({ withHidden: true, page: nextPage });
people = people.concat(newPeople);
nextPage = hasNextPage ? nextPage + 1 : null;
} catch (error) {
handleError(error, $t('errors.failed_to_load_people'));
}
};
let toggleButtonOptions: Record<ToggleVisibility, { icon: string; label: string }> = $derived({
[ToggleVisibility.HIDE_ALL]: { icon: mdiEyeOff, label: $t('hide_all_people') },
[ToggleVisibility.HIDE_UNNANEMD]: { icon: mdiEyeSettings, label: $t('hide_unnamed_people') },
@@ -103,28 +115,18 @@
let toggleButton = $derived(toggleButtonOptions[getNextVisibility(toggleVisibility)]);
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
<div class="h-full overflow-y-auto">
<div
class="sticky top-0 z-1 flex h-16 w-full items-center justify-between border-b bg-white p-1 md:p-8 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg"
>
<div class="flex items-center">
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={$t('close')}
icon={mdiClose}
onclick={onClose}
/>
<div class="flex items-center gap-2">
<p id={titleId} class="ms-2">{$t('show_and_hide_people')}</p>
<p class="text-sm text-gray-400 dark:text-gray-600">({totalPeopleCount.toLocaleString($locale)})</p>
</div>
</div>
<UserPageLayout title={$t('show_and_hide_people')} description={`(${totalPeopleCount.toLocaleString($locale)})`}>
{#snippet buttons()}
<div class="flex items-center justify-end">
<div class="flex items-center md:me-4">
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={$t('close')}
icon={mdiClose}
onclick={() => goto('/people')}
/>
<IconButton
shape="round"
color="secondary"
@@ -144,10 +146,10 @@
</div>
<Button loading={showLoadingSpinner} onclick={handleSaveVisibility} size="small">{$t('done')}</Button>
</div>
</div>
{/snippet}
<div class="flex flex-wrap gap-1 p-2 pb-8 md:px-8">
<PeopleInfiniteScroll {people} hasNextPage={true} {loadNextPage}>
<PeopleInfiniteScroll {people} hasNextPage={nextPage !== null} {loadNextPage}>
{#snippet children({ person })}
{@const hidden = overrides.get(person.id) ?? person.isHidden}
<button
@@ -175,4 +177,4 @@
{/snippet}
</PeopleInfiniteScroll>
</div>
</div>
</UserPageLayout>
@@ -0,0 +1,13 @@
import { getAllPeople } from '@immich/sdk';
import { authenticate } from '$lib/utils/auth';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const people = await getAllPeople({ withHidden: true });
return {
people,
};
}) satisfies PageLoad;
@@ -1,35 +1,50 @@
import { render } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import type { ComponentProps } from 'svelte';
import { vi } from 'vitest';
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
import { personFactory } from '@test-data/factories/person-factory';
import ManagePeopleVisibilityWrapper from './ManagePeopleVisibility.test-wrapper.svelte';
import ManagePeoplePage from './+page.svelte';
import ManagePeoplePageTestWrapper from './ManagePeopleVisibility.test-wrapper.svelte';
describe('ManagePeopleVisibility component', () => {
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: {} } as never,
};
});
vi.mock('$lib/components/layouts/UserPageLayout.svelte', async () => {
return await import('@test-data/mocks/UserPageLayout.mock.svelte');
});
const getData = (
people: ReturnType<typeof personFactory.build>[],
hasNextPage = false,
): ComponentProps<typeof ManagePeoplePage>['data'] => ({
error: undefined,
meta: { title: 'Manage people visibility' },
asset: undefined,
people: {
people,
total: people.length,
hidden: people.filter((person) => person.isHidden).length,
hasNextPage,
},
});
describe('People manage page', () => {
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
});
it('keeps toggled hidden state when loading more people', async () => {
const onClose = vi.fn();
const onUpdate = vi.fn();
const loadNextPage = vi.fn();
const [personA, personB, personC] = [
personFactory.build({ id: 'a', isHidden: false }),
personFactory.build({ id: 'b', isHidden: false }),
personFactory.build({ id: 'c', isHidden: true }),
];
const { container, rerender } = render(ManagePeopleVisibilityWrapper, {
props: {
people: [personA, personB],
totalPeopleCount: 3,
onClose,
onUpdate,
loadNextPage,
},
});
const { container, rerender } = render(ManagePeoplePageTestWrapper, { data: getData([personA, personB], true) });
const user = userEvent.setup();
let personButtons = container.querySelectorAll('button[aria-pressed]');
@@ -38,13 +53,7 @@ describe('ManagePeopleVisibility component', () => {
await user.click(personButtons[0]);
expect(personButtons[0].getAttribute('aria-pressed')).toBe('true');
await rerender({
people: [personA, personB, personC],
totalPeopleCount: 3,
onClose,
onUpdate,
loadNextPage,
});
await rerender({ data: getData([personA, personB, personC], false) });
personButtons = container.querySelectorAll('button[aria-pressed]');
expect(personButtons).toHaveLength(3);
@@ -53,33 +62,15 @@ describe('ManagePeopleVisibility component', () => {
});
it('shows newly loaded hidden people as hidden', async () => {
const onClose = vi.fn();
const onUpdate = vi.fn();
const loadNextPage = vi.fn();
const [personA, personB, personC] = [
personFactory.build({ id: 'a', isHidden: false }),
personFactory.build({ id: 'b', isHidden: false }),
personFactory.build({ id: 'c', isHidden: true }),
];
const { container, rerender } = render(ManagePeopleVisibilityWrapper, {
props: {
people: [personA, personB],
totalPeopleCount: 3,
onClose,
onUpdate,
loadNextPage,
},
});
const { container, rerender } = render(ManagePeoplePageTestWrapper, { data: getData([personA, personB], true) });
await rerender({
people: [personA, personB, personC],
totalPeopleCount: 3,
onClose,
onUpdate,
loadNextPage,
});
await rerender({ data: getData([personA, personB, personC], false) });
const personButtons = container.querySelectorAll('button[aria-pressed]');
expect(personButtons).toHaveLength(3);
@@ -0,0 +1,15 @@
<script lang="ts">
import { TooltipProvider } from '@immich/ui';
import type { ComponentProps } from 'svelte';
import ManagePeoplePage from './+page.svelte';
interface Props {
data: ComponentProps<typeof ManagePeoplePage>['data'];
}
let { data }: Props = $props();
</script>
<TooltipProvider>
<ManagePeoplePage {data} />
</TooltipProvider>
@@ -184,6 +184,7 @@
personIds: $t('people'),
tagIds: $t('tags'),
originalFileName: $t('file_name_text'),
originalPath: $t('full_path_or_folder'),
description: $t('description'),
queryAssetId: $t('query_asset_id'),
ocr: $t('ocr'),
+1 -1
View File
@@ -5,7 +5,7 @@ import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const sharedAlbums = await getAllAlbums({ shared: true });
const sharedAlbums = await getAllAlbums({ isShared: true });
const partners = await getPartners({ direction: PartnerDirection.SharedWith });
const $t = await getFormatter();
@@ -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>
+87 -1
View File
@@ -13,7 +13,7 @@
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import ServerRestartingModal from '$lib/modals/ServerRestartingModal.svelte';
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import { lang, locale } from '$lib/stores/preferences.store';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
import { closeWebsocketConnection, openWebsocketConnection, websocketStore } from '$lib/stores/websocket';
import { maintenanceShouldRedirect } from '$lib/utils/maintenance';
@@ -35,6 +35,8 @@
toastManager,
TooltipProvider,
} from '@immich/ui';
import { En } from 'media-chrome/lang/en';
import { addTranslation } from 'media-chrome/utils/i18n';
import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
@@ -44,6 +46,38 @@
children?: Snippet;
}
const MediaChromeDefaultKeys = [
'Start airplay',
'Stop airplay',
'Audio',
'Start casting',
'Stop casting',
'Enter picture in picture mode',
'Exit picture in picture mode',
'Seek backward',
'Seek forward',
'audio player',
'seek',
'audio tracks',
'chapter: {chapterName}',
'live',
'start airplay',
'stop airplay',
'start casting',
'stop casting',
'enter picture in picture mode',
'exit picture in picture mode',
'seek to live',
'playing live',
'seek back {seekOffset} seconds',
'seek forward {seekOffset} seconds',
'Encryption Error',
'The media is encrypted and there are no keys to decrypt it.',
] as const satisfies Array<keyof typeof En>;
const MediaChromeDefaults = Object.fromEntries(MediaChromeDefaultKeys.map((key) => [key, key])) as {
[K in (typeof MediaChromeDefaultKeys)[number]]: K;
};
$effect(() => {
setTranslations({
cancel: $t('cancel'),
@@ -73,6 +107,58 @@
save: $t('save'),
supporter: $t('supporter'),
});
addTranslation($lang, {
...MediaChromeDefaults,
Captions: $t('media_chrome.captions'),
'Enable captions': $t('media_chrome.enable_captions'),
'Disable captions': $t('media_chrome.disable_captions'),
'Enter fullscreen mode': $t('media_chrome.enter_fullscreen_mode'),
'Exit fullscreen mode': $t('media_chrome.exit_fullscreen_mode'),
Mute: $t('media_chrome.mute'),
Unmute: $t('media_chrome.unmute'),
Loop: $t('media_chrome.loop'),
Play: $t('play'),
Pause: $t('pause'),
'Playback rate': $t('media_chrome.playback_rate'),
'Playback rate {playbackRate}': $t('media_chrome.playback_rate_value'),
Quality: $t('media_chrome.quality'),
Settings: $t('settings'),
Auto: $t('media_chrome.auto'),
'video player': $t('media_chrome.video_player'),
volume: $t('media_chrome.volume'),
'closed captions': $t('media_chrome.closed_captions'),
'current playback rate': $t('media_chrome.playback_rate_current'),
'playback time': $t('media_chrome.playback_time'),
'media loading': $t('media_chrome.media_loading'),
settings: $t('settings'),
quality: $t('media_chrome.quality'),
play: $t('play'),
pause: $t('pause'),
mute: $t('media_chrome.mute'),
unmute: $t('media_chrome.unmute'),
Off: $t('media_chrome.captions_off'),
'enter fullscreen mode': $t('media_chrome.enter_fullscreen_mode'),
'exit fullscreen mode': $t('media_chrome.exit_fullscreen_mode'),
'Network Error': $t('media_chrome.network_error'),
'Decode Error': $t('media_chrome.decode_error'),
'Source Not Supported': $t('media_chrome.not_supported_error'),
'A network error caused the media download to fail.': $t('media_chrome.network_error_description'),
'A media error caused playback to be aborted. The media could be corrupt or your browser does not support this format.':
$t('media_chrome.media_error_description'),
'An unsupported error occurred. The server or network failed, or your browser does not support this format.': $t(
'media_chrome.unsupported_error_description',
),
hour: $t('hour'),
hours: $t('hours'),
minute: $t('minute'),
minutes: $t('minutes'),
second: $t('media_chrome.second'),
seconds: $t('media_chrome.seconds'),
'{time} remaining': $t('media_chrome.time_value_remaining'),
'{currentTime} of {totalTime}': $t('media_chrome.time_value_of_total_time'),
'video not loaded, unknown time.': $t('media_chrome.video_not_loaded_unknown_time'),
});
});
$effect(() => setLocale($locale));
+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>
@@ -0,0 +1,13 @@
<script lang="ts">
interface Props {
buttons?: import('svelte').Snippet;
children?: import('svelte').Snippet;
}
let { buttons, children }: Props = $props();
</script>
<section>
{@render buttons?.()}
{@render children?.()}
</section>
+6
View File
@@ -35,6 +35,12 @@ const config = {
'chromecast-caf-sender': './node_modules/@types/chromecast-caf-sender/index.d.ts',
},
},
onwarn: (warning, handler) => {
if (warning.code === 'state_referenced_locally') {
return;
}
handler(warning);
},
};
export default config;