mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat: persistent memories (#15953)
feat: memories refactor chore: use heart as favorite icon fix: linting
This commit is contained in:
parent
502f6e020d
commit
d350022dec
29 changed files with 585 additions and 70 deletions
|
|
@ -13,25 +13,45 @@
|
|||
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
|
||||
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
|
||||
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
|
||||
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
|
||||
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { type Viewport } from '$lib/stores/assets.store';
|
||||
import { memoryStore } from '$lib/stores/memory.store';
|
||||
import { loadMemories, memoryStore } from '$lib/stores/memory.store';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import { fromLocalDateTime } from '$lib/utils/timeline-util';
|
||||
import { AssetMediaSize, getMemoryLane, type AssetResponseDto, type MemoryLaneResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
AssetMediaSize,
|
||||
deleteMemory,
|
||||
removeMemoryAssets,
|
||||
updateMemory,
|
||||
type AssetResponseDto,
|
||||
type MemoryResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import {
|
||||
mdiCardsOutline,
|
||||
mdiChevronDown,
|
||||
mdiChevronLeft,
|
||||
mdiChevronRight,
|
||||
mdiChevronUp,
|
||||
mdiDotsVertical,
|
||||
mdiHeart,
|
||||
mdiHeartOutline,
|
||||
mdiImageMinusOutline,
|
||||
mdiImageSearch,
|
||||
mdiPause,
|
||||
mdiPlay,
|
||||
|
|
@ -45,9 +65,6 @@
|
|||
import { tweened } from 'svelte/motion';
|
||||
import { derived as storeDerived } from 'svelte/store';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
|
||||
type MemoryIndex = {
|
||||
memoryIndex: number;
|
||||
|
|
@ -55,20 +72,20 @@
|
|||
};
|
||||
|
||||
type MemoryAsset = MemoryIndex & {
|
||||
memory: MemoryLaneResponseDto;
|
||||
memory: MemoryResponseDto;
|
||||
asset: AssetResponseDto;
|
||||
previousMemory?: MemoryLaneResponseDto;
|
||||
previousMemory?: MemoryResponseDto;
|
||||
previous?: MemoryAsset;
|
||||
next?: MemoryAsset;
|
||||
nextMemory?: MemoryLaneResponseDto;
|
||||
nextMemory?: MemoryResponseDto;
|
||||
};
|
||||
|
||||
let memoryGallery: HTMLElement | undefined = $state();
|
||||
let memoryWrapper: HTMLElement | undefined = $state();
|
||||
let galleryInView = $state(false);
|
||||
let paused = $state(false);
|
||||
let current: MemoryAsset | undefined = $state(undefined);
|
||||
// let memories: MemoryAsset[] = [];
|
||||
let current = $state<MemoryAsset | undefined>(undefined);
|
||||
let isSaved = $derived(current?.memory.isSaved);
|
||||
let resetPromise = $state(Promise.resolve());
|
||||
|
||||
const { isViewing } = assetViewingStore;
|
||||
|
|
@ -168,6 +185,7 @@
|
|||
}
|
||||
current.memory.assets = current.memory.assets;
|
||||
};
|
||||
|
||||
const handleRemove = (ids: string[]) => {
|
||||
if (!current) {
|
||||
return;
|
||||
|
|
@ -186,13 +204,65 @@
|
|||
current = loadFromParams($memories, $page);
|
||||
};
|
||||
|
||||
const handleDeleteMemoryAsset = async (current?: MemoryAsset) => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (current.memory.assets.length === 1) {
|
||||
return handleDeleteMemory(current);
|
||||
}
|
||||
|
||||
if (current.previous) {
|
||||
current.previous.next = current.next;
|
||||
}
|
||||
if (current.next) {
|
||||
current.next.previous = current.previous;
|
||||
}
|
||||
|
||||
current.memory.assets = current.memory.assets.filter((asset) => asset.id !== current.asset.id);
|
||||
|
||||
$memoryStore = $memoryStore;
|
||||
|
||||
await removeMemoryAssets({ id: current.memory.id, bulkIdsDto: { ids: [current.asset.id] } });
|
||||
};
|
||||
|
||||
const handleDeleteMemory = async (current?: MemoryAsset) => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteMemory({ id: current.memory.id });
|
||||
|
||||
notificationController.show({ message: $t('removed_memory'), type: NotificationType.Info });
|
||||
|
||||
await loadMemories();
|
||||
init();
|
||||
};
|
||||
|
||||
const handleSaveMemory = async (current?: MemoryAsset) => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
current.memory.isSaved = !current.memory.isSaved;
|
||||
|
||||
await updateMemory({
|
||||
id: current.memory.id,
|
||||
memoryUpdateDto: {
|
||||
isSaved: current.memory.isSaved,
|
||||
},
|
||||
});
|
||||
|
||||
notificationController.show({
|
||||
message: current.memory.isSaved ? $t('added_to_favorites') : $t('removed_from_favorites'),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if (!$memoryStore) {
|
||||
const localTime = new Date();
|
||||
$memoryStore = await getMemoryLane({
|
||||
month: localTime.getMonth() + 1,
|
||||
day: localTime.getDate(),
|
||||
});
|
||||
await loadMemories();
|
||||
}
|
||||
|
||||
init();
|
||||
|
|
@ -268,7 +338,7 @@
|
|||
{#snippet leading()}
|
||||
{#if current}
|
||||
<p class="text-lg">
|
||||
{$memoryLaneTitle(current.memory.yearsAgo)}
|
||||
{$memoryLaneTitle(current.memory)}
|
||||
</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
|
@ -352,7 +422,7 @@
|
|||
{#if current.previousMemory}
|
||||
<div class="absolute bottom-4 right-4 text-left text-white">
|
||||
<p class="text-xs font-semibold text-gray-200">{$t('previous').toUpperCase()}</p>
|
||||
<p class="text-xl">{$memoryLaneTitle(current.previousMemory.yearsAgo)}</p>
|
||||
<p class="text-xl">{$memoryLaneTitle(current.previousMemory)}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
|
|
@ -374,17 +444,63 @@
|
|||
{/key}
|
||||
|
||||
<div
|
||||
class="absolute bottom-6 right-6 transition-all"
|
||||
class="absolute bottom-0 right-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2"
|
||||
class:opacity-0={galleryInView}
|
||||
class:opacity-100={!galleryInView}
|
||||
>
|
||||
<CircleIconButton
|
||||
href="{AppRoute.PHOTOS}?at={current.asset.id}"
|
||||
icon={mdiImageSearch}
|
||||
title={$t('view_in_timeline')}
|
||||
color="light"
|
||||
onclick={() => {}}
|
||||
/>
|
||||
<div class="flex">
|
||||
<IconButton
|
||||
icon={isSaved ? mdiHeart : mdiHeartOutline}
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
size="giant"
|
||||
color="secondary"
|
||||
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
|
||||
onclick={() => handleSaveMemory(current)}
|
||||
class="text-white dark:text-white"
|
||||
/>
|
||||
<!-- <IconButton
|
||||
icon={mdiShareVariantOutline}
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
size="giant"
|
||||
color="secondary"
|
||||
aria-label={$t('share')}
|
||||
/> -->
|
||||
<ButtonContextMenu
|
||||
icon={mdiDotsVertical}
|
||||
title={$t('menu')}
|
||||
onclick={() => handleAction('pause')}
|
||||
direction="left"
|
||||
align="bottom-right"
|
||||
class="text-white dark:text-white"
|
||||
>
|
||||
<MenuOption
|
||||
onClick={() => handleDeleteMemory(current)}
|
||||
text={'Remove memory'}
|
||||
icon={mdiCardsOutline}
|
||||
/>
|
||||
<MenuOption
|
||||
onClick={() => handleDeleteMemoryAsset(current)}
|
||||
text={'Remove photo from this memory'}
|
||||
icon={mdiImageMinusOutline}
|
||||
/>
|
||||
<!-- shortcut={{ key: 'l', shift: shared }} -->
|
||||
</ButtonContextMenu>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<IconButton
|
||||
href="{AppRoute.PHOTOS}?at={current.asset.id}"
|
||||
icon={mdiImageSearch}
|
||||
aria-label={$t('view_in_timeline')}
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
size="giant"
|
||||
class="text-white dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- CONTROL BUTTONS -->
|
||||
{#if current.previous}
|
||||
|
|
@ -449,7 +565,7 @@
|
|||
{#if current.nextMemory}
|
||||
<div class="absolute bottom-4 left-4 text-left text-white">
|
||||
<p class="text-xs font-semibold text-gray-200">{$t('up_next').toUpperCase()}</p>
|
||||
<p class="text-xl">{$memoryLaneTitle(current.nextMemory.yearsAgo)}</p>
|
||||
<p class="text-xl">{$memoryLaneTitle(current.nextMemory)}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -2,20 +2,18 @@
|
|||
import { resizeObserver } from '$lib/actions/resize-observer';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||
import { memoryStore } from '$lib/stores/memory.store';
|
||||
import { loadMemories, memoryStore } from '$lib/stores/memory.store';
|
||||
import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { getMemoryLane } from '@immich/sdk';
|
||||
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let shouldRender = $derived($memoryStore?.length > 0);
|
||||
|
||||
onMount(async () => {
|
||||
const localTime = new Date();
|
||||
$memoryStore = await getMemoryLane({ month: localTime.getMonth() + 1, day: localTime.getDate() });
|
||||
await loadMemories();
|
||||
});
|
||||
|
||||
let memoryLaneElement: HTMLElement | undefined = $state();
|
||||
|
|
@ -71,7 +69,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
<div class="inline-block" use:resizeObserver={({ width }) => (innerWidth = width)}>
|
||||
{#each $memoryStore as memory (memory.yearsAgo)}
|
||||
{#each $memoryStore as memory}
|
||||
{#if memory.assets.length > 0}
|
||||
<a
|
||||
class="memory-card relative mr-8 inline-block aspect-[3/4] md:aspect-video h-[215px] rounded-xl"
|
||||
|
|
@ -84,7 +82,7 @@
|
|||
draggable="false"
|
||||
/>
|
||||
<p class="absolute bottom-2 left-4 z-10 text-lg text-white">
|
||||
{$memoryLaneTitle(memory.yearsAgo)}
|
||||
{$memoryLaneTitle(memory)}
|
||||
</p>
|
||||
<div
|
||||
class="absolute left-0 top-0 z-0 h-full w-full rounded-xl bg-gradient-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20"
|
||||
|
|
|
|||
|
|
@ -1,22 +1,23 @@
|
|||
<script lang="ts">
|
||||
import { clickOutside } from '$lib/actions/click-outside';
|
||||
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import CircleIconButton, {
|
||||
type Color,
|
||||
type Padding,
|
||||
} from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
|
||||
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
|
||||
import {
|
||||
getContextMenuPositionFromBoundingRect,
|
||||
getContextMenuPositionFromEvent,
|
||||
type Align,
|
||||
} from '$lib/utils/context-menu';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
|
||||
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
|
||||
import { clickOutside } from '$lib/actions/click-outside';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
icon: string;
|
||||
title: string;
|
||||
/**
|
||||
|
|
@ -36,7 +37,7 @@
|
|||
buttonClass?: string | undefined;
|
||||
hideContent?: boolean;
|
||||
children?: Snippet;
|
||||
}
|
||||
} & HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
let {
|
||||
icon,
|
||||
|
|
@ -49,6 +50,7 @@
|
|||
buttonClass = undefined,
|
||||
hideContent = false,
|
||||
children,
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
|
|
@ -129,6 +131,7 @@
|
|||
}}
|
||||
use:clickOutside={{ onOutclick: closeDropdown }}
|
||||
onresize={onResize}
|
||||
{...restProps}
|
||||
>
|
||||
<div bind:this={buttonContainer}>
|
||||
<CircleIconButton
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import type { MemoryLaneResponseDto } from '@immich/sdk';
|
||||
import { asLocalTimeISO } from '$lib/utils/date-time';
|
||||
import { searchMemories, type MemoryResponseDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const memoryStore = writable<MemoryLaneResponseDto[]>();
|
||||
export const memoryStore = writable<MemoryResponseDto[]>();
|
||||
|
||||
export const loadMemories = async () => {
|
||||
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
|
||||
memoryStore.set(memories);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
AssetJobName,
|
||||
AssetMediaSize,
|
||||
JobName,
|
||||
MemoryType,
|
||||
finishOAuth,
|
||||
getAssetOriginalPath,
|
||||
getAssetPlaybackPath,
|
||||
|
|
@ -16,6 +17,7 @@ import {
|
|||
linkOAuthAccount,
|
||||
startOAuth,
|
||||
unlinkOAuthAccount,
|
||||
type MemoryResponseDto,
|
||||
type PersonResponseDto,
|
||||
type SharedLinkResponseDto,
|
||||
type UserResponseDto,
|
||||
|
|
@ -320,7 +322,14 @@ export const handlePromiseError = <T>(promise: Promise<T>): void => {
|
|||
};
|
||||
|
||||
export const memoryLaneTitle = derived(t, ($t) => {
|
||||
return (yearsAgo: number) => $t('years_ago', { values: { years: yearsAgo } });
|
||||
return (memory: MemoryResponseDto) => {
|
||||
const now = new Date();
|
||||
if (memory.type === MemoryType.OnThisDay) {
|
||||
return $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } });
|
||||
}
|
||||
|
||||
return $t('unknown');
|
||||
};
|
||||
});
|
||||
|
||||
export const withError = async <T>(fn: () => Promise<T>): Promise<[undefined, T] | [unknown, undefined]> => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export type Align = 'middle' | 'top-left' | 'top-right';
|
||||
export type Align = 'middle' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
|
||||
export type ContextMenuPosition = { x: number; y: number };
|
||||
|
||||
|
|
@ -28,5 +28,11 @@ export const getContextMenuPositionFromBoundingRect = (rect: DOMRect, align: Ali
|
|||
case 'top-right': {
|
||||
return { x: rect.x + rect.width, y: rect.y };
|
||||
}
|
||||
case 'bottom-left': {
|
||||
return { x: rect.x, y: rect.y + rect.height };
|
||||
}
|
||||
case 'bottom-right': {
|
||||
return { x: rect.x + rect.width, y: rect.y + rect.height };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -77,3 +77,11 @@ export const getAlbumDateRange = (album: { startDate?: string; endDate?: string
|
|||
|
||||
return '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Use this to convert from "5pm EST" to "5pm UTC"
|
||||
*
|
||||
* Useful with some APIs where you want to query by "today", but the values in the database are stored as UTC
|
||||
*/
|
||||
export const asLocalTimeISO = (date: DateTime<true>) =>
|
||||
(date.setZone('utc', { keepLocalTime: true }) as DateTime<true>).toISO();
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@
|
|||
{ title: $t('admin.person_cleanup_job'), value: ManualJobName.PersonCleanup },
|
||||
{ title: $t('admin.tag_cleanup_job'), value: ManualJobName.TagCleanup },
|
||||
{ title: $t('admin.user_cleanup_job'), value: ManualJobName.UserCleanup },
|
||||
{ title: $t('admin.memory_cleanup_job'), value: ManualJobName.MemoryCleanup },
|
||||
{ title: $t('admin.memory_generate_job'), value: ManualJobName.MemoryCreate },
|
||||
].map(({ value, title }) => ({ id: value, label: title, value }));
|
||||
|
||||
const handleCancel = () => (isOpen = false);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue