mirror of
https://github.com/immich-app/immich.git
synced 2025-06-03 13:44:16 -04:00
feat(web): lighter timeline buckets (#17719)
* feat(web): lighter timeline buckets * GalleryViewer * weird ssr * Remove generics from AssetInteraction * ensure keys on getAssetInfo, alt-text * empty - trigger ci * re-add alt-text * test fix * update tests * tests * missing import * fix: flappy e2e test * lint * revert settings * unneeded cast * fix after merge * missing import * lint * review * lint * avoid abbreviations * review comment - type safety in test * merge conflicts * lint * lint/abbreviations * fix: left-over migration --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
a65c905621
commit
0bbe70e6a3
@ -2,6 +2,7 @@
|
|||||||
import { shortcut } from '$lib/actions/shortcut';
|
import { shortcut } from '$lib/actions/shortcut';
|
||||||
import AlbumMap from '$lib/components/album-page/album-map.svelte';
|
import AlbumMap from '$lib/components/album-page/album-map.svelte';
|
||||||
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
|
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
|
||||||
|
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||||
@ -16,7 +17,6 @@
|
|||||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||||
import DownloadAction from '../photos-page/actions/download-action.svelte';
|
import DownloadAction from '../photos-page/actions/download-action.svelte';
|
||||||
import AssetGrid from '../photos-page/asset-grid.svelte';
|
import AssetGrid from '../photos-page/asset-grid.svelte';
|
||||||
import AssetSelectControlBar from '../photos-page/asset-select-control-bar.svelte';
|
|
||||||
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
||||||
import ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte';
|
import ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte';
|
||||||
import ThemeButton from '../shared-components/theme-button.svelte';
|
import ThemeButton from '../shared-components/theme-button.svelte';
|
||||||
|
@ -1,20 +1,21 @@
|
|||||||
import type { AssetAction } from '$lib/constants';
|
import type { AssetAction } from '$lib/constants';
|
||||||
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
|
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
|
import type { AlbumResponseDto } from '@immich/sdk';
|
||||||
|
|
||||||
type ActionMap = {
|
type ActionMap = {
|
||||||
[AssetAction.ARCHIVE]: { asset: AssetResponseDto };
|
[AssetAction.ARCHIVE]: { asset: TimelineAsset };
|
||||||
[AssetAction.UNARCHIVE]: { asset: AssetResponseDto };
|
[AssetAction.UNARCHIVE]: { asset: TimelineAsset };
|
||||||
[AssetAction.FAVORITE]: { asset: AssetResponseDto };
|
[AssetAction.FAVORITE]: { asset: TimelineAsset };
|
||||||
[AssetAction.UNFAVORITE]: { asset: AssetResponseDto };
|
[AssetAction.UNFAVORITE]: { asset: TimelineAsset };
|
||||||
[AssetAction.TRASH]: { asset: AssetResponseDto };
|
[AssetAction.TRASH]: { asset: TimelineAsset };
|
||||||
[AssetAction.DELETE]: { asset: AssetResponseDto };
|
[AssetAction.DELETE]: { asset: TimelineAsset };
|
||||||
[AssetAction.RESTORE]: { asset: AssetResponseDto };
|
[AssetAction.RESTORE]: { asset: TimelineAsset };
|
||||||
[AssetAction.ADD]: { asset: AssetResponseDto };
|
[AssetAction.ADD]: { asset: TimelineAsset };
|
||||||
[AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto };
|
[AssetAction.ADD_TO_ALBUM]: { asset: TimelineAsset; album: AlbumResponseDto };
|
||||||
[AssetAction.UNSTACK]: { assets: AssetResponseDto[] };
|
[AssetAction.UNSTACK]: { assets: TimelineAsset[] };
|
||||||
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: AssetResponseDto };
|
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: TimelineAsset };
|
||||||
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: AssetResponseDto };
|
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
|
||||||
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: AssetResponseDto };
|
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Action = {
|
export type Action = {
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import { addAssetsToAlbum, addAssetsToNewAlbum } from '$lib/utils/asset-utils';
|
import { addAssetsToAlbum, addAssetsToNewAlbum } from '$lib/utils/asset-utils';
|
||||||
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
|
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
|
||||||
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
|
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@ -24,14 +25,14 @@
|
|||||||
showSelectionModal = false;
|
showSelectionModal = false;
|
||||||
const album = await addAssetsToNewAlbum(albumName, [asset.id]);
|
const album = await addAssetsToNewAlbum(albumName, [asset.id]);
|
||||||
if (album) {
|
if (album) {
|
||||||
onAction({ type: AssetAction.ADD_TO_ALBUM, asset, album });
|
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddToAlbum = async (album: AlbumResponseDto) => {
|
const handleAddToAlbum = async (album: AlbumResponseDto) => {
|
||||||
showSelectionModal = false;
|
showSelectionModal = false;
|
||||||
await addAssetsToAlbum(album.id, [asset.id]);
|
await addAssetsToAlbum(album.id, [asset.id]);
|
||||||
onAction({ type: AssetAction.ADD_TO_ALBUM, asset, album });
|
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import { toggleArchive } from '$lib/utils/asset-utils';
|
import { toggleArchive } from '$lib/utils/asset-utils';
|
||||||
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline } from '@mdi/js';
|
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@ -18,11 +19,11 @@
|
|||||||
|
|
||||||
const onArchive = async () => {
|
const onArchive = async () => {
|
||||||
if (!asset.isArchived) {
|
if (!asset.isArchived) {
|
||||||
preAction({ type: AssetAction.ARCHIVE, asset });
|
preAction({ type: AssetAction.ARCHIVE, asset: toTimelineAsset(asset) });
|
||||||
}
|
}
|
||||||
const updatedAsset = await toggleArchive(asset);
|
const updatedAsset = await toggleArchive(asset);
|
||||||
if (updatedAsset) {
|
if (updatedAsset) {
|
||||||
onAction({ type: asset.isArchived ? AssetAction.ARCHIVE : AssetAction.UNARCHIVE, asset });
|
onAction({ type: asset.isArchived ? AssetAction.ARCHIVE : AssetAction.UNARCHIVE, asset: toTimelineAsset(asset) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -11,6 +11,7 @@
|
|||||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { deleteAssets, type AssetResponseDto } from '@immich/sdk';
|
import { deleteAssets, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js';
|
import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@ -42,9 +43,9 @@
|
|||||||
|
|
||||||
const trashAsset = async () => {
|
const trashAsset = async () => {
|
||||||
try {
|
try {
|
||||||
preAction({ type: AssetAction.TRASH, asset });
|
preAction({ type: AssetAction.TRASH, asset: toTimelineAsset(asset) });
|
||||||
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id] } });
|
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id] } });
|
||||||
onAction({ type: AssetAction.TRASH, asset });
|
onAction({ type: AssetAction.TRASH, asset: toTimelineAsset(asset) });
|
||||||
|
|
||||||
notificationController.show({
|
notificationController.show({
|
||||||
message: $t('moved_to_trash'),
|
message: $t('moved_to_trash'),
|
||||||
@ -58,7 +59,7 @@
|
|||||||
const deleteAsset = async () => {
|
const deleteAsset = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
|
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
|
||||||
onAction({ type: AssetAction.DELETE, asset });
|
onAction({ type: AssetAction.DELETE, asset: toTimelineAsset(asset) });
|
||||||
|
|
||||||
notificationController.show({
|
notificationController.show({
|
||||||
message: $t('permanently_deleted_asset'),
|
message: $t('permanently_deleted_asset'),
|
||||||
|
@ -2,19 +2,21 @@
|
|||||||
import { shortcut } from '$lib/actions/shortcut';
|
import { shortcut } from '$lib/actions/shortcut';
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import { downloadFile } from '$lib/utils/asset-utils';
|
import { downloadFile } from '$lib/utils/asset-utils';
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import { getAssetInfo } from '@immich/sdk';
|
||||||
import { mdiFolderDownloadOutline } from '@mdi/js';
|
import { mdiFolderDownloadOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: TimelineAsset;
|
||||||
menuItem?: boolean;
|
menuItem?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { asset, menuItem = false }: Props = $props();
|
let { asset, menuItem = false }: Props = $props();
|
||||||
|
|
||||||
const onDownloadFile = () => downloadFile(asset);
|
const onDownloadFile = async () => downloadFile(await getAssetInfo({ id: asset.id, key: authManager.key }));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: onDownloadFile }} />
|
<svelte:window use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: onDownloadFile }} />
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
} from '$lib/components/shared-components/notification/notification';
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
|
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { mdiHeart, mdiHeartOutline } from '@mdi/js';
|
import { mdiHeart, mdiHeartOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@ -30,7 +31,10 @@
|
|||||||
|
|
||||||
asset = { ...asset, isFavorite: data.isFavorite };
|
asset = { ...asset, isFavorite: data.isFavorite };
|
||||||
|
|
||||||
onAction({ type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE, asset });
|
onAction({
|
||||||
|
type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE,
|
||||||
|
asset: toTimelineAsset(asset),
|
||||||
|
});
|
||||||
|
|
||||||
notificationController.show({
|
notificationController.show({
|
||||||
type: NotificationType.Info,
|
type: NotificationType.Info,
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||||
import { keepThisDeleteOthers } from '$lib/utils/asset-utils';
|
import { keepThisDeleteOthers } from '$lib/utils/asset-utils';
|
||||||
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import type { AssetResponseDto, StackResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto, StackResponseDto } from '@immich/sdk';
|
||||||
import { mdiPinOutline } from '@mdi/js';
|
import { mdiPinOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@ -29,7 +30,7 @@
|
|||||||
|
|
||||||
const keptAsset = await keepThisDeleteOthers(asset, stack);
|
const keptAsset = await keepThisDeleteOthers(asset, stack);
|
||||||
if (keptAsset) {
|
if (keptAsset) {
|
||||||
onAction({ type: AssetAction.UNSTACK, assets: [keptAsset] });
|
onAction({ type: AssetAction.UNSTACK, assets: [toTimelineAsset(keptAsset)] });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
} from '$lib/components/shared-components/notification/notification';
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { restoreAssets, type AssetResponseDto } from '@immich/sdk';
|
import { restoreAssets, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { mdiHistory } from '@mdi/js';
|
import { mdiHistory } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@ -23,7 +24,7 @@
|
|||||||
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
|
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
|
||||||
asset.isTrashed = false;
|
asset.isTrashed = false;
|
||||||
|
|
||||||
onAction({ type: AssetAction.RESTORE, asset });
|
onAction({ type: AssetAction.RESTORE, asset: toTimelineAsset(asset) });
|
||||||
|
|
||||||
notificationController.show({
|
notificationController.show({
|
||||||
type: NotificationType.Info,
|
type: NotificationType.Info,
|
||||||
|
@ -3,14 +3,15 @@
|
|||||||
|
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||||
|
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { AssetVisibility, updateAssets, Visibility, type AssetResponseDto } from '@immich/sdk';
|
import { AssetVisibility, updateAssets, Visibility } from '@immich/sdk';
|
||||||
import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js';
|
import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { OnAction, PreAction } from './action';
|
import type { OnAction, PreAction } from './action';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: TimelineAsset;
|
||||||
onAction: OnAction;
|
onAction: OnAction;
|
||||||
preAction: PreAction;
|
preAction: PreAction;
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import { deleteStack } from '$lib/utils/asset-utils';
|
import { deleteStack } from '$lib/utils/asset-utils';
|
||||||
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import type { StackResponseDto } from '@immich/sdk';
|
import type { StackResponseDto } from '@immich/sdk';
|
||||||
import { mdiImageMinusOutline } from '@mdi/js';
|
import { mdiImageMinusOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@ -17,7 +18,7 @@
|
|||||||
const handleUnstack = async () => {
|
const handleUnstack = async () => {
|
||||||
const unstackedAssets = await deleteStack([stack.id]);
|
const unstackedAssets = await deleteStack([stack.id]);
|
||||||
if (unstackedAssets) {
|
if (unstackedAssets) {
|
||||||
onAction({ type: AssetAction.UNSTACK, assets: unstackedAssets });
|
onAction({ type: AssetAction.UNSTACK, assets: unstackedAssets.map((asset) => toTimelineAsset(asset)) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -13,6 +13,7 @@ describe('AssetViewerNavBar component', () => {
|
|||||||
showDownloadButton: false,
|
showDownloadButton: false,
|
||||||
showMotionPlayButton: false,
|
showMotionPlayButton: false,
|
||||||
showShareButton: false,
|
showShareButton: false,
|
||||||
|
preAction: () => {},
|
||||||
onZoomImage: () => {},
|
onZoomImage: () => {},
|
||||||
onCopyImage: () => {},
|
onCopyImage: () => {},
|
||||||
onAction: () => {},
|
onAction: () => {},
|
||||||
|
@ -25,6 +25,7 @@
|
|||||||
import { getAssetJobName, getSharedLink } from '$lib/utils';
|
import { getAssetJobName, getSharedLink } from '$lib/utils';
|
||||||
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
|
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
|
||||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||||
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import {
|
import {
|
||||||
AssetJobName,
|
AssetJobName,
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
@ -138,7 +139,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !isOwner && showDownloadButton}
|
{#if !isOwner && showDownloadButton}
|
||||||
<DownloadAction {asset} />
|
<DownloadAction asset={toTimelineAsset(asset)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showDetailButton}
|
{#if showDetailButton}
|
||||||
@ -166,7 +167,7 @@
|
|||||||
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
|
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if showDownloadButton}
|
{#if showDownloadButton}
|
||||||
<DownloadAction {asset} menuItem />
|
<DownloadAction asset={toTimelineAsset(asset)} menuItem />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !isLocked}
|
{#if !isLocked}
|
||||||
@ -210,7 +211,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !asset.isTrashed}
|
{#if !asset.isTrashed}
|
||||||
<SetVisibilityAction {asset} {onAction} {preAction} />
|
<SetVisibilityAction asset={toTimelineAsset(asset)} {onAction} {preAction} />
|
||||||
{/if}
|
{/if}
|
||||||
<hr />
|
<hr />
|
||||||
<MenuOption
|
<MenuOption
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
|
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
|
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import { isShowDetail } from '$lib/stores/preferences.store';
|
import { isShowDetail } from '$lib/stores/preferences.store';
|
||||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
@ -17,6 +18,7 @@
|
|||||||
import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils';
|
import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
||||||
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import {
|
import {
|
||||||
AssetJobName,
|
AssetJobName,
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
@ -47,7 +49,7 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
preloadAssets?: AssetResponseDto[];
|
preloadAssets?: TimelineAsset[];
|
||||||
showNavigation?: boolean;
|
showNavigation?: boolean;
|
||||||
withStacked?: boolean;
|
withStacked?: boolean;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
@ -56,10 +58,10 @@
|
|||||||
preAction?: PreAction | undefined;
|
preAction?: PreAction | undefined;
|
||||||
onAction?: OnAction | undefined;
|
onAction?: OnAction | undefined;
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
onClose: (dto: { asset: AssetResponseDto }) => void;
|
onClose: (asset: AssetResponseDto) => void;
|
||||||
onNext: () => Promise<HasAsset>;
|
onNext: () => Promise<HasAsset>;
|
||||||
onPrevious: () => Promise<HasAsset>;
|
onPrevious: () => Promise<HasAsset>;
|
||||||
onRandom: () => Promise<AssetResponseDto | undefined>;
|
onRandom: () => Promise<{ id: string } | undefined>;
|
||||||
copyImage?: () => Promise<void>;
|
copyImage?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,7 +83,7 @@
|
|||||||
copyImage = $bindable(),
|
copyImage = $bindable(),
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const { setAsset } = assetViewingStore;
|
const { setAssetId } = assetViewingStore;
|
||||||
const {
|
const {
|
||||||
restartProgress: restartSlideshowProgress,
|
restartProgress: restartSlideshowProgress,
|
||||||
stopProgress: stopSlideshowProgress,
|
stopProgress: stopSlideshowProgress,
|
||||||
@ -121,7 +123,7 @@
|
|||||||
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
if (stack && stack?.assets.length > 1) {
|
if (stack && stack?.assets.length > 1) {
|
||||||
preloadAssets.push(stack.assets[1]);
|
preloadAssets.push(toTimelineAsset(stack.assets[1]));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -161,7 +163,7 @@
|
|||||||
slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
||||||
if (value === SlideshowState.PlaySlideshow) {
|
if (value === SlideshowState.PlaySlideshow) {
|
||||||
slideshowHistory.reset();
|
slideshowHistory.reset();
|
||||||
slideshowHistory.queue(asset);
|
slideshowHistory.queue(toTimelineAsset(asset));
|
||||||
handlePromiseError(handlePlaySlideshow());
|
handlePromiseError(handlePlaySlideshow());
|
||||||
} else if (value === SlideshowState.StopSlideshow) {
|
} else if (value === SlideshowState.StopSlideshow) {
|
||||||
handlePromiseError(handleStopSlideshow());
|
handlePromiseError(handleStopSlideshow());
|
||||||
@ -171,7 +173,7 @@
|
|||||||
shuffleSlideshowUnsubscribe = slideshowNavigation.subscribe((value) => {
|
shuffleSlideshowUnsubscribe = slideshowNavigation.subscribe((value) => {
|
||||||
if (value === SlideshowNavigation.Shuffle) {
|
if (value === SlideshowNavigation.Shuffle) {
|
||||||
slideshowHistory.reset();
|
slideshowHistory.reset();
|
||||||
slideshowHistory.queue(asset);
|
slideshowHistory.queue(toTimelineAsset(asset));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -225,7 +227,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const closeViewer = () => {
|
const closeViewer = () => {
|
||||||
onClose({ asset });
|
onClose(asset);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeEditor = () => {
|
const closeEditor = () => {
|
||||||
@ -292,8 +294,7 @@
|
|||||||
let assetViewerHtmlElement = $state<HTMLElement>();
|
let assetViewerHtmlElement = $state<HTMLElement>();
|
||||||
|
|
||||||
const slideshowHistory = new SlideshowHistory((asset) => {
|
const slideshowHistory = new SlideshowHistory((asset) => {
|
||||||
setAsset(asset);
|
handlePromiseError(setAssetId(asset.id).then(() => ($restartSlideshowProgress = true)));
|
||||||
$restartSlideshowProgress = true;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleVideoStarted = () => {
|
const handleVideoStarted = () => {
|
||||||
@ -563,8 +564,8 @@
|
|||||||
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
|
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
|
||||||
brokenAssetClass="text-xs"
|
brokenAssetClass="text-xs"
|
||||||
dimmed={stackedAsset.id !== asset.id}
|
dimmed={stackedAsset.id !== asset.id}
|
||||||
asset={stackedAsset}
|
asset={toTimelineAsset(stackedAsset)}
|
||||||
onClick={(stackedAsset) => {
|
onClick={() => {
|
||||||
asset = stackedAsset;
|
asset = stackedAsset;
|
||||||
}}
|
}}
|
||||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||||
|
@ -12,6 +12,7 @@
|
|||||||
resetGlobalCropStore,
|
resetGlobalCropStore,
|
||||||
rotateDegrees,
|
rotateDegrees,
|
||||||
} from '$lib/stores/asset-editor.store';
|
} from '$lib/stores/asset-editor.store';
|
||||||
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
import { animateCropChange, recalculateCrop } from './crop-settings';
|
import { animateCropChange, recalculateCrop } from './crop-settings';
|
||||||
import { cropAreaEl, cropFrame, imgElement, isResizingOrDragging, overlayEl, resetCropStore } from './crop-store';
|
import { cropAreaEl, cropFrame, imgElement, isResizingOrDragging, overlayEl, resetCropStore } from './crop-store';
|
||||||
@ -81,7 +82,7 @@
|
|||||||
aria-label="Crop area"
|
aria-label="Crop area"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<img draggable="false" src={img?.src} alt={$getAltText(asset)} />
|
<img draggable="false" src={img?.src} alt={$getAltText(toTimelineAsset(asset))} />
|
||||||
<div class={`${$isResizingOrDragging ? 'resizing' : ''} crop-frame`} bind:this={$cropFrame}>
|
<div class={`${$isResizingOrDragging ? 'resizing' : ''} crop-frame`} bind:this={$cropFrame}>
|
||||||
<div class="grid"></div>
|
<div class="grid"></div>
|
||||||
<div class="corner top-left"></div>
|
<div class="corner top-left"></div>
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
import { zoomImageAction, zoomed } from '$lib/actions/zoom-image';
|
import { zoomImageAction, zoomed } from '$lib/actions/zoom-image';
|
||||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||||
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
import { photoViewerImgElement, type TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
||||||
@ -13,9 +13,10 @@
|
|||||||
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
|
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||||
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
|
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
|
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { swipe, type SwipeCustomEvent } from 'svelte-gestures';
|
import { swipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@ -25,7 +26,7 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
preloadAssets?: AssetResponseDto[] | undefined;
|
preloadAssets?: TimelineAsset[] | undefined;
|
||||||
element?: HTMLDivElement | undefined;
|
element?: HTMLDivElement | undefined;
|
||||||
haveFadeTransition?: boolean;
|
haveFadeTransition?: boolean;
|
||||||
sharedLink?: SharedLinkResponseDto | undefined;
|
sharedLink?: SharedLinkResponseDto | undefined;
|
||||||
@ -69,10 +70,11 @@
|
|||||||
$boundingBoxesArray = [];
|
$boundingBoxesArray = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: AssetResponseDto[]) => {
|
const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: TimelineAsset[]) => {
|
||||||
for (const preloadAsset of preloadAssets || []) {
|
for (const preloadAsset of preloadAssets || []) {
|
||||||
if (preloadAsset.type === AssetTypeEnum.Image) {
|
if (preloadAsset.isImage) {
|
||||||
preloadImageUrl(getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash));
|
let img = new Image();
|
||||||
|
img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -197,7 +199,7 @@
|
|||||||
bind:clientWidth={containerWidth}
|
bind:clientWidth={containerWidth}
|
||||||
bind:clientHeight={containerHeight}
|
bind:clientHeight={containerHeight}
|
||||||
>
|
>
|
||||||
<img style="display:none" src={imageLoaderUrl} alt={$getAltText(asset)} {onload} {onerror} />
|
<img style="display:none" src={imageLoaderUrl} alt="" {onload} {onerror} />
|
||||||
{#if !imageLoaded}
|
{#if !imageLoaded}
|
||||||
<div id="spinner" class="flex h-full items-center justify-center">
|
<div id="spinner" class="flex h-full items-center justify-center">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
@ -213,7 +215,7 @@
|
|||||||
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
||||||
<img
|
<img
|
||||||
src={assetFileUrl}
|
src={assetFileUrl}
|
||||||
alt={$getAltText(asset)}
|
alt=""
|
||||||
class="absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
class="absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
@ -221,7 +223,7 @@
|
|||||||
<img
|
<img
|
||||||
bind:this={$photoViewerImgElement}
|
bind:this={$photoViewerImgElement}
|
||||||
src={assetFileUrl}
|
src={assetFileUrl}
|
||||||
alt={$getAltText(asset)}
|
alt={$getAltText(toTimelineAsset(asset))}
|
||||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||||
? 'object-contain'
|
? 'object-contain'
|
||||||
: slideshowLookCssMapping[$slideshowLook]}"
|
: slideshowLookCssMapping[$slideshowLook]}"
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
||||||
import { timeToSeconds } from '$lib/utils/date-time';
|
import { timeToSeconds } from '$lib/utils/date-time';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
import { AssetMediaSize, Visibility } from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
mdiArchiveArrowDownOutline,
|
mdiArchiveArrowDownOutline,
|
||||||
mdiCameraBurst,
|
mdiCameraBurst,
|
||||||
@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
import { thumbhash } from '$lib/actions/thumbhash';
|
import { thumbhash } from '$lib/actions/thumbhash';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||||
import { focusNext } from '$lib/utils/focus-util';
|
import { focusNext } from '$lib/utils/focus-util';
|
||||||
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
|
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
|
||||||
@ -29,11 +30,11 @@
|
|||||||
import VideoThumbnail from './video-thumbnail.svelte';
|
import VideoThumbnail from './video-thumbnail.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: TimelineAsset;
|
||||||
groupIndex?: number;
|
groupIndex?: number;
|
||||||
thumbnailSize?: number | undefined;
|
thumbnailSize?: number;
|
||||||
thumbnailWidth?: number | undefined;
|
thumbnailWidth?: number;
|
||||||
thumbnailHeight?: number | undefined;
|
thumbnailHeight?: number;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
selectionCandidate?: boolean;
|
selectionCandidate?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@ -44,10 +45,10 @@
|
|||||||
imageClass?: ClassValue;
|
imageClass?: ClassValue;
|
||||||
brokenAssetClass?: ClassValue;
|
brokenAssetClass?: ClassValue;
|
||||||
dimmed?: boolean;
|
dimmed?: boolean;
|
||||||
onClick?: ((asset: AssetResponseDto) => void) | undefined;
|
onClick?: (asset: TimelineAsset) => void;
|
||||||
onSelect?: ((asset: AssetResponseDto) => void) | undefined;
|
onSelect?: (asset: TimelineAsset) => void;
|
||||||
onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined;
|
onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void;
|
||||||
handleFocus?: (() => void) | undefined;
|
handleFocus?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@ -290,13 +291,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !authManager.key && showArchiveIcon && asset.isArchived}
|
{#if !authManager.key && showArchiveIcon && asset.visibility === Visibility.Archive}
|
||||||
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
|
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
|
||||||
<Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" />
|
<Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if asset.type === AssetTypeEnum.Image && asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR}
|
{#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR}
|
||||||
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||||
<span class="pe-2 pt-2">
|
<span class="pe-2 pt-2">
|
||||||
<Icon path={mdiRotate360} size="24" />
|
<Icon path={mdiRotate360} size="24" />
|
||||||
@ -309,7 +310,7 @@
|
|||||||
<div
|
<div
|
||||||
class={[
|
class={[
|
||||||
'absolute flex place-items-center gap-1 text-xs font-medium text-white',
|
'absolute flex place-items-center gap-1 text-xs font-medium text-white',
|
||||||
asset.type == AssetTypeEnum.Image && !asset.livePhotoVideoId ? 'top-0 end-0' : 'top-7 end-1',
|
asset.isImage && !asset.livePhotoVideoId ? 'top-0 end-0' : 'top-7 end-1',
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<span class="pe-2 pt-2 flex place-items-center gap-1">
|
<span class="pe-2 pt-2 flex place-items-center gap-1">
|
||||||
@ -329,17 +330,17 @@
|
|||||||
curve={selected}
|
curve={selected}
|
||||||
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
|
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
|
||||||
/>
|
/>
|
||||||
{#if asset.type === AssetTypeEnum.Video}
|
{#if asset.isVideo}
|
||||||
<div class="absolute top-0 h-full w-full">
|
<div class="absolute top-0 h-full w-full">
|
||||||
<VideoThumbnail
|
<VideoThumbnail
|
||||||
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
|
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
|
||||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||||
curve={selected}
|
curve={selected}
|
||||||
durationInSeconds={timeToSeconds(asset.duration)}
|
durationInSeconds={timeToSeconds(asset.duration!)}
|
||||||
playbackOnIconHover={!$playVideoThumbnailOnHover}
|
playbackOnIconHover={!$playVideoThumbnailOnHover}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
|
{:else if asset.isImage && asset.livePhotoVideoId}
|
||||||
<div class="absolute top-0 h-full w-full">
|
<div class="absolute top-0 h-full w-full">
|
||||||
<VideoThumbnail
|
<VideoThumbnail
|
||||||
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
|
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
|
||||||
|
@ -25,16 +25,18 @@
|
|||||||
NotificationType,
|
NotificationType,
|
||||||
} from '$lib/components/shared-components/notification/notification';
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||||
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { type Viewport } from '$lib/stores/assets-store.svelte';
|
import { type TimelineAsset, type Viewport } from '$lib/stores/assets-store.svelte';
|
||||||
import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte';
|
import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte';
|
||||||
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
import { getAssetPlaybackUrl, getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
import { getAssetPlaybackUrl, getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
||||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||||
import { fromLocalDateTime } from '$lib/utils/timeline-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
import { AssetMediaSize, type AssetResponseDto, AssetTypeEnum } from '@immich/sdk';
|
import { fromLocalDateTime, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
|
import { AssetMediaSize, getAssetInfo } from '@immich/sdk';
|
||||||
import { IconButton } from '@immich/ui';
|
import { IconButton } from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
mdiCardsOutline,
|
mdiCardsOutline,
|
||||||
@ -67,6 +69,11 @@
|
|||||||
let playerInitialized = $state(false);
|
let playerInitialized = $state(false);
|
||||||
let paused = $state(false);
|
let paused = $state(false);
|
||||||
let current = $state<MemoryAsset | undefined>(undefined);
|
let current = $state<MemoryAsset | undefined>(undefined);
|
||||||
|
let currentMemoryAssetFull = $derived.by(async () =>
|
||||||
|
current?.asset ? await getAssetInfo({ id: current.asset.id, key: authManager.key }) : undefined,
|
||||||
|
);
|
||||||
|
let currentTimelineAssets = $derived(current?.memory.assets.map((asset) => toTimelineAsset(asset)) || []);
|
||||||
|
|
||||||
let isSaved = $derived(current?.memory.isSaved);
|
let isSaved = $derived(current?.memory.isSaved);
|
||||||
let viewerHeight = $state(0);
|
let viewerHeight = $state(0);
|
||||||
|
|
||||||
@ -77,8 +84,8 @@
|
|||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
let progressBarController: Tween<number> | undefined = $state(undefined);
|
let progressBarController: Tween<number> | undefined = $state(undefined);
|
||||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||||
const asHref = (asset: AssetResponseDto) => `?${QueryParameter.ID}=${asset.id}`;
|
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
|
||||||
const handleNavigate = async (asset?: AssetResponseDto) => {
|
const handleNavigate = async (asset?: { id: string }) => {
|
||||||
if ($isViewing) {
|
if ($isViewing) {
|
||||||
return asset;
|
return asset;
|
||||||
}
|
}
|
||||||
@ -89,9 +96,9 @@
|
|||||||
|
|
||||||
await goto(asHref(asset));
|
await goto(asHref(asset));
|
||||||
};
|
};
|
||||||
const setProgressDuration = (asset: AssetResponseDto) => {
|
const setProgressDuration = (asset: TimelineAsset) => {
|
||||||
if (asset.type === AssetTypeEnum.Video) {
|
if (asset.isVideo) {
|
||||||
const timeParts = asset.duration.split(':').map(Number);
|
const timeParts = asset.duration!.split(':').map(Number);
|
||||||
const durationInMilliseconds = (timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2]) * 1000;
|
const durationInMilliseconds = (timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2]) * 1000;
|
||||||
progressBarController = new Tween<number>(0, {
|
progressBarController = new Tween<number>(0, {
|
||||||
duration: (from: number, to: number) => (to ? durationInMilliseconds * (to - from) : 0),
|
duration: (from: number, to: number) => (to ? durationInMilliseconds * (to - from) : 0),
|
||||||
@ -107,7 +114,8 @@
|
|||||||
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
|
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
|
||||||
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
|
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
|
||||||
const handleEscape = async () => goto(AppRoute.PHOTOS);
|
const handleEscape = async () => goto(AppRoute.PHOTOS);
|
||||||
const handleSelectAll = () => assetInteraction.selectAssets(current?.memory.assets || []);
|
const handleSelectAll = () =>
|
||||||
|
assetInteraction.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
|
||||||
const handleAction = async (callingContext: string, action: 'reset' | 'pause' | 'play') => {
|
const handleAction = async (callingContext: string, action: 'reset' | 'pause' | 'play') => {
|
||||||
// leaving these log statements here as comments. Very useful to figure out what's going on during dev!
|
// leaving these log statements here as comments. Very useful to figure out what's going on during dev!
|
||||||
// console.log(`handleAction[${callingContext}] called with: ${action}`);
|
// console.log(`handleAction[${callingContext}] called with: ${action}`);
|
||||||
@ -240,7 +248,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const initPlayer = () => {
|
const initPlayer = () => {
|
||||||
const isVideoAssetButPlayerHasNotLoadedYet = current && current.asset.type === AssetTypeEnum.Video && !videoPlayer;
|
const isVideoAssetButPlayerHasNotLoadedYet = current && current.asset.isVideo && !videoPlayer;
|
||||||
if (playerInitialized || isVideoAssetButPlayerHasNotLoadedYet) {
|
if (playerInitialized || isVideoAssetButPlayerHasNotLoadedYet) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -441,7 +449,7 @@
|
|||||||
<div class="relative h-full w-full rounded-2xl bg-black">
|
<div class="relative h-full w-full rounded-2xl bg-black">
|
||||||
{#key current.asset.id}
|
{#key current.asset.id}
|
||||||
<div transition:fade class="h-full w-full">
|
<div transition:fade class="h-full w-full">
|
||||||
{#if current.asset.type === AssetTypeEnum.Video}
|
{#if current.asset.isVideo}
|
||||||
<video
|
<video
|
||||||
bind:this={videoPlayer}
|
bind:this={videoPlayer}
|
||||||
autoplay
|
autoplay
|
||||||
@ -458,7 +466,7 @@
|
|||||||
<img
|
<img
|
||||||
class="h-full w-full rounded-2xl object-contain transition-all"
|
class="h-full w-full rounded-2xl object-contain transition-all"
|
||||||
src={getAssetThumbnailUrl({ id: current.asset.id, size: AssetMediaSize.Preview })}
|
src={getAssetThumbnailUrl({ id: current.asset.id, size: AssetMediaSize.Preview })}
|
||||||
alt={current.asset.exifInfo?.description}
|
alt={$getAltText(current.asset)}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
transition:fade
|
transition:fade
|
||||||
/>
|
/>
|
||||||
@ -551,8 +559,10 @@
|
|||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{current.asset.exifInfo?.city || ''}
|
{#await currentMemoryAssetFull then asset}
|
||||||
{current.asset.exifInfo?.country || ''}
|
{asset?.exifInfo?.city || ''}
|
||||||
|
{asset?.exifInfo?.country || ''}
|
||||||
|
{/await}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -623,7 +633,7 @@
|
|||||||
<GalleryViewer
|
<GalleryViewer
|
||||||
onNext={handleNextAsset}
|
onNext={handleNextAsset}
|
||||||
onPrevious={handlePreviousAsset}
|
onPrevious={handlePreviousAsset}
|
||||||
assets={current.memory.assets}
|
assets={currentTimelineAssets}
|
||||||
viewport={galleryViewport}
|
viewport={galleryViewport}
|
||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
slidingWindowOffset={viewerHeight}
|
slidingWindowOffset={viewerHeight}
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
import type { OnArchive } from '$lib/utils/actions';
|
import type { OnArchive } from '$lib/utils/actions';
|
||||||
|
import { archiveAssets } from '$lib/utils/asset-utils';
|
||||||
|
import { AssetVisibility, Visibility } from '@immich/sdk';
|
||||||
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js';
|
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||||
import { archiveAssets } from '$lib/utils/asset-utils';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onArchive?: OnArchive;
|
onArchive?: OnArchive;
|
||||||
@ -23,10 +24,10 @@
|
|||||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||||
|
|
||||||
const handleArchive = async () => {
|
const handleArchive = async () => {
|
||||||
const isArchived = !unarchive;
|
const isArchived = unarchive ? Visibility.Timeline : Visibility.Archive;
|
||||||
const assets = [...getOwnedAssets()].filter((asset) => asset.isArchived !== isArchived);
|
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== isArchived);
|
||||||
loading = true;
|
loading = true;
|
||||||
const ids = await archiveAssets(assets, isArchived);
|
const ids = await archiveAssets(assets, isArchived as unknown as AssetVisibility);
|
||||||
if (ids) {
|
if (ids) {
|
||||||
onArchive?.(ids, isArchived);
|
onArchive?.(ids, isArchived);
|
||||||
clearSelect();
|
clearSelect();
|
||||||
|
@ -6,9 +6,9 @@
|
|||||||
} from '$lib/components/shared-components/notification/notification';
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
import { getAssetJobIcon, getAssetJobMessage, getAssetJobName } from '$lib/utils';
|
import { getAssetJobIcon, getAssetJobMessage, getAssetJobName } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { AssetJobName, AssetTypeEnum, runAssetJobs } from '@immich/sdk';
|
import { AssetJobName, runAssetJobs } from '@immich/sdk';
|
||||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
jobs?: AssetJobName[];
|
jobs?: AssetJobName[];
|
||||||
@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||||
|
|
||||||
let isAllVideos = $derived([...getOwnedAssets()].every((asset) => asset.type === AssetTypeEnum.Video));
|
const isAllVideos = $derived([...getOwnedAssets()].every((asset) => asset.isVideo));
|
||||||
|
|
||||||
const handleRunJob = async (name: AssetJobName) => {
|
const handleRunJob = async (name: AssetJobName) => {
|
||||||
try {
|
try {
|
||||||
|
@ -4,11 +4,11 @@
|
|||||||
import { getSelectedAssets } from '$lib/utils/asset-utils';
|
import { getSelectedAssets } from '$lib/utils/asset-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { updateAssets } from '@immich/sdk';
|
import { updateAssets } from '@immich/sdk';
|
||||||
|
import { mdiCalendarEditOutline } from '@mdi/js';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||||
import { mdiCalendarEditOutline } from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
interface Props {
|
interface Props {
|
||||||
menuItem?: boolean;
|
menuItem?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
|
||||||
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
|
|
||||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
|
||||||
import { shortcut } from '$lib/actions/shortcut';
|
import { shortcut } from '$lib/actions/shortcut';
|
||||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
|
|
||||||
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
|
||||||
|
import { getAssetInfo } from '@immich/sdk';
|
||||||
import { mdiCloudDownloadOutline, mdiFileDownloadOutline, mdiFolderDownloadOutline } from '@mdi/js';
|
import { mdiCloudDownloadOutline, mdiFileDownloadOutline, mdiFolderDownloadOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||||
|
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
filename?: string;
|
filename?: string;
|
||||||
@ -20,7 +23,8 @@
|
|||||||
const assets = [...getAssets()];
|
const assets = [...getAssets()];
|
||||||
if (assets.length === 1) {
|
if (assets.length === 1) {
|
||||||
clearSelect();
|
clearSelect();
|
||||||
await downloadFile(assets[0]);
|
let asset = await getAssetInfo({ id: assets[0].id, key: authManager.key });
|
||||||
|
await downloadFile(asset);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,12 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
|
import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||||
|
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
|
|
||||||
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import type { OnLink, OnUnlink } from '$lib/utils/actions';
|
import type { OnLink, OnUnlink } from '$lib/utils/actions';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { AssetTypeEnum, getAssetInfo, updateAsset } from '@immich/sdk';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
|
import { getAssetInfo, updateAsset } from '@immich/sdk';
|
||||||
import { mdiLinkOff, mdiMotionPlayOutline, mdiTimerSand } from '@mdi/js';
|
import { mdiLinkOff, mdiMotionPlayOutline, mdiTimerSand } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onLink: OnLink;
|
onLink: OnLink;
|
||||||
@ -28,14 +32,14 @@
|
|||||||
|
|
||||||
const handleLink = async () => {
|
const handleLink = async () => {
|
||||||
let [still, motion] = [...getOwnedAssets()];
|
let [still, motion] = [...getOwnedAssets()];
|
||||||
if (still.type === AssetTypeEnum.Video) {
|
if ((still as TimelineAsset).isVideo) {
|
||||||
[still, motion] = [motion, still];
|
[still, motion] = [motion, still];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading = true;
|
loading = true;
|
||||||
const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: motion.id } });
|
const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: motion.id } });
|
||||||
onLink({ still: stillResponse, motion });
|
onLink({ still: toTimelineAsset(stillResponse), motion: motion as TimelineAsset });
|
||||||
clearSelect();
|
clearSelect();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_link_motion_video'));
|
handleError(error, $t('errors.unable_to_link_motion_video'));
|
||||||
@ -46,17 +50,18 @@
|
|||||||
|
|
||||||
const handleUnlink = async () => {
|
const handleUnlink = async () => {
|
||||||
const [still] = [...getOwnedAssets()];
|
const [still] = [...getOwnedAssets()];
|
||||||
|
if (!still) {
|
||||||
const motionId = still?.livePhotoVideoId;
|
return;
|
||||||
|
}
|
||||||
|
const motionId = still.livePhotoVideoId;
|
||||||
if (!motionId) {
|
if (!motionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading = true;
|
loading = true;
|
||||||
const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: null } });
|
const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: null } });
|
||||||
const motionResponse = await getAssetInfo({ id: motionId });
|
const motionResponse = await getAssetInfo({ id: motionId, key: authManager.key });
|
||||||
onUnlink({ still: stillResponse, motion: motionResponse });
|
onUnlink({ still: toTimelineAsset(stillResponse), motion: toTimelineAsset(motionResponse) });
|
||||||
clearSelect();
|
clearSelect();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_unlink_motion_video'));
|
handleError(error, $t('errors.unable_to_unlink_motion_video'));
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
|
||||||
import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||||
import { mdiImageMinusOutline, mdiImageMultipleOutline } from '@mdi/js';
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
import { stackAssets, deleteStack } from '$lib/utils/asset-utils';
|
|
||||||
import type { OnStack, OnUnstack } from '$lib/utils/actions';
|
import type { OnStack, OnUnstack } from '$lib/utils/actions';
|
||||||
|
import { deleteStack, stackAssets } from '$lib/utils/asset-utils';
|
||||||
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
|
import { mdiImageMinusOutline, mdiImageMultipleOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -34,7 +35,7 @@
|
|||||||
}
|
}
|
||||||
const unstackedAssets = await deleteStack([stack.id]);
|
const unstackedAssets = await deleteStack([stack.id]);
|
||||||
if (unstackedAssets) {
|
if (unstackedAssets) {
|
||||||
onUnstack?.(unstackedAssets);
|
onUnstack?.(unstackedAssets.map((a) => toTimelineAsset(a)));
|
||||||
}
|
}
|
||||||
clearSelect();
|
clearSelect();
|
||||||
};
|
};
|
||||||
|
@ -7,10 +7,11 @@
|
|||||||
assetsSnapshot,
|
assetsSnapshot,
|
||||||
type AssetStore,
|
type AssetStore,
|
||||||
isSelectingAllAssets,
|
isSelectingAllAssets,
|
||||||
|
type TimelineAsset,
|
||||||
} from '$lib/stores/assets-store.svelte';
|
} from '$lib/stores/assets-store.svelte';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import { getDateLocaleString } from '$lib/utils/timeline-util';
|
import { getDateLocaleString } from '$lib/utils/timeline-util';
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
|
||||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||||
import { fly, scale } from 'svelte/transition';
|
import { fly, scale } from 'svelte/transition';
|
||||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||||
@ -30,9 +31,9 @@
|
|||||||
assetStore: AssetStore;
|
assetStore: AssetStore;
|
||||||
assetInteraction: AssetInteraction;
|
assetInteraction: AssetInteraction;
|
||||||
|
|
||||||
onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void;
|
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
|
||||||
onSelectAssets: (asset: AssetResponseDto) => void;
|
onSelectAssets: (asset: TimelineAsset) => void;
|
||||||
onSelectAssetCandidates: (asset: AssetResponseDto | null) => void;
|
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@ -53,7 +54,7 @@
|
|||||||
|
|
||||||
const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150));
|
const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150));
|
||||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||||
const onClick = (assetStore: AssetStore, assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => {
|
const onClick = (assetStore: AssetStore, assets: TimelineAsset[], groupTitle: string, asset: TimelineAsset) => {
|
||||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||||
assetSelectHandler(assetStore, asset, assets, groupTitle);
|
assetSelectHandler(assetStore, asset, assets, groupTitle);
|
||||||
return;
|
return;
|
||||||
@ -61,12 +62,12 @@
|
|||||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets });
|
const handleSelectGroup = (title: string, assets: TimelineAsset[]) => onSelect({ title, assets });
|
||||||
|
|
||||||
const assetSelectHandler = (
|
const assetSelectHandler = (
|
||||||
assetStore: AssetStore,
|
assetStore: AssetStore,
|
||||||
asset: AssetResponseDto,
|
asset: TimelineAsset,
|
||||||
assetsInDateGroup: AssetResponseDto[],
|
assetsInDateGroup: TimelineAsset[],
|
||||||
groupTitle: string,
|
groupTitle: string,
|
||||||
) => {
|
) => {
|
||||||
onSelectAssets(asset);
|
onSelectAssets(asset);
|
||||||
@ -90,7 +91,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => {
|
const assetMouseEventHandler = (groupTitle: string, asset: TimelineAsset | null) => {
|
||||||
// Show multi select icon on hover on date group
|
// Show multi select icon on hover on date group
|
||||||
hoveredDateGroup = groupTitle;
|
hoveredDateGroup = groupTitle;
|
||||||
|
|
||||||
|
@ -8,11 +8,18 @@
|
|||||||
import Scrubber from '$lib/components/shared-components/scrubber/scrubber.svelte';
|
import Scrubber from '$lib/components/shared-components/scrubber/scrubber.svelte';
|
||||||
import { AppRoute, AssetAction } from '$lib/constants';
|
import { AppRoute, AssetAction } from '$lib/constants';
|
||||||
import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
|
import { albumMapViewManager } from '$lib/managers/album-view-map.manager.svelte';
|
||||||
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
import {
|
||||||
|
AssetBucket,
|
||||||
|
assetsSnapshot,
|
||||||
|
AssetStore,
|
||||||
|
isSelectingAllAssets,
|
||||||
|
type TimelineAsset,
|
||||||
|
} from '$lib/stores/assets-store.svelte';
|
||||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||||
import { searchStore } from '$lib/stores/search.svelte';
|
import { searchStore } from '$lib/stores/search.svelte';
|
||||||
@ -23,7 +30,7 @@
|
|||||||
import { focusNext } from '$lib/utils/focus-util';
|
import { focusNext } from '$lib/utils/focus-util';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import { type ScrubberListener } from '$lib/utils/timeline-util';
|
import { type ScrubberListener } from '$lib/utils/timeline-util';
|
||||||
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
|
import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||||
import { onMount, type Snippet } from 'svelte';
|
import { onMount, type Snippet } from 'svelte';
|
||||||
import type { UpdatePayload } from 'vite';
|
import type { UpdatePayload } from 'vite';
|
||||||
import Portal from '../shared-components/portal/portal.svelte';
|
import Portal from '../shared-components/portal/portal.svelte';
|
||||||
@ -52,7 +59,7 @@
|
|||||||
album?: AlbumResponseDto | null;
|
album?: AlbumResponseDto | null;
|
||||||
person?: PersonResponseDto | null;
|
person?: PersonResponseDto | null;
|
||||||
isShowDeleteConfirmation?: boolean;
|
isShowDeleteConfirmation?: boolean;
|
||||||
onSelect?: (asset: AssetResponseDto) => void;
|
onSelect?: (asset: TimelineAsset) => void;
|
||||||
onEscape?: () => void;
|
onEscape?: () => void;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
empty?: Snippet;
|
empty?: Snippet;
|
||||||
@ -358,7 +365,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleArchive = async () => {
|
const toggleArchive = async () => {
|
||||||
await archiveAssets(assetInteraction.selectedAssets, !assetInteraction.isAllArchived);
|
await archiveAssets(
|
||||||
|
assetInteraction.selectedAssets,
|
||||||
|
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
|
||||||
|
);
|
||||||
assetStore.updateAssets(assetInteraction.selectedAssets);
|
assetStore.updateAssets(assetInteraction.selectedAssets);
|
||||||
deselectAllAssets();
|
deselectAllAssets();
|
||||||
};
|
};
|
||||||
@ -369,7 +379,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAsset = (asset: AssetResponseDto) => {
|
const handleSelectAsset = (asset: TimelineAsset) => {
|
||||||
if (!assetStore.albumAssets.has(asset.id)) {
|
if (!assetStore.albumAssets.has(asset.id)) {
|
||||||
assetInteraction.selectAsset(asset);
|
assetInteraction.selectAsset(asset);
|
||||||
}
|
}
|
||||||
@ -380,7 +390,8 @@
|
|||||||
|
|
||||||
if (previousAsset) {
|
if (previousAsset) {
|
||||||
const preloadAsset = await assetStore.getPreviousAsset(previousAsset);
|
const preloadAsset = await assetStore.getPreviousAsset(previousAsset);
|
||||||
assetViewingStore.setAsset(previousAsset, preloadAsset ? [preloadAsset] : []);
|
const asset = await getAssetInfo({ id: previousAsset.id, key: authManager.key });
|
||||||
|
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
||||||
await navigate({ targetRoute: 'current', assetId: previousAsset.id });
|
await navigate({ targetRoute: 'current', assetId: previousAsset.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -391,7 +402,8 @@
|
|||||||
const nextAsset = await assetStore.getNextAsset($viewingAsset);
|
const nextAsset = await assetStore.getNextAsset($viewingAsset);
|
||||||
if (nextAsset) {
|
if (nextAsset) {
|
||||||
const preloadAsset = await assetStore.getNextAsset(nextAsset);
|
const preloadAsset = await assetStore.getNextAsset(nextAsset);
|
||||||
assetViewingStore.setAsset(nextAsset, preloadAsset ? [preloadAsset] : []);
|
const asset = await getAssetInfo({ id: nextAsset.id, key: authManager.key });
|
||||||
|
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
||||||
await navigate({ targetRoute: 'current', assetId: nextAsset.id });
|
await navigate({ targetRoute: 'current', assetId: nextAsset.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -403,14 +415,14 @@
|
|||||||
|
|
||||||
if (randomAsset) {
|
if (randomAsset) {
|
||||||
const preloadAsset = await assetStore.getNextAsset(randomAsset);
|
const preloadAsset = await assetStore.getNextAsset(randomAsset);
|
||||||
assetViewingStore.setAsset(randomAsset, preloadAsset ? [preloadAsset] : []);
|
const asset = await getAssetInfo({ id: randomAsset.id, key: authManager.key });
|
||||||
|
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
||||||
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
|
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
|
||||||
|
return asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
return randomAsset;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = async ({ asset }: { asset: AssetResponseDto }) => {
|
const handleClose = async (asset: { id: string }) => {
|
||||||
assetViewingStore.showAssetViewer(false);
|
assetViewingStore.showAssetViewer(false);
|
||||||
showSkeleton = true;
|
showSkeleton = true;
|
||||||
$gridScrollTarget = { at: asset.id };
|
$gridScrollTarget = { at: asset.id };
|
||||||
@ -428,7 +440,7 @@
|
|||||||
case AssetAction.SET_VISIBILITY_TIMELINE: {
|
case AssetAction.SET_VISIBILITY_TIMELINE: {
|
||||||
// find the next asset to show or close the viewer
|
// find the next asset to show or close the viewer
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
(await handleNext()) || (await handlePrevious()) || (await handleClose({ asset: action.asset }));
|
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
|
||||||
|
|
||||||
// delete after find the next one
|
// delete after find the next one
|
||||||
assetStore.removeAssets([action.asset.id]);
|
assetStore.removeAssets([action.asset.id]);
|
||||||
@ -458,7 +470,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let lastAssetMouseEvent: AssetResponseDto | null = $state(null);
|
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
|
||||||
|
|
||||||
let shiftKeyIsDown = $state(false);
|
let shiftKeyIsDown = $state(false);
|
||||||
|
|
||||||
@ -488,14 +500,14 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => {
|
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
|
||||||
if (asset) {
|
if (asset) {
|
||||||
selectAssetCandidates(asset);
|
selectAssetCandidates(asset);
|
||||||
}
|
}
|
||||||
lastAssetMouseEvent = asset;
|
lastAssetMouseEvent = asset;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGroupSelect = (assetStore: AssetStore, group: string, assets: AssetResponseDto[]) => {
|
const handleGroupSelect = (assetStore: AssetStore, group: string, assets: TimelineAsset[]) => {
|
||||||
if (assetInteraction.selectedGroup.has(group)) {
|
if (assetInteraction.selectedGroup.has(group)) {
|
||||||
assetInteraction.removeGroupFromMultiselectGroup(group);
|
assetInteraction.removeGroupFromMultiselectGroup(group);
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
@ -515,7 +527,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAssets = async (asset: AssetResponseDto) => {
|
const handleSelectAssets = async (asset: TimelineAsset) => {
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -598,7 +610,7 @@
|
|||||||
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
|
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectAssetCandidates = (endAsset: AssetResponseDto) => {
|
const selectAssetCandidates = (endAsset: TimelineAsset) => {
|
||||||
if (!shiftKeyIsDown) {
|
if (!shiftKeyIsDown) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
export interface AssetControlContext {
|
export interface AssetControlContext {
|
||||||
// Wrap assets in a function, because context isn't reactive.
|
// Wrap assets in a function, because context isn't reactive.
|
||||||
getAssets: () => AssetResponseDto[]; // All assets includes partners' assets
|
getAssets: () => TimelineAsset[]; // All assets includes partners' assets
|
||||||
getOwnedAssets: () => AssetResponseDto[]; // Only assets owned by the user
|
getOwnedAssets: () => TimelineAsset[]; // Only assets owned by the user
|
||||||
clearSelect: () => void;
|
clearSelect: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -14,13 +14,13 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import { mdiClose } from '@mdi/js';
|
import { mdiClose } from '@mdi/js';
|
||||||
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assets: AssetResponseDto[];
|
assets: TimelineAsset[];
|
||||||
clearSelect: () => void;
|
clearSelect: () => void;
|
||||||
ownerId?: string | undefined;
|
ownerId?: string | undefined;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
import { memoryStore } from '$lib/stores/memory.store.svelte';
|
import { memoryStore } from '$lib/stores/memory.store.svelte';
|
||||||
import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils';
|
import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@ -82,7 +83,7 @@
|
|||||||
<img
|
<img
|
||||||
class="h-full w-full rounded-xl object-cover"
|
class="h-full w-full rounded-xl object-cover"
|
||||||
src={getAssetThumbnailUrl(memory.assets[0].id)}
|
src={getAssetThumbnailUrl(memory.assets[0].id)}
|
||||||
alt={$t('memory_lane_title', { values: { title: $getAltText(memory.assets[0]) } })}
|
alt={$t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } })}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
@ -11,7 +11,8 @@
|
|||||||
import { cancelMultiselect, downloadArchive } from '$lib/utils/asset-utils';
|
import { cancelMultiselect, downloadArchive } from '$lib/utils/asset-utils';
|
||||||
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
|
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { addSharedLinkAssets, type SharedLinkResponseDto } from '@immich/sdk';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
|
import { addSharedLinkAssets, getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk';
|
||||||
import { mdiArrowLeft, mdiFileImagePlusOutline, mdiFolderDownloadOutline, mdiSelectAll } from '@mdi/js';
|
import { mdiArrowLeft, mdiFileImagePlusOutline, mdiFolderDownloadOutline, mdiSelectAll } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
|
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
|
||||||
@ -33,7 +34,7 @@
|
|||||||
const viewport: Viewport = $state({ width: 0, height: 0 });
|
const viewport: Viewport = $state({ width: 0, height: 0 });
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
let assets = $derived(sharedLink.assets);
|
let assets = $derived(sharedLink.assets.map((a) => toTimelineAsset(a)));
|
||||||
|
|
||||||
dragAndDropFilesStore.subscribe((value) => {
|
dragAndDropFilesStore.subscribe((value) => {
|
||||||
if (value.isDragging && value.files.length > 0) {
|
if (value.isDragging && value.files.length > 0) {
|
||||||
@ -126,15 +127,17 @@
|
|||||||
<section class="my-[160px] mx-4" bind:clientHeight={viewport.height} bind:clientWidth={viewport.width}>
|
<section class="my-[160px] mx-4" bind:clientHeight={viewport.height} bind:clientWidth={viewport.width}>
|
||||||
<GalleryViewer {assets} {assetInteraction} {viewport} />
|
<GalleryViewer {assets} {assetInteraction} {viewport} />
|
||||||
</section>
|
</section>
|
||||||
{:else}
|
{:else if assets.length === 1}
|
||||||
<AssetViewer
|
{#await getAssetInfo({ id: assets[0].id, key: authManager.key }) then asset}
|
||||||
asset={assets[0]}
|
<AssetViewer
|
||||||
showCloseButton={false}
|
{asset}
|
||||||
onAction={handleAction}
|
showCloseButton={false}
|
||||||
onPrevious={() => Promise.resolve(false)}
|
onAction={handleAction}
|
||||||
onNext={() => Promise.resolve(false)}
|
onPrevious={() => Promise.resolve(false)}
|
||||||
onRandom={() => Promise.resolve(undefined)}
|
onNext={() => Promise.resolve(false)}
|
||||||
onClose={() => {}}
|
onRandom={() => Promise.resolve(undefined)}
|
||||||
/>
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import type { Viewport } from '$lib/stores/assets-store.svelte';
|
import type { TimelineAsset, Viewport } from '$lib/stores/assets-store.svelte';
|
||||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
@ -18,7 +18,8 @@
|
|||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
|
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import { type AssetResponseDto } from '@immich/sdk';
|
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
|
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { debounce } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
||||||
@ -26,7 +27,7 @@
|
|||||||
import Portal from '../portal/portal.svelte';
|
import Portal from '../portal/portal.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
assets: AssetResponseDto[];
|
assets: (TimelineAsset | AssetResponseDto)[];
|
||||||
assetInteraction: AssetInteraction;
|
assetInteraction: AssetInteraction;
|
||||||
disableAssetSelect?: boolean;
|
disableAssetSelect?: boolean;
|
||||||
showArchiveIcon?: boolean;
|
showArchiveIcon?: boolean;
|
||||||
@ -34,9 +35,9 @@
|
|||||||
onIntersected?: (() => void) | undefined;
|
onIntersected?: (() => void) | undefined;
|
||||||
showAssetName?: boolean;
|
showAssetName?: boolean;
|
||||||
isShowDeleteConfirmation?: boolean;
|
isShowDeleteConfirmation?: boolean;
|
||||||
onPrevious?: (() => Promise<AssetResponseDto | undefined>) | undefined;
|
onPrevious?: (() => Promise<{ id: string } | undefined>) | undefined;
|
||||||
onNext?: (() => Promise<AssetResponseDto | undefined>) | undefined;
|
onNext?: (() => Promise<{ id: string } | undefined>) | undefined;
|
||||||
onRandom?: (() => Promise<AssetResponseDto | undefined>) | undefined;
|
onRandom?: (() => Promise<{ id: string } | undefined>) | undefined;
|
||||||
pageHeaderOffset?: number;
|
pageHeaderOffset?: number;
|
||||||
slidingWindowOffset?: number;
|
slidingWindowOffset?: number;
|
||||||
}
|
}
|
||||||
@ -57,7 +58,7 @@
|
|||||||
pageHeaderOffset = 0,
|
pageHeaderOffset = 0,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore;
|
let { isViewing: isViewerOpen, asset: viewingAsset, setAssetId } = assetViewingStore;
|
||||||
|
|
||||||
let geometry: CommonJustifiedLayout | undefined = $state();
|
let geometry: CommonJustifiedLayout | undefined = $state();
|
||||||
|
|
||||||
@ -83,20 +84,27 @@
|
|||||||
if (geometry) {
|
if (geometry) {
|
||||||
containerHeight = geometry.containerHeight;
|
containerHeight = geometry.containerHeight;
|
||||||
containerWidth = geometry.containerWidth;
|
containerWidth = geometry.containerWidth;
|
||||||
for (const [i, asset] of assets.entries()) {
|
for (const [index, asset] of assets.entries()) {
|
||||||
const layout = {
|
const top = geometry.getTop(index);
|
||||||
asset,
|
const left = geometry.getLeft(index);
|
||||||
top: geometry.getTop(i),
|
const width = geometry.getWidth(index);
|
||||||
left: geometry.getLeft(i),
|
const height = geometry.getHeight(index);
|
||||||
width: geometry.getWidth(i),
|
|
||||||
height: geometry.getHeight(i),
|
const layoutTopWithOffset = top + pageHeaderOffset;
|
||||||
};
|
const layoutBottom = layoutTopWithOffset + height;
|
||||||
// 54 is the content height of the asset-selection-app-bar
|
|
||||||
const layoutTopWithOffset = layout.top + pageHeaderOffset;
|
|
||||||
const layoutBottom = layoutTopWithOffset + layout.height;
|
|
||||||
|
|
||||||
const display = layoutTopWithOffset < slidingWindow.bottom && layoutBottom > slidingWindow.top;
|
const display = layoutTopWithOffset < slidingWindow.bottom && layoutBottom > slidingWindow.top;
|
||||||
assetLayout.push({ ...layout, display });
|
|
||||||
|
const layout = {
|
||||||
|
asset,
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
display,
|
||||||
|
};
|
||||||
|
|
||||||
|
assetLayout.push(layout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,7 +117,7 @@
|
|||||||
|
|
||||||
let currentViewAssetIndex = 0;
|
let currentViewAssetIndex = 0;
|
||||||
let shiftKeyIsDown = $state(false);
|
let shiftKeyIsDown = $state(false);
|
||||||
let lastAssetMouseEvent: AssetResponseDto | null = $state(null);
|
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
|
||||||
let slidingWindow = $state({ top: 0, bottom: 0 });
|
let slidingWindow = $state({ top: 0, bottom: 0 });
|
||||||
|
|
||||||
const updateSlidingWindow = () => {
|
const updateSlidingWindow = () => {
|
||||||
@ -139,14 +147,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const viewAssetHandler = async (asset: AssetResponseDto) => {
|
const viewAssetHandler = async (asset: TimelineAsset) => {
|
||||||
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
|
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
|
||||||
setAsset(assets[currentViewAssetIndex]);
|
await setAssetId(assets[currentViewAssetIndex].id);
|
||||||
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectAllAssets = () => {
|
const selectAllAssets = () => {
|
||||||
assetInteraction.selectAssets(assets);
|
assetInteraction.selectAssets(assets.map((a) => toTimelineAsset(a)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const deselectAllAssets = () => {
|
const deselectAllAssets = () => {
|
||||||
@ -168,7 +176,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAssets = (asset: AssetResponseDto) => {
|
const handleSelectAssets = (asset: TimelineAsset) => {
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -191,14 +199,14 @@
|
|||||||
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
|
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => {
|
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
|
||||||
if (asset) {
|
if (asset) {
|
||||||
selectAssetCandidates(asset);
|
selectAssetCandidates(asset);
|
||||||
}
|
}
|
||||||
lastAssetMouseEvent = asset;
|
lastAssetMouseEvent = asset;
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectAssetCandidates = (endAsset: AssetResponseDto) => {
|
const selectAssetCandidates = (endAsset: TimelineAsset) => {
|
||||||
if (!shiftKeyIsDown) {
|
if (!shiftKeyIsDown) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -215,12 +223,12 @@
|
|||||||
[start, end] = [end, start];
|
[start, end] = [end, start];
|
||||||
}
|
}
|
||||||
|
|
||||||
assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1));
|
assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1).map((a) => toTimelineAsset(a)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSelectStart = (e: Event) => {
|
const onSelectStart = (event: Event) => {
|
||||||
if (assetInteraction.selectionActive && shiftKeyIsDown) {
|
if (assetInteraction.selectionActive && shiftKeyIsDown) {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -253,7 +261,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleArchive = async () => {
|
const toggleArchive = async () => {
|
||||||
const ids = await archiveAssets(assetInteraction.selectedAssets, !assetInteraction.isAllArchived);
|
const ids = await archiveAssets(
|
||||||
|
assetInteraction.selectedAssets,
|
||||||
|
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
|
||||||
|
);
|
||||||
if (ids) {
|
if (ids) {
|
||||||
assets = assets.filter((asset) => !ids.includes(asset.id));
|
assets = assets.filter((asset) => !ids.includes(asset.id));
|
||||||
deselectAllAssets();
|
deselectAllAssets();
|
||||||
@ -275,7 +286,7 @@
|
|||||||
isShortcutModalOpen = false;
|
isShortcutModalOpen = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
let shortcutList = $derived(
|
const shortcutList = $derived(
|
||||||
(() => {
|
(() => {
|
||||||
if ($isViewerOpen) {
|
if ($isViewerOpen) {
|
||||||
return [];
|
return [];
|
||||||
@ -305,7 +316,7 @@
|
|||||||
|
|
||||||
const handleNext = async (): Promise<boolean> => {
|
const handleNext = async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
let asset: AssetResponseDto | undefined;
|
let asset: { id: string } | undefined;
|
||||||
if (onNext) {
|
if (onNext) {
|
||||||
asset = await onNext();
|
asset = await onNext();
|
||||||
} else {
|
} else {
|
||||||
@ -329,9 +340,9 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRandom = async (): Promise<AssetResponseDto | undefined> => {
|
const handleRandom = async (): Promise<{ id: string } | undefined> => {
|
||||||
try {
|
try {
|
||||||
let asset: AssetResponseDto | undefined;
|
let asset: { id: string } | undefined;
|
||||||
if (onRandom) {
|
if (onRandom) {
|
||||||
asset = await onRandom();
|
asset = await onRandom();
|
||||||
} else {
|
} else {
|
||||||
@ -355,7 +366,7 @@
|
|||||||
|
|
||||||
const handlePrevious = async (): Promise<boolean> => {
|
const handlePrevious = async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
let asset: AssetResponseDto | undefined;
|
let asset: { id: string } | undefined;
|
||||||
if (onPrevious) {
|
if (onPrevious) {
|
||||||
asset = await onPrevious();
|
asset = await onPrevious();
|
||||||
} else {
|
} else {
|
||||||
@ -379,9 +390,9 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateToAsset = async (asset?: AssetResponseDto) => {
|
const navigateToAsset = async (asset?: { id: string }) => {
|
||||||
if (asset && asset.id !== $viewingAsset.id) {
|
if (asset && asset.id !== $viewingAsset.id) {
|
||||||
setAsset(asset);
|
await setAssetId(asset.id);
|
||||||
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -392,7 +403,7 @@
|
|||||||
case AssetAction.DELETE:
|
case AssetAction.DELETE:
|
||||||
case AssetAction.TRASH: {
|
case AssetAction.TRASH: {
|
||||||
assets.splice(
|
assets.splice(
|
||||||
assets.findIndex((a) => a.id === action.asset.id),
|
assets.findIndex((currentAsset) => currentAsset.id === action.asset.id),
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
if (assets.length === 0) {
|
if (assets.length === 0) {
|
||||||
@ -400,21 +411,21 @@
|
|||||||
} else if (currentViewAssetIndex === assets.length) {
|
} else if (currentViewAssetIndex === assets.length) {
|
||||||
await handlePrevious();
|
await handlePrevious();
|
||||||
} else {
|
} else {
|
||||||
setAsset(assets[currentViewAssetIndex]);
|
await setAssetId(assets[currentViewAssetIndex].id);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const assetMouseEventHandler = (asset: AssetResponseDto | null) => {
|
const assetMouseEventHandler = (asset: TimelineAsset | null) => {
|
||||||
if (assetInteraction.selectionActive) {
|
if (assetInteraction.selectionActive) {
|
||||||
handleSelectAssetCandidates(asset);
|
handleSelectAssetCandidates(asset);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
||||||
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
|
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map((selectedAsset) => selectedAsset.id));
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!lastAssetMouseEvent) {
|
if (!lastAssetMouseEvent) {
|
||||||
@ -457,39 +468,38 @@
|
|||||||
style:height={assetLayouts.containerHeight + 'px'}
|
style:height={assetLayouts.containerHeight + 'px'}
|
||||||
style:width={assetLayouts.containerWidth - 1 + 'px'}
|
style:width={assetLayouts.containerWidth - 1 + 'px'}
|
||||||
>
|
>
|
||||||
{#each assetLayouts.assetLayout as layout, index (layout.asset.id + '-' + index)}
|
{#each assetLayouts.assetLayout as layout, layoutIndex (layout.asset.id + '-' + layoutIndex)}
|
||||||
{@const asset = layout.asset}
|
{@const currentAsset = layout.asset}
|
||||||
|
|
||||||
{#if layout.display}
|
{#if layout.display}
|
||||||
<div
|
<div
|
||||||
class="absolute"
|
class="absolute"
|
||||||
style:overflow="clip"
|
style:overflow="clip"
|
||||||
style="width: {layout.width}px; height: {layout.height}px; top: {layout.top}px; left: {layout.left}px"
|
style="width: {layout.width}px; height: {layout.height}px; top: {layout.top}px; left: {layout.left}px"
|
||||||
title={showAssetName ? asset.originalFileName : ''}
|
|
||||||
>
|
>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
readonly={disableAssetSelect}
|
readonly={disableAssetSelect}
|
||||||
onClick={(asset) => {
|
onClick={() => {
|
||||||
if (assetInteraction.selectionActive) {
|
if (assetInteraction.selectionActive) {
|
||||||
handleSelectAssets(asset);
|
handleSelectAssets(toTimelineAsset(currentAsset));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void viewAssetHandler(asset);
|
void viewAssetHandler(toTimelineAsset(currentAsset));
|
||||||
}}
|
}}
|
||||||
onSelect={(asset) => handleSelectAssets(asset)}
|
onSelect={() => handleSelectAssets(toTimelineAsset(currentAsset))}
|
||||||
onMouseEvent={() => assetMouseEventHandler(asset)}
|
onMouseEvent={() => assetMouseEventHandler(toTimelineAsset(currentAsset))}
|
||||||
{showArchiveIcon}
|
{showArchiveIcon}
|
||||||
{asset}
|
asset={toTimelineAsset(currentAsset)}
|
||||||
selected={assetInteraction.hasSelectedAsset(asset.id)}
|
selected={assetInteraction.hasSelectedAsset(currentAsset.id)}
|
||||||
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)}
|
||||||
thumbnailWidth={layout.width}
|
thumbnailWidth={layout.width}
|
||||||
thumbnailHeight={layout.height}
|
thumbnailHeight={layout.height}
|
||||||
/>
|
/>
|
||||||
{#if showAssetName}
|
{#if showAssetName && !isTimelineAsset(currentAsset)}
|
||||||
<div
|
<div
|
||||||
class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-gradient-to-t bg-slate-50/75 overflow-clip text-ellipsis whitespace-pre-wrap"
|
class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-gradient-to-t bg-slate-50/75 overflow-clip text-ellipsis whitespace-pre-wrap"
|
||||||
>
|
>
|
||||||
{asset.originalFileName}
|
{currentAsset.originalFileName}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||||
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
|
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { type AssetResponseDto, getAllAlbums } from '@immich/sdk';
|
import { type AssetResponseDto, getAllAlbums } from '@immich/sdk';
|
||||||
import { mdiHeart, mdiImageMultipleOutline, mdiMagnifyPlus } from '@mdi/js';
|
import { mdiHeart, mdiImageMultipleOutline, mdiMagnifyPlus } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@ -36,7 +37,7 @@
|
|||||||
<!-- THUMBNAIL-->
|
<!-- THUMBNAIL-->
|
||||||
<img
|
<img
|
||||||
src={getAssetThumbnailUrl(asset.id)}
|
src={getAssetThumbnailUrl(asset.id)}
|
||||||
alt={$getAltText(asset)}
|
alt={$getAltText(toTimelineAsset(asset))}
|
||||||
title={assetData}
|
title={assetData}
|
||||||
class="h-60 object-cover rounded-t-xl w-full"
|
class="h-60 object-cover rounded-t-xl w-full"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { resetSavedUser, user } from '$lib/stores/user.store';
|
import { resetSavedUser, user } from '$lib/stores/user.store';
|
||||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
import { Visibility } from '@immich/sdk';
|
||||||
|
import { timelineAssetFactory } from '@test-data/factories/asset-factory';
|
||||||
import { userAdminFactory } from '@test-data/factories/user-factory';
|
import { userAdminFactory } from '@test-data/factories/user-factory';
|
||||||
|
|
||||||
describe('AssetInteraction', () => {
|
describe('AssetInteraction', () => {
|
||||||
@ -11,8 +12,12 @@ describe('AssetInteraction', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calculates derived values from selection', () => {
|
it('calculates derived values from selection', () => {
|
||||||
assetInteraction.selectAsset(assetFactory.build({ isFavorite: true, isArchived: true, isTrashed: true }));
|
assetInteraction.selectAsset(
|
||||||
assetInteraction.selectAsset(assetFactory.build({ isFavorite: true, isArchived: false, isTrashed: false }));
|
timelineAssetFactory.build({ isFavorite: true, visibility: Visibility.Archive, isTrashed: true }),
|
||||||
|
);
|
||||||
|
assetInteraction.selectAsset(
|
||||||
|
timelineAssetFactory.build({ isFavorite: true, visibility: Visibility.Timeline, isTrashed: false }),
|
||||||
|
);
|
||||||
|
|
||||||
expect(assetInteraction.selectionActive).toBe(true);
|
expect(assetInteraction.selectionActive).toBe(true);
|
||||||
expect(assetInteraction.isAllTrashed).toBe(false);
|
expect(assetInteraction.isAllTrashed).toBe(false);
|
||||||
@ -22,7 +27,7 @@ describe('AssetInteraction', () => {
|
|||||||
|
|
||||||
it('updates isAllUserOwned when the active user changes', () => {
|
it('updates isAllUserOwned when the active user changes', () => {
|
||||||
const [user1, user2] = userAdminFactory.buildList(2);
|
const [user1, user2] = userAdminFactory.buildList(2);
|
||||||
assetInteraction.selectAsset(assetFactory.build({ ownerId: user1.id }));
|
assetInteraction.selectAsset(timelineAssetFactory.build({ ownerId: user1.id }));
|
||||||
|
|
||||||
const cleanup = $effect.root(() => {
|
const cleanup = $effect.root(() => {
|
||||||
expect(assetInteraction.isAllUserOwned).toBe(false);
|
expect(assetInteraction.isAllUserOwned).toBe(false);
|
||||||
|
@ -1,36 +1,37 @@
|
|||||||
|
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import type { AssetResponseDto, UserAdminResponseDto } from '@immich/sdk';
|
import { Visibility, type UserAdminResponseDto } from '@immich/sdk';
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import { fromStore } from 'svelte/store';
|
import { fromStore } from 'svelte/store';
|
||||||
|
|
||||||
export class AssetInteraction {
|
export class AssetInteraction {
|
||||||
selectedAssets = $state<AssetResponseDto[]>([]);
|
selectedAssets = $state<TimelineAsset[]>([]);
|
||||||
hasSelectedAsset(assetId: string) {
|
hasSelectedAsset(assetId: string) {
|
||||||
return this.selectedAssets.some((asset) => asset.id === assetId);
|
return this.selectedAssets.some((asset) => asset.id === assetId);
|
||||||
}
|
}
|
||||||
selectedGroup = new SvelteSet<string>();
|
selectedGroup = new SvelteSet<string>();
|
||||||
assetSelectionCandidates = $state<AssetResponseDto[]>([]);
|
assetSelectionCandidates = $state<TimelineAsset[]>([]);
|
||||||
hasSelectionCandidate(assetId: string) {
|
hasSelectionCandidate(assetId: string) {
|
||||||
return this.assetSelectionCandidates.some((asset) => asset.id === assetId);
|
return this.assetSelectionCandidates.some((asset) => asset.id === assetId);
|
||||||
}
|
}
|
||||||
assetSelectionStart = $state<AssetResponseDto | null>(null);
|
assetSelectionStart = $state<TimelineAsset | null>(null);
|
||||||
selectionActive = $derived(this.selectedAssets.length > 0);
|
selectionActive = $derived(this.selectedAssets.length > 0);
|
||||||
|
|
||||||
private user = fromStore<UserAdminResponseDto | undefined>(user);
|
private user = fromStore<UserAdminResponseDto | undefined>(user);
|
||||||
private userId = $derived(this.user.current?.id);
|
private userId = $derived(this.user.current?.id);
|
||||||
|
|
||||||
isAllTrashed = $derived(this.selectedAssets.every((asset) => asset.isTrashed));
|
isAllTrashed = $derived(this.selectedAssets.every((asset) => asset.isTrashed));
|
||||||
isAllArchived = $derived(this.selectedAssets.every((asset) => asset.isArchived));
|
isAllArchived = $derived(this.selectedAssets.every((asset) => asset.visibility === Visibility.Archive));
|
||||||
isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite));
|
isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite));
|
||||||
isAllUserOwned = $derived(this.selectedAssets.every((asset) => asset.ownerId === this.userId));
|
isAllUserOwned = $derived(this.selectedAssets.every((asset) => asset.ownerId === this.userId));
|
||||||
|
|
||||||
selectAsset(asset: AssetResponseDto) {
|
selectAsset(asset: TimelineAsset) {
|
||||||
if (!this.hasSelectedAsset(asset.id)) {
|
if (!this.hasSelectedAsset(asset.id)) {
|
||||||
this.selectedAssets.push(asset);
|
this.selectedAssets.push(asset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
selectAssets(assets: AssetResponseDto[]) {
|
selectAssets(assets: TimelineAsset[]) {
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
this.selectAsset(asset);
|
this.selectAsset(asset);
|
||||||
}
|
}
|
||||||
@ -51,11 +52,11 @@ export class AssetInteraction {
|
|||||||
this.selectedGroup.delete(group);
|
this.selectedGroup.delete(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
setAssetSelectionStart(asset: AssetResponseDto | null) {
|
setAssetSelectionStart(asset: TimelineAsset | null) {
|
||||||
this.assetSelectionStart = asset;
|
this.assetSelectionStart = asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAssetSelectionCandidates(assets: AssetResponseDto[]) {
|
setAssetSelectionCandidates(assets: TimelineAsset[]) {
|
||||||
this.assetSelectionCandidates = assets;
|
this.assetSelectionCandidates = assets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,15 +1,16 @@
|
|||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import { type AssetGridRouteSearchParams } from '$lib/utils/navigation';
|
import { type AssetGridRouteSearchParams } from '$lib/utils/navigation';
|
||||||
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { readonly, writable } from 'svelte/store';
|
import { readonly, writable } from 'svelte/store';
|
||||||
|
|
||||||
function createAssetViewingStore() {
|
function createAssetViewingStore() {
|
||||||
const viewingAssetStoreState = writable<AssetResponseDto>();
|
const viewingAssetStoreState = writable<AssetResponseDto>();
|
||||||
const preloadAssets = writable<AssetResponseDto[]>([]);
|
const preloadAssets = writable<TimelineAsset[]>([]);
|
||||||
const viewState = writable<boolean>(false);
|
const viewState = writable<boolean>(false);
|
||||||
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
|
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
|
||||||
|
|
||||||
const setAsset = (asset: AssetResponseDto, assetsToPreload: AssetResponseDto[] = []) => {
|
const setAsset = (asset: AssetResponseDto, assetsToPreload: TimelineAsset[] = []) => {
|
||||||
preloadAssets.set(assetsToPreload);
|
preloadAssets.set(assetsToPreload);
|
||||||
viewingAssetStoreState.set(asset);
|
viewingAssetStoreState.set(asset);
|
||||||
viewState.set(true);
|
viewState.set(true);
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||||
import { AbortError } from '$lib/utils';
|
import { AbortError } from '$lib/utils';
|
||||||
import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk';
|
import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
import { assetFactory, timelineAssetFactory } from '@test-data/factories/asset-factory';
|
||||||
import { AssetStore } from './assets-store.svelte';
|
import { AssetStore } from './assets-store.svelte';
|
||||||
|
|
||||||
describe('AssetStore', () => {
|
describe('AssetStore', () => {
|
||||||
@ -149,9 +149,8 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('adds assets to new bucket', () => {
|
it('adds assets to new bucket', () => {
|
||||||
const asset = assetFactory.build({
|
const asset = timelineAssetFactory.build({
|
||||||
localDateTime: '2024-01-20T12:00:00.000Z',
|
localDateTime: '2024-01-20T12:00:00.000Z',
|
||||||
fileCreatedAt: '2024-01-20T12:00:00.000Z',
|
|
||||||
});
|
});
|
||||||
assetStore.addAssets([asset]);
|
assetStore.addAssets([asset]);
|
||||||
|
|
||||||
@ -163,9 +162,8 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('adds assets to existing bucket', () => {
|
it('adds assets to existing bucket', () => {
|
||||||
const [assetOne, assetTwo] = assetFactory.buildList(2, {
|
const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, {
|
||||||
localDateTime: '2024-01-20T12:00:00.000Z',
|
localDateTime: '2024-01-20T12:00:00.000Z',
|
||||||
fileCreatedAt: '2024-01-20T12:00:00.000Z',
|
|
||||||
});
|
});
|
||||||
assetStore.addAssets([assetOne]);
|
assetStore.addAssets([assetOne]);
|
||||||
assetStore.addAssets([assetTwo]);
|
assetStore.addAssets([assetTwo]);
|
||||||
@ -177,16 +175,13 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('orders assets in buckets by descending date', () => {
|
it('orders assets in buckets by descending date', () => {
|
||||||
const assetOne = assetFactory.build({
|
const assetOne = timelineAssetFactory.build({
|
||||||
fileCreatedAt: '2024-01-20T12:00:00.000Z',
|
|
||||||
localDateTime: '2024-01-20T12:00:00.000Z',
|
localDateTime: '2024-01-20T12:00:00.000Z',
|
||||||
});
|
});
|
||||||
const assetTwo = assetFactory.build({
|
const assetTwo = timelineAssetFactory.build({
|
||||||
fileCreatedAt: '2024-01-15T12:00:00.000Z',
|
|
||||||
localDateTime: '2024-01-15T12:00:00.000Z',
|
localDateTime: '2024-01-15T12:00:00.000Z',
|
||||||
});
|
});
|
||||||
const assetThree = assetFactory.build({
|
const assetThree = timelineAssetFactory.build({
|
||||||
fileCreatedAt: '2024-01-16T12:00:00.000Z',
|
|
||||||
localDateTime: '2024-01-16T12:00:00.000Z',
|
localDateTime: '2024-01-16T12:00:00.000Z',
|
||||||
});
|
});
|
||||||
assetStore.addAssets([assetOne, assetTwo, assetThree]);
|
assetStore.addAssets([assetOne, assetTwo, assetThree]);
|
||||||
@ -200,9 +195,9 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('orders buckets by descending date', () => {
|
it('orders buckets by descending date', () => {
|
||||||
const assetOne = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
|
const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||||
const assetTwo = assetFactory.build({ localDateTime: '2024-04-20T12:00:00.000Z' });
|
const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-04-20T12:00:00.000Z' });
|
||||||
const assetThree = assetFactory.build({ localDateTime: '2023-01-20T12:00:00.000Z' });
|
const assetThree = timelineAssetFactory.build({ localDateTime: '2023-01-20T12:00:00.000Z' });
|
||||||
assetStore.addAssets([assetOne, assetTwo, assetThree]);
|
assetStore.addAssets([assetOne, assetTwo, assetThree]);
|
||||||
|
|
||||||
expect(assetStore.buckets.length).toEqual(3);
|
expect(assetStore.buckets.length).toEqual(3);
|
||||||
@ -213,7 +208,7 @@ describe('AssetStore', () => {
|
|||||||
|
|
||||||
it('updates existing asset', () => {
|
it('updates existing asset', () => {
|
||||||
const updateAssetsSpy = vi.spyOn(assetStore, 'updateAssets');
|
const updateAssetsSpy = vi.spyOn(assetStore, 'updateAssets');
|
||||||
const asset = assetFactory.build();
|
const asset = timelineAssetFactory.build();
|
||||||
assetStore.addAssets([asset]);
|
assetStore.addAssets([asset]);
|
||||||
|
|
||||||
assetStore.addAssets([asset]);
|
assetStore.addAssets([asset]);
|
||||||
@ -223,8 +218,8 @@ describe('AssetStore', () => {
|
|||||||
|
|
||||||
// disabled due to the wasm Justified Layout import
|
// disabled due to the wasm Justified Layout import
|
||||||
it('ignores trashed assets when isTrashed is true', async () => {
|
it('ignores trashed assets when isTrashed is true', async () => {
|
||||||
const asset = assetFactory.build({ isTrashed: false });
|
const asset = timelineAssetFactory.build({ isTrashed: false });
|
||||||
const trashedAsset = assetFactory.build({ isTrashed: true });
|
const trashedAsset = timelineAssetFactory.build({ isTrashed: true });
|
||||||
|
|
||||||
const assetStore = new AssetStore();
|
const assetStore = new AssetStore();
|
||||||
await assetStore.updateOptions({ isTrashed: true });
|
await assetStore.updateOptions({ isTrashed: true });
|
||||||
@ -244,14 +239,14 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ignores non-existing assets', () => {
|
it('ignores non-existing assets', () => {
|
||||||
assetStore.updateAssets([assetFactory.build()]);
|
assetStore.updateAssets([timelineAssetFactory.build()]);
|
||||||
|
|
||||||
expect(assetStore.buckets.length).toEqual(0);
|
expect(assetStore.buckets.length).toEqual(0);
|
||||||
expect(assetStore.getAssets().length).toEqual(0);
|
expect(assetStore.getAssets().length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates an asset', () => {
|
it('updates an asset', () => {
|
||||||
const asset = assetFactory.build({ isFavorite: false });
|
const asset = timelineAssetFactory.build({ isFavorite: false });
|
||||||
const updatedAsset = { ...asset, isFavorite: true };
|
const updatedAsset = { ...asset, isFavorite: true };
|
||||||
|
|
||||||
assetStore.addAssets([asset]);
|
assetStore.addAssets([asset]);
|
||||||
@ -264,7 +259,7 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('asset moves buckets when asset date changes', () => {
|
it('asset moves buckets when asset date changes', () => {
|
||||||
const asset = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
|
const asset = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||||
const updatedAsset = { ...asset, localDateTime: '2024-03-20T12:00:00.000Z' };
|
const updatedAsset = { ...asset, localDateTime: '2024-03-20T12:00:00.000Z' };
|
||||||
|
|
||||||
assetStore.addAssets([asset]);
|
assetStore.addAssets([asset]);
|
||||||
@ -292,7 +287,7 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ignores invalid IDs', () => {
|
it('ignores invalid IDs', () => {
|
||||||
assetStore.addAssets(assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }));
|
assetStore.addAssets(timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }));
|
||||||
assetStore.removeAssets(['', 'invalid', '4c7d9acc']);
|
assetStore.removeAssets(['', 'invalid', '4c7d9acc']);
|
||||||
|
|
||||||
expect(assetStore.getAssets().length).toEqual(2);
|
expect(assetStore.getAssets().length).toEqual(2);
|
||||||
@ -301,7 +296,7 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('removes asset from bucket', () => {
|
it('removes asset from bucket', () => {
|
||||||
const [assetOne, assetTwo] = assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
|
const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||||
assetStore.addAssets([assetOne, assetTwo]);
|
assetStore.addAssets([assetOne, assetTwo]);
|
||||||
assetStore.removeAssets([assetOne.id]);
|
assetStore.removeAssets([assetOne.id]);
|
||||||
|
|
||||||
@ -311,7 +306,7 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not remove bucket when empty', () => {
|
it('does not remove bucket when empty', () => {
|
||||||
const assets = assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
|
const assets = timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||||
assetStore.addAssets(assets);
|
assetStore.addAssets(assets);
|
||||||
assetStore.removeAssets(assets.map((asset) => asset.id));
|
assetStore.removeAssets(assets.map((asset) => asset.id));
|
||||||
|
|
||||||
@ -334,12 +329,10 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('populated store returns first asset', () => {
|
it('populated store returns first asset', () => {
|
||||||
const assetOne = assetFactory.build({
|
const assetOne = timelineAssetFactory.build({
|
||||||
fileCreatedAt: '2024-01-20T12:00:00.000Z',
|
|
||||||
localDateTime: '2024-01-20T12:00:00.000Z',
|
localDateTime: '2024-01-20T12:00:00.000Z',
|
||||||
});
|
});
|
||||||
const assetTwo = assetFactory.build({
|
const assetTwo = timelineAssetFactory.build({
|
||||||
fileCreatedAt: '2024-01-15T12:00:00.000Z',
|
|
||||||
localDateTime: '2024-01-15T12:00:00.000Z',
|
localDateTime: '2024-01-15T12:00:00.000Z',
|
||||||
});
|
});
|
||||||
assetStore.addAssets([assetOne, assetTwo]);
|
assetStore.addAssets([assetOne, assetTwo]);
|
||||||
@ -445,8 +438,8 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns the bucket index', () => {
|
it('returns the bucket index', () => {
|
||||||
const assetOne = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
|
const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||||
const assetTwo = assetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
|
const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
|
||||||
assetStore.addAssets([assetOne, assetTwo]);
|
assetStore.addAssets([assetOne, assetTwo]);
|
||||||
|
|
||||||
expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.bucketDate).toEqual('2024-02-01T00:00:00.000Z');
|
expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.bucketDate).toEqual('2024-02-01T00:00:00.000Z');
|
||||||
@ -454,8 +447,8 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ignores removed buckets', () => {
|
it('ignores removed buckets', () => {
|
||||||
const assetOne = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
|
const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||||
const assetTwo = assetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
|
const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
|
||||||
assetStore.addAssets([assetOne, assetTwo]);
|
assetStore.addAssets([assetOne, assetTwo]);
|
||||||
|
|
||||||
assetStore.removeAssets([assetTwo.id]);
|
assetStore.removeAssets([assetTwo.id]);
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||||
import {
|
import {
|
||||||
@ -6,7 +7,7 @@ import {
|
|||||||
type CommonLayoutOptions,
|
type CommonLayoutOptions,
|
||||||
type CommonPosition,
|
type CommonPosition,
|
||||||
} from '$lib/utils/layout-utils';
|
} from '$lib/utils/layout-utils';
|
||||||
import { formatDateGroupTitle, fromLocalDateTime } from '$lib/utils/timeline-util';
|
import { formatDateGroupTitle, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { TUNABLES } from '$lib/utils/tunables';
|
import { TUNABLES } from '$lib/utils/tunables';
|
||||||
import {
|
import {
|
||||||
AssetOrder,
|
AssetOrder,
|
||||||
@ -15,18 +16,17 @@ import {
|
|||||||
getTimeBucket,
|
getTimeBucket,
|
||||||
getTimeBuckets,
|
getTimeBuckets,
|
||||||
TimeBucketSize,
|
TimeBucketSize,
|
||||||
|
Visibility,
|
||||||
type AssetResponseDto,
|
type AssetResponseDto,
|
||||||
|
type AssetStackResponseDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { clamp, debounce, isEqual, throttle } from 'lodash-es';
|
import { clamp, debounce, isEqual, throttle } from 'lodash-es';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import { get, writable, type Unsubscriber } from 'svelte/store';
|
import { get, writable, type Unsubscriber } from 'svelte/store';
|
||||||
import { handleError } from '../utils/handle-error';
|
import { handleError } from '../utils/handle-error';
|
||||||
import { websocketEvents } from './websocket';
|
import { websocketEvents } from './websocket';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||||
} = TUNABLES;
|
} = TUNABLES;
|
||||||
@ -61,13 +61,35 @@ function updateObject(target: any, source: any): boolean {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assetSnapshot(asset: AssetResponseDto) {
|
export function assetSnapshot(asset: TimelineAsset): TimelineAsset {
|
||||||
return $state.snapshot(asset);
|
return $state.snapshot(asset) as TimelineAsset;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assetsSnapshot(assets: AssetResponseDto[]) {
|
export function assetsSnapshot(assets: TimelineAsset[]): TimelineAsset[] {
|
||||||
return assets.map((a) => $state.snapshot(a));
|
return assets.map((a) => $state.snapshot(a)) as TimelineAsset[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TimelineAsset = {
|
||||||
|
id: string;
|
||||||
|
ownerId: string;
|
||||||
|
ratio: number;
|
||||||
|
thumbhash: string | null;
|
||||||
|
localDateTime: string;
|
||||||
|
visibility: Visibility;
|
||||||
|
isFavorite: boolean;
|
||||||
|
isTrashed: boolean;
|
||||||
|
isVideo: boolean;
|
||||||
|
isImage: boolean;
|
||||||
|
stack: AssetStackResponseDto | null;
|
||||||
|
duration: string | null;
|
||||||
|
projectionType: string | null;
|
||||||
|
livePhotoVideoId: string | null;
|
||||||
|
text: {
|
||||||
|
city: string | null;
|
||||||
|
country: string | null;
|
||||||
|
people: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
class IntersectingAsset {
|
class IntersectingAsset {
|
||||||
// --- public ---
|
// --- public ---
|
||||||
readonly #group: AssetDateGroup;
|
readonly #group: AssetDateGroup;
|
||||||
@ -91,17 +113,17 @@ class IntersectingAsset {
|
|||||||
});
|
});
|
||||||
|
|
||||||
position: CommonPosition | undefined = $state();
|
position: CommonPosition | undefined = $state();
|
||||||
asset: AssetResponseDto | undefined = $state();
|
asset: TimelineAsset | undefined = $state();
|
||||||
id: string | undefined = $derived(this.asset?.id);
|
id: string | undefined = $derived(this.asset?.id);
|
||||||
|
|
||||||
constructor(group: AssetDateGroup, asset: AssetResponseDto) {
|
constructor(group: AssetDateGroup, asset: TimelineAsset) {
|
||||||
this.#group = group;
|
this.#group = group;
|
||||||
this.asset = asset;
|
this.asset = asset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
type AssetOperation = (asset: AssetResponseDto) => { remove: boolean };
|
type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
|
||||||
|
|
||||||
type MoveAsset = { asset: AssetResponseDto; year: number; month: number };
|
type MoveAsset = { asset: TimelineAsset; year: number; month: number };
|
||||||
export class AssetDateGroup {
|
export class AssetDateGroup {
|
||||||
// --- public
|
// --- public
|
||||||
readonly bucket: AssetBucket;
|
readonly bucket: AssetBucket;
|
||||||
@ -130,8 +152,8 @@ export class AssetDateGroup {
|
|||||||
|
|
||||||
sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) {
|
sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) {
|
||||||
this.intersetingAssets.sort((a, b) => {
|
this.intersetingAssets.sort((a, b) => {
|
||||||
const aDate = DateTime.fromISO(a.asset!.fileCreatedAt).toUTC();
|
const aDate = DateTime.fromISO(a.asset!.localDateTime).toUTC();
|
||||||
const bDate = DateTime.fromISO(b.asset!.fileCreatedAt).toUTC();
|
const bDate = DateTime.fromISO(b.asset!.localDateTime).toUTC();
|
||||||
|
|
||||||
if (sortOrder === AssetOrder.Asc) {
|
if (sortOrder === AssetOrder.Asc) {
|
||||||
return aDate.diff(bDate).milliseconds;
|
return aDate.diff(bDate).milliseconds;
|
||||||
@ -226,6 +248,25 @@ export type ViewportXY = Viewport & {
|
|||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
class AddContext {
|
||||||
|
lookupCache: {
|
||||||
|
[dayOfMonth: number]: AssetDateGroup;
|
||||||
|
} = {};
|
||||||
|
unprocessedAssets: TimelineAsset[] = [];
|
||||||
|
changedDateGroups = new Set<AssetDateGroup>();
|
||||||
|
newDateGroups = new Set<AssetDateGroup>();
|
||||||
|
sort(bucket: AssetBucket, sortOrder: AssetOrder = AssetOrder.Desc) {
|
||||||
|
for (const group of this.changedDateGroups) {
|
||||||
|
group.sortAssets(sortOrder);
|
||||||
|
}
|
||||||
|
for (const group of this.newDateGroups) {
|
||||||
|
group.sortAssets(sortOrder);
|
||||||
|
}
|
||||||
|
if (this.newDateGroups.size > 0) {
|
||||||
|
bucket.sortDateGroups();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
export class AssetBucket {
|
export class AssetBucket {
|
||||||
// --- public ---
|
// --- public ---
|
||||||
#intersecting: boolean = $state(false);
|
#intersecting: boolean = $state(false);
|
||||||
@ -317,7 +358,7 @@ export class AssetBucket {
|
|||||||
getAssets() {
|
getAssets() {
|
||||||
// eslint-disable-next-line unicorn/no-array-reduce
|
// eslint-disable-next-line unicorn/no-array-reduce
|
||||||
return this.dateGroups.reduce(
|
return this.dateGroups.reduce(
|
||||||
(accumulator: AssetResponseDto[], g: AssetDateGroup) => accumulator.concat(g.getAssets()),
|
(accumulator: TimelineAsset[], g: AssetDateGroup) => accumulator.concat(g.getAssets()),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -382,56 +423,56 @@ export class AssetBucket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// note - if the assets are not part of this bucket, they will not be added
|
// note - if the assets are not part of this bucket, they will not be added
|
||||||
addAssets(assets: AssetResponseDto[]) {
|
addAssets(bucketResponse: AssetResponseDto[]) {
|
||||||
const lookupCache: {
|
const addContext = new AddContext();
|
||||||
[dayOfMonth: number]: AssetDateGroup;
|
for (const asset of bucketResponse) {
|
||||||
} = {};
|
const timelineAsset = toTimelineAsset(asset);
|
||||||
const unprocessedAssets: AssetResponseDto[] = [];
|
this.addTimelineAsset(timelineAsset, addContext);
|
||||||
const changedDateGroups = new Set<AssetDateGroup>();
|
|
||||||
const newDateGroups = new Set<AssetDateGroup>();
|
|
||||||
for (const asset of assets) {
|
|
||||||
const date = DateTime.fromISO(asset.localDateTime).toUTC();
|
|
||||||
const month = date.get('month');
|
|
||||||
const year = date.get('year');
|
|
||||||
if (this.month === month && this.year === year) {
|
|
||||||
const day = date.get('day');
|
|
||||||
let dateGroup: AssetDateGroup | undefined = lookupCache[day];
|
|
||||||
if (!dateGroup) {
|
|
||||||
dateGroup = this.findDateGroupByDay(day);
|
|
||||||
if (dateGroup) {
|
|
||||||
lookupCache[day] = dateGroup;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (dateGroup) {
|
|
||||||
const intersectingAsset = new IntersectingAsset(dateGroup, asset);
|
|
||||||
if (dateGroup.intersetingAssets.some((a) => a.id === asset.id)) {
|
|
||||||
console.error(`Ignoring attempt to add duplicate asset ${asset.id} to ${dateGroup.groupTitle}`);
|
|
||||||
} else {
|
|
||||||
dateGroup.intersetingAssets.push(intersectingAsset);
|
|
||||||
changedDateGroups.add(dateGroup);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day);
|
|
||||||
dateGroup.intersetingAssets.push(new IntersectingAsset(dateGroup, asset));
|
|
||||||
this.dateGroups.push(dateGroup);
|
|
||||||
lookupCache[day] = dateGroup;
|
|
||||||
newDateGroups.add(dateGroup);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
unprocessedAssets.push(asset);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (const group of changedDateGroups) {
|
|
||||||
group.sortAssets(this.#sortOrder);
|
addContext.sort(this, this.#sortOrder);
|
||||||
}
|
return addContext.unprocessedAssets;
|
||||||
for (const group of newDateGroups) {
|
|
||||||
group.sortAssets(this.#sortOrder);
|
|
||||||
}
|
|
||||||
if (newDateGroups.size > 0) {
|
|
||||||
this.sortDateGroups();
|
|
||||||
}
|
|
||||||
return unprocessedAssets;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) {
|
||||||
|
const { id, localDateTime } = timelineAsset;
|
||||||
|
const date = DateTime.fromISO(localDateTime).toUTC();
|
||||||
|
|
||||||
|
const month = date.get('month');
|
||||||
|
const year = date.get('year');
|
||||||
|
|
||||||
|
// If the timeline asset does not belong to the current bucket, mark it as unprocessed
|
||||||
|
if (this.month !== month || this.year !== year) {
|
||||||
|
addContext.unprocessedAssets.push(timelineAsset);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const day = date.get('day');
|
||||||
|
let dateGroup: AssetDateGroup | undefined = addContext.lookupCache[day] || this.findDateGroupByDay(day);
|
||||||
|
|
||||||
|
if (dateGroup) {
|
||||||
|
// Cache the found date group for future lookups
|
||||||
|
addContext.lookupCache[day] = dateGroup;
|
||||||
|
} else {
|
||||||
|
// Create a new date group if none exists for the given day
|
||||||
|
dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day);
|
||||||
|
this.dateGroups.push(dateGroup);
|
||||||
|
addContext.lookupCache[day] = dateGroup;
|
||||||
|
addContext.newDateGroups.add(dateGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate assets in the date group
|
||||||
|
if (dateGroup.intersetingAssets.some((a) => a.id === id)) {
|
||||||
|
console.error(`Ignoring attempt to add duplicate asset ${id} to ${dateGroup.groupTitle}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the timeline asset to the date group
|
||||||
|
const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset);
|
||||||
|
dateGroup.intersetingAssets.push(intersectingAsset);
|
||||||
|
addContext.changedDateGroups.add(dateGroup);
|
||||||
|
}
|
||||||
|
|
||||||
getRandomDateGroup() {
|
getRandomDateGroup() {
|
||||||
const random = Math.floor(Math.random() * this.dateGroups.length);
|
const random = Math.floor(Math.random() * this.dateGroups.length);
|
||||||
return this.dateGroups[random];
|
return this.dateGroups[random];
|
||||||
@ -512,17 +553,16 @@ export class AssetBucket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMismatched = (option: boolean | undefined, value: boolean): boolean =>
|
const isMismatched = <T>(option: T | undefined, value: T): boolean => (option === undefined ? false : option !== value);
|
||||||
option === undefined ? false : option !== value;
|
|
||||||
|
|
||||||
interface AddAsset {
|
interface AddAsset {
|
||||||
type: 'add';
|
type: 'add';
|
||||||
values: AssetResponseDto[];
|
values: TimelineAsset[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateAsset {
|
interface UpdateAsset {
|
||||||
type: 'update';
|
type: 'update';
|
||||||
values: AssetResponseDto[];
|
values: TimelineAsset[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DeleteAsset {
|
interface DeleteAsset {
|
||||||
@ -719,9 +759,13 @@ export class AssetStore {
|
|||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
this.#unsubscribers.push(
|
this.#unsubscribers.push(
|
||||||
websocketEvents.on('on_upload_success', (asset) => this.#addPendingChanges({ type: 'add', values: [asset] })),
|
websocketEvents.on('on_upload_success', (asset) =>
|
||||||
|
this.#addPendingChanges({ type: 'add', values: [toTimelineAsset(asset)] }),
|
||||||
|
),
|
||||||
websocketEvents.on('on_asset_trash', (ids) => this.#addPendingChanges({ type: 'trash', values: ids })),
|
websocketEvents.on('on_asset_trash', (ids) => this.#addPendingChanges({ type: 'trash', values: ids })),
|
||||||
websocketEvents.on('on_asset_update', (asset) => this.#addPendingChanges({ type: 'update', values: [asset] })),
|
websocketEvents.on('on_asset_update', (asset) =>
|
||||||
|
this.#addPendingChanges({ type: 'update', values: [toTimelineAsset(asset)] }),
|
||||||
|
),
|
||||||
websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })),
|
websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -735,8 +779,8 @@ export class AssetStore {
|
|||||||
|
|
||||||
#getPendingChangeBatches() {
|
#getPendingChangeBatches() {
|
||||||
const batch: {
|
const batch: {
|
||||||
add: AssetResponseDto[];
|
add: TimelineAsset[];
|
||||||
update: AssetResponseDto[];
|
update: TimelineAsset[];
|
||||||
remove: string[];
|
remove: string[];
|
||||||
} = {
|
} = {
|
||||||
add: [],
|
add: [],
|
||||||
@ -1069,7 +1113,7 @@ export class AssetStore {
|
|||||||
// so no need to load the bucket, it already has assets
|
// so no need to load the bucket, it already has assets
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const assets = await getTimeBucket(
|
const bucketResponse = await getTimeBucket(
|
||||||
{
|
{
|
||||||
...this.#options,
|
...this.#options,
|
||||||
timeBucket: bucketDate,
|
timeBucket: bucketDate,
|
||||||
@ -1078,7 +1122,7 @@ export class AssetStore {
|
|||||||
},
|
},
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
if (assets) {
|
if (bucketResponse) {
|
||||||
if (this.#options.timelineAlbumId) {
|
if (this.#options.timelineAlbumId) {
|
||||||
const albumAssets = await getTimeBucket(
|
const albumAssets = await getTimeBucket(
|
||||||
{
|
{
|
||||||
@ -1089,12 +1133,11 @@ export class AssetStore {
|
|||||||
},
|
},
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
for (const asset of albumAssets) {
|
for (const { id } of albumAssets) {
|
||||||
this.albumAssets.add(asset.id);
|
this.albumAssets.add(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const unprocessed = bucket.addAssets(bucketResponse);
|
||||||
const unprocessed = bucket.addAssets(assets);
|
|
||||||
if (unprocessed.length > 0) {
|
if (unprocessed.length > 0) {
|
||||||
console.error(
|
console.error(
|
||||||
`Warning: getTimeBucket API returning assets not in requested month: ${bucket.bucketDate}, ${JSON.stringify(unprocessed.map((a) => ({ id: a.id, localDateTime: a.localDateTime })))}`,
|
`Warning: getTimeBucket API returning assets not in requested month: ${bucket.bucketDate}, ${JSON.stringify(unprocessed.map((a) => ({ id: a.id, localDateTime: a.localDateTime })))}`,
|
||||||
@ -1108,8 +1151,8 @@ export class AssetStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addAssets(assets: AssetResponseDto[]) {
|
addAssets(assets: TimelineAsset[]) {
|
||||||
const assetsToUpdate: AssetResponseDto[] = [];
|
const assetsToUpdate: TimelineAsset[] = [];
|
||||||
|
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
if (this.isExcluded(asset)) {
|
if (this.isExcluded(asset)) {
|
||||||
@ -1122,7 +1165,7 @@ export class AssetStore {
|
|||||||
this.#addAssetsToBuckets([...notUpdated]);
|
this.#addAssetsToBuckets([...notUpdated]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#addAssetsToBuckets(assets: AssetResponseDto[]) {
|
#addAssetsToBuckets(assets: TimelineAsset[]) {
|
||||||
if (assets.length === 0) {
|
if (assets.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1139,7 +1182,9 @@ export class AssetStore {
|
|||||||
bucket = new AssetBucket(this, utc, 1, this.#options.order);
|
bucket = new AssetBucket(this, utc, 1, this.#options.order);
|
||||||
this.buckets.push(bucket);
|
this.buckets.push(bucket);
|
||||||
}
|
}
|
||||||
bucket.addAssets([asset]);
|
const addContext = new AddContext();
|
||||||
|
bucket.addTimelineAsset(asset, addContext);
|
||||||
|
addContext.sort(bucket, this.#options.order);
|
||||||
updatedBuckets.add(bucket);
|
updatedBuckets.add(bucket);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1165,7 +1210,7 @@ export class AssetStore {
|
|||||||
await this.initTask.waitUntilCompletion();
|
await this.initTask.waitUntilCompletion();
|
||||||
let bucket = this.#findBucketForAsset(id);
|
let bucket = this.#findBucketForAsset(id);
|
||||||
if (!bucket) {
|
if (!bucket) {
|
||||||
const asset = await getAssetInfo({ id });
|
const asset = toTimelineAsset(await getAssetInfo({ id, key: authManager.key }));
|
||||||
if (!asset || this.isExcluded(asset)) {
|
if (!asset || this.isExcluded(asset)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1178,7 +1223,7 @@ export class AssetStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async #loadBucketAtTime(localDateTime: string, options?: { cancelable: boolean }) {
|
async #loadBucketAtTime(localDateTime: string, options?: { cancelable: boolean }) {
|
||||||
let date = fromLocalDateTime(localDateTime);
|
let date = DateTime.fromISO(localDateTime).toUTC();
|
||||||
// Only support TimeBucketSize.Month
|
// Only support TimeBucketSize.Month
|
||||||
date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 });
|
date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 });
|
||||||
const iso = date.toISO()!;
|
const iso = date.toISO()!;
|
||||||
@ -1188,7 +1233,7 @@ export class AssetStore {
|
|||||||
return this.getBucketByDate(year, month);
|
return this.getBucketByDate(year, month);
|
||||||
}
|
}
|
||||||
|
|
||||||
async #getBucketInfoForAsset(asset: AssetResponseDto, options?: { cancelable: boolean }) {
|
async #getBucketInfoForAsset(asset: { id: string; localDateTime: string }, options?: { cancelable: boolean }) {
|
||||||
const bucketInfo = this.#findBucketForAsset(asset.id);
|
const bucketInfo = this.#findBucketForAsset(asset.id);
|
||||||
if (bucketInfo) {
|
if (bucketInfo) {
|
||||||
return bucketInfo;
|
return bucketInfo;
|
||||||
@ -1222,7 +1267,7 @@ export class AssetStore {
|
|||||||
const changedBuckets = new Set<AssetBucket>();
|
const changedBuckets = new Set<AssetBucket>();
|
||||||
let idsToProcess = new Set(ids);
|
let idsToProcess = new Set(ids);
|
||||||
const idsProcessed = new Set<string>();
|
const idsProcessed = new Set<string>();
|
||||||
const combinedMoveAssets: { asset: AssetResponseDto; year: number; month: number }[][] = [];
|
const combinedMoveAssets: { asset: TimelineAsset; year: number; month: number }[][] = [];
|
||||||
for (const bucket of this.buckets) {
|
for (const bucket of this.buckets) {
|
||||||
if (idsToProcess.size > 0) {
|
if (idsToProcess.size > 0) {
|
||||||
const { moveAssets, processedIds, changedGeometry } = bucket.runAssetOperation(idsToProcess, operation);
|
const { moveAssets, processedIds, changedGeometry } = bucket.runAssetOperation(idsToProcess, operation);
|
||||||
@ -1265,8 +1310,8 @@ export class AssetStore {
|
|||||||
this.#runAssetOperation(new Set(ids), operation);
|
this.#runAssetOperation(new Set(ids), operation);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAssets(assets: AssetResponseDto[]) {
|
updateAssets(assets: TimelineAsset[]) {
|
||||||
const lookup = new Map<string, AssetResponseDto>(assets.map((asset) => [asset.id, asset]));
|
const lookup = new Map<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
|
||||||
const { unprocessedIds } = this.#runAssetOperation(new Set(lookup.keys()), (asset) => {
|
const { unprocessedIds } = this.#runAssetOperation(new Set(lookup.keys()), (asset) => {
|
||||||
updateObject(asset, lookup.get(asset.id));
|
updateObject(asset, lookup.get(asset.id));
|
||||||
return { remove: false };
|
return { remove: false };
|
||||||
@ -1288,11 +1333,11 @@ export class AssetStore {
|
|||||||
this.updateIntersections();
|
this.updateIntersections();
|
||||||
}
|
}
|
||||||
|
|
||||||
getFirstAsset(): AssetResponseDto | undefined {
|
getFirstAsset(): TimelineAsset | undefined {
|
||||||
return this.buckets[0]?.getFirstAsset();
|
return this.buckets[0]?.getFirstAsset();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> {
|
async getPreviousAsset(asset: { id: string; localDateTime: string }): Promise<TimelineAsset | undefined> {
|
||||||
let bucket = await this.#getBucketInfoForAsset(asset);
|
let bucket = await this.#getBucketInfoForAsset(asset);
|
||||||
if (!bucket) {
|
if (!bucket) {
|
||||||
return;
|
return;
|
||||||
@ -1335,7 +1380,7 @@ export class AssetStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> {
|
async getNextAsset(asset: { id: string; localDateTime: string }): Promise<TimelineAsset | undefined> {
|
||||||
let bucket = await this.#getBucketInfoForAsset(asset);
|
let bucket = await this.#getBucketInfoForAsset(asset);
|
||||||
if (!bucket) {
|
if (!bucket) {
|
||||||
return;
|
return;
|
||||||
@ -1374,9 +1419,9 @@ export class AssetStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isExcluded(asset: AssetResponseDto) {
|
isExcluded(asset: TimelineAsset) {
|
||||||
return (
|
return (
|
||||||
isMismatched(this.#options.visibility === AssetVisibility.Archive, asset.isArchived) ||
|
isMismatched(this.#options.visibility, asset.visibility as unknown as AssetVisibility) ||
|
||||||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
||||||
isMismatched(this.#options.isTrashed, asset.isTrashed)
|
isMismatched(this.#options.isTrashed, asset.isTrashed)
|
||||||
);
|
);
|
||||||
|
@ -1,13 +1,8 @@
|
|||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
|
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import { asLocalTimeISO } from '$lib/utils/date-time';
|
import { asLocalTimeISO } from '$lib/utils/date-time';
|
||||||
import {
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
type AssetResponseDto,
|
import { deleteMemory, type MemoryResponseDto, removeMemoryAssets, searchMemories, updateMemory } from '@immich/sdk';
|
||||||
deleteMemory,
|
|
||||||
type MemoryResponseDto,
|
|
||||||
removeMemoryAssets,
|
|
||||||
searchMemories,
|
|
||||||
updateMemory,
|
|
||||||
} from '@immich/sdk';
|
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
type MemoryIndex = {
|
type MemoryIndex = {
|
||||||
@ -17,7 +12,7 @@ type MemoryIndex = {
|
|||||||
|
|
||||||
export type MemoryAsset = MemoryIndex & {
|
export type MemoryAsset = MemoryIndex & {
|
||||||
memory: MemoryResponseDto;
|
memory: MemoryResponseDto;
|
||||||
asset: AssetResponseDto;
|
asset: TimelineAsset;
|
||||||
previousMemory?: MemoryResponseDto;
|
previousMemory?: MemoryResponseDto;
|
||||||
previous?: MemoryAsset;
|
previous?: MemoryAsset;
|
||||||
next?: MemoryAsset;
|
next?: MemoryAsset;
|
||||||
@ -41,7 +36,7 @@ class MemoryStoreSvelte {
|
|||||||
memoryIndex,
|
memoryIndex,
|
||||||
previousMemory: this.memories[memoryIndex - 1],
|
previousMemory: this.memories[memoryIndex - 1],
|
||||||
nextMemory: this.memories[memoryIndex + 1],
|
nextMemory: this.memories[memoryIndex + 1],
|
||||||
asset,
|
asset: toTimelineAsset(asset),
|
||||||
assetIndex,
|
assetIndex,
|
||||||
previous,
|
previous,
|
||||||
};
|
};
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
|
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
|
||||||
import type { AssetStore } from '$lib/stores/assets-store.svelte';
|
import type { AssetStore, TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import type { StackResponse } from '$lib/utils/asset-utils';
|
import type { StackResponse } from '$lib/utils/asset-utils';
|
||||||
import { deleteAssets as deleteBulk, type AssetResponseDto } from '@immich/sdk';
|
import { deleteAssets as deleteBulk, Visibility } from '@immich/sdk';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { handleError } from './handle-error';
|
import { handleError } from './handle-error';
|
||||||
|
|
||||||
export type OnDelete = (assetIds: string[]) => void;
|
export type OnDelete = (assetIds: string[]) => void;
|
||||||
export type OnRestore = (ids: string[]) => void;
|
export type OnRestore = (ids: string[]) => void;
|
||||||
export type OnLink = (assets: { still: AssetResponseDto; motion: AssetResponseDto }) => void;
|
export type OnLink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
|
||||||
export type OnUnlink = (assets: { still: AssetResponseDto; motion: AssetResponseDto }) => void;
|
export type OnUnlink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
|
||||||
export type OnAddToAlbum = (ids: string[], albumId: string) => void;
|
export type OnAddToAlbum = (ids: string[], albumId: string) => void;
|
||||||
export type OnArchive = (ids: string[], isArchived: boolean) => void;
|
export type OnArchive = (ids: string[], visibility: Visibility) => void;
|
||||||
export type OnFavorite = (ids: string[], favorite: boolean) => void;
|
export type OnFavorite = (ids: string[], favorite: boolean) => void;
|
||||||
export type OnStack = (result: StackResponse) => void;
|
export type OnStack = (result: StackResponse) => void;
|
||||||
export type OnUnstack = (assets: AssetResponseDto[]) => void;
|
export type OnUnstack = (assets: TimelineAsset[]) => void;
|
||||||
export type OnSetVisibility = (ids: string[]) => void;
|
export type OnSetVisibility = (ids: string[]) => void;
|
||||||
|
|
||||||
export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => {
|
export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => {
|
||||||
@ -65,11 +65,11 @@ export function updateStackedAssetInTimeline(assetStore: AssetStore, { stack, to
|
|||||||
* @param assetStore - The asset store to update.
|
* @param assetStore - The asset store to update.
|
||||||
* @param assets - The array of asset response DTOs to update in the asset store.
|
* @param assets - The array of asset response DTOs to update in the asset store.
|
||||||
*/
|
*/
|
||||||
export function updateUnstackedAssetInTimeline(assetStore: AssetStore, assets: AssetResponseDto[]) {
|
export function updateUnstackedAssetInTimeline(assetStore: AssetStore, assets: TimelineAsset[]) {
|
||||||
assetStore.updateAssetOperation(
|
assetStore.updateAssetOperation(
|
||||||
assets.map((asset) => asset.id),
|
assets.map((asset) => asset.id),
|
||||||
(asset) => {
|
(asset) => {
|
||||||
asset.stack = undefined;
|
asset.stack = null;
|
||||||
return { remove: false };
|
return { remove: false };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -6,7 +6,12 @@ import { AppRoute } from '$lib/constants';
|
|||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetsSnapshot, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets-store.svelte';
|
import {
|
||||||
|
assetsSnapshot,
|
||||||
|
isSelectingAllAssets,
|
||||||
|
type AssetStore,
|
||||||
|
type TimelineAsset,
|
||||||
|
} from '$lib/stores/assets-store.svelte';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
import { downloadRequest, withError } from '$lib/utils';
|
import { downloadRequest, withError } from '$lib/utils';
|
||||||
import { createAlbum } from '$lib/utils/album-utils';
|
import { createAlbum } from '$lib/utils/album-utils';
|
||||||
@ -366,7 +371,7 @@ export const getAssetType = (type: AssetTypeEnum) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSelectedAssets = (assets: AssetResponseDto[], user: UserResponseDto | null): string[] => {
|
export const getSelectedAssets = (assets: TimelineAsset[], user: UserResponseDto | null): string[] => {
|
||||||
const ids = [...assets].filter((a) => user && a.ownerId === user.id).map((a) => a.id);
|
const ids = [...assets].filter((a) => user && a.ownerId === user.id).map((a) => a.id);
|
||||||
|
|
||||||
const numberOfIssues = [...assets].filter((a) => user && a.ownerId !== user.id).length;
|
const numberOfIssues = [...assets].filter((a) => user && a.ownerId !== user.id).length;
|
||||||
@ -385,7 +390,7 @@ export type StackResponse = {
|
|||||||
toDeleteIds: string[];
|
toDeleteIds: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const stackAssets = async (assets: AssetResponseDto[], showNotification = true): Promise<StackResponse> => {
|
export const stackAssets = async (assets: { id: string }[], showNotification = true): Promise<StackResponse> => {
|
||||||
if (assets.length < 2) {
|
if (assets.length < 2) {
|
||||||
return { stack: undefined, toDeleteIds: [] };
|
return { stack: undefined, toDeleteIds: [] };
|
||||||
}
|
}
|
||||||
@ -405,10 +410,6 @@ export const stackAssets = async (assets: AssetResponseDto[], showNotification =
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [index, asset] of assets.entries()) {
|
|
||||||
asset.stack = index === 0 ? { id: stack.id, assetCount: stack.assets.length, primaryAssetId: asset.id } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stack,
|
stack,
|
||||||
toDeleteIds: assets.slice(1).map((asset) => asset.id),
|
toDeleteIds: assets.slice(1).map((asset) => asset.id),
|
||||||
@ -525,30 +526,29 @@ export const toggleArchive = async (asset: AssetResponseDto) => {
|
|||||||
return asset;
|
return asset;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const archiveAssets = async (assets: AssetResponseDto[], archive: boolean) => {
|
export const archiveAssets = async (assets: { id: string }[], visibility: AssetVisibility) => {
|
||||||
const isArchived = archive;
|
|
||||||
const ids = assets.map(({ id }) => id);
|
const ids = assets.map(({ id }) => id);
|
||||||
const $t = get(t);
|
const $t = get(t);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (ids.length > 0) {
|
if (ids.length > 0) {
|
||||||
await updateAssets({
|
await updateAssets({
|
||||||
assetBulkUpdateDto: { ids, visibility: isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline },
|
assetBulkUpdateDto: { ids, visibility },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const asset of assets) {
|
|
||||||
asset.isArchived = isArchived;
|
|
||||||
}
|
|
||||||
|
|
||||||
notificationController.show({
|
notificationController.show({
|
||||||
message: isArchived
|
message:
|
||||||
? $t('archived_count', { values: { count: ids.length } })
|
visibility === AssetVisibility.Archive
|
||||||
: $t('unarchived_count', { values: { count: ids.length } }),
|
? $t('archived_count', { values: { count: ids.length } })
|
||||||
|
: $t('unarchived_count', { values: { count: ids.length } }),
|
||||||
type: NotificationType.Info,
|
type: NotificationType.Info,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_archive_unarchive', { values: { archived: isArchived } }));
|
handleError(
|
||||||
|
error,
|
||||||
|
$t('errors.unable_to_archive_unarchive', { values: { archived: visibility === AssetVisibility.Archive } }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ids;
|
return ids;
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
import { getAssetRatio } from '$lib/utils/asset-utils';
|
|
||||||
// import { TUNABLES } from '$lib/utils/tunables';
|
// import { TUNABLES } from '$lib/utils/tunables';
|
||||||
// note: it's important that this is not imported in more than one file due to https://github.com/sveltejs/kit/issues/7805
|
// note: it's important that this is not imported in more than one file due to https://github.com/sveltejs/kit/issues/7805
|
||||||
// import { JustifiedLayout, type LayoutOptions } from '@immich/justified-layout-wasm';
|
// import { JustifiedLayout, type LayoutOptions } from '@immich/justified-layout-wasm';
|
||||||
|
|
||||||
|
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
|
import { getAssetRatio } from '$lib/utils/asset-utils';
|
||||||
|
import { isTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
import createJustifiedLayout from 'justified-layout';
|
import createJustifiedLayout from 'justified-layout';
|
||||||
|
|
||||||
@ -26,7 +29,7 @@ export type CommonLayoutOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function getJustifiedLayoutFromAssets(
|
export function getJustifiedLayoutFromAssets(
|
||||||
assets: AssetResponseDto[],
|
assets: (TimelineAsset | AssetResponseDto)[],
|
||||||
options: CommonLayoutOptions,
|
options: CommonLayoutOptions,
|
||||||
): CommonJustifiedLayout {
|
): CommonJustifiedLayout {
|
||||||
// if (useWasm) {
|
// if (useWasm) {
|
||||||
@ -87,7 +90,7 @@ class Adapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayoutOptions) {
|
export function justifiedLayout(assets: (TimelineAsset | AssetResponseDto)[], options: CommonLayoutOptions) {
|
||||||
const adapter = {
|
const adapter = {
|
||||||
targetRowHeight: options.rowHeight,
|
targetRowHeight: options.rowHeight,
|
||||||
containerWidth: options.rowWidth,
|
containerWidth: options.rowWidth,
|
||||||
@ -96,7 +99,7 @@ export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayou
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = createJustifiedLayout(
|
const result = createJustifiedLayout(
|
||||||
assets.map((g) => getAssetRatio(g)),
|
assets.map((asset) => (isTimelineAsset(asset) ? asset.ratio : getAssetRatio(asset))),
|
||||||
adapter,
|
adapter,
|
||||||
);
|
);
|
||||||
return new Adapter(result);
|
return new Adapter(result);
|
||||||
|
@ -1,17 +1,15 @@
|
|||||||
import type { AssetResponseDto } from '@immich/sdk';
|
|
||||||
|
|
||||||
export class SlideshowHistory {
|
export class SlideshowHistory {
|
||||||
private history: AssetResponseDto[] = [];
|
private history: { id: string }[] = [];
|
||||||
private index = 0;
|
private index = 0;
|
||||||
|
|
||||||
constructor(private onChange: (asset: AssetResponseDto) => void) {}
|
constructor(private onChange: (asset: { id: string }) => void) {}
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.history = [];
|
this.history = [];
|
||||||
this.index = 0;
|
this.index = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
queue(asset: AssetResponseDto) {
|
queue(asset: { id: string }) {
|
||||||
this.history.push(asset);
|
this.history.push(asset);
|
||||||
|
|
||||||
// If we were at the end of the slideshow history, move the index to the new end
|
// If we were at the end of the slideshow history, move the index to the new end
|
||||||
|
@ -1,11 +1,16 @@
|
|||||||
|
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
import { Visibility } from '@immich/sdk';
|
||||||
import { init, register, waitLocale } from 'svelte-i18n';
|
import { init, register, waitLocale } from 'svelte-i18n';
|
||||||
|
|
||||||
const onePerson = [{ name: 'person' }];
|
interface Person {
|
||||||
const twoPeople = [{ name: 'person1' }, { name: 'person2' }];
|
name: string;
|
||||||
const threePeople = [{ name: 'person1' }, { name: 'person2' }, { name: 'person3' }];
|
}
|
||||||
const fourPeople = [{ name: 'person1' }, { name: 'person2' }, { name: 'person3' }, { name: 'person4' }];
|
|
||||||
|
const onePerson: Person[] = [{ name: 'person' }];
|
||||||
|
const twoPeople: Person[] = [{ name: 'person1' }, { name: 'person2' }];
|
||||||
|
const threePeople: Person[] = [{ name: 'person1' }, { name: 'person2' }, { name: 'person3' }];
|
||||||
|
const fourPeople: Person[] = [{ name: 'person1' }, { name: 'person2' }, { name: 'person3' }, { name: 'person4' }];
|
||||||
|
|
||||||
describe('getAltText', () => {
|
describe('getAltText', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@ -38,27 +43,44 @@ describe('getAltText', () => {
|
|||||||
${true} | ${'city'} | ${'country'} | ${fourPeople} | ${'Video taken in city, country with person1, person2, and 2 others on January 1, 2024'}
|
${true} | ${'city'} | ${'country'} | ${fourPeople} | ${'Video taken in city, country with person1, person2, and 2 others on January 1, 2024'}
|
||||||
`(
|
`(
|
||||||
'generates correctly formatted alt text when isVideo=$isVideo, city=$city, country=$country, people=$people.length',
|
'generates correctly formatted alt text when isVideo=$isVideo, city=$city, country=$country, people=$people.length',
|
||||||
({ isVideo, city, country, people, expected }) => {
|
({
|
||||||
const asset = {
|
isVideo,
|
||||||
exifInfo: { city, country },
|
city,
|
||||||
|
country,
|
||||||
|
people,
|
||||||
|
expected,
|
||||||
|
}: {
|
||||||
|
isVideo: boolean;
|
||||||
|
city?: string;
|
||||||
|
country?: string;
|
||||||
|
people?: Person[];
|
||||||
|
expected: string;
|
||||||
|
}) => {
|
||||||
|
const asset: TimelineAsset = {
|
||||||
|
id: 'test-id',
|
||||||
|
ownerId: 'test-owner',
|
||||||
|
ratio: 1,
|
||||||
|
thumbhash: null,
|
||||||
localDateTime: '2024-01-01T12:00:00.000Z',
|
localDateTime: '2024-01-01T12:00:00.000Z',
|
||||||
people,
|
visibility: Visibility.Timeline,
|
||||||
type: isVideo ? AssetTypeEnum.Video : AssetTypeEnum.Image,
|
isFavorite: false,
|
||||||
} as AssetResponseDto;
|
isTrashed: false,
|
||||||
|
isVideo,
|
||||||
|
isImage: !isVideo,
|
||||||
|
stack: null,
|
||||||
|
duration: null,
|
||||||
|
projectionType: null,
|
||||||
|
livePhotoVideoId: null,
|
||||||
|
text: {
|
||||||
|
city: city ?? null,
|
||||||
|
country: country ?? null,
|
||||||
|
people: people?.map((person: Person) => person.name) ?? [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
getAltText.subscribe((fn) => {
|
getAltText.subscribe((fn) => {
|
||||||
expect(fn(asset)).toEqual(expected);
|
expect(fn(asset)).toEqual(expected);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it('defaults to the description, if available', () => {
|
|
||||||
const asset = {
|
|
||||||
exifInfo: { description: 'description' },
|
|
||||||
} as AssetResponseDto;
|
|
||||||
|
|
||||||
getAltText.subscribe((fn) => {
|
|
||||||
expect(fn(asset)).toEqual('description');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
|
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { derived, get } from 'svelte/store';
|
import { derived, get } from 'svelte/store';
|
||||||
import { fromLocalDateTime } from './timeline-util';
|
import { fromLocalDateTime } from './timeline-util';
|
||||||
@ -39,21 +39,18 @@ export function getThumbnailSize(assetCount: number, viewWidth: number): number
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getAltText = derived(t, ($t) => {
|
export const getAltText = derived(t, ($t) => {
|
||||||
return (asset: AssetResponseDto) => {
|
return (asset: TimelineAsset) => {
|
||||||
if (asset.exifInfo?.description) {
|
|
||||||
return asset.exifInfo.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }, { locale: get(locale) });
|
const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }, { locale: get(locale) });
|
||||||
const hasPlace = !!asset.exifInfo?.city && !!asset.exifInfo?.country;
|
const { city, country, people: names } = asset.text;
|
||||||
const names = asset.people?.filter((p) => p.name).map((p) => p.name) ?? [];
|
const hasPlace = city && country;
|
||||||
|
|
||||||
const peopleCount = names.length;
|
const peopleCount = names.length;
|
||||||
const isVideo = asset.type === AssetTypeEnum.Video;
|
const isVideo = asset.isVideo;
|
||||||
|
|
||||||
const values = {
|
const values = {
|
||||||
date,
|
date,
|
||||||
city: asset.exifInfo?.city,
|
city,
|
||||||
country: asset.exifInfo?.country,
|
country,
|
||||||
person1: names[0],
|
person1: names[0],
|
||||||
person2: names[1],
|
person2: names[1],
|
||||||
person3: names[2],
|
person3: names[2],
|
||||||
|
@ -1,4 +1,8 @@
|
|||||||
|
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
import { getAssetRatio } from '$lib/utils/asset-utils';
|
||||||
|
|
||||||
|
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { memoize } from 'lodash-es';
|
import { memoize } from 'lodash-es';
|
||||||
import { DateTime, type LocaleOptions } from 'luxon';
|
import { DateTime, type LocaleOptions } from 'luxon';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
@ -56,3 +60,39 @@ export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): strin
|
|||||||
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
|
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
|
||||||
|
|
||||||
export const formatDateGroupTitle = memoize(formatGroupTitle);
|
export const formatDateGroupTitle = memoize(formatGroupTitle);
|
||||||
|
|
||||||
|
export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): TimelineAsset => {
|
||||||
|
if (isTimelineAsset(unknownAsset)) {
|
||||||
|
return unknownAsset;
|
||||||
|
}
|
||||||
|
const assetResponse = unknownAsset as AssetResponseDto;
|
||||||
|
const { width, height } = getAssetRatio(assetResponse);
|
||||||
|
const ratio = width / height;
|
||||||
|
const city = assetResponse.exifInfo?.city;
|
||||||
|
const country = assetResponse.exifInfo?.country;
|
||||||
|
const people = assetResponse.people?.map((person) => person.name) || [];
|
||||||
|
const text = {
|
||||||
|
city: city || null,
|
||||||
|
country: country || null,
|
||||||
|
people,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id: assetResponse.id,
|
||||||
|
ownerId: assetResponse.ownerId,
|
||||||
|
ratio,
|
||||||
|
thumbhash: assetResponse.thumbhash,
|
||||||
|
localDateTime: assetResponse.localDateTime,
|
||||||
|
isFavorite: assetResponse.isFavorite,
|
||||||
|
visibility: assetResponse.visibility,
|
||||||
|
isTrashed: assetResponse.isTrashed,
|
||||||
|
isVideo: assetResponse.type == AssetTypeEnum.Video,
|
||||||
|
isImage: assetResponse.type == AssetTypeEnum.Image,
|
||||||
|
stack: assetResponse.stack || null,
|
||||||
|
duration: assetResponse.duration || null,
|
||||||
|
projectionType: assetResponse.exifInfo?.projectionType || null,
|
||||||
|
livePhotoVideoId: assetResponse.livePhotoVideoId || null,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export const isTimelineAsset = (asset: AssetResponseDto | TimelineAsset): asset is TimelineAsset =>
|
||||||
|
(asset as TimelineAsset).ratio !== undefined;
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
function getBoolean(string: string | null, fallback: boolean) {
|
function getBoolean(string: string | null, fallback: boolean) {
|
||||||
if (string === null) {
|
if (string === null) {
|
||||||
return fallback;
|
return fallback;
|
||||||
@ -10,18 +12,23 @@ function getNumber(string: string | null, fallback: number) {
|
|||||||
}
|
}
|
||||||
return Number.parseInt(string);
|
return Number.parseInt(string);
|
||||||
}
|
}
|
||||||
|
const storage = browser
|
||||||
|
? localStorage
|
||||||
|
: {
|
||||||
|
getItem: () => null,
|
||||||
|
};
|
||||||
export const TUNABLES = {
|
export const TUNABLES = {
|
||||||
LAYOUT: {
|
LAYOUT: {
|
||||||
WASM: getBoolean(localStorage.getItem('LAYOUT.WASM'), false),
|
WASM: getBoolean(storage.getItem('LAYOUT.WASM'), false),
|
||||||
},
|
},
|
||||||
TIMELINE: {
|
TIMELINE: {
|
||||||
INTERSECTION_EXPAND_TOP: getNumber(localStorage.getItem('TIMELINE_INTERSECTION_EXPAND_TOP'), 500),
|
INTERSECTION_EXPAND_TOP: getNumber(storage.getItem('TIMELINE_INTERSECTION_EXPAND_TOP'), 500),
|
||||||
INTERSECTION_EXPAND_BOTTOM: getNumber(localStorage.getItem('TIMELINE_INTERSECTION_EXPAND_BOTTOM'), 500),
|
INTERSECTION_EXPAND_BOTTOM: getNumber(storage.getItem('TIMELINE_INTERSECTION_EXPAND_BOTTOM'), 500),
|
||||||
},
|
},
|
||||||
ASSET_GRID: {
|
ASSET_GRID: {
|
||||||
NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(localStorage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false),
|
NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(storage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false),
|
||||||
},
|
},
|
||||||
IMAGE_THUMBNAIL: {
|
IMAGE_THUMBNAIL: {
|
||||||
THUMBHASH_FADE_DURATION: getNumber(localStorage.getItem('THUMBHASH_FADE_DURATION'), 150),
|
THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 150),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -92,7 +92,7 @@
|
|||||||
|
|
||||||
let { data = $bindable() }: Props = $props();
|
let { data = $bindable() }: Props = $props();
|
||||||
|
|
||||||
let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore;
|
let { isViewing: showAssetViewer, setAssetId, gridScrollTarget } = assetViewingStore;
|
||||||
let { slideshowState, slideshowNavigation } = slideshowStore;
|
let { slideshowState, slideshowNavigation } = slideshowStore;
|
||||||
|
|
||||||
let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
|
let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
|
||||||
@ -174,8 +174,7 @@
|
|||||||
? await assetStore.getRandomAsset()
|
? await assetStore.getRandomAsset()
|
||||||
: assetStore.buckets[0]?.dateGroups[0]?.intersetingAssets[0]?.asset;
|
: assetStore.buckets[0]?.dateGroups[0]?.intersetingAssets[0]?.asset;
|
||||||
if (asset) {
|
if (asset) {
|
||||||
setAsset(asset);
|
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
|
||||||
$slideshowState = SlideshowState.PlaySlideshow;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -47,9 +47,9 @@
|
|||||||
>
|
>
|
||||||
<ArchiveAction
|
<ArchiveAction
|
||||||
unarchive
|
unarchive
|
||||||
onArchive={(ids, isArchived) =>
|
onArchive={(ids, visibility) =>
|
||||||
assetStore.updateAssetOperation(ids, (asset) => {
|
assetStore.updateAssetOperation(ids, (asset) => {
|
||||||
asset.isArchived = isArchived;
|
asset.visibility = visibility;
|
||||||
return { remove: false };
|
return { remove: false };
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
@ -31,7 +31,7 @@
|
|||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
const assetStore = new AssetStore();
|
const assetStore = new AssetStore();
|
||||||
void assetStore.updateOptions({ isFavorite: true });
|
void assetStore.updateOptions({ isFavorite: true, withStacked: true });
|
||||||
onDestroy(() => assetStore.destroy());
|
onDestroy(() => assetStore.destroy());
|
||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
@ -78,6 +78,7 @@
|
|||||||
<UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}>
|
<UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}>
|
||||||
<AssetGrid
|
<AssetGrid
|
||||||
enableRouting={true}
|
enableRouting={true}
|
||||||
|
withStacked={true}
|
||||||
{assetStore}
|
{assetStore}
|
||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
removeAction={AssetAction.UNFAVORITE}
|
removeAction={AssetAction.UNFAVORITE}
|
||||||
|
@ -28,6 +28,7 @@
|
|||||||
import { foldersStore } from '$lib/stores/folders.svelte';
|
import { foldersStore } from '$lib/stores/folders.svelte';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||||
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
|
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
|
||||||
import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
|
import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@ -49,15 +50,15 @@
|
|||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async function initializeFolders() {
|
||||||
await foldersStore.fetchUniquePaths();
|
await foldersStore.fetchUniquePaths();
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleNavigation = async (folderName: string) => {
|
const handleNavigateToFolder = async function handleNavigateToFolder(folderName: string) {
|
||||||
await navigateToView(normalizeTreePath(`${data.path || ''}/${folderName}`));
|
await navigateToView(normalizeTreePath(`${data.path || ''}/${folderName}`));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLink = (path: string) => {
|
const getLinkForPath = function getLinkForPath(path: string) {
|
||||||
const url = new URL(AppRoute.FOLDERS, globalThis.location.href);
|
const url = new URL(AppRoute.FOLDERS, globalThis.location.href);
|
||||||
if (path) {
|
if (path) {
|
||||||
url.searchParams.set(QueryParameter.PATH, path);
|
url.searchParams.set(QueryParameter.PATH, path);
|
||||||
@ -65,25 +66,27 @@
|
|||||||
return url.href;
|
return url.href;
|
||||||
};
|
};
|
||||||
|
|
||||||
afterNavigate(() => {
|
afterNavigate(function clearAssetSelection() {
|
||||||
// Clear the asset selection when we navigate (like going to another folder)
|
// Clear the asset selection when we navigate (like going to another folder)
|
||||||
cancelMultiselect(assetInteraction);
|
cancelMultiselect(assetInteraction);
|
||||||
});
|
});
|
||||||
|
|
||||||
const navigateToView = (path: string) => goto(getLink(path));
|
const navigateToView = function navigateToView(path: string) {
|
||||||
|
return goto(getLinkForPath(path));
|
||||||
|
};
|
||||||
|
|
||||||
const triggerAssetUpdate = async () => {
|
const triggerAssetUpdate = async function updateAssets() {
|
||||||
cancelMultiselect(assetInteraction);
|
cancelMultiselect(assetInteraction);
|
||||||
await foldersStore.refreshAssetsByPath(data.path);
|
await foldersStore.refreshAssetsByPath(data.path);
|
||||||
await invalidateAll();
|
await invalidateAll();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAllAssets = function handleSelectAllAssets() {
|
||||||
if (!data.pathAssets) {
|
if (!data.pathAssets) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
assetInteraction.selectAssets(data.pathAssets);
|
assetInteraction.selectAssets(data.pathAssets.map((asset) => toTimelineAsset(asset)));
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -94,14 +97,14 @@
|
|||||||
clearSelect={() => cancelMultiselect(assetInteraction)}
|
clearSelect={() => cancelMultiselect(assetInteraction)}
|
||||||
>
|
>
|
||||||
<CreateSharedLink />
|
<CreateSharedLink />
|
||||||
<CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAll} />
|
<CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAllAssets} />
|
||||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||||
<AddToAlbum onAddToAlbum={() => cancelMultiselect(assetInteraction)} />
|
<AddToAlbum onAddToAlbum={() => cancelMultiselect(assetInteraction)} />
|
||||||
<AddToAlbum onAddToAlbum={() => cancelMultiselect(assetInteraction)} shared />
|
<AddToAlbum onAddToAlbum={() => cancelMultiselect(assetInteraction)} shared />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction
|
<FavoriteAction
|
||||||
removeFavorite={assetInteraction.isAllFavorite}
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
onFavorite={(ids, isFavorite) => {
|
onFavorite={function handleFavoriteUpdate(ids, isFavorite) {
|
||||||
if (data.pathAssets && data.pathAssets.length > 0) {
|
if (data.pathAssets && data.pathAssets.length > 0) {
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const asset = data.pathAssets.find((asset) => asset.id === id);
|
const asset = data.pathAssets.find((asset) => asset.id === id);
|
||||||
@ -141,17 +144,17 @@
|
|||||||
icons={{ default: mdiFolderOutline, active: mdiFolder }}
|
icons={{ default: mdiFolderOutline, active: mdiFolder }}
|
||||||
items={tree}
|
items={tree}
|
||||||
active={currentPath}
|
active={currentPath}
|
||||||
{getLink}
|
getLink={getLinkForPath}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<Breadcrumbs {pathSegments} icon={mdiFolderHome} title={$t('folders')} {getLink} />
|
<Breadcrumbs {pathSegments} icon={mdiFolderHome} title={$t('folders')} getLink={getLinkForPath} />
|
||||||
|
|
||||||
<section class="mt-2 h-[calc(100%-theme(spacing.20))] overflow-auto immich-scrollbar">
|
<section class="mt-2 h-[calc(100%-theme(spacing.20))] overflow-auto immich-scrollbar">
|
||||||
<TreeItemThumbnails items={currentTreeItems} icon={mdiFolder} onClick={handleNavigation} />
|
<TreeItemThumbnails items={currentTreeItems} icon={mdiFolder} onClick={handleNavigateToFolder} />
|
||||||
|
|
||||||
<!-- Assets -->
|
<!-- Assets -->
|
||||||
{#if data.pathAssets && data.pathAssets.length > 0}
|
{#if data.pathAssets && data.pathAssets.length > 0}
|
||||||
|
@ -35,7 +35,7 @@
|
|||||||
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
|
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
import { websocketEvents } from '$lib/stores/websocket';
|
import { websocketEvents } from '$lib/stores/websocket';
|
||||||
@ -47,7 +47,6 @@
|
|||||||
getPersonStatistics,
|
getPersonStatistics,
|
||||||
searchPerson,
|
searchPerson,
|
||||||
updatePerson,
|
updatePerson,
|
||||||
type AssetResponseDto,
|
|
||||||
type PersonResponseDto,
|
type PersonResponseDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
@ -204,7 +203,7 @@
|
|||||||
data = { ...data, person };
|
data = { ...data, person };
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectFeaturePhoto = async (asset: AssetResponseDto) => {
|
const handleSelectFeaturePhoto = async (asset: TimelineAsset) => {
|
||||||
if (viewMode !== PersonPageViewMode.SELECT_PERSON) {
|
if (viewMode !== PersonPageViewMode.SELECT_PERSON) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -34,7 +34,8 @@
|
|||||||
type OnUnlink,
|
type OnUnlink,
|
||||||
} from '$lib/utils/actions';
|
} from '$lib/utils/actions';
|
||||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||||
import { AssetTypeEnum, AssetVisibility } from '@immich/sdk';
|
import { AssetVisibility } from '@immich/sdk';
|
||||||
|
|
||||||
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
|
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@ -52,8 +53,8 @@
|
|||||||
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
|
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
|
||||||
const isLivePhotoCandidate =
|
const isLivePhotoCandidate =
|
||||||
selectedAssets.length === 2 &&
|
selectedAssets.length === 2 &&
|
||||||
selectedAssets.some((asset) => asset.type === AssetTypeEnum.Image) &&
|
selectedAssets.some((asset) => asset.isImage) &&
|
||||||
selectedAssets.some((asset) => asset.type === AssetTypeEnum.Video);
|
selectedAssets.some((asset) => asset.isVideo);
|
||||||
|
|
||||||
return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
|
return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
|
||||||
});
|
});
|
||||||
|
@ -25,7 +25,7 @@
|
|||||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import type { Viewport } from '$lib/stores/assets-store.svelte';
|
import type { TimelineAsset, Viewport } from '$lib/stores/assets-store.svelte';
|
||||||
import { lang, locale } from '$lib/stores/preferences.store';
|
import { lang, locale } from '$lib/stores/preferences.store';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
@ -34,9 +34,9 @@
|
|||||||
import { parseUtcDate } from '$lib/utils/date-time';
|
import { parseUtcDate } from '$lib/utils/date-time';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
|
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
|
||||||
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import {
|
import {
|
||||||
type AlbumResponseDto,
|
type AlbumResponseDto,
|
||||||
type AssetResponseDto,
|
|
||||||
getPerson,
|
getPerson,
|
||||||
getTagById,
|
getTagById,
|
||||||
type MetadataSearchDto,
|
type MetadataSearchDto,
|
||||||
@ -59,7 +59,7 @@
|
|||||||
|
|
||||||
let nextPage = $state(1);
|
let nextPage = $state(1);
|
||||||
let searchResultAlbums: AlbumResponseDto[] = $state([]);
|
let searchResultAlbums: AlbumResponseDto[] = $state([]);
|
||||||
let searchResultAssets: AssetResponseDto[] = $state([]);
|
let searchResultAssets: TimelineAsset[] = $state([]);
|
||||||
let isLoading = $state(true);
|
let isLoading = $state(true);
|
||||||
let scrollY = $state(0);
|
let scrollY = $state(0);
|
||||||
let scrollYHistory = 0;
|
let scrollYHistory = 0;
|
||||||
@ -123,7 +123,7 @@
|
|||||||
|
|
||||||
const onAssetDelete = (assetIds: string[]) => {
|
const onAssetDelete = (assetIds: string[]) => {
|
||||||
const assetIdSet = new Set(assetIds);
|
const assetIdSet = new Set(assetIds);
|
||||||
searchResultAssets = searchResultAssets.filter((a: AssetResponseDto) => !assetIdSet.has(a.id));
|
searchResultAssets = searchResultAssets.filter((asset: TimelineAsset) => !assetIdSet.has(asset.id));
|
||||||
};
|
};
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
assetInteraction.selectAssets(searchResultAssets);
|
assetInteraction.selectAssets(searchResultAssets);
|
||||||
@ -161,7 +161,7 @@
|
|||||||
: await searchAssets({ metadataSearchDto: searchDto });
|
: await searchAssets({ metadataSearchDto: searchDto });
|
||||||
|
|
||||||
searchResultAlbums.push(...albums.items);
|
searchResultAlbums.push(...albums.items);
|
||||||
searchResultAssets.push(...assets.items);
|
searchResultAssets.push(...assets.items.map((asset) => toTimelineAsset(asset)));
|
||||||
|
|
||||||
nextPage = Number(assets.nextPage) || 0;
|
nextPage = Number(assets.nextPage) || 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -239,7 +239,7 @@
|
|||||||
|
|
||||||
if (terms.isNotInAlbum.toString() == 'true') {
|
if (terms.isNotInAlbum.toString() == 'true') {
|
||||||
const assetIdSet = new Set(assetIds);
|
const assetIdSet = new Set(assetIds);
|
||||||
searchResultAssets = searchResultAssets.filter((a: AssetResponseDto) => !assetIdSet.has(a.id));
|
searchResultAssets = searchResultAssets.filter((asset) => !assetIdSet.has(asset.id));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -250,30 +250,81 @@
|
|||||||
|
|
||||||
<svelte:window use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} bind:scrollY />
|
<svelte:window use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} bind:scrollY />
|
||||||
|
|
||||||
|
<section>
|
||||||
|
{#if assetInteraction.selectionActive}
|
||||||
|
<div class="fixed top-0 start-0 w-full">
|
||||||
|
<AssetSelectControlBar
|
||||||
|
assets={assetInteraction.selectedAssets}
|
||||||
|
clearSelect={() => cancelMultiselect(assetInteraction)}
|
||||||
|
>
|
||||||
|
<CreateSharedLink />
|
||||||
|
<CircleIconButton title={$t('select_all')} icon={mdiSelectAll} onclick={handleSelectAll} />
|
||||||
|
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||||
|
<AddToAlbum {onAddToAlbum} />
|
||||||
|
<AddToAlbum shared {onAddToAlbum} />
|
||||||
|
</ButtonContextMenu>
|
||||||
|
<FavoriteAction
|
||||||
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
|
onFavorite={(assetIds, isFavorite) => {
|
||||||
|
for (const assetId of assetIds) {
|
||||||
|
const asset = searchResultAssets.find((searchAsset) => searchAsset.id === assetId);
|
||||||
|
if (asset) {
|
||||||
|
asset.isFavorite = isFavorite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
|
<DownloadAction menuItem />
|
||||||
|
<ChangeDate menuItem />
|
||||||
|
<ChangeLocation menuItem />
|
||||||
|
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
|
||||||
|
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||||
|
<TagAction menuItem />
|
||||||
|
{/if}
|
||||||
|
<DeleteAssets menuItem {onAssetDelete} />
|
||||||
|
<hr />
|
||||||
|
<AssetJobActions />
|
||||||
|
</ButtonContextMenu>
|
||||||
|
</AssetSelectControlBar>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="fixed top-0 start-0 w-full">
|
||||||
|
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
||||||
|
<div class="absolute bg-light"></div>
|
||||||
|
<div class="w-full flex-1 ps-4">
|
||||||
|
<SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
|
||||||
|
</div>
|
||||||
|
</ControlAppBar>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
{#if terms}
|
{#if terms}
|
||||||
<section
|
<section
|
||||||
id="search-chips"
|
id="search-chips"
|
||||||
class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24"
|
class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24"
|
||||||
>
|
>
|
||||||
{#each getObjectKeys(terms) as key (key)}
|
{#each getObjectKeys(terms) as searchKey (searchKey)}
|
||||||
{@const value = terms[key]}
|
{@const value = terms[searchKey]}
|
||||||
<div class="flex place-content-center place-items-center text-xs">
|
<div class="flex place-content-center place-items-center text-xs">
|
||||||
<div
|
<div
|
||||||
class="bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
|
class="bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
|
||||||
{value === true ? 'rounded-full' : 'rounded-s-full'}"
|
{value === true ? 'rounded-full' : 'rounded-s-full'}"
|
||||||
>
|
>
|
||||||
{getHumanReadableSearchKey(key as keyof SearchTerms)}
|
{getHumanReadableSearchKey(searchKey as keyof SearchTerms)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if value !== true}
|
{#if value !== true}
|
||||||
<div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-e-full">
|
<div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-e-full">
|
||||||
{#if (key === 'takenAfter' || key === 'takenBefore') && typeof value === 'string'}
|
{#if (searchKey === 'takenAfter' || searchKey === 'takenBefore') && typeof value === 'string'}
|
||||||
{getHumanReadableDate(value)}
|
{getHumanReadableDate(value)}
|
||||||
{:else if key === 'personIds' && Array.isArray(value)}
|
{:else if searchKey === 'personIds' && Array.isArray(value)}
|
||||||
{#await getPersonName(value) then personName}
|
{#await getPersonName(value) then personName}
|
||||||
{personName}
|
{personName}
|
||||||
{/await}
|
{/await}
|
||||||
{:else if key === 'tagIds' && Array.isArray(value)}
|
{:else if searchKey === 'tagIds' && Array.isArray(value)}
|
||||||
{#await getTagNames(value) then tagNames}
|
{#await getTagNames(value) then tagNames}
|
||||||
{tagNames}
|
{tagNames}
|
||||||
{/await}
|
{/await}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import { AssetTypeEnum, Visibility, type AssetResponseDto } from '@immich/sdk';
|
import { AssetTypeEnum, Visibility, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { Sync } from 'factory.ts';
|
import { Sync } from 'factory.ts';
|
||||||
@ -26,3 +27,25 @@ export const assetFactory = Sync.makeFactory<AssetResponseDto>({
|
|||||||
hasMetadata: Sync.each(() => faker.datatype.boolean()),
|
hasMetadata: Sync.each(() => faker.datatype.boolean()),
|
||||||
visibility: Visibility.Timeline,
|
visibility: Visibility.Timeline,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
|
||||||
|
id: Sync.each(() => faker.string.uuid()),
|
||||||
|
ratio: Sync.each(() => faker.number.int()),
|
||||||
|
ownerId: Sync.each(() => faker.string.uuid()),
|
||||||
|
thumbhash: Sync.each(() => faker.string.alphanumeric(28)),
|
||||||
|
localDateTime: Sync.each(() => faker.date.past().toISOString()),
|
||||||
|
isFavorite: Sync.each(() => faker.datatype.boolean()),
|
||||||
|
visibility: Visibility.Timeline,
|
||||||
|
isTrashed: false,
|
||||||
|
isImage: true,
|
||||||
|
isVideo: false,
|
||||||
|
duration: '0:00:00.00000',
|
||||||
|
stack: null,
|
||||||
|
projectionType: null,
|
||||||
|
livePhotoVideoId: Sync.each(() => faker.string.uuid()),
|
||||||
|
text: Sync.each(() => ({
|
||||||
|
city: faker.location.city(),
|
||||||
|
country: faker.location.country(),
|
||||||
|
people: [faker.person.fullName()],
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user