You've already forked immich
							
							
				mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 00:18:28 +02:00 
			
		
		
		
	refactor(web): albums list (1) (#7660)
* refactor: albums list * fix: rename filename * chore: fix merge * pr feedback * chore: fix merge * pr feedback --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
		
							
								
								
									
										68
									
								
								web/src/lib/components/album-page/albums-controls.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								web/src/lib/components/album-page/albums-controls.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
|         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
									
								
								web/src/lib/components/album-page/albums-list.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										282
									
								
								web/src/lib/components/album-page/albums-list.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
|   }; | ||||
| </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} | ||||
| @@ -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> | ||||
| @@ -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} | ||||
|         ↓ | ||||
|       {:else} | ||||
							
								
								
									
										87
									
								
								web/src/lib/components/album-page/albums-table.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								web/src/lib/components/album-page/albums-table.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | ||||
| <script lang="ts"> | ||||
|   import { AppRoute } from '$lib/constants'; | ||||
|   import type { AlbumResponseDto } from '@immich/sdk'; | ||||
|   import TableHeader from '$lib/components/album-page/albums-table-header.svelte'; | ||||
|   import { goto } from '$app/navigation'; | ||||
|   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'; | ||||
|  | ||||
|   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); | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| <table class="mt-2 w-full text-left"> | ||||
|   <thead | ||||
|     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} | ||||
|       <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}`)} | ||||
|         on:keydown={(event) => event.key === 'Enter' && goto(`${AppRoute.ALBUMS}/${album.id}`)} | ||||
|         tabindex="0" | ||||
|       > | ||||
|         <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`} | ||||
|           </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%]"> | ||||
|           <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> | ||||
|         </td> | ||||
|       </tr> | ||||
|     {/each} | ||||
|   </tbody> | ||||
| </table> | ||||
| @@ -1,406 +1,17 @@ | ||||
| <script lang="ts" context="module"> | ||||
|   export interface Sort { | ||||
|     title: string; | ||||
|     sortDesc: boolean; | ||||
|     widthClass: string; | ||||
|     sortFn: (reverse: boolean, albums: AlbumResponseDto[]) => AlbumResponseDto[]; | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <script lang="ts"> | ||||
|   import { goto } from '$app/navigation'; | ||||
|   import AlbumCard from '$lib/components/album-page/album-card.svelte'; | ||||
|   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 TableHeader from '$lib/components/elements/table-header.svelte'; | ||||
|   import EditAlbumForm from '$lib/components/forms/edit-album-form.svelte'; | ||||
|   import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; | ||||
|   import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte'; | ||||
|   import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte'; | ||||
|   import MenuOption from '$lib/components/shared-components/context-menu/menu-option.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 { AppRoute, dateFormats } from '$lib/constants'; | ||||
|   import { AlbumViewMode, albumViewSettings, locale } from '$lib/stores/preferences.store'; | ||||
|   import type { AlbumResponseDto } from '@immich/sdk'; | ||||
|   import { | ||||
|     mdiArrowDownThin, | ||||
|     mdiArrowUpThin, | ||||
|     mdiDeleteOutline, | ||||
|     mdiFormatListBulletedSquare, | ||||
|     mdiPencilOutline, | ||||
|     mdiPlusBoxOutline, | ||||
|     mdiTrashCanOutline, | ||||
|     mdiViewGridOutline, | ||||
|   } from '@mdi/js'; | ||||
|   import { orderBy } from 'lodash-es'; | ||||
|   import { onMount } from 'svelte'; | ||||
|   import { flip } from 'svelte/animate'; | ||||
|   import type { PageData } from './$types'; | ||||
|   import { useAlbums } from './albums.bloc'; | ||||
|   import SearchBar from '$lib/components/elements/search-bar.svelte'; | ||||
|   import AlbumsControls from '$lib/components/album-page/albums-controls.svelte'; | ||||
|   import Albums from '$lib/components/album-page/albums-list.svelte'; | ||||
|  | ||||
|   export let data: PageData; | ||||
|  | ||||
|   let shouldShowEditUserForm = false; | ||||
|   let selectedAlbum: AlbumResponseDto; | ||||
|   let searchAlbum = ''; | ||||
|  | ||||
|   let sortByOptions: Record<string, Sort> = { | ||||
|     albumTitle: { | ||||
|       title: 'Album title', | ||||
|       sortDesc: $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']); | ||||
|       }, | ||||
|     }, | ||||
|     numberOfAssets: { | ||||
|       title: 'Number of assets', | ||||
|       sortDesc: $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']); | ||||
|       }, | ||||
|     }, | ||||
|     lastModified: { | ||||
|       title: 'Last modified', | ||||
|       sortDesc: $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']); | ||||
|       }, | ||||
|     }, | ||||
|     created: { | ||||
|       title: 'Created date', | ||||
|       sortDesc: $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']); | ||||
|       }, | ||||
|     }, | ||||
|     mostRecent: { | ||||
|       title: 'Most recent photo', | ||||
|       sortDesc: $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; | ||||
|         }); | ||||
|       }, | ||||
|     }, | ||||
|     mostOld: { | ||||
|       title: 'Oldest photo', | ||||
|       sortDesc: $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; | ||||
|         }); | ||||
|       }, | ||||
|     }, | ||||
|   }; | ||||
|  | ||||
|   const handleEdit = (album: AlbumResponseDto) => { | ||||
|     selectedAlbum = { ...album }; | ||||
|     shouldShowEditUserForm = true; | ||||
|   }; | ||||
|  | ||||
|   const { | ||||
|     albums: unsortedAlbums, | ||||
|     isShowContextMenu, | ||||
|     contextMenuPosition, | ||||
|     contextMenuTargetAlbum, | ||||
|     createAlbum, | ||||
|     deleteAlbum, | ||||
|     showAlbumContextMenu, | ||||
|     closeAlbumContextMenu, | ||||
|   } = useAlbums({ albums: data.albums }); | ||||
|  | ||||
|   let albums = unsortedAlbums; | ||||
|   let albumToDelete: AlbumResponseDto | null; | ||||
|  | ||||
|   const chooseAlbumToDelete = (album: AlbumResponseDto) => { | ||||
|     $contextMenuTargetAlbum = album; | ||||
|     setAlbumToDelete(); | ||||
|   }; | ||||
|  | ||||
|   const setAlbumToDelete = () => { | ||||
|     albumToDelete = $contextMenuTargetAlbum ?? null; | ||||
|     closeAlbumContextMenu(); | ||||
|   }; | ||||
|  | ||||
|   const deleteSelectedAlbum = async () => { | ||||
|     if (!albumToDelete) { | ||||
|       return; | ||||
|     } | ||||
|     try { | ||||
|       await deleteAlbum(albumToDelete); | ||||
|     } catch { | ||||
|       notificationController.show({ | ||||
|         message: 'Error deleting album', | ||||
|         type: NotificationType.Error, | ||||
|       }); | ||||
|     } finally { | ||||
|       albumToDelete = null; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   $: { | ||||
|     for (const key in sortByOptions) { | ||||
|       if (sortByOptions[key].title === $albumViewSettings.sortBy) { | ||||
|         $albums = sortByOptions[key].sortFn(sortByOptions[key].sortDesc, $unsortedAlbums); | ||||
|         $albumViewSettings.sortDesc = sortByOptions[key].sortDesc; // "Save" sortDesc | ||||
|         $albumViewSettings.sortBy = sortByOptions[key].title; | ||||
|         break; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   $: albumsFiltered = $albums.filter((album) => album.albumName.toLowerCase().includes(searchAlbum.toLowerCase())); | ||||
|  | ||||
|   const searchSort = (searched: string): Sort => { | ||||
|     for (const key in sortByOptions) { | ||||
|       if (sortByOptions[key].title === searched) { | ||||
|         return sortByOptions[key]; | ||||
|       } | ||||
|     } | ||||
|     return sortByOptions[0]; | ||||
|   }; | ||||
|  | ||||
|   const handleCreateAlbum = async () => { | ||||
|     const newAlbum = await createAlbum(); | ||||
|     if (newAlbum) { | ||||
|       await goto(`${AppRoute.ALBUMS}/${newAlbum.id}`); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const dateLocaleString = (dateString: string) => { | ||||
|     return new Date(dateString).toLocaleDateString($locale, dateFormats.album); | ||||
|   }; | ||||
|  | ||||
|   onMount(async () => { | ||||
|     await removeAlbumsIfEmpty(); | ||||
|   }); | ||||
|  | ||||
|   const removeAlbumsIfEmpty = async () => { | ||||
|     try { | ||||
|       for (const album of $albums) { | ||||
|         if (album.assetCount == 0 && album.albumName == '') { | ||||
|           await deleteAlbum(album); | ||||
|         } | ||||
|       } | ||||
|     } catch (error) { | ||||
|       console.log(error); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const successModifyAlbum = () => { | ||||
|     shouldShowEditUserForm = false; | ||||
|     notificationController.show({ | ||||
|       message: 'Album infos updated', | ||||
|       type: NotificationType.Info, | ||||
|     }); | ||||
|     $albums[$albums.findIndex((x) => x.id === selectedAlbum.id)] = selectedAlbum; | ||||
|   }; | ||||
|  | ||||
|   const handleChangeListMode = () => { | ||||
|     $albumViewSettings.view = | ||||
|       $albumViewSettings.view === AlbumViewMode.Cover ? AlbumViewMode.List : AlbumViewMode.Cover; | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| {#if shouldShowEditUserForm} | ||||
|   <FullScreenModal onClose={() => (shouldShowEditUserForm = false)}> | ||||
|     <EditAlbumForm | ||||
|       album={selectedAlbum} | ||||
|       on:editSuccess={() => successModifyAlbum()} | ||||
|       on:cancel={() => (shouldShowEditUserForm = false)} | ||||
|     /> | ||||
|   </FullScreenModal> | ||||
| {/if} | ||||
|  | ||||
| <UserPageLayout title={data.meta.title}> | ||||
|   <div class="flex place-items-center gap-2" slot="buttons"> | ||||
|     <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 in sortByOptions) { | ||||
|           if (sortByOptions[key].title === event.detail.title) { | ||||
|             sortByOptions[key].sortDesc = !sortByOptions[key].sortDesc; | ||||
|             $albumViewSettings.sortBy = sortByOptions[key].title; | ||||
|           } | ||||
|         } | ||||
|       }} | ||||
|     /> | ||||
|  | ||||
|     <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> | ||||
|     <AlbumsControls bind:searchAlbum /> | ||||
|   </div> | ||||
|   {#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={(e) => showAlbumContextMenu(e.detail, album)} | ||||
|             /> | ||||
|           </a> | ||||
|         {/each} | ||||
|       </div> | ||||
|     {:else if $albumViewSettings.view === AlbumViewMode.List} | ||||
|       <table class="mt-2 w-full text-left"> | ||||
|         <thead | ||||
|           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 Object.keys(sortByOptions) as key (key)} | ||||
|               <TableHeader bind:albumViewSettings={$albumViewSettings.sortBy} bind:option={sortByOptions[key]} /> | ||||
|             {/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}`)} | ||||
|               on:keydown={(event) => event.key === 'Enter' && goto(`${AppRoute.ALBUMS}/${album.id}`)} | ||||
|               tabindex="0" | ||||
|             > | ||||
|               <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`} | ||||
|                 </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%]"> | ||||
|                 <button | ||||
|                   on:click|stopPropagation={() => handleEdit(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={() => chooseAlbumToDelete(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> | ||||
|               </td> | ||||
|             </tr> | ||||
|           {/each} | ||||
|         </tbody> | ||||
|       </table> | ||||
|     {/if} | ||||
|  | ||||
|     <!-- Empty Message --> | ||||
|   {:else} | ||||
|     <EmptyPlaceholder text="Create an album to organize your photos and videos" onClick={handleCreateAlbum} /> | ||||
|   {/if} | ||||
|   <Albums albums={data.albums} {searchAlbum} /> | ||||
| </UserPageLayout> | ||||
|  | ||||
| <!-- Context Menu --> | ||||
| {#if $isShowContextMenu} | ||||
|   <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> | ||||
| {/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} | ||||
|   | ||||
| @@ -1,73 +0,0 @@ | ||||
| import type { OnShowContextMenuDetail } from '$lib/components/album-page/album-card'; | ||||
| import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; | ||||
| import { asyncTimeout } from '$lib/utils'; | ||||
| import { handleError } from '$lib/utils/handle-error'; | ||||
| import { createAlbum, deleteAlbum, getAllAlbums, type AlbumResponseDto } from '@immich/sdk'; | ||||
| import { derived, get, writable } from 'svelte/store'; | ||||
|  | ||||
| type AlbumsProperties = { albums: AlbumResponseDto[] }; | ||||
|  | ||||
| export const useAlbums = (properties: AlbumsProperties) => { | ||||
|   const albums = writable([...properties.albums]); | ||||
|   const contextMenuPosition = writable<OnShowContextMenuDetail>({ x: 0, y: 0 }); | ||||
|   const contextMenuTargetAlbum = writable<AlbumResponseDto | undefined>(); | ||||
|   const isShowContextMenu = derived(contextMenuTargetAlbum, ($selectedAlbum) => !!$selectedAlbum); | ||||
|  | ||||
|   async function loadAlbums(): Promise<void> { | ||||
|     try { | ||||
|       const data = await getAllAlbums({}); | ||||
|       albums.set(data); | ||||
|  | ||||
|       // Delete album that has no photos and is named '' | ||||
|       for (const album of data) { | ||||
|         if (album.albumName === '' && album.assetCount === 0) { | ||||
|           await asyncTimeout(500); | ||||
|           await handleDeleteAlbum(album); | ||||
|         } | ||||
|       } | ||||
|     } catch { | ||||
|       notificationController.show({ | ||||
|         message: 'Error loading albums', | ||||
|         type: NotificationType.Error, | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async function handleCreateAlbum(): Promise<AlbumResponseDto | undefined> { | ||||
|     try { | ||||
|       return await createAlbum({ createAlbumDto: { albumName: '' } }); | ||||
|     } catch (error) { | ||||
|       handleError(error, 'Unable to create album'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async function handleDeleteAlbum(albumToDelete: AlbumResponseDto): Promise<void> { | ||||
|     await deleteAlbum({ id: albumToDelete.id }); | ||||
|     albums.set(get(albums).filter(({ id }) => id !== albumToDelete.id)); | ||||
|   } | ||||
|  | ||||
|   function showAlbumContextMenu(contextMenuDetail: OnShowContextMenuDetail, album: AlbumResponseDto): void { | ||||
|     contextMenuTargetAlbum.set(album); | ||||
|  | ||||
|     contextMenuPosition.set({ | ||||
|       x: contextMenuDetail.x, | ||||
|       y: contextMenuDetail.y, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   function closeAlbumContextMenu() { | ||||
|     contextMenuTargetAlbum.set(undefined); | ||||
|   } | ||||
|  | ||||
|   return { | ||||
|     albums, | ||||
|     isShowContextMenu, | ||||
|     contextMenuPosition, | ||||
|     contextMenuTargetAlbum, | ||||
|     loadAlbums, | ||||
|     createAlbum: handleCreateAlbum, | ||||
|     deleteAlbum: handleDeleteAlbum, | ||||
|     showAlbumContextMenu, | ||||
|     closeAlbumContextMenu, | ||||
|   }; | ||||
| }; | ||||
		Reference in New Issue
	
	Block a user