merge main

This commit is contained in:
martabal 2023-11-05 18:32:07 +01:00
parent 6faa597aaf
commit ee4120c5f7
No known key found for this signature in database
GPG key ID: C00196E3148A52BD
12 changed files with 475 additions and 275 deletions

View file

@ -40,7 +40,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
progressInPercentage: 0,
cancelToken: CancellationToken(),
autoBackup: Store.get(StoreKey.autoBackup, false),
backgroundBackup: false,
backgroundBackup: Store.get(StoreKey.backgroundBackup, false),
backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true),
backupRequireCharging:
Store.get(StoreKey.backupRequireCharging, false),
@ -171,6 +171,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state.backupRequireCharging,
);
await Store.put(StoreKey.backupTriggerDelay, state.backupTriggerDelay);
await Store.put(StoreKey.backgroundBackup, state.backgroundBackup);
} else {
state = state.copyWith(
backgroundBackup: wasEnabled,
@ -383,6 +384,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final isEnabled = await _backgroundService.isBackgroundBackupEnabled();
state = state.copyWith(backgroundBackup: isEnabled);
if (isEnabled != Store.get(StoreKey.backgroundBackup, !isEnabled)) {
Store.put(StoreKey.backgroundBackup, isEnabled);
}
if (state.backupProgress != BackUpProgressEnum.inBackground) {
await _getBackupAlbumsInfo();

View file

@ -156,6 +156,7 @@ enum StoreKey<T> {
accessToken<String>(11, type: String),
serverEndpoint<String>(12, type: String),
autoBackup<bool>(13, type: bool),
backgroundBackup<bool>(14, type: bool),
// user settings from [AppSettingsEnum] below:
loadPreview<bool>(100, type: bool),
loadOriginal<bool>(101, type: bool),

View file

@ -1,4 +1,4 @@
import { Colorspace, SystemConfigKey } from '@app/infra/entities';
import { AssetFaceEntity, Colorspace, SystemConfigKey } from '@app/infra/entities';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import {
IAccessRepositoryMock,
@ -449,6 +449,23 @@ describe(PersonService.name, () => {
expect(machineLearningMock.detectFaces).not.toHaveBeenCalled();
});
it('should skip it the asset has already been processed', async () => {
assetMock.getByIds.mockResolvedValue([
{
...assetStub.noResizePath,
faces: [
{
id: 'asset-face-1',
assetId: assetStub.noResizePath.id,
personId: faceStub.face1.personId,
} as AssetFaceEntity,
],
},
]);
await sut.handleRecognizeFaces({ id: assetStub.noResizePath.id });
expect(machineLearningMock.detectFaces).not.toHaveBeenCalled();
});
it('should handle no results', async () => {
machineLearningMock.detectFaces.mockResolvedValue([]);
assetMock.getByIds.mockResolvedValue([assetStub.image]);

View file

@ -217,7 +217,7 @@ export class PersonService {
}
const [asset] = await this.assetRepository.getByIds([id]);
if (!asset || !asset.resizePath) {
if (!asset || !asset.resizePath || asset.faces?.length > 0) {
return false;
}

View 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>

View file

@ -1,9 +1,9 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher, onMount } from 'svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import { mdiClose, mdiHeart, mdiSend, mdiDotsVertical } from '@mdi/js';
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 { isTenMinutesApart } from '$lib/utils/timesince';
import { clickOutside } from '$lib/utils/click-outside';
@ -15,6 +15,13 @@
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 diff = dateTime.diffNow().shiftTo(...units);
const unit = units.find((unit) => diff.get(unit) !== 0) || 'second';
@ -27,9 +34,9 @@
export let reactions: ActivityResponseDto[];
export let user: UserResponseDto;
export let assetId: string;
export let assetId: string | undefined = undefined;
export let albumId: string;
export let assetType: AssetTypeEnum;
export let assetType: AssetTypeEnum | undefined = undefined;
export let albumOwnerId: string;
let textArea: HTMLTextAreaElement;
@ -37,7 +44,7 @@
let activityHeight: number;
let chatHeight: number;
let divHeight: number;
let previousAssetId: string | null;
let previousAssetId: string | undefined = assetId;
let message = '';
let isSendingMessage = false;
@ -51,11 +58,14 @@
}
$: {
if (previousAssetId != assetId) {
if (assetId && previousAssetId != assetId) {
getReactions();
previousAssetId = assetId;
}
}
onMount(async () => {
await getReactions();
});
const getReactions = async () => {
try {
@ -161,11 +171,20 @@
{#each reactions as reaction, index (reaction.id)}
{#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>
<div class="flex items-center">
<UserAvatar user={reaction.user} size="sm" />
</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}
<div class="flex items-start w-fit pt-[5px]" title="Delete comment">
<button on:click={() => (!showDeleteReaction[index] ? showOptionsMenu(index) : '')}>
@ -176,17 +195,18 @@
<div>
{#if showDeleteReaction[index]}
<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
on:outclick={() => (showDeleteReaction[index] = false)}
on:click={() => handleDeleteReaction(reaction, index)}
>
Delete
Remove
</button>
{/if}
</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
class=" px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
title={new Date(reaction.createdAt).toLocaleDateString(undefined, timeOptions)}
@ -196,17 +216,26 @@
{/if}
{:else if reaction.type === 'like'}
<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="w-full"
title={`${reaction.user.firstName} ${reaction.user.lastName} (${reaction.user.email})`}
>
{`${reaction.user.firstName} ${reaction.user.lastName} liked this ${getAssetType(
assetType,
).toLowerCase()}`}
{`${reaction.user.firstName} ${reaction.user.lastName} liked ${
assetType ? `this ${getAssetType(assetType).toLowerCase()}` : 'it'
}`}
</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}
<div class="flex items-start w-fit" title="Delete like">
<button on:click={() => (!showDeleteReaction[index] ? showOptionsMenu(index) : '')}>
@ -217,12 +246,12 @@
<div>
{#if showDeleteReaction[index]}
<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
on:outclick={() => (showDeleteReaction[index] = false)}
on:click={() => handleDeleteReaction(reaction, index)}
>
Delete Like
Remove
</button>
{/if}
</div>
@ -266,8 +295,8 @@
</div>
</div>
{:else if message}
<div class="flex items-end w-fit ml-0 text-immich-primary dark:text-white">
<CircleIconButton size="15" icon={mdiSend} />
<div class="flex items-end w-fit ml-0">
<CircleIconButton size="15" icon={mdiSend} iconColor={'dark'} hoverColor={'rgb(173,203,250)'} />
</div>
{/if}
</form>

View file

@ -33,18 +33,13 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { featureFlags } from '$lib/stores/server-config.store';
import {
mdiHeartOutline,
mdiHeart,
mdiCommentOutline,
mdiChevronLeft,
mdiChevronRight,
mdiImageBrokenVariant,
} from '@mdi/js';
import { mdiChevronLeft, mdiChevronRight, mdiImageBrokenVariant } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import { stackAssetsStore } from '$lib/stores/stacked-asset.store';
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 SlideshowBar from './slideshow-bar.svelte';
@ -55,7 +50,7 @@
$: isTrashEnabled = $featureFlags.trash;
export let force = false;
export let withStacked = false;
export let isShared = true;
export let isShared = false;
export let user: UserResponseDto | 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 () => {
if (album) {
try {
@ -658,25 +663,13 @@
{/if}
{#if $slideshowState === SlideshowState.None && isShared}
<div class="z-[9999] absolute bottom-0 right-0 mb-6 mr-6 justify-self-end">
<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={handleFavorite}>
<div class="items-center justify-center">
<Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} />
</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>
<ActivityStatus
{isLiked}
{numberOfComments}
{isShowActivity}
on:favorite={handleFavorite}
on:openActivityTab={handleOpenActivity}
/>
</div>
{/if}
{/key}
@ -746,7 +739,7 @@
<div
transition:fly={{ duration: 150 }}
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"
>
<ActivityViewer
@ -756,8 +749,8 @@
albumId={album.id}
assetId={asset.id}
bind:reactions
on:addComment={() => numberOfComments++}
on:deleteComment={() => numberOfComments--}
on:addComment={handleAddComment}
on:deleteComment={handleRemoveComment}
on:deleteLike={() => (isLiked = null)}
on:close={() => (isShowActivity = false)}
/>

View file

@ -10,6 +10,7 @@
export let isOpacity = false;
export let forceDark = false;
export let hideMobile = false;
export let iconColor = 'currentColor';
</script>
<button
@ -23,7 +24,7 @@
{hideMobile && 'hidden sm:flex'}"
on:click
>
<Icon path={icon} {size} />
<Icon path={icon} {size} color={iconColor} />
<slot />
</button>

View file

@ -40,7 +40,7 @@
});
</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
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 ${

View file

@ -93,7 +93,7 @@
{#if $assetStore.timelineHeight > height}
<div
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:height={height + 'px'}
style:background-color={isDragging ? 'transparent' : 'transparent'}

View 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));
};

View file

@ -35,7 +35,7 @@
import { downloadArchive } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
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 type { PageData } from './$types';
import { clickOutside } from '$lib/utils/click-outside';
@ -45,11 +45,16 @@
mdiDotsVertical,
mdiArrowLeft,
mdiFileImagePlusOutline,
mdiShareVariantOutline,
mdiDeleteOutline,
mdiFolderDownloadOutline,
mdiLink,
mdiShareVariantOutline,
mdiDeleteOutline,
} 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;
@ -77,6 +82,12 @@
let isCreatingSharedAlbum = false;
let currentAlbumName = '';
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 assetInteractionStore = createAssetInteractionStore();
@ -89,6 +100,13 @@
$: isOwned = data.user.id == album.ownerId;
$: isAllUserOwned = Array.from($selectedAssets).every((asset) => asset.ownerId === data.user.id);
$: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite);
$: {
if (isShowActivity) {
assetGridWidth = globalWidth - (globalWidth < 768 ? 360 : 460);
} else {
assetGridWidth = globalWidth;
}
}
afterNavigate(({ from }) => {
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 asset = $slideshowShuffle ? await assetStore.getRandomAsset() : assetStore.assets[0];
if (asset) {
@ -321,239 +396,275 @@
};
</script>
<header>
{#if $isMultiSelectState}
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
<CreateSharedLink />
<SelectAllAssets {assetStore} {assetInteractionStore} />
<AssetSelectContextMenu icon={mdiPlus} title="Add">
<AddToAlbum />
<AddToAlbum shared />
</AssetSelectContextMenu>
<AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
{#if isAllUserOwned}
<FavoriteAction menuItem removeFavorite={isAllFavorite} />
{/if}
<ArchiveAction menuItem />
<DownloadAction menuItem filename="{album.albumName}.zip" />
{#if isOwned || isAllUserOwned}
<RemoveFromAlbum menuItem bind:album onRemove={(assetIds) => handleRemoveAssets(assetIds)} />
{/if}
{#if isAllUserOwned}
<DeleteAssets menuItem onAssetDelete={(assetId) => assetStore.removeAsset(assetId)} />
{/if}
</AssetSelectContextMenu>
</AssetSelectControlBar>
{:else}
{#if viewMode === ViewMode.VIEW || viewMode === ViewMode.ALBUM_OPTIONS}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close-button-click={() => goto(backUrl)}>
<svelte:fragment slot="trailing">
<CircleIconButton
title="Add Photos"
on:click={() => (viewMode = ViewMode.SELECT_ASSETS)}
icon={mdiFileImagePlusOutline}
/>
{#if isOwned}
<CircleIconButton
title="Share"
on:click={() => (viewMode = ViewMode.SELECT_USERS)}
icon={mdiShareVariantOutline}
/>
<CircleIconButton
title="Delete album"
on:click={() => (viewMode = ViewMode.CONFIRM_DELETE)}
icon={mdiDeleteOutline}
/>
<div class="flex overflow-hidden" bind:clientWidth={globalWidth}>
<div class="relative w-full shrink">
{#if $isMultiSelectState}
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
<CreateSharedLink />
<SelectAllAssets {assetStore} {assetInteractionStore} />
<AssetSelectContextMenu icon={mdiPlus} title="Add">
<AddToAlbum />
<AddToAlbum shared />
</AssetSelectContextMenu>
<AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
{#if isAllUserOwned}
<FavoriteAction menuItem removeFavorite={isAllFavorite} />
{/if}
{#if album.assetCount > 0}
<CircleIconButton title="Download" on:click={handleDownloadAlbum} icon={mdiFolderDownloadOutline} />
<ArchiveAction menuItem />
<DownloadAction menuItem filename="{album.albumName}.zip" />
{#if isOwned || isAllUserOwned}
<RemoveFromAlbum menuItem bind:album onRemove={(assetIds) => handleRemoveAssets(assetIds)} />
{/if}
{#if isAllUserOwned}
<DeleteAssets menuItem onAssetDelete={(assetId) => assetStore.removeAsset(assetId)} />
{/if}
</AssetSelectContextMenu>
</AssetSelectControlBar>
{:else}
{#if viewMode === ViewMode.VIEW || viewMode === ViewMode.ALBUM_OPTIONS}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close-button-click={() => goto(backUrl)}>
<svelte:fragment slot="trailing">
<CircleIconButton
title="Add Photos"
on:click={() => (viewMode = ViewMode.SELECT_ASSETS)}
icon={mdiFileImagePlusOutline}
/>
{#if isOwned}
<div use:clickOutside on:outclick={() => (viewMode = ViewMode.VIEW)}>
<CircleIconButton title="Album options" on:click={handleOpenAlbumOptions} icon={mdiDotsVertical}>
{#if viewMode === ViewMode.ALBUM_OPTIONS}
<ContextMenu {...contextMenuPosition}>
{#if album.assetCount !== 0}
<MenuOption on:click={handleStartSlideshow} text="Slideshow" />
{/if}
<MenuOption on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" />
</ContextMenu>
{/if}
</CircleIconButton>
</div>
<CircleIconButton
title="Share"
on:click={() => (viewMode = ViewMode.SELECT_USERS)}
icon={mdiShareVariantOutline}
/>
<CircleIconButton
title="Delete album"
on:click={() => (viewMode = ViewMode.CONFIRM_DELETE)}
icon={mdiDeleteOutline}
/>
{/if}
{/if}
{#if isCreatingSharedAlbum && album.sharedUsers.length === 0}
<Button
size="sm"
rounded="lg"
disabled={album.assetCount == 0}
on:click={() => (viewMode = ViewMode.SELECT_USERS)}
>
Share
</Button>
{/if}
</svelte:fragment>
</ControlAppBar>
{/if}
{#if viewMode === ViewMode.SELECT_ASSETS}
<ControlAppBar on:close-button-click={handleCloseSelectAssets}>
<svelte:fragment slot="leading">
<p class="text-lg dark:text-immich-dark-fg">
{#if $timelineSelected.size == 0}
Add to album
{:else}
{$timelineSelected.size.toLocaleString($locale)} selected
{/if}
</p>
</svelte:fragment>
<svelte:fragment slot="trailing">
<button
on:click={handleSelectFromComputer}
class="rounded-lg px-6 py-2 text-sm font-medium text-immich-primary transition-all hover:bg-immich-primary/10 dark:text-immich-dark-primary dark:hover:bg-immich-dark-primary/25"
>
Select from computer
</button>
<Button size="sm" rounded="lg" disabled={$timelineSelected.size === 0} on:click={handleAddAssets}>Done</Button
>
</svelte:fragment>
</ControlAppBar>
{/if}
{#if viewMode === ViewMode.SELECT_THUMBNAIL}
<ControlAppBar on:close-button-click={() => (viewMode = ViewMode.VIEW)}>
<svelte:fragment slot="leading">Select Album Cover</svelte:fragment>
</ControlAppBar>
{/if}
{/if}
</header>
<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"
>
{#if viewMode === ViewMode.SELECT_ASSETS}
<AssetGrid
user={data.user}
assetStore={timelineStore}
assetInteractionStore={timelineInteractionStore}
isSelectionMode={true}
/>
{:else}
<AssetGrid
{album}
user={data.user}
{assetStore}
{assetInteractionStore}
isShared={album.sharedUsers.length > 0}
isSelectionMode={viewMode === ViewMode.SELECT_THUMBNAIL}
singleSelect={viewMode === ViewMode.SELECT_THUMBNAIL}
on:select={({ detail: asset }) => handleUpdateThumbnail(asset.id)}
on:escape={handleEscape}
>
{#if viewMode !== ViewMode.SELECT_THUMBNAIL}
<!-- ALBUM TITLE -->
<section class="pt-24">
<input
on:keydown={(e) => e.key == 'Enter' && titleInput.blur()}
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
? 'hover:border-gray-400'
: 'hover:border-transparent'} bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray"
type="text"
bind:value={album.albumName}
disabled={!isOwned}
bind:this={titleInput}
title="Edit Title"
/>
<!-- ALBUM SUMMARY -->
{#if album.assetCount > 0}
<span class="my-4 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
<p class="">{getDateRange()}</p>
<p>·</p>
<p>{album.assetCount} items</p>
</span>
{/if}
<!-- ALBUM SHARING -->
{#if album.sharedUsers.length > 0 || (album.hasSharedLink && isOwned)}
<div class="my-6 flex gap-x-1">
<!-- link -->
{#if album.hasSharedLink && isOwned}
<CircleIconButton
backgroundColor="#d3d3d3"
forceDark
size="20"
icon={mdiLink}
on:click={() => (viewMode = ViewMode.LINK_SHARING)}
/>
{/if}
<!-- owner -->
<button on:click={() => (viewMode = ViewMode.VIEW_USERS)}>
<UserAvatar user={album.owner} size="md" />
</button>
<!-- users -->
{#each album.sharedUsers as user (user.id)}
<button on:click={() => (viewMode = ViewMode.VIEW_USERS)}>
<UserAvatar {user} size="md" />
</button>
{/each}
{#if album.assetCount > 0}
<CircleIconButton title="Download" on:click={handleDownloadAlbum} icon={mdiFolderDownloadOutline} />
{#if isOwned}
<CircleIconButton
backgroundColor="#d3d3d3"
forceDark
size="20"
icon={mdiPlus}
on:click={() => (viewMode = ViewMode.SELECT_USERS)}
title="Add more users"
/>
<div use:clickOutside on:outclick={() => (viewMode = ViewMode.VIEW)}>
<CircleIconButton title="Album options" on:click={handleOpenAlbumOptions} icon={mdiDotsVertical}>
{#if viewMode === ViewMode.ALBUM_OPTIONS}
<ContextMenu {...contextMenuPosition}>
{#if album.assetCount !== 0}
<MenuOption on:click={handleStartSlideshow} text="Slideshow" />
{/if}
<MenuOption on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" />
</ContextMenu>
{/if}
</CircleIconButton>
</div>
{/if}
</div>
{/if}
{/if}
<!-- ALBUM DESCRIPTION -->
{#if isOwned || album.description}
<button
class="mb-12 mt-6 w-full border-b-2 border-transparent pb-2 text-left text-lg font-medium transition-colors hover:border-b-2 dark:text-gray-300"
on:click={() => (isEditingDescription = true)}
class:hover:border-gray-400={isOwned}
disabled={!isOwned}
title="Edit description"
>
{album.description || 'Add description'}
</button>
{/if}
</section>
{#if isCreatingSharedAlbum && album.sharedUsers.length === 0}
<Button
size="sm"
rounded="lg"
disabled={album.assetCount == 0}
on:click={() => (viewMode = ViewMode.SELECT_USERS)}
>
Share
</Button>
{/if}
</svelte:fragment>
</ControlAppBar>
{/if}
{#if album.assetCount === 0}
<section id="empty-album" class=" mt-[200px] flex place-content-center place-items-center">
<div class="w-[300px]">
<p class="text-xs dark:text-immich-dark-fg">ADD PHOTOS</p>
{#if viewMode === ViewMode.SELECT_ASSETS}
<ControlAppBar on:close-button-click={handleCloseSelectAssets}>
<svelte:fragment slot="leading">
<p class="text-lg dark:text-immich-dark-fg">
{#if $timelineSelected.size === 0}
Add to album
{:else}
{$timelineSelected.size.toLocaleString($locale)} selected
{/if}
</p>
</svelte:fragment>
<svelte:fragment slot="trailing">
<button
on:click={() => (viewMode = ViewMode.SELECT_ASSETS)}
class="mt-5 flex w-full place-items-center gap-6 rounded-md border bg-immich-bg px-8 py-8 text-immich-fg transition-all hover:bg-gray-100 hover:text-immich-primary dark:border-none dark:bg-immich-dark-gray dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
on:click={handleSelectFromComputer}
class="rounded-lg px-6 py-2 text-sm font-medium text-immich-primary transition-all hover:bg-immich-primary/10 dark:text-immich-dark-primary dark:hover:bg-immich-dark-primary/25"
>
<span class="text-text-immich-primary dark:text-immich-dark-primary"
><Icon path={mdiPlus} size="24" />
</span>
<span class="text-lg">Select photos</span>
Select from computer
</button>
</div>
</section>
<Button size="sm" rounded="lg" disabled={$timelineSelected.size === 0} on:click={handleAddAssets}
>Done</Button
>
</svelte:fragment>
</ControlAppBar>
{/if}
</AssetGrid>
{#if viewMode === ViewMode.SELECT_THUMBNAIL}
<ControlAppBar on:close-button-click={() => (viewMode = ViewMode.VIEW)}>
<svelte:fragment slot="leading">Select Album Cover</svelte:fragment>
</ControlAppBar>
{/if}
{/if}
<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"
style={`width:${assetGridWidth}px`}
>
{#if viewMode === ViewMode.SELECT_ASSETS}
<AssetGrid
user={data.user}
assetStore={timelineStore}
assetInteractionStore={timelineInteractionStore}
isSelectionMode={true}
/>
{:else}
<AssetGrid
{album}
user={data.user}
{assetStore}
{assetInteractionStore}
isShared={album.sharedUsers.length > 0}
isSelectionMode={viewMode === ViewMode.SELECT_THUMBNAIL}
singleSelect={viewMode === ViewMode.SELECT_THUMBNAIL}
on:select={({ detail: asset }) => handleUpdateThumbnail(asset.id)}
on:escape={handleEscape}
>
{#if viewMode !== ViewMode.SELECT_THUMBNAIL}
<!-- ALBUM TITLE -->
<section class="pt-24">
<input
on:keydown={(e) => e.key === 'Enter' && titleInput.blur()}
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
? 'hover:border-gray-400'
: 'hover:border-transparent'} bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray"
type="text"
bind:value={album.albumName}
disabled={!isOwned}
bind:this={titleInput}
title="Edit Title"
/>
<!-- ALBUM SUMMARY -->
{#if album.assetCount > 0}
<span class="my-4 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
<p class="">{getDateRange()}</p>
<p>·</p>
<p>{album.assetCount} items</p>
</span>
{/if}
<!-- ALBUM SHARING -->
{#if album.sharedUsers.length > 0 || (album.hasSharedLink && isOwned)}
<div class="my-6 flex gap-x-1">
<!-- link -->
{#if album.hasSharedLink && isOwned}
<CircleIconButton
backgroundColor="#d3d3d3"
forceDark
size="20"
icon={mdiLink}
on:click={() => (viewMode = ViewMode.LINK_SHARING)}
/>
{/if}
<!-- owner -->
<button on:click={() => (viewMode = ViewMode.VIEW_USERS)}>
<UserAvatar user={album.owner} size="md" />
</button>
<!-- users -->
{#each album.sharedUsers as user (user.id)}
<button on:click={() => (viewMode = ViewMode.VIEW_USERS)}>
<UserAvatar {user} size="md" />
</button>
{/each}
{#if isOwned}
<CircleIconButton
backgroundColor="#d3d3d3"
forceDark
size="20"
icon={mdiPlus}
on:click={() => (viewMode = ViewMode.SELECT_USERS)}
title="Add more users"
/>
{/if}
</div>
{/if}
<!-- ALBUM DESCRIPTION -->
{#if isOwned || album.description}
<button
class="mb-12 mt-6 w-full border-b-2 border-transparent pb-2 text-left text-lg font-medium transition-colors hover:border-b-2 dark:text-gray-300"
on:click={() => (isEditingDescription = true)}
class:hover:border-gray-400={isOwned}
disabled={!isOwned}
title="Edit description"
>
{album.description || 'Add description'}
</button>
{/if}
</section>
{/if}
{#if album.assetCount === 0}
<section id="empty-album" class=" mt-[200px] flex place-content-center place-items-center">
<div class="w-[300px]">
<p class="text-xs dark:text-immich-dark-fg">ADD PHOTOS</p>
<button
on:click={() => (viewMode = ViewMode.SELECT_ASSETS)}
class="mt-5 flex w-full place-items-center gap-6 rounded-md border bg-immich-bg px-8 py-8 text-immich-fg transition-all hover:bg-gray-100 hover:text-immich-primary dark:border-none dark:bg-immich-dark-gray dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
>
<span class="text-text-immich-primary dark:text-immich-dark-primary"
><Icon path={mdiPlus} size="24" />
</span>
<span class="text-lg">Select photos</span>
</button>
</div>
</section>
{/if}
</AssetGrid>
{/if}
{#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}
</main>
</div>
{#if viewMode === ViewMode.SELECT_USERS}
<UserSelectionModal
{album}