mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-08-07 09:22:56 +02:00
vike migration
This commit is contained in:
-42
@@ -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
@@ -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 +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 |
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}</>
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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]
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 device—no local
|
||||
setup required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user