feat: Implement album and person sharing with user search and a dedicated share dialog.

This commit is contained in:
2025-12-04 00:58:10 +01:00
parent 74d74a128b
commit 7f07169064
23 changed files with 816 additions and 66 deletions

View File

@@ -0,0 +1,188 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { searchUsers } from "@/services/user-service";
import type { User } from "@/domain/types";
import { useQuery } from "@tanstack/react-query";
import { useDebounce } from "@/hooks/use-debounce";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Loader2, X } from "lucide-react";
export type SharePermission = "view" | "contribute" | "can_use";
interface ShareDialogProps {
title: string;
trigger: React.ReactNode;
currentShares: { user: User; permission: SharePermission }[];
onAddShare: (userId: string, permission: SharePermission) => Promise<void>;
onRemoveShare: (userId: string) => Promise<void>;
permissionOptions: { value: SharePermission; label: string }[];
}
export function ShareDialog({
title,
trigger,
currentShares,
onAddShare,
onRemoveShare,
permissionOptions,
}: ShareDialogProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [selectedPermission, setSelectedPermission] = useState<SharePermission>(
permissionOptions[0].value
);
const debouncedQuery = useDebounce(searchQuery, 300);
const { data: searchResults, isLoading } = useQuery({
queryKey: ["users", "search", debouncedQuery],
queryFn: () => searchUsers(debouncedQuery),
enabled: debouncedQuery.length > 1,
});
const handleAdd = async (user: User) => {
await onAddShare(user.id, selectedPermission);
setSearchQuery(""); // Clear search after adding
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="flex gap-2">
<Input
placeholder="Search users by name or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1"
/>
<Select
value={selectedPermission}
onValueChange={(val) => setSelectedPermission(val as SharePermission)}
>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{permissionOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Search Results */}
{searchQuery.length > 1 && (
<div className="border rounded-md p-2 max-h-[150px] overflow-y-auto space-y-2">
{isLoading ? (
<div className="flex justify-center p-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : searchResults && searchResults.length > 0 ? (
searchResults.map((user) => {
const isAlreadyShared = currentShares.some(
(s) => s.user.id === user.id
);
return (
<div
key={user.id}
className="flex items-center justify-between p-2 hover:bg-gray-100 rounded-md"
>
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarFallback>
{user.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium">
{user.username}
</span>
<span className="text-xs text-gray-500">
{user.email}
</span>
</div>
</div>
<Button
size="sm"
variant="ghost"
disabled={isAlreadyShared}
onClick={() => handleAdd(user)}
>
{isAlreadyShared ? "Added" : "Add"}
</Button>
</div>
);
})
) : (
<p className="text-sm text-gray-500 text-center p-2">
No users found.
</p>
)}
</div>
)}
<div className="space-y-2">
<h4 className="text-sm font-medium">Shared with</h4>
{currentShares.length === 0 ? (
<p className="text-sm text-gray-500">Not shared with anyone yet.</p>
) : (
<div className="space-y-2">
{currentShares.map((share) => (
<div
key={share.user.id}
className="flex items-center justify-between border p-2 rounded-md"
>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarFallback>
{share.user.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">
{share.user.username}
</p>
<p className="text-xs text-gray-500">
{share.permission}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => onRemoveShare(share.user.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -9,7 +9,9 @@ import {
removeMediaFromAlbum,
setAlbumThumbnail,
shareAlbum,
unshareAlbum,
updateAlbum,
getAlbumShares,
type AddMediaToAlbumPayload,
type RemoveMediaFromAlbumPayload,
type SetAlbumThumbnailPayload,
@@ -149,17 +151,36 @@ export const useRemoveMediaFromAlbum = (albumId: string) => {
* Mutation hook to share an album with another user.
*/
export const useShareAlbum = (albumId: string) => {
// const queryClient = useQueryClient();
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
queryClient.invalidateQueries({ queryKey: [ALBUMS_KEY, "details", albumId, "shares"] });
},
});
};
export const useUnshareAlbum = (albumId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (targetUserId: string) => unshareAlbum(albumId, targetUserId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [ALBUMS_KEY, "details", albumId, "shares"] });
},
});
};
/**
* Query hook to fetch shares for an album.
*/
export const useGetAlbumShares = (albumId: string) => {
return useQuery({
queryKey: [ALBUMS_KEY, "details", albumId, "shares"],
queryFn: () => getAlbumShares(albumId),
enabled: !!albumId,
});
};
/**
* Mutation hook to set an album's thumbnail.
*/

View File

@@ -16,6 +16,7 @@ import {
unsharePerson,
updatePerson,
clusterFaces,
getPersonShares,
type CreatePersonPayload,
type MergePersonPayload,
type SetPersonThumbnailPayload,
@@ -45,7 +46,7 @@ export const useGetPerson = (personId: string) => {
export const useListPersonMedia = (personId: string) => {
return useInfiniteQuery({
queryKey: [PERSON_KEY, "details", personId, "media"],
queryFn: ({ pageParam = 1 }) => listMediaForPerson({personId, page: pageParam, limit: 20} ),
queryFn: ({ pageParam = 1 }) => listMediaForPerson({ personId, page: pageParam, limit: 20 }),
getNextPageParam: (lastPage) => {
return lastPage.has_next_page ? lastPage.page + 1 : undefined;
},
@@ -64,6 +65,34 @@ export const useCreatePerson = () => {
});
};
export const useClusterFaces = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: clusterFaces,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [PERSON_KEY] });
},
});
};
export const useSharePerson = (personId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: SharePersonPayload) => sharePerson(personId, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [PERSON_KEY, "details", personId, "shares"] });
},
});
};
export const useGetPersonShares = (personId: string) => {
return useQuery({
queryKey: [PERSON_KEY, "details", personId, "shares"],
queryFn: () => getPersonShares(personId),
enabled: !!personId,
});
};
export const useUpdatePerson = (personId: string) => {
const queryClient = useQueryClient();
return useMutation({
@@ -95,16 +124,14 @@ export const useDeletePerson = (personId: string) => {
});
};
export const useSharePerson = (personId: string) => {
return useMutation({
mutationFn: (payload: SharePersonPayload) => sharePerson(personId, payload),
});
};
export const useUnsharePerson = (personId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: UnsharePersonPayload) =>
unsharePerson(personId, payload),
unsharePerson(personId, payload.target_user_id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [PERSON_KEY, "details", personId, "shares"] });
},
});
};
@@ -131,10 +158,4 @@ export const useSetPersonThumbnail = (personId: string) => {
queryClient.invalidateQueries({ queryKey: [PERSON_KEY, "list"] });
},
});
};
export const useClusterFaces = () => {
return useMutation({
mutationFn: clusterFaces,
});
};

View File

@@ -0,0 +1,15 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -1,6 +1,8 @@
import { AddMediaToAlbumDialog } from "@/components/albums/add-media-to-album-dialog";
import { AuthenticatedImage } from "@/components/media/authenticated-image";
import { MediaViewer } from "@/components/media/media-viewer";
import { ShareDialog, type SharePermission } from "@/components/sharing/share-dialog";
import { Button } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
@@ -11,10 +13,13 @@ import type { Media } from "@/domain/types";
import {
useGetAlbum,
useGetAlbumMedia,
useGetAlbumShares,
useRemoveMediaFromAlbum,
useShareAlbum,
useUnshareAlbum,
} from "@/features/albums/use-albums";
import { createFileRoute } from "@tanstack/react-router";
import { Eye, Trash2 } from "lucide-react";
import { Eye, Share2, Trash2 } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/albums/$albumId")({
@@ -33,6 +38,10 @@ function AlbumDetailPage() {
isLoading: isLoadingMedia,
error: mediaError,
} = useGetAlbumMedia(albumId);
const { data: shares } = useGetAlbumShares(albumId);
const { mutateAsync: shareAlbum } = useShareAlbum(albumId);
const { mutateAsync: unshareAlbum } = useUnshareAlbum(albumId);
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
const { mutate: removeMedia, isPending: isRemoving } =
@@ -42,18 +51,57 @@ function AlbumDetailPage() {
const error = albumError || mediaError;
const handleRemoveMedia = (mediaId: string) => {
removeMedia({
media_ids: [mediaId],
if (confirm("Are you sure you want to remove this photo from the album?")) {
removeMedia({
media_ids: [mediaId],
});
}
};
const handleAddShare = async (userId: string, permission: SharePermission) => {
await shareAlbum({
target_user_id: userId,
permission: permission === "can_use" ? "contribute" : "view",
});
};
const handleRemoveShare = async (userId: string) => {
if (confirm("Are you sure you want to remove this share?")) {
await unshareAlbum(userId);
}
};
const currentShares =
shares?.map((s) => ({
user: s.user,
permission: s.permission === "contribute" ? "can_use" : ("view" as SharePermission),
})) ?? [];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold truncate">
{album?.name ?? "Loading album..."}
</h1>
<AddMediaToAlbumDialog albumId={albumId} />
<div className="flex gap-2">
<ShareDialog
title={`Share Album: ${album?.name}`}
trigger={
<Button variant="outline">
<Share2 className="mr-2 h-4 w-4" />
Share
</Button>
}
currentShares={currentShares}
onAddShare={handleAddShare}
onRemoveShare={handleRemoveShare}
permissionOptions={[
{ value: "view", label: "Can View" },
{ value: "can_use", label: "Can Contribute" },
]}
/>
<AddMediaToAlbumDialog albumId={albumId} />
</div>
</div>
{isLoading && <p>Loading photos...</p>}

View File

@@ -1,11 +1,19 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { useGetPerson, useListPersonMedia } from "@/features/people/use-people";
import { ShareDialog, type SharePermission } from "@/components/sharing/share-dialog";
import {
useGetPerson,
useGetPersonShares,
useListPersonMedia,
useSharePerson,
useUnsharePerson,
useUpdatePerson,
} 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";
import { Share2 } from "lucide-react";
export const Route = createFileRoute("/people/$personId")({
component: PersonDetailPage,
@@ -15,27 +23,100 @@ function PersonDetailPage() {
const { personId } = Route.useParams();
const { data: person, isLoading: isLoadingPerson } = useGetPerson(personId);
const {
data: mediaPages,
isLoading: isLoadingMedia,
data: mediaPage,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading: isLoadingMedia,
} = useListPersonMedia(personId);
const { data: shares } = useGetPersonShares(personId);
const { mutateAsync: sharePerson } = useSharePerson(personId);
const { mutateAsync: unsharePerson } = useUnsharePerson(personId);
const { mutate: updatePerson } = useUpdatePerson(personId);
const [isEditingName, setIsEditingName] = useState(false);
const [newName, setNewName] = useState("");
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
const allMedia = mediaPages?.pages.flatMap((page) => page.data) ?? [];
const handleNameSave = () => {
if (newName && newName !== person?.name) {
updatePerson({ name: newName });
}
setIsEditingName(false);
};
const handleAddShare = async (userId: string, permission: SharePermission) => {
await sharePerson({
target_user_id: userId,
permission: permission === "can_use" ? "can_use" : "view",
});
};
const handleRemoveShare = async (userId: string) => {
if (confirm("Are you sure you want to remove this share?")) {
await unsharePerson({ target_user_id: userId });
}
};
const currentShares =
shares?.map((s) => ({
user: s.user,
permission: s.permission === "can_use" ? "can_use" : ("view" as SharePermission),
})) ?? [];
if (isLoadingPerson) return <div>Loading person...</div>;
if (!person) return <div>Person not found</div>;
const allMedia = mediaPage?.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 className="flex items-center justify-between">
{isEditingName ? (
<div className="flex items-center gap-2">
<input
className="text-3xl font-bold border rounded px-2 py-1"
value={newName}
onChange={(e) => setNewName(e.target.value)}
autoFocus
/>
<Button onClick={handleNameSave}>Save</Button>
<Button variant="ghost" onClick={() => setIsEditingName(false)}>
Cancel
</Button>
</div>
) : (
<h1
className="text-3xl font-bold cursor-pointer hover:underline decoration-dashed"
onClick={() => {
setNewName(person.name);
setIsEditingName(true);
}}
title="Click to edit name"
>
{person.name}
</h1>
)}
<ShareDialog
title={`Share Person: ${person.name}`}
trigger={
<Button variant="outline">
<Share2 className="mr-2 h-4 w-4" />
Share
</Button>
}
currentShares={currentShares}
onAddShare={handleAddShare}
onRemoveShare={handleRemoveShare}
permissionOptions={[
{ value: "view", label: "Can View" },
{ value: "can_use", label: "Can Use (Assign Faces)" },
]}
/>
</div>
{(isLoadingPerson || isLoadingMedia) && !mediaPages && (
{(isLoadingPerson || isLoadingMedia) && !mediaPage && (
<p>Loading photos...</p>
)}

View File

@@ -1,4 +1,4 @@
import type { Album, AlbumPermission, Media } from "@/domain/types";
import type { Album, AlbumPermission, Media, User } from "@/domain/types";
import apiClient from "@/services/api-client";
import { processMediaUrls } from "./media-service";
@@ -87,9 +87,28 @@ export const shareAlbum = async (
await apiClient.post(`/albums/${albumId}/share`, payload);
};
export const unshareAlbum = async (
albumId: string,
targetUserId: string,
): Promise<void> => {
await apiClient.delete(`/albums/${albumId}/share`, {
data: { target_user_id: targetUserId },
});
};
export const setAlbumThumbnail = async (
albumId: string,
payload: SetAlbumThumbnailPayload,
): Promise<void> => {
await apiClient.put(`/albums/${albumId}/thumbnail`, payload);
};
export type AlbumShare = {
user: User;
permission: AlbumPermission;
};
export const getAlbumShares = async (albumId: string): Promise<AlbumShare[]> => {
const { data } = await apiClient.get(`/albums/${albumId}/share`);
return data;
};

View File

@@ -3,6 +3,7 @@ import type {
PaginatedResponse,
Person,
PersonPermission,
User,
} from "@/domain/types";
import apiClient from "@/services/api-client";
import { processMediaUrls } from "./media-service"; // We can import the helper
@@ -35,7 +36,7 @@ export type SetPersonThumbnailPayload = {
};
export type ListPeopleParams = {
personId: string,
personId: string,
page: number;
limit: number;
};
@@ -88,11 +89,15 @@ export const sharePerson = async (
await apiClient.post(`/people/${personId}/share`, payload);
};
export const unsharePerson = async (
personId: string,
payload: UnsharePersonPayload,
targetUserId: string,
): Promise<void> => {
await apiClient.delete(`/people/${personId}/share`, { data: payload });
await apiClient.delete(`/people/${personId}/share`, {
data: { target_user_id: targetUserId },
});
};
export const mergePerson = async (
@@ -109,6 +114,16 @@ export const setPersonThumbnail = async (
await apiClient.put(`/people/${personId}/thumbnail`, payload);
};
export type PersonShare = {
user: User;
permission: PersonPermission;
};
export const getPersonShares = async (personId: string): Promise<PersonShare[]> => {
const { data } = await apiClient.get(`/people/${personId}/share`);
return data;
};
export const clusterFaces = async (): Promise<void> => {
await apiClient.post("/people/cluster");
};

View File

@@ -7,4 +7,14 @@ import apiClient from "@/services/api-client";
export const getMe = async (): Promise<User> => {
const { data } = await apiClient.get("/users/me");
return data;
};
/**
* Searches for users by username or email.
*/
export const searchUsers = async (query: string): Promise<User[]> => {
const { data } = await apiClient.get("/users/search", {
params: { query },
});
return data;
};