diff --git a/biome.json b/biome.json index c799e77..630b2fc 100644 --- a/biome.json +++ b/biome.json @@ -6,7 +6,13 @@ "useIgnoreFile": true }, "files": { - "includes": ["./**", "convex/**", "src/**", "!vendor", "!convex/_generated"] + "includes": [ + "src/**", + "convex/**", + "*.json", + "!vendor", + "!convex/_generated" + ] }, "css": { "parser": { @@ -28,12 +34,12 @@ }, "javascript": { "formatter": { - "quoteStyle": "double", + "quoteStyle": "single", "indentStyle": "space", "indentWidth": 2, "quoteProperties": "asNeeded", - "semicolons": "always", - "trailingCommas": "all" + "semicolons": "asNeeded", + "trailingCommas": "es5" } }, "assist": { diff --git a/convex/actions.ts b/convex/actions.ts index 0ba5a46..06ded00 100644 --- a/convex/actions.ts +++ b/convex/actions.ts @@ -1,76 +1,79 @@ -import { v } from "convex/values"; -import { internal } from "./_generated/api"; -import { action } from "./_generated/server"; +import { v } from 'convex/values' +import { internal } from './_generated/api' +import { action } from './_generated/server' export const dispatchGithubBuild = action({ - args: { - buildId: v.id("builds"), - target: v.string(), - flags: v.string(), - version: v.string(), - buildHash: v.string(), - }, - handler: async (ctx, args) => { - const githubToken = process.env.GITHUB_TOKEN; - if (!githubToken) { - throw new Error("GITHUB_TOKEN is not set"); - } + args: { + buildId: v.id('builds'), + target: v.string(), + flags: v.string(), + version: v.string(), + buildHash: v.string(), + }, + handler: async (ctx, args) => { + const githubToken = process.env.GITHUB_TOKEN + if (!githubToken) { + throw new Error('GITHUB_TOKEN is not set') + } - const convexUrl = process.env.CONVEX_SITE_URL; - if (!convexUrl) { - console.error("CONVEX_SITE_URL is not set"); - // Proceeding anyway might fail if workflow requires it - } + const convexUrl = process.env.CONVEX_SITE_URL + if (!convexUrl) { + console.error('CONVEX_SITE_URL is not set') + // Proceeding anyway might fail if workflow requires it + } - console.log("dispatchGithubBuild args:", JSON.stringify(args, null, 2)); + console.log('dispatchGithubBuild args:', JSON.stringify(args, null, 2)) - if (!args.buildHash) { - throw new Error("args.buildHash is missing or empty"); - } + if (!args.buildHash) { + throw new Error('args.buildHash is missing or empty') + } - const payload = { - ref: "main", // or make this configurable - inputs: { - target: args.target, - flags: args.flags, - version: args.version, - build_id: args.buildId, - build_hash: args.buildHash, - convex_url: convexUrl || "https://example.com", // Fallback to avoid missing input error if that's the cause - }, - }; + const payload = { + ref: 'main', // or make this configurable + inputs: { + target: args.target, + flags: args.flags, + version: args.version, + build_id: args.buildId, + build_hash: args.buildHash, + convex_url: convexUrl || 'https://example.com', // Fallback to avoid missing input error if that's the cause + }, + } - console.log("Dispatching GitHub build with payload:", JSON.stringify(payload, null, 2)); + console.log( + 'Dispatching GitHub build with payload:', + JSON.stringify(payload, null, 2) + ) - try { - const response = await fetch( - "https://api.github.com/repos/MeshEnvy/configurable-web-flasher/actions/workflows/custom_build.yml/dispatches", - { - method: "POST", - headers: { - Authorization: `Bearer ${githubToken}`, - Accept: "application/vnd.github.v3+json", - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }, - ); + try { + const response = await fetch( + 'https://api.github.com/repos/MeshEnvy/configurable-web-flasher/actions/workflows/custom_build.yml/dispatches', + { + method: 'POST', + headers: { + Authorization: `Bearer ${githubToken}`, + Accept: 'application/vnd.github.v3+json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + } + ) - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`GitHub API failed: ${response.status} ${errorText}`); - } + if (!response.ok) { + const errorText = await response.text() + throw new Error(`GitHub API failed: ${response.status} ${errorText}`) + } - // Note: GitHub dispatch API doesn't return the run ID immediately. - // We rely on the webhook to link the run back to our build record. - // Alternatively, we could poll for the most recent run, but that's race-condition prone. - } catch (error) { - await ctx.runMutation(internal.builds.logBuildError, { - buildId: args.buildId, - error: String(error), - }); - // Re-throw so it shows up in Convex logs too - throw error; - } - }, -}); + // Note: GitHub dispatch API doesn't return the run ID immediately. + // We rely on the webhook to link the run back to our build record. + // Alternatively, we could poll for the most recent run, but that's race-condition prone. + } catch (error) { + await ctx.runMutation(internal.builds.logBuildError, { + buildId: args.buildId, + error: String(error), + }) + // Re-throw so it shows up in Convex logs too + throw error + } + }, +}) diff --git a/convex/auth.config.ts b/convex/auth.config.ts index f4eb564..afc6264 100644 --- a/convex/auth.config.ts +++ b/convex/auth.config.ts @@ -2,7 +2,7 @@ export default { providers: [ { domain: process.env.CONVEX_SITE_URL, - applicationID: "convex", + applicationID: 'convex', }, ], -}; +} diff --git a/convex/auth.ts b/convex/auth.ts index 0d86c34..528be55 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -1,6 +1,6 @@ -import Google from "@auth/core/providers/google"; -import { convexAuth } from "@convex-dev/auth/server"; +import Google from '@auth/core/providers/google' +import { convexAuth } from '@convex-dev/auth/server' export const { auth, signIn, signOut, store } = convexAuth({ - providers: [Google], -}); + providers: [Google], +}) diff --git a/convex/builds.ts b/convex/builds.ts index 166c0b1..39f3ec1 100644 --- a/convex/builds.ts +++ b/convex/builds.ts @@ -1,14 +1,14 @@ -import { getAuthUserId } from "@convex-dev/auth/server"; -import { v } from "convex/values"; -import { api } from "./_generated/api"; -import { internalMutation, mutation, query } from "./_generated/server"; -import modulesData from "./modules.json"; +import { getAuthUserId } from '@convex-dev/auth/server' +import { v } from 'convex/values' +import { api } from './_generated/api' +import { internalMutation, mutation, query } from './_generated/server' +import modulesData from './modules.json' type BuildUpdateData = { - status: string; - completedAt?: number; - artifactUrl?: string; -}; + status: string + completedAt?: number + artifactUrl?: string +} /** * Computes a stable SHA-256 hash from version, target, and flags. @@ -17,25 +17,23 @@ type BuildUpdateData = { async function computeBuildHash( version: string, target: string, - flags: string, + flags: string ): Promise { // Input is now the exact parameters used for the build const input = JSON.stringify({ version, target, flags, - }); + }) // Use Web Crypto API for SHA-256 hashing - const encoder = new TextEncoder(); - const data = encoder.encode(input); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); + const encoder = new TextEncoder() + const data = encoder.encode(input) + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') - return hashHex; + return hashHex } /** @@ -44,46 +42,46 @@ async function computeBuildHash( * Or custom domain if R2_PUBLIC_URL is set. */ function getR2ArtifactUrl(buildHash: string): string { - const r2PublicUrl = process.env.R2_PUBLIC_URL; + const r2PublicUrl = process.env.R2_PUBLIC_URL if (r2PublicUrl) { // Custom domain configured - return `${r2PublicUrl}/${buildHash}.uf2`; + return `${r2PublicUrl}/${buildHash}.uf2` } // Default R2 public URL pattern (requires public bucket) - const bucketName = process.env.R2_BUCKET_NAME || "firmware-builds"; - const accountId = process.env.R2_ACCOUNT_ID || ""; + const bucketName = process.env.R2_BUCKET_NAME || 'firmware-builds' + const accountId = process.env.R2_ACCOUNT_ID || '' if (accountId) { - return `https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${buildHash}.uf2`; + return `https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${buildHash}.uf2` } // Fallback: assume custom domain or public bucket URL - return `https://${bucketName}.r2.cloudflarestorage.com/${buildHash}.uf2`; + return `https://${bucketName}.r2.cloudflarestorage.com/${buildHash}.uf2` } export const triggerBuild = mutation({ args: { - profileId: v.id("profiles"), + profileId: v.id('profiles'), }, handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Unauthorized"); + const userId = await getAuthUserId(ctx) + if (!userId) throw new Error('Unauthorized') - const profile = await ctx.db.get(args.profileId); + const profile = await ctx.db.get(args.profileId) if (!profile || profile.userId !== userId) { - throw new Error("Unauthorized"); + throw new Error('Unauthorized') } // Convert config object to flags string - const flags: string[] = []; + const flags: string[] = [] // Handle Modules (Inverted Logic: Default Excluded) for (const module of modulesData.modules) { // If config[id] is NOT false (explicitly included), we exclude it. if (profile.config[module.id] !== false) { - flags.push(`-D${module.id}=1`); + flags.push(`-D${module.id}=1`) } } - const flagsString = flags.join(" "); + const flagsString = flags.join(' ') // Create build records for each target for (const target of profile.targets) { @@ -91,44 +89,44 @@ export const triggerBuild = mutation({ const buildHash = await computeBuildHash( profile.version, target, - flagsString, - ); + flagsString + ) console.log( - `Computed build hash for ${target}: ${buildHash} (Flags: ${flagsString})`, - ); + `Computed build hash for ${target}: ${buildHash} (Flags: ${flagsString})` + ) // Check cache for existing build const cached = await ctx.db - .query("buildCache") - .withIndex("by_hash_target", (q) => - q.eq("buildHash", buildHash).eq("target", target), + .query('buildCache') + .withIndex('by_hash_target', (q) => + q.eq('buildHash', buildHash).eq('target', target) ) - .first(); + .first() if (cached) { // Use cached artifact, skip GitHub workflow - const artifactUrl = getR2ArtifactUrl(buildHash); - await ctx.db.insert("builds", { + const artifactUrl = getR2ArtifactUrl(buildHash) + await ctx.db.insert('builds', { profileId: profile._id, target: target, githubRunId: 0, - status: "success", + status: 'success', artifactUrl: artifactUrl, startedAt: Date.now(), completedAt: Date.now(), buildHash: buildHash, - }); + }) } else { // Not cached, proceed with normal build flow - const buildId = await ctx.db.insert("builds", { + const buildId = await ctx.db.insert('builds', { profileId: profile._id, target: target, githubRunId: 0, - status: "queued", + status: 'queued', startedAt: Date.now(), buildHash: buildHash, - }); + }) // Schedule the action to dispatch GitHub workflow await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, { @@ -137,107 +135,107 @@ export const triggerBuild = mutation({ flags: flagsString, version: profile.version, buildHash: buildHash, - }); + }) } } }, -}); +}) export const listByProfile = query({ - args: { profileId: v.id("profiles") }, + args: { profileId: v.id('profiles') }, handler: async (ctx, args) => { return await ctx.db - .query("builds") - .withIndex("by_profile", (q) => q.eq("profileId", args.profileId)) - .order("desc") - .take(10); + .query('builds') + .withIndex('by_profile', (q) => q.eq('profileId', args.profileId)) + .order('desc') + .take(10) }, -}); +}) export const get = query({ - args: { buildId: v.id("builds") }, + args: { buildId: v.id('builds') }, handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) return null; + const userId = await getAuthUserId(ctx) + if (!userId) return null - const build = await ctx.db.get(args.buildId); - if (!build) return null; + const build = await ctx.db.get(args.buildId) + if (!build) return null - const profile = await ctx.db.get(build.profileId); - if (!profile || profile.userId !== userId) return null; + const profile = await ctx.db.get(build.profileId) + if (!profile || profile.userId !== userId) return null - return build; + return build }, -}); +}) // Internal query to get build without auth checks (for webhooks) export const getInternal = internalMutation({ - args: { buildId: v.id("builds") }, + args: { buildId: v.id('builds') }, handler: async (ctx, args) => { - return await ctx.db.get(args.buildId); + return await ctx.db.get(args.buildId) }, -}); +}) export const deleteBuild = mutation({ - args: { buildId: v.id("builds") }, + args: { buildId: v.id('builds') }, handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Unauthorized"); + const userId = await getAuthUserId(ctx) + if (!userId) throw new Error('Unauthorized') - const build = await ctx.db.get(args.buildId); - if (!build) throw new Error("Build not found"); + const build = await ctx.db.get(args.buildId) + if (!build) throw new Error('Build not found') - const profile = await ctx.db.get(build.profileId); + const profile = await ctx.db.get(build.profileId) if (!profile || profile.userId !== userId) { - throw new Error("Unauthorized"); + throw new Error('Unauthorized') } - await ctx.db.delete(args.buildId); + await ctx.db.delete(args.buildId) }, -}); +}) export const retryBuild = mutation({ - args: { buildId: v.id("builds") }, + args: { buildId: v.id('builds') }, handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Unauthorized"); + const userId = await getAuthUserId(ctx) + if (!userId) throw new Error('Unauthorized') - const build = await ctx.db.get(args.buildId); - if (!build) throw new Error("Build not found"); + const build = await ctx.db.get(args.buildId) + if (!build) throw new Error('Build not found') - const profile = await ctx.db.get(build.profileId); + const profile = await ctx.db.get(build.profileId) if (!profile || profile.userId !== userId) { - throw new Error("Unauthorized"); + throw new Error('Unauthorized') } // Reset build status await ctx.db.patch(args.buildId, { - status: "queued", + status: 'queued', startedAt: Date.now(), completedAt: undefined, - }); + }) // Convert config object to flags string - const flags: string[] = []; + const flags: string[] = [] // Handle Modules (Inverted Logic: Default Excluded) for (const module of modulesData.modules) { // If config[id] is NOT false (explicitly included), we exclude it. if (profile.config[module.id] !== false) { - flags.push(`-D${module.id}=1`); + flags.push(`-D${module.id}=1`) } } - const flagsString = flags.join(" "); + const flagsString = flags.join(' ') // Compute build hash for retry using flags const buildHash = await computeBuildHash( profile.version, build.target, - flagsString, - ); + flagsString + ) - console.log(`Computed retry hash: ${buildHash} (Flags: ${flagsString})`); + console.log(`Computed retry hash: ${buildHash} (Flags: ${flagsString})`) await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, { buildId: args.buildId, @@ -245,82 +243,82 @@ export const retryBuild = mutation({ flags: flagsString, version: profile.version, buildHash: buildHash, - }); + }) }, -}); +}) // Internal mutation to log errors from actions export const logBuildError = internalMutation({ args: { - buildId: v.id("builds"), + buildId: v.id('builds'), error: v.string(), }, handler: async (ctx, args) => { await ctx.db.patch(args.buildId, { - status: "failure", + status: 'failure', completedAt: Date.now(), - }); + }) }, -}); +}) // Internal mutation to update build status export const updateBuildStatus = internalMutation({ args: { - buildId: v.id("builds"), + buildId: v.id('builds'), status: v.string(), // Accepts any status string value artifactUrl: v.optional(v.string()), }, handler: async (ctx, args) => { - const build = await ctx.db.get(args.buildId); - if (!build) return; + const build = await ctx.db.get(args.buildId) + if (!build) return const updateData: BuildUpdateData = { status: args.status, - }; + } // Only set completedAt for final statuses - if (args.status === "success" || args.status === "failure") { - updateData.completedAt = Date.now(); + if (args.status === 'success' || args.status === 'failure') { + updateData.completedAt = Date.now() } if (args.artifactUrl) { - updateData.artifactUrl = args.artifactUrl; + updateData.artifactUrl = args.artifactUrl } - await ctx.db.patch(args.buildId, updateData); + await ctx.db.patch(args.buildId, updateData) // If build succeeded, store in cache with R2 URL - if (args.status === "success" && build.buildHash && build.target) { + if (args.status === 'success' && build.buildHash && build.target) { // Get version from profile - const profile = await ctx.db.get(build.profileId); + const profile = await ctx.db.get(build.profileId) if (profile) { // Construct R2 URL from hash - const artifactUrl = getR2ArtifactUrl(build.buildHash); + const artifactUrl = getR2ArtifactUrl(build.buildHash) // Update build with R2 URL if not already set if (!args.artifactUrl) { - await ctx.db.patch(args.buildId, { artifactUrl }); + await ctx.db.patch(args.buildId, { artifactUrl }) } // Check if cache entry already exists const existing = await ctx.db - .query("buildCache") - .withIndex("by_hash_target", (q) => - q.eq("buildHash", build.buildHash).eq("target", build.target), + .query('buildCache') + .withIndex('by_hash_target', (q) => + q.eq('buildHash', build.buildHash).eq('target', build.target) ) - .first(); + .first() if (!existing) { // Store in cache - await ctx.db.insert("buildCache", { + await ctx.db.insert('buildCache', { buildHash: build.buildHash, target: build.target, artifactUrl: artifactUrl, version: profile.version, createdAt: Date.now(), - }); + }) } } } }, -}); +}) diff --git a/convex/http.ts b/convex/http.ts index 54c8073..561c519 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -1,46 +1,46 @@ -import { httpRouter } from "convex/server"; -import { internal } from "./_generated/api"; -import { httpAction } from "./_generated/server"; -import { auth } from "./auth"; +import { httpRouter } from 'convex/server' +import { internal } from './_generated/api' +import { httpAction } from './_generated/server' +import { auth } from './auth' -const http = httpRouter(); +const http = httpRouter() -auth.addHttpRoutes(http); +auth.addHttpRoutes(http) http.route({ - path: "/github-webhook", - method: "POST", + path: '/github-webhook', + method: 'POST', handler: httpAction(async (ctx, request) => { - const payload = await request.json(); + const payload = await request.json() // Verify signature (TODO: Add HMAC verification) // Validate build_id is present if (!payload.build_id || !payload.status) { - return new Response("Missing build_id or status", { status: 400 }); + return new Response('Missing build_id or status', { status: 400 }) } // Verify build exists const build = await ctx.runMutation(internal.builds.getInternal, { buildId: payload.build_id, - }); + }) if (!build) { - return new Response("Build not found", { status: 404 }); + return new Response('Build not found', { status: 404 }) } // Handle status updates (intermediate statuses) and completion (final statuses) - if (payload.action === "status_update" || payload.action === "completed") { + if (payload.action === 'status_update' || payload.action === 'completed') { await ctx.runMutation(internal.builds.updateBuildStatus, { buildId: payload.build_id, status: payload.status, - }); + }) - return new Response(null, { status: 200 }); + return new Response(null, { status: 200 }) } - return new Response(null, { status: 200 }); + return new Response(null, { status: 200 }) }), -}); +}) -export default http; +export default http diff --git a/convex/modules.json b/convex/modules.json index 48c6a9e..7830a8a 100644 --- a/convex/modules.json +++ b/convex/modules.json @@ -1,159 +1,159 @@ { - "modules": [ - { - "id": "MESHTASTIC_EXCLUDE_ADMIN", - "name": "Admin", - "description": "Remote device configuration and management. Allows changing settings, reading device info, and rebooting nodes over the mesh network." - }, - { - "id": "MESHTASTIC_EXCLUDE_ATAK", - "name": "ATAK Plugin", - "description": "Integration with ATAK (Android Team Awareness Kit) for tactical situational awareness. Enables military/emergency response coordination." - }, - { - "id": "MESHTASTIC_EXCLUDE_AUDIO", - "name": "Audio", - "description": "Audio codec support for voice communication over the mesh." - }, - { - "id": "MESHTASTIC_EXCLUDE_BLUETOOTH", - "name": "Bluetooth", - "description": "Bluetooth connectivity for pairing with phones and apps. Required for mobile app communication on most devices." - }, - { - "id": "MESHTASTIC_EXCLUDE_CANNEDMESSAGES", - "name": "Canned Messages", - "description": "Pre-defined quick messages that can be sent with button presses. Useful for devices with limited input (no keyboard). Includes on-screen keyboard for some devices." - }, - { - "id": "MESHTASTIC_EXCLUDE_DETECTIONSENSOR", - "name": "Detection Sensor", - "description": "Motion/presence detection sensor integration. Broadcasts detection events when sensors trigger (PIR, door switches, etc.)." - }, - { - "id": "MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR", - "name": "Environmental Sensor", - "description": "Environmental monitoring sensors including temperature, humidity, pressure, air quality, and light sensors. Broadcasts telemetry data to the mesh." - }, - { - "id": "MESHTASTIC_EXCLUDE_EXTERNALNOTIFICATION", - "name": "External Notification", - "description": "Drive external LEDs, buzzers, and speakers for notifications. Plays RTTTL ringtones and can control GPIO outputs when messages arrive." - }, - { - "id": "MESHTASTIC_EXCLUDE_GPS", - "name": "GPS", - "description": "GPS receiver support for position tracking and sharing. Disabling prevents position broadcasts but the device still relays position packets for other nodes." - }, - { - "id": "MESHTASTIC_EXCLUDE_HEALTH_TELEMETRY", - "name": "Health Telemetry", - "description": "Heart rate and health monitoring sensors (like MAX30102 pulse oximeter). Broadcasts health metrics to the mesh." - }, - { - "id": "MESHTASTIC_EXCLUDE_I2C", - "name": "I2C", - "description": "I2C bus support for external sensors and peripherals. Required for most sensor modules and OLED displays." - }, - { - "id": "MESHTASTIC_EXCLUDE_INPUTBROKER", - "name": "Input Broker", - "description": "Input device handling (buttons, encoders, touchscreens). Routes button presses to appropriate modules like Canned Messages." - }, - { - "id": "MESHTASTIC_EXCLUDE_MQTT", - "name": "MQTT", - "description": "MQTT client for cloud integration. Publishes mesh messages to MQTT brokers and subscribes to receive cloud messages. Enables IoT integration and remote monitoring." - }, - { - "id": "MESHTASTIC_EXCLUDE_NEIGHBORINFO", - "name": "Neighbor Info", - "description": "Broadcasts information about directly-reachable neighbor nodes including signal strength (SNR). Helps build mesh topology maps and track network health." - }, - { - "id": "MESHTASTIC_EXCLUDE_PAXCOUNTER", - "name": "Pax Counter", - "description": "Counts nearby WiFi and Bluetooth devices for crowd density estimation. Useful for people-counting in public spaces." - }, - { - "id": "MESHTASTIC_EXCLUDE_PKI", - "name": "PKI", - "description": "Public Key Infrastructure for enhanced security and key verification between nodes." - }, - { - "id": "MESHTASTIC_EXCLUDE_POWERMON", - "name": "Power Monitor", - "description": "Battery and power monitoring hardware support (INA260, INA219, etc.). Tracks voltage, current, and power consumption." - }, - { - "id": "MESHTASTIC_EXCLUDE_POWER_FSM", - "name": "Power FSM", - "description": "Power management finite state machine. Handles sleep modes, power state transitions, and battery optimization." - }, - { - "id": "MESHTASTIC_EXCLUDE_POWER_TELEMETRY", - "name": "Power Telemetry", - "description": "Broadcasts battery voltage, current, and power consumption data to the mesh. Different from Power Monitor which is the hardware interface." - }, - { - "id": "MESHTASTIC_EXCLUDE_POWERSTRESS", - "name": "Power Stress", - "description": "Power consumption testing tool. Stresses the device to measure battery life under various transmission patterns. For development/testing only." - }, - { - "id": "MESHTASTIC_EXCLUDE_RANGETEST", - "name": "Range Test", - "description": "Mesh range and signal quality testing. Sends periodic packets and logs signal strength (RSSI), SNR, and packet loss statistics to a file." - }, - { - "id": "MESHTASTIC_EXCLUDE_REMOTEHARDWARE", - "name": "Remote Hardware", - "description": "Remote GPIO control over the mesh. Read/write digital pins, read ADC values, and control hardware on remote nodes." - }, - { - "id": "MESHTASTIC_EXCLUDE_SCREEN", - "name": "Screen", - "description": "OLED/E-Ink display support. Shows messages, node info, and status on screen. Disabling saves power but removes visual feedback." - }, - { - "id": "MESHTASTIC_EXCLUDE_SERIAL", - "name": "Serial", - "description": "Serial port communication for sensors and external devices. Can relay serial data over the mesh and supports NMEA GPS bridging." - }, - { - "id": "MESHTASTIC_EXCLUDE_STOREFORWARD", - "name": "Store & Forward", - "description": "Message store-and-forward server for offline nodes. Router devices can cache messages and replay them when distant nodes reconnect. Requires PSRAM." - }, - { - "id": "MESHTASTIC_EXCLUDE_TEXTMESSAGE", - "name": "Text Messaging", - "description": "Send and receive text messages between nodes. Displays messages on OLED screens and forwards to connected apps. **Important:** Disabling prevents sending/receiving but the node still relays messages for others." - }, - { - "id": "MESHTASTIC_EXCLUDE_TRACEROUTE", - "name": "Traceroute", - "description": "Network path tracing tool. Shows the route packets take through the mesh, including all intermediate hops and hop limits." - }, - { - "id": "MESHTASTIC_EXCLUDE_TZ", - "name": "Timezone", - "description": "Timezone database support for local time display. Allows devices to show correct local time based on GPS position." - }, - { - "id": "MESHTASTIC_EXCLUDE_WAYPOINT", - "name": "Waypoint", - "description": "Share and display waypoints (points of interest) on the mesh. Shows waypoints on screen and in apps for navigation and location marking." - }, - { - "id": "MESHTASTIC_EXCLUDE_WEBSERVER", - "name": "Web Server", - "description": "Built-in web interface for device configuration. Automatically excluded if WiFi is disabled." - }, - { - "id": "MESHTASTIC_EXCLUDE_WIFI", - "name": "WiFi", - "description": "WiFi connectivity for network access, web server, and MQTT. Disabling saves power but removes WiFi features including the web interface." - } - ] + "modules": [ + { + "id": "MESHTASTIC_EXCLUDE_ADMIN", + "name": "Admin", + "description": "Remote device configuration and management. Allows changing settings, reading device info, and rebooting nodes over the mesh network." + }, + { + "id": "MESHTASTIC_EXCLUDE_ATAK", + "name": "ATAK Plugin", + "description": "Integration with ATAK (Android Team Awareness Kit) for tactical situational awareness. Enables military/emergency response coordination." + }, + { + "id": "MESHTASTIC_EXCLUDE_AUDIO", + "name": "Audio", + "description": "Audio codec support for voice communication over the mesh." + }, + { + "id": "MESHTASTIC_EXCLUDE_BLUETOOTH", + "name": "Bluetooth", + "description": "Bluetooth connectivity for pairing with phones and apps. Required for mobile app communication on most devices." + }, + { + "id": "MESHTASTIC_EXCLUDE_CANNEDMESSAGES", + "name": "Canned Messages", + "description": "Pre-defined quick messages that can be sent with button presses. Useful for devices with limited input (no keyboard). Includes on-screen keyboard for some devices." + }, + { + "id": "MESHTASTIC_EXCLUDE_DETECTIONSENSOR", + "name": "Detection Sensor", + "description": "Motion/presence detection sensor integration. Broadcasts detection events when sensors trigger (PIR, door switches, etc.)." + }, + { + "id": "MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR", + "name": "Environmental Sensor", + "description": "Environmental monitoring sensors including temperature, humidity, pressure, air quality, and light sensors. Broadcasts telemetry data to the mesh." + }, + { + "id": "MESHTASTIC_EXCLUDE_EXTERNALNOTIFICATION", + "name": "External Notification", + "description": "Drive external LEDs, buzzers, and speakers for notifications. Plays RTTTL ringtones and can control GPIO outputs when messages arrive." + }, + { + "id": "MESHTASTIC_EXCLUDE_GPS", + "name": "GPS", + "description": "GPS receiver support for position tracking and sharing. Disabling prevents position broadcasts but the device still relays position packets for other nodes." + }, + { + "id": "MESHTASTIC_EXCLUDE_HEALTH_TELEMETRY", + "name": "Health Telemetry", + "description": "Heart rate and health monitoring sensors (like MAX30102 pulse oximeter). Broadcasts health metrics to the mesh." + }, + { + "id": "MESHTASTIC_EXCLUDE_I2C", + "name": "I2C", + "description": "I2C bus support for external sensors and peripherals. Required for most sensor modules and OLED displays." + }, + { + "id": "MESHTASTIC_EXCLUDE_INPUTBROKER", + "name": "Input Broker", + "description": "Input device handling (buttons, encoders, touchscreens). Routes button presses to appropriate modules like Canned Messages." + }, + { + "id": "MESHTASTIC_EXCLUDE_MQTT", + "name": "MQTT", + "description": "MQTT client for cloud integration. Publishes mesh messages to MQTT brokers and subscribes to receive cloud messages. Enables IoT integration and remote monitoring." + }, + { + "id": "MESHTASTIC_EXCLUDE_NEIGHBORINFO", + "name": "Neighbor Info", + "description": "Broadcasts information about directly-reachable neighbor nodes including signal strength (SNR). Helps build mesh topology maps and track network health." + }, + { + "id": "MESHTASTIC_EXCLUDE_PAXCOUNTER", + "name": "Pax Counter", + "description": "Counts nearby WiFi and Bluetooth devices for crowd density estimation. Useful for people-counting in public spaces." + }, + { + "id": "MESHTASTIC_EXCLUDE_PKI", + "name": "PKI", + "description": "Public Key Infrastructure for enhanced security and key verification between nodes." + }, + { + "id": "MESHTASTIC_EXCLUDE_POWERMON", + "name": "Power Monitor", + "description": "Battery and power monitoring hardware support (INA260, INA219, etc.). Tracks voltage, current, and power consumption." + }, + { + "id": "MESHTASTIC_EXCLUDE_POWER_FSM", + "name": "Power FSM", + "description": "Power management finite state machine. Handles sleep modes, power state transitions, and battery optimization." + }, + { + "id": "MESHTASTIC_EXCLUDE_POWER_TELEMETRY", + "name": "Power Telemetry", + "description": "Broadcasts battery voltage, current, and power consumption data to the mesh. Different from Power Monitor which is the hardware interface." + }, + { + "id": "MESHTASTIC_EXCLUDE_POWERSTRESS", + "name": "Power Stress", + "description": "Power consumption testing tool. Stresses the device to measure battery life under various transmission patterns. For development/testing only." + }, + { + "id": "MESHTASTIC_EXCLUDE_RANGETEST", + "name": "Range Test", + "description": "Mesh range and signal quality testing. Sends periodic packets and logs signal strength (RSSI), SNR, and packet loss statistics to a file." + }, + { + "id": "MESHTASTIC_EXCLUDE_REMOTEHARDWARE", + "name": "Remote Hardware", + "description": "Remote GPIO control over the mesh. Read/write digital pins, read ADC values, and control hardware on remote nodes." + }, + { + "id": "MESHTASTIC_EXCLUDE_SCREEN", + "name": "Screen", + "description": "OLED/E-Ink display support. Shows messages, node info, and status on screen. Disabling saves power but removes visual feedback." + }, + { + "id": "MESHTASTIC_EXCLUDE_SERIAL", + "name": "Serial", + "description": "Serial port communication for sensors and external devices. Can relay serial data over the mesh and supports NMEA GPS bridging." + }, + { + "id": "MESHTASTIC_EXCLUDE_STOREFORWARD", + "name": "Store & Forward", + "description": "Message store-and-forward server for offline nodes. Router devices can cache messages and replay them when distant nodes reconnect. Requires PSRAM." + }, + { + "id": "MESHTASTIC_EXCLUDE_TEXTMESSAGE", + "name": "Text Messaging", + "description": "Send and receive text messages between nodes. Displays messages on OLED screens and forwards to connected apps. **Important:** Disabling prevents sending/receiving but the node still relays messages for others." + }, + { + "id": "MESHTASTIC_EXCLUDE_TRACEROUTE", + "name": "Traceroute", + "description": "Network path tracing tool. Shows the route packets take through the mesh, including all intermediate hops and hop limits." + }, + { + "id": "MESHTASTIC_EXCLUDE_TZ", + "name": "Timezone", + "description": "Timezone database support for local time display. Allows devices to show correct local time based on GPS position." + }, + { + "id": "MESHTASTIC_EXCLUDE_WAYPOINT", + "name": "Waypoint", + "description": "Share and display waypoints (points of interest) on the mesh. Shows waypoints on screen and in apps for navigation and location marking." + }, + { + "id": "MESHTASTIC_EXCLUDE_WEBSERVER", + "name": "Web Server", + "description": "Built-in web interface for device configuration. Automatically excluded if WiFi is disabled." + }, + { + "id": "MESHTASTIC_EXCLUDE_WIFI", + "name": "WiFi", + "description": "WiFi connectivity for network access, web server, and MQTT. Disabling saves power but removes WiFi features including the web interface." + } + ] } diff --git a/convex/profiles.ts b/convex/profiles.ts index e4b81a7..deb05fb 100644 --- a/convex/profiles.ts +++ b/convex/profiles.ts @@ -1,80 +1,80 @@ -import { getAuthUserId } from "@convex-dev/auth/server"; -import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; +import { getAuthUserId } from '@convex-dev/auth/server' +import { v } from 'convex/values' +import { mutation, query } from './_generated/server' export const list = query({ - args: {}, - handler: async (ctx) => { - const userId = await getAuthUserId(ctx); - if (!userId) return []; + args: {}, + handler: async (ctx) => { + const userId = await getAuthUserId(ctx) + if (!userId) return [] - return await ctx.db - .query("profiles") - .withIndex("by_user", (q) => q.eq("userId", userId)) - .collect(); - }, -}); + return await ctx.db + .query('profiles') + .withIndex('by_user', (q) => q.eq('userId', userId)) + .collect() + }, +}) export const create = mutation({ - args: { - name: v.string(), - targets: v.array(v.string()), - config: v.any(), - version: v.string(), - }, - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Unauthorized"); + args: { + name: v.string(), + targets: v.array(v.string()), + config: v.any(), + version: v.string(), + }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx) + if (!userId) throw new Error('Unauthorized') - return await ctx.db.insert("profiles", { - userId, - name: args.name, - targets: args.targets, - config: args.config, - version: args.version, - updatedAt: Date.now(), - }); - }, -}); + return await ctx.db.insert('profiles', { + userId, + name: args.name, + targets: args.targets, + config: args.config, + version: args.version, + updatedAt: Date.now(), + }) + }, +}) export const update = mutation({ - args: { - id: v.id("profiles"), - name: v.string(), - targets: v.array(v.string()), - config: v.any(), - version: v.optional(v.string()), - }, - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Unauthorized"); + args: { + id: v.id('profiles'), + name: v.string(), + targets: v.array(v.string()), + config: v.any(), + version: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx) + if (!userId) throw new Error('Unauthorized') - const profile = await ctx.db.get(args.id); - if (!profile || profile.userId !== userId) { - throw new Error("Unauthorized"); - } + const profile = await ctx.db.get(args.id) + if (!profile || profile.userId !== userId) { + throw new Error('Unauthorized') + } - await ctx.db.patch(args.id, { - name: args.name, - targets: args.targets, - config: args.config, - version: args.version, - updatedAt: Date.now(), - }); - }, -}); + await ctx.db.patch(args.id, { + name: args.name, + targets: args.targets, + config: args.config, + version: args.version, + updatedAt: Date.now(), + }) + }, +}) export const remove = mutation({ - args: { id: v.id("profiles") }, - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new Error("Unauthorized"); + args: { id: v.id('profiles') }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx) + if (!userId) throw new Error('Unauthorized') - const profile = await ctx.db.get(args.id); - if (!profile || profile.userId !== userId) { - throw new Error("Unauthorized"); - } + const profile = await ctx.db.get(args.id) + if (!profile || profile.userId !== userId) { + throw new Error('Unauthorized') + } - await ctx.db.delete(args.id); - }, -}); + await ctx.db.delete(args.id) + }, +}) diff --git a/convex/schema.ts b/convex/schema.ts index 6e6d089..2c29b71 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1,19 +1,19 @@ -import { authTables } from "@convex-dev/auth/server"; -import { defineSchema, defineTable } from "convex/server"; -import { v } from "convex/values"; +import { authTables } from '@convex-dev/auth/server' +import { defineSchema, defineTable } from 'convex/server' +import { v } from 'convex/values' export default defineSchema({ ...authTables, profiles: defineTable({ - userId: v.id("users"), + 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"]), + }).index('by_user', ['userId']), builds: defineTable({ - profileId: v.id("profiles"), + profileId: v.id('profiles'), target: v.string(), githubRunId: v.number(), status: v.string(), // Accepts arbitrary status strings (e.g., "queued", "checking_out", "building", "uploading", "success", "failure") @@ -21,7 +21,7 @@ export default defineSchema({ startedAt: v.number(), completedAt: v.optional(v.number()), buildHash: v.string(), - }).index("by_profile", ["profileId"]), + }).index('by_profile', ['profileId']), buildCache: defineTable({ buildHash: v.string(), @@ -29,5 +29,5 @@ export default defineSchema({ artifactUrl: v.string(), version: v.string(), createdAt: v.number(), - }).index("by_hash_target", ["buildHash", "target"]), -}); + }).index('by_hash_target', ['buildHash', 'target']), +}) diff --git a/package.json b/package.json index 562c003..ec0a904 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "dev": "bun run generate:versions && vite", "build": "bun run generate:versions && tsc && vite build", "lint": "biome lint", - "lint:fix": "biome lint --fix", + "lint:fix": "biome lint --fix && biome format --write", "preview": "vite preview", "deploy": "npx convex deploy --cmd 'bun run build' && wrangler deploy" }, diff --git a/src/App.tsx b/src/App.tsx index 50e6e8b..27cb816 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,37 +1,37 @@ -import { Authenticated, AuthLoading, Unauthenticated } from "convex/react"; -import { Loader2 } from "lucide-react"; -import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; -import { Toaster } from "@/components/ui/sonner"; -import BuildDetail from "./pages/BuildDetail"; -import Dashboard from "./pages/Dashboard"; -import LandingPage from "./pages/LandingPage"; +import { Authenticated, AuthLoading, Unauthenticated } from 'convex/react' +import { Loader2 } from 'lucide-react' +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { Toaster } from '@/components/ui/sonner' +import BuildDetail from './pages/BuildDetail' +import Dashboard from './pages/Dashboard' +import LandingPage from './pages/LandingPage' function App() { - return ( - - -
- -
-
+ return ( + + +
+ +
+
- - - } /> - } /> - - + + + } /> + } /> + + - - - } /> - } /> - } /> - - - -
- ); + + + } /> + } /> + } /> + + + +
+ ) } -export default App; +export default App diff --git a/src/components/BuildsPanel.tsx b/src/components/BuildsPanel.tsx index d00edce..2a734ad 100644 --- a/src/components/BuildsPanel.tsx +++ b/src/components/BuildsPanel.tsx @@ -1,82 +1,75 @@ -import { useMutation, useQuery } from "convex/react"; -import { - CheckCircle, - Clock, - Loader2, - RotateCw, - Trash2, - XCircle, -} from "lucide-react"; -import { Link } from "react-router-dom"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { humanizeStatus, timeAgo } from "@/lib/utils"; -import { api } from "../../convex/_generated/api"; -import type { Id } from "../../convex/_generated/dataModel"; +import { useMutation, useQuery } from 'convex/react' +import { CheckCircle, Loader2, RotateCw, Trash2, XCircle } from 'lucide-react' +import { Link } from 'react-router-dom' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { humanizeStatus, timeAgo } from '@/lib/utils' +import { api } from '../../convex/_generated/api' +import type { Id } from '../../convex/_generated/dataModel' interface BuildsPanelProps { - profileId: Id<"profiles">; + profileId: Id<'profiles'> } export default function BuildsPanel({ profileId }: BuildsPanelProps) { - const builds = useQuery(api.builds.listByProfile, { profileId }); - const deleteBuild = useMutation(api.builds.deleteBuild); - const retryBuild = useMutation(api.builds.retryBuild); + const builds = useQuery(api.builds.listByProfile, { profileId }) + const deleteBuild = useMutation(api.builds.deleteBuild) + const retryBuild = useMutation(api.builds.retryBuild) const getStatusIcon = (status: string) => { - if (status === "success") { - return ; + if (status === 'success') { + return } - if (status === "failure") { - return ; + if (status === 'failure') { + return } // All other statuses show as in progress - return ; - }; + return + } const getStatusColor = (status: string) => { - if (status === "success") { - return "text-green-400"; + if (status === 'success') { + return 'text-green-400' } - if (status === "failure") { - return "text-red-400"; + if (status === 'failure') { + return 'text-red-400' } // All other statuses show as in progress - return "text-blue-400"; - }; + return 'text-blue-400' + } - const handleDelete = async (buildId: Id<"builds">) => { + const handleDelete = async (buildId: Id<'builds'>) => { try { - await deleteBuild({ buildId }); - toast.success("Build deleted", { - description: "Build record has been removed.", - }); + await deleteBuild({ buildId }) + toast.success('Build deleted', { + description: 'Build record has been removed.', + }) } catch (error) { - toast.error("Delete failed", { + toast.error('Delete failed', { description: String(error), - }); + }) } - }; + } - const handleRetry = async (buildId: Id<"builds">) => { + const handleRetry = async (buildId: Id<'builds'>) => { try { - await retryBuild({ buildId }); - toast.success("Build retrying", { - description: "Build has been queued again.", - }); + await retryBuild({ buildId }) + toast.success('Build retrying', { + description: 'Build has been queued again.', + }) } catch (error) { - toast.error("Retry failed", { + toast.error('Retry failed', { description: String(error), - }); + }) } - }; + } if (!builds || builds.length === 0) { return (
No builds yet. Click "Build" to start.
- ); + ) } return ( @@ -108,14 +101,14 @@ export default function BuildsPanel({ profileId }: BuildsPanelProps) {
- {build.status === "failure" && ( + {build.status === 'failure' && (
- ); + ) } diff --git a/src/components/ModuleCard.tsx b/src/components/ModuleCard.tsx index 51c8309..73f0140 100644 --- a/src/components/ModuleCard.tsx +++ b/src/components/ModuleCard.tsx @@ -1,62 +1,62 @@ interface ModuleCardProps { - name: string; - description: string; - selected: boolean; - onClick: () => void; + name: string + description: string + selected: boolean + onClick: () => void } export function ModuleCard({ - name, - description, - selected, - onClick, + name, + description, + selected, + onClick, }: ModuleCardProps) { - return ( - - ); + > + {selected && ( + + Checkmark + + + )} + + +
+

{name}

+

+ {description} +

+
+ + + ) } diff --git a/src/components/ProfileEditor.tsx b/src/components/ProfileEditor.tsx index b00db5c..58a4153 100644 --- a/src/components/ProfileEditor.tsx +++ b/src/components/ProfileEditor.tsx @@ -1,241 +1,241 @@ -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"; -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"; +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' +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; + name: string + targets: string[] + config: Record + version: string } interface ProfileEditorProps { - initialData?: Doc<"profiles">; - onSave: () => void; - onCancel: () => void; + initialData?: Doc<'profiles'> + onSave: () => void + onCancel: () => void } export default function ProfileEditor({ - initialData, - onSave, - onCancel, + initialData, + onSave, + onCancel, }: ProfileEditorProps) { - const createProfile = useMutation(api.profiles.create); - const updateProfile = useMutation(api.profiles.update); + 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 { register, handleSubmit, setValue, watch } = useForm({ + defaultValues: initialData || { + name: '', + targets: [], + config: {}, + version: VERSIONS[0], + }, + }) - const targets = watch("targets"); + 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, - ); - }, []); + // 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 + ) + }, []) - const categories = React.useMemo( - () => Object.keys(groupedTargets).sort(), - [groupedTargets], - ); + const categories = React.useMemo( + () => Object.keys(groupedTargets).sort(), + [groupedTargets] + ) - const [activeCategory, setActiveCategory] = React.useState( - categories[0] || "Heltec", - ); + 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]); + // 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 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, - }); - } else { - await createProfile(data); - } - onSave(); - }; + 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, + }) + } else { + await createProfile(data) + } + onSave() + } - return ( -
-
-
- - -
-
- - -
-
+ return ( + +
+
+ + +
+
+ + +
+
-
-
Targets
-
- {/* Category Pills */} -
- {categories.map((category) => { - const count = groupedTargets[category].filter((t) => - targets?.includes(t.id), - ).length; - const isActive = activeCategory === category; +
+
Targets
+
+ {/* Category Pills */} +
+ {categories.map((category) => { + const count = groupedTargets[category].filter((t) => + targets?.includes(t.id) + ).length + const isActive = activeCategory === category - return ( - - ); - })} -
+ return ( + + ) + })} +
- {/* Active Category Targets */} -
-
- {groupedTargets[activeCategory]?.map((item) => ( -
- toggleTarget(item.id)} - /> - -
- ))} -
-
-
-
+ {/* Active Category Targets */} +
+
+ {groupedTargets[activeCategory]?.map((item) => ( +
+ toggleTarget(item.id)} + /> + +
+ ))} +
+
+
+
-
-
-
-

Modules

-

- Select the modules to include in your build. -

-
-
- {modulesData.modules.map((module) => { - // Inverted logic: - // config[id] === false -> Explicitly Included - // config[id] === true or undefined -> Excluded - const configValue = watch(`config.${module.id}`); - const isIncluded = configValue === false; +
+
+
+

Modules

+

+ Select the modules to include in your build. +

+
+
+ {modulesData.modules.map((module) => { + // Inverted logic: + // config[id] === false -> Explicitly Included + // config[id] === true or undefined -> Excluded + const configValue = watch(`config.${module.id}`) + const isIncluded = configValue === false - return ( - { - // Toggle: - // If currently included (true), we want to exclude (set config to true) - // If currently excluded (false), we want to include (set config to false) - setValue(`config.${module.id}`, !!isIncluded); - }} - /> - ); - })} -
-
-
+ return ( + { + // Toggle: + // If currently included (true), we want to exclude (set config to true) + // If currently excluded (false), we want to include (set config to false) + setValue(`config.${module.id}`, !!isIncluded) + }} + /> + ) + })} +
+
+
-
- - -
-
- ); +
+ + +
+ + ) } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 1aef6a5..0954fd4 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,57 +1,57 @@ -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; -import * as React from "react"; +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' +import * as React from 'react' -import { cn } from "@/lib/utils"; +import { cn } from '@/lib/utils' const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", - { - variants: { - variant: { - default: - "bg-primary text-primary-foreground shadow hover:bg-primary/90", - destructive: - "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", - outline: - "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", - secondary: - "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2", - sm: "h-8 rounded-md px-3 text-xs", - lg: "h-10 rounded-md px-8", - icon: "h-9 w-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - }, -); + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + default: + 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: + 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +) export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { - asChild?: boolean; + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button"; - return ( - - ); - }, -); -Button.displayName = "Button"; + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + return ( + + ) + } +) +Button.displayName = 'Button' -export { Button, buttonVariants }; +export { Button, buttonVariants } diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index 5b0acbe..d61b073 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -1,28 +1,28 @@ -import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; -import { Check } from "lucide-react"; -import * as React from "react"; +import * as CheckboxPrimitive from '@radix-ui/react-checkbox' +import { Check } from 'lucide-react' +import * as React from 'react' -import { cn } from "@/lib/utils"; +import { cn } from '@/lib/utils' const Checkbox = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - - - - - -)); -Checkbox.displayName = CheckboxPrimitive.Root.displayName; + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName -export { Checkbox }; +export { Checkbox } diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 875315a..b140689 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -1,22 +1,22 @@ -import * as React from "react"; +import * as React from 'react' -import { cn } from "@/lib/utils"; +import { cn } from '@/lib/utils' -const Input = React.forwardRef>( - ({ className, type, ...props }, ref) => { - return ( - - ); - }, -); -Input.displayName = "Input"; +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = 'Input' -export { Input }; +export { Input } diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index 1128edf..8bff2eb 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -1,24 +1,24 @@ -import { useTheme } from "next-themes" -import { Toaster as Sonner } from "sonner" +import { useTheme } from 'next-themes' +import { Toaster as Sonner } from 'sonner' type ToasterProps = React.ComponentProps const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme() + const { theme = 'system' } = useTheme() return ( = {}; +export const TARGETS: Record = {} // Sort by display name -const sortedHardware = [...hardwareList].sort((a, b) => - (a.displayName || "").localeCompare(b.displayName || "") -); +const sortedHardware = [...hardwareList].sort((a, b) => + (a.displayName || '').localeCompare(b.displayName || '') +) sortedHardware.forEach((hw) => { - if (hw.platformioTarget) { - TARGETS[hw.platformioTarget] = { - name: hw.displayName || hw.platformioTarget, - category: hw.tags?.[0] || "Other", - architecture: hw.architecture, - }; - } -}); + if (hw.platformioTarget) { + TARGETS[hw.platformioTarget] = { + name: hw.displayName || hw.platformioTarget, + category: hw.tags?.[0] || 'Other', + architecture: hw.architecture, + } + } +}) diff --git a/src/constants/versions.ts b/src/constants/versions.ts index 7b188bc..e46c665 100644 --- a/src/constants/versions.ts +++ b/src/constants/versions.ts @@ -1,242 +1,242 @@ // This file is auto-generated by scripts/generate-versions.js export const VERSIONS = [ - "v2.7.15.567b8ea", - "v2.7.14.e959000", - "v2.7.13.597fa0b", - "v2.7.12.45f15b8", - "v2.7.11.ee68575", - "v2.7.10.94d4bdf", - "v2.7.9.70724be", - "v2.7.8.a0c0388", - "v2.7.7.5ae4ff9", - "v2.7.6.834c3c5", - "v2.7.5.ddd1499", - "v2.7.4.c1f4f79", - "v2.7.3.cf574c7", - "v2.7.2.f6d3782", - "v2.7.1.f35ca81", - "v2.7.0.705515a", - "v2.7.0.195b7cc", - "v2.6.13.0561f2c", - "v2.6.12.9861e82", - "v2.6.11.60ec05e", - "v2.6.10.9ce4455", - "v2.6.9.f223b8a", - "v2.6.8.ef9d0d7", - "v2.6.7.2d6181f", - "v2.6.6.54c1423", - "v2.6.5.fc3d9f2", - "v2.6.4.b89355f", - "v2.6.3.d28af68", - "v2.6.3.640e731", - "v2.6.2.31c0e8f", - "v2.6.1.7c3edde", - "v2.6.0.f7afa9a", - "v2.5.23.bf958ed", - "v2.5.22.d1fa27d", - "v2.5.21.447533a", - "v2.5.20.4c97351", - "v2.5.19.f9876cf", - "v2.5.19.d5cd6f8", - "v2.5.18.89ebafc", - "v2.5.17.b4b2fd6", - "v2.5.16.f81d3b0", - "v2.5.15.79da236", - "v2.5.14.f2ee0df", - "v2.5.13.295278b", - "v2.5.13.1a06f88", - "v2.5.12.aa184e6", - "v2.5.11.8e2a3e5", - "v2.5.10.0fc5c9b", - "v2.5.9.936260f", - "v2.5.8.6485f03", - "v2.5.7.f77c87d", - "v2.5.6.d55c08d", - "v2.5.5.e182ae7", - "v2.5.4.8d288d5", - "v2.5.3.a70d5ee", - "v2.5.2.771cb52", - "v2.5.1.c13b44b", - "v2.5.0.e470619", - "v2.5.0.d6dac17", - "v2.5.0.ab7de7f", - "v2.5.0.33eb073", - "v2.5.0.9e55e6b", - "v2.5.0.9ac0e26", - "v2.4.3.efc27f2", - "v2.4.3.91d6612", - "v2.4.2.5b45303", - "v2.4.1.394e0e1", - "v2.4.0.46d7b82", - "v2.3.15.deb7c27", - "v2.3.14.64531fa", - "v2.3.13.83f5ba0", - "v2.3.12.24458a7", - "v2.3.11.2740a56", - "v2.3.10.d19607b", - "v2.3.9.f06c56a", - "v2.3.8.d490a33", - "v2.3.7.30fbcab", - "v2.3.6.7a3570a", - "v2.3.5.2f9b68e", - "v2.3.4.ea61808", - "v2.3.3.8187fa7", - "v2.3.2.63df972", - "v2.3.1.4fa7f5a", - "v2.3.0.5f47ca1", - "v2.2.24.e6a2c06", - "v2.2.23.5672e68", - "v2.2.22.404d0dd", - "v2.2.21.7f7c5cb", - "v2.2.20.af5ac32", - "v2.2.19.8f6a283", - "v2.2.18.e9bde80", - "v2.2.17.dbac2b1", - "v2.2.16.1c6acfd", - "v2.2.15.31c4693", - "v2.2.14.57542ce", - "v2.2.13.f570204", - "v2.2.12.092e6f2", - "v2.2.11.10265aa", - "v2.2.10.7cebd79", - "v2.2.9.47301a5", - "v2.2.8.61f6fb2", - "v2.2.7.e8970ad", - "v2.2.6.b53cb38", - "v2.2.5.8255128", - "v2.2.4.3bcab0e", - "v2.2.3.282cc0b", - "v2.2.2.f35c7be", - "v2.2.1.fb5f2e4", - "v2.2.0.9f6584b", - "v2.1.23.04bbdc6", - "v2.1.22.191a69d", - "v2.1.21.97d7a89", - "v2.1.20.470363d", - "v2.1.19.eb7025f", - "v2.1.18.de53280", - "v2.1.17.7ca2e81", - "v2.1.16.a2c5b92", - "v2.1.15.cd78723", - "v2.1.14.99a31c1", - "v2.1.13.7475c86", - "v2.1.12.7711b03", - "v2.1.11.5ec624d", - "v2.1.10.7ef12c7", - "v2.1.9.d43ddc9", - "v2.1.8.ee971e3", - "v2.1.7.242f880", - "v2.1.6.5679a82", - "v2.1.5.23272da", - "v2.1.4.958d2cf", - "v2.1.3.8c68d88", - "v2.1.2.6d20215", - "v2.1.1.dc2ca9c", - "v2.1.0.331a1af", - "v2.0.23.7bb281d", - "v2.0.22.fbfd0f1", - "v2.0.21.83e6cea", - "v2.0.20.7100416", - "v2.0.19.3209aea", - "v2.0.18.1a7991c", - "v2.0.17.5d1c06b", - "v2.0.16.2242b68", - "v2.0.15.aafbde0", - "v2.0.14.2baaad8", - "v2.0.13.7e27729", - "v2.0.12.2400dd4", - "v2.0.11.8914d1a", - "v2.0.10.e09b12c", - "v2.0.9.6ea0963", - "v2.0.8.090e166", - "v2.0.7.91ff7b9", - "v2.0.6.97fd5cf", - "v2.0.5.65e8209", - "v2.0.4.5417671", - "v2.0.3.09fe616", - "v2.0.2.8146e84", - "v2.0.1.ad05b91", - "v2.0.0.18ab874", - "v1.3.48.82bcd39", - "v1.3.47.05147c0", - "v1.3.46.d4ea956", - "v1.3.45.b0d0552", - "v1.3.44.4fa8d02", - "v1.3.43.aae9d2f", - "v1.3.42.9bd9252", - "v1.3.41.80ddb81", - "v1.3.40.e87ecc2", - "v1.3.39.ddc3727", - "v1.3.38.1253abd", - "v1.3.37.97712a9", - "v1.3.36.dd720f2", - "v1.3.36.64f852e", - "v1.3.36.7e03019", - "v1.3.35.3251cd5", - "v1.3.34.401b5d9", - "v1.3.33.ab0095c", - "v1.3.32.7e6c22f", - "v1.3.31.0084643", - "v1.3.30.9fe2ddb", - "v1.3.29.7afc149", - "v1.3.28.41f9541", - "v1.3.27.c88ba58", - "v1.3.26.0010231", - "v1.3.25.85f46d3", - "v1.3.24.dff6915", - "v1.3.23.5462d84", - "v1.3.22.c725a6b", - "v1.3.21.cf00ac5", - "v1.3.20.9a5ff93", - "v1.3.19.3c6a2f7", - "v1.3.17.c9822de", - "v1.3.16.97899ae", - "v1.3.15.432d067", - "v1.3.13.71a43a9", - "v1.3.12.6306c53", - "v1.3.11.0411401", - "v1.3.10.cc2a84a", - "v1.3.10.4df0e91", - "v1.3.9.92185e7", - "v1.3.8.90df7c2", - "v1.3.7.bb22b6e", - "v1.3.6.f511bab", - "v1.3.5.e5b19fd", - "v1.3.4.2b20bf3", - "v1.3.3.2fe124e", - "v1.2.testing1", - "v1.2.65.0adc5ce", - "v1.2.64.fc48fcd", - "v1.2.63.9879494", - "v1.2.62.3ddd74e", - "v1.2.61.d551c17", - "v1.2.60.ab959de", - "v1.2.59.d81c1c0", - "v1.2.58.6af1822", - "v1.2.57.f7c6955", - "v1.2.56.596a73c", - "v1.2.55.9db7c62", - "v1.2.54.288f2be", - "v1.2.53.19c1f9f", - "v1.2.52.b63802c", - "v1.2.51.f9ff06b", - "v1.2.50.41dcfdd", - "v1.2.49.5354c49", - "v1.2.48.371335e", - "v1.2.47", - "v1.2.46.dce2fe4", - "v1.2.46.9d21e58", - "v1.2.45.b674054", - "v1.2.44.f2c9c55", - "v1.2.43.a405d81", - "v1.2.42.2759c8d", - "v1.2.41.32f3682", - "v1.2.39.06892c4", - "v1.2.38.cf4e508", - "v1.2.38.451b085", - "v1.2.36", - "v1.2.30.80e4bc6", - "v1.2.29.6c95659" -] as const; + 'v2.7.15.567b8ea', + 'v2.7.14.e959000', + 'v2.7.13.597fa0b', + 'v2.7.12.45f15b8', + 'v2.7.11.ee68575', + 'v2.7.10.94d4bdf', + 'v2.7.9.70724be', + 'v2.7.8.a0c0388', + 'v2.7.7.5ae4ff9', + 'v2.7.6.834c3c5', + 'v2.7.5.ddd1499', + 'v2.7.4.c1f4f79', + 'v2.7.3.cf574c7', + 'v2.7.2.f6d3782', + 'v2.7.1.f35ca81', + 'v2.7.0.705515a', + 'v2.7.0.195b7cc', + 'v2.6.13.0561f2c', + 'v2.6.12.9861e82', + 'v2.6.11.60ec05e', + 'v2.6.10.9ce4455', + 'v2.6.9.f223b8a', + 'v2.6.8.ef9d0d7', + 'v2.6.7.2d6181f', + 'v2.6.6.54c1423', + 'v2.6.5.fc3d9f2', + 'v2.6.4.b89355f', + 'v2.6.3.d28af68', + 'v2.6.3.640e731', + 'v2.6.2.31c0e8f', + 'v2.6.1.7c3edde', + 'v2.6.0.f7afa9a', + 'v2.5.23.bf958ed', + 'v2.5.22.d1fa27d', + 'v2.5.21.447533a', + 'v2.5.20.4c97351', + 'v2.5.19.f9876cf', + 'v2.5.19.d5cd6f8', + 'v2.5.18.89ebafc', + 'v2.5.17.b4b2fd6', + 'v2.5.16.f81d3b0', + 'v2.5.15.79da236', + 'v2.5.14.f2ee0df', + 'v2.5.13.295278b', + 'v2.5.13.1a06f88', + 'v2.5.12.aa184e6', + 'v2.5.11.8e2a3e5', + 'v2.5.10.0fc5c9b', + 'v2.5.9.936260f', + 'v2.5.8.6485f03', + 'v2.5.7.f77c87d', + 'v2.5.6.d55c08d', + 'v2.5.5.e182ae7', + 'v2.5.4.8d288d5', + 'v2.5.3.a70d5ee', + 'v2.5.2.771cb52', + 'v2.5.1.c13b44b', + 'v2.5.0.e470619', + 'v2.5.0.d6dac17', + 'v2.5.0.ab7de7f', + 'v2.5.0.33eb073', + 'v2.5.0.9e55e6b', + 'v2.5.0.9ac0e26', + 'v2.4.3.efc27f2', + 'v2.4.3.91d6612', + 'v2.4.2.5b45303', + 'v2.4.1.394e0e1', + 'v2.4.0.46d7b82', + 'v2.3.15.deb7c27', + 'v2.3.14.64531fa', + 'v2.3.13.83f5ba0', + 'v2.3.12.24458a7', + 'v2.3.11.2740a56', + 'v2.3.10.d19607b', + 'v2.3.9.f06c56a', + 'v2.3.8.d490a33', + 'v2.3.7.30fbcab', + 'v2.3.6.7a3570a', + 'v2.3.5.2f9b68e', + 'v2.3.4.ea61808', + 'v2.3.3.8187fa7', + 'v2.3.2.63df972', + 'v2.3.1.4fa7f5a', + 'v2.3.0.5f47ca1', + 'v2.2.24.e6a2c06', + 'v2.2.23.5672e68', + 'v2.2.22.404d0dd', + 'v2.2.21.7f7c5cb', + 'v2.2.20.af5ac32', + 'v2.2.19.8f6a283', + 'v2.2.18.e9bde80', + 'v2.2.17.dbac2b1', + 'v2.2.16.1c6acfd', + 'v2.2.15.31c4693', + 'v2.2.14.57542ce', + 'v2.2.13.f570204', + 'v2.2.12.092e6f2', + 'v2.2.11.10265aa', + 'v2.2.10.7cebd79', + 'v2.2.9.47301a5', + 'v2.2.8.61f6fb2', + 'v2.2.7.e8970ad', + 'v2.2.6.b53cb38', + 'v2.2.5.8255128', + 'v2.2.4.3bcab0e', + 'v2.2.3.282cc0b', + 'v2.2.2.f35c7be', + 'v2.2.1.fb5f2e4', + 'v2.2.0.9f6584b', + 'v2.1.23.04bbdc6', + 'v2.1.22.191a69d', + 'v2.1.21.97d7a89', + 'v2.1.20.470363d', + 'v2.1.19.eb7025f', + 'v2.1.18.de53280', + 'v2.1.17.7ca2e81', + 'v2.1.16.a2c5b92', + 'v2.1.15.cd78723', + 'v2.1.14.99a31c1', + 'v2.1.13.7475c86', + 'v2.1.12.7711b03', + 'v2.1.11.5ec624d', + 'v2.1.10.7ef12c7', + 'v2.1.9.d43ddc9', + 'v2.1.8.ee971e3', + 'v2.1.7.242f880', + 'v2.1.6.5679a82', + 'v2.1.5.23272da', + 'v2.1.4.958d2cf', + 'v2.1.3.8c68d88', + 'v2.1.2.6d20215', + 'v2.1.1.dc2ca9c', + 'v2.1.0.331a1af', + 'v2.0.23.7bb281d', + 'v2.0.22.fbfd0f1', + 'v2.0.21.83e6cea', + 'v2.0.20.7100416', + 'v2.0.19.3209aea', + 'v2.0.18.1a7991c', + 'v2.0.17.5d1c06b', + 'v2.0.16.2242b68', + 'v2.0.15.aafbde0', + 'v2.0.14.2baaad8', + 'v2.0.13.7e27729', + 'v2.0.12.2400dd4', + 'v2.0.11.8914d1a', + 'v2.0.10.e09b12c', + 'v2.0.9.6ea0963', + 'v2.0.8.090e166', + 'v2.0.7.91ff7b9', + 'v2.0.6.97fd5cf', + 'v2.0.5.65e8209', + 'v2.0.4.5417671', + 'v2.0.3.09fe616', + 'v2.0.2.8146e84', + 'v2.0.1.ad05b91', + 'v2.0.0.18ab874', + 'v1.3.48.82bcd39', + 'v1.3.47.05147c0', + 'v1.3.46.d4ea956', + 'v1.3.45.b0d0552', + 'v1.3.44.4fa8d02', + 'v1.3.43.aae9d2f', + 'v1.3.42.9bd9252', + 'v1.3.41.80ddb81', + 'v1.3.40.e87ecc2', + 'v1.3.39.ddc3727', + 'v1.3.38.1253abd', + 'v1.3.37.97712a9', + 'v1.3.36.dd720f2', + 'v1.3.36.64f852e', + 'v1.3.36.7e03019', + 'v1.3.35.3251cd5', + 'v1.3.34.401b5d9', + 'v1.3.33.ab0095c', + 'v1.3.32.7e6c22f', + 'v1.3.31.0084643', + 'v1.3.30.9fe2ddb', + 'v1.3.29.7afc149', + 'v1.3.28.41f9541', + 'v1.3.27.c88ba58', + 'v1.3.26.0010231', + 'v1.3.25.85f46d3', + 'v1.3.24.dff6915', + 'v1.3.23.5462d84', + 'v1.3.22.c725a6b', + 'v1.3.21.cf00ac5', + 'v1.3.20.9a5ff93', + 'v1.3.19.3c6a2f7', + 'v1.3.17.c9822de', + 'v1.3.16.97899ae', + 'v1.3.15.432d067', + 'v1.3.13.71a43a9', + 'v1.3.12.6306c53', + 'v1.3.11.0411401', + 'v1.3.10.cc2a84a', + 'v1.3.10.4df0e91', + 'v1.3.9.92185e7', + 'v1.3.8.90df7c2', + 'v1.3.7.bb22b6e', + 'v1.3.6.f511bab', + 'v1.3.5.e5b19fd', + 'v1.3.4.2b20bf3', + 'v1.3.3.2fe124e', + 'v1.2.testing1', + 'v1.2.65.0adc5ce', + 'v1.2.64.fc48fcd', + 'v1.2.63.9879494', + 'v1.2.62.3ddd74e', + 'v1.2.61.d551c17', + 'v1.2.60.ab959de', + 'v1.2.59.d81c1c0', + 'v1.2.58.6af1822', + 'v1.2.57.f7c6955', + 'v1.2.56.596a73c', + 'v1.2.55.9db7c62', + 'v1.2.54.288f2be', + 'v1.2.53.19c1f9f', + 'v1.2.52.b63802c', + 'v1.2.51.f9ff06b', + 'v1.2.50.41dcfdd', + 'v1.2.49.5354c49', + 'v1.2.48.371335e', + 'v1.2.47', + 'v1.2.46.dce2fe4', + 'v1.2.46.9d21e58', + 'v1.2.45.b674054', + 'v1.2.44.f2c9c55', + 'v1.2.43.a405d81', + 'v1.2.42.2759c8d', + 'v1.2.41.32f3682', + 'v1.2.39.06892c4', + 'v1.2.38.cf4e508', + 'v1.2.38.451b085', + 'v1.2.36', + 'v1.2.30.80e4bc6', + 'v1.2.29.6c95659', +] as const -export type FirmwareVersion = typeof VERSIONS[number]; +export type FirmwareVersion = (typeof VERSIONS)[number] diff --git a/src/index.css b/src/index.css index 66b7f12..cce707b 100644 --- a/src/index.css +++ b/src/index.css @@ -1,32 +1,32 @@ @import "tailwindcss"; @theme { - --font-sans: system-ui, -apple-system, sans-serif; - - --color-background: oklch(0.145 0 0); - --color-foreground: oklch(0.985 0 0); - --color-primary: oklch(0.488 0.243 264.376); - --color-primary-foreground: oklch(0.985 0 0); - --color-secondary: oklch(0.269 0 0); - --color-secondary-foreground: oklch(0.985 0 0); - --color-muted: oklch(0.269 0 0); - --color-muted-foreground: oklch(0.708 0 0); - --color-accent: oklch(0.488 0.243 264.376); - --color-accent-foreground: oklch(0.985 0 0); - --color-destructive: oklch(0.704 0.191 22.216); - --color-border: oklch(1 0 0 / 10%); - --color-input: oklch(1 0 0 / 15%); - --color-ring: oklch(0.488 0.243 264.376); - - --radius: 0.5rem; + --font-sans: system-ui, -apple-system, sans-serif; + + --color-background: oklch(0.145 0 0); + --color-foreground: oklch(0.985 0 0); + --color-primary: oklch(0.488 0.243 264.376); + --color-primary-foreground: oklch(0.985 0 0); + --color-secondary: oklch(0.269 0 0); + --color-secondary-foreground: oklch(0.985 0 0); + --color-muted: oklch(0.269 0 0); + --color-muted-foreground: oklch(0.708 0 0); + --color-accent: oklch(0.488 0.243 264.376); + --color-accent-foreground: oklch(0.985 0 0); + --color-destructive: oklch(0.704 0.191 22.216); + --color-border: oklch(1 0 0 / 10%); + --color-input: oklch(1 0 0 / 15%); + --color-ring: oklch(0.488 0.243 264.376); + + --radius: 0.5rem; } @layer base { - * { - @apply border-border; - } - - body { - @apply bg-background text-foreground font-sans; - } + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground font-sans; + } } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 6616148..2a4c41c 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,46 +1,46 @@ -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return twMerge(clsx(inputs)) } export function timeAgo(date: number | string | Date): string { - const now = new Date(); - const past = new Date(date); - const msPerMinute = 60 * 1000; - const msPerHour = msPerMinute * 60; - const msPerDay = msPerHour * 24; - const msPerMonth = msPerDay * 30; - const msPerYear = msPerDay * 365; + const now = new Date() + const past = new Date(date) + const msPerMinute = 60 * 1000 + const msPerHour = msPerMinute * 60 + const msPerDay = msPerHour * 24 + const msPerMonth = msPerDay * 30 + const msPerYear = msPerDay * 365 - const elapsed = now.getTime() - past.getTime(); + const elapsed = now.getTime() - past.getTime() if (elapsed < msPerMinute) { - return `${Math.round(elapsed / 1000)}s ago`; + return `${Math.round(elapsed / 1000)}s ago` } else if (elapsed < msPerHour) { - return `${Math.round(elapsed / msPerMinute)}m ago`; + return `${Math.round(elapsed / msPerMinute)}m ago` } else if (elapsed < msPerDay) { - return `${Math.round(elapsed / msPerHour)}h ago`; + return `${Math.round(elapsed / msPerHour)}h ago` } else if (elapsed < msPerMonth) { - return `${Math.round(elapsed / msPerDay)}d ago`; + return `${Math.round(elapsed / msPerDay)}d ago` } else if (elapsed < msPerYear) { - return `${Math.round(elapsed / msPerMonth)}mo ago`; + return `${Math.round(elapsed / msPerMonth)}mo ago` } else { - return `${Math.round(elapsed / msPerYear)}y ago`; + return `${Math.round(elapsed / msPerYear)}y ago` } } export function humanizeStatus(status: string): string { // Handle special statuses - if (status === "success") return "Success"; - if (status === "failure") return "Failure"; - if (status === "queued") return "Queued"; - if (status === "in_progress") return "In Progress"; + if (status === 'success') return 'Success' + if (status === 'failure') return 'Failure' + if (status === 'queued') return 'Queued' + if (status === 'in_progress') return 'In Progress' // Convert snake_case/underscore_separated to Title Case return status - .split("_") + .split('_') .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" "); + .join(' ') } diff --git a/src/main.tsx b/src/main.tsx index 8ec0418..d11e3d8 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,21 +1,21 @@ -import { ConvexAuthProvider } from "@convex-dev/auth/react"; -import { ConvexReactClient } from "convex/react"; -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import "./index.css"; -import App from "./App"; +import { ConvexAuthProvider } from '@convex-dev/auth/react' +import { ConvexReactClient } from 'convex/react' +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App' -const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); +const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string) -const rootElement = document.getElementById("root"); +const rootElement = document.getElementById('root') if (!rootElement) { - throw new Error("Root element not found"); + throw new Error('Root element not found') } createRoot(rootElement).render( - - - - - , -); + + + + + +) diff --git a/src/pages/BuildDetail.tsx b/src/pages/BuildDetail.tsx index ff69a4b..e3b99fe 100644 --- a/src/pages/BuildDetail.tsx +++ b/src/pages/BuildDetail.tsx @@ -1,29 +1,29 @@ -import { useQuery } from "convex/react"; +import { useQuery } from 'convex/react' import { ArrowLeft, CheckCircle, Download, Loader2, XCircle, -} from "lucide-react"; -import { Link, useParams } from "react-router-dom"; -import { Button } from "@/components/ui/button"; -import { humanizeStatus } from "@/lib/utils"; -import { api } from "../../convex/_generated/api"; -import type { Id } from "../../convex/_generated/dataModel"; +} from 'lucide-react' +import { Link, useParams } from 'react-router-dom' +import { Button } from '@/components/ui/button' +import { humanizeStatus } from '@/lib/utils' +import { api } from '../../convex/_generated/api' +import type { Id } from '../../convex/_generated/dataModel' export default function BuildDetail() { - const { buildId } = useParams<{ buildId: string }>(); + const { buildId } = useParams<{ buildId: string }>() const build = useQuery(api.builds.get, { - buildId: buildId as Id<"builds">, - }); + buildId: buildId as Id<'builds'>, + }) if (build === undefined) { return (
- ); + ) } if (build === null) { @@ -34,30 +34,30 @@ export default function BuildDetail() { - ); + ) } const getStatusColor = (status: string) => { - if (status === "success") { - return "text-green-400"; + if (status === 'success') { + return 'text-green-400' } - if (status === "failure") { - return "text-red-400"; + if (status === 'failure') { + return 'text-red-400' } // All other statuses show as in progress - return "text-blue-400"; - }; + return 'text-blue-400' + } const getStatusIcon = (status: string) => { - if (status === "success") { - return ; + if (status === 'success') { + return } - if (status === "failure") { - return ; + if (status === 'failure') { + return } // All other statuses show as in progress - return ; - }; + return + } return (
@@ -87,7 +87,7 @@ export default function BuildDetail() {
- {build.status === "success" && build.artifactUrl && ( + {build.status === 'success' && build.artifactUrl && ( - ); + ) } diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 7fc08fc..11b10ba 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,143 +1,143 @@ -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 { Button } from "@/components/ui/button"; -import { api } from "../../convex/_generated/api"; -import type { Doc, Id } from "../../convex/_generated/dataModel"; +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 { 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); - const [isEditing, setIsEditing] = useState(false); - const [editingProfile, setEditingProfile] = useState | null>( - null, - ); + const { signOut } = useAuthActions() + const profiles = useQuery(api.profiles.list) + const triggerBuild = useMutation(api.builds.triggerBuild) + const removeProfile = useMutation(api.profiles.remove) + const [isEditing, setIsEditing] = useState(false) + const [editingProfile, setEditingProfile] = useState | null>( + null + ) - const handleEdit = (profile: Doc<"profiles">) => { - setEditingProfile(profile); - setIsEditing(true); - }; + const handleEdit = (profile: Doc<'profiles'>) => { + setEditingProfile(profile) + setIsEditing(true) + } - const handleCreate = () => { - setEditingProfile(null); - setIsEditing(true); - }; + const handleCreate = () => { + setEditingProfile(null) + setIsEditing(true) + } - const handleBuild = async (profileId: Id<"profiles">) => { - try { - await triggerBuild({ profileId }); - toast.success("Build started", { - description: "Check the build status below.", - }); - } catch (error) { - toast.error("Build failed", { - description: String(error), - }); - } - }; + const handleBuild = async (profileId: Id<'profiles'>) => { + try { + await triggerBuild({ profileId }) + toast.success('Build started', { + description: 'Check the build status below.', + }) + } catch (error) { + toast.error('Build failed', { + description: String(error), + }) + } + } - const handleDelete = async ( - profileId: Id<"profiles">, - profileName: string, - ) => { - if ( - !confirm( - `Are you sure you want to delete "${profileName}"? This action cannot be undone.`, - ) - ) { - return; - } + const handleDelete = async ( + profileId: Id<'profiles'>, + profileName: string + ) => { + if ( + !confirm( + `Are you sure you want to delete "${profileName}"? This action cannot be undone.` + ) + ) { + return + } - try { - await removeProfile({ id: profileId }); - toast.success("Profile deleted", { - description: `"${profileName}" has been deleted successfully.`, - }); - } catch (error) { - toast.error("Delete failed", { - description: String(error), - }); - } - }; + try { + await removeProfile({ id: profileId }) + toast.success('Profile deleted', { + description: `"${profileName}" has been deleted successfully.`, + }) + } catch (error) { + toast.error('Delete failed', { + description: String(error), + }) + } + } - return ( -
-
-

My Fleet

-
- - -
-
+ return ( +
+
+

My Fleet

+
+ + +
+
-
- {isEditing ? ( - setIsEditing(false)} - onCancel={() => setIsEditing(false)} - /> - ) : ( -
- {profiles?.map((profile) => ( -
-

{profile.name}

-

- Version:{" "} - {profile.version} -

-

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

-
- - - -
+
+ {isEditing ? ( + setIsEditing(false)} + onCancel={() => setIsEditing(false)} + /> + ) : ( +
+ {profiles?.map((profile) => ( +
+

{profile.name}

+

+ Version:{' '} + {profile.version} +

+

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

+
+ + + +
-
- -
-
- ))} - {profiles?.length === 0 && ( -
- No profiles found. Create one to get started. -
- )} -
- )} -
-
- ); +
+ +
+
+ ))} + {profiles?.length === 0 && ( +
+ No profiles found. Create one to get started. +
+ )} +
+ )} + +
+ ) } diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 09d3d31..1b9923c 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -1,24 +1,24 @@ -import { useAuthActions } from "@convex-dev/auth/react"; -import { Button } from "@/components/ui/button"; +import { useAuthActions } from '@convex-dev/auth/react' +import { Button } from '@/components/ui/button' export default function LandingPage() { - const { signIn } = useAuthActions(); + const { signIn } = useAuthActions() - return ( -
-

- Firmware Configurator -

-

- Manage your Meshtastic fleet. Create custom profiles, build firmware in - the cloud, and flash directly from your browser. -

- -
- ); + return ( +
+

+ Firmware Configurator +

+

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

+ +
+ ) } diff --git a/wrangler.json b/wrangler.json index c028cff..1fded9a 100644 --- a/wrangler.json +++ b/wrangler.json @@ -1,10 +1,10 @@ { - "name": "react-web-flasher", - "compatibility_date": "2024-09-23", - "assets": { - "directory": "./dist" - }, - "observability": { - "enabled": true - } + "name": "react-web-flasher", + "compatibility_date": "2024-09-23", + "assets": { + "directory": "./dist" + }, + "observability": { + "enabled": true + } }