summaryrefslogtreecommitdiff
path: root/apps/web/src/components/landing
diff options
context:
space:
mode:
authorSimon Orzel <simonorzel@gmail.com>2025-07-20 18:35:06 +0200
committerSimon Orzel <simonorzel@gmail.com>2025-07-20 18:35:06 +0200
commitb03287df8d6fa14153af264c6a3e3da5b2a06153 (patch)
tree100b2c2a729295419dce61655bca418e74195ec4 /apps/web/src/components/landing
parent061624ab588afff18073ae62fbac425c1aa5cf3a (diff)
parentba3da43728bc051aa033dc7b45042b0f0f788e63 (diff)
downloadopencut-b03287df8d6fa14153af264c6a3e3da5b2a06153.tar.gz
Merge branch 'main' into feat/project-preview-images
Diffstat (limited to 'apps/web/src/components/landing')
-rw-r--r--apps/web/src/components/landing/handlebars.tsx274
-rw-r--r--apps/web/src/components/landing/hero.tsx336
2 files changed, 305 insertions, 305 deletions
diff --git a/apps/web/src/components/landing/handlebars.tsx b/apps/web/src/components/landing/handlebars.tsx
index d65fccff..36705e90 100644
--- a/apps/web/src/components/landing/handlebars.tsx
+++ b/apps/web/src/components/landing/handlebars.tsx
@@ -4,161 +4,161 @@ import React, { useState, useRef, useEffect } from "react";
import { motion, useMotionValue, useTransform, PanInfo } from "motion/react";
interface HandlebarsProps {
- children: React.ReactNode;
- minWidth?: number;
- maxWidth?: number;
- onRangeChange?: (left: number, right: number) => void;
+ children: React.ReactNode;
+ minWidth?: number;
+ maxWidth?: number;
+ onRangeChange?: (left: number, right: number) => void;
}
export function Handlebars({
- children,
- minWidth = 50,
- maxWidth = 400,
- onRangeChange,
+ children,
+ minWidth = 50,
+ maxWidth = 400,
+ onRangeChange,
}: HandlebarsProps) {
- const [leftHandle, setLeftHandle] = useState(0);
- const [rightHandle, setRightHandle] = useState(maxWidth);
- const [contentWidth, setContentWidth] = useState(maxWidth);
+ const [leftHandle, setLeftHandle] = useState(0);
+ const [rightHandle, setRightHandle] = useState(maxWidth);
+ const [contentWidth, setContentWidth] = useState(maxWidth);
- const leftHandleX = useMotionValue(0);
- const rightHandleX = useMotionValue(maxWidth);
+ const leftHandleX = useMotionValue(0);
+ const rightHandleX = useMotionValue(maxWidth);
- const visibleWidth = useTransform(
- [leftHandleX, rightHandleX],
- (values: number[]) => values[1] - values[0],
- );
+ const visibleWidth = useTransform(
+ [leftHandleX, rightHandleX],
+ (values: number[]) => values[1] - values[0]
+ );
- const contentLeft = useTransform(leftHandleX, (left: number) => -left);
+ const contentLeft = useTransform(leftHandleX, (left: number) => -left);
- const containerRef = useRef<HTMLDivElement>(null);
- const measureRef = useRef<HTMLDivElement>(null);
+ const containerRef = useRef<HTMLDivElement>(null);
+ const measureRef = useRef<HTMLDivElement>(null);
- useEffect(() => {
- if (!measureRef.current) return;
+ useEffect(() => {
+ if (!measureRef.current) return;
- const measureContent = () => {
- if (measureRef.current) {
- const width = measureRef.current.scrollWidth;
- const paddedWidth = width + 32;
- setContentWidth(paddedWidth);
- setRightHandle(paddedWidth);
- rightHandleX.set(paddedWidth);
- }
- };
+ const measureContent = () => {
+ if (measureRef.current) {
+ const width = measureRef.current.scrollWidth;
+ const paddedWidth = width + 32;
+ setContentWidth(paddedWidth);
+ setRightHandle(paddedWidth);
+ rightHandleX.set(paddedWidth);
+ }
+ };
- measureContent();
- const timer = setTimeout(measureContent, 50);
+ measureContent();
+ const timer = setTimeout(measureContent, 50);
- return () => clearTimeout(timer);
- }, [children, rightHandleX]);
+ return () => clearTimeout(timer);
+ }, [children, rightHandleX]);
- useEffect(() => {
- leftHandleX.set(leftHandle);
- }, [leftHandle, leftHandleX]);
+ useEffect(() => {
+ leftHandleX.set(leftHandle);
+ }, [leftHandle, leftHandleX]);
- useEffect(() => {
- rightHandleX.set(rightHandle);
- }, [rightHandle, rightHandleX]);
+ useEffect(() => {
+ rightHandleX.set(rightHandle);
+ }, [rightHandle, rightHandleX]);
- useEffect(() => {
- onRangeChange?.(leftHandle, rightHandle);
- }, [leftHandle, rightHandle, onRangeChange]);
+ useEffect(() => {
+ onRangeChange?.(leftHandle, rightHandle);
+ }, [leftHandle, rightHandle, onRangeChange]);
- const handleLeftDrag = (event: any, info: PanInfo) => {
- const newLeft = Math.max(
- 0,
- Math.min(leftHandle + info.offset.x, rightHandle - minWidth),
- );
- setLeftHandle(newLeft);
- };
+ const handleLeftDrag = (event: any, info: PanInfo) => {
+ const newLeft = Math.max(
+ 0,
+ Math.min(leftHandle + info.offset.x, rightHandle - minWidth)
+ );
+ setLeftHandle(newLeft);
+ };
- const handleRightDrag = (event: any, info: PanInfo) => {
- const newRight = Math.max(
- leftHandle + minWidth,
- Math.min(contentWidth, rightHandle + info.offset.x),
- );
- setRightHandle(newRight);
- };
+ const handleRightDrag = (event: any, info: PanInfo) => {
+ const newRight = Math.max(
+ leftHandle + minWidth,
+ Math.min(contentWidth, rightHandle + info.offset.x)
+ );
+ setRightHandle(newRight);
+ };
- return (
- <div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
- <div
- ref={measureRef}
- className="absolute -left-[9999px] top-0 invisible px-4 whitespace-nowrap font-[inherit]"
- >
- {children}
- </div>
+ return (
+ <div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
+ <div
+ ref={measureRef}
+ className="absolute -left-[9999px] top-0 invisible px-4 whitespace-nowrap font-[inherit]"
+ >
+ {children}
+ </div>
- <div
- ref={containerRef}
- className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2"
- style={{ width: contentWidth }}
- >
- <div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between">
- <motion.div
- className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none"
- style={{
- position: "absolute",
- x: leftHandleX,
- left: 0,
- zIndex: 10,
- }}
- drag="x"
- dragConstraints={{ left: 0, right: rightHandle - minWidth }}
- dragElastic={0}
- dragMomentum={false}
- onDrag={handleLeftDrag}
- whileHover={{ scale: 1.05 }}
- whileDrag={{ scale: 1.1 }}
- transition={{ type: "spring", stiffness: 400, damping: 30 }}
- >
- <div className="w-2 h-8 rounded-full bg-yellow-500"></div>
- </motion.div>
+ <div
+ ref={containerRef}
+ className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2"
+ style={{ width: contentWidth }}
+ >
+ <div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between">
+ <motion.div
+ className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none"
+ style={{
+ position: "absolute",
+ x: leftHandleX,
+ left: 0,
+ zIndex: 10,
+ }}
+ drag="x"
+ dragConstraints={{ left: 0, right: rightHandle - minWidth }}
+ dragElastic={0}
+ dragMomentum={false}
+ onDrag={handleLeftDrag}
+ whileHover={{ scale: 1.05 }}
+ whileDrag={{ scale: 1.1 }}
+ transition={{ type: "spring", stiffness: 400, damping: 30 }}
+ >
+ <div className="w-2 h-8 rounded-full bg-yellow-500"></div>
+ </motion.div>
- <motion.div
- className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none"
- style={{
- position: "absolute",
- x: rightHandleX,
- left: -30,
- zIndex: 10,
- }}
- drag="x"
- dragConstraints={{
- left: leftHandle + minWidth,
- right: contentWidth,
- }}
- dragElastic={0}
- dragMomentum={false}
- onDrag={handleRightDrag}
- whileHover={{ scale: 1.05 }}
- whileDrag={{ scale: 1.1 }}
- transition={{ type: "spring", stiffness: 400, damping: 30 }}
- >
- <div className="w-2 h-8 rounded-full bg-yellow-500"></div>
- </motion.div>
- </div>
+ <motion.div
+ className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none"
+ style={{
+ position: "absolute",
+ x: rightHandleX,
+ left: -30,
+ zIndex: 10,
+ }}
+ drag="x"
+ dragConstraints={{
+ left: leftHandle + minWidth,
+ right: contentWidth,
+ }}
+ dragElastic={0}
+ dragMomentum={false}
+ onDrag={handleRightDrag}
+ whileHover={{ scale: 1.05 }}
+ whileDrag={{ scale: 1.1 }}
+ transition={{ type: "spring", stiffness: 400, damping: 30 }}
+ >
+ <div className="w-2 h-8 rounded-full bg-yellow-500"></div>
+ </motion.div>
+ </div>
- <motion.div
- className="relative overflow-hidden rounded-2xl"
- style={{
- width: visibleWidth,
- x: leftHandleX,
- height: "100%",
- }}
- >
- <motion.div
- className="w-full h-full flex items-center justify-center px-4"
- style={{
- x: contentLeft,
- width: contentWidth,
- whiteSpace: "nowrap",
- }}
- >
- {children}
- </motion.div>
- </motion.div>
- </div>
- </div>
- );
-}
+ <motion.div
+ className="relative overflow-hidden rounded-2xl"
+ style={{
+ width: visibleWidth,
+ x: leftHandleX,
+ height: "100%",
+ }}
+ >
+ <motion.div
+ className="w-full h-full flex items-center justify-center px-4"
+ style={{
+ x: contentLeft,
+ width: contentWidth,
+ whiteSpace: "nowrap",
+ }}
+ >
+ {children}
+ </motion.div>
+ </motion.div>
+ </div>
+ </div>
+ );
+};
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx
index 4030c438..d5fe38d1 100644
--- a/apps/web/src/components/landing/hero.tsx
+++ b/apps/web/src/components/landing/hero.tsx
@@ -13,184 +13,184 @@ import Image from "next/image";
import { Handlebars } from "./handlebars";
export function Hero() {
- const [email, setEmail] = useState("");
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [csrfToken, setCsrfToken] = useState<string | null>(null);
+ const [email, setEmail] = useState("");
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [csrfToken, setCsrfToken] = useState<string | null>(null);
- useEffect(() => {
- let isMounted = true;
- fetch("/api/waitlist/token", {
- credentials: "include",
- })
- .then((res) => res.json())
- .then((data) => {
- if (isMounted && data.token) {
- setCsrfToken(data.token);
- }
- })
- .catch((err) => {
- console.error("Failed to fetch CSRF token:", err);
- if (isMounted) {
- toast.error("Security initialization failed", {
- description: "Please refresh the page to continue.",
- });
- }
- });
- }, []);
+ useEffect(() => {
+ let isMounted = true;
+ fetch("/api/waitlist/token", {
+ credentials: "include",
+ })
+ .then((res) => res.json())
+ .then((data) => {
+ if (isMounted && data.token) {
+ setCsrfToken(data.token);
+ }
+ })
+ .catch((err) => {
+ console.error("Failed to fetch CSRF token:", err);
+ if (isMounted) {
+ toast.error("Security initialization failed", {
+ description: "Please refresh the page to continue.",
+ });
+ }
+ });
+ }, []);
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
- if (!email.trim()) {
- toast.error("Email required", {
- description: "Please enter your email address.",
- });
- return;
- }
+ if (!email.trim()) {
+ toast.error("Email required", {
+ description: "Please enter your email address.",
+ });
+ return;
+ }
- if (!csrfToken) {
- toast.error("Security error", {
- description: "Please refresh the page and try again.",
- });
- return;
- }
+ if (!csrfToken) {
+ toast.error("Security error", {
+ description: "Please refresh the page and try again.",
+ });
+ return;
+ }
- setIsSubmitting(true);
+ setIsSubmitting(true);
- try {
- const response = await fetch("/api/waitlist", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- "X-CSRF-Token": csrfToken,
- },
- credentials: "include",
- body: JSON.stringify({ email: email.trim() }),
- });
+ try {
+ const response = await fetch("/api/waitlist", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRF-Token": csrfToken,
+ },
+ credentials: "include",
+ body: JSON.stringify({ email: email.trim() }),
+ });
- const data = (await response.json()) as { error: string };
+ const data = (await response.json()) as { error: string };
- if (response.ok) {
- toast.success("Welcome to the waitlist! 🎉", {
- description: "You'll be notified when we launch.",
- });
- setEmail("");
+ if (response.ok) {
+ toast.success("Welcome to the waitlist! 🎉", {
+ description: "You'll be notified when we launch.",
+ });
+ setEmail("");
- fetch("/api/waitlist/token", { credentials: "include" })
- .then((res) => res.json())
- .then((data) => {
- if (data.token) setCsrfToken(data.token);
- })
- .catch((err) => {
- console.error("Failed to refresh CSRF token:", err);
- });
- } else {
- toast.error("Oops!", {
- description:
- (data as { error: string }).error ||
- "Something went wrong. Please try again.",
- });
- }
- } catch (error) {
- toast.error("Network error", {
- description: "Please check your connection and try again.",
- });
- } finally {
- setIsSubmitting(false);
- }
- };
+ fetch("/api/waitlist/token", { credentials: "include" })
+ .then((res) => res.json())
+ .then((data) => {
+ if (data.token) setCsrfToken(data.token);
+ })
+ .catch((err) => {
+ console.error("Failed to refresh CSRF token:", err);
+ });
+ } else {
+ toast.error("Oops!", {
+ description:
+ (data as { error: string }).error ||
+ "Something went wrong. Please try again.",
+ });
+ }
+ } catch (error) {
+ toast.error("Network error", {
+ description: "Please check your connection and try again.",
+ });
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
- return (
- <div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4">
- <Image
- className="absolute top-0 left-0 -z-50 size-full object-cover"
- src="/landing-page-bg.png"
- height={1903.5}
- width={1269}
- alt="landing-page.bg"
- />
- <motion.div
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- transition={{ duration: 1 }}
- className="max-w-3xl mx-auto w-full flex-1 flex flex-col justify-center"
- >
- <motion.div
- initial={{ opacity: 0, y: 20 }}
- animate={{ opacity: 1, y: 0 }}
- transition={{ delay: 0.6, duration: 0.8 }}
- className="mb-8 flex justify-center"
- >
- <SponsorButton
- href="https://vercel.com/?utm_source=opencut"
- logo={VercelIcon}
- companyName="Vercel"
- />
- </motion.div>
- <motion.div
- initial={{ opacity: 0, y: 20 }}
- animate={{ opacity: 1, y: 0 }}
- transition={{ delay: 0.2, duration: 0.8 }}
- className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]"
- >
- <h1>The Open Source</h1>
- <Handlebars>Video Editor</Handlebars>
- </motion.div>
+ return (
+ <div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4">
+ <Image
+ className="absolute top-0 left-0 -z-50 size-full object-cover"
+ src="/landing-page-bg.png"
+ height={1903.5}
+ width={1269}
+ alt="landing-page.bg"
+ />
+ <motion.div
+ initial={{ opacity: 0 }}
+ animate={{ opacity: 1 }}
+ transition={{ duration: 1 }}
+ className="max-w-3xl mx-auto w-full flex-1 flex flex-col justify-center"
+ >
+ <motion.div
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ delay: 0.6, duration: 0.8 }}
+ className="mb-8 flex justify-center"
+ >
+ <SponsorButton
+ href="https://vercel.com/?utm_source=opencut"
+ logo={VercelIcon}
+ companyName="Vercel"
+ />
+ </motion.div>
+ <motion.div
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ delay: 0.2, duration: 0.8 }}
+ className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]"
+ >
+ <h1>The Open Source</h1>
+ <Handlebars>Video Editor</Handlebars>
+ </motion.div>
- <motion.p
- className="mt-10 text-base sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- transition={{ delay: 0.4, duration: 0.8 }}
- >
- A simple but powerful video editor that gets the job done. Works on
- any platform.
- </motion.p>
+ <motion.p
+ className="mt-10 text-base sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
+ initial={{ opacity: 0 }}
+ animate={{ opacity: 1 }}
+ transition={{ delay: 0.4, duration: 0.8 }}
+ >
+ A simple but powerful video editor that gets the job done. Works on
+ any platform.
+ </motion.p>
- <motion.div
- className="mt-12 flex gap-8 justify-center"
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- transition={{ delay: 0.6, duration: 0.8 }}
- >
- <form
- onSubmit={handleSubmit}
- className="flex gap-3 w-full max-w-lg flex-col sm:flex-row"
- >
- <div className="relative w-full">
- <Input
- type="email"
- placeholder="Enter your email"
- className="h-11 text-base flex-1"
- value={email}
- onChange={(e) => setEmail(e.target.value)}
- disabled={isSubmitting || !csrfToken}
- required
- />
- </div>
- <Button
- type="submit"
- size="lg"
- className="px-6 h-11 text-base !bg-foreground"
- disabled={isSubmitting || !csrfToken}
- >
- <span className="relative z-10">
- {isSubmitting ? "Joining..." : "Join waitlist"}
- </span>
- <ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" />
- </Button>
- </form>
- </motion.div>
- <motion.div
- initial={{ opacity: 0, y: 10 }}
- animate={{ opacity: 1, y: 0 }}
- transition={{ delay: 0.8, duration: 0.6 }}
- className="mt-8 inline-flex items-center gap-2 text-sm text-muted-foreground justify-center"
- >
- <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
- <span>50k+ people already joined</span>
- </motion.div>
- </motion.div>
- </div>
- );
+ <motion.div
+ className="mt-12 flex gap-8 justify-center"
+ initial={{ opacity: 0 }}
+ animate={{ opacity: 1 }}
+ transition={{ delay: 0.6, duration: 0.8 }}
+ >
+ <form
+ onSubmit={handleSubmit}
+ className="flex gap-3 w-full max-w-lg flex-col sm:flex-row"
+ >
+ <div className="relative w-full">
+ <Input
+ type="email"
+ placeholder="Enter your email"
+ className="h-11 text-base flex-1"
+ value={email}
+ onChange={(e) => setEmail(e.target.value)}
+ disabled={isSubmitting || !csrfToken}
+ required
+ />
+ </div>
+ <Button
+ type="submit"
+ size="lg"
+ className="px-6 h-11 text-base !bg-foreground"
+ disabled={isSubmitting || !csrfToken}
+ >
+ <span className="relative z-10">
+ {isSubmitting ? "Joining..." : "Join waitlist"}
+ </span>
+ <ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" />
+ </Button>
+ </form>
+ </motion.div>
+ <motion.div
+ initial={{ opacity: 0, y: 10 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ delay: 0.8, duration: 0.6 }}
+ className="mt-8 inline-flex items-center gap-2 text-sm text-muted-foreground justify-center"
+ >
+ <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
+ <span>50k+ people already joined</span>
+ </motion.div>
+ </motion.div>
+ </div>
+ );
}