feat: refactor build and profile management by introducing build configuration schema, updating related functions, and enhancing routing for build creation and progress tracking

This commit is contained in:
Ben Allfree
2025-11-26 05:24:26 -08:00
parent 273fac6652
commit 4a8625f69b
9 changed files with 680 additions and 74 deletions
+96 -40
View File
@@ -5,7 +5,7 @@ import { api, internal } from './_generated/api'
import type { Id } from './_generated/dataModel'
import { internalMutation, mutation, query } from './_generated/server'
import { generateSignedDownloadUrl } from './lib/r2'
import { type BuildFields, buildFields, type ProfileFields } from './schema'
import { type BuildConfigFields, type BuildFields, buildFields } from './schema'
type BuildUpdateData = {
status: string
@@ -19,13 +19,22 @@ export const get = query({
},
})
export const getByHash = query({
args: { buildHash: v.string() },
handler: async (ctx, args) => {
const build = await ctx.db
.query('builds')
.filter((q) => q.eq(q.field('buildHash'), args.buildHash))
.unique()
return build ?? null
},
})
/**
* Computes flags string from profile config.
* Computes flags string from build config.
* Only excludes modules explicitly marked as excluded (config[id] === true).
*/
export function computeFlagsFromProfile(
config: ProfileFields['config']
): string {
export function computeFlagsFromConfig(config: BuildConfigFields): string {
// Sort modules to ensure consistent order
return Object.keys(config.modulesExcluded)
.sort()
@@ -36,9 +45,9 @@ export function computeFlagsFromProfile(
/**
* Computes a stable SHA-256 hash from version, target, and flags.
* This hash uniquely identifies a build configuration based on what is actually executed.
* Internal helper for hash computation.
*/
export async function computeBuildHash(
async function computeBuildHashInternal(
version: string,
target: string,
flags: string
@@ -61,15 +70,18 @@ export async function computeBuildHash(
}
/**
* Computes buildHash for a profile and target.
* Computes buildHash from build config.
* This is the single source of truth for build hash computation.
*/
export async function computeBuildHashForProfile(
profile: ProfileFields,
target: string
export async function computeBuildHash(
config: BuildConfigFields
): Promise<{ hash: string; flags: string }> {
const flags = computeFlagsFromProfile(profile.config)
const hash = await computeBuildHash(profile.version, target, flags)
const flags = computeFlagsFromConfig(config)
const hash = await computeBuildHashInternal(
config.version,
config.target,
flags
)
return { hash, flags }
}
@@ -95,7 +107,7 @@ export function getR2ArtifactUrl(
// This is the single source of truth for build creation
export const upsertBuild = internalMutation({
args: {
...pick(buildFields, ['buildHash', 'target', 'version', 'profileString']),
...pick(buildFields, ['buildHash', 'config']),
status: v.optional(v.string()),
flags: v.string(),
},
@@ -107,7 +119,7 @@ export const upsertBuild = internalMutation({
.filter((q) => q.eq(q.field('buildHash'), args.buildHash))
.unique()
const { status, buildHash, target, version, profileString, flags } = args
const { status, buildHash, config, flags } = args
if (existingBuild) {
await ctx.db.patch(existingBuild._id, {
@@ -119,21 +131,19 @@ export const upsertBuild = internalMutation({
// Create new build
const buildId = await ctx.db.insert('builds', {
target,
status: 'queued',
startedAt: Date.now(),
buildHash,
updatedAt: Date.now(),
version,
profileString,
config,
})
// Dispatch GitHub workflow if needed
await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, {
target: config.target,
version: config.version,
buildId,
target,
flags,
version,
buildHash,
})
@@ -141,39 +151,52 @@ export const upsertBuild = internalMutation({
},
})
export const ensureBuildForProfileTarget = mutation({
export const ensureBuildFromConfig = mutation({
args: {
profileId: v.id('profiles'),
target: v.string(),
version: v.string(),
modulesExcluded: v.optional(v.record(v.string(), v.boolean())),
profileName: v.optional(v.string()),
profileDescription: v.optional(v.string()),
},
handler: async (ctx, args): Promise<Id<'builds'>> => {
const userId = await getAuthUserId(ctx)
if (!userId) {
throw new Error('Unauthorized')
}
const profile = await ctx.db.get(args.profileId)
if (!profile) {
throw new Error('Profile not found')
handler: async (ctx, args) => {
// Construct config for the build
const config: BuildConfigFields = {
version: args.version,
modulesExcluded: args.modulesExcluded ?? {},
target: args.target,
}
// Compute build hash (single source of truth)
const { hash: buildHash, flags: flagsString } =
await computeBuildHashForProfile(profile, args.target)
const { hash: buildHash, flags } = await computeBuildHash(config)
const existingBuild = await ctx.db
.query('builds')
.filter((q) => q.eq(q.field('buildHash'), buildHash))
.unique()
if (existingBuild) {
return {
buildId: existingBuild._id,
existed: true,
buildHash,
}
}
// Upsert the build (single source of truth)
const buildId: Id<'builds'> = await ctx.runMutation(
internal.builds.upsertBuild,
{
buildHash,
target: args.target,
flags: flagsString,
version: profile.version,
profileString: JSON.stringify(profile),
flags,
config,
}
)
return buildId
return {
buildId,
existed: false,
buildHash,
}
},
})
@@ -294,7 +317,40 @@ export const generateDownloadUrl = mutation({
throw new Error('Could not determine file extension from artifact path')
}
const filename = `${slug}-${build.target}.${ext}`
const filename = `${slug}-${build.config.target}.${ext}`
return await generateSignedDownloadUrl(
objectKey,
filename,
'application/octet-stream'
)
},
})
export const generateAnonymousDownloadUrl = mutation({
args: {
build: v.object(buildFields),
slug: v.string(),
},
handler: async (ctx, args) => {
let objectKey = args.build.artifactPath || ''
if (objectKey.startsWith('/')) {
objectKey = objectKey.substring(1)
}
const parts = objectKey.split('.')
const ext = parts.length > 1 ? parts.pop() : undefined
if (!ext) {
throw new Error('Could not determine file extension from artifact path')
}
const {
buildHash,
config: { target, version },
} = args.build
const pfx = args.slug ? `${args.slug}-` : ''
const filename = `${pfx}${target}-${version}-${buildHash.substring(0, 4)}.${ext}`
return await generateSignedDownloadUrl(
objectKey,
+2 -3
View File
@@ -1,6 +1,7 @@
import { getAuthUserId } from '@convex-dev/auth/server'
import { v } from 'convex/values'
import { internalMutation, mutation, query } from './_generated/server'
import { buildConfigFields } from './schema'
export const list = query({
args: {},
@@ -77,7 +78,7 @@ export const upsert = mutation({
id: v.optional(v.id('profiles')),
name: v.string(),
description: v.string(),
config: v.any(),
config: v.object(buildConfigFields),
version: v.string(),
isPublic: v.boolean(),
},
@@ -96,7 +97,6 @@ export const upsert = mutation({
name: args.name,
description: args.description,
config: args.config,
version: args.version,
isPublic: args.isPublic,
flashCount: profile.flashCount ?? 0,
updatedAt: Date.now(),
@@ -110,7 +110,6 @@ export const upsert = mutation({
name: args.name,
description: args.description,
config: args.config,
version: args.version,
flashCount: 0,
updatedAt: Date.now(),
isPublic: args.isPublic ?? true,
+15 -9
View File
@@ -3,14 +3,17 @@ import { defineSchema, defineTable } from 'convex/server'
import { type Infer, v } from 'convex/values'
import type { Doc } from './_generated/dataModel'
export const buildConfigFields = {
version: v.string(),
modulesExcluded: v.record(v.string(), v.boolean()),
target: v.string(),
}
export const profileFields = {
userId: v.id('users'),
name: v.string(),
description: v.string(),
version: v.string(),
config: v.object({
modulesExcluded: v.record(v.string(), v.boolean()),
}),
config: v.object(buildConfigFields),
isPublic: v.boolean(),
flashCount: v.number(),
updatedAt: v.number(),
@@ -18,12 +21,10 @@ export const profileFields = {
export const buildFields = {
buildHash: v.string(),
target: v.string(),
version: v.string(),
status: v.string(),
startedAt: v.number(),
updatedAt: v.number(),
profileString: v.string(),
config: v.object(buildConfigFields),
// Optional props
completedAt: v.optional(v.number()),
@@ -39,7 +40,12 @@ export const schema = defineSchema({
export type ProfilesDoc = Doc<'profiles'>
export type BuildsDoc = Doc<'builds'>
export type ProfileFields = Infer<typeof schema.tables.profiles.validator>
export type BuildFields = Infer<typeof schema.tables.builds.validator>
export const buildsDocValidator = schema.tables.builds.validator
export const profilesDocValidator = schema.tables.profiles.validator
export type ProfileFields = Infer<typeof profilesDocValidator>
export type BuildFields = Infer<typeof buildsDocValidator>
const buildConfigFieldsValidator = v.object(buildConfigFields)
export type BuildConfigFields = Infer<typeof buildConfigFieldsValidator>
export default schema
+6
View File
@@ -9,6 +9,8 @@ import {
} from 'react-router-dom'
import { Toaster } from '@/components/ui/sonner'
import Navbar from './components/Navbar'
import BuildNew from './pages/BuildNew'
import BuildProgress from './pages/BuildProgress'
import Dashboard from './pages/Dashboard'
import LandingPage from './pages/LandingPage'
import ProfileDetail from './pages/ProfileDetail'
@@ -36,6 +38,8 @@ function App() {
<ConditionalNavbar />
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/builds/new" element={<BuildNew />} />
<Route path="/builds/:buildHash" element={<BuildProgress />} />
<Route path="/profiles/:id" element={<ProfileDetail />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -46,6 +50,8 @@ function App() {
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/builds/new" element={<BuildNew />} />
<Route path="/builds/:buildHash" element={<BuildProgress />} />
<Route
path="/dashboard/profiles/:id"
element={<ProfileEditorPage />}
+316
View File
@@ -0,0 +1,316 @@
import { useMutation } from 'convex/react'
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { ModuleToggle } from '@/components/ModuleToggle'
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
import modulesData from '../../convex/modules.json'
import { TARGETS } from '../constants/targets'
import { VERSIONS } from '../constants/versions'
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 navigate = useNavigate()
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
const STORAGE_KEY = 'quick_build_target'
const persistTargetSelection = (targetId: string) => {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(STORAGE_KEY, targetId)
} catch (error) {
console.error('Failed to persist target selection', error)
}
}
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 [isFlashing, setIsFlashing] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [showModuleOverrides, setShowModuleOverrides] = useState(false)
useEffect(() => {
if (!activeCategory && TARGET_CATEGORIES.length > 0) {
setActiveCategory(TARGET_CATEGORIES[0])
}
}, [activeCategory])
useEffect(() => {
if (!selectedTarget && activeCategory) {
const first = GROUPED_TARGETS[activeCategory]?.[0]?.id
if (first) {
setSelectedTarget(first)
}
}
}, [selectedTarget, activeCategory])
useEffect(() => {
if (typeof window === 'undefined') return
try {
const savedTarget = localStorage.getItem(STORAGE_KEY)
if (savedTarget && TARGETS[savedTarget]) {
setSelectedTarget(savedTarget)
const category = TARGETS[savedTarget].category || 'Other'
if (TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
} catch (error) {
console.error('Failed to read saved target', error)
}
}, [])
const handleSelectTarget = (targetId: string) => {
setSelectedTarget(targetId)
persistTargetSelection(targetId)
const category = TARGETS[targetId]?.category || 'Other'
if (category && TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
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])
const moduleCount = Object.keys(moduleConfig).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 handleFlash = async () => {
if (!selectedTarget) return
setIsFlashing(true)
setErrorMessage(null)
try {
const result = await ensureBuildFromConfig({
target: selectedTarget,
version: selectedVersion,
modulesExcluded: moduleConfig,
})
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
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">
Quick build
</p>
<h1 className="text-4xl font-bold mt-1">
Flash a custom firmware version
</h1>
<p className="text-slate-400 mt-2 max-w-2xl">
Choose your Meshtastic target, adjust optional modules, and queue
a new build instantly. Well send you to the build status page as
soon as it starts.
</p>
</div>
<Link
to="/"
className="text-slate-400 hover:text-white transition-colors text-sm"
>
Back to landing page
</Link>
</div>
<div className="space-y-6 bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<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-4">
<div className="flex flex-wrap gap-2">
{TARGET_CATEGORIES.map((category) => {
const isActive = activeCategory === category
return (
<button
key={category}
type="button"
onClick={() => 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">
{(activeCategory ? GROUPED_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 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">Module overrides</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="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-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>
)
}
+203
View File
@@ -0,0 +1,203 @@
import { useMutation, useQuery } from 'convex/react'
import { pick } from 'convex-helpers'
import { ArrowLeft, CheckCircle, Loader2, XCircle } from 'lucide-react'
import { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { humanizeStatus } from '@/lib/utils'
import { api } from '../../convex/_generated/api'
import { type BuildFields, buildFields } from '../../convex/schema'
import { TARGETS } from '../constants/targets'
export default function BuildProgress() {
const { buildHash } = useParams<{ buildHash: string }>()
const build = useQuery(
api.builds.getByHash,
buildHash ? { buildHash } : 'skip'
)
const generateDownloadUrl = useMutation(
api.builds.generateAnonymousDownloadUrl
)
const [downloadError, setDownloadError] = useState<string | null>(null)
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.{' '}
<Link to="/builds/new" className="text-cyan-400">
Start a new build
</Link>
.
</p>
</div>
</div>
)
}
if (build === undefined) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
</div>
)
}
if (!build) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto space-y-4">
<Link
to="/builds/new"
className="inline-flex items-center text-slate-400 hover:text-white"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Quick Build
</Link>
<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>
</p>
</div>
</div>
</div>
)
}
const targetMeta = build.config.target
? TARGETS[build.config.target]
: undefined
const targetLabel = targetMeta?.name ?? build.config.target
const status = build.status || 'queued'
const handleDownload = async () => {
setDownloadError(null)
try {
const url = await generateDownloadUrl({
build: pick(build, Object.keys(buildFields) as (keyof BuildFields)[]),
slug: `quick-build`,
})
window.location.href = url
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setDownloadError('Failed to generate download link.')
console.error('Download error', message)
}
}
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/configurable-web-flasher/actions/runs/${build.githubRunId}`
: null
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">
<Link
to="/builds/new"
className="inline-flex items-center text-slate-400 hover:text-white"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Quick Build
</Link>
<p className="text-sm text-slate-500 font-mono break-all">
Hash: {build.buildHash}
</p>
</div>
<div className="bg-slate-900/60 rounded-lg border border-slate-800 p-6 space-y-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>
{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>
)}
{status === 'success' && build.artifactPath && (
<div className="space-y-2">
<Button
onClick={handleDownload}
className="w-full bg-cyan-600 hover:bg-cyan-700"
>
Download firmware
</Button>
{downloadError && (
<p className="text-sm text-red-400">{downloadError}</p>
)}
</div>
)}
{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>
</div>
</div>
)
}
+30 -5
View File
@@ -8,6 +8,23 @@ import {
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
function QuickBuildIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
role="img"
aria-label="Quick build"
{...props}
>
<title>Quick build</title>
<path fill="currentColor" d="M11 15H6l7-14v8h5l-7 14z" />
</svg>
)
}
export default function LandingPage() {
const navigate = useNavigate()
const { signIn } = useAuthActions()
@@ -16,7 +33,6 @@ export default function LandingPage() {
return (
<div className="min-h-screen bg-slate-950 text-white">
<div className="max-w-7xl mx-auto">
{/* Hero Section */}
<div className="text-center py-20 px-8">
<h1 className="text-6xl md:text-7xl font-bold mb-6 leading-[1.1]">
<span className="bg-gradient-to-r from-cyan-400 to-blue-600 bg-clip-text text-transparent inline-block pb-2">
@@ -27,7 +43,7 @@ export default function LandingPage() {
Create custom profiles, build firmware in the cloud, and flash
directly from your browser.
</p>
<div className="flex justify-center">
<div className="flex flex-wrap items-center justify-center gap-3">
<Authenticated>
<Button
onClick={() => navigate('/dashboard')}
@@ -48,6 +64,15 @@ export default function LandingPage() {
Sign in
</Button>
</Unauthenticated>
<Button
onClick={() => navigate('/builds/new')}
size="lg"
variant="outline"
className="border-cyan-500/50 text-white hover:bg-slate-900/60"
>
<QuickBuildIcon className="mr-2 h-5 w-5" />
Quick Build
</Button>
</div>
</div>
@@ -67,9 +92,9 @@ export default function LandingPage() {
key={profile._id}
type="button"
onClick={() => navigate(`/profiles/${profile._id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
navigate(`/profiles/${profile._id}`)
}
}}
+4 -8
View File
@@ -16,9 +16,7 @@ export default function ProfileDetail() {
const navigate = useNavigate()
const { isAuthenticated } = useConvexAuth()
const { signIn } = useAuthActions()
const ensureBuildForProfileTarget = useMutation(
api.builds.ensureBuildForProfileTarget
)
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
const profile = useQuery(
api.profiles.get,
id ? { id: id as Id<'profiles'> } : 'skip'
@@ -107,10 +105,8 @@ export default function ProfileDetail() {
}
try {
await ensureBuildForProfileTarget({
profileId: id as Id<'profiles'>,
target: selectedTarget,
})
if (!profile) return
await ensureBuildFromConfig(profile.config)
navigate(`/profiles/${id}/flash/${selectedTarget}`)
} catch (error) {
toast.error('Failed to start flash', {
@@ -132,7 +128,7 @@ export default function ProfileDetail() {
</p>
</div>
<ProfileStatisticPills
version={profile.version}
version={profile.config.version}
flashCount={totalFlashes}
/>
</div>
+8 -9
View File
@@ -16,9 +16,7 @@ export default function ProfileFlash() {
target: string
}>()
const ensureBuildForProfileTarget = useMutation(
api.builds.ensureBuildForProfileTarget
)
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
const [buildId, setBuildId] = useState<Id<'builds'> | null>(null)
@@ -34,12 +32,12 @@ export default function ProfileFlash() {
const generateDownloadUrl = useMutation(api.builds.generateDownloadUrl)
useEffect(() => {
if (id && target) {
ensureBuildForProfileTarget({ profileId: id as Id<'profiles'>, target })
.then(setBuildId)
if (id && target && profile) {
ensureBuildFromConfig(profile.config)
.then((result) => setBuildId(result.buildId))
.catch(() => setBuildId(null))
}
}, [id, target, ensureBuildForProfileTarget])
}, [id, target, profile, ensureBuildFromConfig])
if (build === undefined || profile === undefined) {
return (
@@ -144,14 +142,15 @@ export default function ProfileFlash() {
</p>
<h1 className="text-3xl font-bold mt-1">{profile.name}</h1>
<p className="text-slate-400 text-sm mt-2">
Version: <span className="text-slate-200">{profile.version}</span>
Version:{' '}
<span className="text-slate-200">{profile.config.version}</span>
</p>
</div>
<p className="text-slate-200 leading-relaxed">
{profile.description}
</p>
<ProfileStatisticPills
version={profile.version}
version={profile.config.version}
flashCount={totalFlashes}
/>
</div>