feat(web/server) public album sharing (#1266)

This commit is contained in:
Alex 2023-01-09 14:16:08 -06:00 committed by GitHub
parent fd15cdbf40
commit 10789503c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
101 changed files with 4879 additions and 347 deletions

View file

@ -93,7 +93,7 @@
>.
</p>
<SettingSwitch title="Enable" bind:checked={oauthConfig.enabled} />
<SettingSwitch title="ENABLE" bind:checked={oauthConfig.enabled} />
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}

View file

@ -25,7 +25,7 @@
<div class="w-full">
<div class={`flex place-items-center gap-1 h-[26px]`}>
<label class={`immich-form-label text-xs`} for={label}>{label}</label>
<label class={`immich-form-label text-sm`} for={label}>{label}</label>
{#if required}
<div class="text-red-400">*</div>
{/if}

View file

@ -8,13 +8,13 @@
<div class="flex justify-between place-items-center">
<div>
<h2 class="immich-form-label text-sm">
{title.toUpperCase()}
{title}
</h2>
<p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
</div>
<label class="relative inline-block w-[36px] h-[10px]" {disabled}>
<label class="relative inline-block w-[36px] h-[10px]">
<input
class="opacity-0 w-0 h-0 disabled::cursor-not-allowed"
type="checkbox"

View file

@ -93,6 +93,7 @@ describe('AlbumCard component', () => {
expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledWith(
'thumbnailIdOne',
ThumbnailFormat.Jpeg,
'',
{ responseType: 'blob' }
);
expect(createObjectURLMock).toHaveBeenCalledWith(thumbnailBlob);

View file

@ -1,7 +1,15 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/stores';
import { AlbumResponseDto, api, AssetResponseDto, ThumbnailFormat, UserResponseDto } from '@api';
import {
AlbumResponseDto,
api,
AssetResponseDto,
SharedLinkResponseDto,
SharedLinkType,
ThumbnailFormat,
UserResponseDto
} from '@api';
import { onMount } from 'svelte';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import Plus from 'svelte-material-icons/Plus.svelte';
@ -23,20 +31,30 @@
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import ThumbnailSelection from './thumbnail-selection.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
import {
notificationController,
NotificationType
} from '../shared-components/notification/notification';
import { browser } from '$app/environment';
import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
import CreateSharedLinkModal from '../shared-components/create-share-link-modal/create-shared-link-modal.svelte';
import ThemeButton from '../shared-components/theme-button.svelte';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { bulkDownload } from '$lib/utils/asset-utils';
export let album: AlbumResponseDto;
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore;
let isShowAssetViewer = false;
let isShowAssetSelection = false;
let isShowShareLinkModal = false;
$: $isAlbumAssetSelectionOpen = isShowAssetSelection;
$: {
if (browser) {
@ -65,6 +83,7 @@
let titleInput: HTMLInputElement;
let contextMenuPosition = { x: 0, y: 0 };
$: isPublicShared = sharedLink;
$: isOwned = currentUser?.id == album.ownerId;
let multiSelectAsset: Set<AssetResponseDto> = new Set();
@ -82,7 +101,11 @@
if (album.assets?.length < 6) {
thumbnailSize = Math.floor(viewWidth / album.assetCount - album.assetCount);
} else {
thumbnailSize = Math.floor(viewWidth / 6 - 6);
if (viewWidth > 600) thumbnailSize = Math.floor(viewWidth / 6 - 6);
else if (viewWidth > 400) thumbnailSize = Math.floor(viewWidth / 4 - 6);
else if (viewWidth > 300) thumbnailSize = Math.floor(viewWidth / 2 - 6);
else if (viewWidth > 200) thumbnailSize = Math.floor(viewWidth / 2 - 6);
else if (viewWidth > 100) thumbnailSize = Math.floor(viewWidth / 1 - 6);
}
}
@ -219,9 +242,17 @@
const createAlbumHandler = async (event: CustomEvent) => {
const { assets }: { assets: AssetResponseDto[] } = event.detail;
try {
const { data } = await api.albumApi.addAssetsToAlbum(album.id, {
assetIds: assets.map((a) => a.id)
});
const { data } = await api.albumApi.addAssetsToAlbum(
album.id,
{
assetIds: assets.map((a) => a.id)
},
{
params: {
key: sharedLink?.key
}
}
);
if (data.album) {
album = data.album;
@ -316,6 +347,9 @@
album.id,
skip || undefined,
{
params: {
key: sharedLink?.key
},
responseType: 'blob',
onDownloadProgress: function (progressEvent) {
const request = this as XMLHttpRequest;
@ -397,6 +431,23 @@
isShowThumbnailSelection = false;
};
const onSharedLinkClickHandler = () => {
isShowShareUserSelection = false;
isShowShareLinkModal = true;
};
const handleDownloadSelectedAssets = async () => {
await bulkDownload(
album.albumName,
Array.from(multiSelectAsset),
() => {
isMultiSelectionMode = false;
clearMultiSelectAssetAssetHandler();
},
sharedLink?.key
);
};
</script>
<section class="bg-immich-bg dark:bg-immich-dark-bg">
@ -413,6 +464,11 @@
</p>
</svelte:fragment>
<svelte:fragment slot="trailing">
<CircleIconButton
title="Download"
on:click={handleDownloadSelectedAssets}
logo={CloudDownloadOutline}
/>
{#if isOwned}
<CircleIconButton
title="Remove from album"
@ -426,14 +482,45 @@
<!-- Default app bar -->
{#if !isMultiSelectionMode}
<ControlAppBar on:close-button-click={() => goto(backUrl)} backIcon={ArrowLeft}>
<ControlAppBar
on:close-button-click={() => goto(backUrl)}
backIcon={ArrowLeft}
showBackButton={(!isPublicShared && isOwned) ||
(!isPublicShared && !isOwned) ||
(isPublicShared && isOwned)}
>
<svelte:fragment slot="leading">
{#if isPublicShared && !isOwned}
<a
data-sveltekit-preload-data="hover"
class="flex gap-2 place-items-center hover:cursor-pointer ml-6"
href="https://immich.app"
>
<img src="/immich-logo.svg" alt="immich logo" height="30" width="30" />
<h1 class="font-immich-title text-lg text-immich-primary dark:text-immich-dark-primary">
IMMICH
</h1>
</a>
{/if}
</svelte:fragment>
<svelte:fragment slot="trailing">
{#if album.assetCount > 0}
<CircleIconButton
title="Add Photos"
on:click={() => (isShowAssetSelection = true)}
logo={FileImagePlusOutline}
/>
{#if !sharedLink}
<CircleIconButton
title="Add Photos"
on:click={() => (isShowAssetSelection = true)}
logo={FileImagePlusOutline}
/>
{/if}
{#if sharedLink?.allowUpload}
<CircleIconButton
title="Add Photos"
on:click={() => openFileUploadDialog(album.id, sharedLink?.key)}
logo={FileImagePlusOutline}
/>
{/if}
<!-- Share and remove album -->
{#if isOwned}
@ -451,11 +538,17 @@
logo={FolderDownloadOutline}
/>
<CircleIconButton
title="Album options"
on:click={(event) => showAlbumOptionsMenu(event)}
logo={DotsVertical}
/>
{#if !isPublicShared}
<CircleIconButton
title="Album options"
on:click={(event) => showAlbumOptionsMenu(event)}
logo={DotsVertical}
/>
{/if}
{#if isPublicShared}
<ThemeButton />
{/if}
{/if}
{#if isCreatingSharedAlbum && album.sharedUsers.length == 0}
@ -470,7 +563,7 @@
</ControlAppBar>
{/if}
<section class="m-auto my-[160px] w-[60%]">
<section class="flex flex-col my-[160px] px-6 sm:px-12 md:px-24 lg:px-40">
<input
on:keydown={(e) => {
if (e.key == 'Enter') {
@ -492,7 +585,6 @@
{#if album.assetCount > 0}
<p class="my-4 text-sm text-gray-500 font-medium">{getDateRange()}</p>
{/if}
{#if album.shared}
<div class="my-6 flex">
{#each album.sharedUsers as user}
@ -521,6 +613,7 @@
<ImmichThumbnail
{asset}
{thumbnailSize}
publicSharedKey={sharedLink?.key}
format={ThumbnailFormat.Jpeg}
on:click={(e) =>
isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e)}
@ -531,6 +624,7 @@
<ImmichThumbnail
{asset}
{thumbnailSize}
publicSharedKey={sharedLink?.key}
on:click={(e) =>
isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e)}
on:select={selectAssetHandler}
@ -564,6 +658,7 @@
{#if isShowAssetViewer}
<AssetViewer
asset={selectedAsset}
publicSharedKey={sharedLink?.key}
on:navigate-previous={navigateAssetBackward}
on:navigate-next={navigateAssetForward}
on:close={closeViewer}
@ -581,12 +676,21 @@
{#if isShowShareUserSelection}
<UserSelectionModal
{album}
on:close={() => (isShowShareUserSelection = false)}
on:add-user={addUserHandler}
on:sharedlinkclick={onSharedLinkClickHandler}
sharedUsersInAlbum={new Set(album.sharedUsers)}
/>
{/if}
{#if isShowShareLinkModal}
<CreateSharedLinkModal
on:close={() => (isShowShareLinkModal = false)}
shareType={SharedLinkType.Album}
{album}
/>
{/if}
{#if isShowShareInfoModal}
<ShareInfoModal
on:close={() => (isShowShareInfoModal = false)}

View file

@ -51,7 +51,7 @@
<svelte:fragment slot="trailing">
<button
on:click={() =>
openFileUploadDialog(albumId, () => {
openFileUploadDialog(albumId, '', () => {
assetInteractionStore.clearMultiselect();
dispatch('go-back');
})}

View file

@ -1,16 +1,21 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { api, UserResponseDto } from '@api';
import { AlbumResponseDto, api, SharedLinkResponseDto, UserResponseDto } from '@api';
import BaseModal from '../shared-components/base-modal.svelte';
import CircleAvatar from '../shared-components/circle-avatar.svelte';
import Link from 'svelte-material-icons/Link.svelte';
import ShareCircle from 'svelte-material-icons/ShareCircle.svelte';
import { goto } from '$app/navigation';
export let album: AlbumResponseDto;
export let sharedUsersInAlbum: Set<UserResponseDto>;
let users: UserResponseDto[] = [];
let selectedUsers: UserResponseDto[] = [];
const dispatch = createEventDispatcher();
let sharedLinks: SharedLinkResponseDto[] = [];
onMount(async () => {
await getSharedLinks();
const { data } = await api.userApi.getAllUsers(false);
// remove soft deleted users
@ -22,6 +27,12 @@
});
});
const getSharedLinks = async () => {
const { data } = await api.shareApi.getAllSharedLinks();
sharedLinks = data.filter((link) => link.album?.id === album.id);
};
const selectUser = (user: UserResponseDto) => {
if (selectedUsers.includes(user)) {
selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id);
@ -33,6 +44,10 @@
const deselectUser = (user: UserResponseDto) => {
selectedUsers = selectedUsers.filter((selectedUser) => selectedUser.id !== user.id);
};
const onSharedLinkClick = () => {
dispatch('sharedlinkclick');
};
</script>
<BaseModal on:close={() => dispatch('close')}>
@ -93,7 +108,7 @@
{/each}
</div>
{:else}
<p class="text-sm px-5">
<p class="text-sm p-5">
Looks like you have shared this album with all users or you don't have any user to share
with.
</p>
@ -109,4 +124,25 @@
</div>
{/if}
</div>
<hr />
<div id="shared-buttons" class="flex my-4 justify-around place-items-center place-content-center">
<button
class="flex flex-col gap-2 place-items-center place-content-center hover:cursor-pointer"
on:click={onSharedLinkClick}
>
<Link size={24} />
<p class="text-sm">Create link</p>
</button>
{#if sharedLinks.length}
<button
class="flex flex-col gap-2 place-items-center place-content-center hover:cursor-pointer"
on:click={() => goto('/sharing/sharedlinks')}
>
<ShareCircle size={24} />
<p class="text-sm">View links</p>
</button>
{/if}
</div>
</BaseModal>

View file

@ -23,7 +23,7 @@
export let showMotionPlayButton: boolean;
export let isMotionPhotoPlaying = false;
const isOwner = asset.ownerId === $page.data.user.id;
const isOwner = asset.ownerId === $page.data.user?.id;
const dispatch = createEventDispatcher();
@ -94,12 +94,15 @@
title="Favorite"
/>
{/if}
<CircleIconButton logo={DeleteOutline} on:click={() => dispatch('delete')} title="Delete" />
<CircleIconButton
logo={DotsVertical}
on:click={(event) => showOptionsMenu(event)}
title="More"
/>
{#if isOwner}
<CircleIconButton logo={DeleteOutline} on:click={() => dispatch('delete')} title="Delete" />
<CircleIconButton
logo={DotsVertical}
on:click={(event) => showOptionsMenu(event)}
title="More"
/>
{/if}
</div>
</div>

View file

@ -10,12 +10,7 @@
import { downloadAssets } from '$lib/stores/download';
import VideoViewer from './video-viewer.svelte';
import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte';
import {
api,
AssetResponseDto,
AssetTypeEnum,
AlbumResponseDto
} from '@api';
import { api, AssetResponseDto, AssetTypeEnum, AlbumResponseDto } from '@api';
import {
notificationController,
NotificationType
@ -25,6 +20,9 @@
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
export let asset: AssetResponseDto;
export let publicSharedKey = '';
export let showNavigation = true;
$: {
appearsInAlbums = [];
@ -91,12 +89,12 @@
const handleDownload = () => {
if (asset.livePhotoVideoId) {
downloadFile(asset.livePhotoVideoId, true);
downloadFile(asset.id, false);
downloadFile(asset.livePhotoVideoId, true, publicSharedKey);
downloadFile(asset.id, false, publicSharedKey);
return;
}
downloadFile(asset.id, false);
downloadFile(asset.id, false, publicSharedKey);
};
/**
@ -111,7 +109,7 @@
};
};
const downloadFile = async (assetId: string, isLivePhoto: boolean) => {
const downloadFile = async (assetId: string, isLivePhoto: boolean, key: string) => {
try {
const { filenameWithoutExtension } = getTemplateFilename();
@ -126,6 +124,9 @@
$downloadAssets[imageFileName] = 0;
const { data, status } = await api.assetApi.downloadFile(assetId, false, false, {
params: {
key
},
responseType: 'blob',
onDownloadProgress: (progressEvent) => {
if (progressEvent.lengthComputable) {
@ -251,69 +252,74 @@
/>
</div>
<div
class={`row-start-2 row-span-end col-start-1 col-span-2 flex place-items-center hover:cursor-pointer w-3/4 mb-[60px] ${
asset.type === AssetTypeEnum.Video ? '' : 'z-[999]'
}`}
on:mouseenter={() => {
halfLeftHover = true;
halfRightHover = false;
}}
on:mouseleave={() => {
halfLeftHover = false;
}}
on:click={navigateAssetBackward}
on:keydown={navigateAssetBackward}
>
<button
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 z-[1000] text-gray-500 mx-4"
class:navigation-button-hover={halfLeftHover}
{#if showNavigation}
<div
class={`row-start-2 row-span-end col-start-1 col-span-2 flex place-items-center hover:cursor-pointer w-3/4 mb-[60px] ${
asset.type === AssetTypeEnum.Video ? '' : 'z-[999]'
}`}
on:mouseenter={() => {
halfLeftHover = true;
halfRightHover = false;
}}
on:mouseleave={() => {
halfLeftHover = false;
}}
on:click={navigateAssetBackward}
on:keydown={navigateAssetBackward}
>
<ChevronLeft size="36" />
</button>
</div>
<button
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 z-[1000] text-gray-500 mx-4"
class:navigation-button-hover={halfLeftHover}
on:click={navigateAssetBackward}
>
<ChevronLeft size="36" />
</button>
</div>
{/if}
<div class="row-start-1 row-span-full col-start-1 col-span-4">
{#key asset.id}
{#if asset.type === AssetTypeEnum.Image}
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
<VideoViewer
{publicSharedKey}
assetId={asset.livePhotoVideoId}
on:close={closeViewer}
on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
/>
{:else}
<PhotoViewer assetId={asset.id} on:close={closeViewer} />
<PhotoViewer {publicSharedKey} assetId={asset.id} on:close={closeViewer} />
{/if}
{:else}
<VideoViewer assetId={asset.id} on:close={closeViewer} />
<VideoViewer {publicSharedKey} assetId={asset.id} on:close={closeViewer} />
{/if}
{/key}
</div>
<div
class={`row-start-2 row-span-full col-start-3 col-span-2 flex justify-end place-items-center hover:cursor-pointer w-3/4 justify-self-end mb-[60px] ${
asset.type === AssetTypeEnum.Video ? '' : 'z-[500]'
}`}
on:click={navigateAssetForward}
on:keydown={navigateAssetForward}
on:mouseenter={() => {
halfLeftHover = false;
halfRightHover = true;
}}
on:mouseleave={() => {
halfRightHover = false;
}}
>
<button
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4"
class:navigation-button-hover={halfRightHover}
{#if showNavigation}
<div
class={`row-start-2 row-span-full col-start-3 col-span-2 flex justify-end place-items-center hover:cursor-pointer w-3/4 justify-self-end mb-[60px] ${
asset.type === AssetTypeEnum.Video ? '' : 'z-[500]'
}`}
on:click={navigateAssetForward}
on:keydown={navigateAssetForward}
on:mouseenter={() => {
halfLeftHover = false;
halfRightHover = true;
}}
on:mouseleave={() => {
halfRightHover = false;
}}
>
<ChevronRight size="36" />
</button>
</div>
<button
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4"
class:navigation-button-hover={halfRightHover}
on:click={navigateAssetForward}
>
<ChevronRight size="36" />
</button>
</div>
{/if}
{#if isShowDetail}
<div

View file

@ -11,6 +11,7 @@
} from '../shared-components/notification/notification';
export let assetId: string;
export let publicSharedKey = '';
let assetInfo: AssetResponseDto;
let assetData: string;
@ -18,7 +19,11 @@
let copyImageToClipboard: (src: string) => Promise<Blob>;
onMount(async () => {
const { data } = await api.assetApi.getAssetById(assetId);
const { data } = await api.assetApi.getAssetById(assetId, {
params: {
key: publicSharedKey
}
});
assetInfo = data;
//Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
@ -29,6 +34,9 @@
const loadAssetData = async () => {
try {
const { data } = await api.assetApi.serveFile(assetInfo.id, false, true, {
params: {
key: publicSharedKey
},
responseType: 'blob'
});

View file

@ -6,7 +6,7 @@
import { api, AssetResponseDto, getFileUrl } from '@api';
export let assetId: string;
export let publicSharedKey = '';
let asset: AssetResponseDto;
let videoPlayerNode: HTMLVideoElement;
@ -15,7 +15,11 @@
const dispatch = createEventDispatcher();
onMount(async () => {
const { data: assetInfo } = await api.assetApi.getAssetById(assetId);
const { data: assetInfo } = await api.assetApi.getAssetById(assetId, {
params: {
key: publicSharedKey
}
});
await loadVideoData(assetInfo);
@ -25,7 +29,7 @@
const loadVideoData = async (assetInfo: AssetResponseDto) => {
isVideoLoading = true;
videoUrl = getFileUrl(assetInfo.id, false, true);
videoUrl = getFileUrl(assetInfo.id, false, true, publicSharedKey);
return assetInfo;
};

View file

@ -5,6 +5,8 @@
import Close from 'svelte-material-icons/Close.svelte';
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
import { fly } from 'svelte/transition';
export let showBackButton = true;
export let backIcon = Close;
export let tailwindClasses = '';
@ -42,14 +44,15 @@
class={`flex justify-between ${appBarBorder} rounded-lg p-2 mx-2 mt-2 transition-all place-items-center ${tailwindClasses} dark:bg-immich-dark-gray`}
>
<div class="flex place-items-center gap-6 dark:text-immich-dark-fg">
<CircleIconButton
on:click={() => dispatch('close-button-click')}
logo={backIcon}
backgroundColor={'transparent'}
hoverColor={'#e2e7e9'}
size={'24'}
/>
{#if showBackButton}
<CircleIconButton
on:click={() => dispatch('close-button-click')}
logo={backIcon}
backgroundColor={'transparent'}
hoverColor={'#e2e7e9'}
size={'24'}
/>
{/if}
<slot name="leading" />
</div>

View file

@ -0,0 +1,243 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import BaseModal from '../base-modal.svelte';
import Link from 'svelte-material-icons/Link.svelte';
import { AlbumResponseDto, api, SharedLinkResponseDto, SharedLinkType } from '@api';
import { notificationController, NotificationType } from '../notification/notification';
import { ImmichDropDownOption } from '../dropdown-button.svelte';
import SettingSwitch from '$lib/components/admin-page/settings/setting-switch.svelte';
import DropdownButton from '../dropdown-button.svelte';
import SettingInputField, {
SettingInputFieldType
} from '$lib/components/admin-page/settings/setting-input-field.svelte';
export let shareType: SharedLinkType;
export let album: AlbumResponseDto | undefined;
export let editingLink: SharedLinkResponseDto | undefined = undefined;
let isLoading = false;
let isShowSharedLink = false;
let expirationTime = '';
let isAllowUpload = false;
let sharedLink = '';
let description = '';
let shouldChangeExpirationTime = false;
const dispatch = createEventDispatcher();
const expiredDateOption: ImmichDropDownOption = {
default: 'Never',
options: ['Never', '30 minutes', '1 hour', '6 hours', '1 day', '7 days', '30 days']
};
onMount(() => {
if (editingLink) {
if (editingLink.description) {
description = editingLink.description;
}
isAllowUpload = editingLink.allowUpload;
}
});
const createAlbumSharedLink = async () => {
if (album) {
isLoading = true;
try {
const expirationTime = getExpirationTimeInMillisecond();
const currentTime = new Date().getTime();
const expirationDate = expirationTime
? new Date(currentTime + expirationTime).toISOString()
: undefined;
const { data } = await api.albumApi.createAlbumSharedLink({
albumId: album.id,
expiredAt: expirationDate,
allowUpload: isAllowUpload,
description: description
});
buildSharedLink(data);
isLoading = false;
isShowSharedLink = true;
} catch (e) {
console.error('[createAlbumSharedLink] Error: ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Failed to create shared link'
});
isLoading = false;
}
}
};
const buildSharedLink = (createdLink: SharedLinkResponseDto) => {
sharedLink = `${window.location.origin}/share/${createdLink.key}`;
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(sharedLink);
notificationController.show({
message: 'Copied to clipboard!',
type: NotificationType.Info
});
} catch (error) {
console.error('Error', error);
}
};
const getExpirationTimeInMillisecond = () => {
switch (expirationTime) {
case '30 minutes':
return 30 * 60 * 1000;
case '1 hour':
return 60 * 60 * 1000;
case '6 hours':
return 6 * 60 * 60 * 1000;
case '1 day':
return 24 * 60 * 60 * 1000;
case '7 days':
return 7 * 24 * 60 * 60 * 1000;
case '30 days':
return 30 * 24 * 60 * 60 * 1000;
default:
return 0;
}
};
const handleEditLink = async () => {
if (editingLink) {
try {
const expirationTime = getExpirationTimeInMillisecond();
const currentTime = new Date().getTime();
let expirationDate = expirationTime
? new Date(currentTime + expirationTime).toISOString()
: undefined;
if (expirationTime === 0) {
expirationDate = undefined;
}
await api.shareApi.editSharedLink(editingLink.id, {
description: description,
expiredAt: expirationDate,
allowUpload: isAllowUpload,
isEditExpireTime: shouldChangeExpirationTime
});
notificationController.show({
type: NotificationType.Info,
message: 'Edited'
});
dispatch('close');
} catch (e) {
console.error('[handleEditLink]', e);
notificationController.show({
type: NotificationType.Error,
message: 'Failed to edit shared link'
});
}
}
};
</script>
<BaseModal on:close={() => dispatch('close')}>
<svelte:fragment slot="title">
<span class="flex gap-2 place-items-center">
<Link size={24} />
{#if editingLink}
<p class="font-medium text-immich-fg dark:text-immich-dark-fg">Edit link</p>
{:else}
<p class="font-medium text-immich-fg dark:text-immich-dark-fg">Create link to share</p>
{/if}
</span>
</svelte:fragment>
<section class="mx-6 mb-6">
{#if shareType == SharedLinkType.Album}
{#if !editingLink}
<div>Let anyone with the link see photos and people in this album.</div>
{:else}
<div class="text-sm">
Public album | <span class="text-immich-primary dark:text-immich-dark-primary"
>{editingLink.album?.albumName}</span
>
</div>
{/if}
{/if}
<div class="mt-6 mb-2">
<p class="text-xs">LINK OPTIONS</p>
</div>
<div class="p-4 bg-gray-100 dark:bg-black/40 rounded-lg">
<div class="flex flex-col">
<div class="mb-4">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="Description"
bind:value={description}
/>
</div>
<SettingSwitch bind:checked={isAllowUpload} title={'Allow public user to upload'} />
<div class="text-sm mt-4">
{#if editingLink}
<p class="my-2 immich-form-label">
<SettingSwitch
bind:checked={shouldChangeExpirationTime}
title={'Change expiration time'}
/>
</p>
{:else}
<p class="my-2 immich-form-label">Expire after</p>
{/if}
<DropdownButton
options={expiredDateOption}
bind:selected={expirationTime}
disabled={editingLink && !shouldChangeExpirationTime}
/>
</div>
</div>
</div>
</section>
<hr />
<section class="m-6">
{#if !isShowSharedLink}
{#if editingLink}
<div class="flex justify-end">
<button
on:click={handleEditLink}
class="text-white dark:text-black bg-immich-primary px-4 py-2 rounded-lg text-sm transition-colors hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:hover:bg-immich-dark-primary/75"
>
Confirm
</button>
</div>
{:else}
<div class="flex justify-end">
<button
on:click={createAlbumSharedLink}
class="text-white dark:text-black bg-immich-primary px-4 py-2 rounded-lg text-sm transition-colors hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:hover:bg-immich-dark-primary/75"
>
Create Link
</button>
</div>
{/if}
{/if}
{#if isShowSharedLink}
<div class="flex w-full gap-4">
<input class="immich-form-input w-full" bind:value={sharedLink} />
<button
on:click={() => handleCopy()}
class="flex-1 transition-colors bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 dark:text-immich-dark-gray px-6 py-2 text-white rounded-full shadow-md w-full font-medium"
>Copy</button
>
</div>
{/if}
</section>
</BaseModal>

View file

@ -0,0 +1,76 @@
<script lang="ts" context="module">
export type ImmichDropDownOption = {
default: string;
options: string[];
};
</script>
<script lang="ts">
import { onMount } from 'svelte';
export let options: ImmichDropDownOption;
export let selected: string;
export let disabled = false;
onMount(() => {
selected = options.default;
});
export let isOpen = false;
const toggle = () => (isOpen = !isOpen);
</script>
<div id="immich-dropdown" class="relative">
<button
{disabled}
on:click={toggle}
aria-expanded={isOpen}
class="bg-gray-200 w-full flex p-2 rounded-lg dark:bg-gray-600 place-items-center justify-between disabled:cursor-not-allowed dark:disabled:bg-gray-300 disabled:bg-gray-600 "
>
<div>
{selected}
</div>
<div>
<svg
style="tran"
width="20"
height="20"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{#if isOpen}
<div class="flex flex-col mt-2 absolute w-full">
{#each options.options as option}
<button
on:click={() => {
selected = option;
isOpen = false;
}}
class="bg-gray-200 dark:bg-gray-500 dark:hover:bg-gray-700 w-full flex p-2 hover:bg-gray-300 transition-all "
>
{option}
</button>
{/each}
</div>
{/if}
</div>
<style>
svg {
transition: transform 0.2s ease-in;
}
[aria-expanded='true'] svg {
transform: rotate(0.5turn);
}
</style>

View file

@ -18,6 +18,9 @@
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
export let selected = false;
export let disabled = false;
export let publicSharedKey = '';
export let isRoundedCorner = false;
let imageData: string;
let mouseOver = false;
@ -35,10 +38,9 @@
isThumbnailVideoPlaying = false;
if (isLivePhoto && asset.livePhotoVideoId) {
console.log('get file url');
videoUrl = getFileUrl(asset.livePhotoVideoId, false, true);
videoUrl = getFileUrl(asset.livePhotoVideoId, false, true, publicSharedKey);
} else {
videoUrl = getFileUrl(asset.id, false, true);
videoUrl = getFileUrl(asset.id, false, true, publicSharedKey);
}
};
@ -118,6 +120,8 @@
return 'border-[20px] border-immich-primary/20';
} else if (disabled) {
return 'border-[20px] border-gray-300';
} else if (isRoundedCorner) {
return 'rounded-[20px]';
} else {
return '';
}
@ -244,7 +248,7 @@
style:width={`${thumbnailSize}px`}
style:height={`${thumbnailSize}px`}
in:fade={{ duration: 150 }}
src={`/api/asset/thumbnail/${asset.id}?format=${format}`}
src={`/api/asset/thumbnail/${asset.id}?format=${format}&key=${publicSharedKey}`}
alt={asset.id}
class={`object-cover ${getSize()} transition-all z-0 ${getThumbnailBorderStyle()}`}
loading="lazy"

View file

@ -49,7 +49,7 @@
on:click={toggleTheme}
id="theme-toggle"
type="button"
class="text-gray-500 dark:text-immich-dark-primary hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none rounded-lg text-sm p-2.5"
class="text-gray-500 dark:text-immich-dark-primary hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none rounded-full text-sm p-2.5"
>
<svg
id="theme-toggle-dark-icon"

View file

@ -0,0 +1,142 @@
<script lang="ts">
import { api, AssetResponseDto, SharedLinkResponseDto, SharedLinkType } from '@api';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import OpenInNew from 'svelte-material-icons/OpenInNew.svelte';
import Delete from 'svelte-material-icons/TrashCanOutline.svelte';
import ContentCopy from 'svelte-material-icons/ContentCopy.svelte';
import CircleEditOutline from 'svelte-material-icons/CircleEditOutline.svelte';
import * as luxon from 'luxon';
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
import { createEventDispatcher } from 'svelte';
import { goto } from '$app/navigation';
export let link: SharedLinkResponseDto;
let expirationCountdown: luxon.DurationObjectUnits;
const dispatch = createEventDispatcher();
const getAssetInfo = async (): Promise<AssetResponseDto> => {
let assetId = '';
if (link.album?.albumThumbnailAssetId) {
assetId = link.album.albumThumbnailAssetId;
} else if (link.assets.length > 0) {
assetId = link.assets[0];
}
const { data } = await api.assetApi.getAssetById(assetId);
return data;
};
const getCountDownExpirationDate = () => {
if (!link.expiresAt) {
return;
}
const expiresAtDate = luxon.DateTime.fromISO(new Date(link.expiresAt).toISOString());
const now = luxon.DateTime.now();
expirationCountdown = expiresAtDate
.diff(now, ['days', 'hours', 'minutes', 'seconds'])
.toObject();
if (expirationCountdown.days && expirationCountdown.days > 0) {
return expiresAtDate.toRelativeCalendar({ base: now, locale: 'en-US', unit: 'days' });
} else if (expirationCountdown.hours && expirationCountdown.hours > 0) {
return expiresAtDate.toRelativeCalendar({ base: now, locale: 'en-US', unit: 'hours' });
} else if (expirationCountdown.minutes && expirationCountdown.minutes > 0) {
return expiresAtDate.toRelativeCalendar({ base: now, locale: 'en-US', unit: 'minutes' });
} else if (expirationCountdown.seconds && expirationCountdown.seconds > 0) {
return expiresAtDate.toRelativeCalendar({ base: now, locale: 'en-US', unit: 'seconds' });
}
};
const isExpired = (expiresAt: string) => {
const now = new Date().getTime();
const expiration = new Date(expiresAt).getTime();
return now > expiration;
};
</script>
<div
class="w-full flex gap-4 dark:text-immich-gray transition-all border-b border-gray-200 dark:border-gray-600 hover:border-immich-primary dark:hover:border-immich-dark-primary py-4"
>
<div>
{#await getAssetInfo()}
<LoadingSpinner />
{:then asset}
<img
id={asset.id}
src={`/api/asset/thumbnail/${asset.id}?format=WEBP`}
alt={asset.id}
class="object-cover w-[100px] h-[100px] rounded-lg"
loading="lazy"
/>
{/await}
</div>
<div class="flex flex-col justify-between">
<div class="info-top">
<div class="text-xs font-mono font-semibold text-gray-500 dark:text-gray-400">
{#if link.expiresAt}
{#if isExpired(link.expiresAt)}
<p class="text-red-600 dark:text-red-400 font-bold">Expired</p>
{:else}
<p>
Expires {getCountDownExpirationDate()}
</p>
{/if}
{:else}
<p>Expires ∞</p>
{/if}
</div>
<div class="text-sm">
<div
class="flex gap-2 place-items-center text-immich-primary dark:text-immich-dark-primary"
>
{#if link.type === SharedLinkType.Album}
<p>
{link.album?.albumName.toUpperCase()}
</p>
{:else if link.type === SharedLinkType.Individual}
<p>INDIVIDUAL SHARE</p>
{/if}
{#if !link.expiresAt || !isExpired(link.expiresAt)}
<div
class="hover:cursor-pointer"
title="Go to share page"
on:click={() => goto(`/share/${link.key}`)}
on:keydown={() => goto(`/share/${link.key}`)}
>
<OpenInNew />
</div>
{/if}
</div>
<p class="text-sm">{link.description ?? ''}</p>
</div>
</div>
<div class="info-bottom">
{#if link.allowUpload}
<div
class="text-xs px-2 py-1 bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-gray flex place-items-center place-content-center rounded-full w-[100px]"
>
Allow upload
</div>
{/if}
</div>
</div>
<div class="flex-auto flex flex-col place-content-center place-items-end text-right">
<div class="flex">
<CircleIconButton logo={Delete} on:click={() => dispatch('delete')} />
<CircleIconButton logo={CircleEditOutline} on:click={() => dispatch('edit')} />
<CircleIconButton logo={ContentCopy} on:click={() => dispatch('copy')} />
</div>
</div>
</div>