mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-05-06 21:42:37 +02:00
feat: add convex-helpers dependency and implement module toggling in ProfileEditor for improved build configuration management
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.29.3",
|
||||
"convex-helpers": "^0.1.106",
|
||||
"lucide-react": "^0.554.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^18.2.0",
|
||||
@@ -529,6 +530,8 @@
|
||||
|
||||
"convex": ["convex@1.29.3", "", { "dependencies": { "esbuild": "0.25.4", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-tg5TXzMjpNk9m50YRtdp6US+t7ckxE4E+7DNKUCjJ2MupQs2RBSPF/z5SNN4GUmQLSfg0eMILDySzdAvjTrhnw=="],
|
||||
|
||||
"convex-helpers": ["convex-helpers@0.1.106", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.25.4", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-hWRe3yDaAVHMe4CUYw1YoQLiPZ1KIx6Kbf0w6UcRDx1BXpJgMCl3GVIMiSeYiA0PkbwjnIwGWIvoUVKloG5Tyw=="],
|
||||
|
||||
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
+111
-202
@@ -1,21 +1,44 @@
|
||||
import { getAuthUserId } from '@convex-dev/auth/server'
|
||||
import { v } from 'convex/values'
|
||||
import { api } from './_generated/api'
|
||||
import { pick } from 'convex-helpers'
|
||||
import { api, internal } from './_generated/api'
|
||||
import type { Id } from './_generated/dataModel'
|
||||
import { internalMutation, mutation, query } from './_generated/server'
|
||||
import { generateSignedDownloadUrl } from './lib/r2'
|
||||
import modulesData from './modules.json'
|
||||
import { type BuildFields, buildFields, type ProfileFields } from './schema'
|
||||
|
||||
type BuildUpdateData = {
|
||||
status: string
|
||||
completedAt?: number
|
||||
}
|
||||
|
||||
export const get = query({
|
||||
args: { id: v.id('builds') },
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db.get(args.id)
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Computes flags string from profile config.
|
||||
* Only excludes modules explicitly marked as excluded (config[id] === true).
|
||||
*/
|
||||
export function computeFlagsFromProfile(
|
||||
config: ProfileFields['config']
|
||||
): string {
|
||||
// Sort modules to ensure consistent order
|
||||
return Object.keys(config.modulesExcluded)
|
||||
.sort()
|
||||
.filter((module) => config.modulesExcluded[module])
|
||||
.map((moduleExcludedName: string) => `-D${moduleExcludedName}=1`)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a stable SHA-256 hash from version, target, and flags.
|
||||
* This hash uniquely identifies a build configuration based on what is actually executed.
|
||||
*/
|
||||
async function computeBuildHash(
|
||||
export async function computeBuildHash(
|
||||
version: string,
|
||||
target: string,
|
||||
flags: string
|
||||
@@ -37,15 +60,27 @@ async function computeBuildHash(
|
||||
return hashHex
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes buildHash for a profile and target.
|
||||
* This is the single source of truth for build hash computation.
|
||||
*/
|
||||
export async function computeBuildHashForProfile(
|
||||
profile: ProfileFields,
|
||||
target: string
|
||||
): Promise<{ hash: string; flags: string }> {
|
||||
const flags = computeFlagsFromProfile(profile.config)
|
||||
const hash = await computeBuildHash(profile.version, target, flags)
|
||||
return { hash, flags }
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the R2 artifact URL from a build.
|
||||
* Uses artifactPath if available, otherwise falls back to buildHash.uf2
|
||||
* Or custom domain if R2_PUBLIC_URL is set.
|
||||
*/
|
||||
export function getR2ArtifactUrl(build: {
|
||||
buildHash: string
|
||||
artifactPath?: string
|
||||
}): string {
|
||||
export function getR2ArtifactUrl(
|
||||
build: Pick<BuildFields, 'buildHash' | 'artifactPath'>
|
||||
): string {
|
||||
const r2PublicUrl = process.env.R2_PUBLIC_URL
|
||||
if (!r2PublicUrl) {
|
||||
throw new Error('R2_PUBLIC_URL is not set')
|
||||
@@ -56,12 +91,62 @@ export function getR2ArtifactUrl(build: {
|
||||
return `${r2PublicUrl}${normalizedPath}`
|
||||
}
|
||||
|
||||
export const triggerBuildViaProfile = mutation({
|
||||
// Internal mutation to upsert a build by buildHash
|
||||
// This is the single source of truth for build creation
|
||||
export const upsertBuild = internalMutation({
|
||||
args: {
|
||||
...pick(buildFields, ['buildHash', 'target', 'version', 'profileString']),
|
||||
status: v.optional(v.string()),
|
||||
flags: v.string(),
|
||||
},
|
||||
|
||||
handler: async (ctx, args) => {
|
||||
// Check if build already exists with this hash
|
||||
const existingBuild = await ctx.db
|
||||
.query('builds')
|
||||
.filter((q) => q.eq(q.field('buildHash'), args.buildHash))
|
||||
.unique()
|
||||
|
||||
const { status, buildHash, target, version, profileString, flags } = args
|
||||
|
||||
if (existingBuild) {
|
||||
await ctx.db.patch(existingBuild._id, {
|
||||
status: status ?? existingBuild.status,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
return existingBuild._id
|
||||
}
|
||||
|
||||
// Create new build
|
||||
const buildId = await ctx.db.insert('builds', {
|
||||
target,
|
||||
status: 'queued',
|
||||
startedAt: Date.now(),
|
||||
buildHash,
|
||||
updatedAt: Date.now(),
|
||||
version,
|
||||
profileString,
|
||||
})
|
||||
|
||||
// Dispatch GitHub workflow if needed
|
||||
await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, {
|
||||
buildId,
|
||||
target,
|
||||
flags,
|
||||
version,
|
||||
buildHash,
|
||||
})
|
||||
|
||||
return buildId
|
||||
},
|
||||
})
|
||||
|
||||
export const ensureBuildForProfileTarget = mutation({
|
||||
args: {
|
||||
profileId: v.id('profiles'),
|
||||
target: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
handler: async (ctx, args): Promise<Id<'builds'>> => {
|
||||
const userId = await getAuthUserId(ctx)
|
||||
if (!userId) {
|
||||
throw new Error('Unauthorized')
|
||||
@@ -72,167 +157,23 @@ export const triggerBuildViaProfile = mutation({
|
||||
throw new Error('Profile not found')
|
||||
}
|
||||
|
||||
// Convert config object to flags string
|
||||
const flags: string[] = []
|
||||
// Compute build hash (single source of truth)
|
||||
const { hash: buildHash, flags: flagsString } =
|
||||
await computeBuildHashForProfile(profile, args.target)
|
||||
|
||||
// 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`)
|
||||
}
|
||||
}
|
||||
|
||||
const flagsString = flags.join(' ')
|
||||
|
||||
// Compute build hash
|
||||
const buildHash = await computeBuildHash(
|
||||
profile.version,
|
||||
args.target,
|
||||
flagsString
|
||||
)
|
||||
|
||||
// Check if build already exists with this hash
|
||||
let existingBuild = await ctx.db
|
||||
.query('builds')
|
||||
.filter((q) => q.eq('buildHash', buildHash))
|
||||
.first()
|
||||
|
||||
let buildId: Id<'builds'>
|
||||
let shouldDispatch = false
|
||||
|
||||
if (existingBuild) {
|
||||
// Build already exists, use it
|
||||
buildId = existingBuild._id
|
||||
} else {
|
||||
// Create new build and dispatch workflow
|
||||
buildId = await ctx.db.insert('builds', {
|
||||
target: args.target,
|
||||
githubRunId: 0,
|
||||
status: 'queued',
|
||||
startedAt: Date.now(),
|
||||
buildHash: buildHash,
|
||||
})
|
||||
shouldDispatch = true
|
||||
|
||||
// Handle race condition
|
||||
existingBuild = await ctx.db
|
||||
.query('builds')
|
||||
.filter((q) => q.eq('buildHash', buildHash))
|
||||
.first()
|
||||
|
||||
if (existingBuild && existingBuild._id !== buildId) {
|
||||
await ctx.db.delete(buildId)
|
||||
buildId = existingBuild._id
|
||||
shouldDispatch = false
|
||||
}
|
||||
}
|
||||
|
||||
// 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')
|
||||
.filter((q) => q.eq('profileId', profileId))
|
||||
.collect()
|
||||
|
||||
// Find existing profileBuild with matching target by checking the build
|
||||
let foundExisting = null
|
||||
for (const pb of profileBuilds) {
|
||||
const build = await ctx.db.get(pb.buildId)
|
||||
if (build?.target === args.target) {
|
||||
foundExisting = pb
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (foundExisting) {
|
||||
await ctx.db.patch(foundExisting._id, {
|
||||
buildId: buildId,
|
||||
})
|
||||
} else {
|
||||
await ctx.db.insert('profileBuilds', {
|
||||
profileId: args.profileId,
|
||||
buildId: buildId,
|
||||
})
|
||||
}
|
||||
|
||||
// Dispatch GitHub workflow if needed
|
||||
if (shouldDispatch) {
|
||||
await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, {
|
||||
buildId: buildId,
|
||||
// Upsert the build (single source of truth)
|
||||
const buildId: Id<'builds'> = await ctx.runMutation(
|
||||
internal.builds.upsertBuild,
|
||||
{
|
||||
buildHash,
|
||||
target: args.target,
|
||||
flags: flagsString,
|
||||
version: profile.version,
|
||||
buildHash: buildHash,
|
||||
})
|
||||
}
|
||||
|
||||
return buildId
|
||||
},
|
||||
})
|
||||
|
||||
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')
|
||||
.filter((q) => q.eq('profileId', listProfileId))
|
||||
.collect()
|
||||
|
||||
// Get builds for each profileBuild
|
||||
const builds = await Promise.all(
|
||||
profileBuilds.map(async (pb) => {
|
||||
const build = await ctx.db.get(pb.buildId)
|
||||
if (!build) return null
|
||||
// Return build with computed artifactUrl
|
||||
return {
|
||||
...build,
|
||||
artifactUrl: getR2ArtifactUrl(build),
|
||||
}
|
||||
})
|
||||
profileString: JSON.stringify(profile),
|
||||
}
|
||||
)
|
||||
|
||||
// Filter out nulls and sort by startedAt descending
|
||||
return builds
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.sort((a, b) => b.startedAt - a.startedAt)
|
||||
.slice(0, 10)
|
||||
},
|
||||
})
|
||||
|
||||
export const get = query({
|
||||
args: { buildId: v.id('builds') },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx)
|
||||
if (!userId) return null
|
||||
|
||||
const build = await ctx.db.get(args.buildId)
|
||||
if (!build) return null
|
||||
|
||||
// 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')
|
||||
.filter((q) => q.eq('buildId', buildId))
|
||||
.collect()
|
||||
|
||||
// Check if any of these profileBuilds link to a profile owned by the user
|
||||
for (const pb of profileBuilds) {
|
||||
const profile = await ctx.db.get(pb.profileId)
|
||||
if (profile && profile.userId === userId) {
|
||||
// Return build with computed artifactUrl
|
||||
return {
|
||||
...build,
|
||||
artifactUrl: getR2ArtifactUrl(build),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
return buildId
|
||||
},
|
||||
})
|
||||
|
||||
@@ -244,41 +185,6 @@ export const getInternal = internalMutation({
|
||||
},
|
||||
})
|
||||
|
||||
export const deleteBuild = mutation({
|
||||
args: {
|
||||
buildId: v.id('builds'),
|
||||
profileId: v.id('profiles'),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx)
|
||||
if (!userId) throw new Error('Unauthorized')
|
||||
|
||||
// Verify profile belongs to user
|
||||
const profile = await ctx.db.get(args.profileId)
|
||||
if (!profile || profile.userId !== userId) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
// Find profileBuild linking this profile to this build
|
||||
const buildId = args.buildId as string
|
||||
const profileBuilds = await ctx.db
|
||||
.query('profileBuilds')
|
||||
.filter((q) => q.eq('buildId', buildId))
|
||||
.collect()
|
||||
|
||||
const profileBuild = profileBuilds.find(
|
||||
(pb) => pb.profileId === args.profileId
|
||||
)
|
||||
|
||||
if (!profileBuild) {
|
||||
throw new Error('ProfileBuild not found')
|
||||
}
|
||||
|
||||
// Delete only the profileBuild record (not the underlying build)
|
||||
await ctx.db.delete(profileBuild._id)
|
||||
},
|
||||
})
|
||||
|
||||
// Internal mutation to log errors from actions
|
||||
export const logBuildError = internalMutation({
|
||||
args: {
|
||||
@@ -296,10 +202,13 @@ export const logBuildError = internalMutation({
|
||||
// Internal mutation to update build status
|
||||
export const updateBuildStatus = internalMutation({
|
||||
args: {
|
||||
...pick(buildFields, [
|
||||
'status',
|
||||
'completedAt',
|
||||
'artifactPath',
|
||||
'githubRunId',
|
||||
]),
|
||||
buildId: v.id('builds'),
|
||||
status: v.string(), // Accepts any status string value
|
||||
artifactPath: v.optional(v.string()),
|
||||
githubRunId: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const build = await ctx.db.get(args.buildId)
|
||||
|
||||
+43
-126
@@ -1,7 +1,13 @@
|
||||
import { getAuthUserId } from '@convex-dev/auth/server'
|
||||
import { v } from 'convex/values'
|
||||
import { mutation, query } from './_generated/server'
|
||||
import { getR2ArtifactUrl } from './builds'
|
||||
import { api, internal } from './_generated/api'
|
||||
import type { Doc, Id } from './_generated/dataModel'
|
||||
import { action, internalMutation, mutation, query } from './_generated/server'
|
||||
import {
|
||||
computeBuildHashForProfile,
|
||||
computeFlagsFromProfile,
|
||||
getR2ArtifactUrl,
|
||||
} from './builds'
|
||||
|
||||
export const list = query({
|
||||
args: {},
|
||||
@@ -44,73 +50,11 @@ export const get = query({
|
||||
},
|
||||
})
|
||||
|
||||
export const getTargets = query({
|
||||
args: { profileId: v.id('profiles') },
|
||||
// Internal mutation to get a build by ID
|
||||
export const getBuildById = internalMutation({
|
||||
args: { buildId: v.id('builds') },
|
||||
handler: async (ctx, args) => {
|
||||
const profileId = args.profileId as string
|
||||
const profileBuilds = await ctx.db
|
||||
.query('profileBuilds')
|
||||
.filter((q) => q.eq('profileId', profileId))
|
||||
.collect()
|
||||
// Get unique targets from builds
|
||||
const builds = await Promise.all(
|
||||
profileBuilds.map((pb) => ctx.db.get(pb.buildId))
|
||||
)
|
||||
const targets = new Set(
|
||||
builds
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.map((b) => b.target)
|
||||
)
|
||||
return Array.from(targets)
|
||||
},
|
||||
})
|
||||
|
||||
export const getProfileTarget = query({
|
||||
args: {
|
||||
profileId: v.id('profiles'),
|
||||
target: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
// Get all profileBuilds for this profile
|
||||
const profileBuilds = await ctx.db
|
||||
.query('profileBuilds')
|
||||
.filter((q) => q.eq(q.field('profileId'), args.profileId))
|
||||
.collect()
|
||||
|
||||
// Find the profileBuild with matching target by checking the build
|
||||
for (const profileBuild of profileBuilds) {
|
||||
const build = await ctx.db.get(profileBuild.buildId)
|
||||
if (build?.target === args.target) {
|
||||
return {
|
||||
profileBuild,
|
||||
build: {
|
||||
...build,
|
||||
artifactUrl: getR2ArtifactUrl(build),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
})
|
||||
|
||||
export const getFlashCount = query({
|
||||
args: { profileId: v.id('profiles') },
|
||||
handler: async (ctx, args) => {
|
||||
const profileBuilds = await ctx.db
|
||||
.query('profileBuilds')
|
||||
.filter((q) => q.eq(q.field('profileId'), args.profileId))
|
||||
.collect()
|
||||
|
||||
let successCount = 0
|
||||
for (const profileBuild of profileBuilds) {
|
||||
const build = await ctx.db.get(profileBuild.buildId)
|
||||
if (build && build.status === 'success') {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
return successCount
|
||||
return await ctx.db.get(args.buildId)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -135,11 +79,11 @@ export const recordFlash = mutation({
|
||||
},
|
||||
})
|
||||
|
||||
export const create = mutation({
|
||||
export const upsert = mutation({
|
||||
args: {
|
||||
id: v.optional(v.id('profiles')),
|
||||
name: v.string(),
|
||||
description: v.string(),
|
||||
targets: v.optional(v.array(v.string())),
|
||||
config: v.any(),
|
||||
version: v.string(),
|
||||
isPublic: v.boolean(),
|
||||
@@ -148,56 +92,39 @@ export const create = mutation({
|
||||
const userId = await getAuthUserId(ctx)
|
||||
if (!userId) throw new Error('Unauthorized')
|
||||
|
||||
const profileId = await ctx.db.insert('profiles', {
|
||||
userId,
|
||||
name: args.name,
|
||||
description: args.description,
|
||||
config: args.config,
|
||||
version: args.version,
|
||||
flashCount: 0,
|
||||
updatedAt: Date.now(),
|
||||
isPublic: args.isPublic ?? true,
|
||||
})
|
||||
if (args.id) {
|
||||
// Update existing profile
|
||||
const profile = await ctx.db.get(args.id)
|
||||
if (!profile || profile.userId !== userId) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
// Note: targets are now tracked via profileBuilds when builds are triggered
|
||||
// No need to create profileTargets entries
|
||||
await ctx.db.patch(args.id, {
|
||||
name: args.name,
|
||||
description: args.description,
|
||||
config: args.config,
|
||||
version: args.version,
|
||||
isPublic: args.isPublic,
|
||||
flashCount: profile.flashCount ?? 0,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
return profileId
|
||||
},
|
||||
})
|
||||
return args.id
|
||||
} else {
|
||||
// Create new profile
|
||||
const profileId = await ctx.db.insert('profiles', {
|
||||
userId,
|
||||
name: args.name,
|
||||
description: args.description,
|
||||
config: args.config,
|
||||
version: args.version,
|
||||
flashCount: 0,
|
||||
updatedAt: Date.now(),
|
||||
isPublic: args.isPublic ?? true,
|
||||
})
|
||||
|
||||
export const update = mutation({
|
||||
args: {
|
||||
id: v.id('profiles'),
|
||||
name: v.string(),
|
||||
description: v.string(),
|
||||
targets: v.optional(v.array(v.string())),
|
||||
config: v.any(),
|
||||
version: v.optional(v.string()),
|
||||
isPublic: v.boolean(),
|
||||
},
|
||||
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')
|
||||
return profileId
|
||||
}
|
||||
|
||||
// Update profile
|
||||
await ctx.db.patch(args.id, {
|
||||
name: args.name,
|
||||
description: args.description,
|
||||
config: args.config,
|
||||
version: args.version,
|
||||
isPublic: args.isPublic,
|
||||
flashCount: profile.flashCount ?? 0,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
// Note: targets are now tracked via profileBuilds when builds are triggered
|
||||
// No need to sync profileTargets
|
||||
},
|
||||
})
|
||||
|
||||
@@ -212,16 +139,6 @@ export const remove = mutation({
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
// Delete associated profileBuilds
|
||||
const profileBuilds = await ctx.db
|
||||
.query('profileBuilds')
|
||||
.filter((q) => q.eq(q.field('profileId'), args.id))
|
||||
.collect()
|
||||
|
||||
for (const profileBuild of profileBuilds) {
|
||||
await ctx.db.delete(profileBuild._id)
|
||||
}
|
||||
|
||||
await ctx.db.delete(args.id)
|
||||
},
|
||||
})
|
||||
|
||||
+41
-10
@@ -1,14 +1,45 @@
|
||||
import { authTables } from '@convex-dev/auth/server'
|
||||
import { defineSchema } from 'convex/server'
|
||||
import { defineSchema, defineTable } from 'convex/server'
|
||||
import { type Infer, v } from 'convex/values'
|
||||
import type { Doc } from './_generated/dataModel'
|
||||
|
||||
const schema = defineSchema(
|
||||
{
|
||||
...authTables,
|
||||
// your other tables...
|
||||
},
|
||||
{
|
||||
strictTableNameTypes: false,
|
||||
}
|
||||
)
|
||||
export const profileFields = {
|
||||
userId: v.id('users'),
|
||||
name: v.string(),
|
||||
description: v.string(),
|
||||
version: v.string(),
|
||||
config: v.object({
|
||||
modulesExcluded: v.record(v.string(), v.boolean()),
|
||||
}),
|
||||
isPublic: v.boolean(),
|
||||
flashCount: v.number(),
|
||||
updatedAt: v.number(),
|
||||
}
|
||||
|
||||
export const buildFields = {
|
||||
buildHash: v.string(),
|
||||
target: v.string(),
|
||||
version: v.string(),
|
||||
status: v.string(),
|
||||
startedAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
profileString: v.string(),
|
||||
|
||||
// Optional props
|
||||
completedAt: v.optional(v.number()),
|
||||
artifactPath: v.optional(v.string()),
|
||||
githubRunId: v.optional(v.number()),
|
||||
}
|
||||
|
||||
export const schema = defineSchema({
|
||||
...authTables,
|
||||
profiles: defineTable(profileFields),
|
||||
builds: defineTable(buildFields),
|
||||
})
|
||||
|
||||
export type ProfilesDoc = Doc<'profiles'>
|
||||
export type BuildsDoc = Doc<'builds'>
|
||||
export type ProfileFields = Infer<typeof schema.tables.profiles.validator>
|
||||
export type BuildFields = Infer<typeof schema.tables.builds.validator>
|
||||
|
||||
export default schema
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.29.3",
|
||||
"convex-helpers": "^0.1.106",
|
||||
"lucide-react": "^0.554.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
interface ModuleToggleProps {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
isExcluded: boolean
|
||||
onToggle: (excluded: boolean) => void
|
||||
}
|
||||
|
||||
export function ModuleToggle({
|
||||
name,
|
||||
description,
|
||||
isExcluded,
|
||||
onToggle,
|
||||
}: ModuleToggleProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-4 p-4 rounded-lg border-2 border-slate-700 bg-slate-900/50 hover:border-slate-600 transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-sm mb-1">{name}</h4>
|
||||
<p className="text-xs text-slate-400 leading-relaxed">{description}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
<Switch
|
||||
checked={isExcluded}
|
||||
onCheckedChange={onToggle}
|
||||
labelLeft="Default"
|
||||
labelRight="Excluded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Doc } from '../../convex/_generated/dataModel'
|
||||
import type { ProfileFields } from '../../convex/schema'
|
||||
|
||||
export const profileCardClasses =
|
||||
'border border-slate-800 rounded-lg p-6 bg-slate-900/50 flex flex-col gap-4'
|
||||
@@ -30,7 +31,7 @@ export function ProfileStatisticPills({
|
||||
}
|
||||
|
||||
interface ProfileCardContentProps {
|
||||
profile: Doc<'profiles'>
|
||||
profile: Doc<'profiles'> & ProfileFields
|
||||
}
|
||||
|
||||
export function ProfileCardContent({ profile }: ProfileCardContentProps) {
|
||||
|
||||
@@ -4,21 +4,21 @@ 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 type { ProfileFields, ProfilesDoc } from '../../convex/schema'
|
||||
import { VERSIONS } from '../constants/versions'
|
||||
import { ModuleCard } from './ModuleCard'
|
||||
import { ModuleToggle } from './ModuleToggle'
|
||||
|
||||
interface ProfileFormValues {
|
||||
name: string
|
||||
description: string
|
||||
config: Record<string, boolean>
|
||||
version: string
|
||||
isPublic: boolean
|
||||
// Form values use flattened config for UI, but will be transformed to nested on submit
|
||||
type ProfileFormValues = Omit<
|
||||
ProfileFields,
|
||||
'_id' | '_creationTime' | 'userId' | 'flashCount' | 'updatedAt' | 'config'
|
||||
> & {
|
||||
config: Record<string, boolean | undefined> // Flattened: moduleId -> boolean
|
||||
}
|
||||
|
||||
interface ProfileEditorProps {
|
||||
initialData?: Doc<'profiles'>
|
||||
initialData?: ProfilesDoc
|
||||
onSave: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
@@ -28,8 +28,28 @@ export default function ProfileEditor({
|
||||
onSave,
|
||||
onCancel,
|
||||
}: ProfileEditorProps) {
|
||||
const createProfile = useMutation(api.profiles.create)
|
||||
const updateProfile = useMutation(api.profiles.update)
|
||||
const upsertProfile = useMutation(api.profiles.upsert)
|
||||
|
||||
// Flatten config for UI: transform config.modulesExcluded to flat object
|
||||
const getFlattenedConfig = (
|
||||
config: ProfileFields['config'] | undefined
|
||||
): Record<string, boolean> => {
|
||||
if (!config || !config.modulesExcluded) return {}
|
||||
return { ...config.modulesExcluded }
|
||||
}
|
||||
|
||||
// Transform flat config back to nested structure for database
|
||||
const getNestedConfig = (
|
||||
flatConfig: Record<string, boolean | undefined>
|
||||
): ProfileFields['config'] => {
|
||||
const modulesExcluded: Record<string, boolean> = {}
|
||||
for (const [key, value] of Object.entries(flatConfig)) {
|
||||
if (value === true) {
|
||||
modulesExcluded[key] = true
|
||||
}
|
||||
}
|
||||
return { modulesExcluded }
|
||||
}
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -41,31 +61,23 @@ export default function ProfileEditor({
|
||||
defaultValues: {
|
||||
name: initialData?.name || '',
|
||||
description: initialData?.description || '',
|
||||
config: initialData?.config || {},
|
||||
config: getFlattenedConfig(initialData?.config),
|
||||
version: initialData?.version || VERSIONS[0],
|
||||
isPublic: initialData?.isPublic ?? true,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (data: ProfileFormValues) => {
|
||||
if (initialData?._id) {
|
||||
await updateProfile({
|
||||
id: initialData._id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
config: data.config,
|
||||
version: data.version,
|
||||
isPublic: data.isPublic,
|
||||
})
|
||||
} else {
|
||||
await createProfile({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
config: data.config,
|
||||
version: data.version,
|
||||
isPublic: data.isPublic,
|
||||
})
|
||||
}
|
||||
// Transform flattened config back to nested structure
|
||||
const nestedConfig = getNestedConfig(data.config)
|
||||
await upsertProfile({
|
||||
id: initialData?._id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
config: nestedConfig,
|
||||
version: data.version,
|
||||
isPublic: data.isPublic,
|
||||
})
|
||||
onSave()
|
||||
}
|
||||
|
||||
@@ -151,28 +163,36 @@ export default function ProfileEditor({
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium">Modules</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
Select the modules to include in your build.
|
||||
Modules are included by default if supported by your target.
|
||||
Toggle to exclude modules you don't need.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{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
|
||||
// Flattened config: config[id] === true -> Explicitly Excluded
|
||||
// config[id] === undefined/false -> Default (included if target supports)
|
||||
const currentConfig = watch('config') as Record<
|
||||
string,
|
||||
boolean | undefined
|
||||
>
|
||||
const configValue = currentConfig[module.id]
|
||||
const isExcluded = configValue === true
|
||||
|
||||
return (
|
||||
<ModuleCard
|
||||
<ModuleToggle
|
||||
key={module.id}
|
||||
id={module.id}
|
||||
name={module.name}
|
||||
description={module.description}
|
||||
selected={isIncluded}
|
||||
onClick={() => {
|
||||
// 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)
|
||||
isExcluded={isExcluded}
|
||||
onToggle={(excluded) => {
|
||||
const newConfig = { ...currentConfig }
|
||||
if (excluded) {
|
||||
newConfig[module.id] = true
|
||||
} else {
|
||||
delete newConfig[module.id]
|
||||
}
|
||||
setValue('config', newConfig)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SwitchProps {
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
labelLeft?: string
|
||||
labelRight?: string
|
||||
}
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled = false,
|
||||
className,
|
||||
labelLeft,
|
||||
labelRight,
|
||||
}: SwitchProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && onCheckedChange(!checked)}
|
||||
className={cn(
|
||||
'relative inline-flex h-8 w-24 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
checked ? 'bg-red-600' : 'bg-slate-600',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block h-6 w-6 transform rounded-full bg-white transition-transform',
|
||||
checked ? 'translate-x-[68px]' : 'translate-x-1'
|
||||
)}
|
||||
/>
|
||||
{checked && labelRight && (
|
||||
<span className="absolute left-2 text-xs font-medium text-white">
|
||||
{labelRight}
|
||||
</span>
|
||||
)}
|
||||
{!checked && labelLeft && (
|
||||
<span className="absolute right-2 text-xs font-medium text-white">
|
||||
{labelLeft}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ 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 type { ProfileFields } from '../../convex/schema'
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate()
|
||||
@@ -18,7 +19,7 @@ export default function Dashboard() {
|
||||
const removeProfile = useMutation(api.profiles.remove)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
const handleEdit = (profile: Doc<'profiles'>) => {
|
||||
const handleEdit = (profile: Doc<'profiles'> & ProfileFields) => {
|
||||
navigate(`/dashboard/profiles/${profile._id}`)
|
||||
}
|
||||
|
||||
|
||||
+18
-17
@@ -1,5 +1,5 @@
|
||||
import { useAuthActions } from '@convex-dev/auth/react'
|
||||
import { useConvexAuth, useMutation, useQuery } from 'convex/react'
|
||||
import { useAction, useConvexAuth, useMutation, useQuery } from 'convex/react'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
@@ -16,15 +16,13 @@ export default function ProfileDetail() {
|
||||
const navigate = useNavigate()
|
||||
const { isAuthenticated } = useConvexAuth()
|
||||
const { signIn } = useAuthActions()
|
||||
const triggerBuildViaProfile = useMutation(api.builds.triggerBuildViaProfile)
|
||||
const ensureBuildForProfileTarget = useMutation(
|
||||
api.builds.ensureBuildForProfileTarget
|
||||
)
|
||||
const profile = useQuery(
|
||||
api.profiles.get,
|
||||
id ? { id: id as Id<'profiles'> } : 'skip'
|
||||
)
|
||||
const flashCount = useQuery(
|
||||
api.profiles.getFlashCount,
|
||||
id ? { profileId: id as Id<'profiles'> } : 'skip'
|
||||
)
|
||||
const [selectedTarget, setSelectedTarget] = useState<string>('')
|
||||
|
||||
// Group targets by category
|
||||
@@ -79,7 +77,7 @@ export default function ProfileDetail() {
|
||||
return <div>Profile ID required</div>
|
||||
}
|
||||
|
||||
if (profile === undefined || flashCount === undefined) {
|
||||
if (profile === undefined) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">
|
||||
<div>Loading...</div>
|
||||
@@ -95,9 +93,9 @@ export default function ProfileDetail() {
|
||||
)
|
||||
}
|
||||
|
||||
// Get enabled modules (inverted logic: config[id] === false means included)
|
||||
const enabledModules = modulesData.modules.filter(
|
||||
(module) => profile.config[module.id] === false
|
||||
// Get excluded modules (new logic: config[id] === true means excluded)
|
||||
const excludedModules = modulesData.modules.filter(
|
||||
(module) => profile.config.modulesExcluded[module.id] === true
|
||||
)
|
||||
|
||||
const handleFlash = async () => {
|
||||
@@ -109,7 +107,7 @@ export default function ProfileDetail() {
|
||||
}
|
||||
|
||||
try {
|
||||
await triggerBuildViaProfile({
|
||||
await ensureBuildForProfileTarget({
|
||||
profileId: id as Id<'profiles'>,
|
||||
target: selectedTarget,
|
||||
})
|
||||
@@ -130,7 +128,7 @@ export default function ProfileDetail() {
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold mb-2">{profile.name}</h1>
|
||||
<p className="text-slate-400">
|
||||
Flashed {flashCount} time{flashCount !== 1 ? 's' : ''}
|
||||
Flashed {totalFlashes} time{totalFlashes !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<ProfileStatisticPills
|
||||
@@ -140,15 +138,18 @@ export default function ProfileDetail() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Enabled Modules */}
|
||||
{/* Excluded Modules */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-4">Enabled Modules</h2>
|
||||
<h2 className="text-2xl font-semibold mb-4">Excluded Modules</h2>
|
||||
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
|
||||
{enabledModules.length === 0 ? (
|
||||
<p className="text-slate-400">No modules enabled</p>
|
||||
{excludedModules.length === 0 ? (
|
||||
<p className="text-slate-400">
|
||||
No modules explicitly excluded. All modules supported by your
|
||||
target will be included.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{enabledModules.map((module) => (
|
||||
{excludedModules.map((module) => (
|
||||
<div
|
||||
key={module.id}
|
||||
className="border-b border-slate-800 pb-4 last:border-b-0 last:pb-0"
|
||||
|
||||
+34
-16
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQuery } from 'convex/react'
|
||||
import { ArrowLeft, CheckCircle, Loader2, XCircle } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { ProfileStatisticPills } from '@/components/ProfileCard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -15,17 +16,32 @@ export default function ProfileFlash() {
|
||||
target: string
|
||||
}>()
|
||||
|
||||
const data = useQuery(
|
||||
api.profiles.getProfileTarget,
|
||||
id && target ? { profileId: id as Id<'profiles'>, target } : 'skip'
|
||||
const ensureBuildForProfileTarget = useMutation(
|
||||
api.builds.ensureBuildForProfileTarget
|
||||
)
|
||||
|
||||
const [buildId, setBuildId] = useState<Id<'builds'> | null>(null)
|
||||
|
||||
const build = useQuery(
|
||||
api.builds.get, // query you write that does ctx.db.get(id)
|
||||
buildId ? { id: buildId } : 'skip'
|
||||
)
|
||||
|
||||
const profile = useQuery(
|
||||
api.profiles.get,
|
||||
id ? { id: id as Id<'profiles'> } : 'skip'
|
||||
)
|
||||
const generateDownloadUrl = useMutation(api.builds.generateDownloadUrl)
|
||||
|
||||
if (data === undefined || profile === undefined) {
|
||||
useEffect(() => {
|
||||
if (id && target) {
|
||||
ensureBuildForProfileTarget({ profileId: id as Id<'profiles'>, target })
|
||||
.then(setBuildId)
|
||||
.catch(() => setBuildId(null))
|
||||
}
|
||||
}, [id, target, ensureBuildForProfileTarget])
|
||||
|
||||
if (build === undefined || profile === undefined) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
|
||||
@@ -33,7 +49,7 @@ export default function ProfileFlash() {
|
||||
)
|
||||
}
|
||||
|
||||
if (data === null || !data.build) {
|
||||
if (!build) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
@@ -69,16 +85,15 @@ export default function ProfileFlash() {
|
||||
)
|
||||
}
|
||||
|
||||
const build = data.build
|
||||
const targetMeta = target ? TARGETS[target] : undefined
|
||||
const targetLabel = targetMeta?.name ?? target ?? 'Unknown Target'
|
||||
const includedModules = modulesData.modules.filter(
|
||||
(module) => profile.config?.[module.id] === false
|
||||
const excludedModules = modulesData.modules.filter(
|
||||
(module) => profile.config.modulesExcluded[module.id] === true
|
||||
)
|
||||
const totalFlashes = profile.flashCount ?? 0
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!id || !build.artifactUrl) return
|
||||
if (!id || !build.artifactPath) return
|
||||
|
||||
try {
|
||||
const url = await generateDownloadUrl({
|
||||
@@ -108,7 +123,7 @@ export default function ProfileFlash() {
|
||||
}
|
||||
|
||||
const githubActionUrl =
|
||||
build.githubRunId > 0
|
||||
build.githubRunId && build.githubRunId > 0
|
||||
? `https://github.com/MeshEnvy/configurable-web-flasher/actions/runs/${build.githubRunId}`
|
||||
: null
|
||||
|
||||
@@ -142,12 +157,15 @@ export default function ProfileFlash() {
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/50 rounded-lg border border-slate-800 p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Included Modules</h2>
|
||||
{includedModules.length === 0 ? (
|
||||
<p className="text-slate-400 text-sm">No modules included.</p>
|
||||
<h2 className="text-xl font-semibold mb-4">Excluded Modules</h2>
|
||||
{excludedModules.length === 0 ? (
|
||||
<p className="text-slate-400 text-sm">
|
||||
No modules explicitly excluded. All modules supported by this
|
||||
target are included.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{includedModules.map((module) => (
|
||||
{excludedModules.map((module) => (
|
||||
<div key={module.id}>
|
||||
<p className="font-medium text-sm">{module.name}</p>
|
||||
<p className="text-slate-400 text-sm">{module.description}</p>
|
||||
@@ -193,12 +211,12 @@ export default function ProfileFlash() {
|
||||
</a>
|
||||
)}
|
||||
<span>•</span>
|
||||
<span>{new Date(build.startedAt).toLocaleString()}</span>
|
||||
<span>{new Date(build.updatedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{build.status === 'success' && build.artifactUrl && (
|
||||
{build.status === 'success' && build.artifactPath && (
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
|
||||
Reference in New Issue
Block a user