feat: implement add media to album functionality with dialog and API integration
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user