mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-03-28 17:42:55 +01:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
663fa54096 | ||
|
|
56d13d3e08 | ||
|
|
7349d81102 | ||
|
|
c75aab0c14 | ||
|
|
fa7eb6ea32 | ||
|
|
bdb848e15f | ||
|
|
cdc4959dca | ||
|
|
c38e6b735c | ||
|
|
de43b1b024 | ||
|
|
f1e5390afc | ||
|
|
b1ac3abf2f | ||
|
|
305ccc64a9 | ||
|
|
ef6196ab7e | ||
|
|
665989a41a | ||
|
|
daee517764 | ||
|
|
9b7661562f | ||
|
|
154bee2e8c | ||
|
|
e931c217cd | ||
|
|
431d91f511 | ||
|
|
b80b42f1c0 | ||
|
|
10ba16a9a4 |
@@ -1,8 +0,0 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
when making changes to a module that has a changelog, add the appropriate unreleased change log entry
|
||||
when making changes to a project that has a changelog, add the appropriate unreleased change log entry
|
||||
|
||||
when preparing an MPM plugin release, don't forget to update @public/registry.json
|
||||
|
||||
changelog uses: major/minor/patch and does NOT use added/changed/removed
|
||||
|
||||
when preparing a release, do not touch git. no tagging, no commits
|
||||
|
||||
5
.github/workflows/deploy.yml
vendored
5
.github/workflows/deploy.yml
vendored
@@ -15,11 +15,6 @@ jobs:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Add meshtastic remote to vendor/firmware
|
||||
run: |
|
||||
git remote add meshtastic git@github.com:meshtastic/firmware.git
|
||||
working-directory: vendor/firmware
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
|
||||
19
.gitmodules
vendored
19
.gitmodules
vendored
@@ -1,13 +1,3 @@
|
||||
[submodule "vendor/web-flasher"]
|
||||
path = vendor/web-flasher
|
||||
url = https://github.com/meshtastic/web-flasher
|
||||
[submodule "vendor/firmware"]
|
||||
path = vendor/firmware
|
||||
url = https://github.com/MeshEnvy/firmware.git
|
||||
branch = meshenvy/module-registry
|
||||
[submodule "vendor/api"]
|
||||
path = vendor/api
|
||||
url = https://github.com/meshtastic/api.git
|
||||
[submodule "vendor/mpm"]
|
||||
path = vendor/mpm
|
||||
url = git@github.com:MeshEnvy/mpm.git
|
||||
@@ -17,9 +7,12 @@
|
||||
[submodule "vendor/lodb"]
|
||||
path = vendor/lodb
|
||||
url = git@github.com:MeshEnvy/lodb.git
|
||||
[submodule "vendor/meshcore"]
|
||||
path = vendor/meshcore
|
||||
url = git@github.com:MeshEnvy/MeshCore.git
|
||||
[submodule "vendor/meshscript"]
|
||||
path = vendor/meshscript
|
||||
url = git@github.com:MeshEnvy/meshscript.git
|
||||
[submodule "vendor/meshcore-flasher"]
|
||||
path = vendor/meshcore-flasher
|
||||
url = git@github.com:meshcore-dev/flasher.meshcore.dev.git
|
||||
[submodule "vendor/lofs"]
|
||||
path = vendor/lofs
|
||||
url = git@github.com:MeshEnvy/lofs.git
|
||||
|
||||
46
CHANGELOG.md
46
CHANGELOG.md
@@ -7,9 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.0] - 2025-12-10
|
||||
|
||||
### Minor
|
||||
|
||||
- Added vendors.json mapping vendors to models and platformio targets
|
||||
- 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
|
||||
- Refactored Builder component into smaller reusable components (BuilderHeader, TargetSelector, VersionSelector, ModuleConfig, PluginConfig, BuildActions)
|
||||
- Extracted target selection and plugin compatibility logic into reusable hooks (useTargetSelection, usePluginCompatibility)
|
||||
|
||||
### 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
|
||||
|
||||
### Minor
|
||||
|
||||
- Added footer with links to legal pages (License, Privacy Policy, Terms of Service)
|
||||
- Added licensing notice page explaining GPLv3 licensing for generated projects
|
||||
- Added privacy policy page detailing data collection and usage
|
||||
- Added terms of service page with usage terms and disclaimers
|
||||
|
||||
### Patch
|
||||
|
||||
- Fix prerendering on dynamic routes
|
||||
|
||||
## [0.2.0] - 2025-12-10
|
||||
|
||||
### Minor
|
||||
|
||||
- Switched OAuth authentication provider from Google to GitHub
|
||||
- Integrated Giscus comments into build pages for discussion threads per build configuration
|
||||
|
||||
### Patch
|
||||
|
||||
- Made build hash label clickable in BuildProgress component to navigate to build detail page
|
||||
|
||||
## [0.1.0] - 2025-12-10
|
||||
|
||||
Initial release
|
||||
|
||||
[Unreleased]: https://github.com/MeshEnvy/mesh-forge/compare/v0.1.0...HEAD
|
||||
[Unreleased]: https://github.com/MeshEnvy/mesh-forge/compare/v0.4.0...HEAD
|
||||
[0.4.0]: https://github.com/MeshEnvy/mesh-forge/releases/tag/v0.4.0
|
||||
[0.3.0]: https://github.com/MeshEnvy/mesh-forge/releases/tag/v0.3.0
|
||||
[0.2.0]: https://github.com/MeshEnvy/mesh-forge/releases/tag/v0.2.0
|
||||
[0.1.0]: https://github.com/MeshEnvy/mesh-forge/releases/tag/v0.1.0
|
||||
|
||||
34
components/BuildActions.tsx
Normal file
34
components/BuildActions.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-2">
|
||||
<Button onClick={onFlash} disabled={isFlashDisabled} className="w-full bg-cyan-600 hover:bg-cyan-700">
|
||||
{isFlashing ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Queuing build...
|
||||
</span>
|
||||
) : (
|
||||
`Flash ${selectedTargetLabel || ""}`.trim() || "Flash"
|
||||
)}
|
||||
</Button>
|
||||
{errorMessage && <p className="text-sm text-red-400">{errorMessage}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { SourceAvailable } from "@/components/SourceAvailable"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Doc } from "@/convex/_generated/dataModel"
|
||||
import { ArtifactType } from "@/convex/builds"
|
||||
import { ArtifactType } from "@/convex/lib/filename"
|
||||
import { useMutation } from "convex/react"
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
@@ -2,12 +2,12 @@ import { BuildDownloadButton } from "@/components/BuildDownloadButton"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { TARGETS } from "@/constants/targets"
|
||||
import type { Doc } from "@/convex/_generated/dataModel"
|
||||
import { ArtifactType } from "@/convex/builds"
|
||||
import { getArtifactFilenameBase } from "@/convex/lib/filename"
|
||||
import { ArtifactType, getArtifactFilenameBase } from "@/convex/lib/filename"
|
||||
import { computeFlagsFromConfig } from "@/convex/lib/flags"
|
||||
import modulesData from "@/convex/modules.json"
|
||||
import { getImplicitDependencies, humanizeStatus } from "@/lib/utils"
|
||||
import registryData from "@/public/registry.json"
|
||||
import { AlertCircle, CheckCircle, Copy, Loader2, Share2, X, XCircle } from "lucide-react"
|
||||
import { CheckCircle, Copy, Loader2, Share2, X, XCircle } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { navigate } from "vike/client/router"
|
||||
@@ -49,7 +49,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?hash=${build.buildHash}`
|
||||
|
||||
const handleShare = async () => {
|
||||
try {
|
||||
@@ -65,7 +65,10 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
|
||||
}
|
||||
|
||||
const generateBashCommand = (): string => {
|
||||
const flags = computeFlagsFromConfig(build.config)
|
||||
const flags = computeFlagsFromConfig(
|
||||
build.config,
|
||||
registryData as Record<string, { configOptions?: Record<string, { define: string }> }>
|
||||
)
|
||||
const target = build.config.target
|
||||
const version = build.config.version
|
||||
const plugins = build.config.pluginsEnabled || []
|
||||
@@ -103,9 +106,8 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
|
||||
}
|
||||
|
||||
// Set build flags and build
|
||||
if (flags) {
|
||||
commands.push(`export PLATFORMIO_BUILD_FLAGS="${flags}"`)
|
||||
}
|
||||
// Always export PLATFORMIO_BUILD_FLAGS (even if empty) so users can see what was used
|
||||
commands.push(`export PLATFORMIO_BUILD_FLAGS="${flags || ""}"`)
|
||||
commands.push(`pio run -e ${target}`)
|
||||
|
||||
return commands.join("\n")
|
||||
@@ -130,69 +132,6 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
|
||||
}
|
||||
}
|
||||
|
||||
// Compute build flags from config (same logic as computeFlagsFromConfig in convex/builds.ts)
|
||||
const computeFlagsFromConfig = (config: typeof build.config): string => {
|
||||
return Object.keys(config.modulesExcluded)
|
||||
.sort()
|
||||
.filter(module => config.modulesExcluded[module])
|
||||
.map((moduleExcludedName: string) => `-D${moduleExcludedName}=1`)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
// Generate GitHub discussion URL with prefilled body
|
||||
const generateDiscussionUrl = (): string => {
|
||||
const flags = computeFlagsFromConfig(build.config)
|
||||
const plugins = build.config.pluginsEnabled?.join(", ") || "(none)"
|
||||
const timestamp = new Date(build.startedAt).toISOString()
|
||||
const githubRunLink = githubActionUrl ? `[View run](${githubActionUrl})` : "(not available)"
|
||||
const buildPageUrl = `${window.location.origin}/builds/${build.buildHash}`
|
||||
|
||||
// Format plugins as +plugin@version
|
||||
const formattedPlugins =
|
||||
build.config.pluginsEnabled
|
||||
?.map(plugin => {
|
||||
// Plugin might be "slug@version" or just "slug"
|
||||
return plugin.includes("@") ? `+${plugin}` : `+${plugin}`
|
||||
})
|
||||
.join(" ") || ""
|
||||
|
||||
const bracketContent = [
|
||||
build.config.target,
|
||||
`v${build.config.version}`,
|
||||
...(formattedPlugins ? [formattedPlugins] : []),
|
||||
].join(" ")
|
||||
|
||||
const discussionTitle = `Build ${build.status === "failure" ? "Failed" : "Issue"}: ${targetLabel} [${bracketContent}]`
|
||||
|
||||
const discussionBody = `## Build ${build.status === "failure" ? "Failed" : "Information"}
|
||||
|
||||
**Build Hash**: \`${build.buildHash}\`
|
||||
**Target Board**: ${build.config.target}
|
||||
**Firmware Version**: ${build.config.version}
|
||||
**Build Flags**: ${flags || "(none)"}
|
||||
**Plugins**: ${plugins}
|
||||
**Build Timestamp**: ${timestamp}
|
||||
|
||||
**Build Page**: [View build page](${buildPageUrl})
|
||||
**GitHub Run**: ${githubRunLink}
|
||||
|
||||
## Additional Information
|
||||
(Please add any additional details about the issue here)`
|
||||
|
||||
const baseUrl = "https://github.com/MeshEnvy/mesh-forge/discussions/new"
|
||||
const params = new URLSearchParams({
|
||||
category: "q-a",
|
||||
title: discussionTitle,
|
||||
body: discussionBody,
|
||||
})
|
||||
|
||||
return `${baseUrl}?${params.toString()}`
|
||||
}
|
||||
|
||||
const handleReportIssue = () => {
|
||||
window.open(generateDiscussionUrl(), "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
const handleRetry = async () => {
|
||||
if (!build?._id || !onRetry) return
|
||||
try {
|
||||
@@ -270,7 +209,16 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-2 flex items-center gap-2">
|
||||
{getStatusIcon()}
|
||||
{targetLabel}
|
||||
<a
|
||||
href={`/builds?hash=${build.buildHash}`}
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
navigate(`/builds?hash=${build.buildHash}`)
|
||||
}}
|
||||
className="hover:text-cyan-400 transition-colors"
|
||||
>
|
||||
{targetLabel}
|
||||
</a>
|
||||
{status !== "success" && status !== "failure" && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -302,12 +250,8 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
|
||||
</div>
|
||||
{showActions && (
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleReportIssue} variant="outline" className="border-slate-600 hover:bg-slate-800">
|
||||
<AlertCircle className="w-4 h-4 mr-2" />
|
||||
Support
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate(`/builds/new/${build.buildHash}`)}
|
||||
onClick={() => navigate(`/builds?clone=${build.buildHash}`)}
|
||||
variant="outline"
|
||||
className="border-slate-600 hover:bg-slate-800"
|
||||
aria-label="Clone"
|
||||
|
||||
347
components/Builder.tsx
Normal file
347
components/Builder.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
64
components/BuilderHeader.tsx
Normal file
64
components/BuilderHeader.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { CheckCircle2 } from "lucide-react"
|
||||
|
||||
interface BuilderHeaderProps {
|
||||
preselectedPlugin?: {
|
||||
name: string
|
||||
description: string
|
||||
imageUrl?: string
|
||||
featured?: boolean
|
||||
includes?: string[]
|
||||
} | null
|
||||
}
|
||||
|
||||
export function BuilderHeader({ preselectedPlugin }: BuilderHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-wider text-slate-500">
|
||||
{preselectedPlugin ? "Plugin build" : "Quick build"}
|
||||
</p>
|
||||
<h1 className="text-4xl font-bold mt-1">
|
||||
{preselectedPlugin ? `Build firmware for ${preselectedPlugin.name}` : "Flash a custom firmware version"}
|
||||
</h1>
|
||||
<p className="text-slate-400 mt-2 max-w-2xl">
|
||||
{preselectedPlugin
|
||||
? `Select a compatible Meshtastic target and configure your build for ${preselectedPlugin.name}. We'll send you to the build status page as soon as it starts.`
|
||||
: "Choose your Meshtastic target, adjust optional modules, and queue a new build instantly. We'll send you to the build status page as soon as it starts."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{preselectedPlugin && (
|
||||
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-400 shrink-0 mt-1" />
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
{preselectedPlugin.imageUrl && (
|
||||
<img
|
||||
src={preselectedPlugin.imageUrl}
|
||||
alt={`${preselectedPlugin.name} logo`}
|
||||
className="w-16 h-16 rounded-lg object-contain shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-2xl font-bold">{preselectedPlugin.name}</h2>
|
||||
{preselectedPlugin.featured && (
|
||||
<span className="px-2 py-1 text-xs font-medium text-green-400 bg-green-400/10 border border-green-400/20 rounded">
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-slate-400 mb-3">{preselectedPlugin.description}</p>
|
||||
{preselectedPlugin.includes && preselectedPlugin.includes.length > 0 && (
|
||||
<p className="text-sm text-slate-500">Compatible with: {preselectedPlugin.includes.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
21
components/Footer.tsx
Normal file
21
components/Footer.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-slate-800 bg-slate-950 mt-auto">
|
||||
<div className="max-w-7xl mx-auto px-8 py-6">
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-400">
|
||||
<a href="/license" className="hover:text-white transition-colors">
|
||||
License
|
||||
</a>
|
||||
<span className="text-slate-600">•</span>
|
||||
<a href="/privacy" className="hover:text-white transition-colors">
|
||||
Privacy Policy
|
||||
</a>
|
||||
<span className="text-slate-600">•</span>
|
||||
<a href="/terms" className="hover:text-white transition-colors">
|
||||
Terms of Service
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
55
components/GiscusComments.tsx
Normal file
55
components/GiscusComments.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Doc } from "@/convex/_generated/dataModel"
|
||||
import { getBuildIdentifier } from "@/convex/lib/filename"
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
interface GiscusCommentsProps {
|
||||
build: Doc<"builds">
|
||||
}
|
||||
|
||||
export function GiscusComments({ build }: GiscusCommentsProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
// Compute the data-term using build identifier
|
||||
const term = getBuildIdentifier(
|
||||
build.config.version,
|
||||
build.config.target,
|
||||
build.buildHash,
|
||||
build.config.pluginsEnabled
|
||||
)
|
||||
|
||||
// Clear any existing script
|
||||
containerRef.current.innerHTML = ""
|
||||
|
||||
// Create script element
|
||||
const script = document.createElement("script")
|
||||
script.src = "https://giscus.app/client.js"
|
||||
script.setAttribute("data-repo", "MeshEnvy/mesh-forge")
|
||||
script.setAttribute("data-repo-id", "R_kgDOQa32VQ")
|
||||
script.setAttribute("data-category", "Builds")
|
||||
script.setAttribute("data-category-id", "DIC_kwDOQa32Vc4CznuV")
|
||||
script.setAttribute("data-mapping", "specific")
|
||||
script.setAttribute("data-term", term)
|
||||
script.setAttribute("data-strict", "1")
|
||||
script.setAttribute("data-reactions-enabled", "1")
|
||||
script.setAttribute("data-emit-metadata", "0")
|
||||
script.setAttribute("data-input-position", "bottom")
|
||||
script.setAttribute("data-theme", "dark_tritanopia")
|
||||
script.setAttribute("data-lang", "en")
|
||||
script.crossOrigin = "anonymous"
|
||||
script.async = true
|
||||
|
||||
containerRef.current.appendChild(script)
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (containerRef.current && containerRef.current.contains(script)) {
|
||||
containerRef.current.removeChild(script)
|
||||
}
|
||||
}
|
||||
}, [build])
|
||||
|
||||
return <div ref={containerRef} className="mt-6" />
|
||||
}
|
||||
74
components/ModuleConfig.tsx
Normal file
74
components/ModuleConfig.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { ModuleToggle } from "@/components/ModuleToggle"
|
||||
import modulesData from "@/convex/modules.json"
|
||||
import { ChevronDown, ChevronRight } from "lucide-react"
|
||||
|
||||
interface ModuleConfigProps {
|
||||
moduleConfig: Record<string, boolean>
|
||||
showModuleOverrides: boolean
|
||||
onToggleShow: () => void
|
||||
onToggleModule: (id: string, excluded: boolean) => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
export function ModuleConfig({
|
||||
moduleConfig,
|
||||
showModuleOverrides,
|
||||
onToggleShow,
|
||||
onToggleModule,
|
||||
onReset,
|
||||
}: ModuleConfigProps) {
|
||||
const moduleCount = Object.keys(moduleConfig).length
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
|
||||
<button type="button" onClick={onToggleShow} className="w-full flex items-center justify-between text-left">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Core Modules</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{moduleCount === 0
|
||||
? "Using default modules for this target."
|
||||
: `${moduleCount} module${moduleCount === 1 ? "" : "s"} excluded.`}
|
||||
</p>
|
||||
</div>
|
||||
{showModuleOverrides ? (
|
||||
<ChevronDown className="w-4 h-4 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showModuleOverrides && (
|
||||
<div className="space-y-2 pr-1">
|
||||
<div className="rounded-lg bg-slate-800/50 border border-slate-700 p-3">
|
||||
<p className="text-xs text-slate-400 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-slate-400 hover:text-white underline"
|
||||
onClick={onReset}
|
||||
disabled={moduleCount === 0}
|
||||
>
|
||||
Reset overrides
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{modulesData.modules.map(module => (
|
||||
<ModuleToggle
|
||||
key={module.id}
|
||||
id={module.id}
|
||||
name={module.name}
|
||||
description={module.description}
|
||||
isExcluded={moduleConfig[module.id] === true}
|
||||
onToggle={excluded => onToggleModule(module.id, excluded)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export default function Navbar() {
|
||||
/>
|
||||
<Unauthenticated>
|
||||
<Button
|
||||
onClick={() => signIn("google", { redirectTo: window.location.href })}
|
||||
onClick={() => signIn("github", { redirectTo: window.location.href })}
|
||||
className="bg-cyan-600 hover:bg-cyan-700"
|
||||
>
|
||||
Sign In
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Download, Star, Zap } from "lucide-react"
|
||||
import { navigate } from "vike/client/router"
|
||||
@@ -55,6 +56,10 @@ interface PluginCardLinkToggleProps extends PluginCardBaseProps {
|
||||
onToggle: (enabled: boolean) => void
|
||||
disabled?: boolean
|
||||
enabledLabel?: string
|
||||
diagnostics?: {
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}
|
||||
}
|
||||
|
||||
type PluginCardProps = PluginCardToggleProps | PluginCardLinkProps | PluginCardLinkToggleProps
|
||||
@@ -176,109 +181,168 @@ export function PluginCard(props: PluginCardProps) {
|
||||
{isIncompatible && incompatibleReason && (
|
||||
<p className="text-xs text-red-400 mt-1 font-medium">{incompatibleReason}</p>
|
||||
)}
|
||||
{/* Diagnostics checkbox - only show for link-toggle variant when enabled */}
|
||||
{isLinkToggle && props.isEnabled && props.diagnostics && (
|
||||
<div className="mt-2">
|
||||
<label className="flex items-start gap-2 cursor-pointer group" htmlFor={`${id}-diagnostics`}>
|
||||
<Checkbox
|
||||
id={`${id}-diagnostics`}
|
||||
checked={props.diagnostics.checked}
|
||||
onCheckedChange={props.diagnostics.onCheckedChange}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-slate-300 group-hover:text-white transition-colors">
|
||||
Include Diagnostics
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">Enable diagnostic logging for this plugin</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</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
|
||||
{/* Footer with metadata and toggle */}
|
||||
<div className="flex items-center justify-between gap-3 mt-auto pt-2 border-t border-slate-700/50">
|
||||
{/* 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"
|
||||
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">
|
||||
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) &&
|
||||
(isLink ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
window.open(homepage, "_blank", "noopener,noreferrer")
|
||||
}}
|
||||
className="hover:opacity-80 transition-opacity"
|
||||
aria-label="Homepage"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
) : (
|
||||
<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 &&
|
||||
(isLink ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
window.open(repo, "_blank", "noopener,noreferrer")
|
||||
}}
|
||||
className="hover:opacity-80 transition-opacity"
|
||||
aria-label="GitHub repository"
|
||||
>
|
||||
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<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>
|
||||
{/* Toggle switch or Build Now button */}
|
||||
{isLinkToggle ? (
|
||||
<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"}
|
||||
/>
|
||||
) : isLink ? (
|
||||
<button
|
||||
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"
|
||||
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 shrink-0"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const baseClassName = `relative flex ${isToggle ? "items-start gap-4" : "flex-col gap-3"} p-4 rounded-lg border-2 transition-colors h-full ${
|
||||
const baseClassName = `relative flex ${isToggle ? "items-start gap-4" : "flex-col"} p-4 rounded-lg border-2 transition-colors h-full ${
|
||||
isIncompatible
|
||||
? "border-slate-800 bg-slate-900/30 opacity-60 cursor-not-allowed"
|
||||
: prominent
|
||||
@@ -287,7 +351,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 (
|
||||
<a href={href} className={baseClassName}>
|
||||
{cardContent}
|
||||
|
||||
176
components/PluginConfig.tsx
Normal file
176
components/PluginConfig.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
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<string, boolean>
|
||||
pluginOptionsConfig: Record<string, Record<string, boolean>>
|
||||
selectedTarget: string
|
||||
pluginParam?: string
|
||||
pluginFlashCounts: Record<string, number>
|
||||
showPlugins: boolean
|
||||
onToggleShow: () => void
|
||||
onTogglePlugin: (id: string, enabled: boolean) => void
|
||||
onTogglePluginOption: (pluginId: string, optionKey: string, enabled: boolean) => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
export function PluginConfig({
|
||||
pluginConfig,
|
||||
pluginOptionsConfig,
|
||||
selectedTarget,
|
||||
pluginParam,
|
||||
pluginFlashCounts,
|
||||
showPlugins,
|
||||
onToggleShow,
|
||||
onTogglePlugin,
|
||||
onTogglePluginOption,
|
||||
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<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
|
||||
// Compute all enabled plugins (explicit + implicit)
|
||||
const allEnabledPlugins = getDependedPlugins(
|
||||
explicitPlugins,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
|
||||
<button type="button" onClick={onToggleShow} className="w-full flex items-center justify-between text-left">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Plugins</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{pluginCount === 0
|
||||
? "No plugins enabled."
|
||||
: `${pluginCount} plugin${pluginCount === 1 ? "" : "s"} enabled.`}
|
||||
</p>
|
||||
</div>
|
||||
{showPlugins ? (
|
||||
<ChevronDown className="w-4 h-4 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showPlugins && (
|
||||
<div className="space-y-2 pr-1">
|
||||
<div className="rounded-lg bg-slate-800/50 border border-slate-700 p-3">
|
||||
<p className="text-xs text-slate-400 leading-relaxed">
|
||||
Plugins are 3rd party add-ons. They are not maintained, endorsed, or supported by Meshtastic. Use at your
|
||||
own risk.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-slate-400 hover:text-white underline"
|
||||
onClick={onReset}
|
||||
disabled={pluginCount === 0}
|
||||
>
|
||||
Reset plugins
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-2" key={`plugins-${selectedTarget}`}>
|
||||
{Object.entries(registryData)
|
||||
.sort(([, pluginA], [, pluginB]) => {
|
||||
// Featured plugins first
|
||||
const featuredA = (pluginA as { featured?: boolean }).featured ?? false
|
||||
const featuredB = (pluginB as { featured?: boolean }).featured ?? false
|
||||
if (featuredA !== featuredB) {
|
||||
return featuredA ? -1 : 1
|
||||
}
|
||||
// Then alphabetical by name
|
||||
return (pluginA as { name: string }).name.localeCompare((pluginB as { name: string }).name)
|
||||
})
|
||||
.map(([slug, plugin]) => {
|
||||
// Check if plugin is required by another explicitly selected plugin
|
||||
const isRequired = isRequiredByOther(
|
||||
slug,
|
||||
explicitPlugins,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
// 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
|
||||
|
||||
const pluginRegistry = plugin as {
|
||||
featured?: boolean
|
||||
}
|
||||
const isPluginEnabled = allEnabledPlugins.includes(slug)
|
||||
const pluginOptions = pluginOptionsConfig[slug] ?? {}
|
||||
|
||||
return (
|
||||
<PluginCard
|
||||
key={`${slug}-${selectedTarget}`}
|
||||
variant="link-toggle"
|
||||
id={slug}
|
||||
name={plugin.name}
|
||||
description={plugin.description}
|
||||
imageUrl={plugin.imageUrl}
|
||||
isEnabled={isPluginEnabled}
|
||||
onToggle={enabled => onTogglePlugin(slug, enabled)}
|
||||
disabled={isImplicit || isIncompatible || isPreselected}
|
||||
enabledLabel={isPreselected ? "Locked" : isImplicit ? "Required" : "Add"}
|
||||
incompatibleReason={isIncompatible ? "Not compatible with this target" : undefined}
|
||||
featured={pluginRegistry.featured ?? false}
|
||||
flashCount={pluginFlashCounts[slug] ?? 0}
|
||||
homepage={plugin.homepage}
|
||||
version={plugin.version}
|
||||
repo={plugin.repo}
|
||||
diagnostics={
|
||||
isPluginEnabled
|
||||
? {
|
||||
checked: pluginOptions.diagnostics ?? false,
|
||||
onCheckedChange: checked => onTogglePluginOption(slug, "diagnostics", checked === true),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
components/TargetSelector.tsx
Normal file
76
components/TargetSelector.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { TARGETS } from "@/constants/targets"
|
||||
|
||||
type TargetGroup = (typeof TARGETS)[string] & { id: string }
|
||||
|
||||
interface TargetSelectorProps {
|
||||
activeCategory: string
|
||||
categories: string[]
|
||||
groupedTargets: Record<string, TargetGroup[]>
|
||||
selectedTarget: string
|
||||
compatibleTargets: Set<string> | 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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map(category => {
|
||||
const isActive = activeCategory === category
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() => onCategoryChange(category)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
isActive ? "bg-blue-600 text-white" : "bg-slate-800 text-slate-300 hover:bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950/60 p-4 rounded-lg border border-slate-800/60">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{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 (
|
||||
<button
|
||||
key={target.id}
|
||||
type="button"
|
||||
onClick={() => isCompatible && onTargetSelect(target.id)}
|
||||
disabled={!isCompatible}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
isSelected
|
||||
? "bg-cyan-600 text-white"
|
||||
: isCompatible
|
||||
? "bg-slate-800 text-slate-300 hover:bg-slate-700"
|
||||
: "bg-slate-800/50 text-slate-500 cursor-not-allowed opacity-50"
|
||||
}`}
|
||||
>
|
||||
{target.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
components/VersionSelector.tsx
Normal file
28
components/VersionSelector.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { VERSIONS } from "@/constants/versions"
|
||||
|
||||
interface VersionSelectorProps {
|
||||
selectedVersion: string
|
||||
onVersionChange: (version: string) => void
|
||||
}
|
||||
|
||||
export function VersionSelector({ selectedVersion, onVersionChange }: VersionSelectorProps) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="build-version" className="block text-sm font-medium mb-2">
|
||||
Firmware version
|
||||
</label>
|
||||
<select
|
||||
id="build-version"
|
||||
value={selectedVersion}
|
||||
onChange={event => onVersionChange(event.target.value)}
|
||||
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(version => (
|
||||
<option key={version} value={version}>
|
||||
{version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,210 +1,210 @@
|
||||
{
|
||||
"betafpv2400txmicro": "esp32",
|
||||
"betafpv900txnano": "esp32",
|
||||
"betafpv_2400_tx_micro": "esp32",
|
||||
"betafpv_900_tx_nano": "esp32",
|
||||
"chatter2": "esp32",
|
||||
"9m2ibraprsloratracker": "esp32",
|
||||
"meshtasticdrdev": "esp32",
|
||||
"9m2ibr_aprs_lora_tracker": "esp32",
|
||||
"meshtastic-dr-dev": "esp32",
|
||||
"hydra": "esp32",
|
||||
"meshtasticdiyv1": "esp32",
|
||||
"meshtasticdiyv11": "esp32",
|
||||
"hackerboxesesp32io": "esp32",
|
||||
"heltecv1": "esp32",
|
||||
"heltecv20": "esp32",
|
||||
"heltecv21": "esp32",
|
||||
"heltecwirelessbridge": "esp32",
|
||||
"heltecwslv21": "esp32",
|
||||
"m5stackcore": "esp32",
|
||||
"m5stackcoreink": "esp32",
|
||||
"nanog1": "esp32",
|
||||
"nanog1explorer": "esp32",
|
||||
"radiomaster900bandit": "esp32",
|
||||
"radiomaster900banditmicro": "esp32",
|
||||
"radiomaster900banditnano": "esp32",
|
||||
"meshtastic-diy-v1": "esp32",
|
||||
"meshtastic-diy-v1_1": "esp32",
|
||||
"hackerboxes-esp32-io": "esp32",
|
||||
"heltec-v1": "esp32",
|
||||
"heltec-v2_0": "esp32",
|
||||
"heltec-v2_1": "esp32",
|
||||
"heltec-wireless-bridge": "esp32",
|
||||
"heltec-wsl-v2_1": "esp32",
|
||||
"m5stack-core": "esp32",
|
||||
"m5stack-coreink": "esp32",
|
||||
"nano-g1": "esp32",
|
||||
"nano-g1-explorer": "esp32",
|
||||
"radiomaster_900_bandit": "esp32",
|
||||
"radiomaster_900_bandit_micro": "esp32",
|
||||
"radiomaster_900_bandit_nano": "esp32",
|
||||
"rak11200": "esp32",
|
||||
"stationg1": "esp32",
|
||||
"station-g1": "esp32",
|
||||
"tbeam": "esp32",
|
||||
"tbeamdisplayshield": "tbeam",
|
||||
"tbeam07": "esp32",
|
||||
"tlorav1": "esp32",
|
||||
"tlorav13": "esp32",
|
||||
"tlorav2": "esp32",
|
||||
"tlorav2116": "esp32",
|
||||
"sugarcube": "tlorav2116",
|
||||
"tlorav2116tcxo": "esp32",
|
||||
"tlorav2118": "esp32",
|
||||
"tlorav330tcxo": "esp32",
|
||||
"tbeam-displayshield": "tbeam",
|
||||
"tbeam0_7": "esp32",
|
||||
"tlora-v1": "esp32",
|
||||
"tlora_v1_3": "esp32",
|
||||
"tlora-v2": "esp32",
|
||||
"tlora-v2-1-1_6": "esp32",
|
||||
"sugarcube": "tlora-v2-1-1_6",
|
||||
"tlora-v2-1-1_6-tcxo": "esp32",
|
||||
"tlora-v2-1-1_8": "esp32",
|
||||
"tlora-v3-3-0-tcxo": "esp32",
|
||||
"trackerd": "esp32",
|
||||
"wiphone": "esp32",
|
||||
"aic3": "esp32c3",
|
||||
"esp32c3supermini": "esp32c3",
|
||||
"esp32c3base": "esp32",
|
||||
"hackerboxesesp32c3oled": "esp32c3",
|
||||
"heltecht62esp32c3sx1262": "esp32c3",
|
||||
"heltechru3601": "esp32c3",
|
||||
"m5stackstampc3": "esp32c3",
|
||||
"esp32c6base": "esp32",
|
||||
"m5stackunitc6l": "esp32c6",
|
||||
"tlorac6": "esp32c6",
|
||||
"esp32s2base": "esp32",
|
||||
"nuggets2lora": "esp32s2",
|
||||
"CDEBYTEEoRaS3": "esp32s3",
|
||||
"EBYTEESP32S3": "esp32s3",
|
||||
"thinknodem2": "esp32s3",
|
||||
"thinknodem5": "esp32s3",
|
||||
"bpipicowesp32s3": "esp32s3",
|
||||
"crowpanelesp32s35epaper": "esp32s3",
|
||||
"crowpanelesp32s34epaper": "esp32s3",
|
||||
"crowpanelesp32s32epaper": "esp32s3",
|
||||
"myesp32s3diyeink": "esp32s3",
|
||||
"myesp32s3diyoled": "esp32s3",
|
||||
"tenergys3e22": "esp32s3",
|
||||
"dreamcatcher2206": "esp32s3",
|
||||
"crowpanelbase": "crowpanel",
|
||||
"elecrowadv2428tft": "crowpanelsmallesp32s3base",
|
||||
"elecrowadv35tft": "crowpanelsmallesp32s3base",
|
||||
"elecrowadv1435070tft": "crowpanellargeesp32s3base",
|
||||
"ESP32S3Pico": "esp32s3",
|
||||
"esp32s3base": "esp32",
|
||||
"hackadaycommunicator": "esp32s3",
|
||||
"helteccapsulesensorv3": "esp32s3",
|
||||
"heltecsensorhub": "esp32s3",
|
||||
"heltecv3": "esp32s3",
|
||||
"heltecv4base": "esp32s3",
|
||||
"heltecv4": "heltecv4base",
|
||||
"heltecv4tft": "heltecv4base",
|
||||
"heltecvisionmastere213": "esp32s3",
|
||||
"heltecvisionmastere213inkhud": "esp32s3",
|
||||
"heltecvisionmastere290": "esp32s3",
|
||||
"heltecvisionmastere290inkhud": "esp32s3",
|
||||
"heltecvisionmastert190": "esp32s3",
|
||||
"heltecwirelesspaper": "esp32s3",
|
||||
"heltecwirelesspaperinkhud": "esp32s3",
|
||||
"heltecwirelesspaperv10": "esp32s3",
|
||||
"heltecwirelesstracker": "esp32s3",
|
||||
"heltecwirelesstrackerV10": "esp32s3",
|
||||
"heltecwirelesstrackerv2": "esp32s3",
|
||||
"heltecwslv3": "esp32s3",
|
||||
"ai-c3": "esp32c3",
|
||||
"esp32c3_super_mini": "esp32c3",
|
||||
"esp32c3_base": "esp32",
|
||||
"hackerboxes-esp32c3-oled": "esp32c3",
|
||||
"heltec-ht62-esp32c3-sx1262": "esp32c3",
|
||||
"heltec-hru-3601": "esp32c3",
|
||||
"m5stack-stamp-c3": "esp32c3",
|
||||
"esp32c6_base": "esp32",
|
||||
"m5stack-unitc6l": "esp32c6",
|
||||
"tlora-c6": "esp32c6",
|
||||
"esp32s2_base": "esp32",
|
||||
"nugget-s2-lora": "esp32s2",
|
||||
"CDEBYTE_EoRa-S3": "esp32s3",
|
||||
"EBYTE_ESP32-S3": "esp32s3",
|
||||
"thinknode_m2": "esp32s3",
|
||||
"thinknode_m5": "esp32s3",
|
||||
"bpi_picow_esp32_s3": "esp32s3",
|
||||
"crowpanel-esp32s3-5-epaper": "esp32s3",
|
||||
"crowpanel-esp32s3-4-epaper": "esp32s3",
|
||||
"crowpanel-esp32s3-2-epaper": "esp32s3",
|
||||
"my-esp32s3-diy-eink": "esp32s3",
|
||||
"my-esp32s3-diy-oled": "esp32s3",
|
||||
"t-energy-s3_e22": "esp32s3",
|
||||
"dreamcatcher-2206": "esp32s3",
|
||||
"crowpanel_base": "crowpanel",
|
||||
"elecrow-adv-24-28-tft": "crowpanel_small_esp32s3_base",
|
||||
"elecrow-adv-35-tft": "crowpanel_small_esp32s3_base",
|
||||
"elecrow-adv1-43-50-70-tft": "crowpanel_large_esp32s3_base",
|
||||
"ESP32-S3-Pico": "esp32s3",
|
||||
"esp32s3_base": "esp32",
|
||||
"hackaday-communicator": "esp32s3",
|
||||
"heltec_capsule_sensor_v3": "esp32s3",
|
||||
"heltec_sensor_hub": "esp32s3",
|
||||
"heltec-v3": "esp32s3",
|
||||
"heltec_v4_base": "esp32s3",
|
||||
"heltec-v4": "heltec_v4_base",
|
||||
"heltec-v4-tft": "heltec_v4_base",
|
||||
"heltec-vision-master-e213": "esp32s3",
|
||||
"heltec-vision-master-e213-inkhud": "esp32s3",
|
||||
"heltec-vision-master-e290": "esp32s3",
|
||||
"heltec-vision-master-e290-inkhud": "esp32s3",
|
||||
"heltec-vision-master-t190": "esp32s3",
|
||||
"heltec-wireless-paper": "esp32s3",
|
||||
"heltec-wireless-paper-inkhud": "esp32s3",
|
||||
"heltec-wireless-paper-v1_0": "esp32s3",
|
||||
"heltec-wireless-tracker": "esp32s3",
|
||||
"heltec-wireless-tracker-V1-0": "esp32s3",
|
||||
"heltec-wireless-tracker-v2": "esp32s3",
|
||||
"heltec-wsl-v3": "esp32s3",
|
||||
"icarus": "esp32s3",
|
||||
"link32s3v1": "esp32s3",
|
||||
"m5stackcores3": "esp32s3",
|
||||
"meshtabbase": "esp32s3",
|
||||
"meshtab32TNresistive": "meshtabbase",
|
||||
"meshtab32IPSresistive": "meshtabbase",
|
||||
"meshtab35IPSresistive": "meshtabbase",
|
||||
"meshtab35TNresistive": "meshtabbase",
|
||||
"meshtab32IPScapacitive": "meshtabbase",
|
||||
"meshtab35IPScapacitive": "meshtabbase",
|
||||
"meshtab40IPScapacitive": "meshtabbase",
|
||||
"nibbleesp32": "esp32s3",
|
||||
"nuggets3lora": "esp32s3",
|
||||
"picomputers3": "esp32s3",
|
||||
"picomputers3tft": "picomputers3",
|
||||
"link32-s3-v1": "esp32s3",
|
||||
"m5stack-cores3": "esp32s3",
|
||||
"mesh_tab_base": "esp32s3",
|
||||
"mesh-tab-3-2-TN-resistive": "mesh_tab_base",
|
||||
"mesh-tab-3-2-IPS-resistive": "mesh_tab_base",
|
||||
"mesh-tab-3-5-IPS-resistive": "mesh_tab_base",
|
||||
"mesh-tab-3-5-TN-resistive": "mesh_tab_base",
|
||||
"mesh-tab-3-2-IPS-capacitive": "mesh_tab_base",
|
||||
"mesh-tab-3-5-IPS-capacitive": "mesh_tab_base",
|
||||
"mesh-tab-4-0-IPS-capacitive": "mesh_tab_base",
|
||||
"nibble-esp32": "esp32s3",
|
||||
"nugget-s3-lora": "esp32s3",
|
||||
"picomputer-s3": "esp32s3",
|
||||
"picomputer-s3-tft": "picomputer-s3",
|
||||
"rak3312": "esp32s3",
|
||||
"rakwismeshtapv2tft": "rakwismeshtaps3",
|
||||
"seeedsensecapindicator": "esp32s3",
|
||||
"seeedsensecapindicatortft": "seeedsensecapindicator",
|
||||
"seeedxiaos3": "esp32s3",
|
||||
"stationg2": "esp32s3",
|
||||
"tdeck": "esp32s3",
|
||||
"tdecktft": "tdeck",
|
||||
"tdeckpro": "esp32s3",
|
||||
"tethelite": "esp32s3",
|
||||
"twatchs3": "esp32s3",
|
||||
"tbeams3core": "esp32s3",
|
||||
"tlorapager": "esp32s3",
|
||||
"tlorapagertft": "tlorapager",
|
||||
"tlorat3s3epaper": "esp32s3",
|
||||
"tlorat3s3epaperinkhud": "esp32s3",
|
||||
"tlorat3s3v1": "esp32s3",
|
||||
"rak_wismesh_tap_v2-tft": "rak_wismeshtap_s3",
|
||||
"seeed-sensecap-indicator": "esp32s3",
|
||||
"seeed-sensecap-indicator-tft": "seeed-sensecap-indicator",
|
||||
"seeed-xiao-s3": "esp32s3",
|
||||
"station-g2": "esp32s3",
|
||||
"t-deck": "esp32s3",
|
||||
"t-deck-tft": "t-deck",
|
||||
"t-deck-pro": "esp32s3",
|
||||
"t-eth-elite": "esp32s3",
|
||||
"t-watch-s3": "esp32s3",
|
||||
"tbeam-s3-core": "esp32s3",
|
||||
"tlora-pager": "esp32s3",
|
||||
"tlora-pager-tft": "tlora-pager",
|
||||
"tlora-t3s3-epaper": "esp32s3",
|
||||
"tlora-t3s3-epaper-inkhud": "esp32s3",
|
||||
"tlora-t3s3-v1": "esp32s3",
|
||||
"tracksenger": "esp32s3",
|
||||
"tracksengerlcd": "esp32s3",
|
||||
"tracksengeroled": "esp32s3",
|
||||
"tracksenger-lcd": "esp32s3",
|
||||
"tracksenger-oled": "esp32s3",
|
||||
"unphone": "esp32s3",
|
||||
"unphonetft": "unphone",
|
||||
"unphone-tft": "unphone",
|
||||
"coverage": "native",
|
||||
"buildroot": "portduino",
|
||||
"pca10059diyeink": "nrf52840",
|
||||
"thinknodem1": "nrf52840",
|
||||
"thinknodem1inkhud": "nrf52840",
|
||||
"thinknodem3": "nrf52840",
|
||||
"thinknodem6": "nrf52840",
|
||||
"ME25LS014Y10TD": "nrf52840",
|
||||
"ME25LS014Y10TDeink": "nrf52840",
|
||||
"pca10059_diy_eink": "nrf52840",
|
||||
"thinknode_m1": "nrf52840",
|
||||
"thinknode_m1-inkhud": "nrf52840",
|
||||
"thinknode_m3": "nrf52840",
|
||||
"thinknode_m6": "nrf52840",
|
||||
"ME25LS01-4Y10TD": "nrf52840",
|
||||
"ME25LS01-4Y10TD_e-ink": "nrf52840",
|
||||
"ms24sf1": "nrf52840",
|
||||
"makerpythonnrf52840sx1280eink": "nrf52840",
|
||||
"makerpythonnrf52840sx1280oled": "nrf52840",
|
||||
"TWCmeshv4": "nrf52840",
|
||||
"makerpython_nrf52840_sx1280_eink": "nrf52840",
|
||||
"makerpython_nrf52840_sx1280_oled": "nrf52840",
|
||||
"TWC_mesh_v4": "nrf52840",
|
||||
"canaryone": "nrf52840",
|
||||
"WashTastic": "nrf52840",
|
||||
"nrf52promicrodiytcxo": "nrf52840",
|
||||
"nrf52promicrodiyinkhud": "nrf52840",
|
||||
"seeedxiaonrf52840wiosx1262": "nrf52840",
|
||||
"seeedxiaonrf52840e22900m30s": "seeedxiaonrf52840kit",
|
||||
"seeedxiaonrf52840e22900m33s": "seeedxiaonrf52840kit",
|
||||
"xiaoble": "seeedxiaonrf52840kit",
|
||||
"featherdiy": "nrf52840",
|
||||
"gat562meshtrialtracker": "nrf52840",
|
||||
"heltecmeshnodet114": "nrf52840",
|
||||
"heltecmeshnodet114inkhud": "nrf52840",
|
||||
"heltecmeshpocket5000": "nrf52840",
|
||||
"heltecmeshpocket5000inkhud": "nrf52840",
|
||||
"heltecmeshpocket10000": "nrf52840",
|
||||
"heltecmeshpocket10000inkhud": "nrf52840",
|
||||
"heltecmeshsolarbase": "nrf52840",
|
||||
"heltecmeshsolar": "heltecmeshsolarbase",
|
||||
"heltecmeshsolareink": "heltecmeshsolarbase",
|
||||
"heltecmeshsolarinkhud": "heltecmeshsolarbase",
|
||||
"heltecmeshsolaroled": "heltecmeshsolarbase",
|
||||
"heltecmeshsolartft": "heltecmeshsolarbase",
|
||||
"nrf52_promicro_diy_tcxo": "nrf52840",
|
||||
"nrf52_promicro_diy-inkhud": "nrf52840",
|
||||
"seeed-xiao-nrf52840-wio-sx1262": "nrf52840",
|
||||
"seeed_xiao_nrf52840_e22_900m30s": "seeed_xiao_nrf52840_kit",
|
||||
"seeed_xiao_nrf52840_e22_900m33s": "seeed_xiao_nrf52840_kit",
|
||||
"xiao_ble": "seeed_xiao_nrf52840_kit",
|
||||
"feather_diy": "nrf52840",
|
||||
"gat562_mesh_trial_tracker": "nrf52840",
|
||||
"heltec-mesh-node-t114": "nrf52840",
|
||||
"heltec-mesh-node-t114-inkhud": "nrf52840",
|
||||
"heltec-mesh-pocket-5000": "nrf52840",
|
||||
"heltec-mesh-pocket-5000-inkhud": "nrf52840",
|
||||
"heltec-mesh-pocket-10000": "nrf52840",
|
||||
"heltec-mesh-pocket-10000-inkhud": "nrf52840",
|
||||
"heltec_mesh_solar_base": "nrf52840",
|
||||
"heltec-mesh-solar": "heltec_mesh_solar_base",
|
||||
"heltec-mesh-solar-eink": "heltec_mesh_solar_base",
|
||||
"heltec-mesh-solar-inkhud": "heltec_mesh_solar_base",
|
||||
"heltec-mesh-solar-oled": "heltec_mesh_solar_base",
|
||||
"heltec-mesh-solar-tft": "heltec_mesh_solar_base",
|
||||
"meshlink": "nrf52840",
|
||||
"meshlinkeink": "nrf52840",
|
||||
"meshlink_eink": "nrf52840",
|
||||
"meshtiny": "nrf52840",
|
||||
"monteopshw1": "nrf52840",
|
||||
"muzibase": "nrf52840",
|
||||
"nanog2ultra": "nrf52840",
|
||||
"nrf52832base": "nrf52",
|
||||
"nrf52840base": "nrf52",
|
||||
"r1neo": "nrf52840",
|
||||
"monteops_hw1": "nrf52840",
|
||||
"muzi-base": "nrf52840",
|
||||
"nano-g2-ultra": "nrf52840",
|
||||
"nrf52832_base": "nrf52",
|
||||
"nrf52840_base": "nrf52",
|
||||
"r1-neo": "nrf52840",
|
||||
"rak2560": "nrf52840",
|
||||
"rak34011watt": "nrf52840",
|
||||
"rak3401-1watt": "nrf52840",
|
||||
"rak4631": "nrf52840",
|
||||
"rak4631dbg": "rak4631",
|
||||
"rak4631eink": "nrf52840",
|
||||
"rak4631einkonrxtx": "nrf52840",
|
||||
"rak4631ethgw": "nrf52840",
|
||||
"rak4631ethgwdbg": "rak4631",
|
||||
"rak4631nomadstarmeteorpro": "nrf52840",
|
||||
"rak4631nomadstarmeteorprodbg": "rak4631nomadstarmeteorpro",
|
||||
"rakwismeshtag": "nrf52840",
|
||||
"rakwismeshtap": "nrf52840",
|
||||
"seeedsolarnode": "nrf52840",
|
||||
"seeedwiotrackerL1": "nrf52840",
|
||||
"seeedwiotrackerL1eink": "nrf52840",
|
||||
"seeedwiotrackerL1einkinkhud": "nrf52840",
|
||||
"seeedxiaonrf52840kit": "nrf52840",
|
||||
"seeedxiaonrf52840kiti2c": "seeedxiaonrf52840kit",
|
||||
"techo": "nrf52840",
|
||||
"techoinkhud": "nrf52840",
|
||||
"techolite": "nrf52840",
|
||||
"trackert1000e": "nrf52840",
|
||||
"wiosdkwm1110": "nrf52840",
|
||||
"wiot1000s": "nrf52840",
|
||||
"wiotrackerwm1110": "nrf52840",
|
||||
"challenger2040lora": "rp2040",
|
||||
"rak4631_dbg": "rak4631",
|
||||
"rak4631_eink": "nrf52840",
|
||||
"rak4631_eink_onrxtx": "nrf52840",
|
||||
"rak4631_eth_gw": "nrf52840",
|
||||
"rak4631_eth_gw_dbg": "rak4631",
|
||||
"rak4631_nomadstar_meteor_pro": "nrf52840",
|
||||
"rak4631_nomadstar_meteor_pro_dbg": "rak4631_nomadstar_meteor_pro",
|
||||
"rak_wismeshtag": "nrf52840",
|
||||
"rak_wismeshtap": "nrf52840",
|
||||
"seeed_solar_node": "nrf52840",
|
||||
"seeed_wio_tracker_L1": "nrf52840",
|
||||
"seeed_wio_tracker_L1_eink": "nrf52840",
|
||||
"seeed_wio_tracker_L1_eink-inkhud": "nrf52840",
|
||||
"seeed_xiao_nrf52840_kit": "nrf52840",
|
||||
"seeed_xiao_nrf52840_kit_i2c": "seeed_xiao_nrf52840_kit",
|
||||
"t-echo": "nrf52840",
|
||||
"t-echo-inkhud": "nrf52840",
|
||||
"t-echo-lite": "nrf52840",
|
||||
"tracker-t1000-e": "nrf52840",
|
||||
"wio-sdk-wm1110": "nrf52840",
|
||||
"wio-t1000-s": "nrf52840",
|
||||
"wio-tracker-wm1110": "nrf52840",
|
||||
"challenger_2040_lora": "rp2040",
|
||||
"catsniffer": "rp2040",
|
||||
"featherrp2040rfm95": "rp2040",
|
||||
"nibblerp2040": "rp2040",
|
||||
"feather_rp2040_rfm95": "rp2040",
|
||||
"nibble-rp2040": "rp2040",
|
||||
"rak11310": "rp2040",
|
||||
"rp2040lora": "rp2040",
|
||||
"rp2040-lora": "rp2040",
|
||||
"pico": "rp2040",
|
||||
"picoslowclock": "rp2040",
|
||||
"pico_slowclock": "rp2040",
|
||||
"picow": "rp2040",
|
||||
"senselorarp2040": "rp2040",
|
||||
"senselora_rp2040": "rp2040",
|
||||
"pico2": "rp2350",
|
||||
"pico2w": "rp2350",
|
||||
"CDEBYTEE77MBL": "stm32",
|
||||
"CDEBYTE_E77-MBL": "stm32",
|
||||
"rak3172": "stm32",
|
||||
"wioe5": "stm32",
|
||||
"wio-e5": "stm32",
|
||||
"esp32c3": "esp32",
|
||||
"esp32c6": "esp32",
|
||||
"esp32s2": "esp32",
|
||||
@@ -217,4 +217,4 @@
|
||||
"rp2350": null,
|
||||
"stm32": null,
|
||||
"portduino": null
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import hardwareList from "@/vendor/web-flasher/public/data/hardware-list.json"
|
||||
import architectureHierarchy from "@/constants/architecture-hierarchy.json"
|
||||
import vendorsData from "@/constants/vendors.json"
|
||||
|
||||
export interface TargetMetadata {
|
||||
name: string
|
||||
@@ -6,17 +7,43 @@ export interface TargetMetadata {
|
||||
architecture?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace a target back to its base architecture
|
||||
*/
|
||||
function getBaseArchitecture(target: string): string | null {
|
||||
const parentMap = architectureHierarchy as Record<string, string | null>
|
||||
const visited = new Set<string>()
|
||||
let current: string | null = target
|
||||
|
||||
while (current && !visited.has(current)) {
|
||||
visited.add(current)
|
||||
if (!current) break
|
||||
const parent: string | null | undefined = parentMap[current]
|
||||
|
||||
if (parent === null) {
|
||||
return current
|
||||
}
|
||||
|
||||
if (parent === undefined) {
|
||||
return current
|
||||
}
|
||||
|
||||
current = parent
|
||||
}
|
||||
|
||||
return current || target
|
||||
}
|
||||
|
||||
export const TARGETS: Record<string, TargetMetadata> = {}
|
||||
|
||||
// Sort by display name
|
||||
const sortedHardware = [...hardwareList].sort((a, b) => (a.displayName || "").localeCompare(b.displayName || ""))
|
||||
|
||||
sortedHardware.forEach(hw => {
|
||||
if (hw.platformioTarget) {
|
||||
TARGETS[hw.platformioTarget] = {
|
||||
name: hw.displayName || hw.platformioTarget,
|
||||
category: hw.tags?.[0] || "Other",
|
||||
architecture: hw.architecture,
|
||||
// Build TARGETS from vendors.json and architecture-hierarchy.json
|
||||
for (const [vendor, models] of Object.entries(vendorsData)) {
|
||||
for (const [modelName, target] of Object.entries(models)) {
|
||||
const architecture = getBaseArchitecture(target)
|
||||
TARGETS[target] = {
|
||||
name: modelName,
|
||||
category: vendor,
|
||||
architecture: architecture || undefined,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
244
constants/vendors.json
Normal file
244
constants/vendors.json
Normal file
@@ -0,0 +1,244 @@
|
||||
{
|
||||
"B&Q": {
|
||||
"Nano G1": "nano-g1",
|
||||
"Nano G1 Explorer": "nano-g1-explorer",
|
||||
"Nano G2 Ultra": "nano-g2-ultra",
|
||||
"Station G1": "station-g1",
|
||||
"Station G2": "station-g2"
|
||||
},
|
||||
"BetaFPV": {
|
||||
"2400TX Micro": "betafpv_2400_tx_micro",
|
||||
"900TX Nano": "betafpv_900_tx_nano"
|
||||
},
|
||||
"Canary": {
|
||||
"One": "canaryone"
|
||||
},
|
||||
"CDEByte": {
|
||||
"EoRa S3": "CDEBYTE_EoRa-S3",
|
||||
"E77MBL": "CDEBYTE_E77-MBL"
|
||||
},
|
||||
"DIY": {
|
||||
"DR-DEV": "meshtastic-dr-dev",
|
||||
"Hydra": "hydra",
|
||||
"V1": "meshtastic-diy-v1",
|
||||
"V1.1": "meshtastic-diy-v1_1",
|
||||
"NRF52 Pro-micro DIY": "nrf52_promicro_diy_tcxo",
|
||||
"NRF52 Pro-micro DIY InkHUD": "nrf52_promicro_diy-inkhud",
|
||||
"PCA10059 DIY E-Ink": "pca10059_diy_eink"
|
||||
},
|
||||
"EByte": {
|
||||
"ESP32-S3": "EBYTE_ESP32-S3"
|
||||
},
|
||||
"Elecrow": {
|
||||
"Crowpanel Adv 2.4/2.8 TFT": "elecrow-adv-24-28-tft",
|
||||
"Crowpanel Adv 3.5 TFT": "elecrow-adv-35-tft",
|
||||
"Crowpanel Adv 4.3/5.0/7.0 TFT": "elecrow-adv1-43-50-70-tft",
|
||||
"ThinkNode M1": "thinknode_m1",
|
||||
"ThinkNode M2": "thinknode_m2",
|
||||
"ThinkNode M3": "thinknode_m3",
|
||||
"ThinkNode M5": "thinknode_m5",
|
||||
"ThinkNode M6": "thinknode_m6"
|
||||
},
|
||||
"Heltec": {
|
||||
"V1": "heltec-v1",
|
||||
"V2.0": "heltec-v2_0",
|
||||
"V2.1": "heltec-v2_1",
|
||||
"V3": "heltec-v3",
|
||||
"V4": "heltec-v4",
|
||||
"V4 TFT": "heltec-v4-tft",
|
||||
"HT62": "heltec-ht62-esp32c3-sx1262",
|
||||
"RU3601": "heltec-hru-3601",
|
||||
"Wireless Bridge": "heltec-wireless-bridge",
|
||||
"Wireless Stick Lite V2.1": "heltec-wsl-v2_1",
|
||||
"Wireless Stick Lite V3": "heltec-wsl-v3",
|
||||
"Wireless Paper": "heltec-wireless-paper",
|
||||
"Wireless Paper InkHUD": "heltec-wireless-paper-inkhud",
|
||||
"Wireless Paper V1.0": "heltec-wireless-paper-v1_0",
|
||||
"Wireless Tracker": "heltec-wireless-tracker",
|
||||
"Wireless Tracker V1.0": "heltec-wireless-tracker-V1-0",
|
||||
"Wireless Tracker V2": "heltec-wireless-tracker-v2",
|
||||
"Vision Master E213": "heltec-vision-master-e213",
|
||||
"Vision Master E213 InkHUD": "heltec-vision-master-e213-inkhud",
|
||||
"Vision Master E290": "heltec-vision-master-e290",
|
||||
"Vision Master E290 InkHUD": "heltec-vision-master-e290-inkhud",
|
||||
"Vision Master T190": "heltec-vision-master-t190",
|
||||
"Capsule Sensor V3": "heltec_capsule_sensor_v3",
|
||||
"Sensor Hub": "heltec_sensor_hub",
|
||||
"Mesh Node T114": "heltec-mesh-node-t114",
|
||||
"Mesh Node T114 InkHUD": "heltec-mesh-node-t114-inkhud",
|
||||
"MeshPocket 5000": "heltec-mesh-pocket-5000",
|
||||
"MeshPocket 5000 InkHUD": "heltec-mesh-pocket-5000-inkhud",
|
||||
"MeshPocket 10000": "heltec-mesh-pocket-10000",
|
||||
"MeshPocket 10000 InkHUD": "heltec-mesh-pocket-10000-inkhud",
|
||||
"MeshSolar": "heltec-mesh-solar",
|
||||
"MeshSolar E-Ink": "heltec-mesh-solar-eink",
|
||||
"MeshSolar InkHUD": "heltec-mesh-solar-inkhud",
|
||||
"MeshSolar OLED": "heltec-mesh-solar-oled",
|
||||
"MeshSolar TFT": "heltec-mesh-solar-tft"
|
||||
},
|
||||
"HackerBoxes": {
|
||||
"ESP32 IO": "hackerboxes-esp32-io",
|
||||
"ESP32-C3 OLED": "hackerboxes-esp32c3-oled"
|
||||
},
|
||||
"LilyGo": {
|
||||
"T-Beam": "tbeam",
|
||||
"T-Beam Display Shield": "tbeam-displayshield",
|
||||
"T-Beam V0.7": "tbeam0_7",
|
||||
"T-Beam S3 Core": "tbeam-s3-core",
|
||||
"T-Deck": "t-deck",
|
||||
"T-Deck TFT": "t-deck-tft",
|
||||
"T-Deck Pro": "t-deck-pro",
|
||||
"T-Echo": "t-echo",
|
||||
"T-Echo InkHUD": "t-echo-inkhud",
|
||||
"T-Echo Lite": "t-echo-lite",
|
||||
"T-LoRa V1": "tlora-v1",
|
||||
"T-LoRa V1.3": "tlora_v1_3",
|
||||
"T-LoRa V2": "tlora-v2",
|
||||
"T-LoRa V2.1-1.6": "tlora-v2-1-1_6",
|
||||
"T-LoRa V2.1-1.6 TCXO": "tlora-v2-1-1_6-tcxo",
|
||||
"T-LoRa V2.1-1.8": "tlora-v2-1-1_8",
|
||||
"T-LoRa V3.3.0 TCXO": "tlora-v3-3-0-tcxo",
|
||||
"T-LoRa C6": "tlora-c6",
|
||||
"T-LoRa Pager": "tlora-pager",
|
||||
"T-LoRa Pager TFT": "tlora-pager-tft",
|
||||
"T-LoRa T3-S3": "tlora-t3s3-v1",
|
||||
"T-LoRa T3-S3 E-Paper": "tlora-t3s3-epaper",
|
||||
"T-LoRa T3-S3 E-Paper InkHUD": "tlora-t3s3-epaper-inkhud",
|
||||
"T-Watch S3": "t-watch-s3",
|
||||
"Sugar Cube": "sugarcube"
|
||||
},
|
||||
"M5Stack": {
|
||||
"Core": "m5stack-core",
|
||||
"Core Ink": "m5stack-coreink",
|
||||
"Core S3": "m5stack-cores3",
|
||||
"Stamp C3": "m5stack-stamp-c3",
|
||||
"Unit C6L": "m5stack-unitc6l"
|
||||
},
|
||||
"MakerPython": {
|
||||
"NRF52840 SX1280 E-Ink": "makerpython_nrf52840_sx1280_eink",
|
||||
"NRF52840 SX1280 OLED": "makerpython_nrf52840_sx1280_oled"
|
||||
},
|
||||
"muzi": {
|
||||
"R1 Neo": "r1-neo"
|
||||
},
|
||||
"NomadStar": {
|
||||
"Meteor Pro": "rak4631_nomadstar_meteor_pro",
|
||||
"Meteor Pro Debug": "rak4631_nomadstar_meteor_pro_dbg"
|
||||
},
|
||||
"RAK": {
|
||||
"WisBlock 11200": "rak11200",
|
||||
"WisBlock 11310": "rak11310",
|
||||
"WisBlock 4631": "rak4631",
|
||||
"WisBlock 4631 Debug": "rak4631_dbg",
|
||||
"WisBlock 4631 E-Ink": "rak4631_eink",
|
||||
"WisBlock 4631 E-Ink on RX/TX": "rak4631_eink_onrxtx",
|
||||
"WisBlock 4631 ETH Gateway": "rak4631_eth_gw",
|
||||
"WisBlock 4631 ETH Gateway Debug": "rak4631_eth_gw_dbg",
|
||||
"WisBlock 3312": "rak3312",
|
||||
"WisBlock 3172": "rak3172",
|
||||
"WisBlock 2560": "rak2560",
|
||||
"WisBlock 3401 1Watt": "rak3401-1watt",
|
||||
"WisMesh Tag": "rak_wismeshtag",
|
||||
"WisMesh Tap": "rak_wismeshtap",
|
||||
"WisMesh Tap V2 TFT": "rak_wismesh_tap_v2-tft"
|
||||
},
|
||||
"RadioMaster": {
|
||||
"900 Bandit": "radiomaster_900_bandit",
|
||||
"900 Bandit Micro": "radiomaster_900_bandit_micro",
|
||||
"900 Bandit Nano": "radiomaster_900_bandit_nano"
|
||||
},
|
||||
"RPi": {
|
||||
"Pico": "pico",
|
||||
"Pico Slow Clock": "pico_slowclock",
|
||||
"Pico W": "picow",
|
||||
"Pico 2": "pico2",
|
||||
"Pico 2W": "pico2w"
|
||||
},
|
||||
"Seeed": {
|
||||
"Xiao ESP32-S3": "seeed-xiao-s3",
|
||||
"Xiao NRF52840 Kit": "seeed_xiao_nrf52840_kit",
|
||||
"Xiao NRF52840 Kit I2C": "seeed_xiao_nrf52840_kit_i2c",
|
||||
"Xiao NRF52840 WIO SX1262": "seeed-xiao-nrf52840-wio-sx1262",
|
||||
"Xiao NRF52840 E22900M30S": "seeed_xiao_nrf52840_e22_900m30s",
|
||||
"Xiao NRF52840 E22900M33S": "seeed_xiao_nrf52840_e22_900m33s",
|
||||
"Xiao BLE": "xiao_ble",
|
||||
"Wio Tracker L1": "seeed_wio_tracker_L1",
|
||||
"Wio Tracker L1 E-Ink": "seeed_wio_tracker_L1_eink",
|
||||
"Wio Tracker L1 E-Ink InkHUD": "seeed_wio_tracker_L1_eink-inkhud",
|
||||
"Wio Tracker WM1110": "wio-tracker-wm1110",
|
||||
"Wio SDK WM1110": "wio-sdk-wm1110",
|
||||
"Wio T1000S": "wio-t1000-s",
|
||||
"SenseCAP Indicator": "seeed-sensecap-indicator",
|
||||
"SenseCAP Indicator TFT": "seeed-sensecap-indicator-tft",
|
||||
"Solar Node": "seeed_solar_node"
|
||||
},
|
||||
"Waveshare": {
|
||||
"RP2040 LoRa": "rp2040-lora"
|
||||
},
|
||||
"Other": {
|
||||
"Chatter2": "chatter2",
|
||||
"9M2 IBRA PRS LoRa Tracker": "9m2ibr_aprs_lora_tracker",
|
||||
"AIC3": "ai-c3",
|
||||
"ESP32-C3": "esp32c3",
|
||||
"ESP32-C3 Super Mini": "esp32c3_super_mini",
|
||||
"ESP32-C6": "esp32c6",
|
||||
"ESP32-S2": "esp32s2",
|
||||
"ESP32-S3": "esp32s3",
|
||||
"ESP32-S3 Pico": "ESP32-S3-Pico",
|
||||
"Crowpanel ESP32-S3 2 E-Paper": "crowpanel-esp32s3-2-epaper",
|
||||
"Crowpanel ESP32-S3 4 E-Paper": "crowpanel-esp32s3-4-epaper",
|
||||
"Crowpanel ESP32-S3 5 E-Paper": "crowpanel-esp32s3-5-epaper",
|
||||
"BPi Pico W ESP32-S3": "bpi_picow_esp32_s3",
|
||||
"My ESP32-S3 DIY E-Ink": "my-esp32s3-diy-eink",
|
||||
"My ESP32-S3 DIY OLED": "my-esp32s3-diy-oled",
|
||||
"T-Energy S3 E22": "t-energy-s3_e22",
|
||||
"Dreamcatcher 2206": "dreamcatcher-2206",
|
||||
"Hackaday Communicator": "hackaday-communicator",
|
||||
"Icarus": "icarus",
|
||||
"Link32S3 V1": "link32-s3-v1",
|
||||
"MeshTab 32 TN Resistive": "mesh-tab-3-2-TN-resistive",
|
||||
"MeshTab 32 IPS Resistive": "mesh-tab-3-2-IPS-resistive",
|
||||
"MeshTab 35 IPS Resistive": "mesh-tab-3-5-IPS-resistive",
|
||||
"MeshTab 35 TN Resistive": "mesh-tab-3-5-TN-resistive",
|
||||
"MeshTab 32 IPS Capacitive": "mesh-tab-3-2-IPS-capacitive",
|
||||
"MeshTab 35 IPS Capacitive": "mesh-tab-3-5-IPS-capacitive",
|
||||
"MeshTab 40 IPS Capacitive": "mesh-tab-4-0-IPS-capacitive",
|
||||
"Nibble ESP32": "nibble-esp32",
|
||||
"Nugget S2 LoRa": "nugget-s2-lora",
|
||||
"Nugget S3 LoRa": "nugget-s3-lora",
|
||||
"Pi Computer S3": "picomputer-s3",
|
||||
"Pi Computer S3 TFT": "picomputer-s3-tft",
|
||||
"T-Echo Lite": "t-eth-elite",
|
||||
"Tracksenger": "tracksenger",
|
||||
"Tracksenger LCD": "tracksenger-lcd",
|
||||
"Tracksenger OLED": "tracksenger-oled",
|
||||
"unPhone": "unphone",
|
||||
"unPhone TFT": "unphone-tft",
|
||||
"NRF52832": "nrf52832",
|
||||
"NRF52840": "nrf52840",
|
||||
"ThinkNode M1 InkHUD": "thinknode_m1-inkhud",
|
||||
"ME25LS014Y10TD": "ME25LS01-4Y10TD",
|
||||
"ME25LS014Y10TD E-Ink": "ME25LS01-4Y10TD_e-ink",
|
||||
"MS24SF1": "ms24sf1",
|
||||
"TWC Mesh V4": "TWC_mesh_v4",
|
||||
"WashTastic": "WashTastic",
|
||||
"Feather DIY": "feather_diy",
|
||||
"Feather RP2040 RFM95": "feather_rp2040_rfm95",
|
||||
"GAT562 Mesh Trial Tracker": "gat562_mesh_trial_tracker",
|
||||
"MeshLink": "meshlink",
|
||||
"MeshLink E-Ink": "meshlink_eink",
|
||||
"MeshTiny": "meshtiny",
|
||||
"MonteOps HW1": "monteops_hw1",
|
||||
"R1 Neo": "r1-neo",
|
||||
"Tracker T1000E": "tracker-t1000-e",
|
||||
"Tracker D": "trackerd",
|
||||
"WiPhone": "wiphone",
|
||||
"Challenger 2040 LoRa": "challenger_2040_lora",
|
||||
"Cat Sniffer": "catsniffer",
|
||||
"Nibbler RP2040": "nibble-rp2040",
|
||||
"SenseLoRa RP2040": "senselora_rp2040",
|
||||
"Wio E5": "wio-e5",
|
||||
"Coverage": "coverage",
|
||||
"Buildroot": "buildroot"
|
||||
}
|
||||
}
|
||||
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -15,6 +15,7 @@ import type * as builds from "../builds.js";
|
||||
import type * as helpers from "../helpers.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as lib_filename from "../lib/filename.js";
|
||||
import type * as lib_flags from "../lib/flags.js";
|
||||
import type * as lib_r2 from "../lib/r2.js";
|
||||
import type * as plugins from "../plugins.js";
|
||||
import type * as profiles from "../profiles.js";
|
||||
@@ -33,6 +34,7 @@ declare const fullApi: ApiFromModules<{
|
||||
helpers: typeof helpers;
|
||||
http: typeof http;
|
||||
"lib/filename": typeof lib_filename;
|
||||
"lib/flags": typeof lib_flags;
|
||||
"lib/r2": typeof lib_r2;
|
||||
plugins: typeof plugins;
|
||||
profiles: typeof profiles;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Google from "@auth/core/providers/google"
|
||||
import GitHub from "@auth/core/providers/github"
|
||||
import { convexAuth } from "@convex-dev/auth/server"
|
||||
|
||||
export const { auth, signIn, signOut, store } = convexAuth({
|
||||
providers: [Google],
|
||||
providers: [GitHub],
|
||||
})
|
||||
|
||||
@@ -3,15 +3,11 @@ import { v } from "convex/values"
|
||||
import { api, internal } from "./_generated/api"
|
||||
import type { Doc, Id } from "./_generated/dataModel"
|
||||
import { internalMutation, mutation, query } from "./_generated/server"
|
||||
import { getArtifactFilenameBase } from "./lib/filename"
|
||||
import { ArtifactType, getArtifactFilenameBase } from "./lib/filename"
|
||||
import { computeFlagsFromConfig } from "./lib/flags"
|
||||
import { generateSignedDownloadUrl } from "./lib/r2"
|
||||
import { buildFields } from "./schema"
|
||||
|
||||
export enum ArtifactType {
|
||||
Firmware = "firmware",
|
||||
Source = "source",
|
||||
}
|
||||
|
||||
type BuildUpdateData = {
|
||||
status: string
|
||||
completedAt?: number
|
||||
@@ -36,18 +32,8 @@ export const getByHash = query({
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Computes flags string from build config.
|
||||
* Only excludes modules explicitly marked as excluded (config[id] === true).
|
||||
*/
|
||||
export function computeFlagsFromConfig(config: Doc<"builds">["config"]): string {
|
||||
// Sort modules to ensure consistent order
|
||||
return Object.keys(config.modulesExcluded)
|
||||
.sort()
|
||||
.filter(module => config.modulesExcluded[module])
|
||||
.map((moduleExcludedName: string) => `-D${moduleExcludedName}=1`)
|
||||
.join(" ")
|
||||
}
|
||||
// Re-export for backward compatibility
|
||||
export { computeFlagsFromConfig } from "./lib/flags"
|
||||
|
||||
/**
|
||||
* Encodes a byte array to base62 string.
|
||||
@@ -82,16 +68,39 @@ async function computeBuildHashInternal(
|
||||
version: string,
|
||||
target: string,
|
||||
flags: string,
|
||||
plugins: string[]
|
||||
plugins: string[],
|
||||
pluginConfig?: Record<string, Record<string, boolean>>
|
||||
): Promise<string> {
|
||||
// Input is now the exact parameters used for the build
|
||||
// Sort plugins array for consistent hashing
|
||||
const sortedPlugins = [...plugins].sort()
|
||||
// Sort plugin config for consistent hashing
|
||||
const sortedPluginConfig = pluginConfig
|
||||
? Object.keys(pluginConfig)
|
||||
.sort()
|
||||
.reduce(
|
||||
(acc, pluginSlug) => {
|
||||
const sortedOptions = Object.keys(pluginConfig[pluginSlug])
|
||||
.sort()
|
||||
.reduce(
|
||||
(opts, optKey) => {
|
||||
opts[optKey] = pluginConfig[pluginSlug][optKey]
|
||||
return opts
|
||||
},
|
||||
{} as Record<string, boolean>
|
||||
)
|
||||
acc[pluginSlug] = sortedOptions
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, Record<string, boolean>>
|
||||
)
|
||||
: undefined
|
||||
const input = JSON.stringify({
|
||||
version,
|
||||
target,
|
||||
flags,
|
||||
plugins: sortedPlugins,
|
||||
pluginConfig: sortedPluginConfig,
|
||||
})
|
||||
|
||||
// Use Web Crypto API for SHA-256 hashing
|
||||
@@ -108,10 +117,13 @@ async function computeBuildHashInternal(
|
||||
* Computes buildHash from build config.
|
||||
* This is the single source of truth for build hash computation.
|
||||
*/
|
||||
export async function computeBuildHash(config: Doc<"builds">["config"]): Promise<{ hash: string; flags: string }> {
|
||||
const flags = computeFlagsFromConfig(config)
|
||||
export async function computeBuildHash(
|
||||
config: Doc<"builds">["config"],
|
||||
registryData?: Record<string, { configOptions?: Record<string, { define: string }> }>
|
||||
): Promise<{ hash: string; flags: string }> {
|
||||
const flags = computeFlagsFromConfig(config, registryData)
|
||||
const plugins = config.pluginsEnabled ?? []
|
||||
const hash = await computeBuildHashInternal(config.version, config.target, flags, plugins)
|
||||
const hash = await computeBuildHashInternal(config.version, config.target, flags, plugins, config.pluginConfigs)
|
||||
return { hash, flags }
|
||||
}
|
||||
|
||||
@@ -190,6 +202,8 @@ export const ensureBuildFromConfig = mutation({
|
||||
version: v.string(),
|
||||
modulesExcluded: v.optional(v.record(v.string(), v.boolean())),
|
||||
pluginsEnabled: v.optional(v.array(v.string())),
|
||||
pluginConfigs: v.optional(v.record(v.string(), v.record(v.string(), v.boolean()))),
|
||||
registryData: v.optional(v.any()),
|
||||
profileName: v.optional(v.string()),
|
||||
profileDescription: v.optional(v.string()),
|
||||
},
|
||||
@@ -200,10 +214,15 @@ export const ensureBuildFromConfig = mutation({
|
||||
modulesExcluded: args.modulesExcluded ?? {},
|
||||
target: args.target,
|
||||
pluginsEnabled: args.pluginsEnabled,
|
||||
pluginConfigs: args.pluginConfigs,
|
||||
}
|
||||
|
||||
// Compute build hash (single source of truth)
|
||||
const { hash: buildHash, flags } = await computeBuildHash(config)
|
||||
// Registry data is optional - diagnostics works for all plugins without registry lookup
|
||||
const registryData = args.registryData as
|
||||
| Record<string, { configOptions?: Record<string, { define: string }> }>
|
||||
| undefined
|
||||
const { hash: buildHash, flags } = await computeBuildHash(config, registryData)
|
||||
|
||||
const existingBuild = await ctx.db
|
||||
.query("builds")
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* Artifact type enumeration.
|
||||
* Safe to import in client-side code.
|
||||
*/
|
||||
export enum ArtifactType {
|
||||
Firmware = "firmware",
|
||||
Source = "source",
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the artifact filename base (without extension) matching the download filename format.
|
||||
* Format: meshtastic-{version}-{target}-{last4hash}-{jobId}-{artifactType}
|
||||
@@ -27,3 +36,32 @@ export function getArtifactFilenameBase(
|
||||
|
||||
return `${os}-${version}-${target}-${last4Hash}-${githubRunId}-${artifactType}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a build identifier for use in external systems (e.g., Giscus discussions).
|
||||
* Format: {target}-{version}-{last4hash} or {target}-{version}-{last4hash}-{plugins}
|
||||
* This is a simpler identifier without OS prefix, job ID, or artifact type.
|
||||
*
|
||||
* @param version - The firmware version
|
||||
* @param target - The target board name
|
||||
* @param buildHash - The build hash (used to get last 4 characters)
|
||||
* @param pluginsEnabled - Optional array of enabled plugin slugs
|
||||
* @returns The build identifier (e.g., "tbeam-v2.7.16-a1b2" or "tbeam-v2.7.16-a1b2-plugin1+plugin2")
|
||||
*/
|
||||
export function getBuildIdentifier(
|
||||
version: string,
|
||||
target: string,
|
||||
buildHash: string,
|
||||
pluginsEnabled?: string[]
|
||||
): string {
|
||||
const last4Hash = buildHash.slice(-4)
|
||||
let identifier = `${target}-${version}-${last4Hash}`
|
||||
|
||||
if (pluginsEnabled && pluginsEnabled.length > 0) {
|
||||
const sortedPlugins = [...pluginsEnabled].sort()
|
||||
const pluginsStr = sortedPlugins.join("+")
|
||||
identifier = `${identifier}-${pluginsStr}`
|
||||
}
|
||||
|
||||
return identifier
|
||||
}
|
||||
|
||||
52
convex/lib/flags.ts
Normal file
52
convex/lib/flags.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Doc } from "../_generated/dataModel"
|
||||
|
||||
/**
|
||||
* Computes flags string from build config.
|
||||
* Only excludes modules explicitly marked as excluded (config[id] === true).
|
||||
* Also includes plugin config options (e.g., diagnostics).
|
||||
*
|
||||
* @param config - Build configuration with modulesExcluded and pluginConfigs
|
||||
* @param registryData - Optional registry data for custom config options (not needed for diagnostics)
|
||||
*/
|
||||
export function computeFlagsFromConfig(
|
||||
config: Doc<"builds">["config"],
|
||||
registryData?: Record<string, { configOptions?: Record<string, { define: string }> }>
|
||||
): string {
|
||||
const flags: string[] = []
|
||||
|
||||
// Sort modules to ensure consistent order
|
||||
const moduleFlags = Object.keys(config.modulesExcluded)
|
||||
.sort()
|
||||
.filter(module => config.modulesExcluded[module])
|
||||
.map((moduleExcludedName: string) => `-D${moduleExcludedName}=1`)
|
||||
flags.push(...moduleFlags)
|
||||
|
||||
// Add plugin config options (diagnostics is available for all plugins)
|
||||
if (config.pluginConfigs) {
|
||||
for (const [pluginSlug, pluginOptions] of Object.entries(config.pluginConfigs)) {
|
||||
// Handle diagnostics option (available for all plugins)
|
||||
if (pluginOptions.diagnostics) {
|
||||
// Convert plugin slug to uppercase define name (e.g., "lofs" -> "LOFS_PLUGIN_DIAGNOSTICS")
|
||||
const defineName = `${pluginSlug.toUpperCase().replace(/-/g, "_")}_PLUGIN_DIAGNOSTICS`
|
||||
flags.push(`-D${defineName}`)
|
||||
}
|
||||
|
||||
// Handle other custom config options from registry (if any)
|
||||
if (registryData) {
|
||||
const plugin = registryData[pluginSlug]
|
||||
if (plugin?.configOptions) {
|
||||
for (const [optionKey, enabled] of Object.entries(pluginOptions)) {
|
||||
if (optionKey !== "diagnostics" && enabled) {
|
||||
const option = plugin.configOptions[optionKey]
|
||||
if (option?.define) {
|
||||
flags.push(`-D${option.define}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return flags.join(" ")
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export const buildConfigFields = {
|
||||
modulesExcluded: v.record(v.string(), v.boolean()),
|
||||
target: v.string(),
|
||||
pluginsEnabled: v.optional(v.array(v.string())),
|
||||
pluginConfigs: v.optional(v.record(v.string(), v.record(v.string(), v.boolean()))),
|
||||
}
|
||||
|
||||
export const profileFields = {
|
||||
|
||||
74
hooks/usePluginCompatibility.ts
Normal file
74
hooks/usePluginCompatibility.ts
Normal file
@@ -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<string, TargetGroup[]>
|
||||
)
|
||||
|
||||
export function usePluginCompatibility(enabledPlugins: string[], preselectedPlugin?: { includes?: string[] } | null) {
|
||||
// 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>[] = []
|
||||
|
||||
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<string, TargetGroup[]>
|
||||
)
|
||||
: GROUPED_TARGETS
|
||||
|
||||
const filteredTargetCategories = Object.keys(filteredGroupedTargets).sort((a, b) => a.localeCompare(b))
|
||||
|
||||
return {
|
||||
compatibleTargets,
|
||||
filteredGroupedTargets,
|
||||
filteredTargetCategories,
|
||||
}
|
||||
}
|
||||
215
hooks/useTargetSelection.ts
Normal file
215
hooks/useTargetSelection.ts
Normal file
@@ -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<string, TargetGroup[]>
|
||||
)
|
||||
|
||||
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<string> | null,
|
||||
filteredGroupedTargets: Record<string, TargetGroup[]>,
|
||||
filteredTargetCategories: string[]
|
||||
) {
|
||||
const [activeCategory, setActiveCategory] = useState<string>(TARGET_CATEGORIES[0] ?? "")
|
||||
const [selectedTarget, setSelectedTarget] = useState<string>(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<string, TargetGroup[]>,
|
||||
TARGET_CATEGORIES,
|
||||
}
|
||||
}
|
||||
80
lib/utils.ts
80
lib/utils.ts
@@ -127,30 +127,19 @@ export function isRequiredByOther(
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize architecture name (remove hyphens and underscores to match PlatformIO format)
|
||||
* PlatformIO uses "esp32s3", "nrf52840" (no hyphens, no underscores)
|
||||
* Hardware list uses "esp32-s3" (with hyphens)
|
||||
* Some sources might use "esp32_s3" (with underscores)
|
||||
*/
|
||||
function normalizeArchitecture(arch: string): string {
|
||||
return arch.replace(/[-_]/g, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace a target/variant/architecture back to its base architecture
|
||||
* Follows the parent chain until it reaches a base architecture (null parent)
|
||||
*/
|
||||
export function getBaseArchitecture(name: string): string | null {
|
||||
const normalized = normalizeArchitecture(name)
|
||||
const parentMap = PARENT_MAP as Record<string, string | null>
|
||||
|
||||
const visited = new Set<string>()
|
||||
let current = normalized
|
||||
let current: string | null = name
|
||||
|
||||
while (current && !visited.has(current)) {
|
||||
visited.add(current)
|
||||
const parent = parentMap[current]
|
||||
if (!current) break
|
||||
const parent: string | null | undefined = parentMap[current]
|
||||
|
||||
// If parent is null, we've reached a base architecture
|
||||
if (parent === null) {
|
||||
@@ -162,11 +151,11 @@ export function getBaseArchitecture(name: string): string | null {
|
||||
return current
|
||||
}
|
||||
|
||||
current = normalizeArchitecture(parent)
|
||||
current = parent
|
||||
}
|
||||
|
||||
// Circular reference or unknown, return the last known
|
||||
return current || normalized
|
||||
return current || name
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,17 +163,16 @@ export function getBaseArchitecture(name: string): string | null {
|
||||
* (including itself and all parent architectures up to base)
|
||||
*/
|
||||
export function getCompatibleArchitectures(arch: string): string[] {
|
||||
const normalized = normalizeArchitecture(arch)
|
||||
const parentMap = PARENT_MAP as Record<string, string | null>
|
||||
|
||||
const compatible = [normalized]
|
||||
const compatible = [arch]
|
||||
const visited = new Set<string>()
|
||||
let current = normalized
|
||||
let current: string | null = arch
|
||||
|
||||
// Follow parent chain up to base architecture
|
||||
while (current && !visited.has(current)) {
|
||||
visited.add(current)
|
||||
const parent = parentMap[current]
|
||||
if (!current) break
|
||||
const parent: string | null | undefined = parentMap[current]
|
||||
|
||||
if (parent === null) {
|
||||
// Reached base architecture
|
||||
@@ -196,12 +184,11 @@ export function getCompatibleArchitectures(arch: string): string[] {
|
||||
break
|
||||
}
|
||||
|
||||
const normalizedParent = normalizeArchitecture(parent)
|
||||
if (!compatible.includes(normalizedParent)) {
|
||||
compatible.push(normalizedParent)
|
||||
if (!compatible.includes(parent)) {
|
||||
compatible.push(parent)
|
||||
}
|
||||
|
||||
current = normalizedParent
|
||||
current = parent
|
||||
}
|
||||
|
||||
return compatible
|
||||
@@ -227,18 +214,16 @@ export function isPluginCompatibleWithTarget(
|
||||
|
||||
const parentMap = PARENT_MAP as Record<string, string | null>
|
||||
|
||||
// Normalize target name first (all keys in parentMap are normalized)
|
||||
const normalizedTarget = normalizeArchitecture(targetName)
|
||||
|
||||
// Get all compatible names for the target (target itself + all parents up to base architecture)
|
||||
const compatibleNames = new Set<string>([normalizedTarget])
|
||||
const compatibleNames = new Set<string>([targetName])
|
||||
const visited = new Set<string>()
|
||||
let current = normalizedTarget
|
||||
let current: string | null = targetName
|
||||
|
||||
// Follow parent chain (all keys and values in parentMap are already normalized)
|
||||
// Follow parent chain
|
||||
while (current && !visited.has(current)) {
|
||||
visited.add(current)
|
||||
const parent = parentMap[current]
|
||||
if (!current) break
|
||||
const parent: string | null | undefined = parentMap[current]
|
||||
|
||||
if (parent === null) {
|
||||
// Reached base architecture
|
||||
@@ -251,30 +236,21 @@ export function isPluginCompatibleWithTarget(
|
||||
break
|
||||
}
|
||||
|
||||
// Parent is already normalized (from JSON)
|
||||
compatibleNames.add(parent)
|
||||
current = parent
|
||||
}
|
||||
|
||||
// Check excludes first - if target matches any exclude, it's incompatible
|
||||
// compatibleNames are already normalized, normalize excludes for comparison
|
||||
if (pluginExcludes && pluginExcludes.length > 0) {
|
||||
const isExcluded = pluginExcludes.some(exclude => {
|
||||
const normalizedExclude = normalizeArchitecture(exclude)
|
||||
return compatibleNames.has(normalizedExclude)
|
||||
})
|
||||
const isExcluded = pluginExcludes.some(exclude => compatibleNames.has(exclude))
|
||||
if (isExcluded) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// If includes are specified, target must match at least one include
|
||||
// compatibleNames are already normalized, normalize includes for comparison
|
||||
if (pluginIncludes && pluginIncludes.length > 0) {
|
||||
return pluginIncludes.some(include => {
|
||||
const normalizedInclude = normalizeArchitecture(include)
|
||||
return compatibleNames.has(normalizedInclude)
|
||||
})
|
||||
return pluginIncludes.some(include => compatibleNames.has(include))
|
||||
}
|
||||
|
||||
// If no includes/excludes specified, assume compatible with all (backward compatible)
|
||||
@@ -301,34 +277,30 @@ export function isPluginCompatibleWithArchitecture(
|
||||
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)))
|
||||
const includesSet = new Set(includes)
|
||||
|
||||
// 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
|
||||
let current: string | null = target
|
||||
|
||||
// Trace up the parent chain
|
||||
while (current && !visited.has(current)) {
|
||||
visited.add(current)
|
||||
if (!current) break
|
||||
|
||||
// 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)
|
||||
if (includesSet.has(current)) {
|
||||
compatibleTargets.add(target)
|
||||
break
|
||||
}
|
||||
|
||||
// Move to parent
|
||||
const parentValue = parentMap[current]
|
||||
if (parentValue === null || parentValue === undefined) {
|
||||
const parent: string | null | undefined = parentMap[current]
|
||||
if (parent === null || parent === undefined) {
|
||||
break
|
||||
}
|
||||
current = normalizeArchitecture(parentValue)
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mesh-forge",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"author": "benallfree",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Footer from "@/components/Footer"
|
||||
import Navbar from "@/components/Navbar"
|
||||
import { ConvexAuthProvider } from "@convex-dev/auth/react"
|
||||
import { ConvexReactClient } from "convex/react"
|
||||
@@ -18,8 +19,11 @@ function ConditionalNavbar() {
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ConvexAuthProvider client={convex}>
|
||||
<ConditionalNavbar />
|
||||
{children}
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<ConditionalNavbar />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</ConvexAuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,5 +11,8 @@ export default {
|
||||
"Build custom firmware with third-party plugins: BBS's, custom hardware, games, and more. An open ecosystem growing to hundreds of plugins.",
|
||||
|
||||
extends: [vikeReact],
|
||||
prerender: true,
|
||||
prerender: {
|
||||
partial: true,
|
||||
},
|
||||
ssr: false,
|
||||
} satisfies Config
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import Builder from "@/components/Builder"
|
||||
import { BuildProgress } from "@/components/BuildProgress"
|
||||
import { GiscusComments } from "@/components/GiscusComments"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
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 buildHash = urlSearchParams?.get("hash")
|
||||
const pluginParam = urlSearchParams?.get("plugin")
|
||||
|
||||
// If we have a build hash, show the build progress page
|
||||
if (buildHash) {
|
||||
return <BuildViewPage buildHash={buildHash} />
|
||||
}
|
||||
|
||||
// Otherwise, show the builder (handles clone and plugin params)
|
||||
return <Builder cloneHash={cloneHash || undefined} pluginParam={pluginParam || undefined} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen bg-slate-950 text-white p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<p className="text-slate-300">
|
||||
Build hash missing.{" "}
|
||||
<a href="/builds/new" className="text-cyan-400">
|
||||
Start a new build
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (build === undefined) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">
|
||||
@@ -68,6 +68,7 @@ export default function BuildProgressPage() {
|
||||
<div className="min-h-screen bg-slate-950 text-white p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<BuildProgress build={build} isAdmin={isAdmin === true} onRetry={handleRetry} />
|
||||
<GiscusComments build={build} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
prerender: false,
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import Builder from "./Builder"
|
||||
|
||||
export default function BuildNew() {
|
||||
return <Builder />
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import Builder from "../Builder"
|
||||
|
||||
export default function BuildNew() {
|
||||
return <Builder />
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
prerender: false,
|
||||
}
|
||||
@@ -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<string, TargetGroup[]>
|
||||
)
|
||||
|
||||
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<string>(TARGET_CATEGORIES[0] ?? "")
|
||||
const [selectedTarget, setSelectedTarget] = useState<string>(DEFAULT_TARGET)
|
||||
const [selectedVersion, setSelectedVersion] = useState<string>(VERSIONS[0])
|
||||
const [moduleConfig, setModuleConfig] = useState<Record<string, boolean>>({})
|
||||
const [pluginConfig, setPluginConfig] = useState<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)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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(() => {
|
||||
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<string>()
|
||||
for (const pluginSlug of allPluginSlugs) {
|
||||
if (
|
||||
isRequiredByOther(
|
||||
pluginSlug,
|
||||
allPluginSlugs,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
) {
|
||||
requiredByOthers.add(pluginSlug)
|
||||
}
|
||||
}
|
||||
|
||||
// Only add plugins that are NOT required by others (explicitly selected)
|
||||
const pluginObj: Record<string, boolean> = {}
|
||||
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<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
|
||||
// Check if this plugin is required by another explicitly selected plugin
|
||||
const isRequired = isRequiredByOther(
|
||||
id,
|
||||
explicitPlugins,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
|
||||
// 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<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
|
||||
// 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<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}`
|
||||
})
|
||||
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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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 className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-wider text-slate-500">
|
||||
{preselectedPlugin ? "Plugin build" : "Quick build"}
|
||||
</p>
|
||||
<h1 className="text-4xl font-bold mt-1">
|
||||
{preselectedPlugin ? `Build firmware for ${preselectedPlugin.name}` : "Flash a custom firmware version"}
|
||||
</h1>
|
||||
<p className="text-slate-400 mt-2 max-w-2xl">
|
||||
{preselectedPlugin
|
||||
? `Select a compatible Meshtastic target and configure your build for ${preselectedPlugin.name}. We'll send you to the build status page as soon as it starts.`
|
||||
: "Choose your Meshtastic target, adjust optional modules, and queue a new build instantly. We'll send you to the build status page as soon as it starts."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{preselectedPlugin && (
|
||||
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-400 shrink-0 mt-1" />
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
{preselectedPlugin.imageUrl && (
|
||||
<img
|
||||
src={preselectedPlugin.imageUrl}
|
||||
alt={`${preselectedPlugin.name} logo`}
|
||||
className="w-16 h-16 rounded-lg object-contain shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-2xl font-bold">{preselectedPlugin.name}</h2>
|
||||
{preselectedPlugin.featured && (
|
||||
<span className="px-2 py-1 text-xs font-medium text-green-400 bg-green-400/10 border border-green-400/20 rounded">
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-slate-400 mb-3">{preselectedPlugin.description}</p>
|
||||
{preselectedPlugin.includes && preselectedPlugin.includes.length > 0 && (
|
||||
<p className="text-sm text-slate-500">Compatible with: {preselectedPlugin.includes.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6 bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES).map(category => {
|
||||
const isActive = activeCategory === category
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Always allow switching to category - the useEffect will handle target selection
|
||||
setActiveCategory(category)
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
isActive ? "bg-blue-600 text-white" : "bg-slate-800 text-slate-300 hover:bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950/60 p-4 rounded-lg border border-slate-800/60">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(() => {
|
||||
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
|
||||
return (activeCategory ? targets[activeCategory] : [])?.map(target => {
|
||||
const isSelected = selectedTarget === target.id
|
||||
return (
|
||||
<button
|
||||
key={target.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectTarget(target.id)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
isSelected ? "bg-cyan-600 text-white" : "bg-slate-800 text-slate-300 hover:bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
{target.name}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="build-version" className="block text-sm font-medium mb-2">
|
||||
Firmware version
|
||||
</label>
|
||||
<select
|
||||
id="build-version"
|
||||
value={selectedVersion}
|
||||
onChange={event => setSelectedVersion(event.target.value)}
|
||||
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(version => (
|
||||
<option key={version} value={version}>
|
||||
{version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModuleOverrides(prev => !prev)}
|
||||
className="w-full flex items-center justify-between text-left"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Core Modules</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{moduleCount === 0
|
||||
? "Using default modules for this target."
|
||||
: `${moduleCount} module${moduleCount === 1 ? "" : "s"} excluded.`}
|
||||
</p>
|
||||
</div>
|
||||
{showModuleOverrides ? (
|
||||
<ChevronDown className="w-4 h-4 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showModuleOverrides && (
|
||||
<div className="space-y-2 pr-1">
|
||||
<div className="rounded-lg bg-slate-800/50 border border-slate-700 p-3">
|
||||
<p className="text-xs text-slate-400 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-slate-400 hover:text-white underline"
|
||||
onClick={() => setModuleConfig({})}
|
||||
disabled={moduleCount === 0}
|
||||
>
|
||||
Reset overrides
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{modulesData.modules.map(module => (
|
||||
<ModuleToggle
|
||||
key={module.id}
|
||||
id={module.id}
|
||||
name={module.name}
|
||||
description={module.description}
|
||||
isExcluded={moduleConfig[module.id] === true}
|
||||
onToggle={excluded => handleToggleModule(module.id, excluded)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPlugins(prev => !prev)}
|
||||
className="w-full flex items-center justify-between text-left"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Plugins</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{pluginCount === 0
|
||||
? "No plugins enabled."
|
||||
: `${pluginCount} plugin${pluginCount === 1 ? "" : "s"} enabled.`}
|
||||
</p>
|
||||
</div>
|
||||
{showPlugins ? (
|
||||
<ChevronDown className="w-4 h-4 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showPlugins && (
|
||||
<div className="space-y-2 pr-1">
|
||||
<div className="rounded-lg bg-slate-800/50 border border-slate-700 p-3">
|
||||
<p className="text-xs text-slate-400 leading-relaxed">
|
||||
Plugins are 3rd party add-ons. They are not maintained, endorsed, or supported by Meshtastic. Use at
|
||||
your own risk.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-slate-400 hover:text-white underline"
|
||||
onClick={() => setPluginConfig({})}
|
||||
disabled={pluginCount === 0}
|
||||
>
|
||||
Reset plugins
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-2" key={`plugins-${selectedTarget}`}>
|
||||
{(() => {
|
||||
// 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<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
|
||||
// Compute all enabled plugins (explicit + implicit)
|
||||
const allEnabledPlugins = getDependedPlugins(
|
||||
explicitPlugins,
|
||||
registryData as Record<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
|
||||
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<string, { dependencies?: Record<string, string> }>
|
||||
)
|
||||
// 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 (
|
||||
<PluginCard
|
||||
key={`${slug}-${selectedTarget}`}
|
||||
variant="link-toggle"
|
||||
id={slug}
|
||||
name={plugin.name}
|
||||
description={plugin.description}
|
||||
imageUrl={plugin.imageUrl}
|
||||
isEnabled={allEnabledPlugins.includes(slug)}
|
||||
onToggle={enabled => handleTogglePlugin(slug, enabled)}
|
||||
disabled={isImplicit || isIncompatible || 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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button onClick={handleFlash} disabled={isFlashDisabled} className="w-full bg-cyan-600 hover:bg-cyan-700">
|
||||
{isFlashing ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Queuing build...
|
||||
</span>
|
||||
) : (
|
||||
`Flash ${selectedTargetLabel || ""}`.trim() || "Flash"
|
||||
)}
|
||||
</Button>
|
||||
{errorMessage && <p className="text-sm text-red-400">{errorMessage}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -28,4 +28,4 @@ While the build tools themselves (PlatformIO, pip, etc.) still require internet
|
||||
|
||||
## Getting Started
|
||||
|
||||
Ready to build your custom firmware? Visit the [builder](https://meshforge.org/builds/new) to get started, or explore the [Plugin Registry](/docs/registry) to see what's available.
|
||||
Ready to build your custom firmware? Visit the [builder](https://meshforge.org/builds) to get started, or explore the [Plugin Registry](/docs/registry) to see what's available.
|
||||
|
||||
@@ -10,9 +10,9 @@ The registry contains plugins that add new features and capabilities to Meshtast
|
||||
|
||||
### Using Mesh Forge (Recommended)
|
||||
|
||||
The easiest way to use plugins is through [Mesh Forge](https://meshforge.org/builds/new), which lets you build custom firmware with plugins directly in your browser:
|
||||
The easiest way to use plugins is through [Mesh Forge](https://meshforge.org/builds), which lets you build custom firmware with plugins directly in your browser:
|
||||
|
||||
1. Visit [meshforge.org/builds/new](https://meshforge.org/builds/new)
|
||||
1. Visit [meshforge.org/builds](https://meshforge.org/builds)
|
||||
2. Browse available plugins from the registry
|
||||
3. Select the plugins you want to include
|
||||
4. Build your custom firmware
|
||||
|
||||
@@ -149,7 +149,7 @@ export default function LandingPage() {
|
||||
description={customBuildPlugin.description}
|
||||
imageUrl={customBuildPlugin.imageUrl}
|
||||
featured={false}
|
||||
href="/builds/new"
|
||||
href="/builds"
|
||||
prominent={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
7
pages/license/+Layout.tsx
Normal file
7
pages/license/+Layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
<article className="prose prose-invert lg:prose-xl max-w-none">{children}</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
pages/license/+Page.mdx
Normal file
36
pages/license/+Page.mdx
Normal file
@@ -0,0 +1,36 @@
|
||||
# MeshForge Licensing Notice
|
||||
|
||||
## 1. Generated Projects (the zip files you download)
|
||||
|
||||
Every project you download from MeshForge is a modified version of one or more upstream open-source code bases. The primary codebase is licensed under the GNU General Public License version 3 (GPLv3).
|
||||
|
||||
Because the GPLv3 is "viral", the entire combined work you receive — including the original GPLv3 code plus all patches, plugins, themes, or extensions added by MeshForge or selected by you — is licensed **exclusively** under the GNU GPLv3.
|
||||
|
||||
- You may use, modify, and redistribute the downloaded project **only** under the terms of the GPLv3 ([full text](https://www.gnu.org/licenses/gpl-3.0.html)).
|
||||
- No additional permissions or dual-licensing terms are granted for the combined work.
|
||||
- All original copyright and license notices are preserved inside the zip.
|
||||
|
||||
## 2. Plugins, patches, and extensions from the MeshForge registry
|
||||
|
||||
Many optional components available in our registry are originally licensed under MIT, Apache 2.0, BSD, ISC, or other licenses that are explicitly compatible with GPLv3.
|
||||
|
||||
We make every reasonable effort to ensure that only GPLv3-compatible components are offered in the registry. However, the final responsibility for verifying that every selected component is compatible with GPLv3 lies with you, the user.
|
||||
|
||||
Users who receive the combined project are always free to extract individual components that were originally under a GPLv3-compatible permissive license (MIT, Apache 2.0, BSD, etc.) and reuse those components under their original permissive license terms, provided they comply with those original licenses.
|
||||
|
||||
## 3. MeshForge website and generation tool
|
||||
|
||||
The MeshForge website and the software that downloads upstream code, applies customisations, combines selected plugins, and produces the final zip file are separate works that do not incorporate any GPLv3-covered code.
|
||||
|
||||
MeshForge.org is owned and operated by MeshEnvy NCC, a Nevada 501(c)(3) Nonprofit Corporation.
|
||||
|
||||
Copyright © 2025 MeshForge.org
|
||||
Licensed under the MIT License ([full text](https://opensource.org/licenses/MIT))
|
||||
|
||||
## Summary
|
||||
|
||||
- Downloaded projects (the complete zip) → GPLv3 only
|
||||
- Individual compatible components inside the zip → may still be reused under their original permissive license if extracted
|
||||
- MeshForge tool and website → MIT License
|
||||
|
||||
Questions or concerns? → [legal@meshforge.org](mailto:legal@meshforge.org)
|
||||
@@ -132,7 +132,7 @@ export default function PluginPage() {
|
||||
Homepage
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => navigate(`/builds/new?plugin=${slug}`)} className="bg-cyan-600 hover:bg-cyan-700">
|
||||
<Button onClick={() => navigate(`/builds?plugin=${slug}`)} className="bg-cyan-600 hover:bg-cyan-700">
|
||||
Build with this Plugin
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export default {
|
||||
prerender: false,
|
||||
}
|
||||
|
||||
8
pages/privacy/+Layout.tsx
Normal file
8
pages/privacy/+Layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
<article className="prose prose-invert lg:prose-xl max-w-none">{children}</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
108
pages/privacy/+Page.mdx
Normal file
108
pages/privacy/+Page.mdx
Normal file
@@ -0,0 +1,108 @@
|
||||
# Privacy Policy
|
||||
|
||||
**Last Updated: December 2025**
|
||||
|
||||
MeshForge.org is owned and operated by MeshEnvy NCC, a Nevada 501(c)(3) Nonprofit Corporation.
|
||||
|
||||
MeshForge ("we", "our", or "us") is committed to protecting your privacy. This Privacy Policy explains how we collect, use, and safeguard your information when you use our website and services.
|
||||
|
||||
## Information We Collect
|
||||
|
||||
### Account Information
|
||||
|
||||
When you sign in using GitHub OAuth, we collect:
|
||||
|
||||
- Your GitHub username and user ID
|
||||
- Your GitHub profile information (name, email if publicly available)
|
||||
- Authentication tokens provided by GitHub
|
||||
|
||||
### Build and Configuration Data
|
||||
|
||||
When you create firmware builds, we collect and store:
|
||||
|
||||
- Build configurations (selected modules, plugins, target hardware)
|
||||
- Build status and completion information
|
||||
- Build artifacts (firmware binaries and source code archives)
|
||||
- Build history and timestamps
|
||||
|
||||
### Usage Data
|
||||
|
||||
We automatically collect certain information when you use our service:
|
||||
|
||||
- Build requests and download activity
|
||||
- Plugin usage statistics
|
||||
- Timestamps of actions
|
||||
- IP addresses (for security and abuse prevention)
|
||||
|
||||
### Public Profiles
|
||||
|
||||
If you choose to make a build profile public, the following information may be visible to other users:
|
||||
|
||||
- Profile name and description
|
||||
- Build configuration
|
||||
- Flash/download counts
|
||||
|
||||
## How We Use Your Information
|
||||
|
||||
We use the information we collect to:
|
||||
|
||||
- Provide and maintain our firmware building service
|
||||
- Process your build requests and deliver firmware binaries
|
||||
- Store your build configurations for future use
|
||||
- Generate download statistics and usage analytics
|
||||
- Prevent abuse and ensure service security
|
||||
- Improve our service and user experience
|
||||
|
||||
## Data Storage and Security
|
||||
|
||||
- Your data is stored securely using Convex cloud infrastructure
|
||||
- Build artifacts are stored in cloud storage (R2/S3) with secure access controls
|
||||
- We implement industry-standard security measures to protect your data
|
||||
- Authentication is handled securely through GitHub OAuth
|
||||
|
||||
## Data Retention
|
||||
|
||||
- Build configurations and profiles are retained until you delete them
|
||||
- Build artifacts may be retained for a reasonable period to ensure availability
|
||||
- We may retain certain data for legal compliance and abuse prevention
|
||||
|
||||
## Your Rights
|
||||
|
||||
You have the right to:
|
||||
|
||||
- Access your personal data
|
||||
- Delete your account and associated data
|
||||
- Make your profiles private or public
|
||||
- Request a copy of your data
|
||||
|
||||
To exercise these rights, contact us at [legal@meshforge.org](mailto:legal@meshforge.org).
|
||||
|
||||
## Third-Party Services
|
||||
|
||||
We use the following third-party services:
|
||||
|
||||
- **Convex**: Backend database and authentication services
|
||||
- **GitHub**: OAuth authentication provider
|
||||
- **Cloud Storage Providers**: For storing build artifacts
|
||||
|
||||
These services have their own privacy policies. We recommend reviewing them.
|
||||
|
||||
## Cookies and Tracking
|
||||
|
||||
We use minimal cookies and tracking technologies:
|
||||
|
||||
- Authentication session cookies (required for service functionality)
|
||||
- No third-party advertising or marketing cookies
|
||||
- No cross-site tracking
|
||||
|
||||
## Children's Privacy
|
||||
|
||||
Our service is not intended for users under the age of 13. We do not knowingly collect personal information from children under 13.
|
||||
|
||||
## Changes to This Policy
|
||||
|
||||
We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last Updated" date.
|
||||
|
||||
## Contact Us
|
||||
|
||||
If you have questions about this Privacy Policy, please contact us at [legal@meshforge.org](mailto:legal@meshforge.org).
|
||||
8
pages/terms/+Layout.tsx
Normal file
8
pages/terms/+Layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
<article className="prose prose-invert lg:prose-xl max-w-none">{children}</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
123
pages/terms/+Page.mdx
Normal file
123
pages/terms/+Page.mdx
Normal file
@@ -0,0 +1,123 @@
|
||||
# Terms of Service
|
||||
|
||||
**Last Updated: January 2025**
|
||||
|
||||
MeshForge.org is owned and operated by MeshEnvy NCC, a Nevada 501(c)(3) Nonprofit Corporation.
|
||||
|
||||
Please read these Terms of Service ("Terms") carefully before using MeshForge ("the Service") operated by MeshForge.org ("we", "us", or "our").
|
||||
|
||||
## Acceptance of Terms
|
||||
|
||||
By accessing or using MeshForge, you agree to be bound by these Terms. If you disagree with any part of these terms, you may not access the Service.
|
||||
|
||||
## Description of Service
|
||||
|
||||
MeshForge is a cloud-based firmware builder that allows you to:
|
||||
|
||||
- Create custom Meshtastic firmware builds
|
||||
- Select modules and plugins for inclusion
|
||||
- Download firmware binaries and source code
|
||||
- Share build configurations with others
|
||||
|
||||
## User Accounts
|
||||
|
||||
### Account Requirements
|
||||
|
||||
- You must be at least 13 years old to use this Service
|
||||
- You must provide accurate and complete information when creating an account
|
||||
- You are responsible for maintaining the security of your account
|
||||
- You are responsible for all activities that occur under your account
|
||||
|
||||
### GitHub Authentication
|
||||
|
||||
- MeshForge uses GitHub OAuth for authentication
|
||||
- By signing in, you authorize us to access your GitHub account information
|
||||
- You must comply with GitHub's Terms of Service
|
||||
|
||||
## Acceptable Use
|
||||
|
||||
You agree not to:
|
||||
|
||||
- Use the Service for any illegal purpose or in violation of any laws
|
||||
- Attempt to gain unauthorized access to the Service or related systems
|
||||
- Interfere with or disrupt the Service or servers
|
||||
- Use the Service to build firmware that violates third-party rights
|
||||
- Abuse, harass, or harm other users
|
||||
- Upload malicious code or attempt to exploit vulnerabilities
|
||||
- Use automated systems to access the Service without permission
|
||||
|
||||
## Build Limitations
|
||||
|
||||
- Builds are subject to resource limitations and may be rate-limited
|
||||
- We reserve the right to reject or cancel builds that violate these Terms
|
||||
- Build artifacts may be removed after a reasonable retention period
|
||||
- We are not responsible for firmware that fails to compile or function
|
||||
|
||||
## Intellectual Property
|
||||
|
||||
### Your Content
|
||||
|
||||
- You retain ownership of build configurations you create
|
||||
- You grant us a license to store, process, and serve your builds
|
||||
- Public profiles may be visible to other users
|
||||
|
||||
### MeshForge Content
|
||||
|
||||
- The MeshForge website and tooling are licensed under the MIT License
|
||||
- Generated firmware projects are licensed under GPLv3 (see [License](/license))
|
||||
- Third-party plugins retain their original licenses
|
||||
|
||||
### Third-Party Code
|
||||
|
||||
- Generated firmware includes upstream Meshtastic code (GPLv3)
|
||||
- Plugins may have their own licenses
|
||||
- You are responsible for complying with all applicable licenses
|
||||
|
||||
## Disclaimers
|
||||
|
||||
### Service Availability
|
||||
|
||||
- The Service is provided "as is" and "as available"
|
||||
- We do not guarantee uninterrupted or error-free operation
|
||||
- We reserve the right to modify or discontinue the Service at any time
|
||||
|
||||
### Firmware Disclaimer
|
||||
|
||||
- **USE AT YOUR OWN RISK**: Firmware built through MeshForge may not function correctly
|
||||
- We are not responsible for damage to hardware or data loss
|
||||
- We do not guarantee compatibility with any specific hardware
|
||||
- Always test firmware in a safe environment before deployment
|
||||
|
||||
### No Warranty
|
||||
|
||||
THE SERVICE IS PROVIDED WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT.
|
||||
|
||||
## Limitation of Liability
|
||||
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY LAW, MESHFORGE.ORG AND MESHENVY NCC SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS OR REVENUES, WHETHER INCURRED DIRECTLY OR INDIRECTLY, OR ANY LOSS OF DATA, USE, GOODWILL, OR OTHER INTANGIBLE LOSSES.
|
||||
|
||||
## Indemnification
|
||||
|
||||
You agree to indemnify and hold harmless MeshForge.org and MeshEnvy NCC from any claims, damages, losses, liabilities, and expenses (including legal fees) arising out of your use of the Service or violation of these Terms.
|
||||
|
||||
## Termination
|
||||
|
||||
We may terminate or suspend your account immediately, without prior notice, for conduct that we believe violates these Terms or is harmful to other users, us, or third parties.
|
||||
|
||||
You may terminate your account at any time by contacting us or deleting your account through the Service.
|
||||
|
||||
## Changes to Terms
|
||||
|
||||
We reserve the right to modify these Terms at any time. We will notify users of material changes by posting the updated Terms on this page. Your continued use of the Service after changes constitutes acceptance of the new Terms.
|
||||
|
||||
## Governing Law
|
||||
|
||||
These Terms shall be governed by and construed in accordance with the laws of the jurisdiction in which MeshForge.org operates, without regard to its conflict of law provisions.
|
||||
|
||||
## Severability
|
||||
|
||||
If any provision of these Terms is found to be unenforceable or invalid, that provision shall be limited or eliminated to the minimum extent necessary, and the remaining provisions shall remain in full force and effect.
|
||||
|
||||
## Contact Information
|
||||
|
||||
If you have questions about these Terms, please contact us at [legal@meshforge.org](mailto:legal@meshforge.org).
|
||||
@@ -9,14 +9,6 @@ const FIRMWARE_DIR = path.resolve(__dirname, "../vendor/firmware")
|
||||
const VARIANTS_DIR = path.join(FIRMWARE_DIR, "variants")
|
||||
const OUTPUT_FILE = path.resolve(__dirname, "../constants/architecture-hierarchy.json")
|
||||
|
||||
/**
|
||||
* Normalize architecture/target name (remove hyphens and underscores)
|
||||
* This ensures consistent format matching PlatformIO architecture names
|
||||
*/
|
||||
function normalizeName(name) {
|
||||
return name.replace(/[-_]/g, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse PlatformIO ini file to extract sections and their properties
|
||||
*/
|
||||
@@ -342,15 +334,8 @@ function buildParentMapping() {
|
||||
delete resolvedParentMap[key]
|
||||
}
|
||||
|
||||
// Normalize all keys and values (strip hyphens and underscores)
|
||||
const normalizedMap = {}
|
||||
for (const [key, value] of Object.entries(resolvedParentMap)) {
|
||||
const normalizedKey = normalizeName(key)
|
||||
const normalizedValue = value !== null ? normalizeName(value) : null
|
||||
normalizedMap[normalizedKey] = normalizedValue
|
||||
}
|
||||
|
||||
return normalizedMap
|
||||
// Return map with actual PlatformIO environment names (no normalization)
|
||||
return resolvedParentMap
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
1
vendor/api
vendored
1
vendor/api
vendored
Submodule vendor/api deleted from 1774354d2b
1
vendor/firmware
vendored
1
vendor/firmware
vendored
Submodule vendor/firmware deleted from aa72e397f2
1
vendor/lofs
vendored
Submodule
1
vendor/lofs
vendored
Submodule
Submodule vendor/lofs added at 6e6ac2b367
1
vendor/meshcore
vendored
1
vendor/meshcore
vendored
Submodule vendor/meshcore deleted from 6d3219329f
1
vendor/web-flasher
vendored
1
vendor/web-flasher
vendored
Submodule vendor/web-flasher deleted from c165572117
Reference in New Issue
Block a user