mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-03-28 17:42:55 +01:00
fix: refactor build routes for improved navigation
This commit is contained in:
@@ -13,10 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Refactored targets.ts to use vendors.json and architecture-hierarchy.json instead of hardware-list.json
|
- Refactored targets.ts to use vendors.json and architecture-hierarchy.json instead of hardware-list.json
|
||||||
- Updated architecture-hierarchy.json generation to use actual PlatformIO environment names (removed normalization)
|
- Updated architecture-hierarchy.json generation to use actual PlatformIO environment names (removed normalization)
|
||||||
- Removed normalization from lib/utils.ts since all inputs now use standardized PlatformIO names
|
- Removed normalization from lib/utils.ts since all inputs now use standardized PlatformIO names
|
||||||
|
- Refactored build routes from dynamic parameterized routes to query string parameters for Vike SSG compatibility
|
||||||
|
|
||||||
### Patch
|
### Patch
|
||||||
|
|
||||||
- Fix Convex server functions being imported in browser by moving ArtifactType enum to client-safe location
|
- Fix Convex server functions being imported in browser by moving ArtifactType enum to client-safe location
|
||||||
|
- Fix nested anchor tag hydration error in PluginCard component by converting nested links to buttons when parent is a link
|
||||||
|
|
||||||
## [0.3.0] - 2025-12-10
|
## [0.3.0] - 2025-12-10
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
|
|||||||
? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`
|
? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const shareUrl = `${window.location.origin}/builds/new/${build.buildHash}`
|
const shareUrl = `${window.location.origin}/builds?clone=${build.buildHash}`
|
||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -216,10 +216,10 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
|
|||||||
<h2 className="text-2xl font-semibold mb-2 flex items-center gap-2">
|
<h2 className="text-2xl font-semibold mb-2 flex items-center gap-2">
|
||||||
{getStatusIcon()}
|
{getStatusIcon()}
|
||||||
<a
|
<a
|
||||||
href={`/builds/${build.buildHash}`}
|
href={`/builds?id=${build.buildHash}`}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
navigate(`/builds/${build.buildHash}`)
|
navigate(`/builds?id=${build.buildHash}`)
|
||||||
}}
|
}}
|
||||||
className="hover:text-cyan-400 transition-colors"
|
className="hover:text-cyan-400 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -257,7 +257,7 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
|
|||||||
{showActions && (
|
{showActions && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate(`/builds/new/${build.buildHash}`)}
|
onClick={() => navigate(`/builds?clone=${build.buildHash}`)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-slate-600 hover:bg-slate-800"
|
className="border-slate-600 hover:bg-slate-800"
|
||||||
aria-label="Clone"
|
aria-label="Clone"
|
||||||
|
|||||||
296
components/Builder.tsx
Normal file
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>
|
<span>{downloads.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{homepage && homepage !== repo && (isLink || isLinkToggle) && (
|
{homepage &&
|
||||||
<a
|
homepage !== repo &&
|
||||||
href={homepage}
|
(isLink || isLinkToggle) &&
|
||||||
target="_blank"
|
(isLink ? (
|
||||||
rel="noopener noreferrer"
|
<button
|
||||||
onClick={e => e.stopPropagation()}
|
type="button"
|
||||||
className="hover:opacity-80 transition-opacity"
|
onClick={e => {
|
||||||
>
|
e.preventDefault()
|
||||||
<svg
|
e.stopPropagation()
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
window.open(homepage, "_blank", "noopener,noreferrer")
|
||||||
width="16"
|
}}
|
||||||
height="16"
|
className="hover:opacity-80 transition-opacity"
|
||||||
viewBox="0 0 24 24"
|
|
||||||
className="text-slate-400"
|
|
||||||
fill="currentColor"
|
|
||||||
role="img"
|
|
||||||
aria-label="Homepage"
|
aria-label="Homepage"
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="text-slate-400"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
|
role="img"
|
||||||
/>
|
aria-label="Homepage"
|
||||||
</svg>
|
>
|
||||||
</a>
|
<path
|
||||||
)}
|
fill="currentColor"
|
||||||
{starsBadgeUrl && repo && (
|
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
|
||||||
<a
|
/>
|
||||||
href={repo}
|
</svg>
|
||||||
target="_blank"
|
</button>
|
||||||
rel="noopener noreferrer"
|
) : (
|
||||||
onClick={e => e.stopPropagation()}
|
<a
|
||||||
className="hover:opacity-80 transition-opacity"
|
href={homepage}
|
||||||
>
|
target="_blank"
|
||||||
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
|
rel="noopener noreferrer"
|
||||||
</a>
|
onClick={e => e.stopPropagation()}
|
||||||
)}
|
className="hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="text-slate-400"
|
||||||
|
fill="currentColor"
|
||||||
|
role="img"
|
||||||
|
aria-label="Homepage"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
{starsBadgeUrl &&
|
||||||
|
repo &&
|
||||||
|
(isLink ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
window.open(repo, "_blank", "noopener,noreferrer")
|
||||||
|
}}
|
||||||
|
className="hover:opacity-80 transition-opacity"
|
||||||
|
aria-label="GitHub repository"
|
||||||
|
>
|
||||||
|
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={repo}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Build Now button - absolutely positioned in lower right */}
|
{/* Build Now button - absolutely positioned in lower right */}
|
||||||
{isLink && (
|
{isLink && (
|
||||||
@@ -249,7 +294,7 @@ export function PluginCard(props: PluginCardProps) {
|
|||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
navigate(`/builds/new?plugin=${id}`)
|
navigate(`/builds?plugin=${id}`)
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 rounded hover:bg-cyan-400/20 transition-colors cursor-pointer"
|
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 rounded hover:bg-cyan-400/20 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -287,7 +332,7 @@ export function PluginCard(props: PluginCardProps) {
|
|||||||
} ${isLink ? "group" : ""}`
|
} ${isLink ? "group" : ""}`
|
||||||
|
|
||||||
if (isLink) {
|
if (isLink) {
|
||||||
const href = props.href || `/builds/new?plugin=${id}`
|
const href = props.href || `/builds?plugin=${id}`
|
||||||
return (
|
return (
|
||||||
<a href={href} className={baseClassName}>
|
<a href={href} className={baseClassName}>
|
||||||
{cardContent}
|
{cardContent}
|
||||||
|
|||||||
158
components/PluginConfig.tsx
Normal file
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Builder from "@/components/Builder"
|
||||||
import { BuildProgress } from "@/components/BuildProgress"
|
import { BuildProgress } from "@/components/BuildProgress"
|
||||||
import { GiscusComments } from "@/components/GiscusComments"
|
import { GiscusComments } from "@/components/GiscusComments"
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
@@ -6,29 +7,27 @@ import { Loader2 } from "lucide-react"
|
|||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { usePageContext } from "vike-react/usePageContext"
|
import { usePageContext } from "vike-react/usePageContext"
|
||||||
|
|
||||||
export default function BuildProgressPage() {
|
export default function BuildsPage() {
|
||||||
const pageContext = usePageContext()
|
const pageContext = usePageContext()
|
||||||
const buildHash = pageContext.routeParams?.buildHash as string | undefined
|
const urlSearchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null
|
||||||
const build = useQuery(api.builds.getByHash, buildHash ? { buildHash } : "skip")
|
const cloneHash = urlSearchParams?.get("clone")
|
||||||
|
const buildId = urlSearchParams?.get("id")
|
||||||
|
const pluginParam = urlSearchParams?.get("plugin")
|
||||||
|
|
||||||
|
// If we have a build ID, show the build progress page
|
||||||
|
if (buildId) {
|
||||||
|
return <BuildViewPage buildHash={buildId} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, show the builder (handles clone and plugin params)
|
||||||
|
return <Builder cloneHash={cloneHash || undefined} pluginParam={pluginParam || undefined} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function BuildViewPage({ buildHash }: { buildHash: string }) {
|
||||||
|
const build = useQuery(api.builds.getByHash, { buildHash })
|
||||||
const isAdmin = useQuery(api.admin.isAdmin)
|
const isAdmin = useQuery(api.admin.isAdmin)
|
||||||
const retryBuild = useMutation(api.admin.retryBuild)
|
const retryBuild = useMutation(api.admin.retryBuild)
|
||||||
|
|
||||||
if (!buildHash) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-slate-950 text-white p-8">
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
<p className="text-slate-300">
|
|
||||||
Build hash missing.{" "}
|
|
||||||
<a href="/builds/new" className="text-cyan-400">
|
|
||||||
Start a new build
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (build === undefined) {
|
if (build === undefined) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">
|
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">
|
||||||
@@ -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}
|
description={customBuildPlugin.description}
|
||||||
imageUrl={customBuildPlugin.imageUrl}
|
imageUrl={customBuildPlugin.imageUrl}
|
||||||
featured={false}
|
featured={false}
|
||||||
href="/builds/new"
|
href="/builds"
|
||||||
prominent={true}
|
prominent={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export default function PluginPage() {
|
|||||||
Homepage
|
Homepage
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={() => navigate(`/builds/new?plugin=${slug}`)} className="bg-cyan-600 hover:bg-cyan-700">
|
<Button onClick={() => navigate(`/builds?plugin=${slug}`)} className="bg-cyan-600 hover:bg-cyan-700">
|
||||||
Build with this Plugin
|
Build with this Plugin
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user