diff --git a/apps/web/actions/organization/settings.ts b/apps/web/actions/organization/settings.ts new file mode 100644 index 0000000000..212b712fb1 --- /dev/null +++ b/apps/web/actions/organization/settings.ts @@ -0,0 +1,44 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { organizations } from "@cap/database/schema"; +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export async function updateOrganizationSettings(settings: { + disableSummary?: boolean; + disableCaptions?: boolean; + disableChapters?: boolean; + disableReactions?: boolean; + disableTranscript?: boolean; + disableComments?: boolean; +}) { + const user = await getCurrentUser(); + + if (!user) { + throw new Error("Unauthorized"); + } + + if (!settings) { + throw new Error("Settings are required"); + } + + const [organization] = await db() + .select() + .from(organizations) + .where(eq(organizations.id, user.activeOrganizationId)); + + if (!organization) { + throw new Error("Organization not found"); + } + + await db() + .update(organizations) + .set({ settings }) + .where(eq(organizations.id, user.activeOrganizationId)); + + revalidatePath("/dashboard/caps"); + + return { success: true }; +} diff --git a/apps/web/actions/videos/generate-ai-metadata.ts b/apps/web/actions/videos/generate-ai-metadata.ts index 80d987fcb0..ec2f4910d8 100644 --- a/apps/web/actions/videos/generate-ai-metadata.ts +++ b/apps/web/actions/videos/generate-ai-metadata.ts @@ -41,7 +41,6 @@ export async function generateAiMetadata( const updatedAtTime = new Date(videoData.updatedAt).getTime(); const currentTime = new Date().getTime(); const tenMinutesInMs = 10 * 60 * 1000; - const minutesElapsed = Math.round((currentTime - updatedAtTime) / 60000); if (currentTime - updatedAtTime > tenMinutesInMs) { await db() diff --git a/apps/web/actions/videos/get-status.ts b/apps/web/actions/videos/get-status.ts index eb4a0d8cd2..93d2daacd4 100644 --- a/apps/web/actions/videos/get-status.ts +++ b/apps/web/actions/videos/get-status.ts @@ -14,8 +14,10 @@ import { generateAiMetadata } from "./generate-ai-metadata"; const MAX_AI_PROCESSING_TIME = 10 * 60 * 1000; +type TranscriptionStatus = "PROCESSING" | "COMPLETE" | "ERROR" | "SKIPPED"; + export interface VideoStatusResult { - transcriptionStatus: "PROCESSING" | "COMPLETE" | "ERROR" | null; + transcriptionStatus: TranscriptionStatus | null; aiTitle: string | null; aiProcessing: boolean; summary: string | null; @@ -124,10 +126,7 @@ export async function getVideoStatus( return { transcriptionStatus: - (updatedVideo.transcriptionStatus as - | "PROCESSING" - | "COMPLETE" - | "ERROR") || null, + (updatedVideo.transcriptionStatus as TranscriptionStatus) || null, aiProcessing: false, aiTitle: updatedMetadata.aiTitle || null, summary: updatedMetadata.summary || null, @@ -214,8 +213,7 @@ export async function getVideoStatus( return { transcriptionStatus: - (video.transcriptionStatus as "PROCESSING" | "COMPLETE" | "ERROR") || - null, + (video.transcriptionStatus as TranscriptionStatus) || null, aiProcessing: true, aiTitle: metadata.aiTitle || null, summary: metadata.summary || null, @@ -232,8 +230,7 @@ export async function getVideoStatus( return { transcriptionStatus: - (video.transcriptionStatus as "PROCESSING" | "COMPLETE" | "ERROR") || - null, + (video.transcriptionStatus as TranscriptionStatus) || null, aiProcessing: metadata.aiProcessing || false, aiTitle: metadata.aiTitle || null, summary: metadata.summary || null, diff --git a/apps/web/actions/videos/settings.ts b/apps/web/actions/videos/settings.ts new file mode 100644 index 0000000000..430b50f18a --- /dev/null +++ b/apps/web/actions/videos/settings.ts @@ -0,0 +1,45 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { videos } from "@cap/database/schema"; +import type { Video } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; + +export async function updateVideoSettings( + videoId: Video.VideoId, + videoSettings: { + disableSummary?: boolean; + disableCaptions?: boolean; + disableChapters?: boolean; + disableReactions?: boolean; + disableTranscript?: boolean; + disableComments?: boolean; + }, +) { + const user = await getCurrentUser(); + + if (!user || !videoId || !videoSettings) { + throw new Error("Missing required data for updating video settings"); + } + + const [video] = await db() + .select() + .from(videos) + .where(eq(videos.id, videoId)); + + if (!video) { + throw new Error("Video not found for updating video settings"); + } + + if (video.ownerId !== user.id) { + throw new Error("You don't have permission to update this video settings"); + } + + await db() + .update(videos) + .set({ settings: videoSettings }) + .where(eq(videos.id, videoId)); + + return { success: true }; +} diff --git a/apps/web/app/(org)/dashboard/Contexts.tsx b/apps/web/app/(org)/dashboard/Contexts.tsx index 3bd54d2b5d..a8799701e9 100644 --- a/apps/web/app/(org)/dashboard/Contexts.tsx +++ b/apps/web/app/(org)/dashboard/Contexts.tsx @@ -6,11 +6,17 @@ import Cookies from "js-cookie"; import { usePathname } from "next/navigation"; import { createContext, useContext, useEffect, useState } from "react"; import { UpgradeModal } from "@/components/UpgradeModal"; -import type { Organization, Spaces, UserPreferences } from "./dashboard-data"; +import type { + Organization, + OrganizationSettings, + Spaces, + UserPreferences, +} from "./dashboard-data"; type SharedContext = { organizationData: Organization[] | null; activeOrganization: Organization | null; + organizationSettings: OrganizationSettings | null; spacesData: Spaces[] | null; userSpaces: Spaces[] | null; sharedSpaces: Spaces[] | null; @@ -50,6 +56,7 @@ export function DashboardContexts({ spacesData, user, isSubscribed, + organizationSettings, userPreferences, anyNewNotifications, initialTheme, @@ -62,6 +69,7 @@ export function DashboardContexts({ spacesData: SharedContext["spacesData"]; user: SharedContext["user"]; isSubscribed: SharedContext["isSubscribed"]; + organizationSettings: SharedContext["organizationSettings"]; userPreferences: SharedContext["userPreferences"]; anyNewNotifications: boolean; initialTheme: ITheme; @@ -154,6 +162,7 @@ export function DashboardContexts({ spacesData, anyNewNotifications, userPreferences, + organizationSettings, userSpaces, sharedSpaces, activeSpace, diff --git a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx index b4cd329016..b474227c5a 100644 --- a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx +++ b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx @@ -44,7 +44,7 @@ const MobileTab = () => { } }); return ( -
+
{open && } diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index 2d88b13c64..560205301e 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -94,7 +94,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { position="right" content={activeOrg?.organization.name ?? "No organization found"} > - + + setIsSettingsDialogOpen(false)} + /> setIsPasswordDialogOpen(false)} @@ -323,6 +342,31 @@ export const CapCard = ({ "top-2 right-2 flex-col gap-2 z-[51]", )} > + {isOwner ? ( + { + e.stopPropagation(); + setIsSettingsDialogOpen(true); + }} + className="delay-0" + icon={() => { + return ; + }} + /> + ) : ( + { + e.stopPropagation(); + handleDownload(); + }} + className="delay-0" + icon={() => ( + + )} + /> + )} { @@ -363,54 +407,10 @@ export const CapCard = ({ ); }} /> - { - e.stopPropagation(); - handleDownload(); - }} - disabled={ - downloadMutation.isPending || - (enableBetaUploadProgress && cap.hasActiveUpload) - } - className="delay-25" - icon={() => { - return downloadMutation.isPending ? ( -
- -
- ) : ( - - ); - }} - /> {isOwner && ( - +
- + + { + e.stopPropagation(); + handleDownload(); + }} + className="flex gap-2 items-center rounded-lg" + > + +

Download

+
{ toast.promise(duplicateMutation.mutateAsync(), { @@ -522,8 +536,8 @@ export const CapCard = ({ href={`/s/${cap.id}`} > {imageStatus !== "success" && uploadProgress ? ( -
-
+
+
{uploadProgress.status === "failed" ? (
diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCardButton.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCardButton.tsx index f6e9504e85..61868a1f9a 100644 --- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCardButton.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCardButton.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Button } from "@cap/ui"; import clsx from "clsx"; import type { MouseEvent, ReactNode } from "react"; @@ -23,7 +25,7 @@ export const CapCardButton = ({ return ( + + + + + ); +}; diff --git a/apps/web/app/(org)/dashboard/caps/page.tsx b/apps/web/app/(org)/dashboard/caps/page.tsx index e9bd381e4e..d381db2b3b 100644 --- a/apps/web/app/(org)/dashboard/caps/page.tsx +++ b/apps/web/app/(org)/dashboard/caps/page.tsx @@ -175,20 +175,27 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { hasActiveUpload: sql`${videoUploads.videoId} IS NOT NULL`.mapWith( Boolean, ), + settings: videos.settings, }) .from(videos) .leftJoin(comments, eq(videos.id, comments.videoId)) - .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) - .leftJoin(organizations, eq(sharedVideos.organizationId, organizations.id)) + .leftJoin(organizations, eq(videos.orgId, organizations.id)) .leftJoin(users, eq(videos.ownerId, users.id)) .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) - .where(and(eq(videos.ownerId, userId), isNull(videos.folderId))) + .where( + and( + eq(videos.ownerId, userId), + eq(videos.orgId, user.activeOrganizationId), + isNull(videos.folderId), + ), + ) .groupBy( videos.id, videos.ownerId, videos.name, videos.createdAt, videos.metadata, + videos.orgId, users.name, ) .orderBy( @@ -230,6 +237,7 @@ export default async function CapsPage(props: PageProps<"/dashboard/caps">) { ...videoWithoutEffectiveDate, id: Video.VideoId.make(video.id), foldersData, + settings: video.settings, sharedOrganizations: Array.isArray(video.sharedOrganizations) ? video.sharedOrganizations.filter( (organization) => organization.id !== null, diff --git a/apps/web/app/(org)/dashboard/dashboard-data.ts b/apps/web/app/(org)/dashboard/dashboard-data.ts index c581a783d6..b41b75741c 100644 --- a/apps/web/app/(org)/dashboard/dashboard-data.ts +++ b/apps/web/app/(org)/dashboard/dashboard-data.ts @@ -25,6 +25,10 @@ export type Organization = { totalInvites: number; }; +export type OrganizationSettings = NonNullable< + (typeof organizations.$inferSelect)["settings"] +>; + export type Spaces = Omit< typeof spaces.$inferSelect, "createdAt" | "updatedAt" @@ -40,6 +44,7 @@ export async function getDashboardData(user: typeof userSelectProps) { const organizationsWithMembers = await db() .select({ organization: organizations, + settings: organizations.settings, member: organizationMembers, user: { id: users.id, @@ -79,6 +84,7 @@ export async function getDashboardData(user: typeof userSelectProps) { let anyNewNotifications = false; let spacesData: Spaces[] = []; + let organizationSettings: OrganizationSettings | null = null; // Find active organization ID @@ -89,6 +95,7 @@ export async function getDashboardData(user: typeof userSelectProps) { if (!activeOrganizationId && organizationIds.length > 0) { activeOrganizationId = organizationIds[0]; } + // Only fetch spaces for the active organization if (activeOrganizationId) { @@ -106,6 +113,12 @@ export async function getDashboardData(user: typeof userSelectProps) { anyNewNotifications = !!notification; + const [organizationSetting] = await db() + .select({ settings: organizations.settings }) + .from(organizations) + .where(eq(organizations.id, activeOrganizationId)); + organizationSettings = organizationSetting?.settings || null; + spacesData = await db() .select({ id: spaces.id, @@ -262,6 +275,7 @@ export async function getDashboardData(user: typeof userSelectProps) { return { organizationSelect, + organizationSettings, spacesData, anyNewNotifications, userPreferences, @@ -273,6 +287,7 @@ export async function getDashboardData(user: typeof userSelectProps) { spacesData: [], anyNewNotifications: false, userPreferences: null, + organizationSettings: null, }; } } diff --git a/apps/web/app/(org)/dashboard/layout.tsx b/apps/web/app/(org)/dashboard/layout.tsx index 336837c4b5..99d71fdbe6 100644 --- a/apps/web/app/(org)/dashboard/layout.tsx +++ b/apps/web/app/(org)/dashboard/layout.tsx @@ -10,6 +10,7 @@ import { UploadingProvider } from "./caps/UploadingContext"; import { getDashboardData, type Organization, + type OrganizationSettings, type Spaces, type UserPreferences, } from "./dashboard-data"; @@ -32,18 +33,21 @@ export default async function DashboardLayout({ } let organizationSelect: Organization[] = []; + let organizationSettings: OrganizationSettings | null = null; let spacesData: Spaces[] = []; let anyNewNotifications = false; let userPreferences: UserPreferences; try { const dashboardData = await getDashboardData(user); organizationSelect = dashboardData.organizationSelect; + organizationSettings = dashboardData.organizationSettings; userPreferences = dashboardData.userPreferences?.preferences || null; spacesData = dashboardData.spacesData; anyNewNotifications = dashboardData.anyNewNotifications; } catch (error) { console.error("Failed to load dashboard data", error); organizationSelect = []; + organizationSettings = null; spacesData = []; anyNewNotifications = false; userPreferences = null; @@ -70,6 +74,7 @@ export default async function DashboardLayout({ return ( {
+
+ +
+ { - const [activeTab, setActiveTab] = useState("Notifications"); + const { user, organizationSettings } = useDashboardContext(); + const [settings, setSettings] = useState( + organizationSettings || { + disableComments: false, + disableSummary: false, + disableCaptions: false, + disableChapters: false, + disableReactions: false, + disableTranscript: false, + }, + ); + + const lastSavedSettings = useRef( + organizationSettings || settings, + ); + + const isUserPro = userIsPro(user); + + const debouncedUpdateSettings = useDebounce(settings, 1000); + + useEffect(() => { + const next = organizationSettings ?? { + disableComments: false, + disableSummary: false, + disableCaptions: false, + disableChapters: false, + disableReactions: false, + disableTranscript: false, + }; + setSettings(next); + lastSavedSettings.current = next; + }, [organizationSettings]); + + useEffect(() => { + if ( + debouncedUpdateSettings && + debouncedUpdateSettings !== lastSavedSettings.current + ) { + const handleUpdate = async () => { + const changedKeys: Array = []; + for (const key of Object.keys(debouncedUpdateSettings) as Array< + keyof OrganizationSettings + >) { + if ( + debouncedUpdateSettings[key] !== lastSavedSettings.current?.[key] + ) { + changedKeys.push(key); + } + } + + if (changedKeys.length === 0) { + return; + } + + try { + await updateOrganizationSettings(debouncedUpdateSettings); + + changedKeys.forEach((changedKey) => { + const option = options.find((opt) => opt.value === changedKey); + const isDisabled = debouncedUpdateSettings[changedKey]; + const action = isDisabled ? "disabled" : "enabled"; + const label = option?.label.split(" ")[1] || changedKey; + toast.success( + `${label.charAt(0).toUpperCase()}${label.slice(1)} ${action}`, + ); + }); + + lastSavedSettings.current = debouncedUpdateSettings; + } catch (error) { + console.error("Error updating organization settings:", error); + toast.error("Failed to update settings"); + if (organizationSettings) { + setSettings(organizationSettings); + } + } + }; + + handleUpdate(); + } + }, [debouncedUpdateSettings, organizationSettings]); + + const handleToggle = (key: keyof OrganizationSettings) => { + setSettings((prev) => { + const newValue = !prev?.[key]; + + if (key === "disableTranscript" && newValue === true) { + return { + ...prev, + [key]: newValue, + disableSummary: true, + disableChapters: true, + }; + } + + return { + ...prev, + [key]: newValue, + }; + }); + }; + return ( Cap Settings - Enable or disable specific settings for your organization. - Notifications, videos, etc... + Enable or disable specific settings for your organization. These + settings will be applied as defaults for new caps. -
-
-

- Coming Soon -

-
-
- {["Notifications", "Videos"].map((setting) => ( - setActiveTab(setting)} - className={clsx("relative cursor-pointer")} +
+ {options.map((option) => ( +
+
-

+

{option.label}

+ {option.pro && ( +

+ Pro +

)} - > - {setting} -

- {/** Indicator */} - {activeTab === setting && ( - - )} - - ))} -
-
- {activeTab === "Videos" ? ( - - {VideoTabSettings.map((setting, index) => ( - -

{setting.label}

- -
- ))} -
- ) : ( - - {NotificationTabSettings.map((setting, index) => ( - -

{setting.label}

- -
- ))} -
- )} -
+
+

{option.description}

+
+ { + handleToggle(option.value as keyof OrganizationSettings); + }} + checked={!settings?.[option.value as keyof typeof settings]} + /> +
+ ))}
); diff --git a/apps/web/app/(org)/dashboard/settings/organization/page.tsx b/apps/web/app/(org)/dashboard/settings/organization/page.tsx index 48bed4f14e..b2c0e29f13 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/page.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/page.tsx @@ -4,7 +4,6 @@ import { organizationMembers, organizations } from "@cap/database/schema"; import { and, eq } from "drizzle-orm"; import type { Metadata } from "next"; import { redirect } from "next/navigation"; -import { getDashboardData } from "../../dashboard-data"; import { Organization } from "./Organization"; export const metadata: Metadata = { diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx index 2519774669..7201e4572a 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/SharedCaps.tsx @@ -281,32 +281,38 @@ export const SharedCaps = ({ )} -

Videos

-
- {data.map((cap) => { - const isOwner = cap.ownerId === currentUserId; - return ( - - setIsDraggingCap({ isOwner, isDragging: true }) - } - onDragEnd={() => setIsDraggingCap({ isOwner, isDragging: false })} - /> - ); - })} -
- {(data.length > limit || data.length === limit || page !== 1) && ( -
- -
+ {data.length > 0 && ( + <> +

Videos

+
+ {data.map((cap) => { + const isOwner = cap.ownerId === currentUserId; + return ( + + setIsDraggingCap({ isOwner, isDragging: true }) + } + onDragEnd={() => + setIsDraggingCap({ isOwner, isDragging: false }) + } + /> + ); + })} +
+ {(data.length > limit || data.length === limit || page !== 1) && ( +
+ +
+ )} + )}
); diff --git a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/SharedCapCard.tsx b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/SharedCapCard.tsx index 943c3484a0..5bfbcaf61b 100644 --- a/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/SharedCapCard.tsx +++ b/apps/web/app/(org)/dashboard/spaces/[spaceId]/components/SharedCapCard.tsx @@ -1,3 +1,5 @@ +"use client"; + import type { VideoMetadata } from "@cap/database/types"; import type { Video } from "@cap/web-domain"; import { faBuilding, faUser } from "@fortawesome/free-solid-svg-icons"; diff --git a/apps/web/app/(site)/Navbar.tsx b/apps/web/app/(site)/Navbar.tsx index 7118c019e7..cbeac9a586 100644 --- a/apps/web/app/(site)/Navbar.tsx +++ b/apps/web/app/(site)/Navbar.tsx @@ -138,7 +138,7 @@ export const Navbar = () => { Effect.succeed( -
+

This video is private

If you own this video, please sign in{" "} @@ -237,7 +238,7 @@ async function EmbedContent({ !user.email.endsWith(`@${organization[0].allowedEmailDomain}`) ) { return ( -

+

Access Restricted

This video is only accessible to members of this organization. @@ -284,7 +285,7 @@ async function EmbedContent({ if (video.isScreenshot === true) { return ( -

+

Screenshots cannot be embedded

); diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index 9ee7cac6c1..fb28603fc0 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -17,18 +17,12 @@ import { getVideoStatus, type VideoStatusResult, } from "@/actions/videos/get-status"; +import type { OrganizationSettings } from "@/app/(org)/dashboard/dashboard-data"; import { ShareVideo } from "./_components/ShareVideo"; import { Sidebar } from "./_components/Sidebar"; +import SummaryChapters from "./_components/SummaryChapters"; import { Toolbar } from "./_components/Toolbar"; -const formatTime = (time: number) => { - const minutes = Math.floor(time / 60); - const seconds = Math.floor(time % 60); - return `${minutes.toString().padStart(2, "0")}:${seconds - .toString() - .padStart(2, "0")}`; -}; - type CommentWithAuthor = typeof commentsSchema.$inferSelect & { authorName: string | null; }; @@ -44,6 +38,7 @@ type VideoWithOrganizationInfo = typeof videos.$inferSelect & { sharedOrganizations?: { id: string; name: string }[]; hasPassword?: boolean; ownerIsPro?: boolean; + orgSettings?: OrganizationSettings | null; }; interface ShareProps { @@ -53,6 +48,7 @@ interface ShareProps { views: MaybePromise; customDomain: string | null; domainVerified: boolean; + videoSettings?: OrganizationSettings | null; userOrganizations?: { id: string; name: string }[]; initialAiData?: { title?: string | null; @@ -146,6 +142,7 @@ export const Share = ({ views, initialAiData, aiGenerationEnabled, + videoSettings, }: ShareProps) => { const effectiveDate: Date = data.metadata?.customCreatedAt ? new Date(data.metadata.customCreatedAt) @@ -271,6 +268,19 @@ export const Share = ({ }, 100); }, []); + const isDisabled = (setting: keyof NonNullable) => + videoSettings?.[setting] ?? data.orgSettings?.[setting] ?? false; + + const areChaptersDisabled = isDisabled("disableChapters"); + const isSummaryDisabled = isDisabled("disableSummary"); + const areCaptionsDisabled = isDisabled("disableCaptions"); + const areCommentStampsDisabled = isDisabled("disableComments"); + const areReactionStampsDisabled = isDisabled("disableReactions"); + const allSettingsDisabled = + isDisabled("disableComments") && + isDisabled("disableSummary") && + isDisabled("disableTranscript"); + return (
@@ -281,6 +291,10 @@ export const Share = ({ data={{ ...data, transcriptionStatus }} user={user} comments={comments} + areChaptersDisabled={areChaptersDisabled} + areCaptionsDisabled={areCaptionsDisabled} + areCommentStampsDisabled={areCommentStampsDisabled} + areReactionStampsDisabled={areReactionStampsDisabled} chapters={aiData?.chapters || []} aiProcessing={aiData?.processing || false} ref={playerRef} @@ -297,27 +311,30 @@ export const Share = ({
-
- -
+ {!allSettingsDisabled && ( +
+ +
+ )}
@@ -325,6 +342,10 @@ export const Share = ({ @@ -364,45 +385,13 @@ export const Share = ({
)} - {!aiLoading && - (aiData?.summary || - (aiData?.chapters && aiData.chapters.length > 0)) && ( -
- {aiData?.summary && ( - <> -

Summary

-
- - Generated by Cap AI - -
-

- {aiData.summary} -

- - )} - - {aiData?.chapters && aiData.chapters.length > 0 && ( -
-

Chapters

-
- {aiData.chapters.map((chapter) => ( -
handleSeek(chapter.start)} - > - - {formatTime(chapter.start)} - - {chapter.title} -
- ))} -
-
- )} -
- )} +
); diff --git a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx index 957413a851..84c70eb083 100644 --- a/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/CapVideoPlayer.tsx @@ -35,11 +35,14 @@ interface Props { videoId: Video.VideoId; chaptersSrc: string; captionsSrc: string; + disableCaptions?: boolean; videoRef: React.RefObject; mediaPlayerClassName?: string; autoplay?: boolean; enableCrossOrigin?: boolean; hasActiveUpload: boolean | undefined; + disableCommentStamps?: boolean; + disableReactionStamps?: boolean; comments?: Array<{ id: string; timestamp: number | null; @@ -55,12 +58,15 @@ export function CapVideoPlayer({ videoId, chaptersSrc, captionsSrc, + disableCaptions, videoRef, mediaPlayerClassName, autoplay = false, enableCrossOrigin = false, hasActiveUpload, comments = [], + disableCommentStamps = false, + disableReactionStamps = false, onSeek, }: Props) { const [currentCue, setCurrentCue] = useState(""); @@ -610,11 +616,17 @@ export function CapVideoPlayer({ {mainControlsVisible && markersReady && - comments - .filter( - (comment) => comment && comment.timestamp !== null && comment.id, - ) - .map((comment) => { + (() => { + const filteredComments = comments.filter( + (comment) => + comment && + comment.timestamp !== null && + comment.id && + !(disableCommentStamps && comment.type === "text") && + !(disableReactionStamps && comment.type === "emoji"), + ); + + return filteredComments.map((comment) => { const position = (Number(comment.timestamp) / duration) * 100; const containerPadding = 20; const availableWidth = `calc(100% - ${containerPadding * 2}px)`; @@ -631,7 +643,8 @@ export function CapVideoPlayer({ hoveredComment={hoveredComment} /> ); - })} + }); + })()}
- + {!disableCaptions && ( + + )} diff --git a/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx b/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx index f0f8ecec89..eebe6c2961 100644 --- a/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx +++ b/apps/web/app/s/[videoId]/_components/HLSVideoPlayer.tsx @@ -37,6 +37,7 @@ interface Props { captionsSrc: string; videoRef: React.RefObject; mediaPlayerClassName?: string; + disableCaptions?: boolean; autoplay?: boolean; hasActiveUpload?: boolean; } @@ -50,6 +51,7 @@ export function HLSVideoPlayer({ mediaPlayerClassName, autoplay = false, hasActiveUpload, + disableCaptions, }: Props) { const hlsInstance = useRef(null); const [currentCue, setCurrentCue] = useState(""); @@ -58,18 +60,6 @@ export function HLSVideoPlayer({ const [showPlayButton, setShowPlayButton] = useState(false); const [videoLoaded, setVideoLoaded] = useState(false); const [hasPlayedOnce, setHasPlayedOnce] = useState(false); - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 640); - }; - - checkMobile(); - window.addEventListener("resize", checkMobile); - - return () => window.removeEventListener("resize", checkMobile); - }, []); useEffect(() => { const video = videoRef.current; @@ -368,8 +358,15 @@ export function HLSVideoPlayer({ playsInline autoPlay={autoplay} > - - + {chaptersSrc && } + {captionsSrc && ( + + )} {currentCue && toggleCaptions && (
- + {!disableCaptions && ( + + )} diff --git a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx index 30e0b78566..0b8a6ef6c6 100644 --- a/apps/web/app/s/[videoId]/_components/ShareVideo.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareVideo.tsx @@ -10,6 +10,7 @@ import { useRef, useState, } from "react"; +import type { OrganizationSettings } from "@/app/(org)/dashboard/dashboard-data"; import { UpgradeModal } from "@/components/UpgradeModal"; import { CapVideoPlayer } from "./CapVideoPlayer"; import { HLSVideoPlayer } from "./HLSVideoPlayer"; @@ -36,194 +37,216 @@ export const ShareVideo = forwardRef< data: typeof videos.$inferSelect & { ownerIsPro?: boolean; hasActiveUpload?: boolean; + orgSettings?: OrganizationSettings | null; }; user: typeof userSelectProps | null; comments: MaybePromise; chapters?: { title: string; start: number }[]; + areChaptersDisabled?: boolean; + areCaptionsDisabled?: boolean; + areCommentStampsDisabled?: boolean; + areReactionStampsDisabled?: boolean; aiProcessing?: boolean; } ->(({ data, comments, chapters = [] }, ref) => { - const videoRef = useRef(null); - useImperativeHandle(ref, () => videoRef.current as HTMLVideoElement, []); - - const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); - const [transcriptData, setTranscriptData] = useState([]); - const [subtitleUrl, setSubtitleUrl] = useState(null); - const [chaptersUrl, setChaptersUrl] = useState(null); - const [commentsData, setCommentsData] = useState([]); - - const { data: transcriptContent, error: transcriptError } = useTranscript( - data.id, - data.transcriptionStatus, - ); - - // Handle comments data - useEffect(() => { - if (comments) { - if (Array.isArray(comments)) { - setCommentsData(comments); - } else { - comments.then(setCommentsData); +>( + ( + { + data, + comments, + chapters = [], + areCaptionsDisabled, + areChaptersDisabled, + areCommentStampsDisabled, + areReactionStampsDisabled, + }, + ref, + ) => { + const videoRef = useRef(null); + useImperativeHandle(ref, () => videoRef.current as HTMLVideoElement, []); + + const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); + const [transcriptData, setTranscriptData] = useState([]); + const [subtitleUrl, setSubtitleUrl] = useState(null); + const [chaptersUrl, setChaptersUrl] = useState(null); + const [commentsData, setCommentsData] = useState([]); + + const { data: transcriptContent, error: transcriptError } = useTranscript( + data.id, + data.transcriptionStatus, + ); + + // Handle comments data + useEffect(() => { + if (comments) { + if (Array.isArray(comments)) { + setCommentsData(comments); + } else { + comments.then(setCommentsData); + } } - } - }, [comments]); - - // Handle seek functionality - const handleSeek = (time: number) => { - if (videoRef.current) { - videoRef.current.currentTime = time; - } - }; - - useEffect(() => { - if (transcriptContent) { - const parsed = parseVTT(transcriptContent); - setTranscriptData(parsed); - } else if (transcriptError) { - console.error( - "[Transcript] Transcript error from React Query:", - transcriptError.message, - ); - } - }, [transcriptContent, transcriptError]); - - // Handle subtitle URL creation - useEffect(() => { - if ( - data.transcriptionStatus === "COMPLETE" && - transcriptData && - transcriptData.length > 0 - ) { - const vttContent = formatTranscriptAsVTT(transcriptData); - const blob = new Blob([vttContent], { type: "text/vtt" }); - const newUrl = URL.createObjectURL(blob); + }, [comments]); - // Clean up previous URL - if (subtitleUrl) { - URL.revokeObjectURL(subtitleUrl); + // Handle seek functionality + const handleSeek = (time: number) => { + if (videoRef.current) { + videoRef.current.currentTime = time; } + }; - setSubtitleUrl(newUrl); - - return () => { - URL.revokeObjectURL(newUrl); - }; - } else { - // Clean up if no longer needed - if (subtitleUrl) { - URL.revokeObjectURL(subtitleUrl); - setSubtitleUrl(null); + useEffect(() => { + if (transcriptContent) { + const parsed = parseVTT(transcriptContent); + setTranscriptData(parsed); + } else if (transcriptError) { + console.error( + "[Transcript] Transcript error from React Query:", + transcriptError.message, + ); } - } - }, [data.transcriptionStatus, transcriptData]); - - // Handle chapters URL creation - useEffect(() => { - if (chapters?.length > 0) { - const vttContent = formatChaptersAsVTT(chapters); - const blob = new Blob([vttContent], { type: "text/vtt" }); - const newUrl = URL.createObjectURL(blob); - - // Clean up previous URL - if (chaptersUrl) { - URL.revokeObjectURL(chaptersUrl); + }, [transcriptContent, transcriptError]); + + // Handle subtitle URL creation + useEffect(() => { + if ( + data.transcriptionStatus === "COMPLETE" && + transcriptData && + transcriptData.length > 0 + ) { + const vttContent = formatTranscriptAsVTT(transcriptData); + const blob = new Blob([vttContent], { type: "text/vtt" }); + const newUrl = URL.createObjectURL(blob); + + // Clean up previous URL + if (subtitleUrl) { + URL.revokeObjectURL(subtitleUrl); + } + + setSubtitleUrl(newUrl); + + return () => { + URL.revokeObjectURL(newUrl); + }; + } else { + // Clean up if no longer needed + if (subtitleUrl) { + URL.revokeObjectURL(subtitleUrl); + setSubtitleUrl(null); + } } + }, [data.transcriptionStatus, transcriptData]); - setChaptersUrl(newUrl); + // Handle chapters URL creation + useEffect(() => { + if (chapters?.length > 0) { + const vttContent = formatChaptersAsVTT(chapters); + const blob = new Blob([vttContent], { type: "text/vtt" }); + const newUrl = URL.createObjectURL(blob); - return () => { - URL.revokeObjectURL(newUrl); - }; - } else { - // Clean up if no longer needed - if (chaptersUrl) { - URL.revokeObjectURL(chaptersUrl); - setChaptersUrl(null); + // Clean up previous URL + if (chaptersUrl) { + URL.revokeObjectURL(chaptersUrl); + } + + setChaptersUrl(newUrl); + + return () => { + URL.revokeObjectURL(newUrl); + }; + } else { + // Clean up if no longer needed + if (chaptersUrl) { + URL.revokeObjectURL(chaptersUrl); + setChaptersUrl(null); + } } + }, [chapters]); + + let videoSrc: string; + let enableCrossOrigin = false; + + if (data.source.type === "desktopMP4") { + videoSrc = `/api/playlist?userId=${data.ownerId}&videoId=${data.id}&videoType=mp4`; + // Start with CORS enabled for desktopMP4, but CapVideoPlayer will dynamically disable if needed + enableCrossOrigin = true; + } else if ( + NODE_ENV === "development" || + ((data.skipProcessing === true || data.jobStatus !== "COMPLETE") && + data.source.type === "MediaConvert") + ) { + videoSrc = `/api/playlist?userId=${data.ownerId}&videoId=${data.id}&videoType=master`; + } else if (data.source.type === "MediaConvert") { + videoSrc = `/api/playlist?userId=${data.ownerId}&videoId=${data.id}&videoType=video`; + } else { + videoSrc = `/api/playlist?userId=${data.ownerId}&videoId=${data.id}&videoType=video`; } - }, [chapters]); - - let videoSrc: string; - let enableCrossOrigin = false; - - if (data.source.type === "desktopMP4") { - videoSrc = `/api/playlist?userId=${data.ownerId}&videoId=${data.id}&videoType=mp4`; - // Start with CORS enabled for desktopMP4, but CapVideoPlayer will dynamically disable if needed - enableCrossOrigin = true; - } else if ( - NODE_ENV === "development" || - ((data.skipProcessing === true || data.jobStatus !== "COMPLETE") && - data.source.type === "MediaConvert") - ) { - videoSrc = `/api/playlist?userId=${data.ownerId}&videoId=${data.id}&videoType=master`; - } else if (data.source.type === "MediaConvert") { - videoSrc = `/api/playlist?userId=${data.ownerId}&videoId=${data.id}&videoType=video`; - } else { - videoSrc = `/api/playlist?userId=${data.ownerId}&videoId=${data.id}&videoType=video`; - } - return ( - <> -
- {data.source.type === "desktopMP4" ? ( - ({ - id: comment.id, - type: comment.type, - timestamp: comment.timestamp, - content: comment.content, - authorName: comment.authorName, - }))} - onSeek={handleSeek} - /> - ) : ( - - )} -
- - {!data.ownerIsPro && ( -
-
{ - e.stopPropagation(); - setUpgradeModalOpen(true); - }} - > -
-
- -
+ return ( + <> +
+ {data.source.type === "desktopMP4" ? ( + ({ + id: comment.id, + type: comment.type, + timestamp: comment.timestamp, + content: comment.content, + authorName: comment.authorName, + }))} + onSeek={handleSeek} + /> + ) : ( + + )} +
-
-

- Remove watermark -

+ {!data.ownerIsPro && ( +
+
{ + e.stopPropagation(); + setUpgradeModalOpen(true); + }} + > +
+
+ +
+ +
+

+ Remove watermark +

+
-
- )} - - - ); -}); + )} + + + ); + }, +); diff --git a/apps/web/app/s/[videoId]/_components/Sidebar.tsx b/apps/web/app/s/[videoId]/_components/Sidebar.tsx index bb806ff7bf..b3e15dac6c 100644 --- a/apps/web/app/s/[videoId]/_components/Sidebar.tsx +++ b/apps/web/app/s/[videoId]/_components/Sidebar.tsx @@ -2,8 +2,10 @@ import type { userSelectProps } from "@cap/database/auth/session"; import type { comments as commentsSchema, videos } from "@cap/database/schema"; import { classNames } from "@cap/utils"; import type { Video } from "@cap/web-domain"; +import clsx from "clsx"; import { AnimatePresence, motion } from "framer-motion"; import { forwardRef, Suspense, useState } from "react"; +import type { OrganizationSettings } from "@/app/(org)/dashboard/dashboard-data"; import { Activity } from "./tabs/Activity"; import { Settings } from "./tabs/Settings"; import { Summary } from "./tabs/Summary"; @@ -18,6 +20,7 @@ type CommentType = typeof commentsSchema.$inferSelect & { type VideoWithOrganizationInfo = typeof videos.$inferSelect & { organizationMembers?: string[]; organizationId?: string; + orgSettings?: OrganizationSettings | null; }; interface SidebarProps { @@ -30,6 +33,7 @@ interface SidebarProps { setCommentsData: React.Dispatch>; views: MaybePromise; onSeek?: (time: number) => void; + videoSettings?: OrganizationSettings | null; videoId: Video.VideoId; aiData?: { title?: string | null; @@ -75,6 +79,7 @@ export const Sidebar = forwardRef<{ scrollToBottom: () => void }, SidebarProps>( handleCommentSuccess, setOptimisticComments, views, + videoSettings, onSeek, videoId, aiData, @@ -88,16 +93,42 @@ export const Sidebar = forwardRef<{ scrollToBottom: () => void }, SidebarProps>( data.organizationMembers?.includes(user?.id ?? "")), ); - const [activeTab, setActiveTab] = useState("activity"); - const [[page, direction], setPage] = useState([0, 0]); + const defaultTab = !( + videoSettings?.disableComments ?? data.orgSettings?.disableComments + ) + ? "activity" + : !(videoSettings?.disableSummary ?? data.orgSettings?.disableSummary) + ? "summary" + : !( + videoSettings?.disableTranscript ?? + data.orgSettings?.disableTranscript + ) + ? "transcript" + : "activity"; - const hasExistingAiData = - aiData?.summary || (aiData?.chapters && aiData.chapters.length > 0); + const [activeTab, setActiveTab] = useState(defaultTab); + const [[page, direction], setPage] = useState([0, 0]); const tabs = [ - { id: "activity", label: "Comments" }, - { id: "summary", label: "Summary" }, - { id: "transcript", label: "Transcript" }, + { + id: "activity", + label: "Comments", + disabled: + videoSettings?.disableComments ?? data.orgSettings?.disableComments, + }, + { + id: "summary", + label: "Summary", + disabled: + videoSettings?.disableSummary ?? data.orgSettings?.disableSummary, + }, + { + id: "transcript", + label: "Transcript", + disabled: + videoSettings?.disableTranscript ?? + data.orgSettings?.disableTranscript, + }, ]; const paginate = (tabId: TabType) => { @@ -125,6 +156,11 @@ export const Sidebar = forwardRef<{ scrollToBottom: () => void }, SidebarProps>( ref={ref} views={views} comments={commentsData} + commentsDisabled={ + videoSettings?.disableComments ?? + data.orgSettings?.disableComments ?? + false + } setComments={setCommentsData} user={user} optimisticComments={optimisticComments} @@ -141,6 +177,7 @@ export const Sidebar = forwardRef<{ scrollToBottom: () => void }, SidebarProps>( void }, SidebarProps>( } }; + const allTabsDisabled = tabs.every((tab) => tab.disabled); + return (
-
- {tabs.map((tab) => ( - - ))} + + {tab.label} + + {activeTab === tab.id && ( + + )} + + ))}
diff --git a/apps/web/app/s/[videoId]/_components/SummaryChapters.tsx b/apps/web/app/s/[videoId]/_components/SummaryChapters.tsx new file mode 100644 index 0000000000..bf7673c049 --- /dev/null +++ b/apps/web/app/s/[videoId]/_components/SummaryChapters.tsx @@ -0,0 +1,73 @@ +import { formatTimeMinutes } from "./utils/transcript-utils"; + +interface SummaryChaptersProps { + isSummaryDisabled: boolean; + areChaptersDisabled: boolean; + handleSeek: (time: number) => void; + aiData: { + title: string | null; + summary: string | null; + chapters: + | { + title: string; + start: number; + }[] + | null; + processing: boolean; + }; + aiLoading: boolean; +} + +const SummaryChapters = ({ + isSummaryDisabled, + areChaptersDisabled, + handleSeek, + aiData, + aiLoading, +}: SummaryChaptersProps) => { + const hasSummary = !isSummaryDisabled && !!aiData?.summary; + const hasChapters = + !areChaptersDisabled && + Array.isArray(aiData?.chapters) && + aiData.chapters.length > 0; + + if (aiLoading || (!hasSummary && !hasChapters)) return null; + + return ( +
+ {hasSummary && ( + <> +

Summary

+
+ + Generated by Cap AI + +
+

{aiData.summary}

+ + )} + + {hasChapters && ( +
+

Chapters

+
+ {aiData.chapters?.map((chapter) => ( +
handleSeek(chapter.start)} + > + + {formatTimeMinutes(chapter.start)} + + {chapter.title} +
+ ))} +
+
+ )} +
+ ); +}; + +export default SummaryChapters; diff --git a/apps/web/app/s/[videoId]/_components/Toolbar.tsx b/apps/web/app/s/[videoId]/_components/Toolbar.tsx index db222c4bd5..11e4336135 100644 --- a/apps/web/app/s/[videoId]/_components/Toolbar.tsx +++ b/apps/web/app/s/[videoId]/_components/Toolbar.tsx @@ -4,6 +4,7 @@ import { Button } from "@cap/ui"; import { AnimatePresence, motion } from "motion/react"; import { startTransition, useEffect, useState } from "react"; import { newComment } from "@/actions/videos/new-comment"; +import type { OrganizationSettings } from "@/app/(org)/dashboard/dashboard-data"; import type { CommentType } from "../Share"; import { AuthOverlay } from "./AuthOverlay"; @@ -11,10 +12,13 @@ const MotionButton = motion.create(Button); // million-ignore interface ToolbarProps { - data: typeof videos.$inferSelect; + data: typeof videos.$inferSelect & { + orgSettings?: OrganizationSettings | null; + }; user: typeof userSelectProps | null; onOptimisticComment?: (comment: CommentType) => void; onCommentSuccess?: (comment: CommentType) => void; + disableReactions?: boolean; } export const Toolbar = ({ @@ -22,6 +26,7 @@ export const Toolbar = ({ user, onOptimisticComment, onCommentSuccess, + disableReactions, }: ToolbarProps) => { const [commentBoxOpen, setCommentBoxOpen] = useState(false); const [comment, setComment] = useState(""); @@ -168,6 +173,10 @@ export const Toolbar = ({ setCommentBoxOpen(true); }; + if (disableReactions) { + return null; + } + return ( <> void; onSeek?: (time: number) => void; setShowAuthOverlay: (v: boolean) => void; + commentsDisabled: boolean; } >((props, ref) => { const { @@ -41,6 +44,7 @@ export const Comments = Object.assign( setComments, handleCommentSuccess, onSeek, + commentsDisabled, } = props; const commentParams = useSearchParams().get("comment"); const replyParams = useSearchParams().get("reply"); @@ -184,12 +188,22 @@ export const Comments = Object.assign( return ( - {rootComments.length === 0 ? ( + {commentsDisabled ? ( +
+ } + commentsDisabled={commentsDisabled} + /> +
+ ) : rootComments.length === 0 ? ( ) : (
@@ -238,24 +252,26 @@ export const Comments = Object.assign( {props.children}
-
- {props.user ? ( - - ) : ( - - )} -
+ {!props.commentInputProps?.disabled && ( +
+ {props.user ? ( + + ) : ( + + )} +
+ )} ), Skeleton: (props: { diff --git a/apps/web/app/s/[videoId]/_components/tabs/Activity/EmptyState.tsx b/apps/web/app/s/[videoId]/_components/tabs/Activity/EmptyState.tsx index 50d4452e20..4d1b093594 100644 --- a/apps/web/app/s/[videoId]/_components/tabs/Activity/EmptyState.tsx +++ b/apps/web/app/s/[videoId]/_components/tabs/Activity/EmptyState.tsx @@ -1,12 +1,30 @@ import { LoadingSpinner } from "@cap/ui"; +import type { FontAwesomeIconProps } from "@fortawesome/react-fontawesome"; +import type { ReactElement } from "react"; +import React from "react"; -const EmptyState = () => ( +const EmptyState = ({ + commentsDisabled, + icon, +}: { + commentsDisabled?: boolean; + icon?: ReactElement; +}) => (
-
- -

No comments yet

+ {!commentsDisabled && } + {icon && ( +
+ {React.cloneElement(icon, { className: "text-gray-12 size-8" })} +
+ )} +
+

+ {commentsDisabled ? "Disabled" : "No comments yet"} +

- Be the first to share your thoughts! + {commentsDisabled + ? "Comments are disabled for this video" + : "Be the first to share your thoughts!"}

diff --git a/apps/web/app/s/[videoId]/_components/tabs/Activity/index.tsx b/apps/web/app/s/[videoId]/_components/tabs/Activity/index.tsx index ef7c6c3d57..c9cce9d037 100644 --- a/apps/web/app/s/[videoId]/_components/tabs/Activity/index.tsx +++ b/apps/web/app/s/[videoId]/_components/tabs/Activity/index.tsx @@ -3,13 +3,7 @@ import type { userSelectProps } from "@cap/database/auth/session"; import type { Video } from "@cap/web-domain"; import type React from "react"; -import { - forwardRef, - type JSX, - type RefObject, - Suspense, - useState, -} from "react"; +import { forwardRef, type JSX, Suspense, useState } from "react"; import { CapCardAnalytics } from "@/app/(org)/dashboard/caps/components/CapCard/CapCardAnalytics"; import type { CommentType } from "../../../Share"; import { AuthOverlay } from "../../AuthOverlay"; @@ -27,6 +21,7 @@ interface ActivityProps { optimisticComments: CommentType[]; setOptimisticComments: (newComment: CommentType) => void; isOwnerOrMember: boolean; + commentsDisabled: boolean; } export const Activity = Object.assign( @@ -41,6 +36,7 @@ export const Activity = Object.assign( optimisticComments, setOptimisticComments, setComments, + commentsDisabled, ...props }, ref, @@ -71,6 +67,7 @@ export const Activity = Object.assign( videoId={videoId} setShowAuthOverlay={setShowAuthOverlay} onSeek={props.onSeek} + commentsDisabled={commentsDisabled} /> )} diff --git a/apps/web/app/s/[videoId]/_components/tabs/Summary.tsx b/apps/web/app/s/[videoId]/_components/tabs/Summary.tsx index 3127b249e7..8dc2bc6ec4 100644 --- a/apps/web/app/s/[videoId]/_components/tabs/Summary.tsx +++ b/apps/web/app/s/[videoId]/_components/tabs/Summary.tsx @@ -3,6 +3,8 @@ import type { userSelectProps } from "@cap/database/auth/session"; import { Button } from "@cap/ui"; import { userIsPro } from "@cap/utils"; +import { faRectangleList } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useEffect, useState } from "react"; interface Chapter { @@ -21,6 +23,7 @@ interface SummaryProps { }; aiGenerationEnabled?: boolean; user: typeof userSelectProps | null; + isSummaryDisabled?: boolean; } const formatTime = (time: number) => { @@ -34,24 +37,24 @@ const formatTime = (time: number) => { const SkeletonLoader = () => (
-
-
+
+
-
-
-
-
-
+
+
+
+
+
-
+
{[1, 2, 3, 4].map((i) => (
-
-
+
+
))}
@@ -62,6 +65,7 @@ const SkeletonLoader = () => ( export const Summary: React.FC = ({ onSeek, initialAiData, + isSummaryDisabled = false, aiGenerationEnabled = false, user, }) => { @@ -100,8 +104,8 @@ export const Summary: React.FC = ({ return (
-
-
+
+
= ({ />
-

+

Unlock Cap AI

-

+

Upgrade to Cap Pro to access AI-powered features including automatic titles, video summaries, and intelligent chapter generation. @@ -139,6 +143,8 @@ export const Summary: React.FC = ({ ); } + if (isSummaryDisabled) return null; + if (isLoading || aiData?.processing) { return (

@@ -152,22 +158,12 @@ export const Summary: React.FC = ({ if (!aiData?.summary && (!aiData?.chapters || aiData.chapters.length === 0)) { return (
-
- - - -

+ +
+

No summary available

@@ -203,10 +199,10 @@ export const Summary: React.FC = ({ {aiData.chapters.map((chapter) => (

handleSeek(chapter.start)} > - + {formatTime(chapter.start)} {chapter.title} diff --git a/apps/web/app/s/[videoId]/_components/utils/transcript-utils.ts b/apps/web/app/s/[videoId]/_components/utils/transcript-utils.ts index dba8952c58..d24b1cd5b3 100644 --- a/apps/web/app/s/[videoId]/_components/utils/transcript-utils.ts +++ b/apps/web/app/s/[videoId]/_components/utils/transcript-utils.ts @@ -20,6 +20,14 @@ export const formatTime = (seconds: number): string => { .padStart(3, "0")}`; }; +export const formatTimeMinutes = (time: number) => { + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + return `${minutes.toString().padStart(2, "0")}:${seconds + .toString() + .padStart(2, "0")}`; +}; + /** * Formats transcript entries as VTT format for subtitles */ diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index be76d9dcab..246736232f 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -25,7 +25,10 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { generateAiMetadata } from "@/actions/videos/generate-ai-metadata"; import { getVideoAnalytics } from "@/actions/videos/get-analytics"; -import { getDashboardData } from "@/app/(org)/dashboard/dashboard-data"; +import { + getDashboardData, + type OrganizationSettings, +} from "@/app/(org)/dashboard/dashboard-data"; import { createNotification } from "@/lib/Notification"; import * as EffectRuntime from "@/lib/server"; import { transcribeVideo } from "@/lib/transcribe"; @@ -91,11 +94,6 @@ async function getSharedSpacesForVideo(videoId: Video.VideoId) { return sharedSpaces; } -type Props = { - params: Promise<{ [key: string]: string | string[] | undefined }>; - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; -}; - type VideoWithOrganization = typeof videos.$inferSelect & { sharedOrganization?: { organizationId: string; @@ -106,6 +104,7 @@ type VideoWithOrganization = typeof videos.$inferSelect & { password?: string | null; hasPassword?: boolean; ownerIsPro?: boolean; + orgSettings?: OrganizationSettings | null; }; const ALLOWED_REFERRERS = [ @@ -282,6 +281,7 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { skipProcessing: videos.skipProcessing, transcriptionStatus: videos.transcriptionStatus, source: videos.source, + videoSettings: videos.settings, width: videos.width, height: videos.height, duration: videos.duration, @@ -290,6 +290,7 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { sharedOrganization: { organizationId: sharedVideos.organizationId, }, + orgSettings: organizations.settings, ownerIsPro: sql`${users.stripeSubscriptionStatus} IN ('active','trialing','complete','paid') OR ${users.thirdPartyStripeSubscriptionId} IS NOT NULL`.mapWith( Boolean, @@ -302,6 +303,7 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) { .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) .leftJoin(users, eq(videos.ownerId, users.id)) .leftJoin(videoUploads, eq(videos.id, videoUploads.videoId)) + .leftJoin(organizations, eq(videos.orgId, organizations.id)) .where(eq(videos.id, videoId)), ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId))); @@ -348,10 +350,15 @@ async function AuthorizedContent({ video, searchParams, }: { - video: Omit, "folderId" | "password"> & { + video: Omit< + InferSelectModel, + "folderId" | "password" | "settings" + > & { sharedOrganization: { organizationId: string } | null; hasPassword: boolean; ownerIsPro?: boolean; + orgSettings?: OrganizationSettings | null; + videoSettings?: OrganizationSettings | null; }; searchParams: { [key: string]: string | string[] | undefined }; }) { @@ -470,10 +477,13 @@ async function AuthorizedContent({ sharedOrganization: { organizationId: sharedVideos.organizationId, }, + orgSettings: organizations.settings, + videoSettings: videos.settings, }) .from(videos) .leftJoin(sharedVideos, eq(videos.id, sharedVideos.videoId)) .leftJoin(users, eq(videos.ownerId, users.id)) + .leftJoin(organizations, eq(videos.orgId, organizations.id)) .where(eq(videos.id, videoId)) .execute(); @@ -666,6 +676,8 @@ async function AuthorizedContent({ sharedOrganizations: sharedOrganizations, password: null, folderId: null, + orgSettings: video.orgSettings || null, + settings: video.videoSettings || null, }; return ( @@ -691,6 +703,7 @@ async function AuthorizedContent({ { + //if dev - don't transcribe + if (serverEnv().NODE_ENV === "development") { + console.log("[transcribeAudio] Development mode, skipping transcription"); + return ""; + } + console.log("[transcribeAudio] Starting transcription for URL:", videoUrl); const deepgram = createClient(serverEnv().DEEPGRAM_API_KEY as string); diff --git a/apps/web/package.json b/apps/web/package.json index 14555f8650..4f5bc1c266 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -53,7 +53,7 @@ "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slider": "^1.3.5", - "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.6", "@react-email/components": "^0.1.0", "@react-email/render": "1.1.2", diff --git a/package.json b/package.json index 1fd394ce4f..96ff28d229 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@clack/prompts": "^0.10.0", "@effect/language-service": "^0.34.0", "dotenv-cli": "latest", + "prettier": "^3.5.3", "turbo": "^2.3.4", "typescript": "^5.8.3" }, diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 808fe48f79..f3efe3fbdc 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1754314124918, "tag": "0007_cheerful_rocket_raccoon", "breakpoints": true + }, + { + "idx": 8, + "version": "5", + "when": 1759139970377, + "tag": "0008_condemned_gamora", + "breakpoints": true } ] } diff --git a/packages/database/schema.ts b/packages/database/schema.ts index ca6f2c285b..035e895ade 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -159,6 +159,14 @@ export const organizations = mysqlTable( allowedEmailDomain: varchar("allowedEmailDomain", { length: 255 }), customDomain: varchar("customDomain", { length: 255 }), domainVerified: timestamp("domainVerified"), + settings: json("settings").$type<{ + disableSummary?: boolean; + disableCaptions?: boolean; + disableChapters?: boolean; + disableReactions?: boolean; + disableTranscript?: boolean; + disableComments?: boolean; + }>(), iconUrl: varchar("iconUrl", { length: 1024 }), createdAt: timestamp("createdAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow().onUpdateNow(), @@ -260,8 +268,16 @@ export const videos = mysqlTable( fps: int("fps"), metadata: json("metadata").$type(), public: boolean("public").notNull().default(true), + settings: json("settings").$type<{ + disableSummary?: boolean; + disableCaptions?: boolean; + disableChapters?: boolean; + disableReactions?: boolean; + disableTranscript?: boolean; + disableComments?: boolean; + }>(), transcriptionStatus: varchar("transcriptionStatus", { length: 255 }).$type< - "PROCESSING" | "COMPLETE" | "ERROR" + "PROCESSING" | "COMPLETE" | "ERROR" | "SKIPPED" >(), source: json("source") .$type< diff --git a/packages/ui/src/components/Switch.tsx b/packages/ui/src/components/Switch.tsx index 61bf461ed5..950853b915 100644 --- a/packages/ui/src/components/Switch.tsx +++ b/packages/ui/src/components/Switch.tsx @@ -14,7 +14,7 @@ const Switch = React.forwardRef< "w-11 h-6 p-[0.125rem]", "bg-gray-5 data-[state=checked]:bg-blue-500", "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500", - "disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-gray-200", + "disabled:cursor-not-allowed disabled:opacity-40 disabled:bg-gray-4", className, )} {...props} diff --git a/packages/web-api-contract-effect/src/index.ts b/packages/web-api-contract-effect/src/index.ts index c5b583daa3..34a896bca1 100644 --- a/packages/web-api-contract-effect/src/index.ts +++ b/packages/web-api-contract-effect/src/index.ts @@ -4,12 +4,16 @@ import { HttpApiError, HttpApiGroup, HttpApiMiddleware, - HttpServerError, } from "@effect/platform"; import { Context, Data } from "effect"; import * as Schema from "effect/Schema"; -const TranscriptionStatus = Schema.Literal("PROCESSING", "COMPLETE", "ERROR"); +const TranscriptionStatus = Schema.Literal( + "PROCESSING", + "COMPLETE", + "ERROR", + "SKIPPED", +); const OSType = Schema.Literal("macos", "windows"); const LicenseType = Schema.Literal("yearly", "lifetime"); diff --git a/packages/web-domain/src/Video.ts b/packages/web-domain/src/Video.ts index 232efbd0f1..ac5dbec916 100644 --- a/packages/web-domain/src/Video.ts +++ b/packages/web-domain/src/Video.ts @@ -26,7 +26,7 @@ export class Video extends Schema.Class