feat: add description field to profiles and update related components for enhanced profile management

This commit is contained in:
Ben Allfree
2025-11-24 03:28:52 -08:00
parent a21ca48fa6
commit 728ac418ae
9 changed files with 232 additions and 53 deletions
+4
View File
@@ -115,6 +115,7 @@ export const getFlashCount = query({
export const create = mutation({
args: {
name: v.string(),
description: v.string(),
targets: v.optional(v.array(v.string())),
config: v.any(),
version: v.string(),
@@ -127,6 +128,7 @@ export const create = mutation({
const profileId = await ctx.db.insert('profiles', {
userId,
name: args.name,
description: args.description,
config: args.config,
version: args.version,
updatedAt: Date.now(),
@@ -144,6 +146,7 @@ export const update = mutation({
args: {
id: v.id('profiles'),
name: v.string(),
description: v.string(),
targets: v.optional(v.array(v.string())),
config: v.any(),
version: v.optional(v.string()),
@@ -161,6 +164,7 @@ export const update = mutation({
// Update profile
await ctx.db.patch(args.id, {
name: args.name,
description: args.description,
config: args.config,
version: args.version,
isPublic: args.isPublic,
+1
View File
@@ -7,6 +7,7 @@ export default defineSchema({
profiles: defineTable({
userId: v.id('users'),
name: v.string(),
description: v.string(),
config: v.any(), // JSON object for flags
version: v.string(),
updatedAt: v.number(),
+5
View File
@@ -7,6 +7,7 @@ import BuildDetail from './pages/BuildDetail'
import Dashboard from './pages/Dashboard'
import LandingPage from './pages/LandingPage'
import ProfileDetail from './pages/ProfileDetail'
import ProfileEditorPage from './pages/ProfileEditorPage'
import ProfileFlash from './pages/ProfileFlash'
function App() {
@@ -37,6 +38,10 @@ function App() {
<Route path="/" element={<LandingPage />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/builds/:buildId" element={<BuildDetail />} />
<Route
path="/dashboard/profiles/:id"
element={<ProfileEditorPage />}
/>
<Route path="/profiles/:id" element={<ProfileDetail />} />
<Route
path="/profiles/:id/flash/:target"
+41 -10
View File
@@ -11,6 +11,7 @@ import { ModuleCard } from './ModuleCard'
interface ProfileFormValues {
name: string
description: string
config: Record<string, boolean>
version: string
isPublic: boolean
@@ -30,21 +31,28 @@ export default function ProfileEditor({
const createProfile = useMutation(api.profiles.create)
const updateProfile = useMutation(api.profiles.update)
const { register, handleSubmit, setValue, watch } =
useForm<ProfileFormValues>({
defaultValues: {
name: initialData?.name || '',
config: initialData?.config || {},
version: initialData?.version || VERSIONS[0],
isPublic: initialData?.isPublic ?? true,
},
})
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<ProfileFormValues>({
defaultValues: {
name: initialData?.name || '',
description: initialData?.description || '',
config: initialData?.config || {},
version: initialData?.version || VERSIONS[0],
isPublic: initialData?.isPublic ?? true,
},
})
const onSubmit = async (data: ProfileFormValues) => {
if (initialData?._id) {
await updateProfile({
id: initialData._id,
name: data.name,
description: data.description,
config: data.config,
version: data.version,
isPublic: data.isPublic,
@@ -52,6 +60,7 @@ export default function ProfileEditor({
} else {
await createProfile({
name: data.name,
description: data.description,
config: data.config,
version: data.version,
isPublic: data.isPublic,
@@ -72,10 +81,13 @@ export default function ProfileEditor({
</label>
<Input
id="name"
{...register('name')}
{...register('name', { required: 'Profile name is required' })}
className="bg-slate-950 border-slate-800"
placeholder="e.g. Solar Repeater"
/>
{errors.name && (
<p className="mt-1 text-sm text-red-400">{errors.name.message}</p>
)}
</div>
<div>
<label htmlFor="version" className="block text-sm font-medium mb-2">
@@ -95,6 +107,25 @@ export default function ProfileEditor({
</div>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium mb-2">
Description
</label>
<textarea
id="description"
{...register('description', {
required: 'Profile description is required',
})}
className="w-full min-h-[120px] rounded-md border border-slate-800 bg-slate-950 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:ring-offset-slate-950"
placeholder="Describe what this profile is best suited for"
/>
{errors.description && (
<p className="mt-1 text-sm text-red-400">
{errors.description.message}
</p>
)}
</div>
<div>
<div className="flex items-center space-x-2">
<Checkbox
+13 -20
View File
@@ -1,30 +1,25 @@
import { useMutation, useQuery } from 'convex/react'
import { Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
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 navigate = useNavigate()
const profiles = useQuery(api.profiles.list)
const removeProfile = useMutation(api.profiles.remove)
const [isEditing, setIsEditing] = useState(false)
const [editingProfile, setEditingProfile] = useState<
Doc<'profiles'> | undefined
>(undefined)
const [isCreating, setIsCreating] = useState(false)
const handleEdit = (profile: Doc<'profiles'>) => {
setEditingProfile(profile)
setIsEditing(true)
navigate(`/dashboard/profiles/${profile._id}`)
}
const handleCreate = () => {
setEditingProfile(undefined)
setIsEditing(true)
setIsCreating(true)
}
const handleDelete = async (
@@ -64,11 +59,10 @@ export default function Dashboard() {
</header>
<main>
{isEditing ? (
{isCreating ? (
<ProfileEditor
initialData={editingProfile}
onSave={() => setIsEditing(false)}
onCancel={() => setIsEditing(false)}
onSave={() => setIsCreating(false)}
onCancel={() => setIsCreating(false)}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
@@ -82,10 +76,13 @@ export default function Dashboard() {
Version:{' '}
<span className="text-slate-200">{profile.version}</span>
</p>
<p className="text-slate-400 text-sm mb-4">
<ProfileTargets profileId={profile._id} />
<p className="text-slate-300 text-sm mb-4 leading-relaxed">
{profile.description}
</p>
<div className="flex gap-2">
<Button size="sm" asChild>
<Link to={`/profiles/${profile._id}`}>Use</Link>
</Button>
<Button
size="sm"
variant="secondary"
@@ -101,10 +98,6 @@ export default function Dashboard() {
<Trash2 className="w-4 h-4" />
</Button>
</div>
<div className="mt-4 pt-4 border-t border-slate-800">
<BuildsPanel profileId={profile._id} />
</div>
</div>
))}
{profiles?.length === 0 && (
+3
View File
@@ -51,6 +51,9 @@ export default function LandingPage() {
Version:{' '}
<span className="text-slate-200">{profile.version}</span>
</p>
<p className="text-slate-300 text-sm mt-3 leading-relaxed">
{profile.description}
</p>
</button>
))}
</div>
-5
View File
@@ -178,11 +178,6 @@ export default function ProfileDetail() {
}`}
>
{item.name}
{item.architecture && (
<span className="ml-2 text-xs opacity-75">
({item.architecture})
</span>
)}
</button>
)
})}
+86
View File
@@ -0,0 +1,86 @@
import { useQuery } from 'convex/react'
import { ArrowLeft, Loader2 } from 'lucide-react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import ProfileEditor from '@/components/ProfileEditor'
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
import type { Id } from '../../convex/_generated/dataModel'
export default function ProfileEditorPage() {
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const profile = useQuery(
api.profiles.get,
id ? { id: id as Id<'profiles'> } : 'skip'
)
if (!id) {
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">
No profile id provided.{' '}
<Link to="/dashboard" className="text-cyan-400">
Back to dashboard
</Link>
</p>
</div>
</div>
)
}
if (profile === 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 (profile === null) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto">
<Link
to="/dashboard"
className="inline-flex items-center text-slate-400 hover:text-white mb-4"
>
<ArrowLeft className="w-4 h-4 mr-2" /> Back to Dashboard
</Link>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
<p className="text-slate-300">Profile not found.</p>
</div>
</div>
</div>
)
}
const handleDone = () => {
navigate('/dashboard')
}
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<Link
to="/dashboard"
className="inline-flex items-center text-slate-400 hover:text-white"
>
<ArrowLeft className="w-4 h-4 mr-2" /> Back to Dashboard
</Link>
<Button variant="outline" onClick={handleDone}>
Cancel
</Button>
</div>
<ProfileEditor
initialData={profile}
onSave={handleDone}
onCancel={handleDone}
/>
</div>
</div>
)
}
+79 -18
View File
@@ -11,6 +11,8 @@ import { Button } from '@/components/ui/button'
import { humanizeStatus } from '@/lib/utils'
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 ProfileFlash() {
const { id, target } = useParams<{
@@ -22,8 +24,12 @@ export default function ProfileFlash() {
api.profiles.getProfileTarget,
id && target ? { profileId: id as Id<'profiles'>, target } : 'skip'
)
const profile = useQuery(
api.profiles.get,
id ? { id: id as Id<'profiles'> } : 'skip'
)
if (data === undefined) {
if (data === undefined || profile === 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" />
@@ -49,7 +55,30 @@ export default function ProfileFlash() {
)
}
if (!profile) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto">
<Link
to={`/profiles/${id}`}
className="inline-flex items-center text-slate-400 hover:text-white mb-4"
>
<ArrowLeft className="w-4 h-4 mr-2" /> Back to Profile
</Link>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
<p className="text-slate-400">Profile not found</p>
</div>
</div>
</div>
)
}
const build = data.build
const targetMeta = target ? TARGETS[target] : undefined
const targetLabel = targetMeta?.name ?? target ?? 'Unknown Target'
const includedModules = modulesData.modules.filter(
(module) => profile.config?.[module.id] === false
)
const getStatusColor = (status: string) => {
if (status === 'success') return 'text-green-400'
@@ -74,38 +103,70 @@ export default function ProfileFlash() {
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto">
<div className="max-w-4xl mx-auto space-y-6">
<Link
to={`/profiles/${id}`}
className="inline-flex items-center text-slate-400 hover:text-white mb-4"
className="inline-flex items-center text-slate-400 hover:text-white"
>
<ArrowLeft className="w-4 h-4 mr-2" /> Back to Profile
</Link>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6 space-y-4">
<div>
<p className="text-sm uppercase tracking-wide text-slate-500">
Profile
</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>
</p>
</div>
<p className="text-slate-200 leading-relaxed">
{profile.description}
</p>
</div>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
{getStatusIcon(build.status)}
<div>
<h1 className="text-3xl font-bold">{target}</h1>
<div className="flex items-center gap-2 text-slate-400 mt-1">
<span className={getStatusColor(build.status)}>
{humanizeStatus(build.status)}
</span>
<span></span>
<span>{new Date(build.startedAt).toLocaleString()}</span>
<h2 className="text-xl font-semibold mb-4">Included Modules</h2>
{includedModules.length === 0 ? (
<p className="text-slate-400 text-sm">No modules included.</p>
) : (
<div className="space-y-3">
{includedModules.map((module) => (
<div key={module.id}>
<p className="font-medium text-sm">{module.name}</p>
<p className="text-slate-400 text-sm">{module.description}</p>
</div>
))}
</div>
)}
</div>
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6 space-y-4">
<div className="flex items-center gap-4">
{getStatusIcon(build.status)}
<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(build.status)}>
{humanizeStatus(build.status)}
</span>
<span></span>
<span>{new Date(build.startedAt).toLocaleString()}</span>
</div>
</div>
</div>
{githubActionUrl && (
<div className="mt-4">
<div>
<a
href={githubActionUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-cyan-400 hover:text-cyan-300"
className="inline-flex items-center text-cyan-400 hover:text-cyan-300 text-sm"
>
View on GitHub Actions
<ExternalLink className="w-4 h-4 ml-2" />
@@ -114,13 +175,13 @@ export default function ProfileFlash() {
)}
{build.status === 'success' && build.artifactUrl && (
<div className="mt-4">
<div>
<a
href={build.artifactUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button className="bg-cyan-600 hover:bg-cyan-700">
<Button className="bg-cyan-600 hover:bg-cyan-700 w-full">
Download Firmware
</Button>
</a>