mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' into show-in-timeline-toggle
This commit is contained in:
@@ -1 +0,0 @@
|
||||
24.15.0
|
||||
+7
-1
@@ -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" },
|
||||
]
|
||||
|
||||
+2
-5
@@ -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 .",
|
||||
@@ -27,7 +27,7 @@
|
||||
"@formatjs/icu-messageformat-parser": "^3.0.0",
|
||||
"@immich/justified-layout-wasm": "^0.4.3",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@immich/ui": "^0.76.0",
|
||||
"@immich/ui": "^0.77.0",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
@@ -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';
|
||||
@@ -14,6 +15,7 @@
|
||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { getAssetActions } from '$lib/services/asset.service';
|
||||
import { faceManager } from '$lib/stores/face.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
@@ -48,6 +50,7 @@
|
||||
import OcrButton from './OcrButton.svelte';
|
||||
import PhotoViewer from './PhotoViewer.svelte';
|
||||
import SlideshowBar from './SlideshowBar.svelte';
|
||||
import SlideshowMetadataOverlay from './SlideshowMetadataOverlay.svelte';
|
||||
import VideoViewer from './VideoWrapperViewer.svelte';
|
||||
|
||||
export type AssetCursor = {
|
||||
@@ -246,6 +249,22 @@
|
||||
}, $t('error_while_navigating'));
|
||||
};
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -315,6 +334,7 @@
|
||||
case AssetAction.SET_PERSON_FEATURED_PHOTO: {
|
||||
const assetInfo = await getAssetInfo({ id: asset.id });
|
||||
cursor.current = { ...asset, people: assetInfo.people };
|
||||
eventManager.emit('AssetUpdate', cursor.current);
|
||||
break;
|
||||
}
|
||||
case AssetAction.RATING: {
|
||||
@@ -358,11 +378,14 @@
|
||||
const refresh = async () => {
|
||||
await refreshStack();
|
||||
ocrManager.clear();
|
||||
faceManager.clear();
|
||||
if (!sharedLink) {
|
||||
if (previewStackedAsset) {
|
||||
await ocrManager.getAssetOcr(previewStackedAsset.id);
|
||||
await faceManager.getAssetFaces(previewStackedAsset.id);
|
||||
}
|
||||
await ocrManager.getAssetOcr(asset.id);
|
||||
await faceManager.getAssetFaces(asset.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -459,6 +482,10 @@
|
||||
id="immich-asset-viewer"
|
||||
class="fixed inset-s-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
||||
use:focusTrap
|
||||
use:shortcuts={[
|
||||
{ shortcut: { key: 'ArrowUp' }, onShortcut: () => navigateStack('previous') },
|
||||
{ shortcut: { key: 'ArrowDown' }, onShortcut: () => navigateStack('next') },
|
||||
]}
|
||||
bind:this={assetViewerHtmlElement}
|
||||
>
|
||||
<!-- Top navigation bar -->
|
||||
@@ -567,6 +594,10 @@
|
||||
<OcrButton />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $slideshowState !== SlideshowState.None}
|
||||
<SlideshowMetadataOverlay {asset} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !assetViewerManager.isFaceEditMode && nextAsset}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { faceManager } from '$lib/stores/face.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { type AssetResponseDto } from '@immich/sdk';
|
||||
@@ -19,8 +20,7 @@
|
||||
|
||||
const { asset, isOwner, previousRoute }: Props = $props();
|
||||
|
||||
const unassignedFaces = $derived(asset.unassignedFaces || []);
|
||||
const people = $derived(asset.people || []);
|
||||
const people = $derived(Array.from(faceManager.people));
|
||||
const visiblePeople = $derived(
|
||||
people
|
||||
.filter((p) => assetViewerManager.isShowingHiddenPeople || !p.isHidden)
|
||||
@@ -82,7 +82,7 @@
|
||||
onclick={() => assetViewerManager.toggleFaceEditMode()}
|
||||
/>
|
||||
|
||||
{#if people.length > 0 || unassignedFaces.length > 0}
|
||||
{#if faceManager.data.length > 0}
|
||||
<IconButton
|
||||
aria-label={$t('edit_people')}
|
||||
icon={mdiPencil}
|
||||
@@ -98,15 +98,14 @@
|
||||
|
||||
<div class="mt-2 grid {visiblePeople.length <= 6 ? 'grid-cols-3 gap-3' : 'grid-cols-4 gap-2'}">
|
||||
{#each visiblePeople as person (person.id)}
|
||||
{@const isHighlighted = person.faces.some((f) =>
|
||||
assetViewerManager.highlightedFaces.some((b) => b.id === f.id),
|
||||
)}
|
||||
{@const personFaces = faceManager.facesByPersonId.get(person.id) ?? []}
|
||||
{@const isHighlighted = personFaces.some((f) => assetViewerManager.highlightedFaces.some((b) => b.id === f.id))}
|
||||
<a
|
||||
class="group outline-none"
|
||||
href={Route.viewPerson(person, { previousRoute })}
|
||||
onfocus={() => assetViewerManager.setHighlightedFaces(person.faces)}
|
||||
onfocus={() => assetViewerManager.setHighlightedFaces(personFaces)}
|
||||
onblur={() => assetViewerManager.clearHighlightedFaces()}
|
||||
onpointerenter={() => assetViewerManager.setHighlightedFaces(person.faces)}
|
||||
onpointerenter={() => assetViewerManager.setHighlightedFaces(personFaces)}
|
||||
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
|
||||
>
|
||||
<ImageThumbnail
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||
import { assetViewerManager, type Faces } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { faceManager } from '$lib/stores/face.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
@@ -157,13 +158,14 @@
|
||||
const faceToNameMap = $derived.by(() => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const map = new Map<Faces, string>();
|
||||
for (const person of asset.people ?? []) {
|
||||
if (person.isHidden && !assetViewerManager.isShowingHiddenPeople) {
|
||||
for (const face of faceManager.data) {
|
||||
if (!face.person) {
|
||||
continue;
|
||||
}
|
||||
for (const face of person.faces ?? []) {
|
||||
map.set(face, person.name);
|
||||
if (face.person.isHidden && !assetViewerManager.isShowingHiddenPeople) {
|
||||
continue;
|
||||
}
|
||||
map.set(face, face.person.name);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { SlideshowMetadataOverlayMode, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { Text } from '@immich/ui';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
type Props = {
|
||||
asset: AssetResponseDto;
|
||||
};
|
||||
|
||||
const { asset }: Props = $props();
|
||||
|
||||
const { slideshowShowMetadataOverlay, slideshowMetadataOverlayMode } = slideshowStore;
|
||||
|
||||
const opacity = 0.7;
|
||||
|
||||
const description = $derived(asset.exifInfo?.description?.trim() || '');
|
||||
|
||||
const dateTime = $derived(
|
||||
asset.exifInfo?.timeZone && asset.exifInfo?.dateTimeOriginal
|
||||
? fromISODateTime(asset.exifInfo.dateTimeOriginal, asset.exifInfo.timeZone)
|
||||
: fromISODateTimeUTC(asset.localDateTime),
|
||||
);
|
||||
const dateString = $derived(dateTime.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY));
|
||||
|
||||
const locationString = $derived(
|
||||
[asset.exifInfo?.city, asset.exifInfo?.state, asset.exifInfo?.country].filter(Boolean).join(', '),
|
||||
);
|
||||
|
||||
const shouldShow = $derived.by(() => {
|
||||
if (!$slideshowShowMetadataOverlay) {
|
||||
return false;
|
||||
}
|
||||
if ($slideshowMetadataOverlayMode === SlideshowMetadataOverlayMode.DescriptionOnly) {
|
||||
return !!description;
|
||||
}
|
||||
return !!description || !!dateString || !!locationString;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if shouldShow}
|
||||
<div class="absolute inset-x-0 bottom-0 z-10">
|
||||
<div
|
||||
class="w-full px-6 py-4"
|
||||
style="background: linear-gradient(to top, rgba(0, 0, 0, {opacity}) 0%, rgba(0, 0, 0, {opacity * 0.8}) 100%);"
|
||||
>
|
||||
<div class="flex flex-col gap-2 text-white">
|
||||
{#if description}
|
||||
<Text fontWeight="medium" class="leading-relaxed wrap-break-word whitespace-pre-wrap">{description}</Text>
|
||||
{/if}
|
||||
{#if $slideshowMetadataOverlayMode !== SlideshowMetadataOverlayMode.DescriptionOnly}
|
||||
<div class="flex flex-col gap-1 text-sm opacity-90">
|
||||
{#if dateString}
|
||||
<Text>{dateString}</Text>
|
||||
{/if}
|
||||
{#if locationString}
|
||||
<Text>{locationString}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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}
|
||||
@@ -194,14 +195,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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { OnAction } from '$lib/components/asset-viewer/actions/action';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
@@ -6,7 +7,6 @@
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { mdiFaceManProfile } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAction } from './action';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
|
||||
@@ -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?.()}
|
||||
@@ -11,6 +11,7 @@
|
||||
options: RenderedOption[];
|
||||
selectedOption: RenderedOption;
|
||||
isEdited?: boolean;
|
||||
disabled?: boolean;
|
||||
onToggle: (option: RenderedOption) => void;
|
||||
children?: Snippet;
|
||||
}
|
||||
@@ -21,12 +22,13 @@
|
||||
options,
|
||||
selectedOption = $bindable(),
|
||||
isEdited = false,
|
||||
disabled = false,
|
||||
onToggle,
|
||||
children,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex place-items-center justify-between">
|
||||
<div class="flex place-items-center justify-between" class:pointer-events-none={disabled} class:opacity-50={disabled}>
|
||||
<div>
|
||||
<div class="flex h-6.5 place-items-center gap-1">
|
||||
<label class="text-sm font-medium" for={title}>
|
||||
|
||||
@@ -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)' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getAssetInfo, getAssetOcr } from '@immich/sdk';
|
||||
import { getAssetInfo, getAssetOcr, getFaces } from '@immich/sdk';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
|
||||
@@ -38,6 +38,7 @@ class AsyncCache<K, V> {
|
||||
class AssetCacheManager {
|
||||
#assetCache = new AsyncCache(getAssetInfo);
|
||||
#ocrCache = new AsyncCache(getAssetOcr);
|
||||
#faceCache = new AsyncCache(getFaces);
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
@@ -58,10 +59,15 @@ class AssetCacheManager {
|
||||
return this.#ocrCache.getOrFetch({ id }, true);
|
||||
}
|
||||
|
||||
async getAssetFaces(id: string) {
|
||||
return this.#faceCache.getOrFetch({ id }, true);
|
||||
}
|
||||
|
||||
invalidateAsset(id: string) {
|
||||
const { key, slug } = authManager.params;
|
||||
this.#assetCache.clearKey({ id, key, slug });
|
||||
this.#ocrCache.clearKey({ id });
|
||||
this.#faceCache.clearKey({ id });
|
||||
}
|
||||
|
||||
clearAssetCache() {
|
||||
@@ -72,9 +78,14 @@ class AssetCacheManager {
|
||||
this.#ocrCache.clear();
|
||||
}
|
||||
|
||||
clearFaceCache() {
|
||||
this.#faceCache.clear();
|
||||
}
|
||||
|
||||
invalidate() {
|
||||
this.clearAssetCache();
|
||||
this.clearOcrCache();
|
||||
this.clearFaceCache();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
import { AssetOrder, AssetOrderBy } from '@immich/sdk';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
|
||||
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
|
||||
import { plainDateTimeCompare } from '$lib/utils/timeline-util';
|
||||
import { getOrderingDate, plainDateTimeCompare } from '$lib/utils/timeline-util';
|
||||
import type { TimelineMonth } from './timeline-month.svelte';
|
||||
import type { Direction, MoveAsset, TimelineAsset } from './types';
|
||||
import { ViewerAsset } from './viewer-asset.svelte';
|
||||
@@ -12,6 +12,7 @@ export class TimelineDay {
|
||||
readonly index: number;
|
||||
readonly groupTitle: string;
|
||||
readonly day: number;
|
||||
readonly orderBy: AssetOrderBy;
|
||||
viewerAssets: ViewerAsset[] = $state([]);
|
||||
|
||||
height = $state(0);
|
||||
@@ -24,11 +25,12 @@ export class TimelineDay {
|
||||
#col = $state(0);
|
||||
#deferredLayout = false;
|
||||
|
||||
constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string) {
|
||||
constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) {
|
||||
this.index = index;
|
||||
this.timelineMonth = timelineMonth;
|
||||
this.day = day;
|
||||
this.groupTitle = groupTitle;
|
||||
this.orderBy = orderBy;
|
||||
}
|
||||
|
||||
get top() {
|
||||
@@ -115,10 +117,10 @@ export class TimelineDay {
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldTime = { ...asset.localDateTime };
|
||||
const oldTime = { ...getOrderingDate(asset, this.orderBy) };
|
||||
const callbackResult = callback(asset);
|
||||
let remove = (callbackResult as { remove?: boolean } | undefined)?.remove ?? false;
|
||||
const newTime = asset.localDateTime;
|
||||
const newTime = getOrderingDate(asset, this.orderBy);
|
||||
if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
|
||||
const { year, month, day } = newTime;
|
||||
remove = true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AssetOrder, getAssetInfo, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
|
||||
import { AssetOrder, getAssetInfo, getTimeBuckets, AssetOrderBy, type AssetResponseDto } from '@immich/sdk';
|
||||
import { clamp, isEqual } from 'lodash-es';
|
||||
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
|
||||
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
@@ -20,6 +20,7 @@ import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websoc
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import { PersistedLocalStorage } from '$lib/utils/persisted';
|
||||
import {
|
||||
getOrderingDate,
|
||||
isAssetResponseDto,
|
||||
setDifference,
|
||||
toTimelineAsset,
|
||||
@@ -252,6 +253,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
timeBucket.count,
|
||||
false,
|
||||
this.#options.order,
|
||||
this.#options.orderBy,
|
||||
);
|
||||
});
|
||||
this.albumAssets.clear();
|
||||
@@ -393,7 +395,10 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
return;
|
||||
}
|
||||
|
||||
timelineMonth = await this.#loadTimelineMonthAtTime(timelineAsset.localDateTime, { cancelable: false });
|
||||
timelineMonth = await this.#loadTimelineMonthAtTime(
|
||||
getOrderingDate(timelineAsset, this.#options.orderBy || AssetOrderBy.TakenAt),
|
||||
{ cancelable: false },
|
||||
);
|
||||
if (timelineMonth?.findAssetById({ id })) {
|
||||
return timelineMonth;
|
||||
}
|
||||
@@ -462,10 +467,11 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
}
|
||||
|
||||
protected upsertSegmentForAsset(asset: TimelineAsset) {
|
||||
let month = getTimelineMonthByDate(this, asset.localDateTime);
|
||||
const dateTime = getOrderingDate(asset, this.#options.orderBy || AssetOrderBy.TakenAt);
|
||||
let month = getTimelineMonthByDate(this, dateTime);
|
||||
|
||||
if (!month) {
|
||||
month = new TimelineMonth(this, asset.localDateTime, 1, true, this.#options.order);
|
||||
month = new TimelineMonth(this, dateTime, 1, true, this.#options.order, this.#options.orderBy);
|
||||
this.months.push(month);
|
||||
}
|
||||
return month;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
import { AssetOrder, AssetOrderBy, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -15,10 +15,12 @@ import {
|
||||
fromTimelinePlainDate,
|
||||
fromTimelinePlainDateTime,
|
||||
fromTimelinePlainYearMonth,
|
||||
fromISODateTimeUTC,
|
||||
getTimes,
|
||||
setDifference,
|
||||
type TimelineDateTime,
|
||||
type TimelineYearMonth,
|
||||
getOrderingDate,
|
||||
} from '$lib/utils/timeline-util';
|
||||
import { GroupInsertionCache } from './group-insertion-cache.svelte';
|
||||
import { TimelineDay } from './timeline-day.svelte';
|
||||
@@ -37,6 +39,7 @@ export class TimelineMonth {
|
||||
|
||||
#initialCount: number = 0;
|
||||
#sortOrder: AssetOrder = AssetOrder.Desc;
|
||||
#orderBy: AssetOrderBy = AssetOrderBy.TakenAt;
|
||||
percent: number = $state(0);
|
||||
|
||||
assetsCount: number = $derived(
|
||||
@@ -56,10 +59,12 @@ export class TimelineMonth {
|
||||
initialCount: number,
|
||||
loaded: boolean,
|
||||
order: AssetOrder = AssetOrder.Desc,
|
||||
orderBy: AssetOrderBy = AssetOrderBy.TakenAt,
|
||||
) {
|
||||
this.timelineManager = timelineManager;
|
||||
this.#initialCount = initialCount;
|
||||
this.#sortOrder = order;
|
||||
this.#orderBy = orderBy;
|
||||
|
||||
this.yearMonth = { year: yearMonth.year, month: yearMonth.month };
|
||||
this.title = formatTimelineMonthTitle(fromTimelinePlainYearMonth(yearMonth));
|
||||
@@ -185,6 +190,7 @@ export class TimelineMonth {
|
||||
isVideo: !bucketAssets.isImage[i],
|
||||
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
|
||||
localDateTime,
|
||||
createdAt: fromISODateTimeUTC(bucketAssets.createdAt[i]).setZone('local'),
|
||||
fileCreatedAt,
|
||||
ownerId: bucketAssets.ownerId[i],
|
||||
projectionType: bucketAssets.projectionType[i],
|
||||
@@ -229,22 +235,22 @@ export class TimelineMonth {
|
||||
}
|
||||
|
||||
addTimelineAsset(timelineAsset: TimelineAsset, addContext: GroupInsertionCache) {
|
||||
const { localDateTime } = timelineAsset;
|
||||
const dateTime = getOrderingDate(timelineAsset, this.#orderBy);
|
||||
|
||||
const { year, month } = this.yearMonth;
|
||||
if (month !== localDateTime.month || year !== localDateTime.year) {
|
||||
if (month !== dateTime.month || year !== dateTime.year) {
|
||||
addContext.unprocessedAssets.push(timelineAsset);
|
||||
return;
|
||||
}
|
||||
|
||||
let timelineDay = addContext.getTimelineDay(localDateTime) || this.findTimelineDayByDay(localDateTime.day);
|
||||
let timelineDay = addContext.getTimelineDay(dateTime) || this.findTimelineDayByDay(dateTime.day);
|
||||
if (timelineDay) {
|
||||
addContext.setTimelineDay(timelineDay, localDateTime);
|
||||
addContext.setTimelineDay(timelineDay, dateTime);
|
||||
} else {
|
||||
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
|
||||
timelineDay = new TimelineDay(this, this.timelineDays.length, localDateTime.day, groupTitle);
|
||||
const groupTitle = formatGroupTitle(fromTimelinePlainDate(dateTime));
|
||||
timelineDay = new TimelineDay(this, this.timelineDays.length, dateTime.day, groupTitle, this.#orderBy);
|
||||
this.timelineDays.push(timelineDay);
|
||||
addContext.setTimelineDay(timelineDay, localDateTime);
|
||||
addContext.setTimelineDay(timelineDay, dateTime);
|
||||
addContext.newTimelineDays.add(timelineDay);
|
||||
}
|
||||
|
||||
@@ -372,7 +378,7 @@ export class TimelineMonth {
|
||||
let closest = undefined;
|
||||
let smallestDiff = Infinity;
|
||||
for (const current of this.assetsIterator()) {
|
||||
const currentAssetDate = fromTimelinePlainDateTime(current.localDateTime);
|
||||
const currentAssetDate = fromTimelinePlainDateTime(getOrderingDate(current, this.#orderBy));
|
||||
const diff = Math.abs(targetDate.diff(currentAssetDate).as('milliseconds'));
|
||||
if (diff < smallestDiff) {
|
||||
smallestDiff = diff;
|
||||
|
||||
@@ -22,6 +22,7 @@ export type TimelineAsset = {
|
||||
ratio: number;
|
||||
thumbhash: string | null;
|
||||
localDateTime: TimelineDateTime;
|
||||
createdAt: TimelineDateTime;
|
||||
fileCreatedAt: TimelineDateTime;
|
||||
visibility: AssetVisibility;
|
||||
isFavorite: boolean;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,7 +11,13 @@
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import SettingDropdown from '../components/shared-components/settings/SettingDropdown.svelte';
|
||||
import { SlideshowLook, SlideshowNavigation, SlideshowState, slideshowStore } from '../stores/slideshow.store';
|
||||
import {
|
||||
SlideshowLook,
|
||||
SlideshowMetadataOverlayMode,
|
||||
SlideshowNavigation,
|
||||
SlideshowState,
|
||||
slideshowStore,
|
||||
} from '../stores/slideshow.store';
|
||||
|
||||
const {
|
||||
slideshowDelay,
|
||||
@@ -22,6 +28,8 @@
|
||||
slideshowAutoplay,
|
||||
slideshowRepeat,
|
||||
slideshowState,
|
||||
slideshowShowMetadataOverlay,
|
||||
slideshowMetadataOverlayMode,
|
||||
} = slideshowStore;
|
||||
|
||||
type Props = {
|
||||
@@ -38,6 +46,8 @@
|
||||
let tempSlideshowTransition = $state($slideshowTransition);
|
||||
let tempSlideshowAutoplay = $state($slideshowAutoplay);
|
||||
let tempSlideshowRepeat = $state($slideshowRepeat);
|
||||
let tempSlideshowShowMetadataOverlay = $state($slideshowShowMetadataOverlay);
|
||||
let tempSlideshowMetadataOverlayMode = $state($slideshowMetadataOverlayMode);
|
||||
|
||||
const navigationOptions: Record<SlideshowNavigation, RenderedOption> = {
|
||||
[SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') },
|
||||
@@ -51,7 +61,16 @@
|
||||
[SlideshowLook.BlurredBackground]: { icon: mdiPanorama, title: $t('blurred_background') },
|
||||
};
|
||||
|
||||
const handleToggle = <Type extends SlideshowNavigation | SlideshowLook>(
|
||||
const metadataOverlayModeOptions: Record<SlideshowMetadataOverlayMode, RenderedOption> = {
|
||||
[SlideshowMetadataOverlayMode.DescriptionOnly]: {
|
||||
title: $t('slideshow_metadata_overlay_mode_description_only'),
|
||||
},
|
||||
[SlideshowMetadataOverlayMode.Full]: {
|
||||
title: $t('slideshow_metadata_overlay_mode_full'),
|
||||
},
|
||||
};
|
||||
|
||||
const handleToggle = <Type extends SlideshowNavigation | SlideshowLook | SlideshowMetadataOverlayMode>(
|
||||
record: RenderedOption,
|
||||
options: Record<Type, RenderedOption>,
|
||||
): undefined | Type => {
|
||||
@@ -71,6 +90,8 @@
|
||||
$slideshowAutoplay = tempSlideshowAutoplay;
|
||||
$slideshowRepeat = tempSlideshowRepeat;
|
||||
$slideshowState = SlideshowState.PlaySlideshow;
|
||||
$slideshowShowMetadataOverlay = tempSlideshowShowMetadataOverlay;
|
||||
$slideshowMetadataOverlayMode = tempSlideshowMetadataOverlayMode;
|
||||
onClose();
|
||||
};
|
||||
</script>
|
||||
@@ -111,6 +132,21 @@
|
||||
<Switch bind:checked={tempSlideshowRepeat} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('show_slideshow_metadata_overlay')}>
|
||||
<Switch bind:checked={tempSlideshowShowMetadataOverlay} />
|
||||
</Field>
|
||||
|
||||
<SettingDropdown
|
||||
title={$t('slideshow_metadata_overlay_mode')}
|
||||
options={Object.values(metadataOverlayModeOptions)}
|
||||
selectedOption={metadataOverlayModeOptions[tempSlideshowMetadataOverlayMode]}
|
||||
disabled={!tempSlideshowShowMetadataOverlay}
|
||||
onToggle={(option) => {
|
||||
tempSlideshowMetadataOverlayMode =
|
||||
handleToggle(option, metadataOverlayModeOptions) || tempSlideshowMetadataOverlayMode;
|
||||
}}
|
||||
/>
|
||||
|
||||
<Field label={$t('duration')}>
|
||||
<NumberInput min={1} bind:value={tempSlideshowDelay} />
|
||||
<HelperText>{$t('admin.slideshow_duration_description')}</HelperText>
|
||||
|
||||
@@ -105,6 +105,7 @@ export const Route = {
|
||||
locked: () => '/locked',
|
||||
trash: () => '/trash',
|
||||
viewTrashedAsset: ({ id }: { id: string }) => `/trash/photos/${id}`,
|
||||
recentlyAdded: () => '/recently-added',
|
||||
|
||||
// search
|
||||
search: (dto?: MetadataSearchDto | SmartSearchDto) => {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { AssetFaceResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
||||
import type { Faces } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
|
||||
class FaceManager {
|
||||
#data = $state<AssetFaceResponseDto[]>([]);
|
||||
#faceLoader = new CancellableTask();
|
||||
#cleared = false;
|
||||
|
||||
readonly faceNames = $derived.by(() => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const map = new Map<Faces, string>();
|
||||
|
||||
for (const face of this.data) {
|
||||
if (!face.person) {
|
||||
continue;
|
||||
}
|
||||
map.set(face, face.person.name);
|
||||
}
|
||||
|
||||
return map;
|
||||
});
|
||||
|
||||
readonly people = $derived.by(() => {
|
||||
const people = new SvelteSet<PersonResponseDto>();
|
||||
|
||||
for (const face of this.data) {
|
||||
if (face.person) {
|
||||
people.add(face.person);
|
||||
}
|
||||
}
|
||||
|
||||
return people;
|
||||
});
|
||||
|
||||
readonly facesByPersonId = $derived.by(() => {
|
||||
const map = new SvelteMap<string, AssetFaceResponseDto[]>();
|
||||
for (const face of faceManager.data) {
|
||||
if (!face.person) {
|
||||
continue;
|
||||
}
|
||||
const existing = map.get(face.person.id);
|
||||
if (existing) {
|
||||
existing.push(face);
|
||||
} else {
|
||||
map.set(face.person.id, [face]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
get data() {
|
||||
return this.#data;
|
||||
}
|
||||
|
||||
async getAssetFaces(id: string) {
|
||||
if (this.#cleared) {
|
||||
await this.#faceLoader.reset();
|
||||
this.#cleared = false;
|
||||
}
|
||||
await this.#faceLoader.execute(async () => {
|
||||
this.#data = await assetCacheManager.getAssetFaces(id);
|
||||
}, false);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.#cleared = true;
|
||||
this.#data = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const faceManager = new FaceManager();
|
||||
@@ -7,6 +7,7 @@ import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||
vi.mock('@immich/sdk', () => ({
|
||||
getAssetInfo: vi.fn(),
|
||||
getAssetOcr: vi.fn(),
|
||||
getFaces: vi.fn(),
|
||||
}));
|
||||
|
||||
const createMockOcrData = (overrides?: Partial<OcrBoundingBox>): OcrBoundingBox[] => [
|
||||
|
||||
@@ -19,6 +19,11 @@ export enum SlideshowLook {
|
||||
BlurredBackground = 'blurred-background',
|
||||
}
|
||||
|
||||
export enum SlideshowMetadataOverlayMode {
|
||||
DescriptionOnly = 'description-only',
|
||||
Full = 'full',
|
||||
}
|
||||
|
||||
export const slideshowLookCssMapping: Record<SlideshowLook, string> = {
|
||||
[SlideshowLook.Contain]: 'object-contain',
|
||||
[SlideshowLook.Cover]: 'object-cover',
|
||||
@@ -41,6 +46,11 @@ function createSlideshowStore() {
|
||||
const slideshowTransition = persisted<boolean>('slideshow-transition', true);
|
||||
const slideshowAutoplay = persisted<boolean>('slideshow-autoplay', true, {});
|
||||
const slideshowRepeat = persisted<boolean>('slideshow-repeat', false);
|
||||
const slideshowShowMetadataOverlay = persisted<boolean>('slideshow-show-metadata-overlay', false);
|
||||
const slideshowMetadataOverlayMode = persisted<SlideshowMetadataOverlayMode>(
|
||||
'slideshow-metadata-overlay-mode',
|
||||
SlideshowMetadataOverlayMode.Full,
|
||||
);
|
||||
|
||||
return {
|
||||
restartProgress: {
|
||||
@@ -73,6 +83,8 @@ function createSlideshowStore() {
|
||||
slideshowTransition,
|
||||
slideshowAutoplay,
|
||||
slideshowRepeat,
|
||||
slideshowShowMetadataOverlay,
|
||||
slideshowMetadataOverlayMode,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,18 @@ export function getServerErrorMessage(error: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(data?.errors) && data.errors.length > 0) {
|
||||
const details = data.errors
|
||||
.map(({ path, message }) => {
|
||||
const field = path
|
||||
.map((segment, i) => (typeof segment === 'number' ? `[${segment}]` : i === 0 ? segment : `.${segment}`))
|
||||
.join('');
|
||||
return field ? `${field}: ${message}` : message;
|
||||
})
|
||||
.join(', ');
|
||||
return `${data.message}: ${details}`;
|
||||
}
|
||||
|
||||
return data?.message || error.message;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,15 @@ describe('getAltText', () => {
|
||||
second: testDate.getUTCSeconds(),
|
||||
millisecond: testDate.getUTCMilliseconds(),
|
||||
},
|
||||
createdAt: {
|
||||
year: testDate.getUTCFullYear(),
|
||||
month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based
|
||||
day: testDate.getUTCDate(),
|
||||
hour: testDate.getUTCHours(),
|
||||
minute: testDate.getUTCMinutes(),
|
||||
second: testDate.getUTCSeconds(),
|
||||
millisecond: testDate.getUTCMilliseconds(),
|
||||
},
|
||||
localDateTime: {
|
||||
year: testDate.getUTCFullYear(),
|
||||
month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based
|
||||
|
||||
@@ -7,6 +7,9 @@ describe('formatGroupTitle', () => {
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers();
|
||||
process.env.TZ = 'UTC';
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.setSystemTime(new Date('2024-07-27T12:00:00Z'));
|
||||
});
|
||||
|
||||
@@ -31,6 +34,13 @@ describe('formatGroupTitle', () => {
|
||||
expect(formatGroupTitle(date)).toBe('hier');
|
||||
});
|
||||
|
||||
it('formats yesterday across month boundaries', () => {
|
||||
vi.setSystemTime(new Date('2024-05-01T12:00:00Z'));
|
||||
const date = parseUtcDate('2024-04-30T23:59:59Z');
|
||||
locale.set('en');
|
||||
expect(formatGroupTitle(date)).toBe('yesterday');
|
||||
});
|
||||
|
||||
it('formats last week', () => {
|
||||
const date = parseUtcDate('2024-07-21T00:00:00Z');
|
||||
locale.set('en');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||
import { AssetTypeEnum, AssetOrderBy, type AssetResponseDto } from '@immich/sdk';
|
||||
import { DateTime, type LocaleOptions } from 'luxon';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -128,7 +128,7 @@ export function formatGroupTitle(_date: DateTime): string {
|
||||
|
||||
// Yesterday
|
||||
if (today.minus({ days: 1 }).hasSame(date, 'day')) {
|
||||
return date.toRelativeCalendar({ locale: get(locale) });
|
||||
return date.toRelativeCalendar({ locale: get(locale), unit: 'days' });
|
||||
}
|
||||
|
||||
// Last week
|
||||
@@ -166,6 +166,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
|
||||
|
||||
const localDateTime = fromISODateTimeUTCToObject(assetResponse.localDateTime);
|
||||
const fileCreatedAt = fromISODateTimeToObject(assetResponse.fileCreatedAt, assetResponse.exifInfo?.timeZone ?? 'UTC');
|
||||
const createdAt = fromISODateTimeUTCToObject(assetResponse.createdAt);
|
||||
|
||||
return {
|
||||
id: assetResponse.id,
|
||||
@@ -174,6 +175,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
|
||||
ratio,
|
||||
thumbhash: assetResponse.thumbhash,
|
||||
localDateTime,
|
||||
createdAt,
|
||||
fileCreatedAt,
|
||||
isFavorite: assetResponse.isFavorite,
|
||||
visibility: assetResponse.visibility,
|
||||
@@ -236,3 +238,6 @@ export function setDifference<T>(setA: Set<T>, setB: Set<T>): SvelteSet<T> {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const getOrderingDate = (asset: TimelineAsset, order: AssetOrderBy) =>
|
||||
order === AssetOrderBy.CreatedAt ? asset.createdAt : asset.localDateTime;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+33
-30
@@ -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
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
import { mdiHeart } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
@@ -24,6 +26,9 @@
|
||||
};
|
||||
|
||||
let places = $derived(getFieldItems(data.items, 'exifInfo.city'));
|
||||
let recents = $derived(
|
||||
getFieldItems(data.items, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()),
|
||||
);
|
||||
let people = $state(data.response.people);
|
||||
|
||||
let hasPeople = $derived(data.response.total > 0);
|
||||
@@ -107,7 +112,31 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !hasPeople && places.length === 0}
|
||||
{#if recents.length > 0}
|
||||
<div class="mt-2 mb-6">
|
||||
<div class="flex justify-between">
|
||||
<p class="mb-4 font-medium dark:text-immich-dark-fg">{$t('recently_added')}</p>
|
||||
<a
|
||||
href={Route.recentlyAdded()}
|
||||
class="pe-4 text-sm font-medium hover:text-immich-primary dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
|
||||
draggable="false">{$t('view_all')}</a
|
||||
>
|
||||
</div>
|
||||
<div class="flex h-24 flex-wrap gap-x-1 overflow-hidden md:h-42">
|
||||
{#each recents as item (item.data.id)}
|
||||
<a class="relative h-full flex-auto" href={Route.viewAsset({ id: item.data.id })} draggable="false">
|
||||
<img
|
||||
src={getAssetMediaUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
|
||||
alt={$getAltText(toTimelineAsset(item.data))}
|
||||
class="size-full min-w-max rounded-xl object-cover"
|
||||
/>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !hasPeople && places.length === 0 && recents.length === 0}
|
||||
<EmptyPlaceholder text={$t('no_explore_results_message')} class="mx-auto mt-10" />
|
||||
{/if}
|
||||
</UserPageLayout>
|
||||
|
||||
@@ -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>
|
||||
+3
-6
@@ -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();
|
||||
|
||||
+37
-35
@@ -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;
|
||||
+33
-42
@@ -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>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
||||
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
|
||||
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
|
||||
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
|
||||
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
|
||||
import ChangeLocation from '$lib/components/timeline/actions/ChangeLocationAction.svelte';
|
||||
import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte';
|
||||
import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
|
||||
import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
|
||||
import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte';
|
||||
import LinkLivePhotoAction from '$lib/components/timeline/actions/LinkLivePhotoAction.svelte';
|
||||
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
|
||||
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
|
||||
import StackAction from '$lib/components/timeline/actions/StackAction.svelte';
|
||||
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
|
||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { getAssetBulkActions } from '$lib/services/asset.service';
|
||||
import {
|
||||
updateStackedAssetInTimeline,
|
||||
updateUnstackedAssetInTimeline,
|
||||
type OnLink,
|
||||
type OnUnlink,
|
||||
} from '$lib/utils/actions';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { AssetVisibility, AssetOrderBy } from '@immich/sdk';
|
||||
import { ActionButton, CommandPaletteDefaultProvider } from '@immich/ui';
|
||||
import { mdiDotsVertical } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
type Props = {
|
||||
data: PageData;
|
||||
};
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
||||
const options = {
|
||||
visibility: AssetVisibility.Timeline,
|
||||
withStacked: true,
|
||||
withPartners: true,
|
||||
orderBy: AssetOrderBy.CreatedAt,
|
||||
};
|
||||
|
||||
let selectedAssets = $derived(assetMultiSelectManager.assets);
|
||||
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
|
||||
let isLinkActionAvailable = $derived.by(() => {
|
||||
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
|
||||
const isLivePhotoCandidate =
|
||||
selectedAssets.length === 2 &&
|
||||
selectedAssets.some((asset) => asset.isImage) &&
|
||||
selectedAssets.some((asset) => asset.isVideo);
|
||||
|
||||
return assetMultiSelectManager.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
|
||||
});
|
||||
|
||||
const handleEscape = () => {
|
||||
if (assetViewerManager.isViewing) {
|
||||
return;
|
||||
}
|
||||
if (assetMultiSelectManager.selectionActive) {
|
||||
assetMultiSelectManager.clear();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleLink: OnLink = ({ still, motion }) => {
|
||||
timelineManager.removeAssets([motion.id]);
|
||||
timelineManager.upsertAssets([still]);
|
||||
};
|
||||
|
||||
const handleUnlink: OnUnlink = ({ still, motion }) => {
|
||||
timelineManager.upsertAssets([motion]);
|
||||
timelineManager.upsertAssets([still]);
|
||||
};
|
||||
|
||||
const handleSetVisibility = (assetIds: string[]) => {
|
||||
timelineManager.removeAssets(assetIds);
|
||||
assetMultiSelectManager.clear();
|
||||
};
|
||||
</script>
|
||||
|
||||
<UserPageLayout hideNavbar={assetMultiSelectManager.selectionActive} title={data.meta.title} scrollbar={false}>
|
||||
<Timeline
|
||||
enableRouting={true}
|
||||
bind:timelineManager
|
||||
{options}
|
||||
assetInteraction={assetMultiSelectManager}
|
||||
removeAction={AssetAction.ARCHIVE}
|
||||
onEscape={handleEscape}
|
||||
withStacked
|
||||
>
|
||||
{#snippet empty()}
|
||||
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mx-auto mt-10" />
|
||||
{/snippet}
|
||||
</Timeline>
|
||||
</UserPageLayout>
|
||||
|
||||
{#if assetMultiSelectManager.selectionActive}
|
||||
<AssetSelectControlBar>
|
||||
{@const Actions = getAssetBulkActions($t)}
|
||||
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
|
||||
|
||||
<CreateSharedLink />
|
||||
<SelectAllAssets {timelineManager} assetInteraction={assetMultiSelectManager} />
|
||||
<ActionButton action={Actions.AddToAlbum} />
|
||||
|
||||
{#if assetMultiSelectManager.isAllUserOwned}
|
||||
<FavoriteAction
|
||||
removeFavorite={assetMultiSelectManager.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
|
||||
/>
|
||||
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
{#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected}
|
||||
<StackAction
|
||||
unstack={isAssetStackSelected}
|
||||
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
|
||||
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
|
||||
/>
|
||||
{/if}
|
||||
{#if isLinkActionAvailable}
|
||||
<LinkLivePhotoAction
|
||||
menuItem
|
||||
unlink={assetMultiSelectManager.assets.length === 1}
|
||||
onLink={handleLink}
|
||||
onUnlink={handleUnlink}
|
||||
/>
|
||||
{/if}
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction
|
||||
menuItem
|
||||
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
|
||||
/>
|
||||
{#if authManager.preferences.tags.enabled}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets
|
||||
menuItem
|
||||
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
|
||||
/>
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
<hr />
|
||||
<ActionMenuItem action={Actions.RegenerateThumbnailJob} />
|
||||
<ActionMenuItem action={Actions.RefreshMetadataJob} />
|
||||
<ActionMenuItem action={Actions.TranscodeVideoJob} />
|
||||
</ButtonContextMenu>
|
||||
{:else}
|
||||
<DownloadAction />
|
||||
{/if}
|
||||
</AssetSelectControlBar>
|
||||
{/if}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url);
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
meta: {
|
||||
title: $t('recently_added_page_title'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
@@ -42,7 +42,7 @@
|
||||
type SmartSearchDto,
|
||||
} from '@immich/sdk';
|
||||
import { ActionButton, CommandPaletteDefaultProvider, Icon, IconButton, LoadingSpinner } from '@immich/ui';
|
||||
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiSelectAll } from '@mdi/js';
|
||||
import { mdiArrowLeft, mdiClose, mdiDotsVertical, mdiImageOffOutline, mdiSelectAll } from '@mdi/js';
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY));
|
||||
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
|
||||
let terms = $derived<SearchTerms>(searchQuery ? JSON.parse(searchQuery) : {});
|
||||
let searchTermKeys = $derived(getObjectKeys(terms));
|
||||
|
||||
$effect(() => {
|
||||
// we want this to *only* be reactive on `terms`
|
||||
@@ -184,6 +185,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'),
|
||||
@@ -234,50 +236,65 @@
|
||||
function getObjectKeys<T extends object>(obj: T): (keyof T)[] {
|
||||
return Object.keys(obj) as (keyof T)[];
|
||||
}
|
||||
|
||||
function removeFilter(key: keyof SearchTerms) {
|
||||
delete terms[key];
|
||||
void goto(Route.search(terms));
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window bind:scrollY />
|
||||
|
||||
<OnEvents {onAlbumAddAssets} />
|
||||
|
||||
{#if terms}
|
||||
<section
|
||||
id="search-chips"
|
||||
class="mt-24 flex w-full flex-wrap place-content-center place-items-center gap-5 px-24 text-center"
|
||||
>
|
||||
{#each getObjectKeys(terms) as searchKey (searchKey)}
|
||||
{@const value = terms[searchKey]}
|
||||
<div class="flex place-content-center place-items-center items-stretch text-xs">
|
||||
{#if searchTermKeys.length > 0}
|
||||
<section id="search-chips" class="mx-auto mt-24 w-full max-w-7xl px-4 sm:px-8 lg:px-12">
|
||||
<div class="flex w-full flex-wrap place-content-center place-items-center gap-2.5 sm:gap-3">
|
||||
{#each searchTermKeys as searchKey (searchKey)}
|
||||
{@const value = terms[searchKey]}
|
||||
<div
|
||||
class="flex items-center justify-center bg-immich-primary px-4 py-2 text-white dark:bg-immich-dark-primary dark:text-black
|
||||
{value === true ? 'rounded-full' : 'rounded-s-full'}"
|
||||
class="inline-flex max-w-full items-center rounded-full bg-primary/10 py-1 ps-1 pe-1 text-xs text-primary ring-1 ring-primary/15 transition-shadow hover:ring-primary/25 dark:bg-immich-dark-primary/15 dark:text-immich-dark-primary dark:ring-immich-dark-primary/20 dark:hover:ring-immich-dark-primary/30"
|
||||
>
|
||||
{getHumanReadableSearchKey(searchKey as keyof SearchTerms)}
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-primary px-3 py-1.5 font-medium text-light dark:bg-immich-dark-primary dark:text-immich-dark-gray"
|
||||
>
|
||||
{getHumanReadableSearchKey(searchKey as keyof SearchTerms)}
|
||||
</span>
|
||||
|
||||
{#if value !== true}
|
||||
<div class="rounded-e-full bg-gray-300 px-4 py-2 dark:bg-gray-800 dark:text-white">
|
||||
{#if (searchKey === 'takenAfter' || searchKey === 'takenBefore') && typeof value === 'string'}
|
||||
{getHumanReadableDate(value)}
|
||||
{:else if searchKey === 'personIds' && Array.isArray(value)}
|
||||
{#await getPersonName(value) then personName}
|
||||
{personName}
|
||||
{/await}
|
||||
{:else if searchKey === 'tagIds' && (Array.isArray(value) || value === null)}
|
||||
{#await getTagNames(value) then tagNames}
|
||||
{tagNames}
|
||||
{/await}
|
||||
{:else if searchKey === 'rating'}
|
||||
{$t('rating_count', { values: { count: value ?? 0 } })}
|
||||
{:else if value === null || value === ''}
|
||||
{$t('unknown')}
|
||||
{:else}
|
||||
{value}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if value !== true}
|
||||
<span class="max-w-[min(36rem,55vw)] min-w-0 truncate px-3 py-1.5 text-immich-fg dark:text-immich-dark-fg">
|
||||
{#if (searchKey === 'takenAfter' || searchKey === 'takenBefore') && typeof value === 'string'}
|
||||
{getHumanReadableDate(value)}
|
||||
{:else if searchKey === 'personIds' && Array.isArray(value)}
|
||||
{#await getPersonName(value) then personName}
|
||||
{personName}
|
||||
{/await}
|
||||
{:else if searchKey === 'tagIds' && (Array.isArray(value) || value === null)}
|
||||
{#await getTagNames(value) then tagNames}
|
||||
{tagNames}
|
||||
{/await}
|
||||
{:else if searchKey === 'rating'}
|
||||
{$t('rating_count', { values: { count: value ?? 0 } })}
|
||||
{:else if value === null || value === ''}
|
||||
{$t('unknown')}
|
||||
{:else}
|
||||
{value}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="ms-0.5 flex size-7 shrink-0 items-center justify-center rounded-full text-primary outline-offset-2 outline-immich-primary transition-colors hover:bg-primary/15 focus-visible:outline-2 dark:text-immich-dark-primary dark:outline-immich-dark-primary dark:hover:bg-immich-dark-primary/20"
|
||||
aria-label={$t('remove_filter')}
|
||||
title={$t('remove_filter')}
|
||||
onclick={() => removeFilter(searchKey)}
|
||||
>
|
||||
<Icon icon={mdiClose} size="14" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -38,6 +38,7 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
|
||||
tags: [],
|
||||
thumbhash: Sync.each(() => faker.string.alphanumeric(28)),
|
||||
localDateTime: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())),
|
||||
createdAt: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())),
|
||||
fileCreatedAt: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())),
|
||||
isFavorite: Sync.each(() => faker.datatype.boolean()),
|
||||
visibility: AssetVisibility.Timeline,
|
||||
@@ -66,6 +67,7 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
|
||||
livePhotoVideoId: [],
|
||||
fileCreatedAt: [],
|
||||
localOffsetHours: [],
|
||||
createdAt: [],
|
||||
ownerId: [],
|
||||
projectionType: [],
|
||||
ratio: [],
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user