From c7bb8afd9cce6b505f570232fdd2314eb0d09519 Mon Sep 17 00:00:00 2001 From: Somay Chauhan Date: Fri, 16 Jan 2026 11:47:20 +0530 Subject: [PATCH] feat: add cap options dropdown menu to share page with settings and actions Add comprehensive options dropdown to share page header with settings dialog, password protection, analytics, download, duplicate, and delete actions. Pass isPro prop to SettingsDialog to support non-owner pro users viewing owner settings. --- .../caps/components/SettingsDialog.tsx | 5 +- .../_components/CapOptionsDropdown.tsx | 239 ++++++++++++++++++ .../s/[videoId]/_components/ShareHeader.tsx | 31 +-- apps/web/app/s/[videoId]/page.tsx | 1 + 4 files changed, 256 insertions(+), 20 deletions(-) create mode 100644 apps/web/app/s/[videoId]/_components/CapOptionsDropdown.tsx diff --git a/apps/web/app/(org)/dashboard/caps/components/SettingsDialog.tsx b/apps/web/app/(org)/dashboard/caps/components/SettingsDialog.tsx index 80715a66fe..c6c3377fa5 100644 --- a/apps/web/app/(org)/dashboard/caps/components/SettingsDialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/SettingsDialog.tsx @@ -22,6 +22,7 @@ interface SettingsDialogProps { onClose: () => void; capId: Video.VideoId; settingsData?: OrganizationSettings; + isPro?: boolean; } const options: { @@ -70,8 +71,10 @@ export const SettingsDialog = ({ onClose, capId, settingsData, + isPro, }: SettingsDialogProps) => { const { user, organizationSettings } = useDashboardContext(); + const isProUser = isPro ?? user?.isPro ?? false; const [saveLoading, setSaveLoading] = useState(false); const buildSettings = useCallback( (data?: OrganizationSettings): OrganizationSettings => ({ @@ -190,7 +193,7 @@ export const SettingsDialog = ({ void; +} + +export const CapOptionsDropdown: React.FC = ({ + videoId, + videoName, + hasPassword, + isOwnerPro, + settingsData, + onDeleted, +}) => { + const router = useRouter(); + const rpc = useRpcClient(); + + const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false); + const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false); + const [passwordProtected, setPasswordProtected] = useState(hasPassword); + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); + + const downloadMutation = useEffectMutation({ + mutationFn: () => + Effect.gen(function* () { + const result = yield* rpc.VideoGetDownloadInfo(videoId); + const httpClient = yield* HttpClient.HttpClient; + if (Option.isSome(result)) { + const fetchResponse = yield* httpClient.get(result.value.downloadUrl); + const blob = yield* fetchResponse.arrayBuffer; + + const blobUrl = window.URL.createObjectURL(new Blob([blob])); + const link = document.createElement("a"); + link.href = blobUrl; + link.download = result.value.fileName; + link.style.display = "none"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + window.URL.revokeObjectURL(blobUrl); + } else { + throw new Error("Failed to get download URL"); + } + }), + }); + + const deleteMutation = useEffectMutation({ + mutationFn: () => rpc.VideoDelete(videoId), + onSuccess: () => { + toast.success("Cap deleted successfully"); + setConfirmDeleteOpen(false); + onDeleted?.(); + router.push("/dashboard/caps"); + }, + onError: () => { + toast.error("Failed to delete cap"); + }, + }); + + const duplicateMutation = useEffectMutation({ + mutationFn: () => rpc.VideoDuplicate(videoId), + onSuccess: () => { + toast.success("Cap duplicated successfully"); + router.refresh(); + }, + }); + + const handleDownload = () => { + if (downloadMutation.isPending) return; + + toast.promise(downloadMutation.mutateAsync(), { + loading: "Preparing download...", + success: "Download started successfully", + error: (error) => { + if (error instanceof Error) { + return error.message; + } + return "Failed to download video - please try again."; + }, + }); + }; + + const handlePasswordUpdated = (protectedStatus: boolean) => { + setPasswordProtected(protectedStatus); + router.refresh(); + }; + + return ( + <> + setIsSettingsDialogOpen(false)} + isPro={isOwnerPro} + /> + setIsPasswordDialogOpen(false)} + videoId={videoId} + hasPassword={passwordProtected} + onPasswordUpdated={handlePasswordUpdated} + /> + } + title="Delete Cap" + description={`Are you sure you want to delete the cap "${videoName}"? This action cannot be undone.`} + confirmLabel={deleteMutation.isPending ? "Deleting..." : "Delete"} + cancelLabel="Cancel" + confirmVariant="destructive" + loading={deleteMutation.isPending} + onConfirm={() => deleteMutation.mutate()} + onCancel={() => setConfirmDeleteOpen(false)} + /> + + + + + + + + + setIsSettingsDialogOpen(true)} + className="flex gap-2 items-center rounded-lg" + > + + Settings + + { + router.push(`/dashboard/analytics?capId=${videoId}`); + }} + className="flex gap-2 items-center rounded-lg" + > + + View analytics + + + + Download + + { + toast.promise(duplicateMutation.mutateAsync(), { + loading: "Duplicating cap...", + success: "Cap duplicated successfully", + error: "Failed to duplicate cap", + }); + }} + disabled={duplicateMutation.isPending} + className="flex gap-2 items-center rounded-lg" + > + + Duplicate + + { + if (!isOwnerPro) setUpgradeModalOpen(true); + else setIsPasswordDialogOpen(true); + }} + className="flex gap-2 items-center rounded-lg" + > + + + {passwordProtected ? "Edit password" : "Add password"} + + + setConfirmDeleteOpen(true)} + className="flex gap-2 items-center rounded-lg" + > + + Delete Cap + + + + + ); +}; diff --git a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx index 4e437a23ec..54b3d206f9 100644 --- a/apps/web/app/s/[videoId]/_components/ShareHeader.tsx +++ b/apps/web/app/s/[videoId]/_components/ShareHeader.tsx @@ -3,12 +3,12 @@ import { buildEnv, NODE_ENV } from "@cap/env"; import { Button } from "@cap/ui"; import { - faChartSimple, faChevronDown, faLock, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Check, Copy, Globe2 } from "lucide-react"; +import { CapOptionsDropdown } from "./CapOptionsDropdown"; import moment from "moment"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; @@ -23,6 +23,7 @@ import { Tooltip } from "@/components/Tooltip"; import { UpgradeModal } from "@/components/UpgradeModal"; import { usePublicEnv } from "@/utils/public-env"; import type { VideoData } from "../types"; +import type { OrganizationSettings } from "@/app/(org)/dashboard/dashboard-data"; export const ShareHeader = ({ data, @@ -31,6 +32,7 @@ export const ShareHeader = ({ sharedOrganizations = [], sharedSpaces = [], spacesData = null, + videoSettings, }: { data: VideoData; customDomain?: string | null; @@ -50,6 +52,7 @@ export const ShareHeader = ({ organizationId: string; }[]; spacesData?: Spaces[] | null; + videoSettings?: OrganizationSettings | null; }) => { const user = useCurrentUser(); const { push, refresh } = useRouter(); @@ -179,6 +182,7 @@ export const ShareHeader = ({ }; const userIsOwnerAndNotPro = user?.id === data.owner.id && !data.owner.isPro; + const userIsOwnerAndPro = user?.id === data.owner.id && data.owner.isPro; return ( <> @@ -296,24 +300,13 @@ export const ShareHeader = ({ {user !== null && (
{isOwner && ( - - - + )}