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)
+
+
+
+ )}
setFootprintDialogOpen(true)}>
- Footprint
+ Chip
diff --git a/src/components/CreateNewSnippetWithAiHero.tsx b/src/components/CreateNewSnippetWithAiHero.tsx
index e510932d..c3527043 100644
--- a/src/components/CreateNewSnippetWithAiHero.tsx
+++ b/src/components/CreateNewSnippetWithAiHero.tsx
@@ -1,16 +1,7 @@
import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import {
- Globe,
- Code,
- Sun,
- Battery,
- Cpu,
- Grid,
- LayoutGrid,
- Bot,
-} from "lucide-react"
+import { Input } from "@/components/ui/input"
+import { Battery, Bot, Cpu, LayoutGrid } from "lucide-react"
import { useState } from "react"
import { useLocation } from "wouter"
@@ -49,34 +40,36 @@ export function CreateNewSnippetWithAiHero() {
/>
-
-
-
+
+
+
+
+
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 (