feat: add plugin management features with new PluginCard and PluginsPage components, and introduce Plugin type definitions

This commit is contained in:
Ben Allfree
2025-12-06 22:02:04 -08:00
parent 086a98050c
commit f6ac64aa0c
24 changed files with 809 additions and 388 deletions

BIN
assets/custom-build.pxd Normal file

Binary file not shown.

View File

@@ -1,11 +1,11 @@
import { SourceAvailable } from "@/components/SourceAvailable"
import { Button } from "@/components/ui/button"
import { api } from "@/convex/_generated/api"
import type { Doc } from "@/convex/_generated/dataModel"
import { ArtifactType } from "@/convex/builds"
import { useMutation } from "convex/react"
import { useState } from "react"
import { toast } from "sonner"
import { api } from "../convex/_generated/api"
import type { Doc } from "../convex/_generated/dataModel"
import { ArtifactType } from "../convex/builds"
interface BuildDownloadButtonProps {
build: Doc<"builds">

View File

@@ -1,10 +1,10 @@
import favicon from "@/assets/favicon-96x96.png"
import { DiscordButton } from "@/components/DiscordButton"
import { RedditButton } from "@/components/RedditButton"
import { Button } from "@/components/ui/button"
import { api } from "@/convex/_generated/api"
import { useAuthActions } from "@convex-dev/auth/react"
import { Authenticated, Unauthenticated, useQuery } from "convex/react"
import favicon from "../assets/favicon-96x96.png"
import { api } from "../convex/_generated/api"
export default function Navbar() {
const { signOut, signIn } = useAuthActions()
@@ -25,6 +25,9 @@ export default function Navbar() {
<a href="/docs" className="text-slate-300 hover:text-white transition-colors">
Docs
</a>
<a href="/plugins" className="text-slate-300 hover:text-white transition-colors">
Plugins
</a>
<Authenticated>
{isAdmin && (
<a href="/admin" className="text-slate-300 hover:text-white transition-colors">

304
components/PluginCard.tsx Normal file
View File

@@ -0,0 +1,304 @@
import { Switch } from "@/components/ui/switch"
import { Download, Star, Zap } from "lucide-react"
import { navigate } from "vike/client/router"
function getGitHubStarsBadgeUrl(repoUrl?: string): string | null {
if (!repoUrl) return null
try {
const url = new URL(repoUrl)
if (url.hostname === "github.com" || url.hostname === "www.github.com") {
const pathParts = url.pathname.split("/").filter(Boolean)
if (pathParts.length >= 2) {
const owner = pathParts[0]
const repo = pathParts[1]
return `https://img.shields.io/github/stars/${owner}/${repo}?style=flat&logo=github&logoColor=white&labelColor=rgb(0,0,0,0)&color=rgb(30,30,30)&label=★`
}
}
} catch {
// Invalid URL, return null
}
return null
}
interface PluginCardBaseProps {
id: string
name: string
description: string
imageUrl?: string
featured?: boolean
repo?: string
homepage?: string
version?: string
downloads?: number
stars?: number
flashCount?: number
incompatibleReason?: string
prominent?: boolean
}
interface PluginCardToggleProps extends PluginCardBaseProps {
variant: "toggle"
isEnabled: boolean
onToggle: (enabled: boolean) => void
disabled?: boolean
enabledLabel?: string
}
interface PluginCardLinkProps extends PluginCardBaseProps {
variant: "link"
href?: string
}
interface PluginCardLinkToggleProps extends PluginCardBaseProps {
variant: "link-toggle"
isEnabled: boolean
onToggle: (enabled: boolean) => void
disabled?: boolean
enabledLabel?: string
}
type PluginCardProps = PluginCardToggleProps | PluginCardLinkProps | PluginCardLinkToggleProps
export function PluginCard(props: PluginCardProps) {
const {
id,
name,
description,
imageUrl,
featured = false,
repo,
homepage,
version,
downloads,
stars,
flashCount,
incompatibleReason,
prominent = false,
} = props
const starsBadgeUrl = getGitHubStarsBadgeUrl(repo)
const isIncompatible = !!incompatibleReason
const isToggle = props.variant === "toggle"
const isLink = props.variant === "link"
const isLinkToggle = props.variant === "link-toggle"
const cardContent = (
<>
{isToggle ? (
<>
{/* Toggle layout: horizontal with switch on right */}
<div className="flex items-start gap-4 flex-1 min-w-0">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-1">
<h4 className={`font-semibold text-sm ${isIncompatible ? "text-slate-500" : ""}`}>{name}</h4>
{featured && <Star className="w-4 h-4 text-yellow-400 fill-yellow-400 shrink-0" />}
</div>
<p
className={`text-xs leading-relaxed text-left ${isIncompatible ? "text-slate-500" : "text-slate-400"}`}
>
{description}
</p>
{isIncompatible && incompatibleReason && (
<p className="text-xs text-red-400 mt-1 font-medium">{incompatibleReason}</p>
)}
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<Switch
checked={props.isEnabled}
onCheckedChange={props.onToggle}
disabled={props.disabled}
labelLeft="Skip"
labelRight={props.enabledLabel || "Add"}
className={props.isEnabled ? "bg-green-600" : "bg-slate-600"}
/>
</div>
</div>
{/* Metadata in bottom right for toggle */}
<div className="absolute bottom-2 right-2 flex items-center gap-3 text-xs text-slate-400 z-10">
{version && <span className="text-slate-500">v{version}</span>}
{flashCount !== undefined && (
<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>
)}
</div>
</>
) : (
<>
{/* Link/link-toggle layout: vertical with image */}
<div className="flex items-start gap-3">
{imageUrl && (
<img src={imageUrl} alt={`${name} logo`} className="w-12 h-12 rounded-lg object-contain shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-1 flex-wrap">
<h4
className={`font-semibold text-sm ${
isIncompatible
? "text-slate-500"
: prominent
? "text-cyan-100 group-hover:text-white transition-colors"
: isLinkToggle
? ""
: "text-white group-hover:text-cyan-400 transition-colors"
}`}
>
{name}
</h4>
{featured &&
(isLinkToggle ? (
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400 shrink-0" />
) : (
<span className="px-1.5 py-0.5 text-xs font-medium text-green-400 bg-green-400/10 border border-green-400/20 rounded">
Featured
</span>
))}
</div>
<p
className={`text-xs leading-relaxed text-left ${
isIncompatible ? "text-slate-500" : prominent ? "text-cyan-200/90" : "text-slate-400"
}`}
>
{description}
</p>
{isIncompatible && incompatibleReason && (
<p className="text-xs text-red-400 mt-1 font-medium">{incompatibleReason}</p>
)}
</div>
</div>
{/* Metadata row */}
<div className="flex items-center gap-3 flex-wrap text-xs text-slate-400">
{version && <span className="text-slate-500">v{version}</span>}
{isLinkToggle && flashCount !== undefined && (
<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>
)}
{isLink && downloads !== undefined && (
<div className="flex items-center gap-1">
<Download className="w-3.5 h-3.5" />
<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"
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 && (
<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 && (
<div className="absolute bottom-4 right-4 z-10">
<button
onClick={e => {
e.preventDefault()
e.stopPropagation()
navigate(`/builds/new?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"
>
<Zap className="w-3 h-3" />
Build Now
</button>
</div>
)}
{/* Toggle switch - absolutely positioned in lower right */}
{isLinkToggle && (
<div className="absolute bottom-4 right-4 z-10">
<div className="flex flex-col items-end gap-1 shrink-0">
<Switch
checked={props.isEnabled}
onCheckedChange={props.onToggle}
disabled={props.disabled}
labelLeft="Skip"
labelRight={props.enabledLabel || "Add"}
className={props.isEnabled ? "bg-green-600" : "bg-slate-600"}
/>
</div>
</div>
)}
</>
)}
</>
)
const baseClassName = `relative flex ${isToggle ? "items-start gap-4" : "flex-col gap-3"} p-4 rounded-lg border-2 transition-colors h-full ${
isIncompatible
? "border-slate-800 bg-slate-900/30 opacity-60 cursor-not-allowed"
: prominent
? "border-cyan-400 bg-gradient-to-br from-cyan-500/30 via-cyan-600/20 to-blue-600/30 hover:from-cyan-500/40 hover:via-cyan-600/30 hover:to-blue-600/40 hover:border-cyan-300 shadow-xl shadow-cyan-500/30"
: "border-slate-700 bg-slate-900/50 hover:border-slate-600"
} ${isLink ? "group" : ""}`
if (isLink) {
const href = props.href || `/builds/new?plugin=${id}`
return (
<a href={href} className={baseClassName}>
{cardContent}
</a>
)
}
return <div className={baseClassName}>{cardContent}</div>
}
// Export convenience wrappers for backward compatibility
export function PluginToggle(props: Omit<PluginCardToggleProps, "variant">) {
return <PluginCard {...props} variant="toggle" />
}

View File

@@ -1,96 +0,0 @@
import { Switch } from "@/components/ui/switch"
import { ExternalLink, Star } from "lucide-react"
interface PluginToggleProps {
id: string
name: string
description: string
isEnabled: boolean
onToggle: (enabled: boolean) => void
featured?: boolean
flashCount?: number
homepage?: string
version?: string
disabled?: boolean
enabledLabel?: string
incompatibleReason?: string
}
export function PluginToggle({
name,
description,
isEnabled,
onToggle,
featured = false,
flashCount = 0,
homepage,
version,
disabled = false,
enabledLabel = "Add",
incompatibleReason,
}: PluginToggleProps) {
const isIncompatible = !!incompatibleReason
return (
<div
className={`relative flex items-start gap-4 p-4 rounded-lg border-2 transition-colors ${
isIncompatible
? "border-slate-800 bg-slate-900/30 opacity-60 cursor-not-allowed"
: "border-slate-700 bg-slate-900/50 hover:border-slate-600"
}`}
>
{/* 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">
{version && <span className="text-slate-500">v{version}</span>}
<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 ${isIncompatible ? "text-slate-500" : ""}`}>{name}</h4>
{featured && <Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />}
</div>
<p className={`text-xs leading-relaxed ${isIncompatible ? "text-slate-500" : "text-slate-400"}`}>
{description}
</p>
{isIncompatible && incompatibleReason && (
<p className="text-xs text-red-400 mt-1 font-medium">{incompatibleReason}</p>
)}
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<Switch
checked={isEnabled}
onCheckedChange={onToggle}
disabled={disabled}
labelLeft="Skip"
labelRight={enabledLabel}
className={isEnabled ? "bg-green-600" : "bg-slate-600"}
/>
</div>
</div>
)
}

View File

@@ -1,39 +0,0 @@
import type { Doc } from "../convex/_generated/dataModel"
export const profileCardClasses = "border border-slate-800 rounded-lg p-6 bg-slate-900/50 flex flex-col gap-4"
interface ProfilePillsProps {
version: string
flashCount?: number
flashLabel?: string
}
export function ProfileStatisticPills({ version, flashCount, flashLabel }: ProfilePillsProps) {
const normalizedCount = flashCount ?? 0
const normalizedLabel = flashLabel ?? (normalizedCount === 1 ? "flash" : "flashes")
return (
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-wide">
<span className="inline-flex items-center rounded-full bg-slate-800/80 text-slate-200 px-3 py-1">{version}</span>
<span className="inline-flex items-center rounded-full bg-cyan-500/10 text-cyan-300 px-3 py-1">
{normalizedCount} {normalizedLabel}
</span>
</div>
)
}
interface ProfileCardContentProps {
profile: Doc<"profiles">
}
export function ProfileCardContent({ profile }: ProfileCardContentProps) {
const flashCount = profile.flashCount ?? 0
return (
<>
<div className="flex-1">
<h3 className="text-xl font-semibold mb-2">{profile.name}</h3>
<p className="text-slate-300 text-sm leading-relaxed">{profile.description}</p>
</div>
<ProfileStatisticPills version={profile.config.version} flashCount={flashCount} />
</>
)
}

View File

@@ -1,168 +0,0 @@
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { useMutation } from "convex/react"
import { useForm } from "react-hook-form"
import { VERSIONS } from "../constants/versions"
import { api } from "../convex/_generated/api"
import type { Doc } from "../convex/_generated/dataModel"
import modulesData from "../convex/modules.json"
import { ModuleToggle } from "./ModuleToggle"
// Form values use flattened config for UI, but will be transformed to nested on submit
type ProfileFormValues = Omit<Doc<"profiles">, "_id" | "_creationTime" | "userId" | "flashCount" | "updatedAt">
interface ProfileEditorProps {
initialData?: Doc<"profiles">
onSave: () => void
onCancel: () => void
}
export default function ProfileEditor({ initialData, onSave, onCancel }: ProfileEditorProps) {
const upsertProfile = useMutation(api.profiles.upsert)
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<ProfileFormValues>({
defaultValues: {
name: initialData?.name || "",
description: initialData?.description || "",
config: {
version: VERSIONS[0],
modulesExcluded: {},
target: "",
...initialData?.config,
},
isPublic: initialData?.isPublic ?? true,
},
})
const onSubmit = async (data: ProfileFormValues) => {
await upsertProfile({
id: initialData?._id,
name: data.name,
description: data.description,
config: data.config,
isPublic: data.isPublic,
})
onSave()
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 bg-slate-900 p-6 rounded-lg border border-slate-800">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-2">
Profile Name
</label>
<Input
id="name"
{...register("name", { required: "Profile name is required" })}
className="bg-slate-950 border-slate-800"
placeholder="e.g. Solar Repeater"
/>
{errors.name && <p className="mt-1 text-sm text-red-400">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="version" className="block text-sm font-medium mb-2">
Firmware Version
</label>
<select
id="version"
{...register("config.version")}
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(v => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium mb-2">
Description
</label>
<textarea
id="description"
{...register("description", {
required: "Profile description is required",
})}
className="w-full min-h-[120px] rounded-md border border-slate-800 bg-slate-950 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:ring-offset-slate-950"
placeholder="Describe what this profile is best suited for"
/>
{errors.description && <p className="mt-1 text-sm text-red-400">{errors.description.message}</p>}
</div>
<div>
<div className="flex items-center space-x-2">
<Checkbox
id="isPublic"
checked={watch("isPublic")}
onCheckedChange={checked => setValue("isPublic", !!checked)}
disabled
/>
<label
htmlFor="isPublic"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Make profile public
</label>
</div>
<p className="text-xs text-slate-400 mt-1 ml-6">Public profiles are visible to everyone on the home page</p>
</div>
<div className="space-y-6">
<div>
<div className="mb-4">
<h3 className="text-lg font-medium">Modules</h3>
<p className="text-sm text-slate-400">
Modules are included by default if supported by your target. Toggle to exclude modules you don't need.
</p>
</div>
<div className="flex flex-col gap-2">
{modulesData.modules.map(module => {
// Flattened config: config[id] === true -> Explicitly Excluded
// config[id] === undefined/false -> Default (included if target supports)
const currentConfig = watch("config") as Doc<"builds">["config"]
const configValue = currentConfig.modulesExcluded[module.id]
const isExcluded = configValue === true
return (
<ModuleToggle
key={module.id}
id={module.id}
name={module.name}
description={module.description}
isExcluded={isExcluded}
onToggle={excluded => {
const newConfig = { ...currentConfig }
if (excluded) {
newConfig.modulesExcluded[module.id] = true
} else {
delete newConfig.modulesExcluded[module.id]
}
setValue("config", newConfig)
}}
/>
)
})}
</div>
</div>
</div>
<div className="flex justify-end gap-4 pt-4">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit">Save Profile</Button>
</div>
</form>
)
}

View File

@@ -1,4 +1,4 @@
import hardwareList from "../vendor/web-flasher/public/data/hardware-list.json"
import hardwareList from "@/vendor/web-flasher/public/data/hardware-list.json"
export interface TargetMetadata {
name: string

View File

@@ -1,6 +1,6 @@
import PARENT_MAP from "@/constants/architecture-hierarchy.json"
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import PARENT_MAP from "../constants/architecture-hierarchy.json"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -292,3 +292,45 @@ export function isPluginCompatibleWithArchitecture(
// Legacy support: treat architectures array as includes
return isPluginCompatibleWithTarget(pluginArchitectures, undefined, targetArchitecture)
}
/**
* Get all targets that inherit from (are descendants of) the given architectures
* Traces backwards through the hierarchy to find all targets/variants that inherit from the includes
* Returns normalized target IDs that can be matched against TARGETS keys
*/
export function getTargetsCompatibleWithIncludes(includes: string[]): Set<string> {
const parentMap = PARENT_MAP as Record<string, string | null>
const compatibleTargets = new Set<string>()
// Normalize includes
const normalizedIncludes = new Set(includes.map(include => normalizeArchitecture(include)))
// For each target in the parent map, check if it or any of its ancestors match the includes
for (const target of Object.keys(parentMap)) {
const normalizedTarget = normalizeArchitecture(target)
const visited = new Set<string>()
let current: string | null = normalizedTarget
// Trace up the parent chain
while (current && !visited.has(current)) {
visited.add(current)
// Check if current matches any of the includes
if (normalizedIncludes.has(current)) {
// Add both the normalized version and the original (for matching against TARGETS)
compatibleTargets.add(normalizedTarget)
compatibleTargets.add(target)
break
}
// Move to parent
const parentValue = parentMap[current]
if (parentValue === null || parentValue === undefined) {
break
}
current = normalizeArchitecture(parentValue)
}
}
return compatibleTargets
}

View File

@@ -1,11 +1,11 @@
// https://vike.dev/Head
import appleTouchIconUrl from "../assets/apple-touch-icon.png"
import favicon96x96Url from "../assets/favicon-96x96.png"
import faviconIcoUrl from "../assets/favicon.ico"
import faviconUrl from "../assets/favicon.svg"
import logoUrl from "../assets/logo.png"
import siteWebmanifestUrl from "../assets/site.webmanifest"
import appleTouchIconUrl from "@/assets/apple-touch-icon.png"
import favicon96x96Url from "@/assets/favicon-96x96.png"
import faviconIcoUrl from "@/assets/favicon.ico"
import faviconUrl from "@/assets/favicon.svg"
import logoUrl from "@/assets/logo.png"
import siteWebmanifestUrl from "@/assets/site.webmanifest"
export function Head() {
return (

View File

@@ -1,12 +1,12 @@
import { BuildDownloadButton } from "@/components/BuildDownloadButton"
import { Button } from "@/components/ui/button"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { ArtifactType } from "@/convex/builds"
import { useMutation, useQuery } from "convex/react"
import { useState } from "react"
import { toast } from "sonner"
import { navigate } from "vike/client/router"
import { api } from "../../convex/_generated/api"
import type { Id } from "../../convex/_generated/dataModel"
import { ArtifactType } from "../../convex/builds"
type FilterType = "all" | "failed"

View File

@@ -1,17 +1,17 @@
import { BuildDownloadButton } from "@/components/BuildDownloadButton"
import { Button } from "@/components/ui/button"
import { TARGETS } from "@/constants/targets"
import { api } from "@/convex/_generated/api"
import { ArtifactType } from "@/convex/builds"
import modulesData from "@/convex/modules.json"
import { getImplicitDependencies, humanizeStatus } from "@/lib/utils"
import registryData from "@/public/registry.json"
import { useMutation, useQuery } from "convex/react"
import { AlertCircle, ArrowLeft, CheckCircle, Loader2, Share2, XCircle } from "lucide-react"
import { useState } from "react"
import { toast } from "sonner"
import { usePageContext } from "vike-react/usePageContext"
import { navigate } from "vike/client/router"
import { TARGETS } from "../../../constants/targets"
import { api } from "../../../convex/_generated/api"
import { ArtifactType } from "../../../convex/builds"
import modulesData from "../../../convex/modules.json"
import registryData from "../../../public/registry.json"
export default function BuildProgress() {
const pageContext = usePageContext()

View File

@@ -1,23 +1,24 @@
import { ModuleToggle } from "@/components/ModuleToggle"
import { PluginToggle } from "@/components/PluginToggle"
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 { ChevronDown, ChevronRight, Loader2 } from "lucide-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"
import { TARGETS } from "../../../constants/targets"
import { VERSIONS } from "../../../constants/versions"
import { api } from "../../../convex/_generated/api"
import modulesData from "../../../convex/modules.json"
import registryData from "../../../public/registry.json"
type TargetGroup = (typeof TARGETS)[string] & { id: string }
@@ -45,16 +46,45 @@ export default function BuildNew() {
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 persistTargetSelection = (targetId: string) => {
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])
@@ -66,46 +96,228 @@ export default function BuildNew() {
const [showPlugins, setShowPlugins] = useState(true)
const [isLoadingSharedBuild, setIsLoadingSharedBuild] = useState(false)
useEffect(() => {
if (!activeCategory && TARGET_CATEGORIES.length > 0) {
setActiveCategory(TARGET_CATEGORIES[0])
// 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)
}
}, [activeCategory])
// 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(() => {
if (!selectedTarget && activeCategory) {
const first = GROUPED_TARGETS[activeCategory]?.[0]?.id
if (first) {
setSelectedTarget(first)
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)
}
}
}
}, [selectedTarget, 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]) {
setSelectedTarget(savedTarget)
const category = TARGETS[savedTarget].category || "Other"
if (TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
// 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)
persistTargetSelection(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 {
@@ -330,25 +542,64 @@ export default function BuildNew() {
<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">Quick build</p>
<h1 className="text-4xl font-bold mt-1">Flash a custom firmware version</h1>
<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">
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.
{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">
{TARGET_CATEGORIES.map(category => {
{(compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES).map(category => {
const isActive = activeCategory === category
return (
<button
key={category}
type="button"
onClick={() => setActiveCategory(category)}
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"
}`}
@@ -361,21 +612,24 @@ export default function BuildNew() {
<div className="bg-slate-950/60 p-4 rounded-lg border border-slate-800/60">
<div className="flex flex-wrap gap-2">
{(activeCategory ? GROUPED_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>
)
})}
{(() => {
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>
@@ -553,21 +807,27 @@ export default function BuildNew() {
// 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 (
<PluginToggle
<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}
enabledLabel={isImplicit ? "Required" : "Add"}
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}
/>
)
})

View File

@@ -1,5 +1,5 @@
import { Link } from "@/components/Link"
import { usePageContext } from "vike-react/usePageContext"
import { Link } from "../../components/Link"
const navSections = [
{

View File

@@ -1,6 +1,8 @@
import { DiscordButton } from "@/components/DiscordButton"
import { PluginCard } from "@/components/PluginCard"
import { RedditButton } from "@/components/RedditButton"
import { Button } from "@/components/ui/button"
import registryData from "@/public/registry.json"
import { navigate } from "vike/client/router"
function QuickBuildIcon(props: React.SVGProps<SVGSVGElement>) {
@@ -11,10 +13,10 @@ function QuickBuildIcon(props: React.SVGProps<SVGSVGElement>) {
height="24"
viewBox="0 0 24 24"
role="img"
aria-label="Quick build"
aria-label="Custom build"
{...props}
>
<title>Quick build</title>
<title>Custom build</title>
<path fill="currentColor" d="M11 15H6l7-14v8h5l-7 14z" />
</svg>
)
@@ -41,6 +43,18 @@ function DocsIcon(props: React.SVGProps<SVGSVGElement>) {
}
export default function LandingPage() {
const featuredPlugins = Object.entries(registryData)
.filter(([, plugin]) => plugin.featured === true)
.sort(([, pluginA], [, pluginB]) => pluginA.name.localeCompare(pluginB.name))
const customBuildPlugin = {
id: "custom-build",
name: "Build your own",
description: "Create a custom firmware build with your choice of plugins and modules",
imageUrl: "/custom-build.webp",
featured: false,
}
return (
<div className="min-h-screen bg-slate-950 text-white">
<div className="max-w-7xl mx-auto">
@@ -55,16 +69,46 @@ export default function LandingPage() {
growing to hundreds of plugins.
</p>
{featuredPlugins.length > 0 && (
<div className="mb-10">
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-8 pb-24 md:pb-8 max-w-6xl mx-auto relative">
<h2 className="text-2xl font-bold mb-6 text-center">Popular Builds</h2>
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(260px,260px))] gap-4 auto-rows-fr justify-center">
{featuredPlugins.map(([slug, plugin]) => (
<div key={slug} className="h-full">
<PluginCard
variant="link"
id={slug}
name={plugin.name}
description={plugin.description}
imageUrl={plugin.imageUrl}
featured={false}
repo={plugin.repo}
homepage={plugin.homepage}
version={plugin.version}
downloads={plugin.downloads}
stars={plugin.stars}
/>
</div>
))}
<div className="h-full">
<PluginCard
variant="link"
id={customBuildPlugin.id}
name={customBuildPlugin.name}
description={customBuildPlugin.description}
imageUrl={customBuildPlugin.imageUrl}
featured={false}
href="/builds/new"
prominent={true}
/>
</div>
</div>
</div>
</div>
)}
<div className="flex flex-col items-center gap-4 mb-10">
<Button
onClick={() => navigate("/builds/new")}
size="lg"
variant="outline"
className="border-cyan-500/50 text-white hover:bg-slate-900/60 text-lg px-8 py-6"
>
<QuickBuildIcon className="mr-2 h-6 w-6" />
Quick Build
</Button>
<div className="flex flex-wrap items-center justify-center gap-3">
<Button
onClick={() => navigate("/docs")}

52
pages/plugins/+Page.tsx Normal file
View File

@@ -0,0 +1,52 @@
import { PluginCard } from "@/components/PluginCard"
import registryData from "@/public/registry.json"
import { PluginDisplay } from "@/types"
export default function PluginsPage() {
const plugins = 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)
})
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>
<h1 className="text-4xl font-bold mb-2">Plugin Registry</h1>
<p className="text-slate-400 max-w-2xl">
Browse community-developed plugins that extend Meshtastic firmware functionality. Featured plugins are shown
first.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
{plugins.map(([slug, plugin]) => {
const pluginDisplay = plugin as PluginDisplay
return (
<PluginCard
key={slug}
variant="link"
id={slug}
name={pluginDisplay.name}
description={pluginDisplay.description}
imageUrl={pluginDisplay.imageUrl}
featured={pluginDisplay.featured ?? false}
repo={pluginDisplay.repo}
homepage={pluginDisplay.homepage}
version={pluginDisplay.version}
downloads={pluginDisplay.downloads}
stars={pluginDisplay.stars}
/>
)
})}
</div>
</div>
</div>
)
}

BIN
public/custom-build.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -4,6 +4,7 @@
"description": "Micro database for Meshtastic - A synchronous, protobuf-based database for Meshtastic",
"repo": "https://github.com/MeshEnvy/lodb",
"homepage": "https://github.com/MeshEnvy/lodb",
"imageUrl": "https://raw.githubusercontent.com/MeshEnvy/lodb/refs/heads/main/logo.webp",
"version": "1.1.0",
"author": "benallfree",
"featured": false,
@@ -15,6 +16,7 @@
"name": "LoBBS",
"author": "benallfree",
"description": "BBS for Meshtastic right on the firmware - A full bulletin board system that runs entirely inside the Meshtastic firmware",
"imageUrl": "https://raw.githubusercontent.com/MeshEnvy/lobbs/refs/heads/main/logo.webp",
"repo": "https://github.com/MeshEnvy/lobbs",
"homepage": "https://github.com/MeshEnvy/lobbs",
"version": "1.1.1",

17
types.ts Normal file
View File

@@ -0,0 +1,17 @@
export type Plugin = {
name: string
description: string
repo: string
homepage: string
imageUrl: string
version: string
author: string
featured: boolean
dependencies: Record<string, string>
includes?: string[]
}
export type PluginDisplay = Plugin & {
downloads?: number
stars?: number
}

2
vendor/api vendored

Submodule vendor/api updated: 1f8983aa9c...1774354d2b

2
vendor/lobbs vendored

2
vendor/lodb vendored

2
vendor/mpm vendored

Submodule vendor/mpm updated: f2f9ebc3ec...d1014944fd