From 20a48ff11bcb94fab2c2934e06140bf72220fcde Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Mon, 9 Nov 2026 14:54:08 +0100 Subject: [PATCH 01/28] refactor: extract delayed loading function to seperate hook --- frontend/src/hooks/useDelayedLoading.ts | 20 ++++ frontend/src/pages/HomePage.tsx | 128 ++++++++++++++++-------- 2 files changed, 108 insertions(+), 40 deletions(-) create mode 100644 frontend/src/hooks/useDelayedLoading.ts diff --git a/frontend/src/hooks/useDelayedLoading.ts b/frontend/src/hooks/useDelayedLoading.ts new file mode 100644 index 0000000..783ac0d --- /dev/null +++ b/frontend/src/hooks/useDelayedLoading.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; + +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/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index f5c346d..13d78c8 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,15 +1,22 @@ import { useState, useEffect, useRef } from "react"; -import { Link, useLocation, useNavigate, useSearchParams } from "react-router-dom"; +import { + Link, + useLocation, + useNavigate, + useSearchParams, +} from "react-router-dom"; import { CircleUserRound } from "lucide-react"; import ArtworkGrid from "../components/ArtworkGrid"; import Hero from "../components/Hero"; import Pagination from "../components/Pagination"; import { useArtworks } from "../hooks/useArtworks"; + import CatalogueControls from "../components/CatalogueControls"; import ThemeToggleButton from "../components/ThemeToggleButton"; import type { ArtworkSort } from "../types/ArtworkSort"; import type { ArtworkPeriod } from "../types/ArtworkPeriod"; import { useAuthStore } from "../store/authStore"; +import { useDelayedLoading } from "../hooks/useDelayedLoading"; const PAGE_SIZE = 12; const MIN_ARTWORK_YEAR = -8000; @@ -19,33 +26,56 @@ export default function HomePage() { const [searchParams, setSearchParams] = useSearchParams(); const location = useLocation(); const navigate = useNavigate(); - const [page, setPage] = useState(() => Math.max(1, Number(searchParams.get("page")) || 1)); + const [page, setPage] = useState(() => + 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 [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 ?? "") + 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 ?? "") + 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; + 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; + 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); @@ -60,7 +90,9 @@ export default function HomePage() { ? "catalogue-controls" : "artwork-catalogue"; document.getElementById(targetId)?.scrollIntoView({ - behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", + behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth", block: "start", }); } @@ -91,17 +123,8 @@ export default function HomePage() { dateFrom, dateTo, ); - useEffect(() => { - if (!loading) return; - - const timer = window.setTimeout(() => { - setShowLoading(true); - }, 300); - return () => { - window.clearTimeout(timer); - }; - }, [loading]); + const showLoading = useDelayedLoading(loading); useEffect(() => { const catalogue = catalogueRef.current; @@ -137,7 +160,9 @@ export default function HomePage() { window.requestAnimationFrame(() => { artworkResultsRef.current?.scrollIntoView({ - behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", + behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth", block: "start", }); }); @@ -152,13 +177,17 @@ export default function HomePage() { try { const savedPosition = sessionStorage.getItem("catalogue-return"); if (savedPosition) { - const parsed = JSON.parse(savedPosition) as { url?: string; scrollY?: number }; + 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; @@ -170,12 +199,21 @@ export default function HomePage() { window.scrollTo(0, returnScrollY); }); if (location.state?.restoreScrollY !== undefined) { - navigate(`${location.pathname}${location.search}`, { replace: true, state: null }); + navigate(`${location.pathname}${location.search}`, { + replace: true, + state: null, + }); } - }, [loading, location.key, location.pathname, location.search, location.state, navigate]); + }, [ + loading, + location.key, + location.pathname, + location.search, + location.state, + navigate, + ]); function handleSearch(value: string) { - setShowLoading(false); const nextSearch = value.trim(); setSearch(nextSearch); setPage(1); @@ -183,67 +221,71 @@ export default function HomePage() { } 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 }); + updateQuery({ + period: value, + dateFrom: undefined, + dateTo: undefined, + page: undefined, + }); } - function handleDateRangeChange(from: number | undefined, to: number | undefined) { - setShowLoading(false); + 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 }); + 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([]); @@ -312,10 +354,16 @@ export default function HomePage() { />
{error &&

Error: {error}

} -

{loading && showLoading ? "Loading artworks..." : ""}

+

+ {loading && showLoading ? "Loading artworks..." : ""} +

-
+
{!error && artworks.length > 0 && } {!loading && !error && artworks.length === 0 && ( From 0c414adb4d27c3af988a85dc4125f9cc611aac5a Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Mon, 9 Nov 2026 15:04:23 +0100 Subject: [PATCH 02/28] refactor: extract element visibility to a seperate hook --- .../hooks/{ => common}/useDelayedLoading.ts | 0 .../src/hooks/common/useElementVisibility.ts | 36 +++++++++++++ frontend/src/pages/HomePage.tsx | 50 ++++++------------- 3 files changed, 52 insertions(+), 34 deletions(-) rename frontend/src/hooks/{ => common}/useDelayedLoading.ts (100%) create mode 100644 frontend/src/hooks/common/useElementVisibility.ts diff --git a/frontend/src/hooks/useDelayedLoading.ts b/frontend/src/hooks/common/useDelayedLoading.ts similarity index 100% rename from frontend/src/hooks/useDelayedLoading.ts rename to frontend/src/hooks/common/useDelayedLoading.ts diff --git a/frontend/src/hooks/common/useElementVisibility.ts b/frontend/src/hooks/common/useElementVisibility.ts new file mode 100644 index 0000000..d6a4a5a --- /dev/null +++ b/frontend/src/hooks/common/useElementVisibility.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect, useState } from "react"; + +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/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 13d78c8..bf90249 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -9,14 +9,17 @@ import { CircleUserRound } from "lucide-react"; import ArtworkGrid from "../components/ArtworkGrid"; import Hero from "../components/Hero"; import Pagination from "../components/Pagination"; -import { useArtworks } from "../hooks/useArtworks"; import CatalogueControls from "../components/CatalogueControls"; import ThemeToggleButton from "../components/ThemeToggleButton"; import type { ArtworkSort } from "../types/ArtworkSort"; import type { ArtworkPeriod } from "../types/ArtworkPeriod"; + +//Hooks +import { useArtworks } from "../hooks/useArtworks"; import { useAuthStore } from "../store/authStore"; -import { useDelayedLoading } from "../hooks/useDelayedLoading"; +import { useDelayedLoading } from "../hooks/common/useDelayedLoading"; +import { useElementVisibility } from "../hooks/common/useElementVisibility"; const PAGE_SIZE = 12; const MIN_ARTWORK_YEAR = -8000; @@ -76,11 +79,18 @@ export default function HomePage() { ? value : undefined; }); + + const { elementRef: catalogueRef, isVisible: galleryVisible } = + useElementVisibility({ + threshold: 0.01, + }); + + const { elementRef: paginationRef, isVisible: paginationVisible } = + useElementVisibility({ + rootMargin: "0px 0px 96px 0px", + }); + 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)); @@ -126,34 +136,6 @@ export default function HomePage() { const showLoading = useDelayedLoading(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; From a1fa227ee5ab571d0a156a6a68f457df1f19e4a1 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Mon, 9 Nov 2026 16:06:24 +0100 Subject: [PATCH 03/28] refactor: extract handlers --- .../hooks/catalogue/useCatalogueFilters.ts | 192 ++++++++++++++++++ frontend/src/pages/HomePage.tsx | 168 +++------------ 2 files changed, 217 insertions(+), 143 deletions(-) create mode 100644 frontend/src/hooks/catalogue/useCatalogueFilters.ts diff --git a/frontend/src/hooks/catalogue/useCatalogueFilters.ts b/frontend/src/hooks/catalogue/useCatalogueFilters.ts new file mode 100644 index 0000000..b17fab7 --- /dev/null +++ b/frontend/src/hooks/catalogue/useCatalogueFilters.ts @@ -0,0 +1,192 @@ +import { useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import type { ArtworkSort } from "../../types/ArtworkSort"; +import type { ArtworkPeriod } from "../../types/ArtworkPeriod"; + +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/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index bf90249..52f0b0a 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -12,73 +12,48 @@ import Pagination from "../components/Pagination"; import CatalogueControls from "../components/CatalogueControls"; import ThemeToggleButton from "../components/ThemeToggleButton"; -import type { ArtworkSort } from "../types/ArtworkSort"; -import type { ArtworkPeriod } from "../types/ArtworkPeriod"; //Hooks import { useArtworks } from "../hooks/useArtworks"; import { useAuthStore } from "../store/authStore"; import { useDelayedLoading } from "../hooks/common/useDelayedLoading"; import { useElementVisibility } from "../hooks/common/useElementVisibility"; +import { useCatalogueFilters } from "../hooks/catalogue/useCatalogueFilters"; const PAGE_SIZE = 12; const MIN_ARTWORK_YEAR = -8000; const MAX_ARTWORK_YEAR = new Date().getFullYear(); export default function HomePage() { - const [searchParams, setSearchParams] = useSearchParams(); + const [searchParams] = useSearchParams(); const location = useLocation(); const navigate = useNavigate(); const [page, setPage] = useState(() => 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 { + search, + updateQuery, + handleSearch, + handleArtistChange, + handleCountryChange, + handleCultureChange, + handleArtPeriodChange, + handleSortChange, + handlePeriodChange, + handleDateRangeChange, + handleReset, + + artists, + countries, + cultures, + artPeriods, + sort, + period, + dateFrom, + dateTo, + } = useCatalogueFilters(setPage); const { elementRef: catalogueRef, isVisible: galleryVisible } = useElementVisibility({ @@ -107,19 +82,6 @@ export default function HomePage() { }); } - 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, @@ -195,92 +157,12 @@ export default function HomePage() { navigate, ]); - 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 handlePageChange(value: number) { shouldScrollToPageRef.current = value > page; setPage(value); updateQuery({ page: value === 1 ? undefined : String(value) }); } - function handleReset() { - setSearch(""); - setArtists([]); - setCountries([]); - setCultures([]); - setArtPeriods([]); - setSort(undefined); - setPeriod(undefined); - setDateFrom(undefined); - setDateTo(undefined); - setPage(1); - setSearchParams({}, { replace: true }); - } - return (
From 0d65fc907c69e469e128bff3df64b4b0c4dc37d6 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Mon, 9 Nov 2026 21:48:08 +0100 Subject: [PATCH 04/28] refactor: extract scroll restoration --- .../useCatalogueScrollRestoration.ts | 56 +++++++++++++++++ frontend/src/pages/HomePage.tsx | 60 ++----------------- 2 files changed, 62 insertions(+), 54 deletions(-) create mode 100644 frontend/src/hooks/catalogue/useCatalogueScrollRestoration.ts diff --git a/frontend/src/hooks/catalogue/useCatalogueScrollRestoration.ts b/frontend/src/hooks/catalogue/useCatalogueScrollRestoration.ts new file mode 100644 index 0000000..762e15d --- /dev/null +++ b/frontend/src/hooks/catalogue/useCatalogueScrollRestoration.ts @@ -0,0 +1,56 @@ +import { useEffect } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +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/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 52f0b0a..f257998 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,15 +1,11 @@ import { useState, useEffect, useRef } from "react"; -import { - Link, - useLocation, - useNavigate, - useSearchParams, -} from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import { CircleUserRound } from "lucide-react"; + +//Components import ArtworkGrid from "../components/ArtworkGrid"; import Hero from "../components/Hero"; import Pagination from "../components/Pagination"; - import CatalogueControls from "../components/CatalogueControls"; import ThemeToggleButton from "../components/ThemeToggleButton"; @@ -19,6 +15,7 @@ import { useAuthStore } from "../store/authStore"; import { useDelayedLoading } from "../hooks/common/useDelayedLoading"; import { useElementVisibility } from "../hooks/common/useElementVisibility"; import { useCatalogueFilters } from "../hooks/catalogue/useCatalogueFilters"; +import { useCatalogueScrollRestoration } from "../hooks/catalogue/useCatalogueScrollRestoration"; const PAGE_SIZE = 12; const MIN_ARTWORK_YEAR = -8000; @@ -26,8 +23,6 @@ const MAX_ARTWORK_YEAR = new Date().getFullYear(); export default function HomePage() { const [searchParams] = useSearchParams(); - const location = useLocation(); - const navigate = useNavigate(); const [page, setPage] = useState(() => Math.max(1, Number(searchParams.get("page")) || 1), ); @@ -97,6 +92,8 @@ export default function HomePage() { ); const showLoading = useDelayedLoading(loading); + useCatalogueScrollRestoration(loading); + const totalPages = Math.ceil(totalCount / PAGE_SIZE); useEffect(() => { if (!shouldScrollToPageRef.current) return; @@ -112,51 +109,6 @@ export default function HomePage() { }); }, [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 { - // 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, - ]); - function handlePageChange(value: number) { shouldScrollToPageRef.current = value > page; setPage(value); From 2528d319cbf97ba082258ffde1391f604df311fc Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 09:48:15 +0100 Subject: [PATCH 05/28] docs: add comments explaining the central function --- frontend/src/hooks/catalogue/useCatalogueFilters.ts | 1 + frontend/src/hooks/catalogue/useCatalogueScrollRestoration.ts | 1 + frontend/src/hooks/common/useDelayedLoading.ts | 1 + frontend/src/hooks/common/useElementVisibility.ts | 1 + 4 files changed, 4 insertions(+) diff --git a/frontend/src/hooks/catalogue/useCatalogueFilters.ts b/frontend/src/hooks/catalogue/useCatalogueFilters.ts index b17fab7..55ce47d 100644 --- a/frontend/src/hooks/catalogue/useCatalogueFilters.ts +++ b/frontend/src/hooks/catalogue/useCatalogueFilters.ts @@ -3,6 +3,7 @@ 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") ?? ""); diff --git a/frontend/src/hooks/catalogue/useCatalogueScrollRestoration.ts b/frontend/src/hooks/catalogue/useCatalogueScrollRestoration.ts index 762e15d..65e7da4 100644 --- a/frontend/src/hooks/catalogue/useCatalogueScrollRestoration.ts +++ b/frontend/src/hooks/catalogue/useCatalogueScrollRestoration.ts @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { useLocation, useNavigate } from "react-router-dom"; +//Restores scrollposition export function useCatalogueScrollRestoration(loading: boolean) { const location = useLocation(); const navigate = useNavigate(); diff --git a/frontend/src/hooks/common/useDelayedLoading.ts b/frontend/src/hooks/common/useDelayedLoading.ts index 783ac0d..e581a81 100644 --- a/frontend/src/hooks/common/useDelayedLoading.ts +++ b/frontend/src/hooks/common/useDelayedLoading.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; +//Handles delayed loading indicator export function useDelayedLoading(loading: boolean, delay = 300) { const [delayedLoading, setDelayedLoading] = useState(false); diff --git a/frontend/src/hooks/common/useElementVisibility.ts b/frontend/src/hooks/common/useElementVisibility.ts index d6a4a5a..71a1e4c 100644 --- a/frontend/src/hooks/common/useElementVisibility.ts +++ b/frontend/src/hooks/common/useElementVisibility.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from "react"; +//Observes whether elements are visible export function useElementVisibility( options?: IntersectionObserverInit, ) { From 1ad0b3f058206b1afa8378639763c89fb4a8a8dd Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 10:23:34 +0100 Subject: [PATCH 06/28] refactor: rearrange and comment code --- frontend/src/pages/HomePage.tsx | 65 ++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index f257998..59b7620 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -2,31 +2,47 @@ import { useState, useEffect, useRef } from "react"; import { Link, useSearchParams } from "react-router-dom"; import { CircleUserRound } from "lucide-react"; -//Components +// Components import ArtworkGrid from "../components/ArtworkGrid"; import Hero from "../components/Hero"; import Pagination from "../components/Pagination"; import CatalogueControls from "../components/CatalogueControls"; import ThemeToggleButton from "../components/ThemeToggleButton"; -//Hooks +// Hooks import { useArtworks } from "../hooks/useArtworks"; -import { useAuthStore } from "../store/authStore"; 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 HomePage() { + // 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, @@ -50,6 +66,7 @@ export default function HomePage() { dateTo, } = useCatalogueFilters(setPage); + // Element visiility const { elementRef: catalogueRef, isVisible: galleryVisible } = useElementVisibility({ threshold: 0.01, @@ -60,23 +77,7 @@ export default function HomePage() { rootMargin: "0px 0px 96px 0px", }); - const [showFilters, setShowFilters] = useState(false); - 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", - }); - } - + // Artwork data const { artworks, totalCount, loading, error } = useArtworks( page, PAGE_SIZE, @@ -91,10 +92,14 @@ export default function HomePage() { 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; @@ -109,6 +114,19 @@ export default function HomePage() { }); }, [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); @@ -117,7 +135,10 @@ export default function HomePage() { return (
-
+
{!error && artworks.length > 0 && } From 70687b72b8d7b11ed8c9c23b13a4768b425db47c Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 10:49:26 +0100 Subject: [PATCH 07/28] refactor: extract successMessage on gallery page --- .../src/hooks/common/useSuccessMessage.ts | 21 ++++++++++++ frontend/src/pages/GalleryPage.tsx | 33 ++++++++----------- 2 files changed, 35 insertions(+), 19 deletions(-) create mode 100644 frontend/src/hooks/common/useSuccessMessage.ts 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/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 0ac15a8..40e5d09 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -1,15 +1,20 @@ 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 type { Artwork } from "../types/Artwork"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Trash2, Pencil } from "lucide-react"; + +// Components +import DeleteGalleryDialog from "../components/DeleteGalleryDialog"; import ArtworkGrid from "../components/ArtworkGrid"; import Pagination from "../components/Pagination"; +// Hooks +import { useGallery } from "../hooks/useGallery"; +import { useRemoveArtworkFromGallery } from "../hooks/useRemoveArtworkFromGallery"; +import { useRenameGallery } from "../hooks/useRenameGallery"; +import { useDeleteGallery } from "../hooks/useDeleteGallery"; +import { useSuccessMessage } from "../hooks/common/useSuccessMessage"; + export default function GalleryPage() { const { id = "" } = useParams(); const navigate = useNavigate(); @@ -20,7 +25,7 @@ export default function GalleryPage() { const pageSize = 12; const [refreshKey, setRefreshKey] = useState(0); - const [successMessage, setSuccessMessage] = useState(""); + const { successMessage, setSuccessMessage } = useSuccessMessage(); const [isEditing, setIsEditing] = useState(false); const [titleInput, setTitleInput] = useState(""); const [isRenaming, setIsRenaming] = useState(false); @@ -41,16 +46,6 @@ export default function GalleryPage() { } = useRemoveArtworkFromGallery(); const totalPages = Math.ceil((gallery?.totalCount ?? 0) / pageSize); - useEffect(() => { - if (!successMessage) return; - - const timer = window.setTimeout(() => { - setSuccessMessage(""); - }, 6000); - - return () => window.clearTimeout(timer); - }, [successMessage]); - function handleToggleEditing() { setIsRenaming(false); setIsEditing((current) => !current); @@ -199,7 +194,7 @@ export default function GalleryPage() { ) : (
-

+

{gallery.title}

@@ -269,7 +264,7 @@ export default function GalleryPage() {

) : ( <> -
+
Date: Tue, 10 Nov 2026 10:56:15 +0100 Subject: [PATCH 08/28] refactor: extract gallery name functions to a seperate hook --- .../src/hooks/gallery/useGalleryRename.ts | 44 +++++++++++++++++ frontend/src/pages/GalleryPage.tsx | 49 +++++++------------ 2 files changed, 63 insertions(+), 30 deletions(-) create mode 100644 frontend/src/hooks/gallery/useGalleryRename.ts diff --git a/frontend/src/hooks/gallery/useGalleryRename.ts b/frontend/src/hooks/gallery/useGalleryRename.ts new file mode 100644 index 0000000..919df9c --- /dev/null +++ b/frontend/src/hooks/gallery/useGalleryRename.ts @@ -0,0 +1,44 @@ +import { useState } from "react"; +import { useRenameGallery } from "../useRenameGallery"; + +// 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 } = useRenameGallery(); + + 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/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 40e5d09..c207f60 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -10,8 +10,8 @@ import Pagination from "../components/Pagination"; // Hooks import { useGallery } from "../hooks/useGallery"; +import { useGalleryRename } from "../hooks/gallery/useGalleryRename"; import { useRemoveArtworkFromGallery } from "../hooks/useRemoveArtworkFromGallery"; -import { useRenameGallery } from "../hooks/useRenameGallery"; import { useDeleteGallery } from "../hooks/useDeleteGallery"; import { useSuccessMessage } from "../hooks/common/useSuccessMessage"; @@ -26,11 +26,23 @@ export default function GalleryPage() { const [refreshKey, setRefreshKey] = useState(0); const { successMessage, setSuccessMessage } = useSuccessMessage(); - const [isEditing, setIsEditing] = useState(false); - const [titleInput, setTitleInput] = useState(""); - const [isRenaming, setIsRenaming] = useState(false); - const { renameGallery, renaming, error: renameError } = useRenameGallery(); + const { + isRenaming, + setIsRenaming, + titleInput, + setTitleInput, + renaming, + renameError, + handleStartRenaming, + handleCancelRenaming, + handleRename, + } = useGalleryRename(id, () => { + setRefreshKey((current) => current + 1); + setSuccessMessage("Gallery name updated."); + }); + + const [isEditing, setIsEditing] = useState(false); const { gallery, loading, error } = useGallery( id, @@ -51,30 +63,6 @@ export default function GalleryPage() { 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); @@ -149,6 +137,7 @@ export default function GalleryPage() {
{ event.preventDefault(); + setSuccessMessage(""); void handleRename(); }} onKeyDown={(event) => { @@ -201,7 +190,7 @@ export default function GalleryPage() { {isEditing && ( + + +
+ ) : ( +
+

+ {title} +

+ + {isEditing && ( + + )} +
+ )} + + {isRenaming && renameError && ( +

+ Could not rename gallery: {renameError} +

+ )} +
+ +
+

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

+ + +
+ + ); +} diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 92ad40e..ac87b8b 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -1,12 +1,13 @@ import { Link, useParams, useNavigate } from "react-router-dom"; import type { Artwork } from "../types/Artwork"; import { useState } from "react"; -import { Trash2, Pencil } from "lucide-react"; +import { Trash2 } from "lucide-react"; // Components -import DeleteGalleryDialog from "../components/DeleteGalleryDialog"; +import GalleryHeader from "../components/gallery/GalleryHeader"; import ArtworkGrid from "../components/ArtworkGrid"; import Pagination from "../components/Pagination"; +import DeleteGalleryDialog from "../components/DeleteGalleryDialog"; // Hooks import { useGallery } from "../hooks/gallery/useGallery"; @@ -15,6 +16,8 @@ import { useRemoveArtworkFromGallery } from "../hooks/gallery/useRemoveArtworkFr import { useDeleteGallery } from "../hooks/gallery/useDeleteGallery"; import { useSuccessMessage } from "../hooks/common/useSuccessMessage"; +const PAGE_SIZE = 12; + export default function GalleryPage() { const { id = "" } = useParams(); const navigate = useNavigate(); @@ -22,7 +25,6 @@ export default function GalleryPage() { const { deleteGallery, deleting, error: deleteError } = useDeleteGallery(); const [page, setPage] = useState(1); - const pageSize = 12; const [refreshKey, setRefreshKey] = useState(0); const { successMessage, setSuccessMessage } = useSuccessMessage(); @@ -47,7 +49,7 @@ export default function GalleryPage() { const { gallery, loading, error } = useGallery( id, page, - pageSize, + PAGE_SIZE, refreshKey, ); @@ -56,7 +58,7 @@ export default function GalleryPage() { removingId, error: removeError, } = useRemoveArtworkFromGallery(); - const totalPages = Math.ceil((gallery?.totalCount ?? 0) / pageSize); + const totalPages = Math.ceil((gallery?.totalCount ?? 0) / PAGE_SIZE); function handleToggleEditing() { setIsRenaming(false); @@ -83,7 +85,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)); @@ -131,103 +133,23 @@ export default function GalleryPage() {

Gallery not found

) : (
-
-
- {isRenaming ? ( -
{ - event.preventDefault(); - setSuccessMessage(""); - void handleRename(); - }} - onKeyDown={(event) => { - if (event.key === "Escape" && !renaming) { - event.preventDefault(); - handleCancelRenaming(); - } - }} - className="flex max-w-xl flex-wrap items-center gap-3" - > - - - input?.focus()} - id="gallery-title" - name="title" - type="text" - value={titleInput} - onChange={(event) => setTitleInput(event.target.value)} - required - disabled={renaming} - className="min-h-11 w-full rounded border border-gray-300 bg-white px-3 py-2 font-serif text-3xl text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-700 disabled:opacity-50 dark:border-[#504b46] dark:bg-[#242321] dark:text-[#e6dfd6] sm:text-4xl" - /> - - - - -
- ) : ( -
-

- {gallery.title} -

- - {isEditing && ( - - )} -
- )} - - {isRenaming && renameError && ( -

- Could not rename gallery: {renameError} -

- )} -
- -
-

- {gallery.totalCount}{" "} - {gallery.totalCount === 1 ? "artwork" : "artworks"} -

- - -
-
+ { + setSuccessMessage(""); + void handleRename(); + }} + /> {isEditing && (
From 7441a72bde3fe5cb7f6f95fe4853cd6e830e99f3 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 11:19:26 +0100 Subject: [PATCH 14/28] refactor: move gallery components to new gallery folder --- frontend/src/components/{ => gallery}/CreateGalleryModal.tsx | 2 +- frontend/src/components/{ => gallery}/DeleteGalleryDialog.tsx | 2 +- frontend/src/components/{ => gallery}/GalleryCard.tsx | 4 +--- frontend/src/pages/GalleryPage.tsx | 2 +- frontend/src/pages/ProfilePage.tsx | 4 ++-- 5 files changed, 6 insertions(+), 8 deletions(-) rename frontend/src/components/{ => gallery}/CreateGalleryModal.tsx (98%) rename frontend/src/components/{ => gallery}/DeleteGalleryDialog.tsx (97%) rename frontend/src/components/{ => gallery}/GalleryCard.tsx (95%) 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({
)} -

- {title} -

+

{title}

{artworkCount} {artworkCount === 1 ? "artwork" : "artworks"} diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index ac87b8b..4ca2afc 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -7,7 +7,7 @@ import { Trash2 } from "lucide-react"; import GalleryHeader from "../components/gallery/GalleryHeader"; import ArtworkGrid from "../components/ArtworkGrid"; import Pagination from "../components/Pagination"; -import DeleteGalleryDialog from "../components/DeleteGalleryDialog"; +import DeleteGalleryDialog from "../components/gallery/DeleteGalleryDialog"; // Hooks import { useGallery } from "../hooks/gallery/useGallery"; diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index f9ba18c..c2d66ee 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -1,8 +1,8 @@ -import GalleryCard from "../components/GalleryCard"; +import GalleryCard from "../components/gallery/GalleryCard"; import { LucidePlus } from "lucide-react"; import { useEffect, useState } from "react"; import type { Gallery } from "../types/Gallery"; -import CreateGalleryModal from "../components/CreateGalleryModal"; +import CreateGalleryModal from "../components/gallery/CreateGalleryModal"; import { useGalleryCoverImages } from "../hooks/useGalleryCoverImages"; import { Link, useNavigate, useLocation } from "react-router-dom"; import { useAuthStore } from "../store/authStore"; From 63a0ac8092ac633f3057afba068ce736bedebcd6 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 11:20:23 +0100 Subject: [PATCH 15/28] refactor: add AddToGalleryModal.tsx to gallery folder --- .../src/components/{ => gallery}/AddToGalleryModal.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) rename frontend/src/components/{ => gallery}/AddToGalleryModal.tsx (95%) 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. From a66ea025a82c2fadbde3042f122110dab888868c Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 11:23:19 +0100 Subject: [PATCH 16/28] refactor: update paths --- frontend/src/components/ArtworkCard.tsx | 37 +++++++++++++++++------- frontend/src/pages/ArtworkDetailPage.tsx | 34 ++++++++++++++++------ 2 files changed, 52 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/ArtworkCard.tsx b/frontend/src/components/ArtworkCard.tsx index 18dd6a8..6896d30 100644 --- a/frontend/src/components/ArtworkCard.tsx +++ b/frontend/src/components/ArtworkCard.tsx @@ -3,7 +3,7 @@ import { Link, useLocation, useNavigate } from "react-router-dom"; import { Plus } from "lucide-react"; import type { Artwork } from "../types/Artwork"; import { useAuthStore } from "../store/authStore"; -import AddToGalleryModal from "./AddToGalleryModal"; +import AddToGalleryModal from "./gallery/AddToGalleryModal"; import "./ArtworkCard.css"; type ArtworkCardProps = { @@ -15,7 +15,11 @@ function ArtworkImage({ src, alt }: { src: string; alt: string }) { const [hasError, setHasError] = useState(false); if (hasError) { - 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 && (
@@ -143,7 +151,9 @@ export default function ArtworkDetailPage() { {artwork.medium && (

Medium

-

{artwork.medium}

+

+ {artwork.medium} +

)} @@ -157,21 +167,27 @@ export default function ArtworkDetailPage() { {artwork.culture && (

Culture

-

{artwork.culture}

+

+ {artwork.culture} +

)} {artwork.period && (

Period

-

{artwork.period}

+

+ {artwork.period} +

)} {artwork.dimensions && (

Dimensions

-

{artwork.dimensions}

+

+ {artwork.dimensions} +

)}
From eb19c3ff9910ef553bbc47492e3c7c367e7e02a3 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 11:42:55 +0100 Subject: [PATCH 17/28] refactor: rearrange and comment GallertPage.tsx --- frontend/src/pages/GalleryPage.tsx | 31 +++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 4ca2afc..7258af9 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -1,8 +1,10 @@ import { Link, useParams, useNavigate } from "react-router-dom"; -import type { Artwork } from "../types/Artwork"; import { useState } from "react"; import { Trash2 } from "lucide-react"; +// Types +import type { Artwork } from "../types/Artwork"; + // Components import GalleryHeader from "../components/gallery/GalleryHeader"; import ArtworkGrid from "../components/ArtworkGrid"; @@ -19,15 +21,26 @@ 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 [refreshKey, setRefreshKey] = useState(0); + const [isEditing, setIsEditing] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + // Hooks const { successMessage, setSuccessMessage } = useSuccessMessage(); + const { deleteGallery, deleting, error: deleteError } = useDeleteGallery(); + + const { gallery, loading, error } = useGallery( + id, + page, + PAGE_SIZE, + refreshKey, + ); const { isRenaming, @@ -44,15 +57,6 @@ export default function GalleryPage() { setSuccessMessage("Gallery name updated."); }); - const [isEditing, setIsEditing] = useState(false); - - const { gallery, loading, error } = useGallery( - id, - page, - PAGE_SIZE, - refreshKey, - ); - const { removeArtwork, removingId, @@ -60,6 +64,7 @@ export default function GalleryPage() { } = useRemoveArtworkFromGallery(); const totalPages = Math.ceil((gallery?.totalCount ?? 0) / PAGE_SIZE); + // Handlers function handleToggleEditing() { setIsRenaming(false); setIsEditing((current) => !current); From 4991588491e0aec185f8e7a3d231a171920ff11a Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 11:52:30 +0100 Subject: [PATCH 18/28] refactor: extract ArtworkDetails --- .../src/components/artwork/ArtworkDetails.tsx | 41 ++++++ frontend/src/pages/ArtworkDetailPage.tsx | 118 +++--------------- 2 files changed, 57 insertions(+), 102 deletions(-) create mode 100644 frontend/src/components/artwork/ArtworkDetails.tsx 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/pages/ArtworkDetailPage.tsx b/frontend/src/pages/ArtworkDetailPage.tsx index 45ac8de..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"; + +// 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,55 +85,7 @@ export default function ArtworkDetailPage() {

{artwork.date_display}

)}
- -
-

Details

- -
- {artwork.medium && ( -
-

Medium

-

- {artwork.medium} -

-
- )} - - {place && ( -
-

Place

-

{place}

-
- )} - - {artwork.culture && ( -
-

Culture

-

- {artwork.culture} -

-
- )} - - {artwork.period && ( -
-

Period

-

- {artwork.period} -

-
- )} - - {artwork.dimensions && ( -
-

Dimensions

-

- {artwork.dimensions} -

-
- )} -
-
+
{/* Artwork image */} @@ -144,54 +105,7 @@ export default function ArtworkDetailPage() {
{/* Details */} -
-

Details

- -
- {artwork.medium && ( -
-

Medium

-

- {artwork.medium} -

-
- )} - - {place && ( -
-

Place

-

{place}

-
- )} - - {artwork.culture && ( -
-

Culture

-

- {artwork.culture} -

-
- )} - - {artwork.period && ( -
-

Period

-

- {artwork.period} -

-
- )} - - {artwork.dimensions && ( -
-

Dimensions

-

- {artwork.dimensions} -

-
- )} -
-
+ {showGalleryModal && ( Date: Tue, 10 Nov 2026 11:56:09 +0100 Subject: [PATCH 19/28] refactor: move artwork related files to designated folder --- frontend/src/components/{ => artwork}/ArtworkCard.css | 0 frontend/src/components/{ => artwork}/ArtworkCard.tsx | 6 +++--- frontend/src/components/{ => artwork}/ArtworkGrid.css | 0 frontend/src/components/{ => artwork}/ArtworkGrid.tsx | 7 +++++-- frontend/src/pages/GalleryPage.tsx | 2 +- 5 files changed, 9 insertions(+), 6 deletions(-) rename frontend/src/components/{ => artwork}/ArtworkCard.css (100%) rename frontend/src/components/{ => artwork}/ArtworkCard.tsx (95%) rename frontend/src/components/{ => artwork}/ArtworkGrid.css (100%) rename frontend/src/components/{ => artwork}/ArtworkGrid.tsx (80%) diff --git a/frontend/src/components/ArtworkCard.css b/frontend/src/components/artwork/ArtworkCard.css similarity index 100% rename from frontend/src/components/ArtworkCard.css rename to frontend/src/components/artwork/ArtworkCard.css diff --git a/frontend/src/components/ArtworkCard.tsx b/frontend/src/components/artwork/ArtworkCard.tsx similarity index 95% rename from frontend/src/components/ArtworkCard.tsx rename to frontend/src/components/artwork/ArtworkCard.tsx index 6896d30..68da07f 100644 --- a/frontend/src/components/ArtworkCard.tsx +++ b/frontend/src/components/artwork/ArtworkCard.tsx @@ -1,9 +1,9 @@ import { useState } from "react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { Plus } from "lucide-react"; -import type { Artwork } from "../types/Artwork"; -import { useAuthStore } from "../store/authStore"; -import AddToGalleryModal from "./gallery/AddToGalleryModal"; +import type { Artwork } from "../../types/Artwork"; +import { useAuthStore } from "../../store/authStore"; +import AddToGalleryModal from "../gallery/AddToGalleryModal"; import "./ArtworkCard.css"; type ArtworkCardProps = { 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/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 7258af9..2dd36b8 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -7,7 +7,7 @@ import type { Artwork } from "../types/Artwork"; // Components import GalleryHeader from "../components/gallery/GalleryHeader"; -import ArtworkGrid from "../components/ArtworkGrid"; +import ArtworkGrid from "../components/artwork/ArtworkGrid"; import Pagination from "../components/Pagination"; import DeleteGalleryDialog from "../components/gallery/DeleteGalleryDialog"; From 5828473b0357b37fc5f5a9a49a64bc7fbf84f838 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 11:57:41 +0100 Subject: [PATCH 20/28] refactor: update path --- frontend/src/pages/ArtCataloguePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/ArtCataloguePage.tsx b/frontend/src/pages/ArtCataloguePage.tsx index 59b7620..7db0444 100644 --- a/frontend/src/pages/ArtCataloguePage.tsx +++ b/frontend/src/pages/ArtCataloguePage.tsx @@ -3,7 +3,7 @@ import { Link, useSearchParams } from "react-router-dom"; import { CircleUserRound } from "lucide-react"; // Components -import ArtworkGrid from "../components/ArtworkGrid"; +import ArtworkGrid from "../components/artwork/ArtworkGrid"; import Hero from "../components/Hero"; import Pagination from "../components/Pagination"; import CatalogueControls from "../components/CatalogueControls"; From 70c9006779a068ff0483bd7660bafbb39e043199 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 12:27:47 +0100 Subject: [PATCH 21/28] refactor: extract Register functions to seperate hook --- frontend/src/hooks/auth/useRegister.ts | 121 +++++++++++++++++++++++ frontend/src/pages/CreateAccountPage.tsx | 112 +++------------------ 2 files changed, 135 insertions(+), 98 deletions(-) create mode 100644 frontend/src/hooks/auth/useRegister.ts 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/pages/CreateAccountPage.tsx b/frontend/src/pages/CreateAccountPage.tsx index 3a72d14..afd7883 100644 --- a/frontend/src/pages/CreateAccountPage.tsx +++ b/frontend/src/pages/CreateAccountPage.tsx @@ -1,111 +1,28 @@ import { useState, type SubmitEvent as ReactSubmitEvent } from "react"; import { Link, useNavigate } from "react-router-dom"; -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?: { 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}

    )} From 7b6207844d2eff472f0a3a9a0dc78e551d6a8258 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 12:32:06 +0100 Subject: [PATCH 22/28] refactor: extract login functions --- frontend/src/hooks/auth/useLogin.ts | 81 +++++++++++++++++++++++++++++ frontend/src/pages/LoginPage.tsx | 79 ++++++---------------------- 2 files changed, 97 insertions(+), 63 deletions(-) create mode 100644 frontend/src/hooks/auth/useLogin.ts 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/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}

    )} From da0cfa35e02988cec2e5d371c375f260d8554c8a Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 12:47:39 +0100 Subject: [PATCH 23/28] refactor: extract image functions from ProfilePage --- .../src/hooks/profile/useProfileImages.ts | 87 ++++++++ .../hooks/profile/useUploadProfileImage.ts | 116 ++++++++++ frontend/src/pages/ProfilePage.tsx | 200 ++++-------------- 3 files changed, 240 insertions(+), 163 deletions(-) create mode 100644 frontend/src/hooks/profile/useProfileImages.ts create mode 100644 frontend/src/hooks/profile/useUploadProfileImage.ts 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/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index c2d66ee..3e9542f 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -1,10 +1,20 @@ -import GalleryCard from "../components/gallery/GalleryCard"; import { LucidePlus } from "lucide-react"; import { useEffect, useState } from "react"; +import { Link, useNavigate, useLocation } from "react-router-dom"; + +// Types import type { Gallery } from "../types/Gallery"; + +// Components +import GalleryCard from "../components/gallery/GalleryCard"; import CreateGalleryModal from "../components/gallery/CreateGalleryModal"; + +// Hooks import { useGalleryCoverImages } from "../hooks/useGalleryCoverImages"; -import { Link, useNavigate, useLocation } from "react-router-dom"; +import { useProfileImages } from "../hooks/profile/useProfileImages"; +import { useUploadProfileImage } from "../hooks/profile/useUploadProfileImage"; + +// Store import { useAuthStore } from "../store/authStore"; const coverColors = [ @@ -17,9 +27,11 @@ const coverColors = [ ]; export default function ProfilePage() { + // Router const navigate = useNavigate(); const { pathname, search, hash, state } = useLocation(); + // Success message const [successMessage, setSuccessMessage] = useState(() => typeof state?.successMessage === "string" ? state.successMessage : "", ); @@ -39,68 +51,36 @@ export default function ProfilePage() { 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); + // Galleries const [galleries, setGalleries] = useState([]); 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); + const { + profileImage, + coverImage, + isLoadingImages, + setProfileImage, + setCoverImage, + } = useProfileImages(token); + + // Fetch galleries useEffect(() => { async function fetchGalleries() { const query = ` @@ -145,134 +125,28 @@ export default function ProfilePage() { void fetchGalleries(); }, [refreshGalleries, token]); + // Handlers async function handleCoverImageChange( event: React.ChangeEvent, ) { - const file = event.target.files?.[0]; - if (!file) return; - - if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) { - alert("Please select a JPEG, PNG or WebP image"); - return; - } + const image = await uploadImage(event, "cover"); - if (file.size > 2_000_000) { - alert("Image must be smaller than 2 MB"); - return; + if (image) { + setCoverImage(image); } - - 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("/"); @@ -363,7 +237,7 @@ export default function ProfilePage() { From 4a2d76072e384f17a477348baee27c50b931bd21 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 14:55:54 +0100 Subject: [PATCH 24/28] refactor: extract gallery functions to seperate hook --- frontend/src/hooks/gallery/useMyGallery.ts | 94 ++++++++++++++++++++++ frontend/src/pages/ProfilePage.tsx | 60 ++------------ 2 files changed, 102 insertions(+), 52 deletions(-) create mode 100644 frontend/src/hooks/gallery/useMyGallery.ts 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/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 3e9542f..6858900 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -2,9 +2,6 @@ import { LucidePlus } from "lucide-react"; import { useEffect, useState } from "react"; import { Link, useNavigate, useLocation } from "react-router-dom"; -// Types -import type { Gallery } from "../types/Gallery"; - // Components import GalleryCard from "../components/gallery/GalleryCard"; import CreateGalleryModal from "../components/gallery/CreateGalleryModal"; @@ -13,6 +10,7 @@ import CreateGalleryModal from "../components/gallery/CreateGalleryModal"; import { useGalleryCoverImages } from "../hooks/useGalleryCoverImages"; import { useProfileImages } from "../hooks/profile/useProfileImages"; import { useUploadProfileImage } from "../hooks/profile/useUploadProfileImage"; +import { useMyGalleries } from "../hooks/gallery/useMyGallery"; // Store import { useAuthStore } from "../store/authStore"; @@ -65,13 +63,12 @@ export default function ProfilePage() { const [showCreateGallery, setShowCreateGallery] = useState(false); // Galleries - const [galleries, setGalleries] = useState([]); + const { galleries, isLoading, error, refetchGalleries } = useMyGalleries(); const galleryCoverImages = useGalleryCoverImages(galleries); - const [isLoading, setIsLoading] = useState(true); // Galleries - const [refreshGalleries, setRefreshGalleries] = useState(0); // Profile image hooks const { uploadImage } = useUploadProfileImage(token); + const { profileImage, coverImage, @@ -80,51 +77,6 @@ export default function ProfilePage() { setCoverImage, } = useProfileImages(token); - // Fetch galleries - 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]); - // Handlers async function handleCoverImageChange( event: React.ChangeEvent, @@ -261,12 +213,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 From bbe1fe0ed11da4035775dae8c6486263abe7b8e9 Mon Sep 17 00:00:00 2001 From: Emma Fredriksen Date: Tue, 10 Nov 2026 15:10:01 +0100 Subject: [PATCH 25/28] refactor: extract profile header --- .../src/components/profile/ProfileHeader.tsx | 92 +++++++++++++++++++ frontend/src/pages/ProfilePage.tsx | 75 ++------------- 2 files changed, 102 insertions(+), 65 deletions(-) create mode 100644 frontend/src/components/profile/ProfileHeader.tsx 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 && ( + Cover + ) + )} + + + + +
    + +
    +