diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 02a900a..e7be7c5 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -5119,23 +5119,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/yaml": {
- "version": "2.9.1",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
- "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
- "license": "ISC",
- "optional": true,
- "peer": true,
- "bin": {
- "yaml": "bin.mjs"
- },
- "engines": {
- "node": ">= 14.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/eemeli"
- }
- },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 3b608eb..8886843 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,6 +1,6 @@
import { Routes, Route, useLocation } from "react-router-dom";
import "./index.css";
-import HomePage from "./pages/HomePage";
+import ArtCataloguePage from "./pages/ArtCataloguePage";
import Login from "./pages/LoginPage";
import CreateAccount from "./pages/CreateAccountPage";
import ProfilePage from "./pages/ProfilePage";
@@ -17,10 +17,12 @@ function App() {
const showFooter = pathname !== "/login" && pathname !== "/create-account";
return (
-
+
- } />
+ } />
} />
} />
Image unavailable
;
+ return (
+
+ Image unavailable
+
+ );
}
return (
@@ -31,21 +35,28 @@ function ArtworkImage({ src, alt }: { src: string; alt: string }) {
);
}
-export default function ArtworkCard({ artwork, showGalleryAction = true }: ArtworkCardProps) {
+export default function ArtworkCard({
+ artwork,
+ showGalleryAction = true,
+}: ArtworkCardProps) {
const artist = artwork.artist_display?.trim() || "Unknown artist";
const location = useLocation();
const navigate = useNavigate();
const isLoggedIn = useAuthStore((state) => Boolean(state.token));
const [showGalleryModal, setShowGalleryModal] = useState(false);
-// Made with AI help.
+ // Made with AI help.
function rememberCataloguePosition() {
try {
- sessionStorage.setItem("catalogue-return", JSON.stringify({
- url: `${location.pathname}${location.search}`,
- scrollY: window.scrollY,
- }));
+ sessionStorage.setItem(
+ "catalogue-return",
+ JSON.stringify({
+ url: `${location.pathname}${location.search}`,
+ scrollY: window.scrollY,
+ }),
+ );
} catch {
+ // Catch
}
}
@@ -64,7 +75,9 @@ export default function ArtworkCard({ artwork, showGalleryAction = true }: Artwo
alt={artwork.image_url || `${artwork.title} by ${artist}`}
/>
) : (
-
Image unavailable
+
+ Image unavailable
+
)}
{artwork.title}
@@ -74,7 +87,11 @@ export default function ArtworkCard({ artwork, showGalleryAction = true }: Artwo
{showGalleryAction && (
{
if (isLoggedIn) setShowGalleryModal(true);
diff --git a/frontend/src/components/artwork/ArtworkDetails.tsx b/frontend/src/components/artwork/ArtworkDetails.tsx
new file mode 100644
index 0000000..3d61838
--- /dev/null
+++ b/frontend/src/components/artwork/ArtworkDetails.tsx
@@ -0,0 +1,41 @@
+import type { Artwork } from "../../types/Artwork";
+
+type ArtworkDetailsProps = {
+ artwork: Artwork;
+ className?: string;
+};
+
+// Displays artwork details on both mobile and desktop
+export default function ArtworkDetails({
+ artwork,
+ className = "",
+}: ArtworkDetailsProps) {
+ const place = [artwork.city, artwork.country].filter(Boolean).join(", ");
+
+ const details = [
+ { label: "Medium", value: artwork.medium },
+ { label: "Place", value: place },
+ { label: "Culture", value: artwork.culture },
+ { label: "Period", value: artwork.period },
+ { label: "Dimensions", value: artwork.dimensions },
+ ].filter((detail) => detail.value);
+
+ return (
+
+ Details
+
+
+ {details.map((detail) => (
+
+
{detail.label}
+
+
{detail.value}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/ArtworkGrid.css b/frontend/src/components/artwork/ArtworkGrid.css
similarity index 100%
rename from frontend/src/components/ArtworkGrid.css
rename to frontend/src/components/artwork/ArtworkGrid.css
diff --git a/frontend/src/components/ArtworkGrid.tsx b/frontend/src/components/artwork/ArtworkGrid.tsx
similarity index 80%
rename from frontend/src/components/ArtworkGrid.tsx
rename to frontend/src/components/artwork/ArtworkGrid.tsx
index fd52685..76d937e 100644
--- a/frontend/src/components/ArtworkGrid.tsx
+++ b/frontend/src/components/artwork/ArtworkGrid.tsx
@@ -1,4 +1,4 @@
-import type { Artwork } from "../types/Artwork";
+import type { Artwork } from "../../types/Artwork";
import type { ReactNode } from "react";
import ArtworkCard from "./ArtworkCard";
import "./ArtworkGrid.css";
@@ -22,7 +22,10 @@ export default function ArtworkGrid({
{artworks.map((artwork) => (
-
+
{renderAction?.(artwork)}
))}
diff --git a/frontend/src/components/AddToGalleryModal.tsx b/frontend/src/components/gallery/AddToGalleryModal.tsx
similarity index 95%
rename from frontend/src/components/AddToGalleryModal.tsx
rename to frontend/src/components/gallery/AddToGalleryModal.tsx
index fbf1a14..c7dd44c 100644
--- a/frontend/src/components/AddToGalleryModal.tsx
+++ b/frontend/src/components/gallery/AddToGalleryModal.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
-import type { Gallery } from "../types/Gallery";
-import { useAuthStore } from "../store/authStore";
+import type { Gallery } from "../../types/Gallery";
+import { useAuthStore } from "../../store/authStore";
type AddToGalleryModalProps = {
artworkId: number;
@@ -151,7 +151,9 @@ export default function AddToGalleryModal({
{loading ? (
- Loading galleries...
+
+ Loading galleries...
+
) : galleries.length === 0 ? (
You have not created any galleries yet.
diff --git a/frontend/src/components/CreateGalleryModal.tsx b/frontend/src/components/gallery/CreateGalleryModal.tsx
similarity index 98%
rename from frontend/src/components/CreateGalleryModal.tsx
rename to frontend/src/components/gallery/CreateGalleryModal.tsx
index bcbb283..cf84f35 100644
--- a/frontend/src/components/CreateGalleryModal.tsx
+++ b/frontend/src/components/gallery/CreateGalleryModal.tsx
@@ -1,6 +1,6 @@
import { LucideX } from "lucide-react";
import { useState } from "react";
-import { useAuthStore } from "../store/authStore";
+import { useAuthStore } from "../../store/authStore";
type CreateGalleryModalProps = {
onClose: () => void;
diff --git a/frontend/src/components/DeleteGalleryDialog.tsx b/frontend/src/components/gallery/DeleteGalleryDialog.tsx
similarity index 97%
rename from frontend/src/components/DeleteGalleryDialog.tsx
rename to frontend/src/components/gallery/DeleteGalleryDialog.tsx
index 1610456..9e7d6f8 100644
--- a/frontend/src/components/DeleteGalleryDialog.tsx
+++ b/frontend/src/components/gallery/DeleteGalleryDialog.tsx
@@ -48,7 +48,7 @@ export default function DeleteGalleryDialog({
Delete “{title}”? This cannot be undone. Artworks will remain in the
catalogue and in your other galleries.
diff --git a/frontend/src/components/GalleryCard.tsx b/frontend/src/components/gallery/GalleryCard.tsx
similarity index 95%
rename from frontend/src/components/GalleryCard.tsx
rename to frontend/src/components/gallery/GalleryCard.tsx
index d5bf25f..5ba070b 100644
--- a/frontend/src/components/GalleryCard.tsx
+++ b/frontend/src/components/gallery/GalleryCard.tsx
@@ -61,9 +61,7 @@ export default function GalleryCard({
)}
-
- {title}
-
+ {title}
{artworkCount} {artworkCount === 1 ? "artwork" : "artworks"}
diff --git a/frontend/src/components/gallery/GalleryHeader.tsx b/frontend/src/components/gallery/GalleryHeader.tsx
new file mode 100644
index 0000000..b14de6a
--- /dev/null
+++ b/frontend/src/components/gallery/GalleryHeader.tsx
@@ -0,0 +1,130 @@
+import { Pencil } from "lucide-react";
+
+type GalleryHeaderProps = {
+ title: string;
+ totalCount: number;
+ isEditing: boolean;
+ isRenaming: boolean;
+ titleInput: string;
+ renaming: boolean;
+ renameError: string;
+ onTitleChange: (value: string) => void;
+ onToggleEditing: () => void;
+ onStartRenaming: (title: string) => void;
+ onCancelRenaming: () => void;
+ onRename: () => void;
+};
+
+// Displays gallery details and controls for editing the gallery name
+export default function GalleryHeader({
+ title,
+ totalCount,
+ isEditing,
+ isRenaming,
+ titleInput,
+ renaming,
+ renameError,
+ onTitleChange,
+ onToggleEditing,
+ onStartRenaming,
+ onCancelRenaming,
+ onRename,
+}: GalleryHeaderProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/profile/ProfileHeader.tsx b/frontend/src/components/profile/ProfileHeader.tsx
new file mode 100644
index 0000000..a4f4b09
--- /dev/null
+++ b/frontend/src/components/profile/ProfileHeader.tsx
@@ -0,0 +1,92 @@
+import type { ChangeEvent } from "react";
+
+type ProfileHeaderProps = {
+ username: string;
+ coverColor: string;
+ coverImage: string | null;
+ profileImage: string | null;
+ isLoadingImages: boolean;
+ onCoverImageChange: (event: ChangeEvent) => void;
+ onProfileImageChange: (event: ChangeEvent) => void;
+};
+
+// Displays the user's cover image, profile image and username
+export default function ProfileHeader({
+ username,
+ coverColor,
+ coverImage,
+ profileImage,
+ isLoadingImages,
+ onCoverImageChange,
+ onProfileImageChange,
+}: ProfileHeaderProps) {
+ return (
+ <>
+
+ {isLoadingImages ? (
+
+ ) : (
+ coverImage && (
+
+ )
+ )}
+
+
+ Change
+
+
+
+
+
+
+
+ {isLoadingImages ? (
+
+ ) : profileImage ? (
+
+ ) : (
+
+ Choose image
+
+ )}
+
+ {/* Hover overlay */}
+
+ Change image
+
+
+
+
+
+
{username}
+
+ >
+ );
+}
diff --git a/frontend/src/hooks/auth/useLogin.ts b/frontend/src/hooks/auth/useLogin.ts
new file mode 100644
index 0000000..a53be09
--- /dev/null
+++ b/frontend/src/hooks/auth/useLogin.ts
@@ -0,0 +1,81 @@
+import { useState } from "react";
+import type { User } from "../../types/User";
+import { useAuthStore } from "../../store/authStore";
+
+type LoginResponse = {
+ data?: {
+ login: {
+ user: User;
+ token: string;
+ };
+ };
+ errors?: Array<{ message: string }>;
+};
+
+const LOGIN_MUTATION = `
+ mutation Login($username: String!, $password: String!) {
+ login(username: $username, password: $password) {
+ user {
+ id
+ username
+ }
+ token
+ }
+ }
+`;
+
+// Handles user login and authentication state
+export function useLogin() {
+ const setAuth = useAuthStore((state) => state.setAuth);
+
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState("");
+
+ async function login(username: string, password: string): Promise {
+ setError("");
+ setIsSubmitting(true);
+
+ try {
+ const response = await fetch(`${import.meta.env.BASE_URL}graphql`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ query: LOGIN_MUTATION,
+ variables: { username, password },
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Server error (${response.status}).`);
+ }
+
+ const result = (await response.json()) as LoginResponse;
+
+ if (result.errors?.length) {
+ throw new Error(result.errors[0].message);
+ }
+
+ if (!result.data?.login) {
+ throw new Error("The server did not return a user.");
+ }
+
+ const { user, token } = result.data.login;
+
+ setAuth(user, token);
+
+ return true;
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Something went wrong.");
+
+ return false;
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ return {
+ login,
+ isSubmitting,
+ error,
+ };
+}
diff --git a/frontend/src/hooks/auth/useRegister.ts b/frontend/src/hooks/auth/useRegister.ts
new file mode 100644
index 0000000..af1d042
--- /dev/null
+++ b/frontend/src/hooks/auth/useRegister.ts
@@ -0,0 +1,121 @@
+import { useState } from "react";
+import type { User } from "../../types/User";
+import { useAuthStore } from "../../store/authStore";
+
+type RegisterResponse = {
+ data?: { register?: User };
+ errors?: Array<{ message: string }>;
+};
+
+type LoginResponse = {
+ data?: {
+ login: {
+ user: User;
+ token: string;
+ };
+ };
+ errors?: Array<{ message: string }>;
+};
+
+const REGISTER_MUTATION = `
+ mutation Register($username: String!, $password: String!) {
+ register(username: $username, password: $password) {
+ id
+ username
+ }
+ }
+`;
+
+const LOGIN_MUTATION = `
+ mutation Login($username: String!, $password: String!) {
+ login(username: $username, password: $password) {
+ user {
+ id
+ username
+ }
+ token
+ }
+ }
+`;
+
+// Handles account registration and automatic login
+export function useRegister() {
+ const setAuth = useAuthStore((state) => state.setAuth);
+
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState("");
+
+ async function register(
+ username: string,
+ password: string,
+ ): Promise {
+ setError("");
+ setIsSubmitting(true);
+
+ try {
+ const response = await fetch(`${import.meta.env.BASE_URL}graphql`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ query: REGISTER_MUTATION,
+ variables: { username, password },
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Server error (${response.status}).`);
+ }
+
+ const result = (await response.json()) as RegisterResponse;
+
+ if (result.errors?.length) {
+ throw new Error(result.errors[0].message);
+ }
+
+ if (!result.data?.register) {
+ throw new Error("The server did not return the new account.");
+ }
+
+ const loginResponse = await fetch(`${import.meta.env.BASE_URL}graphql`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ query: LOGIN_MUTATION,
+ variables: { username, password },
+ }),
+ });
+
+ if (!loginResponse.ok) {
+ throw new Error(
+ `Account created, but automatic login failed (server error ${loginResponse.status}). Please log in manually.`,
+ );
+ }
+
+ const loginResult = (await loginResponse.json()) as LoginResponse;
+
+ if (loginResult.errors?.length || !loginResult.data?.login) {
+ throw new Error(
+ "Account created, but automatic login failed. Please log in manually.",
+ );
+ }
+
+ const { user, token } = loginResult.data.login;
+
+ setAuth(user, token);
+
+ return true;
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Something went wrong.");
+
+ return false;
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ return {
+ register,
+ isSubmitting,
+ error,
+ };
+}
diff --git a/frontend/src/hooks/catalogue/useCatalogueFilters.ts b/frontend/src/hooks/catalogue/useCatalogueFilters.ts
new file mode 100644
index 0000000..55ce47d
--- /dev/null
+++ b/frontend/src/hooks/catalogue/useCatalogueFilters.ts
@@ -0,0 +1,193 @@
+import { useState } from "react";
+import { useSearchParams } from "react-router-dom";
+import type { ArtworkSort } from "../../types/ArtworkSort";
+import type { ArtworkPeriod } from "../../types/ArtworkPeriod";
+
+//Handles filters and URL parameters
+export function useCatalogueFilters(setPage: (page: number) => void) {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [search, setSearch] = useState(() => searchParams.get("search") ?? "");
+ const [artists, setArtists] = useState(() => searchParams.getAll("artist"));
+
+ const [countries, setCountries] = useState(() =>
+ searchParams.getAll("country"),
+ );
+
+ const [cultures, setCultures] = useState(() =>
+ searchParams.getAll("culture"),
+ );
+
+ const [artPeriods, setArtPeriods] = useState(() =>
+ searchParams.getAll("artPeriod"),
+ );
+
+ const [sort, setSort] = useState(() => {
+ const value = searchParams.get("sort");
+ return [
+ "TITLE_ASC",
+ "TITLE_DESC",
+ "ARTIST_ASC",
+ "ARTIST_DESC",
+ "YEAR_ASC",
+ "YEAR_DESC",
+ ].includes(value ?? "")
+ ? (value as ArtworkSort)
+ : undefined;
+ });
+
+ const [period, setPeriod] = useState(() => {
+ const value = searchParams.get("period");
+ return [
+ "BEFORE_1500",
+ "YEARS_1500_1799",
+ "CENTURY_19",
+ "CENTURY_20_PLUS",
+ ].includes(value ?? "")
+ ? (value as ArtworkPeriod)
+ : undefined;
+ });
+
+ const [dateFrom, setDateFrom] = useState(() => {
+ const value = Number(searchParams.get("dateFrom"));
+ return searchParams.has("dateFrom") && Number.isFinite(value)
+ ? value
+ : undefined;
+ });
+
+ const [dateTo, setDateTo] = useState(() => {
+ const value = Number(searchParams.get("dateTo"));
+ return searchParams.has("dateTo") && Number.isFinite(value)
+ ? value
+ : undefined;
+ });
+
+ function updateQuery(values: Record) {
+ const next = new URLSearchParams(searchParams);
+
+ for (const [key, value] of Object.entries(values)) {
+ next.delete(key);
+
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ next.append(key, item);
+ }
+ } else if (value) {
+ next.set(key, value);
+ }
+ }
+
+ setSearchParams(next, { replace: true });
+ }
+
+ function handleSearch(value: string) {
+ const nextSearch = value.trim();
+ setSearch(nextSearch);
+ setPage(1);
+ updateQuery({ search: nextSearch, page: undefined });
+ }
+
+ function handleArtistChange(value: string[]) {
+ setArtists(value);
+ setPage(1);
+ updateQuery({ artist: value, page: undefined });
+ }
+
+ function handleCountryChange(value: string[]) {
+ setCountries(value);
+ setPage(1);
+ updateQuery({ country: value, page: undefined });
+ }
+
+ function handleCultureChange(value: string[]) {
+ setCultures(value);
+ setPage(1);
+ updateQuery({ culture: value, page: undefined });
+ }
+
+ function handleArtPeriodChange(value: string[]) {
+ setArtPeriods(value);
+ setPage(1);
+ updateQuery({ artPeriod: value, page: undefined });
+ }
+
+ function handleSortChange(value: ArtworkSort | undefined) {
+ setSort(value);
+ setPage(1);
+ updateQuery({ sort: value, page: undefined });
+ }
+
+ function handlePeriodChange(value: ArtworkPeriod | undefined) {
+ setPeriod(value);
+ setDateFrom(undefined);
+ setDateTo(undefined);
+ setPage(1);
+ updateQuery({
+ period: value,
+ dateFrom: undefined,
+ dateTo: undefined,
+ page: undefined,
+ });
+ }
+
+ function handleDateRangeChange(
+ from: number | undefined,
+ to: number | undefined,
+ ) {
+ setDateFrom(from);
+ setDateTo(to);
+ setPeriod(undefined);
+ setPage(1);
+ updateQuery({
+ period: undefined,
+ dateFrom: from === undefined ? undefined : String(from),
+ dateTo: to === undefined ? undefined : String(to),
+ page: undefined,
+ });
+ }
+
+ function handleReset() {
+ setSearch("");
+ setArtists([]);
+ setCountries([]);
+ setCultures([]);
+ setArtPeriods([]);
+ setSort(undefined);
+ setPeriod(undefined);
+ setDateFrom(undefined);
+ setDateTo(undefined);
+ setPage(1);
+ setSearchParams({}, { replace: true });
+ }
+
+ return {
+ updateQuery,
+ handleSearch,
+ handleArtistChange,
+ handleCountryChange,
+ handleCultureChange,
+ handleArtPeriodChange,
+ handleSortChange,
+ handlePeriodChange,
+ handleDateRangeChange,
+ handleReset,
+
+ search,
+ setSearch,
+ artists,
+ setArtists,
+ countries,
+ setCountries,
+ cultures,
+ setCultures,
+ artPeriods,
+ setArtPeriods,
+ sort,
+ setSort,
+ period,
+ setPeriod,
+ dateFrom,
+ setDateFrom,
+ dateTo,
+ setDateTo,
+ };
+}
diff --git a/frontend/src/hooks/catalogue/useCatalogueScrollRestoration.ts b/frontend/src/hooks/catalogue/useCatalogueScrollRestoration.ts
new file mode 100644
index 0000000..65e7da4
--- /dev/null
+++ b/frontend/src/hooks/catalogue/useCatalogueScrollRestoration.ts
@@ -0,0 +1,57 @@
+import { useEffect } from "react";
+import { useLocation, useNavigate } from "react-router-dom";
+
+//Restores scrollposition
+export function useCatalogueScrollRestoration(loading: boolean) {
+ const location = useLocation();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ if (loading) return;
+
+ let returnScrollY: number | undefined;
+
+ try {
+ const savedPosition = sessionStorage.getItem("catalogue-return");
+
+ if (savedPosition) {
+ const parsed = JSON.parse(savedPosition) as {
+ url?: string;
+ scrollY?: number;
+ };
+
+ if (`${location.pathname}${location.search}` === parsed.url) {
+ returnScrollY = parsed.scrollY;
+ }
+
+ sessionStorage.removeItem("catalogue-return");
+ }
+ } catch {
+ // Ignore invalid sessionStorage data and use fallback.
+ }
+
+ if (typeof returnScrollY !== "number") {
+ returnScrollY = location.state?.restoreScrollY;
+ }
+
+ if (typeof returnScrollY !== "number") return;
+
+ window.requestAnimationFrame(() => {
+ window.scrollTo(0, returnScrollY);
+ });
+
+ if (location.state?.restoreScrollY !== undefined) {
+ navigate(`${location.pathname}${location.search}`, {
+ replace: true,
+ state: null,
+ });
+ }
+ }, [
+ loading,
+ location.key,
+ location.pathname,
+ location.search,
+ location.state,
+ navigate,
+ ]);
+}
diff --git a/frontend/src/hooks/common/useDelayedLoading.ts b/frontend/src/hooks/common/useDelayedLoading.ts
new file mode 100644
index 0000000..e581a81
--- /dev/null
+++ b/frontend/src/hooks/common/useDelayedLoading.ts
@@ -0,0 +1,21 @@
+import { useEffect, useState } from "react";
+
+//Handles delayed loading indicator
+export function useDelayedLoading(loading: boolean, delay = 300) {
+ const [delayedLoading, setDelayedLoading] = useState(false);
+
+ useEffect(() => {
+ if (!loading) return;
+
+ const timer = window.setTimeout(() => {
+ setDelayedLoading(true);
+ }, delay);
+
+ return () => {
+ window.clearTimeout(timer);
+ setDelayedLoading(false);
+ };
+ }, [loading, delay]);
+
+ return loading && delayedLoading;
+}
diff --git a/frontend/src/hooks/common/useElementVisibility.ts b/frontend/src/hooks/common/useElementVisibility.ts
new file mode 100644
index 0000000..71a1e4c
--- /dev/null
+++ b/frontend/src/hooks/common/useElementVisibility.ts
@@ -0,0 +1,37 @@
+import { useCallback, useEffect, useState } from "react";
+
+//Observes whether elements are visible
+export function useElementVisibility(
+ options?: IntersectionObserverInit,
+) {
+ const [element, setElement] = useState(null);
+ const [isVisible, setIsVisible] = useState(false);
+
+ const elementRef = useCallback((node: T | null) => {
+ setElement(node);
+ if (!node) {
+ setIsVisible(false);
+ }
+ }, []);
+
+ const { root, rootMargin, threshold } = options ?? {};
+
+ useEffect(() => {
+ if (!element || typeof IntersectionObserver === "undefined") {
+ return;
+ }
+
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ setIsVisible(entry.isIntersecting);
+ },
+ { root, rootMargin, threshold },
+ );
+
+ observer.observe(element);
+
+ return () => observer.disconnect();
+ }, [element, root, rootMargin, threshold]);
+
+ return { elementRef, isVisible };
+}
diff --git a/frontend/src/hooks/common/useSuccessMessage.ts b/frontend/src/hooks/common/useSuccessMessage.ts
new file mode 100644
index 0000000..b052bdb
--- /dev/null
+++ b/frontend/src/hooks/common/useSuccessMessage.ts
@@ -0,0 +1,21 @@
+import { useEffect, useState } from "react";
+
+// Displays success messages and automatically clears them after 6 seconds
+export function useSuccessMessage() {
+ const [successMessage, setSuccessMessage] = useState("");
+
+ useEffect(() => {
+ if (!successMessage) return;
+
+ const timer = window.setTimeout(() => {
+ setSuccessMessage("");
+ }, 6000);
+
+ return () => window.clearTimeout(timer);
+ }, [successMessage]);
+
+ return {
+ successMessage,
+ setSuccessMessage,
+ };
+}
diff --git a/frontend/src/hooks/useDeleteGallery.ts b/frontend/src/hooks/gallery/useDeleteGallery.ts
similarity index 96%
rename from frontend/src/hooks/useDeleteGallery.ts
rename to frontend/src/hooks/gallery/useDeleteGallery.ts
index 47d50c2..8f18523 100644
--- a/frontend/src/hooks/useDeleteGallery.ts
+++ b/frontend/src/hooks/gallery/useDeleteGallery.ts
@@ -1,5 +1,5 @@
import { useState } from "react";
-import { useAuthStore } from "../store/authStore";
+import { useAuthStore } from "../../store/authStore";
type DeleteGalleryResponse = {
data?: {
diff --git a/frontend/src/hooks/useGallery.ts b/frontend/src/hooks/gallery/useGallery.ts
similarity index 95%
rename from frontend/src/hooks/useGallery.ts
rename to frontend/src/hooks/gallery/useGallery.ts
index 890fbf9..1115d39 100644
--- a/frontend/src/hooks/useGallery.ts
+++ b/frontend/src/hooks/gallery/useGallery.ts
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
-import type { GalleryPage } from "../types/Gallery";
-import { useAuthStore } from "../store/authStore";
+import type { GalleryPage } from "../../types/Gallery";
+import { useAuthStore } from "../../store/authStore";
type GalleryResponse = {
data?: {
diff --git a/frontend/src/hooks/useGalleryCoverImages.ts b/frontend/src/hooks/gallery/useGalleryCoverImages.ts
similarity index 97%
rename from frontend/src/hooks/useGalleryCoverImages.ts
rename to frontend/src/hooks/gallery/useGalleryCoverImages.ts
index 960e9b7..8d284fe 100644
--- a/frontend/src/hooks/useGalleryCoverImages.ts
+++ b/frontend/src/hooks/gallery/useGalleryCoverImages.ts
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
-import type { Gallery } from "../types/Gallery";
+import type { Gallery } from "../../types/Gallery";
type ArtworkResponse = {
data?: {
diff --git a/frontend/src/hooks/gallery/useGalleryRename.ts b/frontend/src/hooks/gallery/useGalleryRename.ts
new file mode 100644
index 0000000..6a56170
--- /dev/null
+++ b/frontend/src/hooks/gallery/useGalleryRename.ts
@@ -0,0 +1,48 @@
+import { useState } from "react";
+import { useRenameGalleryMutation } from "./useRenameGalleryMutation";
+
+// Manages gallery rename state and handles saving a new gallery title
+export function useGalleryRename(
+ galleryId: string,
+ onSuccess: (title: string) => void,
+) {
+ const [isRenaming, setIsRenaming] = useState(false);
+ const [titleInput, setTitleInput] = useState("");
+
+ const {
+ renameGallery,
+ renaming,
+ error: renameError,
+ } = useRenameGalleryMutation();
+
+ function handleStartRenaming(currentTitle: string) {
+ setTitleInput(currentTitle);
+ setIsRenaming(true);
+ }
+
+ function handleCancelRenaming() {
+ setIsRenaming(false);
+ }
+
+ async function handleRename() {
+ const updatedGallery = await renameGallery(galleryId, titleInput);
+
+ if (!updatedGallery) return;
+
+ setIsRenaming(false);
+ setTitleInput(updatedGallery.title);
+ onSuccess(updatedGallery.title);
+ }
+
+ return {
+ isRenaming,
+ setIsRenaming,
+ titleInput,
+ setTitleInput,
+ renaming,
+ renameError,
+ handleStartRenaming,
+ handleCancelRenaming,
+ handleRename,
+ };
+}
diff --git a/frontend/src/hooks/gallery/useMyGallery.ts b/frontend/src/hooks/gallery/useMyGallery.ts
new file mode 100644
index 0000000..1e27ec3
--- /dev/null
+++ b/frontend/src/hooks/gallery/useMyGallery.ts
@@ -0,0 +1,94 @@
+import { useEffect, useState } from "react";
+import type { Gallery } from "../../types/Gallery";
+import { useAuthStore } from "../../store/authStore";
+
+type MyGalleriesResponse = {
+ data?: {
+ myGalleries: Gallery[];
+ };
+ errors?: { message: string }[];
+};
+
+// Fetches the logged-in user's galleries
+export function useMyGalleries() {
+ const token = useAuthStore((state) => state.token);
+
+ const [galleries, setGalleries] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [refreshKey, setRefreshKey] = useState(0);
+
+ useEffect(() => {
+ const controller = new AbortController();
+
+ async function fetchGalleries() {
+ setIsLoading(true);
+ setError("");
+
+ try {
+ if (!token) {
+ throw new Error("You must be logged in");
+ }
+
+ const response = await fetch(`${import.meta.env.BASE_URL}graphql`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ signal: controller.signal,
+ body: JSON.stringify({
+ query: `
+ query {
+ myGalleries {
+ id
+ userId
+ title
+ artworkIds
+ }
+ }
+ `,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const result = (await response.json()) as MyGalleriesResponse;
+
+ if (result.errors?.length) {
+ throw new Error(result.errors[0].message);
+ }
+
+ if (!result.data) {
+ throw new Error("Could not load galleries");
+ }
+
+ if (!controller.signal.aborted) {
+ setGalleries(result.data.myGalleries);
+ }
+ } catch (err) {
+ if (controller.signal.aborted) return;
+
+ setError(
+ err instanceof Error ? err.message : "Could not load galleries",
+ );
+ } finally {
+ if (!controller.signal.aborted) {
+ setIsLoading(false);
+ }
+ }
+ }
+
+ void fetchGalleries();
+
+ return () => controller.abort();
+ }, [token, refreshKey]);
+
+ function refetchGalleries() {
+ setRefreshKey((prev) => prev + 1);
+ }
+
+ return { galleries, isLoading, error, refetchGalleries };
+}
diff --git a/frontend/src/hooks/useRemoveArtworkFromGallery.ts b/frontend/src/hooks/gallery/useRemoveArtworkFromGallery.ts
similarity index 94%
rename from frontend/src/hooks/useRemoveArtworkFromGallery.ts
rename to frontend/src/hooks/gallery/useRemoveArtworkFromGallery.ts
index e94cff5..8da2490 100644
--- a/frontend/src/hooks/useRemoveArtworkFromGallery.ts
+++ b/frontend/src/hooks/gallery/useRemoveArtworkFromGallery.ts
@@ -1,6 +1,6 @@
import { useState } from "react";
-import { useAuthStore } from "../store/authStore";
-import type { Gallery } from "../types/Gallery";
+import { useAuthStore } from "../../store/authStore";
+import type { Gallery } from "../../types/Gallery";
type RemoveArtworkResponse = {
data?: {
diff --git a/frontend/src/hooks/useRenameGallery.ts b/frontend/src/hooks/gallery/useRenameGalleryMutation.ts
similarity index 92%
rename from frontend/src/hooks/useRenameGallery.ts
rename to frontend/src/hooks/gallery/useRenameGalleryMutation.ts
index c2bbf44..edca2db 100644
--- a/frontend/src/hooks/useRenameGallery.ts
+++ b/frontend/src/hooks/gallery/useRenameGalleryMutation.ts
@@ -1,6 +1,6 @@
import { useState } from "react";
-import { useAuthStore } from "../store/authStore";
-import type { Gallery } from "../types/Gallery";
+import { useAuthStore } from "../../store/authStore";
+import type { Gallery } from "../../types/Gallery";
type RenameGalleryResponse = {
data?: {
@@ -9,7 +9,7 @@ type RenameGalleryResponse = {
errors?: { message: string }[];
};
-export function useRenameGallery() {
+export function useRenameGalleryMutation() {
const token = useAuthStore((state) => state.token);
const [renaming, setRenaming] = useState(false);
diff --git a/frontend/src/hooks/profile/useProfileImages.ts b/frontend/src/hooks/profile/useProfileImages.ts
new file mode 100644
index 0000000..8851e86
--- /dev/null
+++ b/frontend/src/hooks/profile/useProfileImages.ts
@@ -0,0 +1,87 @@
+import { useEffect, useState } from "react";
+
+type ProfileImagesResponse = {
+ data?: {
+ me: {
+ profileImage: string | null;
+ coverImage: string | null;
+ } | null;
+ };
+ errors?: Array<{ message: string }>;
+};
+
+// Fetches the user's profile and cover images
+export function useProfileImages(token: string | null) {
+ const [profileImage, setProfileImage] = useState(null);
+ const [coverImage, setCoverImage] = useState(null);
+ const [isLoadingImages, setIsLoadingImages] = useState(true);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function fetchProfileImages() {
+ setIsLoadingImages(true);
+
+ const query = `
+ query {
+ me {
+ profileImage
+ coverImage
+ }
+ }
+ `;
+
+ try {
+ const response = await fetch(`${import.meta.env.BASE_URL}graphql`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token ?? ""}`,
+ },
+ body: JSON.stringify({ query }),
+ });
+
+ if (!response.ok) {
+ throw new Error("Could not fetch profile images");
+ }
+
+ const result = (await response.json()) as ProfileImagesResponse;
+
+ if (result.errors?.length) {
+ throw new Error(result.errors[0].message);
+ }
+
+ if (!result.data?.me) {
+ throw new Error("The server did not return a user.");
+ }
+
+ if (cancelled) return;
+
+ setProfileImage(result.data.me.profileImage);
+ setCoverImage(result.data.me.coverImage);
+ } catch (error) {
+ if (!cancelled) {
+ console.error("Error fetching profile images:", error);
+ }
+ } finally {
+ if (!cancelled) {
+ setIsLoadingImages(false);
+ }
+ }
+ }
+
+ void fetchProfileImages();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [token]);
+
+ return {
+ profileImage,
+ coverImage,
+ isLoadingImages,
+ setProfileImage,
+ setCoverImage,
+ };
+}
diff --git a/frontend/src/hooks/profile/useUploadProfileImage.ts b/frontend/src/hooks/profile/useUploadProfileImage.ts
new file mode 100644
index 0000000..4315ec6
--- /dev/null
+++ b/frontend/src/hooks/profile/useUploadProfileImage.ts
@@ -0,0 +1,116 @@
+import { useState, type ChangeEvent } from "react";
+
+type ImageType = "profile" | "cover";
+
+type UploadResponse = {
+ data?: {
+ updateProfileImage?: {
+ profileImage: string | null;
+ };
+ updateCoverImage?: {
+ coverImage: string | null;
+ };
+ };
+ errors?: Array<{ message: string }>;
+};
+
+const mutations = {
+ profile: `
+ mutation UpdateProfileImage($image: String!) {
+ updateProfileImage(image: $image) {
+ profileImage
+ }
+ }
+ `,
+ cover: `
+ mutation UpdateCoverImage($image: String!) {
+ updateCoverImage(image: $image) {
+ coverImage
+ }
+ }
+ `,
+};
+
+// Validates and uploads profile or cover images
+export function useUploadProfileImage(token: string | null) {
+ const [isUploading, setIsUploading] = useState(false);
+
+ async function uploadImage(
+ event: ChangeEvent,
+ type: ImageType,
+ ): Promise {
+ const file = event.target.files?.[0];
+ if (!file) return null;
+
+ if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
+ alert("Please select a JPEG, PNG or WebP image");
+ return null;
+ }
+
+ if (file.size > 2_000_000) {
+ alert("Image must be smaller than 2 MB");
+ return null;
+ }
+
+ setIsUploading(true);
+
+ try {
+ const image = await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+
+ reader.onload = () => {
+ if (typeof reader.result === "string") {
+ resolve(reader.result);
+ } else {
+ reject(new Error("Could not read image"));
+ }
+ };
+
+ reader.onerror = () => reject(new Error("Could not read image"));
+
+ reader.readAsDataURL(file);
+ });
+
+ const response = await fetch(`${import.meta.env.BASE_URL}graphql`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token ?? ""}`,
+ },
+ body: JSON.stringify({
+ query: mutations[type],
+ variables: { image },
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Could not upload ${type} image`);
+ }
+
+ const result = (await response.json()) as UploadResponse;
+
+ if (result.errors?.length) {
+ throw new Error(result.errors[0].message);
+ }
+
+ const imageUrl =
+ type === "profile"
+ ? result.data?.updateProfileImage?.profileImage
+ : result.data?.updateCoverImage?.coverImage;
+
+ if (!imageUrl) {
+ throw new Error("The server did not return an image.");
+ }
+
+ return imageUrl;
+ } catch (error) {
+ console.error(`Error uploading ${type} image:`, error);
+ alert(`Could not save ${type} image`);
+ return null;
+ } finally {
+ setIsUploading(false);
+ }
+ }
+
+ return { uploadImage, isUploading };
+}
diff --git a/frontend/src/pages/ArtCataloguePage.tsx b/frontend/src/pages/ArtCataloguePage.tsx
new file mode 100644
index 0000000..103a59e
--- /dev/null
+++ b/frontend/src/pages/ArtCataloguePage.tsx
@@ -0,0 +1,226 @@
+import { useState, useEffect, useRef } from "react";
+import { Link, useSearchParams } from "react-router-dom";
+import { CircleUserRound } from "lucide-react";
+
+// Components
+import ArtworkGrid from "../components/artwork/ArtworkGrid";
+import Hero from "../components/Hero";
+import Pagination from "../components/Pagination";
+import CatalogueControls from "../components/CatalogueControls";
+import ThemeToggleButton from "../components/ThemeToggleButton";
+
+// Hooks
+import { useArtworks } from "../hooks/useArtworks";
+import { useDelayedLoading } from "../hooks/common/useDelayedLoading";
+import { useElementVisibility } from "../hooks/common/useElementVisibility";
+import { useCatalogueFilters } from "../hooks/catalogue/useCatalogueFilters";
+import { useCatalogueScrollRestoration } from "../hooks/catalogue/useCatalogueScrollRestoration";
+
+// Store
+import { useAuthStore } from "../store/authStore";
+
+// Constants
+const PAGE_SIZE = 12;
+const MIN_ARTWORK_YEAR = -8000;
+const MAX_ARTWORK_YEAR = new Date().getFullYear();
+
+export default function ArtCataloguePage() {
+ // URL and pagination state
+ const [searchParams] = useSearchParams();
+
+ const [page, setPage] = useState(() =>
+ Math.max(1, Number(searchParams.get("page")) || 1),
+ );
+
+ // UI state
+ const [showFilters, setShowFilters] = useState(false);
+
+ // Refs
+ const artworkResultsRef = useRef(null);
+ const shouldScrollToPageRef = useRef(false);
+
+ // Authentication
+ const isLoggedIn = useAuthStore((state) => Boolean(state.token));
+
+ // Catalogue filters
+ const {
+ search,
+ updateQuery,
+ handleSearch,
+ handleArtistChange,
+ handleCountryChange,
+ handleCultureChange,
+ handleArtPeriodChange,
+ handleSortChange,
+ handlePeriodChange,
+ handleDateRangeChange,
+ handleReset,
+
+ artists,
+ countries,
+ cultures,
+ artPeriods,
+ sort,
+ period,
+ dateFrom,
+ dateTo,
+ } = useCatalogueFilters(setPage);
+
+ // Element visiility
+ const { elementRef: catalogueRef, isVisible: galleryVisible } =
+ useElementVisibility({
+ threshold: 0.01,
+ });
+
+ const { elementRef: paginationRef, isVisible: paginationVisible } =
+ useElementVisibility({
+ rootMargin: "0px 0px 96px 0px",
+ });
+
+ // Artwork data
+ const { artworks, totalCount, loading, error } = useArtworks(
+ page,
+ PAGE_SIZE,
+ search,
+ artists,
+ countries,
+ cultures,
+ artPeriods,
+ sort,
+ period,
+ dateFrom,
+ dateTo,
+ );
+
+ // Loading and scrolling restoration
+ const showLoading = useDelayedLoading(loading);
+ useCatalogueScrollRestoration(loading);
+
+ // Dervived values
+ const totalPages = Math.ceil(totalCount / PAGE_SIZE);
+
+ // Scroll down to artworks when paging (skip hero)
+ useEffect(() => {
+ if (!shouldScrollToPageRef.current) return;
+ shouldScrollToPageRef.current = false;
+
+ window.requestAnimationFrame(() => {
+ artworkResultsRef.current?.scrollIntoView({
+ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ ? "auto"
+ : "smooth",
+ block: "start",
+ });
+ });
+ }, [page]);
+
+ // Handlers
+ function handleExploreArtworks() {
+ const targetId = window.matchMedia("(min-width: 1024px)").matches
+ ? "catalogue-controls"
+ : "artwork-catalogue";
+ document.getElementById(targetId)?.scrollIntoView({
+ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ ? "auto"
+ : "smooth",
+ block: "start",
+ });
+ }
+
+ function handlePageChange(value: number) {
+ shouldScrollToPageRef.current = value > page;
+ setPage(value);
+ updateQuery({ page: value === 1 ? undefined : String(value) });
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ setShowFilters((current) => !current)}
+ paginationVisible={paginationVisible}
+ galleryVisible={galleryVisible}
+ />
+
+ {error &&
Error: {error}
}
+
+ {loading && showLoading ? "Loading artworks..." : ""}
+
+
+
+
+ {!error && artworks.length > 0 &&
}
+
+ {!loading && !error && artworks.length === 0 && (
+
+ No artworks match your search and filters. Try changing your
+ selections or use Clear all.
+
+ )}
+
+
+ {!error && totalPages > 1 && (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/ArtworkDetailPage.tsx b/frontend/src/pages/ArtworkDetailPage.tsx
index e090d9a..64543bf 100644
--- a/frontend/src/pages/ArtworkDetailPage.tsx
+++ b/frontend/src/pages/ArtworkDetailPage.tsx
@@ -1,17 +1,27 @@
import { Link, useParams, useLocation } from "react-router-dom";
-import { useArtwork } from "../hooks/useArtwork";
import { useState } from "react";
-import AddToGalleryModal from "../components/AddToGalleryModal";
+
+// Components
+import AddToGalleryModal from "../components/gallery/AddToGalleryModal";
+import ArtworkDetails from "../components/artwork/ArtworkDetails";
+
+// Hooks
+import { useArtwork } from "../hooks/useArtwork";
// ChatGPT was used to assist with the implementation of this page.
export default function ArtworkDetailPage() {
+ // Router
const { id } = useParams();
- const artworkId = Number(id);
+ const location = useLocation();
- const { artwork, loading, error } = useArtwork(artworkId);
+ // State
const [showGalleryModal, setShowGalleryModal] = useState(false);
- const location = useLocation();
+ // Hooks
+ const artworkId = Number(id);
+ const { artwork, loading, error } = useArtwork(artworkId);
+
+ // Navigation
const from = location.state?.from;
const backTo =
@@ -40,7 +50,6 @@ export default function ArtworkDetailPage() {
}
const artist = artwork.artist_display?.trim() || "Unknown artist";
- const place = [artwork.city, artwork.country].filter(Boolean).join(", ");
return (
@@ -76,47 +85,7 @@ export default function ArtworkDetailPage() {
{artwork.date_display}
)}
-
-
- Details
-
-
- {artwork.medium && (
-
-
Medium
-
{artwork.medium}
-
- )}
-
- {place && (
-
- )}
-
- {artwork.culture && (
-
-
Culture
-
{artwork.culture}
-
- )}
-
- {artwork.period && (
-
-
Period
-
{artwork.period}
-
- )}
-
- {artwork.dimensions && (
-
-
Dimensions
-
{artwork.dimensions}
-
- )}
-
-
+
{/* Artwork image */}
@@ -136,46 +105,7 @@ export default function ArtworkDetailPage() {
{/* Details */}
-
- Details
-
-
- {artwork.medium && (
-
-
Medium
-
{artwork.medium}
-
- )}
-
- {place && (
-
- )}
-
- {artwork.culture && (
-
-
Culture
-
{artwork.culture}
-
- )}
-
- {artwork.period && (
-
-
Period
-
{artwork.period}
-
- )}
-
- {artwork.dimensions && (
-
-
Dimensions
-
{artwork.dimensions}
-
- )}
-
-
+
{showGalleryModal && (
;
-};
-
-type LoginResponse = {
- data?: {
- login: {
- user: User;
- token: string;
- };
- };
- errors?: { message: string }[];
-};
+// Hooks
+import { useRegister } from "../hooks/auth/useRegister";
export default function CreateAccount() {
+ // Router
const navigate = useNavigate();
- const setAuth = useAuthStore((state) => state.setAuth);
+ // State
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
- const [message, setMessage] = useState("");
- const [isSubmitting, setIsSubmitting] = useState(false);
+ // Hooks
+ const { register, isSubmitting, error } = useRegister();
+
+ // Handlers
async function handleRegister(event: ReactSubmitEvent) {
event.preventDefault();
- setMessage("");
- setIsSubmitting(true);
-
- const query = `
- mutation Register($username: String!, $password: String!) {
- register(username: $username, password: $password) {
- id
- username
- }
- }
- `;
-
- try {
- const response = await fetch(`${import.meta.env.BASE_URL}graphql`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ query, variables: { username, password } }),
- });
-
- if (!response.ok) {
- throw new Error(`Server error (${response.status}).`);
- }
-
- const result = (await response.json()) as RegisterResponse;
- if (result.errors?.length) {
- throw new Error(result.errors[0].message);
- }
- if (!result.data?.register) {
- throw new Error("The server did not return the new account.");
- }
-
- const loginQuery = `
- mutation Login($username: String!, $password: String!) {
- login(username: $username, password: $password) {
- user {
- id
- username
- }
- token
- }
- }
- `;
-
- const loginResponse = await fetch(`${import.meta.env.BASE_URL}graphql`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- query: loginQuery,
- variables: { username, password },
- }),
- });
-
- if (!loginResponse.ok) {
- throw new Error(`Server error (${loginResponse.status}).`);
- }
-
- const loginResult = (await loginResponse.json()) as LoginResponse;
-
- if (loginResult.errors?.length) {
- throw new Error(loginResult.errors[0].message);
- }
-
- if (!loginResult.data?.login) {
- throw new Error("Account was created, but login failed.");
- }
-
- const { user, token } = loginResult.data.login;
- setAuth(user, token);
+ const success = await register(username, password);
+ if (success) {
navigate("/profile");
- } catch (error) {
- setMessage(
- error instanceof Error ? error.message : "Something went wrong.",
- );
- } finally {
- setIsSubmitting(false);
}
}
@@ -174,13 +91,12 @@ export default function CreateAccount() {
{isSubmitting ? "Creating account..." : "Create account"}
- {message && (
+ {error && (
- {message}
+ {error}
)}
diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx
index 0ac15a8..2dd36b8 100644
--- a/frontend/src/pages/GalleryPage.tsx
+++ b/frontend/src/pages/GalleryPage.tsx
@@ -1,85 +1,75 @@
import { Link, useParams, useNavigate } from "react-router-dom";
-import { useGallery } from "../hooks/useGallery";
-import { useRemoveArtworkFromGallery } from "../hooks/useRemoveArtworkFromGallery";
-import { useRenameGallery } from "../hooks/useRenameGallery";
-import { useDeleteGallery } from "../hooks/useDeleteGallery";
-import DeleteGalleryDialog from "../components/DeleteGalleryDialog";
+import { useState } from "react";
+import { Trash2 } from "lucide-react";
+
+// Types
import type { Artwork } from "../types/Artwork";
-import { useState, useEffect } from "react";
-import { Trash2, Pencil } from "lucide-react";
-import ArtworkGrid from "../components/ArtworkGrid";
+
+// Components
+import GalleryHeader from "../components/gallery/GalleryHeader";
+import ArtworkGrid from "../components/artwork/ArtworkGrid";
import Pagination from "../components/Pagination";
+import DeleteGalleryDialog from "../components/gallery/DeleteGalleryDialog";
+
+// Hooks
+import { useGallery } from "../hooks/gallery/useGallery";
+import { useGalleryRename } from "../hooks/gallery/useGalleryRename";
+import { useRemoveArtworkFromGallery } from "../hooks/gallery/useRemoveArtworkFromGallery";
+import { useDeleteGallery } from "../hooks/gallery/useDeleteGallery";
+import { useSuccessMessage } from "../hooks/common/useSuccessMessage";
+
+const PAGE_SIZE = 12;
export default function GalleryPage() {
+ // Router
const { id = "" } = useParams();
const navigate = useNavigate();
- const [showDeleteDialog, setShowDeleteDialog] = useState(false);
- const { deleteGallery, deleting, error: deleteError } = useDeleteGallery();
+ // State
const [page, setPage] = useState(1);
- const pageSize = 12;
-
const [refreshKey, setRefreshKey] = useState(0);
- const [successMessage, setSuccessMessage] = useState("");
const [isEditing, setIsEditing] = useState(false);
- const [titleInput, setTitleInput] = useState("");
- const [isRenaming, setIsRenaming] = useState(false);
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
- const { renameGallery, renaming, error: renameError } = useRenameGallery();
+ // Hooks
+ const { successMessage, setSuccessMessage } = useSuccessMessage();
+ const { deleteGallery, deleting, error: deleteError } = useDeleteGallery();
const { gallery, loading, error } = useGallery(
id,
page,
- pageSize,
+ PAGE_SIZE,
refreshKey,
);
+ const {
+ isRenaming,
+ setIsRenaming,
+ titleInput,
+ setTitleInput,
+ renaming,
+ renameError,
+ handleStartRenaming,
+ handleCancelRenaming,
+ handleRename,
+ } = useGalleryRename(id, () => {
+ setRefreshKey((current) => current + 1);
+ setSuccessMessage("Gallery name updated.");
+ });
+
const {
removeArtwork,
removingId,
error: removeError,
} = useRemoveArtworkFromGallery();
- const totalPages = Math.ceil((gallery?.totalCount ?? 0) / pageSize);
-
- useEffect(() => {
- if (!successMessage) return;
-
- const timer = window.setTimeout(() => {
- setSuccessMessage("");
- }, 6000);
-
- return () => window.clearTimeout(timer);
- }, [successMessage]);
+ const totalPages = Math.ceil((gallery?.totalCount ?? 0) / PAGE_SIZE);
+ // Handlers
function handleToggleEditing() {
setIsRenaming(false);
setIsEditing((current) => !current);
}
- function handleStartRenaming() {
- if (!gallery) return;
-
- setTitleInput(gallery.title);
- setIsRenaming(true);
- }
-
- function handleCancelRenaming() {
- setIsRenaming(false);
- }
-
- async function handleRename() {
- setSuccessMessage("");
-
- const updatedGallery = await renameGallery(id, titleInput);
-
- if (!updatedGallery) return;
- setIsRenaming(false);
-
- setTitleInput(updatedGallery.title);
- setRefreshKey((current) => current + 1);
- setSuccessMessage("Gallery name updated.");
- }
-
async function handleDeleteGallery() {
const deletedId = await deleteGallery(id);
@@ -100,7 +90,7 @@ export default function GalleryPage() {
const lastPage = Math.max(
1,
- Math.ceil(updatedGallery.artworkIds.length / pageSize),
+ Math.ceil(updatedGallery.artworkIds.length / PAGE_SIZE),
);
setPage((currentPage) => Math.min(currentPage, lastPage));
@@ -148,102 +138,23 @@ export default function GalleryPage() {
Gallery not found
) : (
-
+
{
+ setSuccessMessage("");
+ void handleRename();
+ }}
+ />
{isEditing && (
@@ -269,7 +180,7 @@ export default function GalleryPage() {
) : (
<>
-
+
Math.max(1, Number(searchParams.get("page")) || 1));
- const [search, setSearch] = useState(() => searchParams.get("search") ?? "");
- const [artists, setArtists] = useState(() => searchParams.getAll("artist"));
- const [countries, setCountries] = useState(() => searchParams.getAll("country"));
- const [cultures, setCultures] = useState(() => searchParams.getAll("culture"));
- const [artPeriods, setArtPeriods] = useState(() => searchParams.getAll("artPeriod"));
- const [sort, setSort] = useState(() => {
- const value = searchParams.get("sort");
- return ["TITLE_ASC", "TITLE_DESC", "ARTIST_ASC", "ARTIST_DESC", "YEAR_ASC", "YEAR_DESC"].includes(value ?? "")
- ? (value as ArtworkSort)
- : undefined;
- });
- const [period, setPeriod] = useState(() => {
- const value = searchParams.get("period");
- return ["BEFORE_1500", "YEARS_1500_1799", "CENTURY_19", "CENTURY_20_PLUS"].includes(value ?? "")
- ? (value as ArtworkPeriod)
- : undefined;
- });
- const [dateFrom, setDateFrom] = useState(() => {
- const value = Number(searchParams.get("dateFrom"));
- return searchParams.has("dateFrom") && Number.isFinite(value) ? value : undefined;
- });
- const [dateTo, setDateTo] = useState(() => {
- const value = Number(searchParams.get("dateTo"));
- return searchParams.has("dateTo") && Number.isFinite(value) ? value : undefined;
- });
- const [showLoading, setShowLoading] = useState(false);
- const [showFilters, setShowFilters] = useState(false);
- const [paginationVisible, setPaginationVisible] = useState(false);
- const [galleryVisible, setGalleryVisible] = useState(false);
- const catalogueRef = useRef(null);
- const paginationRef = useRef(null);
- const artworkResultsRef = useRef(null);
- const shouldScrollToPageRef = useRef(false);
- const isLoggedIn = useAuthStore((state) => Boolean(state.token));
-
- function handleExploreArtworks() {
- const targetId = window.matchMedia("(min-width: 1024px)").matches
- ? "catalogue-controls"
- : "artwork-catalogue";
- document.getElementById(targetId)?.scrollIntoView({
- behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
- block: "start",
- });
- }
-
- function updateQuery(values: Record) {
- const next = new URLSearchParams(searchParams);
- for (const [key, value] of Object.entries(values)) {
- next.delete(key);
- if (Array.isArray(value)) {
- for (const item of value) next.append(key, item);
- } else if (value) {
- next.set(key, value);
- }
- }
- setSearchParams(next, { replace: true });
- }
-
- const { artworks, totalCount, loading, error } = useArtworks(
- page,
- PAGE_SIZE,
- search,
- artists,
- countries,
- cultures,
- artPeriods,
- sort,
- period,
- dateFrom,
- dateTo,
- );
- useEffect(() => {
- if (!loading) return;
-
- const timer = window.setTimeout(() => {
- setShowLoading(true);
- }, 300);
-
- return () => {
- window.clearTimeout(timer);
- };
- }, [loading]);
-
- useEffect(() => {
- const catalogue = catalogueRef.current;
- if (!catalogue || typeof IntersectionObserver === "undefined") return;
-
- const observer = new IntersectionObserver(
- ([entry]) => setGalleryVisible(entry.isIntersecting),
- { threshold: 0.01 },
- );
- observer.observe(catalogue);
- return () => observer.disconnect();
- }, []);
-
- useEffect(() => {
- const pagination = paginationRef.current;
- if (!pagination || typeof IntersectionObserver === "undefined") {
- setPaginationVisible(false);
- return;
- }
-
- const observer = new IntersectionObserver(
- ([entry]) => setPaginationVisible(entry.isIntersecting),
- { rootMargin: "0px 0px 96px 0px" },
- );
- observer.observe(pagination);
-
- return () => observer.disconnect();
- }, [totalCount, error]);
-
- useEffect(() => {
- if (!shouldScrollToPageRef.current) return;
- shouldScrollToPageRef.current = false;
-
- window.requestAnimationFrame(() => {
- artworkResultsRef.current?.scrollIntoView({
- behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
- block: "start",
- });
- });
- }, [page]);
-
- // Made with help from AI.
- const totalPages = Math.ceil(totalCount / PAGE_SIZE);
- useEffect(() => {
- if (loading) return;
-
- let returnScrollY: number | undefined;
- try {
- const savedPosition = sessionStorage.getItem("catalogue-return");
- if (savedPosition) {
- const parsed = JSON.parse(savedPosition) as { url?: string; scrollY?: number };
- if (`${location.pathname}${location.search}` === parsed.url) {
- returnScrollY = parsed.scrollY;
- }
- sessionStorage.removeItem("catalogue-return");
- }
- } catch {
- }
- if (typeof returnScrollY !== "number") {
- returnScrollY = location.state?.restoreScrollY;
- }
-
- if (typeof returnScrollY !== "number") return;
-
- window.requestAnimationFrame(() => {
- window.scrollTo(0, returnScrollY);
- });
- if (location.state?.restoreScrollY !== undefined) {
- navigate(`${location.pathname}${location.search}`, { replace: true, state: null });
- }
- }, [loading, location.key, location.pathname, location.search, location.state, navigate]);
-
- function handleSearch(value: string) {
- setShowLoading(false);
- const nextSearch = value.trim();
- setSearch(nextSearch);
- setPage(1);
- updateQuery({ search: nextSearch, page: undefined });
- }
-
- function handleArtistChange(value: string[]) {
- setShowLoading(false);
- setArtists(value);
- setPage(1);
- updateQuery({ artist: value, page: undefined });
- }
-
- function handleCountryChange(value: string[]) {
- setShowLoading(false);
- setCountries(value);
- setPage(1);
- updateQuery({ country: value, page: undefined });
- }
-
- function handleCultureChange(value: string[]) {
- setShowLoading(false);
- setCultures(value);
- setPage(1);
- updateQuery({ culture: value, page: undefined });
- }
-
- function handleArtPeriodChange(value: string[]) {
- setShowLoading(false);
- setArtPeriods(value);
- setPage(1);
- updateQuery({ artPeriod: value, page: undefined });
- }
-
- function handleSortChange(value: ArtworkSort | undefined) {
- setShowLoading(false);
- setSort(value);
- setPage(1);
- updateQuery({ sort: value, page: undefined });
- }
-
- function handlePeriodChange(value: ArtworkPeriod | undefined) {
- setShowLoading(false);
- setPeriod(value);
- setDateFrom(undefined);
- setDateTo(undefined);
- setPage(1);
- updateQuery({ period: value, dateFrom: undefined, dateTo: undefined, page: undefined });
- }
-
- function handleDateRangeChange(from: number | undefined, to: number | undefined) {
- setShowLoading(false);
- setDateFrom(from);
- setDateTo(to);
- setPeriod(undefined);
- setPage(1);
- updateQuery({ period: undefined, dateFrom: from === undefined ? undefined : String(from), dateTo: to === undefined ? undefined : String(to), page: undefined });
- }
-
- function handlePageChange(value: number) {
- setShowLoading(false);
- shouldScrollToPageRef.current = value > page;
- setPage(value);
- updateQuery({ page: value === 1 ? undefined : String(value) });
- }
-
- function handleReset() {
- setShowLoading(false);
- setSearch("");
- setArtists([]);
- setCountries([]);
- setCultures([]);
- setArtPeriods([]);
- setSort(undefined);
- setPeriod(undefined);
- setDateFrom(undefined);
- setDateTo(undefined);
- setPage(1);
- setSearchParams({}, { replace: true });
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
- setShowFilters((current) => !current)}
- paginationVisible={paginationVisible}
- galleryVisible={galleryVisible}
- />
-
- {error &&
Error: {error}
}
-
{loading && showLoading ? "Loading artworks..." : ""}
-
-
-
- {!error && artworks.length > 0 &&
}
-
- {!loading && !error && artworks.length === 0 && (
-
- No artworks match your search and filters. Try changing your
- selections or use Clear all.
-
- )}
-
-
- {!error && totalPages > 1 && (
-
- )}
-
-
- );
-}
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
index bbdda7c..e13e50e 100644
--- a/frontend/src/pages/LoginPage.tsx
+++ b/frontend/src/pages/LoginPage.tsx
@@ -1,74 +1,28 @@
import { Link, useNavigate } from "react-router-dom";
import { useState, type SubmitEvent as ReactSubmitEvent } from "react";
-import type { User } from "../types/User";
-import { useAuthStore } from "../store/authStore";
-
-type LoginResponse = {
- data?: {
- login: {
- user: User;
- token: string;
- };
- };
- errors?: { message: string }[];
-};
+
+// Hooks
+import { useLogin } from "../hooks/auth/useLogin";
function Login() {
+ // Router
const navigate = useNavigate();
- const setAuth = useAuthStore((state) => state.setAuth);
+ // States
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
- const [message, setMessage] = useState("");
- const [isSubmitting, setIsSubmitting] = useState(false);
+ // Hooks
+ const { login, isSubmitting, error } = useLogin();
+
+ // Handlers
async function handleLogin(event: ReactSubmitEvent) {
event.preventDefault();
- setMessage("");
- setIsSubmitting(true);
-
- const query = `
- mutation Login($username: String!, $password: String!) {
- login(username: $username, password: $password) {
- user {
- id
- username
- }
- token
- }
- }
- `;
-
- try {
- const response = await fetch(`${import.meta.env.BASE_URL}graphql`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ query, variables: { username, password } }),
- });
-
- if (!response.ok) {
- throw new Error(`Server error (${response.status}).`);
- }
-
- const result = (await response.json()) as LoginResponse;
- if (result.errors?.length) {
- throw new Error(result.errors[0].message);
- }
- if (!result.data?.login) {
- throw new Error("The server did not return a user.");
- }
-
- const { user, token } = result.data.login;
-
- setAuth(user, token);
+ const success = await login(username, password);
+
+ if (success) {
navigate("/profile");
- } catch (error) {
- setMessage(
- error instanceof Error ? error.message : "Something went wrong.",
- );
- } finally {
- setIsSubmitting(false);
}
}
@@ -133,13 +87,12 @@ function Login() {
{isSubmitting ? "Logging in..." : "Login"}
- {message && (
+ {error && (
- {message}
+ {error}
)}
diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx
index f9ba18c..6102d28 100644
--- a/frontend/src/pages/ProfilePage.tsx
+++ b/frontend/src/pages/ProfilePage.tsx
@@ -1,10 +1,20 @@
-import GalleryCard from "../components/GalleryCard";
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";
import { Link, useNavigate, useLocation } from "react-router-dom";
+
+// Components
+import GalleryCard from "../components/gallery/GalleryCard";
+import CreateGalleryModal from "../components/gallery/CreateGalleryModal";
+import ProfileHeader from "../components/profile/ProfileHeader";
+
+// Hooks
+import { useGalleryCoverImages } from "../hooks/gallery/useGalleryCoverImages";
+import { useProfileImages } from "../hooks/profile/useProfileImages";
+import { useUploadProfileImage } from "../hooks/profile/useUploadProfileImage";
+import { useMyGalleries } from "../hooks/gallery/useMyGallery";
+import { useSuccessMessage } from "../hooks/common/useSuccessMessage";
+
+// Store
import { useAuthStore } from "../store/authStore";
const coverColors = [
@@ -17,262 +27,74 @@ const coverColors = [
];
export default function ProfilePage() {
+ // Router
const navigate = useNavigate();
const { pathname, search, hash, state } = useLocation();
- const [successMessage, setSuccessMessage] = useState(() =>
- typeof state?.successMessage === "string" ? state.successMessage : "",
- );
+ // Success message
+ const { successMessage, setSuccessMessage } = useSuccessMessage();
useEffect(() => {
- if (!successMessage) return;
+ if (typeof state?.successMessage !== "string") return;
+
+ setSuccessMessage(state.successMessage);
navigate(`${pathname}${search}${hash}`, {
replace: true,
state: null,
});
+ }, [state, navigate, pathname, search, hash, setSuccessMessage]);
- const timer = window.setTimeout(() => {
- setSuccessMessage("");
- }, 6000);
-
- return () => window.clearTimeout(timer);
- }, [successMessage, navigate, pathname, search, hash]);
-
+ // Auth
const user = useAuthStore((state) => state.user);
const token = useAuthStore((state) => state.token);
const logout = useAuthStore((state) => state.logout);
+ // UI state
const [coverColor] = useState(() => {
const randomIndex = Math.floor(Math.random() * coverColors.length);
return coverColors[randomIndex];
});
- const [coverImage, setCoverImage] = useState(null);
const [showCreateGallery, setShowCreateGallery] = useState(false);
- const [profileImage, setProfileImage] = useState(null);
- const [galleries, setGalleries] = useState([]);
+ // Galleries
+ const { galleries, isLoading, error, refetchGalleries } = useMyGalleries();
const galleryCoverImages = useGalleryCoverImages(galleries);
- const [isLoading, setIsLoading] = useState(true); // Galleries
- const [isLoadingImages, setIsLoadingImages] = useState(true);
- const [refreshGalleries, setRefreshGalleries] = useState(0);
-
- useEffect(() => {
- async function fetchProfileImage() {
- const query = `
- query {
- me {
- profileImage
- coverImage
- }
- }
- `;
-
- try {
- const response = await fetch(`${import.meta.env.BASE_URL}graphql`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${token ?? ""}`,
- },
- body: JSON.stringify({ query }),
- });
-
- if (!response.ok) {
- throw new Error("Could not fetch profile image");
- }
-
- const result = await response.json();
-
- if (result.errors?.length) {
- throw new Error(result.errors[0].message);
- }
-
- setProfileImage(result.data.me.profileImage);
- setCoverImage(result.data.me.coverImage);
- } catch (error) {
- console.error("Error fetching profile images:", error);
- } finally {
- setIsLoadingImages(false);
- }
- }
- void fetchProfileImage();
- }, [token]);
+ // Profile image hooks
+ const { uploadImage } = useUploadProfileImage(token);
- useEffect(() => {
- async function fetchGalleries() {
- const query = `
- query {
- myGalleries {
- id
- userId
- title
- artworkIds
- }
- }
- `;
-
- try {
- const response = await fetch(`${import.meta.env.BASE_URL}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();
-
- if (result.errors?.length) {
- throw new Error(result.errors[0].message);
- }
-
- setGalleries(result.data.myGalleries);
- } catch (error) {
- console.error("Error fetching galleries:", error);
- } finally {
- setIsLoading(false);
- }
- }
-
- void fetchGalleries();
- }, [refreshGalleries, token]);
+ const {
+ profileImage,
+ coverImage,
+ isLoadingImages,
+ setProfileImage,
+ setCoverImage,
+ } = useProfileImages(token);
+ // Handlers
async function handleCoverImageChange(
event: React.ChangeEvent,
) {
- const file = event.target.files?.[0];
- if (!file) return;
+ const image = await uploadImage(event, "cover");
- if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
- alert("Please select a JPEG, PNG or WebP image");
- return;
+ if (image) {
+ setCoverImage(image);
}
-
- if (file.size > 2_000_000) {
- alert("Image must be smaller than 2 MB");
- return;
- }
-
- const reader = new FileReader();
-
- reader.onload = async () => {
- const image = reader.result;
- if (typeof image !== "string") return;
-
- const mutation = `
- mutation UpdateCoverImage($image: String!) {
- updateCoverImage(image: $image) {
- coverImage
- }
- }
- `;
-
- try {
- const response = await fetch(`${import.meta.env.BASE_URL}graphql`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${token ?? ""}`,
- },
- body: JSON.stringify({
- query: mutation,
- variables: { image },
- }),
- });
-
- if (!response.ok) {
- throw new Error("Could not upload cover image");
- }
-
- const result = await response.json();
-
- if (result.errors?.length) {
- throw new Error(result.errors[0].message);
- }
-
- setCoverImage(result.data.updateCoverImage.coverImage);
- } catch (error) {
- console.error("Error uploading cover image:", error);
- alert("Could not save cover image");
- }
- };
-
- reader.readAsDataURL(file);
}
async function handleProfileImageChange(
event: React.ChangeEvent,
) {
- const file = event.target.files?.[0];
- if (!file) return;
+ const image = await uploadImage(event, "profile");
- if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
- alert("Please select a JPEG, PNG or WebP image");
- return;
+ if (image) {
+ setProfileImage(image);
}
-
- if (file.size > 2_000_000) {
- alert("Image must be smaller than 2 MB");
- return;
- }
-
- const reader = new FileReader();
-
- reader.onload = async () => {
- const image = reader.result;
- if (typeof image !== "string") return;
-
- const mutation = `
- mutation UpdateProfileImage($image: String!) {
- updateProfileImage(image: $image) {
- id
- username
- profileImage
- }
- }
- `;
-
- try {
- const response = await fetch(`${import.meta.env.BASE_URL}graphql`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${token ?? ""}`,
- },
- body: JSON.stringify({
- query: mutation,
- variables: { image },
- }),
- });
-
- if (!response.ok) {
- throw new Error("Could not upload profile image");
- }
-
- const result = await response.json();
-
- if (result.errors?.length) {
- throw new Error(result.errors[0].message);
- }
-
- setProfileImage(result.data.updateProfileImage.profileImage);
- } catch (error) {
- console.error("Error uploading profile image:", error);
- alert("Could not save profile image");
- }
- };
-
- reader.readAsDataURL(file);
}
+ // Logout
function handleLogout() {
logout();
navigate("/");
@@ -307,71 +129,15 @@ export default function ProfilePage() {
Log out
-
- {isLoadingImages ? (
-
- ) : (
- coverImage && (
-
- )
- )}
-
-
- Change
-
-
-
-
-
-
- {isLoadingImages ? (
-
- ) : profileImage ? (
-
- ) : (
-
- Choose image
-
- )}
-
- {/* Hover overlay */}
-
- Change image
-
-
-
-
-
- {user ? user.username : "Not logged in"}
-
-
+
Your Gallery
@@ -387,12 +153,16 @@ export default function ProfilePage() {
{showCreateGallery && (
setShowCreateGallery(false)}
- onCreated={() => setRefreshGalleries((prev) => prev + 1)}
+ onCreated={refetchGalleries}
/>
)}
{isLoading ? (
Loading galleries...
+ ) : error ? (
+
+ {error}
+
) : galleries.length === 0 ? (
No galleries created
diff --git a/frontend/src/test/GalleryPage.test.tsx b/frontend/src/test/GalleryPage.test.tsx
index a79def0..87eb3a1 100644
--- a/frontend/src/test/GalleryPage.test.tsx
+++ b/frontend/src/test/GalleryPage.test.tsx
@@ -2,17 +2,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, act, fireEvent, within } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import GalleryPage from "../pages/GalleryPage";
-import { useGallery } from "../hooks/useGallery";
-import { useRemoveArtworkFromGallery } from "../hooks/useRemoveArtworkFromGallery";
-import { useRenameGallery } from "../hooks/useRenameGallery";
-import { useDeleteGallery } from "../hooks/useDeleteGallery";
+import { useGallery } from "../hooks/gallery/useGallery";
+import { useRemoveArtworkFromGallery } from "../hooks/gallery/useRemoveArtworkFromGallery";
+import { useRenameGalleryMutation } from "../hooks/gallery/useRenameGalleryMutation";
+import { useDeleteGallery } from "../hooks/gallery/useDeleteGallery";
import userEvent from "@testing-library/user-event";
-vi.mock("../hooks/useGallery", () => ({
+vi.mock("../hooks/gallery/useGallery", () => ({
useGallery: vi.fn(),
}));
-vi.mock("../hooks/useRemoveArtworkFromGallery", () => ({
+vi.mock("../hooks/gallery/useRemoveArtworkFromGallery", () => ({
useRemoveArtworkFromGallery: vi.fn(),
}));
@@ -25,19 +25,19 @@ const mockRemoveArtwork =
const mockedUseGallery = vi.mocked(useGallery);
-vi.mock("../hooks/useRenameGallery", () => ({
- useRenameGallery: vi.fn(),
+vi.mock("../hooks/gallery/useRenameGalleryMutation", () => ({
+ useRenameGalleryMutation: vi.fn(),
}));
-vi.mock("../hooks/useDeleteGallery", () => ({
+vi.mock("../hooks/gallery/useDeleteGallery", () => ({
useDeleteGallery: vi.fn(),
}));
-const mockedUseRenameGallery = vi.mocked(useRenameGallery);
+const mockedUseRenameGalleryMutation = vi.mocked(useRenameGalleryMutation);
const mockedUseDeleteGallery = vi.mocked(useDeleteGallery);
const mockRenameGallery =
- vi.fn["renameGallery"]>();
+ vi.fn["renameGallery"]>();
const mockDeleteGallery =
vi.fn["deleteGallery"]>();
@@ -68,7 +68,7 @@ describe("GalleryPage", () => {
beforeEach(() => {
vi.resetAllMocks();
- mockedUseRenameGallery.mockReturnValue({
+ mockedUseRenameGalleryMutation.mockReturnValue({
renameGallery: mockRenameGallery,
renaming: false,
error: "",
@@ -702,7 +702,7 @@ describe("GalleryPage", () => {
mockRenameGallery.mockResolvedValue(null);
- mockedUseRenameGallery.mockReturnValue({
+ mockedUseRenameGalleryMutation.mockReturnValue({
renameGallery: mockRenameGallery,
renaming: false,
error: "Network error",
@@ -717,7 +717,7 @@ describe("GalleryPage", () => {
await user.clear(input);
await user.type(input, "Updated gallery");
- await user.click(screen.getByRole("button", { name: /^Save$/}));
+ await user.click(screen.getByRole("button", { name: /^Save$/ }));
expect(mockRenameGallery).toHaveBeenCalledExactlyOnceWith(
"test-gallery",
diff --git a/frontend/src/types/TypesPlaceHolder.tsx b/frontend/src/types/TypesPlaceHolder.tsx
deleted file mode 100644
index e69de29..0000000