feat: shared links custom URL (#19999)

* feat: custom url for shared links

* feat: use a separate route and query param

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
Jed-Giblin
2025-07-28 14:16:55 -04:00
committed by GitHub
parent 16b14b390f
commit 9b3718120b
65 changed files with 947 additions and 432 deletions
@@ -402,9 +402,8 @@
const handleShareLink = async () => {
const sharedLink = await modalManager.show(SharedLinkCreateModal, { albumId: album.id });
if (sharedLink) {
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink.key) });
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) });
}
};
@@ -0,0 +1,5 @@
<script lang="ts">
import SharedLinkErrorPage from '$lib/components/pages/SharedLinkErrorPage.svelte';
</script>
<SharedLinkErrorPage />
@@ -0,0 +1,12 @@
<script lang="ts">
import SharedLinkPage from '$lib/components/pages/SharedLinkPage.svelte';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
let { data }: Props = $props();
</script>
<SharedLinkPage {data} />
@@ -0,0 +1,4 @@
import { loadSharedLink } from '$lib/utils/shared-links';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => loadSharedLink({ params, url })) satisfies PageLoad;
@@ -1,14 +1,5 @@
<script lang="ts">
import { page } from '$app/state';
import SharedLinkErrorPage from '$lib/components/pages/SharedLinkErrorPage.svelte';
</script>
<svelte:head>
<title>Oops! Error - Immich</title>
</svelte:head>
<section class="flex flex-col px-4 h-dvh w-dvw place-content-center place-items-center">
<h1 class="py-10 text-4xl text-immich-primary dark:text-immich-dark-primary">Page not found :/</h1>
{#if page.error?.message}
<h2 class="text-xl text-immich-fg dark:text-immich-dark-fg">{page.error.message}</h2>
{/if}
</section>
<SharedLinkErrorPage />
@@ -1,97 +1,12 @@
<script lang="ts">
import AlbumViewer from '$lib/components/album-page/album-viewer.svelte';
import IndividualSharedViewer from '$lib/components/share-page/individual-shared-viewer.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte';
import PasswordField from '$lib/components/shared-components/password-field.svelte';
import ThemeButton from '$lib/components/shared-components/theme-button.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { user } from '$lib/stores/user.store';
import { setSharedLink } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
import { getMySharedLink, SharedLinkType } from '@immich/sdk';
import { Button } from '@immich/ui';
import { tick } from 'svelte';
import { t } from 'svelte-i18n';
import SharedLinkPage from '$lib/components/pages/SharedLinkPage.svelte';
import type { PageData } from './$types';
interface Props {
type Props = {
data: PageData;
}
};
let { data }: Props = $props();
let { gridScrollTarget } = assetViewingStore;
let { sharedLink, passwordRequired, sharedLinkKey: key, meta } = $state(data);
let { title, description } = $state(meta);
let isOwned = $derived($user ? $user.id === sharedLink?.userId : false);
let password = $state('');
const handlePasswordSubmit = async () => {
try {
sharedLink = await getMySharedLink({ password, key });
setSharedLink(sharedLink);
passwordRequired = false;
title = (sharedLink.album ? sharedLink.album.albumName : $t('public_share')) + ' - Immich';
description =
sharedLink.description ||
$t('shared_photos_and_videos_count', { values: { assetCount: sharedLink.assets.length } });
await tick();
await navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
{ forceNavigate: true, replaceState: true },
);
} catch (error) {
handleError(error, $t('errors.unable_to_get_shared_link'));
}
};
const onsubmit = async (event: Event) => {
event.preventDefault();
await handlePasswordSubmit();
};
</script>
<svelte:head>
<title>{title}</title>
<meta name="description" content={description} />
</svelte:head>
{#if passwordRequired}
<main
class="relative h-dvh overflow-hidden px-6 max-md:pt-(--navbar-height-md) pt-(--navbar-height) sm:px-12 md:px-24 lg:px-40"
>
<div class="flex flex-col items-center justify-center mt-20">
<div class="text-2xl font-bold text-immich-primary dark:text-immich-dark-primary">{$t('password_required')}</div>
<div class="mt-4 text-lg text-immich-primary dark:text-immich-dark-primary">
{$t('sharing_enter_password')}
</div>
<div class="mt-4">
<form class="flex gap-x-2" novalidate {onsubmit}>
<PasswordField autocomplete="off" bind:password placeholder="Password" />
<Button type="submit">{$t('submit')}</Button>
</form>
</div>
</div>
</main>
<header>
<ControlAppBar showBackButton={false}>
{#snippet leading()}
<ImmichLogoSmallLink />
{/snippet}
{#snippet trailing()}
<ThemeButton />
{/snippet}
</ControlAppBar>
</header>
{/if}
{#if !passwordRequired && sharedLink?.type == SharedLinkType.Album}
<AlbumViewer {sharedLink} />
{/if}
{#if !passwordRequired && sharedLink?.type == SharedLinkType.Individual}
<div class="immich-scrollbar">
<IndividualSharedViewer {sharedLink} {isOwned} />
</div>
{/if}
<SharedLinkPage {data} />
@@ -1,44 +1,4 @@
import { getAssetThumbnailUrl, setSharedLink } from '$lib/utils';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { getMySharedLink, isHttpError } from '@immich/sdk';
import { loadSharedLink } from '$lib/utils/shared-links';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
const { key } = params;
await authenticate(url, { public: true });
const $t = await getFormatter();
try {
const [sharedLink, asset] = await Promise.all([getMySharedLink({ key }), getAssetInfoFromParam(params)]);
setSharedLink(sharedLink);
const assetCount = sharedLink.assets.length;
const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id;
const assetPath = assetId ? getAssetThumbnailUrl(assetId) : '/feature-panel.png';
return {
sharedLink,
sharedLinkKey: key,
asset,
meta: {
title: sharedLink.album ? sharedLink.album.albumName : $t('public_share'),
description: sharedLink.description || $t('shared_photos_and_videos_count', { values: { assetCount } }),
imageUrl: assetPath,
},
};
} catch (error) {
if (isHttpError(error) && error.data.message === 'Invalid password') {
return {
passwordRequired: true,
sharedLinkKey: key,
meta: {
title: $t('password_required'),
},
};
}
throw error;
}
}) satisfies PageLoad;
export const load = (async ({ params, url }) => loadSharedLink({ params, url })) satisfies PageLoad;