Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions backend/src/graphql/resolvers/galleryResolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
},
};
}
1 change: 1 addition & 0 deletions backend/src/graphql/resolvers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function createResolvers(db: Db) {
register: userResolvers.register,
updateProfileImage: userResolvers.updateProfileImage,
createGallery: galleryResolvers.createGallery,
addArtworkToGallery: galleryResolvers.addArtworkToGallery,
updateCoverImage: userResolvers.updateCoverImage,
},
};
Expand Down
1 change: 1 addition & 0 deletions backend/src/graphql/typeDefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!
}
Expand Down
183 changes: 183 additions & 0 deletions frontend/src/components/AddToGalleryModal.tsx
Original file line number Diff line number Diff line change
@@ -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<Gallery[]>([]);
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4">
<div
role="dialog"
aria-modal="true"
aria-labelledby="add-to-gallery-title"
className="w-full max-w-md rounded-2xl bg-white p-6 shadow-lg"
>
<div className="flex items-center justify-between">
<h2 id="add-to-gallery-title" className="font-serif text-2xl">
Add to gallery
</h2>

<button
type="button"
onClick={onClose}
aria-label="Close"
className="text-2xl text-gray-500 hover:text-black"
>
×
</button>
</div>

{loading ? (
<p className="mt-6 text-gray-500">Loading galleries...</p>
) : galleries.length === 0 ? (
<p className="mt-6 text-gray-500">
You have not created any galleries yet.
</p>
) : (
<div className="mt-6 flex flex-col gap-3">
{galleries.map((gallery) => (
<button
key={gallery.id}
type="button"
onClick={() => void handleAddToGallery(gallery.id)}
className="rounded-lg border border-gray-200 px-4 py-3 text-left transition hover:bg-gray-100"
>
{gallery.title}
</button>
))}
</div>
)}

{message && (
<p role="alert" className="mt-4 text-sm text-red-600">
{message}
</p>
)}
</div>
</div>
);
}
51 changes: 42 additions & 9 deletions frontend/src/components/GalleryCard.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,64 @@
type GalleryCardProps = {
title: string;
artworkCount: number;
imageUrl: string;
imageUrls: string[];
};

export default function GalleryCard({
title,
artworkCount,
imageUrl,
imageUrls,
}: GalleryCardProps) {
return (
<div className="w-64">
{artworkCount === 0 ? (
<div className="h-40 w-full rounded-xl bg-gray-200 flex items-center justify-center">
{imageUrls.length === 0 ? (
<div className="flex h-40 w-full items-center justify-center rounded-xl bg-gray-200">
<span className="text-gray-500">No image yet</span>
</div>
) : (
) : imageUrls.length === 1 ? (
<img
src={imageUrl}
alt={title}
src={imageUrls[0]}
alt=""
className="h-40 w-full rounded-xl object-cover"
/>
) : imageUrls.length === 2 ? (
<div className="grid h-40 grid-cols-2 overflow-hidden rounded-xl">
{imageUrls.map((imageUrl) => (
<img
key={imageUrl}
src={imageUrl}
alt=""
className="h-full w-full object-cover"
/>
))}
</div>
) : (
<div className="grid h-40 grid-cols-[1.6fr_1fr] grid-rows-2 overflow-hidden rounded-xl">
<img
src={imageUrls[0]}
alt=""
className="row-span-2 h-full w-full object-cover"
/>

<img
src={imageUrls[1]}
alt=""
className="h-full w-full object-cover"
/>

<img
src={imageUrls[2]}
alt=""
className="h-full w-full object-cover"
/>
</div>
)}

<h2 className="mt-3 text-lg font-bold">{title}</h2>

<p className="text-sm text-gray-700">{artworkCount} artworks</p>
<p className="text-sm text-gray-700">
{artworkCount} {artworkCount === 1 ? "artwork" : "artworks"}
</p>
</div>
);
}
}
Loading