forked from iarv/mesh-forge
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
|
||||
- Updated architecture-hierarchy.json generation to use actual PlatformIO environment names (removed normalization)
|
||||
- Removed normalization from lib/utils.ts since all inputs now use standardized PlatformIO names
|
||||
- Refactored build routes from dynamic parameterized routes to query string parameters for Vike SSG compatibility
|
||||
|
||||
### Patch
|
||||
|
||||
- Fix Convex server functions being imported in browser by moving ArtifactType enum to client-safe location
|
||||
- Fix nested anchor tag hydration error in PluginCard component by converting nested links to buttons when parent is a link
|
||||
|
||||
## [0.3.0] - 2025-12-10
|
||||
|
||||
|
||||
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}`
|
||||
: null
|
||||
|
||||
const shareUrl = `${window.location.origin}/builds/new/${build.buildHash}`
|
||||
const shareUrl = `${window.location.origin}/builds?clone=${build.buildHash}`
|
||||
|
||||
const handleShare = async () => {
|
||||
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">
|
||||
{getStatusIcon()}
|
||||
<a
|
||||
href={`/builds/${build.buildHash}`}
|
||||
href={`/builds?id=${build.buildHash}`}
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
navigate(`/builds/${build.buildHash}`)
|
||||
navigate(`/builds?id=${build.buildHash}`)
|
||||
}}
|
||||
className="hover:text-cyan-400 transition-colors"
|
||||
>
|
||||
@@ -257,7 +257,7 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
|
||||
{showActions && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => navigate(`/builds/new/${build.buildHash}`)}
|
||||
onClick={() => navigate(`/builds?clone=${build.buildHash}`)}
|
||||
variant="outline"
|
||||
className="border-slate-600 hover:bg-slate-800"
|
||||
aria-label="Clone"
|
||||
|
||||
296
components/Builder.tsx
Normal file
296
components/Builder.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { BuildActions } from "@/components/BuildActions"
|
||||
import { BuilderHeader } from "@/components/BuilderHeader"
|
||||
import { ModuleConfig } from "@/components/ModuleConfig"
|
||||
import { PluginConfig } from "@/components/PluginConfig"
|
||||
import { TargetSelector } from "@/components/TargetSelector"
|
||||
import { VersionSelector } from "@/components/VersionSelector"
|
||||
import { TARGETS } from "@/constants/targets"
|
||||
import { VERSIONS } from "@/constants/versions"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { usePluginCompatibility } from "@/hooks/usePluginCompatibility"
|
||||
import { useTargetSelection } from "@/hooks/useTargetSelection"
|
||||
import { getDependedPlugins, getImplicitDependencies, isRequiredByOther } from "@/lib/utils"
|
||||
import registryData from "@/public/registry.json"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { navigate } from "vike/client/router"
|
||||
|
||||
interface BuilderProps {
|
||||
cloneHash?: string
|
||||
pluginParam?: string
|
||||
}
|
||||
|
||||
export default function Builder({ cloneHash, pluginParam }: BuilderProps) {
|
||||
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
|
||||
const pluginFlashCounts = useQuery(api.plugins.getAll) ?? {}
|
||||
const sharedBuild = useQuery(api.builds.getByHash, cloneHash ? { buildHash: cloneHash } : "skip")
|
||||
|
||||
const preselectedPlugin =
|
||||
pluginParam && pluginParam in registryData
|
||||
? (
|
||||
registryData as Record<
|
||||
string,
|
||||
{ includes?: string[]; name: string; description: string; imageUrl?: string; featured?: boolean }
|
||||
>
|
||||
)[pluginParam]
|
||||
: null
|
||||
|
||||
const [selectedVersion, setSelectedVersion] = useState<string>(VERSIONS[0])
|
||||
const [moduleConfig, setModuleConfig] = useState<Record<string, boolean>>({})
|
||||
const [pluginConfig, setPluginConfig] = useState<Record<string, boolean>>({})
|
||||
const [isFlashing, setIsFlashing] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [showModuleOverrides, setShowModuleOverrides] = useState(false)
|
||||
const [showPlugins, setShowPlugins] = useState(true)
|
||||
const [isLoadingSharedBuild, setIsLoadingSharedBuild] = useState(false)
|
||||
|
||||
// Get all enabled plugins
|
||||
const enabledPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
|
||||
|
||||
// Calculate plugin compatibility
|
||||
const { compatibleTargets, filteredGroupedTargets, filteredTargetCategories } = usePluginCompatibility(
|
||||
enabledPlugins,
|
||||
preselectedPlugin
|
||||
)
|
||||
|
||||
// Target selection logic
|
||||
const { activeCategory, selectedTarget, setActiveCategory, handleSelectTarget, TARGET_CATEGORIES, GROUPED_TARGETS } =
|
||||
useTargetSelection(compatibleTargets, filteredGroupedTargets, filteredTargetCategories)
|
||||
|
||||
// Preselect plugin from URL parameter
|
||||
useEffect(() => {
|
||||
if (pluginParam && preselectedPlugin && !cloneHash) {
|
||||
setPluginConfig({ [pluginParam]: true })
|
||||
setShowPlugins(true)
|
||||
}
|
||||
}, [pluginParam, preselectedPlugin, cloneHash])
|
||||
|
||||
// Pre-populate form from shared build
|
||||
useEffect(() => {
|
||||
if (!cloneHash) return
|
||||
if (sharedBuild === undefined) {
|
||||
setIsLoadingSharedBuild(true)
|
||||
return
|
||||
}
|
||||
setIsLoadingSharedBuild(false)
|
||||
|
||||
if (!sharedBuild) {
|
||||
setErrorMessage("Build not found. The shared build may have been deleted.")
|
||||
toast.error("Build not found", {
|
||||
description: "The shared build could not be loaded.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const config = sharedBuild.config
|
||||
|
||||
if (config.target && TARGETS[config.target]) {
|
||||
handleSelectTarget(config.target)
|
||||
const category = TARGETS[config.target].category || "Other"
|
||||
if (TARGET_CATEGORIES.includes(category)) {
|
||||
setActiveCategory(category)
|
||||
}
|
||||
}
|
||||
|
||||
if (config.version && (VERSIONS as readonly string[]).includes(config.version)) {
|
||||
setSelectedVersion(config.version as (typeof VERSIONS)[number])
|
||||
}
|
||||
|
||||
if (config.modulesExcluded) {
|
||||
setModuleConfig(config.modulesExcluded)
|
||||
if (Object.keys(config.modulesExcluded).length > 0) {
|
||||
setShowModuleOverrides(true)
|
||||
}
|
||||
}
|
||||
|
||||
if (config.pluginsEnabled && config.pluginsEnabled.length > 0) {
|
||||
const allPluginSlugs = config.pluginsEnabled.map(pluginId => {
|
||||
return pluginId.includes("@") ? pluginId.split("@")[0] : pluginId
|
||||
})
|
||||
|
||||
const requiredByOthers = new Set<string>()
|
||||
for (const pluginSlug of allPluginSlugs) {
|
||||
if (
|
||||
isRequiredByOther(
|
||||
pluginSlug,
|
||||
allPluginSlugs,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
) {
|
||||
requiredByOthers.add(pluginSlug)
|
||||
}
|
||||
}
|
||||
|
||||
const pluginObj: Record<string, boolean> = {}
|
||||
allPluginSlugs.forEach(slug => {
|
||||
if (slug in registryData && !requiredByOthers.has(slug)) {
|
||||
pluginObj[slug] = true
|
||||
}
|
||||
})
|
||||
setPluginConfig(pluginObj)
|
||||
setShowPlugins(true)
|
||||
}
|
||||
}, [cloneHash, sharedBuild, handleSelectTarget, setActiveCategory, TARGET_CATEGORIES])
|
||||
|
||||
const selectedTargetLabel = (selectedTarget && TARGETS[selectedTarget]?.name) || selectedTarget
|
||||
|
||||
const handleToggleModule = (id: string, excluded: boolean) => {
|
||||
setModuleConfig(prev => {
|
||||
const next = { ...prev }
|
||||
if (excluded) {
|
||||
next[id] = true
|
||||
} else {
|
||||
delete next[id]
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleTogglePlugin = (id: string, enabled: boolean) => {
|
||||
const explicitPlugins = Object.keys(pluginConfig).filter(pluginId => pluginConfig[pluginId] === true)
|
||||
|
||||
const implicitDeps = getImplicitDependencies(
|
||||
explicitPlugins,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
|
||||
const isRequired = isRequiredByOther(
|
||||
id,
|
||||
explicitPlugins,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
|
||||
if (implicitDeps.has(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!enabled && isRequired) {
|
||||
return
|
||||
}
|
||||
|
||||
setPluginConfig(prev => {
|
||||
const next = { ...prev }
|
||||
if (enabled) {
|
||||
next[id] = true
|
||||
} else {
|
||||
delete next[id]
|
||||
|
||||
const remainingExplicit = Object.keys(next).filter(pluginId => next[pluginId] === true)
|
||||
const allStillNeeded = getDependedPlugins(
|
||||
remainingExplicit,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
|
||||
for (const pluginId of Object.keys(next)) {
|
||||
if (next[pluginId] === true && !allStillNeeded.includes(pluginId) && !remainingExplicit.includes(pluginId)) {
|
||||
delete next[pluginId]
|
||||
}
|
||||
}
|
||||
|
||||
for (const pluginId of remainingExplicit) {
|
||||
next[pluginId] = true
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleFlash = async () => {
|
||||
if (!selectedTarget) return
|
||||
setIsFlashing(true)
|
||||
setErrorMessage(null)
|
||||
try {
|
||||
const enabledSlugs = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
|
||||
|
||||
const implicitDeps = getImplicitDependencies(
|
||||
enabledSlugs,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
const explicitOnlySlugs = enabledSlugs.filter(slug => !implicitDeps.has(slug))
|
||||
|
||||
const pluginsEnabled = explicitOnlySlugs.map(slug => {
|
||||
const plugin = (registryData as Record<string, { version: string }>)[slug]
|
||||
return `${slug}@${plugin.version}`
|
||||
})
|
||||
const result = await ensureBuildFromConfig({
|
||||
target: selectedTarget,
|
||||
version: selectedVersion,
|
||||
modulesExcluded: moduleConfig,
|
||||
pluginsEnabled: pluginsEnabled.length > 0 ? pluginsEnabled : undefined,
|
||||
})
|
||||
navigate(`/builds?id=${result.buildHash}`)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
setErrorMessage("Failed to start build. Please try again.")
|
||||
toast.error("Failed to start build", {
|
||||
description: message,
|
||||
})
|
||||
} finally {
|
||||
setIsFlashing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoadingSharedBuild) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white p-6 md:p-10 flex items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-cyan-500 mx-auto" />
|
||||
<p className="text-slate-400">Loading shared build configuration...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white p-6 md:p-10">
|
||||
<div className="max-w-6xl mx-auto space-y-8">
|
||||
<BuilderHeader preselectedPlugin={preselectedPlugin} />
|
||||
|
||||
<div className="space-y-6 bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
|
||||
<TargetSelector
|
||||
activeCategory={activeCategory}
|
||||
categories={categories}
|
||||
groupedTargets={compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS}
|
||||
selectedTarget={selectedTarget}
|
||||
compatibleTargets={compatibleTargets}
|
||||
onCategoryChange={setActiveCategory}
|
||||
onTargetSelect={handleSelectTarget}
|
||||
/>
|
||||
|
||||
<VersionSelector selectedVersion={selectedVersion} onVersionChange={setSelectedVersion} />
|
||||
|
||||
<ModuleConfig
|
||||
moduleConfig={moduleConfig}
|
||||
showModuleOverrides={showModuleOverrides}
|
||||
onToggleShow={() => setShowModuleOverrides(prev => !prev)}
|
||||
onToggleModule={handleToggleModule}
|
||||
onReset={() => setModuleConfig({})}
|
||||
/>
|
||||
|
||||
<PluginConfig
|
||||
pluginConfig={pluginConfig}
|
||||
selectedTarget={selectedTarget}
|
||||
pluginParam={pluginParam}
|
||||
pluginFlashCounts={pluginFlashCounts}
|
||||
showPlugins={showPlugins}
|
||||
onToggleShow={() => setShowPlugins(prev => !prev)}
|
||||
onTogglePlugin={handleTogglePlugin}
|
||||
onReset={() => setPluginConfig({})}
|
||||
/>
|
||||
|
||||
<BuildActions
|
||||
selectedTargetLabel={selectedTargetLabel}
|
||||
isFlashing={isFlashing}
|
||||
isFlashDisabled={!selectedTarget || isFlashing}
|
||||
errorMessage={errorMessage}
|
||||
onFlash={handleFlash}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
64
components/BuilderHeader.tsx
Normal file
64
components/BuilderHeader.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { CheckCircle2 } from "lucide-react"
|
||||
|
||||
interface BuilderHeaderProps {
|
||||
preselectedPlugin?: {
|
||||
name: string
|
||||
description: string
|
||||
imageUrl?: string
|
||||
featured?: boolean
|
||||
includes?: string[]
|
||||
} | null
|
||||
}
|
||||
|
||||
export function BuilderHeader({ preselectedPlugin }: BuilderHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-wider text-slate-500">
|
||||
{preselectedPlugin ? "Plugin build" : "Quick build"}
|
||||
</p>
|
||||
<h1 className="text-4xl font-bold mt-1">
|
||||
{preselectedPlugin ? `Build firmware for ${preselectedPlugin.name}` : "Flash a custom firmware version"}
|
||||
</h1>
|
||||
<p className="text-slate-400 mt-2 max-w-2xl">
|
||||
{preselectedPlugin
|
||||
? `Select a compatible Meshtastic target and configure your build for ${preselectedPlugin.name}. We'll send you to the build status page as soon as it starts.`
|
||||
: "Choose your Meshtastic target, adjust optional modules, and queue a new build instantly. We'll send you to the build status page as soon as it starts."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{preselectedPlugin && (
|
||||
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-400 shrink-0 mt-1" />
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
{preselectedPlugin.imageUrl && (
|
||||
<img
|
||||
src={preselectedPlugin.imageUrl}
|
||||
alt={`${preselectedPlugin.name} logo`}
|
||||
className="w-16 h-16 rounded-lg object-contain shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-2xl font-bold">{preselectedPlugin.name}</h2>
|
||||
{preselectedPlugin.featured && (
|
||||
<span className="px-2 py-1 text-xs font-medium text-green-400 bg-green-400/10 border border-green-400/20 rounded">
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-slate-400 mb-3">{preselectedPlugin.description}</p>
|
||||
{preselectedPlugin.includes && preselectedPlugin.includes.length > 0 && (
|
||||
<p className="text-sm text-slate-500">Compatible with: {preselectedPlugin.includes.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
74
components/ModuleConfig.tsx
Normal file
74
components/ModuleConfig.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { ModuleToggle } from "@/components/ModuleToggle"
|
||||
import modulesData from "@/convex/modules.json"
|
||||
import { ChevronDown, ChevronRight } from "lucide-react"
|
||||
|
||||
interface ModuleConfigProps {
|
||||
moduleConfig: Record<string, boolean>
|
||||
showModuleOverrides: boolean
|
||||
onToggleShow: () => void
|
||||
onToggleModule: (id: string, excluded: boolean) => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
export function ModuleConfig({
|
||||
moduleConfig,
|
||||
showModuleOverrides,
|
||||
onToggleShow,
|
||||
onToggleModule,
|
||||
onReset,
|
||||
}: ModuleConfigProps) {
|
||||
const moduleCount = Object.keys(moduleConfig).length
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
|
||||
<button type="button" onClick={onToggleShow} className="w-full flex items-center justify-between text-left">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Core Modules</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{moduleCount === 0
|
||||
? "Using default modules for this target."
|
||||
: `${moduleCount} module${moduleCount === 1 ? "" : "s"} excluded.`}
|
||||
</p>
|
||||
</div>
|
||||
{showModuleOverrides ? (
|
||||
<ChevronDown className="w-4 h-4 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showModuleOverrides && (
|
||||
<div className="space-y-2 pr-1">
|
||||
<div className="rounded-lg bg-slate-800/50 border border-slate-700 p-3">
|
||||
<p className="text-xs text-slate-400 leading-relaxed">
|
||||
Core Modules are officially maintained modules by Meshtastic. They are selectively included or excluded by
|
||||
default depending on the target device. You can explicitly exclude modules you know you don't want.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-slate-400 hover:text-white underline"
|
||||
onClick={onReset}
|
||||
disabled={moduleCount === 0}
|
||||
>
|
||||
Reset overrides
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{modulesData.modules.map(module => (
|
||||
<ModuleToggle
|
||||
key={module.id}
|
||||
id={module.id}
|
||||
name={module.name}
|
||||
description={module.description}
|
||||
isExcluded={moduleConfig[module.id] === true}
|
||||
onToggle={excluded => onToggleModule(module.id, excluded)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -205,42 +205,87 @@ export function PluginCard(props: PluginCardProps) {
|
||||
<span>{downloads.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{homepage && homepage !== repo && (isLink || isLinkToggle) && (
|
||||
<a
|
||||
href={homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
className="text-slate-400"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
{homepage &&
|
||||
homepage !== repo &&
|
||||
(isLink || isLinkToggle) &&
|
||||
(isLink ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
window.open(homepage, "_blank", "noopener,noreferrer")
|
||||
}}
|
||||
className="hover:opacity-80 transition-opacity"
|
||||
aria-label="Homepage"
|
||||
>
|
||||
<path
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
className="text-slate-400"
|
||||
fill="currentColor"
|
||||
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{starsBadgeUrl && repo && (
|
||||
<a
|
||||
href={repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
|
||||
</a>
|
||||
)}
|
||||
role="img"
|
||||
aria-label="Homepage"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
className="text-slate-400"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label="Homepage"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
))}
|
||||
{starsBadgeUrl &&
|
||||
repo &&
|
||||
(isLink ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
window.open(repo, "_blank", "noopener,noreferrer")
|
||||
}}
|
||||
className="hover:opacity-80 transition-opacity"
|
||||
aria-label="GitHub repository"
|
||||
>
|
||||
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
{/* Build Now button - absolutely positioned in lower right */}
|
||||
{isLink && (
|
||||
@@ -249,7 +294,7 @@ export function PluginCard(props: PluginCardProps) {
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
navigate(`/builds/new?plugin=${id}`)
|
||||
navigate(`/builds?plugin=${id}`)
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 rounded hover:bg-cyan-400/20 transition-colors cursor-pointer"
|
||||
>
|
||||
@@ -287,7 +332,7 @@ export function PluginCard(props: PluginCardProps) {
|
||||
} ${isLink ? "group" : ""}`
|
||||
|
||||
if (isLink) {
|
||||
const href = props.href || `/builds/new?plugin=${id}`
|
||||
const href = props.href || `/builds?plugin=${id}`
|
||||
return (
|
||||
<a href={href} className={baseClassName}>
|
||||
{cardContent}
|
||||
|
||||
158
components/PluginConfig.tsx
Normal file
158
components/PluginConfig.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { PluginCard } from "@/components/PluginCard"
|
||||
import {
|
||||
getDependedPlugins,
|
||||
getImplicitDependencies,
|
||||
isPluginCompatibleWithTarget,
|
||||
isRequiredByOther,
|
||||
} from "@/lib/utils"
|
||||
import registryData from "@/public/registry.json"
|
||||
import { ChevronDown, ChevronRight } from "lucide-react"
|
||||
|
||||
interface PluginConfigProps {
|
||||
pluginConfig: Record<string, boolean>
|
||||
selectedTarget: string
|
||||
pluginParam?: string
|
||||
pluginFlashCounts: Record<string, number>
|
||||
showPlugins: boolean
|
||||
onToggleShow: () => void
|
||||
onTogglePlugin: (id: string, enabled: boolean) => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
export function PluginConfig({
|
||||
pluginConfig,
|
||||
selectedTarget,
|
||||
pluginParam,
|
||||
pluginFlashCounts,
|
||||
showPlugins,
|
||||
onToggleShow,
|
||||
onTogglePlugin,
|
||||
onReset,
|
||||
}: PluginConfigProps) {
|
||||
const pluginCount = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true).length
|
||||
|
||||
// Get explicitly selected plugins (user-selected)
|
||||
const explicitPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
|
||||
|
||||
// Compute implicit dependencies (dependencies that are not explicitly selected)
|
||||
const implicitDeps = getImplicitDependencies(
|
||||
explicitPlugins,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
|
||||
// Compute all enabled plugins (explicit + implicit)
|
||||
const allEnabledPlugins = getDependedPlugins(
|
||||
explicitPlugins,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
|
||||
<button type="button" onClick={onToggleShow} className="w-full flex items-center justify-between text-left">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Plugins</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{pluginCount === 0
|
||||
? "No plugins enabled."
|
||||
: `${pluginCount} plugin${pluginCount === 1 ? "" : "s"} enabled.`}
|
||||
</p>
|
||||
</div>
|
||||
{showPlugins ? (
|
||||
<ChevronDown className="w-4 h-4 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showPlugins && (
|
||||
<div className="space-y-2 pr-1">
|
||||
<div className="rounded-lg bg-slate-800/50 border border-slate-700 p-3">
|
||||
<p className="text-xs text-slate-400 leading-relaxed">
|
||||
Plugins are 3rd party add-ons. They are not maintained, endorsed, or supported by Meshtastic. Use at your
|
||||
own risk.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-slate-400 hover:text-white underline"
|
||||
onClick={onReset}
|
||||
disabled={pluginCount === 0}
|
||||
>
|
||||
Reset plugins
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-2" key={`plugins-${selectedTarget}`}>
|
||||
{Object.entries(registryData)
|
||||
.sort(([, pluginA], [, pluginB]) => {
|
||||
// Featured plugins first
|
||||
const featuredA = pluginA.featured ?? false
|
||||
const featuredB = pluginB.featured ?? false
|
||||
if (featuredA !== featuredB) {
|
||||
return featuredA ? -1 : 1
|
||||
}
|
||||
// Then alphabetical by name
|
||||
return pluginA.name.localeCompare(pluginB.name)
|
||||
})
|
||||
.map(([slug, plugin]) => {
|
||||
// Check if plugin is required by another explicitly selected plugin
|
||||
const isRequired = isRequiredByOther(
|
||||
slug,
|
||||
explicitPlugins,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
// Plugin is implicit if it's either:
|
||||
// 1. Not explicitly selected but is a dependency, OR
|
||||
// 2. Explicitly selected but required by another explicitly selected plugin
|
||||
const isImplicit = implicitDeps.has(slug) || (explicitPlugins.includes(slug) && isRequired)
|
||||
|
||||
// Check plugin compatibility with selected target
|
||||
const pluginIncludes = (plugin as { includes?: string[] }).includes
|
||||
const pluginExcludes = (plugin as { excludes?: string[] }).excludes
|
||||
// Legacy support: check for old "architectures" field
|
||||
const legacyArchitectures = (plugin as { architectures?: string[] }).architectures
|
||||
const hasCompatibilityConstraints =
|
||||
(pluginIncludes && pluginIncludes.length > 0) ||
|
||||
(pluginExcludes && pluginExcludes.length > 0) ||
|
||||
(legacyArchitectures && legacyArchitectures.length > 0)
|
||||
const isCompatible =
|
||||
hasCompatibilityConstraints && selectedTarget
|
||||
? isPluginCompatibleWithTarget(
|
||||
pluginIncludes || legacyArchitectures,
|
||||
pluginExcludes,
|
||||
selectedTarget
|
||||
)
|
||||
: true // If no constraints or no target selected, assume compatible
|
||||
// Mark as incompatible if plugin has compatibility constraints and target is not compatible
|
||||
const isIncompatible = !isCompatible && hasCompatibilityConstraints && !!selectedTarget
|
||||
|
||||
// Check if this is the preselected plugin from URL
|
||||
const isPreselected = pluginParam === slug
|
||||
|
||||
return (
|
||||
<PluginCard
|
||||
key={`${slug}-${selectedTarget}`}
|
||||
variant="link-toggle"
|
||||
id={slug}
|
||||
name={plugin.name}
|
||||
description={plugin.description}
|
||||
imageUrl={plugin.imageUrl}
|
||||
isEnabled={allEnabledPlugins.includes(slug)}
|
||||
onToggle={enabled => onTogglePlugin(slug, enabled)}
|
||||
disabled={isImplicit || isIncompatible || isPreselected}
|
||||
enabledLabel={isPreselected ? "Locked" : isImplicit ? "Required" : "Add"}
|
||||
incompatibleReason={isIncompatible ? "Not compatible with this target" : undefined}
|
||||
featured={plugin.featured ?? false}
|
||||
flashCount={pluginFlashCounts[slug] ?? 0}
|
||||
homepage={plugin.homepage}
|
||||
version={plugin.version}
|
||||
repo={plugin.repo}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
components/TargetSelector.tsx
Normal file
76
components/TargetSelector.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { TARGETS } from "@/constants/targets"
|
||||
|
||||
type TargetGroup = (typeof TARGETS)[string] & { id: string }
|
||||
|
||||
interface TargetSelectorProps {
|
||||
activeCategory: string
|
||||
categories: string[]
|
||||
groupedTargets: Record<string, TargetGroup[]>
|
||||
selectedTarget: string
|
||||
compatibleTargets: Set<string> | null
|
||||
onCategoryChange: (category: string) => void
|
||||
onTargetSelect: (targetId: string) => void
|
||||
}
|
||||
|
||||
export function TargetSelector({
|
||||
activeCategory,
|
||||
categories,
|
||||
groupedTargets,
|
||||
selectedTarget,
|
||||
compatibleTargets,
|
||||
onCategoryChange,
|
||||
onTargetSelect,
|
||||
}: TargetSelectorProps) {
|
||||
const targets = activeCategory ? groupedTargets[activeCategory] : []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map(category => {
|
||||
const isActive = activeCategory === category
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() => onCategoryChange(category)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
isActive ? "bg-blue-600 text-white" : "bg-slate-800 text-slate-300 hover:bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950/60 p-4 rounded-lg border border-slate-800/60">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{targets?.map(target => {
|
||||
const isSelected = selectedTarget === target.id
|
||||
const normalizedId = target.id.replace(/[-_]/g, "")
|
||||
const isCompatible =
|
||||
!compatibleTargets || compatibleTargets.has(target.id) || compatibleTargets.has(normalizedId)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={target.id}
|
||||
type="button"
|
||||
onClick={() => isCompatible && onTargetSelect(target.id)}
|
||||
disabled={!isCompatible}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
isSelected
|
||||
? "bg-cyan-600 text-white"
|
||||
: isCompatible
|
||||
? "bg-slate-800 text-slate-300 hover:bg-slate-700"
|
||||
: "bg-slate-800/50 text-slate-500 cursor-not-allowed opacity-50"
|
||||
}`}
|
||||
>
|
||||
{target.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
components/VersionSelector.tsx
Normal file
28
components/VersionSelector.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { VERSIONS } from "@/constants/versions"
|
||||
|
||||
interface VersionSelectorProps {
|
||||
selectedVersion: string
|
||||
onVersionChange: (version: string) => void
|
||||
}
|
||||
|
||||
export function VersionSelector({ selectedVersion, onVersionChange }: VersionSelectorProps) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="build-version" className="block text-sm font-medium mb-2">
|
||||
Firmware version
|
||||
</label>
|
||||
<select
|
||||
id="build-version"
|
||||
value={selectedVersion}
|
||||
onChange={event => onVersionChange(event.target.value)}
|
||||
className="w-full h-10 px-3 rounded-md border border-slate-800 bg-slate-950 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:ring-offset-slate-950"
|
||||
>
|
||||
{VERSIONS.map(version => (
|
||||
<option key={version} value={version}>
|
||||
{version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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 { GiscusComments } from "@/components/GiscusComments"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
@@ -6,29 +7,27 @@ import { Loader2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { usePageContext } from "vike-react/usePageContext"
|
||||
|
||||
export default function BuildProgressPage() {
|
||||
export default function BuildsPage() {
|
||||
const pageContext = usePageContext()
|
||||
const buildHash = pageContext.routeParams?.buildHash as string | undefined
|
||||
const build = useQuery(api.builds.getByHash, buildHash ? { buildHash } : "skip")
|
||||
const urlSearchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null
|
||||
const cloneHash = urlSearchParams?.get("clone")
|
||||
const buildId = urlSearchParams?.get("id")
|
||||
const pluginParam = urlSearchParams?.get("plugin")
|
||||
|
||||
// If we have a build ID, show the build progress page
|
||||
if (buildId) {
|
||||
return <BuildViewPage buildHash={buildId} />
|
||||
}
|
||||
|
||||
// Otherwise, show the builder (handles clone and plugin params)
|
||||
return <Builder cloneHash={cloneHash || undefined} pluginParam={pluginParam || undefined} />
|
||||
}
|
||||
|
||||
function BuildViewPage({ buildHash }: { buildHash: string }) {
|
||||
const build = useQuery(api.builds.getByHash, { buildHash })
|
||||
const isAdmin = useQuery(api.admin.isAdmin)
|
||||
const retryBuild = useMutation(api.admin.retryBuild)
|
||||
|
||||
if (!buildHash) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<p className="text-slate-300">
|
||||
Build hash missing.{" "}
|
||||
<a href="/builds/new" className="text-cyan-400">
|
||||
Start a new build
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (build === undefined) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">
|
||||
@@ -1,5 +0,0 @@
|
||||
import Builder from "./Builder"
|
||||
|
||||
export default function BuildNew() {
|
||||
return <Builder />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import Builder from "../Builder"
|
||||
|
||||
export default function BuildNew() {
|
||||
return <Builder />
|
||||
}
|
||||
@@ -1,857 +0,0 @@
|
||||
import { ModuleToggle } from "@/components/ModuleToggle"
|
||||
import { PluginCard } from "@/components/PluginCard"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { TARGETS } from "@/constants/targets"
|
||||
import { VERSIONS } from "@/constants/versions"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import modulesData from "@/convex/modules.json"
|
||||
import {
|
||||
getDependedPlugins,
|
||||
getImplicitDependencies,
|
||||
getTargetsCompatibleWithIncludes,
|
||||
isPluginCompatibleWithTarget,
|
||||
isRequiredByOther,
|
||||
} from "@/lib/utils"
|
||||
import registryData from "@/public/registry.json"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { CheckCircle2, ChevronDown, ChevronRight, Loader2 } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { usePageContext } from "vike-react/usePageContext"
|
||||
import { navigate } from "vike/client/router"
|
||||
|
||||
type TargetGroup = (typeof TARGETS)[string] & { id: string }
|
||||
|
||||
const GROUPED_TARGETS = Object.entries(TARGETS).reduce(
|
||||
(acc, [id, meta]) => {
|
||||
const category = meta.category || "Other"
|
||||
if (!acc[category]) acc[category] = []
|
||||
acc[category].push({ id, ...meta })
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, TargetGroup[]>
|
||||
)
|
||||
|
||||
const TARGET_CATEGORIES = Object.keys(GROUPED_TARGETS).sort((a, b) => a.localeCompare(b))
|
||||
|
||||
const DEFAULT_TARGET =
|
||||
TARGET_CATEGORIES.length > 0 && GROUPED_TARGETS[TARGET_CATEGORIES[0]]?.length
|
||||
? GROUPED_TARGETS[TARGET_CATEGORIES[0]][0].id
|
||||
: ""
|
||||
|
||||
export default function BuildNew() {
|
||||
const pageContext = usePageContext()
|
||||
const buildHashParam = pageContext.routeParams?.buildHash
|
||||
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
|
||||
const pluginFlashCounts = useQuery(api.plugins.getAll) ?? {}
|
||||
const sharedBuild = useQuery(api.builds.getByHash, buildHashParam ? { buildHash: buildHashParam } : "skip")
|
||||
|
||||
// Get plugin from URL query parameter
|
||||
const pluginParam = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("plugin") : null
|
||||
const preselectedPlugin =
|
||||
pluginParam && pluginParam in registryData
|
||||
? (
|
||||
registryData as Record<
|
||||
string,
|
||||
{ includes?: string[]; name: string; description: string; imageUrl?: string; featured?: boolean }
|
||||
>
|
||||
)[pluginParam]
|
||||
: null
|
||||
|
||||
const STORAGE_KEY = "quick_build_target"
|
||||
const getStorageKeyForCategory = (category: string) => `quick_build_target_${category}`
|
||||
|
||||
const persistTargetSelection = (targetId: string, category?: string) => {
|
||||
if (typeof window === "undefined") return
|
||||
try {
|
||||
// Store global most recent selection
|
||||
window.localStorage.setItem(STORAGE_KEY, targetId)
|
||||
// Store per-brand selection if category provided
|
||||
if (category) {
|
||||
window.localStorage.setItem(getStorageKeyForCategory(category), targetId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to persist target selection", error)
|
||||
}
|
||||
}
|
||||
|
||||
const getSavedTargetForCategory = (category: string): string | null => {
|
||||
if (typeof window === "undefined") return null
|
||||
try {
|
||||
return window.localStorage.getItem(getStorageKeyForCategory(category))
|
||||
} catch (error) {
|
||||
console.error("Failed to read saved target for category", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const [activeCategory, setActiveCategory] = useState<string>(TARGET_CATEGORIES[0] ?? "")
|
||||
const [selectedTarget, setSelectedTarget] = useState<string>(DEFAULT_TARGET)
|
||||
const [selectedVersion, setSelectedVersion] = useState<string>(VERSIONS[0])
|
||||
const [moduleConfig, setModuleConfig] = useState<Record<string, boolean>>({})
|
||||
const [pluginConfig, setPluginConfig] = useState<Record<string, boolean>>({})
|
||||
const [isFlashing, setIsFlashing] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [showModuleOverrides, setShowModuleOverrides] = useState(false)
|
||||
const [showPlugins, setShowPlugins] = useState(true)
|
||||
const [isLoadingSharedBuild, setIsLoadingSharedBuild] = useState(false)
|
||||
|
||||
// Get all enabled plugins
|
||||
const enabledPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
|
||||
|
||||
// Filter targets based on plugin compatibility
|
||||
// Start with preselected plugin compatibility if present
|
||||
let compatibleTargets: Set<string> | null = preselectedPlugin?.includes
|
||||
? getTargetsCompatibleWithIncludes(preselectedPlugin.includes)
|
||||
: null
|
||||
|
||||
// Intersect with compatibility of all enabled plugins
|
||||
if (enabledPlugins.length > 0) {
|
||||
const pluginRegistry = registryData as Record<string, { includes?: string[] }>
|
||||
const allCompatibleSets: Set<string>[] = []
|
||||
|
||||
// Get compatible targets for each enabled plugin
|
||||
for (const pluginId of enabledPlugins) {
|
||||
const plugin = pluginRegistry[pluginId]
|
||||
if (plugin?.includes && plugin.includes.length > 0) {
|
||||
// Plugin has includes - get compatible targets
|
||||
allCompatibleSets.push(getTargetsCompatibleWithIncludes(plugin.includes))
|
||||
}
|
||||
// If plugin has no includes, it's compatible with all targets (don't add to set)
|
||||
}
|
||||
|
||||
// If we have compatible sets, find intersection
|
||||
if (allCompatibleSets.length > 0) {
|
||||
if (compatibleTargets) {
|
||||
// Intersect with preselected plugin compatibility
|
||||
compatibleTargets = new Set(
|
||||
Array.from(compatibleTargets).filter(target => allCompatibleSets.every(set => set.has(target)))
|
||||
)
|
||||
} else {
|
||||
// Start with first set, then intersect with others
|
||||
compatibleTargets = allCompatibleSets[0]
|
||||
for (let i = 1; i < allCompatibleSets.length; i++) {
|
||||
compatibleTargets = new Set(Array.from(compatibleTargets).filter(target => allCompatibleSets[i].has(target)))
|
||||
}
|
||||
}
|
||||
} else if (!compatibleTargets) {
|
||||
// No enabled plugins have includes, so all targets are compatible
|
||||
// (only if there's no preselected plugin with includes)
|
||||
compatibleTargets = null
|
||||
}
|
||||
}
|
||||
|
||||
const filteredGroupedTargets = compatibleTargets
|
||||
? Object.entries(GROUPED_TARGETS).reduce(
|
||||
(acc, [category, targets]) => {
|
||||
const filtered = targets.filter(target => {
|
||||
// Check both normalized and original target ID
|
||||
const normalizedId = target.id.replace(/[-_]/g, "")
|
||||
return compatibleTargets.has(target.id) || compatibleTargets.has(normalizedId)
|
||||
})
|
||||
if (filtered.length > 0) {
|
||||
acc[category] = filtered
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, TargetGroup[]>
|
||||
)
|
||||
: GROUPED_TARGETS
|
||||
const filteredTargetCategories = Object.keys(filteredGroupedTargets).sort((a, b) => a.localeCompare(b))
|
||||
|
||||
// Preselect plugin from URL parameter
|
||||
useEffect(() => {
|
||||
if (pluginParam && preselectedPlugin && !buildHashParam) {
|
||||
setPluginConfig({ [pluginParam]: true })
|
||||
setShowPlugins(true)
|
||||
}
|
||||
}, [pluginParam, preselectedPlugin, buildHashParam])
|
||||
|
||||
useEffect(() => {
|
||||
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
|
||||
if (!activeCategory && categories.length > 0) {
|
||||
setActiveCategory(categories[0])
|
||||
}
|
||||
}, [activeCategory, compatibleTargets, filteredTargetCategories])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCategory) {
|
||||
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
|
||||
const categoryTargets = targets[activeCategory] || []
|
||||
|
||||
if (categoryTargets.length === 0) return
|
||||
|
||||
// Check if current selected target is in this category
|
||||
const isCurrentTargetInCategory = categoryTargets.some(t => t.id === selectedTarget)
|
||||
|
||||
if (!isCurrentTargetInCategory) {
|
||||
// Try to restore per-brand selection
|
||||
const savedTargetForCategory = getSavedTargetForCategory(activeCategory)
|
||||
const isValidSavedTarget = savedTargetForCategory && categoryTargets.some(t => t.id === savedTargetForCategory)
|
||||
|
||||
if (isValidSavedTarget) {
|
||||
setSelectedTarget(savedTargetForCategory)
|
||||
// Persist the restored selection
|
||||
persistTargetSelection(savedTargetForCategory, activeCategory)
|
||||
} else {
|
||||
// Default to first target in category and persist it
|
||||
const firstTarget = categoryTargets[0].id
|
||||
setSelectedTarget(firstTarget)
|
||||
persistTargetSelection(firstTarget, activeCategory)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [activeCategory, compatibleTargets, filteredGroupedTargets, selectedTarget])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
try {
|
||||
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
|
||||
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
|
||||
|
||||
if (categories.length === 0) return
|
||||
|
||||
// Try to restore the most recent global selection first
|
||||
const savedTarget = localStorage.getItem(STORAGE_KEY)
|
||||
if (savedTarget && TARGETS[savedTarget]) {
|
||||
// Check if saved target exists in filtered targets
|
||||
const isCompatible = Object.values(targets).some(categoryTargets =>
|
||||
categoryTargets.some(target => target.id === savedTarget)
|
||||
)
|
||||
|
||||
if (isCompatible) {
|
||||
const category = TARGETS[savedTarget].category || "Other"
|
||||
if (categories.includes(category)) {
|
||||
setActiveCategory(category)
|
||||
setSelectedTarget(savedTarget)
|
||||
persistTargetSelection(savedTarget, category)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to per-brand selection for first category
|
||||
const firstCategory = categories[0]
|
||||
const categoryTargets = targets[firstCategory] || []
|
||||
|
||||
if (categoryTargets.length > 0) {
|
||||
// Try to restore per-brand selection
|
||||
const savedTargetForCategory = getSavedTargetForCategory(firstCategory)
|
||||
const isValidSavedTarget = savedTargetForCategory && categoryTargets.some(t => t.id === savedTargetForCategory)
|
||||
|
||||
if (isValidSavedTarget) {
|
||||
setActiveCategory(firstCategory)
|
||||
setSelectedTarget(savedTargetForCategory)
|
||||
persistTargetSelection(savedTargetForCategory, firstCategory)
|
||||
} else {
|
||||
// Default to first target in category
|
||||
const firstTarget = categoryTargets[0].id
|
||||
setActiveCategory(firstCategory)
|
||||
setSelectedTarget(firstTarget)
|
||||
persistTargetSelection(firstTarget, firstCategory)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to read saved target", error)
|
||||
}
|
||||
}, [compatibleTargets, filteredGroupedTargets, filteredTargetCategories])
|
||||
|
||||
const handleSelectTarget = (targetId: string) => {
|
||||
// Validate target is compatible with selected plugins
|
||||
if (compatibleTargets) {
|
||||
const normalizedId = targetId.replace(/[-_]/g, "")
|
||||
const isCompatible = compatibleTargets.has(targetId) || compatibleTargets.has(normalizedId)
|
||||
if (!isCompatible) {
|
||||
// Target is not compatible, don't allow selection
|
||||
return
|
||||
}
|
||||
}
|
||||
setSelectedTarget(targetId)
|
||||
const category = TARGETS[targetId]?.category || "Other"
|
||||
persistTargetSelection(targetId, category)
|
||||
if (category && TARGET_CATEGORIES.includes(category)) {
|
||||
setActiveCategory(category)
|
||||
}
|
||||
}
|
||||
|
||||
// Update selected target if it becomes incompatible with selected plugins
|
||||
useEffect(() => {
|
||||
if (!selectedTarget || !compatibleTargets) return
|
||||
|
||||
const normalizedId = selectedTarget.replace(/[-_]/g, "")
|
||||
const isCompatible = compatibleTargets.has(selectedTarget) || compatibleTargets.has(normalizedId)
|
||||
|
||||
if (!isCompatible) {
|
||||
// Current target is no longer compatible, find a compatible one
|
||||
const targets = filteredGroupedTargets
|
||||
const categories = filteredTargetCategories
|
||||
|
||||
if (categories.length > 0) {
|
||||
// Try to find a compatible target in the current category first
|
||||
const currentCategory = TARGETS[selectedTarget]?.category
|
||||
if (currentCategory && targets[currentCategory] && targets[currentCategory].length > 0) {
|
||||
const savedTargetForCategory = getSavedTargetForCategory(currentCategory)
|
||||
const isValidSavedTarget =
|
||||
savedTargetForCategory && targets[currentCategory].some(t => t.id === savedTargetForCategory)
|
||||
|
||||
if (isValidSavedTarget) {
|
||||
setSelectedTarget(savedTargetForCategory)
|
||||
persistTargetSelection(savedTargetForCategory, currentCategory)
|
||||
return
|
||||
}
|
||||
|
||||
// Default to first target in current category
|
||||
setSelectedTarget(targets[currentCategory][0].id)
|
||||
persistTargetSelection(targets[currentCategory][0].id, currentCategory)
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to first compatible target
|
||||
const firstCategory = categories[0]
|
||||
const firstTarget = targets[firstCategory]?.[0]?.id
|
||||
if (firstTarget) {
|
||||
setSelectedTarget(firstTarget)
|
||||
setActiveCategory(firstCategory)
|
||||
persistTargetSelection(firstTarget, firstCategory)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [compatibleTargets, filteredGroupedTargets, filteredTargetCategories, selectedTarget])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !selectedTarget) return
|
||||
try {
|
||||
if (!window.localStorage.getItem(STORAGE_KEY)) {
|
||||
window.localStorage.setItem(STORAGE_KEY, selectedTarget)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize target storage", error)
|
||||
}
|
||||
}, [selectedTarget])
|
||||
|
||||
// Pre-populate form from shared build
|
||||
useEffect(() => {
|
||||
if (!buildHashParam) return
|
||||
if (sharedBuild === undefined) {
|
||||
setIsLoadingSharedBuild(true)
|
||||
return
|
||||
}
|
||||
setIsLoadingSharedBuild(false)
|
||||
|
||||
if (!sharedBuild) {
|
||||
setErrorMessage("Build not found. The shared build may have been deleted.")
|
||||
toast.error("Build not found", {
|
||||
description: "The shared build could not be loaded.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const config = sharedBuild.config
|
||||
|
||||
// Set target and category
|
||||
if (config.target && TARGETS[config.target]) {
|
||||
setSelectedTarget(config.target)
|
||||
const category = TARGETS[config.target].category || "Other"
|
||||
if (TARGET_CATEGORIES.includes(category)) {
|
||||
setActiveCategory(category)
|
||||
}
|
||||
}
|
||||
|
||||
// Set version
|
||||
if (config.version && (VERSIONS as readonly string[]).includes(config.version)) {
|
||||
setSelectedVersion(config.version as (typeof VERSIONS)[number])
|
||||
}
|
||||
|
||||
// Set module config (already in the correct format)
|
||||
if (config.modulesExcluded) {
|
||||
setModuleConfig(config.modulesExcluded)
|
||||
if (Object.keys(config.modulesExcluded).length > 0) {
|
||||
setShowModuleOverrides(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Set plugin config (convert array to object format)
|
||||
// Only add explicitly selected plugins, not implicit dependencies
|
||||
if (config.pluginsEnabled && config.pluginsEnabled.length > 0) {
|
||||
const allPluginSlugs = config.pluginsEnabled.map(pluginId => {
|
||||
return pluginId.includes("@") ? pluginId.split("@")[0] : pluginId
|
||||
})
|
||||
|
||||
// Determine which plugins are required by others (implicit dependencies)
|
||||
const requiredByOthers = new Set<string>()
|
||||
for (const pluginSlug of allPluginSlugs) {
|
||||
if (
|
||||
isRequiredByOther(
|
||||
pluginSlug,
|
||||
allPluginSlugs,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
) {
|
||||
requiredByOthers.add(pluginSlug)
|
||||
}
|
||||
}
|
||||
|
||||
// Only add plugins that are NOT required by others (explicitly selected)
|
||||
const pluginObj: Record<string, boolean> = {}
|
||||
allPluginSlugs.forEach(slug => {
|
||||
if (slug in registryData && !requiredByOthers.has(slug)) {
|
||||
pluginObj[slug] = true
|
||||
}
|
||||
})
|
||||
setPluginConfig(pluginObj)
|
||||
setShowPlugins(true)
|
||||
}
|
||||
}, [buildHashParam, sharedBuild])
|
||||
|
||||
const moduleCount = Object.keys(moduleConfig).length
|
||||
const pluginCount = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true).length
|
||||
const selectedTargetLabel = (selectedTarget && TARGETS[selectedTarget]?.name) || selectedTarget
|
||||
|
||||
const handleToggleModule = (id: string, excluded: boolean) => {
|
||||
setModuleConfig(prev => {
|
||||
const next = { ...prev }
|
||||
if (excluded) {
|
||||
next[id] = true
|
||||
} else {
|
||||
delete next[id]
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleTogglePlugin = (id: string, enabled: boolean) => {
|
||||
// Get current explicit selections
|
||||
const explicitPlugins = Object.keys(pluginConfig).filter(pluginId => pluginConfig[pluginId] === true)
|
||||
|
||||
// Check if this plugin is currently an implicit dependency
|
||||
const implicitDeps = getImplicitDependencies(
|
||||
explicitPlugins,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
|
||||
// Check if this plugin is required by another explicitly selected plugin
|
||||
const isRequired = isRequiredByOther(
|
||||
id,
|
||||
explicitPlugins,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
|
||||
// Don't allow toggling implicit dependencies at all
|
||||
// (they should be disabled in the UI, but add this as a safeguard)
|
||||
if (implicitDeps.has(id)) {
|
||||
return // Can't toggle implicit dependencies
|
||||
}
|
||||
|
||||
// Don't allow disabling if it's required by another explicitly selected plugin
|
||||
if (!enabled && isRequired) {
|
||||
return // Can't disable required plugins
|
||||
}
|
||||
|
||||
setPluginConfig(prev => {
|
||||
const next = { ...prev }
|
||||
if (enabled) {
|
||||
// Enabling: add to explicit selection (even if it was implicit)
|
||||
next[id] = true
|
||||
} else {
|
||||
// Disabling: remove from explicit selection
|
||||
delete next[id]
|
||||
|
||||
// Recompute what plugins are still needed after removal
|
||||
const remainingExplicit = Object.keys(next).filter(pluginId => next[pluginId] === true)
|
||||
const allStillNeeded = getDependedPlugins(
|
||||
remainingExplicit,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
|
||||
// Remove any plugins from config that are no longer needed
|
||||
// BUT preserve all plugins that are currently explicitly selected (in remainingExplicit)
|
||||
// This ensures that plugins that were explicitly selected remain explicitly selected
|
||||
// even if they temporarily became implicit and then un-implicit
|
||||
for (const pluginId of Object.keys(next)) {
|
||||
if (next[pluginId] === true && !allStillNeeded.includes(pluginId) && !remainingExplicit.includes(pluginId)) {
|
||||
// This plugin is no longer needed and is not in the remaining explicit list
|
||||
// Only remove if it's truly not needed and wasn't explicitly selected
|
||||
// Note: If a plugin is in `next` with value `true`, it should be in `remainingExplicit`
|
||||
// So this condition should rarely be true, but we keep it as a safety check
|
||||
delete next[pluginId]
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all remaining explicitly selected plugins stay in config
|
||||
// (they should already be there, but this ensures they remain even if they're not needed)
|
||||
for (const pluginId of remainingExplicit) {
|
||||
next[pluginId] = true
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleFlash = async () => {
|
||||
if (!selectedTarget) return
|
||||
setIsFlashing(true)
|
||||
setErrorMessage(null)
|
||||
try {
|
||||
const enabledSlugs = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
|
||||
|
||||
// Double-check: filter out any implicit dependencies that might have snuck in
|
||||
// This ensures we only send explicitly selected plugins to the backend
|
||||
const implicitDeps = getImplicitDependencies(
|
||||
enabledSlugs,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
const explicitOnlySlugs = enabledSlugs.filter(slug => !implicitDeps.has(slug))
|
||||
|
||||
const pluginsEnabled = explicitOnlySlugs.map(slug => {
|
||||
const plugin = (registryData as Record<string, { version: string }>)[slug]
|
||||
return `${slug}@${plugin.version}`
|
||||
})
|
||||
const result = await ensureBuildFromConfig({
|
||||
target: selectedTarget,
|
||||
version: selectedVersion,
|
||||
modulesExcluded: moduleConfig,
|
||||
pluginsEnabled: pluginsEnabled.length > 0 ? pluginsEnabled : undefined,
|
||||
})
|
||||
navigate(`/builds/${result.buildHash}`)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
setErrorMessage("Failed to start build. Please try again.")
|
||||
toast.error("Failed to start build", {
|
||||
description: message,
|
||||
})
|
||||
} finally {
|
||||
setIsFlashing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isFlashDisabled = !selectedTarget || isFlashing
|
||||
|
||||
if (isLoadingSharedBuild) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white p-6 md:p-10 flex items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-cyan-500 mx-auto" />
|
||||
<p className="text-slate-400">Loading shared build configuration...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white p-6 md:p-10">
|
||||
<div className="max-w-6xl mx-auto space-y-8">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-wider text-slate-500">
|
||||
{preselectedPlugin ? "Plugin build" : "Quick build"}
|
||||
</p>
|
||||
<h1 className="text-4xl font-bold mt-1">
|
||||
{preselectedPlugin ? `Build firmware for ${preselectedPlugin.name}` : "Flash a custom firmware version"}
|
||||
</h1>
|
||||
<p className="text-slate-400 mt-2 max-w-2xl">
|
||||
{preselectedPlugin
|
||||
? `Select a compatible Meshtastic target and configure your build for ${preselectedPlugin.name}. We'll send you to the build status page as soon as it starts.`
|
||||
: "Choose your Meshtastic target, adjust optional modules, and queue a new build instantly. We'll send you to the build status page as soon as it starts."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{preselectedPlugin && (
|
||||
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-400 shrink-0 mt-1" />
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
{preselectedPlugin.imageUrl && (
|
||||
<img
|
||||
src={preselectedPlugin.imageUrl}
|
||||
alt={`${preselectedPlugin.name} logo`}
|
||||
className="w-16 h-16 rounded-lg object-contain shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-2xl font-bold">{preselectedPlugin.name}</h2>
|
||||
{preselectedPlugin.featured && (
|
||||
<span className="px-2 py-1 text-xs font-medium text-green-400 bg-green-400/10 border border-green-400/20 rounded">
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-slate-400 mb-3">{preselectedPlugin.description}</p>
|
||||
{preselectedPlugin.includes && preselectedPlugin.includes.length > 0 && (
|
||||
<p className="text-sm text-slate-500">Compatible with: {preselectedPlugin.includes.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6 bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES).map(category => {
|
||||
const isActive = activeCategory === category
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Always allow switching to category - the useEffect will handle target selection
|
||||
setActiveCategory(category)
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
isActive ? "bg-blue-600 text-white" : "bg-slate-800 text-slate-300 hover:bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950/60 p-4 rounded-lg border border-slate-800/60">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(() => {
|
||||
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
|
||||
return (activeCategory ? targets[activeCategory] : [])?.map(target => {
|
||||
const isSelected = selectedTarget === target.id
|
||||
return (
|
||||
<button
|
||||
key={target.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectTarget(target.id)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
isSelected ? "bg-cyan-600 text-white" : "bg-slate-800 text-slate-300 hover:bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
{target.name}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="build-version" className="block text-sm font-medium mb-2">
|
||||
Firmware version
|
||||
</label>
|
||||
<select
|
||||
id="build-version"
|
||||
value={selectedVersion}
|
||||
onChange={event => setSelectedVersion(event.target.value)}
|
||||
className="w-full h-10 px-3 rounded-md border border-slate-800 bg-slate-950 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:ring-offset-slate-950"
|
||||
>
|
||||
{VERSIONS.map(version => (
|
||||
<option key={version} value={version}>
|
||||
{version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModuleOverrides(prev => !prev)}
|
||||
className="w-full flex items-center justify-between text-left"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Core Modules</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{moduleCount === 0
|
||||
? "Using default modules for this target."
|
||||
: `${moduleCount} module${moduleCount === 1 ? "" : "s"} excluded.`}
|
||||
</p>
|
||||
</div>
|
||||
{showModuleOverrides ? (
|
||||
<ChevronDown className="w-4 h-4 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showModuleOverrides && (
|
||||
<div className="space-y-2 pr-1">
|
||||
<div className="rounded-lg bg-slate-800/50 border border-slate-700 p-3">
|
||||
<p className="text-xs text-slate-400 leading-relaxed">
|
||||
Core Modules are officially maintained modules by Meshtastic. They are selectively included or
|
||||
excluded by default depending on the target device. You can explicitly exclude modules you know you
|
||||
don't want.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-slate-400 hover:text-white underline"
|
||||
onClick={() => setModuleConfig({})}
|
||||
disabled={moduleCount === 0}
|
||||
>
|
||||
Reset overrides
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{modulesData.modules.map(module => (
|
||||
<ModuleToggle
|
||||
key={module.id}
|
||||
id={module.id}
|
||||
name={module.name}
|
||||
description={module.description}
|
||||
isExcluded={moduleConfig[module.id] === true}
|
||||
onToggle={excluded => handleToggleModule(module.id, excluded)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPlugins(prev => !prev)}
|
||||
className="w-full flex items-center justify-between text-left"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Plugins</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{pluginCount === 0
|
||||
? "No plugins enabled."
|
||||
: `${pluginCount} plugin${pluginCount === 1 ? "" : "s"} enabled.`}
|
||||
</p>
|
||||
</div>
|
||||
{showPlugins ? (
|
||||
<ChevronDown className="w-4 h-4 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showPlugins && (
|
||||
<div className="space-y-2 pr-1">
|
||||
<div className="rounded-lg bg-slate-800/50 border border-slate-700 p-3">
|
||||
<p className="text-xs text-slate-400 leading-relaxed">
|
||||
Plugins are 3rd party add-ons. They are not maintained, endorsed, or supported by Meshtastic. Use at
|
||||
your own risk.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-slate-400 hover:text-white underline"
|
||||
onClick={() => setPluginConfig({})}
|
||||
disabled={pluginCount === 0}
|
||||
>
|
||||
Reset plugins
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-2" key={`plugins-${selectedTarget}`}>
|
||||
{(() => {
|
||||
// Get explicitly selected plugins (user-selected)
|
||||
const explicitPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
|
||||
|
||||
// Compute implicit dependencies (dependencies that are not explicitly selected)
|
||||
const implicitDeps = getImplicitDependencies(
|
||||
explicitPlugins,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
|
||||
// Compute all enabled plugins (explicit + implicit)
|
||||
const allEnabledPlugins = getDependedPlugins(
|
||||
explicitPlugins,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
|
||||
return Object.entries(registryData)
|
||||
.sort(([, pluginA], [, pluginB]) => {
|
||||
// Featured plugins first
|
||||
const featuredA = pluginA.featured ?? false
|
||||
const featuredB = pluginB.featured ?? false
|
||||
if (featuredA !== featuredB) {
|
||||
return featuredA ? -1 : 1
|
||||
}
|
||||
// Then alphabetical by name
|
||||
return pluginA.name.localeCompare(pluginB.name)
|
||||
})
|
||||
.map(([slug, plugin]) => {
|
||||
// Check if plugin is required by another explicitly selected plugin
|
||||
const isRequired = isRequiredByOther(
|
||||
slug,
|
||||
explicitPlugins,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
// Plugin is implicit if it's either:
|
||||
// 1. Not explicitly selected but is a dependency, OR
|
||||
// 2. Explicitly selected but required by another explicitly selected plugin
|
||||
const isImplicit = implicitDeps.has(slug) || (explicitPlugins.includes(slug) && isRequired)
|
||||
|
||||
// Check plugin compatibility with selected target
|
||||
const pluginIncludes = (plugin as { includes?: string[] }).includes
|
||||
const pluginExcludes = (plugin as { excludes?: string[] }).excludes
|
||||
// Legacy support: check for old "architectures" field
|
||||
const legacyArchitectures = (plugin as { architectures?: string[] }).architectures
|
||||
const hasCompatibilityConstraints =
|
||||
(pluginIncludes && pluginIncludes.length > 0) ||
|
||||
(pluginExcludes && pluginExcludes.length > 0) ||
|
||||
(legacyArchitectures && legacyArchitectures.length > 0)
|
||||
const isCompatible =
|
||||
hasCompatibilityConstraints && selectedTarget
|
||||
? isPluginCompatibleWithTarget(
|
||||
pluginIncludes || legacyArchitectures,
|
||||
pluginExcludes,
|
||||
selectedTarget
|
||||
)
|
||||
: true // If no constraints or no target selected, assume compatible
|
||||
// Mark as incompatible if plugin has compatibility constraints and target is not compatible
|
||||
const isIncompatible = !isCompatible && hasCompatibilityConstraints && !!selectedTarget
|
||||
|
||||
// Check if this is the preselected plugin from URL
|
||||
const isPreselected = pluginParam === slug
|
||||
|
||||
return (
|
||||
<PluginCard
|
||||
key={`${slug}-${selectedTarget}`}
|
||||
variant="link-toggle"
|
||||
id={slug}
|
||||
name={plugin.name}
|
||||
description={plugin.description}
|
||||
imageUrl={plugin.imageUrl}
|
||||
isEnabled={allEnabledPlugins.includes(slug)}
|
||||
onToggle={enabled => handleTogglePlugin(slug, enabled)}
|
||||
disabled={isImplicit || isIncompatible || isPreselected}
|
||||
enabledLabel={isPreselected ? "Locked" : isImplicit ? "Required" : "Add"}
|
||||
incompatibleReason={isIncompatible ? "Not compatible with this target" : undefined}
|
||||
featured={plugin.featured ?? false}
|
||||
flashCount={pluginFlashCounts[slug] ?? 0}
|
||||
homepage={plugin.homepage}
|
||||
version={plugin.version}
|
||||
repo={plugin.repo}
|
||||
/>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button onClick={handleFlash} disabled={isFlashDisabled} className="w-full bg-cyan-600 hover:bg-cyan-700">
|
||||
{isFlashing ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Queuing build...
|
||||
</span>
|
||||
) : (
|
||||
`Flash ${selectedTargetLabel || ""}`.trim() || "Flash"
|
||||
)}
|
||||
</Button>
|
||||
{errorMessage && <p className="text-sm text-red-400">{errorMessage}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -149,7 +149,7 @@ export default function LandingPage() {
|
||||
description={customBuildPlugin.description}
|
||||
imageUrl={customBuildPlugin.imageUrl}
|
||||
featured={false}
|
||||
href="/builds/new"
|
||||
href="/builds"
|
||||
prominent={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function PluginPage() {
|
||||
Homepage
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => navigate(`/builds/new?plugin=${slug}`)} className="bg-cyan-600 hover:bg-cyan-700">
|
||||
<Button onClick={() => navigate(`/builds?plugin=${slug}`)} className="bg-cyan-600 hover:bg-cyan-700">
|
||||
Build with this Plugin
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user