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