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 ? (
-
+
-

+

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 (
- +

{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({ +
+
{ if (pending.current) event.preventDefault() }} + onCancel={(event) => { + if (pending.current) event.preventDefault(); + }} > -

Publish build

+

+ Publish build +

{published ? (

Your build is published!

- View community builds + + View community builds +
) : ( -
{ - event.preventDefault() - if (pending.current) return - if (!title.trim()) { setError('Enter a build name.'); return } - pending.current = true - setSaving(true) - setError('') - try { - await publishBuild({ - tittel: title.trim(), comment: comment.trim(), - partIds: Object.values(parts).map(part => part.id), - }) - setPublished(true) - setTitle('') - setComment('') - } catch (error) { - setError(error instanceof Error ? error.message : 'Could not publish build.') - } finally { - pending.current = false - setSaving(false) - } - }}> -

Share this build with everyone in the community.

+ { + event.preventDefault(); + if (pending.current) return; + if (!title.trim()) { + setError("Enter a build name."); + return; + } + pending.current = true; + setSaving(true); + setError(""); + try { + await publishBuild({ + tittel: title.trim(), + comment: comment.trim(), + partIds: Object.values(parts).map((part) => part.id), + }); + setPublished(true); + setTitle(""); + setComment(""); + } catch (error) { + setError( + error instanceof Error + ? error.message + : "Could not publish build.", + ); + } finally { + pending.current = false; + setSaving(false); + } + }} + > +

+ Share this build with everyone in the community. +