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 ( - {/* We use a resizable panel group to show the image and sidebar */} + + {media?.original_filename ?? "Media Viewer"} + {/* --- Panel 1: The Image --- */}
{media ? ( - +
+ + {faces && imageDimensions && ( + + )} +
) : ( )} @@ -43,12 +80,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/components/sharing/share-dialog.tsx b/libertas-frontend/src/components/sharing/share-dialog.tsx new file mode 100644 index 0000000..bcf8c8c --- /dev/null +++ b/libertas-frontend/src/components/sharing/share-dialog.tsx @@ -0,0 +1,188 @@ +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { searchUsers } from "@/services/user-service"; +import type { User } from "@/domain/types"; +import { useQuery } from "@tanstack/react-query"; +import { useDebounce } from "@/hooks/use-debounce"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Loader2, X } from "lucide-react"; + +export type SharePermission = "view" | "contribute" | "can_use"; + +interface ShareDialogProps { + title: string; + trigger: React.ReactNode; + currentShares: { user: User; permission: SharePermission }[]; + onAddShare: (userId: string, permission: SharePermission) => Promise; + onRemoveShare: (userId: string) => Promise; + permissionOptions: { value: SharePermission; label: string }[]; +} + +export function ShareDialog({ + title, + trigger, + currentShares, + onAddShare, + onRemoveShare, + permissionOptions, +}: ShareDialogProps) { + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedPermission, setSelectedPermission] = useState( + permissionOptions[0].value + ); + const debouncedQuery = useDebounce(searchQuery, 300); + + const { data: searchResults, isLoading } = useQuery({ + queryKey: ["users", "search", debouncedQuery], + queryFn: () => searchUsers(debouncedQuery), + enabled: debouncedQuery.length > 1, + }); + + const handleAdd = async (user: User) => { + await onAddShare(user.id, selectedPermission); + setSearchQuery(""); // Clear search after adding + }; + + return ( + + {trigger} + + + {title} + +
+
+ setSearchQuery(e.target.value)} + className="flex-1" + /> + +
+ + {/* Search Results */} + {searchQuery.length > 1 && ( +
+ {isLoading ? ( +
+ +
+ ) : searchResults && searchResults.length > 0 ? ( + searchResults.map((user) => { + const isAlreadyShared = currentShares.some( + (s) => s.user.id === user.id + ); + return ( +
+
+ + + {user.username.substring(0, 2).toUpperCase()} + + +
+ + {user.username} + + + {user.email} + +
+
+ +
+ ); + }) + ) : ( +

+ No users found. +

+ )} +
+ )} + +
+

Shared with

+ {currentShares.length === 0 ? ( +

Not shared with anyone yet.

+ ) : ( +
+ {currentShares.map((share) => ( +
+
+ + + {share.user.username.substring(0, 2).toUpperCase()} + + +
+

+ {share.user.username} +

+

+ {share.permission} +

+
+
+ +
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/libertas-frontend/src/features/albums/use-albums.ts b/libertas-frontend/src/features/albums/use-albums.ts index 4fb14fc..f8c9945 100644 --- a/libertas-frontend/src/features/albums/use-albums.ts +++ b/libertas-frontend/src/features/albums/use-albums.ts @@ -9,9 +9,10 @@ import { removeMediaFromAlbum, setAlbumThumbnail, shareAlbum, + unshareAlbum, updateAlbum, + getAlbumShares, type AddMediaToAlbumPayload, - type CreateAlbumPayload, type RemoveMediaFromAlbumPayload, type SetAlbumThumbnailPayload, type ShareAlbumPayload, @@ -154,13 +155,32 @@ export const useShareAlbum = (albumId: string) => { return useMutation({ mutationFn: (payload: ShareAlbumPayload) => shareAlbum(albumId, payload), onSuccess: () => { - // Invalidate sharing info (when we add that query) - // queryClient.invalidateQueries({ queryKey: [ALBUMS_KEY, "details", albumId, "shares"] }); - // TODO: Add success toast + queryClient.invalidateQueries({ queryKey: [ALBUMS_KEY, "details", albumId, "shares"] }); }, }); }; +export const useUnshareAlbum = (albumId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (targetUserId: string) => unshareAlbum(albumId, targetUserId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [ALBUMS_KEY, "details", albumId, "shares"] }); + }, + }); +}; + +/** + * Query hook to fetch shares for an album. + */ +export const useGetAlbumShares = (albumId: string) => { + return useQuery({ + queryKey: [ALBUMS_KEY, "details", albumId, "shares"], + queryFn: () => getAlbumShares(albumId), + enabled: !!albumId, + }); +}; + /** * Mutation hook to set an album's thumbnail. */ diff --git a/libertas-frontend/src/features/media/use-media.ts b/libertas-frontend/src/features/media/use-media.ts index bf476d4..9d4b7b2 100644 --- a/libertas-frontend/src/features/media/use-media.ts +++ b/libertas-frontend/src/features/media/use-media.ts @@ -17,10 +17,25 @@ const MEDIA_KEY = ["media"]; * Query hook to fetch a paginated list of all media. * This uses `useInfiniteQuery` for "load more" functionality. */ -export const useGetMediaList = () => { +export const useGetMediaList = ( + page: number, + limit: number, + sortBy?: string, + order?: 'asc' | 'desc', + mimeType?: string, + filters?: string[] +) => { return useInfiniteQuery({ - queryKey: [MEDIA_KEY, "list"], - queryFn: ({ pageParam = 1 }) => getMediaList({ page: pageParam, limit: 20 }), + queryKey: [MEDIA_KEY, "list", page, limit, sortBy, order, mimeType, filters], + queryFn: ({ pageParam = 1 }) => + getMediaList({ + page: pageParam, + limit, + sort_by: sortBy, + order, + mime_type: mimeType, + filters, + }), getNextPageParam: (lastPage) => { return lastPage.has_next_page ? lastPage.page + 1 : undefined; }, diff --git a/libertas-frontend/src/features/people/use-people.ts b/libertas-frontend/src/features/people/use-people.ts index 594ef5d..016950e 100644 --- a/libertas-frontend/src/features/people/use-people.ts +++ b/libertas-frontend/src/features/people/use-people.ts @@ -16,6 +16,7 @@ import { unsharePerson, updatePerson, clusterFaces, + getPersonShares, type CreatePersonPayload, type MergePersonPayload, type SetPersonThumbnailPayload, @@ -45,7 +46,7 @@ export const useGetPerson = (personId: string) => { export const useListPersonMedia = (personId: string) => { return useInfiniteQuery({ queryKey: [PERSON_KEY, "details", personId, "media"], - queryFn: ({ pageParam = 1 }) => listMediaForPerson({personId, page: pageParam, limit: 20} ), + queryFn: ({ pageParam = 1 }) => listMediaForPerson({ personId, page: pageParam, limit: 20 }), getNextPageParam: (lastPage) => { return lastPage.has_next_page ? lastPage.page + 1 : undefined; }, @@ -64,6 +65,34 @@ export const useCreatePerson = () => { }); }; +export const useClusterFaces = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: clusterFaces, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [PERSON_KEY] }); + }, + }); +}; + +export const useSharePerson = (personId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: SharePersonPayload) => sharePerson(personId, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [PERSON_KEY, "details", personId, "shares"] }); + }, + }); +}; + +export const useGetPersonShares = (personId: string) => { + return useQuery({ + queryKey: [PERSON_KEY, "details", personId, "shares"], + queryFn: () => getPersonShares(personId), + enabled: !!personId, + }); +}; + export const useUpdatePerson = (personId: string) => { const queryClient = useQueryClient(); return useMutation({ @@ -95,16 +124,14 @@ export const useDeletePerson = (personId: string) => { }); }; -export const useSharePerson = (personId: string) => { - return useMutation({ - mutationFn: (payload: SharePersonPayload) => sharePerson(personId, payload), - }); -}; - export const useUnsharePerson = (personId: string) => { + const queryClient = useQueryClient(); return useMutation({ mutationFn: (payload: UnsharePersonPayload) => - unsharePerson(personId, payload), + unsharePerson(personId, payload.target_user_id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [PERSON_KEY, "details", personId, "shares"] }); + }, }); }; @@ -131,10 +158,4 @@ export const useSetPersonThumbnail = (personId: string) => { queryClient.invalidateQueries({ queryKey: [PERSON_KEY, "list"] }); }, }); -}; - -export const useClusterFaces = () => { - return useMutation({ - mutationFn: clusterFaces, - }); }; \ No newline at end of file diff --git a/libertas-frontend/src/hooks/use-debounce.ts b/libertas-frontend/src/hooks/use-debounce.ts new file mode 100644 index 0000000..eb328eb --- /dev/null +++ b/libertas-frontend/src/hooks/use-debounce.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; + +export function useDebounce(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay || 500); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/libertas-frontend/src/main.tsx b/libertas-frontend/src/main.tsx index 7830be8..fd8a9b3 100644 --- a/libertas-frontend/src/main.tsx +++ b/libertas-frontend/src/main.tsx @@ -1,4 +1,3 @@ -import React from "react"; import ReactDOM from "react-dom/client"; import { RouterProvider, createRouter } from "@tanstack/react-router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; diff --git a/libertas-frontend/src/routes/albums/$albumId.tsx b/libertas-frontend/src/routes/albums/$albumId.tsx index 7cd66cf..455adca 100644 --- a/libertas-frontend/src/routes/albums/$albumId.tsx +++ b/libertas-frontend/src/routes/albums/$albumId.tsx @@ -1,6 +1,8 @@ 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 { ShareDialog, type SharePermission } from "@/components/sharing/share-dialog"; +import { Button } from "@/components/ui/button"; import { ContextMenu, ContextMenuContent, @@ -11,10 +13,13 @@ import type { Media } from "@/domain/types"; import { useGetAlbum, useGetAlbumMedia, + useGetAlbumShares, useRemoveMediaFromAlbum, + useShareAlbum, + useUnshareAlbum, } from "@/features/albums/use-albums"; import { createFileRoute } from "@tanstack/react-router"; -import { Eye, Trash2 } from "lucide-react"; +import { Eye, Share2, Trash2 } from "lucide-react"; import { useState } from "react"; export const Route = createFileRoute("/albums/$albumId")({ @@ -33,6 +38,10 @@ function AlbumDetailPage() { isLoading: isLoadingMedia, error: mediaError, } = useGetAlbumMedia(albumId); + const { data: shares } = useGetAlbumShares(albumId); + const { mutateAsync: shareAlbum } = useShareAlbum(albumId); + const { mutateAsync: unshareAlbum } = useUnshareAlbum(albumId); + const [selectedMedia, setSelectedMedia] = useState(null); const { mutate: removeMedia, isPending: isRemoving } = @@ -42,18 +51,57 @@ function AlbumDetailPage() { const error = albumError || mediaError; const handleRemoveMedia = (mediaId: string) => { - removeMedia({ - media_ids: [mediaId], + if (confirm("Are you sure you want to remove this photo from the album?")) { + removeMedia({ + media_ids: [mediaId], + }); + } + }; + + const handleAddShare = async (userId: string, permission: SharePermission) => { + await shareAlbum({ + target_user_id: userId, + permission: permission === "can_use" ? "contribute" : "view", }); }; + const handleRemoveShare = async (userId: string) => { + if (confirm("Are you sure you want to remove this share?")) { + await unshareAlbum(userId); + } + }; + + const currentShares = + shares?.map((s) => ({ + user: s.user, + permission: s.permission === "contribute" ? "can_use" : ("view" as SharePermission), + })) ?? []; + return (

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

- +
+ + + Share + + } + currentShares={currentShares} + onAddShare={handleAddShare} + onRemoveShare={handleRemoveShare} + permissionOptions={[ + { value: "view", label: "Can View" }, + { value: "can_use", label: "Can Contribute" }, + ]} + /> + +
{isLoading &&

Loading photos...

} diff --git a/libertas-frontend/src/routes/media/index.tsx b/libertas-frontend/src/routes/media/index.tsx index c3383d7..9aab844 100644 --- a/libertas-frontend/src/routes/media/index.tsx +++ b/libertas-frontend/src/routes/media/index.tsx @@ -3,16 +3,33 @@ import { createFileRoute } from "@tanstack/react-router"; import { Button } from "@/components/ui/button"; import { AuthenticatedImage } from "@/components/media/authenticated-image"; import type { Media } from "@/domain/types"; -import { useMemo, useState } from "react"; // Import useMemo +import { useMemo, useState } from "react"; import { MediaViewer } from "@/components/media/media-viewer"; -import { groupMediaByDate } from "@/lib/date-utils"; // Import our new helper -import { parseISO } from "date-fns"; +import { groupMediaByDate } from "@/lib/date-utils"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; export const Route = createFileRoute("/media/")({ component: MediaPage, }); function MediaPage() { + const [sortBy, setSortBy] = useState("created_at"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); + const [mimeType, setMimeType] = useState(undefined); + const [filters, setFilters] = useState([]); + + // Filter input state + const [filterField, setFilterField] = useState('original_filename'); + const [customFieldName, setCustomFieldName] = useState(''); + const [filterOperator, setFilterOperator] = useState('like'); + const [filterValue, setFilterValue] = useState(''); + const { data, isLoading, @@ -20,20 +37,12 @@ function MediaPage() { fetchNextPage, hasNextPage, isFetchingNextPage, - } = useGetMediaList(); + } = useGetMediaList(1, 20, sortBy, sortOrder, mimeType, filters); const [selectedMedia, setSelectedMedia] = useState(null); const allMedia = useMemo( - () => - data?.pages - .flatMap((page) => page.data) - .sort((a, b) => { - // Sort by date (newest first) - const dateA = a.date_taken ?? a.created_at; - const dateB = b.date_taken ?? b.created_at; - return parseISO(dateB).getTime() - parseISO(dateA).getTime(); - }) ?? [], + () => data?.pages.flatMap((page) => page.data) ?? [], [data] ); @@ -44,10 +53,146 @@ function MediaPage() { [groupedMedia] ); + const handleAddFilter = () => { + const field = filterField === 'custom' ? customFieldName : filterField; + if (field && filterValue) { + const newFilter = `${field}:${filterOperator}:${filterValue}`; + setFilters([...filters, newFilter]); + setFilterValue(''); // Clear value after adding + } + }; + + const handleRemoveFilter = (index: number) => { + const newFilters = [...filters]; + newFilters.splice(index, 1); + setFilters(newFilters); + }; + return (
-
-

All Photos

+
+
+

All Photos

+
+ + + + + {sortBy === "custom" && ( + { + if (e.target.value) setSortBy(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + setSortBy(e.currentTarget.value); + } + }} + /> + )} + + +
+
+ + {/* Advanced Filters */} +
+ Add Filter: + + + {filterField === 'custom' && ( + setCustomFieldName(e.target.value)} + /> + )} + + + + setFilterValue(e.target.value)} + placeholder="Value" + className="border rounded px-2 py-1 text-sm w-[150px]" + onKeyDown={(e) => e.key === 'Enter' && handleAddFilter()} + /> + +
+ + {/* Active Filters List */} + {filters.length > 0 && ( +
+ {filters.map((f, i) => ( +
+ {f.replace(/:/g, ' ')} + +
+ ))} +
+ )}
{isLoading &&

Loading photos...

} diff --git a/libertas-frontend/src/routes/people/$personId.tsx b/libertas-frontend/src/routes/people/$personId.tsx index b511ea1..5dfd0c1 100644 --- a/libertas-frontend/src/routes/people/$personId.tsx +++ b/libertas-frontend/src/routes/people/$personId.tsx @@ -1,11 +1,19 @@ import { useState } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { useGetPerson, useListPersonMedia } from "@/features/people/use-people"; +import { ShareDialog, type SharePermission } from "@/components/sharing/share-dialog"; +import { + useGetPerson, + useGetPersonShares, + useListPersonMedia, + useSharePerson, + useUnsharePerson, + useUpdatePerson, +} from "@/features/people/use-people"; import { AuthenticatedImage } from "@/components/media/authenticated-image"; import { MediaViewer } from "@/components/media/media-viewer"; import { Button } from "@/components/ui/button"; import { type Media } from "@/domain/types"; -import { EditPersonDialog } from "@/components/people/edit-person-dialog"; +import { Share2 } from "lucide-react"; export const Route = createFileRoute("/people/$personId")({ component: PersonDetailPage, @@ -15,27 +23,100 @@ function PersonDetailPage() { const { personId } = Route.useParams(); const { data: person, isLoading: isLoadingPerson } = useGetPerson(personId); const { - data: mediaPages, - isLoading: isLoadingMedia, + data: mediaPage, fetchNextPage, hasNextPage, isFetchingNextPage, + isLoading: isLoadingMedia, } = useListPersonMedia(personId); + const { data: shares } = useGetPersonShares(personId); + const { mutateAsync: sharePerson } = useSharePerson(personId); + const { mutateAsync: unsharePerson } = useUnsharePerson(personId); + const { mutate: updatePerson } = useUpdatePerson(personId); + const [isEditingName, setIsEditingName] = useState(false); + const [newName, setNewName] = useState(""); const [selectedMedia, setSelectedMedia] = useState(null); - const allMedia = mediaPages?.pages.flatMap((page) => page.data) ?? []; + const handleNameSave = () => { + if (newName && newName !== person?.name) { + updatePerson({ name: newName }); + } + setIsEditingName(false); + }; + + const handleAddShare = async (userId: string, permission: SharePermission) => { + await sharePerson({ + target_user_id: userId, + permission: permission === "can_use" ? "can_use" : "view", + }); + }; + + const handleRemoveShare = async (userId: string) => { + if (confirm("Are you sure you want to remove this share?")) { + await unsharePerson({ target_user_id: userId }); + } + }; + + const currentShares = + shares?.map((s) => ({ + user: s.user, + permission: s.permission === "can_use" ? "can_use" : ("view" as SharePermission), + })) ?? []; + + if (isLoadingPerson) return
Loading person...
; + if (!person) return
Person not found
; + + const allMedia = mediaPage?.pages.flatMap((page) => page.data) ?? []; return (
-
-

- {person?.name ?? "Loading person..."} -

- {person && } +
+ {isEditingName ? ( +
+ setNewName(e.target.value)} + autoFocus + /> + + +
+ ) : ( +

{ + setNewName(person.name); + setIsEditingName(true); + }} + title="Click to edit name" + > + {person.name} +

+ )} + + + + Share + + } + currentShares={currentShares} + onAddShare={handleAddShare} + onRemoveShare={handleRemoveShare} + permissionOptions={[ + { value: "view", label: "Can View" }, + { value: "can_use", label: "Can Use (Assign Faces)" }, + ]} + />
- {(isLoadingPerson || isLoadingMedia) && !mediaPages && ( + {(isLoadingPerson || isLoadingMedia) && !mediaPage && (

Loading photos...

)} diff --git a/libertas-frontend/src/services/album-service.ts b/libertas-frontend/src/services/album-service.ts index acf10b7..6b9bff6 100644 --- a/libertas-frontend/src/services/album-service.ts +++ b/libertas-frontend/src/services/album-service.ts @@ -1,4 +1,4 @@ -import type { Album, AlbumPermission, Media } from "@/domain/types"; +import type { Album, AlbumPermission, Media, User } from "@/domain/types"; import apiClient from "@/services/api-client"; import { processMediaUrls } from "./media-service"; @@ -87,9 +87,28 @@ export const shareAlbum = async ( await apiClient.post(`/albums/${albumId}/share`, payload); }; +export const unshareAlbum = async ( + albumId: string, + targetUserId: string, +): Promise => { + await apiClient.delete(`/albums/${albumId}/share`, { + data: { target_user_id: targetUserId }, + }); +}; + export const setAlbumThumbnail = async ( albumId: string, payload: SetAlbumThumbnailPayload, ): Promise => { await apiClient.put(`/albums/${albumId}/thumbnail`, payload); +}; + +export type AlbumShare = { + user: User; + permission: AlbumPermission; +}; + +export const getAlbumShares = async (albumId: string): Promise => { + const { data } = await apiClient.get(`/albums/${albumId}/share`); + return data; }; \ No newline at end of file diff --git a/libertas-frontend/src/services/media-service.ts b/libertas-frontend/src/services/media-service.ts index b2cc7a7..ff1cef1 100644 --- a/libertas-frontend/src/services/media-service.ts +++ b/libertas-frontend/src/services/media-service.ts @@ -1,12 +1,16 @@ import type { Media, MediaDetails, PaginatedResponse } from "@/domain/types" import apiClient from "@/services/api-client" -type MediaListParams = { - page: number - limit: number -} +export interface MediaListParams { + page?: number; + limit?: number; + sort_by?: string; + order?: 'asc' | 'desc'; + mime_type?: string; + filters?: string[]; +}; -const API_PREFIX = import.meta.env.VITE_PREFIX_PATH || ''; +const API_PREFIX = import.meta.env.VITE_PREFIX_PATH || ""; export const processMediaUrls = (media: Media): Media => ({ ...media, @@ -22,10 +26,23 @@ export const processMediaUrls = (media: Media): Media => ({ export const getMediaList = async ({ page, limit, + sort_by, + order, + mime_type, + filters, }: MediaListParams): Promise> => { - const { data } = await apiClient.get("/media", { - params: { page, limit }, - }); + const params = new URLSearchParams(); + if (page) params.append("page", page.toString()); + if (limit) params.append("limit", limit.toString()); + if (sort_by) params.append("sort_by", sort_by); + if (order) params.append("order", order); + if (mime_type) params.append("mime_type", mime_type); + + if (filters && filters.length > 0) { + filters.forEach(f => params.append("filters", f)); + } + + const { data } = await apiClient.get(`/media?${params.toString()}`); data.data = data.data.map(processMediaUrls); return data; @@ -63,10 +80,8 @@ 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.file_url = `${API_PREFIX}${data.file_url}`; data.thumbnail_url = data.thumbnail_url ? `${API_PREFIX}${data.thumbnail_url}` : null; diff --git a/libertas-frontend/src/services/person-service.ts b/libertas-frontend/src/services/person-service.ts index f3050bb..9cd85a3 100644 --- a/libertas-frontend/src/services/person-service.ts +++ b/libertas-frontend/src/services/person-service.ts @@ -3,6 +3,7 @@ import type { PaginatedResponse, Person, PersonPermission, + User, } from "@/domain/types"; import apiClient from "@/services/api-client"; import { processMediaUrls } from "./media-service"; // We can import the helper @@ -35,7 +36,7 @@ export type SetPersonThumbnailPayload = { }; export type ListPeopleParams = { - personId: string, + personId: string, page: number; limit: number; }; @@ -88,11 +89,15 @@ export const sharePerson = async ( await apiClient.post(`/people/${personId}/share`, payload); }; + + export const unsharePerson = async ( personId: string, - payload: UnsharePersonPayload, + targetUserId: string, ): Promise => { - await apiClient.delete(`/people/${personId}/share`, { data: payload }); + await apiClient.delete(`/people/${personId}/share`, { + data: { target_user_id: targetUserId }, + }); }; export const mergePerson = async ( @@ -109,6 +114,16 @@ export const setPersonThumbnail = async ( await apiClient.put(`/people/${personId}/thumbnail`, payload); }; +export type PersonShare = { + user: User; + permission: PersonPermission; +}; + +export const getPersonShares = async (personId: string): Promise => { + const { data } = await apiClient.get(`/people/${personId}/share`); + return data; +}; + export const clusterFaces = async (): Promise => { await apiClient.post("/people/cluster"); }; \ No newline at end of file diff --git a/libertas-frontend/src/services/user-service.ts b/libertas-frontend/src/services/user-service.ts index e241c24..2caa5d2 100644 --- a/libertas-frontend/src/services/user-service.ts +++ b/libertas-frontend/src/services/user-service.ts @@ -7,4 +7,14 @@ import apiClient from "@/services/api-client"; export const getMe = async (): Promise => { const { data } = await apiClient.get("/users/me"); return data; +}; + +/** + * Searches for users by username or email. + */ +export const searchUsers = async (query: string): Promise => { + const { data } = await apiClient.get("/users/search", { + params: { query }, + }); + return data; }; \ No newline at end of file diff --git a/libertas_api/src/extractors/query_options.rs b/libertas_api/src/extractors/query_options.rs index 5154e65..bca8d46 100644 --- a/libertas_api/src/extractors/query_options.rs +++ b/libertas_api/src/extractors/query_options.rs @@ -5,7 +5,8 @@ use axum::{ use libertas_core::{ error::CoreError, schema::{ - FilterParams, ListMediaOptions, MetadataFilter, PaginationParams, SortOrder, SortParams, + FilterCondition, FilterOperator, FilterParams, ListMediaOptions, MetadataFilter, + PaginationParams, SortOrder, SortParams, }, }; @@ -61,9 +62,44 @@ impl From for ListMediaOptions { Some(PaginationParams { page, limit }) }; + let conditions = if params.filters.is_empty() { + None + } else { + let mut conds = Vec::new(); + for filter_str in params.filters { + let parts: Vec<&str> = filter_str.splitn(3, ':').collect(); + if parts.len() == 3 { + let field = parts[0].to_string(); + let op_str = parts[1]; + let value = parts[2].to_string(); + + let operator = match op_str.to_lowercase().as_str() { + "eq" => Some(FilterOperator::Eq), + "neq" => Some(FilterOperator::Neq), + "like" => Some(FilterOperator::Like), + "gt" => Some(FilterOperator::Gt), + "lt" => Some(FilterOperator::Lt), + "gte" => Some(FilterOperator::Gte), + "lte" => Some(FilterOperator::Lte), + _ => None, + }; + + if let Some(op) = operator { + conds.push(FilterCondition { + field, + operator: op, + value, + }); + } + } + } + if conds.is_empty() { None } else { Some(conds) } + }; + let filter = Some(FilterParams { mime_type: params.mime_type, metadata_filters, + conditions, }); ListMediaOptions { @@ -81,10 +117,33 @@ impl FromRequestParts for ApiListMediaOptions { parts: &mut Parts, state: &AppState, ) -> Result { - let Query(params) = Query::::from_request_parts(parts, state) + let Query(raw_params) = Query::>::from_request_parts(parts, state) .await .map_err(|e| ApiError::from(CoreError::Validation(e.to_string())))?; + let mut params = ListMediaParams { + sort_by: None, + order: None, + mime_type: None, + metadata: Vec::new(), + filters: Vec::new(), + page: None, + limit: None, + }; + + for (key, value) in raw_params { + match key.as_str() { + "sort_by" => params.sort_by = Some(value), + "order" => params.order = Some(value), + "mime_type" => params.mime_type = Some(value), + "metadata" => params.metadata.push(value), + "filters" => params.filters.push(value), + "page" => params.page = value.parse().ok(), + "limit" => params.limit = value.parse().ok(), + _ => {} + } + } + Ok(ApiListMediaOptions(params.into())) } } diff --git a/libertas_api/src/handlers/album_handlers.rs b/libertas_api/src/handlers/album_handlers.rs index c6bef9f..b286ebd 100644 --- a/libertas_api/src/handlers/album_handlers.rs +++ b/libertas_api/src/handlers/album_handlers.rs @@ -83,6 +83,46 @@ async fn list_user_albums( Ok(Json(response)) } +async fn unshare_album( + State(state): State, + UserId(user_id): UserId, + Path(album_id): Path, + Json(payload): Json, +) -> Result { + state + .album_service + .unshare_album(album_id, payload.target_user_id, user_id) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn list_album_shares( + State(state): State, + UserId(user_id): UserId, + Path(album_id): Path, +) -> Result>, ApiError> { + let shares = state + .album_service + .get_album_shares(album_id, user_id) + .await?; + + let response = shares + .into_iter() + .map(|(user, permission)| crate::schema::AlbumShareResponse { + user: crate::schema::UserResponse { + id: user.id, + username: user.username, + email: user.email, + storage_used: user.storage_used, + storage_quota: user.storage_quota, + }, + permission, + }) + .collect(); + + Ok(Json(response)) +} + async fn get_album_details( State(state): State, UserId(user_id): UserId, @@ -179,5 +219,10 @@ pub fn album_routes() -> Router { .get(get_media_for_album) .delete(remove_media_from_album), ) - .route("/{id}/share", post(share_album)) + .route( + "/{id}/share", + post(share_album) + .get(list_album_shares) + .delete(unshare_album), + ) } diff --git a/libertas_api/src/handlers/person_handlers.rs b/libertas_api/src/handlers/person_handlers.rs index 001c9d5..6c6c83c 100644 --- a/libertas_api/src/handlers/person_handlers.rs +++ b/libertas_api/src/handlers/person_handlers.rs @@ -29,7 +29,9 @@ pub fn people_routes() -> Router { ) .route( "/{person_id}/share", - post(share_person).delete(unshare_person), + post(share_person) + .delete(unshare_person) + .get(list_person_shares), ) .route("/{person_id}/merge", post(merge_person)) .route("/{person_id}/thumbnail", put(set_person_thumbnail)) @@ -120,7 +122,7 @@ async fn unshare_person( State(state): State, UserId(user_id): UserId, Path(person_id): Path, - Json(payload): Json, + Json(payload): Json, ) -> Result { state .person_service @@ -129,6 +131,33 @@ async fn unshare_person( Ok(StatusCode::NO_CONTENT) } +async fn list_person_shares( + State(state): State, + UserId(user_id): UserId, + Path(person_id): Path, +) -> Result>, ApiError> { + let shares = state + .person_service + .get_person_shares(person_id, user_id) + .await?; + + let response = shares + .into_iter() + .map(|(user, permission)| crate::schema::PersonShareResponse { + user: crate::schema::UserResponse { + id: user.id, + username: user.username, + email: user.email, + storage_used: user.storage_used, + storage_quota: user.storage_quota, + }, + permission, + }) + .collect(); + + Ok(Json(response)) +} + async fn list_faces_for_media( State(state): State, UserId(user_id): UserId, diff --git a/libertas_api/src/handlers/user_handlers.rs b/libertas_api/src/handlers/user_handlers.rs index 6e42a9a..9a2cc28 100644 --- a/libertas_api/src/handlers/user_handlers.rs +++ b/libertas_api/src/handlers/user_handlers.rs @@ -18,6 +18,33 @@ pub async fn get_me( Ok(Json(response)) } -pub fn user_routes() -> Router { - Router::new().route("/me", axum::routing::get(get_me)) +#[derive(serde::Deserialize)] +pub struct SearchUserQuery { + query: String, +} + +pub async fn search_users( + State(state): State, + axum::extract::Query(params): axum::extract::Query, +) -> Result>, ApiError> { + let users = state.user_service.search_users(¶ms.query).await?; + + let response = users + .into_iter() + .map(|user| UserResponse { + id: user.id, + username: user.username, + email: user.email, + storage_used: user.storage_used, + storage_quota: user.storage_quota, + }) + .collect(); + + Ok(Json(response)) +} + +pub fn user_routes() -> Router { + Router::new() + .route("/me", axum::routing::get(get_me)) + .route("/search", axum::routing::get(search_users)) } diff --git a/libertas_api/src/schema.rs b/libertas_api/src/schema.rs index 167f728..d2134f7 100644 --- a/libertas_api/src/schema.rs +++ b/libertas_api/src/schema.rs @@ -42,6 +42,8 @@ pub struct ListMediaParams { pub mime_type: Option, #[serde(default)] pub metadata: Vec, + #[serde(default)] + pub filters: Vec, pub page: Option, pub limit: Option, } @@ -64,6 +66,17 @@ pub struct ShareAlbumRequest { pub permission: AlbumPermission, } +#[derive(Deserialize)] +pub struct UnshareAlbumRequest { + pub target_user_id: Uuid, +} + +#[derive(Serialize)] +pub struct AlbumShareResponse { + pub user: UserResponse, + pub permission: AlbumPermission, +} + #[derive(Serialize)] pub struct AlbumResponse { pub id: Uuid, @@ -244,12 +257,23 @@ pub struct SharePersonRequest { pub permission: PersonPermission, } +#[derive(Deserialize)] +pub struct UnsharePersonRequest { + pub target_user_id: Uuid, +} + #[derive(Serialize)] pub struct PublicAlbumBundleResponse { pub album: AlbumResponse, pub media: Vec, } +#[derive(Serialize)] +pub struct PersonShareResponse { + pub user: UserResponse, + pub permission: PersonPermission, +} + #[derive(Deserialize)] pub struct MergePersonRequest { pub source_person_id: Uuid, diff --git a/libertas_api/src/services/album_service.rs b/libertas_api/src/services/album_service.rs index e5146f5..c3c9cf4 100644 --- a/libertas_api/src/services/album_service.rs +++ b/libertas_api/src/services/album_service.rs @@ -213,4 +213,36 @@ impl AlbumService for AlbumServiceImpl { .remove_media_from_album(album_id, media_ids) .await } + + async fn get_album_shares( + &self, + album_id: Uuid, + user_id: Uuid, + ) -> CoreResult< + Vec<( + libertas_core::models::User, + libertas_core::models::AlbumPermission, + )>, + > { + self.auth_service + .check_permission(Some(user_id), Permission::ShareAlbum(album_id)) + .await?; + + self.album_share_repo.list_shares_for_album(album_id).await + } + + async fn unshare_album( + &self, + album_id: Uuid, + target_user_id: Uuid, + owner_id: Uuid, + ) -> CoreResult<()> { + self.auth_service + .check_permission(Some(owner_id), Permission::ShareAlbum(album_id)) + .await?; + + self.album_share_repo + .remove_share(album_id, target_user_id) + .await + } } diff --git a/libertas_api/src/services/person_service.rs b/libertas_api/src/services/person_service.rs index 9e964f4..4016292 100644 --- a/libertas_api/src/services/person_service.rs +++ b/libertas_api/src/services/person_service.rs @@ -187,21 +187,6 @@ impl PersonService for PersonServiceImpl { .await } - async fn unshare_person( - &self, - person_id: Uuid, - target_user_id: Uuid, - owner_id: Uuid, - ) -> CoreResult<()> { - self.auth_service - .check_permission(Some(owner_id), authz::Permission::SharePerson(person_id)) - .await?; - - self.person_share_repo - .remove_share(person_id, target_user_id) - .await - } - async fn merge_people( &self, target_person_id: Uuid, @@ -342,4 +327,33 @@ impl PersonService for PersonServiceImpl { let response = PaginatedResponse::new(data, pagination.page, pagination.limit, total_items); Ok(response) } + + async fn get_person_shares( + &self, + person_id: Uuid, + user_id: Uuid, + ) -> CoreResult> { + self.auth_service + .check_permission(Some(user_id), authz::Permission::SharePerson(person_id)) + .await?; + + self.person_share_repo + .list_shares_for_person(person_id) + .await + } + + async fn unshare_person( + &self, + person_id: Uuid, + target_user_id: Uuid, + owner_id: Uuid, + ) -> CoreResult<()> { + self.auth_service + .check_permission(Some(owner_id), authz::Permission::SharePerson(person_id)) + .await?; + + self.person_share_repo + .remove_share(person_id, target_user_id) + .await + } } diff --git a/libertas_api/src/services/user_service.rs b/libertas_api/src/services/user_service.rs index a30118f..5523856 100644 --- a/libertas_api/src/services/user_service.rs +++ b/libertas_api/src/services/user_service.rs @@ -96,4 +96,8 @@ impl UserService for UserServiceImpl { .await? .ok_or(CoreError::NotFound("User".to_string(), user_id)) } + + async fn search_users(&self, query: &str) -> CoreResult> { + self.repo.search_users(query).await + } } diff --git a/libertas_core/src/models.rs b/libertas_core/src/models.rs index 7dfb14f..fe484a4 100644 --- a/libertas_core/src/models.rs +++ b/libertas_core/src/models.rs @@ -1,4 +1,4 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -120,7 +120,8 @@ pub struct AlbumMedia { pub media_id: uuid::Uuid, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] pub enum AlbumPermission { View, Contribute, @@ -166,7 +167,8 @@ pub struct Tag { pub name: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] pub enum PersonPermission { View, CanUse, diff --git a/libertas_core/src/repositories.rs b/libertas_core/src/repositories.rs index 913c596..3df48d7 100644 --- a/libertas_core/src/repositories.rs +++ b/libertas_core/src/repositories.rs @@ -36,6 +36,7 @@ pub trait UserRepository: Send + Sync { async fn find_by_username(&self, username: &str) -> CoreResult>; async fn find_by_id(&self, id: Uuid) -> CoreResult>; async fn update_storage_used(&self, user_id: Uuid, bytes: i64) -> CoreResult<()>; + async fn search_users(&self, query: &str) -> CoreResult>; } #[async_trait] @@ -73,6 +74,13 @@ pub trait AlbumShareRepository: Send + Sync { media_id: Uuid, user_id: Uuid, ) -> CoreResult; + + async fn list_shares_for_album( + &self, + album_id: Uuid, + ) -> CoreResult>; + + async fn remove_share(&self, album_id: Uuid, user_id: Uuid) -> CoreResult<()>; } #[async_trait] @@ -131,6 +139,11 @@ pub trait PersonShareRepository: Send + Sync { &self, user_id: Uuid, ) -> CoreResult>; + + async fn list_shares_for_person( + &self, + person_id: Uuid, + ) -> CoreResult>; } #[async_trait] diff --git a/libertas_core/src/schema.rs b/libertas_core/src/schema.rs index 7eb840e..16a8256 100644 --- a/libertas_core/src/schema.rs +++ b/libertas_core/src/schema.rs @@ -59,6 +59,7 @@ pub struct SortParams { pub struct FilterParams { pub mime_type: Option, pub metadata_filters: Option>, + pub conditions: Option>, // In the future, we can add fields like: // pub date_range: Option<(chrono::DateTime, chrono::DateTime)>, } @@ -82,6 +83,24 @@ pub struct MetadataFilter { pub tag_value: String, } +#[derive(Debug, Clone)] +pub struct FilterCondition { + pub field: String, + pub operator: FilterOperator, + pub value: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum FilterOperator { + Eq, + Neq, + Like, + Gt, + Lt, + Gte, + Lte, +} + pub struct MediaImportBundle { pub media_model: Media, pub metadata_models: Vec, diff --git a/libertas_core/src/services.rs b/libertas_core/src/services.rs index bf58376..92633b4 100644 --- a/libertas_core/src/services.rs +++ b/libertas_core/src/services.rs @@ -5,8 +5,8 @@ use crate::{ authz::Permission, error::CoreResult, models::{ - Album, FaceRegion, Media, MediaBundle, Person, PersonPermission, PublicAlbumBundle, Tag, - User, + Album, AlbumPermission, FaceRegion, Media, MediaBundle, Person, PersonPermission, + PublicAlbumBundle, Tag, User, }, schema::{ AddMediaToAlbumData, CreateAlbumData, CreateUserData, ListMediaOptions, LoginUserData, @@ -35,6 +35,7 @@ pub trait UserService: Send + Sync { async fn register(&self, data: CreateUserData<'_>) -> CoreResult; async fn login(&self, data: LoginUserData<'_>) -> CoreResult; async fn get_user_details(&self, user_id: uuid::Uuid) -> CoreResult; + async fn search_users(&self, query: &str) -> CoreResult>; } #[async_trait] @@ -65,6 +66,18 @@ pub trait AlbumService: Send + Sync { media_ids: &[Uuid], user_id: Uuid, ) -> CoreResult<()>; + async fn get_album_shares( + &self, + album_id: Uuid, + user_id: Uuid, + ) -> CoreResult>; + + async fn unshare_album( + &self, + album_id: Uuid, + target_user_id: Uuid, + owner_id: Uuid, + ) -> CoreResult<()>; } #[async_trait] @@ -143,6 +156,12 @@ pub trait PersonService: Send + Sync { user_id: Uuid, options: ListMediaOptions, ) -> CoreResult>; + + async fn get_person_shares( + &self, + person_id: Uuid, + user_id: Uuid, + ) -> CoreResult>; } #[async_trait] diff --git a/libertas_infra/src/db_models.rs b/libertas_infra/src/db_models.rs index 8df2861..3d14b4d 100644 --- a/libertas_infra/src/db_models.rs +++ b/libertas_infra/src/db_models.rs @@ -65,7 +65,7 @@ pub struct PostgresMediaMetadata { } #[derive(Debug, Clone, Copy, sqlx::Type, PartialEq, Eq, Deserialize)] -#[sqlx(rename_all = "lowercase")] +#[sqlx(rename_all = "snake_case")] #[sqlx(type_name = "album_permission")] pub enum PostgresAlbumPermission { View, @@ -103,7 +103,7 @@ pub struct PostgresFaceRegion { } #[derive(Debug, Clone, Copy, sqlx::Type, PartialEq, Eq, Deserialize)] -#[sqlx(rename_all = "lowercase")] +#[sqlx(rename_all = "snake_case")] #[sqlx(type_name = "person_permission")] pub enum PostgresPersonPermission { View, diff --git a/libertas_infra/src/query_builder.rs b/libertas_infra/src/query_builder.rs index 0f08bc6..081132d 100644 --- a/libertas_infra/src/query_builder.rs +++ b/libertas_infra/src/query_builder.rs @@ -4,6 +4,324 @@ use libertas_core::{ }; use sqlx::QueryBuilder as SqlxQueryBuilder; +pub trait SortStrategy: Send + Sync { + fn can_handle(&self, column: &str) -> bool; + fn apply_join<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + column: &'a str, + ) -> CoreResult<()>; + fn apply_sort<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + column: &'a str, + direction: &str, + ) -> CoreResult<()>; +} + +pub struct StandardSortStrategy { + allowed_columns: Vec, +} + +impl StandardSortStrategy { + pub fn new(allowed_columns: Vec) -> Self { + Self { allowed_columns } + } +} + +impl SortStrategy for StandardSortStrategy { + fn can_handle(&self, column: &str) -> bool { + self.allowed_columns.contains(&column.to_string()) + } + + fn apply_join<'a>( + &self, + _query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + _column: &'a str, + ) -> CoreResult<()> { + Ok(()) + } + + fn apply_sort<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + column: &'a str, + direction: &str, + ) -> CoreResult<()> { + let nulls_order = if direction == "ASC" { + "NULLS LAST" + } else { + "NULLS FIRST" + }; + let order_by_clause = format!(" ORDER BY {} {} {}", column, direction, nulls_order); + query.push(order_by_clause); + Ok(()) + } +} + +pub struct MetadataSortStrategy; + +impl SortStrategy for MetadataSortStrategy { + fn can_handle(&self, _column: &str) -> bool { + true // Handles everything else + } + + fn apply_join<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + column: &'a str, + ) -> CoreResult<()> { + // Join with media_metadata to sort by tag value + query.push(" LEFT JOIN media_metadata sort_mm ON media.id = sort_mm.media_id AND sort_mm.tag_name = "); + query.push_bind(column); + Ok(()) + } + + fn apply_sort<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + _column: &'a str, + direction: &str, + ) -> CoreResult<()> { + let nulls_order = if direction == "ASC" { + "NULLS LAST" + } else { + "NULLS FIRST" + }; + + let order_by_clause = format!(" ORDER BY sort_mm.tag_value {} {}", direction, nulls_order); + query.push(order_by_clause); + + Ok(()) + } +} + +pub trait FilterStrategy: Send + Sync { + fn can_handle(&self, field: &str) -> bool; + fn apply_join<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + field: &'a str, + ) -> CoreResult<()>; + fn apply_condition<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + condition: &'a libertas_core::schema::FilterCondition, + ) -> CoreResult<()>; +} + +pub struct StandardFilterStrategy { + allowed_columns: Vec, +} + +impl StandardFilterStrategy { + pub fn new(allowed_columns: Vec) -> Self { + Self { allowed_columns } + } +} + +impl FilterStrategy for StandardFilterStrategy { + fn can_handle(&self, field: &str) -> bool { + self.allowed_columns.contains(&field.to_string()) + } + + fn apply_join<'a>( + &self, + _query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + _field: &'a str, + ) -> CoreResult<()> { + Ok(()) + } + + fn apply_condition<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + condition: &'a libertas_core::schema::FilterCondition, + ) -> CoreResult<()> { + use libertas_core::schema::FilterOperator; + + let is_timestamp = + ["date_taken", "created_at", "updated_at"].contains(&condition.field.as_str()); + let is_year = condition.value.len() == 4 && condition.value.chars().all(char::is_numeric); + + if is_timestamp && is_year { + match condition.operator { + FilterOperator::Eq => { + query.push(format!( + " AND EXTRACT(YEAR FROM media.{}) = ", + condition.field + )); + query.push_bind(condition.value.parse::().unwrap_or(0)); + return Ok(()); + } + FilterOperator::Neq => { + query.push(format!( + " AND EXTRACT(YEAR FROM media.{}) != ", + condition.field + )); + query.push_bind(condition.value.parse::().unwrap_or(0)); + return Ok(()); + } + FilterOperator::Gt => { + query.push(format!(" AND media.{} > ", condition.field)); + query.push_bind(format!("{}-12-31 23:59:59.999Z", condition.value)); + query.push("::timestamptz"); + return Ok(()); + } + FilterOperator::Lt => { + query.push(format!(" AND media.{} < ", condition.field)); + query.push_bind(format!("{}-01-01 00:00:00.000Z", condition.value)); + query.push("::timestamptz"); + return Ok(()); + } + FilterOperator::Gte => { + query.push(format!(" AND media.{} >= ", condition.field)); + query.push_bind(format!("{}-01-01 00:00:00.000Z", condition.value)); + query.push("::timestamptz"); + return Ok(()); + } + FilterOperator::Lte => { + query.push(format!(" AND media.{} <= ", condition.field)); + query.push_bind(format!("{}-12-31 23:59:59.999Z", condition.value)); + query.push("::timestamptz"); + return Ok(()); + } + _ => {} // Fallthrough for Like + } + } + + let op = match condition.operator { + FilterOperator::Eq => "=", + FilterOperator::Neq => "!=", + FilterOperator::Like => "ILIKE", + FilterOperator::Gt => ">", + FilterOperator::Lt => "<", + FilterOperator::Gte => ">=", + FilterOperator::Lte => "<=", + }; + + query.push(format!(" AND media.{} {} ", condition.field, op)); + + if condition.operator == FilterOperator::Like { + query.push_bind(format!("%{}%", condition.value)); + } else { + query.push_bind(&condition.value); + } + + if is_timestamp { + query.push("::timestamptz"); + } + + Ok(()) + } +} + +pub struct MetadataFilterStrategy; + +impl FilterStrategy for MetadataFilterStrategy { + fn can_handle(&self, field: &str) -> bool { + field.starts_with("metadata.") + } + + fn apply_join<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + field: &'a str, + ) -> CoreResult<()> { + // Alias based on field name to allow multiple metadata filters + // e.g. metadata.Camera -> filter_metadata_Camera + let alias = format!("filter_{}", field.replace(".", "_")); + let tag_name = field.strip_prefix("metadata.").unwrap_or(field); + + query.push(format!( + " JOIN media_metadata {} ON media.id = {}.media_id AND {}.tag_name = ", + alias, alias, alias + )); + query.push_bind(tag_name); + + Ok(()) + } + + fn apply_condition<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + condition: &'a libertas_core::schema::FilterCondition, + ) -> CoreResult<()> { + use libertas_core::schema::FilterOperator; + + let alias = format!("filter_{}", condition.field.replace(".", "_")); + + let op = match condition.operator { + FilterOperator::Eq => "=", + FilterOperator::Neq => "!=", + FilterOperator::Like => "ILIKE", + FilterOperator::Gt => ">", + FilterOperator::Lt => "<", + FilterOperator::Gte => ">=", + FilterOperator::Lte => "<=", + }; + + query.push(format!(" AND {}.tag_value {} ", alias, op)); + + if condition.operator == FilterOperator::Like { + query.push_bind(format!("%{}%", condition.value)); + } else { + query.push_bind(&condition.value); + } + + Ok(()) + } +} + +pub struct TagFilterStrategy; + +impl FilterStrategy for TagFilterStrategy { + fn can_handle(&self, field: &str) -> bool { + field == "tag.name" + } + + fn apply_join<'a>( + &self, + _query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + _field: &'a str, + ) -> CoreResult<()> { + // We use EXISTS subqueries in apply_condition, so no main query join is needed. + Ok(()) + } + + fn apply_condition<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + condition: &'a libertas_core::schema::FilterCondition, + ) -> CoreResult<()> { + use libertas_core::schema::FilterOperator; + + let op = match condition.operator { + FilterOperator::Eq => "=", + FilterOperator::Neq => "!=", + FilterOperator::Like => "ILIKE", + FilterOperator::Gt => ">", + FilterOperator::Lt => "<", + FilterOperator::Gte => ">=", + FilterOperator::Lte => "<=", + }; + + query.push(" AND EXISTS (SELECT 1 FROM media_tags mt JOIN tags t ON mt.tag_id = t.id WHERE mt.media_id = media.id AND t.name "); + query.push(op); + query.push(" "); + + if condition.operator == FilterOperator::Like { + query.push_bind(format!("%{}%", condition.value)); + } else { + query.push_bind(&condition.value); + } + + query.push(" ) "); + + Ok(()) + } +} + pub trait QueryBuilder { fn apply_options_to_query<'a>( &self, @@ -13,28 +331,65 @@ pub trait QueryBuilder { } pub struct MediaQueryBuilder { - allowed_sort_columns: Vec, + sort_strategies: Vec>, + filter_strategies: Vec>, } impl MediaQueryBuilder { - pub fn new(allowed_sort_columns: Vec) -> Self { + pub fn new( + sort_strategies: Vec>, + filter_strategies: Vec>, + ) -> Self { Self { - allowed_sort_columns, + sort_strategies, + filter_strategies, } } - fn validate_sort_column<'a>(&self, column: &'a str) -> CoreResult<&'a str> { - if self.allowed_sort_columns.contains(&column.to_string()) { - Ok(column) - } else { - Err(CoreError::Validation(format!( - "Sorting by '{}' is not supported", - column - ))) + pub fn apply_joins<'a>( + &self, + mut query: SqlxQueryBuilder<'a, sqlx::Postgres>, + options: &'a ListMediaOptions, + ) -> CoreResult> { + if let Some(filter) = &options.filter { + if let Some(metadata_filters) = &filter.metadata_filters { + if !metadata_filters.is_empty() { + query.push(" JOIN media_metadata mm ON media.id = mm.media_id "); + } + } + + if let Some(conditions) = &filter.conditions { + let mut joined_fields = std::collections::HashSet::new(); + for condition in conditions { + if joined_fields.contains(&condition.field) { + continue; + } + let strategy = self + .filter_strategies + .iter() + .find(|s| s.can_handle(&condition.field)); + if let Some(strategy) = strategy { + strategy.apply_join(&mut query, &condition.field)?; + joined_fields.insert(condition.field.clone()); + } + } + } } + + if let Some(sort) = &options.sort { + let strategy = self + .sort_strategies + .iter() + .find(|s| s.can_handle(&sort.sort_by)); + if let Some(strategy) = strategy { + strategy.apply_join(&mut query, &sort.sort_by)?; + } + } + + Ok(query) } - pub fn apply_filters_to_query<'a>( + pub fn apply_conditions<'a>( &self, mut query: SqlxQueryBuilder<'a, sqlx::Postgres>, options: &'a ListMediaOptions, @@ -49,7 +404,6 @@ impl MediaQueryBuilder { if let Some(metadata_filters) = &filter.metadata_filters { if !metadata_filters.is_empty() { metadata_filter_count = metadata_filters.len() as i64; - query.push(" JOIN media_metadata mm ON media.id = mm.media_id "); query.push(" AND ( "); for (i, filter) in metadata_filters.iter().enumerate() { @@ -65,6 +419,18 @@ impl MediaQueryBuilder { query.push(" ) "); } } + + if let Some(conditions) = &filter.conditions { + for condition in conditions { + let strategy = self + .filter_strategies + .iter() + .find(|s| s.can_handle(&condition.field)); + if let Some(strategy) = strategy { + strategy.apply_condition(&mut query, condition)?; + } + } + } } Ok((query, metadata_filter_count)) } @@ -75,18 +441,25 @@ impl MediaQueryBuilder { options: &'a ListMediaOptions, ) -> CoreResult> { if let Some(sort) = &options.sort { - let column = self.validate_sort_column(&sort.sort_by)?; let direction = match sort.sort_order { SortOrder::Asc => "ASC", SortOrder::Desc => "DESC", }; - let nulls_order = if direction == "ASC" { - "NULLS LAST" + + let strategy = self + .sort_strategies + .iter() + .find(|s| s.can_handle(&sort.sort_by)); + + if let Some(strategy) = strategy { + strategy.apply_sort(&mut query, &sort.sort_by, direction)?; } else { - "NULLS FIRST" - }; - let order_by_clause = format!("ORDER BY {} {} {}", column, direction, nulls_order); - query.push(order_by_clause); + // Should not happen if we have a default/catch-all strategy, but good to handle + return Err(CoreError::Validation(format!( + "No sort strategy found for column: {}", + sort.sort_by + ))); + } } else { query.push(" ORDER BY media.created_at DESC NULLS LAST "); } diff --git a/libertas_infra/src/repositories/album_share_repository.rs b/libertas_infra/src/repositories/album_share_repository.rs index 0b68cfb..4158a1f 100644 --- a/libertas_infra/src/repositories/album_share_repository.rs +++ b/libertas_infra/src/repositories/album_share_repository.rs @@ -111,4 +111,61 @@ impl AlbumShareRepository for PostgresAlbumShareRepository { Ok(result.exists.unwrap_or(false)) } + + async fn list_shares_for_album( + &self, + album_id: Uuid, + ) -> CoreResult> { + let rows = sqlx::query!( + r#" + SELECT + u.id, u.username, u.email, u.hashed_password, u.created_at, u.updated_at, + u.role, u.storage_quota, u.storage_used, + ash.permission as "permission: PostgresAlbumPermission" + FROM album_shares ash + JOIN users u ON ash.user_id = u.id + WHERE ash.album_id = $1 + "#, + album_id + ) + .fetch_all(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + let result = rows + .into_iter() + .map(|row| { + let user = crate::db_models::PostgresUser { + id: row.id, + username: row.username, + email: row.email, + hashed_password: row.hashed_password, + created_at: row.created_at, + updated_at: row.updated_at, + role: row.role, + storage_quota: row.storage_quota, + storage_used: row.storage_used, + } + .into(); + (user, row.permission.into()) + }) + .collect(); + + Ok(result) + } + + async fn remove_share(&self, album_id: Uuid, user_id: Uuid) -> CoreResult<()> { + sqlx::query!( + r#" + DELETE FROM album_shares + WHERE album_id = $1 AND user_id = $2 + "#, + album_id, + user_id + ) + .execute(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + Ok(()) + } } diff --git a/libertas_infra/src/repositories/media_repository.rs b/libertas_infra/src/repositories/media_repository.rs index e2b9212..05e8d86 100644 --- a/libertas_infra/src/repositories/media_repository.rs +++ b/libertas_infra/src/repositories/media_repository.rs @@ -21,14 +21,31 @@ pub struct PostgresMediaRepository { impl PostgresMediaRepository { pub fn new(pool: PgPool, config: &AppConfig) -> Self { - let allowed_columns = config + let mut allowed_columns = config .allowed_sort_columns .clone() .unwrap_or_else(|| vec!["created_at".to_string(), "original_filename".to_string()]); + allowed_columns.push("date_taken".to_string()); + + let sort_strategies: Vec> = vec![ + Box::new(crate::query_builder::StandardSortStrategy::new( + allowed_columns.clone(), + )), + Box::new(crate::query_builder::MetadataSortStrategy), + ]; + + let filter_strategies: Vec> = vec![ + Box::new(crate::query_builder::StandardFilterStrategy::new( + allowed_columns, + )), + Box::new(crate::query_builder::MetadataFilterStrategy), + Box::new(crate::query_builder::TagFilterStrategy), + ]; + Self { pool, - query_builder: Arc::new(MediaQueryBuilder::new(allowed_columns)), + query_builder: Arc::new(MediaQueryBuilder::new(sort_strategies, filter_strategies)), } } @@ -107,12 +124,14 @@ impl MediaRepository for PostgresMediaRepository { ) -> CoreResult<(Vec, i64)> { let count_base_sql = "SELECT COUNT(DISTINCT media.id) as total FROM media"; let mut count_query = sqlx::QueryBuilder::new(count_base_sql); + + count_query = self.query_builder.apply_joins(count_query, options)?; + count_query.push(" WHERE media.owner_id = "); count_query.push_bind(user_id); - let (mut count_query, metadata_filter_count) = self - .query_builder - .apply_filters_to_query(count_query, options)?; + let (mut count_query, metadata_filter_count) = + self.query_builder.apply_conditions(count_query, options)?; if metadata_filter_count > 0 { count_query.push(" GROUP BY media.id "); @@ -133,12 +152,14 @@ impl MediaRepository for PostgresMediaRepository { let data_base_sql = "SELECT media.id, media.owner_id, media.storage_path, media.original_filename, media.mime_type, media.hash, media.created_at, media.thumbnail_path, media.date_taken FROM media"; let mut data_query = sqlx::QueryBuilder::new(data_base_sql); + + data_query = self.query_builder.apply_joins(data_query, options)?; + data_query.push(" WHERE media.owner_id = "); data_query.push_bind(user_id); - let (mut data_query, metadata_filter_count) = self - .query_builder - .apply_filters_to_query(data_query, options)?; + let (mut data_query, metadata_filter_count) = + self.query_builder.apply_conditions(data_query, options)?; if metadata_filter_count > 0 { data_query.push(" GROUP BY media.id "); @@ -174,12 +195,14 @@ impl MediaRepository for PostgresMediaRepository { JOIN face_regions fr ON media.id = fr.media_id "; let mut count_query = sqlx::QueryBuilder::new(count_base_sql); + + count_query = self.query_builder.apply_joins(count_query, options)?; + count_query.push(" WHERE fr.person_id = "); count_query.push_bind(person_id); - let (mut count_query, _metadata_filter_count) = self - .query_builder - .apply_filters_to_query(count_query, options)?; + let (mut count_query, _metadata_filter_count) = + self.query_builder.apply_conditions(count_query, options)?; let total_items_result = count_query .build_query_scalar() @@ -195,12 +218,14 @@ impl MediaRepository for PostgresMediaRepository { JOIN face_regions fr ON media.id = fr.media_id "; let mut data_query = sqlx::QueryBuilder::new(data_base_sql); + + data_query = self.query_builder.apply_joins(data_query, options)?; + data_query.push(" WHERE fr.person_id = "); data_query.push_bind(person_id); - let (mut data_query, _metadata_filter_count) = self - .query_builder - .apply_filters_to_query(data_query, options)?; + let (mut data_query, _metadata_filter_count) = + self.query_builder.apply_conditions(data_query, options)?; data_query.push(" GROUP BY media.id "); diff --git a/libertas_infra/src/repositories/person_share_repository.rs b/libertas_infra/src/repositories/person_share_repository.rs index 2851b14..25f3f74 100644 --- a/libertas_infra/src/repositories/person_share_repository.rs +++ b/libertas_infra/src/repositories/person_share_repository.rs @@ -4,7 +4,7 @@ use libertas_core::{ models::{Person, PersonPermission}, repositories::PersonShareRepository, }; -use sqlx::{types::Uuid, PgPool}; +use sqlx::{PgPool, types::Uuid}; use crate::db_models::{PostgresPersonPermission, PostgresPersonShared}; @@ -103,4 +103,46 @@ impl PersonShareRepository for PostgresPersonShareRepository { .map(PostgresPersonShared::into) .collect()) } -} \ No newline at end of file + + async fn list_shares_for_person( + &self, + person_id: Uuid, + ) -> CoreResult> { + let rows = sqlx::query!( + r#" + SELECT + u.id, u.username, u.email, u.hashed_password, u.created_at, u.updated_at, + u.role, u.storage_quota, u.storage_used, + ps.permission as "permission: PostgresPersonPermission" + FROM person_shares ps + JOIN users u ON ps.user_id = u.id + WHERE ps.person_id = $1 + "#, + person_id + ) + .fetch_all(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + let result = rows + .into_iter() + .map(|row| { + let user = crate::db_models::PostgresUser { + id: row.id, + username: row.username, + email: row.email, + hashed_password: row.hashed_password, + created_at: row.created_at, + updated_at: row.updated_at, + role: row.role, + storage_quota: row.storage_quota, + storage_used: row.storage_used, + } + .into(); + (user, row.permission.into()) + }) + .collect(); + + Ok(result) + } +} diff --git a/libertas_infra/src/repositories/user_repository.rs b/libertas_infra/src/repositories/user_repository.rs index 6d734be..8c4c060 100644 --- a/libertas_infra/src/repositories/user_repository.rs +++ b/libertas_infra/src/repositories/user_repository.rs @@ -137,6 +137,27 @@ impl UserRepository for PostgresUserRepository { async fn update_storage_used(&self, user_id: Uuid, bytes: i64) -> CoreResult<()> { Self::update_storage_used_internal(&self.pool, user_id, bytes).await } + + async fn search_users(&self, query: &str) -> CoreResult> { + let pattern = format!("%{}%", query); + let pg_users = sqlx::query_as!( + PostgresUser, + r#" + SELECT + id, username, email, hashed_password, created_at, updated_at, + role, storage_quota, storage_used + FROM users + WHERE username ILIKE $1 OR email ILIKE $1 + LIMIT 10 + "#, + pattern + ) + .fetch_all(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + Ok(pg_users.into_iter().map(|u| u.into()).collect()) + } } #[async_trait] @@ -165,4 +186,9 @@ impl UserRepository for SqliteUserRepository { println!("SQLITE REPO: Updating user storage used"); Ok(()) } + + async fn search_users(&self, _query: &str) -> CoreResult> { + println!("SQLITE REPO: Searching users"); + Ok(Vec::new()) + } }