forked from iarv/mesh-forge
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa7eb6ea32 | ||
|
|
bdb848e15f | ||
|
|
cdc4959dca | ||
|
|
c38e6b735c | ||
|
|
de43b1b024 |
13
.gitmodules
vendored
13
.gitmodules
vendored
@@ -1,13 +1,3 @@
|
||||
[submodule "vendor/web-flasher"]
|
||||
path = vendor/web-flasher
|
||||
url = https://github.com/meshtastic/web-flasher
|
||||
[submodule "vendor/firmware"]
|
||||
path = vendor/firmware
|
||||
url = https://github.com/MeshEnvy/firmware.git
|
||||
branch = meshenvy/module-registry
|
||||
[submodule "vendor/api"]
|
||||
path = vendor/api
|
||||
url = https://github.com/meshtastic/api.git
|
||||
[submodule "vendor/mpm"]
|
||||
path = vendor/mpm
|
||||
url = git@github.com:MeshEnvy/mpm.git
|
||||
@@ -17,9 +7,6 @@
|
||||
[submodule "vendor/lodb"]
|
||||
path = vendor/lodb
|
||||
url = git@github.com:MeshEnvy/lodb.git
|
||||
[submodule "vendor/meshcore"]
|
||||
path = vendor/meshcore
|
||||
url = git@github.com:MeshEnvy/MeshCore.git
|
||||
[submodule "vendor/meshscript"]
|
||||
path = vendor/meshscript
|
||||
url = git@github.com:MeshEnvy/meshscript.git
|
||||
|
||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.0] - 2025-12-10
|
||||
|
||||
### Minor
|
||||
|
||||
- Added vendors.json mapping vendors to models and platformio targets
|
||||
- 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)
|
||||
- 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
|
||||
- Refactored Builder component into smaller reusable components (BuilderHeader, TargetSelector, VersionSelector, ModuleConfig, PluginConfig, BuildActions)
|
||||
- Extracted target selection and plugin compatibility logic into reusable hooks (useTargetSelection, usePluginCompatibility)
|
||||
|
||||
### Patch
|
||||
|
||||
- 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
|
||||
|
||||
### Minor
|
||||
@@ -35,7 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
Initial release
|
||||
|
||||
[Unreleased]: https://github.com/MeshEnvy/mesh-forge/compare/v0.3.0...HEAD
|
||||
[Unreleased]: https://github.com/MeshEnvy/mesh-forge/compare/v0.4.0...HEAD
|
||||
[0.4.0]: https://github.com/MeshEnvy/mesh-forge/releases/tag/v0.4.0
|
||||
[0.3.0]: https://github.com/MeshEnvy/mesh-forge/releases/tag/v0.3.0
|
||||
[0.2.0]: https://github.com/MeshEnvy/mesh-forge/releases/tag/v0.2.0
|
||||
[0.1.0]: https://github.com/MeshEnvy/mesh-forge/releases/tag/v0.1.0
|
||||
|
||||
34
components/BuildActions.tsx
Normal file
34
components/BuildActions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ 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"
|
||||
import { ArtifactType } from "@/convex/lib/filename"
|
||||
import { useMutation } from "convex/react"
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
@@ -2,8 +2,7 @@ import { BuildDownloadButton } from "@/components/BuildDownloadButton"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { TARGETS } from "@/constants/targets"
|
||||
import type { Doc } from "@/convex/_generated/dataModel"
|
||||
import { ArtifactType } from "@/convex/builds"
|
||||
import { getArtifactFilenameBase } from "@/convex/lib/filename"
|
||||
import { ArtifactType, getArtifactFilenameBase } from "@/convex/lib/filename"
|
||||
import modulesData from "@/convex/modules.json"
|
||||
import { getImplicitDependencies, humanizeStatus } from "@/lib/utils"
|
||||
import registryData from "@/public/registry.json"
|
||||
@@ -49,7 +48,7 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
|
||||
? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`
|
||||
: null
|
||||
|
||||
const shareUrl = `${window.location.origin}/builds/new/${build.buildHash}`
|
||||
const shareUrl = `${window.location.origin}/builds?clone=${build.buildHash}`
|
||||
|
||||
const handleShare = async () => {
|
||||
try {
|
||||
@@ -217,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">
|
||||
{getStatusIcon()}
|
||||
<a
|
||||
href={`/builds/${build.buildHash}`}
|
||||
href={`/builds?id=${build.buildHash}`}
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
navigate(`/builds/${build.buildHash}`)
|
||||
navigate(`/builds?id=${build.buildHash}`)
|
||||
}}
|
||||
className="hover:text-cyan-400 transition-colors"
|
||||
>
|
||||
@@ -258,7 +257,7 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
|
||||
{showActions && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => navigate(`/builds/new/${build.buildHash}`)}
|
||||
onClick={() => navigate(`/builds?clone=${build.buildHash}`)}
|
||||
variant="outline"
|
||||
className="border-slate-600 hover:bg-slate-800"
|
||||
aria-label="Clone"
|
||||
|
||||
296
components/Builder.tsx
Normal file
296
components/Builder.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
components/BuilderHeader.tsx
Normal file
64
components/BuilderHeader.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
74
components/ModuleConfig.tsx
Normal file
74
components/ModuleConfig.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -205,42 +205,87 @@ export function PluginCard(props: PluginCardProps) {
|
||||
<span>{downloads.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{homepage && homepage !== repo && (isLink || isLinkToggle) && (
|
||||
<a
|
||||
href={homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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"
|
||||
{homepage &&
|
||||
homepage !== repo &&
|
||||
(isLink || isLinkToggle) &&
|
||||
(isLink ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
window.open(homepage, "_blank", "noopener,noreferrer")
|
||||
}}
|
||||
className="hover:opacity-80 transition-opacity"
|
||||
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"
|
||||
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 && (
|
||||
<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>
|
||||
)}
|
||||
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>
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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>
|
||||
{/* Build Now button - absolutely positioned in lower right */}
|
||||
{isLink && (
|
||||
@@ -249,7 +294,7 @@ export function PluginCard(props: PluginCardProps) {
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
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"
|
||||
>
|
||||
@@ -287,7 +332,7 @@ export function PluginCard(props: PluginCardProps) {
|
||||
} ${isLink ? "group" : ""}`
|
||||
|
||||
if (isLink) {
|
||||
const href = props.href || `/builds/new?plugin=${id}`
|
||||
const href = props.href || `/builds?plugin=${id}`
|
||||
return (
|
||||
<a href={href} className={baseClassName}>
|
||||
{cardContent}
|
||||
|
||||
158
components/PluginConfig.tsx
Normal file
158
components/PluginConfig.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
components/TargetSelector.tsx
Normal file
76
components/TargetSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
components/VersionSelector.tsx
Normal file
28
components/VersionSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,210 +1,210 @@
|
||||
{
|
||||
"betafpv2400txmicro": "esp32",
|
||||
"betafpv900txnano": "esp32",
|
||||
"betafpv_2400_tx_micro": "esp32",
|
||||
"betafpv_900_tx_nano": "esp32",
|
||||
"chatter2": "esp32",
|
||||
"9m2ibraprsloratracker": "esp32",
|
||||
"meshtasticdrdev": "esp32",
|
||||
"9m2ibr_aprs_lora_tracker": "esp32",
|
||||
"meshtastic-dr-dev": "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",
|
||||
"meshtastic-diy-v1": "esp32",
|
||||
"meshtastic-diy-v1_1": "esp32",
|
||||
"hackerboxes-esp32-io": "esp32",
|
||||
"heltec-v1": "esp32",
|
||||
"heltec-v2_0": "esp32",
|
||||
"heltec-v2_1": "esp32",
|
||||
"heltec-wireless-bridge": "esp32",
|
||||
"heltec-wsl-v2_1": "esp32",
|
||||
"m5stack-core": "esp32",
|
||||
"m5stack-coreink": "esp32",
|
||||
"nano-g1": "esp32",
|
||||
"nano-g1-explorer": "esp32",
|
||||
"radiomaster_900_bandit": "esp32",
|
||||
"radiomaster_900_bandit_micro": "esp32",
|
||||
"radiomaster_900_bandit_nano": "esp32",
|
||||
"rak11200": "esp32",
|
||||
"stationg1": "esp32",
|
||||
"station-g1": "esp32",
|
||||
"tbeam": "esp32",
|
||||
"tbeamdisplayshield": "tbeam",
|
||||
"tbeam07": "esp32",
|
||||
"tlorav1": "esp32",
|
||||
"tlorav13": "esp32",
|
||||
"tlorav2": "esp32",
|
||||
"tlorav2116": "esp32",
|
||||
"sugarcube": "tlorav2116",
|
||||
"tlorav2116tcxo": "esp32",
|
||||
"tlorav2118": "esp32",
|
||||
"tlorav330tcxo": "esp32",
|
||||
"tbeam-displayshield": "tbeam",
|
||||
"tbeam0_7": "esp32",
|
||||
"tlora-v1": "esp32",
|
||||
"tlora_v1_3": "esp32",
|
||||
"tlora-v2": "esp32",
|
||||
"tlora-v2-1-1_6": "esp32",
|
||||
"sugarcube": "tlora-v2-1-1_6",
|
||||
"tlora-v2-1-1_6-tcxo": "esp32",
|
||||
"tlora-v2-1-1_8": "esp32",
|
||||
"tlora-v3-3-0-tcxo": "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",
|
||||
"ai-c3": "esp32c3",
|
||||
"esp32c3_super_mini": "esp32c3",
|
||||
"esp32c3_base": "esp32",
|
||||
"hackerboxes-esp32c3-oled": "esp32c3",
|
||||
"heltec-ht62-esp32c3-sx1262": "esp32c3",
|
||||
"heltec-hru-3601": "esp32c3",
|
||||
"m5stack-stamp-c3": "esp32c3",
|
||||
"esp32c6_base": "esp32",
|
||||
"m5stack-unitc6l": "esp32c6",
|
||||
"tlora-c6": "esp32c6",
|
||||
"esp32s2_base": "esp32",
|
||||
"nugget-s2-lora": "esp32s2",
|
||||
"CDEBYTE_EoRa-S3": "esp32s3",
|
||||
"EBYTE_ESP32-S3": "esp32s3",
|
||||
"thinknode_m2": "esp32s3",
|
||||
"thinknode_m5": "esp32s3",
|
||||
"bpi_picow_esp32_s3": "esp32s3",
|
||||
"crowpanel-esp32s3-5-epaper": "esp32s3",
|
||||
"crowpanel-esp32s3-4-epaper": "esp32s3",
|
||||
"crowpanel-esp32s3-2-epaper": "esp32s3",
|
||||
"my-esp32s3-diy-eink": "esp32s3",
|
||||
"my-esp32s3-diy-oled": "esp32s3",
|
||||
"t-energy-s3_e22": "esp32s3",
|
||||
"dreamcatcher-2206": "esp32s3",
|
||||
"crowpanel_base": "crowpanel",
|
||||
"elecrow-adv-24-28-tft": "crowpanel_small_esp32s3_base",
|
||||
"elecrow-adv-35-tft": "crowpanel_small_esp32s3_base",
|
||||
"elecrow-adv1-43-50-70-tft": "crowpanel_large_esp32s3_base",
|
||||
"ESP32-S3-Pico": "esp32s3",
|
||||
"esp32s3_base": "esp32",
|
||||
"hackaday-communicator": "esp32s3",
|
||||
"heltec_capsule_sensor_v3": "esp32s3",
|
||||
"heltec_sensor_hub": "esp32s3",
|
||||
"heltec-v3": "esp32s3",
|
||||
"heltec_v4_base": "esp32s3",
|
||||
"heltec-v4": "heltec_v4_base",
|
||||
"heltec-v4-tft": "heltec_v4_base",
|
||||
"heltec-vision-master-e213": "esp32s3",
|
||||
"heltec-vision-master-e213-inkhud": "esp32s3",
|
||||
"heltec-vision-master-e290": "esp32s3",
|
||||
"heltec-vision-master-e290-inkhud": "esp32s3",
|
||||
"heltec-vision-master-t190": "esp32s3",
|
||||
"heltec-wireless-paper": "esp32s3",
|
||||
"heltec-wireless-paper-inkhud": "esp32s3",
|
||||
"heltec-wireless-paper-v1_0": "esp32s3",
|
||||
"heltec-wireless-tracker": "esp32s3",
|
||||
"heltec-wireless-tracker-V1-0": "esp32s3",
|
||||
"heltec-wireless-tracker-v2": "esp32s3",
|
||||
"heltec-wsl-v3": "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",
|
||||
"link32-s3-v1": "esp32s3",
|
||||
"m5stack-cores3": "esp32s3",
|
||||
"mesh_tab_base": "esp32s3",
|
||||
"mesh-tab-3-2-TN-resistive": "mesh_tab_base",
|
||||
"mesh-tab-3-2-IPS-resistive": "mesh_tab_base",
|
||||
"mesh-tab-3-5-IPS-resistive": "mesh_tab_base",
|
||||
"mesh-tab-3-5-TN-resistive": "mesh_tab_base",
|
||||
"mesh-tab-3-2-IPS-capacitive": "mesh_tab_base",
|
||||
"mesh-tab-3-5-IPS-capacitive": "mesh_tab_base",
|
||||
"mesh-tab-4-0-IPS-capacitive": "mesh_tab_base",
|
||||
"nibble-esp32": "esp32s3",
|
||||
"nugget-s3-lora": "esp32s3",
|
||||
"picomputer-s3": "esp32s3",
|
||||
"picomputer-s3-tft": "picomputer-s3",
|
||||
"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",
|
||||
"rak_wismesh_tap_v2-tft": "rak_wismeshtap_s3",
|
||||
"seeed-sensecap-indicator": "esp32s3",
|
||||
"seeed-sensecap-indicator-tft": "seeed-sensecap-indicator",
|
||||
"seeed-xiao-s3": "esp32s3",
|
||||
"station-g2": "esp32s3",
|
||||
"t-deck": "esp32s3",
|
||||
"t-deck-tft": "t-deck",
|
||||
"t-deck-pro": "esp32s3",
|
||||
"t-eth-elite": "esp32s3",
|
||||
"t-watch-s3": "esp32s3",
|
||||
"tbeam-s3-core": "esp32s3",
|
||||
"tlora-pager": "esp32s3",
|
||||
"tlora-pager-tft": "tlora-pager",
|
||||
"tlora-t3s3-epaper": "esp32s3",
|
||||
"tlora-t3s3-epaper-inkhud": "esp32s3",
|
||||
"tlora-t3s3-v1": "esp32s3",
|
||||
"tracksenger": "esp32s3",
|
||||
"tracksengerlcd": "esp32s3",
|
||||
"tracksengeroled": "esp32s3",
|
||||
"tracksenger-lcd": "esp32s3",
|
||||
"tracksenger-oled": "esp32s3",
|
||||
"unphone": "esp32s3",
|
||||
"unphonetft": "unphone",
|
||||
"unphone-tft": "unphone",
|
||||
"coverage": "native",
|
||||
"buildroot": "portduino",
|
||||
"pca10059diyeink": "nrf52840",
|
||||
"thinknodem1": "nrf52840",
|
||||
"thinknodem1inkhud": "nrf52840",
|
||||
"thinknodem3": "nrf52840",
|
||||
"thinknodem6": "nrf52840",
|
||||
"ME25LS014Y10TD": "nrf52840",
|
||||
"ME25LS014Y10TDeink": "nrf52840",
|
||||
"pca10059_diy_eink": "nrf52840",
|
||||
"thinknode_m1": "nrf52840",
|
||||
"thinknode_m1-inkhud": "nrf52840",
|
||||
"thinknode_m3": "nrf52840",
|
||||
"thinknode_m6": "nrf52840",
|
||||
"ME25LS01-4Y10TD": "nrf52840",
|
||||
"ME25LS01-4Y10TD_e-ink": "nrf52840",
|
||||
"ms24sf1": "nrf52840",
|
||||
"makerpythonnrf52840sx1280eink": "nrf52840",
|
||||
"makerpythonnrf52840sx1280oled": "nrf52840",
|
||||
"TWCmeshv4": "nrf52840",
|
||||
"makerpython_nrf52840_sx1280_eink": "nrf52840",
|
||||
"makerpython_nrf52840_sx1280_oled": "nrf52840",
|
||||
"TWC_mesh_v4": "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",
|
||||
"nrf52_promicro_diy_tcxo": "nrf52840",
|
||||
"nrf52_promicro_diy-inkhud": "nrf52840",
|
||||
"seeed-xiao-nrf52840-wio-sx1262": "nrf52840",
|
||||
"seeed_xiao_nrf52840_e22_900m30s": "seeed_xiao_nrf52840_kit",
|
||||
"seeed_xiao_nrf52840_e22_900m33s": "seeed_xiao_nrf52840_kit",
|
||||
"xiao_ble": "seeed_xiao_nrf52840_kit",
|
||||
"feather_diy": "nrf52840",
|
||||
"gat562_mesh_trial_tracker": "nrf52840",
|
||||
"heltec-mesh-node-t114": "nrf52840",
|
||||
"heltec-mesh-node-t114-inkhud": "nrf52840",
|
||||
"heltec-mesh-pocket-5000": "nrf52840",
|
||||
"heltec-mesh-pocket-5000-inkhud": "nrf52840",
|
||||
"heltec-mesh-pocket-10000": "nrf52840",
|
||||
"heltec-mesh-pocket-10000-inkhud": "nrf52840",
|
||||
"heltec_mesh_solar_base": "nrf52840",
|
||||
"heltec-mesh-solar": "heltec_mesh_solar_base",
|
||||
"heltec-mesh-solar-eink": "heltec_mesh_solar_base",
|
||||
"heltec-mesh-solar-inkhud": "heltec_mesh_solar_base",
|
||||
"heltec-mesh-solar-oled": "heltec_mesh_solar_base",
|
||||
"heltec-mesh-solar-tft": "heltec_mesh_solar_base",
|
||||
"meshlink": "nrf52840",
|
||||
"meshlinkeink": "nrf52840",
|
||||
"meshlink_eink": "nrf52840",
|
||||
"meshtiny": "nrf52840",
|
||||
"monteopshw1": "nrf52840",
|
||||
"muzibase": "nrf52840",
|
||||
"nanog2ultra": "nrf52840",
|
||||
"nrf52832base": "nrf52",
|
||||
"nrf52840base": "nrf52",
|
||||
"r1neo": "nrf52840",
|
||||
"monteops_hw1": "nrf52840",
|
||||
"muzi-base": "nrf52840",
|
||||
"nano-g2-ultra": "nrf52840",
|
||||
"nrf52832_base": "nrf52",
|
||||
"nrf52840_base": "nrf52",
|
||||
"r1-neo": "nrf52840",
|
||||
"rak2560": "nrf52840",
|
||||
"rak34011watt": "nrf52840",
|
||||
"rak3401-1watt": "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",
|
||||
"rak4631_dbg": "rak4631",
|
||||
"rak4631_eink": "nrf52840",
|
||||
"rak4631_eink_onrxtx": "nrf52840",
|
||||
"rak4631_eth_gw": "nrf52840",
|
||||
"rak4631_eth_gw_dbg": "rak4631",
|
||||
"rak4631_nomadstar_meteor_pro": "nrf52840",
|
||||
"rak4631_nomadstar_meteor_pro_dbg": "rak4631_nomadstar_meteor_pro",
|
||||
"rak_wismeshtag": "nrf52840",
|
||||
"rak_wismeshtap": "nrf52840",
|
||||
"seeed_solar_node": "nrf52840",
|
||||
"seeed_wio_tracker_L1": "nrf52840",
|
||||
"seeed_wio_tracker_L1_eink": "nrf52840",
|
||||
"seeed_wio_tracker_L1_eink-inkhud": "nrf52840",
|
||||
"seeed_xiao_nrf52840_kit": "nrf52840",
|
||||
"seeed_xiao_nrf52840_kit_i2c": "seeed_xiao_nrf52840_kit",
|
||||
"t-echo": "nrf52840",
|
||||
"t-echo-inkhud": "nrf52840",
|
||||
"t-echo-lite": "nrf52840",
|
||||
"tracker-t1000-e": "nrf52840",
|
||||
"wio-sdk-wm1110": "nrf52840",
|
||||
"wio-t1000-s": "nrf52840",
|
||||
"wio-tracker-wm1110": "nrf52840",
|
||||
"challenger_2040_lora": "rp2040",
|
||||
"catsniffer": "rp2040",
|
||||
"featherrp2040rfm95": "rp2040",
|
||||
"nibblerp2040": "rp2040",
|
||||
"feather_rp2040_rfm95": "rp2040",
|
||||
"nibble-rp2040": "rp2040",
|
||||
"rak11310": "rp2040",
|
||||
"rp2040lora": "rp2040",
|
||||
"rp2040-lora": "rp2040",
|
||||
"pico": "rp2040",
|
||||
"picoslowclock": "rp2040",
|
||||
"pico_slowclock": "rp2040",
|
||||
"picow": "rp2040",
|
||||
"senselorarp2040": "rp2040",
|
||||
"senselora_rp2040": "rp2040",
|
||||
"pico2": "rp2350",
|
||||
"pico2w": "rp2350",
|
||||
"CDEBYTEE77MBL": "stm32",
|
||||
"CDEBYTE_E77-MBL": "stm32",
|
||||
"rak3172": "stm32",
|
||||
"wioe5": "stm32",
|
||||
"wio-e5": "stm32",
|
||||
"esp32c3": "esp32",
|
||||
"esp32c6": "esp32",
|
||||
"esp32s2": "esp32",
|
||||
@@ -217,4 +217,4 @@
|
||||
"rp2350": null,
|
||||
"stm32": null,
|
||||
"portduino": null
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import hardwareList from "@/vendor/web-flasher/public/data/hardware-list.json"
|
||||
import architectureHierarchy from "@/constants/architecture-hierarchy.json"
|
||||
import vendorsData from "@/constants/vendors.json"
|
||||
|
||||
export interface TargetMetadata {
|
||||
name: string
|
||||
@@ -6,17 +7,43 @@ export interface TargetMetadata {
|
||||
architecture?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace a target back to its base architecture
|
||||
*/
|
||||
function getBaseArchitecture(target: string): string | null {
|
||||
const parentMap = architectureHierarchy as Record<string, string | null>
|
||||
const visited = new Set<string>()
|
||||
let current: string | null = target
|
||||
|
||||
while (current && !visited.has(current)) {
|
||||
visited.add(current)
|
||||
if (!current) break
|
||||
const parent: string | null | undefined = parentMap[current]
|
||||
|
||||
if (parent === null) {
|
||||
return current
|
||||
}
|
||||
|
||||
if (parent === undefined) {
|
||||
return current
|
||||
}
|
||||
|
||||
current = parent
|
||||
}
|
||||
|
||||
return current || target
|
||||
}
|
||||
|
||||
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,
|
||||
// Build TARGETS from vendors.json and architecture-hierarchy.json
|
||||
for (const [vendor, models] of Object.entries(vendorsData)) {
|
||||
for (const [modelName, target] of Object.entries(models)) {
|
||||
const architecture = getBaseArchitecture(target)
|
||||
TARGETS[target] = {
|
||||
name: modelName,
|
||||
category: vendor,
|
||||
architecture: architecture || undefined,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
244
constants/vendors.json
Normal file
244
constants/vendors.json
Normal file
@@ -0,0 +1,244 @@
|
||||
{
|
||||
"B&Q": {
|
||||
"Nano G1": "nano-g1",
|
||||
"Nano G1 Explorer": "nano-g1-explorer",
|
||||
"Nano G2 Ultra": "nano-g2-ultra",
|
||||
"Station G1": "station-g1",
|
||||
"Station G2": "station-g2"
|
||||
},
|
||||
"BetaFPV": {
|
||||
"2400TX Micro": "betafpv_2400_tx_micro",
|
||||
"900TX Nano": "betafpv_900_tx_nano"
|
||||
},
|
||||
"Canary": {
|
||||
"One": "canaryone"
|
||||
},
|
||||
"CDEByte": {
|
||||
"EoRa S3": "CDEBYTE_EoRa-S3",
|
||||
"E77MBL": "CDEBYTE_E77-MBL"
|
||||
},
|
||||
"DIY": {
|
||||
"DR-DEV": "meshtastic-dr-dev",
|
||||
"Hydra": "hydra",
|
||||
"V1": "meshtastic-diy-v1",
|
||||
"V1.1": "meshtastic-diy-v1_1",
|
||||
"NRF52 Pro-micro DIY": "nrf52_promicro_diy_tcxo",
|
||||
"NRF52 Pro-micro DIY InkHUD": "nrf52_promicro_diy-inkhud",
|
||||
"PCA10059 DIY E-Ink": "pca10059_diy_eink"
|
||||
},
|
||||
"EByte": {
|
||||
"ESP32-S3": "EBYTE_ESP32-S3"
|
||||
},
|
||||
"Elecrow": {
|
||||
"Crowpanel Adv 2.4/2.8 TFT": "elecrow-adv-24-28-tft",
|
||||
"Crowpanel Adv 3.5 TFT": "elecrow-adv-35-tft",
|
||||
"Crowpanel Adv 4.3/5.0/7.0 TFT": "elecrow-adv1-43-50-70-tft",
|
||||
"ThinkNode M1": "thinknode_m1",
|
||||
"ThinkNode M2": "thinknode_m2",
|
||||
"ThinkNode M3": "thinknode_m3",
|
||||
"ThinkNode M5": "thinknode_m5",
|
||||
"ThinkNode M6": "thinknode_m6"
|
||||
},
|
||||
"Heltec": {
|
||||
"V1": "heltec-v1",
|
||||
"V2.0": "heltec-v2_0",
|
||||
"V2.1": "heltec-v2_1",
|
||||
"V3": "heltec-v3",
|
||||
"V4": "heltec-v4",
|
||||
"V4 TFT": "heltec-v4-tft",
|
||||
"HT62": "heltec-ht62-esp32c3-sx1262",
|
||||
"RU3601": "heltec-hru-3601",
|
||||
"Wireless Bridge": "heltec-wireless-bridge",
|
||||
"Wireless Stick Lite V2.1": "heltec-wsl-v2_1",
|
||||
"Wireless Stick Lite V3": "heltec-wsl-v3",
|
||||
"Wireless Paper": "heltec-wireless-paper",
|
||||
"Wireless Paper InkHUD": "heltec-wireless-paper-inkhud",
|
||||
"Wireless Paper V1.0": "heltec-wireless-paper-v1_0",
|
||||
"Wireless Tracker": "heltec-wireless-tracker",
|
||||
"Wireless Tracker V1.0": "heltec-wireless-tracker-V1-0",
|
||||
"Wireless Tracker V2": "heltec-wireless-tracker-v2",
|
||||
"Vision Master E213": "heltec-vision-master-e213",
|
||||
"Vision Master E213 InkHUD": "heltec-vision-master-e213-inkhud",
|
||||
"Vision Master E290": "heltec-vision-master-e290",
|
||||
"Vision Master E290 InkHUD": "heltec-vision-master-e290-inkhud",
|
||||
"Vision Master T190": "heltec-vision-master-t190",
|
||||
"Capsule Sensor V3": "heltec_capsule_sensor_v3",
|
||||
"Sensor Hub": "heltec_sensor_hub",
|
||||
"Mesh Node T114": "heltec-mesh-node-t114",
|
||||
"Mesh Node T114 InkHUD": "heltec-mesh-node-t114-inkhud",
|
||||
"MeshPocket 5000": "heltec-mesh-pocket-5000",
|
||||
"MeshPocket 5000 InkHUD": "heltec-mesh-pocket-5000-inkhud",
|
||||
"MeshPocket 10000": "heltec-mesh-pocket-10000",
|
||||
"MeshPocket 10000 InkHUD": "heltec-mesh-pocket-10000-inkhud",
|
||||
"MeshSolar": "heltec-mesh-solar",
|
||||
"MeshSolar E-Ink": "heltec-mesh-solar-eink",
|
||||
"MeshSolar InkHUD": "heltec-mesh-solar-inkhud",
|
||||
"MeshSolar OLED": "heltec-mesh-solar-oled",
|
||||
"MeshSolar TFT": "heltec-mesh-solar-tft"
|
||||
},
|
||||
"HackerBoxes": {
|
||||
"ESP32 IO": "hackerboxes-esp32-io",
|
||||
"ESP32-C3 OLED": "hackerboxes-esp32c3-oled"
|
||||
},
|
||||
"LilyGo": {
|
||||
"T-Beam": "tbeam",
|
||||
"T-Beam Display Shield": "tbeam-displayshield",
|
||||
"T-Beam V0.7": "tbeam0_7",
|
||||
"T-Beam S3 Core": "tbeam-s3-core",
|
||||
"T-Deck": "t-deck",
|
||||
"T-Deck TFT": "t-deck-tft",
|
||||
"T-Deck Pro": "t-deck-pro",
|
||||
"T-Echo": "t-echo",
|
||||
"T-Echo InkHUD": "t-echo-inkhud",
|
||||
"T-Echo Lite": "t-echo-lite",
|
||||
"T-LoRa V1": "tlora-v1",
|
||||
"T-LoRa V1.3": "tlora_v1_3",
|
||||
"T-LoRa V2": "tlora-v2",
|
||||
"T-LoRa V2.1-1.6": "tlora-v2-1-1_6",
|
||||
"T-LoRa V2.1-1.6 TCXO": "tlora-v2-1-1_6-tcxo",
|
||||
"T-LoRa V2.1-1.8": "tlora-v2-1-1_8",
|
||||
"T-LoRa V3.3.0 TCXO": "tlora-v3-3-0-tcxo",
|
||||
"T-LoRa C6": "tlora-c6",
|
||||
"T-LoRa Pager": "tlora-pager",
|
||||
"T-LoRa Pager TFT": "tlora-pager-tft",
|
||||
"T-LoRa T3-S3": "tlora-t3s3-v1",
|
||||
"T-LoRa T3-S3 E-Paper": "tlora-t3s3-epaper",
|
||||
"T-LoRa T3-S3 E-Paper InkHUD": "tlora-t3s3-epaper-inkhud",
|
||||
"T-Watch S3": "t-watch-s3",
|
||||
"Sugar Cube": "sugarcube"
|
||||
},
|
||||
"M5Stack": {
|
||||
"Core": "m5stack-core",
|
||||
"Core Ink": "m5stack-coreink",
|
||||
"Core S3": "m5stack-cores3",
|
||||
"Stamp C3": "m5stack-stamp-c3",
|
||||
"Unit C6L": "m5stack-unitc6l"
|
||||
},
|
||||
"MakerPython": {
|
||||
"NRF52840 SX1280 E-Ink": "makerpython_nrf52840_sx1280_eink",
|
||||
"NRF52840 SX1280 OLED": "makerpython_nrf52840_sx1280_oled"
|
||||
},
|
||||
"muzi": {
|
||||
"R1 Neo": "r1-neo"
|
||||
},
|
||||
"NomadStar": {
|
||||
"Meteor Pro": "rak4631_nomadstar_meteor_pro",
|
||||
"Meteor Pro Debug": "rak4631_nomadstar_meteor_pro_dbg"
|
||||
},
|
||||
"RAK": {
|
||||
"WisBlock 11200": "rak11200",
|
||||
"WisBlock 11310": "rak11310",
|
||||
"WisBlock 4631": "rak4631",
|
||||
"WisBlock 4631 Debug": "rak4631_dbg",
|
||||
"WisBlock 4631 E-Ink": "rak4631_eink",
|
||||
"WisBlock 4631 E-Ink on RX/TX": "rak4631_eink_onrxtx",
|
||||
"WisBlock 4631 ETH Gateway": "rak4631_eth_gw",
|
||||
"WisBlock 4631 ETH Gateway Debug": "rak4631_eth_gw_dbg",
|
||||
"WisBlock 3312": "rak3312",
|
||||
"WisBlock 3172": "rak3172",
|
||||
"WisBlock 2560": "rak2560",
|
||||
"WisBlock 3401 1Watt": "rak3401-1watt",
|
||||
"WisMesh Tag": "rak_wismeshtag",
|
||||
"WisMesh Tap": "rak_wismeshtap",
|
||||
"WisMesh Tap V2 TFT": "rak_wismesh_tap_v2-tft"
|
||||
},
|
||||
"RadioMaster": {
|
||||
"900 Bandit": "radiomaster_900_bandit",
|
||||
"900 Bandit Micro": "radiomaster_900_bandit_micro",
|
||||
"900 Bandit Nano": "radiomaster_900_bandit_nano"
|
||||
},
|
||||
"RPi": {
|
||||
"Pico": "pico",
|
||||
"Pico Slow Clock": "pico_slowclock",
|
||||
"Pico W": "picow",
|
||||
"Pico 2": "pico2",
|
||||
"Pico 2W": "pico2w"
|
||||
},
|
||||
"Seeed": {
|
||||
"Xiao ESP32-S3": "seeed-xiao-s3",
|
||||
"Xiao NRF52840 Kit": "seeed_xiao_nrf52840_kit",
|
||||
"Xiao NRF52840 Kit I2C": "seeed_xiao_nrf52840_kit_i2c",
|
||||
"Xiao NRF52840 WIO SX1262": "seeed-xiao-nrf52840-wio-sx1262",
|
||||
"Xiao NRF52840 E22900M30S": "seeed_xiao_nrf52840_e22_900m30s",
|
||||
"Xiao NRF52840 E22900M33S": "seeed_xiao_nrf52840_e22_900m33s",
|
||||
"Xiao BLE": "xiao_ble",
|
||||
"Wio Tracker L1": "seeed_wio_tracker_L1",
|
||||
"Wio Tracker L1 E-Ink": "seeed_wio_tracker_L1_eink",
|
||||
"Wio Tracker L1 E-Ink InkHUD": "seeed_wio_tracker_L1_eink-inkhud",
|
||||
"Wio Tracker WM1110": "wio-tracker-wm1110",
|
||||
"Wio SDK WM1110": "wio-sdk-wm1110",
|
||||
"Wio T1000S": "wio-t1000-s",
|
||||
"SenseCAP Indicator": "seeed-sensecap-indicator",
|
||||
"SenseCAP Indicator TFT": "seeed-sensecap-indicator-tft",
|
||||
"Solar Node": "seeed_solar_node"
|
||||
},
|
||||
"Waveshare": {
|
||||
"RP2040 LoRa": "rp2040-lora"
|
||||
},
|
||||
"Other": {
|
||||
"Chatter2": "chatter2",
|
||||
"9M2 IBRA PRS LoRa Tracker": "9m2ibr_aprs_lora_tracker",
|
||||
"AIC3": "ai-c3",
|
||||
"ESP32-C3": "esp32c3",
|
||||
"ESP32-C3 Super Mini": "esp32c3_super_mini",
|
||||
"ESP32-C6": "esp32c6",
|
||||
"ESP32-S2": "esp32s2",
|
||||
"ESP32-S3": "esp32s3",
|
||||
"ESP32-S3 Pico": "ESP32-S3-Pico",
|
||||
"Crowpanel ESP32-S3 2 E-Paper": "crowpanel-esp32s3-2-epaper",
|
||||
"Crowpanel ESP32-S3 4 E-Paper": "crowpanel-esp32s3-4-epaper",
|
||||
"Crowpanel ESP32-S3 5 E-Paper": "crowpanel-esp32s3-5-epaper",
|
||||
"BPi Pico W ESP32-S3": "bpi_picow_esp32_s3",
|
||||
"My ESP32-S3 DIY E-Ink": "my-esp32s3-diy-eink",
|
||||
"My ESP32-S3 DIY OLED": "my-esp32s3-diy-oled",
|
||||
"T-Energy S3 E22": "t-energy-s3_e22",
|
||||
"Dreamcatcher 2206": "dreamcatcher-2206",
|
||||
"Hackaday Communicator": "hackaday-communicator",
|
||||
"Icarus": "icarus",
|
||||
"Link32S3 V1": "link32-s3-v1",
|
||||
"MeshTab 32 TN Resistive": "mesh-tab-3-2-TN-resistive",
|
||||
"MeshTab 32 IPS Resistive": "mesh-tab-3-2-IPS-resistive",
|
||||
"MeshTab 35 IPS Resistive": "mesh-tab-3-5-IPS-resistive",
|
||||
"MeshTab 35 TN Resistive": "mesh-tab-3-5-TN-resistive",
|
||||
"MeshTab 32 IPS Capacitive": "mesh-tab-3-2-IPS-capacitive",
|
||||
"MeshTab 35 IPS Capacitive": "mesh-tab-3-5-IPS-capacitive",
|
||||
"MeshTab 40 IPS Capacitive": "mesh-tab-4-0-IPS-capacitive",
|
||||
"Nibble ESP32": "nibble-esp32",
|
||||
"Nugget S2 LoRa": "nugget-s2-lora",
|
||||
"Nugget S3 LoRa": "nugget-s3-lora",
|
||||
"Pi Computer S3": "picomputer-s3",
|
||||
"Pi Computer S3 TFT": "picomputer-s3-tft",
|
||||
"T-Echo Lite": "t-eth-elite",
|
||||
"Tracksenger": "tracksenger",
|
||||
"Tracksenger LCD": "tracksenger-lcd",
|
||||
"Tracksenger OLED": "tracksenger-oled",
|
||||
"unPhone": "unphone",
|
||||
"unPhone TFT": "unphone-tft",
|
||||
"NRF52832": "nrf52832",
|
||||
"NRF52840": "nrf52840",
|
||||
"ThinkNode M1 InkHUD": "thinknode_m1-inkhud",
|
||||
"ME25LS014Y10TD": "ME25LS01-4Y10TD",
|
||||
"ME25LS014Y10TD E-Ink": "ME25LS01-4Y10TD_e-ink",
|
||||
"MS24SF1": "ms24sf1",
|
||||
"TWC Mesh V4": "TWC_mesh_v4",
|
||||
"WashTastic": "WashTastic",
|
||||
"Feather DIY": "feather_diy",
|
||||
"Feather RP2040 RFM95": "feather_rp2040_rfm95",
|
||||
"GAT562 Mesh Trial Tracker": "gat562_mesh_trial_tracker",
|
||||
"MeshLink": "meshlink",
|
||||
"MeshLink E-Ink": "meshlink_eink",
|
||||
"MeshTiny": "meshtiny",
|
||||
"MonteOps HW1": "monteops_hw1",
|
||||
"R1 Neo": "r1-neo",
|
||||
"Tracker T1000E": "tracker-t1000-e",
|
||||
"Tracker D": "trackerd",
|
||||
"WiPhone": "wiphone",
|
||||
"Challenger 2040 LoRa": "challenger_2040_lora",
|
||||
"Cat Sniffer": "catsniffer",
|
||||
"Nibbler RP2040": "nibble-rp2040",
|
||||
"SenseLoRa RP2040": "senselora_rp2040",
|
||||
"Wio E5": "wio-e5",
|
||||
"Coverage": "coverage",
|
||||
"Buildroot": "buildroot"
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,10 @@ import { v } from "convex/values"
|
||||
import { api, internal } from "./_generated/api"
|
||||
import type { Doc, Id } from "./_generated/dataModel"
|
||||
import { internalMutation, mutation, query } from "./_generated/server"
|
||||
import { getArtifactFilenameBase } from "./lib/filename"
|
||||
import { ArtifactType, getArtifactFilenameBase } from "./lib/filename"
|
||||
import { generateSignedDownloadUrl } from "./lib/r2"
|
||||
import { buildFields } from "./schema"
|
||||
|
||||
export enum ArtifactType {
|
||||
Firmware = "firmware",
|
||||
Source = "source",
|
||||
}
|
||||
|
||||
type BuildUpdateData = {
|
||||
status: string
|
||||
completedAt?: number
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* Artifact type enumeration.
|
||||
* Safe to import in client-side code.
|
||||
*/
|
||||
export enum ArtifactType {
|
||||
Firmware = "firmware",
|
||||
Source = "source",
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the artifact filename base (without extension) matching the download filename format.
|
||||
* Format: meshtastic-{version}-{target}-{last4hash}-{jobId}-{artifactType}
|
||||
|
||||
74
hooks/usePluginCompatibility.ts
Normal file
74
hooks/usePluginCompatibility.ts
Normal 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
215
hooks/useTargetSelection.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
80
lib/utils.ts
80
lib/utils.ts
@@ -127,30 +127,19 @@ export function isRequiredByOther(
|
||||
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
|
||||
let current: string | null = name
|
||||
|
||||
while (current && !visited.has(current)) {
|
||||
visited.add(current)
|
||||
const parent = parentMap[current]
|
||||
if (!current) break
|
||||
const parent: string | null | undefined = parentMap[current]
|
||||
|
||||
// If parent is null, we've reached a base architecture
|
||||
if (parent === null) {
|
||||
@@ -162,11 +151,11 @@ export function getBaseArchitecture(name: string): string | null {
|
||||
return current
|
||||
}
|
||||
|
||||
current = normalizeArchitecture(parent)
|
||||
current = parent
|
||||
}
|
||||
|
||||
// Circular reference or unknown, return the last known
|
||||
return current || normalized
|
||||
return current || name
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,17 +163,16 @@ export function getBaseArchitecture(name: string): string | null {
|
||||
* (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 compatible = [arch]
|
||||
const visited = new Set<string>()
|
||||
let current = normalized
|
||||
let current: string | null = arch
|
||||
|
||||
// Follow parent chain up to base architecture
|
||||
while (current && !visited.has(current)) {
|
||||
visited.add(current)
|
||||
const parent = parentMap[current]
|
||||
if (!current) break
|
||||
const parent: string | null | undefined = parentMap[current]
|
||||
|
||||
if (parent === null) {
|
||||
// Reached base architecture
|
||||
@@ -196,12 +184,11 @@ export function getCompatibleArchitectures(arch: string): string[] {
|
||||
break
|
||||
}
|
||||
|
||||
const normalizedParent = normalizeArchitecture(parent)
|
||||
if (!compatible.includes(normalizedParent)) {
|
||||
compatible.push(normalizedParent)
|
||||
if (!compatible.includes(parent)) {
|
||||
compatible.push(parent)
|
||||
}
|
||||
|
||||
current = normalizedParent
|
||||
current = parent
|
||||
}
|
||||
|
||||
return compatible
|
||||
@@ -227,18 +214,16 @@ export function isPluginCompatibleWithTarget(
|
||||
|
||||
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 compatibleNames = new Set<string>([targetName])
|
||||
const visited = new Set<string>()
|
||||
let current = normalizedTarget
|
||||
let current: string | null = targetName
|
||||
|
||||
// Follow parent chain (all keys and values in parentMap are already normalized)
|
||||
// Follow parent chain
|
||||
while (current && !visited.has(current)) {
|
||||
visited.add(current)
|
||||
const parent = parentMap[current]
|
||||
if (!current) break
|
||||
const parent: string | null | undefined = parentMap[current]
|
||||
|
||||
if (parent === null) {
|
||||
// Reached base architecture
|
||||
@@ -251,30 +236,21 @@ export function isPluginCompatibleWithTarget(
|
||||
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)
|
||||
})
|
||||
const isExcluded = pluginExcludes.some(exclude => compatibleNames.has(exclude))
|
||||
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)
|
||||
})
|
||||
return pluginIncludes.some(include => compatibleNames.has(include))
|
||||
}
|
||||
|
||||
// If no includes/excludes specified, assume compatible with all (backward compatible)
|
||||
@@ -301,34 +277,30 @@ export function isPluginCompatibleWithArchitecture(
|
||||
export function getTargetsCompatibleWithIncludes(includes: string[]): Set<string> {
|
||||
const parentMap = PARENT_MAP as Record<string, string | null>
|
||||
const compatibleTargets = new Set<string>()
|
||||
|
||||
// Normalize includes
|
||||
const normalizedIncludes = new Set(includes.map(include => normalizeArchitecture(include)))
|
||||
const includesSet = new Set(includes)
|
||||
|
||||
// For each target in the parent map, check if it or any of its ancestors match the includes
|
||||
for (const target of Object.keys(parentMap)) {
|
||||
const normalizedTarget = normalizeArchitecture(target)
|
||||
const visited = new Set<string>()
|
||||
let current: string | null = normalizedTarget
|
||||
let current: string | null = target
|
||||
|
||||
// Trace up the parent chain
|
||||
while (current && !visited.has(current)) {
|
||||
visited.add(current)
|
||||
if (!current) break
|
||||
|
||||
// Check if current matches any of the includes
|
||||
if (normalizedIncludes.has(current)) {
|
||||
// Add both the normalized version and the original (for matching against TARGETS)
|
||||
compatibleTargets.add(normalizedTarget)
|
||||
if (includesSet.has(current)) {
|
||||
compatibleTargets.add(target)
|
||||
break
|
||||
}
|
||||
|
||||
// Move to parent
|
||||
const parentValue = parentMap[current]
|
||||
if (parentValue === null || parentValue === undefined) {
|
||||
const parent: string | null | undefined = parentMap[current]
|
||||
if (parent === null || parent === undefined) {
|
||||
break
|
||||
}
|
||||
current = normalizeArchitecture(parentValue)
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mesh-forge",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"author": "benallfree",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Builder from "@/components/Builder"
|
||||
import { BuildProgress } from "@/components/BuildProgress"
|
||||
import { GiscusComments } from "@/components/GiscusComments"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
@@ -6,29 +7,27 @@ import { Loader2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { usePageContext } from "vike-react/usePageContext"
|
||||
|
||||
export default function BuildProgressPage() {
|
||||
export default function BuildsPage() {
|
||||
const pageContext = usePageContext()
|
||||
const buildHash = pageContext.routeParams?.buildHash as string | undefined
|
||||
const build = useQuery(api.builds.getByHash, buildHash ? { buildHash } : "skip")
|
||||
const urlSearchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null
|
||||
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 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) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">
|
||||
@@ -1,5 +0,0 @@
|
||||
import Builder from "./Builder"
|
||||
|
||||
export default function BuildNew() {
|
||||
return <Builder />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import Builder from "../Builder"
|
||||
|
||||
export default function BuildNew() {
|
||||
return <Builder />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -149,7 +149,7 @@ export default function LandingPage() {
|
||||
description={customBuildPlugin.description}
|
||||
imageUrl={customBuildPlugin.imageUrl}
|
||||
featured={false}
|
||||
href="/builds/new"
|
||||
href="/builds"
|
||||
prominent={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function PluginPage() {
|
||||
Homepage
|
||||
</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
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -9,14 +9,6 @@ const FIRMWARE_DIR = path.resolve(__dirname, "../vendor/firmware")
|
||||
const VARIANTS_DIR = path.join(FIRMWARE_DIR, "variants")
|
||||
const OUTPUT_FILE = path.resolve(__dirname, "../constants/architecture-hierarchy.json")
|
||||
|
||||
/**
|
||||
* Normalize architecture/target name (remove hyphens and underscores)
|
||||
* This ensures consistent format matching PlatformIO architecture names
|
||||
*/
|
||||
function normalizeName(name) {
|
||||
return name.replace(/[-_]/g, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse PlatformIO ini file to extract sections and their properties
|
||||
*/
|
||||
@@ -342,15 +334,8 @@ function buildParentMapping() {
|
||||
delete resolvedParentMap[key]
|
||||
}
|
||||
|
||||
// Normalize all keys and values (strip hyphens and underscores)
|
||||
const normalizedMap = {}
|
||||
for (const [key, value] of Object.entries(resolvedParentMap)) {
|
||||
const normalizedKey = normalizeName(key)
|
||||
const normalizedValue = value !== null ? normalizeName(value) : null
|
||||
normalizedMap[normalizedKey] = normalizedValue
|
||||
}
|
||||
|
||||
return normalizedMap
|
||||
// Return map with actual PlatformIO environment names (no normalization)
|
||||
return resolvedParentMap
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
1
vendor/api
vendored
1
vendor/api
vendored
Submodule vendor/api deleted from 1774354d2b
1
vendor/firmware
vendored
1
vendor/firmware
vendored
Submodule vendor/firmware deleted from 5910cc2e26
1
vendor/meshcore
vendored
1
vendor/meshcore
vendored
Submodule vendor/meshcore deleted from 6d3219329f
1
vendor/web-flasher
vendored
1
vendor/web-flasher
vendored
Submodule vendor/web-flasher deleted from c165572117
Reference in New Issue
Block a user