diff --git a/apps/web/actions/folders/add-videos.ts b/apps/web/actions/folders/add-videos.ts new file mode 100644 index 0000000000..2c95af5847 --- /dev/null +++ b/apps/web/actions/folders/add-videos.ts @@ -0,0 +1,97 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { + folders, + sharedVideos, + spaceVideos, + videos, +} from "@cap/database/schema"; +import type { Folder, Space, Video } from "@cap/web-domain"; +import { and, eq, inArray } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export async function addVideosToFolder( + folderId: Folder.FolderId, + videoIds: Video.VideoId[], + spaceId: Space.SpaceIdOrOrganisationId, +) { + try { + const user = await getCurrentUser(); + + if (!user || !user.id) { + throw new Error("Unauthorized"); + } + + if (!folderId || !videoIds || videoIds.length === 0) { + throw new Error("Missing required data"); + } + + const [folder] = await db() + .select({ id: folders.id, spaceId: folders.spaceId }) + .from(folders) + .where(eq(folders.id, folderId)); + + if (!folder) { + throw new Error("Folder not found"); + } + + const userVideos = await db() + .select({ id: videos.id }) + .from(videos) + .where(and(eq(videos.ownerId, user.id), inArray(videos.id, videoIds))); + + const validVideoIds = userVideos.map((v) => v.id); + + if (validVideoIds.length === 0) { + throw new Error("No valid videos found"); + } + + const isAllSpacesEntry = spaceId === user.activeOrganizationId; + + //if video already exists in the space, then move it + if (isAllSpacesEntry) { + await db() + .update(sharedVideos) + .set({ folderId }) + .where( + and( + eq(sharedVideos.organizationId, user.activeOrganizationId), + inArray(sharedVideos.videoId, validVideoIds), + ), + ); + } else { + await db() + .update(spaceVideos) + .set({ folderId }) + .where( + and( + eq(spaceVideos.spaceId, spaceId), + inArray(spaceVideos.videoId, validVideoIds), + ), + ); + } + + revalidatePath(`/dashboard/caps`); + revalidatePath(`/dashboard/folder/${folderId}`); + if (spaceId) { + revalidatePath(`/dashboard/spaces/${spaceId}/folder/${folderId}`); + } + + return { + success: true, + message: `${validVideoIds.length} video${validVideoIds.length === 1 ? "" : "s"} added to folder`, + addedCount: validVideoIds.length, + }; + } catch (error) { + console.error("Error adding videos to folder:", error); + return { + success: false, + error: + error instanceof Error + ? error.message + : "Failed to add videos to folder", + }; + } +} diff --git a/apps/web/actions/folders/get-folder-videos.ts b/apps/web/actions/folders/get-folder-videos.ts new file mode 100644 index 0000000000..704f6aeb22 --- /dev/null +++ b/apps/web/actions/folders/get-folder-videos.ts @@ -0,0 +1,50 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { sharedVideos, spaceVideos } from "@cap/database/schema"; +import type { Folder, Space, Video } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; + +export async function getFolderVideoIds( + folderId: Folder.FolderId, + spaceId: Space.SpaceIdOrOrganisationId, +) { + try { + const user = await getCurrentUser(); + + if (!user || !user.id) { + throw new Error("Unauthorized"); + } + + if (!folderId) { + throw new Error("Folder ID is required"); + } + + const isAllSpacesEntry = user.activeOrganizationId === spaceId; + + const rows = isAllSpacesEntry + ? await db() + .select({ id: sharedVideos.videoId }) + .from(sharedVideos) + .where(eq(sharedVideos.folderId, folderId)) + : await db() + .select({ id: spaceVideos.videoId }) + .from(spaceVideos) + .where(eq(spaceVideos.folderId, folderId)); + + return { + success: true, + data: rows.map((r) => r.id as Video.VideoId), + }; + } catch (error) { + console.error("Error fetching folder video IDs:", error); + return { + success: false, + error: + error instanceof Error + ? error.message + : "Failed to fetch folder videos", + }; + } +} diff --git a/apps/web/actions/folders/moveVideoToFolder.ts b/apps/web/actions/folders/moveVideoToFolder.ts index 86a88fbbdd..59d8de51d0 100644 --- a/apps/web/actions/folders/moveVideoToFolder.ts +++ b/apps/web/actions/folders/moveVideoToFolder.ts @@ -2,11 +2,15 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { folders, spaceVideos, videos } from "@cap/database/schema"; -import type { Folder, Video } from "@cap/web-domain"; +import { + folders, + sharedVideos, + spaceVideos, + videos, +} from "@cap/database/schema"; +import type { Folder, Space, Video } from "@cap/web-domain"; import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; - export async function moveVideoToFolder({ videoId, folderId, @@ -14,7 +18,7 @@ export async function moveVideoToFolder({ }: { videoId: Video.VideoId; folderId: Folder.FolderId | null; - spaceId?: string | null; + spaceId?: Space.SpaceIdOrOrganisationId | null; }) { const user = await getCurrentUser(); if (!user || !user.activeOrganizationId) @@ -30,6 +34,8 @@ export async function moveVideoToFolder({ const originalFolderId = currentVideo?.folderId; + const isAllSpacesEntry = spaceId === user.activeOrganizationId; + // If folderId is provided, verify it exists and belongs to the same organization if (folderId) { const [folder] = await db() @@ -47,24 +53,36 @@ export async function moveVideoToFolder({ } } - if (spaceId) { + if (spaceId && !isAllSpacesEntry) { await db() .update(spaceVideos) .set({ folderId: folderId === null ? null : folderId, }) - .where(eq(spaceVideos.videoId, videoId)); + .where( + and(eq(spaceVideos.videoId, videoId), eq(spaceVideos.spaceId, spaceId)), + ); + } else if (spaceId && isAllSpacesEntry) { + await db() + .update(sharedVideos) + .set({ + folderId: folderId === null ? null : folderId, + }) + .where( + and( + eq(sharedVideos.videoId, videoId), + eq(sharedVideos.organizationId, user.activeOrganizationId), + ), + ); + } else { + await db() + .update(videos) + .set({ + folderId: folderId === null ? null : folderId, + }) + .where(eq(videos.id, videoId)); } - // Update the video's folderId - await db() - .update(videos) - .set({ - folderId, - updatedAt: new Date(), - }) - .where(eq(videos.id, videoId)); - // Always revalidate the main caps page revalidatePath(`/dashboard/caps`); diff --git a/apps/web/actions/folders/remove-videos.ts b/apps/web/actions/folders/remove-videos.ts new file mode 100644 index 0000000000..680a7a3590 --- /dev/null +++ b/apps/web/actions/folders/remove-videos.ts @@ -0,0 +1,112 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { + folders, + sharedVideos, + spaceVideos, + videos, +} from "@cap/database/schema"; +import type { Folder, Space, Video } from "@cap/web-domain"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export async function removeVideosFromFolder( + folderId: Folder.FolderId, + videoIds: Video.VideoId[], + spaceId: Space.SpaceIdOrOrganisationId, +) { + try { + const user = await getCurrentUser(); + + if (!user || !user.id) { + throw new Error("Unauthorized"); + } + + const isAllSpacesEntry = user.activeOrganizationId === spaceId; + + if (!folderId || !videoIds || videoIds.length === 0) { + throw new Error("Missing required data"); + } + + // Verify folder exists and is accessible + const [folder] = await db() + .select({ id: folders.id, spaceId: folders.spaceId }) + .from(folders) + .where(eq(folders.id, folderId)); + + if (!folder) { + throw new Error("Folder not found"); + } + + // Only allow updating videos the user owns + const userVideos = await db() + .select({ id: videos.id }) + .from(videos) + .where(and(eq(videos.ownerId, user.id), inArray(videos.id, videoIds))); + + const validVideoIds = userVideos.map((v) => v.id); + + if (validVideoIds.length === 0) { + throw new Error("No valid videos found"); + } + + // Clear the folderId on the videos + await db() + .update(videos) + .set({ folderId: null, updatedAt: new Date() }) + .where( + and(inArray(videos.id, validVideoIds), eq(videos.folderId, folderId)), + ); + + // Clear the folderId in the appropriate table based on context + if (isAllSpacesEntry || !folder.spaceId) { + // Organization-level folder - clear folderId in sharedVideos + await db() + .update(sharedVideos) + .set({ folderId: null }) + .where( + and( + eq(sharedVideos.organizationId, user.activeOrganizationId), + inArray(sharedVideos.videoId, validVideoIds), + eq(sharedVideos.folderId, folderId), + ), + ); + } else if (folder.spaceId) { + // Space-level folder - clear folderId in spaceVideos + await db() + .update(spaceVideos) + .set({ folderId: null }) + .where( + and( + sql`${spaceVideos.spaceId} = ${folder.spaceId}`, + inArray(spaceVideos.videoId, validVideoIds), + eq(spaceVideos.folderId, folderId), + ), + ); + } + + // Revalidate relevant paths + revalidatePath(`/dashboard/caps`); + revalidatePath(`/dashboard/folder/${folderId}`); + if (folder.spaceId) { + revalidatePath(`/dashboard/spaces/${folder.spaceId}/folder/${folderId}`); + } + + return { + success: true, + message: `${validVideoIds.length} video${validVideoIds.length === 1 ? "" : "s"} removed from folder`, + removedCount: validVideoIds.length, + }; + } catch (error) { + console.error("Error removing videos from folder:", error); + return { + success: false, + error: + error instanceof Error + ? error.message + : "Failed to remove videos from folder", + }; + } +} diff --git a/apps/web/actions/organizations/add-videos.ts b/apps/web/actions/organizations/add-videos.ts index 97c24fc84e..a811dcd623 100644 --- a/apps/web/actions/organizations/add-videos.ts +++ b/apps/web/actions/organizations/add-videos.ts @@ -87,36 +87,40 @@ export async function addVideosToOrganization( (id) => !existingVideoIds.includes(id), ); - if (newVideoIds.length === 0) { - return { - success: true, - message: "Videos already shared with organization", - }; + // Update existing videos to move them to root (clear folderId) + if (existingVideoIds.length > 0) { + await db() + .update(sharedVideos) + .set({ folderId: null }) + .where( + and( + eq(sharedVideos.organizationId, organizationId), + inArray(sharedVideos.videoId, existingVideoIds), + ), + ); } - const sharedVideoEntries = newVideoIds.map((videoId) => ({ - id: nanoId(), - videoId, - organizationId, - sharedByUserId: user.id, - })); + // Insert new videos + if (newVideoIds.length > 0) { + const sharedVideoEntries = newVideoIds.map((videoId) => ({ + id: nanoId(), + videoId, + organizationId, + sharedByUserId: user.id, + })); - await db().insert(sharedVideos).values(sharedVideoEntries); - - // Clear folderId for videos added to organization so they appear in main view - await db() - .update(videos) - .set({ folderId: null }) - .where(inArray(videos.id, newVideoIds)); + await db().insert(sharedVideos).values(sharedVideoEntries); + } revalidatePath(`/dashboard/spaces/${organizationId}`); revalidatePath("/dashboard/caps"); + const totalUpdated = existingVideoIds.length + newVideoIds.length; return { success: true, - message: `${newVideoIds.length} video${ - newVideoIds.length === 1 ? "" : "s" - } shared with organization`, + message: `${totalUpdated} video${ + totalUpdated === 1 ? "" : "s" + } ${totalUpdated === 1 ? "is" : "are"} now in organization root`, }; } catch (error) { console.error("Error adding videos to organization:", error); diff --git a/apps/web/actions/organizations/get-organization-videos.ts b/apps/web/actions/organizations/get-organization-videos.ts index 41431c3af1..2e584388f4 100644 --- a/apps/web/actions/organizations/get-organization-videos.ts +++ b/apps/web/actions/organizations/get-organization-videos.ts @@ -4,7 +4,7 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { sharedVideos } from "@cap/database/schema"; import type { Organisation } from "@cap/web-domain"; -import { eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; export async function getOrganizationVideoIds( organizationId: Organisation.OrganisationId, @@ -25,7 +25,12 @@ export async function getOrganizationVideoIds( videoId: sharedVideos.videoId, }) .from(sharedVideos) - .where(eq(sharedVideos.organizationId, organizationId)); + .where( + and( + eq(sharedVideos.organizationId, organizationId), + isNull(sharedVideos.folderId), + ), + ); return { success: true, diff --git a/apps/web/actions/spaces/add-videos.ts b/apps/web/actions/spaces/add-videos.ts index bbb5656816..55cbf8d5f9 100644 --- a/apps/web/actions/spaces/add-videos.ts +++ b/apps/web/actions/spaces/add-videos.ts @@ -3,7 +3,7 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { nanoId } from "@cap/database/helpers"; -import { spaces, spaceVideos, videos } from "@cap/database/schema"; +import { sharedVideos, spaceVideos, videos } from "@cap/database/schema"; import type { Space, Video } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; @@ -23,14 +23,7 @@ export async function addVideosToSpace( throw new Error("Missing required data"); } - const [space] = await db() - .select() - .from(spaces) - .where(eq(spaces.id, spaceId)); - - if (!space) { - throw new Error("Space not found"); - } + const isAllSpacesEntry = user.activeOrganizationId === spaceId; const userVideos = await db() .select({ id: videos.id }) @@ -43,40 +36,91 @@ export async function addVideosToSpace( throw new Error("No valid videos found"); } - const existingSpaceVideos = await db() - .select({ videoId: spaceVideos.videoId }) - .from(spaceVideos) - .where( - and( - eq(spaceVideos.spaceId, spaceId), - inArray(spaceVideos.videoId, validVideoIds), - ), + if (isAllSpacesEntry) { + const existingSharedVideos = await db() + .select({ videoId: sharedVideos.videoId }) + .from(sharedVideos) + .where( + and( + eq(sharedVideos.organizationId, spaceId), + inArray(sharedVideos.videoId, validVideoIds), + ), + ); + + const existingVideoIds = existingSharedVideos.map((v) => v.videoId); + const newVideoIds = validVideoIds.filter( + (id) => !existingVideoIds.includes(id), ); - const existingVideoIds = existingSpaceVideos.map((sv) => sv.videoId); - const newVideoIds = validVideoIds.filter( - (id) => !existingVideoIds.includes(id), - ); + if (existingVideoIds.length > 0) { + await db() + .update(sharedVideos) + .set({ folderId: null }) + .where( + and( + eq(sharedVideos.organizationId, spaceId), + inArray(sharedVideos.videoId, existingVideoIds), + ), + ); + } + + // Insert new videos + if (newVideoIds.length > 0) { + const sharedVideoEntries = newVideoIds.map((videoId) => ({ + id: nanoId(), + videoId, + organizationId: spaceId, + sharedByUserId: user.id, + })); + await db().insert(sharedVideos).values(sharedVideoEntries); + } + } else { + // Check which videos already exist in spaceVideos + const existingSpaceVideos = await db() + .select({ videoId: spaceVideos.videoId }) + .from(spaceVideos) + .where( + and( + eq(spaceVideos.spaceId, spaceId), + inArray(spaceVideos.videoId, validVideoIds), + ), + ); + + const existingVideoIds = existingSpaceVideos.map((v) => v.videoId); + const newVideoIds = validVideoIds.filter( + (id) => !existingVideoIds.includes(id), + ); - if (newVideoIds.length === 0) { - return { success: true, message: "Videos already added to space" }; + if (existingVideoIds.length > 0) { + await db() + .update(spaceVideos) + .set({ folderId: null }) + .where( + and( + eq(spaceVideos.spaceId, spaceId), + inArray(spaceVideos.videoId, existingVideoIds), + ), + ); + } + + if (newVideoIds.length > 0) { + const spaceVideoEntries = newVideoIds.map((videoId) => ({ + id: nanoId(), + videoId, + spaceId, + addedById: user.id, + })); + + await db().insert(spaceVideos).values(spaceVideoEntries); + } } - const spaceVideoEntries = newVideoIds.map((videoId) => ({ - id: nanoId(), - videoId, - spaceId, - addedById: user.id, - })); - - await db().insert(spaceVideos).values(spaceVideoEntries); - revalidatePath(`/dashboard/spaces/${spaceId}`); revalidatePath("/dashboard/caps"); return { success: true, - message: `${newVideoIds.length} video${newVideoIds.length === 1 ? "" : "s"} added to space`, + message: `${validVideoIds.length} video${validVideoIds.length === 1 ? "" : "s"} added to ${isAllSpacesEntry ? "organization" : "space"}`, }; } catch (error) { console.error("Error adding videos to space:", error); diff --git a/apps/web/actions/spaces/get-space-videos.ts b/apps/web/actions/spaces/get-space-videos.ts index 7ebf0d61fe..ddbcc42b82 100644 --- a/apps/web/actions/spaces/get-space-videos.ts +++ b/apps/web/actions/spaces/get-space-videos.ts @@ -2,9 +2,9 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { spaceVideos } from "@cap/database/schema"; +import { sharedVideos, spaceVideos } from "@cap/database/schema"; import type { Space } from "@cap/web-domain"; -import { eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; export async function getSpaceVideoIds(spaceId: Space.SpaceIdOrOrganisationId) { try { @@ -18,12 +18,28 @@ export async function getSpaceVideoIds(spaceId: Space.SpaceIdOrOrganisationId) { throw new Error("Space ID is required"); } - const videoIds = await db() - .select({ - videoId: spaceVideos.videoId, - }) - .from(spaceVideos) - .where(eq(spaceVideos.spaceId, spaceId)); + const isAllSpacesEntry = user.activeOrganizationId === spaceId; + + const videoIds = isAllSpacesEntry + ? await db() + .select({ + videoId: sharedVideos.videoId, + }) + .from(sharedVideos) + .where( + and( + eq(sharedVideos.organizationId, spaceId), + isNull(sharedVideos.folderId), + ), + ) + : await db() + .select({ + videoId: spaceVideos.videoId, + }) + .from(spaceVideos) + .where( + and(eq(spaceVideos.spaceId, spaceId), isNull(spaceVideos.folderId)), + ); return { success: true, diff --git a/apps/web/actions/spaces/get-user-videos.ts b/apps/web/actions/spaces/get-user-videos.ts new file mode 100644 index 0000000000..201eb8647a --- /dev/null +++ b/apps/web/actions/spaces/get-user-videos.ts @@ -0,0 +1,140 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { + comments, + folders, + sharedVideos, + spaces, + spaceVideos, + users, + videos, + videoUploads, +} from "@cap/database/schema"; +import type { Space } from "@cap/web-domain"; +import { and, desc, eq, sql } from "drizzle-orm"; + +export async function getUserVideos(spaceId: Space.SpaceIdOrOrganisationId) { + try { + const user = await getCurrentUser(); + + if (!user || !user.id) { + throw new Error("Unauthorized"); + } + + const userId = user.id; + const isAllSpacesEntry = user.activeOrganizationId === spaceId; + + const selectFields = { + id: videos.id, + ownerId: videos.ownerId, + name: videos.name, + createdAt: videos.createdAt, + metadata: videos.metadata, + 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)`, + ownerName: users.name, + folderName: folders.name, + folderColor: folders.color, + effectiveDate: sql` + COALESCE( + JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.customCreatedAt')), + ${videos.createdAt} + ) + `, + hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( + Boolean, + ), + }; + + const videoData = isAllSpacesEntry + ? await db() + .select(selectFields) + .from(videos) + .leftJoin(comments, eq(videos.id, comments.videoId)) + .leftJoin(users, eq(videos.ownerId, users.id)) + .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) + .leftJoin( + sharedVideos, + and( + eq(videos.id, sharedVideos.videoId), + eq(sharedVideos.organizationId, spaceId), + ), + ) + .leftJoin(folders, eq(sharedVideos.folderId, folders.id)) + .leftJoin(spaces, eq(folders.spaceId, spaces.id)) + .where(eq(videos.ownerId, userId)) + .groupBy( + videos.id, + videos.ownerId, + videos.name, + videos.createdAt, + videos.metadata, + users.name, + folders.name, + folders.color, + folders.spaceId, + videos.folderId, + ) + .orderBy( + desc(sql`COALESCE( + JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.customCreatedAt')), + ${videos.createdAt} + )`), + ) + : await db() + .select(selectFields) + .from(videos) + .leftJoin(comments, eq(videos.id, comments.videoId)) + .leftJoin(users, eq(videos.ownerId, users.id)) + .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) + .leftJoin( + spaceVideos, + and( + eq(videos.id, spaceVideos.videoId), + eq(spaceVideos.spaceId, spaceId), + ), + ) + .leftJoin(folders, eq(spaceVideos.folderId, folders.id)) + .leftJoin(spaces, eq(folders.spaceId, spaces.id)) + .where(eq(videos.ownerId, userId)) + .groupBy( + videos.id, + videos.ownerId, + videos.name, + videos.createdAt, + videos.metadata, + users.name, + folders.name, + folders.color, + folders.spaceId, + videos.folderId, + ) + .orderBy( + desc(sql`COALESCE( + JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.customCreatedAt')), + ${videos.createdAt} + )`), + ); + + const processedVideoData = videoData.map((video) => { + const { effectiveDate: _effectiveDate, ...videoWithoutEffectiveDate } = + video; + return { + ...videoWithoutEffectiveDate, + ownerName: video.ownerName ?? "", + folderName: video.folderName ?? null, + folderColor: video.folderColor ?? null, + metadata: video.metadata as + | { customCreatedAt?: string; [key: string]: unknown } + | undefined, + }; + }); + + return { success: true, data: processedVideoData }; + } catch (error) { + console.error("Error fetching user videos:", error); + return { success: false, error: "Failed to fetch videos" }; + } +} diff --git a/apps/web/actions/spaces/remove-videos.ts b/apps/web/actions/spaces/remove-videos.ts index dcf39fb264..fb732ad87e 100644 --- a/apps/web/actions/spaces/remove-videos.ts +++ b/apps/web/actions/spaces/remove-videos.ts @@ -2,7 +2,7 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { folders, spaceVideos, videos } from "@cap/database/schema"; +import { sharedVideos, spaceVideos, videos } from "@cap/database/schema"; import type { Space, Video } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; @@ -34,32 +34,24 @@ export async function removeVideosFromSpace( throw new Error("No valid videos found"); } - // Remove from spaceVideos - await db() - .delete(spaceVideos) - .where( - and( - eq(spaceVideos.spaceId, spaceId), - inArray(spaceVideos.videoId, validVideoIds), - ), - ); + const isAllSpacesEntry = user.activeOrganizationId === spaceId; - // Set folderId to null for any removed videos that are in folders belonging to this space - // Find all folder IDs in this space - const folderRows = await db() - .select({ id: folders.id }) - .from(folders) - .where(eq(folders.spaceId, spaceId)); - const folderIds = folderRows.map((f) => f.id); - - if (folderIds.length > 0) { + if (isAllSpacesEntry) { + await db() + .delete(sharedVideos) + .where( + and( + eq(sharedVideos.organizationId, spaceId), + inArray(sharedVideos.videoId, validVideoIds), + ), + ); + } else { await db() - .update(videos) - .set({ folderId: null }) + .delete(spaceVideos) .where( and( - inArray(videos.id, validVideoIds), - inArray(videos.folderId, folderIds), + eq(spaceVideos.spaceId, spaceId), + inArray(spaceVideos.videoId, validVideoIds), ), ); } @@ -68,13 +60,16 @@ export async function removeVideosFromSpace( return { success: true, - message: `Removed ${validVideoIds.length} video(s) from space and folders`, + message: `Removed ${validVideoIds.length} video(s) from ${isAllSpacesEntry ? "organization" : "space"} and folders`, deletedCount: validVideoIds.length, }; - } catch (error: any) { + } catch (error) { return { success: false, - message: error.message || "Failed to remove videos from space", + message: + error instanceof Error + ? error.message + : "Failed to remove videos from space", }; } } diff --git a/apps/web/actions/videos/get-user-videos.ts b/apps/web/actions/videos/get-user-videos.ts deleted file mode 100644 index d12d4c0431..0000000000 --- a/apps/web/actions/videos/get-user-videos.ts +++ /dev/null @@ -1,75 +0,0 @@ -"use server"; - -import { db } from "@cap/database"; -import { getCurrentUser } from "@cap/database/auth/session"; -import { comments, users, videos, videoUploads } from "@cap/database/schema"; -import { desc, eq, sql } from "drizzle-orm"; - -export async function getUserVideos(limit?: number) { - try { - const user = await getCurrentUser(); - - if (!user || !user.id) { - throw new Error("Unauthorized"); - } - - const userId = user.id; - - const videoData = await db() - .select({ - id: videos.id, - ownerId: videos.ownerId, - name: videos.name, - createdAt: videos.createdAt, - metadata: videos.metadata, - 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)`, - ownerName: users.name, - effectiveDate: sql` - COALESCE( - JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.customCreatedAt')), - ${videos.createdAt} - ) - `, - hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( - Boolean, - ), - }) - .from(videos) - .leftJoin(comments, eq(videos.id, comments.videoId)) - .leftJoin(users, eq(videos.ownerId, users.id)) - .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) - .where(eq(videos.ownerId, userId)) - .groupBy( - videos.id, - videos.ownerId, - videos.name, - videos.createdAt, - videos.metadata, - users.name, - ) - .orderBy( - desc(sql`COALESCE( - JSON_UNQUOTE(JSON_EXTRACT(${videos.metadata}, '$.customCreatedAt')), - ${videos.createdAt} - )`), - ) - .limit(limit || 20); - - const processedVideoData = videoData.map((video) => { - const { effectiveDate, ...videoWithoutEffectiveDate } = video; - return { - ...videoWithoutEffectiveDate, - ownerName: video.ownerName ?? "", - metadata: video.metadata as - | { customCreatedAt?: string; [key: string]: any } - | undefined, - }; - }); - - return { success: true, data: processedVideoData }; - } catch (error) { - console.error("Error fetching user videos:", error); - return { success: false, error: "Failed to fetch videos" }; - } -} diff --git a/apps/web/app/(org)/dashboard/caps/components/Folder.tsx b/apps/web/app/(org)/dashboard/caps/components/Folder.tsx index 51b2b846d1..239821e25a 100644 --- a/apps/web/app/(org)/dashboard/caps/components/Folder.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/Folder.tsx @@ -1,5 +1,5 @@ "use client"; -import type { Folder } from "@cap/web-domain"; +import type { Folder, Space } from "@cap/web-domain"; import { faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Fit, Layout, useRive } from "@rive-app/react-canvas"; @@ -21,8 +21,8 @@ export type FolderDataType = { id: Folder.FolderId; color: "normal" | "blue" | "red" | "yellow"; videoCount: number; - spaceId?: string | null; - parentId?: string | null; + spaceId?: Space.SpaceIdOrOrganisationId | null; + parentId: Folder.FolderId | null; }; const FolderCard = ({ diff --git a/apps/web/app/(org)/dashboard/caps/page.tsx b/apps/web/app/(org)/dashboard/caps/page.tsx index ad73d21aa1..f78bb69d82 100644 --- a/apps/web/app/(org)/dashboard/caps/page.tsx +++ b/apps/web/app/(org)/dashboard/caps/page.tsx @@ -133,20 +133,6 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { .where(eq(organizations.id, user.activeOrganizationId)) .limit(1); - let customDomain: string | null = null; - let domainVerified = false; - - if ( - organizationData.length > 0 && - organizationData[0] && - organizationData[0].customDomain - ) { - customDomain = organizationData[0].customDomain; - if (organizationData[0].domainVerified !== null) { - domainVerified = true; - } - } - const videoData = await db() .select({ id: videos.id, diff --git a/apps/web/app/(org)/dashboard/folder/[id]/components/BreadcrumbItem.tsx b/apps/web/app/(org)/dashboard/folder/[id]/components/BreadcrumbItem.tsx index 30f486272c..8e41ade757 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/components/BreadcrumbItem.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/components/BreadcrumbItem.tsx @@ -1,6 +1,6 @@ "use client"; -import type { Folder } from "@cap/web-domain"; +import type { Folder, Space } from "@cap/web-domain"; import clsx from "clsx"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -15,6 +15,7 @@ interface BreadcrumbItemProps { id: Folder.FolderId; name: string; color: "normal" | "blue" | "red" | "yellow"; + spaceId?: Space.SpaceIdOrOrganisationId | null; isLast: boolean; } @@ -23,6 +24,7 @@ export function BreadcrumbItem({ name, color, isLast, + spaceId, }: BreadcrumbItemProps) { const [isDragOver, setIsDragOver] = useState(false); const [isMoving, setIsMoving] = useState(false); @@ -59,7 +61,11 @@ export function BreadcrumbItem({ if (!capData.id) return; setIsMoving(true); - await moveVideoToFolder({ videoId: capData.id, folderId: id }); + await moveVideoToFolder({ + videoId: capData.id, + folderId: id, + spaceId: spaceId ?? null, + }); router.refresh(); toast.success(`"${capData.name}" moved to "${name}" folder`); } catch (error) { diff --git a/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx b/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx index 70ed6af15c..9d135f4c14 100644 --- a/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx +++ b/apps/web/app/(org)/dashboard/folder/[id]/components/ClientMyCapsLink.tsx @@ -1,7 +1,7 @@ "use client"; import { Avatar } from "@cap/ui"; -import type { Video } from "@cap/web-domain"; +import type { Space, Video } from "@cap/web-domain"; import clsx from "clsx"; import Image from "next/image"; import Link from "next/link"; @@ -12,7 +12,11 @@ import { moveVideoToFolder } from "@/actions/folders/moveVideoToFolder"; import { useDashboardContext } from "../../../Contexts"; import { registerDropTarget } from "./ClientCapCard"; -export function ClientMyCapsLink() { +export function ClientMyCapsLink({ + spaceId, +}: { + spaceId?: Space.SpaceIdOrOrganisationId; +}) { const [isDragOver, setIsDragOver] = useState(false); const [isMovingVideo, setIsMovingVideo] = useState(false); const linkRef = useRef(null); @@ -90,7 +94,7 @@ export function ClientMyCapsLink() { await moveVideoToFolder({ videoId: capData.id, folderId: null, - spaceId: activeSpace?.id, + spaceId: spaceId ?? null, }); router.refresh(); if (activeSpace) { @@ -109,9 +113,7 @@ export function ClientMyCapsLink() { return ( { const [childFolders, breadcrumb, videosData] = yield* Effect.all([ getChildFolders(params.id, { variant: "user" }), getFolderBreadcrumb(params.id), - getVideosByFolderId(params.id), + getVideosByFolderId(params.id, { + variant: "user", + }), ]); return ( diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx index cf0b63387f..b116ce8cd3 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx @@ -46,6 +46,7 @@ export const SharedCaps = ({ data, count, spaceData, + spaceId, spaceMembers, organizationMembers, currentUserId, @@ -57,6 +58,7 @@ export const SharedCaps = ({ count: number; dubApiKeyEnabled: boolean; spaceData?: SpaceData; + spaceId: Space.SpaceIdOrOrganisationId; hideSharedWith?: boolean; spaceMembers?: SpaceMemberData[]; organizationMembers?: OrganizationMemberData[]; @@ -180,7 +182,7 @@ export const SharedCaps = ({ setIsAddVideosDialogOpen(false)} - spaceId={spaceData.id} + spaceId={spaceId} spaceName={spaceData.name} onVideosAdded={handleVideosAdded} /> @@ -192,6 +194,7 @@ export const SharedCaps = ({ organizationId={organizationData.id} organizationName={organizationData.name} onVideosAdded={handleVideosAdded} + spaceId={spaceId} /> )} @@ -246,7 +249,7 @@ export const SharedCaps = ({ setIsAddVideosDialogOpen(false)} - spaceId={spaceData.id} + spaceId={spaceId} spaceName={spaceData.name} onVideosAdded={handleVideosAdded} /> @@ -258,6 +261,7 @@ export const SharedCaps = ({ organizationId={organizationData.id} organizationName={organizationData.name} onVideosAdded={handleVideosAdded} + spaceId={spaceId} /> )} ))} @@ -294,7 +300,7 @@ function AddVideosDialogBase({ entityVideoIds={entityVideoIds || []} height={300} columnCount={3} - rowHeight={200} + rowHeight={230} /> )} diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/AddVideosToOrganizationDialog.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/AddVideosToOrganizationDialog.tsx index bd5ece90c0..5810e08046 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/AddVideosToOrganizationDialog.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/AddVideosToOrganizationDialog.tsx @@ -5,7 +5,7 @@ import type React from "react"; import { addVideosToOrganization } from "@/actions/organizations/add-videos"; import { getOrganizationVideoIds } from "@/actions/organizations/get-organization-videos"; import { removeVideosFromOrganization } from "@/actions/organizations/remove-videos"; -import { getUserVideos } from "@/actions/videos/get-user-videos"; +import { getUserVideos } from "@/actions/spaces/get-user-videos"; import AddVideosDialogBase from "./AddVideosDialogBase"; interface AddVideosToOrganizationDialogProps { @@ -14,6 +14,7 @@ interface AddVideosToOrganizationDialogProps { organizationId: Organisation.OrganisationId; organizationName: string; onVideosAdded?: () => void; + spaceId: string; } export const AddVideosToOrganizationDialog: React.FC< @@ -28,8 +29,8 @@ export const AddVideosToOrganizationDialog: React.FC< onVideosAdded={onVideosAdded} removeVideos={removeVideosFromOrganization} addVideos={addVideosToOrganization} - getVideos={getUserVideos} - getEntityVideoIds={getOrganizationVideoIds} + getVideos={() => getUserVideos(organizationId)} + getEntityVideoIds={() => getOrganizationVideoIds(organizationId)} /> ); }; diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VideoCard.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VideoCard.tsx index 590f1f75db..884508aca3 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VideoCard.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VideoCard.tsx @@ -1,9 +1,13 @@ +import { faHome, faRecordVinyl } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Fit, Layout, useRive } from "@rive-app/react-canvas"; import clsx from "clsx"; import { AnimatePresence, motion } from "framer-motion"; import { Check, Minus, Plus } from "lucide-react"; import moment from "moment"; import type React from "react"; import { memo, useState } from "react"; +import { useTheme } from "@/app/(org)/dashboard/Contexts"; import { Tooltip } from "@/components/Tooltip"; import { type ImageLoadingStatus, @@ -21,6 +25,7 @@ interface VideoCardProps { const VideoCard: React.FC = memo( ({ video, isSelected, onToggle, isAlreadyInEntity, className }) => { + const { theme } = useTheme(); const effectiveDate = video.metadata?.customCreatedAt ? new Date(video.metadata.customCreatedAt) : video.createdAt; @@ -28,11 +33,37 @@ const VideoCard: React.FC = memo( const [imageStatus, setImageStatus] = useState("loading"); + const folderColor = video.folderColor || "normal"; + const artboard = + theme === "dark" && folderColor === "normal" + ? "folder" + : folderColor === "normal" + ? "folder-dark" + : `folder-${folderColor}`; + + const { RiveComponent: FolderRive } = useRive({ + src: "/rive/dashboard.riv", + artboard, + animations: "idle", + autoplay: true, + layout: new Layout({ + fit: Fit.Contain, + }), + }); + return (
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onToggle(); + } + }} className={clsx( - "flex relative flex-col p-3 w-full min-h-fit rounded-xl border transition-all duration-200 group", + "flex relative flex-col p-3 w-full h-full rounded-xl border transition-all duration-200 group", className, isAlreadyInEntity && isSelected && "border-red-500", isAlreadyInEntity && !isSelected && "border-blue-500", @@ -115,20 +146,51 @@ const VideoCard: React.FC = memo( />
-
- -

- {video.name} -

-
-

- {moment(effectiveDate).format("MMM D, YYYY")} -

+
+
+ +

+ {video.name} +

+
+

+ {moment(effectiveDate).format("MMM D, YYYY")} +

+
+
+ {video.folderName ? ( + <> + +

+ {video.folderName} +

+ + ) : isAlreadyInEntity ? ( + <> + +

Root

+ + ) : ( + <> + +

Caps

+ + )} +
); diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VirtualizedVideoGrid.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VirtualizedVideoGrid.tsx index e991bb89f5..e4c688a404 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VirtualizedVideoGrid.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/VirtualizedVideoGrid.tsx @@ -83,7 +83,7 @@ const VirtualizedVideoGrid = ({ return (
+ + setOpen(false)} + entityId={folderId} + entityName={folderName} + onVideosAdded={() => { + router.refresh(); + }} + addVideos={(folderIdArg, videoIds) => + addVideosToFolder(folderIdArg, videoIds, spaceId) + } + removeVideos={(folderIdArg, videoIds) => + removeVideosFromFolder(folderIdArg, videoIds, spaceId) + } + getVideos={() => getUserVideos(spaceId)} + getEntityVideoIds={() => getFolderVideoIds(folderId, spaceId)} + /> + + ); +} diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx index 4af71d3a4e..655f78b9d5 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/folder/[folderId]/page.tsx @@ -17,9 +17,13 @@ import { NewSubfolderButton, } from "../../../../folder/[id]/components"; import FolderVideosSection from "../../../../folder/[id]/components/FolderVideosSection"; +import AddVideosButton from "./AddVideosButton"; const FolderPage = async (props: { - params: Promise<{ spaceId: string; folderId: Folder.FolderId }>; + params: Promise<{ + spaceId: Space.SpaceIdOrOrganisationId; + folderId: Folder.FolderId; + }>; }) => { const params = await props.params; const user = await getCurrentUser(); @@ -40,21 +44,32 @@ const FolderPage = async (props: { : { variant: "org", organizationId: spaceOrOrg.organization.id }, ), getFolderBreadcrumb(params.folderId), - getVideosByFolderId(params.folderId), + getVideosByFolderId( + params.folderId, + spaceOrOrg.variant === "space" + ? { variant: "space", spaceId: spaceOrOrg.space.id } + : { variant: "org", organizationId: spaceOrOrg.organization.id }, + ), ]); return (
+
- + {breadcrumb.map((folder, index) => (

/

`( - SELECT COUNT(*) FROM videos WHERE videos.folderId = folders.id + SELECT COUNT(*) FROM ${table} WHERE ${table}.folderId = folders.id )`, }) .from(folders) @@ -110,7 +114,7 @@ export default async function SharedCapsPage(props: { await Promise.all([ fetchSpaceMembers(space.id), fetchOrganizationMembers(space.organizationId), - fetchFolders(space.id), + fetchFolders(space.id, false), ]); async function fetchSpaceVideos( @@ -195,6 +199,7 @@ export default async function SharedCapsPage(props: { data={processedVideoData} count={totalCount} spaceData={space} + spaceId={params.spaceId as Space.SpaceIdOrOrganisationId} dubApiKeyEnabled={!!serverEnv().DUB_API_KEY} spaceMembers={spaceMembersData} organizationMembers={organizationMembersData} @@ -238,7 +243,7 @@ export default async function SharedCapsPage(props: { .where( and( eq(sharedVideos.organizationId, orgId), - isNull(videos.folderId), + isNull(sharedVideos.folderId), ), ) .groupBy( @@ -280,7 +285,7 @@ export default async function SharedCapsPage(props: { await Promise.all([ fetchOrganizationVideos(organization.id, page, limit), fetchOrganizationMembers(organization.id), - fetchFolders(organization.id), + fetchFolders(organization.id, true), ]); const { videos: orgVideoData, totalCount } = organizationVideos; @@ -302,6 +307,7 @@ export default async function SharedCapsPage(props: { count={totalCount} hideSharedWith organizationData={organization} + spaceId={params.spaceId as Space.SpaceIdOrOrganisationId} dubApiKeyEnabled={!!serverEnv().DUB_API_KEY} organizationMembers={organizationMembersData} currentUserId={user.id} diff --git a/apps/web/lib/folder.ts b/apps/web/lib/folder.ts index 89b686d5a5..ed32731545 100644 --- a/apps/web/lib/folder.ts +++ b/apps/web/lib/folder.ts @@ -1,7 +1,5 @@ import "server-only"; -import { db } from "@cap/database"; -import { getCurrentUser } from "@cap/database/auth/session"; import { comments, folders, @@ -19,7 +17,6 @@ import { CurrentUser, Folder } from "@cap/web-domain"; import { and, desc, eq } 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"); @@ -159,6 +156,10 @@ const getSharedSpacesForVideos = Effect.fn(function* ( export const getVideosByFolderId = Effect.fn(function* ( folderId: Folder.FolderId, + root: + | { variant: "user" } + | { variant: "space"; spaceId: Space.SpaceIdOrOrganisationId } + | { variant: "org"; organizationId: Organisation.OrganisationId }, ) { if (!folderId) throw new Error("Folder ID is required"); const db = yield* Database; @@ -205,13 +206,20 @@ export const getVideosByFolderId = Effect.fn(function* ( .from(videos) .leftJoin(comments, eq(videos.id, comments.videoId)) .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) + .leftJoin(spaceVideos, eq(videos.id, spaceVideos.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)) + .where( + root.variant === "space" + ? eq(spaceVideos.folderId, folderId) + : root.variant === "org" + ? eq(sharedVideos.folderId, folderId) + : eq(videos.folderId, folderId), + ) .groupBy( videos.id, videos.ownerId, diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index f3efe3fbdc..d045a0c84c 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1759139970377, "tag": "0008_condemned_gamora", "breakpoints": true + }, + { + "idx": 9, + "version": "5", + "when": 1759993551600, + "tag": "0009_sad_carmella_unuscione", + "breakpoints": true } ] } diff --git a/packages/database/schema.ts b/packages/database/schema.ts index bac98ae965..11d29c3e9b 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -336,6 +336,7 @@ export const sharedVideos = mysqlTable( { id: nanoId("id").notNull().primaryKey().unique(), videoId: nanoId("videoId").notNull().$type(), + folderId: nanoIdNullable("folderId").$type(), organizationId: nanoId("organizationId") .notNull() .$type(), @@ -344,6 +345,7 @@ export const sharedVideos = mysqlTable( }, (table) => ({ videoIdIndex: index("video_id_idx").on(table.videoId), + folderIdIndex: index("folder_id_idx").on(table.folderId), organizationIdIndex: index("organization_id_idx").on(table.organizationId), sharedByUserIdIndex: index("shared_by_user_id_idx").on( table.sharedByUserId, @@ -352,6 +354,10 @@ export const sharedVideos = mysqlTable( table.videoId, table.organizationId, ), + videoIdFolderIdIndex: index("video_id_folder_id_idx").on( + table.videoId, + table.folderId, + ), }), );