diff --git a/convex/builds.ts b/convex/builds.ts index e66494b..7077f66 100644 --- a/convex/builds.ts +++ b/convex/builds.ts @@ -84,8 +84,16 @@ export const triggerBuild = mutation({ const flagsString = flags.join(' ') + // Get targets from profileTargets + const profileTargets = await ctx.db + .query('profileTargets') + .withIndex('by_profile', (q) => q.eq('profileId', args.profileId)) + .collect() + + const targets = profileTargets.map((pt) => pt.target) + // Create build records for each target - for (const target of profile.targets) { + for (const target of targets) { // Compute build hash using the generated flags const buildHash = await computeBuildHash( profile.version, diff --git a/convex/profiles.ts b/convex/profiles.ts index f7f6489..0ef8b2c 100644 --- a/convex/profiles.ts +++ b/convex/profiles.ts @@ -15,25 +15,96 @@ export const list = query({ }, }) +export const listPublic = query({ + args: {}, + handler: async (ctx) => { + // Get all profiles and filter for public ones (isPublic === true or undefined) + // Note: Index query with optional field may not work as expected, so we filter manually + const allProfiles = await ctx.db.query('profiles').collect() + return allProfiles.filter((p) => p.isPublic !== false) + }, +}) + +export const get = query({ + args: { id: v.id('profiles') }, + handler: async (ctx, args) => { + const profile = await ctx.db.get(args.id) + if (!profile) return null + // Treat undefined as public (backward compatibility) + if (profile.isPublic === false) { + // Check if user owns this profile + const userId = await getAuthUserId(ctx) + if (!userId || profile.userId !== userId) { + return null + } + } + return profile + }, +}) + +export const getTargets = query({ + args: { profileId: v.id('profiles') }, + handler: async (ctx, args) => { + const profileTargets = await ctx.db + .query('profileTargets') + .withIndex('by_profile', (q) => q.eq('profileId', args.profileId)) + .collect() + return profileTargets.map((pt) => pt.target) + }, +}) + +export const getFlashCount = query({ + args: { profileId: v.id('profiles') }, + handler: async (ctx, args) => { + const profileBuilds = await ctx.db + .query('profileBuilds') + .withIndex('by_profile', (q) => q.eq('profileId', args.profileId)) + .collect() + + let successCount = 0 + for (const profileBuild of profileBuilds) { + const build = await ctx.db.get(profileBuild.buildId) + if (build && build.status === 'success') { + successCount++ + } + } + return successCount + }, +}) + export const create = mutation({ args: { name: v.string(), - targets: v.array(v.string()), + targets: v.optional(v.array(v.string())), config: v.any(), version: v.string(), + isPublic: v.optional(v.boolean()), }, handler: async (ctx, args) => { const userId = await getAuthUserId(ctx) if (!userId) throw new Error('Unauthorized') - return await ctx.db.insert('profiles', { + const profileId = await ctx.db.insert('profiles', { userId, name: args.name, - targets: args.targets, config: args.config, version: args.version, updatedAt: Date.now(), + isPublic: args.isPublic ?? true, }) + + // Create profileTargets entries + if (args.targets) { + for (const target of args.targets) { + await ctx.db.insert('profileTargets', { + profileId, + target, + createdAt: Date.now(), + }) + } + } + + return profileId }, }) @@ -41,9 +112,10 @@ export const update = mutation({ args: { id: v.id('profiles'), name: v.string(), - targets: v.array(v.string()), + targets: v.optional(v.array(v.string())), config: v.any(), version: v.optional(v.string()), + isPublic: v.optional(v.boolean()), }, handler: async (ctx, args) => { const userId = await getAuthUserId(ctx) @@ -54,33 +126,46 @@ export const update = mutation({ throw new Error('Unauthorized') } - // Get new targets for comparison - const newTargets = new Set(args.targets) - // Update profile await ctx.db.patch(args.id, { name: args.name, - targets: args.targets, config: args.config, version: args.version, + isPublic: args.isPublic, updatedAt: Date.now(), }) - // Sync profileBuilds: delete profileBuilds for targets that are no longer in the list - const profileBuilds = await ctx.db - .query('profileBuilds') - .withIndex('by_profile', (q) => q.eq('profileId', args.id)) - .collect() + // Sync profileTargets if targets are provided + if (args.targets !== undefined) { + const newTargets = new Set(args.targets) - for (const profileBuild of profileBuilds) { - if (!newTargets.has(profileBuild.target)) { - // Target was removed, delete the profileBuild - await ctx.db.delete(profileBuild._id) + const existingProfileTargets = await ctx.db + .query('profileTargets') + .withIndex('by_profile', (q) => q.eq('profileId', args.id)) + .collect() + + const existingTargets = new Set( + existingProfileTargets.map((pt) => pt.target) + ) + + // Delete targets that are no longer in the list + for (const profileTarget of existingProfileTargets) { + if (!newTargets.has(profileTarget.target)) { + await ctx.db.delete(profileTarget._id) + } + } + + // Add new targets + for (const target of args.targets) { + if (!existingTargets.has(target)) { + await ctx.db.insert('profileTargets', { + profileId: args.id, + target, + createdAt: Date.now(), + }) + } } } - - // Note: We don't create profileBuilds for new targets here. - // User must trigger a build to create profileBuilds for new targets. }, }) @@ -95,6 +180,16 @@ export const remove = mutation({ throw new Error('Unauthorized') } + // Delete associated profileTargets + const profileTargets = await ctx.db + .query('profileTargets') + .withIndex('by_profile', (q) => q.eq('profileId', args.id)) + .collect() + + for (const profileTarget of profileTargets) { + await ctx.db.delete(profileTarget._id) + } + await ctx.db.delete(args.id) }, }) diff --git a/convex/schema.ts b/convex/schema.ts index 2b54f15..d31e165 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -7,11 +7,13 @@ export default defineSchema({ profiles: defineTable({ userId: v.id('users'), name: v.string(), - targets: v.array(v.string()), // e.g. ["tbeam", "rak4631"] config: v.any(), // JSON object for flags version: v.string(), updatedAt: v.number(), - }).index('by_user', ['userId']), + isPublic: v.optional(v.boolean()), + }) + .index('by_user', ['userId']) + .index('by_public', ['isPublic']), builds: defineTable({ target: v.string(), githubRunId: v.number(), @@ -22,6 +24,14 @@ export default defineSchema({ buildHash: v.string(), }).index('by_hash', ['buildHash']), + profileTargets: defineTable({ + profileId: v.id('profiles'), + target: v.string(), + createdAt: v.number(), + }) + .index('by_profile', ['profileId']) + .index('by_profile_target', ['profileId', 'target']), + profileBuilds: defineTable({ profileId: v.id('profiles'), buildId: v.id('builds'), diff --git a/src/App.tsx b/src/App.tsx index 27cb816..37b3295 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,13 @@ import { Authenticated, AuthLoading, Unauthenticated } from 'convex/react' import { Loader2 } from 'lucide-react' -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import { Toaster } from '@/components/ui/sonner' +import Navbar from './components/Navbar' import BuildDetail from './pages/BuildDetail' import Dashboard from './pages/Dashboard' import LandingPage from './pages/LandingPage' +import ProfileDetail from './pages/ProfileDetail' +import ProfileFlash from './pages/ProfileFlash' function App() { return ( @@ -16,16 +19,23 @@ function App() { + } /> + } /> + } /> } /> + - } /> + } /> + } /> } /> + } /> + } /> } /> diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx new file mode 100644 index 0000000..d2c1357 --- /dev/null +++ b/src/components/Navbar.tsx @@ -0,0 +1,50 @@ +import { useAuthActions } from '@convex-dev/auth/react' +import { Authenticated, Unauthenticated } from 'convex/react' +import { Link } from 'react-router-dom' +import { Button } from '@/components/ui/button' + +export default function Navbar() { + const { signIn, signOut } = useAuthActions() + + return ( + + ) +} diff --git a/src/components/ProfileEditor.tsx b/src/components/ProfileEditor.tsx index 58a4153..3208fe7 100644 --- a/src/components/ProfileEditor.tsx +++ b/src/components/ProfileEditor.tsx @@ -1,5 +1,4 @@ import { useMutation } from 'convex/react' -import * as React from 'react' import { useForm } from 'react-hook-form' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' @@ -7,15 +6,14 @@ 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 { TARGETS } from '../constants/targets' import { VERSIONS } from '../constants/versions' import { ModuleCard } from './ModuleCard' interface ProfileFormValues { name: string - targets: string[] config: Record version: string + isPublic: boolean } interface ProfileEditorProps { @@ -32,69 +30,32 @@ export default function ProfileEditor({ const createProfile = useMutation(api.profiles.create) const updateProfile = useMutation(api.profiles.update) - const { register, handleSubmit, setValue, watch } = useForm({ - defaultValues: initialData || { - name: '', - targets: [], - config: {}, - 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 + const { register, handleSubmit, setValue, watch } = + useForm({ + defaultValues: { + name: initialData?.name || '', + config: initialData?.config || {}, + version: initialData?.version || VERSIONS[0], + isPublic: initialData?.isPublic ?? true, }, - {} as Record - ) - }, []) - - const categories = React.useMemo( - () => Object.keys(groupedTargets).sort(), - [groupedTargets] - ) - - const [activeCategory, setActiveCategory] = React.useState( - 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)) { - setValue( - 'targets', - current.filter((t: string) => t !== target) - ) - } else { - setValue('targets', [...current, target]) - } - } + }) const onSubmit = async (data: ProfileFormValues) => { if (initialData?._id) { await updateProfile({ id: initialData._id, name: data.name, - targets: data.targets, config: data.config, version: data.version, + isPublic: data.isPublic, }) } else { - await createProfile(data) + await createProfile({ + name: data.name, + config: data.config, + version: data.version, + isPublic: data.isPublic, + }) } onSave() } @@ -135,64 +96,23 @@ export default function ProfileEditor({
-
Targets
-
- {/* Category Pills */} -
- {categories.map((category) => { - const count = groupedTargets[category].filter((t) => - targets?.includes(t.id) - ).length - const isActive = activeCategory === category - - return ( - - ) - })} -
- - {/* Active Category Targets */} -
-
- {groupedTargets[activeCategory]?.map((item) => ( -
- toggleTarget(item.id)} - /> - -
- ))} -
-
+
+ setValue('isPublic', !!checked)} + disabled + /> +
+

+ Public profiles are visible to everyone on the home page +

diff --git a/src/components/ProfileTargets.tsx b/src/components/ProfileTargets.tsx new file mode 100644 index 0000000..7043aad --- /dev/null +++ b/src/components/ProfileTargets.tsx @@ -0,0 +1,26 @@ +import { useQuery } from 'convex/react' +import { api } from '../../convex/_generated/api' +import type { Id } from '../../convex/_generated/dataModel' + +interface ProfileTargetsProps { + profileId: Id<'profiles'> +} + +export default function ProfileTargets({ profileId }: ProfileTargetsProps) { + const targets = useQuery(api.profiles.getTargets, { profileId }) + + if (targets === undefined) { + return Loading... + } + + if (targets.length === 0) { + return No targets + } + + return ( + + Targets: {targets.join(', ')} + + ) +} + diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 11b10ba..84f8820 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,16 +1,15 @@ -import { useAuthActions } from '@convex-dev/auth/react' import { useMutation, useQuery } from 'convex/react' import { Plus, Trash2 } from 'lucide-react' import { useState } from 'react' 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 { signOut } = useAuthActions() const profiles = useQuery(api.profiles.list) const triggerBuild = useMutation(api.builds.triggerBuild) const removeProfile = useMutation(api.profiles.remove) @@ -70,17 +69,12 @@ export default function Dashboard() {

My Fleet

-
- - -
+
@@ -103,7 +97,7 @@ export default function Dashboard() { {profile.version}

- Targets: {profile.targets.join(', ')} +

+
+
+ {/* Hero Section */} +
+

+ + Manage your Meshtastic fleet + +

+

+ Create custom profiles, build firmware in the cloud, and flash + directly from your browser. +

+
+ +
+ {profiles === undefined ? ( +
+ Loading profiles... +
+ ) : profiles.length === 0 ? ( +
+ No public profiles available yet. +
+ ) : ( +
+ {profiles.map((profile) => ( + + ))} +
+ )} +
+
) } diff --git a/src/pages/ProfileDetail.tsx b/src/pages/ProfileDetail.tsx new file mode 100644 index 0000000..48dc02c --- /dev/null +++ b/src/pages/ProfileDetail.tsx @@ -0,0 +1,195 @@ +import { useQuery } from 'convex/react' +import * as React from 'react' +import { useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { Button } from '@/components/ui/button' +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 ProfileDetail() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const profile = useQuery( + api.profiles.get, + id ? { id: id as Id<'profiles'> } : 'skip' + ) + const flashCount = useQuery( + api.profiles.getFlashCount, + id ? { profileId: id as Id<'profiles'> } : 'skip' + ) + const [selectedTarget, setSelectedTarget] = useState('') + + // Group targets by category + const groupedTargets = React.useMemo(() => { + return Object.entries(TARGETS).reduce( + (acc, [targetId, meta]) => { + const category = meta.category || 'Other' + if (!acc[category]) acc[category] = [] + acc[category].push({ id: targetId, ...meta }) + return acc + }, + {} as Record + ) + }, []) + + const categories = React.useMemo( + () => Object.keys(groupedTargets).sort(), + [groupedTargets] + ) + + const [activeCategory, setActiveCategory] = React.useState( + categories[0] || '' + ) + + // Update active category when categories load if not set + React.useEffect(() => { + if (!activeCategory && categories.length > 0) { + setActiveCategory(categories[0]) + } + }, [categories, activeCategory]) + + if (!id) { + return
Profile ID required
+ } + + if (profile === undefined || flashCount === undefined) { + return ( +
+
Loading...
+
+ ) + } + + if (profile === null) { + return ( +
+
Profile not found
+
+ ) + } + + // Get enabled modules (inverted logic: config[id] === false means included) + const enabledModules = modulesData.modules.filter( + (module) => profile.config[module.id] === false + ) + + const handleFlash = () => { + if (selectedTarget) { + navigate(`/profiles/${id}/flash`) + } + } + + return ( +
+
+

{profile.name}

+

+ Version: {profile.version} • Flashed {flashCount} time + {flashCount !== 1 ? 's' : ''} +

+ +
+ {/* Enabled Modules */} +
+

Enabled Modules

+
+ {enabledModules.length === 0 ? ( +

No modules enabled

+ ) : ( +
+ {enabledModules.map((module) => ( +
+

+ {module.name} +

+

+ {module.description} +

+
+ ))} +
+ )} +
+
+ + {/* Target Selection and Flash */} +
+

Flash Firmware

+
+
+
+
+ Select Target +
+
+ {/* Category Pills */} +
+ {categories.map((category) => { + const isActive = activeCategory === category + + return ( + + ) + })} +
+ + {/* Active Category Targets */} +
+
+ {groupedTargets[activeCategory]?.map((item) => { + const isSelected = selectedTarget === item.id + return ( + + ) + })} +
+
+
+
+ +
+
+
+
+
+
+ ) +} diff --git a/src/pages/ProfileFlash.tsx b/src/pages/ProfileFlash.tsx new file mode 100644 index 0000000..8af52f3 --- /dev/null +++ b/src/pages/ProfileFlash.tsx @@ -0,0 +1,17 @@ +import { useParams } from 'react-router-dom' + +export default function ProfileFlash() { + const { id } = useParams<{ id: string }>() + + return ( +
+
+

Flash Firmware

+
+

Flash functionality coming soon...

+

Profile ID: {id}

+
+
+
+ ) +}