diff --git a/libertas-frontend/src/features/albums/use-albums.ts b/libertas-frontend/src/features/albums/use-albums.ts index 9284ac9..983c2ef 100644 --- a/libertas-frontend/src/features/albums/use-albums.ts +++ b/libertas-frontend/src/features/albums/use-albums.ts @@ -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 + }, + }); }; \ No newline at end of file diff --git a/libertas-frontend/src/routes/albums/$albumId.tsx b/libertas-frontend/src/routes/albums/$albumId.tsx index 274c303..bcc8dfc 100644 --- a/libertas-frontend/src/routes/albums/$albumId.tsx +++ b/libertas-frontend/src/routes/albums/$albumId.tsx @@ -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(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 (
@@ -28,21 +58,39 @@ function AlbumDetailPage() {
{isLoading &&

Loading photos...

} + {error &&

Error loading photos: {error.message}

} {media && media.length > 0 && (
{media.map((m) => ( -
setSelectedMedia(m)} - > - -
+ + +
setSelectedMedia(m)} + > + +
+
+ + setSelectedMedia(m)}> + + View + + handleRemoveMedia(m.id)} + disabled={isRemoving} + > + + Remove from Album + + +
))}
)} diff --git a/libertas-frontend/src/services/album-service.ts b/libertas-frontend/src/services/album-service.ts index 3dcf7fe..5ea36d0 100644 --- a/libertas-frontend/src/services/album-service.ts +++ b/libertas-frontend/src/services/album-service.ts @@ -66,4 +66,18 @@ export const addMediaToAlbum = async ( export const getAlbum = async (albumId: string): Promise => { 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 => { + await apiClient.delete(`/albums/${albumId}/media`, { data: payload }); }; \ No newline at end of file diff --git a/libertas_api/src/handlers/album_handlers.rs b/libertas_api/src/handlers/album_handlers.rs index bd73748..664ecf0 100644 --- a/libertas_api/src/handlers/album_handlers.rs +++ b/libertas_api/src/handlers/album_handlers.rs @@ -5,7 +5,8 @@ use axum::{ routing::{get, post, put}, }; use libertas_core::schema::{ - AddMediaToAlbumData, CreateAlbumData, ShareAlbumData, UpdateAlbumData, + AddMediaToAlbumData, CreateAlbumData, RemoveMediaFromAlbumRequest, ShareAlbumData, + UpdateAlbumData, }; use uuid::Uuid; @@ -148,6 +149,19 @@ async fn get_media_for_album( Ok(Json(response)) } +async fn remove_media_from_album( + State(state): State, + UserId(user_id): UserId, + Path(album_id): Path, + Json(payload): Json, +) -> Result { + state + .album_service + .remove_media_from_album(album_id, &payload.media_ids, user_id) + .await?; + Ok(StatusCode::NO_CONTENT) +} + pub fn album_routes() -> Router { Router::new() .route("/", post(create_album).get(list_user_albums)) @@ -160,7 +174,9 @@ pub fn album_routes() -> Router { .route("/{id}/thumbnail", put(set_album_thumbnail)) .route( "/{id}/media", - post(add_media_to_album).get(get_media_for_album), + post(add_media_to_album) + .get(get_media_for_album) + .delete(remove_media_from_album), ) .route("/{id}/share", post(share_album)) } diff --git a/libertas_api/src/services/album_service.rs b/libertas_api/src/services/album_service.rs index d9cd450..745428b 100644 --- a/libertas_api/src/services/album_service.rs +++ b/libertas_api/src/services/album_service.rs @@ -198,4 +198,19 @@ impl AlbumService for AlbumServiceImpl { Ok(media) } + + async fn remove_media_from_album( + &self, + album_id: Uuid, + media_ids: &[Uuid], + user_id: Uuid, + ) -> CoreResult<()> { + self.auth_service + .check_permission(Some(user_id), Permission::EditAlbum(album_id)) + .await?; + + self.album_repo + .remove_media_from_album(album_id, media_ids) + .await + } } diff --git a/libertas_core/src/repositories.rs b/libertas_core/src/repositories.rs index 1f4a55f..b47777b 100644 --- a/libertas_core/src/repositories.rs +++ b/libertas_core/src/repositories.rs @@ -49,6 +49,7 @@ pub trait AlbumRepository: Send + Sync { async fn list_media_by_album_id(&self, album_id: Uuid) -> CoreResult>; async fn is_media_in_public_album(&self, media_id: Uuid) -> CoreResult; async fn set_thumbnail_media_id(&self, album_id: Uuid, media_id: Uuid) -> CoreResult<()>; + async fn remove_media_from_album(&self, album_id: Uuid, media_ids: &[Uuid]) -> CoreResult<()>; } #[async_trait] diff --git a/libertas_core/src/schema.rs b/libertas_core/src/schema.rs index 81eab94..7eb840e 100644 --- a/libertas_core/src/schema.rs +++ b/libertas_core/src/schema.rs @@ -121,3 +121,8 @@ impl PaginatedResponse { } } } + +#[derive(serde::Deserialize)] +pub struct RemoveMediaFromAlbumRequest { + pub media_ids: Vec, +} diff --git a/libertas_core/src/services.rs b/libertas_core/src/services.rs index b73a536..3b39626 100644 --- a/libertas_core/src/services.rs +++ b/libertas_core/src/services.rs @@ -59,6 +59,12 @@ pub trait AlbumService: Send + Sync { user_id: Uuid, ) -> CoreResult<()>; async fn get_album_media(&self, album_id: Uuid, user_id: Uuid) -> CoreResult>; + async fn remove_media_from_album( + &self, + album_id: Uuid, + media_ids: &[Uuid], + user_id: Uuid, + ) -> CoreResult<()>; } #[async_trait] diff --git a/libertas_infra/src/repositories/album_repository.rs b/libertas_infra/src/repositories/album_repository.rs index 69dd0a0..be8f72f 100644 --- a/libertas_infra/src/repositories/album_repository.rs +++ b/libertas_infra/src/repositories/album_repository.rs @@ -183,4 +183,24 @@ impl AlbumRepository for PostgresAlbumRepository { Ok(()) } + + async fn remove_media_from_album(&self, album_id: Uuid, media_ids: &[Uuid]) -> CoreResult<()> { + if media_ids.is_empty() { + return Ok(()); + } + + sqlx::query!( + r#" + DELETE FROM album_media + WHERE album_id = $1 AND media_id = ANY($2) + "#, + album_id, + media_ids + ) + .execute(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + Ok(()) + } }