feat: add functionality to remove media from album, including API integration and UI context menu
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
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"];
|
const ALBUMS_KEY = ["albums"];
|
||||||
|
|
||||||
@@ -75,3 +75,30 @@ export const useGetAlbum = (albumId: string) => {
|
|||||||
enabled: !!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
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,9 +1,20 @@
|
|||||||
import { AddMediaToAlbumDialog } from "@/components/albums/add-media-to-album-dialog";
|
import { AddMediaToAlbumDialog } from "@/components/albums/add-media-to-album-dialog";
|
||||||
import { AuthenticatedImage } from "@/components/media/authenticated-image";
|
import { AuthenticatedImage } from "@/components/media/authenticated-image";
|
||||||
import { MediaViewer } from "@/components/media/media-viewer";
|
import { MediaViewer } from "@/components/media/media-viewer";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
import type { Media } from "@/domain/types";
|
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 { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Eye, Trash2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export const Route = createFileRoute("/albums/$albumId")({
|
export const Route = createFileRoute("/albums/$albumId")({
|
||||||
@@ -12,11 +23,30 @@ export const Route = createFileRoute("/albums/$albumId")({
|
|||||||
|
|
||||||
function AlbumDetailPage() {
|
function AlbumDetailPage() {
|
||||||
const { albumId } = Route.useParams();
|
const { albumId } = Route.useParams();
|
||||||
const { data: album, isLoading: isLoadingAlbum } = useGetAlbum(albumId);
|
const {
|
||||||
const { data: media, isLoading: isLoadingMedia } = useGetAlbumMedia(albumId);
|
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 [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
||||||
|
|
||||||
|
const { mutate: removeMedia, isPending: isRemoving } =
|
||||||
|
useRemoveMediaFromAlbum();
|
||||||
|
|
||||||
const isLoading = isLoadingAlbum || isLoadingMedia;
|
const isLoading = isLoadingAlbum || isLoadingMedia;
|
||||||
|
const error = albumError || mediaError;
|
||||||
|
|
||||||
|
const handleRemoveMedia = (mediaId: string) => {
|
||||||
|
removeMedia({
|
||||||
|
albumId,
|
||||||
|
payload: { media_ids: [mediaId] },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -28,21 +58,39 @@ function AlbumDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && <p>Loading photos...</p>}
|
{isLoading && <p>Loading photos...</p>}
|
||||||
|
{error && <p>Error loading photos: {error.message}</p>}
|
||||||
|
|
||||||
{media && media.length > 0 && (
|
{media && media.length > 0 && (
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
|
||||||
{media.map((m) => (
|
{media.map((m) => (
|
||||||
<div
|
<ContextMenu key={m.id}>
|
||||||
key={m.id}
|
<ContextMenuTrigger>
|
||||||
className="aspect-square bg-gray-200 rounded-md overflow-hidden cursor-pointer hover:opacity-80 transition-opacity"
|
<div
|
||||||
onClick={() => setSelectedMedia(m)}
|
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}
|
<AuthenticatedImage
|
||||||
alt={m.original_filename}
|
src={m.thumbnail_url ?? m.file_url}
|
||||||
className="w-full h-full object-cover"
|
alt={m.original_filename}
|
||||||
/>
|
className="w-full h-full object-cover"
|
||||||
</div>
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -67,3 +67,17 @@ export const getAlbum = async (albumId: string): Promise<Album> => {
|
|||||||
const { data } = await apiClient.get(`/albums/${albumId}`);
|
const { data } = await apiClient.get(`/albums/${albumId}`);
|
||||||
return data;
|
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 });
|
||||||
|
};
|
||||||
@@ -5,7 +5,8 @@ use axum::{
|
|||||||
routing::{get, post, put},
|
routing::{get, post, put},
|
||||||
};
|
};
|
||||||
use libertas_core::schema::{
|
use libertas_core::schema::{
|
||||||
AddMediaToAlbumData, CreateAlbumData, ShareAlbumData, UpdateAlbumData,
|
AddMediaToAlbumData, CreateAlbumData, RemoveMediaFromAlbumRequest, ShareAlbumData,
|
||||||
|
UpdateAlbumData,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -148,6 +149,19 @@ async fn get_media_for_album(
|
|||||||
Ok(Json(response))
|
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> {
|
pub fn album_routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", post(create_album).get(list_user_albums))
|
.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}/thumbnail", put(set_album_thumbnail))
|
||||||
.route(
|
.route(
|
||||||
"/{id}/media",
|
"/{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))
|
.route("/{id}/share", post(share_album))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,4 +198,19 @@ impl AlbumService for AlbumServiceImpl {
|
|||||||
|
|
||||||
Ok(media)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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 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 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]
|
#[async_trait]
|
||||||
|
|||||||
@@ -121,3 +121,8 @@ impl<T> PaginatedResponse<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct RemoveMediaFromAlbumRequest {
|
||||||
|
pub media_ids: Vec<uuid::Uuid>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ pub trait AlbumService: Send + Sync {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> CoreResult<()>;
|
) -> CoreResult<()>;
|
||||||
async fn get_album_media(&self, album_id: Uuid, user_id: Uuid) -> CoreResult<Vec<Media>>;
|
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]
|
#[async_trait]
|
||||||
|
|||||||
@@ -183,4 +183,24 @@ impl AlbumRepository for PostgresAlbumRepository {
|
|||||||
|
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user