From 11fbd305238bf66329c2cbecb15a1d41e6d0cbee Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:13:20 +0000 Subject: [PATCH 01/81] feat(database): add developer platform schema tables --- packages/database/schema.ts | 273 ++++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 125d40cb7d9..9e205801737 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -15,6 +15,7 @@ import { customType, datetime, float, + foreignKey, index, int, json, @@ -885,3 +886,275 @@ export const importedVideos = mysqlTable( primaryKey({ columns: [table.orgId, table.source, table.sourceId] }), ], ); + +export const developerApps = mysqlTable( + "developer_apps", + { + id: nanoId("id").notNull().primaryKey(), + ownerId: nanoId("ownerId").notNull().$type(), + name: varchar("name", { length: 255 }).notNull(), + environment: varchar("environment", { + length: 16, + enum: ["development", "production"], + }).notNull(), + logoUrl: varchar("logoUrl", { length: 1024 }), + deletedAt: timestamp("deletedAt"), + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), + }, + (table) => [index("owner_deleted_idx").on(table.ownerId, table.deletedAt)], +); + +export const developerAppDomains = mysqlTable( + "developer_app_domains", + { + id: nanoId("id").notNull().primaryKey(), + appId: nanoId("appId") + .notNull() + .references(() => developerApps.id), + domain: varchar("domain", { length: 253 }).notNull(), + createdAt: timestamp("createdAt").notNull().defaultNow(), + }, + (table) => [unique("app_domain_unique").on(table.appId, table.domain)], +); + +export const developerApiKeys = mysqlTable( + "developer_api_keys", + { + id: nanoId("id").notNull().primaryKey(), + appId: nanoId("appId") + .notNull() + .references(() => developerApps.id), + keyType: varchar("keyType", { + length: 8, + enum: ["public", "secret"], + }).notNull(), + keyPrefix: varchar("keyPrefix", { length: 12 }).notNull(), + keyHash: varchar("keyHash", { length: 64 }).notNull(), + encryptedKey: encryptedText("encryptedKey").notNull(), + lastUsedAt: timestamp("lastUsedAt"), + revokedAt: timestamp("revokedAt"), + createdAt: timestamp("createdAt").notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("key_hash_idx").on(table.keyHash), + index("app_key_type_idx").on(table.appId, table.keyType), + ], +); + +export const developerVideos = mysqlTable( + "developer_videos", + { + id: nanoId("id").notNull().primaryKey(), + appId: nanoId("appId") + .notNull() + .references(() => developerApps.id), + externalUserId: varchar("externalUserId", { length: 255 }), + name: varchar("name", { length: 255 }).notNull().default("Untitled"), + duration: float("duration"), + width: int("width"), + height: int("height"), + fps: int("fps"), + s3Key: varchar("s3Key", { length: 512 }), + transcriptionStatus: varchar("transcriptionStatus", { + length: 16, + enum: ["PROCESSING", "COMPLETE", "ERROR", "SKIPPED", "NO_AUDIO"], + }), + metadata: json("metadata"), + deletedAt: timestamp("deletedAt"), + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), + }, + (table) => [ + index("app_created_idx").on(table.appId, table.createdAt), + index("app_user_idx").on(table.appId, table.externalUserId), + index("app_deleted_idx").on(table.appId, table.deletedAt), + ], +); + +export const developerCreditAccounts = mysqlTable( + "developer_credit_accounts", + { + id: nanoId("id").notNull().primaryKey(), + appId: nanoId("appId") + .notNull() + .references(() => developerApps.id), + ownerId: nanoId("ownerId").notNull().$type(), + balanceMicroCredits: bigint("balanceMicroCredits", { + mode: "number", + unsigned: true, + }) + .notNull() + .default(0), + stripeCustomerId: varchar("stripeCustomerId", { length: 255 }), + stripePaymentMethodId: varchar("stripePaymentMethodId", { length: 255 }), + autoTopUpEnabled: boolean("autoTopUpEnabled").notNull().default(false), + autoTopUpThresholdMicroCredits: bigint("autoTopUpThresholdMicroCredits", { + mode: "number", + unsigned: true, + }) + .notNull() + .default(0), + autoTopUpAmountCents: int("autoTopUpAmountCents").notNull().default(0), + createdAt: timestamp("createdAt").notNull().defaultNow(), + updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), + }, + (table) => [uniqueIndex("app_id_unique").on(table.appId)], +); + +export type DeveloperCreditTransactionType = + | "topup" + | "video_create" + | "storage_daily" + | "refund" + | "adjustment"; + +export type DeveloperCreditReferenceType = + | "developer_video" + | "stripe_payment_intent" + | "manual"; + +export const developerCreditTransactions = mysqlTable( + "developer_credit_transactions", + { + id: nanoId("id").notNull().primaryKey(), + accountId: nanoId("accountId").notNull(), + type: varchar("type", { + length: 16, + enum: ["topup", "video_create", "storage_daily", "refund", "adjustment"], + }) + .notNull() + .$type(), + amountMicroCredits: bigint("amountMicroCredits", { + mode: "number", + }).notNull(), + balanceAfterMicroCredits: bigint("balanceAfterMicroCredits", { + mode: "number", + unsigned: true, + }).notNull(), + referenceId: varchar("referenceId", { length: 255 }), + referenceType: varchar("referenceType", { + length: 32, + enum: ["developer_video", "stripe_payment_intent", "manual"], + }).$type(), + metadata: json("metadata"), + createdAt: timestamp("createdAt").notNull().defaultNow(), + }, + (table) => [ + foreignKey({ + name: "dev_credit_txn_account_fk", + columns: [table.accountId], + foreignColumns: [developerCreditAccounts.id], + }), + index("account_type_created_idx").on( + table.accountId, + table.type, + table.createdAt, + ), + ], +); + +export const developerDailyStorageSnapshots = mysqlTable( + "developer_daily_storage_snapshots", + { + id: nanoId("id").notNull().primaryKey(), + appId: nanoId("appId") + .notNull() + .references(() => developerApps.id), + snapshotDate: varchar("snapshotDate", { length: 10 }).notNull(), + totalDurationMinutes: float("totalDurationMinutes").notNull().default(0), + videoCount: int("videoCount").notNull().default(0), + microCreditsCharged: bigint("microCreditsCharged", { + mode: "number", + unsigned: true, + }) + .notNull() + .default(0), + processedAt: timestamp("processedAt"), + createdAt: timestamp("createdAt").notNull().defaultNow(), + }, + (table) => [unique("app_date_unique").on(table.appId, table.snapshotDate)], +); + +export const developerAppsRelations = relations( + developerApps, + ({ one, many }) => ({ + owner: one(users, { + fields: [developerApps.ownerId], + references: [users.id], + }), + domains: many(developerAppDomains), + apiKeys: many(developerApiKeys), + videos: many(developerVideos), + creditAccount: one(developerCreditAccounts, { + fields: [developerApps.id], + references: [developerCreditAccounts.appId], + }), + storageSnapshots: many(developerDailyStorageSnapshots), + }), +); + +export const developerAppDomainsRelations = relations( + developerAppDomains, + ({ one }) => ({ + app: one(developerApps, { + fields: [developerAppDomains.appId], + references: [developerApps.id], + }), + }), +); + +export const developerApiKeysRelations = relations( + developerApiKeys, + ({ one }) => ({ + app: one(developerApps, { + fields: [developerApiKeys.appId], + references: [developerApps.id], + }), + }), +); + +export const developerVideosRelations = relations( + developerVideos, + ({ one }) => ({ + app: one(developerApps, { + fields: [developerVideos.appId], + references: [developerApps.id], + }), + }), +); + +export const developerCreditAccountsRelations = relations( + developerCreditAccounts, + ({ one, many }) => ({ + app: one(developerApps, { + fields: [developerCreditAccounts.appId], + references: [developerApps.id], + }), + owner: one(users, { + fields: [developerCreditAccounts.ownerId], + references: [users.id], + }), + transactions: many(developerCreditTransactions), + }), +); + +export const developerCreditTransactionsRelations = relations( + developerCreditTransactions, + ({ one }) => ({ + account: one(developerCreditAccounts, { + fields: [developerCreditTransactions.accountId], + references: [developerCreditAccounts.id], + }), + }), +); + +export const developerDailyStorageSnapshotsRelations = relations( + developerDailyStorageSnapshots, + ({ one }) => ({ + app: one(developerApps, { + fields: [developerDailyStorageSnapshots.appId], + references: [developerApps.id], + }), + }), +); From f9eeaf41670b7c70a81273c0b52e5ac3daeb00ee Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:13:23 +0000 Subject: [PATCH 02/81] feat(utils): add Stripe developer credits product ID --- packages/utils/src/constants/plans.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/utils/src/constants/plans.ts b/packages/utils/src/constants/plans.ts index 0f44770079a..286259fa445 100644 --- a/packages/utils/src/constants/plans.ts +++ b/packages/utils/src/constants/plans.ts @@ -1,5 +1,7 @@ import { buildEnv } from "@cap/env"; +export const STRIPE_DEVELOPER_CREDITS_PRODUCT_ID = "prod_U4mswfBp0bFc39"; + export const STRIPE_PLAN_IDS = { development: { yearly: "price_1Q3esrFJxA1XpeSsFwp486RN", From c4e43d4260811f550890075d0a5c3f1cf920d0c5 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:13:27 +0000 Subject: [PATCH 03/81] feat(web): add developer API key hashing utility --- apps/web/lib/developer-key-hash.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 apps/web/lib/developer-key-hash.ts diff --git a/apps/web/lib/developer-key-hash.ts b/apps/web/lib/developer-key-hash.ts new file mode 100644 index 00000000000..d52c4f62d79 --- /dev/null +++ b/apps/web/lib/developer-key-hash.ts @@ -0,0 +1,30 @@ +import { serverEnv } from "@cap/env"; + +let hmacKeyCache: CryptoKey | null = null; + +async function getHmacKey(): Promise { + if (hmacKeyCache) return hmacKeyCache; + const secret = serverEnv().NEXTAUTH_SECRET; + const encoder = new TextEncoder(); + hmacKeyCache = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return hmacKeyCache; +} + +export async function hashKey(key: string): Promise { + const hmacKey = await getHmacKey(); + const encoder = new TextEncoder(); + const signature = await crypto.subtle.sign( + "HMAC", + hmacKey, + encoder.encode(key), + ); + return Array.from(new Uint8Array(signature)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} From 1a944788714b08330e45587b618dd1e74b051176 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:13:28 +0000 Subject: [PATCH 04/81] feat(web): add developer platform server actions --- apps/web/actions/developers/add-domain.ts | 44 ++++++++++++ apps/web/actions/developers/create-app.ts | 68 ++++++++++++++++++ apps/web/actions/developers/delete-app.ts | 34 +++++++++ apps/web/actions/developers/delete-video.ts | 36 ++++++++++ .../actions/developers/purchase-credits.ts | 64 +++++++++++++++++ .../web/actions/developers/regenerate-keys.ts | 71 +++++++++++++++++++ apps/web/actions/developers/remove-domain.ts | 38 ++++++++++ apps/web/actions/developers/update-app.ts | 46 ++++++++++++ .../actions/developers/update-auto-topup.ts | 60 ++++++++++++++++ 9 files changed, 461 insertions(+) create mode 100644 apps/web/actions/developers/add-domain.ts create mode 100644 apps/web/actions/developers/create-app.ts create mode 100644 apps/web/actions/developers/delete-app.ts create mode 100644 apps/web/actions/developers/delete-video.ts create mode 100644 apps/web/actions/developers/purchase-credits.ts create mode 100644 apps/web/actions/developers/regenerate-keys.ts create mode 100644 apps/web/actions/developers/remove-domain.ts create mode 100644 apps/web/actions/developers/update-app.ts create mode 100644 apps/web/actions/developers/update-auto-topup.ts diff --git a/apps/web/actions/developers/add-domain.ts b/apps/web/actions/developers/add-domain.ts new file mode 100644 index 00000000000..a0afa2fe7ae --- /dev/null +++ b/apps/web/actions/developers/add-domain.ts @@ -0,0 +1,44 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { nanoId } from "@cap/database/helpers"; +import { developerAppDomains, developerApps } from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export async function addDeveloperDomain(appId: string, domain: string) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const trimmed = domain.trim().toLowerCase(); + if (!trimmed) throw new Error("Domain is required"); + + const urlPattern = /^https?:\/\/[a-z0-9.-]+(:[0-9]+)?$/; + if (!urlPattern.test(trimmed)) { + throw new Error("Domain must be a valid origin (e.g. https://myapp.com)"); + } + + const [app] = await db() + .select() + .from(developerApps) + .where( + and( + eq(developerApps.id, appId), + eq(developerApps.ownerId, user.id), + isNull(developerApps.deletedAt), + ), + ) + .limit(1); + + if (!app) throw new Error("App not found"); + + await db().insert(developerAppDomains).values({ + id: nanoId(), + appId, + domain: trimmed, + }); + + revalidatePath("/dashboard/developers"); + return { success: true }; +} diff --git a/apps/web/actions/developers/create-app.ts b/apps/web/actions/developers/create-app.ts new file mode 100644 index 00000000000..fa964d75527 --- /dev/null +++ b/apps/web/actions/developers/create-app.ts @@ -0,0 +1,68 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { encrypt } from "@cap/database/crypto"; +import { nanoId, nanoIdLong } from "@cap/database/helpers"; +import { + developerApiKeys, + developerApps, + developerCreditAccounts, +} from "@cap/database/schema"; +import { hashKey } from "@/lib/developer-key-hash"; + +export async function createDeveloperApp(data: { + name: string; + environment: "development" | "production"; +}) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + if (!data.name.trim()) throw new Error("App name is required"); + + const appId = nanoId(); + const publicKeyRaw = `cpk_${nanoIdLong()}`; + const secretKeyRaw = `csk_${nanoIdLong()}`; + const publicKeyHash = await hashKey(publicKeyRaw); + const secretKeyHash = await hashKey(secretKeyRaw); + + await db().insert(developerApps).values({ + id: appId, + ownerId: user.id, + name: data.name.trim(), + environment: data.environment, + }); + + await db() + .insert(developerApiKeys) + .values([ + { + id: nanoId(), + appId, + keyType: "public", + keyPrefix: publicKeyRaw.slice(0, 12), + keyHash: publicKeyHash, + encryptedKey: await encrypt(publicKeyRaw), + }, + { + id: nanoId(), + appId, + keyType: "secret", + keyPrefix: secretKeyRaw.slice(0, 12), + keyHash: secretKeyHash, + encryptedKey: await encrypt(secretKeyRaw), + }, + ]); + + await db().insert(developerCreditAccounts).values({ + id: nanoId(), + appId, + ownerId: user.id, + }); + + return { + appId, + publicKey: publicKeyRaw, + secretKey: secretKeyRaw, + }; +} diff --git a/apps/web/actions/developers/delete-app.ts b/apps/web/actions/developers/delete-app.ts new file mode 100644 index 00000000000..f90ad7fec56 --- /dev/null +++ b/apps/web/actions/developers/delete-app.ts @@ -0,0 +1,34 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { developerApps } from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export async function deleteDeveloperApp(appId: string) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const [app] = await db() + .select() + .from(developerApps) + .where( + and( + eq(developerApps.id, appId), + eq(developerApps.ownerId, user.id), + isNull(developerApps.deletedAt), + ), + ) + .limit(1); + + if (!app) throw new Error("App not found"); + + await db() + .update(developerApps) + .set({ deletedAt: new Date() }) + .where(eq(developerApps.id, appId)); + + revalidatePath("/dashboard/developers"); + return { success: true }; +} diff --git a/apps/web/actions/developers/delete-video.ts b/apps/web/actions/developers/delete-video.ts new file mode 100644 index 00000000000..b26e3817c26 --- /dev/null +++ b/apps/web/actions/developers/delete-video.ts @@ -0,0 +1,36 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { developerApps, developerVideos } from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export async function deleteDeveloperVideo(appId: string, videoId: string) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const [app] = await db() + .select() + .from(developerApps) + .where( + and( + eq(developerApps.id, appId), + eq(developerApps.ownerId, user.id), + isNull(developerApps.deletedAt), + ), + ) + .limit(1); + + if (!app) throw new Error("App not found"); + + await db() + .update(developerVideos) + .set({ deletedAt: new Date() }) + .where( + and(eq(developerVideos.id, videoId), eq(developerVideos.appId, appId)), + ); + + revalidatePath("/dashboard/developers"); + return { success: true }; +} diff --git a/apps/web/actions/developers/purchase-credits.ts b/apps/web/actions/developers/purchase-credits.ts new file mode 100644 index 00000000000..da79b7464b1 --- /dev/null +++ b/apps/web/actions/developers/purchase-credits.ts @@ -0,0 +1,64 @@ +import { db } from "@cap/database"; +import { nanoId } from "@cap/database/helpers"; +import { + type DeveloperCreditReferenceType, + developerCreditAccounts, + developerCreditTransactions, +} from "@cap/database/schema"; +import { eq, sql } from "drizzle-orm"; + +const MICRO_CREDITS_PER_DOLLAR = 100_000; + +export async function addCreditsToAccount({ + accountId, + amountCents, + referenceId, + referenceType, + metadata, +}: { + accountId: string; + amountCents: number; + referenceId?: string; + referenceType?: DeveloperCreditReferenceType; + metadata?: Record; +}): Promise { + const microCreditsToAdd = Math.floor( + (amountCents / 100) * MICRO_CREDITS_PER_DOLLAR, + ); + + const newBalance = await db().transaction(async (tx) => { + await tx + .update(developerCreditAccounts) + .set({ + balanceMicroCredits: sql`${developerCreditAccounts.balanceMicroCredits} + ${microCreditsToAdd}`, + }) + .where(eq(developerCreditAccounts.id, accountId)); + + const [updated] = await tx + .select({ + balanceMicroCredits: developerCreditAccounts.balanceMicroCredits, + }) + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.id, accountId)) + .limit(1); + + if (!updated) { + throw new Error(`Credit account not found: ${accountId}`); + } + + await tx.insert(developerCreditTransactions).values({ + id: nanoId(), + accountId, + type: "topup", + amountMicroCredits: microCreditsToAdd, + balanceAfterMicroCredits: updated.balanceMicroCredits, + referenceId, + referenceType, + metadata: metadata ?? { amountCents }, + }); + + return updated.balanceMicroCredits; + }); + + return newBalance; +} diff --git a/apps/web/actions/developers/regenerate-keys.ts b/apps/web/actions/developers/regenerate-keys.ts new file mode 100644 index 00000000000..8906904c8cb --- /dev/null +++ b/apps/web/actions/developers/regenerate-keys.ts @@ -0,0 +1,71 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { encrypt } from "@cap/database/crypto"; +import { nanoId, nanoIdLong } from "@cap/database/helpers"; +import { developerApiKeys, developerApps } from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { hashKey } from "@/lib/developer-key-hash"; + +export async function regenerateDeveloperKeys(appId: string) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const [app] = await db() + .select() + .from(developerApps) + .where( + and( + eq(developerApps.id, appId), + eq(developerApps.ownerId, user.id), + isNull(developerApps.deletedAt), + ), + ) + .limit(1); + + if (!app) throw new Error("App not found"); + + await db() + .update(developerApiKeys) + .set({ revokedAt: new Date() }) + .where( + and( + eq(developerApiKeys.appId, appId), + isNull(developerApiKeys.revokedAt), + ), + ); + + const publicKeyRaw = `cpk_${nanoIdLong()}`; + const secretKeyRaw = `csk_${nanoIdLong()}`; + const publicKeyHash = await hashKey(publicKeyRaw); + const secretKeyHash = await hashKey(secretKeyRaw); + + await db() + .insert(developerApiKeys) + .values([ + { + id: nanoId(), + appId, + keyType: "public", + keyPrefix: publicKeyRaw.slice(0, 12), + keyHash: publicKeyHash, + encryptedKey: await encrypt(publicKeyRaw), + }, + { + id: nanoId(), + appId, + keyType: "secret", + keyPrefix: secretKeyRaw.slice(0, 12), + keyHash: secretKeyHash, + encryptedKey: await encrypt(secretKeyRaw), + }, + ]); + + revalidatePath("/dashboard/developers"); + return { + publicKey: publicKeyRaw, + secretKey: secretKeyRaw, + }; +} diff --git a/apps/web/actions/developers/remove-domain.ts b/apps/web/actions/developers/remove-domain.ts new file mode 100644 index 00000000000..e82e98bc59f --- /dev/null +++ b/apps/web/actions/developers/remove-domain.ts @@ -0,0 +1,38 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { developerAppDomains, developerApps } from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export async function removeDeveloperDomain(appId: string, domainId: string) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const [app] = await db() + .select() + .from(developerApps) + .where( + and( + eq(developerApps.id, appId), + eq(developerApps.ownerId, user.id), + isNull(developerApps.deletedAt), + ), + ) + .limit(1); + + if (!app) throw new Error("App not found"); + + await db() + .delete(developerAppDomains) + .where( + and( + eq(developerAppDomains.id, domainId), + eq(developerAppDomains.appId, appId), + ), + ); + + revalidatePath("/dashboard/developers"); + return { success: true }; +} diff --git a/apps/web/actions/developers/update-app.ts b/apps/web/actions/developers/update-app.ts new file mode 100644 index 00000000000..82100b74e15 --- /dev/null +++ b/apps/web/actions/developers/update-app.ts @@ -0,0 +1,46 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { developerApps } from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export async function updateDeveloperApp(data: { + appId: string; + name?: string; + environment?: "development" | "production"; + logoUrl?: string | null; +}) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const [app] = await db() + .select() + .from(developerApps) + .where( + and( + eq(developerApps.id, data.appId), + eq(developerApps.ownerId, user.id), + isNull(developerApps.deletedAt), + ), + ) + .limit(1); + + if (!app) throw new Error("App not found"); + + const updates: Partial = {}; + if (data.name !== undefined) updates.name = data.name.trim(); + if (data.environment !== undefined) updates.environment = data.environment; + if (data.logoUrl !== undefined) updates.logoUrl = data.logoUrl; + + if (Object.keys(updates).length > 0) { + await db() + .update(developerApps) + .set(updates) + .where(eq(developerApps.id, data.appId)); + } + + revalidatePath("/dashboard/developers"); + return { success: true }; +} diff --git a/apps/web/actions/developers/update-auto-topup.ts b/apps/web/actions/developers/update-auto-topup.ts new file mode 100644 index 00000000000..68927e65c1c --- /dev/null +++ b/apps/web/actions/developers/update-auto-topup.ts @@ -0,0 +1,60 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { developerApps, developerCreditAccounts } from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export async function updateDeveloperAutoTopUp(data: { + appId: string; + enabled: boolean; + thresholdMicroCredits?: number; + amountCents?: number; +}) { + const user = await getCurrentUser(); + if (!user) throw new Error("Unauthorized"); + + const [app] = await db() + .select() + .from(developerApps) + .where( + and( + eq(developerApps.id, data.appId), + eq(developerApps.ownerId, user.id), + isNull(developerApps.deletedAt), + ), + ) + .limit(1); + + if (!app) throw new Error("App not found"); + + if ( + data.thresholdMicroCredits !== undefined && + data.thresholdMicroCredits < 0 + ) { + throw new Error("Threshold must be non-negative"); + } + if (data.amountCents !== undefined && data.amountCents <= 0) { + throw new Error("Top-up amount must be positive"); + } + + const updates: Partial = { + autoTopUpEnabled: data.enabled, + }; + + if (data.thresholdMicroCredits !== undefined) { + updates.autoTopUpThresholdMicroCredits = data.thresholdMicroCredits; + } + if (data.amountCents !== undefined) { + updates.autoTopUpAmountCents = data.amountCents; + } + + await db() + .update(developerCreditAccounts) + .set(updates) + .where(eq(developerCreditAccounts.appId, data.appId)); + + revalidatePath("/dashboard/developers"); + return { success: true }; +} From d459fc6d9ac48fe8c06d2fedadf64f7b5b8fe70e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:13:31 +0000 Subject: [PATCH 05/81] feat(web): add developer API auth middleware and CORS --- apps/web/app/api/utils.ts | 146 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 140 insertions(+), 6 deletions(-) diff --git a/apps/web/app/api/utils.ts b/apps/web/app/api/utils.ts index aeab0dd0a3d..cc71822b2ba 100644 --- a/apps/web/app/api/utils.ts +++ b/apps/web/app/api/utils.ts @@ -1,21 +1,26 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { authApiKeys, users } from "@cap/database/schema"; +import { + authApiKeys, + developerApiKeys, + developerAppDomains, + developerApps, + users, +} from "@cap/database/schema"; import { buildEnv } from "@cap/env"; -import { eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import type { Context } from "hono"; import { cors } from "hono/cors"; import { createMiddleware } from "hono/factory"; import { cookies } from "next/headers"; +import { hashKey } from "@/lib/developer-key-hash"; async function getAuth(c: Context) { - console.log("auth header: ", c.req.header("authorization")); const authHeader = c.req.header("authorization")?.split(" ")[1]; let user; if (authHeader?.length === 36) { - console.log("Using API key auth"); const res = await db() .select() .from(users) @@ -36,8 +41,6 @@ async function getAuth(c: Context) { user = await getCurrentUser(); } - console.log("User: ", user); - if (!user) return; return { user }; } @@ -82,3 +85,134 @@ export const corsMiddleware = cors({ allowMethods: ["POST", "OPTIONS"], allowHeaders: ["Content-Type", "Authorization", "sentry-trace", "baggage"], }); + +export const developerSdkCors = cors({ + origin: "*", + credentials: false, + allowMethods: ["GET", "POST", "OPTIONS"], + allowHeaders: ["Content-Type", "Authorization"], +}); + +export const withDeveloperPublicAuth = createMiddleware<{ + Variables: { + developerAppId: string; + developerKeyType: "public"; + }; +}>(async (c, next) => { + const authHeader = c.req.header("authorization")?.split(" ")[1]; + if (!authHeader?.startsWith("cpk_")) { + return c.json({ error: "Invalid public key" }, 401); + } + + const keyHash = await hashKey(authHeader); + const [keyRow] = await db() + .select({ appId: developerApiKeys.appId }) + .from(developerApiKeys) + .where( + and( + eq(developerApiKeys.keyHash, keyHash), + eq(developerApiKeys.keyType, "public"), + isNull(developerApiKeys.revokedAt), + ), + ) + .limit(1); + + if (!keyRow) { + return c.json({ error: "Invalid or revoked public key" }, 401); + } + + const [app] = await db() + .select() + .from(developerApps) + .where( + and(eq(developerApps.id, keyRow.appId), isNull(developerApps.deletedAt)), + ) + .limit(1); + + if (!app) { + return c.json({ error: "App not found" }, 401); + } + + const origin = c.req.header("origin"); + if (app.environment === "production") { + if (!origin) { + return c.json( + { error: "Origin header required for production apps" }, + 403, + ); + } + const [allowedDomain] = await db() + .select() + .from(developerAppDomains) + .where( + and( + eq(developerAppDomains.appId, app.id), + eq(developerAppDomains.domain, origin), + ), + ) + .limit(1); + + if (!allowedDomain) { + return c.json({ error: "Origin not allowed" }, 403); + } + } + + await db() + .update(developerApiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(developerApiKeys.keyHash, keyHash)); + + c.set("developerAppId", app.id); + c.set("developerKeyType", "public" as const); + await next(); +}); + +export const withDeveloperSecretAuth = createMiddleware<{ + Variables: { + developerAppId: string; + developerKeyType: "secret"; + }; +}>(async (c, next) => { + const authHeader = c.req.header("authorization")?.split(" ")[1]; + if (!authHeader?.startsWith("csk_")) { + return c.json({ error: "Invalid secret key" }, 401); + } + + const keyHash = await hashKey(authHeader); + const [keyRow] = await db() + .select({ appId: developerApiKeys.appId }) + .from(developerApiKeys) + .where( + and( + eq(developerApiKeys.keyHash, keyHash), + eq(developerApiKeys.keyType, "secret"), + isNull(developerApiKeys.revokedAt), + ), + ) + .limit(1); + + if (!keyRow) { + return c.json({ error: "Invalid or revoked secret key" }, 401); + } + + const [app] = await db() + .select() + .from(developerApps) + .where( + and(eq(developerApps.id, keyRow.appId), isNull(developerApps.deletedAt)), + ) + .limit(1); + + if (!app) { + return c.json({ error: "App not found" }, 401); + } + + await db() + .update(developerApiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(developerApiKeys.keyHash, keyHash)); + + c.set("developerAppId", app.id); + c.set("developerKeyType", "secret" as const); + await next(); +}); From ae7de3950ca370207b94e25401c59810a2104c99 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:13:40 +0000 Subject: [PATCH 06/81] feat(web): add developer SDK API routes --- .../api/developer/sdk/v1/[...route]/route.ts | 15 + .../api/developer/sdk/v1/[...route]/upload.ts | 294 ++++++++++++++++++ .../sdk/v1/[...route]/video-create.ts | 67 ++++ 3 files changed, 376 insertions(+) create mode 100644 apps/web/app/api/developer/sdk/v1/[...route]/route.ts create mode 100644 apps/web/app/api/developer/sdk/v1/[...route]/upload.ts create mode 100644 apps/web/app/api/developer/sdk/v1/[...route]/video-create.ts diff --git a/apps/web/app/api/developer/sdk/v1/[...route]/route.ts b/apps/web/app/api/developer/sdk/v1/[...route]/route.ts new file mode 100644 index 00000000000..3d90bd4d67e --- /dev/null +++ b/apps/web/app/api/developer/sdk/v1/[...route]/route.ts @@ -0,0 +1,15 @@ +import { Hono } from "hono"; +import { handle } from "hono/vercel"; +import { developerSdkCors } from "../../../../utils"; +import * as upload from "./upload"; +import * as videoCreate from "./video-create"; + +const app = new Hono() + .basePath("/api/developer/sdk/v1") + .use(developerSdkCors) + .route("/videos", videoCreate.app) + .route("/upload/multipart", upload.app); + +export const GET = handle(app); +export const POST = handle(app); +export const OPTIONS = handle(app); diff --git a/apps/web/app/api/developer/sdk/v1/[...route]/upload.ts b/apps/web/app/api/developer/sdk/v1/[...route]/upload.ts new file mode 100644 index 00000000000..d5039077bfb --- /dev/null +++ b/apps/web/app/api/developer/sdk/v1/[...route]/upload.ts @@ -0,0 +1,294 @@ +import { db } from "@cap/database"; +import { nanoId } from "@cap/database/helpers"; +import { + developerCreditAccounts, + developerCreditTransactions, + developerVideos, +} from "@cap/database/schema"; +import { provideOptionalAuth, S3Buckets } from "@cap/web-backend"; +import { zValidator } from "@hono/zod-validator"; +import { and, eq, sql } from "drizzle-orm"; +import { Effect } from "effect"; +import { Hono } from "hono"; +import { z } from "zod"; +import { runPromise } from "@/lib/server"; +import { withDeveloperPublicAuth } from "../../../../utils"; + +const MICRO_CREDITS_PER_MINUTE = 5_000; + +export const app = new Hono<{ + Variables: { + developerAppId: string; + developerKeyType: "public"; + }; +}>().use(withDeveloperPublicAuth); + +app.post( + "/initiate", + zValidator( + "json", + z.object({ + videoId: z.string(), + contentType: z.string().optional(), + }), + ), + async (c) => { + const appId = c.get("developerAppId"); + const { videoId, contentType } = c.req.valid("json"); + + const [video] = await db() + .select() + .from(developerVideos) + .where(eq(developerVideos.id, videoId)) + .limit(1); + + if (!video || video.appId !== appId) { + return c.json({ error: "Video not found" }, 404); + } + + if (!video.s3Key) { + return c.json({ error: "Video has no S3 key" }, 400); + } + + const s3Key = video.s3Key; + + try { + const uploadId = await Effect.gen(function* () { + const [bucket] = yield* S3Buckets.getBucketAccess(); + const { UploadId } = yield* bucket.multipart.create(s3Key, { + ContentType: contentType ?? "video/mp4", + CacheControl: "max-age=31536000", + }); + if (!UploadId) throw new Error("No UploadId returned"); + return UploadId; + }).pipe(provideOptionalAuth, runPromise); + + return c.json({ uploadId }); + } catch (error) { + console.error("Error initiating multipart upload:", error); + return c.json({ error: "Failed to initiate upload" }, 500); + } + }, +); + +app.post( + "/presign-part", + zValidator( + "json", + z.object({ + videoId: z.string(), + uploadId: z.string(), + partNumber: z.number(), + }), + ), + async (c) => { + const appId = c.get("developerAppId"); + const { videoId, uploadId, partNumber } = c.req.valid("json"); + + const [video] = await db() + .select() + .from(developerVideos) + .where(eq(developerVideos.id, videoId)) + .limit(1); + + if (!video || video.appId !== appId) { + return c.json({ error: "Video not found" }, 404); + } + + if (!video.s3Key) { + return c.json({ error: "Video has no S3 key" }, 400); + } + + const s3Key = video.s3Key; + + try { + const presignedUrl = await Effect.gen(function* () { + const [bucket] = yield* S3Buckets.getBucketAccess(); + return yield* bucket.multipart.getPresignedUploadPartUrl( + s3Key, + uploadId, + partNumber, + ); + }).pipe(provideOptionalAuth, runPromise); + + return c.json({ presignedUrl }); + } catch (error) { + console.error("Error creating presigned URL:", error); + return c.json({ error: "Failed to create presigned URL" }, 500); + } + }, +); + +app.post( + "/complete", + zValidator( + "json", + z.object({ + videoId: z.string(), + uploadId: z.string(), + parts: z.array( + z.object({ + partNumber: z.number(), + etag: z.string(), + size: z.number(), + }), + ), + durationInSecs: z.number().optional(), + width: z.number().optional(), + height: z.number().optional(), + fps: z.number().optional(), + }), + ), + async (c) => { + const appId = c.get("developerAppId"); + const { videoId, uploadId, parts, durationInSecs, width, height, fps } = + c.req.valid("json"); + + const [video] = await db() + .select() + .from(developerVideos) + .where(eq(developerVideos.id, videoId)) + .limit(1); + + if (!video || video.appId !== appId) { + return c.json({ error: "Video not found" }, 404); + } + + if (!video.s3Key) { + return c.json({ error: "Video has no S3 key" }, 400); + } + + const s3Key = video.s3Key; + + try { + await Effect.gen(function* () { + const [bucket] = yield* S3Buckets.getBucketAccess(); + + const sortedParts = [...parts].sort( + (a, b) => a.partNumber - b.partNumber, + ); + const formattedParts = sortedParts.map((part) => ({ + PartNumber: part.partNumber, + ETag: part.etag, + })); + + yield* bucket.multipart.complete(s3Key, uploadId, { + MultipartUpload: { Parts: formattedParts }, + }); + }).pipe(provideOptionalAuth, runPromise); + + const updates: Record = {}; + if (durationInSecs !== undefined) updates.duration = durationInSecs; + if (width !== undefined) updates.width = width; + if (height !== undefined) updates.height = height; + if (fps !== undefined) updates.fps = fps; + + if (Object.keys(updates).length > 0) { + await db() + .update(developerVideos) + .set(updates) + .where(eq(developerVideos.id, videoId)); + } + + if (durationInSecs && durationInSecs > 0) { + const durationMinutes = durationInSecs / 60; + const microCreditsToDebit = Math.floor( + durationMinutes * MICRO_CREDITS_PER_MINUTE, + ); + + const [account] = await db() + .select() + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.appId, appId)) + .limit(1); + + if (account && microCreditsToDebit > 0) { + await db().transaction(async (tx) => { + await tx + .update(developerCreditAccounts) + .set({ + balanceMicroCredits: sql`${developerCreditAccounts.balanceMicroCredits} - ${microCreditsToDebit}`, + }) + .where( + and( + eq(developerCreditAccounts.id, account.id), + sql`${developerCreditAccounts.balanceMicroCredits} >= ${microCreditsToDebit}`, + ), + ); + + const [updated] = await tx + .select({ + balanceMicroCredits: + developerCreditAccounts.balanceMicroCredits, + }) + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.id, account.id)) + .limit(1); + + await tx.insert(developerCreditTransactions).values({ + id: nanoId(), + accountId: account.id, + type: "video_create", + amountMicroCredits: -microCreditsToDebit, + balanceAfterMicroCredits: updated.balanceMicroCredits, + referenceId: videoId, + referenceType: "developer_video", + metadata: { durationSeconds: durationInSecs }, + }); + }); + } + } + + return c.json({ success: true }); + } catch (error) { + console.error("Error completing multipart upload:", error); + return c.json({ error: "Failed to complete upload" }, 500); + } + }, +); + +app.post( + "/abort", + zValidator( + "json", + z.object({ + videoId: z.string(), + uploadId: z.string(), + }), + ), + async (c) => { + const appId = c.get("developerAppId"); + const { videoId, uploadId } = c.req.valid("json"); + + const [video] = await db() + .select() + .from(developerVideos) + .where(eq(developerVideos.id, videoId)) + .limit(1); + + if (!video || video.appId !== appId) { + return c.json({ error: "Video not found" }, 404); + } + + if (!video.s3Key) { + return c.json({ error: "Video has no S3 key" }, 400); + } + + const s3Key = video.s3Key; + + try { + await Effect.gen(function* () { + const [bucket] = yield* S3Buckets.getBucketAccess(); + const multipart = bucket.multipart as typeof bucket.multipart & { + abort: (key: string, uploadId: string) => Effect.Effect; + }; + yield* multipart.abort(s3Key, uploadId); + }).pipe(provideOptionalAuth, runPromise); + + return c.json({ success: true }); + } catch (error) { + console.error("Error aborting multipart upload:", error); + return c.json({ error: "Failed to abort upload" }, 500); + } + }, +); diff --git a/apps/web/app/api/developer/sdk/v1/[...route]/video-create.ts b/apps/web/app/api/developer/sdk/v1/[...route]/video-create.ts new file mode 100644 index 00000000000..f155928f963 --- /dev/null +++ b/apps/web/app/api/developer/sdk/v1/[...route]/video-create.ts @@ -0,0 +1,67 @@ +import { db } from "@cap/database"; +import { nanoId } from "@cap/database/helpers"; +import { developerCreditAccounts, developerVideos } from "@cap/database/schema"; +import { buildEnv } from "@cap/env"; +import { zValidator } from "@hono/zod-validator"; +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { z } from "zod"; +import { withDeveloperPublicAuth } from "../../../../utils"; + +const MIN_BALANCE_MICRO_CREDITS = 5_000; + +export const app = new Hono<{ + Variables: { + developerAppId: string; + developerKeyType: "public"; + }; +}>().use(withDeveloperPublicAuth); + +app.post( + "/create", + zValidator( + "json", + z.object({ + name: z.string().optional(), + userId: z.string().optional(), + metadata: z.record(z.unknown()).optional(), + }), + ), + async (c) => { + const appId = c.get("developerAppId"); + const body = c.req.valid("json"); + + const [account] = await db() + .select() + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.appId, appId)) + .limit(1); + + if (!account || account.balanceMicroCredits < MIN_BALANCE_MICRO_CREDITS) { + return c.json({ error: "Insufficient credits" }, 402); + } + + const videoId = nanoId(); + const s3Key = `developer/${appId}/${videoId}/result.mp4`; + + await db() + .insert(developerVideos) + .values({ + id: videoId, + appId, + externalUserId: body.userId, + name: body.name ?? "Untitled", + s3Key, + metadata: body.metadata, + }); + + const webUrl = buildEnv.NEXT_PUBLIC_WEB_URL; + + return c.json({ + videoId, + s3Key, + shareUrl: `${webUrl}/dev/${videoId}`, + embedUrl: `${webUrl}/embed/${videoId}?sdk=1`, + }); + }, +); From e24876d4bf5a63ec71914d14b40f5ea6e2410e35 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:13:41 +0000 Subject: [PATCH 07/81] feat(web): add developer management API routes --- .../app/api/developer/v1/[...route]/route.ts | 14 ++ .../app/api/developer/v1/[...route]/usage.ts | 47 +++++++ .../app/api/developer/v1/[...route]/videos.ts | 125 ++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 apps/web/app/api/developer/v1/[...route]/route.ts create mode 100644 apps/web/app/api/developer/v1/[...route]/usage.ts create mode 100644 apps/web/app/api/developer/v1/[...route]/videos.ts diff --git a/apps/web/app/api/developer/v1/[...route]/route.ts b/apps/web/app/api/developer/v1/[...route]/route.ts new file mode 100644 index 00000000000..590c2060903 --- /dev/null +++ b/apps/web/app/api/developer/v1/[...route]/route.ts @@ -0,0 +1,14 @@ +import { Hono } from "hono"; +import { handle } from "hono/vercel"; + +import * as usage from "./usage"; +import * as videos from "./videos"; + +const app = new Hono() + .basePath("/api/developer/v1") + .route("/videos", videos.app) + .route("/usage", usage.app); + +export const GET = handle(app); +export const POST = handle(app); +export const DELETE = handle(app); diff --git a/apps/web/app/api/developer/v1/[...route]/usage.ts b/apps/web/app/api/developer/v1/[...route]/usage.ts new file mode 100644 index 00000000000..d478586986c --- /dev/null +++ b/apps/web/app/api/developer/v1/[...route]/usage.ts @@ -0,0 +1,47 @@ +import { db } from "@cap/database"; +import { developerCreditAccounts, developerVideos } from "@cap/database/schema"; +import { and, count, eq, isNull, sql } from "drizzle-orm"; +import { Hono } from "hono"; +import { withDeveloperSecretAuth } from "../../../utils"; + +export const app = new Hono<{ + Variables: { + developerAppId: string; + developerKeyType: "secret"; + }; +}>().use(withDeveloperSecretAuth); + +app.get("/", async (c) => { + const appId = c.get("developerAppId"); + + const [[account], [videoStats]] = await Promise.all([ + db() + .select() + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.appId, appId)) + .limit(1), + db() + .select({ + totalVideos: count(), + totalDurationMinutes: sql`COALESCE(SUM(${developerVideos.duration}) / 60, 0)`, + }) + .from(developerVideos) + .where( + and( + eq(developerVideos.appId, appId), + isNull(developerVideos.deletedAt), + ), + ), + ]); + + return c.json({ + data: { + balanceMicroCredits: account?.balanceMicroCredits ?? 0, + balanceDollars: ((account?.balanceMicroCredits ?? 0) / 100_000).toFixed( + 2, + ), + totalVideos: videoStats?.totalVideos ?? 0, + totalDurationMinutes: videoStats?.totalDurationMinutes ?? 0, + }, + }); +}); diff --git a/apps/web/app/api/developer/v1/[...route]/videos.ts b/apps/web/app/api/developer/v1/[...route]/videos.ts new file mode 100644 index 00000000000..bf0a1f08a14 --- /dev/null +++ b/apps/web/app/api/developer/v1/[...route]/videos.ts @@ -0,0 +1,125 @@ +import { db } from "@cap/database"; +import { developerVideos } from "@cap/database/schema"; +import { and, desc, eq, isNull } from "drizzle-orm"; +import { Hono } from "hono"; +import { withDeveloperSecretAuth } from "../../../utils"; + +export const app = new Hono<{ + Variables: { + developerAppId: string; + developerKeyType: "secret"; + }; +}>().use(withDeveloperSecretAuth); + +app.get("/", async (c) => { + const appId = c.get("developerAppId"); + const userId = c.req.query("userId"); + const limit = Math.min(Number(c.req.query("limit") ?? 50), 100); + const offset = Number(c.req.query("offset") ?? 0); + + const conditions = [ + eq(developerVideos.appId, appId), + isNull(developerVideos.deletedAt), + ]; + + if (userId) { + conditions.push(eq(developerVideos.externalUserId, userId)); + } + + const videos = await db() + .select() + .from(developerVideos) + .where(and(...conditions)) + .orderBy(desc(developerVideos.createdAt)) + .limit(limit) + .offset(offset); + + return c.json({ data: videos }); +}); + +app.get("/:id", async (c) => { + const appId = c.get("developerAppId"); + const videoId = c.req.param("id"); + + const [video] = await db() + .select() + .from(developerVideos) + .where( + and( + eq(developerVideos.id, videoId), + eq(developerVideos.appId, appId), + isNull(developerVideos.deletedAt), + ), + ) + .limit(1); + + if (!video) { + return c.json({ error: "Video not found" }, 404); + } + + return c.json({ data: video }); +}); + +app.delete("/:id", async (c) => { + const appId = c.get("developerAppId"); + const videoId = c.req.param("id"); + + const [video] = await db() + .select() + .from(developerVideos) + .where( + and( + eq(developerVideos.id, videoId), + eq(developerVideos.appId, appId), + isNull(developerVideos.deletedAt), + ), + ) + .limit(1); + + if (!video) { + return c.json({ error: "Video not found" }, 404); + } + + await db() + .update(developerVideos) + .set({ deletedAt: new Date() }) + .where(eq(developerVideos.id, videoId)); + + return c.json({ success: true }); +}); + +app.get("/:id/status", async (c) => { + const appId = c.get("developerAppId"); + const videoId = c.req.param("id"); + + const [video] = await db() + .select({ + id: developerVideos.id, + duration: developerVideos.duration, + width: developerVideos.width, + height: developerVideos.height, + transcriptionStatus: developerVideos.transcriptionStatus, + }) + .from(developerVideos) + .where( + and( + eq(developerVideos.id, videoId), + eq(developerVideos.appId, appId), + isNull(developerVideos.deletedAt), + ), + ) + .limit(1); + + if (!video) { + return c.json({ error: "Video not found" }, 404); + } + + const ready = video.duration !== null && video.width !== null; + + return c.json({ + data: { + ...video, + ready, + }, + }); +}); From fa56fafa8b0faa4cd8a749904e2de7d12e3d5832 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:13:41 +0000 Subject: [PATCH 08/81] feat(web): add developer credits checkout API --- .../api/developer/credits/checkout/route.ts | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 apps/web/app/api/developer/credits/checkout/route.ts diff --git a/apps/web/app/api/developer/credits/checkout/route.ts b/apps/web/app/api/developer/credits/checkout/route.ts new file mode 100644 index 00000000000..5e007b7562b --- /dev/null +++ b/apps/web/app/api/developer/credits/checkout/route.ts @@ -0,0 +1,145 @@ +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { + developerApps, + developerCreditAccounts, + users, +} from "@cap/database/schema"; +import { serverEnv } from "@cap/env"; +import { STRIPE_DEVELOPER_CREDITS_PRODUCT_ID, stripe } from "@cap/utils"; +import { and, eq, isNull } from "drizzle-orm"; +import type { NextRequest } from "next/server"; +import type Stripe from "stripe"; + +export async function POST(request: NextRequest) { + const user = await getCurrentUser(); + if (!user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { appId, amountCents } = await request.json(); + + if ( + !appId || + typeof amountCents !== "number" || + !Number.isInteger(amountCents) || + amountCents < 500 || + amountCents > 100_000 + ) { + return Response.json( + { + error: "Invalid request. Purchase must be between $5.00 and $1,000.00", + }, + { status: 400 }, + ); + } + + const [app] = await db() + .select() + .from(developerApps) + .where( + and( + eq(developerApps.id, appId), + eq(developerApps.ownerId, user.id), + isNull(developerApps.deletedAt), + ), + ) + .limit(1); + + if (!app) { + return Response.json({ error: "App not found" }, { status: 404 }); + } + + const [account] = await db() + .select() + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.appId, appId)) + .limit(1); + + if (!account) { + return Response.json( + { error: "Credit account not found" }, + { status: 404 }, + ); + } + + try { + let customerId = account.stripeCustomerId ?? user.stripeCustomerId; + + if (!customerId) { + const existingCustomers = await stripe().customers.list({ + email: user.email, + limit: 1, + }); + + let customer: Stripe.Customer; + if (existingCustomers.data.length > 0 && existingCustomers.data[0]) { + customer = existingCustomers.data[0]; + customer = await stripe().customers.update(customer.id, { + metadata: { + ...customer.metadata, + userId: user.id, + }, + }); + } else { + customer = await stripe().customers.create({ + email: user.email, + metadata: { + userId: user.id, + }, + }); + } + + await db() + .update(users) + .set({ stripeCustomerId: customer.id }) + .where(eq(users.id, user.id)); + + await db() + .update(developerCreditAccounts) + .set({ stripeCustomerId: customer.id }) + .where(eq(developerCreditAccounts.id, account.id)); + + customerId = customer.id; + } + + const checkoutSession = await stripe().checkout.sessions.create({ + customer: customerId, + line_items: [ + { + price_data: { + currency: "usd", + product: STRIPE_DEVELOPER_CREDITS_PRODUCT_ID, + unit_amount: amountCents, + }, + quantity: 1, + }, + ], + mode: "payment", + success_url: `${serverEnv().WEB_URL}/dashboard/developers/credits?purchase=success`, + cancel_url: `${serverEnv().WEB_URL}/dashboard/developers/credits`, + metadata: { + type: "developer_credits", + appId, + accountId: account.id, + amountCents: String(amountCents), + userId: user.id, + }, + }); + + if (checkoutSession.url) { + return Response.json({ url: checkoutSession.url }, { status: 200 }); + } + + return Response.json( + { error: "Failed to create checkout session" }, + { status: 500 }, + ); + } catch (error) { + console.error("Error creating developer credits checkout:", error); + return Response.json( + { error: "Failed to create checkout session" }, + { status: 500 }, + ); + } +} From 599e2dc514eef9e32a09c73612dd509a9870b216 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:13:42 +0000 Subject: [PATCH 09/81] feat(web): add developer storage billing cron job --- .../app/api/cron/developer-storage/route.ts | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 apps/web/app/api/cron/developer-storage/route.ts diff --git a/apps/web/app/api/cron/developer-storage/route.ts b/apps/web/app/api/cron/developer-storage/route.ts new file mode 100644 index 00000000000..848bd7e4b41 --- /dev/null +++ b/apps/web/app/api/cron/developer-storage/route.ts @@ -0,0 +1,168 @@ +import { timingSafeEqual } from "node:crypto"; +import { db } from "@cap/database"; +import { nanoId } from "@cap/database/helpers"; +import { + developerApps, + developerCreditAccounts, + developerCreditTransactions, + developerDailyStorageSnapshots, + developerVideos, +} from "@cap/database/schema"; +import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { NextResponse } from "next/server"; + +const MICRO_CREDITS_PER_MINUTE_PER_DAY = 3.33; + +export async function GET(request: Request) { + const authHeader = request.headers.get("authorization"); + const expected = `Bearer ${process.env.CRON_SECRET}`; + if ( + !authHeader || + authHeader.length !== expected.length || + !timingSafeEqual(Buffer.from(authHeader), Buffer.from(expected)) + ) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const today = new Date().toISOString().slice(0, 10); + + const apps = await db() + .select({ id: developerApps.id }) + .from(developerApps) + .where(isNull(developerApps.deletedAt)); + + if (apps.length === 0) { + return NextResponse.json({ + success: true, + date: today, + appsProcessed: 0, + }); + } + + const appIds = apps.map((a) => a.id); + + const existingSnapshots = await db() + .select() + .from(developerDailyStorageSnapshots) + .where( + and( + inArray(developerDailyStorageSnapshots.appId, appIds), + eq(developerDailyStorageSnapshots.snapshotDate, today), + ), + ); + + const snapshotsByApp = new Map(existingSnapshots.map((s) => [s.appId, s])); + + const videoStats = await db() + .select({ + appId: developerVideos.appId, + totalDurationMinutes: sql`COALESCE(SUM(${developerVideos.duration}) / 60, 0)`, + videoCount: sql`COUNT(*)`, + }) + .from(developerVideos) + .where( + and( + inArray(developerVideos.appId, appIds), + isNull(developerVideos.deletedAt), + ), + ) + .groupBy(developerVideos.appId); + + const statsByApp = new Map(videoStats.map((s) => [s.appId, s])); + + const accounts = await db() + .select() + .from(developerCreditAccounts) + .where(inArray(developerCreditAccounts.appId, appIds)); + + const accountsByApp = new Map(accounts.map((a) => [a.appId, a])); + + let processed = 0; + + for (const app of apps) { + const existing = snapshotsByApp.get(app.id); + if (existing?.processedAt) continue; + + const stats = statsByApp.get(app.id); + const totalMinutes = stats?.totalDurationMinutes ?? 0; + const videoCount = Number(stats?.videoCount ?? 0); + + if (totalMinutes <= 0) continue; + + const microCreditsToCharge = Math.floor( + totalMinutes * MICRO_CREDITS_PER_MINUTE_PER_DAY, + ); + + if (microCreditsToCharge <= 0) continue; + + const account = accountsByApp.get(app.id); + if (!account) continue; + + await db().transaction(async (tx) => { + await tx + .update(developerCreditAccounts) + .set({ + balanceMicroCredits: sql`${developerCreditAccounts.balanceMicroCredits} - ${microCreditsToCharge}`, + }) + .where( + and( + eq(developerCreditAccounts.id, account.id), + sql`${developerCreditAccounts.balanceMicroCredits} >= ${microCreditsToCharge}`, + ), + ); + + const [updated] = await tx + .select({ + balanceMicroCredits: developerCreditAccounts.balanceMicroCredits, + }) + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.id, account.id)) + .limit(1); + + await tx.insert(developerCreditTransactions).values({ + id: nanoId(), + accountId: account.id, + type: "storage_daily", + amountMicroCredits: -microCreditsToCharge, + balanceAfterMicroCredits: updated.balanceMicroCredits, + referenceType: "manual", + metadata: { + snapshotDate: today, + totalDurationMinutes: totalMinutes, + videoCount, + }, + }); + + const snapshotId = existing?.id ?? nanoId(); + if (existing) { + await tx + .update(developerDailyStorageSnapshots) + .set({ + totalDurationMinutes: totalMinutes, + videoCount, + microCreditsCharged: microCreditsToCharge, + processedAt: new Date(), + }) + .where(eq(developerDailyStorageSnapshots.id, snapshotId)); + } else { + await tx.insert(developerDailyStorageSnapshots).values({ + id: snapshotId, + appId: app.id, + snapshotDate: today, + totalDurationMinutes: totalMinutes, + videoCount, + microCreditsCharged: microCreditsToCharge, + processedAt: new Date(), + }); + } + }); + + processed++; + } + + return NextResponse.json({ + success: true, + date: today, + appsProcessed: processed, + }); +} From 539ed94b1bc541c35e46c82bb347de615cc13ceb Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:13:45 +0000 Subject: [PATCH 10/81] feat(web): handle developer credits in Stripe webhook --- apps/web/app/api/webhooks/stripe/route.ts | 64 ++++++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts index 8624da99b10..d41fda3e4c4 100644 --- a/apps/web/app/api/webhooks/stripe/route.ts +++ b/apps/web/app/api/webhooks/stripe/route.ts @@ -1,13 +1,14 @@ import { db } from "@cap/database"; import { nanoId } from "@cap/database/helpers"; -import { users } from "@cap/database/schema"; +import { developerCreditTransactions, users } from "@cap/database/schema"; import { buildEnv, serverEnv } from "@cap/env"; import { stripe } from "@cap/utils"; import { Organisation, User } from "@cap/web-domain"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { NextResponse } from "next/server"; import { PostHog } from "posthog-node"; import type Stripe from "stripe"; +import { addCreditsToAccount } from "@/actions/developers/purchase-credits"; const relevantEvents = new Set([ "checkout.session.completed", @@ -144,6 +145,65 @@ export const POST = async (req: Request) => { subscriptionId: session.subscription, }); + if (session.metadata?.type === "developer_credits") { + const { accountId, amountCents } = session.metadata; + const paymentIntentId = + typeof session.payment_intent === "string" + ? session.payment_intent + : null; + + if (!accountId || !amountCents || !paymentIntentId) { + console.error( + "Missing required metadata for developer credits:", + { accountId, amountCents, paymentIntentId }, + ); + return new Response("Missing metadata", { status: 400 }); + } + + console.log("Processing developer credits purchase:", { + accountId, + amountCents, + paymentIntentId, + }); + + const [existingTxn] = await db() + .select({ id: developerCreditTransactions.id }) + .from(developerCreditTransactions) + .where( + and( + eq(developerCreditTransactions.accountId, accountId), + eq(developerCreditTransactions.referenceId, paymentIntentId), + eq( + developerCreditTransactions.referenceType, + "stripe_payment_intent", + ), + ), + ) + .limit(1); + + if (existingTxn) { + console.log( + "Duplicate webhook delivery — transaction already exists:", + existingTxn.id, + ); + return NextResponse.json({ received: true }); + } + + await addCreditsToAccount({ + accountId, + amountCents: Number(amountCents), + referenceId: paymentIntentId, + referenceType: "stripe_payment_intent", + metadata: { + amountCents: Number(amountCents), + stripeSessionId: session.id, + }, + }); + + console.log("Developer credits added successfully"); + return NextResponse.json({ received: true }); + } + const customer = await stripe().customers.retrieve( session.customer as string, ); From 5cfd616e094a5218ca2dcf29934085598bc14e97 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:13:48 +0000 Subject: [PATCH 11/81] feat(web): add developer dashboard components and pages --- .../developers/DevelopersContext.tsx | 28 +++ .../developers/_components/ApiKeyDisplay.tsx | 61 +++++ .../developers/_components/AppCard.tsx | 34 +++ .../_components/CreateAppDialog.tsx | 144 ++++++++++++ .../_components/CreditTransactionTable.tsx | 71 ++++++ .../_components/DeveloperSidebarContent.tsx | 214 ++++++++++++++++++ .../_components/DeveloperSidebarRegistrar.tsx | 18 ++ .../_components/DeveloperThemeForcer.tsx | 34 +++ .../developers/_components/DomainRow.tsx | 43 ++++ .../_components/EnvironmentBadge.tsx | 24 ++ .../developers/_components/StatBox.tsx | 21 ++ .../developers/apps/AppsListClient.tsx | 45 ++++ .../apps/[appId]/api-keys/ApiKeysClient.tsx | 127 +++++++++++ .../developers/apps/[appId]/api-keys/page.tsx | 10 + .../apps/[appId]/domains/DomainsClient.tsx | 117 ++++++++++ .../developers/apps/[appId]/domains/page.tsx | 10 + .../developers/apps/[appId]/layout.tsx | 27 +++ .../[appId]/settings/AppSettingsClient.tsx | 163 +++++++++++++ .../developers/apps/[appId]/settings/page.tsx | 10 + .../apps/[appId]/videos/VideosClient.tsx | 138 +++++++++++ .../developers/apps/[appId]/videos/page.tsx | 44 ++++ .../(org)/dashboard/developers/apps/page.tsx | 10 + .../developers/credits/CreditsClient.tsx | 193 ++++++++++++++++ .../dashboard/developers/credits/page.tsx | 35 +++ .../dashboard/developers/developer-data.ts | 151 ++++++++++++ .../app/(org)/dashboard/developers/layout.tsx | 31 +++ .../app/(org)/dashboard/developers/page.tsx | 5 + .../developers/usage/UsageClient.tsx | 81 +++++++ .../(org)/dashboard/developers/usage/page.tsx | 10 + 29 files changed, 1899 insertions(+) create mode 100644 apps/web/app/(org)/dashboard/developers/DevelopersContext.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/_components/ApiKeyDisplay.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/_components/AppCard.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/_components/CreateAppDialog.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/_components/CreditTransactionTable.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/_components/DeveloperSidebarContent.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/_components/DeveloperSidebarRegistrar.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/_components/DeveloperThemeForcer.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/_components/DomainRow.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/_components/EnvironmentBadge.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/_components/StatBox.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/apps/AppsListClient.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/apps/[appId]/api-keys/ApiKeysClient.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/apps/[appId]/api-keys/page.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/apps/[appId]/domains/DomainsClient.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/apps/[appId]/domains/page.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/apps/[appId]/layout.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/apps/[appId]/settings/AppSettingsClient.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/apps/[appId]/settings/page.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/apps/[appId]/videos/VideosClient.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/apps/[appId]/videos/page.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/apps/page.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/credits/CreditsClient.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/credits/page.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/developer-data.ts create mode 100644 apps/web/app/(org)/dashboard/developers/layout.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/page.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/usage/UsageClient.tsx create mode 100644 apps/web/app/(org)/dashboard/developers/usage/page.tsx diff --git a/apps/web/app/(org)/dashboard/developers/DevelopersContext.tsx b/apps/web/app/(org)/dashboard/developers/DevelopersContext.tsx new file mode 100644 index 00000000000..fb602b8405e --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/DevelopersContext.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { createContext, type ReactNode, useContext } from "react"; +import type { DeveloperApp } from "./developer-data"; + +type DevelopersContextType = { + apps: DeveloperApp[]; +}; + +const DevelopersContext = createContext({ + apps: [], +}); + +export const useDevelopersContext = () => useContext(DevelopersContext); + +export function DevelopersProvider({ + children, + apps, +}: { + children: ReactNode; + apps: DeveloperApp[]; +}) { + return ( + + {children} + + ); +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/ApiKeyDisplay.tsx b/apps/web/app/(org)/dashboard/developers/_components/ApiKeyDisplay.tsx new file mode 100644 index 00000000000..4a8b8922b68 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/ApiKeyDisplay.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Label } from "@cap/ui"; +import { Check, Copy, Eye, EyeOff } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; + +export function ApiKeyDisplay({ + label, + value, + sensitive = false, +}: { + label: string; + value: string; + sensitive?: boolean; +}) { + const [visible, setVisible] = useState(!sensitive); + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(value); + setCopied(true); + toast.success("Copied to clipboard"); + setTimeout(() => setCopied(false), 2000); + }; + + const displayValue = visible + ? value + : `${value.slice(0, 8)}${"•".repeat(20)}`; + + return ( +
+ +
+ + {displayValue} + + {sensitive && ( + + )} + +
+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/AppCard.tsx b/apps/web/app/(org)/dashboard/developers/_components/AppCard.tsx new file mode 100644 index 00000000000..448bca6378f --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/AppCard.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { ArrowRight } from "lucide-react"; +import Link from "next/link"; +import type { DeveloperApp } from "../developer-data"; +import { EnvironmentBadge } from "./EnvironmentBadge"; + +export function AppCard({ app }: { app: DeveloperApp }) { + return ( + +
+
+

{app.name}

+ +
+ +
+
+ {app.videoCount} videos + + {app.creditAccount + ? `$${((app.creditAccount.balanceMicroCredits ?? 0) / 100_000).toFixed(2)} credits` + : "$0.00 credits"} + +
+ + ); +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/CreateAppDialog.tsx b/apps/web/app/(org)/dashboard/developers/_components/CreateAppDialog.tsx new file mode 100644 index 00000000000..8c7e3f4aa8c --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/CreateAppDialog.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, +} from "@cap/ui"; +import { useMutation } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +import { createDeveloperApp } from "@/actions/developers/create-app"; +import { ApiKeyDisplay } from "./ApiKeyDisplay"; + +export function CreateAppDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const router = useRouter(); + const appNameId = useId(); + const [step, setStep] = useState<"create" | "keys">("create"); + const [name, setName] = useState(""); + const [environment, setEnvironment] = useState<"development" | "production">( + "development", + ); + const [keys, setKeys] = useState<{ + publicKey: string; + secretKey: string; + } | null>(null); + + const createMutation = useMutation({ + mutationFn: () => createDeveloperApp({ name, environment }), + onSuccess: (result) => { + setKeys({ + publicKey: result.publicKey, + secretKey: result.secretKey, + }); + setStep("keys"); + router.refresh(); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to create app", + ); + }, + }); + + const handleClose = () => { + setStep("create"); + setName(""); + setEnvironment("development"); + setKeys(null); + onOpenChange(false); + }; + + return ( + + + {step === "create" && ( + <> + + Create Developer App + +
+
+ + setName(e.target.value)} + placeholder="My App" + /> +
+
+ +
+ + +
+
+
+ + + + + + )} + {step === "keys" && keys && ( + <> + + API Keys Created + +
+

+ Save your secret key now. You won't be able to see it again. +

+ + +
+ + + + + )} +
+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/CreditTransactionTable.tsx b/apps/web/app/(org)/dashboard/developers/_components/CreditTransactionTable.tsx new file mode 100644 index 00000000000..cc7bdbf8006 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/CreditTransactionTable.tsx @@ -0,0 +1,71 @@ +"use client"; + +import type { DeveloperTransaction } from "../developer-data"; + +const typeLabels: Record = { + topup: "Top Up", + video_create: "Recording", + storage_daily: "Storage", + refund: "Refund", + adjustment: "Adjustment", +}; + +export function CreditTransactionTable({ + transactions, +}: { + transactions: DeveloperTransaction[]; +}) { + if (transactions.length === 0) { + return ( +

+ No transactions yet +

+ ); + } + + return ( +
+ + + + + + + + + + + {transactions.map((tx) => ( + + + + + + + ))} + +
+ Type + + Amount + + Balance + + Date +
+ {typeLabels[tx.type] ?? tx.type} + = 0 ? "text-green-400" : "text-red-400" + }`} + > + {tx.amountMicroCredits >= 0 ? "+" : ""}$ + {(Math.abs(tx.amountMicroCredits) / 100_000).toFixed(4)} + + ${(tx.balanceAfterMicroCredits / 100_000).toFixed(2)} + + {new Date(tx.createdAt).toLocaleDateString()} +
+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/DeveloperSidebarContent.tsx b/apps/web/app/(org)/dashboard/developers/_components/DeveloperSidebarContent.tsx new file mode 100644 index 00000000000..cc6c3b6332e --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/DeveloperSidebarContent.tsx @@ -0,0 +1,214 @@ +"use client"; + +import clsx from "clsx"; +import { motion } from "framer-motion"; +import { + BarChart3, + Box, + CreditCard, + Globe, + Key, + Settings, + Video, +} from "lucide-react"; +import Link from "next/link"; +import { useParams, usePathname } from "next/navigation"; +import { Tooltip } from "@/components/Tooltip"; +import { useDashboardContext } from "../../Contexts"; +import { EnvironmentBadge } from "./EnvironmentBadge"; + +const mainNav = [ + { name: "Apps", href: "/dashboard/developers/apps", icon: Box }, + { name: "Usage", href: "/dashboard/developers/usage", icon: BarChart3 }, + { + name: "Credits", + href: "/dashboard/developers/credits", + icon: CreditCard, + }, +]; + +const appNav = [ + { name: "Settings", href: "settings", icon: Settings }, + { name: "API Keys", href: "api-keys", icon: Key }, + { name: "Domains", href: "domains", icon: Globe }, + { name: "Videos", href: "videos", icon: Video }, +]; + +export function DeveloperSidebarContent() { + const pathname = usePathname(); + const params = useParams<{ appId?: string }>(); + const { sidebarCollapsed, developerApps } = useDashboardContext(); + + const currentApp = + params.appId && developerApps + ? developerApps.find((a) => a.id === params.appId) + : null; + + const basePath = params.appId + ? `/dashboard/developers/apps/${params.appId}` + : null; + + const isActive = (href: string) => + pathname === href || pathname.startsWith(`${href}/`); + + return ( + + ); +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/DeveloperSidebarRegistrar.tsx b/apps/web/app/(org)/dashboard/developers/_components/DeveloperSidebarRegistrar.tsx new file mode 100644 index 00000000000..a544d80928f --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/DeveloperSidebarRegistrar.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect } from "react"; +import { useDashboardContext } from "../../Contexts"; +import type { DeveloperApp } from "../developer-data"; + +export function DeveloperSidebarRegistrar({ apps }: { apps: DeveloperApp[] }) { + const { setDeveloperApps } = useDashboardContext(); + + useEffect(() => { + setDeveloperApps(apps); + return () => { + setDeveloperApps(null); + }; + }, [apps, setDeveloperApps]); + + return null; +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/DeveloperThemeForcer.tsx b/apps/web/app/(org)/dashboard/developers/_components/DeveloperThemeForcer.tsx new file mode 100644 index 00000000000..d57578714e0 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/DeveloperThemeForcer.tsx @@ -0,0 +1,34 @@ +"use client"; + +import Cookies from "js-cookie"; +import { useEffect, useRef } from "react"; +import { useTheme } from "../../Contexts"; + +export function DeveloperThemeForcer({ + children, +}: { + children: React.ReactNode; +}) { + const { theme, setThemeHandler } = useTheme(); + const previousTheme = useRef<"light" | "dark">( + (Cookies.get("theme") as "light" | "dark") ?? "light", + ); + + useEffect(() => { + if (theme !== "dark") { + setThemeHandler("dark"); + } + }, [theme, setThemeHandler]); + + useEffect(() => { + const saved = previousTheme.current; + return () => { + if (saved !== "dark") { + document.body.className = saved; + Cookies.set("theme", saved, { expires: 365 }); + } + }; + }, []); + + return <>{children}; +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/DomainRow.tsx b/apps/web/app/(org)/dashboard/developers/_components/DomainRow.tsx new file mode 100644 index 00000000000..91672dda3f0 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/DomainRow.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { X } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { removeDeveloperDomain } from "@/actions/developers/remove-domain"; + +export function DomainRow({ + appId, + domainId, + domain, +}: { + appId: string; + domainId: string; + domain: string; +}) { + const router = useRouter(); + const removeMutation = useMutation({ + mutationFn: () => removeDeveloperDomain(appId, domainId), + onSuccess: () => { + toast.success("Domain removed"); + router.refresh(); + }, + onError: () => { + toast.error("Failed to remove domain"); + }, + }); + + return ( +
+ {domain} + +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/EnvironmentBadge.tsx b/apps/web/app/(org)/dashboard/developers/_components/EnvironmentBadge.tsx new file mode 100644 index 00000000000..fe913b83769 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/EnvironmentBadge.tsx @@ -0,0 +1,24 @@ +import clsx from "clsx"; + +export function EnvironmentBadge({ + environment, + size = "sm", +}: { + environment: string; + size?: "sm" | "xs"; +}) { + const isProduction = environment === "production"; + return ( + + {isProduction ? "prod" : "dev"} + + ); +} diff --git a/apps/web/app/(org)/dashboard/developers/_components/StatBox.tsx b/apps/web/app/(org)/dashboard/developers/_components/StatBox.tsx new file mode 100644 index 00000000000..3a271e5f5c8 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/_components/StatBox.tsx @@ -0,0 +1,21 @@ +"use client"; + +export function StatBox({ + label, + value, + subtext, +}: { + label: string; + value: string | number; + subtext?: string; +}) { + return ( +
+ {label} + + {value} + + {subtext && {subtext}} +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/AppsListClient.tsx b/apps/web/app/(org)/dashboard/developers/apps/AppsListClient.tsx new file mode 100644 index 00000000000..d8d4f72a571 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/AppsListClient.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { Button } from "@cap/ui"; +import { Plus } from "lucide-react"; +import { useState } from "react"; +import { AppCard } from "../_components/AppCard"; +import { CreateAppDialog } from "../_components/CreateAppDialog"; +import { useDevelopersContext } from "../DevelopersContext"; + +export function AppsListClient() { + const { apps } = useDevelopersContext(); + const [createOpen, setCreateOpen] = useState(false); + + return ( + <> +
+

Your Apps

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

No apps yet

+

+ Create your first app to get started with the Cap Developer SDK +

+ +
+ ) : ( +
+ {apps.map((app) => ( + + ))} +
+ )} + + + + ); +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/[appId]/api-keys/ApiKeysClient.tsx b/apps/web/app/(org)/dashboard/developers/apps/[appId]/api-keys/ApiKeysClient.tsx new file mode 100644 index 00000000000..f38663a83f2 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/[appId]/api-keys/ApiKeysClient.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { Button, Card, CardHeader, CardTitle } from "@cap/ui"; +import { useMutation } from "@tanstack/react-query"; +import { RefreshCw } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { regenerateDeveloperKeys } from "@/actions/developers/regenerate-keys"; +import { ApiKeyDisplay } from "../../../_components/ApiKeyDisplay"; +import { useDevelopersContext } from "../../../DevelopersContext"; + +export function ApiKeysClient() { + const { appId } = useParams<{ appId: string }>(); + const { apps } = useDevelopersContext(); + const app = apps.find((a) => a.id === appId); + const router = useRouter(); + + const [newKeys, setNewKeys] = useState<{ + publicKey: string; + secretKey: string; + } | null>(null); + const [confirmRegenerate, setConfirmRegenerate] = useState(false); + + const regenerateMutation = useMutation({ + mutationFn: () => regenerateDeveloperKeys(appId), + onSuccess: (result) => { + setNewKeys(result); + setConfirmRegenerate(false); + toast.success("Keys regenerated"); + router.refresh(); + }, + onError: () => toast.error("Failed to regenerate keys"), + }); + + if (!app) { + return

App not found

; + } + + const publicKey = app.apiKeys.find((k) => k.keyType === "public"); + const secretKey = app.apiKeys.find((k) => k.keyType === "secret"); + + return ( +
+ {newKeys && ( + +

+ New keys generated. Save your secret key now! +

+
+ + +
+
+ )} + + + + Current Keys + +
+ {publicKey && ( + + )} + {secretKey && ( +
+ + Secret Key + + + {"•".repeat(24)} + +

+ Regenerate to reveal a new secret key +

+
+ )} +
+
+ + + + Regenerate Keys + +
+ {!confirmRegenerate ? ( + + ) : ( +
+ + +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/[appId]/api-keys/page.tsx b/apps/web/app/(org)/dashboard/developers/apps/[appId]/api-keys/page.tsx new file mode 100644 index 00000000000..e215a7631eb --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/[appId]/api-keys/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { ApiKeysClient } from "./ApiKeysClient"; + +export const metadata: Metadata = { + title: "API Keys — Cap", +}; + +export default async function ApiKeysPage() { + return ; +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/[appId]/domains/DomainsClient.tsx b/apps/web/app/(org)/dashboard/developers/apps/[appId]/domains/DomainsClient.tsx new file mode 100644 index 00000000000..0ccce19d3d3 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/[appId]/domains/DomainsClient.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { + Button, + Card, + CardDescription, + CardHeader, + CardTitle, + Input, + Label, +} from "@cap/ui"; +import { useMutation } from "@tanstack/react-query"; +import { AlertTriangle, Plus } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +import { addDeveloperDomain } from "@/actions/developers/add-domain"; +import { DomainRow } from "../../../_components/DomainRow"; +import { useDevelopersContext } from "../../../DevelopersContext"; + +export function DomainsClient() { + const { appId } = useParams<{ appId: string }>(); + const { apps } = useDevelopersContext(); + const app = apps.find((a) => a.id === appId); + const router = useRouter(); + const domainInputId = useId(); + const [newDomain, setNewDomain] = useState(""); + + const addMutation = useMutation({ + mutationFn: () => addDeveloperDomain(appId, newDomain), + onSuccess: () => { + setNewDomain(""); + toast.success("Domain added"); + router.refresh(); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to add domain", + ); + }, + }); + + if (!app) { + return

App not found

; + } + + return ( +
+ {app.environment === "development" && ( +
+ + Development apps allow all localhost origins automatically. +
+ )} + + + + Allowed Domains + + Restrict which domains can use your public API key. + + +
{ + e.preventDefault(); + addMutation.mutate(); + }} + className="flex gap-2 items-end mt-4" + > +
+ + setNewDomain(e.target.value)} + placeholder="https://myapp.com" + /> +
+ +
+ + {app.domains.length > 0 && ( +
+ {app.domains.map((d) => ( + + ))} +
+ )} + + {app.domains.length === 0 && ( +

+ No domains configured +

+ )} +
+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/[appId]/domains/page.tsx b/apps/web/app/(org)/dashboard/developers/apps/[appId]/domains/page.tsx new file mode 100644 index 00000000000..db25636b31b --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/[appId]/domains/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { DomainsClient } from "./DomainsClient"; + +export const metadata: Metadata = { + title: "Allowed Domains — Cap", +}; + +export default async function DomainsPage() { + return ; +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/[appId]/layout.tsx b/apps/web/app/(org)/dashboard/developers/apps/[appId]/layout.tsx new file mode 100644 index 00000000000..388507f634e --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/[appId]/layout.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { EnvironmentBadge } from "../../_components/EnvironmentBadge"; +import { useDevelopersContext } from "../../DevelopersContext"; + +export default function AppDetailLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { appId } = useParams<{ appId: string }>(); + const { apps } = useDevelopersContext(); + const app = apps.find((a) => a.id === appId); + + return ( +
+
+

+ {app?.name ?? "App"} +

+ {app && } +
+ {children} +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/[appId]/settings/AppSettingsClient.tsx b/apps/web/app/(org)/dashboard/developers/apps/[appId]/settings/AppSettingsClient.tsx new file mode 100644 index 00000000000..43bddcd9867 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/[appId]/settings/AppSettingsClient.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { + Button, + Card, + CardDescription, + CardHeader, + CardTitle, + Input, + Label, +} from "@cap/ui"; +import { useMutation } from "@tanstack/react-query"; +import { Trash2 } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +import { deleteDeveloperApp } from "@/actions/developers/delete-app"; +import { updateDeveloperApp } from "@/actions/developers/update-app"; +import { useDevelopersContext } from "../../../DevelopersContext"; + +export function AppSettingsClient() { + const { appId } = useParams<{ appId: string }>(); + const { apps } = useDevelopersContext(); + const app = apps.find((a) => a.id === appId); + const router = useRouter(); + const nameInputId = useId(); + + const [name, setName] = useState(app?.name ?? ""); + const [environment, setEnvironment] = useState( + app?.environment ?? "development", + ); + const [confirmDelete, setConfirmDelete] = useState(false); + + const updateMutation = useMutation({ + mutationFn: () => + updateDeveloperApp({ + appId, + name, + environment: environment as "development" | "production", + }), + onSuccess: () => { + toast.success("App updated"); + router.refresh(); + }, + onError: () => toast.error("Failed to update app"), + }); + + const deleteMutation = useMutation({ + mutationFn: () => deleteDeveloperApp(appId), + onSuccess: () => { + toast.success("App deleted"); + router.push("/dashboard/developers/apps"); + router.refresh(); + }, + onError: () => toast.error("Failed to delete app"), + }); + + if (!app) { + return

App not found

; + } + + return ( +
+ + + General + + Update your app name and environment. + + +
{ + e.preventDefault(); + updateMutation.mutate(); + }} + className="flex flex-col gap-4 mt-4" + > +
+ + setName(e.target.value)} + /> +
+
+ +
+ + +
+
+ +
+
+ + + + Danger Zone + + Deleting an app will revoke all API keys and stop all SDK + integrations. + + +
+ {!confirmDelete ? ( + + ) : ( +
+ + +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/[appId]/settings/page.tsx b/apps/web/app/(org)/dashboard/developers/apps/[appId]/settings/page.tsx new file mode 100644 index 00000000000..8b8b6684f51 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/[appId]/settings/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { AppSettingsClient } from "./AppSettingsClient"; + +export const metadata: Metadata = { + title: "App Settings — Cap", +}; + +export default async function AppSettingsPage() { + return ; +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/[appId]/videos/VideosClient.tsx b/apps/web/app/(org)/dashboard/developers/apps/[appId]/videos/VideosClient.tsx new file mode 100644 index 00000000000..9675def821d --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/[appId]/videos/VideosClient.tsx @@ -0,0 +1,138 @@ +"use client"; + +import type { developerVideos } from "@cap/database/schema"; +import { + Button, + Card, + CardDescription, + CardHeader, + CardTitle, + Input, +} from "@cap/ui"; +import { useMutation } from "@tanstack/react-query"; +import { Search, Trash2 } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { deleteDeveloperVideo } from "@/actions/developers/delete-video"; + +type Video = typeof developerVideos.$inferSelect; + +export function VideosClient({ + appId, + videos, +}: { + appId: string; + videos: Video[]; +}) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [userIdFilter, setUserIdFilter] = useState( + searchParams.get("userId") ?? "", + ); + + const handleFilter = () => { + const params = new URLSearchParams(); + if (userIdFilter.trim()) params.set("userId", userIdFilter.trim()); + router.push( + `/dashboard/developers/apps/${appId}/videos?${params.toString()}`, + ); + }; + + return ( +
+ + + Videos + + Videos recorded through the SDK for this app. + + + +
+
+ setUserIdFilter(e.target.value)} + placeholder="Filter by user ID..." + /> +
+ +
+ + {videos.length === 0 ? ( +

+ No videos found +

+ ) : ( +
+ + + + + + + + + + + {videos.map((video) => ( + + ))} + +
+ Name + + User ID + + Duration + + Created + +
+
+ )} +
+
+ ); +} + +function VideoRow({ video, appId }: { video: Video; appId: string }) { + const router = useRouter(); + const deleteMutation = useMutation({ + mutationFn: () => deleteDeveloperVideo(appId, video.id), + onSuccess: () => { + toast.success("Video deleted"); + router.refresh(); + }, + onError: () => toast.error("Failed to delete video"), + }); + + return ( + + {video.name} + + {video.externalUserId ?? "\u2014"} + + + {video.duration ? `${(video.duration / 60).toFixed(1)}m` : "\u2014"} + + + {new Date(video.createdAt).toLocaleDateString()} + + + + + + ); +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/[appId]/videos/page.tsx b/apps/web/app/(org)/dashboard/developers/apps/[appId]/videos/page.tsx new file mode 100644 index 00000000000..7b810b935bf --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/[appId]/videos/page.tsx @@ -0,0 +1,44 @@ +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { developerApps } from "@cap/database/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { getDeveloperAppVideos } from "../../../developer-data"; +import { VideosClient } from "./VideosClient"; + +export const metadata: Metadata = { + title: "Developer Videos — Cap", +}; + +export default async function VideosPage({ + params, + searchParams, +}: { + params: Promise<{ appId: string }>; + searchParams: Promise<{ userId?: string }>; +}) { + const user = await getCurrentUser(); + if (!user) redirect("/auth/signin"); + + const { appId } = await params; + const { userId } = await searchParams; + + const [app] = await db() + .select() + .from(developerApps) + .where( + and( + eq(developerApps.id, appId), + eq(developerApps.ownerId, user.id), + isNull(developerApps.deletedAt), + ), + ) + .limit(1); + + if (!app) redirect("/dashboard/developers/apps"); + + const videos = await getDeveloperAppVideos(appId, { userId }); + + return ; +} diff --git a/apps/web/app/(org)/dashboard/developers/apps/page.tsx b/apps/web/app/(org)/dashboard/developers/apps/page.tsx new file mode 100644 index 00000000000..0de507cd266 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/apps/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { AppsListClient } from "./AppsListClient"; + +export const metadata: Metadata = { + title: "Developer Apps — Cap", +}; + +export default async function AppsPage() { + return ; +} diff --git a/apps/web/app/(org)/dashboard/developers/credits/CreditsClient.tsx b/apps/web/app/(org)/dashboard/developers/credits/CreditsClient.tsx new file mode 100644 index 00000000000..5cf92754285 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/credits/CreditsClient.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { + Button, + Card, + CardDescription, + CardHeader, + CardTitle, + Input, +} from "@cap/ui"; +import { useMutation } from "@tanstack/react-query"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { updateDeveloperAutoTopUp } from "@/actions/developers/update-auto-topup"; +import { CreditTransactionTable } from "../_components/CreditTransactionTable"; +import { StatBox } from "../_components/StatBox"; +import { useDevelopersContext } from "../DevelopersContext"; +import type { DeveloperTransaction } from "../developer-data"; + +const presets = [ + { label: "$10", cents: 1000 }, + { label: "$25", cents: 2500 }, + { label: "$50", cents: 5000 }, +]; + +export function CreditsClient({ + transactions, +}: { + transactions: DeveloperTransaction[]; +}) { + const { apps } = useDevelopersContext(); + const router = useRouter(); + const searchParams = useSearchParams(); + const [selectedApp, setSelectedApp] = useState(apps[0]?.id ?? ""); + const [customAmount, setCustomAmount] = useState(""); + + const app = apps.find((a) => a.id === selectedApp); + const balance = app?.creditAccount?.balanceMicroCredits ?? 0; + const autoTopUp = app?.creditAccount?.autoTopUpEnabled ?? false; + + useEffect(() => { + if (searchParams.get("purchase") === "success") { + toast.success("Credits purchased successfully!"); + router.replace("/dashboard/developers/credits"); + } + }, [searchParams, router]); + + const purchaseMutation = useMutation({ + mutationFn: async (amountCents: number) => { + const res = await fetch("/api/developer/credits/checkout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ appId: selectedApp, amountCents }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error ?? "Failed to start checkout"); + } + + const { url } = await res.json(); + window.location.href = url; + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to purchase credits", + ); + }, + }); + + const autoTopUpMutation = useMutation({ + mutationFn: (enabled: boolean) => + updateDeveloperAutoTopUp({ + appId: selectedApp, + enabled, + thresholdMicroCredits: 500_000, + amountCents: 2500, + }), + onSuccess: () => { + toast.success("Auto top-up updated"); + router.refresh(); + }, + onError: () => toast.error("Failed to update auto top-up"), + }); + + return ( +
+
+

Credits

+ {apps.length > 1 && ( + + )} +
+ +
+ + + +
+ +
+ + + Purchase Credits + Add credits to your account. + +
+ {presets.map((preset) => ( + + ))} +
+
+ setCustomAmount(e.target.value)} + placeholder="$" + className="w-20" + /> + +
+
+ + + + Auto Top-Up + + Automatically add $25 when balance drops below $5. + + +
+ +
+
+
+ + + + Transaction History + +
+ +
+
+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/credits/page.tsx b/apps/web/app/(org)/dashboard/developers/credits/page.tsx new file mode 100644 index 00000000000..9dedda42d07 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/credits/page.tsx @@ -0,0 +1,35 @@ +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { developerCreditTransactions } from "@cap/database/schema"; +import { desc, inArray } from "drizzle-orm"; +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { getDeveloperApps } from "../developer-data"; +import { CreditsClient } from "./CreditsClient"; + +export const metadata: Metadata = { + title: "Developer Credits — Cap", +}; + +export default async function CreditsPage() { + const user = await getCurrentUser(); + if (!user) redirect("/auth/signin"); + + const apps = await getDeveloperApps(user); + + const accountIds = apps + .map((a) => a.creditAccount?.id) + .filter((id): id is string => Boolean(id)); + + const transactions = + accountIds.length > 0 + ? await db() + .select() + .from(developerCreditTransactions) + .where(inArray(developerCreditTransactions.accountId, accountIds)) + .orderBy(desc(developerCreditTransactions.createdAt)) + .limit(50) + : []; + + return ; +} diff --git a/apps/web/app/(org)/dashboard/developers/developer-data.ts b/apps/web/app/(org)/dashboard/developers/developer-data.ts new file mode 100644 index 00000000000..66bc539d223 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/developer-data.ts @@ -0,0 +1,151 @@ +import { db } from "@cap/database"; +import type { userSelectProps } from "@cap/database/auth/session"; +import { + developerApiKeys, + developerAppDomains, + developerApps, + developerCreditAccounts, + developerCreditTransactions, + developerVideos, +} from "@cap/database/schema"; +import { and, count, desc, eq, inArray, isNull, sql } from "drizzle-orm"; + +export type DeveloperApiKey = Pick< + typeof developerApiKeys.$inferSelect, + "id" | "keyType" | "keyPrefix" | "createdAt" | "revokedAt" +> & { + fullKey?: string; +}; + +export type DeveloperApp = typeof developerApps.$inferSelect & { + domains: (typeof developerAppDomains.$inferSelect)[]; + apiKeys: DeveloperApiKey[]; + creditAccount: typeof developerCreditAccounts.$inferSelect | null; + videoCount: number; +}; + +export type DeveloperTransaction = + typeof developerCreditTransactions.$inferSelect; + +export async function getDeveloperApps( + user: typeof userSelectProps, +): Promise { + const apps = await db() + .select() + .from(developerApps) + .where( + and(eq(developerApps.ownerId, user.id), isNull(developerApps.deletedAt)), + ) + .orderBy(desc(developerApps.createdAt)); + + if (apps.length === 0) return []; + + const appIds = apps.map((a) => a.id); + + const [allDomains, allApiKeys, allCreditAccounts, allVideoCounts] = + await Promise.all([ + db() + .select() + .from(developerAppDomains) + .where(inArray(developerAppDomains.appId, appIds)), + db() + .select({ + id: developerApiKeys.id, + appId: developerApiKeys.appId, + keyType: developerApiKeys.keyType, + keyPrefix: developerApiKeys.keyPrefix, + encryptedKey: developerApiKeys.encryptedKey, + createdAt: developerApiKeys.createdAt, + revokedAt: developerApiKeys.revokedAt, + }) + .from(developerApiKeys) + .where( + and( + inArray(developerApiKeys.appId, appIds), + isNull(developerApiKeys.revokedAt), + ), + ), + db() + .select() + .from(developerCreditAccounts) + .where(inArray(developerCreditAccounts.appId, appIds)), + db() + .select({ + appId: developerVideos.appId, + count: count(), + }) + .from(developerVideos) + .where( + and( + inArray(developerVideos.appId, appIds), + isNull(developerVideos.deletedAt), + ), + ) + .groupBy(developerVideos.appId), + ]); + + return apps.map((app) => ({ + ...app, + domains: allDomains.filter((d) => d.appId === app.id), + apiKeys: allApiKeys + .filter((k) => k.appId === app.id) + .map((k) => ({ + id: k.id, + keyType: k.keyType, + keyPrefix: k.keyPrefix, + createdAt: k.createdAt, + revokedAt: k.revokedAt, + fullKey: k.keyType === "public" ? k.encryptedKey : undefined, + })), + creditAccount: allCreditAccounts.find((c) => c.appId === app.id) ?? null, + videoCount: allVideoCounts.find((v) => v.appId === app.id)?.count ?? 0, + })); +} + +export async function getDeveloperAppVideos( + appId: string, + options?: { userId?: string; limit?: number; offset?: number }, +) { + const conditions = [ + eq(developerVideos.appId, appId), + isNull(developerVideos.deletedAt), + ]; + + if (options?.userId) { + conditions.push(eq(developerVideos.externalUserId, options.userId)); + } + + return db() + .select() + .from(developerVideos) + .where(and(...conditions)) + .orderBy(desc(developerVideos.createdAt)) + .limit(options?.limit ?? 50) + .offset(options?.offset ?? 0); +} + +export async function getDeveloperTransactions(accountId: string, limit = 50) { + return db() + .select() + .from(developerCreditTransactions) + .where(eq(developerCreditTransactions.accountId, accountId)) + .orderBy(desc(developerCreditTransactions.createdAt)) + .limit(limit); +} + +export async function getDeveloperUsageSummary(appId: string) { + const [videoStats] = await db() + .select({ + totalVideos: count(), + totalDurationMinutes: sql`COALESCE(SUM(${developerVideos.duration}) / 60, 0)`, + }) + .from(developerVideos) + .where( + and(eq(developerVideos.appId, appId), isNull(developerVideos.deletedAt)), + ); + + return { + totalVideos: videoStats?.totalVideos ?? 0, + totalDurationMinutes: videoStats?.totalDurationMinutes ?? 0, + }; +} diff --git a/apps/web/app/(org)/dashboard/developers/layout.tsx b/apps/web/app/(org)/dashboard/developers/layout.tsx new file mode 100644 index 00000000000..84a4a10b81b --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/layout.tsx @@ -0,0 +1,31 @@ +import { getCurrentUser } from "@cap/database/auth/session"; +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { DeveloperSidebarRegistrar } from "./_components/DeveloperSidebarRegistrar"; +import { DeveloperThemeForcer } from "./_components/DeveloperThemeForcer"; +import { DevelopersProvider } from "./DevelopersContext"; +import { getDeveloperApps } from "./developer-data"; + +export const metadata: Metadata = { + title: "Developers — Cap", +}; + +export default async function DevelopersLayout({ + children, +}: { + children: React.ReactNode; +}) { + const user = await getCurrentUser(); + if (!user) redirect("/auth/signin"); + + const apps = await getDeveloperApps(user); + + return ( + + + + {children} + + + ); +} diff --git a/apps/web/app/(org)/dashboard/developers/page.tsx b/apps/web/app/(org)/dashboard/developers/page.tsx new file mode 100644 index 00000000000..7a92dc8f068 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function DevelopersPage() { + redirect("/dashboard/developers/apps"); +} diff --git a/apps/web/app/(org)/dashboard/developers/usage/UsageClient.tsx b/apps/web/app/(org)/dashboard/developers/usage/UsageClient.tsx new file mode 100644 index 00000000000..7b026993864 --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/usage/UsageClient.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { Card, CardHeader, CardTitle } from "@cap/ui"; +import { EnvironmentBadge } from "../_components/EnvironmentBadge"; +import { StatBox } from "../_components/StatBox"; +import { useDevelopersContext } from "../DevelopersContext"; + +export function UsageClient() { + const { apps } = useDevelopersContext(); + + const totalVideos = apps.reduce((sum, app) => sum + app.videoCount, 0); + const totalBalance = apps.reduce( + (sum, app) => sum + (app.creditAccount?.balanceMicroCredits ?? 0), + 0, + ); + + return ( +
+

Usage Overview

+ +
+ + + +
+ + {apps.length > 0 && ( + + + Usage by App + +
+ + + + + + + + + + + {apps.map((app) => ( + + + + + + + ))} + +
+ App + + Environment + + Videos + + Balance +
{app.name} + + + {app.videoCount} + + $ + {( + (app.creditAccount?.balanceMicroCredits ?? 0) / 100_000 + ).toFixed(2)} +
+
+
+ )} +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/developers/usage/page.tsx b/apps/web/app/(org)/dashboard/developers/usage/page.tsx new file mode 100644 index 00000000000..3bb1b66940d --- /dev/null +++ b/apps/web/app/(org)/dashboard/developers/usage/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { UsageClient } from "./UsageClient"; + +export const metadata: Metadata = { + title: "Developer Usage — Cap", +}; + +export default async function UsagePage() { + return ; +} From 7e0e3872ea447230661128ba43d9e4aec761fcd4 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:13:52 +0000 Subject: [PATCH 12/81] feat(web): add developer section to dashboard navigation --- apps/web/app/(org)/dashboard/Contexts.tsx | 11 ++ .../_components/AnimatedIcons/Code.tsx | 105 ++++++++++++++++++ .../_components/AnimatedIcons/index.ts | 2 + .../dashboard/_components/Navbar/Desktop.tsx | 45 ++++++-- .../dashboard/_components/Navbar/Items.tsx | 13 +++ .../dashboard/_components/Navbar/Top.tsx | 35 +++--- 6 files changed, 186 insertions(+), 25 deletions(-) create mode 100644 apps/web/app/(org)/dashboard/_components/AnimatedIcons/Code.tsx diff --git a/apps/web/app/(org)/dashboard/Contexts.tsx b/apps/web/app/(org)/dashboard/Contexts.tsx index 5ae70ee82cd..faa2a7f1358 100644 --- a/apps/web/app/(org)/dashboard/Contexts.tsx +++ b/apps/web/app/(org)/dashboard/Contexts.tsx @@ -12,6 +12,7 @@ import type { Spaces, UserPreferences, } from "./dashboard-data"; +import type { DeveloperApp } from "./developers/developer-data"; type SharedContext = { organizationData: Organization[] | null; @@ -31,6 +32,9 @@ type SharedContext = { setUpgradeModalOpen: (open: boolean) => void; referClickedState: boolean; setReferClickedStateHandler: (referClicked: boolean) => void; + isDeveloperSection: boolean; + developerApps: DeveloperApp[] | null; + setDeveloperApps: (apps: DeveloperApp[] | null) => void; }; type ITheme = "light" | "dark"; @@ -83,7 +87,11 @@ export function DashboardContexts({ ); const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); const [referClickedState, setReferClickedState] = useState(referClicked); + const [developerApps, setDeveloperApps] = useState( + null, + ); const pathname = usePathname(); + const isDeveloperSection = pathname.startsWith("/dashboard/developers"); // Calculate user's spaces (both owned and member of) const userSpaces = @@ -176,6 +184,9 @@ export function DashboardContexts({ setUpgradeModalOpen, referClickedState, setReferClickedStateHandler, + isDeveloperSection, + developerApps, + setDeveloperApps, }} > {children} diff --git a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Code.tsx b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Code.tsx new file mode 100644 index 00000000000..5204730a5ae --- /dev/null +++ b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Code.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { motion, useAnimation } from "motion/react"; +import type { HTMLAttributes } from "react"; +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; +import { cn } from "@/lib/utils"; + +export interface CodeIconHandle { + startAnimation: () => void; + stopAnimation: () => void; +} + +interface CodeIconProps extends HTMLAttributes { + size?: number; +} + +const CodeIcon = forwardRef( + ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { + const controls = useAnimation(); + const isControlledRef = useRef(false); + + useImperativeHandle(ref, () => { + isControlledRef.current = true; + + return { + startAnimation: () => controls.start("animate"), + stopAnimation: () => controls.start("normal"), + }; + }); + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start("animate"); + } else { + onMouseEnter?.(e); + } + }, + [controls, onMouseEnter], + ); + + const handleMouseLeave = useCallback( + (e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start("normal"); + } else { + onMouseLeave?.(e); + } + }, + [controls, onMouseLeave], + ); + + return ( +
+ + + + +
+ ); + }, +); + +CodeIcon.displayName = "CodeIcon"; + +export default CodeIcon; diff --git a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts index a78bba9d4f1..f3570be3a5f 100644 --- a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts +++ b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts @@ -4,6 +4,7 @@ import ChartLineIcon from "./ChartLine"; import MessageCircleMoreIcon from "./Chat"; import ChatIcon from "./Chat"; import ClapIcon from "./Clap"; +import CodeIcon from "./Code"; import CogIcon from "./Cog"; import DownloadIcon from "./Download"; import HomeIcon from "./Home"; @@ -18,6 +19,7 @@ export { ArrowUpIcon, CapIcon, MessageCircleMoreIcon, + CodeIcon, CogIcon, DownloadIcon, HomeIcon, diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Desktop.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Desktop.tsx index 62551931b9c..0d43dcfcc4a 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Desktop.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Desktop.tsx @@ -3,15 +3,17 @@ import { Button, Logo } from "@cap/ui"; import clsx from "clsx"; import { motion } from "framer-motion"; import { useDetectPlatform } from "hooks/useDetectPlatform"; -import { ChevronRight } from "lucide-react"; +import { ArrowLeft, ChevronRight } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; import { Tooltip } from "@/components/Tooltip"; import { useDashboardContext } from "../../Contexts"; +import { DeveloperSidebarContent } from "../../developers/_components/DeveloperSidebarContent"; import AdminNavItems from "./Items"; export const DesktopNav = () => { - const { toggleSidebarCollapsed, sidebarCollapsed } = useDashboardContext(); + const { toggleSidebarCollapsed, sidebarCollapsed, isDeveloperSection } = + useDashboardContext(); const { platform } = useDetectPlatform(); const cmdSymbol = platform === "macos" ? "⌘" : "Ctrl"; @@ -47,16 +49,33 @@ export const DesktopNav = () => { >
- - - + > + + {!sidebarCollapsed && ( + Dashboard + )} + + ) : ( + + + + )} {
- + {isDeveloperSection ? ( + + ) : ( + + )}
diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index 160105bbc12..a4e6d8fcc70 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -38,6 +38,7 @@ import { useDashboardContext } from "../../Contexts"; import { CapIcon, ChartLineIcon, + CodeIcon, CogIcon, ImportIcon, RecordIcon, @@ -90,6 +91,18 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { icon: , subNav: [], }, + ...(buildEnv.NEXT_PUBLIC_IS_CAP + ? [ + { + name: "Developers", + href: `/dashboard/developers`, + ownerOnly: true, + matchChildren: true, + icon: , + subNav: [] as { name: string; href: string }[], + }, + ] + : []), ]; const [dialogOpen, setDialogOpen] = useState(false); diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx index 6af7c64f025..c634a876c62 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx @@ -47,7 +47,8 @@ import type { DownloadIconHandle } from "../AnimatedIcons/Download"; import type { ReferIconHandle } from "../AnimatedIcons/Refer"; const Top = () => { - const { activeSpace, anyNewNotifications } = useDashboardContext(); + const { activeSpace, anyNewNotifications, isDeveloperSection } = + useDashboardContext(); const [toggleNotifications, setToggleNotifications] = useState(false); const bellRef = useRef(null); const { theme, setThemeHandler } = useTheme(); @@ -68,6 +69,10 @@ const Top = () => { "/dashboard/analytics": "Analytics", [`/dashboard/folder/${params.id}`]: "Caps", [`/dashboard/analytics/s/${params.id}`]: "Analytics: Cap video title", + "/dashboard/developers": "Developers", + "/dashboard/developers/apps": "Developer Apps", + "/dashboard/developers/usage": "Developer Usage", + "/dashboard/developers/credits": "Developer Credits", }; const title = activeSpace ? activeSpace.name : titles[pathname] || ""; @@ -160,20 +165,22 @@ const Top = () => { {toggleNotifications && } -
{ - if (document.startViewTransition) { - document.startViewTransition(() => { + {!isDeveloperSection && ( +
{ + if (document.startViewTransition) { + document.startViewTransition(() => { + setThemeHandler(theme === "light" ? "dark" : "light"); + }); + } else { setThemeHandler(theme === "light" ? "dark" : "light"); - }); - } else { - setThemeHandler(theme === "light" ? "dark" : "light"); - } - }} - className="hidden justify-center items-center rounded-full transition-colors cursor-pointer bg-gray-3 lg:flex hover:bg-gray-5 size-9" - > - -
+ } + }} + className="hidden justify-center items-center rounded-full transition-colors cursor-pointer bg-gray-3 lg:flex hover:bg-gray-5 size-9" + > + +
+ )} From 52094115bebb93a51417348a6d07e98c53c76740 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:13:56 +0000 Subject: [PATCH 13/81] feat(web): add docs route group with sidebar layout --- apps/web/app/(docs)/docs/[[...slug]]/page.tsx | 71 ++++ .../docs/_components/DocsBreadcrumbs.tsx | 43 +++ .../(docs)/docs/_components/DocsHeader.tsx | 113 ++++++ .../docs/_components/DocsMobileMenu.tsx | 100 ++++++ .../(docs)/docs/_components/DocsPrevNext.tsx | 54 +++ .../(docs)/docs/_components/DocsSearch.tsx | 323 ++++++++++++++++++ .../(docs)/docs/_components/DocsSidebar.tsx | 46 +++ .../docs/_components/DocsTableOfContents.tsx | 84 +++++ apps/web/app/(docs)/docs/docs-config.ts | 89 +++++ apps/web/app/(docs)/docs/layout.tsx | 25 ++ apps/web/app/(docs)/layout.tsx | 5 + 11 files changed, 953 insertions(+) create mode 100644 apps/web/app/(docs)/docs/[[...slug]]/page.tsx create mode 100644 apps/web/app/(docs)/docs/_components/DocsBreadcrumbs.tsx create mode 100644 apps/web/app/(docs)/docs/_components/DocsHeader.tsx create mode 100644 apps/web/app/(docs)/docs/_components/DocsMobileMenu.tsx create mode 100644 apps/web/app/(docs)/docs/_components/DocsPrevNext.tsx create mode 100644 apps/web/app/(docs)/docs/_components/DocsSearch.tsx create mode 100644 apps/web/app/(docs)/docs/_components/DocsSidebar.tsx create mode 100644 apps/web/app/(docs)/docs/_components/DocsTableOfContents.tsx create mode 100644 apps/web/app/(docs)/docs/docs-config.ts create mode 100644 apps/web/app/(docs)/docs/layout.tsx create mode 100644 apps/web/app/(docs)/layout.tsx diff --git a/apps/web/app/(docs)/docs/[[...slug]]/page.tsx b/apps/web/app/(docs)/docs/[[...slug]]/page.tsx new file mode 100644 index 00000000000..2377500a6ab --- /dev/null +++ b/apps/web/app/(docs)/docs/[[...slug]]/page.tsx @@ -0,0 +1,71 @@ +import { buildEnv } from "@cap/env"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { CustomMDX } from "@/components/mdx"; +import { extractHeadings, getDocBySlug } from "@/utils/docs"; +import { DocsBreadcrumbs } from "../_components/DocsBreadcrumbs"; +import { DocsPrevNext } from "../_components/DocsPrevNext"; +import { DocsTableOfContents } from "../_components/DocsTableOfContents"; + +interface DocPageProps { + params: Promise<{ slug?: string[] }>; +} + +export async function generateMetadata( + props: DocPageProps, +): Promise { + const params = await props.params; + const slug = params.slug?.join("/") ?? "introduction"; + const doc = getDocBySlug(slug); + if (!doc) return; + + const { title, summary: description, image } = doc.metadata; + const ogImage = image ? `${buildEnv.NEXT_PUBLIC_WEB_URL}${image}` : undefined; + + return { + title: `${title} - Cap Docs`, + description: description || title, + openGraph: { + title: `${title} - Cap Docs`, + description: description || title, + type: "article", + url: `${buildEnv.NEXT_PUBLIC_WEB_URL}/docs/${slug}`, + ...(ogImage && { images: [{ url: ogImage }] }), + }, + }; +} + +export default async function DocPage(props: DocPageProps) { + const params = await props.params; + const slug = params.slug?.join("/") ?? "introduction"; + const doc = getDocBySlug(slug); + + if (!doc) { + notFound(); + } + + const headings = extractHeadings(doc.content); + + return ( +
+
+ +
+

+ {doc.metadata.title} +

+ {doc.metadata.summary && ( +

{doc.metadata.summary}

+ )} +
+ +
+
+ +
+
+ +
+
+ ); +} diff --git a/apps/web/app/(docs)/docs/_components/DocsBreadcrumbs.tsx b/apps/web/app/(docs)/docs/_components/DocsBreadcrumbs.tsx new file mode 100644 index 00000000000..21f10affd13 --- /dev/null +++ b/apps/web/app/(docs)/docs/_components/DocsBreadcrumbs.tsx @@ -0,0 +1,43 @@ +import { ChevronRight } from "lucide-react"; +import Link from "next/link"; +import { getBreadcrumbs } from "../docs-config"; + +interface DocsBreadcrumbsProps { + currentSlug: string; +} + +export function DocsBreadcrumbs({ currentSlug }: DocsBreadcrumbsProps) { + const breadcrumbs = getBreadcrumbs(currentSlug); + + return ( + + ); +} diff --git a/apps/web/app/(docs)/docs/_components/DocsHeader.tsx b/apps/web/app/(docs)/docs/_components/DocsHeader.tsx new file mode 100644 index 00000000000..0d33b3bc3e7 --- /dev/null +++ b/apps/web/app/(docs)/docs/_components/DocsHeader.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { Logo } from "@cap/ui"; +import { ExternalLink, Menu, Search } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useState } from "react"; + +function useOS() { + const [isMac, setIsMac] = useState(true); + + useEffect(() => { + setIsMac(navigator.platform.toUpperCase().indexOf("MAC") >= 0); + }, []); + + return { isMac }; +} + +export function DocsHeader() { + const { isMac } = useOS(); + + const handleSearchClick = () => { + window.dispatchEvent(new CustomEvent("open-docs-search")); + }; + + const handleMobileMenuClick = () => { + window.dispatchEvent(new CustomEvent("open-docs-mobile-menu")); + }; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + window.dispatchEvent(new CustomEvent("open-docs-search")); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + return ( +
+
+ + + + + / + + Docs + +
+ + + +
+ + + cap.so + + + + + GitHub + + + +
+
+ ); +} diff --git a/apps/web/app/(docs)/docs/_components/DocsMobileMenu.tsx b/apps/web/app/(docs)/docs/_components/DocsMobileMenu.tsx new file mode 100644 index 00000000000..f4429b4b4da --- /dev/null +++ b/apps/web/app/(docs)/docs/_components/DocsMobileMenu.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { X } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import { docsConfig, type SidebarGroup } from "../docs-config"; + +export function DocsMobileMenu() { + const [isOpen, setIsOpen] = useState(false); + const pathname = usePathname(); + const prevPathname = useRef(pathname); + + useEffect(() => { + const handleOpen = () => setIsOpen(true); + window.addEventListener("open-docs-mobile-menu", handleOpen); + return () => + window.removeEventListener("open-docs-mobile-menu", handleOpen); + }, []); + + useEffect(() => { + if (prevPathname.current !== pathname) { + setIsOpen(false); + prevPathname.current = pathname; + } + }, [pathname]); + + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [isOpen]); + + const isActive = (slug: string) => { + return pathname === `/docs/${slug}` || pathname === `/docs/${slug}/`; + }; + + if (!isOpen) return null; + + return ( +
+ +
+ + + + ); +} diff --git a/apps/web/app/(docs)/docs/_components/DocsPrevNext.tsx b/apps/web/app/(docs)/docs/_components/DocsPrevNext.tsx new file mode 100644 index 00000000000..e41482a4bde --- /dev/null +++ b/apps/web/app/(docs)/docs/_components/DocsPrevNext.tsx @@ -0,0 +1,54 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import Link from "next/link"; +import { getAdjacentDocs } from "../docs-config"; + +interface DocsPrevNextProps { + currentSlug: string; +} + +export function DocsPrevNext({ currentSlug }: DocsPrevNextProps) { + const { prev, next } = getAdjacentDocs(currentSlug); + + if (!prev && !next) return null; + + return ( +
+ {prev ? ( + + +
+ + Previous + + + {prev.title} + +
+ + ) : ( +
+ )} + {next ? ( + +
+ + Next + + + {next.title} + +
+ + + ) : ( +
+ )} +
+ ); +} diff --git a/apps/web/app/(docs)/docs/_components/DocsSearch.tsx b/apps/web/app/(docs)/docs/_components/DocsSearch.tsx new file mode 100644 index 00000000000..56eed63685d --- /dev/null +++ b/apps/web/app/(docs)/docs/_components/DocsSearch.tsx @@ -0,0 +1,323 @@ +"use client"; + +import { FileText, Search } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; + +interface SearchItem { + slug: string; + title: string; + summary: string; + content: string; + group: string; +} + +interface DocsSearchProps { + searchIndex: SearchItem[]; +} + +interface GroupedResults { + group: string; + items: SearchItem[]; +} + +function groupResults(items: SearchItem[]): GroupedResults[] { + const map = new Map(); + for (const item of items) { + const existing = map.get(item.group); + if (existing) { + existing.push(item); + } else { + map.set(item.group, [item]); + } + } + return Array.from(map.entries()).map(([group, items]) => ({ + group, + items, + })); +} + +function truncateSummary(text: string, maxLength = 120): string { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength).trimEnd()}...`; +} + +export function DocsSearch({ searchIndex }: DocsSearchProps) { + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + const [isAnimating, setIsAnimating] = useState(false); + const router = useRouter(); + const inputRef = useRef(null); + const resultsRef = useRef(null); + const activeItemRef = useRef(null); + const instanceId = useId(); + const resultsId = `${instanceId}-docs-search-results`; + const prevQueryRef = useRef(query); + const prevActiveIndexRef = useRef(activeIndex); + + const filteredResults = useMemo(() => { + if (!query.trim()) return []; + const lowerQuery = query.toLowerCase(); + return searchIndex.filter( + (item) => + item.title.toLowerCase().includes(lowerQuery) || + item.summary.toLowerCase().includes(lowerQuery) || + item.content.toLowerCase().includes(lowerQuery), + ); + }, [query, searchIndex]); + + const grouped = useMemo( + () => groupResults(filteredResults), + [filteredResults], + ); + + const flatResults = useMemo(() => grouped.flatMap((g) => g.items), [grouped]); + + const open = useCallback(() => { + setIsOpen(true); + setQuery(""); + setActiveIndex(0); + requestAnimationFrame(() => { + setIsAnimating(true); + inputRef.current?.focus(); + }); + }, []); + + const close = useCallback(() => { + setIsAnimating(false); + const timeout = setTimeout(() => { + setIsOpen(false); + setQuery(""); + setActiveIndex(0); + }, 150); + return () => clearTimeout(timeout); + }, []); + + const navigateTo = useCallback( + (slug: string) => { + close(); + router.push(`/docs/${slug}`); + }, + [close, router], + ); + + useEffect(() => { + const handleCustomEvent = () => open(); + window.addEventListener("open-docs-search", handleCustomEvent); + return () => + window.removeEventListener("open-docs-search", handleCustomEvent); + }, [open]); + + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + close(); + return; + } + + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((prev) => + prev < flatResults.length - 1 ? prev + 1 : 0, + ); + return; + } + + if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((prev) => + prev > 0 ? prev - 1 : flatResults.length - 1, + ); + return; + } + + if (e.key === "Enter") { + e.preventDefault(); + const selected = flatResults[activeIndex]; + if (selected) { + navigateTo(selected.slug); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, flatResults, activeIndex, close, navigateTo]); + + if (prevQueryRef.current !== query) { + prevQueryRef.current = query; + setActiveIndex(0); + } + + if (prevActiveIndexRef.current !== activeIndex) { + prevActiveIndexRef.current = activeIndex; + activeItemRef.current?.scrollIntoView({ block: "nearest" }); + } + + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [isOpen]); + + if (!isOpen) return null; + + let flatIndex = -1; + + return ( +
+ + ); + })} +
+ ))} +
+ + {flatResults.length > 0 && ( +
+ + + ↑ + + + ↓ + + to navigate + + + + ↵ + + to select + +
+ )} +
+ + ); +} diff --git a/apps/web/app/(docs)/docs/_components/DocsSidebar.tsx b/apps/web/app/(docs)/docs/_components/DocsSidebar.tsx new file mode 100644 index 00000000000..1e028a3e20b --- /dev/null +++ b/apps/web/app/(docs)/docs/_components/DocsSidebar.tsx @@ -0,0 +1,46 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { docsConfig, type SidebarGroup } from "../docs-config"; + +export function DocsSidebar() { + const pathname = usePathname(); + + const isActive = (slug: string) => { + return pathname === `/docs/${slug}` || pathname === `/docs/${slug}/`; + }; + + return ( + + ); +} diff --git a/apps/web/app/(docs)/docs/_components/DocsTableOfContents.tsx b/apps/web/app/(docs)/docs/_components/DocsTableOfContents.tsx new file mode 100644 index 00000000000..7a677ed83c3 --- /dev/null +++ b/apps/web/app/(docs)/docs/_components/DocsTableOfContents.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface Heading { + level: number; + text: string; + slug: string; +} + +interface DocsTableOfContentsProps { + headings: Heading[]; +} + +export function DocsTableOfContents({ headings }: DocsTableOfContentsProps) { + const [activeSlug, setActiveSlug] = useState(""); + + useEffect(() => { + if (headings.length === 0) return; + + const slugs = headings.map((h) => h.slug); + const elements = slugs + .map((slug) => document.getElementById(slug)) + .filter(Boolean) as HTMLElement[]; + + if (elements.length === 0) return; + + const observer = new IntersectionObserver( + (entries) => { + const visibleEntries = entries.filter((entry) => entry.isIntersecting); + if (visibleEntries.length > 0) { + const topEntry = visibleEntries.reduce((prev, curr) => + prev.boundingClientRect.top < curr.boundingClientRect.top + ? prev + : curr, + ); + setActiveSlug(topEntry.target.id); + } + }, + { + rootMargin: "-80px 0px -60% 0px", + threshold: 0, + }, + ); + + for (const el of elements) { + observer.observe(el); + } + + return () => observer.disconnect(); + }, [headings]); + + const filteredHeadings = headings.filter( + (h) => h.level === 2 || h.level === 3, + ); + + if (filteredHeadings.length === 0) return null; + + return ( +
+

+ On this page +

+ +
+ ); +} diff --git a/apps/web/app/(docs)/docs/docs-config.ts b/apps/web/app/(docs)/docs/docs-config.ts new file mode 100644 index 00000000000..48fa2b2d84d --- /dev/null +++ b/apps/web/app/(docs)/docs/docs-config.ts @@ -0,0 +1,89 @@ +export interface SidebarLink { + title: string; + slug: string; +} + +export interface SidebarGroup { + title: string; + items: SidebarLink[]; +} + +export const docsConfig = { + sidebar: [ + { + title: "Getting Started", + items: [ + { title: "Introduction", slug: "introduction" }, + { title: "Installation", slug: "installation" }, + { title: "Quickstart", slug: "quickstart" }, + ], + }, + { + title: "Recording", + items: [ + { title: "Instant Mode", slug: "recording/instant-mode" }, + { title: "Studio Mode", slug: "recording/studio-mode" }, + { title: "Camera & Microphone", slug: "recording/camera-and-mic" }, + { + title: "Keyboard Shortcuts", + slug: "recording/keyboard-shortcuts", + }, + ], + }, + { + title: "Sharing & Playback", + items: [ + { title: "Share a Cap", slug: "sharing/share-a-cap" }, + { title: "Embeds", slug: "sharing/embeds" }, + { title: "Comments", slug: "sharing/comments" }, + { title: "Analytics", slug: "sharing/analytics" }, + ], + }, + { + title: "Self-hosting", + items: [ + { title: "Overview", slug: "self-hosting" }, + { title: "S3: AWS", slug: "s3-config/aws-s3" }, + { title: "S3: Cloudflare R2", slug: "s3-config/cloudflare-r2" }, + ], + }, + { + title: "API & Developers", + items: [ + { title: "REST API", slug: "api/rest-api" }, + { title: "Webhooks", slug: "api/webhooks" }, + ], + }, + { + title: "Legal", + items: [{ title: "Commercial License", slug: "commercial-license" }], + }, + ] satisfies SidebarGroup[], +}; + +export function flattenSidebar(): SidebarLink[] { + return docsConfig.sidebar.flatMap((group) => group.items); +} + +export function getAdjacentDocs(currentSlug: string) { + const flat = flattenSidebar(); + const idx = flat.findIndex((item) => item.slug === currentSlug); + return { + prev: idx > 0 ? flat[idx - 1] : null, + next: idx < flat.length - 1 ? flat[idx + 1] : null, + }; +} + +export function getBreadcrumbs(currentSlug: string) { + for (const group of docsConfig.sidebar) { + const item = group.items.find((i) => i.slug === currentSlug); + if (item) { + return [ + { title: "Docs", slug: "" }, + { title: group.title, slug: group.items[0]?.slug ?? "" }, + { title: item.title, slug: item.slug }, + ]; + } + } + return [{ title: "Docs", slug: "" }]; +} diff --git a/apps/web/app/(docs)/docs/layout.tsx b/apps/web/app/(docs)/docs/layout.tsx new file mode 100644 index 00000000000..854ba586a7f --- /dev/null +++ b/apps/web/app/(docs)/docs/layout.tsx @@ -0,0 +1,25 @@ +import type { PropsWithChildren } from "react"; +import { getDocSearchIndex } from "@/utils/docs"; +import { DocsHeader } from "./_components/DocsHeader"; +import { DocsMobileMenu } from "./_components/DocsMobileMenu"; +import { DocsSearch } from "./_components/DocsSearch"; +import { DocsSidebar } from "./_components/DocsSidebar"; +import { docsConfig } from "./docs-config"; + +export default function DocsLayout(props: PropsWithChildren) { + const searchIndex = getDocSearchIndex(docsConfig.sidebar); + + return ( +
+ + +
+ + +
{props.children}
+
+
+ ); +} diff --git a/apps/web/app/(docs)/layout.tsx b/apps/web/app/(docs)/layout.tsx new file mode 100644 index 00000000000..3d1a59d3212 --- /dev/null +++ b/apps/web/app/(docs)/layout.tsx @@ -0,0 +1,5 @@ +import type { PropsWithChildren } from "react"; + +export default function DocsRootLayout(props: PropsWithChildren) { + return <>{props.children}; +} From 4b9c17545d6e5cdf2f8b8b179ce07678307c4d7c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:13:57 +0000 Subject: [PATCH 14/81] feat(web): add docs content utility for MDX loading --- apps/web/utils/docs.ts | 145 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 apps/web/utils/docs.ts diff --git a/apps/web/utils/docs.ts b/apps/web/utils/docs.ts new file mode 100644 index 00000000000..3ba2367fffb --- /dev/null +++ b/apps/web/utils/docs.ts @@ -0,0 +1,145 @@ +import fs from "node:fs"; +import path from "node:path"; + +export interface DocMetadata { + title: string; + summary: string; + description?: string; + tags?: string; + image?: string; +} + +export interface Doc { + metadata: DocMetadata; + slug: string; + content: string; +} + +export interface DocHeading { + level: number; + text: string; + slug: string; +} + +function parseFrontmatter(fileContent: string) { + const frontmatterRegex = /---\s*([\s\S]*?)\s*---/; + const match = frontmatterRegex.exec(fileContent); + if (!match?.[1]) { + throw new Error("Invalid or missing frontmatter"); + } + + const frontMatterBlock = match[1]; + const content = fileContent.replace(frontmatterRegex, "").trim(); + const frontMatterLines = frontMatterBlock.trim().split("\n"); + const metadata: Partial = {}; + + for (const line of frontMatterLines) { + const [key, ...valueArr] = line.split(": "); + if (!key) continue; + let value = valueArr.join(": ").trim(); + value = value.replace(/^['"](.*)['"]$/, "$1"); + (metadata as Record)[key.trim()] = value; + } + + return { metadata: metadata as DocMetadata, content }; +} + +function getMDXFiles(dir: string): string[] { + const files: string[] = []; + + function scanDir(currentDir: string) { + const entries = fs.readdirSync(currentDir); + for (const entry of entries) { + const fullPath = path.join(currentDir, entry); + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + scanDir(fullPath); + } else if (path.extname(entry) === ".mdx") { + files.push(path.relative(dir, fullPath)); + } + } + } + + scanDir(dir); + return files; +} + +const docsDir = path.join(process.cwd(), "content/docs"); + +export function getAllDocs(): Doc[] { + const mdxFiles = getMDXFiles(docsDir); + return mdxFiles.map((relativePath) => { + const fullPath = path.join(docsDir, relativePath); + const { metadata, content } = parseFrontmatter( + fs.readFileSync(fullPath, "utf-8"), + ); + const slug = relativePath + .replace(/\.mdx$/, "") + .split(path.sep) + .join("/"); + return { metadata, slug, content }; + }); +} + +export function getDocBySlug(slug: string): Doc | undefined { + return getAllDocs().find((doc) => doc.slug === slug); +} + +export function extractHeadings(content: string): DocHeading[] { + const headingRegex = /^(#{2,3})\s+(.+)$/gm; + const headings: DocHeading[] = []; + for (const match of content.matchAll(headingRegex)) { + const level = match[1]!.length; + const text = match[2]!.trim(); + const slug = text + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/&/g, "-and-") + .replace(/[^\w-]+/g, "") + .replace(/--+/g, "-"); + headings.push({ level, text, slug }); + } + return headings; +} + +export function getDocSearchIndex( + sidebar: Array<{ + title: string; + items: Array<{ slug: string; title: string }>; + }>, +): Array<{ + slug: string; + title: string; + summary: string; + content: string; + group: string; +}> { + const docs = getAllDocs(); + + return docs.map((doc) => { + let group = ""; + for (const section of sidebar) { + if (section.items.some((item) => item.slug === doc.slug)) { + group = section.title; + break; + } + } + + const plainContent = doc.content + .replace(/---[\s\S]*?---/, "") + .replace(/```[\s\S]*?```/g, "") + .replace(/<[^>]+>/g, "") + .replace(/[#*`[\]()]/g, "") + .replace(/\n+/g, " ") + .trim() + .slice(0, 500); + + return { + slug: doc.slug, + title: doc.metadata.title, + summary: doc.metadata.summary || "", + content: plainContent, + group, + }; + }); +} From a19b18b6ba5ed0785bf1437a4f44e9b3bf9f565e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:14:01 +0000 Subject: [PATCH 15/81] feat(web): enhance MDX with syntax highlighting and CapEmbed --- apps/web/components/mdx.tsx | 35 +++++++++++++++++++++++++++++++++++ apps/web/package.json | 2 ++ 2 files changed, 37 insertions(+) diff --git a/apps/web/components/mdx.tsx b/apps/web/components/mdx.tsx index ef9918ded49..090d2f2acd4 100644 --- a/apps/web/components/mdx.tsx +++ b/apps/web/components/mdx.tsx @@ -2,6 +2,9 @@ import Image from "next/image"; import Link from "next/link"; import { MDXRemote } from "next-mdx-remote/rsc"; import React, { type ReactNode } from "react"; +import type { Options as RehypePrettyCodeOptions } from "rehype-pretty-code"; +import rehypePrettyCode from "rehype-pretty-code"; +import remarkGfm from "remark-gfm"; interface TableData { headers: string[]; @@ -105,6 +108,26 @@ function Warning(props: WarningProps) { ); } +interface CapEmbedProps { + src: string; +} + +function CapEmbed({ src }: CapEmbedProps) { + return ( + +``` + +Replace `[video-id]` with your actual video ID. + +## Responsive Embed + +To make your embed responsive and maintain the correct aspect ratio across screen sizes, wrap the iframe in a container with a percentage-based padding: + +```html +
+ +
+``` + +The `padding-bottom: 56.25%` value creates a 16:9 aspect ratio container. Adjust this value if your recordings use a different aspect ratio: + +| Aspect Ratio | Padding Bottom | +|--------------|---------------| +| 16:9 | 56.25% | +| 4:3 | 75% | +| 21:9 | 42.86% | +| 1:1 | 100% | + +## Embed in MDX / React + +If you are using MDX or a React-based site (Next.js, Gatsby, Docusaurus), you can embed directly with JSX: + +```jsx +
+ +
+``` + +## Troubleshooting + +**Embed is not loading**: Verify the video ID is correct and the recording has not been deleted. Check that the embed URL uses `https://`. + +**Embed is blocked**: Some corporate networks or browser extensions block iframes. Ask the viewer to try a different network or disable iframe-blocking extensions. + +**Aspect ratio looks wrong**: Adjust the `padding-bottom` percentage in the responsive container to match your recording's actual aspect ratio. + +**Password-protected videos**: Embeds of password-protected recordings will show a password prompt. The viewer must enter the correct password to watch. diff --git a/apps/web/content/docs/sharing/share-a-cap.mdx b/apps/web/content/docs/sharing/share-a-cap.mdx new file mode 100644 index 00000000000..570f79d872c --- /dev/null +++ b/apps/web/content/docs/sharing/share-a-cap.mdx @@ -0,0 +1,124 @@ +--- +title: "Share a Cap" +summary: "Share your recordings with a link" +tags: "Sharing" +--- + +Every Cap recording gets a unique shareable link. This guide covers how sharing works, how to customize your share settings, and what viewers see when they open your link. + +## How Sharing Works + +When you finish a recording in Instant Mode, Cap automatically generates a shareable link and copies it to your clipboard. In Studio Mode, the link is generated after you finish editing and upload the recording. + +The link follows this format: + +``` +https://cap.so/s/[video-id] +``` + +If you are self-hosting Cap, the link uses your custom domain instead. + +Anyone with the link can watch your recording in their browser. No account or download is required to view a shared Cap. + +## Copying the Link + +After recording, the link is automatically copied to your clipboard. You can also find and copy the link from: + +- **Cap Desktop** - Click the share icon on any recording in your library. +- **Cap Dashboard** - Navigate to [Cap.so/dashboard](https://cap.so/dashboard), find your recording, and click the share button. + +## Customizing Share Settings + +You can customize how your shared recordings appear and control who can access them. + +### Title and Description + +Give your recording a descriptive title so viewers know what they are about to watch: + +1. Open your [Cap dashboard](https://cap.so/dashboard). +2. Click on the recording you want to update. +3. Edit the title field at the top of the page. + +A good title helps viewers find your recording later and sets expectations before they press play. + +### Password Protection + +Restrict access to your recording by setting a password: + +1. Open the recording's settings on your dashboard. +2. Enable **Password Protection**. +3. Enter a password. +4. Save your changes. + +When viewers open the link, they will be prompted to enter the password before they can watch. This is useful for sensitive content, internal demos, or client recordings. + +## The Viewer Experience + +When someone opens your shared link, they see a clean video player page with: + +### Video Player + +- High-quality video playback +- Play, pause, and scrub controls +- Fullscreen mode +- Adjustable playback speed (0.5x, 0.75x, 1x, 1.25x, 1.5x, 1.75x, 2x) +- Volume control + +### AI Captions + +Cap automatically generates captions for recordings that include audio. Viewers can: + +- Toggle captions on and off +- Read along as the video plays +- Captions are synced to the audio timeline + +### Comments + +Viewers can leave timestamped comments on your recording. See [Comments](/docs/sharing/comments) for more details. + +### Download + +Viewers can download the video file directly from the share page. + +## Sharing to Different Platforms + +### Slack and Teams + +Paste your Cap link directly into a Slack or Microsoft Teams message. Most platforms will show a rich preview with the recording title and thumbnail. + +### Email + +Include the Cap link in any email. Recipients click the link to watch in their browser. + +### GitHub and GitLab + +Paste the link in issues, pull requests, or comments. This is great for bug reports, feature demonstrations, and code review walkthroughs. + +### Documentation + +Embed your recordings directly in documentation sites using an iframe. See [Embeds](/docs/sharing/embeds) for embed instructions. + +### Social Media + +Share the link on Twitter/X, LinkedIn, or any social platform. The link will display a preview card with your recording's thumbnail and title. + +## Managing Shared Recordings + +### Viewing All Recordings + +Your [Cap dashboard](https://cap.so/dashboard) shows all of your recordings with their share links, view counts, and creation dates. Use the dashboard to manage, organize, and review your content. + +### Deleting a Recording + +To remove a shared recording: + +1. Open your [Cap dashboard](https://cap.so/dashboard). +2. Find the recording you want to delete. +3. Click the menu icon and select **Delete**. +4. Confirm the deletion. + +Once deleted, the share link will no longer work and viewers will see a "not found" page. + +### Revoking Access + +If you need to quickly restrict access to a recording without deleting it, enable password protection. Existing viewers without the password will no longer be able to watch it. From f8b63f832adc9a1d58d717cfcd5b6986ac744a24 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:14:06 +0000 Subject: [PATCH 17/81] docs(web): update self-hosting guide with notes and CapEmbed --- apps/web/content/docs/self-hosting.mdx | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/apps/web/content/docs/self-hosting.mdx b/apps/web/content/docs/self-hosting.mdx index 7b776436531..3f4b6370fbe 100644 --- a/apps/web/content/docs/self-hosting.mdx +++ b/apps/web/content/docs/self-hosting.mdx @@ -5,24 +5,10 @@ tags: "Deployment, Self-hosting" image: "/docs/self-hosting.webp" --- -Cap Web is our web application for uploading and sharing Caps - it's what runs right here on [cap.so](https://cap.so). +Cap Web is our web application for uploading and sharing Caps - it's what runs right here on [Cap.so](https://cap.so). You can upload videos to it from the dashboard or from Cap Desktop. -
- -
+ ## Quick Start (One Command) @@ -62,6 +48,8 @@ Best for VPS, home servers, or any Docker-capable host. 2. Run `docker compose up -d` 3. Access Cap at `http://localhost:3000` +> **Note:** The main `docker-compose.yml` builds the media server from source, so a full clone of the repository is required. The Coolify compose file uses a pre-built image from `ghcr.io` instead. + **Custom configuration:** Create a `.env` file to customize your deployment: @@ -99,6 +87,8 @@ For Coolify users, use `docker-compose.coolify.yml` which includes environment v 4. Configure environment variables in Coolify's UI 5. Deploy +> **Note:** The Coolify compose file uses slightly different environment variable names: `WEB_URL` instead of `CAP_URL`, and `S3_PUBLIC_ENDPOINT` instead of `S3_PUBLIC_URL`. + ## Connecting Cap Desktop 1. Open Cap Desktop settings From cc26fada3ce0048635e182c50bf40c27c3bca438 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:14:10 +0000 Subject: [PATCH 18/81] refactor(web): remove old docs pages from site layout --- apps/web/app/(site)/docs/[...slug]/page.tsx | 201 ------------------ apps/web/app/(site)/docs/[slug]/page.tsx | 84 -------- .../app/(site)/docs/_components/DocPage.tsx | 37 ---- apps/web/app/(site)/docs/page.tsx | 10 - 4 files changed, 332 deletions(-) delete mode 100644 apps/web/app/(site)/docs/[...slug]/page.tsx delete mode 100644 apps/web/app/(site)/docs/[slug]/page.tsx delete mode 100644 apps/web/app/(site)/docs/_components/DocPage.tsx delete mode 100644 apps/web/app/(site)/docs/page.tsx diff --git a/apps/web/app/(site)/docs/[...slug]/page.tsx b/apps/web/app/(site)/docs/[...slug]/page.tsx deleted file mode 100644 index 157589f8f99..00000000000 --- a/apps/web/app/(site)/docs/[...slug]/page.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { buildEnv } from "@cap/env"; -import type { Metadata } from "next"; -import Image from "next/image"; -import Link from "next/link"; -import { notFound } from "next/navigation"; -import { CustomMDX } from "@/components/mdx"; -import type { DocMetadata } from "@/utils/blog"; -import { getDocs } from "@/utils/blog"; - -type Doc = { - metadata: DocMetadata; - slug: string; - content: string; -}; - -interface DocProps { - params: Promise<{ - slug: string[]; - }>; -} - -export async function generateMetadata( - props: DocProps, -): Promise { - const params = await props.params; - if (!params?.slug) return; - - const fullSlug = params.slug.join("/"); - - // If it's a category page - if (params.slug.length === 1) { - const category = params.slug[0]; - if (!category) return; - - const displayCategory = category - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); - - return { - title: `${displayCategory} Documentation - Cap`, - description: `Documentation for ${displayCategory} in Cap`, - }; - } - - // If it's a doc page - const allDocs = getDocs() as Doc[]; - const doc = allDocs.find((doc) => doc.slug === fullSlug); - if (!doc) return; - - const { title, summary, image } = doc.metadata; - const ogImage = image ? `${buildEnv.NEXT_PUBLIC_WEB_URL}${image}` : undefined; - const description = summary || title; - - return { - title, - description, - openGraph: { - title, - description, - type: "article", - url: `${buildEnv.NEXT_PUBLIC_WEB_URL}/docs/${fullSlug}`, - ...(ogImage && { - images: [{ url: ogImage }], - }), - }, - twitter: { - card: "summary_large_image", - title, - description, - ...(ogImage && { images: [ogImage] }), - }, - }; -} - -export default async function DocPage(props: DocProps) { - const params = await props.params; - if (!params?.slug) notFound(); - - const fullSlug = params.slug.join("/"); - const allDocs = getDocs() as Doc[]; - - // Handle category pages (e.g., /docs/s3-config) - if (params.slug.length === 1) { - const category = params.slug[0]; - if (!category) notFound(); - - // Find docs that either: - // 1. Have a slug that exactly matches the category, or - // 2. Have a slug that starts with category/ - const categoryDocs = allDocs - .filter( - (doc) => doc.slug === category || doc.slug.startsWith(`${category}/`), - ) - .sort((a, b) => { - // Sort by depth (root level first) - const aDepth = a.slug.split("/").length; - const bDepth = b.slug.split("/").length; - if (aDepth !== bDepth) return aDepth - bDepth; - - // Then by title - return a.metadata.title.localeCompare(b.metadata.title); - }); - - if (categoryDocs.length === 0) { - notFound(); - } - - // Format the category name for display - const displayCategory = category - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); - - // Find the root category doc if it exists - const rootDoc = categoryDocs.find((doc) => doc.slug === category); - - return ( -
-

{displayCategory} Documentation

- {/* Show root category content if it exists */} - {rootDoc && ( -
- -
-
- )} - {/* Show subcategory docs */} - {categoryDocs.length > (rootDoc ? 1 : 0) && ( - <> -

Available Guides

-
- {categoryDocs - .filter((doc) => doc.slug !== category) - .map((doc) => ( - -
-

{doc.metadata.title}

- {doc.metadata.summary && ( -

- {doc.metadata.summary} -

- )} - {doc.metadata.tags && ( -
- {doc.metadata.tags.split(", ").map((tag) => ( - - {tag} - - ))} -
- )} -
- - ))} -
- - )} -
- ); - } - - // Handle individual doc pages - const doc = allDocs.find((doc) => doc.slug === fullSlug); - - if (!doc) { - notFound(); - } - - return ( -
- {doc.metadata.image && ( -
- {doc.metadata.title} -
- )} -
-
-

{doc.metadata.title}

-
-
- -
-
- ); -} diff --git a/apps/web/app/(site)/docs/[slug]/page.tsx b/apps/web/app/(site)/docs/[slug]/page.tsx deleted file mode 100644 index 31575d0d296..00000000000 --- a/apps/web/app/(site)/docs/[slug]/page.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { buildEnv } from "@cap/env"; -import type { Metadata } from "next"; -import Image from "next/image"; -import { notFound } from "next/navigation"; -import { CustomMDX } from "@/components/mdx"; -import { getDocs } from "@/utils/blog"; - -interface DocProps { - params: Promise<{ - slug: string; - }>; -} - -export async function generateMetadata( - props: DocProps, -): Promise { - const params = await props.params; - const doc = getDocs().find((doc) => doc.slug === params.slug); - if (!doc) { - return; - } - - const { title, summary: description, image } = doc.metadata; - const ogImage = image ? `${buildEnv.NEXT_PUBLIC_WEB_URL}${image}` : undefined; - - return { - title, - description, - openGraph: { - title, - description, - type: "article", - url: `${buildEnv.NEXT_PUBLIC_WEB_URL}/docs/${doc.slug}`, - ...(ogImage && { - images: [ - { - url: ogImage, - }, - ], - }), - }, - twitter: { - card: "summary_large_image", - title, - description, - ...(ogImage && { images: [ogImage] }), - }, - }; -} - -export default async function DocPage(props: DocProps) { - const params = await props.params; - const doc = getDocs().find((doc) => doc.slug === params.slug); - - if (!doc) { - notFound(); - } - - return ( -
- {doc.metadata.image && ( -
- {doc.metadata.title} -
- )} - -
-
-

{doc.metadata.title}

-
-
- -
-
- ); -} diff --git a/apps/web/app/(site)/docs/_components/DocPage.tsx b/apps/web/app/(site)/docs/_components/DocPage.tsx deleted file mode 100644 index 442d83df0a9..00000000000 --- a/apps/web/app/(site)/docs/_components/DocPage.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import Image from "next/image"; -import { MDXRemote } from "next-mdx-remote/rsc"; -import { getDocs } from "@/utils/blog"; - -export const DocPage = ({ docSlug }: { docSlug: string }) => { - const doc = getDocs().find((doc) => doc.slug === docSlug); - - if (!doc) { - return null; - } - - return ( -
- {doc.metadata.image && ( -
- {doc.metadata.title} -
- )} - -
-

{doc.metadata.title}

-
-
- -
- ); -}; diff --git a/apps/web/app/(site)/docs/page.tsx b/apps/web/app/(site)/docs/page.tsx deleted file mode 100644 index 252666b8fa1..00000000000 --- a/apps/web/app/(site)/docs/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { Metadata } from "next"; -import { DocsPage } from "@/components/pages/DocsPage"; - -export const metadata: Metadata = { - title: "Documentation — Cap", -}; - -export default function App() { - return ; -} From b79b2234b67e5c6fa6692fcf1bf86215068ca43f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:14:11 +0000 Subject: [PATCH 19/81] style(web): fix not-prose anchor color inheritance --- apps/web/app/globals.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 39baa1e6346..20a677a014d 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -783,7 +783,8 @@ footer a { } .prose .not-prose p, -.prose .not-prose li { +.prose .not-prose li, +.prose .not-prose a { color: inherit !important; font-size: inherit !important; line-height: inherit !important; From b7d0ce650a005f6383100d2eae0eac0bbcddfa02 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:14:15 +0000 Subject: [PATCH 20/81] feat(sdk): add @cap/sdk-embed package --- packages/sdk-embed/package.json | 30 ++++++++++++++ packages/sdk-embed/src/index.ts | 2 + packages/sdk-embed/src/react/CapEmbed.tsx | 37 +++++++++++++++++ packages/sdk-embed/src/react/index.ts | 1 + packages/sdk-embed/src/types.ts | 10 +++++ .../sdk-embed/src/vanilla/cap-embed-loader.ts | 20 +++++++++ packages/sdk-embed/src/vanilla/cap-embed.ts | 41 +++++++++++++++++++ packages/sdk-embed/tsconfig.json | 17 ++++++++ 8 files changed, 158 insertions(+) create mode 100644 packages/sdk-embed/package.json create mode 100644 packages/sdk-embed/src/index.ts create mode 100644 packages/sdk-embed/src/react/CapEmbed.tsx create mode 100644 packages/sdk-embed/src/react/index.ts create mode 100644 packages/sdk-embed/src/types.ts create mode 100644 packages/sdk-embed/src/vanilla/cap-embed-loader.ts create mode 100644 packages/sdk-embed/src/vanilla/cap-embed.ts create mode 100644 packages/sdk-embed/tsconfig.json diff --git a/packages/sdk-embed/package.json b/packages/sdk-embed/package.json new file mode 100644 index 00000000000..01ac8ffbbc0 --- /dev/null +++ b/packages/sdk-embed/package.json @@ -0,0 +1,30 @@ +{ + "name": "@cap/sdk-embed", + "version": "0.1.0", + "description": "Cap Embedding SDK for third-party integrations", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./react": "./src/react/index.ts", + "./vanilla": "./src/vanilla/cap-embed-loader.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": {}, + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.7.3" + } +} diff --git a/packages/sdk-embed/src/index.ts b/packages/sdk-embed/src/index.ts new file mode 100644 index 00000000000..f26bd4ede65 --- /dev/null +++ b/packages/sdk-embed/src/index.ts @@ -0,0 +1,2 @@ +export type { EmbedOptions } from "./types"; +export { createEmbedUrl } from "./vanilla/cap-embed"; diff --git a/packages/sdk-embed/src/react/CapEmbed.tsx b/packages/sdk-embed/src/react/CapEmbed.tsx new file mode 100644 index 00000000000..e8fb2e4a2df --- /dev/null +++ b/packages/sdk-embed/src/react/CapEmbed.tsx @@ -0,0 +1,37 @@ +import type { CSSProperties } from "react"; +import type { EmbedOptions } from "../types"; +import { createEmbedUrl } from "../vanilla/cap-embed"; + +interface CapEmbedProps extends EmbedOptions { + className?: string; + style?: CSSProperties; + width?: string | number; + height?: string | number; +} + +export function CapEmbed({ + className, + style, + width = "100%", + height = "100%", + ...options +}: CapEmbedProps) { + const src = createEmbedUrl(options); + + return ( +