mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 10:37:11 -04:00 
			
		
		
		
	feat(web): global activity (#4796)
* feat: global activity * fix: tests * pr feedback * use flexbox * fix: deleted control actions * fix: flex box * fix: do not show activity tab by default * feat: better grouping * fix: set isShared default value to false * fix: prevent re-rendering the asset grid * fix: activity status above the scrollbar * fix: prevent re-rendering the asset grid * fix: prevent re-rendering the asset grid * pr feedback * pr feedback * pr feedback * styling and better thumbnail --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
		
							parent
							
								
									68000c21a8
								
							
						
					
					
						commit
						a0743d8b7d
					
				
							
								
								
									
										33
									
								
								web/src/lib/components/asset-viewer/activity-status.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								web/src/lib/components/asset-viewer/activity-status.svelte
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,33 @@ | |||||||
|  | <script lang="ts"> | ||||||
|  |   import { mdiCommentOutline, mdiHeart, mdiHeartOutline } from '@mdi/js'; | ||||||
|  |   import { createEventDispatcher } from 'svelte'; | ||||||
|  |   import Icon from '../elements/icon.svelte'; | ||||||
|  |   import type { ActivityResponseDto } from '@api'; | ||||||
|  | 
 | ||||||
|  |   export let isLiked: ActivityResponseDto | null; | ||||||
|  |   export let numberOfComments: number | undefined; | ||||||
|  |   export let isShowActivity: boolean | undefined; | ||||||
|  | 
 | ||||||
|  |   const dispatch = createEventDispatcher(); | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <div | ||||||
|  |   class="w-full h-14 flex p-4 text-white items-center justify-center rounded-full gap-4 bg-immich-dark-bg bg-opacity-60" | ||||||
|  | > | ||||||
|  |   <button on:click={() => dispatch('favorite')}> | ||||||
|  |     <!-- svelte-ignore missing-declaration --> | ||||||
|  |     <div class="items-center justify-center"> | ||||||
|  |       <Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} /> | ||||||
|  |     </div> | ||||||
|  |   </button> | ||||||
|  |   <button on:click={() => dispatch('openActivityTab')}> | ||||||
|  |     <div class="flex gap-2 items-center justify-center"> | ||||||
|  |       <Icon path={mdiCommentOutline} class="scale-x-[-1]" size={24} /> | ||||||
|  |       {#if numberOfComments} | ||||||
|  |         <div class="text-xl">{numberOfComments}</div> | ||||||
|  |       {:else if !isShowActivity} | ||||||
|  |         <div class="text-lg">Say something</div> | ||||||
|  |       {/if} | ||||||
|  |     </div> | ||||||
|  |   </button> | ||||||
|  | </div> | ||||||
| @ -1,9 +1,9 @@ | |||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
|   import { createEventDispatcher } from 'svelte'; |   import { createEventDispatcher, onMount } from 'svelte'; | ||||||
|   import UserAvatar from '../shared-components/user-avatar.svelte'; |   import UserAvatar from '../shared-components/user-avatar.svelte'; | ||||||
|   import { mdiClose, mdiHeart, mdiSend, mdiDotsVertical } from '@mdi/js'; |   import { mdiClose, mdiHeart, mdiSend, mdiDotsVertical } from '@mdi/js'; | ||||||
|   import Icon from '$lib/components/elements/icon.svelte'; |   import Icon from '$lib/components/elements/icon.svelte'; | ||||||
|   import { ActivityResponseDto, api, AssetTypeEnum, ReactionType, type UserResponseDto } from '@api'; |   import { ActivityResponseDto, api, AssetTypeEnum, ReactionType, ThumbnailFormat, type UserResponseDto } from '@api'; | ||||||
|   import { handleError } from '$lib/utils/handle-error'; |   import { handleError } from '$lib/utils/handle-error'; | ||||||
|   import { isTenMinutesApart } from '$lib/utils/timesince'; |   import { isTenMinutesApart } from '$lib/utils/timesince'; | ||||||
|   import { clickOutside } from '$lib/utils/click-outside'; |   import { clickOutside } from '$lib/utils/click-outside'; | ||||||
| @ -15,6 +15,13 @@ | |||||||
| 
 | 
 | ||||||
|   const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second']; |   const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second']; | ||||||
| 
 | 
 | ||||||
|  |   const shouldGroup = (currentDate: string, nextDate: string): boolean => { | ||||||
|  |     const currentDateTime = luxon.DateTime.fromISO(currentDate); | ||||||
|  |     const nextDateTime = luxon.DateTime.fromISO(nextDate); | ||||||
|  | 
 | ||||||
|  |     return currentDateTime.hasSame(nextDateTime, 'hour') || currentDateTime.toRelative() === nextDateTime.toRelative(); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|   const timeSince = (dateTime: luxon.DateTime) => { |   const timeSince = (dateTime: luxon.DateTime) => { | ||||||
|     const diff = dateTime.diffNow().shiftTo(...units); |     const diff = dateTime.diffNow().shiftTo(...units); | ||||||
|     const unit = units.find((unit) => diff.get(unit) !== 0) || 'second'; |     const unit = units.find((unit) => diff.get(unit) !== 0) || 'second'; | ||||||
| @ -27,9 +34,9 @@ | |||||||
| 
 | 
 | ||||||
|   export let reactions: ActivityResponseDto[]; |   export let reactions: ActivityResponseDto[]; | ||||||
|   export let user: UserResponseDto; |   export let user: UserResponseDto; | ||||||
|   export let assetId: string; |   export let assetId: string | undefined = undefined; | ||||||
|   export let albumId: string; |   export let albumId: string; | ||||||
|   export let assetType: AssetTypeEnum; |   export let assetType: AssetTypeEnum | undefined = undefined; | ||||||
|   export let albumOwnerId: string; |   export let albumOwnerId: string; | ||||||
| 
 | 
 | ||||||
|   let textArea: HTMLTextAreaElement; |   let textArea: HTMLTextAreaElement; | ||||||
| @ -37,7 +44,7 @@ | |||||||
|   let activityHeight: number; |   let activityHeight: number; | ||||||
|   let chatHeight: number; |   let chatHeight: number; | ||||||
|   let divHeight: number; |   let divHeight: number; | ||||||
|   let previousAssetId: string | null; |   let previousAssetId: string | undefined = assetId; | ||||||
|   let message = ''; |   let message = ''; | ||||||
|   let isSendingMessage = false; |   let isSendingMessage = false; | ||||||
| 
 | 
 | ||||||
| @ -51,11 +58,14 @@ | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   $: { |   $: { | ||||||
|     if (previousAssetId != assetId) { |     if (assetId && previousAssetId != assetId) { | ||||||
|       getReactions(); |       getReactions(); | ||||||
|       previousAssetId = assetId; |       previousAssetId = assetId; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |   onMount(async () => { | ||||||
|  |     await getReactions(); | ||||||
|  |   }); | ||||||
| 
 | 
 | ||||||
|   const getReactions = async () => { |   const getReactions = async () => { | ||||||
|     try { |     try { | ||||||
| @ -161,11 +171,20 @@ | |||||||
|         {#each reactions as reaction, index (reaction.id)} |         {#each reactions as reaction, index (reaction.id)} | ||||||
|           {#if reaction.type === 'comment'} |           {#if reaction.type === 'comment'} | ||||||
|             <div class="flex dark:bg-gray-800 bg-gray-200 p-3 mx-2 mt-3 rounded-lg gap-4 justify-start"> |             <div class="flex dark:bg-gray-800 bg-gray-200 p-3 mx-2 mt-3 rounded-lg gap-4 justify-start"> | ||||||
|               <div> |               <div class="flex items-center"> | ||||||
|                 <UserAvatar user={reaction.user} size="sm" /> |                 <UserAvatar user={reaction.user} size="sm" /> | ||||||
|               </div> |               </div> | ||||||
| 
 | 
 | ||||||
|               <div class="w-full leading-4 overflow-hidden self-center break-words text-sm">{reaction.comment}</div> |               <div class="w-full leading-4 overflow-hidden self-center break-words text-sm">{reaction.comment}</div> | ||||||
|  |               {#if assetId === undefined && reaction.assetId} | ||||||
|  |                 <div class="aspect-square w-[75px] h-[75px]"> | ||||||
|  |                   <img | ||||||
|  |                     class="rounded-lg w-[75px] h-[75px] object-cover" | ||||||
|  |                     src={api.getAssetThumbnailUrl(reaction.assetId, ThumbnailFormat.Webp)} | ||||||
|  |                     alt="comment-thumbnail" | ||||||
|  |                   /> | ||||||
|  |                 </div> | ||||||
|  |               {/if} | ||||||
|               {#if reaction.user.id === user.id || albumOwnerId === user.id} |               {#if reaction.user.id === user.id || albumOwnerId === user.id} | ||||||
|                 <div class="flex items-start w-fit pt-[5px]" title="Delete comment"> |                 <div class="flex items-start w-fit pt-[5px]" title="Delete comment"> | ||||||
|                   <button on:click={() => (!showDeleteReaction[index] ? showOptionsMenu(index) : '')}> |                   <button on:click={() => (!showDeleteReaction[index] ? showOptionsMenu(index) : '')}> | ||||||
| @ -176,17 +195,18 @@ | |||||||
|               <div> |               <div> | ||||||
|                 {#if showDeleteReaction[index]} |                 {#if showDeleteReaction[index]} | ||||||
|                   <button |                   <button | ||||||
|                     class="absolute right-6 rounded-xl items-center bg-gray-300 dark:bg-slate-100 py-2 px-6 text-left text-sm font-medium text-immich-fg hover:bg-red-300 focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg dark:hover:bg-red-300 transition-colors" |                     class="absolute right-6 rounded-xl items-center bg-gray-300 dark:bg-slate-100 py-3 px-6 text-left text-sm font-medium text-immich-fg hover:bg-red-300 focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg dark:hover:bg-red-100 transition-colors" | ||||||
|                     use:clickOutside |                     use:clickOutside | ||||||
|                     on:outclick={() => (showDeleteReaction[index] = false)} |                     on:outclick={() => (showDeleteReaction[index] = false)} | ||||||
|                     on:click={() => handleDeleteReaction(reaction, index)} |                     on:click={() => handleDeleteReaction(reaction, index)} | ||||||
|                   > |                   > | ||||||
|                     Delete |                     Remove | ||||||
|                   </button> |                   </button> | ||||||
|                 {/if} |                 {/if} | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|             {#if (index != reactions.length - 1 && isTenMinutesApart(reactions[index].createdAt, reactions[index + 1].createdAt)) || index === reactions.length - 1} | 
 | ||||||
|  |             {#if (index != reactions.length - 1 && !shouldGroup(reactions[index].createdAt, reactions[index + 1].createdAt)) || index === reactions.length - 1} | ||||||
|               <div |               <div | ||||||
|                 class=" px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300" |                 class=" px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300" | ||||||
|                 title={new Date(reaction.createdAt).toLocaleDateString(undefined, timeOptions)} |                 title={new Date(reaction.createdAt).toLocaleDateString(undefined, timeOptions)} | ||||||
| @ -196,17 +216,26 @@ | |||||||
|             {/if} |             {/if} | ||||||
|           {:else if reaction.type === 'like'} |           {:else if reaction.type === 'like'} | ||||||
|             <div class="relative"> |             <div class="relative"> | ||||||
|               <div class="flex p-2 mx-2 mt-2 rounded-full gap-2 items-center text-sm"> |               <div class="flex p-3 mx-2 mt-3 rounded-full gap-4 items-center text-sm"> | ||||||
|                 <div class="text-red-600"><Icon path={mdiHeart} size={20} /></div> |                 <div class="text-red-600"><Icon path={mdiHeart} size={20} /></div> | ||||||
| 
 | 
 | ||||||
|                 <div |                 <div | ||||||
|                   class="w-full" |                   class="w-full" | ||||||
|                   title={`${reaction.user.firstName} ${reaction.user.lastName} (${reaction.user.email})`} |                   title={`${reaction.user.firstName} ${reaction.user.lastName} (${reaction.user.email})`} | ||||||
|                 > |                 > | ||||||
|                   {`${reaction.user.firstName} ${reaction.user.lastName} liked this ${getAssetType( |                   {`${reaction.user.firstName} ${reaction.user.lastName} liked ${ | ||||||
|                     assetType, |                     assetType ? `this ${getAssetType(assetType).toLowerCase()}` : 'it' | ||||||
|                   ).toLowerCase()}`} |                   }`} | ||||||
|                 </div> |                 </div> | ||||||
|  |                 {#if assetId === undefined && reaction.assetId} | ||||||
|  |                   <div class="aspect-square w-[75px] h-[75px]"> | ||||||
|  |                     <img | ||||||
|  |                       class="rounded-lg w-[75px] h-[75px] object-cover" | ||||||
|  |                       src={api.getAssetThumbnailUrl(reaction.assetId, ThumbnailFormat.Webp)} | ||||||
|  |                       alt="like-thumbnail" | ||||||
|  |                     /> | ||||||
|  |                   </div> | ||||||
|  |                 {/if} | ||||||
|                 {#if reaction.user.id === user.id || albumOwnerId === user.id} |                 {#if reaction.user.id === user.id || albumOwnerId === user.id} | ||||||
|                   <div class="flex items-start w-fit" title="Delete like"> |                   <div class="flex items-start w-fit" title="Delete like"> | ||||||
|                     <button on:click={() => (!showDeleteReaction[index] ? showOptionsMenu(index) : '')}> |                     <button on:click={() => (!showDeleteReaction[index] ? showOptionsMenu(index) : '')}> | ||||||
| @ -217,12 +246,12 @@ | |||||||
|                 <div> |                 <div> | ||||||
|                   {#if showDeleteReaction[index]} |                   {#if showDeleteReaction[index]} | ||||||
|                     <button |                     <button | ||||||
|                       class="absolute top-2 right-6 rounded-xl items-center bg-gray-300 dark:bg-slate-100 p-3 text-left text-sm font-medium text-immich-fg hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg" |                       class="absolute right-6 rounded-xl items-center bg-gray-300 dark:bg-slate-100 py-3 px-6 text-left text-sm font-medium text-immich-fg hover:bg-red-300 focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg dark:hover:bg-red-100 transition-colors" | ||||||
|                       use:clickOutside |                       use:clickOutside | ||||||
|                       on:outclick={() => (showDeleteReaction[index] = false)} |                       on:outclick={() => (showDeleteReaction[index] = false)} | ||||||
|                       on:click={() => handleDeleteReaction(reaction, index)} |                       on:click={() => handleDeleteReaction(reaction, index)} | ||||||
|                     > |                     > | ||||||
|                       Delete Like |                       Remove | ||||||
|                     </button> |                     </button> | ||||||
|                   {/if} |                   {/if} | ||||||
|                 </div> |                 </div> | ||||||
| @ -266,8 +295,8 @@ | |||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|           {:else if message} |           {:else if message} | ||||||
|             <div class="flex items-end w-fit ml-0 text-immich-primary dark:text-white"> |             <div class="flex items-end w-fit ml-0"> | ||||||
|               <CircleIconButton size="15" icon={mdiSend} /> |               <CircleIconButton size="15" icon={mdiSend} iconColor={'dark'} hoverColor={'rgb(173,203,250)'} /> | ||||||
|             </div> |             </div> | ||||||
|           {/if} |           {/if} | ||||||
|         </form> |         </form> | ||||||
|  | |||||||
| @ -33,18 +33,13 @@ | |||||||
|   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; |   import { assetViewingStore } from '$lib/stores/asset-viewing.store'; | ||||||
|   import { SlideshowHistory } from '$lib/utils/slideshow-history'; |   import { SlideshowHistory } from '$lib/utils/slideshow-history'; | ||||||
|   import { featureFlags } from '$lib/stores/server-config.store'; |   import { featureFlags } from '$lib/stores/server-config.store'; | ||||||
|   import { |   import { mdiChevronLeft, mdiChevronRight, mdiImageBrokenVariant } from '@mdi/js'; | ||||||
|     mdiHeartOutline, |  | ||||||
|     mdiHeart, |  | ||||||
|     mdiCommentOutline, |  | ||||||
|     mdiChevronLeft, |  | ||||||
|     mdiChevronRight, |  | ||||||
|     mdiImageBrokenVariant, |  | ||||||
|   } from '@mdi/js'; |  | ||||||
|   import Icon from '$lib/components/elements/icon.svelte'; |   import Icon from '$lib/components/elements/icon.svelte'; | ||||||
|   import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; |   import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; | ||||||
|   import { stackAssetsStore } from '$lib/stores/stacked-asset.store'; |   import { stackAssetsStore } from '$lib/stores/stacked-asset.store'; | ||||||
|   import ActivityViewer from './activity-viewer.svelte'; |   import ActivityViewer from './activity-viewer.svelte'; | ||||||
|  |   import ActivityStatus from './activity-status.svelte'; | ||||||
|  |   import { updateNumberOfComments } from '$lib/stores/activity.store'; | ||||||
|   import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; |   import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; | ||||||
|   import SlideshowBar from './slideshow-bar.svelte'; |   import SlideshowBar from './slideshow-bar.svelte'; | ||||||
| 
 | 
 | ||||||
| @ -55,7 +50,7 @@ | |||||||
|   $: isTrashEnabled = $featureFlags.trash; |   $: isTrashEnabled = $featureFlags.trash; | ||||||
|   export let force = false; |   export let force = false; | ||||||
|   export let withStacked = false; |   export let withStacked = false; | ||||||
|   export let isShared = true; |   export let isShared = false; | ||||||
|   export let user: UserResponseDto | null = null; |   export let user: UserResponseDto | null = null; | ||||||
|   export let album: AlbumResponseDto | null = null; |   export let album: AlbumResponseDto | null = null; | ||||||
| 
 | 
 | ||||||
| @ -109,6 +104,16 @@ | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   const handleAddComment = () => { | ||||||
|  |     numberOfComments++; | ||||||
|  |     updateNumberOfComments(1); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const handleRemoveComment = () => { | ||||||
|  |     numberOfComments--; | ||||||
|  |     updateNumberOfComments(-1); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|   const handleFavorite = async () => { |   const handleFavorite = async () => { | ||||||
|     if (album) { |     if (album) { | ||||||
|       try { |       try { | ||||||
| @ -658,25 +663,13 @@ | |||||||
|         {/if} |         {/if} | ||||||
|         {#if $slideshowState === SlideshowState.None && isShared} |         {#if $slideshowState === SlideshowState.None && isShared} | ||||||
|           <div class="z-[9999] absolute bottom-0 right-0 mb-6 mr-6 justify-self-end"> |           <div class="z-[9999] absolute bottom-0 right-0 mb-6 mr-6 justify-self-end"> | ||||||
|             <div |             <ActivityStatus | ||||||
|               class="w-full h-14 flex p-4 text-white items-center justify-center rounded-full gap-4 bg-immich-dark-bg bg-opacity-60" |               {isLiked} | ||||||
|             > |               {numberOfComments} | ||||||
|               <button on:click={handleFavorite}> |               {isShowActivity} | ||||||
|                 <div class="items-center justify-center"> |               on:favorite={handleFavorite} | ||||||
|                   <Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} /> |               on:openActivityTab={handleOpenActivity} | ||||||
|                 </div> |             /> | ||||||
|               </button> |  | ||||||
|               <button on:click={handleOpenActivity}> |  | ||||||
|                 <div class="flex gap-2 items-center justify-center"> |  | ||||||
|                   <Icon path={mdiCommentOutline} class="scale-x-[-1]" size={24} /> |  | ||||||
|                   {#if numberOfComments} |  | ||||||
|                     <div class="text-xl">{numberOfComments}</div> |  | ||||||
|                   {:else if !isShowActivity && !$isShowDetail} |  | ||||||
|                     <div class="text-lg">Say something</div> |  | ||||||
|                   {/if} |  | ||||||
|                 </div> |  | ||||||
|               </button> |  | ||||||
|             </div> |  | ||||||
|           </div> |           </div> | ||||||
|         {/if} |         {/if} | ||||||
|       {/key} |       {/key} | ||||||
| @ -746,7 +739,7 @@ | |||||||
|     <div |     <div | ||||||
|       transition:fly={{ duration: 150 }} |       transition:fly={{ duration: 150 }} | ||||||
|       id="activity-panel" |       id="activity-panel" | ||||||
|       class="z-[1002] row-start-1 row-span-5 w-[460px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg pl-4" |       class="z-[1002] row-start-1 row-span-5 w-[360px] md:w-[460px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg pl-4" | ||||||
|       translate="yes" |       translate="yes" | ||||||
|     > |     > | ||||||
|       <ActivityViewer |       <ActivityViewer | ||||||
| @ -756,8 +749,8 @@ | |||||||
|         albumId={album.id} |         albumId={album.id} | ||||||
|         assetId={asset.id} |         assetId={asset.id} | ||||||
|         bind:reactions |         bind:reactions | ||||||
|         on:addComment={() => numberOfComments++} |         on:addComment={handleAddComment} | ||||||
|         on:deleteComment={() => numberOfComments--} |         on:deleteComment={handleRemoveComment} | ||||||
|         on:deleteLike={() => (isLiked = null)} |         on:deleteLike={() => (isLiked = null)} | ||||||
|         on:close={() => (isShowActivity = false)} |         on:close={() => (isShowActivity = false)} | ||||||
|       /> |       /> | ||||||
|  | |||||||
| @ -10,6 +10,7 @@ | |||||||
|   export let isOpacity = false; |   export let isOpacity = false; | ||||||
|   export let forceDark = false; |   export let forceDark = false; | ||||||
|   export let hideMobile = false; |   export let hideMobile = false; | ||||||
|  |   export let iconColor = 'currentColor'; | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <button | <button | ||||||
| @ -23,7 +24,7 @@ | |||||||
|   {hideMobile && 'hidden sm:flex'}" |   {hideMobile && 'hidden sm:flex'}" | ||||||
|   on:click |   on:click | ||||||
| > | > | ||||||
|   <Icon path={icon} {size} /> |   <Icon path={icon} {size} color={iconColor} /> | ||||||
|   <slot /> |   <slot /> | ||||||
| </button> | </button> | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -40,7 +40,7 @@ | |||||||
|   }); |   }); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <div in:fly={{ y: 10, duration: 200 }} class="fixed top-0 z-[100] w-full bg-transparent"> | <div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent"> | ||||||
|   <div |   <div | ||||||
|     id="asset-selection-app-bar" |     id="asset-selection-app-bar" | ||||||
|     class={`grid grid-cols-[10%_80%_10%] justify-between md:grid-cols-[20%_60%_20%] lg:grid-cols-3 ${appBarBorder} mx-2 mt-2 place-items-center rounded-lg p-2 transition-all ${tailwindClasses} dark:bg-immich-dark-gray ${ |     class={`grid grid-cols-[10%_80%_10%] justify-between md:grid-cols-[20%_60%_20%] lg:grid-cols-3 ${appBarBorder} mx-2 mt-2 place-items-center rounded-lg p-2 transition-all ${tailwindClasses} dark:bg-immich-dark-gray ${ | ||||||
|  | |||||||
| @ -93,7 +93,7 @@ | |||||||
| {#if $assetStore.timelineHeight > height} | {#if $assetStore.timelineHeight > height} | ||||||
|   <div |   <div | ||||||
|     id="immich-scrubbable-scrollbar" |     id="immich-scrubbable-scrollbar" | ||||||
|     class="fixed right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize" |     class="absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize" | ||||||
|     style:width={isDragging ? '100vw' : '60px'} |     style:width={isDragging ? '100vw' : '60px'} | ||||||
|     style:height={height + 'px'} |     style:height={height + 'px'} | ||||||
|     style:background-color={isDragging ? 'transparent' : 'transparent'} |     style:background-color={isDragging ? 'transparent' : 'transparent'} | ||||||
|  | |||||||
							
								
								
									
										11
									
								
								web/src/lib/stores/activity.store.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								web/src/lib/stores/activity.store.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,11 @@ | |||||||
|  | import { writable } from 'svelte/store'; | ||||||
|  | 
 | ||||||
|  | export const numberOfComments = writable<number | undefined>(undefined); | ||||||
|  | 
 | ||||||
|  | export const setNumberOfComments = (number: number) => { | ||||||
|  |   numberOfComments.set(number); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const updateNumberOfComments = (addOrRemove: 1 | -1) => { | ||||||
|  |   numberOfComments.update((n) => (n ? n + addOrRemove : undefined)); | ||||||
|  | }; | ||||||
| @ -35,7 +35,7 @@ | |||||||
|   import { downloadArchive } from '$lib/utils/asset-utils'; |   import { downloadArchive } from '$lib/utils/asset-utils'; | ||||||
|   import { openFileUploadDialog } from '$lib/utils/file-uploader'; |   import { openFileUploadDialog } from '$lib/utils/file-uploader'; | ||||||
|   import { handleError } from '$lib/utils/handle-error'; |   import { handleError } from '$lib/utils/handle-error'; | ||||||
|   import { UserResponseDto, api } from '@api'; |   import { ActivityResponseDto, ReactionType, UserResponseDto, api } from '@api'; | ||||||
|   import Icon from '$lib/components/elements/icon.svelte'; |   import Icon from '$lib/components/elements/icon.svelte'; | ||||||
|   import type { PageData } from './$types'; |   import type { PageData } from './$types'; | ||||||
|   import { clickOutside } from '$lib/utils/click-outside'; |   import { clickOutside } from '$lib/utils/click-outside'; | ||||||
| @ -45,11 +45,16 @@ | |||||||
|     mdiDotsVertical, |     mdiDotsVertical, | ||||||
|     mdiArrowLeft, |     mdiArrowLeft, | ||||||
|     mdiFileImagePlusOutline, |     mdiFileImagePlusOutline, | ||||||
|     mdiShareVariantOutline, |  | ||||||
|     mdiDeleteOutline, |  | ||||||
|     mdiFolderDownloadOutline, |     mdiFolderDownloadOutline, | ||||||
|     mdiLink, |     mdiLink, | ||||||
|  |     mdiShareVariantOutline, | ||||||
|  |     mdiDeleteOutline, | ||||||
|   } from '@mdi/js'; |   } from '@mdi/js'; | ||||||
|  |   import { onMount } from 'svelte'; | ||||||
|  |   import { fly } from 'svelte/transition'; | ||||||
|  |   import ActivityViewer from '$lib/components/asset-viewer/activity-viewer.svelte'; | ||||||
|  |   import ActivityStatus from '$lib/components/asset-viewer/activity-status.svelte'; | ||||||
|  |   import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store'; | ||||||
| 
 | 
 | ||||||
|   export let data: PageData; |   export let data: PageData; | ||||||
| 
 | 
 | ||||||
| @ -77,6 +82,12 @@ | |||||||
|   let isCreatingSharedAlbum = false; |   let isCreatingSharedAlbum = false; | ||||||
|   let currentAlbumName = ''; |   let currentAlbumName = ''; | ||||||
|   let contextMenuPosition: { x: number; y: number } = { x: 0, y: 0 }; |   let contextMenuPosition: { x: number; y: number } = { x: 0, y: 0 }; | ||||||
|  |   let isShowActivity = false; | ||||||
|  |   let isLiked: ActivityResponseDto | null = null; | ||||||
|  |   let reactions: ActivityResponseDto[] = []; | ||||||
|  |   let user = data.user; | ||||||
|  |   let globalWidth: number; | ||||||
|  |   let assetGridWidth: number; | ||||||
| 
 | 
 | ||||||
|   const assetStore = new AssetStore({ albumId: album.id }); |   const assetStore = new AssetStore({ albumId: album.id }); | ||||||
|   const assetInteractionStore = createAssetInteractionStore(); |   const assetInteractionStore = createAssetInteractionStore(); | ||||||
| @ -89,6 +100,13 @@ | |||||||
|   $: isOwned = data.user.id == album.ownerId; |   $: isOwned = data.user.id == album.ownerId; | ||||||
|   $: isAllUserOwned = Array.from($selectedAssets).every((asset) => asset.ownerId === data.user.id); |   $: isAllUserOwned = Array.from($selectedAssets).every((asset) => asset.ownerId === data.user.id); | ||||||
|   $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite); |   $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite); | ||||||
|  |   $: { | ||||||
|  |     if (isShowActivity) { | ||||||
|  |       assetGridWidth = globalWidth - (globalWidth < 768 ? 360 : 460); | ||||||
|  |     } else { | ||||||
|  |       assetGridWidth = globalWidth; | ||||||
|  |     } | ||||||
|  |   } | ||||||
| 
 | 
 | ||||||
|   afterNavigate(({ from }) => { |   afterNavigate(({ from }) => { | ||||||
|     assetViewingStore.showAssetViewer(false); |     assetViewingStore.showAssetViewer(false); | ||||||
| @ -110,6 +128,63 @@ | |||||||
|     } |     } | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|  |   const handleFavorite = async () => { | ||||||
|  |     try { | ||||||
|  |       if (isLiked) { | ||||||
|  |         const activityId = isLiked.id; | ||||||
|  |         await api.activityApi.deleteActivity({ id: activityId }); | ||||||
|  |         reactions = reactions.filter((reaction) => reaction.id !== activityId); | ||||||
|  |         isLiked = null; | ||||||
|  |       } else { | ||||||
|  |         const { data } = await api.activityApi.createActivity({ | ||||||
|  |           activityCreateDto: { albumId: album.id, type: ReactionType.Like }, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         isLiked = data; | ||||||
|  |         reactions = [...reactions, isLiked]; | ||||||
|  |       } | ||||||
|  |     } catch (error) { | ||||||
|  |       handleError(error, "Can't change favorite for asset"); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const getFavorite = async () => { | ||||||
|  |     if (user) { | ||||||
|  |       try { | ||||||
|  |         const { data } = await api.activityApi.getActivities({ | ||||||
|  |           userId: user.id, | ||||||
|  |           albumId: album.id, | ||||||
|  |           type: ReactionType.Like, | ||||||
|  |         }); | ||||||
|  |         if (data.length > 0) { | ||||||
|  |           isLiked = data[0]; | ||||||
|  |         } | ||||||
|  |       } catch (error) { | ||||||
|  |         handleError(error, "Can't get Favorite"); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const getNumberOfComments = async () => { | ||||||
|  |     try { | ||||||
|  |       const { data } = await api.activityApi.getActivityStatistics({ albumId: album.id }); | ||||||
|  |       setNumberOfComments(data.comments); | ||||||
|  |     } catch (error) { | ||||||
|  |       handleError(error, "Can't get number of comments"); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const handleOpenAndCloseActivityTab = () => { | ||||||
|  |     isShowActivity = !isShowActivity; | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   onMount(async () => { | ||||||
|  |     if (album.sharedUsers.length > 0) { | ||||||
|  |       getFavorite(); | ||||||
|  |       getNumberOfComments(); | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|   const handleStartSlideshow = async () => { |   const handleStartSlideshow = async () => { | ||||||
|     const asset = $slideshowShuffle ? await assetStore.getRandomAsset() : assetStore.assets[0]; |     const asset = $slideshowShuffle ? await assetStore.getRandomAsset() : assetStore.assets[0]; | ||||||
|     if (asset) { |     if (asset) { | ||||||
| @ -321,7 +396,8 @@ | |||||||
|   }; |   }; | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <header> | <div class="flex overflow-hidden" bind:clientWidth={globalWidth}> | ||||||
|  |   <div class="relative w-full shrink"> | ||||||
|     {#if $isMultiSelectState} |     {#if $isMultiSelectState} | ||||||
|       <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}> |       <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}> | ||||||
|         <CreateSharedLink /> |         <CreateSharedLink /> | ||||||
| @ -404,7 +480,7 @@ | |||||||
|         <ControlAppBar on:close-button-click={handleCloseSelectAssets}> |         <ControlAppBar on:close-button-click={handleCloseSelectAssets}> | ||||||
|           <svelte:fragment slot="leading"> |           <svelte:fragment slot="leading"> | ||||||
|             <p class="text-lg dark:text-immich-dark-fg"> |             <p class="text-lg dark:text-immich-dark-fg"> | ||||||
|             {#if $timelineSelected.size == 0} |               {#if $timelineSelected.size === 0} | ||||||
|                 Add to album |                 Add to album | ||||||
|               {:else} |               {:else} | ||||||
|                 {$timelineSelected.size.toLocaleString($locale)} selected |                 {$timelineSelected.size.toLocaleString($locale)} selected | ||||||
| @ -419,7 +495,8 @@ | |||||||
|             > |             > | ||||||
|               Select from computer |               Select from computer | ||||||
|             </button> |             </button> | ||||||
|           <Button size="sm" rounded="lg" disabled={$timelineSelected.size === 0} on:click={handleAddAssets}>Done</Button |             <Button size="sm" rounded="lg" disabled={$timelineSelected.size === 0} on:click={handleAddAssets} | ||||||
|  |               >Done</Button | ||||||
|             > |             > | ||||||
|           </svelte:fragment> |           </svelte:fragment> | ||||||
|         </ControlAppBar> |         </ControlAppBar> | ||||||
| @ -431,11 +508,11 @@ | |||||||
|         </ControlAppBar> |         </ControlAppBar> | ||||||
|       {/if} |       {/if} | ||||||
|     {/if} |     {/if} | ||||||
| </header> |  | ||||||
| 
 | 
 | ||||||
| <main |     <main | ||||||
|       class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg sm:px-12 md:px-24 lg:px-40" |       class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg sm:px-12 md:px-24 lg:px-40" | ||||||
| > |       style={`width:${assetGridWidth}px`} | ||||||
|  |     > | ||||||
|       {#if viewMode === ViewMode.SELECT_ASSETS} |       {#if viewMode === ViewMode.SELECT_ASSETS} | ||||||
|         <AssetGrid |         <AssetGrid | ||||||
|           user={data.user} |           user={data.user} | ||||||
| @ -459,7 +536,7 @@ | |||||||
|             <!-- ALBUM TITLE --> |             <!-- ALBUM TITLE --> | ||||||
|             <section class="pt-24"> |             <section class="pt-24"> | ||||||
|               <input |               <input | ||||||
|             on:keydown={(e) => e.key == 'Enter' && titleInput.blur()} |                 on:keydown={(e) => e.key === 'Enter' && titleInput.blur()} | ||||||
|                 on:blur={handleUpdateName} |                 on:blur={handleUpdateName} | ||||||
|                 class="w-[99%] border-b-2 border-transparent text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned |                 class="w-[99%] border-b-2 border-transparent text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned | ||||||
|                   ? 'hover:border-gray-400' |                   ? 'hover:border-gray-400' | ||||||
| @ -552,8 +629,42 @@ | |||||||
|           {/if} |           {/if} | ||||||
|         </AssetGrid> |         </AssetGrid> | ||||||
|       {/if} |       {/if} | ||||||
| </main> |  | ||||||
| 
 | 
 | ||||||
|  |       {#if album.sharedUsers.length > 0 && !$showAssetViewer} | ||||||
|  |         <div class="absolute z-[2] bottom-0 right-0 mb-6 mr-6 justify-self-end"> | ||||||
|  |           <ActivityStatus | ||||||
|  |             {isLiked} | ||||||
|  |             numberOfComments={$numberOfComments} | ||||||
|  |             {isShowActivity} | ||||||
|  |             on:favorite={handleFavorite} | ||||||
|  |             on:openActivityTab={handleOpenAndCloseActivityTab} | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |       {/if} | ||||||
|  |     </main> | ||||||
|  |   </div> | ||||||
|  |   {#if album.sharedUsers.length > 0 && album && isShowActivity && user && !$showAssetViewer} | ||||||
|  |     <div class="flex"> | ||||||
|  |       <div | ||||||
|  |         transition:fly={{ duration: 150 }} | ||||||
|  |         id="activity-panel" | ||||||
|  |         class="z-[1002] w-[360px] md:w-[460px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg pl-4" | ||||||
|  |         translate="yes" | ||||||
|  |       > | ||||||
|  |         <ActivityViewer | ||||||
|  |           {user} | ||||||
|  |           albumOwnerId={album.ownerId} | ||||||
|  |           albumId={album.id} | ||||||
|  |           bind:reactions | ||||||
|  |           on:addComment={() => updateNumberOfComments(1)} | ||||||
|  |           on:deleteComment={() => updateNumberOfComments(-1)} | ||||||
|  |           on:deleteLike={() => (isLiked = null)} | ||||||
|  |           on:close={handleOpenAndCloseActivityTab} | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   {/if} | ||||||
|  | </div> | ||||||
| {#if viewMode === ViewMode.SELECT_USERS} | {#if viewMode === ViewMode.SELECT_USERS} | ||||||
|   <UserSelectionModal |   <UserSelectionModal | ||||||
|     {album} |     {album} | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user