mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-03-28 17:42:55 +01:00
refactor: replace index queries with filter method for database queries, enhance profile management with flash count tracking, and introduce ProfileCard component for improved UI consistency
This commit is contained in:
34
convex/_generated/dataModel.d.ts
vendored
34
convex/_generated/dataModel.d.ts
vendored
@@ -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<DataModel>;
|
||||
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<TableName extends TableNames> = DocumentByName<
|
||||
DataModel,
|
||||
TableName
|
||||
>;
|
||||
export type Doc = any;
|
||||
|
||||
/**
|
||||
* An identifier for a document in Convex.
|
||||
@@ -42,10 +42,8 @@ export type Doc<TableName extends TableNames> = 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<TableName extends TableNames | SystemTableNames> =
|
||||
export type Id<TableName extends TableNames = TableNames> =
|
||||
GenericId<TableName>;
|
||||
|
||||
/**
|
||||
@@ -57,4 +55,4 @@ export type Id<TableName extends TableNames | SystemTableNames> =
|
||||
* This type is used to parameterize methods like `queryGeneric` and
|
||||
* `mutationGeneric` to make them type-safe.
|
||||
*/
|
||||
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
|
||||
export type DataModel = AnyDataModel;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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']),
|
||||
})
|
||||
52
src/components/ProfileCard.tsx
Normal file
52
src/components/ProfileCard.tsx
Normal file
@@ -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 (
|
||||
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-wide">
|
||||
<span className="inline-flex items-center rounded-full bg-slate-800/80 text-slate-200 px-3 py-1">
|
||||
{version}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-cyan-500/10 text-cyan-300 px-3 py-1">
|
||||
{normalizedCount} {normalizedLabel}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProfileCardContentProps {
|
||||
profile: Doc<'profiles'>
|
||||
}
|
||||
|
||||
export function ProfileCardContent({ profile }: ProfileCardContentProps) {
|
||||
const flashCount = profile.flashCount ?? 0
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold mb-2">{profile.name}</h3>
|
||||
<p className="text-slate-300 text-sm leading-relaxed">
|
||||
{profile.description}
|
||||
</p>
|
||||
</div>
|
||||
<ProfileStatisticPills
|
||||
version={profile.version}
|
||||
flashCount={flashCount}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{profiles?.map((profile) => (
|
||||
<div
|
||||
key={profile._id}
|
||||
className="border border-slate-800 rounded-lg p-6 bg-slate-900/50"
|
||||
>
|
||||
<h3 className="text-xl font-semibold mb-2">{profile.name}</h3>
|
||||
<p className="text-slate-400 text-sm mb-1">
|
||||
Version:{' '}
|
||||
<span className="text-slate-200">{profile.version}</span>
|
||||
</p>
|
||||
<p className="text-slate-300 text-sm mb-4 leading-relaxed">
|
||||
{profile.description}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<div key={profile._id} className={profileCardClasses}>
|
||||
<ProfileCardContent profile={profile} />
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button size="sm" asChild>
|
||||
<Link to={`/profiles/${profile._id}`}>Use</Link>
|
||||
</Button>
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
<h3 className="text-xl font-semibold mb-2">{profile.name}</h3>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Version:{' '}
|
||||
<span className="text-slate-200">{profile.version}</span>
|
||||
</p>
|
||||
<p className="text-slate-300 text-sm mt-3 leading-relaxed">
|
||||
{profile.description}
|
||||
</p>
|
||||
<ProfileCardContent profile={profile} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-slate-950 text-white p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-4xl font-bold mb-2">{profile.name}</h1>
|
||||
<p className="text-slate-400 mb-8">
|
||||
Version: {profile.version} • Flashed {flashCount} time
|
||||
{flashCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<div className="space-y-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">{profile.name}</h1>
|
||||
<p className="text-slate-400">
|
||||
Flashed {flashCount} time{flashCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<ProfileStatisticPills
|
||||
version={profile.version}
|
||||
flashCount={totalFlashes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Enabled Modules */}
|
||||
|
||||
@@ -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() {
|
||||
<p className="text-slate-200 leading-relaxed">
|
||||
{profile.description}
|
||||
</p>
|
||||
<ProfileStatisticPills
|
||||
version={profile.version}
|
||||
flashCount={totalFlashes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
|
||||
@@ -176,15 +195,12 @@ export default function ProfileFlash() {
|
||||
|
||||
{build.status === 'success' && build.artifactUrl && (
|
||||
<div>
|
||||
<a
|
||||
href={build.artifactUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
className="bg-cyan-600 hover:bg-cyan-700 w-full"
|
||||
>
|
||||
<Button className="bg-cyan-600 hover:bg-cyan-700 w-full">
|
||||
Download Firmware
|
||||
</Button>
|
||||
</a>
|
||||
Download Firmware
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user