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 index e2cf664..f4ac399 100644 --- a/libertas-frontend/src/components/albums/add-media-to-album-dialog.tsx +++ b/libertas-frontend/src/components/albums/add-media-to-album-dialog.tsx @@ -31,7 +31,7 @@ export function AddMediaToAlbumDialog({ albumId }: AddMediaToAlbumDialogProps) { fetchNextPage, hasNextPage, isFetchingNextPage, - } = useGetMediaList(); + } = useGetMediaList(1, 20); const { mutate: addMedia, isPending: isAdding } = useAddMediaToAlbum(albumId); diff --git a/libertas-frontend/src/components/media/face-overlay.tsx b/libertas-frontend/src/components/media/face-overlay.tsx new file mode 100644 index 0000000..65e7c11 --- /dev/null +++ b/libertas-frontend/src/components/media/face-overlay.tsx @@ -0,0 +1,56 @@ +import type { FaceRegion } from "@/domain/types"; +import { cn } from "@/lib/utils"; + +type FaceOverlayProps = { + faces: FaceRegion[]; + hoveredFaceId: string | null; + imageWidth: number; + imageHeight: number; + className?: string; + onFaceClick?: (face: FaceRegion) => void; +}; + +export function FaceOverlay({ + faces, + hoveredFaceId, + imageWidth, + imageHeight, + className, + onFaceClick, +}: FaceOverlayProps) { + if (!imageWidth || !imageHeight) return null; + + return ( +
+ {faces.map((face) => { + const isHovered = face.id === hoveredFaceId; + + // Calculate percentages + const left = (face.x_min / imageWidth) * 100; + const top = (face.y_min / imageHeight) * 100; + const width = ((face.x_max - face.x_min) / imageWidth) * 100; + const height = ((face.y_max - face.y_min) / imageHeight) * 100; + + return ( +
onFaceClick?.(face)} + title={face.person_id ? "Assigned Person" : "Unknown Person"} + /> + ); + })} +
+ ); +} diff --git a/libertas-frontend/src/components/media/media-details-sidebar.tsx b/libertas-frontend/src/components/media/media-details-sidebar.tsx index 946ff51..10e18be 100644 --- a/libertas-frontend/src/components/media/media-details-sidebar.tsx +++ b/libertas-frontend/src/components/media/media-details-sidebar.tsx @@ -1,6 +1,5 @@ -import type { Media, MediaMetadata } from "@/domain/types"; +import type { FaceRegion, Media, MediaMetadata } from "@/domain/types"; import { useGetMediaDetails } from "@/features/media/use-media"; -import { useListMediaFaces } from "@/features/faces/use-faces"; import { useListMediaTags } from "@/features/tags/use-tags"; import { ScrollArea } from "@/components/ui/scroll-area"; import { @@ -17,6 +16,10 @@ import { Separator } from "../ui/separator"; type MediaDetailsSidebarProps = { media: Media; + faces: FaceRegion[] | undefined; + isLoadingFaces: boolean; + onHoverFace: (faceId: string | null) => void; + onFaceClick: (face: FaceRegion) => void; }; function findMeta( @@ -28,14 +31,17 @@ function findMeta( const manualTags = new Set(["DateTimeOriginal", "Make", "Model"]); -export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) { +export function MediaDetailsSidebar({ + media, + faces, + isLoadingFaces, + onHoverFace, + onFaceClick +}: MediaDetailsSidebarProps) { const { data: details, isLoading: isLoadingDetails } = useGetMediaDetails( media.id ); const { data: tags, isLoading: isLoadingTags } = useListMediaTags(media.id); - const { data: faces, isLoading: isLoadingFaces } = useListMediaFaces( - media.id - ); const displayDate = media.date_taken ? format(parseISO(media.date_taken), "MMMM d, yyyy 'at' h:mm a") @@ -53,8 +59,6 @@ export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) { ) .sort((a, b) => a.tag_name.localeCompare(b.tag_name)); - console.log("Other Metadata:", details); - return (
@@ -70,7 +74,7 @@ export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) { defaultValue={["details", "tags", "people"]} className="w-full" > - {/* --- People Section (Unchanged) --- */} + {/* --- People Section --- */} People @@ -78,7 +82,13 @@ export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) { {faces && faces.length > 0 && (
{faces.map((face) => ( - + onHoverFace(face.id)} + onMouseLeave={() => onHoverFace(null)} + onClick={() => onFaceClick(face)} + /> ))}
)} diff --git a/libertas-frontend/src/components/media/media-viewer.tsx b/libertas-frontend/src/components/media/media-viewer.tsx index a02fcc5..a989d4c 100644 --- a/libertas-frontend/src/components/media/media-viewer.tsx +++ b/libertas-frontend/src/components/media/media-viewer.tsx @@ -1,5 +1,5 @@ import { Dialog, DialogContent } from "@/components/ui/dialog"; -import { type Media } from "@/domain/types"; +import { type Media, type FaceRegion } from "@/domain/types"; import { AuthenticatedImage } from "./authenticated-image"; import { Skeleton } from "../ui/skeleton"; import { @@ -8,6 +8,10 @@ import { ResizableHandle, } from "@/components/ui/resizable"; import { MediaDetailsSidebar } from "./media-details-sidebar"; +import { useListMediaFaces } from "@/features/faces/use-faces"; +import { useState, useRef } from "react"; +import { FaceOverlay } from "./face-overlay"; +import { PersonAssignmentDialog } from "@/components/people/person-assignment-dialog"; type MediaViewerProps = { media: Media | null; @@ -16,21 +20,51 @@ type MediaViewerProps = { export function MediaViewer({ media, onOpenChange }: MediaViewerProps) { const isOpen = media !== null; + const { data: faces, isLoading: isLoadingFaces } = useListMediaFaces(media?.id ?? ""); + const [hoveredFaceId, setHoveredFaceId] = useState(null); + const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null); + const imageRef = useRef(null); + + // Assignment dialog state + const [assignmentDialogOpen, setAssignmentDialogOpen] = useState(false); + const [selectedFace, setSelectedFace] = useState(null); + + const handleImageLoad = (e: React.SyntheticEvent) => { + const img = e.currentTarget; + setImageDimensions({ width: img.naturalWidth, height: img.naturalHeight }); + }; + + const handleFaceClick = (face: FaceRegion) => { + setSelectedFace(face); + setAssignmentDialogOpen(true); + }; return ( - {/* We use a resizable panel group to show the image and sidebar */} {/* --- Panel 1: The Image --- */}
{media ? ( - +
+ + {faces && imageDimensions && ( + + )} +
) : ( )} @@ -43,12 +77,31 @@ export function MediaViewer({ media, onOpenChange }: MediaViewerProps) { {/* --- Panel 2: The Details Sidebar --- */} {media ? ( - + ) : ( )} + + {selectedFace && media && ( + { + setAssignmentDialogOpen(open); + if (!open) setSelectedFace(null); + }} + faceId={selectedFace.id} + mediaId={media.id} + currentPersonId={selectedFace.person_id} + /> + )}
); diff --git a/libertas-frontend/src/components/people/person-assignment-dialog.tsx b/libertas-frontend/src/components/people/person-assignment-dialog.tsx new file mode 100644 index 0000000..6691326 --- /dev/null +++ b/libertas-frontend/src/components/people/person-assignment-dialog.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useListPeople, useCreatePerson } from "@/features/people/use-people"; +import { useAssignFace } from "@/features/faces/use-faces"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { UserSquare, Plus } from "lucide-react"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; + +type PersonAssignmentDialogProps = { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + faceId: string; + mediaId: string; + currentPersonId: string | null; +}; + +export function PersonAssignmentDialog({ + isOpen, + onOpenChange, + faceId, + mediaId, + currentPersonId, +}: PersonAssignmentDialogProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [newPersonName, setNewPersonName] = useState(""); + + const { data: people } = useListPeople(); + const assignFace = useAssignFace(faceId, mediaId); + const createPerson = useCreatePerson(); + + const filteredPeople = people?.filter((person) => + person.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const handleAssign = (personId: string) => { + assignFace.mutate( + { person_id: personId }, + { + onSuccess: () => { + onOpenChange(false); + }, + } + ); + }; + + const handleCreateAndAssign = () => { + if (!newPersonName.trim()) return; + + createPerson.mutate( + { name: newPersonName }, + { + onSuccess: (newPerson) => { + handleAssign(newPerson.id); + }, + } + ); + }; + + return ( + + + + Assign Person + + + {isCreating ? ( +
+ setNewPersonName(e.target.value)} + autoFocus + /> +
+ + +
+
+ ) : ( +
+ setSearchQuery(e.target.value)} + /> + + +
+ + + {filteredPeople?.map((person) => ( + + ))} +
+
+
+ )} +
+
+ ); +} diff --git a/libertas-frontend/src/components/people/person-face-badge.tsx b/libertas-frontend/src/components/people/person-face-badge.tsx index d729f0f..8400aec 100644 --- a/libertas-frontend/src/components/people/person-face-badge.tsx +++ b/libertas-frontend/src/components/people/person-face-badge.tsx @@ -2,26 +2,52 @@ import { useGetPerson } from "@/features/people/use-people"; import { Link } from "@tanstack/react-router"; import { Badge } from "@/components/ui/badge"; import { UserSquare } from "lucide-react"; +import type { FaceRegion } from "@/domain/types"; +import { cn } from "@/lib/utils"; type PersonFaceBadgeProps = { - personId: string | null; + face: FaceRegion; + onMouseEnter?: () => void; + onMouseLeave?: () => void; + onClick?: () => void; }; -export function PersonFaceBadge({ personId }: PersonFaceBadgeProps) { - const { data: person } = useGetPerson(personId ?? ""); +export function PersonFaceBadge({ + face, + onMouseEnter, + onMouseLeave, + onClick +}: PersonFaceBadgeProps) { + const { data: person } = useGetPerson(face.person_id ?? ""); const content = ( { + // Prevent navigation if we're just assigning + if (!face.person_id) { + e.preventDefault(); + onClick?.(); + } + }} > - {person ? person.name : personId ? "Loading..." : "Unknown"} + {person ? person.name : face.person_id ? "Loading..." : "Unknown"} ); - if (!personId || !person) { - return content; + if (!face.person_id || !person) { + return ( +
+ {content} +
+ ); } return ( @@ -29,6 +55,8 @@ export function PersonFaceBadge({ personId }: PersonFaceBadgeProps) { to="/people/$personId" params={{ personId: person.id }} className="hover:opacity-80" + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} > {content} diff --git a/libertas-frontend/src/services/media-service.ts b/libertas-frontend/src/services/media-service.ts index af726fe..ff1cef1 100644 --- a/libertas-frontend/src/services/media-service.ts +++ b/libertas-frontend/src/services/media-service.ts @@ -80,8 +80,6 @@ export const getMediaDetails = async ( mediaId: string, ): Promise => { const { data } = await apiClient.get(`/media/${mediaId}`); - console.log('Data for media details: ', data); - // Process the media URLs in the details response data.file_url = `${API_PREFIX}${data.file_url}`; data.thumbnail_url = data.thumbnail_url