mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-07-03 00:11:26 +02:00
feat: add description field to profiles and update related components for enhanced profile management
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -178,11 +178,6 @@ export default function ProfileDetail() {
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
{item.architecture && (
|
||||
<span className="ml-2 text-xs opacity-75">
|
||||
({item.architecture})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user