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,
|
||||
} = useGetMediaList();
|
||||
|
||||
const { mutate: addMedia, isPending: isAdding } = useAddMediaToAlbum();
|
||||
const { mutate: addMedia, isPending: isAdding } = useAddMediaToAlbum(albumId);
|
||||
|
||||
const toggleSelection = (mediaId: string) => {
|
||||
setSelectedMediaIds((prev) =>
|
||||
@@ -46,8 +46,7 @@ export function AddMediaToAlbumDialog({ albumId }: AddMediaToAlbumDialogProps) {
|
||||
const handleSubmit = () => {
|
||||
addMedia(
|
||||
{
|
||||
albumId,
|
||||
payload: { media_ids: selectedMediaIds },
|
||||
media_ids: selectedMediaIds,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -4,11 +4,11 @@ import { cn } from "@/lib/utils";
|
||||
import { useAuthStorage } from "@/hooks/use-auth-storage";
|
||||
|
||||
export function Sidebar() {
|
||||
const { user, clearToken } = useAuthStorage();
|
||||
const { user, clearAuth } = useAuthStorage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
clearToken();
|
||||
clearAuth();
|
||||
navigate({ to: "/login" });
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ export function Sidebar() {
|
||||
</SidebarLink>
|
||||
</div>
|
||||
<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
|
||||
onClick={handleLogout}
|
||||
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> = {
|
||||
data: T[]
|
||||
page: number
|
||||
@@ -8,39 +70,14 @@ export type PaginatedResponse<T> = {
|
||||
has_prev_page: boolean
|
||||
}
|
||||
|
||||
export type User = {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
storage_used: number
|
||||
storage_quota: number
|
||||
}
|
||||
|
||||
export type Media = {
|
||||
id: string
|
||||
original_filename: string
|
||||
mime_type: string
|
||||
hash: string
|
||||
file_url: string
|
||||
thumbnail_url: string | null
|
||||
}
|
||||
export type MediaDetails = {
|
||||
media: Media;
|
||||
metadata: MediaMetadata[];
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
// --- Permission Enums ---
|
||||
|
||||
export type Person = {
|
||||
id: string
|
||||
owner_id: string
|
||||
name: string
|
||||
thumbnail_media_id: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
export type AlbumPermission = "view" | "contribute";
|
||||
|
||||
export type PersonPermission = "view" | "can_use";
|
||||
@@ -1,5 +1,23 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { addMediaToAlbum, createAlbum, getAlbum, getAlbumMedia, getAlbums, removeMediaFromAlbum, type AddMediaToAlbumPayload, type RemoveMediaFromAlbumPayload } from '@/services/album-service'
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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"];
|
||||
|
||||
@@ -8,60 +26,8 @@ const ALBUMS_KEY = ["albums"];
|
||||
*/
|
||||
export const useGetAlbums = () => {
|
||||
return useQuery({
|
||||
queryKey: ['albums'],
|
||||
queryKey: [ALBUMS_KEY, "list"],
|
||||
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) => {
|
||||
return useQuery({
|
||||
queryKey: [ALBUMS_KEY, albumId],
|
||||
queryKey: [ALBUMS_KEY, "details", albumId],
|
||||
queryFn: () => getAlbum(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();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
albumId,
|
||||
payload,
|
||||
}: {
|
||||
albumId: string;
|
||||
payload: RemoveMediaFromAlbumPayload;
|
||||
}) => removeMediaFromAlbum(albumId, payload),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ALBUMS_KEY, variables.albumId, "media"],
|
||||
});
|
||||
// TODO: Add success toast
|
||||
mutationFn: createAlbum,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [ALBUMS_KEY, "list"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to remove media from album:", error);
|
||||
// TODO: Add error toast
|
||||
console.error("Failed to create album:", error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 apiClient from "@/services/api-client"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { useAuthStorage } from "@/hooks/use-auth-storage";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { login, register } from "@/services/auth-service";
|
||||
|
||||
type LoginCredentials = {
|
||||
usernameOrEmail: string
|
||||
password: string
|
||||
}
|
||||
// Types
|
||||
export type LoginCredentials = {
|
||||
usernameOrEmail: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
type LoginResponse = {
|
||||
token: string
|
||||
user: User
|
||||
}
|
||||
export type LoginResponse = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
|
||||
const { data } = await apiClient.post('/auth/login', credentials)
|
||||
return data
|
||||
}
|
||||
export type RegisterPayload = LoginCredentials & {
|
||||
email: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mutation hook for user login.
|
||||
*/
|
||||
export const useLogin = () => {
|
||||
const navigate = useNavigate()
|
||||
const { setToken } = useAuthStorage()
|
||||
const navigate = useNavigate();
|
||||
const { setToken } = useAuthStorage();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: login,
|
||||
onSuccess: (data) => {
|
||||
setToken(data.token, data.user)
|
||||
navigate({ to: '/' })
|
||||
setToken(data.token);
|
||||
navigate({ to: "/" });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Login failed:', error)
|
||||
console.error("Login failed:", error);
|
||||
// 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 {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery, // Import useQuery
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { getMediaList, uploadMedia } from '@/services/media-service'
|
||||
} from "@tanstack/react-query";
|
||||
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.
|
||||
@@ -13,33 +19,65 @@ const MEDIA_LIST_KEY = ['mediaList']
|
||||
*/
|
||||
export const useGetMediaList = () => {
|
||||
return useInfiniteQuery({
|
||||
queryKey: MEDIA_LIST_KEY,
|
||||
queryKey: [MEDIA_KEY, "list"],
|
||||
queryFn: ({ pageParam = 1 }) => getMediaList({ page: pageParam, limit: 20 }),
|
||||
getNextPageParam: (lastPage) => {
|
||||
return lastPage.has_next_page ? lastPage.page + 1 : undefined
|
||||
return lastPage.has_next_page ? lastPage.page + 1 : undefined;
|
||||
},
|
||||
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.
|
||||
*/
|
||||
export const useUploadMedia = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ file }: { file: File }) =>
|
||||
uploadMedia(file, (progress) => {
|
||||
// TODO: Update upload progress state
|
||||
console.log('Upload Progress:', progress)
|
||||
console.log("Upload Progress:", progress);
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: MEDIA_LIST_KEY })
|
||||
// Invalidate the entire media list
|
||||
queryClient.invalidateQueries({ queryKey: [MEDIA_KEY, "list"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Upload failed:', error)
|
||||
console.error("Upload failed:", error);
|
||||
// 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"
|
||||
|
||||
type AuthState = {
|
||||
token: string | null
|
||||
user: User | null
|
||||
setToken: (token: string, user: User) => void
|
||||
clearToken: () => void
|
||||
}
|
||||
token: string | null;
|
||||
user: User | null;
|
||||
setToken: (token: string) => void;
|
||||
setUser: (user: User) => void;
|
||||
clearAuth: () => void;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Global store for authentication state (token and user).
|
||||
* Persisted to localStorage.
|
||||
*/
|
||||
export const useAuthStorage = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
setToken: (token, user) => set({ token, user }),
|
||||
clearToken: () => set({ token: null, user: null }),
|
||||
setToken: (token) => set({ token }),
|
||||
setUser: (user) => set({ user }),
|
||||
clearAuth: () => set({ token: null, user: null }),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
name: "auth-storage",
|
||||
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 MediaIndexRouteImport } from './routes/media/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'
|
||||
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
@@ -41,6 +42,11 @@ const AlbumsIndexRoute = AlbumsIndexRouteImport.update({
|
||||
path: '/albums/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PeoplePersonIdRoute = PeoplePersonIdRouteImport.update({
|
||||
id: '/people/$personId',
|
||||
path: '/people/$personId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AlbumsAlbumIdRoute = AlbumsAlbumIdRouteImport.update({
|
||||
id: '/albums/$albumId',
|
||||
path: '/albums/$albumId',
|
||||
@@ -51,6 +57,7 @@ export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/albums/$albumId': typeof AlbumsAlbumIdRoute
|
||||
'/people/$personId': typeof PeoplePersonIdRoute
|
||||
'/albums': typeof AlbumsIndexRoute
|
||||
'/media': typeof MediaIndexRoute
|
||||
'/people': typeof PeopleIndexRoute
|
||||
@@ -59,6 +66,7 @@ export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/albums/$albumId': typeof AlbumsAlbumIdRoute
|
||||
'/people/$personId': typeof PeoplePersonIdRoute
|
||||
'/albums': typeof AlbumsIndexRoute
|
||||
'/media': typeof MediaIndexRoute
|
||||
'/people': typeof PeopleIndexRoute
|
||||
@@ -68,6 +76,7 @@ export interface FileRoutesById {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/albums/$albumId': typeof AlbumsAlbumIdRoute
|
||||
'/people/$personId': typeof PeoplePersonIdRoute
|
||||
'/albums/': typeof AlbumsIndexRoute
|
||||
'/media/': typeof MediaIndexRoute
|
||||
'/people/': typeof PeopleIndexRoute
|
||||
@@ -78,16 +87,25 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/albums/$albumId'
|
||||
| '/people/$personId'
|
||||
| '/albums'
|
||||
| '/media'
|
||||
| '/people'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/login' | '/albums/$albumId' | '/albums' | '/media' | '/people'
|
||||
to:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/albums/$albumId'
|
||||
| '/people/$personId'
|
||||
| '/albums'
|
||||
| '/media'
|
||||
| '/people'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/albums/$albumId'
|
||||
| '/people/$personId'
|
||||
| '/albums/'
|
||||
| '/media/'
|
||||
| '/people/'
|
||||
@@ -97,6 +115,7 @@ export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
AlbumsAlbumIdRoute: typeof AlbumsAlbumIdRoute
|
||||
PeoplePersonIdRoute: typeof PeoplePersonIdRoute
|
||||
AlbumsIndexRoute: typeof AlbumsIndexRoute
|
||||
MediaIndexRoute: typeof MediaIndexRoute
|
||||
PeopleIndexRoute: typeof PeopleIndexRoute
|
||||
@@ -139,6 +158,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AlbumsIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/people/$personId': {
|
||||
id: '/people/$personId'
|
||||
path: '/people/$personId'
|
||||
fullPath: '/people/$personId'
|
||||
preLoaderRoute: typeof PeoplePersonIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/albums/$albumId': {
|
||||
id: '/albums/$albumId'
|
||||
path: '/albums/$albumId'
|
||||
@@ -153,6 +179,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
AlbumsAlbumIdRoute: AlbumsAlbumIdRoute,
|
||||
PeoplePersonIdRoute: PeoplePersonIdRoute,
|
||||
AlbumsIndexRoute: AlbumsIndexRoute,
|
||||
MediaIndexRoute: MediaIndexRoute,
|
||||
PeopleIndexRoute: PeopleIndexRoute,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
Link,
|
||||
Outlet,
|
||||
createRootRouteWithContext,
|
||||
useNavigate,
|
||||
useLocation,
|
||||
} from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
@@ -11,25 +11,31 @@ import { useAuthStorage } from "@/hooks/use-auth-storage";
|
||||
import { useEffect } from "react";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { UploadDialog } from "@/components/media/upload-dialog";
|
||||
import { useGetMe } from "@/features/user/use-user"; // Import the new hook
|
||||
|
||||
export const Route = createRootRouteWithContext<{
|
||||
queryClient: QueryClient;
|
||||
}>()({
|
||||
component: RootComponent,
|
||||
notFoundComponent: () => {
|
||||
return (
|
||||
<div>
|
||||
<p>This is the notFoundComponent configured on root route</p>
|
||||
<Link to="/">Start Over</Link>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
// notFoundComponent can stay as-is
|
||||
});
|
||||
|
||||
function RootComponent() {
|
||||
const token = useAuthStorage((s) => s.token);
|
||||
const { token, user, setUser } = useAuthStorage();
|
||||
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(() => {
|
||||
if (!token && location.pathname !== "/login") {
|
||||
navigate({
|
||||
@@ -37,8 +43,9 @@ function RootComponent() {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}, [token, navigate]);
|
||||
}, [token, navigate, location.pathname]);
|
||||
|
||||
// 4. Render public routes (login page)
|
||||
if (!token) {
|
||||
return (
|
||||
<>
|
||||
@@ -49,10 +56,11 @@ function RootComponent() {
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Render the full authenticated app layout
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
<Sidebar /> {/* */}
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col h-screen">
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<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 { mutate: removeMedia, isPending: isRemoving } =
|
||||
useRemoveMediaFromAlbum();
|
||||
useRemoveMediaFromAlbum(albumId);
|
||||
|
||||
const isLoading = isLoadingAlbum || isLoadingMedia;
|
||||
const error = albumError || mediaError;
|
||||
|
||||
const handleRemoveMedia = (mediaId: string) => {
|
||||
removeMedia({
|
||||
albumId,
|
||||
payload: { media_ids: [mediaId] },
|
||||
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 { 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/")({
|
||||
component: 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 (
|
||||
<div>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">People</h1>
|
||||
<p className="mt-4">
|
||||
This is where you'll see all the people identified in your photos.
|
||||
</p>
|
||||
{/* TODO: Add 'Cluster Faces' button */}
|
||||
<Button onClick={handleCluster} disabled={isClustering}>
|
||||
{isClustering ? "Clustering..." : "Scan for New People"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,58 +1,71 @@
|
||||
import type { Album, Media } from "@/domain/types"
|
||||
import apiClient from "@/services/api-client"
|
||||
import type { Album, AlbumPermission, Media } from "@/domain/types";
|
||||
import apiClient from "@/services/api-client";
|
||||
import { processMediaUrls } from "./media-service";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type CreateAlbumPayload = {
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches a list of albums.
|
||||
* 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 UpdateAlbumPayload = Partial<CreateAlbumPayload> & {
|
||||
is_public?: boolean;
|
||||
};
|
||||
|
||||
export type AddMediaToAlbumPayload = {
|
||||
media_ids: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a list of media IDs to a specific album.
|
||||
*/
|
||||
export type RemoveMediaFromAlbumPayload = {
|
||||
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 (
|
||||
albumId: string,
|
||||
payload: AddMediaToAlbumPayload,
|
||||
@@ -60,24 +73,23 @@ export const addMediaToAlbum = async (
|
||||
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 (
|
||||
albumId: string,
|
||||
payload: RemoveMediaFromAlbumPayload,
|
||||
): Promise<void> => {
|
||||
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,
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
useAuthStorage.getState().clearToken()
|
||||
useAuthStorage.getState().clearAuth()
|
||||
window.location.reload()
|
||||
}
|
||||
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"
|
||||
|
||||
type MediaListParams = {
|
||||
@@ -6,6 +6,16 @@ type MediaListParams = {
|
||||
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.
|
||||
*/
|
||||
@@ -13,23 +23,13 @@ export const getMediaList = async ({
|
||||
page,
|
||||
limit,
|
||||
}: MediaListParams): Promise<PaginatedResponse<Media>> => {
|
||||
const { data } = await apiClient.get('/media', {
|
||||
const { data } = await apiClient.get("/media", {
|
||||
params: { page, limit },
|
||||
})
|
||||
});
|
||||
|
||||
// we need to append base url to file_url and thumbnail_url
|
||||
const prefix = import.meta.env.VITE_PREFIX_PATH || apiClient.defaults.baseURL;
|
||||
|
||||
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
|
||||
}
|
||||
data.data = data.data.map(processMediaUrls);
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Uploads a new media file.
|
||||
@@ -38,19 +38,39 @@ export const uploadMedia = async (
|
||||
file: File,
|
||||
onProgress: (progress: number) => void,
|
||||
): Promise<Media> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const { data } = await apiClient.post('/media', formData, {
|
||||
const { data } = await apiClient.post("/media", formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const percentCompleted = Math.round(
|
||||
(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,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post, put},
|
||||
};
|
||||
use libertas_core::schema::{
|
||||
@@ -24,7 +25,7 @@ async fn create_album(
|
||||
State(state): State<AppState>,
|
||||
UserId(user_id): UserId,
|
||||
Json(payload): Json<CreateAlbumRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let album_data = CreateAlbumData {
|
||||
owner_id: user_id,
|
||||
name: &payload.name,
|
||||
@@ -32,9 +33,9 @@ async fn create_album(
|
||||
is_public: payload.is_public.unwrap_or(false),
|
||||
};
|
||||
|
||||
state.album_service.create_album(album_data).await?;
|
||||
|
||||
Ok(StatusCode::CREATED)
|
||||
let album = state.album_service.create_album(album_data).await?;
|
||||
let album_response = AlbumResponse::from(album);
|
||||
Ok((StatusCode::CREATED, Json(album_response)))
|
||||
}
|
||||
|
||||
async fn add_media_to_album(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
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(
|
||||
State(state): State<AppState>,
|
||||
@@ -20,6 +23,8 @@ pub async fn register(
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
storage_used: user.storage_used,
|
||||
storage_quota: user.storage_quota,
|
||||
};
|
||||
|
||||
Ok((StatusCode::CREATED, Json(response)))
|
||||
|
||||
@@ -12,6 +12,8 @@ pub async fn get_me(
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
storage_used: user.storage_used,
|
||||
storage_quota: user.storage_quota,
|
||||
};
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ pub struct UserResponse {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub storage_used: i64,
|
||||
pub storage_quota: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
||||
@@ -34,7 +34,7 @@ impl AlbumServiceImpl {
|
||||
|
||||
#[async_trait]
|
||||
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() {
|
||||
return Err(CoreError::Validation(
|
||||
"Album name cannot be empty".to_string(),
|
||||
|
||||
@@ -40,7 +40,7 @@ pub trait UserRepository: Send + Sync {
|
||||
|
||||
#[async_trait]
|
||||
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 list_by_user(&self, user_id: Uuid) -> CoreResult<Vec<Album>>;
|
||||
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]
|
||||
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 add_media_to_album(&self, data: AddMediaToAlbumData, user_id: Uuid) -> CoreResult<()>;
|
||||
async fn list_user_albums(&self, user_id: Uuid) -> CoreResult<Vec<Album>>;
|
||||
|
||||
@@ -22,7 +22,7 @@ impl PostgresAlbumRepository {
|
||||
|
||||
#[async_trait]
|
||||
impl AlbumRepository for PostgresAlbumRepository {
|
||||
async fn create(&self, album: Album) -> CoreResult<()> {
|
||||
async fn create(&self, album: Album) -> CoreResult<Album> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO albums (id, owner_id, name, description, is_public, created_at, updated_at)
|
||||
@@ -40,7 +40,7 @@ impl AlbumRepository for PostgresAlbumRepository {
|
||||
.await
|
||||
.map_err(|e| CoreError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
Ok(album)
|
||||
}
|
||||
|
||||
async fn find_by_id(&self, id: Uuid) -> CoreResult<Option<Album>> {
|
||||
|
||||
Reference in New Issue
Block a user