refactor: album queries (#32668)

This commit is contained in:
Jason Rasmussen
2026-10-24 11:51:43 +03:00
committed by Codex
parent d17640a283
commit e3bf54e2ba
28 changed files with 508 additions and 620 deletions
@@ -1,13 +1,13 @@
<script lang="ts">
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import MapModal from '$lib/modals/MapModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { getAlbumMapMarkersQuery } from '$lib/queries/album.query';
import { navigate } from '$lib/utils/navigation';
import { getAlbumMapMarkers, type AlbumResponseDto, type MapMarkerResponseDto } from '@immich/sdk';
import { type AlbumResponseDto } from '@immich/sdk';
import { IconButton, modalManager } from '@immich/ui';
import { mdiMapOutline } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import { createQuery } from '@tanstack/svelte-query';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
@@ -15,17 +15,12 @@
}
let { album }: Props = $props();
let cancelable: AbortController;
let returnToMap = $state(false);
let mapMarkers: MapMarkerResponseDto[] = $state([]);
onMount(async () => {
mapMarkers = await loadMapMarkers();
});
const query = createQuery(() => getAlbumMapMarkersQuery(album.id));
const mapMarkers = $derived(query.data ?? []);
onDestroy(() => {
cancelable?.abort();
assetViewerManager.showAssetViewer(false);
});
@@ -38,18 +33,6 @@
void onClick();
});
const loadMapMarkers = async () => {
cancelable?.abort();
cancelable = new AbortController();
try {
return await getAlbumMapMarkers({ ...authManager.params, id: album.id }, { signal: cancelable.signal });
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
return [];
}
};
const onClick = async () => {
const assetIds = await modalManager.show(MapModal, { mapMarkers });
if (assetIds) {
@@ -1,9 +1,10 @@
<script lang="ts">
import AlbumCover from '$lib/components/album-page/AlbumCover.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAlbumInfo } from '@immich/sdk';
import { getAlbumQuery } from '$lib/queries/album.query';
import { queryClient } from '$lib/queries/client';
import { IconButton, Text, LoadingSpinner } from '@immich/ui';
import { mdiTrashCanOutline } from '@mdi/js';
import { createQuery } from '@tanstack/svelte-query';
import { t } from 'svelte-i18n';
interface Props {
@@ -12,12 +13,18 @@
}
let { albumId, onDelete }: Props = $props();
const query = createQuery(
() => getAlbumQuery(albumId),
() => queryClient,
);
const album = $derived(query.data);
</script>
<div>
{#await getAlbumInfo({ ...authManager.params, id: albumId })}
{#if query.isPending}
<LoadingSpinner />
{:then album}
{:else if album}
<div class="flex gap-2">
<AlbumCover {album} class="size-24" />
<p
@@ -46,7 +53,7 @@
/>
</div>
</div>
{:catch}
{:else}
<div class="flex justify-between gap-2">
<div class="flex flex-col gap-1">
<Text>{$t('unknown')}</Text>
@@ -63,5 +70,5 @@
/>
</div>
</div>
{/await}
{/if}
</div>
@@ -1,7 +1,6 @@
<script lang="ts">
import AlbumCardGroup from '$lib/components/album-page/AlbumCardGroup.svelte';
import AlbumsTable from '$lib/components/album-page/AlbumsTable.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { handleDeleteAlbum, isAlbumOwner } from '$lib/services/album.service';
import {
@@ -32,8 +31,8 @@
}
let {
ownedAlbums = $bindable([]),
sharedAlbums = $bindable([]),
ownedAlbums = [],
sharedAlbums = [],
searchQuery = '',
userSettings,
allowEdit = false,
@@ -166,29 +165,8 @@
const albumsToRemove = ownedAlbums.filter((album) => album.assetCount === 0 && !album.albumName);
await Promise.allSettled(albumsToRemove.map((album) => handleDeleteAlbum(album, { prompt: false, notify: false })));
};
const findAndUpdate = (albums: AlbumResponseDto[], album: AlbumResponseDto) => {
const target = albums.find(({ id }) => id === album.id);
if (target) {
Object.assign(target, album);
}
return albums;
};
const onAlbumUpdate = (album: AlbumResponseDto) => {
ownedAlbums = findAndUpdate(ownedAlbums, album);
sharedAlbums = findAndUpdate(sharedAlbums, album);
};
const onAlbumDelete = (album: AlbumResponseDto) => {
ownedAlbums = ownedAlbums.filter(({ id }) => id !== album.id);
sharedAlbums = sharedAlbums.filter(({ id }) => id !== album.id);
};
</script>
<OnEvents {onAlbumUpdate} {onAlbumDelete} />
{#if albums.length > 0}
{#if userSettings.view === AlbumViewMode.Cover}
<!-- Album Cards -->
@@ -13,26 +13,20 @@
import { getAssetMediaUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { getParentPath } from '$lib/utils/tree-utils';
import {
AssetMediaSize,
getAllAlbums,
getAssetInfo,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import { createQuery } from '@tanstack/svelte-query';
import { Icon, IconButton, Link, LoadingSpinner, Text } from '@immich/ui';
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import { slide } from 'svelte/transition';
import PersonSidePanel from '../faces-page/PersonSidePanel.svelte';
import OnEvents from '../OnEvents.svelte';
import UserAvatar from '../shared-components/UserAvatar.svelte';
import AlbumListItemDetails from './AlbumListItemDetails.svelte';
import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte';
import { faceManager } from '$lib/stores/face.svelte';
import { getAlbumsByAssetQuery } from '$lib/queries/album.query';
import { createPreloadedQuery } from '$lib/queries/client';
import { getPublicConfigQuery } from '$lib/queries/config-public.query';
@@ -60,20 +54,11 @@
let previousId: string | undefined = $state();
let previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos());
const refreshAlbums = async () => {
if (authManager.isSharedLink) {
return [];
}
try {
return await getAllAlbums({ assetId: asset.id });
} catch (error) {
handleError(error, 'Error getting asset album membership');
return [];
}
};
let albums = $derived(refreshAlbums());
const albumsQuery = createQuery(() => ({
...getAlbumsByAssetQuery(asset.id),
enabled: !authManager.isSharedLink,
}));
const albums = $derived(albumsQuery.data ?? []);
$effect(() => {
if (!previousId) {
@@ -116,8 +101,6 @@
});
</script>
<OnEvents onAlbumAddAssets={() => (albums = refreshAlbums())} />
{#if !assetViewerManager.isEditFacesPanelOpen}
<section class="relative p-2">
<div class="flex place-items-center gap-2">
@@ -345,39 +328,37 @@
</section>
{/if}
{#await albums then albums}
{#if albums.length > 0}
<section class="p-6 dark:text-immich-dark-fg">
<div class="pb-4">
<Text size="small" color="muted">{$t('appears_in')}</Text>
</div>
{#each albums as album (album.id)}
<a href={Route.viewAlbum(album)}>
<div class="flex items-center gap-4 pt-2 hover:cursor-pointer">
<div>
<img
alt={album.albumName}
class="size-12.5 rounded-sm object-cover"
src={album.albumThumbnailAssetId &&
getAssetMediaUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
draggable="false"
/>
</div>
{#if albums.length > 0}
<section class="p-6 dark:text-immich-dark-fg">
<div class="pb-4">
<Text size="small" color="muted">{$t('appears_in')}</Text>
</div>
{#each albums as album (album.id)}
<a href={Route.viewAlbum(album)}>
<div class="flex items-center gap-4 pt-2 hover:cursor-pointer">
<div>
<img
alt={album.albumName}
class="size-12.5 rounded-sm object-cover"
src={album.albumThumbnailAssetId &&
getAssetMediaUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
draggable="false"
/>
</div>
<div class="my-auto">
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
<div class="flex flex-col gap-0 text-sm">
<div>
<AlbumListItemDetails {album} />
</div>
<div class="my-auto">
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
<div class="flex flex-col gap-0 text-sm">
<div>
<AlbumListItemDetails {album} />
</div>
</div>
</div>
</a>
{/each}
</section>
{/if}
{/await}
</div>
</a>
{/each}
</section>
{/if}
{#if authManager.authenticated && authManager.preferences.tags.enabled}
<section class="relative px-2 pb-12 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/svelte';
import { tick } from 'svelte';
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import RecentAlbums from '$lib/components/shared-components/side-bar/RecentAlbums.svelte';
import { albumFactory } from '@test-data/factories/album-factory';
@@ -16,13 +15,8 @@ describe('RecentAlbums component', () => {
sdkMock.getAllAlbums.mockResolvedValueOnce([...albums]);
render(RecentAlbums);
const links = await screen.findAllByRole('link');
expect(sdkMock.getAllAlbums).toHaveBeenCalledOnce();
// wtf
await tick();
await tick();
const links = screen.getAllByRole('link');
expect(links).toHaveLength(3);
// @ts-expect-error pending a type fix: https://github.com/testing-library/jest-dom/pull/742/changes
expect(links[0]).toHaveAttribute('href', `/albums/${albums[2].id}`);
@@ -1,28 +1,12 @@
<script lang="ts">
import { getRecentAlbumsQuery } from '$lib/queries/album.query';
import { queryClient } from '$lib/queries/client';
import { Route } from '$lib/route';
import { userInteraction } from '$lib/stores/user.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getAllAlbums } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { createQuery } from '@tanstack/svelte-query';
let albums = $state(userInteraction.recentAlbums);
const refreshAlbums = async () => {
try {
const allAlbums = await getAllAlbums({});
albums = allAlbums.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()).slice(0, 3);
userInteraction.recentAlbums = albums;
} catch (error) {
handleError(error, $t('failed_to_load_assets'));
}
};
$effect(() => {
if (!userInteraction.recentAlbums) {
void refreshAlbums();
}
});
const query = createQuery(getRecentAlbumsQuery, () => queryClient);
const albums = $derived(query.data ?? []);
</script>
{#each albums as album (album.id)}
@@ -1,6 +1,4 @@
import type {
AlbumResponseDto,
AlbumUserRole,
ApiKeyResponseDto,
AssetResponseDto,
IntegrityReport,
@@ -47,12 +45,6 @@ export type Events = {
AlbumAddAssets: [{ assetIds: string[]; albumIds: string[] }];
AlbumRemoveAssets: [{ assetIds: string[]; albumIds: string[] }];
AlbumCreate: [AlbumResponseDto];
AlbumUpdate: [AlbumResponseDto];
AlbumDelete: [AlbumResponseDto];
AlbumShare: [];
AlbumUserUpdate: [{ albumId: string; userId: string; role: AlbumUserRole }];
AlbumUserDelete: [{ albumId: string; userId: string }];
PersonUpdate: [PersonResponseDto];
PersonShare: [{ personId: string }];
+7 -5
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import AlbumCover from '$lib/components/album-page/AlbumCover.svelte';
import { handleUpdateAlbum } from '$lib/services/album.service';
import { updateAlbumInfoMutation } from '$lib/queries/album.query';
import { executeMutation } from '$lib/queries/client';
import { type AlbumResponseDto } from '@immich/sdk';
import { Field, FormModal, Input, Textarea } from '@immich/ui';
import { mdiRenameOutline } from '@mdi/js';
@@ -17,10 +18,11 @@
let description = $state(album.description);
const onSubmit = async () => {
const success = await handleUpdateAlbum(album, { albumName, description: description || null });
if (success) {
onClose();
}
await executeMutation(
updateAlbumInfoMutation,
[{ id: album.id, updateAlbumDto: { albumName, description: description || null } }],
{ onSuccess: onClose },
);
};
</script>
+24 -25
View File
@@ -1,19 +1,20 @@
<script lang="ts">
import AlbumSharedLink from '$lib/components/album-page/AlbumSharedLink.svelte';
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
import { getAlbumActions, handleRemoveUserFromAlbum, handleUpdateUserAlbumRole } from '$lib/services/album.service';
import { getAlbumQuery, updateAlbumInfoMutation } from '$lib/queries/album.query';
import { executeMutation, queryClient } from '$lib/queries/client';
import { getAllSharedLinksQuery } from '$lib/queries/shared-link.query';
import {
getAlbumActions,
handleRemoveUserFromAlbum,
handleUpdateAlbum,
handleUpdateUserAlbumRole,
} from '$lib/services/album.service';
import { AlbumUserRole, AssetOrder, getAlbumInfo, type AlbumResponseDto, type UserResponseDto } from '@immich/sdk';
AlbumUserRole,
AssetOrder,
type AlbumResponseDto,
type UpdateAlbumDto,
type UserResponseDto,
} from '@immich/sdk';
import { Field, HStack, Modal, ModalBody, Select, Stack, Switch, Text, type SelectOption } from '@immich/ui';
import { createQuery } from '@tanstack/svelte-query';
import { queryClient } from '$lib/queries/client';
import { getAllSharedLinksQuery } from '$lib/queries/shared-link.query';
import { t } from 'svelte-i18n';
type Props = {
@@ -22,7 +23,13 @@
onClose: () => void;
};
let { album, readOnly = false, onClose }: Props = $props();
let { album: initialAlbum, readOnly = false, onClose }: Props = $props();
const query = createQuery(
() => getAlbumQuery(initialAlbum.id),
() => queryClient,
);
const album = $derived(query.data ?? initialAlbum);
const handleRoleSelect = async (user: UserResponseDto, role: AlbumUserRole | 'none') => {
if (role === 'none') {
@@ -33,26 +40,18 @@
await handleUpdateUserAlbumRole({ albumId: album.id, userId: user.id, role });
};
const refreshAlbum = async () => {
album = await getAlbumInfo({ id: album.id });
};
const onAlbumUserDelete = async ({ userId }: { userId: string }) => {
album.albumUsers = album.albumUsers.filter(({ user: { id } }) => id !== userId);
await refreshAlbum();
};
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album));
const query = createQuery(
const updateAlbum = (updateAlbumDto: UpdateAlbumDto) =>
executeMutation(updateAlbumInfoMutation, [{ id: album.id, updateAlbumDto }]);
const sharedLinksQuery = createQuery(
() => getAllSharedLinksQuery({ albumId: album.id }),
() => queryClient,
);
const sharedLinks = $derived(query.data ?? []);
const sharedLinks = $derived(sharedLinksQuery.data ?? []);
</script>
<OnEvents {onAlbumUserDelete} onAlbumShare={refreshAlbum} onAlbumUpdate={(newAlbum) => (album = newAlbum)} />
<Modal title={readOnly ? $t('album') : $t('options')} {onClose} size="small">
<ModalBody>
<Stack gap={6}>
@@ -67,14 +66,14 @@
{ label: $t('newest_first'), value: AssetOrder.Desc },
{ label: $t('oldest_first'), value: AssetOrder.Asc },
]}
onChange={(value) => handleUpdateAlbum(album, { order: value })}
onChange={(value) => updateAlbum({ order: value })}
/>
</Field>
{/if}
<Field label={$t('comments_and_likes')} description={$t('let_others_respond')} disabled={readOnly}>
<Switch
checked={album.isActivityEnabled}
onCheckedChange={(checked) => handleUpdateAlbum(album, { isActivityEnabled: checked })}
onCheckedChange={(checked) => updateAlbum({ isActivityEnabled: checked })}
/>
</Field>
</div>
+16 -17
View File
@@ -5,19 +5,25 @@
AlbumModalRowType,
isSelectableRowType,
} from '$lib/components/shared-components/album-selection/album-selection-utils';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { createAlbumMutation, getAllAlbumsQuery, getRecentAlbumsQuery } from '$lib/queries/album.query';
import { executeMutation, queryClient } from '$lib/queries/client';
import { albumViewSettings } from '$lib/stores/preferences.store';
import { createAlbum, getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
import { type AlbumResponseDto } from '@immich/sdk';
import { Button, Icon, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiKeyboardReturn } from '@mdi/js';
import { onMount } from 'svelte';
import { createQuery } from '@tanstack/svelte-query';
import { t } from 'svelte-i18n';
import AlbumListItem from '../components/asset-viewer/AlbumListItem.svelte';
import NewAlbumListItem from '../components/shared-components/album-selection/NewAlbumListItem.svelte';
let albums: AlbumResponseDto[] = $state([]);
let recentAlbums: AlbumResponseDto[] = $state([]);
let loading = $state(true);
const queries = {
albums: createQuery(getAllAlbumsQuery, () => queryClient),
recentAlbums: createQuery(getRecentAlbumsQuery, () => queryClient),
};
const albums = $derived(queries.albums.data ?? []);
const recentAlbums = $derived(queries.recentAlbums.data ?? []);
const loading = $derived(queries.albums.isPending);
let search = $state('');
let selectedRowIndex: number = $state(-1);
@@ -28,12 +34,6 @@
let { onClose, selectedItemsCount }: Props = $props();
onMount(async () => {
albums = await getAllAlbums({});
recentAlbums = [...albums].sort((a, b) => (new Date(a.updatedAt) > new Date(b.updatedAt) ? -1 : 1)).slice(0, 3);
loading = false;
});
const multiSelectedAlbumIds: string[] = $state([]);
const multiSelectActive = $derived(multiSelectedAlbumIds.length > 0);
@@ -43,11 +43,10 @@
);
const selectableRowCount = $derived(albumModalRows.filter((row) => isSelectableRowType(row.type)).length);
const onNewAlbum = async (name: string) => {
const album = await createAlbum({ createAlbumDto: { albumName: name } });
eventManager.emit('AlbumCreate', album);
onClose([album]);
};
const onNewAlbum = (name: string) =>
executeMutation(createAlbumMutation, [{ createAlbumDto: { albumName: name } }], {
onSuccess: (album) => onClose([album]),
});
const handleAlbumClick = (album?: AlbumResponseDto) => {
if (multiSelectActive) {
@@ -1,6 +1,7 @@
<script lang="ts">
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import { addAssetsToAlbums } from '$lib/services/album.service';
import { addAssetsToAlbumsMutation } from '$lib/queries/album.query';
import { executeMutation } from '$lib/queries/client';
import { type AlbumResponseDto } from '@immich/sdk';
type Props = {
@@ -17,10 +18,7 @@
return;
}
const success = await addAssetsToAlbums(albumIds, assetIds, { notify: true });
if (success) {
onClose();
}
await executeMutation(addAssetsToAlbumsMutation, { albumIds, assetIds }, { onSuccess: onClose });
};
</script>
+219 -3
View File
@@ -1,8 +1,224 @@
import { getAlbumInfo } from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
import {
addAssetsToAlbum,
addAssetsToAlbums,
addUsersToAlbum,
AlbumUserRole,
BulkIdErrorReason,
createAlbum,
deleteAlbum,
getAlbumInfo,
getAlbumMapMarkers,
getAlbumStatistics,
getAllAlbums,
removeAssetFromAlbum,
removeUserFromAlbum,
updateAlbumInfo,
updateAlbumUser,
type AlbumResponseDto,
type AlbumsAddAssetsResponseDto,
type BulkIdResponseDto,
} from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { mutationOptions, queryOptions, type MutationMeta } from '@tanstack/svelte-query';
import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { asMutation, queryClient } from '$lib/queries/client';
import { Route } from '$lib/route';
import { getFormatter } from '$lib/utils/i18n';
type AlbumsFilter = { isOwned?: boolean; isShared?: boolean };
export const albumsQueryKey = ['albums'];
const isOwned = (album: AlbumResponseDto) =>
authManager.authenticated &&
album.albumUsers.some(({ role, user }) => role === AlbumUserRole.Owner && user.id === authManager.user.id);
const matchesFilter = (filter: AlbumsFilter) => (album: AlbumResponseDto) =>
(filter.isOwned === undefined || isOwned(album) === filter.isOwned) &&
(filter.isShared === undefined || album.shared === filter.isShared);
const findOrFail = (albums: AlbumResponseDto[], id: string) => {
const album = albums.find((album) => album.id === id);
if (!album) {
throw new Error(`Album ${id} not found`);
}
return album;
};
export const getAllAlbumsQuery = (filter: AlbumsFilter = {}) =>
queryOptions({
queryKey: albumsQueryKey,
queryFn: ({ signal }) => getAllAlbums({}, { signal }),
select: (albums) => albums.filter(matchesFilter(filter)),
});
export const getRecentAlbumsQuery = () =>
queryOptions({
...getAllAlbumsQuery(),
select: (albums) =>
[...albums].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()).slice(0, 3),
});
export const getAlbumQuery = (id: string) =>
queryOptions({
...getAllAlbumsQuery(),
select: (albums) => findOrFail(albums, id),
});
export const getAlbumsByAssetQuery = (assetId: string) =>
queryOptions({
queryKey: [...albumsQueryKey, 'asset', assetId],
queryFn: ({ signal }) => getAllAlbums({ assetId }, { signal }),
});
export const getAlbumStatisticsQuery = () =>
queryOptions({
queryKey: [...albumsQueryKey, 'statistics'],
queryFn: ({ signal }) => getAlbumStatistics({ signal }),
});
export const getAlbumInfoQuery = (id: string) =>
queryOptions({
queryKey: ['album', id],
queryFn: ({ signal }) => getAlbumInfo({ id }, { signal }),
queryFn: ({ signal }) => getAlbumInfo({ ...authManager.params, id }, { signal }),
});
export const getAlbumMapMarkersQuery = (id: string) =>
queryOptions({
queryKey: ['album', id, 'map-markers'],
queryFn: ({ signal }) => getAlbumMapMarkers({ ...authManager.params, id }, { signal }),
});
export const invalidateAlbums = () => queryClient.invalidateQueries({ queryKey: albumsQueryKey });
export const invalidateAlbum = (id: string) =>
queryClient.invalidateQueries({ queryKey: getAlbumInfoQuery(id).queryKey, exact: true });
const onAlbumChange = async (album: AlbumResponseDto) => {
queryClient.setQueryData(getAlbumInfoQuery(album.id).queryKey, album);
await Promise.all([invalidateAlbum(album.id), invalidateAlbums()]);
};
const onAlbumAssetsChange = (ids: string[]) =>
Promise.all([...ids.map((id) => invalidateAlbum(id)), invalidateAlbums()]);
export const createAlbumMutation = () =>
asMutation(createAlbum, {
onSuccess: onAlbumChange,
meta: { error: { toast: 'errors.failed_to_create_album' } },
});
const defaultMeta: MutationMeta = {
success: { toast: 'album_info_updated' },
error: { toast: 'errors.unable_to_update_album_info' },
};
export const updateAlbumInfoMutation = (meta?: MutationMeta) =>
asMutation(updateAlbumInfo, { onSuccess: onAlbumChange, meta: meta ?? defaultMeta });
export const deleteAlbumMutation = (meta?: MutationMeta) =>
asMutation(deleteAlbum, {
onSuccess: (_, [{ id }]) => {
queryClient.removeQueries({ queryKey: ['album', id] });
return invalidateAlbums();
},
meta: meta ?? { error: { toast: 'errors.unable_to_delete_album' } },
});
const notifyAddToAlbum = ($t: MessageFormatter, albumId: string, assetIds: string[], results: BulkIdResponseDto[]) => {
const successCount = results.filter(({ success }) => success).length;
const duplicateCount = results.filter(({ error }) => error === 'duplicate').length;
let description: string | undefined;
if (duplicateCount === assetIds.length) {
description = $t('assets_were_part_of_album_count', { values: { count: duplicateCount } });
} else if (successCount === assetIds.length) {
description = $t('assets_added_to_album_count', { values: { count: successCount } });
} else if (successCount > 0) {
description = $t('assets_added_to_album_partial_count', { values: { successCount, totalCount: assetIds.length } });
}
const button = { label: $t('view_album'), onclick: () => goto(Route.viewAlbum({ id: albumId })) };
if (description) {
toastManager.primary({ description, button }, { timeout: 5000 });
return;
}
toastManager.danger(
{ description: $t('assets_cannot_be_added_to_album_count', { values: { count: assetIds.length } }), button },
{ timeout: 5000 },
);
};
const notifyAddToAlbums = (
$t: MessageFormatter,
albumIds: string[],
assetIds: string[],
results: AlbumsAddAssetsResponseDto,
) => {
if (results.error === BulkIdErrorReason.Duplicate) {
toastManager.info($t('assets_were_part_of_albums_count', { values: { count: assetIds.length } }));
} else if (results.error) {
toastManager.warning($t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } }));
} else {
toastManager.primary(
$t('assets_added_to_albums_count', {
values: { albumTotal: albumIds.length, assetTotal: assetIds.length },
}),
);
}
};
export const addAssetsToAlbumsMutation = ({ notify = true }: { notify?: boolean } = {}) =>
mutationOptions({
mutationFn: ({
albumIds,
assetIds,
}: {
albumIds: string[];
assetIds: string[];
}): Promise<BulkIdResponseDto[] | AlbumsAddAssetsResponseDto> =>
albumIds.length === 1
? addAssetsToAlbum({ ...authManager.params, id: albumIds[0], bulkIdsDto: { ids: assetIds } })
: addAssetsToAlbums({ ...authManager.params, albumsAddAssetsDto: { albumIds, assetIds } }),
onSuccess: async (results, { albumIds, assetIds }) => {
eventManager.emit('AlbumAddAssets', { assetIds, albumIds });
if (notify) {
const $t = await getFormatter();
if (Array.isArray(results)) {
notifyAddToAlbum($t, albumIds[0], assetIds, results);
} else {
notifyAddToAlbums($t, albumIds, assetIds, results);
}
}
await onAlbumAssetsChange(albumIds);
},
meta: { error: { toast: 'errors.error_adding_assets_to_album' } },
});
export const removeAssetFromAlbumMutation = () =>
asMutation(removeAssetFromAlbum, {
onSuccess: (_, [{ id }]) => onAlbumAssetsChange([id]),
meta: { error: { toast: 'errors.error_removing_assets_from_album' } },
});
export const addUsersToAlbumMutation = () =>
asMutation(addUsersToAlbum, {
onSuccess: onAlbumChange,
meta: { error: { toast: 'errors.error_adding_users_to_album' } },
});
export const updateAlbumUserMutation = () =>
asMutation(updateAlbumUser, {
onSuccess: (_, [{ id }]) => onAlbumAssetsChange([id]),
meta: { error: { toast: 'errors.unable_to_change_album_user_role' } },
});
export const removeUserFromAlbumMutation = () =>
asMutation(removeUserFromAlbum, {
onSuccess: (_, [{ id }]) => onAlbumAssetsChange([id]),
meta: { error: { toast: 'errors.unable_to_remove_album_users' } },
});
+9 -5
View File
@@ -7,7 +7,7 @@ import {
type SharedLinkResponseDto,
} from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
import { getAlbumInfoQuery } from '$lib/queries/album.query';
import { invalidateAlbum, invalidateAlbums } from '$lib/queries/album.query';
import { asMutation, queryClient } from '$lib/queries/client';
const findOrFail = (sharedLinks: SharedLinkResponseDto[], id: string) => {
@@ -31,15 +31,19 @@ export const getSharedLinkQuery = (id: string) =>
select: (sharedLinks) => findOrFail(sharedLinks, id),
});
const invalidateAlbum = (albumId?: string | null) =>
albumId ? queryClient.invalidateQueries({ queryKey: getAlbumInfoQuery(albumId).queryKey }) : undefined;
const onAlbumSharingChange = async (albumId?: string | null) => {
await invalidateAlbums();
if (albumId) {
await invalidateAlbum(albumId);
}
};
export const createSharedLinkMutation = () =>
asMutation(createSharedLink, {
onSuccess: async (_, [{ sharedLinkCreateDto }]) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: getAllSharedLinksQuery().queryKey, exact: true }),
invalidateAlbum(sharedLinkCreateDto.albumId),
onAlbumSharingChange(sharedLinkCreateDto.albumId),
]);
},
meta: { error: { toast: 'errors.failed_to_create_shared_link' } },
@@ -60,7 +64,7 @@ export const removeSharedLinkMutation = () =>
queryClient.setQueryData(getAllSharedLinksQuery().queryKey, (sharedLinks) =>
sharedLinks?.filter((sharedLink) => sharedLink.id !== id),
);
await invalidateAlbum(albumId);
await onAlbumSharingChange(albumId);
},
meta: { success: { toast: 'deleted_shared_link' }, error: { toast: 'errors.unable_to_delete_shared_link' } },
});
+47 -176
View File
@@ -1,21 +1,5 @@
import {
addAssetsToAlbum as addToAlbum,
addAssetsToAlbums as addToAlbums,
addUsersToAlbum,
AlbumUserRole,
BulkIdErrorReason,
deleteAlbum,
removeUserFromAlbum,
updateAlbumInfo,
updateAlbumUser,
type AlbumResponseDto,
type AlbumsAddAssetsResponseDto,
type AssetResponseDto,
type BulkIdResponseDto,
type UpdateAlbumDto,
type UserResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { AlbumUserRole, type AlbumResponseDto, type UserResponseDto } from '@immich/sdk';
import { modalManager, type ActionItem } from '@immich/ui';
import {
mdiDownload,
mdiImageOutline,
@@ -31,19 +15,26 @@ import {
} from '@mdi/js';
import { type MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import AddUsersModal from '$lib/modals/AddUsersModal.svelte';
import AlbumEditModal from '$lib/modals/AlbumEditModal.svelte';
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import {
addAssetsToAlbumsMutation,
addUsersToAlbumMutation,
deleteAlbumMutation,
removeUserFromAlbumMutation,
updateAlbumInfoMutation,
updateAlbumUserMutation,
} from '$lib/queries/album.query';
import { executeMutation } from '$lib/queries/client';
import { Route } from '$lib/route';
import { userInteraction } from '$lib/stores/user.svelte';
import { createAlbumAndRedirect } from '$lib/utils/album-utils';
import { downloadArchive } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
export const isAlbumOwner = (album: AlbumResponseDto) => album.albumUsers[0].user.id === authManager.user.id;
@@ -129,7 +120,7 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) =
return { AddUsers, CreateSharedLink, Delete, Download, Edit, Leave, Options, Share };
};
export const getAlbumAssetActions = ($t: MessageFormatter, album: AlbumResponseDto, asset: AssetResponseDto) => {
export const getAlbumAssetActions = ($t: MessageFormatter, album: AlbumResponseDto, asset: { id: string }) => {
const SetCover: ActionItem = {
title: $t('set_as_album_cover'),
icon: mdiImageOutline,
@@ -146,11 +137,7 @@ export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponse
icon: mdiPlusBoxOutline,
$if: () => assets.length > 0,
onAction: () =>
addAssetsToAlbums(
[album.id],
assets.map(({ id }) => id),
{ notify: true },
).then(() => undefined),
executeMutation(addAssetsToAlbumsMutation, { albumIds: [album.id], assetIds: assets.map(({ id }) => id) }),
};
const Upload: ActionItem = {
@@ -163,78 +150,13 @@ export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponse
return { AddAssets, Upload };
};
export const addAssetsToAlbums = async (albumIds: string[], assetIds: string[], { notify }: { notify: boolean }) => {
const $t = await getFormatter();
try {
if (albumIds.length === 1) {
const albumId = albumIds[0];
const results = await addToAlbum({ ...authManager.params, id: albumId, bulkIdsDto: { ids: assetIds } });
if (notify) {
notifyAddToAlbum($t, albumId, assetIds, results);
}
}
if (albumIds.length > 1) {
const results = await addToAlbums({ ...authManager.params, albumsAddAssetsDto: { albumIds, assetIds } });
if (notify) {
notifyAddToAlbums($t, albumIds, assetIds, results);
}
}
eventManager.emit('AlbumAddAssets', { assetIds, albumIds });
return true;
} catch (error) {
handleError(error, $t('errors.error_adding_assets_to_album'));
return false;
const leaveAlbumPage = (album: AlbumResponseDto) => {
if (page.params.albumId === album.id) {
void goto(Route.albums());
}
};
const notifyAddToAlbum = ($t: MessageFormatter, albumId: string, assetIds: string[], results: BulkIdResponseDto[]) => {
const successCount = results.filter(({ success }) => success).length;
const duplicateCount = results.filter(({ error }) => error === 'duplicate').length;
let description: string | undefined;
if (duplicateCount === assetIds.length) {
description = $t('assets_were_part_of_album_count', { values: { count: duplicateCount } });
} else if (successCount === assetIds.length) {
description = $t('assets_added_to_album_count', { values: { count: successCount } });
} else if (successCount > 0) {
description = $t('assets_added_to_album_partial_count', { values: { successCount, totalCount: assetIds.length } });
}
const button = { label: $t('view_album'), onclick: () => goto(Route.viewAlbum({ id: albumId })) };
if (description) {
toastManager.primary({ description, button }, { timeout: 5000 });
return;
}
toastManager.danger(
{ description: $t('assets_cannot_be_added_to_album_count', { values: { count: assetIds.length } }), button },
{ timeout: 5000 },
);
};
const notifyAddToAlbums = (
$t: MessageFormatter,
albumIds: string[],
assetIds: string[],
results: AlbumsAddAssetsResponseDto,
) => {
if (results.error === BulkIdErrorReason.Duplicate) {
toastManager.info($t('assets_were_part_of_albums_count', { values: { count: assetIds.length } }));
} else if (results.error) {
toastManager.warning($t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } }));
} else {
toastManager.primary(
$t('assets_added_to_albums_count', {
values: { albumTotal: albumIds.length, assetTotal: assetIds.length },
}),
);
}
};
export const handleUpdateUserAlbumRole = async ({
export const handleUpdateUserAlbumRole = ({
albumId,
userId,
role,
@@ -242,28 +164,14 @@ export const handleUpdateUserAlbumRole = async ({
albumId: string;
userId: string;
role: AlbumUserRole;
}) => {
const $t = await getFormatter();
}) => executeMutation(updateAlbumUserMutation, [{ id: albumId, userId, updateAlbumUserDto: { role } }]);
try {
await updateAlbumUser({ id: albumId, userId, updateAlbumUserDto: { role } });
eventManager.emit('AlbumUserUpdate', { albumId, userId, role });
} catch (error) {
handleError(error, $t('errors.unable_to_change_album_user_role'));
}
};
export const handleAddUsersToAlbum = async (album: AlbumResponseDto, users: UserResponseDto[]) => {
const $t = await getFormatter();
try {
await addUsersToAlbum({ id: album.id, addUsersDto: { albumUsers: users.map(({ id }) => ({ userId: id })) } });
eventManager.emit('AlbumShare');
return true;
} catch (error) {
handleError(error, $t('errors.error_adding_users_to_album'));
}
};
export const handleAddUsersToAlbum = (album: AlbumResponseDto, users: UserResponseDto[], onSuccess?: () => void) =>
executeMutation(
addUsersToAlbumMutation,
[{ id: album.id, addUsersDto: { albumUsers: users.map(({ id }) => ({ userId: id })) } }],
{ onSuccess },
);
export const handleRemoveUserFromAlbum = async (album: AlbumResponseDto, albumUser: UserResponseDto) => {
const $t = await getFormatter();
@@ -278,12 +186,7 @@ export const handleRemoveUserFromAlbum = async (album: AlbumResponseDto, albumUs
return;
}
try {
await removeUserFromAlbum({ id: album.id, userId: albumUser.id });
eventManager.emit('AlbumUserDelete', { albumId: album.id, userId: albumUser.id });
} catch (error) {
handleError(error, $t('errors.unable_to_remove_album_users'));
}
await executeMutation(removeUserFromAlbumMutation, [{ id: album.id, userId: albumUser.id }]);
};
export const handleLeaveAlbum = async (album: AlbumResponseDto) => {
@@ -299,49 +202,20 @@ export const handleLeaveAlbum = async (album: AlbumResponseDto) => {
return;
}
try {
await removeUserFromAlbum({ id: album.id, userId: 'me' });
userInteraction.recentAlbums = undefined;
eventManager.emit('AlbumDelete', album);
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_remove_album_users'));
}
await executeMutation(removeUserFromAlbumMutation, [{ id: album.id, userId: 'me' }], {
onSuccess: () => leaveAlbumPage(album),
});
};
const handleUpdateThumbnail = async (album: AlbumResponseDto, assetId: string) => {
const $t = await getFormatter();
try {
const response = await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
albumThumbnailAssetId: assetId,
},
});
eventManager.emit('AlbumUpdate', response);
toastManager.primary($t('album_cover_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_cover'));
}
};
export const handleUpdateAlbum = async ({ id }: { id: string }, dto: UpdateAlbumDto) => {
const $t = await getFormatter();
try {
const response = await updateAlbumInfo({ id, updateAlbumDto: dto });
eventManager.emit('AlbumUpdate', response);
toastManager.primary({
description: $t('album_info_updated'),
button: { label: $t('view_album'), onclick: () => goto(Route.viewAlbum({ id })) },
});
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_info'));
}
};
const handleUpdateThumbnail = (album: AlbumResponseDto, assetId: string) =>
executeMutation(
() =>
updateAlbumInfoMutation({
success: { toast: 'album_cover_updated' },
error: { toast: 'errors.unable_to_update_album_cover' },
}),
[{ id: album.id, updateAlbumDto: { albumThumbnailAssetId: assetId } }],
);
export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { prompt?: boolean; notify?: boolean }) => {
const $t = await getFormatter();
@@ -355,21 +229,18 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro
const description = $t('album_delete_confirmation_description');
const success = await modalManager.showDialog({ prompt: `${confirmation} ${description}` });
if (!success) {
return false;
return;
}
}
try {
await deleteAlbum({ id: album.id });
eventManager.emit('AlbumDelete', album);
if (notify) {
toastManager.primary();
}
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_delete_album'), { notify });
return false;
}
await executeMutation(
() =>
deleteAlbumMutation(
notify ? { success: { toast: true }, error: { toast: 'errors.unable_to_delete_album' } } : {},
),
[{ id: album.id }],
{ onSuccess: () => leaveAlbumPage(album) },
);
};
export const handleDownloadAlbum = async (album: AlbumResponseDto) => {
+9 -15
View File
@@ -5,7 +5,6 @@ import {
AssetVisibility,
bulkTagAssets,
getAssetInfo,
removeAssetFromAlbum,
runAssetJobs,
updateAsset,
type AlbumResponseDto,
@@ -52,7 +51,8 @@ import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import ProfileImageCropperModal from '$lib/modals/ProfileImageCropperModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { readPreloadedQuery } from '$lib/queries/client';
import { removeAssetFromAlbumMutation } from '$lib/queries/album.query';
import { executeMutation, readPreloadedQuery } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
import { Route } from '$lib/route';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
@@ -518,19 +518,13 @@ const handleBulkRemoveAssetsFromAlbum = async (assetIds: string[], album: AlbumR
const handleRemoveAssetsFromAlbum = async (assetIds: string[], album: AlbumResponseDto) => {
const $t = await getFormatter();
try {
const results = await removeAssetFromAlbum({
id: album.id,
bulkIdsDto: { ids: assetIds },
});
const count = results.filter(({ success }) => success).length;
toastManager.primary($t('assets_removed_count', { values: { count } }));
eventManager.emit('AlbumRemoveAssets', { assetIds, albumIds: [album.id] });
} catch (error) {
handleError(error, $t('errors.error_removing_assets_from_album'));
}
await executeMutation(removeAssetFromAlbumMutation, [{ id: album.id, bulkIdsDto: { ids: assetIds } }], {
onSuccess: (results) => {
const count = results.filter(({ success }) => success).length;
toastManager.primary($t('assets_removed_count', { values: { count } }));
eventManager.emit('AlbumRemoveAssets', { assetIds, albumIds: [album.id] });
},
});
};
const getAssetJobMessage = ($t: MessageFormatter, job: AssetJobName) => {
-28
View File
@@ -1,28 +0,0 @@
import type { AlbumResponseDto } from '@immich/sdk';
import { eventManager } from '$lib/managers/event-manager.svelte';
interface UserInteractions {
recentAlbums?: AlbumResponseDto[];
}
const defaultUserInteraction: UserInteractions = {
recentAlbums: undefined,
};
export const userInteraction = $state<UserInteractions>(defaultUserInteraction);
const resetRecentAlbums = () => {
userInteraction.recentAlbums = undefined;
};
const reset = () => {
Object.assign(userInteraction, defaultUserInteraction);
};
// eslint-disable-next-line unicorn/no-top-level-side-effects
eventManager.on({
AlbumCreate: () => resetRecentAlbums(),
AlbumUpdate: () => resetRecentAlbums(),
AlbumDelete: () => resetRecentAlbums(),
AuthLogout: () => reset(),
});
+6 -26
View File
@@ -1,10 +1,9 @@
import type { AlbumResponseDto } from '@immich/sdk';
import * as sdk from '@immich/sdk';
import { orderBy } from 'lodash-es';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { createAlbumMutation } from '$lib/queries/album.query';
import { executeMutation } from '$lib/queries/client';
import { Route } from '$lib/route';
import {
AlbumFilter,
@@ -15,35 +14,16 @@ import {
locale,
type AlbumViewSettings,
} from '$lib/stores/preferences.store';
import { handleError } from '$lib/utils/handle-error';
/**
* -------------------------
* Albums General Management
* -------------------------
*/
export const createAlbum = async (name?: string, assetIds?: string[]) => {
try {
const newAlbum: AlbumResponseDto = await sdk.createAlbum({
createAlbumDto: {
albumName: name ?? '',
assetIds,
},
});
eventManager.emit('AlbumCreate', newAlbum);
return newAlbum;
} catch (error) {
const $t = get(t);
handleError(error, $t('errors.failed_to_create_album'));
}
};
export const createAlbumAndRedirect = async (name?: string, assetIds?: string[]) => {
const newAlbum = await createAlbum(name, assetIds);
if (newAlbum) {
await goto(Route.viewAlbum(newAlbum));
}
};
export const createAlbumAndRedirect = (name?: string, assetIds?: string[]) =>
executeMutation(createAlbumMutation, [{ createAlbumDto: { albumName: name ?? '', assetIds } }], {
onSuccess: (album) => void goto(Route.viewAlbum(album)),
});
/**
* -------------
+6 -2
View File
@@ -12,7 +12,8 @@ import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { uploadManager } from '$lib/managers/upload-manager.svelte';
import { addAssetsToAlbums } from '$lib/services/album.service';
import { addAssetsToAlbumsMutation } from '$lib/queries/album.query';
import { executeMutation } from '$lib/queries/client';
import { uploadAssetsStore } from '$lib/stores/upload';
import { UploadState } from '$lib/types';
import { uploadRequest } from '$lib/utils';
@@ -221,7 +222,10 @@ async function fileUploader({
if (albumId && !authManager.isSharedLink) {
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_adding_to_album') });
await addAssetsToAlbums([albumId], [responseData.id], { notify: false });
await executeMutation(() => addAssetsToAlbumsMutation({ notify: false }), {
albumIds: [albumId],
assetIds: [responseData.id],
});
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_added_to_album') });
}
+9 -2
View File
@@ -6,6 +6,8 @@
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import GroupTab from '$lib/elements/GroupTab.svelte';
import SearchBar from '$lib/elements/SearchBar.svelte';
import { getAllAlbumsQuery } from '$lib/queries/album.query';
import { createPreloadedQuery } from '$lib/queries/client';
import { Route } from '$lib/route';
import { AlbumFilter, albumViewSettings } from '$lib/stores/preferences.store';
import { createAlbumAndRedirect } from '$lib/utils/album-utils';
@@ -18,6 +20,11 @@
let { data }: Props = $props();
const queries = {
ownedAlbums: createPreloadedQuery(() => getAllAlbumsQuery({ isOwned: true })),
sharedAlbums: createPreloadedQuery(() => getAllAlbumsQuery({ isShared: true })),
};
let searchQuery = $state('');
let albumGroups: string[] = $state([]);
</script>
@@ -44,8 +51,8 @@
</div>
<Albums
ownedAlbums={data.albums}
sharedAlbums={data.sharedAlbums}
ownedAlbums={queries.ownedAlbums.data}
sharedAlbums={queries.sharedAlbums.data}
userSettings={$albumViewSettings}
allowEdit
{searchQuery}
+3 -5
View File
@@ -1,17 +1,15 @@
import { getAllAlbums } from '@immich/sdk';
import { getAllAlbumsQuery } from '$lib/queries/album.query';
import { queryClient } from '$lib/queries/client';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const sharedAlbums = await getAllAlbums({ isShared: true });
const albums = await getAllAlbums({ isOwned: true });
await queryClient.query(getAllAlbumsQuery());
const $t = await getFormatter();
return {
albums,
sharedAlbums,
meta: {
title: $t('albums'),
},
@@ -27,27 +27,18 @@
import { assetMultiSelectManager, AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { getAlbumInfoQuery } from '$lib/queries/album.query';
import { createPreloadedQuery, queryClient } from '$lib/queries/client';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { Route } from '$lib/route';
import {
getAlbumActions,
getAlbumAssetsActions,
handleDeleteAlbum,
isAlbumEditor,
isAlbumOwner,
} from '$lib/services/album.service';
import { getAlbumActions, getAlbumAssetsActions, isAlbumEditor, isAlbumOwner } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { isAlbumsRoute, navigate, type AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { AlbumUserRole, AssetVisibility, updateAlbumInfo, type AlbumResponseDto } from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, Icon, IconButton, toastManager } from '@immich/ui';
import { AlbumUserRole, AssetVisibility } from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, Icon, IconButton } from '@immich/ui';
import {
mdiAccountEye,
mdiAccountEyeOutline,
@@ -68,12 +59,23 @@
import AlbumTitle from './AlbumTitle.svelte';
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import { delay } from '$lib/utils/asset-utils';
import {
deleteAlbumMutation,
getAlbumInfoQuery,
invalidateAlbum,
updateAlbumInfoMutation,
} from '$lib/queries/album.query';
import { createPreloadedQuery, executeMutation } from '$lib/queries/client';
import { getUserConfigQuery } from '$lib/queries/config-user.query';
const query = createPreloadedQuery(getUserConfigQuery);
const config = $derived(query.data);
let { params }: PageProps = $props();
const { params }: PageProps = $props();
const queries = {
config: createPreloadedQuery(getUserConfigQuery),
album: createPreloadedQuery(() => getAlbumInfoQuery(params.albumId)),
};
const config = $derived(queries.config.data);
const album = $derived(queries.album.data);
let { slideshowState, slideshowNavigation } = slideshowStore;
let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
@@ -122,16 +124,7 @@
await goto(Route.albums());
};
const refreshAlbum = async (updated?: AlbumResponseDto) => {
if (updated) {
if (updated.id !== album.id) {
return;
}
queryClient.setQueryData(getAlbumInfoQuery(album.id).queryKey, updated);
}
await queryClient.invalidateQueries({ queryKey: getAlbumInfoQuery(album.id).queryKey });
};
const refreshAlbum = () => invalidateAlbum(album.id);
const setModeToView = async () => {
timelineManager.suspendTransitions = true;
@@ -191,29 +184,22 @@
await updateThumbnail(firstAsset.id);
};
const updateThumbnail = async (assetId: string) => {
try {
const response = await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
albumThumbnailAssetId: assetId,
},
});
eventManager.emit('AlbumUpdate', response);
toastManager.primary($t('album_cover_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_cover'));
}
};
const updateThumbnail = (assetId: string) =>
executeMutation(
() =>
updateAlbumInfoMutation({
success: { toast: 'album_cover_updated' },
error: { toast: 'errors.unable_to_update_album_cover' },
}),
[{ id: album.id, updateAlbumDto: { albumThumbnailAssetId: assetId } }],
);
onNavigate(async ({ to }) => {
if (!isAlbumsRoute(to?.route.id) && album.assetCount === 0 && !album.albumName) {
await handleDeleteAlbum(album, { notify: false, prompt: false });
await executeMutation(() => deleteAlbumMutation({}), [{ id: album.id }]);
}
});
const albumQuery = createPreloadedQuery(() => getAlbumInfoQuery(params.albumId));
let album = $derived(albumQuery.data);
const albumId = $derived(album.id);
const albumHasViewers = $derived(album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer));
@@ -274,42 +260,15 @@
viewMode === AlbumPageViewMode.SELECT_ASSETS ? timelineMultiSelectManager : assetMultiSelectManager,
);
const onAlbumDelete = async ({ id }: AlbumResponseDto) => {
if (id !== album.id) {
return;
}
await goto(Route.albums());
viewMode = AlbumPageViewMode.VIEW;
};
const onAlbumAddAssets = async ({ albumIds, assetIds }: { albumIds: string[]; assetIds: string[] }) => {
const onAlbumAddAssets = async ({ albumIds }: { albumIds: string[]; assetIds: string[] }) => {
if (!albumIds.includes(album.id)) {
return;
}
album = { ...album, assetCount: album.assetCount + assetIds.length };
timelineMultiSelectManager.clear();
await setModeToView();
};
const onAlbumShare = async () => {
await refreshAlbum();
await setModeToView();
};
const onAlbumUserUpdate = ({ albumId, userId, role }: { albumId: string; userId: string; role: AlbumUserRole }) => {
if (albumId !== album.id) {
return;
}
const albumUsers = album.albumUsers.map((albumUser) =>
albumUser.user.id === userId ? { ...albumUser, role } : albumUser,
);
album = { ...album, albumUsers };
};
const { Cast } = $derived(getGlobalActions($t));
const Actions = $derived(getAlbumActions($t, album));
const { AddAssets, Upload } = $derived(getAlbumAssetsActions($t, album, timelineMultiSelectManager.assets));
@@ -323,16 +282,7 @@
});
</script>
<OnEvents
{onAlbumDelete}
{onAlbumAddAssets}
{onAlbumRemoveAssets}
{onAlbumShare}
{onAlbumUserUpdate}
onAlbumUserDelete={() => refreshAlbum()}
onAlbumUpdate={(album) => refreshAlbum(album)}
onAssetsDelete={() => refreshAlbum()}
/>
<OnEvents {onAlbumAddAssets} {onAlbumRemoveAssets} onAssetsDelete={() => refreshAlbum()} />
<CommandPaletteDefaultProvider name={$t('album')} actions={[AddAssets, Upload, Close, ...Object.values(Actions)]} />
<div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: Route.albums() }}>
@@ -357,12 +307,7 @@
{#if viewMode !== AlbumPageViewMode.SELECT_THUMBNAIL}
<!-- ALBUM TITLE -->
<section class="pt-8 md:pt-24">
<AlbumTitle
id={album.id}
albumName={album.albumName}
{isEditor}
onUpdate={(albumName) => (album = { ...album, albumName })}
/>
<AlbumTitle id={album.id} albumName={album.albumName} {isEditor} />
{#if album.assetCount > 0}
<AlbumSummary {album} />
@@ -406,11 +351,7 @@
<ActionButton action={Actions.Share} />
</div>
{/if}
<AlbumDescription
id={album.id}
{isEditor}
bind:description={() => album.description, (description) => (album = { ...album, description })}
/>
<AlbumDescription id={album.id} {isEditor} description={album.description} />
</section>
{/if}
@@ -1,8 +1,7 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { updateAlbumInfo } from '@immich/sdk';
import { updateAlbumInfoMutation } from '$lib/queries/album.query';
import { executeMutation } from '$lib/queries/client';
import { Textarea } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fromAction } from 'svelte/attachments';
@@ -13,26 +12,20 @@
isEditor: boolean;
}
let { id, description = $bindable(), isEditor }: Props = $props();
let { id, description, isEditor }: Props = $props();
const handleFocusOut = async () => {
try {
const response = await updateAlbumInfo({
id,
updateAlbumDto: {
description: description || null,
},
});
eventManager.emit('AlbumUpdate', response);
} catch (error) {
handleError(error, $t('errors.unable_to_save_album'));
}
};
let value = $derived(description);
const handleFocusOut = () =>
executeMutation(
() => updateAlbumInfoMutation({ error: { toast: 'errors.unable_to_save_album' } }),
[{ id, updateAlbumDto: { description: value || null } }],
);
</script>
{#if isEditor}
<Textarea
bind:value={description}
bind:value
variant="ghost"
onfocusout={handleFocusOut}
placeholder={$t('add_a_description')}
@@ -1,8 +1,7 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { updateAlbumInfo } from '@immich/sdk';
import { updateAlbumInfoMutation } from '$lib/queries/album.query';
import { executeMutation } from '$lib/queries/client';
import { Textarea } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fromAction } from 'svelte/attachments';
@@ -11,10 +10,9 @@
id: string;
albumName: string;
isEditor: boolean;
onUpdate: (albumName: string) => void;
};
let { id, albumName = $bindable(), isEditor, onUpdate }: Props = $props();
let { id, albumName, isEditor }: Props = $props();
let newAlbumName = $derived(albumName);
@@ -25,14 +23,10 @@
return;
}
try {
const response = await updateAlbumInfo({ id, updateAlbumDto: { albumName: newAlbumName } });
({ albumName } = response);
eventManager.emit('AlbumUpdate', response);
onUpdate(albumName);
} catch (error) {
handleError(error, $t('errors.unable_to_save_album'));
}
await executeMutation(
() => updateAlbumInfoMutation({ error: { toast: 'errors.unable_to_save_album' } }),
[{ id, updateAlbumDto: { albumName: newAlbumName } }],
);
};
const textClasses = 'text-2xl lg:text-6xl text-primary';
+6 -1
View File
@@ -4,6 +4,8 @@
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import UserAvatar from '$lib/components/shared-components/UserAvatar.svelte';
import { getAllAlbumsQuery } from '$lib/queries/album.query';
import { createPreloadedQuery } from '$lib/queries/client';
import { Route } from '$lib/route';
import { getAlbumsActions } from '$lib/services/album.service';
import { getSharedLinksActions } from '$lib/services/shared-link.service';
@@ -24,6 +26,9 @@
let { data }: Props = $props();
const query = createPreloadedQuery(() => getAllAlbumsQuery({ isShared: true }));
const sharedAlbums = $derived(query.data);
const settings: AlbumViewSettings = {
view: AlbumViewMode.Cover,
filter: AlbumFilter.Shared,
@@ -76,7 +81,7 @@
<div>
<!-- Shared Album List -->
<Albums sharedAlbums={data.sharedAlbums} userSettings={settings} showOwner>
<Albums {sharedAlbums} userSettings={settings} showOwner>
<!-- Empty List -->
{#snippet empty()}
<EmptyPlaceholder text={$t('no_shared_albums_message')} src={empty2Url} class="mx-auto mt-10" />
+4 -3
View File
@@ -1,16 +1,17 @@
import { PartnerDirection, getAllAlbums, getPartners } from '@immich/sdk';
import { PartnerDirection, getPartners } from '@immich/sdk';
import { getAllAlbumsQuery } from '$lib/queries/album.query';
import { queryClient } from '$lib/queries/client';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const sharedAlbums = await getAllAlbums({ isShared: true });
await queryClient.query(getAllAlbumsQuery({ isShared: true }));
const partners = await getPartners({ direction: PartnerDirection.SharedWith });
const $t = await getFormatter();
return {
sharedAlbums,
partners,
meta: {
title: $t('sharing'),
@@ -2,17 +2,18 @@
import { getHeatmapRange } from '$lib';
import CalendarHeatmap from '$lib/components/CalendarHeatmap.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte';
import { getAlbumStatisticsQuery } from '$lib/queries/album.query';
import { locale } from '$lib/stores/preferences.store';
import {
AssetVisibility,
CalendarHeatmapType,
getAlbumStatistics,
getAssetStatistics,
getMyCalendarHeatmap,
type AlbumStatisticsResponseDto,
type AssetStatsResponseDto,
} from '@immich/sdk';
import { Heading, Table, TableBody, TableCell, TableHeader, TableHeading, TableRow } from '@immich/ui';
import { createQuery } from '@tanstack/svelte-query';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -42,19 +43,15 @@
total: 0,
});
let albumStats: AlbumStatisticsResponseDto = $state({
owned: 0,
shared: 0,
notShared: 0,
});
const query = createQuery(getAlbumStatisticsQuery);
const albumStats: AlbumStatisticsResponseDto = $derived(query.data ?? { owned: 0, shared: 0, notShared: 0 });
const getUsage = async () => {
[timelineStats, favoriteStats, archiveStats, trashStats, albumStats] = await Promise.all([
[timelineStats, favoriteStats, archiveStats, trashStats] = await Promise.all([
getAssetStatistics({ visibility: AssetVisibility.Timeline }),
getAssetStatistics({ isFavorite: true }),
getAssetStatistics({ visibility: AssetVisibility.Archive }),
getAssetStatistics({ isTrashed: true }),
getAlbumStatistics(),
]);
};
@@ -4,9 +4,11 @@
import { getAllMetadataItems, type DifferingMetadataFields } from '$lib/utils/duplicate-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAllAlbums, type AssetResponseDto } from '@immich/sdk';
import { getAlbumsByAssetQuery } from '$lib/queries/album.query';
import { type AssetResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiBookmarkOutline, mdiHeart, mdiImageMultipleOutline, mdiMagnifyPlus } from '@mdi/js';
import { createQuery } from '@tanstack/svelte-query';
import { t } from 'svelte-i18n';
import InfoRow from './InfoRow.svelte';
@@ -30,6 +32,9 @@
initialVisibleCount = 5,
}: Props = $props();
const query = createQuery(() => getAlbumsByAssetQuery(asset.id));
const albums = $derived(query.data);
const listFormat = $derived(new Intl.ListFormat($lang));
const isFromExternalLibrary = $derived(!!asset.libraryId);
@@ -114,17 +119,15 @@
<!-- Albums always shown -->
<InfoRow icon={mdiBookmarkOutline} title={$t('albums')}>
{#await getAllAlbums({ assetId: asset.id })}
{#if !albums}
{$t('scanning_for_album')}
{:then albums}
{#if albums.length === 1}
{albums[0].albumName}
{:else}
<span title={listFormat.format(albums.map(({ albumName }) => albumName))}>
{$t('in_albums', { values: { count: albums.length } })}
</span>
{/if}
{/await}
{:else if albums.length === 1}
{albums[0].albumName}
{:else}
<span title={listFormat.format(albums.map(({ albumName }) => albumName))}>
{$t('in_albums', { values: { count: albums.length } })}
</span>
{/if}
</InfoRow>
</div>
</div>
@@ -1,26 +1,11 @@
<script module lang="ts">
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAlbumInfo, getTagById } from '@immich/sdk';
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const albumNameCache = new Map<string, Promise<string>>();
const getAlbumName = (id: string): Promise<string> => {
let albumName = albumNameCache.get(id);
if (!albumName) {
albumName = getAlbumInfo({ ...authManager.params, id })
.then((album) => album.albumName)
.catch(() => id);
albumNameCache.set(id, albumName);
}
return albumName;
};
</script>
<script lang="ts">
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import type { JSONSchemaProperty } from '$lib/types';
import { type WorkflowStepDto } from '@immich/sdk';
import { Badge, Card, CardBody, CardDescription, CardHeader, CardTitle, Icon, IconButton } from '@immich/ui';
import { getTagById } from '@immich/sdk';
import { getAlbumQuery } from '$lib/queries/album.query';
import { queryClient } from '$lib/queries/client';
import {
mdiAutoFix,
mdiDragVertical,
@@ -47,6 +32,12 @@
let { step, index, onEdit, onDelete, onInsertBefore, onDragOver, onDrop, onDragEnd, onDragStart }: Props = $props();
const getAlbumName = (id: string): Promise<string> =>
queryClient
.query(getAlbumQuery(id))
.then((album) => album.albumName)
.catch(() => id);
const method = $derived(pluginManager.getMethod(step.method));
const isFilter = $derived(method?.uiHints?.includes('Filter') ?? false);
const schema = $derived(method?.schema as JSONSchemaProperty | undefined);