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:
Alex 2022-07-15 23:18:17 -05:00 committed by GitHub
parent 1887b5a860
commit 7134f93eb8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
62 changed files with 2572 additions and 991 deletions

View file

@ -0,0 +1,57 @@
<script lang="ts">
import { AlbumResponseDto, api, ThumbnailFormat } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
import { fade } from 'svelte/transition';
export let album: AlbumResponseDto;
let imageData: string = '/no-thumbnail.png';
const dispatch = createEventDispatcher();
const loadImageData = async (thubmnailId: string | null) => {
if (thubmnailId == null) {
return '/no-thumbnail.png';
}
const { data } = await api.assetApi.getAssetThumbnail(thubmnailId!, ThumbnailFormat.Jpeg, { responseType: 'blob' });
if (data instanceof Blob) {
imageData = URL.createObjectURL(data);
return imageData;
}
};
</script>
<div class="h-[339px] w-[275px] hover:cursor-pointer mt-4" on:click={() => dispatch('click', album)}>
<div class={`h-[275px] w-[275px]`}>
{#await loadImageData(album.albumThumbnailAssetId)}
<div class={`bg-immich-primary/10 w-full h-full flex place-items-center place-content-center rounded-xl`}>
...
</div>
{:then imageData}
<img
in:fade={{ duration: 250 }}
src={imageData}
alt={album.id}
class={`object-cover w-full h-full transition-all z-0 rounded-xl duration-300 hover:translate-x-2 hover:-translate-y-2 hover:shadow-[-8px_8px_0px_0_#FFB800]`}
/>
{/await}
</div>
<div class="mt-4">
<p class="text-sm font-medium text-gray-800">
{album.albumName}
</p>
<span class="text-xs flex gap-2">
<p>{album.assets.length} items</p>
{#if album.shared}
<p>·</p>
<p>Shared</p>
{/if}
</span>
</div>
</div>
<style>
</style>

View file

@ -0,0 +1,101 @@
<script lang="ts">
import { AlbumResponseDto, ThumbnailFormat } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import FileImagePlusOutline from 'svelte-material-icons/FileImagePlusOutline.svelte';
import CircleAvatar from '../shared/circle-avatar.svelte';
import ImmichThumbnail from '../shared/immich-thumbnail.svelte';
const dispatch = createEventDispatcher();
export let album: AlbumResponseDto;
let viewWidth: number;
let thumbnailSize: number = 300;
let border = '';
$: {
if (album.assets.length < 6) {
thumbnailSize = Math.floor(viewWidth / album.assets.length - album.assets.length);
} else {
thumbnailSize = Math.floor(viewWidth / 6 - 6);
}
}
const getDateRange = () => {
const startDate = new Date(album.assets[0].createdAt);
const endDate = new Date(album.assets[album.assets.length - 1].createdAt);
const startDateString = startDate.toLocaleDateString('us-EN', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
const endDateString = endDate.toLocaleDateString('us-EN', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
return `${startDateString} - ${endDateString}`;
};
onMount(() => {
window.onscroll = (event: Event) => {
if (window.pageYOffset > 80) {
border = 'border border-gray-200 bg-gray-50';
} else {
border = '';
}
};
});
</script>
<section class="w-screen h-screen bg-immich-bg">
<div class="fixed top-0 w-full bg-immich-bg z-[100]">
<div class={`flex justify-between rounded-lg ${border} p-2 mx-2 mt-2 transition-all`}>
<a sveltekit:prefetch href="/albums" title="Go Back">
<button
id="immich-circle-icon-button"
class={`rounded-full p-3 flex place-items-center place-content-center text-gray-600 transition-all hover:bg-gray-200`}
>
<ArrowLeft size="24" />
</button>
</a>
<div class="right-button-group" title="Add Photos">
<button
id="immich-circle-icon-button"
class={`rounded-full p-3 flex place-items-center place-content-center text-gray-600 transition-all hover:bg-gray-200`}
on:click={() => dispatch('click')}
>
<FileImagePlusOutline size="24" />
</button>
</div>
</div>
</div>
<section class="m-6 py-[72px] px-[160px]">
<p class="text-6xl text-immich-primary">
{album.albumName}
</p>
<p class="my-4 text-sm text-gray-500">{getDateRange()}</p>
{#if album.sharedUsers.length > 0}
<div class="mb-4">
{#each album.sharedUsers as user}
<span class="mr-1">
<CircleAvatar {user} />
</span>
{/each}
</div>
{/if}
<div class="flex flex-wrap gap-1 w-full" bind:clientWidth={viewWidth}>
{#each album.assets as asset}
{#if album.assets.length < 7}
<ImmichThumbnail {asset} {thumbnailSize} format={ThumbnailFormat.Jpeg} />
{:else}
<ImmichThumbnail {asset} {thumbnailSize} />
{/if}
{/each}
</div>
</section>
</section>

View file

@ -6,7 +6,7 @@
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
import CircleIconButton from '../shared/circle_icon_button.svelte';
import CircleIconButton from '../shared/circle-icon-button.svelte';
const dispatch = createEventDispatcher();
</script>

View file

@ -5,13 +5,12 @@
import { flattenAssetGroupByDate } from '$lib/stores/assets';
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
import { AssetType } from '../../models/immich-asset';
import PhotoViewer from './photo-viewer.svelte';
import DetailPanel from './detail-panel.svelte';
import { session } from '$app/stores';
import { downloadAssets } from '$lib/stores/download';
import VideoViewer from './video-viewer.svelte';
import { api, AssetResponseDto } from '@api';
import { api, AssetResponseDto, AssetTypeEnum } from '@api';
const dispatch = createEventDispatcher();
@ -191,7 +190,7 @@
<div class="row-start-1 row-span-full col-start-1 col-span-4">
{#key selectedIndex}
{#if viewAssetId && viewDeviceId}
{#if selectedAsset.type == AssetType.IMAGE}
{#if selectedAsset.type == AssetTypeEnum.Image}
<PhotoViewer assetId={viewAssetId} deviceId={viewDeviceId} on:close={closeViewer} />
{:else}
<VideoViewer assetId={viewAssetId} on:close={closeViewer} />

View 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}

View file

@ -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);
}
};
}

View file

@ -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>

View file

@ -1,5 +1,4 @@
<script lang="ts">
import { AssetType } from '../../models/immich-asset';
import { session } from '$app/stores';
import { createEventDispatcher, onDestroy } from 'svelte';
import { fade, fly } from 'svelte/transition';
@ -7,13 +6,15 @@
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 '../shared/loading-spinner.svelte';
import { api, AssetResponseDto } from '@api';
import LoadingSpinner from './loading-spinner.svelte';
import { api, AssetResponseDto, AssetTypeEnum, ThumbnailFormat } from '@api';
const dispatch = createEventDispatcher();
export let asset: AssetResponseDto;
export let groupIndex: number;
export let groupIndex = 0;
export let thumbnailSize: number | undefined = undefined;
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
let imageData: string;
let videoData: string;
@ -29,7 +30,9 @@
const loadImageData = async () => {
if ($session.user) {
const { data } = await api.assetApi.getAssetThumbnail(asset.id, { responseType: 'blob' });
const { data } = await api.assetApi.getAssetThumbnail(asset.id, format, {
responseType: 'blob'
});
if (data instanceof Blob) {
imageData = URL.createObjectURL(data);
return imageData;
@ -42,9 +45,15 @@
if ($session.user) {
try {
const { data } = await api.assetApi.serveFile(asset.deviceAssetId, asset.deviceId, false, true, {
responseType: 'blob',
});
const { data } = await api.assetApi.serveFile(
asset.deviceAssetId,
asset.deviceId,
false,
true,
{
responseType: 'blob'
}
);
if (!(data instanceof Blob)) {
return;
@ -109,6 +118,10 @@
});
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)') {
@ -135,6 +148,8 @@
<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}
@ -156,8 +171,10 @@
{/if}
<!-- Playback and info -->
{#if asset.type === AssetType.VIDEO}
<div class="absolute right-2 top-2 text-white text-xs font-medium flex gap-1 place-items-center z-10">
{#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}
@ -189,9 +206,17 @@
<!-- Thumbnail -->
{#if intersecting}
{#await loadImageData()}
<div class={`bg-immich-primary/10 ${getSize()} flex place-items-center place-content-center`}>...</div>
<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}
@ -201,9 +226,17 @@
{/await}
{/if}
{#if mouseOver && asset.type === AssetType.VIDEO}
{#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" bind:this={videoPlayerNode}>
<video
muted
autoplay
preload="none"
class="h-full object-cover"
width="250px"
style:width={`${thumbnailSize}px`}
bind:this={videoPlayerNode}
>
<track kind="captions" />
</video>
</div>

View file

@ -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

View file

@ -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>

View 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>