From bdb848e15f3b87083e1d7121d701df230be99a8d Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Wed, 10 Dec 2025 17:47:38 -0800 Subject: [PATCH] fix: refactor build routes for improved navigation --- CHANGELOG.md | 2 + components/BuildActions.tsx | 34 + components/BuildProgress.tsx | 8 +- components/Builder.tsx | 296 ++++++++ components/BuilderHeader.tsx | 64 ++ components/ModuleConfig.tsx | 74 ++ components/PluginCard.tsx | 115 +++- components/PluginConfig.tsx | 158 +++++ components/TargetSelector.tsx | 76 +++ components/VersionSelector.tsx | 28 + hooks/usePluginCompatibility.ts | 74 ++ hooks/useTargetSelection.ts | 215 ++++++ pages/builds/{@buildHash => }/+Page.tsx | 37 +- pages/builds/new/+Page.tsx | 5 - pages/builds/new/@buildHash/+Page.tsx | 5 - pages/builds/new/Builder.tsx | 857 ------------------------ pages/index/+Page.tsx | 2 +- pages/plugins/@slug/+Page.tsx | 2 +- 18 files changed, 1125 insertions(+), 927 deletions(-) create mode 100644 components/BuildActions.tsx create mode 100644 components/Builder.tsx create mode 100644 components/BuilderHeader.tsx create mode 100644 components/ModuleConfig.tsx create mode 100644 components/PluginConfig.tsx create mode 100644 components/TargetSelector.tsx create mode 100644 components/VersionSelector.tsx create mode 100644 hooks/usePluginCompatibility.ts create mode 100644 hooks/useTargetSelection.ts rename pages/builds/{@buildHash => }/+Page.tsx (70%) delete mode 100644 pages/builds/new/+Page.tsx delete mode 100644 pages/builds/new/@buildHash/+Page.tsx delete mode 100644 pages/builds/new/Builder.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 877a293..b8c147c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Refactored targets.ts to use vendors.json and architecture-hierarchy.json instead of hardware-list.json - Updated architecture-hierarchy.json generation to use actual PlatformIO environment names (removed normalization) - Removed normalization from lib/utils.ts since all inputs now use standardized PlatformIO names +- Refactored build routes from dynamic parameterized routes to query string parameters for Vike SSG compatibility ### Patch - Fix Convex server functions being imported in browser by moving ArtifactType enum to client-safe location +- Fix nested anchor tag hydration error in PluginCard component by converting nested links to buttons when parent is a link ## [0.3.0] - 2025-12-10 diff --git a/components/BuildActions.tsx b/components/BuildActions.tsx new file mode 100644 index 0000000..cbedd1b --- /dev/null +++ b/components/BuildActions.tsx @@ -0,0 +1,34 @@ +import { Button } from "@/components/ui/button" +import { Loader2 } from "lucide-react" + +interface BuildActionsProps { + selectedTargetLabel: string + isFlashing: boolean + isFlashDisabled: boolean + errorMessage: string | null + onFlash: () => void +} + +export function BuildActions({ + selectedTargetLabel, + isFlashing, + isFlashDisabled, + errorMessage, + onFlash, +}: BuildActionsProps) { + return ( +
+ + {errorMessage &&

{errorMessage}

} +
+ ) +} diff --git a/components/BuildProgress.tsx b/components/BuildProgress.tsx index f586670..78faf54 100644 --- a/components/BuildProgress.tsx +++ b/components/BuildProgress.tsx @@ -48,7 +48,7 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t ? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}` : null - const shareUrl = `${window.location.origin}/builds/new/${build.buildHash}` + const shareUrl = `${window.location.origin}/builds?clone=${build.buildHash}` const handleShare = async () => { try { @@ -216,10 +216,10 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t

{getStatusIcon()} { e.preventDefault() - navigate(`/builds/${build.buildHash}`) + navigate(`/builds?id=${build.buildHash}`) }} className="hover:text-cyan-400 transition-colors" > @@ -257,7 +257,7 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t {showActions && (
+ + {showModuleOverrides && ( +
+
+

+ Core Modules are officially maintained modules by Meshtastic. They are selectively included or excluded by + default depending on the target device. You can explicitly exclude modules you know you don't want. +

+
+
+ +
+
+ {modulesData.modules.map(module => ( + onToggleModule(module.id, excluded)} + /> + ))} +
+
+ )} +
+ ) +} diff --git a/components/PluginCard.tsx b/components/PluginCard.tsx index 7e26fea..b35b4e8 100644 --- a/components/PluginCard.tsx +++ b/components/PluginCard.tsx @@ -205,42 +205,87 @@ export function PluginCard(props: PluginCardProps) { {downloads.toLocaleString()} )} - {homepage && homepage !== repo && (isLink || isLinkToggle) && ( -
e.stopPropagation()} - className="hover:opacity-80 transition-opacity" - > - { + e.preventDefault() + e.stopPropagation() + window.open(homepage, "_blank", "noopener,noreferrer") + }} + className="hover:opacity-80 transition-opacity" aria-label="Homepage" > - - - - )} - {starsBadgeUrl && repo && ( - e.stopPropagation()} - className="hover:opacity-80 transition-opacity" - > - GitHub stars - - )} + role="img" + aria-label="Homepage" + > + + + + ) : ( + e.stopPropagation()} + className="hover:opacity-80 transition-opacity" + > + + + + + ))} + {starsBadgeUrl && + repo && + (isLink ? ( + + ) : ( + e.stopPropagation()} + className="hover:opacity-80 transition-opacity" + > + GitHub stars + + ))} {/* Build Now button - absolutely positioned in lower right */} {isLink && ( @@ -249,7 +294,7 @@ export function PluginCard(props: PluginCardProps) { onClick={e => { e.preventDefault() e.stopPropagation() - navigate(`/builds/new?plugin=${id}`) + navigate(`/builds?plugin=${id}`) }} className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 rounded hover:bg-cyan-400/20 transition-colors cursor-pointer" > @@ -287,7 +332,7 @@ export function PluginCard(props: PluginCardProps) { } ${isLink ? "group" : ""}` if (isLink) { - const href = props.href || `/builds/new?plugin=${id}` + const href = props.href || `/builds?plugin=${id}` return ( {cardContent} diff --git a/components/PluginConfig.tsx b/components/PluginConfig.tsx new file mode 100644 index 0000000..14801b9 --- /dev/null +++ b/components/PluginConfig.tsx @@ -0,0 +1,158 @@ +import { PluginCard } from "@/components/PluginCard" +import { + getDependedPlugins, + getImplicitDependencies, + isPluginCompatibleWithTarget, + isRequiredByOther, +} from "@/lib/utils" +import registryData from "@/public/registry.json" +import { ChevronDown, ChevronRight } from "lucide-react" + +interface PluginConfigProps { + pluginConfig: Record + selectedTarget: string + pluginParam?: string + pluginFlashCounts: Record + showPlugins: boolean + onToggleShow: () => void + onTogglePlugin: (id: string, enabled: boolean) => void + onReset: () => void +} + +export function PluginConfig({ + pluginConfig, + selectedTarget, + pluginParam, + pluginFlashCounts, + showPlugins, + onToggleShow, + onTogglePlugin, + onReset, +}: PluginConfigProps) { + const pluginCount = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true).length + + // Get explicitly selected plugins (user-selected) + const explicitPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true) + + // Compute implicit dependencies (dependencies that are not explicitly selected) + const implicitDeps = getImplicitDependencies( + explicitPlugins, + registryData as Record }> + ) + + // Compute all enabled plugins (explicit + implicit) + const allEnabledPlugins = getDependedPlugins( + explicitPlugins, + registryData as Record }> + ) + + return ( +
+ + + {showPlugins && ( +
+
+

+ Plugins are 3rd party add-ons. They are not maintained, endorsed, or supported by Meshtastic. Use at your + own risk. +

+
+
+ +
+
+ {Object.entries(registryData) + .sort(([, pluginA], [, pluginB]) => { + // Featured plugins first + const featuredA = pluginA.featured ?? false + const featuredB = pluginB.featured ?? false + if (featuredA !== featuredB) { + return featuredA ? -1 : 1 + } + // Then alphabetical by name + return pluginA.name.localeCompare(pluginB.name) + }) + .map(([slug, plugin]) => { + // Check if plugin is required by another explicitly selected plugin + const isRequired = isRequiredByOther( + slug, + explicitPlugins, + registryData as Record }> + ) + // Plugin is implicit if it's either: + // 1. Not explicitly selected but is a dependency, OR + // 2. Explicitly selected but required by another explicitly selected plugin + const isImplicit = implicitDeps.has(slug) || (explicitPlugins.includes(slug) && isRequired) + + // Check plugin compatibility with selected target + const pluginIncludes = (plugin as { includes?: string[] }).includes + const pluginExcludes = (plugin as { excludes?: string[] }).excludes + // Legacy support: check for old "architectures" field + const legacyArchitectures = (plugin as { architectures?: string[] }).architectures + const hasCompatibilityConstraints = + (pluginIncludes && pluginIncludes.length > 0) || + (pluginExcludes && pluginExcludes.length > 0) || + (legacyArchitectures && legacyArchitectures.length > 0) + const isCompatible = + hasCompatibilityConstraints && selectedTarget + ? isPluginCompatibleWithTarget( + pluginIncludes || legacyArchitectures, + pluginExcludes, + selectedTarget + ) + : true // If no constraints or no target selected, assume compatible + // Mark as incompatible if plugin has compatibility constraints and target is not compatible + const isIncompatible = !isCompatible && hasCompatibilityConstraints && !!selectedTarget + + // Check if this is the preselected plugin from URL + const isPreselected = pluginParam === slug + + return ( + onTogglePlugin(slug, enabled)} + disabled={isImplicit || isIncompatible || isPreselected} + enabledLabel={isPreselected ? "Locked" : isImplicit ? "Required" : "Add"} + incompatibleReason={isIncompatible ? "Not compatible with this target" : undefined} + featured={plugin.featured ?? false} + flashCount={pluginFlashCounts[slug] ?? 0} + homepage={plugin.homepage} + version={plugin.version} + repo={plugin.repo} + /> + ) + })} +
+
+ )} +
+ ) +} diff --git a/components/TargetSelector.tsx b/components/TargetSelector.tsx new file mode 100644 index 0000000..22ba3fb --- /dev/null +++ b/components/TargetSelector.tsx @@ -0,0 +1,76 @@ +import { TARGETS } from "@/constants/targets" + +type TargetGroup = (typeof TARGETS)[string] & { id: string } + +interface TargetSelectorProps { + activeCategory: string + categories: string[] + groupedTargets: Record + selectedTarget: string + compatibleTargets: Set | null + onCategoryChange: (category: string) => void + onTargetSelect: (targetId: string) => void +} + +export function TargetSelector({ + activeCategory, + categories, + groupedTargets, + selectedTarget, + compatibleTargets, + onCategoryChange, + onTargetSelect, +}: TargetSelectorProps) { + const targets = activeCategory ? groupedTargets[activeCategory] : [] + + return ( +
+
+ {categories.map(category => { + const isActive = activeCategory === category + return ( + + ) + })} +
+ +
+
+ {targets?.map(target => { + const isSelected = selectedTarget === target.id + const normalizedId = target.id.replace(/[-_]/g, "") + const isCompatible = + !compatibleTargets || compatibleTargets.has(target.id) || compatibleTargets.has(normalizedId) + + return ( + + ) + })} +
+
+
+ ) +} diff --git a/components/VersionSelector.tsx b/components/VersionSelector.tsx new file mode 100644 index 0000000..8351e7c --- /dev/null +++ b/components/VersionSelector.tsx @@ -0,0 +1,28 @@ +import { VERSIONS } from "@/constants/versions" + +interface VersionSelectorProps { + selectedVersion: string + onVersionChange: (version: string) => void +} + +export function VersionSelector({ selectedVersion, onVersionChange }: VersionSelectorProps) { + return ( +
+ + +
+ ) +} diff --git a/hooks/usePluginCompatibility.ts b/hooks/usePluginCompatibility.ts new file mode 100644 index 0000000..49bb5f9 --- /dev/null +++ b/hooks/usePluginCompatibility.ts @@ -0,0 +1,74 @@ +import { TARGETS } from "@/constants/targets" +import { getTargetsCompatibleWithIncludes } from "@/lib/utils" +import registryData from "@/public/registry.json" + +type TargetGroup = (typeof TARGETS)[string] & { id: string } + +const GROUPED_TARGETS = Object.entries(TARGETS).reduce( + (acc, [id, meta]) => { + const category = meta.category || "Other" + if (!acc[category]) acc[category] = [] + acc[category].push({ id, ...meta }) + return acc + }, + {} as Record +) + +export function usePluginCompatibility(enabledPlugins: string[], preselectedPlugin?: { includes?: string[] } | null) { + // Start with preselected plugin compatibility if present + let compatibleTargets: Set | null = preselectedPlugin?.includes + ? getTargetsCompatibleWithIncludes(preselectedPlugin.includes) + : null + + // Intersect with compatibility of all enabled plugins + if (enabledPlugins.length > 0) { + const pluginRegistry = registryData as Record + const allCompatibleSets: Set[] = [] + + for (const pluginId of enabledPlugins) { + const plugin = pluginRegistry[pluginId] + if (plugin?.includes && plugin.includes.length > 0) { + allCompatibleSets.push(getTargetsCompatibleWithIncludes(plugin.includes)) + } + } + + if (allCompatibleSets.length > 0) { + if (compatibleTargets) { + compatibleTargets = new Set( + Array.from(compatibleTargets).filter(target => allCompatibleSets.every(set => set.has(target))) + ) + } else { + compatibleTargets = allCompatibleSets[0] + for (let i = 1; i < allCompatibleSets.length; i++) { + compatibleTargets = new Set(Array.from(compatibleTargets).filter(target => allCompatibleSets[i].has(target))) + } + } + } else if (!compatibleTargets) { + compatibleTargets = null + } + } + + const filteredGroupedTargets = compatibleTargets + ? Object.entries(GROUPED_TARGETS).reduce( + (acc, [category, targets]) => { + const filtered = targets.filter(target => { + const normalizedId = target.id.replace(/[-_]/g, "") + return compatibleTargets.has(target.id) || compatibleTargets.has(normalizedId) + }) + if (filtered.length > 0) { + acc[category] = filtered + } + return acc + }, + {} as Record + ) + : GROUPED_TARGETS + + const filteredTargetCategories = Object.keys(filteredGroupedTargets).sort((a, b) => a.localeCompare(b)) + + return { + compatibleTargets, + filteredGroupedTargets, + filteredTargetCategories, + } +} diff --git a/hooks/useTargetSelection.ts b/hooks/useTargetSelection.ts new file mode 100644 index 0000000..62335b8 --- /dev/null +++ b/hooks/useTargetSelection.ts @@ -0,0 +1,215 @@ +import { TARGETS } from "@/constants/targets" +import { useEffect, useState } from "react" + +type TargetGroup = (typeof TARGETS)[string] & { id: string } + +const GROUPED_TARGETS = Object.entries(TARGETS).reduce( + (acc, [id, meta]) => { + const category = meta.category || "Other" + if (!acc[category]) acc[category] = [] + acc[category].push({ id, ...meta }) + return acc + }, + {} as Record +) + +const TARGET_CATEGORIES = Object.keys(GROUPED_TARGETS).sort((a, b) => a.localeCompare(b)) + +const DEFAULT_TARGET = + TARGET_CATEGORIES.length > 0 && GROUPED_TARGETS[TARGET_CATEGORIES[0]]?.length + ? GROUPED_TARGETS[TARGET_CATEGORIES[0]][0].id + : "" + +const STORAGE_KEY = "quick_build_target" +const getStorageKeyForCategory = (category: string) => `quick_build_target_${category}` + +export function useTargetSelection( + compatibleTargets: Set | null, + filteredGroupedTargets: Record, + filteredTargetCategories: string[] +) { + const [activeCategory, setActiveCategory] = useState(TARGET_CATEGORIES[0] ?? "") + const [selectedTarget, setSelectedTarget] = useState(DEFAULT_TARGET) + + const persistTargetSelection = (targetId: string, category?: string) => { + if (typeof window === "undefined") return + try { + window.localStorage.setItem(STORAGE_KEY, targetId) + if (category) { + window.localStorage.setItem(getStorageKeyForCategory(category), targetId) + } + } catch (error) { + console.error("Failed to persist target selection", error) + } + } + + const getSavedTargetForCategory = (category: string): string | null => { + if (typeof window === "undefined") return null + try { + return window.localStorage.getItem(getStorageKeyForCategory(category)) + } catch (error) { + console.error("Failed to read saved target for category", error) + return null + } + } + + const handleSelectTarget = (targetId: string) => { + if (compatibleTargets) { + const normalizedId = targetId.replace(/[-_]/g, "") + const isCompatible = compatibleTargets.has(targetId) || compatibleTargets.has(normalizedId) + if (!isCompatible) { + return + } + } + setSelectedTarget(targetId) + const category = TARGETS[targetId]?.category || "Other" + persistTargetSelection(targetId, category) + if (category && TARGET_CATEGORIES.includes(category)) { + setActiveCategory(category) + } + } + + // Initialize active category + useEffect(() => { + const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES + if (!activeCategory && categories.length > 0) { + setActiveCategory(categories[0]) + } + }, [activeCategory, compatibleTargets, filteredTargetCategories]) + + // Handle category change - auto-select target + useEffect(() => { + if (activeCategory) { + const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS + const categoryTargets = targets[activeCategory] || [] + + if (categoryTargets.length === 0) return + + const isCurrentTargetInCategory = categoryTargets.some(t => t.id === selectedTarget) + + if (!isCurrentTargetInCategory) { + const savedTargetForCategory = getSavedTargetForCategory(activeCategory) + const isValidSavedTarget = savedTargetForCategory && categoryTargets.some(t => t.id === savedTargetForCategory) + + if (isValidSavedTarget) { + setSelectedTarget(savedTargetForCategory) + persistTargetSelection(savedTargetForCategory, activeCategory) + } else { + const firstTarget = categoryTargets[0].id + setSelectedTarget(firstTarget) + persistTargetSelection(firstTarget, activeCategory) + } + } + } + }, [activeCategory, compatibleTargets, filteredGroupedTargets, selectedTarget]) + + // Restore saved target on mount + useEffect(() => { + if (typeof window === "undefined") return + try { + const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS + const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES + + if (categories.length === 0) return + + const savedTarget = localStorage.getItem(STORAGE_KEY) + if (savedTarget && TARGETS[savedTarget]) { + const isCompatible = Object.values(targets).some(categoryTargets => + categoryTargets.some(target => target.id === savedTarget) + ) + + if (isCompatible) { + const category = TARGETS[savedTarget].category || "Other" + if (categories.includes(category)) { + setActiveCategory(category) + setSelectedTarget(savedTarget) + persistTargetSelection(savedTarget, category) + return + } + } + } + + const firstCategory = categories[0] + const categoryTargets = targets[firstCategory] || [] + + if (categoryTargets.length > 0) { + const savedTargetForCategory = getSavedTargetForCategory(firstCategory) + const isValidSavedTarget = savedTargetForCategory && categoryTargets.some(t => t.id === savedTargetForCategory) + + if (isValidSavedTarget) { + setActiveCategory(firstCategory) + setSelectedTarget(savedTargetForCategory) + persistTargetSelection(savedTargetForCategory, firstCategory) + } else { + const firstTarget = categoryTargets[0].id + setActiveCategory(firstCategory) + setSelectedTarget(firstTarget) + persistTargetSelection(firstTarget, firstCategory) + } + } + } catch (error) { + console.error("Failed to read saved target", error) + } + }, [compatibleTargets, filteredGroupedTargets, filteredTargetCategories]) + + // Update selected target if it becomes incompatible + useEffect(() => { + if (!selectedTarget || !compatibleTargets) return + + const normalizedId = selectedTarget.replace(/[-_]/g, "") + const isCompatible = compatibleTargets.has(selectedTarget) || compatibleTargets.has(normalizedId) + + if (!isCompatible) { + const targets = filteredGroupedTargets + const categories = filteredTargetCategories + + if (categories.length > 0) { + const currentCategory = TARGETS[selectedTarget]?.category + if (currentCategory && targets[currentCategory] && targets[currentCategory].length > 0) { + const savedTargetForCategory = getSavedTargetForCategory(currentCategory) + const isValidSavedTarget = + savedTargetForCategory && targets[currentCategory].some(t => t.id === savedTargetForCategory) + + if (isValidSavedTarget) { + setSelectedTarget(savedTargetForCategory) + persistTargetSelection(savedTargetForCategory, currentCategory) + return + } + + setSelectedTarget(targets[currentCategory][0].id) + persistTargetSelection(targets[currentCategory][0].id, currentCategory) + return + } + + const firstCategory = categories[0] + const firstTarget = targets[firstCategory]?.[0]?.id + if (firstTarget) { + setSelectedTarget(firstTarget) + setActiveCategory(firstCategory) + persistTargetSelection(firstTarget, firstCategory) + } + } + } + }, [compatibleTargets, filteredGroupedTargets, filteredTargetCategories, selectedTarget]) + + // Initialize storage + useEffect(() => { + if (typeof window === "undefined" || !selectedTarget) return + try { + if (!window.localStorage.getItem(STORAGE_KEY)) { + window.localStorage.setItem(STORAGE_KEY, selectedTarget) + } + } catch (error) { + console.error("Failed to initialize target storage", error) + } + }, [selectedTarget]) + + return { + activeCategory, + selectedTarget, + setActiveCategory, + handleSelectTarget, + GROUPED_TARGETS: GROUPED_TARGETS as Record, + TARGET_CATEGORIES, + } +} diff --git a/pages/builds/@buildHash/+Page.tsx b/pages/builds/+Page.tsx similarity index 70% rename from pages/builds/@buildHash/+Page.tsx rename to pages/builds/+Page.tsx index 1cd6d97..93f86fd 100644 --- a/pages/builds/@buildHash/+Page.tsx +++ b/pages/builds/+Page.tsx @@ -1,3 +1,4 @@ +import Builder from "@/components/Builder" import { BuildProgress } from "@/components/BuildProgress" import { GiscusComments } from "@/components/GiscusComments" import { api } from "@/convex/_generated/api" @@ -6,29 +7,27 @@ import { Loader2 } from "lucide-react" import { toast } from "sonner" import { usePageContext } from "vike-react/usePageContext" -export default function BuildProgressPage() { +export default function BuildsPage() { const pageContext = usePageContext() - const buildHash = pageContext.routeParams?.buildHash as string | undefined - const build = useQuery(api.builds.getByHash, buildHash ? { buildHash } : "skip") + const urlSearchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null + const cloneHash = urlSearchParams?.get("clone") + const buildId = urlSearchParams?.get("id") + const pluginParam = urlSearchParams?.get("plugin") + + // If we have a build ID, show the build progress page + if (buildId) { + return + } + + // Otherwise, show the builder (handles clone and plugin params) + return +} + +function BuildViewPage({ buildHash }: { buildHash: string }) { + const build = useQuery(api.builds.getByHash, { buildHash }) const isAdmin = useQuery(api.admin.isAdmin) const retryBuild = useMutation(api.admin.retryBuild) - if (!buildHash) { - return ( -
-
-

- Build hash missing.{" "} - - Start a new build - - . -

-
-
- ) - } - if (build === undefined) { return (
diff --git a/pages/builds/new/+Page.tsx b/pages/builds/new/+Page.tsx deleted file mode 100644 index 5cc12b7..0000000 --- a/pages/builds/new/+Page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import Builder from "./Builder" - -export default function BuildNew() { - return -} diff --git a/pages/builds/new/@buildHash/+Page.tsx b/pages/builds/new/@buildHash/+Page.tsx deleted file mode 100644 index ee47899..0000000 --- a/pages/builds/new/@buildHash/+Page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import Builder from "../Builder" - -export default function BuildNew() { - return -} diff --git a/pages/builds/new/Builder.tsx b/pages/builds/new/Builder.tsx deleted file mode 100644 index 8ef30df..0000000 --- a/pages/builds/new/Builder.tsx +++ /dev/null @@ -1,857 +0,0 @@ -import { ModuleToggle } from "@/components/ModuleToggle" -import { PluginCard } from "@/components/PluginCard" -import { Button } from "@/components/ui/button" -import { TARGETS } from "@/constants/targets" -import { VERSIONS } from "@/constants/versions" -import { api } from "@/convex/_generated/api" -import modulesData from "@/convex/modules.json" -import { - getDependedPlugins, - getImplicitDependencies, - getTargetsCompatibleWithIncludes, - isPluginCompatibleWithTarget, - isRequiredByOther, -} from "@/lib/utils" -import registryData from "@/public/registry.json" -import { useMutation, useQuery } from "convex/react" -import { CheckCircle2, ChevronDown, ChevronRight, Loader2 } from "lucide-react" -import { useEffect, useState } from "react" -import { toast } from "sonner" -import { usePageContext } from "vike-react/usePageContext" -import { navigate } from "vike/client/router" - -type TargetGroup = (typeof TARGETS)[string] & { id: string } - -const GROUPED_TARGETS = Object.entries(TARGETS).reduce( - (acc, [id, meta]) => { - const category = meta.category || "Other" - if (!acc[category]) acc[category] = [] - acc[category].push({ id, ...meta }) - return acc - }, - {} as Record -) - -const TARGET_CATEGORIES = Object.keys(GROUPED_TARGETS).sort((a, b) => a.localeCompare(b)) - -const DEFAULT_TARGET = - TARGET_CATEGORIES.length > 0 && GROUPED_TARGETS[TARGET_CATEGORIES[0]]?.length - ? GROUPED_TARGETS[TARGET_CATEGORIES[0]][0].id - : "" - -export default function BuildNew() { - const pageContext = usePageContext() - const buildHashParam = pageContext.routeParams?.buildHash - const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig) - const pluginFlashCounts = useQuery(api.plugins.getAll) ?? {} - const sharedBuild = useQuery(api.builds.getByHash, buildHashParam ? { buildHash: buildHashParam } : "skip") - - // Get plugin from URL query parameter - const pluginParam = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("plugin") : null - const preselectedPlugin = - pluginParam && pluginParam in registryData - ? ( - registryData as Record< - string, - { includes?: string[]; name: string; description: string; imageUrl?: string; featured?: boolean } - > - )[pluginParam] - : null - - const STORAGE_KEY = "quick_build_target" - const getStorageKeyForCategory = (category: string) => `quick_build_target_${category}` - - const persistTargetSelection = (targetId: string, category?: string) => { - if (typeof window === "undefined") return - try { - // Store global most recent selection - window.localStorage.setItem(STORAGE_KEY, targetId) - // Store per-brand selection if category provided - if (category) { - window.localStorage.setItem(getStorageKeyForCategory(category), targetId) - } - } catch (error) { - console.error("Failed to persist target selection", error) - } - } - - const getSavedTargetForCategory = (category: string): string | null => { - if (typeof window === "undefined") return null - try { - return window.localStorage.getItem(getStorageKeyForCategory(category)) - } catch (error) { - console.error("Failed to read saved target for category", error) - return null - } - } - - const [activeCategory, setActiveCategory] = useState(TARGET_CATEGORIES[0] ?? "") - const [selectedTarget, setSelectedTarget] = useState(DEFAULT_TARGET) - const [selectedVersion, setSelectedVersion] = useState(VERSIONS[0]) - const [moduleConfig, setModuleConfig] = useState>({}) - const [pluginConfig, setPluginConfig] = useState>({}) - const [isFlashing, setIsFlashing] = useState(false) - const [errorMessage, setErrorMessage] = useState(null) - const [showModuleOverrides, setShowModuleOverrides] = useState(false) - const [showPlugins, setShowPlugins] = useState(true) - const [isLoadingSharedBuild, setIsLoadingSharedBuild] = useState(false) - - // Get all enabled plugins - const enabledPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true) - - // Filter targets based on plugin compatibility - // Start with preselected plugin compatibility if present - let compatibleTargets: Set | null = preselectedPlugin?.includes - ? getTargetsCompatibleWithIncludes(preselectedPlugin.includes) - : null - - // Intersect with compatibility of all enabled plugins - if (enabledPlugins.length > 0) { - const pluginRegistry = registryData as Record - const allCompatibleSets: Set[] = [] - - // Get compatible targets for each enabled plugin - for (const pluginId of enabledPlugins) { - const plugin = pluginRegistry[pluginId] - if (plugin?.includes && plugin.includes.length > 0) { - // Plugin has includes - get compatible targets - allCompatibleSets.push(getTargetsCompatibleWithIncludes(plugin.includes)) - } - // If plugin has no includes, it's compatible with all targets (don't add to set) - } - - // If we have compatible sets, find intersection - if (allCompatibleSets.length > 0) { - if (compatibleTargets) { - // Intersect with preselected plugin compatibility - compatibleTargets = new Set( - Array.from(compatibleTargets).filter(target => allCompatibleSets.every(set => set.has(target))) - ) - } else { - // Start with first set, then intersect with others - compatibleTargets = allCompatibleSets[0] - for (let i = 1; i < allCompatibleSets.length; i++) { - compatibleTargets = new Set(Array.from(compatibleTargets).filter(target => allCompatibleSets[i].has(target))) - } - } - } else if (!compatibleTargets) { - // No enabled plugins have includes, so all targets are compatible - // (only if there's no preselected plugin with includes) - compatibleTargets = null - } - } - - const filteredGroupedTargets = compatibleTargets - ? Object.entries(GROUPED_TARGETS).reduce( - (acc, [category, targets]) => { - const filtered = targets.filter(target => { - // Check both normalized and original target ID - const normalizedId = target.id.replace(/[-_]/g, "") - return compatibleTargets.has(target.id) || compatibleTargets.has(normalizedId) - }) - if (filtered.length > 0) { - acc[category] = filtered - } - return acc - }, - {} as Record - ) - : GROUPED_TARGETS - const filteredTargetCategories = Object.keys(filteredGroupedTargets).sort((a, b) => a.localeCompare(b)) - - // Preselect plugin from URL parameter - useEffect(() => { - if (pluginParam && preselectedPlugin && !buildHashParam) { - setPluginConfig({ [pluginParam]: true }) - setShowPlugins(true) - } - }, [pluginParam, preselectedPlugin, buildHashParam]) - - useEffect(() => { - const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES - if (!activeCategory && categories.length > 0) { - setActiveCategory(categories[0]) - } - }, [activeCategory, compatibleTargets, filteredTargetCategories]) - - useEffect(() => { - if (activeCategory) { - const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS - const categoryTargets = targets[activeCategory] || [] - - if (categoryTargets.length === 0) return - - // Check if current selected target is in this category - const isCurrentTargetInCategory = categoryTargets.some(t => t.id === selectedTarget) - - if (!isCurrentTargetInCategory) { - // Try to restore per-brand selection - const savedTargetForCategory = getSavedTargetForCategory(activeCategory) - const isValidSavedTarget = savedTargetForCategory && categoryTargets.some(t => t.id === savedTargetForCategory) - - if (isValidSavedTarget) { - setSelectedTarget(savedTargetForCategory) - // Persist the restored selection - persistTargetSelection(savedTargetForCategory, activeCategory) - } else { - // Default to first target in category and persist it - const firstTarget = categoryTargets[0].id - setSelectedTarget(firstTarget) - persistTargetSelection(firstTarget, activeCategory) - } - } - } - }, [activeCategory, compatibleTargets, filteredGroupedTargets, selectedTarget]) - - useEffect(() => { - if (typeof window === "undefined") return - try { - const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS - const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES - - if (categories.length === 0) return - - // Try to restore the most recent global selection first - const savedTarget = localStorage.getItem(STORAGE_KEY) - if (savedTarget && TARGETS[savedTarget]) { - // Check if saved target exists in filtered targets - const isCompatible = Object.values(targets).some(categoryTargets => - categoryTargets.some(target => target.id === savedTarget) - ) - - if (isCompatible) { - const category = TARGETS[savedTarget].category || "Other" - if (categories.includes(category)) { - setActiveCategory(category) - setSelectedTarget(savedTarget) - persistTargetSelection(savedTarget, category) - return - } - } - } - - // Fall back to per-brand selection for first category - const firstCategory = categories[0] - const categoryTargets = targets[firstCategory] || [] - - if (categoryTargets.length > 0) { - // Try to restore per-brand selection - const savedTargetForCategory = getSavedTargetForCategory(firstCategory) - const isValidSavedTarget = savedTargetForCategory && categoryTargets.some(t => t.id === savedTargetForCategory) - - if (isValidSavedTarget) { - setActiveCategory(firstCategory) - setSelectedTarget(savedTargetForCategory) - persistTargetSelection(savedTargetForCategory, firstCategory) - } else { - // Default to first target in category - const firstTarget = categoryTargets[0].id - setActiveCategory(firstCategory) - setSelectedTarget(firstTarget) - persistTargetSelection(firstTarget, firstCategory) - } - } - } catch (error) { - console.error("Failed to read saved target", error) - } - }, [compatibleTargets, filteredGroupedTargets, filteredTargetCategories]) - - const handleSelectTarget = (targetId: string) => { - // Validate target is compatible with selected plugins - if (compatibleTargets) { - const normalizedId = targetId.replace(/[-_]/g, "") - const isCompatible = compatibleTargets.has(targetId) || compatibleTargets.has(normalizedId) - if (!isCompatible) { - // Target is not compatible, don't allow selection - return - } - } - setSelectedTarget(targetId) - const category = TARGETS[targetId]?.category || "Other" - persistTargetSelection(targetId, category) - if (category && TARGET_CATEGORIES.includes(category)) { - setActiveCategory(category) - } - } - - // Update selected target if it becomes incompatible with selected plugins - useEffect(() => { - if (!selectedTarget || !compatibleTargets) return - - const normalizedId = selectedTarget.replace(/[-_]/g, "") - const isCompatible = compatibleTargets.has(selectedTarget) || compatibleTargets.has(normalizedId) - - if (!isCompatible) { - // Current target is no longer compatible, find a compatible one - const targets = filteredGroupedTargets - const categories = filteredTargetCategories - - if (categories.length > 0) { - // Try to find a compatible target in the current category first - const currentCategory = TARGETS[selectedTarget]?.category - if (currentCategory && targets[currentCategory] && targets[currentCategory].length > 0) { - const savedTargetForCategory = getSavedTargetForCategory(currentCategory) - const isValidSavedTarget = - savedTargetForCategory && targets[currentCategory].some(t => t.id === savedTargetForCategory) - - if (isValidSavedTarget) { - setSelectedTarget(savedTargetForCategory) - persistTargetSelection(savedTargetForCategory, currentCategory) - return - } - - // Default to first target in current category - setSelectedTarget(targets[currentCategory][0].id) - persistTargetSelection(targets[currentCategory][0].id, currentCategory) - return - } - - // Fall back to first compatible target - const firstCategory = categories[0] - const firstTarget = targets[firstCategory]?.[0]?.id - if (firstTarget) { - setSelectedTarget(firstTarget) - setActiveCategory(firstCategory) - persistTargetSelection(firstTarget, firstCategory) - } - } - } - }, [compatibleTargets, filteredGroupedTargets, filteredTargetCategories, selectedTarget]) - - useEffect(() => { - if (typeof window === "undefined" || !selectedTarget) return - try { - if (!window.localStorage.getItem(STORAGE_KEY)) { - window.localStorage.setItem(STORAGE_KEY, selectedTarget) - } - } catch (error) { - console.error("Failed to initialize target storage", error) - } - }, [selectedTarget]) - - // Pre-populate form from shared build - useEffect(() => { - if (!buildHashParam) return - if (sharedBuild === undefined) { - setIsLoadingSharedBuild(true) - return - } - setIsLoadingSharedBuild(false) - - if (!sharedBuild) { - setErrorMessage("Build not found. The shared build may have been deleted.") - toast.error("Build not found", { - description: "The shared build could not be loaded.", - }) - return - } - - const config = sharedBuild.config - - // Set target and category - if (config.target && TARGETS[config.target]) { - setSelectedTarget(config.target) - const category = TARGETS[config.target].category || "Other" - if (TARGET_CATEGORIES.includes(category)) { - setActiveCategory(category) - } - } - - // Set version - if (config.version && (VERSIONS as readonly string[]).includes(config.version)) { - setSelectedVersion(config.version as (typeof VERSIONS)[number]) - } - - // Set module config (already in the correct format) - if (config.modulesExcluded) { - setModuleConfig(config.modulesExcluded) - if (Object.keys(config.modulesExcluded).length > 0) { - setShowModuleOverrides(true) - } - } - - // Set plugin config (convert array to object format) - // Only add explicitly selected plugins, not implicit dependencies - if (config.pluginsEnabled && config.pluginsEnabled.length > 0) { - const allPluginSlugs = config.pluginsEnabled.map(pluginId => { - return pluginId.includes("@") ? pluginId.split("@")[0] : pluginId - }) - - // Determine which plugins are required by others (implicit dependencies) - const requiredByOthers = new Set() - for (const pluginSlug of allPluginSlugs) { - if ( - isRequiredByOther( - pluginSlug, - allPluginSlugs, - registryData as Record }> - ) - ) { - requiredByOthers.add(pluginSlug) - } - } - - // Only add plugins that are NOT required by others (explicitly selected) - const pluginObj: Record = {} - allPluginSlugs.forEach(slug => { - if (slug in registryData && !requiredByOthers.has(slug)) { - pluginObj[slug] = true - } - }) - setPluginConfig(pluginObj) - setShowPlugins(true) - } - }, [buildHashParam, sharedBuild]) - - const moduleCount = Object.keys(moduleConfig).length - const pluginCount = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true).length - const selectedTargetLabel = (selectedTarget && TARGETS[selectedTarget]?.name) || selectedTarget - - const handleToggleModule = (id: string, excluded: boolean) => { - setModuleConfig(prev => { - const next = { ...prev } - if (excluded) { - next[id] = true - } else { - delete next[id] - } - return next - }) - } - - const handleTogglePlugin = (id: string, enabled: boolean) => { - // Get current explicit selections - const explicitPlugins = Object.keys(pluginConfig).filter(pluginId => pluginConfig[pluginId] === true) - - // Check if this plugin is currently an implicit dependency - const implicitDeps = getImplicitDependencies( - explicitPlugins, - registryData as Record }> - ) - - // Check if this plugin is required by another explicitly selected plugin - const isRequired = isRequiredByOther( - id, - explicitPlugins, - registryData as Record }> - ) - - // Don't allow toggling implicit dependencies at all - // (they should be disabled in the UI, but add this as a safeguard) - if (implicitDeps.has(id)) { - return // Can't toggle implicit dependencies - } - - // Don't allow disabling if it's required by another explicitly selected plugin - if (!enabled && isRequired) { - return // Can't disable required plugins - } - - setPluginConfig(prev => { - const next = { ...prev } - if (enabled) { - // Enabling: add to explicit selection (even if it was implicit) - next[id] = true - } else { - // Disabling: remove from explicit selection - delete next[id] - - // Recompute what plugins are still needed after removal - const remainingExplicit = Object.keys(next).filter(pluginId => next[pluginId] === true) - const allStillNeeded = getDependedPlugins( - remainingExplicit, - registryData as Record }> - ) - - // Remove any plugins from config that are no longer needed - // BUT preserve all plugins that are currently explicitly selected (in remainingExplicit) - // This ensures that plugins that were explicitly selected remain explicitly selected - // even if they temporarily became implicit and then un-implicit - for (const pluginId of Object.keys(next)) { - if (next[pluginId] === true && !allStillNeeded.includes(pluginId) && !remainingExplicit.includes(pluginId)) { - // This plugin is no longer needed and is not in the remaining explicit list - // Only remove if it's truly not needed and wasn't explicitly selected - // Note: If a plugin is in `next` with value `true`, it should be in `remainingExplicit` - // So this condition should rarely be true, but we keep it as a safety check - delete next[pluginId] - } - } - - // Ensure all remaining explicitly selected plugins stay in config - // (they should already be there, but this ensures they remain even if they're not needed) - for (const pluginId of remainingExplicit) { - next[pluginId] = true - } - } - return next - }) - } - - const handleFlash = async () => { - if (!selectedTarget) return - setIsFlashing(true) - setErrorMessage(null) - try { - const enabledSlugs = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true) - - // Double-check: filter out any implicit dependencies that might have snuck in - // This ensures we only send explicitly selected plugins to the backend - const implicitDeps = getImplicitDependencies( - enabledSlugs, - registryData as Record }> - ) - const explicitOnlySlugs = enabledSlugs.filter(slug => !implicitDeps.has(slug)) - - const pluginsEnabled = explicitOnlySlugs.map(slug => { - const plugin = (registryData as Record)[slug] - return `${slug}@${plugin.version}` - }) - const result = await ensureBuildFromConfig({ - target: selectedTarget, - version: selectedVersion, - modulesExcluded: moduleConfig, - pluginsEnabled: pluginsEnabled.length > 0 ? pluginsEnabled : undefined, - }) - navigate(`/builds/${result.buildHash}`) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - setErrorMessage("Failed to start build. Please try again.") - toast.error("Failed to start build", { - description: message, - }) - } finally { - setIsFlashing(false) - } - } - - const isFlashDisabled = !selectedTarget || isFlashing - - if (isLoadingSharedBuild) { - return ( -
-
- -

Loading shared build configuration...

-
-
- ) - } - - return ( -
-
-
-
-

- {preselectedPlugin ? "Plugin build" : "Quick build"} -

-

- {preselectedPlugin ? `Build firmware for ${preselectedPlugin.name}` : "Flash a custom firmware version"} -

-

- {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."} -

-
-
- - {preselectedPlugin && ( -
-
- -
- {preselectedPlugin.imageUrl && ( - {`${preselectedPlugin.name} - )} -
-
-

{preselectedPlugin.name}

- {preselectedPlugin.featured && ( - - Featured - - )} -
-

{preselectedPlugin.description}

- {preselectedPlugin.includes && preselectedPlugin.includes.length > 0 && ( -

Compatible with: {preselectedPlugin.includes.join(", ")}

- )} -
-
-
-
- )} - -
-
-
- {(compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES).map(category => { - const isActive = activeCategory === category - return ( - - ) - })} -
- -
-
- {(() => { - const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS - return (activeCategory ? targets[activeCategory] : [])?.map(target => { - const isSelected = selectedTarget === target.id - return ( - - ) - }) - })()} -
-
-
- -
- - -
- -
- - - {showModuleOverrides && ( -
-
-

- Core Modules are officially maintained modules by Meshtastic. They are selectively included or - excluded by default depending on the target device. You can explicitly exclude modules you know you - don't want. -

-
-
- -
-
- {modulesData.modules.map(module => ( - handleToggleModule(module.id, excluded)} - /> - ))} -
-
- )} -
- -
- - - {showPlugins && ( -
-
-

- Plugins are 3rd party add-ons. They are not maintained, endorsed, or supported by Meshtastic. Use at - your own risk. -

-
-
- -
-
- {(() => { - // Get explicitly selected plugins (user-selected) - const explicitPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true) - - // Compute implicit dependencies (dependencies that are not explicitly selected) - const implicitDeps = getImplicitDependencies( - explicitPlugins, - registryData as Record }> - ) - - // Compute all enabled plugins (explicit + implicit) - const allEnabledPlugins = getDependedPlugins( - explicitPlugins, - registryData as Record }> - ) - - return Object.entries(registryData) - .sort(([, pluginA], [, pluginB]) => { - // Featured plugins first - const featuredA = pluginA.featured ?? false - const featuredB = pluginB.featured ?? false - if (featuredA !== featuredB) { - return featuredA ? -1 : 1 - } - // Then alphabetical by name - return pluginA.name.localeCompare(pluginB.name) - }) - .map(([slug, plugin]) => { - // Check if plugin is required by another explicitly selected plugin - const isRequired = isRequiredByOther( - slug, - explicitPlugins, - registryData as Record }> - ) - // Plugin is implicit if it's either: - // 1. Not explicitly selected but is a dependency, OR - // 2. Explicitly selected but required by another explicitly selected plugin - const isImplicit = implicitDeps.has(slug) || (explicitPlugins.includes(slug) && isRequired) - - // Check plugin compatibility with selected target - const pluginIncludes = (plugin as { includes?: string[] }).includes - const pluginExcludes = (plugin as { excludes?: string[] }).excludes - // Legacy support: check for old "architectures" field - const legacyArchitectures = (plugin as { architectures?: string[] }).architectures - const hasCompatibilityConstraints = - (pluginIncludes && pluginIncludes.length > 0) || - (pluginExcludes && pluginExcludes.length > 0) || - (legacyArchitectures && legacyArchitectures.length > 0) - const isCompatible = - hasCompatibilityConstraints && selectedTarget - ? isPluginCompatibleWithTarget( - pluginIncludes || legacyArchitectures, - pluginExcludes, - selectedTarget - ) - : true // If no constraints or no target selected, assume compatible - // Mark as incompatible if plugin has compatibility constraints and target is not compatible - const isIncompatible = !isCompatible && hasCompatibilityConstraints && !!selectedTarget - - // Check if this is the preselected plugin from URL - const isPreselected = pluginParam === slug - - return ( - handleTogglePlugin(slug, enabled)} - disabled={isImplicit || isIncompatible || isPreselected} - enabledLabel={isPreselected ? "Locked" : isImplicit ? "Required" : "Add"} - incompatibleReason={isIncompatible ? "Not compatible with this target" : undefined} - featured={plugin.featured ?? false} - flashCount={pluginFlashCounts[slug] ?? 0} - homepage={plugin.homepage} - version={plugin.version} - repo={plugin.repo} - /> - ) - }) - })()} -
-
- )} -
- -
- - {errorMessage &&

{errorMessage}

} -
-
-
-
- ) -} diff --git a/pages/index/+Page.tsx b/pages/index/+Page.tsx index 42e2a60..41371fb 100644 --- a/pages/index/+Page.tsx +++ b/pages/index/+Page.tsx @@ -149,7 +149,7 @@ export default function LandingPage() { description={customBuildPlugin.description} imageUrl={customBuildPlugin.imageUrl} featured={false} - href="/builds/new" + href="/builds" prominent={true} />
diff --git a/pages/plugins/@slug/+Page.tsx b/pages/plugins/@slug/+Page.tsx index d33d00a..6ae5f05 100644 --- a/pages/plugins/@slug/+Page.tsx +++ b/pages/plugins/@slug/+Page.tsx @@ -132,7 +132,7 @@ export default function PluginPage() { Homepage )} -