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 });
};

View File

@@ -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<AppState>,
UserId(user_id): UserId,
Path(album_id): Path<Uuid>,
Json(payload): Json<RemoveMediaFromAlbumRequest>,
) -> Result<StatusCode, ApiError> {
state
.album_service
.remove_media_from_album(album_id, &payload.media_ids, user_id)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub fn album_routes() -> Router<AppState> {
Router::new()
.route("/", post(create_album).get(list_user_albums))
@@ -160,7 +174,9 @@ pub fn album_routes() -> Router<AppState> {
.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))
}

View File

@@ -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
}
}

View File

@@ -49,6 +49,7 @@ pub trait AlbumRepository: Send + Sync {
async fn list_media_by_album_id(&self, album_id: Uuid) -> CoreResult<Vec<Media>>;
async fn is_media_in_public_album(&self, media_id: Uuid) -> CoreResult<bool>;
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]

View File

@@ -121,3 +121,8 @@ impl<T> PaginatedResponse<T> {
}
}
}
#[derive(serde::Deserialize)]
pub struct RemoveMediaFromAlbumRequest {
pub media_ids: Vec<uuid::Uuid>,
}

View File

@@ -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<Vec<Media>>;
async fn remove_media_from_album(
&self,
album_id: Uuid,
media_ids: &[Uuid],
user_id: Uuid,
) -> CoreResult<()>;
}
#[async_trait]

View File

@@ -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(())
}
}