Skip to content

Commit 268e19b

Browse files
committed
feat: Add 'Record a Cap' page to dashboard
1 parent 25f0bfb commit 268e19b

File tree

6 files changed

+292
-2
lines changed

6 files changed

+292
-2
lines changed
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"use client";
2+
3+
import { motion, useAnimation } from "motion/react";
4+
import type { HTMLAttributes } from "react";
5+
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
6+
import { cn } from "@/lib/utils";
7+
8+
export interface RecordIconHandle {
9+
startAnimation: () => void;
10+
stopAnimation: () => void;
11+
}
12+
13+
interface RecordIconProps extends HTMLAttributes<HTMLDivElement> {
14+
size?: number;
15+
}
16+
17+
const RecordIcon = forwardRef<RecordIconHandle, RecordIconProps>(
18+
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
19+
const controls = useAnimation();
20+
const isControlledRef = useRef(false);
21+
22+
useImperativeHandle(ref, () => {
23+
isControlledRef.current = true;
24+
25+
return {
26+
startAnimation: () => controls.start("animate"),
27+
stopAnimation: () => controls.start("normal"),
28+
};
29+
});
30+
31+
const handleMouseEnter = useCallback(
32+
(e: React.MouseEvent<HTMLDivElement>) => {
33+
if (!isControlledRef.current) {
34+
controls.start("animate");
35+
} else {
36+
onMouseEnter?.(e);
37+
}
38+
},
39+
[controls, onMouseEnter],
40+
);
41+
42+
const handleMouseLeave = useCallback(
43+
(e: React.MouseEvent<HTMLDivElement>) => {
44+
if (!isControlledRef.current) {
45+
controls.start("normal");
46+
} else {
47+
onMouseLeave?.(e);
48+
}
49+
},
50+
[controls, onMouseLeave],
51+
);
52+
53+
return (
54+
<div
55+
className={cn(className)}
56+
onMouseEnter={handleMouseEnter}
57+
onMouseLeave={handleMouseLeave}
58+
{...props}
59+
>
60+
<motion.svg
61+
xmlns="http://www.w3.org/2000/svg"
62+
width={size}
63+
height={size}
64+
viewBox="0 0 24 24"
65+
fill="none"
66+
stroke="currentColor"
67+
strokeWidth="2"
68+
strokeLinecap="round"
69+
strokeLinejoin="round"
70+
transition={{ type: "spring", stiffness: 50, damping: 10 }}
71+
>
72+
<rect x="2" y="3" width="20" height="14" rx="2" />
73+
<line x1="8" y1="21" x2="16" y2="21" />
74+
<line x1="12" y1="17" x2="12" y2="21" />
75+
<motion.circle
76+
cx="12"
77+
cy="10"
78+
r="2"
79+
variants={{
80+
normal: { scale: 1, opacity: 0, fill: "currentColor" },
81+
animate: {
82+
scale: [1, 1.25, 1],
83+
opacity: [0, 1, 0.8, 1],
84+
fill: "currentColor",
85+
},
86+
}}
87+
animate={controls}
88+
transition={{ duration: 0.9, ease: "easeInOut" }}
89+
/>
90+
</motion.svg>
91+
</div>
92+
);
93+
},
94+
);
95+
96+
RecordIcon.displayName = "RecordIcon";
97+
98+
export default RecordIcon;

apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import DownloadIcon from "./Download";
66
import HomeIcon from "./Home";
77
import LayersIcon from "./Layers";
88
import LogoutIcon from "./Logout";
9+
import RecordIcon from "./Record";
910
import ReferIcon from "./Refer";
1011
import SettingsGearIcon from "./Settings";
1112

@@ -20,4 +21,5 @@ export {
2021
LogoutIcon,
2122
SettingsGearIcon,
2223
ReferIcon,
24+
RecordIcon,
2325
};

apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import { SignedImageUrl } from "@/components/SignedImageUrl";
3636
import { Tooltip } from "@/components/Tooltip";
3737
import { UsageButton } from "@/components/UsageButton";
3838
import { useDashboardContext } from "../../Contexts";
39-
import { CapIcon, CogIcon } from "../AnimatedIcons";
39+
import { CapIcon, CogIcon, RecordIcon } from "../AnimatedIcons";
4040
import type { CogIconHandle } from "../AnimatedIcons/Cog";
4141
import CapAIBox from "./CapAIBox";
4242
import SpacesList from "./SpacesList";
@@ -59,6 +59,12 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => {
5959
icon: <CapIcon />,
6060
subNav: [],
6161
},
62+
{
63+
name: "Record a Cap",
64+
href: `/dashboard/caps/record`,
65+
icon: <RecordIcon />,
66+
subNav: [],
67+
},
6268
{
6369
name: "Organization Settings",
6470
href: `/dashboard/settings/organization`,
@@ -78,7 +84,12 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => {
7884
const [openAIDialog, setOpenAIDialog] = useState(false);
7985
const router = useRouter();
8086

81-
const isPathActive = (path: string) => pathname.includes(path);
87+
const isPathActive = (path: string) => {
88+
if (path === "/dashboard/caps") {
89+
return pathname === "/dashboard/caps";
90+
}
91+
return pathname === path || pathname.startsWith(`${path}/`);
92+
};
8293
const isDomainSetupVerified =
8394
activeOrg?.organization.customDomain &&
8495
activeOrg?.organization.domainVerified;

apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const Top = () => {
5959
"/dashboard/caps": "Caps",
6060
"/dashboard/folder": "Caps",
6161
"/dashboard/shared-caps": "Shared Caps",
62+
"/dashboard/caps/record": "Record a Cap",
6263
"/dashboard/settings/organization": "Organization Settings",
6364
"/dashboard/settings/account": "Account Settings",
6465
"/dashboard/spaces": "Spaces",
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"use client";
2+
3+
import { Button } from "@cap/ui";
4+
import { faDownload } from "@fortawesome/free-solid-svg-icons";
5+
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
6+
import { ChevronDown } from "lucide-react";
7+
import { AnimatePresence, motion } from "motion/react";
8+
import { useCallback, useId, useRef, useState } from "react";
9+
import { FREE_PLAN_MAX_RECORDING_MS } from "../components/web-recorder-dialog/web-recorder-constants";
10+
import { WebRecorderDialog } from "../components/web-recorder-dialog/web-recorder-dialog";
11+
12+
export const RecordVideoPage = () => {
13+
const checkingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
14+
15+
const openDesktop = useCallback(() => {
16+
let handled = false;
17+
const onChange = () => {
18+
handled = true;
19+
document.removeEventListener("visibilitychange", onChange);
20+
window.removeEventListener("pagehide", onChange);
21+
window.removeEventListener("blur", onChange);
22+
};
23+
document.addEventListener("visibilitychange", onChange, { once: true });
24+
window.addEventListener("pagehide", onChange, { once: true });
25+
window.addEventListener("blur", onChange, { once: true });
26+
27+
window.location.href = "cap-desktop://";
28+
29+
if (checkingRef.current) clearTimeout(checkingRef.current);
30+
checkingRef.current = setTimeout(() => {
31+
if (!handled && document.visibilityState === "visible") {
32+
document.removeEventListener("visibilitychange", onChange);
33+
window.removeEventListener("pagehide", onChange);
34+
window.removeEventListener("blur", onChange);
35+
window.location.assign("/download");
36+
}
37+
}, 1500);
38+
}, []);
39+
40+
return (
41+
<div
42+
className="flex flex-col flex-1 justify-center items-center w-full h-full"
43+
style={{ scrollbarGutter: "stable" }}
44+
>
45+
<div className="flex flex-col gap-3 justify-center items-center h-full text-center">
46+
<div className="w-full px-5">
47+
<div className="mx-auto w-full max-w-[560px]">
48+
<div className="flex flex-col items-center">
49+
<p className="max-w-md text-gray-10 text-md">
50+
Choose how you'd like to record your Cap
51+
</p>
52+
</div>
53+
<div className="flex flex-wrap gap-3 justify-center items-center mt-4">
54+
<Button
55+
onClick={openDesktop}
56+
className="flex relative gap-2 justify-center items-center"
57+
variant="primary"
58+
>
59+
<FontAwesomeIcon className="size-3.5" icon={faDownload} />
60+
Open Cap Desktop
61+
</Button>
62+
<p className="text-sm text-gray-10">or</p>
63+
<WebRecorderDialog />
64+
</div>
65+
<FaqAccordion />
66+
</div>
67+
</div>
68+
</div>
69+
</div>
70+
);
71+
};
72+
73+
const FaqAccordion = () => {
74+
const freeMinutes = Math.floor(FREE_PLAN_MAX_RECORDING_MS / 60000);
75+
const items = [
76+
{
77+
id: "what-is-cap",
78+
q: "What is a Cap?",
79+
a: "A Cap is a quick video recording of your screen, camera, or both that you can share instantly with a link.",
80+
},
81+
{
82+
id: "how-it-works",
83+
q: "How does it work?",
84+
a: "On compatible browsers, your capture uploads in the background while you record. Otherwise it records first and uploads immediately after you stop, so your link is ready right away.",
85+
},
86+
{
87+
id: "browsers",
88+
q: "Which browsers are recommended?",
89+
a: "We recommend Google Chrome or other Chromium‑based browsers for the most reliable recording and upload behavior. Most modern browsers are supported, but capabilities can vary.",
90+
},
91+
{
92+
id: "pip",
93+
q: "How do I keep my webcam visible?",
94+
a: "On compatible browsers, selecting a camera opens a picture‑in‑picture window that’s captured when you record fullscreen. We recommend recording fullscreen to keep it on top. If PiP capture isn’t supported, your camera stays within the Cap recorder tab.",
95+
},
96+
{
97+
id: "what-can-i-record",
98+
q: "What can I record?",
99+
a: "You can record your entire screen, a specific window, a browser tab, or just your camera.",
100+
},
101+
{
102+
id: "system-audio",
103+
q: "Can I record system audio?",
104+
a: "Browsers limit system‑wide audio capture. Chrome can capture tab audio, but full system audio is best with Cap Desktop.",
105+
},
106+
{
107+
id: "install",
108+
q: "Do I need to install the app?",
109+
a: `No. You can record in your browser. For longer recordings, system audio, and advanced editing, use Cap Desktop. The Free plan supports up to ${freeMinutes} minutes per recording in the browser.`,
110+
},
111+
];
112+
113+
return (
114+
<div className="mt-8 w-full max-w-[560px] px-5 text-left">
115+
<div className="divide-y divide-gray-4 rounded-lg border border-gray-4 bg-gray-2 w-full">
116+
{items.map((it) => (
117+
<AccordionItem key={it.id} title={it.q} content={it.a} />
118+
))}
119+
</div>
120+
</div>
121+
);
122+
};
123+
124+
const AccordionItem = ({
125+
title,
126+
content,
127+
}: {
128+
title: string;
129+
content: string;
130+
}) => {
131+
const [open, setOpen] = useState(false);
132+
const contentId = useId();
133+
const headerId = useId();
134+
135+
return (
136+
<div className="p-3">
137+
<button
138+
id={headerId}
139+
aria-controls={contentId}
140+
aria-expanded={open}
141+
onClick={() => setOpen((v) => !v)}
142+
type="button"
143+
className="flex w-full items-center justify-between gap-3 text-left"
144+
>
145+
<span className="text-sm font-medium text-gray-12">{title}</span>
146+
<ChevronDown
147+
className="size-4 shrink-0 text-gray-10 transition-transform duration-200"
148+
style={{ transform: open ? "rotate(180deg)" : "rotate(0deg)" }}
149+
/>
150+
</button>
151+
<AnimatePresence initial={false}>
152+
{open && (
153+
<motion.section
154+
id={contentId}
155+
aria-labelledby={headerId}
156+
initial={{ opacity: 0, height: 0 }}
157+
animate={{ opacity: 1, height: "auto" }}
158+
exit={{ opacity: 0, height: 0 }}
159+
transition={{ duration: 0.18 }}
160+
className="overflow-hidden"
161+
>
162+
<div className="pt-2 text-sm text-gray-10">{content}</div>
163+
</motion.section>
164+
)}
165+
</AnimatePresence>
166+
</div>
167+
);
168+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { Metadata } from "next";
2+
import { RecordVideoPage } from "./RecordVideoPage";
3+
4+
export const metadata: Metadata = {
5+
title: "Record a Cap",
6+
};
7+
8+
export default function RecordVideoRoute() {
9+
return <RecordVideoPage />;
10+
}

0 commit comments

Comments
 (0)