mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
feat(web/server) public album sharing (#1266)
This commit is contained in:
parent
fd15cdbf40
commit
10789503c1
101 changed files with 4879 additions and 347 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue