You've already forked immich
mirror of
https://github.com/immich-app/immich.git
synced 2025-06-27 05:11:11 +02:00
chore(web): migration svelte 5 syntax (#13883)
This commit is contained in:
@ -9,11 +9,15 @@
|
||||
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let onAction: OnAction;
|
||||
export let shared = false;
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
onAction: OnAction;
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
let showSelectionModal = false;
|
||||
let { asset, onAction, shared = false }: Props = $props();
|
||||
|
||||
let showSelectionModal = $state(false);
|
||||
|
||||
const handleAddToNewAlbum = async (albumName: string) => {
|
||||
showSelectionModal = false;
|
||||
|
@ -8,8 +8,12 @@
|
||||
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let onAction: OnAction;
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
onAction: OnAction;
|
||||
}
|
||||
|
||||
let { asset, onAction }: Props = $props();
|
||||
|
||||
const onArchive = async () => {
|
||||
const updatedAsset = await toggleArchive(asset);
|
||||
|
@ -4,9 +4,13 @@
|
||||
import { mdiArrowLeft } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let onClose: () => void;
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:window use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
|
||||
|
||||
<CircleIconButton color="opaque" icon={mdiArrowLeft} title={$t('go_back')} on:click={onClose} />
|
||||
<CircleIconButton color="opaque" icon={mdiArrowLeft} title={$t('go_back')} onclick={onClose} />
|
||||
|
@ -16,10 +16,14 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAction } from './action';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let onAction: OnAction;
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
onAction: OnAction;
|
||||
}
|
||||
|
||||
let showConfirmModal = false;
|
||||
let { asset, onAction }: Props = $props();
|
||||
|
||||
let showConfirmModal = $state(false);
|
||||
|
||||
const trashOrDelete = async (force = false) => {
|
||||
if (force || !$featureFlags.trash) {
|
||||
@ -77,7 +81,7 @@
|
||||
color="opaque"
|
||||
icon={asset.isTrashed ? mdiDeleteForeverOutline : mdiDeleteOutline}
|
||||
title={asset.isTrashed ? $t('permanently_delete') : $t('delete')}
|
||||
on:click={() => trashOrDelete(asset.isTrashed)}
|
||||
onclick={() => trashOrDelete(asset.isTrashed)}
|
||||
/>
|
||||
|
||||
{#if showConfirmModal}
|
||||
|
@ -7,8 +7,12 @@
|
||||
import { mdiFolderDownloadOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let menuItem = false;
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
menuItem?: boolean;
|
||||
}
|
||||
|
||||
let { asset, menuItem = false }: Props = $props();
|
||||
|
||||
const onDownloadFile = () => downloadFile(asset);
|
||||
</script>
|
||||
@ -16,7 +20,7 @@
|
||||
<svelte:window use:shortcut={{ shortcut: { key: 'd', shift: true }, onShortcut: onDownloadFile }} />
|
||||
|
||||
{#if !menuItem}
|
||||
<CircleIconButton color="opaque" icon={mdiFolderDownloadOutline} title={$t('download')} on:click={onDownloadFile} />
|
||||
<CircleIconButton color="opaque" icon={mdiFolderDownloadOutline} title={$t('download')} onclick={onDownloadFile} />
|
||||
{:else}
|
||||
<MenuOption icon={mdiFolderDownloadOutline} text={$t('download')} onClick={onDownloadFile} />
|
||||
{/if}
|
||||
|
@ -12,8 +12,12 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAction } from './action';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let onAction: OnAction;
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
onAction: OnAction;
|
||||
}
|
||||
|
||||
let { asset, onAction }: Props = $props();
|
||||
|
||||
const toggleFavorite = async () => {
|
||||
try {
|
||||
@ -24,7 +28,8 @@
|
||||
},
|
||||
});
|
||||
|
||||
asset.isFavorite = data.isFavorite;
|
||||
asset = { ...asset, isFavorite: data.isFavorite };
|
||||
|
||||
onAction({ type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE, asset });
|
||||
|
||||
notificationController.show({
|
||||
@ -43,5 +48,5 @@
|
||||
color="opaque"
|
||||
icon={asset.isFavorite ? mdiHeart : mdiHeartOutline}
|
||||
title={asset.isFavorite ? $t('unfavorite') : $t('to_favorite')}
|
||||
on:click={toggleFavorite}
|
||||
onclick={toggleFavorite}
|
||||
/>
|
||||
|
@ -3,13 +3,17 @@
|
||||
import { mdiMotionPauseOutline, mdiPlaySpeed } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let isPlaying: boolean;
|
||||
export let onClick: (shouldPlay: boolean) => void;
|
||||
interface Props {
|
||||
isPlaying: boolean;
|
||||
onClick: (shouldPlay: boolean) => void;
|
||||
}
|
||||
|
||||
let { isPlaying, onClick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<CircleIconButton
|
||||
color="opaque"
|
||||
icon={isPlaying ? mdiMotionPauseOutline : mdiPlaySpeed}
|
||||
title={isPlaying ? $t('stop_motion_photo') : $t('play_motion_photo')}
|
||||
on:click={() => onClick(!isPlaying)}
|
||||
onclick={() => onClick(!isPlaying)}
|
||||
/>
|
||||
|
@ -5,7 +5,11 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import NavigationArea from '../navigation-area.svelte';
|
||||
|
||||
export let onNextAsset: () => void;
|
||||
interface Props {
|
||||
onNextAsset: () => void;
|
||||
}
|
||||
|
||||
let { onNextAsset }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
|
@ -5,7 +5,11 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import NavigationArea from '../navigation-area.svelte';
|
||||
|
||||
export let onPreviousAsset: () => void;
|
||||
interface Props {
|
||||
onPreviousAsset: () => void;
|
||||
}
|
||||
|
||||
let { onPreviousAsset }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
|
@ -11,8 +11,12 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAction } from './action';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let onAction: OnAction;
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
onAction: OnAction;
|
||||
}
|
||||
|
||||
let { asset = $bindable(), onAction }: Props = $props();
|
||||
|
||||
const handleRestoreAsset = async () => {
|
||||
try {
|
||||
|
@ -9,8 +9,12 @@
|
||||
import { mdiImageOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let album: AlbumResponseDto;
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
album: AlbumResponseDto;
|
||||
}
|
||||
|
||||
let { asset, album }: Props = $props();
|
||||
|
||||
const handleUpdateThumbnail = async () => {
|
||||
try {
|
||||
|
@ -6,9 +6,13 @@
|
||||
import { mdiAccountCircleOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
}
|
||||
|
||||
let showProfileImageCrop = false;
|
||||
let { asset }: Props = $props();
|
||||
|
||||
let showProfileImageCrop = $state(false);
|
||||
</script>
|
||||
|
||||
<MenuOption
|
||||
|
@ -6,17 +6,16 @@
|
||||
import { mdiShareVariantOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
}
|
||||
|
||||
let showModal = false;
|
||||
let { asset }: Props = $props();
|
||||
|
||||
let showModal = $state(false);
|
||||
</script>
|
||||
|
||||
<CircleIconButton
|
||||
color="opaque"
|
||||
icon={mdiShareVariantOutline}
|
||||
on:click={() => (showModal = true)}
|
||||
title={$t('share')}
|
||||
/>
|
||||
<CircleIconButton color="opaque" icon={mdiShareVariantOutline} onclick={() => (showModal = true)} title={$t('share')} />
|
||||
|
||||
{#if showModal}
|
||||
<Portal target="body">
|
||||
|
@ -4,9 +4,13 @@
|
||||
import { mdiInformationOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let onShowDetail: () => void;
|
||||
interface Props {
|
||||
onShowDetail: () => void;
|
||||
}
|
||||
|
||||
let { onShowDetail }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:window use:shortcut={{ shortcut: { key: 'i' }, onShortcut: onShowDetail }} />
|
||||
|
||||
<CircleIconButton color="opaque" icon={mdiInformationOutline} on:click={onShowDetail} title={$t('info')} />
|
||||
<CircleIconButton color="opaque" icon={mdiInformationOutline} onclick={onShowDetail} title={$t('info')} />
|
||||
|
@ -7,8 +7,12 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAction } from './action';
|
||||
|
||||
export let stack: StackResponseDto;
|
||||
export let onAction: OnAction;
|
||||
interface Props {
|
||||
stack: StackResponseDto;
|
||||
onAction: OnAction;
|
||||
}
|
||||
|
||||
let { stack, onAction }: Props = $props();
|
||||
|
||||
const handleUnstack = async () => {
|
||||
const unstackedAssets = await deleteStack([stack.id]);
|
||||
|
@ -4,20 +4,24 @@
|
||||
import { mdiCommentOutline, mdiHeart, mdiHeartOutline } from '@mdi/js';
|
||||
import Icon from '../elements/icon.svelte';
|
||||
|
||||
export let isLiked: ActivityResponseDto | null;
|
||||
export let numberOfComments: number | undefined;
|
||||
export let disabled: boolean;
|
||||
export let onOpenActivityTab: () => void;
|
||||
export let onFavorite: () => void;
|
||||
interface Props {
|
||||
isLiked: ActivityResponseDto | null;
|
||||
numberOfComments: number | undefined;
|
||||
disabled: boolean;
|
||||
onOpenActivityTab: () => void;
|
||||
onFavorite: () => void;
|
||||
}
|
||||
|
||||
let { isLiked, numberOfComments, disabled, onOpenActivityTab, onFavorite }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="w-full flex p-4 text-white items-center justify-center rounded-full gap-5 bg-immich-dark-bg bg-opacity-60">
|
||||
<button type="button" class={disabled ? 'cursor-not-allowed' : ''} on:click={onFavorite} {disabled}>
|
||||
<button type="button" class={disabled ? 'cursor-not-allowed' : ''} onclick={onFavorite} {disabled}>
|
||||
<div class="items-center justify-center">
|
||||
<Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} />
|
||||
</div>
|
||||
</button>
|
||||
<button type="button" on:click={onOpenActivityTab}>
|
||||
<button type="button" onclick={onOpenActivityTab}>
|
||||
<div class="flex gap-2 items-center justify-center">
|
||||
<Icon path={mdiCommentOutline} class="scale-x-[-1]" size={24} />
|
||||
{#if numberOfComments}
|
||||
|
@ -47,40 +47,45 @@
|
||||
return relativeFormatter.format(Math.trunc(diff.as(unit)), unit);
|
||||
};
|
||||
|
||||
export let reactions: ActivityResponseDto[];
|
||||
export let user: UserResponseDto;
|
||||
export let assetId: string | undefined = undefined;
|
||||
export let albumId: string;
|
||||
export let assetType: AssetTypeEnum | undefined = undefined;
|
||||
export let albumOwnerId: string;
|
||||
export let disabled: boolean;
|
||||
export let isLiked: ActivityResponseDto | null;
|
||||
export let onDeleteComment: () => void;
|
||||
export let onDeleteLike: () => void;
|
||||
export let onAddComment: () => void;
|
||||
export let onClose: () => void;
|
||||
|
||||
let textArea: HTMLTextAreaElement;
|
||||
let innerHeight: number;
|
||||
let activityHeight: number;
|
||||
let chatHeight: number;
|
||||
let divHeight: number;
|
||||
let previousAssetId: string | undefined = assetId;
|
||||
let message = '';
|
||||
let isSendingMessage = false;
|
||||
|
||||
$: {
|
||||
if (innerHeight && activityHeight) {
|
||||
divHeight = innerHeight - activityHeight;
|
||||
}
|
||||
interface Props {
|
||||
reactions: ActivityResponseDto[];
|
||||
user: UserResponseDto;
|
||||
assetId?: string | undefined;
|
||||
albumId: string;
|
||||
assetType?: AssetTypeEnum | undefined;
|
||||
albumOwnerId: string;
|
||||
disabled: boolean;
|
||||
isLiked: ActivityResponseDto | null;
|
||||
onDeleteComment: () => void;
|
||||
onDeleteLike: () => void;
|
||||
onAddComment: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
$: {
|
||||
if (assetId && previousAssetId != assetId) {
|
||||
handlePromiseError(getReactions());
|
||||
previousAssetId = assetId;
|
||||
}
|
||||
}
|
||||
let {
|
||||
reactions = $bindable(),
|
||||
user,
|
||||
assetId = undefined,
|
||||
albumId,
|
||||
assetType = undefined,
|
||||
albumOwnerId,
|
||||
disabled,
|
||||
isLiked,
|
||||
onDeleteComment,
|
||||
onDeleteLike,
|
||||
onAddComment,
|
||||
onClose,
|
||||
}: Props = $props();
|
||||
|
||||
let textArea: HTMLTextAreaElement | undefined = $state();
|
||||
let innerHeight: number = $state(0);
|
||||
let activityHeight: number = $state(0);
|
||||
let chatHeight: number = $state(0);
|
||||
let divHeight: number = $state(0);
|
||||
let previousAssetId: string | undefined = $state(assetId);
|
||||
let message = $state('');
|
||||
let isSendingMessage = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await getReactions();
|
||||
});
|
||||
@ -136,7 +141,11 @@
|
||||
activityCreateDto: { albumId, assetId, type: ReactionType.Comment, comment: message },
|
||||
});
|
||||
reactions.push(data);
|
||||
textArea.style.height = '18px';
|
||||
|
||||
if (textArea) {
|
||||
textArea.style.height = '18px';
|
||||
}
|
||||
|
||||
message = '';
|
||||
onAddComment();
|
||||
// Re-render the activity feed
|
||||
@ -148,6 +157,22 @@
|
||||
}
|
||||
isSendingMessage = false;
|
||||
};
|
||||
$effect(() => {
|
||||
if (innerHeight && activityHeight) {
|
||||
divHeight = innerHeight - activityHeight;
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (assetId && previousAssetId != assetId) {
|
||||
handlePromiseError(getReactions());
|
||||
previousAssetId = assetId;
|
||||
}
|
||||
});
|
||||
|
||||
const onsubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
await handleSendComment();
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="overflow-y-hidden relative h-full" bind:offsetHeight={innerHeight}>
|
||||
@ -157,7 +182,7 @@
|
||||
bind:clientHeight={activityHeight}
|
||||
>
|
||||
<div class="flex place-items-center gap-2">
|
||||
<CircleIconButton on:click={onClose} icon={mdiClose} title={$t('close')} />
|
||||
<CircleIconButton onclick={onClose} icon={mdiClose} title={$t('close')} />
|
||||
|
||||
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('activity')}</p>
|
||||
</div>
|
||||
@ -277,7 +302,7 @@
|
||||
<div>
|
||||
<UserAvatar {user} size="md" showTitle={false} />
|
||||
</div>
|
||||
<form class="flex w-full max-h-56 gap-1" on:submit|preventDefault={() => handleSendComment()}>
|
||||
<form class="flex w-full max-h-56 gap-1" {onsubmit}>
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<textarea
|
||||
{disabled}
|
||||
@ -285,7 +310,7 @@
|
||||
bind:value={message}
|
||||
use:autoGrowHeight={'5px'}
|
||||
placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')}
|
||||
on:input={() => autoGrowHeight(textArea, '5px')}
|
||||
oninput={() => autoGrowHeight(textArea, '5px')}
|
||||
use:shortcut={{
|
||||
shortcut: { key: 'Enter' },
|
||||
onShortcut: () => handleSendComment(),
|
||||
@ -308,7 +333,7 @@
|
||||
size="15"
|
||||
icon={mdiSend}
|
||||
class="dark:text-immich-dark-gray"
|
||||
on:click={() => handleSendComment()}
|
||||
onclick={() => handleSendComment()}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -2,7 +2,11 @@
|
||||
import type { AlbumResponseDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let album: AlbumResponseDto;
|
||||
interface Props {
|
||||
album: AlbumResponseDto;
|
||||
}
|
||||
|
||||
let { album }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span>{$t('items_count', { values: { count: album.assetCount } })}</span>
|
||||
|
@ -4,15 +4,19 @@
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils.js';
|
||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||
|
||||
export let album: AlbumResponseDto;
|
||||
export let searchQuery = '';
|
||||
export let onAlbumClick: () => void;
|
||||
interface Props {
|
||||
album: AlbumResponseDto;
|
||||
searchQuery?: string;
|
||||
onAlbumClick: () => void;
|
||||
}
|
||||
|
||||
let albumNameArray: string[] = ['', '', ''];
|
||||
let { album, searchQuery = '', onAlbumClick }: Props = $props();
|
||||
|
||||
let albumNameArray: string[] = $state(['', '', '']);
|
||||
|
||||
// This part of the code is responsible for splitting album name into 3 parts where part 2 is the search query
|
||||
// It is used to highlight the search query in the album name
|
||||
$: {
|
||||
$effect(() => {
|
||||
let { albumName } = album;
|
||||
let findIndex = normalizeSearchString(albumName).indexOf(normalizeSearchString(searchQuery));
|
||||
let findLength = searchQuery.length;
|
||||
@ -21,12 +25,12 @@
|
||||
albumName.slice(findIndex, findIndex + findLength),
|
||||
albumName.slice(findIndex + findLength),
|
||||
];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
on:click={onAlbumClick}
|
||||
onclick={onAlbumClick}
|
||||
class="flex w-full gap-4 px-6 py-2 text-left transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
|
||||
>
|
||||
<span class="h-12 w-12 shrink-0 rounded-xl bg-slate-300">
|
||||
|
@ -44,25 +44,44 @@
|
||||
} from '@mdi/js';
|
||||
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let album: AlbumResponseDto | null = null;
|
||||
export let stack: StackResponseDto | null = null;
|
||||
export let showDetailButton: boolean;
|
||||
export let showSlideshow = false;
|
||||
export let onZoomImage: () => void;
|
||||
export let onCopyImage: () => void;
|
||||
export let onAction: OnAction;
|
||||
export let onRunJob: (name: AssetJobName) => void;
|
||||
export let onPlaySlideshow: () => void;
|
||||
export let onShowDetail: () => void;
|
||||
// export let showEditorHandler: () => void;
|
||||
export let onClose: () => void;
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
album?: AlbumResponseDto | null;
|
||||
stack?: StackResponseDto | null;
|
||||
showDetailButton: boolean;
|
||||
showSlideshow?: boolean;
|
||||
onZoomImage: () => void;
|
||||
onCopyImage?: () => Promise<void>;
|
||||
onAction: OnAction;
|
||||
onRunJob: (name: AssetJobName) => void;
|
||||
onPlaySlideshow: () => void;
|
||||
onShowDetail: () => void;
|
||||
// export let showEditorHandler: () => void;
|
||||
onClose: () => void;
|
||||
motionPhoto?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
asset,
|
||||
album = null,
|
||||
stack = null,
|
||||
showDetailButton,
|
||||
showSlideshow = false,
|
||||
onZoomImage,
|
||||
onCopyImage,
|
||||
onAction,
|
||||
onRunJob,
|
||||
onPlaySlideshow,
|
||||
onShowDetail,
|
||||
onClose,
|
||||
motionPhoto,
|
||||
}: Props = $props();
|
||||
|
||||
const sharedLink = getSharedLink();
|
||||
$: isOwner = $user && asset.ownerId === $user?.id;
|
||||
// svelte-ignore reactive_declaration_non_reactive_property
|
||||
$: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
|
||||
let isOwner = $derived($user && asset.ownerId === $user?.id);
|
||||
let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline);
|
||||
// $: showEditorButton =
|
||||
// isOwner &&
|
||||
// asset.type === AssetTypeEnum.Image &&
|
||||
@ -88,10 +107,10 @@
|
||||
<ShareAction {asset} />
|
||||
{/if}
|
||||
{#if asset.isOffline}
|
||||
<CircleIconButton color="alert" icon={mdiAlertOutline} on:click={onShowDetail} title={$t('asset_offline')} />
|
||||
<CircleIconButton color="alert" icon={mdiAlertOutline} onclick={onShowDetail} title={$t('asset_offline')} />
|
||||
{/if}
|
||||
{#if asset.livePhotoVideoId}
|
||||
<slot name="motion-photo" />
|
||||
{@render motionPhoto?.()}
|
||||
{/if}
|
||||
{#if asset.type === AssetTypeEnum.Image}
|
||||
<CircleIconButton
|
||||
@ -99,11 +118,11 @@
|
||||
hideMobile={true}
|
||||
icon={$photoZoomState && $photoZoomState.currentZoom > 1 ? mdiMagnifyMinusOutline : mdiMagnifyPlusOutline}
|
||||
title={$t('zoom_image')}
|
||||
on:click={onZoomImage}
|
||||
onclick={onZoomImage}
|
||||
/>
|
||||
{/if}
|
||||
{#if canCopyImageToClipboard() && asset.type === AssetTypeEnum.Image}
|
||||
<CircleIconButton color="opaque" icon={mdiContentCopy} title={$t('copy_image')} on:click={onCopyImage} />
|
||||
<CircleIconButton color="opaque" icon={mdiContentCopy} title={$t('copy_image')} onclick={() => onCopyImage?.()} />
|
||||
{/if}
|
||||
|
||||
{#if !isOwner && showDownloadButton}
|
||||
@ -122,7 +141,7 @@
|
||||
color="opaque"
|
||||
hideMobile={true}
|
||||
icon={mdiImageEditOutline}
|
||||
on:click={showEditorHandler}
|
||||
onclick={showEditorHandler}
|
||||
title={$t('editor')}
|
||||
/>
|
||||
{/if} -->
|
||||
|
@ -48,18 +48,37 @@
|
||||
import SlideshowBar from './slideshow-bar.svelte';
|
||||
import VideoViewer from './video-wrapper-viewer.svelte';
|
||||
|
||||
export let assetStore: AssetStore | null = null;
|
||||
export let asset: AssetResponseDto;
|
||||
export let preloadAssets: AssetResponseDto[] = [];
|
||||
export let showNavigation = true;
|
||||
export let withStacked = false;
|
||||
export let isShared = false;
|
||||
export let album: AlbumResponseDto | null = null;
|
||||
export let onAction: OnAction | undefined = undefined;
|
||||
export let reactions: ActivityResponseDto[] = [];
|
||||
export let onClose: (dto: { asset: AssetResponseDto }) => void;
|
||||
export let onNext: () => void;
|
||||
export let onPrevious: () => void;
|
||||
interface Props {
|
||||
assetStore?: AssetStore | null;
|
||||
asset: AssetResponseDto;
|
||||
preloadAssets?: AssetResponseDto[];
|
||||
showNavigation?: boolean;
|
||||
withStacked?: boolean;
|
||||
isShared?: boolean;
|
||||
album?: AlbumResponseDto | null;
|
||||
onAction?: OnAction | undefined;
|
||||
reactions?: ActivityResponseDto[];
|
||||
onClose: (dto: { asset: AssetResponseDto }) => void;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
copyImage?: () => Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
assetStore = null,
|
||||
asset = $bindable(),
|
||||
preloadAssets = $bindable([]),
|
||||
showNavigation = true,
|
||||
withStacked = false,
|
||||
isShared = false,
|
||||
album = null,
|
||||
onAction = undefined,
|
||||
reactions = $bindable([]),
|
||||
onClose,
|
||||
onNext,
|
||||
onPrevious,
|
||||
copyImage = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
const { setAsset } = assetViewingStore;
|
||||
const {
|
||||
@ -70,26 +89,23 @@
|
||||
slideshowTransition,
|
||||
} = slideshowStore;
|
||||
|
||||
let appearsInAlbums: AlbumResponseDto[] = [];
|
||||
let shouldPlayMotionPhoto = false;
|
||||
let appearsInAlbums: AlbumResponseDto[] = $state([]);
|
||||
let shouldPlayMotionPhoto = $state(false);
|
||||
let sharedLink = getSharedLink();
|
||||
let enableDetailPanel = asset.hasMetadata;
|
||||
let slideshowStateUnsubscribe: () => void;
|
||||
let shuffleSlideshowUnsubscribe: () => void;
|
||||
let previewStackedAsset: AssetResponseDto | undefined;
|
||||
let isShowActivity = false;
|
||||
let isShowEditor = false;
|
||||
let isLiked: ActivityResponseDto | null = null;
|
||||
let numberOfComments: number;
|
||||
let fullscreenElement: Element;
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
let isShowActivity = $state(false);
|
||||
let isShowEditor = $state(false);
|
||||
let isLiked: ActivityResponseDto | null = $state(null);
|
||||
let numberOfComments = $state(0);
|
||||
let fullscreenElement = $state<Element>();
|
||||
let unsubscribes: (() => void)[] = [];
|
||||
let selectedEditType: string = '';
|
||||
let stack: StackResponseDto | null = null;
|
||||
let selectedEditType: string = $state('');
|
||||
let stack: StackResponseDto | null = $state(null);
|
||||
|
||||
let zoomToggle = () => void 0;
|
||||
let copyImage: () => Promise<void>;
|
||||
|
||||
$: isFullScreen = fullscreenElement !== null;
|
||||
let zoomToggle = $state(() => void 0);
|
||||
|
||||
const refreshStack = async () => {
|
||||
if (isSharedLink()) {
|
||||
@ -109,16 +125,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
$: if (asset) {
|
||||
handlePromiseError(refreshStack());
|
||||
}
|
||||
|
||||
$: {
|
||||
if (album && !album.isActivityEnabled && numberOfComments === 0) {
|
||||
isShowActivity = false;
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddComment = () => {
|
||||
numberOfComments++;
|
||||
updateNumberOfComments(1);
|
||||
@ -184,13 +190,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
$: {
|
||||
if (isShared && asset.id) {
|
||||
handlePromiseError(getFavorite());
|
||||
handlePromiseError(getNumberOfComments());
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
unsubscribes.push(
|
||||
websocketEvents.on('on_upload_success', onAssetUpdate),
|
||||
@ -233,12 +232,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
$: {
|
||||
if (asset.id && !sharedLink) {
|
||||
handlePromiseError(handleGetAllAlbums());
|
||||
}
|
||||
}
|
||||
|
||||
const handleGetAllAlbums = async () => {
|
||||
if (isSharedLink()) {
|
||||
return;
|
||||
@ -337,7 +330,7 @@
|
||||
* Slide show mode
|
||||
*/
|
||||
|
||||
let assetViewerHtmlElement: HTMLElement;
|
||||
let assetViewerHtmlElement = $state<HTMLElement>();
|
||||
|
||||
const slideshowHistory = new SlideshowHistory((asset) => {
|
||||
setAsset(asset);
|
||||
@ -352,7 +345,7 @@
|
||||
|
||||
const handlePlaySlideshow = async () => {
|
||||
try {
|
||||
await assetViewerHtmlElement.requestFullscreen?.();
|
||||
await assetViewerHtmlElement?.requestFullscreen?.();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_enter_fullscreen'));
|
||||
$slideshowState = SlideshowState.StopSlideshow;
|
||||
@ -395,6 +388,28 @@
|
||||
const handleUpdateSelectedEditType = (type: string) => {
|
||||
selectedEditType = type;
|
||||
};
|
||||
let isFullScreen = $derived(fullscreenElement !== null);
|
||||
$effect(() => {
|
||||
if (asset) {
|
||||
handlePromiseError(refreshStack());
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (album && !album.isActivityEnabled && numberOfComments === 0) {
|
||||
isShowActivity = false;
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (isShared && asset.id) {
|
||||
handlePromiseError(getFavorite());
|
||||
handlePromiseError(getNumberOfComments());
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (asset.id && !sharedLink) {
|
||||
handlePromiseError(handleGetAllAlbums());
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document bind:fullscreenElement />
|
||||
@ -421,11 +436,12 @@
|
||||
onShowDetail={toggleDetailPanel}
|
||||
onClose={closeViewer}
|
||||
>
|
||||
<MotionPhotoAction
|
||||
slot="motion-photo"
|
||||
isPlaying={shouldPlayMotionPhoto}
|
||||
onClick={(shouldPlay) => (shouldPlayMotionPhoto = shouldPlay)}
|
||||
/>
|
||||
{#snippet motionPhoto()}
|
||||
<MotionPhotoAction
|
||||
isPlaying={shouldPlayMotionPhoto}
|
||||
onClick={(shouldPlay) => (shouldPlayMotionPhoto = shouldPlay)}
|
||||
/>
|
||||
{/snippet}
|
||||
</AssetViewerNavBar>
|
||||
</div>
|
||||
{/if}
|
||||
@ -442,7 +458,7 @@
|
||||
<div class="z-[1000] absolute w-full flex">
|
||||
<SlideshowBar
|
||||
{isFullScreen}
|
||||
onSetToFullScreen={() => assetViewerHtmlElement.requestFullscreen?.()}
|
||||
onSetToFullScreen={() => assetViewerHtmlElement?.requestFullscreen?.()}
|
||||
onPrevious={() => navigateAsset('previous')}
|
||||
onNext={() => navigateAsset('next')}
|
||||
onClose={() => ($slideshowState = SlideshowState.StopSlideshow)}
|
||||
@ -460,7 +476,7 @@
|
||||
{preloadAssets}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
on:close={closeViewer}
|
||||
onClose={closeViewer}
|
||||
haveFadeTransition={false}
|
||||
{sharedLink}
|
||||
/>
|
||||
@ -472,9 +488,9 @@
|
||||
loopVideo={true}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
on:close={closeViewer}
|
||||
on:onVideoEnded={() => navigateAsset()}
|
||||
on:onVideoStarted={handleVideoStarted}
|
||||
onClose={closeViewer}
|
||||
onVideoEnded={() => navigateAsset()}
|
||||
onVideoStarted={handleVideoStarted}
|
||||
/>
|
||||
{/if}
|
||||
{/key}
|
||||
@ -489,8 +505,7 @@
|
||||
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
on:close={closeViewer}
|
||||
on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
|
||||
onVideoEnded={() => (shouldPlayMotionPhoto = false)}
|
||||
/>
|
||||
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
|
||||
.toLowerCase()
|
||||
@ -506,7 +521,7 @@
|
||||
{preloadAssets}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
on:close={closeViewer}
|
||||
onClose={closeViewer}
|
||||
{sharedLink}
|
||||
haveFadeTransition={$slideshowState === SlideshowState.None || $slideshowTransition}
|
||||
/>
|
||||
@ -519,9 +534,9 @@
|
||||
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
on:close={closeViewer}
|
||||
on:onVideoEnded={() => navigateAsset()}
|
||||
on:onVideoStarted={handleVideoStarted}
|
||||
onClose={closeViewer}
|
||||
onVideoEnded={() => navigateAsset()}
|
||||
onVideoStarted={handleVideoStarted}
|
||||
/>
|
||||
{/if}
|
||||
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || numberOfComments > 0)}
|
||||
@ -574,7 +589,7 @@
|
||||
class="z-[1002] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 overflow-x-auto horizontal-scrollbar"
|
||||
>
|
||||
<div class="relative w-full whitespace-nowrap transition-all">
|
||||
{#each stackedAssets as stackedAsset, index (stackedAsset.id)}
|
||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||
<div
|
||||
class="{stackedAsset.id == asset.id
|
||||
? '-translate-y-[1px]'
|
||||
@ -587,7 +602,6 @@
|
||||
asset={stackedAsset}
|
||||
onClick={(stackedAsset) => {
|
||||
asset = stackedAsset;
|
||||
preloadAssets = index + 1 >= stackedAssets.length ? [] : [stackedAssets[index + 1]];
|
||||
}}
|
||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||
disableMouseOver
|
||||
|
@ -8,14 +8,21 @@
|
||||
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let isOwner: boolean;
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
$: description = asset.exifInfo?.description || '';
|
||||
let { asset, isOwner }: Props = $props();
|
||||
|
||||
let description = $derived(asset.exifInfo?.description || '');
|
||||
|
||||
const handleFocusOut = async (newDescription: string) => {
|
||||
try {
|
||||
await updateAsset({ id: asset.id, updateAssetDto: { description: newDescription } });
|
||||
|
||||
asset.exifInfo = { ...asset.exifInfo, description: newDescription };
|
||||
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: $t('asset_description_updated'),
|
||||
@ -23,7 +30,6 @@
|
||||
} catch (error) {
|
||||
handleError(error, $t('cannot_update_the_description'));
|
||||
}
|
||||
description = newDescription;
|
||||
};
|
||||
</script>
|
||||
|
||||
|
@ -7,10 +7,14 @@
|
||||
import { mdiMapMarkerOutline, mdiPencil } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let isOwner: boolean;
|
||||
export let asset: AssetResponseDto;
|
||||
interface Props {
|
||||
isOwner: boolean;
|
||||
asset: AssetResponseDto;
|
||||
}
|
||||
|
||||
let isShowChangeLocation = false;
|
||||
let { isOwner, asset = $bindable() }: Props = $props();
|
||||
|
||||
let isShowChangeLocation = $state(false);
|
||||
|
||||
async function handleConfirmChangeLocation(gps: { lng: number; lat: number }) {
|
||||
isShowChangeLocation = false;
|
||||
@ -30,7 +34,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full text-left justify-between place-items-start gap-4 py-4"
|
||||
on:click={() => (isOwner ? (isShowChangeLocation = true) : null)}
|
||||
onclick={() => (isOwner ? (isShowChangeLocation = true) : null)}
|
||||
title={isOwner ? $t('edit_location') : ''}
|
||||
class:hover:dark:text-immich-dark-primary={isOwner}
|
||||
class:hover:text-immich-primary={isOwner}
|
||||
@ -65,7 +69,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full text-left justify-between place-items-start gap-4 py-4 rounded-lg hover:dark:text-immich-dark-primary hover:text-immich-primary"
|
||||
on:click={() => (isShowChangeLocation = true)}
|
||||
onclick={() => (isShowChangeLocation = true)}
|
||||
title={$t('add_location')}
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
|
@ -6,10 +6,14 @@
|
||||
import { handlePromiseError, isSharedLink } from '$lib/utils';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let isOwner: boolean;
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
$: rating = asset.exifInfo?.rating || 0;
|
||||
let { asset, isOwner }: Props = $props();
|
||||
|
||||
let rating = $derived(asset.exifInfo?.rating || 0);
|
||||
|
||||
const handleChangeRating = async (rating: number) => {
|
||||
try {
|
||||
|
@ -9,12 +9,16 @@
|
||||
import { mdiClose, mdiPlus } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let isOwner: boolean;
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
$: tags = asset.tags || [];
|
||||
let { asset = $bindable(), isOwner }: Props = $props();
|
||||
|
||||
let isOpen = false;
|
||||
let tags = $derived(asset.tags || []);
|
||||
|
||||
let isOpen = $state(false);
|
||||
|
||||
const handleAdd = () => (isOpen = true);
|
||||
|
||||
@ -58,7 +62,7 @@
|
||||
type="button"
|
||||
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-tr-full rounded-br-full place-items-center place-content-center pr-2 pl-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
|
||||
title="Remove tag"
|
||||
on:click={() => handleRemove(tag.id)}
|
||||
onclick={() => handleRemove(tag.id)}
|
||||
>
|
||||
<Icon path={mdiClose} />
|
||||
</button>
|
||||
@ -68,7 +72,7 @@
|
||||
type="button"
|
||||
class="rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-700 dark:hover:text-gray-200 flex place-items-center place-content-center gap-1 px-2 py-1"
|
||||
title="Add tag"
|
||||
on:click={handleAdd}
|
||||
onclick={handleAdd}
|
||||
>
|
||||
<span class="text-sm px-1 flex place-items-center place-content-center gap-1"><Icon path={mdiPlus} />Add</span>
|
||||
</button>
|
||||
|
@ -46,10 +46,14 @@
|
||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let albums: AlbumResponseDto[] = [];
|
||||
export let currentAlbum: AlbumResponseDto | null = null;
|
||||
export let onClose: () => void;
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
albums?: AlbumResponseDto[];
|
||||
currentAlbum?: AlbumResponseDto | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { asset, albums = [], currentAlbum = null, onClose }: Props = $props();
|
||||
|
||||
const getDimensions = (exifInfo: ExifResponseDto) => {
|
||||
const { exifImageWidth: width, exifImageHeight: height } = exifInfo;
|
||||
@ -60,11 +64,11 @@
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
let showAssetPath = false;
|
||||
let showEditFaces = false;
|
||||
let previousId: string;
|
||||
let showAssetPath = $state(false);
|
||||
let showEditFaces = $state(false);
|
||||
let previousId: string | undefined = $state();
|
||||
|
||||
$: {
|
||||
$effect(() => {
|
||||
if (!previousId) {
|
||||
previousId = asset.id;
|
||||
}
|
||||
@ -72,9 +76,9 @@
|
||||
showEditFaces = false;
|
||||
previousId = asset.id;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$: isOwner = $user?.id === asset.ownerId;
|
||||
let isOwner = $derived($user?.id === asset.ownerId);
|
||||
|
||||
const handleNewAsset = async (newAsset: AssetResponseDto) => {
|
||||
// TODO: check if reloading asset data is necessary
|
||||
@ -85,27 +89,30 @@
|
||||
}
|
||||
};
|
||||
|
||||
$: handlePromiseError(handleNewAsset(asset));
|
||||
$effect(() => {
|
||||
handlePromiseError(handleNewAsset(asset));
|
||||
});
|
||||
|
||||
$: latlng = (() => {
|
||||
const lat = asset.exifInfo?.latitude;
|
||||
const lng = asset.exifInfo?.longitude;
|
||||
let latlng = $derived(
|
||||
(() => {
|
||||
const lat = asset.exifInfo?.latitude;
|
||||
const lng = asset.exifInfo?.longitude;
|
||||
|
||||
if (lat && lng) {
|
||||
return { lat: Number(lat.toFixed(7)), lng: Number(lng.toFixed(7)) };
|
||||
}
|
||||
})();
|
||||
if (lat && lng) {
|
||||
return { lat: Number(lat.toFixed(7)), lng: Number(lng.toFixed(7)) };
|
||||
}
|
||||
})(),
|
||||
);
|
||||
|
||||
$: people = asset.people || [];
|
||||
$: showingHiddenPeople = false;
|
||||
|
||||
$: unassignedFaces = asset.unassignedFaces || [];
|
||||
|
||||
$: timeZone = asset.exifInfo?.timeZone;
|
||||
$: dateTime =
|
||||
let people = $state(asset.people || []);
|
||||
let unassignedFaces = $state(asset.unassignedFaces || []);
|
||||
let showingHiddenPeople = $state(false);
|
||||
let timeZone = $derived(asset.exifInfo?.timeZone);
|
||||
let dateTime = $derived(
|
||||
timeZone && asset.exifInfo?.dateTimeOriginal
|
||||
? fromDateTimeOriginal(asset.exifInfo.dateTimeOriginal, timeZone)
|
||||
: fromLocalDateTime(asset.localDateTime);
|
||||
: fromLocalDateTime(asset.localDateTime),
|
||||
);
|
||||
|
||||
const getMegapixel = (width: number, height: number): number | undefined => {
|
||||
const megapixel = Math.round((height * width) / 1_000_000);
|
||||
@ -127,7 +134,7 @@
|
||||
|
||||
const toggleAssetPath = () => (showAssetPath = !showAssetPath);
|
||||
|
||||
let isShowChangeDate = false;
|
||||
let isShowChangeDate = $state(false);
|
||||
|
||||
async function handleConfirmChangeDate(dateTimeOriginal: string) {
|
||||
isShowChangeDate = false;
|
||||
@ -141,7 +148,7 @@
|
||||
|
||||
<section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
|
||||
<div class="flex place-items-center gap-2">
|
||||
<CircleIconButton icon={mdiClose} title={$t('close')} on:click={onClose} />
|
||||
<CircleIconButton icon={mdiClose} title={$t('close')} onclick={onClose} />
|
||||
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">{$t('info')}</p>
|
||||
</div>
|
||||
|
||||
@ -190,7 +197,7 @@
|
||||
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
|
||||
padding="1"
|
||||
buttonSize="32"
|
||||
on:click={() => (showingHiddenPeople = !showingHiddenPeople)}
|
||||
onclick={() => (showingHiddenPeople = !showingHiddenPeople)}
|
||||
/>
|
||||
{/if}
|
||||
<CircleIconButton
|
||||
@ -199,7 +206,7 @@
|
||||
padding="1"
|
||||
size="20"
|
||||
buttonSize="32"
|
||||
on:click={() => (showEditFaces = true)}
|
||||
onclick={() => (showEditFaces = true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -212,10 +219,10 @@
|
||||
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={currentAlbum?.id
|
||||
? `${AppRoute.ALBUMS}/${currentAlbum?.id}`
|
||||
: AppRoute.PHOTOS}"
|
||||
on:focus={() => ($boundingBoxesArray = people[index].faces)}
|
||||
on:blur={() => ($boundingBoxesArray = [])}
|
||||
on:mouseover={() => ($boundingBoxesArray = people[index].faces)}
|
||||
on:mouseleave={() => ($boundingBoxesArray = [])}
|
||||
onfocus={() => ($boundingBoxesArray = people[index].faces)}
|
||||
onblur={() => ($boundingBoxesArray = [])}
|
||||
onmouseover={() => ($boundingBoxesArray = people[index].faces)}
|
||||
onmouseleave={() => ($boundingBoxesArray = [])}
|
||||
>
|
||||
<div class="relative">
|
||||
<ImageThumbnail
|
||||
@ -278,7 +285,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full text-left justify-between place-items-start gap-4 py-4"
|
||||
on:click={() => (isOwner ? (isShowChangeDate = true) : null)}
|
||||
onclick={() => (isOwner ? (isShowChangeDate = true) : null)}
|
||||
title={isOwner ? $t('edit_date') : ''}
|
||||
class:hover:dark:text-immich-dark-primary={isOwner}
|
||||
class:hover:text-immich-primary={isOwner}
|
||||
@ -357,7 +364,7 @@
|
||||
title={$t('show_file_location')}
|
||||
size="16"
|
||||
padding="2"
|
||||
on:click={toggleAssetPath}
|
||||
onclick={toggleAssetPath}
|
||||
/>
|
||||
{/if}
|
||||
</p>
|
||||
@ -428,8 +435,7 @@
|
||||
</div>
|
||||
{/await}
|
||||
{:then component}
|
||||
<svelte:component
|
||||
this={component.default}
|
||||
<component.default
|
||||
mapMarkers={[
|
||||
{
|
||||
lat: latlng.lat,
|
||||
@ -446,7 +452,7 @@
|
||||
useLocationPin
|
||||
onOpenInMapView={() => goto(`${AppRoute.MAP}#12.5/${latlng.lat}/${latlng.lng}`)}
|
||||
>
|
||||
<svelte:fragment slot="popup" let:marker>
|
||||
{#snippet popup({ marker })}
|
||||
{@const { lat, lon } = marker}
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<p class="font-bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</p>
|
||||
@ -458,8 +464,8 @@
|
||||
{$t('open_in_openstreetmap')}
|
||||
</a>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</svelte:component>
|
||||
{/snippet}
|
||||
</component.default>
|
||||
{/await}
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -44,7 +44,7 @@
|
||||
<div class="absolute right-2">
|
||||
<CircleIconButton
|
||||
title={$t('close')}
|
||||
on:click={() => abort(downloadKey, download)}
|
||||
onclick={() => abort(downloadKey, download)}
|
||||
size="20"
|
||||
icon={mdiClose}
|
||||
class="dark:text-immich-dark-gray"
|
||||
|
@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount, afterUpdate, onDestroy, tick } from 'svelte';
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { getAssetOriginalUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
@ -17,11 +17,23 @@
|
||||
resetGlobalCropStore,
|
||||
rotateDegrees,
|
||||
} from '$lib/stores/asset-editor.store';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
export let asset;
|
||||
let img: HTMLImageElement;
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
}
|
||||
|
||||
$: imgElement.set(img);
|
||||
let { asset }: Props = $props();
|
||||
|
||||
let img = $state<HTMLImageElement>();
|
||||
|
||||
$effect(() => {
|
||||
if (!img) {
|
||||
return;
|
||||
}
|
||||
|
||||
imgElement.set(img);
|
||||
});
|
||||
|
||||
cropAspectRatio.subscribe((value) => {
|
||||
if (!img || !$cropAreaEl) {
|
||||
@ -54,7 +66,7 @@
|
||||
resetGlobalCropStore();
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
$effect(() => {
|
||||
resizeCanvas();
|
||||
});
|
||||
</script>
|
||||
@ -64,8 +76,8 @@
|
||||
class={`crop-area ${$changedOriention ? 'changedOriention' : ''}`}
|
||||
style={`rotate:${$rotateDegrees}deg`}
|
||||
bind:this={$cropAreaEl}
|
||||
on:mousedown={handleMouseDown}
|
||||
on:mouseup={handleMouseUp}
|
||||
onmousedown={handleMouseDown}
|
||||
onmouseup={handleMouseUp}
|
||||
aria-label="Crop area"
|
||||
type="button"
|
||||
>
|
||||
|
@ -3,37 +3,41 @@
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import type { CropAspectRatio } from '$lib/stores/asset-editor.store';
|
||||
|
||||
export let size: {
|
||||
icon: string;
|
||||
name: CropAspectRatio;
|
||||
viewBox: string;
|
||||
rotate?: boolean;
|
||||
};
|
||||
export let selectedSize: CropAspectRatio;
|
||||
export let rotateHorizontal: boolean;
|
||||
export let selectType: (size: CropAspectRatio) => void;
|
||||
interface Props {
|
||||
size: {
|
||||
icon: string;
|
||||
name: CropAspectRatio;
|
||||
viewBox: string;
|
||||
rotate?: boolean;
|
||||
};
|
||||
selectedSize: CropAspectRatio;
|
||||
rotateHorizontal: boolean;
|
||||
selectType: (size: CropAspectRatio) => void;
|
||||
}
|
||||
|
||||
$: isSelected = selectedSize === size.name;
|
||||
$: buttonColor = (isSelected ? 'primary' : 'transparent-gray') as Color;
|
||||
let { size, selectedSize, rotateHorizontal, selectType }: Props = $props();
|
||||
|
||||
$: rotatedTitle = (title: string, toRotate: boolean) => {
|
||||
let isSelected = $derived(selectedSize === size.name);
|
||||
let buttonColor = $derived((isSelected ? 'primary' : 'transparent-gray') as Color);
|
||||
|
||||
let rotatedTitle = $derived((title: string, toRotate: boolean) => {
|
||||
let sides = title.split(':');
|
||||
if (toRotate) {
|
||||
sides.reverse();
|
||||
}
|
||||
return sides.join(':');
|
||||
};
|
||||
});
|
||||
|
||||
$: toRotate = (def: boolean | undefined) => {
|
||||
let toRotate = $derived((def: boolean | undefined) => {
|
||||
if (def === false) {
|
||||
return false;
|
||||
}
|
||||
return (def && !rotateHorizontal) || (!def && rotateHorizontal);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<li>
|
||||
<Button color={buttonColor} class="flex-col gap-1" size="sm" rounded="lg" on:click={() => selectType(size.name)}>
|
||||
<Button color={buttonColor} class="flex-col gap-1" size="sm" rounded="lg" onclick={() => selectType(size.name)}>
|
||||
<Icon size="1.75em" path={size.icon} viewBox={size.viewBox} class={toRotate(size.rotate) ? 'rotate-90' : ''} />
|
||||
<span>{rotatedTitle(size.name, rotateHorizontal)}</span>
|
||||
</Button>
|
||||
|
@ -16,7 +16,7 @@
|
||||
import { tick } from 'svelte';
|
||||
import CropPreset from './crop-preset.svelte';
|
||||
|
||||
$: rotateHorizontal = [90, 270].includes($normaizedRorateDegrees);
|
||||
let rotateHorizontal = $derived([90, 270].includes($normaizedRorateDegrees));
|
||||
const icon_16_9 = `M200-280q-33 0-56.5-23.5T120-360v-240q0-33 23.5-56.5T200-680h560q33 0 56.5 23.5T840-600v240q0 33-23.5 56.5T760-280H200Zm0-80h560v-240H200v240Zm0 0v-240 240Z`;
|
||||
const icon_4_3 = `M19 5H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 12H5V7h14v10z`;
|
||||
const icon_3_2 = `M200-240q-33 0-56.5-23.5T120-320v-320q0-33 23.5-56.5T200-720h560q33 0 56.5 23.5T840-640v320q0 33-23.5 56.5T760-240H200Zm0-80h560v-320H200v320Zm0 0v-320 320Z`;
|
||||
@ -92,14 +92,17 @@
|
||||
},
|
||||
];
|
||||
|
||||
let selectedSize: CropAspectRatio = 'free';
|
||||
$cropAspectRatio = selectedSize;
|
||||
let selectedSize: CropAspectRatio = $state('free');
|
||||
|
||||
$: sizesRows = [
|
||||
$effect(() => {
|
||||
$cropAspectRatio = selectedSize;
|
||||
});
|
||||
|
||||
let sizesRows = $derived([
|
||||
sizes.filter((s) => s.rotate === false),
|
||||
sizes.filter((s) => s.rotate === undefined),
|
||||
sizes.filter((s) => s.rotate === true),
|
||||
];
|
||||
]);
|
||||
|
||||
async function rotate(clock: boolean) {
|
||||
rotateDegrees.update((v) => {
|
||||
@ -145,7 +148,7 @@
|
||||
<h2>{$t('editor_crop_tool_h2_rotation').toUpperCase()}</h2>
|
||||
</div>
|
||||
<ul class="flex-wrap flex-row flex gap-x-6 gap-y-4 justify-center">
|
||||
<li><CircleIconButton title={$t('anti_clockwise')} on:click={() => rotate(false)} icon={mdiRotateLeft} /></li>
|
||||
<li><CircleIconButton title={$t('clockwise')} on:click={() => rotate(true)} icon={mdiRotateRight} /></li>
|
||||
<li><CircleIconButton title={$t('anti_clockwise')} onclick={() => rotate(false)} icon={mdiRotateLeft} /></li>
|
||||
<li><CircleIconButton title={$t('clockwise')} onclick={() => rotate(true)} icon={mdiRotateRight} /></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -9,8 +9,6 @@
|
||||
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
|
||||
onMount(() => {
|
||||
return websocketEvents.on('on_asset_update', (assetUpdate) => {
|
||||
if (assetUpdate.id === asset.id) {
|
||||
@ -19,12 +17,16 @@
|
||||
});
|
||||
});
|
||||
|
||||
export let onUpdateSelectedType: (type: string) => void;
|
||||
export let onClose: () => void;
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
onUpdateSelectedType: (type: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let selectedType: string = editTypes[0].name;
|
||||
// svelte-ignore reactive_declaration_non_reactive_property
|
||||
$: selectedTypeObj = editTypes.find((t) => t.name === selectedType) || editTypes[0];
|
||||
let { asset = $bindable(), onUpdateSelectedType, onClose }: Props = $props();
|
||||
|
||||
let selectedType: string = $state(editTypes[0].name);
|
||||
let selectedTypeObj = $derived(editTypes.find((t) => t.name === selectedType) || editTypes[0]);
|
||||
|
||||
setTimeout(() => {
|
||||
onUpdateSelectedType(selectedType);
|
||||
@ -39,7 +41,7 @@
|
||||
|
||||
<section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
|
||||
<div class="flex place-items-center gap-2">
|
||||
<CircleIconButton icon={mdiClose} title={$t('close')} on:click={onClose} />
|
||||
<CircleIconButton icon={mdiClose} title={$t('close')} onclick={onClose} />
|
||||
<p class="text-lg text-immich-fg dark:text-immich-dark-fg capitalize">{$t('editor')}</p>
|
||||
</div>
|
||||
<section class="px-4 py-4">
|
||||
@ -50,14 +52,14 @@
|
||||
color={etype.name === selectedType ? 'primary' : 'opaque'}
|
||||
icon={etype.icon}
|
||||
title={etype.name}
|
||||
on:click={() => selectType(etype.name)}
|
||||
onclick={() => selectType(etype.name)}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<svelte:component this={selectedTypeObj.component} />
|
||||
<selectedTypeObj.component />
|
||||
</section>
|
||||
</section>
|
||||
|
||||
|
@ -1,13 +1,20 @@
|
||||
<script lang="ts">
|
||||
export let onClick: (e: MouseEvent) => void;
|
||||
export let label: string;
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
onClick: (e: MouseEvent) => void;
|
||||
label: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { onClick, label, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="my-auto mx-4 rounded-full p-3 text-gray-500 transition hover:bg-gray-500 hover:text-white"
|
||||
aria-label={label}
|
||||
on:click={onClick}
|
||||
onclick={onClick}
|
||||
>
|
||||
<slot />
|
||||
{@render children?.()}
|
||||
</button>
|
||||
|
@ -8,7 +8,11 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
|
||||
export let asset: { id: string; type: AssetTypeEnum.Video } | AssetResponseDto;
|
||||
interface Props {
|
||||
asset: { id: string; type: AssetTypeEnum.Video } | AssetResponseDto;
|
||||
}
|
||||
|
||||
let { asset }: Props = $props();
|
||||
|
||||
const photoSphereConfigs =
|
||||
asset.type === AssetTypeEnum.Video
|
||||
@ -43,14 +47,7 @@
|
||||
{#await Promise.all([loadAssetData(), import('./photo-sphere-viewer-adapter.svelte'), ...photoSphereConfigs])}
|
||||
<LoadingSpinner />
|
||||
{:then [data, module, adapter, plugins, navbar]}
|
||||
<svelte:component
|
||||
this={module.default}
|
||||
panorama={data}
|
||||
plugins={plugins ?? undefined}
|
||||
{navbar}
|
||||
{adapter}
|
||||
{originalImageUrl}
|
||||
/>
|
||||
<module.default panorama={data} plugins={plugins ?? undefined} {navbar} {adapter} {originalImageUrl} />
|
||||
{:catch}
|
||||
{$t('errors.failed_to_load_asset')}
|
||||
{/await}
|
||||
|
@ -10,16 +10,24 @@
|
||||
import '@photo-sphere-viewer/core/index.css';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
export let panorama: string | { source: string };
|
||||
export let originalImageUrl: string | null;
|
||||
export let adapter: AdapterConstructor | [AdapterConstructor, unknown] = EquirectangularAdapter;
|
||||
export let plugins: (PluginConstructor | [PluginConstructor, unknown])[] = [];
|
||||
export let navbar = false;
|
||||
interface Props {
|
||||
panorama: string | { source: string };
|
||||
originalImageUrl: string | null;
|
||||
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
|
||||
plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
|
||||
navbar?: boolean;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let { panorama, originalImageUrl, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
|
||||
|
||||
let container: HTMLDivElement | undefined = $state();
|
||||
let viewer: Viewer;
|
||||
|
||||
onMount(() => {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewer = new Viewer({
|
||||
adapter,
|
||||
plugins,
|
||||
|
@ -20,33 +20,38 @@
|
||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let preloadAssets: AssetResponseDto[] | undefined = undefined;
|
||||
export let element: HTMLDivElement | undefined = undefined;
|
||||
export let haveFadeTransition = true;
|
||||
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
|
||||
export let onPreviousAsset: (() => void) | null = null;
|
||||
export let onNextAsset: (() => void) | null = null;
|
||||
export let copyImage: (() => Promise<void>) | null = null;
|
||||
export let zoomToggle: (() => void) | null = null;
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
preloadAssets?: AssetResponseDto[] | undefined;
|
||||
element?: HTMLDivElement | undefined;
|
||||
haveFadeTransition?: boolean;
|
||||
sharedLink?: SharedLinkResponseDto | undefined;
|
||||
onPreviousAsset?: (() => void) | null;
|
||||
onNextAsset?: (() => void) | null;
|
||||
copyImage?: () => Promise<void>;
|
||||
zoomToggle?: (() => void) | null;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
asset,
|
||||
preloadAssets = undefined,
|
||||
element = $bindable(),
|
||||
haveFadeTransition = true,
|
||||
sharedLink = undefined,
|
||||
onPreviousAsset = null,
|
||||
onNextAsset = null,
|
||||
copyImage = $bindable(),
|
||||
zoomToggle = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
|
||||
let assetFileUrl: string = '';
|
||||
let imageLoaded: boolean = false;
|
||||
let imageError: boolean = false;
|
||||
let forceUseOriginal: boolean = false;
|
||||
let loader: HTMLImageElement;
|
||||
let assetFileUrl: string = $state('');
|
||||
let imageLoaded: boolean = $state(false);
|
||||
let imageError: boolean = $state(false);
|
||||
|
||||
$: isWebCompatible = isWebCompatibleImage(asset);
|
||||
$: useOriginalByDefault = isWebCompatible && $alwaysLoadOriginalFile;
|
||||
$: useOriginalImage = useOriginalByDefault || forceUseOriginal;
|
||||
// when true, will force loading of the original image
|
||||
$: forceUseOriginal =
|
||||
forceUseOriginal || asset.originalMimeType === 'image/gif' || ($photoZoomState.currentZoom > 1 && isWebCompatible);
|
||||
|
||||
$: preload(useOriginalImage, preloadAssets);
|
||||
$: imageLoaderUrl = getAssetUrl(asset.id, useOriginalImage, asset.checksum);
|
||||
let loader = $state<HTMLImageElement>();
|
||||
|
||||
photoZoomState.set({
|
||||
currentRotation: 0,
|
||||
@ -129,16 +134,31 @@
|
||||
const onerror = () => {
|
||||
imageError = imageLoaded = true;
|
||||
};
|
||||
if (loader.complete) {
|
||||
if (loader?.complete) {
|
||||
onload();
|
||||
}
|
||||
loader.addEventListener('load', onload);
|
||||
loader.addEventListener('error', onerror);
|
||||
loader?.addEventListener('load', onload);
|
||||
loader?.addEventListener('error', onerror);
|
||||
return () => {
|
||||
loader?.removeEventListener('load', onload);
|
||||
loader?.removeEventListener('error', onerror);
|
||||
};
|
||||
});
|
||||
let isWebCompatible = $derived(isWebCompatibleImage(asset));
|
||||
let useOriginalByDefault = $derived(isWebCompatible && $alwaysLoadOriginalFile);
|
||||
// when true, will force loading of the original image
|
||||
|
||||
let forceUseOriginal: boolean = $derived(
|
||||
asset.originalMimeType === 'image/gif' || ($photoZoomState.currentZoom > 1 && isWebCompatible),
|
||||
);
|
||||
|
||||
let useOriginalImage = $derived(useOriginalByDefault || forceUseOriginal);
|
||||
|
||||
$effect(() => {
|
||||
preload(useOriginalImage, preloadAssets);
|
||||
});
|
||||
|
||||
let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.checksum));
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
@ -150,15 +170,15 @@
|
||||
{#if imageError}
|
||||
<BrokenAsset class="text-xl" />
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<!-- svelte-ignore a11y_missing_attribute -->
|
||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
|
||||
<div bind:this={element} class="relative h-full select-none">
|
||||
<img
|
||||
style="display:none"
|
||||
src={imageLoaderUrl}
|
||||
alt={$getAltText(asset)}
|
||||
on:load={() => ((imageLoaded = true), (assetFileUrl = imageLoaderUrl))}
|
||||
on:error={() => (imageError = imageLoaded = true)}
|
||||
onload={() => ((imageLoaded = true), (assetFileUrl = imageLoaderUrl))}
|
||||
onerror={() => (imageError = imageLoaded = true)}
|
||||
/>
|
||||
{#if !imageLoaded}
|
||||
<div id="spinner" class="flex h-full items-center justify-center">
|
||||
@ -168,7 +188,7 @@
|
||||
<div
|
||||
use:zoomImageAction
|
||||
use:swipe
|
||||
on:swipe={onSwipe}
|
||||
onswipe={onSwipe}
|
||||
class="h-full w-full"
|
||||
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
|
||||
>
|
||||
|
@ -9,20 +9,30 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
export let isFullScreen: boolean;
|
||||
export let onNext = () => {};
|
||||
export let onPrevious = () => {};
|
||||
export let onClose = () => {};
|
||||
export let onSetToFullScreen = () => {};
|
||||
interface Props {
|
||||
isFullScreen: boolean;
|
||||
onNext?: () => void;
|
||||
onPrevious?: () => void;
|
||||
onClose?: () => void;
|
||||
onSetToFullScreen?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
isFullScreen,
|
||||
onNext = () => {},
|
||||
onPrevious = () => {},
|
||||
onClose = () => {},
|
||||
onSetToFullScreen = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
const { restartProgress, stopProgress, slideshowDelay, showProgressBar, slideshowNavigation } = slideshowStore;
|
||||
|
||||
let progressBarStatus: ProgressBarStatus;
|
||||
let progressBar: ProgressBar;
|
||||
let showSettings = false;
|
||||
let showControls = true;
|
||||
let progressBarStatus: ProgressBarStatus | undefined = $state();
|
||||
let progressBar = $state<ReturnType<typeof ProgressBar>>();
|
||||
let showSettings = $state(false);
|
||||
let showControls = $state(true);
|
||||
let timer: NodeJS.Timeout;
|
||||
let isOverControls = false;
|
||||
let isOverControls = $state(false);
|
||||
|
||||
let unsubscribeRestart: () => void;
|
||||
let unsubscribeStop: () => void;
|
||||
@ -55,13 +65,13 @@
|
||||
hideControlsAfterDelay();
|
||||
unsubscribeRestart = restartProgress.subscribe((value) => {
|
||||
if (value) {
|
||||
progressBar.restart(value);
|
||||
progressBar?.restart(value);
|
||||
}
|
||||
});
|
||||
|
||||
unsubscribeStop = stopProgress.subscribe((value) => {
|
||||
if (value) {
|
||||
progressBar.restart(false);
|
||||
progressBar?.restart(false);
|
||||
stopControlsHideTimer();
|
||||
}
|
||||
});
|
||||
@ -77,7 +87,9 @@
|
||||
}
|
||||
});
|
||||
|
||||
const handleDone = () => {
|
||||
const handleDone = async () => {
|
||||
await progressBar?.reset();
|
||||
|
||||
if ($slideshowNavigation === SlideshowNavigation.AscendingOrder) {
|
||||
onPrevious();
|
||||
return;
|
||||
@ -87,7 +99,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:mousemove={showControlBar}
|
||||
onmousemove={showControlBar}
|
||||
use:shortcuts={[
|
||||
{ shortcut: { key: 'Escape' }, onShortcut: onClose },
|
||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: onPrevious },
|
||||
@ -98,32 +110,32 @@
|
||||
{#if showControls}
|
||||
<div
|
||||
class="m-4 flex gap-2"
|
||||
on:mouseenter={() => (isOverControls = true)}
|
||||
on:mouseleave={() => (isOverControls = false)}
|
||||
onmouseenter={() => (isOverControls = true)}
|
||||
onmouseleave={() => (isOverControls = false)}
|
||||
transition:fly={{ duration: 150 }}
|
||||
role="navigation"
|
||||
>
|
||||
<CircleIconButton buttonSize="50" icon={mdiClose} on:click={onClose} title={$t('exit_slideshow')} />
|
||||
<CircleIconButton buttonSize="50" icon={mdiClose} onclick={onClose} title={$t('exit_slideshow')} />
|
||||
|
||||
<CircleIconButton
|
||||
buttonSize="50"
|
||||
icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
|
||||
on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
|
||||
onclick={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar?.play() : progressBar?.pause())}
|
||||
title={progressBarStatus === ProgressBarStatus.Paused ? $t('play') : $t('pause')}
|
||||
/>
|
||||
<CircleIconButton buttonSize="50" icon={mdiChevronLeft} on:click={onPrevious} title={$t('previous')} />
|
||||
<CircleIconButton buttonSize="50" icon={mdiChevronRight} on:click={onNext} title={$t('next')} />
|
||||
<CircleIconButton buttonSize="50" icon={mdiChevronLeft} onclick={onPrevious} title={$t('previous')} />
|
||||
<CircleIconButton buttonSize="50" icon={mdiChevronRight} onclick={onNext} title={$t('next')} />
|
||||
<CircleIconButton
|
||||
buttonSize="50"
|
||||
icon={mdiCog}
|
||||
on:click={() => (showSettings = !showSettings)}
|
||||
onclick={() => (showSettings = !showSettings)}
|
||||
title={$t('slideshow_settings')}
|
||||
/>
|
||||
{#if !isFullScreen}
|
||||
<CircleIconButton
|
||||
buttonSize="50"
|
||||
icon={mdiFullscreen}
|
||||
on:click={onSetToFullScreen}
|
||||
onclick={onSetToFullScreen}
|
||||
title={$t('set_slideshow_to_fullscreen')}
|
||||
/>
|
||||
{/if}
|
||||
|
@ -4,31 +4,53 @@
|
||||
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import { tick } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { swipe } from 'svelte-gestures';
|
||||
import type { SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let assetId: string;
|
||||
export let loopVideo: boolean;
|
||||
export let checksum: string;
|
||||
export let onPreviousAsset: () => void = () => {};
|
||||
export let onNextAsset: () => void = () => {};
|
||||
export let onVideoEnded: () => void = () => {};
|
||||
export let onVideoStarted: () => void = () => {};
|
||||
|
||||
let element: HTMLVideoElement | undefined = undefined;
|
||||
let isVideoLoading = true;
|
||||
let assetFileUrl: string;
|
||||
let forceMuted = false;
|
||||
|
||||
$: if (element) {
|
||||
assetFileUrl = getAssetPlaybackUrl({ id: assetId, checksum });
|
||||
forceMuted = false;
|
||||
element.load();
|
||||
interface Props {
|
||||
assetId: string;
|
||||
loopVideo: boolean;
|
||||
checksum: string;
|
||||
onPreviousAsset?: () => void;
|
||||
onNextAsset?: () => void;
|
||||
onVideoEnded?: () => void;
|
||||
onVideoStarted?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
assetId,
|
||||
loopVideo,
|
||||
checksum,
|
||||
onPreviousAsset = () => {},
|
||||
onNextAsset = () => {},
|
||||
onVideoEnded = () => {},
|
||||
onVideoStarted = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||
let isLoading = $state(true);
|
||||
let assetFileUrl = $state('');
|
||||
let forceMuted = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (videoPlayer) {
|
||||
assetFileUrl = getAssetPlaybackUrl({ id: assetId, checksum });
|
||||
forceMuted = false;
|
||||
videoPlayer.load();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (videoPlayer) {
|
||||
videoPlayer.src = '';
|
||||
}
|
||||
});
|
||||
|
||||
const handleCanPlay = async (video: HTMLVideoElement) => {
|
||||
try {
|
||||
await video.play();
|
||||
@ -38,16 +60,16 @@
|
||||
await tryForceMutedPlay(video);
|
||||
return;
|
||||
}
|
||||
|
||||
handleError(error, $t('errors.unable_to_play_video'));
|
||||
} finally {
|
||||
isVideoLoading = false;
|
||||
isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const tryForceMutedPlay = async (video: HTMLVideoElement) => {
|
||||
try {
|
||||
forceMuted = true;
|
||||
await tick();
|
||||
video.muted = true;
|
||||
await handleCanPlay(video);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_play_video'));
|
||||
@ -66,21 +88,22 @@
|
||||
|
||||
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
|
||||
<video
|
||||
bind:this={element}
|
||||
bind:this={videoPlayer}
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay
|
||||
playsinline
|
||||
controls
|
||||
class="h-full object-contain"
|
||||
use:swipe
|
||||
on:swipe={onSwipe}
|
||||
on:canplay={(e) => handleCanPlay(e.currentTarget)}
|
||||
on:ended={onVideoEnded}
|
||||
on:volumechange={(e) => {
|
||||
onswipe={onSwipe}
|
||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||
onended={onVideoEnded}
|
||||
onvolumechange={(e) => {
|
||||
if (!forceMuted) {
|
||||
$videoViewerMuted = e.currentTarget.muted;
|
||||
}
|
||||
}}
|
||||
onclose={() => onClose()}
|
||||
muted={forceMuted || $videoViewerMuted}
|
||||
bind:volume={$videoViewerVolume}
|
||||
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, checksum })}
|
||||
@ -88,7 +111,7 @@
|
||||
>
|
||||
</video>
|
||||
|
||||
{#if isVideoLoading}
|
||||
{#if isLoading}
|
||||
<div class="absolute flex place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
@ -4,12 +4,29 @@
|
||||
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
|
||||
import PanoramaViewer from '$lib/components/asset-viewer/panorama-viewer.svelte';
|
||||
|
||||
export let assetId: string;
|
||||
export let projectionType: string | null | undefined;
|
||||
export let checksum: string;
|
||||
export let loopVideo: boolean;
|
||||
export let onPreviousAsset: () => void;
|
||||
export let onNextAsset: () => void;
|
||||
interface Props {
|
||||
assetId: string;
|
||||
projectionType: string | null | undefined;
|
||||
checksum: string;
|
||||
loopVideo: boolean;
|
||||
onClose?: () => void;
|
||||
onPreviousAsset?: () => void;
|
||||
onNextAsset?: () => void;
|
||||
onVideoEnded?: () => void;
|
||||
onVideoStarted?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
assetId,
|
||||
projectionType,
|
||||
checksum,
|
||||
loopVideo,
|
||||
onPreviousAsset,
|
||||
onClose,
|
||||
onNextAsset,
|
||||
onVideoEnded,
|
||||
onVideoStarted,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
|
||||
@ -21,7 +38,8 @@
|
||||
{assetId}
|
||||
{onPreviousAsset}
|
||||
{onNextAsset}
|
||||
on:onVideoEnded
|
||||
on:onVideoStarted
|
||||
{onVideoEnded}
|
||||
{onVideoStarted}
|
||||
{onClose}
|
||||
/>
|
||||
{/if}
|
||||
|
Reference in New Issue
Block a user