mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00: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
27 changed files with 1352 additions and 621 deletions
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}`;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue