mirror of
https://github.com/immich-app/immich.git
synced 2025-12-21 09:15:44 +03:00
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:
@@ -369,7 +369,7 @@
|
||||
|
||||
if (sharedLink) {
|
||||
handleSharedLinkCreated(albumToShare);
|
||||
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink.key) });
|
||||
await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
let { asset, menuItem = false }: Props = $props();
|
||||
|
||||
const onDownloadFile = async () => downloadFile(await getAssetInfo({ id: asset.id, key: authManager.key }));
|
||||
const onDownloadFile = async () => downloadFile(await getAssetInfo({ ...authManager.params, id: asset.id }));
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: onDownloadFile }} />
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
const sharedLink = await modalManager.show(SharedLinkCreateModal, { assetIds: [asset.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) });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
let zoomToggle = $state(() => void 0);
|
||||
|
||||
const refreshStack = async () => {
|
||||
if (authManager.key) {
|
||||
if (authManager.isSharedLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
});
|
||||
|
||||
const handleGetAllAlbums = async () => {
|
||||
if (authManager.key) {
|
||||
if (authManager.isSharedLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if !authManager.key && $preferences?.ratings.enabled}
|
||||
{#if !authManager.isSharedLink && $preferences?.ratings.enabled}
|
||||
<section class="px-4 pt-2">
|
||||
<StarRating {rating} readOnly={!isOwner} onRating={(rating) => handlePromiseError(handleChangeRating(rating))} />
|
||||
</section>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 't' }, onShortcut: handleAddTag }} />
|
||||
|
||||
{#if isOwner && !authManager.key}
|
||||
{#if isOwner && !authManager.isSharedLink}
|
||||
<section class="px-4 mt-4">
|
||||
<div class="flex h-10 w-full items-center justify-between text-sm">
|
||||
<h2>{$t('tags').toUpperCase()}</h2>
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
|
||||
const handleNewAsset = async (newAsset: AssetResponseDto) => {
|
||||
// TODO: check if reloading asset data is necessary
|
||||
if (newAsset.id && !authManager.key) {
|
||||
if (newAsset.id && !authManager.isSharedLink) {
|
||||
const data = await getAssetInfo({ id: asset.id });
|
||||
people = data?.people || [];
|
||||
unassignedFaces = data?.unassignedFaces || [];
|
||||
@@ -195,7 +195,7 @@
|
||||
<DetailPanelDescription {asset} {isOwner} />
|
||||
<DetailPanelRating {asset} {isOwner} />
|
||||
|
||||
{#if !authManager.key && isOwner}
|
||||
{#if !authManager.isSharedLink && isOwner}
|
||||
<section class="px-4 pt-4 text-sm">
|
||||
<div class="flex h-10 w-full items-center justify-between">
|
||||
<h2>{$t('people').toUpperCase()}</h2>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
const { asset }: Props = $props();
|
||||
|
||||
const loadAssetData = async (id: string) => {
|
||||
const data = await viewAsset({ id, size: AssetMediaSize.Preview, key: authManager.key });
|
||||
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
|
||||
return URL.createObjectURL(data);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -270,13 +270,13 @@
|
||||
{/if}
|
||||
|
||||
<!-- Favorite asset star -->
|
||||
{#if !authManager.key && asset.isFavorite}
|
||||
{#if !authManager.isSharedLink && asset.isFavorite}
|
||||
<div class="absolute bottom-2 start-2">
|
||||
<Icon path={mdiHeart} size="24" class="text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !authManager.key && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
|
||||
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
|
||||
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
|
||||
<Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" />
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
let paused = $state(false);
|
||||
let current = $state<MemoryAsset | undefined>(undefined);
|
||||
let currentMemoryAssetFull = $derived.by(async () =>
|
||||
current?.asset ? await getAssetInfo({ id: current.asset.id, key: authManager.key }) : undefined,
|
||||
current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined,
|
||||
);
|
||||
let currentTimelineAssets = $derived(current?.memory.assets.map((asset) => toTimelineAsset(asset)) || []);
|
||||
|
||||
|
||||
14
web/src/lib/components/pages/SharedLinkErrorPage.svelte
Normal file
14
web/src/lib/components/pages/SharedLinkErrorPage.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
</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>
|
||||
108
web/src/lib/components/pages/SharedLinkPage.svelte
Normal file
108
web/src/lib/components/pages/SharedLinkPage.svelte
Normal file
@@ -0,0 +1,108 @@
|
||||
<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, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { Button } from '@immich/ui';
|
||||
import { tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
data: {
|
||||
meta: {
|
||||
title: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
};
|
||||
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
key?: string;
|
||||
slug?: string;
|
||||
asset?: AssetResponseDto;
|
||||
passwordRequired?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const { data }: Props = $props();
|
||||
|
||||
let { gridScrollTarget } = assetViewingStore;
|
||||
let { sharedLink, passwordRequired, key, slug, 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, slug });
|
||||
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}
|
||||
@@ -15,7 +15,7 @@
|
||||
});
|
||||
|
||||
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) });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
|
||||
import { getAssetInfo } from '@immich/sdk';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiCloudDownloadOutline, mdiFileDownloadOutline, mdiFolderDownloadOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||
import { IconButton } from '@immich/ui';
|
||||
|
||||
interface Props {
|
||||
filename?: string;
|
||||
@@ -23,7 +23,7 @@
|
||||
const assets = [...getAssets()];
|
||||
if (assets.length === 1) {
|
||||
clearSelect();
|
||||
let asset = await getAssetInfo({ id: assets[0].id, key: authManager.key });
|
||||
let asset = await getAssetInfo({ ...authManager.params, id: assets[0].id });
|
||||
await downloadFile(asset);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import type { OnLink, OnUnlink } from '$lib/utils/actions';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getAssetInfo, updateAsset } from '@immich/sdk';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiLinkOff, mdiMotionPlayOutline, mdiTimerSand } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
import { IconButton } from '@immich/ui';
|
||||
|
||||
interface Props {
|
||||
onLink: OnLink;
|
||||
@@ -59,7 +59,7 @@
|
||||
try {
|
||||
loading = true;
|
||||
const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: null } });
|
||||
const motionResponse = await getAssetInfo({ id: motionId, key: authManager.key });
|
||||
const motionResponse = await getAssetInfo({ ...authManager.params, id: motionId });
|
||||
onUnlink({ still: toTimelineAsset(stillResponse), motion: toTimelineAsset(motionResponse) });
|
||||
clearSelect();
|
||||
} catch (error) {
|
||||
|
||||
@@ -29,11 +29,11 @@
|
||||
|
||||
try {
|
||||
const results = await removeSharedLinkAssets({
|
||||
...authManager.params,
|
||||
id: sharedLink.id,
|
||||
assetIdsDto: {
|
||||
assetIds: [...getAssets()].map((asset) => asset.id),
|
||||
},
|
||||
key: authManager.key,
|
||||
});
|
||||
|
||||
for (const result of results) {
|
||||
|
||||
@@ -443,7 +443,7 @@
|
||||
|
||||
if (laterAsset) {
|
||||
const preloadAsset = await timelineManager.getLaterAsset(laterAsset);
|
||||
const asset = await getAssetInfo({ id: laterAsset.id, key: authManager.key });
|
||||
const asset = await getAssetInfo({ ...authManager.params, id: laterAsset.id });
|
||||
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
||||
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
|
||||
}
|
||||
@@ -458,7 +458,7 @@
|
||||
|
||||
if (earlierAsset) {
|
||||
const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset);
|
||||
const asset = await getAssetInfo({ id: earlierAsset.id, key: authManager.key });
|
||||
const asset = await getAssetInfo({ ...authManager.params, id: earlierAsset.id });
|
||||
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
||||
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
|
||||
}
|
||||
@@ -471,7 +471,7 @@
|
||||
const randomAsset = await timelineManager.getRandomAsset();
|
||||
|
||||
if (randomAsset) {
|
||||
const asset = await getAssetInfo({ id: randomAsset.id, key: authManager.key });
|
||||
const asset = await getAssetInfo({ ...authManager.params, id: randomAsset.id });
|
||||
assetViewingStore.setAsset(asset);
|
||||
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
|
||||
return asset;
|
||||
@@ -869,7 +869,7 @@
|
||||
style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
||||
tabindex="-1"
|
||||
bind:clientHeight={timelineManager.viewportHeight}
|
||||
bind:clientWidth={null, (v) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
|
||||
bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
|
||||
bind:this={element}
|
||||
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
|
||||
>
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
import ImmichLogoSmallLink from '$lib/components/shared-components/immich-logo-small-link.svelte';
|
||||
import { AppRoute, AssetAction } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import type { Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { cancelMultiselect, downloadArchive } from '$lib/utils/asset-utils';
|
||||
@@ -13,6 +13,7 @@
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { addSharedLinkAssets, getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiArrowLeft, mdiFileImagePlusOutline, mdiFolderDownloadOutline, mdiSelectAll } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
|
||||
@@ -22,7 +23,6 @@
|
||||
import ControlAppBar from '../shared-components/control-app-bar.svelte';
|
||||
import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte';
|
||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
||||
import { IconButton } from '@immich/ui';
|
||||
|
||||
interface Props {
|
||||
sharedLink: SharedLinkResponseDto;
|
||||
@@ -54,11 +54,11 @@
|
||||
? openFileUploadDialog()
|
||||
: fileUploadHandler({ files }));
|
||||
const data = await addSharedLinkAssets({
|
||||
...authManager.params,
|
||||
id: sharedLink.id,
|
||||
assetIdsDto: {
|
||||
assetIds: results.filter((id) => !!id) as string[],
|
||||
},
|
||||
key: authManager.key,
|
||||
});
|
||||
|
||||
const added = data.filter((item) => item.success).length;
|
||||
@@ -145,7 +145,7 @@
|
||||
<GalleryViewer {assets} {assetInteraction} {viewport} />
|
||||
</section>
|
||||
{:else if assets.length === 1}
|
||||
{#await getAssetInfo({ id: assets[0].id, key: authManager.key }) then asset}
|
||||
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
|
||||
<AssetViewer
|
||||
{asset}
|
||||
showCloseButton={false}
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
}
|
||||
|
||||
const filesArray: File[] = Array.from<File>(files);
|
||||
if (authManager.key) {
|
||||
if (authManager.isSharedLink) {
|
||||
dragAndDropFilesStore.set({ isDragging: true, files: filesArray });
|
||||
} else {
|
||||
await fileUploadHandler({ files: filesArray, albumId, isLockedAssets: isInLockedFolder });
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
let { link, menuItem = false }: Props = $props();
|
||||
|
||||
const handleCopy = async () => {
|
||||
await copyToClipboard(makeSharedLinkUrl(link.key));
|
||||
await copyToClipboard(makeSharedLinkUrl(link));
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/elements/badge.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import SharedLinkCopy from '$lib/components/sharedlinks-page/actions/shared-link-copy.svelte';
|
||||
import SharedLinkDelete from '$lib/components/sharedlinks-page/actions/shared-link-delete.svelte';
|
||||
import SharedLinkEdit from '$lib/components/sharedlinks-page/actions/shared-link-edit.svelte';
|
||||
import ShareCover from '$lib/components/sharedlinks-page/covers/share-cover.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { mdiDotsVertical } from '@mdi/js';
|
||||
import { DateTime, type ToRelativeUnit } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import SharedLinkDelete from '$lib/components/sharedlinks-page/actions/shared-link-delete.svelte';
|
||||
import SharedLinkEdit from '$lib/components/sharedlinks-page/actions/shared-link-edit.svelte';
|
||||
import SharedLinkCopy from '$lib/components/sharedlinks-page/actions/shared-link-copy.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import { mdiDotsVertical } from '@mdi/js';
|
||||
|
||||
interface Props {
|
||||
link: SharedLinkResponseDto;
|
||||
@@ -91,6 +91,9 @@
|
||||
{#if link.password}
|
||||
<Badge rounded="full"><span class="text-xs px-1">{$t('password')}</span></Badge>
|
||||
{/if}
|
||||
{#if link.slug}
|
||||
<Badge rounded="full"><span class="text-xs px-1">{$t('custom_url')}</span></Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</svelte:element>
|
||||
|
||||
@@ -6,7 +6,8 @@ import { isSharedLinkRoute } from '$lib/utils/navigation';
|
||||
import { logout } from '@immich/sdk';
|
||||
|
||||
class AuthManager {
|
||||
key = $derived(isSharedLinkRoute(page.route?.id) ? page.params.key : undefined);
|
||||
isSharedLink = $derived(isSharedLinkRoute(page.route?.id));
|
||||
params = $derived(this.isSharedLink ? { key: page.params.key, slug: page.params.slug } : {});
|
||||
|
||||
async logout() {
|
||||
let redirectUri;
|
||||
|
||||
@@ -18,12 +18,11 @@ export async function loadFromTimeBuckets(
|
||||
}
|
||||
|
||||
const timeBucket = toISOYearMonthUTC(monthGroup.yearMonth);
|
||||
const key = authManager.key;
|
||||
const bucketResponse = await getTimeBucket(
|
||||
{
|
||||
...authManager.params,
|
||||
...options,
|
||||
timeBucket,
|
||||
key,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
@@ -35,9 +34,9 @@ export async function loadFromTimeBuckets(
|
||||
if (options.timelineAlbumId) {
|
||||
const albumAssets = await getTimeBucket(
|
||||
{
|
||||
...authManager.params,
|
||||
albumId: options.timelineAlbumId,
|
||||
timeBucket,
|
||||
key,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
@@ -288,8 +288,8 @@ export class TimelineManager {
|
||||
|
||||
async #initializeMonthGroups() {
|
||||
const timebuckets = await getTimeBuckets({
|
||||
...authManager.params,
|
||||
...this.#options,
|
||||
key: authManager.key,
|
||||
});
|
||||
|
||||
this.months = timebuckets.map((timeBucket) => {
|
||||
@@ -423,7 +423,7 @@ export class TimelineManager {
|
||||
if (monthGroup) {
|
||||
return monthGroup;
|
||||
}
|
||||
const asset = toTimelineAsset(await getAssetInfo({ id, key: authManager.key }));
|
||||
const asset = toTimelineAsset(await getAssetInfo({ ...authManager.params, id }));
|
||||
if (!asset || this.isExcluded(asset)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
let sharedLinkUrl = $state('');
|
||||
const handleViewQrCode = (sharedLink: SharedLinkResponseDto) => {
|
||||
sharedLinkUrl = makeSharedLinkUrl(sharedLink.key);
|
||||
sharedLinkUrl = makeSharedLinkUrl(sharedLink);
|
||||
};
|
||||
|
||||
const roleOptions: Array<{ title: string; value: AlbumUserRole | 'none'; icon?: string }> = [
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
<script lang="ts">
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { SharedLinkType, createSharedLink, updateSharedLink, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { Button, Field, Input, Modal, ModalBody, ModalFooter, PasswordInput, Switch, Text } from '@immich/ui';
|
||||
import { mdiLink } from '@mdi/js';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { NotificationType, notificationController } from '../components/shared-components/notification/notification';
|
||||
import SettingInputField from '../components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '../components/shared-components/settings/setting-switch.svelte';
|
||||
|
||||
interface Props {
|
||||
onClose: (sharedLink?: SharedLinkResponseDto) => void;
|
||||
@@ -28,8 +25,8 @@
|
||||
let showMetadata = $state(true);
|
||||
let expirationOption: number = $state(0);
|
||||
let password = $state('');
|
||||
let slug = $state('');
|
||||
let shouldChangeExpirationTime = $state(false);
|
||||
let enablePassword = $state(false);
|
||||
|
||||
const expirationOptions: [number, Intl.RelativeTimeFormatUnit][] = [
|
||||
[30, 'minutes'],
|
||||
@@ -63,17 +60,15 @@
|
||||
if (editingLink.description) {
|
||||
description = editingLink.description;
|
||||
}
|
||||
if (editingLink.password) {
|
||||
password = editingLink.password;
|
||||
}
|
||||
|
||||
password = editingLink.password ?? '';
|
||||
slug = editingLink.slug ?? '';
|
||||
allowUpload = editingLink.allowUpload;
|
||||
allowDownload = editingLink.allowDownload;
|
||||
showMetadata = editingLink.showMetadata;
|
||||
|
||||
albumId = editingLink.album?.id;
|
||||
assetIds = editingLink.assets.map(({ id }) => id);
|
||||
|
||||
enablePassword = !!editingLink.password;
|
||||
}
|
||||
|
||||
const handleCreateSharedLink = async () => {
|
||||
@@ -91,6 +86,7 @@
|
||||
password,
|
||||
allowDownload,
|
||||
showMetadata,
|
||||
slug,
|
||||
},
|
||||
});
|
||||
onClose(data);
|
||||
@@ -111,11 +107,12 @@
|
||||
id: editingLink.id,
|
||||
sharedLinkEditDto: {
|
||||
description,
|
||||
password: enablePassword ? password : '',
|
||||
password: password ?? null,
|
||||
expiresAt: shouldChangeExpirationTime ? expirationDate : undefined,
|
||||
allowUpload,
|
||||
allowDownload,
|
||||
showMetadata,
|
||||
slug: slug.trim() ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -165,63 +162,51 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="mb-2 mt-4">
|
||||
<p class="text-xs">{$t('link_options').toUpperCase()}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-100 p-4 dark:bg-black/40 overflow-y-auto">
|
||||
<div class="flex flex-col">
|
||||
<div class="mb-2">
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('description')}
|
||||
bind:value={description}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('password')}
|
||||
bind:value={password}
|
||||
disabled={!enablePassword}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
<SettingSwitch bind:checked={enablePassword} title={$t('require_password')} />
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
<SettingSwitch bind:checked={showMetadata} title={$t('show_metadata')} />
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
<SettingSwitch
|
||||
bind:checked={allowDownload}
|
||||
title={$t('allow_public_user_to_download')}
|
||||
disabled={!showMetadata}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
<SettingSwitch bind:checked={allowUpload} title={$t('allow_public_user_to_upload')} />
|
||||
</div>
|
||||
|
||||
{#if editingLink}
|
||||
<div class="my-3">
|
||||
<SettingSwitch bind:checked={shouldChangeExpirationTime} title={$t('change_expiration_time')} />
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 mt-4">
|
||||
<div>
|
||||
<Field label={$t('custom_url')} description={$t('shared_link_custom_url_description')}>
|
||||
<Input bind:value={slug} placeholder="immich-10000" />
|
||||
</Field>
|
||||
{#if slug}
|
||||
<Text size="tiny" color="muted" class="pt-2">/s/{encodeURIComponent(slug)}</Text>
|
||||
{/if}
|
||||
<div class="mt-3">
|
||||
<SettingSelect
|
||||
bind:value={expirationOption}
|
||||
options={expiredDateOptions}
|
||||
label={$t('expire_after')}
|
||||
disabled={editingLink && !shouldChangeExpirationTime}
|
||||
number={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label={$t('password')} description={$t('shared_link_password_description')}>
|
||||
<PasswordInput bind:value={password} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('description')}>
|
||||
<Input bind:value={description} />
|
||||
</Field>
|
||||
|
||||
<div class="mt-2">
|
||||
<SettingSelect
|
||||
bind:value={expirationOption}
|
||||
options={expiredDateOptions}
|
||||
label={$t('expire_after')}
|
||||
disabled={editingLink && !shouldChangeExpirationTime}
|
||||
number={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Field label={$t('show_metadata')}>
|
||||
<Switch bind:checked={showMetadata} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('allow_public_user_to_download')} disabled={!showMetadata}>
|
||||
<Switch bind:checked={allowDownload} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('allow_public_user_to_upload')}>
|
||||
<Switch bind:checked={allowUpload} />
|
||||
</Field>
|
||||
|
||||
{#if editingLink}
|
||||
<Field label={$t('change_expiration_time')}>
|
||||
<Switch bind:checked={shouldChangeExpirationTime} />
|
||||
</Field>
|
||||
{/if}
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ function createAssetViewingStore() {
|
||||
};
|
||||
|
||||
const setAssetId = async (id: string): Promise<AssetResponseDto> => {
|
||||
const asset = await getAssetInfo({ id, key: authManager.key });
|
||||
const asset = await getAssetInfo({ ...authManager.params, id });
|
||||
setAsset(asset);
|
||||
return asset;
|
||||
};
|
||||
|
||||
@@ -184,7 +184,7 @@ export const getAssetOriginalUrl = (options: string | AssetUrlOptions) => {
|
||||
options = { id: options };
|
||||
}
|
||||
const { id, cacheKey } = options;
|
||||
return createUrl(getAssetOriginalPath(id), { key: authManager.key, c: cacheKey });
|
||||
return createUrl(getAssetOriginalPath(id), { ...authManager.params, c: cacheKey });
|
||||
};
|
||||
|
||||
export const getAssetThumbnailUrl = (options: string | (AssetUrlOptions & { size?: AssetMediaSize })) => {
|
||||
@@ -192,7 +192,7 @@ export const getAssetThumbnailUrl = (options: string | (AssetUrlOptions & { size
|
||||
options = { id: options };
|
||||
}
|
||||
const { id, size, cacheKey } = options;
|
||||
return createUrl(getAssetThumbnailPath(id), { size, key: authManager.key, c: cacheKey });
|
||||
return createUrl(getAssetThumbnailPath(id), { ...authManager.params, size, c: cacheKey });
|
||||
};
|
||||
|
||||
export const getAssetPlaybackUrl = (options: string | AssetUrlOptions) => {
|
||||
@@ -200,7 +200,7 @@ export const getAssetPlaybackUrl = (options: string | AssetUrlOptions) => {
|
||||
options = { id: options };
|
||||
}
|
||||
const { id, cacheKey } = options;
|
||||
return createUrl(getAssetPlaybackPath(id), { key: authManager.key, c: cacheKey });
|
||||
return createUrl(getAssetPlaybackPath(id), { ...authManager.params, c: cacheKey });
|
||||
};
|
||||
|
||||
export const getProfileImageUrl = (user: UserResponseDto) =>
|
||||
@@ -257,8 +257,9 @@ export const copyToClipboard = async (secret: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const makeSharedLinkUrl = (key: string) => {
|
||||
return new URL(`share/${key}`, get(serverConfig).externalDomain || globalThis.location.origin).href;
|
||||
export const makeSharedLinkUrl = (sharedLink: SharedLinkResponseDto) => {
|
||||
const path = sharedLink.slug ? `s/${sharedLink.slug}` : `share/${sharedLink.key}`;
|
||||
return new URL(path, get(serverConfig).externalDomain || globalThis.location.origin).href;
|
||||
};
|
||||
|
||||
export const oauth = {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { downloadRequest, withError } from '$lib/utils';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { asQueryString } from '$lib/utils/shared-links';
|
||||
import {
|
||||
addAssetsToAlbum as addAssets,
|
||||
AssetVisibility,
|
||||
@@ -42,11 +43,11 @@ import { handleError } from './handle-error';
|
||||
|
||||
export const addAssetsToAlbum = async (albumId: string, assetIds: string[], showNotification = true) => {
|
||||
const result = await addAssets({
|
||||
...authManager.params,
|
||||
id: albumId,
|
||||
bulkIdsDto: {
|
||||
ids: assetIds,
|
||||
},
|
||||
key: authManager.key,
|
||||
});
|
||||
const count = result.filter(({ success }) => success).length;
|
||||
const duplicateErrorCount = result.filter(({ error }) => error === 'duplicate').length;
|
||||
@@ -155,7 +156,7 @@ export const downloadArchive = async (fileName: string, options: Omit<DownloadIn
|
||||
const $preferences = get<UserPreferencesResponseDto | undefined>(preferences);
|
||||
const dto = { ...options, archiveSize: $preferences?.download.archiveSize };
|
||||
|
||||
const [error, downloadInfo] = await withError(() => getDownloadInfo({ downloadInfoDto: dto, key: authManager.key }));
|
||||
const [error, downloadInfo] = await withError(() => getDownloadInfo({ ...authManager.params, downloadInfoDto: dto }));
|
||||
if (error) {
|
||||
const $t = get(t);
|
||||
handleError(error, $t('errors.unable_to_download_files'));
|
||||
@@ -170,7 +171,7 @@ export const downloadArchive = async (fileName: string, options: Omit<DownloadIn
|
||||
const archive = downloadInfo.archives[index];
|
||||
const suffix = downloadInfo.archives.length > 1 ? `+${index + 1}` : '';
|
||||
const archiveName = fileName.replace('.zip', `${suffix}-${DateTime.now().toFormat('yyyyLLdd_HHmmss')}.zip`);
|
||||
const key = authManager.key;
|
||||
const queryParams = asQueryString(authManager.params);
|
||||
|
||||
let downloadKey = `${archiveName} `;
|
||||
if (downloadInfo.archives.length > 1) {
|
||||
@@ -184,7 +185,7 @@ export const downloadArchive = async (fileName: string, options: Omit<DownloadIn
|
||||
// TODO use sdk once it supports progress events
|
||||
const { data } = await downloadRequest({
|
||||
method: 'POST',
|
||||
url: getBaseUrl() + '/download/archive' + (key ? `?key=${key}` : ''),
|
||||
url: getBaseUrl() + '/download/archive' + (queryParams ? `?${queryParams}` : ''),
|
||||
data: { assetIds: archive.assetIds },
|
||||
signal: abort.signal,
|
||||
onDownloadProgress: (event) => downloadManager.update(downloadKey, event.loaded),
|
||||
@@ -217,7 +218,7 @@ export const downloadFile = async (asset: AssetResponseDto) => {
|
||||
};
|
||||
|
||||
if (asset.livePhotoVideoId) {
|
||||
const motionAsset = await getAssetInfo({ id: asset.livePhotoVideoId, key: authManager.key });
|
||||
const motionAsset = await getAssetInfo({ ...authManager.params, id: asset.livePhotoVideoId });
|
||||
if (!isAndroidMotionVideo(motionAsset) || get(preferences)?.download.includeEmbeddedVideos) {
|
||||
assets.push({
|
||||
filename: motionAsset.originalFileName,
|
||||
@@ -227,16 +228,16 @@ export const downloadFile = async (asset: AssetResponseDto) => {
|
||||
}
|
||||
}
|
||||
|
||||
const queryParams = asQueryString(authManager.params);
|
||||
|
||||
for (const { filename, id } of assets) {
|
||||
try {
|
||||
const key = authManager.key;
|
||||
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: $t('downloading_asset_filename', { values: { filename: asset.originalFileName } }),
|
||||
});
|
||||
|
||||
downloadUrl(getBaseUrl() + `/assets/${id}/original` + (key ? `?key=${key}` : ''), filename);
|
||||
downloadUrl(getBaseUrl() + `/assets/${id}/original` + (queryParams ? `?${queryParams}` : ''), filename);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.error_downloading', { values: { filename } }));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import { uploadRequest } from '$lib/utils';
|
||||
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
|
||||
import { ExecutorQueue } from '$lib/utils/executor-queue';
|
||||
import { asQueryString } from '$lib/utils/shared-links';
|
||||
import {
|
||||
Action,
|
||||
AssetMediaStatus,
|
||||
@@ -152,8 +153,7 @@ async function fileUploader({
|
||||
}
|
||||
|
||||
let responseData: { id: string; status: AssetMediaStatus; isTrashed?: boolean } | undefined;
|
||||
const key = authManager.key;
|
||||
if (crypto?.subtle?.digest && !key) {
|
||||
if (crypto?.subtle?.digest && !authManager.isSharedLink) {
|
||||
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_hashing') });
|
||||
await tick();
|
||||
try {
|
||||
@@ -179,10 +179,12 @@ async function fileUploader({
|
||||
}
|
||||
|
||||
if (!responseData) {
|
||||
const queryParams = asQueryString(authManager.params);
|
||||
|
||||
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_uploading') });
|
||||
if (replaceAssetId) {
|
||||
const response = await uploadRequest<AssetMediaResponseDto>({
|
||||
url: getBaseUrl() + getAssetOriginalPath(replaceAssetId) + (key ? `?key=${key}` : ''),
|
||||
url: getBaseUrl() + getAssetOriginalPath(replaceAssetId) + (queryParams ? `?${queryParams}` : ''),
|
||||
method: 'PUT',
|
||||
data: formData,
|
||||
onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total),
|
||||
@@ -190,7 +192,7 @@ async function fileUploader({
|
||||
responseData = response.data;
|
||||
} else {
|
||||
const response = await uploadRequest<AssetMediaResponseDto>({
|
||||
url: getBaseUrl() + '/assets' + (key ? `?key=${key}` : ''),
|
||||
url: getBaseUrl() + '/assets' + (queryParams ? `?${queryParams}` : ''),
|
||||
data: formData,
|
||||
onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total),
|
||||
});
|
||||
|
||||
@@ -13,7 +13,8 @@ export const isExternalUrl = (url: string): boolean => {
|
||||
};
|
||||
|
||||
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 isSharedLinkRoute = (route?: string | null) =>
|
||||
!!route?.startsWith('/(user)/share/[key]') || !!route?.startsWith('/(user)/s/[slug]');
|
||||
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]');
|
||||
|
||||
58
web/src/lib/utils/shared-links.ts
Normal file
58
web/src/lib/utils/shared-links.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
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';
|
||||
|
||||
export const asQueryString = ({ slug, key }: { slug?: string; key?: string }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (slug) {
|
||||
params.set('slug', slug);
|
||||
}
|
||||
|
||||
if (key) {
|
||||
params.set('key', key);
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
export const loadSharedLink = async ({ url, params }: { url: URL; params: { key?: string; slug?: string } }) => {
|
||||
const { key, slug } = params;
|
||||
await authenticate(url, { public: true });
|
||||
|
||||
const common = { key, slug };
|
||||
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
const [sharedLink, asset] = await Promise.all([getMySharedLink({ key, slug }), 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 {
|
||||
...common,
|
||||
sharedLink,
|
||||
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 {
|
||||
...common,
|
||||
passwordRequired: true,
|
||||
meta: {
|
||||
title: $t('password_required'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -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) });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
5
web/src/routes/(user)/s/[slug]/+error.svelte
Normal file
5
web/src/routes/(user)/s/[slug]/+error.svelte
Normal file
@@ -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;
|
||||
|
||||
@@ -16,4 +16,5 @@ export const sharedLinkFactory = Sync.makeFactory<SharedLinkResponseDto>({
|
||||
allowUpload: Sync.each(() => faker.datatype.boolean()),
|
||||
allowDownload: Sync.each(() => faker.datatype.boolean()),
|
||||
showMetadata: Sync.each(() => faker.datatype.boolean()),
|
||||
slug: null,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user