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
-42
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;
}
-94
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
-1
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

-106
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
}
-47
View File
@@ -1,47 +0,0 @@
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
function DiscordIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
{...props}
>
<path d="M19.27 5.33C17.94 4.71 16.5 4.26 15 4a.1.1 0 0 0-.07.03c-.18.33-.39.76-.53 1.09a16.1 16.1 0 0 0-4.8 0c-.14-.34-.35-.76-.54-1.09c-.01-.02-.04-.03-.07-.03c-1.5.26-2.93.71-4.27 1.33c-.01 0-.02.01-.03.02c-2.72 4.07-3.47 8.03-3.1 11.95c0 .02.01.04.03.05c1.8 1.32 3.53 2.12 5.24 2.65c.03.01.06 0 .07-.02c.4-.55.76-1.13 1.07-1.74c.02-.04 0-.08-.04-.09c-.57-.22-1.11-.48-1.64-.78c-.04-.02-.04-.08-.01-.11c.11-.08.22-.17.33-.25c.02-.02.05-.02.07-.01c3.44 1.57 7.15 1.57 10.55 0c.02-.01.05-.01.07.01c.11.09.22.17.33.26c.04.03.04.09-.01.11c-.52.31-1.07.56-1.64.78c-.04.01-.05.06-.04.09c.32.61.68 1.19 1.07 1.74c.03.01.06.02.09.01c1.72-.53 3.45-1.33 5.25-2.65c.02-.01.03-.03.03-.05c.44-4.53-.73-8.46-3.1-11.95c-.01-.01-.02-.02-.04-.02M8.52 14.91c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.84 2.12-1.89 2.12m6.97 0c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.83 2.12-1.89 2.12" />
</svg>
)
}
interface DiscordButtonProps {
variant?: 'default' | 'outline' | 'ghost' | 'link' | 'destructive'
size?: 'default' | 'sm' | 'lg' | 'icon'
className?: string
}
export function DiscordButton({
variant = 'outline',
size,
className,
}: DiscordButtonProps) {
return (
<a
href="https://discord.gg/8KgJpvjfaJ"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant={variant}
size={size}
className={cn('flex items-center gap-2', className)}
>
<DiscordIcon className="w-4 h-4" />
Discord
</Button>
</a>
)
}
-62
View File
@@ -1,62 +0,0 @@
interface ModuleCardProps {
name: string
description: string
selected: boolean
onClick: () => void
}
export function ModuleCard({
name,
description,
selected,
onClick,
}: ModuleCardProps) {
return (
<button
type="button"
onClick={onClick}
className={`
w-full text-left p-4 rounded-lg border-2 transition-all
${
selected
? 'border-blue-500 bg-blue-500/10'
: 'border-slate-700 bg-slate-900/50 hover:border-slate-600'
}
`}
>
<div className="flex items-start gap-3">
<div className="mt-1">
<div
className={`
w-5 h-5 rounded border-2 flex items-center justify-center
${selected ? 'border-blue-500 bg-blue-500' : 'border-slate-500'}
`}
>
{selected && (
<svg
className="w-3 h-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<title>Checkmark</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</div>
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm mb-1">{name}</h4>
<p className="text-xs text-slate-400 leading-relaxed">
{description}
</p>
</div>
</div>
</button>
)
}
-34
View File
@@ -1,34 +0,0 @@
import { Switch } from '@/components/ui/switch'
interface ModuleToggleProps {
id: string
name: string
description: string
isExcluded: boolean
onToggle: (excluded: boolean) => void
}
export function ModuleToggle({
name,
description,
isExcluded,
onToggle,
}: ModuleToggleProps) {
return (
<div className="flex items-start gap-4 p-4 rounded-lg border-2 border-slate-700 bg-slate-900/50 hover:border-slate-600 transition-colors">
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm mb-1">{name}</h4>
<p className="text-xs text-slate-400 leading-relaxed">{description}</p>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<Switch
checked={isExcluded}
onCheckedChange={onToggle}
labelLeft="Default"
labelRight="Excluded"
className={isExcluded ? 'bg-orange-600' : 'bg-slate-600'}
/>
</div>
</div>
)
}
-77
View File
@@ -1,77 +0,0 @@
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'
export default function Navbar() {
const { signOut, signIn } = useAuthActions()
const isAdmin = useQuery(api.admin.isAdmin)
return (
<nav className="border-b border-slate-800 bg-slate-950">
<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"
/>
<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>
<div className="flex items-center gap-4">
<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"
>
Admin
</Link>
)}
</Authenticated>
</div>
</div>
<div className="flex items-center gap-4">
<DiscordButton
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
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"
/>
<Unauthenticated>
<Button
onClick={() => signIn('google', { redirectTo: window.location.href })}
className="bg-cyan-600 hover:bg-cyan-700"
>
Sign In
</Button>
</Unauthenticated>
<Authenticated>
<Button variant="outline" onClick={() => signOut()}>
Sign Out
</Button>
</Authenticated>
</div>
</div>
</div>
</nav>
)
}
-102
View File
@@ -1,102 +0,0 @@
import { ExternalLink, Star } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
interface PluginToggleProps {
id: string
name: string
description: string
isEnabled: boolean
onToggle: (enabled: boolean) => void
featured?: boolean
flashCount?: number
homepage?: string
version?: string
disabled?: boolean
enabledLabel?: string
incompatibleReason?: string
}
export function PluginToggle({
name,
description,
isEnabled,
onToggle,
featured = false,
flashCount = 0,
homepage,
version,
disabled = false,
enabledLabel = 'Add',
incompatibleReason,
}: PluginToggleProps) {
const isIncompatible = !!incompatibleReason
return (
<div
className={`relative flex items-start gap-4 p-4 rounded-lg border-2 transition-colors ${
isIncompatible
? 'border-slate-800 bg-slate-900/30 opacity-60 cursor-not-allowed'
: 'border-slate-700 bg-slate-900/50 hover:border-slate-600'
}`}
>
{/* Flash count and homepage links in lower right */}
<div className="absolute bottom-2 right-2 flex items-center gap-3 text-xs text-slate-400 z-10">
{version && <span className="text-slate-500">v{version}</span>}
<div className="flex items-center gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
className="text-slate-400"
fill="currentColor"
role="img"
aria-label="Download"
>
<path d="m14 2l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm4 18V9h-5V4H6v16zm-6-1l-4-4h2.5v-3h3v3H16z" />
</svg>
<span>{flashCount}</span>
</div>
{homepage && (
<a
href={homepage}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-slate-400 hover:text-slate-300 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-1">
<h4 className={`font-semibold text-sm ${isIncompatible ? 'text-slate-500' : ''}`}>
{name}
</h4>
{featured && (
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
)}
</div>
<p className={`text-xs leading-relaxed ${isIncompatible ? 'text-slate-500' : 'text-slate-400'}`}>
{description}
</p>
{isIncompatible && incompatibleReason && (
<p className="text-xs text-red-400 mt-1 font-medium">
{incompatibleReason}
</p>
)}
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<Switch
checked={isEnabled}
onCheckedChange={onToggle}
disabled={disabled}
labelLeft="Skip"
labelRight={enabledLabel}
className={isEnabled ? 'bg-green-600' : 'bg-slate-600'}
/>
</div>
</div>
)
}
-52
View File
@@ -1,52 +0,0 @@
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'
interface ProfilePillsProps {
version: string
flashCount?: number
flashLabel?: string
}
export function ProfileStatisticPills({
version,
flashCount,
flashLabel,
}: ProfilePillsProps) {
const normalizedCount = flashCount ?? 0
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-cyan-500/10 text-cyan-300 px-3 py-1">
{normalizedCount} {normalizedLabel}
</span>
</div>
)
}
interface ProfileCardContentProps {
profile: Doc<'profiles'>
}
export function ProfileCardContent({ profile }: ProfileCardContentProps) {
const flashCount = profile.flashCount ?? 0
return (
<>
<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>
</div>
<ProfileStatisticPills
version={profile.config.version}
flashCount={flashCount}
/>
</>
)
}
-187
View File
@@ -1,187 +0,0 @@
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'
// 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'
>
interface ProfileEditorProps {
initialData?: Doc<'profiles'>
onSave: () => void
onCancel: () => void
}
export default function ProfileEditor({
initialData,
onSave,
onCancel,
}: ProfileEditorProps) {
const upsertProfile = useMutation(api.profiles.upsert)
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<ProfileFormValues>({
defaultValues: {
name: initialData?.name || '',
description: initialData?.description || '',
config: {
version: VERSIONS[0],
modulesExcluded: {},
target: '',
...initialData?.config,
},
isPublic: initialData?.isPublic ?? true,
},
})
const onSubmit = async (data: ProfileFormValues) => {
await upsertProfile({
id: initialData?._id,
name: data.name,
description: data.description,
config: data.config,
isPublic: data.isPublic,
})
onSave()
}
return (
<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">
Profile Name
</label>
<Input
id="name"
{...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>
)}
</div>
<div>
<label htmlFor="version" className="block text-sm font-medium mb-2">
Firmware Version
</label>
<select
id="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) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium mb-2">
Description
</label>
<textarea
id="description"
{...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>
)}
</div>
<div>
<div className="flex items-center space-x-2">
<Checkbox
id="isPublic"
checked={watch('isPublic')}
onCheckedChange={(checked) => setValue('isPublic', !!checked)}
disabled
/>
<label
htmlFor="isPublic"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
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>
</div>
<div className="space-y-6">
<div>
<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.
</p>
</div>
<div className="flex flex-col gap-2">
{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 configValue = currentConfig.modulesExcluded[module.id]
const isExcluded = configValue === true
return (
<ModuleToggle
key={module.id}
id={module.id}
name={module.name}
description={module.description}
isExcluded={isExcluded}
onToggle={(excluded) => {
const newConfig = { ...currentConfig }
if (excluded) {
newConfig.modulesExcluded[module.id] = true
} else {
delete newConfig.modulesExcluded[module.id]
}
setValue('config', newConfig)
}}
/>
)
})}
</div>
</div>
</div>
<div className="flex justify-end gap-4 pt-4">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit">Save Profile</Button>
</div>
</form>
)
}
-168
View File
@@ -1,168 +0,0 @@
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
function RedditIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
{...props}
>
<mask id="SVGfUZuVbjp">
<g fill="#fff">
<path
fillOpacity="0"
stroke="#fff"
strokeDasharray="48"
strokeDashoffset="48"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 9.42c4.42 0 8 2.37 8 5.29c0 2.92 -3.58 5.29 -8 5.29c-4.42 0 -8 -2.37 -8 -5.29c0 -2.92 3.58 -5.29 8 -5.29Z"
>
<animate
fill="freeze"
attributeName="fill-opacity"
begin="0.6s"
dur="0.4s"
values="0;1"
/>
<animate
fill="freeze"
attributeName="stroke-dashoffset"
dur="0.6s"
values="48;0"
/>
</path>
<circle cx="7.24" cy="11.97" r="2.24" opacity="0">
<animate
fill="freeze"
attributeName="cx"
begin="1s"
dur="0.2s"
values="7.24;3.94"
/>
<set fill="freeze" attributeName="opacity" begin="1s" to="1" />
</circle>
<circle cx="16.76" cy="11.97" r="2.24" opacity="0">
<animate
fill="freeze"
attributeName="cx"
begin="1s"
dur="0.2s"
values="16.76;20.06"
/>
<set fill="freeze" attributeName="opacity" begin="1s" to="1" />
</circle>
<circle cx="18.45" cy="4.23" r="1.61" opacity="0">
<animate
attributeName="cx"
begin="2.4s"
dur="6s"
repeatCount="indefinite"
values="18.45;5.75;18.45"
/>
<set fill="freeze" attributeName="opacity" begin="2.6s" to="1" />
</circle>
</g>
<path
fill="none"
stroke="#fff"
strokeDasharray="12"
strokeDashoffset="12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth=".8"
d="M12 8.75L13.18 3.11L18.21 4.18"
>
<animate
attributeName="d"
begin="2.4s"
dur="6s"
repeatCount="indefinite"
values="M12 8.75L13.18 3.11L18.21 4.18;M12 8.75L12 2L12 4.18;M12 8.75L10.82 3.11L5.79 4.18;M12 8.75L12 2L12 4.18;M12 8.75L13.18 3.11L18.21 4.18"
/>
<animate
fill="freeze"
attributeName="stroke-dashoffset"
begin="2.4s"
dur="0.2s"
values="12;0"
/>
</path>
<g fillOpacity="0">
<circle cx="8.45" cy="13.59" r="1.61">
<animate
fill="freeze"
attributeName="fill-opacity"
begin="1.2s"
dur="0.4s"
values="0;1"
/>
</circle>
<circle cx="15.55" cy="13.59" r="1.61">
<animate
fill="freeze"
attributeName="fill-opacity"
begin="1.6s"
dur="0.4s"
values="0;1"
/>
</circle>
</g>
<path
fill="none"
stroke="#000"
strokeDasharray="10"
strokeDashoffset="10"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth=".8"
d="M8.47 17.52c0 0 0.94 1.06 3.53 1.06c2.58 0 3.53 -1.06 3.53 -1.06"
>
<animate
fill="freeze"
attributeName="stroke-dashoffset"
begin="2s"
dur="0.2s"
values="10;0"
/>
</path>
</mask>
<rect width="24" height="24" fill="currentColor" mask="url(#SVGfUZuVbjp)" />
</svg>
)
}
interface RedditButtonProps {
variant?: 'default' | 'outline' | 'ghost' | 'link' | 'destructive'
size?: 'default' | 'sm' | 'lg' | 'icon'
className?: string
}
export function RedditButton({
variant = 'outline',
size,
className,
}: RedditButtonProps) {
return (
<a
href="https://www.reddit.com/r/MeshForge/"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant={variant}
size={size}
className={cn('flex items-center gap-2', className)}
>
<RedditIcon className="w-4 h-4" />
Reddit
</Button>
</a>
)
}
-19
View File
@@ -1,19 +0,0 @@
interface SourceAvailableProps {
sourcePath: string | undefined
children: React.ReactNode
}
/**
* Component that only renders children when sourcePath is available.
* Uses the sourcePath field from the build instead of polling.
*/
export function SourceAvailable({
sourcePath,
children,
}: SourceAvailableProps) {
if (!sourcePath) {
return null
}
return <>{children}</>
}
-57
View File
@@ -1,57 +0,0 @@
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }
-28
View File
@@ -1,28 +0,0 @@
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { Check } from 'lucide-react'
import * as React from 'react'
import { cn } from '@/lib/utils'
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('grid place-content-center text-current')}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
-22
View File
@@ -1,22 +0,0 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }
-29
View File
@@ -1,29 +0,0 @@
import { useTheme } from 'next-themes'
import { Toaster as Sonner } from 'sonner'
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme()
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
)
}
export { Toaster }
-51
View File
@@ -1,51 +0,0 @@
import { cn } from '@/lib/utils'
interface SwitchProps {
checked: boolean
onCheckedChange: (checked: boolean) => void
disabled?: boolean
className?: string
labelLeft?: string
labelRight?: string
}
export function Switch({
checked,
onCheckedChange,
disabled = false,
className,
labelLeft,
labelRight,
}: SwitchProps) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
className={cn(
'relative inline-flex h-8 w-24 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950 disabled:cursor-not-allowed disabled:opacity-50',
!className && (checked ? 'bg-red-600' : 'bg-slate-600'),
className
)}
>
<span
className={cn(
'inline-block h-6 w-6 transform rounded-full bg-white transition-transform',
checked ? 'translate-x-[68px]' : 'translate-x-1'
)}
/>
{checked && labelRight && (
<span className="absolute left-2 text-xs font-medium text-white">
{labelRight}
</span>
)}
{!checked && labelLeft && (
<span className="absolute right-2 text-xs font-medium text-white">
{labelLeft}
</span>
)}
</button>
)
}
-220
View File
@@ -1,220 +0,0 @@
{
"betafpv2400txmicro": "esp32",
"betafpv900txnano": "esp32",
"chatter2": "esp32",
"9m2ibraprsloratracker": "esp32",
"meshtasticdrdev": "esp32",
"hydra": "esp32",
"meshtasticdiyv1": "esp32",
"meshtasticdiyv11": "esp32",
"hackerboxesesp32io": "esp32",
"heltecv1": "esp32",
"heltecv20": "esp32",
"heltecv21": "esp32",
"heltecwirelessbridge": "esp32",
"heltecwslv21": "esp32",
"m5stackcore": "esp32",
"m5stackcoreink": "esp32",
"nanog1": "esp32",
"nanog1explorer": "esp32",
"radiomaster900bandit": "esp32",
"radiomaster900banditmicro": "esp32",
"radiomaster900banditnano": "esp32",
"rak11200": "esp32",
"stationg1": "esp32",
"tbeam": "esp32",
"tbeamdisplayshield": "tbeam",
"tbeam07": "esp32",
"tlorav1": "esp32",
"tlorav13": "esp32",
"tlorav2": "esp32",
"tlorav2116": "esp32",
"sugarcube": "tlorav2116",
"tlorav2116tcxo": "esp32",
"tlorav2118": "esp32",
"tlorav330tcxo": "esp32",
"trackerd": "esp32",
"wiphone": "esp32",
"aic3": "esp32c3",
"esp32c3supermini": "esp32c3",
"esp32c3base": "esp32",
"hackerboxesesp32c3oled": "esp32c3",
"heltecht62esp32c3sx1262": "esp32c3",
"heltechru3601": "esp32c3",
"m5stackstampc3": "esp32c3",
"esp32c6base": "esp32",
"m5stackunitc6l": "esp32c6",
"tlorac6": "esp32c6",
"esp32s2base": "esp32",
"nuggets2lora": "esp32s2",
"CDEBYTEEoRaS3": "esp32s3",
"EBYTEESP32S3": "esp32s3",
"thinknodem2": "esp32s3",
"thinknodem5": "esp32s3",
"bpipicowesp32s3": "esp32s3",
"crowpanelesp32s35epaper": "esp32s3",
"crowpanelesp32s34epaper": "esp32s3",
"crowpanelesp32s32epaper": "esp32s3",
"myesp32s3diyeink": "esp32s3",
"myesp32s3diyoled": "esp32s3",
"tenergys3e22": "esp32s3",
"dreamcatcher2206": "esp32s3",
"crowpanelbase": "crowpanel",
"elecrowadv2428tft": "crowpanelsmallesp32s3base",
"elecrowadv35tft": "crowpanelsmallesp32s3base",
"elecrowadv1435070tft": "crowpanellargeesp32s3base",
"ESP32S3Pico": "esp32s3",
"esp32s3base": "esp32",
"hackadaycommunicator": "esp32s3",
"helteccapsulesensorv3": "esp32s3",
"heltecsensorhub": "esp32s3",
"heltecv3": "esp32s3",
"heltecv4base": "esp32s3",
"heltecv4": "heltecv4base",
"heltecv4tft": "heltecv4base",
"heltecvisionmastere213": "esp32s3",
"heltecvisionmastere213inkhud": "esp32s3",
"heltecvisionmastere290": "esp32s3",
"heltecvisionmastere290inkhud": "esp32s3",
"heltecvisionmastert190": "esp32s3",
"heltecwirelesspaper": "esp32s3",
"heltecwirelesspaperinkhud": "esp32s3",
"heltecwirelesspaperv10": "esp32s3",
"heltecwirelesstracker": "esp32s3",
"heltecwirelesstrackerV10": "esp32s3",
"heltecwirelesstrackerv2": "esp32s3",
"heltecwslv3": "esp32s3",
"icarus": "esp32s3",
"link32s3v1": "esp32s3",
"m5stackcores3": "esp32s3",
"meshtabbase": "esp32s3",
"meshtab32TNresistive": "meshtabbase",
"meshtab32IPSresistive": "meshtabbase",
"meshtab35IPSresistive": "meshtabbase",
"meshtab35TNresistive": "meshtabbase",
"meshtab32IPScapacitive": "meshtabbase",
"meshtab35IPScapacitive": "meshtabbase",
"meshtab40IPScapacitive": "meshtabbase",
"nibbleesp32": "esp32s3",
"nuggets3lora": "esp32s3",
"picomputers3": "esp32s3",
"picomputers3tft": "picomputers3",
"rak3312": "esp32s3",
"rakwismeshtapv2tft": "rakwismeshtaps3",
"seeedsensecapindicator": "esp32s3",
"seeedsensecapindicatortft": "seeedsensecapindicator",
"seeedxiaos3": "esp32s3",
"stationg2": "esp32s3",
"tdeck": "esp32s3",
"tdecktft": "tdeck",
"tdeckpro": "esp32s3",
"tethelite": "esp32s3",
"twatchs3": "esp32s3",
"tbeams3core": "esp32s3",
"tlorapager": "esp32s3",
"tlorapagertft": "tlorapager",
"tlorat3s3epaper": "esp32s3",
"tlorat3s3epaperinkhud": "esp32s3",
"tlorat3s3v1": "esp32s3",
"tracksenger": "esp32s3",
"tracksengerlcd": "esp32s3",
"tracksengeroled": "esp32s3",
"unphone": "esp32s3",
"unphonetft": "unphone",
"coverage": "native",
"buildroot": "portduino",
"pca10059diyeink": "nrf52840",
"thinknodem1": "nrf52840",
"thinknodem1inkhud": "nrf52840",
"thinknodem3": "nrf52840",
"thinknodem6": "nrf52840",
"ME25LS014Y10TD": "nrf52840",
"ME25LS014Y10TDeink": "nrf52840",
"ms24sf1": "nrf52840",
"makerpythonnrf52840sx1280eink": "nrf52840",
"makerpythonnrf52840sx1280oled": "nrf52840",
"TWCmeshv4": "nrf52840",
"canaryone": "nrf52840",
"WashTastic": "nrf52840",
"nrf52promicrodiytcxo": "nrf52840",
"nrf52promicrodiyinkhud": "nrf52840",
"seeedxiaonrf52840wiosx1262": "nrf52840",
"seeedxiaonrf52840e22900m30s": "seeedxiaonrf52840kit",
"seeedxiaonrf52840e22900m33s": "seeedxiaonrf52840kit",
"xiaoble": "seeedxiaonrf52840kit",
"featherdiy": "nrf52840",
"gat562meshtrialtracker": "nrf52840",
"heltecmeshnodet114": "nrf52840",
"heltecmeshnodet114inkhud": "nrf52840",
"heltecmeshpocket5000": "nrf52840",
"heltecmeshpocket5000inkhud": "nrf52840",
"heltecmeshpocket10000": "nrf52840",
"heltecmeshpocket10000inkhud": "nrf52840",
"heltecmeshsolarbase": "nrf52840",
"heltecmeshsolar": "heltecmeshsolarbase",
"heltecmeshsolareink": "heltecmeshsolarbase",
"heltecmeshsolarinkhud": "heltecmeshsolarbase",
"heltecmeshsolaroled": "heltecmeshsolarbase",
"heltecmeshsolartft": "heltecmeshsolarbase",
"meshlink": "nrf52840",
"meshlinkeink": "nrf52840",
"meshtiny": "nrf52840",
"monteopshw1": "nrf52840",
"muzibase": "nrf52840",
"nanog2ultra": "nrf52840",
"nrf52832base": "nrf52",
"nrf52840base": "nrf52",
"r1neo": "nrf52840",
"rak2560": "nrf52840",
"rak34011watt": "nrf52840",
"rak4631": "nrf52840",
"rak4631dbg": "rak4631",
"rak4631eink": "nrf52840",
"rak4631einkonrxtx": "nrf52840",
"rak4631ethgw": "nrf52840",
"rak4631ethgwdbg": "rak4631",
"rak4631nomadstarmeteorpro": "nrf52840",
"rak4631nomadstarmeteorprodbg": "rak4631nomadstarmeteorpro",
"rakwismeshtag": "nrf52840",
"rakwismeshtap": "nrf52840",
"seeedsolarnode": "nrf52840",
"seeedwiotrackerL1": "nrf52840",
"seeedwiotrackerL1eink": "nrf52840",
"seeedwiotrackerL1einkinkhud": "nrf52840",
"seeedxiaonrf52840kit": "nrf52840",
"seeedxiaonrf52840kiti2c": "seeedxiaonrf52840kit",
"techo": "nrf52840",
"techoinkhud": "nrf52840",
"techolite": "nrf52840",
"trackert1000e": "nrf52840",
"wiosdkwm1110": "nrf52840",
"wiot1000s": "nrf52840",
"wiotrackerwm1110": "nrf52840",
"challenger2040lora": "rp2040",
"catsniffer": "rp2040",
"featherrp2040rfm95": "rp2040",
"nibblerp2040": "rp2040",
"rak11310": "rp2040",
"rp2040lora": "rp2040",
"pico": "rp2040",
"picoslowclock": "rp2040",
"picow": "rp2040",
"senselorarp2040": "rp2040",
"pico2": "rp2350",
"pico2w": "rp2350",
"CDEBYTEE77MBL": "stm32",
"rak3172": "stm32",
"wioe5": "stm32",
"esp32c3": "esp32",
"esp32c6": "esp32",
"esp32s2": "esp32",
"esp32s3": "esp32",
"nrf52832": "nrf52",
"nrf52840": "nrf52",
"esp32": null,
"nrf52": null,
"rp2040": null,
"rp2350": null,
"stm32": null,
"portduino": null
}
-24
View File
@@ -1,24 +0,0 @@
import hardwareList from '../../vendor/web-flasher/public/data/hardware-list.json'
export interface TargetMetadata {
name: string
category: string
architecture?: string
}
export const TARGETS: Record<string, TargetMetadata> = {}
// Sort by display name
const sortedHardware = [...hardwareList].sort((a, b) =>
(a.displayName || '').localeCompare(b.displayName || '')
)
sortedHardware.forEach((hw) => {
if (hw.platformioTarget) {
TARGETS[hw.platformioTarget] = {
name: hw.displayName || hw.platformioTarget,
category: hw.tags?.[0] || 'Other',
architecture: hw.architecture,
}
}
})
-243
View File
@@ -1,243 +0,0 @@
// This file is auto-generated by scripts/generate-versions.js
export const VERSIONS = [
'v2.7.16.a597230',
'v2.7.15.567b8ea',
'v2.7.14.e959000',
'v2.7.13.597fa0b',
'v2.7.12.45f15b8',
'v2.7.11.ee68575',
'v2.7.10.94d4bdf',
'v2.7.9.70724be',
'v2.7.8.a0c0388',
'v2.7.7.5ae4ff9',
'v2.7.6.834c3c5',
'v2.7.5.ddd1499',
'v2.7.4.c1f4f79',
'v2.7.3.cf574c7',
'v2.7.2.f6d3782',
'v2.7.1.f35ca81',
'v2.7.0.705515a',
'v2.7.0.195b7cc',
'v2.6.13.0561f2c',
'v2.6.12.9861e82',
'v2.6.11.60ec05e',
'v2.6.10.9ce4455',
'v2.6.9.f223b8a',
'v2.6.8.ef9d0d7',
'v2.6.7.2d6181f',
'v2.6.6.54c1423',
'v2.6.5.fc3d9f2',
'v2.6.4.b89355f',
'v2.6.3.d28af68',
'v2.6.3.640e731',
'v2.6.2.31c0e8f',
'v2.6.1.7c3edde',
'v2.6.0.f7afa9a',
'v2.5.23.bf958ed',
'v2.5.22.d1fa27d',
'v2.5.21.447533a',
'v2.5.20.4c97351',
'v2.5.19.f9876cf',
'v2.5.19.d5cd6f8',
'v2.5.18.89ebafc',
'v2.5.17.b4b2fd6',
'v2.5.16.f81d3b0',
'v2.5.15.79da236',
'v2.5.14.f2ee0df',
'v2.5.13.295278b',
'v2.5.13.1a06f88',
'v2.5.12.aa184e6',
'v2.5.11.8e2a3e5',
'v2.5.10.0fc5c9b',
'v2.5.9.936260f',
'v2.5.8.6485f03',
'v2.5.7.f77c87d',
'v2.5.6.d55c08d',
'v2.5.5.e182ae7',
'v2.5.4.8d288d5',
'v2.5.3.a70d5ee',
'v2.5.2.771cb52',
'v2.5.1.c13b44b',
'v2.5.0.e470619',
'v2.5.0.d6dac17',
'v2.5.0.ab7de7f',
'v2.5.0.33eb073',
'v2.5.0.9e55e6b',
'v2.5.0.9ac0e26',
'v2.4.3.efc27f2',
'v2.4.3.91d6612',
'v2.4.2.5b45303',
'v2.4.1.394e0e1',
'v2.4.0.46d7b82',
'v2.3.15.deb7c27',
'v2.3.14.64531fa',
'v2.3.13.83f5ba0',
'v2.3.12.24458a7',
'v2.3.11.2740a56',
'v2.3.10.d19607b',
'v2.3.9.f06c56a',
'v2.3.8.d490a33',
'v2.3.7.30fbcab',
'v2.3.6.7a3570a',
'v2.3.5.2f9b68e',
'v2.3.4.ea61808',
'v2.3.3.8187fa7',
'v2.3.2.63df972',
'v2.3.1.4fa7f5a',
'v2.3.0.5f47ca1',
'v2.2.24.e6a2c06',
'v2.2.23.5672e68',
'v2.2.22.404d0dd',
'v2.2.21.7f7c5cb',
'v2.2.20.af5ac32',
'v2.2.19.8f6a283',
'v2.2.18.e9bde80',
'v2.2.17.dbac2b1',
'v2.2.16.1c6acfd',
'v2.2.15.31c4693',
'v2.2.14.57542ce',
'v2.2.13.f570204',
'v2.2.12.092e6f2',
'v2.2.11.10265aa',
'v2.2.10.7cebd79',
'v2.2.9.47301a5',
'v2.2.8.61f6fb2',
'v2.2.7.e8970ad',
'v2.2.6.b53cb38',
'v2.2.5.8255128',
'v2.2.4.3bcab0e',
'v2.2.3.282cc0b',
'v2.2.2.f35c7be',
'v2.2.1.fb5f2e4',
'v2.2.0.9f6584b',
'v2.1.23.04bbdc6',
'v2.1.22.191a69d',
'v2.1.21.97d7a89',
'v2.1.20.470363d',
'v2.1.19.eb7025f',
'v2.1.18.de53280',
'v2.1.17.7ca2e81',
'v2.1.16.a2c5b92',
'v2.1.15.cd78723',
'v2.1.14.99a31c1',
'v2.1.13.7475c86',
'v2.1.12.7711b03',
'v2.1.11.5ec624d',
'v2.1.10.7ef12c7',
'v2.1.9.d43ddc9',
'v2.1.8.ee971e3',
'v2.1.7.242f880',
'v2.1.6.5679a82',
'v2.1.5.23272da',
'v2.1.4.958d2cf',
'v2.1.3.8c68d88',
'v2.1.2.6d20215',
'v2.1.1.dc2ca9c',
'v2.1.0.331a1af',
'v2.0.23.7bb281d',
'v2.0.22.fbfd0f1',
'v2.0.21.83e6cea',
'v2.0.20.7100416',
'v2.0.19.3209aea',
'v2.0.18.1a7991c',
'v2.0.17.5d1c06b',
'v2.0.16.2242b68',
'v2.0.15.aafbde0',
'v2.0.14.2baaad8',
'v2.0.13.7e27729',
'v2.0.12.2400dd4',
'v2.0.11.8914d1a',
'v2.0.10.e09b12c',
'v2.0.9.6ea0963',
'v2.0.8.090e166',
'v2.0.7.91ff7b9',
'v2.0.6.97fd5cf',
'v2.0.5.65e8209',
'v2.0.4.5417671',
'v2.0.3.09fe616',
'v2.0.2.8146e84',
'v2.0.1.ad05b91',
'v2.0.0.18ab874',
'v1.3.48.82bcd39',
'v1.3.47.05147c0',
'v1.3.46.d4ea956',
'v1.3.45.b0d0552',
'v1.3.44.4fa8d02',
'v1.3.43.aae9d2f',
'v1.3.42.9bd9252',
'v1.3.41.80ddb81',
'v1.3.40.e87ecc2',
'v1.3.39.ddc3727',
'v1.3.38.1253abd',
'v1.3.37.97712a9',
'v1.3.36.dd720f2',
'v1.3.36.64f852e',
'v1.3.36.7e03019',
'v1.3.35.3251cd5',
'v1.3.34.401b5d9',
'v1.3.33.ab0095c',
'v1.3.32.7e6c22f',
'v1.3.31.0084643',
'v1.3.30.9fe2ddb',
'v1.3.29.7afc149',
'v1.3.28.41f9541',
'v1.3.27.c88ba58',
'v1.3.26.0010231',
'v1.3.25.85f46d3',
'v1.3.24.dff6915',
'v1.3.23.5462d84',
'v1.3.22.c725a6b',
'v1.3.21.cf00ac5',
'v1.3.20.9a5ff93',
'v1.3.19.3c6a2f7',
'v1.3.17.c9822de',
'v1.3.16.97899ae',
'v1.3.15.432d067',
'v1.3.13.71a43a9',
'v1.3.12.6306c53',
'v1.3.11.0411401',
'v1.3.10.cc2a84a',
'v1.3.10.4df0e91',
'v1.3.9.92185e7',
'v1.3.8.90df7c2',
'v1.3.7.bb22b6e',
'v1.3.6.f511bab',
'v1.3.5.e5b19fd',
'v1.3.4.2b20bf3',
'v1.3.3.2fe124e',
'v1.2.testing1',
'v1.2.65.0adc5ce',
'v1.2.64.fc48fcd',
'v1.2.63.9879494',
'v1.2.62.3ddd74e',
'v1.2.61.d551c17',
'v1.2.60.ab959de',
'v1.2.59.d81c1c0',
'v1.2.58.6af1822',
'v1.2.57.f7c6955',
'v1.2.56.596a73c',
'v1.2.55.9db7c62',
'v1.2.54.288f2be',
'v1.2.53.19c1f9f',
'v1.2.52.b63802c',
'v1.2.51.f9ff06b',
'v1.2.50.41dcfdd',
'v1.2.49.5354c49',
'v1.2.48.371335e',
'v1.2.47',
'v1.2.46.dce2fe4',
'v1.2.46.9d21e58',
'v1.2.45.b674054',
'v1.2.44.f2c9c55',
'v1.2.43.a405d81',
'v1.2.42.2759c8d',
'v1.2.41.32f3682',
'v1.2.39.06892c4',
'v1.2.38.cf4e508',
'v1.2.38.451b085',
'v1.2.36',
'v1.2.30.80e4bc6',
'v1.2.29.6c95659',
] as const
export type FirmwareVersion = (typeof VERSIONS)[number]
-32
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;
}
}
-294
View File
@@ -1,294 +0,0 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import PARENT_MAP from '../constants/architecture-hierarchy.json'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function timeAgo(date: number | string | Date): string {
const now = new Date()
const past = new Date(date)
const msPerMinute = 60 * 1000
const msPerHour = msPerMinute * 60
const msPerDay = msPerHour * 24
const msPerMonth = msPerDay * 30
const msPerYear = msPerDay * 365
const elapsed = now.getTime() - past.getTime()
if (elapsed < msPerMinute) {
return `${Math.round(elapsed / 1000)}s ago`
} else if (elapsed < msPerHour) {
return `${Math.round(elapsed / msPerMinute)}m ago`
} else if (elapsed < msPerDay) {
return `${Math.round(elapsed / msPerHour)}h ago`
} else if (elapsed < msPerMonth) {
return `${Math.round(elapsed / msPerDay)}d ago`
} else if (elapsed < msPerYear) {
return `${Math.round(elapsed / msPerMonth)}mo ago`
} else {
return `${Math.round(elapsed / msPerYear)}y ago`
}
}
export function humanizeStatus(status: string): string {
// Handle special statuses
if (status === 'success') return 'Success'
if (status === 'failure') return 'Failure'
if (status === 'queued') return 'Queued'
if (status === 'in_progress') return 'In Progress'
// Convert snake_case/underscore_separated to Title Case
return status
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}
/**
* Resolves plugin dependencies recursively from registry.
* Returns all plugins that should be enabled (selected plugins + their dependencies).
* Filters out "meshtastic" as it's a firmware version requirement, not a plugin.
*/
export function getDependedPlugins(
selectedPlugins: string[],
registry: Record<string, { dependencies?: Record<string, string> }>
): string[] {
const result = new Set<string>()
const visited = new Set<string>()
function resolveDependencies(pluginId: string) {
// Prevent circular dependencies
if (visited.has(pluginId)) return
visited.add(pluginId)
const plugin = registry[pluginId]
if (!plugin || !plugin.dependencies) return
// Process each dependency
for (const [depId] of Object.entries(plugin.dependencies)) {
// Skip "meshtastic" - it's a firmware version requirement, not a plugin
if (depId === 'meshtastic') continue
// Only include dependencies that exist in the registry
if (depId in registry) {
result.add(depId)
// Recursively resolve transitive dependencies
resolveDependencies(depId)
}
}
}
// Start with selected plugins
for (const pluginId of selectedPlugins) {
if (pluginId in registry) {
result.add(pluginId)
resolveDependencies(pluginId)
}
}
return Array.from(result)
}
/**
* Gets only the implicit dependencies (dependencies that are not explicitly selected).
* Returns a set of plugin IDs that are dependencies but not in the explicitly selected list.
*/
export function getImplicitDependencies(
explicitlySelectedPlugins: string[],
registry: Record<string, { dependencies?: Record<string, string> }>
): Set<string> {
const allDependencies = getDependedPlugins(explicitlySelectedPlugins, registry)
const explicitSet = new Set(explicitlySelectedPlugins)
return new Set(allDependencies.filter((id) => !explicitSet.has(id)))
}
/**
* Checks if a plugin is required by any other explicitly selected plugin.
* Returns true if the plugin is a dependency (direct or transitive) of at least one explicitly selected plugin.
*/
export function isRequiredByOther(
pluginId: string,
explicitlySelectedPlugins: string[],
registry: Record<string, { dependencies?: Record<string, string> }>
): boolean {
// Check if any explicitly selected plugin depends on this plugin
for (const selectedId of explicitlySelectedPlugins) {
if (selectedId === pluginId) continue // Skip self
// Get all dependencies (including transitive) of this selected plugin
const allDeps = getDependedPlugins([selectedId], registry)
if (allDeps.includes(pluginId)) {
return true
}
}
return false
}
/**
* Normalize architecture name (remove hyphens and underscores to match PlatformIO format)
* PlatformIO uses "esp32s3", "nrf52840" (no hyphens, no underscores)
* Hardware list uses "esp32-s3" (with hyphens)
* Some sources might use "esp32_s3" (with underscores)
*/
function normalizeArchitecture(arch: string): string {
return arch.replace(/[-_]/g, '')
}
/**
* Trace a target/variant/architecture back to its base architecture
* Follows the parent chain until it reaches a base architecture (null parent)
*/
export function getBaseArchitecture(name: string): string | null {
const normalized = normalizeArchitecture(name)
const parentMap = PARENT_MAP as Record<string, string | null>
const visited = new Set<string>()
let current = normalized
while (current && !visited.has(current)) {
visited.add(current)
const parent = parentMap[current]
// If parent is null, we've reached a base architecture
if (parent === null) {
return current
}
// If no parent found, return current (might be unknown)
if (parent === undefined) {
return current
}
current = normalizeArchitecture(parent)
}
// Circular reference or unknown, return the last known
return current || normalized
}
/**
* Get all compatible architectures for a given architecture
* (including itself and all parent architectures up to base)
*/
export function getCompatibleArchitectures(arch: string): string[] {
const normalized = normalizeArchitecture(arch)
const parentMap = PARENT_MAP as Record<string, string | null>
const compatible = [normalized]
const visited = new Set<string>()
let current = normalized
// Follow parent chain up to base architecture
while (current && !visited.has(current)) {
visited.add(current)
const parent = parentMap[current]
if (parent === null) {
// Reached base architecture
break
}
if (parent === undefined) {
// Unknown, stop here
break
}
const normalizedParent = normalizeArchitecture(parent)
if (!compatible.includes(normalizedParent)) {
compatible.push(normalizedParent)
}
current = normalizedParent
}
return compatible
}
/**
* Check if a plugin is compatible with a target
* Plugin can specify includes/excludes arrays with targets, variant bases, or architectures
*
* @param pluginIncludes - Array of architectures/targets the plugin explicitly supports
* @param pluginExcludes - Array of architectures/targets the plugin explicitly doesn't support
* @param targetName - The target name to check compatibility against
*/
export function isPluginCompatibleWithTarget(
pluginIncludes: string[] | undefined,
pluginExcludes: string[] | undefined,
targetName: string | undefined
): boolean {
// If target not specified, can't determine compatibility
if (!targetName) {
return true // Default to compatible if unknown
}
const parentMap = PARENT_MAP as Record<string, string | null>
// Normalize target name first (all keys in parentMap are normalized)
const normalizedTarget = normalizeArchitecture(targetName)
// Get all compatible names for the target (target itself + all parents up to base architecture)
const compatibleNames = new Set<string>([normalizedTarget])
const visited = new Set<string>()
let current = normalizedTarget
// Follow parent chain (all keys and values in parentMap are already normalized)
while (current && !visited.has(current)) {
visited.add(current)
const parent = parentMap[current]
if (parent === null) {
// Reached base architecture
compatibleNames.add(current) // Add the base architecture itself
break
}
if (parent === undefined) {
// Unknown, stop here
break
}
// Parent is already normalized (from JSON)
compatibleNames.add(parent)
current = parent
}
// Check excludes first - if target matches any exclude, it's incompatible
// compatibleNames are already normalized, normalize excludes for comparison
if (pluginExcludes && pluginExcludes.length > 0) {
const isExcluded = pluginExcludes.some((exclude) => {
const normalizedExclude = normalizeArchitecture(exclude)
return compatibleNames.has(normalizedExclude)
})
if (isExcluded) {
return false
}
}
// If includes are specified, target must match at least one include
// compatibleNames are already normalized, normalize includes for comparison
if (pluginIncludes && pluginIncludes.length > 0) {
return pluginIncludes.some((include) => {
const normalizedInclude = normalizeArchitecture(include)
return compatibleNames.has(normalizedInclude)
})
}
// If no includes/excludes specified, assume compatible with all (backward compatible)
return true
}
/**
* Check if a plugin is compatible with a target architecture
* @deprecated Use isPluginCompatibleWithTarget instead
*/
export function isPluginCompatibleWithArchitecture(
pluginArchitectures: string[] | undefined,
targetArchitecture: string | undefined
): boolean {
// Legacy support: treat architectures array as includes
return isPluginCompatibleWithTarget(pluginArchitectures, undefined, targetArchitecture)
}
-21
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>
)
-259
View File
@@ -1,259 +0,0 @@
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'
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 builds = filter === 'failed' ? failedBuilds : allBuilds
// Show loading state
if (isAdmin === undefined) {
return (
<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
if (isAdmin === false) {
return (
<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>
</div>
</div>
)
}
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.',
})
} catch (error) {
toast.error('Failed to retry build', {
description: String(error),
})
}
}
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleString()
}
const getStatusBadge = (status: string) => {
const statusConfig = {
success: {
bg: 'bg-green-500/20',
text: 'text-green-400',
label: 'Success',
},
failure: { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Failed' },
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',
label: status,
}
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.
</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' : ''}
>
All Builds
</Button>
<Button
variant={filter === 'failed' ? 'default' : 'outline'}
onClick={() => setFilter('failed')}
className={
filter === 'failed' ? 'bg-cyan-600 hover:bg-cyan-700' : ''
}
>
Failed Builds
</Button>
</div>
</header>
<main>
{builds === undefined ? (
<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="space-y-4">
{builds.map((build) => (
<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">
<span className="text-xl font-mono font-semibold text-white">
{build.buildHash.substring(0, 8)}
</span>
{getStatusBadge(build.status)}
</div>
<div className="flex gap-2">
<Button
onClick={() => navigate(`/builds/${build.buildHash}`)}
variant="outline"
size="sm"
className="border-slate-600 hover:bg-slate-800"
>
Public View
</Button>
<Button
onClick={() => navigate(`/builds/new/${build.buildHash}`)}
variant="outline"
size="sm"
className="border-slate-600 hover:bg-slate-800"
>
Clone
</Button>
<Button
onClick={() => handleRetry(build._id)}
className="bg-cyan-600 hover:bg-cyan-700"
size="sm"
>
Re-run Build
</Button>
</div>
</div>
{/* Build Configuration Section */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<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>
<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>
</div>
<div className="space-y-2">
<div>
<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'}
</div>
</div>
</div>
</div>
{/* Run History Section */}
{(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
{(build.githubRunIdHistory?.length ?? 0) > 0 &&
` (${(build.githubRunIdHistory?.length ?? 0) + (build.githubRunId ? 1 : 0)} total)`}
</span>
<div className="flex flex-wrap gap-2">
{build.githubRunId && (
<a
href={`https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-cyan-400 hover:text-cyan-300 underline font-semibold"
title="Current run"
>
{build.githubRunId}
</a>
)}
{build.githubRunIdHistory?.map((id) => (
<a
key={id}
href={`https://github.com/MeshEnvy/mesh-forge/actions/runs/${id}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-cyan-400 hover:text-cyan-300 underline"
>
{id}
</a>
))}
</div>
</div>
)}
{/* Download Actions */}
{build.buildHash && (
<div className="flex gap-3">
{build.status === 'success' && (
<BuildDownloadButton
build={build}
type={ArtifactType.Firmware}
/>
)}
<BuildDownloadButton
build={build}
type={ArtifactType.Source}
/>
</div>
)}
</div>
))}
</div>
)}
</main>
</div>
)
}
-56
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>
)
}
-683
View File
@@ -1,683 +0,0 @@
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 {
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'
type TargetGroup = (typeof TARGETS)[string] & { id: string }
const GROUPED_TARGETS = Object.entries(TARGETS).reduce(
(acc, [id, meta]) => {
const category = meta.category || 'Other'
if (!acc[category]) acc[category] = []
acc[category].push({ id, ...meta })
return acc
},
{} as Record<string, TargetGroup[]>
)
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 ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
const pluginFlashCounts = useQuery(api.plugins.getAll) ?? {}
const sharedBuild = useQuery(
api.builds.getByHash,
buildHashParam ? { buildHash: buildHashParam } : 'skip'
)
const STORAGE_KEY = 'quick_build_target'
const persistTargetSelection = (targetId: string) => {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(STORAGE_KEY, targetId)
} catch (error) {
console.error('Failed to persist target selection', error)
}
}
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>>({})
const [pluginConfig, setPluginConfig] = useState<Record<string, boolean>>({})
const [isFlashing, setIsFlashing] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [showModuleOverrides, setShowModuleOverrides] = useState(false)
const [showPlugins, setShowPlugins] = useState(true)
const [isLoadingSharedBuild, setIsLoadingSharedBuild] = useState(false)
useEffect(() => {
if (!activeCategory && TARGET_CATEGORIES.length > 0) {
setActiveCategory(TARGET_CATEGORIES[0])
}
}, [activeCategory])
useEffect(() => {
if (!selectedTarget && activeCategory) {
const first = GROUPED_TARGETS[activeCategory]?.[0]?.id
if (first) {
setSelectedTarget(first)
}
}
}, [selectedTarget, activeCategory])
useEffect(() => {
if (typeof window === 'undefined') return
try {
const savedTarget = localStorage.getItem(STORAGE_KEY)
if (savedTarget && TARGETS[savedTarget]) {
setSelectedTarget(savedTarget)
const category = TARGETS[savedTarget].category || 'Other'
if (TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
} catch (error) {
console.error('Failed to read saved target', error)
}
}, [])
const handleSelectTarget = (targetId: string) => {
setSelectedTarget(targetId)
persistTargetSelection(targetId)
const category = TARGETS[targetId]?.category || 'Other'
if (category && TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
useEffect(() => {
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)
}
}, [selectedTarget])
// Pre-populate form from shared build
useEffect(() => {
if (!buildHashParam) return
if (sharedBuild === undefined) {
setIsLoadingSharedBuild(true)
return
}
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.',
})
return
}
const config = sharedBuild.config
// Set target and category
if (config.target && TARGETS[config.target]) {
setSelectedTarget(config.target)
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)
) {
setSelectedVersion(config.version as (typeof VERSIONS)[number])
}
// Set module config (already in the correct format)
if (config.modulesExcluded) {
setModuleConfig(config.modulesExcluded)
if (Object.keys(config.modulesExcluded).length > 0) {
setShowModuleOverrides(true)
}
}
// 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
})
// Determine which plugins are required by others (implicit dependencies)
const requiredByOthers = new Set<string>()
for (const pluginSlug of allPluginSlugs) {
if (
isRequiredByOther(
pluginSlug,
allPluginSlugs,
registryData as Record<
string,
{ dependencies?: Record<string, string> }
>
)
) {
requiredByOthers.add(pluginSlug)
}
}
// Only add plugins that are NOT required by others (explicitly selected)
const pluginObj: Record<string, boolean> = {}
allPluginSlugs.forEach((slug) => {
if (slug in registryData && !requiredByOthers.has(slug)) {
pluginObj[slug] = true
}
})
setPluginConfig(pluginObj)
setShowPlugins(true)
}
}, [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 handleToggleModule = (id: string, excluded: boolean) => {
setModuleConfig((prev) => {
const next = { ...prev }
if (excluded) {
next[id] = true
} else {
delete next[id]
}
return next
})
}
const handleTogglePlugin = (id: string, enabled: boolean) => {
// Get current explicit selections
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> }
>
)
// Check if this plugin is required by another explicitly selected plugin
const isRequired = isRequiredByOther(
id,
explicitPlugins,
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) => {
const next = { ...prev }
if (enabled) {
// Enabling: add to explicit selection (even if it was implicit)
next[id] = true
} 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 allStillNeeded = getDependedPlugins(
remainingExplicit,
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)
) {
// 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`
// So this condition should rarely be true, but we keep it as a safety check
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) {
next[pluginId] = true
}
}
return next
})
}
const handleFlash = async () => {
if (!selectedTarget) return
setIsFlashing(true)
setErrorMessage(null)
try {
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> }
>
)
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({
target: selectedTarget,
version: selectedVersion,
modulesExcluded: moduleConfig,
pluginsEnabled: pluginsEnabled.length > 0 ? pluginsEnabled : undefined,
})
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', {
description: message,
})
} finally {
setIsFlashing(false)
}
}
const isFlashDisabled = !selectedTarget || isFlashing
if (isLoadingSharedBuild) {
return (
<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>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-slate-950 text-white p-6 md:p-10">
<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-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.
</p>
</div>
</div>
<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) => {
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>
<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>
)
}
)}
</div>
</div>
</div>
<div>
<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)}
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) => (
<option key={version} value={version}>
{version}
</option>
))}
</select>
</div>
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
<button
type="button"
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.`}
</p>
</div>
{showModuleOverrides ? (
<ChevronDown className="w-4 h-4 text-slate-400" />
) : (
<ChevronRight className="w-4 h-4 text-slate-400" />
)}
</button>
{showModuleOverrides && (
<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.
</p>
</div>
<div className="flex justify-end">
<button
type="button"
className="text-xs text-slate-400 hover:text-white underline"
onClick={() => setModuleConfig({})}
disabled={moduleCount === 0}
>
Reset overrides
</button>
</div>
<div className="grid gap-2 md:grid-cols-2">
{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)
}
/>
))}
</div>
</div>
)}
</div>
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
<button
type="button"
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.`}
</p>
</div>
{showPlugins ? (
<ChevronDown className="w-4 h-4 text-slate-400" />
) : (
<ChevronRight className="w-4 h-4 text-slate-400" />
)}
</button>
{showPlugins && (
<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.
</p>
</div>
<div className="flex justify-end">
<button
type="button"
className="text-xs text-slate-400 hover:text-white underline"
onClick={() => setPluginConfig({})}
disabled={pluginCount === 0}
>
Reset plugins
</button>
</div>
<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
)
// Compute implicit dependencies (dependencies that are not explicitly selected)
const implicitDeps = getImplicitDependencies(
explicitPlugins,
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> }
>
)
return Object.entries(registryData)
.sort(([, pluginA], [, pluginB]) => {
// Featured plugins first
const featuredA = pluginA.featured ?? false
const featuredB = pluginB.featured ?? false
if (featuredA !== featuredB) {
return featuredA ? -1 : 1
}
// Then alphabetical by name
return pluginA.name.localeCompare(pluginB.name)
})
.map(([slug, plugin]) => {
// Check if plugin is required by another explicitly selected plugin
const isRequired = isRequiredByOther(
slug,
explicitPlugins,
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)
// 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) ||
(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
// Mark as incompatible if plugin has compatibility constraints and target is not compatible
const isIncompatible = !isCompatible && hasCompatibilityConstraints && !!selectedTarget
return (
<PluginToggle
key={`${slug}-${selectedTarget}`}
id={slug}
name={plugin.name}
description={plugin.description}
isEnabled={allEnabledPlugins.includes(slug)}
onToggle={(enabled) =>
handleTogglePlugin(slug, enabled)
}
disabled={isImplicit || isIncompatible}
enabledLabel={isImplicit ? 'Required' : 'Add'}
incompatibleReason={
isIncompatible
? 'Not compatible with this target'
: undefined
}
featured={plugin.featured ?? false}
flashCount={pluginFlashCounts[slug] ?? 0}
homepage={plugin.homepage}
version={plugin.version}
/>
)
})
})()}
</div>
</div>
)}
</div>
<div className="space-y-2">
<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'
)}
</Button>
{errorMessage && (
<p className="text-sm text-red-400">{errorMessage}</p>
)}
</div>
</div>
</div>
</div>
)
}
-608
View File
@@ -1,608 +0,0 @@
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'
export default function BuildProgress() {
const { buildHash } = useParams<{ buildHash: string }>()
const navigate = useNavigate()
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)
if (!buildHash) {
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">
Build hash missing.{' '}
<Link to="/builds/new" className="text-cyan-400">
Start a new build
</Link>
.
</p>
</div>
</div>
)
}
if (build === 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 space-y-4">
<Link
to={`/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>
<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>
</p>
</div>
</div>
</div>
)
}
const targetMeta = build.config.target
? TARGETS[build.config.target]
: undefined
const targetLabel = targetMeta?.name ?? build.config.target
const status = build.status || 'queued'
const getStatusIcon = () => {
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 getStatusColor = () => {
if (status === 'success') return 'text-green-400'
if (status === 'failure') return 'text-red-400'
return 'text-blue-400'
}
const githubActionUrl =
build.githubRunId && build.githubRunId > 0
? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`
: null
const shareUrl = `${window.location.origin}/builds/new/${build.buildHash}`
const handleShare = async () => {
try {
await navigator.clipboard.writeText(shareUrl)
setShareUrlCopied(true)
toast.success('Share link copied to clipboard')
setTimeout(() => setShareUrlCopied(false), 2000)
} catch {
toast.error('Failed to copy link', {
description: 'Please copy the URL manually',
})
}
}
// Compute build flags from config (same logic as computeFlagsFromConfig in convex/builds.ts)
const computeFlagsFromConfig = (config: typeof build.config): string => {
return Object.keys(config.modulesExcluded)
.sort()
.filter((module) => config.modulesExcluded[module])
.map((moduleExcludedName: string) => `-D${moduleExcludedName}=1`)
.join(' ')
}
// Generate GitHub discussion URL with prefilled body
const generateDiscussionUrl = (): string => {
const flags = computeFlagsFromConfig(build.config)
const plugins = build.config.pluginsEnabled?.join(', ') || '(none)'
const timestamp = new Date(build.startedAt).toISOString()
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 bracketContent = [
build.config.target,
`v${build.config.version}`,
...(formattedPlugins ? [formattedPlugins] : []),
].join(' ')
const discussionTitle = `Build ${build.status === 'failure' ? 'Failed' : 'Issue'}: ${targetLabel} [${bracketContent}]`
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)'}
**Plugins**: ${plugins}
**Build Timestamp**: ${timestamp}
**Build Page**: [View build page](${buildPageUrl})
**GitHub Run**: ${githubRunLink}
## Additional Information
(Please add any additional details about the issue here)`
const baseUrl = 'https://github.com/MeshEnvy/mesh-forge/discussions/new'
const params = new URLSearchParams({
category: 'q-a',
title: discussionTitle,
body: discussionBody,
})
return `${baseUrl}?${params.toString()}`
}
const handleReportIssue = () => {
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.',
})
} catch (error) {
toast.error('Failed to retry build', {
description: String(error),
})
}
}
const getStatusBadge = (status: string) => {
const statusConfig = {
success: {
bg: 'bg-green-500/20',
text: 'text-green-400',
label: 'Success',
},
failure: { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Failed' },
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',
label: status,
}
return (
<span className={`px-2 py-1 ${config.bg} ${config.text} rounded text-sm`}>
{config.label}
</span>
)
}
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleString()
}
// Get excluded modules
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
}
)
// Get implicit dependencies (dependencies that are not explicitly selected)
const implicitDeps = getImplicitDependencies(
explicitPluginSlugs,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Separate explicit and implicit plugins
const explicitPlugins: Array<{
id: string
name: string
description: string
version: string
}> = []
const implicitPlugins: Array<{
id: string
name: string
description: string
version: string
}> = []
// Process explicitly selected plugins
;(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 pluginInfo = {
id: slug,
name: pluginData?.name || slug,
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]
if (pluginData) {
implicitPlugins.push({
id: slug,
name: pluginData.name || slug,
description: pluginData.description || '',
version: pluginData.version || '',
})
}
}
return (
<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}`}
className="inline-flex items-center text-slate-400 hover:text-white"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Quick Build
</Link>
</div>
<div className="bg-slate-900/60 rounded-lg border border-slate-800 p-6 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
{getStatusIcon()}
<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()}>
{humanizeStatus(status)}
</span>
<span></span>
<span>{new Date(build.updatedAt).toLocaleString()}</span>
{githubActionUrl && (
<>
<span></span>
<a
href={githubActionUrl}
target="_blank"
rel="noopener noreferrer"
className="text-slate-500 hover:text-slate-300"
>
View run
</a>
</>
)}
</div>
</div>
</div>
<div className="flex gap-2">
<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"
>
<Share2 className="w-4 h-4 mr-2" />
{shareUrlCopied ? 'Copied!' : 'Share Build'}
</Button>
</div>
</div>
{/* Admin Controls Section */}
{isAdmin === true && build && (
<div className="border-t border-slate-800 pt-4 mt-4 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h3 className="text-lg font-semibold mb-2">Admin Controls</h3>
<div className="flex items-center gap-3 mb-2">
<span className="text-sm font-mono font-semibold text-white">
{build.buildHash.substring(0, 8)}
</span>
{getStatusBadge(build.status)}
</div>
</div>
<div className="flex gap-2">
<Button
onClick={() => navigate(`/builds/new/${build.buildHash}`)}
variant="outline"
size="sm"
className="border-slate-600 hover:bg-slate-800"
>
Clone
</Button>
<Button
onClick={handleRetry}
className="bg-cyan-600 hover:bg-cyan-700"
size="sm"
>
Re-run Build
</Button>
</div>
</div>
{/* Build Configuration Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<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>
<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>
</div>
<div className="space-y-2">
<div>
<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'}
</div>
</div>
</div>
</div>
{/* Run History Section */}
{(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
{(build.githubRunIdHistory?.length ?? 0) > 0 &&
` (${(build.githubRunIdHistory?.length ?? 0) + (build.githubRunId ? 1 : 0)} total)`}
</span>
<div className="flex flex-wrap gap-2">
{build.githubRunId && (
<a
href={`https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-cyan-400 hover:text-cyan-300 underline font-semibold"
title="Current run"
>
{build.githubRunId}
</a>
)}
{build.githubRunIdHistory?.map((id) => (
<a
key={id}
href={`https://github.com/MeshEnvy/mesh-forge/actions/runs/${id}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-cyan-400 hover:text-cyan-300 underline"
>
{id}
</a>
))}
</div>
</div>
)}
</div>
)}
{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.
</p>
</div>
)}
{/* Build Configuration Summary */}
{(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 && (
<div>
<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>
</div>
))}
</div>
</div>
</div>
)}
{/* Enabled Plugins */}
{explicitPlugins.length > 0 && (
<div>
<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"
>
<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>
)}
</div>
{plugin.description && (
<p className="text-slate-400 text-sm">
{plugin.description}
</p>
)}
</div>
))}
</div>
</div>
</div>
)}
{/* Required Plugins (Implicit Dependencies) */}
{implicitPlugins.length > 0 && (
<div>
<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"
>
<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>
)}
</div>
{plugin.description && (
<p className="text-slate-400 text-sm">
{plugin.description}
</p>
)}
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
{status === 'success' && build.buildHash && (
<BuildDownloadButton
build={build}
type={ArtifactType.Firmware}
className="w-full bg-cyan-600 hover:bg-cyan-700"
/>
)}
{build.buildHash && (
<BuildDownloadButton
build={build}
type={ArtifactType.Source}
variant="outline"
className="w-full bg-slate-700 hover:bg-slate-600"
/>
)}
{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.
</p>
</div>
)}
{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.
</p>
</div>
)}
</div>
</div>
</div>
)
}
-107
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>
)
}
-110
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>
)
}
-236
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>
)
}
-86
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>
)
}
-283
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
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />