mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-06-12 01:44:47 +02:00
188 lines
6.1 KiB
TypeScript
188 lines
6.1 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 type { Doc } from '../../convex/_generated/dataModel'
|
|
import modulesData from '../../convex/modules.json'
|
|
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<
|
|
Doc<'profiles'>,
|
|
'_id' | '_creationTime' | 'userId' | 'flashCount' | 'updatedAt'
|
|
>
|
|
|
|
interface ProfileEditorProps {
|
|
initialData?: Doc<'profiles'>
|
|
onSave: () => void
|
|
onCancel: () => void
|
|
}
|
|
|
|
export default function ProfileEditor({
|
|
initialData,
|
|
onSave,
|
|
onCancel,
|
|
}: ProfileEditorProps) {
|
|
const upsertProfile = useMutation(api.profiles.upsert)
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
setValue,
|
|
watch,
|
|
formState: { errors },
|
|
} = useForm<ProfileFormValues>({
|
|
defaultValues: {
|
|
name: initialData?.name || '',
|
|
description: initialData?.description || '',
|
|
config: {
|
|
version: VERSIONS[0],
|
|
modulesExcluded: {},
|
|
target: '',
|
|
...initialData?.config,
|
|
},
|
|
isPublic: initialData?.isPublic ?? true,
|
|
},
|
|
})
|
|
|
|
const onSubmit = async (data: ProfileFormValues) => {
|
|
await upsertProfile({
|
|
id: initialData?._id,
|
|
name: data.name,
|
|
description: data.description,
|
|
config: data.config,
|
|
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('config.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 Doc<'builds'>['config']
|
|
const configValue = currentConfig.modulesExcluded[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.modulesExcluded[module.id] = true
|
|
} else {
|
|
delete newConfig.modulesExcluded[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>
|
|
)
|
|
}
|