mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-08-07 09:22:56 +02:00
feat: Implement target categorization and filtering in the Profile Editor UI.
This commit is contained in:
@@ -4,6 +4,8 @@ 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 { TARGETS } from "../constants/targets";
|
||||
import { VERSIONS } from "../constants/versions";
|
||||
|
||||
interface ProfileEditorProps {
|
||||
initialData?: any;
|
||||
@@ -27,11 +29,41 @@ export default function ProfileEditor({
|
||||
MESHTASTIC_EXCLUDE_MQTT: false,
|
||||
MESHTASTIC_EXCLUDE_AUDIO: false,
|
||||
},
|
||||
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;
|
||||
},
|
||||
{} 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)) {
|
||||
@@ -51,6 +83,7 @@ export default function ProfileEditor({
|
||||
name: data.name,
|
||||
targets: data.targets,
|
||||
config: data.config,
|
||||
version: data.version,
|
||||
});
|
||||
} else {
|
||||
await createProfile(data);
|
||||
@@ -63,33 +96,87 @@ export default function ProfileEditor({
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-6 bg-slate-900 p-6 rounded-lg border border-slate-800"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Profile Name</label>
|
||||
<Input
|
||||
{...register("name")}
|
||||
className="bg-slate-950 border-slate-800"
|
||||
placeholder="e.g. Solar Repeater"
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Profile Name</label>
|
||||
<Input
|
||||
{...register("name")}
|
||||
className="bg-slate-950 border-slate-800"
|
||||
placeholder="e.g. Solar Repeater"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Firmware Version
|
||||
</label>
|
||||
<select
|
||||
{...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 className="block text-sm font-medium mb-2">Targets</label>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{["tbeam", "rak4631", "heltec_v3", "pico"].map((t) => (
|
||||
<div key={t} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={t}
|
||||
checked={targets?.includes(t)}
|
||||
onCheckedChange={() => toggleTarget(t)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={t}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t}
|
||||
</label>
|
||||
{/* ... existing target UI */}
|
||||
|
||||
<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}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user