feat: Implement person management features
- Added hooks for listing, creating, updating, deleting, sharing, and merging people. - Introduced a new route for person details and media. - Implemented clustering faces functionality. - Created services for person-related API interactions. feat: Introduce tag management functionality - Added hooks for listing, adding, and removing tags from media. - Created services for tag-related API interactions. feat: Enhance user authentication handling - Added a hook to fetch current user details. - Updated auth storage to manage user state more effectively. feat: Update album management features - Enhanced album service to return created album details. - Updated API handlers to return album responses upon creation. - Modified album repository to return created album. feat: Implement media management improvements - Added media details fetching and processing of media URLs. - Enhanced media upload functionality to return processed media. feat: Introduce face management features - Added services for listing faces for media and assigning faces to persons. fix: Update API client to clear authentication state on 401 errors.
This commit is contained in:
@@ -33,7 +33,7 @@ export function AddMediaToAlbumDialog({ albumId }: AddMediaToAlbumDialogProps) {
|
|||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
} = useGetMediaList();
|
} = useGetMediaList();
|
||||||
|
|
||||||
const { mutate: addMedia, isPending: isAdding } = useAddMediaToAlbum();
|
const { mutate: addMedia, isPending: isAdding } = useAddMediaToAlbum(albumId);
|
||||||
|
|
||||||
const toggleSelection = (mediaId: string) => {
|
const toggleSelection = (mediaId: string) => {
|
||||||
setSelectedMediaIds((prev) =>
|
setSelectedMediaIds((prev) =>
|
||||||
@@ -46,8 +46,7 @@ export function AddMediaToAlbumDialog({ albumId }: AddMediaToAlbumDialogProps) {
|
|||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
addMedia(
|
addMedia(
|
||||||
{
|
{
|
||||||
albumId,
|
media_ids: selectedMediaIds,
|
||||||
payload: { media_ids: selectedMediaIds },
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { cn } from "@/lib/utils";
|
|||||||
import { useAuthStorage } from "@/hooks/use-auth-storage";
|
import { useAuthStorage } from "@/hooks/use-auth-storage";
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { user, clearToken } = useAuthStorage();
|
const { user, clearAuth } = useAuthStorage();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
clearToken();
|
clearAuth();
|
||||||
navigate({ to: "/login" });
|
navigate({ to: "/login" });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ export function Sidebar() {
|
|||||||
</SidebarLink>
|
</SidebarLink>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 border-t border-gray-700">
|
<div className="p-4 border-t border-gray-700">
|
||||||
<p className="text-sm text-gray-400">{user?.email}</p>
|
<p className="text-sm text-gray-400">{user?.email ?? "Loading..."}</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="w-full mt-2 text-left text-red-400 hover:text-red-300"
|
className="w-full mt-2 text-left text-red-400 hover:text-red-300"
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogFooter,
|
||||||
|
DialogClose,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useUpdatePerson } from "@/features/people/use-people";
|
||||||
|
import { type Person } from "@/domain/types";
|
||||||
|
import { Pencil } from "lucide-react";
|
||||||
|
|
||||||
|
type EditPersonDialogProps = {
|
||||||
|
person: Person;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EditPersonDialog({ person }: EditPersonDialogProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { mutate: updatePerson, isPending } = useUpdatePerson(person.id);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const name = formData.get("name") as string;
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
updatePerson(
|
||||||
|
{ name },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<Pencil size={18} />
|
||||||
|
<span className="sr-only">Edit Name</span>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Person</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Change the name for this person.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="name" className="text-right">
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
defaultValue={person.name}
|
||||||
|
required
|
||||||
|
className="col-span-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="outline">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
libertas-frontend/src/components/people/person-card.tsx
Normal file
102
libertas-frontend/src/components/people/person-card.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { type Person } from "@/domain/types";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Trash2, UserSquare } from "lucide-react";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { useDeletePerson } from "@/features/people/use-people";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type PersonCardProps = {
|
||||||
|
person: Person;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PersonCard({ person }: PersonCardProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const { mutate: deletePerson, isPending: isDeleting } = useDeletePerson(
|
||||||
|
person.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
deletePerson();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger>
|
||||||
|
<Card
|
||||||
|
className="overflow-hidden hover:shadow-lg transition-shadow cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
// Navigate on left click
|
||||||
|
navigate({
|
||||||
|
to: "/people/$personId",
|
||||||
|
params: { personId: person.id },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardHeader className="p-0">
|
||||||
|
<div className="aspect-square bg-gray-200 flex items-center justify-center">
|
||||||
|
{/* TODO: Add person thumbnail */}
|
||||||
|
<UserSquare className="w-1/2 h-1/2 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<CardTitle className="text-lg truncate text-center">
|
||||||
|
{person.name}
|
||||||
|
</CardTitle>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onSelect={() => setShowDeleteDialog(true)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete Person
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
|
||||||
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete{" "}
|
||||||
|
<strong>{person.name}</strong> and unassign all associated faces.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className={buttonVariants({ variant: "destructive" })}
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? "Deleting..." : "Delete"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,65 @@
|
|||||||
|
|
||||||
|
|
||||||
|
// --- Core Types ---
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
storage_used: number; // in bytes
|
||||||
|
storage_quota: number; // in bytes
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Media = {
|
||||||
|
id: string;
|
||||||
|
original_filename: string;
|
||||||
|
mime_type: string;
|
||||||
|
hash: string;
|
||||||
|
file_url: string;
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Album = {
|
||||||
|
id: string;
|
||||||
|
owner_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
is_public: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
thumbnail_media_id: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Person = {
|
||||||
|
id: string;
|
||||||
|
owner_id: string;
|
||||||
|
name: string;
|
||||||
|
thumbnail_media_id: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Tag = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FaceRegion = {
|
||||||
|
id: string;
|
||||||
|
media_id: string;
|
||||||
|
person_id: string | null;
|
||||||
|
x_min: number;
|
||||||
|
y_min: number;
|
||||||
|
x_max: number;
|
||||||
|
y_max: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaMetadata = {
|
||||||
|
source: string;
|
||||||
|
tag_name: string;
|
||||||
|
tag_value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- API Response Types ---
|
||||||
|
|
||||||
export type PaginatedResponse<T> = {
|
export type PaginatedResponse<T> = {
|
||||||
data: T[]
|
data: T[]
|
||||||
page: number
|
page: number
|
||||||
@@ -8,39 +70,14 @@ export type PaginatedResponse<T> = {
|
|||||||
has_prev_page: boolean
|
has_prev_page: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type User = {
|
|
||||||
id: string
|
|
||||||
username: string
|
|
||||||
email: string
|
|
||||||
storage_used: number
|
|
||||||
storage_quota: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Media = {
|
export type MediaDetails = {
|
||||||
id: string
|
media: Media;
|
||||||
original_filename: string
|
metadata: MediaMetadata[];
|
||||||
mime_type: string
|
};
|
||||||
hash: string
|
|
||||||
file_url: string
|
|
||||||
thumbnail_url: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Album = {
|
// --- Permission Enums ---
|
||||||
id: string
|
|
||||||
owner_id: string
|
|
||||||
name: string
|
|
||||||
description: string | null
|
|
||||||
is_public: boolean
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
thumbnail_media_id: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Person = {
|
export type AlbumPermission = "view" | "contribute";
|
||||||
id: string
|
|
||||||
owner_id: string
|
export type PersonPermission = "view" | "can_use";
|
||||||
name: string
|
|
||||||
thumbnail_media_id: string | null
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,23 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { addMediaToAlbum, createAlbum, getAlbum, getAlbumMedia, getAlbums, removeMediaFromAlbum, type AddMediaToAlbumPayload, type RemoveMediaFromAlbumPayload } from '@/services/album-service'
|
import {
|
||||||
|
addMediaToAlbum,
|
||||||
|
createAlbum,
|
||||||
|
deleteAlbum,
|
||||||
|
getAlbum,
|
||||||
|
getAlbumMedia,
|
||||||
|
getAlbums,
|
||||||
|
removeMediaFromAlbum,
|
||||||
|
setAlbumThumbnail,
|
||||||
|
shareAlbum,
|
||||||
|
updateAlbum,
|
||||||
|
type AddMediaToAlbumPayload,
|
||||||
|
type CreateAlbumPayload,
|
||||||
|
type RemoveMediaFromAlbumPayload,
|
||||||
|
type SetAlbumThumbnailPayload,
|
||||||
|
type ShareAlbumPayload,
|
||||||
|
type UpdateAlbumPayload,
|
||||||
|
} from "@/services/album-service";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
const ALBUMS_KEY = ["albums"];
|
const ALBUMS_KEY = ["albums"];
|
||||||
|
|
||||||
@@ -8,60 +26,8 @@ const ALBUMS_KEY = ["albums"];
|
|||||||
*/
|
*/
|
||||||
export const useGetAlbums = () => {
|
export const useGetAlbums = () => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['albums'],
|
queryKey: [ALBUMS_KEY, "list"],
|
||||||
queryFn: getAlbums,
|
queryFn: getAlbums,
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mutation hook to create a new album.
|
|
||||||
*/
|
|
||||||
export const useCreateAlbum = () => {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: createAlbum,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['albums'] })
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error('Failed to create album:', error)
|
|
||||||
// TODO: Add user-facing toast
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useGetAlbumMedia = (albumId: string) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [ALBUMS_KEY, albumId, "media"],
|
|
||||||
queryFn: () => getAlbumMedia(albumId),
|
|
||||||
enabled: !!albumId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mutation hook to add media to an album.
|
|
||||||
*/
|
|
||||||
export const useAddMediaToAlbum = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
albumId,
|
|
||||||
payload,
|
|
||||||
}: {
|
|
||||||
albumId: string;
|
|
||||||
payload: AddMediaToAlbumPayload;
|
|
||||||
}) => addMediaToAlbum(albumId, payload),
|
|
||||||
onSuccess: (_data, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: [ALBUMS_KEY, variables.albumId, "media"],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("Failed to add media to album:", error);
|
|
||||||
// TODO: Add user-facing toast
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,35 +36,146 @@ export const useAddMediaToAlbum = () => {
|
|||||||
*/
|
*/
|
||||||
export const useGetAlbum = (albumId: string) => {
|
export const useGetAlbum = (albumId: string) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [ALBUMS_KEY, albumId],
|
queryKey: [ALBUMS_KEY, "details", albumId],
|
||||||
queryFn: () => getAlbum(albumId),
|
queryFn: () => getAlbum(albumId),
|
||||||
enabled: !!albumId,
|
enabled: !!albumId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mutation hook to remove media from an album.
|
* Query hook to fetch all media for a single album.
|
||||||
*/
|
*/
|
||||||
export const useRemoveMediaFromAlbum = () => {
|
export const useGetAlbumMedia = (albumId: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [ALBUMS_KEY, "details", albumId, "media"],
|
||||||
|
queryFn: () => getAlbumMedia(albumId),
|
||||||
|
enabled: !!albumId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation hook to create a new album.
|
||||||
|
*/
|
||||||
|
export const useCreateAlbum = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({
|
mutationFn: createAlbum,
|
||||||
albumId,
|
onSuccess: () => {
|
||||||
payload,
|
queryClient.invalidateQueries({ queryKey: [ALBUMS_KEY, "list"] });
|
||||||
}: {
|
|
||||||
albumId: string;
|
|
||||||
payload: RemoveMediaFromAlbumPayload;
|
|
||||||
}) => removeMediaFromAlbum(albumId, payload),
|
|
||||||
onSuccess: (_data, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: [ALBUMS_KEY, variables.albumId, "media"],
|
|
||||||
});
|
|
||||||
// TODO: Add success toast
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Failed to remove media from album:", error);
|
console.error("Failed to create album:", error);
|
||||||
// TODO: Add error toast
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation hook to update an album's details.
|
||||||
|
*/
|
||||||
|
export const useUpdateAlbum = (albumId: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: UpdateAlbumPayload) => updateAlbum(albumId, payload),
|
||||||
|
onSuccess: (updatedAlbum) => {
|
||||||
|
// Update the list query
|
||||||
|
queryClient.invalidateQueries({ queryKey: [ALBUMS_KEY, "list"] });
|
||||||
|
// Update the details query
|
||||||
|
queryClient.setQueryData(
|
||||||
|
[ALBUMS_KEY, "details", albumId],
|
||||||
|
updatedAlbum,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation hook to delete an album.
|
||||||
|
*/
|
||||||
|
export const useDeleteAlbum = (albumId: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => deleteAlbum(albumId),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate the list
|
||||||
|
queryClient.invalidateQueries({ queryKey: [ALBUMS_KEY, "list"] });
|
||||||
|
// Remove the details query
|
||||||
|
queryClient.removeQueries({
|
||||||
|
queryKey: [ALBUMS_KEY, "details", albumId],
|
||||||
|
});
|
||||||
|
// Navigate away from the deleted album
|
||||||
|
navigate({ to: "/albums" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation hook to add media to an album.
|
||||||
|
*/
|
||||||
|
export const useAddMediaToAlbum = (albumId: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: AddMediaToAlbumPayload) =>
|
||||||
|
addMediaToAlbum(albumId, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [ALBUMS_KEY, "details", albumId, "media"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation hook to remove media from an album.
|
||||||
|
*/
|
||||||
|
export const useRemoveMediaFromAlbum = (albumId: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: RemoveMediaFromAlbumPayload) =>
|
||||||
|
removeMediaFromAlbum(albumId, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [ALBUMS_KEY, "details", albumId, "media"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation hook to share an album with another user.
|
||||||
|
*/
|
||||||
|
export const useShareAlbum = (albumId: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: ShareAlbumPayload) => shareAlbum(albumId, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate sharing info (when we add that query)
|
||||||
|
// queryClient.invalidateQueries({ queryKey: [ALBUMS_KEY, "details", albumId, "shares"] });
|
||||||
|
// TODO: Add success toast
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation hook to set an album's thumbnail.
|
||||||
|
*/
|
||||||
|
export const useSetAlbumThumbnail = (albumId: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: SetAlbumThumbnailPayload) =>
|
||||||
|
setAlbumThumbnail(albumId, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate both the album details (for the thumbnail_id) and the list
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [ALBUMS_KEY, "details", albumId],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: [ALBUMS_KEY, "list"] });
|
||||||
|
// TODO: Add success toast
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1,37 +1,58 @@
|
|||||||
import type { User } from "@/domain/types"
|
import { useAuthStorage } from "@/hooks/use-auth-storage";
|
||||||
import { useAuthStorage } from "@/hooks/use-auth-storage"
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import apiClient from "@/services/api-client"
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "@tanstack/react-router"
|
import { login, register } from "@/services/auth-service";
|
||||||
import { useMutation } from "@tanstack/react-query"
|
|
||||||
|
|
||||||
type LoginCredentials = {
|
// Types
|
||||||
usernameOrEmail: string
|
export type LoginCredentials = {
|
||||||
password: string
|
usernameOrEmail: string;
|
||||||
}
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
type LoginResponse = {
|
export type LoginResponse = {
|
||||||
token: string
|
token: string;
|
||||||
user: User
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
|
export type RegisterPayload = LoginCredentials & {
|
||||||
const { data } = await apiClient.post('/auth/login', credentials)
|
email: string;
|
||||||
return data
|
};
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation hook for user login.
|
||||||
|
*/
|
||||||
export const useLogin = () => {
|
export const useLogin = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const { setToken } = useAuthStorage()
|
const { setToken } = useAuthStorage();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: login,
|
mutationFn: login,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setToken(data.token, data.user)
|
setToken(data.token);
|
||||||
navigate({ to: '/' })
|
navigate({ to: "/" });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error('Login failed:', error)
|
console.error("Login failed:", error);
|
||||||
// TODO: Add user-facing error toast
|
// TODO: Add user-facing error toast
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation hook for user registration.
|
||||||
|
*/
|
||||||
|
export const useRegister = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: register,
|
||||||
|
onSuccess: () => {
|
||||||
|
// After successful registration, send them to the login page
|
||||||
|
// TODO: Add a success toast: "Registration successful! Please log in."
|
||||||
|
navigate({ to: "/login" });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Registration failed:", error);
|
||||||
|
// TODO: Add user-facing error toast
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
50
libertas-frontend/src/features/faces/use-faces.ts
Normal file
50
libertas-frontend/src/features/faces/use-faces.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
assignFaceToPerson,
|
||||||
|
listFacesForMedia,
|
||||||
|
type AssignFacePayload,
|
||||||
|
} from "@/services/face-service";
|
||||||
|
import type { FaceRegion } from "@/domain/types";
|
||||||
|
|
||||||
|
const FACE_KEY = ["faces"];
|
||||||
|
const PERSON_KEY = ["people"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query hook to fetch all faces for a specific media item.
|
||||||
|
*/
|
||||||
|
export const useListMediaFaces = (mediaId: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [FACE_KEY, "list", mediaId],
|
||||||
|
queryFn: () => listFacesForMedia(mediaId),
|
||||||
|
enabled: !!mediaId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation hook to assign a face to a person.
|
||||||
|
*/
|
||||||
|
export const useAssignFace = (faceId: string, mediaId: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: AssignFacePayload) =>
|
||||||
|
assignFaceToPerson(faceId, payload),
|
||||||
|
onSuccess: (updatedFace) => {
|
||||||
|
// Update the list of faces for this media
|
||||||
|
queryClient.setQueryData(
|
||||||
|
[FACE_KEY, "list", mediaId],
|
||||||
|
(oldData: FaceRegion[] | undefined) => {
|
||||||
|
return oldData?.map((face) =>
|
||||||
|
face.id === faceId ? updatedFace : face,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Invalidate the media list for the person
|
||||||
|
if (updatedFace.person_id) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [PERSON_KEY, "details", updatedFace.person_id, "media"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
|
useQuery, // Import useQuery
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from '@tanstack/react-query'
|
} from "@tanstack/react-query";
|
||||||
import { getMediaList, uploadMedia } from '@/services/media-service'
|
import {
|
||||||
|
deleteMedia, // Import deleteMedia
|
||||||
|
getMediaDetails, // Import getMediaDetails
|
||||||
|
getMediaList,
|
||||||
|
uploadMedia,
|
||||||
|
} from "@/services/media-service";
|
||||||
|
|
||||||
const MEDIA_LIST_KEY = ['mediaList']
|
const MEDIA_KEY = ["media"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query hook to fetch a paginated list of all media.
|
* Query hook to fetch a paginated list of all media.
|
||||||
@@ -13,33 +19,65 @@ const MEDIA_LIST_KEY = ['mediaList']
|
|||||||
*/
|
*/
|
||||||
export const useGetMediaList = () => {
|
export const useGetMediaList = () => {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: MEDIA_LIST_KEY,
|
queryKey: [MEDIA_KEY, "list"],
|
||||||
queryFn: ({ pageParam = 1 }) => getMediaList({ page: pageParam, limit: 20 }),
|
queryFn: ({ pageParam = 1 }) => getMediaList({ 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;
|
||||||
},
|
},
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query hook to fetch details for a single media item.
|
||||||
|
*/
|
||||||
|
export const useGetMediaDetails = (mediaId: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [MEDIA_KEY, "details", mediaId],
|
||||||
|
queryFn: () => getMediaDetails(mediaId),
|
||||||
|
enabled: !!mediaId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mutation hook to upload a new media file.
|
* Mutation hook to upload a new media file.
|
||||||
*/
|
*/
|
||||||
export const useUploadMedia = () => {
|
export const useUploadMedia = () => {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ file }: { file: File }) =>
|
mutationFn: ({ file }: { file: File }) =>
|
||||||
uploadMedia(file, (progress) => {
|
uploadMedia(file, (progress) => {
|
||||||
// TODO: Update upload progress state
|
// TODO: Update upload progress state
|
||||||
console.log('Upload Progress:', progress)
|
console.log("Upload Progress:", progress);
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: MEDIA_LIST_KEY })
|
// Invalidate the entire media list
|
||||||
|
queryClient.invalidateQueries({ queryKey: [MEDIA_KEY, "list"] });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error('Upload failed:', error)
|
console.error("Upload failed:", error);
|
||||||
// TODO: Add user-facing toast
|
// TODO: Add user-facing toast
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation hook to delete a media item.
|
||||||
|
*/
|
||||||
|
export const useDeleteMedia = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (mediaId: string) => deleteMedia(mediaId),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate the list to remove the deleted item
|
||||||
|
queryClient.invalidateQueries({ queryKey: [MEDIA_KEY, "list"] });
|
||||||
|
// TODO: Invalidate any open details queries for this media
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Delete media failed:", error);
|
||||||
|
// TODO: Add user-facing toast
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
140
libertas-frontend/src/features/people/use-people.ts
Normal file
140
libertas-frontend/src/features/people/use-people.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import {
|
||||||
|
useInfiniteQuery,
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
createPerson,
|
||||||
|
deletePerson,
|
||||||
|
getPerson,
|
||||||
|
listMediaForPerson,
|
||||||
|
listPeople,
|
||||||
|
mergePerson,
|
||||||
|
setPersonThumbnail,
|
||||||
|
sharePerson,
|
||||||
|
unsharePerson,
|
||||||
|
updatePerson,
|
||||||
|
clusterFaces,
|
||||||
|
type CreatePersonPayload,
|
||||||
|
type MergePersonPayload,
|
||||||
|
type SetPersonThumbnailPayload,
|
||||||
|
type SharePersonPayload,
|
||||||
|
type UnsharePersonPayload,
|
||||||
|
type UpdatePersonPayload,
|
||||||
|
} from "@/services/person-service";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
const PERSON_KEY = ["people"];
|
||||||
|
|
||||||
|
export const useListPeople = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [PERSON_KEY, "list"],
|
||||||
|
queryFn: listPeople,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetPerson = (personId: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [PERSON_KEY, "details", personId],
|
||||||
|
queryFn: () => getPerson(personId),
|
||||||
|
enabled: !!personId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useListPersonMedia = (personId: string) => {
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: [PERSON_KEY, "details", personId, "media"],
|
||||||
|
queryFn: ({ pageParam = 1 }) => listMediaForPerson({personId, page: pageParam, limit: 20} ),
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
return lastPage.has_next_page ? lastPage.page + 1 : undefined;
|
||||||
|
},
|
||||||
|
initialPageParam: 1,
|
||||||
|
enabled: !!personId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreatePerson = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: CreatePersonPayload) => createPerson(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [PERSON_KEY, "list"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdatePerson = (personId: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: UpdatePersonPayload) =>
|
||||||
|
updatePerson(personId, payload),
|
||||||
|
onSuccess: (updatedPerson) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [PERSON_KEY, "list"] });
|
||||||
|
queryClient.setQueryData(
|
||||||
|
[PERSON_KEY, "details", personId],
|
||||||
|
updatedPerson,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeletePerson = (personId: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => deletePerson(personId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [PERSON_KEY, "list"] });
|
||||||
|
queryClient.removeQueries({
|
||||||
|
queryKey: [PERSON_KEY, "details", personId],
|
||||||
|
});
|
||||||
|
navigate({ to: "/people" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSharePerson = (personId: string) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: SharePersonPayload) => sharePerson(personId, payload),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUnsharePerson = (personId: string) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: UnsharePersonPayload) =>
|
||||||
|
unsharePerson(personId, payload),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMergePerson = (targetPersonId: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: MergePersonPayload) =>
|
||||||
|
mergePerson(targetPersonId, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [PERSON_KEY] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSetPersonThumbnail = (personId: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: SetPersonThumbnailPayload) =>
|
||||||
|
setPersonThumbnail(personId, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [PERSON_KEY, "details", personId],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: [PERSON_KEY, "list"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useClusterFaces = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: clusterFaces,
|
||||||
|
});
|
||||||
|
};
|
||||||
48
libertas-frontend/src/features/tags/use-tags.ts
Normal file
48
libertas-frontend/src/features/tags/use-tags.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
addTagsToMedia,
|
||||||
|
listTagsForMedia,
|
||||||
|
removeTagFromMedia,
|
||||||
|
type AddTagsPayload,
|
||||||
|
} from "@/services/tag-service";
|
||||||
|
|
||||||
|
const TAG_KEY = ["tags"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query hook to fetch all tags for a specific media item.
|
||||||
|
*/
|
||||||
|
export const useListMediaTags = (mediaId: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [TAG_KEY, "list", mediaId],
|
||||||
|
queryFn: () => listTagsForMedia(mediaId),
|
||||||
|
enabled: !!mediaId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation hook to add tags to a media item.
|
||||||
|
*/
|
||||||
|
export const useAddMediaTags = (mediaId: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: AddTagsPayload) => addTagsToMedia(mediaId, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [TAG_KEY, "list", mediaId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation hook to remove a tag from a media item.
|
||||||
|
*/
|
||||||
|
export const useRemoveMediaTag = (mediaId: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (tagName: string) => removeTagFromMedia(mediaId, tagName),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [TAG_KEY, "list", mediaId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
17
libertas-frontend/src/features/user/use-user.ts
Normal file
17
libertas-frontend/src/features/user/use-user.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getMe } from "@/services/user-service";
|
||||||
|
|
||||||
|
const USER_KEY = ["user"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query hook to fetch the current user's details.
|
||||||
|
* @param enabled Whether the query should be enabled to run.
|
||||||
|
*/
|
||||||
|
export const useGetMe = (enabled: boolean) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [USER_KEY, "me"],
|
||||||
|
queryFn: getMe,
|
||||||
|
enabled: enabled, // Only run if enabled (e.g., if token exists)
|
||||||
|
staleTime: 1000 * 60 * 5, // Cache user data for 5 minutes
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -3,27 +3,26 @@ import { createJSONStorage, persist } from 'zustand/middleware'
|
|||||||
import type { User } from "@/domain/types"
|
import type { User } from "@/domain/types"
|
||||||
|
|
||||||
type AuthState = {
|
type AuthState = {
|
||||||
token: string | null
|
token: string | null;
|
||||||
user: User | null
|
user: User | null;
|
||||||
setToken: (token: string, user: User) => void
|
setToken: (token: string) => void;
|
||||||
clearToken: () => void
|
setUser: (user: User) => void;
|
||||||
}
|
clearAuth: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Global store for authentication state (token and user).
|
|
||||||
* Persisted to localStorage.
|
|
||||||
*/
|
|
||||||
export const useAuthStorage = create<AuthState>()(
|
export const useAuthStorage = create<AuthState>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
token: null,
|
token: null,
|
||||||
user: null,
|
user: null,
|
||||||
setToken: (token, user) => set({ token, user }),
|
setToken: (token) => set({ token }),
|
||||||
clearToken: () => set({ token: null, user: null }),
|
setUser: (user) => set({ user }),
|
||||||
|
clearAuth: () => set({ token: null, user: null }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'auth-storage',
|
name: "auth-storage",
|
||||||
storage: createJSONStorage(() => localStorage),
|
storage: createJSONStorage(() => localStorage),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
@@ -14,6 +14,7 @@ import { Route as IndexRouteImport } from './routes/index'
|
|||||||
import { Route as PeopleIndexRouteImport } from './routes/people/index'
|
import { Route as PeopleIndexRouteImport } from './routes/people/index'
|
||||||
import { Route as MediaIndexRouteImport } from './routes/media/index'
|
import { Route as MediaIndexRouteImport } from './routes/media/index'
|
||||||
import { Route as AlbumsIndexRouteImport } from './routes/albums/index'
|
import { Route as AlbumsIndexRouteImport } from './routes/albums/index'
|
||||||
|
import { Route as PeoplePersonIdRouteImport } from './routes/people/$personId'
|
||||||
import { Route as AlbumsAlbumIdRouteImport } from './routes/albums/$albumId'
|
import { Route as AlbumsAlbumIdRouteImport } from './routes/albums/$albumId'
|
||||||
|
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
@@ -41,6 +42,11 @@ const AlbumsIndexRoute = AlbumsIndexRouteImport.update({
|
|||||||
path: '/albums/',
|
path: '/albums/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const PeoplePersonIdRoute = PeoplePersonIdRouteImport.update({
|
||||||
|
id: '/people/$personId',
|
||||||
|
path: '/people/$personId',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AlbumsAlbumIdRoute = AlbumsAlbumIdRouteImport.update({
|
const AlbumsAlbumIdRoute = AlbumsAlbumIdRouteImport.update({
|
||||||
id: '/albums/$albumId',
|
id: '/albums/$albumId',
|
||||||
path: '/albums/$albumId',
|
path: '/albums/$albumId',
|
||||||
@@ -51,6 +57,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/albums/$albumId': typeof AlbumsAlbumIdRoute
|
'/albums/$albumId': typeof AlbumsAlbumIdRoute
|
||||||
|
'/people/$personId': typeof PeoplePersonIdRoute
|
||||||
'/albums': typeof AlbumsIndexRoute
|
'/albums': typeof AlbumsIndexRoute
|
||||||
'/media': typeof MediaIndexRoute
|
'/media': typeof MediaIndexRoute
|
||||||
'/people': typeof PeopleIndexRoute
|
'/people': typeof PeopleIndexRoute
|
||||||
@@ -59,6 +66,7 @@ export interface FileRoutesByTo {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/albums/$albumId': typeof AlbumsAlbumIdRoute
|
'/albums/$albumId': typeof AlbumsAlbumIdRoute
|
||||||
|
'/people/$personId': typeof PeoplePersonIdRoute
|
||||||
'/albums': typeof AlbumsIndexRoute
|
'/albums': typeof AlbumsIndexRoute
|
||||||
'/media': typeof MediaIndexRoute
|
'/media': typeof MediaIndexRoute
|
||||||
'/people': typeof PeopleIndexRoute
|
'/people': typeof PeopleIndexRoute
|
||||||
@@ -68,6 +76,7 @@ export interface FileRoutesById {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/albums/$albumId': typeof AlbumsAlbumIdRoute
|
'/albums/$albumId': typeof AlbumsAlbumIdRoute
|
||||||
|
'/people/$personId': typeof PeoplePersonIdRoute
|
||||||
'/albums/': typeof AlbumsIndexRoute
|
'/albums/': typeof AlbumsIndexRoute
|
||||||
'/media/': typeof MediaIndexRoute
|
'/media/': typeof MediaIndexRoute
|
||||||
'/people/': typeof PeopleIndexRoute
|
'/people/': typeof PeopleIndexRoute
|
||||||
@@ -78,16 +87,25 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/albums/$albumId'
|
| '/albums/$albumId'
|
||||||
|
| '/people/$personId'
|
||||||
| '/albums'
|
| '/albums'
|
||||||
| '/media'
|
| '/media'
|
||||||
| '/people'
|
| '/people'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/login' | '/albums/$albumId' | '/albums' | '/media' | '/people'
|
to:
|
||||||
|
| '/'
|
||||||
|
| '/login'
|
||||||
|
| '/albums/$albumId'
|
||||||
|
| '/people/$personId'
|
||||||
|
| '/albums'
|
||||||
|
| '/media'
|
||||||
|
| '/people'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/albums/$albumId'
|
| '/albums/$albumId'
|
||||||
|
| '/people/$personId'
|
||||||
| '/albums/'
|
| '/albums/'
|
||||||
| '/media/'
|
| '/media/'
|
||||||
| '/people/'
|
| '/people/'
|
||||||
@@ -97,6 +115,7 @@ export interface RootRouteChildren {
|
|||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
AlbumsAlbumIdRoute: typeof AlbumsAlbumIdRoute
|
AlbumsAlbumIdRoute: typeof AlbumsAlbumIdRoute
|
||||||
|
PeoplePersonIdRoute: typeof PeoplePersonIdRoute
|
||||||
AlbumsIndexRoute: typeof AlbumsIndexRoute
|
AlbumsIndexRoute: typeof AlbumsIndexRoute
|
||||||
MediaIndexRoute: typeof MediaIndexRoute
|
MediaIndexRoute: typeof MediaIndexRoute
|
||||||
PeopleIndexRoute: typeof PeopleIndexRoute
|
PeopleIndexRoute: typeof PeopleIndexRoute
|
||||||
@@ -139,6 +158,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AlbumsIndexRouteImport
|
preLoaderRoute: typeof AlbumsIndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/people/$personId': {
|
||||||
|
id: '/people/$personId'
|
||||||
|
path: '/people/$personId'
|
||||||
|
fullPath: '/people/$personId'
|
||||||
|
preLoaderRoute: typeof PeoplePersonIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/albums/$albumId': {
|
'/albums/$albumId': {
|
||||||
id: '/albums/$albumId'
|
id: '/albums/$albumId'
|
||||||
path: '/albums/$albumId'
|
path: '/albums/$albumId'
|
||||||
@@ -153,6 +179,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
AlbumsAlbumIdRoute: AlbumsAlbumIdRoute,
|
AlbumsAlbumIdRoute: AlbumsAlbumIdRoute,
|
||||||
|
PeoplePersonIdRoute: PeoplePersonIdRoute,
|
||||||
AlbumsIndexRoute: AlbumsIndexRoute,
|
AlbumsIndexRoute: AlbumsIndexRoute,
|
||||||
MediaIndexRoute: MediaIndexRoute,
|
MediaIndexRoute: MediaIndexRoute,
|
||||||
PeopleIndexRoute: PeopleIndexRoute,
|
PeopleIndexRoute: PeopleIndexRoute,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
Link,
|
|
||||||
Outlet,
|
Outlet,
|
||||||
createRootRouteWithContext,
|
createRootRouteWithContext,
|
||||||
useNavigate,
|
useNavigate,
|
||||||
|
useLocation,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
@@ -11,25 +11,31 @@ import { useAuthStorage } from "@/hooks/use-auth-storage";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Sidebar } from "@/components/layout/sidebar";
|
import { Sidebar } from "@/components/layout/sidebar";
|
||||||
import { UploadDialog } from "@/components/media/upload-dialog";
|
import { UploadDialog } from "@/components/media/upload-dialog";
|
||||||
|
import { useGetMe } from "@/features/user/use-user"; // Import the new hook
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<{
|
export const Route = createRootRouteWithContext<{
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
}>()({
|
}>()({
|
||||||
component: RootComponent,
|
component: RootComponent,
|
||||||
notFoundComponent: () => {
|
// notFoundComponent can stay as-is
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p>This is the notFoundComponent configured on root route</p>
|
|
||||||
<Link to="/">Start Over</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function RootComponent() {
|
function RootComponent() {
|
||||||
const token = useAuthStorage((s) => s.token);
|
const { token, user, setUser } = useAuthStorage();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// 1. Fetch user data if we have a token but no user object in the store
|
||||||
|
const { data: userData } = useGetMe(!!token && !user);
|
||||||
|
|
||||||
|
// 2. When user data loads, save it to the global auth store
|
||||||
|
useEffect(() => {
|
||||||
|
if (userData) {
|
||||||
|
setUser(userData);
|
||||||
|
}
|
||||||
|
}, [userData, setUser]);
|
||||||
|
|
||||||
|
// 3. Handle redirecting unauthenticated users to login
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token && location.pathname !== "/login") {
|
if (!token && location.pathname !== "/login") {
|
||||||
navigate({
|
navigate({
|
||||||
@@ -37,8 +43,9 @@ function RootComponent() {
|
|||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [token, navigate]);
|
}, [token, navigate, location.pathname]);
|
||||||
|
|
||||||
|
// 4. Render public routes (login page)
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -49,10 +56,11 @@ function RootComponent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. Render the full authenticated app layout
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-screen bg-gray-100">
|
<div className="flex h-screen bg-gray-100">
|
||||||
<Sidebar /> {/* */}
|
<Sidebar />
|
||||||
<div className="flex-1 flex flex-col h-screen">
|
<div className="flex-1 flex flex-col h-screen">
|
||||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||||
<div className="mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
|||||||
@@ -36,15 +36,14 @@ function AlbumDetailPage() {
|
|||||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
||||||
|
|
||||||
const { mutate: removeMedia, isPending: isRemoving } =
|
const { mutate: removeMedia, isPending: isRemoving } =
|
||||||
useRemoveMediaFromAlbum();
|
useRemoveMediaFromAlbum(albumId);
|
||||||
|
|
||||||
const isLoading = isLoadingAlbum || isLoadingMedia;
|
const isLoading = isLoadingAlbum || isLoadingMedia;
|
||||||
const error = albumError || mediaError;
|
const error = albumError || mediaError;
|
||||||
|
|
||||||
const handleRemoveMedia = (mediaId: string) => {
|
const handleRemoveMedia = (mediaId: string) => {
|
||||||
removeMedia({
|
removeMedia({
|
||||||
albumId,
|
media_ids: [mediaId],
|
||||||
payload: { media_ids: [mediaId] },
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
82
libertas-frontend/src/routes/people/$personId.tsx
Normal file
82
libertas-frontend/src/routes/people/$personId.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useGetPerson, useListPersonMedia } from "@/features/people/use-people";
|
||||||
|
import { AuthenticatedImage } from "@/components/media/authenticated-image";
|
||||||
|
import { MediaViewer } from "@/components/media/media-viewer";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { type Media } from "@/domain/types";
|
||||||
|
import { EditPersonDialog } from "@/components/people/edit-person-dialog";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/people/$personId")({
|
||||||
|
component: PersonDetailPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function PersonDetailPage() {
|
||||||
|
const { personId } = Route.useParams();
|
||||||
|
const { data: person, isLoading: isLoadingPerson } = useGetPerson(personId);
|
||||||
|
const {
|
||||||
|
data: mediaPages,
|
||||||
|
isLoading: isLoadingMedia,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useListPersonMedia(personId);
|
||||||
|
|
||||||
|
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
||||||
|
|
||||||
|
const allMedia = mediaPages?.pages.flatMap((page) => page.data) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h1 className="text-3xl font-bold truncate">
|
||||||
|
{person?.name ?? "Loading person..."}
|
||||||
|
</h1>
|
||||||
|
{person && <EditPersonDialog person={person} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(isLoadingPerson || isLoadingMedia) && !mediaPages && (
|
||||||
|
<p>Loading photos...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{allMedia.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
|
||||||
|
{allMedia.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="aspect-square bg-gray-200 rounded-md overflow-hidden cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
onClick={() => setSelectedMedia(m)}
|
||||||
|
>
|
||||||
|
<AuthenticatedImage
|
||||||
|
src={m.thumbnail_url ?? m.file_url}
|
||||||
|
alt={m.original_filename}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasNextPage && (
|
||||||
|
<div className="flex justify-center mt-6">
|
||||||
|
<Button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
|
||||||
|
{isFetchingNextPage ? "Loading more..." : "Load More"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoadingMedia && allMedia.length === 0 && (
|
||||||
|
<p>No photos have been tagged with this person yet.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MediaViewer
|
||||||
|
media={selectedMedia}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setSelectedMedia(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,50 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useListPeople, useClusterFaces } from "@/features/people/use-people";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { PersonCard } from "@/components/people/person-card";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
export const Route = createFileRoute("/people/")({
|
export const Route = createFileRoute("/people/")({
|
||||||
component: PeoplePage,
|
component: PeoplePage,
|
||||||
});
|
});
|
||||||
|
|
||||||
function PeoplePage() {
|
function PeoplePage() {
|
||||||
|
const { data: people, isLoading, error } = useListPeople();
|
||||||
|
const { mutate: clusterFaces, isPending: isClustering } = useClusterFaces();
|
||||||
|
|
||||||
|
const handleCluster = () => {
|
||||||
|
clusterFaces(undefined, {
|
||||||
|
onSuccess: () => {
|
||||||
|
// TODO: Add a success toast
|
||||||
|
console.log("Clustering job started!");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<h1 className="text-3xl font-bold">People</h1>
|
<div className="flex items-center justify-between">
|
||||||
<p className="mt-4">
|
<h1 className="text-3xl font-bold">People</h1>
|
||||||
This is where you'll see all the people identified in your photos.
|
<Button onClick={handleCluster} disabled={isClustering}>
|
||||||
</p>
|
{isClustering ? "Clustering..." : "Scan for New People"}
|
||||||
{/* TODO: Add 'Cluster Faces' button */}
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{isLoading && <p>Loading people...</p>}
|
||||||
|
{error && <p>Error loading people: {error.message}</p>}
|
||||||
|
|
||||||
|
{people && people.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
||||||
|
{people.map((person) => (
|
||||||
|
<PersonCard key={person.id} person={person} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{people && people.length === 0 && (
|
||||||
|
<p>No people found. Try scanning for new people to get started.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,71 @@
|
|||||||
import type { Album, Media } from "@/domain/types"
|
import type { Album, AlbumPermission, Media } from "@/domain/types";
|
||||||
import apiClient from "@/services/api-client"
|
import apiClient from "@/services/api-client";
|
||||||
|
import { processMediaUrls } from "./media-service";
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
export type CreateAlbumPayload = {
|
export type CreateAlbumPayload = {
|
||||||
name: string
|
name: string;
|
||||||
description?: string
|
description?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
export type UpdateAlbumPayload = Partial<CreateAlbumPayload> & {
|
||||||
* Fetches a list of albums.
|
is_public?: boolean;
|
||||||
* TODO: This should become paginated later.
|
|
||||||
*/
|
|
||||||
export const getAlbums = async (): Promise<Album[]> => {
|
|
||||||
const { data } = await apiClient.get('/albums')
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new album.
|
|
||||||
*/
|
|
||||||
export const createAlbum = async (
|
|
||||||
payload: CreateAlbumPayload,
|
|
||||||
): Promise<Album> => {
|
|
||||||
const { data } = await apiClient.post('/albums', payload)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches all media for a specific album.
|
|
||||||
*/
|
|
||||||
export const getAlbumMedia = async (albumId: string): Promise<Media[]> => {
|
|
||||||
const { data } = await apiClient.get(`/albums/${albumId}/media`);
|
|
||||||
|
|
||||||
|
|
||||||
const prefix = import.meta.env.VITE_PREFIX_PATH || apiClient.defaults.baseURL;
|
|
||||||
|
|
||||||
|
|
||||||
const processedMedia = data.map((media: Media) => ({
|
|
||||||
...media,
|
|
||||||
file_url: `${prefix}${media.file_url}`,
|
|
||||||
thumbnail_url: media.thumbnail_url
|
|
||||||
? `${prefix}${media.thumbnail_url}`
|
|
||||||
: null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return processedMedia;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AddMediaToAlbumPayload = {
|
export type AddMediaToAlbumPayload = {
|
||||||
media_ids: string[];
|
media_ids: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export type RemoveMediaFromAlbumPayload = {
|
||||||
* Adds a list of media IDs to a specific album.
|
media_ids: string[];
|
||||||
*/
|
};
|
||||||
|
|
||||||
|
export type ShareAlbumPayload = {
|
||||||
|
target_user_id: string;
|
||||||
|
permission: AlbumPermission;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SetAlbumThumbnailPayload = {
|
||||||
|
media_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Service Functions ---
|
||||||
|
|
||||||
|
export const getAlbums = async (): Promise<Album[]> => {
|
||||||
|
const { data } = await apiClient.get("/albums");
|
||||||
|
return data; // Album object doesn't have URLs
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAlbum = async (albumId: string): Promise<Album> => {
|
||||||
|
const { data } = await apiClient.get(`/albums/${albumId}`);
|
||||||
|
return data; // Album object doesn't have URLs
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAlbum = async (
|
||||||
|
payload: CreateAlbumPayload,
|
||||||
|
): Promise<Album> => {
|
||||||
|
const { data } = await apiClient.post("/albums", payload);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateAlbum = async (
|
||||||
|
albumId: string,
|
||||||
|
payload: UpdateAlbumPayload,
|
||||||
|
): Promise<Album> => {
|
||||||
|
const { data } = await apiClient.put(`/albums/${albumId}`, payload);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteAlbum = async (albumId: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/albums/${albumId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAlbumMedia = async (albumId: string): Promise<Media[]> => {
|
||||||
|
const { data } = await apiClient.get(`/albums/${albumId}/media`);
|
||||||
|
return data.map(processMediaUrls); // Process all media URLs
|
||||||
|
};
|
||||||
|
|
||||||
export const addMediaToAlbum = async (
|
export const addMediaToAlbum = async (
|
||||||
albumId: string,
|
albumId: string,
|
||||||
payload: AddMediaToAlbumPayload,
|
payload: AddMediaToAlbumPayload,
|
||||||
@@ -60,24 +73,23 @@ export const addMediaToAlbum = async (
|
|||||||
await apiClient.post(`/albums/${albumId}/media`, payload);
|
await apiClient.post(`/albums/${albumId}/media`, payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches a single album by its ID.
|
|
||||||
*/
|
|
||||||
export const getAlbum = async (albumId: string): Promise<Album> => {
|
|
||||||
const { data } = await apiClient.get(`/albums/${albumId}`);
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RemoveMediaFromAlbumPayload = {
|
|
||||||
media_ids: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes a list of media IDs from a specific album.
|
|
||||||
*/
|
|
||||||
export const removeMediaFromAlbum = async (
|
export const removeMediaFromAlbum = async (
|
||||||
albumId: string,
|
albumId: string,
|
||||||
payload: RemoveMediaFromAlbumPayload,
|
payload: RemoveMediaFromAlbumPayload,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await apiClient.delete(`/albums/${albumId}/media`, { data: payload });
|
await apiClient.delete(`/albums/${albumId}/media`, { data: payload });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shareAlbum = async (
|
||||||
|
albumId: string,
|
||||||
|
payload: ShareAlbumPayload,
|
||||||
|
): Promise<void> => {
|
||||||
|
await apiClient.post(`/albums/${albumId}/share`, payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setAlbumThumbnail = async (
|
||||||
|
albumId: string,
|
||||||
|
payload: SetAlbumThumbnailPayload,
|
||||||
|
): Promise<void> => {
|
||||||
|
await apiClient.put(`/albums/${albumId}/thumbnail`, payload);
|
||||||
};
|
};
|
||||||
@@ -23,7 +23,7 @@ apiClient.interceptors.response.use(
|
|||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response && error.response.status === 401) {
|
if (error.response && error.response.status === 401) {
|
||||||
useAuthStorage.getState().clearToken()
|
useAuthStorage.getState().clearAuth()
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
|
|||||||
25
libertas-frontend/src/services/auth-service.ts
Normal file
25
libertas-frontend/src/services/auth-service.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { User } from "@/domain/types";
|
||||||
|
import apiClient from "@/services/api-client";
|
||||||
|
import type {
|
||||||
|
LoginCredentials,
|
||||||
|
LoginResponse,
|
||||||
|
RegisterPayload,
|
||||||
|
} from "@/features/auth/use-auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs in a user. The backend only returns a token.
|
||||||
|
*/
|
||||||
|
export const login = async (
|
||||||
|
credentials: LoginCredentials,
|
||||||
|
): Promise<LoginResponse> => {
|
||||||
|
const { data } = await apiClient.post("/auth/login", credentials);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a new user. The backend returns the new User object (without a token).
|
||||||
|
*/
|
||||||
|
export const register = async (payload: RegisterPayload): Promise<User> => {
|
||||||
|
const { data } = await apiClient.post("/auth/register", payload);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
27
libertas-frontend/src/services/face-service.ts
Normal file
27
libertas-frontend/src/services/face-service.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { FaceRegion } from "@/domain/types";
|
||||||
|
import apiClient from "@/services/api-client";
|
||||||
|
|
||||||
|
export type AssignFacePayload = {
|
||||||
|
person_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all detected face regions for a given media item.
|
||||||
|
*/
|
||||||
|
export const listFacesForMedia = async (
|
||||||
|
mediaId: string,
|
||||||
|
): Promise<FaceRegion[]> => {
|
||||||
|
const { data } = await apiClient.get(`/media/${mediaId}/faces`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns a face region to a person.
|
||||||
|
*/
|
||||||
|
export const assignFaceToPerson = async (
|
||||||
|
faceId: string,
|
||||||
|
payload: AssignFacePayload,
|
||||||
|
): Promise<FaceRegion> => {
|
||||||
|
const { data } = await apiClient.put(`/faces/${faceId}/person`, payload);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Media, 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 = {
|
type MediaListParams = {
|
||||||
@@ -6,6 +6,16 @@ type MediaListParams = {
|
|||||||
limit: number
|
limit: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const API_PREFIX = import.meta.env.VITE_PREFIX_PATH || '';
|
||||||
|
|
||||||
|
export const processMediaUrls = (media: Media): Media => ({
|
||||||
|
...media,
|
||||||
|
file_url: `${API_PREFIX}${media.file_url}`,
|
||||||
|
thumbnail_url: media.thumbnail_url
|
||||||
|
? `${API_PREFIX}${media.thumbnail_url}`
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a paginated list of media.
|
* Fetches a paginated list of media.
|
||||||
*/
|
*/
|
||||||
@@ -13,23 +23,13 @@ export const getMediaList = async ({
|
|||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
}: MediaListParams): Promise<PaginatedResponse<Media>> => {
|
}: MediaListParams): Promise<PaginatedResponse<Media>> => {
|
||||||
const { data } = await apiClient.get('/media', {
|
const { data } = await apiClient.get("/media", {
|
||||||
params: { page, limit },
|
params: { page, limit },
|
||||||
})
|
});
|
||||||
|
|
||||||
// we need to append base url to file_url and thumbnail_url
|
data.data = data.data.map(processMediaUrls);
|
||||||
const prefix = import.meta.env.VITE_PREFIX_PATH || apiClient.defaults.baseURL;
|
return data;
|
||||||
|
};
|
||||||
data.data = data.data.map((media: Media) => ({
|
|
||||||
...media,
|
|
||||||
file_url: `${prefix}${media.file_url}`,
|
|
||||||
thumbnail_url: media.thumbnail_url
|
|
||||||
? `${prefix}${media.thumbnail_url}`
|
|
||||||
: null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploads a new media file.
|
* Uploads a new media file.
|
||||||
@@ -38,19 +38,39 @@ export const uploadMedia = async (
|
|||||||
file: File,
|
file: File,
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
): Promise<Media> => {
|
): Promise<Media> => {
|
||||||
const formData = new FormData()
|
const formData = new FormData();
|
||||||
formData.append('file', file)
|
formData.append("file", file);
|
||||||
|
|
||||||
const { data } = await apiClient.post('/media', formData, {
|
const { data } = await apiClient.post("/media", formData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
"Content-Type": "multipart/form-data",
|
||||||
},
|
},
|
||||||
onUploadProgress: (progressEvent) => {
|
onUploadProgress: (progressEvent) => {
|
||||||
const percentCompleted = Math.round(
|
const percentCompleted = Math.round(
|
||||||
(progressEvent.loaded * 100) / (progressEvent.total ?? 100),
|
(progressEvent.loaded * 100) / (progressEvent.total ?? 100),
|
||||||
)
|
);
|
||||||
onProgress(percentCompleted)
|
onProgress(percentCompleted);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
return data
|
// Process the single media object returned by the upload
|
||||||
}
|
return processMediaUrls(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the details for a single media item.
|
||||||
|
*/
|
||||||
|
export const getMediaDetails = async (
|
||||||
|
mediaId: string,
|
||||||
|
): Promise<MediaDetails> => {
|
||||||
|
const { data } = await apiClient.get(`/media/${mediaId}`);
|
||||||
|
// Process the nested media object's URLs
|
||||||
|
data.media = processMediaUrls(data.media);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a media item by its ID.
|
||||||
|
*/
|
||||||
|
export const deleteMedia = async (mediaId: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/media/${mediaId}`);
|
||||||
|
};
|
||||||
114
libertas-frontend/src/services/person-service.ts
Normal file
114
libertas-frontend/src/services/person-service.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import type {
|
||||||
|
Media,
|
||||||
|
PaginatedResponse,
|
||||||
|
Person,
|
||||||
|
PersonPermission,
|
||||||
|
} from "@/domain/types";
|
||||||
|
import apiClient from "@/services/api-client";
|
||||||
|
import { processMediaUrls } from "./media-service"; // We can import the helper
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
export type CreatePersonPayload = {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdatePersonPayload = {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SharePersonPayload = {
|
||||||
|
target_user_id: string;
|
||||||
|
permission: PersonPermission;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UnsharePersonPayload = {
|
||||||
|
target_user_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MergePersonPayload = {
|
||||||
|
source_person_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SetPersonThumbnailPayload = {
|
||||||
|
face_region_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListPeopleParams = {
|
||||||
|
personId: string,
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Service Functions ---
|
||||||
|
|
||||||
|
export const listPeople = async (): Promise<Person[]> => {
|
||||||
|
const { data } = await apiClient.get("/people");
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPerson = async (personId: string): Promise<Person> => {
|
||||||
|
const { data } = await apiClient.get(`/people/${personId}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPerson = async (
|
||||||
|
payload: CreatePersonPayload,
|
||||||
|
): Promise<Person> => {
|
||||||
|
const { data } = await apiClient.post("/people", payload);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updatePerson = async (
|
||||||
|
personId: string,
|
||||||
|
payload: UpdatePersonPayload,
|
||||||
|
): Promise<Person> => {
|
||||||
|
const { data } = await apiClient.put(`/people/${personId}`, payload);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deletePerson = async (personId: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/people/${personId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listMediaForPerson = async (
|
||||||
|
{ personId, page, limit }: ListPeopleParams
|
||||||
|
): Promise<PaginatedResponse<Media>> => {
|
||||||
|
const { data } = await apiClient.get(`/people/${personId}/media`, {
|
||||||
|
params: { page, limit },
|
||||||
|
});
|
||||||
|
data.data = data.data.map(processMediaUrls);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sharePerson = async (
|
||||||
|
personId: string,
|
||||||
|
payload: SharePersonPayload,
|
||||||
|
): Promise<void> => {
|
||||||
|
await apiClient.post(`/people/${personId}/share`, payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unsharePerson = async (
|
||||||
|
personId: string,
|
||||||
|
payload: UnsharePersonPayload,
|
||||||
|
): Promise<void> => {
|
||||||
|
await apiClient.delete(`/people/${personId}/share`, { data: payload });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mergePerson = async (
|
||||||
|
targetPersonId: string,
|
||||||
|
payload: MergePersonPayload,
|
||||||
|
): Promise<void> => {
|
||||||
|
await apiClient.post(`/people/${targetPersonId}/merge`, payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setPersonThumbnail = async (
|
||||||
|
personId: string,
|
||||||
|
payload: SetPersonThumbnailPayload,
|
||||||
|
): Promise<void> => {
|
||||||
|
await apiClient.put(`/people/${personId}/thumbnail`, payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clusterFaces = async (): Promise<void> => {
|
||||||
|
await apiClient.post("/people/cluster");
|
||||||
|
};
|
||||||
36
libertas-frontend/src/services/tag-service.ts
Normal file
36
libertas-frontend/src/services/tag-service.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Tag } from "@/domain/types";
|
||||||
|
import apiClient from "@/services/api-client";
|
||||||
|
|
||||||
|
export type AddTagsPayload = {
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all tags for a specific media item.
|
||||||
|
*/
|
||||||
|
export const listTagsForMedia = async (mediaId: string): Promise<Tag[]> => {
|
||||||
|
const { data } = await apiClient.get(`/media/${mediaId}/tags`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds one or more tags to a media item.
|
||||||
|
*/
|
||||||
|
export const addTagsToMedia = async (
|
||||||
|
mediaId: string,
|
||||||
|
payload: AddTagsPayload,
|
||||||
|
): Promise<Tag[]> => {
|
||||||
|
const { data } = await apiClient.post(`/media/${mediaId}/tags`, payload);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a single tag from a media item.
|
||||||
|
*/
|
||||||
|
export const removeTagFromMedia = async (
|
||||||
|
mediaId: string,
|
||||||
|
tagName: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
// Backend expects the tag name to be URL-encoded
|
||||||
|
await apiClient.delete(`/media/${mediaId}/tags/${encodeURIComponent(tagName)}`);
|
||||||
|
};
|
||||||
10
libertas-frontend/src/services/user-service.ts
Normal file
10
libertas-frontend/src/services/user-service.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { User } from "@/domain/types";
|
||||||
|
import apiClient from "@/services/api-client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the currently authenticated user's details.
|
||||||
|
*/
|
||||||
|
export const getMe = async (): Promise<User> => {
|
||||||
|
const { data } = await apiClient.get("/users/me");
|
||||||
|
return data;
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ use axum::{
|
|||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
routing::{get, post, put},
|
routing::{get, post, put},
|
||||||
};
|
};
|
||||||
use libertas_core::schema::{
|
use libertas_core::schema::{
|
||||||
@@ -24,7 +25,7 @@ async fn create_album(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
UserId(user_id): UserId,
|
UserId(user_id): UserId,
|
||||||
Json(payload): Json<CreateAlbumRequest>,
|
Json(payload): Json<CreateAlbumRequest>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let album_data = CreateAlbumData {
|
let album_data = CreateAlbumData {
|
||||||
owner_id: user_id,
|
owner_id: user_id,
|
||||||
name: &payload.name,
|
name: &payload.name,
|
||||||
@@ -32,9 +33,9 @@ async fn create_album(
|
|||||||
is_public: payload.is_public.unwrap_or(false),
|
is_public: payload.is_public.unwrap_or(false),
|
||||||
};
|
};
|
||||||
|
|
||||||
state.album_service.create_album(album_data).await?;
|
let album = state.album_service.create_album(album_data).await?;
|
||||||
|
let album_response = AlbumResponse::from(album);
|
||||||
Ok(StatusCode::CREATED)
|
Ok((StatusCode::CREATED, Json(album_response)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn add_media_to_album(
|
async fn add_media_to_album(
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
use axum::{Json, extract::State, http::StatusCode};
|
use axum::{Json, extract::State, http::StatusCode};
|
||||||
use libertas_core::schema::{CreateUserData, LoginUserData};
|
use libertas_core::schema::{CreateUserData, LoginUserData};
|
||||||
|
|
||||||
use crate::{error::ApiError, schema::{LoginRequest, LoginResponse, RegisterRequest, UserResponse}, state::AppState};
|
use crate::{
|
||||||
|
error::ApiError,
|
||||||
|
schema::{LoginRequest, LoginResponse, RegisterRequest, UserResponse},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -20,6 +23,8 @@ pub async fn register(
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
storage_used: user.storage_used,
|
||||||
|
storage_quota: user.storage_quota,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(response)))
|
Ok((StatusCode::CREATED, Json(response)))
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ pub async fn get_me(
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
storage_used: user.storage_used,
|
||||||
|
storage_quota: user.storage_quota,
|
||||||
};
|
};
|
||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ pub struct UserResponse {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
|
pub storage_used: i64,
|
||||||
|
pub storage_quota: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ impl AlbumServiceImpl {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl AlbumService for AlbumServiceImpl {
|
impl AlbumService for AlbumServiceImpl {
|
||||||
async fn create_album(&self, data: CreateAlbumData<'_>) -> CoreResult<()> {
|
async fn create_album(&self, data: CreateAlbumData<'_>) -> CoreResult<Album> {
|
||||||
if data.name.is_empty() {
|
if data.name.is_empty() {
|
||||||
return Err(CoreError::Validation(
|
return Err(CoreError::Validation(
|
||||||
"Album name cannot be empty".to_string(),
|
"Album name cannot be empty".to_string(),
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ pub trait UserRepository: Send + Sync {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait AlbumRepository: Send + Sync {
|
pub trait AlbumRepository: Send + Sync {
|
||||||
async fn create(&self, album: Album) -> CoreResult<()>;
|
async fn create(&self, album: Album) -> CoreResult<Album>;
|
||||||
async fn find_by_id(&self, id: Uuid) -> CoreResult<Option<Album>>;
|
async fn find_by_id(&self, id: Uuid) -> CoreResult<Option<Album>>;
|
||||||
async fn list_by_user(&self, user_id: Uuid) -> CoreResult<Vec<Album>>;
|
async fn list_by_user(&self, user_id: Uuid) -> CoreResult<Vec<Album>>;
|
||||||
async fn add_media_to_album(&self, album_id: Uuid, media_ids: &[Uuid]) -> CoreResult<()>;
|
async fn add_media_to_album(&self, album_id: Uuid, media_ids: &[Uuid]) -> CoreResult<()>;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ pub trait UserService: Send + Sync {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait AlbumService: Send + Sync {
|
pub trait AlbumService: Send + Sync {
|
||||||
async fn create_album(&self, data: CreateAlbumData<'_>) -> CoreResult<()>;
|
async fn create_album(&self, data: CreateAlbumData<'_>) -> CoreResult<Album>;
|
||||||
async fn get_album_details(&self, album_id: Uuid, user_id: Uuid) -> CoreResult<Album>;
|
async fn get_album_details(&self, album_id: Uuid, user_id: Uuid) -> CoreResult<Album>;
|
||||||
async fn add_media_to_album(&self, data: AddMediaToAlbumData, user_id: Uuid) -> CoreResult<()>;
|
async fn add_media_to_album(&self, data: AddMediaToAlbumData, user_id: Uuid) -> CoreResult<()>;
|
||||||
async fn list_user_albums(&self, user_id: Uuid) -> CoreResult<Vec<Album>>;
|
async fn list_user_albums(&self, user_id: Uuid) -> CoreResult<Vec<Album>>;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ impl PostgresAlbumRepository {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl AlbumRepository for PostgresAlbumRepository {
|
impl AlbumRepository for PostgresAlbumRepository {
|
||||||
async fn create(&self, album: Album) -> CoreResult<()> {
|
async fn create(&self, album: Album) -> CoreResult<Album> {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO albums (id, owner_id, name, description, is_public, created_at, updated_at)
|
INSERT INTO albums (id, owner_id, name, description, is_public, created_at, updated_at)
|
||||||
@@ -40,7 +40,7 @@ impl AlbumRepository for PostgresAlbumRepository {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| CoreError::Database(e.to_string()))?;
|
.map_err(|e| CoreError::Database(e.to_string()))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(album)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn find_by_id(&self, id: Uuid) -> CoreResult<Option<Album>> {
|
async fn find_by_id(&self, id: Uuid) -> CoreResult<Option<Album>> {
|
||||||
|
|||||||
Reference in New Issue
Block a user