Bladeren bron

feat(web) Add drag n drop upload functionality (#1216)

* Add image drag n drop functionality

* Change upload cover name, background color and opacity
Krisjanis Lejejs 2 jaren geleden
bovenliggende
commit
10b0924cfb

+ 0 - 2
web/src/app.css

@@ -46,9 +46,7 @@ html::-webkit-scrollbar-thumb:hover {
 }
 
 body {
-	/* min-height: 100vh; */
 	margin: 0;
-	/* background-color: #f6f8fe; */
 	color: #5f6368;
 }
 

+ 2 - 2
web/src/app.html

@@ -7,7 +7,7 @@
 		%sveltekit.head%
 	</head>
 
-	<body class="bg-immich-bg dark:bg-immich-dark-bg">
-		<div>%sveltekit.body%</div>
+	<body class="bg-immich-bg dark:bg-immich-dark-bg fixed inset-0 w-full h-full">
+		<div class="fixed inset-0 w-full h-full">%sveltekit.body%</div>
 	</body>
 </html>

+ 1 - 20
web/src/lib/components/album-page/album-viewer.svelte

@@ -236,25 +236,6 @@
 		}
 	};
 
-	const assetUploadedToAlbumHandler = async (event: CustomEvent) => {
-		const { assetIds }: { assetIds: string[] } = event.detail;
-		try {
-			const { data } = await api.albumApi.addAssetsToAlbum(album.id, {
-				assetIds: assetIds
-			});
-
-			if (data.album) {
-				album = data.album;
-			}
-		} catch (e) {
-			console.error('Error [assetUploadedToAlbumHandler] ', e);
-			notificationController.show({
-				type: NotificationType.Error,
-				message: 'Error adding asset to album, check console for more details'
-			});
-		}
-	};
-
 	const addUserHandler = async (event: CustomEvent) => {
 		const { selectedUsers }: { selectedUsers: UserResponseDto[] } = event.detail;
 
@@ -591,10 +572,10 @@
 
 {#if isShowAssetSelection}
 	<AssetSelection
+		albumId={album.id}
 		assetsInAlbum={album.assets}
 		on:go-back={() => (isShowAssetSelection = false)}
 		on:create-album={createAlbumHandler}
-		on:asset-uploaded={assetUploadedToAlbumHandler}
 	/>
 {/if}
 

+ 7 - 41
web/src/lib/components/album-page/asset-selection.svelte

@@ -3,8 +3,7 @@
 	import { quintOut } from 'svelte/easing';
 	import { fly } from 'svelte/transition';
 	import { AssetResponseDto } from '@api';
-	import { openFileUploadDialog, UploadType } from '$lib/utils/file-uploader';
-	import { albumUploadAssetStore } from '$lib/stores/album-upload-asset';
+	import { openFileUploadDialog } from '$lib/utils/file-uploader';
 	import ControlAppBar from '../shared-components/control-app-bar.svelte';
 	import AssetGrid from '../photos-page/asset-grid.svelte';
 	import {
@@ -15,50 +14,13 @@
 
 	const dispatch = createEventDispatcher();
 
+	export let albumId: string;
 	export let assetsInAlbum: AssetResponseDto[];
 
-	let uploadAssets: string[] = [];
-	let uploadAssetsCount = 9999;
-
 	onMount(() => {
 		$assetsInAlbumStoreState = assetsInAlbum;
-
-		albumUploadAssetStore.asset.subscribe((uploadedAsset) => {
-			uploadAssets = uploadedAsset;
-		});
-
-		albumUploadAssetStore.count.subscribe((count) => {
-			uploadAssetsCount = count;
-		});
 	});
 
-	/**
-	 * Watch for the uploading event - when the uploaded assets are the same number of the chosen asset
-	 * navigate back and add them to the album
-	 */
-	$: {
-		if (uploadAssets.length == uploadAssetsCount) {
-			// Filter assets that are already in the album
-			const assetIds = uploadAssets.filter(
-				(asset) => !!asset && !assetsInAlbum.some((a) => a.id === asset)
-			);
-
-			// Add the just uploaded assets to the album
-			if (assetIds.length) {
-				dispatch('asset-uploaded', {
-					assetIds
-				});
-			}
-
-			// Clean up states.
-			albumUploadAssetStore.asset.set([]);
-			albumUploadAssetStore.count.set(9999);
-
-			assetInteractionStore.clearMultiselect();
-			dispatch('go-back');
-		}
-	}
-
 	const addSelectedAssets = async () => {
 		dispatch('create-album', {
 			assets: Array.from($selectedAssets)
@@ -88,7 +50,11 @@
 
 		<svelte:fragment slot="trailing">
 			<button
-				on:click={() => openFileUploadDialog(UploadType.ALBUM)}
+				on:click={() =>
+					openFileUploadDialog(albumId, () => {
+						assetInteractionStore.clearMultiselect();
+						dispatch('go-back');
+					})}
 				class="text-immich-primary dark:text-immich-dark-primary text-sm hover:bg-immich-primary/10 dark:hover:bg-immich-dark-primary/25 transition-all px-6 py-2 rounded-lg font-medium"
 			>
 				Select from computer

+ 6 - 15
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -12,7 +12,6 @@
 	import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte';
 	import {
 		api,
-		AddAssetsResponseDto,
 		AssetResponseDto,
 		AssetTypeEnum,
 		AlbumResponseDto
@@ -23,6 +22,7 @@
 	} from '../shared-components/notification/notification';
 
 	import { assetStore } from '$lib/stores/assets.store';
+	import { addAssetsToAlbum } from '$lib/utils/asset-utils';
 
 	export let asset: AssetResponseDto;
 	$: {
@@ -209,17 +209,6 @@
 		addToSharedAlbum = shared;
 	};
 
-	const showAddNotification = (dto: AddAssetsResponseDto) => {
-		notificationController.show({
-			message: `Added ${dto.successfullyAdded} to ${dto.album?.albumName}`,
-			type: NotificationType.Info
-		});
-
-		if (dto.successfullyAdded === 1 && dto.album) {
-			appearsInAlbums = [...appearsInAlbums, dto.album];
-		}
-	};
-
 	const handleAddToNewAlbum = () => {
 		isShowAlbumPicker = false;
 		api.albumApi.createAlbum({ albumName: 'Untitled', assetIds: [asset.id] }).then((response) => {
@@ -232,9 +221,11 @@
 		isShowAlbumPicker = false;
 		const album = event.detail.album;
 
-		api.albumApi
-			.addAssetsToAlbum(album.id, { assetIds: [asset.id] })
-			.then((response) => showAddNotification(response.data));
+		addAssetsToAlbum(album.id, [asset.id]).then((dto) => {
+			if (dto.successfullyAdded === 1 && dto.album) {
+				appearsInAlbums = [...appearsInAlbums, dto.album];
+			}
+		});
 	};
 </script>
 

+ 25 - 0
web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte

@@ -0,0 +1,25 @@
+<script lang="ts">
+	import { fade } from 'svelte/transition';
+
+	export let dropHandler: (event: DragEvent) => void;
+	export let dragOverHandler: (event: DragEvent) => void;
+	export let dragLeaveHandler: () => void;
+</script>
+
+<div
+	in:fade={{ duration: 250 }}
+	out:fade={{ duration: 250 }}
+	on:drop={dropHandler}
+	on:dragover={dragOverHandler}
+	on:dragleave={dragLeaveHandler}
+	class="fixed inset-0 w-full h-full z-[1000] flex flex-col items-center justify-center bg-gray-100/90 dark:bg-immich-dark-bg/90 text-immich-dark-gray dark:text-immich-gray"
+>
+	<img
+		src="/immich-logo.svg"
+		alt="immich logo"
+		height="200"
+		width="200"
+		class="animate-bounce pb-16"
+	/>
+	<div class="text-2xl">Drop files anywhere to upload</div>
+</div>

+ 0 - 13
web/src/lib/stores/album-upload-asset.ts

@@ -1,13 +0,0 @@
-import { writable } from 'svelte/store';
-
-function createAlbumUploadStore() {
-	const albumUploadAsset = writable<Array<string>>([]);
-	const albumUploadAssetCount = writable<number>(9999);
-
-	return {
-		asset: albumUploadAsset,
-		count: albumUploadAssetCount
-	};
-}
-
-export const albumUploadAssetStore = createAlbumUploadStore();

+ 21 - 0
web/src/lib/utils/asset-utils.ts

@@ -0,0 +1,21 @@
+import { api, AddAssetsResponseDto } from '@api';
+import {
+	notificationController,
+	NotificationType
+} from '$lib/components/shared-components/notification/notification';
+
+export const addAssetsToAlbum = async (
+	albumId: string,
+	assetIds: Array<string>
+): Promise<AddAssetsResponseDto> =>
+	api.albumApi.addAssetsToAlbum(albumId, { assetIds }).then(({ data: dto }) => {
+		if (dto.successfullyAdded > 0) {
+			// This might be 0 if the user tries to add an asset that is already in the album
+			notificationController.show({
+				message: `Added ${dto.successfullyAdded} to ${dto.album?.albumName}`,
+				type: NotificationType.Info
+			});
+		}
+
+		return dto;
+	});

+ 58 - 74
web/src/lib/utils/file-uploader.ts

@@ -7,25 +7,12 @@ import * as exifr from 'exifr';
 import { uploadAssetsStore } from '$lib/stores/upload';
 import type { UploadAsset } from '../models/upload-asset';
 import { api, AssetFileUploadResponseDto } from '@api';
-import { albumUploadAssetStore } from '$lib/stores/album-upload-asset';
-/**
- * Determine if the upload is for album or for the user general backup
- * @variant GENERAL - Upload assets to the server for general backup
- * @variant ALBUM - Upload assets to the server for backup and add to the album
- */
-export enum UploadType {
-	/**
-	 * Upload assets to the server
-	 */
-	GENERAL = 'GENERAL',
-
-	/**
-	 * Upload assets to the server and add to album
-	 */
-	ALBUM = 'ALBUM'
-}
+import { addAssetsToAlbum } from '$lib/utils/asset-utils';
 
-export const openFileUploadDialog = (uploadType: UploadType) => {
+export const openFileUploadDialog = (
+	albumId: string | undefined = undefined,
+	callback?: () => void
+) => {
 	try {
 		const fileSelector = document.createElement('input');
 
@@ -40,30 +27,8 @@ export const openFileUploadDialog = (uploadType: UploadType) => {
 			}
 			const files = Array.from<File>(target.files);
 
-			if (files.length > 50) {
-				notificationController.show({
-					type: NotificationType.Error,
-					message: `Cannot upload more than 50 files at a time - you are uploading ${files.length} files. 
-          Please check out <u>the bulk upload documentation</u> if you need to upload more than 50 files.`,
-					timeout: 10000,
-					action: { type: 'link', target: 'https://immich.app/docs/features/bulk-upload' }
-				});
-
-				return;
-			}
-
-			const acceptedFile = files.filter(
-				(e) => e.type.split('/')[0] === 'video' || e.type.split('/')[0] === 'image'
-			);
-
-			if (uploadType === UploadType.ALBUM) {
-				albumUploadAssetStore.asset.set([]);
-				albumUploadAssetStore.count.set(acceptedFile.length);
-			}
-
-			for (const asset of acceptedFile) {
-				await fileUploader(asset, uploadType);
-			}
+			await fileUploadHandler(files, albumId);
+			callback && callback();
 		};
 
 		fileSelector.click();
@@ -72,8 +37,30 @@ export const openFileUploadDialog = (uploadType: UploadType) => {
 	}
 };
 
+export const fileUploadHandler = async (files: File[], albumId: string | undefined = undefined) => {
+	if (files.length > 50) {
+		notificationController.show({
+			type: NotificationType.Error,
+			message: `Cannot upload more than 50 files at a time - you are uploading ${files.length} files. 
+			Please check out <u>the bulk upload documentation</u> if you need to upload more than 50 files.`,
+			timeout: 10000,
+			action: { type: 'link', target: 'https://immich.app/docs/features/bulk-upload' }
+		});
+
+		return;
+	}
+
+	const acceptedFile = files.filter(
+		(e) => e.type.split('/')[0] === 'video' || e.type.split('/')[0] === 'image'
+	);
+
+	for (const asset of acceptedFile) {
+		await fileUploader(asset, albumId);
+	}
+};
+
 //TODO: should probably use the @api SDK
-async function fileUploader(asset: File, uploadType: UploadType) {
+async function fileUploader(asset: File, albumId: string | undefined = undefined) {
 	const assetType = asset.type.split('/')[0].toUpperCase();
 	const temp = asset.name.split('.');
 	const fileExtension = temp[temp.length - 1];
@@ -121,7 +108,6 @@ async function fileUploader(asset: File, uploadType: UploadType) {
 		formData.append('assetData', asset);
 
 		// Check if asset upload on server before performing upload
-
 		const { data, status } = await api.assetApi.checkDuplicateAsset({
 			deviceAssetId: String(deviceAssetId),
 			deviceId: 'WEB'
@@ -130,10 +116,8 @@ async function fileUploader(asset: File, uploadType: UploadType) {
 		if (status === 200) {
 			if (data.isExist) {
 				const dataId = data.id;
-				if (uploadType === UploadType.ALBUM && dataId) {
-					albumUploadAssetStore.asset.update((a) => {
-						return [...a, dataId];
-					});
+				if (albumId && dataId) {
+					addAssetsToAlbum(albumId, [dataId]);
 				}
 				return;
 			}
@@ -155,35 +139,30 @@ async function fileUploader(asset: File, uploadType: UploadType) {
 		request.upload.onload = () => {
 			setTimeout(() => {
 				uploadAssetsStore.removeUploadAsset(deviceAssetId);
-			}, 1000);
-		};
 
-		request.onreadystatechange = () => {
-			try {
-				if (request.readyState === 4 && uploadType === UploadType.ALBUM) {
-					const res: AssetFileUploadResponseDto = JSON.parse(request.response || '{}');
-
-					albumUploadAssetStore.asset.update((assets) => {
-						return [...assets, res?.id || ''];
-					});
-
-					if (request.status !== 201) {
-						handleUploadError(asset, res);
+				if (albumId) {
+					try {
+						const res: AssetFileUploadResponseDto = JSON.parse(request.response || '{}');
+						if (res.id) {
+							addAssetsToAlbum(albumId, [res.id]);
+						}
+					} catch (e) {
+						console.error('ERROR parsing data JSON in upload onload');
 					}
 				}
-			} catch (e) {
-				console.error('ERROR parsing data JSON in upload onreadystatechange');
-			}
+			}, 1000);
 		};
 
 		// listen for `error` event
 		request.upload.onerror = () => {
 			uploadAssetsStore.removeUploadAsset(deviceAssetId);
+			handleUploadError(asset, request.response);
 		};
 
 		// listen for `abort` event
 		request.upload.onabort = () => {
 			uploadAssetsStore.removeUploadAsset(deviceAssetId);
+			handleUploadError(asset, request.response);
 		};
 
 		// listen for `progress` event
@@ -199,14 +178,19 @@ async function fileUploader(asset: File, uploadType: UploadType) {
 		console.log('error uploading file ', e);
 	}
 }
-// TODO: This should have a proper type
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-function handleUploadError(asset: File, respBody: any, extraMessage?: string) {
-	const extraMsg = respBody ? ' ' + respBody?.message : '';
-
-	notificationController.show({
-		type: NotificationType.Error,
-		message: `Cannot upload file ${asset.name} ${extraMsg}${extraMessage}`,
-		timeout: 5000
-	});
+
+function handleUploadError(asset: File, respBody = '{}', extraMessage?: string) {
+	try {
+		const res = JSON.parse(respBody);
+
+		const extraMsg = res ? ' ' + res?.message : '';
+
+		notificationController.show({
+			type: NotificationType.Error,
+			message: `Cannot upload file ${asset.name} ${extraMsg}${extraMessage}`,
+			timeout: 5000
+		});
+	} catch (e) {
+		console.error('ERROR parsing data JSON in handleUploadError');
+	}
 }

+ 36 - 1
web/src/routes/+layout.svelte

@@ -2,20 +2,24 @@
 	import '../app.css';
 
 	import { fade } from 'svelte/transition';
+	import { page } from '$app/stores';
 	import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
 	import AnnouncementBox from '$lib/components/shared-components/announcement-box.svelte';
+	import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
 	import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
 	import { onMount } from 'svelte';
 	import { checkAppVersion } from '$lib/utils/check-app-version';
 	import { afterNavigate, beforeNavigate } from '$app/navigation';
 	import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
 	import NotificationList from '$lib/components/shared-components/notification/notification-list.svelte';
+	import { fileUploadHandler } from '$lib/utils/file-uploader';
 
 	let shouldShowAnnouncement: boolean;
 	let localVersion: string;
 	let remoteVersion: string;
 	let showNavigationLoadingBar = false;
 	let canShow = false;
+	let showUploadCover = false;
 
 	onMount(async () => {
 		checkUserTheme();
@@ -48,9 +52,32 @@
 	afterNavigate(() => {
 		showNavigationLoadingBar = false;
 	});
+
+	const dropHandler = async (event: DragEvent) => {
+		event.preventDefault();
+		event.stopPropagation();
+
+		showUploadCover = false;
+
+		const files = event.dataTransfer?.files;
+		if (!files) {
+			return;
+		}
+
+		const filesArray: File[] = Array.from<File>(files);
+		const albumId = ($page.route.id === '/albums/[albumId]' || undefined) && $page.params.albumId;
+
+		await fileUploadHandler(filesArray, albumId);
+	};
+
+	// Required to prevent default browser behavior
+	const dragOverHandler = (event: DragEvent) => {
+		event.preventDefault();
+		event.stopPropagation();
+	};
 </script>
 
-<main>
+<main on:dragenter={() => (showUploadCover = true)} class="fixed inset-0 w-full h-full">
 	{#if canShow}
 		<div in:fade={{ duration: 100 }}>
 			{#if showNavigationLoadingBar}
@@ -59,6 +86,14 @@
 
 			<slot />
 
+			{#if showUploadCover}
+				<UploadCover
+					{dropHandler}
+					{dragOverHandler}
+					dragLeaveHandler={() => (showUploadCover = false)}
+				/>
+			{/if}
+
 			<DownloadPanel />
 			<UploadPanel />
 			<NotificationList />

+ 4 - 10
web/src/routes/photos/+page.svelte

@@ -9,7 +9,7 @@
 
 	import type { PageData } from './$types';
 
-	import { openFileUploadDialog, UploadType } from '$lib/utils/file-uploader';
+	import { openFileUploadDialog } from '$lib/utils/file-uploader';
 	import {
 		assetInteractionStore,
 		isMultiSelectStoreState,
@@ -26,6 +26,7 @@
 		NotificationType
 	} from '$lib/components/shared-components/notification/notification';
 	import { assetStore } from '$lib/stores/assets.store';
+	import { addAssetsToAlbum } from '$lib/utils/asset-utils';
 
 	export let data: PageData;
 
@@ -100,12 +101,8 @@
 		const album = event.detail.album;
 
 		const assetIds = Array.from($selectedAssets).map((asset) => asset.id);
-		api.albumApi.addAssetsToAlbum(album.id, { assetIds }).then(({ data: dto }) => {
-			notificationController.show({
-				message: `Added ${dto.successfullyAdded} to ${dto.album?.albumName}`,
-				type: NotificationType.Info
-			});
 
+		addAssetsToAlbum(album.id, assetIds).then(() => {
 			assetInteractionStore.clearMultiselect();
 		});
 	};
@@ -137,10 +134,7 @@
 			</svelte:fragment>
 		</ControlAppBar>
 	{:else}
-		<NavigationBar
-			user={data.user}
-			on:uploadClicked={() => openFileUploadDialog(UploadType.GENERAL)}
-		/>
+		<NavigationBar user={data.user} on:uploadClicked={() => openFileUploadDialog()} />
 	{/if}
 
 	{#if isShowAddMenu}