mirror of
https://github.com/immich-app/immich.git
synced 2024-11-24 08:52:28 +02:00
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:
parent
1e004611e4
commit
a78260296c
@ -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 {
|
||||
|
@ -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}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
6
web/src/params/id.ts
Normal file
6
web/src/params/id.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import type { ParamMatcher } from '@sveltejs/kit';
|
||||
|
||||
/* Returns true if the given param matches UUID format */
|
||||
export const match: ParamMatcher = (param: string) => {
|
||||
return /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12}$/.test(param);
|
||||
};
|
5
web/src/params/photos.ts
Normal file
5
web/src/params/photos.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { ParamMatcher } from '@sveltejs/kit';
|
||||
|
||||
export const match: ParamMatcher = (param: string) => {
|
||||
return param === 'photos';
|
||||
};
|
@ -1,6 +1,28 @@
|
||||
<script lang="ts">
|
||||
import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
let { isViewing: showAssetViewer, setAsset } = assetViewingStore;
|
||||
|
||||
// This block takes care of opening the viewer.
|
||||
// $page.data.asset is loaded by route specific +page.ts loaders if that
|
||||
// route contains the assetId path.
|
||||
$: {
|
||||
if ($page.data.asset) {
|
||||
setAsset($page.data.asset);
|
||||
} else {
|
||||
$showAssetViewer = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
<div class:display-none={$showAssetViewer}>
|
||||
<slot />
|
||||
</div>
|
||||
<UploadCover />
|
||||
|
||||
<style>
|
||||
.display-none {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
@ -79,6 +79,7 @@
|
||||
import AlbumDescription from '$lib/components/album-page/album-description.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import AlbumSummary from '$lib/components/album-page/album-summary.svelte';
|
||||
import { isAlbumsRoute, isPeopleRoute, isSearchRoute } from '$lib/utils/navigation';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
@ -137,15 +138,14 @@
|
||||
album.sharedUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0);
|
||||
|
||||
afterNavigate(({ from }) => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
|
||||
let url: string | undefined = from?.url?.pathname;
|
||||
|
||||
if (from?.route.id === '/(user)/search') {
|
||||
url = from.url.href;
|
||||
const route = from?.route?.id;
|
||||
if (isSearchRoute(route)) {
|
||||
url = from?.url.href;
|
||||
}
|
||||
|
||||
if (from?.route.id === '/(user)/albums/[albumId]' || from?.route.id === '/(user)/people/[personId]') {
|
||||
if (isAlbumsRoute(route) || isPeopleRoute(route)) {
|
||||
url = AppRoute.ALBUMS;
|
||||
}
|
||||
|
@ -1,13 +1,18 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getAssetInfoFromParam } from '$lib/utils/navigation';
|
||||
import { getAlbumInfo } from '@immich/sdk';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ params }) => {
|
||||
await authenticate();
|
||||
const album = await getAlbumInfo({ id: params.albumId, withoutAssets: true });
|
||||
const [album, asset] = await Promise.all([
|
||||
getAlbumInfo({ id: params.albumId, withoutAssets: true }),
|
||||
getAssetInfoFromParam(params),
|
||||
]);
|
||||
|
||||
return {
|
||||
album,
|
||||
asset,
|
||||
meta: {
|
||||
title: album.albumName,
|
||||
},
|
@ -1,13 +0,0 @@
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = ({ params }) => {
|
||||
const albumId = params.albumId;
|
||||
|
||||
if (albumId) {
|
||||
redirect(302, `${AppRoute.ALBUMS}/${albumId}`);
|
||||
} else {
|
||||
redirect(302, AppRoute.PHOTOS);
|
||||
}
|
||||
};
|
@ -1,10 +1,13 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getAssetInfoFromParam } from '$lib/utils/navigation';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
export const load = (async ({ params }) => {
|
||||
await authenticate();
|
||||
const asset = await getAssetInfoFromParam(params);
|
||||
|
||||
return {
|
||||
asset,
|
||||
meta: {
|
||||
title: 'Archive',
|
||||
},
|
@ -1,7 +0,0 @@
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = () => {
|
||||
redirect(302, AppRoute.ARCHIVE);
|
||||
};
|
@ -1,9 +1,13 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getAssetInfoFromParam } from '$lib/utils/navigation';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
export const load = (async ({ params }) => {
|
||||
await authenticate();
|
||||
const asset = await getAssetInfoFromParam(params);
|
||||
|
||||
return {
|
||||
asset,
|
||||
meta: {
|
||||
title: 'Favorites',
|
||||
},
|
@ -1,7 +0,0 @@
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = () => {
|
||||
redirect(302, AppRoute.FAVORITES);
|
||||
};
|
@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import AssetViewer from '$lib/components/asset-viewer/asset-viewer.svelte';
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import MapSettingsModal from '$lib/components/map-page/map-settings-modal.svelte';
|
||||
import Map from '$lib/components/shared-components/map/map.svelte';
|
||||
@ -16,10 +15,11 @@
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset } = assetViewingStore;
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
|
||||
|
||||
let abortController: AbortController;
|
||||
let mapMarkers: MapMarkerResponseDto[] = [];
|
||||
@ -87,20 +87,22 @@
|
||||
}
|
||||
|
||||
async function onViewAssets(assetIds: string[]) {
|
||||
await assetViewingStore.setAssetId(assetIds[0]);
|
||||
viewingAssets = assetIds;
|
||||
viewingAssetCursor = 0;
|
||||
await setAssetId(assetIds[0]);
|
||||
}
|
||||
|
||||
async function navigateNext() {
|
||||
if (viewingAssetCursor < viewingAssets.length - 1) {
|
||||
await assetViewingStore.setAssetId(viewingAssets[++viewingAssetCursor]);
|
||||
await setAssetId(viewingAssets[++viewingAssetCursor]);
|
||||
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
||||
}
|
||||
}
|
||||
|
||||
async function navigatePrevious() {
|
||||
if (viewingAssetCursor > 0) {
|
||||
await assetViewingStore.setAssetId(viewingAssets[--viewingAssetCursor]);
|
||||
await setAssetId(viewingAssets[--viewingAssetCursor]);
|
||||
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -113,14 +115,16 @@
|
||||
>
|
||||
<Portal target="body">
|
||||
{#if $showAssetViewer}
|
||||
<AssetViewer
|
||||
asset={$viewingAsset}
|
||||
showNavigation={viewingAssets.length > 1}
|
||||
on:next={navigateNext}
|
||||
on:previous={navigatePrevious}
|
||||
on:close={() => assetViewingStore.showAssetViewer(false)}
|
||||
isShared={false}
|
||||
/>
|
||||
{#await import('../../../../../lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||
<AssetViewer
|
||||
asset={$viewingAsset}
|
||||
showNavigation={viewingAssets.length > 1}
|
||||
on:next={navigateNext}
|
||||
on:previous={navigatePrevious}
|
||||
on:close={() => assetViewingStore.showAssetViewer(false)}
|
||||
isShared={false}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
</Portal>
|
||||
|
@ -1,9 +1,13 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getAssetInfoFromParam } from '$lib/utils/navigation';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
export const load = (async ({ params }) => {
|
||||
await authenticate();
|
||||
const asset = await getAssetInfoFromParam(params);
|
||||
|
||||
return {
|
||||
asset,
|
||||
meta: {
|
||||
title: 'Map',
|
||||
},
|
@ -1,10 +1,14 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getAssetInfoFromParam } from '$lib/utils/navigation';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
export const load = (async ({ params }) => {
|
||||
const user = await authenticate();
|
||||
const asset = await getAssetInfoFromParam(params);
|
||||
|
||||
return {
|
||||
user,
|
||||
asset,
|
||||
meta: {
|
||||
title: 'Memory',
|
||||
},
|
@ -1,9 +0,0 @@
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
await authenticate();
|
||||
redirect(302, AppRoute.MEMORY);
|
||||
}) satisfies PageLoad;
|
@ -1,7 +0,0 @@
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (() => {
|
||||
redirect(302, AppRoute.PHOTOS);
|
||||
}) satisfies PageLoad;
|
@ -1,18 +1,21 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getAssetInfoFromParam } from '$lib/utils/navigation';
|
||||
import { getPerson, getPersonStatistics } from '@immich/sdk';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ params }) => {
|
||||
await authenticate();
|
||||
|
||||
const [person, statistics] = await Promise.all([
|
||||
const [person, statistics, asset] = await Promise.all([
|
||||
getPerson({ id: params.personId }),
|
||||
getPersonStatistics({ id: params.personId }),
|
||||
getAssetInfoFromParam(params),
|
||||
]);
|
||||
|
||||
return {
|
||||
person,
|
||||
statistics,
|
||||
asset,
|
||||
meta: {
|
||||
title: person.name || 'Person',
|
||||
},
|
@ -1,7 +0,0 @@
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (({ params }) => {
|
||||
redirect(302, `${AppRoute.PEOPLE}/${params.personId}`);
|
||||
}) satisfies PageLoad;
|
@ -1,9 +1,12 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getAssetInfoFromParam } from '$lib/utils/navigation';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
export const load = (async ({ params }) => {
|
||||
await authenticate();
|
||||
const asset = await getAssetInfoFromParam(params);
|
||||
return {
|
||||
asset,
|
||||
meta: {
|
||||
title: 'Photos',
|
||||
},
|
@ -1,7 +0,0 @@
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (() => {
|
||||
redirect(302, AppRoute.PHOTOS);
|
||||
}) satisfies PageLoad;
|
@ -38,6 +38,7 @@
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
|
||||
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
|
||||
|
||||
const MAX_ASSET_COUNT = 5000;
|
||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||
@ -73,12 +74,13 @@
|
||||
if (from?.url && from.route.id !== $page.route.id) {
|
||||
previousRoute = from.url.href;
|
||||
}
|
||||
const route = from?.route?.id;
|
||||
|
||||
if (from?.route.id === '/(user)/people/[personId]') {
|
||||
if (isPeopleRoute(route)) {
|
||||
previousRoute = AppRoute.PHOTOS;
|
||||
}
|
||||
|
||||
if (from?.route.id === '/(user)/albums/[albumId]') {
|
||||
if (isAlbumsRoute(route)) {
|
||||
previousRoute = AppRoute.EXPLORE;
|
||||
}
|
||||
});
|
@ -1,9 +1,12 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getAssetInfoFromParam } from '$lib/utils/navigation';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
export const load = (async ({ params }) => {
|
||||
await authenticate();
|
||||
const asset = await getAssetInfoFromParam(params);
|
||||
return {
|
||||
asset,
|
||||
meta: {
|
||||
title: 'Search',
|
||||
},
|
@ -1,7 +0,0 @@
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (() => {
|
||||
redirect(302, AppRoute.SEARCH);
|
||||
}) satisfies PageLoad;
|
@ -1,9 +1,12 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getAssetInfoFromParam } from '$lib/utils/navigation';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
export const load = (async ({ params }) => {
|
||||
await authenticate();
|
||||
const asset = await getAssetInfoFromParam(params);
|
||||
return {
|
||||
asset,
|
||||
meta: {
|
||||
title: 'Trash',
|
||||
},
|
@ -1,7 +0,0 @@
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (() => {
|
||||
redirect(302, AppRoute.TRASH);
|
||||
}) satisfies PageLoad;
|
@ -17,11 +17,10 @@
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import '../app.css';
|
||||
import { isAssetViewerRoute, isSharedLinkRoute } from '$lib/utils/navigation';
|
||||
|
||||
let showNavigationLoadingBar = false;
|
||||
|
||||
const isSharedLinkRoute = (route: string | null) => route?.startsWith('/(user)/share/[key]');
|
||||
|
||||
$: changeTheme($colorTheme);
|
||||
|
||||
$: if ($user) {
|
||||
@ -62,7 +61,10 @@
|
||||
setKey($page.params.key);
|
||||
}
|
||||
|
||||
beforeNavigate(() => {
|
||||
beforeNavigate(({ from, to }) => {
|
||||
if (isAssetViewerRoute(from) && isAssetViewerRoute(to)) {
|
||||
return;
|
||||
}
|
||||
showNavigationLoadingBar = true;
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user