Files
mesh-forge/src/components/ProfileEditor.tsx
T

213 lines
7.0 KiB
TypeScript

import { useMutation } from 'convex/react'
import { useForm } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { api } from '../../convex/_generated/api'
import modulesData from '../../convex/modules.json'
import type { ProfileFields, ProfilesDoc } from '../../convex/schema'
import { VERSIONS } from '../constants/versions'
import { ModuleToggle } from './ModuleToggle'
// Form values use flattened config for UI, but will be transformed to nested on submit
type ProfileFormValues = Omit<
ProfileFields,
'_id' | '_creationTime' | 'userId' | 'flashCount' | 'updatedAt' | 'config'
> & {
config: Record<string, boolean | undefined> // Flattened: moduleId -> boolean
}
interface ProfileEditorProps {
initialData?: ProfilesDoc
onSave: () => void
onCancel: () => void
}
export default function ProfileEditor({
initialData,
onSave,
onCancel,
}: ProfileEditorProps) {
const upsertProfile = useMutation(api.profiles.upsert)
// Flatten config for UI: transform config.modulesExcluded to flat object
const getFlattenedConfig = (
config: ProfileFields['config'] | undefined
): Record<string, boolean> => {
if (!config || !config.modulesExcluded) return {}
return { ...config.modulesExcluded }
}
// Transform flat config back to nested structure for database
const getNestedConfig = (
flatConfig: Record<string, boolean | undefined>
): ProfileFields['config'] => {
const modulesExcluded: Record<string, boolean> = {}
for (const [key, value] of Object.entries(flatConfig)) {
if (value === true) {
modulesExcluded[key] = true
}
}
return { modulesExcluded }
}
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<ProfileFormValues>({
defaultValues: {
name: initialData?.name || '',
description: initialData?.description || '',
config: getFlattenedConfig(initialData?.config),
version: initialData?.version || VERSIONS[0],
isPublic: initialData?.isPublic ?? true,
},
})
const onSubmit = async (data: ProfileFormValues) => {
// Transform flattened config back to nested structure
const nestedConfig = getNestedConfig(data.config)
await upsertProfile({
id: initialData?._id,
name: data.name,
description: data.description,
config: nestedConfig,
version: data.version,
isPublic: data.isPublic,
})
onSave()
}
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="space-y-6 bg-slate-900 p-6 rounded-lg border border-slate-800"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-2">
Profile Name
</label>
<Input
id="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">
Firmware Version
</label>
<select
id="version"
{...register('version')}
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((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</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
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">
<div>
<div className="mb-4">
<h3 className="text-lg font-medium">Modules</h3>
<p className="text-sm text-slate-400">
Modules are included by default if supported by your target.
Toggle to exclude modules you don't need.
</p>
</div>
<div className="flex flex-col gap-2">
{modulesData.modules.map((module) => {
// Flattened config: config[id] === true -> Explicitly Excluded
// config[id] === undefined/false -> Default (included if target supports)
const currentConfig = watch('config') as Record<
string,
boolean | undefined
>
const configValue = currentConfig[module.id]
const isExcluded = configValue === true
return (
<ModuleToggle
key={module.id}
id={module.id}
name={module.name}
description={module.description}
isExcluded={isExcluded}
onToggle={(excluded) => {
const newConfig = { ...currentConfig }
if (excluded) {
newConfig[module.id] = true
} else {
delete newConfig[module.id]
}
setValue('config', newConfig)
}}
/>
)
})}
</div>
</div>
</div>
<div className="flex justify-end gap-4 pt-4">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit">Save Profile</Button>
</div>
</form>
)
}