mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-03-28 17:42:55 +01:00
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 { SourceAvailable } from "@/components/SourceAvailable"
|
||||||
import { Button } from "@/components/ui/button"
|
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 { useMutation } from "convex/react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { api } from "../convex/_generated/api"
|
|
||||||
import type { Doc } from "../convex/_generated/dataModel"
|
|
||||||
import { ArtifactType } from "../convex/builds"
|
|
||||||
|
|
||||||
interface BuildDownloadButtonProps {
|
interface BuildDownloadButtonProps {
|
||||||
build: Doc<"builds">
|
build: Doc<"builds">
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import favicon from "@/assets/favicon-96x96.png"
|
||||||
import { DiscordButton } from "@/components/DiscordButton"
|
import { DiscordButton } from "@/components/DiscordButton"
|
||||||
import { RedditButton } from "@/components/RedditButton"
|
import { RedditButton } from "@/components/RedditButton"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
import { useAuthActions } from "@convex-dev/auth/react"
|
import { useAuthActions } from "@convex-dev/auth/react"
|
||||||
import { Authenticated, Unauthenticated, useQuery } from "convex/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() {
|
export default function Navbar() {
|
||||||
const { signOut, signIn } = useAuthActions()
|
const { signOut, signIn } = useAuthActions()
|
||||||
@@ -25,6 +25,9 @@ export default function Navbar() {
|
|||||||
<a href="/docs" className="text-slate-300 hover:text-white transition-colors">
|
<a href="/docs" className="text-slate-300 hover:text-white transition-colors">
|
||||||
Docs
|
Docs
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/plugins" className="text-slate-300 hover:text-white transition-colors">
|
||||||
|
Plugins
|
||||||
|
</a>
|
||||||
<Authenticated>
|
<Authenticated>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<a href="/admin" className="text-slate-300 hover:text-white transition-colors">
|
<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 {
|
export interface TargetMetadata {
|
||||||
name: string
|
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 { type ClassValue, clsx } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
import PARENT_MAP from "../constants/architecture-hierarchy.json"
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -292,3 +292,45 @@ export function isPluginCompatibleWithArchitecture(
|
|||||||
// Legacy support: treat architectures array as includes
|
// Legacy support: treat architectures array as includes
|
||||||
return isPluginCompatibleWithTarget(pluginArchitectures, undefined, targetArchitecture)
|
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
|
// https://vike.dev/Head
|
||||||
|
|
||||||
import appleTouchIconUrl from "../assets/apple-touch-icon.png"
|
import appleTouchIconUrl from "@/assets/apple-touch-icon.png"
|
||||||
import favicon96x96Url from "../assets/favicon-96x96.png"
|
import favicon96x96Url from "@/assets/favicon-96x96.png"
|
||||||
import faviconIcoUrl from "../assets/favicon.ico"
|
import faviconIcoUrl from "@/assets/favicon.ico"
|
||||||
import faviconUrl from "../assets/favicon.svg"
|
import faviconUrl from "@/assets/favicon.svg"
|
||||||
import logoUrl from "../assets/logo.png"
|
import logoUrl from "@/assets/logo.png"
|
||||||
import siteWebmanifestUrl from "../assets/site.webmanifest"
|
import siteWebmanifestUrl from "@/assets/site.webmanifest"
|
||||||
|
|
||||||
export function Head() {
|
export function Head() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { BuildDownloadButton } from "@/components/BuildDownloadButton"
|
import { BuildDownloadButton } from "@/components/BuildDownloadButton"
|
||||||
import { Button } from "@/components/ui/button"
|
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 { useMutation, useQuery } from "convex/react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { navigate } from "vike/client/router"
|
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"
|
type FilterType = "all" | "failed"
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { BuildDownloadButton } from "@/components/BuildDownloadButton"
|
import { BuildDownloadButton } from "@/components/BuildDownloadButton"
|
||||||
import { Button } from "@/components/ui/button"
|
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 { getImplicitDependencies, humanizeStatus } from "@/lib/utils"
|
||||||
|
import registryData from "@/public/registry.json"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { useMutation, useQuery } from "convex/react"
|
||||||
import { AlertCircle, ArrowLeft, CheckCircle, Loader2, Share2, XCircle } from "lucide-react"
|
import { AlertCircle, ArrowLeft, CheckCircle, Loader2, Share2, XCircle } from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { usePageContext } from "vike-react/usePageContext"
|
import { usePageContext } from "vike-react/usePageContext"
|
||||||
import { navigate } from "vike/client/router"
|
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() {
|
export default function BuildProgress() {
|
||||||
const pageContext = usePageContext()
|
const pageContext = usePageContext()
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
import { ModuleToggle } from "@/components/ModuleToggle"
|
import { ModuleToggle } from "@/components/ModuleToggle"
|
||||||
import { PluginToggle } from "@/components/PluginToggle"
|
import { PluginCard } from "@/components/PluginCard"
|
||||||
import { Button } from "@/components/ui/button"
|
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 {
|
import {
|
||||||
getDependedPlugins,
|
getDependedPlugins,
|
||||||
getImplicitDependencies,
|
getImplicitDependencies,
|
||||||
|
getTargetsCompatibleWithIncludes,
|
||||||
isPluginCompatibleWithTarget,
|
isPluginCompatibleWithTarget,
|
||||||
isRequiredByOther,
|
isRequiredByOther,
|
||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
|
import registryData from "@/public/registry.json"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
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 { useEffect, useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { usePageContext } from "vike-react/usePageContext"
|
import { usePageContext } from "vike-react/usePageContext"
|
||||||
import { navigate } from "vike/client/router"
|
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 }
|
type TargetGroup = (typeof TARGETS)[string] & { id: string }
|
||||||
|
|
||||||
@@ -45,16 +46,45 @@ export default function BuildNew() {
|
|||||||
const pluginFlashCounts = useQuery(api.plugins.getAll) ?? {}
|
const pluginFlashCounts = useQuery(api.plugins.getAll) ?? {}
|
||||||
const sharedBuild = useQuery(api.builds.getByHash, buildHashParam ? { buildHash: buildHashParam } : "skip")
|
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 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
|
if (typeof window === "undefined") return
|
||||||
try {
|
try {
|
||||||
|
// Store global most recent selection
|
||||||
window.localStorage.setItem(STORAGE_KEY, targetId)
|
window.localStorage.setItem(STORAGE_KEY, targetId)
|
||||||
|
// Store per-brand selection if category provided
|
||||||
|
if (category) {
|
||||||
|
window.localStorage.setItem(getStorageKeyForCategory(category), targetId)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to persist target selection", 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 [activeCategory, setActiveCategory] = useState<string>(TARGET_CATEGORIES[0] ?? "")
|
||||||
const [selectedTarget, setSelectedTarget] = useState<string>(DEFAULT_TARGET)
|
const [selectedTarget, setSelectedTarget] = useState<string>(DEFAULT_TARGET)
|
||||||
const [selectedVersion, setSelectedVersion] = useState<string>(VERSIONS[0])
|
const [selectedVersion, setSelectedVersion] = useState<string>(VERSIONS[0])
|
||||||
@@ -66,46 +96,228 @@ export default function BuildNew() {
|
|||||||
const [showPlugins, setShowPlugins] = useState(true)
|
const [showPlugins, setShowPlugins] = useState(true)
|
||||||
const [isLoadingSharedBuild, setIsLoadingSharedBuild] = useState(false)
|
const [isLoadingSharedBuild, setIsLoadingSharedBuild] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
// Get all enabled plugins
|
||||||
if (!activeCategory && TARGET_CATEGORIES.length > 0) {
|
const enabledPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
|
||||||
setActiveCategory(TARGET_CATEGORIES[0])
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!selectedTarget && activeCategory) {
|
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
|
||||||
const first = GROUPED_TARGETS[activeCategory]?.[0]?.id
|
if (!activeCategory && categories.length > 0) {
|
||||||
if (first) {
|
setActiveCategory(categories[0])
|
||||||
setSelectedTarget(first)
|
}
|
||||||
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
try {
|
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)
|
const savedTarget = localStorage.getItem(STORAGE_KEY)
|
||||||
if (savedTarget && TARGETS[savedTarget]) {
|
if (savedTarget && TARGETS[savedTarget]) {
|
||||||
setSelectedTarget(savedTarget)
|
// Check if saved target exists in filtered targets
|
||||||
const category = TARGETS[savedTarget].category || "Other"
|
const isCompatible = Object.values(targets).some(categoryTargets =>
|
||||||
if (TARGET_CATEGORIES.includes(category)) {
|
categoryTargets.some(target => target.id === savedTarget)
|
||||||
setActiveCategory(category)
|
)
|
||||||
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error("Failed to read saved target", error)
|
console.error("Failed to read saved target", error)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [compatibleTargets, filteredGroupedTargets, filteredTargetCategories])
|
||||||
|
|
||||||
const handleSelectTarget = (targetId: string) => {
|
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)
|
setSelectedTarget(targetId)
|
||||||
persistTargetSelection(targetId)
|
|
||||||
const category = TARGETS[targetId]?.category || "Other"
|
const category = TARGETS[targetId]?.category || "Other"
|
||||||
|
persistTargetSelection(targetId, category)
|
||||||
if (category && TARGET_CATEGORIES.includes(category)) {
|
if (category && TARGET_CATEGORIES.includes(category)) {
|
||||||
setActiveCategory(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(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined" || !selectedTarget) return
|
if (typeof window === "undefined" || !selectedTarget) return
|
||||||
try {
|
try {
|
||||||
@@ -330,25 +542,64 @@ export default function BuildNew() {
|
|||||||
<div className="max-w-6xl mx-auto space-y-8">
|
<div className="max-w-6xl mx-auto space-y-8">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm uppercase tracking-wider text-slate-500">Quick build</p>
|
<p className="text-sm uppercase tracking-wider text-slate-500">
|
||||||
<h1 className="text-4xl font-bold mt-1">Flash a custom firmware version</h1>
|
{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">
|
<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
|
{preselectedPlugin
|
||||||
the build status page as soon as it starts.
|
? `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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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-6 bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{TARGET_CATEGORIES.map(category => {
|
{(compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES).map(category => {
|
||||||
const isActive = activeCategory === category
|
const isActive = activeCategory === category
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={category}
|
key={category}
|
||||||
type="button"
|
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 ${
|
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"
|
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="bg-slate-950/60 p-4 rounded-lg border border-slate-800/60">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{(activeCategory ? GROUPED_TARGETS[activeCategory] : [])?.map(target => {
|
{(() => {
|
||||||
const isSelected = selectedTarget === target.id
|
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
|
||||||
return (
|
return (activeCategory ? targets[activeCategory] : [])?.map(target => {
|
||||||
<button
|
const isSelected = selectedTarget === target.id
|
||||||
key={target.id}
|
return (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => handleSelectTarget(target.id)}
|
key={target.id}
|
||||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
type="button"
|
||||||
isSelected ? "bg-cyan-600 text-white" : "bg-slate-800 text-slate-300 hover:bg-slate-700"
|
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>
|
>
|
||||||
)
|
{target.name}
|
||||||
})}
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -553,21 +807,27 @@ export default function BuildNew() {
|
|||||||
// Mark as incompatible if plugin has compatibility constraints and target is not compatible
|
// Mark as incompatible if plugin has compatibility constraints and target is not compatible
|
||||||
const isIncompatible = !isCompatible && hasCompatibilityConstraints && !!selectedTarget
|
const isIncompatible = !isCompatible && hasCompatibilityConstraints && !!selectedTarget
|
||||||
|
|
||||||
|
// Check if this is the preselected plugin from URL
|
||||||
|
const isPreselected = pluginParam === slug
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PluginToggle
|
<PluginCard
|
||||||
key={`${slug}-${selectedTarget}`}
|
key={`${slug}-${selectedTarget}`}
|
||||||
|
variant="link-toggle"
|
||||||
id={slug}
|
id={slug}
|
||||||
name={plugin.name}
|
name={plugin.name}
|
||||||
description={plugin.description}
|
description={plugin.description}
|
||||||
|
imageUrl={plugin.imageUrl}
|
||||||
isEnabled={allEnabledPlugins.includes(slug)}
|
isEnabled={allEnabledPlugins.includes(slug)}
|
||||||
onToggle={enabled => handleTogglePlugin(slug, enabled)}
|
onToggle={enabled => handleTogglePlugin(slug, enabled)}
|
||||||
disabled={isImplicit || isIncompatible}
|
disabled={isImplicit || isIncompatible || isPreselected}
|
||||||
enabledLabel={isImplicit ? "Required" : "Add"}
|
enabledLabel={isPreselected ? "Locked" : isImplicit ? "Required" : "Add"}
|
||||||
incompatibleReason={isIncompatible ? "Not compatible with this target" : undefined}
|
incompatibleReason={isIncompatible ? "Not compatible with this target" : undefined}
|
||||||
featured={plugin.featured ?? false}
|
featured={plugin.featured ?? false}
|
||||||
flashCount={pluginFlashCounts[slug] ?? 0}
|
flashCount={pluginFlashCounts[slug] ?? 0}
|
||||||
homepage={plugin.homepage}
|
homepage={plugin.homepage}
|
||||||
version={plugin.version}
|
version={plugin.version}
|
||||||
|
repo={plugin.repo}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { Link } from "@/components/Link"
|
||||||
import { usePageContext } from "vike-react/usePageContext"
|
import { usePageContext } from "vike-react/usePageContext"
|
||||||
import { Link } from "../../components/Link"
|
|
||||||
|
|
||||||
const navSections = [
|
const navSections = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { DiscordButton } from "@/components/DiscordButton"
|
import { DiscordButton } from "@/components/DiscordButton"
|
||||||
|
import { PluginCard } from "@/components/PluginCard"
|
||||||
import { RedditButton } from "@/components/RedditButton"
|
import { RedditButton } from "@/components/RedditButton"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import registryData from "@/public/registry.json"
|
||||||
import { navigate } from "vike/client/router"
|
import { navigate } from "vike/client/router"
|
||||||
|
|
||||||
function QuickBuildIcon(props: React.SVGProps<SVGSVGElement>) {
|
function QuickBuildIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
@@ -11,10 +13,10 @@ function QuickBuildIcon(props: React.SVGProps<SVGSVGElement>) {
|
|||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Quick build"
|
aria-label="Custom build"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<title>Quick build</title>
|
<title>Custom build</title>
|
||||||
<path fill="currentColor" d="M11 15H6l7-14v8h5l-7 14z" />
|
<path fill="currentColor" d="M11 15H6l7-14v8h5l-7 14z" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
@@ -41,6 +43,18 @@ function DocsIcon(props: React.SVGProps<SVGSVGElement>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LandingPage() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 text-white">
|
<div className="min-h-screen bg-slate-950 text-white">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
@@ -55,16 +69,46 @@ export default function LandingPage() {
|
|||||||
growing to hundreds of plugins.
|
growing to hundreds of plugins.
|
||||||
</p>
|
</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">
|
<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">
|
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate("/docs")}
|
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",
|
"description": "Micro database for Meshtastic - A synchronous, protobuf-based database for Meshtastic",
|
||||||
"repo": "https://github.com/MeshEnvy/lodb",
|
"repo": "https://github.com/MeshEnvy/lodb",
|
||||||
"homepage": "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",
|
"version": "1.1.0",
|
||||||
"author": "benallfree",
|
"author": "benallfree",
|
||||||
"featured": false,
|
"featured": false,
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
"name": "LoBBS",
|
"name": "LoBBS",
|
||||||
"author": "benallfree",
|
"author": "benallfree",
|
||||||
"description": "BBS for Meshtastic right on the firmware - A full bulletin board system that runs entirely inside the Meshtastic firmware",
|
"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",
|
"repo": "https://github.com/MeshEnvy/lobbs",
|
||||||
"homepage": "https://github.com/MeshEnvy/lobbs",
|
"homepage": "https://github.com/MeshEnvy/lobbs",
|
||||||
"version": "1.1.1",
|
"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