forked from iarv/mesh-forge
feat: add plugin management features with new PluginCard and PluginsPage components, and introduce Plugin type definitions
This commit is contained in:
BIN
assets/custom-build.pxd
Normal file
BIN
assets/custom-build.pxd
Normal file
Binary file not shown.
@@ -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">
|
||||
|
||||
@@ -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
304
components/PluginCard.tsx
Normal 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" />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
44
lib/utils.ts
44
lib/utils.ts
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}, [activeCategory])
|
||||
// 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(() => {
|
||||
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)
|
||||
// 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 (TARGET_CATEGORIES.includes(category)) {
|
||||
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,7 +612,9 @@ 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 targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
|
||||
return (activeCategory ? targets[activeCategory] : [])?.map(target => {
|
||||
const isSelected = selectedTarget === target.id
|
||||
return (
|
||||
<button
|
||||
@@ -375,7 +628,8 @@ export default function BuildNew() {
|
||||
{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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from "@/components/Link"
|
||||
import { usePageContext } from "vike-react/usePageContext"
|
||||
import { Link } from "../../components/Link"
|
||||
|
||||
const navSections = [
|
||||
{
|
||||
|
||||
@@ -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
52
pages/plugins/+Page.tsx
Normal 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
BIN
public/custom-build.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -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
17
types.ts
Normal 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
2
vendor/api
vendored
Submodule vendor/api updated: 1f8983aa9c...1774354d2b
2
vendor/firmware
vendored
2
vendor/firmware
vendored
Submodule vendor/firmware updated: a19a0d59cd...578f67c943
2
vendor/lobbs
vendored
2
vendor/lobbs
vendored
Submodule vendor/lobbs updated: 2edab47a31...ab0ef08768
2
vendor/lodb
vendored
2
vendor/lodb
vendored
Submodule vendor/lodb updated: 7f0c58c687...86f9ffc804
2
vendor/mpm
vendored
2
vendor/mpm
vendored
Submodule vendor/mpm updated: f2f9ebc3ec...d1014944fd
Reference in New Issue
Block a user