Merge branch 'staging' into pr/vishesh711/284
This commit is contained in:
commit
49d467cd83
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<Set<string>>(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 (
|
||||
<EditorProvider>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
3
bun.lock
3
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=="],
|
||||
|
|
|
|||
Loading…
Reference in New Issue