Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(web): albums list (1) #7660

Merged
merged 11 commits into from
Mar 14, 2024
68 changes: 68 additions & 0 deletions web/src/lib/components/album-page/albums-controls.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<script lang="ts">
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 { AlbumViewMode, albumViewSettings } from '$lib/stores/preferences.store';
import {
mdiArrowDownThin,
mdiArrowUpThin,
mdiFormatListBulletedSquare,
mdiPlusBoxOutline,
mdiViewGridOutline,
} from '@mdi/js';
import { sortByOptions, type Sort, handleCreateAlbum } from '$lib/components/album-page/albums-list.svelte';
import SearchBar from '$lib/components/elements/search-bar.svelte';

export let searchAlbum: string;

const searchSort = (searched: string): Sort => {
return sortByOptions.find((option) => option.title === searched) || sortByOptions[0];
};

const handleChangeListMode = () => {
$albumViewSettings.view =
$albumViewSettings.view === AlbumViewMode.Cover ? AlbumViewMode.List : AlbumViewMode.Cover;
};
</script>

<div class="hidden lg:block lg:w-40 xl:w-60 2xl:w-80 h-10">
<SearchBar placeholder="Search albums" bind:name={searchAlbum} isSearching={false} />
</div>
<LinkButton on:click={handleCreateAlbum}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiPlusBoxOutline} size="18" />
Create album
</div>
</LinkButton>

<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;
martabal marked this conversation as resolved.
Show resolved Hide resolved
break;
}
}
}}
/>

<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>
{:else}
<Icon path={mdiFormatListBulletedSquare} size="18" />
<p class="hidden sm:block">List</p>
{/if}
</div>
</LinkButton>
282 changes: 282 additions & 0 deletions web/src/lib/components/album-page/albums-list.svelte
martabal marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
<script lang="ts" context="module">
import { AlbumViewMode, albumViewSettings } from '$lib/stores/preferences.store';
import { goto } from '$app/navigation';
import type { OnShowContextMenuDetail } from '$lib/components/album-page/album-card';
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 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 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 AlbumsTable from '$lib/components/album-page/albums-table.svelte';
import { handleError } from '$lib/utils/handle-error';

export let albums: AlbumResponseDto[];
export let searchAlbum: string;

let shouldShowEditAlbumForm = false;
let selectedAlbum: AlbumResponseDto;
let albumToDelete: AlbumResponseDto | null;
let contextMenuPosition: OnShowContextMenuDetail = { x: 0, y: 0 };
let contextMenuTargetAlbum: AlbumResponseDto | undefined = undefined;

$: {
for (const key of sortByOptions) {
if (key.title === $albumViewSettings.sortBy) {
albums = key.sortFn(key.sortDesc, albums);
$albumViewSettings.sortDesc = key.sortDesc; // "Save" sortDesc
$albumViewSettings.sortBy = key.title;
break;
}
}
}
$: isShowContextMenu = !!contextMenuTargetAlbum;
$: albumsFiltered = albums.filter((album) => album.albumName.toLowerCase().includes(searchAlbum.toLowerCase()));

onMount(async () => {
await removeAlbumsIfEmpty();
});

function showAlbumContextMenu(contextMenuDetail: OnShowContextMenuDetail, album: AlbumResponseDto): void {
contextMenuTargetAlbum = album;
contextMenuPosition = {
x: contextMenuDetail.x,
y: contextMenuDetail.y,
};
}

function closeAlbumContextMenu() {
contextMenuTargetAlbum = undefined;
}

async function handleDeleteAlbum(albumToDelete: AlbumResponseDto): Promise<void> {
await deleteAlbum({ id: albumToDelete.id });
albums = albums.filter(({ id }) => id !== albumToDelete.id);
}

const chooseAlbumToDelete = (album: AlbumResponseDto) => {
contextMenuTargetAlbum = album;
setAlbumToDelete();
};

const setAlbumToDelete = () => {
albumToDelete = contextMenuTargetAlbum ?? null;
closeAlbumContextMenu();
};

const handleEdit = (album: AlbumResponseDto) => {
selectedAlbum = { ...album };
shouldShowEditAlbumForm = true;
};

const deleteSelectedAlbum = async () => {
if (!albumToDelete) {
return;
}
try {
await handleDeleteAlbum(albumToDelete);
} catch {
notificationController.show({
message: 'Error deleting album',
type: NotificationType.Error,
});
} finally {
albumToDelete = null;
}
};

const removeAlbumsIfEmpty = async () => {
for (const album of albums) {
if (album.assetCount == 0 && album.albumName == '') {
try {
await handleDeleteAlbum(album);
} catch (error) {
console.log(error);
}
}
}
};

const successModifyAlbum = () => {
shouldShowEditAlbumForm = false;
notificationController.show({
message: 'Album infos updated',
type: NotificationType.Info,
});
albums[albums.findIndex((x) => x.id === selectedAlbum.id)] = selectedAlbum;
martabal marked this conversation as resolved.
Show resolved Hide resolved
};
</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 -->
{#if $albumViewSettings.view === AlbumViewMode.Cover}
<div class="grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))]">
{#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}
on:showalbumcontextmenu={({ detail }) => showAlbumContextMenu(detail, album)}
/>
</a>
{/each}
</div>
{:else if $albumViewSettings.view === AlbumViewMode.List}
<AlbumsTable
{sortByOptions}
{albumsFiltered}
onChooseAlbumToDelete={(album) => chooseAlbumToDelete(album)}
onAlbumToEdit={(album) => handleEdit(album)}
/>
{/if}

<!-- Empty Message -->
{:else}
<EmptyPlaceholder text="Create an album to organize your photos and videos" onClick={handleCreateAlbum} />
{/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}

{#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}
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
<script lang="ts">
import type { Sort } from '../../../routes/(user)/albums/+page.svelte';
import { albumViewSettings } from '$lib/stores/preferences.store';
import type { Sort } from '$lib/components/album-page/albums-list.svelte';

export let albumViewSettings: string;
export let option: Sort;

const handleSort = () => {
if (albumViewSettings === option.title) {
if ($albumViewSettings.sortBy === option.title) {
$albumViewSettings.sortDesc = !option.sortDesc;
option.sortDesc = !option.sortDesc;
} else {
albumViewSettings = option.title;
$albumViewSettings.sortBy = option.title;
}
};
</script>
Expand All @@ -18,7 +19,7 @@
class="rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
on:click={() => handleSort()}
>
{#if albumViewSettings === option.title}
{#if $albumViewSettings.sortBy === option.title}
{#if option.sortDesc}
&#8595;
{:else}
Expand Down