feat: implement add media to album functionality with dialog and API integration

This commit is contained in:
2025-11-16 01:38:04 +01:00
parent 43157cef4e
commit 07b797b82b
10 changed files with 321 additions and 57 deletions

View File

@@ -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<string[]>([]);
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 (
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
setSelectedMediaIds([]);
}
}}
>
<DialogTrigger asChild>
<Button variant="outline">
<Plus size={18} className="mr-2" />
Add Photos
</Button>
</DialogTrigger>
<DialogContent className="max-w-5xl h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Add Photos to Album</DialogTitle>
<DialogDescription>
Select photos from your library to add to this album.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto pr-2">
{isLoading && <p>Loading photos...</p>}
{error && <p>Error loading photos: {error.message}</p>}
{data && (
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 gap-2">
{data.pages.map((page) =>
page.data.map((media) => {
const isSelected = selectedMediaIds.includes(media.id);
return (
<div
key={media.id}
className={cn(
"aspect-square bg-gray-200 rounded-md overflow-hidden cursor-pointer transition-all",
isSelected
? "ring-4 ring-blue-500 ring-offset-2 opacity-100"
: "opacity-80 hover:opacity-100"
)}
onClick={() => toggleSelection(media.id)}
>
<AuthenticatedImage
src={media.thumbnail_url ?? media.file_url}
alt={media.original_filename}
className="w-full h-full object-cover"
/>
</div>
);
})
)}
</div>
)}
{hasNextPage && (
<div className="flex justify-center mt-6">
<Button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? "Loading more..." : "Load More"}
</Button>
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button
type="submit"
onClick={handleSubmit}
disabled={selectedMediaIds.length === 0 || isAdding}
>
{isAdding ? "Adding..." : `Add ${selectedMediaIds.length} Photo(s)`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -28,7 +28,6 @@ export function AlbumCard({ album }: AlbumCardProps) {
</CardContent>
<CardFooter className="p-4 pt-0">
{/* TODO: Show photo count */}
<p className="text-sm text-gray-600">0 items</p>
</CardFooter>
</Card>
</Link>

View File

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

View File

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

View File

@@ -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 (
<div className="p-2">
<h3>About</h3>
</div>
);
}

View File

@@ -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<Media | null>(null);
const isLoading = isLoadingAlbum || isLoadingMedia;
return (
<div>
<h1 className="text-3xl font-bold">Album: {albumId}</h1>
<p className="mt-4">
This page will show the details and photos for a single album.
</p>
{/* TODO: Fetch album details and display media grid */}
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold truncate">
{album?.name ?? "Loading album..."}
</h1>
<AddMediaToAlbumDialog albumId={albumId} />
</div>
{isLoading && <p>Loading photos...</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>
))}
</div>
)}
{media && media.length === 0 && <p>This album is empty.</p>}
<MediaViewer
media={selectedMedia}
onOpenChange={(open) => {
if (!open) {
setSelectedMedia(null);
}
}}
/>
</div>
);
}

View File

@@ -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<Album> => {
const { data } = await apiClient.post('/albums', payload)
return data
}
}
/**
* Fetches all media for a specific album.
*/
export const getAlbumMedia = async (albumId: string): Promise<Media[]> => {
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<void> => {
await apiClient.post(`/albums/${albumId}/media`, payload);
};
/**
* Fetches a single album by its ID.
*/
export const getAlbum = async (albumId: string): Promise<Album> => {
const { data } = await apiClient.get(`/albums/${albumId}`);
return data;
};