Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
**/node_modules/
**/dist/
**/dist-ssr/
**/coverage/
**/*.tsbuildinfo
**/package-lock.json
backend/data/
7 changes: 7 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"singleQuote": false,
"semi": true,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "all"
}
27 changes: 21 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.

Expand Down Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion backend/server/.prettierrc

This file was deleted.

2 changes: 1 addition & 1 deletion backend/server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions backend/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
89 changes: 69 additions & 20 deletions backend/server/src/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 *`,
Expand All @@ -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" },
});
Expand All @@ -438,8 +479,16 @@ export const resolvers = {
Build: {
createdAt: (row: { created_at: Date }) => row.created_at.toISOString(),
parts: async (row: Record<string, unknown>) => {
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<PartRow>(
`SELECT ${PART_COLUMNS} FROM parts WHERE id = ANY($1::uuid[]) ORDER BY category`,
[ids],
Expand Down
4 changes: 2 additions & 2 deletions backend/server/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,11 @@ export const typeDefs = /* GraphQL */ `
tittel: String!
comment: String
partIds: [ID!]!
}
}
type Mutation {
publishBuild(input: PublishBuildInput!): Build!
}
}
type Query {
"""
Expand Down
20 changes: 11 additions & 9 deletions frontend/eslint.config.js
Original file line number Diff line number Diff line change
@@ -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,
},
},
])
]);
4 changes: 2 additions & 2 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>Full Circuit</title>
</head>
<body>
<div id="root"></div>
Expand Down
Loading