chore: merge main into feat/hero_view_transitions

Change-Id: I6e6316f66343b8f3ea9fe33ed3f8f3e56a6a6964
This commit is contained in:
midzelis
2026-05-14 02:30:18 +00:00
379 changed files with 25782 additions and 3260 deletions
+1 -1
View File
@@ -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",
@@ -16,6 +16,7 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.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';
@@ -50,6 +51,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 = {
@@ -375,6 +377,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: {
@@ -418,11 +421,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);
}
};
@@ -639,6 +645,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';
@@ -156,13 +157,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}
@@ -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;
@@ -212,16 +212,10 @@
};
}
try {
return {
fileCreatedAfter: dateAfter ? new Date(dateAfter).toISOString() : undefined,
fileCreatedBefore: dateBefore ? new Date(dateBefore).toISOString() : undefined,
};
} catch {
$mapSettings.dateAfter = '';
$mapSettings.dateBefore = '';
return {};
}
return {
fileCreatedAfter: dateAfter?.toUTC().toISO(),
fileCreatedBefore: dateBefore?.toUTC().toISO(),
};
}
async function loadMapMarkers() {
@@ -237,7 +231,7 @@
{
isArchived: includeArchived || undefined,
isFavorite: onlyFavorites || undefined,
fileCreatedAfter: fileCreatedAfter || undefined,
fileCreatedAfter,
fileCreatedBefore,
withPartners: withPartners || undefined,
withSharedAlbums: withSharedAlbums || undefined,
@@ -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}>
@@ -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();
}
}
@@ -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;
+27 -38
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import DateInput from '$lib/elements/DateInput.svelte';
import type { MapSettings } from '$lib/stores/preferences.store';
import { Button, Field, FormModal, Select, Stack, Switch } from '@immich/ui';
import { Button, DatePicker, Field, FormModal, Select, Stack, Switch } from '@immich/ui';
import { Duration } from 'luxon';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
@@ -41,29 +40,21 @@
{#if customDateRange}
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-8">
<label class="shrink-0 text-sm immich-form-label" for="date-after">{$t('date_after')}</label>
<DateInput
class="immich-form-input w-40"
type="date"
id="date-after"
max={settings.dateBefore}
bind:value={settings.dateAfter}
/>
</div>
<div class="flex items-center justify-between gap-8">
<label class="shrink-0 text-sm immich-form-label" for="date-before">{$t('date_before')}</label>
<DateInput class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} />
</div>
<div class="flex justify-center text-xs">
<Field label={$t('date_after')}>
<DatePicker bind:value={settings.dateAfter} maxDate={settings.dateBefore} />
</Field>
<Field label={$t('date_before')}>
<DatePicker bind:value={settings.dateBefore} />
</Field>
<div class="flex justify-center">
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = false;
settings.dateAfter = '';
settings.dateBefore = '';
settings.dateAfter = undefined;
settings.dateBefore = undefined;
}}
>
{$t('remove_custom_date_range')}
@@ -71,7 +62,7 @@
</div>
</div>
{:else}
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1">
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-2">
<Field label={$t('date_range')}>
<Select
bind:value={settings.relativeDate}
@@ -82,40 +73,38 @@
},
{
label: $t('past_durations.hours', { values: { hours: 24 } }),
value: Duration.fromObject({ hours: 24 }).toISO() || '',
value: Duration.fromObject({ hours: 24 }).toISO(),
},
{
label: $t('past_durations.days', { values: { days: 7 } }),
value: Duration.fromObject({ days: 7 }).toISO() || '',
value: Duration.fromObject({ days: 7 }).toISO(),
},
{
label: $t('past_durations.days', { values: { days: 30 } }),
value: Duration.fromObject({ days: 30 }).toISO() || '',
value: Duration.fromObject({ days: 30 }).toISO(),
},
{
label: $t('past_durations.years', { values: { years: 1 } }),
value: Duration.fromObject({ years: 1 }).toISO() || '',
value: Duration.fromObject({ years: 1 }).toISO(),
},
{
label: $t('past_durations.years', { values: { years: 3 } }),
value: Duration.fromObject({ years: 3 }).toISO() || '',
value: Duration.fromObject({ years: 3 }).toISO(),
},
]}
/>
</Field>
<div class="text-xs">
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = true;
settings.relativeDate = '';
}}
>
{$t('use_custom_date_range')}
</Button>
</div>
<Button
color="primary"
size="small"
variant="ghost"
onclick={() => {
customDateRange = true;
settings.relativeDate = '';
}}
>
{$t('use_custom_date_range')}
</Button>
</div>
{/if}
</Stack>
@@ -1,9 +1,9 @@
<script lang="ts">
import DateInput from '$lib/elements/DateInput.svelte';
import { handleUpdatePersonBirthDate } from '$lib/services/person.service';
import { type PersonResponseDto } from '@immich/sdk';
import { Button, FormModal, Text } from '@immich/ui';
import { Button, DatePicker, Field, FormModal, HelperText } from '@immich/ui';
import { mdiCake } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
type Props = {
@@ -12,32 +12,25 @@
};
let { person, onClose }: Props = $props();
let birthDate = $derived(person.birthDate ?? '');
let birthDate = $derived(person.birthDate ? DateTime.fromISO(person.birthDate) : undefined);
const onSubmit = async () => {
const success = await handleUpdatePersonBirthDate(person, birthDate);
const success = await handleUpdatePersonBirthDate(person, birthDate?.toISODate() ?? '');
if (success) {
onClose();
}
};
const todayFormatted = new Date().toISOString().split('T')[0];
</script>
<FormModal title={$t('set_date_of_birth')} size="small" icon={mdiCake} {onClose} {onSubmit}>
<Text size="small">{$t('birthdate_set_description')}</Text>
<div class="my-4 flex flex-col gap-2">
<DateInput
class="immich-form-input"
id="birthDate"
name="birthDate"
type="date"
bind:value={birthDate}
max={todayFormatted}
/>
<div class="my-2 flex flex-col gap-2">
<Field label={$t('date_of_birth')}>
<DatePicker bind:value={birthDate} maxDate={DateTime.now()} />
<HelperText>{$t('birthdate_set_description')}</HelperText>
</Field>
{#if person.birthDate}
<div class="flex justify-end">
<Button shape="round" color="secondary" size="small" onclick={() => (birthDate = '')}>
<Button shape="round" color="secondary" size="small" onclick={() => (birthDate = undefined)}>
{$t('clear')}
</Button>
</div>
@@ -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>
+1
View File
@@ -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) => {
+74
View File
@@ -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();
+1
View File
@@ -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[] => [
+3 -4
View File
@@ -1,3 +1,4 @@
import type { DateTime } from 'luxon';
import { persisted } from 'svelte-persisted-store';
import { browser } from '$app/environment';
import { defaultLang } from '$lib/constants';
@@ -26,8 +27,8 @@ export interface MapSettings {
withPartners: boolean;
withSharedAlbums: boolean;
relativeDate: string;
dateAfter: string;
dateBefore: string;
dateAfter?: DateTime<true>;
dateBefore?: DateTime<true>;
}
const defaultMapSettings = {
@@ -37,8 +38,6 @@ const defaultMapSettings = {
withPartners: false,
withSharedAlbums: false,
relativeDate: '',
dateAfter: '',
dateBefore: '',
};
const persistedObject = <T>(key: string, defaults: T) =>
+12
View File
@@ -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,
};
}
+9
View File
@@ -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
+6 -1
View File
@@ -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';
@@ -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;
+30 -1
View File
@@ -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>
@@ -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`
@@ -235,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}
@@ -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: [],