diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 02a900a..e7be7c5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5119,23 +5119,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", - "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3b608eb..8886843 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { Routes, Route, useLocation } from "react-router-dom"; import "./index.css"; -import HomePage from "./pages/HomePage"; +import ArtCataloguePage from "./pages/ArtCataloguePage"; import Login from "./pages/LoginPage"; import CreateAccount from "./pages/CreateAccountPage"; import ProfilePage from "./pages/ProfilePage"; @@ -17,10 +17,12 @@ function App() { const showFooter = pathname !== "/login" && pathname !== "/create-account"; return ( -
+
- } /> + } /> } /> } /> Image unavailable
; + return ( +
+ Image unavailable +
+ ); } return ( @@ -31,21 +35,28 @@ function ArtworkImage({ src, alt }: { src: string; alt: string }) { ); } -export default function ArtworkCard({ artwork, showGalleryAction = true }: ArtworkCardProps) { +export default function ArtworkCard({ + artwork, + showGalleryAction = true, +}: ArtworkCardProps) { const artist = artwork.artist_display?.trim() || "Unknown artist"; const location = useLocation(); const navigate = useNavigate(); const isLoggedIn = useAuthStore((state) => Boolean(state.token)); const [showGalleryModal, setShowGalleryModal] = useState(false); -// Made with AI help. + // Made with AI help. function rememberCataloguePosition() { try { - sessionStorage.setItem("catalogue-return", JSON.stringify({ - url: `${location.pathname}${location.search}`, - scrollY: window.scrollY, - })); + sessionStorage.setItem( + "catalogue-return", + JSON.stringify({ + url: `${location.pathname}${location.search}`, + scrollY: window.scrollY, + }), + ); } catch { + // Catch } } @@ -64,7 +75,9 @@ export default function ArtworkCard({ artwork, showGalleryAction = true }: Artwo alt={artwork.image_url || `${artwork.title} by ${artist}`} /> ) : ( -
Image unavailable
+
+ Image unavailable +
)}

{artwork.title}

@@ -74,7 +87,11 @@ export default function ArtworkCard({ artwork, showGalleryAction = true }: Artwo {showGalleryAction && (
{loading ? ( -

Loading galleries...

+

+ Loading galleries... +

) : galleries.length === 0 ? (

You have not created any galleries yet. diff --git a/frontend/src/components/CreateGalleryModal.tsx b/frontend/src/components/gallery/CreateGalleryModal.tsx similarity index 98% rename from frontend/src/components/CreateGalleryModal.tsx rename to frontend/src/components/gallery/CreateGalleryModal.tsx index bcbb283..cf84f35 100644 --- a/frontend/src/components/CreateGalleryModal.tsx +++ b/frontend/src/components/gallery/CreateGalleryModal.tsx @@ -1,6 +1,6 @@ import { LucideX } from "lucide-react"; import { useState } from "react"; -import { useAuthStore } from "../store/authStore"; +import { useAuthStore } from "../../store/authStore"; type CreateGalleryModalProps = { onClose: () => void; diff --git a/frontend/src/components/DeleteGalleryDialog.tsx b/frontend/src/components/gallery/DeleteGalleryDialog.tsx similarity index 97% rename from frontend/src/components/DeleteGalleryDialog.tsx rename to frontend/src/components/gallery/DeleteGalleryDialog.tsx index 1610456..9e7d6f8 100644 --- a/frontend/src/components/DeleteGalleryDialog.tsx +++ b/frontend/src/components/gallery/DeleteGalleryDialog.tsx @@ -48,7 +48,7 @@ export default function DeleteGalleryDialog({

- {title} -

+

{title}

{artworkCount} {artworkCount === 1 ? "artwork" : "artworks"} diff --git a/frontend/src/components/gallery/GalleryHeader.tsx b/frontend/src/components/gallery/GalleryHeader.tsx new file mode 100644 index 0000000..b14de6a --- /dev/null +++ b/frontend/src/components/gallery/GalleryHeader.tsx @@ -0,0 +1,130 @@ +import { Pencil } from "lucide-react"; + +type GalleryHeaderProps = { + title: string; + totalCount: number; + isEditing: boolean; + isRenaming: boolean; + titleInput: string; + renaming: boolean; + renameError: string; + onTitleChange: (value: string) => void; + onToggleEditing: () => void; + onStartRenaming: (title: string) => void; + onCancelRenaming: () => void; + onRename: () => void; +}; + +// Displays gallery details and controls for editing the gallery name +export default function GalleryHeader({ + title, + totalCount, + isEditing, + isRenaming, + titleInput, + renaming, + renameError, + onTitleChange, + onToggleEditing, + onStartRenaming, + onCancelRenaming, + onRename, +}: GalleryHeaderProps) { + return ( +

+
+ {isRenaming ? ( +
{ + event.preventDefault(); + onRename(); + }} + onKeyDown={(event) => { + if (event.key === "Escape" && !renaming) { + event.preventDefault(); + onCancelRenaming(); + } + }} + className="flex max-w-xl flex-wrap items-center gap-3" + > + + + input?.focus()} + id="gallery-title" + name="title" + type="text" + value={titleInput} + onChange={(event) => onTitleChange(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" + /> + + + + +
+ ) : ( +
+

+ {title} +

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

+ Could not rename gallery: {renameError} +

+ )} +
+ +
+

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

+ + +
+
+ ); +} 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 + ) + )} + + + + +
+ +
+
- -
-

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 */} @@ -136,46 +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 && ( ; -}; - -type LoginResponse = { - data?: { - login: { - user: User; - token: string; - }; - }; - errors?: { message: string }[]; -}; +// Hooks +import { useRegister } from "../hooks/auth/useRegister"; export default function CreateAccount() { + // Router const navigate = useNavigate(); - const setAuth = useAuthStore((state) => state.setAuth); + // State const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); - const [message, setMessage] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); + // Hooks + const { register, isSubmitting, error } = useRegister(); + + // Handlers async function handleRegister(event: ReactSubmitEvent) { event.preventDefault(); - setMessage(""); - setIsSubmitting(true); - - const query = ` - mutation Register($username: String!, $password: String!) { - register(username: $username, password: $password) { - id - username - } - } - `; - - try { - const response = await fetch(`${import.meta.env.BASE_URL}graphql`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ query, variables: { username, password } }), - }); - - if (!response.ok) { - throw new Error(`Server error (${response.status}).`); - } - - const result = (await response.json()) as RegisterResponse; - if (result.errors?.length) { - throw new Error(result.errors[0].message); - } - if (!result.data?.register) { - throw new Error("The server did not return the new account."); - } - - const loginQuery = ` - mutation Login($username: String!, $password: String!) { - login(username: $username, password: $password) { - user { - id - username - } - token - } - } - `; - - const loginResponse = await fetch(`${import.meta.env.BASE_URL}graphql`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - query: loginQuery, - variables: { username, password }, - }), - }); - - if (!loginResponse.ok) { - throw new Error(`Server error (${loginResponse.status}).`); - } - - const loginResult = (await loginResponse.json()) as LoginResponse; - - if (loginResult.errors?.length) { - throw new Error(loginResult.errors[0].message); - } - - if (!loginResult.data?.login) { - throw new Error("Account was created, but login failed."); - } - - const { user, token } = loginResult.data.login; - setAuth(user, token); + const success = await register(username, password); + if (success) { navigate("/profile"); - } catch (error) { - setMessage( - error instanceof Error ? error.message : "Something went wrong.", - ); - } finally { - setIsSubmitting(false); } } @@ -174,13 +91,12 @@ export default function CreateAccount() { {isSubmitting ? "Creating account..." : "Create account"} - {message && ( + {error && (

- {message} + {error}

)} diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 0ac15a8..2dd36b8 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -1,85 +1,75 @@ import { Link, useParams, useNavigate } from "react-router-dom"; -import { useGallery } from "../hooks/useGallery"; -import { useRemoveArtworkFromGallery } from "../hooks/useRemoveArtworkFromGallery"; -import { useRenameGallery } from "../hooks/useRenameGallery"; -import { useDeleteGallery } from "../hooks/useDeleteGallery"; -import DeleteGalleryDialog from "../components/DeleteGalleryDialog"; +import { useState } from "react"; +import { Trash2 } from "lucide-react"; + +// Types import type { Artwork } from "../types/Artwork"; -import { useState, useEffect } from "react"; -import { Trash2, Pencil } from "lucide-react"; -import ArtworkGrid from "../components/ArtworkGrid"; + +// Components +import GalleryHeader from "../components/gallery/GalleryHeader"; +import ArtworkGrid from "../components/artwork/ArtworkGrid"; import Pagination from "../components/Pagination"; +import DeleteGalleryDialog from "../components/gallery/DeleteGalleryDialog"; + +// Hooks +import { useGallery } from "../hooks/gallery/useGallery"; +import { useGalleryRename } from "../hooks/gallery/useGalleryRename"; +import { useRemoveArtworkFromGallery } from "../hooks/gallery/useRemoveArtworkFromGallery"; +import { useDeleteGallery } from "../hooks/gallery/useDeleteGallery"; +import { useSuccessMessage } from "../hooks/common/useSuccessMessage"; + +const PAGE_SIZE = 12; export default function GalleryPage() { + // Router const { id = "" } = useParams(); const navigate = useNavigate(); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const { deleteGallery, deleting, error: deleteError } = useDeleteGallery(); + // State const [page, setPage] = useState(1); - const pageSize = 12; - const [refreshKey, setRefreshKey] = useState(0); - const [successMessage, setSuccessMessage] = useState(""); const [isEditing, setIsEditing] = useState(false); - const [titleInput, setTitleInput] = useState(""); - const [isRenaming, setIsRenaming] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const { renameGallery, renaming, error: renameError } = useRenameGallery(); + // Hooks + const { successMessage, setSuccessMessage } = useSuccessMessage(); + const { deleteGallery, deleting, error: deleteError } = useDeleteGallery(); const { gallery, loading, error } = useGallery( id, page, - pageSize, + PAGE_SIZE, refreshKey, ); + const { + isRenaming, + setIsRenaming, + titleInput, + setTitleInput, + renaming, + renameError, + handleStartRenaming, + handleCancelRenaming, + handleRename, + } = useGalleryRename(id, () => { + setRefreshKey((current) => current + 1); + setSuccessMessage("Gallery name updated."); + }); + const { removeArtwork, removingId, error: removeError, } = useRemoveArtworkFromGallery(); - const totalPages = Math.ceil((gallery?.totalCount ?? 0) / pageSize); - - useEffect(() => { - if (!successMessage) return; - - const timer = window.setTimeout(() => { - setSuccessMessage(""); - }, 6000); - - return () => window.clearTimeout(timer); - }, [successMessage]); + const totalPages = Math.ceil((gallery?.totalCount ?? 0) / PAGE_SIZE); + // Handlers function handleToggleEditing() { setIsRenaming(false); setIsEditing((current) => !current); } - function handleStartRenaming() { - if (!gallery) return; - - setTitleInput(gallery.title); - setIsRenaming(true); - } - - function handleCancelRenaming() { - setIsRenaming(false); - } - - async function handleRename() { - setSuccessMessage(""); - - const updatedGallery = await renameGallery(id, titleInput); - - if (!updatedGallery) return; - setIsRenaming(false); - - setTitleInput(updatedGallery.title); - setRefreshKey((current) => current + 1); - setSuccessMessage("Gallery name updated."); - } - async function handleDeleteGallery() { const deletedId = await deleteGallery(id); @@ -100,7 +90,7 @@ export default function GalleryPage() { const lastPage = Math.max( 1, - Math.ceil(updatedGallery.artworkIds.length / pageSize), + Math.ceil(updatedGallery.artworkIds.length / PAGE_SIZE), ); setPage((currentPage) => Math.min(currentPage, lastPage)); @@ -148,102 +138,23 @@ export default function GalleryPage() {

Gallery not found

) : (
-
-
- {isRenaming ? ( -
{ - event.preventDefault(); - 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 && (
@@ -269,7 +180,7 @@ export default function GalleryPage() {

) : ( <> -
+
Math.max(1, Number(searchParams.get("page")) || 1)); - const [search, setSearch] = useState(() => searchParams.get("search") ?? ""); - const [artists, setArtists] = useState(() => searchParams.getAll("artist")); - const [countries, setCountries] = useState(() => searchParams.getAll("country")); - const [cultures, setCultures] = useState(() => searchParams.getAll("culture")); - const [artPeriods, setArtPeriods] = useState(() => searchParams.getAll("artPeriod")); - const [sort, setSort] = useState(() => { - const value = searchParams.get("sort"); - return ["TITLE_ASC", "TITLE_DESC", "ARTIST_ASC", "ARTIST_DESC", "YEAR_ASC", "YEAR_DESC"].includes(value ?? "") - ? (value as ArtworkSort) - : undefined; - }); - const [period, setPeriod] = useState(() => { - const value = searchParams.get("period"); - return ["BEFORE_1500", "YEARS_1500_1799", "CENTURY_19", "CENTURY_20_PLUS"].includes(value ?? "") - ? (value as ArtworkPeriod) - : undefined; - }); - const [dateFrom, setDateFrom] = useState(() => { - const value = Number(searchParams.get("dateFrom")); - return searchParams.has("dateFrom") && Number.isFinite(value) ? value : undefined; - }); - const [dateTo, setDateTo] = useState(() => { - const value = Number(searchParams.get("dateTo")); - return searchParams.has("dateTo") && Number.isFinite(value) ? value : undefined; - }); - const [showLoading, setShowLoading] = useState(false); - const [showFilters, setShowFilters] = useState(false); - const [paginationVisible, setPaginationVisible] = useState(false); - const [galleryVisible, setGalleryVisible] = useState(false); - const catalogueRef = useRef(null); - const paginationRef = useRef(null); - const artworkResultsRef = useRef(null); - const shouldScrollToPageRef = useRef(false); - const isLoggedIn = useAuthStore((state) => Boolean(state.token)); - - function handleExploreArtworks() { - const targetId = window.matchMedia("(min-width: 1024px)").matches - ? "catalogue-controls" - : "artwork-catalogue"; - document.getElementById(targetId)?.scrollIntoView({ - behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", - block: "start", - }); - } - - function updateQuery(values: Record) { - const next = new URLSearchParams(searchParams); - for (const [key, value] of Object.entries(values)) { - next.delete(key); - if (Array.isArray(value)) { - for (const item of value) next.append(key, item); - } else if (value) { - next.set(key, value); - } - } - setSearchParams(next, { replace: true }); - } - - const { artworks, totalCount, loading, error } = useArtworks( - page, - PAGE_SIZE, - search, - artists, - countries, - cultures, - artPeriods, - sort, - period, - dateFrom, - dateTo, - ); - useEffect(() => { - if (!loading) return; - - const timer = window.setTimeout(() => { - setShowLoading(true); - }, 300); - - return () => { - window.clearTimeout(timer); - }; - }, [loading]); - - useEffect(() => { - const catalogue = catalogueRef.current; - if (!catalogue || typeof IntersectionObserver === "undefined") return; - - const observer = new IntersectionObserver( - ([entry]) => setGalleryVisible(entry.isIntersecting), - { threshold: 0.01 }, - ); - observer.observe(catalogue); - return () => observer.disconnect(); - }, []); - - useEffect(() => { - const pagination = paginationRef.current; - if (!pagination || typeof IntersectionObserver === "undefined") { - setPaginationVisible(false); - return; - } - - const observer = new IntersectionObserver( - ([entry]) => setPaginationVisible(entry.isIntersecting), - { rootMargin: "0px 0px 96px 0px" }, - ); - observer.observe(pagination); - - return () => observer.disconnect(); - }, [totalCount, error]); - - useEffect(() => { - if (!shouldScrollToPageRef.current) return; - shouldScrollToPageRef.current = false; - - window.requestAnimationFrame(() => { - artworkResultsRef.current?.scrollIntoView({ - behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", - block: "start", - }); - }); - }, [page]); - - // Made with help from AI. - const totalPages = Math.ceil(totalCount / PAGE_SIZE); - useEffect(() => { - if (loading) return; - - let returnScrollY: number | undefined; - try { - const savedPosition = sessionStorage.getItem("catalogue-return"); - if (savedPosition) { - const parsed = JSON.parse(savedPosition) as { url?: string; scrollY?: number }; - if (`${location.pathname}${location.search}` === parsed.url) { - returnScrollY = parsed.scrollY; - } - sessionStorage.removeItem("catalogue-return"); - } - } catch { - } - if (typeof returnScrollY !== "number") { - returnScrollY = location.state?.restoreScrollY; - } - - if (typeof returnScrollY !== "number") return; - - window.requestAnimationFrame(() => { - window.scrollTo(0, returnScrollY); - }); - if (location.state?.restoreScrollY !== undefined) { - navigate(`${location.pathname}${location.search}`, { replace: true, state: null }); - } - }, [loading, location.key, location.pathname, location.search, location.state, navigate]); - - function handleSearch(value: string) { - setShowLoading(false); - const nextSearch = value.trim(); - setSearch(nextSearch); - setPage(1); - updateQuery({ search: nextSearch, page: undefined }); - } - - function handleArtistChange(value: string[]) { - setShowLoading(false); - setArtists(value); - setPage(1); - updateQuery({ artist: value, page: undefined }); - } - - function handleCountryChange(value: string[]) { - setShowLoading(false); - setCountries(value); - setPage(1); - updateQuery({ country: value, page: undefined }); - } - - function handleCultureChange(value: string[]) { - setShowLoading(false); - setCultures(value); - setPage(1); - updateQuery({ culture: value, page: undefined }); - } - - function handleArtPeriodChange(value: string[]) { - setShowLoading(false); - setArtPeriods(value); - setPage(1); - updateQuery({ artPeriod: value, page: undefined }); - } - - function handleSortChange(value: ArtworkSort | undefined) { - setShowLoading(false); - setSort(value); - setPage(1); - updateQuery({ sort: value, page: undefined }); - } - - function handlePeriodChange(value: ArtworkPeriod | undefined) { - setShowLoading(false); - setPeriod(value); - setDateFrom(undefined); - setDateTo(undefined); - setPage(1); - updateQuery({ period: value, dateFrom: undefined, dateTo: undefined, page: undefined }); - } - - function handleDateRangeChange(from: number | undefined, to: number | undefined) { - setShowLoading(false); - setDateFrom(from); - setDateTo(to); - setPeriod(undefined); - setPage(1); - updateQuery({ period: undefined, dateFrom: from === undefined ? undefined : String(from), dateTo: to === undefined ? undefined : String(to), page: undefined }); - } - - function handlePageChange(value: number) { - setShowLoading(false); - shouldScrollToPageRef.current = value > page; - setPage(value); - updateQuery({ page: value === 1 ? undefined : String(value) }); - } - - function handleReset() { - setShowLoading(false); - setSearch(""); - setArtists([]); - setCountries([]); - setCultures([]); - setArtPeriods([]); - setSort(undefined); - setPeriod(undefined); - setDateFrom(undefined); - setDateTo(undefined); - setPage(1); - setSearchParams({}, { replace: true }); - } - - return ( -
-
- -
- - -
- setShowFilters((current) => !current)} - paginationVisible={paginationVisible} - galleryVisible={galleryVisible} - /> -
- {error &&

Error: {error}

} -

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

-
- -
- {!error && artworks.length > 0 && } - - {!loading && !error && artworks.length === 0 && ( -

- No artworks match your search and filters. Try changing your - selections or use Clear all. -

- )} -
- - {!error && totalPages > 1 && ( -
- -
- )} -
-
- ); -} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index bbdda7c..e13e50e 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,74 +1,28 @@ import { Link, useNavigate } from "react-router-dom"; import { useState, type SubmitEvent as ReactSubmitEvent } from "react"; -import type { User } from "../types/User"; -import { useAuthStore } from "../store/authStore"; - -type LoginResponse = { - data?: { - login: { - user: User; - token: string; - }; - }; - errors?: { message: string }[]; -}; + +// Hooks +import { useLogin } from "../hooks/auth/useLogin"; function Login() { + // Router const navigate = useNavigate(); - const setAuth = useAuthStore((state) => state.setAuth); + // States const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); - const [message, setMessage] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); + // Hooks + const { login, isSubmitting, error } = useLogin(); + + // Handlers async function handleLogin(event: ReactSubmitEvent) { event.preventDefault(); - setMessage(""); - setIsSubmitting(true); - - const query = ` - mutation Login($username: String!, $password: String!) { - login(username: $username, password: $password) { - user { - id - username - } - token - } - } - `; - - try { - const response = await fetch(`${import.meta.env.BASE_URL}graphql`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ query, variables: { username, password } }), - }); - - if (!response.ok) { - throw new Error(`Server error (${response.status}).`); - } - - const result = (await response.json()) as LoginResponse; - if (result.errors?.length) { - throw new Error(result.errors[0].message); - } - if (!result.data?.login) { - throw new Error("The server did not return a user."); - } - - const { user, token } = result.data.login; - - setAuth(user, token); + const success = await login(username, password); + + if (success) { navigate("/profile"); - } catch (error) { - setMessage( - error instanceof Error ? error.message : "Something went wrong.", - ); - } finally { - setIsSubmitting(false); } } @@ -133,13 +87,12 @@ function Login() { {isSubmitting ? "Logging in..." : "Login"} - {message && ( + {error && (

- {message} + {error}

)} diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index f9ba18c..6102d28 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -1,10 +1,20 @@ -import GalleryCard from "../components/GalleryCard"; import { LucidePlus } from "lucide-react"; import { useEffect, useState } from "react"; -import type { Gallery } from "../types/Gallery"; -import CreateGalleryModal from "../components/CreateGalleryModal"; -import { useGalleryCoverImages } from "../hooks/useGalleryCoverImages"; import { Link, useNavigate, useLocation } from "react-router-dom"; + +// Components +import GalleryCard from "../components/gallery/GalleryCard"; +import CreateGalleryModal from "../components/gallery/CreateGalleryModal"; +import ProfileHeader from "../components/profile/ProfileHeader"; + +// Hooks +import { useGalleryCoverImages } from "../hooks/gallery/useGalleryCoverImages"; +import { useProfileImages } from "../hooks/profile/useProfileImages"; +import { useUploadProfileImage } from "../hooks/profile/useUploadProfileImage"; +import { useMyGalleries } from "../hooks/gallery/useMyGallery"; +import { useSuccessMessage } from "../hooks/common/useSuccessMessage"; + +// Store import { useAuthStore } from "../store/authStore"; const coverColors = [ @@ -17,262 +27,74 @@ const coverColors = [ ]; export default function ProfilePage() { + // Router const navigate = useNavigate(); const { pathname, search, hash, state } = useLocation(); - const [successMessage, setSuccessMessage] = useState(() => - typeof state?.successMessage === "string" ? state.successMessage : "", - ); + // Success message + const { successMessage, setSuccessMessage } = useSuccessMessage(); useEffect(() => { - if (!successMessage) return; + if (typeof state?.successMessage !== "string") return; + + setSuccessMessage(state.successMessage); navigate(`${pathname}${search}${hash}`, { replace: true, state: null, }); + }, [state, navigate, pathname, search, hash, setSuccessMessage]); - const timer = window.setTimeout(() => { - setSuccessMessage(""); - }, 6000); - - return () => window.clearTimeout(timer); - }, [successMessage, navigate, pathname, search, hash]); - + // Auth const user = useAuthStore((state) => state.user); const token = useAuthStore((state) => state.token); const logout = useAuthStore((state) => state.logout); + // UI state const [coverColor] = useState(() => { const randomIndex = Math.floor(Math.random() * coverColors.length); return coverColors[randomIndex]; }); - const [coverImage, setCoverImage] = useState(null); const [showCreateGallery, setShowCreateGallery] = useState(false); - const [profileImage, setProfileImage] = useState(null); - const [galleries, setGalleries] = useState([]); + // Galleries + const { galleries, isLoading, error, refetchGalleries } = useMyGalleries(); const galleryCoverImages = useGalleryCoverImages(galleries); - const [isLoading, setIsLoading] = useState(true); // Galleries - const [isLoadingImages, setIsLoadingImages] = useState(true); - const [refreshGalleries, setRefreshGalleries] = useState(0); - - useEffect(() => { - async function fetchProfileImage() { - const query = ` - query { - me { - profileImage - coverImage - } - } - `; - - try { - const response = await fetch(`${import.meta.env.BASE_URL}graphql`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token ?? ""}`, - }, - body: JSON.stringify({ query }), - }); - - if (!response.ok) { - throw new Error("Could not fetch profile image"); - } - - const result = await response.json(); - - if (result.errors?.length) { - throw new Error(result.errors[0].message); - } - - setProfileImage(result.data.me.profileImage); - setCoverImage(result.data.me.coverImage); - } catch (error) { - console.error("Error fetching profile images:", error); - } finally { - setIsLoadingImages(false); - } - } - void fetchProfileImage(); - }, [token]); + // Profile image hooks + const { uploadImage } = useUploadProfileImage(token); - useEffect(() => { - async function fetchGalleries() { - const query = ` - query { - myGalleries { - id - userId - title - artworkIds - } - } - `; - - try { - const response = await fetch(`${import.meta.env.BASE_URL}graphql`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token ?? ""}`, - }, - body: JSON.stringify({ query }), - }); - - if (!response.ok) { - throw new Error("Could not fetch galleries"); - } - - const result = await response.json(); - - if (result.errors?.length) { - throw new Error(result.errors[0].message); - } - - setGalleries(result.data.myGalleries); - } catch (error) { - console.error("Error fetching galleries:", error); - } finally { - setIsLoading(false); - } - } - - void fetchGalleries(); - }, [refreshGalleries, token]); + const { + profileImage, + coverImage, + isLoadingImages, + setProfileImage, + setCoverImage, + } = useProfileImages(token); + // Handlers async function handleCoverImageChange( event: React.ChangeEvent, ) { - const file = event.target.files?.[0]; - if (!file) return; + const image = await uploadImage(event, "cover"); - if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) { - alert("Please select a JPEG, PNG or WebP image"); - return; + if (image) { + setCoverImage(image); } - - if (file.size > 2_000_000) { - alert("Image must be smaller than 2 MB"); - return; - } - - const reader = new FileReader(); - - reader.onload = async () => { - const image = reader.result; - if (typeof image !== "string") return; - - const mutation = ` - mutation UpdateCoverImage($image: String!) { - updateCoverImage(image: $image) { - coverImage - } - } - `; - - try { - const response = await fetch(`${import.meta.env.BASE_URL}graphql`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token ?? ""}`, - }, - body: JSON.stringify({ - query: mutation, - variables: { image }, - }), - }); - - if (!response.ok) { - throw new Error("Could not upload cover image"); - } - - const result = await response.json(); - - if (result.errors?.length) { - throw new Error(result.errors[0].message); - } - - setCoverImage(result.data.updateCoverImage.coverImage); - } catch (error) { - console.error("Error uploading cover image:", error); - alert("Could not save cover image"); - } - }; - - reader.readAsDataURL(file); } async function handleProfileImageChange( event: React.ChangeEvent, ) { - const file = event.target.files?.[0]; - if (!file) return; + const image = await uploadImage(event, "profile"); - if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) { - alert("Please select a JPEG, PNG or WebP image"); - return; + if (image) { + setProfileImage(image); } - - if (file.size > 2_000_000) { - alert("Image must be smaller than 2 MB"); - return; - } - - const reader = new FileReader(); - - reader.onload = async () => { - const image = reader.result; - if (typeof image !== "string") return; - - const mutation = ` - mutation UpdateProfileImage($image: String!) { - updateProfileImage(image: $image) { - id - username - profileImage - } - } - `; - - try { - const response = await fetch(`${import.meta.env.BASE_URL}graphql`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token ?? ""}`, - }, - body: JSON.stringify({ - query: mutation, - variables: { image }, - }), - }); - - if (!response.ok) { - throw new Error("Could not upload profile image"); - } - - const result = await response.json(); - - if (result.errors?.length) { - throw new Error(result.errors[0].message); - } - - setProfileImage(result.data.updateProfileImage.profileImage); - } catch (error) { - console.error("Error uploading profile image:", error); - alert("Could not save profile image"); - } - }; - - reader.readAsDataURL(file); } + // Logout function handleLogout() { logout(); navigate("/"); @@ -307,71 +129,15 @@ export default function ProfilePage() { Log out -
- {isLoadingImages ? ( -
- ) : ( - coverImage && ( - Cover - ) - )} - - - - -
-
-