mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
feat(web,server): activity (#4682)
* feat: activity * regenerate api * fix: make asset owner unable to delete comment * fix: merge * fix: tests * feat: use textarea instead of input * fix: do actions only if the album is shared * fix: placeholder opacity * fix(web): improve messages UI * fix(web): improve input message UI * pr feedback * fix: tests * pr feedback * pr feedback * pr feedback * fix permissions * regenerate api * pr feedback * pr feedback * multiple improvements on web * fix: ui colors * WIP * chore: open api * pr feedback * fix: add comment * chore: clean up * pr feedback * refactor: endpoints * chore: open api * fix: filter by type * fix: e2e * feat: e2e remove own comment * fix: web tests * remove console.log * chore: cleanup * fix: ui tweaks * pr feedback * fix web test * fix: unit tests * chore: remove unused code * revert useless changes * fix: grouping messages * fix: remove nullable on updatedAt * fix: text overflow * styling --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
68f6446718
commit
ce5966c23d
66 changed files with 4487 additions and 38 deletions
|
|
@ -5,7 +5,7 @@
|
|||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import type { AlbumResponseDto, SharedLinkResponseDto } from '@api';
|
||||
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@api';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { dateFormats } from '../../constants';
|
||||
import { createAssetInteractionStore } from '../../stores/asset-interaction.store';
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
|
||||
|
||||
export let sharedLink: SharedLinkResponseDto;
|
||||
export let user: UserResponseDto | undefined = undefined;
|
||||
|
||||
const album = sharedLink.album as AlbumResponseDto;
|
||||
|
||||
|
|
@ -138,7 +139,7 @@
|
|||
<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"
|
||||
>
|
||||
<AssetGrid {assetStore} {assetInteractionStore}>
|
||||
<AssetGrid {album} {user} {assetStore} {assetInteractionStore}>
|
||||
<section class="pt-24">
|
||||
<!-- ALBUM TITLE -->
|
||||
<p
|
||||
|
|
|
|||
289
web/src/lib/components/asset-viewer/activity-viewer.svelte
Normal file
289
web/src/lib/components/asset-viewer/activity-viewer.svelte
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } 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 { handleError } from '$lib/utils/handle-error';
|
||||
import { isTenMinutesApart } from '$lib/utils/timesince';
|
||||
import { clickOutside } from '$lib/utils/click-outside';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
||||
import { getAssetType } from '$lib/utils/asset-utils';
|
||||
import * as luxon from 'luxon';
|
||||
|
||||
const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
|
||||
|
||||
const timeSince = (dateTime: luxon.DateTime) => {
|
||||
const diff = dateTime.diffNow().shiftTo(...units);
|
||||
const unit = units.find((unit) => diff.get(unit) !== 0) || 'second';
|
||||
|
||||
const relativeFormatter = new Intl.RelativeTimeFormat('en', {
|
||||
numeric: 'auto',
|
||||
});
|
||||
return relativeFormatter.format(Math.trunc(diff.as(unit)), unit);
|
||||
};
|
||||
|
||||
export let reactions: ActivityResponseDto[];
|
||||
export let user: UserResponseDto;
|
||||
export let assetId: string;
|
||||
export let albumId: string;
|
||||
export let assetType: AssetTypeEnum;
|
||||
export let albumOwnerId: string;
|
||||
|
||||
let textArea: HTMLTextAreaElement;
|
||||
let innerHeight: number;
|
||||
let activityHeight: number;
|
||||
let chatHeight: number;
|
||||
let divHeight: number;
|
||||
let previousAssetId: string | null;
|
||||
let message = '';
|
||||
let isSendingMessage = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
$: showDeleteReaction = Array(reactions.length).fill(false);
|
||||
$: {
|
||||
if (innerHeight && activityHeight) {
|
||||
divHeight = innerHeight - activityHeight;
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if (previousAssetId != assetId) {
|
||||
getReactions();
|
||||
previousAssetId = assetId;
|
||||
}
|
||||
}
|
||||
|
||||
const getReactions = async () => {
|
||||
try {
|
||||
const { data } = await api.activityApi.getActivities({ assetId, albumId });
|
||||
reactions = data;
|
||||
} catch (error) {
|
||||
handleError(error, 'Error when fetching reactions');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnter = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleSendComment();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const autoGrow = () => {
|
||||
textArea.style.height = '5px';
|
||||
textArea.style.height = textArea.scrollHeight + 'px';
|
||||
};
|
||||
|
||||
const timeOptions = {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
} as Intl.DateTimeFormatOptions;
|
||||
|
||||
const handleDeleteReaction = async (reaction: ActivityResponseDto, index: number) => {
|
||||
try {
|
||||
await api.activityApi.deleteActivity({ id: reaction.id });
|
||||
reactions.splice(index, 1);
|
||||
showDeleteReaction.splice(index, 1);
|
||||
reactions = reactions;
|
||||
if (reaction.type === 'like' && reaction.user.id === user.id) {
|
||||
dispatch('deleteLike');
|
||||
} else {
|
||||
dispatch('deleteComment');
|
||||
}
|
||||
notificationController.show({
|
||||
message: `${reaction.type} deleted`,
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, `Can't remove ${reaction.type}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendComment = async () => {
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => (isSendingMessage = true), 100);
|
||||
try {
|
||||
const { data } = await api.activityApi.createActivity({
|
||||
activityCreateDto: { albumId, assetId, type: ReactionType.Comment, comment: message },
|
||||
});
|
||||
reactions.push(data);
|
||||
textArea.style.height = '18px';
|
||||
message = '';
|
||||
dispatch('addComment');
|
||||
// Re-render the activity feed
|
||||
reactions = reactions;
|
||||
} catch (error) {
|
||||
handleError(error, "Can't add your comment");
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
isSendingMessage = false;
|
||||
};
|
||||
|
||||
const showOptionsMenu = (index: number) => {
|
||||
showDeleteReaction[index] = !showDeleteReaction[index];
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="overflow-y-hidden relative h-full" bind:offsetHeight={innerHeight}>
|
||||
<div class="dark:bg-immich-dark-bg dark:text-immich-dark-fg w-full h-full">
|
||||
<div
|
||||
class="flex w-full h-fit dark:bg-immich-dark-bg dark:text-immich-dark-fg p-2 bg-white"
|
||||
bind:clientHeight={activityHeight}
|
||||
>
|
||||
<div class="flex place-items-center gap-2">
|
||||
<button
|
||||
class="flex place-content-center place-items-center rounded-full p-3 transition-colors hover:bg-gray-200 dark:text-immich-dark-fg dark:hover:bg-gray-900"
|
||||
on:click={() => dispatch('close')}
|
||||
>
|
||||
<Icon path={mdiClose} size="24" />
|
||||
</button>
|
||||
|
||||
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">Activity</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if innerHeight}
|
||||
<div
|
||||
class="overflow-y-auto immich-scrollbar relative w-full"
|
||||
style="height: {divHeight}px;padding-bottom: {chatHeight}px"
|
||||
>
|
||||
{#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>
|
||||
<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 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) : '')}>
|
||||
<Icon path={mdiDotsVertical} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<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"
|
||||
use:clickOutside
|
||||
on:outclick={() => (showDeleteReaction[index] = false)}
|
||||
on:click={() => handleDeleteReaction(reaction, index)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if (index != reactions.length - 1 && isTenMinutesApart(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)}
|
||||
>
|
||||
{timeSince(luxon.DateTime.fromISO(reaction.createdAt))}
|
||||
</div>
|
||||
{/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="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()}`}
|
||||
</div>
|
||||
{#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) : '')}>
|
||||
<Icon path={mdiDotsVertical} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<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"
|
||||
use:clickOutside
|
||||
on:outclick={() => (showDeleteReaction[index] = false)}
|
||||
on:click={() => handleDeleteReaction(reaction, index)}
|
||||
>
|
||||
Delete Like
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if (index != reactions.length - 1 && isTenMinutesApart(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(navigator.language, timeOptions)}
|
||||
>
|
||||
{timeSince(luxon.DateTime.fromISO(reaction.createdAt))}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="absolute w-full bottom-0">
|
||||
<div class="flex items-center justify-center p-2 mr-2" bind:clientHeight={chatHeight}>
|
||||
<div class="flex p-2 gap-4 h-fit bg-gray-200 text-immich-dark-gray rounded-3xl w-full">
|
||||
<div>
|
||||
<UserAvatar {user} size="md" showTitle={false} />
|
||||
</div>
|
||||
<form class="flex w-full max-h-56 gap-1" on:submit|preventDefault={() => handleSendComment()}>
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<textarea
|
||||
bind:this={textArea}
|
||||
bind:value={message}
|
||||
placeholder="Say something"
|
||||
on:input={autoGrow}
|
||||
on:keypress={handleEnter}
|
||||
class="h-[18px] w-full max-h-56 pr-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200"
|
||||
/>
|
||||
</div>
|
||||
{#if isSendingMessage}
|
||||
<div class="flex items-end place-items-center pb-2 ml-0">
|
||||
<div class="flex w-full place-items-center">
|
||||
<LoadingSpinner />
|
||||
</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>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
::placeholder {
|
||||
color: rgb(60, 60, 60);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
::-ms-input-placeholder {
|
||||
/* Edge 12 -18 */
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,6 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { AlbumResponseDto, api, AssetJobName, AssetResponseDto, AssetTypeEnum, SharedLinkResponseDto } from '@api';
|
||||
import {
|
||||
ActivityResponseDto,
|
||||
AlbumResponseDto,
|
||||
api,
|
||||
AssetJobName,
|
||||
AssetResponseDto,
|
||||
AssetTypeEnum,
|
||||
ReactionType,
|
||||
SharedLinkResponseDto,
|
||||
UserResponseDto,
|
||||
} from '@api';
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte';
|
||||
|
|
@ -14,7 +24,7 @@
|
|||
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
|
||||
import ProfileImageCropper from '../shared-components/profile-image-cropper.svelte';
|
||||
import { isShowDetail } from '$lib/stores/preferences.store';
|
||||
import { addAssetsToAlbum, downloadFile } from '$lib/utils/asset-utils';
|
||||
import { addAssetsToAlbum, downloadFile, getAssetType } from '$lib/utils/asset-utils';
|
||||
import NavigationArea from './navigation-area.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
|
|
@ -23,10 +33,21 @@
|
|||
import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte';
|
||||
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiImageBrokenVariant, mdiPause, mdiPlay } from '@mdi/js';
|
||||
import {
|
||||
mdiChevronLeft,
|
||||
mdiHeartOutline,
|
||||
mdiHeart,
|
||||
mdiCommentOutline,
|
||||
mdiChevronRight,
|
||||
mdiClose,
|
||||
mdiImageBrokenVariant,
|
||||
mdiPause,
|
||||
mdiPlay,
|
||||
} 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';
|
||||
|
||||
export let assetStore: AssetStore | null = null;
|
||||
export let asset: AssetResponseDto;
|
||||
|
|
@ -35,6 +56,11 @@
|
|||
$: isTrashEnabled = $featureFlags.trash;
|
||||
export let force = false;
|
||||
export let withStacked = false;
|
||||
export let isShared = true;
|
||||
export let user: UserResponseDto | null = null;
|
||||
export let album: AlbumResponseDto | null = null;
|
||||
|
||||
let reactions: ActivityResponseDto[] = [];
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
archived: AssetResponseDto;
|
||||
|
|
@ -57,6 +83,9 @@
|
|||
let shouldShowDetailButton = asset.hasMetadata;
|
||||
let canCopyImagesToClipboard: boolean;
|
||||
let previewStackedAsset: AssetResponseDto | undefined;
|
||||
let isShowActivity = false;
|
||||
let isLiked: ActivityResponseDto | null = null;
|
||||
let numberOfComments: number;
|
||||
|
||||
$: {
|
||||
if (asset.stackCount && asset.stack) {
|
||||
|
|
@ -71,6 +100,62 @@
|
|||
}
|
||||
}
|
||||
|
||||
const handleFavorite = async () => {
|
||||
if (album) {
|
||||
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, assetId: asset.id, type: ReactionType.Like },
|
||||
});
|
||||
|
||||
isLiked = data;
|
||||
reactions = [...reactions, isLiked];
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, "Can't change favorite for asset");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getFavorite = async () => {
|
||||
if (album) {
|
||||
try {
|
||||
const { data } = await api.activityApi.getActivities({
|
||||
assetId: asset.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 () => {
|
||||
if (album) {
|
||||
try {
|
||||
const { data } = await api.activityApi.getActivityStatistics({ assetId: asset.id, albumId: album.id });
|
||||
numberOfComments = data.comments;
|
||||
} catch (error) {
|
||||
handleError(error, "Can't get number of comments");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$: {
|
||||
if (isShared && asset.id) {
|
||||
getFavorite();
|
||||
getNumberOfComments();
|
||||
}
|
||||
}
|
||||
const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo);
|
||||
|
||||
onMount(async () => {
|
||||
|
|
@ -116,6 +201,13 @@
|
|||
}
|
||||
};
|
||||
|
||||
const handleOpenActivity = () => {
|
||||
if ($isShowDetail) {
|
||||
$isShowDetail = false;
|
||||
}
|
||||
isShowActivity = !isShowActivity;
|
||||
};
|
||||
|
||||
const handleKeyboardPress = (event: KeyboardEvent) => {
|
||||
if (shouldIgnoreShortcut(event)) {
|
||||
return;
|
||||
|
|
@ -157,6 +249,7 @@
|
|||
toggleFavorite();
|
||||
return;
|
||||
case 'i':
|
||||
isShowActivity = false;
|
||||
$isShowDetail = !$isShowDetail;
|
||||
return;
|
||||
}
|
||||
|
|
@ -193,6 +286,9 @@
|
|||
};
|
||||
|
||||
const showDetailInfoHandler = () => {
|
||||
if (isShowActivity) {
|
||||
isShowActivity = false;
|
||||
}
|
||||
$isShowDetail = !$isShowDetail;
|
||||
};
|
||||
|
||||
|
|
@ -317,17 +413,6 @@
|
|||
}
|
||||
};
|
||||
|
||||
const getAssetType = () => {
|
||||
switch (asset.type) {
|
||||
case 'IMAGE':
|
||||
return 'Photo';
|
||||
case 'VIDEO':
|
||||
return 'Video';
|
||||
default:
|
||||
return 'Asset';
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunJob = async (name: AssetJobName) => {
|
||||
try {
|
||||
await api.assetApi.runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
|
||||
|
|
@ -471,7 +556,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
<!-- Asset Viewer -->
|
||||
<div class="col-span-4 col-start-1 row-span-full row-start-1">
|
||||
<div class="relative col-span-4 col-start-1 row-span-full row-start-1">
|
||||
{#if previewStackedAsset}
|
||||
{#key previewStackedAsset.id}
|
||||
{#if previewStackedAsset.type === AssetTypeEnum.Image}
|
||||
|
|
@ -517,6 +602,29 @@
|
|||
on:onVideoStarted={handleVideoStarted}
|
||||
/>
|
||||
{/if}
|
||||
{#if 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>
|
||||
</div>
|
||||
{/if}
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
|
|
@ -582,6 +690,28 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isShared && album && isShowActivity && user}
|
||||
<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"
|
||||
translate="yes"
|
||||
>
|
||||
<ActivityViewer
|
||||
{user}
|
||||
assetType={asset.type}
|
||||
albumOwnerId={album.ownerId}
|
||||
albumId={album.id}
|
||||
assetId={asset.id}
|
||||
bind:reactions
|
||||
on:addComment={() => numberOfComments++}
|
||||
on:deleteComment={() => numberOfComments--}
|
||||
on:deleteLike={() => (isLiked = null)}
|
||||
on:close={() => (isShowActivity = false)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isShowAlbumPicker}
|
||||
<AlbumSelectionModal
|
||||
shared={addToSharedAlbum}
|
||||
|
|
@ -594,15 +724,15 @@
|
|||
|
||||
{#if isShowDeleteConfirmation}
|
||||
<ConfirmDialogue
|
||||
title="Delete {getAssetType()}"
|
||||
title="Delete {getAssetType(asset.type)}"
|
||||
confirmText="Delete"
|
||||
on:confirm={deleteAsset}
|
||||
on:cancel={() => (isShowDeleteConfirmation = false)}
|
||||
>
|
||||
<svelte:fragment slot="prompt">
|
||||
<p>
|
||||
Are you sure you want to delete this {getAssetType().toLowerCase()}? This will also remove it from its
|
||||
album(s).
|
||||
Are you sure you want to delete this {getAssetType(asset.type).toLowerCase()}? This will also remove it from
|
||||
its album(s).
|
||||
</p>
|
||||
<p><b>You cannot undo this action!</b></p>
|
||||
</svelte:fragment>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { useZoomImageWheel } from '@zoom-image/svelte';
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let element: HTMLDivElement | undefined = undefined;
|
||||
|
|
@ -54,11 +55,14 @@
|
|||
}
|
||||
};
|
||||
|
||||
const handleKeypress = async ({ metaKey, ctrlKey, key }: KeyboardEvent) => {
|
||||
const handleKeypress = async (event: KeyboardEvent) => {
|
||||
if (shouldIgnoreShortcut(event)) {
|
||||
return;
|
||||
}
|
||||
if (window.getSelection()?.type === 'Range') {
|
||||
return;
|
||||
}
|
||||
if ((metaKey || ctrlKey) && key === 'c') {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'c') {
|
||||
await doCopy();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { isSearchEnabled } from '$lib/stores/search.store';
|
||||
import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto } from '@api';
|
||||
import type { AlbumResponseDto, AssetResponseDto, UserResponseDto } from '@api';
|
||||
import { DateTime } from 'luxon';
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
|
||||
|
|
@ -26,6 +26,9 @@
|
|||
export let assetInteractionStore: AssetInteractionStore;
|
||||
export let removeAction: AssetAction | null = null;
|
||||
export let withStacked = false;
|
||||
export let isShared = false;
|
||||
export let user: UserResponseDto | null = null;
|
||||
export let album: AlbumResponseDto | null = null;
|
||||
|
||||
$: isTrashEnabled = $featureFlags.loaded && $featureFlags.trash;
|
||||
export let forceDelete = false;
|
||||
|
|
@ -391,10 +394,13 @@
|
|||
<Portal target="body">
|
||||
{#if $showAssetViewer}
|
||||
<AssetViewer
|
||||
{user}
|
||||
{withStacked}
|
||||
{assetStore}
|
||||
asset={$viewingAsset}
|
||||
force={forceDelete || !isTrashEnabled}
|
||||
{isShared}
|
||||
{album}
|
||||
on:previous={() => handlePrevious()}
|
||||
on:next={() => handleNext()}
|
||||
on:close={() => handleClose()}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { downloadArchive } from '$lib/utils/asset-utils';
|
||||
import { api, AssetResponseDto, SharedLinkResponseDto } from '@api';
|
||||
import { api, AssetResponseDto, SharedLinkResponseDto, UserResponseDto } from '@api';
|
||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import DownloadAction from '../photos-page/actions/download-action.svelte';
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
export let sharedLink: SharedLinkResponseDto;
|
||||
export let isOwned: boolean;
|
||||
export let user: UserResponseDto | undefined = undefined;
|
||||
|
||||
let selectedAssets: Set<AssetResponseDto> = new Set();
|
||||
|
||||
|
|
@ -102,6 +103,6 @@
|
|||
</ControlAppBar>
|
||||
{/if}
|
||||
<section class="my-[160px] flex flex-col px-6 sm:px-12 md:px-24 lg:px-40">
|
||||
<GalleryViewer {assets} bind:selectedAssets />
|
||||
<GalleryViewer {user} {assets} bind:selectedAssets />
|
||||
</section>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { page } from '$app/stores';
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetResponseDto, ThumbnailFormat } from '@api';
|
||||
import { AssetResponseDto, ThumbnailFormat, UserResponseDto } from '@api';
|
||||
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { getThumbnailSize } from '$lib/utils/thumbnail-util';
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
export let selectedAssets: Set<AssetResponseDto> = new Set();
|
||||
export let disableAssetSelect = false;
|
||||
export let showArchiveIcon = false;
|
||||
export let user: UserResponseDto | undefined = undefined;
|
||||
|
||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||
|
||||
|
|
@ -103,6 +104,7 @@
|
|||
<!-- Overlay Asset Viewer -->
|
||||
{#if $showAssetViewer}
|
||||
<AssetViewer
|
||||
{user}
|
||||
asset={selectedAsset}
|
||||
on:previous={navigateAssetBackward}
|
||||
on:next={navigateAssetForward}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,17 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { imageLoad } from '$lib/utils/image-load';
|
||||
import { api, UserResponseDto } from '@api';
|
||||
import { api } from '@api';
|
||||
|
||||
export let user: UserResponseDto;
|
||||
interface User {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
profileImagePath: string;
|
||||
}
|
||||
|
||||
export let user: User;
|
||||
export let color: Color = 'primary';
|
||||
export let size: Size = 'full';
|
||||
export let rounded = true;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue