瀏覽代碼

fix(web): sorting options for albums (#5233)

* fix: albums

* pr feedback

* fix: current behavior

* rename

* fix: album metadatas

* fix: tests

* fix: e2e test

* simplify

* fix: cover shared links

* rename function

* merge main

* merge main

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
martin 1 年之前
父節點
當前提交
3aa2927dae

+ 66 - 14
server/src/domain/album/album.service.spec.ts

@@ -58,9 +58,9 @@ describe(AlbumService.name, () => {
   describe('getAll', () => {
     it('gets list of albums for auth user', async () => {
       albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]);
-      albumMock.getAssetCountForIds.mockResolvedValue([
-        { albumId: albumStub.empty.id, assetCount: 0 },
-        { albumId: albumStub.sharedWithUser.id, assetCount: 0 },
+      albumMock.getMetadataForIds.mockResolvedValue([
+        { albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined },
+        { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined },
       ]);
       albumMock.getInvalidThumbnail.mockResolvedValue([]);
 
@@ -72,7 +72,14 @@ describe(AlbumService.name, () => {
 
     it('gets list of albums that have a specific asset', async () => {
       albumMock.getByAssetId.mockResolvedValue([albumStub.oneAsset]);
-      albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.oneAsset.id, assetCount: 1 }]);
+      albumMock.getMetadataForIds.mockResolvedValue([
+        {
+          albumId: albumStub.oneAsset.id,
+          assetCount: 1,
+          startDate: new Date('1970-01-01'),
+          endDate: new Date('1970-01-01'),
+        },
+      ]);
       albumMock.getInvalidThumbnail.mockResolvedValue([]);
 
       const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id });
@@ -83,7 +90,9 @@ describe(AlbumService.name, () => {
 
     it('gets list of albums that are shared', async () => {
       albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]);
-      albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.sharedWithUser.id, assetCount: 0 }]);
+      albumMock.getMetadataForIds.mockResolvedValue([
+        { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined },
+      ]);
       albumMock.getInvalidThumbnail.mockResolvedValue([]);
 
       const result = await sut.getAll(authStub.admin, { shared: true });
@@ -94,7 +103,9 @@ describe(AlbumService.name, () => {
 
     it('gets list of albums that are NOT shared', async () => {
       albumMock.getNotShared.mockResolvedValue([albumStub.empty]);
-      albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.empty.id, assetCount: 0 }]);
+      albumMock.getMetadataForIds.mockResolvedValue([
+        { albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined },
+      ]);
       albumMock.getInvalidThumbnail.mockResolvedValue([]);
 
       const result = await sut.getAll(authStub.admin, { shared: false });
@@ -106,7 +117,14 @@ describe(AlbumService.name, () => {
 
   it('counts assets correctly', async () => {
     albumMock.getOwned.mockResolvedValue([albumStub.oneAsset]);
-    albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.oneAsset.id, assetCount: 1 }]);
+    albumMock.getMetadataForIds.mockResolvedValue([
+      {
+        albumId: albumStub.oneAsset.id,
+        assetCount: 1,
+        startDate: new Date('1970-01-01'),
+        endDate: new Date('1970-01-01'),
+      },
+    ]);
     albumMock.getInvalidThumbnail.mockResolvedValue([]);
 
     const result = await sut.getAll(authStub.admin, {});
@@ -118,8 +136,13 @@ describe(AlbumService.name, () => {
 
   it('updates the album thumbnail by listing all albums', async () => {
     albumMock.getOwned.mockResolvedValue([albumStub.oneAssetInvalidThumbnail]);
-    albumMock.getAssetCountForIds.mockResolvedValue([
-      { albumId: albumStub.oneAssetInvalidThumbnail.id, assetCount: 1 },
+    albumMock.getMetadataForIds.mockResolvedValue([
+      {
+        albumId: albumStub.oneAssetInvalidThumbnail.id,
+        assetCount: 1,
+        startDate: new Date('1970-01-01'),
+        endDate: new Date('1970-01-01'),
+      },
     ]);
     albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.oneAssetInvalidThumbnail.id]);
     albumMock.update.mockResolvedValue(albumStub.oneAssetValidThumbnail);
@@ -134,8 +157,13 @@ describe(AlbumService.name, () => {
 
   it('removes the thumbnail for an empty album', async () => {
     albumMock.getOwned.mockResolvedValue([albumStub.emptyWithInvalidThumbnail]);
-    albumMock.getAssetCountForIds.mockResolvedValue([
-      { albumId: albumStub.emptyWithInvalidThumbnail.id, assetCount: 1 },
+    albumMock.getMetadataForIds.mockResolvedValue([
+      {
+        albumId: albumStub.emptyWithInvalidThumbnail.id,
+        assetCount: 1,
+        startDate: new Date('1970-01-01'),
+        endDate: new Date('1970-01-01'),
+      },
     ]);
     albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.emptyWithInvalidThumbnail.id]);
     albumMock.update.mockResolvedValue(albumStub.emptyWithValidThumbnail);
@@ -413,10 +441,18 @@ describe(AlbumService.name, () => {
     it('should get a shared album', async () => {
       albumMock.getById.mockResolvedValue(albumStub.oneAsset);
       accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id]));
+      albumMock.getMetadataForIds.mockResolvedValue([
+        {
+          albumId: albumStub.oneAsset.id,
+          assetCount: 1,
+          startDate: new Date('1970-01-01'),
+          endDate: new Date('1970-01-01'),
+        },
+      ]);
 
       await sut.get(authStub.admin, albumStub.oneAsset.id, {});
 
-      expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true });
+      expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: false });
       expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(
         authStub.admin.id,
         new Set([albumStub.oneAsset.id]),
@@ -426,10 +462,18 @@ describe(AlbumService.name, () => {
     it('should get a shared album via a shared link', async () => {
       albumMock.getById.mockResolvedValue(albumStub.oneAsset);
       accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123']));
+      albumMock.getMetadataForIds.mockResolvedValue([
+        {
+          albumId: albumStub.oneAsset.id,
+          assetCount: 1,
+          startDate: new Date('1970-01-01'),
+          endDate: new Date('1970-01-01'),
+        },
+      ]);
 
       await sut.get(authStub.adminSharedLink, 'album-123', {});
 
-      expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
+      expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: false });
       expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith(
         authStub.adminSharedLink.sharedLinkId,
         new Set(['album-123']),
@@ -439,10 +483,18 @@ describe(AlbumService.name, () => {
     it('should get a shared album via shared with user', async () => {
       albumMock.getById.mockResolvedValue(albumStub.oneAsset);
       accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123']));
+      albumMock.getMetadataForIds.mockResolvedValue([
+        {
+          albumId: albumStub.oneAsset.id,
+          assetCount: 1,
+          startDate: new Date('1970-01-01'),
+          endDate: new Date('1970-01-01'),
+        },
+      ]);
 
       await sut.get(authStub.user1, 'album-123', {});
 
-      expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
+      expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: false });
       expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, new Set(['album-123']));
     });
 

+ 27 - 7
server/src/domain/album/album.service.ts

@@ -6,6 +6,7 @@ import { AuthUserDto } from '../auth';
 import { setUnion } from '../domain.util';
 import { JobName } from '../job';
 import {
+  AlbumAssetCount,
   AlbumInfoOptions,
   IAccessRepository,
   IAlbumRepository,
@@ -69,11 +70,19 @@ export class AlbumService {
 
     // Get asset count for each album. Then map the result to an object:
     // { [albumId]: assetCount }
-    const albumsAssetCount = await this.albumRepository.getAssetCountForIds(albums.map((album) => album.id));
-    const albumsAssetCountObj = albumsAssetCount.reduce((obj: Record<string, number>, { albumId, assetCount }) => {
-      obj[albumId] = assetCount;
-      return obj;
-    }, {});
+    const albumMetadataForIds = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id));
+    const albumMetadataForIdsObj: Record<string, AlbumAssetCount> = albumMetadataForIds.reduce(
+      (obj: Record<string, AlbumAssetCount>, { albumId, assetCount, startDate, endDate }) => {
+        obj[albumId] = {
+          albumId,
+          assetCount,
+          startDate,
+          endDate,
+        };
+        return obj;
+      },
+      {},
+    );
 
     return Promise.all(
       albums.map(async (album) => {
@@ -81,7 +90,9 @@ export class AlbumService {
         return {
           ...mapAlbumWithoutAssets(album),
           sharedLinks: undefined,
-          assetCount: albumsAssetCountObj[album.id],
+          startDate: albumMetadataForIdsObj[album.id].startDate,
+          endDate: albumMetadataForIdsObj[album.id].endDate,
+          assetCount: albumMetadataForIdsObj[album.id].assetCount,
           lastModifiedAssetTimestamp: lastModifiedAsset?.fileModifiedAt,
         };
       }),
@@ -91,7 +102,16 @@ export class AlbumService {
   async get(authUser: AuthUserDto, id: string, dto: AlbumInfoDto): Promise<AlbumResponseDto> {
     await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
     await this.albumRepository.updateThumbnails();
-    return mapAlbum(await this.findOrFail(id, { withAssets: true }), !dto.withoutAssets);
+    const withAssets = dto.withoutAssets === undefined ? false : !dto.withoutAssets;
+    const album = await this.findOrFail(id, { withAssets });
+    const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]);
+
+    return {
+      ...mapAlbum(album, withAssets),
+      startDate: albumMetadataForIds.startDate,
+      endDate: albumMetadataForIds.endDate,
+      assetCount: albumMetadataForIds.assetCount,
+    };
   }
 
   async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {

+ 3 - 1
server/src/domain/repositories/album.repository.ts

@@ -5,6 +5,8 @@ export const IAlbumRepository = 'IAlbumRepository';
 export interface AlbumAssetCount {
   albumId: string;
   assetCount: number;
+  startDate: Date | undefined;
+  endDate: Date | undefined;
 }
 
 export interface AlbumInfoOptions {
@@ -30,7 +32,7 @@ export interface IAlbumRepository {
   hasAsset(asset: AlbumAsset): Promise<boolean>;
   removeAsset(assetId: string): Promise<void>;
   removeAssets(assets: AlbumAssets): Promise<void>;
-  getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]>;
+  getMetadataForIds(ids: string[]): Promise<AlbumAssetCount[]>;
   getInvalidThumbnail(): Promise<string[]>;
   getOwned(ownerId: string): Promise<AlbumEntity[]>;
   getShared(ownerId: string): Promise<AlbumEntity[]>;

+ 12 - 7
server/src/infra/repositories/album.repository.ts

@@ -59,25 +59,30 @@ export class AlbumRepository implements IAlbumRepository {
     });
   }
 
-  async getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]> {
+  async getMetadataForIds(ids: string[]): Promise<AlbumAssetCount[]> {
     // Guard against running invalid query when ids list is empty.
     if (!ids.length) {
       return [];
     }
 
     // Only possible with query builder because of GROUP BY.
-    const countByAlbums = await this.repository
+    const albumMetadatas = await this.repository
       .createQueryBuilder('album')
       .select('album.id')
-      .addSelect('COUNT(albums_assets.assetsId)', 'asset_count')
-      .leftJoin('albums_assets_assets', 'albums_assets', 'albums_assets.albumsId = album.id')
+      .addSelect('MIN(assets.fileCreatedAt)', 'start_date')
+      .addSelect('MAX(assets.fileCreatedAt)', 'end_date')
+      .addSelect('COUNT(album_assets.assetsId)', 'asset_count')
+      .leftJoin('albums_assets_assets', 'album_assets', 'album_assets.albumsId = album.id')
+      .leftJoin('assets', 'assets', 'assets.id = album_assets.assetsId')
       .where('album.id IN (:...ids)', { ids })
       .groupBy('album.id')
       .getRawMany();
 
-    return countByAlbums.map<AlbumAssetCount>((albumCount) => ({
-      albumId: albumCount['album_id'],
-      assetCount: Number(albumCount['asset_count']),
+    return albumMetadatas.map<AlbumAssetCount>((metadatas) => ({
+      albumId: metadatas['album_id'],
+      assetCount: Number(metadatas['asset_count']),
+      startDate: metadatas['end_date'] ? new Date(metadatas['start_date']) : undefined,
+      endDate: metadatas['end_date'] ? new Date(metadatas['end_date']) : undefined,
     }));
   }
 

+ 2 - 2
server/test/e2e/album.e2e-spec.ts

@@ -246,7 +246,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
 
     it('should return album info for own album', async () => {
       const { status, body } = await request(server)
-        .get(`/album/${user1Albums[0].id}`)
+        .get(`/album/${user1Albums[0].id}?withoutAssets=false`)
         .set('Authorization', `Bearer ${user1.accessToken}`);
 
       expect(status).toBe(200);
@@ -255,7 +255,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
 
     it('should return album info for shared album', async () => {
       const { status, body } = await request(server)
-        .get(`/album/${user2Albums[0].id}`)
+        .get(`/album/${user2Albums[0].id}?withoutAssets=false`)
         .set('Authorization', `Bearer ${user1.accessToken}`);
 
       expect(status).toBe(200);

+ 1 - 1
server/test/repositories/album.repository.mock.ts

@@ -5,7 +5,7 @@ export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
     getById: jest.fn(),
     getByIds: jest.fn(),
     getByAssetId: jest.fn(),
-    getAssetCountForIds: jest.fn(),
+    getMetadataForIds: jest.fn(),
     getInvalidThumbnail: jest.fn(),
     getOwned: jest.fn(),
     getShared: jest.fn(),

+ 4 - 4
web/src/lib/components/elements/table-header.svelte

@@ -5,10 +5,10 @@
   export let option: Sort;
 
   const handleSort = () => {
-    if (albumViewSettings === option.sortTitle) {
+    if (albumViewSettings === option.title) {
       option.sortDesc = !option.sortDesc;
     } else {
-      albumViewSettings = option.sortTitle;
+      albumViewSettings = option.title;
     }
   };
 </script>
@@ -18,12 +18,12 @@
     class="rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
     on:click={() => handleSort()}
   >
-    {#if albumViewSettings === option.sortTitle}
+    {#if albumViewSettings === option.title}
       {#if option.sortDesc}
         &#8595;
       {:else}
         &#8593;
       {/if}
-    {/if}{option.table}</button
+    {/if}{option.title}</button
   ></th
 >

+ 19 - 8
web/src/lib/components/sharedlinks-page/shared-link-card.svelte

@@ -7,13 +7,14 @@
   import { createEventDispatcher } from 'svelte';
   import { goto } from '$app/navigation';
   import { mdiCircleEditOutline, mdiContentCopy, mdiDelete, mdiOpenInNew } from '@mdi/js';
+  import noThumbnailUrl from '$lib/assets/no-thumbnail.png';
 
   export let link: SharedLinkResponseDto;
 
   let expirationCountdown: luxon.DurationObjectUnits;
   const dispatch = createEventDispatcher();
 
-  const getAssetInfo = async (): Promise<AssetResponseDto> => {
+  const getThumbnail = async (): Promise<AssetResponseDto> => {
     let assetId = '';
 
     if (link.album?.albumThumbnailAssetId) {
@@ -60,18 +61,28 @@
   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>
-    {#await getAssetInfo()}
-      <LoadingSpinner />
-    {:then asset}
+    {#if link?.album?.albumThumbnailAssetId || link.assets.length > 0}
+      {#await getThumbnail()}
+        <LoadingSpinner />
+      {:then asset}
+        <img
+          id={asset.id}
+          src={api.getAssetThumbnailUrl(asset.id, ThumbnailFormat.Webp)}
+          alt={asset.id}
+          class="h-[100px] w-[100px] rounded-lg object-cover"
+          loading="lazy"
+          draggable="false"
+        />
+      {/await}
+    {:else}
       <img
-        id={asset.id}
-        src={api.getAssetThumbnailUrl(asset.id, ThumbnailFormat.Webp)}
-        alt={asset.id}
+        src={noThumbnailUrl}
+        alt={'Album without assets'}
         class="h-[100px] w-[100px] rounded-lg object-cover"
         loading="lazy"
         draggable="false"
       />
-    {/await}
+    {/if}
   </div>
 
   <div class="flex flex-col justify-between">

+ 87 - 35
web/src/routes/(user)/albums/+page.svelte

@@ -1,9 +1,6 @@
 <script lang="ts" context="module">
-  // table is the text printed in the table and sortTitle is the text printed in the dropDow menu
-
   export interface Sort {
-    table: string;
-    sortTitle: string;
+    title: string;
     sortDesc: boolean;
     widthClass: string;
     sortFn: (reverse: boolean, albums: AlbumResponseDto[]) => AlbumResponseDto[];
@@ -54,46 +51,75 @@
 
   let sortByOptions: Record<string, Sort> = {
     albumTitle: {
-      table: 'Album title',
-      sortTitle: 'Album title',
+      title: 'Album title',
       sortDesc: $albumViewSettings.sortDesc, // Load Sort Direction
-      widthClass: 'w-8/12 text-left sm:w-4/12 md:w-4/12 md:w-4/12 2xl:w-6/12',
+      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: {
-      table: 'Assets',
-      sortTitle: 'Number of assets',
+      title: 'Number of assets',
       sortDesc: $albumViewSettings.sortDesc,
-      widthClass: 'w-4/12 text-center sm:w-2/12 2xl:w-1/12',
+      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: {
-      table: 'Updated date',
-      sortTitle: 'Last modified',
+      title: 'Last modified',
       sortDesc: $albumViewSettings.sortDesc,
-      widthClass: 'text-center hidden sm:block w-3/12 lg:w-2/12',
+      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: {
-      table: 'Created date',
-      sortTitle: 'Most recent photo',
+      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 sm:block w-3/12 lg:w-2/12',
+      widthClass: 'text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]',
       sortFn: (reverse, albums) => {
         return orderBy(
           albums,
-          [
-            (album) =>
-              album.lastModifiedAssetTimestamp ? new Date(album.lastModifiedAssetTimestamp) : new Date(album.updatedAt),
-          ],
+          [(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;
+        });
       },
     },
   };
@@ -144,16 +170,25 @@
   };
 
   $: {
-    const { sortBy } = $albumViewSettings;
     for (const key in sortByOptions) {
-      if (sortByOptions[key].sortTitle === sortBy) {
+      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;
       }
     }
   }
 
+  const test = (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) {
@@ -220,19 +255,20 @@
 
     <Dropdown
       options={Object.values(sortByOptions)}
+      selectedOption={test($albumViewSettings.sortBy)}
       render={(option) => {
         return {
-          title: option.sortTitle,
+          title: option.title,
           icon: option.sortDesc ? mdiArrowDownThin : mdiArrowUpThin,
         };
       }}
       on:select={(event) => {
         for (const key in sortByOptions) {
-          if (sortByOptions[key].sortTitle === event.detail.sortTitle) {
+          if (sortByOptions[key].title === event.detail.title) {
             sortByOptions[key].sortDesc = !sortByOptions[key].sortDesc;
+            $albumViewSettings.sortBy = sortByOptions[key].title;
           }
         }
-        $albumViewSettings.sortBy = event.detail.sortTitle;
       }}
     />
 
@@ -271,7 +307,7 @@
             {#each Object.keys(sortByOptions) as key (key)}
               <TableHeader bind:albumViewSettings={$albumViewSettings.sortBy} bind:option={sortByOptions[key]} />
             {/each}
-            <th class="hidden w-2/12 text-center text-sm font-medium lg:block 2xl:w-1/12">Action</th>
+            <th class="hidden text-center text-sm font-medium 2xl:block 2xl:w-[12%]">Action</th>
           </tr>
         </thead>
         <tbody
@@ -284,18 +320,34 @@
               on:keydown={(event) => event.key === 'Enter' && goto(`albums/${album.id}`)}
               tabindex="0"
             >
-              <td class="text-md w-8/12 text-ellipsis text-left sm:w-4/12 md:w-4/12 2xl:w-6/12">{album.albumName}</td>
-              <td class="text-md w-4/12 text-ellipsis text-center sm:w-2/12 md:w-2/12 2xl:w-1/12">
+              <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 ? `item` : `items`}
+                {album.assetCount > 1 ? `items` : `item`}
               </td>
-              <td class="text-md hidden w-3/12 text-ellipsis text-center sm:block lg:w-2/12"
-                >{dateLocaleString(album.updatedAt)}</td
-              >
-              <td class="text-md hidden w-3/12 text-ellipsis text-center sm:block lg:w-2/12"
+              <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 hidden w-2/12 text-ellipsis text-center lg:block 2xl:w-1/12">
+              <td class="text-md text-ellipsis text-center hidden xl:block xl:w-[15%] 2xl:w-[12%]">
+                {#if album.endDate}
+                  {dateLocaleString(album.endDate)}
+                {:else}
+                  &#10060;
+                {/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}
+                  &#10060;
+                {/if}</td
+              >
+              <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 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"