Compare commits

7 Commits

37 changed files with 1893 additions and 163 deletions

View File

@@ -31,7 +31,7 @@ export function AddMediaToAlbumDialog({ albumId }: AddMediaToAlbumDialogProps) {
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useGetMediaList();
} = useGetMediaList(1, 20);
const { mutate: addMedia, isPending: isAdding } = useAddMediaToAlbum(albumId);

View File

@@ -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 (
<div className={cn("absolute inset-0 pointer-events-none", className)}>
{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 (
<div
key={face.id}
className={cn(
"absolute border-2 transition-colors duration-200 pointer-events-auto cursor-pointer",
isHovered
? "border-yellow-400 bg-yellow-400/20 z-10"
: "border-white/50 hover:border-white/80"
)}
style={{
left: `${left}%`,
top: `${top}%`,
width: `${width}%`,
height: `${height}%`,
}}
onClick={() => onFaceClick?.(face)}
title={face.person_id ? "Assigned Person" : "Unknown Person"}
/>
);
})}
</div>
);
}

View File

@@ -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 (
<ScrollArea className="h-full w-full">
<div className="p-4 space-y-6">
@@ -70,7 +95,7 @@ export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) {
defaultValue={["details", "tags", "people"]}
className="w-full"
>
{/* --- People Section (Unchanged) --- */}
{/* --- People Section --- */}
<AccordionItem value="people">
<AccordionTrigger>People</AccordionTrigger>
<AccordionContent>
@@ -78,7 +103,13 @@ export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) {
{faces && faces.length > 0 && (
<div className="flex flex-wrap gap-2">
{faces.map((face) => (
<PersonFaceBadge key={face.id} personId={face.person_id} />
<PersonFaceBadge
key={face.id}
face={face}
onMouseEnter={() => onHoverFace(face.id)}
onMouseLeave={() => onHoverFace(null)}
onClick={() => onFaceClick(face)}
/>
))}
</div>
)}
@@ -92,14 +123,35 @@ export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) {
<AccordionItem value="tags">
<AccordionTrigger>Tags</AccordionTrigger>
<AccordionContent>
{/* TODO: Add input to add tags */}
<AccordionContent className="space-y-4">
<div className="flex gap-2">
<Input
placeholder="Add tag..."
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddTag();
}}
className="h-8"
/>
<Button size="sm" variant="outline" onClick={handleAddTag} disabled={!newTag.trim() || addTags.isPending} className="h-8 w-8 p-0">
<Plus size={16} />
</Button>
</div>
{isLoadingTags && <Skeleton className="h-8 w-full" />}
{tags && tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Badge key={tag.id} variant="secondary">
<Badge key={tag.id} variant="secondary" className="pr-1 gap-1">
{tag.name}
<button
onClick={() => handleRemoveTag(tag.name)}
className="hover:bg-muted rounded-full p-0.5 transition-colors"
disabled={removeTag.isPending}
>
<X size={12} />
</button>
</Badge>
))}
</div>
@@ -120,6 +172,8 @@ export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) {
value={`${cameraMake} ${cameraModel}`}
/>
)}
{cameraMake && <DetailRow label="Make" value={cameraMake} />}
{cameraModel && <DetailRow label="Model" value={cameraModel} />}
<DetailRow label="MIME Type" value={media.mime_type} />
<DetailRow label="File Hash" value={media.hash} isMono />

View File

@@ -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<string | null>(null);
const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null);
const imageRef = useRef<HTMLImageElement>(null);
// Assignment dialog state
const [assignmentDialogOpen, setAssignmentDialogOpen] = useState(false);
const [selectedFace, setSelectedFace] = useState<FaceRegion | null>(null);
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
setImageDimensions({ width: img.naturalWidth, height: img.naturalHeight });
};
const handleFaceClick = (face: FaceRegion) => {
setSelectedFace(face);
setAssignmentDialogOpen(true);
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="min-w-[90vw] max-w-full h-[90vh] p-0 border-0">
{/* We use a resizable panel group to show the image and sidebar */}
<DialogTitle className="sr-only">
{media?.original_filename ?? "Media Viewer"}
</DialogTitle>
<ResizablePanelGroup direction="horizontal" className="h-full">
{/* --- Panel 1: The Image --- */}
<ResizablePanel defaultSize={75} className="bg-gray-100">
<div className="flex h-full items-center justify-center overflow-hidden relative p-4">
{media ? (
<AuthenticatedImage
src={media.file_url}
alt={media.original_filename}
className="max-w-full max-h-full object-contain"
/>
<div className="relative inline-block max-w-full max-h-full">
<AuthenticatedImage
src={media.file_url}
alt={media.original_filename}
className="max-w-full max-h-full object-contain"
onLoad={handleImageLoad}
ref={imageRef}
/>
{faces && imageDimensions && (
<FaceOverlay
faces={faces}
hoveredFaceId={hoveredFaceId}
imageWidth={imageDimensions.width}
imageHeight={imageDimensions.height}
onFaceClick={handleFaceClick}
/>
)}
</div>
) : (
<Skeleton className="w-full h-full" />
)}
@@ -43,12 +80,31 @@ export function MediaViewer({ media, onOpenChange }: MediaViewerProps) {
{/* --- Panel 2: The Details Sidebar --- */}
<ResizablePanel defaultSize={25} minSize={20} maxSize={40}>
{media ? (
<MediaDetailsSidebar media={media} />
<MediaDetailsSidebar
media={media}
faces={faces}
isLoadingFaces={isLoadingFaces}
onHoverFace={setHoveredFaceId}
onFaceClick={handleFaceClick}
/>
) : (
<Skeleton className="w-full h-full" />
)}
</ResizablePanel>
</ResizablePanelGroup>
{selectedFace && media && (
<PersonAssignmentDialog
isOpen={assignmentDialogOpen}
onOpenChange={(open) => {
setAssignmentDialogOpen(open);
if (!open) setSelectedFace(null);
}}
faceId={selectedFace.id}
mediaId={media.id}
currentPersonId={selectedFace.person_id}
/>
)}
</DialogContent>
</Dialog>
);

View File

@@ -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 (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Assign Person</DialogTitle>
</DialogHeader>
{isCreating ? (
<div className="space-y-4 py-4">
<Input
placeholder="Enter person name"
value={newPersonName}
onChange={(e) => setNewPersonName(e.target.value)}
autoFocus
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsCreating(false)}>
Cancel
</Button>
<Button onClick={handleCreateAndAssign} disabled={!newPersonName.trim()}>
Create & Assign
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<Input
placeholder="Search people..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<ScrollArea className="h-[300px] pr-4">
<div className="space-y-2">
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={() => setIsCreating(true)}
>
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center">
<Plus size={16} />
</div>
<span>Create "{searchQuery || "New Person"}"</span>
</Button>
{filteredPeople?.map((person) => (
<Button
key={person.id}
variant={currentPersonId === person.id ? "secondary" : "ghost"}
className="w-full justify-start gap-2"
onClick={() => handleAssign(person.id)}
>
<Avatar className="h-8 w-8">
{/* TODO: Add thumbnail URL if available */}
<AvatarFallback>
<UserSquare size={16} />
</AvatarFallback>
</Avatar>
<span>{person.name}</span>
</Button>
))}
</div>
</ScrollArea>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -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 = (
<Badge
variant="secondary"
className="inline-flex items-center gap-2 text-sm"
className={cn(
"inline-flex items-center gap-2 text-sm cursor-pointer transition-colors",
!face.person_id && "hover:bg-yellow-100"
)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={(e) => {
// Prevent navigation if we're just assigning
if (!face.person_id) {
e.preventDefault();
onClick?.();
}
}}
>
<UserSquare size={16} />
{person ? person.name : personId ? "Loading..." : "Unknown"}
{person ? person.name : face.person_id ? "Loading..." : "Unknown"}
</Badge>
);
if (!personId || !person) {
return content;
if (!face.person_id || !person) {
return (
<div onClick={onClick}>
{content}
</div>
);
}
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}
</Link>

View File

@@ -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<void>;
onRemoveShare: (userId: string) => Promise<void>;
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<SharePermission>(
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="flex gap-2">
<Input
placeholder="Search users by name or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1"
/>
<Select
value={selectedPermission}
onValueChange={(val) => setSelectedPermission(val as SharePermission)}
>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{permissionOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Search Results */}
{searchQuery.length > 1 && (
<div className="border rounded-md p-2 max-h-[150px] overflow-y-auto space-y-2">
{isLoading ? (
<div className="flex justify-center p-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : searchResults && searchResults.length > 0 ? (
searchResults.map((user) => {
const isAlreadyShared = currentShares.some(
(s) => s.user.id === user.id
);
return (
<div
key={user.id}
className="flex items-center justify-between p-2 hover:bg-gray-100 rounded-md"
>
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarFallback>
{user.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium">
{user.username}
</span>
<span className="text-xs text-gray-500">
{user.email}
</span>
</div>
</div>
<Button
size="sm"
variant="ghost"
disabled={isAlreadyShared}
onClick={() => handleAdd(user)}
>
{isAlreadyShared ? "Added" : "Add"}
</Button>
</div>
);
})
) : (
<p className="text-sm text-gray-500 text-center p-2">
No users found.
</p>
)}
</div>
)}
<div className="space-y-2">
<h4 className="text-sm font-medium">Shared with</h4>
{currentShares.length === 0 ? (
<p className="text-sm text-gray-500">Not shared with anyone yet.</p>
) : (
<div className="space-y-2">
{currentShares.map((share) => (
<div
key={share.user.id}
className="flex items-center justify-between border p-2 rounded-md"
>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarFallback>
{share.user.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">
{share.user.username}
</p>
<p className="text-xs text-gray-500">
{share.permission}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => onRemoveShare(share.user.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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.
*/

View File

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

View File

@@ -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"] });
},
});
};
@@ -132,9 +159,3 @@ export const useSetPersonThumbnail = (personId: string) => {
},
});
};
export const useClusterFaces = () => {
return useMutation({
mutationFn: clusterFaces,
});
};

View File

@@ -0,0 +1,15 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -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";

View File

@@ -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<Media | null>(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 (
<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 className="flex gap-2">
<ShareDialog
title={`Share Album: ${album?.name}`}
trigger={
<Button variant="outline">
<Share2 className="mr-2 h-4 w-4" />
Share
</Button>
}
currentShares={currentShares}
onAddShare={handleAddShare}
onRemoveShare={handleRemoveShare}
permissionOptions={[
{ value: "view", label: "Can View" },
{ value: "can_use", label: "Can Contribute" },
]}
/>
<AddMediaToAlbumDialog albumId={albumId} />
</div>
</div>
{isLoading && <p>Loading photos...</p>}

View File

@@ -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<string>("created_at");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const [mimeType, setMimeType] = useState<string | undefined>(undefined);
const [filters, setFilters] = useState<string[]>([]);
// 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<Media | null>(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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">All Photos</h1>
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<h1 className="text-3xl font-bold">All Photos</h1>
<div className="flex flex-wrap items-center gap-2">
<Select value={mimeType || "all"} onValueChange={(val) => setMimeType(val === "all" ? undefined : val)}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="image/jpeg">JPEG</SelectItem>
<SelectItem value="image/png">PNG</SelectItem>
<SelectItem value="video/mp4">MP4</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="created_at">Date Created</SelectItem>
<SelectItem value="date_taken">Date Taken</SelectItem>
<SelectItem value="original_filename">Filename</SelectItem>
<SelectItem value="custom">Custom Tag...</SelectItem>
</SelectContent>
</Select>
{sortBy === "custom" && (
<input
type="text"
placeholder="Enter tag name"
className="border rounded px-2 py-1 text-sm w-[140px]"
onBlur={(e) => {
if (e.target.value) setSortBy(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setSortBy(e.currentTarget.value);
}
}}
/>
)}
<Select
value={sortOrder}
onValueChange={(val) => setSortOrder(val as "asc" | "desc")}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Order" />
</SelectTrigger>
<SelectContent>
<SelectItem value="desc">Newest First</SelectItem>
<SelectItem value="asc">Oldest First</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Advanced Filters */}
<div className="flex flex-wrap gap-2 items-center border p-2 rounded bg-gray-50">
<span className="text-sm font-semibold">Add Filter:</span>
<Select value={filterField} onValueChange={setFilterField}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Field" />
</SelectTrigger>
<SelectContent>
<SelectItem value="original_filename">Filename</SelectItem>
<SelectItem value="tag.name">Tag</SelectItem>
<SelectItem value="metadata.Make">Make</SelectItem>
<SelectItem value="metadata.Model">Model</SelectItem>
<SelectItem value="metadata.ISO">ISO</SelectItem>
<SelectItem value="metadata.FNumber">F-Number</SelectItem>
<SelectItem value="custom">Custom Field...</SelectItem>
</SelectContent>
</Select>
{filterField === 'custom' && (
<input
className="border rounded px-2 py-1 text-sm w-[120px]"
placeholder="Field name"
value={customFieldName}
onChange={(e) => setCustomFieldName(e.target.value)}
/>
)}
<Select value={filterOperator} onValueChange={setFilterOperator}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="Op" />
</SelectTrigger>
<SelectContent>
<SelectItem value="eq">=</SelectItem>
<SelectItem value="neq">!=</SelectItem>
<SelectItem value="like">Like</SelectItem>
<SelectItem value="gt">&gt;</SelectItem>
<SelectItem value="lt">&lt;</SelectItem>
<SelectItem value="gte">&gt;=</SelectItem>
<SelectItem value="lte">&lt;=</SelectItem>
</SelectContent>
</Select>
<input
type="text"
value={filterValue}
onChange={(e) => setFilterValue(e.target.value)}
placeholder="Value"
className="border rounded px-2 py-1 text-sm w-[150px]"
onKeyDown={(e) => e.key === 'Enter' && handleAddFilter()}
/>
<Button onClick={handleAddFilter} size="sm">Add</Button>
</div>
{/* Active Filters List */}
{filters.length > 0 && (
<div className="flex gap-2 flex-wrap">
{filters.map((f, i) => (
<div key={i} className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-sm flex items-center gap-2 border border-blue-200">
<span>{f.replace(/:/g, ' ')}</span>
<button onClick={() => handleRemoveFilter(i)} className="text-red-500 font-bold hover:text-red-700">×</button>
</div>
))}
</div>
)}
</div>
{isLoading && <p>Loading photos...</p>}

View File

@@ -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<Media | null>(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 <div>Loading person...</div>;
if (!person) return <div>Person not found</div>;
const allMedia = mediaPage?.pages.flatMap((page) => page.data) ?? [];
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<h1 className="text-3xl font-bold truncate">
{person?.name ?? "Loading person..."}
</h1>
{person && <EditPersonDialog person={person} />}
<div className="flex items-center justify-between">
{isEditingName ? (
<div className="flex items-center gap-2">
<input
className="text-3xl font-bold border rounded px-2 py-1"
value={newName}
onChange={(e) => setNewName(e.target.value)}
autoFocus
/>
<Button onClick={handleNameSave}>Save</Button>
<Button variant="ghost" onClick={() => setIsEditingName(false)}>
Cancel
</Button>
</div>
) : (
<h1
className="text-3xl font-bold cursor-pointer hover:underline decoration-dashed"
onClick={() => {
setNewName(person.name);
setIsEditingName(true);
}}
title="Click to edit name"
>
{person.name}
</h1>
)}
<ShareDialog
title={`Share Person: ${person.name}`}
trigger={
<Button variant="outline">
<Share2 className="mr-2 h-4 w-4" />
Share
</Button>
}
currentShares={currentShares}
onAddShare={handleAddShare}
onRemoveShare={handleRemoveShare}
permissionOptions={[
{ value: "view", label: "Can View" },
{ value: "can_use", label: "Can Use (Assign Faces)" },
]}
/>
</div>
{(isLoadingPerson || isLoadingMedia) && !mediaPages && (
{(isLoadingPerson || isLoadingMedia) && !mediaPage && (
<p>Loading photos...</p>
)}

View File

@@ -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<void> => {
await apiClient.delete(`/albums/${albumId}/share`, {
data: { target_user_id: targetUserId },
});
};
export const setAlbumThumbnail = async (
albumId: string,
payload: SetAlbumThumbnailPayload,
): Promise<void> => {
await apiClient.put(`/albums/${albumId}/thumbnail`, payload);
};
export type AlbumShare = {
user: User;
permission: AlbumPermission;
};
export const getAlbumShares = async (albumId: string): Promise<AlbumShare[]> => {
const { data } = await apiClient.get(`/albums/${albumId}/share`);
return data;
};

View File

@@ -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<PaginatedResponse<Media>> => {
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<MediaDetails> => {
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;

View File

@@ -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<void> => {
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<PersonShare[]> => {
const { data } = await apiClient.get(`/people/${personId}/share`);
return data;
};
export const clusterFaces = async (): Promise<void> => {
await apiClient.post("/people/cluster");
};

View File

@@ -8,3 +8,13 @@ export const getMe = async (): Promise<User> => {
const { data } = await apiClient.get("/users/me");
return data;
};
/**
* Searches for users by username or email.
*/
export const searchUsers = async (query: string): Promise<User[]> => {
const { data } = await apiClient.get("/users/search", {
params: { query },
});
return data;
};

View File

@@ -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<ListMediaParams> 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<AppState> for ApiListMediaOptions {
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let Query(params) = Query::<ListMediaParams>::from_request_parts(parts, state)
let Query(raw_params) = Query::<Vec<(String, String)>>::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()))
}
}

View File

@@ -83,6 +83,46 @@ async fn list_user_albums(
Ok(Json(response))
}
async fn unshare_album(
State(state): State<AppState>,
UserId(user_id): UserId,
Path(album_id): Path<Uuid>,
Json(payload): Json<crate::schema::UnshareAlbumRequest>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
UserId(user_id): UserId,
Path(album_id): Path<Uuid>,
) -> Result<Json<Vec<crate::schema::AlbumShareResponse>>, 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<AppState>,
UserId(user_id): UserId,
@@ -179,5 +219,10 @@ pub fn album_routes() -> Router<AppState> {
.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),
)
}

View File

@@ -29,7 +29,9 @@ pub fn people_routes() -> Router<AppState> {
)
.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<AppState>,
UserId(user_id): UserId,
Path(person_id): Path<Uuid>,
Json(payload): Json<SharePersonRequest>,
Json(payload): Json<crate::schema::UnsharePersonRequest>,
) -> Result<StatusCode, ApiError> {
state
.person_service
@@ -129,6 +131,33 @@ async fn unshare_person(
Ok(StatusCode::NO_CONTENT)
}
async fn list_person_shares(
State(state): State<AppState>,
UserId(user_id): UserId,
Path(person_id): Path<Uuid>,
) -> Result<Json<Vec<crate::schema::PersonShareResponse>>, 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<AppState>,
UserId(user_id): UserId,

View File

@@ -18,6 +18,33 @@ pub async fn get_me(
Ok(Json(response))
}
pub fn user_routes() -> Router<AppState> {
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<AppState>,
axum::extract::Query(params): axum::extract::Query<SearchUserQuery>,
) -> Result<Json<Vec<UserResponse>>, ApiError> {
let users = state.user_service.search_users(&params.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<AppState> {
Router::new()
.route("/me", axum::routing::get(get_me))
.route("/search", axum::routing::get(search_users))
}

View File

@@ -42,6 +42,8 @@ pub struct ListMediaParams {
pub mime_type: Option<String>,
#[serde(default)]
pub metadata: Vec<String>,
#[serde(default)]
pub filters: Vec<String>,
pub page: Option<u32>,
pub limit: Option<u32>,
}
@@ -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<MediaResponse>,
}
#[derive(Serialize)]
pub struct PersonShareResponse {
pub user: UserResponse,
pub permission: PersonPermission,
}
#[derive(Deserialize)]
pub struct MergePersonRequest {
pub source_person_id: Uuid,

View File

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

View File

@@ -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<Vec<(libertas_core::models::User, PersonPermission)>> {
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
}
}

View File

@@ -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<Vec<User>> {
self.repo.search_users(query).await
}
}

View File

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

View File

@@ -36,6 +36,7 @@ pub trait UserRepository: Send + Sync {
async fn find_by_username(&self, username: &str) -> CoreResult<Option<User>>;
async fn find_by_id(&self, id: Uuid) -> CoreResult<Option<User>>;
async fn update_storage_used(&self, user_id: Uuid, bytes: i64) -> CoreResult<()>;
async fn search_users(&self, query: &str) -> CoreResult<Vec<User>>;
}
#[async_trait]
@@ -73,6 +74,13 @@ pub trait AlbumShareRepository: Send + Sync {
media_id: Uuid,
user_id: Uuid,
) -> CoreResult<bool>;
async fn list_shares_for_album(
&self,
album_id: Uuid,
) -> CoreResult<Vec<(User, AlbumPermission)>>;
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<Vec<(Person, PersonPermission)>>;
async fn list_shares_for_person(
&self,
person_id: Uuid,
) -> CoreResult<Vec<(User, PersonPermission)>>;
}
#[async_trait]

View File

@@ -59,6 +59,7 @@ pub struct SortParams {
pub struct FilterParams {
pub mime_type: Option<String>,
pub metadata_filters: Option<Vec<MetadataFilter>>,
pub conditions: Option<Vec<FilterCondition>>,
// In the future, we can add fields like:
// pub date_range: Option<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>,
}
@@ -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<MediaMetadata>,

View File

@@ -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<User>;
async fn login(&self, data: LoginUserData<'_>) -> CoreResult<String>;
async fn get_user_details(&self, user_id: uuid::Uuid) -> CoreResult<User>;
async fn search_users(&self, query: &str) -> CoreResult<Vec<User>>;
}
#[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<Vec<(User, AlbumPermission)>>;
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<PaginatedResponse<Media>>;
async fn get_person_shares(
&self,
person_id: Uuid,
user_id: Uuid,
) -> CoreResult<Vec<(User, PersonPermission)>>;
}
#[async_trait]

View File

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

View File

@@ -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<String>,
}
impl StandardSortStrategy {
pub fn new(allowed_columns: Vec<String>) -> 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<String>,
}
impl StandardFilterStrategy {
pub fn new(allowed_columns: Vec<String>) -> 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::<i32>().unwrap_or(0));
return Ok(());
}
FilterOperator::Neq => {
query.push(format!(
" AND EXTRACT(YEAR FROM media.{}) != ",
condition.field
));
query.push_bind(condition.value.parse::<i32>().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<T> {
fn apply_options_to_query<'a>(
&self,
@@ -13,28 +331,65 @@ pub trait QueryBuilder<T> {
}
pub struct MediaQueryBuilder {
allowed_sort_columns: Vec<String>,
sort_strategies: Vec<Box<dyn SortStrategy>>,
filter_strategies: Vec<Box<dyn FilterStrategy>>,
}
impl MediaQueryBuilder {
pub fn new(allowed_sort_columns: Vec<String>) -> Self {
pub fn new(
sort_strategies: Vec<Box<dyn SortStrategy>>,
filter_strategies: Vec<Box<dyn FilterStrategy>>,
) -> 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<SqlxQueryBuilder<'a, sqlx::Postgres>> {
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<SqlxQueryBuilder<'a, sqlx::Postgres>> {
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 ");
}

View File

@@ -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<Vec<(libertas_core::models::User, AlbumPermission)>> {
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(())
}
}

View File

@@ -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<Box<dyn crate::query_builder::SortStrategy>> = vec![
Box::new(crate::query_builder::StandardSortStrategy::new(
allowed_columns.clone(),
)),
Box::new(crate::query_builder::MetadataSortStrategy),
];
let filter_strategies: Vec<Box<dyn crate::query_builder::FilterStrategy>> = 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<Media>, 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 ");

View File

@@ -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())
}
async fn list_shares_for_person(
&self,
person_id: Uuid,
) -> CoreResult<Vec<(libertas_core::models::User, PersonPermission)>> {
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)
}
}

View File

@@ -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<Vec<User>> {
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<Vec<User>> {
println!("SQLITE REPO: Searching users");
Ok(Vec::new())
}
}