mirror of
https://github.com/immich-app/immich.git
synced 2024-12-26 10:50:29 +02:00
feat(web): enhance ux/ui of the album list page (#8499)
* feat(web): enhance ux/ui of the album list page * fix unit tests * feat(web): enhance ux/ui of the album list page * fix unit tests * small styling * better dot * lint --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
939e91f9ed
commit
8f981b6052
@ -1,6 +1,4 @@
|
||||
import { createObjectURLMock } from '$lib/__mocks__/jsdom-url.mock';
|
||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||
import { ThumbnailFormat } from '@immich/sdk';
|
||||
import { albumFactory } from '@test-data';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, waitFor, type RenderResult } from '@testing-library/svelte';
|
||||
@ -33,7 +31,7 @@ describe('AlbumCard component', () => {
|
||||
shared: true,
|
||||
},
|
||||
])('shows album data without thumbnail with count $count - shared: $shared', async ({ album, count, shared }) => {
|
||||
sut = render(AlbumCard, { album });
|
||||
sut = render(AlbumCard, { album, showItemCount: true });
|
||||
|
||||
const albumImgElement = sut.getByTestId('album-image');
|
||||
const albumNameElement = sut.getByTestId('album-name');
|
||||
@ -52,36 +50,22 @@ describe('AlbumCard component', () => {
|
||||
expect(albumDetailsElement).toHaveTextContent(new RegExp(detailsText));
|
||||
});
|
||||
|
||||
it('shows album data and loads the thumbnail image when available', async () => {
|
||||
const thumbnailFile = new File([new Blob()], 'fileThumbnail');
|
||||
const thumbnailUrl = 'blob:thumbnailUrlOne';
|
||||
sdkMock.getAssetThumbnail.mockResolvedValue(thumbnailFile);
|
||||
createObjectURLMock.mockReturnValueOnce(thumbnailUrl);
|
||||
|
||||
it('shows album data', () => {
|
||||
const album = albumFactory.build({
|
||||
albumThumbnailAssetId: 'thumbnailIdOne',
|
||||
shared: false,
|
||||
albumName: 'some album name',
|
||||
});
|
||||
sut = render(AlbumCard, { album });
|
||||
sut = render(AlbumCard, { album, showItemCount: true });
|
||||
|
||||
const albumImgElement = sut.getByTestId('album-image');
|
||||
const albumNameElement = sut.getByTestId('album-name');
|
||||
const albumDetailsElement = sut.getByTestId('album-details');
|
||||
expect(albumImgElement).toHaveAttribute('alt', album.albumName);
|
||||
|
||||
await waitFor(() => expect(albumImgElement).toHaveAttribute('src', thumbnailUrl));
|
||||
|
||||
expect(albumImgElement).toHaveAttribute('alt', album.albumName);
|
||||
expect(sdkMock.getAssetThumbnail).toHaveBeenCalledTimes(1);
|
||||
expect(sdkMock.getAssetThumbnail).toHaveBeenCalledWith({
|
||||
id: 'thumbnailIdOne',
|
||||
format: ThumbnailFormat.Jpeg,
|
||||
});
|
||||
expect(createObjectURLMock).toHaveBeenCalledWith(thumbnailFile);
|
||||
expect(albumImgElement).toHaveAttribute('src');
|
||||
|
||||
expect(albumNameElement).toHaveTextContent('some album name');
|
||||
expect(albumDetailsElement).toHaveTextContent('0 items');
|
||||
expect(albumDetailsElement).toHaveTextContent('0 item');
|
||||
});
|
||||
|
||||
it('hides context menu when "onShowContextMenu" is undefined', () => {
|
||||
|
69
web/src/lib/components/album-page/album-card-group.svelte
Normal file
69
web/src/lib/components/album-page/album-card-group.svelte
Normal file
@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { flip } from 'svelte/animate';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import type { AlbumResponseDto } from '@immich/sdk';
|
||||
import { albumViewSettings } from '$lib/stores/preferences.store';
|
||||
import type { ContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import { type AlbumGroup, isAlbumGroupCollapsed, toggleAlbumGroupCollapsing } from '$lib/utils/album-utils';
|
||||
import { mdiChevronRight } from '@mdi/js';
|
||||
import AlbumCard from '$lib/components/album-page/album-card.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
|
||||
export let albums: AlbumResponseDto[];
|
||||
export let group: AlbumGroup | undefined = undefined;
|
||||
export let showOwner = false;
|
||||
export let showDateRange = false;
|
||||
export let showItemCount = false;
|
||||
export let onShowContextMenu: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined =
|
||||
undefined;
|
||||
|
||||
$: isCollapsed = !!group && isAlbumGroupCollapsed($albumViewSettings, group.id);
|
||||
|
||||
const showContextMenu = (position: ContextMenuPosition, album: AlbumResponseDto) => {
|
||||
onShowContextMenu?.(position, album);
|
||||
};
|
||||
|
||||
$: iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90';
|
||||
</script>
|
||||
|
||||
{#if group}
|
||||
<div class="grid">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<p on:click={() => toggleAlbumGroupCollapsing(group.id)} class="w-fit mt-2 pt-2 pr-2 mb-2 hover:cursor-pointer">
|
||||
<Icon
|
||||
path={mdiChevronRight}
|
||||
size="24"
|
||||
class="inline-block -mt-2.5 transition-all duration-[250ms] {iconRotation}"
|
||||
/>
|
||||
<span class="font-bold text-3xl text-black dark:text-white">{group.name}</span>
|
||||
<span class="ml-1.5 dark:text-immich-dark-fg">({albums.length} {albums.length > 1 ? 'albums' : 'album'})</span>
|
||||
</p>
|
||||
<hr class="dark:border-immich-dark-gray" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4">
|
||||
{#if !isCollapsed}
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] gap-y-4" transition:slide={{ duration: 300 }}>
|
||||
{#each albums as album, index (album.id)}
|
||||
<a
|
||||
data-sveltekit-preload-data="hover"
|
||||
href="{AppRoute.ALBUMS}/{album.id}"
|
||||
animate:flip={{ duration: 400 }}
|
||||
on:contextmenu|preventDefault={(e) => showContextMenu({ x: e.x, y: e.y }, album)}
|
||||
>
|
||||
<AlbumCard
|
||||
{album}
|
||||
{showOwner}
|
||||
{showDateRange}
|
||||
{showItemCount}
|
||||
preload={index < 20}
|
||||
onShowContextMenu={onShowContextMenu ? (position) => showContextMenu(position, album) : undefined}
|
||||
/>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
@ -2,43 +2,25 @@
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { ThumbnailFormat, getAssetThumbnail, getUserById, type AlbumResponseDto } from '@immich/sdk';
|
||||
import type { AlbumResponseDto } from '@immich/sdk';
|
||||
import { mdiDotsVertical } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { getContextMenuPosition, type ContextMenuPosition } from '../../utils/context-menu';
|
||||
import IconButton from '../elements/buttons/icon-button.svelte';
|
||||
import { getContextMenuPosition, type ContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import { getShortDateRange } from '$lib/utils/date-time';
|
||||
import IconButton from '$lib/components/elements/buttons/icon-button.svelte';
|
||||
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
|
||||
|
||||
export let album: AlbumResponseDto;
|
||||
export let isSharingView = false;
|
||||
export let showItemCount = true;
|
||||
export let showOwner = false;
|
||||
export let showDateRange = false;
|
||||
export let showItemCount = false;
|
||||
export let preload = false;
|
||||
export let onShowContextMenu: ((position: ContextMenuPosition) => void) | undefined = undefined;
|
||||
|
||||
$: imageData = album.albumThumbnailAssetId
|
||||
? getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Webp)
|
||||
: null;
|
||||
|
||||
const loadHighQualityThumbnail = async (assetId: string | null) => {
|
||||
if (!assetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await getAssetThumbnail({ id: assetId, format: ThumbnailFormat.Jpeg });
|
||||
return URL.createObjectURL(data);
|
||||
};
|
||||
export let onShowContextMenu: ((position: ContextMenuPosition) => unknown) | undefined = undefined;
|
||||
|
||||
const showAlbumContextMenu = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onShowContextMenu?.(getContextMenuPosition(e));
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
imageData = (await loadHighQualityThumbnail(album.albumThumbnailAssetId)) || null;
|
||||
});
|
||||
|
||||
const getAlbumOwnerInfo = () => getUserById({ id: album.ownerId });
|
||||
</script>
|
||||
|
||||
<div
|
||||
@ -57,60 +39,43 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class={`relative aspect-square`}>
|
||||
{#if album.albumThumbnailAssetId}
|
||||
<img
|
||||
loading={preload ? 'eager' : 'lazy'}
|
||||
src={imageData}
|
||||
alt={album.albumName}
|
||||
class="z-0 h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg"
|
||||
data-testid="album-image"
|
||||
draggable="false"
|
||||
/>
|
||||
{:else}
|
||||
<enhanced:img
|
||||
loading={preload ? 'eager' : 'lazy'}
|
||||
src="$lib/assets/no-thumbnail.png"
|
||||
sizes="min(271px,186px)"
|
||||
alt={album.albumName}
|
||||
class="z-0 h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg"
|
||||
data-testid="album-image"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<AlbumCover {album} {preload} css="h-full w-full transition-all duration-300 hover:shadow-lg" />
|
||||
|
||||
<div class="mt-4">
|
||||
<p
|
||||
class="w-full truncate text-lg font-semibold text-black dark:text-white group-hover:text-immich-primary dark:group-hover:text-immich-dark-primary"
|
||||
class="w-full leading-6 text-lg line-clamp-2 font-semibold text-black dark:text-white group-hover:text-immich-primary dark:group-hover:text-immich-dark-primary"
|
||||
data-testid="album-name"
|
||||
title={album.albumName}
|
||||
>
|
||||
{album.albumName}
|
||||
</p>
|
||||
|
||||
<span class="flex gap-2 text-sm dark:text-immich-dark-fg" data-testid="album-details">
|
||||
{#if showDateRange && album.startDate && album.endDate}
|
||||
<p class="flex text-sm dark:text-immich-dark-fg capitalize">
|
||||
{getShortDateRange(album.startDate, album.endDate)}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<span class="flex gap-2 text-sm" data-testid="album-details">
|
||||
{#if showItemCount}
|
||||
<p>
|
||||
{album.assetCount.toLocaleString($locale)}
|
||||
{album.assetCount == 1 ? `item` : `items`}
|
||||
{album.assetCount === 1 ? `item` : `items`}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if isSharingView || album.shared}
|
||||
<p>·</p>
|
||||
{#if (showOwner || album.shared) && showItemCount}
|
||||
<p>•</p>
|
||||
{/if}
|
||||
|
||||
{#if isSharingView}
|
||||
{#await getAlbumOwnerInfo() then albumOwner}
|
||||
{#if $user.email == albumOwner.email}
|
||||
<p>Owned</p>
|
||||
{:else}
|
||||
<p>
|
||||
Shared by {albumOwner.name}
|
||||
</p>
|
||||
{/if}
|
||||
{/await}
|
||||
{#if showOwner}
|
||||
{#if $user.id === album.ownerId}
|
||||
<p>Owned</p>
|
||||
{:else if album.owner}
|
||||
<p>Shared by {album.owner.name}</p>
|
||||
{:else}
|
||||
<p>Shared</p>
|
||||
{/if}
|
||||
{:else if album.shared}
|
||||
<p>Shared</p>
|
||||
{/if}
|
||||
|
36
web/src/lib/components/album-page/album-cover.svelte
Normal file
36
web/src/lib/components/album-page/album-cover.svelte
Normal file
@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { ThumbnailFormat, type AlbumResponseDto } from '@immich/sdk';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
|
||||
export let album: AlbumResponseDto | undefined;
|
||||
export let preload = false;
|
||||
export let css = '';
|
||||
|
||||
$: thumbnailUrl =
|
||||
album && album.albumThumbnailAssetId
|
||||
? getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Webp)
|
||||
: null;
|
||||
</script>
|
||||
|
||||
<div class="relative aspect-square">
|
||||
{#if thumbnailUrl}
|
||||
<img
|
||||
loading={preload ? 'eager' : 'lazy'}
|
||||
src={thumbnailUrl}
|
||||
alt={album?.albumName ?? 'Unknown Album'}
|
||||
class="z-0 rounded-xl object-cover {css}"
|
||||
data-testid="album-image"
|
||||
draggable="false"
|
||||
/>
|
||||
{:else}
|
||||
<enhanced:img
|
||||
loading={preload ? 'eager' : 'lazy'}
|
||||
src="$lib/assets/no-thumbnail.png"
|
||||
sizes="min(271px,186px)"
|
||||
alt={album?.albumName ?? 'Empty Album'}
|
||||
class="z-0 rounded-xl object-cover {css}"
|
||||
data-testid="album-image"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
@ -37,7 +37,7 @@
|
||||
on:input={(e) => autoGrowHeight(e.currentTarget)}
|
||||
on:focusout={handleUpdateDescription}
|
||||
use:autoGrowHeight
|
||||
placeholder="Add description"
|
||||
placeholder="Add a description"
|
||||
use:shortcut={{
|
||||
shortcut: { key: 'Enter', ctrl: true },
|
||||
onShortcut: (e) => e.currentTarget.blur(),
|
||||
|
@ -6,7 +6,7 @@
|
||||
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
|
||||
import { createAssetInteractionStore } from '../../stores/asset-interaction.store';
|
||||
import { AssetStore } from '../../stores/assets.store';
|
||||
import { downloadArchive } from '../../utils/asset-utils';
|
||||
import { downloadAlbum } from '../../utils/asset-utils';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import DownloadAction from '../photos-page/actions/download-action.svelte';
|
||||
import AssetGrid from '../photos-page/asset-grid.svelte';
|
||||
@ -36,10 +36,6 @@
|
||||
dragAndDropFilesStore.set({ isDragging: false, files: [] });
|
||||
}
|
||||
});
|
||||
|
||||
const downloadAlbum = async () => {
|
||||
await downloadArchive(`${album.albumName}.zip`, { albumId: album.id });
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
@ -83,7 +79,7 @@
|
||||
{/if}
|
||||
|
||||
{#if album.assetCount > 0 && sharedLink.allowDownload}
|
||||
<CircleIconButton title="Download" on:click={() => downloadAlbum()} icon={mdiFolderDownloadOutline} />
|
||||
<CircleIconButton title="Download" on:click={() => downloadAlbum(album)} icon={mdiFolderDownloadOutline} />
|
||||
{/if}
|
||||
|
||||
<ThemeButton />
|
||||
|
@ -2,30 +2,94 @@
|
||||
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
|
||||
import Dropdown from '$lib/components/elements/dropdown.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { AlbumFilter, AlbumViewMode, albumViewSettings } from '$lib/stores/preferences.store';
|
||||
import {
|
||||
AlbumFilter,
|
||||
AlbumGroupBy,
|
||||
AlbumViewMode,
|
||||
albumViewSettings,
|
||||
SortOrder,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import {
|
||||
mdiArrowDownThin,
|
||||
mdiArrowUpThin,
|
||||
mdiFolderArrowDownOutline,
|
||||
mdiFolderArrowUpOutline,
|
||||
mdiFolderRemoveOutline,
|
||||
mdiFormatListBulletedSquare,
|
||||
mdiPlusBoxOutline,
|
||||
mdiUnfoldLessHorizontal,
|
||||
mdiUnfoldMoreHorizontal,
|
||||
mdiViewGridOutline,
|
||||
} from '@mdi/js';
|
||||
import { sortByOptions, type Sort, handleCreateAlbum } from '$lib/components/album-page/albums-list.svelte';
|
||||
import {
|
||||
type AlbumGroupOptionMetadata,
|
||||
type AlbumSortOptionMetadata,
|
||||
findGroupOptionMetadata,
|
||||
findSortOptionMetadata,
|
||||
getSelectedAlbumGroupOption,
|
||||
groupOptionsMetadata,
|
||||
sortOptionsMetadata,
|
||||
} from '$lib/utils/album-utils';
|
||||
import SearchBar from '$lib/components/elements/search-bar.svelte';
|
||||
import GroupTab from '$lib/components/elements/group-tab.svelte';
|
||||
import { createAlbumAndRedirect, collapseAllAlbumGroups, expandAllAlbumGroups } from '$lib/utils/album-utils';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
export let searchAlbum: string;
|
||||
export let albumGroups: string[];
|
||||
export let searchQuery: string;
|
||||
|
||||
const searchSort = (searched: string): Sort => {
|
||||
return sortByOptions.find((option) => option.title === searched) || sortByOptions[0];
|
||||
const flipOrdering = (ordering: string) => {
|
||||
return ordering === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc;
|
||||
};
|
||||
|
||||
const handleChangeGroupBy = ({ id, defaultOrder }: AlbumGroupOptionMetadata) => {
|
||||
if ($albumViewSettings.groupBy === id) {
|
||||
$albumViewSettings.groupOrder = flipOrdering($albumViewSettings.groupOrder);
|
||||
} else {
|
||||
$albumViewSettings.groupBy = id;
|
||||
$albumViewSettings.groupOrder = defaultOrder;
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeSortBy = ({ id, defaultOrder }: AlbumSortOptionMetadata) => {
|
||||
if ($albumViewSettings.sortBy === id) {
|
||||
$albumViewSettings.sortOrder = flipOrdering($albumViewSettings.sortOrder);
|
||||
} else {
|
||||
$albumViewSettings.sortBy = id;
|
||||
$albumViewSettings.sortOrder = defaultOrder;
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeListMode = () => {
|
||||
$albumViewSettings.view =
|
||||
$albumViewSettings.view === AlbumViewMode.Cover ? AlbumViewMode.List : AlbumViewMode.Cover;
|
||||
};
|
||||
|
||||
let selectedGroupOption: AlbumGroupOptionMetadata;
|
||||
let groupIcon: string;
|
||||
|
||||
$: {
|
||||
selectedGroupOption = findGroupOptionMetadata($albumViewSettings.groupBy);
|
||||
if (selectedGroupOption.isDisabled()) {
|
||||
selectedGroupOption = findGroupOptionMetadata(AlbumGroupBy.None);
|
||||
}
|
||||
}
|
||||
|
||||
$: selectedSortOption = findSortOptionMetadata($albumViewSettings.sortBy);
|
||||
|
||||
$: {
|
||||
if (selectedGroupOption.id === AlbumGroupBy.None) {
|
||||
groupIcon = mdiFolderRemoveOutline;
|
||||
} else {
|
||||
groupIcon =
|
||||
$albumViewSettings.groupOrder === SortOrder.Desc ? mdiFolderArrowDownOutline : mdiFolderArrowUpOutline;
|
||||
}
|
||||
}
|
||||
|
||||
$: sortIcon = $albumViewSettings.sortOrder === SortOrder.Desc ? mdiArrowDownThin : mdiArrowUpThin;
|
||||
</script>
|
||||
|
||||
<!-- Filter Albums by Sharing Status (All, Owned, Shared) -->
|
||||
<div class="hidden xl:block h-10">
|
||||
<GroupTab
|
||||
filters={Object.keys(AlbumFilter)}
|
||||
@ -33,44 +97,78 @@
|
||||
onSelect={(selected) => ($albumViewSettings.filter = selected)}
|
||||
/>
|
||||
</div>
|
||||
<div class="hidden xl:block xl:w-60 2xl:w-80 h-10">
|
||||
<SearchBar placeholder="Search albums" bind:name={searchAlbum} isSearching={false} />
|
||||
|
||||
<!-- Search Albums -->
|
||||
<div class="hidden xl:block h-10 xl:w-60 2xl:w-80">
|
||||
<SearchBar placeholder="Search albums" bind:name={searchQuery} isSearching={false} />
|
||||
</div>
|
||||
<LinkButton on:click={handleCreateAlbum}>
|
||||
|
||||
<!-- Create Album -->
|
||||
<LinkButton on:click={() => createAlbumAndRedirect()}>
|
||||
<div class="flex place-items-center gap-2 text-sm">
|
||||
<Icon path={mdiPlusBoxOutline} size="18" />
|
||||
<p class="hidden md:block">Create album</p>
|
||||
</div>
|
||||
</LinkButton>
|
||||
|
||||
<!-- Sort Albums -->
|
||||
<Dropdown
|
||||
options={Object.values(sortByOptions)}
|
||||
selectedOption={searchSort($albumViewSettings.sortBy)}
|
||||
render={(option) => {
|
||||
return {
|
||||
title: option.title,
|
||||
icon: option.sortDesc ? mdiArrowDownThin : mdiArrowUpThin,
|
||||
};
|
||||
}}
|
||||
on:select={(event) => {
|
||||
for (const key of sortByOptions) {
|
||||
if (key.title === event.detail.title) {
|
||||
key.sortDesc = !key.sortDesc;
|
||||
$albumViewSettings.sortBy = key.title;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Sort albums by..."
|
||||
options={Object.values(sortOptionsMetadata)}
|
||||
selectedOption={selectedSortOption}
|
||||
on:select={({ detail }) => handleChangeSortBy(detail)}
|
||||
render={({ text }) => ({
|
||||
title: text,
|
||||
icon: sortIcon,
|
||||
})}
|
||||
/>
|
||||
|
||||
<!-- Group Albums -->
|
||||
<Dropdown
|
||||
title="Group albums by..."
|
||||
options={Object.values(groupOptionsMetadata)}
|
||||
selectedOption={selectedGroupOption}
|
||||
on:select={({ detail }) => handleChangeGroupBy(detail)}
|
||||
render={({ text, isDisabled }) => ({
|
||||
title: text,
|
||||
icon: groupIcon,
|
||||
disabled: isDisabled(),
|
||||
})}
|
||||
/>
|
||||
|
||||
{#if getSelectedAlbumGroupOption($albumViewSettings) !== AlbumGroupBy.None}
|
||||
<span in:fly={{ x: -50, duration: 250 }}>
|
||||
<!-- Expand Album Groups -->
|
||||
<div class="hidden xl:flex gap-0">
|
||||
<div class="block">
|
||||
<LinkButton title="Expand all" on:click={() => expandAllAlbumGroups()}>
|
||||
<div class="flex place-items-center gap-2 text-sm">
|
||||
<Icon path={mdiUnfoldMoreHorizontal} size="18" />
|
||||
</div>
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
<!-- Collapse Album Groups -->
|
||||
<div class="block">
|
||||
<LinkButton title="Collapse all" on:click={() => collapseAllAlbumGroups(albumGroups)}>
|
||||
<div class="flex place-items-center gap-2 text-sm">
|
||||
<Icon path={mdiUnfoldLessHorizontal} size="18" />
|
||||
</div>
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Cover/List Display Toggle -->
|
||||
<LinkButton on:click={() => handleChangeListMode()}>
|
||||
<div class="flex place-items-center gap-2 text-sm">
|
||||
{#if $albumViewSettings.view === AlbumViewMode.List}
|
||||
<Icon path={mdiViewGridOutline} size="18" />
|
||||
<p class="hidden sm:block">Cover</p>
|
||||
<p class="hidden md:block">Covers</p>
|
||||
{:else}
|
||||
<Icon path={mdiFormatListBulletedSquare} size="18" />
|
||||
<p class="hidden sm:block">List</p>
|
||||
<p class="hidden md:block">List</p>
|
||||
{/if}
|
||||
</div>
|
||||
</LinkButton>
|
||||
|
@ -1,206 +1,279 @@
|
||||
<script lang="ts" context="module">
|
||||
import { AlbumFilter, AlbumViewMode, albumViewSettings } from '$lib/stores/preferences.store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { createAlbum, deleteAlbum, type AlbumResponseDto } from '@immich/sdk';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export const handleCreateAlbum = async () => {
|
||||
try {
|
||||
const newAlbum = await createAlbum({ createAlbumDto: { albumName: '' } });
|
||||
|
||||
await goto(`${AppRoute.ALBUMS}/${newAlbum.id}`);
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to create album');
|
||||
}
|
||||
};
|
||||
|
||||
export interface Sort {
|
||||
title: string;
|
||||
sortDesc: boolean;
|
||||
widthClass: string;
|
||||
sortFn: (reverse: boolean, albums: AlbumResponseDto[]) => AlbumResponseDto[];
|
||||
}
|
||||
|
||||
export let sortByOptions: Sort[] = [
|
||||
{
|
||||
title: 'Album title',
|
||||
sortDesc: get(albumViewSettings).sortDesc, // Load Sort Direction
|
||||
widthClass: 'text-left w-8/12 sm:w-4/12 md:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%]',
|
||||
sortFn: (reverse, albums) => {
|
||||
return orderBy(albums, 'albumName', [reverse ? 'desc' : 'asc']);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Number of assets',
|
||||
sortDesc: get(albumViewSettings).sortDesc,
|
||||
widthClass: 'text-center w-4/12 m:w-2/12 md:w-2/12 xl:w-[15%] 2xl:w-[12%]',
|
||||
sortFn: (reverse, albums) => {
|
||||
return orderBy(albums, 'assetCount', [reverse ? 'desc' : 'asc']);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Last modified',
|
||||
sortDesc: get(albumViewSettings).sortDesc,
|
||||
widthClass: 'text-center hidden sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]',
|
||||
sortFn: (reverse, albums) => {
|
||||
return orderBy(albums, [(album) => new Date(album.updatedAt)], [reverse ? 'desc' : 'asc']);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Created date',
|
||||
sortDesc: get(albumViewSettings).sortDesc,
|
||||
widthClass: 'text-center hidden sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]',
|
||||
sortFn: (reverse, albums) => {
|
||||
return orderBy(albums, [(album) => new Date(album.createdAt)], [reverse ? 'desc' : 'asc']);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Most recent photo',
|
||||
sortDesc: get(albumViewSettings).sortDesc,
|
||||
widthClass: 'text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]',
|
||||
sortFn: (reverse, albums) => {
|
||||
return orderBy(
|
||||
albums,
|
||||
[(album) => (album.endDate ? new Date(album.endDate) : '')],
|
||||
[reverse ? 'desc' : 'asc'],
|
||||
).sort((a, b) => {
|
||||
if (a.endDate === undefined) {
|
||||
return 1;
|
||||
}
|
||||
if (b.endDate === undefined) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Oldest photo',
|
||||
sortDesc: get(albumViewSettings).sortDesc,
|
||||
widthClass: 'text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]',
|
||||
sortFn: (reverse, albums) => {
|
||||
return orderBy(
|
||||
albums,
|
||||
[(album) => (album.startDate ? new Date(album.startDate) : null)],
|
||||
[reverse ? 'desc' : 'asc'],
|
||||
).sort((a, b) => {
|
||||
if (a.startDate === undefined) {
|
||||
return 1;
|
||||
}
|
||||
if (b.startDate === undefined) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import AlbumCard from '$lib/components/album-page/album-card.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { groupBy, orderBy } from 'lodash-es';
|
||||
import { addUsersToAlbum, deleteAlbum, type UserResponseDto, type AlbumResponseDto } from '@immich/sdk';
|
||||
import { mdiDeleteOutline, mdiShareVariantOutline, mdiFolderDownloadOutline, mdiRenameOutline } from '@mdi/js';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import EditAlbumForm from '$lib/components/forms/edit-album-form.svelte';
|
||||
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
|
||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||
import {
|
||||
NotificationType,
|
||||
notificationController,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { mdiDeleteOutline } from '@mdi/js';
|
||||
import { orderBy } from 'lodash-es';
|
||||
import { onMount } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
|
||||
import RightClickContextMenu from '$lib/components/shared-components/context-menu/right-click-context-menu.svelte';
|
||||
import AlbumsTable from '$lib/components/album-page/albums-table.svelte';
|
||||
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
|
||||
import UserSelectionModal from '$lib/components/album-page/user-selection-modal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { downloadAlbum } from '$lib/utils/asset-utils';
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils';
|
||||
import { getSelectedAlbumGroupOption, type AlbumGroup } from '$lib/utils/album-utils';
|
||||
import type { ContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import GroupTab from '$lib/components/elements/group-tab.svelte';
|
||||
import SearchBar from '$lib/components/elements/search-bar.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import {
|
||||
AlbumGroupBy,
|
||||
AlbumSortBy,
|
||||
AlbumFilter,
|
||||
AlbumViewMode,
|
||||
SortOrder,
|
||||
locale,
|
||||
type AlbumViewSettings,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
|
||||
export let ownedAlbums: AlbumResponseDto[];
|
||||
export let sharedAlbums: AlbumResponseDto[];
|
||||
export let searchAlbum: string;
|
||||
export let ownedAlbums: AlbumResponseDto[] = [];
|
||||
export let sharedAlbums: AlbumResponseDto[] = [];
|
||||
export let searchQuery: string = '';
|
||||
export let userSettings: AlbumViewSettings;
|
||||
export let allowEdit = false;
|
||||
export let showOwner = false;
|
||||
export let albumGroupIds: string[] = [];
|
||||
|
||||
interface AlbumGroupOption {
|
||||
[option: string]: (order: SortOrder, albums: AlbumResponseDto[]) => AlbumGroup[];
|
||||
}
|
||||
|
||||
interface AlbumSortOption {
|
||||
[option: string]: (order: SortOrder, albums: AlbumResponseDto[]) => AlbumResponseDto[];
|
||||
}
|
||||
|
||||
const groupOptions: AlbumGroupOption = {
|
||||
/** No grouping */
|
||||
[AlbumGroupBy.None]: (order, albums): AlbumGroup[] => {
|
||||
return [
|
||||
{
|
||||
id: 'Albums',
|
||||
name: 'Albums',
|
||||
albums,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
/** Group by year */
|
||||
[AlbumGroupBy.Year]: (order, albums): AlbumGroup[] => {
|
||||
const unknownYear = 'Unknown Year';
|
||||
const useStartDate = userSettings.sortBy === AlbumSortBy.OldestPhoto;
|
||||
|
||||
const groupedByYear = groupBy(albums, (album) => {
|
||||
const date = useStartDate ? album.startDate : album.endDate;
|
||||
return date ? new Date(date).getFullYear() : unknownYear;
|
||||
});
|
||||
|
||||
const sortSign = order === SortOrder.Desc ? -1 : 1;
|
||||
const sortedByYear = Object.entries(groupedByYear).sort(([a], [b]) => {
|
||||
// We make sure empty albums stay at the end of the list
|
||||
if (a === unknownYear) {
|
||||
return 1;
|
||||
} else if (b === unknownYear) {
|
||||
return -1;
|
||||
} else {
|
||||
return (Number.parseInt(a) - Number.parseInt(b)) * sortSign;
|
||||
}
|
||||
});
|
||||
|
||||
return sortedByYear.map(([year, albums]) => ({
|
||||
id: year,
|
||||
name: year,
|
||||
albums,
|
||||
}));
|
||||
},
|
||||
|
||||
/** Group by owner */
|
||||
[AlbumGroupBy.Owner]: (order, albums): AlbumGroup[] => {
|
||||
const currentUserId = $user.id;
|
||||
const groupedByOwnerIds = groupBy(albums, 'ownerId');
|
||||
|
||||
const sortSign = order === SortOrder.Desc ? -1 : 1;
|
||||
const sortedByOwnerNames = Object.entries(groupedByOwnerIds).sort(([ownerA, albumsA], [ownerB, albumsB]) => {
|
||||
// We make sure owned albums stay either at the beginning or the end
|
||||
// of the list
|
||||
if (ownerA === currentUserId) {
|
||||
return -sortSign;
|
||||
} else if (ownerB === currentUserId) {
|
||||
return sortSign;
|
||||
} else {
|
||||
return albumsA[0].owner.name.localeCompare(albumsB[0].owner.name, $locale) * sortSign;
|
||||
}
|
||||
});
|
||||
|
||||
return sortedByOwnerNames.map(([ownerId, albums]) => ({
|
||||
id: ownerId,
|
||||
name: ownerId === currentUserId ? 'My albums' : albums[0].owner.name,
|
||||
albums,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const sortOptions: AlbumSortOption = {
|
||||
/** Sort by album title */
|
||||
[AlbumSortBy.Title]: (order, albums) => {
|
||||
const sortSign = order === SortOrder.Desc ? -1 : 1;
|
||||
return albums.slice().sort((a, b) => a.albumName.localeCompare(b.albumName, $locale) * sortSign);
|
||||
},
|
||||
|
||||
/** Sort by asset count */
|
||||
[AlbumSortBy.ItemCount]: (order, albums) => {
|
||||
return orderBy(albums, 'assetCount', [order]);
|
||||
},
|
||||
|
||||
/** Sort by last modified */
|
||||
[AlbumSortBy.DateModified]: (order, albums) => {
|
||||
return orderBy(albums, [({ updatedAt }) => new Date(updatedAt)], [order]);
|
||||
},
|
||||
|
||||
/** Sort by creation date */
|
||||
[AlbumSortBy.DateCreated]: (order, albums) => {
|
||||
return orderBy(albums, [({ createdAt }) => new Date(createdAt)], [order]);
|
||||
},
|
||||
|
||||
/** Sort by the most recent photo date */
|
||||
[AlbumSortBy.MostRecentPhoto]: (order, albums) => {
|
||||
albums = orderBy(albums, [({ endDate }) => (endDate ? new Date(endDate) : '')], [order]);
|
||||
return albums.sort(sortUnknownYearAlbums);
|
||||
},
|
||||
|
||||
/** Sort by the oldest photo date */
|
||||
[AlbumSortBy.OldestPhoto]: (order, albums) => {
|
||||
albums = orderBy(albums, [({ startDate }) => (startDate ? new Date(startDate) : '')], [order]);
|
||||
return albums.sort(sortUnknownYearAlbums);
|
||||
},
|
||||
};
|
||||
|
||||
let albums: AlbumResponseDto[] = [];
|
||||
let shouldShowEditAlbumForm = false;
|
||||
let selectedAlbum: AlbumResponseDto;
|
||||
let albumToDelete: AlbumResponseDto | null;
|
||||
let filteredAlbums: AlbumResponseDto[] = [];
|
||||
let groupedAlbums: AlbumGroup[] = [];
|
||||
|
||||
let albumGroupOption: string = AlbumGroupBy.None;
|
||||
|
||||
let showShareByURLModal = false;
|
||||
|
||||
let albumToEdit: AlbumResponseDto | null = null;
|
||||
let albumToShare: AlbumResponseDto | null = null;
|
||||
let albumToDelete: AlbumResponseDto | null = null;
|
||||
|
||||
let contextMenuPosition: ContextMenuPosition = { x: 0, y: 0 };
|
||||
let contextMenuTargetAlbum: AlbumResponseDto | undefined = undefined;
|
||||
let contextMenuTargetAlbum: AlbumResponseDto | null = null;
|
||||
|
||||
// Step 1: Filter between Owned and Shared albums, or both.
|
||||
$: {
|
||||
for (const key of sortByOptions) {
|
||||
if (key.title === $albumViewSettings.sortBy) {
|
||||
switch ($albumViewSettings.filter) {
|
||||
case AlbumFilter.All: {
|
||||
albums = key.sortFn(
|
||||
key.sortDesc,
|
||||
[...sharedAlbums, ...ownedAlbums].filter(
|
||||
(album, index, self) => index === self.findIndex((item) => album.id === item.id),
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case AlbumFilter.Owned: {
|
||||
albums = key.sortFn(key.sortDesc, ownedAlbums);
|
||||
break;
|
||||
}
|
||||
|
||||
case AlbumFilter.Shared: {
|
||||
albums = key.sortFn(key.sortDesc, sharedAlbums);
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
albums = key.sortFn(
|
||||
key.sortDesc,
|
||||
[...sharedAlbums, ...ownedAlbums].filter(
|
||||
(album, index, self) => index === self.findIndex((item) => album.id === item.id),
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$albumViewSettings.sortDesc = key.sortDesc;
|
||||
$albumViewSettings.sortBy = key.title;
|
||||
switch (userSettings.filter) {
|
||||
case AlbumFilter.Owned: {
|
||||
albums = ownedAlbums;
|
||||
break;
|
||||
}
|
||||
case AlbumFilter.Shared: {
|
||||
albums = sharedAlbums;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const userId = $user.id;
|
||||
const nonOwnedAlbums = sharedAlbums.filter((album) => album.ownerId !== userId);
|
||||
albums = nonOwnedAlbums.length > 0 ? ownedAlbums.concat(nonOwnedAlbums) : ownedAlbums;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: isShowContextMenu = !!contextMenuTargetAlbum;
|
||||
$: albumsFiltered = albums.filter((album) => album.albumName.toLowerCase().includes(searchAlbum.toLowerCase()));
|
||||
// Step 2: Filter using the given search query.
|
||||
$: {
|
||||
if (searchQuery) {
|
||||
const searchAlbumNormalized = normalizeSearchString(searchQuery);
|
||||
|
||||
filteredAlbums = albums.filter((album) => {
|
||||
return normalizeSearchString(album.albumName).includes(searchAlbumNormalized);
|
||||
});
|
||||
} else {
|
||||
filteredAlbums = albums;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Group albums.
|
||||
$: {
|
||||
albumGroupOption = getSelectedAlbumGroupOption(userSettings);
|
||||
const groupFunc = groupOptions[albumGroupOption] ?? groupOptions[AlbumGroupBy.None];
|
||||
groupedAlbums = groupFunc(stringToSortOrder(userSettings.groupOrder), filteredAlbums);
|
||||
}
|
||||
|
||||
// Step 4: Sort albums amongst each group.
|
||||
$: {
|
||||
const defaultSortOption = AlbumSortBy.DateModified;
|
||||
const selectedSortOption = userSettings.sortBy ?? defaultSortOption;
|
||||
|
||||
const sortFunc = sortOptions[selectedSortOption] ?? sortOptions[defaultSortOption];
|
||||
const sortOrder = stringToSortOrder(userSettings.sortOrder);
|
||||
|
||||
groupedAlbums = groupedAlbums.map((group) => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
albums: sortFunc(sortOrder, group.albums),
|
||||
}));
|
||||
|
||||
albumGroupIds = groupedAlbums.map(({ id }) => id);
|
||||
}
|
||||
|
||||
$: showContextMenu = !!contextMenuTargetAlbum;
|
||||
$: showFullContextMenu = allowEdit && contextMenuTargetAlbum && contextMenuTargetAlbum.ownerId === $user.id;
|
||||
|
||||
onMount(async () => {
|
||||
await removeAlbumsIfEmpty();
|
||||
if (allowEdit) {
|
||||
await removeAlbumsIfEmpty();
|
||||
}
|
||||
});
|
||||
|
||||
function showAlbumContextMenu(contextMenuDetail: ContextMenuPosition, album: AlbumResponseDto): void {
|
||||
const sortUnknownYearAlbums = (a: AlbumResponseDto, b: AlbumResponseDto) => {
|
||||
if (!a.endDate) {
|
||||
return 1;
|
||||
}
|
||||
if (!b.endDate) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const stringToSortOrder = (order: string) => {
|
||||
return order === 'desc' ? SortOrder.Desc : SortOrder.Asc;
|
||||
};
|
||||
|
||||
const showAlbumContextMenu = (contextMenuDetail: ContextMenuPosition, album: AlbumResponseDto) => {
|
||||
contextMenuTargetAlbum = album;
|
||||
contextMenuPosition = {
|
||||
x: contextMenuDetail.x,
|
||||
y: contextMenuDetail.y,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function closeAlbumContextMenu() {
|
||||
contextMenuTargetAlbum = undefined;
|
||||
}
|
||||
const closeAlbumContextMenu = () => {
|
||||
contextMenuTargetAlbum = null;
|
||||
};
|
||||
|
||||
const handleDownloadAlbum = async () => {
|
||||
if (contextMenuTargetAlbum) {
|
||||
const album = contextMenuTargetAlbum;
|
||||
closeAlbumContextMenu();
|
||||
await downloadAlbum(album);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAlbum = async (albumToDelete: AlbumResponseDto) => {
|
||||
await deleteAlbum({
|
||||
id: albumToDelete.id,
|
||||
});
|
||||
|
||||
async function handleDeleteAlbum(albumToDelete: AlbumResponseDto): Promise<void> {
|
||||
await deleteAlbum({ id: albumToDelete.id });
|
||||
ownedAlbums = ownedAlbums.filter(({ id }) => id !== albumToDelete.id);
|
||||
}
|
||||
|
||||
const chooseAlbumToDelete = (album: AlbumResponseDto) => {
|
||||
contextMenuTargetAlbum = album;
|
||||
setAlbumToDelete();
|
||||
sharedAlbums = sharedAlbums.filter(({ id }) => id !== albumToDelete.id);
|
||||
};
|
||||
|
||||
const setAlbumToDelete = () => {
|
||||
@ -209,8 +282,8 @@
|
||||
};
|
||||
|
||||
const handleEdit = (album: AlbumResponseDto) => {
|
||||
selectedAlbum = { ...album };
|
||||
shouldShowEditAlbumForm = true;
|
||||
albumToEdit = album;
|
||||
closeAlbumContextMenu();
|
||||
};
|
||||
|
||||
const deleteSelectedAlbum = async () => {
|
||||
@ -230,101 +303,170 @@
|
||||
};
|
||||
|
||||
const removeAlbumsIfEmpty = async () => {
|
||||
for (const album of ownedAlbums) {
|
||||
if (album.assetCount == 0 && album.albumName == '') {
|
||||
try {
|
||||
await handleDeleteAlbum(album);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
const albumsToRemove = ownedAlbums.filter((album) => album.assetCount === 0 && !album.albumName);
|
||||
await Promise.allSettled(albumsToRemove.map((album) => handleDeleteAlbum(album)));
|
||||
};
|
||||
|
||||
const updateAlbumInfo = (album: AlbumResponseDto) => {
|
||||
ownedAlbums[ownedAlbums.findIndex(({ id }) => id === album.id)] = album;
|
||||
sharedAlbums[sharedAlbums.findIndex(({ id }) => id === album.id)] = album;
|
||||
};
|
||||
|
||||
const successEditAlbumInfo = (album: AlbumResponseDto) => {
|
||||
albumToEdit = null;
|
||||
|
||||
notificationController.show({
|
||||
message: 'Album info updated',
|
||||
type: NotificationType.Info,
|
||||
button: {
|
||||
text: 'View Album',
|
||||
onClick() {
|
||||
return goto(`${AppRoute.ALBUMS}/${album.id}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
updateAlbumInfo(album);
|
||||
};
|
||||
|
||||
const handleAddUsers = async (users: UserResponseDto[]) => {
|
||||
if (!albumToShare) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const album = await addUsersToAlbum({
|
||||
id: albumToShare.id,
|
||||
addUsersDto: {
|
||||
sharedUserIds: [...users].map(({ id }) => id),
|
||||
},
|
||||
});
|
||||
updateAlbumInfo(album);
|
||||
} catch (error) {
|
||||
handleError(error, 'Error adding users to album');
|
||||
} finally {
|
||||
albumToShare = null;
|
||||
}
|
||||
};
|
||||
|
||||
const successModifyAlbum = () => {
|
||||
shouldShowEditAlbumForm = false;
|
||||
notificationController.show({
|
||||
message: 'Album infos updated',
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
ownedAlbums[ownedAlbums.findIndex((x) => x.id === selectedAlbum.id)] = selectedAlbum;
|
||||
const handleSharedLinkCreated = (album: AlbumResponseDto) => {
|
||||
album.shared = true;
|
||||
album.hasSharedLink = true;
|
||||
updateAlbumInfo(album);
|
||||
};
|
||||
|
||||
const openShareModal = () => {
|
||||
albumToShare = contextMenuTargetAlbum;
|
||||
closeAlbumContextMenu();
|
||||
};
|
||||
|
||||
const closeShareModal = () => {
|
||||
albumToShare = null;
|
||||
showShareByURLModal = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if shouldShowEditAlbumForm}
|
||||
<FullScreenModal onClose={() => (shouldShowEditAlbumForm = false)}>
|
||||
<EditAlbumForm
|
||||
album={selectedAlbum}
|
||||
on:editSuccess={() => successModifyAlbum()}
|
||||
on:cancel={() => (shouldShowEditAlbumForm = false)}
|
||||
/>
|
||||
</FullScreenModal>
|
||||
{/if}
|
||||
|
||||
{#if albums.length > 0}
|
||||
<!-- Album Card -->
|
||||
<div class="xl:hidden">
|
||||
<div class="w-fit h-14 dark:text-immich-dark-fg py-2">
|
||||
<GroupTab
|
||||
filters={Object.keys(AlbumFilter)}
|
||||
selected={$albumViewSettings.filter}
|
||||
onSelect={(selected) => ($albumViewSettings.filter = selected)}
|
||||
{#if userSettings.view === AlbumViewMode.Cover}
|
||||
<!-- Album Cards -->
|
||||
{#if albumGroupOption === AlbumGroupBy.None}
|
||||
<AlbumCardGroup
|
||||
albums={groupedAlbums[0].albums}
|
||||
{showOwner}
|
||||
showDateRange
|
||||
showItemCount
|
||||
onShowContextMenu={showAlbumContextMenu}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-60">
|
||||
<SearchBar placeholder="Search albums" bind:name={searchAlbum} isSearching={false} />
|
||||
</div>
|
||||
</div>
|
||||
{#if $albumViewSettings.view === AlbumViewMode.Cover}
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] mt-4 gap-y-4">
|
||||
{#each albumsFiltered as album, index (album.id)}
|
||||
<a data-sveltekit-preload-data="hover" href="{AppRoute.ALBUMS}/{album.id}" animate:flip={{ duration: 200 }}>
|
||||
<AlbumCard
|
||||
preload={index < 20}
|
||||
{album}
|
||||
onShowContextMenu={(position) => showAlbumContextMenu(position, album)}
|
||||
/>
|
||||
</a>
|
||||
{:else}
|
||||
{#each groupedAlbums as albumGroup (albumGroup.id)}
|
||||
<AlbumCardGroup
|
||||
albums={albumGroup.albums}
|
||||
group={albumGroup}
|
||||
{showOwner}
|
||||
showDateRange
|
||||
showItemCount
|
||||
onShowContextMenu={showAlbumContextMenu}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if $albumViewSettings.view === AlbumViewMode.List}
|
||||
<AlbumsTable
|
||||
{sortByOptions}
|
||||
{albumsFiltered}
|
||||
onChooseAlbumToDelete={(album) => chooseAlbumToDelete(album)}
|
||||
onAlbumToEdit={(album) => handleEdit(album)}
|
||||
/>
|
||||
{/if}
|
||||
{:else if userSettings.view === AlbumViewMode.List}
|
||||
<!-- Album Table -->
|
||||
<AlbumsTable {groupedAlbums} {albumGroupOption} onShowContextMenu={showAlbumContextMenu} />
|
||||
{/if}
|
||||
|
||||
<!-- Empty Message -->
|
||||
{:else}
|
||||
<EmptyPlaceholder text="Create an album to organize your photos and videos" onClick={handleCreateAlbum} />
|
||||
<!-- Empty Message -->
|
||||
<slot name="empty" />
|
||||
{/if}
|
||||
|
||||
<!-- Context Menu -->
|
||||
{#if isShowContextMenu}
|
||||
<section class="fixed left-0 top-0 z-10 flex h-screen w-screen">
|
||||
<ContextMenu {...contextMenuPosition} on:outclick={closeAlbumContextMenu} on:escape={closeAlbumContextMenu}>
|
||||
<MenuOption on:click={() => setAlbumToDelete()}>
|
||||
<span class="flex place-content-center place-items-center gap-2">
|
||||
<Icon path={mdiDeleteOutline} size="18" />
|
||||
<p>Delete album</p>
|
||||
</span>
|
||||
</MenuOption>
|
||||
</ContextMenu>
|
||||
</section>
|
||||
{/if}
|
||||
<RightClickContextMenu {...contextMenuPosition} isOpen={showContextMenu} onClose={closeAlbumContextMenu}>
|
||||
{#if showFullContextMenu}
|
||||
<MenuOption on:click={() => contextMenuTargetAlbum && handleEdit(contextMenuTargetAlbum)}>
|
||||
<p class="flex gap-2">
|
||||
<Icon path={mdiRenameOutline} size="18" />
|
||||
Edit
|
||||
</p>
|
||||
</MenuOption>
|
||||
<MenuOption on:click={() => openShareModal()}>
|
||||
<p class="flex gap-2">
|
||||
<Icon path={mdiShareVariantOutline} size="18" />
|
||||
Share
|
||||
</p>
|
||||
</MenuOption>
|
||||
{/if}
|
||||
<MenuOption on:click={() => handleDownloadAlbum()}>
|
||||
<p class="flex gap-2">
|
||||
<Icon path={mdiFolderDownloadOutline} size="18" />
|
||||
Download
|
||||
</p>
|
||||
</MenuOption>
|
||||
{#if showFullContextMenu}
|
||||
<MenuOption on:click={() => setAlbumToDelete()}>
|
||||
<p class="flex gap-2">
|
||||
<Icon path={mdiDeleteOutline} size="18" />
|
||||
Delete
|
||||
</p>
|
||||
</MenuOption>
|
||||
{/if}
|
||||
</RightClickContextMenu>
|
||||
|
||||
{#if albumToDelete}
|
||||
<ConfirmDialogue
|
||||
title="Delete Album"
|
||||
confirmText="Delete"
|
||||
onConfirm={deleteSelectedAlbum}
|
||||
onClose={() => (albumToDelete = null)}
|
||||
>
|
||||
<svelte:fragment slot="prompt">
|
||||
<p>Are you sure you want to delete the album <b>{albumToDelete.albumName}</b>?</p>
|
||||
<p>If this album is shared, other users will not be able to access it anymore.</p>
|
||||
</svelte:fragment>
|
||||
</ConfirmDialogue>
|
||||
{#if allowEdit}
|
||||
<!-- Edit Modal -->
|
||||
{#if albumToEdit}
|
||||
<FullScreenModal onClose={() => (albumToEdit = null)}>
|
||||
<EditAlbumForm album={albumToEdit} onEditSuccess={successEditAlbumInfo} onCancel={() => (albumToEdit = null)} />
|
||||
</FullScreenModal>
|
||||
{/if}
|
||||
|
||||
<!-- Share Modal -->
|
||||
{#if albumToShare}
|
||||
{#if showShareByURLModal}
|
||||
<CreateSharedLinkModal
|
||||
albumId={albumToShare.id}
|
||||
on:close={() => closeShareModal()}
|
||||
on:created={() => albumToShare && handleSharedLinkCreated(albumToShare)}
|
||||
/>
|
||||
{:else}
|
||||
<UserSelectionModal
|
||||
album={albumToShare}
|
||||
on:select={({ detail: users }) => handleAddUsers(users)}
|
||||
on:share={() => (showShareByURLModal = true)}
|
||||
on:close={() => closeShareModal()}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Delete Modal -->
|
||||
{#if albumToDelete}
|
||||
<ConfirmDialogue
|
||||
title="Delete Album"
|
||||
confirmText="Delete"
|
||||
onConfirm={deleteSelectedAlbum}
|
||||
onClose={() => (albumToDelete = null)}
|
||||
>
|
||||
<svelte:fragment slot="prompt">
|
||||
<p>Are you sure you want to delete the album <b>{albumToDelete.albumName}</b>?</p>
|
||||
<p>If this album is shared, other users will not be able to access it anymore.</p>
|
||||
</svelte:fragment>
|
||||
</ConfirmDialogue>
|
||||
{/if}
|
||||
{/if}
|
||||
|
@ -1,30 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { albumViewSettings } from '$lib/stores/preferences.store';
|
||||
import type { Sort } from '$lib/components/album-page/albums-list.svelte';
|
||||
import { albumViewSettings, SortOrder } from '$lib/stores/preferences.store';
|
||||
import type { AlbumSortOptionMetadata } from '$lib/utils/album-utils';
|
||||
|
||||
export let option: Sort;
|
||||
export let option: AlbumSortOptionMetadata;
|
||||
|
||||
const handleSort = () => {
|
||||
if ($albumViewSettings.sortBy === option.title) {
|
||||
$albumViewSettings.sortDesc = !option.sortDesc;
|
||||
option.sortDesc = !option.sortDesc;
|
||||
if ($albumViewSettings.sortBy === option.id) {
|
||||
$albumViewSettings.sortOrder = $albumViewSettings.sortOrder === SortOrder.Asc ? SortOrder.Desc : SortOrder.Asc;
|
||||
} else {
|
||||
$albumViewSettings.sortBy = option.title;
|
||||
$albumViewSettings.sortBy = option.id;
|
||||
$albumViewSettings.sortOrder = option.defaultOrder;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<th class="{option.widthClass} text-sm font-medium"
|
||||
><button
|
||||
<th class="text-sm font-medium {option.columnStyle}">
|
||||
<button
|
||||
class="rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
|
||||
on:click={() => handleSort()}
|
||||
on:click={handleSort}
|
||||
>
|
||||
{#if $albumViewSettings.sortBy === option.title}
|
||||
{#if option.sortDesc}
|
||||
{#if $albumViewSettings.sortBy === option.id}
|
||||
{#if $albumViewSettings.sortOrder === SortOrder.Desc}
|
||||
↓
|
||||
{:else}
|
||||
↑
|
||||
{/if}
|
||||
{/if}{option.title}</button
|
||||
></th
|
||||
>
|
||||
{/if}
|
||||
{option.text}
|
||||
</button>
|
||||
</th>
|
||||
|
64
web/src/lib/components/album-page/albums-table-row.svelte
Normal file
64
web/src/lib/components/album-page/albums-table-row.svelte
Normal file
@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute, dateFormats } from '$lib/constants';
|
||||
import type { AlbumResponseDto } from '@immich/sdk';
|
||||
import type { ContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { mdiShareVariantOutline } from '@mdi/js';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
|
||||
export let album: AlbumResponseDto;
|
||||
export let onShowContextMenu: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined =
|
||||
undefined;
|
||||
|
||||
const showContextMenu = (position: ContextMenuPosition) => {
|
||||
onShowContextMenu?.(position, album);
|
||||
};
|
||||
|
||||
const dateLocaleString = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString($locale, dateFormats.album);
|
||||
};
|
||||
</script>
|
||||
|
||||
<tr
|
||||
class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-2 text-center odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5"
|
||||
on:click={() => goto(`${AppRoute.ALBUMS}/${album.id}`)}
|
||||
on:contextmenu|preventDefault={(e) => showContextMenu({ x: e.x, y: e.y })}
|
||||
>
|
||||
<td class="text-md text-ellipsis text-left w-8/12 sm:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%] items-center">
|
||||
{album.albumName}
|
||||
{#if album.shared}
|
||||
<Icon
|
||||
path={mdiShareVariantOutline}
|
||||
size="16"
|
||||
class="inline ml-1 opacity-70"
|
||||
title={album.ownerId === $user.id ? 'Shared by you' : `Shared by ${album.owner.name}`}
|
||||
/>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-md text-ellipsis text-center sm:w-2/12 md:w-2/12 xl:w-[15%] 2xl:w-[12%]">
|
||||
{album.assetCount}
|
||||
{album.assetCount > 1 ? `items` : `item`}
|
||||
</td>
|
||||
<td class="text-md hidden text-ellipsis text-center sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]">
|
||||
{dateLocaleString(album.updatedAt)}
|
||||
</td>
|
||||
<td class="text-md hidden text-ellipsis text-center sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]">
|
||||
{dateLocaleString(album.createdAt)}
|
||||
</td>
|
||||
<td class="text-md text-ellipsis text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]">
|
||||
{#if album.endDate}
|
||||
{dateLocaleString(album.endDate)}
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-md text-ellipsis text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]">
|
||||
{#if album.startDate}
|
||||
{dateLocaleString(album.startDate)}
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
@ -1,23 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { slide } from 'svelte/transition';
|
||||
import type { AlbumResponseDto } from '@immich/sdk';
|
||||
import TableHeader from '$lib/components/album-page/albums-table-header.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { AlbumGroupBy, albumViewSettings } from '$lib/stores/preferences.store';
|
||||
import type { ContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import { mdiChevronRight } from '@mdi/js';
|
||||
import AlbumTableHeader from '$lib/components/album-page/albums-table-header.svelte';
|
||||
import AlbumTableRow from '$lib/components/album-page/albums-table-row.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
|
||||
import type { Sort } from '$lib/components/album-page/albums-list.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { dateFormats } from '$lib/constants';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import {
|
||||
isAlbumGroupCollapsed,
|
||||
toggleAlbumGroupCollapsing,
|
||||
sortOptionsMetadata,
|
||||
type AlbumGroup,
|
||||
} from '$lib/utils/album-utils';
|
||||
|
||||
export let albumsFiltered: AlbumResponseDto[];
|
||||
export let sortByOptions: Sort[];
|
||||
export let onChooseAlbumToDelete: (album: AlbumResponseDto) => void;
|
||||
export let onAlbumToEdit: (album: AlbumResponseDto) => void;
|
||||
|
||||
const dateLocaleString = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString($locale, dateFormats.album);
|
||||
};
|
||||
export let groupedAlbums: AlbumGroup[];
|
||||
export let albumGroupOption: string = AlbumGroupBy.None;
|
||||
export let onShowContextMenu: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined =
|
||||
undefined;
|
||||
</script>
|
||||
|
||||
<table class="mt-2 w-full text-left">
|
||||
@ -25,64 +25,49 @@
|
||||
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
|
||||
>
|
||||
<tr class="flex w-full place-items-center p-2 md:p-5">
|
||||
{#each sortByOptions as option, index (index)}
|
||||
<TableHeader {option} />
|
||||
{#each sortOptionsMetadata as option, index (index)}
|
||||
<AlbumTableHeader {option} />
|
||||
{/each}
|
||||
<th class="hidden text-center text-sm font-medium 2xl:block 2xl:w-[12%]">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg">
|
||||
{#each albumsFiltered as album (album.id)}
|
||||
<tr
|
||||
class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-2 text-center odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5"
|
||||
on:click={() => goto(`${AppRoute.ALBUMS}/${album.id}`)}
|
||||
{#if albumGroupOption === AlbumGroupBy.None}
|
||||
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg">
|
||||
{#each groupedAlbums[0].albums as album (album.id)}
|
||||
<AlbumTableRow {album} {onShowContextMenu} />
|
||||
{/each}
|
||||
</tbody>
|
||||
{:else}
|
||||
{#each groupedAlbums as albumGroup (albumGroup.id)}
|
||||
{@const isCollapsed = isAlbumGroupCollapsed($albumViewSettings, albumGroup.id)}
|
||||
{@const iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90'}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<tbody
|
||||
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg mt-4 hover:cursor-pointer"
|
||||
on:click={() => toggleAlbumGroupCollapsing(albumGroup.id)}
|
||||
>
|
||||
<a data-sveltekit-preload-data="hover" class="flex w-full" href="{AppRoute.ALBUMS}/{album.id}">
|
||||
<td class="text-md text-ellipsis text-left w-8/12 sm:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%]"
|
||||
>{album.albumName}</td
|
||||
>
|
||||
<td class="text-md text-ellipsis text-center sm:w-2/12 md:w-2/12 xl:w-[15%] 2xl:w-[12%]">
|
||||
{album.assetCount}
|
||||
{album.assetCount > 1 ? `items` : `item`}
|
||||
<tr class="flex w-full place-items-center p-2 md:pl-5 md:pr-5 md:pt-3 md:pb-3">
|
||||
<td class="text-md text-left -mb-1">
|
||||
<Icon
|
||||
path={mdiChevronRight}
|
||||
size="20"
|
||||
class="inline-block -mt-2 transition-all duration-[250ms] {iconRotation}"
|
||||
/>
|
||||
<span class="font-bold text-2xl">{albumGroup.name}</span>
|
||||
<span class="ml-1.5">({albumGroup.albums.length} {albumGroup.albums.length > 1 ? 'albums' : 'album'})</span>
|
||||
</td>
|
||||
<td class="text-md hidden text-ellipsis text-center sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]"
|
||||
>{dateLocaleString(album.updatedAt)}
|
||||
</td>
|
||||
<td class="text-md hidden text-ellipsis text-center sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]"
|
||||
>{dateLocaleString(album.createdAt)}</td
|
||||
>
|
||||
<td class="text-md text-ellipsis text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]">
|
||||
{#if album.endDate}
|
||||
{dateLocaleString(album.endDate)}
|
||||
{:else}
|
||||
❌
|
||||
{/if}</td
|
||||
>
|
||||
<td class="text-md text-ellipsis text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]"
|
||||
>{#if album.startDate}
|
||||
{dateLocaleString(album.startDate)}
|
||||
{:else}
|
||||
❌
|
||||
{/if}</td
|
||||
>
|
||||
</a>
|
||||
<td class="text-md hidden text-ellipsis text-center 2xl:block xl:w-[15%] 2xl:w-[12%]">
|
||||
{#if $user.id === album.ownerId}
|
||||
<button
|
||||
on:click|stopPropagation={() => onAlbumToEdit(album)}
|
||||
class="rounded-full z-1 bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
|
||||
>
|
||||
<Icon path={mdiPencilOutline} size="16" />
|
||||
</button>
|
||||
<button
|
||||
on:click|stopPropagation={() => onChooseAlbumToDelete(album)}
|
||||
class="rounded-full z-1 bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
|
||||
>
|
||||
<Icon path={mdiTrashCanOutline} size="16" />
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
</tr>
|
||||
</tbody>
|
||||
{#if !isCollapsed}
|
||||
<tbody
|
||||
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg mt-4"
|
||||
transition:slide={{ duration: 300 }}
|
||||
>
|
||||
{#each albumGroup.albums as album (album.id)}
|
||||
<AlbumTableRow {album} {onShowContextMenu} />
|
||||
{/each}
|
||||
</tbody>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
{/if}
|
||||
</table>
|
||||
|
@ -29,6 +29,7 @@
|
||||
runAssetJobs,
|
||||
updateAsset,
|
||||
updateAssets,
|
||||
updateAlbumInfo,
|
||||
type ActivityResponseDto,
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
@ -496,6 +497,27 @@
|
||||
handleError(error, `Unable to unstack`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateThumbnail = async () => {
|
||||
if (!album) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAlbumInfo({
|
||||
id: album.id,
|
||||
updateAlbumDto: {
|
||||
albumThumbnailAssetId: asset.id,
|
||||
},
|
||||
});
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: 'Album cover updated',
|
||||
timeout: 1500,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to update album cover');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
@ -524,6 +546,7 @@
|
||||
<div class="z-[1002] col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
|
||||
<AssetViewerNavBar
|
||||
{asset}
|
||||
{album}
|
||||
isMotionPhotoPlaying={shouldPlayMotionPhoto}
|
||||
showCopyButton={canCopyImagesToClipboard && asset.type === AssetTypeEnum.Image}
|
||||
showZoomButton={asset.type === AssetTypeEnum.Image}
|
||||
@ -544,6 +567,7 @@
|
||||
on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
|
||||
on:toggleArchive={toggleArchive}
|
||||
on:asProfileImage={() => (isShowProfileImageCrop = true)}
|
||||
on:setAsAlbumCover={handleUpdateThumbnail}
|
||||
on:runJob={({ detail: job }) => handleRunJob(job)}
|
||||
on:playSlideShow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
|
||||
on:unstack={handleUnstack}
|
||||
|
@ -648,7 +648,7 @@
|
||||
<p class="pb-4 text-sm">APPEARS IN</p>
|
||||
{#each albums as album}
|
||||
<a data-sveltekit-preload-data="hover" href={`/albums/${album.id}`}>
|
||||
<div class="flex gap-4 py-2 hover:cursor-pointer">
|
||||
<div class="flex gap-4 py-2 hover:cursor-pointer items-center">
|
||||
<div>
|
||||
<img
|
||||
alt={album.albumName}
|
||||
@ -661,11 +661,13 @@
|
||||
|
||||
<div class="mb-auto mt-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 class="flex flex-col gap-0 text-sm">
|
||||
<div>
|
||||
<span>{album.assetCount} items</span>
|
||||
{#if album.shared}
|
||||
<span> • Shared</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -6,6 +6,7 @@
|
||||
export type RenderedOption = {
|
||||
title: string;
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -33,6 +34,7 @@
|
||||
export let showMenu = false;
|
||||
export let controlable = false;
|
||||
export let hideTextOnSmallScreen = true;
|
||||
export let title: string | undefined = undefined;
|
||||
|
||||
export let render: (item: T) => string | RenderedOption = String;
|
||||
|
||||
@ -61,6 +63,7 @@
|
||||
return {
|
||||
title: renderedOption.title,
|
||||
icon: renderedOption.icon,
|
||||
disabled: renderedOption.disabled,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -69,9 +72,9 @@
|
||||
$: renderedSelectedOption = renderOption(selectedOption);
|
||||
</script>
|
||||
|
||||
<div id="dropdown-button" use:clickOutside on:outclick={handleClickOutside} on:escape={handleClickOutside}>
|
||||
<div use:clickOutside on:outclick={handleClickOutside} on:escape={handleClickOutside}>
|
||||
<!-- BUTTON TITLE -->
|
||||
<LinkButton on:click={() => (showMenu = true)} fullwidth>
|
||||
<LinkButton on:click={() => (showMenu = true)} fullwidth {title}>
|
||||
<div class="flex place-items-center gap-2 text-sm">
|
||||
{#if renderedSelectedOption?.icon}
|
||||
<Icon path={renderedSelectedOption.icon} size="18" />
|
||||
@ -84,13 +87,15 @@
|
||||
{#if showMenu}
|
||||
<div
|
||||
transition:fly={{ y: -30, duration: 250 }}
|
||||
class="text-md fixed z-50 flex min-w-[250px] max-h-[70vh] overflow-y-auto immich-scrollbar flex-col rounded-2xl bg-gray-100 py-2 text-black shadow-lg dark:bg-gray-700 dark:text-white {className}"
|
||||
class="text-sm font-medium fixed z-50 flex min-w-[250px] max-h-[70vh] overflow-y-auto immich-scrollbar flex-col rounded-2xl bg-gray-100 py-2 text-black shadow-lg dark:bg-gray-700 dark:text-white {className}"
|
||||
>
|
||||
{#each options as option (option)}
|
||||
{@const renderedOption = renderOption(option)}
|
||||
{@const buttonStyle = renderedOption.disabled ? '' : 'transition-all hover:bg-gray-300 dark:hover:bg-gray-800'}
|
||||
<button
|
||||
class="grid grid-cols-[20px,1fr] place-items-center p-2 transition-all hover:bg-gray-300 dark:hover:bg-gray-800"
|
||||
on:click={() => handleSelectOption(option)}
|
||||
class="grid grid-cols-[36px,1fr] place-items-center p-2 disabled:opacity-40 {buttonStyle}"
|
||||
disabled={renderedOption.disabled}
|
||||
on:click={() => !renderedOption.disabled && handleSelectOption(option)}
|
||||
>
|
||||
{#if isEqual(selectedOption, option)}
|
||||
<div class="text-immich-primary dark:text-immich-dark-primary">
|
||||
|
@ -1,59 +1,72 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { updateAlbumInfo, type AlbumResponseDto } from '@immich/sdk';
|
||||
import { mdiImageAlbum } from '@mdi/js';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { handleError } from '../../utils/handle-error';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
|
||||
|
||||
export let album: AlbumResponseDto;
|
||||
export let onEditSuccess: ((album: AlbumResponseDto) => unknown) | undefined = undefined;
|
||||
export let onCancel: (() => unknown) | undefined = undefined;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
editSuccess: void;
|
||||
cancel: void;
|
||||
}>();
|
||||
let albumName = album.albumName;
|
||||
let description = album.description;
|
||||
|
||||
const editUser = async () => {
|
||||
let isSubmitting = false;
|
||||
|
||||
const handleUpdateAlbumInfo = async () => {
|
||||
isSubmitting = true;
|
||||
try {
|
||||
await updateAlbumInfo({
|
||||
id: album.id,
|
||||
updateAlbumDto: {
|
||||
albumName: album.albumName,
|
||||
description: album.description,
|
||||
albumName,
|
||||
description,
|
||||
},
|
||||
});
|
||||
|
||||
dispatch('editSuccess');
|
||||
album.albumName = albumName;
|
||||
album.description = description;
|
||||
onEditSuccess?.(album);
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to update user');
|
||||
handleError(error, 'Unable to update album info');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="max-h-screen w-[500px] max-w-[95vw] overflow-y-auto rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||
class="max-h-screen w-[700px] max-w-[95vw] overflow-y-auto rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
|
||||
class="flex flex-col place-content-center place-items-center gap-4 px-4 mb-4 text-immich-primary dark:text-immich-dark-primary"
|
||||
>
|
||||
<Icon path={mdiImageAlbum} size="4em" />
|
||||
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Edit album</h1>
|
||||
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Edit Album</h1>
|
||||
</div>
|
||||
|
||||
<form on:submit|preventDefault={editUser} autocomplete="off">
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="name">Name</label>
|
||||
<input class="immich-form-input" id="name" type="text" bind:value={album.albumName} />
|
||||
<form on:submit|preventDefault={handleUpdateAlbumInfo} autocomplete="off">
|
||||
<div class="flex items-center">
|
||||
<div class="hidden sm:flex">
|
||||
<AlbumCover {album} css="h-[200px] w-[200px] m-4 shadow-lg" />
|
||||
</div>
|
||||
|
||||
<div class="flex-grow">
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="name">Name</label>
|
||||
<input class="immich-form-input" id="name" type="text" bind:value={albumName} />
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="description">Description</label>
|
||||
<textarea class="immich-form-input" id="description" bind:value={description} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="description">Description</label>
|
||||
<textarea class="immich-form-input" id="description" bind:value={album.description} />
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex w-full gap-4 px-4">
|
||||
<Button color="gray" fullwidth on:click={() => dispatch('cancel')}>Cancel</Button>
|
||||
<Button type="submit" fullwidth>Confirm</Button>
|
||||
<div class="flex justify-center">
|
||||
<div class="mt-8 flex w-full sm:w-2/3 gap-4 px-4">
|
||||
<Button color="gray" fullwidth on:click={() => onCancel?.()}>Cancel</Button>
|
||||
<Button type="submit" fullwidth disabled={isSubmitting}>OK</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -1,30 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { clickOutside } from '$lib/utils/click-outside';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { clickOutside } from '$lib/utils/click-outside';
|
||||
|
||||
export let direction: 'left' | 'right' = 'right';
|
||||
export let x = 0;
|
||||
export let y = 0;
|
||||
|
||||
let menuElement: HTMLDivElement;
|
||||
export let menuElement: HTMLDivElement | undefined = undefined;
|
||||
|
||||
let left: number;
|
||||
let top: number;
|
||||
|
||||
$: if (menuElement) {
|
||||
const rect = menuElement.getBoundingClientRect();
|
||||
const directionWidth = direction === 'left' ? rect.width : 0;
|
||||
// We need to bind clientHeight since the bounding box may return a height
|
||||
// of zero when starting the 'slide' animation.
|
||||
let height: number;
|
||||
|
||||
left = Math.min(window.innerWidth - rect.width, x - directionWidth);
|
||||
top = Math.min(window.innerHeight - rect.height, y);
|
||||
$: {
|
||||
if (menuElement) {
|
||||
const rect = menuElement.getBoundingClientRect();
|
||||
const directionWidth = direction === 'left' ? rect.width : 0;
|
||||
const menuHeight = Math.min(menuElement.clientHeight, height) || 0;
|
||||
|
||||
left = Math.min(window.innerWidth - rect.width, x - directionWidth);
|
||||
top = Math.min(window.innerHeight - menuHeight, y);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
transition:slide={{ duration: 200, easing: quintOut }}
|
||||
bind:this={menuElement}
|
||||
bind:clientHeight={height}
|
||||
transition:slide={{ duration: 250, easing: quintOut }}
|
||||
class="absolute z-10 min-w-[200px] w-max max-w-[300px] overflow-hidden rounded-lg shadow-lg"
|
||||
style="left: {left}px; top: {top}px;"
|
||||
style:top="{top}px"
|
||||
style:left="{left}px"
|
||||
role="menu"
|
||||
use:clickOutside
|
||||
on:outclick
|
||||
|
@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
|
||||
|
||||
export let direction: 'left' | 'right' = 'right';
|
||||
export let x = 0;
|
||||
export let y = 0;
|
||||
export let isOpen = false;
|
||||
export let onClose: (() => unknown) | undefined;
|
||||
|
||||
let uniqueKey = {};
|
||||
let contextMenuElement: HTMLDivElement;
|
||||
|
||||
const reopenContextMenu = async (event: MouseEvent) => {
|
||||
const contextMenuEvent = new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
clientX: event.x,
|
||||
clientY: event.y,
|
||||
});
|
||||
|
||||
const elements = document.elementsFromPoint(event.x, event.y);
|
||||
|
||||
if (elements.includes(contextMenuElement)) {
|
||||
// User right-clicked on the context menu itself, we keep the context
|
||||
// menu as is
|
||||
return;
|
||||
}
|
||||
|
||||
closeContextMenu();
|
||||
await tick();
|
||||
uniqueKey = {};
|
||||
|
||||
// Event will bubble through the DOM tree
|
||||
const sectionIndex = elements.indexOf(event.target as Element);
|
||||
elements.at(sectionIndex + 1)?.dispatchEvent(contextMenuEvent);
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
onClose?.();
|
||||
};
|
||||
</script>
|
||||
|
||||
{#key uniqueKey}
|
||||
{#if isOpen}
|
||||
<section
|
||||
class="fixed left-0 top-0 z-10 flex h-screen w-screen"
|
||||
on:contextmenu|preventDefault={reopenContextMenu}
|
||||
role="presentation"
|
||||
>
|
||||
<ContextMenu
|
||||
{x}
|
||||
{y}
|
||||
{direction}
|
||||
on:outclick={closeContextMenu}
|
||||
on:escape={closeContextMenu}
|
||||
bind:menuElement={contextMenuElement}
|
||||
>
|
||||
<slot />
|
||||
</ContextMenu>
|
||||
</section>
|
||||
{/if}
|
||||
{/key}
|
@ -32,6 +32,7 @@
|
||||
const dispatch = createEventDispatcher<{
|
||||
close: void;
|
||||
escape: void;
|
||||
created: void;
|
||||
}>();
|
||||
|
||||
const expiredDateOption: ImmichDropDownOption = {
|
||||
@ -78,6 +79,7 @@
|
||||
},
|
||||
});
|
||||
sharedLink = makeSharedLinkUrl($serverConfig.externalDomain, data.key);
|
||||
dispatch('created');
|
||||
} catch (error) {
|
||||
handleError(error, 'Failed to create shared link');
|
||||
}
|
||||
|
@ -1,20 +1,13 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import {
|
||||
SharedLinkType,
|
||||
ThumbnailFormat,
|
||||
getAssetInfo,
|
||||
type AssetResponseDto,
|
||||
type SharedLinkResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { SharedLinkType, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { mdiCircleEditOutline, mdiContentCopy, mdiDelete, mdiOpenInNew } from '@mdi/js';
|
||||
import * as luxon from 'luxon';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
|
||||
|
||||
export let link: SharedLinkResponseDto;
|
||||
|
||||
@ -25,18 +18,6 @@
|
||||
edit: void;
|
||||
}>();
|
||||
|
||||
const getThumbnail = async (): Promise<AssetResponseDto> => {
|
||||
let assetId = '';
|
||||
|
||||
if (link.album?.albumThumbnailAssetId) {
|
||||
assetId = link.album.albumThumbnailAssetId;
|
||||
} else if (link.assets.length > 0) {
|
||||
assetId = link.assets[0].id;
|
||||
}
|
||||
|
||||
return getAssetInfo({ id: assetId });
|
||||
};
|
||||
|
||||
const getCountDownExpirationDate = () => {
|
||||
if (!link.expiresAt) {
|
||||
return;
|
||||
@ -70,28 +51,7 @@
|
||||
class="flex w-full gap-4 border-b border-gray-200 py-4 transition-all hover:border-immich-primary dark:border-gray-600 dark:text-immich-gray dark:hover:border-immich-dark-primary"
|
||||
>
|
||||
<div>
|
||||
{#if link?.album?.albumThumbnailAssetId || link.assets.length > 0}
|
||||
{#await getThumbnail()}
|
||||
<LoadingSpinner />
|
||||
{:then asset}
|
||||
<img
|
||||
id={asset.id}
|
||||
src={getAssetThumbnailUrl(asset.id, ThumbnailFormat.Webp)}
|
||||
alt={asset.id}
|
||||
class="h-[100px] w-[100px] rounded-lg object-cover"
|
||||
loading="lazy"
|
||||
draggable="false"
|
||||
/>
|
||||
{/await}
|
||||
{:else}
|
||||
<enhanced:img
|
||||
src="$lib/assets/no-thumbnail.png"
|
||||
alt={'Album without assets'}
|
||||
class="h-[100px] w-[100px] rounded-lg object-cover"
|
||||
loading="lazy"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
<AlbumCover album={link?.album} css="h-[100px] w-[100px] transition-all duration-300 hover:shadow-lg" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-between">
|
||||
|
@ -67,10 +67,16 @@ export const videoViewerVolume = persisted<number>('video-viewer-volume', 1, {})
|
||||
export const isShowDetail = persisted<boolean>('info-opened', false, {});
|
||||
|
||||
export interface AlbumViewSettings {
|
||||
sortBy: string;
|
||||
sortDesc: boolean;
|
||||
view: string;
|
||||
filter: string;
|
||||
groupBy: string;
|
||||
groupOrder: string;
|
||||
sortBy: string;
|
||||
sortOrder: string;
|
||||
collapsedGroups: {
|
||||
// Grouping Option => Array<Group ID>
|
||||
[group: string]: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SidebarSettings {
|
||||
@ -83,6 +89,11 @@ export const sidebarSettings = persisted<SidebarSettings>('sidebar-settings-1',
|
||||
sharing: true,
|
||||
});
|
||||
|
||||
export enum SortOrder {
|
||||
Asc = 'asc',
|
||||
Desc = 'desc',
|
||||
}
|
||||
|
||||
export enum AlbumViewMode {
|
||||
Cover = 'Cover',
|
||||
List = 'List',
|
||||
@ -94,11 +105,29 @@ export enum AlbumFilter {
|
||||
Shared = 'Shared',
|
||||
}
|
||||
|
||||
export enum AlbumGroupBy {
|
||||
None = 'None',
|
||||
Year = 'Year',
|
||||
Owner = 'Owner',
|
||||
}
|
||||
|
||||
export enum AlbumSortBy {
|
||||
Title = 'Title',
|
||||
ItemCount = 'ItemCount',
|
||||
DateModified = 'DateModified',
|
||||
DateCreated = 'DateCreated',
|
||||
MostRecentPhoto = 'MostRecentPhoto',
|
||||
OldestPhoto = 'OldestPhoto',
|
||||
}
|
||||
|
||||
export const albumViewSettings = persisted<AlbumViewSettings>('album-view-settings', {
|
||||
sortBy: 'Most recent photo',
|
||||
sortDesc: true,
|
||||
view: AlbumViewMode.Cover,
|
||||
filter: AlbumFilter.All,
|
||||
groupBy: AlbumGroupBy.Year,
|
||||
groupOrder: SortOrder.Desc,
|
||||
sortBy: AlbumSortBy.MostRecentPhoto,
|
||||
sortOrder: SortOrder.Desc,
|
||||
collapsedGroups: {},
|
||||
});
|
||||
|
||||
export const showDeleteModal = persisted<boolean>('delete-confirm-dialog', true, {});
|
||||
|
203
web/src/lib/utils/album-utils.ts
Normal file
203
web/src/lib/utils/album-utils.ts
Normal file
@ -0,0 +1,203 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import {
|
||||
AlbumGroupBy,
|
||||
AlbumSortBy,
|
||||
SortOrder,
|
||||
albumViewSettings,
|
||||
type AlbumViewSettings,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import type { AlbumResponseDto } from '@immich/sdk';
|
||||
import * as sdk from '@immich/sdk';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
/**
|
||||
* -------------------------
|
||||
* Albums General Management
|
||||
* -------------------------
|
||||
*/
|
||||
export const createAlbum = async (name?: string, assetIds?: string[]) => {
|
||||
try {
|
||||
const newAlbum: AlbumResponseDto = await sdk.createAlbum({
|
||||
createAlbumDto: {
|
||||
albumName: name ?? '',
|
||||
assetIds,
|
||||
},
|
||||
});
|
||||
return newAlbum;
|
||||
} catch (error) {
|
||||
handleError(error, 'Failed to create album');
|
||||
}
|
||||
};
|
||||
|
||||
export const createAlbumAndRedirect = async (name?: string, assetIds?: string[]) => {
|
||||
const newAlbum = await createAlbum(name, assetIds);
|
||||
if (newAlbum) {
|
||||
await goto(`${AppRoute.ALBUMS}/${newAlbum.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* -------------
|
||||
* Album Sorting
|
||||
* -------------
|
||||
*/
|
||||
export interface AlbumSortOptionMetadata {
|
||||
id: AlbumSortBy;
|
||||
text: string;
|
||||
defaultOrder: SortOrder;
|
||||
columnStyle: string;
|
||||
}
|
||||
|
||||
export const sortOptionsMetadata: AlbumSortOptionMetadata[] = [
|
||||
{
|
||||
id: AlbumSortBy.Title,
|
||||
text: 'Title',
|
||||
defaultOrder: SortOrder.Asc,
|
||||
columnStyle: 'text-left w-8/12 sm:w-4/12 md:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%]',
|
||||
},
|
||||
{
|
||||
id: AlbumSortBy.ItemCount,
|
||||
text: 'Number of items',
|
||||
defaultOrder: SortOrder.Desc,
|
||||
columnStyle: 'text-center w-4/12 m:w-2/12 md:w-2/12 xl:w-[15%] 2xl:w-[12%]',
|
||||
},
|
||||
{
|
||||
id: AlbumSortBy.DateModified,
|
||||
text: 'Date modified',
|
||||
defaultOrder: SortOrder.Desc,
|
||||
columnStyle: 'text-center hidden sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]',
|
||||
},
|
||||
{
|
||||
id: AlbumSortBy.DateCreated,
|
||||
text: 'Date created',
|
||||
defaultOrder: SortOrder.Desc,
|
||||
columnStyle: 'text-center hidden sm:block w-3/12 xl:w-[15%] 2xl:w-[12%]',
|
||||
},
|
||||
{
|
||||
id: AlbumSortBy.MostRecentPhoto,
|
||||
text: 'Most recent photo',
|
||||
defaultOrder: SortOrder.Desc,
|
||||
columnStyle: 'text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]',
|
||||
},
|
||||
{
|
||||
id: AlbumSortBy.OldestPhoto,
|
||||
text: 'Oldest photo',
|
||||
defaultOrder: SortOrder.Desc,
|
||||
columnStyle: 'text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]',
|
||||
},
|
||||
];
|
||||
|
||||
export const findSortOptionMetadata = (sortBy: string) => {
|
||||
// Default is sort by most recent photo
|
||||
const defaultSortOption = sortOptionsMetadata[4];
|
||||
return sortOptionsMetadata.find(({ id }) => sortBy === id) ?? defaultSortOption;
|
||||
};
|
||||
|
||||
/**
|
||||
* --------------
|
||||
* Album Grouping
|
||||
* --------------
|
||||
*/
|
||||
export interface AlbumGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
albums: AlbumResponseDto[];
|
||||
}
|
||||
|
||||
export interface AlbumGroupOptionMetadata {
|
||||
id: AlbumGroupBy;
|
||||
text: string;
|
||||
defaultOrder: SortOrder;
|
||||
isDisabled: () => boolean;
|
||||
}
|
||||
|
||||
export const groupOptionsMetadata: AlbumGroupOptionMetadata[] = [
|
||||
{
|
||||
id: AlbumGroupBy.None,
|
||||
text: 'No grouping',
|
||||
defaultOrder: SortOrder.Asc,
|
||||
isDisabled: () => false,
|
||||
},
|
||||
{
|
||||
id: AlbumGroupBy.Year,
|
||||
text: 'Group by year',
|
||||
defaultOrder: SortOrder.Desc,
|
||||
isDisabled() {
|
||||
const disabledWithSortOptions: string[] = [AlbumSortBy.DateCreated, AlbumSortBy.DateModified];
|
||||
return disabledWithSortOptions.includes(get(albumViewSettings).sortBy);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: AlbumGroupBy.Owner,
|
||||
text: 'Group by owner',
|
||||
defaultOrder: SortOrder.Asc,
|
||||
isDisabled: () => false,
|
||||
},
|
||||
];
|
||||
|
||||
export const findGroupOptionMetadata = (groupBy: string) => {
|
||||
// Default is no grouping
|
||||
const defaultGroupOption = groupOptionsMetadata[0];
|
||||
return groupOptionsMetadata.find(({ id }) => groupBy === id) ?? defaultGroupOption;
|
||||
};
|
||||
|
||||
export const getSelectedAlbumGroupOption = (settings: AlbumViewSettings) => {
|
||||
const defaultGroupOption = AlbumGroupBy.None;
|
||||
const albumGroupOption = settings.groupBy ?? defaultGroupOption;
|
||||
|
||||
if (findGroupOptionMetadata(albumGroupOption).isDisabled()) {
|
||||
return defaultGroupOption;
|
||||
}
|
||||
return albumGroupOption;
|
||||
};
|
||||
|
||||
/**
|
||||
* ----------------------------
|
||||
* Album Groups Collapse/Expand
|
||||
* ----------------------------
|
||||
*/
|
||||
const getCollapsedAlbumGroups = (settings: AlbumViewSettings) => {
|
||||
settings.collapsedGroups ??= {};
|
||||
const { collapsedGroups, groupBy } = settings;
|
||||
collapsedGroups[groupBy] ??= [];
|
||||
return collapsedGroups[groupBy];
|
||||
};
|
||||
|
||||
export const isAlbumGroupCollapsed = (settings: AlbumViewSettings, groupId: string) => {
|
||||
if (settings.groupBy === AlbumGroupBy.None) {
|
||||
return false;
|
||||
}
|
||||
return getCollapsedAlbumGroups(settings).includes(groupId);
|
||||
};
|
||||
|
||||
export const toggleAlbumGroupCollapsing = (groupId: string) => {
|
||||
const settings = get(albumViewSettings);
|
||||
if (settings.groupBy === AlbumGroupBy.None) {
|
||||
return;
|
||||
}
|
||||
const collapsedGroups = getCollapsedAlbumGroups(settings);
|
||||
const groupIndex = collapsedGroups.indexOf(groupId);
|
||||
if (groupIndex === -1) {
|
||||
// Collapse
|
||||
collapsedGroups.push(groupId);
|
||||
} else {
|
||||
// Expand
|
||||
collapsedGroups.splice(groupIndex, 1);
|
||||
}
|
||||
albumViewSettings.set(settings);
|
||||
};
|
||||
|
||||
export const collapseAllAlbumGroups = (groupIds: string[]) => {
|
||||
albumViewSettings.update((settings) => {
|
||||
const collapsedGroups = getCollapsedAlbumGroups(settings);
|
||||
collapsedGroups.length = 0;
|
||||
collapsedGroups.push(...groupIds);
|
||||
return settings;
|
||||
});
|
||||
};
|
||||
|
||||
export const expandAllAlbumGroups = () => {
|
||||
collapseAllAlbumGroups([]);
|
||||
};
|
@ -5,13 +5,14 @@ import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'
|
||||
import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store';
|
||||
import { downloadManager } from '$lib/stores/download';
|
||||
import { downloadRequest, getKey } from '$lib/utils';
|
||||
import { createAlbum } from '$lib/utils/album-utils';
|
||||
import { encodeHTMLSpecialChars } from '$lib/utils/string-utils';
|
||||
import {
|
||||
addAssetsToAlbum as addAssets,
|
||||
createAlbum,
|
||||
defaults,
|
||||
getDownloadInfo,
|
||||
updateAssets,
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
type AssetTypeEnum,
|
||||
type DownloadInfoDto,
|
||||
@ -48,33 +49,30 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: string[]) => {
|
||||
};
|
||||
|
||||
export const addAssetsToNewAlbum = async (albumName: string, assetIds: string[]) => {
|
||||
try {
|
||||
const album = await createAlbum({
|
||||
createAlbumDto: {
|
||||
albumName,
|
||||
assetIds,
|
||||
},
|
||||
});
|
||||
const displayName = albumName ? `<b>${encodeHTMLSpecialChars(albumName)}</b>` : 'new album';
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
timeout: 5000,
|
||||
message: `Added ${assetIds.length} asset${assetIds.length === 1 ? '' : 's'} to ${displayName}`,
|
||||
html: true,
|
||||
button: {
|
||||
text: 'View Album',
|
||||
onClick() {
|
||||
return goto(`${AppRoute.ALBUMS}/${album.id}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
return album;
|
||||
} catch {
|
||||
notificationController.show({
|
||||
type: NotificationType.Error,
|
||||
message: 'Failed to create album',
|
||||
});
|
||||
const album = await createAlbum(albumName, assetIds);
|
||||
if (!album) {
|
||||
return;
|
||||
}
|
||||
const displayName = albumName ? `<b>${encodeHTMLSpecialChars(albumName)}</b>` : 'new album';
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
timeout: 5000,
|
||||
message: `Added ${assetIds.length} asset${assetIds.length === 1 ? '' : 's'} to ${displayName}`,
|
||||
html: true,
|
||||
button: {
|
||||
text: 'View Album',
|
||||
onClick() {
|
||||
return goto(`${AppRoute.ALBUMS}/${album.id}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
return album;
|
||||
};
|
||||
|
||||
export const downloadAlbum = async (album: AlbumResponseDto) => {
|
||||
await downloadArchive(`${album.albumName}.zip`, {
|
||||
albumId: album.id,
|
||||
});
|
||||
};
|
||||
|
||||
export const downloadBlob = (data: Blob, filename: string) => {
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
/**
|
||||
* Convert time like `01:02:03.456` to seconds.
|
||||
@ -15,3 +17,37 @@ export function timeToSeconds(time: string) {
|
||||
export function parseUtcDate(date: string) {
|
||||
return DateTime.fromISO(date, { zone: 'UTC' }).toUTC();
|
||||
}
|
||||
|
||||
export const getShortDateRange = (startDate: string | Date, endDate: string | Date) => {
|
||||
startDate = startDate instanceof Date ? startDate : new Date(startDate);
|
||||
endDate = endDate instanceof Date ? endDate : new Date(endDate);
|
||||
|
||||
const userLocale = get(locale);
|
||||
const endDateLocalized = endDate.toLocaleString(userLocale, {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
if (startDate.getFullYear() === endDate.getFullYear()) {
|
||||
if (startDate.getMonth() === endDate.getMonth()) {
|
||||
// Same year and month.
|
||||
// e.g.: aug. 2024
|
||||
return endDateLocalized;
|
||||
} else {
|
||||
// Same year but different month.
|
||||
// e.g.: jul. - sept. 2024
|
||||
const startMonthLocalized = startDate.toLocaleString(userLocale, {
|
||||
month: 'short',
|
||||
});
|
||||
return `${startMonthLocalized} - ${endDateLocalized}`;
|
||||
}
|
||||
} else {
|
||||
// Different year.
|
||||
// e.g.: feb. 2021 - sept. 2024
|
||||
const startDateLocalized = startDate.toLocaleString(userLocale, {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
return `${startDateLocalized} - ${endDateLocalized}`;
|
||||
}
|
||||
};
|
||||
|
@ -1,17 +1,50 @@
|
||||
<script lang="ts">
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { AlbumFilter, albumViewSettings } from '$lib/stores/preferences.store';
|
||||
import { createAlbumAndRedirect } from '$lib/utils/album-utils';
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import AlbumsControls from '$lib/components/album-page/albums-controls.svelte';
|
||||
import Albums from '$lib/components/album-page/albums-list.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import GroupTab from '$lib/components/elements/group-tab.svelte';
|
||||
import SearchBar from '$lib/components/elements/search-bar.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let searchAlbum = '';
|
||||
let searchQuery = '';
|
||||
let albumGroups: string[] = [];
|
||||
</script>
|
||||
|
||||
<UserPageLayout title={data.meta.title}>
|
||||
<div class="flex place-items-center gap-2" slot="buttons">
|
||||
<AlbumsControls bind:searchAlbum />
|
||||
<AlbumsControls {albumGroups} bind:searchQuery />
|
||||
</div>
|
||||
<Albums ownedAlbums={data.albums} {searchAlbum} sharedAlbums={data.sharedAlbums} />
|
||||
|
||||
<div class="xl:hidden">
|
||||
<div class="w-fit h-14 dark:text-immich-dark-fg py-2">
|
||||
<GroupTab
|
||||
filters={Object.keys(AlbumFilter)}
|
||||
selected={$albumViewSettings.filter}
|
||||
onSelect={(selected) => ($albumViewSettings.filter = selected)}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-60">
|
||||
<SearchBar placeholder="Search albums" bind:name={searchQuery} isSearching={false} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Albums
|
||||
ownedAlbums={data.albums}
|
||||
sharedAlbums={data.sharedAlbums}
|
||||
userSettings={$albumViewSettings}
|
||||
allowEdit
|
||||
{searchQuery}
|
||||
bind:albumGroupIds={albumGroups}
|
||||
>
|
||||
<EmptyPlaceholder
|
||||
slot="empty"
|
||||
text="Create an album to organize your photos and videos"
|
||||
onClick={() => createAlbumAndRedirect()}
|
||||
/>
|
||||
</Albums>
|
||||
</UserPageLayout>
|
||||
|
@ -39,7 +39,7 @@
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { downloadArchive } from '$lib/utils/asset-utils';
|
||||
import { downloadAlbum } from '$lib/utils/asset-utils';
|
||||
import { clickOutside } from '$lib/utils/click-outside';
|
||||
import { getContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
@ -342,7 +342,7 @@
|
||||
};
|
||||
|
||||
const handleDownloadAlbum = async () => {
|
||||
await downloadArchive(`${album.albumName}.zip`, { albumId: album.id });
|
||||
await downloadAlbum(album);
|
||||
};
|
||||
|
||||
const handleRemoveAlbum = async () => {
|
||||
@ -369,6 +369,18 @@
|
||||
viewMode = ViewMode.VIEW;
|
||||
assetInteractionStore.clearMultiselect();
|
||||
|
||||
await updateThumbnail(assetId);
|
||||
};
|
||||
|
||||
const updateThumbnailUsingCurrentSelection = async () => {
|
||||
if ($selectedAssets.size === 1) {
|
||||
const assetId = [...$selectedAssets][0].id;
|
||||
assetInteractionStore.clearMultiselect();
|
||||
await updateThumbnail(assetId);
|
||||
}
|
||||
};
|
||||
|
||||
const updateThumbnail = async (assetId: string) => {
|
||||
try {
|
||||
await updateAlbumInfo({
|
||||
id: album.id,
|
||||
@ -400,6 +412,13 @@
|
||||
{#if isAllUserOwned}
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
{#if $selectedAssets.size === 1}
|
||||
<MenuOption
|
||||
text="Set as album cover"
|
||||
icon={mdiImageOutline}
|
||||
on:click={() => updateThumbnailUsingCurrentSelection()}
|
||||
/>
|
||||
{/if}
|
||||
<ArchiveAction menuItem unarchive={isAllArchived} onArchive={() => assetStore.triggerUpdate()} />
|
||||
{/if}
|
||||
{#if isOwned || isAllUserOwned}
|
||||
|
@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import AlbumCard from '$lib/components/album-page/album-card.svelte';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
|
||||
@ -31,7 +30,6 @@
|
||||
type AlbumResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
|
||||
import { flip } from 'svelte/animate';
|
||||
import type { Viewport } from '$lib/stores/assets.store';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||
@ -39,6 +37,7 @@
|
||||
import { parseUtcDate } from '$lib/utils/date-time';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
|
||||
|
||||
const MAX_ASSET_COUNT = 5000;
|
||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||
@ -275,13 +274,7 @@
|
||||
{#if searchResultAlbums.length > 0}
|
||||
<section>
|
||||
<div class="ml-6 text-4xl font-medium text-black/70 dark:text-white/80">ALBUMS</div>
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] mt-4 gap-y-4">
|
||||
{#each searchResultAlbums as album, index (album.id)}
|
||||
<a data-sveltekit-preload-data="hover" href={`albums/${album.id}`} animate:flip={{ duration: 200 }}>
|
||||
<AlbumCard preload={index < 20} {album} isSharingView={false} showItemCount={false} />
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<AlbumCardGroup albums={searchResultAlbums} showDateRange showItemCount />
|
||||
|
||||
<div class="m-6 text-4xl font-medium text-black/70 dark:text-white/80">PHOTOS & VIDEOS</div>
|
||||
</section>
|
||||
|
@ -1,37 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import empty2Url from '$lib/assets/empty-2.svg';
|
||||
import AlbumCard from '$lib/components/album-page/album-card.svelte';
|
||||
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { createAlbum } from '@immich/sdk';
|
||||
import { mdiLink, mdiPlusBoxOutline } from '@mdi/js';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { handleError } from '../../../lib/utils/handle-error';
|
||||
import type { PageData } from './$types';
|
||||
import { createAlbumAndRedirect } from '$lib/utils/album-utils';
|
||||
import {
|
||||
AlbumFilter,
|
||||
AlbumGroupBy,
|
||||
AlbumSortBy,
|
||||
AlbumViewMode,
|
||||
SortOrder,
|
||||
type AlbumViewSettings,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import Albums from '$lib/components/album-page/albums-list.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
const createSharedAlbum = async () => {
|
||||
try {
|
||||
const newAlbum = await createAlbum({ createAlbumDto: { albumName: '' } });
|
||||
await goto(`${AppRoute.ALBUMS}/${newAlbum.id}`);
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to create album');
|
||||
}
|
||||
const settings: AlbumViewSettings = {
|
||||
view: AlbumViewMode.Cover,
|
||||
filter: AlbumFilter.Shared,
|
||||
groupBy: AlbumGroupBy.None,
|
||||
groupOrder: SortOrder.Desc,
|
||||
sortBy: AlbumSortBy.MostRecentPhoto,
|
||||
sortOrder: SortOrder.Desc,
|
||||
collapsedGroups: {},
|
||||
};
|
||||
</script>
|
||||
|
||||
<UserPageLayout title={data.meta.title}>
|
||||
<div class="flex" slot="buttons">
|
||||
<LinkButton on:click={createSharedAlbum}>
|
||||
<LinkButton on:click={() => createAlbumAndRedirect()}>
|
||||
<div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm">
|
||||
<Icon path={mdiPlusBoxOutline} size="18" class="shrink-0" />
|
||||
<span class="leading-none max-sm:text-xs">Create shared album</span>
|
||||
<span class="leading-none max-sm:text-xs">Create album</span>
|
||||
</div>
|
||||
</LinkButton>
|
||||
|
||||
@ -79,22 +86,15 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- Share Album List -->
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] mt-4 gap-y-4">
|
||||
{#each data.sharedAlbums as album, index (album.id)}
|
||||
<a data-sveltekit-preload-data="hover" href={`albums/${album.id}`} animate:flip={{ duration: 200 }}>
|
||||
<AlbumCard preload={index < 20} {album} isSharingView />
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Empty List -->
|
||||
{#if data.sharedAlbums.length === 0}
|
||||
<!-- Shared Album List -->
|
||||
<Albums sharedAlbums={data.sharedAlbums} userSettings={settings} showOwner>
|
||||
<!-- Empty List -->
|
||||
<EmptyPlaceholder
|
||||
text="Create a shared album to share photos and videos with people in your network"
|
||||
slot="empty"
|
||||
text="Create an album to share photos and videos with people in your network"
|
||||
src={empty2Url}
|
||||
/>
|
||||
{/if}
|
||||
</Albums>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
Loading…
Reference in New Issue
Block a user