diff --git a/README.md b/README.md index 4ca1aa8..30e0618 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,22 @@ The app displays a component catalog backed by PostgreSQL and GraphQL, with sear We have worked to make Full Circuit usable for as many people as possible. We use semantic HTML elements, labels for inputs and buttons, and alternative text for component images. Keyboard users can navigate using focusable controls with a visible focus outline. We have also made the layout responsive so it works on both mobile and desktop. We will continue checking accessibility as the project develops. +The add-to-build feedback dialog confirms the action on the current page and lets users choose between **Continue exploring** and **Go back to the build page**. This reduces the "boomerang effect" of moving back and forth between the catalog and builder just to check whether a component was added. Keeping users in their browsing context reduces the need to reorient themselves and gives them control over when to navigate. When replacing a component, the dialog also lets users keep their current selection or confirm the replacement. The dialog has an accessible title and description, and loading and success messages use a status announcement for assistive technologies. + ## Sustainability -We have tried to reduce unnecessary data transfer. The catalog loads a limited number of components at a time, and we only request the information needed to display them. More detailed information is fetched when users choose to view a component. We also use WebP images and a dark color scheme, which may help reduce energy use. These choices can reduce resource use while keeping the application easy to use. +We aim to make as few unnecessary backend calls as possible by fetching data only when needed and reusing information already available in the browser. The catalog's data flow works as follows: + +1. **Check the catalogue cache.** Each query is identified by its search, filters, sorting, page size, and cursor. If a matching response is less than one minute old, the frontend reuses its parts, total count, and pagination information without contacting the backend. For example, switching from GPUs to CPUs and back can reuse the previous GPU results. +2. **Fetch summaries when there is no fresh cached response.** The catalog requests 40 components at a time, including fields such as name, price, category, and image URL. Full specifications are left out, so browsing does not download detailed data for every displayed part. The response is then cached for reuse. Requests are debounced by 300 ms to avoid a backend call for every keystroke during rapid typing. +3. **Fetch specifications when the user needs them.** Opening a component's details fetches its specifications if they are missing. Adding directly from a card also fetches missing specifications, because the build needs them for compatibility checks. +4. **Reuse loaded specifications.** A card keeps specifications fetched for its detail view while it remains mounted. Opening details, closing them, and then adding from that same card therefore requires only one specifications request. Adding from inside the detail view also reuses the complete part already available. + +The catalogue cache holds at most 100 pages to limit memory use and is cleared on a browser reload. Specifications stored in an individual card are discarded when that card unmounts. These measures reduce repeated network transfers and backend queries while allowing catalogue results to be refreshed after their cache entries expire. + +Memoized component cards and a stable details callback let React skip rendering unchanged cards. Stable IDs and keeping the previous results mounted during loading also avoid unnecessarily removing and recreating matching cards when filters change. This reduces repeated rendering work on the user's device and removes the brief empty-list flash. The result-count area keeps its space during loading to prevent the cards from jumping. + +Component images use WebP. When the mobile detail view crops image whitespace, it explicitly requests WebP output at quality `0.9` instead of the canvas default of PNG. This avoids generating PNG copies of WebP images and aims to keep the encoded image data compact. Cropping still requires pixel processing and re-encoding; the generated image is local and does not require another server download. Together, these choices target lower resource use, although we have not measured their energy or carbon savings. ## Dataset @@ -135,32 +148,37 @@ npm run dev ## Current prototype -The frontend is built with React, TypeScript, Vite, and Tailwind CSS. The catalog handles loading, error, and empty states. Catalog cards and the component detail dialog display data retrieved from the backend. The full-screen detail dialog uses separate mobile and desktop layouts, with an expandable specifications panel on desktop. +The frontend is built with React, TypeScript, Vite, and Tailwind CSS. The catalog supports search, filtering, sorting, and switching between list and grid layouts. It loads 40 components at a time through infinite scrolling or the **Load more** button, and handles loading, error, and empty states. Recent catalogue responses are cached for one minute. Previous cards remain visible while new results load, and unchanged cards can skip rendering through memoization. + +Catalog cards initially display summary data from the backend. Full specifications are fetched when needed for details or adding to a build, and cards retain specifications loaded for their detail view while mounted. The full-screen detail dialog has separate mobile and desktop layouts, both with expandable specifications. See [Sustainability](#sustainability) for the caching and data-flow details. ### PC builder -Open the builder through the navigation bar or by visiting `?page=builder`. Use **Add to build** on a catalog card or component detail page to select a part. +Open the builder through the navigation bar or by visiting `/project2/builder`. Use **Add to build** on a catalog card or component detail page to select a part. -- Each of the eight categories has one slot. Adding another component in the same category replaces the previous selection. +- Each of the eight categories has one slot. Adding another component in the same category opens a confirmation dialog, allowing users to keep the current component or replace it. +- Feedback confirms when a component has been added and offers **Continue exploring** or **Go back to the build page**, so users choose when to leave the catalog. - Empty slots have a **Choose** action that opens the category catalog. Selected slots show the component image, name, first three specifications, and price, with actions to change or delete the selection. - The summary shows the selected component count, progress, and total price in EUR. Missing prices currently contribute zero to the total, so the displayed sum may be incomplete. -- **Clear build** removes all selections. +- **Clear build** asks for confirmation before removing all selections. Selections are stored as JSON under the `pc-builder` key in `localStorage` and restored when the app loads. This preserves the current build across refreshes and page navigation in the same browser and site. Localhost and the deployed site have separate storage. The compatibility panel checks selected parts and shows results such as compatible, incompatible, warning, or unknown. The checks may not cover every possible combination. Users can also publish their builds to the database, but private builds are not synchronized between browsers. ### Navigation and shared build state -`frontend/src/App.tsx` renders the page based on URL query parameters: +`frontend/src/App.tsx` uses React Router to render pages based on URL paths: + +| Page | Route within the app | +| ------------------ | ---------------------------------------------- | +| Home | `/` | +| Explore components | `/explore` | +| Component category | `/explore/category/CPU` (or another category) | +| PC builder | `/builder` | +| Community builds | `/community` | -| Page | URL relative to the app base | -| ------------------ | ------------------------------------------- | -| Home | No query parameters | -| Explore components | `?page=explore` | -| Component category | `?category=CPU` (or another category value) | -| PC builder | `?page=builder` | -| Community builds | `?page=community` | +`BrowserRouter` in `frontend/src/main.tsx` uses `import.meta.env.BASE_URL` as its base path. With the `/project2/` deployment base, the builder's full path is `/project2/builder`. Category URLs use the values defined in `frontend/src/types/categories.ts`, such as `CPU`, rather than display labels such as `Processors`. Unknown routes display a 404 message. -The builder takes precedence if both `page=builder` and a category are supplied. Category URLs use the values defined in `frontend/src/types/categories.ts`, such as `CPU`, rather than display labels such as `Processors`. Navigation uses regular links or `window.location.href`, which reload the page; the build provider restores saved selections afterward. Home links use `import.meta.env.BASE_URL` to support deployment under `/project2/`. +Most navigation uses React Router links or `navigate()`, changing pages without a full browser reload. The feedback dialog's **Go back to the build page** action currently uses `window.location.href`, which reloads the page; the build provider then restores saved selections from local storage. `BuildProvider` wraps the app in `frontend/src/main.tsx`. Components use `useBuild()` from `frontend/src/context/BuildContext.ts` to access: @@ -175,7 +193,7 @@ Use `AddToBuildButton` for actions that add a component to this shared state. Th Users can publish their current build from the PC builder by entering a name and an optional comment. Publishing saves the chosen parts in PostgreSQL through GraphQL. Empty builds cannot be published, and the page shows an error if publishing fails. -The Community page at `?page=community` lists published builds with their names, comments, and parts. Viewing the list does not change the visitor's current build. Individual build pages and shareable links are not available yet. +The Community page at `/project2/community` lists published builds with their names, comments, and parts. It displays up to 20 builds per page, with **Previous** and **Next** controls when available, and handles loading, error, and empty states. Viewing the list does not change the visitor's current build. Individual build pages and shareable links are not available yet. ## Planned functionality diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c3f02db..e4be207 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,9 @@ import { useState } from "react"; import "./App.css"; -import HomePage from "./pages/homePage/HomePage"; -import CategoryPage from "./pages/categoryPage/CategoryPage"; -import PcBuilderPage from "./pages/pcBuilderPage/PcBuilderPage"; -import CommunityPage from "./pages/communityPage/CommunityPage"; +import HomePage from "./pages/homePage/homePage"; +import CategoryPage from "./pages/categoryPage/categoryPage"; +import PcBuilderPage from "./pages/pcBuilderPage/pcBuilderPage"; +import CommunityPage from "./pages/communityPage/communityPage"; import NavBar from "./components/pageSections/Header"; import Footer from "./components/pageSections/Footer"; import BackToTopButton from "./components/buttons/BackToTopButton"; diff --git a/frontend/src/api/parts.ts b/frontend/src/api/parts.ts index 53e0313..8a91da0 100644 --- a/frontend/src/api/parts.ts +++ b/frontend/src/api/parts.ts @@ -76,7 +76,6 @@ export async function getParts( manufacturer priceEur imageUrl - specs } } totalCount @@ -142,3 +141,45 @@ export async function getParts( pageInfo: connection.pageInfo, }; } + +export async function getPartSpecifications( + id: string, + signal?: AbortSignal, +): Promise { + const response = await fetch('/api/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal, + body: JSON.stringify({ + query: ` + query GetPartSpecifications($id: ID!) { + part(id: $id) { + specs + } + } + `, + variables: { id }, + }), + }) + + if (!response.ok) { + throw new Error(`Could not load specifications (HTTP ${response.status}).`) + } + + const result: { + data?: { part: { specs: PcPart['details'] } | null } + errors?: { message: string }[] + } = await response.json() + + if (result.errors?.length) { + throw new Error(result.errors.map((error) => error.message).join(', ')) + } + if (!result.data) { + throw new Error('The server returned no data.') + } + if (!result.data.part) { + throw new Error('Part not found.') + } + + return result.data.part.specs +} diff --git a/frontend/src/components/AddToBuildFeedback.tsx b/frontend/src/components/AddToBuildFeedback.tsx new file mode 100644 index 0000000..acfe6a8 --- /dev/null +++ b/frontend/src/components/AddToBuildFeedback.tsx @@ -0,0 +1,128 @@ +import type { PcPart } from "@/types/types"; +import { useBuild } from "@/context/BuildContext"; +import { useEffect, useId, useRef } from "react"; +import { flushSync } from "react-dom"; +import { ArrowRight, X } from "lucide-react"; +import SuccessNotification from "./SuccessNotification"; + +type AddToBuildFeedbackProps = { + part: PcPart; + stage: "confirm" | "loading" | "success"; + onReplace: () => Promise; + onClose: () => void; +}; + +export default function AddToBuildFeedback({ part, stage, onReplace, onClose }: AddToBuildFeedbackProps) { + const dialogRef = useRef(null); + const titleId = useId(); + const descriptionId = useId(); + const { parts } = useBuild(); + const savedPart = parts[part.category]; + const secondaryButton = "cursor-pointer rounded-lg border border-secondary/50 px-4 py-2 text-xs font-medium text-white transition-colors hover:bg-surface-dark"; + const primaryButton = "cursor-pointer rounded-lg border border-accent bg-accent px-5 py-2 text-xs font-semibold text-background transition-colors hover:bg-accent/90"; + + useEffect(() => { + dialogRef.current?.showModal(); + }, [stage]); + + function closeFeedback() { + dialogRef.current?.close(); + } + + function goToBuild() { + // Remove the feedback before the browser saves this page for Back navigation. + flushSync(() => onClose()); + window.location.href = "/project2/builder"; + } + + if (stage === "success") { + return ( + + ); + } + + return ( + + + +
+
+
+

+ {stage === "confirm" ? "Replace component?" : "Adding component…"} +

+

+ {stage === "confirm" + ? `You have already selected ${savedPart?.name}. Would you like to replace it with ${part.name}?` + : `Adding ${part.name} to your build.`} +

+
+
+ + {stage === "confirm" && ( +
+
+ {savedPart?.image_url && ( +
+ {savedPart.name} +
+ )} +
+

Current component

+

{savedPart?.name}

+
+
+
+ )} + {stage === "confirm" && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/AllPartsPreview.tsx b/frontend/src/components/AllPartsPreview.tsx index 5e65be9..b9ffd86 100644 --- a/frontend/src/components/AllPartsPreview.tsx +++ b/frontend/src/components/AllPartsPreview.tsx @@ -3,7 +3,9 @@ import PcPartCard from "@/components/PcPartCard"; import ViewSelector, { type ViewType } from "@/components/ViewSelector"; import type { PcPart } from "@/types/types"; import type { PartsOptions } from "@/api/parts"; -import { useCallback, useEffect, useRef, useState } from "react"; +import MobileComponentDetailPage from "./MobileComponentDetailPage"; +import DesktopComponentDetailPage from "./DesktopComponentDetailPage"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; type AllPartsPreviewProps = PartsOptions & { featured?: boolean; @@ -11,6 +13,18 @@ type AllPartsPreviewProps = PartsOptions & { viewenabled?: boolean; }; +// Compare summary values, not the new objects returned by each API request. +const PreviewPartCard = memo(function PreviewPartCard({ + onShowDetails, + ...summary +}: Omit & { + onShowDetails: (part: PcPart) => void; +}) { + // Catalogue results omit specifications; card actions fetch them when needed. + const part = { ...summary, details: {} } as PcPart; + return ; +}); + export default function AllPartsPreview({ featured = false, defaultview = "4-grid-view", @@ -21,7 +35,6 @@ export default function AllPartsPreview({ return ( void; }) { - const [cursors, setCursors] = useState([]); - const [allParts, setAllParts] = useState([]); + const filterKey = JSON.stringify(options); + const [pagination, setPagination] = useState({ + filterKey, + after: undefined as string | undefined, + parts: [] as PcPart[], + }); + // Reset pagination without unmounting cards that may match the new filters. + if (pagination.filterKey !== filterKey) { + setPagination({ filterKey, after: undefined, parts: [] }); + } const loadMoreRef = useRef(null); - const requestedCursorRef = useRef(null); const first = options.first ?? 40; + const dialogRef = useRef(null); + const [selectedPart, setSelectedPart] = useState(null); + const [isDesktop, setIsDesktop] = useState(false); + + const handleShowDetails = useCallback((part: PcPart) => { + setIsDesktop(window.matchMedia("(min-width: 48rem)").matches); + setSelectedPart(part); + }, []); + + useEffect(() => { + if (!selectedPart) return; + dialogRef.current?.showModal(); + const query = window.matchMedia("(min-width: 48rem)"); + const handleChange = (event: MediaQueryListEvent) => + setIsDesktop(event.matches); + query.addEventListener("change", handleChange); + return () => query.removeEventListener("change", handleChange); + }, [selectedPart]); + + useEffect(() => { + if (selectedPart) dialogRef.current?.scrollTo(0, 0); + }, [isDesktop, selectedPart]); + + function closeDetails() { + dialogRef.current?.close(); + setSelectedPart(null); + } - const { parts, totalCount, pageInfo, loading, error } = useParts({ + const { parts, totalCount, pageInfo, loading, error, retry } = useParts({ ...options, first, - after: cursors.at(-1), + after: pagination.filterKey === filterKey ? pagination.after : undefined, }); - const visibleParts = [...allParts, ...parts]; + const allParts = pagination.parts; + const [lastResult, setLastResult] = useState({ allParts, parts }); + if ( + !loading && + !error && + pagination.filterKey === filterKey && + (lastResult.parts !== parts || lastResult.allParts !== allParts) + ) { + setLastResult({ allParts, parts }); + } + // Keep the last completed list mounted while the next request is pending. + const visibleParts = loading + ? [...lastResult.allParts, ...lastResult.parts] + : [...allParts, ...parts]; const handleLoadMore = useCallback(() => { const cursor = pageInfo?.endCursor; @@ -63,15 +123,17 @@ function PartsResults({ error || !pageInfo?.hasNextPage || !cursor || - requestedCursorRef.current === cursor + pagination.after === cursor ) { return; } - requestedCursorRef.current = cursor; - setAllParts((previous) => [...previous, ...parts]); - setCursors((previous) => [...previous, cursor]); - }, [loading, error, pageInfo, parts]); + setPagination((previous) => + previous.after === cursor + ? previous + : { filterKey, after: cursor, parts: [...previous.parts, ...parts] }, + ); + }, [loading, error, pageInfo, parts, pagination.after, filterKey]); function getStyle() { switch (viewType) { @@ -114,18 +176,71 @@ function PartsResults({ )} - {!featured && !loading && !error && ( -

+ {!featured && ( +

Showing {visibleParts.length} of {totalCount} components

)} -
+
{visibleParts.map((part) => ( - + ))}
+ { + if ( + event.target === event.currentTarget && + !event.currentTarget.open + ) { + setSelectedPart(null); + } + }} + onSubmit={(event) => { + if ( + event.target instanceof HTMLFormElement && + event.target.method === "dialog" && + event.target.closest("dialog") === event.currentTarget + ) { + event.preventDefault(); + closeDetails(); + } + }} + onClick={(e) => { + if (e.target === e.currentTarget) closeDetails(); + }} + > + {selectedPart && + (isDesktop ? ( + + ) : ( + + ))} + + {loading &&

Loading parts...

} {error &&

{error}

} @@ -137,17 +252,17 @@ function PartsResults({
- - { - if ( - event.target instanceof HTMLFormElement && - event.target.method === "dialog" - ) { - event.preventDefault(); - closeDetails(); - } - }} - onClick={(e) => { - if (e.target === e.currentTarget) closeDetails(); - }} - > - {isDesktop ? ( - - ) : ( - - )} - ); } diff --git a/frontend/src/components/SuccessNotification.tsx b/frontend/src/components/SuccessNotification.tsx new file mode 100644 index 0000000..bef99b9 --- /dev/null +++ b/frontend/src/components/SuccessNotification.tsx @@ -0,0 +1,96 @@ +import { useEffect, useId, useRef } from "react"; +import { CircleCheck, X } from "lucide-react"; + +type SuccessNotificationProps = { + title: string; + description: string; + onClose: () => void; + action?: { label: string; onClick: () => void }; +}; + +export default function SuccessNotification({ + title, + description, + onClose, + action, +}: SuccessNotificationProps) { + const dialogRef = useRef(null); + const titleId = useId(); + const descriptionId = useId(); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + dialog.showModal(); + + let exitAnimation: Animation | undefined; + const timer = window.setTimeout(() => { + if (!dialog.open) return; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + dialog.close(); + return; + } + exitAnimation = dialog.animate( + [ + { transform: "translateX(0)", opacity: 1 }, + { transform: "translateX(calc(100% + 1rem))", opacity: 0 }, + ], + { duration: 250, easing: "ease-in", fill: "forwards" }, + ); + exitAnimation.onfinish = () => dialog.close(); + }, 5000); + return () => { + window.clearTimeout(timer); + exitAnimation?.cancel(); + }; + }, []); + + return ( + + +
+
+
+ ); +} diff --git a/frontend/src/components/buttons/AddToBuildButton.tsx b/frontend/src/components/buttons/AddToBuildButton.tsx index a3f5582..8107348 100644 --- a/frontend/src/components/buttons/AddToBuildButton.tsx +++ b/frontend/src/components/buttons/AddToBuildButton.tsx @@ -1,9 +1,11 @@ import type { ReactNode } from "react"; +import { useRef, useState } from "react"; import type { PcPart } from "@/types/types"; -import { useBuild } from "@/context/buildContext"; -import { useNavigate } from "react-router"; +import { useBuild } from "@/context/BuildContext"; import { twMerge } from "tailwind-merge"; import Button from "./Button"; +import AddToBuildFeedback from "../AddToBuildFeedback"; +import { getPartSpecifications } from "@/api/parts"; type AddToBuildButtonProps = { part: PcPart; @@ -16,27 +18,65 @@ export default function AddToBuildButton({ children, className, }: AddToBuildButtonProps) { - const navigate = useNavigate(); const { parts, addPart, removePart } = useBuild(); + const [showFeedback, setShowFeedback] = useState(false); const inBuild = parts[part.category]?.id === part.id; + const [stage, setStage] = useState<"confirm" | "loading" | "success">("confirm"); + const adding = useRef(false); + + async function handleAdd(){ //Reuse existing specifications, or fetch them before adding the part. + if (adding.current) return; + adding.current = true; + const loadingTimer = window.setTimeout(() => setStage("loading"), 500); + try { + const hasSpecifications = part.details && Object.keys(part.details).length > 0; + const specifications = hasSpecifications + ? part.details + : await getPartSpecifications(part.id); + const completePart = {...part, details: specifications,} as PcPart; + addPart(completePart); + setStage("success"); + } finally { + window.clearTimeout(loadingTimer); + adding.current = false; + } + } + + async function handleClick(){ + if (adding.current) return; + if (inBuild) { + removePart(part.category); + setShowFeedback(false); + return; + } + setStage("confirm"); + setShowFeedback(true); + if(!parts[part.category]){ + await handleAdd() + } + } return ( - + + {showFeedback && (stage !== "confirm" || parts[part.category]) && ( + setShowFeedback(false)} + /> )} - onClick={() => { - if (inBuild) { - removePart(part.category); - return; - } - addPart(part); - navigate("/builder", { state: { highlight: part.category } }); - }} - > - {children ?? (inBuild ? "Remove from build" : "Add to build")} - + ); } diff --git a/frontend/src/components/buttons/Button.tsx b/frontend/src/components/buttons/Button.tsx index 6404599..fc7983d 100644 --- a/frontend/src/components/buttons/Button.tsx +++ b/frontend/src/components/buttons/Button.tsx @@ -7,6 +7,7 @@ type ButtonProps = { className?: string; onClick?: () => void; type?: "button" | "submit"; + disabled?: boolean; }; export default function Button({ @@ -14,12 +15,14 @@ export default function Button({ className = "", onClick, type = "button", + disabled = false, }: ButtonProps) { - const inactive = !onClick && type !== "submit"; + const inactive = disabled || (!onClick && type !== "submit"); return (