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:
2025-11-16 02:24:50 +01:00
parent f41a3169e9
commit 94b184d3b0
34 changed files with 1300 additions and 281 deletions

View File

@@ -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: () => {

View File

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

View File

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

View 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>
</>
);
}

View File

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

View File

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

View File

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

View 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"],
});
}
},
});
};

View File

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

View 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,
});
};

View 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] });
},
});
};

View 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
});
};

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View File

@@ -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>
<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 */}
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">People</h1>
<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>
);
}

View File

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

View File

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

View 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;
};

View 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;
};

View File

@@ -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}`);
};

View 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");
};

View 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)}`);
};

View 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;
};