mirror of
https://github.com/immich-app/immich.git
synced 2025-12-26 01:11:47 +03:00
Compare commits
4 Commits
feat/editi
...
fix/partne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75b57f76dd | ||
|
|
0147366984 | ||
|
|
06e89da048 | ||
|
|
83f8065f10 |
@@ -1,19 +0,0 @@
|
||||
import { tick } from 'svelte';
|
||||
import type { Action } from 'svelte/action';
|
||||
|
||||
type Parameters = {
|
||||
height?: string;
|
||||
value: string; // added to enable reactivity
|
||||
};
|
||||
|
||||
export const autoGrowHeight: Action<HTMLTextAreaElement, Parameters> = (textarea, { height = 'auto' }) => {
|
||||
const update = () => {
|
||||
void tick().then(() => {
|
||||
textarea.style.height = height;
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
});
|
||||
};
|
||||
|
||||
update();
|
||||
return { update };
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { updateAlbumInfo } from '@immich/sdk';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
|
||||
import { updateAlbumInfo } from '@immich/sdk';
|
||||
import { Textarea } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fromAction } from 'svelte/attachments';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -12,27 +14,34 @@
|
||||
|
||||
let { id, description = $bindable(), isOwned }: Props = $props();
|
||||
|
||||
const handleUpdateDescription = async (newDescription: string) => {
|
||||
const handleFocusOut = async () => {
|
||||
try {
|
||||
await updateAlbumInfo({
|
||||
id,
|
||||
updateAlbumDto: {
|
||||
description: newDescription,
|
||||
description,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_album'));
|
||||
}
|
||||
description = newDescription;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if isOwned}
|
||||
<AutogrowTextarea
|
||||
content={description}
|
||||
class="w-full mt-2 text-black dark:text-white border-b-2 border-transparent border-gray-500 bg-transparent text-base outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:focus:border-immich-dark-primary hover:border-gray-400"
|
||||
onContentUpdate={handleUpdateDescription}
|
||||
<Textarea
|
||||
bind:value={description}
|
||||
class="outline-none border-b border-gray-500 bg-transparent ring-0 focus:ring-0 resize-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary dark:bg-transparent"
|
||||
rows={1}
|
||||
grow
|
||||
shape="rectangle"
|
||||
onfocusout={handleFocusOut}
|
||||
placeholder={$t('add_a_description')}
|
||||
data-testid="autogrow-textarea"
|
||||
{@attach fromAction(shortcut, () => ({
|
||||
shortcut: { key: 'Enter', ctrl: true },
|
||||
onShortcut: (e) => e.currentTarget.blur(),
|
||||
}))}
|
||||
/>
|
||||
{:else if description}
|
||||
<p class="break-words whitespace-pre-line w-full text-black dark:text-white text-base">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { autoGrowHeight } from '$lib/actions/autogrow';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
@@ -12,10 +11,11 @@
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { isTenMinutesApart } from '$lib/utils/timesince';
|
||||
import { ReactionType, type ActivityResponseDto, type AssetTypeEnum, type UserResponseDto } from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, toastManager } from '@immich/ui';
|
||||
import { Icon, IconButton, LoadingSpinner, Textarea, toastManager } from '@immich/ui';
|
||||
import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiSend, mdiThumbUp } from '@mdi/js';
|
||||
import * as luxon from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fromAction } from 'svelte/attachments';
|
||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||
|
||||
const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
|
||||
@@ -245,19 +245,20 @@
|
||||
</div>
|
||||
<form class="flex w-full max-h-56 gap-1" {onsubmit}>
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<textarea
|
||||
<Textarea
|
||||
{disabled}
|
||||
bind:value={message}
|
||||
use:autoGrowHeight={{ height: '5px', value: message }}
|
||||
rows={1}
|
||||
grow
|
||||
placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')}
|
||||
use:shortcut={{
|
||||
{@attach fromAction(shortcut, () => ({
|
||||
shortcut: { key: 'Enter' },
|
||||
onShortcut: () => handleSendComment(),
|
||||
}}
|
||||
}))}
|
||||
class="h-4.5 {disabled
|
||||
? 'cursor-not-allowed'
|
||||
: ''} w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200"
|
||||
></textarea>
|
||||
: ''} w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200 dark:bg-gray-200"
|
||||
></Textarea>
|
||||
</div>
|
||||
{#if isSendingMessage}
|
||||
<div class="flex items-end place-items-center pb-2 ms-0">
|
||||
|
||||
@@ -183,107 +183,107 @@
|
||||
|
||||
{#if isOwner}
|
||||
<DeleteAction {asset} {onAction} {preAction} />
|
||||
{/if}
|
||||
|
||||
<ButtonContextMenu direction="left" align="top-right" color="secondary" title={$t('more')} icon={mdiDotsVertical}>
|
||||
{#if showSlideshow && !isLocked}
|
||||
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
|
||||
<ButtonContextMenu direction="left" align="top-right" color="secondary" title={$t('more')} icon={mdiDotsVertical}>
|
||||
{#if showSlideshow && !isLocked}
|
||||
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
|
||||
{/if}
|
||||
{#if isOwner && showDownloadButton}
|
||||
<DownloadAction asset={toTimelineAsset(asset)} menuItem />
|
||||
{/if}
|
||||
|
||||
{#if !isLocked}
|
||||
{#if asset.isTrashed}
|
||||
<RestoreAction {asset} {onAction} />
|
||||
{:else}
|
||||
<AddToAlbumAction {asset} {onAction} />
|
||||
<AddToAlbumAction {asset} {onAction} shared />
|
||||
{/if}
|
||||
{#if showDownloadButton}
|
||||
<DownloadAction asset={toTimelineAsset(asset)} menuItem />
|
||||
{/if}
|
||||
|
||||
{#if isOwner}
|
||||
<AddToStackAction {asset} {stack} {onAction} />
|
||||
{#if stack}
|
||||
<UnstackAction {stack} {onAction} />
|
||||
<KeepThisDeleteOthersAction {stack} {asset} {onAction} />
|
||||
{#if stack?.primaryAssetId !== asset.id}
|
||||
<SetStackPrimaryAsset {stack} {asset} {onAction} />
|
||||
{#if stack?.assets?.length > 2}
|
||||
<RemoveAssetFromStack {asset} {stack} {onAction} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if album}
|
||||
<SetAlbumCoverAction {asset} {album} />
|
||||
{/if}
|
||||
{#if person}
|
||||
<SetFeaturedPhotoAction {asset} {person} {onAction} />
|
||||
{/if}
|
||||
{#if asset.type === AssetTypeEnum.Image && !isLocked}
|
||||
<SetProfilePictureAction {asset} />
|
||||
{/if}
|
||||
|
||||
{#if !isLocked}
|
||||
{#if asset.isTrashed}
|
||||
<RestoreAction {asset} {onAction} />
|
||||
{:else}
|
||||
<AddToAlbumAction {asset} {onAction} />
|
||||
<AddToAlbumAction {asset} {onAction} shared />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if isOwner}
|
||||
<AddToStackAction {asset} {stack} {onAction} />
|
||||
{#if stack}
|
||||
<UnstackAction {stack} {onAction} />
|
||||
<KeepThisDeleteOthersAction {stack} {asset} {onAction} />
|
||||
{#if stack?.primaryAssetId !== asset.id}
|
||||
<SetStackPrimaryAsset {stack} {asset} {onAction} />
|
||||
{#if stack?.assets?.length > 2}
|
||||
<RemoveAssetFromStack {asset} {stack} {onAction} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if album}
|
||||
<SetAlbumCoverAction {asset} {album} />
|
||||
{/if}
|
||||
{#if person}
|
||||
<SetFeaturedPhotoAction {asset} {person} {onAction} />
|
||||
{/if}
|
||||
{#if asset.type === AssetTypeEnum.Image && !isLocked}
|
||||
<SetProfilePictureAction {asset} />
|
||||
{/if}
|
||||
|
||||
{#if !isLocked}
|
||||
<ArchiveAction {asset} {onAction} {preAction} />
|
||||
<ArchiveAction {asset} {onAction} {preAction} />
|
||||
<MenuOption
|
||||
icon={mdiUpload}
|
||||
onClick={() => handleReplaceAsset(asset.id)}
|
||||
text={$t('replace_with_upload')}
|
||||
/>
|
||||
{#if !asset.isArchived && !asset.isTrashed}
|
||||
<MenuOption
|
||||
icon={mdiUpload}
|
||||
onClick={() => handleReplaceAsset(asset.id)}
|
||||
text={$t('replace_with_upload')}
|
||||
/>
|
||||
{#if !asset.isArchived && !asset.isTrashed}
|
||||
<MenuOption
|
||||
icon={mdiImageSearch}
|
||||
onClick={() => goto(resolve(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`))}
|
||||
text={$t('view_in_timeline')}
|
||||
/>
|
||||
{/if}
|
||||
{#if !asset.isArchived && !asset.isTrashed && smartSearchEnabled}
|
||||
<MenuOption
|
||||
icon={mdiCompare}
|
||||
onClick={() =>
|
||||
goto(resolve(`${AppRoute.SEARCH}?query={"queryAssetId":"${stack?.primaryAssetId ?? asset.id}"}`))}
|
||||
text={$t('view_similar_photos')}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !asset.isTrashed}
|
||||
<SetVisibilityAction asset={toTimelineAsset(asset)} {onAction} {preAction} />
|
||||
{/if}
|
||||
|
||||
{#if asset.type === AssetTypeEnum.Video}
|
||||
<MenuOption
|
||||
icon={mdiVideoOutline}
|
||||
onClick={() => setPlayOriginalVideo(!playOriginalVideo)}
|
||||
text={playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video')}
|
||||
icon={mdiImageSearch}
|
||||
onClick={() => goto(resolve(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`))}
|
||||
text={$t('view_in_timeline')}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<hr />
|
||||
<MenuOption
|
||||
icon={mdiHeadSyncOutline}
|
||||
onClick={() => onRunJob(AssetJobName.RefreshFaces)}
|
||||
text={$getAssetJobName(AssetJobName.RefreshFaces)}
|
||||
/>
|
||||
<MenuOption
|
||||
icon={mdiDatabaseRefreshOutline}
|
||||
onClick={() => onRunJob(AssetJobName.RefreshMetadata)}
|
||||
text={$getAssetJobName(AssetJobName.RefreshMetadata)}
|
||||
/>
|
||||
<MenuOption
|
||||
icon={mdiImageRefreshOutline}
|
||||
onClick={() => onRunJob(AssetJobName.RegenerateThumbnail)}
|
||||
text={$getAssetJobName(AssetJobName.RegenerateThumbnail)}
|
||||
/>
|
||||
{#if asset.type === AssetTypeEnum.Video}
|
||||
{#if !asset.isArchived && !asset.isTrashed && smartSearchEnabled}
|
||||
<MenuOption
|
||||
icon={mdiCogRefreshOutline}
|
||||
onClick={() => onRunJob(AssetJobName.TranscodeVideo)}
|
||||
text={$getAssetJobName(AssetJobName.TranscodeVideo)}
|
||||
icon={mdiCompare}
|
||||
onClick={() =>
|
||||
goto(resolve(`${AppRoute.SEARCH}?query={"queryAssetId":"${stack?.primaryAssetId ?? asset.id}"}`))}
|
||||
text={$t('view_similar_photos')}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</ButtonContextMenu>
|
||||
{/if}
|
||||
|
||||
{#if !asset.isTrashed}
|
||||
<SetVisibilityAction asset={toTimelineAsset(asset)} {onAction} {preAction} />
|
||||
{/if}
|
||||
|
||||
{#if asset.type === AssetTypeEnum.Video}
|
||||
<MenuOption
|
||||
icon={mdiVideoOutline}
|
||||
onClick={() => setPlayOriginalVideo(!playOriginalVideo)}
|
||||
text={playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video')}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<hr />
|
||||
<MenuOption
|
||||
icon={mdiHeadSyncOutline}
|
||||
onClick={() => onRunJob(AssetJobName.RefreshFaces)}
|
||||
text={$getAssetJobName(AssetJobName.RefreshFaces)}
|
||||
/>
|
||||
<MenuOption
|
||||
icon={mdiDatabaseRefreshOutline}
|
||||
onClick={() => onRunJob(AssetJobName.RefreshMetadata)}
|
||||
text={$getAssetJobName(AssetJobName.RefreshMetadata)}
|
||||
/>
|
||||
<MenuOption
|
||||
icon={mdiImageRefreshOutline}
|
||||
onClick={() => onRunJob(AssetJobName.RegenerateThumbnail)}
|
||||
text={$getAssetJobName(AssetJobName.RegenerateThumbnail)}
|
||||
/>
|
||||
{#if asset.type === AssetTypeEnum.Video}
|
||||
<MenuOption
|
||||
icon={mdiCogRefreshOutline}
|
||||
onClick={() => onRunJob(AssetJobName.TranscodeVideo)}
|
||||
text={$getAssetJobName(AssetJobName.TranscodeVideo)}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</ButtonContextMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { Textarea, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fromAction } from 'svelte/attachments';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
@@ -12,15 +13,17 @@
|
||||
|
||||
let { asset, isOwner }: Props = $props();
|
||||
|
||||
let description = $derived(asset.exifInfo?.description || '');
|
||||
let currentDescription = asset.exifInfo?.description ?? '';
|
||||
let draftDescription = $state(currentDescription);
|
||||
|
||||
const handleFocusOut = async (newDescription: string) => {
|
||||
const handleFocusOut = async () => {
|
||||
if (draftDescription === currentDescription) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAsset({ id: asset.id, updateAssetDto: { description: newDescription } });
|
||||
|
||||
asset.exifInfo = { ...asset.exifInfo, description: newDescription };
|
||||
|
||||
await updateAsset({ id: asset.id, updateAssetDto: { description: draftDescription } });
|
||||
toastManager.success($t('asset_description_updated'));
|
||||
currentDescription = draftDescription;
|
||||
} catch (error) {
|
||||
handleError(error, $t('cannot_update_the_description'));
|
||||
}
|
||||
@@ -29,15 +32,23 @@
|
||||
|
||||
{#if isOwner}
|
||||
<section class="px-4 mt-10">
|
||||
<AutogrowTextarea
|
||||
content={description}
|
||||
class="max-h-125 w-full border-b border-gray-500 bg-transparent text-base text-black outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:text-white dark:focus:border-immich-dark-primary immich-scrollbar"
|
||||
onContentUpdate={handleFocusOut}
|
||||
<Textarea
|
||||
bind:value={draftDescription}
|
||||
class="max-h-40 outline-none border-b border-gray-500 bg-transparent ring-0 focus:ring-0 resize-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary dark:bg-transparent"
|
||||
rows={1}
|
||||
grow
|
||||
shape="rectangle"
|
||||
onfocusout={handleFocusOut}
|
||||
placeholder={$t('add_a_description')}
|
||||
data-testid="autogrow-textarea"
|
||||
{@attach fromAction(shortcut, () => ({
|
||||
shortcut: { key: 'Enter', ctrl: true },
|
||||
onShortcut: (e) => e.currentTarget.blur(),
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
{:else if description}
|
||||
{:else if draftDescription}
|
||||
<section class="px-4 mt-6">
|
||||
<p class="wrap-break-word whitespace-pre-line w-full text-black dark:text-white text-base">{description}</p>
|
||||
<p class="wrap-break-word whitespace-pre-line w-full text-black dark:text-white text-base">{draftDescription}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
|
||||
import { render, screen, waitFor } from '@testing-library/svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
describe('AutogrowTextarea component', () => {
|
||||
const getTextarea = () => screen.getByTestId('autogrow-textarea') as HTMLTextAreaElement;
|
||||
|
||||
it('should render correctly', () => {
|
||||
render(AutogrowTextarea);
|
||||
const textarea = getTextarea();
|
||||
expect(textarea).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show the content passed to the component', () => {
|
||||
render(AutogrowTextarea, { content: 'stuff' });
|
||||
const textarea = getTextarea();
|
||||
expect(textarea.value).toBe('stuff');
|
||||
});
|
||||
|
||||
it('should show the placeholder passed to the component', () => {
|
||||
render(AutogrowTextarea, { placeholder: 'asdf' });
|
||||
const textarea = getTextarea();
|
||||
expect(textarea.placeholder).toBe('asdf');
|
||||
});
|
||||
|
||||
it('should execute the passed callback on blur', async () => {
|
||||
const user = userEvent.setup();
|
||||
const update = vi.fn();
|
||||
render(AutogrowTextarea, { content: 'existing', onContentUpdate: update });
|
||||
const textarea = getTextarea();
|
||||
await user.click(textarea);
|
||||
await user.keyboard('extra');
|
||||
textarea.blur();
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith('existingextra'));
|
||||
});
|
||||
|
||||
it('should execute the passed callback when pressing ctrl+enter in the textarea', async () => {
|
||||
const user = userEvent.setup();
|
||||
const update = vi.fn();
|
||||
render(AutogrowTextarea, { onContentUpdate: update });
|
||||
const textarea = getTextarea();
|
||||
await user.click(textarea);
|
||||
const string = 'content';
|
||||
await user.keyboard(string);
|
||||
await user.keyboard('{Control>}{Enter}{/Control}');
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith(string));
|
||||
});
|
||||
|
||||
it('should not execute the passed callback if the text has not changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const update = vi.fn();
|
||||
render(AutogrowTextarea, { content: 'initial', onContentUpdate: update });
|
||||
const textarea = getTextarea();
|
||||
await user.click(textarea);
|
||||
await user.clear(textarea);
|
||||
await user.keyboard('initial');
|
||||
await user.keyboard('{Control>}{Enter}{/Control}');
|
||||
await waitFor(() => expect(update).not.toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { autoGrowHeight } from '$lib/actions/autogrow';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
|
||||
interface Props {
|
||||
content?: string;
|
||||
class?: string;
|
||||
onContentUpdate?: (newContent: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let { content = '', class: className = '', onContentUpdate = () => null, placeholder = '' }: Props = $props();
|
||||
|
||||
let newContent = $derived(content);
|
||||
|
||||
const updateContent = () => {
|
||||
if (content === newContent) {
|
||||
return;
|
||||
}
|
||||
onContentUpdate(newContent);
|
||||
};
|
||||
</script>
|
||||
|
||||
<textarea
|
||||
bind:value={newContent}
|
||||
class="resize-none {className}"
|
||||
onfocusout={updateContent}
|
||||
{placeholder}
|
||||
use:shortcut={{
|
||||
shortcut: { key: 'Enter', ctrl: true },
|
||||
onShortcut: (e) => e.currentTarget.blur(),
|
||||
}}
|
||||
use:autoGrowHeight={{ value: newContent }}
|
||||
data-testid="autogrow-textarea">{content}</textarea
|
||||
>
|
||||
@@ -57,6 +57,9 @@
|
||||
|
||||
return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
|
||||
});
|
||||
|
||||
let isAllUserOwned = $derived($user && selectedAssets.every((asset) => asset.ownerId === $user.id));
|
||||
|
||||
const handleEscape = () => {
|
||||
if ($showAssetViewer) {
|
||||
return;
|
||||
@@ -118,45 +121,51 @@
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</ButtonContextMenu>
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
|
||||
></FavoriteAction>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
{#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected}
|
||||
<StackAction
|
||||
unstack={isAssetStackSelected}
|
||||
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
|
||||
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
|
||||
/>
|
||||
{/if}
|
||||
{#if isLinkActionAvailable}
|
||||
<LinkLivePhotoAction
|
||||
|
||||
{#if isAllUserOwned}
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
|
||||
/>
|
||||
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
{#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected}
|
||||
<StackAction
|
||||
unstack={isAssetStackSelected}
|
||||
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
|
||||
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
|
||||
/>
|
||||
{/if}
|
||||
{#if isLinkActionAvailable}
|
||||
<LinkLivePhotoAction
|
||||
menuItem
|
||||
unlink={assetInteraction.selectedAssets.length === 1}
|
||||
onLink={handleLink}
|
||||
onUnlink={handleUnlink}
|
||||
/>
|
||||
{/if}
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction
|
||||
menuItem
|
||||
unlink={assetInteraction.selectedAssets.length === 1}
|
||||
onLink={handleLink}
|
||||
onUnlink={handleUnlink}
|
||||
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
|
||||
/>
|
||||
{/if}
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction
|
||||
menuItem
|
||||
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
|
||||
/>
|
||||
{#if $preferences.tags.enabled}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets
|
||||
menuItem
|
||||
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
|
||||
/>
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
<hr />
|
||||
<AssetJobActions />
|
||||
</ButtonContextMenu>
|
||||
{#if $preferences.tags.enabled}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets
|
||||
menuItem
|
||||
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
|
||||
/>
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
<hr />
|
||||
<AssetJobActions />
|
||||
</ButtonContextMenu>
|
||||
{:else}
|
||||
<DownloadAction />
|
||||
{/if}
|
||||
</AssetSelectControlBar>
|
||||
{/if}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { lang, locale } from '$lib/stores/preferences.store';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import { parseUtcDate } from '$lib/utils/date-time';
|
||||
@@ -70,6 +70,8 @@
|
||||
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
|
||||
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
|
||||
|
||||
let isAllUserOwned = $derived($user && assetInteraction.selectedAssets.every((asset) => asset.ownerId === $user.id));
|
||||
|
||||
$effect(() => {
|
||||
// we want this to *only* be reactive on `terms`
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
@@ -257,64 +259,6 @@
|
||||
<svelte:window bind:scrollY />
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} />
|
||||
|
||||
<section>
|
||||
{#if assetInteraction.selectionActive}
|
||||
<div class="fixed top-0 start-0 w-full">
|
||||
<AssetSelectControlBar
|
||||
assets={assetInteraction.selectedAssets}
|
||||
clearSelect={() => cancelMultiselect(assetInteraction)}
|
||||
>
|
||||
<CreateSharedLink />
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('select_all')}
|
||||
icon={mdiSelectAll}
|
||||
onclick={handleSelectAll}
|
||||
/>
|
||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||
<AddToAlbum {onAddToAlbum} />
|
||||
<AddToAlbum shared {onAddToAlbum} />
|
||||
</ButtonContextMenu>
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(assetIds, isFavorite) => {
|
||||
for (const assetId of assetIds) {
|
||||
const asset = searchResultAssets.find((searchAsset) => searchAsset.id === assetId);
|
||||
if (asset) {
|
||||
asset.isFavorite = isFavorite;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
|
||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
|
||||
<hr />
|
||||
<AssetJobActions />
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="fixed top-0 start-0 w-full">
|
||||
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
||||
<div class="absolute bg-light"></div>
|
||||
<div class="w-full flex-1 ps-4">
|
||||
<SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
|
||||
</div>
|
||||
</ControlAppBar>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if terms}
|
||||
<section
|
||||
id="search-chips"
|
||||
@@ -418,34 +362,38 @@
|
||||
<AddToAlbum {onAddToAlbum} />
|
||||
<AddToAlbum shared {onAddToAlbum} />
|
||||
</ButtonContextMenu>
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) => {
|
||||
for (const id of ids) {
|
||||
const asset = searchResultAssets.find((asset) => asset.id === id);
|
||||
if (asset) {
|
||||
asset.isFavorite = isFavorite;
|
||||
{#if isAllUserOwned}
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) => {
|
||||
for (const id of ids) {
|
||||
const asset = searchResultAssets.find((asset) => asset.id === id);
|
||||
if (asset) {
|
||||
asset.isFavorite = isFavorite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
|
||||
{#if assetInteraction.isAllUserOwned}
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
{/if}
|
||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
|
||||
<hr />
|
||||
<AssetJobActions />
|
||||
</ButtonContextMenu>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
|
||||
{#if assetInteraction.isAllUserOwned}
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
{/if}
|
||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
|
||||
<hr />
|
||||
<AssetJobActions />
|
||||
</ButtonContextMenu>
|
||||
{:else}
|
||||
<DownloadAction />
|
||||
{/if}
|
||||
</AssetSelectControlBar>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
Reference in New Issue
Block a user