diff --git a/libertas-frontend/src/components/albums/add-media-to-album-dialog.tsx b/libertas-frontend/src/components/albums/add-media-to-album-dialog.tsx new file mode 100644 index 0000000..f32339a --- /dev/null +++ b/libertas-frontend/src/components/albums/add-media-to-album-dialog.tsx @@ -0,0 +1,144 @@ +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, + DialogClose, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { useGetMediaList } from "@/features/media/use-media"; +import { useAddMediaToAlbum } from "@/features/albums/use-albums"; +import { AuthenticatedImage } from "@/components/media/authenticated-image"; +import { Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; + +type AddMediaToAlbumDialogProps = { + albumId: string; +}; + +export function AddMediaToAlbumDialog({ albumId }: AddMediaToAlbumDialogProps) { + const [isOpen, setIsOpen] = useState(false); + const [selectedMediaIds, setSelectedMediaIds] = useState([]); + + const { + data, + isLoading, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useGetMediaList(); + + const { mutate: addMedia, isPending: isAdding } = useAddMediaToAlbum(); + + const toggleSelection = (mediaId: string) => { + setSelectedMediaIds((prev) => + prev.includes(mediaId) + ? prev.filter((id) => id !== mediaId) + : [...prev, mediaId] + ); + }; + + const handleSubmit = () => { + addMedia( + { + albumId, + payload: { media_ids: selectedMediaIds }, + }, + { + onSuccess: () => { + setIsOpen(false); + setSelectedMediaIds([]); + }, + } + ); + }; + + return ( + { + setIsOpen(open); + if (!open) { + setSelectedMediaIds([]); + } + }} + > + + + + + + Add Photos to Album + + Select photos from your library to add to this album. + + + +
+ {isLoading &&

Loading photos...

} + {error &&

Error loading photos: {error.message}

} + {data && ( +
+ {data.pages.map((page) => + page.data.map((media) => { + const isSelected = selectedMediaIds.includes(media.id); + return ( +
toggleSelection(media.id)} + > + +
+ ); + }) + )} +
+ )} + {hasNextPage && ( +
+ +
+ )} +
+ + + + + + + +
+
+ ); +} diff --git a/libertas-frontend/src/components/albums/album-card.tsx b/libertas-frontend/src/components/albums/album-card.tsx index 494ae0d..b2cc521 100644 --- a/libertas-frontend/src/components/albums/album-card.tsx +++ b/libertas-frontend/src/components/albums/album-card.tsx @@ -28,7 +28,6 @@ export function AlbumCard({ album }: AlbumCardProps) { {/* TODO: Show photo count */} -

0 items

diff --git a/libertas-frontend/src/features/albums/use-albums.ts b/libertas-frontend/src/features/albums/use-albums.ts index 6fff32c..9284ac9 100644 --- a/libertas-frontend/src/features/albums/use-albums.ts +++ b/libertas-frontend/src/features/albums/use-albums.ts @@ -1,5 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { createAlbum, getAlbums } from '@/services/album-service' +import { addMediaToAlbum, createAlbum, getAlbum, getAlbumMedia, getAlbums, type AddMediaToAlbumPayload } from '@/services/album-service' + +const ALBUMS_KEY = ["albums"]; /** * Query hook to fetch a list of all albums. @@ -27,4 +29,49 @@ export const useCreateAlbum = () => { // TODO: Add user-facing toast }, }) -} \ No newline at end of file +} + +export const useGetAlbumMedia = (albumId: string) => { + return useQuery({ + queryKey: [ALBUMS_KEY, albumId, "media"], + queryFn: () => getAlbumMedia(albumId), + enabled: !!albumId, + }); +}; + +/** + * Mutation hook to add media to an album. + */ +export const useAddMediaToAlbum = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + albumId, + payload, + }: { + albumId: string; + payload: AddMediaToAlbumPayload; + }) => addMediaToAlbum(albumId, payload), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: [ALBUMS_KEY, variables.albumId, "media"], + }); + }, + onError: (error) => { + console.error("Failed to add media to album:", error); + // TODO: Add user-facing toast + }, + }); +}; + +/** + * Query hook to fetch a single album by ID. + */ +export const useGetAlbum = (albumId: string) => { + return useQuery({ + queryKey: [ALBUMS_KEY, albumId], + queryFn: () => getAlbum(albumId), + enabled: !!albumId, + }); +}; \ No newline at end of file diff --git a/libertas-frontend/src/routeTree.gen.ts b/libertas-frontend/src/routeTree.gen.ts index 569c270..6858034 100644 --- a/libertas-frontend/src/routeTree.gen.ts +++ b/libertas-frontend/src/routeTree.gen.ts @@ -10,7 +10,6 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LoginRouteImport } from './routes/login' -import { Route as AboutRouteImport } from './routes/about' import { Route as IndexRouteImport } from './routes/index' import { Route as PeopleIndexRouteImport } from './routes/people/index' import { Route as MediaIndexRouteImport } from './routes/media/index' @@ -22,11 +21,6 @@ const LoginRoute = LoginRouteImport.update({ path: '/login', getParentRoute: () => rootRouteImport, } as any) -const AboutRoute = AboutRouteImport.update({ - id: '/about', - path: '/about', - getParentRoute: () => rootRouteImport, -} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -55,7 +49,6 @@ const AlbumsAlbumIdRoute = AlbumsAlbumIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/about': typeof AboutRoute '/login': typeof LoginRoute '/albums/$albumId': typeof AlbumsAlbumIdRoute '/albums': typeof AlbumsIndexRoute @@ -64,7 +57,6 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute - '/about': typeof AboutRoute '/login': typeof LoginRoute '/albums/$albumId': typeof AlbumsAlbumIdRoute '/albums': typeof AlbumsIndexRoute @@ -74,7 +66,6 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute - '/about': typeof AboutRoute '/login': typeof LoginRoute '/albums/$albumId': typeof AlbumsAlbumIdRoute '/albums/': typeof AlbumsIndexRoute @@ -85,25 +76,16 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' - | '/about' | '/login' | '/albums/$albumId' | '/albums' | '/media' | '/people' fileRoutesByTo: FileRoutesByTo - to: - | '/' - | '/about' - | '/login' - | '/albums/$albumId' - | '/albums' - | '/media' - | '/people' + to: '/' | '/login' | '/albums/$albumId' | '/albums' | '/media' | '/people' id: | '__root__' | '/' - | '/about' | '/login' | '/albums/$albumId' | '/albums/' @@ -113,7 +95,6 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute - AboutRoute: typeof AboutRoute LoginRoute: typeof LoginRoute AlbumsAlbumIdRoute: typeof AlbumsAlbumIdRoute AlbumsIndexRoute: typeof AlbumsIndexRoute @@ -130,13 +111,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } - '/about': { - id: '/about' - path: '/about' - fullPath: '/about' - preLoaderRoute: typeof AboutRouteImport - parentRoute: typeof rootRouteImport - } '/': { id: '/' path: '/' @@ -177,7 +151,6 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, - AboutRoute: AboutRoute, LoginRoute: LoginRoute, AlbumsAlbumIdRoute: AlbumsAlbumIdRoute, AlbumsIndexRoute: AlbumsIndexRoute, diff --git a/libertas-frontend/src/routes/about.tsx b/libertas-frontend/src/routes/about.tsx deleted file mode 100644 index de88d2a..0000000 --- a/libertas-frontend/src/routes/about.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import * as React from "react"; - -export const Route = createFileRoute("/about")({ - component: AboutComponent, -}); - -function AboutComponent() { - return ( -
-

About

-
- ); -} diff --git a/libertas-frontend/src/routes/albums/$albumId.tsx b/libertas-frontend/src/routes/albums/$albumId.tsx index f29258e..274c303 100644 --- a/libertas-frontend/src/routes/albums/$albumId.tsx +++ b/libertas-frontend/src/routes/albums/$albumId.tsx @@ -1,4 +1,10 @@ +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 type { Media } from "@/domain/types"; +import { useGetAlbum, useGetAlbumMedia } from "@/features/albums/use-albums"; import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; export const Route = createFileRoute("/albums/$albumId")({ component: AlbumDetailPage, @@ -6,14 +12,51 @@ 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 [selectedMedia, setSelectedMedia] = useState(null); + + const isLoading = isLoadingAlbum || isLoadingMedia; return ( -
-

Album: {albumId}

-

- This page will show the details and photos for a single album. -

- {/* TODO: Fetch album details and display media grid */} +
+
+

+ {album?.name ?? "Loading album..."} +

+ +
+ + {isLoading &&

Loading photos...

} + + {media && media.length > 0 && ( +
+ {media.map((m) => ( +
setSelectedMedia(m)} + > + +
+ ))} +
+ )} + + {media && media.length === 0 &&

This album is empty.

} + + { + if (!open) { + setSelectedMedia(null); + } + }} + />
); } diff --git a/libertas-frontend/src/services/album-service.ts b/libertas-frontend/src/services/album-service.ts index 17379c1..3dcf7fe 100644 --- a/libertas-frontend/src/services/album-service.ts +++ b/libertas-frontend/src/services/album-service.ts @@ -1,4 +1,4 @@ -import type { Album } from "@/domain/types" +import type { Album, Media } from "@/domain/types" import apiClient from "@/services/api-client" export type CreateAlbumPayload = { @@ -23,4 +23,47 @@ export const createAlbum = async ( ): Promise => { const { data } = await apiClient.post('/albums', payload) return data -} \ No newline at end of file +} + +/** + * Fetches all media for a specific album. + */ +export const getAlbumMedia = async (albumId: string): Promise => { + const { data } = await apiClient.get(`/albums/${albumId}/media`); + + + const prefix = import.meta.env.VITE_PREFIX_PATH || apiClient.defaults.baseURL; + + + const processedMedia = data.map((media: Media) => ({ + ...media, + file_url: `${prefix}${media.file_url}`, + thumbnail_url: media.thumbnail_url + ? `${prefix}${media.thumbnail_url}` + : null, + })); + + return processedMedia; +}; + +export type AddMediaToAlbumPayload = { + media_ids: string[]; +}; + +/** + * Adds a list of media IDs to a specific album. + */ +export const addMediaToAlbum = async ( + albumId: string, + payload: AddMediaToAlbumPayload, +): Promise => { + await apiClient.post(`/albums/${albumId}/media`, payload); +}; + +/** + * Fetches a single album by its ID. + */ +export const getAlbum = async (albumId: string): Promise => { + const { data } = await apiClient.get(`/albums/${albumId}`); + return data; +}; \ 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 87904f1..bd73748 100644 --- a/libertas_api/src/handlers/album_handlers.rs +++ b/libertas_api/src/handlers/album_handlers.rs @@ -13,8 +13,8 @@ use crate::{ error::ApiError, middleware::auth::UserId, schema::{ - AddMediaToAlbumRequest, AlbumResponse, CreateAlbumRequest, SetThumbnailRequest, - ShareAlbumRequest, UpdateAlbumRequest, + AddMediaToAlbumRequest, AlbumResponse, CreateAlbumRequest, MediaResponse, + SetThumbnailRequest, ShareAlbumRequest, UpdateAlbumRequest, }, state::AppState, }; @@ -133,6 +133,21 @@ async fn set_album_thumbnail( Ok(StatusCode::OK) } +async fn get_media_for_album( + State(state): State, + UserId(user_id): UserId, + Path(album_id): Path, +) -> Result>, ApiError> { + let media_list = state + .album_service + .get_album_media(album_id, user_id) + .await?; + + let response = media_list.into_iter().map(MediaResponse::from).collect(); + + Ok(Json(response)) +} + pub fn album_routes() -> Router { Router::new() .route("/", post(create_album).get(list_user_albums)) @@ -143,6 +158,9 @@ pub fn album_routes() -> Router { .delete(delete_album), ) .route("/{id}/thumbnail", put(set_album_thumbnail)) - .route("/{id}/media", post(add_media_to_album)) + .route( + "/{id}/media", + post(add_media_to_album).get(get_media_for_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 f723c05..d9cd450 100644 --- a/libertas_api/src/services/album_service.rs +++ b/libertas_api/src/services/album_service.rs @@ -5,7 +5,7 @@ use chrono::Utc; use libertas_core::{ authz::{self, Permission}, error::{CoreError, CoreResult}, - models::{Album, PublicAlbumBundle}, + models::{Album, Media, PublicAlbumBundle}, repositories::{AlbumRepository, AlbumShareRepository}, schema::{AddMediaToAlbumData, CreateAlbumData, ShareAlbumData, UpdateAlbumData}, services::{AlbumService, AuthorizationService}, @@ -188,4 +188,14 @@ impl AlbumService for AlbumServiceImpl { .set_thumbnail_media_id(album_id, media_id) .await } + + async fn get_album_media(&self, album_id: Uuid, user_id: Uuid) -> CoreResult> { + self.auth_service + .check_permission(Some(user_id), Permission::ViewAlbum(album_id)) + .await?; + + let media = self.album_repo.list_media_by_album_id(album_id).await?; + + Ok(media) + } } diff --git a/libertas_core/src/services.rs b/libertas_core/src/services.rs index 6203f7d..b73a536 100644 --- a/libertas_core/src/services.rs +++ b/libertas_core/src/services.rs @@ -58,6 +58,7 @@ pub trait AlbumService: Send + Sync { media_id: Uuid, user_id: Uuid, ) -> CoreResult<()>; + async fn get_album_media(&self, album_id: Uuid, user_id: Uuid) -> CoreResult>; } #[async_trait]