diff --git a/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts b/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts index 477ea297e5..a4bded2953 100644 --- a/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts +++ b/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts @@ -18,8 +18,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { ) { super({ jwtFromRequest: ExtractJwt.fromExtractors([ - immichJwtService.extractJwtFromHeader, immichJwtService.extractJwtFromCookie, + immichJwtService.extractJwtFromHeader, ]), ignoreExpiration: false, secretOrKey: jwtSecret, diff --git a/web/src/app.css b/web/src/app.css index 8b74329938..12b80cf28f 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -6,26 +6,53 @@ @tailwind utilities; :root { - font-family: 'Work Sans', sans-serif; + font-family: 'Work Sans', sans-serif; } body { - min-height: 100vh; + /* min-height: 100vh; */ margin: 0; background-color: #f6f8fe; - color: #5f6368; + color: #5f6368; } @layer utilities { - .immich-form-input { - @apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm - } + .immich-form-input { + @apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm; + } - .immich-form-label { - @apply font-medium text-sm text-gray-500 - } + .immich-form-label { + @apply font-medium text-sm text-gray-500; + } - .immich-btn-primary { - @apply bg-immich-primary text-gray-100 border rounded-xl py-2 px-4 transition-all duration-150 hover:bg-immich-primary hover:shadow-lg text-sm font-medium - } -} \ No newline at end of file + .immich-btn-primary { + @apply bg-immich-primary text-gray-100 border rounded-xl py-2 px-4 transition-all duration-150 hover:bg-immich-primary hover:shadow-lg text-sm font-medium; + } + + .immich-text-button { + @apply flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium; + } + + /* width */ + .immich-scrollbar::-webkit-scrollbar { + width: 8px; + } + + /* Track */ + .immich-scrollbar::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 16px; + } + + /* Handle */ + .immich-scrollbar::-webkit-scrollbar-thumb { + background: rgba(85, 86, 87, 0.408); + border-radius: 16px; + } + + /* Handle on hover */ + .immich-scrollbar::-webkit-scrollbar-thumb:hover { + background: #4250afad; + border-radius: 16px; + } +} diff --git a/web/src/lib/components/album-page/album-app-bar.svelte b/web/src/lib/components/album-page/album-app-bar.svelte new file mode 100644 index 0000000000..ba700eb269 --- /dev/null +++ b/web/src/lib/components/album-page/album-app-bar.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + import { createEventDispatcher, onMount } from 'svelte'; + import Close from 'svelte-material-icons/Close.svelte'; + + export let backIcon = Close; + let appBarBorder = ''; + const dispatch = createEventDispatcher(); + onMount(() => { + window.onscroll = () => { + if (window.pageYOffset > 80) { + appBarBorder = 'border border-gray-200 bg-gray-50'; + } else { + appBarBorder = ''; + } + }; + }); +</script> + +<div class="fixed top-0 w-full bg-transparent z-[100]"> + <div + id="asset-selection-app-bar" + class={`flex justify-between ${appBarBorder} rounded-lg p-2 mx-2 mt-2 transition-all place-items-center`} + > + <div class="flex place-items-center gap-6"> + <button + on:click={() => dispatch('close-button-click')} + id="immich-circle-icon-button" + class={`rounded-full p-3 flex place-items-center place-content-center text-gray-600 transition-all hover:bg-gray-200`} + > + <svelte:component this={backIcon} size="24" /> + </button> + <slot name="leading" /> + </div> + + <div class="flex place-items-center gap-6 mr-4"> + <slot name="trailing" /> + </div> + </div> +</div> diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index c78dd6490b..b87c8dc3cf 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -1,18 +1,30 @@ <script lang="ts"> - import { afterNavigate } from '$app/navigation'; + import { afterNavigate, goto } from '$app/navigation'; import { page } from '$app/stores'; - import { AlbumResponseDto, AssetResponseDto, ThumbnailFormat } from '@api'; + import { AlbumResponseDto, api, AssetResponseDto, ThumbnailFormat, UserResponseDto } from '@api'; import { createEventDispatcher, onMount } from 'svelte'; import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte'; + import Plus from 'svelte-material-icons/Plus.svelte'; import FileImagePlusOutline from 'svelte-material-icons/FileImagePlusOutline.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 _ from 'lodash-es'; + import { assets } from '$app/paths'; + import UserSelection from './user-selection-modal.svelte'; + import AlbumAppBar from './album-app-bar.svelte'; + import UserSelectionModal from './user-selection-modal.svelte'; const dispatch = createEventDispatcher(); export let album: AlbumResponseDto; let isShowAssetViewer = false; + let isShowAssetSelection = false; + let isShowShareUserSelection = false; + let isEditingTitle = false; + let isCreatingSharedAlbum = false; + let selectedAsset: AssetResponseDto; let currentViewAssetIndex = 0; @@ -20,10 +32,20 @@ let thumbnailSize: number = 300; let border = ''; let backUrl = '/albums'; + let currentAlbumName = ''; + let currentUser: UserResponseDto; + let bodyElement: HTMLElement; - afterNavigate(({ from, to }) => { + $: isOwned = currentUser?.id == album.ownerId; + + afterNavigate(({ from }) => { backUrl = from?.pathname ?? '/albums'; + + if (from?.pathname === '/sharing') { + isCreatingSharedAlbum = true; + } }); + $: { if (album.assets.length < 6) { thumbnailSize = Math.floor(viewWidth / album.assets.length - album.assets.length); @@ -36,20 +58,18 @@ const startDate = new Date(album.assets[0].createdAt); const endDate = new Date(album.assets[album.assets.length - 1].createdAt); - const startDateString = startDate.toLocaleDateString('us-EN', { + const timeFormatOption: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' - }); - const endDateString = endDate.toLocaleDateString('us-EN', { - month: 'short', - day: 'numeric', - year: 'numeric' - }); + }; + + const startDateString = startDate.toLocaleDateString('us-EN', timeFormatOption); + const endDateString = endDate.toLocaleDateString('us-EN', timeFormatOption); return `${startDateString} - ${endDateString}`; }; - onMount(() => { + onMount(async () => { window.onscroll = (event: Event) => { if (window.pageYOffset > 80) { border = 'border border-gray-200 bg-gray-50'; @@ -57,6 +77,15 @@ border = ''; } }; + + currentAlbumName = album.albumName; + + try { + const { data } = await api.userApi.getMyUserInfo(); + currentUser = data; + } catch (e) { + console.log('Error [getMyUserInfo - album-viewer] ', e); + } }); const viewAsset = (event: CustomEvent) => { @@ -102,62 +131,152 @@ isShowAssetViewer = false; history.pushState(null, '', `${$page.url.pathname}`); }; + + // Update Album Name + $: { + if (!isEditingTitle && currentAlbumName != album.albumName && isOwned) { + api.albumApi + .updateAlbumInfo(album.id, { + ownerId: album.ownerId, + albumName: album.albumName + }) + .then(() => { + currentAlbumName = album.albumName; + }) + .catch((e) => { + console.log('Error [updateAlbumInfo] ', e); + }); + } + } + + const createAlbumHandler = async (event: CustomEvent) => { + const { assets }: { assets: string[] } = event.detail; + + try { + const { data } = await api.albumApi.addAssetsToAlbum(album.id, { assetIds: assets }); + album = data; + + isShowAssetSelection = false; + } catch (e) { + console.log('Error [createAlbumHandler] ', e); + } + }; + + const addUserHandler = async (event: CustomEvent) => { + const { selectedUsers }: { selectedUsers: UserResponseDto[] } = event.detail; + + try { + const { data } = await api.albumApi.addUsersToAlbum(album.id, { + sharedUserIds: Array.from(selectedUsers).map((u) => u.id) + }); + + album = data; + + isShowShareUserSelection = false; + } catch (e) { + console.log('Error [createAlbumHandler] ', e); + } + }; + + // Prevent scrolling when modal is open + $: { + if (isShowShareUserSelection == true) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + } </script> -<section class="w-screen h-screen bg-immich-bg"> - <div class="fixed top-0 w-full bg-immich-bg z-[100]"> - <div class={`flex justify-between rounded-lg ${border} p-2 mx-2 mt-2 transition-all`}> - <a sveltekit:prefetch href={backUrl} title="Go Back"> +<svelte:body bind:this={bodyElement} /> +<section class="bg-immich-bg relative"> + <AlbumAppBar on:close-button-click={() => goto(backUrl)} backIcon={ArrowLeft}> + <svelte:fragment slot="trailing"> + {#if album.assets.length > 0} <button id="immich-circle-icon-button" class={`rounded-full p-3 flex place-items-center place-content-center text-gray-600 transition-all hover:bg-gray-200`} - > - <ArrowLeft size="24" /> - </button> - </a> - <div class="right-button-group" title="Add Photos"> - <button - id="immich-circle-icon-button" - class={`rounded-full p-3 flex place-items-center place-content-center text-gray-600 transition-all hover:bg-gray-200`} - on:click={() => dispatch('click')} + on:click={() => (isShowAssetSelection = true)} > <FileImagePlusOutline size="24" /> </button> - </div> - </div> - </div> + {/if} - <section class="m-6 py-[72px] px-[160px]"> - <p class="text-6xl text-immich-primary"> - {album.albumName} - </p> + {#if isCreatingSharedAlbum && album.sharedUsers.length == 0} + <button + disabled={album.assets.length == 0} + on:click={() => (isShowShareUserSelection = true)} + class="immich-text-button border bg-immich-primary text-gray-50 hover:bg-immich-primary/75 px-6 text-sm disabled:opacity-25 disabled:bg-gray-500 disabled:cursor-not-allowed" + ><span class="px-2">Share</span></button + > + {/if} + </svelte:fragment> + </AlbumAppBar> - <p class="my-4 text-sm text-gray-500">{getDateRange()}</p> + <section class="m-auto my-[160px] w-[60%]"> + <input + on:focus={() => (isEditingTitle = true)} + on:blur={() => (isEditingTitle = false)} + class={`transition-all text-6xl text-immich-primary w-[99%] border-b-2 border-transparent outline-none ${ + isOwned ? 'hover:border-gray-400' : 'hover:border-transparent' + } focus:outline-none focus:border-b-2 focus:border-immich-primary bg-immich-bg`} + type="text" + bind:value={album.albumName} + disabled={!isOwned} + /> - {#if album.sharedUsers.length > 0} - <div class="mb-4"> + {#if album.assets.length > 0} + <p class="my-4 text-sm text-gray-500">{getDateRange()}</p> + {/if} + + {#if album.shared} + <div class="my-4 flex"> {#each album.sharedUsers as user} <span class="mr-1"> <CircleAvatar {user} /> </span> {/each} + + <button + style:display={isOwned ? 'block' : 'none'} + on:click={() => (isShowShareUserSelection = true)} + title="Add more users" + class="h-12 w-12 border bg-white transition-colors hover:bg-gray-300 text-3xl flex place-items-center place-content-center rounded-full" + >+</button + > </div> {/if} - <div class="flex flex-wrap gap-1 w-full" bind:clientWidth={viewWidth}> - {#each album.assets as asset} - {#if album.assets.length < 7} - <ImmichThumbnail - {asset} - {thumbnailSize} - format={ThumbnailFormat.Jpeg} - on:viewAsset={viewAsset} - /> - {:else} - <ImmichThumbnail {asset} {thumbnailSize} on:viewAsset={viewAsset} /> - {/if} - {/each} - </div> + {#if album.assets.length > 0} + <div class="flex flex-wrap gap-1 w-full" bind:clientWidth={viewWidth}> + {#each album.assets as asset} + {#if album.assets.length < 7} + <ImmichThumbnail + {asset} + {thumbnailSize} + format={ThumbnailFormat.Jpeg} + on:click={viewAsset} + /> + {:else} + <ImmichThumbnail {asset} {thumbnailSize} on:click={viewAsset} /> + {/if} + {/each} + </div> + {:else} + <!-- Album is empty - Show asset selectection buttons --> + <section id="empty-album" class=" mt-[200px] flex place-content-center place-items-center"> + <div class="w-[300px]"> + <p class="text-xs">ADD PHOTOS</p> + <button + on:click={() => (isShowAssetSelection = true)} + class="w-full py-8 border bg-white rounded-md mt-5 flex place-items-center gap-6 px-8 transition-all hover:bg-gray-100 hover:text-immich-primary" + > + <span><Plus color="#4250af" size="24" /> </span> + <span class="text-lg text-immich-fg">Select photos</span> + </button> + </div> + </section> + {/if} </section> </section> @@ -170,3 +289,19 @@ on:close={closeViewer} /> {/if} + +{#if isShowAssetSelection} + <AssetSelection + assetsInAlbum={album.assets} + on:go-back={() => (isShowAssetSelection = false)} + on:create-album={createAlbumHandler} + /> +{/if} + +{#if isShowShareUserSelection} + <UserSelectionModal + on:close={() => (isShowShareUserSelection = false)} + on:add-user={addUserHandler} + sharedUsersInAlbum={new Set(album.sharedUsers)} + /> +{/if} diff --git a/web/src/lib/components/album-page/asset-selection.svelte b/web/src/lib/components/album-page/asset-selection.svelte new file mode 100644 index 0000000000..362dc6f2d1 --- /dev/null +++ b/web/src/lib/components/album-page/asset-selection.svelte @@ -0,0 +1,197 @@ +<script lang="ts"> + import { createEventDispatcher, onMount } from 'svelte'; + import { quintOut } from 'svelte/easing'; + import { fly } from 'svelte/transition'; + import { assetsGroupByDate, flattenAssetGroupByDate } from '$lib/stores/assets'; + import CheckCircle from 'svelte-material-icons/CheckCircle.svelte'; + import CircleOutline from 'svelte-material-icons/CircleOutline.svelte'; + import moment from 'moment'; + import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte'; + import { AssetResponseDto } from '@api'; + import AlbumAppBar from './album-app-bar.svelte'; + + const dispatch = createEventDispatcher(); + + export let assetsInAlbum: AssetResponseDto[]; + + let selectedAsset: Set<string> = new Set(); + let selectedGroup: Set<number> = new Set(); + let existingGroup: Set<number> = new Set(); + let groupWithAssetsInAlbum: Record<number, Set<string>> = {}; + + onMount(() => scanForExistingSelectedGroup()); + + const selectAssetHandler = (assetId: string, groupIndex: number) => { + const tempSelectedAsset = new Set(selectedAsset); + + if (selectedAsset.has(assetId)) { + tempSelectedAsset.delete(assetId); + + const tempSelectedGroup = new Set(selectedGroup); + tempSelectedGroup.delete(groupIndex); + selectedGroup = tempSelectedGroup; + } else { + tempSelectedAsset.add(assetId); + } + + selectedAsset = tempSelectedAsset; + + // Check if all assets are selected in a group to toggle the group selection's icon + if (!selectedGroup.has(groupIndex)) { + const assetsInGroup = $assetsGroupByDate[groupIndex]; + let selectedAssetsInGroupCount = 0; + + assetsInGroup.forEach((asset) => { + if (selectedAsset.has(asset.id)) { + selectedAssetsInGroupCount++; + } + }); + + // Taking into account of assets in group that are already in album + if (groupWithAssetsInAlbum[groupIndex]) { + selectedAssetsInGroupCount += groupWithAssetsInAlbum[groupIndex].size; + } + + // if all assets are selected in a group, add the group to selected group + if (selectedAssetsInGroupCount == assetsInGroup.length) { + selectedGroup = selectedGroup.add(groupIndex); + } + } + }; + + const selectAssetGroupHandler = (groupIndex: number) => { + if (existingGroup.has(groupIndex)) return; + + let tempSelectedGroup = new Set(selectedGroup); + let tempSelectedAsset = new Set(selectedAsset); + + if (selectedGroup.has(groupIndex)) { + tempSelectedGroup.delete(groupIndex); + tempSelectedAsset.forEach((assetId) => { + if ($assetsGroupByDate[groupIndex].find((a) => a.id == assetId)) { + tempSelectedAsset.delete(assetId); + } + }); + } else { + tempSelectedGroup.add(groupIndex); + tempSelectedAsset = new Set([ + ...selectedAsset, + ...$assetsGroupByDate[groupIndex].map((a) => a.id) + ]); + } + + // Remove existed assets in the date group + if (groupWithAssetsInAlbum[groupIndex]) { + tempSelectedAsset.forEach((assetId) => { + if (groupWithAssetsInAlbum[groupIndex].has(assetId)) { + tempSelectedAsset.delete(assetId); + } + }); + } + + selectedAsset = tempSelectedAsset; + selectedGroup = tempSelectedGroup; + }; + + const addSelectedAssets = async () => { + dispatch('create-album', { + assets: Array.from(selectedAsset) + }); + }; + + /** + * This function is used to scan for existing selected group in the album + * and format it into the form of Record<any, Set<string>> to conditionally render and perform interaction + * relationship between the noneselected assets/groups + * with the existing assets/groups + */ + const scanForExistingSelectedGroup = () => { + if (assetsInAlbum) { + // Convert to each assetGroup to set of assetIds + const distinctAssetGroup = $assetsGroupByDate.map((assetGroup) => { + return new Set(assetGroup.map((asset) => asset.id)); + }); + + // Find the group that contains all existed assets with the same set of assetIds + for (const assetInAlbum of assetsInAlbum) { + distinctAssetGroup.forEach((group, index) => { + if (group.has(assetInAlbum.id)) { + groupWithAssetsInAlbum[index] = new Set(groupWithAssetsInAlbum[index] || []).add( + assetInAlbum.id + ); + } + }); + } + + Object.keys(groupWithAssetsInAlbum).forEach((key) => { + if (distinctAssetGroup[parseInt(key)].size == groupWithAssetsInAlbum[parseInt(key)].size) { + existingGroup = existingGroup.add(parseInt(key)); + } + }); + } + }; +</script> + +<section + transition:fly={{ y: 1000, duration: 200, easing: quintOut }} + class="absolute top-0 left-0 w-full h-full bg-immich-bg z-[200]" +> + <AlbumAppBar on:close-button-click={() => dispatch('go-back')}> + <svelte:fragment slot="leading"> + {#if selectedAsset.size == 0} + <p class="text-lg">Add to album</p> + {:else} + <p class="text-lg">{selectedAsset.size} selected</p> + {/if} + </svelte:fragment> + + <svelte:fragment slot="trailing"> + <button + disabled={selectedAsset.size === 0} + on:click={addSelectedAssets} + class="immich-text-button border bg-immich-primary text-gray-50 hover:bg-immich-primary/75 px-6 text-sm disabled:opacity-25 disabled:bg-gray-500 disabled:cursor-not-allowed" + ><span class="px-2">Done</span></button + > + </svelte:fragment> + </AlbumAppBar> + + <section id="image-grid" class="flex flex-wrap gap-14 mt-[160px] px-20"> + {#each $assetsGroupByDate as assetsInDateGroup, groupIndex} + <!-- Asset Group By Date --> + <div class="flex flex-col"> + <!-- Date group title --> + <p class="font-medium text-sm text-immich-fg mb-2 flex place-items-center h-6"> + <span + in:fly={{ x: -24, duration: 200, opacity: 0.5 }} + out:fly={{ x: -24, duration: 200 }} + class="inline-block px-2 hover:cursor-pointer" + on:click={() => selectAssetGroupHandler(groupIndex)} + > + {#if selectedGroup.has(groupIndex)} + <CheckCircle size="24" color="#4250af" /> + {:else if existingGroup.has(groupIndex)} + <CheckCircle size="24" color="#757575" /> + {:else} + <CircleOutline size="24" color="#757575" /> + {/if} + </span> + + {moment(assetsInDateGroup[0].createdAt).format('ddd, MMM DD YYYY')} + </p> + + <!-- Image grid --> + <div class="flex flex-wrap gap-[2px]"> + {#each assetsInDateGroup as asset} + <ImmichThumbnail + {asset} + on:click={() => selectAssetHandler(asset.id, groupIndex)} + {groupIndex} + selected={selectedAsset.has(asset.id)} + isExisted={assetsInAlbum.findIndex((a) => a.id == asset.id) != -1} + /> + {/each} + </div> + </div> + {/each} + </section> +</section> diff --git a/web/src/lib/components/album-page/user-selection-modal.svelte b/web/src/lib/components/album-page/user-selection-modal.svelte new file mode 100644 index 0000000000..60cfd83bbf --- /dev/null +++ b/web/src/lib/components/album-page/user-selection-modal.svelte @@ -0,0 +1,117 @@ +<script lang="ts"> + import { createEventDispatcher, onMount } from 'svelte'; + import { api, UserResponseDto } from '@api'; + import BaseModal from '../shared-components/base-modal.svelte'; + import CircleAvatar from '../shared-components/circle-avatar.svelte'; + + export let sharedUsersInAlbum: Set<UserResponseDto>; + let users: UserResponseDto[] = []; + let selectedUsers: Set<UserResponseDto> = new Set(); + + const dispatch = createEventDispatcher(); + + onMount(async () => { + const { data } = await api.userApi.getAllUsers(false); + + users = data; + + // Remove the existed shared users from the album + sharedUsersInAlbum.forEach((sharedUser) => { + users = users.filter((user) => user.id !== sharedUser.id); + }); + }); + + const selectUser = (user: UserResponseDto) => { + const tempSelectedUsers = new Set(selectedUsers); + + if (selectedUsers.has(user)) { + tempSelectedUsers.delete(user); + } else { + tempSelectedUsers.add(user); + } + + selectedUsers = tempSelectedUsers; + }; + + const deselectUser = (user: UserResponseDto) => { + const tempSelectedUsers = new Set(selectedUsers); + + tempSelectedUsers.delete(user); + + selectedUsers = tempSelectedUsers; + }; +</script> + +<BaseModal on:close={() => dispatch('close')}> + <svelte:fragment slot="title"> + <span class="flex gap-2 place-items-center"> + <img src="/immich-logo.svg" width="24" alt="Immich" /> + <p class="font-medium text-immich-fg">Invite to album</p> + </span> + </svelte:fragment> + + <div class="max-h-[400px] overflow-y-auto immich-scrollbar"> + {#if selectedUsers.size > 0} + <div class="flex gap-4 py-2 px-5 overflow-x-auto place-items-center mb-2"> + <p class="font-medium">To</p> + + {#each Array.from(selectedUsers) as user} + <button + on:click={() => deselectUser(user)} + class="flex gap-1 place-items-center border border-gray-400 rounded-full p-1 hover:bg-gray-200 transition-colors" + > + <CircleAvatar size={28} {user} /> + <p class="text-xs font-medium">{user.firstName} {user.lastName}</p> + </button> + {/each} + </div> + {/if} + + {#if users.length > 0} + <p class="text-xs font-medium px-5">SUGGESTIONS</p> + + <div class="my-4"> + {#each users as user} + <button + on:click={() => selectUser(user)} + class="w-full flex place-items-center gap-4 py-4 px-5 hover:bg-gray-200 transition-all" + > + {#if selectedUsers.has(user)} + <span + class="bg-immich-primary text-white rounded-full w-12 h-12 border flex place-items-center place-content-center text-3xl" + >✓</span + > + {:else} + <CircleAvatar {user} /> + {/if} + + <div class="text-left"> + <p class="text-immich-fg"> + {user.firstName} + {user.lastName} + </p> + <p class="text-xs "> + {user.email} + </p> + </div> + </button> + {/each} + </div> + {:else} + <p class="text-sm px-5"> + Looks like you have shared this album with all users or you don't have any user to share + with. + </p> + {/if} + + {#if selectedUsers.size > 0} + <div class="flex place-content-end p-5 "> + <button + on:click={() => dispatch('add-user', { selectedUsers })} + class="text-white bg-immich-primary px-4 py-2 rounded-lg text-sm font-bold transition-colors hover:bg-immich-primary/75" + >Add</button + > + </div> + {/if} + </div> +</BaseModal> diff --git a/web/src/lib/components/shared-components/base-modal.svelte b/web/src/lib/components/shared-components/base-modal.svelte new file mode 100644 index 0000000000..40297bfbbe --- /dev/null +++ b/web/src/lib/components/shared-components/base-modal.svelte @@ -0,0 +1,31 @@ +<script lang="ts"> + import { fly } from 'svelte/transition'; + import { quintOut } from 'svelte/easing'; + import Close from 'svelte-material-icons/Close.svelte'; + import { createEventDispatcher } from 'svelte'; + + const dispatch = createEventDispatcher(); +</script> + +<div + id="immich-modal" + transition:fly={{ y: 1000, duration: 200, easing: quintOut }} + class="absolute top-0 w-screen h-screen z-[9999] bg-black/50 flex place-items-center place-content-center" +> + <div class="bg-white w-[450px] min-h-[200px] max-h-[500px] rounded-lg shadow-md"> + <div class="flex justify-between place-items-center p-5"> + <div> + <slot name="title"> + <p>Modal Title</p> + </slot> + </div> + <button on:click={() => dispatch('close')}> + <Close size="24" /> + </button> + </div> + + <div class="mt-4"> + <slot /> + </div> + </div> +</div> diff --git a/web/src/lib/components/shared-components/circle-avatar.svelte b/web/src/lib/components/shared-components/circle-avatar.svelte index 6afbcdeb59..1fea03910b 100644 --- a/web/src/lib/components/shared-components/circle-avatar.svelte +++ b/web/src/lib/components/shared-components/circle-avatar.svelte @@ -1,9 +1,11 @@ <script lang="ts"> import { api, UserResponseDto } from '@api'; - import { onMount } from 'svelte'; export let user: UserResponseDto; + // Avatar Size In Pixel + export let size: number = 48; + const getUserAvatar = async () => { try { const { data } = await api.userApi.getProfileImage(user.id, { @@ -20,12 +22,18 @@ </script> {#await getUserAvatar()} - <div class="w-12 h-12 rounded-full bg-immich-primary/25" /> + <div + style:width={`${size}px`} + style:height={`${size}px`} + class={` rounded-full bg-immich-primary/25`} + /> {:then data} <img src={data} alt="profile-img" - class="inline rounded-full w-12 h-12 object-cover border shadow-md" + style:width={`${size}px`} + style:height={`${size}px`} + class={`inline rounded-full object-cover border shadow-md`} title={user.email} /> {/await} diff --git a/web/src/lib/components/shared-components/immich-thumbnail.svelte b/web/src/lib/components/shared-components/immich-thumbnail.svelte index 9f13c3da70..8917ffbe12 100644 --- a/web/src/lib/components/shared-components/immich-thumbnail.svelte +++ b/web/src/lib/components/shared-components/immich-thumbnail.svelte @@ -15,6 +15,8 @@ export let groupIndex = 0; export let thumbnailSize: number | undefined = undefined; export let format: ThumbnailFormat = ThumbnailFormat.Webp; + export let selected: boolean = false; + export let isExisted: boolean = false; let imageData: string; let videoData: string; @@ -27,6 +29,7 @@ let isThumbnailVideoPlaying = false; let calculateVideoDurationIntervalHandler: NodeJS.Timer; let videoProgress = '00:00'; + let videoAbortController: AbortController; const loadImageData = async () => { if ($session.user) { @@ -42,52 +45,51 @@ const loadVideoData = async () => { isThumbnailVideoPlaying = false; + videoAbortController = new AbortController(); - if ($session.user) { - try { - const { data } = await api.assetApi.serveFile( - asset.deviceAssetId, - asset.deviceId, - false, - true, - { - responseType: 'blob' - } - ); - - if (!(data instanceof Blob)) { - return; + try { + const { data } = await api.assetApi.serveFile( + asset.deviceAssetId, + asset.deviceId, + false, + true, + { + responseType: 'blob', + signal: videoAbortController.signal } + ); - videoData = URL.createObjectURL(data); + if (!(data instanceof Blob)) { + return; + } - videoPlayerNode.src = videoData; - // videoPlayerNode.src = videoData + '#t=0,5'; + videoData = URL.createObjectURL(data); - videoPlayerNode.load(); + videoPlayerNode.src = videoData; - videoPlayerNode.onloadeddata = () => { - console.log('first frame load'); - }; + videoPlayerNode.load(); - videoPlayerNode.oncanplaythrough = () => { - console.log('can play through'); - }; + videoPlayerNode.onloadeddata = () => { + console.log('first frame load'); + }; - videoPlayerNode.oncanplay = () => { - console.log('can play'); - videoPlayerNode.muted = true; - videoPlayerNode.play(); + videoPlayerNode.oncanplaythrough = () => { + console.log('can play through'); + }; - isThumbnailVideoPlaying = true; - calculateVideoDurationIntervalHandler = setInterval(() => { - videoProgress = getVideoDurationInString(Math.round(videoPlayerNode.currentTime)); - }, 1000); - }; + videoPlayerNode.oncanplay = () => { + console.log('can play'); + videoPlayerNode.muted = true; + videoPlayerNode.play(); - return videoData; - } catch (e) {} - } + isThumbnailVideoPlaying = true; + calculateVideoDurationIntervalHandler = setInterval(() => { + videoProgress = getVideoDurationInString(Math.round(videoPlayerNode.currentTime)); + }, 1000); + }; + + return videoData; + } catch (e) {} }; const getVideoDurationInString = (currentTime: number) => { @@ -137,6 +139,11 @@ const handleMouseLeaveThumbnail = () => { mouseOver = false; + + // Stop XHR download of video + videoAbortController?.abort(); + + // Stop video playback URL.revokeObjectURL(videoData); clearInterval(calculateVideoDurationIntervalHandler); @@ -144,28 +151,59 @@ isThumbnailVideoPlaying = false; videoProgress = '00:00'; }; + + $: getThumbnailBorderStyle = () => { + if (selected) { + return 'border-[20px] border-immich-primary/20'; + } else if (isExisted) { + return 'border-[20px] border-gray-300'; + } else { + return ''; + } + }; + + $: getOverlaySelectorIconStyle = () => { + if (selected || isExisted) { + return ''; + } else { + return 'bg-gradient-to-b from-gray-800/50'; + } + }; + const thumbnailClickedHandler = () => { + if (!isExisted) { + dispatch('click', { assetId: asset.id, deviceId: asset.deviceId }); + } + }; </script> <IntersectionObserver once={true} let:intersecting> <div style:width={`${thumbnailSize}px`} style:height={`${thumbnailSize}px`} - class={`bg-gray-100 relative hover:cursor-pointer ${getSize()}`} + class={`bg-gray-100 relative ${getSize()} ${ + isExisted ? 'cursor-not-allowed' : 'hover:cursor-pointer' + }`} on:mouseenter={handleMouseOverThumbnail} on:mouseleave={handleMouseLeaveThumbnail} - on:click={() => dispatch('viewAsset', { assetId: asset.id, deviceId: asset.deviceId })} + on:click={thumbnailClickedHandler} > - {#if mouseOver} + {#if mouseOver || selected || isExisted} <div in:fade={{ duration: 200 }} - class="w-full bg-gradient-to-b from-gray-800/50 via-white/0 to-white/0 absolute p-2 z-10" + class={`w-full ${getOverlaySelectorIconStyle()} via-white/0 to-white/0 absolute p-2 z-10`} > <div on:mouseenter={() => (mouseOverIcon = true)} on:mouseleave={() => (mouseOverIcon = false)} class="inline-block" > - <CheckCircle size="24" color={mouseOverIcon ? 'white' : '#d8dadb'} /> + {#if selected} + <CheckCircle size="24" color="#4250af" /> + {:else if isExisted} + <CheckCircle size="24" color="#252525" /> + {:else} + <CheckCircle size="24" color={mouseOverIcon ? 'white' : '#d8dadb'} /> + {/if} </div> </div> {/if} @@ -209,7 +247,7 @@ <div style:width={`${thumbnailSize}px`} style:height={`${thumbnailSize}px`} - class={`bg-immich-primary/10 ${getSize()} flex place-items-center place-content-center`} + class={`bg-immich-primary/10 ${getSize()} flex place-items-center place-content-center `} > ... </div> @@ -220,7 +258,7 @@ in:fade={{ duration: 250 }} src={imageData} alt={asset.id} - class={`object-cover ${getSize()} transition-all duration-100 z-0`} + class={`object-cover ${getSize()} transition-all duration-100 z-0 ${getThumbnailBorderStyle()}`} loading="lazy" /> {/await} diff --git a/web/src/lib/components/shared-components/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar.svelte index 31582efc44..eab2a83436 100644 --- a/web/src/lib/components/shared-components/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar.svelte @@ -61,14 +61,17 @@ <h1 class="font-immich-title text-2xl text-immich-primary">IMMICH</h1> </a> <div class="flex-1 ml-24"> - <input class="w-[50%] border rounded-md bg-gray-200 px-8 py-4" placeholder="Search - Coming soon" /> + <input + class="w-[50%] border rounded-md bg-gray-200 px-8 py-4" + placeholder="Search - Coming soon" + /> </div> <section class="flex gap-4 place-items-center"> {#if $page.url.pathname !== '/admin'} <button in:fly={{ x: 50, duration: 250 }} on:click={() => dispatch('uploadClicked')} - class="flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium" + class="immich-text-button" > <TrayArrowUp size="20" /> <span> Upload </span> @@ -158,7 +161,9 @@ </div> <div class="mb-6"> - <button class="border rounded-3xl px-6 py-2 hover:bg-gray-50" on:click={logOut}>Sign Out</button> + <button class="border rounded-3xl px-6 py-2 hover:bg-gray-50" on:click={logOut} + >Sign Out</button + > </div> </div> {/if} diff --git a/web/src/lib/components/shared-components/upload-panel.svelte b/web/src/lib/components/shared-components/upload-panel.svelte index d959ff7063..038077dff8 100644 --- a/web/src/lib/components/shared-components/upload-panel.svelte +++ b/web/src/lib/components/shared-components/upload-panel.svelte @@ -79,9 +79,7 @@ isUploading = value; if (isUploading == false) { - if ($session.user) { - getAssetsInfo($session.user.accessToken); - } + getAssetsInfo(); } }); </script> @@ -107,7 +105,7 @@ </button> </div> - <div id="upload-item-list" class="max-h-[400px] overflow-y-auto pr-2 rounded-lg"> + <div class="max-h-[400px] overflow-y-auto pr-2 rounded-lg immich-scrollbar"> {#each $uploadAssetsStore as uploadAsset} <div in:fade={{ duration: 250 }} @@ -136,7 +134,9 @@ <input disabled class="bg-gray-100 border w-full p-1 rounded-md text-[10px] px-2" - value={`[${getSizeInHumanReadableFormat(uploadAsset.file.size)}] ${uploadAsset.file.name}`} + value={`[${getSizeInHumanReadableFormat(uploadAsset.file.size)}] ${ + uploadAsset.file.name + }`} /> <div class="w-full bg-gray-300 h-[15px] rounded-md mt-[5px] text-white relative"> @@ -144,7 +144,9 @@ class="bg-immich-primary h-[15px] rounded-md transition-all" style={`width: ${uploadAsset.progress}%`} /> - <p class="absolute h-full w-full text-center top-0 text-[10px] ">{uploadAsset.progress}/100</p> + <p class="absolute h-full w-full text-center top-0 text-[10px] "> + {uploadAsset.progress}/100 + </p> </div> </div> </div> @@ -173,28 +175,3 @@ {/if} </div> {/if} - -<style> - /* width */ - #upload-item-list::-webkit-scrollbar { - width: 5px; - } - - /* Track */ - #upload-item-list::-webkit-scrollbar-track { - background: #f1f1f1; - border-radius: 16px; - } - - /* Handle */ - #upload-item-list::-webkit-scrollbar-thumb { - background: #4250af68; - border-radius: 16px; - } - - /* Handle on hover */ - #upload-item-list::-webkit-scrollbar-thumb:hover { - background: #4250afad; - border-radius: 16px; - } -</style> diff --git a/web/src/routes/albums/index.svelte b/web/src/routes/albums/index.svelte index 121f597d14..e15547b7d5 100644 --- a/web/src/routes/albums/index.svelte +++ b/web/src/routes/albums/index.svelte @@ -17,10 +17,10 @@ }; } - let allAlbums: AlbumResponseDto[] = []; + let albums: AlbumResponseDto[] = []; try { const { data } = await api.albumApi.getAllAlbums(); - allAlbums = data; + albums = data; } catch (e) { console.log('Error [getAllAlbums] ', e); } @@ -29,7 +29,7 @@ status: 200, props: { user: session.user, - allAlbums: allAlbums + albums: albums } }; }; @@ -38,12 +38,47 @@ <script lang="ts"> import AlbumCard from '$lib/components/album-page/album-card.svelte'; import { goto } from '$app/navigation'; + import { onMount } from 'svelte'; export let user: ImmichUser; - export let allAlbums: AlbumResponseDto[]; + export let albums: AlbumResponseDto[]; - const showAlbum = (event: CustomEvent) => { - goto('/albums/' + event.detail.id); + onMount(async () => { + const { data } = await api.albumApi.getAllAlbums(); + albums = data; + + // Delete album that has no photos and is named 'Untitled' + for (const album of albums) { + if (album.albumName === 'Untitled' && album.assets.length === 0) { + const isDeleted = await deleteAlbum(album); + + if (isDeleted) { + albums = albums.filter((a) => a.id !== album.id); + } + } + } + }); + + const createAlbum = async () => { + try { + const { data: newAlbum } = await api.albumApi.createAlbum({ + albumName: 'Untitled' + }); + + goto('/albums/' + newAlbum.id); + } catch (e) { + console.log('Error [createAlbum] ', e); + } + }; + + const deleteAlbum = async (album: AlbumResponseDto) => { + try { + await api.albumApi.deleteAlbum(album.id); + return true; + } catch (e) { + console.log('Error [deleteAlbum] ', e); + return false; + } }; </script> @@ -68,9 +103,7 @@ </div> <div> - <button - class="flex place-items-center gap-1 text-sm hover:bg-immich-primary/5 p-2 rounded-lg font-medium hover:text-gray-700" - > + <button on:click={createAlbum} class="immich-text-button text-sm"> <span> <PlusBoxOutline size="18" /> </span> @@ -85,8 +118,10 @@ <!-- Album Card --> <div class="flex flex-wrap gap-8"> - {#each allAlbums as album} - <a sveltekit:prefetch href={`albums/${album.id}`}> <AlbumCard {album} /></a> + {#each albums as album} + <a sveltekit:prefetch href={`albums/${album.id}`}> + <AlbumCard {album} /> + </a> {/each} </div> </section> diff --git a/web/src/routes/photos/index.svelte b/web/src/routes/photos/index.svelte index 4ca3797823..e2cc935c84 100644 --- a/web/src/routes/photos/index.svelte +++ b/web/src/routes/photos/index.svelte @@ -173,7 +173,7 @@ <ImmichThumbnail {asset} on:mouseEvent={thumbnailMouseEventHandler} - on:viewAsset={viewAssetHandler} + on:click={viewAssetHandler} {groupIndex} /> {/each} diff --git a/web/src/routes/sharing/index.svelte b/web/src/routes/sharing/index.svelte index 6013df5cac..4cfbf55fb3 100644 --- a/web/src/routes/sharing/index.svelte +++ b/web/src/routes/sharing/index.svelte @@ -28,6 +28,28 @@ } }; }; + + const createSharedAlbum = async () => { + try { + const { data: newAlbum } = await api.albumApi.createAlbum({ + albumName: 'Untitled' + }); + + goto('/albums/' + newAlbum.id); + } catch (e) { + console.log('Error [createAlbum] ', e); + } + }; + + const deleteAlbum = async (album: AlbumResponseDto) => { + try { + await api.albumApi.deleteAlbum(album.id); + return true; + } catch (e) { + console.log('Error [deleteAlbum] ', e); + return false; + } + }; </script> <script lang="ts"> @@ -36,6 +58,7 @@ import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte'; import AlbumCard from '$lib/components/album-page/album-card.svelte'; import SharedAlbumListTile from '$lib/components/sharing-page/shared-album-list-tile.svelte'; + import { goto } from '$app/navigation'; export let user: UserResponseDto; export let sharedAlbums: AlbumResponseDto[]; @@ -62,6 +85,7 @@ <div> <button + on:click={createSharedAlbum} class="flex place-items-center gap-1 text-sm hover:bg-immich-primary/5 p-2 rounded-lg font-medium hover:text-gray-700" > <span>