refactor: enhance profile management by adding public profile listing, improving target handling in profiles, and updating UI components for better user experience

This commit is contained in:
Ben Allfree
2025-11-23 20:05:08 -08:00
parent 944be265d8
commit f1be099e93
11 changed files with 531 additions and 168 deletions
+9 -1
View File
@@ -84,8 +84,16 @@ export const triggerBuild = mutation({
const flagsString = flags.join(' ')
// Get targets from profileTargets
const profileTargets = await ctx.db
.query('profileTargets')
.withIndex('by_profile', (q) => q.eq('profileId', args.profileId))
.collect()
const targets = profileTargets.map((pt) => pt.target)
// Create build records for each target
for (const target of profile.targets) {
for (const target of targets) {
// Compute build hash using the generated flags
const buildHash = await computeBuildHash(
profile.version,
+115 -20
View File
@@ -15,25 +15,96 @@ export const list = query({
},
})
export const listPublic = query({
args: {},
handler: async (ctx) => {
// Get all profiles and filter for public ones (isPublic === true or undefined)
// Note: Index query with optional field may not work as expected, so we filter manually
const allProfiles = await ctx.db.query('profiles').collect()
return allProfiles.filter((p) => p.isPublic !== false)
},
})
export const get = query({
args: { id: v.id('profiles') },
handler: async (ctx, args) => {
const profile = await ctx.db.get(args.id)
if (!profile) return null
// Treat undefined as public (backward compatibility)
if (profile.isPublic === false) {
// Check if user owns this profile
const userId = await getAuthUserId(ctx)
if (!userId || profile.userId !== userId) {
return null
}
}
return profile
},
})
export const getTargets = query({
args: { profileId: v.id('profiles') },
handler: async (ctx, args) => {
const profileTargets = await ctx.db
.query('profileTargets')
.withIndex('by_profile', (q) => q.eq('profileId', args.profileId))
.collect()
return profileTargets.map((pt) => pt.target)
},
})
export const getFlashCount = query({
args: { profileId: v.id('profiles') },
handler: async (ctx, args) => {
const profileBuilds = await ctx.db
.query('profileBuilds')
.withIndex('by_profile', (q) => q.eq('profileId', args.profileId))
.collect()
let successCount = 0
for (const profileBuild of profileBuilds) {
const build = await ctx.db.get(profileBuild.buildId)
if (build && build.status === 'success') {
successCount++
}
}
return successCount
},
})
export const create = mutation({
args: {
name: v.string(),
targets: v.array(v.string()),
targets: v.optional(v.array(v.string())),
config: v.any(),
version: v.string(),
isPublic: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx)
if (!userId) throw new Error('Unauthorized')
return await ctx.db.insert('profiles', {
const profileId = await ctx.db.insert('profiles', {
userId,
name: args.name,
targets: args.targets,
config: args.config,
version: args.version,
updatedAt: Date.now(),
isPublic: args.isPublic ?? true,
})
// Create profileTargets entries
if (args.targets) {
for (const target of args.targets) {
await ctx.db.insert('profileTargets', {
profileId,
target,
createdAt: Date.now(),
})
}
}
return profileId
},
})
@@ -41,9 +112,10 @@ export const update = mutation({
args: {
id: v.id('profiles'),
name: v.string(),
targets: v.array(v.string()),
targets: v.optional(v.array(v.string())),
config: v.any(),
version: v.optional(v.string()),
isPublic: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx)
@@ -54,33 +126,46 @@ export const update = mutation({
throw new Error('Unauthorized')
}
// Get new targets for comparison
const newTargets = new Set(args.targets)
// Update profile
await ctx.db.patch(args.id, {
name: args.name,
targets: args.targets,
config: args.config,
version: args.version,
isPublic: args.isPublic,
updatedAt: Date.now(),
})
// Sync profileBuilds: delete profileBuilds for targets that are no longer in the list
const profileBuilds = await ctx.db
.query('profileBuilds')
.withIndex('by_profile', (q) => q.eq('profileId', args.id))
.collect()
// Sync profileTargets if targets are provided
if (args.targets !== undefined) {
const newTargets = new Set(args.targets)
for (const profileBuild of profileBuilds) {
if (!newTargets.has(profileBuild.target)) {
// Target was removed, delete the profileBuild
await ctx.db.delete(profileBuild._id)
const existingProfileTargets = await ctx.db
.query('profileTargets')
.withIndex('by_profile', (q) => q.eq('profileId', args.id))
.collect()
const existingTargets = new Set(
existingProfileTargets.map((pt) => pt.target)
)
// Delete targets that are no longer in the list
for (const profileTarget of existingProfileTargets) {
if (!newTargets.has(profileTarget.target)) {
await ctx.db.delete(profileTarget._id)
}
}
// Add new targets
for (const target of args.targets) {
if (!existingTargets.has(target)) {
await ctx.db.insert('profileTargets', {
profileId: args.id,
target,
createdAt: Date.now(),
})
}
}
}
// Note: We don't create profileBuilds for new targets here.
// User must trigger a build to create profileBuilds for new targets.
},
})
@@ -95,6 +180,16 @@ export const remove = mutation({
throw new Error('Unauthorized')
}
// Delete associated profileTargets
const profileTargets = await ctx.db
.query('profileTargets')
.withIndex('by_profile', (q) => q.eq('profileId', args.id))
.collect()
for (const profileTarget of profileTargets) {
await ctx.db.delete(profileTarget._id)
}
await ctx.db.delete(args.id)
},
})
+12 -2
View File
@@ -7,11 +7,13 @@ export default defineSchema({
profiles: defineTable({
userId: v.id('users'),
name: v.string(),
targets: v.array(v.string()), // e.g. ["tbeam", "rak4631"]
config: v.any(), // JSON object for flags
version: v.string(),
updatedAt: v.number(),
}).index('by_user', ['userId']),
isPublic: v.optional(v.boolean()),
})
.index('by_user', ['userId'])
.index('by_public', ['isPublic']),
builds: defineTable({
target: v.string(),
githubRunId: v.number(),
@@ -22,6 +24,14 @@ export default defineSchema({
buildHash: v.string(),
}).index('by_hash', ['buildHash']),
profileTargets: defineTable({
profileId: v.id('profiles'),
target: v.string(),
createdAt: v.number(),
})
.index('by_profile', ['profileId'])
.index('by_profile_target', ['profileId', 'target']),
profileBuilds: defineTable({
profileId: v.id('profiles'),
buildId: v.id('builds'),
+12 -2
View File
@@ -1,10 +1,13 @@
import { Authenticated, AuthLoading, Unauthenticated } from 'convex/react'
import { Loader2 } from 'lucide-react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { Toaster } from '@/components/ui/sonner'
import Navbar from './components/Navbar'
import BuildDetail from './pages/BuildDetail'
import Dashboard from './pages/Dashboard'
import LandingPage from './pages/LandingPage'
import ProfileDetail from './pages/ProfileDetail'
import ProfileFlash from './pages/ProfileFlash'
function App() {
return (
@@ -16,16 +19,23 @@ function App() {
</AuthLoading>
<Unauthenticated>
<Navbar />
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/profiles/:id" element={<ProfileDetail />} />
<Route path="/profiles/:id/flash" element={<ProfileFlash />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Unauthenticated>
<Authenticated>
<Navbar />
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/" element={<LandingPage />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/builds/:buildId" element={<BuildDetail />} />
<Route path="/profiles/:id" element={<ProfileDetail />} />
<Route path="/profiles/:id/flash" element={<ProfileFlash />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Authenticated>
+50
View File
@@ -0,0 +1,50 @@
import { useAuthActions } from '@convex-dev/auth/react'
import { Authenticated, Unauthenticated } from 'convex/react'
import { Link } from 'react-router-dom'
import { Button } from '@/components/ui/button'
export default function Navbar() {
const { signIn, signOut } = useAuthActions()
return (
<nav className="border-b border-slate-800 bg-slate-950">
<div className="max-w-7xl mx-auto px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-8">
<Link
to="/"
className="text-2xl font-bold bg-gradient-to-r from-cyan-400 to-blue-600 bg-clip-text text-transparent hover:opacity-80 transition-opacity"
>
FlashEnvy
</Link>
<div className="flex items-center gap-4">
<Authenticated>
<Link
to="/dashboard"
className="text-slate-300 hover:text-white transition-colors"
>
Dashboard
</Link>
</Authenticated>
</div>
</div>
<div className="flex items-center gap-4">
<Unauthenticated>
<Button
onClick={() => signIn('google')}
className="bg-white text-slate-900 hover:bg-slate-200"
>
Sign in
</Button>
</Unauthenticated>
<Authenticated>
<Button variant="outline" onClick={() => signOut()}>
Sign Out
</Button>
</Authenticated>
</div>
</div>
</div>
</nav>
)
}
+32 -112
View File
@@ -1,5 +1,4 @@
import { useMutation } from 'convex/react'
import * as React from 'react'
import { useForm } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
@@ -7,15 +6,14 @@ import { Input } from '@/components/ui/input'
import { api } from '../../convex/_generated/api'
import type { Doc } from '../../convex/_generated/dataModel'
import modulesData from '../../convex/modules.json'
import { TARGETS } from '../constants/targets'
import { VERSIONS } from '../constants/versions'
import { ModuleCard } from './ModuleCard'
interface ProfileFormValues {
name: string
targets: string[]
config: Record<string, boolean>
version: string
isPublic: boolean
}
interface ProfileEditorProps {
@@ -32,69 +30,32 @@ export default function ProfileEditor({
const createProfile = useMutation(api.profiles.create)
const updateProfile = useMutation(api.profiles.update)
const { register, handleSubmit, setValue, watch } = useForm({
defaultValues: initialData || {
name: '',
targets: [],
config: {},
version: VERSIONS[0],
},
})
const targets = watch('targets')
// Group targets by category
const groupedTargets = React.useMemo(() => {
return Object.entries(TARGETS).reduce(
(acc, [id, meta]) => {
const category = meta.category || 'Other'
if (!acc[category]) acc[category] = []
acc[category].push({ id, ...meta })
return acc
const { register, handleSubmit, setValue, watch } =
useForm<ProfileFormValues>({
defaultValues: {
name: initialData?.name || '',
config: initialData?.config || {},
version: initialData?.version || VERSIONS[0],
isPublic: initialData?.isPublic ?? true,
},
{} as Record<string, ((typeof TARGETS)[string] & { id: string })[]>
)
}, [])
const categories = React.useMemo(
() => Object.keys(groupedTargets).sort(),
[groupedTargets]
)
const [activeCategory, setActiveCategory] = React.useState<string>(
categories[0] || 'Heltec'
)
// Update active category when categories load if not set
React.useEffect(() => {
if (!activeCategory && categories.length > 0) {
setActiveCategory(categories[0])
}
}, [categories, activeCategory])
const toggleTarget = (target: string) => {
const current = targets || []
if (current.includes(target)) {
setValue(
'targets',
current.filter((t: string) => t !== target)
)
} else {
setValue('targets', [...current, target])
}
}
})
const onSubmit = async (data: ProfileFormValues) => {
if (initialData?._id) {
await updateProfile({
id: initialData._id,
name: data.name,
targets: data.targets,
config: data.config,
version: data.version,
isPublic: data.isPublic,
})
} else {
await createProfile(data)
await createProfile({
name: data.name,
config: data.config,
version: data.version,
isPublic: data.isPublic,
})
}
onSave()
}
@@ -135,64 +96,23 @@ export default function ProfileEditor({
</div>
<div>
<div className="block text-sm font-medium mb-2">Targets</div>
<div className="space-y-4">
{/* Category Pills */}
<div className="flex flex-wrap gap-2">
{categories.map((category) => {
const count = groupedTargets[category].filter((t) =>
targets?.includes(t.id)
).length
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}
{count > 0 && (
<span className="ml-2 bg-white/20 px-1.5 py-0.5 rounded-full text-xs">
{count}
</span>
)}
</button>
)
})}
</div>
{/* Active Category Targets */}
<div className="bg-slate-950/50 p-4 rounded-lg border border-slate-800/50">
<div className="flex gap-4 flex-wrap">
{groupedTargets[activeCategory]?.map((item) => (
<div key={item.id} className="flex items-center space-x-2">
<Checkbox
id={item.id}
checked={targets?.includes(item.id)}
onCheckedChange={() => toggleTarget(item.id)}
/>
<label
htmlFor={item.id}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{item.name}
{item.architecture && (
<span className="ml-2 text-xs text-slate-500">
({item.architecture})
</span>
)}
</label>
</div>
))}
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="isPublic"
checked={watch('isPublic')}
onCheckedChange={(checked) => setValue('isPublic', !!checked)}
disabled
/>
<label
htmlFor="isPublic"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Make profile public
</label>
</div>
<p className="text-xs text-slate-400 mt-1 ml-6">
Public profiles are visible to everyone on the home page
</p>
</div>
<div className="space-y-6">
+26
View File
@@ -0,0 +1,26 @@
import { useQuery } from 'convex/react'
import { api } from '../../convex/_generated/api'
import type { Id } from '../../convex/_generated/dataModel'
interface ProfileTargetsProps {
profileId: Id<'profiles'>
}
export default function ProfileTargets({ profileId }: ProfileTargetsProps) {
const targets = useQuery(api.profiles.getTargets, { profileId })
if (targets === undefined) {
return <span className="text-slate-400 text-sm">Loading...</span>
}
if (targets.length === 0) {
return <span className="text-slate-400 text-sm">No targets</span>
}
return (
<span className="text-slate-400 text-sm">
Targets: {targets.join(', ')}
</span>
)
}
+8 -14
View File
@@ -1,16 +1,15 @@
import { useAuthActions } from '@convex-dev/auth/react'
import { useMutation, useQuery } from 'convex/react'
import { Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import BuildsPanel from '@/components/BuildsPanel'
import ProfileEditor from '@/components/ProfileEditor'
import ProfileTargets from '@/components/ProfileTargets'
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
import type { Doc, Id } from '../../convex/_generated/dataModel'
export default function Dashboard() {
const { signOut } = useAuthActions()
const profiles = useQuery(api.profiles.list)
const triggerBuild = useMutation(api.builds.triggerBuild)
const removeProfile = useMutation(api.profiles.remove)
@@ -70,17 +69,12 @@ export default function Dashboard() {
<div className="min-h-screen bg-slate-950 text-white p-8">
<header className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold">My Fleet</h1>
<div className="flex gap-4">
<Button
onClick={handleCreate}
className="bg-cyan-600 hover:bg-cyan-700"
>
<Plus className="w-4 h-4 mr-2" /> New Profile
</Button>
<Button variant="outline" onClick={() => signOut()}>
Sign Out
</Button>
</div>
<Button
onClick={handleCreate}
className="bg-cyan-600 hover:bg-cyan-700"
>
<Plus className="w-4 h-4 mr-2" /> New Profile
</Button>
</header>
<main>
@@ -103,7 +97,7 @@ export default function Dashboard() {
<span className="text-slate-200">{profile.version}</span>
</p>
<p className="text-slate-400 text-sm mb-4">
Targets: {profile.targets.join(', ')}
<ProfileTargets profileId={profile._id} />
</p>
<div className="flex gap-2">
<Button
+55 -17
View File
@@ -1,24 +1,62 @@
import { useAuthActions } from '@convex-dev/auth/react'
import { Button } from '@/components/ui/button'
import { useQuery } from 'convex/react'
import { useNavigate } from 'react-router-dom'
import { api } from '../../convex/_generated/api'
export default function LandingPage() {
const { signIn } = useAuthActions()
const navigate = useNavigate()
const profiles = useQuery(api.profiles.listPublic)
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-950 text-white">
<h1 className="text-5xl font-bold mb-8 bg-gradient-to-r from-cyan-400 to-blue-600 bg-clip-text text-transparent">
Firmware Configurator
</h1>
<p className="text-xl text-slate-400 mb-12 max-w-md text-center">
Manage your Meshtastic fleet. Create custom profiles, build firmware in
the cloud, and flash directly from your browser.
</p>
<Button
onClick={() => signIn('google')}
className="bg-white text-slate-900 hover:bg-slate-200 text-lg px-8 py-6"
>
Sign in with Google
</Button>
<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">
Manage your Meshtastic fleet
</span>
</h1>
<p className="text-xl md:text-2xl text-slate-400 max-w-2xl mx-auto">
Create custom profiles, build firmware in the cloud, and flash
directly from your browser.
</p>
</div>
<main className="px-8 pb-8">
{profiles === undefined ? (
<div className="text-center text-slate-400 py-12">
Loading profiles...
</div>
) : profiles.length === 0 ? (
<div className="text-center text-slate-400 py-12">
No public profiles available yet.
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{profiles.map((profile) => (
<button
key={profile._id}
type="button"
onClick={() => navigate(`/profiles/${profile._id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
navigate(`/profiles/${profile._id}`)
}
}}
className="border border-slate-800 rounded-lg p-6 bg-slate-900/50 hover:bg-slate-900 cursor-pointer transition-colors text-left"
>
<h3 className="text-xl font-semibold mb-2">{profile.name}</h3>
<p className="text-slate-400 text-sm">
Version:{' '}
<span className="text-slate-200">{profile.version}</span>
</p>
</button>
))}
</div>
)}
</main>
</div>
</div>
)
}
+195
View File
@@ -0,0 +1,195 @@
import { useQuery } from 'convex/react'
import * as React from 'react'
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
import type { Id } from '../../convex/_generated/dataModel'
import modulesData from '../../convex/modules.json'
import { TARGETS } from '../constants/targets'
export default function ProfileDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const profile = useQuery(
api.profiles.get,
id ? { id: id as Id<'profiles'> } : 'skip'
)
const flashCount = useQuery(
api.profiles.getFlashCount,
id ? { profileId: id as Id<'profiles'> } : 'skip'
)
const [selectedTarget, setSelectedTarget] = useState<string>('')
// Group targets by category
const groupedTargets = React.useMemo(() => {
return Object.entries(TARGETS).reduce(
(acc, [targetId, meta]) => {
const category = meta.category || 'Other'
if (!acc[category]) acc[category] = []
acc[category].push({ id: targetId, ...meta })
return acc
},
{} as Record<string, ((typeof TARGETS)[string] & { id: string })[]>
)
}, [])
const categories = React.useMemo(
() => Object.keys(groupedTargets).sort(),
[groupedTargets]
)
const [activeCategory, setActiveCategory] = React.useState<string>(
categories[0] || ''
)
// Update active category when categories load if not set
React.useEffect(() => {
if (!activeCategory && categories.length > 0) {
setActiveCategory(categories[0])
}
}, [categories, activeCategory])
if (!id) {
return <div>Profile ID required</div>
}
if (profile === undefined || flashCount === undefined) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">
<div>Loading...</div>
</div>
)
}
if (profile === null) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">
<div>Profile not found</div>
</div>
)
}
// Get enabled modules (inverted logic: config[id] === false means included)
const enabledModules = modulesData.modules.filter(
(module) => profile.config[module.id] === false
)
const handleFlash = () => {
if (selectedTarget) {
navigate(`/profiles/${id}/flash`)
}
}
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-4xl font-bold mb-2">{profile.name}</h1>
<p className="text-slate-400 mb-8">
Version: {profile.version} Flashed {flashCount} time
{flashCount !== 1 ? 's' : ''}
</p>
<div className="space-y-8">
{/* Enabled Modules */}
<div>
<h2 className="text-2xl font-semibold mb-4">Enabled Modules</h2>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
{enabledModules.length === 0 ? (
<p className="text-slate-400">No modules enabled</p>
) : (
<div className="space-y-4">
{enabledModules.map((module) => (
<div
key={module.id}
className="border-b border-slate-800 pb-4 last:border-b-0 last:pb-0"
>
<h3 className="text-lg font-medium mb-1">
{module.name}
</h3>
<p className="text-slate-400 text-sm">
{module.description}
</p>
</div>
))}
</div>
)}
</div>
</div>
{/* Target Selection and Flash */}
<div>
<h2 className="text-2xl font-semibold mb-4">Flash Firmware</h2>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
<div className="space-y-4">
<div>
<div className="block text-sm font-medium mb-2">
Select Target
</div>
<div className="space-y-4">
{/* Category Pills */}
<div className="flex flex-wrap gap-2">
{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>
{/* Active Category Targets */}
<div className="bg-slate-950/50 p-4 rounded-lg border border-slate-800/50">
<div className="flex gap-2 flex-wrap">
{groupedTargets[activeCategory]?.map((item) => {
const isSelected = selectedTarget === item.id
return (
<button
key={item.id}
type="button"
onClick={() => setSelectedTarget(item.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'
}`}
>
{item.name}
{item.architecture && (
<span className="ml-2 text-xs opacity-75">
({item.architecture})
</span>
)}
</button>
)
})}
</div>
</div>
</div>
</div>
<Button
onClick={handleFlash}
disabled={!selectedTarget}
className="w-full bg-cyan-600 hover:bg-cyan-700"
>
Flash
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { useParams } from 'react-router-dom'
export default function ProfileFlash() {
const { id } = useParams<{ id: string }>()
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-4xl font-bold mb-8">Flash Firmware</h1>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
<p className="text-slate-400">Flash functionality coming soon...</p>
<p className="text-slate-500 text-sm mt-2">Profile ID: {id}</p>
</div>
</div>
</div>
)
}