diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..b6dfcbe
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,7 @@
+**/node_modules/
+**/dist/
+**/dist-ssr/
+**/coverage/
+**/*.tsbuildinfo
+**/package-lock.json
+backend/data/
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..a2e48bb
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,7 @@
+{
+ "singleQuote": false,
+ "semi": true,
+ "tabWidth": 2,
+ "useTabs": false,
+ "trailingComma": "all"
+}
diff --git a/README.md b/README.md
index 599f109..4ca1aa8 100644
--- a/README.md
+++ b/README.md
@@ -152,13 +152,13 @@ Selections are stored as JSON under the `pc-builder` key in `localStorage` and r
`frontend/src/App.tsx` renders the page based on URL query parameters:
-| Page | URL relative to the app base |
-| --- | --- |
-| Home | No query parameters |
-| Explore components | `?page=explore` |
+| 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` |
+| PC builder | `?page=builder` |
+| Community builds | `?page=community` |
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/`.
@@ -240,6 +240,21 @@ npm run test # Run frontend tests
## Working in this repository
+### Formatting
+
+Frontend and backend share the Prettier rules in the root `.prettierrc`: double quotes, semicolons, two-space indentation, and trailing commas. Generated files and datasets are excluded through `.prettierignore`.
+
+After installing dependencies with `npm ci` in each project, run these commands from the repository root before opening a pull request:
+
+```bash
+npm --prefix frontend run format
+npm --prefix backend/server run format
+```
+
+Use `format:check` instead of `format` to check formatting without changing files. To format supported files across the entire repository, including this README, run `npm exec --prefix frontend -- prettier --write .` from the root. Prettier handles formatting; run `lint`, `build`, and the frontend tests separately to check the code. Prettier does not rename files or fix spelling errors.
+
+### Pull requests
+
`main` is protected — you cannot push to it directly. Work on a branch and open a pull request:
```bash
diff --git a/backend/server/.prettierrc b/backend/server/.prettierrc
deleted file mode 100644
index 0967ef4..0000000
--- a/backend/server/.prettierrc
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/backend/server/package-lock.json b/backend/server/package-lock.json
index 9b56ec3..f14d85a 100644
--- a/backend/server/package-lock.json
+++ b/backend/server/package-lock.json
@@ -25,7 +25,7 @@
"@types/pg": "^8.23.1",
"eslint": "^10.12.0",
"eslint-config-prettier": "^10.1.8",
- "prettier": "^3.9.10",
+ "prettier": "3.9.10",
"tsx": "^4.23.15",
"typescript": "~6.0.0",
"typescript-eslint": "^8.71.2"
diff --git a/backend/server/package.json b/backend/server/package.json
index aa8dbed..404e03c 100644
--- a/backend/server/package.json
+++ b/backend/server/package.json
@@ -12,8 +12,8 @@
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint .",
- "format": "prettier --write .",
- "format:check": "prettier --check ."
+ "format": "prettier --write . --ignore-path ../../.prettierignore",
+ "format:check": "prettier --check . --ignore-path ../../.prettierignore"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -23,7 +23,7 @@
"@types/pg": "^8.23.1",
"eslint": "^10.12.0",
"eslint-config-prettier": "^10.1.8",
- "prettier": "^3.9.10",
+ "prettier": "3.9.10",
"tsx": "^4.23.15",
"typescript": "~6.0.0",
"typescript-eslint": "^8.71.2"
diff --git a/backend/server/src/resolvers.ts b/backend/server/src/resolvers.ts
index 85d351d..92d97be 100644
--- a/backend/server/src/resolvers.ts
+++ b/backend/server/src/resolvers.ts
@@ -379,29 +379,56 @@ async function manufacturers(
return rows.map((row) => row.manufacturer);
}
-async function publishBuild(_: unknown, { input }: {
- input: { tittel: string; comment?: string | null; partIds: string[] };
-}) {
+async function publishBuild(
+ _: unknown,
+ {
+ input,
+ }: {
+ input: { tittel: string; comment?: string | null; partIds: string[] };
+ },
+) {
const tittel = input.tittel.trim();
const comment = input.comment?.trim() || null;
- const invalid = (message: string) => new GraphQLError(message, {
- extensions: { code: "BAD_USER_INPUT" },
- });
- if (!tittel || [...tittel].length > 50) throw invalid("Enter a title of 1–50 characters.");
- if (comment && [...comment].length > 200) throw invalid("Comment must be at most 200 characters.");
- if (input.partIds.length < 1 || input.partIds.length > 8 || input.partIds.some(id => !UUID_RE.test(id))) {
+ const invalid = (message: string) =>
+ new GraphQLError(message, {
+ extensions: { code: "BAD_USER_INPUT" },
+ });
+ if (!tittel || [...tittel].length > 50)
+ throw invalid("Enter a title of 1–50 characters.");
+ if (comment && [...comment].length > 200)
+ throw invalid("Comment must be at most 200 characters.");
+ if (
+ input.partIds.length < 1 ||
+ input.partIds.length > 8 ||
+ input.partIds.some((id) => !UUID_RE.test(id))
+ ) {
throw invalid("Choose between 1 and 8 valid components.");
}
const { rows: selected } = await pool.query<{ id: string; category: string }>(
- "SELECT id, category FROM parts WHERE id = ANY($1::uuid[])", [input.partIds],
+ "SELECT id, category FROM parts WHERE id = ANY($1::uuid[])",
+ [input.partIds],
);
- const categories = ["CPU", "GPU", "RAM", "Storage", "Motherboard", "PSU", "PCCase", "CPUCooler"];
- if (selected.length !== input.partIds.length ||
- new Set(selected.map(part => part.category)).size !== selected.length ||
- selected.some(part => !categories.includes(part.category))) {
+ const categories = [
+ "CPU",
+ "GPU",
+ "RAM",
+ "Storage",
+ "Motherboard",
+ "PSU",
+ "PCCase",
+ "CPUCooler",
+ ];
+ if (
+ selected.length !== input.partIds.length ||
+ new Set(selected.map((part) => part.category)).size !== selected.length ||
+ selected.some((part) => !categories.includes(part.category))
+ ) {
throw invalid("Choose existing components, with only one per category.");
}
- const ids = categories.map(category => selected.find(part => part.category === category)?.id ?? null);
+ const ids = categories.map(
+ (category) =>
+ selected.find((part) => part.category === category)?.id ?? null,
+ );
const { rows } = await pool.query(
`INSERT INTO builds (tittel, comment, cpu_id, gpu_id, ram_id, storage_id, motherboard_id, psu_id, case_id, cooler_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
@@ -421,9 +448,23 @@ export const resolvers = {
JSON: JSONScalar,
Mutation: { publishBuild },
Query: {
- parts, part, manufacturers,
- builds: async (_: unknown, { limit = 20, offset = 0 }: { limit?: number | null; offset?: number | null }) => {
- if (limit == null || offset == null || limit < 1 || limit > 100 || offset < 0) {
+ parts,
+ part,
+ manufacturers,
+ builds: async (
+ _: unknown,
+ {
+ limit = 20,
+ offset = 0,
+ }: { limit?: number | null; offset?: number | null },
+ ) => {
+ if (
+ limit == null ||
+ offset == null ||
+ limit < 1 ||
+ limit > 100 ||
+ offset < 0
+ ) {
throw new GraphQLError("Invalid page size or offset", {
extensions: { code: "BAD_USER_INPUT" },
});
@@ -438,8 +479,16 @@ export const resolvers = {
Build: {
createdAt: (row: { created_at: Date }) => row.created_at.toISOString(),
parts: async (row: Record) => {
- const ids = [row.cpu_id, row.gpu_id, row.ram_id, row.storage_id,
- row.motherboard_id, row.psu_id, row.case_id, row.cooler_id].filter(Boolean);
+ const ids = [
+ row.cpu_id,
+ row.gpu_id,
+ row.ram_id,
+ row.storage_id,
+ row.motherboard_id,
+ row.psu_id,
+ row.case_id,
+ row.cooler_id,
+ ].filter(Boolean);
const { rows } = await pool.query(
`SELECT ${PART_COLUMNS} FROM parts WHERE id = ANY($1::uuid[]) ORDER BY category`,
[ids],
diff --git a/backend/server/src/schema.ts b/backend/server/src/schema.ts
index 728803a..4ec4210 100644
--- a/backend/server/src/schema.ts
+++ b/backend/server/src/schema.ts
@@ -89,11 +89,11 @@ export const typeDefs = /* GraphQL */ `
tittel: String!
comment: String
partIds: [ID!]!
-}
+ }
type Mutation {
publishBuild(input: PublishBuildInput!): Build!
-}
+ }
type Query {
"""
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index ef614d2..b1a6126 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -1,22 +1,24 @@
-import js from '@eslint/js'
-import globals from 'globals'
-import reactHooks from 'eslint-plugin-react-hooks'
-import reactRefresh from 'eslint-plugin-react-refresh'
-import tseslint from 'typescript-eslint'
-import { defineConfig, globalIgnores } from 'eslint/config'
+import js from "@eslint/js";
+import prettier from "eslint-config-prettier";
+import globals from "globals";
+import reactHooks from "eslint-plugin-react-hooks";
+import reactRefresh from "eslint-plugin-react-refresh";
+import tseslint from "typescript-eslint";
+import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
- globalIgnores(['dist']),
+ globalIgnores(["dist"]),
{
- files: ['**/*.{ts,tsx}'],
+ files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
+ prettier,
],
languageOptions: {
globals: globals.browser,
},
},
-])
+]);
diff --git a/frontend/index.html b/frontend/index.html
index 0fca6f0..bb5cdd7 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,9 +2,9 @@
-
+
- frontend
+ Full Circuit
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index b260a60..4f32fd9 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -12,6 +12,7 @@
"lucide-react": "^1.64.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
+ "react-router": "^8.5.1",
"tailwind-merge": "^3.7.0",
"tailwindcss": "^4.3.3"
},
@@ -25,11 +26,13 @@
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"eslint": "^10.10.0",
+ "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.6",
"globals": "^17.12.0",
"jsdom": "^30.1.3",
"msw": "^3.0.3",
+ "prettier": "3.9.10",
"typescript": "~6.0.2",
"typescript-eslint": "^8.69.0",
"vite": "^8.3.0",
@@ -935,6 +938,12 @@
"url": "https://github.com/sponsors/oxc-project"
}
},
+ "node_modules/@remix-run/route-pattern": {
+ "version": "0.22.1",
+ "resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz",
+ "integrity": "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==",
+ "license": "MIT"
+ },
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
@@ -1892,7 +1901,7 @@
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.3.0"
@@ -2503,6 +2512,12 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/cookie-es": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
+ "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
+ "license": "MIT"
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2748,6 +2763,22 @@
}
}
},
+ "node_modules/eslint-config-prettier": {
+ "version": "10.1.8",
+ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
+ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "eslint-config-prettier": "bin/cli.js"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint-config-prettier"
+ },
+ "peerDependencies": {
+ "eslint": ">=7.0.0"
+ }
+ },
"node_modules/eslint-plugin-react-hooks": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
@@ -3950,6 +3981,22 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/prettier": {
+ "version": "3.9.10",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz",
+ "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -4025,6 +4072,36 @@
"license": "MIT",
"peer": true
},
+ "node_modules/react-router": {
+ "version": "8.5.1",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.5.1.tgz",
+ "integrity": "sha512-5u1eQ46QjuXjNfEnLwzrnou1Bm5dquO3xjVYkkMYCudtkicD2AQHtx+fvXzPATtPJx8TW6DR1x1TDRqsfHyZBw==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/route-pattern": "^0.22.1",
+ "cookie-es": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=22.22.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=19.2.0",
+ "@types/react-dom": ">=19.2.0",
+ "react": ">=19.2.7",
+ "react-dom": ">=19.2.7"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index ac94c40..6908169 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -8,13 +8,16 @@
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
- "test": "vitest run"
+ "test": "vitest run",
+ "format": "prettier --write . --ignore-path ../.prettierignore",
+ "format:check": "prettier --check . --ignore-path ../.prettierignore"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"lucide-react": "^1.64.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
+ "react-router": "^8.5.1",
"tailwind-merge": "^3.7.0",
"tailwindcss": "^4.3.3"
},
@@ -28,11 +31,13 @@
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"eslint": "^10.10.0",
+ "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.6",
"globals": "^17.12.0",
"jsdom": "^30.1.3",
"msw": "^3.0.3",
+ "prettier": "3.9.10",
"typescript": "~6.0.2",
"typescript-eslint": "^8.69.0",
"vite": "^8.3.0",
diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico
new file mode 100644
index 0000000..a2fcf75
Binary files /dev/null and b/frontend/public/favicon.ico differ
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg
deleted file mode 100644
index 6893eb1..0000000
--- a/frontend/public/favicon.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg
deleted file mode 100644
index e952219..0000000
--- a/frontend/public/icons.svg
+++ /dev/null
@@ -1,24 +0,0 @@
-
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 8b13789..e69de29 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -1 +0,0 @@
-
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 3e0db5d..c3f02db 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,38 +1,43 @@
+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 NavBar from "./components/NavBar";
-import Footer from "./components/Footer";
-import BackToTopButton from "./components/BackToTopButton";
+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";
+import { Routes, Route, useParams } from "react-router";
-function App() {
- const params = new URLSearchParams(window.location.search);
- const page = params.get("page");
- const category = params.get("category");
- const search = (params.get("search") ?? "").trim();
-
- let content = ;
+function CategoryRoute() {
+ const { category } = useParams<{ category: string }>();
+ return ;
+}
- if (page === "builder") {
- content = ;
- } else if (page === "community") {
- content = ;
- } else if (category !== null) {
- content = ;
- } else if (page === "explore") {
- content = ;
- }
+function App() {
+ const [search, setSearch] = useState("");
return (
-
- {content}
-
+
+
+ } />
+ }
+ />
+ }
+ />
+ } />
+ } />
+ 404 - Page not found} />
+
+
diff --git a/frontend/src/api/builds.ts b/frontend/src/api/builds.ts
index 45f6952..0fd5272 100644
--- a/frontend/src/api/builds.ts
+++ b/frontend/src/api/builds.ts
@@ -1,39 +1,53 @@
export type SavedBuild = {
- id: string
- tittel: string
- comment: string | null
- createdAt: string
- parts: { id: string; name: string; category: string; priceEur: number | null }[]
-}
+ id: string;
+ tittel: string;
+ comment: string | null;
+ createdAt: string;
+ parts: {
+ id: string;
+ name: string;
+ category: string;
+ priceEur: number | null;
+ }[];
+};
-export async function publishBuild(input: { tittel: string; comment: string; partIds: string[] }): Promise {
- const response = await fetch('/api/graphql', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+export async function publishBuild(input: {
+ tittel: string;
+ comment: string;
+ partIds: string[];
+}): Promise {
+ const response = await fetch("/api/graphql", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: `mutation PublishBuild($input: PublishBuildInput!) {
publishBuild(input: $input) { id }
}`,
variables: { input },
}),
- })
+ });
const result: {
- data?: { publishBuild: { id: string } }
- errors?: { message: string }[]
- } = await response.json().catch(() => ({}))
+ data?: { publishBuild: { id: string } };
+ errors?: { message: string }[];
+ } = await response.json().catch(() => ({}));
if (result.errors?.length) {
- throw new Error(result.errors.map(error => error.message).join(', '))
+ throw new Error(result.errors.map((error) => error.message).join(", "));
}
if (!response.ok || !result.data?.publishBuild) {
- throw new Error(`Could not publish build (HTTP ${response.status}). Check that the backend is updated and running.`)
+ throw new Error(
+ `Could not publish build (HTTP ${response.status}). Check that the backend is updated and running.`,
+ );
}
- return result.data.publishBuild.id
+ return result.data.publishBuild.id;
}
-export async function getBuilds(offset = 0, signal?: AbortSignal): Promise {
- const response = await fetch('/api/graphql', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+export async function getBuilds(
+ offset = 0,
+ signal?: AbortSignal,
+): Promise {
+ const response = await fetch("/api/graphql", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
signal,
body: JSON.stringify({
query: `query GetBuilds($offset: Int!) {
@@ -44,14 +58,15 @@ export async function getBuilds(offset = 0, signal?: AbortSignal): Promise['details']
- }
-}[PcPart['category']]
+ [Category in PcPart["category"]]: {
+ id: string;
+ name: string;
+ category: Category;
+ manufacturer: string | null;
+ priceEur: number | null;
+ imageUrl: string | null;
+ specs: Extract["details"];
+ };
+}[PcPart["category"]];
export type PartsOptions = {
- search?: string
- category?: PcPart['category']
- manufacturers?: string[]
- sortBy?: 'NAME' | 'PRICE' | 'MANUFACTURER' | 'CATEGORY'
- sortDirection?: 'ASC' | 'DESC'
- first?: number,
- after?: string
-}
+ search?: string;
+ category?: PcPart["category"];
+ manufacturers?: string[];
+ sortBy?: "NAME" | "PRICE" | "MANUFACTURER" | "CATEGORY";
+ sortDirection?: "ASC" | "DESC";
+ first?: number;
+ after?: string;
+};
export type PartsPage = {
- parts: PcPart[]
- totalCount: number
+ parts: PcPart[];
+ totalCount: number;
pageInfo: {
- hasNextPage: boolean
- endCursor: string | null
- }
-}
+ hasNextPage: boolean;
+ endCursor: string | null;
+ };
+};
export async function getParts(
{
- search = '',
+ search = "",
category,
manufacturers,
- sortBy = 'NAME',
- sortDirection = 'ASC',
+ sortBy = "NAME",
+ sortDirection = "ASC",
first = 20,
after,
}: PartsOptions = {},
signal?: AbortSignal,
): Promise {
- const response = await fetch('/api/graphql', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ const response = await fetch("/api/graphql", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
signal,
body: JSON.stringify({
query: `
@@ -94,48 +94,51 @@ export async function getParts(
sortBy,
sortDirection,
first,
- after: after ?? null
+ after: after ?? null,
},
}),
- })
+ });
if (!response.ok) {
- throw new Error(`Could not load parts (HTTP ${response.status}).`)
+ throw new Error(`Could not load parts (HTTP ${response.status}).`);
}
const result: {
- data?: {
- parts: {
- edges: { node: ApiPart }[]
- totalCount: number
- pageInfo: PartsPage['pageInfo']
- }
- }
- errors?: { message: string }[]
- } = await response.json()
+ data?: {
+ parts: {
+ edges: { node: ApiPart }[];
+ totalCount: number;
+ pageInfo: PartsPage["pageInfo"];
+ };
+ };
+ errors?: { message: string }[];
+ } = await response.json();
if (result.errors?.length) {
- throw new Error(result.errors.map((error) => error.message).join(', '))
+ throw new Error(result.errors.map((error) => error.message).join(", "));
}
if (!result.data) {
- throw new Error('The server returned no data.')
+ throw new Error("The server returned no data.");
}
- const connection = result.data.parts
+ const connection = result.data.parts;
- const parts = connection.edges.map(({ node }) => ({
- id: node.id,
- name: node.name,
- category: node.category,
- manufacturer: node.manufacturer ?? 'Unknown',
- price_eur: node.priceEur,
- image_url: node.imageUrl ? `/api${node.imageUrl}` : '',
- details: node.specs ?? {},
- } as PcPart))
+ const parts = connection.edges.map(
+ ({ node }) =>
+ ({
+ id: node.id,
+ name: node.name,
+ category: node.category,
+ manufacturer: node.manufacturer ?? "Unknown",
+ price_eur: node.priceEur,
+ image_url: node.imageUrl ? `/api${node.imageUrl}` : "",
+ details: node.specs ?? {},
+ }) as PcPart,
+ );
return {
parts,
totalCount: connection.totalCount,
pageInfo: connection.pageInfo,
- }
+ };
}
diff --git a/frontend/src/assets/.gitkeep b/frontend/src/assets/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx
deleted file mode 100644
index a457711..0000000
--- a/frontend/src/components/Button.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import type { ReactNode } from 'react'
-import { twMerge } from 'tailwind-merge'
-
-
-//Button element with style, and possible to asign specific style when using it
-type ButtonProps = {
- children: ReactNode
- className?: string
- onClick?: () => void
- type?: 'button' | 'submit'
-}
-
-export default function Button({
- children,
- className = '',
- onClick,
- type = 'button',
-}: ButtonProps) {
- const inactive = !onClick && type !== 'submit'
-
- return (
-
- )
-}
diff --git a/frontend/src/components/DesktopComponentDetailPage.tsx b/frontend/src/components/DesktopComponentDetailPage.tsx
index 3783266..0c9212b 100644
--- a/frontend/src/components/DesktopComponentDetailPage.tsx
+++ b/frontend/src/components/DesktopComponentDetailPage.tsx
@@ -1,41 +1,71 @@
-import { useBuild } from '@/context/BuildContext'
-import { Trash2, X } from 'lucide-react'
-import { useId, useState } from 'react'
-import type { PcPart } from '@/types/types'
-import { categories } from '@/types/categories'
-import { formatSpecification, getSpecifications } from '@/utils/partSpecifications'
-import AddToBuildButton from './AddToBuildButton'
+import { useBuild } from "@/context/buildContext";
+import { Trash2, X } from "lucide-react";
+import { useId, useState } from "react";
+import { Link } from "react-router";
+import type { PcPart } from "@/types/types";
+import { categories } from "@/types/categories";
+import {
+ formatSpecification,
+ getSpecifications,
+} from "@/utils/partSpecifications";
+import AddToBuildButton from "./buttons/AddToBuildButton";
export default function DesktopComponentDetailPage({ part }: { part: PcPart }) {
- const [expanded, setExpanded] = useState(false)
- const [failedImage, setFailedImage] = useState('')
- const specificationsId = useId()
- const { parts } = useBuild()
- const inBuild = parts[part.category]?.id === part.id
- const specifications = getSpecifications(part)
- const category = categories.find((item) => item.value === part.category)?.label ?? part.category
- const imageUrl = part.image_url?.trim() ?? ''
+ const [expanded, setExpanded] = useState(false);
+ const [failedImage, setFailedImage] = useState("");
+ const specificationsId = useId();
+ const { parts } = useBuild();
+ const inBuild = parts[part.category]?.id === part.id;
+ const specifications = getSpecifications(part);
+ const category =
+ categories.find((item) => item.value === part.category)?.label ??
+ part.category;
+ const imageUrl = part.image_url?.trim() ?? "";
return (
-
{part.name}
+
+ {part.name}
+
-
{category} / {part.manufacturer}
-
{part.name}
+
+ {category} / {part.manufacturer}
+
+
+ {part.name}
+
{specifications.slice(0, 3).map(([label, value]) => (
-
+
- {label}
- - {formatSpecification(value)}
+ -
+ {formatSpecification(value)}
+
))}
- {part.price_eur === null ? 'Price unavailable' : `€${part.price_eur.toFixed(2)}`}
+ {part.price_eur === null
+ ? "Price unavailable"
+ : `€${part.price_eur.toFixed(2)}`}
-
+
{inBuild ? (
) : (
- +
+
+ +
+
)}
- {inBuild ? 'Remove from build' : 'Add to build'}
+ {inBuild ? "Remove from build" : "Add to build"}
-
+
-
+
Specifications
@@ -108,9 +162,14 @@ export default function DesktopComponentDetailPage({ part }: { part: PcPart }) {
{specifications.slice(0, 4).map(([label, value]) => (
-
+
- {label}
- - {formatSpecification(value)}
+ -
+ {formatSpecification(value)}
+
))}
@@ -119,14 +178,21 @@ export default function DesktopComponentDetailPage({ part }: { part: PcPart }) {
aria-hidden={!expanded}
inert={!expanded}
className={`grid transition-[grid-template-rows,opacity] duration-400 ease-in-out motion-reduce:transition-none ${
- expanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
+ expanded
+ ? "grid-rows-[1fr] opacity-100"
+ : "grid-rows-[0fr] opacity-0"
}`}
>
{specifications.slice(4).map(([label, value]) => (
-
+
- {label}
- - {formatSpecification(value)}
+ -
+ {formatSpecification(value)}
+
))}
@@ -139,10 +205,12 @@ export default function DesktopComponentDetailPage({ part }: { part: PcPart }) {
onClick={() => setExpanded((previous) => !previous)}
className="mt-4 flex cursor-pointer items-center gap-3 text-xs text-accent hover:underline"
>
- {expanded ? 'Show fewer specifications' : 'View all specifications'}
+ {expanded
+ ? "Show fewer specifications"
+ : "View all specifications"}
↗
@@ -152,5 +220,5 @@ export default function DesktopComponentDetailPage({ part }: { part: PcPart }) {
- )
+ );
}
diff --git a/frontend/src/components/FilterComponent.tsx b/frontend/src/components/FilterComponent.tsx
index 6cbb75e..7cc02af 100644
--- a/frontend/src/components/FilterComponent.tsx
+++ b/frontend/src/components/FilterComponent.tsx
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { PartsOptions } from "@/api/parts";
import { categories } from "@/types/categories";
-import Button from "./Button";
+import Button from "./buttons/Button";
// Sort and both filters sit inline only when there is room next to the search field.
const DESKTOP_FILTER_WIDTH = 1200;
diff --git a/frontend/src/components/MobileComponentDetailPage.tsx b/frontend/src/components/MobileComponentDetailPage.tsx
index 6aadf5c..1cbe16a 100644
--- a/frontend/src/components/MobileComponentDetailPage.tsx
+++ b/frontend/src/components/MobileComponentDetailPage.tsx
@@ -5,7 +5,7 @@ import {
formatSpecification,
getSpecifications,
} from "@/utils/partSpecifications";
-import AddToBuildButton from "./AddToBuildButton";
+import AddToBuildButton from "./buttons/AddToBuildButton";
export default function ComponentDetailPage({ part }: { part: PcPart }) {
const imageUrl = part.image_url?.trim() ?? "";
diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx
deleted file mode 100644
index 012edd1..0000000
--- a/frontend/src/components/NavBar.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-import { useState } from "react";
-import { useBuild } from "@/context/BuildContext";
-
-type NavBarProps = {
- search?: string;
- setSearch?: (value: string) => void;
-};
-
-export default function NavBar({
- search: externalSearch,
- setSearch: setExternalSearch,
-}: NavBarProps) {
- const [localSearch, setLocalSearch] = useState(
- () => new URLSearchParams(window.location.search).get("search") ?? "",
- );
- const search = externalSearch ?? localSearch;
- const setSearch = setExternalSearch ?? setLocalSearch;
- const { parts } = useBuild();
- const selectedCount = Object.values(parts).length;
-
- const params = new URLSearchParams(window.location.search);
- const currentPage = params.get("page") ?? "home";
-
- return (
-
- );
-}
diff --git a/frontend/src/components/PcPartCard.tsx b/frontend/src/components/PcPartCard.tsx
index 1bada4c..fd9837a 100644
--- a/frontend/src/components/PcPartCard.tsx
+++ b/frontend/src/components/PcPartCard.tsx
@@ -2,8 +2,8 @@ import { useState, useRef, useEffect } from "react";
import type { PcPart } from "@/types/types";
import MobileComponentDetailPage from "./MobileComponentDetailPage";
import DesktopComponentDetailPage from "./DesktopComponentDetailPage";
-import AddToBuildButton from "./AddToBuildButton";
-import Button from "@/components/Button";
+import AddToBuildButton from "./buttons/AddToBuildButton";
+import Button from "@/components/buttons/Button";
export default function PcPartCard({ part }: { part: PcPart }) {
const [failedImage, setFailedImage] = useState("");
diff --git a/frontend/src/components/AddToBuildButton.tsx b/frontend/src/components/buttons/AddToBuildButton.tsx
similarity index 77%
rename from frontend/src/components/AddToBuildButton.tsx
rename to frontend/src/components/buttons/AddToBuildButton.tsx
index fef6868..a3f5582 100644
--- a/frontend/src/components/AddToBuildButton.tsx
+++ b/frontend/src/components/buttons/AddToBuildButton.tsx
@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import type { PcPart } from "@/types/types";
-import { useBuild } from "@/context/BuildContext";
-import { flushSync } from "react-dom";
+import { useBuild } from "@/context/buildContext";
+import { useNavigate } from "react-router";
import { twMerge } from "tailwind-merge";
import Button from "./Button";
@@ -16,6 +16,7 @@ export default function AddToBuildButton({
children,
className,
}: AddToBuildButtonProps) {
+ const navigate = useNavigate();
const { parts, addPart, removePart } = useBuild();
const inBuild = parts[part.category]?.id === part.id;
@@ -31,8 +32,8 @@ export default function AddToBuildButton({
removePart(part.category);
return;
}
- flushSync(() => addPart(part)); //add the part to the build
- window.location.href = `?page=builder&highlight=${encodeURIComponent(part.category)}`;
+ addPart(part);
+ navigate("/builder", { state: { highlight: part.category } });
}}
>
{children ?? (inBuild ? "Remove from build" : "Add to build")}
diff --git a/frontend/src/components/BackToTopButton.tsx b/frontend/src/components/buttons/BackToTopButton.tsx
similarity index 100%
rename from frontend/src/components/BackToTopButton.tsx
rename to frontend/src/components/buttons/BackToTopButton.tsx
diff --git a/frontend/src/components/buttons/Button.tsx b/frontend/src/components/buttons/Button.tsx
new file mode 100644
index 0000000..6404599
--- /dev/null
+++ b/frontend/src/components/buttons/Button.tsx
@@ -0,0 +1,37 @@
+import type { ReactNode } from "react";
+import { twMerge } from "tailwind-merge";
+
+//Button element with style, and possible to asign specific style when using it
+type ButtonProps = {
+ children: ReactNode;
+ className?: string;
+ onClick?: () => void;
+ type?: "button" | "submit";
+};
+
+export default function Button({
+ children,
+ className = "",
+ onClick,
+ type = "button",
+}: ButtonProps) {
+ const inactive = !onClick && type !== "submit";
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/pageSections/Footer.tsx
similarity index 84%
rename from frontend/src/components/Footer.tsx
rename to frontend/src/components/pageSections/Footer.tsx
index ae56113..acc6f9c 100644
--- a/frontend/src/components/Footer.tsx
+++ b/frontend/src/components/pageSections/Footer.tsx
@@ -9,9 +9,7 @@ export default function Footer() {
-
- © 2026 Team 15, IT2810
-
+ © 2026 Team 15, IT2810
);
diff --git a/frontend/src/components/pageSections/Header.tsx b/frontend/src/components/pageSections/Header.tsx
new file mode 100644
index 0000000..3f54bf4
--- /dev/null
+++ b/frontend/src/components/pageSections/Header.tsx
@@ -0,0 +1,132 @@
+import { useState, type FormEvent } from "react";
+import { Link, NavLink, useNavigate } from "react-router";
+import { Menu, Search, X } from "lucide-react";
+import { useBuild } from "@/context/buildContext";
+
+const navigation = [
+ { to: "/", label: "Home" },
+ { to: "/explore", label: "Explore components" },
+ { to: "/builder", label: "PC Builder" },
+ { to: "/community", label: "Community Page" },
+];
+
+type NavBarProps = {
+ onSearch: (value: string) => void;
+};
+
+export default function NavBar({ onSearch }: NavBarProps) {
+ const [search, setSearch] = useState("");
+ const [menuOpen, setMenuOpen] = useState(false);
+ const navigate = useNavigate();
+ const { parts } = useBuild();
+ const selectedCount = Object.keys(parts).length;
+
+ function handleSearch(event: FormEvent) {
+ event.preventDefault();
+ onSearch(search.trim());
+ navigate("/explore");
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/context/BuildContext.ts b/frontend/src/context/BuildContext.ts
index 8408224..4b14a57 100644
--- a/frontend/src/context/BuildContext.ts
+++ b/frontend/src/context/BuildContext.ts
@@ -1,23 +1,23 @@
-import {createContext, useContext} from 'react'
-import type { PcPart} from '@/types/types'
+import { createContext, useContext } from "react";
+import type { PcPart } from "@/types/types";
-export type BuildParts = Partial>
+export type BuildParts = Partial>;
type BuildContextValue = {
- parts : BuildParts
- addPart : (part: PcPart) => void
- removePart : (category: PcPart['category']) => void
- resetBuild: () => void
-}
+ parts: BuildParts;
+ addPart: (part: PcPart) => void;
+ removePart: (category: PcPart["category"]) => void;
+ resetBuild: () => void;
+};
-export const BuildContext = createContext(null)
+export const BuildContext = createContext(null);
export function useBuild() {
- const context = useContext(BuildContext)
+ const context = useContext(BuildContext);
if (!context) {
- throw new Error('useBuild must be used inside BuildProvider')
+ throw new Error("useBuild must be used inside BuildProvider");
}
- return context
-}
\ No newline at end of file
+ return context;
+}
diff --git a/frontend/src/context/BuildProvider.tsx b/frontend/src/context/BuildProvider.tsx
index 911c3e2..06f9bd0 100644
--- a/frontend/src/context/BuildProvider.tsx
+++ b/frontend/src/context/BuildProvider.tsx
@@ -1,51 +1,51 @@
-import { useEffect, useState } from 'react'
-import type { ReactNode } from 'react'
-import type { PcPart } from '@/types/types'
-import { BuildContext } from './BuildContext'
-import type { BuildParts } from './BuildContext'
+import { useEffect, useState } from "react";
+import type { ReactNode } from "react";
+import type { PcPart } from "@/types/types";
+import { BuildContext } from "./buildContext";
+import type { BuildParts } from "./buildContext";
-const STORAGE_KEY = 'pc-builder'
+const STORAGE_KEY = "pc-builder";
export default function BuildProvider({ children }: { children: ReactNode }) {
const [parts, setParts] = useState(() => {
try {
- const saved = localStorage.getItem(STORAGE_KEY)
- return saved ? JSON.parse(saved) ?? {} : {}
+ const saved = localStorage.getItem(STORAGE_KEY);
+ return saved ? (JSON.parse(saved) ?? {}) : {};
} catch {
- return {}
+ return {};
}
- })
+ });
useEffect(() => {
try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(parts))
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(parts));
} catch {
- console.warn('Could not save the build in this browser.')
+ console.warn("Could not save the build in this browser.");
}
- }, [parts])
+ }, [parts]);
function addPart(part: PcPart) {
setParts((previous) => ({
...previous,
[part.category]: part,
- }))
+ }));
}
- function removePart(category: PcPart['category']) {
+ function removePart(category: PcPart["category"]) {
setParts((previous) => {
- const updated = { ...previous }
- delete updated[category]
- return updated
- })
+ const updated = { ...previous };
+ delete updated[category];
+ return updated;
+ });
}
function resetBuild() {
- setParts({})
-}
+ setParts({});
+ }
return (
{children}
- )
+ );
}
diff --git a/frontend/src/hooks/compatability/data/supportedChipsets.ts b/frontend/src/hooks/compatability/data/supportedChipsets.ts
deleted file mode 100644
index 1612de3..0000000
--- a/frontend/src/hooks/compatability/data/supportedChipsets.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-// Incomplete chipset-family candidates, NOT an exact motherboard CPU support list.
-// Missing combinations must remain UNKNOWN. BIOS support is checked separately.
-// Board-specific verified exceptions belong in motherboardCpuSupport.ts.
-export const supportedChipsets: Record = {
- "Intel Core i5 4440": ["H81", "B85", "H87", "Z87", "H97", "Z97"],
- "Intel Core i5 4460": ["H81", "B85", "H87", "Z87", "H97", "Z97"],
- "Intel Core i5 4590": ["H81", "B85", "H87", "Z87", "H97", "Z97"],
- "Intel Core i5 4690": ["H81", "B85", "H87", "Z87", "H97", "Z97"],
- "Intel Core i7 4770": ["H81", "B85", "H87", "Z87", "H97", "Z97"],
- "Intel Core i7 4770K": ["H81", "B85", "H87", "Z87", "H97", "Z97"],
- "Intel Core i7 4790K": ["H81", "B85", "H87", "Z87", "H97", "Z97"],
-
- // Intel 6th generation
- "Intel Core i3 6100": ["H110", "B150", "H170", "Z170", "B250", "H270", "Z270"],
- "Intel Core i5 6500": ["H110", "B150", "H170", "Z170", "B250", "H270", "Z270"],
- "Intel Core i5 6600K": ["H110", "B150", "H170", "Z170", "B250", "H270", "Z270"],
- "Intel Core i7 6700 OEM/Tray": ["H110", "B150", "H170", "Z170", "B250", "H270", "Z270"],
- "Intel Core i7 6700K": ["H110", "B150", "H170", "Z170", "B250", "H270", "Z270"],
-
- // Intel 7th generation
- "Intel Core i5 7400": ["H110", "B150", "H170", "Z170", "B250", "H270", "Z270"],
- "Intel Core i5 7500": ["H110", "B150", "H170", "Z170", "B250", "H270", "Z270"],
- "Intel Core i7 7700K": ["H110", "B150", "H170", "Z170", "B250", "H270", "Z270"],
-
- // Intel 8th generation
- "Intel Core i3 8100": ["H310", "B360", "B365", "H370", "Z370", "Z390"],
- "Intel Core i3-8350K": ["H310", "B360", "B365", "H370", "Z370", "Z390"],
- "Intel Core i5 8400": ["H310", "B360", "B365", "H370", "Z370", "Z390"],
- "Intel Core i5-8600K": ["H310", "B360", "B365", "H370", "Z370", "Z390"],
- "Intel Core i7 8700K": ["H310", "B360", "B365", "H370", "Z370", "Z390"],
-
- // Intel 9th generation
- "Intel Core i5 9600KF": ["H310", "B360", "B365", "H370", "Z370", "Z390"],
- "Intel Core i7 9700K": ["H310", "B360", "B365", "H370", "Z370", "Z390"],
- "Intel Core i9 9900K": ["H310", "B360", "B365", "H370", "Z370", "Z390"],
- "Intel Core i9-9900KF": ["H310", "B360", "B365", "H370", "Z370", "Z390"],
-
- // Intel 10th generation
- "Intel Core i5 10400": ["H410", "B460", "H470", "Z490", "H510", "B560", "H570", "Z590"],
- "Intel Core i5 10600K": ["H410", "B460", "H470", "Z490", "H510", "B560", "H570", "Z590"],
- "Intel Core i7 10700KF": ["H410", "B460", "H470", "Z490", "H510", "B560", "H570", "Z590"],
-
- // Intel 11th generation — excludes H410 and B460
- "Intel Core i5 11400": ["H470", "Z490", "H510", "B560", "H570", "Z590"],
-
- // Intel 12th generation
- "Intel Core i3 12100": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i3 12100F": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i5 12400": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i5 12400F": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i5 12600K": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i5 12600KF": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i7 12700": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i7 12700F": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i7 12700K": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i7 12700KF": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i9 12900K": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
-
- // Intel 13th generation
- "Intel Core i5 13400F": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i5 13600KF": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i7 13700KF": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i9 13900K": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i9 13900KF": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i9 13900KS": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
-
- // Intel 14th generation
- "Intel Core i5 14600K": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i5 14600KF": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i7 14700K": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i7 14700KF": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i9-14900K": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
- "Intel Core i9-14900KF": ["H610", "B660", "H670", "Z690", "B760", "H770", "Z790"],
-
- // Intel Core Ultra 200
- "Intel Core Ultra 7 265K": ["H810", "B860", "Z890"],
- "Intel Core Ultra 9 285K": ["H810", "B860", "Z890"],
-
- // Intel enthusiast platforms
- "Intel Core i7 4930K": ["X79"],
- "Intel Core i7 7820X": ["X299"],
-
- // AMD Ryzen 1000 — conservative selection
- "AMD Ryzen 3 1200": ["A320", "B350", "X370", "B450", "X470"],
- "AMD Ryzen 5 1500X": ["A320", "B350", "X370", "B450", "X470"],
- "AMD Ryzen 5 1600": ["A320", "B350", "X370", "B450", "X470"],
- "AMD Ryzen 7 1700": ["A320", "B350", "X370", "B450", "X470"],
- "AMD Ryzen 7 1800X": ["A320", "B350", "X370", "B450", "X470"],
-
- // AMD Ryzen 2000
- "AMD Ryzen 7 2700 OEM/Tray": ["A320", "B350", "X370", "B450", "X470", "X570"],
- "AMD Ryzen 7 2700X": ["A320", "B350", "X370", "B450", "X470", "X570"],
-
- // AMD Ryzen 3000 APU — different support from Zen 2 CPUs
- "AMD Ryzen 3 3200G": ["A320", "B350", "X370", "B450", "X470", "X570"],
-
- // AMD Ryzen 3000 — some older boards also support these
- "AMD Ryzen 5 3600": ["B450", "X470", "A520", "B550", "X570"],
- "AMD Ryzen 5 3600X": ["B450", "X470", "A520", "B550", "X570"],
- "AMD Ryzen 7 3700X": ["B450", "X470", "A520", "B550", "X570"],
- "AMD Ryzen 7 3800X OEM/Tray": ["B450", "X470", "A520", "B550", "X570"],
- "AMD Ryzen 9 3900XT": ["B450", "X470", "A520", "B550", "X570"],
-
- // AMD Ryzen 4000
- "AMD Ryzen 5 4500": ["B450", "X470", "A520", "B550", "X570"],
-
- // AMD Ryzen 5000 — some older boards also support these
- "AMD Ryzen 5 5500": ["B450", "X470", "A520", "B550", "X570"],
- "AMD Ryzen 5 5600": ["B450", "X470", "A520", "B550", "X570"],
- "AMD Ryzen 5 5600X": ["B450", "X470", "A520", "B550", "X570"],
- "AMD Ryzen 5 5600G": ["B450", "X470", "A520", "B550", "X570"],
- "AMD Ryzen 7 5700X": ["B450", "X470", "A520", "B550", "X570"],
- "AMD Ryzen 7 5700G": ["B450", "X470", "A520", "B550", "X570"],
- "AMD Ryzen 7 5800X": ["B450", "X470", "A520", "B550", "X570"],
- "AMD Ryzen 7 5800XT": ["B450", "X470", "A520", "B550", "X570"],
- "AMD Ryzen 9 5900X": ["B450", "X470", "A520", "B550", "X570"],
- "AMD Ryzen 9 5950X": ["B450", "X470", "A520", "B550", "X570"],
-
- // AMD Athlon — conservative selection
- "AMD Athlon 200GE": ["A320", "B450", "X470"],
- "AMD Athlon 3000G": ["A320", "B450", "X470"],
-
- // AMD Ryzen 7000
- "AMD Ryzen 5 7500F OEM/Tray": ["A620", "B650", "B650E", "X670", "X670E", "B840", "B850", "X870", "X870E"],
- "AMD Ryzen 5 7600": ["A620", "B650", "B650E", "X670", "X670E", "B840", "B850", "X870", "X870E"],
- "AMD Ryzen 5 7600X": ["A620", "B650", "B650E", "X670", "X670E", "B840", "B850", "X870", "X870E"],
- "AMD Ryzen 7 7700": ["A620", "B650", "B650E", "X670", "X670E", "B840", "B850", "X870", "X870E"],
- "AMD Ryzen 7 7700X": ["A620", "B650", "B650E", "X670", "X670E", "B840", "B850", "X870", "X870E"],
- "AMD Ryzen 7 7800X3D": ["A620", "B650", "B650E", "X670", "X670E", "B840", "B850", "X870", "X870E"],
- "AMD Ryzen 9 7900X": ["A620", "B650", "B650E", "X670", "X670E", "B840", "B850", "X870", "X870E"],
- "AMD Ryzen 9 7900X3D": ["A620", "B650", "B650E", "X670", "X670E", "B840", "B850", "X870", "X870E"],
- "AMD Ryzen 9 7950X": ["A620", "B650", "B650E", "X670", "X670E", "B840", "B850", "X870", "X870E"],
- "AMD Ryzen 9 7950X3D": ["A620", "B650", "B650E", "X670", "X670E", "B840", "B850", "X870", "X870E"],
-
- // AMD Ryzen 8000
- "AMD Ryzen 5 8600G": ["A620", "B650", "B650E", "X670", "X670E", "B840", "B850", "X870", "X870E"],
-
- // AMD Ryzen 9000
- "AMD Ryzen 5 9600X": ["A620", "B650", "B650E", "X670", "X670E", "B840", "B850", "X870", "X870E"],
- "AMD Ryzen 7 9700X": ["A620", "B650", "B650E", "X670", "X670E", "B840", "B850", "X870", "X870E"],
- "AMD Ryzen 7 9800X3D": ["A620", "B650", "B650E", "X670", "X670E", "B840", "B850", "X870", "X870E"],
- "AMD Ryzen 9 9900X": ["A620", "B650", "B650E", "X670", "X670E", "B840", "B850", "X870", "X870E"],
- "AMD Ryzen 9 9950X": ["A620", "B650", "B650E", "X670", "X670E", "B840", "B850", "X870", "X870E"],
- "AMD Ryzen 9 9950X3D": ["A620", "B650", "B650E", "X670", "X670E", "B840", "B850", "X870", "X870E"],
-};
diff --git a/frontend/src/hooks/compatability/checkCompatability.ts b/frontend/src/hooks/compatibility/checkCompatability.ts
similarity index 100%
rename from frontend/src/hooks/compatability/checkCompatability.ts
rename to frontend/src/hooks/compatibility/checkCompatability.ts
diff --git a/frontend/src/hooks/compatability/data/motherboardCpuSupport.ts b/frontend/src/hooks/compatibility/data/motherboardCpuSupport.ts
similarity index 100%
rename from frontend/src/hooks/compatability/data/motherboardCpuSupport.ts
rename to frontend/src/hooks/compatibility/data/motherboardCpuSupport.ts
diff --git a/frontend/src/hooks/compatibility/data/supportedChipsets.ts b/frontend/src/hooks/compatibility/data/supportedChipsets.ts
new file mode 100644
index 0000000..fd5f461
--- /dev/null
+++ b/frontend/src/hooks/compatibility/data/supportedChipsets.ts
@@ -0,0 +1,590 @@
+// Incomplete chipset-family candidates, NOT an exact motherboard CPU support list.
+// Missing combinations must remain UNKNOWN. BIOS support is checked separately.
+// Board-specific verified exceptions belong in motherboardCpuSupport.ts.
+export const supportedChipsets: Record = {
+ "Intel Core i5 4440": ["H81", "B85", "H87", "Z87", "H97", "Z97"],
+ "Intel Core i5 4460": ["H81", "B85", "H87", "Z87", "H97", "Z97"],
+ "Intel Core i5 4590": ["H81", "B85", "H87", "Z87", "H97", "Z97"],
+ "Intel Core i5 4690": ["H81", "B85", "H87", "Z87", "H97", "Z97"],
+ "Intel Core i7 4770": ["H81", "B85", "H87", "Z87", "H97", "Z97"],
+ "Intel Core i7 4770K": ["H81", "B85", "H87", "Z87", "H97", "Z97"],
+ "Intel Core i7 4790K": ["H81", "B85", "H87", "Z87", "H97", "Z97"],
+
+ // Intel 6th generation
+ "Intel Core i3 6100": [
+ "H110",
+ "B150",
+ "H170",
+ "Z170",
+ "B250",
+ "H270",
+ "Z270",
+ ],
+ "Intel Core i5 6500": [
+ "H110",
+ "B150",
+ "H170",
+ "Z170",
+ "B250",
+ "H270",
+ "Z270",
+ ],
+ "Intel Core i5 6600K": [
+ "H110",
+ "B150",
+ "H170",
+ "Z170",
+ "B250",
+ "H270",
+ "Z270",
+ ],
+ "Intel Core i7 6700 OEM/Tray": [
+ "H110",
+ "B150",
+ "H170",
+ "Z170",
+ "B250",
+ "H270",
+ "Z270",
+ ],
+ "Intel Core i7 6700K": [
+ "H110",
+ "B150",
+ "H170",
+ "Z170",
+ "B250",
+ "H270",
+ "Z270",
+ ],
+
+ // Intel 7th generation
+ "Intel Core i5 7400": [
+ "H110",
+ "B150",
+ "H170",
+ "Z170",
+ "B250",
+ "H270",
+ "Z270",
+ ],
+ "Intel Core i5 7500": [
+ "H110",
+ "B150",
+ "H170",
+ "Z170",
+ "B250",
+ "H270",
+ "Z270",
+ ],
+ "Intel Core i7 7700K": [
+ "H110",
+ "B150",
+ "H170",
+ "Z170",
+ "B250",
+ "H270",
+ "Z270",
+ ],
+
+ // Intel 8th generation
+ "Intel Core i3 8100": ["H310", "B360", "B365", "H370", "Z370", "Z390"],
+ "Intel Core i3-8350K": ["H310", "B360", "B365", "H370", "Z370", "Z390"],
+ "Intel Core i5 8400": ["H310", "B360", "B365", "H370", "Z370", "Z390"],
+ "Intel Core i5-8600K": ["H310", "B360", "B365", "H370", "Z370", "Z390"],
+ "Intel Core i7 8700K": ["H310", "B360", "B365", "H370", "Z370", "Z390"],
+
+ // Intel 9th generation
+ "Intel Core i5 9600KF": ["H310", "B360", "B365", "H370", "Z370", "Z390"],
+ "Intel Core i7 9700K": ["H310", "B360", "B365", "H370", "Z370", "Z390"],
+ "Intel Core i9 9900K": ["H310", "B360", "B365", "H370", "Z370", "Z390"],
+ "Intel Core i9-9900KF": ["H310", "B360", "B365", "H370", "Z370", "Z390"],
+
+ // Intel 10th generation
+ "Intel Core i5 10400": [
+ "H410",
+ "B460",
+ "H470",
+ "Z490",
+ "H510",
+ "B560",
+ "H570",
+ "Z590",
+ ],
+ "Intel Core i5 10600K": [
+ "H410",
+ "B460",
+ "H470",
+ "Z490",
+ "H510",
+ "B560",
+ "H570",
+ "Z590",
+ ],
+ "Intel Core i7 10700KF": [
+ "H410",
+ "B460",
+ "H470",
+ "Z490",
+ "H510",
+ "B560",
+ "H570",
+ "Z590",
+ ],
+
+ // Intel 11th generation — excludes H410 and B460
+ "Intel Core i5 11400": ["H470", "Z490", "H510", "B560", "H570", "Z590"],
+
+ // Intel 12th generation
+ "Intel Core i3 12100": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i3 12100F": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i5 12400": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i5 12400F": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i5 12600K": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i5 12600KF": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i7 12700": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i7 12700F": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i7 12700K": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i7 12700KF": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i9 12900K": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+
+ // Intel 13th generation
+ "Intel Core i5 13400F": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i5 13600KF": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i7 13700KF": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i9 13900K": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i9 13900KF": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i9 13900KS": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+
+ // Intel 14th generation
+ "Intel Core i5 14600K": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i5 14600KF": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i7 14700K": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i7 14700KF": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i9-14900K": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+ "Intel Core i9-14900KF": [
+ "H610",
+ "B660",
+ "H670",
+ "Z690",
+ "B760",
+ "H770",
+ "Z790",
+ ],
+
+ // Intel Core Ultra 200
+ "Intel Core Ultra 7 265K": ["H810", "B860", "Z890"],
+ "Intel Core Ultra 9 285K": ["H810", "B860", "Z890"],
+
+ // Intel enthusiast platforms
+ "Intel Core i7 4930K": ["X79"],
+ "Intel Core i7 7820X": ["X299"],
+
+ // AMD Ryzen 1000 — conservative selection
+ "AMD Ryzen 3 1200": ["A320", "B350", "X370", "B450", "X470"],
+ "AMD Ryzen 5 1500X": ["A320", "B350", "X370", "B450", "X470"],
+ "AMD Ryzen 5 1600": ["A320", "B350", "X370", "B450", "X470"],
+ "AMD Ryzen 7 1700": ["A320", "B350", "X370", "B450", "X470"],
+ "AMD Ryzen 7 1800X": ["A320", "B350", "X370", "B450", "X470"],
+
+ // AMD Ryzen 2000
+ "AMD Ryzen 7 2700 OEM/Tray": ["A320", "B350", "X370", "B450", "X470", "X570"],
+ "AMD Ryzen 7 2700X": ["A320", "B350", "X370", "B450", "X470", "X570"],
+
+ // AMD Ryzen 3000 APU — different support from Zen 2 CPUs
+ "AMD Ryzen 3 3200G": ["A320", "B350", "X370", "B450", "X470", "X570"],
+
+ // AMD Ryzen 3000 — some older boards also support these
+ "AMD Ryzen 5 3600": ["B450", "X470", "A520", "B550", "X570"],
+ "AMD Ryzen 5 3600X": ["B450", "X470", "A520", "B550", "X570"],
+ "AMD Ryzen 7 3700X": ["B450", "X470", "A520", "B550", "X570"],
+ "AMD Ryzen 7 3800X OEM/Tray": ["B450", "X470", "A520", "B550", "X570"],
+ "AMD Ryzen 9 3900XT": ["B450", "X470", "A520", "B550", "X570"],
+
+ // AMD Ryzen 4000
+ "AMD Ryzen 5 4500": ["B450", "X470", "A520", "B550", "X570"],
+
+ // AMD Ryzen 5000 — some older boards also support these
+ "AMD Ryzen 5 5500": ["B450", "X470", "A520", "B550", "X570"],
+ "AMD Ryzen 5 5600": ["B450", "X470", "A520", "B550", "X570"],
+ "AMD Ryzen 5 5600X": ["B450", "X470", "A520", "B550", "X570"],
+ "AMD Ryzen 5 5600G": ["B450", "X470", "A520", "B550", "X570"],
+ "AMD Ryzen 7 5700X": ["B450", "X470", "A520", "B550", "X570"],
+ "AMD Ryzen 7 5700G": ["B450", "X470", "A520", "B550", "X570"],
+ "AMD Ryzen 7 5800X": ["B450", "X470", "A520", "B550", "X570"],
+ "AMD Ryzen 7 5800XT": ["B450", "X470", "A520", "B550", "X570"],
+ "AMD Ryzen 9 5900X": ["B450", "X470", "A520", "B550", "X570"],
+ "AMD Ryzen 9 5950X": ["B450", "X470", "A520", "B550", "X570"],
+
+ // AMD Athlon — conservative selection
+ "AMD Athlon 200GE": ["A320", "B450", "X470"],
+ "AMD Athlon 3000G": ["A320", "B450", "X470"],
+
+ // AMD Ryzen 7000
+ "AMD Ryzen 5 7500F OEM/Tray": [
+ "A620",
+ "B650",
+ "B650E",
+ "X670",
+ "X670E",
+ "B840",
+ "B850",
+ "X870",
+ "X870E",
+ ],
+ "AMD Ryzen 5 7600": [
+ "A620",
+ "B650",
+ "B650E",
+ "X670",
+ "X670E",
+ "B840",
+ "B850",
+ "X870",
+ "X870E",
+ ],
+ "AMD Ryzen 5 7600X": [
+ "A620",
+ "B650",
+ "B650E",
+ "X670",
+ "X670E",
+ "B840",
+ "B850",
+ "X870",
+ "X870E",
+ ],
+ "AMD Ryzen 7 7700": [
+ "A620",
+ "B650",
+ "B650E",
+ "X670",
+ "X670E",
+ "B840",
+ "B850",
+ "X870",
+ "X870E",
+ ],
+ "AMD Ryzen 7 7700X": [
+ "A620",
+ "B650",
+ "B650E",
+ "X670",
+ "X670E",
+ "B840",
+ "B850",
+ "X870",
+ "X870E",
+ ],
+ "AMD Ryzen 7 7800X3D": [
+ "A620",
+ "B650",
+ "B650E",
+ "X670",
+ "X670E",
+ "B840",
+ "B850",
+ "X870",
+ "X870E",
+ ],
+ "AMD Ryzen 9 7900X": [
+ "A620",
+ "B650",
+ "B650E",
+ "X670",
+ "X670E",
+ "B840",
+ "B850",
+ "X870",
+ "X870E",
+ ],
+ "AMD Ryzen 9 7900X3D": [
+ "A620",
+ "B650",
+ "B650E",
+ "X670",
+ "X670E",
+ "B840",
+ "B850",
+ "X870",
+ "X870E",
+ ],
+ "AMD Ryzen 9 7950X": [
+ "A620",
+ "B650",
+ "B650E",
+ "X670",
+ "X670E",
+ "B840",
+ "B850",
+ "X870",
+ "X870E",
+ ],
+ "AMD Ryzen 9 7950X3D": [
+ "A620",
+ "B650",
+ "B650E",
+ "X670",
+ "X670E",
+ "B840",
+ "B850",
+ "X870",
+ "X870E",
+ ],
+
+ // AMD Ryzen 8000
+ "AMD Ryzen 5 8600G": [
+ "A620",
+ "B650",
+ "B650E",
+ "X670",
+ "X670E",
+ "B840",
+ "B850",
+ "X870",
+ "X870E",
+ ],
+
+ // AMD Ryzen 9000
+ "AMD Ryzen 5 9600X": [
+ "A620",
+ "B650",
+ "B650E",
+ "X670",
+ "X670E",
+ "B840",
+ "B850",
+ "X870",
+ "X870E",
+ ],
+ "AMD Ryzen 7 9700X": [
+ "A620",
+ "B650",
+ "B650E",
+ "X670",
+ "X670E",
+ "B840",
+ "B850",
+ "X870",
+ "X870E",
+ ],
+ "AMD Ryzen 7 9800X3D": [
+ "A620",
+ "B650",
+ "B650E",
+ "X670",
+ "X670E",
+ "B840",
+ "B850",
+ "X870",
+ "X870E",
+ ],
+ "AMD Ryzen 9 9900X": [
+ "A620",
+ "B650",
+ "B650E",
+ "X670",
+ "X670E",
+ "B840",
+ "B850",
+ "X870",
+ "X870E",
+ ],
+ "AMD Ryzen 9 9950X": [
+ "A620",
+ "B650",
+ "B650E",
+ "X670",
+ "X670E",
+ "B840",
+ "B850",
+ "X870",
+ "X870E",
+ ],
+ "AMD Ryzen 9 9950X3D": [
+ "A620",
+ "B650",
+ "B650E",
+ "X670",
+ "X670E",
+ "B840",
+ "B850",
+ "X870",
+ "X870E",
+ ],
+};
diff --git a/frontend/src/hooks/usePartImageLayout.ts b/frontend/src/hooks/usePartImageLayout.ts
index bca23fd..5c3236b 100644
--- a/frontend/src/hooks/usePartImageLayout.ts
+++ b/frontend/src/hooks/usePartImageLayout.ts
@@ -1,83 +1,108 @@
-import { useEffect, useRef, useState, type CSSProperties, type SyntheticEvent } from 'react'
+import {
+ useEffect,
+ useRef,
+ useState,
+ type CSSProperties,
+ type SyntheticEvent,
+} from "react";
// THIS IS ENTIRE HOOK IS COMPLETELY PROMPTED
function trimImageWhitespace(image: HTMLImageElement): string {
- const canvas = document.createElement('canvas')
- canvas.width = image.naturalWidth
- canvas.height = image.naturalHeight
- const context = canvas.getContext('2d')
- if (!context || !canvas.width || !canvas.height) return image.src
+ const canvas = document.createElement("canvas");
+ canvas.width = image.naturalWidth;
+ canvas.height = image.naturalHeight;
+ const context = canvas.getContext("2d");
+ if (!context || !canvas.width || !canvas.height) return image.src;
try {
- context.drawImage(image, 0, 0)
- const { data } = context.getImageData(0, 0, canvas.width, canvas.height)
- let top = canvas.height
- let bottom = -1
+ context.drawImage(image, 0, 0);
+ const { data } = context.getImageData(0, 0, canvas.width, canvas.height);
+ let top = canvas.height;
+ let bottom = -1;
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
- const i = (y * canvas.width + x) * 4
- const alpha = data[i + 3] / 255
+ const i = (y * canvas.width + x) * 4;
+ const alpha = data[i + 3] / 255;
// Composite transparency onto white; allow a little compression noise.
- const darkest = Math.min(data[i], data[i + 1], data[i + 2])
+ const darkest = Math.min(data[i], data[i + 1], data[i + 2]);
if (255 - (255 - darkest) * alpha < 245) {
- top = Math.min(top, y)
- bottom = y
- break
+ top = Math.min(top, y);
+ bottom = y;
+ break;
}
}
}
- if (bottom < top || (top === 0 && bottom === canvas.height - 1)) return image.src
+ if (bottom < top || (top === 0 && bottom === canvas.height - 1))
+ return image.src;
- const height = bottom - top + 1
- canvas.height = height
- context.drawImage(image, 0, top, canvas.width, height, 0, 0, canvas.width, height)
- return canvas.toDataURL()
+ const height = bottom - top + 1;
+ canvas.height = height;
+ context.drawImage(
+ image,
+ 0,
+ top,
+ canvas.width,
+ height,
+ 0,
+ 0,
+ canvas.width,
+ height,
+ );
+ return canvas.toDataURL();
} catch {
// External images may block pixel access; keep displaying the original.
- return image.src
+ return image.src;
}
}
// Keep image cropping and the panel's image-dependent positioning together.
export function usePartImageLayout(imageUrl: string, expanded: boolean) {
- const headerRef = useRef(null)
- const imageAreaRef = useRef(null)
- const [croppedImage, setCroppedImage] = useState({ source: '', url: '' })
- const [layout, setLayout] = useState({ headerHeight: 0, imageHeight: 0 })
+ const headerRef = useRef(null);
+ const imageAreaRef = useRef(null);
+ const [croppedImage, setCroppedImage] = useState({ source: "", url: "" });
+ const [layout, setLayout] = useState({ headerHeight: 0, imageHeight: 0 });
useEffect(() => {
- const header = headerRef.current
- const imageArea = imageAreaRef.current
- if (!header || !imageArea) return
+ const header = headerRef.current;
+ const imageArea = imageAreaRef.current;
+ if (!header || !imageArea) return;
// Measure again when the dialog opens, the image loads, or the viewport changes.
const observer = new ResizeObserver(() => {
- const headerHeight = header.offsetHeight
- const imageHeight = imageArea.offsetHeight
- if (!headerHeight) return
+ const headerHeight = header.offsetHeight;
+ const imageHeight = imageArea.offsetHeight;
+ if (!headerHeight) return;
setLayout((previous) =>
- previous.headerHeight === headerHeight && previous.imageHeight === imageHeight
+ previous.headerHeight === headerHeight &&
+ previous.imageHeight === imageHeight
? previous
: { headerHeight, imageHeight },
- )
- })
- observer.observe(header)
- observer.observe(imageArea)
- return () => observer.disconnect()
- }, [])
+ );
+ });
+ observer.observe(header);
+ observer.observe(imageArea);
+ return () => observer.disconnect();
+ }, []);
function onImageLoad(event: SyntheticEvent) {
if (croppedImage.source !== imageUrl) {
- setCroppedImage({ source: imageUrl, url: trimImageWhitespace(event.currentTarget) })
+ setCroppedImage({
+ source: imageUrl,
+ url: trimImageWhitespace(event.currentTarget),
+ });
}
}
const specsStyle: CSSProperties = {
- top: expanded ? 0 : layout.headerHeight ? layout.headerHeight + layout.imageHeight + 12 : '45%',
+ top: expanded
+ ? 0
+ : layout.headerHeight
+ ? layout.headerHeight + layout.imageHeight + 12
+ : "45%",
paddingTop: expanded ? layout.headerHeight + 16 : 20,
- }
+ };
return {
imageSrc: croppedImage.source === imageUrl ? croppedImage.url : imageUrl,
@@ -85,5 +110,5 @@ export function usePartImageLayout(imageUrl: string, expanded: boolean) {
headerRef,
imageAreaRef,
specsStyle,
- }
+ };
}
diff --git a/frontend/src/hooks/useParts.ts b/frontend/src/hooks/useParts.ts
index 1d75907..98f35f2 100644
--- a/frontend/src/hooks/useParts.ts
+++ b/frontend/src/hooks/useParts.ts
@@ -1,25 +1,25 @@
-import { useEffect, useState } from 'react'
-import { getParts, type PartsOptions, type PartsPage } from '@/api/parts'
+import { useEffect, useState } from "react";
+import { getParts, type PartsOptions, type PartsPage } from "@/api/parts";
type RequestResult = {
- key: string
- data: PartsPage | null
- error: string | null
-}
+ key: string;
+ data: PartsPage | null;
+ error: string | null;
+};
export function useParts({
- search = '',
+ search = "",
category,
manufacturers,
- sortBy = 'NAME',
- sortDirection = 'ASC',
+ sortBy = "NAME",
+ sortDirection = "ASC",
first = 20,
- after
+ after,
}: PartsOptions = {}) {
+ const [result, setResult] = useState(null);
- const [result, setResult] = useState (null)
-
- const requestKey = JSON.stringify({//the calling component, may call this hook on render, this KEY will stop a request if the last render had the same parameters
+ const requestKey = JSON.stringify({
+ //the calling component, may call this hook on render, this KEY will stop a request if the last render had the same parameters
search,
category,
manufacturers: manufacturers ?? [],
@@ -27,49 +27,48 @@ export function useParts({
sortDirection,
first,
after,
- })
-
+ });
useEffect(() => {
- const controller = new AbortController() //Creates a controller that lets you abort the fetch if a new call comes before this one finishes,e.g. typing a new letter after the search call has started but not finished.
- const options: PartsOptions = JSON.parse(requestKey) //Turns requestKey into object of type PartsOptions.
+ const controller = new AbortController(); //Creates a controller that lets you abort the fetch if a new call comes before this one finishes,e.g. typing a new letter after the search call has started but not finished.
+ const options: PartsOptions = JSON.parse(requestKey); //Turns requestKey into object of type PartsOptions.
const timeout = setTimeout(() => {
getParts(options, controller.signal)
- .then((data) => {
- if(!controller.signal.aborted){
- setResult({
- key:requestKey,
- data,
- error: null,
- })
- }
- })
- .catch((error: unknown) => {
- if(!controller.signal.aborted){
- setResult({
- key: requestKey,
- data: null,
- error:
- error instanceof Error ? error.message : 'Could not load parts'
- })
- }
- })
- }, 300) //wait 300ms before executing in case the request changes
+ .then((data) => {
+ if (!controller.signal.aborted) {
+ setResult({
+ key: requestKey,
+ data,
+ error: null,
+ });
+ }
+ })
+ .catch((error: unknown) => {
+ if (!controller.signal.aborted) {
+ setResult({
+ key: requestKey,
+ data: null,
+ error:
+ error instanceof Error ? error.message : "Could not load parts",
+ });
+ }
+ });
+ }, 300); //wait 300ms before executing in case the request changes
return () => {
- clearTimeout(timeout)
- controller.abort()
- }
- }, [requestKey]) //compares the requestKey from the last call with the requestKey from this call, only if they differ useEffect is executed.
+ clearTimeout(timeout);
+ controller.abort();
+ };
+ }, [requestKey]); //compares the requestKey from the last call with the requestKey from this call, only if they differ useEffect is executed.
- const currentResult = result?.key === requestKey ? result : null
+ const currentResult = result?.key === requestKey ? result : null;
- return{
+ return {
parts: currentResult?.data?.parts ?? [],
totalCount: currentResult?.data?.totalCount ?? 0,
pageInfo: currentResult?.data?.pageInfo,
loading: currentResult === null,
error: currentResult?.error ?? null,
- }
-}
\ No newline at end of file
+ };
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index c3fbb7b..59ea1bb 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -2,9 +2,13 @@
@import "tailwindcss";
@import "./styles/colors.css";
-:focus-visible {
- outline: 3px solid #ffff;
- outline-offset: 4px;
+/* Custom focus-visible styles for accessibility*/
+@layer base {
+ :focus-visible {
+ outline: 3px solid #fff;
+ outline-offset: 4px;
+ border-radius: 8px;
+ }
}
html:has(dialog:modal) {
overflow: hidden;
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index d1778d6..b9c0d0d 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -2,12 +2,15 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
-import BuildProvider from './context/BuildProvider'
+import BuildProvider from "./context/BuildProvider";
+import { BrowserRouter } from "react-router";
createRoot(document.getElementById("root")!).render(
-
-
-
+
+
+
+
+
,
);
diff --git a/frontend/src/pages/categoryPage/categoryPage.tsx b/frontend/src/pages/categoryPage/categoryPage.tsx
index 1167f15..11d417d 100644
--- a/frontend/src/pages/categoryPage/categoryPage.tsx
+++ b/frontend/src/pages/categoryPage/categoryPage.tsx
@@ -2,6 +2,7 @@ import AllPartsPreview from "../../components/AllPartsPreview";
import { categories } from "@/types/categories";
import FilterComponent from "@/components/FilterComponent";
import { useState } from "react";
+import { Link } from "react-router";
import { ArrowLeft } from "lucide-react";
import type { PartsOptions } from "@/api/parts";
@@ -26,13 +27,13 @@ export default function CategoryPage({
return (
-
Back to home
-
+
{invalidCategory
diff --git a/frontend/src/pages/communityPage/communityPage.tsx b/frontend/src/pages/communityPage/communityPage.tsx
index f7a5a18..9d30ac4 100644
--- a/frontend/src/pages/communityPage/communityPage.tsx
+++ b/frontend/src/pages/communityPage/communityPage.tsx
@@ -1,35 +1,35 @@
-import { useEffect, useState } from 'react'
-import { getBuilds, type SavedBuild } from '@/api/builds'
-import Button from '@/components/Button'
-import BuildParts from './components/BuildParts'
+import { useEffect, useState } from "react";
+import { getBuilds, type SavedBuild } from "@/api/builds";
+import Button from "@/components/buttons/Button";
+import BuildParts from "./components/BuildParts";
export default function CommunityPage() {
- const [builds, setBuilds] = useState([])
- const [offset, setOffset] = useState(0)
- const [loading, setLoading] = useState(true)
- const [error, setError] = useState('')
+ const [builds, setBuilds] = useState([]);
+ const [offset, setOffset] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
useEffect(() => {
- const controller = new AbortController()
+ const controller = new AbortController();
getBuilds(offset, controller.signal)
.then((data) => {
- if (!controller.signal.aborted) setBuilds(data)
+ if (!controller.signal.aborted) setBuilds(data);
})
.catch(() => {
if (!controller.signal.aborted) {
- setError('Could not load community builds.')
+ setError("Could not load community builds.");
}
})
.finally(() => {
- if (!controller.signal.aborted) setLoading(false)
- })
- return () => controller.abort()
- }, [offset])
+ if (!controller.signal.aborted) setLoading(false);
+ });
+ return () => controller.abort();
+ }, [offset]);
function changePage(nextOffset: number) {
- setLoading(true)
- setError('')
- setOffset(nextOffset)
+ setLoading(true);
+ setError("");
+ setOffset(nextOffset);
}
return (
@@ -44,19 +44,38 @@ export default function CommunityPage() {
{builds.length === 0 && No builds published yet.
}
{builds.slice(0, 20).map((build) => (
-
+
{build.tittel}
- {build.comment && {build.comment}
}
+ {build.comment && (
+ {build.comment}
+ )}
))}
- {offset > 0 && }
- {builds.length > 20 && }
+ {offset > 0 && (
+
+ )}
+ {builds.length > 20 && (
+
+ )}
>
)}
- )
+ );
}
diff --git a/frontend/src/pages/communityPage/components/BuildParts.tsx b/frontend/src/pages/communityPage/components/BuildParts.tsx
index e562a53..8e83550 100644
--- a/frontend/src/pages/communityPage/components/BuildParts.tsx
+++ b/frontend/src/pages/communityPage/components/BuildParts.tsx
@@ -1,6 +1,6 @@
-import type { SavedBuild } from '@/api/builds'
+import type { SavedBuild } from "@/api/builds";
-export default function BuildParts({ parts }: { parts: SavedBuild['parts'] }) {
+export default function BuildParts({ parts }: { parts: SavedBuild["parts"] }) {
return (
{parts.map((part) => (
@@ -9,5 +9,5 @@ export default function BuildParts({ parts }: { parts: SavedBuild['parts'] }) {
))}
- )
+ );
}
diff --git a/frontend/src/pages/homePage/components/BuildPCGraphic.tsx b/frontend/src/pages/homePage/components/BuildPCGraphic.tsx
index 0501d4d..add805e 100644
--- a/frontend/src/pages/homePage/components/BuildPCGraphic.tsx
+++ b/frontend/src/pages/homePage/components/BuildPCGraphic.tsx
@@ -1,7 +1,9 @@
-import Button from "@/components/Button";
+import { Link, useNavigate } from "react-router";
+import Button from "@/components/buttons/Button";
import heroImage from "@/assets/hero_image.webp";
export default function BuildPCGraphic() {
+ const navigate = useNavigate();
return (
{
- window.location.href = "?page=builder";
+ navigate("/builder");
}}
>
→ Start building
-
Explore components
-
+
);
diff --git a/frontend/src/pages/homePage/components/CategorySelection.tsx b/frontend/src/pages/homePage/components/CategorySelection.tsx
index 536f6db..d6dae1f 100644
--- a/frontend/src/pages/homePage/components/CategorySelection.tsx
+++ b/frontend/src/pages/homePage/components/CategorySelection.tsx
@@ -1,3 +1,4 @@
+import { Link } from "react-router";
import { categories } from "@/types/categories";
export default function CategorySelection() {
@@ -13,23 +14,23 @@ export default function CategorySelection() {
-
View all components →
-
+
diff --git a/frontend/src/pages/homePage/homePage.tsx b/frontend/src/pages/homePage/homePage.tsx
index c3458a8..ca1d905 100644
--- a/frontend/src/pages/homePage/homePage.tsx
+++ b/frontend/src/pages/homePage/homePage.tsx
@@ -1,3 +1,4 @@
+import { Link } from "react-router";
import { useState } from "react";
import AllPartsPreview from "../../components/AllPartsPreview";
import type { PartsOptions } from "@/api/parts";
@@ -24,12 +25,12 @@ export default function HomePage() {
-
Browse processors →
-
+
diff --git a/frontend/src/pages/pcBuilderPage/components/ClearBuild.tsx b/frontend/src/pages/pcBuilderPage/components/ClearBuild.tsx
index bbd2b1c..231eb7f 100644
--- a/frontend/src/pages/pcBuilderPage/components/ClearBuild.tsx
+++ b/frontend/src/pages/pcBuilderPage/components/ClearBuild.tsx
@@ -1,6 +1,6 @@
import { useRef } from "react";
-import Button from "@/components/Button";
-import { useBuild } from "@/context/BuildContext";
+import Button from "@/components/buttons/Button";
+import { useBuild } from "@/context/buildContext";
export default function ClearBuild() {
const dialogRef = useRef(null);
diff --git a/frontend/src/pages/pcBuilderPage/components/CompatabilityComponent.tsx b/frontend/src/pages/pcBuilderPage/components/CompatabilityComponent.tsx
index 7690479..4b92480 100644
--- a/frontend/src/pages/pcBuilderPage/components/CompatabilityComponent.tsx
+++ b/frontend/src/pages/pcBuilderPage/components/CompatabilityComponent.tsx
@@ -1,6 +1,6 @@
import { useId, useState } from "react";
import type { PcPart } from "@/types/types";
-import { checkCompatability } from "@/hooks/compatability/checkCompatability";
+import { checkCompatability } from "@/hooks/compatibility/checkCompatability";
import {
ThumbsUp,
TriangleAlert,
diff --git a/frontend/src/pages/pcBuilderPage/components/ComponentSelectionComponent.tsx b/frontend/src/pages/pcBuilderPage/components/ComponentSelectionComponent.tsx
index fd1641f..d46237a 100644
--- a/frontend/src/pages/pcBuilderPage/components/ComponentSelectionComponent.tsx
+++ b/frontend/src/pages/pcBuilderPage/components/ComponentSelectionComponent.tsx
@@ -1,11 +1,12 @@
+import { useLocation, useNavigate } from "react-router";
import type { PcPart } from "@/types/types";
import { useEffect, useRef, useState } from "react";
import {
formatSpecification,
getSpecifications,
} from "@/utils/partSpecifications";
-import { useBuild } from "@/context/BuildContext";
-import Button from "@/components/Button";
+import { useBuild } from "@/context/buildContext";
+import Button from "@/components/buttons/Button";
import { ArrowLeftRight, Plus, Trash2 } from "lucide-react";
type ComponentSelectionComponentProps = {
@@ -19,22 +20,17 @@ export default function ComponentSelectionComponent({
category,
categoryValue,
}: ComponentSelectionComponentProps) {
+ const navigate = useNavigate();
+ const location = useLocation();
const { removePart } = useBuild();
const [failedImage, setFailedImage] = useState("");
const imageUrl = part?.image_url?.trim() ?? "";
const sectionRef = useRef(null);
- const [highlighted] = useState(
- () =>
- new URLSearchParams(window.location.search).get("highlight") ===
- categoryValue,
- );
+ const highlighted = location.state?.highlight === categoryValue;
- // Move screen to the center of the new component added
+ // Move screen to the center of the new component added.
useEffect(() => {
if (!highlighted) return;
- const url = new URL(window.location.href);
- url.searchParams.delete("highlight");
- window.history.replaceState(window.history.state, "", url);
const frame = requestAnimationFrame(() => {
sectionRef.current?.scrollIntoView({
behavior: "smooth",
@@ -47,6 +43,12 @@ export default function ComponentSelectionComponent({
return (
{
+ if (event.target !== event.currentTarget || !highlighted) return;
+ const state = { ...location.state };
+ delete state.highlight;
+ navigate(location.pathname, { replace: true, state });
+ }}
className={`scroll-mt-24 flex min-h-30 w-full flex-col sm:flex-row items-stretch sm:items-center sm:justify-between gap-3 sm:gap-6 px-3 py-3 sm:py-1 border-b border-gray-600 text-sm
${part ? "bg-[#303B3F]" : "bg-background"}
${highlighted ? "animate-[build-added_2.2s]" : ""}`}
@@ -87,7 +89,9 @@ export default function ComponentSelectionComponent({