From 94b184d3b0c8aa457cce9b06b36e399182b961da Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 16 Nov 2025 02:24:50 +0100 Subject: [PATCH] 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. --- .../albums/add-media-to-album-dialog.tsx | 5 +- .../src/components/layout/sidebar.tsx | 6 +- .../components/people/edit-person-dialog.tsx | 88 +++++++ .../src/components/people/person-card.tsx | 102 ++++++++ libertas-frontend/src/domain/types.ts | 103 +++++--- .../src/features/albums/use-albums.ts | 221 ++++++++++++------ .../src/features/auth/use-auth.ts | 69 ++++-- .../src/features/faces/use-faces.ts | 50 ++++ .../src/features/media/use-media.ts | 64 +++-- .../src/features/people/use-people.ts | 140 +++++++++++ .../src/features/tags/use-tags.ts | 48 ++++ .../src/features/user/use-user.ts | 17 ++ .../src/hooks/use-auth-storage.ts | 25 +- libertas-frontend/src/routeTree.gen.ts | 29 ++- libertas-frontend/src/routes/__root.tsx | 32 ++- .../src/routes/albums/$albumId.tsx | 5 +- .../src/routes/people/$personId.tsx | 82 +++++++ libertas-frontend/src/routes/people/index.tsx | 45 +++- .../src/services/album-service.ts | 134 ++++++----- libertas-frontend/src/services/api-client.ts | 2 +- .../src/services/auth-service.ts | 25 ++ .../src/services/face-service.ts | 27 +++ .../src/services/media-service.ts | 70 ++++-- .../src/services/person-service.ts | 114 +++++++++ libertas-frontend/src/services/tag-service.ts | 36 +++ .../src/services/user-service.ts | 10 + libertas_api/src/handlers/album_handlers.rs | 9 +- libertas_api/src/handlers/auth_handlers.rs | 9 +- libertas_api/src/handlers/user_handlers.rs | 2 + libertas_api/src/schema.rs | 2 + libertas_api/src/services/album_service.rs | 2 +- libertas_core/src/repositories.rs | 2 +- libertas_core/src/services.rs | 2 +- .../src/repositories/album_repository.rs | 4 +- 34 files changed, 1300 insertions(+), 281 deletions(-) create mode 100644 libertas-frontend/src/components/people/edit-person-dialog.tsx create mode 100644 libertas-frontend/src/components/people/person-card.tsx create mode 100644 libertas-frontend/src/features/faces/use-faces.ts create mode 100644 libertas-frontend/src/features/people/use-people.ts create mode 100644 libertas-frontend/src/features/tags/use-tags.ts create mode 100644 libertas-frontend/src/features/user/use-user.ts create mode 100644 libertas-frontend/src/routes/people/$personId.tsx create mode 100644 libertas-frontend/src/services/auth-service.ts create mode 100644 libertas-frontend/src/services/face-service.ts create mode 100644 libertas-frontend/src/services/person-service.ts create mode 100644 libertas-frontend/src/services/tag-service.ts create mode 100644 libertas-frontend/src/services/user-service.ts diff --git a/libertas-frontend/src/components/albums/add-media-to-album-dialog.tsx b/libertas-frontend/src/components/albums/add-media-to-album-dialog.tsx index f32339a..e2cf664 100644 --- a/libertas-frontend/src/components/albums/add-media-to-album-dialog.tsx +++ b/libertas-frontend/src/components/albums/add-media-to-album-dialog.tsx @@ -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: () => { diff --git a/libertas-frontend/src/components/layout/sidebar.tsx b/libertas-frontend/src/components/layout/sidebar.tsx index cdcaff6..29ff135 100644 --- a/libertas-frontend/src/components/layout/sidebar.tsx +++ b/libertas-frontend/src/components/layout/sidebar.tsx @@ -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() {
-

{user?.email}

+

{user?.email ?? "Loading..."}

+ + +
+ + Edit Person + + Change the name for this person. + + +
+
+ + +
+
+ + + + + + +
+
+ + ); +} diff --git a/libertas-frontend/src/components/people/person-card.tsx b/libertas-frontend/src/components/people/person-card.tsx new file mode 100644 index 0000000..443fcf0 --- /dev/null +++ b/libertas-frontend/src/components/people/person-card.tsx @@ -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 ( + <> + + + { + // Navigate on left click + navigate({ + to: "/people/$personId", + params: { personId: person.id }, + }); + }} + > + +
+ {/* TODO: Add person thumbnail */} + +
+
+ + + {person.name} + + +
+
+ + setShowDeleteDialog(true)} + disabled={isDeleting} + > + + Delete Person + + +
+ + + + + Are you sure? + + This action cannot be undone. This will permanently delete{" "} + {person.name} and unassign all associated faces. + + + + Cancel + + {isDeleting ? "Deleting..." : "Delete"} + + + + + + ); +} diff --git a/libertas-frontend/src/domain/types.ts b/libertas-frontend/src/domain/types.ts index c3f5896..6d95423 100644 --- a/libertas-frontend/src/domain/types.ts +++ b/libertas-frontend/src/domain/types.ts @@ -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 = { data: T[] page: number @@ -8,39 +70,14 @@ export type PaginatedResponse = { 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 -} \ No newline at end of file +export type AlbumPermission = "view" | "contribute"; + +export type PersonPermission = "view" | "can_use"; \ No newline at end of file diff --git a/libertas-frontend/src/features/albums/use-albums.ts b/libertas-frontend/src/features/albums/use-albums.ts index 983c2ef..4fb14fc 100644 --- a/libertas-frontend/src/features/albums/use-albums.ts +++ b/libertas-frontend/src/features/albums/use-albums.ts @@ -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 }, }); }; \ No newline at end of file diff --git a/libertas-frontend/src/features/auth/use-auth.ts b/libertas-frontend/src/features/auth/use-auth.ts index de70204..ff1ad41 100644 --- a/libertas-frontend/src/features/auth/use-auth.ts +++ b/libertas-frontend/src/features/auth/use-auth.ts @@ -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 => { - 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 }, - }) -} \ No newline at end of file + }); +}; + +/** + * 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 + }, + }); +}; \ No newline at end of file diff --git a/libertas-frontend/src/features/faces/use-faces.ts b/libertas-frontend/src/features/faces/use-faces.ts new file mode 100644 index 0000000..f46d36b --- /dev/null +++ b/libertas-frontend/src/features/faces/use-faces.ts @@ -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"], + }); + } + }, + }); +}; \ No newline at end of file diff --git a/libertas-frontend/src/features/media/use-media.ts b/libertas-frontend/src/features/media/use-media.ts index 40cd949..bf476d4 100644 --- a/libertas-frontend/src/features/media/use-media.ts +++ b/libertas-frontend/src/features/media/use-media.ts @@ -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 }, - }) -} \ No newline at end of file + }); +}; + +/** + * 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 + }, + }); +}; \ No newline at end of file diff --git a/libertas-frontend/src/features/people/use-people.ts b/libertas-frontend/src/features/people/use-people.ts new file mode 100644 index 0000000..594ef5d --- /dev/null +++ b/libertas-frontend/src/features/people/use-people.ts @@ -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, + }); +}; \ No newline at end of file diff --git a/libertas-frontend/src/features/tags/use-tags.ts b/libertas-frontend/src/features/tags/use-tags.ts new file mode 100644 index 0000000..5d694ab --- /dev/null +++ b/libertas-frontend/src/features/tags/use-tags.ts @@ -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] }); + }, + }); +}; \ No newline at end of file diff --git a/libertas-frontend/src/features/user/use-user.ts b/libertas-frontend/src/features/user/use-user.ts new file mode 100644 index 0000000..8008138 --- /dev/null +++ b/libertas-frontend/src/features/user/use-user.ts @@ -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 + }); +}; \ No newline at end of file diff --git a/libertas-frontend/src/hooks/use-auth-storage.ts b/libertas-frontend/src/hooks/use-auth-storage.ts index bb605e6..90be92f 100644 --- a/libertas-frontend/src/hooks/use-auth-storage.ts +++ b/libertas-frontend/src/hooks/use-auth-storage.ts @@ -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()( 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), }, ), -) \ No newline at end of file +); \ No newline at end of file diff --git a/libertas-frontend/src/routeTree.gen.ts b/libertas-frontend/src/routeTree.gen.ts index 6858034..102a5df 100644 --- a/libertas-frontend/src/routeTree.gen.ts +++ b/libertas-frontend/src/routeTree.gen.ts @@ -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, diff --git a/libertas-frontend/src/routes/__root.tsx b/libertas-frontend/src/routes/__root.tsx index e02bdcb..2afa07f 100644 --- a/libertas-frontend/src/routes/__root.tsx +++ b/libertas-frontend/src/routes/__root.tsx @@ -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 ( -
-

This is the notFoundComponent configured on root route

- Start Over -
- ); - }, + // 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 ( <>
- {/* */} +
diff --git a/libertas-frontend/src/routes/albums/$albumId.tsx b/libertas-frontend/src/routes/albums/$albumId.tsx index bcc8dfc..7cd66cf 100644 --- a/libertas-frontend/src/routes/albums/$albumId.tsx +++ b/libertas-frontend/src/routes/albums/$albumId.tsx @@ -36,15 +36,14 @@ function AlbumDetailPage() { const [selectedMedia, setSelectedMedia] = useState(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], }); }; diff --git a/libertas-frontend/src/routes/people/$personId.tsx b/libertas-frontend/src/routes/people/$personId.tsx new file mode 100644 index 0000000..b511ea1 --- /dev/null +++ b/libertas-frontend/src/routes/people/$personId.tsx @@ -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(null); + + const allMedia = mediaPages?.pages.flatMap((page) => page.data) ?? []; + + return ( +
+
+

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

+ {person && } +
+ + {(isLoadingPerson || isLoadingMedia) && !mediaPages && ( +

Loading photos...

+ )} + + {allMedia.length > 0 && ( +
+ {allMedia.map((m) => ( +
setSelectedMedia(m)} + > + +
+ ))} +
+ )} + + {hasNextPage && ( +
+ +
+ )} + + {!isLoadingMedia && allMedia.length === 0 && ( +

No photos have been tagged with this person yet.

+ )} + + { + if (!open) { + setSelectedMedia(null); + } + }} + /> +
+ ); +} diff --git a/libertas-frontend/src/routes/people/index.tsx b/libertas-frontend/src/routes/people/index.tsx index 7705413..7e456c8 100644 --- a/libertas-frontend/src/routes/people/index.tsx +++ b/libertas-frontend/src/routes/people/index.tsx @@ -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 ( -
-

People

-

- This is where you'll see all the people identified in your photos. -

- {/* TODO: Add 'Cluster Faces' button */} +
+
+

People

+ +
+ + + {isLoading &&

Loading people...

} + {error &&

Error loading people: {error.message}

} + + {people && people.length > 0 && ( +
+ {people.map((person) => ( + + ))} +
+ )} + + {people && people.length === 0 && ( +

No people found. Try scanning for new people to get started.

+ )}
); } diff --git a/libertas-frontend/src/services/album-service.ts b/libertas-frontend/src/services/album-service.ts index 5ea36d0..acf10b7 100644 --- a/libertas-frontend/src/services/album-service.ts +++ b/libertas-frontend/src/services/album-service.ts @@ -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 => { - const { data } = await apiClient.get('/albums') - return data -} - -/** - * Creates a new album. - */ -export const createAlbum = async ( - payload: CreateAlbumPayload, -): Promise => { - const { data } = await apiClient.post('/albums', payload) - return data -} - -/** - * Fetches all media for a specific album. - */ -export const getAlbumMedia = async (albumId: string): Promise => { - 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 & { + 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 => { + const { data } = await apiClient.get("/albums"); + return data; // Album object doesn't have URLs +}; + +export const getAlbum = async (albumId: string): Promise => { + const { data } = await apiClient.get(`/albums/${albumId}`); + return data; // Album object doesn't have URLs +}; + +export const createAlbum = async ( + payload: CreateAlbumPayload, +): Promise => { + const { data } = await apiClient.post("/albums", payload); + return data; +}; + +export const updateAlbum = async ( + albumId: string, + payload: UpdateAlbumPayload, +): Promise => { + const { data } = await apiClient.put(`/albums/${albumId}`, payload); + return data; +}; + +export const deleteAlbum = async (albumId: string): Promise => { + await apiClient.delete(`/albums/${albumId}`); +}; + +export const getAlbumMedia = async (albumId: string): Promise => { + 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 => { - 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 => { await apiClient.delete(`/albums/${albumId}/media`, { data: payload }); +}; + +export const shareAlbum = async ( + albumId: string, + payload: ShareAlbumPayload, +): Promise => { + await apiClient.post(`/albums/${albumId}/share`, payload); +}; + +export const setAlbumThumbnail = async ( + albumId: string, + payload: SetAlbumThumbnailPayload, +): Promise => { + await apiClient.put(`/albums/${albumId}/thumbnail`, payload); }; \ No newline at end of file diff --git a/libertas-frontend/src/services/api-client.ts b/libertas-frontend/src/services/api-client.ts index ade9cab..ee8ef85 100644 --- a/libertas-frontend/src/services/api-client.ts +++ b/libertas-frontend/src/services/api-client.ts @@ -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) diff --git a/libertas-frontend/src/services/auth-service.ts b/libertas-frontend/src/services/auth-service.ts new file mode 100644 index 0000000..3fa1914 --- /dev/null +++ b/libertas-frontend/src/services/auth-service.ts @@ -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 => { + 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 => { + const { data } = await apiClient.post("/auth/register", payload); + return data; +}; \ No newline at end of file diff --git a/libertas-frontend/src/services/face-service.ts b/libertas-frontend/src/services/face-service.ts new file mode 100644 index 0000000..a6492d7 --- /dev/null +++ b/libertas-frontend/src/services/face-service.ts @@ -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 => { + 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 => { + const { data } = await apiClient.put(`/faces/${faceId}/person`, payload); + return data; +}; \ No newline at end of file diff --git a/libertas-frontend/src/services/media-service.ts b/libertas-frontend/src/services/media-service.ts index 7ff8d8a..e5d3a6b 100644 --- a/libertas-frontend/src/services/media-service.ts +++ b/libertas-frontend/src/services/media-service.ts @@ -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> => { - 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 => { - 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 -} \ No newline at end of file + }); + // 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 => { + 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 => { + await apiClient.delete(`/media/${mediaId}`); +}; \ No newline at end of file diff --git a/libertas-frontend/src/services/person-service.ts b/libertas-frontend/src/services/person-service.ts new file mode 100644 index 0000000..f3050bb --- /dev/null +++ b/libertas-frontend/src/services/person-service.ts @@ -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 => { + const { data } = await apiClient.get("/people"); + return data; +}; + +export const getPerson = async (personId: string): Promise => { + const { data } = await apiClient.get(`/people/${personId}`); + return data; +}; + +export const createPerson = async ( + payload: CreatePersonPayload, +): Promise => { + const { data } = await apiClient.post("/people", payload); + return data; +}; + +export const updatePerson = async ( + personId: string, + payload: UpdatePersonPayload, +): Promise => { + const { data } = await apiClient.put(`/people/${personId}`, payload); + return data; +}; + +export const deletePerson = async (personId: string): Promise => { + await apiClient.delete(`/people/${personId}`); +}; + +export const listMediaForPerson = async ( + { personId, page, limit }: ListPeopleParams +): Promise> => { + 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 => { + await apiClient.post(`/people/${personId}/share`, payload); +}; + +export const unsharePerson = async ( + personId: string, + payload: UnsharePersonPayload, +): Promise => { + await apiClient.delete(`/people/${personId}/share`, { data: payload }); +}; + +export const mergePerson = async ( + targetPersonId: string, + payload: MergePersonPayload, +): Promise => { + await apiClient.post(`/people/${targetPersonId}/merge`, payload); +}; + +export const setPersonThumbnail = async ( + personId: string, + payload: SetPersonThumbnailPayload, +): Promise => { + await apiClient.put(`/people/${personId}/thumbnail`, payload); +}; + +export const clusterFaces = async (): Promise => { + await apiClient.post("/people/cluster"); +}; \ No newline at end of file diff --git a/libertas-frontend/src/services/tag-service.ts b/libertas-frontend/src/services/tag-service.ts new file mode 100644 index 0000000..5595788 --- /dev/null +++ b/libertas-frontend/src/services/tag-service.ts @@ -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 => { + 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 => { + 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 => { + // Backend expects the tag name to be URL-encoded + await apiClient.delete(`/media/${mediaId}/tags/${encodeURIComponent(tagName)}`); +}; \ No newline at end of file diff --git a/libertas-frontend/src/services/user-service.ts b/libertas-frontend/src/services/user-service.ts new file mode 100644 index 0000000..e241c24 --- /dev/null +++ b/libertas-frontend/src/services/user-service.ts @@ -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 => { + const { data } = await apiClient.get("/users/me"); + return data; +}; \ No newline at end of file diff --git a/libertas_api/src/handlers/album_handlers.rs b/libertas_api/src/handlers/album_handlers.rs index 664ecf0..c6bef9f 100644 --- a/libertas_api/src/handlers/album_handlers.rs +++ b/libertas_api/src/handlers/album_handlers.rs @@ -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, UserId(user_id): UserId, Json(payload): Json, -) -> Result { +) -> Result { 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( diff --git a/libertas_api/src/handlers/auth_handlers.rs b/libertas_api/src/handlers/auth_handlers.rs index ef27cc4..7de1d9d 100644 --- a/libertas_api/src/handlers/auth_handlers.rs +++ b/libertas_api/src/handlers/auth_handlers.rs @@ -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, @@ -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))) diff --git a/libertas_api/src/handlers/user_handlers.rs b/libertas_api/src/handlers/user_handlers.rs index e8c1fb9..6e42a9a 100644 --- a/libertas_api/src/handlers/user_handlers.rs +++ b/libertas_api/src/handlers/user_handlers.rs @@ -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)) } diff --git a/libertas_api/src/schema.rs b/libertas_api/src/schema.rs index d85e7dc..5d9c7f4 100644 --- a/libertas_api/src/schema.rs +++ b/libertas_api/src/schema.rs @@ -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)] diff --git a/libertas_api/src/services/album_service.rs b/libertas_api/src/services/album_service.rs index 745428b..e5146f5 100644 --- a/libertas_api/src/services/album_service.rs +++ b/libertas_api/src/services/album_service.rs @@ -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 { if data.name.is_empty() { return Err(CoreError::Validation( "Album name cannot be empty".to_string(), diff --git a/libertas_core/src/repositories.rs b/libertas_core/src/repositories.rs index b47777b..913c596 100644 --- a/libertas_core/src/repositories.rs +++ b/libertas_core/src/repositories.rs @@ -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; async fn find_by_id(&self, id: Uuid) -> CoreResult>; async fn list_by_user(&self, user_id: Uuid) -> CoreResult>; async fn add_media_to_album(&self, album_id: Uuid, media_ids: &[Uuid]) -> CoreResult<()>; diff --git a/libertas_core/src/services.rs b/libertas_core/src/services.rs index 3b39626..bf58376 100644 --- a/libertas_core/src/services.rs +++ b/libertas_core/src/services.rs @@ -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; async fn get_album_details(&self, album_id: Uuid, 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>; diff --git a/libertas_infra/src/repositories/album_repository.rs b/libertas_infra/src/repositories/album_repository.rs index be8f72f..5ca0bb2 100644 --- a/libertas_infra/src/repositories/album_repository.rs +++ b/libertas_infra/src/repositories/album_repository.rs @@ -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 { 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> {