feat: add BuildProgress component and enhance BuildDownloadButton with compact mode

This commit is contained in:
Ben Allfree
2025-12-10 04:23:32 -08:00
parent 83dc49dc0d
commit 78fa319ff9
15 changed files with 953 additions and 602 deletions

View File

@@ -12,9 +12,10 @@ interface BuildDownloadButtonProps {
type: ArtifactType
variant?: "default" | "outline"
className?: string
compact?: boolean
}
export function BuildDownloadButton({ build, type, variant, className }: BuildDownloadButtonProps) {
export function BuildDownloadButton({ build, type, variant, className, compact = false }: BuildDownloadButtonProps) {
const generateDownloadUrl = useMutation(api.builds.generateDownloadUrl)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -50,11 +51,54 @@ export function BuildDownloadButton({ build, type, variant, className }: BuildDo
if (type === ArtifactType.Firmware && !build.buildHash) return null
const buttonElement = (
<Button onClick={handleDownload} disabled={isLoading} variant={defaultVariant} className={defaultClassName}>
{type === ArtifactType.Firmware && (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
className="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
>
<rect width="3" height="4.5" x="3.25" y="1.75" />
<path d="m9.75 6.25h3m-3-4.5h1.5v4" />
<rect width="3" height="4.5" x="9.75" y="9.75" />
<path d="m3.25 14.25h3m-3-4.5h1.5v4" />
</svg>
)}
{type === ArtifactType.Source && (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
className="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M9.5 5H9a2 2 0 0 0-2 2v2c0 1-.6 3-3 3c1 0 3 .6 3 3v2a2 2 0 0 0 2 2h.5m5-14h.5a2 2 0 0 1 2 2v2c0 1 .6 3 3 3c-1 0-3 .6-3 3v2a2 2 0 0 1-2 2h-.5" />
</svg>
)}
Download {type === ArtifactType.Firmware ? "firmware" : "source"}
</Button>
)
if (compact) {
return buttonElement
}
const button = (
<div className="space-y-2">
<Button onClick={handleDownload} disabled={isLoading} variant={defaultVariant} className={defaultClassName}>
Download {type === ArtifactType.Firmware ? "firmware" : "source"}
</Button>
{buttonElement}
{type === ArtifactType.Firmware && (
<p className="text-xs text-slate-400 text-center">
Need help flashing?{" "}

View File

@@ -0,0 +1,541 @@
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 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 { useState } from "react"
import { toast } from "sonner"
import { navigate } from "vike/client/router"
interface BuildProgressProps {
build: Doc<"builds">
isAdmin?: boolean
onRetry?: (buildId: Doc<"builds">["_id"]) => Promise<void>
showActions?: boolean
}
export function BuildProgress({ build, isAdmin = false, onRetry, showActions = true }: BuildProgressProps) {
const [shareUrlCopied, setShareUrlCopied] = useState(false)
const [bashCopied, setBashCopied] = useState(false)
const [showBashModal, setShowBashModal] = useState(false)
const targetMeta = build.config.target ? TARGETS[build.config.target] : undefined
const targetLabel = targetMeta?.name ?? build.config.target
const status = build.status || "queued"
const getStatusIcon = () => {
if (status === "success") {
return <CheckCircle className="w-6 h-6 text-green-500" />
}
if (status === "failure") {
return <XCircle className="w-6 h-6 text-red-500" />
}
return <Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
}
const getStatusColor = () => {
if (status === "success") return "text-green-400"
if (status === "failure") return "text-red-400"
return "text-blue-400"
}
const githubActionUrl =
build.githubRunId && build.githubRunId > 0
? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`
: null
const shareUrl = `${window.location.origin}/builds/new/${build.buildHash}`
const handleShare = async () => {
try {
await navigator.clipboard.writeText(shareUrl)
setShareUrlCopied(true)
toast.success("Share link copied to clipboard")
setTimeout(() => setShareUrlCopied(false), 2000)
} catch {
toast.error("Failed to copy link", {
description: "Please copy the URL manually",
})
}
}
const generateBashCommand = (): string => {
const flags = computeFlagsFromConfig(build.config)
const target = build.config.target
const version = build.config.version
const plugins = build.config.pluginsEnabled || []
const commands = []
// Generate directory name matching the download filename format (without .tar.gz extension)
const dirName = getArtifactFilenameBase(version, target, build.buildHash, build.githubRunId, "source")
// Clone firmware repository into named directory
commands.push(`git clone --recursive https://github.com/meshtastic/firmware.git ${dirName}`)
commands.push(`cd ${dirName}`)
// Checkout the specific version
commands.push(`git checkout ${version}`)
// Update submodules after checkout
commands.push(`git submodule update --init --recursive`)
// Install PlatformIO if not already installed
commands.push(`pip install platformio`)
// Install mesh-plugin-manager
commands.push(`pip install mesh-plugin-manager`)
// Initialize mpm
commands.push(`mpm init`)
// Install plugins if any
if (plugins.length > 0) {
const pluginSlugs = plugins.map(plugin => {
// Extract slug from "slug@version" format if present
return plugin.includes("@") ? plugin.split("@")[0] : plugin
})
commands.push(`mpm install ${pluginSlugs.join(" ")}`)
}
// Set build flags and build
if (flags) {
commands.push(`export PLATFORMIO_BUILD_FLAGS="${flags}"`)
}
commands.push(`pio run -e ${target}`)
return commands.join("\n")
}
const handleOpenBashModal = () => {
setBashCopied(false)
setShowBashModal(true)
}
const handleCopyBashFromModal = async () => {
try {
const bashCommand = generateBashCommand()
await navigator.clipboard.writeText(bashCommand)
setBashCopied(true)
toast.success("Bash command copied to clipboard")
setTimeout(() => setBashCopied(false), 2000)
} catch {
toast.error("Failed to copy command", {
description: "Please copy the command manually",
})
}
}
// 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 {
await onRetry(build._id)
} catch (error) {
toast.error("Failed to retry build", {
description: String(error),
})
}
}
// Get excluded modules
const excludedModules = modulesData.modules.filter(module => build.config.modulesExcluded[module.id] === true)
// Get explicitly selected plugins from stored config
// The stored config only contains explicitly selected plugins (not resolved dependencies)
const explicitPluginSlugs = (build.config.pluginsEnabled || []).map(pluginId => {
// Extract slug from "slug@version" format if present
return pluginId.includes("@") ? pluginId.split("@")[0] : pluginId
})
// Get implicit dependencies (dependencies that are not explicitly selected)
const implicitDeps = getImplicitDependencies(
explicitPluginSlugs,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Separate explicit and implicit plugins
const explicitPlugins: Array<{
id: string
name: string
description: string
version: string
}> = []
const implicitPlugins: Array<{
id: string
name: string
description: string
version: string
}> = []
// Process explicitly selected plugins
;(build.config.pluginsEnabled || []).forEach(pluginId => {
// Extract slug from "slug@version" format if present
const slug = pluginId.includes("@") ? pluginId.split("@")[0] : pluginId
const pluginData = (registryData as Record<string, { name: string; description: string; version: string }>)[slug]
const pluginInfo = {
id: slug,
name: pluginData?.name || slug,
description: pluginData?.description || "",
version: pluginId.includes("@") ? pluginId.split("@")[1] : pluginData?.version || "",
}
explicitPlugins.push(pluginInfo)
})
// Process implicit dependencies (resolved but not in stored config)
for (const slug of implicitDeps) {
const pluginData = (registryData as Record<string, { name: string; description: string; version: string }>)[slug]
if (pluginData) {
implicitPlugins.push({
id: slug,
name: pluginData.name || slug,
description: pluginData.description || "",
version: pluginData.version || "",
})
}
}
return (
<div className="bg-slate-900/60 rounded-lg border border-slate-800 p-6 space-y-6">
{/* Header Section */}
<div className="flex items-center gap-4">
<div className="flex-1">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h2 className="text-2xl font-semibold mb-2 flex items-center gap-2">
{getStatusIcon()}
{targetLabel}
{status !== "success" && status !== "failure" && (
<button
type="button"
onClick={() => {
if (githubActionUrl) {
window.open(githubActionUrl, "_blank", "noopener,noreferrer")
}
}}
disabled={!githubActionUrl}
className={`inline-flex items-center gap-1 px-3 py-0.5 rounded-full text-xs font-semibold ${getStatusColor()} bg-slate-800 border border-slate-700 hover:bg-slate-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed`}
style={{ cursor: githubActionUrl ? "pointer" : "not-allowed" }}
title={githubActionUrl ? "View GitHub Actions run" : "GitHub run not available"}
>
{humanizeStatus(status)}
</button>
)}
</h2>
<div className="flex items-center gap-2 text-slate-400 text-sm">
<span className="font-mono">{build.config.target}</span>
<span></span>
<span>v{build.config.version}</span>
<span></span>
<span>
{build.completedAt
? new Date(build.completedAt).toLocaleString()
: new Date(build.updatedAt).toLocaleString()}
</span>
</div>
</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}`)}
variant="outline"
className="border-slate-600 hover:bg-slate-800"
aria-label="Clone"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 512 512"
className="w-4 h-4"
fill="currentColor"
>
<path d="M472 16H160a24.027 24.027 0 0 0-24 24v312a24.027 24.027 0 0 0 24 24h312a24.027 24.027 0 0 0 24-24V40a24.027 24.027 0 0 0-24-24m-8 328H168V48h296Z" />
<path d="M344 464H48V168h56v-32H40a24.027 24.027 0 0 0-24 24v312a24.027 24.027 0 0 0 24 24h312a24.027 24.027 0 0 0 24-24v-64h-32Z" />
</svg>
</Button>
<Button
onClick={handleShare}
variant="outline"
className="border-slate-600 hover:bg-slate-800"
aria-label={shareUrlCopied ? "Copied!" : "Share Build"}
>
<Share2 className="w-4 h-4" />
</Button>
</div>
)}
</div>
{excludedModules.length > 0 && (
<div className="flex flex-wrap items-center gap-2 text-sm mt-2">
<span className="text-slate-500">Excluded Modules:</span>
{excludedModules.map((module, index) => (
<span key={module.id}>
<span className="text-slate-300">{module.name}</span>
{index < excludedModules.length - 1 && <span className="text-slate-500">,</span>}
</span>
))}
</div>
)}
{(explicitPlugins.length > 0 || implicitPlugins.length > 0) && (
<div className="flex flex-wrap items-center gap-2 text-sm mt-2">
<span className="text-slate-500">Plugins:</span>
{explicitPlugins.map((plugin, index) => (
<span key={plugin.id}>
<a href={`/plugins/${plugin.id}`} className="text-cyan-400 hover:text-cyan-300 underline">
{plugin.name}
</a>
{(index < explicitPlugins.length - 1 || implicitPlugins.length > 0) && (
<span className="text-slate-500">,</span>
)}
</span>
))}
{implicitPlugins.map((plugin, index) => (
<span key={plugin.id}>
<a href={`/plugins/${plugin.id}`} className="text-cyan-400 hover:text-cyan-300 underline">
{plugin.name}
</a>
{index < implicitPlugins.length - 1 && <span className="text-slate-500">,</span>}
</span>
))}
</div>
)}
{build && (build.githubRunId || (build.githubRunIdHistory?.length ?? 0) > 0) && (
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm">
<span className="text-slate-500">
Run History
{(build.githubRunIdHistory?.length ?? 0) > 0 &&
` (${(build.githubRunIdHistory?.length ?? 0) + (build.githubRunId ? 1 : 0)} total)`}
</span>
{build.githubRunId && (
<a
href={`https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`}
target="_blank"
rel="noopener noreferrer"
className="text-cyan-400 hover:text-cyan-300 underline font-semibold"
title="Current run"
>
{build.githubRunId}
</a>
)}
{build.githubRunIdHistory?.map(id => (
<a
key={id}
href={`https://github.com/MeshEnvy/mesh-forge/actions/runs/${id}`}
target="_blank"
rel="noopener noreferrer"
className="text-cyan-400 hover:text-cyan-300 underline"
>
{id}
</a>
))}
</div>
)}
</div>
</div>
{/* All Actions */}
{showActions && (
<div className="flex flex-wrap gap-2">
{status === "success" && build.buildHash && (
<BuildDownloadButton
build={build}
type={ArtifactType.Firmware}
className="bg-cyan-600 hover:bg-cyan-700"
compact={true}
/>
)}
{build.buildHash && (
<>
<BuildDownloadButton
build={build}
type={ArtifactType.Source}
variant="outline"
className="bg-slate-700 hover:bg-slate-600"
compact={true}
/>
<Button onClick={handleOpenBashModal} variant="outline" className="bg-slate-700 hover:bg-slate-600">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
className="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
>
<path d="m6.75 7.5l3 2.25l-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25" />
</svg>
Local Build Script (Bash)
</Button>
</>
)}
{isAdmin && build && onRetry && (
<Button onClick={handleRetry} className="bg-cyan-600 hover:bg-cyan-700">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
className="w-4 h-4 mr-2"
fill="currentColor"
>
<path d="M11.896 18a.75.75 0 0 1-.75.75c-3.792 0-6.896-3.005-6.896-6.75s3.104-6.75 6.896-6.75c3.105 0 5.749 2.015 6.605 4.801l.603-1.02a.75.75 0 0 1 1.292.763l-1.63 2.755a.75.75 0 0 1-1.014.272L14.18 11.23a.75.75 0 1 1 .737-1.307l1.472.83c-.574-2.288-2.691-4.003-5.242-4.003C8.149 6.75 5.75 9.117 5.75 12s2.399 5.25 5.396 5.25a.75.75 0 0 1 .75.75" />
</svg>
Retry Build
</Button>
)}
</div>
)}
{/* Status Messages */}
{status === "failure" && (
<div className="rounded-lg border border-red-500/40 bg-red-500/10 p-4 text-sm text-red-100">
<p className="font-medium text-red-200">
Build failed. Please try tweaking your configuration or re-running the build.
</p>
</div>
)}
{status !== "success" && status !== "failure" && (
<div className="rounded-lg border border-blue-500/30 bg-blue-500/5 p-4 text-sm text-blue-100">
<p className="font-medium text-blue-200">
This build is still running. Leave this tab open or come back later using the URL above.
</p>
</div>
)}
{/* Bash Script Modal */}
{showBashModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => {
setShowBashModal(false)
setBashCopied(false)
}}
>
<div
className="bg-slate-900 border border-slate-700 rounded-lg shadow-xl max-w-3xl w-full mx-4 max-h-[80vh] flex flex-col"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-slate-800">
<h3 className="text-lg font-semibold text-white">Build Script</h3>
<button
onClick={() => {
setShowBashModal(false)
setBashCopied(false)
}}
className="text-slate-400 hover:text-white transition-colors"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-4 overflow-auto flex-1">
<pre className="bg-slate-950 border border-slate-800 rounded p-4 text-sm text-slate-300 font-mono overflow-x-auto">
<code>{generateBashCommand()}</code>
</pre>
</div>
<div className="flex items-center justify-end gap-2 p-4 border-t border-slate-800">
<Button
onClick={() => {
setShowBashModal(false)
setBashCopied(false)
}}
variant="outline"
className="border-slate-600 hover:bg-slate-800"
>
Close
</Button>
<Button onClick={handleCopyBashFromModal} className="bg-cyan-600 hover:bg-cyan-700">
{bashCopied ? (
<>
<CheckCircle className="w-4 h-4 mr-2" />
Copied!
</>
) : (
<>
<Copy className="w-4 h-4 mr-2" />
Copy Script
</>
)}
</Button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -251,7 +251,7 @@ export function PluginCard(props: PluginCardProps) {
e.stopPropagation()
navigate(`/builds/new?plugin=${id}`)
}}
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 rounded hover:bg-cyan-400/20 transition-colors"
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"
>
<Zap className="w-3 h-3" />
Build Now

View File

@@ -14,6 +14,7 @@ import type * as auth from "../auth.js";
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_r2 from "../lib/r2.js";
import type * as plugins from "../plugins.js";
import type * as profiles from "../profiles.js";
@@ -31,6 +32,7 @@ declare const fullApi: ApiFromModules<{
builds: typeof builds;
helpers: typeof helpers;
http: typeof http;
"lib/filename": typeof lib_filename;
"lib/r2": typeof lib_r2;
plugins: typeof plugins;
profiles: typeof profiles;

View File

@@ -25,7 +25,7 @@ export const listFailedBuilds = adminQuery({
handler: async ctx => {
const failedBuilds = await ctx.db
.query("builds")
.withIndex("by_status", q => q.eq("status", "failure"))
.withIndex("by_status_updatedAt", q => q.eq("status", "failure"))
.order("desc")
.collect()
@@ -36,7 +36,7 @@ export const listFailedBuilds = adminQuery({
export const listAllBuilds = adminQuery({
args: {},
handler: async ctx => {
const allBuilds = await ctx.db.query("builds").order("desc").collect()
const allBuilds = await ctx.db.query("builds").withIndex("by_updatedAt").order("desc").collect()
return allBuilds
},

View File

@@ -3,6 +3,7 @@ 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 { generateSignedDownloadUrl } from "./lib/r2"
import { buildFields } from "./schema"
@@ -14,6 +15,7 @@ export enum ArtifactType {
type BuildUpdateData = {
status: string
completedAt?: number
updatedAt?: number
}
export const get = query({
@@ -269,6 +271,7 @@ export const updateBuildStatus = internalMutation({
sourcePath?: string
} = {
status: args.status,
updatedAt: Date.now(),
}
// Only set completedAt for final statuses
@@ -374,17 +377,23 @@ export const generateDownloadUrl = mutation({
})
}
const last4Hash = build.buildHash.slice(-4)
const os = "meshtastic" // OS/platform identifier
const version = build.config.version
const target = build.config.target
const jobId = build.githubRunId
// Generate base filename using shared utility function
const filenameBase = getArtifactFilenameBase(
build.config.version,
build.config.target,
build.buildHash,
build.githubRunId,
artifactTypeStr as "source" | "firmware"
)
// Add profile slug if present (inserted between version and target)
// Format: {os}-{version}-{profileSlug}-{target}-{last4hash}-{jobId}-{assetType}.tar.gz
// If no profile, omit profileSlug and its trailing dash
const filename = profileSlug
? `${os}-${version}-${profileSlug}-${target}-${last4Hash}-${jobId}-${artifactTypeStr}.tar.gz`
: `${os}-${version}-${target}-${last4Hash}-${jobId}-${artifactTypeStr}.tar.gz`
? filenameBase.replace(
`meshtastic-${build.config.version}-`,
`meshtastic-${build.config.version}-${profileSlug}-`
) + ".tar.gz"
: filenameBase + ".tar.gz"
return await generateSignedDownloadUrl(objectKey, filename, contentType)
},

29
convex/lib/filename.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* Generates the artifact filename base (without extension) matching the download filename format.
* Format: meshtastic-{version}-{target}-{last4hash}-{jobId}-{artifactType}
* This is the canonical implementation used by both Convex backend and frontend.
*
* @param version - The firmware version
* @param target - The target board name
* @param buildHash - The build hash (used to get last 4 characters)
* @param githubRunId - The GitHub Actions run ID (optional, but required for actual downloads)
* @param artifactType - Either "source" or "firmware"
* @returns The filename base without extension (e.g., "meshtastic-v2.7.16-tbeam-a1b2-1234567890-source")
*/
export function getArtifactFilenameBase(
version: string,
target: string,
buildHash: string,
githubRunId: number | undefined,
artifactType: "source" | "firmware"
): string {
const os = "meshtastic"
const last4Hash = buildHash.slice(-4)
if (!githubRunId) {
// If no githubRunId, fallback format (for builds that haven't completed yet)
return `${os}-${version}-${target}-${last4Hash}-${artifactType}`
}
return `${os}-${version}-${target}-${last4Hash}-${githubRunId}-${artifactType}`
}

View File

@@ -50,7 +50,11 @@ export const userSettingsFields = {
export const schema = defineSchema({
...authTables,
profiles: defineTable(profileFields).index("by_userId", ["userId"]).index("by_isPublic", ["isPublic"]),
builds: defineTable(buildFields).index("by_buildHash", ["buildHash"]).index("by_status", ["status"]),
builds: defineTable(buildFields)
.index("by_buildHash", ["buildHash"])
.index("by_status", ["status"])
.index("by_updatedAt", ["updatedAt"])
.index("by_status_updatedAt", ["status", "updatedAt"]),
plugins: defineTable(pluginFields).index("by_slug", ["slug"]),
userSettings: defineTable(userSettingsFields).index("by_user", ["userId"]),
})

View File

@@ -1,17 +1,30 @@
import { BuildDownloadButton } from "@/components/BuildDownloadButton"
import { BuildProgress } from "@/components/BuildProgress"
import { Button } from "@/components/ui/button"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { ArtifactType } from "@/convex/builds"
import { useMutation, useQuery } from "convex/react"
import { useState } from "react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { navigate } from "vike/client/router"
type FilterType = "all" | "failed"
const FILTER_STORAGE_KEY = "admin-build-filter"
export default function Admin() {
const [filter, setFilter] = useState<FilterType>("failed")
const [filter, setFilter] = useState<FilterType>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem(FILTER_STORAGE_KEY)
if (saved === "all" || saved === "failed") {
return saved
}
}
return "failed"
})
useEffect(() => {
localStorage.setItem(FILTER_STORAGE_KEY, filter)
}, [filter])
const isAdmin = useQuery(api.admin.isAdmin)
const failedBuilds = useQuery(api.admin.listFailedBuilds)
const allBuilds = useQuery(api.admin.listAllBuilds)
@@ -51,35 +64,10 @@ export default function Admin() {
toast.error("Failed to retry build", {
description: String(error),
})
throw error
}
}
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleString()
}
const getStatusBadge = (status: string) => {
const statusConfig = {
success: {
bg: "bg-green-500/20",
text: "text-green-400",
label: "Success",
},
failure: { bg: "bg-red-500/20", text: "text-red-400", label: "Failed" },
queued: {
bg: "bg-yellow-500/20",
text: "text-yellow-400",
label: "Queued",
},
}
const config = statusConfig[status as keyof typeof statusConfig] || {
bg: "bg-slate-500/20",
text: "text-slate-400",
label: status,
}
return <span className={`px-2 py-1 ${config.bg} ${config.text} rounded text-sm`}>{config.label}</span>
}
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<header className="mb-8">
@@ -113,106 +101,8 @@ export default function Admin() {
) : (
<div className="space-y-4">
{builds.map(build => (
<div key={build._id} className="bg-slate-900 border border-slate-800 rounded-lg p-6">
{/* Header Section */}
<div className="flex items-center justify-between mb-4 pb-4 border-b border-slate-800">
<div className="flex items-center gap-3">
<span className="text-xl font-mono font-semibold text-white">
{build.buildHash.substring(0, 8)}
</span>
{getStatusBadge(build.status)}
</div>
<div className="flex gap-2">
<Button
onClick={() => navigate(`/builds/${build.buildHash}`)}
variant="outline"
size="sm"
className="border-slate-600 hover:bg-slate-800"
>
Public View
</Button>
<Button
onClick={() => navigate(`/builds/new/${build.buildHash}`)}
variant="outline"
size="sm"
className="border-slate-600 hover:bg-slate-800"
>
Clone
</Button>
<Button onClick={() => handleRetry(build._id)} className="bg-cyan-600 hover:bg-cyan-700" size="sm">
Re-run Build
</Button>
</div>
</div>
{/* Build Configuration Section */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="space-y-2">
<div>
<span className="text-sm text-slate-500">Target</span>
<div className="text-sm font-mono text-white mt-1">{build.config.target}</div>
</div>
<div>
<span className="text-sm text-slate-500">Version</span>
<div className="text-sm font-mono text-white mt-1">{build.config.version}</div>
</div>
</div>
<div className="space-y-2">
<div>
<span className="text-sm text-slate-500">{build.completedAt ? "Completed" : "Started"}</span>
<div className="text-sm text-white mt-1">
{build.completedAt
? formatDate(build.completedAt)
: build.startedAt
? formatDate(build.startedAt)
: "Unknown"}
</div>
</div>
</div>
</div>
{/* Run History Section */}
{(build.githubRunId || (build.githubRunIdHistory?.length ?? 0) > 0) && (
<div className="mb-4 pb-4 border-b border-slate-800">
<span className="text-xs text-slate-500 mb-2 block">
Run History
{(build.githubRunIdHistory?.length ?? 0) > 0 &&
` (${(build.githubRunIdHistory?.length ?? 0) + (build.githubRunId ? 1 : 0)} total)`}
</span>
<div className="flex flex-wrap gap-2">
{build.githubRunId && (
<a
href={`https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-cyan-400 hover:text-cyan-300 underline font-semibold"
title="Current run"
>
{build.githubRunId}
</a>
)}
{build.githubRunIdHistory?.map(id => (
<a
key={id}
href={`https://github.com/MeshEnvy/mesh-forge/actions/runs/${id}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-cyan-400 hover:text-cyan-300 underline"
>
{id}
</a>
))}
</div>
</div>
)}
{/* Download Actions */}
{build.buildHash && (
<div className="flex gap-3">
{build.status === "success" && <BuildDownloadButton build={build} type={ArtifactType.Firmware} />}
<BuildDownloadButton build={build} type={ArtifactType.Source} />
</div>
)}
<div key={build._id} className="space-y-4">
<BuildProgress build={build} isAdmin={true} onRetry={handleRetry} />
</div>
))}
</div>

View File

@@ -1,25 +1,16 @@
import { BuildDownloadButton } from "@/components/BuildDownloadButton"
import { Button } from "@/components/ui/button"
import { TARGETS } from "@/constants/targets"
import { BuildProgress } from "@/components/BuildProgress"
import { api } from "@/convex/_generated/api"
import { ArtifactType } from "@/convex/builds"
import modulesData from "@/convex/modules.json"
import { getImplicitDependencies, humanizeStatus } from "@/lib/utils"
import registryData from "@/public/registry.json"
import { useMutation, useQuery } from "convex/react"
import { AlertCircle, ArrowLeft, CheckCircle, Loader2, Share2, XCircle } from "lucide-react"
import { useState } from "react"
import { Loader2 } from "lucide-react"
import { toast } from "sonner"
import { usePageContext } from "vike-react/usePageContext"
import { navigate } from "vike/client/router"
export default function BuildProgress() {
export default function BuildProgressPage() {
const pageContext = usePageContext()
const buildHash = pageContext.routeParams?.buildHash as string | undefined
const build = useQuery(api.builds.getByHash, buildHash ? { buildHash } : "skip")
const isAdmin = useQuery(api.admin.isAdmin)
const retryBuild = useMutation(api.admin.retryBuild)
const [shareUrlCopied, setShareUrlCopied] = useState(false)
if (!buildHash) {
return (
@@ -49,10 +40,6 @@ export default function BuildProgress() {
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto space-y-4">
<a href={`/builds/new/${buildHash}`} className="inline-flex items-center text-slate-400 hover:text-white">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Quick Build
</a>
<div className="bg-slate-900/60 border border-slate-800 rounded-lg p-6">
<p className="text-slate-300">
No build found for hash <span className="font-mono">{buildHash}</span>
@@ -63,113 +50,9 @@ export default function BuildProgress() {
)
}
const targetMeta = build.config.target ? TARGETS[build.config.target] : undefined
const targetLabel = targetMeta?.name ?? build.config.target
const status = build.status || "queued"
const getStatusIcon = () => {
if (status === "success") {
return <CheckCircle className="w-6 h-6 text-green-500" />
}
if (status === "failure") {
return <XCircle className="w-6 h-6 text-red-500" />
}
return <Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
}
const getStatusColor = () => {
if (status === "success") return "text-green-400"
if (status === "failure") return "text-red-400"
return "text-blue-400"
}
const githubActionUrl =
build.githubRunId && build.githubRunId > 0
? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`
: null
const shareUrl = `${window.location.origin}/builds/new/${build.buildHash}`
const handleShare = async () => {
const handleRetry = async (buildId: typeof build._id) => {
try {
await navigator.clipboard.writeText(shareUrl)
setShareUrlCopied(true)
toast.success("Share link copied to clipboard")
setTimeout(() => setShareUrlCopied(false), 2000)
} catch {
toast.error("Failed to copy link", {
description: "Please copy the URL manually",
})
}
}
// 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) return
try {
await retryBuild({ buildId: build._id })
await retryBuild({ buildId })
toast.success("Build retry initiated", {
description: "The build has been queued with the latest YAML.",
})
@@ -177,340 +60,14 @@ export default function BuildProgress() {
toast.error("Failed to retry build", {
description: String(error),
})
}
}
const getStatusBadge = (status: string) => {
const statusConfig = {
success: {
bg: "bg-green-500/20",
text: "text-green-400",
label: "Success",
},
failure: { bg: "bg-red-500/20", text: "text-red-400", label: "Failed" },
queued: {
bg: "bg-yellow-500/20",
text: "text-yellow-400",
label: "Queued",
},
}
const config = statusConfig[status as keyof typeof statusConfig] || {
bg: "bg-slate-500/20",
text: "text-slate-400",
label: status,
}
return <span className={`px-2 py-1 ${config.bg} ${config.text} rounded text-sm`}>{config.label}</span>
}
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleString()
}
// Get excluded modules
const excludedModules = modulesData.modules.filter(module => build.config.modulesExcluded[module.id] === true)
// Get explicitly selected plugins from stored config
// The stored config only contains explicitly selected plugins (not resolved dependencies)
const explicitPluginSlugs = (build.config.pluginsEnabled || []).map(pluginId => {
// Extract slug from "slug@version" format if present
return pluginId.includes("@") ? pluginId.split("@")[0] : pluginId
})
// Get implicit dependencies (dependencies that are not explicitly selected)
const implicitDeps = getImplicitDependencies(
explicitPluginSlugs,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Separate explicit and implicit plugins
const explicitPlugins: Array<{
id: string
name: string
description: string
version: string
}> = []
const implicitPlugins: Array<{
id: string
name: string
description: string
version: string
}> = []
// Process explicitly selected plugins
;(build.config.pluginsEnabled || []).forEach(pluginId => {
// Extract slug from "slug@version" format if present
const slug = pluginId.includes("@") ? pluginId.split("@")[0] : pluginId
const pluginData = (registryData as Record<string, { name: string; description: string; version: string }>)[slug]
const pluginInfo = {
id: slug,
name: pluginData?.name || slug,
description: pluginData?.description || "",
version: pluginId.includes("@") ? pluginId.split("@")[1] : pluginData?.version || "",
}
explicitPlugins.push(pluginInfo)
})
// Process implicit dependencies (resolved but not in stored config)
for (const slug of implicitDeps) {
const pluginData = (registryData as Record<string, { name: string; description: string; version: string }>)[slug]
if (pluginData) {
implicitPlugins.push({
id: slug,
name: pluginData.name || slug,
description: pluginData.description || "",
version: pluginData.version || "",
})
throw error
}
}
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<a
href={`/builds/new/${build.buildHash}`}
className="inline-flex items-center text-slate-400 hover:text-white"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Quick Build
</a>
</div>
<div className="bg-slate-900/60 rounded-lg border border-slate-800 p-6 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
{getStatusIcon()}
<div>
<p className="text-sm uppercase tracking-wide text-slate-500">Target</p>
<h2 className="text-2xl font-semibold">{targetLabel}</h2>
<div className="flex items-center gap-2 text-slate-400 mt-1 text-sm">
<span className={getStatusColor()}>{humanizeStatus(status)}</span>
<span></span>
<span>{new Date(build.updatedAt).toLocaleString()}</span>
{githubActionUrl && (
<>
<span></span>
<a
href={githubActionUrl}
target="_blank"
rel="noopener noreferrer"
className="text-slate-500 hover:text-slate-300"
>
View run
</a>
</>
)}
</div>
</div>
</div>
<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" />
Report a Problem
</Button>
<Button onClick={handleShare} className="bg-green-600 hover:bg-green-700">
<Share2 className="w-4 h-4 mr-2" />
{shareUrlCopied ? "Copied!" : "Share Build"}
</Button>
</div>
</div>
{/* Admin Controls Section */}
{isAdmin === true && build && (
<div className="border-t border-slate-800 pt-4 mt-4 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h3 className="text-lg font-semibold mb-2">Admin Controls</h3>
<div className="flex items-center gap-3 mb-2">
<span className="text-sm font-mono font-semibold text-white">
{build.buildHash.substring(0, 8)}
</span>
{getStatusBadge(build.status)}
</div>
</div>
<div className="flex gap-2">
<Button
onClick={() => navigate(`/builds/new/${build.buildHash}`)}
variant="outline"
size="sm"
className="border-slate-600 hover:bg-slate-800"
>
Clone
</Button>
<Button onClick={handleRetry} className="bg-cyan-600 hover:bg-cyan-700" size="sm">
Re-run Build
</Button>
</div>
</div>
{/* Build Configuration Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div>
<span className="text-sm text-slate-500">Target</span>
<div className="text-sm font-mono text-white mt-1">{build.config.target}</div>
</div>
<div>
<span className="text-sm text-slate-500">Version</span>
<div className="text-sm font-mono text-white mt-1">{build.config.version}</div>
</div>
</div>
<div className="space-y-2">
<div>
<span className="text-sm text-slate-500">{build.completedAt ? "Completed" : "Started"}</span>
<div className="text-sm text-white mt-1">
{build.completedAt
? formatDate(build.completedAt)
: build.startedAt
? formatDate(build.startedAt)
: "Unknown"}
</div>
</div>
</div>
</div>
{/* Run History Section */}
{(build.githubRunId || (build.githubRunIdHistory?.length ?? 0) > 0) && (
<div className="pt-4 border-t border-slate-800">
<span className="text-xs text-slate-500 mb-2 block">
Run History
{(build.githubRunIdHistory?.length ?? 0) > 0 &&
` (${(build.githubRunIdHistory?.length ?? 0) + (build.githubRunId ? 1 : 0)} total)`}
</span>
<div className="flex flex-wrap gap-2">
{build.githubRunId && (
<a
href={`https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-cyan-400 hover:text-cyan-300 underline font-semibold"
title="Current run"
>
{build.githubRunId}
</a>
)}
{build.githubRunIdHistory?.map(id => (
<a
key={id}
href={`https://github.com/MeshEnvy/mesh-forge/actions/runs/${id}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-cyan-400 hover:text-cyan-300 underline"
>
{id}
</a>
))}
</div>
</div>
)}
</div>
)}
{status !== "success" && status !== "failure" && (
<div className="rounded-lg border border-slate-800/70 bg-slate-950/60 p-4">
<p className="text-sm text-slate-400">
Builds run in GitHub Actions. When the status is
<span className="text-green-400 font-medium"> success</span>, your firmware artifact will be ready to
download.
</p>
</div>
)}
{/* Build Configuration Summary */}
{(excludedModules.length > 0 || explicitPlugins.length > 0 || implicitPlugins.length > 0) && (
<div className="space-y-6 border-t border-slate-800 pt-6">
{/* Excluded Modules */}
{excludedModules.length > 0 && (
<div>
<h3 className="text-lg font-semibold mb-3">Excluded Modules</h3>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
<div className="space-y-4">
{excludedModules.map(module => (
<div key={module.id} className="border-b border-slate-800 pb-4 last:border-b-0 last:pb-0">
<h4 className="text-base font-medium mb-1">{module.name}</h4>
<p className="text-slate-400 text-sm">{module.description}</p>
</div>
))}
</div>
</div>
</div>
)}
{/* Enabled Plugins */}
{explicitPlugins.length > 0 && (
<div>
<h3 className="text-lg font-semibold mb-3">Enabled Plugins</h3>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
<div className="space-y-4">
{explicitPlugins.map(plugin => (
<div key={plugin.id} className="border-b border-slate-800 pb-4 last:border-b-0 last:pb-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-base font-medium">{plugin.name}</h4>
{plugin.version && <span className="text-xs text-slate-500">v{plugin.version}</span>}
</div>
{plugin.description && <p className="text-slate-400 text-sm">{plugin.description}</p>}
</div>
))}
</div>
</div>
</div>
)}
{/* Required Plugins (Implicit Dependencies) */}
{implicitPlugins.length > 0 && (
<div>
<h3 className="text-lg font-semibold mb-3">Required Plugins</h3>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
<div className="space-y-4">
{implicitPlugins.map(plugin => (
<div key={plugin.id} className="border-b border-slate-800 pb-4 last:border-b-0 last:pb-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-base font-medium">{plugin.name}</h4>
{plugin.version && <span className="text-xs text-slate-500">v{plugin.version}</span>}
</div>
{plugin.description && <p className="text-slate-400 text-sm">{plugin.description}</p>}
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
{status === "success" && build.buildHash && (
<BuildDownloadButton
build={build}
type={ArtifactType.Firmware}
className="w-full bg-cyan-600 hover:bg-cyan-700"
/>
)}
{build.buildHash && (
<BuildDownloadButton
build={build}
type={ArtifactType.Source}
variant="outline"
className="w-full bg-slate-700 hover:bg-slate-600"
/>
)}
{status === "failure" && (
<div className="rounded-lg border border-red-500/40 bg-red-500/10 p-4 text-sm text-red-100">
<p className="font-medium text-red-200">
Build failed. Please try tweaking your configuration or re-running the build.
</p>
</div>
)}
{status !== "success" && status !== "failure" && (
<div className="rounded-lg border border-blue-500/30 bg-blue-500/5 p-4 text-sm text-blue-100">
<p className="font-medium text-blue-200">
This build is still running. Leave this tab open or come back later using the URL above.
</p>
</div>
)}
</div>
<BuildProgress build={build} isAdmin={isAdmin === true} onRetry={handleRetry} />
</div>
</div>
)

View File

@@ -2,9 +2,28 @@ import { DiscordButton } from "@/components/DiscordButton"
import { PluginCard } from "@/components/PluginCard"
import { RedditButton } from "@/components/RedditButton"
import { Button } from "@/components/ui/button"
import { api } from "@/convex/_generated/api"
import registryData from "@/public/registry.json"
import { useQuery } from "convex/react"
import { useEffect, useState } from "react"
import { navigate } from "vike/client/router"
function getGitHubOwnerRepo(repoUrl?: string): { owner: string; repo: string } | null {
if (!repoUrl) return null
try {
const url = new URL(repoUrl)
if (url.hostname === "github.com" || url.hostname === "www.github.com") {
const pathParts = url.pathname.split("/").filter(Boolean)
if (pathParts.length >= 2) {
return { owner: pathParts[0], repo: pathParts[1] }
}
}
} catch {
// Invalid URL
}
return null
}
function QuickBuildIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
@@ -43,10 +62,41 @@ function DocsIcon(props: React.SVGProps<SVGSVGElement>) {
}
export default function LandingPage() {
const flashCounts = useQuery(api.plugins.getAll)
const [githubStars, setGithubStars] = useState<Record<string, number>>({})
const featuredPlugins = Object.entries(registryData)
.filter(([, plugin]) => plugin.featured === true)
.sort(([, pluginA], [, pluginB]) => pluginA.name.localeCompare(pluginB.name))
useEffect(() => {
// Fetch GitHub stars for featured plugins
const fetchStars = async () => {
const stars: Record<string, number> = {}
const promises = featuredPlugins.map(async ([slug, plugin]) => {
const ownerRepo = getGitHubOwnerRepo(plugin.repo)
if (!ownerRepo) return
try {
const res = await fetch(`https://api.github.com/repos/${ownerRepo.owner}/${ownerRepo.repo}`)
const data = await res.json()
if (data.stargazers_count !== undefined) {
stars[slug] = data.stargazers_count
}
} catch {
// Silently fail if GitHub API is unavailable
}
})
await Promise.all(promises)
setGithubStars(stars)
}
if (featuredPlugins.length > 0) {
fetchStars()
}
}, [featuredPlugins.length])
const customBuildPlugin = {
id: "custom-build",
name: "Build your own",
@@ -73,7 +123,7 @@ export default function LandingPage() {
<div className="mb-10">
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-8 pb-24 md:pb-8 max-w-6xl mx-auto relative">
<h2 className="text-2xl font-bold mb-6 text-center">Popular Builds</h2>
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(260px,260px))] gap-4 auto-rows-fr justify-center">
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(312px,312px))] gap-4 auto-rows-fr justify-center">
{featuredPlugins.map(([slug, plugin]) => (
<div key={slug} className="h-full">
<PluginCard
@@ -86,8 +136,8 @@ export default function LandingPage() {
repo={plugin.repo}
homepage={plugin.homepage}
version={plugin.version}
downloads={plugin.downloads}
stars={plugin.stars}
downloads={flashCounts?.[slug]}
stars={githubStars[slug]}
/>
</div>
))}

View File

@@ -1,8 +1,30 @@
import { PluginCard } from "@/components/PluginCard"
import { api } from "@/convex/_generated/api"
import registryData from "@/public/registry.json"
import { PluginDisplay } from "@/types"
import { useQuery } from "convex/react"
import { useEffect, useState } from "react"
function getGitHubOwnerRepo(repoUrl?: string): { owner: string; repo: string } | null {
if (!repoUrl) return null
try {
const url = new URL(repoUrl)
if (url.hostname === "github.com" || url.hostname === "www.github.com") {
const pathParts = url.pathname.split("/").filter(Boolean)
if (pathParts.length >= 2) {
return { owner: pathParts[0], repo: pathParts[1] }
}
}
} catch {
// Invalid URL
}
return null
}
export default function PluginsPage() {
const flashCounts = useQuery(api.plugins.getAll)
const [githubStars, setGithubStars] = useState<Record<string, number>>({})
const plugins = Object.entries(registryData).sort(([, pluginA], [, pluginB]) => {
// Featured plugins first
const featuredA = pluginA.featured ?? false
@@ -14,6 +36,32 @@ export default function PluginsPage() {
return pluginA.name.localeCompare(pluginB.name)
})
useEffect(() => {
// Fetch GitHub stars for all plugins
const fetchStars = async () => {
const stars: Record<string, number> = {}
const promises = plugins.map(async ([slug, plugin]) => {
const ownerRepo = getGitHubOwnerRepo(plugin.repo)
if (!ownerRepo) return
try {
const res = await fetch(`https://api.github.com/repos/${ownerRepo.owner}/${ownerRepo.repo}`)
const data = await res.json()
if (data.stargazers_count !== undefined) {
stars[slug] = data.stargazers_count
}
} catch {
// Silently fail if GitHub API is unavailable
}
})
await Promise.all(promises)
setGithubStars(stars)
}
fetchStars()
}, [])
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">
@@ -40,8 +88,8 @@ export default function PluginsPage() {
repo={pluginDisplay.repo}
homepage={pluginDisplay.homepage}
version={pluginDisplay.version}
downloads={pluginDisplay.downloads}
stars={pluginDisplay.stars}
downloads={flashCounts?.[slug]}
stars={githubStars[slug]}
/>
)
})}

View File

@@ -0,0 +1,173 @@
import { Button } from "@/components/ui/button"
import { api } from "@/convex/_generated/api"
import registryData from "@/public/registry.json"
import { PluginDisplay } from "@/types"
import { useQuery } from "convex/react"
import { Download, Github, Home, Star } from "lucide-react"
import { usePageContext } from "vike-react/usePageContext"
import { navigate } from "vike/client/router"
function getGitHubStarsBadgeUrl(repoUrl?: string): string | null {
if (!repoUrl) return null
try {
const url = new URL(repoUrl)
if (url.hostname === "github.com" || url.hostname === "www.github.com") {
const pathParts = url.pathname.split("/").filter(Boolean)
if (pathParts.length >= 2) {
const owner = pathParts[0]
const repo = pathParts[1]
return `https://img.shields.io/github/stars/${owner}/${repo}?style=flat&logo=github&logoColor=white&labelColor=rgb(0,0,0,0)&color=rgb(30,30,30)&label=★`
}
}
} catch {
// Invalid URL
}
return null
}
export default function PluginPage() {
const pageContext = usePageContext()
const slug = pageContext.routeParams?.slug as string | undefined
const pluginStats = useQuery(api.plugins.get, slug ? { slug } : "skip")
if (!slug) {
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">Plugin slug missing.</p>
</div>
</div>
)
}
const plugin = (registryData as Record<string, PluginDisplay>)[slug]
const starsBadgeUrl = getGitHubStarsBadgeUrl(plugin?.repo)
if (!plugin) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto space-y-4">
<div className="bg-slate-900/60 border border-slate-800 rounded-lg p-6">
<p className="text-slate-300">
Plugin <span className="font-mono">{slug}</span> not found.
</p>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto space-y-6">
<div className="bg-slate-900/60 rounded-lg border border-slate-800 p-6 space-y-6">
<div className="flex items-start gap-6">
{plugin.imageUrl && (
<img
src={plugin.imageUrl}
alt={`${plugin.name} logo`}
className="w-24 h-24 rounded-lg object-contain shrink-0"
/>
)}
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold">{plugin.name}</h1>
{plugin.featured && (
<span className="px-2 py-1 text-xs font-medium text-yellow-400 bg-yellow-400/10 border border-yellow-400/20 rounded">
<Star className="w-3 h-3 inline mr-1 fill-yellow-400" />
Featured
</span>
)}
</div>
<p className="text-slate-300 text-lg mb-4">{plugin.description}</p>
<div className="flex flex-wrap items-center gap-4 text-sm text-slate-400">
{plugin.version && (
<div className="flex items-center gap-1">
<span className="text-slate-500">Version:</span>
<span className="font-mono text-white">v{plugin.version}</span>
</div>
)}
{plugin.author && (
<div className="flex items-center gap-1">
<span className="text-slate-500">Author:</span>
<span className="text-white">{plugin.author}</span>
</div>
)}
<div className="flex items-center gap-1">
<Download className="w-4 h-4" />
<span>{(pluginStats?.flashCount ?? 0).toLocaleString()}</span>
</div>
{starsBadgeUrl && plugin.repo && (
<a
href={plugin.repo}
target="_blank"
rel="noopener noreferrer"
className="hover:opacity-80 transition-opacity"
>
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
</a>
)}
</div>
</div>
</div>
<div className="flex gap-3 pt-4 border-t border-slate-800">
{plugin.repo && (
<Button
onClick={() => window.open(plugin.repo, "_blank", "noopener,noreferrer")}
variant="outline"
className="border-slate-600 hover:bg-slate-800"
>
<Github className="w-4 h-4 mr-2" />
View Repository
</Button>
)}
{plugin.homepage && plugin.homepage !== plugin.repo && (
<Button
onClick={() => window.open(plugin.homepage, "_blank", "noopener,noreferrer")}
variant="outline"
className="border-slate-600 hover:bg-slate-800"
>
<Home className="w-4 h-4 mr-2" />
Homepage
</Button>
)}
<Button onClick={() => navigate(`/builds/new?plugin=${slug}`)} className="bg-cyan-600 hover:bg-cyan-700">
Build with this Plugin
</Button>
</div>
{plugin.dependencies && Object.keys(plugin.dependencies).length > 0 && (
<div className="pt-4 border-t border-slate-800">
<h2 className="text-lg font-semibold mb-3">Dependencies</h2>
<div className="space-y-2">
{Object.entries(plugin.dependencies).map(([depName, depVersion]) => (
<div key={depName} className="flex items-center gap-2 text-sm">
<span className="text-slate-400">{depName}:</span>
<span className="font-mono text-white">{depVersion}</span>
</div>
))}
</div>
</div>
)}
{plugin.includes && plugin.includes.length > 0 && (
<div className="pt-4 border-t border-slate-800">
<h2 className="text-lg font-semibold mb-3">Supported Platforms</h2>
<div className="flex flex-wrap gap-2">
{plugin.includes.map(platform => (
<span
key={platform}
className="px-2 py-1 text-xs font-medium text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 rounded"
>
{platform}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,4 @@
export default {
prerender: false,
}