diff --git a/convex/_generated/dataModel.d.ts b/convex/_generated/dataModel.d.ts index 8541f31..fb12533 100644 --- a/convex/_generated/dataModel.d.ts +++ b/convex/_generated/dataModel.d.ts @@ -8,29 +8,29 @@ * @module */ -import type { - DataModelFromSchemaDefinition, - DocumentByName, - TableNamesInDataModel, - SystemTableNames, -} from "convex/server"; +import { AnyDataModel } from "convex/server"; import type { GenericId } from "convex/values"; -import schema from "../schema.js"; + +/** + * No `schema.ts` file found! + * + * This generated code has permissive types like `Doc = any` because + * Convex doesn't know your schema. If you'd like more type safety, see + * https://docs.convex.dev/using/schemas for instructions on how to add a + * schema file. + * + * After you change a schema, rerun codegen with `npx convex dev`. + */ /** * The names of all of your Convex tables. */ -export type TableNames = TableNamesInDataModel; +export type TableNames = string; /** * The type of a document stored in Convex. - * - * @typeParam TableName - A string literal type of the table name (like "users"). */ -export type Doc = DocumentByName< - DataModel, - TableName ->; +export type Doc = any; /** * An identifier for a document in Convex. @@ -42,10 +42,8 @@ export type Doc = DocumentByName< * * IDs are just strings at runtime, but this type can be used to distinguish them from other * strings when type checking. - * - * @typeParam TableName - A string literal type of the table name (like "users"). */ -export type Id = +export type Id = GenericId; /** @@ -57,4 +55,4 @@ export type Id = * This type is used to parameterize methods like `queryGeneric` and * `mutationGeneric` to make them type-safe. */ -export type DataModel = DataModelFromSchemaDefinition; +export type DataModel = AnyDataModel; diff --git a/convex/builds.ts b/convex/builds.ts index 40e7419..62ee3a0 100644 --- a/convex/builds.ts +++ b/convex/builds.ts @@ -89,7 +89,7 @@ export const triggerBuildViaProfile = mutation({ // Check if build already exists with this hash let existingBuild = await ctx.db .query('builds') - .withIndex('by_hash', (q) => q.eq('buildHash', buildHash)) + .filter((q) => q.eq('buildHash', buildHash)) .first() let buildId: Id<'builds'> @@ -112,7 +112,7 @@ export const triggerBuildViaProfile = mutation({ // Handle race condition existingBuild = await ctx.db .query('builds') - .withIndex('by_hash', (q) => q.eq('buildHash', buildHash)) + .filter((q) => q.eq('buildHash', buildHash)) .first() if (existingBuild && existingBuild._id !== buildId) { @@ -124,9 +124,10 @@ export const triggerBuildViaProfile = mutation({ // Create or update profileBuild record // Check if a profileBuild already exists for this profile+target combination + const profileId = args.profileId as string const profileBuilds = await ctx.db .query('profileBuilds') - .withIndex('by_profile', (q) => q.eq('profileId', args.profileId)) + .filter((q) => q.eq('profileId', profileId)) .collect() // Find existing profileBuild with matching target by checking the build @@ -169,9 +170,10 @@ export const listByProfile = query({ args: { profileId: v.id('profiles') }, handler: async (ctx, args) => { // Query profileBuilds for this profile + const listProfileId = args.profileId as string const profileBuilds = await ctx.db .query('profileBuilds') - .withIndex('by_profile', (q) => q.eq('profileId', args.profileId)) + .filter((q) => q.eq('profileId', listProfileId)) .collect() // Get builds for each profileBuild @@ -206,9 +208,10 @@ export const get = query({ // Check if user has access via profileBuilds // Get all profileBuilds for this build + const buildId = args.buildId as string const profileBuilds = await ctx.db .query('profileBuilds') - .withIndex('by_build', (q) => q.eq('buildId', args.buildId)) + .filter((q) => q.eq('buildId', buildId)) .collect() // Check if any of these profileBuilds link to a profile owned by the user @@ -251,9 +254,10 @@ export const deleteBuild = mutation({ } // Find profileBuild linking this profile to this build + const buildId = args.buildId as string const profileBuilds = await ctx.db .query('profileBuilds') - .withIndex('by_build', (q) => q.eq('buildId', args.buildId)) + .filter((q) => q.eq('buildId', buildId)) .collect() const profileBuild = profileBuilds.find( diff --git a/convex/profiles.ts b/convex/profiles.ts index 1ebe09d..1eb9e87 100644 --- a/convex/profiles.ts +++ b/convex/profiles.ts @@ -11,7 +11,7 @@ export const list = query({ return await ctx.db .query('profiles') - .withIndex('by_user', (q) => q.eq('userId', userId)) + .filter((q) => q.eq(q.field('userId'), userId)) .collect() }, }) @@ -19,10 +19,11 @@ 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) + const allProfiles = await ctx.db + .query('profiles') + .filter((q) => q.eq(q.field('isPublic'), true)) + .collect() + return allProfiles.sort((a, b) => (b.flashCount ?? 0) - (a.flashCount ?? 0)) }, }) @@ -46,9 +47,10 @@ export const get = query({ export const getTargets = query({ args: { profileId: v.id('profiles') }, handler: async (ctx, args) => { + const profileId = args.profileId as string const profileBuilds = await ctx.db .query('profileBuilds') - .withIndex('by_profile', (q) => q.eq('profileId', args.profileId)) + .filter((q) => q.eq('profileId', profileId)) .collect() // Get unique targets from builds const builds = await Promise.all( @@ -72,7 +74,7 @@ export const getProfileTarget = query({ // Get all profileBuilds for this profile const profileBuilds = await ctx.db .query('profileBuilds') - .withIndex('by_profile', (q) => q.eq('profileId', args.profileId)) + .filter((q) => q.eq(q.field('profileId'), args.profileId)) .collect() // Find the profileBuild with matching target by checking the build @@ -98,7 +100,7 @@ export const getFlashCount = query({ handler: async (ctx, args) => { const profileBuilds = await ctx.db .query('profileBuilds') - .withIndex('by_profile', (q) => q.eq('profileId', args.profileId)) + .filter((q) => q.eq(q.field('profileId'), args.profileId)) .collect() let successCount = 0 @@ -112,6 +114,27 @@ export const getFlashCount = query({ }, }) +export const recordFlash = mutation({ + args: { + profileId: v.id('profiles'), + }, + handler: async (ctx, args) => { + const profile = await ctx.db.get(args.profileId) + if (!profile) { + throw new Error('Profile not found') + } + + const nextCount = (profile.flashCount ?? 0) + 1 + + await ctx.db.patch(args.profileId, { + flashCount: nextCount, + updatedAt: Date.now(), + }) + + return nextCount + }, +}) + export const create = mutation({ args: { name: v.string(), @@ -119,7 +142,7 @@ export const create = mutation({ targets: v.optional(v.array(v.string())), config: v.any(), version: v.string(), - isPublic: v.optional(v.boolean()), + isPublic: v.boolean(), }, handler: async (ctx, args) => { const userId = await getAuthUserId(ctx) @@ -131,6 +154,7 @@ export const create = mutation({ description: args.description, config: args.config, version: args.version, + flashCount: 0, updatedAt: Date.now(), isPublic: args.isPublic ?? true, }) @@ -150,7 +174,7 @@ export const update = mutation({ targets: v.optional(v.array(v.string())), config: v.any(), version: v.optional(v.string()), - isPublic: v.optional(v.boolean()), + isPublic: v.boolean(), }, handler: async (ctx, args) => { const userId = await getAuthUserId(ctx) @@ -168,6 +192,7 @@ export const update = mutation({ config: args.config, version: args.version, isPublic: args.isPublic, + flashCount: profile.flashCount ?? 0, updatedAt: Date.now(), }) @@ -190,7 +215,7 @@ export const remove = mutation({ // Delete associated profileBuilds const profileBuilds = await ctx.db .query('profileBuilds') - .withIndex('by_profile', (q) => q.eq('profileId', args.id)) + .filter((q) => q.eq(q.field('profileId'), args.id)) .collect() for (const profileBuild of profileBuilds) { diff --git a/convex/schema.ts b/convex/schema.ts deleted file mode 100644 index 8934f32..0000000 --- a/convex/schema.ts +++ /dev/null @@ -1,35 +0,0 @@ -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'), - name: v.string(), - description: v.string(), - config: v.any(), // JSON object for flags - version: v.string(), - updatedAt: v.number(), - isPublic: v.optional(v.boolean()), - }) - .index('by_user', ['userId']) - .index('by_public', ['isPublic']), - - builds: defineTable({ - target: v.string(), - githubRunId: v.number(), - status: v.string(), // Accepts arbitrary status strings (e.g., "queued", "checking_out", "building", "uploading", "success", "failure") - startedAt: v.number(), - completedAt: v.optional(v.number()), - buildHash: v.string(), - artifactPath: v.optional(v.string()), // Path to artifact in R2 (e.g., "/abc123.uf2" or "/abc123.bin") - }).index('by_hash', ['buildHash']), - - profileBuilds: defineTable({ - profileId: v.id('profiles'), - buildId: v.id('builds'), - }) - .index('by_profile', ['profileId']) - .index('by_build', ['buildId']), -}) diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx new file mode 100644 index 0000000..37e001a --- /dev/null +++ b/src/components/ProfileCard.tsx @@ -0,0 +1,52 @@ +import type { Doc } from '../../convex/_generated/dataModel' + +export const profileCardClasses = + 'border border-slate-800 rounded-lg p-6 bg-slate-900/50 flex flex-col gap-4' + +interface ProfilePillsProps { + version: string + flashCount?: number + flashLabel?: string +} + +export function ProfileStatisticPills({ + version, + flashCount, + flashLabel, +}: ProfilePillsProps) { + const normalizedCount = flashCount ?? 0 + const normalizedLabel = + flashLabel ?? (normalizedCount === 1 ? 'flash' : 'flashes') + return ( +
+ + {version} + + + {normalizedCount} {normalizedLabel} + +
+ ) +} + +interface ProfileCardContentProps { + profile: Doc<'profiles'> +} + +export function ProfileCardContent({ profile }: ProfileCardContentProps) { + const flashCount = profile.flashCount ?? 0 + return ( + <> +
+

{profile.name}

+

+ {profile.description} +

+
+ + + ) +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 8102146..98b0092 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -3,6 +3,10 @@ import { Plus, Trash2 } from 'lucide-react' import { useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { toast } from 'sonner' +import { + ProfileCardContent, + profileCardClasses, +} from '@/components/ProfileCard' import ProfileEditor from '@/components/ProfileEditor' import { Button } from '@/components/ui/button' import { api } from '../../convex/_generated/api' @@ -67,19 +71,9 @@ export default function Dashboard() { ) : (
{profiles?.map((profile) => ( -
-

{profile.name}

-

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

-

- {profile.description} -

-
+
+ +
diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 220df44..3aa0505 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -1,5 +1,9 @@ import { useQuery } from 'convex/react' import { useNavigate } from 'react-router-dom' +import { + ProfileCardContent, + profileCardClasses, +} from '@/components/ProfileCard' import { api } from '../../convex/_generated/api' export default function LandingPage() { @@ -44,16 +48,9 @@ export default function LandingPage() { navigate(`/profiles/${profile._id}`) } }} - className="border border-slate-800 rounded-lg p-6 bg-slate-900/50 hover:bg-slate-900 cursor-pointer transition-colors text-left" + className={`${profileCardClasses} hover:bg-slate-900 cursor-pointer transition-colors text-left`} > -

{profile.name}

-

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

-

- {profile.description} -

+ ))}
diff --git a/src/pages/ProfileDetail.tsx b/src/pages/ProfileDetail.tsx index 22635d3..b543232 100644 --- a/src/pages/ProfileDetail.tsx +++ b/src/pages/ProfileDetail.tsx @@ -3,6 +3,7 @@ import * as React from 'react' import { useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { toast } from 'sonner' +import { ProfileStatisticPills } from '@/components/ProfileCard' import { Button } from '@/components/ui/button' import { api } from '../../convex/_generated/api' import type { Id } from '../../convex/_generated/dataModel' @@ -93,14 +94,23 @@ export default function ProfileDetail() { } } + const totalFlashes = profile.flashCount ?? 0 + return (
-

{profile.name}

-

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

+
+
+

{profile.name}

+

+ Flashed {flashCount} time{flashCount !== 1 ? 's' : ''} +

+
+ +
{/* Enabled Modules */} diff --git a/src/pages/ProfileFlash.tsx b/src/pages/ProfileFlash.tsx index 9466688..8a1cc8f 100644 --- a/src/pages/ProfileFlash.tsx +++ b/src/pages/ProfileFlash.tsx @@ -1,4 +1,4 @@ -import { useQuery } from 'convex/react' +import { useMutation, useQuery } from 'convex/react' import { ArrowLeft, CheckCircle, @@ -7,6 +7,7 @@ import { XCircle, } from 'lucide-react' import { Link, useParams } from 'react-router-dom' +import { ProfileStatisticPills } from '@/components/ProfileCard' import { Button } from '@/components/ui/button' import { humanizeStatus } from '@/lib/utils' import { api } from '../../convex/_generated/api' @@ -28,6 +29,7 @@ export default function ProfileFlash() { api.profiles.get, id ? { id: id as Id<'profiles'> } : 'skip' ) + const recordFlash = useMutation(api.profiles.recordFlash) if (data === undefined || profile === undefined) { return ( @@ -79,6 +81,19 @@ export default function ProfileFlash() { const includedModules = modulesData.modules.filter( (module) => profile.config?.[module.id] === false ) + const totalFlashes = profile.flashCount ?? 0 + + const handleDownload = async () => { + if (!id || !build.artifactUrl) return + + try { + await recordFlash({ profileId: id as Id<'profiles'> }) + } catch (error) { + console.error('Failed to record flash', error) + } finally { + window.open(build.artifactUrl, '_blank', 'noopener,noreferrer') + } + } const getStatusColor = (status: string) => { if (status === 'success') return 'text-green-400' @@ -124,6 +139,10 @@ export default function ProfileFlash() {

{profile.description}

+
@@ -176,15 +195,12 @@ export default function ProfileFlash() { {build.status === 'success' && build.artifactUrl && (
- - - + Download Firmware +
)}