From 568e8850a29c8613667eb2ec2097afb0188d092a Mon Sep 17 00:00:00 2001 From: Eneji Victor Date: Fri, 3 Oct 2025 15:18:56 +0100 Subject: [PATCH 01/11] implement video folder movement functionality --- apps/web/actions/folders/getAllFolders.ts | 35 + .../web/actions/folders/moveVideosToFolder.ts | 84 +++ .../_components/FolderSelectionDialog.tsx | 287 ++++++++ .../caps/components/SelectedCapsBar.tsx | 191 +++--- apps/web/lib/folder.ts | 643 +++++++++++------- 5 files changed, 911 insertions(+), 329 deletions(-) create mode 100644 apps/web/actions/folders/getAllFolders.ts create mode 100644 apps/web/actions/folders/moveVideosToFolder.ts create mode 100644 apps/web/app/(org)/dashboard/_components/FolderSelectionDialog.tsx diff --git a/apps/web/actions/folders/getAllFolders.ts b/apps/web/actions/folders/getAllFolders.ts new file mode 100644 index 000000000..847405768 --- /dev/null +++ b/apps/web/actions/folders/getAllFolders.ts @@ -0,0 +1,35 @@ +"use server"; + +import { getCurrentUser } from "@cap/database/auth/session"; +import { CurrentUser } from "@cap/web-domain"; +import { Effect } from "effect"; +import { getAllFolders } from "../../lib/folder"; +import { runPromise } from "../../lib/server"; + +export async function getAllFoldersAction( + root: + | { variant: "user" } + | { variant: "space"; spaceId: string } + | { variant: "org"; organizationId: string } +) { + try { + const user = await getCurrentUser(); + if (!user || !user.activeOrganizationId) { + return { + success: false as const, + error: "Unauthorized or no active organization", + }; + } + + const folders = await runPromise( + getAllFolders(root).pipe(Effect.provideService(CurrentUser, user)) + ); + return { success: true as const, folders }; + } catch (error) { + console.error("Error fetching folders:", error); + return { + success: false as const, + error: "Failed to fetch folders", + }; + } +} diff --git a/apps/web/actions/folders/moveVideosToFolder.ts b/apps/web/actions/folders/moveVideosToFolder.ts new file mode 100644 index 000000000..0d913beca --- /dev/null +++ b/apps/web/actions/folders/moveVideosToFolder.ts @@ -0,0 +1,84 @@ +"use server"; + +import { getCurrentUser } from "@cap/database/auth/session"; +import { CurrentUser, Video, Folder } from "@cap/web-domain"; +import { Effect } from "effect"; +import { moveVideosToFolder } from "../../lib/folder"; +import { runPromise } from "../../lib/server"; +import { revalidatePath } from "next/cache"; + +interface MoveVideosToFolderParams { + videoIds: string[]; + targetFolderId: string | null; + spaceId?: string | null; +} + +export async function moveVideosToFolderAction({ + videoIds, + targetFolderId, + spaceId, +}: MoveVideosToFolderParams) { + try { + const user = await getCurrentUser(); + if (!user || !user.activeOrganizationId) { + return { + success: false as const, + error: "Unauthorized or no active organization", + }; + } + + const typedVideoIds = videoIds.map((id) => Video.VideoId.make(id)); + const typedTargetFolderId = targetFolderId + ? Folder.FolderId.make(targetFolderId) + : null; + + const root = spaceId + ? { variant: "space" as const, spaceId } + : { variant: "org" as const, organizationId: user.activeOrganizationId }; + + const result = await runPromise( + moveVideosToFolder(typedVideoIds, typedTargetFolderId, root).pipe( + Effect.provideService(CurrentUser, user) + ) + ); + + // Revalidate paths + revalidatePath("/dashboard/caps"); + + if (spaceId) { + revalidatePath(`/dashboard/spaces/${spaceId}`); + result.originalFolderIds.forEach((folderId) => { + if (folderId) { + revalidatePath(`/dashboard/spaces/${spaceId}/folder/${folderId}`); + } + }); + if (result.targetFolderId) { + revalidatePath( + `/dashboard/spaces/${spaceId}/folder/${result.targetFolderId}` + ); + } + } else { + result.originalFolderIds.forEach((folderId) => { + if (folderId) { + revalidatePath(`/dashboard/folder/${folderId}`); + } + }); + if (result.targetFolderId) { + revalidatePath(`/dashboard/folder/${result.targetFolderId}`); + } + } + + return { + success: true as const, + message: `Successfully moved ${result.movedCount} video${ + result.movedCount !== 1 ? "s" : "" + } to ${result.targetFolderId ? "folder" : "root"}`, + }; + } catch (error) { + console.error("Error moving videos to folder:", error); + return { + success: false as const, + error: error instanceof Error ? error.message : "Failed to move videos", + }; + } +} diff --git a/apps/web/app/(org)/dashboard/_components/FolderSelectionDialog.tsx b/apps/web/app/(org)/dashboard/_components/FolderSelectionDialog.tsx new file mode 100644 index 000000000..e75569147 --- /dev/null +++ b/apps/web/app/(org)/dashboard/_components/FolderSelectionDialog.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@cap/ui"; +import { + faFolderOpen, + faChevronRight, + faChevronDown, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { getAllFoldersAction } from "../../../../actions/folders/getAllFolders"; +import { moveVideosToFolderAction } from "../../../../actions/folders/moveVideosToFolder"; +import { useDashboardContext } from "../Contexts"; +import { toast } from "sonner"; + +type FolderWithChildren = { + id: string; + name: string; + color: "normal" | "blue" | "red" | "yellow"; + parentId: string | null; + organizationId: string; + videoCount: number; + children: FolderWithChildren[]; +}; + +interface FolderSelectionDialogProps { + open: boolean; + onClose: () => void; + onConfirm: (folderId: string | null) => void; + loading?: boolean; + selectedCount: number; + videoIds: string[]; +} + +export function FolderSelectionDialog({ + open, + onClose, + onConfirm, + loading = false, + selectedCount, + videoIds, +}: FolderSelectionDialogProps) { + const [selectedFolderId, setSelectedFolderId] = useState(null); + const [expandedFolders, setExpandedFolders] = useState>( + new Set() + ); + const { activeOrganization, activeSpace } = useDashboardContext(); + + // Fetch folders using the server action + const { data: foldersData, isLoading } = useQuery({ + queryKey: ["folders", activeOrganization?.organization.id, activeSpace?.id], + queryFn: async () => { + const root = activeSpace?.id + ? { variant: "space" as const, spaceId: activeSpace.id } + : { + variant: "org" as const, + organizationId: activeOrganization!.organization.id, + }; + + const result = await getAllFoldersAction(root); + + if (!result.success) { + throw new Error(result.error || "Failed to fetch folders"); + } + + return result.folders; + }, + enabled: open && !!activeOrganization?.organization.id, + }); + + const folders = foldersData || []; + + // Toggle folder expansion + const toggleFolderExpansion = (folderId: string) => { + setExpandedFolders((prev) => { + const newSet = new Set(prev); + if (newSet.has(folderId)) { + newSet.delete(folderId); + } else { + newSet.add(folderId); + } + return newSet; + }); + }; + + // Render folder with improved design + const renderFolder = (folder: FolderWithChildren, depth = 0) => { + const hasChildren = folder.children && folder.children.length > 0; + const isExpanded = expandedFolders.has(folder.id); + const isSelected = selectedFolderId === folder.id; + + return ( +
+
setSelectedFolderId(folder.id)} + className={` + group flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer + transition-colors border-l-4 + ${ + isSelected + ? "bg-blue-3 border-blue-9" + : "border-transparent hover:bg-gray-3" + } + `} + style={{ marginLeft: `${depth * 16}px` }} + > + {/* Expand/Collapse button */} + {hasChildren ? ( + + ) : ( +
+ )} + + {/* Folder icon */} +
+
+ +
+ +
+

{folder.name}

+

+ {folder.videoCount} video{folder.videoCount !== 1 ? "s" : ""} +

+
+
+ + {/* Selection indicator */} + {isSelected && ( +
+
+
+ )} +
+ + {/* Render children if expanded */} + {hasChildren && isExpanded && ( +
+ {folder.children.map((child) => renderFolder(child, depth + 1))} +
+ )} +
+ ); + }; + + const handleConfirm = async () => { + try { + const result = await moveVideosToFolderAction({ + videoIds, + targetFolderId: selectedFolderId, + spaceId: activeSpace?.id, + }); + + if (result.success) { + toast.success(result.message); + onConfirm(selectedFolderId); + setSelectedFolderId(null); + } else { + toast.error(result.error); + } + } catch (error) { + toast.error("Failed to move videos"); + console.error("Error moving videos:", error); + } + }; + + const handleCancel = () => { + setSelectedFolderId(null); + onClose(); + }; + + return ( + !open && handleCancel()}> + + }> + + Move {selectedCount} cap{selectedCount !== 1 ? "s" : ""} to folder + + +
+

+ Select a destination folder for the selected caps. +

+ +
+ {/* Option to move to root (no folder) */} +
setSelectedFolderId(null)} + className={` + flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer + transition-colors border-l-4 + ${ + selectedFolderId === null + ? "bg-blue-3 border-blue-9" + : "border-transparent hover:bg-gray-3" + } + `} + > +
+ +
+
+

+ {activeOrganization?.organization.name || "My Caps"} +

+

Move to root folder

+
+ {selectedFolderId === null && ( +
+
+
+ )} +
+ + {/* Loading state */} + {isLoading && ( +
+

Loading folders...

+
+ )} + + {/* Available folders with hierarchy */} + {!isLoading && + folders.map((folder: FolderWithChildren) => renderFolder(folder))} +
+ + {!isLoading && folders.length === 0 && ( +
+ +

No folders available

+

+ Create a folder first to organize your caps +

+
+ )} +
+ + + + + +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/caps/components/SelectedCapsBar.tsx b/apps/web/app/(org)/dashboard/caps/components/SelectedCapsBar.tsx index 8f82e8c6d..6ccf19909 100644 --- a/apps/web/app/(org)/dashboard/caps/components/SelectedCapsBar.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/SelectedCapsBar.tsx @@ -1,98 +1,131 @@ "use client"; import { Button } from "@cap/ui"; -import { faFilm, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { + faFilm, + faTrash, + faFolderOpen, +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import NumberFlow from "@number-flow/react"; import { AnimatePresence, motion } from "framer-motion"; import { ConfirmationDialog } from "@/app/(org)/dashboard/_components/ConfirmationDialog"; +import { FolderSelectionDialog } from "@/app/(org)/dashboard/_components/FolderSelectionDialog"; interface SelectedCapsBarProps { - selectedCaps: string[]; - setSelectedCaps: (caps: Video.VideoId[]) => void; - deleteSelectedCaps: () => void; - isDeleting: boolean; + selectedCaps: string[]; + setSelectedCaps: (caps: Video.VideoId[]) => void; + deleteSelectedCaps: () => void; + isDeleting: boolean; + moveSelectedCaps?: (folderId: string | null) => void; } import type { Video } from "@cap/web-domain"; import { useState } from "react"; export const SelectedCapsBar = ({ - selectedCaps, - setSelectedCaps, - deleteSelectedCaps, - isDeleting, + selectedCaps, + setSelectedCaps, + deleteSelectedCaps, + isDeleting, + moveSelectedCaps, }: SelectedCapsBarProps) => { - const [confirmOpen, setConfirmOpen] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [moveDialogOpen, setMoveDialogOpen] = useState(false); - const handleConfirmDelete = () => { - deleteSelectedCaps(); - setConfirmOpen(false); - }; + const handleConfirmDelete = () => { + deleteSelectedCaps(); + setConfirmOpen(false); + }; - return ( - - {selectedCaps.length > 0 && ( - -
- - cap{selectedCaps.length !== 1 ? "s" : ""} selected -
-
- - - } - title="Delete selected Caps" - description={`Are you sure you want to delete ${ - selectedCaps.length - } cap${ - selectedCaps.length === 1 ? "" : "s" - }? This action cannot be undone.`} - confirmLabel={isDeleting ? "Deleting..." : "Delete"} - cancelLabel="Cancel" - confirmVariant="dark" - loading={isDeleting} - onConfirm={handleConfirmDelete} - onCancel={() => setConfirmOpen(false)} - /> -
-
- )} -
- ); + const handleMoveToFolder = (folderId: string | null) => { + if (moveSelectedCaps) { + moveSelectedCaps(folderId); + } + setMoveDialogOpen(false); + }; + + return ( + + {selectedCaps.length > 0 && ( + +
+ + cap{selectedCaps.length !== 1 ? "s" : ""} selected +
+
+ + + + } + title="Delete selected Caps" + description={`Are you sure you want to delete ${ + selectedCaps.length + } cap${ + selectedCaps.length === 1 ? "" : "s" + }? This action cannot be undone.`} + confirmLabel={isDeleting ? "Deleting..." : "Delete"} + cancelLabel="Cancel" + confirmVariant="dark" + loading={isDeleting} + onConfirm={handleConfirmDelete} + onCancel={() => setConfirmOpen(false)} + /> + + setMoveDialogOpen(false)} + onConfirm={handleMoveToFolder} + selectedCount={selectedCaps.length} + loading={false} + videoIds={selectedCaps} + /> +
+
+ )} +
+ ); }; diff --git a/apps/web/lib/folder.ts b/apps/web/lib/folder.ts index ef6d7c260..530c73d28 100644 --- a/apps/web/lib/folder.ts +++ b/apps/web/lib/folder.ts @@ -3,181 +3,181 @@ import "server-only"; import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { - comments, - folders, - organizations, - sharedVideos, - spaces, - spaceVideos, - users, - videos, - videoUploads, + comments, + folders, + organizations, + sharedVideos, + spaces, + spaceVideos, + users, + videos, + videoUploads, } from "@cap/database/schema"; import { Database } from "@cap/web-backend"; import type { Video } from "@cap/web-domain"; import { CurrentUser, Folder } from "@cap/web-domain"; -import { and, desc, eq } from "drizzle-orm"; +import { and, desc, eq, inArray } from "drizzle-orm"; import { sql } from "drizzle-orm/sql"; import { Effect } from "effect"; import { revalidatePath } from "next/cache"; export const getFolderById = Effect.fn(function* (folderId: string) { - if (!folderId) throw new Error("Folder ID is required"); - const db = yield* Database; + if (!folderId) throw new Error("Folder ID is required"); + const db = yield* Database; - const [folder] = yield* db.execute((db) => - db - .select() - .from(folders) - .where(eq(folders.id, Folder.FolderId.make(folderId))), - ); + const [folder] = yield* db.execute((db) => + db + .select() + .from(folders) + .where(eq(folders.id, Folder.FolderId.make(folderId))) + ); - if (!folder) throw new Error("Folder not found"); + if (!folder) throw new Error("Folder not found"); - return folder; + return folder; }); export const getFolderBreadcrumb = Effect.fn(function* ( - folderId: Folder.FolderId, + folderId: Folder.FolderId ) { - const breadcrumb: Array<{ - id: Folder.FolderId; - name: string; - color: "normal" | "blue" | "red" | "yellow"; - }> = []; - let currentFolderId = folderId; - - while (currentFolderId) { - const folder = yield* getFolderById(currentFolderId); - if (!folder) break; - - breadcrumb.unshift({ - id: folder.id, - name: folder.name, - color: folder.color, - }); - - if (!folder.parentId) break; - currentFolderId = folder.parentId; - } - - return breadcrumb; + const breadcrumb: Array<{ + id: Folder.FolderId; + name: string; + color: "normal" | "blue" | "red" | "yellow"; + }> = []; + let currentFolderId = folderId; + + while (currentFolderId) { + const folder = yield* getFolderById(currentFolderId); + if (!folder) break; + + breadcrumb.unshift({ + id: folder.id, + name: folder.name, + color: folder.color, + }); + + if (!folder.parentId) break; + currentFolderId = folder.parentId; + } + + return breadcrumb; }); // Helper function to fetch shared spaces data for videos const getSharedSpacesForVideos = Effect.fn(function* ( - videoIds: Video.VideoId[], + videoIds: Video.VideoId[] ) { - if (videoIds.length === 0) return {}; - const db = yield* Database; - - // Fetch space-level sharing - const spaceSharing = yield* db.execute((db) => - db - .select({ - videoId: spaceVideos.videoId, - id: spaces.id, - name: spaces.name, - organizationId: spaces.organizationId, - iconUrl: organizations.iconUrl, - }) - .from(spaceVideos) - .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) - .innerJoin(organizations, eq(spaces.organizationId, organizations.id)) - .where( - sql`${spaceVideos.videoId} IN (${sql.join( - videoIds.map((id) => sql`${id}`), - sql`, `, - )})`, - ), - ); - - // Fetch organization-level sharing - const orgSharing = yield* db.execute((db) => - db - .select({ - videoId: sharedVideos.videoId, - id: organizations.id, - name: organizations.name, - organizationId: organizations.id, - iconUrl: organizations.iconUrl, - }) - .from(sharedVideos) - .innerJoin( - organizations, - eq(sharedVideos.organizationId, organizations.id), - ) - .where( - sql`${sharedVideos.videoId} IN (${sql.join( - videoIds.map((id) => sql`${id}`), - sql`, `, - )})`, - ), - ); - - // Combine and group by videoId - const sharedSpacesMap: Record< - string, - Array<{ - id: string; - name: string; - organizationId: string; - iconUrl: string; - isOrg: boolean; - }> - > = {}; - - // Add space-level sharing - spaceSharing.forEach((space) => { - const spaces = sharedSpacesMap[space.videoId] ?? []; - sharedSpacesMap[space.videoId] = spaces; - spaces.push({ - id: space.id, - name: space.name, - organizationId: space.organizationId, - iconUrl: space.iconUrl || "", - isOrg: false, - }); - }); - - // Add organization-level sharing - orgSharing.forEach((org) => { - const spaces = sharedSpacesMap[org.videoId] ?? []; - sharedSpacesMap[org.videoId] = spaces; - - spaces.push({ - id: org.id, - name: org.name, - organizationId: org.organizationId, - iconUrl: org.iconUrl || "", - isOrg: true, - }); - }); - - return sharedSpacesMap; + if (videoIds.length === 0) return {}; + const db = yield* Database; + + // Fetch space-level sharing + const spaceSharing = yield* db.execute((db) => + db + .select({ + videoId: spaceVideos.videoId, + id: spaces.id, + name: spaces.name, + organizationId: spaces.organizationId, + iconUrl: organizations.iconUrl, + }) + .from(spaceVideos) + .innerJoin(spaces, eq(spaceVideos.spaceId, spaces.id)) + .innerJoin(organizations, eq(spaces.organizationId, organizations.id)) + .where( + sql`${spaceVideos.videoId} IN (${sql.join( + videoIds.map((id) => sql`${id}`), + sql`, ` + )})` + ) + ); + + // Fetch organization-level sharing + const orgSharing = yield* db.execute((db) => + db + .select({ + videoId: sharedVideos.videoId, + id: organizations.id, + name: organizations.name, + organizationId: organizations.id, + iconUrl: organizations.iconUrl, + }) + .from(sharedVideos) + .innerJoin( + organizations, + eq(sharedVideos.organizationId, organizations.id) + ) + .where( + sql`${sharedVideos.videoId} IN (${sql.join( + videoIds.map((id) => sql`${id}`), + sql`, ` + )})` + ) + ); + + // Combine and group by videoId + const sharedSpacesMap: Record< + string, + Array<{ + id: string; + name: string; + organizationId: string; + iconUrl: string; + isOrg: boolean; + }> + > = {}; + + // Add space-level sharing + spaceSharing.forEach((space) => { + const spaces = sharedSpacesMap[space.videoId] ?? []; + sharedSpacesMap[space.videoId] = spaces; + spaces.push({ + id: space.id, + name: space.name, + organizationId: space.organizationId, + iconUrl: space.iconUrl || "", + isOrg: false, + }); + }); + + // Add organization-level sharing + orgSharing.forEach((org) => { + const spaces = sharedSpacesMap[org.videoId] ?? []; + sharedSpacesMap[org.videoId] = spaces; + + spaces.push({ + id: org.id, + name: org.name, + organizationId: org.organizationId, + iconUrl: org.iconUrl || "", + isOrg: true, + }); + }); + + return sharedSpacesMap; }); export const getVideosByFolderId = Effect.fn(function* ( - folderId: Folder.FolderId, + folderId: Folder.FolderId ) { - if (!folderId) throw new Error("Folder ID is required"); - const db = yield* Database; - - const videoData = yield* db.execute((db) => - db - .select({ - id: videos.id, - ownerId: videos.ownerId, - name: videos.name, - createdAt: videos.createdAt, - public: videos.public, - metadata: videos.metadata, - duration: videos.duration, - totalComments: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`, - totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, - sharedOrganizations: sql< - { id: string; name: string; iconUrl: string }[] - >` + if (!folderId) throw new Error("Folder ID is required"); + const db = yield* Database; + + const videoData = yield* db.execute((db) => + db + .select({ + id: videos.id, + ownerId: videos.ownerId, + name: videos.name, + createdAt: videos.createdAt, + public: videos.public, + metadata: videos.metadata, + duration: videos.duration, + totalComments: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'text' THEN ${comments.id} END)`, + totalReactions: sql`COUNT(DISTINCT CASE WHEN ${comments.type} = 'emoji' THEN ${comments.id} END)`, + sharedOrganizations: sql< + { id: string; name: string; iconUrl: string }[] + >` COALESCE( JSON_ARRAYAGG( JSON_OBJECT( @@ -190,117 +190,260 @@ export const getVideosByFolderId = Effect.fn(function* ( ) `, - ownerName: users.name, - effectiveDate: sql` + ownerName: users.name, + effectiveDate: sql` COALESCE( JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.customCreatedAt')), ${videos.createdAt} ) `, - hasPassword: sql`${videos.password} IS NOT NULL`.mapWith(Boolean), - hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( - Boolean, - ), - }) - .from(videos) - .leftJoin(comments, eq(videos.id, comments.videoId)) - .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) - .leftJoin( - organizations, - eq(sharedVideos.organizationId, organizations.id), - ) - .leftJoin(users, eq(videos.ownerId, users.id)) - .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) - .where(eq(videos.folderId, folderId)) - .groupBy( - videos.id, - videos.ownerId, - videos.name, - videos.createdAt, - videos.public, - videos.metadata, - users.name, - ) - .orderBy( - desc(sql`COALESCE( + hasPassword: sql`${videos.password} IS NOT NULL`.mapWith(Boolean), + hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( + Boolean + ), + }) + .from(videos) + .leftJoin(comments, eq(videos.id, comments.videoId)) + .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) + .leftJoin( + organizations, + eq(sharedVideos.organizationId, organizations.id) + ) + .leftJoin(users, eq(videos.ownerId, users.id)) + .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) + .where(eq(videos.folderId, folderId)) + .groupBy( + videos.id, + videos.ownerId, + videos.name, + videos.createdAt, + videos.public, + videos.metadata, + users.name + ) + .orderBy( + desc(sql`COALESCE( JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.customCreatedAt')), ${videos.createdAt} - )`), - ), - ); - - // Fetch shared spaces data for all videos - const videoIds = videoData.map((video) => video.id); - const sharedSpacesMap = yield* getSharedSpacesForVideos(videoIds); - - // Process the video data to match the expected format - const processedVideoData = videoData.map((video) => { - return { - id: video.id as Video.VideoId, // Cast to Video.VideoId branded type - ownerId: video.ownerId, - name: video.name, - createdAt: video.createdAt, - public: video.public, - totalComments: video.totalComments, - totalReactions: video.totalReactions, - sharedOrganizations: Array.isArray(video.sharedOrganizations) - ? video.sharedOrganizations.filter( - (organization) => organization.id !== null, - ) - : [], - sharedSpaces: Array.isArray(sharedSpacesMap[video.id]) - ? sharedSpacesMap[video.id] - : [], - ownerName: video.ownerName ?? "", - metadata: video.metadata as - | { - customCreatedAt?: string; - [key: string]: unknown; - } - | undefined, - hasPassword: video.hasPassword, - hasActiveUpload: video.hasActiveUpload, - foldersData: [], // Empty array since videos in a folder don't need folder data - }; - }); - - return processedVideoData; + )`) + ) + ); + + // Fetch shared spaces data for all videos + const videoIds = videoData.map((video) => video.id); + const sharedSpacesMap = yield* getSharedSpacesForVideos(videoIds); + + // Process the video data to match the expected format + const processedVideoData = videoData.map((video) => { + return { + id: video.id as Video.VideoId, // Cast to Video.VideoId branded type + ownerId: video.ownerId, + name: video.name, + createdAt: video.createdAt, + public: video.public, + totalComments: video.totalComments, + totalReactions: video.totalReactions, + sharedOrganizations: Array.isArray(video.sharedOrganizations) + ? video.sharedOrganizations.filter( + (organization) => organization.id !== null + ) + : [], + sharedSpaces: Array.isArray(sharedSpacesMap[video.id]) + ? sharedSpacesMap[video.id] + : [], + ownerName: video.ownerName ?? "", + metadata: video.metadata as + | { + customCreatedAt?: string; + [key: string]: unknown; + } + | undefined, + hasPassword: video.hasPassword, + hasActiveUpload: video.hasActiveUpload, + foldersData: [], // Empty array since videos in a folder don't need folder data + }; + }); + + return processedVideoData; }); export const getChildFolders = Effect.fn(function* ( - folderId: Folder.FolderId, - root: - | { variant: "user" } - | { variant: "space"; spaceId: string } - | { variant: "org"; organizationId: string }, + folderId: Folder.FolderId, + root: + | { variant: "user" } + | { variant: "space"; spaceId: string } + | { variant: "org"; organizationId: string } ) { - const db = yield* Database; - - const user = yield* CurrentUser; - if (!user.activeOrganizationId) throw new Error("No active organization"); - - const childFolders = yield* db.execute((db) => - db - .select({ - id: folders.id, - name: folders.name, - color: folders.color, - parentId: folders.parentId, - organizationId: folders.organizationId, - videoCount: sql`( + const db = yield* Database; + + const user = yield* CurrentUser; + if (!user.activeOrganizationId) throw new Error("No active organization"); + + const childFolders = yield* db.execute((db) => + 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), - root.variant === "space" - ? eq(folders.spaceId, root.spaceId) - : undefined, - ), - ), - ); - - return childFolders; + }) + .from(folders) + .where( + and( + eq(folders.parentId, folderId), + root.variant === "space" + ? eq(folders.spaceId, root.spaceId) + : undefined + ) + ) + ); + + return childFolders; +}); + +export const getAllFolders = Effect.fn(function* ( + root: + | { variant: "user" } + | { variant: "space"; spaceId: string } + | { variant: "org"; organizationId: string } +) { + const db = yield* Database; + const user = yield* CurrentUser; + + if (!user.activeOrganizationId) throw new Error("No active organization"); + + // Get all folders in one query + const allFolders = yield* db.execute((db) => + 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.organizationId, user.activeOrganizationId), + root.variant === "space" + ? eq(folders.spaceId, root.spaceId) + : undefined + ) + ) + ); + + // Define the folder with children type + type FolderWithChildren = { + id: string; + name: string; + color: "normal" | "blue" | "red" | "yellow"; + parentId: string | null; + organizationId: string; + videoCount: number; + children: FolderWithChildren[]; + }; + + // Build hierarchy client-side + const folderMap = new Map(); + const rootFolders: FolderWithChildren[] = []; + + // First pass: create folder objects with children array + allFolders.forEach((folder) => { + folderMap.set(folder.id, { ...folder, children: [] }); + }); + + // Second pass: build parent-child relationships + allFolders.forEach((folder) => { + const folderWithChildren = folderMap.get(folder.id); + + if (folder.parentId) { + const parent = folderMap.get(folder.parentId); + if (parent && folderWithChildren) { + parent.children.push(folderWithChildren); + } + } else { + if (folderWithChildren) { + rootFolders.push(folderWithChildren); + } + } + }); + + return rootFolders; +}); + +export const moveVideosToFolder = Effect.fn(function* ( + videoIds: Video.VideoId[], + targetFolderId: Folder.FolderId | null, + root?: + | { variant: "space"; spaceId: string } + | { variant: "org"; organizationId: string } +) { + if (videoIds.length === 0) throw new Error("No videos to move"); + + const db = yield* Database; + const user = yield* CurrentUser; + + if (!user.activeOrganizationId) throw new Error("No active organization"); + + // Validate that all videos exist and belong to the user + const existingVideos = yield* db.execute((db) => + db + .select({ + id: videos.id, + folderId: videos.folderId, + ownerId: videos.ownerId, + }) + .from(videos) + .where(and(inArray(videos.id, videoIds), eq(videos.ownerId, user.id))) + ); + + if (existingVideos.length !== videoIds.length) { + throw new Error( + "Some videos not found or you don't have permission to move them" + ); + } + + // If target folder is specified, validate it exists and user has access + if (targetFolderId) { + const targetFolder = yield* getFolderById(targetFolderId); + + if (targetFolder.organizationId !== user.activeOrganizationId) { + throw new Error("Target folder not found or you don't have access to it"); + } + + // Validate space context if provided + if (root?.variant === "space" && targetFolder.spaceId !== root.spaceId) { + throw new Error("Target folder does not belong to the specified space"); + } + } + + // Store original folder IDs for potential revalidation + const originalFolderIds = [ + ...new Set(existingVideos.map((v) => v.folderId).filter(Boolean)), + ]; + + // Perform the move operation + yield* db.execute((db) => + db + .update(videos) + .set({ + folderId: targetFolderId, + updatedAt: new Date(), + }) + .where(inArray(videos.id, videoIds)) + ); + + return { + movedCount: videoIds.length, + originalFolderIds, + targetFolderId, + }; }); From 91415b3243c93a1efb586f0692928ebdc3e46a6f Mon Sep 17 00:00:00 2001 From: Eneji Victor Date: Fri, 3 Oct 2025 16:23:01 +0100 Subject: [PATCH 02/11] fix: resolve server action build errors and add space video support --- apps/web/lib/folder.ts | 67 ++++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 15 deletions(-) diff --git a/apps/web/lib/folder.ts b/apps/web/lib/folder.ts index 530c73d28..02ccd91e5 100644 --- a/apps/web/lib/folder.ts +++ b/apps/web/lib/folder.ts @@ -295,6 +295,7 @@ export const getChildFolders = Effect.fn(function* ( .where( and( eq(folders.parentId, folderId), + eq(folders.organizationId, user.activeOrganizationId), root.variant === "space" ? eq(folders.spaceId, root.spaceId) : undefined @@ -425,21 +426,57 @@ export const moveVideosToFolder = Effect.fn(function* ( } } - // Store original folder IDs for potential revalidation - const originalFolderIds = [ - ...new Set(existingVideos.map((v) => v.folderId).filter(Boolean)), - ]; - - // Perform the move operation - yield* db.execute((db) => - db - .update(videos) - .set({ - folderId: targetFolderId, - updatedAt: new Date(), - }) - .where(inArray(videos.id, videoIds)) - ); + // Determine original folder ids and perform the move based on context + let originalFolderIds: (string | null)[] = []; + + if (root?.variant === "space") { + // Collect originals from space_videos + const spaceRows = yield* db.execute((db) => + db + .select({ + folderId: spaceVideos.folderId, + videoId: spaceVideos.videoId, + }) + .from(spaceVideos) + .where( + and( + eq(spaceVideos.spaceId, root.spaceId), + inArray(spaceVideos.videoId, videoIds) + ) + ) + ); + originalFolderIds = [ + ...new Set(spaceRows.map((r) => r.folderId).filter(Boolean)), + ]; + + // Update per-space folder placement + yield* db.execute((db) => + db + .update(spaceVideos) + .set({ folderId: targetFolderId }) + .where( + and( + eq(spaceVideos.spaceId, root.spaceId), + inArray(spaceVideos.videoId, videoIds) + ) + ) + ); + } else { + // ORG/global placement via videos.folderId + originalFolderIds = [ + ...new Set(existingVideos.map((v) => v.folderId).filter(Boolean)), + ]; + + yield* db.execute((db) => + db + .update(videos) + .set({ + folderId: targetFolderId, + updatedAt: new Date(), + }) + .where(inArray(videos.id, videoIds)) + ); + } return { movedCount: videoIds.length, From a15335ad6fda6d3c37ea37e95038c0eabeaee829 Mon Sep 17 00:00:00 2001 From: Eneji Victor Date: Fri, 3 Oct 2025 19:32:04 +0100 Subject: [PATCH 03/11] fix: guard against partial space updates in moveVideosToFolder - Add validation to ensure all requested videos exist in the space before updating - Prevent silent partial updates when some videos are missing from space_videos - Throw descriptive error when videos are not found in the specified space - Improves data integrity and prevents misleading success responses --- apps/web/lib/folder.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/web/lib/folder.ts b/apps/web/lib/folder.ts index 02ccd91e5..2d32ced62 100644 --- a/apps/web/lib/folder.ts +++ b/apps/web/lib/folder.ts @@ -445,6 +445,13 @@ export const moveVideosToFolder = Effect.fn(function* ( ) ) ); + const spaceVideoIds = new Set(spaceRows.map((row) => row.videoId)); + const missingVideoIds = videoIds.filter((id) => !spaceVideoIds.has(id)); + if (missingVideoIds.length > 0) { + throw new Error( + "Some videos are not in the specified space or you don't have permission to move them" + ); + } originalFolderIds = [ ...new Set(spaceRows.map((r) => r.folderId).filter(Boolean)), ]; From 5d8d23c230a008e15bdc5dd23ccddc37c23b172e Mon Sep 17 00:00:00 2001 From: Eneji Victor Date: Fri, 3 Oct 2025 19:51:43 +0100 Subject: [PATCH 04/11] fix: add space permission checks to moveVideosToFolder action --- .../web/actions/folders/moveVideosToFolder.ts | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/web/actions/folders/moveVideosToFolder.ts b/apps/web/actions/folders/moveVideosToFolder.ts index 0d913beca..7570deb27 100644 --- a/apps/web/actions/folders/moveVideosToFolder.ts +++ b/apps/web/actions/folders/moveVideosToFolder.ts @@ -1,9 +1,10 @@ "use server"; import { getCurrentUser } from "@cap/database/auth/session"; -import { CurrentUser, Video, Folder } from "@cap/web-domain"; +import { CurrentUser, Video, Folder, Policy } from "@cap/web-domain"; +import { SpacesPolicy } from "@cap/web-backend"; import { Effect } from "effect"; -import { moveVideosToFolder } from "../../lib/folder"; +import { moveVideosToFolder, getFolderById } from "../../lib/folder"; import { runPromise } from "../../lib/server"; import { revalidatePath } from "next/cache"; @@ -36,11 +37,23 @@ export async function moveVideosToFolderAction({ ? { variant: "space" as const, spaceId } : { variant: "org" as const, organizationId: user.activeOrganizationId }; - const result = await runPromise( - moveVideosToFolder(typedVideoIds, typedTargetFolderId, root).pipe( - Effect.provideService(CurrentUser, user) - ) - ); + // Create effect with permission checks + const moveVideosEffect = spaceId + ? Effect.gen(function* () { + const spacesPolicy = yield* SpacesPolicy; + + // Perform the folder move operation + return yield* moveVideosToFolder( + typedVideoIds, + typedTargetFolderId, + root + ).pipe(Policy.withPolicy(spacesPolicy.isMember(spaceId))); + }).pipe(Effect.provideService(CurrentUser, user)) + : moveVideosToFolder(typedVideoIds, typedTargetFolderId, root).pipe( + Effect.provideService(CurrentUser, user) + ); + + const result = await runPromise(moveVideosEffect); // Revalidate paths revalidatePath("/dashboard/caps"); From ea8994c69b353502fe7fd05dfabad71122b07416 Mon Sep 17 00:00:00 2001 From: Eneji Victor Date: Sat, 4 Oct 2025 13:58:10 +0100 Subject: [PATCH 05/11] fix: implement folder scope isolation and EffectRuntime migration --- .../_components/FolderSelectionDialog.tsx | 110 +++++++++++------- .../caps/components/SelectedCapsBar.tsx | 1 - apps/web/lib/folder.ts | 13 ++- 3 files changed, 77 insertions(+), 47 deletions(-) diff --git a/apps/web/app/(org)/dashboard/_components/FolderSelectionDialog.tsx b/apps/web/app/(org)/dashboard/_components/FolderSelectionDialog.tsx index e75569147..c42154faa 100644 --- a/apps/web/app/(org)/dashboard/_components/FolderSelectionDialog.tsx +++ b/apps/web/app/(org)/dashboard/_components/FolderSelectionDialog.tsx @@ -14,12 +14,15 @@ import { faChevronDown, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useQuery } from "@tanstack/react-query"; +import { useEffectMutation, useEffectQuery } from "@/lib/EffectRuntime"; +import { withRpc } from "@/lib/Rpcs"; import { useState } from "react"; import { getAllFoldersAction } from "../../../../actions/folders/getAllFolders"; import { moveVideosToFolderAction } from "../../../../actions/folders/moveVideosToFolder"; import { useDashboardContext } from "../Contexts"; import { toast } from "sonner"; +import { Effect } from "effect"; +import { useQueryClient } from "@tanstack/react-query"; type FolderWithChildren = { id: string; @@ -35,7 +38,6 @@ interface FolderSelectionDialogProps { open: boolean; onClose: () => void; onConfirm: (folderId: string | null) => void; - loading?: boolean; selectedCount: number; videoIds: string[]; } @@ -44,7 +46,6 @@ export function FolderSelectionDialog({ open, onClose, onConfirm, - loading = false, selectedCount, videoIds, }: FolderSelectionDialogProps) { @@ -53,31 +54,67 @@ export function FolderSelectionDialog({ new Set() ); const { activeOrganization, activeSpace } = useDashboardContext(); + const queryClient = useQueryClient(); - // Fetch folders using the server action - const { data: foldersData, isLoading } = useQuery({ + // Fetch folders using useEffectQuery + const { data: foldersData, isLoading } = useEffectQuery({ queryKey: ["folders", activeOrganization?.organization.id, activeSpace?.id], - queryFn: async () => { - const root = activeSpace?.id - ? { variant: "space" as const, spaceId: activeSpace.id } - : { - variant: "org" as const, - organizationId: activeOrganization!.organization.id, - }; + queryFn: () => + Effect.tryPromise(() => { + const root = activeSpace?.id + ? { variant: "space" as const, spaceId: activeSpace.id } + : { + variant: "org" as const, + organizationId: activeOrganization!.organization.id, + }; - const result = await getAllFoldersAction(root); - - if (!result.success) { - throw new Error(result.error || "Failed to fetch folders"); - } - - return result.folders; - }, + return getAllFoldersAction(root).then((result) => { + if (!result.success) { + throw new Error(result.error || "Failed to fetch folders"); + } + return result.folders; + }); + }), enabled: open && !!activeOrganization?.organization.id, }); const folders = foldersData || []; + // Move videos mutation using useEffectMutation + const moveVideosMutation = useEffectMutation({ + mutationFn: (params: { + videoIds: string[]; + targetFolderId: string | null; + spaceId?: string | null; + }) => + Effect.tryPromise(() => + moveVideosToFolderAction(params).then((result) => { + if (!result.success) { + throw new Error(result.error || "Failed to move videos"); + } + return result; + }) + ), + onSuccess: (result) => { + toast.success(result.message); + + // Invalidate related queries to refresh data + queryClient.invalidateQueries({ + queryKey: ["folders", activeOrganization?.organization.id, activeSpace?.id] + }); + queryClient.invalidateQueries({ + queryKey: ["videos"] + }); + + onConfirm(selectedFolderId); + setSelectedFolderId(null); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to move videos"); + console.error("Error moving videos:", error); + }, + }); + // Toggle folder expansion const toggleFolderExpansion = (folderId: string) => { setExpandedFolders((prev) => { @@ -165,25 +202,12 @@ export function FolderSelectionDialog({ ); }; - const handleConfirm = async () => { - try { - const result = await moveVideosToFolderAction({ - videoIds, - targetFolderId: selectedFolderId, - spaceId: activeSpace?.id, - }); - - if (result.success) { - toast.success(result.message); - onConfirm(selectedFolderId); - setSelectedFolderId(null); - } else { - toast.error(result.error); - } - } catch (error) { - toast.error("Failed to move videos"); - console.error("Error moving videos:", error); - } + const handleConfirm = () => { + moveVideosMutation.mutate({ + videoIds, + targetFolderId: selectedFolderId, + spaceId: activeSpace?.id, + }); }; const handleCancel = () => { @@ -267,7 +291,7 @@ export function FolderSelectionDialog({ onClick={handleCancel} variant="gray" size="sm" - disabled={loading} + disabled={moveVideosMutation.isPending} > Cancel @@ -275,10 +299,10 @@ export function FolderSelectionDialog({ onClick={handleConfirm} variant="dark" size="sm" - spinner={loading} - disabled={loading} + spinner={moveVideosMutation.isPending} + disabled={moveVideosMutation.isPending} > - {loading ? "Moving..." : "Move"} + {moveVideosMutation.isPending ? "Moving..." : "Move"} diff --git a/apps/web/app/(org)/dashboard/caps/components/SelectedCapsBar.tsx b/apps/web/app/(org)/dashboard/caps/components/SelectedCapsBar.tsx index 6ccf19909..17dcfd0d6 100644 --- a/apps/web/app/(org)/dashboard/caps/components/SelectedCapsBar.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/SelectedCapsBar.tsx @@ -120,7 +120,6 @@ export const SelectedCapsBar = ({ onClose={() => setMoveDialogOpen(false)} onConfirm={handleMoveToFolder} selectedCount={selectedCaps.length} - loading={false} videoIds={selectedCaps} />
diff --git a/apps/web/lib/folder.ts b/apps/web/lib/folder.ts index 2d32ced62..85a15483b 100644 --- a/apps/web/lib/folder.ts +++ b/apps/web/lib/folder.ts @@ -16,7 +16,7 @@ import { import { Database } from "@cap/web-backend"; import type { Video } from "@cap/web-domain"; import { CurrentUser, Folder } from "@cap/web-domain"; -import { and, desc, eq, inArray } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull } from "drizzle-orm"; import { sql } from "drizzle-orm/sql"; import { Effect } from "effect"; import { revalidatePath } from "next/cache"; @@ -298,7 +298,7 @@ export const getChildFolders = Effect.fn(function* ( eq(folders.organizationId, user.activeOrganizationId), root.variant === "space" ? eq(folders.spaceId, root.spaceId) - : undefined + : isNull(folders.spaceId) ) ) ); @@ -336,7 +336,7 @@ export const getAllFolders = Effect.fn(function* ( eq(folders.organizationId, user.activeOrganizationId), root.variant === "space" ? eq(folders.spaceId, root.spaceId) - : undefined + : isNull(folders.spaceId) ) ) ); @@ -424,6 +424,13 @@ export const moveVideosToFolder = Effect.fn(function* ( if (root?.variant === "space" && targetFolder.spaceId !== root.spaceId) { throw new Error("Target folder does not belong to the specified space"); } + + // Block moves into space folders when not operating in that space + if (root?.variant !== "space" && targetFolder.spaceId !== null) { + throw new Error( + "Target folder is scoped to a space and cannot be used here" + ); + } } // Determine original folder ids and perform the move based on context From c2d560649346526c3022bf10880e8a8ed35eed3c Mon Sep 17 00:00:00 2001 From: Eneji Victor Date: Sat, 4 Oct 2025 14:32:21 +0100 Subject: [PATCH 06/11] refactor: improve folder security and cache management - Remove inline comments per coding guidelines - Add scope isolation with isNull(folders.spaceId) filters - Implement permission checks with SpacesPolicy validation - Replace broad cache invalidation with targeted setQueryData updates - Return detailed result data for efficient cache mutations - Ensure space context validation to prevent cross-scope moves --- .../web/actions/folders/moveVideosToFolder.ts | 8 +-- .../_components/FolderSelectionDialog.tsx | 70 +++++++++++++------ 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/apps/web/actions/folders/moveVideosToFolder.ts b/apps/web/actions/folders/moveVideosToFolder.ts index 7570deb27..83a2edaea 100644 --- a/apps/web/actions/folders/moveVideosToFolder.ts +++ b/apps/web/actions/folders/moveVideosToFolder.ts @@ -4,7 +4,7 @@ import { getCurrentUser } from "@cap/database/auth/session"; import { CurrentUser, Video, Folder, Policy } from "@cap/web-domain"; import { SpacesPolicy } from "@cap/web-backend"; import { Effect } from "effect"; -import { moveVideosToFolder, getFolderById } from "../../lib/folder"; +import { moveVideosToFolder } from "../../lib/folder"; import { runPromise } from "../../lib/server"; import { revalidatePath } from "next/cache"; @@ -37,12 +37,10 @@ export async function moveVideosToFolderAction({ ? { variant: "space" as const, spaceId } : { variant: "org" as const, organizationId: user.activeOrganizationId }; - // Create effect with permission checks const moveVideosEffect = spaceId ? Effect.gen(function* () { const spacesPolicy = yield* SpacesPolicy; - // Perform the folder move operation return yield* moveVideosToFolder( typedVideoIds, typedTargetFolderId, @@ -55,7 +53,6 @@ export async function moveVideosToFolderAction({ const result = await runPromise(moveVideosEffect); - // Revalidate paths revalidatePath("/dashboard/caps"); if (spaceId) { @@ -86,6 +83,9 @@ export async function moveVideosToFolderAction({ message: `Successfully moved ${result.movedCount} video${ result.movedCount !== 1 ? "s" : "" } to ${result.targetFolderId ? "folder" : "root"}`, + movedCount: result.movedCount, + originalFolderIds: result.originalFolderIds, + targetFolderId: result.targetFolderId, }; } catch (error) { console.error("Error moving videos to folder:", error); diff --git a/apps/web/app/(org)/dashboard/_components/FolderSelectionDialog.tsx b/apps/web/app/(org)/dashboard/_components/FolderSelectionDialog.tsx index c42154faa..d04c5b4c1 100644 --- a/apps/web/app/(org)/dashboard/_components/FolderSelectionDialog.tsx +++ b/apps/web/app/(org)/dashboard/_components/FolderSelectionDialog.tsx @@ -15,7 +15,6 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useEffectMutation, useEffectQuery } from "@/lib/EffectRuntime"; -import { withRpc } from "@/lib/Rpcs"; import { useState } from "react"; import { getAllFoldersAction } from "../../../../actions/folders/getAllFolders"; import { moveVideosToFolderAction } from "../../../../actions/folders/moveVideosToFolder"; @@ -56,7 +55,6 @@ export function FolderSelectionDialog({ const { activeOrganization, activeSpace } = useDashboardContext(); const queryClient = useQueryClient(); - // Fetch folders using useEffectQuery const { data: foldersData, isLoading } = useEffectQuery({ queryKey: ["folders", activeOrganization?.organization.id, activeSpace?.id], queryFn: () => @@ -80,7 +78,6 @@ export function FolderSelectionDialog({ const folders = foldersData || []; - // Move videos mutation using useEffectMutation const moveVideosMutation = useEffectMutation({ mutationFn: (params: { videoIds: string[]; @@ -97,25 +94,62 @@ export function FolderSelectionDialog({ ), onSuccess: (result) => { toast.success(result.message); - - // Invalidate related queries to refresh data - queryClient.invalidateQueries({ - queryKey: ["folders", activeOrganization?.organization.id, activeSpace?.id] - }); - queryClient.invalidateQueries({ - queryKey: ["videos"] - }); - + + const foldersQueryKey = [ + "folders", + activeOrganization?.organization.id, + activeSpace?.id, + ]; + + queryClient.setQueryData( + foldersQueryKey, + (oldFolders: FolderWithChildren[] | undefined) => { + if (!oldFolders) return oldFolders; + + return oldFolders.map((folder) => ({ + ...folder, + videoCount: result.originalFolderIds.includes(folder.id) + ? Math.max(0, folder.videoCount - videoIds.length) + : folder.id === selectedFolderId + ? folder.videoCount + videoIds.length + : folder.videoCount, + children: + folder.children?.map((child) => ({ + ...child, + videoCount: result.originalFolderIds.includes(child.id) + ? Math.max(0, child.videoCount - videoIds.length) + : child.id === selectedFolderId + ? child.videoCount + videoIds.length + : child.videoCount, + })) || [], + })); + } + ); + + queryClient.setQueriesData( + { queryKey: ["videos"] }, + (oldVideos: any[] | undefined) => { + if (!oldVideos) return oldVideos; + + return oldVideos.map((video) => + videoIds.includes(video.id) + ? { ...video, folderId: selectedFolderId } + : video + ); + } + ); + onConfirm(selectedFolderId); setSelectedFolderId(null); }, onError: (error) => { - toast.error(error instanceof Error ? error.message : "Failed to move videos"); + toast.error( + error instanceof Error ? error.message : "Failed to move videos" + ); console.error("Error moving videos:", error); }, }); - // Toggle folder expansion const toggleFolderExpansion = (folderId: string) => { setExpandedFolders((prev) => { const newSet = new Set(prev); @@ -128,7 +162,6 @@ export function FolderSelectionDialog({ }); }; - // Render folder with improved design const renderFolder = (folder: FolderWithChildren, depth = 0) => { const hasChildren = folder.children && folder.children.length > 0; const isExpanded = expandedFolders.has(folder.id); @@ -149,7 +182,6 @@ export function FolderSelectionDialog({ `} style={{ marginLeft: `${depth * 16}px` }} > - {/* Expand/Collapse button */} {hasChildren ? (