diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a2183488f..16ce070632 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,28 @@ concurrency: cancel-in-progress: true jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + outputs: + rust: ${{ steps.filter.outputs.rust }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check for changes + uses: dorny/paths-filter@v2 + id: filter + with: + filters: | + rust: + - '.cargo/**' + - '.github/**' + - 'crates/**' + - 'desktop/src-tauri/**' + - 'Cargo.toml' + - 'Cargo.lock' + typecheck: name: Typecheck runs-on: ubuntu-latest @@ -38,6 +60,8 @@ jobs: clippy: name: Clippy runs-on: macos-latest + needs: changes + if: needs.changes.outputs.rust == 'true' permissions: contents: read steps: diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 11df6dd647..84857e8036 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -54,7 +54,7 @@ "@ts-rest/core": "^3.52.1", "@types/react-tooltip": "^4.2.4", "cva": "npm:class-variance-authority@^0.7.0", - "effect": "^3.7.2", + "effect": "^3.17.7", "mp4box": "^0.5.2", "posthog-js": "^1.215.3", "solid-js": "^1.9.3", diff --git a/apps/web/README.md b/apps/web/README.md index 43049f3c99..869aace954 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,7 +1,4 @@ -# Cap Web App +# `@cap/web` -More details, as well as a contributor guide, will be posted soon. - -## Illustrations - -A big thank you to Popsy (https://popsy.co) for some of the illustrations used in Cap. +Cap's NextJS web app for video sharing. +Used for both self hosting and on [cap.so](https://cap.so). diff --git a/apps/web/actions/folders/deleteFolder.ts b/apps/web/actions/folders/deleteFolder.ts deleted file mode 100644 index aca5ec71b7..0000000000 --- a/apps/web/actions/folders/deleteFolder.ts +++ /dev/null @@ -1,51 +0,0 @@ -"use server"; - -import { db } from "@cap/database"; -import { folders, videos, spaceVideos } from "@cap/database/schema"; -import { eq, and } from "drizzle-orm"; -import { revalidatePath } from "next/cache"; -import { getFolderById } from "./getFolderById"; - -export async function deleteFolder(folderId: string, spaceId?: string | null) { - if (!folderId) throw new Error("Folder ID is required"); - - // Get the folder to find its parent - const folder = await getFolderById(folderId); - const parentId = folder.parentId ?? null; - - // Recursively delete all child folders first - const childFolders = await db() - .select({ id: folders.id }) - .from(folders) - .where(eq(folders.parentId, folderId)); - for (const child of childFolders) { - await deleteFolder(child.id); - } - - // Always update videos.folderId so videos move up to parent folder - await db() - .update(videos) - .set({ folderId: parentId }) - .where(eq(videos.folderId, folderId)); - - // If spaceId is provided, also update spaceVideos.folderId for consistency - if (spaceId) { - await db() - .update(spaceVideos) - .set({ folderId: parentId }) - .where( - and( - eq(spaceVideos.folderId, folderId), - eq(spaceVideos.spaceId, spaceId) - ) - ); - } - - // Delete the folder itself - await db().delete(folders).where(eq(folders.id, folderId)); - if (spaceId) { - revalidatePath(`/dashboard/spaces/${spaceId}`); - } else { - revalidatePath(`/dashboard/caps`); - } -} diff --git a/apps/web/actions/folders/duplicateFolder.ts b/apps/web/actions/folders/duplicateFolder.ts deleted file mode 100644 index faa43fd0a6..0000000000 --- a/apps/web/actions/folders/duplicateFolder.ts +++ /dev/null @@ -1,107 +0,0 @@ -"use server"; - -import { db } from "@cap/database"; -import { folders, videos, s3Buckets } from "@cap/database/schema"; -import { eq } from "drizzle-orm"; -import { nanoId } from "@cap/database/helpers"; -import { revalidatePath } from "next/cache"; -import { getFolderById } from "./getFolderById"; - -export async function duplicateFolder( - folderId: string, - parentId?: string | null, - spaceId?: string | null -): Promise { - if (!folderId) throw new Error("Folder ID is required"); - - // Get the folder to duplicate - const folder = await getFolderById(folderId); - if (!folder) throw new Error("Folder not found"); - - // Create the duplicated folder - const newFolderId = nanoId(); - const now = new Date(); - const newFolder = { - id: newFolderId, - name: folder.name, - color: folder.color, - organizationId: folder.organizationId, - createdById: folder.createdById, - createdAt: now, - updatedAt: now, - parentId: parentId ?? null, - spaceId: spaceId ?? null, - }; - await db().insert(folders).values(newFolder); - - // Duplicate all videos in this folder - const videosInFolder = await db() - .select() - .from(videos) - .where(eq(videos.folderId, folderId)); - for (const video of videosInFolder) { - const newVideoId = nanoId(); - await db() - .insert(videos) - .values({ - ...video, - id: newVideoId, - folderId: newFolderId, - createdAt: now, - updatedAt: now, - }); - - // --- S3 Asset Duplication --- - // Copy all S3 objects from old video to new video - try { - const { createBucketProvider } = await import("@/utils/s3"); - let bucketProvider = null; - let prefix: string | null = null; - let newPrefix: string | null = null; - if (video.bucket) { - // Modern: use custom bucket - const [bucketRow] = await db() - .select() - .from(s3Buckets) - .where(eq(s3Buckets.id, video.bucket)); - if (bucketRow) { - bucketProvider = await createBucketProvider(bucketRow); - prefix = `${video.ownerId}/${video.id}/`; - newPrefix = `${video.ownerId}/${newVideoId}/`; - } - } else if (video.awsBucket) { - // Legacy: use global/default bucket - bucketProvider = await createBucketProvider(); // No arg = default/global bucket - prefix = `${video.ownerId}/${video.id}/`; - newPrefix = `${video.ownerId}/${newVideoId}/`; - } - if (bucketProvider && prefix && newPrefix) { - const objects = await bucketProvider.listObjects({ prefix }); - if (objects.Contents) { - for (const obj of objects.Contents) { - if (!obj.Key) continue; - const newKey = obj.Key.replace(prefix, newPrefix); - await bucketProvider.copyObject( - `${bucketProvider.name}/${obj.Key}`, - newKey - ); - } - } - } - } catch (err) { - console.error("Failed to copy S3 assets for duplicated video", err); - } - } - - // Recursively duplicate all child folders - const childFolders = await db() - .select() - .from(folders) - .where(eq(folders.parentId, folderId)); - for (const child of childFolders) { - await duplicateFolder(child.id, newFolderId); - } - - revalidatePath(`/dashboard/caps`); - return newFolderId; -} diff --git a/apps/web/actions/folders/getChildFolders.ts b/apps/web/actions/folders/getChildFolders.ts deleted file mode 100644 index c822643469..0000000000 --- a/apps/web/actions/folders/getChildFolders.ts +++ /dev/null @@ -1,37 +0,0 @@ -"use server"; - -import { db } from "@cap/database"; -import { folders } from "@cap/database/schema"; -import { eq, and } from "drizzle-orm"; -import { getCurrentUser } from "@cap/database/auth/session"; -import { sql } from "drizzle-orm/sql"; -import { revalidatePath } from "next/cache"; - -export async function getChildFolders(folderId: string) { - const user = await getCurrentUser(); - if (!user || !user.activeOrganizationId) - throw new Error("Unauthorized or no active organization"); - - const childFolders = await db() - .select({ - id: folders.id, - name: folders.name, - color: folders.color, - parentId: folders.parentId, - organizationId: folders.organizationId, - videoCount: sql`( - SELECT COUNT(*) FROM videos WHERE videos.folderId = folders.id - )`, - }) - .from(folders) - .where( - and( - eq(folders.parentId, folderId), - eq(folders.organizationId, user.activeOrganizationId) - ) - ); - - revalidatePath(`/dashboard/folder/${folderId}`); - - return childFolders; -} diff --git a/apps/web/actions/folders/getFolderBreadcrumb.ts b/apps/web/actions/folders/getFolderBreadcrumb.ts deleted file mode 100644 index 9f15646ca1..0000000000 --- a/apps/web/actions/folders/getFolderBreadcrumb.ts +++ /dev/null @@ -1,30 +0,0 @@ -"use server"; - -import { revalidatePath } from "next/cache"; -import { getFolderById } from "./getFolderById"; - -export async function getFolderBreadcrumb(folderId: string) { - const breadcrumb: Array<{ - id: string; - name: string; - color: "normal" | "blue" | "red" | "yellow"; - }> = []; - let currentFolderId = folderId; - - while (currentFolderId) { - const folder = await getFolderById(currentFolderId); - if (!folder) break; - - breadcrumb.unshift({ - id: folder.id, - name: folder.name, - color: folder.color, - }); - - if (!folder.parentId) break; - currentFolderId = folder.parentId; - } - - revalidatePath(`/dashboard/folder/${folderId}`); - return breadcrumb; -} diff --git a/apps/web/actions/folders/getFolderById.ts b/apps/web/actions/folders/getFolderById.ts deleted file mode 100644 index e7827e31f3..0000000000 --- a/apps/web/actions/folders/getFolderById.ts +++ /dev/null @@ -1,20 +0,0 @@ -"use server"; - -import { db } from "@cap/database"; -import { folders } from "@cap/database/schema"; -import { eq } from "drizzle-orm"; -import { revalidatePath } from "next/cache"; - -export async function getFolderById(folderId: string | undefined) { - if (!folderId) throw new Error("Folder ID is required"); - - const [folder] = await db() - .select() - .from(folders) - .where(eq(folders.id, folderId)); - - if (!folder) throw new Error("Folder not found"); - - revalidatePath(`/dashboard/folder/${folderId}`); - return folder; -} diff --git a/apps/web/actions/organization/get-organization.ts b/apps/web/actions/organization/get-organization-sso-data.ts similarity index 79% rename from apps/web/actions/organization/get-organization.ts rename to apps/web/actions/organization/get-organization-sso-data.ts index 69506fd452..1593d45295 100644 --- a/apps/web/actions/organization/get-organization.ts +++ b/apps/web/actions/organization/get-organization-sso-data.ts @@ -4,7 +4,7 @@ import { db } from "@cap/database"; import { organizations } from "@cap/database/schema"; import { eq } from "drizzle-orm"; -export async function getOrganization(organizationId: string) { +export async function getOrganizationSSOData(organizationId: string) { if (!organizationId) { throw new Error("Organization ID is required"); } @@ -18,7 +18,11 @@ export async function getOrganization(organizationId: string) { .from(organizations) .where(eq(organizations.id, organizationId)); - if (!organization || !organization.workosOrganizationId || !organization.workosConnectionId) { + if ( + !organization || + !organization.workosOrganizationId || + !organization.workosConnectionId + ) { throw new Error("Organization not found or SSO not configured"); } @@ -27,4 +31,4 @@ export async function getOrganization(organizationId: string) { connectionId: organization.workosConnectionId, name: organization.name, }; -} \ No newline at end of file +} diff --git a/apps/web/actions/organization/update-space.ts b/apps/web/actions/organization/update-space.ts index bd36922476..5139ffab7e 100644 --- a/apps/web/actions/organization/update-space.ts +++ b/apps/web/actions/organization/update-space.ts @@ -3,7 +3,7 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { spaces, spaceMembers } from "@cap/database/schema"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { uploadSpaceIcon } from "./upload-space-icon"; import { v4 as uuidv4 } from "uuid"; @@ -19,6 +19,13 @@ export async function updateSpace(formData: FormData) { const members = formData.getAll("members[]") as string[]; const iconFile = formData.get("icon") as File | null; + const [membership] = await db() + .select() + .from(spaceMembers) + .where(and(eq(spaceMembers.spaceId, id), eq(spaceMembers.userId, user.id))); + + if (!membership) return { success: false, error: "Unauthorized" }; + // Update space name await db().update(spaces).set({ name }).where(eq(spaces.id, id)); diff --git a/apps/web/actions/videos/delete.ts b/apps/web/actions/videos/delete.ts deleted file mode 100644 index 66f573af26..0000000000 --- a/apps/web/actions/videos/delete.ts +++ /dev/null @@ -1,64 +0,0 @@ -"use server"; - -import { getCurrentUser } from "@cap/database/auth/session"; -import { s3Buckets, videos } from "@cap/database/schema"; -import { db } from "@cap/database"; -import { and, eq } from "drizzle-orm"; -import { createBucketProvider } from "@/utils/s3"; - -export async function deleteVideo(videoId: string) { - try { - const user = await getCurrentUser(); - const userId = user?.id; - - if (!videoId || !userId) { - return { - success: false, - message: "Missing required data", - }; - } - - const query = await db() - .select({ video: videos, bucket: s3Buckets }) - .from(videos) - .leftJoin(s3Buckets, eq(videos.bucket, s3Buckets.id)) - .where(eq(videos.id, videoId)); - - if (!query[0]) { - return { - success: false, - message: "Video not found", - }; - } - - await db() - .delete(videos) - .where(and(eq(videos.id, videoId), eq(videos.ownerId, userId))); - - const bucket = await createBucketProvider(query[0].bucket); - const prefix = `${userId}/${videoId}/`; - - const listedObjects = await bucket.listObjects({ - prefix: prefix, - }); - - if (listedObjects.Contents?.length) { - await bucket.deleteObjects( - listedObjects.Contents.map((content) => ({ - Key: content.Key, - })) - ); - } - - return { - success: true, - message: "Video deleted successfully", - }; - } catch (error) { - console.error("Error deleting video:", error); - return { - success: false, - message: "Failed to delete video", - }; - } -} diff --git a/apps/web/actions/videos/duplicate.ts b/apps/web/actions/videos/duplicate.ts deleted file mode 100644 index ba9670d361..0000000000 --- a/apps/web/actions/videos/duplicate.ts +++ /dev/null @@ -1,74 +0,0 @@ -"use server"; - -import { db } from "@cap/database"; -import { videos, s3Buckets } from "@cap/database/schema"; -import { eq } from "drizzle-orm"; -import { revalidatePath } from "next/cache"; - -import { nanoId } from "@cap/database/helpers"; - -export async function duplicateVideo(videoId: string): Promise { - if (!videoId) throw new Error("Video ID is required"); - - // Get the video - const [video] = await db() - .select() - .from(videos) - .where(eq(videos.id, videoId)); - if (!video) throw new Error("Video not found"); - - const newVideoId = nanoId(); - const now = new Date(); - - // Insert the duplicated video - await db() - .insert(videos) - .values({ - ...video, - id: newVideoId, - createdAt: now, - updatedAt: now, - }); - - // Copy S3 assets - try { - const { createBucketProvider } = await import("@/utils/s3"); - let bucketProvider = null; - let prefix: string | null = null; - let newPrefix: string | null = null; - if (video.bucket) { - const [bucketRow] = await db() - .select() - .from(s3Buckets) - .where(eq(s3Buckets.id, video.bucket)); - if (bucketRow) { - bucketProvider = await createBucketProvider(bucketRow); - prefix = `${video.ownerId}/${video.id}/`; - newPrefix = `${video.ownerId}/${newVideoId}/`; - } - } else if (video.awsBucket) { - bucketProvider = await createBucketProvider(); - prefix = `${video.ownerId}/${video.id}/`; - newPrefix = `${video.ownerId}/${newVideoId}/`; - } - if (bucketProvider && prefix && newPrefix) { - const objects = await bucketProvider.listObjects({ prefix }); - if (objects.Contents) { - for (const obj of objects.Contents) { - if (!obj.Key) continue; - const newKey = obj.Key.replace(prefix, newPrefix); - await bucketProvider.copyObject( - `${bucketProvider.name}/${obj.Key}`, - newKey - ); - } - } - } - } catch (err) { - console.error("Failed to copy S3 assets for duplicated video", err); - } - - revalidatePath("/dashboard/caps"); - - return newVideoId; -} diff --git a/apps/web/actions/videos/get-status.ts b/apps/web/actions/videos/get-status.ts index 5a036aa414..91ae12a46b 100644 --- a/apps/web/actions/videos/get-status.ts +++ b/apps/web/actions/videos/get-status.ts @@ -6,7 +6,7 @@ import { videos, users } from "@cap/database/schema"; import { VideoMetadata } from "@cap/database/types"; import { eq } from "drizzle-orm"; import { generateAiMetadata } from "./generate-ai-metadata"; -import { transcribeVideo } from "./transcribe"; +import { transcribeVideo } from "../../lib/transcribe"; import { isAiGenerationEnabled } from "@/utils/flags"; const MAX_AI_PROCESSING_TIME = 10 * 60 * 1000; @@ -21,7 +21,9 @@ export interface VideoStatusResult { error?: string; } -export async function getVideoStatus(videoId: string): Promise { +export async function getVideoStatus( + videoId: string +): Promise { const user = await getCurrentUser(); if (!user) { @@ -41,12 +43,17 @@ export async function getVideoStatus(videoId: string): Promise { - console.error(`[Get Status] Error starting transcription for video ${videoId}:`, error); + transcribeVideo(videoId, video.ownerId).catch((error) => { + console.error( + `[Get Status] Error starting transcription for video ${videoId}:`, + error + ); }); - + return { transcriptionStatus: "PROCESSING", aiProcessing: false, @@ -56,7 +63,10 @@ export async function getVideoStatus(videoId: string): Promise MAX_AI_PROCESSING_TIME) { - console.log(`[Get Status] AI processing appears stuck for video ${videoId} (${Math.round((currentTime - updatedAtTime) / 60000)} minutes), resetting flag`); - + console.log( + `[Get Status] AI processing appears stuck for video ${videoId} (${Math.round( + (currentTime - updatedAtTime) / 60000 + )} minutes), resetting flag` + ); + await db() .update(videos) - .set({ + .set({ metadata: { ...metadata, aiProcessing: false, - generationError: "AI processing timed out and was reset" - } + generationError: "AI processing timed out and was reset", + }, }) .where(eq(videos.id, videoId)); - - const updatedResult = await db().select().from(videos).where(eq(videos.id, videoId)); + + const updatedResult = await db() + .select() + .from(videos) + .where(eq(videos.id, videoId)); if (updatedResult.length > 0 && updatedResult[0]) { const updatedVideo = updatedResult[0]; - const updatedMetadata = updatedVideo.metadata as VideoMetadata || {}; - + const updatedMetadata = (updatedVideo.metadata as VideoMetadata) || {}; + return { - transcriptionStatus: (updatedVideo.transcriptionStatus as "PROCESSING" | "COMPLETE" | "ERROR") || null, + transcriptionStatus: + (updatedVideo.transcriptionStatus as + | "PROCESSING" + | "COMPLETE" + | "ERROR") || null, aiProcessing: false, aiTitle: updatedMetadata.aiTitle || null, summary: updatedMetadata.summary || null, chapters: updatedMetadata.chapters || null, generationError: updatedMetadata.generationError || null, - error: "AI processing timed out and was reset" + error: "AI processing timed out and was reset", }; } } } if ( - video.transcriptionStatus === "COMPLETE" && - !metadata.aiProcessing && - !metadata.summary && + video.transcriptionStatus === "COMPLETE" && + !metadata.aiProcessing && + !metadata.summary && !metadata.chapters && !metadata.generationError ) { - console.log(`[Get Status] Transcription complete but no AI data, checking feature flag for video owner ${video.ownerId}`); - + console.log( + `[Get Status] Transcription complete but no AI data, checking feature flag for video owner ${video.ownerId}` + ); + const videoOwnerQuery = await db() - .select({ - email: users.email, - stripeSubscriptionStatus: users.stripeSubscriptionStatus + .select({ + email: users.email, + stripeSubscriptionStatus: users.stripeSubscriptionStatus, }) .from(users) .where(eq(users.id, video.ownerId)) .limit(1); - if (videoOwnerQuery.length > 0 && videoOwnerQuery[0] && (await isAiGenerationEnabled(videoOwnerQuery[0]))) { - console.log(`[Get Status] Feature flag enabled, triggering AI generation for video ${videoId}`); - + if ( + videoOwnerQuery.length > 0 && + videoOwnerQuery[0] && + (await isAiGenerationEnabled(videoOwnerQuery[0])) + ) { + console.log( + `[Get Status] Feature flag enabled, triggering AI generation for video ${videoId}` + ); + (async () => { try { - console.log(`[Get Status] Starting AI metadata generation for video ${videoId}`); + console.log( + `[Get Status] Starting AI metadata generation for video ${videoId}` + ); await generateAiMetadata(videoId, video.ownerId); - console.log(`[Get Status] AI metadata generation completed for video ${videoId}`); + console.log( + `[Get Status] AI metadata generation completed for video ${videoId}` + ); } catch (error) { - console.error(`[Get Status] Error generating AI metadata for video ${videoId}:`, error); - + console.error( + `[Get Status] Error generating AI metadata for video ${videoId}:`, + error + ); + try { - const currentVideo = await db().select().from(videos).where(eq(videos.id, videoId)); + const currentVideo = await db() + .select() + .from(videos) + .where(eq(videos.id, videoId)); if (currentVideo.length > 0 && currentVideo[0]) { - const currentMetadata = (currentVideo[0].metadata as VideoMetadata) || {}; + const currentMetadata = + (currentVideo[0].metadata as VideoMetadata) || {}; await db() .update(videos) - .set({ + .set({ metadata: { ...currentMetadata, aiProcessing: false, - generationError: error instanceof Error ? error.message : String(error) - } + generationError: + error instanceof Error ? error.message : String(error), + }, }) .where(eq(videos.id, videoId)); } } catch (resetError) { - console.error(`[Get Status] Failed to reset AI processing flag for video ${videoId}:`, resetError); + console.error( + `[Get Status] Failed to reset AI processing flag for video ${videoId}:`, + resetError + ); } } })(); - + return { - transcriptionStatus: (video.transcriptionStatus as "PROCESSING" | "COMPLETE" | "ERROR") || null, + transcriptionStatus: + (video.transcriptionStatus as "PROCESSING" | "COMPLETE" | "ERROR") || + null, aiProcessing: true, aiTitle: metadata.aiTitle || null, summary: metadata.summary || null, @@ -177,16 +223,20 @@ export async function getVideoStatus(videoId: string): Promise { - const { refresh } = useRouter(); + const router = useRouter(); const params = useSearchParams(); const page = Number(params.get("page")) || 1; const { user } = useDashboardContext(); @@ -66,8 +70,7 @@ export const Caps = ({ const [openNewFolderDialog, setOpenNewFolderDialog] = useState(false); const totalPages = Math.ceil(count / limit); const previousCountRef = useRef(0); - const [selectedCaps, setSelectedCaps] = useState([]); - const [isDeleting, setIsDeleting] = useState(false); + const [selectedCaps, setSelectedCaps] = useState([]); const [isDraggingCap, setIsDraggingCap] = useState(false); const { isUploading, @@ -80,7 +83,7 @@ export const Caps = ({ const anyCapSelected = selectedCaps.length > 0; const { data: analyticsData } = useQuery({ - queryKey: ['analytics', data.map(video => video.id)], + queryKey: ["analytics", data.map((video) => video.id)], queryFn: async () => { if (!dubApiKeyEnabled || data.length === 0) { return {}; @@ -89,9 +92,9 @@ export const Caps = ({ const analyticsPromises = data.map(async (video) => { try { const response = await fetch(`/api/analytics?videoId=${video.id}`, { - method: 'GET', + method: "GET", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, }); @@ -101,7 +104,10 @@ export const Caps = ({ } return { videoId: video.id, count: 0 }; } catch (error) { - console.warn(`Failed to fetch analytics for video ${video.id}:`, error); + console.warn( + `Failed to fetch analytics for video ${video.id}:`, + error + ); return { videoId: video.id, count: 0 }; } }); @@ -110,7 +116,7 @@ export const Caps = ({ const analyticsData: Record = {}; results.forEach((result) => { - if (result.status === 'fulfilled' && result.value) { + if (result.status === "fulfilled" && result.value) { analyticsData[result.value.videoId] = result.value.count; } }); @@ -142,7 +148,7 @@ export const Caps = ({ document.activeElement?.tagName || "" ) ) { - deleteSelectedCaps(); + deleteCaps.mutate(selectedCaps); } } @@ -178,7 +184,7 @@ export const Caps = ({ }; }, []); - const handleCapSelection = (capId: string) => { + const handleCapSelection = (capId: Video.VideoId) => { setSelectedCaps((prev) => { const newSelection = prev.includes(capId) ? prev.filter((id) => id !== capId) @@ -190,85 +196,76 @@ export const Caps = ({ }); }; - const deleteSelectedCaps = async () => { - if (selectedCaps.length === 0) return; + const deleteCaps = useEffectMutation({ + mutationFn: Effect.fn(function* (ids: Video.VideoId[]) { + if (ids.length === 0) return; - setIsDeleting(true); + const rpc = yield* Rpc; - try { - toast.promise( - async () => { - const results = await Promise.allSettled( - selectedCaps.map((capId) => deleteVideo(capId)) - ); + const fiber = yield* Effect.gen(function* () { + const results = yield* Effect.all( + ids.map((id) => rpc.VideoDelete(id).pipe(Effect.exit)), + { concurrency: 10 } + ); - const successCount = results.filter( - (result) => result.status === "fulfilled" && result.value.success - ).length; + const successCount = results.filter(Exit.isSuccess).length; - const errorCount = selectedCaps.length - successCount; + const errorCount = ids.length - successCount; - if (successCount > 0 && errorCount > 0) { - return { success: successCount, error: errorCount }; - } else if (successCount > 0) { - return { success: successCount }; - } else { - throw new Error( + if (successCount > 0 && errorCount > 0) { + return { success: successCount, error: errorCount }; + } else if (successCount > 0) { + return { success: successCount }; + } else { + return yield* Effect.fail( + new Error( `Failed to delete ${errorCount} cap${errorCount === 1 ? "" : "s"}` - ); + ) + ); + } + }).pipe(Effect.fork); + + toast.promise(Effect.runPromise(fiber.await.pipe(Effect.flatten)), { + loading: `Deleting ${ids.length} cap${ids.length === 1 ? "" : "s"}...`, + success: (data) => { + if (data.error) { + return `Successfully deleted ${data.success} cap${ + data.success === 1 ? "" : "s" + }, but failed to delete ${data.error} cap${ + data.error === 1 ? "" : "s" + }`; } + return `Successfully deleted ${data.success} cap${ + data.success === 1 ? "" : "s" + }`; }, - { - loading: `Deleting ${selectedCaps.length} cap${selectedCaps.length === 1 ? "" : "s" - }...`, - success: (data) => { - if (data.error) { - return `Successfully deleted ${data.success} cap${data.success === 1 ? "" : "s" - }, but failed to delete ${data.error} cap${data.error === 1 ? "" : "s" - }`; - } - return `Successfully deleted ${data.success} cap${data.success === 1 ? "" : "s" - }`; - }, - error: (error) => - error.message || "An error occurred while deleting caps", - } - ); + error: (error) => + error.message || "An error occurred while deleting caps", + }); + return yield* fiber.await.pipe(Effect.flatten); + }), + onSuccess: Effect.fn(function* () { setSelectedCaps([]); - refresh(); - } catch (error) { - } finally { - setIsDeleting(false); - } - }; + router.refresh(); + }), + }); - const deleteCap = async (capId: string) => { - try { - await deleteVideo(capId); + const deleteCap = useEffectMutation({ + mutationFn: (id: Video.VideoId) => withRpc((r) => r.VideoDelete(id)), + onSuccess: Effect.fn(function* () { toast.success("Cap deleted successfully"); - refresh(); - } catch (error) { + router.refresh(); + }), + onError: Effect.fn(function* () { toast.error("Failed to delete cap"); - } - }; + }), + }); - if (count === 0) { - return ; - } + if (count === 0) return ; return ( -
- {isDraggingCap && ( -
-
-
- -

Drag to a space to share or folder to move

-
-
-
- )} +
{isUploading && ( - + )} {data.map((cap) => ( { if (selectedCaps.length > 0) { - await deleteSelectedCaps(); + await deleteCaps.mutateAsync(selectedCaps); } else { - await deleteCap(cap.id); + deleteCap.mutateAsync(cap.id); } }} userId={user?.id} @@ -351,13 +346,27 @@ export const Caps = ({
)} - deleteCaps.mutate(selectedCaps)} + isDeleting={deleteCaps.isPending} /> + {isDraggingCap && ( +
+
+
+ +

+ Drag to a space to share or folder to move +

+
+
+
+ )}
); }; diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx index d7a146ed39..476ce315d9 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx @@ -4,14 +4,21 @@ import { useDashboardContext } from "@/app/(org)/dashboard/Contexts"; import { Tooltip } from "@/components/Tooltip"; import { VideoThumbnail } from "@/components/VideoThumbnail"; import { VideoMetadata } from "@cap/database/types"; -import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@cap/ui"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@cap/ui"; import { faCheck, faCopy, - faEllipsis, faLock, + faEllipsis, + faLock, faTrash, faUnlock, - faVideo + faVideo, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import clsx from "clsx"; @@ -24,13 +31,13 @@ import { SharingDialog } from "../SharingDialog"; import { CapCardAnalytics } from "./CapCardAnalytics"; import { CapCardButtons } from "./CapCardButtons"; import { CapCardContent } from "./CapCardContent"; -import { duplicateVideo } from "@/actions/videos/duplicate"; - - +import { EffectRuntime } from "@/lib/EffectRuntime"; +import { withRpc } from "@/lib/Rpcs"; +import { Video } from "@cap/web-domain"; export interface CapCardProps extends PropsWithChildren { cap: { - id: string; + id: Video.VideoId; ownerId: string; name: string; createdAt: Date; @@ -53,7 +60,7 @@ export interface CapCardProps extends PropsWithChildren { hasPassword?: boolean; }; analytics: number; - onDelete?: () => Promise; + onDelete?: () => Promise; userId?: string; sharedCapCard?: boolean; isSelected?: boolean; @@ -91,12 +98,13 @@ export const CapCard = ({ const [copyPressed, setCopyPressed] = useState(false); const [isDragging, setIsDragging] = useState(false); const [isDownloading, setIsDownloading] = useState(false); - const router = useRouter(); const { isSubscribed, setUpgradeModalOpen } = useDashboardContext(); const [confirmOpen, setConfirmOpen] = useState(false); const [removing, setRemoving] = useState(false); + const router = useRouter(); + const handleDeleteClick = (e: React.MouseEvent) => { e.stopPropagation(); setConfirmOpen(true); @@ -129,18 +137,19 @@ export const CapCard = ({ // Helper function to create a drag preview element const createDragPreview = (text: string): HTMLElement => { // Create the element - const element = document.createElement('div'); + const element = document.createElement("div"); // Add text content element.textContent = text; // Apply Tailwind-like styles directly - element.className = 'px-2 py-1.5 text-sm font-medium rounded-lg shadow-md text-gray-1 bg-gray-12'; + element.className = + "px-2 py-1.5 text-sm font-medium rounded-lg shadow-md text-gray-1 bg-gray-12"; // Position off-screen - element.style.position = 'absolute'; - element.style.top = '-9999px'; - element.style.left = '-9999px'; + element.style.position = "absolute"; + element.style.top = "-9999px"; + element.style.left = "-9999px"; return element; }; @@ -158,7 +167,7 @@ export const CapCard = ({ ); // Set drag effect to 'move' to avoid showing the + icon - e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.effectAllowed = "move"; // Set the drag image using the helper function try { @@ -169,7 +178,7 @@ export const CapCard = ({ // Clean up after a short delay setTimeout(() => document.body.removeChild(dragPreview), 100); } catch (error) { - console.error('Error setting drag image:', error); + console.error("Error setting drag image:", error); } setIsDragging(true); @@ -229,7 +238,6 @@ export const CapCard = ({ } }; - const handleCardClick = (e: React.MouseEvent) => { if (anyCapSelected) { e.preventDefault(); @@ -276,17 +284,14 @@ export const CapCard = ({ isSelected ? "!border-blue-10 border-px" : anyCapSelected - ? "border-blue-10 border-px hover:border-blue-10" - : "hover:border-blue-10", + ? "border-blue-10 border-px hover:border-blue-10" + : "hover:border-blue-10", isDragging && "opacity-50", isOwner && !anyCapSelected && "cursor-grab active:cursor-grabbing" )} > {anyCapSelected && !sharedCapCard && ( -
+
)} {!sharedCapCard && (
@@ -317,37 +322,38 @@ export const CapCard = ({ onClick={(e) => { e.stopPropagation(); }} - className={clsx("!size-8 hover:bg-gray-5 hover:border-gray-7 rounded-full min-w-fit !p-0 delay-75", + className={clsx( + "!size-8 hover:bg-gray-5 hover:border-gray-7 rounded-full min-w-fit !p-0 delay-75", isDropdownOpen ? "bg-gray-5 border-gray-7" : "" )} variant="white" size="sm" aria-label="More options" > - + - + { try { - await duplicateVideo(cap.id) + await EffectRuntime.runPromise( + withRpc((r) => r.VideoDuplicate(cap.id)) + ); toast.success("Cap duplicated successfully"); + router.refresh(); } catch (error) { toast.error("Failed to duplicate cap"); } }} className="flex gap-2 items-center rounded-lg" > - +

Duplicate

-

{passwordProtected ? "Edit password" : "Add password"}

+

+ {passwordProtected ? "Edit password" : "Add password"} +

{ @@ -431,7 +439,11 @@ export const CapCard = ({ > { +const FolderCard = ({ + name, + color, + id, + parentId, + videoCount, + spaceId, +}: FolderDataType) => { + const router = useRouter(); const { theme } = useTheme(); const [confirmDeleteFolderOpen, setConfirmDeleteFolderOpen] = useState(false); - const [deleteFolderLoading, setDeleteFolderLoading] = useState(false); const [isRenaming, setIsRenaming] = useState(false); const [updateName, setUpdateName] = useState(name); const nameRef = useRef(null); @@ -38,7 +49,7 @@ const Folder = ({ name, color, id, parentId, videoCount, spaceId }: FolderDataTy // Use a ref to track drag state to avoid re-renders during animation const dragStateRef = useRef({ isDragging: false, - isAnimating: false + isAnimating: false, }); // Add a debounce timer ref to prevent animation stuttering @@ -48,8 +59,8 @@ const Folder = ({ name, color, id, parentId, videoCount, spaceId }: FolderDataTy theme === "dark" && color === "normal" ? "folder" : color === "normal" - ? "folder-dark" - : `folder-${color}`; + ? "folder-dark" + : `folder-${color}`; const { rive, RiveComponent: FolderRive } = useRive({ src: "/rive/dashboard.riv", @@ -61,18 +72,19 @@ const Folder = ({ name, color, id, parentId, videoCount, spaceId }: FolderDataTy }), }); - const deleteFolderHandler = async () => { - try { - setDeleteFolderLoading(true); - await deleteFolder(id, spaceId); + const deleteFolder = useEffectMutation({ + mutationFn: (id: Folder.FolderId) => withRpc((r) => r.FolderDelete(id)), + onSuccess: Effect.fn(function* () { + router.refresh(); toast.success("Folder deleted successfully"); - } catch (error) { + }), + onError: Effect.fn(function* () { toast.error("Failed to delete folder"); - } finally { - setDeleteFolderLoading(false); + }), + onSettled: Effect.fn(function* () { setConfirmDeleteFolderOpen(false); - } - }; + }), + }); useEffect(() => { if (isRenaming && nameRef.current) { @@ -93,7 +105,11 @@ const Folder = ({ name, color, id, parentId, videoCount, spaceId }: FolderDataTy try { setIsMovingVideo(true); - await moveVideoToFolder({ videoId: data.id, folderId: id, spaceId: spaceId ?? activeOrganization?.organization.id }); + await moveVideoToFolder({ + videoId: data.id, + folderId: id, + spaceId: spaceId ?? activeOrganization?.organization.id, + }); toast.success(`"${data.name}" moved to "${name}" folder`); } catch (error) { console.error("Error moving video to folder:", error); @@ -155,16 +171,14 @@ const Folder = ({ name, color, id, parentId, videoCount, spaceId }: FolderDataTy } }; - document.addEventListener('dragend', handleDragEnd); + document.addEventListener("dragend", handleDragEnd); return () => { unregister(); - document.removeEventListener('dragend', handleDragEnd); + document.removeEventListener("dragend", handleDragEnd); }; }, [id, name, rive, isDragOver]); - - const updateFolderNameHandler = async () => { try { await updateFolder({ folderId: id, name: updateName }); @@ -197,7 +211,6 @@ const Folder = ({ name, color, id, parentId, videoCount, spaceId }: FolderDataTy rive.stop(); rive.play("folder-open"); } - } } }; @@ -221,7 +234,6 @@ const Folder = ({ name, color, id, parentId, videoCount, spaceId }: FolderDataTy rive.stop(); rive.play("folder-close"); } - } }; @@ -261,11 +273,15 @@ const Folder = ({ name, color, id, parentId, videoCount, spaceId }: FolderDataTy } }; - return ( - +
{ @@ -311,15 +327,17 @@ const Folder = ({ name, color, id, parentId, videoCount, spaceId }: FolderDataTy isMovingVideo && "opacity-70" )} > -
+
-
{ - e.stopPropagation(); - }} className="flex flex-col justify-center h-10"> +
{ + e.stopPropagation(); + }} + className="flex flex-col justify-center h-10" + > {isRenaming ? (