diff --git a/backend/src/graphql/resolvers/galleryResolvers.ts b/backend/src/graphql/resolvers/galleryResolvers.ts index 3664b25..dfe4e16 100644 --- a/backend/src/graphql/resolvers/galleryResolvers.ts +++ b/backend/src/graphql/resolvers/galleryResolvers.ts @@ -59,5 +59,63 @@ export function createGalleryResolvers(db: Db) { artworkIds: [], }; }, + + addArtworkToGallery: async ( + _parent: unknown, + args: { galleryId: string; artworkId: number }, + context: Context, + ) => { + // Check that the user is logged in + if (!context.userId || !ObjectId.isValid(context.userId)) { + throw new GraphQLError("You must be logged in"); + } + + // Validate the gallery ID before converting it to ObjectId + if (!ObjectId.isValid(args.galleryId)) { + throw new GraphQLError("Invalid gallery"); + } + + const galleryId = new ObjectId(args.galleryId); + const userId = new ObjectId(context.userId); + + // Make sure the gallery exists and belongs to the logged-in user + const gallery = await db.collection("galleries").findOne({ + _id: galleryId, + userId, + }); + + if (!gallery) { + throw new GraphQLError("Gallery not found"); + } + + // Add the artwork ID without creating duplicates + await db.collection("galleries").updateOne( + { + _id: galleryId, + userId, + }, + { + $addToSet: { + artworkIds: args.artworkId, + }, + }, + ); + + // Fetch the updated gallery so it can be returned to the client + const updatedGallery = await db.collection("galleries").findOne({ + _id: galleryId, + }); + + if (!updatedGallery) { + throw new GraphQLError("Gallery not found"); + } + + return { + id: updatedGallery._id.toString(), + userId: updatedGallery.userId.toString(), + title: updatedGallery.title, + artworkIds: updatedGallery.artworkIds, + }; + }, }; } diff --git a/backend/src/graphql/resolvers/index.ts b/backend/src/graphql/resolvers/index.ts index 07169e8..861f644 100644 --- a/backend/src/graphql/resolvers/index.ts +++ b/backend/src/graphql/resolvers/index.ts @@ -20,6 +20,7 @@ export function createResolvers(db: Db) { register: userResolvers.register, updateProfileImage: userResolvers.updateProfileImage, createGallery: galleryResolvers.createGallery, + addArtworkToGallery: galleryResolvers.addArtworkToGallery, updateCoverImage: userResolvers.updateCoverImage, }, }; diff --git a/backend/src/graphql/typeDefs.ts b/backend/src/graphql/typeDefs.ts index d71398f..f3fa358 100644 --- a/backend/src/graphql/typeDefs.ts +++ b/backend/src/graphql/typeDefs.ts @@ -74,6 +74,7 @@ export const typeDefs = `#graphql register(username: String!, password: String!): User! login(username: String!, password: String!): AuthPayload! createGallery(title: String!): Gallery! + addArtworkToGallery(galleryId: ID!, artworkId: Int!): Gallery! updateProfileImage(image: String!): User! updateCoverImage(image: String!): User! } diff --git a/frontend/src/components/AddToGalleryModal.tsx b/frontend/src/components/AddToGalleryModal.tsx new file mode 100644 index 0000000..dde823b --- /dev/null +++ b/frontend/src/components/AddToGalleryModal.tsx @@ -0,0 +1,183 @@ +import { useEffect, useState } from "react"; +import type { Gallery } from "../types/Gallery"; + +type AddToGalleryModalProps = { + artworkId: number; + onClose: () => void; +}; + +type GalleriesResponse = { + data?: { + myGalleries: Gallery[]; + }; + errors?: { message: string }[]; +}; + +type AddArtworkResponse = { + data?: { + addArtworkToGallery: Gallery; + }; + errors?: { message: string }[]; +}; + +export default function AddToGalleryModal({ + artworkId, + onClose, +}: AddToGalleryModalProps) { + const [galleries, setGalleries] = useState([]); + const [loading, setLoading] = useState(true); + const [message, setMessage] = useState(""); + + useEffect(() => { + async function fetchGalleries() { + const query = ` + query { + myGalleries { + id + userId + title + artworkIds + } + } + `; + + try { + const token = sessionStorage.getItem("token"); + + const response = await fetch("/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token ?? ""}`, + }, + body: JSON.stringify({ query }), + }); + + if (!response.ok) { + throw new Error("Could not fetch galleries"); + } + + const result = (await response.json()) as GalleriesResponse; + + if (result.errors?.length) { + throw new Error(result.errors[0].message); + } + + setGalleries(result.data?.myGalleries ?? []); + } catch (error) { + setMessage( + error instanceof Error ? error.message : "Something went wrong", + ); + } finally { + setLoading(false); + } + } + + void fetchGalleries(); + }, []); + + async function handleAddToGallery(galleryId: string) { + const mutation = ` + mutation AddArtworkToGallery($galleryId: ID!, $artworkId: Int!) { + addArtworkToGallery( + galleryId: $galleryId + artworkId: $artworkId + ) { + id + title + artworkIds + } + } + `; + + try { + setMessage(""); + + const token = sessionStorage.getItem("token"); + + const response = await fetch("/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token ?? ""}`, + }, + body: JSON.stringify({ + query: mutation, + variables: { + galleryId, + artworkId, + }, + }), + }); + + if (!response.ok) { + throw new Error("Could not add artwork to gallery"); + } + + const result = (await response.json()) as AddArtworkResponse; + + if (result.errors?.length) { + throw new Error(result.errors[0].message); + } + + onClose(); + } catch (error) { + setMessage( + error instanceof Error ? error.message : "Something went wrong", + ); + } + } + + return ( +
+
+
+ + + +
+ + {loading ? ( +

Loading galleries...

+ ) : galleries.length === 0 ? ( +

+ You have not created any galleries yet. +

+ ) : ( +
+ {galleries.map((gallery) => ( + + ))} +
+ )} + + {message && ( +

+ {message} +

+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/GalleryCard.tsx b/frontend/src/components/GalleryCard.tsx index 4e0af72..7f8b07e 100644 --- a/frontend/src/components/GalleryCard.tsx +++ b/frontend/src/components/GalleryCard.tsx @@ -1,31 +1,64 @@ type GalleryCardProps = { title: string; artworkCount: number; - imageUrl: string; + imageUrls: string[]; }; export default function GalleryCard({ title, artworkCount, - imageUrl, + imageUrls, }: GalleryCardProps) { return (
- {artworkCount === 0 ? ( -
+ {imageUrls.length === 0 ? ( +
No image yet
- ) : ( + ) : imageUrls.length === 1 ? ( {title} + ) : imageUrls.length === 2 ? ( +
+ {imageUrls.map((imageUrl) => ( + + ))} +
+ ) : ( +
+ + + + + +
)}

{title}

-

{artworkCount} artworks

+

+ {artworkCount} {artworkCount === 1 ? "artwork" : "artworks"} +

); -} +} \ No newline at end of file diff --git a/frontend/src/hooks/useGalleryCoverImages.ts b/frontend/src/hooks/useGalleryCoverImages.ts new file mode 100644 index 0000000..8b049e5 --- /dev/null +++ b/frontend/src/hooks/useGalleryCoverImages.ts @@ -0,0 +1,89 @@ +import { useEffect, useState } from "react"; +import type { Gallery } from "../types/Gallery"; + +type ArtworkResponse = { + data?: { + artwork: { + image_url?: string | null; + } | null; + }; + errors?: { message: string }[]; +}; + +export function useGalleryCoverImages(galleries: Gallery[]) { + const [coverImages, setCoverImages] = useState>({}); + + useEffect(() => { + async function fetchCoverImages() { + const images: Record = {}; + + await Promise.all( + galleries.map(async (gallery) => { + const artworkIds = gallery.artworkIds.slice(0, 3); + + if (artworkIds.length === 0) { + return; + } + + const galleryImages = await Promise.all( + artworkIds.map(async (artworkId) => { + const query = ` + query GetArtwork($id: Int!) { + artwork(id: $id) { + image_url + } + } + `; + + try { + const response = await fetch("/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query, + variables: { id: artworkId }, + }), + }); + + if (!response.ok) { + throw new Error("Could not fetch gallery cover image"); + } + + const result = (await response.json()) as ArtworkResponse; + + if (result.errors?.length) { + throw new Error(result.errors[0].message); + } + + return result.data?.artwork?.image_url ?? null; + } catch (error) { + console.error( + `Could not fetch artwork ${artworkId}:`, + error, + ); + + return null; + } + }), + ); + + images[gallery.id] = galleryImages.filter( + (imageUrl): imageUrl is string => Boolean(imageUrl), + ); + }), + ); + + setCoverImages(images); + } + + if (galleries.length === 0) { + return; + } + + void fetchCoverImages(); + }, [galleries]); + + return coverImages; +} \ No newline at end of file diff --git a/frontend/src/pages/ArtworkDetailPage.tsx b/frontend/src/pages/ArtworkDetailPage.tsx index 517b49b..0cad80c 100644 --- a/frontend/src/pages/ArtworkDetailPage.tsx +++ b/frontend/src/pages/ArtworkDetailPage.tsx @@ -1,5 +1,7 @@ import { Link, useParams } from "react-router-dom"; import { useArtwork } from "../hooks/useArtwork"; +import { useState } from "react"; +import AddToGalleryModal from "../components/AddToGalleryModal"; // ChatGPT was used to assist with the implementation of this page. export default function ArtworkDetailPage() { @@ -7,6 +9,7 @@ export default function ArtworkDetailPage() { const artworkId = Number(id); const { artwork, loading, error } = useArtwork(artworkId); + const [showGalleryModal, setShowGalleryModal] = useState(false); if (loading) { return ( @@ -51,6 +54,7 @@ export default function ArtworkDetailPage() {
+ + {showGalleryModal && ( + setShowGalleryModal(false)} + /> + )} ); } \ No newline at end of file diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 9292abc..cb75cb2 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -4,6 +4,7 @@ import { LucidePlus } from "lucide-react"; import { useEffect, useState } from "react"; import type { Gallery } from "../types/Gallery"; import CreateGalleryModal from "../components/CreateGalleryModal"; +import { useGalleryCoverImages } from "../hooks/useGalleryCoverImages"; const coverColors = [ "#F3CA34", // Yellow @@ -27,6 +28,7 @@ export default function ProfilePage() { const storedUser = sessionStorage.getItem("user"); const [galleries, setGalleries] = useState([]); + const galleryCoverImages = useGalleryCoverImages(galleries); const [isLoading, setIsLoading] = useState(true); // Galleries const [isLoadingImages, setIsLoadingImages] = useState(true); const [refreshGalleries, setRefreshGalleries] = useState(0); @@ -335,7 +337,7 @@ export default function ProfilePage() { onCreated={() => setRefreshGalleries((prev) => prev + 1)} /> )} -
+
{isLoading ? (

Loading galleries...

) : galleries.length === 0 ? ( @@ -347,7 +349,7 @@ export default function ProfilePage() { key={gallery.id} title={gallery.title} artworkCount={gallery.artworkIds.length} - imageUrl="" + imageUrls={galleryCoverImages[gallery.id] ?? []} /> ))}