summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/web/next.config.ts3
-rw-r--r--apps/web/package.json1
-rw-r--r--apps/web/src/app/api/waitlist/route.ts68
-rw-r--r--apps/web/src/app/api/waitlist/token/route.ts46
-rw-r--r--apps/web/src/app/contributors/page.tsx2
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx39
-rw-r--r--apps/web/src/app/layout.tsx11
-rw-r--r--apps/web/src/app/page.tsx8
-rw-r--r--apps/web/src/app/projects/page.tsx34
-rw-r--r--apps/web/src/components/editor/media-panel/drag-overlay.tsx57
-rw-r--r--apps/web/src/components/editor/media-panel/views/media.tsx71
-rw-r--r--apps/web/src/components/editor/media-panel/views/text.tsx37
-rw-r--r--apps/web/src/components/editor/timeline-element.tsx95
-rw-r--r--apps/web/src/components/editor/timeline.tsx58
-rw-r--r--apps/web/src/components/landing/hero.tsx100
-rw-r--r--apps/web/src/components/ui/drag-overlay.tsx25
-rw-r--r--apps/web/src/components/ui/draggable-item.tsx22
-rw-r--r--apps/web/src/components/ui/tooltip.tsx2
-rw-r--r--apps/web/src/constants/timeline-constants.ts1
-rw-r--r--apps/web/src/lib/rate-limit.ts1
-rw-r--r--apps/web/src/stores/timeline-store.ts164
-rw-r--r--apps/web/tsconfig.json5
22 files changed, 593 insertions, 257 deletions
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<boolean> {
+ 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<Contributor[]> {
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 f48d032a..d9d8a08c 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,
@@ -34,26 +34,39 @@ 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<Set<string>>(new Set());
useDisableBrowserZoom();
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 (
<EditorProvider>
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 (
<html lang="en" suppressHydrationWarning>
+ <head>
+ <BotIdClient protect={protectedRoutes} />
+ </head>
<body className={`${defaultFont.className} font-sans antialiased`}>
<ThemeProvider attribute="class" forcedTheme="dark">
<TooltipProvider>
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 (
<div>
<Header />
- <Hero signupCount={signupCount} />
+ <Hero />
<Footer />
</div>
);
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx
index 94defe1e..499b999a 100644
--- a/apps/web/src/app/projects/page.tsx
+++ b/apps/web/src/app/projects/page.tsx
@@ -173,28 +173,24 @@ export default function ProjectsPage() {
</div>
{isSelectionMode && savedProjects.length > 0 && (
- <div className="mb-6 p-4 bg-muted/30 rounded-lg border">
+ <div
+ className="mb-6 p-4 bg-muted/30 rounded-lg border cursor-pointer hover:bg-muted/40 transition-colors"
+ onClick={() => handleSelectAll(!allSelected)}
+ >
<div className="flex items-center gap-3">
<Checkbox
checked={allSelected}
- ref={(el) => {
- if (el) {
- const checkboxElement = el.querySelector(
- "input"
- ) as HTMLInputElement;
- if (checkboxElement) {
- checkboxElement.indeterminate = someSelected;
- }
- }
- }}
onCheckedChange={handleSelectAll}
+ onClick={(e) => e.stopPropagation()}
/>
- <span className="text-sm font-medium">
- {allSelected ? "Deselect All" : "Select All"}
- </span>
- <span className="text-sm text-muted-foreground">
- ({selectedProjects.size} of {savedProjects.length} selected)
- </span>
+ <div className="flex-1 flex items-center justify-start gap-2">
+ <span className="text-sm font-medium">
+ {allSelected ? "Deselect All" : "Select All"}
+ </span>
+ <span className="text-sm text-muted-foreground">
+ ({selectedProjects.size} of {savedProjects.length} selected)
+ </span>
+ </div>
</div>
</div>
)}
@@ -296,7 +292,7 @@ function ProjectCard({
{/* Selection checkbox */}
{isSelectionMode && (
<div className="absolute top-3 left-3 z-10">
- <div className="w-5 h-5 rounded bg-background/80 backdrop-blur-sm border flex items-center justify-center">
+ <div className="w-5 h-5 rounded-full bg-background/80 backdrop-blur-sm border flex items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
@@ -326,7 +322,7 @@ function ProjectCard({
</div>
</div>
- <CardContent className="px-0 pt-5 flex flex-col gap-1">
+ <CardContent className="px-0 pt-5 flex flex-col gap-1 p-2">
<div className="flex items-start justify-between">
<h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
{project.name}
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 (
+ <div
+ className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8"
+ onClick={handleClick}
+ >
+ <div className="flex items-center justify-center">
+ <Upload className="h-10 w-10 text-foreground" />
+ </div>
+
+ <div className="space-y-2">
+ <p className="text-xs text-muted-foreground max-w-sm">
+ {isProcessing
+ ? `Processing your files (${progress}%)`
+ : "Drag and drop videos, photos, and audio files here"}
+ </p>
+ </div>
+
+ {isProcessing && (
+ <div className="w-full max-w-xs">
+ <div className="w-full bg-muted/50 rounded-full h-2">
+ <div
+ className="bg-primary h-2 rounded-full transition-all duration-300"
+ style={{ width: `${progress}%` }}
+ />
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
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 */}
- <DragOverlay isVisible={isDragOver} />
-
<div className="p-3 pb-2">
{/* Search and filter controls */}
<div className="flex gap-2">
@@ -227,47 +225,31 @@ export function MediaView() {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
+ <Button
+ variant="outline"
+ size="lg"
+ onClick={handleFileSelect}
+ disabled={isProcessing}
+ className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
+ >
+ {isProcessing ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <Plus className="h-4 w-4" />
+ )}
+ </Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 pt-0">
- {/* Show message if no media, otherwise show media grid */}
- {filteredMediaItems.length === 0 ? (
- <div className="flex flex-col items-center justify-center py-8 text-center h-full">
- <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
- <Image className="h-8 w-8 text-muted-foreground" />
- </div>
- <p className="text-sm text-muted-foreground">
- No media in project
- </p>
- <p className="text-xs text-muted-foreground/70 mt-1">
- Drag files here or use the button below
- </p>
- <Button
- variant="outline"
- size="sm"
- onClick={handleFileSelect}
- disabled={isProcessing}
- className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center mt-2"
- >
- {isProcessing ? (
- <>
- <Upload className="h-4 w-4 animate-spin" />
- <span className="hidden md:inline ml-2">{progress}%</span>
- </>
- ) : (
- <>
- <Plus className="h-4 w-4" />
- <span
- className="hidden sm:inline ml-2"
- aria-label="Add file"
- >
- Add
- </span>
- </>
- )}
- </Button>
- </div>
+ {isDragOver || filteredMediaItems.length === 0 ? (
+ <MediaDragOverlay
+ isVisible={true}
+ isProcessing={isProcessing}
+ progress={progress}
+ onClick={handleFileSelect}
+ isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
+ />
) : (
<div
className="grid gap-2"
@@ -288,6 +270,11 @@ export function MediaView() {
name: item.name,
}}
showPlusOnDrag={false}
+ onAddToTimeline={(currentTime) =>
+ useTimelineStore
+ .getState()
+ .addMediaAtTime(item, currentTime)
+ }
rounded={false}
/>
</ContextMenuTrigger>
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() {
</div>
}
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}
/>
</div>
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 (
<div className="w-full h-full flex items-center justify-center">
- <div className="bg-[#004D52] py-3 w-full h-full">
- <img
- src={mediaItem.url}
- alt={mediaItem.name}
- className="w-full h-full object-cover"
- draggable={false}
+ <div className="bg-[#004D52] py-3 w-full h-full relative">
+ {/* Background with tiled images */}
+ <div
+ className="absolute top-3 bottom-3 left-0 right-0"
+ style={{
+ backgroundImage: mediaItem.url
+ ? `url(${mediaItem.url})`
+ : "none",
+ backgroundRepeat: "repeat-x",
+ backgroundSize: `${tileWidth}px ${tileHeight}px`,
+ backgroundPosition: "left center",
+ pointerEvents: "none",
+ }}
+ aria-label={`Tiled background of ${mediaItem.name}`}
+ />
+ {/* Overlay with vertical borders */}
+ <div
+ className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none"
+ style={{
+ backgroundImage: `repeating-linear-gradient(
+ to right,
+ transparent 0px,
+ transparent ${tileWidth - 1}px,
+ rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
+ rgba(255, 255, 255, 0.6) ${tileWidth}px
+ )`,
+ backgroundPosition: "left center",
+ }}
/>
</div>
</div>
);
}
+ 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 (
<div className="w-full h-full flex items-center gap-2">
- <div className="w-8 h-8 flex-shrink-0">
- <img
- src={mediaItem.thumbnailUrl}
- alt={mediaItem.name}
- className="w-full h-full object-cover rounded-sm"
- draggable={false}
+ <div className="flex-1 h-full relative overflow-hidden">
+ {/* Background with tiled thumbnails */}
+ <div
+ className="absolute inset-0"
+ style={{
+ backgroundImage: mediaItem.thumbnailUrl
+ ? `url(${mediaItem.thumbnailUrl})`
+ : "none",
+ backgroundRepeat: "repeat-x",
+ backgroundSize: `${tileWidth}px ${tileHeight}px`,
+ backgroundPosition: "left center",
+ pointerEvents: "none",
+ }}
+ aria-label={`Tiled thumbnail of ${mediaItem.name}`}
+ />
+ {/* Overlay with vertical borders */}
+ <div
+ className="absolute inset-0 pointer-events-none"
+ style={{
+ backgroundImage: `repeating-linear-gradient(
+ to right,
+ transparent 0px,
+ transparent ${tileWidth - 1}px,
+ rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
+ rgba(255, 255, 255, 0.6) ${tileWidth}px
+ )`,
+ backgroundPosition: "left center",
+ }}
/>
</div>
- <span className="text-xs text-foreground/80 truncate flex-1">
- {element.name}
- </span>
+ {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? (
+ <div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate">
+ {element.name}
+ </div>
+ ) : (
+ <span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]">
+ {element.name}
+ </span>
+ )}
</div>
);
}
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index 7b7455e0..c6e6a331 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -18,6 +18,7 @@ import {
Lock,
ZoomIn,
ZoomOut,
+ LockOpen,
} from "lucide-react";
import {
Tooltip,
@@ -372,29 +373,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) => item.id === dragData.id);
@@ -403,19 +382,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);
@@ -443,18 +410,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) {
@@ -867,7 +823,11 @@ export function Timeline() {
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={toggleSnapping}>
- <Lock className="h-4 w-4" />
+ {snappingEnabled ? (
+ <Lock className="h-4 w-4" />
+ ) : (
+ <LockOpen className="h-4 w-4 text-primary" />
+ )}
</Button>
</TooltipTrigger>
<TooltipContent>Auto snapping</TooltipContent>
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<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.",
+ });
+ }
+ });
+ }, []);
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 (
<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"
- />
+ <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 }}
@@ -93,20 +121,11 @@ export function Hero({ signupCount }: HeroProps) {
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.
+ 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"
- >
+ <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"
@@ -114,35 +133,16 @@ export function Hero({ signupCount }: HeroProps) {
className="h-11 text-base flex-1"
value={email}
onChange={(e) => setEmail(e.target.value)}
- disabled={isSubmitting}
+ disabled={isSubmitting || !csrfToken}
required
/>
</div>
- <Button
- type="submit"
- size="lg"
- className="px-6 h-11 text-base !bg-foreground"
- disabled={isSubmitting}
- >
- <span className="relative z-10">
- {isSubmitting ? "Joining..." : "Join waitlist"}
- </span>
+ <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>
-
- {signupCount > 0 && (
- <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>{signupCount.toLocaleString()} people already joined</span>
- </motion.div>
- )}
</motion.div>
</div>
);
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 (
- <div className="absolute inset-0 bg-accent/20 backdrop-blur-lg border-2 border-dashed border-accent flex items-center justify-center z-10 pointer-events-none">
- <div className="text-center">
- <Upload className="h-8 w-8 text-accent mx-auto mb-2" />
- <p className="text-sm font-medium text-accent">{title}</p>
- <p className="text-xs text-muted-foreground">{description}</p>
- </div>
- </div>
- );
-}
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<string, any>;
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<HTMLDivElement>(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 && (
- <PlusButton className="opacity-0 group-hover:opacity-100" />
+ <PlusButton
+ className="opacity-0 group-hover:opacity-100"
+ onClick={handleAddToTimeline}
+ />
)}
</AspectRatio>
{showLabel && (
@@ -128,7 +139,7 @@ export function DraggableMediaItem({
<div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none">
{preview}
</div>
- {showPlusOnDrag && <PlusButton />}
+ {showPlusOnDrag && <PlusButton onClick={handleAddToTimeline} tooltipText="Add to timeline or drag to position" />}
</AspectRatio>
</div>
</div>,
@@ -138,11 +149,16 @@ export function DraggableMediaItem({
);
}
-function PlusButton({ className }: { className?: string }) {
+function PlusButton({ className, onClick }: { className?: string; onClick?: () => void }) {
return (
<Button
size="icon"
className={cn("absolute bottom-2 right-2 size-4", className)}
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onClick?.();
+ }}
>
<Plus className="!size-3" />
</Button>
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 f012db7b..05e44e2f 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],
ZOOM_LEVEL_MIN: 0.1,
ZOOM_LEVEL_MAX: 10,
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<TimelineStore>((set, get) => {
@@ -963,5 +981,149 @@ export const useTimelineStore = create<TimelineStore>((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/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 810fcfe2..e0bb39d0 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -12,7 +12,7 @@
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
- "moduleResolution": "bundler",
+ "moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
@@ -26,7 +26,8 @@
"@/*": [
"./src/*"
]
- }
+ },
+ "forceConsistentCasingInFileNames": true
},
"include": [
"**/*.ts",