mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-03-28 17:42:55 +01:00
348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
import { BuildActions } from "@/components/BuildActions"
|
|
import { BuilderHeader } from "@/components/BuilderHeader"
|
|
import { ModuleConfig } from "@/components/ModuleConfig"
|
|
import { PluginConfig } from "@/components/PluginConfig"
|
|
import { TargetSelector } from "@/components/TargetSelector"
|
|
import { VersionSelector } from "@/components/VersionSelector"
|
|
import { TARGETS } from "@/constants/targets"
|
|
import { VERSIONS } from "@/constants/versions"
|
|
import { api } from "@/convex/_generated/api"
|
|
import { usePluginCompatibility } from "@/hooks/usePluginCompatibility"
|
|
import { useTargetSelection } from "@/hooks/useTargetSelection"
|
|
import { getDependedPlugins, getImplicitDependencies, isRequiredByOther } from "@/lib/utils"
|
|
import registryData from "@/public/registry.json"
|
|
import { useMutation, useQuery } from "convex/react"
|
|
import { Loader2 } from "lucide-react"
|
|
import { useEffect, useState } from "react"
|
|
import { toast } from "sonner"
|
|
import { navigate } from "vike/client/router"
|
|
|
|
interface BuilderProps {
|
|
cloneHash?: string
|
|
pluginParam?: string
|
|
}
|
|
|
|
export default function Builder({ cloneHash, pluginParam }: BuilderProps) {
|
|
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
|
|
const pluginFlashCounts = useQuery(api.plugins.getAll) ?? {}
|
|
const sharedBuild = useQuery(api.builds.getByHash, cloneHash ? { buildHash: cloneHash } : "skip")
|
|
|
|
const preselectedPlugin =
|
|
pluginParam && pluginParam in registryData
|
|
? (
|
|
registryData as Record<
|
|
string,
|
|
{ includes?: string[]; name: string; description: string; imageUrl?: string; featured?: boolean }
|
|
>
|
|
)[pluginParam]
|
|
: null
|
|
|
|
const [selectedVersion, setSelectedVersion] = useState<string>(VERSIONS[0])
|
|
const [moduleConfig, setModuleConfig] = useState<Record<string, boolean>>({})
|
|
const [pluginConfig, setPluginConfig] = useState<Record<string, boolean>>({})
|
|
const [pluginOptionsConfig, setPluginOptionsConfig] = useState<Record<string, Record<string, boolean>>>({})
|
|
const [isFlashing, setIsFlashing] = useState(false)
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
|
const [showModuleOverrides, setShowModuleOverrides] = useState(false)
|
|
const [showPlugins, setShowPlugins] = useState(true)
|
|
const [isLoadingSharedBuild, setIsLoadingSharedBuild] = useState(false)
|
|
|
|
// Get all enabled plugins
|
|
const enabledPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
|
|
|
|
// Compute all enabled plugins (explicit + implicit)
|
|
const allEnabledPlugins = getDependedPlugins(
|
|
enabledPlugins,
|
|
registryData as Record<string, { dependencies?: Record<string, string> }>
|
|
)
|
|
|
|
// Calculate plugin compatibility
|
|
const { compatibleTargets, filteredGroupedTargets, filteredTargetCategories } = usePluginCompatibility(
|
|
enabledPlugins,
|
|
preselectedPlugin
|
|
)
|
|
|
|
// Target selection logic
|
|
const { activeCategory, selectedTarget, setActiveCategory, handleSelectTarget, TARGET_CATEGORIES, GROUPED_TARGETS } =
|
|
useTargetSelection(compatibleTargets, filteredGroupedTargets, filteredTargetCategories)
|
|
|
|
// Preselect plugin from URL parameter
|
|
useEffect(() => {
|
|
if (pluginParam && preselectedPlugin && !cloneHash) {
|
|
setPluginConfig({ [pluginParam]: true })
|
|
setShowPlugins(true)
|
|
}
|
|
}, [pluginParam, preselectedPlugin, cloneHash])
|
|
|
|
// Pre-populate form from shared build
|
|
useEffect(() => {
|
|
if (!cloneHash) 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
|
|
|
|
if (config.target && TARGETS[config.target]) {
|
|
handleSelectTarget(config.target)
|
|
const category = TARGETS[config.target].category || "Other"
|
|
if (TARGET_CATEGORIES.includes(category)) {
|
|
setActiveCategory(category)
|
|
}
|
|
}
|
|
|
|
if (config.version && (VERSIONS as readonly string[]).includes(config.version)) {
|
|
setSelectedVersion(config.version as (typeof VERSIONS)[number])
|
|
}
|
|
|
|
if (config.modulesExcluded) {
|
|
setModuleConfig(config.modulesExcluded)
|
|
if (Object.keys(config.modulesExcluded).length > 0) {
|
|
setShowModuleOverrides(true)
|
|
}
|
|
}
|
|
|
|
if (config.pluginsEnabled && config.pluginsEnabled.length > 0) {
|
|
const allPluginSlugs = config.pluginsEnabled.map(pluginId => {
|
|
return pluginId.includes("@") ? pluginId.split("@")[0] : pluginId
|
|
})
|
|
|
|
const requiredByOthers = new Set<string>()
|
|
for (const pluginSlug of allPluginSlugs) {
|
|
if (
|
|
isRequiredByOther(
|
|
pluginSlug,
|
|
allPluginSlugs,
|
|
registryData as Record<string, { dependencies?: Record<string, string> }>
|
|
)
|
|
) {
|
|
requiredByOthers.add(pluginSlug)
|
|
}
|
|
}
|
|
|
|
const pluginObj: Record<string, boolean> = {}
|
|
allPluginSlugs.forEach(slug => {
|
|
if (slug in registryData && !requiredByOthers.has(slug)) {
|
|
pluginObj[slug] = true
|
|
}
|
|
})
|
|
setPluginConfig(pluginObj)
|
|
setShowPlugins(true)
|
|
}
|
|
|
|
if (config.pluginConfigs) {
|
|
setPluginOptionsConfig(config.pluginConfigs)
|
|
}
|
|
}, [cloneHash, sharedBuild, handleSelectTarget, setActiveCategory, TARGET_CATEGORIES])
|
|
|
|
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) => {
|
|
const explicitPlugins = Object.keys(pluginConfig).filter(pluginId => pluginConfig[pluginId] === true)
|
|
|
|
const implicitDeps = getImplicitDependencies(
|
|
explicitPlugins,
|
|
registryData as Record<string, { dependencies?: Record<string, string> }>
|
|
)
|
|
|
|
const isRequired = isRequiredByOther(
|
|
id,
|
|
explicitPlugins,
|
|
registryData as Record<string, { dependencies?: Record<string, string> }>
|
|
)
|
|
|
|
if (implicitDeps.has(id)) {
|
|
return
|
|
}
|
|
|
|
if (!enabled && isRequired) {
|
|
return
|
|
}
|
|
|
|
setPluginConfig(prev => {
|
|
const next = { ...prev }
|
|
if (enabled) {
|
|
next[id] = true
|
|
} else {
|
|
delete next[id]
|
|
|
|
const remainingExplicit = Object.keys(next).filter(pluginId => next[pluginId] === true)
|
|
const allStillNeeded = getDependedPlugins(
|
|
remainingExplicit,
|
|
registryData as Record<string, { dependencies?: Record<string, string> }>
|
|
)
|
|
|
|
for (const pluginId of Object.keys(next)) {
|
|
if (next[pluginId] === true && !allStillNeeded.includes(pluginId) && !remainingExplicit.includes(pluginId)) {
|
|
delete next[pluginId]
|
|
}
|
|
}
|
|
|
|
for (const pluginId of remainingExplicit) {
|
|
next[pluginId] = true
|
|
}
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
const handleTogglePluginOption = (pluginId: string, optionKey: string, enabled: boolean) => {
|
|
setPluginOptionsConfig(prev => {
|
|
const next = { ...prev }
|
|
if (!next[pluginId]) {
|
|
next[pluginId] = {}
|
|
}
|
|
const pluginOptions = { ...next[pluginId] }
|
|
if (enabled) {
|
|
pluginOptions[optionKey] = true
|
|
} else {
|
|
delete pluginOptions[optionKey]
|
|
}
|
|
if (Object.keys(pluginOptions).length === 0) {
|
|
delete next[pluginId]
|
|
} else {
|
|
next[pluginId] = pluginOptions
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
const handleFlash = async () => {
|
|
if (!selectedTarget) return
|
|
setIsFlashing(true)
|
|
setErrorMessage(null)
|
|
try {
|
|
const enabledSlugs = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
|
|
|
|
const implicitDeps = getImplicitDependencies(
|
|
enabledSlugs,
|
|
registryData as Record<string, { dependencies?: Record<string, string> }>
|
|
)
|
|
const explicitOnlySlugs = enabledSlugs.filter(slug => !implicitDeps.has(slug))
|
|
|
|
const pluginsEnabled = explicitOnlySlugs.map(slug => {
|
|
const plugin = (registryData as Record<string, { version: string }>)[slug]
|
|
return `${slug}@${plugin.version}`
|
|
})
|
|
|
|
// Filter plugin config to only include enabled plugins
|
|
const filteredPluginConfig = Object.keys(pluginOptionsConfig).reduce(
|
|
(acc, pluginId) => {
|
|
if (allEnabledPlugins.includes(pluginId)) {
|
|
acc[pluginId] = pluginOptionsConfig[pluginId]
|
|
}
|
|
return acc
|
|
},
|
|
{} as Record<string, Record<string, boolean>>
|
|
)
|
|
|
|
const result = await ensureBuildFromConfig({
|
|
target: selectedTarget,
|
|
version: selectedVersion,
|
|
modulesExcluded: moduleConfig,
|
|
pluginsEnabled: pluginsEnabled.length > 0 ? pluginsEnabled : undefined,
|
|
pluginConfigs: Object.keys(filteredPluginConfig).length > 0 ? filteredPluginConfig : undefined,
|
|
registryData: registryData,
|
|
})
|
|
navigate(`/builds?hash=${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)
|
|
}
|
|
}
|
|
|
|
if (isLoadingSharedBuild) {
|
|
return (
|
|
<div className="min-h-screen bg-slate-950 text-white p-6 md:p-10 flex items-center justify-center">
|
|
<div className="text-center space-y-4">
|
|
<Loader2 className="w-8 h-8 animate-spin text-cyan-500 mx-auto" />
|
|
<p className="text-slate-400">Loading shared build configuration...</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
|
|
|
|
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">
|
|
<BuilderHeader preselectedPlugin={preselectedPlugin} />
|
|
|
|
<div className="space-y-6 bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
|
|
<TargetSelector
|
|
activeCategory={activeCategory}
|
|
categories={categories}
|
|
groupedTargets={compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS}
|
|
selectedTarget={selectedTarget}
|
|
compatibleTargets={compatibleTargets}
|
|
onCategoryChange={setActiveCategory}
|
|
onTargetSelect={handleSelectTarget}
|
|
/>
|
|
|
|
<VersionSelector selectedVersion={selectedVersion} onVersionChange={setSelectedVersion} />
|
|
|
|
<ModuleConfig
|
|
moduleConfig={moduleConfig}
|
|
showModuleOverrides={showModuleOverrides}
|
|
onToggleShow={() => setShowModuleOverrides(prev => !prev)}
|
|
onToggleModule={handleToggleModule}
|
|
onReset={() => setModuleConfig({})}
|
|
/>
|
|
|
|
<PluginConfig
|
|
pluginConfig={pluginConfig}
|
|
pluginOptionsConfig={pluginOptionsConfig}
|
|
selectedTarget={selectedTarget}
|
|
pluginParam={pluginParam}
|
|
pluginFlashCounts={pluginFlashCounts}
|
|
showPlugins={showPlugins}
|
|
onToggleShow={() => setShowPlugins(prev => !prev)}
|
|
onTogglePlugin={handleTogglePlugin}
|
|
onTogglePluginOption={handleTogglePluginOption}
|
|
onReset={() => {
|
|
setPluginConfig({})
|
|
setPluginOptionsConfig({})
|
|
}}
|
|
/>
|
|
|
|
<BuildActions
|
|
selectedTargetLabel={selectedTargetLabel}
|
|
isFlashing={isFlashing}
|
|
isFlashDisabled={!selectedTarget || isFlashing}
|
|
errorMessage={errorMessage}
|
|
onFlash={handleFlash}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|