mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-08-07 17:33:37 +02:00
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:
@@ -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 />}
|
||||
|
||||
@@ -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. We’ll 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user