feat(web) Individual assets shared mechanism (#1317)

* Create shared link modal for individual asset

* Added API to create asset shared link

* Added viewer for individual shared link

* Added multiselection app bar

* Refactor gallery viewer to its own component

* Refactor

* Refactor

* Add and remove asset from shared link

* Fixed test

* Fixed notification card doesn't wrap

* Add check asset access when created asset shared link

* pr feedback
This commit is contained in:
Alex
2023-01-14 23:49:47 -06:00
committed by GitHub
parent b9b2b559a1
commit e9fda40b2b
66 changed files with 2085 additions and 242 deletions

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.40.0
* The version of the OpenAPI document: 1.41.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -702,6 +702,37 @@ export interface CreateAlbumShareLinkDto {
*/
'description'?: string;
}
/**
*
* @export
* @interface CreateAssetsShareLinkDto
*/
export interface CreateAssetsShareLinkDto {
/**
*
* @type {Array<string>}
* @memberof CreateAssetsShareLinkDto
*/
'assetIds': Array<string>;
/**
*
* @type {string}
* @memberof CreateAssetsShareLinkDto
*/
'expiredAt'?: string;
/**
*
* @type {boolean}
* @memberof CreateAssetsShareLinkDto
*/
'allowUpload'?: boolean;
/**
*
* @type {string}
* @memberof CreateAssetsShareLinkDto
*/
'description'?: string;
}
/**
*
* @export
@@ -2029,6 +2060,19 @@ export interface UpdateAssetDto {
*/
'isFavorite'?: boolean;
}
/**
*
* @export
* @interface UpdateAssetsToSharedLinkDto
*/
export interface UpdateAssetsToSharedLinkDto {
/**
*
* @type {Array<string>}
* @memberof UpdateAssetsToSharedLinkDto
*/
'assetIds': Array<string>;
}
/**
*
* @export
@@ -3599,6 +3643,45 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions,
};
},
/**
*
* @param {CreateAssetsShareLinkDto} createAssetsShareLinkDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createAssetsSharedLink: async (createAssetsShareLinkDto: CreateAssetsShareLinkDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'createAssetsShareLinkDto' is not null or undefined
assertParamExists('createAssetsSharedLink', 'createAssetsShareLinkDto', createAssetsShareLinkDto)
const localVarPath = `/asset/shared-link`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(createAssetsShareLinkDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {DeleteAssetDto} deleteAssetDto
@@ -4255,6 +4338,45 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions,
};
},
/**
*
* @param {UpdateAssetsToSharedLinkDto} updateAssetsToSharedLinkDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateAssetsInSharedLink: async (updateAssetsToSharedLinkDto: UpdateAssetsToSharedLinkDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'updateAssetsToSharedLinkDto' is not null or undefined
assertParamExists('updateAssetsInSharedLink', 'updateAssetsToSharedLinkDto', updateAssetsToSharedLinkDto)
const localVarPath = `/asset/shared-link`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(updateAssetsToSharedLinkDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {any} assetData
@@ -4329,6 +4451,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.checkExistingAssets(checkExistingAssetsDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {CreateAssetsShareLinkDto} createAssetsShareLinkDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createAssetsSharedLink(createAssetsShareLinkDto: CreateAssetsShareLinkDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SharedLinkResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createAssetsSharedLink(createAssetsShareLinkDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {DeleteAssetDto} deleteAssetDto
@@ -4501,6 +4633,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAsset(assetId, updateAssetDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {UpdateAssetsToSharedLinkDto} updateAssetsToSharedLinkDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updateAssetsInSharedLink(updateAssetsToSharedLinkDto: UpdateAssetsToSharedLinkDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SharedLinkResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssetsInSharedLink(updateAssetsToSharedLinkDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {any} assetData
@@ -4539,6 +4681,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
checkExistingAssets(checkExistingAssetsDto: CheckExistingAssetsDto, options?: any): AxiosPromise<CheckExistingAssetsResponseDto> {
return localVarFp.checkExistingAssets(checkExistingAssetsDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {CreateAssetsShareLinkDto} createAssetsShareLinkDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createAssetsSharedLink(createAssetsShareLinkDto: CreateAssetsShareLinkDto, options?: any): AxiosPromise<SharedLinkResponseDto> {
return localVarFp.createAssetsSharedLink(createAssetsShareLinkDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {DeleteAssetDto} deleteAssetDto
@@ -4694,6 +4845,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
updateAsset(assetId: string, updateAssetDto: UpdateAssetDto, options?: any): AxiosPromise<AssetResponseDto> {
return localVarFp.updateAsset(assetId, updateAssetDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {UpdateAssetsToSharedLinkDto} updateAssetsToSharedLinkDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateAssetsInSharedLink(updateAssetsToSharedLinkDto: UpdateAssetsToSharedLinkDto, options?: any): AxiosPromise<SharedLinkResponseDto> {
return localVarFp.updateAssetsInSharedLink(updateAssetsToSharedLinkDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {any} assetData
@@ -4735,6 +4895,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).checkExistingAssets(checkExistingAssetsDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {CreateAssetsShareLinkDto} createAssetsShareLinkDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public createAssetsSharedLink(createAssetsShareLinkDto: CreateAssetsShareLinkDto, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).createAssetsSharedLink(createAssetsShareLinkDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {DeleteAssetDto} deleteAssetDto
@@ -4924,6 +5095,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).updateAsset(assetId, updateAssetDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {UpdateAssetsToSharedLinkDto} updateAssetsToSharedLinkDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public updateAssetsInSharedLink(updateAssetsToSharedLinkDto: UpdateAssetsToSharedLinkDto, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).updateAssetsInSharedLink(updateAssetsToSharedLinkDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {any} assetData
@@ -5300,6 +5482,7 @@ export const DeviceInfoApiAxiosParamCreator = function (configuration?: Configur
* @deprecated
* @param {UpsertDeviceInfoDto} upsertDeviceInfoDto
* @param {*} [options] Override http request option.
* @deprecated
* @throws {RequiredError}
*/
createDeviceInfo: async (upsertDeviceInfoDto: UpsertDeviceInfoDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
@@ -5339,6 +5522,7 @@ export const DeviceInfoApiAxiosParamCreator = function (configuration?: Configur
* @deprecated
* @param {UpsertDeviceInfoDto} upsertDeviceInfoDto
* @param {*} [options] Override http request option.
* @deprecated
* @throws {RequiredError}
*/
updateDeviceInfo: async (upsertDeviceInfoDto: UpsertDeviceInfoDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
@@ -5427,6 +5611,7 @@ export const DeviceInfoApiFp = function(configuration?: Configuration) {
* @deprecated
* @param {UpsertDeviceInfoDto} upsertDeviceInfoDto
* @param {*} [options] Override http request option.
* @deprecated
* @throws {RequiredError}
*/
async createDeviceInfo(upsertDeviceInfoDto: UpsertDeviceInfoDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<DeviceInfoResponseDto>> {
@@ -5437,6 +5622,7 @@ export const DeviceInfoApiFp = function(configuration?: Configuration) {
* @deprecated
* @param {UpsertDeviceInfoDto} upsertDeviceInfoDto
* @param {*} [options] Override http request option.
* @deprecated
* @throws {RequiredError}
*/
async updateDeviceInfo(upsertDeviceInfoDto: UpsertDeviceInfoDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<DeviceInfoResponseDto>> {
@@ -5467,6 +5653,7 @@ export const DeviceInfoApiFactory = function (configuration?: Configuration, bas
* @deprecated
* @param {UpsertDeviceInfoDto} upsertDeviceInfoDto
* @param {*} [options] Override http request option.
* @deprecated
* @throws {RequiredError}
*/
createDeviceInfo(upsertDeviceInfoDto: UpsertDeviceInfoDto, options?: any): AxiosPromise<DeviceInfoResponseDto> {
@@ -5476,6 +5663,7 @@ export const DeviceInfoApiFactory = function (configuration?: Configuration, bas
* @deprecated
* @param {UpsertDeviceInfoDto} upsertDeviceInfoDto
* @param {*} [options] Override http request option.
* @deprecated
* @throws {RequiredError}
*/
updateDeviceInfo(upsertDeviceInfoDto: UpsertDeviceInfoDto, options?: any): AxiosPromise<DeviceInfoResponseDto> {
@@ -5504,6 +5692,7 @@ export class DeviceInfoApi extends BaseAPI {
* @deprecated
* @param {UpsertDeviceInfoDto} upsertDeviceInfoDto
* @param {*} [options] Override http request option.
* @deprecated
* @throws {RequiredError}
* @memberof DeviceInfoApi
*/
@@ -5515,6 +5704,7 @@ export class DeviceInfoApi extends BaseAPI {
* @deprecated
* @param {UpsertDeviceInfoDto} upsertDeviceInfoDto
* @param {*} [options] Override http request option.
* @deprecated
* @throws {RequiredError}
* @memberof DeviceInfoApi
*/

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.40.0
* The version of the OpenAPI document: 1.41.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.40.0
* The version of the OpenAPI document: 1.41.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.40.0
* The version of the OpenAPI document: 1.41.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.40.0
* The version of the OpenAPI document: 1.41.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -1,13 +1,11 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/stores';
import {
AlbumResponseDto,
api,
AssetResponseDto,
SharedLinkResponseDto,
SharedLinkType,
ThumbnailFormat,
UserResponseDto
} from '@api';
import { onMount } from 'svelte';
@@ -15,9 +13,7 @@
import Plus from 'svelte-material-icons/Plus.svelte';
import FileImagePlusOutline from 'svelte-material-icons/FileImagePlusOutline.svelte';
import ShareVariantOutline from 'svelte-material-icons/ShareVariantOutline.svelte';
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
import CircleAvatar from '../shared-components/circle-avatar.svelte';
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
import AssetSelection from './asset-selection.svelte';
import UserSelectionModal from './user-selection-modal.svelte';
import ShareInfoModal from './share-info-modal.svelte';
@@ -43,14 +39,13 @@
import ThemeButton from '../shared-components/theme-button.svelte';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { bulkDownload } from '$lib/utils/asset-utils';
import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte';
export let album: AlbumResponseDto;
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore;
let isShowAssetViewer = false;
let isShowAssetSelection = false;
let isShowShareLinkModal = false;
@@ -72,11 +67,6 @@
let isShowAlbumOptions = false;
let isShowThumbnailSelection = false;
let selectedAsset: AssetResponseDto;
let currentViewAssetIndex = 0;
let viewWidth: number;
let thumbnailSize = 300;
let backUrl = '/albums';
let currentAlbumName = '';
let currentUser: UserResponseDto;
@@ -97,18 +87,6 @@
}
});
$: {
if (album.assets?.length < 6) {
thumbnailSize = Math.floor(viewWidth / album.assetCount - album.assetCount);
} else {
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);
}
}
const locale = navigator.language;
const albumDateFormat: Intl.DateTimeFormatOptions = {
month: 'short',
@@ -140,28 +118,6 @@
}
});
const viewAssetHandler = (event: CustomEvent) => {
const { asset }: { asset: AssetResponseDto } = event.detail;
currentViewAssetIndex = album.assets.findIndex((a) => a.id == asset.id);
selectedAsset = album.assets[currentViewAssetIndex];
isShowAssetViewer = true;
pushState(selectedAsset.id);
};
const selectAssetHandler = (event: CustomEvent) => {
const { asset }: { asset: AssetResponseDto } = event.detail;
let temp = new Set(multiSelectAsset);
if (multiSelectAsset.has(asset)) {
temp.delete(asset);
} else {
temp.add(asset);
}
multiSelectAsset = temp;
};
const clearMultiSelectAssetAssetHandler = () => {
multiSelectAsset = new Set();
};
@@ -184,40 +140,6 @@
}
}
};
const navigateAssetForward = () => {
try {
if (currentViewAssetIndex < album.assetCount - 1) {
currentViewAssetIndex++;
selectedAsset = album.assets[currentViewAssetIndex];
pushState(selectedAsset.id);
}
} catch (e) {
console.error(e);
}
};
const navigateAssetBackward = () => {
try {
if (currentViewAssetIndex > 0) {
currentViewAssetIndex--;
selectedAsset = album.assets[currentViewAssetIndex];
pushState(selectedAsset.id);
}
} catch (e) {
console.error(e);
}
};
const pushState = (assetId: string) => {
// add a URL to the browser's history
// changes the current URL in the address bar but doesn't perform any SvelteKit navigation
history.pushState(null, '', `${$page.url.pathname}/photos/${assetId}`);
};
const closeViewer = () => {
isShowAssetViewer = false;
history.pushState(null, '', `${$page.url.pathname}`);
};
// Update Album Name
$: {
@@ -606,34 +528,11 @@
{/if}
{#if album.assetCount > 0}
<div class="flex flex-wrap gap-1 w-full pb-20" bind:clientWidth={viewWidth}>
{#each album.assets as asset}
{#key asset.id}
{#if album.assetCount < 7}
<ImmichThumbnail
{asset}
{thumbnailSize}
publicSharedKey={sharedLink?.key}
format={ThumbnailFormat.Jpeg}
on:click={(e) =>
isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e)}
on:select={selectAssetHandler}
selected={multiSelectAsset.has(asset)}
/>
{:else}
<ImmichThumbnail
{asset}
{thumbnailSize}
publicSharedKey={sharedLink?.key}
on:click={(e) =>
isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e)}
on:select={selectAssetHandler}
selected={multiSelectAsset.has(asset)}
/>
{/if}
{/key}
{/each}
</div>
<GalleryViewer
assets={album.assets}
key={sharedLink?.key ?? ''}
bind:selectedAssets={multiSelectAsset}
/>
{:else}
<!-- Album is empty - Show asset selectection buttons -->
<section id="empty-album" class=" mt-[200px] flex place-content-center place-items-center">
@@ -654,17 +553,6 @@
</section>
</section>
<!-- Overlay Asset Viewer -->
{#if isShowAssetViewer}
<AssetViewer
asset={selectedAsset}
publicSharedKey={sharedLink?.key}
on:navigate-previous={navigateAssetBackward}
on:navigate-next={navigateAssetForward}
on:close={closeViewer}
/>
{/if}
{#if isShowAssetSelection}
<AssetSelection
albumId={album.id}

View File

@@ -233,7 +233,7 @@
<section
id="immich-asset-viewer"
class="fixed h-screen w-screen top-0 overflow-y-hidden bg-black z-[999] grid grid-rows-[64px_1fr] grid-cols-4"
class="fixed h-screen w-screen left-0 top-0 overflow-y-hidden bg-black z-[999] grid grid-rows-[64px_1fr] grid-cols-4"
>
<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
<AssetViewerNavBar

View File

@@ -0,0 +1,150 @@
<script lang="ts">
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import { api, AssetResponseDto, SharedLinkResponseDto } from '@api';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import { goto } from '$app/navigation';
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
import FileImagePlusOutline from 'svelte-material-icons/FileImagePlusOutline.svelte';
import FolderDownloadOutline from 'svelte-material-icons/FolderDownloadOutline.svelte';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { bulkDownload } from '$lib/utils/asset-utils';
import Close from 'svelte-material-icons/Close.svelte';
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import {
notificationController,
NotificationType
} from '../shared-components/notification/notification';
export let sharedLink: SharedLinkResponseDto;
export let isOwned: boolean;
let assets = sharedLink.assets;
let selectedAssets: Set<AssetResponseDto> = new Set();
$: isMultiSelectionMode = selectedAssets.size > 0;
const clearMultiSelectAssetAssetHandler = () => {
selectedAssets = new Set();
};
const downloadAssets = async (isAll: boolean) => {
await bulkDownload(
'immich-shared',
isAll ? assets : Array.from(selectedAssets),
() => {
isMultiSelectionMode = false;
clearMultiSelectAssetAssetHandler();
},
sharedLink?.key
);
};
const handleUploadAssets = () => {
openFileUploadDialog(undefined, sharedLink?.key, async (assetId) => {
await api.assetApi.updateAssetsInSharedLink(
{
assetIds: [...assets.map((a) => a.id), assetId]
},
{
params: {
key: sharedLink?.key
}
}
);
notificationController.show({
message: 'Add asset to shared link successfully',
type: NotificationType.Info
});
});
};
const handleRemoveAssetsFromSharedLink = async () => {
if (window.confirm('Do you want to remove selected assets from the shared link?')) {
await api.assetApi.updateAssetsInSharedLink(
{
assetIds: assets.filter((a) => !selectedAssets.has(a)).map((a) => a.id)
},
{
params: {
key: sharedLink?.key
}
}
);
assets = assets.filter((a) => !selectedAssets.has(a));
clearMultiSelectAssetAssetHandler();
}
};
</script>
<section class="bg-immich-bg dark:bg-immich-dark-bg">
{#if isMultiSelectionMode}
<ControlAppBar
on:close-button-click={clearMultiSelectAssetAssetHandler}
backIcon={Close}
tailwindClasses={'bg-white shadow-md'}
>
<svelte:fragment slot="leading">
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">
Selected {selectedAssets.size}
</p>
</svelte:fragment>
<svelte:fragment slot="trailing">
<CircleIconButton
title="Download"
on:click={() => downloadAssets(false)}
logo={CloudDownloadOutline}
/>
{#if isOwned}
<CircleIconButton
title="Remove from album"
on:click={handleRemoveAssetsFromSharedLink}
logo={DeleteOutline}
/>
{/if}
</svelte:fragment>
</ControlAppBar>
{:else}
<ControlAppBar
on:close-button-click={() => goto('/photos')}
backIcon={ArrowLeft}
showBackButton={false}
>
<svelte:fragment slot="leading">
<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>
</svelte:fragment>
<svelte:fragment slot="trailing">
{#if sharedLink?.allowUpload}
<CircleIconButton
title="Add Photos"
on:click={handleUploadAssets}
logo={FileImagePlusOutline}
/>
{/if}
<CircleIconButton
title="Download"
on:click={() => downloadAssets(true)}
logo={FolderDownloadOutline}
/>
</svelte:fragment>
</ControlAppBar>
{/if}
<section class="flex flex-col my-[160px] px-6 sm:px-12 md:px-24 lg:px-40">
<GalleryViewer {assets} key={sharedLink.key} bind:selectedAssets />
</section>
</section>

View File

@@ -2,7 +2,13 @@
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 {
AlbumResponseDto,
api,
AssetResponseDto,
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';
@@ -10,9 +16,11 @@
import SettingInputField, {
SettingInputFieldType
} from '$lib/components/admin-page/settings/setting-input-field.svelte';
import { handleError } from '$lib/utils/handle-error';
export let shareType: SharedLinkType;
export let album: AlbumResponseDto | undefined;
export let sharedAssets: AssetResponseDto[] = [];
export let album: AlbumResponseDto | undefined = undefined;
export let editingLink: SharedLinkResponseDto | undefined = undefined;
let isShowSharedLink = false;
@@ -37,32 +45,36 @@
}
});
const createAlbumSharedLink = async () => {
if (album) {
try {
const expirationTime = getExpirationTimeInMillisecond();
const currentTime = new Date().getTime();
const expirationDate = expirationTime
? new Date(currentTime + expirationTime).toISOString()
: undefined;
const handleCreateSharedLink = async () => {
const expirationTime = getExpirationTimeInMillisecond();
const currentTime = new Date().getTime();
const expirationDate = expirationTime
? new Date(currentTime + expirationTime).toISOString()
: undefined;
try {
if (shareType === SharedLinkType.Album && album) {
const { data } = await api.albumApi.createAlbumSharedLink({
albumId: album.id,
expiredAt: expirationDate,
allowUpload: isAllowUpload,
description: description
});
buildSharedLink(data);
isShowSharedLink = true;
} catch (e) {
console.error('[createAlbumSharedLink] Error: ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Failed to create shared link'
} else {
const { data } = await api.assetApi.createAssetsSharedLink({
assetIds: sharedAssets.map((a) => a.id),
expiredAt: expirationDate,
allowUpload: isAllowUpload,
description: description
});
buildSharedLink(data);
}
} catch (e) {
handleError(e, 'Failed to create shared link');
}
isShowSharedLink = true;
};
const buildSharedLink = (createdLink: SharedLinkResponseDto) => {
@@ -76,8 +88,11 @@
message: 'Copied to clipboard!',
type: NotificationType.Info
});
} catch (error) {
console.error('Error', error);
} catch (e) {
handleError(
e,
'Cannot copy to clipboard, make sure you are accessing the page through https'
);
}
};
@@ -127,11 +142,7 @@
dispatch('close');
} catch (e) {
console.error('[handleEditLink]', e);
notificationController.show({
type: NotificationType.Error,
message: 'Failed to edit shared link'
});
handleError(e, 'Failed to edit shared link');
}
}
};
@@ -162,6 +173,18 @@
{/if}
{/if}
{#if shareType == SharedLinkType.Individual}
{#if !editingLink}
<div>Let anyone with the link see the selected photo(s)</div>
{:else}
<div class="text-sm">
Individual shared | <span class="text-immich-primary dark:text-immich-dark-primary"
>{editingLink.description}</span
>
</div>
{/if}
{/if}
<div class="mt-6 mb-2">
<p class="text-xs">LINK OPTIONS</p>
</div>
@@ -215,7 +238,7 @@
{:else}
<div class="flex justify-end">
<button
on:click={createAlbumSharedLink}
on:click={handleCreateSharedLink}
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

View File

@@ -0,0 +1,118 @@
<script lang="ts">
import { page } from '$app/stores';
import { handleError } from '$lib/utils/handle-error';
import { AssetResponseDto, ThumbnailFormat } from '@api';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import ImmichThumbnail from '../../shared-components/immich-thumbnail.svelte';
export let assets: AssetResponseDto[];
export let key: string;
export let selectedAssets: Set<AssetResponseDto> = new Set();
let isShowAssetViewer = false;
let selectedAsset: AssetResponseDto;
let currentViewAssetIndex = 0;
let viewWidth: number;
let thumbnailSize = 300;
$: isMultiSelectionMode = selectedAssets.size > 0;
$: {
if (assets.length < 6) {
thumbnailSize = Math.floor(viewWidth / assets.length - assets.length);
} else {
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);
}
}
const viewAssetHandler = (event: CustomEvent) => {
const { asset }: { asset: AssetResponseDto } = event.detail;
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
selectedAsset = assets[currentViewAssetIndex];
isShowAssetViewer = true;
pushState(selectedAsset.id);
};
const selectAssetHandler = (event: CustomEvent) => {
const { asset }: { asset: AssetResponseDto } = event.detail;
let temp = new Set(selectedAssets);
if (selectedAssets.has(asset)) {
temp.delete(asset);
} else {
temp.add(asset);
}
selectedAssets = temp;
};
const navigateAssetForward = () => {
try {
if (currentViewAssetIndex < assets.length - 1) {
currentViewAssetIndex++;
selectedAsset = assets[currentViewAssetIndex];
pushState(selectedAsset.id);
}
} catch (e) {
handleError(e, 'Cannot navigate to the next asset');
}
};
const navigateAssetBackward = () => {
try {
if (currentViewAssetIndex > 0) {
currentViewAssetIndex--;
selectedAsset = assets[currentViewAssetIndex];
pushState(selectedAsset.id);
}
} catch (e) {
handleError(e, 'Cannot navigate to previous asset');
}
};
const pushState = (assetId: string) => {
// add a URL to the browser's history
// changes the current URL in the address bar but doesn't perform any SvelteKit navigation
history.pushState(null, '', `${$page.url.pathname}/photos/${assetId}`);
};
const closeViewer = () => {
isShowAssetViewer = false;
history.pushState(null, '', `${$page.url.pathname}`);
};
</script>
{#if assets.length > 0}
<div class="flex flex-wrap gap-1 w-full pb-20" bind:clientWidth={viewWidth}>
{#each assets as asset (asset.id)}
<ImmichThumbnail
{asset}
{thumbnailSize}
publicSharedKey={key}
format={assets.length < 7 ? ThumbnailFormat.Jpeg : ThumbnailFormat.Webp}
on:click={(e) => (isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e))}
on:select={selectAssetHandler}
selected={selectedAssets.has(asset)}
/>
{/each}
</div>
{/if}
<!-- Overlay Asset Viewer -->
{#if isShowAssetViewer}
<AssetViewer
asset={selectedAsset}
publicSharedKey={key}
on:navigate-previous={navigateAssetBackward}
on:navigate-next={navigateAssetForward}
on:close={closeViewer}
/>
{/if}

View File

@@ -90,7 +90,7 @@
</button>
</div>
<p class="whitespace-pre text-sm pl-[28px] pr-[16px]" data-testid="message">
<p class="whitespace-pre-wrap text-sm pl-[28px] pr-[16px]" data-testid="message">
{@html notificationInfo.message}
</p>
</div>

View File

@@ -12,7 +12,7 @@ import { addAssetsToAlbum } from '$lib/utils/asset-utils';
export const openFileUploadDialog = (
albumId: string | undefined = undefined,
sharedKey: string | undefined = undefined,
callback?: () => void
onDone?: (id: string) => void
) => {
try {
const fileSelector = document.createElement('input');
@@ -28,8 +28,7 @@ export const openFileUploadDialog = (
}
const files = Array.from<File>(target.files);
await fileUploadHandler(files, albumId, sharedKey);
callback && callback();
await fileUploadHandler(files, albumId, sharedKey, onDone);
};
fileSelector.click();
@@ -41,7 +40,8 @@ export const openFileUploadDialog = (
export const fileUploadHandler = async (
files: File[],
albumId: string | undefined = undefined,
sharedKey: string | undefined = undefined
sharedKey: string | undefined = undefined,
onDone?: (id: string) => void
) => {
if (files.length > 50) {
notificationController.show({
@@ -54,13 +54,13 @@ export const fileUploadHandler = async (
return;
}
console.log('fileUploadHandler');
const acceptedFile = files.filter(
(e) => e.type.split('/')[0] === 'video' || e.type.split('/')[0] === 'image'
);
for (const asset of acceptedFile) {
await fileUploader(asset, albumId, sharedKey);
await fileUploader(asset, albumId, sharedKey, onDone);
}
};
@@ -68,7 +68,8 @@ export const fileUploadHandler = async (
async function fileUploader(
asset: File,
albumId: string | undefined = undefined,
sharedKey: string | undefined = undefined
sharedKey: string | undefined = undefined,
onDone?: (id: string) => void
) {
const assetType = asset.type.split('/')[0].toUpperCase();
const temp = asset.name.split('.');
@@ -135,6 +136,7 @@ async function fileUploader(
if (albumId && dataId) {
addAssetsToAlbum(albumId, [dataId]);
}
onDone && dataId && onDone(dataId);
return;
}
}
@@ -154,10 +156,9 @@ async function fileUploader(
request.upload.onload = () => {
setTimeout(() => {
uploadAssetsStore.removeUploadAsset(deviceAssetId);
const res: AssetFileUploadResponseDto = JSON.parse(request.response || '{}');
if (albumId) {
try {
const res: AssetFileUploadResponseDto = JSON.parse(request.response || '{}');
if (res.id) {
addAssetsToAlbum(albumId, [res.id], sharedKey);
}
@@ -165,6 +166,7 @@ async function fileUploader(
console.error('ERROR parsing data JSON in upload onload');
}
}
onDone && onDone(res.id);
}, 1000);
};

View File

@@ -6,9 +6,8 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import AlbumSelectionModal from '$lib/components/shared-components/album-selection-modal.svelte';
import { goto } from '$app/navigation';
import type { PageData } from './$types';
import ShareVariantOutline from 'svelte-material-icons/ShareVariantOutline.svelte';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import {
assetInteractionStore,
@@ -21,16 +20,17 @@
import CircleIconButton from '$lib/components/shared-components/circle-icon-button.svelte';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import Plus from 'svelte-material-icons/Plus.svelte';
import { AlbumResponseDto, api } from '@api';
import { AlbumResponseDto, api, SharedLinkType } from '@api';
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { assetStore } from '$lib/stores/assets.store';
import { addAssetsToAlbum, bulkDownload } from '$lib/utils/asset-utils';
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
export let data: PageData;
let isShowCreateSharedLinkModal = false;
const deleteSelectedAssetHandler = async () => {
try {
if (
@@ -114,6 +114,15 @@
assetInteractionStore.clearMultiselect();
});
};
const handleCreateSharedLink = async () => {
isShowCreateSharedLinkModal = true;
};
const handleCloseSharedLinkModal = () => {
assetInteractionStore.clearMultiselect();
isShowCreateSharedLinkModal = false;
};
</script>
<section>
@@ -129,6 +138,11 @@
</p>
</svelte:fragment>
<svelte:fragment slot="trailing">
<CircleIconButton
title="Share"
logo={ShareVariantOutline}
on:click={handleCreateSharedLink}
/>
<CircleIconButton
title="Download"
logo={CloudDownloadOutline}
@@ -164,6 +178,14 @@
on:close={() => (isShowAlbumPicker = false)}
/>
{/if}
{#if isShowCreateSharedLinkModal}
<CreateSharedLinkModal
sharedAssets={Array.from($selectedAssets)}
shareType={SharedLinkType.Individual}
on:close={handleCloseSharedLinkModal}
/>
{/if}
</section>
<section

View File

@@ -5,7 +5,9 @@ import { getThumbnailUrl } from '$lib/utils/asset-utils';
import { serverApi, ThumbnailFormat } from '@api';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
export const load: PageServerLoad = async ({ params, parent }) => {
const { user } = await parent();
const { key } = params;
try {
@@ -22,7 +24,8 @@ export const load: PageServerLoad = async ({ params }) => {
imageUrl: assetId
? getThumbnailUrl(assetId, ThumbnailFormat.Webp, sharedLink.key)
: 'feature-panel.png'
}
},
user
};
} catch (e) {
throw error(404, {

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import AlbumViewer from '$lib/components/album-page/album-viewer.svelte';
import { AlbumResponseDto } from '@api';
import IndividualSharedViewer from '$lib/components/share-page/individual-shared-viewer.svelte';
import { AlbumResponseDto, SharedLinkType } from '@api';
import type { PageData } from './$types';
export let data: PageData;
@@ -8,13 +9,20 @@
const { sharedLink } = data;
let album: AlbumResponseDto | null = null;
let isOwned = data.user ? data.user.id === sharedLink.userId : false;
if (sharedLink.album) {
album = { ...sharedLink.album, assets: sharedLink.assets };
}
</script>
{#if album}
{#if sharedLink.type == SharedLinkType.Album && album}
<div class="immich-scrollbar">
<AlbumViewer {album} {sharedLink} />
</div>
{/if}
{#if sharedLink.type == SharedLinkType.Individual}
<div class="immich-scrollbar">
<IndividualSharedViewer {sharedLink} {isOwned} />
</div>
{/if}