feat: implement plugin management system with support for enabling/disabling plugins and tracking flash counts

This commit is contained in:
Ben Allfree
2025-11-29 17:16:13 -08:00
parent caecfea95f
commit 9786192bee
9 changed files with 279 additions and 7 deletions
+75
View File
@@ -0,0 +1,75 @@
import { ExternalLink, Star } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
interface PluginToggleProps {
id: string
name: string
description: string
isEnabled: boolean
onToggle: (enabled: boolean) => void
featured?: boolean
flashCount?: number
homepage?: string
}
export function PluginToggle({
name,
description,
isEnabled,
onToggle,
featured = false,
flashCount = 0,
homepage,
}: PluginToggleProps) {
return (
<div className="relative flex items-start gap-4 p-4 rounded-lg border-2 border-slate-700 bg-slate-900/50 hover:border-slate-600 transition-colors">
{/* Flash count and homepage links in lower right */}
<div className="absolute bottom-2 right-2 flex items-center gap-3 text-xs text-slate-400 z-10">
<div className="flex items-center gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
className="text-slate-400"
fill="currentColor"
role="img"
aria-label="Download"
>
<path d="m14 2l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm4 18V9h-5V4H6v16zm-6-1l-4-4h2.5v-3h3v3H16z" />
</svg>
<span>{flashCount}</span>
</div>
{homepage && (
<a
href={homepage}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-slate-400 hover:text-slate-300 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-1">
<h4 className="font-semibold text-sm">{name}</h4>
{featured && (
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
)}
</div>
<p className="text-xs text-slate-400 leading-relaxed">{description}</p>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<Switch
checked={isEnabled}
onCheckedChange={onToggle}
labelLeft="Excluded"
labelRight="Included"
className={isEnabled ? 'bg-green-600' : 'bg-slate-600'}
/>
</div>
</div>
)
}
+1 -1
View File
@@ -26,7 +26,7 @@ export function Switch({
onClick={() => !disabled && onCheckedChange(!checked)}
className={cn(
'relative inline-flex h-8 w-24 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950 disabled:cursor-not-allowed disabled:opacity-50',
checked ? 'bg-red-600' : 'bg-slate-600',
!className && (checked ? 'bg-red-600' : 'bg-slate-600'),
className
)}
>
+105 -2
View File
@@ -1,12 +1,14 @@
import { useMutation } from 'convex/react'
import { useMutation, useQuery } from 'convex/react'
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { ModuleToggle } from '@/components/ModuleToggle'
import { PluginToggle } from '@/components/PluginToggle'
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
import modulesData from '../../convex/modules.json'
import registryData from '../../registry/registry.json'
import { TARGETS } from '../constants/targets'
import { VERSIONS } from '../constants/versions'
@@ -34,6 +36,7 @@ const DEFAULT_TARGET =
export default function BuildNew() {
const navigate = useNavigate()
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
const pluginFlashCounts = useQuery(api.plugins.getAll) ?? {}
const STORAGE_KEY = 'quick_build_target'
const persistTargetSelection = (targetId: string) => {
@@ -51,9 +54,11 @@ export default function BuildNew() {
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)
useEffect(() => {
if (!activeCategory && TARGET_CATEGORIES.length > 0) {
@@ -107,6 +112,9 @@ export default function BuildNew() {
}, [selectedTarget])
const moduleCount = Object.keys(moduleConfig).length
const pluginCount = Object.keys(pluginConfig).filter(
(id) => pluginConfig[id] === true
).length
const selectedTargetLabel =
(selectedTarget && TARGETS[selectedTarget]?.name) || selectedTarget
@@ -122,15 +130,31 @@ export default function BuildNew() {
})
}
const handleTogglePlugin = (id: string, enabled: boolean) => {
setPluginConfig((prev) => {
const next = { ...prev }
if (enabled) {
next[id] = true
} else {
delete next[id]
}
return next
})
}
const handleFlash = async () => {
if (!selectedTarget) return
setIsFlashing(true)
setErrorMessage(null)
try {
const pluginsEnabled = Object.keys(pluginConfig).filter(
(id) => pluginConfig[id] === true
)
const result = await ensureBuildFromConfig({
target: selectedTarget,
version: selectedVersion,
modulesExcluded: moduleConfig,
pluginsEnabled: pluginsEnabled.length > 0 ? pluginsEnabled : undefined,
})
navigate(`/builds/${result.buildHash}`)
} catch (error) {
@@ -240,7 +264,7 @@ export default function BuildNew() {
className="w-full flex items-center justify-between text-left"
>
<div>
<p className="text-sm font-medium">Module overrides</p>
<p className="text-sm font-medium">Core Modules</p>
<p className="text-xs text-slate-400">
{moduleCount === 0
? 'Using default modules for this target.'
@@ -256,6 +280,14 @@ export default function BuildNew() {
{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"
@@ -284,6 +316,77 @@ export default function BuildNew() {
)}
</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">
{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]) => (
<PluginToggle
key={slug}
id={slug}
name={plugin.name}
description={plugin.description}
isEnabled={pluginConfig[slug] === true}
onToggle={(enabled) =>
handleTogglePlugin(slug, enabled)
}
featured={plugin.featured ?? false}
flashCount={pluginFlashCounts[slug] ?? 0}
homepage={plugin.homepage}
/>
))}
</div>
</div>
)}
</div>
<div className="space-y-2">
<Button
onClick={handleFlash}