diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a8e0b73d..fb3794cf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @seveibar @tscircuit/core +* @seveibar @imrishabh18 @tscircuit/core diff --git a/.github/workflows/bun-pver-release.yml b/.github/workflows/bun-pver-release.yml new file mode 100644 index 00000000..dd52310b --- /dev/null +++ b/.github/workflows/bun-pver-release.yml @@ -0,0 +1,27 @@ +# Created using @tscircuit/plop (npm install -g @tscircuit/plop) +name: Publish to npm +on: + push: + branches: + - main + paths: + - 'fake-snippets-api/**' +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + - uses: actions/setup-node@v3 + with: + node-version: 20 + registry-url: https://registry.npmjs.org/ + - run: npm install -g pver + - run: bun install --frozen-lockfile + - run: bun run build:fake-api + - run: pver release + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/playwright-test.yml b/.github/workflows/playwright-test.yml index 85b6f5f8..ebe13cc0 100644 --- a/.github/workflows/playwright-test.yml +++ b/.github/workflows/playwright-test.yml @@ -2,6 +2,8 @@ name: Playwright Test on: + push: + branches: [main] pull_request: jobs: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index a6c2355a..508cf256 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -20,11 +20,11 @@ jobs: stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.' close-issue-message: 'This issue has been automatically closed due to inactivity. Please feel free to reopen it if you believe it still needs attention.' - # PR config - days-before-pr-stale: 30 - days-before-pr-close: 7 - stale-pr-message: 'This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.' - close-pr-message: 'This pull request has been automatically closed due to inactivity. Please feel free to reopen it if you believe it still needs attention.' + # Pull request config + stale-pr-message: 'This PR has been automatically marked as stale because it has had no recent activity. It will be closed if no further activity occurs.' + close-pr-message: 'This PR was closed because it has been inactive for 1 day since being marked as stale.' + days-before-pr-stale: 2 + days-before-pr-close: 1 # Labels stale-issue-label: 'stale' diff --git a/.gitignore b/.gitignore index 6b393791..0190dac5 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,7 @@ package-lock.json test-results .yalc -yalc.lock \ No newline at end of file +yalc.lock + +public/assets +public/sitemap.xml \ No newline at end of file diff --git a/README.md b/README.md index b059aa89..e0ceb0bd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,11 @@ # TSCircuit Snippets + + Rewarded Bounties + + + Open Bounties + + [Docs](https://docs.tscircuit.com) · [Website](https://tscircuit.com) · [Twitter](https://x.com/tscircuit) · [discord](https://tscircuit.com/community/join-redirect) · [Quickstart](https://docs.tscircuit.com/quickstart) · [Online Playground](https://tscircuit.com/playground) @@ -90,6 +97,10 @@ This will prompt you to select a specific test file to update. We welcome contributions to TSCircuit Snippets! Please read our [Contributing Guide](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests. +## Example Snippets + +- [Arduino Nano Servo Breakout Board](https://tscircuit.com/Abse2001/Arduino-Nano-Servo-Breakout) + ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/biome.json b/biome.json index 1492d908..419c188b 100644 --- a/biome.json +++ b/biome.json @@ -8,7 +8,7 @@ "indentStyle": "space" }, "files": { - "ignore": ["cosmos-export", "dist", "package.json"] + "ignore": ["cosmos-export", "dist", "package.json", ".vercel"] }, "javascript": { "formatter": { diff --git a/bun-tests/fake-snippets-api/fixtures/get-circuit-json.ts b/bun-tests/fake-snippets-api/fixtures/get-circuit-json.ts index 4484a60d..a7892e7f 100644 --- a/bun-tests/fake-snippets-api/fixtures/get-circuit-json.ts +++ b/bun-tests/fake-snippets-api/fixtures/get-circuit-json.ts @@ -136,7 +136,7 @@ export const generateCircuitJson = async ({ } // Construct and render the circuit - const circuit = constructCircuit(UserElm, type) + const circuit = constructCircuit({ UserElm, type }) // Wait for the circuit to settle await circuit.renderUntilSettled() diff --git a/bun.lockb b/bun.lockb index 7b73b545..202d5c2d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/fake-snippets-api/lib/db/autoload-dev-snippets.ts b/fake-snippets-api/lib/db/autoload-dev-snippets.ts new file mode 100644 index 00000000..ecd8cf32 --- /dev/null +++ b/fake-snippets-api/lib/db/autoload-dev-snippets.ts @@ -0,0 +1,84 @@ +import axios from "redaxios" +import fs from "fs" +import path from "path" +import { DbClient } from "./db-client" + +const extractTsciDependencies = ( + code: string, +): Array<{ owner: string; name: string }> => { + const regex = /@tsci\/([^.]+)\.([^"'\s]+)/g + const matches = Array.from(code.matchAll(regex)) + return matches.map((match) => ({ + owner: match[1], + name: match[2], + })) +} + +const registryApi = axios.create({ + baseURL: "https://registry-api.tscircuit.com", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, +}) + +const fetchSnippetFromRegistry = async (owner: string, name: string) => { + const response = await registryApi.get( + `/snippets/get?owner_name=${owner}&unscoped_name=${name}`, + ) + return response.data.snippet +} + +const loadSnippetWithDependencies = async ( + db: DbClient, + owner: string, + name: string, + loadedSnippets = new Set(), +) => { + const snippetKey = `${owner}/${name}` + if (loadedSnippets.has(snippetKey)) { + return + } + + try { + const snippet = await fetchSnippetFromRegistry(owner, name) + + if (db.getSnippetByAuthorAndName(owner, name)) return + + db.addSnippet(snippet) + loadedSnippets.add(snippetKey) + + const dependencies = extractTsciDependencies(snippet.code) + for (const dep of dependencies) { + loadSnippetWithDependencies(db, dep.owner, dep.name, loadedSnippets) + } + } catch (e) { + console.error(`✗ Failed to load ${snippetKey}:`, e) + } +} + +export const loadAutoloadSnippets = async (db: DbClient) => { + try { + const autoloadPath = path.join( + path.dirname(__dirname), + "db", + "autoload-snippets.json", + ) + if (fs.existsSync(autoloadPath)) { + const autoloadContent = JSON.parse(fs.readFileSync(autoloadPath, "utf8")) + console.log("Loading development snippets from registry...") + + const loadedSnippets = new Set() + for (const snippetRef of autoloadContent.snippets) { + loadSnippetWithDependencies( + db, + snippetRef.owner, + snippetRef.name, + loadedSnippets, + ) + } + } + } catch (e) { + console.error("Failed to load autoload-snippets.json:", e) + } +} diff --git a/fake-snippets-api/lib/db/autoload-snippets.json b/fake-snippets-api/lib/db/autoload-snippets.json new file mode 100644 index 00000000..58a1b42c --- /dev/null +++ b/fake-snippets-api/lib/db/autoload-snippets.json @@ -0,0 +1,24 @@ +{ + "snippets": [ + { + "owner": "Abse2001", + "name": "Arduino-Nano-Servo-Breakout" + }, + { + "owner": "ShiboSoftwareDev", + "name": "Wifi-Camera-Module" + }, + { + "owner": "imrishabh18", + "name": "Arduino-nano" + }, + { + "owner": "seveibar", + "name": "usb-c-flashlight" + }, + { + "owner": "AnasSarkiz", + "name": "grid-of-LEDs-with-an-ESP32" + } + ] +} diff --git a/fake-snippets-api/lib/db/seed.ts b/fake-snippets-api/lib/db/seed.ts index a6d86017..f33fad14 100644 --- a/fake-snippets-api/lib/db/seed.ts +++ b/fake-snippets-api/lib/db/seed.ts @@ -1,4 +1,5 @@ -import type { DbClient } from "./db-client" +import { DbClient } from "./db-client" +import { loadAutoloadSnippets } from "./autoload-dev-snippets" export const seed = (db: DbClient) => { const { account_id } = db.addAccount({ @@ -20,6 +21,11 @@ export const seed = (db: DbClient) => { db.addAccount({ github_username: "seveibar", }) + + if (process.env.AUTOLOAD_SNIPPETS === "true") { + loadAutoloadSnippets(db) + } + db.addSnippet({ name: "testuser/my-test-board", unscoped_name: "my-test-board", diff --git a/fake-snippets-api/routes/api/snippets/create.ts b/fake-snippets-api/routes/api/snippets/create.ts index 17d8c59b..165d4cd1 100644 --- a/fake-snippets-api/routes/api/snippets/create.ts +++ b/fake-snippets-api/routes/api/snippets/create.ts @@ -16,7 +16,7 @@ export default withRouteSpec({ }), jsonResponse: z.object({ ok: z.boolean(), - snippet: snippetSchema, + snippet: snippetSchema.optional(), }), })(async (req, ctx) => { let { @@ -28,9 +28,24 @@ export default withRouteSpec({ circuit_json, dts, } = req.jsonBody + if (!unscoped_name) { unscoped_name = `untitled-${snippet_type}-${ctx.db.idCounter + 1}` } + + const existingSnippet = ctx.db.snippets.find( + (snippet) => + snippet.unscoped_name === unscoped_name && + snippet.owner_name === ctx.auth.github_username, + ) + + if (existingSnippet) { + return ctx.error(400, { + error_code: "snippet_already_exists", + message: "You have already forked this snippet in your account.", + }) + } + const newSnippet: z.input = { snippet_id: `snippet_${ctx.db.idCounter + 1}`, name: `${ctx.auth.github_username}/${unscoped_name}`, @@ -46,7 +61,14 @@ export default withRouteSpec({ dts, } - ctx.db.addSnippet(newSnippet) + try { + ctx.db.addSnippet(newSnippet) + } catch (error) { + return ctx.error(500, { + error_code: "snippet_creation_failed", + message: `Failed to create snippet: ${error}`, + }) + } return ctx.json({ ok: true, diff --git a/index.html b/index.html index ce76dc33..6998a165 100644 --- a/index.html +++ b/index.html @@ -1,15 +1,25 @@ - - + + + + tscircuit - Code Electronics with React + + + + + + + + + - tscircuit Snippets
diff --git a/landing.html b/landing.html new file mode 100644 index 00000000..4cd77954 --- /dev/null +++ b/landing.html @@ -0,0 +1,23 @@ + + + + + + + + tscircuit - Code Electronics with React + + + + + + + + + + + +
+ + + diff --git a/package.json b/package.json index 482b37fa..5c665d48 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,27 @@ { - "name": "snippets", - "private": true, - "version": "0.0.0", + "name": "@tscircuit/fake-snippets", + "version": "0.0.2", "type": "module", "repository": { "type": "git", "url": "https://github.com/tscircuit/snippets" }, + "main": "dist/bundle.js", "scripts": { "start": "bun run dev", "snapshot": "bun scripts/snapshot.ts", "playwright": "bunx playwright test", "playwright:update": "bunx playwright test --update-snapshots", "start:playwright-server": "bun run build:fake-api && vite --port 5177", - "dev": "bun run build:fake-api && vite", + "dev": "bun run build:fake-api && AUTOLOAD_SNIPPETS=true vite", "dev:registry": "SNIPPETS_API_URL=http://localhost:3100 vite", - "build": "bun run build:fake-api && tsc -b && vite build", + "build": "bun run generate-images && bun run generate-sitemap && bun run build:fake-api && tsc -b && vite build", "preview": "vite preview", "format": "biome format --write .", "lint": "biome format .", - "build:fake-api": "winterspec bundle -o dist/bundle.js" + "build:fake-api": "winterspec bundle -o dist/bundle.js", + "generate-images": "bun run scripts/generate-image-sizes.ts", + "generate-sitemap": "bun run scripts/generate-sitemap.ts" }, "dependencies": { "@babel/preset-react": "^7.25.9", @@ -59,29 +61,33 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", - "@tscircuit/3d-viewer": "^0.0.59", - "@tscircuit/footprinter": "^0.0.93", + "@tscircuit/3d-viewer": "^0.0.95", + "@tscircuit/footprinter": "^0.0.99", "@tscircuit/layout": "^0.0.29", + "@tscircuit/math-utils": "^0.0.10", "@tscircuit/mm": "^0.0.8", - "@tscircuit/pcb-viewer": "^1.10.16", - "@tscircuit/props": "^0.0.113", + "@tscircuit/pcb-viewer": "^1.11.12", + "@tscircuit/props": "^0.0.129", "@tscircuit/schematic-viewer": "^1.4.3", "@types/file-saver": "^2.0.7", "@types/ms": "^0.7.34", "@typescript/ata": "^0.9.7", + "@valtown/codemirror-codeium": "^1.1.1", "@valtown/codemirror-ts": "^2.2.0", + "@vercel/analytics": "^1.4.1", "change-case": "^5.4.4", - "circuit-json": "^0.0.117", + "circuit-json": "^0.0.130", "circuit-json-to-bom-csv": "^0.0.6", "circuit-json-to-gerber": "^0.0.16", "circuit-json-to-pnp-csv": "^0.0.6", + "circuit-json-to-readable-netlist": "^0.0.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.4", "codemirror": "^6.0.1", "country-list": "^2.3.0", "date-fns": "^4.1.0", - "dsn-converter": "^0.0.50", + "dsn-converter": "^0.0.57", "easyeda": "^0.0.62", "embla-carousel-react": "^8.3.0", "extract-codefence": "^0.0.4", @@ -90,26 +96,32 @@ "immer": "^10.1.1", "input-otp": "^1.2.4", "jose": "^5.9.3", - "jscad-electronics": "^0.0.23", + "jscad-electronics": "^0.0.24", "jszip": "^3.10.1", + "kicad-converter": "^0.0.16", "lucide-react": "^0.445.0", "ms": "^2.1.3", "next-themes": "^0.3.0", + "posthog-js": "^1.203.2", + "react-cookie-consent": "^9.0.0", "react-day-picker": "8.10.1", "react-dom": "^18.3.1", "react-helmet": "^6.1.0", "react-hook-form": "^7.53.0", + "react-intersection-observer": "^9.14.1", "react-query": "^3.39.3", "react-resizable-panels": "^2.1.3", "recharts": "^2.12.7", "rollup-plugin-visualizer": "^5.12.0", + "sitemap": "^8.0.0", "sonner": "^1.5.0", "states-us": "^1.1.1", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "use-async-memo": "^1.2.5", - "use-mouse-matrix-transform": "^1.1.13", + "use-mouse-matrix-transform": "^1.3.0", "vaul": "^0.9.9", + "vite-plugin-vercel": "^9.0.4", "wouter": "^3.3.5" }, "devDependencies": { @@ -117,8 +129,8 @@ "@babel/standalone": "^7.26.2", "@biomejs/biome": "^1.9.2", "@playwright/test": "^1.48.0", - "@tscircuit/core": "^0.0.229", - "@tscircuit/prompt-benchmarks": "^0.0.19", + "@tscircuit/core": "^0.0.267", + "@tscircuit/prompt-benchmarks": "^0.0.20", "@types/babel__standalone": "^7.1.7", "@types/bun": "^1.1.10", "@types/country-list": "^2.1.4", @@ -126,19 +138,24 @@ "@types/react": "^18.3.9", "@types/react-dom": "^18.3.0", "@types/react-helmet": "^6.1.11", + "@types/sharp": "^0.32.0", "@typescript/vfs": "^1.6.0", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", - "circuit-to-svg": "^0.0.93", + "circuit-to-svg": "^0.0.100", "globals": "^15.9.0", "postcss": "^8.4.47", "prismjs": "^1.29.0", "prompts": "^2.4.2", "react": "^18.3.1", "redaxios": "^0.5.1", + "sharp": "^0.33.5", "tailwindcss": "^3.4.13", + "terser": "^5.27.0", + "tsup": "^8.3.5", "typescript": "^5.6.3", "vite": "^5.4.8", + "vite-plugin-image-optimizer": "^1.1.8", "winterspec": "^0.0.94", "zod": "^3.23.8", "zustand": "^4.5.5", diff --git a/playwright-tests/files-dialog.spec.ts b/playwright-tests/files-dialog.spec.ts new file mode 100644 index 00000000..db9812ce --- /dev/null +++ b/playwright-tests/files-dialog.spec.ts @@ -0,0 +1,19 @@ +// files-dialog.spec.js +import { test, expect } from "@playwright/test" + +test("files dialog", async ({ page }) => { + // Wait for network requests during navigation + await Promise.all([page.goto("http://127.0.0.1:5177/testuser/my-test-board")]) + + // Wait for run button and files tab to be visible + await page.waitForSelector(".run-button", { state: "visible" }) + const filesTab = await page.waitForSelector('span:has-text("Files")', { + state: "visible", + }) + + // Click and wait for any animations or state changes + await filesTab.click() + await page.getByLabel("Files").click() + + await expect(page).toHaveScreenshot(`view-snippet-files.png`) +}) diff --git a/playwright-tests/footprint-dialog/footprint-dialog.spec.ts b/playwright-tests/footprint-dialog/footprint-dialog.spec.ts index 791793a0..2cf0079f 100644 --- a/playwright-tests/footprint-dialog/footprint-dialog.spec.ts +++ b/playwright-tests/footprint-dialog/footprint-dialog.spec.ts @@ -15,9 +15,10 @@ for (const [size, viewport] of Object.entries(viewports)) { test("opens footprint dialog and shows preview", async ({ page }) => { if (isMobileOrTablet) { await page.click('button:has-text("Show Code")') + await page.waitForSelector('button:has-text("Show Preview")') } await page.click('button:has-text("Insert")') - await page.click("text=Footprint") + await page.click("text=Chip") await expect(page.getByRole("dialog")).toBeVisible() await expect(page.getByRole("heading", { name: "Insert" })).toBeVisible() await expect(page).toHaveScreenshot(`footprint-preview-${size}.png`) diff --git a/playwright-tests/footprint-dialog/footprint-insertion.spec.ts b/playwright-tests/footprint-dialog/footprint-insertion.spec.ts index c967bcba..f24e3492 100644 --- a/playwright-tests/footprint-dialog/footprint-insertion.spec.ts +++ b/playwright-tests/footprint-dialog/footprint-insertion.spec.ts @@ -8,7 +8,7 @@ for (const [size, viewport] of Object.entries(viewports)) { test.beforeEach(async ({ page }) => { await page.setViewportSize(viewport) await page.goto("http://127.0.0.1:5177/editor") - await page.waitForLoadState("networkidle") + // await page.waitForLoadState("networkidle") await page.waitForSelector("button.run-button") isMobileOrTablet = page.viewportSize()?.width! <= 768 }) @@ -18,7 +18,7 @@ for (const [size, viewport] of Object.entries(viewports)) { await page.click('button:has-text("Show Code")') } await page.click('button:has-text("Insert")') - await page.click("text=Footprint") + await page.click("text=Chip") await page.fill( 'input[placeholder="Enter chip name (e.g., U1)..."]', "U1", diff --git a/playwright-tests/footprint-dialog/footprint-preview.spec.ts b/playwright-tests/footprint-dialog/footprint-preview.spec.ts index 35fa66c9..daf99a3c 100644 --- a/playwright-tests/footprint-dialog/footprint-preview.spec.ts +++ b/playwright-tests/footprint-dialog/footprint-preview.spec.ts @@ -17,7 +17,7 @@ for (const [size, viewport] of Object.entries(viewports)) { await page.click('button:has-text("Show Code")') } await page.click('button:has-text("Insert")') - await page.click("text=Footprint") + await page.click("text=Chip") await expect(page.getByRole("dialog")).toBeVisible() await page.getByRole("combobox").click() await page.getByRole("option", { name: "dip" }).click() diff --git a/playwright-tests/footprint-dialog/footprint-selection.spec.ts b/playwright-tests/footprint-dialog/footprint-selection.spec.ts index 59d7c8bd..a9966925 100644 --- a/playwright-tests/footprint-dialog/footprint-selection.spec.ts +++ b/playwright-tests/footprint-dialog/footprint-selection.spec.ts @@ -17,7 +17,7 @@ for (const [size, viewport] of Object.entries(viewports)) { await page.click('button:has-text("Show Code")') } await page.click('button:has-text("Insert")') - await page.click("text=Footprint") + await page.click("text=Chip") await page.getByRole("combobox").click() await page.getByRole("option", { name: "ms012" }).click() await expect( diff --git a/playwright-tests/handle-manual-edits.spec.ts b/playwright-tests/handle-manual-edits.spec.ts new file mode 100644 index 00000000..b24441f6 --- /dev/null +++ b/playwright-tests/handle-manual-edits.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from "@playwright/test" + +test("Handle manual edits test", async ({ page }) => { + await page.goto("http://127.0.0.1:5177/editor?snippet_id=snippet_3") + + await page.getByRole("button", { name: "Log in" }).click() + + const combobox = page.getByRole("combobox") + await combobox.waitFor({ state: "visible" }) + await combobox.click() + + const manualEditsFile = page.getByText("manual-edits.json", { exact: true }) + await manualEditsFile.waitFor({ state: "visible" }) + await manualEditsFile.click() + + await page.locator("div").filter({ hasText: /^91›$/ }).first().click() + + const pcbPlacementsData = `{ + "pcb_placements": [ + { + "selector": "U1", + "center": { + "x": -26.03275345576554, + "y": 23.735745797903878 + }, + "relative_to": "group_center", + "_edit_event_id": "0.5072961258141278" + } + ], + "edit_events": [], + "manual_trace_hints": [] + }` + + await page.keyboard.type(pcbPlacementsData) + + await combobox.click() + + const indexFile = page.getByText("index.tsx", { exact: true }) + await indexFile.waitFor({ state: "visible" }) + await indexFile.click() + + await page.getByRole("button", { name: "Error" }).click() + await page + .getByRole("menuitem", { name: "Manual edits exist but have" }) + .click() + await page.waitForTimeout(500) + + const runButton = page.getByRole("button", { name: "Run", exact: true }) + await runButton.waitFor({ state: "visible" }) + await runButton.click() + + await page.waitForTimeout(2000) + + await expect(page).toHaveScreenshot("handle-manual-edits.png") +}) diff --git a/playwright-tests/manual-edits.spec.ts b/playwright-tests/manual-edits.spec.ts index 326e193f..697abe43 100644 --- a/playwright-tests/manual-edits.spec.ts +++ b/playwright-tests/manual-edits.spec.ts @@ -1,17 +1,17 @@ import { test, expect } from "@playwright/test" -test("Manual edits test", async ({ page }) => { +test.skip("Manual edits test", async ({ page }) => { test.setTimeout(60000) // Extend timeout for the test await page.goto("http://127.0.0.1:5177/editor?snippet_id=snippet_3") - await page.waitForLoadState("networkidle") + // await page.waitForLoadState("networkidle") - const loginButton = page.getByRole("button", { name: "Fake testuser Login" }) - await loginButton.waitFor({ state: "visible" }) + const loginButton = page.getByRole("button", { name: "Log in" }) + // await loginButton.waitFor({ state: "visible" }) await loginButton.click() const editorTextbox = page.getByRole("textbox").first() - await editorTextbox.waitFor({ state: "visible" }) + // await editorTextbox.waitFor({ state: "visible" }) await editorTextbox.click() await page.keyboard.press("Control+A") @@ -29,14 +29,15 @@ test("Manual edits test", async ({ page }) => { await editorTextbox.fill(indexCode) const combobox = page.getByRole("combobox") - await combobox.waitFor({ state: "visible" }) await combobox.click() const fileOption = page.getByText("manual-edits.json", { exact: true }) - await fileOption.waitFor({ state: "visible" }) + // await fileOption.waitFor({ state: "visible" }) await fileOption.click() - await page.getByRole("textbox").locator("div").click() + await page.getByRole("textbox").first().click() + await page.keyboard.press("Control+A") + await page.keyboard.press("Backspace") const pcbPlacementsData = `{ "pcb_placements": [ @@ -52,16 +53,17 @@ test("Manual edits test", async ({ page }) => { ], "edit_events": [], "manual_trace_hints": [] - }` + } +` await page.keyboard.type(pcbPlacementsData) const runButton = page.getByRole("button", { name: "Run", exact: true }) - await runButton.waitFor({ state: "visible" }) + // await runButton.waitFor({ state: "visible" }) await runButton.click() const saveButton = page.getByRole("button", { name: "Save" }) - await saveButton.waitFor({ state: "visible", timeout: 20000 }) // Extend timeout for Save button + // await saveButton.waitFor({ state: "visible", timeout: 20000 }) // Extend timeout for Save button await saveButton.click() await page.waitForTimeout(1000) @@ -75,11 +77,12 @@ test("Manual edits test", async ({ page }) => { await page.waitForLoadState("networkidle") const filesLink = page.getByRole("link", { name: "Files" }) - await filesLink.waitFor({ state: "visible" }) + // await filesLink.waitFor({ state: "visible" }) await filesLink.click() + await page.getByLabel("Files").click() const fileLink = page.getByText("manual-edits.json", { exact: true }) - await fileLink.waitFor({ state: "visible" }) + // await fileLink.waitFor({ state: "visible" }) await fileLink.click() await expect(page).toHaveScreenshot("manual-edits-view.png") diff --git a/playwright-tests/preview-page.spec.ts b/playwright-tests/preview-page.spec.ts index 4022c29b..cb2a0ea2 100644 --- a/playwright-tests/preview-page.spec.ts +++ b/playwright-tests/preview-page.spec.ts @@ -2,13 +2,13 @@ import { expect, test } from "@playwright/test" test(`preview-snippet Page`, async ({ page }) => { await page.goto("http://127.0.0.1:5177/preview?snippet_id=snippet_5&view=pcb") - await page.waitForTimeout(1000) + await page.waitForTimeout(5000) await expect(page).toHaveScreenshot(`preview-snippet-pcb.png`) await page.goto( "http://127.0.0.1:5177/preview?snippet_id=snippet_5&view=schematic", ) // Wait for schematic viewer to load - await page.waitForTimeout(1000) + await page.waitForTimeout(5000) await expect(page).toHaveScreenshot(`preview-snippet-schematic.png`) }) diff --git a/playwright-tests/search-links.spec.ts b/playwright-tests/search-links.spec.ts index 16213f38..3c694777 100644 --- a/playwright-tests/search-links.spec.ts +++ b/playwright-tests/search-links.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from "@playwright/test" test("Search links open correctly", async ({ page }) => { - await page.goto("http://127.0.0.1:5177/") + await page.goto("http://127.0.0.1:5177/dashboard") await page.getByPlaceholder("Search").click() await page.getByPlaceholder("Search").fill("sev") @@ -15,13 +15,7 @@ test("Search links open correctly", async ({ page }) => { await page.getByPlaceholder("Search").click() await page.getByPlaceholder("Search").fill("my") - const popupPromise = page.waitForEvent("popup") - await page.getByRole("link", { name: "testuser/my-test-board A" }).click() + await page.getByRole("link", { name: "testuser/my-test-board" }).click() - const popup = await popupPromise - - await popup.waitForLoadState("networkidle") - - const popupUrl = popup.url() - expect(popupUrl).toMatch("http://127.0.0.1:5177/editor?snippet_id=snippet_3") + await expect(page).toHaveURL(/.*testuser\/my-test-board/) }) diff --git a/playwright-tests/search.spec.ts b/playwright-tests/search.spec.ts index f096770c..3e8f4cdc 100644 --- a/playwright-tests/search.spec.ts +++ b/playwright-tests/search.spec.ts @@ -4,7 +4,7 @@ import { viewports } from "./viewports" for (const [size, viewport] of Object.entries(viewports)) { test(`search test for ${size} viewport`, async ({ page }) => { await page.setViewportSize(viewport) - await page.goto("http://127.0.0.1:5177/") + await page.goto("http://127.0.0.1:5177/dashboard") const searchInput = page.getByRole("searchbox", { name: "Search" }).first() diff --git a/playwright-tests/snapshots/editor-page.spec.ts-editor-with-snippet.png b/playwright-tests/snapshots/editor-page.spec.ts-editor-with-snippet.png index 25007a3f..b1a4f5ca 100644 Binary files a/playwright-tests/snapshots/editor-page.spec.ts-editor-with-snippet.png and b/playwright-tests/snapshots/editor-page.spec.ts-editor-with-snippet.png differ diff --git a/playwright-tests/snapshots/files-dialog.spec.ts-view-snippet-files.png b/playwright-tests/snapshots/files-dialog.spec.ts-view-snippet-files.png new file mode 100644 index 00000000..0e423c8d Binary files /dev/null and b/playwright-tests/snapshots/files-dialog.spec.ts-view-snippet-files.png differ diff --git a/playwright-tests/snapshots/footprint-dialog/footprint-dialog.spec.ts-footprint-preview-lg.png b/playwright-tests/snapshots/footprint-dialog/footprint-dialog.spec.ts-footprint-preview-lg.png index 8c2351d1..39dfa327 100644 Binary files a/playwright-tests/snapshots/footprint-dialog/footprint-dialog.spec.ts-footprint-preview-lg.png and b/playwright-tests/snapshots/footprint-dialog/footprint-dialog.spec.ts-footprint-preview-lg.png differ diff --git a/playwright-tests/snapshots/footprint-dialog/footprint-dialog.spec.ts-footprint-preview-md.png b/playwright-tests/snapshots/footprint-dialog/footprint-dialog.spec.ts-footprint-preview-md.png index d7ee389a..b5f86a40 100644 Binary files a/playwright-tests/snapshots/footprint-dialog/footprint-dialog.spec.ts-footprint-preview-md.png and b/playwright-tests/snapshots/footprint-dialog/footprint-dialog.spec.ts-footprint-preview-md.png differ diff --git a/playwright-tests/snapshots/footprint-dialog/footprint-dialog.spec.ts-footprint-preview-xs.png b/playwright-tests/snapshots/footprint-dialog/footprint-dialog.spec.ts-footprint-preview-xs.png index dc253490..5b4c0435 100644 Binary files a/playwright-tests/snapshots/footprint-dialog/footprint-dialog.spec.ts-footprint-preview-xs.png and b/playwright-tests/snapshots/footprint-dialog/footprint-dialog.spec.ts-footprint-preview-xs.png differ diff --git a/playwright-tests/snapshots/footprint-dialog/footprint-insertion.spec.ts-footprint-insertion-lg.png b/playwright-tests/snapshots/footprint-dialog/footprint-insertion.spec.ts-footprint-insertion-lg.png index 3fa8cfca..05e68629 100644 Binary files a/playwright-tests/snapshots/footprint-dialog/footprint-insertion.spec.ts-footprint-insertion-lg.png and b/playwright-tests/snapshots/footprint-dialog/footprint-insertion.spec.ts-footprint-insertion-lg.png differ diff --git a/playwright-tests/snapshots/footprint-dialog/footprint-insertion.spec.ts-footprint-insertion-md.png b/playwright-tests/snapshots/footprint-dialog/footprint-insertion.spec.ts-footprint-insertion-md.png index 3116cba2..9b19704c 100644 Binary files a/playwright-tests/snapshots/footprint-dialog/footprint-insertion.spec.ts-footprint-insertion-md.png and b/playwright-tests/snapshots/footprint-dialog/footprint-insertion.spec.ts-footprint-insertion-md.png differ diff --git a/playwright-tests/snapshots/footprint-dialog/footprint-insertion.spec.ts-footprint-insertion-xs.png b/playwright-tests/snapshots/footprint-dialog/footprint-insertion.spec.ts-footprint-insertion-xs.png index 2d4d8d44..1df753dc 100644 Binary files a/playwright-tests/snapshots/footprint-dialog/footprint-insertion.spec.ts-footprint-insertion-xs.png and b/playwright-tests/snapshots/footprint-dialog/footprint-insertion.spec.ts-footprint-insertion-xs.png differ diff --git a/playwright-tests/snapshots/footprint-dialog/footprint-preview.spec.ts-footprint-preview-lg.png b/playwright-tests/snapshots/footprint-dialog/footprint-preview.spec.ts-footprint-preview-lg.png index 6b844a52..616ebe7b 100644 Binary files a/playwright-tests/snapshots/footprint-dialog/footprint-preview.spec.ts-footprint-preview-lg.png and b/playwright-tests/snapshots/footprint-dialog/footprint-preview.spec.ts-footprint-preview-lg.png differ diff --git a/playwright-tests/snapshots/footprint-dialog/footprint-preview.spec.ts-footprint-preview-md.png b/playwright-tests/snapshots/footprint-dialog/footprint-preview.spec.ts-footprint-preview-md.png index e5b8c35e..79476aa4 100644 Binary files a/playwright-tests/snapshots/footprint-dialog/footprint-preview.spec.ts-footprint-preview-md.png and b/playwright-tests/snapshots/footprint-dialog/footprint-preview.spec.ts-footprint-preview-md.png differ diff --git a/playwright-tests/snapshots/footprint-dialog/footprint-preview.spec.ts-footprint-preview-xs.png b/playwright-tests/snapshots/footprint-dialog/footprint-preview.spec.ts-footprint-preview-xs.png index 985318ef..7d1a5fd5 100644 Binary files a/playwright-tests/snapshots/footprint-dialog/footprint-preview.spec.ts-footprint-preview-xs.png and b/playwright-tests/snapshots/footprint-dialog/footprint-preview.spec.ts-footprint-preview-xs.png differ diff --git a/playwright-tests/snapshots/footprint-dialog/footprint-selection.spec.ts-footprint-preview-lg.png b/playwright-tests/snapshots/footprint-dialog/footprint-selection.spec.ts-footprint-preview-lg.png index 3ccf868b..0c38d4c4 100644 Binary files a/playwright-tests/snapshots/footprint-dialog/footprint-selection.spec.ts-footprint-preview-lg.png and b/playwright-tests/snapshots/footprint-dialog/footprint-selection.spec.ts-footprint-preview-lg.png differ diff --git a/playwright-tests/snapshots/footprint-dialog/footprint-selection.spec.ts-footprint-preview-md.png b/playwright-tests/snapshots/footprint-dialog/footprint-selection.spec.ts-footprint-preview-md.png index c0332feb..7d3d2e5c 100644 Binary files a/playwright-tests/snapshots/footprint-dialog/footprint-selection.spec.ts-footprint-preview-md.png and b/playwright-tests/snapshots/footprint-dialog/footprint-selection.spec.ts-footprint-preview-md.png differ diff --git a/playwright-tests/snapshots/footprint-dialog/footprint-selection.spec.ts-footprint-preview-xs.png b/playwright-tests/snapshots/footprint-dialog/footprint-selection.spec.ts-footprint-preview-xs.png index ae56db9d..e9368a1b 100644 Binary files a/playwright-tests/snapshots/footprint-dialog/footprint-selection.spec.ts-footprint-preview-xs.png and b/playwright-tests/snapshots/footprint-dialog/footprint-selection.spec.ts-footprint-preview-xs.png differ diff --git a/playwright-tests/snapshots/handle-manual-edits.spec.ts-handle-manual-edits.png b/playwright-tests/snapshots/handle-manual-edits.spec.ts-handle-manual-edits.png new file mode 100644 index 00000000..233d3b4f Binary files /dev/null and b/playwright-tests/snapshots/handle-manual-edits.spec.ts-handle-manual-edits.png differ diff --git a/playwright-tests/snapshots/home-page.spec.ts-Home-page-lg.png b/playwright-tests/snapshots/home-page.spec.ts-Home-page-lg.png index ce9a39a1..dc45b61c 100644 Binary files a/playwright-tests/snapshots/home-page.spec.ts-Home-page-lg.png and b/playwright-tests/snapshots/home-page.spec.ts-Home-page-lg.png differ diff --git a/playwright-tests/snapshots/home-page.spec.ts-Home-page-md.png b/playwright-tests/snapshots/home-page.spec.ts-Home-page-md.png index ba5ae783..d849f418 100644 Binary files a/playwright-tests/snapshots/home-page.spec.ts-Home-page-md.png and b/playwright-tests/snapshots/home-page.spec.ts-Home-page-md.png differ diff --git a/playwright-tests/snapshots/home-page.spec.ts-Home-page-xs.png b/playwright-tests/snapshots/home-page.spec.ts-Home-page-xs.png index 2797f97c..36c91d4a 100644 Binary files a/playwright-tests/snapshots/home-page.spec.ts-Home-page-xs.png and b/playwright-tests/snapshots/home-page.spec.ts-Home-page-xs.png differ diff --git a/playwright-tests/snapshots/manual-edits.spec.ts-editor-manual-edits.png b/playwright-tests/snapshots/manual-edits.spec.ts-editor-manual-edits.png index 809c8e29..0327451b 100644 Binary files a/playwright-tests/snapshots/manual-edits.spec.ts-editor-manual-edits.png and b/playwright-tests/snapshots/manual-edits.spec.ts-editor-manual-edits.png differ diff --git a/playwright-tests/snapshots/manual-edits.spec.ts-manual-edits-view.png b/playwright-tests/snapshots/manual-edits.spec.ts-manual-edits-view.png index 680f2bb8..67071dd1 100644 Binary files a/playwright-tests/snapshots/manual-edits.spec.ts-manual-edits-view.png and b/playwright-tests/snapshots/manual-edits.spec.ts-manual-edits-view.png differ diff --git a/playwright-tests/snapshots/search-links.spec.ts-search-links.png b/playwright-tests/snapshots/search-links.spec.ts-search-links.png index 1e2d8ad8..b5878f90 100644 Binary files a/playwright-tests/snapshots/search-links.spec.ts-search-links.png and b/playwright-tests/snapshots/search-links.spec.ts-search-links.png differ diff --git a/playwright-tests/snapshots/search.spec.ts-search-lg.png b/playwright-tests/snapshots/search.spec.ts-search-lg.png index 76a7ea2c..d79eacda 100644 Binary files a/playwright-tests/snapshots/search.spec.ts-search-lg.png and b/playwright-tests/snapshots/search.spec.ts-search-lg.png differ diff --git a/playwright-tests/snapshots/search.spec.ts-search-md.png b/playwright-tests/snapshots/search.spec.ts-search-md.png index 910853e4..c1d7e63e 100644 Binary files a/playwright-tests/snapshots/search.spec.ts-search-md.png and b/playwright-tests/snapshots/search.spec.ts-search-md.png differ diff --git a/playwright-tests/snapshots/search.spec.ts-search-xs.png b/playwright-tests/snapshots/search.spec.ts-search-xs.png index 59f19aaa..bf96e86b 100644 Binary files a/playwright-tests/snapshots/search.spec.ts-search-xs.png and b/playwright-tests/snapshots/search.spec.ts-search-xs.png differ diff --git a/playwright-tests/snapshots/star.spec.ts-remove-star-button.png b/playwright-tests/snapshots/star.spec.ts-remove-star-button.png index ed1a4fda..b70bbf6d 100644 Binary files a/playwright-tests/snapshots/star.spec.ts-remove-star-button.png and b/playwright-tests/snapshots/star.spec.ts-remove-star-button.png differ diff --git a/playwright-tests/snapshots/star.spec.ts-star-button.png b/playwright-tests/snapshots/star.spec.ts-star-button.png index e9e20c58..b8675d1d 100644 Binary files a/playwright-tests/snapshots/star.spec.ts-star-button.png and b/playwright-tests/snapshots/star.spec.ts-star-button.png differ diff --git a/playwright-tests/snapshots/update-description.spec.ts-update-description.png b/playwright-tests/snapshots/update-description.spec.ts-update-description.png new file mode 100644 index 00000000..bb77df09 Binary files /dev/null and b/playwright-tests/snapshots/update-description.spec.ts-update-description.png differ diff --git a/playwright-tests/star.spec.ts b/playwright-tests/star.spec.ts index 4d9d03e8..515f9b12 100644 --- a/playwright-tests/star.spec.ts +++ b/playwright-tests/star.spec.ts @@ -1,35 +1,40 @@ import { test, expect } from "@playwright/test" -test("test for starring a repo", async ({ page }) => { +test.beforeEach(async ({ page }) => { await page.goto("http://127.0.0.1:5177/testuser/my-test-board") - await page.getByRole("button", { name: "Fake testuser Login" }).click() - - await page.waitForLoadState("networkidle") + await page.getByRole("button", { name: "Log in" }).click() +}) +test("test for starring a repo", async ({ page }) => { // Check for initial page content await expect(page.getByText("testuser/my-test-boardBOARD")).toBeVisible() - await expect(page.getByRole("button", { name: "Star" })).toBeVisible() + const starButton = page.getByRole("button", { name: "Star" }) + await expect(starButton).toBeVisible() + + // Add starr + await starButton.click() + await page.waitForTimeout(2000) // Allow time for the action to complete - // add star - await page.click('button:has-text("Star")') - await page.waitForTimeout(3000) + // Verify star action + await expect(page.locator("ol")).toBeVisible() + // await page.getByText('You\'ve starred this snippet').click(); + // Take a screenshot await expect(page).toHaveScreenshot("star-button.png") }) test("test for removing a star from a repo", async ({ page }) => { - await page.goto("http://127.0.0.1:5177/testuser/my-test-board") - await page.getByRole("button", { name: "Fake testuser Login" }).click() - - await page.waitForLoadState("networkidle") + // Ensure repo is starred before test + const starredButton = page.getByRole("button", { name: "Starred" }) + await expect(starredButton).toBeVisible() - // Check for initial page content - await expect(page.getByText("testuser/my-test-boardBOARD")).toBeVisible() - await expect(page.getByRole("button", { name: "Star" })).toBeVisible() + // Remove star + await starredButton.click() - // remove star - await page.click('button:has-text("Starred")') - await page.waitForTimeout(3000) + // Verify destar action + const starButton = page.getByRole("button", { name: "Star" }) + await expect(starButton).toBeVisible() + // Take a screenshot await expect(page).toHaveScreenshot("remove-star-button.png") }) diff --git a/playwright-tests/update-description.spec.ts b/playwright-tests/update-description.spec.ts new file mode 100644 index 00000000..fe2839e4 --- /dev/null +++ b/playwright-tests/update-description.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from "@playwright/test" + +test("test", async ({ page }) => { + await page.goto("http://localhost:5177/editor?snippet_id=snippet_3") + await page.getByRole("button", { name: "Log in" }).click() + await page.locator(".lucide-ellipsis-vertical").click() + await page.waitForTimeout(5000) + await page.getByRole("menuitem", { name: "Edit Description" }).click() + await page.getByPlaceholder("Enter new description").press("End") + await page + .getByPlaceholder("Enter new description") + .fill("Check out this new description") + await page.getByRole("button", { name: "Update" }).click() + await page.locator(".lucide-ellipsis-vertical").click() + await page.waitForTimeout(5000) + await page.getByRole("menuitem", { name: "Edit Description" }).click() + await expect(page).toHaveScreenshot("update-description.png") +}) diff --git a/playwright-tests/view-snippet.spec.ts b/playwright-tests/view-snippet.spec.ts index d939f5c8..ae2d8bc6 100644 --- a/playwright-tests/view-snippet.spec.ts +++ b/playwright-tests/view-snippet.spec.ts @@ -4,25 +4,32 @@ import { viewports } from "./viewports" for (const [size, viewport] of Object.entries(viewports)) { test(`view-snippet Page on ${size} screen`, async ({ page }) => { await page.setViewportSize(viewport) - await page.goto("http://127.0.0.1:5177/testuser/my-test-board") - await page.waitForSelector(".run-button") + // Wait for network requests during navigation + await Promise.all([ + // page.waitForLoadState("networkidle"), + page.goto("http://127.0.0.1:5177/testuser/my-test-board"), + ]) + + // Wait for run button to be visible and clickable + const runButton = await page.waitForSelector(".run-button", { + state: "visible", + }) await expect(page).toHaveScreenshot(`view-snippet-before-${size}.png`) - await page.click(".run-button") - await page.waitForTimeout(5000) + + // Click and wait for any network requests or animations to complete + await Promise.all([runButton.click()]) + + // Wait for PCB tab to be fully loaded and any animations to complete + await page.waitForTimeout(1000) // Reduced timeout, just for animations await expect(page).toHaveScreenshot(`view-snippet-after-${size}.png`) if (size !== "xs") { - await page.click('span:has-text("Files")') + // Wait for Files tab to be visible and clickable + const filesTab = await page.waitForSelector('span:has-text("Files")', { + state: "visible", + }) + await filesTab.click() } }) } - -test("files dialog", async ({ page }) => { - await page.goto("http://127.0.0.1:5177/testuser/my-test-board") - - await page.waitForSelector(".run-button") - - await page.click('span:has-text("Files")') - await expect(page).toHaveScreenshot(`view-snippet-files.png`) -}) diff --git a/playwright.config.ts b/playwright.config.ts index 427950f0..d42b3da2 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,8 +9,9 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, stdout: "pipe", stderr: "pipe", - timeout: 10000, + timeout: 30000, }, + workers: 2, testDir: "playwright-tests", snapshotPathTemplate: "playwright-tests/snapshots/{testFilePath}-{arg}{ext}", testMatch: /.*\.spec\.ts/, @@ -20,6 +21,7 @@ export default defineConfig({ maxDiffPixelRatio: 0.2, // Increase the threshold for considering pixels different threshold: 0.2, + animations: "disabled", }, }, }) diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 00000000..029b271a --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,9 @@ +# Allow all crawlers +User-agent: * +Allow: / + +# Sitemaps +Sitemap: https://tscircuit.com/sitemap.xml + +# Crawl-delay +Crawl-delay: 10 diff --git a/renovate.json b/renovate.json index c09a1f89..42d57543 100644 --- a/renovate.json +++ b/renovate.json @@ -9,7 +9,8 @@ "circuit-to-svg", "jscad-electronics", "circuit-json", - "dsn-converter" + "dsn-converter", + "circuit-json-to-readable-netlist" ], "enabled": false }, diff --git a/scripts/generate-image-sizes.ts b/scripts/generate-image-sizes.ts new file mode 100644 index 00000000..db941e26 --- /dev/null +++ b/scripts/generate-image-sizes.ts @@ -0,0 +1,58 @@ +import sharp from "sharp" +import path from "path" +import fs from "fs" + +const WIDTHS = [400, 600, 800, 1000, 1200, 1600, 2000] +const INPUT_DIR = "src/assets/originals" +const OUTPUT_DIR = "public/assets" + +async function generateImageSizes() { + console.log("one") + if (!fs.existsSync(INPUT_DIR)) { + fs.mkdirSync(INPUT_DIR, { recursive: true }) + } + if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }) + } + + const files = fs.readdirSync(INPUT_DIR) + + for (const file of files) { + if (file.startsWith(".")) continue + + const filePath = path.join(INPUT_DIR, file) + const fileNameWithoutExt = path.parse(file).name + const outputDirForFile = path.join(OUTPUT_DIR) + + if (!fs.existsSync(outputDirForFile)) { + fs.mkdirSync(outputDirForFile, { recursive: true }) + } + + for (const width of WIDTHS) { + const extension = path.extname(file) + const outputPath = path.join( + outputDirForFile, + `${fileNameWithoutExt}-${width}w${extension}`, + ) + + try { + await sharp(filePath) + .resize(width, null, { + withoutEnlargement: true, + fit: "inside", + }) + .webp({ + quality: 80, + effort: 6, + }) + .toFile(outputPath) + + console.log(`Generated ${outputPath}`) + } catch (error) { + console.error(`Error processing ${filePath} at width ${width}:`, error) + } + } + } +} + +generateImageSizes().catch(console.error) diff --git a/scripts/generate-sitemap.ts b/scripts/generate-sitemap.ts new file mode 100644 index 00000000..4aba01ff --- /dev/null +++ b/scripts/generate-sitemap.ts @@ -0,0 +1,103 @@ +import { SitemapStream, streamToPromise } from "sitemap" +import { Readable } from "stream" +import fs from "fs" +import path from "path" + +const staticRoutes = [ + { url: "/", changefreq: "weekly", priority: 1.0 }, + { url: "/editor", changefreq: "weekly", priority: 0.9 }, + { url: "/playground", changefreq: "weekly", priority: 0.9 }, + { url: "/quickstart", changefreq: "monthly", priority: 0.8 }, + { url: "/dashboard", changefreq: "weekly", priority: 0.7 }, + { url: "/newest", changefreq: "daily", priority: 0.8 }, + { url: "/search", changefreq: "weekly", priority: 0.7 }, + { url: "/settings", changefreq: "monthly", priority: 0.6 }, + { url: "/community/join-redirect", changefreq: "monthly", priority: 0.6 }, + { url: "/legal/terms-of-service", changefreq: "yearly", priority: 0.3 }, + { url: "/legal/privacy-policy", changefreq: "yearly", priority: 0.3 }, +] + +async function fetchTrendingSnippets() { + try { + const response = await fetch( + "https://registry-api.tscircuit.com/snippets/list_trending", + { + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }, + ) + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + const text = await response.text() + try { + const data = JSON.parse(text) + return data.snippets || [] + } catch (parseError) { + console.error("JSON Parse Error:", parseError) + console.error("Raw response:", text) + return [] + } + } catch (error) { + console.error("Error fetching trending snippets:", error) + return [] + } +} + +async function generateSitemap() { + try { + const stream = new SitemapStream({ + hostname: "https://tscircuit.com", + xmlns: { + news: false, + xhtml: false, + image: false, + video: false, + }, + }) + + const trendingSnippets = await fetchTrendingSnippets() + + const links = [ + ...staticRoutes, + ...trendingSnippets.map((snippet: any) => ({ + url: `/${snippet.owner_name}/${snippet.unscoped_name}`, + changefreq: "weekly", + priority: 0.7, + lastmod: new Date( + snippet.updated_at || snippet.created_at, + ).toISOString(), + })), + ] + + const sitemap = await streamToPromise(Readable.from(links).pipe(stream)) + + const formattedXml = sitemap + .toString() + .replace(/>\n<") + .split("\n") + .map((line) => line.trim()) + .map((line, i) => { + if (i === 0) return line + if (line.startsWith("")) return line + if (line.startsWith("")) return " " + line + if (line.startsWith("")) return " " + line + return " " + line + }) + .join("\n") + + const outputPath = path.join(process.cwd(), "public", "sitemap.xml") + fs.writeFileSync(outputPath, formattedXml, "utf-8") + + console.log("Sitemap generated successfully at", outputPath) + } catch (error) { + console.error("Error generating sitemap:", error) + process.exit(1) + } +} + +generateSitemap().catch(console.error) diff --git a/src/App.tsx b/src/App.tsx index a40af5f5..49adbf97 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,44 +1,111 @@ +import { ComponentType, Suspense, lazy } from "react" import { Toaster } from "@/components/ui/toaster" import { Route, Switch } from "wouter" import "./components/CmdKMenu" import { ContextProviders } from "./ContextProviders" -import { AiPage } from "./pages/ai" -import AuthenticatePage from "./pages/authorize" -import { DashboardPage } from "./pages/dashboard" -import { EditorPage } from "./pages/editor" -import { LandingPage } from "./pages/landing" -import { MyOrdersPage } from "./pages/my-orders" -import { NewestPage } from "./pages/newest" -import { PreviewPage } from "./pages/preview" -import { QuickstartPage } from "./pages/quickstart" -import { SearchPage } from "./pages/search" -import { SettingsPage } from "./pages/settings" -import { UserProfilePage } from "./pages/user-profile" -import { ViewOrderPage } from "./pages/view-order" -import { ViewSnippetPage } from "./pages/view-snippet" -import { DevLoginPage } from "./pages/dev-login" +import React from "react" + +const lazyImport = (importFn: () => Promise) => + lazy>(async () => { + try { + const module = await importFn() + + if (module.default) { + return { default: module.default } + } + + const pageExportNames = ["Page", "Component", "View"] + for (const suffix of pageExportNames) { + const keys = Object.keys(module).filter((key) => key.endsWith(suffix)) + if (keys.length > 0) { + return { default: module[keys[0]] } + } + } + + const componentExport = Object.values(module).find( + (exp) => typeof exp === "function" && exp.prototype?.isReactComponent, + ) + if (componentExport) { + return { default: componentExport } + } + + throw new Error( + `No valid React component found in module. Available exports: ${Object.keys(module).join(", ")}`, + ) + } catch (error) { + console.error("Failed to load component:", error) + throw error + } + }) + +const AiPage = lazyImport(() => import("@/pages/ai")) +const AuthenticatePage = lazyImport(() => import("@/pages/authorize")) +const DashboardPage = lazyImport(() => import("@/pages/dashboard")) +const EditorPage = lazyImport(async () => { + const [editorModule] = await Promise.all([ + import("@/pages/editor"), + import("@/lib/utils/load-prettier").then((m) => m.loadPrettier()), + ]) + return editorModule +}) +const LandingPage = lazyImport(() => import("@/pages/landing")) +const MyOrdersPage = lazyImport(() => import("@/pages/my-orders")) +const NewestPage = lazyImport(() => import("@/pages/newest")) +const PreviewPage = lazyImport(() => import("@/pages/preview")) +const QuickstartPage = lazyImport(() => import("@/pages/quickstart")) +const SearchPage = lazyImport(() => import("@/pages/search")) +const SettingsPage = lazyImport(() => import("@/pages/settings")) +const UserProfilePage = lazyImport(() => import("@/pages/user-profile")) +const ViewOrderPage = lazyImport(() => import("@/pages/view-order")) +const ViewSnippetPage = lazyImport(() => import("@/pages/view-snippet")) +const DevLoginPage = lazyImport(() => import("@/pages/dev-login")) + +class ErrorBoundary extends React.Component< + { children: React.ReactNode }, + { hasError: boolean } +> { + constructor(props: { children: React.ReactNode }) { + super(props) + this.state = { hasError: false } + } + + static getDerivedStateFromError() { + return { hasError: true } + } + + render() { + if (this.state.hasError) { + return
Something went wrong loading the page.
+ } + return this.props.children + } +} function App() { return ( - - - - - - - - - - - - - - - - - - + + Loading...}> + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/assets/originals/editor_example_1.webp b/src/assets/originals/editor_example_1.webp new file mode 100644 index 00000000..0a2b7b9b Binary files /dev/null and b/src/assets/originals/editor_example_1.webp differ diff --git a/src/assets/originals/editor_example_1_more_square.webp b/src/assets/originals/editor_example_1_more_square.webp new file mode 100644 index 00000000..9c03ce25 Binary files /dev/null and b/src/assets/originals/editor_example_1_more_square.webp differ diff --git a/src/assets/originals/editor_example_2.webp b/src/assets/originals/editor_example_2.webp new file mode 100644 index 00000000..014c6ae2 Binary files /dev/null and b/src/assets/originals/editor_example_2.webp differ diff --git a/src/assets/originals/example_schematic.webp b/src/assets/originals/example_schematic.webp new file mode 100644 index 00000000..1e75b62d Binary files /dev/null and b/src/assets/originals/example_schematic.webp differ diff --git a/src/components/AiChatInterface.tsx b/src/components/AiChatInterface.tsx index 0a506e50..1bc038fd 100644 --- a/src/components/AiChatInterface.tsx +++ b/src/components/AiChatInterface.tsx @@ -6,13 +6,14 @@ import { createCircuitBoard1Template } from "@tscircuit/prompt-benchmarks" import { TextDelta } from "@anthropic-ai/sdk/resources/messages.mjs" import { MagicWandIcon } from "@radix-ui/react-icons" import { AiChatMessage } from "./AiChatMessage" -import { Link, useLocation } from "wouter" +import { useLocation } from "wouter" import { useSnippet } from "@/hooks/use-snippet" import { Edit2 } from "lucide-react" import { SnippetLink } from "./SnippetLink" import { useGlobalStore } from "@/hooks/use-global-store" import { useSignIn } from "@/hooks/use-sign-in" import { extractCodefence } from "extract-codefence" +import { PrefetchPageLink } from "./PrefetchPageLink" export default function AIChatInterface({ code, @@ -148,18 +149,17 @@ export default function AIChatInterface({
- + + +
)} {messages.length === 0 && isLoggedIn && ( @@ -172,9 +172,12 @@ export default function AIChatInterface({
Sign in use the AI chat or{" "} - + use the regular editor - +
diff --git a/src/components/Analytics.tsx b/src/components/Analytics.tsx new file mode 100644 index 00000000..c3dc7d77 --- /dev/null +++ b/src/components/Analytics.tsx @@ -0,0 +1,30 @@ +import { Analytics as VercelAnalytics } from "@vercel/analytics/react" +import posthog from "posthog-js" +import CookieConsent from "react-cookie-consent" + +posthog.init("phc_htd8AQjSfVEsFCLQMAiUooG4Q0DKBCjqYuQglc9V3Wo", { + api_host: "https://us.i.posthog.com", + person_profiles: "always", +}) + +export const Analytics = () => { + return ( +
+ + {/* + This website uses cookies to enhance the user experience. + */} +
+ ) +} diff --git a/src/components/CmdKMenu.tsx b/src/components/CmdKMenu.tsx index 3ed13617..3288954d 100644 --- a/src/components/CmdKMenu.tsx +++ b/src/components/CmdKMenu.tsx @@ -78,7 +78,10 @@ const CmdKMenu = () => { { name: "New Footprint", type: "footprint", disabled: true }, ] - const templates: Template[] = [{ name: "Blinking LED Board", type: "board" }] + const templates: Template[] = [ + { name: "Blinking LED Board", type: "board" }, + { name: "USB-C LED Flashlight", type: "board" }, + ] const importOptions: ImportOption[] = [ { name: "KiCad Footprint", type: "footprint" }, diff --git a/src/components/CodeAndPreview.tsx b/src/components/CodeAndPreview.tsx index 7aa6f167..dabf8877 100644 --- a/src/components/CodeAndPreview.tsx +++ b/src/components/CodeAndPreview.tsx @@ -5,7 +5,7 @@ import { useGlobalStore } from "@/hooks/use-global-store" import { useRunTsx } from "@/hooks/use-run-tsx" import { useToast } from "@/hooks/use-toast" import { useUrlParams } from "@/hooks/use-url-params" -import useWarnUser from "@/hooks/use-warn-user" +import useWarnUserOnPageChange from "@/hooks/use-warn-user-on-page-change" import { decodeUrlHashToText } from "@/lib/decodeUrlHashToText" import { getSnippetTemplate } from "@/lib/get-snippet-template" import { cn } from "@/lib/utils" @@ -26,7 +26,7 @@ export function CodeAndPreview({ snippet }: Props) { const isLoggedIn = useGlobalStore((s) => Boolean(s.session)) const urlParams = useUrlParams() const templateFromUrl = useMemo( - () => getSnippetTemplate(urlParams.template), + () => (urlParams.template ? getSnippetTemplate(urlParams.template) : null), [], ) const defaultCode = useMemo(() => { @@ -36,7 +36,7 @@ export function CodeAndPreview({ snippet }: Props) { // If the snippet_id is in the url, use an empty string as the default // code until the snippet code is loaded (urlParams.snippet_id && "") ?? - templateFromUrl.code + templateFromUrl?.code ) }, []) @@ -51,7 +51,9 @@ export function CodeAndPreview({ snippet }: Props) { const [fullScreen, setFullScreen] = useState(false) const snippetType: "board" | "package" | "model" | "footprint" = - snippet?.snippet_type ?? (templateFromUrl.type as any) + snippet?.snippet_type ?? + (templateFromUrl?.type as any) ?? + urlParams.snippet_type useEffect(() => { if (snippet?.code) { @@ -87,6 +89,7 @@ export function CodeAndPreview({ snippet }: Props) { code, userImports, type: snippetType, + circuitDisplayName: snippet?.name, }) // Update lastRunCode whenever the code is run @@ -100,21 +103,39 @@ export function CodeAndPreview({ snippet }: Props) { mutationFn: async () => { if (!snippet) throw new Error("No snippet to update") - // Validate manual edits before sending - parseJsonOrNull(manualEditsFileContent) - - const response = await axios.post("/snippets/update", { + const updateSnippetPayload = { snippet_id: snippet.snippet_id, code: code, dts: dts, compiled_js: compiledJs, circuit_json: circuitJson, manual_edits_json_content: manualEditsFileContent, - }) - if (response.status !== 200) { - throw new Error("Failed to save snippet") } - return response.data + + try { + const response = await axios.post( + "/snippets/update", + updateSnippetPayload, + ) + return response.data + } catch (error: any) { + const responseStatus = error?.status ?? error?.response?.status + // We would normally only do this if the error is a 413, but we're not + // able to check the status properly because of the browser CORS policy + // (the PAYLOAD_TOO_LARGE error does not have the proper CORS headers) + if ( + import.meta.env.VITE_ALTERNATE_REGISTRY_URL && + (responseStatus === undefined || responseStatus === 413) + ) { + console.log(`Failed to update snippet, attempting alternate registry`) + const response = await axios.post( + `${import.meta.env.VITE_ALTERNATE_REGISTRY_URL}/snippets/update`, + updateSnippetPayload, + ) + return response.data + } + throw error + } }, onSuccess: () => { qc.invalidateQueries({ queryKey: ["snippets", snippet?.snippet_id] }) @@ -137,8 +158,10 @@ export function CodeAndPreview({ snippet }: Props) { }) const createSnippetMutation = useCreateSnippetMutation() + const [lastSavedAt, setLastSavedAt] = useState(Date.now()) const handleSave = async () => { + setLastSavedAt(Date.now()) if (snippet) { updateSnippetMutation.mutate() } else { @@ -150,11 +173,18 @@ export function CodeAndPreview({ snippet }: Props) { } } + const hasManualEditsChanged = + (snippet?.manual_edits_json_content ?? "") !== + (manualEditsFileContent ?? "") + const hasUnsavedChanges = - snippet?.code !== code || - snippet?.manual_edits_json_content !== manualEditsFileContent + !updateSnippetMutation.isLoading && + Date.now() - lastSavedAt > 1000 && + (snippet?.code !== code || hasManualEditsChanged) + const hasUnrunChanges = code !== lastRunCode - useWarnUser({ hasUnsavedChanges }) + + useWarnUserOnPageChange({ hasUnsavedChanges }) if (!snippet && (urlParams.snippet_id || urlParams.should_create_snippet)) { return ( diff --git a/src/components/CodeEditor.tsx b/src/components/CodeEditor.tsx index 6a488449..eb349e01 100644 --- a/src/components/CodeEditor.tsx +++ b/src/components/CodeEditor.tsx @@ -26,7 +26,8 @@ import { import { EditorView } from "codemirror" import { useEffect, useMemo, useRef, useState } from "react" import CodeEditorHeader from "./CodeEditorHeader" - +import { copilotPlugin, Language } from "@valtown/codemirror-codeium" +import { useCodeCompletionApi } from "@/hooks/use-code-completion-ai-api" const defaultImports = ` import React from "@types/react/jsx-runtime" import { Circuit, createUseComponent } from "@tscircuit/core" @@ -55,6 +56,7 @@ export const CodeEditor = ({ const viewRef = useRef(null) const ataRef = useRef | null>(null) const apiUrl = useSnippetsBaseApiUrl() + const codeCompletionApi = useCodeCompletionApi() const [cursorPosition, setCursorPosition] = useState(null) const [code, setCode] = useState(initialCode) @@ -151,6 +153,19 @@ export const CodeEditor = ({ return fetch(input, init) }, delegate: { + started: () => { + const manualEditsTypeDeclaration = ` + declare module "*.json" { + const value: { + pcb_placements?: any[], + edit_events?: any[], + manual_trace_hints?: any[], + } | undefined; + export default value; + } + ` + env.createFile("manual-edits.d.ts", manualEditsTypeDeclaration) + }, receivedFile: (code: string, path: string) => { fsMap.set(path, code) env.createFile(path, code) @@ -210,6 +225,24 @@ export const CodeEditor = ({ } }), ] + if (codeCompletionApi?.apiKey) { + baseExtensions.push( + copilotPlugin({ + apiKey: codeCompletionApi.apiKey, + language: Language.TYPESCRIPT, + }), + EditorView.theme({ + ".cm-ghostText, .cm-ghostText *": { + opacity: "0.6", + filter: "grayscale(20%)", + cursor: "pointer", + }, + ".cm-ghostText:hover": { + background: "#eee", + }, + }), + ) + } // Add TypeScript-specific extensions and handlers const tsExtensions = diff --git a/src/components/CodeEditorHeader.tsx b/src/components/CodeEditorHeader.tsx index d9d95f68..d8700917 100644 --- a/src/components/CodeEditorHeader.tsx +++ b/src/components/CodeEditorHeader.tsx @@ -10,13 +10,15 @@ import { useImportSnippetDialog } from "./dialogs/import-snippet-dialog" import { useToast } from "@/hooks/use-toast" import { FootprintDialog } from "./FootprintDialog" import { useState } from "react" -import { Dialog } from "./ui/dialog" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "./ui/dropdown-menu" +import { AlertTriangle } from "lucide-react" +import { checkIfManualEditsImported } from "@/lib/utils/checkIfManualEditsImported" +import { handleManualEditsImport } from "@/lib/handleManualEditsImport" export type FileName = "index.tsx" | "manual-edits.json" @@ -76,6 +78,7 @@ export const CodeEditorHeader = ({ }) } } + return (
@@ -93,6 +96,30 @@ export const CodeEditorHeader = ({
+ {checkIfManualEditsImported(files) && ( + + + + + + + handleManualEditsImport(files, updateFileContent, toast) + } + > + Manual edits exist but have not been imported. (Click to fix) + + + + )} - - +
+ + + +
diff --git a/src/components/DownloadButtonAndMenu.tsx b/src/components/DownloadButtonAndMenu.tsx index c698b5bf..d25683da 100644 --- a/src/components/DownloadButtonAndMenu.tsx +++ b/src/components/DownloadButtonAndMenu.tsx @@ -7,12 +7,17 @@ import { } from "@/components/ui/dropdown-menu" import { toast, useNotImplementedToast } from "@/hooks/use-toast" import { downloadCircuitJson } from "@/lib/download-fns/download-circuit-json-fn" +import { downloadSimpleRouteJson } from "@/lib/download-fns/download-simple-route-json" import { downloadDsnFile } from "@/lib/download-fns/download-dsn-file-fn" import { downloadFabricationFiles } from "@/lib/download-fns/download-fabrication-files" import { downloadSchematicSvg } from "@/lib/download-fns/download-schematic-svg" +import { downloadReadableNetlist } from "@/lib/download-fns/download-readable-netlist" +import { downloadAssemblySvg } from "@/lib/download-fns/download-assembly-svg" +import { downloadKicadFiles } from "@/lib/download-fns/download-kicad-files" import { AnyCircuitElement } from "circuit-json" import { ChevronDown, Download } from "lucide-react" import React from "react" +import { downloadGltf } from "@/lib/download-fns/download-gltf" interface DownloadButtonAndMenuProps { className?: string @@ -57,19 +62,31 @@ export function DownloadButtonAndMenu({ }} > - Download Circuit JSON + Circuit JSON json notImplemented("3d model downloads")} + onClick={async () => { + try { + await downloadGltf( + circuitJson, + snippetUnscopedName || "circuit", + ) + } catch (error: any) { + toast({ + title: "Error Downloading 3D Model", + description: error.toString(), + }) + } + }} > - Download 3D Model + 3D Model - stl + gltf notImplemented("kicad footprint download")} > - Download Footprint + KiCad Footprint kicad_mod notImplemented("kicad project download")} + onSelect={() => { + downloadKicadFiles( + circuitJson, + snippetUnscopedName || "kicad_project", + ) + }} > - Download KiCad Project + KiCad Project - kicad_* + kicad_*.zip + { @@ -123,7 +146,19 @@ export function DownloadButtonAndMenu({ }} > - Download Schematic SVG + Schematic SVG + + svg + + + { + downloadAssemblySvg(circuitJson, snippetUnscopedName || "circuit") + }} + > + + Assembly SVG svg @@ -135,11 +170,41 @@ export function DownloadButtonAndMenu({ }} > - Download DSN file + Specctra DSN dsn + { + downloadReadableNetlist( + circuitJson, + snippetUnscopedName || "circuit", + ) + }} + > + + Readable Netlist + + txt + + + { + downloadSimpleRouteJson( + circuitJson, + snippetUnscopedName || "circuit", + ) + }} + > + + Simple Route JSON + + json + +
diff --git a/src/components/EditorNav.tsx b/src/components/EditorNav.tsx index 06bc3e50..abcc8364 100644 --- a/src/components/EditorNav.tsx +++ b/src/components/EditorNav.tsx @@ -1,4 +1,5 @@ import { Button } from "@/components/ui/button" +import { GitFork } from "lucide-react" import { DropdownMenu, DropdownMenuContent, @@ -22,6 +23,7 @@ import { Eye, EyeIcon, File, + FilePenLine, MoreVertical, Package, Pencil, @@ -32,7 +34,7 @@ import { Trash2, } from "lucide-react" import { useEffect, useState } from "react" -import { useQueryClient } from "react-query" +import { useMutation, useQueryClient } from "react-query" import { Link, useLocation } from "wouter" import { useAxios } from "../hooks/use-axios" import { useToast } from "../hooks/use-toast" @@ -44,6 +46,8 @@ import { useRenameSnippetDialog } from "./dialogs/rename-snippet-dialog" import { DownloadButtonAndMenu } from "./DownloadButtonAndMenu" import { SnippetLink } from "./SnippetLink" import { TypeBadge } from "./TypeBadge" +import { useUpdateDescriptionDialog } from "./dialogs/edit-description-dialog" +import { useForkSnippetMutation } from "@/hooks/useForkSnippetMutation" export default function EditorNav({ circuitJson, @@ -70,8 +74,13 @@ export default function EditorNav({ }) { const [, navigate] = useLocation() const isLoggedIn = useGlobalStore((s) => Boolean(s.session)) + const session = useGlobalStore((s) => s.session) const { Dialog: RenameDialog, openDialog: openRenameDialog } = useRenameSnippetDialog() + const { + Dialog: UpdateDescriptionDialog, + openDialog: openupdateDescriptionDialog, + } = useUpdateDescriptionDialog() const { Dialog: DeleteDialog, openDialog: openDeleteDialog } = useConfirmDeleteSnippetDialog() const { Dialog: CreateOrderDialog, openDialog: openCreateOrderDialog } = @@ -88,6 +97,17 @@ export default function EditorNav({ const { toast } = useToast() const qc = useQueryClient() + const { mutate: forkSnippet, isLoading: isForking } = useForkSnippetMutation({ + snippet: snippet!, + currentCode: code, + onSuccess: (forkedSnippet) => { + navigate("/editor?snippet_id=" + forkedSnippet.snippet_id) + setTimeout(() => { + window.location.reload() //reload the page + }, 2000) + }, + }) + // Update currentType when snippet or snippetType changes useEffect(() => { setCurrentType(snippetType ?? snippet?.snippet_type) @@ -138,6 +158,9 @@ export default function EditorNav({ } } + const canSaveSnippet = + !snippet || snippet.owner_name === session?.github_username + return (