mirror of
https://github.com/Art051/immich.git
synced 2025-08-11 19:29:00 +00:00
Add ablum feature to web (#352)
* Added album page * Refactor sidebar * Added album assets count info * Added album viewer page * Refactor album sorting * Fixed incorrectly showing selected asset in album selection * Improve fetching speed with prefetch * Refactor to use ImmichThubmnail component for all * Update to the latest version of Svelte * Implement fixed app bar in album viewer * Added shared user avatar * Correctly get all owned albums, including shared
This commit is contained in:
35
web/src/lib/components/shared/circle-avatar.svelte
Normal file
35
web/src/lib/components/shared/circle-avatar.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { api, UserResponseDto } from '@api';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let user: UserResponseDto;
|
||||
|
||||
onMount(() => {
|
||||
console.log(user);
|
||||
});
|
||||
|
||||
const getUserAvatar = async () => {
|
||||
try {
|
||||
const { data } = await api.userApi.getProfileImage(user.id, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
if (data instanceof Blob) {
|
||||
return URL.createObjectURL(data);
|
||||
}
|
||||
} catch (e) {
|
||||
return '/favicon.png';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#await getUserAvatar()}
|
||||
<div class="w-12 h-12 rounded-full bg-immich-primary/25" />
|
||||
{:then data}
|
||||
<img
|
||||
src={data}
|
||||
alt="profile-img"
|
||||
class="inline rounded-full w-12 h-12 object-cover border shadow-md"
|
||||
title={user.email}
|
||||
/>
|
||||
{/await}
|
||||
@@ -1,15 +0,0 @@
|
||||
export function clickOutside(node: Node) {
|
||||
const handleClick = (event: any) => {
|
||||
if (!node.contains(event.target)) {
|
||||
node.dispatchEvent(new CustomEvent("outclick"));
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("click", handleClick, true);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener("click", handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { clickOutside } from './click-outside';
|
||||
import { clickOutside } from '../../utils/click-outside';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
out:fade={{ duration: 100 }}
|
||||
class="absolute w-full h-full bg-black/40 z-[100] flex place-items-center place-content-center "
|
||||
>
|
||||
<div class="z-[9999]" use:clickOutside on:outclick={() => dispatch('clickOutside')}>
|
||||
<div class="z-[9999]" use:clickOutside on:out-click={() => dispatch('clickOutside')}>
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
245
web/src/lib/components/shared/immich-thumbnail.svelte
Normal file
245
web/src/lib/components/shared/immich-thumbnail.svelte
Normal file
@@ -0,0 +1,245 @@
|
||||
<script lang="ts">
|
||||
import { session } from '$app/stores';
|
||||
import { createEventDispatcher, onDestroy } from 'svelte';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
|
||||
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
|
||||
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
|
||||
import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
|
||||
import LoadingSpinner from './loading-spinner.svelte';
|
||||
import { api, AssetResponseDto, AssetTypeEnum, ThumbnailFormat } from '@api';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let groupIndex = 0;
|
||||
export let thumbnailSize: number | undefined = undefined;
|
||||
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
|
||||
|
||||
let imageData: string;
|
||||
let videoData: string;
|
||||
|
||||
let mouseOver: boolean = false;
|
||||
$: dispatch('mouseEvent', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
|
||||
|
||||
let mouseOverIcon: boolean = false;
|
||||
let videoPlayerNode: HTMLVideoElement;
|
||||
let isThumbnailVideoPlaying = false;
|
||||
let calculateVideoDurationIntervalHandler: NodeJS.Timer;
|
||||
let videoProgress = '00:00';
|
||||
|
||||
const loadImageData = async () => {
|
||||
if ($session.user) {
|
||||
const { data } = await api.assetApi.getAssetThumbnail(asset.id, format, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
if (data instanceof Blob) {
|
||||
imageData = URL.createObjectURL(data);
|
||||
return imageData;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadVideoData = async () => {
|
||||
isThumbnailVideoPlaying = false;
|
||||
|
||||
if ($session.user) {
|
||||
try {
|
||||
const { data } = await api.assetApi.serveFile(
|
||||
asset.deviceAssetId,
|
||||
asset.deviceId,
|
||||
false,
|
||||
true,
|
||||
{
|
||||
responseType: 'blob'
|
||||
}
|
||||
);
|
||||
|
||||
if (!(data instanceof Blob)) {
|
||||
return;
|
||||
}
|
||||
|
||||
videoData = URL.createObjectURL(data);
|
||||
|
||||
videoPlayerNode.src = videoData;
|
||||
// videoPlayerNode.src = videoData + '#t=0,5';
|
||||
|
||||
videoPlayerNode.load();
|
||||
|
||||
videoPlayerNode.onloadeddata = () => {
|
||||
console.log('first frame load');
|
||||
};
|
||||
|
||||
videoPlayerNode.oncanplaythrough = () => {
|
||||
console.log('can play through');
|
||||
};
|
||||
|
||||
videoPlayerNode.oncanplay = () => {
|
||||
console.log('can play');
|
||||
videoPlayerNode.muted = true;
|
||||
videoPlayerNode.play();
|
||||
|
||||
isThumbnailVideoPlaying = true;
|
||||
calculateVideoDurationIntervalHandler = setInterval(() => {
|
||||
videoProgress = getVideoDurationInString(Math.round(videoPlayerNode.currentTime));
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return videoData;
|
||||
} catch (e) {}
|
||||
}
|
||||
};
|
||||
|
||||
const getVideoDurationInString = (currentTime: number) => {
|
||||
const minute = Math.floor(currentTime / 60);
|
||||
const second = currentTime % 60;
|
||||
|
||||
const minuteText = minute >= 10 ? `${minute}` : `0${minute}`;
|
||||
const secondText = second >= 10 ? `${second}` : `0${second}`;
|
||||
|
||||
return minuteText + ':' + secondText;
|
||||
};
|
||||
|
||||
const parseVideoDuration = (duration: string) => {
|
||||
const timePart = duration.split(':');
|
||||
const hours = timePart[0];
|
||||
const minutes = timePart[1];
|
||||
const seconds = timePart[2];
|
||||
|
||||
if (hours != '0') {
|
||||
return `${hours}:${minutes}`;
|
||||
} else {
|
||||
return `${minutes}:${seconds.split('.')[0]}`;
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
URL.revokeObjectURL(imageData);
|
||||
});
|
||||
|
||||
const getSize = () => {
|
||||
if (thumbnailSize) {
|
||||
return `w-[${thumbnailSize}px] h-[${thumbnailSize}px]`;
|
||||
}
|
||||
|
||||
if (asset.exifInfo?.orientation === 'Rotate 90 CW') {
|
||||
return 'w-[176px] h-[235px]';
|
||||
} else if (asset.exifInfo?.orientation === 'Horizontal (normal)') {
|
||||
return 'w-[313px] h-[235px]';
|
||||
} else {
|
||||
return 'w-[235px] h-[235px]';
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseOverThumbnail = () => {
|
||||
mouseOver = true;
|
||||
};
|
||||
|
||||
const handleMouseLeaveThumbnail = () => {
|
||||
mouseOver = false;
|
||||
URL.revokeObjectURL(videoData);
|
||||
|
||||
clearInterval(calculateVideoDurationIntervalHandler);
|
||||
|
||||
isThumbnailVideoPlaying = false;
|
||||
videoProgress = '00:00';
|
||||
};
|
||||
</script>
|
||||
|
||||
<IntersectionObserver once={true} let:intersecting>
|
||||
<div
|
||||
style:width={`${thumbnailSize}px`}
|
||||
style:height={`${thumbnailSize}px`}
|
||||
class={`bg-gray-100 relative hover:cursor-pointer ${getSize()}`}
|
||||
on:mouseenter={handleMouseOverThumbnail}
|
||||
on:mouseleave={handleMouseLeaveThumbnail}
|
||||
on:click={() => dispatch('viewAsset', { assetId: asset.id, deviceId: asset.deviceId })}
|
||||
>
|
||||
{#if mouseOver}
|
||||
<div
|
||||
in:fade={{ duration: 200 }}
|
||||
class="w-full bg-gradient-to-b from-gray-800/50 via-white/0 to-white/0 absolute p-2 z-10"
|
||||
>
|
||||
<div
|
||||
on:mouseenter={() => (mouseOverIcon = true)}
|
||||
on:mouseleave={() => (mouseOverIcon = false)}
|
||||
class="inline-block"
|
||||
>
|
||||
<CheckCircle size="24" color={mouseOverIcon ? 'white' : '#d8dadb'} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Playback and info -->
|
||||
{#if asset.type === AssetTypeEnum.Video}
|
||||
<div
|
||||
class="absolute right-2 top-2 text-white text-xs font-medium flex gap-1 place-items-center z-10"
|
||||
>
|
||||
{#if isThumbnailVideoPlaying}
|
||||
<span in:fly={{ x: -25, duration: 500 }}>
|
||||
{videoProgress}
|
||||
</span>
|
||||
{:else}
|
||||
<span in:fade={{ duration: 500 }}>
|
||||
{parseVideoDuration(asset.duration)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if mouseOver}
|
||||
{#if isThumbnailVideoPlaying}
|
||||
<span in:fly={{ x: 25, duration: 500 }}>
|
||||
<PauseCircleOutline size="24" />
|
||||
</span>
|
||||
{:else}
|
||||
<span in:fade={{ duration: 250 }}>
|
||||
<LoadingSpinner />
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span in:fade={{ duration: 500 }}>
|
||||
<PlayCircleOutline size="24" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Thumbnail -->
|
||||
{#if intersecting}
|
||||
{#await loadImageData()}
|
||||
<div
|
||||
style:width={`${thumbnailSize}px`}
|
||||
style:height={`${thumbnailSize}px`}
|
||||
class={`bg-immich-primary/10 ${getSize()} flex place-items-center place-content-center`}
|
||||
>
|
||||
...
|
||||
</div>
|
||||
{:then imageData}
|
||||
<img
|
||||
style:width={`${thumbnailSize}px`}
|
||||
style:height={`${thumbnailSize}px`}
|
||||
in:fade={{ duration: 250 }}
|
||||
src={imageData}
|
||||
alt={asset.id}
|
||||
class={`object-cover ${getSize()} transition-all duration-100 z-0`}
|
||||
loading="lazy"
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
{#if mouseOver && asset.type === AssetTypeEnum.Video}
|
||||
<div class="absolute w-full h-full top-0" on:mouseenter={loadVideoData}>
|
||||
<video
|
||||
muted
|
||||
autoplay
|
||||
preload="none"
|
||||
class="h-full object-cover"
|
||||
width="250px"
|
||||
style:width={`${thumbnailSize}px`}
|
||||
bind:this={videoPlayerNode}
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</IntersectionObserver>
|
||||
@@ -7,7 +7,7 @@
|
||||
import { fade, fly, slide } from 'svelte/transition';
|
||||
import { serverEndpoint } from '../../constants';
|
||||
import TrayArrowUp from 'svelte-material-icons/TrayArrowUp.svelte';
|
||||
import { clickOutside } from './click-outside';
|
||||
import { clickOutside } from '../../utils/click-outside';
|
||||
import { api } from '@api';
|
||||
|
||||
export let user: ImmichUser;
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
<section id="dashboard-navbar" class="fixed w-screen z-[100] bg-immich-bg text-sm">
|
||||
<div class="flex border-b place-items-center px-6 py-2 ">
|
||||
<a class="flex gap-2 place-items-center hover:cursor-pointer" href="/photos">
|
||||
<a sveltekit:prefetch class="flex gap-2 place-items-center hover:cursor-pointer" href="/photos">
|
||||
<img src="/immich-logo.svg" alt="immich logo" height="35" width="35" />
|
||||
<h1 class="font-immich-title text-2xl text-immich-primary">IMMICH</h1>
|
||||
</a>
|
||||
@@ -76,12 +76,13 @@
|
||||
{/if}
|
||||
|
||||
{#if user.isAdmin}
|
||||
<button
|
||||
class={`flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium ${
|
||||
$page.url.pathname == '/admin' && 'text-immich-primary underline'
|
||||
}`}
|
||||
on:click={navigateToAdmin}>Administration</button
|
||||
>
|
||||
<a sveltekit:prefetch href={`admin`}>
|
||||
<button
|
||||
class={`flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium ${
|
||||
$page.url.pathname == '/admin' && 'text-immich-primary underline'
|
||||
}`}>Administration</button
|
||||
>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
@@ -125,7 +126,7 @@
|
||||
id="account-info-panel"
|
||||
class="absolute right-[25px] top-[75px] bg-white shadow-lg rounded-2xl w-[360px] text-center"
|
||||
use:clickOutside
|
||||
on:outclick={() => (shouldShowAccountInfoPanel = false)}
|
||||
on:out-click={() => (shouldShowAccountInfoPanel = false)}
|
||||
>
|
||||
<div class="flex place-items-center place-content-center mt-6">
|
||||
<button
|
||||
|
||||
@@ -5,13 +5,16 @@
|
||||
export let isSelected: boolean;
|
||||
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { AdminSideBarSelection, AppSideBarSelection } from '../../models/admin-sidebar-selection';
|
||||
import type {
|
||||
AdminSideBarSelection,
|
||||
AppSideBarSelection
|
||||
} from '../../../models/admin-sidebar-selection';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const onButtonClicked = () => {
|
||||
dispatch('selected', {
|
||||
actionType,
|
||||
actionType
|
||||
});
|
||||
};
|
||||
</script>
|
||||
65
web/src/lib/components/shared/side-bar/side-bar.svelte
Normal file
65
web/src/lib/components/shared/side-bar/side-bar.svelte
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import { AppSideBarSelection } from '$lib/models/admin-sidebar-selection';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import ImageAlbum from 'svelte-material-icons/ImageAlbum.svelte';
|
||||
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
|
||||
import SideBarButton from './side-bar-button.svelte';
|
||||
import StatusBox from '../status-box.svelte';
|
||||
|
||||
let selectedAction: AppSideBarSelection;
|
||||
|
||||
const onSidebarButtonClicked = (buttonType: CustomEvent) => {
|
||||
selectedAction = buttonType.detail['actionType'] as AppSideBarSelection;
|
||||
|
||||
if (selectedAction == AppSideBarSelection.PHOTOS) {
|
||||
if ($page.routeId != 'photos') {
|
||||
goto('/photos');
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedAction == AppSideBarSelection.ALBUMS) {
|
||||
if ($page.routeId != 'albums') {
|
||||
goto('/albums');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if ($page.routeId == 'albums') {
|
||||
selectedAction = AppSideBarSelection.ALBUMS;
|
||||
} else if ($page.routeId == 'photos') {
|
||||
selectedAction = AppSideBarSelection.PHOTOS;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<section id="sidebar" class="flex flex-col gap-4 pt-8 pr-6">
|
||||
<a sveltekit:prefetch href={$page.routeId != 'photos' ? `/photos` : null}>
|
||||
<SideBarButton
|
||||
title="Photos"
|
||||
logo={ImageOutline}
|
||||
actionType={AppSideBarSelection.PHOTOS}
|
||||
isSelected={selectedAction === AppSideBarSelection.PHOTOS}
|
||||
/></a
|
||||
>
|
||||
|
||||
<div class="text-xs ml-5">
|
||||
<p>LIBRARY</p>
|
||||
</div>
|
||||
<a sveltekit:prefetch href={$page.routeId != 'albums' ? `/albums` : null}>
|
||||
<SideBarButton
|
||||
title="Albums"
|
||||
logo={ImageAlbum}
|
||||
actionType={AppSideBarSelection.ALBUMS}
|
||||
isSelected={selectedAction === AppSideBarSelection.ALBUMS}
|
||||
/>
|
||||
</a>
|
||||
<!-- Status Box -->
|
||||
|
||||
<div class="mb-6 mt-auto">
|
||||
<StatusBox />
|
||||
</div>
|
||||
</section>
|
||||
Reference in New Issue
Block a user