mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
parent
7c2f7d6c51
commit
f55b3add80
242 changed files with 12794 additions and 13426 deletions
|
|
@ -1,56 +1,56 @@
|
|||
<script lang="ts">
|
||||
import { AlbumResponseDto, ThumbnailFormat, api } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { AlbumResponseDto, ThumbnailFormat, api } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatcher = createEventDispatcher();
|
||||
const dispatcher = createEventDispatcher();
|
||||
|
||||
export let album: AlbumResponseDto;
|
||||
export let variant: 'simple' | 'full' = 'full';
|
||||
export let searchQuery = '';
|
||||
let albumNameArray: string[] = ['', '', ''];
|
||||
export let album: AlbumResponseDto;
|
||||
export let variant: 'simple' | 'full' = 'full';
|
||||
export let searchQuery = '';
|
||||
let albumNameArray: string[] = ['', '', ''];
|
||||
|
||||
// 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
|
||||
$: {
|
||||
let { albumName } = album;
|
||||
let findIndex = albumName.toLowerCase().indexOf(searchQuery.toLowerCase());
|
||||
let findLength = searchQuery.length;
|
||||
albumNameArray = [
|
||||
albumName.slice(0, findIndex),
|
||||
albumName.slice(findIndex, findIndex + findLength),
|
||||
albumName.slice(findIndex + findLength)
|
||||
];
|
||||
}
|
||||
// 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
|
||||
$: {
|
||||
let { albumName } = album;
|
||||
let findIndex = albumName.toLowerCase().indexOf(searchQuery.toLowerCase());
|
||||
let findLength = searchQuery.length;
|
||||
albumNameArray = [
|
||||
albumName.slice(0, findIndex),
|
||||
albumName.slice(findIndex, findIndex + findLength),
|
||||
albumName.slice(findIndex + findLength),
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
on:click={() => dispatcher('album')}
|
||||
class="w-full flex gap-4 px-6 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
on:click={() => dispatcher('album')}
|
||||
class="w-full flex gap-4 px-6 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div class="h-12 w-12 rounded-xl bg-slate-300">
|
||||
{#if album.albumThumbnailAssetId}
|
||||
<img
|
||||
src={api.getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Webp)}
|
||||
alt={album.albumName}
|
||||
class={`object-cover h-full w-full transition-all z-0 rounded-xl duration-300 hover:shadow-lg`}
|
||||
data-testid="album-image"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="h-12 flex flex-col items-start justify-center">
|
||||
<span>{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span>
|
||||
<span class="flex gap-1 text-sm">
|
||||
{#if variant === 'simple'}
|
||||
<span
|
||||
>{#if album.shared}Shared{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span>{album.assetCount} items</span>
|
||||
<span
|
||||
>{#if album.shared} · Shared{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-12 w-12 rounded-xl bg-slate-300">
|
||||
{#if album.albumThumbnailAssetId}
|
||||
<img
|
||||
src={api.getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Webp)}
|
||||
alt={album.albumName}
|
||||
class={`object-cover h-full w-full transition-all z-0 rounded-xl duration-300 hover:shadow-lg`}
|
||||
data-testid="album-image"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="h-12 flex flex-col items-start justify-center">
|
||||
<span>{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span>
|
||||
<span class="flex gap-1 text-sm">
|
||||
{#if variant === 'simple'}
|
||||
<span
|
||||
>{#if album.shared}Shared{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span>{album.assetCount} items</span>
|
||||
<span
|
||||
>{#if album.shared} · Shared{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,155 +1,135 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { clickOutside } from '$lib/utils/click-outside';
|
||||
import type { AssetResponseDto } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
|
||||
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
|
||||
import ContentCopy from 'svelte-material-icons/ContentCopy.svelte';
|
||||
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
|
||||
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
|
||||
import Heart from 'svelte-material-icons/Heart.svelte';
|
||||
import HeartOutline from 'svelte-material-icons/HeartOutline.svelte';
|
||||
import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
|
||||
import MagnifyPlusOutline from 'svelte-material-icons/MagnifyPlusOutline.svelte';
|
||||
import MagnifyMinusOutline from 'svelte-material-icons/MagnifyMinusOutline.svelte';
|
||||
import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
|
||||
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
|
||||
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { page } from '$app/stores';
|
||||
import { clickOutside } from '$lib/utils/click-outside';
|
||||
import type { AssetResponseDto } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
|
||||
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
|
||||
import ContentCopy from 'svelte-material-icons/ContentCopy.svelte';
|
||||
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
|
||||
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
|
||||
import Heart from 'svelte-material-icons/Heart.svelte';
|
||||
import HeartOutline from 'svelte-material-icons/HeartOutline.svelte';
|
||||
import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
|
||||
import MagnifyPlusOutline from 'svelte-material-icons/MagnifyPlusOutline.svelte';
|
||||
import MagnifyMinusOutline from 'svelte-material-icons/MagnifyMinusOutline.svelte';
|
||||
import MotionPauseOutline from 'svelte-material-icons/MotionPauseOutline.svelte';
|
||||
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
|
||||
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let showCopyButton: boolean;
|
||||
export let showZoomButton: boolean;
|
||||
export let showMotionPlayButton: boolean;
|
||||
export let isMotionPhotoPlaying = false;
|
||||
export let showDownloadButton: boolean;
|
||||
export let asset: AssetResponseDto;
|
||||
export let showCopyButton: boolean;
|
||||
export let showZoomButton: boolean;
|
||||
export let showMotionPlayButton: boolean;
|
||||
export let isMotionPhotoPlaying = false;
|
||||
export let showDownloadButton: boolean;
|
||||
|
||||
const isOwner = asset.ownerId === $page.data.user?.id;
|
||||
const isOwner = asset.ownerId === $page.data.user?.id;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let contextMenuPosition = { x: 0, y: 0 };
|
||||
let isShowAssetOptions = false;
|
||||
let contextMenuPosition = { x: 0, y: 0 };
|
||||
let isShowAssetOptions = false;
|
||||
|
||||
const showOptionsMenu = ({ x, y }: MouseEvent) => {
|
||||
contextMenuPosition = { x, y };
|
||||
isShowAssetOptions = !isShowAssetOptions;
|
||||
};
|
||||
const showOptionsMenu = ({ x, y }: MouseEvent) => {
|
||||
contextMenuPosition = { x, y };
|
||||
isShowAssetOptions = !isShowAssetOptions;
|
||||
};
|
||||
|
||||
const onMenuClick = (eventName: string) => {
|
||||
isShowAssetOptions = false;
|
||||
dispatch(eventName);
|
||||
};
|
||||
const onMenuClick = (eventName: string) => {
|
||||
isShowAssetOptions = false;
|
||||
dispatch(eventName);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="h-16 flex justify-between place-items-center px-3 transition-transform duration-200 z-[1001] bg-gradient-to-b from-black/40"
|
||||
class="h-16 flex justify-between place-items-center px-3 transition-transform duration-200 z-[1001] bg-gradient-to-b from-black/40"
|
||||
>
|
||||
<div class="text-white">
|
||||
<CircleIconButton isOpacity={true} logo={ArrowLeft} on:click={() => dispatch('goBack')} />
|
||||
</div>
|
||||
<div class="text-white flex gap-2 justify-end w-[calc(100%-3rem)] overflow-hidden">
|
||||
{#if showMotionPlayButton}
|
||||
{#if isMotionPhotoPlaying}
|
||||
<CircleIconButton
|
||||
isOpacity={true}
|
||||
logo={MotionPauseOutline}
|
||||
title="Stop Motion Photo"
|
||||
on:click={() => dispatch('stopMotionPhoto')}
|
||||
/>
|
||||
{:else}
|
||||
<CircleIconButton
|
||||
isOpacity={true}
|
||||
logo={MotionPlayOutline}
|
||||
title="Play Motion Photo"
|
||||
on:click={() => dispatch('playMotionPhoto')}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if showZoomButton}
|
||||
<CircleIconButton
|
||||
isOpacity={true}
|
||||
hideMobile={true}
|
||||
logo={$photoZoomState && $photoZoomState.currentZoom > 1
|
||||
? MagnifyMinusOutline
|
||||
: MagnifyPlusOutline}
|
||||
title="Zoom Image"
|
||||
on:click={() => {
|
||||
const zoomImage = new CustomEvent('zoomImage');
|
||||
window.dispatchEvent(zoomImage);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if showCopyButton}
|
||||
<CircleIconButton
|
||||
isOpacity={true}
|
||||
logo={ContentCopy}
|
||||
title="Copy Image"
|
||||
on:click={() => {
|
||||
const copyEvent = new CustomEvent('copyImage');
|
||||
window.dispatchEvent(copyEvent);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<div class="text-white">
|
||||
<CircleIconButton isOpacity={true} logo={ArrowLeft} on:click={() => dispatch('goBack')} />
|
||||
</div>
|
||||
<div class="text-white flex gap-2 justify-end w-[calc(100%-3rem)] overflow-hidden">
|
||||
{#if showMotionPlayButton}
|
||||
{#if isMotionPhotoPlaying}
|
||||
<CircleIconButton
|
||||
isOpacity={true}
|
||||
logo={MotionPauseOutline}
|
||||
title="Stop Motion Photo"
|
||||
on:click={() => dispatch('stopMotionPhoto')}
|
||||
/>
|
||||
{:else}
|
||||
<CircleIconButton
|
||||
isOpacity={true}
|
||||
logo={MotionPlayOutline}
|
||||
title="Play Motion Photo"
|
||||
on:click={() => dispatch('playMotionPhoto')}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if showZoomButton}
|
||||
<CircleIconButton
|
||||
isOpacity={true}
|
||||
hideMobile={true}
|
||||
logo={$photoZoomState && $photoZoomState.currentZoom > 1 ? MagnifyMinusOutline : MagnifyPlusOutline}
|
||||
title="Zoom Image"
|
||||
on:click={() => {
|
||||
const zoomImage = new CustomEvent('zoomImage');
|
||||
window.dispatchEvent(zoomImage);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if showCopyButton}
|
||||
<CircleIconButton
|
||||
isOpacity={true}
|
||||
logo={ContentCopy}
|
||||
title="Copy Image"
|
||||
on:click={() => {
|
||||
const copyEvent = new CustomEvent('copyImage');
|
||||
window.dispatchEvent(copyEvent);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showDownloadButton}
|
||||
<CircleIconButton
|
||||
isOpacity={true}
|
||||
logo={CloudDownloadOutline}
|
||||
on:click={() => dispatch('download')}
|
||||
title="Download"
|
||||
/>
|
||||
{/if}
|
||||
<CircleIconButton
|
||||
isOpacity={true}
|
||||
logo={InformationOutline}
|
||||
on:click={() => dispatch('showDetail')}
|
||||
title="Info"
|
||||
/>
|
||||
{#if isOwner}
|
||||
<CircleIconButton
|
||||
isOpacity={true}
|
||||
logo={asset.isFavorite ? Heart : HeartOutline}
|
||||
on:click={() => dispatch('favorite')}
|
||||
title="Favorite"
|
||||
/>
|
||||
{/if}
|
||||
{#if showDownloadButton}
|
||||
<CircleIconButton
|
||||
isOpacity={true}
|
||||
logo={CloudDownloadOutline}
|
||||
on:click={() => dispatch('download')}
|
||||
title="Download"
|
||||
/>
|
||||
{/if}
|
||||
<CircleIconButton isOpacity={true} logo={InformationOutline} on:click={() => dispatch('showDetail')} title="Info" />
|
||||
{#if isOwner}
|
||||
<CircleIconButton
|
||||
isOpacity={true}
|
||||
logo={asset.isFavorite ? Heart : HeartOutline}
|
||||
on:click={() => dispatch('favorite')}
|
||||
title="Favorite"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isOwner}
|
||||
<CircleIconButton
|
||||
isOpacity={true}
|
||||
logo={DeleteOutline}
|
||||
on:click={() => dispatch('delete')}
|
||||
title="Delete"
|
||||
/>
|
||||
<div use:clickOutside on:outclick={() => (isShowAssetOptions = false)}>
|
||||
<CircleIconButton
|
||||
isOpacity={true}
|
||||
logo={DotsVertical}
|
||||
on:click={showOptionsMenu}
|
||||
title="More"
|
||||
>
|
||||
{#if isShowAssetOptions}
|
||||
<ContextMenu {...contextMenuPosition} direction="left">
|
||||
<MenuOption on:click={() => onMenuClick('addToAlbum')} text="Add to Album" />
|
||||
<MenuOption
|
||||
on:click={() => onMenuClick('addToSharedAlbum')}
|
||||
text="Add to Shared Album"
|
||||
/>
|
||||
{#if isOwner}
|
||||
<CircleIconButton isOpacity={true} logo={DeleteOutline} on:click={() => dispatch('delete')} title="Delete" />
|
||||
<div use:clickOutside on:outclick={() => (isShowAssetOptions = false)}>
|
||||
<CircleIconButton isOpacity={true} logo={DotsVertical} on:click={showOptionsMenu} title="More">
|
||||
{#if isShowAssetOptions}
|
||||
<ContextMenu {...contextMenuPosition} direction="left">
|
||||
<MenuOption on:click={() => onMenuClick('addToAlbum')} text="Add to Album" />
|
||||
<MenuOption on:click={() => onMenuClick('addToSharedAlbum')} text="Add to Shared Album" />
|
||||
|
||||
{#if isOwner}
|
||||
<MenuOption
|
||||
on:click={() => dispatch('toggleArchive')}
|
||||
text={asset.isArchived ? 'Unarchive' : 'Archive'}
|
||||
/>
|
||||
{/if}
|
||||
</ContextMenu>
|
||||
{/if}
|
||||
</CircleIconButton>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isOwner}
|
||||
<MenuOption
|
||||
on:click={() => dispatch('toggleArchive')}
|
||||
text={asset.isArchived ? 'Unarchive' : 'Archive'}
|
||||
/>
|
||||
{/if}
|
||||
</ContextMenu>
|
||||
{/if}
|
||||
</CircleIconButton>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,399 +1,386 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
AlbumResponseDto,
|
||||
api,
|
||||
AssetResponseDto,
|
||||
AssetTypeEnum,
|
||||
SharedLinkResponseDto
|
||||
} from '@api';
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
|
||||
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
|
||||
import ImageBrokenVariant from 'svelte-material-icons/ImageBrokenVariant.svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte';
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType
|
||||
} from '../shared-components/notification/notification';
|
||||
import AssetViewerNavBar from './asset-viewer-nav-bar.svelte';
|
||||
import DetailPanel from './detail-panel.svelte';
|
||||
import PhotoViewer from './photo-viewer.svelte';
|
||||
import VideoViewer from './video-viewer.svelte';
|
||||
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { AlbumResponseDto, api, AssetResponseDto, AssetTypeEnum, SharedLinkResponseDto } from '@api';
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
|
||||
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
|
||||
import ImageBrokenVariant from 'svelte-material-icons/ImageBrokenVariant.svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte';
|
||||
import { notificationController, NotificationType } from '../shared-components/notification/notification';
|
||||
import AssetViewerNavBar from './asset-viewer-nav-bar.svelte';
|
||||
import DetailPanel from './detail-panel.svelte';
|
||||
import PhotoViewer from './photo-viewer.svelte';
|
||||
import VideoViewer from './video-viewer.svelte';
|
||||
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
|
||||
|
||||
import { assetStore } from '$lib/stores/assets.store';
|
||||
import { isShowDetail } from '$lib/stores/preferences.store';
|
||||
import { addAssetsToAlbum, downloadFile } from '$lib/utils/asset-utils';
|
||||
import { browser } from '$app/environment';
|
||||
import { assetStore } from '$lib/stores/assets.store';
|
||||
import { isShowDetail } from '$lib/stores/preferences.store';
|
||||
import { addAssetsToAlbum, downloadFile } from '$lib/utils/asset-utils';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let publicSharedKey = '';
|
||||
export let showNavigation = true;
|
||||
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
|
||||
export let asset: AssetResponseDto;
|
||||
export let publicSharedKey = '';
|
||||
export let showNavigation = true;
|
||||
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let halfLeftHover = false;
|
||||
let halfRightHover = false;
|
||||
let appearsInAlbums: AlbumResponseDto[] = [];
|
||||
let isShowAlbumPicker = false;
|
||||
let isShowDeleteConfirmation = false;
|
||||
let addToSharedAlbum = true;
|
||||
let shouldPlayMotionPhoto = false;
|
||||
let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : true;
|
||||
let canCopyImagesToClipboard: boolean;
|
||||
const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo.key);
|
||||
const dispatch = createEventDispatcher();
|
||||
let halfLeftHover = false;
|
||||
let halfRightHover = false;
|
||||
let appearsInAlbums: AlbumResponseDto[] = [];
|
||||
let isShowAlbumPicker = false;
|
||||
let isShowDeleteConfirmation = false;
|
||||
let addToSharedAlbum = true;
|
||||
let shouldPlayMotionPhoto = false;
|
||||
let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : true;
|
||||
let canCopyImagesToClipboard: boolean;
|
||||
const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo.key);
|
||||
|
||||
onMount(async () => {
|
||||
document.addEventListener('keydown', onKeyboardPress);
|
||||
onMount(async () => {
|
||||
document.addEventListener('keydown', onKeyboardPress);
|
||||
|
||||
getAllAlbums();
|
||||
getAllAlbums();
|
||||
|
||||
// Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
|
||||
// TODO: Move to regular import once the package correctly supports ESM.
|
||||
const module = await import('copy-image-clipboard');
|
||||
canCopyImagesToClipboard = module.canCopyImagesToClipboard();
|
||||
});
|
||||
// Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
|
||||
// TODO: Move to regular import once the package correctly supports ESM.
|
||||
const module = await import('copy-image-clipboard');
|
||||
canCopyImagesToClipboard = module.canCopyImagesToClipboard();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (browser) {
|
||||
document.removeEventListener('keydown', onKeyboardPress);
|
||||
}
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (browser) {
|
||||
document.removeEventListener('keydown', onKeyboardPress);
|
||||
}
|
||||
});
|
||||
|
||||
$: asset.id && getAllAlbums(); // Update the album information when the asset ID changes
|
||||
$: asset.id && getAllAlbums(); // Update the album information when the asset ID changes
|
||||
|
||||
const getAllAlbums = async () => {
|
||||
try {
|
||||
const { data } = await api.albumApi.getAllAlbums({ assetId: asset.id });
|
||||
appearsInAlbums = data;
|
||||
} catch (e) {
|
||||
console.error('Error getting album that asset belong to', e);
|
||||
}
|
||||
};
|
||||
const getAllAlbums = async () => {
|
||||
try {
|
||||
const { data } = await api.albumApi.getAllAlbums({ assetId: asset.id });
|
||||
appearsInAlbums = data;
|
||||
} catch (e) {
|
||||
console.error('Error getting album that asset belong to', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyboardPress = (key: string) => {
|
||||
switch (key) {
|
||||
case 'Escape':
|
||||
closeViewer();
|
||||
return;
|
||||
case 'Delete':
|
||||
isShowDeleteConfirmation = true;
|
||||
return;
|
||||
case 'i':
|
||||
$isShowDetail = !$isShowDetail;
|
||||
return;
|
||||
case 'ArrowLeft':
|
||||
navigateAssetBackward();
|
||||
return;
|
||||
case 'ArrowRight':
|
||||
navigateAssetForward();
|
||||
return;
|
||||
}
|
||||
};
|
||||
const handleKeyboardPress = (key: string) => {
|
||||
switch (key) {
|
||||
case 'Escape':
|
||||
closeViewer();
|
||||
return;
|
||||
case 'Delete':
|
||||
isShowDeleteConfirmation = true;
|
||||
return;
|
||||
case 'i':
|
||||
$isShowDetail = !$isShowDetail;
|
||||
return;
|
||||
case 'ArrowLeft':
|
||||
navigateAssetBackward();
|
||||
return;
|
||||
case 'ArrowRight':
|
||||
navigateAssetForward();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseViewer = () => {
|
||||
$isShowDetail = false;
|
||||
closeViewer();
|
||||
};
|
||||
const handleCloseViewer = () => {
|
||||
$isShowDetail = false;
|
||||
closeViewer();
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
dispatch('close');
|
||||
};
|
||||
const closeViewer = () => {
|
||||
dispatch('close');
|
||||
};
|
||||
|
||||
const navigateAssetForward = (e?: Event) => {
|
||||
e?.stopPropagation();
|
||||
dispatch('navigate-next');
|
||||
};
|
||||
const navigateAssetForward = (e?: Event) => {
|
||||
e?.stopPropagation();
|
||||
dispatch('navigate-next');
|
||||
};
|
||||
|
||||
const navigateAssetBackward = (e?: Event) => {
|
||||
e?.stopPropagation();
|
||||
dispatch('navigate-previous');
|
||||
};
|
||||
const navigateAssetBackward = (e?: Event) => {
|
||||
e?.stopPropagation();
|
||||
dispatch('navigate-previous');
|
||||
};
|
||||
|
||||
const showDetailInfoHandler = () => {
|
||||
$isShowDetail = !$isShowDetail;
|
||||
};
|
||||
const showDetailInfoHandler = () => {
|
||||
$isShowDetail = !$isShowDetail;
|
||||
};
|
||||
|
||||
const deleteAsset = async () => {
|
||||
try {
|
||||
const { data: deletedAssets } = await api.assetApi.deleteAsset({
|
||||
deleteAssetDto: {
|
||||
ids: [asset.id]
|
||||
}
|
||||
});
|
||||
const deleteAsset = async () => {
|
||||
try {
|
||||
const { data: deletedAssets } = await api.assetApi.deleteAsset({
|
||||
deleteAssetDto: {
|
||||
ids: [asset.id],
|
||||
},
|
||||
});
|
||||
|
||||
navigateAssetForward();
|
||||
navigateAssetForward();
|
||||
|
||||
for (const asset of deletedAssets) {
|
||||
if (asset.status == 'SUCCESS') {
|
||||
assetStore.removeAsset(asset.id);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
message: 'Error deleting this asset, check console for more details'
|
||||
});
|
||||
console.error('Error deleteAsset', e);
|
||||
} finally {
|
||||
isShowDeleteConfirmation = false;
|
||||
}
|
||||
};
|
||||
for (const asset of deletedAssets) {
|
||||
if (asset.status == 'SUCCESS') {
|
||||
assetStore.removeAsset(asset.id);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
message: 'Error deleting this asset, check console for more details',
|
||||
});
|
||||
console.error('Error deleteAsset', e);
|
||||
} finally {
|
||||
isShowDeleteConfirmation = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFavorite = async () => {
|
||||
const { data } = await api.assetApi.updateAsset({
|
||||
id: asset.id,
|
||||
updateAssetDto: {
|
||||
isFavorite: !asset.isFavorite
|
||||
}
|
||||
});
|
||||
const toggleFavorite = async () => {
|
||||
const { data } = await api.assetApi.updateAsset({
|
||||
id: asset.id,
|
||||
updateAssetDto: {
|
||||
isFavorite: !asset.isFavorite,
|
||||
},
|
||||
});
|
||||
|
||||
asset.isFavorite = data.isFavorite;
|
||||
assetStore.updateAsset(asset.id, data.isFavorite);
|
||||
};
|
||||
asset.isFavorite = data.isFavorite;
|
||||
assetStore.updateAsset(asset.id, data.isFavorite);
|
||||
};
|
||||
|
||||
const openAlbumPicker = (shared: boolean) => {
|
||||
isShowAlbumPicker = true;
|
||||
addToSharedAlbum = shared;
|
||||
};
|
||||
const openAlbumPicker = (shared: boolean) => {
|
||||
isShowAlbumPicker = true;
|
||||
addToSharedAlbum = shared;
|
||||
};
|
||||
|
||||
const handleAddToNewAlbum = (event: CustomEvent) => {
|
||||
isShowAlbumPicker = false;
|
||||
const handleAddToNewAlbum = (event: CustomEvent) => {
|
||||
isShowAlbumPicker = false;
|
||||
|
||||
const { albumName }: { albumName: string } = event.detail;
|
||||
api.albumApi
|
||||
.createAlbum({ createAlbumDto: { albumName, assetIds: [asset.id] } })
|
||||
.then((response) => {
|
||||
const album = response.data;
|
||||
goto('/albums/' + album.id);
|
||||
});
|
||||
};
|
||||
const { albumName }: { albumName: string } = event.detail;
|
||||
api.albumApi.createAlbum({ createAlbumDto: { albumName, assetIds: [asset.id] } }).then((response) => {
|
||||
const album = response.data;
|
||||
goto('/albums/' + album.id);
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddToAlbum = async (event: CustomEvent<{ album: AlbumResponseDto }>) => {
|
||||
isShowAlbumPicker = false;
|
||||
const album = event.detail.album;
|
||||
const handleAddToAlbum = async (event: CustomEvent<{ album: AlbumResponseDto }>) => {
|
||||
isShowAlbumPicker = false;
|
||||
const album = event.detail.album;
|
||||
|
||||
addAssetsToAlbum(album.id, [asset.id]).then((dto) => {
|
||||
if (dto.successfullyAdded === 1 && dto.album) {
|
||||
appearsInAlbums = [...appearsInAlbums, dto.album];
|
||||
}
|
||||
});
|
||||
};
|
||||
addAssetsToAlbum(album.id, [asset.id]).then((dto) => {
|
||||
if (dto.successfullyAdded === 1 && dto.album) {
|
||||
appearsInAlbums = [...appearsInAlbums, dto.album];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const disableKeyDownEvent = () => {
|
||||
if (browser) {
|
||||
document.removeEventListener('keydown', onKeyboardPress);
|
||||
}
|
||||
};
|
||||
const disableKeyDownEvent = () => {
|
||||
if (browser) {
|
||||
document.removeEventListener('keydown', onKeyboardPress);
|
||||
}
|
||||
};
|
||||
|
||||
const enableKeyDownEvent = () => {
|
||||
if (browser) {
|
||||
document.addEventListener('keydown', onKeyboardPress);
|
||||
}
|
||||
};
|
||||
const enableKeyDownEvent = () => {
|
||||
if (browser) {
|
||||
document.addEventListener('keydown', onKeyboardPress);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleArchive = async () => {
|
||||
try {
|
||||
const { data } = await api.assetApi.updateAsset({
|
||||
id: asset.id,
|
||||
updateAssetDto: {
|
||||
isArchived: !asset.isArchived
|
||||
}
|
||||
});
|
||||
const toggleArchive = async () => {
|
||||
try {
|
||||
const { data } = await api.assetApi.updateAsset({
|
||||
id: asset.id,
|
||||
updateAssetDto: {
|
||||
isArchived: !asset.isArchived,
|
||||
},
|
||||
});
|
||||
|
||||
asset.isArchived = data.isArchived;
|
||||
asset.isArchived = data.isArchived;
|
||||
|
||||
if (data.isArchived) {
|
||||
dispatch('archived', data);
|
||||
} else {
|
||||
dispatch('unarchived', data);
|
||||
}
|
||||
if (data.isArchived) {
|
||||
dispatch('archived', data);
|
||||
} else {
|
||||
dispatch('unarchived', data);
|
||||
}
|
||||
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: asset.isArchived ? `Added to archive` : `Removed from archive`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
message: `Error ${
|
||||
asset.isArchived ? 'archiving' : 'unarchiving'
|
||||
} asset, check console for more details`
|
||||
});
|
||||
}
|
||||
};
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: asset.isArchived ? `Added to archive` : `Removed from archive`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
message: `Error ${asset.isArchived ? 'archiving' : 'unarchiving'} asset, check console for more details`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getAssetType = () => {
|
||||
switch (asset.type) {
|
||||
case 'IMAGE':
|
||||
return 'Photo';
|
||||
case 'VIDEO':
|
||||
return 'Video';
|
||||
default:
|
||||
return 'Asset';
|
||||
}
|
||||
};
|
||||
const getAssetType = () => {
|
||||
switch (asset.type) {
|
||||
case 'IMAGE':
|
||||
return 'Photo';
|
||||
case 'VIDEO':
|
||||
return 'Video';
|
||||
default:
|
||||
return 'Asset';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<section
|
||||
id="immich-asset-viewer"
|
||||
class="fixed h-screen w-screen left-0 top-0 overflow-y-hidden bg-black z-[1001] grid grid-rows-[64px_1fr] grid-cols-4"
|
||||
id="immich-asset-viewer"
|
||||
class="fixed h-screen w-screen left-0 top-0 overflow-y-hidden bg-black z-[1001] grid grid-rows-[64px_1fr] grid-cols-4"
|
||||
>
|
||||
<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
|
||||
<AssetViewerNavBar
|
||||
{asset}
|
||||
isMotionPhotoPlaying={shouldPlayMotionPhoto}
|
||||
showCopyButton={canCopyImagesToClipboard && asset.type === AssetTypeEnum.Image}
|
||||
showZoomButton={asset.type === AssetTypeEnum.Image}
|
||||
showMotionPlayButton={!!asset.livePhotoVideoId}
|
||||
showDownloadButton={shouldShowDownloadButton}
|
||||
on:goBack={closeViewer}
|
||||
on:showDetail={showDetailInfoHandler}
|
||||
on:download={() => downloadFile(asset, publicSharedKey)}
|
||||
on:delete={() => (isShowDeleteConfirmation = true)}
|
||||
on:favorite={toggleFavorite}
|
||||
on:addToAlbum={() => openAlbumPicker(false)}
|
||||
on:addToSharedAlbum={() => openAlbumPicker(true)}
|
||||
on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)}
|
||||
on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
|
||||
on:toggleArchive={toggleArchive}
|
||||
/>
|
||||
</div>
|
||||
<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
|
||||
<AssetViewerNavBar
|
||||
{asset}
|
||||
isMotionPhotoPlaying={shouldPlayMotionPhoto}
|
||||
showCopyButton={canCopyImagesToClipboard && asset.type === AssetTypeEnum.Image}
|
||||
showZoomButton={asset.type === AssetTypeEnum.Image}
|
||||
showMotionPlayButton={!!asset.livePhotoVideoId}
|
||||
showDownloadButton={shouldShowDownloadButton}
|
||||
on:goBack={closeViewer}
|
||||
on:showDetail={showDetailInfoHandler}
|
||||
on:download={() => downloadFile(asset, publicSharedKey)}
|
||||
on:delete={() => (isShowDeleteConfirmation = true)}
|
||||
on:favorite={toggleFavorite}
|
||||
on:addToAlbum={() => openAlbumPicker(false)}
|
||||
on:addToSharedAlbum={() => openAlbumPicker(true)}
|
||||
on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)}
|
||||
on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
|
||||
on:toggleArchive={toggleArchive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if showNavigation}
|
||||
<div
|
||||
class={`row-start-2 row-span-end col-start-1 flex place-items-center hover:cursor-pointer w-1/4 mb-[60px] ${
|
||||
asset.type === AssetTypeEnum.Video ? '' : 'z-[999]'
|
||||
}`}
|
||||
on:mouseenter={() => {
|
||||
halfLeftHover = true;
|
||||
halfRightHover = false;
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
halfLeftHover = false;
|
||||
}}
|
||||
on:click={navigateAssetBackward}
|
||||
on:keydown={navigateAssetBackward}
|
||||
>
|
||||
<button
|
||||
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 z-[1000] text-gray-500 mx-4"
|
||||
class:navigation-button-hover={halfLeftHover}
|
||||
on:click={navigateAssetBackward}
|
||||
>
|
||||
<ChevronLeft size="36" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showNavigation}
|
||||
<div
|
||||
class={`row-start-2 row-span-end col-start-1 flex place-items-center hover:cursor-pointer w-1/4 mb-[60px] ${
|
||||
asset.type === AssetTypeEnum.Video ? '' : 'z-[999]'
|
||||
}`}
|
||||
on:mouseenter={() => {
|
||||
halfLeftHover = true;
|
||||
halfRightHover = false;
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
halfLeftHover = false;
|
||||
}}
|
||||
on:click={navigateAssetBackward}
|
||||
on:keydown={navigateAssetBackward}
|
||||
>
|
||||
<button
|
||||
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 z-[1000] text-gray-500 mx-4"
|
||||
class:navigation-button-hover={halfLeftHover}
|
||||
on:click={navigateAssetBackward}
|
||||
>
|
||||
<ChevronLeft size="36" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="row-start-1 row-span-full col-start-1 col-span-4">
|
||||
{#key asset.id}
|
||||
{#if !asset.resized}
|
||||
<div class="h-full w-full flex justify-center">
|
||||
<div
|
||||
class="h-full bg-gray-100 dark:bg-immich-dark-gray flex items-center justify-center aspect-square px-auto"
|
||||
>
|
||||
<ImageBrokenVariant size="25%" />
|
||||
</div>
|
||||
</div>
|
||||
{:else if asset.type === AssetTypeEnum.Image}
|
||||
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
|
||||
<VideoViewer
|
||||
{publicSharedKey}
|
||||
assetId={asset.livePhotoVideoId}
|
||||
on:close={closeViewer}
|
||||
on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
|
||||
/>
|
||||
{:else}
|
||||
<PhotoViewer {publicSharedKey} {asset} on:close={closeViewer} />
|
||||
{/if}
|
||||
{:else}
|
||||
<VideoViewer {publicSharedKey} assetId={asset.id} on:close={closeViewer} />
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
<div class="row-start-1 row-span-full col-start-1 col-span-4">
|
||||
{#key asset.id}
|
||||
{#if !asset.resized}
|
||||
<div class="h-full w-full flex justify-center">
|
||||
<div
|
||||
class="h-full bg-gray-100 dark:bg-immich-dark-gray flex items-center justify-center aspect-square px-auto"
|
||||
>
|
||||
<ImageBrokenVariant size="25%" />
|
||||
</div>
|
||||
</div>
|
||||
{:else if asset.type === AssetTypeEnum.Image}
|
||||
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
|
||||
<VideoViewer
|
||||
{publicSharedKey}
|
||||
assetId={asset.livePhotoVideoId}
|
||||
on:close={closeViewer}
|
||||
on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
|
||||
/>
|
||||
{:else}
|
||||
<PhotoViewer {publicSharedKey} {asset} on:close={closeViewer} />
|
||||
{/if}
|
||||
{:else}
|
||||
<VideoViewer {publicSharedKey} assetId={asset.id} on:close={closeViewer} />
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
{#if showNavigation}
|
||||
<div
|
||||
class={`row-start-2 row-span-full col-start-4 flex justify-end place-items-center hover:cursor-pointer w-1/4 justify-self-end mb-[60px] ${
|
||||
asset.type === AssetTypeEnum.Video ? '' : 'z-[500]'
|
||||
}`}
|
||||
on:click={navigateAssetForward}
|
||||
on:keydown={navigateAssetForward}
|
||||
on:mouseenter={() => {
|
||||
halfLeftHover = false;
|
||||
halfRightHover = true;
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
halfRightHover = false;
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="rounded-full p-3 hover:bg-gray-500 hover:text-white text-gray-500 mx-4"
|
||||
class:navigation-button-hover={halfRightHover}
|
||||
on:click={navigateAssetForward}
|
||||
>
|
||||
<ChevronRight size="36" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showNavigation}
|
||||
<div
|
||||
class={`row-start-2 row-span-full col-start-4 flex justify-end place-items-center hover:cursor-pointer w-1/4 justify-self-end mb-[60px] ${
|
||||
asset.type === AssetTypeEnum.Video ? '' : 'z-[500]'
|
||||
}`}
|
||||
on:click={navigateAssetForward}
|
||||
on:keydown={navigateAssetForward}
|
||||
on:mouseenter={() => {
|
||||
halfLeftHover = false;
|
||||
halfRightHover = true;
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
halfRightHover = false;
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="rounded-full p-3 hover:bg-gray-500 hover:text-white text-gray-500 mx-4"
|
||||
class:navigation-button-hover={halfRightHover}
|
||||
on:click={navigateAssetForward}
|
||||
>
|
||||
<ChevronRight size="36" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $isShowDetail}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
id="detail-panel"
|
||||
class="bg-immich-bg w-[360px] z-[1002] row-span-full transition-all overflow-y-auto dark:bg-immich-dark-bg dark:border-l dark:border-l-immich-dark-gray"
|
||||
translate="yes"
|
||||
>
|
||||
<DetailPanel
|
||||
{asset}
|
||||
albums={appearsInAlbums}
|
||||
on:close={() => ($isShowDetail = false)}
|
||||
on:close-viewer={handleCloseViewer}
|
||||
on:description-focus-in={disableKeyDownEvent}
|
||||
on:description-focus-out={enableKeyDownEvent}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $isShowDetail}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
id="detail-panel"
|
||||
class="bg-immich-bg w-[360px] z-[1002] row-span-full transition-all overflow-y-auto dark:bg-immich-dark-bg dark:border-l dark:border-l-immich-dark-gray"
|
||||
translate="yes"
|
||||
>
|
||||
<DetailPanel
|
||||
{asset}
|
||||
albums={appearsInAlbums}
|
||||
on:close={() => ($isShowDetail = false)}
|
||||
on:close-viewer={handleCloseViewer}
|
||||
on:description-focus-in={disableKeyDownEvent}
|
||||
on:description-focus-out={enableKeyDownEvent}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isShowAlbumPicker}
|
||||
<AlbumSelectionModal
|
||||
shared={addToSharedAlbum}
|
||||
on:newAlbum={handleAddToNewAlbum}
|
||||
on:newSharedAlbum={handleAddToNewAlbum}
|
||||
on:album={handleAddToAlbum}
|
||||
on:close={() => (isShowAlbumPicker = false)}
|
||||
/>
|
||||
{/if}
|
||||
{#if isShowAlbumPicker}
|
||||
<AlbumSelectionModal
|
||||
shared={addToSharedAlbum}
|
||||
on:newAlbum={handleAddToNewAlbum}
|
||||
on:newSharedAlbum={handleAddToNewAlbum}
|
||||
on:album={handleAddToAlbum}
|
||||
on:close={() => (isShowAlbumPicker = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isShowDeleteConfirmation}
|
||||
<ConfirmDialogue
|
||||
title="Delete {getAssetType()}"
|
||||
confirmText="Delete"
|
||||
on:confirm={deleteAsset}
|
||||
on:cancel={() => (isShowDeleteConfirmation = false)}
|
||||
>
|
||||
<svelte:fragment slot="prompt">
|
||||
<p>
|
||||
Are you sure you want to delete this {getAssetType().toLowerCase()}? This will also remove
|
||||
it from its album(s).
|
||||
</p>
|
||||
<p><b>You cannot undo this action!</b></p>
|
||||
</svelte:fragment>
|
||||
</ConfirmDialogue>
|
||||
{/if}
|
||||
{#if isShowDeleteConfirmation}
|
||||
<ConfirmDialogue
|
||||
title="Delete {getAssetType()}"
|
||||
confirmText="Delete"
|
||||
on:confirm={deleteAsset}
|
||||
on:cancel={() => (isShowDeleteConfirmation = false)}
|
||||
>
|
||||
<svelte:fragment slot="prompt">
|
||||
<p>
|
||||
Are you sure you want to delete this {getAssetType().toLowerCase()}? This will also remove it from its
|
||||
album(s).
|
||||
</p>
|
||||
<p><b>You cannot undo this action!</b></p>
|
||||
</svelte:fragment>
|
||||
</ConfirmDialogue>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
#immich-asset-viewer {
|
||||
contain: layout;
|
||||
}
|
||||
#immich-asset-viewer {
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
.navigation-button-hover {
|
||||
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||
transition: all 150ms;
|
||||
}
|
||||
.navigation-button-hover {
|
||||
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||
transition: all 150ms;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,296 +1,293 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import type { LatLngTuple } from 'leaflet';
|
||||
import { DateTime } from 'luxon';
|
||||
import Calendar from 'svelte-material-icons/Calendar.svelte';
|
||||
import CameraIris from 'svelte-material-icons/CameraIris.svelte';
|
||||
import Close from 'svelte-material-icons/Close.svelte';
|
||||
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
|
||||
import MapMarkerOutline from 'svelte-material-icons/MapMarkerOutline.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { AssetResponseDto, AlbumResponseDto, api, ThumbnailFormat } from '@api';
|
||||
import { asByteUnitString } from '../../utils/byte-units';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import { getAssetFilename } from '$lib/utils/asset-utils';
|
||||
import { page } from '$app/stores';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import type { LatLngTuple } from 'leaflet';
|
||||
import { DateTime } from 'luxon';
|
||||
import Calendar from 'svelte-material-icons/Calendar.svelte';
|
||||
import CameraIris from 'svelte-material-icons/CameraIris.svelte';
|
||||
import Close from 'svelte-material-icons/Close.svelte';
|
||||
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
|
||||
import MapMarkerOutline from 'svelte-material-icons/MapMarkerOutline.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { AssetResponseDto, AlbumResponseDto, api, ThumbnailFormat } from '@api';
|
||||
import { asByteUnitString } from '../../utils/byte-units';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import { getAssetFilename } from '$lib/utils/asset-utils';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let albums: AlbumResponseDto[] = [];
|
||||
let textarea: HTMLTextAreaElement;
|
||||
let description: string;
|
||||
export let asset: AssetResponseDto;
|
||||
export let albums: AlbumResponseDto[] = [];
|
||||
let textarea: HTMLTextAreaElement;
|
||||
let description: string;
|
||||
|
||||
$: {
|
||||
// Get latest description from server
|
||||
if (asset.id) {
|
||||
api.assetApi.getAssetById({ id: asset.id }).then((res) => {
|
||||
people = res.data?.people || [];
|
||||
textarea.value = res.data?.exifInfo?.description || '';
|
||||
});
|
||||
}
|
||||
}
|
||||
$: {
|
||||
// Get latest description from server
|
||||
if (asset.id) {
|
||||
api.assetApi.getAssetById({ id: asset.id }).then((res) => {
|
||||
people = res.data?.people || [];
|
||||
textarea.value = res.data?.exifInfo?.description || '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$: latlng = (() => {
|
||||
const lat = asset.exifInfo?.latitude;
|
||||
const lng = asset.exifInfo?.longitude;
|
||||
$: latlng = (() => {
|
||||
const lat = asset.exifInfo?.latitude;
|
||||
const lng = asset.exifInfo?.longitude;
|
||||
|
||||
if (lat && lng) {
|
||||
return [lat, lng] as LatLngTuple;
|
||||
}
|
||||
})();
|
||||
if (lat && lng) {
|
||||
return [lat, lng] as LatLngTuple;
|
||||
}
|
||||
})();
|
||||
|
||||
$: people = asset.people || [];
|
||||
$: people = asset.people || [];
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const getMegapixel = (width: number, height: number): number | undefined => {
|
||||
const megapixel = Math.round((height * width) / 1_000_000);
|
||||
const getMegapixel = (width: number, height: number): number | undefined => {
|
||||
const megapixel = Math.round((height * width) / 1_000_000);
|
||||
|
||||
if (megapixel) {
|
||||
return megapixel;
|
||||
}
|
||||
if (megapixel) {
|
||||
return megapixel;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const autoGrowHeight = (e: Event) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = 'auto';
|
||||
target.style.height = `${target.scrollHeight}px`;
|
||||
};
|
||||
const autoGrowHeight = (e: Event) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = 'auto';
|
||||
target.style.height = `${target.scrollHeight}px`;
|
||||
};
|
||||
|
||||
const handleFocusIn = () => {
|
||||
dispatch('description-focus-in');
|
||||
};
|
||||
const handleFocusIn = () => {
|
||||
dispatch('description-focus-in');
|
||||
};
|
||||
|
||||
const handleFocusOut = async () => {
|
||||
dispatch('description-focus-out');
|
||||
try {
|
||||
await api.assetApi.updateAsset({
|
||||
id: asset.id,
|
||||
updateAssetDto: {
|
||||
description: description
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
const handleFocusOut = async () => {
|
||||
dispatch('description-focus-out');
|
||||
try {
|
||||
await api.assetApi.updateAsset({
|
||||
id: asset.id,
|
||||
updateAssetDto: {
|
||||
description: description,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
|
||||
<div class="flex place-items-center gap-2">
|
||||
<button
|
||||
class="rounded-full p-3 flex place-items-center place-content-center hover:bg-gray-200 transition-colors dark:text-immich-dark-fg dark:hover:bg-gray-900"
|
||||
on:click={() => dispatch('close')}
|
||||
>
|
||||
<Close size="24" />
|
||||
</button>
|
||||
<div class="flex place-items-center gap-2">
|
||||
<button
|
||||
class="rounded-full p-3 flex place-items-center place-content-center hover:bg-gray-200 transition-colors dark:text-immich-dark-fg dark:hover:bg-gray-900"
|
||||
on:click={() => dispatch('close')}
|
||||
>
|
||||
<Close size="24" />
|
||||
</button>
|
||||
|
||||
<p class="text-immich-fg dark:text-immich-dark-fg text-lg">Info</p>
|
||||
</div>
|
||||
<p class="text-immich-fg dark:text-immich-dark-fg text-lg">Info</p>
|
||||
</div>
|
||||
|
||||
<section class="mx-4 mt-10">
|
||||
<textarea
|
||||
bind:this={textarea}
|
||||
class="max-h-[500px]
|
||||
<section class="mx-4 mt-10">
|
||||
<textarea
|
||||
bind:this={textarea}
|
||||
class="max-h-[500px]
|
||||
text-base text-black bg-transparent dark:text-white border-b focus:border-b-2 border-gray-500 w-full focus:border-immich-primary dark:focus:border-immich-dark-primary transition-all resize-none overflow-hidden outline-none disabled:border-none"
|
||||
placeholder={$page?.data?.user?.id !== asset.ownerId ? '' : 'Add a description'}
|
||||
style:display={$page?.data?.user?.id !== asset.ownerId && textarea?.value == ''
|
||||
? 'none'
|
||||
: 'block'}
|
||||
on:focusin={handleFocusIn}
|
||||
on:focusout={handleFocusOut}
|
||||
on:input={autoGrowHeight}
|
||||
bind:value={description}
|
||||
disabled={$page?.data?.user?.id !== asset.ownerId}
|
||||
/>
|
||||
</section>
|
||||
placeholder={$page?.data?.user?.id !== asset.ownerId ? '' : 'Add a description'}
|
||||
style:display={$page?.data?.user?.id !== asset.ownerId && textarea?.value == '' ? 'none' : 'block'}
|
||||
on:focusin={handleFocusIn}
|
||||
on:focusout={handleFocusOut}
|
||||
on:input={autoGrowHeight}
|
||||
bind:value={description}
|
||||
disabled={$page?.data?.user?.id !== asset.ownerId}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{#if people.length > 0}
|
||||
<section class="px-4 py-4 text-sm">
|
||||
<h2>PEOPLE</h2>
|
||||
{#if people.length > 0}
|
||||
<section class="px-4 py-4 text-sm">
|
||||
<h2>PEOPLE</h2>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mt-4">
|
||||
{#each people as person (person.id)}
|
||||
<a href="/people/{person.id}" class="w-[90px]" on:click={() => dispatch('close-viewer')}>
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={api.getPeopleThumbnailUrl(person.id)}
|
||||
altText={person.name}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
thumbhash={null}
|
||||
/>
|
||||
<p class="font-medium mt-1 truncate">{person.name}</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-2 mt-4">
|
||||
{#each people as person (person.id)}
|
||||
<a href="/people/{person.id}" class="w-[90px]" on:click={() => dispatch('close-viewer')}>
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={api.getPeopleThumbnailUrl(person.id)}
|
||||
altText={person.name}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
thumbhash={null}
|
||||
/>
|
||||
<p class="font-medium mt-1 truncate">{person.name}</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="px-4 py-4">
|
||||
{#if !asset.exifInfo}
|
||||
<p class="text-sm">NO EXIF INFO AVAILABLE</p>
|
||||
{:else}
|
||||
<p class="text-sm">DETAILS</p>
|
||||
{/if}
|
||||
<div class="px-4 py-4">
|
||||
{#if !asset.exifInfo}
|
||||
<p class="text-sm">NO EXIF INFO AVAILABLE</p>
|
||||
{:else}
|
||||
<p class="text-sm">DETAILS</p>
|
||||
{/if}
|
||||
|
||||
{#if asset.exifInfo?.dateTimeOriginal}
|
||||
{@const assetDateTimeOriginal = DateTime.fromISO(asset.exifInfo.dateTimeOriginal, {
|
||||
zone: asset.exifInfo.timeZone ?? undefined
|
||||
})}
|
||||
<div class="flex gap-4 py-4">
|
||||
<div>
|
||||
<Calendar size="24" />
|
||||
</div>
|
||||
{#if asset.exifInfo?.dateTimeOriginal}
|
||||
{@const assetDateTimeOriginal = DateTime.fromISO(asset.exifInfo.dateTimeOriginal, {
|
||||
zone: asset.exifInfo.timeZone ?? undefined,
|
||||
})}
|
||||
<div class="flex gap-4 py-4">
|
||||
<div>
|
||||
<Calendar size="24" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p>
|
||||
{assetDateTimeOriginal.toLocaleString(
|
||||
{
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
},
|
||||
{ locale: $locale }
|
||||
)}
|
||||
</p>
|
||||
<div class="flex gap-2 text-sm">
|
||||
<p>
|
||||
{assetDateTimeOriginal.toLocaleString(
|
||||
{
|
||||
weekday: 'short',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'longOffset'
|
||||
},
|
||||
{ locale: $locale }
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>{/if}
|
||||
<div>
|
||||
<p>
|
||||
{assetDateTimeOriginal.toLocaleString(
|
||||
{
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
},
|
||||
{ locale: $locale },
|
||||
)}
|
||||
</p>
|
||||
<div class="flex gap-2 text-sm">
|
||||
<p>
|
||||
{assetDateTimeOriginal.toLocaleString(
|
||||
{
|
||||
weekday: 'short',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'longOffset',
|
||||
},
|
||||
{ locale: $locale },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>{/if}
|
||||
|
||||
{#if asset.exifInfo?.fileSizeInByte}
|
||||
<div class="flex gap-4 py-4">
|
||||
<div><ImageOutline size="24" /></div>
|
||||
{#if asset.exifInfo?.fileSizeInByte}
|
||||
<div class="flex gap-4 py-4">
|
||||
<div><ImageOutline size="24" /></div>
|
||||
|
||||
<div>
|
||||
<p class="break-all">
|
||||
{getAssetFilename(asset)}
|
||||
</p>
|
||||
<div class="flex text-sm gap-2">
|
||||
{#if asset.exifInfo.exifImageHeight && asset.exifInfo.exifImageWidth}
|
||||
{#if getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}
|
||||
<p>
|
||||
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
|
||||
</p>
|
||||
{/if}
|
||||
<div>
|
||||
<p class="break-all">
|
||||
{getAssetFilename(asset)}
|
||||
</p>
|
||||
<div class="flex text-sm gap-2">
|
||||
{#if asset.exifInfo.exifImageHeight && asset.exifInfo.exifImageWidth}
|
||||
{#if getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}
|
||||
<p>
|
||||
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p>{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}</p>
|
||||
{/if}
|
||||
<p>{asByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<p>{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}</p>
|
||||
{/if}
|
||||
<p>{asByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if asset.exifInfo?.fNumber}
|
||||
<div class="flex gap-4 py-4">
|
||||
<div><CameraIris size="24" /></div>
|
||||
{#if asset.exifInfo?.fNumber}
|
||||
<div class="flex gap-4 py-4">
|
||||
<div><CameraIris size="24" /></div>
|
||||
|
||||
<div>
|
||||
<p>{asset.exifInfo.make || ''} {asset.exifInfo.model || ''}</p>
|
||||
<div class="flex text-sm gap-2">
|
||||
<p>{`ƒ/${asset.exifInfo.fNumber.toLocaleString($locale)}` || ''}</p>
|
||||
<div>
|
||||
<p>{asset.exifInfo.make || ''} {asset.exifInfo.model || ''}</p>
|
||||
<div class="flex text-sm gap-2">
|
||||
<p>{`ƒ/${asset.exifInfo.fNumber.toLocaleString($locale)}` || ''}</p>
|
||||
|
||||
{#if asset.exifInfo.exposureTime}
|
||||
<p>{`${asset.exifInfo.exposureTime}`}</p>
|
||||
{/if}
|
||||
{#if asset.exifInfo.exposureTime}
|
||||
<p>{`${asset.exifInfo.exposureTime}`}</p>
|
||||
{/if}
|
||||
|
||||
{#if asset.exifInfo.focalLength}
|
||||
<p>{`${asset.exifInfo.focalLength.toLocaleString($locale)} mm`}</p>
|
||||
{/if}
|
||||
{#if asset.exifInfo.focalLength}
|
||||
<p>{`${asset.exifInfo.focalLength.toLocaleString($locale)} mm`}</p>
|
||||
{/if}
|
||||
|
||||
{#if asset.exifInfo.iso}
|
||||
<p>
|
||||
{`ISO${asset.exifInfo.iso}`}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if asset.exifInfo.iso}
|
||||
<p>
|
||||
{`ISO${asset.exifInfo.iso}`}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if asset.exifInfo?.city}
|
||||
<div class="flex gap-4 py-4">
|
||||
<div><MapMarkerOutline size="24" /></div>
|
||||
{#if asset.exifInfo?.city}
|
||||
<div class="flex gap-4 py-4">
|
||||
<div><MapMarkerOutline size="24" /></div>
|
||||
|
||||
<div>
|
||||
<p>{asset.exifInfo.city}</p>
|
||||
<div class="flex text-sm gap-2">
|
||||
<p>{asset.exifInfo.state}</p>
|
||||
</div>
|
||||
<div class="flex text-sm gap-2">
|
||||
<p>{asset.exifInfo.country}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<p>{asset.exifInfo.city}</p>
|
||||
<div class="flex text-sm gap-2">
|
||||
<p>{asset.exifInfo.state}</p>
|
||||
</div>
|
||||
<div class="flex text-sm gap-2">
|
||||
<p>{asset.exifInfo.country}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if latlng}
|
||||
<div class="h-[360px]">
|
||||
{#await import('../shared-components/leaflet') then { Map, TileLayer, Marker }}
|
||||
<Map center={latlng} zoom={14}>
|
||||
<TileLayer
|
||||
urlTemplate={'https://tile.openstreetmap.org/{z}/{x}/{y}.png'}
|
||||
options={{
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}}
|
||||
/>
|
||||
<Marker {latlng} popupContent="{latlng[0].toFixed(7)},{latlng[1].toFixed(7)}" />
|
||||
</Map>
|
||||
{/await}
|
||||
</div>
|
||||
<div class="h-[360px]">
|
||||
{#await import('../shared-components/leaflet') then { Map, TileLayer, Marker }}
|
||||
<Map center={latlng} zoom={14}>
|
||||
<TileLayer
|
||||
urlTemplate={'https://tile.openstreetmap.org/{z}/{x}/{y}.png'}
|
||||
options={{
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
}}
|
||||
/>
|
||||
<Marker {latlng} popupContent="{latlng[0].toFixed(7)},{latlng[1].toFixed(7)}" />
|
||||
</Map>
|
||||
{/await}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section class="p-2 dark:text-immich-dark-fg">
|
||||
<div class="px-4 py-4">
|
||||
{#if albums.length > 0}
|
||||
<p class="text-sm pb-4">APPEARS IN</p>
|
||||
{/if}
|
||||
{#each albums as album}
|
||||
<a data-sveltekit-preload-data="hover" href={`/albums/${album.id}`}>
|
||||
<div
|
||||
class="flex gap-4 py-2 hover:cursor-pointer"
|
||||
on:click={() => dispatch('click', album)}
|
||||
on:keydown={() => dispatch('click', album)}
|
||||
>
|
||||
<div>
|
||||
<img
|
||||
alt={album.albumName}
|
||||
class="w-[50px] h-[50px] object-cover rounded"
|
||||
src={album.albumThumbnailAssetId &&
|
||||
api.getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Jpeg)}
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-4 py-4">
|
||||
{#if albums.length > 0}
|
||||
<p class="text-sm pb-4">APPEARS IN</p>
|
||||
{/if}
|
||||
{#each albums as album}
|
||||
<a data-sveltekit-preload-data="hover" href={`/albums/${album.id}`}>
|
||||
<div
|
||||
class="flex gap-4 py-2 hover:cursor-pointer"
|
||||
on:click={() => dispatch('click', album)}
|
||||
on:keydown={() => dispatch('click', album)}
|
||||
>
|
||||
<div>
|
||||
<img
|
||||
alt={album.albumName}
|
||||
class="w-[50px] h-[50px] object-cover rounded"
|
||||
src={album.albumThumbnailAssetId &&
|
||||
api.getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Jpeg)}
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto mb-auto">
|
||||
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
|
||||
<div class="flex gap-2 text-sm">
|
||||
<p>{album.assetCount} items</p>
|
||||
{#if album.shared}
|
||||
<p>· Shared</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-auto mb-auto">
|
||||
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
|
||||
<div class="flex gap-2 text-sm">
|
||||
<p>{album.assetCount} items</p>
|
||||
{#if album.shared}
|
||||
<p>· Shared</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,28 @@
|
|||
<script lang="ts">
|
||||
import { downloadAssets, isDownloading } from '$lib/stores/download';
|
||||
import { fly, slide } from 'svelte/transition';
|
||||
import { downloadAssets, isDownloading } from '$lib/stores/download';
|
||||
import { fly, slide } from 'svelte/transition';
|
||||
</script>
|
||||
|
||||
{#if $isDownloading}
|
||||
<div
|
||||
transition:fly={{ x: -100, duration: 350 }}
|
||||
class="w-[315px] max-h-[270px] bg-immich-bg border rounded-2xl shadow-sm absolute bottom-10 left-2 p-4 z-[10000] text-sm"
|
||||
>
|
||||
<p class="text-gray-500 text-xs mb-2">DOWNLOADING</p>
|
||||
<div class="max-h-[200px] my-2 overflow-y-auto mb-2 flex flex-col text-sm">
|
||||
{#each Object.keys($downloadAssets) as fileName}
|
||||
<div class="mb-2" transition:slide>
|
||||
<p class="font-medium text-xs truncate">■ {fileName}</p>
|
||||
<div class="flex flex-row-reverse place-items-center gap-5">
|
||||
<p>
|
||||
<span class="text-immich-primary font-medium">{$downloadAssets[fileName]}</span>/100
|
||||
</p>
|
||||
<div class="w-full bg-gray-200 rounded-full h-[7px] dark:bg-gray-700">
|
||||
<div
|
||||
class="bg-immich-primary h-[7px] rounded-full"
|
||||
style={`width: ${$downloadAssets[fileName]}%`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
transition:fly={{ x: -100, duration: 350 }}
|
||||
class="w-[315px] max-h-[270px] bg-immich-bg border rounded-2xl shadow-sm absolute bottom-10 left-2 p-4 z-[10000] text-sm"
|
||||
>
|
||||
<p class="text-gray-500 text-xs mb-2">DOWNLOADING</p>
|
||||
<div class="max-h-[200px] my-2 overflow-y-auto mb-2 flex flex-col text-sm">
|
||||
{#each Object.keys($downloadAssets) as fileName}
|
||||
<div class="mb-2" transition:slide>
|
||||
<p class="font-medium text-xs truncate">■ {fileName}</p>
|
||||
<div class="flex flex-row-reverse place-items-center gap-5">
|
||||
<p>
|
||||
<span class="text-immich-primary font-medium">{$downloadAssets[fileName]}</span>/100
|
||||
</p>
|
||||
<div class="w-full bg-gray-200 rounded-full h-[7px] dark:bg-gray-700">
|
||||
<div class="bg-immich-primary h-[7px] rounded-full" style={`width: ${$downloadAssets[fileName]}%`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,77 +1,77 @@
|
|||
<script lang="ts">
|
||||
import { BucketPosition } from '$lib/models/asset-grid-state';
|
||||
import { onMount } from 'svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { BucketPosition } from '$lib/models/asset-grid-state';
|
||||
import { onMount } from 'svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let once = false;
|
||||
export let top = 0;
|
||||
export let bottom = 0;
|
||||
export let left = 0;
|
||||
export let right = 0;
|
||||
export let root: HTMLElement | null = null;
|
||||
export let once = false;
|
||||
export let top = 0;
|
||||
export let bottom = 0;
|
||||
export let left = 0;
|
||||
export let right = 0;
|
||||
export let root: HTMLElement | null = null;
|
||||
|
||||
let intersecting = false;
|
||||
let container: HTMLDivElement;
|
||||
const dispatch = createEventDispatcher();
|
||||
let intersecting = false;
|
||||
let container: HTMLDivElement;
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
onMount(() => {
|
||||
if (typeof IntersectionObserver !== 'undefined') {
|
||||
const rootMargin = `${top}px ${right}px ${bottom}px ${left}px`;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
intersecting = entries[0].isIntersecting;
|
||||
if (!intersecting) {
|
||||
dispatch('hidden', container);
|
||||
}
|
||||
onMount(() => {
|
||||
if (typeof IntersectionObserver !== 'undefined') {
|
||||
const rootMargin = `${top}px ${right}px ${bottom}px ${left}px`;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
intersecting = entries[0].isIntersecting;
|
||||
if (!intersecting) {
|
||||
dispatch('hidden', container);
|
||||
}
|
||||
|
||||
if (intersecting && once) {
|
||||
observer.unobserve(container);
|
||||
}
|
||||
if (intersecting && once) {
|
||||
observer.unobserve(container);
|
||||
}
|
||||
|
||||
if (intersecting) {
|
||||
let position: BucketPosition = BucketPosition.Visible;
|
||||
if (entries[0].boundingClientRect.top + 50 > entries[0].intersectionRect.bottom) {
|
||||
position = BucketPosition.Below;
|
||||
} else if (entries[0].boundingClientRect.bottom < 0) {
|
||||
position = BucketPosition.Above;
|
||||
}
|
||||
if (intersecting) {
|
||||
let position: BucketPosition = BucketPosition.Visible;
|
||||
if (entries[0].boundingClientRect.top + 50 > entries[0].intersectionRect.bottom) {
|
||||
position = BucketPosition.Below;
|
||||
} else if (entries[0].boundingClientRect.bottom < 0) {
|
||||
position = BucketPosition.Above;
|
||||
}
|
||||
|
||||
dispatch('intersected', {
|
||||
container,
|
||||
position
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin,
|
||||
root
|
||||
}
|
||||
);
|
||||
dispatch('intersected', {
|
||||
container,
|
||||
position,
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin,
|
||||
root,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(container);
|
||||
return () => observer.unobserve(container);
|
||||
}
|
||||
observer.observe(container);
|
||||
return () => observer.unobserve(container);
|
||||
}
|
||||
|
||||
// The following is a fallback for older browsers
|
||||
function handler() {
|
||||
const bcr = container.getBoundingClientRect();
|
||||
// The following is a fallback for older browsers
|
||||
function handler() {
|
||||
const bcr = container.getBoundingClientRect();
|
||||
|
||||
intersecting =
|
||||
bcr.bottom + bottom > 0 &&
|
||||
bcr.right + right > 0 &&
|
||||
bcr.top - top < window.innerHeight &&
|
||||
bcr.left - left < window.innerWidth;
|
||||
intersecting =
|
||||
bcr.bottom + bottom > 0 &&
|
||||
bcr.right + right > 0 &&
|
||||
bcr.top - top < window.innerHeight &&
|
||||
bcr.left - left < window.innerWidth;
|
||||
|
||||
if (intersecting && once) {
|
||||
window.removeEventListener('scroll', handler);
|
||||
}
|
||||
}
|
||||
if (intersecting && once) {
|
||||
window.removeEventListener('scroll', handler);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handler);
|
||||
return () => window.removeEventListener('scroll', handler);
|
||||
});
|
||||
window.addEventListener('scroll', handler);
|
||||
return () => window.removeEventListener('scroll', handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={container}>
|
||||
<slot {intersecting} />
|
||||
<slot {intersecting} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,119 +1,113 @@
|
|||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import { onMount } from 'svelte';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { api, AssetResponseDto } from '@api';
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType
|
||||
} from '../shared-components/notification/notification';
|
||||
import { useZoomImageWheel } from '@zoom-image/svelte';
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { onMount } from 'svelte';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { api, AssetResponseDto } from '@api';
|
||||
import { notificationController, NotificationType } from '../shared-components/notification/notification';
|
||||
import { useZoomImageWheel } from '@zoom-image/svelte';
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let publicSharedKey = '';
|
||||
let imgElement: HTMLDivElement;
|
||||
export let asset: AssetResponseDto;
|
||||
export let publicSharedKey = '';
|
||||
let imgElement: HTMLDivElement;
|
||||
|
||||
let assetData: string;
|
||||
let assetData: string;
|
||||
|
||||
let copyImageToClipboard: (src: string) => Promise<Blob>;
|
||||
let canCopyImagesToClipboard: () => boolean;
|
||||
let copyImageToClipboard: (src: string) => Promise<Blob>;
|
||||
let canCopyImagesToClipboard: () => boolean;
|
||||
|
||||
onMount(async () => {
|
||||
// Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
|
||||
// TODO: Move to regular import once the package correctly supports ESM.
|
||||
const module = await import('copy-image-clipboard');
|
||||
copyImageToClipboard = module.copyImageToClipboard;
|
||||
canCopyImagesToClipboard = module.canCopyImagesToClipboard;
|
||||
});
|
||||
onMount(async () => {
|
||||
// Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
|
||||
// TODO: Move to regular import once the package correctly supports ESM.
|
||||
const module = await import('copy-image-clipboard');
|
||||
copyImageToClipboard = module.copyImageToClipboard;
|
||||
canCopyImagesToClipboard = module.canCopyImagesToClipboard;
|
||||
});
|
||||
|
||||
const loadAssetData = async () => {
|
||||
try {
|
||||
const { data } = await api.assetApi.serveFile(
|
||||
{ id: asset.id, isThumb: false, isWeb: true, key: publicSharedKey },
|
||||
{
|
||||
responseType: 'blob'
|
||||
}
|
||||
);
|
||||
const loadAssetData = async () => {
|
||||
try {
|
||||
const { data } = await api.assetApi.serveFile(
|
||||
{ id: asset.id, isThumb: false, isWeb: true, key: publicSharedKey },
|
||||
{
|
||||
responseType: 'blob',
|
||||
},
|
||||
);
|
||||
|
||||
if (!(data instanceof Blob)) {
|
||||
return;
|
||||
}
|
||||
if (!(data instanceof Blob)) {
|
||||
return;
|
||||
}
|
||||
|
||||
assetData = URL.createObjectURL(data);
|
||||
return assetData;
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
};
|
||||
assetData = URL.createObjectURL(data);
|
||||
return assetData;
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeypress = async ({ metaKey, ctrlKey, key }: KeyboardEvent) => {
|
||||
if ((metaKey || ctrlKey) && key === 'c') {
|
||||
await doCopy();
|
||||
}
|
||||
};
|
||||
const handleKeypress = async ({ metaKey, ctrlKey, key }: KeyboardEvent) => {
|
||||
if ((metaKey || ctrlKey) && key === 'c') {
|
||||
await doCopy();
|
||||
}
|
||||
};
|
||||
|
||||
const doCopy = async () => {
|
||||
if (!canCopyImagesToClipboard()) {
|
||||
return;
|
||||
}
|
||||
const doCopy = async () => {
|
||||
if (!canCopyImagesToClipboard()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await copyImageToClipboard(assetData);
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: 'Copied image to clipboard.',
|
||||
timeout: 3000
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error [photo-viewer]:', err);
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
message: 'Copying image to clipboard failed.'
|
||||
});
|
||||
}
|
||||
};
|
||||
try {
|
||||
await copyImageToClipboard(assetData);
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: 'Copied image to clipboard.',
|
||||
timeout: 3000,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error [photo-viewer]:', err);
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
message: 'Copying image to clipboard failed.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const doZoomImage = async () => {
|
||||
setZoomImageWheelState({
|
||||
currentZoom: $zoomImageWheelState.currentZoom === 1 ? 2 : 1
|
||||
});
|
||||
};
|
||||
const doZoomImage = async () => {
|
||||
setZoomImageWheelState({
|
||||
currentZoom: $zoomImageWheelState.currentZoom === 1 ? 2 : 1,
|
||||
});
|
||||
};
|
||||
|
||||
const {
|
||||
createZoomImage: createZoomImageWheel,
|
||||
zoomImageState: zoomImageWheelState,
|
||||
setZoomImageState: setZoomImageWheelState
|
||||
} = useZoomImageWheel();
|
||||
const {
|
||||
createZoomImage: createZoomImageWheel,
|
||||
zoomImageState: zoomImageWheelState,
|
||||
setZoomImageState: setZoomImageWheelState,
|
||||
} = useZoomImageWheel();
|
||||
|
||||
zoomImageWheelState.subscribe((state) => {
|
||||
photoZoomState.set(state);
|
||||
});
|
||||
zoomImageWheelState.subscribe((state) => {
|
||||
photoZoomState.set(state);
|
||||
});
|
||||
|
||||
$: if (imgElement) {
|
||||
createZoomImageWheel(imgElement, {
|
||||
wheelZoomRatio: 0.06
|
||||
});
|
||||
}
|
||||
$: if (imgElement) {
|
||||
createZoomImageWheel(imgElement, {
|
||||
wheelZoomRatio: 0.06,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeypress} on:copyImage={doCopy} on:zoomImage={doZoomImage} />
|
||||
|
||||
<div
|
||||
transition:fade={{ duration: 150 }}
|
||||
class="flex place-items-center place-content-center h-full select-none"
|
||||
>
|
||||
{#await loadAssetData()}
|
||||
<LoadingSpinner />
|
||||
{:then assetData}
|
||||
<div bind:this={imgElement} class="h-full w-full">
|
||||
<img
|
||||
transition:fade={{ duration: 150 }}
|
||||
src={assetData}
|
||||
alt={asset.id}
|
||||
class="object-contain h-full w-full"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
{/await}
|
||||
<div transition:fade={{ duration: 150 }} class="flex place-items-center place-content-center h-full select-none">
|
||||
{#await loadAssetData()}
|
||||
<LoadingSpinner />
|
||||
{:then assetData}
|
||||
<div bind:this={imgElement} class="h-full w-full">
|
||||
<img
|
||||
transition:fade={{ duration: 150 }}
|
||||
src={assetData}
|
||||
alt={asset.id}
|
||||
class="object-contain h-full w-full"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,45 +1,42 @@
|
|||
<script lang="ts">
|
||||
import { api } from '@api';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { videoViewerVolume } from '$lib/stores/preferences.store';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { api } from '@api';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { videoViewerVolume } from '$lib/stores/preferences.store';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
|
||||
export let assetId: string;
|
||||
export let publicSharedKey: string | undefined = undefined;
|
||||
export let assetId: string;
|
||||
export let publicSharedKey: string | undefined = undefined;
|
||||
|
||||
let isVideoLoading = true;
|
||||
const dispatch = createEventDispatcher();
|
||||
let isVideoLoading = true;
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const handleCanPlay = (ev: Event & { currentTarget: HTMLVideoElement }) => {
|
||||
const playerNode = ev.currentTarget;
|
||||
const handleCanPlay = (ev: Event & { currentTarget: HTMLVideoElement }) => {
|
||||
const playerNode = ev.currentTarget;
|
||||
|
||||
playerNode.muted = true;
|
||||
playerNode.play();
|
||||
playerNode.muted = false;
|
||||
playerNode.muted = true;
|
||||
playerNode.play();
|
||||
playerNode.muted = false;
|
||||
|
||||
isVideoLoading = false;
|
||||
};
|
||||
isVideoLoading = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
transition:fade={{ duration: 150 }}
|
||||
class="flex place-items-center place-content-center h-full select-none"
|
||||
>
|
||||
<video
|
||||
controls
|
||||
class="h-full object-contain"
|
||||
on:canplay={handleCanPlay}
|
||||
on:ended={() => dispatch('onVideoEnded')}
|
||||
bind:volume={$videoViewerVolume}
|
||||
>
|
||||
<source src={api.getAssetFileUrl(assetId, false, true, publicSharedKey)} type="video/mp4" />
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
<div transition:fade={{ duration: 150 }} class="flex place-items-center place-content-center h-full select-none">
|
||||
<video
|
||||
controls
|
||||
class="h-full object-contain"
|
||||
on:canplay={handleCanPlay}
|
||||
on:ended={() => dispatch('onVideoEnded')}
|
||||
bind:volume={$videoViewerVolume}
|
||||
>
|
||||
<source src={api.getAssetFileUrl(assetId, false, true, publicSharedKey)} type="video/mp4" />
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
|
||||
{#if isVideoLoading}
|
||||
<div class="absolute flex place-items-center place-content-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
{#if isVideoLoading}
|
||||
<div class="absolute flex place-items-center place-content-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue