immich/web/src/lib/components/photos-page/asset-grid.svelte
Jason Rasmussen 5b87abb021
feat(server): read-write external assets ()
* refactor: remove isReadOnly and isExternal usages

* chore: open api

* fix: linting

* remove mobile isReadOnly dependency

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-05-03 15:34:57 -04:00

494 lines
16 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { BucketPosition, isSelectingAllAssets, type AssetStore, type Viewport } from '$lib/stores/assets.store';
import { locale, showDeleteModal } from '$lib/stores/preferences.store';
import { isSearchEnabled } from '$lib/stores/search.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { deleteAssets } from '$lib/utils/actions';
import { type ShortcutOptions, shortcuts } from '$lib/utils/shortcut';
import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
import Portal from '../shared-components/portal/portal.svelte';
import Scrollbar from '../shared-components/scrollbar/scrollbar.svelte';
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
import AssetDateGroup from './asset-date-group.svelte';
import { stackAssets } from '$lib/utils/asset-utils';
import DeleteAssetDialog from './delete-asset-dialog.svelte';
import { handlePromiseError } from '$lib/utils';
import { selectAllAssets } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
export let isSelectionMode = false;
export let singleSelect = false;
export let assetStore: AssetStore;
export let assetInteractionStore: AssetInteractionStore;
export let removeAction: AssetAction | null = null;
export let withStacked = false;
export let showArchiveIcon = false;
export let isShared = false;
export let album: AlbumResponseDto | null = null;
export let isShowDeleteConfirmation = false;
$: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash;
const { assetSelectionCandidates, assetSelectionStart, selectedGroup, selectedAssets, isMultiSelectState } =
assetInteractionStore;
const viewport: Viewport = { width: 0, height: 0 };
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets } = assetViewingStore;
let element: HTMLElement;
let showShortcuts = false;
let showSkeleton = true;
$: timelineY = element?.scrollTop || 0;
$: isEmpty = $assetStore.initialized && $assetStore.buckets.length === 0;
$: idsSelectedAssets = [...$selectedAssets].map(({ id }) => id);
$: {
void assetStore.updateViewport(viewport);
}
const dispatch = createEventDispatcher<{ select: AssetResponseDto; escape: void }>();
onMount(async () => {
showSkeleton = false;
assetStore.connect();
await assetStore.init(viewport);
});
onDestroy(() => {
if ($showAssetViewer) {
$showAssetViewer = false;
}
assetStore.disconnect();
});
const trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false;
await deleteAssets(!(isTrashEnabled && !force), (assetIds) => assetStore.removeAssets(assetIds), idsSelectedAssets);
assetInteractionStore.clearMultiselect();
};
const onDelete = () => {
const hasTrashedAsset = Array.from($selectedAssets).some((asset) => asset.isTrashed);
if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(hasTrashedAsset));
};
const onForceDelete = () => {
if ($showDeleteModal) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(true));
};
const onStackAssets = async () => {
const ids = await stackAssets(Array.from($selectedAssets));
if (ids) {
assetStore.removeAssets(ids);
dispatch('escape');
}
};
$: shortcutList = (() => {
if ($isSearchEnabled || $showAssetViewer) {
return [];
}
const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: 'Escape' }, onShortcut: () => dispatch('escape') },
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteractionStore) },
{ shortcut: { key: 'PageUp' }, onShortcut: () => (element.scrollTop = 0) },
{ shortcut: { key: 'PageDown' }, onShortcut: () => (element.scrollTop = viewport.height) },
];
if ($isMultiSelectState) {
shortcuts.push(
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
);
}
return shortcuts;
})();
const handleSelectAsset = (asset: AssetResponseDto) => {
if (!assetStore.albumAssets.has(asset.id)) {
assetInteractionStore.selectAsset(asset);
}
};
async function intersectedHandler(event: CustomEvent) {
const element_ = event.detail.container as HTMLElement;
const target = element_.firstChild as HTMLElement;
if (target) {
const bucketDate = target.id.split('_')[1];
await assetStore.loadBucket(bucketDate, event.detail.position);
}
}
function handleScrollTimeline(event: CustomEvent) {
element.scrollBy(0, event.detail.heightDelta);
}
const handlePrevious = async () => {
const previousAsset = await assetStore.getPreviousAsset($viewingAsset);
if (previousAsset) {
const preloadAsset = await assetStore.getPreviousAsset(previousAsset);
assetViewingStore.setAsset(previousAsset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: previousAsset.id });
}
return !!previousAsset;
};
const handleNext = async () => {
const nextAsset = await assetStore.getNextAsset($viewingAsset);
if (nextAsset) {
const preloadAsset = await assetStore.getNextAsset(nextAsset);
assetViewingStore.setAsset(nextAsset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: nextAsset.id });
}
return !!nextAsset;
};
const handleClose = () => assetViewingStore.showAssetViewer(false);
const handleAction = async (action: AssetAction, asset: AssetResponseDto) => {
switch (action) {
case removeAction:
case AssetAction.TRASH:
case AssetAction.RESTORE:
case AssetAction.DELETE: {
// find the next asset to show or close the viewer
(await handleNext()) || (await handlePrevious()) || handleClose();
// delete after find the next one
assetStore.removeAssets([asset.id]);
break;
}
case AssetAction.ARCHIVE:
case AssetAction.UNARCHIVE:
case AssetAction.FAVORITE:
case AssetAction.UNFAVORITE: {
assetStore.updateAssets([asset]);
break;
}
case AssetAction.ADD: {
assetStore.addAssets([asset]);
break;
}
}
};
let animationTick = false;
const handleTimelineScroll = () => {
if (animationTick) {
return;
}
animationTick = true;
window.requestAnimationFrame(() => {
timelineY = element?.scrollTop || 0;
animationTick = false;
});
};
let lastAssetMouseEvent: AssetResponseDto | null = null;
$: if (!lastAssetMouseEvent) {
assetInteractionStore.clearAssetSelectionCandidates();
}
let shiftKeyIsDown = false;
const deselectAllAssets = () => {
$isSelectingAllAssets = false;
assetInteractionStore.clearMultiselect();
};
const onKeyDown = (event: KeyboardEvent) => {
if ($isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = true;
}
};
const onKeyUp = (event: KeyboardEvent) => {
if ($isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = false;
}
};
$: if (!shiftKeyIsDown) {
assetInteractionStore.clearAssetSelectionCandidates();
}
$: if (shiftKeyIsDown && lastAssetMouseEvent) {
selectAssetCandidates(lastAssetMouseEvent);
}
const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => {
if (asset) {
selectAssetCandidates(asset);
}
lastAssetMouseEvent = asset;
};
const handleGroupSelect = (group: string, assets: AssetResponseDto[]) => {
if ($selectedGroup.has(group)) {
assetInteractionStore.removeGroupFromMultiselectGroup(group);
for (const asset of assets) {
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
}
} else {
assetInteractionStore.addGroupToMultiselectGroup(group);
for (const asset of assets) {
handleSelectAsset(asset);
}
}
};
const handleSelectAssets = async (asset: AssetResponseDto) => {
if (!asset) {
return;
}
dispatch('select', asset);
if (singleSelect) {
element.scrollTop = 0;
return;
}
const rangeSelection = $assetSelectionCandidates.size > 0;
const deselect = $selectedAssets.has(asset);
// Select/deselect already loaded assets
if (deselect) {
for (const candidate of $assetSelectionCandidates || []) {
assetInteractionStore.removeAssetFromMultiselectGroup(candidate);
}
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
} else {
for (const candidate of $assetSelectionCandidates || []) {
handleSelectAsset(candidate);
}
handleSelectAsset(asset);
}
assetInteractionStore.clearAssetSelectionCandidates();
if ($assetSelectionStart && rangeSelection) {
let startBucketIndex = $assetStore.getBucketIndexByAssetId($assetSelectionStart.id);
let endBucketIndex = $assetStore.getBucketIndexByAssetId(asset.id);
if (startBucketIndex === null || endBucketIndex === null) {
return;
}
if (endBucketIndex < startBucketIndex) {
[startBucketIndex, endBucketIndex] = [endBucketIndex, startBucketIndex];
}
// Select/deselect assets in all intermediate buckets
for (let bucketIndex = startBucketIndex + 1; bucketIndex < endBucketIndex; bucketIndex++) {
const bucket = $assetStore.buckets[bucketIndex];
await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
for (const asset of bucket.assets) {
if (deselect) {
assetInteractionStore.removeAssetFromMultiselectGroup(asset);
} else {
handleSelectAsset(asset);
}
}
}
// Update date group selection
for (let bucketIndex = startBucketIndex; bucketIndex <= endBucketIndex; bucketIndex++) {
const bucket = $assetStore.buckets[bucketIndex];
// Split bucket into date groups and check each group
const assetsGroupByDate = splitBucketIntoDateGroups(bucket.assets, $locale);
for (const dateGroup of assetsGroupByDate) {
const dateGroupTitle = formatGroupTitle(DateTime.fromISO(dateGroup[0].fileCreatedAt).startOf('day'));
if (dateGroup.every((a) => $selectedAssets.has(a))) {
assetInteractionStore.addGroupToMultiselectGroup(dateGroupTitle);
} else {
assetInteractionStore.removeGroupFromMultiselectGroup(dateGroupTitle);
}
}
}
}
assetInteractionStore.setAssetSelectionStart(deselect ? null : asset);
};
const selectAssetCandidates = (asset: AssetResponseDto) => {
if (!shiftKeyIsDown) {
return;
}
const rangeStart = $assetSelectionStart;
if (!rangeStart) {
return;
}
let start = $assetStore.assets.indexOf(rangeStart);
let end = $assetStore.assets.indexOf(asset);
if (start > end) {
[start, end] = [end, start];
}
assetInteractionStore.setAssetSelectionCandidates($assetStore.assets.slice(start, end + 1));
};
const onSelectStart = (e: Event) => {
if ($isMultiSelectState && shiftKeyIsDown) {
e.preventDefault();
}
};
</script>
<svelte:window on:keydown={onKeyDown} on:keyup={onKeyUp} on:selectstart={onSelectStart} use:shortcuts={shortcutList} />
{#if isShowDeleteConfirmation}
<DeleteAssetDialog
size={idsSelectedAssets.length}
on:cancel={() => (isShowDeleteConfirmation = false)}
on:confirm={() => handlePromiseError(trashOrDelete(true))}
/>
{/if}
{#if showShortcuts}
<ShowShortcuts on:close={() => (showShortcuts = !showShortcuts)} />
{/if}
<Scrollbar
{assetStore}
height={viewport.height}
{timelineY}
on:scrollTimeline={({ detail }) => (element.scrollTop = detail)}
/>
<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
<section
id="asset-grid"
class="scrollbar-hidden h-full overflow-y-auto pb-[60px] {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
bind:clientHeight={viewport.height}
bind:clientWidth={viewport.width}
bind:this={element}
on:scroll={handleTimelineScroll}
>
<!-- skeleton -->
{#if showSkeleton}
<div class="mt-8 animate-pulse">
<div class="mb-2 h-4 w-24 rounded-full bg-immich-primary/20 dark:bg-immich-dark-primary/20" />
<div class="flex w-[120%] flex-wrap">
{#each Array.from({ length: 100 }) as _}
<div class="m-[1px] h-[10em] w-[16em] bg-immich-primary/20 dark:bg-immich-dark-primary/20" />
{/each}
</div>
</div>
{/if}
{#if element}
<slot />
<!-- (optional) empty placeholder -->
{#if isEmpty}
<slot name="empty" />
{/if}
<section id="virtual-timeline" style:height={$assetStore.timelineHeight + 'px'}>
{#each $assetStore.buckets as bucket (bucket.bucketDate)}
<IntersectionObserver
on:intersected={intersectedHandler}
on:hidden={() => assetStore.cancelBucket(bucket)}
let:intersecting
top={750}
bottom={750}
root={element}
>
<div id={'bucket_' + bucket.bucketDate} style:height={bucket.bucketHeight + 'px'}>
{#if intersecting}
<AssetDateGroup
{withStacked}
{showArchiveIcon}
{assetStore}
{assetInteractionStore}
{isSelectionMode}
{singleSelect}
on:select={({ detail: group }) => handleGroupSelect(group.title, group.assets)}
on:shift={handleScrollTimeline}
on:selectAssetCandidates={({ detail: asset }) => handleSelectAssetCandidates(asset)}
on:selectAssets={({ detail: asset }) => handleSelectAssets(asset)}
assets={bucket.assets}
bucketDate={bucket.bucketDate}
bucketHeight={bucket.bucketHeight}
{viewport}
/>
{/if}
</div>
</IntersectionObserver>
{/each}
</section>
{/if}
</section>
<Portal target="body">
{#if $showAssetViewer}
{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
{withStacked}
{assetStore}
asset={$viewingAsset}
preloadAssets={$preloadAssets}
{isShared}
{album}
on:previous={handlePrevious}
on:next={handleNext}
on:close={handleClose}
on:action={({ detail: action }) => handleAction(action.type, action.asset)}
/>
{/await}
{/if}
</Portal>
<style>
#asset-grid {
contain: layout;
scrollbar-width: none;
}
</style>