feat: add functionality to remove media from album, including API integration and UI context menu

This commit is contained in:
2025-11-16 01:47:36 +01:00
parent 07b797b82b
commit f41a3169e9
9 changed files with 169 additions and 17 deletions

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { addMediaToAlbum, createAlbum, getAlbum, getAlbumMedia, getAlbums, type AddMediaToAlbumPayload } from '@/services/album-service'
import { addMediaToAlbum, createAlbum, getAlbum, getAlbumMedia, getAlbums, removeMediaFromAlbum, type AddMediaToAlbumPayload, type RemoveMediaFromAlbumPayload } from '@/services/album-service'
const ALBUMS_KEY = ["albums"];
@@ -74,4 +74,31 @@ export const useGetAlbum = (albumId: string) => {
queryFn: () => getAlbum(albumId),
enabled: !!albumId,
});
};
/**
* Mutation hook to remove media from an album.
*/
export const useRemoveMediaFromAlbum = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
albumId,
payload,
}: {
albumId: string;
payload: RemoveMediaFromAlbumPayload;
}) => removeMediaFromAlbum(albumId, payload),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: [ALBUMS_KEY, variables.albumId, "media"],
});
// TODO: Add success toast
},
onError: (error) => {
console.error("Failed to remove media from album:", error);
// TODO: Add error toast
},
});
};

View File

@@ -1,9 +1,20 @@
import { AddMediaToAlbumDialog } from "@/components/albums/add-media-to-album-dialog";
import { AuthenticatedImage } from "@/components/media/authenticated-image";
import { MediaViewer } from "@/components/media/media-viewer";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import type { Media } from "@/domain/types";
import { useGetAlbum, useGetAlbumMedia } from "@/features/albums/use-albums";
import {
useGetAlbum,
useGetAlbumMedia,
useRemoveMediaFromAlbum,
} from "@/features/albums/use-albums";
import { createFileRoute } from "@tanstack/react-router";
import { Eye, Trash2 } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/albums/$albumId")({
@@ -12,11 +23,30 @@ export const Route = createFileRoute("/albums/$albumId")({
function AlbumDetailPage() {
const { albumId } = Route.useParams();
const { data: album, isLoading: isLoadingAlbum } = useGetAlbum(albumId);
const { data: media, isLoading: isLoadingMedia } = useGetAlbumMedia(albumId);
const {
data: album,
isLoading: isLoadingAlbum,
error: albumError,
} = useGetAlbum(albumId);
const {
data: media,
isLoading: isLoadingMedia,
error: mediaError,
} = useGetAlbumMedia(albumId);
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
const { mutate: removeMedia, isPending: isRemoving } =
useRemoveMediaFromAlbum();
const isLoading = isLoadingAlbum || isLoadingMedia;
const error = albumError || mediaError;
const handleRemoveMedia = (mediaId: string) => {
removeMedia({
albumId,
payload: { media_ids: [mediaId] },
});
};
return (
<div className="space-y-6">
@@ -28,21 +58,39 @@ function AlbumDetailPage() {
</div>
{isLoading && <p>Loading photos...</p>}
{error && <p>Error loading photos: {error.message}</p>}
{media && media.length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
{media.map((m) => (
<div
key={m.id}
className="aspect-square bg-gray-200 rounded-md overflow-hidden cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setSelectedMedia(m)}
>
<AuthenticatedImage
src={m.thumbnail_url ?? m.file_url}
alt={m.original_filename}
className="w-full h-full object-cover"
/>
</div>
<ContextMenu key={m.id}>
<ContextMenuTrigger>
<div
className="aspect-square bg-gray-200 rounded-md overflow-hidden cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setSelectedMedia(m)}
>
<AuthenticatedImage
src={m.thumbnail_url ?? m.file_url}
alt={m.original_filename}
className="w-full h-full object-cover"
/>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => setSelectedMedia(m)}>
<Eye className="mr-2 h-4 w-4" />
View
</ContextMenuItem>
<ContextMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => handleRemoveMedia(m.id)}
disabled={isRemoving}
>
<Trash2 className="mr-2 h-4 w-4" />
Remove from Album
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</div>
)}

View File

@@ -66,4 +66,18 @@ export const addMediaToAlbum = async (
export const getAlbum = async (albumId: string): Promise<Album> => {
const { data } = await apiClient.get(`/albums/${albumId}`);
return data;
};
export type RemoveMediaFromAlbumPayload = {
media_ids: string[];
};
/**
* Removes a list of media IDs from a specific album.
*/
export const removeMediaFromAlbum = async (
albumId: string,
payload: RemoveMediaFromAlbumPayload,
): Promise<void> => {
await apiClient.delete(`/albums/${albumId}/media`, { data: payload });
};