vike migration

This commit is contained in:
Ben Allfree
2025-12-06 17:12:03 -08:00
parent 69822a12b9
commit 3a543de36b
84 changed files with 1995 additions and 2622 deletions

View File

@@ -19,7 +19,7 @@ jobs:
run: |
git remote add meshtastic git@github.com:meshtastic/firmware.git
working-directory: vendor/firmware
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
@@ -39,10 +39,3 @@ jobs:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy
- name: Deploy Registry
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: deploy --config wrangler-registry.json

18
.vscode/settings.json vendored
View File

@@ -1,26 +1,26 @@
{
"editor.defaultFormatter": "biomejs.biome",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
"source.organizeImports.prettier": "explicit",
"source.fixAll.prettier": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 137 KiB

View File

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -1,74 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.7/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": [
"src/**",
"convex/**",
"*.json",
"!vendor",
"!convex/_generated"
]
},
"css": {
"parser": {
"tailwindDirectives": true
},
"linter": {
"enabled": false
}
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedImports": {
"level": "warn",
"fix": "safe"
},
"noUnusedVariables": {
"level": "warn",
"fix": "safe"
},
"noUnusedFunctionParameters": {
"level": "warn",
"fix": "safe"
}
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"indentStyle": "space",
"indentWidth": 2,
"quoteProperties": "asNeeded",
"semicolons": "asNeeded",
"trailingCommas": "es5"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"json": {
"formatter": {
"trailingCommas": "none",
"indentStyle": "space",
"indentWidth": 2
}
}
}

870
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
import { SourceAvailable } from "@/components/SourceAvailable"
import { Button } from "@/components/ui/button"
import { useMutation } from "convex/react"
import { useState } from "react"
import { toast } from "sonner"
import { api } from "../convex/_generated/api"
import type { Doc } from "../convex/_generated/dataModel"
import { ArtifactType } from "../convex/builds"
interface BuildDownloadButtonProps {
build: Doc<"builds">
type: ArtifactType
variant?: "default" | "outline"
className?: string
}
export function BuildDownloadButton({ build, type, variant, className }: BuildDownloadButtonProps) {
const generateDownloadUrl = useMutation(api.builds.generateDownloadUrl)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Default styling based on type
const defaultVariant = variant ?? (type === ArtifactType.Firmware ? "default" : "outline")
const defaultClassName =
className ?? (type === ArtifactType.Firmware ? "bg-cyan-600 hover:bg-cyan-700" : "bg-slate-700 hover:bg-slate-600")
const handleDownload = async () => {
setError(null)
setIsLoading(true)
try {
const url = await generateDownloadUrl({
buildId: build._id,
artifactType: type,
})
window.location.href = url
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const errorMsg =
type === ArtifactType.Firmware
? "Failed to generate download link."
: "Failed to generate source download link."
setError(errorMsg)
toast.error(errorMsg, {
description: message,
})
} finally {
setIsLoading(false)
}
}
if (type === ArtifactType.Firmware && !build.buildHash) return null
const button = (
<div className="space-y-2">
<Button onClick={handleDownload} disabled={isLoading} variant={defaultVariant} className={defaultClassName}>
Download {type === ArtifactType.Firmware ? "firmware" : "source"}
</Button>
{type === ArtifactType.Firmware && (
<p className="text-xs text-slate-400 text-center">
Need help flashing?{" "}
<a href="/docs/esp32" className="text-cyan-400 hover:text-cyan-300 underline">
ESP32
</a>{" "}
and{" "}
<a href="/docs/nRF52" className="text-cyan-400 hover:text-cyan-300 underline">
nRF52
</a>
</p>
)}
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
)
// For source downloads, only show when sourcePath is available
if (type === ArtifactType.Source) {
return <SourceAvailable sourcePath={build.sourcePath}>{button}</SourceAvailable>
}
return button
}

13
components/Link.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { usePageContext } from "vike-react/usePageContext";
import type { ReactNode } from "react";
export function Link({ href, children }: { href: string; children: ReactNode }) {
const pageContext = usePageContext();
const { urlPathname } = pageContext;
const isActive = href === "/" ? urlPathname === href : urlPathname.startsWith(href);
return (
<a href={href} className={isActive ? "is-active" : undefined}>
{children}
</a>
);
}

View File

@@ -1,10 +1,10 @@
import { useAuthActions } from '@convex-dev/auth/react'
import { Authenticated, Unauthenticated, useQuery } from 'convex/react'
import { Link } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { DiscordButton } from '@/components/DiscordButton'
import { RedditButton } from '@/components/RedditButton'
import { api } from '../../convex/_generated/api'
import { DiscordButton } from "@/components/DiscordButton"
import { RedditButton } from "@/components/RedditButton"
import { Button } from "@/components/ui/button"
import { useAuthActions } from "@convex-dev/auth/react"
import { Authenticated, Unauthenticated, useQuery } from "convex/react"
import favicon from "../assets/favicon-96x96.png"
import { api } from "../convex/_generated/api"
export default function Navbar() {
const { signOut, signIn } = useAuthActions()
@@ -15,34 +15,21 @@ export default function Navbar() {
<div className="max-w-7xl mx-auto px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-8">
<Link
to="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
>
<img
src="/favicon-96x96.png"
alt="Mesh Forge logo"
className="h-10 w-10 rounded-lg"
/>
<a href="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
<img src={favicon} alt="Mesh Forge logo" className="h-10 w-10 rounded-lg" />
<span className="text-2xl font-bold bg-gradient-to-r from-cyan-400 to-blue-600 bg-clip-text text-transparent">
Mesh Forge
</span>
</Link>
</a>
<div className="flex items-center gap-4">
<a href="/docs" className="text-slate-300 hover:text-white transition-colors">
Docs
</a>
<Authenticated>
<Link
to="/dashboard"
className="text-slate-300 hover:text-white transition-colors"
>
Dashboard
</Link>
{isAdmin && (
<Link
to="/admin"
className="text-slate-300 hover:text-white transition-colors"
>
<a href="/admin" className="text-slate-300 hover:text-white transition-colors">
Admin
</Link>
</a>
)}
</Authenticated>
</div>
@@ -58,7 +45,7 @@ export default function Navbar() {
/>
<Unauthenticated>
<Button
onClick={() => signIn('google', { redirectTo: window.location.href })}
onClick={() => signIn("google", { redirectTo: window.location.href })}
className="bg-cyan-600 hover:bg-cyan-700"
>
Sign In

View File

@@ -1,7 +1,6 @@
import type { Doc } from '../../convex/_generated/dataModel'
import type { Doc } from "../convex/_generated/dataModel"
export const profileCardClasses =
'border border-slate-800 rounded-lg p-6 bg-slate-900/50 flex flex-col gap-4'
export const profileCardClasses = "border border-slate-800 rounded-lg p-6 bg-slate-900/50 flex flex-col gap-4"
interface ProfilePillsProps {
version: string
@@ -9,19 +8,12 @@ interface ProfilePillsProps {
flashLabel?: string
}
export function ProfileStatisticPills({
version,
flashCount,
flashLabel,
}: ProfilePillsProps) {
export function ProfileStatisticPills({ version, flashCount, flashLabel }: ProfilePillsProps) {
const normalizedCount = flashCount ?? 0
const normalizedLabel =
flashLabel ?? (normalizedCount === 1 ? 'flash' : 'flashes')
const normalizedLabel = flashLabel ?? (normalizedCount === 1 ? "flash" : "flashes")
return (
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-wide">
<span className="inline-flex items-center rounded-full bg-slate-800/80 text-slate-200 px-3 py-1">
{version}
</span>
<span className="inline-flex items-center rounded-full bg-slate-800/80 text-slate-200 px-3 py-1">{version}</span>
<span className="inline-flex items-center rounded-full bg-cyan-500/10 text-cyan-300 px-3 py-1">
{normalizedCount} {normalizedLabel}
</span>
@@ -30,7 +22,7 @@ export function ProfileStatisticPills({
}
interface ProfileCardContentProps {
profile: Doc<'profiles'>
profile: Doc<"profiles">
}
export function ProfileCardContent({ profile }: ProfileCardContentProps) {
@@ -39,14 +31,9 @@ export function ProfileCardContent({ profile }: ProfileCardContentProps) {
<>
<div className="flex-1">
<h3 className="text-xl font-semibold mb-2">{profile.name}</h3>
<p className="text-slate-300 text-sm leading-relaxed">
{profile.description}
</p>
<p className="text-slate-300 text-sm leading-relaxed">{profile.description}</p>
</div>
<ProfileStatisticPills
version={profile.config.version}
flashCount={flashCount}
/>
<ProfileStatisticPills version={profile.config.version} flashCount={flashCount} />
</>
)
}

View File

@@ -1,31 +1,24 @@
import { useMutation } from 'convex/react'
import { useForm } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { api } from '../../convex/_generated/api'
import type { Doc } from '../../convex/_generated/dataModel'
import modulesData from '../../convex/modules.json'
import { VERSIONS } from '../constants/versions'
import { ModuleToggle } from './ModuleToggle'
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { useMutation } from "convex/react"
import { useForm } from "react-hook-form"
import { VERSIONS } from "../constants/versions"
import { api } from "../convex/_generated/api"
import type { Doc } from "../convex/_generated/dataModel"
import modulesData from "../convex/modules.json"
import { ModuleToggle } from "./ModuleToggle"
// Form values use flattened config for UI, but will be transformed to nested on submit
type ProfileFormValues = Omit<
Doc<'profiles'>,
'_id' | '_creationTime' | 'userId' | 'flashCount' | 'updatedAt'
>
type ProfileFormValues = Omit<Doc<"profiles">, "_id" | "_creationTime" | "userId" | "flashCount" | "updatedAt">
interface ProfileEditorProps {
initialData?: Doc<'profiles'>
initialData?: Doc<"profiles">
onSave: () => void
onCancel: () => void
}
export default function ProfileEditor({
initialData,
onSave,
onCancel,
}: ProfileEditorProps) {
export default function ProfileEditor({ initialData, onSave, onCancel }: ProfileEditorProps) {
const upsertProfile = useMutation(api.profiles.upsert)
const {
@@ -36,12 +29,12 @@ export default function ProfileEditor({
formState: { errors },
} = useForm<ProfileFormValues>({
defaultValues: {
name: initialData?.name || '',
description: initialData?.description || '',
name: initialData?.name || "",
description: initialData?.description || "",
config: {
version: VERSIONS[0],
modulesExcluded: {},
target: '',
target: "",
...initialData?.config,
},
isPublic: initialData?.isPublic ?? true,
@@ -60,10 +53,7 @@ export default function ProfileEditor({
}
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="space-y-6 bg-slate-900 p-6 rounded-lg border border-slate-800"
>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 bg-slate-900 p-6 rounded-lg border border-slate-800">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-2">
@@ -71,13 +61,11 @@ export default function ProfileEditor({
</label>
<Input
id="name"
{...register('name', { required: 'Profile name is required' })}
{...register("name", { required: "Profile name is required" })}
className="bg-slate-950 border-slate-800"
placeholder="e.g. Solar Repeater"
/>
{errors.name && (
<p className="mt-1 text-sm text-red-400">{errors.name.message}</p>
)}
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="version" className="block text-sm font-medium mb-2">
@@ -85,10 +73,10 @@ export default function ProfileEditor({
</label>
<select
id="version"
{...register('config.version')}
{...register("config.version")}
className="w-full h-10 px-3 rounded-md border border-slate-800 bg-slate-950 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:ring-offset-slate-950"
>
{VERSIONS.map((v) => (
{VERSIONS.map(v => (
<option key={v} value={v}>
{v}
</option>
@@ -103,25 +91,21 @@ export default function ProfileEditor({
</label>
<textarea
id="description"
{...register('description', {
required: 'Profile description is required',
{...register("description", {
required: "Profile description is required",
})}
className="w-full min-h-[120px] rounded-md border border-slate-800 bg-slate-950 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:ring-offset-slate-950"
placeholder="Describe what this profile is best suited for"
/>
{errors.description && (
<p className="mt-1 text-sm text-red-400">
{errors.description.message}
</p>
)}
{errors.description && <p className="mt-1 text-sm text-red-400">{errors.description.message}</p>}
</div>
<div>
<div className="flex items-center space-x-2">
<Checkbox
id="isPublic"
checked={watch('isPublic')}
onCheckedChange={(checked) => setValue('isPublic', !!checked)}
checked={watch("isPublic")}
onCheckedChange={checked => setValue("isPublic", !!checked)}
disabled
/>
<label
@@ -131,9 +115,7 @@ export default function ProfileEditor({
Make profile public
</label>
</div>
<p className="text-xs text-slate-400 mt-1 ml-6">
Public profiles are visible to everyone on the home page
</p>
<p className="text-xs text-slate-400 mt-1 ml-6">Public profiles are visible to everyone on the home page</p>
</div>
<div className="space-y-6">
@@ -141,15 +123,14 @@ export default function ProfileEditor({
<div className="mb-4">
<h3 className="text-lg font-medium">Modules</h3>
<p className="text-sm text-slate-400">
Modules are included by default if supported by your target.
Toggle to exclude modules you don't need.
Modules are included by default if supported by your target. Toggle to exclude modules you don't need.
</p>
</div>
<div className="flex flex-col gap-2">
{modulesData.modules.map((module) => {
{modulesData.modules.map(module => {
// Flattened config: config[id] === true -> Explicitly Excluded
// config[id] === undefined/false -> Default (included if target supports)
const currentConfig = watch('config') as Doc<'builds'>['config']
const currentConfig = watch("config") as Doc<"builds">["config"]
const configValue = currentConfig.modulesExcluded[module.id]
const isExcluded = configValue === true
@@ -160,14 +141,14 @@ export default function ProfileEditor({
name={module.name}
description={module.description}
isExcluded={isExcluded}
onToggle={(excluded) => {
onToggle={excluded => {
const newConfig = { ...currentConfig }
if (excluded) {
newConfig.modulesExcluded[module.id] = true
} else {
delete newConfig.modulesExcluded[module.id]
}
setValue('config', newConfig)
setValue("config", newConfig)
}}
/>
)

View File

@@ -1,4 +1,4 @@
import hardwareList from '../../vendor/web-flasher/public/data/hardware-list.json'
import hardwareList from "../vendor/web-flasher/public/data/hardware-list.json"
export interface TargetMetadata {
name: string
@@ -9,15 +9,13 @@ export interface TargetMetadata {
export const TARGETS: Record<string, TargetMetadata> = {}
// Sort by display name
const sortedHardware = [...hardwareList].sort((a, b) =>
(a.displayName || '').localeCompare(b.displayName || '')
)
const sortedHardware = [...hardwareList].sort((a, b) => (a.displayName || "").localeCompare(b.displayName || ""))
sortedHardware.forEach((hw) => {
sortedHardware.forEach(hw => {
if (hw.platformioTarget) {
TARGETS[hw.platformioTarget] = {
name: hw.displayName || hw.platformioTarget,
category: hw.tags?.[0] || 'Other',
category: hw.tags?.[0] || "Other",
architecture: hw.architecture,
}
}

View File

@@ -1,17 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<title>Mesh Forge</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,51 +1,48 @@
{
"name": "mesh-forge",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"generate:versions": "node scripts/generate-versions.js && biome format src/constants/versions.ts --write",
"generate:architecture": "node scripts/generate-architecture-hierarchy.js",
"dev": "bun run generate:versions && bun run generate:architecture && vite",
"build": "bun run generate:versions && bun run generate:architecture && tsc && vite build",
"lint": "biome lint",
"lint:fix": "biome lint --fix && biome format --write",
"preview": "vite preview",
"deploy": "npx convex deploy --cmd 'bun run build' && wrangler deploy && wrangler deploy --config wrangler-registry.json"
"dev": "vike dev",
"build": "vike build",
"preview": "vike build && vike preview",
"lint": "prettier --write .",
"lint:fix": "prettier --write .",
"deploy": "npx convex deploy --cmd 'bun run build' && wrangler deploy"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.937.0",
"@aws-sdk/s3-request-presigner": "^3.937.0",
"@convex-dev/auth": "^0.0.90",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"@photonjs/cloudflare": "^0.1.9",
"convex": "^1.29.3",
"convex-helpers": "^0.1.106",
"lucide-react": "^0.554.0",
"next-themes": "^0.4.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.66.1",
"react-router-dom": "^7.9.6",
"lucide-react": "^0.556.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
"vike": "^0.4.247",
"vike-photon": "^0.1.21",
"vike-react": "^0.6.13"
},
"devDependencies": {
"@biomejs/biome": "^2.3.7",
"@changesets/cli": "^2.29.8",
"@tailwindcss/postcss": "^4.1.17",
"@mdx-js/rollup": "^3.1.1",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@types/node": "^24.10.1",
"@types/react": "^18.0.37",
"@types/react-dom": "^18.0.11",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.22",
"postcss": "^8.5.6",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"class-variance-authority": "^0.7.1",
"prettier": "^3.7.3",
"prettier-plugin-organize-imports": "^4.3.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"typescript": "^5.0.2",
"vite": "^4.3.9",
"wrangler": "^4.50.0"
}
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.9.3",
"vite": "^7.2.6",
"wrangler": "^4.51.0"
},
"type": "module"
}

22
pages/+Head.tsx Normal file
View File

@@ -0,0 +1,22 @@
// https://vike.dev/Head
import logoUrl from "../assets/logo.png";
import faviconUrl from "../assets/favicon.svg";
import appleTouchIconUrl from "../assets/apple-touch-icon.png";
import siteWebmanifestUrl from "../assets/site.webmanifest";
import favicon96x96Url from "../assets/favicon-96x96.png";
import faviconIcoUrl from "../assets/favicon.ico";
export function Head() {
return (
<>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href={favicon96x96Url} sizes="96x96" />
<link rel="icon" type="image/svg+xml" href={faviconUrl} />
<link rel="shortcut icon" href={faviconIcoUrl} />
<link rel="apple-touch-icon" sizes="180x180" href={appleTouchIconUrl} />
<link rel="manifest" href={siteWebmanifestUrl} />
<link rel="icon" href={logoUrl} />
</>
);
}

27
pages/+Layout.tsx Normal file
View File

@@ -0,0 +1,27 @@
import "./Layout.css";
import "./tailwind.css";
import logoUrl from "../assets/logo.png";
import { Link } from "../components/Link";
import { ConvexReactClient } from "convex/react";
import { ConvexAuthProvider } from "@convex-dev/auth/react";
import Navbar from "@/components/Navbar";
import { usePageContext } from "vike-react/usePageContext";
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);
function ConditionalNavbar() {
const pageContext = usePageContext();
if (pageContext.urlPathname === "/") {
return null;
}
return <Navbar />;
}
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<ConvexAuthProvider client={convex}>
<ConditionalNavbar />
{children}
</ConvexAuthProvider>
);
}

15
pages/+config.ts Normal file
View File

@@ -0,0 +1,15 @@
import vikeReact from "vike-react/config"
import type { Config } from "vike/types"
// Default config (can be overridden by pages)
// https://vike.dev/config
export default {
// https://vike.dev/head-tags
title: "Mesh Forge",
description:
"Build custom firmware with third-party plugins: BBS's, custom hardware, games, and more. An open ecosystem growing to hundreds of plugins.",
extends: [vikeReact],
prerender: true,
} satisfies Config

View File

@@ -0,0 +1,4 @@
export async function onPageTransitionEnd() {
console.log("Page transition end");
document.body.classList.remove("page-transition");
}

View File

@@ -0,0 +1,9 @@
// https://vike.dev/onPageTransitionStart
import type { PageContextClient } from "vike/types";
export async function onPageTransitionStart(pageContext: Partial<PageContextClient>) {
console.log("Page transition start");
console.log("pageContext.isBackwardNavigation", pageContext.isBackwardNavigation);
document.body.classList.add("page-transition");
}

29
pages/Layout.css Normal file
View File

@@ -0,0 +1,29 @@
/* Links */
a {
text-decoration: none;
}
#sidebar a {
padding: 2px 10px;
margin-left: -10px;
}
#sidebar a.is-active {
background-color: #eee;
}
/* Reset */
body {
margin: 0;
font-family: sans-serif;
}
* {
box-sizing: border-box;
}
/* Page Transition Animation */
#page-content {
opacity: 1;
transition: opacity 0.3s ease-in-out;
}
body.page-transition #page-content {
opacity: 0;
}

19
pages/_error/+Page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { usePageContext } from "vike-react/usePageContext";
export default function Page() {
const { is404 } = usePageContext();
if (is404) {
return (
<>
<h1>Page Not Found</h1>
<p>This page could not be found.</p>
</>
);
}
return (
<>
<h1>Internal Error</h1>
<p>Something went wrong.</p>
</>
);
}

View File

@@ -1,24 +1,23 @@
import { useMutation, useQuery } from 'convex/react'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { BuildDownloadButton } from '@/components/BuildDownloadButton'
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
import type { Id } from '../../convex/_generated/dataModel'
import { ArtifactType } from '../../convex/builds'
import { useMutation, useQuery } from "convex/react";
import { useState } from "react";
import { toast } from "sonner";
import { navigate } from "vike/client/router";
import { BuildDownloadButton } from "@/components/BuildDownloadButton";
import { Button } from "@/components/ui/button";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
import { ArtifactType } from "../../convex/builds";
type FilterType = 'all' | 'failed'
type FilterType = "all" | "failed";
export default function Admin() {
const navigate = useNavigate()
const [filter, setFilter] = useState<FilterType>('failed')
const isAdmin = useQuery(api.admin.isAdmin)
const failedBuilds = useQuery(api.admin.listFailedBuilds)
const allBuilds = useQuery(api.admin.listAllBuilds)
const retryBuild = useMutation(api.admin.retryBuild)
const [filter, setFilter] = useState<FilterType>("failed");
const isAdmin = useQuery(api.admin.isAdmin);
const failedBuilds = useQuery(api.admin.listFailedBuilds);
const allBuilds = useQuery(api.admin.listAllBuilds);
const retryBuild = useMutation(api.admin.retryBuild);
const builds = filter === 'failed' ? failedBuilds : allBuilds
const builds = filter === "failed" ? failedBuilds : allBuilds;
// Show loading state
if (isAdmin === undefined) {
@@ -26,7 +25,7 @@ export default function Admin() {
<div className="min-h-screen bg-slate-950 text-white flex items-center justify-center">
<div className="text-slate-400">Loading...</div>
</div>
)
);
}
// Redirect if not admin
@@ -35,80 +34,71 @@ export default function Admin() {
<div className="min-h-screen bg-slate-950 text-white flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Access Denied</h1>
<p className="text-slate-400 mb-4">
You must be an admin to access this page.
</p>
<Button onClick={() => navigate('/')}>Go Home</Button>
<p className="text-slate-400 mb-4">You must be an admin to access this page.</p>
<Button onClick={() => navigate("/")}>Go Home</Button>
</div>
</div>
)
);
}
const handleRetry = async (buildId: Id<'builds'>) => {
const handleRetry = async (buildId: Id<"builds">) => {
try {
await retryBuild({ buildId })
toast.success('Build retry initiated', {
description: 'The build has been queued with the latest YAML.',
})
await retryBuild({ buildId });
toast.success("Build retry initiated", {
description: "The build has been queued with the latest YAML.",
});
} catch (error) {
toast.error('Failed to retry build', {
toast.error("Failed to retry build", {
description: String(error),
})
});
}
}
};
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleString()
}
return new Date(timestamp).toLocaleString();
};
const getStatusBadge = (status: string) => {
const statusConfig = {
success: {
bg: 'bg-green-500/20',
text: 'text-green-400',
label: 'Success',
bg: "bg-green-500/20",
text: "text-green-400",
label: "Success",
},
failure: { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Failed' },
failure: { bg: "bg-red-500/20", text: "text-red-400", label: "Failed" },
queued: {
bg: 'bg-yellow-500/20',
text: 'text-yellow-400',
label: 'Queued',
bg: "bg-yellow-500/20",
text: "text-yellow-400",
label: "Queued",
},
}
};
const config = statusConfig[status as keyof typeof statusConfig] || {
bg: 'bg-slate-500/20',
text: 'text-slate-400',
bg: "bg-slate-500/20",
text: "text-slate-400",
label: status,
}
return (
<span className={`px-2 py-1 ${config.bg} ${config.text} rounded text-sm`}>
{config.label}
</span>
)
}
};
return <span className={`px-2 py-1 ${config.bg} ${config.text} rounded text-sm`}>{config.label}</span>;
};
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<header className="mb-8">
<h1 className="text-3xl font-bold mb-2">Admin - Builds</h1>
<p className="text-slate-400 mb-4">
View and manage builds. Retry failed builds with the latest GitHub
Actions workflow YAML.
View and manage builds. Retry failed builds with the latest GitHub Actions workflow YAML.
</p>
<div className="flex gap-2">
<Button
variant={filter === 'all' ? 'default' : 'outline'}
onClick={() => setFilter('all')}
className={filter === 'all' ? 'bg-cyan-600 hover:bg-cyan-700' : ''}
variant={filter === "all" ? "default" : "outline"}
onClick={() => setFilter("all")}
className={filter === "all" ? "bg-cyan-600 hover:bg-cyan-700" : ""}
>
All Builds
</Button>
<Button
variant={filter === 'failed' ? 'default' : 'outline'}
onClick={() => setFilter('failed')}
className={
filter === 'failed' ? 'bg-cyan-600 hover:bg-cyan-700' : ''
}
variant={filter === "failed" ? "default" : "outline"}
onClick={() => setFilter("failed")}
className={filter === "failed" ? "bg-cyan-600 hover:bg-cyan-700" : ""}
>
Failed Builds
</Button>
@@ -117,20 +107,13 @@ export default function Admin() {
<main>
{builds === undefined ? (
<div className="text-center text-slate-400 py-12">
Loading builds...
</div>
<div className="text-center text-slate-400 py-12">Loading builds...</div>
) : builds.length === 0 ? (
<div className="text-center text-slate-400 py-12">
No {filter === 'failed' ? 'failed ' : ''}builds found.
</div>
<div className="text-center text-slate-400 py-12">No {filter === "failed" ? "failed " : ""}builds found.</div>
) : (
<div className="space-y-4">
{builds.map((build) => (
<div
key={build._id}
className="bg-slate-900 border border-slate-800 rounded-lg p-6"
>
<div key={build._id} className="bg-slate-900 border border-slate-800 rounded-lg p-6">
{/* Header Section */}
<div className="flex items-center justify-between mb-4 pb-4 border-b border-slate-800">
<div className="flex items-center gap-3">
@@ -156,11 +139,7 @@ export default function Admin() {
>
Clone
</Button>
<Button
onClick={() => handleRetry(build._id)}
className="bg-cyan-600 hover:bg-cyan-700"
size="sm"
>
<Button onClick={() => handleRetry(build._id)} className="bg-cyan-600 hover:bg-cyan-700" size="sm">
Re-run Build
</Button>
</div>
@@ -171,36 +150,29 @@ export default function Admin() {
<div className="space-y-2">
<div>
<span className="text-sm text-slate-500">Target</span>
<div className="text-sm font-mono text-white mt-1">
{build.config.target}
</div>
<div className="text-sm font-mono text-white mt-1">{build.config.target}</div>
</div>
<div>
<span className="text-sm text-slate-500">Version</span>
<div className="text-sm font-mono text-white mt-1">
{build.config.version}
</div>
<div className="text-sm font-mono text-white mt-1">{build.config.version}</div>
</div>
</div>
<div className="space-y-2">
<div>
<span className="text-sm text-slate-500">
{build.completedAt ? 'Completed' : 'Started'}
</span>
<span className="text-sm text-slate-500">{build.completedAt ? "Completed" : "Started"}</span>
<div className="text-sm text-white mt-1">
{build.completedAt
? formatDate(build.completedAt)
: build.startedAt
? formatDate(build.startedAt)
: 'Unknown'}
: "Unknown"}
</div>
</div>
</div>
</div>
{/* Run History Section */}
{(build.githubRunId ||
(build.githubRunIdHistory?.length ?? 0) > 0) && (
{(build.githubRunId || (build.githubRunIdHistory?.length ?? 0) > 0) && (
<div className="mb-4 pb-4 border-b border-slate-800">
<span className="text-xs text-slate-500 mb-2 block">
Run History
@@ -237,16 +209,8 @@ export default function Admin() {
{/* Download Actions */}
{build.buildHash && (
<div className="flex gap-3">
{build.status === 'success' && (
<BuildDownloadButton
build={build}
type={ArtifactType.Firmware}
/>
)}
<BuildDownloadButton
build={build}
type={ArtifactType.Source}
/>
{build.status === "success" && <BuildDownloadButton build={build} type={ArtifactType.Firmware} />}
<BuildDownloadButton build={build} type={ArtifactType.Source} />
</div>
)}
</div>
@@ -255,5 +219,5 @@ export default function Admin() {
)}
</main>
</div>
)
);
}

View File

@@ -1,31 +1,22 @@
import { useMutation, useQuery } from 'convex/react'
import {
AlertCircle,
ArrowLeft,
CheckCircle,
Loader2,
Share2,
XCircle,
} from 'lucide-react'
import { useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { BuildDownloadButton } from '@/components/BuildDownloadButton'
import { Button } from '@/components/ui/button'
import { getImplicitDependencies, humanizeStatus } from '@/lib/utils'
import { api } from '../../convex/_generated/api'
import { ArtifactType } from '../../convex/builds'
import modulesData from '../../convex/modules.json'
import registryData from '../../registry/registry.json'
import { TARGETS } from '../constants/targets'
import { BuildDownloadButton } from "@/components/BuildDownloadButton"
import { Button } from "@/components/ui/button"
import { getImplicitDependencies, humanizeStatus } from "@/lib/utils"
import { useMutation, useQuery } from "convex/react"
import { AlertCircle, ArrowLeft, CheckCircle, Loader2, Share2, XCircle } from "lucide-react"
import { useState } from "react"
import { toast } from "sonner"
import { usePageContext } from "vike-react/usePageContext"
import { navigate } from "vike/client/router"
import { TARGETS } from "../../../constants/targets"
import { api } from "../../../convex/_generated/api"
import { ArtifactType } from "../../../convex/builds"
import modulesData from "../../../convex/modules.json"
import registryData from "../../../public/registry.json"
export default function BuildProgress() {
const { buildHash } = useParams<{ buildHash: string }>()
const navigate = useNavigate()
const build = useQuery(
api.builds.getByHash,
buildHash ? { buildHash } : 'skip'
)
const pageContext = usePageContext()
const buildHash = pageContext.routeParams?.buildHash as string | undefined
const build = useQuery(api.builds.getByHash, buildHash ? { buildHash } : "skip")
const isAdmin = useQuery(api.admin.isAdmin)
const retryBuild = useMutation(api.admin.retryBuild)
const [shareUrlCopied, setShareUrlCopied] = useState(false)
@@ -35,10 +26,10 @@ export default function BuildProgress() {
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto">
<p className="text-slate-300">
Build hash missing.{' '}
<Link to="/builds/new" className="text-cyan-400">
Build hash missing.{" "}
<a href="/builds/new" className="text-cyan-400">
Start a new build
</Link>
</a>
.
</p>
</div>
@@ -58,17 +49,13 @@ export default function BuildProgress() {
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto space-y-4">
<Link
to={`/builds/new/${buildHash}`}
className="inline-flex items-center text-slate-400 hover:text-white"
>
<a href={`/builds/new/${buildHash}`} className="inline-flex items-center text-slate-400 hover:text-white">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Quick Build
</Link>
</a>
<div className="bg-slate-900/60 border border-slate-800 rounded-lg p-6">
<p className="text-slate-300">
No build found for hash{' '}
<span className="font-mono">{buildHash}</span>
No build found for hash <span className="font-mono">{buildHash}</span>
</p>
</div>
</div>
@@ -76,26 +63,24 @@ export default function BuildProgress() {
)
}
const targetMeta = build.config.target
? TARGETS[build.config.target]
: undefined
const targetMeta = build.config.target ? TARGETS[build.config.target] : undefined
const targetLabel = targetMeta?.name ?? build.config.target
const status = build.status || 'queued'
const status = build.status || "queued"
const getStatusIcon = () => {
if (status === 'success') {
if (status === "success") {
return <CheckCircle className="w-6 h-6 text-green-500" />
}
if (status === 'failure') {
if (status === "failure") {
return <XCircle className="w-6 h-6 text-red-500" />
}
return <Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
}
const getStatusColor = () => {
if (status === 'success') return 'text-green-400'
if (status === 'failure') return 'text-red-400'
return 'text-blue-400'
if (status === "success") return "text-green-400"
if (status === "failure") return "text-red-400"
return "text-blue-400"
}
const githubActionUrl =
@@ -109,11 +94,11 @@ export default function BuildProgress() {
try {
await navigator.clipboard.writeText(shareUrl)
setShareUrlCopied(true)
toast.success('Share link copied to clipboard')
toast.success("Share link copied to clipboard")
setTimeout(() => setShareUrlCopied(false), 2000)
} catch {
toast.error('Failed to copy link', {
description: 'Please copy the URL manually',
toast.error("Failed to copy link", {
description: "Please copy the URL manually",
})
}
}
@@ -122,43 +107,42 @@ export default function BuildProgress() {
const computeFlagsFromConfig = (config: typeof build.config): string => {
return Object.keys(config.modulesExcluded)
.sort()
.filter((module) => config.modulesExcluded[module])
.filter(module => config.modulesExcluded[module])
.map((moduleExcludedName: string) => `-D${moduleExcludedName}=1`)
.join(' ')
.join(" ")
}
// Generate GitHub discussion URL with prefilled body
const generateDiscussionUrl = (): string => {
const flags = computeFlagsFromConfig(build.config)
const plugins = build.config.pluginsEnabled?.join(', ') || '(none)'
const plugins = build.config.pluginsEnabled?.join(", ") || "(none)"
const timestamp = new Date(build.startedAt).toISOString()
const githubRunLink = githubActionUrl
? `[View run](${githubActionUrl})`
: '(not available)'
const githubRunLink = githubActionUrl ? `[View run](${githubActionUrl})` : "(not available)"
const buildPageUrl = `${window.location.origin}/builds/${build.buildHash}`
// Format plugins as +plugin@version
const formattedPlugins = build.config.pluginsEnabled
?.map((plugin) => {
// Plugin might be "slug@version" or just "slug"
return plugin.includes('@') ? `+${plugin}` : `+${plugin}`
})
.join(' ') || ''
const formattedPlugins =
build.config.pluginsEnabled
?.map(plugin => {
// Plugin might be "slug@version" or just "slug"
return plugin.includes("@") ? `+${plugin}` : `+${plugin}`
})
.join(" ") || ""
const bracketContent = [
build.config.target,
`v${build.config.version}`,
...(formattedPlugins ? [formattedPlugins] : []),
].join(' ')
].join(" ")
const discussionTitle = `Build ${build.status === 'failure' ? 'Failed' : 'Issue'}: ${targetLabel} [${bracketContent}]`
const discussionTitle = `Build ${build.status === "failure" ? "Failed" : "Issue"}: ${targetLabel} [${bracketContent}]`
const discussionBody = `## Build ${build.status === 'failure' ? 'Failed' : 'Information'}
const discussionBody = `## Build ${build.status === "failure" ? "Failed" : "Information"}
**Build Hash**: \`${build.buildHash}\`
**Target Board**: ${build.config.target}
**Firmware Version**: ${build.config.version}
**Build Flags**: ${flags || '(none)'}
**Build Flags**: ${flags || "(none)"}
**Plugins**: ${plugins}
**Build Timestamp**: ${timestamp}
@@ -168,9 +152,9 @@ export default function BuildProgress() {
## Additional Information
(Please add any additional details about the issue here)`
const baseUrl = 'https://github.com/MeshEnvy/mesh-forge/discussions/new'
const baseUrl = "https://github.com/MeshEnvy/mesh-forge/discussions/new"
const params = new URLSearchParams({
category: 'q-a',
category: "q-a",
title: discussionTitle,
body: discussionBody,
})
@@ -179,18 +163,18 @@ export default function BuildProgress() {
}
const handleReportIssue = () => {
window.open(generateDiscussionUrl(), '_blank', 'noopener,noreferrer')
window.open(generateDiscussionUrl(), "_blank", "noopener,noreferrer")
}
const handleRetry = async () => {
if (!build?._id) return
try {
await retryBuild({ buildId: build._id })
toast.success('Build retry initiated', {
description: 'The build has been queued with the latest YAML.',
toast.success("Build retry initiated", {
description: "The build has been queued with the latest YAML.",
})
} catch (error) {
toast.error('Failed to retry build', {
toast.error("Failed to retry build", {
description: String(error),
})
}
@@ -199,27 +183,23 @@ export default function BuildProgress() {
const getStatusBadge = (status: string) => {
const statusConfig = {
success: {
bg: 'bg-green-500/20',
text: 'text-green-400',
label: 'Success',
bg: "bg-green-500/20",
text: "text-green-400",
label: "Success",
},
failure: { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Failed' },
failure: { bg: "bg-red-500/20", text: "text-red-400", label: "Failed" },
queued: {
bg: 'bg-yellow-500/20',
text: 'text-yellow-400',
label: 'Queued',
bg: "bg-yellow-500/20",
text: "text-yellow-400",
label: "Queued",
},
}
const config = statusConfig[status as keyof typeof statusConfig] || {
bg: 'bg-slate-500/20',
text: 'text-slate-400',
bg: "bg-slate-500/20",
text: "text-slate-400",
label: status,
}
return (
<span className={`px-2 py-1 ${config.bg} ${config.text} rounded text-sm`}>
{config.label}
</span>
)
return <span className={`px-2 py-1 ${config.bg} ${config.text} rounded text-sm`}>{config.label}</span>
}
const formatDate = (timestamp: number) => {
@@ -227,18 +207,14 @@ export default function BuildProgress() {
}
// Get excluded modules
const excludedModules = modulesData.modules.filter(
(module) => build.config.modulesExcluded[module.id] === true
)
const excludedModules = modulesData.modules.filter(module => build.config.modulesExcluded[module.id] === true)
// Get explicitly selected plugins from stored config
// The stored config only contains explicitly selected plugins (not resolved dependencies)
const explicitPluginSlugs = (build.config.pluginsEnabled || []).map(
(pluginId) => {
// Extract slug from "slug@version" format if present
return pluginId.includes('@') ? pluginId.split('@')[0] : pluginId
}
)
const explicitPluginSlugs = (build.config.pluginsEnabled || []).map(pluginId => {
// Extract slug from "slug@version" format if present
return pluginId.includes("@") ? pluginId.split("@")[0] : pluginId
})
// Get implicit dependencies (dependencies that are not explicitly selected)
const implicitDeps = getImplicitDependencies(
@@ -261,36 +237,28 @@ export default function BuildProgress() {
}> = []
// Process explicitly selected plugins
;(build.config.pluginsEnabled || []).forEach((pluginId) => {
;(build.config.pluginsEnabled || []).forEach(pluginId => {
// Extract slug from "slug@version" format if present
const slug = pluginId.includes('@') ? pluginId.split('@')[0] : pluginId
const pluginData = (registryData as Record<
string,
{ name: string; description: string; version: string }
>)[slug]
const slug = pluginId.includes("@") ? pluginId.split("@")[0] : pluginId
const pluginData = (registryData as Record<string, { name: string; description: string; version: string }>)[slug]
const pluginInfo = {
id: slug,
name: pluginData?.name || slug,
description: pluginData?.description || '',
version: pluginId.includes('@')
? pluginId.split('@')[1]
: pluginData?.version || '',
description: pluginData?.description || "",
version: pluginId.includes("@") ? pluginId.split("@")[1] : pluginData?.version || "",
}
explicitPlugins.push(pluginInfo)
})
// Process implicit dependencies (resolved but not in stored config)
for (const slug of implicitDeps) {
const pluginData = (registryData as Record<
string,
{ name: string; description: string; version: string }
>)[slug]
const pluginData = (registryData as Record<string, { name: string; description: string; version: string }>)[slug]
if (pluginData) {
implicitPlugins.push({
id: slug,
name: pluginData.name || slug,
description: pluginData.description || '',
version: pluginData.version || '',
description: pluginData.description || "",
version: pluginData.version || "",
})
}
}
@@ -299,13 +267,13 @@ export default function BuildProgress() {
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<Link
to={`/builds/new/${build.buildHash}`}
<a
href={`/builds/new/${build.buildHash}`}
className="inline-flex items-center text-slate-400 hover:text-white"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Quick Build
</Link>
</a>
</div>
<div className="bg-slate-900/60 rounded-lg border border-slate-800 p-6 space-y-4">
@@ -313,14 +281,10 @@ export default function BuildProgress() {
<div className="flex items-center gap-4">
{getStatusIcon()}
<div>
<p className="text-sm uppercase tracking-wide text-slate-500">
Target
</p>
<p className="text-sm uppercase tracking-wide text-slate-500">Target</p>
<h2 className="text-2xl font-semibold">{targetLabel}</h2>
<div className="flex items-center gap-2 text-slate-400 mt-1 text-sm">
<span className={getStatusColor()}>
{humanizeStatus(status)}
</span>
<span className={getStatusColor()}>{humanizeStatus(status)}</span>
<span></span>
<span>{new Date(build.updatedAt).toLocaleString()}</span>
{githubActionUrl && (
@@ -340,20 +304,13 @@ export default function BuildProgress() {
</div>
</div>
<div className="flex gap-2">
<Button
onClick={handleReportIssue}
variant="outline"
className="border-slate-600 hover:bg-slate-800"
>
<Button onClick={handleReportIssue} variant="outline" className="border-slate-600 hover:bg-slate-800">
<AlertCircle className="w-4 h-4 mr-2" />
Report a Problem
</Button>
<Button
onClick={handleShare}
className="bg-green-600 hover:bg-green-700"
>
<Button onClick={handleShare} className="bg-green-600 hover:bg-green-700">
<Share2 className="w-4 h-4 mr-2" />
{shareUrlCopied ? 'Copied!' : 'Share Build'}
{shareUrlCopied ? "Copied!" : "Share Build"}
</Button>
</div>
</div>
@@ -380,11 +337,7 @@ export default function BuildProgress() {
>
Clone
</Button>
<Button
onClick={handleRetry}
className="bg-cyan-600 hover:bg-cyan-700"
size="sm"
>
<Button onClick={handleRetry} className="bg-cyan-600 hover:bg-cyan-700" size="sm">
Re-run Build
</Button>
</div>
@@ -395,36 +348,29 @@ export default function BuildProgress() {
<div className="space-y-2">
<div>
<span className="text-sm text-slate-500">Target</span>
<div className="text-sm font-mono text-white mt-1">
{build.config.target}
</div>
<div className="text-sm font-mono text-white mt-1">{build.config.target}</div>
</div>
<div>
<span className="text-sm text-slate-500">Version</span>
<div className="text-sm font-mono text-white mt-1">
{build.config.version}
</div>
<div className="text-sm font-mono text-white mt-1">{build.config.version}</div>
</div>
</div>
<div className="space-y-2">
<div>
<span className="text-sm text-slate-500">
{build.completedAt ? 'Completed' : 'Started'}
</span>
<span className="text-sm text-slate-500">{build.completedAt ? "Completed" : "Started"}</span>
<div className="text-sm text-white mt-1">
{build.completedAt
? formatDate(build.completedAt)
: build.startedAt
? formatDate(build.startedAt)
: 'Unknown'}
: "Unknown"}
</div>
</div>
</div>
</div>
{/* Run History Section */}
{(build.githubRunId ||
(build.githubRunIdHistory?.length ?? 0) > 0) && (
{(build.githubRunId || (build.githubRunIdHistory?.length ?? 0) > 0) && (
<div className="pt-4 border-t border-slate-800">
<span className="text-xs text-slate-500 mb-2 block">
Run History
@@ -443,7 +389,7 @@ export default function BuildProgress() {
{build.githubRunId}
</a>
)}
{build.githubRunIdHistory?.map((id) => (
{build.githubRunIdHistory?.map(id => (
<a
key={id}
href={`https://github.com/MeshEnvy/mesh-forge/actions/runs/${id}`}
@@ -460,20 +406,18 @@ export default function BuildProgress() {
</div>
)}
{status !== 'success' && status !== 'failure' && (
{status !== "success" && status !== "failure" && (
<div className="rounded-lg border border-slate-800/70 bg-slate-950/60 p-4">
<p className="text-sm text-slate-400">
Builds run in GitHub Actions. When the status is
<span className="text-green-400 font-medium"> success</span>,
your firmware artifact will be ready to download.
<span className="text-green-400 font-medium"> success</span>, your firmware artifact will be ready to
download.
</p>
</div>
)}
{/* Build Configuration Summary */}
{(excludedModules.length > 0 ||
explicitPlugins.length > 0 ||
implicitPlugins.length > 0) && (
{(excludedModules.length > 0 || explicitPlugins.length > 0 || implicitPlugins.length > 0) && (
<div className="space-y-6 border-t border-slate-800 pt-6">
{/* Excluded Modules */}
{excludedModules.length > 0 && (
@@ -481,17 +425,10 @@ export default function BuildProgress() {
<h3 className="text-lg font-semibold mb-3">Excluded Modules</h3>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
<div className="space-y-4">
{excludedModules.map((module) => (
<div
key={module.id}
className="border-b border-slate-800 pb-4 last:border-b-0 last:pb-0"
>
<h4 className="text-base font-medium mb-1">
{module.name}
</h4>
<p className="text-slate-400 text-sm">
{module.description}
</p>
{excludedModules.map(module => (
<div key={module.id} className="border-b border-slate-800 pb-4 last:border-b-0 last:pb-0">
<h4 className="text-base font-medium mb-1">{module.name}</h4>
<p className="text-slate-400 text-sm">{module.description}</p>
</div>
))}
</div>
@@ -505,26 +442,13 @@ export default function BuildProgress() {
<h3 className="text-lg font-semibold mb-3">Enabled Plugins</h3>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
<div className="space-y-4">
{explicitPlugins.map((plugin) => (
<div
key={plugin.id}
className="border-b border-slate-800 pb-4 last:border-b-0 last:pb-0"
>
{explicitPlugins.map(plugin => (
<div key={plugin.id} className="border-b border-slate-800 pb-4 last:border-b-0 last:pb-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-base font-medium">
{plugin.name}
</h4>
{plugin.version && (
<span className="text-xs text-slate-500">
v{plugin.version}
</span>
)}
<h4 className="text-base font-medium">{plugin.name}</h4>
{plugin.version && <span className="text-xs text-slate-500">v{plugin.version}</span>}
</div>
{plugin.description && (
<p className="text-slate-400 text-sm">
{plugin.description}
</p>
)}
{plugin.description && <p className="text-slate-400 text-sm">{plugin.description}</p>}
</div>
))}
</div>
@@ -538,26 +462,13 @@ export default function BuildProgress() {
<h3 className="text-lg font-semibold mb-3">Required Plugins</h3>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
<div className="space-y-4">
{implicitPlugins.map((plugin) => (
<div
key={plugin.id}
className="border-b border-slate-800 pb-4 last:border-b-0 last:pb-0"
>
{implicitPlugins.map(plugin => (
<div key={plugin.id} className="border-b border-slate-800 pb-4 last:border-b-0 last:pb-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-base font-medium">
{plugin.name}
</h4>
{plugin.version && (
<span className="text-xs text-slate-500">
v{plugin.version}
</span>
)}
<h4 className="text-base font-medium">{plugin.name}</h4>
{plugin.version && <span className="text-xs text-slate-500">v{plugin.version}</span>}
</div>
{plugin.description && (
<p className="text-slate-400 text-sm">
{plugin.description}
</p>
)}
{plugin.description && <p className="text-slate-400 text-sm">{plugin.description}</p>}
</div>
))}
</div>
@@ -567,7 +478,7 @@ export default function BuildProgress() {
</div>
)}
{status === 'success' && build.buildHash && (
{status === "success" && build.buildHash && (
<BuildDownloadButton
build={build}
type={ArtifactType.Firmware}
@@ -584,20 +495,18 @@ export default function BuildProgress() {
/>
)}
{status === 'failure' && (
{status === "failure" && (
<div className="rounded-lg border border-red-500/40 bg-red-500/10 p-4 text-sm text-red-100">
<p className="font-medium text-red-200">
Build failed. Please try tweaking your configuration or
re-running the build.
Build failed. Please try tweaking your configuration or re-running the build.
</p>
</div>
)}
{status !== 'success' && status !== 'failure' && (
{status !== "success" && status !== "failure" && (
<div className="rounded-lg border border-blue-500/30 bg-blue-500/5 p-4 text-sm text-blue-100">
<p className="font-medium text-blue-200">
This build is still running. Leave this tab open or come back
later using the URL above.
This build is still running. Leave this tab open or come back later using the URL above.
</p>
</div>
)}

View File

@@ -0,0 +1,3 @@
export default {
prerender: false,
}

View File

@@ -0,0 +1,5 @@
import Builder from "./Builder";
export default function BuildNew() {
return <Builder />;
}

View File

@@ -0,0 +1,5 @@
import Builder from "../Builder";
export default function BuildNew() {
return <Builder />;
}

View File

@@ -0,0 +1,3 @@
export default {
prerender: false,
}

View File

@@ -1,28 +1,29 @@
import { useMutation, useQuery } from 'convex/react'
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { ModuleToggle } from '@/components/ModuleToggle'
import { PluginToggle } from '@/components/PluginToggle'
import { Button } from '@/components/ui/button'
import { ModuleToggle } from "@/components/ModuleToggle"
import { PluginToggle } from "@/components/PluginToggle"
import { Button } from "@/components/ui/button"
import {
getDependedPlugins,
getImplicitDependencies,
isRequiredByOther,
isPluginCompatibleWithTarget,
} from '@/lib/utils'
import { api } from '../../convex/_generated/api'
import modulesData from '../../convex/modules.json'
import registryData from '../../registry/registry.json'
import { TARGETS } from '../constants/targets'
import { VERSIONS } from '../constants/versions'
isRequiredByOther,
} from "@/lib/utils"
import { useMutation, useQuery } from "convex/react"
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { usePageContext } from "vike-react/usePageContext"
import { navigate } from "vike/client/router"
import { TARGETS } from "../../../constants/targets"
import { VERSIONS } from "../../../constants/versions"
import { api } from "../../../convex/_generated/api"
import modulesData from "../../../convex/modules.json"
import registryData from "../../../public/registry.json"
type TargetGroup = (typeof TARGETS)[string] & { id: string }
const GROUPED_TARGETS = Object.entries(TARGETS).reduce(
(acc, [id, meta]) => {
const category = meta.category || 'Other'
const category = meta.category || "Other"
if (!acc[category]) acc[category] = []
acc[category].push({ id, ...meta })
return acc
@@ -30,38 +31,31 @@ const GROUPED_TARGETS = Object.entries(TARGETS).reduce(
{} as Record<string, TargetGroup[]>
)
const TARGET_CATEGORIES = Object.keys(GROUPED_TARGETS).sort((a, b) =>
a.localeCompare(b)
)
const TARGET_CATEGORIES = Object.keys(GROUPED_TARGETS).sort((a, b) => a.localeCompare(b))
const DEFAULT_TARGET =
TARGET_CATEGORIES.length > 0 && GROUPED_TARGETS[TARGET_CATEGORIES[0]]?.length
? GROUPED_TARGETS[TARGET_CATEGORIES[0]][0].id
: ''
: ""
export default function BuildNew() {
const navigate = useNavigate()
const { buildHash: buildHashParam } = useParams<{ buildHash?: string }>()
const pageContext = usePageContext()
const buildHashParam = pageContext.routeParams?.buildHash
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
const pluginFlashCounts = useQuery(api.plugins.getAll) ?? {}
const sharedBuild = useQuery(
api.builds.getByHash,
buildHashParam ? { buildHash: buildHashParam } : 'skip'
)
const sharedBuild = useQuery(api.builds.getByHash, buildHashParam ? { buildHash: buildHashParam } : "skip")
const STORAGE_KEY = 'quick_build_target'
const STORAGE_KEY = "quick_build_target"
const persistTargetSelection = (targetId: string) => {
if (typeof window === 'undefined') return
if (typeof window === "undefined") return
try {
window.localStorage.setItem(STORAGE_KEY, targetId)
} catch (error) {
console.error('Failed to persist target selection', error)
console.error("Failed to persist target selection", error)
}
}
const [activeCategory, setActiveCategory] = useState<string>(
TARGET_CATEGORIES[0] ?? ''
)
const [activeCategory, setActiveCategory] = useState<string>(TARGET_CATEGORIES[0] ?? "")
const [selectedTarget, setSelectedTarget] = useState<string>(DEFAULT_TARGET)
const [selectedVersion, setSelectedVersion] = useState<string>(VERSIONS[0])
const [moduleConfig, setModuleConfig] = useState<Record<string, boolean>>({})
@@ -88,38 +82,38 @@ export default function BuildNew() {
}, [selectedTarget, activeCategory])
useEffect(() => {
if (typeof window === 'undefined') return
if (typeof window === "undefined") return
try {
const savedTarget = localStorage.getItem(STORAGE_KEY)
if (savedTarget && TARGETS[savedTarget]) {
setSelectedTarget(savedTarget)
const category = TARGETS[savedTarget].category || 'Other'
const category = TARGETS[savedTarget].category || "Other"
if (TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
} catch (error) {
console.error('Failed to read saved target', error)
console.error("Failed to read saved target", error)
}
}, [])
const handleSelectTarget = (targetId: string) => {
setSelectedTarget(targetId)
persistTargetSelection(targetId)
const category = TARGETS[targetId]?.category || 'Other'
const category = TARGETS[targetId]?.category || "Other"
if (category && TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
useEffect(() => {
if (typeof window === 'undefined' || !selectedTarget) return
if (typeof window === "undefined" || !selectedTarget) return
try {
if (!window.localStorage.getItem(STORAGE_KEY)) {
window.localStorage.setItem(STORAGE_KEY, selectedTarget)
}
} catch (error) {
console.error('Failed to initialize target storage', error)
console.error("Failed to initialize target storage", error)
}
}, [selectedTarget])
@@ -133,11 +127,9 @@ export default function BuildNew() {
setIsLoadingSharedBuild(false)
if (!sharedBuild) {
setErrorMessage(
'Build not found. The shared build may have been deleted.'
)
toast.error('Build not found', {
description: 'The shared build could not be loaded.',
setErrorMessage("Build not found. The shared build may have been deleted.")
toast.error("Build not found", {
description: "The shared build could not be loaded.",
})
return
}
@@ -147,17 +139,14 @@ export default function BuildNew() {
// Set target and category
if (config.target && TARGETS[config.target]) {
setSelectedTarget(config.target)
const category = TARGETS[config.target].category || 'Other'
const category = TARGETS[config.target].category || "Other"
if (TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
// Set version
if (
config.version &&
(VERSIONS as readonly string[]).includes(config.version)
) {
if (config.version && (VERSIONS as readonly string[]).includes(config.version)) {
setSelectedVersion(config.version as (typeof VERSIONS)[number])
}
@@ -172,8 +161,8 @@ export default function BuildNew() {
// Set plugin config (convert array to object format)
// Only add explicitly selected plugins, not implicit dependencies
if (config.pluginsEnabled && config.pluginsEnabled.length > 0) {
const allPluginSlugs = config.pluginsEnabled.map((pluginId) => {
return pluginId.includes('@') ? pluginId.split('@')[0] : pluginId
const allPluginSlugs = config.pluginsEnabled.map(pluginId => {
return pluginId.includes("@") ? pluginId.split("@")[0] : pluginId
})
// Determine which plugins are required by others (implicit dependencies)
@@ -183,10 +172,7 @@ export default function BuildNew() {
isRequiredByOther(
pluginSlug,
allPluginSlugs,
registryData as Record<
string,
{ dependencies?: Record<string, string> }
>
registryData as Record<string, { dependencies?: Record<string, string> }>
)
) {
requiredByOthers.add(pluginSlug)
@@ -195,7 +181,7 @@ export default function BuildNew() {
// Only add plugins that are NOT required by others (explicitly selected)
const pluginObj: Record<string, boolean> = {}
allPluginSlugs.forEach((slug) => {
allPluginSlugs.forEach(slug => {
if (slug in registryData && !requiredByOthers.has(slug)) {
pluginObj[slug] = true
}
@@ -206,14 +192,11 @@ export default function BuildNew() {
}, [buildHashParam, sharedBuild])
const moduleCount = Object.keys(moduleConfig).length
const pluginCount = Object.keys(pluginConfig).filter(
(id) => pluginConfig[id] === true
).length
const selectedTargetLabel =
(selectedTarget && TARGETS[selectedTarget]?.name) || selectedTarget
const pluginCount = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true).length
const selectedTargetLabel = (selectedTarget && TARGETS[selectedTarget]?.name) || selectedTarget
const handleToggleModule = (id: string, excluded: boolean) => {
setModuleConfig((prev) => {
setModuleConfig(prev => {
const next = { ...prev }
if (excluded) {
next[id] = true
@@ -226,41 +209,33 @@ export default function BuildNew() {
const handleTogglePlugin = (id: string, enabled: boolean) => {
// Get current explicit selections
const explicitPlugins = Object.keys(pluginConfig).filter(
(pluginId) => pluginConfig[pluginId] === true
)
const explicitPlugins = Object.keys(pluginConfig).filter(pluginId => pluginConfig[pluginId] === true)
// Check if this plugin is currently an implicit dependency
const implicitDeps = getImplicitDependencies(
explicitPlugins,
registryData as Record<
string,
{ dependencies?: Record<string, string> }
>
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Check if this plugin is required by another explicitly selected plugin
const isRequired = isRequiredByOther(
id,
explicitPlugins,
registryData as Record<
string,
{ dependencies?: Record<string, string> }
>
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Don't allow toggling implicit dependencies at all
// (they should be disabled in the UI, but add this as a safeguard)
if (implicitDeps.has(id)) {
return // Can't toggle implicit dependencies
}
// Don't allow disabling if it's required by another explicitly selected plugin
if (!enabled && isRequired) {
return // Can't disable required plugins
}
setPluginConfig((prev) => {
setPluginConfig(prev => {
const next = { ...prev }
if (enabled) {
// Enabling: add to explicit selection (even if it was implicit)
@@ -268,29 +243,20 @@ export default function BuildNew() {
} else {
// Disabling: remove from explicit selection
delete next[id]
// Recompute what plugins are still needed after removal
const remainingExplicit = Object.keys(next).filter(
(pluginId) => next[pluginId] === true
)
const remainingExplicit = Object.keys(next).filter(pluginId => next[pluginId] === true)
const allStillNeeded = getDependedPlugins(
remainingExplicit,
registryData as Record<
string,
{ dependencies?: Record<string, string> }
>
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Remove any plugins from config that are no longer needed
// BUT preserve all plugins that are currently explicitly selected (in remainingExplicit)
// This ensures that plugins that were explicitly selected remain explicitly selected
// even if they temporarily became implicit and then un-implicit
for (const pluginId of Object.keys(next)) {
if (
next[pluginId] === true &&
!allStillNeeded.includes(pluginId) &&
!remainingExplicit.includes(pluginId)
) {
if (next[pluginId] === true && !allStillNeeded.includes(pluginId) && !remainingExplicit.includes(pluginId)) {
// This plugin is no longer needed and is not in the remaining explicit list
// Only remove if it's truly not needed and wasn't explicitly selected
// Note: If a plugin is in `next` with value `true`, it should be in `remainingExplicit`
@@ -298,7 +264,7 @@ export default function BuildNew() {
delete next[pluginId]
}
}
// Ensure all remaining explicitly selected plugins stay in config
// (they should already be there, but this ensures they remain even if they're not needed)
for (const pluginId of remainingExplicit) {
@@ -314,27 +280,18 @@ export default function BuildNew() {
setIsFlashing(true)
setErrorMessage(null)
try {
const enabledSlugs = Object.keys(pluginConfig).filter(
(id) => pluginConfig[id] === true
)
const enabledSlugs = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
// Double-check: filter out any implicit dependencies that might have snuck in
// This ensures we only send explicitly selected plugins to the backend
const implicitDeps = getImplicitDependencies(
enabledSlugs,
registryData as Record<
string,
{ dependencies?: Record<string, string> }
>
registryData as Record<string, { dependencies?: Record<string, string> }>
)
const explicitOnlySlugs = enabledSlugs.filter(
(slug) => !implicitDeps.has(slug)
)
const pluginsEnabled = explicitOnlySlugs.map((slug) => {
const plugin = (registryData as Record<string, { version: string }>)[
slug
]
const explicitOnlySlugs = enabledSlugs.filter(slug => !implicitDeps.has(slug))
const pluginsEnabled = explicitOnlySlugs.map(slug => {
const plugin = (registryData as Record<string, { version: string }>)[slug]
return `${slug}@${plugin.version}`
})
const result = await ensureBuildFromConfig({
@@ -346,8 +303,8 @@ export default function BuildNew() {
navigate(`/builds/${result.buildHash}`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setErrorMessage('Failed to start build. Please try again.')
toast.error('Failed to start build', {
setErrorMessage("Failed to start build. Please try again.")
toast.error("Failed to start build", {
description: message,
})
} finally {
@@ -362,9 +319,7 @@ export default function BuildNew() {
<div className="min-h-screen bg-slate-950 text-white p-6 md:p-10 flex items-center justify-center">
<div className="text-center space-y-4">
<Loader2 className="w-8 h-8 animate-spin text-cyan-500 mx-auto" />
<p className="text-slate-400">
Loading shared build configuration...
</p>
<p className="text-slate-400">Loading shared build configuration...</p>
</div>
</div>
)
@@ -375,16 +330,11 @@ export default function BuildNew() {
<div className="max-w-6xl mx-auto space-y-8">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<p className="text-sm uppercase tracking-wider text-slate-500">
Quick build
</p>
<h1 className="text-4xl font-bold mt-1">
Flash a custom firmware version
</h1>
<p className="text-sm uppercase tracking-wider text-slate-500">Quick build</p>
<h1 className="text-4xl font-bold mt-1">Flash a custom firmware version</h1>
<p className="text-slate-400 mt-2 max-w-2xl">
Choose your Meshtastic target, adjust optional modules, and queue
a new build instantly. We'll send you to the build status page as
soon as it starts.
Choose your Meshtastic target, adjust optional modules, and queue a new build instantly. We'll send you to
the build status page as soon as it starts.
</p>
</div>
</div>
@@ -392,7 +342,7 @@ export default function BuildNew() {
<div className="space-y-6 bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{TARGET_CATEGORIES.map((category) => {
{TARGET_CATEGORIES.map(category => {
const isActive = activeCategory === category
return (
<button
@@ -400,9 +350,7 @@ export default function BuildNew() {
type="button"
onClick={() => setActiveCategory(category)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isActive
? 'bg-blue-600 text-white'
: 'bg-slate-800 text-slate-300 hover:bg-slate-700'
isActive ? "bg-blue-600 text-white" : "bg-slate-800 text-slate-300 hover:bg-slate-700"
}`}
>
{category}
@@ -413,43 +361,36 @@ export default function BuildNew() {
<div className="bg-slate-950/60 p-4 rounded-lg border border-slate-800/60">
<div className="flex flex-wrap gap-2">
{(activeCategory ? GROUPED_TARGETS[activeCategory] : [])?.map(
(target) => {
const isSelected = selectedTarget === target.id
return (
<button
key={target.id}
type="button"
onClick={() => handleSelectTarget(target.id)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isSelected
? 'bg-cyan-600 text-white'
: 'bg-slate-800 text-slate-300 hover:bg-slate-700'
}`}
>
{target.name}
</button>
)
}
)}
{(activeCategory ? GROUPED_TARGETS[activeCategory] : [])?.map(target => {
const isSelected = selectedTarget === target.id
return (
<button
key={target.id}
type="button"
onClick={() => handleSelectTarget(target.id)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isSelected ? "bg-cyan-600 text-white" : "bg-slate-800 text-slate-300 hover:bg-slate-700"
}`}
>
{target.name}
</button>
)
})}
</div>
</div>
</div>
<div>
<label
htmlFor="build-version"
className="block text-sm font-medium mb-2"
>
<label htmlFor="build-version" className="block text-sm font-medium mb-2">
Firmware version
</label>
<select
id="build-version"
value={selectedVersion}
onChange={(event) => setSelectedVersion(event.target.value)}
onChange={event => setSelectedVersion(event.target.value)}
className="w-full h-10 px-3 rounded-md border border-slate-800 bg-slate-950 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:ring-offset-slate-950"
>
{VERSIONS.map((version) => (
{VERSIONS.map(version => (
<option key={version} value={version}>
{version}
</option>
@@ -460,15 +401,15 @@ export default function BuildNew() {
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
<button
type="button"
onClick={() => setShowModuleOverrides((prev) => !prev)}
onClick={() => setShowModuleOverrides(prev => !prev)}
className="w-full flex items-center justify-between text-left"
>
<div>
<p className="text-sm font-medium">Core Modules</p>
<p className="text-xs text-slate-400">
{moduleCount === 0
? 'Using default modules for this target.'
: `${moduleCount} module${moduleCount === 1 ? '' : 's'} excluded.`}
? "Using default modules for this target."
: `${moduleCount} module${moduleCount === 1 ? "" : "s"} excluded.`}
</p>
</div>
{showModuleOverrides ? (
@@ -482,10 +423,9 @@ export default function BuildNew() {
<div className="space-y-2 pr-1">
<div className="rounded-lg bg-slate-800/50 border border-slate-700 p-3">
<p className="text-xs text-slate-400 leading-relaxed">
Core Modules are officially maintained modules by
Meshtastic. They are selectively included or excluded by
default depending on the target device. You can explicitly
exclude modules you know you don't want.
Core Modules are officially maintained modules by Meshtastic. They are selectively included or
excluded by default depending on the target device. You can explicitly exclude modules you know you
don't want.
</p>
</div>
<div className="flex justify-end">
@@ -499,16 +439,14 @@ export default function BuildNew() {
</button>
</div>
<div className="grid gap-2 md:grid-cols-2">
{modulesData.modules.map((module) => (
{modulesData.modules.map(module => (
<ModuleToggle
key={module.id}
id={module.id}
name={module.name}
description={module.description}
isExcluded={moduleConfig[module.id] === true}
onToggle={(excluded) =>
handleToggleModule(module.id, excluded)
}
onToggle={excluded => handleToggleModule(module.id, excluded)}
/>
))}
</div>
@@ -519,15 +457,15 @@ export default function BuildNew() {
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
<button
type="button"
onClick={() => setShowPlugins((prev) => !prev)}
onClick={() => setShowPlugins(prev => !prev)}
className="w-full flex items-center justify-between text-left"
>
<div>
<p className="text-sm font-medium">Plugins</p>
<p className="text-xs text-slate-400">
{pluginCount === 0
? 'No plugins enabled.'
: `${pluginCount} plugin${pluginCount === 1 ? '' : 's'} enabled.`}
? "No plugins enabled."
: `${pluginCount} plugin${pluginCount === 1 ? "" : "s"} enabled.`}
</p>
</div>
{showPlugins ? (
@@ -541,8 +479,8 @@ export default function BuildNew() {
<div className="space-y-2 pr-1">
<div className="rounded-lg bg-slate-800/50 border border-slate-700 p-3">
<p className="text-xs text-slate-400 leading-relaxed">
Plugins are 3rd party add-ons. They are not maintained,
endorsed, or supported by Meshtastic. Use at your own risk.
Plugins are 3rd party add-ons. They are not maintained, endorsed, or supported by Meshtastic. Use at
your own risk.
</p>
</div>
<div className="flex justify-end">
@@ -558,26 +496,18 @@ export default function BuildNew() {
<div className="grid gap-2 md:grid-cols-2" key={`plugins-${selectedTarget}`}>
{(() => {
// Get explicitly selected plugins (user-selected)
const explicitPlugins = Object.keys(pluginConfig).filter(
(id) => pluginConfig[id] === true
)
const explicitPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
// Compute implicit dependencies (dependencies that are not explicitly selected)
const implicitDeps = getImplicitDependencies(
explicitPlugins,
registryData as Record<
string,
{ dependencies?: Record<string, string> }
>
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Compute all enabled plugins (explicit + implicit)
const allEnabledPlugins = getDependedPlugins(
explicitPlugins,
registryData as Record<
string,
{ dependencies?: Record<string, string> }
>
registryData as Record<string, { dependencies?: Record<string, string> }>
)
return Object.entries(registryData)
@@ -596,37 +526,33 @@ export default function BuildNew() {
const isRequired = isRequiredByOther(
slug,
explicitPlugins,
registryData as Record<
string,
{ dependencies?: Record<string, string> }
>
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Plugin is implicit if it's either:
// 1. Not explicitly selected but is a dependency, OR
// 2. Explicitly selected but required by another explicitly selected plugin
const isImplicit =
implicitDeps.has(slug) ||
(explicitPlugins.includes(slug) && isRequired)
const isImplicit = implicitDeps.has(slug) || (explicitPlugins.includes(slug) && isRequired)
// Check plugin compatibility with selected target
const pluginIncludes = (plugin as { includes?: string[] }).includes
const pluginExcludes = (plugin as { excludes?: string[] }).excludes
// Legacy support: check for old "architectures" field
const legacyArchitectures = (plugin as { architectures?: string[] }).architectures
const hasCompatibilityConstraints =
(pluginIncludes && pluginIncludes.length > 0) ||
const hasCompatibilityConstraints =
(pluginIncludes && pluginIncludes.length > 0) ||
(pluginExcludes && pluginExcludes.length > 0) ||
(legacyArchitectures && legacyArchitectures.length > 0)
const isCompatible = hasCompatibilityConstraints && selectedTarget
? isPluginCompatibleWithTarget(
pluginIncludes || legacyArchitectures,
pluginExcludes,
selectedTarget
)
: true // If no constraints or no target selected, assume compatible
const isCompatible =
hasCompatibilityConstraints && selectedTarget
? isPluginCompatibleWithTarget(
pluginIncludes || legacyArchitectures,
pluginExcludes,
selectedTarget
)
: true // If no constraints or no target selected, assume compatible
// Mark as incompatible if plugin has compatibility constraints and target is not compatible
const isIncompatible = !isCompatible && hasCompatibilityConstraints && !!selectedTarget
return (
<PluginToggle
key={`${slug}-${selectedTarget}`}
@@ -634,16 +560,10 @@ export default function BuildNew() {
name={plugin.name}
description={plugin.description}
isEnabled={allEnabledPlugins.includes(slug)}
onToggle={(enabled) =>
handleTogglePlugin(slug, enabled)
}
onToggle={enabled => handleTogglePlugin(slug, enabled)}
disabled={isImplicit || isIncompatible}
enabledLabel={isImplicit ? 'Required' : 'Add'}
incompatibleReason={
isIncompatible
? 'Not compatible with this target'
: undefined
}
enabledLabel={isImplicit ? "Required" : "Add"}
incompatibleReason={isIncompatible ? "Not compatible with this target" : undefined}
featured={plugin.featured ?? false}
flashCount={pluginFlashCounts[slug] ?? 0}
homepage={plugin.homepage}
@@ -658,23 +578,17 @@ export default function BuildNew() {
</div>
<div className="space-y-2">
<Button
onClick={handleFlash}
disabled={isFlashDisabled}
className="w-full bg-cyan-600 hover:bg-cyan-700"
>
<Button onClick={handleFlash} disabled={isFlashDisabled} className="w-full bg-cyan-600 hover:bg-cyan-700">
{isFlashing ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Queuing build...
</span>
) : (
`Flash ${selectedTargetLabel || ''}`.trim() || 'Flash'
`Flash ${selectedTargetLabel || ""}`.trim() || "Flash"
)}
</Button>
{errorMessage && (
<p className="text-sm text-red-400">{errorMessage}</p>
)}
{errorMessage && <p className="text-sm text-red-400">{errorMessage}</p>}
</div>
</div>
</div>

74
pages/docs/+Layout.tsx Normal file
View File

@@ -0,0 +1,74 @@
import { Link } from "../../components/Link";
import { usePageContext } from "vike-react/usePageContext";
const navSections = [
{
items: [{ href: "/docs", label: "Overview" }],
},
{
heading: "Plugins",
items: [
{ href: "/docs/registry", label: "Overview" },
{ href: "/docs/plugin-authoring", label: "Authoring Guide" },
],
},
{
heading: "Flashing",
items: [
{ href: "/docs/esp32", label: "ESP32" },
{ href: "/docs/nRF52", label: "nRF52" },
],
},
];
function NavLink({ href, label }: { href: string; label: string }) {
const pageContext = usePageContext();
const { urlPathname } = pageContext;
const isActive = href === "/docs" ? urlPathname === href : urlPathname.startsWith(href);
return (
<Link href={href}>
<span
className={`block px-3 py-2 rounded-md text-sm transition-colors ${
isActive
? "bg-secondary text-secondary-foreground font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-secondary/50"
}`}
>
{label}
</span>
</Link>
);
}
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex gap-8 max-w-7xl mx-auto px-6 py-8">
<aside className="w-64 shrink-0">
<nav className="sticky top-8">
<ul className="space-y-4">
{navSections.map((section, sectionIndex) => (
<li key={sectionIndex}>
{section.heading && (
<h3 className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{section.heading}
</h3>
)}
<ul className="space-y-1 mt-1">
{section.items.map((item) => (
<li key={item.href}>
<NavLink href={item.href} label={item.label} />
</li>
))}
</ul>
</li>
))}
</ul>
</nav>
</aside>
<main className="flex-1 min-w-0">
<article className="prose prose-invert lg:prose-xl max-w-none">{children}</article>
</main>
</div>
);
}

31
pages/docs/+Page.mdx Normal file
View File

@@ -0,0 +1,31 @@
# Mesh Forge
Mesh Forge is a cloud-based firmware builder that gives you complete control over your Meshtastic node. Choose exactly which modules you want, include third-party plugins, and build your custom firmware in the cloud—no local environment setup required.
## What is Mesh Forge?
Mesh Forge removes the hours of painful setup (installing Python, PlatformIO, toolchains, and debugging paths) and guarantees a successful, reproducible build instantly. Simply configure your firmware, click build, and download a ready-to-flash binary—along with the complete source code used to create it.
**Key Features:**
- ✅ **No Installation Required** - Build firmware directly in your browser
- ✅ **Complete Control** - Choose exactly which modules and plugins to include
- ✅ **Shareable Builds** - Share custom configurations with a simple link
- ✅ **Source Code Included** - Every build includes the complete source code for audit and modification
- ✅ **Plugin Ecosystem** - Extend firmware functionality without modifying core code
## The Plugin Ecosystem
Along with the builder, Mesh Forge includes a plugin ecosystem that allows developers to create extensions to Meshtastic's core firmware without sending Pull Requests to the core repository or forcing users to compile firmware manually.
The vision is that there may someday be hundreds of plugins covering functionality either too specific or too high level to be considered core. This enables the community to extend Meshtastic in ways that wouldn't be practical to include in the main firmware.
## Sovereignty
Every firmware build from Mesh Forge includes not just the binary, but a complete source code zip file containing the configuration files and build recipe (`platformio.ini`) used to create your binary. You are free to audit, modify, and build that code on any system you choose.
While the build tools themselves (PlatformIO, pip, etc.) still require internet connectivity, Mesh Forge provides full transparency and control over the firmware source code itself.
## Getting Started
Ready to build your custom firmware? Visit the [builder](https://meshforge.org/builds/new) to get started, or explore the [Plugin Registry](/docs/registry) to see what's available.

View File

@@ -0,0 +1,66 @@
# ESP Device Flashing Guide
## Overview
This guide will walk you through flashing custom firmware to your ESP device using the firmware files downloaded from MeshForge.
## Prerequisites
- Custom firmware built and downloaded from MeshForge
- ESP device (ESP32, ESP8266, etc.)
- USB cable to connect your device to your computer
## Step-by-Step Instructions
### 1. Build and Download Firmware
1. Build your custom firmware for your device on [meshforge.org](https://meshforge.org/)
2. When the build completes, download the compressed firmware archive
### 2. Extract Firmware Files
Extract the downloaded archive to a folder. You should see the following files:
- `firmware.bin` - Main application binary
- `firmware.factory.bin` - Factory application binary
- `firmware.elf` - ELF debug file (not needed for flashing)
- `partitions.bin` - Partition table binary
- `bootloader.bin` - Bootloader binary
### 3. Flash Using ESPTool
1. Open [ESP Tool Web Flasher](https://esptool.spacehuhn.com/) in your browser
2. Connect your ESP device to your computer via USB
3. Select the appropriate COM port (or device) when prompted
4. Configure the flash settings using the following memory offsets:
#### Memory Offsets
| Offset | File | Description |
| --------- | ---------------------- | --------------------------------------------------------------------- |
| `0x1000` | `bootloader.bin` | Second-stage bootloader binary |
| `0x8000` | `partitions.bin` | Partition table that defines data and application partition locations |
| `0x10000` | `firmware.factory.bin` | Main application (factory) binary |
| `0xe000` | `boot_app0.bin` | Boot application file (if included in your firmware package) |
**Note:** Some configurations may include additional data partitions like NVS (Non-Volatile Storage) or Wi-Fi calibration data at their defined offsets. Only flash files that are present in your extracted firmware folder.
### 4. Complete the Flash Process
1. Click the flash/upload button in ESP Tool
2. Wait for the flashing process to complete
3. You should see a success message when finished
### 5. Reboot Your Device
1. Disconnect and reconnect your ESP device, or
2. Press the reset button on your device, or
3. Use the reset command in ESP Tool if available
Your device should now be running the custom firmware!
## Troubleshooting
- **Device not detected:** Make sure you have the correct USB drivers installed for your ESP device
- **Flash failed:** Verify that you're using the correct memory offsets for your device model
- **Device won't boot:** Ensure all required files (bootloader, partitions, and firmware) were flashed successfully

106
pages/docs/nRF52/+Page.mdx Normal file
View File

@@ -0,0 +1,106 @@
# nRF52 Device Flashing Guide
## Overview
This guide will walk you through flashing custom firmware to your nRF device (nRF52840, nRF52832, etc.) using the UF2 firmware files downloaded from MeshForge.
## Prerequisites
- Custom firmware built and downloaded from MeshForge
- nRF device (nRF52840, nRF52832, or compatible)
- USB cable to connect your device to your computer
## Step-by-Step Instructions
### 1. Build and Download Firmware
1. Build your custom firmware for your nRF device on [meshforge.org](https://meshforge.org/)
2. When the build completes, download the compressed firmware archive (`.tgz` file)
### 2. Extract Firmware Files
Extract the downloaded `.tgz` archive to a folder. You should see the following files:
- `firmware.uf2` - UF2 firmware file (this is what you'll flash)
- `firmware.elf` - ELF debug file (not needed for flashing)
**Note:** The `firmware.uf2` file is the main file you need for flashing.
### 3. Enter DFU Mode
Your nRF device needs to be in DFU (Device Firmware Update) mode before flashing. The method varies by device:
#### Common Methods:
- **Double-tap reset button**: Quickly press the reset button twice in succession
- **Button combination**: Hold the BOOT/DFU button while pressing and releasing RESET
- **Specific device instructions**: Some devices have unique methods (check your device documentation)
#### Verify DFU Mode:
When successfully in DFU mode, you should see:
- A new USB drive appear on your computer (typically named "DFU" or similar)
- The device LED may change behavior (blinking pattern or solid color)
- The device will not appear as a serial port
### 4. Flash the Firmware
1. **Locate the USB drive**: Open your file manager and find the DFU drive that appeared when you entered DFU mode
2. **Copy the UF2 file**: Drag and drop (or copy) the `firmware.uf2` file onto the DFU drive
3. **Wait for completion**: The device will automatically begin flashing. You may see the drive activity indicator or LED changes
4. **Automatic reboot**: Once flashing completes, the device will automatically reboot and exit DFU mode
### 5. Verify the Flash
After the device reboots:
- The DFU drive should disappear from your computer
- The device should appear as a serial port (if applicable)
- Your device should now be running the custom firmware
## Troubleshooting
### Device Not Entering DFU Mode
- **Try different button combinations**: Some devices require specific timing or button sequences
- **Check device documentation**: Your specific device may have unique DFU entry requirements
- **Ensure USB connection**: Make sure the USB cable supports data transfer (not just charging)
### DFU Drive Not Appearing
- **Check device manager**: On Windows, verify the device is recognized
- **Try different USB port**: Some USB ports may not work properly
- **Install drivers**: Some devices require specific USB drivers (check manufacturer documentation)
- **Try different cable**: Faulty cables can prevent proper communication
### Flash Fails or Device Won't Boot
- **Verify firmware compatibility**: Ensure the firmware matches your exact device model
- **Try re-entering DFU mode**: Sometimes the device needs to be reset and DFU mode re-entered
- **Check file integrity**: Re-download the firmware file to ensure it wasn't corrupted
- **Use erase option**: Some devices may need to be erased before flashing (check device-specific instructions)
### Device Stuck in DFU Mode
- **Disconnect and reconnect**: Unplug the USB cable and plug it back in
- **Hard reset**: Some devices have a hard reset method (check device documentation)
- **Re-flash bootloader**: In extreme cases, the bootloader may need to be reflashed using a debugger
## Additional Notes
- **Firmware versions**: Always ensure you're flashing firmware compatible with your device model
- **Backup**: If possible, keep a backup of your working firmware before flashing new versions
- **Multiple devices**: If flashing multiple devices, ensure each is in DFU mode before copying the UF2 file
## Device-Specific Information
Different nRF devices may have slight variations in the flashing process. Common nRF devices include:
- **nRF52840**: Most common, supports UF2 bootloader
- **nRF52832**: Older generation, may require different methods
- **Adafruit nRF52840 Feather**: Uses double-tap reset for DFU mode
- **Seeed XIAO nRF52840**: Typically uses double-tap reset
- **RAK4631**: May use different button combinations
Check your specific device documentation for exact DFU entry procedures.

View File

@@ -0,0 +1,110 @@
# Meshtastic Plugin Authoring Guide
## Installation & Setup
> **Note**: Until the plugin system is officially accepted, you must use `pip install` followed by `mpm init` in the firmware folder to apply the plugin patches to the firmware. You'll need to do this to older versions of the firmware even if this is eventually accepted into core.
```bash
# Install MPM
pip install mesh-plugin-manager
# From the firmware folder (directory containing platformio.ini)
mpm init
```
The build system automatically uses MPM during PlatformIO builds to include all plugins and generate protobuf bindings.
## Plugin Structure
The only requirement for a plugin is that it must have a `./src` directory:
```
plugins/
└── myplugin/
└── src/
├── MyModule.h
├── MyModule.cpp
└── mymodule.proto
```
- Plugin directory name can be anything
- All source files must be placed in `./src`
- Only files in `./src` are compiled (the root plugin directory and all other subdirectories are excluded from the build)
## Automatic Protobuf Generation
MPM automatically scans for and generates protobuf files:
- **Discovery**: Recursively scans plugin directories for `.proto` files
- **Options file**: Auto-detects matching `.options` files (e.g., `mymodule.proto` → `mymodule.options`)
- **Generation**: Uses `nanopb` tooling to generate C++ files
- **Output**: Generated files are placed in the same directory as the `.proto` file
- **Timing**: Runs during PlatformIO pre-build phase (configured in `platformio.ini`)
Example protobuf structure:
```
src/plugins/myplugin/src/
├── mymodule.proto # Protobuf definition
├── mymodule.options # Nanopb options (optional)
├── mymodule.pb.h # Generated header
└── mymodule.pb.c # Generated implementation
```
## Include Path Setup
The plugin's `src/` directory is automatically added to the compiler's include path (`CPPPATH`) during build:
- Headers in `src/` can be included directly: `#include "MyModule.h"`
- No need to specify relative paths from other plugin files
- The build system handles this automatically
## Module Registration
If your plugin implements a Meshtastic module, use the `#pragma MPM_MODULE` directive in your header file:
1. Add `#pragma MPM_MODULE(ClassName)` to your module's header file (`.h`)
2. Optionally specify a variable name: `#pragma MPM_MODULE(ClassName, variableName)`
3. If you specify a variable name, declare it as `extern` in your header file
4. Your module will be automatically initialized when the firmware starts
Example (without variable):
```cpp
// MyModule.h
#pragma once
#pragma MPM_MODULE(MyModule)
class MyModule : public SinglePortModule {
// ... module definition ...
};
```
Example (with variable - for modules that need to be referenced elsewhere):
```cpp
// MyModule.h
#pragma once
#pragma MPM_MODULE(MyModule, myModule)
#include "SinglePortModule.h"
class MyModule : public SinglePortModule {
// ... module definition ...
};
// Declare the variable as extern so other files can reference it
extern MyModule *myModule;
```
The variable will be assigned in the generated `init_dynamic_modules()` function. If you don't need to reference your module from other files, you can omit the variable name and extern declaration.
> **Note**: Module registration is optional. Plugins that don't implement Meshtastic modules (e.g., utility libraries) don't need this.
For details on writing Meshtastic modules, see the [Module API documentation](https://meshtastic.org/docs/development/device/module-api/).
## Example Plugins
- [LoBBS](https://github.com/MeshEnvy/lobbs) - an on-firmware BBS
- [LoDB](https://github.com/MeshEnvy/lodb) - a microncontroller-friendly relational database for persisting settings, data, and more
- See https://meshforge.org for more

View File

@@ -0,0 +1,57 @@
# Mesh Plugin Registry
The Mesh Plugin Registry is a collection of community-developed plugins that extend Meshtastic firmware functionality. This guide explains how to discover, use, and build firmware with plugins from the registry.
## What is the Registry?
The registry contains plugins that add new features and capabilities to Meshtastic devices. Each plugin is maintained by the community and can be easily integrated into custom firmware builds.
## How to Use Plugins
### Using Mesh Forge (Recommended)
The easiest way to use plugins is through [Mesh Forge](https://meshforge.org/builds/new), which lets you build custom firmware with plugins directly in your browser:
1. Visit [meshforge.org/builds/new](https://meshforge.org/builds/new)
2. Browse available plugins from the registry
3. Select the plugins you want to include
4. Build your custom firmware
5. Download and flash the firmware to your device
**Benefits:**
- Zero installation required
- Simple web interface
- Automatic dependency resolution
- Ready-to-flash firmware files
### Using Mesh Plugin Manager (Advanced)
For local development and more control, you can use the [Mesh Plugin Manager](https://pypi.org/project/mesh-plugin-manager/) command-line tool:
1. Install Mesh Plugin Manager: `pip install mesh-plugin-manager`
2. Set up PlatformIO and Poetry (required dependencies)
3. Use the CLI to build firmware with selected plugins
4. Flash the firmware to your device
**Benefits:**
- Full control over the build process
- Local development workflow
- CLI interface for automation
- Developer-friendly
## Creating Your Own Plugin
If you want to create and contribute plugins to the registry, check out the [Plugin Development Guide](https://github.com/MeshEnvy/firmware/blob/meshenvy/module-registry/plugins/README.md) for documentation on:
- Plugin structure and architecture
- Protobuf message generation
- Module registration
- Testing and submission guidelines
## Additional Resources
- [MeshEnvy](https://meshenvy.org) - Built by MeshEnvy (not affiliated with Meshtastic)
- [Meshtastic](https://meshtastic.org) - Learn more about Meshtastic

120
pages/index/+Page.tsx Normal file
View File

@@ -0,0 +1,120 @@
import { DiscordButton } from "@/components/DiscordButton"
import { RedditButton } from "@/components/RedditButton"
import { Button } from "@/components/ui/button"
import { navigate } from "vike/client/router"
function QuickBuildIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
role="img"
aria-label="Quick build"
{...props}
>
<title>Quick build</title>
<path fill="currentColor" d="M11 15H6l7-14v8h5l-7 14z" />
</svg>
)
}
function DocsIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
role="img"
aria-label="Docs"
{...props}
>
<title>Docs</title>
<path
fill="currentColor"
d="M6.75 22q-1.125 0-1.937-.763T4 19.35V5.4q0-.95.588-1.7t1.537-.95L16 .8v16l-9.475 1.9q-.225.05-.375.238T6 19.35q0 .275.225.463T6.75 20H18V4h2v18zM7 16.575l2-.4V4.225l-2 .4z"
/>
</svg>
)
}
export default function LandingPage() {
return (
<div className="min-h-screen bg-slate-950 text-white">
<div className="max-w-7xl mx-auto">
<div className="text-center py-20 px-8">
<h1 className="text-6xl md:text-7xl font-bold mb-6 leading-[1.1]">
<span className="bg-gradient-to-r from-cyan-400 to-blue-600 bg-clip-text text-transparent inline-block pb-2">
Mesh beyond the basics
</span>
</h1>
<p className="text-xl md:text-2xl text-slate-400 max-w-3xl mx-auto mb-10">
Build custom firmware with third-party plugins: BBS's, custom hardware, games, and more. An open ecosystem
growing to hundreds of plugins.
</p>
<div className="flex flex-col items-center gap-4 mb-10">
<Button
onClick={() => navigate("/builds/new")}
size="lg"
variant="outline"
className="border-cyan-500/50 text-white hover:bg-slate-900/60 text-lg px-8 py-6"
>
<QuickBuildIcon className="mr-2 h-6 w-6" />
Quick Build
</Button>
<div className="flex flex-wrap items-center justify-center gap-3">
<Button
onClick={() => navigate("/docs")}
size="default"
variant="default"
className="bg-gradient-to-r from-blue-500 to-cyan-600 hover:from-blue-600 hover:to-cyan-700 text-white border-0 shadow-lg shadow-cyan-500/50"
>
<DocsIcon className="mr-2 h-4 w-4" />
Docs
</Button>
<DiscordButton
size="default"
variant="default"
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white border-0 shadow-lg shadow-purple-500/50"
/>
<RedditButton
size="default"
variant="default"
className="bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white border-0 shadow-lg shadow-orange-500/50"
/>
</div>
</div>
{/* Benefits Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-w-5xl mx-auto mb-10">
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-6 text-left">
<h3 className="text-lg font-semibold text-cyan-400 mb-2">Zero Install</h3>
<p className="text-slate-300 text-sm">No downloads, no toolchains. Everything runs in your browser.</p>
</div>
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-6 text-left">
<h3 className="text-lg font-semibold text-cyan-400 mb-2">Custom Firmware</h3>
<p className="text-slate-300 text-sm">Build bespoke Meshtastic firmware tailored to your exact needs.</p>
</div>
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-6 text-left">
<h3 className="text-lg font-semibold text-cyan-400 mb-2">Community Extensions</h3>
<p className="text-slate-300 text-sm">Include community modules and extensions beyond core Meshtastic.</p>
</div>
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-6 text-left md:col-span-2 lg:col-span-1">
<h3 className="text-lg font-semibold text-cyan-400 mb-2">Share & Remix</h3>
<p className="text-slate-300 text-sm">Publish your build profiles and let others remix your configs.</p>
</div>
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-6 text-left md:col-span-2 lg:col-span-2">
<h3 className="text-lg font-semibold text-cyan-400 mb-2">Cloud Builds</h3>
<p className="text-slate-300 text-sm">
Compile in the cloud, flash directly to your deviceno local setup required.
</p>
</div>
</div>
</div>
</div>
</div>
)
}

153
pages/tailwind.css Normal file
View File

@@ -0,0 +1,153 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme {
--font-sans: system-ui, -apple-system, sans-serif;
--color-background: oklch(0.145 0 0);
--color-foreground: oklch(0.985 0 0);
--color-primary: oklch(0.488 0.243 264.376);
--color-primary-foreground: oklch(0.985 0 0);
--color-secondary: oklch(0.269 0 0);
--color-secondary-foreground: oklch(0.985 0 0);
--color-muted: oklch(0.269 0 0);
--color-muted-foreground: oklch(0.708 0 0);
--color-accent: oklch(0.488 0.243 264.376);
--color-accent-foreground: oklch(0.985 0 0);
--color-destructive: oklch(0.704 0.191 22.216);
--color-border: oklch(1 0 0 / 10%);
--color-input: oklch(1 0 0 / 15%);
--color-ring: oklch(0.488 0.243 264.376);
--radius: 0.5rem;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-sans;
}
}
.prose.prose-invert h1 {
font-size: 2.25rem;
font-weight: 700;
margin-bottom: 1.5rem;
margin-top: 2rem;
line-height: 1.3;
padding-bottom: 0.15em;
background: linear-gradient(to right, rgb(34, 211, 238), rgb(59, 130, 246), rgb(147, 51, 234));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.prose.prose-invert h2 {
font-size: 1.875rem;
font-weight: 600;
margin-bottom: 1rem;
margin-top: 2rem;
color: oklch(0.985 0 0);
border-bottom: 1px solid oklch(1 0 0 / 10%);
padding-bottom: 0.5rem;
}
.prose.prose-invert h3 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.75rem;
margin-top: 1.5rem;
color: oklch(0.985 0 0);
}
.prose.prose-invert h4 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
margin-top: 1rem;
color: oklch(0.985 0 0);
}
.prose.prose-invert p {
margin-bottom: 1rem;
line-height: 1.75;
color: oklch(0.708 0 0);
}
.prose.prose-invert a {
color: oklch(0.488 0.243 264.376);
font-weight: 500;
text-decoration: underline;
text-decoration-color: oklch(0.488 0.243 264.376 / 0.3);
text-underline-offset: 2px;
transition:
color 0.2s,
text-decoration-color 0.2s;
}
.prose.prose-invert a:hover {
color: oklch(0.488 0.243 264.376 / 0.8);
text-decoration-color: oklch(0.488 0.243 264.376 / 0.6);
}
.prose.prose-invert strong {
font-weight: 600;
color: oklch(0.985 0 0);
}
.prose.prose-invert code {
background-color: oklch(0.269 0 0);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
color: oklch(0.488 0.243 264.376);
border: 1px solid oklch(1 0 0 / 10%);
}
.prose.prose-invert pre {
background-color: oklch(0.269 0 0);
border: 1px solid oklch(1 0 0 / 10%);
border-radius: 0.5rem;
padding: 1rem;
overflow-x: auto;
}
.prose.prose-invert pre code {
background-color: transparent;
padding: 0;
border: 0;
color: oklch(0.985 0 0);
}
.prose.prose-invert ul,
.prose.prose-invert ol {
margin-bottom: 1rem;
}
.prose.prose-invert ul > li,
.prose.prose-invert ol > li {
margin-top: 0.5rem;
color: oklch(0.708 0 0);
}
.prose.prose-invert ul > li::marker,
.prose.prose-invert ol > li::marker {
color: oklch(0.488 0.243 264.376);
}
.prose.prose-invert blockquote {
border-left: 4px solid oklch(0.488 0.243 264.376);
padding-left: 1rem;
font-style: italic;
color: oklch(0.708 0 0);
margin: 1rem 0;
}
.prose.prose-invert hr {
border-color: oklch(1 0 0 / 10%);
margin: 2rem 0;
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
},
};

15
prettier.config.js Normal file
View File

@@ -0,0 +1,15 @@
/**
* @see https://prettier.io/docs/configuration
* @type {import("prettier").Config}
*/
const config = {
plugins: ["prettier-plugin-organize-imports"],
printWidth: 120,
trailingComma: "es5",
arrowParens: "avoid",
tabWidth: 2,
useTabs: false,
semi: false,
};
export default config;

View File

@@ -19,9 +19,7 @@
"homepage": "https://github.com/MeshEnvy/lobbs",
"version": "1.1.1",
"featured": true,
"includes": [
"esp32"
],
"includes": ["esp32"],
"dependencies": {
"lodb": ">=1.1.0",
"meshtastic": ">=2.7.0"

View File

@@ -1,354 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mesh Plugin Registry</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
color: #1a1a1a;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 2rem;
}
.container {
max-width: 1000px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.hero {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 4rem 2rem;
text-align: center;
}
.hero h1 {
font-size: 3rem;
margin-bottom: 1rem;
font-weight: 700;
}
.hero p {
font-size: 1.3rem;
opacity: 0.95;
margin-bottom: 2rem;
}
.hero-meta {
font-size: 0.9rem;
opacity: 0.85;
margin-top: 1rem;
}
.hero-meta a {
color: white;
text-decoration: underline;
}
.main-cta {
text-align: center;
padding: 3rem 2rem;
background: #f8f9ff;
}
.cta-button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1.5rem 3rem;
border-radius: 12px;
text-decoration: none;
font-weight: 600;
font-size: 1.4rem;
transition:
transform 0.2s,
box-shadow 0.2s;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
.cta-button:hover {
transform: translateY(-3px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.5);
}
.content {
padding: 4rem 2rem;
}
.methods {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 3rem;
}
.method-card {
background: #f8f9ff;
border-radius: 12px;
padding: 2rem;
border: 2px solid #e8eaff;
}
.method-card.easy {
border-color: #667eea;
}
.method-card h3 {
font-size: 1.5rem;
color: #667eea;
margin-bottom: 0.5rem;
}
.method-card .badge {
display: inline-block;
background: #667eea;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 1rem;
}
.method-card .badge.hard {
background: #6c757d;
}
.method-card p {
color: #4a4a4a;
margin-bottom: 1.5rem;
font-size: 1.05rem;
}
.method-card ul {
list-style: none;
padding: 0;
margin-bottom: 1.5rem;
}
.method-card ul li {
padding: 0.5rem 0;
padding-left: 1.5rem;
position: relative;
color: #4a4a4a;
}
.method-card ul li::before {
content: "✓";
position: absolute;
left: 0;
color: #667eea;
font-weight: bold;
}
.link-button {
display: inline-block;
color: #667eea;
padding: 0.75rem 1.5rem;
border: 2px solid #667eea;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
transition:
background 0.2s,
color 0.2s;
}
.link-button:hover {
background: #667eea;
color: white;
}
.authors {
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
border-radius: 12px;
padding: 2rem;
text-align: center;
border: 2px solid #e8eaff;
}
.authors h3 {
font-size: 1.5rem;
color: #667eea;
margin-bottom: 1rem;
}
.authors p {
color: #4a4a4a;
margin-bottom: 1.5rem;
font-size: 1.05rem;
}
.footer {
background: #f8f9fa;
padding: 2rem;
text-align: center;
color: #6c757d;
border-top: 1px solid #e0e0e0;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
body {
padding: 1rem;
}
.hero h1 {
font-size: 2rem;
}
.hero p {
font-size: 1.1rem;
}
.methods {
grid-template-columns: 1fr;
}
.content {
padding: 2rem 1.5rem;
}
.cta-button {
font-size: 1.2rem;
padding: 1.25rem 2rem;
}
}
</style>
</head>
<body>
<div class="container">
<div class="hero">
<h1>🔌 Mesh Plugin Registry</h1>
<p>Extend your Meshtastic firmware with community plugins</p>
<div class="hero-meta">
Built by
<a
href="https://meshenvy.org"
target="_blank"
rel="noopener noreferrer"
>MeshEnvy</a
>
• Not affiliated with Meshtastic
</div>
</div>
<div class="main-cta">
<a
href="https://meshforge.org/builds/new"
target="_blank"
rel="noopener noreferrer"
class="cta-button"
>Browse Meshtastic Plugins →</a
>
</div>
<div class="content">
<div class="methods">
<div class="method-card easy">
<span class="badge">Recommended</span>
<h3>Mesh Forge</h3>
<p>
Build custom firmware with plugins directly in your browser. No
installation or setup required.
</p>
<ul>
<li>Zero installation</li>
<li>Simple interface</li>
<li>Automatic dependencies</li>
<li>Ready-to-flash files</li>
</ul>
<a
href="https://meshforge.org/builds/new"
target="_blank"
rel="noopener noreferrer"
class="link-button"
>Get Started →</a
>
</div>
<div class="method-card">
<span class="badge hard">Advanced</span>
<h3>Mesh Plugin Manager</h3>
<p>
Command-line tool for building firmware locally. Requires
PlatformIO and Poetry setup.
</p>
<ul>
<li>Local development</li>
<li>Full control</li>
<li>CLI interface</li>
<li>Developer-focused</li>
</ul>
<a
href="https://pypi.org/project/mesh-plugin-manager/"
target="_blank"
rel="noopener noreferrer"
class="link-button"
>View on PyPI →</a
>
</div>
</div>
<div class="authors">
<h3>Creating Your Own Plugin?</h3>
<p>
Check out the plugin development guide for documentation on plugin
structure, protobuf generation, and module registration.
</p>
<a
href="https://github.com/MeshEnvy/firmware/blob/meshenvy/module-registry/plugins/README.md"
target="_blank"
rel="noopener noreferrer"
class="link-button"
>Plugin Development Guide →</a
>
</div>
</div>
<div class="footer">
<p>
Built by
<a
href="https://meshenvy.org"
target="_blank"
rel="noopener noreferrer"
><strong>MeshEnvy</strong></a
>
</p>
<p style="margin-top: 0.5rem; font-size: 0.9rem">
<a
href="https://meshtastic.org"
target="_blank"
rel="noopener noreferrer"
>Learn more about Meshtastic</a
>
</p>
</div>
</div>
</body>
</html>

View File

@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -1,94 +0,0 @@
import { Authenticated, AuthLoading, Unauthenticated } from 'convex/react'
import { Loader2 } from 'lucide-react'
import { lazy, Suspense } from 'react'
import {
BrowserRouter,
Navigate,
Route,
Routes,
useLocation,
} from 'react-router-dom'
import { Toaster } from '@/components/ui/sonner'
import Navbar from './components/Navbar'
const Admin = lazy(() => import('./pages/Admin'))
const BuildNew = lazy(() => import('./pages/BuildNew'))
const BuildProgress = lazy(() => import('./pages/BuildProgress'))
const Dashboard = lazy(() => import('./pages/Dashboard'))
const LandingPage = lazy(() => import('./pages/LandingPage'))
const ProfileDetail = lazy(() => import('./pages/ProfileDetail'))
const ProfileEditorPage = lazy(() => import('./pages/ProfileEditorPage'))
const ProfileFlash = lazy(() => import('./pages/ProfileFlash'))
function ConditionalNavbar() {
const location = useLocation()
if (location.pathname === '/') {
return null
}
return <Navbar />
}
function App() {
return (
<BrowserRouter>
<AuthLoading>
<div className="flex items-center justify-center min-h-screen bg-slate-950">
<Loader2 className="w-10 h-10 text-cyan-500 animate-spin" />
</div>
</AuthLoading>
<Unauthenticated>
<ConditionalNavbar />
<Suspense
fallback={
<div className="flex items-center justify-center min-h-screen bg-slate-950">
<Loader2 className="w-10 h-10 text-cyan-500 animate-spin" />
</div>
}
>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/builds/new/:buildHash" element={<BuildNew />} />
<Route path="/builds/new" element={<BuildNew />} />
<Route path="/builds/:buildHash" element={<BuildProgress />} />
<Route path="/profiles/:id" element={<ProfileDetail />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</Unauthenticated>
<Authenticated>
<ConditionalNavbar />
<Suspense
fallback={
<div className="flex items-center justify-center min-h-screen bg-slate-950">
<Loader2 className="w-10 h-10 text-cyan-500 animate-spin" />
</div>
}
>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/admin" element={<Admin />} />
<Route path="/builds/new/:buildHash" element={<BuildNew />} />
<Route path="/builds/new" element={<BuildNew />} />
<Route path="/builds/:buildHash" element={<BuildProgress />} />
<Route
path="/dashboard/profiles/:id"
element={<ProfileEditorPage />}
/>
<Route path="/profiles/:id" element={<ProfileDetail />} />
<Route
path="/profiles/:id/flash/:target"
element={<ProfileFlash />}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</Authenticated>
<Toaster />
</BrowserRouter>
)
}
export default App

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,106 +0,0 @@
import { useMutation } from 'convex/react'
import { useState } from 'react'
import { toast } from 'sonner'
import { SourceAvailable } from '@/components/SourceAvailable'
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
import type { Doc } from '../../convex/_generated/dataModel'
import { ArtifactType } from '../../convex/builds'
interface BuildDownloadButtonProps {
build: Doc<'builds'>
type: ArtifactType
variant?: 'default' | 'outline'
className?: string
}
export function BuildDownloadButton({
build,
type,
variant,
className,
}: BuildDownloadButtonProps) {
const generateDownloadUrl = useMutation(api.builds.generateDownloadUrl)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Default styling based on type
const defaultVariant =
variant ?? (type === ArtifactType.Firmware ? 'default' : 'outline')
const defaultClassName =
className ??
(type === ArtifactType.Firmware
? 'bg-cyan-600 hover:bg-cyan-700'
: 'bg-slate-700 hover:bg-slate-600')
const handleDownload = async () => {
setError(null)
setIsLoading(true)
try {
const url = await generateDownloadUrl({
buildId: build._id,
artifactType: type,
})
window.location.href = url
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const errorMsg =
type === ArtifactType.Firmware
? 'Failed to generate download link.'
: 'Failed to generate source download link.'
setError(errorMsg)
toast.error(errorMsg, {
description: message,
})
} finally {
setIsLoading(false)
}
}
if (type === ArtifactType.Firmware && !build.buildHash) return null
const button = (
<div className="space-y-2">
<Button
onClick={handleDownload}
disabled={isLoading}
variant={defaultVariant}
className={defaultClassName}
>
Download {type === ArtifactType.Firmware ? 'firmware' : 'source'}
</Button>
{type === ArtifactType.Firmware && (
<p className="text-xs text-slate-400 text-center">
Need help flashing?{' '}
<a
href="https://github.com/MeshEnvy/mesh-forge/discussions/5"
target="_blank"
rel="noopener noreferrer"
className="text-cyan-400 hover:text-cyan-300 underline"
>
ESP32
</a>
{' '}and{' '}
<a
href="https://github.com/MeshEnvy/mesh-forge/discussions/6"
target="_blank"
rel="noopener noreferrer"
className="text-cyan-400 hover:text-cyan-300 underline"
>
nRF52
</a>
</p>
)}
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
)
// For source downloads, only show when sourcePath is available
if (type === ArtifactType.Source) {
return (
<SourceAvailable sourcePath={build.sourcePath}>{button}</SourceAvailable>
)
}
return button
}

View File

@@ -1,32 +0,0 @@
@import "tailwindcss";
@theme {
--font-sans: system-ui, -apple-system, sans-serif;
--color-background: oklch(0.145 0 0);
--color-foreground: oklch(0.985 0 0);
--color-primary: oklch(0.488 0.243 264.376);
--color-primary-foreground: oklch(0.985 0 0);
--color-secondary: oklch(0.269 0 0);
--color-secondary-foreground: oklch(0.985 0 0);
--color-muted: oklch(0.269 0 0);
--color-muted-foreground: oklch(0.708 0 0);
--color-accent: oklch(0.488 0.243 264.376);
--color-accent-foreground: oklch(0.985 0 0);
--color-destructive: oklch(0.704 0.191 22.216);
--color-border: oklch(1 0 0 / 10%);
--color-input: oklch(1 0 0 / 15%);
--color-ring: oklch(0.488 0.243 264.376);
--radius: 0.5rem;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-sans;
}
}

View File

@@ -1,21 +0,0 @@
import { ConvexAuthProvider } from '@convex-dev/auth/react'
import { ConvexReactClient } from 'convex/react'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string)
const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error('Root element not found')
}
createRoot(rootElement).render(
<StrictMode>
<ConvexAuthProvider client={convex}>
<App />
</ConvexAuthProvider>
</StrictMode>
)

View File

@@ -1,56 +0,0 @@
import { useQuery } from 'convex/react'
import { useNavigate } from 'react-router-dom'
import {
ProfileCardContent,
profileCardClasses,
} from '@/components/ProfileCard'
import { api } from '../../convex/_generated/api'
export default function Browse() {
const navigate = useNavigate()
const profiles = useQuery(api.profiles.listPublic)
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-7xl mx-auto">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-2">Browse Public Profiles</h1>
<p className="text-slate-400">
Discover and explore firmware profiles shared by the community
</p>
</header>
<main>
{profiles === undefined ? (
<div className="text-center text-slate-400 py-12">
Loading profiles...
</div>
) : profiles.length === 0 ? (
<div className="text-center text-slate-400 py-12">
No public profiles available yet.
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{profiles.map((profile) => (
<button
key={profile._id}
type="button"
onClick={() => navigate(`/profiles/${profile._id}`)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
navigate(`/profiles/${profile._id}`)
}
}}
className={`${profileCardClasses} hover:bg-slate-900 cursor-pointer transition-colors text-left`}
>
<ProfileCardContent profile={profile} />
</button>
))}
</div>
)}
</main>
</div>
</div>
)
}

View File

@@ -1,107 +0,0 @@
import { useMutation, useQuery } from 'convex/react'
import { Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import {
ProfileCardContent,
profileCardClasses,
} from '@/components/ProfileCard'
import ProfileEditor from '@/components/ProfileEditor'
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
import type { Doc, Id } from '../../convex/_generated/dataModel'
export default function Dashboard() {
const navigate = useNavigate()
const profiles = useQuery(api.profiles.list)
const removeProfile = useMutation(api.profiles.remove)
const [isCreating, setIsCreating] = useState(false)
const handleEdit = (profile: Doc<'profiles'>) => {
navigate(`/dashboard/profiles/${profile._id}`)
}
const handleCreate = () => {
setIsCreating(true)
}
const handleDelete = async (
profileId: Id<'profiles'>,
profileName: string
) => {
if (
!confirm(
`Are you sure you want to delete "${profileName}"? This action cannot be undone.`
)
) {
return
}
try {
await removeProfile({ id: profileId })
toast.success('Profile deleted', {
description: `"${profileName}" has been deleted successfully.`,
})
} catch (error) {
toast.error('Delete failed', {
description: String(error),
})
}
}
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<header className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold">My Fleet</h1>
<Button
onClick={handleCreate}
className="bg-cyan-600 hover:bg-cyan-700"
>
<Plus className="w-4 h-4 mr-2" /> New Profile
</Button>
</header>
<main>
{isCreating ? (
<ProfileEditor
onSave={() => setIsCreating(false)}
onCancel={() => setIsCreating(false)}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{profiles?.map((profile) => (
<div key={profile._id} className={profileCardClasses}>
<ProfileCardContent profile={profile} />
<div className="flex gap-2 pt-2">
<Button size="sm" asChild>
<Link to={`/profiles/${profile._id}`}>Flash</Link>
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => handleEdit(profile)}
>
Edit
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDelete(profile._id, profile.name)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
{profiles?.length === 0 && (
<div className="col-span-3 text-center text-slate-500 py-12">
No profiles found. Create one to get started.
</div>
)}
</div>
)}
</main>
</div>
)
}

View File

@@ -1,110 +0,0 @@
import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { DiscordButton } from '@/components/DiscordButton'
import { RedditButton } from '@/components/RedditButton'
function QuickBuildIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
role="img"
aria-label="Quick build"
{...props}
>
<title>Quick build</title>
<path fill="currentColor" d="M11 15H6l7-14v8h5l-7 14z" />
</svg>
)
}
export default function LandingPage() {
const navigate = useNavigate()
return (
<div className="min-h-screen bg-slate-950 text-white">
<div className="max-w-7xl mx-auto">
<div className="text-center py-20 px-8">
<h1 className="text-6xl md:text-7xl font-bold mb-6 leading-[1.1]">
<span className="bg-gradient-to-r from-cyan-400 to-blue-600 bg-clip-text text-transparent inline-block pb-2">
Extend Meshtastic beyond the core
</span>
</h1>
<p className="text-xl md:text-2xl text-slate-400 max-w-3xl mx-auto mb-10">
Build custom firmware with third-party plugins: BBS's, custom hardware,
games, and more. An open ecosystem growing to hundreds of plugins.
</p>
<div className="flex flex-wrap items-center justify-center gap-3 mb-10">
<Button
onClick={() => navigate('/builds/new')}
size="lg"
variant="outline"
className="border-cyan-500/50 text-white hover:bg-slate-900/60"
>
<QuickBuildIcon className="mr-2 h-5 w-5" />
Quick Build
</Button>
<DiscordButton
size="lg"
variant="default"
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white border-0 shadow-lg shadow-purple-500/50"
/>
<RedditButton
size="lg"
variant="default"
className="bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white border-0 shadow-lg shadow-orange-500/50"
/>
</div>
{/* Benefits Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-w-5xl mx-auto mb-10">
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-6 text-left">
<h3 className="text-lg font-semibold text-cyan-400 mb-2">
Zero Install
</h3>
<p className="text-slate-300 text-sm">
No downloads, no toolchains. Everything runs in your browser.
</p>
</div>
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-6 text-left">
<h3 className="text-lg font-semibold text-cyan-400 mb-2">
Custom Firmware
</h3>
<p className="text-slate-300 text-sm">
Build bespoke Meshtastic firmware tailored to your exact needs.
</p>
</div>
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-6 text-left">
<h3 className="text-lg font-semibold text-cyan-400 mb-2">
Community Extensions
</h3>
<p className="text-slate-300 text-sm">
Include community modules and extensions beyond core Meshtastic.
</p>
</div>
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-6 text-left md:col-span-2 lg:col-span-1">
<h3 className="text-lg font-semibold text-cyan-400 mb-2">
Share & Remix
</h3>
<p className="text-slate-300 text-sm">
Publish your build profiles and let others remix your configs.
</p>
</div>
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-6 text-left md:col-span-2 lg:col-span-2">
<h3 className="text-lg font-semibold text-cyan-400 mb-2">
Cloud Builds
</h3>
<p className="text-slate-300 text-sm">
Compile in the cloud, flash directly to your deviceno local
setup required.
</p>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,236 +0,0 @@
import { useAuthActions } from '@convex-dev/auth/react'
import { useConvexAuth, useMutation, useQuery } from 'convex/react'
import * as React from 'react'
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { ProfileStatisticPills } from '@/components/ProfileCard'
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
import type { Id } from '../../convex/_generated/dataModel'
import modulesData from '../../convex/modules.json'
import { TARGETS } from '../constants/targets'
export default function ProfileDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { isAuthenticated } = useConvexAuth()
const { signIn } = useAuthActions()
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
const profile = useQuery(
api.profiles.get,
id ? { id: id as Id<'profiles'> } : 'skip'
)
const [selectedTarget, setSelectedTarget] = useState<string>('')
// Group targets by category
const groupedTargets = React.useMemo(() => {
return Object.entries(TARGETS).reduce(
(acc, [targetId, meta]) => {
const category = meta.category || 'Other'
if (!acc[category]) acc[category] = []
acc[category].push({ id: targetId, ...meta })
return acc
},
{} as Record<string, ((typeof TARGETS)[string] & { id: string })[]>
)
}, [])
const categories = React.useMemo(
() => Object.keys(groupedTargets).sort(),
[groupedTargets]
)
const [activeCategory, setActiveCategory] = React.useState<string>(
categories[0] || ''
)
// Update active category when categories load if not set
React.useEffect(() => {
if (!activeCategory && categories.length > 0) {
setActiveCategory(categories[0])
}
}, [categories, activeCategory])
// Load saved target
React.useEffect(() => {
if (!id) return
const savedTarget = localStorage.getItem(`profile_target_${id}`)
if (savedTarget && TARGETS[savedTarget]) {
setSelectedTarget(savedTarget)
const category = TARGETS[savedTarget].category || 'Other'
if (categories.includes(category)) {
setActiveCategory(category)
}
}
}, [id, categories])
// Save target on change
React.useEffect(() => {
if (!id || !selectedTarget) return
localStorage.setItem(`profile_target_${id}`, selectedTarget)
}, [id, selectedTarget])
if (!id) {
return <div>Profile ID required</div>
}
if (profile === undefined) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">
<div>Loading...</div>
</div>
)
}
if (profile === null) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">
<div>Profile not found</div>
</div>
)
}
// Get excluded modules (new logic: config[id] === true means excluded)
const excludedModules = modulesData.modules.filter(
(module) => profile.config.modulesExcluded[module.id] === true
)
const handleFlash = async () => {
if (!selectedTarget || !id) return
if (!isAuthenticated) {
void signIn('google', { redirectTo: window.location.href })
return
}
try {
if (!profile) return
await ensureBuildFromConfig(profile.config)
navigate(`/profiles/${id}/flash/${selectedTarget}`)
} catch (error) {
toast.error('Failed to start flash', {
description: String(error),
})
}
}
const totalFlashes = profile.flashCount ?? 0
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto">
<div className="space-y-4 mb-8">
<div>
<h1 className="text-4xl font-bold mb-2">{profile.name}</h1>
<p className="text-slate-400">
Flashed {totalFlashes} time{totalFlashes !== 1 ? 's' : ''}
</p>
</div>
<ProfileStatisticPills
version={profile.config.version}
flashCount={totalFlashes}
/>
</div>
<div className="space-y-8">
{/* Excluded Modules */}
<div>
<h2 className="text-2xl font-semibold mb-4">Excluded Modules</h2>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
{excludedModules.length === 0 ? (
<p className="text-slate-400">
No modules explicitly excluded. All modules supported by your
target will be included.
</p>
) : (
<div className="space-y-4">
{excludedModules.map((module) => (
<div
key={module.id}
className="border-b border-slate-800 pb-4 last:border-b-0 last:pb-0"
>
<h3 className="text-lg font-medium mb-1">
{module.name}
</h3>
<p className="text-slate-400 text-sm">
{module.description}
</p>
</div>
))}
</div>
)}
</div>
</div>
{/* Target Selection and Flash */}
<div>
<h2 className="text-2xl font-semibold mb-4">Flash Firmware</h2>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
<div className="space-y-4">
<div>
<div className="block text-sm font-medium mb-2">
Select Target
</div>
<div className="space-y-4">
{/* Category Pills */}
<div className="flex flex-wrap gap-2">
{categories.map((category) => {
const isActive = activeCategory === category
return (
<button
key={category}
type="button"
onClick={() => setActiveCategory(category)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isActive
? 'bg-blue-600 text-white'
: 'bg-slate-800 text-slate-300 hover:bg-slate-700'
}`}
>
{category}
</button>
)
})}
</div>
{/* Active Category Targets */}
<div className="bg-slate-950/50 p-4 rounded-lg border border-slate-800/50">
<div className="flex gap-2 flex-wrap">
{groupedTargets[activeCategory]?.map((item) => {
const isSelected = selectedTarget === item.id
return (
<button
key={item.id}
type="button"
onClick={() => setSelectedTarget(item.id)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isSelected
? 'bg-cyan-600 text-white'
: 'bg-slate-800 text-slate-300 hover:bg-slate-700'
}`}
>
{item.name}
</button>
)
})}
</div>
</div>
</div>
</div>
<Button
onClick={handleFlash}
disabled={!selectedTarget}
className="w-full bg-cyan-600 hover:bg-cyan-700"
>
Flash
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,86 +0,0 @@
import { useQuery } from 'convex/react'
import { ArrowLeft, Loader2 } from 'lucide-react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import ProfileEditor from '@/components/ProfileEditor'
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
import type { Id } from '../../convex/_generated/dataModel'
export default function ProfileEditorPage() {
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const profile = useQuery(
api.profiles.get,
id ? { id: id as Id<'profiles'> } : 'skip'
)
if (!id) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto">
<p className="text-slate-300">
No profile id provided.{' '}
<Link to="/dashboard" className="text-cyan-400">
Back to dashboard
</Link>
</p>
</div>
</div>
)
}
if (profile === undefined) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
</div>
)
}
if (profile === null) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto">
<Link
to="/dashboard"
className="inline-flex items-center text-slate-400 hover:text-white mb-4"
>
<ArrowLeft className="w-4 h-4 mr-2" /> Back to Dashboard
</Link>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
<p className="text-slate-300">Profile not found.</p>
</div>
</div>
</div>
)
}
const handleDone = () => {
navigate('/dashboard')
}
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<Link
to="/dashboard"
className="inline-flex items-center text-slate-400 hover:text-white"
>
<ArrowLeft className="w-4 h-4 mr-2" /> Back to Dashboard
</Link>
<Button variant="outline" onClick={handleDone}>
Cancel
</Button>
</div>
<ProfileEditor
initialData={profile}
onSave={handleDone}
onCancel={handleDone}
/>
</div>
</div>
)
}

View File

@@ -1,283 +0,0 @@
import { useMutation, useQuery } from 'convex/react'
import { ArrowLeft, CheckCircle, Loader2, XCircle } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { ProfileStatisticPills } from '@/components/ProfileCard'
import { SourceAvailable } from '@/components/SourceAvailable'
import { Button } from '@/components/ui/button'
import { humanizeStatus } from '@/lib/utils'
import { api } from '../../convex/_generated/api'
import type { Id } from '../../convex/_generated/dataModel'
import { ArtifactType } from '../../convex/builds'
import modulesData from '../../convex/modules.json'
import { TARGETS } from '../constants/targets'
export default function ProfileFlash() {
const { id, target } = useParams<{
id: string
target: string
}>()
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
const [buildId, setBuildId] = useState<Id<'builds'> | null>(null)
const build = useQuery(
api.builds.get, // query you write that does ctx.db.get(id)
buildId ? { id: buildId } : 'skip'
)
const profile = useQuery(
api.profiles.get,
id ? { id: id as Id<'profiles'> } : 'skip'
)
const generateDownloadUrl = useMutation(api.builds.generateDownloadUrl)
useEffect(() => {
if (id && target && profile) {
ensureBuildFromConfig(profile.config)
.then((result) => setBuildId(result.buildId))
.catch(() => setBuildId(null))
}
}, [id, target, profile, ensureBuildFromConfig])
if (build === undefined || profile === undefined) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
</div>
)
}
if (!build) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto">
<Link
to={`/profiles/${id}`}
className="inline-flex items-center text-slate-400 hover:text-white mb-4"
>
<ArrowLeft className="w-4 h-4 mr-2" /> Back to Profile
</Link>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
<p className="text-slate-400">Build not found</p>
</div>
</div>
</div>
)
}
if (!profile) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto">
<Link
to={`/profiles/${id}`}
className="inline-flex items-center text-slate-400 hover:text-white mb-4"
>
<ArrowLeft className="w-4 h-4 mr-2" /> Back to Profile
</Link>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
<p className="text-slate-400">Profile not found</p>
</div>
</div>
</div>
)
}
const targetMeta = target ? TARGETS[target] : undefined
const targetLabel = targetMeta?.name ?? target ?? 'Unknown Target'
const excludedModules = modulesData.modules.filter(
(module) => profile.config.modulesExcluded[module.id] === true
)
const totalFlashes = profile.flashCount ?? 0
const handleDownload = async () => {
if (!id || !build.buildHash) return
try {
const url = await generateDownloadUrl({
buildId: build._id,
profileId: id as Id<'profiles'>,
artifactType: ArtifactType.Firmware,
})
window.location.href = url
} catch (error) {
console.error('Failed to generate download URL', error)
}
}
const handleSourceDownload = async () => {
if (!id) return
try {
const url = await generateDownloadUrl({
buildId: build._id,
profileId: id as Id<'profiles'>,
artifactType: ArtifactType.Source,
})
window.location.href = url
} catch (error) {
console.error('Failed to generate source download URL', error)
}
}
const getStatusColor = (status: string) => {
if (status === 'success') return 'text-green-400'
if (status === 'failure') return 'text-red-400'
return 'text-blue-400'
}
const getStatusIcon = (status: string) => {
if (status === 'success') {
return <CheckCircle className="w-6 h-6 text-green-500" />
}
if (status === 'failure') {
return <XCircle className="w-6 h-6 text-red-500" />
}
return <Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
}
const githubActionUrl =
build.githubRunId && build.githubRunId > 0
? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`
: null
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto space-y-6">
<Link
to={`/profiles/${id}`}
className="inline-flex items-center text-slate-400 hover:text-white"
>
<ArrowLeft className="w-4 h-4 mr-2" /> Back to Profile
</Link>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6 space-y-4">
<div>
<p className="text-sm uppercase tracking-wide text-slate-500">
Profile
</p>
<h1 className="text-3xl font-bold mt-1">{profile.name}</h1>
<p className="text-slate-400 text-sm mt-2">
Version:{' '}
<span className="text-slate-200">{profile.config.version}</span>
</p>
</div>
<p className="text-slate-200 leading-relaxed">
{profile.description}
</p>
<ProfileStatisticPills
version={profile.config.version}
flashCount={totalFlashes}
/>
</div>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
<h2 className="text-xl font-semibold mb-4">Excluded Modules</h2>
{excludedModules.length === 0 ? (
<p className="text-slate-400 text-sm">
No modules explicitly excluded. All modules supported by this
target are included.
</p>
) : (
<div className="space-y-3">
{excludedModules.map((module) => (
<div key={module.id}>
<p className="font-medium text-sm">{module.name}</p>
<p className="text-slate-400 text-sm">{module.description}</p>
</div>
))}
</div>
)}
</div>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6 space-y-4">
<div className="flex items-center gap-4">
{getStatusIcon(build.status)}
<div>
<p className="text-sm uppercase tracking-wide text-slate-500">
Target
</p>
<h2 className="text-2xl font-semibold">{targetLabel}</h2>
<div className="flex items-center gap-2 text-slate-400 mt-1 text-sm">
<span className={getStatusColor(build.status)}>
{humanizeStatus(build.status)}
</span>
{githubActionUrl && (
<a
href={githubActionUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-slate-500 hover:text-slate-300 transition-colors"
title="View on GitHub Actions"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 32 32"
className="w-4 h-4"
>
<title>View on GitHub Actions</title>
<path
fill="currentColor"
d="M26 18h-6a2 2 0 0 0-2 2v2h-6a2 2 0 0 1-2-2v-6h2a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2v6a4 4 0 0 0 4 4h6v2a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2M6.5 12a.5.5 0 0 1-.5-.5v-5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 .5.5v5a.5.5 0 0 1-.5.5ZM26 25.5a.5.5 0 0 1-.5.5h-5a.5.5 0 0 1-.5-.5v-5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 .5.5Z"
/>
</svg>
</a>
)}
<span></span>
<span>{new Date(build.updatedAt).toLocaleString()}</span>
</div>
</div>
</div>
{build.status === 'success' && build.buildHash && (
<div className="space-y-2">
<Button
onClick={handleDownload}
className="bg-cyan-600 hover:bg-cyan-700 w-full"
>
Download Firmware
</Button>
<p className="text-xs text-slate-400 text-center">
Need help flashing?{' '}
<a
href="https://github.com/MeshEnvy/mesh-forge/discussions/5"
target="_blank"
rel="noopener noreferrer"
className="text-cyan-400 hover:text-cyan-300 underline"
>
[ESP32]
</a>
{' '}and{' '}
<a
href="https://github.com/MeshEnvy/mesh-forge/discussions/6"
target="_blank"
rel="noopener noreferrer"
className="text-cyan-400 hover:text-cyan-300 underline"
>
[nRF52]
</a>
{' '}guides
</p>
</div>
)}
<SourceAvailable sourcePath={build.sourcePath}>
<div className="space-y-2">
<Button
onClick={handleSourceDownload}
className="bg-slate-700 hover:bg-slate-600 w-full"
variant="outline"
>
Download Source
</Button>
</div>
</SourceAvailable>
</div>
</div>
</div>
)
}

1
src/vite-env.d.ts vendored
View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,29 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"types": ["vite/client", "vike-react"],
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"jsxImportSource": "react",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./*"]
}
},
"include": ["src", "vendor/web-flasher/public/data"],
"references": [{ "path": "./tsconfig.node.json" }]
"exclude": ["dist"]
}

2
vendor/lobbs vendored

2
vendor/lodb vendored

2
vendor/mpm vendored

Submodule vendor/mpm updated: 44c620d34d...f2f9ebc3ec

View File

@@ -1,13 +1,15 @@
import path from "node:path";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import vike from "vike/plugin";
import { defineConfig } from "vite";
import path from "node:path";
import mdx from "@mdx-js/rollup";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [vike(), mdx(), react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@": path.resolve(__dirname, "."),
},
},
});

View File

@@ -1,10 +0,0 @@
{
"name": "mesh-forge-registry",
"compatibility_date": "2024-09-23",
"assets": {
"directory": "./registry"
},
"observability": {
"enabled": true
}
}

View File

@@ -2,7 +2,7 @@
"name": "mesh-forge",
"compatibility_date": "2025-11-29",
"assets": {
"directory": "./dist",
"directory": "./dist/client",
"not_found_handling": "single-page-application"
},
"observability": {