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

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