feat(web): assets now have a permanent URL (#8532)

* Remove asest redirect pages

* Rename route paths to handle optional assetId

* Update old references to new routes

* Load and display asset from all routes that can show assetId

* Add <main> in base layout, update portals to target it

* Wire up updating navigation in response to open/close/prev/next

* Replace events with navigation functions

* Add types to param matcher

* misc cleanup

* Fix reload on /search pages

* Avoid loading bar between photos nav. Delay loading bar by 200ms for all navigations

* Update url for maps routes. Note: on page reload, next/prev is not available

* Dynamically load asset-viewer on map page

* When reloading a url with assetUrl, hide background page to prevent flash during load

* Mostly style, review comments

* Load buckets for assets on demand

* Forgot this update call

* typo

* fix test

* Fix carelessness

* Review comment

* merge main

* remove assets

* fix submodule

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
Min Idzelis 2024-04-24 15:24:19 -04:00 committed by GitHub
parent 1e004611e4
commit a78260296c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 289 additions and 190 deletions

View file

@ -51,6 +51,7 @@
import PhotoViewer from './photo-viewer.svelte';
import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte';
import { navigate } from '$lib/utils/navigation';
export let assetStore: AssetStore | null = null;
export let asset: AssetResponseDto;
@ -191,6 +192,7 @@
}
onMount(async () => {
await navigate({ targetRoute: 'current', assetId: asset.id });
slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
@ -263,11 +265,14 @@
$isShowDetail = !$isShowDetail;
};
const handleCloseViewer = () => {
closeViewer();
const handleCloseViewer = async () => {
await closeViewer();
};
const closeViewer = () => dispatch('close');
const closeViewer = async () => {
dispatch('close');
await navigate({ targetRoute: 'current', assetId: null });
};
const navigateAssetRandom = async () => {
if (!assetStore) {
@ -300,7 +305,7 @@
if ($slideshowState === SlideshowState.PlaySlideshow && assetStore) {
const hasNext =
order === 'previous' ? await assetStore.getPreviousAsset(asset.id) : await assetStore.getNextAsset(asset.id);
order === 'previous' ? await assetStore.getPreviousAsset(asset) : await assetStore.getNextAsset(asset);
if (hasNext) {
$restartSlideshowProgress = true;
} else {

View file

@ -22,6 +22,7 @@
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;
@ -48,6 +49,10 @@
$: isEmpty = $assetStore.initialized && $assetStore.buckets.length === 0;
$: idsSelectedAssets = [...$selectedAssets].filter((a) => !a.isExternal).map((a) => a.id);
$: {
void assetStore.updateViewport(viewport);
}
const dispatch = createEventDispatcher<{ select: AssetResponseDto; escape: void }>();
onMount(async () => {
@ -142,22 +147,24 @@
}
const handlePrevious = async () => {
const previousAsset = await assetStore.getPreviousAsset($viewingAsset.id);
const previousAsset = await assetStore.getPreviousAsset($viewingAsset);
if (previousAsset) {
const preloadAsset = await assetStore.getPreviousAsset(previousAsset.id);
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.id);
const nextAsset = await assetStore.getNextAsset($viewingAsset);
if (nextAsset) {
const preloadAsset = await assetStore.getNextAsset(nextAsset.id);
const preloadAsset = await assetStore.getNextAsset(nextAsset);
assetViewingStore.setAsset(nextAsset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: nextAsset.id });
}
return !!nextAsset;
@ -462,8 +469,8 @@
<Portal target="body">
{#if $showAssetViewer}
{#await import('../asset-viewer/asset-viewer.svelte') then AssetViewer}
<AssetViewer.default
{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
{withStacked}
{assetStore}
asset={$viewingAsset}

View file

@ -4,9 +4,10 @@
import { page } from '$app/stores';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { fileUploadHandler } from '$lib/utils/file-uploader';
import { isAlbumsRoute, isSharedLinkRoute } from '$lib/utils/navigation';
$: albumId = ($page.route?.id === '/(user)/albums/[albumId]' || undefined) && $page.params.albumId;
$: isShare = $page.route?.id === '/(user)/share/[key]' || undefined;
$: albumId = isAlbumsRoute($page.route?.id) ? $page.params.albumId : undefined;
$: isShare = isSharedLinkRoute($page.route?.id);
let dragStartTarget: EventTarget | null = null;

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import Portal from '../portal/portal.svelte';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { BucketPosition, Viewport } from '$lib/stores/assets.store';
@ -10,7 +10,7 @@
import justifiedLayout from 'justified-layout';
import { getAssetRatio } from '$lib/utils/asset-utils';
import { calculateWidth } from '$lib/utils/timeline-util';
import { pushState, replaceState } from '$app/navigation';
import { navigate } from '$lib/utils/navigation';
const dispatch = createEventDispatcher<{ intersected: { container: HTMLDivElement; position: BucketPosition } }>();
@ -20,17 +20,15 @@
export let showArchiveIcon = false;
export let viewport: Viewport;
let { isViewing: showAssetViewer } = assetViewingStore;
let { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
let selectedAsset: AssetResponseDto;
let currentViewAssetIndex = 0;
$: isMultiSelectionMode = selectedAssets.size > 0;
const viewAssetHandler = (asset: AssetResponseDto) => {
const viewAssetHandler = async (asset: AssetResponseDto) => {
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
selectedAsset = assets[currentViewAssetIndex];
$showAssetViewer = true;
updateAssetState(selectedAsset.id, false);
setAsset(assets[currentViewAssetIndex]);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
};
const selectAssetHandler = (asset: AssetResponseDto) => {
@ -45,45 +43,28 @@
selectedAssets = temporary;
};
const navigateAssetForward = () => {
const navigateAssetForward = async () => {
try {
if (currentViewAssetIndex < assets.length - 1) {
currentViewAssetIndex++;
selectedAsset = assets[currentViewAssetIndex];
updateAssetState(selectedAsset.id);
setAsset(assets[++currentViewAssetIndex]);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
}
} catch (error) {
handleError(error, 'Cannot navigate to the next asset');
}
};
const navigateAssetBackward = () => {
const navigateAssetBackward = async () => {
try {
if (currentViewAssetIndex > 0) {
currentViewAssetIndex--;
selectedAsset = assets[currentViewAssetIndex];
updateAssetState(selectedAsset.id);
setAsset(assets[--currentViewAssetIndex]);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
}
} catch (error) {
handleError(error, 'Cannot navigate to previous asset');
}
};
const updateAssetState = (assetId: string, replace = true) => {
const route = `${$page.url.pathname}/photos/${assetId}`;
if (replace) {
replaceState(route, {});
} else {
pushState(route, {});
}
};
const closeViewer = () => {
$showAssetViewer = false;
pushState(`${$page.url.pathname}${$page.url.search}`, {});
};
onDestroy(() => {
$showAssetViewer = false;
});
@ -107,8 +88,6 @@
})();
</script>
<svelte:window on:popstate|preventDefault={closeViewer} />
{#if assets.length > 0}
<div class="relative" style="height: {geometry.containerHeight}px;width: {geometry.containerWidth}px ">
{#each assets as asset, i (i)}
@ -136,10 +115,7 @@
<!-- Overlay Asset Viewer -->
{#if $showAssetViewer}
<AssetViewer
asset={selectedAsset}
on:previous={navigateAssetBackward}
on:next={navigateAssetForward}
on:close={closeViewer}
/>
<Portal target="body">
<AssetViewer asset={$viewingAsset} on:previous={navigateAssetBackward} on:next={navigateAssetForward} />
</Portal>
{/if}

View file

@ -3,16 +3,30 @@
import { cubicOut } from 'svelte/easing';
import { tweened } from 'svelte/motion';
let showing = false;
// delay showing any progress for a little bit so very fast loads
// do not cause flicker
const delay = 100;
const progress = tweened(0, {
duration: 1000,
easing: cubicOut,
});
onMount(async () => {
await progress.set(90);
function animate() {
showing = true;
void progress.set(90);
}
onMount(() => {
const timer = setTimeout(animate, delay);
return () => clearTimeout(timer);
});
</script>
<div class="absolute left-0 top-0 z-[999999999] h-[3px] w-screen bg-white">
<span class="absolute h-[3px] bg-immich-primary" style:width={`${$progress}%`} />
</div>
{#if showing}
<div class="absolute left-0 top-0 z-[999999999] h-[3px] w-screen bg-white">
<span class="absolute h-[3px] bg-immich-primary" style:width={`${$progress}%`} />
</div>
{/if}

View file

@ -307,15 +307,15 @@ describe('AssetStore', () => {
});
it('returns null for invalid assetId', async () => {
expect(() => assetStore.getPreviousAsset('invalid')).not.toThrow();
expect(await assetStore.getPreviousAsset('invalid')).toBeNull();
expect(() => assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow();
expect(await assetStore.getPreviousAsset({ id: 'invalid' } as AssetResponseDto)).toBeNull();
});
it('returns previous assetId', async () => {
await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible);
const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
expect(await assetStore.getPreviousAsset(bucket!.assets[1].id)).toEqual(bucket!.assets[0]);
expect(await assetStore.getPreviousAsset(bucket!.assets[1])).toEqual(bucket!.assets[0]);
});
it('returns previous assetId spanning multiple buckets', async () => {
@ -324,7 +324,7 @@ describe('AssetStore', () => {
const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z');
const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z');
expect(await assetStore.getPreviousAsset(bucket!.assets[0].id)).toEqual(previousBucket!.assets[0]);
expect(await assetStore.getPreviousAsset(bucket!.assets[0])).toEqual(previousBucket!.assets[0]);
});
it('loads previous bucket', async () => {
@ -333,7 +333,7 @@ describe('AssetStore', () => {
const loadBucketSpy = vi.spyOn(assetStore, 'loadBucket');
const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z');
const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z');
expect(await assetStore.getPreviousAsset(bucket!.assets[0].id)).toEqual(previousBucket!.assets[0]);
expect(await assetStore.getPreviousAsset(bucket!.assets[0])).toEqual(previousBucket!.assets[0]);
expect(loadBucketSpy).toBeCalledTimes(1);
});
@ -344,12 +344,12 @@ describe('AssetStore', () => {
const [assetOne, assetTwo, assetThree] = assetStore.assets;
assetStore.removeAssets([assetTwo.id]);
expect(await assetStore.getPreviousAsset(assetThree.id)).toEqual(assetOne);
expect(await assetStore.getPreviousAsset(assetThree)).toEqual(assetOne);
});
it('returns null when no more assets', async () => {
await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible);
expect(await assetStore.getPreviousAsset(assetStore.assets[0].id)).toBeNull();
expect(await assetStore.getPreviousAsset(assetStore.assets[0])).toBeNull();
});
});

View file

@ -1,4 +1,5 @@
import { getKey } from '$lib/utils';
import { fromLocalDateTime } from '$lib/utils/timeline-util';
import { TimeBucketSize, getTimeBucket, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
import { throttle } from 'lodash-es';
import { DateTime } from 'luxon';
@ -188,32 +189,40 @@ export class AssetStore {
this.assetToBucket = {};
this.albumAssets = new Set();
const buckets = await getTimeBuckets({
const timebuckets = await getTimeBuckets({
...this.options,
key: getKey(),
});
this.initialized = true;
this.buckets = buckets.map((bucket) => {
const unwrappedWidth = (3 / 2) * bucket.count * THUMBNAIL_HEIGHT * (7 / 10);
this.buckets = timebuckets.map((bucket) => ({
bucketDate: bucket.timeBucket,
bucketHeight: 0,
bucketCount: bucket.count,
assets: [],
cancelToken: null,
position: BucketPosition.Unknown,
}));
// if loading an asset, the grid-view may be hidden, which means
// it has 0 width and height. No need to update bucket or timeline
// heights in this case. Later, updateViewport will be called to
// update the heights.
if (viewport.height !== 0 && viewport.width !== 0) {
await this.updateViewport(viewport);
}
}
async updateViewport(viewport: Viewport) {
for (const bucket of this.buckets) {
const unwrappedWidth = (3 / 2) * bucket.bucketCount * THUMBNAIL_HEIGHT * (7 / 10);
const rows = Math.ceil(unwrappedWidth / viewport.width);
const height = rows * THUMBNAIL_HEIGHT;
return {
bucketDate: bucket.timeBucket,
bucketHeight: height,
bucketCount: bucket.count,
assets: [],
cancelToken: null,
position: BucketPosition.Unknown,
};
});
bucket.bucketHeight = height;
}
this.timelineHeight = this.buckets.reduce((accumulator, b) => accumulator + b.bucketHeight, 0);
this.emit(false);
let height = 0;
const loaders = [];
for (const bucket of this.buckets) {
@ -222,10 +231,10 @@ export class AssetStore {
loaders.push(this.loadBucket(bucket.bucketDate, BucketPosition.Visible));
continue;
}
break;
}
await Promise.all(loaders);
this.emit(false);
}
async loadBucket(bucketDate: string, position: BucketPosition): Promise<void> {
@ -380,8 +389,19 @@ export class AssetStore {
return this.buckets.find((bucket) => bucket.bucketDate === bucketDate) || null;
}
getBucketInfoForAssetId(assetId: string) {
return this.assetToBucket[assetId] || null;
async getBucketInfoForAssetId({ id, localDateTime }: Pick<AssetResponseDto, 'id' | 'localDateTime'>) {
const bucketInfo = this.assetToBucket[id];
if (bucketInfo) {
return bucketInfo;
}
let date = fromLocalDateTime(localDateTime);
if (this.options.size == TimeBucketSize.Month) {
date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 });
} else if (this.options.size == TimeBucketSize.Day) {
date = date.set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
}
await this.loadBucket(date.toISO()!, BucketPosition.Unknown);
return this.assetToBucket[id] || null;
}
getBucketIndexByAssetId(assetId: string) {
@ -451,8 +471,8 @@ export class AssetStore {
this.emit(true);
}
async getPreviousAsset(assetId: string): Promise<AssetResponseDto | null> {
const info = this.getBucketInfoForAssetId(assetId);
async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> {
const info = await this.getBucketInfoForAssetId(asset);
if (!info) {
return null;
}
@ -472,8 +492,8 @@ export class AssetStore {
return previousBucket.assets.at(-1) || null;
}
async getNextAsset(assetId: string): Promise<AssetResponseDto | null> {
const info = this.getBucketInfoForAssetId(assetId);
async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | null> {
const info = await this.getBucketInfoForAssetId(asset);
if (!info) {
return null;
}

View file

@ -1,9 +1,83 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { AppRoute } from '$lib/constants';
import { getAssetInfo } from '@immich/sdk';
import type { NavigationTarget } from '@sveltejs/kit';
import { get } from 'svelte/store';
export const isExternalUrl = (url: string): boolean => {
return new URL(url, window.location.href).origin !== window.location.origin;
};
export const isPhotosRoute = (route?: string | null) => !!route?.startsWith('/(user)/photos/[[assetId=id]]');
export const isSharedLinkRoute = (route?: string | null) => !!route?.startsWith('/(user)/share/[key]');
export const isSearchRoute = (route?: string | null) => !!route?.startsWith('/(user)/search');
export const isAlbumsRoute = (route?: string | null) => !!route?.startsWith('/(user)/albums/[albumId=id]');
export const isPeopleRoute = (route?: string | null) => !!route?.startsWith('/(user)/people/[personId]');
export const isAssetViewerRoute = (target?: NavigationTarget | null) =>
!!(target?.route.id?.endsWith('/[[assetId=id]]') && 'assetId' in (target?.params || {}));
export function getAssetInfoFromParam({ assetId }: { assetId?: string }) {
return assetId && getAssetInfo({ id: assetId });
}
function currentUrlWithoutAsset() {
const $page = get(page);
// This contains special casing for the /photos/:assetId route, which hangs directly
// off / instead of a subpath, unlike every other asset-containing route.
return isPhotosRoute($page.route.id)
? AppRoute.PHOTOS + $page.url.search
: $page.url.pathname.replace(/(\/photos.*)$/, '') + $page.url.search;
}
function currentUrlReplaceAssetId(assetId: string) {
const $page = get(page);
// this contains special casing for the /photos/:assetId photos route, which hangs directly
// off / instead of a subpath, unlike every other asset-containing route.
return isPhotosRoute($page.route.id)
? `${AppRoute.PHOTOS}/${assetId}${$page.url.search}`
: `${$page.url.pathname.replace(/(\/photos.*)$/, '')}/photos/${assetId}${$page.url.search}`;
}
function currentUrl() {
const $page = get(page);
const current = $page.url;
return current.pathname + current.search;
}
interface Route {
/**
* The route to target, or 'current' to stay on current route.
*/
targetRoute: string | 'current';
}
interface AssetRoute extends Route {
targetRoute: 'current';
assetId: string | null;
}
function isAssetRoute(route: Route): route is AssetRoute {
return route.targetRoute === 'current' && 'assetId' in route;
}
async function navigateAssetRoute(route: AssetRoute) {
const { assetId } = route;
const next = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset();
if (next !== currentUrl()) {
await goto(next, { replaceState: false });
}
}
export function navigate<T extends Route>(change: T): Promise<void> {
if (isAssetRoute(change)) {
return navigateAssetRoute(change);
}
// future navigation requests here
throw `Invalid navigation: ${JSON.stringify(change)}`;
}
export const clearQueryParam = async (queryParam: string, url: URL) => {
if (url.searchParams.has(queryParam)) {
url.searchParams.delete(queryParam);