fix: refactor build routes for improved navigation

This commit is contained in:
Ben Allfree
2025-12-10 17:47:38 -08:00
parent cdc4959dca
commit bdb848e15f
18 changed files with 1125 additions and 927 deletions

View File

@@ -13,10 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Refactored targets.ts to use vendors.json and architecture-hierarchy.json instead of hardware-list.json - Refactored targets.ts to use vendors.json and architecture-hierarchy.json instead of hardware-list.json
- Updated architecture-hierarchy.json generation to use actual PlatformIO environment names (removed normalization) - Updated architecture-hierarchy.json generation to use actual PlatformIO environment names (removed normalization)
- Removed normalization from lib/utils.ts since all inputs now use standardized PlatformIO names - Removed normalization from lib/utils.ts since all inputs now use standardized PlatformIO names
- Refactored build routes from dynamic parameterized routes to query string parameters for Vike SSG compatibility
### Patch ### Patch
- Fix Convex server functions being imported in browser by moving ArtifactType enum to client-safe location - Fix Convex server functions being imported in browser by moving ArtifactType enum to client-safe location
- Fix nested anchor tag hydration error in PluginCard component by converting nested links to buttons when parent is a link
## [0.3.0] - 2025-12-10 ## [0.3.0] - 2025-12-10

View File

@@ -0,0 +1,34 @@
import { Button } from "@/components/ui/button"
import { Loader2 } from "lucide-react"
interface BuildActionsProps {
selectedTargetLabel: string
isFlashing: boolean
isFlashDisabled: boolean
errorMessage: string | null
onFlash: () => void
}
export function BuildActions({
selectedTargetLabel,
isFlashing,
isFlashDisabled,
errorMessage,
onFlash,
}: BuildActionsProps) {
return (
<div className="space-y-2">
<Button onClick={onFlash} 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>
)
}

View File

@@ -48,7 +48,7 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}` ? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`
: null : null
const shareUrl = `${window.location.origin}/builds/new/${build.buildHash}` const shareUrl = `${window.location.origin}/builds?clone=${build.buildHash}`
const handleShare = async () => { const handleShare = async () => {
try { try {
@@ -216,10 +216,10 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
<h2 className="text-2xl font-semibold mb-2 flex items-center gap-2"> <h2 className="text-2xl font-semibold mb-2 flex items-center gap-2">
{getStatusIcon()} {getStatusIcon()}
<a <a
href={`/builds/${build.buildHash}`} href={`/builds?id=${build.buildHash}`}
onClick={e => { onClick={e => {
e.preventDefault() e.preventDefault()
navigate(`/builds/${build.buildHash}`) navigate(`/builds?id=${build.buildHash}`)
}} }}
className="hover:text-cyan-400 transition-colors" className="hover:text-cyan-400 transition-colors"
> >
@@ -257,7 +257,7 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
{showActions && ( {showActions && (
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={() => navigate(`/builds/new/${build.buildHash}`)} onClick={() => navigate(`/builds?clone=${build.buildHash}`)}
variant="outline" variant="outline"
className="border-slate-600 hover:bg-slate-800" className="border-slate-600 hover:bg-slate-800"
aria-label="Clone" aria-label="Clone"

296
components/Builder.tsx Normal file
View File

@@ -0,0 +1,296 @@
import { BuildActions } from "@/components/BuildActions"
import { BuilderHeader } from "@/components/BuilderHeader"
import { ModuleConfig } from "@/components/ModuleConfig"
import { PluginConfig } from "@/components/PluginConfig"
import { TargetSelector } from "@/components/TargetSelector"
import { VersionSelector } from "@/components/VersionSelector"
import { TARGETS } from "@/constants/targets"
import { VERSIONS } from "@/constants/versions"
import { api } from "@/convex/_generated/api"
import { usePluginCompatibility } from "@/hooks/usePluginCompatibility"
import { useTargetSelection } from "@/hooks/useTargetSelection"
import { getDependedPlugins, getImplicitDependencies, isRequiredByOther } from "@/lib/utils"
import registryData from "@/public/registry.json"
import { useMutation, useQuery } from "convex/react"
import { Loader2 } from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { navigate } from "vike/client/router"
interface BuilderProps {
cloneHash?: string
pluginParam?: string
}
export default function Builder({ cloneHash, pluginParam }: BuilderProps) {
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
const pluginFlashCounts = useQuery(api.plugins.getAll) ?? {}
const sharedBuild = useQuery(api.builds.getByHash, cloneHash ? { buildHash: cloneHash } : "skip")
const preselectedPlugin =
pluginParam && pluginParam in registryData
? (
registryData as Record<
string,
{ includes?: string[]; name: string; description: string; imageUrl?: string; featured?: boolean }
>
)[pluginParam]
: null
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)
// Get all enabled plugins
const enabledPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
// Calculate plugin compatibility
const { compatibleTargets, filteredGroupedTargets, filteredTargetCategories } = usePluginCompatibility(
enabledPlugins,
preselectedPlugin
)
// Target selection logic
const { activeCategory, selectedTarget, setActiveCategory, handleSelectTarget, TARGET_CATEGORIES, GROUPED_TARGETS } =
useTargetSelection(compatibleTargets, filteredGroupedTargets, filteredTargetCategories)
// Preselect plugin from URL parameter
useEffect(() => {
if (pluginParam && preselectedPlugin && !cloneHash) {
setPluginConfig({ [pluginParam]: true })
setShowPlugins(true)
}
}, [pluginParam, preselectedPlugin, cloneHash])
// Pre-populate form from shared build
useEffect(() => {
if (!cloneHash) 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
if (config.target && TARGETS[config.target]) {
handleSelectTarget(config.target)
const category = TARGETS[config.target].category || "Other"
if (TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
if (config.version && (VERSIONS as readonly string[]).includes(config.version)) {
setSelectedVersion(config.version as (typeof VERSIONS)[number])
}
if (config.modulesExcluded) {
setModuleConfig(config.modulesExcluded)
if (Object.keys(config.modulesExcluded).length > 0) {
setShowModuleOverrides(true)
}
}
if (config.pluginsEnabled && config.pluginsEnabled.length > 0) {
const allPluginSlugs = config.pluginsEnabled.map(pluginId => {
return pluginId.includes("@") ? pluginId.split("@")[0] : pluginId
})
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)
}
}
const pluginObj: Record<string, boolean> = {}
allPluginSlugs.forEach(slug => {
if (slug in registryData && !requiredByOthers.has(slug)) {
pluginObj[slug] = true
}
})
setPluginConfig(pluginObj)
setShowPlugins(true)
}
}, [cloneHash, sharedBuild, handleSelectTarget, setActiveCategory, TARGET_CATEGORIES])
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) => {
const explicitPlugins = Object.keys(pluginConfig).filter(pluginId => pluginConfig[pluginId] === true)
const implicitDeps = getImplicitDependencies(
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
const isRequired = isRequiredByOther(
id,
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
if (implicitDeps.has(id)) {
return
}
if (!enabled && isRequired) {
return
}
setPluginConfig(prev => {
const next = { ...prev }
if (enabled) {
next[id] = true
} else {
delete next[id]
const remainingExplicit = Object.keys(next).filter(pluginId => next[pluginId] === true)
const allStillNeeded = getDependedPlugins(
remainingExplicit,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
for (const pluginId of Object.keys(next)) {
if (next[pluginId] === true && !allStillNeeded.includes(pluginId) && !remainingExplicit.includes(pluginId)) {
delete next[pluginId]
}
}
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)
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?id=${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)
}
}
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>
)
}
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
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">
<BuilderHeader preselectedPlugin={preselectedPlugin} />
<div className="space-y-6 bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<TargetSelector
activeCategory={activeCategory}
categories={categories}
groupedTargets={compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS}
selectedTarget={selectedTarget}
compatibleTargets={compatibleTargets}
onCategoryChange={setActiveCategory}
onTargetSelect={handleSelectTarget}
/>
<VersionSelector selectedVersion={selectedVersion} onVersionChange={setSelectedVersion} />
<ModuleConfig
moduleConfig={moduleConfig}
showModuleOverrides={showModuleOverrides}
onToggleShow={() => setShowModuleOverrides(prev => !prev)}
onToggleModule={handleToggleModule}
onReset={() => setModuleConfig({})}
/>
<PluginConfig
pluginConfig={pluginConfig}
selectedTarget={selectedTarget}
pluginParam={pluginParam}
pluginFlashCounts={pluginFlashCounts}
showPlugins={showPlugins}
onToggleShow={() => setShowPlugins(prev => !prev)}
onTogglePlugin={handleTogglePlugin}
onReset={() => setPluginConfig({})}
/>
<BuildActions
selectedTargetLabel={selectedTargetLabel}
isFlashing={isFlashing}
isFlashDisabled={!selectedTarget || isFlashing}
errorMessage={errorMessage}
onFlash={handleFlash}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,64 @@
import { CheckCircle2 } from "lucide-react"
interface BuilderHeaderProps {
preselectedPlugin?: {
name: string
description: string
imageUrl?: string
featured?: boolean
includes?: string[]
} | null
}
export function BuilderHeader({ preselectedPlugin }: BuilderHeaderProps) {
return (
<>
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<p className="text-sm uppercase tracking-wider text-slate-500">
{preselectedPlugin ? "Plugin build" : "Quick build"}
</p>
<h1 className="text-4xl font-bold mt-1">
{preselectedPlugin ? `Build firmware for ${preselectedPlugin.name}` : "Flash a custom firmware version"}
</h1>
<p className="text-slate-400 mt-2 max-w-2xl">
{preselectedPlugin
? `Select a compatible Meshtastic target and configure your build for ${preselectedPlugin.name}. We'll send you to the build status page as soon as it starts.`
: "Choose your Meshtastic target, adjust optional modules, and queue a new build instantly. We'll send you to the build status page as soon as it starts."}
</p>
</div>
</div>
{preselectedPlugin && (
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<div className="flex items-start gap-4">
<CheckCircle2 className="w-8 h-8 text-green-400 shrink-0 mt-1" />
<div className="flex items-start gap-4 flex-1">
{preselectedPlugin.imageUrl && (
<img
src={preselectedPlugin.imageUrl}
alt={`${preselectedPlugin.name} logo`}
className="w-16 h-16 rounded-lg object-contain shrink-0"
/>
)}
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h2 className="text-2xl font-bold">{preselectedPlugin.name}</h2>
{preselectedPlugin.featured && (
<span className="px-2 py-1 text-xs font-medium text-green-400 bg-green-400/10 border border-green-400/20 rounded">
Featured
</span>
)}
</div>
<p className="text-slate-400 mb-3">{preselectedPlugin.description}</p>
{preselectedPlugin.includes && preselectedPlugin.includes.length > 0 && (
<p className="text-sm text-slate-500">Compatible with: {preselectedPlugin.includes.join(", ")}</p>
)}
</div>
</div>
</div>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,74 @@
import { ModuleToggle } from "@/components/ModuleToggle"
import modulesData from "@/convex/modules.json"
import { ChevronDown, ChevronRight } from "lucide-react"
interface ModuleConfigProps {
moduleConfig: Record<string, boolean>
showModuleOverrides: boolean
onToggleShow: () => void
onToggleModule: (id: string, excluded: boolean) => void
onReset: () => void
}
export function ModuleConfig({
moduleConfig,
showModuleOverrides,
onToggleShow,
onToggleModule,
onReset,
}: ModuleConfigProps) {
const moduleCount = Object.keys(moduleConfig).length
return (
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
<button type="button" onClick={onToggleShow} 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={onReset}
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 => onToggleModule(module.id, excluded)}
/>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -205,42 +205,87 @@ export function PluginCard(props: PluginCardProps) {
<span>{downloads.toLocaleString()}</span> <span>{downloads.toLocaleString()}</span>
</div> </div>
)} )}
{homepage && homepage !== repo && (isLink || isLinkToggle) && ( {homepage &&
<a homepage !== repo &&
href={homepage} (isLink || isLinkToggle) &&
target="_blank" (isLink ? (
rel="noopener noreferrer" <button
onClick={e => e.stopPropagation()} type="button"
className="hover:opacity-80 transition-opacity" onClick={e => {
> e.preventDefault()
<svg e.stopPropagation()
xmlns="http://www.w3.org/2000/svg" window.open(homepage, "_blank", "noopener,noreferrer")
width="16" }}
height="16" className="hover:opacity-80 transition-opacity"
viewBox="0 0 24 24"
className="text-slate-400"
fill="currentColor"
role="img"
aria-label="Homepage" aria-label="Homepage"
> >
<path <svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
className="text-slate-400"
fill="currentColor" fill="currentColor"
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825" role="img"
/> aria-label="Homepage"
</svg> >
</a> <path
)} fill="currentColor"
{starsBadgeUrl && repo && ( d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
<a />
href={repo} </svg>
target="_blank" </button>
rel="noopener noreferrer" ) : (
onClick={e => e.stopPropagation()} <a
className="hover:opacity-80 transition-opacity" href={homepage}
> target="_blank"
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" /> rel="noopener noreferrer"
</a> onClick={e => e.stopPropagation()}
)} className="hover:opacity-80 transition-opacity"
>
<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="Homepage"
>
<path
fill="currentColor"
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
/>
</svg>
</a>
))}
{starsBadgeUrl &&
repo &&
(isLink ? (
<button
type="button"
onClick={e => {
e.preventDefault()
e.stopPropagation()
window.open(repo, "_blank", "noopener,noreferrer")
}}
className="hover:opacity-80 transition-opacity"
aria-label="GitHub repository"
>
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
</button>
) : (
<a
href={repo}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="hover:opacity-80 transition-opacity"
>
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
</a>
))}
</div> </div>
{/* Build Now button - absolutely positioned in lower right */} {/* Build Now button - absolutely positioned in lower right */}
{isLink && ( {isLink && (
@@ -249,7 +294,7 @@ export function PluginCard(props: PluginCardProps) {
onClick={e => { onClick={e => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
navigate(`/builds/new?plugin=${id}`) navigate(`/builds?plugin=${id}`)
}} }}
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 rounded hover:bg-cyan-400/20 transition-colors cursor-pointer" className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 rounded hover:bg-cyan-400/20 transition-colors cursor-pointer"
> >
@@ -287,7 +332,7 @@ export function PluginCard(props: PluginCardProps) {
} ${isLink ? "group" : ""}` } ${isLink ? "group" : ""}`
if (isLink) { if (isLink) {
const href = props.href || `/builds/new?plugin=${id}` const href = props.href || `/builds?plugin=${id}`
return ( return (
<a href={href} className={baseClassName}> <a href={href} className={baseClassName}>
{cardContent} {cardContent}

158
components/PluginConfig.tsx Normal file
View File

@@ -0,0 +1,158 @@
import { PluginCard } from "@/components/PluginCard"
import {
getDependedPlugins,
getImplicitDependencies,
isPluginCompatibleWithTarget,
isRequiredByOther,
} from "@/lib/utils"
import registryData from "@/public/registry.json"
import { ChevronDown, ChevronRight } from "lucide-react"
interface PluginConfigProps {
pluginConfig: Record<string, boolean>
selectedTarget: string
pluginParam?: string
pluginFlashCounts: Record<string, number>
showPlugins: boolean
onToggleShow: () => void
onTogglePlugin: (id: string, enabled: boolean) => void
onReset: () => void
}
export function PluginConfig({
pluginConfig,
selectedTarget,
pluginParam,
pluginFlashCounts,
showPlugins,
onToggleShow,
onTogglePlugin,
onReset,
}: PluginConfigProps) {
const pluginCount = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true).length
// 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 (
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
<button type="button" onClick={onToggleShow} 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={onReset}
disabled={pluginCount === 0}
>
Reset plugins
</button>
</div>
<div className="grid gap-2 md:grid-cols-2" key={`plugins-${selectedTarget}`}>
{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
// Check if this is the preselected plugin from URL
const isPreselected = pluginParam === slug
return (
<PluginCard
key={`${slug}-${selectedTarget}`}
variant="link-toggle"
id={slug}
name={plugin.name}
description={plugin.description}
imageUrl={plugin.imageUrl}
isEnabled={allEnabledPlugins.includes(slug)}
onToggle={enabled => onTogglePlugin(slug, enabled)}
disabled={isImplicit || isIncompatible || isPreselected}
enabledLabel={isPreselected ? "Locked" : 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}
repo={plugin.repo}
/>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { TARGETS } from "@/constants/targets"
type TargetGroup = (typeof TARGETS)[string] & { id: string }
interface TargetSelectorProps {
activeCategory: string
categories: string[]
groupedTargets: Record<string, TargetGroup[]>
selectedTarget: string
compatibleTargets: Set<string> | null
onCategoryChange: (category: string) => void
onTargetSelect: (targetId: string) => void
}
export function TargetSelector({
activeCategory,
categories,
groupedTargets,
selectedTarget,
compatibleTargets,
onCategoryChange,
onTargetSelect,
}: TargetSelectorProps) {
const targets = activeCategory ? groupedTargets[activeCategory] : []
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{categories.map(category => {
const isActive = activeCategory === category
return (
<button
key={category}
type="button"
onClick={() => onCategoryChange(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">
{targets?.map(target => {
const isSelected = selectedTarget === target.id
const normalizedId = target.id.replace(/[-_]/g, "")
const isCompatible =
!compatibleTargets || compatibleTargets.has(target.id) || compatibleTargets.has(normalizedId)
return (
<button
key={target.id}
type="button"
onClick={() => isCompatible && onTargetSelect(target.id)}
disabled={!isCompatible}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isSelected
? "bg-cyan-600 text-white"
: isCompatible
? "bg-slate-800 text-slate-300 hover:bg-slate-700"
: "bg-slate-800/50 text-slate-500 cursor-not-allowed opacity-50"
}`}
>
{target.name}
</button>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { VERSIONS } from "@/constants/versions"
interface VersionSelectorProps {
selectedVersion: string
onVersionChange: (version: string) => void
}
export function VersionSelector({ selectedVersion, onVersionChange }: VersionSelectorProps) {
return (
<div>
<label htmlFor="build-version" className="block text-sm font-medium mb-2">
Firmware version
</label>
<select
id="build-version"
value={selectedVersion}
onChange={event => onVersionChange(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>
)
}

View File

@@ -0,0 +1,74 @@
import { TARGETS } from "@/constants/targets"
import { getTargetsCompatibleWithIncludes } from "@/lib/utils"
import registryData from "@/public/registry.json"
type TargetGroup = (typeof TARGETS)[string] & { id: string }
const GROUPED_TARGETS = Object.entries(TARGETS).reduce(
(acc, [id, meta]) => {
const category = meta.category || "Other"
if (!acc[category]) acc[category] = []
acc[category].push({ id, ...meta })
return acc
},
{} as Record<string, TargetGroup[]>
)
export function usePluginCompatibility(enabledPlugins: string[], preselectedPlugin?: { includes?: string[] } | null) {
// Start with preselected plugin compatibility if present
let compatibleTargets: Set<string> | null = preselectedPlugin?.includes
? getTargetsCompatibleWithIncludes(preselectedPlugin.includes)
: null
// Intersect with compatibility of all enabled plugins
if (enabledPlugins.length > 0) {
const pluginRegistry = registryData as Record<string, { includes?: string[] }>
const allCompatibleSets: Set<string>[] = []
for (const pluginId of enabledPlugins) {
const plugin = pluginRegistry[pluginId]
if (plugin?.includes && plugin.includes.length > 0) {
allCompatibleSets.push(getTargetsCompatibleWithIncludes(plugin.includes))
}
}
if (allCompatibleSets.length > 0) {
if (compatibleTargets) {
compatibleTargets = new Set(
Array.from(compatibleTargets).filter(target => allCompatibleSets.every(set => set.has(target)))
)
} else {
compatibleTargets = allCompatibleSets[0]
for (let i = 1; i < allCompatibleSets.length; i++) {
compatibleTargets = new Set(Array.from(compatibleTargets).filter(target => allCompatibleSets[i].has(target)))
}
}
} else if (!compatibleTargets) {
compatibleTargets = null
}
}
const filteredGroupedTargets = compatibleTargets
? Object.entries(GROUPED_TARGETS).reduce(
(acc, [category, targets]) => {
const filtered = targets.filter(target => {
const normalizedId = target.id.replace(/[-_]/g, "")
return compatibleTargets.has(target.id) || compatibleTargets.has(normalizedId)
})
if (filtered.length > 0) {
acc[category] = filtered
}
return acc
},
{} as Record<string, TargetGroup[]>
)
: GROUPED_TARGETS
const filteredTargetCategories = Object.keys(filteredGroupedTargets).sort((a, b) => a.localeCompare(b))
return {
compatibleTargets,
filteredGroupedTargets,
filteredTargetCategories,
}
}

215
hooks/useTargetSelection.ts Normal file
View File

@@ -0,0 +1,215 @@
import { TARGETS } from "@/constants/targets"
import { useEffect, useState } from "react"
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
: ""
const STORAGE_KEY = "quick_build_target"
const getStorageKeyForCategory = (category: string) => `quick_build_target_${category}`
export function useTargetSelection(
compatibleTargets: Set<string> | null,
filteredGroupedTargets: Record<string, TargetGroup[]>,
filteredTargetCategories: string[]
) {
const [activeCategory, setActiveCategory] = useState<string>(TARGET_CATEGORIES[0] ?? "")
const [selectedTarget, setSelectedTarget] = useState<string>(DEFAULT_TARGET)
const persistTargetSelection = (targetId: string, category?: string) => {
if (typeof window === "undefined") return
try {
window.localStorage.setItem(STORAGE_KEY, targetId)
if (category) {
window.localStorage.setItem(getStorageKeyForCategory(category), targetId)
}
} catch (error) {
console.error("Failed to persist target selection", error)
}
}
const getSavedTargetForCategory = (category: string): string | null => {
if (typeof window === "undefined") return null
try {
return window.localStorage.getItem(getStorageKeyForCategory(category))
} catch (error) {
console.error("Failed to read saved target for category", error)
return null
}
}
const handleSelectTarget = (targetId: string) => {
if (compatibleTargets) {
const normalizedId = targetId.replace(/[-_]/g, "")
const isCompatible = compatibleTargets.has(targetId) || compatibleTargets.has(normalizedId)
if (!isCompatible) {
return
}
}
setSelectedTarget(targetId)
const category = TARGETS[targetId]?.category || "Other"
persistTargetSelection(targetId, category)
if (category && TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
// Initialize active category
useEffect(() => {
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
if (!activeCategory && categories.length > 0) {
setActiveCategory(categories[0])
}
}, [activeCategory, compatibleTargets, filteredTargetCategories])
// Handle category change - auto-select target
useEffect(() => {
if (activeCategory) {
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
const categoryTargets = targets[activeCategory] || []
if (categoryTargets.length === 0) return
const isCurrentTargetInCategory = categoryTargets.some(t => t.id === selectedTarget)
if (!isCurrentTargetInCategory) {
const savedTargetForCategory = getSavedTargetForCategory(activeCategory)
const isValidSavedTarget = savedTargetForCategory && categoryTargets.some(t => t.id === savedTargetForCategory)
if (isValidSavedTarget) {
setSelectedTarget(savedTargetForCategory)
persistTargetSelection(savedTargetForCategory, activeCategory)
} else {
const firstTarget = categoryTargets[0].id
setSelectedTarget(firstTarget)
persistTargetSelection(firstTarget, activeCategory)
}
}
}
}, [activeCategory, compatibleTargets, filteredGroupedTargets, selectedTarget])
// Restore saved target on mount
useEffect(() => {
if (typeof window === "undefined") return
try {
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
if (categories.length === 0) return
const savedTarget = localStorage.getItem(STORAGE_KEY)
if (savedTarget && TARGETS[savedTarget]) {
const isCompatible = Object.values(targets).some(categoryTargets =>
categoryTargets.some(target => target.id === savedTarget)
)
if (isCompatible) {
const category = TARGETS[savedTarget].category || "Other"
if (categories.includes(category)) {
setActiveCategory(category)
setSelectedTarget(savedTarget)
persistTargetSelection(savedTarget, category)
return
}
}
}
const firstCategory = categories[0]
const categoryTargets = targets[firstCategory] || []
if (categoryTargets.length > 0) {
const savedTargetForCategory = getSavedTargetForCategory(firstCategory)
const isValidSavedTarget = savedTargetForCategory && categoryTargets.some(t => t.id === savedTargetForCategory)
if (isValidSavedTarget) {
setActiveCategory(firstCategory)
setSelectedTarget(savedTargetForCategory)
persistTargetSelection(savedTargetForCategory, firstCategory)
} else {
const firstTarget = categoryTargets[0].id
setActiveCategory(firstCategory)
setSelectedTarget(firstTarget)
persistTargetSelection(firstTarget, firstCategory)
}
}
} catch (error) {
console.error("Failed to read saved target", error)
}
}, [compatibleTargets, filteredGroupedTargets, filteredTargetCategories])
// Update selected target if it becomes incompatible
useEffect(() => {
if (!selectedTarget || !compatibleTargets) return
const normalizedId = selectedTarget.replace(/[-_]/g, "")
const isCompatible = compatibleTargets.has(selectedTarget) || compatibleTargets.has(normalizedId)
if (!isCompatible) {
const targets = filteredGroupedTargets
const categories = filteredTargetCategories
if (categories.length > 0) {
const currentCategory = TARGETS[selectedTarget]?.category
if (currentCategory && targets[currentCategory] && targets[currentCategory].length > 0) {
const savedTargetForCategory = getSavedTargetForCategory(currentCategory)
const isValidSavedTarget =
savedTargetForCategory && targets[currentCategory].some(t => t.id === savedTargetForCategory)
if (isValidSavedTarget) {
setSelectedTarget(savedTargetForCategory)
persistTargetSelection(savedTargetForCategory, currentCategory)
return
}
setSelectedTarget(targets[currentCategory][0].id)
persistTargetSelection(targets[currentCategory][0].id, currentCategory)
return
}
const firstCategory = categories[0]
const firstTarget = targets[firstCategory]?.[0]?.id
if (firstTarget) {
setSelectedTarget(firstTarget)
setActiveCategory(firstCategory)
persistTargetSelection(firstTarget, firstCategory)
}
}
}
}, [compatibleTargets, filteredGroupedTargets, filteredTargetCategories, selectedTarget])
// Initialize storage
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])
return {
activeCategory,
selectedTarget,
setActiveCategory,
handleSelectTarget,
GROUPED_TARGETS: GROUPED_TARGETS as Record<string, TargetGroup[]>,
TARGET_CATEGORIES,
}
}

View File

@@ -1,3 +1,4 @@
import Builder from "@/components/Builder"
import { BuildProgress } from "@/components/BuildProgress" import { BuildProgress } from "@/components/BuildProgress"
import { GiscusComments } from "@/components/GiscusComments" import { GiscusComments } from "@/components/GiscusComments"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
@@ -6,29 +7,27 @@ import { Loader2 } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { usePageContext } from "vike-react/usePageContext" import { usePageContext } from "vike-react/usePageContext"
export default function BuildProgressPage() { export default function BuildsPage() {
const pageContext = usePageContext() const pageContext = usePageContext()
const buildHash = pageContext.routeParams?.buildHash as string | undefined const urlSearchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null
const build = useQuery(api.builds.getByHash, buildHash ? { buildHash } : "skip") const cloneHash = urlSearchParams?.get("clone")
const buildId = urlSearchParams?.get("id")
const pluginParam = urlSearchParams?.get("plugin")
// If we have a build ID, show the build progress page
if (buildId) {
return <BuildViewPage buildHash={buildId} />
}
// Otherwise, show the builder (handles clone and plugin params)
return <Builder cloneHash={cloneHash || undefined} pluginParam={pluginParam || undefined} />
}
function BuildViewPage({ buildHash }: { buildHash: string }) {
const build = useQuery(api.builds.getByHash, { buildHash })
const isAdmin = useQuery(api.admin.isAdmin) const isAdmin = useQuery(api.admin.isAdmin)
const retryBuild = useMutation(api.admin.retryBuild) const retryBuild = useMutation(api.admin.retryBuild)
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.{" "}
<a href="/builds/new" className="text-cyan-400">
Start a new build
</a>
.
</p>
</div>
</div>
)
}
if (build === undefined) { if (build === undefined) {
return ( return (
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center"> <div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">

View File

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

View File

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

View File

@@ -1,857 +0,0 @@
import { ModuleToggle } from "@/components/ModuleToggle"
import { PluginCard } from "@/components/PluginCard"
import { Button } from "@/components/ui/button"
import { TARGETS } from "@/constants/targets"
import { VERSIONS } from "@/constants/versions"
import { api } from "@/convex/_generated/api"
import modulesData from "@/convex/modules.json"
import {
getDependedPlugins,
getImplicitDependencies,
getTargetsCompatibleWithIncludes,
isPluginCompatibleWithTarget,
isRequiredByOther,
} from "@/lib/utils"
import registryData from "@/public/registry.json"
import { useMutation, useQuery } from "convex/react"
import { CheckCircle2, ChevronDown, ChevronRight, Loader2 } from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { usePageContext } from "vike-react/usePageContext"
import { navigate } from "vike/client/router"
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 pageContext = usePageContext()
const buildHashParam = pageContext.routeParams?.buildHash
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
const pluginFlashCounts = useQuery(api.plugins.getAll) ?? {}
const sharedBuild = useQuery(api.builds.getByHash, buildHashParam ? { buildHash: buildHashParam } : "skip")
// Get plugin from URL query parameter
const pluginParam = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("plugin") : null
const preselectedPlugin =
pluginParam && pluginParam in registryData
? (
registryData as Record<
string,
{ includes?: string[]; name: string; description: string; imageUrl?: string; featured?: boolean }
>
)[pluginParam]
: null
const STORAGE_KEY = "quick_build_target"
const getStorageKeyForCategory = (category: string) => `quick_build_target_${category}`
const persistTargetSelection = (targetId: string, category?: string) => {
if (typeof window === "undefined") return
try {
// Store global most recent selection
window.localStorage.setItem(STORAGE_KEY, targetId)
// Store per-brand selection if category provided
if (category) {
window.localStorage.setItem(getStorageKeyForCategory(category), targetId)
}
} catch (error) {
console.error("Failed to persist target selection", error)
}
}
const getSavedTargetForCategory = (category: string): string | null => {
if (typeof window === "undefined") return null
try {
return window.localStorage.getItem(getStorageKeyForCategory(category))
} catch (error) {
console.error("Failed to read saved target for category", error)
return null
}
}
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)
// Get all enabled plugins
const enabledPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
// Filter targets based on plugin compatibility
// Start with preselected plugin compatibility if present
let compatibleTargets: Set<string> | null = preselectedPlugin?.includes
? getTargetsCompatibleWithIncludes(preselectedPlugin.includes)
: null
// Intersect with compatibility of all enabled plugins
if (enabledPlugins.length > 0) {
const pluginRegistry = registryData as Record<string, { includes?: string[] }>
const allCompatibleSets: Set<string>[] = []
// Get compatible targets for each enabled plugin
for (const pluginId of enabledPlugins) {
const plugin = pluginRegistry[pluginId]
if (plugin?.includes && plugin.includes.length > 0) {
// Plugin has includes - get compatible targets
allCompatibleSets.push(getTargetsCompatibleWithIncludes(plugin.includes))
}
// If plugin has no includes, it's compatible with all targets (don't add to set)
}
// If we have compatible sets, find intersection
if (allCompatibleSets.length > 0) {
if (compatibleTargets) {
// Intersect with preselected plugin compatibility
compatibleTargets = new Set(
Array.from(compatibleTargets).filter(target => allCompatibleSets.every(set => set.has(target)))
)
} else {
// Start with first set, then intersect with others
compatibleTargets = allCompatibleSets[0]
for (let i = 1; i < allCompatibleSets.length; i++) {
compatibleTargets = new Set(Array.from(compatibleTargets).filter(target => allCompatibleSets[i].has(target)))
}
}
} else if (!compatibleTargets) {
// No enabled plugins have includes, so all targets are compatible
// (only if there's no preselected plugin with includes)
compatibleTargets = null
}
}
const filteredGroupedTargets = compatibleTargets
? Object.entries(GROUPED_TARGETS).reduce(
(acc, [category, targets]) => {
const filtered = targets.filter(target => {
// Check both normalized and original target ID
const normalizedId = target.id.replace(/[-_]/g, "")
return compatibleTargets.has(target.id) || compatibleTargets.has(normalizedId)
})
if (filtered.length > 0) {
acc[category] = filtered
}
return acc
},
{} as Record<string, TargetGroup[]>
)
: GROUPED_TARGETS
const filteredTargetCategories = Object.keys(filteredGroupedTargets).sort((a, b) => a.localeCompare(b))
// Preselect plugin from URL parameter
useEffect(() => {
if (pluginParam && preselectedPlugin && !buildHashParam) {
setPluginConfig({ [pluginParam]: true })
setShowPlugins(true)
}
}, [pluginParam, preselectedPlugin, buildHashParam])
useEffect(() => {
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
if (!activeCategory && categories.length > 0) {
setActiveCategory(categories[0])
}
}, [activeCategory, compatibleTargets, filteredTargetCategories])
useEffect(() => {
if (activeCategory) {
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
const categoryTargets = targets[activeCategory] || []
if (categoryTargets.length === 0) return
// Check if current selected target is in this category
const isCurrentTargetInCategory = categoryTargets.some(t => t.id === selectedTarget)
if (!isCurrentTargetInCategory) {
// Try to restore per-brand selection
const savedTargetForCategory = getSavedTargetForCategory(activeCategory)
const isValidSavedTarget = savedTargetForCategory && categoryTargets.some(t => t.id === savedTargetForCategory)
if (isValidSavedTarget) {
setSelectedTarget(savedTargetForCategory)
// Persist the restored selection
persistTargetSelection(savedTargetForCategory, activeCategory)
} else {
// Default to first target in category and persist it
const firstTarget = categoryTargets[0].id
setSelectedTarget(firstTarget)
persistTargetSelection(firstTarget, activeCategory)
}
}
}
}, [activeCategory, compatibleTargets, filteredGroupedTargets, selectedTarget])
useEffect(() => {
if (typeof window === "undefined") return
try {
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
if (categories.length === 0) return
// Try to restore the most recent global selection first
const savedTarget = localStorage.getItem(STORAGE_KEY)
if (savedTarget && TARGETS[savedTarget]) {
// Check if saved target exists in filtered targets
const isCompatible = Object.values(targets).some(categoryTargets =>
categoryTargets.some(target => target.id === savedTarget)
)
if (isCompatible) {
const category = TARGETS[savedTarget].category || "Other"
if (categories.includes(category)) {
setActiveCategory(category)
setSelectedTarget(savedTarget)
persistTargetSelection(savedTarget, category)
return
}
}
}
// Fall back to per-brand selection for first category
const firstCategory = categories[0]
const categoryTargets = targets[firstCategory] || []
if (categoryTargets.length > 0) {
// Try to restore per-brand selection
const savedTargetForCategory = getSavedTargetForCategory(firstCategory)
const isValidSavedTarget = savedTargetForCategory && categoryTargets.some(t => t.id === savedTargetForCategory)
if (isValidSavedTarget) {
setActiveCategory(firstCategory)
setSelectedTarget(savedTargetForCategory)
persistTargetSelection(savedTargetForCategory, firstCategory)
} else {
// Default to first target in category
const firstTarget = categoryTargets[0].id
setActiveCategory(firstCategory)
setSelectedTarget(firstTarget)
persistTargetSelection(firstTarget, firstCategory)
}
}
} catch (error) {
console.error("Failed to read saved target", error)
}
}, [compatibleTargets, filteredGroupedTargets, filteredTargetCategories])
const handleSelectTarget = (targetId: string) => {
// Validate target is compatible with selected plugins
if (compatibleTargets) {
const normalizedId = targetId.replace(/[-_]/g, "")
const isCompatible = compatibleTargets.has(targetId) || compatibleTargets.has(normalizedId)
if (!isCompatible) {
// Target is not compatible, don't allow selection
return
}
}
setSelectedTarget(targetId)
const category = TARGETS[targetId]?.category || "Other"
persistTargetSelection(targetId, category)
if (category && TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
// Update selected target if it becomes incompatible with selected plugins
useEffect(() => {
if (!selectedTarget || !compatibleTargets) return
const normalizedId = selectedTarget.replace(/[-_]/g, "")
const isCompatible = compatibleTargets.has(selectedTarget) || compatibleTargets.has(normalizedId)
if (!isCompatible) {
// Current target is no longer compatible, find a compatible one
const targets = filteredGroupedTargets
const categories = filteredTargetCategories
if (categories.length > 0) {
// Try to find a compatible target in the current category first
const currentCategory = TARGETS[selectedTarget]?.category
if (currentCategory && targets[currentCategory] && targets[currentCategory].length > 0) {
const savedTargetForCategory = getSavedTargetForCategory(currentCategory)
const isValidSavedTarget =
savedTargetForCategory && targets[currentCategory].some(t => t.id === savedTargetForCategory)
if (isValidSavedTarget) {
setSelectedTarget(savedTargetForCategory)
persistTargetSelection(savedTargetForCategory, currentCategory)
return
}
// Default to first target in current category
setSelectedTarget(targets[currentCategory][0].id)
persistTargetSelection(targets[currentCategory][0].id, currentCategory)
return
}
// Fall back to first compatible target
const firstCategory = categories[0]
const firstTarget = targets[firstCategory]?.[0]?.id
if (firstTarget) {
setSelectedTarget(firstTarget)
setActiveCategory(firstCategory)
persistTargetSelection(firstTarget, firstCategory)
}
}
}
}, [compatibleTargets, filteredGroupedTargets, filteredTargetCategories, selectedTarget])
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">
{preselectedPlugin ? "Plugin build" : "Quick build"}
</p>
<h1 className="text-4xl font-bold mt-1">
{preselectedPlugin ? `Build firmware for ${preselectedPlugin.name}` : "Flash a custom firmware version"}
</h1>
<p className="text-slate-400 mt-2 max-w-2xl">
{preselectedPlugin
? `Select a compatible Meshtastic target and configure your build for ${preselectedPlugin.name}. We'll send you to the build status page as soon as it starts.`
: "Choose your Meshtastic target, adjust optional modules, and queue a new build instantly. We'll send you to the build status page as soon as it starts."}
</p>
</div>
</div>
{preselectedPlugin && (
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<div className="flex items-start gap-4">
<CheckCircle2 className="w-8 h-8 text-green-400 shrink-0 mt-1" />
<div className="flex items-start gap-4 flex-1">
{preselectedPlugin.imageUrl && (
<img
src={preselectedPlugin.imageUrl}
alt={`${preselectedPlugin.name} logo`}
className="w-16 h-16 rounded-lg object-contain shrink-0"
/>
)}
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h2 className="text-2xl font-bold">{preselectedPlugin.name}</h2>
{preselectedPlugin.featured && (
<span className="px-2 py-1 text-xs font-medium text-green-400 bg-green-400/10 border border-green-400/20 rounded">
Featured
</span>
)}
</div>
<p className="text-slate-400 mb-3">{preselectedPlugin.description}</p>
{preselectedPlugin.includes && preselectedPlugin.includes.length > 0 && (
<p className="text-sm text-slate-500">Compatible with: {preselectedPlugin.includes.join(", ")}</p>
)}
</div>
</div>
</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">
{(compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES).map(category => {
const isActive = activeCategory === category
return (
<button
key={category}
type="button"
onClick={() => {
// Always allow switching to category - the useEffect will handle target selection
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">
{(() => {
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
return (activeCategory ? 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
// Check if this is the preselected plugin from URL
const isPreselected = pluginParam === slug
return (
<PluginCard
key={`${slug}-${selectedTarget}`}
variant="link-toggle"
id={slug}
name={plugin.name}
description={plugin.description}
imageUrl={plugin.imageUrl}
isEnabled={allEnabledPlugins.includes(slug)}
onToggle={enabled => handleTogglePlugin(slug, enabled)}
disabled={isImplicit || isIncompatible || isPreselected}
enabledLabel={isPreselected ? "Locked" : 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}
repo={plugin.repo}
/>
)
})
})()}
</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>
)
}

View File

@@ -149,7 +149,7 @@ export default function LandingPage() {
description={customBuildPlugin.description} description={customBuildPlugin.description}
imageUrl={customBuildPlugin.imageUrl} imageUrl={customBuildPlugin.imageUrl}
featured={false} featured={false}
href="/builds/new" href="/builds"
prominent={true} prominent={true}
/> />
</div> </div>

View File

@@ -132,7 +132,7 @@ export default function PluginPage() {
Homepage Homepage
</Button> </Button>
)} )}
<Button onClick={() => navigate(`/builds/new?plugin=${slug}`)} className="bg-cyan-600 hover:bg-cyan-700"> <Button onClick={() => navigate(`/builds?plugin=${slug}`)} className="bg-cyan-600 hover:bg-cyan-700">
Build with this Plugin Build with this Plugin
</Button> </Button>
</div> </div>