diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index a0a0d31b..02261068 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from "next"; +import { withBotId } from "botid/next/config"; const nextConfig: NextConfig = { compiler: { @@ -21,4 +22,4 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default withBotId(nextConfig); diff --git a/apps/web/package.json b/apps/web/package.json index 07b36dcb..193eb244 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,6 +27,7 @@ "@upstash/redis": "^1.35.0", "@vercel/analytics": "^1.4.1", "better-auth": "^1.2.7", + "botid": "^1.4.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", diff --git a/apps/web/src/app/api/waitlist/route.ts b/apps/web/src/app/api/waitlist/route.ts index f6f81103..f62e24e9 100644 --- a/apps/web/src/app/api/waitlist/route.ts +++ b/apps/web/src/app/api/waitlist/route.ts @@ -1,54 +1,77 @@ import { NextRequest, NextResponse } from "next/server"; import { db, eq } from "@opencut/db"; import { waitlist } from "@opencut/db/schema"; +import { checkBotId } from "botid/server"; import { nanoid } from "nanoid"; import { waitlistRateLimit } from "@/lib/rate-limit"; import { z } from "zod"; +import { env } from "@/env"; +import { cookies } from "next/headers"; +import crypto from "crypto"; const waitlistSchema = z.object({ email: z.string().email("Invalid email format").min(1, "Email is required"), }); +const CSRF_TOKEN_NAME = "waitlist-csrf"; +const TOKEN_EXPIRY = 60 * 60 * 1000; + +async function validateCSRFToken(request: NextRequest): Promise { + const clientToken = request.headers.get("x-csrf-token"); + if (!clientToken) return false; + + const cookieStore = await cookies(); + const cookieValue = cookieStore.get(CSRF_TOKEN_NAME)?.value; + if (!cookieValue) return false; + + const [token, timestamp, signature] = cookieValue.split(":"); + if (!token || !timestamp || !signature) return false; + + if (clientToken !== token) return false; + + const now = Date.now(); + const tokenTime = parseInt(timestamp); + if (now - tokenTime > TOKEN_EXPIRY) return false; + + const expectedSignature = crypto.createHmac("sha256", env.BETTER_AUTH_SECRET).update(`${token}:${timestamp}`).digest("hex"); + + return signature === expectedSignature; +} + export async function POST(request: NextRequest) { - // Rate limit check + const verification = await checkBotId(); + + if (verification.isBot) { + return NextResponse.json({ error: "Access denied" }, { status: 403 }); + } + const identifier = request.headers.get("x-forwarded-for") ?? "127.0.0.1"; const { success } = await waitlistRateLimit.limit(identifier); if (!success) { - return NextResponse.json( - { error: "Too many requests. Please try again later." }, - { status: 429 } - ); + return NextResponse.json({ error: "Too many requests. Please try again later." }, { status: 429 }); + } + const isValidToken = await validateCSRFToken(request); + if (!isValidToken) { + return NextResponse.json({ error: "Invalid security token" }, { status: 403 }); } try { const body = await request.json(); const { email } = waitlistSchema.parse(body); - // Check if email already exists - const existingEmail = await db - .select() - .from(waitlist) - .where(eq(waitlist.email, email.toLowerCase())) - .limit(1); + const existingEmail = await db.select().from(waitlist).where(eq(waitlist.email, email.toLowerCase())).limit(1); if (existingEmail.length > 0) { - return NextResponse.json( - { error: "Email already registered" }, - { status: 409 } - ); + return NextResponse.json({ error: "Email already registered" }, { status: 409 }); } - // Add to waitlist await db.insert(waitlist).values({ id: nanoid(), email: email.toLowerCase(), }); - return NextResponse.json( - { message: "Successfully joined waitlist!" }, - { status: 201 } - ); + return NextResponse.json({ message: "Successfully joined waitlist!" }, { status: 201 }); } catch (error) { if (error instanceof z.ZodError) { const firstError = error.errors[0]; @@ -56,9 +79,6 @@ export async function POST(request: NextRequest) { } console.error("Waitlist signup error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/apps/web/src/app/api/waitlist/token/route.ts b/apps/web/src/app/api/waitlist/token/route.ts new file mode 100644 index 00000000..4c7695fa --- /dev/null +++ b/apps/web/src/app/api/waitlist/token/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import crypto from "crypto"; +import { env } from "@/env"; + +const CSRF_TOKEN_NAME = "waitlist-csrf"; +const TOKEN_EXPIRY = 60 * 60 * 1000; +const allowedHosts = env.NODE_ENV === "development" ? ["localhost:3000", "127.0.0.1:3000"] : ["opencut.app", "www.opencut.app"]; + +export async function GET(request: NextRequest) { + const referer = request.headers.get("referer"); + const host = request.headers.get("host"); + + if (referer) { + const refererUrl = new URL(referer); + + if (!allowedHosts.some((allowed) => refererUrl.host === allowed || refererUrl.host.endsWith(allowed))) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + } else if (host) { + if (!allowedHosts.some((allowed) => host === allowed || host.endsWith(allowed))) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + } else { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + if (!env.BETTER_AUTH_SECRET) { + throw new Error("BETTER_AUTH_SECRET must be configured"); + } + + const token = crypto.randomBytes(32).toString("hex"); + const timestamp = Date.now(); + const signature = crypto.createHmac("sha256", env.BETTER_AUTH_SECRET).update(`${token}:${timestamp}`).digest("hex"); + + const cookieStore = await cookies(); + cookieStore.set(CSRF_TOKEN_NAME, `${token}:${timestamp}:${signature}`, { + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict", + maxAge: TOKEN_EXPIRY / 1000, + path: "/", + }); + + return NextResponse.json({ token }); +} diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx index fcf96e11..5613f1f9 100644 --- a/apps/web/src/app/contributors/page.tsx +++ b/apps/web/src/app/contributors/page.tsx @@ -32,7 +32,7 @@ interface Contributor { async function getContributors(): Promise { try { const response = await fetch( - "https://api.github.com/repos/OpenCut-app/OpenCut/contributors", + "https://api.github.com/repos/OpenCut-app/OpenCut/contributors?per_page=100", { headers: { Accept: "application/vnd.github.v3+json", diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 315e0764..65667106 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect } from "react"; -import { useParams } from "next/navigation"; +import { useEffect, useRef } from "react"; +import { useParams, useRouter } from "next/navigation"; import { ResizablePanelGroup, ResizablePanel, @@ -33,25 +33,38 @@ export default function Editor() { const { activeProject, loadProject, createNewProject } = useProjectStore(); const params = useParams(); + const router = useRouter(); const projectId = params.project_id as string; + const handledProjectIds = useRef>(new Set()); usePlaybackControls(); useEffect(() => { - const initializeProject = async () => { - if (projectId && (!activeProject || activeProject.id !== projectId)) { - try { - await loadProject(projectId); - } catch (error) { - console.error("Failed to load project:", error); - // If project doesn't exist, create a new one - await createNewProject("Untitled Project"); - } + const initProject = async () => { + + if (!projectId) return; + + if (activeProject?.id === projectId) { + return; + } + + if (handledProjectIds.current.has(projectId)) { + return; + } + + try { + await loadProject(projectId); + } catch (error) { + handledProjectIds.current.add(projectId); + + const newProjectId = await createNewProject("Untitled Project"); + router.replace(`/editor/${newProjectId}`); + return; } }; - initializeProject(); - }, [projectId, activeProject, loadProject, createNewProject]); + initProject(); + }, [projectId, activeProject?.id, loadProject, createNewProject, router]); return ( diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 9ac54262..1cbc3f2e 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -7,9 +7,17 @@ import { TooltipProvider } from "../components/ui/tooltip"; import { StorageProvider } from "../components/storage-provider"; import { baseMetaData } from "./metadata"; import { defaultFont } from "../lib/font-config"; +import { BotIdClient } from "botid/client"; export const metadata = baseMetaData; +const protectedRoutes = [ + { + path: "/api/waitlist", + method: "POST", + }, +]; + export default function RootLayout({ children, }: Readonly<{ @@ -17,6 +25,9 @@ export default function RootLayout({ }>) { return ( + + + diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 74308470..74961549 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,18 +1,12 @@ import { Hero } from "@/components/landing/hero"; import { Header } from "@/components/header"; import { Footer } from "@/components/footer"; -import { getWaitlistCount } from "@/lib/waitlist"; - -// Force dynamic rendering so waitlist count updates in real-time -export const dynamic = "force-dynamic"; export default async function Home() { - const signupCount = await getWaitlistCount(); - return (
- +
); diff --git a/apps/web/src/components/editor/media-panel/drag-overlay.tsx b/apps/web/src/components/editor/media-panel/drag-overlay.tsx new file mode 100644 index 00000000..3f3820db --- /dev/null +++ b/apps/web/src/components/editor/media-panel/drag-overlay.tsx @@ -0,0 +1,57 @@ +import { Upload, Plus, Image } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface MediaDragOverlayProps { + isVisible: boolean; + isProcessing?: boolean; + progress?: number; + onClick?: () => void; + isEmptyState?: boolean; +} + +export function MediaDragOverlay({ + isVisible, + isProcessing = false, + progress = 0, + onClick, + isEmptyState = false, +}: MediaDragOverlayProps) { + if (!isVisible) return null; + + const handleClick = (e: React.MouseEvent) => { + if (isProcessing || !onClick) return; + e.preventDefault(); + e.stopPropagation(); + onClick(); + }; + + return ( +
+
+ +
+ +
+

+ {isProcessing + ? `Processing your files (${progress}%)` + : "Drag and drop videos, photos, and audio files here"} +

+
+ + {isProcessing && ( +
+
+
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx index 5438737f..631fe39a 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -3,11 +3,11 @@ import { useDragDrop } from "@/hooks/use-drag-drop"; import { processMediaFiles } from "@/lib/media-processing"; import { useMediaStore, type MediaItem } from "@/stores/media-store"; -import { Image, Music, Plus, Upload, Video } from "lucide-react"; +import { Image, Loader2, Music, Plus, Video } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { DragOverlay } from "@/components/ui/drag-overlay"; +import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay"; import { ContextMenu, ContextMenuContent, @@ -24,6 +24,7 @@ import { } from "@/components/ui/select"; import { DraggableMediaItem } from "@/components/ui/draggable-item"; import { useProjectStore } from "@/stores/project-store"; +import { useTimelineStore } from "@/stores/timeline-store"; export function MediaView() { const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); @@ -203,9 +204,6 @@ export function MediaView() { className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`} {...dragProps} > - {/* Show overlay when dragging files over the panel */} - -
{/* Search and filter controls */}
@@ -227,47 +225,31 @@ export function MediaView() { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} /> +
- {/* Show message if no media, otherwise show media grid */} - {filteredMediaItems.length === 0 ? ( -
-
- -
-

- No media in project -

-

- Drag files here or use the button below -

- -
+ {isDragOver || filteredMediaItems.length === 0 ? ( + ) : (
+ useTimelineStore + .getState() + .addMediaAtTime(item, currentTime) + } rounded={false} /> diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx index bec32c85..6d0d310b 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -1,4 +1,30 @@ import { DraggableMediaItem } from "@/components/ui/draggable-item"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { type TextElement } from "@/types/timeline"; + +let textData: TextElement = { + id: "default-text", + type: "text", + name: "Default text", + content: "Default text", + fontSize: 48, + fontFamily: "Arial", + color: "#ffffff", + backgroundColor: "transparent", + textAlign: "center" as const, + fontWeight: "normal" as const, + fontStyle: "normal" as const, + textDecoration: "none" as const, + x: 0, + y: 0, + rotation: 0, + opacity: 1, + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, +}; export function TextView() { return ( @@ -11,12 +37,15 @@ export function TextView() {
} dragData={{ - id: "default-text", - type: "text", - name: "Default text", - content: "Default text", + id: textData.id, + type: textData.type, + name: textData.name, + content: textData.content, }} aspectRatio={1} + onAddToTimeline={(currentTime) => + useTimelineStore.getState().addTextAtTime(textData, currentTime) + } showLabel={false} />
diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx index ee9445f3..8cccd0e4 100644 --- a/apps/web/src/components/editor/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline-element.tsx @@ -24,6 +24,7 @@ import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; import { getTrackElementClasses, TIMELINE_CONSTANTS, + getTrackHeight, } from "@/constants/timeline-constants"; import { DropdownMenu, @@ -267,35 +268,99 @@ export function TimelineElement({ ); } + const TILE_ASPECT_RATIO = 16 / 9; + if (mediaItem.type === "image") { + // Calculate tile size based on 16:9 aspect ratio + const trackHeight = getTrackHeight(track.type); + const tileHeight = trackHeight - 8; // Account for padding + const tileWidth = tileHeight * TILE_ASPECT_RATIO; + return (
-
- {mediaItem.name} + {/* Background with tiled images */} +
+ {/* Overlay with vertical borders */} +
); } + const VIDEO_TILE_PADDING = 16; + const OVERLAY_SPACE_MULTIPLIER = 1.5; + if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { + const trackHeight = getTrackHeight(track.type); + const tileHeight = trackHeight - VIDEO_TILE_PADDING; + const tileWidth = tileHeight * TILE_ASPECT_RATIO; + return (
-
- {mediaItem.name} + {/* Background with tiled thumbnails */} +
+ {/* Overlay with vertical borders */} +
- - {element.name} - + {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? ( +
+ {element.name} +
+ ) : ( + + {element.name} + + )}
); } diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index ca5bb672..6775e845 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -18,6 +18,7 @@ import { TypeIcon, Magnet, Lock, + LockOpen, } from "lucide-react"; import { Tooltip, @@ -571,29 +572,7 @@ export function Timeline() { if (dragData.type === "text") { // Always create new text track to avoid overlaps - const newTrackId = addTrack("text"); - - addElementToTrack(newTrackId, { - type: "text", - name: dragData.name || "Text", - content: dragData.content || "Default Text", - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, - fontSize: 48, - fontFamily: "Arial", - color: "#ffffff", - backgroundColor: "transparent", - textAlign: "center", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - x: 0, - y: 0, - rotation: 0, - opacity: 1, - }); + useTimelineStore.getState().addTextToNewTrack(dragData); } else { // Handle media items const mediaItem = mediaItems.find((item: any) => item.id === dragData.id); @@ -602,19 +581,7 @@ export function Timeline() { return; } - const trackType = dragData.type === "audio" ? "audio" : "media"; - let targetTrack = tracks.find((t) => t.type === trackType); - const newTrackId = targetTrack ? targetTrack.id : addTrack(trackType); - - addElementToTrack(newTrackId, { - type: "media", - mediaId: mediaItem.id, - name: mediaItem.name, - duration: mediaItem.duration || 5, - startTime: 0, - trimStart: 0, - trimEnd: 0, - }); + useTimelineStore.getState().addMediaToNewTrack(mediaItem); } } catch (error) { console.error("Error parsing dropped item data:", error); @@ -642,18 +609,7 @@ export function Timeline() { item.name === processedItem.name && item.url === processedItem.url ); if (addedItem) { - const trackType = - processedItem.type === "audio" ? "audio" : "media"; - const newTrackId = addTrack(trackType); - addElementToTrack(newTrackId, { - type: "media", - mediaId: addedItem.id, - name: addedItem.name, - duration: addedItem.duration || 5, - startTime: 0, - trimStart: 0, - trimEnd: 0, - }); + useTimelineStore.getState().addMediaToNewTrack(addedItem); } } } catch (error) { @@ -1048,7 +1004,11 @@ export function Timeline() { Auto snapping diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index 01ad7e7e..b1910dc5 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -4,19 +4,37 @@ import { motion } from "motion/react"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { ArrowRight } from "lucide-react"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { toast } from "sonner"; import Image from "next/image"; import { Handlebars } from "./handlebars"; -interface HeroProps { - signupCount: number; -} - -export function Hero({ signupCount }: HeroProps) { +export function Hero() { const [email, setEmail] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); + const [csrfToken, setCsrfToken] = useState(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.", + }); + } + }); + }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -28,6 +46,13 @@ export function Hero({ signupCount }: HeroProps) { return; } + if (!csrfToken) { + toast.error("Security error", { + description: "Please refresh the page and try again.", + }); + return; + } + setIsSubmitting(true); try { @@ -35,7 +60,9 @@ export function Hero({ signupCount }: HeroProps) { method: "POST", headers: { "Content-Type": "application/json", + "X-CSRF-Token": csrfToken, }, + credentials: "include", body: JSON.stringify({ email: email.trim() }), }); @@ -46,11 +73,18 @@ export function Hero({ signupCount }: HeroProps) { 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.", + description: (data as { error: string }).error || "Something went wrong. Please try again.", }); } } catch (error) { @@ -64,13 +98,7 @@ export function Hero({ signupCount }: HeroProps) { return (
- landing-page.bg + landing-page.bg - A simple but powerful video editor that gets the job done. Works on - any platform. + A simple but powerful video editor that gets the job done. Works on any platform. - -
+ +
setEmail(e.target.value)} - disabled={isSubmitting} + disabled={isSubmitting || !csrfToken} required />
-
- - {signupCount > 0 && ( - -
- {signupCount.toLocaleString()} people already joined - - )}
); diff --git a/apps/web/src/components/ui/drag-overlay.tsx b/apps/web/src/components/ui/drag-overlay.tsx deleted file mode 100644 index ba6fe08f..00000000 --- a/apps/web/src/components/ui/drag-overlay.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Upload } from "lucide-react"; - -interface DragOverlayProps { - isVisible: boolean; - title?: string; - description?: string; -} - -export function DragOverlay({ - isVisible, - title = "Drop files here", - description = "Images, videos, and audio files", -}: DragOverlayProps) { - if (!isVisible) return null; - - return ( -
-
- -

{title}

-

{description}

-
-
- ); -} diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index 16518dde..c1f81e16 100644 --- a/apps/web/src/components/ui/draggable-item.tsx +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -6,12 +6,14 @@ import { ReactNode, useState, useRef, useEffect } from "react"; import { createPortal } from "react-dom"; import { Plus } from "lucide-react"; import { cn } from "@/lib/utils"; +import { usePlaybackStore } from "@/stores/playback-store"; export interface DraggableMediaItemProps { name: string; preview: ReactNode; dragData: Record; onDragStart?: (e: React.DragEvent) => void; + onAddToTimeline?: (currentTime: number) => void; aspectRatio?: number; className?: string; showPlusOnDrag?: boolean; @@ -24,6 +26,7 @@ export function DraggableMediaItem({ preview, dragData, onDragStart, + onAddToTimeline, aspectRatio = 16 / 9, className = "", showPlusOnDrag = true, @@ -33,6 +36,11 @@ export function DraggableMediaItem({ const [isDragging, setIsDragging] = useState(false); const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); const dragRef = useRef(null); + const currentTime = usePlaybackStore((state) => state.currentTime); + + const handleAddToTimeline = () => { + onAddToTimeline?.(currentTime); + }; const emptyImg = new window.Image(); emptyImg.src = @@ -92,7 +100,10 @@ export function DraggableMediaItem({ > {preview} {!isDragging && ( - + )} {showLabel && ( @@ -128,7 +139,7 @@ export function DraggableMediaItem({
{preview}
- {showPlusOnDrag && } + {showPlusOnDrag && }
, @@ -138,11 +149,16 @@ export function DraggableMediaItem({ ); } -function PlusButton({ className }: { className?: string }) { +function PlusButton({ className, onClick }: { className?: string; onClick?: () => void }) { return ( diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 0da023e6..4971e262 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -20,7 +20,7 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 overflow-hidden rounded-md bg-foreground/10 px-3 py-1.5 text-xs text-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 overflow-hidden rounded-md bg-foreground px-3 py-1.5 text-xs text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} {...props} diff --git a/apps/web/src/constants/timeline-constants.ts b/apps/web/src/constants/timeline-constants.ts index 92045c5e..8c76a2e8 100644 --- a/apps/web/src/constants/timeline-constants.ts +++ b/apps/web/src/constants/timeline-constants.ts @@ -74,6 +74,7 @@ export const TIMELINE_CONSTANTS = { PIXELS_PER_SECOND: 50, TRACK_HEIGHT: 60, // Default fallback DEFAULT_TEXT_DURATION: 5, + DEFAULT_IMAGE_DURATION: 5, ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4], } as const; diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts index 92e78e74..ac63a64c 100644 --- a/apps/web/src/lib/rate-limit.ts +++ b/apps/web/src/lib/rate-limit.ts @@ -12,4 +12,5 @@ export const waitlistRateLimit = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(5, "1 m"), // 5 requests per minute analytics: true, + prefix: "waitlist-rate-limit", }); diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index f6b9bfd8..9a2c7361 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -5,15 +5,22 @@ import { CreateTimelineElement, TimelineTrack, TextElement, + DragData, sortTracksByOrder, ensureMainTrack, validateElementTrackCompatibility, } from "@/types/timeline"; import { useEditorStore } from "./editor-store"; -import { useMediaStore, getMediaAspectRatio } from "./media-store"; +import { + useMediaStore, + getMediaAspectRatio, + type MediaItem, +} from "./media-store"; import { storageService } from "@/lib/storage/storage-service"; import { useProjectStore } from "./project-store"; import { generateUUID } from "@/lib/utils"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { toast } from "sonner"; // Helper function to manage element naming with suffixes const getElementNameWithSuffix = ( @@ -166,6 +173,17 @@ interface TimelineStore { > > ) => void; + checkElementOverlap: ( + trackId: string, + startTime: number, + duration: number, + excludeElementId?: string + ) => boolean; + findOrCreateTrack: (trackType: TrackType) => string; + addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean; + addTextAtTime: (item: TextElement, currentTime?: number) => boolean; + addMediaToNewTrack: (item: MediaItem) => boolean; + addTextToNewTrack: (item: TextElement | DragData) => boolean; } export const useTimelineStore = create((set, get) => { @@ -963,5 +981,149 @@ export const useTimelineStore = create((set, get) => { toggleSnapping: () => { set((state) => ({ snappingEnabled: !state.snappingEnabled })); }, + + checkElementOverlap: (trackId, startTime, duration, excludeElementId) => { + const track = get()._tracks.find((t) => t.id === trackId); + if (!track) return false; + + const overlap = track.elements.some((element) => { + const elementEnd = + element.startTime + + element.duration - + element.trimStart - + element.trimEnd; + + if (element.id === excludeElementId) { + return false; + } + + return ( + (startTime >= element.startTime && startTime < elementEnd) || + (startTime + duration > element.startTime && + startTime + duration <= elementEnd) || + (startTime < element.startTime && startTime + duration > elementEnd) + ); + }); + return overlap; + }, + + findOrCreateTrack: (trackType) => { + // Always create new text track to allow multiple text elements + if (trackType === "text") { + return get().addTrack(trackType); + } + + const existingTrack = get()._tracks.find((t) => t.type === trackType); + if (existingTrack) { + return existingTrack.id; + } + + return get().addTrack(trackType); + }, + + addMediaAtTime: (item, currentTime = 0) => { + const trackType = item.type === "audio" ? "audio" : "media"; + const targetTrackId = get().findOrCreateTrack(trackType); + + const duration = + item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION; + + if (get().checkElementOverlap(targetTrackId, currentTime, duration)) { + toast.error( + "Cannot place element here - it would overlap with existing elements" + ); + return false; + } + + get().addElementToTrack(targetTrackId, { + type: "media", + mediaId: item.id, + name: item.name, + duration, + startTime: currentTime, + trimStart: 0, + trimEnd: 0, + }); + return true; + }, + + addTextAtTime: (item, currentTime = 0) => { + const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements + + get().addElementToTrack(targetTrackId, { + type: "text", + name: item.name || "Text", + content: item.content || "Default Text", + duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: currentTime, + trimStart: 0, + trimEnd: 0, + fontSize: item.fontSize || 48, + fontFamily: item.fontFamily || "Arial", + color: item.color || "#ffffff", + backgroundColor: item.backgroundColor || "transparent", + textAlign: item.textAlign || "center", + fontWeight: item.fontWeight || "normal", + fontStyle: item.fontStyle || "normal", + textDecoration: item.textDecoration || "none", + x: item.x || 0, + y: item.y || 0, + rotation: item.rotation || 0, + opacity: item.opacity !== undefined ? item.opacity : 1, + }); + return true; + }, + + addMediaToNewTrack: (item) => { + const trackType = item.type === "audio" ? "audio" : "media"; + const targetTrackId = get().findOrCreateTrack(trackType); + + get().addElementToTrack(targetTrackId, { + type: "media", + mediaId: item.id, + name: item.name, + duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, + }); + return true; + }, + + addTextToNewTrack: (item) => { + const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements + + get().addElementToTrack(targetTrackId, { + type: "text", + name: item.name || "Text", + content: + ("content" in item ? item.content : "Default Text") || "Default Text", + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, + fontSize: ("fontSize" in item ? item.fontSize : 48) || 48, + fontFamily: + ("fontFamily" in item ? item.fontFamily : "Arial") || "Arial", + color: ("color" in item ? item.color : "#ffffff") || "#ffffff", + backgroundColor: + ("backgroundColor" in item ? item.backgroundColor : "transparent") || + "transparent", + textAlign: + ("textAlign" in item ? item.textAlign : "center") || "center", + fontWeight: + ("fontWeight" in item ? item.fontWeight : "normal") || "normal", + fontStyle: + ("fontStyle" in item ? item.fontStyle : "normal") || "normal", + textDecoration: + ("textDecoration" in item ? item.textDecoration : "none") || "none", + x: ("x" in item ? item.x : 0) || 0, + y: ("y" in item ? item.y : 0) || 0, + rotation: ("rotation" in item ? item.rotation : 0) || 0, + opacity: + "opacity" in item && item.opacity !== undefined ? item.opacity : 1, + }); + return true; + }, }; }); diff --git a/bun.lock b/bun.lock index 0e22ec75..7102ca46 100644 --- a/bun.lock +++ b/bun.lock @@ -28,6 +28,7 @@ "@upstash/redis": "^1.35.0", "@vercel/analytics": "^1.4.1", "better-auth": "^1.2.7", + "botid": "^1.4.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", @@ -507,6 +508,8 @@ "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + "botid": ["botid@1.4.2", "", { "peerDependencies": { "next": "*", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["next"] }, "sha512-yiRWEdxXa5QhxzJW4lTk0lRZkbqPsVWdGrhnHLLihZf0xBEtsTUGtxLqK++IY80FX/Ye/rNMnGqBp2pl4yYU8w=="], + "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],