december improvements #2
@@ -31,7 +31,7 @@ export function AddMediaToAlbumDialog({ albumId }: AddMediaToAlbumDialogProps) {
|
|||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
} = useGetMediaList();
|
} = useGetMediaList(1, 20);
|
||||||
|
|
||||||
const { mutate: addMedia, isPending: isAdding } = useAddMediaToAlbum(albumId);
|
const { mutate: addMedia, isPending: isAdding } = useAddMediaToAlbum(albumId);
|
||||||
|
|
||||||
|
|||||||
56
libertas-frontend/src/components/media/face-overlay.tsx
Normal file
56
libertas-frontend/src/components/media/face-overlay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { useGetMediaDetails } from "@/features/media/use-media";
|
||||||
import { useListMediaFaces } from "@/features/faces/use-faces";
|
import { useListMediaTags, useAddMediaTags, useRemoveMediaTag } from "@/features/tags/use-tags";
|
||||||
import { useListMediaTags } from "@/features/tags/use-tags";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
@@ -14,9 +13,17 @@ import { PersonFaceBadge } from "@/components/people/person-face-badge";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
import { Separator } from "../ui/separator";
|
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 = {
|
type MediaDetailsSidebarProps = {
|
||||||
media: Media;
|
media: Media;
|
||||||
|
faces: FaceRegion[] | undefined;
|
||||||
|
isLoadingFaces: boolean;
|
||||||
|
onHoverFace: (faceId: string | null) => void;
|
||||||
|
onFaceClick: (face: FaceRegion) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function findMeta(
|
function findMeta(
|
||||||
@@ -28,14 +35,34 @@ function findMeta(
|
|||||||
|
|
||||||
const manualTags = new Set(["DateTimeOriginal", "Make", "Model"]);
|
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(
|
const { data: details, isLoading: isLoadingDetails } = useGetMediaDetails(
|
||||||
media.id
|
media.id
|
||||||
);
|
);
|
||||||
const { data: tags, isLoading: isLoadingTags } = useListMediaTags(media.id);
|
const { data: tags, isLoading: isLoadingTags } = useListMediaTags(media.id);
|
||||||
const { data: faces, isLoading: isLoadingFaces } = useListMediaFaces(
|
const addTags = useAddMediaTags(media.id);
|
||||||
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
|
const displayDate = media.date_taken
|
||||||
? format(parseISO(media.date_taken), "MMMM d, yyyy 'at' h:mm a")
|
? 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));
|
.sort((a, b) => a.tag_name.localeCompare(b.tag_name));
|
||||||
|
|
||||||
console.log("Other Metadata:", details);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full w-full">
|
<ScrollArea className="h-full w-full">
|
||||||
<div className="p-4 space-y-6">
|
<div className="p-4 space-y-6">
|
||||||
@@ -70,7 +95,7 @@ export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) {
|
|||||||
defaultValue={["details", "tags", "people"]}
|
defaultValue={["details", "tags", "people"]}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{/* --- People Section (Unchanged) --- */}
|
{/* --- People Section --- */}
|
||||||
<AccordionItem value="people">
|
<AccordionItem value="people">
|
||||||
<AccordionTrigger>People</AccordionTrigger>
|
<AccordionTrigger>People</AccordionTrigger>
|
||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
@@ -78,7 +103,13 @@ export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) {
|
|||||||
{faces && faces.length > 0 && (
|
{faces && faces.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{faces.map((face) => (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -92,14 +123,35 @@ export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) {
|
|||||||
|
|
||||||
<AccordionItem value="tags">
|
<AccordionItem value="tags">
|
||||||
<AccordionTrigger>Tags</AccordionTrigger>
|
<AccordionTrigger>Tags</AccordionTrigger>
|
||||||
<AccordionContent>
|
<AccordionContent className="space-y-4">
|
||||||
{/* TODO: Add input to add tags */}
|
<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" />}
|
{isLoadingTags && <Skeleton className="h-8 w-full" />}
|
||||||
{tags && tags.length > 0 && (
|
{tags && tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<Badge key={tag.id} variant="secondary">
|
<Badge key={tag.id} variant="secondary" className="pr-1 gap-1">
|
||||||
{tag.name}
|
{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>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -120,6 +172,8 @@ export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) {
|
|||||||
value={`${cameraMake} ${cameraModel}`}
|
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="MIME Type" value={media.mime_type} />
|
||||||
<DetailRow label="File Hash" value={media.hash} isMono />
|
<DetailRow label="File Hash" value={media.hash} isMono />
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { type Media } from "@/domain/types";
|
import { type Media, type FaceRegion } from "@/domain/types";
|
||||||
import { AuthenticatedImage } from "./authenticated-image";
|
import { AuthenticatedImage } from "./authenticated-image";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { Skeleton } from "../ui/skeleton";
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
} from "@/components/ui/resizable";
|
} from "@/components/ui/resizable";
|
||||||
import { MediaDetailsSidebar } from "./media-details-sidebar";
|
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 = {
|
type MediaViewerProps = {
|
||||||
media: Media | null;
|
media: Media | null;
|
||||||
@@ -16,21 +20,54 @@ type MediaViewerProps = {
|
|||||||
|
|
||||||
export function MediaViewer({ media, onOpenChange }: MediaViewerProps) {
|
export function MediaViewer({ media, onOpenChange }: MediaViewerProps) {
|
||||||
const isOpen = media !== null;
|
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 (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="min-w-[90vw] max-w-full h-[90vh] p-0 border-0">
|
<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">
|
<ResizablePanelGroup direction="horizontal" className="h-full">
|
||||||
{/* --- Panel 1: The Image --- */}
|
{/* --- Panel 1: The Image --- */}
|
||||||
<ResizablePanel defaultSize={75} className="bg-gray-100">
|
<ResizablePanel defaultSize={75} className="bg-gray-100">
|
||||||
<div className="flex h-full items-center justify-center overflow-hidden relative p-4">
|
<div className="flex h-full items-center justify-center overflow-hidden relative p-4">
|
||||||
{media ? (
|
{media ? (
|
||||||
|
<div className="relative inline-block max-w-full max-h-full">
|
||||||
<AuthenticatedImage
|
<AuthenticatedImage
|
||||||
src={media.file_url}
|
src={media.file_url}
|
||||||
alt={media.original_filename}
|
alt={media.original_filename}
|
||||||
className="max-w-full max-h-full object-contain"
|
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" />
|
<Skeleton className="w-full h-full" />
|
||||||
)}
|
)}
|
||||||
@@ -43,12 +80,31 @@ export function MediaViewer({ media, onOpenChange }: MediaViewerProps) {
|
|||||||
{/* --- Panel 2: The Details Sidebar --- */}
|
{/* --- Panel 2: The Details Sidebar --- */}
|
||||||
<ResizablePanel defaultSize={25} minSize={20} maxSize={40}>
|
<ResizablePanel defaultSize={25} minSize={20} maxSize={40}>
|
||||||
{media ? (
|
{media ? (
|
||||||
<MediaDetailsSidebar media={media} />
|
<MediaDetailsSidebar
|
||||||
|
media={media}
|
||||||
|
faces={faces}
|
||||||
|
isLoadingFaces={isLoadingFaces}
|
||||||
|
onHoverFace={setHoveredFaceId}
|
||||||
|
onFaceClick={handleFaceClick}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Skeleton className="w-full h-full" />
|
<Skeleton className="w-full h-full" />
|
||||||
)}
|
)}
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,26 +2,52 @@ import { useGetPerson } from "@/features/people/use-people";
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { UserSquare } from "lucide-react";
|
import { UserSquare } from "lucide-react";
|
||||||
|
import type { FaceRegion } from "@/domain/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type PersonFaceBadgeProps = {
|
type PersonFaceBadgeProps = {
|
||||||
personId: string | null;
|
face: FaceRegion;
|
||||||
|
onMouseEnter?: () => void;
|
||||||
|
onMouseLeave?: () => void;
|
||||||
|
onClick?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PersonFaceBadge({ personId }: PersonFaceBadgeProps) {
|
export function PersonFaceBadge({
|
||||||
const { data: person } = useGetPerson(personId ?? "");
|
face,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
onClick
|
||||||
|
}: PersonFaceBadgeProps) {
|
||||||
|
const { data: person } = useGetPerson(face.person_id ?? "");
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
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} />
|
<UserSquare size={16} />
|
||||||
{person ? person.name : personId ? "Loading..." : "Unknown"}
|
{person ? person.name : face.person_id ? "Loading..." : "Unknown"}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!personId || !person) {
|
if (!face.person_id || !person) {
|
||||||
return content;
|
return (
|
||||||
|
<div onClick={onClick}>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -29,6 +55,8 @@ export function PersonFaceBadge({ personId }: PersonFaceBadgeProps) {
|
|||||||
to="/people/$personId"
|
to="/people/$personId"
|
||||||
params={{ personId: person.id }}
|
params={{ personId: person.id }}
|
||||||
className="hover:opacity-80"
|
className="hover:opacity-80"
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
188
libertas-frontend/src/components/sharing/share-dialog.tsx
Normal file
188
libertas-frontend/src/components/sharing/share-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,9 +9,10 @@ import {
|
|||||||
removeMediaFromAlbum,
|
removeMediaFromAlbum,
|
||||||
setAlbumThumbnail,
|
setAlbumThumbnail,
|
||||||
shareAlbum,
|
shareAlbum,
|
||||||
|
unshareAlbum,
|
||||||
updateAlbum,
|
updateAlbum,
|
||||||
|
getAlbumShares,
|
||||||
type AddMediaToAlbumPayload,
|
type AddMediaToAlbumPayload,
|
||||||
type CreateAlbumPayload,
|
|
||||||
type RemoveMediaFromAlbumPayload,
|
type RemoveMediaFromAlbumPayload,
|
||||||
type SetAlbumThumbnailPayload,
|
type SetAlbumThumbnailPayload,
|
||||||
type ShareAlbumPayload,
|
type ShareAlbumPayload,
|
||||||
@@ -154,13 +155,32 @@ export const useShareAlbum = (albumId: string) => {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (payload: ShareAlbumPayload) => shareAlbum(albumId, payload),
|
mutationFn: (payload: ShareAlbumPayload) => shareAlbum(albumId, payload),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Invalidate sharing info (when we add that query)
|
queryClient.invalidateQueries({ queryKey: [ALBUMS_KEY, "details", albumId, "shares"] });
|
||||||
// queryClient.invalidateQueries({ queryKey: [ALBUMS_KEY, "details", albumId, "shares"] });
|
|
||||||
// TODO: Add success toast
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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.
|
* Mutation hook to set an album's thumbnail.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,10 +17,25 @@ const MEDIA_KEY = ["media"];
|
|||||||
* Query hook to fetch a paginated list of all media.
|
* Query hook to fetch a paginated list of all media.
|
||||||
* This uses `useInfiniteQuery` for "load more" functionality.
|
* 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({
|
return useInfiniteQuery({
|
||||||
queryKey: [MEDIA_KEY, "list"],
|
queryKey: [MEDIA_KEY, "list", page, limit, sortBy, order, mimeType, filters],
|
||||||
queryFn: ({ pageParam = 1 }) => getMediaList({ page: pageParam, limit: 20 }),
|
queryFn: ({ pageParam = 1 }) =>
|
||||||
|
getMediaList({
|
||||||
|
page: pageParam,
|
||||||
|
limit,
|
||||||
|
sort_by: sortBy,
|
||||||
|
order,
|
||||||
|
mime_type: mimeType,
|
||||||
|
filters,
|
||||||
|
}),
|
||||||
getNextPageParam: (lastPage) => {
|
getNextPageParam: (lastPage) => {
|
||||||
return lastPage.has_next_page ? lastPage.page + 1 : undefined;
|
return lastPage.has_next_page ? lastPage.page + 1 : undefined;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
unsharePerson,
|
unsharePerson,
|
||||||
updatePerson,
|
updatePerson,
|
||||||
clusterFaces,
|
clusterFaces,
|
||||||
|
getPersonShares,
|
||||||
type CreatePersonPayload,
|
type CreatePersonPayload,
|
||||||
type MergePersonPayload,
|
type MergePersonPayload,
|
||||||
type SetPersonThumbnailPayload,
|
type SetPersonThumbnailPayload,
|
||||||
@@ -45,7 +46,7 @@ export const useGetPerson = (personId: string) => {
|
|||||||
export const useListPersonMedia = (personId: string) => {
|
export const useListPersonMedia = (personId: string) => {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: [PERSON_KEY, "details", personId, "media"],
|
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) => {
|
getNextPageParam: (lastPage) => {
|
||||||
return lastPage.has_next_page ? lastPage.page + 1 : undefined;
|
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) => {
|
export const useUpdatePerson = (personId: string) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
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) => {
|
export const useUnsharePerson = (personId: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (payload: UnsharePersonPayload) =>
|
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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
15
libertas-frontend/src/hooks/use-debounce.ts
Normal file
15
libertas-frontend/src/hooks/use-debounce.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { AddMediaToAlbumDialog } from "@/components/albums/add-media-to-album-dialog";
|
import { AddMediaToAlbumDialog } from "@/components/albums/add-media-to-album-dialog";
|
||||||
import { AuthenticatedImage } from "@/components/media/authenticated-image";
|
import { AuthenticatedImage } from "@/components/media/authenticated-image";
|
||||||
import { MediaViewer } from "@/components/media/media-viewer";
|
import { MediaViewer } from "@/components/media/media-viewer";
|
||||||
|
import { ShareDialog, type SharePermission } from "@/components/sharing/share-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
@@ -11,10 +13,13 @@ import type { Media } from "@/domain/types";
|
|||||||
import {
|
import {
|
||||||
useGetAlbum,
|
useGetAlbum,
|
||||||
useGetAlbumMedia,
|
useGetAlbumMedia,
|
||||||
|
useGetAlbumShares,
|
||||||
useRemoveMediaFromAlbum,
|
useRemoveMediaFromAlbum,
|
||||||
|
useShareAlbum,
|
||||||
|
useUnshareAlbum,
|
||||||
} from "@/features/albums/use-albums";
|
} from "@/features/albums/use-albums";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { Eye, Trash2 } from "lucide-react";
|
import { Eye, Share2, Trash2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export const Route = createFileRoute("/albums/$albumId")({
|
export const Route = createFileRoute("/albums/$albumId")({
|
||||||
@@ -33,6 +38,10 @@ function AlbumDetailPage() {
|
|||||||
isLoading: isLoadingMedia,
|
isLoading: isLoadingMedia,
|
||||||
error: mediaError,
|
error: mediaError,
|
||||||
} = useGetAlbumMedia(albumId);
|
} = 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 [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
||||||
|
|
||||||
const { mutate: removeMedia, isPending: isRemoving } =
|
const { mutate: removeMedia, isPending: isRemoving } =
|
||||||
@@ -42,19 +51,58 @@ function AlbumDetailPage() {
|
|||||||
const error = albumError || mediaError;
|
const error = albumError || mediaError;
|
||||||
|
|
||||||
const handleRemoveMedia = (mediaId: string) => {
|
const handleRemoveMedia = (mediaId: string) => {
|
||||||
|
if (confirm("Are you sure you want to remove this photo from the album?")) {
|
||||||
removeMedia({
|
removeMedia({
|
||||||
media_ids: [mediaId],
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-3xl font-bold truncate">
|
<h1 className="text-3xl font-bold truncate">
|
||||||
{album?.name ?? "Loading album..."}
|
{album?.name ?? "Loading album..."}
|
||||||
</h1>
|
</h1>
|
||||||
|
<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} />
|
<AddMediaToAlbumDialog albumId={albumId} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading && <p>Loading photos...</p>}
|
{isLoading && <p>Loading photos...</p>}
|
||||||
{error && <p>Error loading photos: {error.message}</p>}
|
{error && <p>Error loading photos: {error.message}</p>}
|
||||||
|
|||||||
@@ -3,16 +3,33 @@ import { createFileRoute } from "@tanstack/react-router";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AuthenticatedImage } from "@/components/media/authenticated-image";
|
import { AuthenticatedImage } from "@/components/media/authenticated-image";
|
||||||
import type { Media } from "@/domain/types";
|
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 { MediaViewer } from "@/components/media/media-viewer";
|
||||||
import { groupMediaByDate } from "@/lib/date-utils"; // Import our new helper
|
import { groupMediaByDate } from "@/lib/date-utils";
|
||||||
import { parseISO } from "date-fns";
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
export const Route = createFileRoute("/media/")({
|
export const Route = createFileRoute("/media/")({
|
||||||
component: MediaPage,
|
component: MediaPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
function 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 {
|
const {
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -20,20 +37,12 @@ function MediaPage() {
|
|||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
} = useGetMediaList();
|
} = useGetMediaList(1, 20, sortBy, sortOrder, mimeType, filters);
|
||||||
|
|
||||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
||||||
|
|
||||||
const allMedia = useMemo(
|
const allMedia = useMemo(
|
||||||
() =>
|
() => data?.pages.flatMap((page) => page.data) ?? [],
|
||||||
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]
|
[data]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -44,10 +53,146 @@ function MediaPage() {
|
|||||||
[groupedMedia]
|
[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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<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">></SelectItem>
|
||||||
|
<SelectItem value="lt"><</SelectItem>
|
||||||
|
<SelectItem value="gte">>=</SelectItem>
|
||||||
|
<SelectItem value="lte"><=</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>
|
</div>
|
||||||
|
|
||||||
{isLoading && <p>Loading photos...</p>}
|
{isLoading && <p>Loading photos...</p>}
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
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 { AuthenticatedImage } from "@/components/media/authenticated-image";
|
||||||
import { MediaViewer } from "@/components/media/media-viewer";
|
import { MediaViewer } from "@/components/media/media-viewer";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { type Media } from "@/domain/types";
|
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")({
|
export const Route = createFileRoute("/people/$personId")({
|
||||||
component: PersonDetailPage,
|
component: PersonDetailPage,
|
||||||
@@ -15,27 +23,100 @@ function PersonDetailPage() {
|
|||||||
const { personId } = Route.useParams();
|
const { personId } = Route.useParams();
|
||||||
const { data: person, isLoading: isLoadingPerson } = useGetPerson(personId);
|
const { data: person, isLoading: isLoadingPerson } = useGetPerson(personId);
|
||||||
const {
|
const {
|
||||||
data: mediaPages,
|
data: mediaPage,
|
||||||
isLoading: isLoadingMedia,
|
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
|
isLoading: isLoadingMedia,
|
||||||
} = useListPersonMedia(personId);
|
} = 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 [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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-3xl font-bold truncate">
|
{isEditingName ? (
|
||||||
{person?.name ?? "Loading person..."}
|
<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>
|
</h1>
|
||||||
{person && <EditPersonDialog person={person} />}
|
)}
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{(isLoadingPerson || isLoadingMedia) && !mediaPages && (
|
{(isLoadingPerson || isLoadingMedia) && !mediaPage && (
|
||||||
<p>Loading photos...</p>
|
<p>Loading photos...</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 apiClient from "@/services/api-client";
|
||||||
import { processMediaUrls } from "./media-service";
|
import { processMediaUrls } from "./media-service";
|
||||||
|
|
||||||
@@ -87,9 +87,28 @@ export const shareAlbum = async (
|
|||||||
await apiClient.post(`/albums/${albumId}/share`, payload);
|
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 (
|
export const setAlbumThumbnail = async (
|
||||||
albumId: string,
|
albumId: string,
|
||||||
payload: SetAlbumThumbnailPayload,
|
payload: SetAlbumThumbnailPayload,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await apiClient.put(`/albums/${albumId}/thumbnail`, payload);
|
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;
|
||||||
|
};
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import type { Media, MediaDetails, PaginatedResponse } from "@/domain/types"
|
import type { Media, MediaDetails, PaginatedResponse } from "@/domain/types"
|
||||||
import apiClient from "@/services/api-client"
|
import apiClient from "@/services/api-client"
|
||||||
|
|
||||||
type MediaListParams = {
|
export interface MediaListParams {
|
||||||
page: number
|
page?: number;
|
||||||
limit: 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 => ({
|
export const processMediaUrls = (media: Media): Media => ({
|
||||||
...media,
|
...media,
|
||||||
@@ -22,10 +26,23 @@ export const processMediaUrls = (media: Media): Media => ({
|
|||||||
export const getMediaList = async ({
|
export const getMediaList = async ({
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
|
sort_by,
|
||||||
|
order,
|
||||||
|
mime_type,
|
||||||
|
filters,
|
||||||
}: MediaListParams): Promise<PaginatedResponse<Media>> => {
|
}: MediaListParams): Promise<PaginatedResponse<Media>> => {
|
||||||
const { data } = await apiClient.get("/media", {
|
const params = new URLSearchParams();
|
||||||
params: { page, limit },
|
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);
|
data.data = data.data.map(processMediaUrls);
|
||||||
return data;
|
return data;
|
||||||
@@ -63,8 +80,6 @@ export const getMediaDetails = async (
|
|||||||
mediaId: string,
|
mediaId: string,
|
||||||
): Promise<MediaDetails> => {
|
): Promise<MediaDetails> => {
|
||||||
const { data } = await apiClient.get(`/media/${mediaId}`);
|
const { data } = await apiClient.get(`/media/${mediaId}`);
|
||||||
console.log('Data for media details: ', data);
|
|
||||||
|
|
||||||
// Process the media URLs in the details response
|
// 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
|
data.thumbnail_url = data.thumbnail_url
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
Person,
|
Person,
|
||||||
PersonPermission,
|
PersonPermission,
|
||||||
|
User,
|
||||||
} from "@/domain/types";
|
} from "@/domain/types";
|
||||||
import apiClient from "@/services/api-client";
|
import apiClient from "@/services/api-client";
|
||||||
import { processMediaUrls } from "./media-service"; // We can import the helper
|
import { processMediaUrls } from "./media-service"; // We can import the helper
|
||||||
@@ -88,11 +89,15 @@ export const sharePerson = async (
|
|||||||
await apiClient.post(`/people/${personId}/share`, payload);
|
await apiClient.post(`/people/${personId}/share`, payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const unsharePerson = async (
|
export const unsharePerson = async (
|
||||||
personId: string,
|
personId: string,
|
||||||
payload: UnsharePersonPayload,
|
targetUserId: string,
|
||||||
): Promise<void> => {
|
): 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 (
|
export const mergePerson = async (
|
||||||
@@ -109,6 +114,16 @@ export const setPersonThumbnail = async (
|
|||||||
await apiClient.put(`/people/${personId}/thumbnail`, payload);
|
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> => {
|
export const clusterFaces = async (): Promise<void> => {
|
||||||
await apiClient.post("/people/cluster");
|
await apiClient.post("/people/cluster");
|
||||||
};
|
};
|
||||||
@@ -8,3 +8,13 @@ export const getMe = async (): Promise<User> => {
|
|||||||
const { data } = await apiClient.get("/users/me");
|
const { data } = await apiClient.get("/users/me");
|
||||||
return data;
|
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;
|
||||||
|
};
|
||||||
@@ -5,7 +5,8 @@ use axum::{
|
|||||||
use libertas_core::{
|
use libertas_core::{
|
||||||
error::CoreError,
|
error::CoreError,
|
||||||
schema::{
|
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 })
|
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 {
|
let filter = Some(FilterParams {
|
||||||
mime_type: params.mime_type,
|
mime_type: params.mime_type,
|
||||||
metadata_filters,
|
metadata_filters,
|
||||||
|
conditions,
|
||||||
});
|
});
|
||||||
|
|
||||||
ListMediaOptions {
|
ListMediaOptions {
|
||||||
@@ -81,10 +117,33 @@ impl FromRequestParts<AppState> for ApiListMediaOptions {
|
|||||||
parts: &mut Parts,
|
parts: &mut Parts,
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
) -> Result<Self, Self::Rejection> {
|
) -> 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
|
.await
|
||||||
.map_err(|e| ApiError::from(CoreError::Validation(e.to_string())))?;
|
.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()))
|
Ok(ApiListMediaOptions(params.into()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,46 @@ async fn list_user_albums(
|
|||||||
Ok(Json(response))
|
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(
|
async fn get_album_details(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
UserId(user_id): UserId,
|
UserId(user_id): UserId,
|
||||||
@@ -179,5 +219,10 @@ pub fn album_routes() -> Router<AppState> {
|
|||||||
.get(get_media_for_album)
|
.get(get_media_for_album)
|
||||||
.delete(remove_media_from_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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ pub fn people_routes() -> Router<AppState> {
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/{person_id}/share",
|
"/{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}/merge", post(merge_person))
|
||||||
.route("/{person_id}/thumbnail", put(set_person_thumbnail))
|
.route("/{person_id}/thumbnail", put(set_person_thumbnail))
|
||||||
@@ -120,7 +122,7 @@ async fn unshare_person(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
UserId(user_id): UserId,
|
UserId(user_id): UserId,
|
||||||
Path(person_id): Path<Uuid>,
|
Path(person_id): Path<Uuid>,
|
||||||
Json(payload): Json<SharePersonRequest>,
|
Json(payload): Json<crate::schema::UnsharePersonRequest>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
state
|
state
|
||||||
.person_service
|
.person_service
|
||||||
@@ -129,6 +131,33 @@ async fn unshare_person(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
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(
|
async fn list_faces_for_media(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
UserId(user_id): UserId,
|
UserId(user_id): UserId,
|
||||||
|
|||||||
@@ -18,6 +18,33 @@ pub async fn get_me(
|
|||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn user_routes() -> Router<AppState> {
|
#[derive(serde::Deserialize)]
|
||||||
Router::new().route("/me", axum::routing::get(get_me))
|
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(¶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<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/me", axum::routing::get(get_me))
|
||||||
|
.route("/search", axum::routing::get(search_users))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ pub struct ListMediaParams {
|
|||||||
pub mime_type: Option<String>,
|
pub mime_type: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub metadata: Vec<String>,
|
pub metadata: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub filters: Vec<String>,
|
||||||
pub page: Option<u32>,
|
pub page: Option<u32>,
|
||||||
pub limit: Option<u32>,
|
pub limit: Option<u32>,
|
||||||
}
|
}
|
||||||
@@ -64,6 +66,17 @@ pub struct ShareAlbumRequest {
|
|||||||
pub permission: AlbumPermission,
|
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)]
|
#[derive(Serialize)]
|
||||||
pub struct AlbumResponse {
|
pub struct AlbumResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -244,12 +257,23 @@ pub struct SharePersonRequest {
|
|||||||
pub permission: PersonPermission,
|
pub permission: PersonPermission,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UnsharePersonRequest {
|
||||||
|
pub target_user_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct PublicAlbumBundleResponse {
|
pub struct PublicAlbumBundleResponse {
|
||||||
pub album: AlbumResponse,
|
pub album: AlbumResponse,
|
||||||
pub media: Vec<MediaResponse>,
|
pub media: Vec<MediaResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct PersonShareResponse {
|
||||||
|
pub user: UserResponse,
|
||||||
|
pub permission: PersonPermission,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct MergePersonRequest {
|
pub struct MergePersonRequest {
|
||||||
pub source_person_id: Uuid,
|
pub source_person_id: Uuid,
|
||||||
|
|||||||
@@ -213,4 +213,36 @@ impl AlbumService for AlbumServiceImpl {
|
|||||||
.remove_media_from_album(album_id, media_ids)
|
.remove_media_from_album(album_id, media_ids)
|
||||||
.await
|
.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,21 +187,6 @@ impl PersonService for PersonServiceImpl {
|
|||||||
.await
|
.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(
|
async fn merge_people(
|
||||||
&self,
|
&self,
|
||||||
target_person_id: Uuid,
|
target_person_id: Uuid,
|
||||||
@@ -342,4 +327,33 @@ impl PersonService for PersonServiceImpl {
|
|||||||
let response = PaginatedResponse::new(data, pagination.page, pagination.limit, total_items);
|
let response = PaginatedResponse::new(data, pagination.page, pagination.limit, total_items);
|
||||||
Ok(response)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,4 +96,8 @@ impl UserService for UserServiceImpl {
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(CoreError::NotFound("User".to_string(), user_id))
|
.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|
||||||
@@ -120,7 +120,8 @@ pub struct AlbumMedia {
|
|||||||
pub media_id: uuid::Uuid,
|
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 {
|
pub enum AlbumPermission {
|
||||||
View,
|
View,
|
||||||
Contribute,
|
Contribute,
|
||||||
@@ -166,7 +167,8 @@ pub struct Tag {
|
|||||||
pub name: String,
|
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 {
|
pub enum PersonPermission {
|
||||||
View,
|
View,
|
||||||
CanUse,
|
CanUse,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ pub trait UserRepository: Send + Sync {
|
|||||||
async fn find_by_username(&self, username: &str) -> CoreResult<Option<User>>;
|
async fn find_by_username(&self, username: &str) -> CoreResult<Option<User>>;
|
||||||
async fn find_by_id(&self, id: Uuid) -> 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 update_storage_used(&self, user_id: Uuid, bytes: i64) -> CoreResult<()>;
|
||||||
|
async fn search_users(&self, query: &str) -> CoreResult<Vec<User>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -73,6 +74,13 @@ pub trait AlbumShareRepository: Send + Sync {
|
|||||||
media_id: Uuid,
|
media_id: Uuid,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> CoreResult<bool>;
|
) -> 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]
|
#[async_trait]
|
||||||
@@ -131,6 +139,11 @@ pub trait PersonShareRepository: Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> CoreResult<Vec<(Person, PersonPermission)>>;
|
) -> CoreResult<Vec<(Person, PersonPermission)>>;
|
||||||
|
|
||||||
|
async fn list_shares_for_person(
|
||||||
|
&self,
|
||||||
|
person_id: Uuid,
|
||||||
|
) -> CoreResult<Vec<(User, PersonPermission)>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ pub struct SortParams {
|
|||||||
pub struct FilterParams {
|
pub struct FilterParams {
|
||||||
pub mime_type: Option<String>,
|
pub mime_type: Option<String>,
|
||||||
pub metadata_filters: Option<Vec<MetadataFilter>>,
|
pub metadata_filters: Option<Vec<MetadataFilter>>,
|
||||||
|
pub conditions: Option<Vec<FilterCondition>>,
|
||||||
// In the future, we can add fields like:
|
// In the future, we can add fields like:
|
||||||
// pub date_range: Option<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>,
|
// pub date_range: Option<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>,
|
||||||
}
|
}
|
||||||
@@ -82,6 +83,24 @@ pub struct MetadataFilter {
|
|||||||
pub tag_value: String,
|
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 struct MediaImportBundle {
|
||||||
pub media_model: Media,
|
pub media_model: Media,
|
||||||
pub metadata_models: Vec<MediaMetadata>,
|
pub metadata_models: Vec<MediaMetadata>,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use crate::{
|
|||||||
authz::Permission,
|
authz::Permission,
|
||||||
error::CoreResult,
|
error::CoreResult,
|
||||||
models::{
|
models::{
|
||||||
Album, FaceRegion, Media, MediaBundle, Person, PersonPermission, PublicAlbumBundle, Tag,
|
Album, AlbumPermission, FaceRegion, Media, MediaBundle, Person, PersonPermission,
|
||||||
User,
|
PublicAlbumBundle, Tag, User,
|
||||||
},
|
},
|
||||||
schema::{
|
schema::{
|
||||||
AddMediaToAlbumData, CreateAlbumData, CreateUserData, ListMediaOptions, LoginUserData,
|
AddMediaToAlbumData, CreateAlbumData, CreateUserData, ListMediaOptions, LoginUserData,
|
||||||
@@ -35,6 +35,7 @@ pub trait UserService: Send + Sync {
|
|||||||
async fn register(&self, data: CreateUserData<'_>) -> CoreResult<User>;
|
async fn register(&self, data: CreateUserData<'_>) -> CoreResult<User>;
|
||||||
async fn login(&self, data: LoginUserData<'_>) -> CoreResult<String>;
|
async fn login(&self, data: LoginUserData<'_>) -> CoreResult<String>;
|
||||||
async fn get_user_details(&self, user_id: uuid::Uuid) -> CoreResult<User>;
|
async fn get_user_details(&self, user_id: uuid::Uuid) -> CoreResult<User>;
|
||||||
|
async fn search_users(&self, query: &str) -> CoreResult<Vec<User>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -65,6 +66,18 @@ pub trait AlbumService: Send + Sync {
|
|||||||
media_ids: &[Uuid],
|
media_ids: &[Uuid],
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> CoreResult<()>;
|
) -> 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]
|
#[async_trait]
|
||||||
@@ -143,6 +156,12 @@ pub trait PersonService: Send + Sync {
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
options: ListMediaOptions,
|
options: ListMediaOptions,
|
||||||
) -> CoreResult<PaginatedResponse<Media>>;
|
) -> CoreResult<PaginatedResponse<Media>>;
|
||||||
|
|
||||||
|
async fn get_person_shares(
|
||||||
|
&self,
|
||||||
|
person_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> CoreResult<Vec<(User, PersonPermission)>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ pub struct PostgresMediaMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, sqlx::Type, PartialEq, Eq, Deserialize)]
|
#[derive(Debug, Clone, Copy, sqlx::Type, PartialEq, Eq, Deserialize)]
|
||||||
#[sqlx(rename_all = "lowercase")]
|
#[sqlx(rename_all = "snake_case")]
|
||||||
#[sqlx(type_name = "album_permission")]
|
#[sqlx(type_name = "album_permission")]
|
||||||
pub enum PostgresAlbumPermission {
|
pub enum PostgresAlbumPermission {
|
||||||
View,
|
View,
|
||||||
@@ -103,7 +103,7 @@ pub struct PostgresFaceRegion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, sqlx::Type, PartialEq, Eq, Deserialize)]
|
#[derive(Debug, Clone, Copy, sqlx::Type, PartialEq, Eq, Deserialize)]
|
||||||
#[sqlx(rename_all = "lowercase")]
|
#[sqlx(rename_all = "snake_case")]
|
||||||
#[sqlx(type_name = "person_permission")]
|
#[sqlx(type_name = "person_permission")]
|
||||||
pub enum PostgresPersonPermission {
|
pub enum PostgresPersonPermission {
|
||||||
View,
|
View,
|
||||||
|
|||||||
@@ -4,6 +4,324 @@ use libertas_core::{
|
|||||||
};
|
};
|
||||||
use sqlx::QueryBuilder as SqlxQueryBuilder;
|
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> {
|
pub trait QueryBuilder<T> {
|
||||||
fn apply_options_to_query<'a>(
|
fn apply_options_to_query<'a>(
|
||||||
&self,
|
&self,
|
||||||
@@ -13,28 +331,65 @@ pub trait QueryBuilder<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct MediaQueryBuilder {
|
pub struct MediaQueryBuilder {
|
||||||
allowed_sort_columns: Vec<String>,
|
sort_strategies: Vec<Box<dyn SortStrategy>>,
|
||||||
|
filter_strategies: Vec<Box<dyn FilterStrategy>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MediaQueryBuilder {
|
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 {
|
Self {
|
||||||
allowed_sort_columns,
|
sort_strategies,
|
||||||
|
filter_strategies,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_sort_column<'a>(&self, column: &'a str) -> CoreResult<&'a str> {
|
pub fn apply_joins<'a>(
|
||||||
if self.allowed_sort_columns.contains(&column.to_string()) {
|
&self,
|
||||||
Ok(column)
|
mut query: SqlxQueryBuilder<'a, sqlx::Postgres>,
|
||||||
} else {
|
options: &'a ListMediaOptions,
|
||||||
Err(CoreError::Validation(format!(
|
) -> CoreResult<SqlxQueryBuilder<'a, sqlx::Postgres>> {
|
||||||
"Sorting by '{}' is not supported",
|
if let Some(filter) = &options.filter {
|
||||||
column
|
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 ");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_filters_to_query<'a>(
|
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_conditions<'a>(
|
||||||
&self,
|
&self,
|
||||||
mut query: SqlxQueryBuilder<'a, sqlx::Postgres>,
|
mut query: SqlxQueryBuilder<'a, sqlx::Postgres>,
|
||||||
options: &'a ListMediaOptions,
|
options: &'a ListMediaOptions,
|
||||||
@@ -49,7 +404,6 @@ impl MediaQueryBuilder {
|
|||||||
if let Some(metadata_filters) = &filter.metadata_filters {
|
if let Some(metadata_filters) = &filter.metadata_filters {
|
||||||
if !metadata_filters.is_empty() {
|
if !metadata_filters.is_empty() {
|
||||||
metadata_filter_count = metadata_filters.len() as i64;
|
metadata_filter_count = metadata_filters.len() as i64;
|
||||||
query.push(" JOIN media_metadata mm ON media.id = mm.media_id ");
|
|
||||||
query.push(" AND ( ");
|
query.push(" AND ( ");
|
||||||
|
|
||||||
for (i, filter) in metadata_filters.iter().enumerate() {
|
for (i, filter) in metadata_filters.iter().enumerate() {
|
||||||
@@ -65,6 +419,18 @@ impl MediaQueryBuilder {
|
|||||||
query.push(" ) ");
|
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))
|
Ok((query, metadata_filter_count))
|
||||||
}
|
}
|
||||||
@@ -75,18 +441,25 @@ impl MediaQueryBuilder {
|
|||||||
options: &'a ListMediaOptions,
|
options: &'a ListMediaOptions,
|
||||||
) -> CoreResult<SqlxQueryBuilder<'a, sqlx::Postgres>> {
|
) -> CoreResult<SqlxQueryBuilder<'a, sqlx::Postgres>> {
|
||||||
if let Some(sort) = &options.sort {
|
if let Some(sort) = &options.sort {
|
||||||
let column = self.validate_sort_column(&sort.sort_by)?;
|
|
||||||
let direction = match sort.sort_order {
|
let direction = match sort.sort_order {
|
||||||
SortOrder::Asc => "ASC",
|
SortOrder::Asc => "ASC",
|
||||||
SortOrder::Desc => "DESC",
|
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 {
|
} else {
|
||||||
"NULLS FIRST"
|
// Should not happen if we have a default/catch-all strategy, but good to handle
|
||||||
};
|
return Err(CoreError::Validation(format!(
|
||||||
let order_by_clause = format!("ORDER BY {} {} {}", column, direction, nulls_order);
|
"No sort strategy found for column: {}",
|
||||||
query.push(order_by_clause);
|
sort.sort_by
|
||||||
|
)));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
query.push(" ORDER BY media.created_at DESC NULLS LAST ");
|
query.push(" ORDER BY media.created_at DESC NULLS LAST ");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,4 +111,61 @@ impl AlbumShareRepository for PostgresAlbumShareRepository {
|
|||||||
|
|
||||||
Ok(result.exists.unwrap_or(false))
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,31 @@ pub struct PostgresMediaRepository {
|
|||||||
|
|
||||||
impl PostgresMediaRepository {
|
impl PostgresMediaRepository {
|
||||||
pub fn new(pool: PgPool, config: &AppConfig) -> Self {
|
pub fn new(pool: PgPool, config: &AppConfig) -> Self {
|
||||||
let allowed_columns = config
|
let mut allowed_columns = config
|
||||||
.allowed_sort_columns
|
.allowed_sort_columns
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| vec!["created_at".to_string(), "original_filename".to_string()]);
|
.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 {
|
Self {
|
||||||
pool,
|
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)> {
|
) -> CoreResult<(Vec<Media>, i64)> {
|
||||||
let count_base_sql = "SELECT COUNT(DISTINCT media.id) as total FROM media";
|
let count_base_sql = "SELECT COUNT(DISTINCT media.id) as total FROM media";
|
||||||
let mut count_query = sqlx::QueryBuilder::new(count_base_sql);
|
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(" WHERE media.owner_id = ");
|
||||||
count_query.push_bind(user_id);
|
count_query.push_bind(user_id);
|
||||||
|
|
||||||
let (mut count_query, metadata_filter_count) = self
|
let (mut count_query, metadata_filter_count) =
|
||||||
.query_builder
|
self.query_builder.apply_conditions(count_query, options)?;
|
||||||
.apply_filters_to_query(count_query, options)?;
|
|
||||||
|
|
||||||
if metadata_filter_count > 0 {
|
if metadata_filter_count > 0 {
|
||||||
count_query.push(" GROUP BY media.id ");
|
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 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);
|
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(" WHERE media.owner_id = ");
|
||||||
data_query.push_bind(user_id);
|
data_query.push_bind(user_id);
|
||||||
|
|
||||||
let (mut data_query, metadata_filter_count) = self
|
let (mut data_query, metadata_filter_count) =
|
||||||
.query_builder
|
self.query_builder.apply_conditions(data_query, options)?;
|
||||||
.apply_filters_to_query(data_query, options)?;
|
|
||||||
|
|
||||||
if metadata_filter_count > 0 {
|
if metadata_filter_count > 0 {
|
||||||
data_query.push(" GROUP BY media.id ");
|
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
|
JOIN face_regions fr ON media.id = fr.media_id
|
||||||
";
|
";
|
||||||
let mut count_query = sqlx::QueryBuilder::new(count_base_sql);
|
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(" WHERE fr.person_id = ");
|
||||||
count_query.push_bind(person_id);
|
count_query.push_bind(person_id);
|
||||||
|
|
||||||
let (mut count_query, _metadata_filter_count) = self
|
let (mut count_query, _metadata_filter_count) =
|
||||||
.query_builder
|
self.query_builder.apply_conditions(count_query, options)?;
|
||||||
.apply_filters_to_query(count_query, options)?;
|
|
||||||
|
|
||||||
let total_items_result = count_query
|
let total_items_result = count_query
|
||||||
.build_query_scalar()
|
.build_query_scalar()
|
||||||
@@ -195,12 +218,14 @@ impl MediaRepository for PostgresMediaRepository {
|
|||||||
JOIN face_regions fr ON media.id = fr.media_id
|
JOIN face_regions fr ON media.id = fr.media_id
|
||||||
";
|
";
|
||||||
let mut data_query = sqlx::QueryBuilder::new(data_base_sql);
|
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(" WHERE fr.person_id = ");
|
||||||
data_query.push_bind(person_id);
|
data_query.push_bind(person_id);
|
||||||
|
|
||||||
let (mut data_query, _metadata_filter_count) = self
|
let (mut data_query, _metadata_filter_count) =
|
||||||
.query_builder
|
self.query_builder.apply_conditions(data_query, options)?;
|
||||||
.apply_filters_to_query(data_query, options)?;
|
|
||||||
|
|
||||||
data_query.push(" GROUP BY media.id ");
|
data_query.push(" GROUP BY media.id ");
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use libertas_core::{
|
|||||||
models::{Person, PersonPermission},
|
models::{Person, PersonPermission},
|
||||||
repositories::PersonShareRepository,
|
repositories::PersonShareRepository,
|
||||||
};
|
};
|
||||||
use sqlx::{types::Uuid, PgPool};
|
use sqlx::{PgPool, types::Uuid};
|
||||||
|
|
||||||
use crate::db_models::{PostgresPersonPermission, PostgresPersonShared};
|
use crate::db_models::{PostgresPersonPermission, PostgresPersonShared};
|
||||||
|
|
||||||
@@ -103,4 +103,46 @@ impl PersonShareRepository for PostgresPersonShareRepository {
|
|||||||
.map(PostgresPersonShared::into)
|
.map(PostgresPersonShared::into)
|
||||||
.collect())
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +137,27 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
async fn update_storage_used(&self, user_id: Uuid, bytes: i64) -> CoreResult<()> {
|
async fn update_storage_used(&self, user_id: Uuid, bytes: i64) -> CoreResult<()> {
|
||||||
Self::update_storage_used_internal(&self.pool, user_id, bytes).await
|
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]
|
#[async_trait]
|
||||||
@@ -165,4 +186,9 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
println!("SQLITE REPO: Updating user storage used");
|
println!("SQLITE REPO: Updating user storage used");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn search_users(&self, _query: &str) -> CoreResult<Vec<User>> {
|
||||||
|
println!("SQLITE REPO: Searching users");
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user