feat: persistent memories (#15953)

feat: memories

refactor

chore: use heart as favorite icon

fix: linting
This commit is contained in:
Jason Rasmussen
2025-02-21 13:31:37 -05:00
committed by GitHub
parent 502f6e020d
commit d350022dec
29 changed files with 585 additions and 70 deletions

View File

@@ -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>

View File

@@ -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"

View File

@@ -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