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 1ab43a4..a144fa1 100644
--- a/libertas-frontend/src/components/media/media-details-sidebar.tsx
+++ b/libertas-frontend/src/components/media/media-details-sidebar.tsx
@@ -1,7 +1,6 @@
-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 { useListMediaTags, useAddMediaTags, useRemoveMediaTag } from "@/features/tags/use-tags";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Accordion,
@@ -14,9 +13,17 @@ import { PersonFaceBadge } from "@/components/people/person-face-badge";
import { Skeleton } from "@/components/ui/skeleton";
import { format, parseISO } from "date-fns";
import { Separator } from "../ui/separator";
+import { useState } from "react";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Plus, X } from "lucide-react";
type MediaDetailsSidebarProps = {
media: Media;
+ faces: FaceRegion[] | undefined;
+ isLoadingFaces: boolean;
+ onHoverFace: (faceId: string | null) => void;
+ onFaceClick: (face: FaceRegion) => void;
};
function findMeta(
@@ -28,14 +35,34 @@ 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 addTags = useAddMediaTags(media.id);
+ const removeTag = useRemoveMediaTag(media.id);
+ const [newTag, setNewTag] = useState("");
+
+ const handleAddTag = () => {
+ if (!newTag.trim()) return;
+ addTags.mutate(
+ { tags: [newTag.trim()] },
+ {
+ onSuccess: () => setNewTag(""),
+ }
+ );
+ };
+
+ const handleRemoveTag = (tagName: string) => {
+ removeTag.mutate(tagName);
+ };
const displayDate = media.date_taken
? format(parseISO(media.date_taken), "MMMM d, yyyy 'at' h:mm a")
@@ -53,8 +80,6 @@ export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) {
)
.sort((a, b) => a.tag_name.localeCompare(b.tag_name));
- console.log("Other Metadata:", details);
-
return (
@@ -70,7 +95,7 @@ export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) {
defaultValue={["details", "tags", "people"]}
className="w-full"
>
- {/* --- People Section (Unchanged) --- */}
+ {/* --- People Section --- */}
People
@@ -78,7 +103,13 @@ export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) {
{faces && faces.length > 0 && (
{faces.map((face) => (
-
+
onHoverFace(face.id)}
+ onMouseLeave={() => onHoverFace(null)}
+ onClick={() => onFaceClick(face)}
+ />
))}
)}
@@ -92,14 +123,35 @@ export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) {
Tags
-
- {/* TODO: Add input to add tags */}
+
+
+
setNewTag(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleAddTag();
+ }}
+ className="h-8"
+ />
+
+
+
{isLoadingTags && }
{tags && tags.length > 0 && (
{tags.map((tag) => (
-
+
{tag.name}
+
))}
@@ -120,6 +172,8 @@ export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) {
value={`${cameraMake} ${cameraModel}`}
/>
)}
+ {cameraMake && }
+ {cameraModel && }
diff --git a/libertas-frontend/src/components/media/media-viewer.tsx b/libertas-frontend/src/components/media/media-viewer.tsx
index a02fcc5..574dbc7 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 { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
+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,54 @@ 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 (